Merge remote-tracking branch 'origin/main' into dev/winston/fix-29752

# Conflicts:
#	codex-rs/config/src/permissions_toml.rs
This commit is contained in:
Winston Howes
2026-07-09 22:33:55 -07:00
577 changed files with 21886 additions and 7575 deletions
+3
View File
@@ -1,5 +1,8 @@
# Core crate ownership.
/codex-rs/arg0/ @openai/codex-core-agent-team
/codex-rs/codex-mcp/ @openai/codex-core-agent-team
/codex-rs/core/ @openai/codex-core-agent-team
/codex-rs/exec-server/ @openai/codex-core-agent-team
/codex-rs/exec-server-protocol/ @openai/codex-core-agent-team
/codex-rs/ext/extension-api/ @openai/codex-core-agent-team
/codex-rs/prompts/ @openai/codex-core-agent-team
+6 -20
View File
@@ -12,7 +12,8 @@ outputs:
runs:
using: composite
steps:
- uses: ./.github/actions/setup-ci
- id: setup_ci
uses: ./.github/actions/setup-ci
- name: Set up Bazel
uses: bazel-contrib/setup-bazel@c5acdfb288317d0b5c0bbd7a396a3dc868bb0f86 # 0.19.0
@@ -20,30 +21,15 @@ runs:
# and Windows can use the runner's standalone Bazel, ignoring .bazelversion.
with:
bazelisk-version: 1.28.1
# setup-bazel writes an explicit output_base, which otherwise overrides
# BAZEL_OUTPUT_USER_ROOT and leaves Bazel's I/O-heavy trees on C:.
output-base: ${{ steps.setup_ci.outputs.bazel-output-base }}
- name: Configure Bazel repository cache
id: configure_bazel_repository_cache
shell: pwsh
run: |
# Keep the repository cache under HOME on all runners. Windows `D:\a`
# cache paths match `.bazelrc`, but `actions/cache/restore` currently
# returns HTTP 400 for that path in the Windows clippy job.
$repositoryCachePath = Join-Path $HOME '.cache/bazel-repo-cache'
"repository-cache-path=$repositoryCachePath" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
"BAZEL_REPOSITORY_CACHE=$repositoryCachePath" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
- name: Configure Bazel output root (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
# Use the shortest available drive to reduce argv/path length issues,
# but avoid the drive root because some Windows test launchers mis-handle
# MANIFEST paths there.
$hasDDrive = Test-Path 'D:\'
$bazelOutputUserRoot = if ($hasDDrive) { 'D:\b' } else { 'C:\b' }
$repoContentsCache = Join-Path $env:RUNNER_TEMP "bazel-repo-contents-cache-$env:GITHUB_RUN_ID-$env:GITHUB_JOB"
"BAZEL_OUTPUT_USER_ROOT=$bazelOutputUserRoot" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
"BAZEL_REPO_CONTENTS_CACHE=$repoContentsCache" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
"repository-cache-path=$env:BAZEL_REPOSITORY_CACHE" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
- name: Expose MSVC SDK environment (Windows)
if: runner.os == 'Windows'
+58
View File
@@ -1,9 +1,67 @@
name: setup-ci
description: Prepare common tools and environment shared by CI jobs.
outputs:
bazel-output-base:
description: Filesystem path used for Bazel's output base.
value: ${{ steps.configure_ci_build_paths.outputs.bazel-output-base }}
cargo-target-dir:
description: Filesystem path used for Cargo's target directory.
value: ${{ steps.configure_ci_build_paths.outputs.cargo-target-dir }}
runs:
using: composite
steps:
# setup-ci expects either this step or the Unix fallback below to define
# CI_BUILD_ROOT. Windows puts it on a Dev Drive because Cargo and Bazel
# spend significant time reading and writing build/cache trees.
- name: Configure Dev Drive (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: ./.github/scripts/setup-dev-drive.ps1
- name: Configure CI build root (Unix)
if: runner.os != 'Windows'
shell: bash
run: echo "CI_BUILD_ROOT=$HOME/.cache/codex-ci" >> "$GITHUB_ENV"
- name: Configure CI build paths
id: configure_ci_build_paths
shell: bash
run: |
set -euo pipefail
# setup-bazel passes output_base explicitly, so keep both it and the
# user root under the shared build root. Keep these directory names tiny
# on every platform so Windows Bazel paths do not overflow argv or
# confuse test MANIFEST handling.
bazel_output_base="$CI_BUILD_ROOT/o"
bazel_output_user_root="$CI_BUILD_ROOT/b"
bazel_repository_cache="$CI_BUILD_ROOT/bazel-repository-cache"
bazel_repo_contents_cache="$CI_BUILD_ROOT/bazel-repo-contents-cache-$GITHUB_RUN_ID-$GITHUB_JOB"
cargo_target_dir="$CI_BUILD_ROOT/cargo-target"
tmp="$CI_BUILD_ROOT/tmp"
build_dirs=(
"$bazel_output_base"
"$bazel_output_user_root"
"$bazel_repository_cache"
"$bazel_repo_contents_cache"
"$cargo_target_dir"
"$tmp"
)
mkdir -p "${build_dirs[@]}"
echo "bazel-output-base=$bazel_output_base" >> "$GITHUB_OUTPUT"
echo "cargo-target-dir=$cargo_target_dir" >> "$GITHUB_OUTPUT"
{
echo "BAZEL_OUTPUT_BASE=$bazel_output_base"
echo "BAZEL_OUTPUT_USER_ROOT=$bazel_output_user_root"
echo "BAZEL_REPOSITORY_CACHE=$bazel_repository_cache"
echo "BAZEL_REPO_CONTENTS_CACHE=$bazel_repo_contents_cache"
echo "CARGO_TARGET_DIR=$cargo_target_dir"
echo "TEMP=$tmp"
echo "TMP=$tmp"
} >> "$GITHUB_ENV"
- name: Prefer the Git CLI for Cargo git dependencies
shell: bash
run: echo "CARGO_NET_GIT_FETCH_WITH_CLI=true" >> "$GITHUB_ENV"
+1 -1
View File
@@ -48,7 +48,7 @@ runs:
{
echo "files<<EOF"
for binary in ${BINARIES}; do
echo "${GITHUB_WORKSPACE}/codex-rs/target/${TARGET}/release/${binary}.exe"
echo "${CARGO_TARGET_DIR}/${TARGET}/release/${binary}.exe"
done
echo "EOF"
} >> "$GITHUB_OUTPUT"
+44 -15
View File
@@ -131,25 +131,54 @@ def bazel_args_without_remote_execution(args: Sequence[str]) -> list[str]:
def bazel_args_with_remote_config(
args: Sequence[str], env: Mapping[str, str]
) -> list[str]:
command_idx = next(
(idx for idx, arg in enumerate(args) if not arg.startswith("-")),
None,
)
if command_idx is None:
raise ValueError("expected a Bazel command")
config = remote_config(args, env)
if config is None:
return bazel_args_without_remote_execution(args)
configured_args = bazel_args_without_remote_execution(args)
else:
# `remote_config()` returns a configuration only when this key is present.
api_key = env["BUILDBUDDY_API_KEY"]
remote_args = [
f"--config={config}",
f"--remote_header=x-buildbuddy-api-key={api_key}",
]
# `remote_config()` returns a configuration only when this key is present.
api_key = env["BUILDBUDDY_API_KEY"]
remote_args = [
f"--config={config}",
f"--remote_header=x-buildbuddy-api-key={api_key}",
# Insert immediately after the Bazel command. This keeps wrapper-added
# options out of positional payloads and lets later CI configs override
# shared RBE defaults such as the Windows cross-compilation exec platforms.
configured_args = [
*args[: command_idx + 1],
*remote_args,
*args[command_idx + 1 :],
]
try:
separator_idx = configured_args.index("--")
except ValueError:
separator_idx = len(configured_args)
cache_args = [
f"{option_prefix}{env[env_name]}"
for env_name, option_prefix in (
("BAZEL_REPO_CONTENTS_CACHE", "--repo_contents_cache="),
("BAZEL_REPOSITORY_CACHE", "--repository_cache="),
)
if env.get(env_name)
and not any(
arg.startswith(option_prefix) for arg in configured_args[:separator_idx]
)
]
return [
*configured_args[:separator_idx],
*cache_args,
*configured_args[separator_idx:],
]
# Insert immediately after the Bazel command. This keeps wrapper-added
# options out of positional payloads and lets later CI configs override
# shared RBE defaults such as the Windows cross-compilation exec platforms.
insertion_idx = next(
(idx + 1 for idx, arg in enumerate(args) if not arg.startswith("-")),
len(args),
)
return [*args[:insertion_idx], *remote_args, *args[insertion_idx:]]
def bazel_command(*args: str, env: Mapping[str, str] | None = None) -> list[str]:
+12 -6
View File
@@ -258,21 +258,27 @@ def stage_artifacts(
print(staged_checksums)
def upstream_release_pair_paths(source_root: Path, target: str) -> tuple[Path, Path]:
def upstream_release_pair_paths(
target: str,
target_dir: Path,
) -> tuple[Path, Path]:
lib_name = (
"rusty_v8.lib" if target.endswith("-pc-windows-msvc") else "librusty_v8.a"
)
gn_out = source_root / "target" / target / "release" / "gn_out"
gn_out = target_dir / target / "release" / "gn_out"
return gn_out / "obj" / lib_name, gn_out / "src_binding.rs"
def stage_upstream_release_pair(
source_root: Path,
target: str,
output_dir: Path,
target_dir: Path,
sandbox: bool = False,
) -> None:
lib_path, binding_path = upstream_release_pair_paths(source_root, target)
lib_path, binding_path = upstream_release_pair_paths(
target,
target_dir,
)
stage_artifacts(target, lib_path, binding_path, output_dir, sandbox)
@@ -330,7 +336,7 @@ def parse_args() -> argparse.Namespace:
"stage-upstream-release-pair"
)
stage_upstream_release_pair_parser.add_argument(
"--source-root", type=Path, required=True
"--target-dir", type=Path, required=True
)
stage_upstream_release_pair_parser.add_argument("--target", required=True)
stage_upstream_release_pair_parser.add_argument("--output-dir", required=True)
@@ -373,9 +379,9 @@ def main() -> int:
return 0
if args.command == "stage-upstream-release-pair":
stage_upstream_release_pair(
source_root=args.source_root,
target=args.target,
output_dir=Path(args.output_dir),
target_dir=args.target_dir,
sandbox=args.sandbox,
)
return 0
+19 -18
View File
@@ -1,14 +1,15 @@
# Configure a fast drive for Windows CI jobs.
#
# GitHub-hosted Windows runners do not always expose a secondary D: volume. When
# they do not, try to create a Dev Drive VHD and fall back to C: if the runner
# image does not allow that provisioning path.
# they do not, create a Dev Drive VHD. CI depends on this path for its
# build directories where CI spends significant time doing I/O, so fail the
# job if no real Dev Drive is available.
function Use-FallbackDrive {
param([string]$Reason)
function Test-DevDrive {
param([string]$Drive)
Write-Warning "$Reason Falling back to C:"
return "C:"
& fsutil devdrv query $Drive *> $null
return $LASTEXITCODE -eq 0
}
function Invoke-BestEffort {
@@ -21,10 +22,14 @@ function Invoke-BestEffort {
}
}
if (Test-Path "D:\") {
Write-Output "Using existing drive at D:"
if ((Test-Path "D:\") -and (Test-DevDrive "D:")) {
Write-Output "Using existing Dev Drive at D:"
$Drive = "D:"
} else {
if (Test-Path "D:\") {
Write-Output "Existing D: volume is not a Dev Drive; provisioning a new Dev Drive VHD."
}
try {
$VhdPath = Join-Path $env:RUNNER_TEMP "codex-dev-drive.vhdx"
$SizeBytes = 64GB
@@ -42,21 +47,17 @@ if (Test-Path "D:\") {
$Drive = "$($Volume.DriveLetter):"
if (-not (Test-DevDrive $Drive)) {
throw "Provisioned volume at $Drive did not pass Dev Drive verification."
}
Invoke-BestEffort { fsutil devdrv trust $Drive } "Trusting Dev Drive $Drive"
Invoke-BestEffort { fsutil devdrv enable /disallowAv } "Disabling AV filter attachment for Dev Drives"
Invoke-BestEffort { fsutil devdrv query $Drive } "Querying Dev Drive $Drive"
Write-Output "Using Dev Drive at $Drive"
} catch {
$Drive = Use-FallbackDrive "Failed to create Dev Drive: $($_.Exception.Message)"
throw "Failed to create Dev Drive: $($_.Exception.Message)"
}
}
$Tmp = "$Drive\codex-tmp"
New-Item -Path $Tmp -ItemType Directory -Force | Out-Null
@(
"DEV_DRIVE=$Drive"
"TMP=$Tmp"
"TEMP=$Tmp"
) | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
"CI_BUILD_ROOT=$Drive" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
@@ -214,6 +214,48 @@ class RunBazelWithBuildBuddyTest(unittest.TestCase):
],
)
def test_bazel_command_uses_configured_local_caches(self) -> None:
env = {
"BAZEL_REPO_CONTENTS_CACHE": "/tmp/bazel-repo-contents",
"BAZEL_REPOSITORY_CACHE": "/tmp/bazel-repository",
}
self.assertEqual(
run_bazel_with_buildbuddy.bazel_command(
"build",
"--config=local",
"//codex-rs/...",
env=env,
),
[
"bazel",
"build",
"--config=local",
"//codex-rs/...",
"--repo_contents_cache=/tmp/bazel-repo-contents",
"--repository_cache=/tmp/bazel-repository",
],
)
def test_bazel_command_adds_local_caches_before_separator(self) -> None:
self.assertEqual(
run_bazel_with_buildbuddy.bazel_command(
"build",
"//codex-rs/...",
"--",
"--program-arg",
env={"BAZEL_REPOSITORY_CACHE": "/tmp/bazel-repository"},
),
[
"bazel",
"build",
"//codex-rs/...",
"--repository_cache=/tmp/bazel-repository",
"--",
"--program-arg",
],
)
def test_main_preserves_spaced_argument_and_child_exit_status(self) -> None:
spaced_arg = (
r"--test_env=PATH=C:\Program Files\PowerShell\7;C:\Program Files\Git\bin"
+11 -6
View File
@@ -205,8 +205,8 @@ class RustyV8BazelTest(unittest.TestCase):
),
),
rusty_v8_bazel.upstream_release_pair_paths(
Path("/tmp/rusty_v8"),
"x86_64-apple-darwin",
Path("/tmp/rusty_v8/target"),
),
)
self.assertEqual(
@@ -221,25 +221,30 @@ class RustyV8BazelTest(unittest.TestCase):
),
),
rusty_v8_bazel.upstream_release_pair_paths(
Path("/tmp/rusty_v8"),
"x86_64-pc-windows-msvc",
Path("/tmp/rusty_v8/target"),
),
)
def test_stage_upstream_release_pair(self) -> None:
with TemporaryDirectory() as source_dir, TemporaryDirectory() as output_dir:
source_root = Path(source_dir)
with (
TemporaryDirectory() as target_dir,
TemporaryDirectory() as output_dir,
):
gn_out = (
source_root / "target" / "x86_64-pc-windows-msvc" / "release" / "gn_out"
Path(target_dir)
/ "x86_64-pc-windows-msvc"
/ "release"
/ "gn_out"
)
(gn_out / "obj").mkdir(parents=True)
(gn_out / "obj" / "rusty_v8.lib").write_bytes(b"archive")
(gn_out / "src_binding.rs").write_text("binding")
rusty_v8_bazel.stage_upstream_release_pair(
source_root,
"x86_64-pc-windows-msvc",
Path(output_dir),
Path(target_dir),
sandbox=True,
)
+11 -7
View File
@@ -47,13 +47,17 @@ version = "149.2.0"
)
)
def test_setup_ci_change_requires_canary_and_source_build(self) -> None:
changed_files = {".github/actions/setup-ci/action.yml"}
self.assertTrue(canary_required(changed_files, "149.2.0", "149.2.0"))
self.assertTrue(
windows_source_required(changed_files, "149.2.0", "149.2.0")
)
def test_shared_ci_setup_changes_require_canary_and_source_build(self) -> None:
for path in (
".github/actions/setup-ci/action.yml",
".github/scripts/setup-dev-drive.ps1",
):
with self.subTest(path=path):
changed_files = {path}
self.assertTrue(canary_required(changed_files, "149.2.0", "149.2.0"))
self.assertTrue(
windows_source_required(changed_files, "149.2.0", "149.2.0")
)
def test_manual_dispatch_requires_source_build(self) -> None:
self.assertTrue(
+2
View File
@@ -25,6 +25,7 @@ CANARY_PATH_PATTERNS = {
".github/scripts/run_bazel_with_buildbuddy.py",
".github/scripts/rusty_v8_bazel.py",
".github/scripts/rusty_v8_module_bazel.py",
".github/scripts/setup-dev-drive.ps1",
".github/scripts/v8_canary_changes.py",
".github/workflows/postmerge-ci.yml",
".github/workflows/rusty-v8-release.yml",
@@ -44,6 +45,7 @@ WINDOWS_SOURCE_BUILD_PATHS = {
".github/actions/setup-ci/**",
".github/scripts/rusty_v8_bazel.py",
".github/scripts/rusty_v8_module_bazel.py",
".github/scripts/setup-dev-drive.ps1",
".github/scripts/v8_canary_changes.py",
".github/workflows/rusty-v8-release.yml",
".github/workflows/v8-canary.yml",
@@ -71,11 +71,6 @@ jobs:
with:
persist-credentials: false
- name: Configure Dev Drive (Windows)
if: ${{ runner.os == 'Windows' }}
shell: pwsh
run: ../.github/scripts/setup-dev-drive.ps1
- uses: ./.github/actions/setup-ci
- name: Install Linux build dependencies
@@ -136,11 +131,7 @@ jobs:
echo "Using sccache GitHub backend"
else
echo "SCCACHE_GHA_ENABLED=false" >> "$GITHUB_ENV"
if [[ -n "${DEV_DRIVE:-}" ]]; then
echo "SCCACHE_DIR=${DEV_DRIVE}\\.sccache" >> "$GITHUB_ENV"
else
echo "SCCACHE_DIR=${{ github.workspace }}/.sccache" >> "$GITHUB_ENV"
fi
echo "SCCACHE_DIR=${CI_BUILD_ROOT}/sccache" >> "$GITHUB_ENV"
echo "Using sccache local disk + actions/cache fallback"
fi
@@ -206,7 +197,7 @@ jobs:
--profile ${{ inputs.profile }} \
-p codex-linux-sandbox \
--bin codex-linux-sandbox
cp "target/${{ inputs.target }}/${{ inputs.profile }}/codex-linux-sandbox" "${helper_dir}/"
cp "${CARGO_TARGET_DIR}/${{ inputs.target }}/${{ inputs.profile }}/codex-linux-sandbox" "${helper_dir}/"
else
cargo build \
--target ${{ inputs.target }} \
@@ -214,8 +205,8 @@ jobs:
-p codex-windows-sandbox \
--bin codex-windows-sandbox-setup \
--bin codex-command-runner
cp "target/${{ inputs.target }}/${{ inputs.profile }}/codex-windows-sandbox-setup.exe" "${helper_dir}/"
cp "target/${{ inputs.target }}/${{ inputs.profile }}/codex-command-runner.exe" "${helper_dir}/"
cp "${CARGO_TARGET_DIR}/${{ inputs.target }}/${{ inputs.profile }}/codex-windows-sandbox-setup.exe" "${helper_dir}/"
cp "${CARGO_TARGET_DIR}/${{ inputs.target }}/${{ inputs.profile }}/codex-command-runner.exe" "${helper_dir}/"
fi
- name: Upload Cargo timings (nextest)
@@ -223,7 +214,7 @@ jobs:
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: cargo-timings-rust-ci-nextest-${{ inputs.target }}-${{ inputs.profile }}
path: codex-rs/target/**/cargo-timings/cargo-timing.html
path: ${{ env.CARGO_TARGET_DIR }}/**/cargo-timings/cargo-timing.html
if-no-files-found: warn
- name: Upload nextest archive
@@ -369,13 +360,13 @@ jobs:
if [[ "${RUNNER_OS}" == "Linux" ]]; then
helper_dir="${RUNNER_TEMP}/${TEST_HELPERS_ARTIFACT}"
helper_target_dir="$(pwd)/target/${{ inputs.target }}/${{ inputs.profile }}"
helper_target_dir="${CARGO_TARGET_DIR}/${{ inputs.target }}/${{ inputs.profile }}"
mkdir -p "${helper_target_dir}"
cp "${helper_dir}/codex-linux-sandbox" "${helper_target_dir}/"
chmod +x "${helper_target_dir}/codex-linux-sandbox"
elif [[ "${RUNNER_OS}" == "Windows" ]]; then
helper_dir="${RUNNER_TEMP}/${TEST_HELPERS_ARTIFACT}"
helper_target_dir="$(pwd)/target/${{ inputs.target }}/${{ inputs.profile }}"
helper_target_dir="${CARGO_TARGET_DIR}/${{ inputs.target }}/${{ inputs.profile }}"
mkdir -p "${helper_target_dir}"
cp "${helper_dir}/codex-windows-sandbox-setup.exe" "${helper_target_dir}/"
cp "${helper_dir}/codex-command-runner.exe" "${helper_target_dir}/"
@@ -423,7 +414,7 @@ jobs:
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: nextest-junit-rust-ci-${{ inputs.artifact_id }}-shard-${{ matrix.shard }}
path: codex-rs/target/nextest/default/junit.xml
path: ${{ env.CARGO_TARGET_DIR }}/nextest/default/junit.xml
if-no-files-found: warn
- name: Tear down remote test env
+1 -1
View File
@@ -403,7 +403,7 @@ jobs:
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: cargo-timings-rust-ci-clippy-${{ matrix.target }}-${{ matrix.profile }}
path: codex-rs/target/**/cargo-timings/cargo-timing.html
path: ${{ env.CARGO_TARGET_DIR }}/**/cargo-timings/cargo-timing.html
if-no-files-found: warn
# Save caches explicitly; make non-fatal so cache packaging
+11 -11
View File
@@ -110,13 +110,13 @@ jobs:
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: cargo-timings-rust-release-windows-${{ matrix.target }}-${{ matrix.bundle }}
path: codex-rs/target/**/cargo-timings/cargo-timing.html
path: ${{ env.CARGO_TARGET_DIR }}/**/cargo-timings/cargo-timing.html
if-no-files-found: warn
- name: Stage Windows binaries
shell: bash
run: |
release_dir="target/${{ matrix.target }}/release"
release_dir="$CARGO_TARGET_DIR/${{ matrix.target }}/release"
output_dir="$release_dir/staged-${{ matrix.bundle }}"
mkdir -p "$output_dir"
for binary in ${{ matrix.binaries }}; do
@@ -139,7 +139,7 @@ jobs:
with:
name: windows-binaries-${{ matrix.target }}-${{ matrix.bundle }}
path: |
codex-rs/target/${{ matrix.target }}/release/staged-${{ matrix.bundle }}/*
${{ env.CARGO_TARGET_DIR }}/${{ matrix.target }}/release/staged-${{ matrix.bundle }}/*
build-windows-symbols:
needs:
@@ -168,14 +168,14 @@ jobs:
with:
pattern: windows-binaries-${{ matrix.target }}-*
merge-multiple: true
path: codex-rs/target/${{ matrix.target }}/release
path: ${{ env.CARGO_TARGET_DIR }}/${{ matrix.target }}/release
- name: Build symbols archive
shell: bash
run: |
bash "${GITHUB_WORKSPACE}/.github/scripts/archive-release-symbols-and-strip-binaries.sh" \
--target "${{ matrix.target }}" \
--artifact-name "${{ matrix.target }}" \
--release-dir "target/${{ matrix.target }}/release" \
--release-dir "${CARGO_TARGET_DIR}/${{ matrix.target }}/release" \
--archive-dir "symbols-dist/${{ matrix.target }}" \
--binaries "${WINDOWS_BINARIES}"
- name: Upload symbols archive
@@ -226,26 +226,26 @@ jobs:
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: windows-binaries-${{ matrix.target }}-primary
path: codex-rs/target/${{ matrix.target }}/release
path: ${{ env.CARGO_TARGET_DIR }}/${{ matrix.target }}/release
- name: Download prebuilt Windows helper binaries
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: windows-binaries-${{ matrix.target }}-helpers
path: codex-rs/target/${{ matrix.target }}/release
path: ${{ env.CARGO_TARGET_DIR }}/${{ matrix.target }}/release
- name: Download prebuilt Windows app-server binary
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: windows-binaries-${{ matrix.target }}-app-server
path: codex-rs/target/${{ matrix.target }}/release
path: ${{ env.CARGO_TARGET_DIR }}/${{ matrix.target }}/release
- name: Verify binaries
shell: bash
run: |
set -euo pipefail
for binary in ${WINDOWS_BINARIES}; do
ls -lh "target/${{ matrix.target }}/release/${binary}.exe"
ls -lh "$CARGO_TARGET_DIR/${{ matrix.target }}/release/${binary}.exe"
done
- name: Sign Windows binaries with Azure Trusted Signing
@@ -267,7 +267,7 @@ jobs:
mkdir -p "$dest"
for binary in ${WINDOWS_BINARIES}; do
cp "target/${{ matrix.target }}/release/${binary}.exe" \
cp "$CARGO_TARGET_DIR/${{ matrix.target }}/release/${binary}.exe" \
"$dest/${binary}-${{ matrix.target }}.exe"
done
@@ -291,7 +291,7 @@ jobs:
bash "$archive_script" \
--target "$target" \
--bundle "{}" \
--entrypoint-dir "target/$target/release" \
--entrypoint-dir "$CARGO_TARGET_DIR/$target/release" \
--archive-dir "dist/$target"
- name: Build Python runtime wheel
+1 -1
View File
@@ -392,7 +392,7 @@ jobs:
run: |
set -euo pipefail
python3 .github/scripts/rusty_v8_bazel.py stage-upstream-release-pair \
--source-root upstream-rusty-v8 \
--target-dir upstream-rusty-v8/target \
--target "${TARGET}" \
--output-dir "dist/${TARGET}" \
--sandbox
+1 -2
View File
@@ -160,8 +160,7 @@ jobs:
continue-on-error: true
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: |
~/.cache/bazel-repo-cache
path: ${{ steps.setup_bazel.outputs.repository-cache-path }}
key: bazel-cache-x86_64-unknown-linux-gnu-${{ hashFiles('MODULE.bazel', 'codex-rs/Cargo.lock', 'codex-rs/Cargo.toml') }}
- name: Check for a clean worktree
+10 -9
View File
@@ -312,15 +312,16 @@ jobs:
fail-fast: false
matrix:
include:
- runner: windows-2022
- runner: windows-2025
target: x86_64-pc-windows-msvc
- runner: windows-2022
- runner: windows-2025
target: aarch64-pc-windows-msvc
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: ./.github/actions/setup-ci
- id: setup_ci
uses: ./.github/actions/setup-ci
- name: Configure git for upstream checkout
shell: bash
@@ -364,17 +365,17 @@ jobs:
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
with:
path: |
upstream-rusty-v8/target/sccache
upstream-rusty-v8/target/${{ matrix.target }}/release/gn_out
key: rusty-v8-source-${{ matrix.target }}-sandbox-${{ hashFiles('upstream-rusty-v8/Cargo.lock', 'upstream-rusty-v8/build.rs', 'upstream-rusty-v8/git_submodule_status.txt') }}
${{ steps.setup_ci.outputs.cargo-target-dir }}/sccache
${{ steps.setup_ci.outputs.cargo-target-dir }}/${{ matrix.target }}/release/gn_out
key: rusty-v8-source-${{ matrix.target }}-${{ matrix.runner }}-sandbox-${{ hashFiles('upstream-rusty-v8/Cargo.lock', 'upstream-rusty-v8/build.rs', 'upstream-rusty-v8/git_submodule_status.txt') }}
restore-keys: |
rusty-v8-source-${{ matrix.target }}-sandbox-
rusty-v8-source-${{ matrix.target }}-${{ matrix.runner }}-sandbox-
- name: Install and start sccache
shell: pwsh
env:
SCCACHE_CACHE_SIZE: 256M
SCCACHE_DIR: ${{ github.workspace }}/upstream-rusty-v8/target/sccache
SCCACHE_DIR: ${{ steps.setup_ci.outputs.cargo-target-dir }}/sccache
SCCACHE_IDLE_TIMEOUT: 0
run: |
$version = "v0.8.2"
@@ -409,7 +410,7 @@ jobs:
run: |
set -euo pipefail
python3 .github/scripts/rusty_v8_bazel.py stage-upstream-release-pair \
--source-root upstream-rusty-v8 \
--target-dir "${CARGO_TARGET_DIR}" \
--target "${TARGET}" \
--output-dir "dist/${TARGET}" \
--sandbox
+1 -1
View File
@@ -253,7 +253,7 @@ Use `just bench-smoke` to dry-run the benchmark for a single iteration to ensure
- Tests should exercise app-server's public JSON-RPC API.
- Use similar server mocking as for core integration tests.
- Use `TestAppServer::new_with_auto_env()` and `TestAppServer::send_thread_start_request_with_auto_env()`
- Use `TestAppServer::builder().build()` and `TestAppServer::send_thread_start_request_with_auto_env()`
by default to ensure that new tests work with foreign app/exec OSes. See `$remote-tests` for
details.
+5
View File
@@ -0,0 +1,5 @@
package(default_visibility = ["//visibility:public"])
exports_files([
"e2e_benchmark.bzl",
])
+56
View File
@@ -0,0 +1,56 @@
load("@crates//:defs.bzl", "all_crate_deps")
load("@rules_rust//rust:defs.bzl", "rust_binary")
load("//:defs.bzl", "workspace_root_test")
_WORKSPACE_ROOT_MARKER = "//codex-rs/utils/cargo-bin:repo_root.marker"
def codex_e2e_benchmark(name, binaries = [], data = [], deps = []):
"""Defines a Bazel-only Divan end-to-end benchmark.
The benchmark source lives at `e2e_benches/<name>.rs`, with hyphens in
`name` replaced by underscores. `binaries` are runtime executables made
available through the same `CARGO_BIN_EXE_*` bridge used by Rust tests.
Args:
name: Stem for the generated `<name>-bench` target and benchmark.
binaries: Runtime executable labels that the benchmark spawns.
data: Additional runtime files needed by the benchmark.
deps: Additional Rust dependencies beyond the crate's Cargo deps.
"""
benchmark_name = name.replace("-", "_")
source = "e2e_benches/{}.rs".format(benchmark_name)
binary_name = name + "-bench-bin"
runfile_env = {
binary: "CARGO_BIN_EXE_" + native.package_relative_label(binary).name
for binary in binaries
}
rust_binary(
name = binary_name,
testonly = True,
srcs = [source],
crate_name = benchmark_name + "_bench",
crate_root = source,
deps = all_crate_deps(
normal = True,
normal_dev = True,
) + [
"@crates//:divan",
] + deps,
)
workspace_root_test(
name = name + "-bench",
args = [
"--bench",
benchmark_name,
],
data = data,
# Keep path resolution inside the wrapper so manifest-only runfiles
# work on every supported host platform.
runfile_env = runfile_env,
tags = ["manual"],
test_bin = ":" + binary_name,
visibility = ["//codex-rs:__pkg__"],
workspace_root_marker = _WORKSPACE_ROOT_MARKER,
)
+50 -5
View File
@@ -11,6 +11,7 @@ import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const require = createRequire(import.meta.url);
const codexPackageRoot = realpathSync(path.join(__dirname, ".."));
const PLATFORM_PACKAGE_BY_TARGET = {
"x86_64-unknown-linux-musl": "@openai/codex-linux-x64",
@@ -98,7 +99,9 @@ function findCodexExecutable() {
const updateCommand =
packageManager === "bun"
? "bun install -g @openai/codex@latest"
: "npm install -g @openai/codex@latest";
: packageManager === "pnpm"
? "pnpm add -g @openai/codex@latest"
: "npm install -g @openai/codex@latest";
throw new Error(
`Missing optional dependency ${platformPackage}. Reinstall Codex: ${updateCommand}`,
);
@@ -112,11 +115,47 @@ const binaryPath = findCodexExecutable();
// and guarantees that when either the child terminates or the parent
// receives a fatal signal, both processes exit in a predictable manner.
function isPnpmOwnedCodexInstall(nodeModulesDir) {
if (!existsSync(path.join(nodeModulesDir, ".modules.yaml"))) {
return false;
}
try {
return (
realpathSync(path.join(nodeModulesDir, "@openai", "codex")) ===
codexPackageRoot
);
} catch {
return false;
}
}
/**
* Use heuristics to detect the package manager that was used to install Codex
* in order to give the user a hint about how to update it.
*/
function detectPackageManager() {
// pnpm's owning node_modules directory can be several parents above the
// package in isolated global layouts. Search ancestors of both the canonical
// package root and lexical entrypoint because pnpm may link either path.
const entrypointDir = path.dirname(path.resolve(process.argv[1]));
for (const startDir of new Set([codexPackageRoot, entrypointDir])) {
const filesystemRoot = path.parse(startDir).root;
for (
let currentDir = startDir;
currentDir !== filesystemRoot;
currentDir = path.dirname(currentDir)
) {
if (isPnpmOwnedCodexInstall(path.join(currentDir, "node_modules"))) {
return "pnpm";
}
}
if (isPnpmOwnedCodexInstall(path.join(filesystemRoot, "node_modules"))) {
return "pnpm";
}
}
const userAgent = process.env.npm_config_user_agent || "";
if (/\bbun\//.test(userAgent)) {
return "bun";
@@ -137,15 +176,21 @@ function detectPackageManager() {
return userAgent ? "npm" : null;
}
const packageManager = detectPackageManager();
const packageManagerEnvVar =
detectPackageManager() === "bun"
packageManager === "bun"
? "CODEX_MANAGED_BY_BUN"
: "CODEX_MANAGED_BY_NPM";
: packageManager === "pnpm"
? "CODEX_MANAGED_BY_PNPM"
: "CODEX_MANAGED_BY_NPM";
const env = {
...process.env,
[packageManagerEnvVar]: "1",
CODEX_MANAGED_PACKAGE_ROOT: realpathSync(path.join(__dirname, "..")),
CODEX_MANAGED_PACKAGE_ROOT: codexPackageRoot,
};
delete env.CODEX_MANAGED_BY_NPM;
delete env.CODEX_MANAGED_BY_BUN;
delete env.CODEX_MANAGED_BY_PNPM;
env[packageManagerEnvVar] = "1";
const child = spawn(binaryPath, process.argv.slice(2), {
stdio: "inherit",
-26
View File
@@ -1,26 +0,0 @@
name: Cargo audit
on:
pull_request:
push:
branches:
- main
permissions:
contents: read
jobs:
audit:
runs-on: ubuntu-latest
defaults:
run:
working-directory: codex-rs
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0
- name: Install cargo-audit
uses: taiki-e/install-action@v2
with:
tool: cargo-audit
- name: Run cargo audit
run: cargo audit --deny warnings
+9
View File
@@ -15,3 +15,12 @@ filegroup(
),
visibility = ["//visibility:public"],
)
test_suite(
name = "e2e-benchmarks",
tags = ["manual"],
tests = [
"//codex-rs/cli:codex-help-bench",
],
visibility = ["//visibility:public"],
)
+46
View File
@@ -409,10 +409,14 @@ dependencies = [
"codex-protocol",
"codex-utils-cargo-bin",
"core_test_support",
"pretty_assertions",
"serde",
"serde_json",
"shlex",
"tempfile",
"tokio",
"tokio-util",
"url",
"uuid",
"wiremock",
]
@@ -1939,6 +1943,7 @@ dependencies = [
"codex-http-client",
"codex-protocol",
"codex-utils-rustls-provider",
"codex-websocket-client",
"eventsource-stream",
"futures",
"http 1.4.0",
@@ -1993,6 +1998,7 @@ dependencies = [
"codex-guardian",
"codex-home",
"codex-hooks",
"codex-http-client",
"codex-image-generation-extension",
"codex-login",
"codex-mcp",
@@ -2107,6 +2113,7 @@ dependencies = [
"anyhow",
"clap",
"codex-experimental-api-macros",
"codex-extension-items",
"codex-protocol",
"codex-shell-command",
"codex-utils-absolute-path",
@@ -2641,6 +2648,7 @@ dependencies = [
"codex-exec-server",
"codex-execpolicy",
"codex-extension-api",
"codex-extension-items",
"codex-features",
"codex-feedback",
"codex-file-system",
@@ -2752,6 +2760,7 @@ dependencies = [
"codex-extension-api",
"codex-features",
"codex-home",
"codex-image-generation-extension",
"codex-login",
"codex-model-provider-info",
"codex-models-manager",
@@ -2802,6 +2811,7 @@ dependencies = [
"tracing-subscriber",
"tracing-test",
"url",
"which 8.0.0",
"wiremock",
"zip 2.4.2",
]
@@ -3013,6 +3023,18 @@ dependencies = [
"tokio",
]
[[package]]
name = "codex-extension-items"
version = "0.0.0"
dependencies = [
"codex-utils-absolute-path",
"pretty_assertions",
"schemars 0.8.22",
"serde",
"serde_json",
"ts-rs",
]
[[package]]
name = "codex-external-agent-migration"
version = "0.0.0"
@@ -3235,6 +3257,7 @@ dependencies = [
"codex-core",
"codex-exec-server",
"codex-extension-api",
"codex-extension-items",
"codex-login",
"codex-model-provider",
"codex-model-provider-info",
@@ -3327,6 +3350,7 @@ dependencies = [
"codex-terminal-detection",
"codex-utils-template",
"core_test_support",
"http 1.4.0",
"jsonwebtoken",
"keyring",
"once_cell",
@@ -3344,6 +3368,7 @@ dependencies = [
"tiny_http",
"tokio",
"tracing",
"tracing-subscriber",
"url",
"urlencoding",
"webbrowser",
@@ -3421,6 +3446,7 @@ dependencies = [
"codex-exec-server",
"codex-extension-api",
"codex-home",
"codex-image-generation-extension",
"codex-login",
"codex-protocol",
"codex-shell-command",
@@ -3570,6 +3596,7 @@ version = "0.0.0"
dependencies = [
"chrono",
"codex-collaboration-mode-templates",
"codex-http-client",
"codex-login",
"codex-otel",
"codex-protocol",
@@ -3714,6 +3741,7 @@ dependencies = [
"chrono",
"codex-async-utils",
"codex-execpolicy",
"codex-extension-items",
"codex-network-proxy",
"codex-utils-absolute-path",
"codex-utils-image",
@@ -4071,6 +4099,7 @@ version = "0.0.0"
dependencies = [
"codex-code-mode",
"codex-connectors",
"codex-extension-items",
"codex-features",
"codex-file-system",
"codex-protocol",
@@ -4453,6 +4482,7 @@ dependencies = [
"codex-api",
"codex-core",
"codex-extension-api",
"codex-extension-items",
"codex-login",
"codex-model-provider",
"codex-model-provider-info",
@@ -4465,6 +4495,22 @@ dependencies = [
"url",
]
[[package]]
name = "codex-websocket-client"
version = "0.0.0"
dependencies = [
"codex-http-client",
"codex-utils-rustls-provider",
"futures",
"pretty_assertions",
"rcgen",
"rustls",
"tokio",
"tokio-rustls",
"tokio-tungstenite",
"url",
]
[[package]]
name = "codex-windows-sandbox"
version = "0.0.0"
+5
View File
@@ -54,6 +54,7 @@ members = [
"ext/goal",
"ext/guardian",
"ext/image-generation",
"ext/items",
"ext/memories",
"ext/mcp",
"ext/skills",
@@ -89,6 +90,7 @@ members = [
"tui",
"tools",
"v8-poc",
"websocket-client",
"utils/absolute-path",
"utils/path-uri",
"utils/cargo-bin",
@@ -166,6 +168,7 @@ codex-code-mode = { path = "code-mode" }
codex-code-mode-protocol = { path = "code-mode-protocol" }
codex-home = { path = "codex-home" }
codex-http-client = { path = "http-client" }
codex-websocket-client = { path = "websocket-client" }
codex-config = { path = "config" }
codex-connectors = { path = "connectors" }
codex-connectors-extension = { path = "ext/connectors" }
@@ -180,6 +183,7 @@ codex-exec-server-protocol = { path = "exec-server-protocol" }
codex-exec-server = { path = "exec-server" }
codex-execpolicy = { path = "execpolicy" }
codex-extension-api = { path = "ext/extension-api" }
codex-extension-items = { path = "ext/items" }
codex-goal-extension = { path = "ext/goal" }
codex-guardian = { path = "ext/guardian" }
codex-image-generation-extension = { path = "ext/image-generation" }
@@ -425,6 +429,7 @@ thiserror = "2.0.17"
time = "0.3.47"
tiny_http = "0.12"
tokio = "1"
tokio-rustls = "0.26.4"
tokio-stream = "0.1.18"
tokio-test = "0.4"
tokio-tungstenite = { version = "0.28.0", features = [
@@ -66,6 +66,7 @@ use crate::facts::PluginInstallRequestSource;
use crate::facts::PluginInstallRequested;
use crate::facts::PluginInstallRequestedInput;
use crate::facts::PluginInstallRequestedPlugin;
use crate::facts::PluginInstallSource;
use crate::facts::PluginState;
use crate::facts::PluginStateChangedInput;
use crate::facts::PluginUsedInput;
@@ -103,6 +104,7 @@ use codex_app_server_protocol::GuardianApprovalReview;
use codex_app_server_protocol::GuardianApprovalReviewAction;
use codex_app_server_protocol::GuardianApprovalReviewStatus;
use codex_app_server_protocol::GuardianCommandSource as AppServerGuardianCommandSource;
use codex_app_server_protocol::ImageGenerationItem;
use codex_app_server_protocol::InitializeCapabilities;
use codex_app_server_protocol::InitializeParams;
use codex_app_server_protocol::ItemCompletedNotification;
@@ -139,6 +141,7 @@ use codex_app_server_protocol::TurnStatus as AppServerTurnStatus;
use codex_app_server_protocol::TurnSteerParams;
use codex_app_server_protocol::TurnSteerResponse;
use codex_app_server_protocol::UserInput;
use codex_app_server_protocol::WebSearchItem;
use codex_login::default_client::DEFAULT_ORIGINATOR;
use codex_login::default_client::originator;
use codex_plugin::AppConnectorId;
@@ -3240,6 +3243,7 @@ fn plugin_install_failed_event_serializes_expected_shape() {
event_type: "codex_plugin_install_failed",
event_params: CodexPluginInstallFailedMetadata {
plugin: codex_plugin_metadata(sample_plugin_metadata()),
source: PluginInstallSource::Manual,
error_type: "store_io".to_string(),
},
});
@@ -3259,6 +3263,7 @@ fn plugin_install_failed_event_serializes_expected_shape() {
"mcp_server_count": 2,
"connector_ids": ["calendar", "drive"],
"product_client_id": originator().value,
"source": "manual",
"error_type": "store_io"
}
})
@@ -3690,6 +3695,7 @@ async fn reducer_ingests_plugin_install_failed_fact() {
AnalyticsFact::Custom(CustomAnalyticsFact::PluginInstallFailed(
PluginInstallFailedInput {
plugin: sample_plugin_metadata(),
source: PluginInstallSource::ExternalAgentMigration,
error_type: "invalid_plugin".to_string(),
},
)),
@@ -3711,6 +3717,7 @@ async fn reducer_ingests_plugin_install_failed_fact() {
"mcp_server_count": 2,
"connector_ids": ["calendar", "drive"],
"product_client_id": originator().value,
"source": "external_agent_migration",
"error_type": "invalid_plugin"
}
}])
@@ -3732,6 +3739,7 @@ async fn reducer_ingests_plugin_install_failed_fact_without_detail() {
AnalyticsFact::Custom(CustomAnalyticsFact::PluginInstallFailed(
PluginInstallFailedInput {
plugin,
source: PluginInstallSource::Manual,
error_type: "remote_catalog_unexpected_status".to_string(),
},
)),
@@ -3753,6 +3761,7 @@ async fn reducer_ingests_plugin_install_failed_fact_without_detail() {
"mcp_server_count": null,
"connector_ids": null,
"product_client_id": originator().value,
"source": "manual",
"error_type": "remote_catalog_unexpected_status"
}
}])
@@ -4400,18 +4409,18 @@ async fn turn_event_counts_completed_tool_items() {
agent_thread_id: "thread-child".to_string(),
agent_path: "/root/child".to_string(),
},
ThreadItem::WebSearch {
ThreadItem::WebSearch(WebSearchItem {
id: "web-1".to_string(),
query: "codex".to_string(),
action: None,
},
ThreadItem::ImageGeneration {
}),
ThreadItem::ImageGeneration(ImageGenerationItem {
id: "image-1".to_string(),
status: "completed".to_string(),
revised_prompt: None,
result: "ok".to_string(),
saved_path: None,
},
}),
];
for item in completed_tool_items {
+8 -1
View File
@@ -18,6 +18,7 @@ use crate::facts::HookRunInput;
use crate::facts::PluginInstallFailedInput;
use crate::facts::PluginInstallRequested;
use crate::facts::PluginInstallRequestedInput;
use crate::facts::PluginInstallSource;
use crate::facts::PluginState;
use crate::facts::PluginStateChangedInput;
use crate::facts::SkillInvocation;
@@ -370,10 +371,16 @@ impl AnalyticsEventsClient {
));
}
pub fn track_plugin_install_failed(&self, plugin: PluginTelemetryMetadata, error_type: String) {
pub fn track_plugin_install_failed(
&self,
plugin: PluginTelemetryMetadata,
source: PluginInstallSource,
error_type: String,
) {
self.record_fact(AnalyticsFact::Custom(
CustomAnalyticsFact::PluginInstallFailed(PluginInstallFailedInput {
plugin,
source,
error_type,
}),
));
+1
View File
@@ -991,6 +991,7 @@ pub(crate) struct CodexPluginEventRequest {
pub(crate) struct CodexPluginInstallFailedMetadata {
#[serde(flatten)]
pub(crate) plugin: CodexPluginMetadata,
pub(crate) source: crate::facts::PluginInstallSource,
pub(crate) error_type: String,
}
+8
View File
@@ -579,8 +579,16 @@ pub(crate) struct PluginStateChangedInput {
pub state: PluginState,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum PluginInstallSource {
Manual,
ExternalAgentMigration,
}
pub(crate) struct PluginInstallFailedInput {
pub plugin: PluginTelemetryMetadata,
pub source: PluginInstallSource,
pub error_type: String,
}
+1
View File
@@ -45,6 +45,7 @@ pub use facts::InvocationType;
pub use facts::PluginInstallRequestSource;
pub use facts::PluginInstallRequested;
pub use facts::PluginInstallRequestedPlugin;
pub use facts::PluginInstallSource;
pub use facts::SkillInvocation;
pub use facts::SubAgentThreadStartedInput;
pub use facts::ThreadInitializationMode;
+21 -22
View File
@@ -402,8 +402,8 @@ impl TurnToolCounts {
ThreadItem::CollabAgentToolCall { .. } | ThreadItem::SubAgentActivity { .. } => {
self.subagent_tool_call += 1;
}
ThreadItem::WebSearch { .. } => self.web_search += 1,
ThreadItem::ImageGeneration { .. } => self.image_generation += 1,
ThreadItem::WebSearch(_) => self.web_search += 1,
ThreadItem::ImageGeneration(_) => self.image_generation += 1,
ThreadItem::UserMessage { .. }
| ThreadItem::HookPrompt { .. }
| ThreadItem::AgentMessage { .. }
@@ -837,12 +837,17 @@ impl AnalyticsReducer {
input: PluginInstallFailedInput,
out: &mut Vec<TrackEventRequest>,
) {
let PluginInstallFailedInput { plugin, error_type } = input;
let PluginInstallFailedInput {
plugin,
source,
error_type,
} = input;
out.push(TrackEventRequest::PluginInstallFailed(
CodexPluginInstallFailedEventRequest {
event_type: "codex_plugin_install_failed",
event_params: CodexPluginInstallFailedMetadata {
plugin: codex_plugin_metadata(plugin),
source,
error_type,
},
},
@@ -1732,9 +1737,9 @@ fn tracked_tool_item_id(item: &ThreadItem) -> Option<&str> {
| ThreadItem::FileChange { id, .. }
| ThreadItem::McpToolCall { id, .. }
| ThreadItem::DynamicToolCall { id, .. }
| ThreadItem::CollabAgentToolCall { id, .. }
| ThreadItem::WebSearch { id, .. }
| ThreadItem::ImageGeneration { id, .. } => Some(id),
| ThreadItem::CollabAgentToolCall { id, .. } => Some(id),
ThreadItem::WebSearch(item) => Some(&item.id),
ThreadItem::ImageGeneration(item) => Some(&item.id),
ThreadItem::UserMessage { .. }
| ThreadItem::HookPrompt { .. }
| ThreadItem::AgentMessage { .. }
@@ -2027,11 +2032,11 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option<TrackEventRequest> {
},
))
}
ThreadItem::WebSearch { id, query, action } => {
ThreadItem::WebSearch(item) => {
let base = tool_item_base(
thread_id,
turn_id,
id.clone(),
item.id.clone(),
"web_search".to_string(),
ToolItemOutcome {
terminal_status: ToolItemTerminalStatus::Completed,
@@ -2051,24 +2056,18 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option<TrackEventRequest> {
event_type: "codex_web_search_event",
event_params: CodexWebSearchEventParams {
base,
web_search_action: action.as_ref().map(web_search_action_kind),
query_present: !query.trim().is_empty(),
query_count: web_search_query_count(query, action.as_ref()),
web_search_action: item.action.as_ref().map(web_search_action_kind),
query_present: !item.query.trim().is_empty(),
query_count: web_search_query_count(&item.query, item.action.as_ref()),
},
}))
}
ThreadItem::ImageGeneration {
id,
status,
revised_prompt,
saved_path,
..
} => {
let (terminal_status, failure_kind) = image_generation_outcome(status.as_str());
ThreadItem::ImageGeneration(item) => {
let (terminal_status, failure_kind) = image_generation_outcome(item.status.as_str());
let base = tool_item_base(
thread_id,
turn_id,
id.clone(),
item.id.clone(),
"image_generation".to_string(),
ToolItemOutcome {
terminal_status,
@@ -2089,8 +2088,8 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option<TrackEventRequest> {
event_type: "codex_image_generation_event",
event_params: CodexImageGenerationEventParams {
base,
revised_prompt_present: revised_prompt.is_some(),
saved_path_present: saved_path.is_some(),
revised_prompt_present: item.revised_prompt.is_some(),
saved_path_present: item.saved_path.is_some(),
},
},
))
+1
View File
@@ -16,6 +16,7 @@ workspace = true
anyhow = { workspace = true }
clap = { workspace = true, features = ["derive"] }
codex-experimental-api-macros = { workspace = true }
codex-extension-items = { workspace = true }
codex-protocol = { workspace = true }
codex-shell-command = { workspace = true }
codex-utils-absolute-path = { workspace = true }
@@ -1463,6 +1463,17 @@
},
{
"properties": {
"appBrand": {
"anyOf": [
{
"$ref": "#/definitions/LoginAppBrand"
},
{
"type": "null"
}
],
"default": null
},
"codexStreamlinedLogin": {
"type": "boolean"
},
@@ -1472,6 +1483,9 @@
],
"title": "ChatgptLoginAccountParamsType",
"type": "string"
},
"useHostedLoginSuccessPage": {
"type": "boolean"
}
},
"required": [
@@ -1529,9 +1543,41 @@
],
"title": "ChatgptAuthTokensLoginAccountParams",
"type": "object"
},
{
"description": "[UNSTABLE] Managed Amazon Bedrock login is experimental.",
"properties": {
"apiKey": {
"type": "string"
},
"region": {
"type": "string"
},
"type": {
"enum": [
"amazonBedrock"
],
"title": "AmazonBedrockLoginAccountParamsType",
"type": "string"
}
},
"required": [
"apiKey",
"region",
"type"
],
"title": "AmazonBedrockLoginAccountParams",
"type": "object"
}
]
},
"LoginAppBrand": {
"enum": [
"codex",
"chatgpt"
],
"type": "string"
},
"MarketplaceAddParams": {
"properties": {
"refName": {
@@ -536,6 +536,13 @@
],
"type": "string"
},
{
"description": "Backend auth supplied as request headers.",
"enum": [
"headers"
],
"type": "string"
},
{
"description": "Programmatic Codex auth backed by a registered Agent Identity.",
"enum": [
@@ -6951,6 +6951,13 @@
],
"type": "string"
},
{
"description": "Backend auth supplied as request headers.",
"enum": [
"headers"
],
"type": "string"
},
{
"description": "Programmatic Codex auth backed by a registered Agent Identity.",
"enum": [
@@ -11504,6 +11511,17 @@
},
{
"properties": {
"appBrand": {
"anyOf": [
{
"$ref": "#/definitions/v2/LoginAppBrand"
},
{
"type": "null"
}
],
"default": null
},
"codexStreamlinedLogin": {
"type": "boolean"
},
@@ -11513,6 +11531,9 @@
],
"title": "Chatgptv2::LoginAccountParamsType",
"type": "string"
},
"useHostedLoginSuccessPage": {
"type": "boolean"
}
},
"required": [
@@ -11570,6 +11591,31 @@
],
"title": "ChatgptAuthTokensv2::LoginAccountParams",
"type": "object"
},
{
"description": "[UNSTABLE] Managed Amazon Bedrock login is experimental.",
"properties": {
"apiKey": {
"type": "string"
},
"region": {
"type": "string"
},
"type": {
"enum": [
"amazonBedrock"
],
"title": "AmazonBedrockv2::LoginAccountParamsType",
"type": "string"
}
},
"required": [
"apiKey",
"region",
"type"
],
"title": "AmazonBedrockv2::LoginAccountParams",
"type": "object"
}
],
"title": "LoginAccountParams"
@@ -11663,10 +11709,33 @@
],
"title": "ChatgptAuthTokensv2::LoginAccountResponse",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"amazonBedrock"
],
"title": "AmazonBedrockv2::LoginAccountResponseType",
"type": "string"
}
},
"required": [
"type"
],
"title": "AmazonBedrockv2::LoginAccountResponse",
"type": "object"
}
],
"title": "LoginAccountResponse"
},
"LoginAppBrand": {
"enum": [
"codex",
"chatgpt"
],
"type": "string"
},
"LogoutAccountResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "LogoutAccountResponse",
@@ -1077,6 +1077,13 @@
],
"type": "string"
},
{
"description": "Backend auth supplied as request headers.",
"enum": [
"headers"
],
"type": "string"
},
{
"description": "Programmatic Codex auth backed by a registered Agent Identity.",
"enum": [
@@ -7908,6 +7915,17 @@
},
{
"properties": {
"appBrand": {
"anyOf": [
{
"$ref": "#/definitions/LoginAppBrand"
},
{
"type": "null"
}
],
"default": null
},
"codexStreamlinedLogin": {
"type": "boolean"
},
@@ -7917,6 +7935,9 @@
],
"title": "Chatgptv2::LoginAccountParamsType",
"type": "string"
},
"useHostedLoginSuccessPage": {
"type": "boolean"
}
},
"required": [
@@ -7974,6 +7995,31 @@
],
"title": "ChatgptAuthTokensv2::LoginAccountParams",
"type": "object"
},
{
"description": "[UNSTABLE] Managed Amazon Bedrock login is experimental.",
"properties": {
"apiKey": {
"type": "string"
},
"region": {
"type": "string"
},
"type": {
"enum": [
"amazonBedrock"
],
"title": "AmazonBedrockv2::LoginAccountParamsType",
"type": "string"
}
},
"required": [
"apiKey",
"region",
"type"
],
"title": "AmazonBedrockv2::LoginAccountParams",
"type": "object"
}
],
"title": "LoginAccountParams"
@@ -8067,10 +8113,33 @@
],
"title": "ChatgptAuthTokensv2::LoginAccountResponse",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"amazonBedrock"
],
"title": "AmazonBedrockv2::LoginAccountResponseType",
"type": "string"
}
},
"required": [
"type"
],
"title": "AmazonBedrockv2::LoginAccountResponse",
"type": "object"
}
],
"title": "LoginAccountResponse"
},
"LoginAppBrand": {
"enum": [
"codex",
"chatgpt"
],
"type": "string"
},
"LogoutAccountResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "LogoutAccountResponse",
@@ -25,6 +25,13 @@
],
"type": "string"
},
{
"description": "Backend auth supplied as request headers.",
"enum": [
"headers"
],
"type": "string"
},
{
"description": "Programmatic Codex auth backed by a registered Agent Identity.",
"enum": [
@@ -1,5 +1,14 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"LoginAppBrand": {
"enum": [
"codex",
"chatgpt"
],
"type": "string"
}
},
"oneOf": [
{
"properties": {
@@ -23,6 +32,17 @@
},
{
"properties": {
"appBrand": {
"anyOf": [
{
"$ref": "#/definitions/LoginAppBrand"
},
{
"type": "null"
}
],
"default": null
},
"codexStreamlinedLogin": {
"type": "boolean"
},
@@ -32,6 +52,9 @@
],
"title": "Chatgptv2::LoginAccountParamsType",
"type": "string"
},
"useHostedLoginSuccessPage": {
"type": "boolean"
}
},
"required": [
@@ -89,6 +112,31 @@
],
"title": "ChatgptAuthTokensv2::LoginAccountParams",
"type": "object"
},
{
"description": "[UNSTABLE] Managed Amazon Bedrock login is experimental.",
"properties": {
"apiKey": {
"type": "string"
},
"region": {
"type": "string"
},
"type": {
"enum": [
"amazonBedrock"
],
"title": "AmazonBedrockv2::LoginAccountParamsType",
"type": "string"
}
},
"required": [
"apiKey",
"region",
"type"
],
"title": "AmazonBedrockv2::LoginAccountParams",
"type": "object"
}
],
"title": "LoginAccountParams"
@@ -87,6 +87,22 @@
],
"title": "ChatgptAuthTokensv2::LoginAccountResponse",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"amazonBedrock"
],
"title": "AmazonBedrockv2::LoginAccountResponseType",
"type": "string"
}
},
"required": [
"type"
],
"title": "AmazonBedrockv2::LoginAccountResponse",
"type": "object"
}
],
"title": "LoginAccountResponse"
+1 -1
View File
@@ -5,4 +5,4 @@
/**
* Authentication mode for OpenAI-backed providers.
*/
export type AuthMode = "apikey" | "chatgpt" | "chatgptAuthTokens" | "agentIdentity" | "personalAccessToken" | "bedrockApiKey";
export type AuthMode = "apikey" | "chatgpt" | "chatgptAuthTokens" | "headers" | "agentIdentity" | "personalAccessToken" | "bedrockApiKey";
@@ -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 { AbsolutePathBuf } from "./AbsolutePathBuf";
export type ImageGenerationItem = { id: string, status: string, revisedPrompt: string | null, result: string, savedPath?: AbsolutePathBuf, };
@@ -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 { WebSearchAction } from "./v2/WebSearchAction";
export type WebSearchItem = { id: string, query: string, action: WebSearchAction | null, };
+2
View File
@@ -37,6 +37,7 @@ export type { GitDiffToRemoteParams } from "./GitDiffToRemoteParams";
export type { GitDiffToRemoteResponse } from "./GitDiffToRemoteResponse";
export type { GitSha } from "./GitSha";
export type { ImageDetail } from "./ImageDetail";
export type { ImageGenerationItem } from "./ImageGenerationItem";
export type { InitializeCapabilities } from "./InitializeCapabilities";
export type { InitializeParams } from "./InitializeParams";
export type { InitializeResponse } from "./InitializeResponse";
@@ -81,6 +82,7 @@ export type { Tool } from "./Tool";
export type { Verbosity } from "./Verbosity";
export type { WebSearchAction } from "./WebSearchAction";
export type { WebSearchContextSize } from "./WebSearchContextSize";
export type { WebSearchItem } from "./WebSearchItem";
export type { WebSearchLocation } from "./WebSearchLocation";
export type { WebSearchMode } from "./WebSearchMode";
export type { WebSearchToolConfig } from "./WebSearchToolConfig";
@@ -1,8 +1,9 @@
// 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 { LoginAppBrand } from "./LoginAppBrand";
export type LoginAccountParams = { "type": "apiKey", apiKey: string, } | { "type": "chatgpt", codexStreamlinedLogin?: boolean, } | { "type": "chatgptDeviceCode" } | { "type": "chatgptAuthTokens",
export type LoginAccountParams = { "type": "apiKey", apiKey: string, } | { "type": "chatgpt", codexStreamlinedLogin?: boolean, useHostedLoginSuccessPage?: boolean, appBrand?: LoginAppBrand | null, } | { "type": "chatgptDeviceCode" } | { "type": "chatgptAuthTokens",
/**
* Access token (JWT) supplied by the client.
* This token is used for backend API requests and email extraction.
@@ -18,4 +19,4 @@ chatgptAccountId: string,
* When `null`, Codex attempts to derive the plan type from access-token
* claims. If unavailable, the plan defaults to `unknown`.
*/
chatgptPlanType?: string | null, };
chatgptPlanType?: string | null, } | { "type": "amazonBedrock", apiKey: string, region: string, };
@@ -14,4 +14,4 @@ verificationUrl: string,
/**
* One-time code the user must enter after signing in.
*/
userCode: string, } | { "type": "chatgptAuthTokens", };
userCode: string, } | { "type": "chatgptAuthTokens", } | { "type": "amazonBedrock", };
@@ -0,0 +1,5 @@
// 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.
export type LoginAppBrand = "codex" | "chatgpt";
@@ -1,10 +1,11 @@
// 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 { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { ImageGenerationItem } from "../ImageGenerationItem";
import type { LegacyAppPathString } from "../LegacyAppPathString";
import type { MessagePhase } from "../MessagePhase";
import type { ReasoningEffort } from "../ReasoningEffort";
import type { WebSearchItem } from "../WebSearchItem";
import type { JsonValue } from "../serde_json/JsonValue";
import type { CollabAgentState } from "./CollabAgentState";
import type { CollabAgentTool } from "./CollabAgentTool";
@@ -24,7 +25,6 @@ import type { MemoryCitation } from "./MemoryCitation";
import type { PatchApplyStatus } from "./PatchApplyStatus";
import type { SubAgentActivityKind } from "./SubAgentActivityKind";
import type { UserInput } from "./UserInput";
import type { WebSearchAction } from "./WebSearchAction";
export type ThreadItem = { "type": "userMessage", id: string, clientId: string | null, content: Array<UserInput>, } | { "type": "hookPrompt", id: string, fragments: Array<HookPromptFragment>, } | { "type": "agentMessage", id: string, text: string, phase: MessagePhase | null, memoryCitation: MemoryCitation | null, } | { "type": "plan", id: string, text: string, } | { "type": "reasoning", id: string, summary: Array<string>, content: Array<string>, } | { "type": "commandExecution", id: string,
/**
@@ -105,4 +105,4 @@ reasoningEffort: ReasoningEffort | null,
/**
* Last known status of the target agents, when available.
*/
agentsStates: { [key in string]?: CollabAgentState }, } | { "type": "subAgentActivity", id: string, kind: SubAgentActivityKind, agentThreadId: string, agentPath: string, } | { "type": "webSearch", id: string, query: string, action: WebSearchAction | null, } | { "type": "imageView", id: string, path: LegacyAppPathString, } | { "type": "sleep", id: string, durationMs: number, } | { "type": "imageGeneration", id: string, status: string, revisedPrompt: string | null, result: string, savedPath?: AbsolutePathBuf, } | { "type": "enteredReviewMode", id: string, review: string, } | { "type": "exitedReviewMode", id: string, review: string, } | { "type": "contextCompaction", id: string, };
agentsStates: { [key in string]?: CollabAgentState }, } | { "type": "subAgentActivity", id: string, kind: SubAgentActivityKind, agentThreadId: string, agentPath: string, } | { "type": "webSearch" } & WebSearchItem | { "type": "imageView", id: string, path: LegacyAppPathString, } | { "type": "sleep", id: string, durationMs: number, } | { "type": "imageGeneration" } & ImageGenerationItem | { "type": "enteredReviewMode", id: string, review: string, } | { "type": "exitedReviewMode", id: string, review: string, } | { "type": "contextCompaction", id: string, };
@@ -194,6 +194,7 @@ export type { ListMcpServerStatusParams } from "./ListMcpServerStatusParams";
export type { ListMcpServerStatusResponse } from "./ListMcpServerStatusResponse";
export type { LoginAccountParams } from "./LoginAccountParams";
export type { LoginAccountResponse } from "./LoginAccountResponse";
export type { LoginAppBrand } from "./LoginAppBrand";
export type { LogoutAccountResponse } from "./LogoutAccountResponse";
export type { ManagedHooksRequirements } from "./ManagedHooksRequirements";
export type { MarketplaceAddParams } from "./MarketplaceAddParams";
@@ -31,6 +31,11 @@ pub enum AuthMode {
#[ts(rename = "chatgptAuthTokens")]
#[strum(serialize = "chatgptAuthTokens")]
ChatgptAuthTokens,
/// Backend auth supplied as request headers.
#[serde(rename = "headers")]
#[ts(rename = "headers")]
#[strum(serialize = "headers")]
Headers,
/// Programmatic Codex auth backed by a registered Agent Identity.
#[serde(rename = "agentIdentity")]
#[ts(rename = "agentIdentity")]
@@ -53,7 +58,7 @@ impl AuthMode {
pub fn has_chatgpt_account(self) -> bool {
match self {
Self::Chatgpt | Self::ChatgptAuthTokens | Self::PersonalAccessToken => true,
Self::ApiKey | Self::AgentIdentity | Self::BedrockApiKey => false,
Self::ApiKey | Self::Headers | Self::AgentIdentity | Self::BedrockApiKey => false,
}
}
@@ -62,6 +67,7 @@ impl AuthMode {
match self {
Self::Chatgpt
| Self::ChatgptAuthTokens
| Self::Headers
| Self::AgentIdentity
| Self::PersonalAccessToken => true,
Self::ApiKey | Self::BedrockApiKey => false,
@@ -2716,12 +2722,42 @@ mod tests {
Ok(())
}
#[test]
fn serialize_account_login_amazon_bedrock() -> Result<()> {
let request = ClientRequest::LoginAccount {
request_id: RequestId::Integer(2),
params: v2::LoginAccountParams::AmazonBedrock {
api_key: "secret".to_string(),
region: "us-west-2".to_string(),
},
};
assert_eq!(
json!({
"method": "account/login/start",
"id": 2,
"params": {
"type": "amazonBedrock",
"apiKey": "secret",
"region": "us-west-2"
}
}),
serde_json::to_value(&request)?,
);
assert_eq!(
json!({"type": "amazonBedrock"}),
serde_json::to_value(v2::LoginAccountResponse::AmazonBedrock {})?,
);
Ok(())
}
#[test]
fn serialize_account_login_chatgpt() -> Result<()> {
let request = ClientRequest::LoginAccount {
request_id: RequestId::Integer(3),
params: v2::LoginAccountParams::Chatgpt {
app_brand: None,
codex_streamlined_login: false,
use_hosted_login_success_page: false,
},
};
assert_eq!(
@@ -2729,7 +2765,8 @@ mod tests {
"method": "account/login/start",
"id": 3,
"params": {
"type": "chatgpt"
"type": "chatgpt",
"appBrand": null
}
}),
serde_json::to_value(&request)?,
@@ -2742,7 +2779,9 @@ mod tests {
let request = ClientRequest::LoginAccount {
request_id: RequestId::Integer(3),
params: v2::LoginAccountParams::Chatgpt {
app_brand: None,
codex_streamlined_login: true,
use_hosted_login_success_page: false,
},
};
assert_eq!(
@@ -2751,6 +2790,7 @@ mod tests {
"id": 3,
"params": {
"type": "chatgpt",
"appBrand": null,
"codexStreamlinedLogin": true
}
}),
@@ -2759,6 +2799,32 @@ mod tests {
Ok(())
}
#[test]
fn serialize_account_login_chatgpt_with_hosted_success_page() -> Result<()> {
let request = ClientRequest::LoginAccount {
request_id: RequestId::Integer(3),
params: v2::LoginAccountParams::Chatgpt {
app_brand: Some(v2::LoginAppBrand::Chatgpt),
codex_streamlined_login: true,
use_hosted_login_success_page: true,
},
};
assert_eq!(
json!({
"method": "account/login/start",
"id": 3,
"params": {
"type": "chatgpt",
"appBrand": "chatgpt",
"codexStreamlinedLogin": true,
"useHostedLoginSuccessPage": true
}
}),
serde_json::to_value(&request)?,
);
Ok(())
}
#[test]
fn serialize_account_login_chatgpt_device_code() -> Result<()> {
let request = ClientRequest::LoginAccount {
@@ -33,6 +33,9 @@ use codex_protocol::protocol::GuardianAssessmentAction;
use codex_protocol::protocol::GuardianAssessmentEvent;
use codex_protocol::protocol::PatchApplyBeginEvent;
use codex_protocol::protocol::PatchApplyEndEvent;
use codex_protocol::protocol::ReviewOutputEvent;
use codex_protocol::review_format::REVIEW_FALLBACK_MESSAGE;
use codex_protocol::review_format::render_review_output_text;
use codex_shell_command::parse_command::parse_command;
use codex_shell_command::parse_command::shlex_join;
use codex_utils_path_uri::PathConvention;
@@ -41,6 +44,12 @@ use std::collections::HashMap;
use std::path::PathBuf;
use tracing::warn;
pub(crate) fn review_output_text(output: Option<&ReviewOutputEvent>) -> String {
output
.map(render_review_output_text)
.unwrap_or_else(|| REVIEW_FALLBACK_MESSAGE.to_string())
}
pub fn build_file_change_approval_request_item(
payload: &ApplyPatchApprovalRequestEvent,
) -> ThreadItem {
@@ -4,6 +4,7 @@ use crate::protocol::item_builders::build_file_change_approval_request_item;
use crate::protocol::item_builders::build_file_change_begin_item;
use crate::protocol::item_builders::build_file_change_end_item;
use crate::protocol::item_builders::build_item_from_guardian_event;
use crate::protocol::item_builders::review_output_text;
use crate::protocol::v2::CollabAgentState;
use crate::protocol::v2::CollabAgentTool;
use crate::protocol::v2::CollabAgentToolCallStatus;
@@ -21,7 +22,11 @@ use crate::protocol::v2::TurnError;
use crate::protocol::v2::TurnItemsView;
use crate::protocol::v2::TurnStatus;
use crate::protocol::v2::UserInput;
#[cfg(test)]
use crate::protocol::v2::WebSearchAction;
use crate::protocol::v2::WebSearchItem;
use crate::protocol::v2::web_search_action_from_core;
use codex_extension_items::image_generation::ImageGenerationItem;
use codex_protocol::items::parse_hook_prompt_message;
use codex_protocol::models::MessagePhase;
use codex_protocol::protocol::AgentReasoningEvent;
@@ -45,7 +50,6 @@ use codex_protocol::protocol::McpToolCallBeginEvent;
use codex_protocol::protocol::McpToolCallEndEvent;
use codex_protocol::protocol::PatchApplyBeginEvent;
use codex_protocol::protocol::PatchApplyEndEvent;
use codex_protocol::protocol::ReviewOutputEvent;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::ThreadRolledBackEvent;
use codex_protocol::protocol::TurnAbortedEvent;
@@ -55,6 +59,8 @@ use codex_protocol::protocol::UserMessageEvent;
use codex_protocol::protocol::ViewImageToolCallEvent;
use codex_protocol::protocol::WebSearchBeginEvent;
use codex_protocol::protocol::WebSearchEndEvent;
#[cfg(test)]
use codex_protocol::review_format::REVIEW_FALLBACK_MESSAGE;
use std::collections::HashMap;
use tracing::warn;
use uuid::Uuid;
@@ -586,15 +592,23 @@ impl ThreadHistoryBuilder {
turn_id: &str,
item: &codex_protocol::items::TurnItem,
) {
let is_review_mode_item = matches!(
item,
codex_protocol::items::TurnItem::EnteredReviewMode(_)
| codex_protocol::items::TurnItem::ExitedReviewMode(_)
);
let should_upsert = match item {
codex_protocol::items::TurnItem::Plan(plan) => !plan.text.is_empty(),
codex_protocol::items::TurnItem::Sleep(_)
| codex_protocol::items::TurnItem::HookPrompt(_)
| codex_protocol::items::TurnItem::CommandExecution(_)
| codex_protocol::items::TurnItem::DynamicToolCall(_)
| codex_protocol::items::TurnItem::CollabAgentToolCall(_)
| codex_protocol::items::TurnItem::SubAgentActivity(_) => true,
| codex_protocol::items::TurnItem::SubAgentActivity(_)
| codex_protocol::items::TurnItem::Extension(_)
| codex_protocol::items::TurnItem::EnteredReviewMode(_)
| codex_protocol::items::TurnItem::ExitedReviewMode(_) => true,
codex_protocol::items::TurnItem::UserMessage(_)
| codex_protocol::items::TurnItem::HookPrompt(_)
| codex_protocol::items::TurnItem::AgentMessage(_)
| codex_protocol::items::TurnItem::Reasoning(_)
| codex_protocol::items::TurnItem::WebSearch(_)
@@ -606,25 +620,30 @@ impl ThreadHistoryBuilder {
};
if should_upsert {
self.upsert_item_in_turn_id(turn_id, ThreadItem::from(item.clone()));
let item = ThreadItem::from(item.clone());
if is_review_mode_item {
self.upsert_review_mode_item(Some(turn_id), item);
} else {
self.upsert_item_in_turn_id(turn_id, item);
}
}
}
fn handle_web_search_begin(&mut self, payload: &WebSearchBeginEvent) {
let item = ThreadItem::WebSearch {
let item = ThreadItem::WebSearch(WebSearchItem {
id: payload.call_id.clone(),
query: String::new(),
action: None,
};
});
self.upsert_item_in_current_turn(item);
}
fn handle_web_search_end(&mut self, payload: &WebSearchEndEvent) {
let item = ThreadItem::WebSearch {
let item = ThreadItem::WebSearch(WebSearchItem {
id: payload.call_id.clone(),
query: payload.query.clone(),
action: Some(WebSearchAction::from(payload.action.clone())),
};
action: Some(web_search_action_from_core(payload.action.clone())),
});
self.upsert_item_in_current_turn(item);
}
@@ -827,24 +846,24 @@ impl ThreadHistoryBuilder {
}
fn handle_image_generation_begin(&mut self, payload: &ImageGenerationBeginEvent) {
let item = ThreadItem::ImageGeneration {
let item = ThreadItem::ImageGeneration(ImageGenerationItem {
id: payload.call_id.clone(),
status: String::new(),
revised_prompt: None,
result: String::new(),
saved_path: None,
};
});
self.upsert_item_in_current_turn(item);
}
fn handle_image_generation_end(&mut self, payload: &ImageGenerationEndEvent) {
let item = ThreadItem::ImageGeneration {
let item = ThreadItem::ImageGeneration(ImageGenerationItem {
id: payload.call_id.clone(),
status: payload.status.clone(),
revised_prompt: payload.revised_prompt.clone(),
result: payload.result.clone(),
saved_path: payload.saved_path.clone(),
};
});
self.upsert_item_in_current_turn(item);
}
@@ -1103,26 +1122,55 @@ impl ThreadHistoryBuilder {
self.push_item_in_current_turn(ThreadItem::ContextCompaction { id });
}
fn handle_entered_review_mode(&mut self, payload: &codex_protocol::protocol::ReviewRequest) {
fn handle_entered_review_mode(
&mut self,
payload: &codex_protocol::protocol::EnteredReviewModeEvent,
) {
let review = payload
.user_facing_hint
.clone()
.unwrap_or_else(|| "Review requested.".to_string());
let id = self.next_item_id();
self.push_item_in_current_turn(ThreadItem::EnteredReviewMode { id, review });
let id = payload
.item_id
.clone()
.unwrap_or_else(|| self.next_item_id());
self.upsert_review_mode_item(
payload.turn_id.as_deref(),
ThreadItem::EnteredReviewMode { id, review },
);
}
fn handle_exited_review_mode(
&mut self,
payload: &codex_protocol::protocol::ExitedReviewModeEvent,
) {
let review = payload
.review_output
let review = review_output_text(payload.review_output.as_ref());
let id = payload
.item_id
.clone()
.unwrap_or_else(|| self.next_item_id());
self.upsert_review_mode_item(
payload.turn_id.as_deref(),
ThreadItem::ExitedReviewMode { id, review },
);
}
fn upsert_review_mode_item(&mut self, turn_id: Option<&str>, item: ThreadItem) {
let Some(turn_id) = turn_id else {
self.upsert_item_in_current_turn(item);
return;
};
let current_turn_matches = self
.current_turn
.as_ref()
.map(render_review_output_text)
.unwrap_or_else(|| REVIEW_FALLBACK_MESSAGE.to_string());
let id = self.next_item_id();
self.push_item_in_current_turn(ThreadItem::ExitedReviewMode { id, review });
.is_some_and(|turn| turn.id == turn_id);
if !current_turn_matches && !self.turns.iter().any(|turn| turn.id == turn_id) {
self.finish_current_turn();
let turn = self.new_turn(Some(turn_id.to_string()));
self.record_changed_pending_turn(&turn);
self.current_turn = Some(turn);
}
self.upsert_item_in_turn_id(turn_id, item);
}
fn handle_error(&mut self, payload: &ErrorEvent) {
@@ -1438,17 +1486,6 @@ impl ThreadHistoryBuilder {
}
}
const REVIEW_FALLBACK_MESSAGE: &str = "Reviewer failed to output a response.";
fn render_review_output_text(output: &ReviewOutputEvent) -> String {
let explanation = output.overall_explanation.trim();
if explanation.is_empty() {
REVIEW_FALLBACK_MESSAGE.to_string()
} else {
explanation.to_string()
}
}
fn convert_dynamic_tool_content_items(
items: &[codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem],
) -> Vec<DynamicToolCallOutputContentItem> {
@@ -1548,10 +1585,13 @@ impl From<&PendingTurn> for Turn {
mod tests {
use super::*;
use crate::protocol::v2::CommandExecutionSource;
use codex_extension_items::ExtensionItem as CoreExtensionItem;
use codex_protocol::ThreadId;
use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem as CoreDynamicToolCallOutputContentItem;
use codex_protocol::items::CommandExecutionItem as CoreCommandExecutionItem;
use codex_protocol::items::CommandExecutionStatus as CoreCommandExecutionStatus;
use codex_protocol::items::EnteredReviewModeItem as CoreEnteredReviewModeItem;
use codex_protocol::items::ExitedReviewModeItem as CoreExitedReviewModeItem;
use codex_protocol::items::HookPromptFragment as CoreHookPromptFragment;
use codex_protocol::items::SleepItem as CoreSleepItem;
use codex_protocol::items::TurnItem as CoreTurnItem;
@@ -1569,12 +1609,15 @@ mod tests {
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::CompactedItem;
use codex_protocol::protocol::DynamicToolCallResponseEvent;
use codex_protocol::protocol::EnteredReviewModeEvent;
use codex_protocol::protocol::ExecCommandEndEvent;
use codex_protocol::protocol::ExecCommandSource;
use codex_protocol::protocol::ExitedReviewModeEvent;
use codex_protocol::protocol::ItemStartedEvent;
use codex_protocol::protocol::McpInvocation;
use codex_protocol::protocol::McpToolCallEndEvent;
use codex_protocol::protocol::PatchApplyBeginEvent;
use codex_protocol::protocol::ReviewTarget;
use codex_protocol::protocol::ThreadRolledBackEvent;
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::TurnAbortedEvent;
@@ -1699,6 +1742,111 @@ mod tests {
);
}
#[test]
fn review_mode_events_replay_persisted_ids() {
let events = vec![
EventMsg::EnteredReviewMode(EnteredReviewModeEvent {
target: ReviewTarget::Custom {
instructions: "review this".into(),
},
user_facing_hint: Some("Review requested.".into()),
turn_id: Some("turn-1".into()),
item_id: Some("entered-review".into()),
}),
EventMsg::ExitedReviewMode(ExitedReviewModeEvent {
turn_id: Some("turn-1".into()),
item_id: Some("exited-review".into()),
review_output: None,
}),
EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-1".into(),
last_agent_message: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
}),
];
let mut builder = ThreadHistoryBuilder::new();
for event in &events {
builder.handle_event(event);
}
let turns = builder.finish();
assert_eq!(turns[0].id, "turn-1");
assert_eq!(
turns[0].items,
vec![
ThreadItem::EnteredReviewMode {
id: "entered-review".into(),
review: "Review requested.".into(),
},
ThreadItem::ExitedReviewMode {
id: "exited-review".into(),
review: REVIEW_FALLBACK_MESSAGE.into(),
},
]
);
}
#[test]
fn review_mode_items_replay_without_turn_started() {
let thread_id = ThreadId::new();
let entered = CoreTurnItem::EnteredReviewMode(CoreEnteredReviewModeItem {
id: "entered-review".into(),
target: ReviewTarget::Custom {
instructions: "review this".into(),
},
user_facing_hint: "Review requested.".into(),
});
let exited = CoreTurnItem::ExitedReviewMode(CoreExitedReviewModeItem {
id: "exited-review".into(),
review_output: None,
});
let events = vec![
EventMsg::ItemCompleted(ItemCompletedEvent {
thread_id,
turn_id: "turn-1".into(),
item: entered,
completed_at_ms: 0,
}),
EventMsg::ItemCompleted(ItemCompletedEvent {
thread_id,
turn_id: "turn-1".into(),
item: exited,
completed_at_ms: 0,
}),
EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-1".into(),
last_agent_message: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
}),
];
let mut builder = ThreadHistoryBuilder::new();
for event in &events {
builder.handle_event(event);
}
let turns = builder.finish();
assert_eq!(turns[0].id, "turn-1");
assert_eq!(
turns[0].items,
vec![
ThreadItem::EnteredReviewMode {
id: "entered-review".into(),
review: "Review requested.".into(),
},
ThreadItem::ExitedReviewMode {
id: "exited-review".into(),
review: REVIEW_FALLBACK_MESSAGE.into(),
},
]
);
}
#[test]
fn rebuilds_user_message_image_details_from_legacy_events() {
let local_path = PathBuf::from("/tmp/local.png");
@@ -1846,6 +1994,60 @@ mod tests {
);
}
#[test]
fn rebuilds_extension_image_generation_item_from_persisted_completion() {
let turn_id = "turn-1";
let thread_id = ThreadId::new();
let saved_path = test_path_buf("/tmp/image-1.png").abs();
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_id: turn_id.to_string(),
trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
}),
EventMsg::ItemCompleted(ItemCompletedEvent {
thread_id,
turn_id: turn_id.to_string(),
item: CoreTurnItem::Extension(CoreExtensionItem::ImageGeneration(
ImageGenerationItem {
id: "image-1".to_string(),
status: "completed".to_string(),
revised_prompt: Some("A blue square".to_string()),
result: "cG5n".to_string(),
saved_path: Some(saved_path.clone()),
},
)),
completed_at_ms: 1_000,
}),
EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: turn_id.to_string(),
last_agent_message: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
}),
];
let items = events
.into_iter()
.map(RolloutItem::EventMsg)
.collect::<Vec<_>>();
let turns = build_turns_from_rollout_items(&items);
assert_eq!(
turns[0].items,
vec![ThreadItem::ImageGeneration(ImageGenerationItem {
id: "image-1".to_string(),
status: "completed".to_string(),
revised_prompt: Some("A blue square".to_string()),
result: "cG5n".to_string(),
saved_path: Some(saved_path),
})]
);
}
#[test]
fn rebuilds_command_execution_item_from_persisted_completion() {
let turn_id = "turn-1";
@@ -2058,13 +2260,13 @@ mod tests {
text_elements: Vec::new(),
}],
},
ThreadItem::ImageGeneration {
ThreadItem::ImageGeneration(ImageGenerationItem {
id: "ig_123".into(),
status: "completed".into(),
revised_prompt: Some("final prompt".into()),
result: "Zm9v".into(),
saved_path: Some(test_path_buf("/tmp/ig_123.png").abs()),
},
}),
],
}
);
@@ -2492,14 +2694,14 @@ mod tests {
assert_eq!(turns[0].items.len(), 4);
assert_eq!(
turns[0].items[1],
ThreadItem::WebSearch {
ThreadItem::WebSearch(WebSearchItem {
id: "search-1".into(),
query: "codex".into(),
action: Some(WebSearchAction::Search {
query: Some("codex".into()),
queries: None,
}),
}
})
);
assert_eq!(
turns[0].items[2],
@@ -3825,6 +4027,37 @@ mod tests {
);
}
#[test]
fn canonical_hook_prompt_completion_updates_turn_history() {
let hook_prompt = CoreTurnItem::HookPrompt(codex_protocol::items::HookPromptItem {
id: "hook-prompt-1".into(),
fragments: vec![CoreHookPromptFragment::from_single_hook(
"Retry with tests.",
"hook-run-1",
)],
});
let expected_item = ThreadItem::from(hook_prompt.clone());
let mut builder = ThreadHistoryBuilder::new();
builder.handle_event(&EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-a".into(),
trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
}));
builder.handle_event(&EventMsg::ItemCompleted(ItemCompletedEvent {
thread_id: ThreadId::new(),
turn_id: "turn-a".into(),
item: hook_prompt,
completed_at_ms: 0,
}));
assert_eq!(
builder.active_turn_snapshot().expect("active turn").items,
vec![expected_item]
);
}
#[test]
fn ignores_plain_user_response_items_in_rollout_replay() {
let items = vec![
@@ -3872,7 +4105,6 @@ mod tests {
..Default::default()
}),
));
assert_eq!(
changes,
ThreadHistoryChangeSet {
@@ -3919,20 +4151,19 @@ mod tests {
},
}),
));
assert_eq!(
changes,
ThreadHistoryChangeSet {
changed_items: vec![ThreadHistoryItemChange {
turn_id: "rollout-0".into(),
item: ThreadItem::WebSearch {
item: ThreadItem::WebSearch(WebSearchItem {
id: "search-1".into(),
query: "codex".into(),
action: Some(WebSearchAction::Search {
query: Some("codex".into()),
queries: None,
}),
},
}),
}],
changed_turns: Vec::new(),
removed_turn_ids: Vec::new(),
@@ -3954,7 +4185,6 @@ mod tests {
text: "raw content".into(),
}),
));
assert_eq!(
changes,
ThreadHistoryChangeSet {
@@ -4054,20 +4284,19 @@ mod tests {
},
})),
]);
assert_eq!(
changes,
ThreadHistoryChangeSet {
changed_items: vec![ThreadHistoryItemChange {
turn_id: "rollout-0".into(),
item: ThreadItem::WebSearch {
item: ThreadItem::WebSearch(WebSearchItem {
id: "search-1".into(),
query: "codex".into(),
action: Some(WebSearchAction::Search {
query: Some("codex".into()),
queries: None,
}),
},
}),
}],
changed_turns: vec![ThreadHistoryTurnChange {
turn_id: "rollout-0".into(),
@@ -78,6 +78,11 @@ pub enum LoginAccountParams {
Chatgpt {
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
codex_streamlined_login: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
use_hosted_login_success_page: bool,
#[serde(default)]
#[ts(optional = nullable)]
app_brand: Option<LoginAppBrand>,
},
#[serde(rename = "chatgptDeviceCode")]
#[ts(rename = "chatgptDeviceCode")]
@@ -100,6 +105,21 @@ pub enum LoginAccountParams {
#[ts(optional = nullable)]
chatgpt_plan_type: Option<String>,
},
/// [UNSTABLE] Managed Amazon Bedrock login is experimental.
#[experimental("account/login/start.amazonBedrock")]
#[serde(rename = "amazonBedrock", rename_all = "camelCase")]
#[ts(rename = "amazonBedrock", rename_all = "camelCase")]
AmazonBedrock { api_key: String, region: String },
}
#[derive(Serialize, Deserialize, Debug, Default, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "lowercase")]
#[ts(rename_all = "lowercase")]
#[ts(export_to = "v2/")]
pub enum LoginAppBrand {
#[default]
Codex,
Chatgpt,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -133,6 +153,9 @@ pub enum LoginAccountResponse {
#[serde(rename = "chatgptAuthTokens", rename_all = "camelCase")]
#[ts(rename = "chatgptAuthTokens", rename_all = "camelCase")]
ChatgptAuthTokens {},
#[serde(rename = "amazonBedrock", rename_all = "camelCase")]
#[ts(rename = "amazonBedrock", rename_all = "camelCase")]
AmazonBedrock {},
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -10,7 +10,12 @@ use super::UserInput;
use super::shared::v2_enum_from_core;
use crate::protocol::item_builders::command_actions_for_path_uri;
use crate::protocol::item_builders::convert_patch_changes;
use crate::protocol::item_builders::review_output_text;
use codex_experimental_api_macros::ExperimentalApi;
use codex_extension_items::ExtensionItem;
pub use codex_extension_items::image_generation::ImageGenerationItem;
pub use codex_extension_items::web_search::WebSearchAction;
pub use codex_extension_items::web_search::WebSearchItem;
use codex_protocol::approvals::GuardianAssessmentAction as CoreGuardianAssessmentAction;
use codex_protocol::approvals::GuardianAssessmentDecisionSource as CoreGuardianAssessmentDecisionSource;
use codex_protocol::approvals::GuardianCommandSource as CoreGuardianCommandSource;
@@ -246,7 +251,10 @@ pub enum ThreadItem {
#[ts(rename_all = "camelCase")]
/// EXPERIMENTAL - proposed plan item content. The completed plan item is
/// authoritative and may not match the concatenation of `PlanDelta` text.
Plan { id: String, text: String },
Plan {
id: String,
text: String,
},
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
Reasoning {
@@ -353,13 +361,7 @@ pub enum ThreadItem {
agent_thread_id: String,
agent_path: String,
},
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
WebSearch {
id: String,
query: String,
action: Option<WebSearchAction>,
},
WebSearch(WebSearchItem),
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
ImageView {
@@ -373,26 +375,24 @@ pub enum ThreadItem {
#[ts(type = "number")]
duration_ms: u64,
},
ImageGeneration(ImageGenerationItem),
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
ImageGeneration {
EnteredReviewMode {
id: String,
status: String,
revised_prompt: Option<String>,
result: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
saved_path: Option<AbsolutePathBuf>,
review: String,
},
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
EnteredReviewMode { id: String, review: String },
ExitedReviewMode {
id: String,
review: String,
},
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
ExitedReviewMode { id: String, review: String },
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
ContextCompaction { id: String },
ContextCompaction {
id: String,
},
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
@@ -429,13 +429,13 @@ impl ThreadItem {
| ThreadItem::DynamicToolCall { id, .. }
| ThreadItem::CollabAgentToolCall { id, .. }
| ThreadItem::SubAgentActivity { id, .. }
| ThreadItem::WebSearch { id, .. }
| ThreadItem::ImageView { id, .. }
| ThreadItem::Sleep { id, .. }
| ThreadItem::ImageGeneration { id, .. }
| ThreadItem::EnteredReviewMode { id, .. }
| ThreadItem::ExitedReviewMode { id, .. }
| ThreadItem::ContextCompaction { id, .. } => id,
ThreadItem::WebSearch(item) => &item.id,
ThreadItem::ImageGeneration(item) => &item.id,
}
}
}
@@ -783,40 +783,20 @@ impl TryFrom<GuardianApprovalReviewAction> for CoreGuardianAssessmentAction {
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "camelCase")]
#[ts(tag = "type", rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub enum WebSearchAction {
Search {
query: Option<String>,
queries: Option<Vec<String>>,
},
OpenPage {
url: Option<String>,
},
FindInPage {
url: Option<String>,
pattern: Option<String>,
},
#[serde(other)]
Other,
}
impl From<codex_protocol::models::WebSearchAction> for WebSearchAction {
fn from(value: codex_protocol::models::WebSearchAction) -> Self {
match value {
codex_protocol::models::WebSearchAction::Search { query, queries } => {
WebSearchAction::Search { query, queries }
}
codex_protocol::models::WebSearchAction::OpenPage { url } => {
WebSearchAction::OpenPage { url }
}
codex_protocol::models::WebSearchAction::FindInPage { url, pattern } => {
WebSearchAction::FindInPage { url, pattern }
}
codex_protocol::models::WebSearchAction::Other => WebSearchAction::Other,
pub(crate) fn web_search_action_from_core(
value: codex_protocol::models::WebSearchAction,
) -> WebSearchAction {
match value {
codex_protocol::models::WebSearchAction::Search { query, queries } => {
WebSearchAction::Search { query, queries }
}
codex_protocol::models::WebSearchAction::OpenPage { url } => {
WebSearchAction::OpenPage { url }
}
codex_protocol::models::WebSearchAction::FindInPage { url, pattern } => {
WebSearchAction::FindInPage { url, pattern }
}
codex_protocol::models::WebSearchAction::Other => WebSearchAction::Other,
}
}
@@ -918,11 +898,11 @@ impl From<CoreTurnItem> for ThreadItem {
agent_thread_id: activity.agent_thread_id.to_string(),
agent_path: String::from(activity.agent_path),
},
CoreTurnItem::WebSearch(search) => ThreadItem::WebSearch {
CoreTurnItem::WebSearch(search) => ThreadItem::WebSearch(WebSearchItem {
id: search.id,
query: search.query,
action: Some(WebSearchAction::from(search.action)),
},
action: Some(web_search_action_from_core(search.action)),
}),
CoreTurnItem::ImageView(image) => ThreadItem::ImageView {
id: image.id,
path: image.path.into(),
@@ -931,12 +911,26 @@ impl From<CoreTurnItem> for ThreadItem {
id: sleep.id,
duration_ms: sleep.duration_ms,
},
CoreTurnItem::ImageGeneration(image) => ThreadItem::ImageGeneration {
id: image.id,
status: image.status,
revised_prompt: image.revised_prompt,
result: image.result,
saved_path: image.saved_path,
CoreTurnItem::Extension(extension) => match extension {
ExtensionItem::ImageGeneration(item) => ThreadItem::ImageGeneration(item),
ExtensionItem::WebSearch(item) => ThreadItem::WebSearch(item),
},
CoreTurnItem::ImageGeneration(image) => {
ThreadItem::ImageGeneration(ImageGenerationItem {
id: image.id,
status: image.status,
revised_prompt: image.revised_prompt,
result: image.result,
saved_path: image.saved_path,
})
}
CoreTurnItem::EnteredReviewMode(review) => ThreadItem::EnteredReviewMode {
id: review.id,
review: review.user_facing_hint,
},
CoreTurnItem::ExitedReviewMode(review) => ThreadItem::ExitedReviewMode {
id: review.id,
review: review_output_text(review.review_output.as_ref()),
},
CoreTurnItem::FileChange(file_change) => ThreadItem::FileChange {
id: file_change.id,
@@ -19,7 +19,7 @@ use codex_protocol::items::ReasoningItem;
use codex_protocol::items::SubAgentActivityItem;
use codex_protocol::items::TurnItem;
use codex_protocol::items::UserMessageItem;
use codex_protocol::items::WebSearchItem;
use codex_protocol::items::WebSearchItem as CoreWebSearchItem;
use codex_protocol::mcp::CallToolResult;
use codex_protocol::mcp::McpServerInfo;
use codex_protocol::memory_citation::MemoryCitation as CoreMemoryCitation;
@@ -2770,7 +2770,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
}
);
let search_item = TurnItem::WebSearch(WebSearchItem {
let search_item = TurnItem::WebSearch(CoreWebSearchItem {
id: "search-1".to_string(),
query: "docs".to_string(),
action: CoreWebSearchAction::Search {
@@ -2779,16 +2779,24 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
},
});
let expected_search_item = WebSearchItem {
id: "search-1".to_string(),
query: "docs".to_string(),
action: Some(WebSearchAction::Search {
query: Some("docs".to_string()),
queries: None,
}),
};
assert_eq!(
ThreadItem::from(search_item),
ThreadItem::WebSearch {
id: "search-1".to_string(),
query: "docs".to_string(),
action: Some(WebSearchAction::Search {
query: Some("docs".to_string()),
queries: None,
}),
}
ThreadItem::WebSearch(expected_search_item.clone())
);
assert_eq!(
ThreadItem::from(TurnItem::Extension(
codex_extension_items::ExtensionItem::WebSearch(expected_search_item.clone()),
)),
ThreadItem::WebSearch(expected_search_item)
);
let image_view_item = TurnItem::ImageView(ImageViewItem {
@@ -1722,7 +1722,9 @@ impl CodexClient {
let request = ClientRequest::LoginAccount {
request_id: request_id.clone(),
params: codex_app_server_protocol::LoginAccountParams::Chatgpt {
app_brand: None,
codex_streamlined_login: false,
use_hosted_login_success_page: false,
},
};
+1
View File
@@ -5,6 +5,7 @@ codex_rust_crate(
crate_name = "codex_app_server",
extra_binaries = [
"//codex-rs/bwrap:bwrap",
"//codex-rs/code-mode-host:codex-code-mode-host",
],
extra_binaries_non_windows = [
"//codex-rs/cli:codex",
+5
View File
@@ -12,6 +12,10 @@ path = "src/main.rs"
name = "codex-app-server-test-notify-capture"
path = "src/bin/notify_capture.rs"
[[bin]]
name = "exec-server"
path = "src/bin/exec_server.rs"
[lib]
name = "codex_app_server"
path = "src/lib.rs"
@@ -47,6 +51,7 @@ codex-guardian = { workspace = true }
codex-git-utils = { workspace = true }
codex-file-watcher = { workspace = true }
codex-hooks = { workspace = true }
codex-http-client = { workspace = true }
codex-otel = { workspace = true }
codex-plugin = { workspace = true }
codex-shell-command = { workspace = true }
+32 -2
View File
@@ -1921,16 +1921,17 @@ Codex supports these authentication modes. The current mode is surfaced in `acco
- **API key (`apiKey`)**: Caller supplies an OpenAI API key via `account/login/start` with `type: "apiKey"`. The API key is saved and used for API requests.
- **ChatGPT managed (`chatgpt`)** (recommended): Codex owns the ChatGPT OAuth flow and refresh tokens. Start via `account/login/start` with `type: "chatgpt"` for the browser flow or `type: "chatgptDeviceCode"` for device code; Codex persists tokens to disk and refreshes them automatically.
- **Codex managed Amazon Bedrock auth (`amazonBedrock`, experimental)**: Caller supplies an Amazon Bedrock API key and region via `account/login/start` with `type: "amazonBedrock"`. The client must enable the `experimentalApi` initialization capability for Codex-managed Amazon Bedrock login. Codex replaces the current primary auth with the Bedrock credential and writes `model_provider = "amazon-bedrock"` to the user config.
- **Personal access token (`personalAccessToken`)**: Codex uses a ChatGPT-backed personal access token loaded outside the app-server login RPCs, such as with `codex login --with-access-token` or `CODEX_ACCESS_TOKEN`.
### API Overview
- `account/read` — fetch current account info; optionally refresh tokens.
- `account/login/start` — begin login (`apiKey`, `chatgpt`, `chatgptDeviceCode`).
- `account/login/start` — begin login (`apiKey`, `chatgpt`, `chatgptDeviceCode`, `amazonBedrock`).
- `account/login/completed` (notify) — emitted when a login attempt finishes (success or error).
- `account/login/cancel` — cancel a pending managed ChatGPT login by `loginId`.
- `account/logout` — sign out; triggers `account/updated`.
- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, `personalAccessToken`, or `null`) and includes the current ChatGPT `planType` when available.
- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `bedrockApiKey`, `chatgpt`, `personalAccessToken`, or `null`) and includes the current ChatGPT `planType` when available.
- `account/rateLimits/read` — fetch ChatGPT rate limits, an optional effective monthly credit limit, and the earned rate-limit resets currently available, including expiry details when provided by the backend. Rate-limit updates arrive via `account/rateLimits/updated` (notify); reset-credit data is snapshot-only.
- `account/rateLimitResetCredit/consume` — consume one earned reset using a caller-provided idempotency key, optionally selecting a reset-credit ID returned by `account/rateLimits/read`.
- `account/usage/read` — fetch ChatGPT account token-activity summary and daily buckets.
@@ -1995,12 +1996,41 @@ Field notes:
{ "id": 3, "result": { "type": "chatgpt", "loginId": "<uuid>", "authUrl": "https://chatgpt.com/…&redirect_uri=http%3A%2F%2Flocalhost%3A<port>%2Fauth%2Fcallback" } }
```
2. Open `authUrl` in a browser; the app-server hosts the local callback.
By default, a successful callback redirects to the local success page. Clients may set
`useHostedLoginSuccessPage: true` to redirect successful callbacks that do not require
organization setup to the hosted Codex success page instead. When hosted login success is
enabled, clients may set `appBrand` to `"codex"` or `"chatgpt"` to select the matching hosted
page artwork; omitted or `null` values default to `"codex"`.
3. Wait for notifications:
```json
{ "method": "account/login/completed", "params": { "loginId": "<uuid>", "success": true, "error": null } }
{ "method": "account/updated", "params": { "authMode": "chatgpt", "planType": "plus" } }
```
### 3) Log in with an Amazon Bedrock API key
This experimental flow requires the client to initialize with `experimentalApi: true`.
1. Send:
```json
{
"method": "account/login/start",
"id": 3,
"params": { "type": "amazonBedrock", "apiKey": "…", "region": "us-west-2" }
}
```
2. Expect:
```json
{ "id": 3, "result": { "type": "amazonBedrock" } }
```
3. Notifications:
```json
{ "method": "account/login/completed", "params": { "loginId": null, "success": true, "error": null } }
{ "method": "account/updated", "params": { "authMode": "bedrockApiKey", "planType": null } }
```
Codex stores the key and region as the primary Codex auth, replacing any previously stored login, and writes `model_provider = "amazon-bedrock"` to the active user config. Existing loaded sessions keep their current provider selection, so clients should restart the app-server before sending more model requests. This limitation will be addressed in a follow-up.
### 4) Log in with ChatGPT (device code flow)
1. Start:
+1
View File
@@ -12,6 +12,7 @@ pub(crate) fn auth_mode_to_api(auth_mode: AuthMode) -> ApiAuthMode {
AuthMode::ApiKey => ApiAuthMode::ApiKey,
AuthMode::Chatgpt => ApiAuthMode::Chatgpt,
AuthMode::ChatgptAuthTokens => ApiAuthMode::ChatgptAuthTokens,
AuthMode::Headers => ApiAuthMode::Headers,
AuthMode::AgentIdentity => ApiAuthMode::AgentIdentity,
AuthMode::PersonalAccessToken => ApiAuthMode::PersonalAccessToken,
AuthMode::BedrockApiKey => ApiAuthMode::BedrockApiKey,
+84 -249
View File
@@ -86,11 +86,9 @@ use codex_app_server_protocol::guardian_auto_approval_review_notification;
use codex_app_server_protocol::item_event_to_server_notification;
use codex_core::CodexThread;
use codex_core::ThreadManager;
use codex_core::review_format::format_review_findings_block;
use codex_core::review_prompts;
use codex_protocol::ThreadId;
use codex_protocol::items::CollabAgentTool as CoreCollabAgentTool;
use codex_protocol::items::TurnItem as CoreTurnItem;
use codex_protocol::items::parse_hook_prompt_message;
use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile;
use codex_protocol::plan_tool::UpdatePlanArgs;
use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo;
@@ -100,7 +98,6 @@ use codex_protocol::protocol::ExecApprovalRequestEvent;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::RealtimeEvent;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::ReviewOutputEvent;
use codex_protocol::protocol::SubAgentActivityKind;
use codex_protocol::protocol::TokenCountEvent;
use codex_protocol::protocol::TurnAbortedEvent;
@@ -822,25 +819,34 @@ pub(crate) async fn apply_bespoke_event_handling(
on_request_permissions_response(pending_response, conversation, thread_state).await;
});
}
EventMsg::DynamicToolCallRequest(_) | EventMsg::DynamicToolCallResponse(_) => {
// Deprecated dynamic-tool events are still fanned out for raw-event and rollout
// compatibility consumers. App-server v2 receives the canonical DynamicToolCall
// item lifecycle and dispatches client requests from canonical starts instead.
}
EventMsg::McpToolCallBegin(_) | EventMsg::McpToolCallEnd(_) => {
// Deprecated MCP tool-call events are still fanned out for legacy clients.
// App-server v2 receives the canonical TurnItem::McpToolCall lifecycle instead.
}
msg @ (EventMsg::CollabAgentSpawnBegin(_)
EventMsg::DynamicToolCallRequest(_)
| EventMsg::DynamicToolCallResponse(_)
| EventMsg::CollabAgentSpawnBegin(_)
| EventMsg::CollabAgentSpawnEnd(_)
| EventMsg::CollabAgentInteractionBegin(_)
| EventMsg::CollabAgentInteractionEnd(_)
| EventMsg::CollabWaitingBegin(_)
| EventMsg::CollabWaitingEnd(_)
| EventMsg::CollabCloseBegin(_)
| EventMsg::CollabCloseEnd(_)
| EventMsg::CollabResumeBegin(_)
| EventMsg::CollabResumeEnd(_)
| EventMsg::AgentMessageContentDelta(_)
| EventMsg::SubAgentActivity(_)
| EventMsg::ExecCommandBegin(_)
| EventMsg::ExecCommandEnd(_)
| EventMsg::EnteredReviewMode(_)
| EventMsg::ExitedReviewMode(_) => {
// Deprecated item lifecycle events are still fanned out for raw-event and rollout
// compatibility consumers.
// App-server v2 receives TurnItem lifecycle instead, and dispatches dynamic tool
// requests from DynamicToolCall starts.
}
EventMsg::McpToolCallBegin(_) | EventMsg::McpToolCallEnd(_) => {
// Deprecated MCP tool-call events are still fanned out for raw-event and rollout
// compatibility consumers.
// App-server v2 receives the canonical TurnItem::McpToolCall lifecycle instead.
}
msg @ (EventMsg::AgentMessageContentDelta(_)
| EventMsg::PlanDelta(_)
| EventMsg::ReasoningContentDelta(_)
| EventMsg::ReasoningRawContentDelta(_)
@@ -852,43 +858,9 @@ pub(crate) async fn apply_bespoke_event_handling(
);
outgoing.send_server_notification(notification).await;
}
EventMsg::SubAgentActivity(activity) => {
if activity.kind == SubAgentActivityKind::Interrupted
&& thread_manager
.get_thread(activity.agent_thread_id)
.await
.is_err()
{
thread_watch_manager
.remove_thread(&activity.agent_thread_id.to_string())
.await;
}
let notification = item_event_to_server_notification(
EventMsg::SubAgentActivity(activity),
&conversation_id.to_string(),
&event_turn_id,
);
outgoing.send_server_notification(notification).await;
}
EventMsg::CollabCloseEnd(end_event) => {
if thread_manager
.get_thread(end_event.receiver_thread_id)
.await
.is_err()
{
thread_watch_manager
.remove_thread(&end_event.receiver_thread_id.to_string())
.await;
}
let notification = item_event_to_server_notification(
EventMsg::CollabCloseEnd(end_event),
&conversation_id.to_string(),
&event_turn_id,
);
outgoing.send_server_notification(notification).await;
}
EventMsg::ContextCompacted(..) => {
// Core still fans out this deprecated event for legacy clients;
// Core still fans out this deprecated event for raw-event and rollout compatibility
// consumers;
// v2 clients receive the canonical ContextCompaction item instead.
}
EventMsg::DeprecationNotice(event) => {
@@ -964,33 +936,6 @@ pub(crate) async fn apply_bespoke_event_handling(
.await;
}
EventMsg::ViewImageToolCall(_) => {}
EventMsg::EnteredReviewMode(review_request) => {
let review = review_request
.user_facing_hint
.unwrap_or_else(|| review_prompts::user_facing_hint(&review_request.target));
let item = ThreadItem::EnteredReviewMode {
id: event_turn_id.clone(),
review,
};
let started = ItemStartedNotification {
thread_id: conversation_id.to_string(),
turn_id: event_turn_id.clone(),
started_at_ms: now_unix_timestamp_ms(),
item: item.clone(),
};
outgoing
.send_server_notification(ServerNotification::ItemStarted(started))
.await;
let completed = ItemCompletedNotification {
thread_id: conversation_id.to_string(),
turn_id: event_turn_id.clone(),
completed_at_ms: now_unix_timestamp_ms(),
item,
};
outgoing
.send_server_notification(ServerNotification::ItemCompleted(completed))
.await;
}
EventMsg::ItemStarted(event) => {
let should_emit = match &event.item {
// Approval and guardian flows can emit the command start notification before core
@@ -1033,7 +978,13 @@ pub(crate) async fn apply_bespoke_event_handling(
}
}
EventMsg::ItemCompleted(event) => {
apply_canonical_item_completed_side_effects(&thread_state, &event.item).await;
apply_canonical_item_completed_side_effects(
&thread_manager,
&thread_watch_manager,
&thread_state,
&event.item,
)
.await;
let notification = item_event_to_server_notification(
EventMsg::ItemCompleted(event),
&conversation_id.to_string(),
@@ -1069,42 +1020,7 @@ pub(crate) async fn apply_bespoke_event_handling(
.send_server_notification(ServerNotification::HookCompleted(notification))
.await;
}
EventMsg::ExitedReviewMode(review_event) => {
let review = match review_event.review_output {
Some(output) => render_review_output_text(&output),
None => REVIEW_FALLBACK_MESSAGE.to_string(),
};
let item = ThreadItem::ExitedReviewMode {
id: event_turn_id.clone(),
review,
};
let started = ItemStartedNotification {
thread_id: conversation_id.to_string(),
turn_id: event_turn_id.clone(),
started_at_ms: now_unix_timestamp_ms(),
item: item.clone(),
};
outgoing
.send_server_notification(ServerNotification::ItemStarted(started))
.await;
let completed = ItemCompletedNotification {
thread_id: conversation_id.to_string(),
turn_id: event_turn_id.clone(),
completed_at_ms: now_unix_timestamp_ms(),
item,
};
outgoing
.send_server_notification(ServerNotification::ItemCompleted(completed))
.await;
}
EventMsg::RawResponseItem(raw_response_item_event) => {
maybe_emit_hook_prompt_item_completed(
conversation_id,
&event_turn_id,
&raw_response_item_event.item,
&outgoing,
)
.await;
maybe_emit_raw_response_item_completed(
conversation_id,
&event_turn_id,
@@ -1114,14 +1030,10 @@ pub(crate) async fn apply_bespoke_event_handling(
.await;
}
EventMsg::PatchApplyBegin(_) | EventMsg::PatchApplyEnd(_) => {
// Core still fans out these deprecated events for legacy clients;
// Core still fans out these deprecated events for raw-event and rollout compatibility
// consumers;
// v2 clients receive the canonical FileChange item instead.
}
EventMsg::ExecCommandBegin(_) | EventMsg::ExecCommandEnd(_) => {
// Deprecated command-execution events are still fanned out for raw-event and rollout
// compatibility consumers. App-server v2 receives the canonical CommandExecution
// item lifecycle instead.
}
EventMsg::ExecCommandOutputDelta(exec_command_output_delta_event) => {
let notification = item_event_to_server_notification(
EventMsg::ExecCommandOutputDelta(exec_command_output_delta_event),
@@ -1334,16 +1246,48 @@ async fn emit_turn_completed_with_status(
}
async fn apply_canonical_item_completed_side_effects(
thread_manager: &Arc<ThreadManager>,
thread_watch_manager: &ThreadWatchManager,
thread_state: &Arc<Mutex<ThreadState>>,
item: &CoreTurnItem,
) {
if let CoreTurnItem::CommandExecution(item) = item {
thread_state
.lock()
.await
.turn_summary
.command_execution_started
.remove(&item.id);
match item {
CoreTurnItem::CommandExecution(item) => {
thread_state
.lock()
.await
.turn_summary
.command_execution_started
.remove(&item.id);
}
CoreTurnItem::SubAgentActivity(activity)
if activity.kind == SubAgentActivityKind::Interrupted =>
{
remove_missing_thread_watch(
thread_manager,
thread_watch_manager,
activity.agent_thread_id,
)
.await;
}
CoreTurnItem::CollabAgentToolCall(item) if item.tool == CoreCollabAgentTool::CloseAgent => {
for thread_id in &item.receiver_thread_ids {
remove_missing_thread_watch(thread_manager, thread_watch_manager, *thread_id).await;
}
}
_ => {}
}
}
async fn remove_missing_thread_watch(
thread_manager: &Arc<ThreadManager>,
thread_watch_manager: &ThreadWatchManager,
thread_id: ThreadId,
) {
if thread_manager.get_thread(thread_id).await.is_err() {
thread_watch_manager
.remove_thread(&thread_id.to_string())
.await;
}
}
@@ -1454,45 +1398,6 @@ async fn maybe_emit_raw_response_item_completed(
.await;
}
pub(crate) async fn maybe_emit_hook_prompt_item_completed(
conversation_id: ThreadId,
turn_id: &str,
item: &codex_protocol::models::ResponseItem,
outgoing: &ThreadScopedOutgoingMessageSender,
) {
let codex_protocol::models::ResponseItem::Message {
role, content, id, ..
} = item
else {
return;
};
if role != "user" {
return;
}
let Some(hook_prompt) = parse_hook_prompt_message(id.as_ref(), content) else {
return;
};
let notification = ItemCompletedNotification {
thread_id: conversation_id.to_string(),
turn_id: turn_id.to_string(),
completed_at_ms: now_unix_timestamp_ms(),
item: ThreadItem::HookPrompt {
id: hook_prompt.id,
fragments: hook_prompt
.fragments
.into_iter()
.map(codex_app_server_protocol::HookPromptFragment::from)
.collect(),
},
};
outgoing
.send_server_notification(ServerNotification::ItemCompleted(notification))
.await;
}
async fn find_and_remove_turn_summary(
_conversation_id: ThreadId,
thread_state: &Arc<Mutex<ThreadState>>,
@@ -1944,28 +1849,6 @@ fn request_permissions_response_from_client_result(
}))
}
const REVIEW_FALLBACK_MESSAGE: &str = "Reviewer failed to output a response.";
fn render_review_output_text(output: &ReviewOutputEvent) -> String {
let mut sections = Vec::new();
let explanation = output.overall_explanation.trim();
if !explanation.is_empty() {
sections.push(explanation.to_string());
}
if !output.findings.is_empty() {
let findings = format_review_findings_block(&output.findings, /*selection*/ None);
let trimmed = findings.trim();
if !trimmed.is_empty() {
sections.push(trimmed.to_string());
}
}
if sections.is_empty() {
REVIEW_FALLBACK_MESSAGE.to_string()
} else {
sections.join("\n\n")
}
}
fn map_file_change_approval_decision(decision: FileChangeApprovalDecision) -> ReviewDecision {
match decision {
FileChangeApprovalDecision::Accept => ReviewDecision::Approved,
@@ -2174,9 +2057,8 @@ mod tests {
use codex_protocol::AgentPath;
use codex_protocol::items::DynamicToolCallItem;
use codex_protocol::items::DynamicToolCallStatus as CoreDynamicToolCallStatus;
use codex_protocol::items::HookPromptFragment;
use codex_protocol::items::SubAgentActivityItem;
use codex_protocol::items::TurnItem as CoreTurnItem;
use codex_protocol::items::build_hook_prompt_message;
use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions;
use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions;
use codex_protocol::models::PermissionProfile;
@@ -2192,12 +2074,12 @@ mod tests {
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::GuardianAssessmentEvent;
use codex_protocol::protocol::GuardianAssessmentStatus;
use codex_protocol::protocol::ItemCompletedEvent;
use codex_protocol::protocol::ItemStartedEvent;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::RateLimitWindow;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentActivityEvent;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::TokenUsageInfo;
use codex_protocol::protocol::UserMessageEvent;
@@ -3420,13 +3302,17 @@ mod tests {
apply_bespoke_event_handling(
Event {
id: "turn-1".to_string(),
msg: EventMsg::SubAgentActivity(SubAgentActivityEvent {
event_id: "activity-1".to_string(),
occurred_at_ms: 42,
agent_thread_id: child_thread_id,
agent_path: AgentPath::try_from("/root/worker")
.expect("agent path should parse"),
kind: SubAgentActivityKind::Interrupted,
msg: EventMsg::ItemCompleted(ItemCompletedEvent {
thread_id: conversation_id,
turn_id: "turn-1".to_string(),
item: CoreTurnItem::SubAgentActivity(SubAgentActivityItem {
id: "activity-1".to_string(),
kind: SubAgentActivityKind::Interrupted,
agent_thread_id: child_thread_id,
agent_path: AgentPath::try_from("/root/worker")
.expect("agent path should parse"),
}),
completed_at_ms: 42,
}),
},
conversation_id,
@@ -4058,55 +3944,4 @@ mod tests {
assert!(rx.try_recv().is_err(), "no extra messages expected");
Ok(())
}
#[tokio::test]
async fn test_hook_prompt_raw_response_emits_item_completed() -> Result<()> {
let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY);
let outgoing = Arc::new(OutgoingMessageSender::new(
tx,
codex_analytics::AnalyticsEventsClient::disabled(),
));
let conversation_id = ThreadId::new();
let outgoing = ThreadScopedOutgoingMessageSender::new(
outgoing,
vec![ConnectionId(1)],
conversation_id,
);
let item = build_hook_prompt_message(&[
HookPromptFragment::from_single_hook("Retry with tests.", "hook-run-1"),
HookPromptFragment::from_single_hook("Then summarize cleanly.", "hook-run-2"),
])
.expect("hook prompt message");
maybe_emit_hook_prompt_item_completed(conversation_id, "turn-1", &item, &outgoing).await;
let msg = recv_broadcast_message(&mut rx).await?;
match msg {
OutgoingMessage::AppServerNotification(ServerNotification::ItemCompleted(
notification,
)) => {
assert_eq!(notification.thread_id, conversation_id.to_string());
assert_eq!(notification.turn_id, "turn-1");
assert_eq!(
notification.item,
ThreadItem::HookPrompt {
id: notification.item.id().to_string(),
fragments: vec![
codex_app_server_protocol::HookPromptFragment {
text: "Retry with tests.".into(),
hook_run_id: "hook-run-1".into(),
},
codex_app_server_protocol::HookPromptFragment {
text: "Then summarize cleanly.".into(),
hook_run_id: "hook-run-2".into(),
},
],
}
);
}
other => bail!("unexpected message: {other:?}"),
}
assert!(rx.try_recv().is_err(), "no extra messages expected");
Ok(())
}
}
@@ -0,0 +1,31 @@
//! Cargo entry point for the minimal exec-server integration-test fixture.
//!
//! This mirrors `//codex-rs/exec-server/testing:exec-server` so Cargo-backed
//! app-server integration tests can receive `CARGO_BIN_EXE_exec-server`. It
//! also handles the filesystem-helper argv mode because exec-server re-execs
//! `codex_self_exe` for sandboxed filesystem requests.
use codex_exec_server::ExecServerRuntimePaths;
use std::ffi::OsStr;
const CODEX_LINUX_SANDBOX_EXE_ENV_VAR: &str = "CODEX_TEST_LINUX_SANDBOX_EXE";
fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut args = std::env::args_os();
let _ = args.next();
if args.next().as_deref() == Some(OsStr::new(codex_exec_server::CODEX_FS_HELPER_ARG1)) {
codex_exec_server::run_fs_helper_main();
}
let current_exe = std::env::current_exe()?;
let codex_linux_sandbox_exe =
std::env::var_os(CODEX_LINUX_SANDBOX_EXE_ENV_VAR).map(std::path::PathBuf::from);
let runtime_paths = ExecServerRuntimePaths::new(current_exe, codex_linux_sandbox_exe)?;
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?
.block_on(codex_exec_server::run_main(
"ws://127.0.0.1:0",
runtime_paths,
))
}
@@ -1,8 +1,12 @@
use codex_analytics::AnalyticsEventsClient;
use codex_analytics::PluginInstallSource;
use codex_config::types::PluginConfig;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_core_plugins::PluginInstallError;
use codex_core_plugins::PluginInstallRequest;
use codex_core_plugins::PluginsManager;
use codex_core_plugins::marketplace::MarketplaceError;
use codex_core_plugins::marketplace::MarketplacePluginInstallPolicy;
use codex_core_plugins::marketplace::find_marketplace_manifest_path;
use codex_core_plugins::marketplace_add::MarketplaceAddRequest;
@@ -36,6 +40,7 @@ const EXTERNAL_AGENT_CONFIG_DETECT_METRIC: &str = "codex.external_agent_config.d
const EXTERNAL_AGENT_CONFIG_IMPORT_METRIC: &str = "codex.external_agent_config.import";
const EXTERNAL_AGENT_DIR: &str = ".claude";
const EXTERNAL_AGENT_CONFIG_MD: &str = "CLAUDE.md";
const EXTERNAL_AGENT_KNOWN_MARKETPLACES_PATH: &str = "plugins/known_marketplaces.json";
const EXTERNAL_OFFICIAL_MARKETPLACE_NAME: &str = "claude-plugins-official";
const EXTERNAL_OFFICIAL_MARKETPLACE_SOURCE: &str = "anthropics/claude-plugins-official";
@@ -176,14 +181,16 @@ pub(crate) struct ExternalAgentConfigMigrationItem {
pub(crate) struct ExternalAgentConfigService {
codex_home: PathBuf,
external_agent_home: PathBuf,
analytics_events_client: Option<AnalyticsEventsClient>,
}
impl ExternalAgentConfigService {
pub(crate) fn new(codex_home: PathBuf) -> Self {
pub(crate) fn new(codex_home: PathBuf, analytics_events_client: AnalyticsEventsClient) -> Self {
let external_agent_home = default_external_agent_home();
Self {
codex_home,
external_agent_home,
analytics_events_client: Some(analytics_events_client),
}
}
@@ -192,6 +199,7 @@ impl ExternalAgentConfigService {
Self {
codex_home,
external_agent_home,
analytics_events_client: None,
}
}
@@ -805,9 +813,10 @@ impl ExternalAgentConfigService {
configured_plugin_ids: &HashSet<String>,
configured_marketplace_plugins: &BTreeMap<String, HashSet<String>>,
) -> Option<ExternalAgentConfigMigrationItem> {
let import_sources = self.marketplace_import_sources(settings, source_root);
let plugin_details = extract_plugin_migration_details(
settings,
source_root,
&import_sources,
configured_plugin_ids,
configured_marketplace_plugins,
)?;
@@ -836,7 +845,7 @@ impl ExternalAgentConfigService {
);
let source_root = cwd.unwrap_or(self.external_agent_home.as_path());
let import_sources = effective_external_settings(&source_settings)?
.map(|settings| collect_marketplace_import_sources(&settings, source_root))
.map(|settings| self.marketplace_import_sources(&settings, source_root))
.unwrap_or_default();
let mut local_plugins = Vec::new();
@@ -893,7 +902,19 @@ impl ExternalAgentConfigService {
.map_err(|err| io::Error::other(format!("failed to load config: {err}")))?;
let requirements = config.config_layer_stack.requirements().clone();
let mut outcome = PluginImportOutcome::default();
let plugins_manager = PluginsManager::new(self.codex_home.clone());
let plugins_manager = PluginsManager::new(self.codex_home.clone())
.with_plugin_install_source(PluginInstallSource::ExternalAgentMigration);
if let Some(analytics_events_client) = self.analytics_events_client.clone() {
plugins_manager.set_analytics_events_client(analytics_events_client);
}
let source_settings = cwd.map_or_else(
|| self.external_agent_home.join("settings.json"),
|cwd| cwd.join(EXTERNAL_AGENT_DIR).join("settings.json"),
);
let source_root = cwd.unwrap_or(self.external_agent_home.as_path());
let import_sources = effective_external_settings(&source_settings)?
.map(|settings| self.marketplace_import_sources(&settings, source_root))
.unwrap_or_default();
for plugin_group in plugins {
let marketplace_name = plugin_group.marketplace_name.clone();
let plugin_names = plugin_group.plugin_names;
@@ -901,16 +922,7 @@ impl ExternalAgentConfigService {
.iter()
.map(|plugin_name| format!("{plugin_name}@{marketplace_name}"))
.collect::<Vec<_>>();
let source_settings = cwd.map_or_else(
|| self.external_agent_home.join("settings.json"),
|cwd| cwd.join(EXTERNAL_AGENT_DIR).join("settings.json"),
);
let source_root = cwd.unwrap_or(self.external_agent_home.as_path());
let import_source =
effective_external_settings(&source_settings)?.and_then(|settings| {
collect_marketplace_import_sources(&settings, source_root)
.remove(&marketplace_name)
});
let import_source = import_sources.get(&marketplace_name).cloned();
let Some(import_source) = import_source else {
let message = format!(
"external agent plugin marketplace source was not found: {marketplace_name}"
@@ -1009,12 +1021,21 @@ impl ExternalAgentConfigService {
Err(err) => {
let plugin_id = format!("{plugin_name}@{marketplace_name}");
outcome.failed_plugin_ids.push(plugin_id.clone());
outcome.raw_errors.push(plugin_import_raw_error(
let mut raw_error = plugin_import_raw_error(
cwd,
"plugin_import",
err.to_string(),
Some(plugin_id),
));
);
if matches!(
err,
PluginInstallError::Marketplace(
MarketplaceError::PluginNotFound { .. }
)
) {
raw_error.error_type = Some("plugin_not_found".to_string());
}
outcome.raw_errors.push(raw_error);
}
}
}
@@ -1023,6 +1044,95 @@ impl ExternalAgentConfigService {
Ok(outcome)
}
fn marketplace_import_sources(
&self,
settings: &JsonValue,
source_root: &Path,
) -> BTreeMap<String, MarketplaceImportSource> {
let known_marketplaces_path = self
.external_agent_home
.join(EXTERNAL_AGENT_KNOWN_MARKETPLACES_PATH);
let known_marketplaces = match read_external_settings(&known_marketplaces_path) {
Ok(known_marketplaces) => known_marketplaces,
Err(err) => {
tracing::warn!(
path = %known_marketplaces_path.display(),
error = %err,
"ignoring invalid external agent marketplace registry"
);
None
}
};
let mut import_sources = known_marketplaces
.as_ref()
.map(|known_marketplaces| {
collect_marketplace_import_sources(
known_marketplaces,
self.external_agent_home.as_path(),
)
})
.unwrap_or_default();
if let Some(extra_known_marketplaces) = settings
.as_object()
.and_then(|settings| settings.get("extraKnownMarketplaces"))
{
let mut scoped_marketplaces = extra_known_marketplaces.clone();
if let Some(scoped_marketplaces) = scoped_marketplaces.as_object_mut() {
for (name, scoped_marketplace) in scoped_marketplaces {
import_sources.remove(name);
let Some(known_marketplace) = known_marketplaces
.as_ref()
.and_then(JsonValue::as_object)
.and_then(|known_marketplaces| known_marketplaces.get(name))
else {
continue;
};
if scoped_marketplace.get("source") != known_marketplace.get("source") {
continue;
}
let Some(install_location) = known_marketplace
.get("installLocation")
.and_then(JsonValue::as_str)
else {
continue;
};
let install_location = Path::new(install_location);
let install_location = if install_location.is_absolute() {
install_location.to_path_buf()
} else {
self.external_agent_home.join(install_location)
};
let Some(scoped_marketplace) = scoped_marketplace.as_object_mut() else {
continue;
};
scoped_marketplace.insert(
"installLocation".to_string(),
JsonValue::String(install_location.display().to_string()),
);
}
}
import_sources.extend(collect_marketplace_import_sources(
&scoped_marketplaces,
source_root,
));
}
if has_enabled_plugin_for_marketplace(settings, EXTERNAL_OFFICIAL_MARKETPLACE_NAME)
&& !import_sources.contains_key(EXTERNAL_OFFICIAL_MARKETPLACE_NAME)
{
import_sources.insert(
EXTERNAL_OFFICIAL_MARKETPLACE_NAME.to_string(),
MarketplaceImportSource {
source: EXTERNAL_OFFICIAL_MARKETPLACE_SOURCE.to_string(),
ref_name: None,
},
);
}
import_sources
}
fn import_config(&self, cwd: Option<&Path>) -> io::Result<Option<(String, String)>> {
let repo_root = find_repo_root(cwd)?;
let (source_settings, target_config) = if let Some(repo_root) = repo_root.as_ref() {
@@ -1321,16 +1431,16 @@ fn merge_json_settings(existing: &mut JsonValue, incoming: &JsonValue) {
}
fn extract_plugin_migration_details(
settings: &JsonValue,
source_root: &Path,
import_sources: &BTreeMap<String, MarketplaceImportSource>,
configured_plugin_ids: &HashSet<String>,
configured_marketplace_plugins: &BTreeMap<String, HashSet<String>>,
) -> Option<MigrationDetails> {
let loadable_marketplaces = collect_marketplace_import_sources(settings, source_root)
.into_iter()
let loadable_marketplaces = import_sources
.iter()
.filter_map(|(marketplace_name, source)| {
is_local_marketplace_source(&source.source, source.ref_name)
is_local_marketplace_source(&source.source, source.ref_name.clone())
.ok()
.map(|_| marketplace_name)
.map(|_| marketplace_name.clone())
})
.collect::<HashSet<_>>();
let mut plugins = BTreeMap::new();
@@ -1345,9 +1455,19 @@ fn extract_plugin_migration_details(
configured_marketplace_plugins.get(&plugin_id.marketplace_name)
{
if !installable_plugins.contains(&plugin_id.plugin_name) {
tracing::warn!(
plugin_id = %plugin_id.as_key(),
marketplace_name = %plugin_id.marketplace_name,
"enabled external agent plugin was not found in configured marketplace"
);
continue;
}
} else if !loadable_marketplaces.contains(&plugin_id.marketplace_name) {
tracing::warn!(
plugin_id = %plugin_id.as_key(),
marketplace_name = %plugin_id.marketplace_name,
"marketplace source was not found for enabled external agent plugin"
);
continue;
}
let plugin_group = plugins
@@ -1444,13 +1564,11 @@ fn configured_marketplace_plugins(
}
fn collect_marketplace_import_sources(
settings: &JsonValue,
marketplaces: &JsonValue,
source_root: &Path,
) -> BTreeMap<String, MarketplaceImportSource> {
let mut import_sources: BTreeMap<String, MarketplaceImportSource> = settings
marketplaces
.as_object()
.and_then(|settings| settings.get("extraKnownMarketplaces"))
.and_then(JsonValue::as_object)
.map(|extra_known_marketplaces| {
extra_known_marketplaces
.iter()
@@ -1462,46 +1580,69 @@ fn collect_marketplace_import_sources(
} else {
value.as_object()?
};
let source = source_fields
.get("repo")
.or_else(|| source_fields.get("url"))
.or_else(|| source_fields.get("path"))
.or_else(|| value.get("source"))?
.as_str()?
.trim()
.to_string();
if source.is_empty() {
return None;
let source_kind = source_fields
.get("source")
.and_then(JsonValue::as_str)
.map(str::trim);
let declared_source = match source_kind {
Some("github") => source_fields.get("repo"),
Some("git") => source_fields.get("url"),
Some("directory" | "local") => source_fields.get("path"),
Some("file" | "url" | "npm" | "settings") => None,
Some(_) => source_fields.get("source"),
None => source_fields
.get("repo")
.or_else(|| source_fields.get("url"))
.or_else(|| source_fields.get("path"))
.or_else(|| value.get("source")),
}
let source = resolve_external_marketplace_source(&source, source_root);
let ref_name = source_fields
.get("ref")
.or_else(|| value.get("ref"))
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let materialized_source = value
.get("installLocation")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
.and_then(|value| {
let path = Path::new(value);
let path = if path.is_absolute() {
path.to_path_buf()
} else {
source_root.join(path)
};
path.is_dir().then(|| path.display().to_string())
});
let (source, ref_name) = if let Some(source) = declared_source {
let source = if matches!(source_kind, Some("directory" | "local")) {
let path = Path::new(source);
if path.is_absolute() {
path.to_path_buf()
} else {
source_root.join(path)
}
.display()
.to_string()
} else {
resolve_external_marketplace_source(source, source_root)
};
let ref_name = source_fields
.get("ref")
.or_else(|| value.get("ref"))
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
(source, ref_name)
} else {
(materialized_source?, None)
};
Some((name.clone(), MarketplaceImportSource { source, ref_name }))
})
.collect()
})
.unwrap_or_default();
if has_enabled_plugin_for_marketplace(settings, EXTERNAL_OFFICIAL_MARKETPLACE_NAME)
&& !import_sources.contains_key(EXTERNAL_OFFICIAL_MARKETPLACE_NAME)
{
import_sources.insert(
EXTERNAL_OFFICIAL_MARKETPLACE_NAME.to_string(),
MarketplaceImportSource {
source: EXTERNAL_OFFICIAL_MARKETPLACE_SOURCE.to_string(),
ref_name: None,
},
);
}
import_sources
.unwrap_or_default()
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -39,6 +39,7 @@ fn assert_single_plugin_raw_error(
raw_errors: &[ExternalAgentConfigImportRawError],
failure_stage: &str,
source: &str,
error_type: Option<&str>,
) {
assert_eq!(raw_errors.len(), 1);
let raw_error = &raw_errors[0];
@@ -47,7 +48,7 @@ fn assert_single_plugin_raw_error(
ExternalAgentConfigMigrationItemType::Plugins
);
assert_eq!(raw_error.failure_stage, failure_stage);
assert_eq!(raw_error.error_type, None);
assert_eq!(raw_error.error_type.as_deref(), error_type);
assert_eq!(raw_error.cwd, None);
assert_eq!(raw_error.source.as_deref(), Some(source));
assert!(!raw_error.message.is_empty());
@@ -1742,6 +1743,169 @@ async fn detect_home_lists_enabled_plugins_from_settings() {
);
}
#[tokio::test]
async fn detect_home_uses_materialized_known_marketplace_for_inline_npm_source() {
let (_root, external_agent_home, codex_home) = fixture_paths();
let marketplace_root = external_agent_home
.join("plugins")
.join("marketplaces")
.join("acme-tools");
fs::create_dir_all(external_agent_home.join("plugins"))
.expect("create external agent plugins dir");
fs::create_dir_all(&marketplace_root).expect("create installed marketplace dir");
fs::write(
external_agent_home.join("settings.json"),
r#"{
"enabledPlugins": {
"formatter@acme-tools": true
},
"extraKnownMarketplaces": {
"acme-tools": {
"source": {
"source": "settings",
"name": "acme-tools",
"plugins": [{
"name": "formatter",
"source": {
"source": "npm",
"package": "@acme/formatter"
}
}]
}
}
}
}"#,
)
.expect("write settings");
fs::write(
external_agent_home.join(EXTERNAL_AGENT_KNOWN_MARKETPLACES_PATH),
serde_json::to_string_pretty(&serde_json::json!({
"acme-tools": {
"source": {
"source": "settings",
"name": "acme-tools",
"plugins": [{
"name": "formatter",
"source": {
"source": "npm",
"package": "@acme/formatter",
},
}],
},
"installLocation": "plugins/marketplaces/acme-tools",
"lastUpdated": "2026-07-09T00:16:23.611Z",
}
}))
.expect("serialize known marketplaces"),
)
.expect("write known marketplaces");
let items = service_for_paths(external_agent_home.clone(), codex_home)
.detect(ExternalAgentConfigDetectOptions {
include_home: true,
cwds: None,
})
.await
.expect("detect");
assert_eq!(
items,
vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
description: format!(
"Migrate enabled plugins from {}",
external_agent_home.join("settings.json").display()
),
cwd: None,
details: Some(MigrationDetails {
plugins: vec![PluginsMigration {
marketplace_name: "acme-tools".to_string(),
plugin_names: vec!["formatter".to_string()],
}],
..Default::default()
}),
}]
);
}
#[test]
fn marketplace_import_sources_prefers_scoped_source_over_registry_name_collision() {
let (root, external_agent_home, codex_home) = fixture_paths();
let source_root = root.path().join("repo");
let scoped_marketplace = source_root.join("repo-marketplace");
let cached_marketplace = external_agent_home.join("plugins/marketplaces/debug");
fs::create_dir_all(&scoped_marketplace).expect("create scoped marketplace");
fs::create_dir_all(&cached_marketplace).expect("create cached marketplace");
fs::write(
external_agent_home.join(EXTERNAL_AGENT_KNOWN_MARKETPLACES_PATH),
serde_json::to_string_pretty(&serde_json::json!({
"debug": {
"source": {
"source": "github",
"repo": "acme/global-marketplace",
},
"installLocation": cached_marketplace,
}
}))
.expect("serialize known marketplaces"),
)
.expect("write known marketplaces");
let settings = serde_json::json!({
"extraKnownMarketplaces": {
"debug": {
"source": {
"source": "directory",
"path": "./repo-marketplace",
}
}
}
});
let import_sources = service_for_paths(external_agent_home, codex_home)
.marketplace_import_sources(&settings, &source_root);
assert_eq!(
import_sources.get("debug"),
Some(&MarketplaceImportSource {
source: source_root.join("./repo-marketplace").display().to_string(),
ref_name: None,
})
);
}
#[test]
fn marketplace_import_sources_prefers_supported_declaration_over_materialization() {
let (_root, external_agent_home, codex_home) = fixture_paths();
let cached_marketplace = external_agent_home.join("plugins/marketplaces/acme-tools");
fs::create_dir_all(&cached_marketplace).expect("create cached marketplace");
fs::write(
external_agent_home.join(EXTERNAL_AGENT_KNOWN_MARKETPLACES_PATH),
serde_json::to_string_pretty(&serde_json::json!({
"acme-tools": {
"source": {
"source": "git",
"url": "https://git.example.com/acme/tools.git",
"ref": "release",
},
"installLocation": cached_marketplace,
}
}))
.expect("serialize known marketplaces"),
)
.expect("write known marketplaces");
let import_sources = service_for_paths(external_agent_home.clone(), codex_home)
.marketplace_import_sources(&serde_json::json!({}), &external_agent_home);
assert_eq!(
import_sources.get("acme-tools"),
Some(&MarketplaceImportSource {
source: "https://git.example.com/acme/tools.git".to_string(),
ref_name: Some("release".to_string()),
})
);
}
#[tokio::test]
async fn detect_home_plugins_uses_local_settings_over_project_settings() {
let (_root, external_agent_home, codex_home) = fixture_paths();
@@ -2255,6 +2419,7 @@ async fn import_plugins_requires_source_marketplace_details() {
&outcome.raw_errors,
"plugin_import",
"formatter@other-tools",
/*error_type*/ None,
);
}
@@ -2290,7 +2455,12 @@ async fn import_plugins_defers_marketplace_source_validation_to_add_marketplace(
outcome.failed_plugin_ids,
vec!["formatter@acme-tools".to_string()]
);
assert_single_plugin_raw_error(&outcome.raw_errors, "plugin_import", "formatter@acme-tools");
assert_single_plugin_raw_error(
&outcome.raw_errors,
"plugin_import",
"formatter@acme-tools",
/*error_type*/ None,
);
}
#[tokio::test]
@@ -2610,6 +2780,7 @@ async fn import_plugins_infers_external_official_marketplace_when_missing_from_s
&outcome.raw_errors,
"plugin_import",
&format!("sample@{EXTERNAL_OFFICIAL_MARKETPLACE_NAME}"),
Some("plugin_not_found"),
);
}
+95
View File
@@ -0,0 +1,95 @@
use std::sync::Arc;
use std::sync::RwLock;
use codex_app_server_protocol::ChatgptAuthTokensRefreshParams;
use codex_app_server_protocol::ChatgptAuthTokensRefreshReason;
use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse;
use codex_app_server_protocol::ServerRequestPayload;
use codex_login::CodexAuth;
use codex_login::ExternalAuthFuture;
use codex_login::auth::ExternalAuth;
use codex_login::auth::ExternalAuthRefreshContext;
use codex_login::auth::ExternalAuthRefreshReason;
use tokio::time::Duration;
use tokio::time::timeout;
use crate::outgoing_message::OutgoingMessageSender;
const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(10);
pub(crate) struct ExternalAuthBridge {
outgoing: Arc<OutgoingMessageSender>,
auth: RwLock<CodexAuth>,
}
impl ExternalAuthBridge {
pub(crate) fn new(outgoing: Arc<OutgoingMessageSender>, auth: CodexAuth) -> Self {
Self {
outgoing,
auth: RwLock::new(auth),
}
}
async fn refresh(&self, context: ExternalAuthRefreshContext) -> std::io::Result<CodexAuth> {
let reason = match context.reason {
ExternalAuthRefreshReason::Unauthorized => ChatgptAuthTokensRefreshReason::Unauthorized,
};
let params = ChatgptAuthTokensRefreshParams {
reason,
previous_account_id: context.previous_account_id,
};
let (request_id, rx) = self
.outgoing
.send_request(ServerRequestPayload::ChatgptAuthTokensRefresh(params))
.await;
let result = match timeout(EXTERNAL_AUTH_REFRESH_TIMEOUT, rx).await {
Ok(result) => {
let result = result.map_err(|err| {
std::io::Error::other(format!("auth refresh request canceled: {err}"))
})?;
result.map_err(|err| {
std::io::Error::other(format!(
"auth refresh request failed: code={} message={}",
err.code, err.message
))
})?
}
Err(_) => {
let _canceled = self.outgoing.cancel_request(&request_id).await;
return Err(std::io::Error::other(format!(
"auth refresh request timed out after {}s",
EXTERNAL_AUTH_REFRESH_TIMEOUT.as_secs()
)));
}
};
let response: ChatgptAuthTokensRefreshResponse =
serde_json::from_value(result).map_err(std::io::Error::other)?;
let auth = CodexAuth::from_external_chatgpt_tokens(
response.access_token.as_str(),
response.chatgpt_account_id.as_str(),
response.chatgpt_plan_type.as_deref(),
)?;
*self
.auth
.write()
.map_err(|_| std::io::Error::other("external auth lock is poisoned"))? = auth.clone();
Ok(auth)
}
}
impl ExternalAuth for ExternalAuthBridge {
fn resolve(&self) -> ExternalAuthFuture<'_, CodexAuth> {
Box::pin(async {
self.auth
.read()
.map(|auth| auth.clone())
.map_err(|_| std::io::Error::other("external auth lock is poisoned"))
})
}
fn refresh(&self, context: ExternalAuthRefreshContext) -> ExternalAuthFuture<'_, CodexAuth> {
Box::pin(ExternalAuthBridge::refresh(self, context))
}
}
+1
View File
@@ -96,6 +96,7 @@ mod current_time;
mod dynamic_tools;
mod error_code;
mod extensions;
mod external_auth;
mod filters;
mod fs_watch;
mod fuzzy_file_search;
+2 -85
View File
@@ -50,9 +50,6 @@ use crate::transport::AppServerTransport;
use crate::transport::RemoteControlHandle;
use codex_analytics::AnalyticsEventsClient;
use codex_analytics::AppServerRpcTransport;
use codex_app_server_protocol::ChatgptAuthTokensRefreshParams;
use codex_app_server_protocol::ChatgptAuthTokensRefreshReason;
use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse;
use codex_app_server_protocol::ClientNotification;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ClientResponsePayload;
@@ -63,7 +60,6 @@ use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCRequest;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::ServerRequestPayload;
use codex_app_server_protocol::experimental_required_message;
use codex_arg0::Arg0DispatchPaths;
use codex_chatgpt::workspace_settings;
@@ -74,12 +70,7 @@ use codex_feedback::CodexFeedback;
use codex_goal_extension::GoalService;
use codex_home::CodexHomeUserInstructionsProvider;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_login::auth::ExternalAuth;
use codex_login::auth::ExternalAuthRefreshContext;
use codex_login::auth::ExternalAuthRefreshReason;
use codex_protocol::ThreadId;
use codex_protocol::auth::AuthMode as LoginAuthMode;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::W3cTraceContext;
use codex_rollout::StateDbHandle;
@@ -95,7 +86,6 @@ use tracing::Instrument;
use crate::models_refresh_worker::ModelsRefreshWorker;
const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(10);
const CONNECTION_RPC_DRAIN_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 30);
fn deserialize_client_request(
@@ -109,77 +99,6 @@ fn deserialize_client_request(
})
}
#[derive(Clone)]
struct ExternalAuthRefreshBridge {
outgoing: Arc<OutgoingMessageSender>,
}
impl ExternalAuthRefreshBridge {
fn map_reason(reason: ExternalAuthRefreshReason) -> ChatgptAuthTokensRefreshReason {
match reason {
ExternalAuthRefreshReason::Unauthorized => ChatgptAuthTokensRefreshReason::Unauthorized,
}
}
async fn refresh(&self, context: ExternalAuthRefreshContext) -> std::io::Result<CodexAuth> {
let params = ChatgptAuthTokensRefreshParams {
reason: Self::map_reason(context.reason),
previous_account_id: context.previous_account_id,
};
let (request_id, rx) = self
.outgoing
.send_request(ServerRequestPayload::ChatgptAuthTokensRefresh(params))
.await;
let result = match timeout(EXTERNAL_AUTH_REFRESH_TIMEOUT, rx).await {
Ok(result) => {
// Two failure scenarios:
// 1) `oneshot::Receiver` failed (sender dropped) => request canceled/channel closed.
// 2) client answered with JSON-RPC error payload => propagate code/message.
let result = result.map_err(|err| {
std::io::Error::other(format!("auth refresh request canceled: {err}"))
})?;
result.map_err(|err| {
std::io::Error::other(format!(
"auth refresh request failed: code={} message={}",
err.code, err.message
))
})?
}
Err(_) => {
let _canceled = self.outgoing.cancel_request(&request_id).await;
return Err(std::io::Error::other(format!(
"auth refresh request timed out after {}s",
EXTERNAL_AUTH_REFRESH_TIMEOUT.as_secs()
)));
}
};
let response: ChatgptAuthTokensRefreshResponse =
serde_json::from_value(result).map_err(std::io::Error::other)?;
CodexAuth::from_external_chatgpt_tokens(
response.access_token.as_str(),
response.chatgpt_account_id.as_str(),
response.chatgpt_plan_type.as_deref(),
)
}
}
impl ExternalAuth for ExternalAuthRefreshBridge {
fn auth_mode(&self) -> LoginAuthMode {
LoginAuthMode::Chatgpt
}
fn refresh(
&self,
context: ExternalAuthRefreshContext,
) -> codex_login::ExternalAuthFuture<'_, CodexAuth> {
Box::pin(ExternalAuthRefreshBridge::refresh(self, context))
}
}
pub(crate) struct MessageProcessor {
outgoing: Arc<OutgoingMessageSender>,
models_refresh_worker: ModelsRefreshWorker,
@@ -324,9 +243,6 @@ impl MessageProcessor {
remote_control_handle,
plugin_startup_tasks,
} = args;
auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge {
outgoing: outgoing.clone(),
}));
let thread_state_manager = ThreadStateManager::new();
// The thread store is intentionally process-scoped. Config reloads can
// affect per-thread behavior, but they must not move newly started,
@@ -383,7 +299,8 @@ impl MessageProcessor {
)
});
let models_manager = thread_manager.get_models_manager();
let models_refresh_worker = crate::models_refresh_worker::spawn(&models_manager);
let models_refresh_worker =
crate::models_refresh_worker::spawn(&models_manager, config.http_client_factory());
thread_manager
.plugins_manager()
.set_analytics_events_client(analytics_events_client.clone());
+3 -1
View File
@@ -5,6 +5,7 @@ use codex_app_server_protocol::ModelServiceTier;
use codex_app_server_protocol::ModelUpgradeInfo;
use codex_app_server_protocol::ReasoningEffortOption;
use codex_core::ThreadManager;
use codex_http_client::HttpClientFactory;
use codex_models_manager::manager::RefreshStrategy;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ReasoningEffortPreset;
@@ -12,9 +13,10 @@ use codex_protocol::openai_models::ReasoningEffortPreset;
pub async fn supported_models(
thread_manager: Arc<ThreadManager>,
include_hidden: bool,
http_client_factory: HttpClientFactory,
) -> Vec<Model> {
thread_manager
.list_models(RefreshStrategy::OnlineIfUncached)
.list_models(RefreshStrategy::OnlineIfUncached, http_client_factory)
.await
.into_iter()
.filter(|preset| include_hidden || preset.show_in_picker)
@@ -1,6 +1,7 @@
use std::sync::Arc;
use std::time::Duration;
use codex_http_client::HttpClientFactory;
use codex_models_manager::manager::RefreshStrategy;
use codex_models_manager::manager::SharedModelsManager;
use tokio::task::JoinHandle;
@@ -26,12 +27,16 @@ impl Drop for ModelsRefreshWorker {
}
}
pub(crate) fn spawn(models_manager: &SharedModelsManager) -> ModelsRefreshWorker {
spawn_with_interval(models_manager, MODELS_REFRESH_INTERVAL)
pub(crate) fn spawn(
models_manager: &SharedModelsManager,
http_client_factory: HttpClientFactory,
) -> ModelsRefreshWorker {
spawn_with_interval(models_manager, http_client_factory, MODELS_REFRESH_INTERVAL)
}
fn spawn_with_interval(
models_manager: &SharedModelsManager,
http_client_factory: HttpClientFactory,
refresh_interval: Duration,
) -> ModelsRefreshWorker {
let models_manager = Arc::downgrade(models_manager);
@@ -45,7 +50,9 @@ fn spawn_with_interval(
let Some(models_manager) = models_manager.upgrade() else {
break;
};
models_manager.list_models(RefreshStrategy::Online).await;
models_manager
.list_models(RefreshStrategy::Online, http_client_factory.clone())
.await;
drop(models_manager);
tokio::select! {
@@ -3,6 +3,8 @@ use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use std::time::Duration;
use codex_http_client::HttpClientFactory;
use codex_http_client::OutboundProxyPolicy;
use codex_models_manager::manager::ModelsEndpointClient;
use codex_models_manager::manager::ModelsEndpointFuture;
use codex_models_manager::manager::OpenAiModelsManager;
@@ -55,6 +57,7 @@ impl ModelsEndpointClient for TestModelsEndpoint {
fn list_models<'a>(
&'a self,
_client_version: &'a str,
_http_client_factory: HttpClientFactory,
) -> ModelsEndpointFuture<'a, CoreResult<(Vec<ModelInfo>, Option<String>)>> {
Box::pin(async move {
let fetch_index = self.fetch_count.fetch_add(1, Ordering::SeqCst);
@@ -79,7 +82,11 @@ async fn refreshes_immediately_periodically_and_stops_when_dropped() {
endpoint.clone(),
/*auth_manager*/ None,
));
let worker = spawn_with_interval(&models_manager, Duration::from_millis(10));
let worker = spawn_with_interval(
&models_manager,
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
Duration::from_millis(10),
);
endpoint.wait_for_fetch_count(/*expected*/ 2).await;
drop(worker);
@@ -1,5 +1,4 @@
use crate::bespoke_event_handling::apply_bespoke_event_handling;
use crate::bespoke_event_handling::maybe_emit_hook_prompt_item_completed;
use crate::command_exec::CommandExecManager;
use crate::command_exec::StartCommandExecParams;
use crate::config_manager::ConfigManager;
@@ -94,6 +93,7 @@ use codex_app_server_protocol::ListMcpServerStatusResponse;
use codex_app_server_protocol::LoginAccountParams;
use codex_app_server_protocol::LoginAccountResponse;
use codex_app_server_protocol::LoginApiKeyParams;
use codex_app_server_protocol::LoginAppBrand;
use codex_app_server_protocol::LogoutAccountResponse;
use codex_app_server_protocol::MarketplaceAddParams;
use codex_app_server_protocol::MarketplaceAddResponse;
@@ -379,10 +379,12 @@ use codex_feedback::FeedbackUploadOptions;
use codex_git_utils::git_diff_to_remote;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_login::AuthManager;
use codex_login::CODEX_OPEN_APP_URL;
use codex_login::CodexAuth;
use codex_login::LoginSuccessPage;
use codex_login::LoginSuccessPageBrand;
use codex_login::ServerOptions as LoginServerOptions;
use codex_login::ShutdownHandle;
use codex_login::auth::login_with_chatgpt_auth_tokens;
use codex_login::complete_device_code_login;
use codex_login::login_with_api_key;
use codex_login::oauth_client_id;
@@ -436,8 +438,8 @@ use codex_protocol::protocol::SessionConfiguredEvent;
use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::TurnEnvironmentSelection;
use codex_protocol::protocol::TurnEnvironmentSelections;
use codex_protocol::protocol::USER_MESSAGE_BEGIN;
use codex_protocol::protocol::W3cTraceContext;
use codex_protocol::protocol::strip_user_message_prefix;
use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS;
use codex_protocol::user_input::UserInput as CoreInputItem;
use codex_rmcp_client::perform_oauth_login_return_url_with_http_client;
@@ -623,10 +625,10 @@ pub(crate) use self::thread_summary::summary_to_thread;
pub(crate) use self::thread_summary::thread_settings_from_config_snapshot;
pub(crate) use self::thread_summary::thread_settings_from_core_snapshot;
pub(crate) fn build_api_turns_from_rollout_items(items: &[RolloutItem]) -> Vec<Turn> {
pub(crate) fn build_legacy_api_turns_from_rollout_items(items: &[RolloutItem]) -> Vec<Turn> {
let mut builder = ThreadHistoryBuilder::new();
for item in items {
if is_persisted_rollout_item(item) {
if is_persisted_rollout_item(item, codex_protocol::protocol::ThreadHistoryMode::Legacy) {
builder.handle_rollout_item(item);
}
}
@@ -1,5 +1,6 @@
use super::*;
use crate::auth_mode::auth_mode_to_api;
use crate::external_auth::ExternalAuthBridge;
use chrono::DateTime;
mod rate_limit_resets;
@@ -9,9 +10,11 @@ const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60);
const ACCOUNT_TOKEN_USAGE_FETCH_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 10);
const ACCOUNT_WORKSPACE_MESSAGES_FETCH_TIMEOUT: Duration =
Duration::from_millis(/*millis*/ 1000);
// The override is intentionally available only in debug builds, matching the login path below.
// Login overrides are intentionally available only in debug builds.
#[cfg(debug_assertions)]
const LOGIN_ISSUER_OVERRIDE_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER";
#[cfg(debug_assertions)]
const LOGIN_OPEN_APP_URL_OVERRIDE_ENV_VAR: &str = "CODEX_APP_SERVER_DEV_OPEN_APP_URL";
enum ActiveLogin {
Browser {
@@ -253,9 +256,25 @@ impl AccountRequestProcessor {
.await;
}
LoginAccountParams::Chatgpt {
app_brand,
codex_streamlined_login,
use_hosted_login_success_page,
} => {
self.login_chatgpt_v2(request_id, codex_streamlined_login)
let login_success_page = if use_hosted_login_success_page {
let app_brand = match app_brand.unwrap_or_default() {
LoginAppBrand::Codex => LoginSuccessPageBrand::Codex,
LoginAppBrand::Chatgpt => LoginSuccessPageBrand::Chatgpt,
};
LoginSuccessPage::Hosted {
url: CODEX_OPEN_APP_URL.parse().map_err(|err| {
internal_error(format!("invalid Codex open app URL: {err}"))
})?,
app_brand,
}
} else {
LoginSuccessPage::default()
};
self.login_chatgpt_v2(request_id, codex_streamlined_login, login_success_page)
.await;
}
LoginAccountParams::ChatgptDeviceCode => {
@@ -274,6 +293,9 @@ impl AccountRequestProcessor {
)
.await;
}
LoginAccountParams::AmazonBedrock { .. } => {
return Err(invalid_request("Amazon Bedrock login is not implemented"));
}
}
Ok(())
}
@@ -341,6 +363,7 @@ impl AccountRequestProcessor {
async fn login_chatgpt_common(
&self,
codex_streamlined_login: bool,
login_success_page: LoginSuccessPage,
) -> std::result::Result<LoginServerOptions, JSONRPCErrorError> {
let config = self.config.as_ref();
@@ -357,6 +380,7 @@ impl AccountRequestProcessor {
let opts = LoginServerOptions {
open_browser: false,
codex_streamlined_login,
login_success_page,
..LoginServerOptions::new(
config.codex_home.to_path_buf(),
oauth_client_id(),
@@ -374,6 +398,14 @@ impl AccountRequestProcessor {
{
opts.issuer = issuer;
}
if let LoginSuccessPage::Hosted { url, .. } = &mut opts.login_success_page
&& let Ok(open_app_url) = std::env::var(LOGIN_OPEN_APP_URL_OVERRIDE_ENV_VAR)
&& !open_app_url.trim().is_empty()
{
*url = open_app_url
.parse()
.map_err(|err| internal_error(format!("invalid Codex open app URL: {err}")))?;
}
opts
};
@@ -393,16 +425,22 @@ impl AccountRequestProcessor {
&self,
request_id: ConnectionRequestId,
codex_streamlined_login: bool,
login_success_page: LoginSuccessPage,
) {
let result = self.login_chatgpt_response(codex_streamlined_login).await;
let result = self
.login_chatgpt_response(codex_streamlined_login, login_success_page)
.await;
self.outgoing.send_result(request_id, result).await;
}
async fn login_chatgpt_response(
&self,
codex_streamlined_login: bool,
login_success_page: LoginSuccessPage,
) -> Result<LoginAccountResponse, JSONRPCErrorError> {
let opts = self.login_chatgpt_common(codex_streamlined_login).await?;
let opts = self
.login_chatgpt_common(codex_streamlined_login, login_success_page)
.await?;
let server = run_login_server(opts)
.map_err(|err| internal_error(format!("failed to start login server: {err}")))?;
let login_id = Uuid::new_v4();
@@ -474,7 +512,10 @@ impl AccountRequestProcessor {
&self,
) -> Result<LoginAccountResponse, JSONRPCErrorError> {
let opts = self
.login_chatgpt_common(/*codex_streamlined_login*/ false)
.login_chatgpt_common(
/*codex_streamlined_login*/ false,
LoginSuccessPage::default(),
)
.await?;
let device_code = request_device_code(&opts)
.await
@@ -617,14 +658,19 @@ impl AccountRequestProcessor {
)));
}
login_with_chatgpt_auth_tokens(
&self.config.codex_home,
let auth = CodexAuth::from_external_chatgpt_tokens(
&access_token,
&chatgpt_account_id,
chatgpt_plan_type.as_deref(),
)
.map_err(|err| internal_error(format!("failed to set external auth: {err}")))?;
self.auth_manager.reload().await;
self.auth_manager
.set_external_auth(Arc::new(ExternalAuthBridge::new(
Arc::clone(&self.outgoing),
auth,
)))
.await
.map_err(|err| internal_error(format!("failed to set external auth: {err}")))?;
self.config_manager.replace_cloud_config_bundle_loader(
self.auth_manager.clone(),
self.config.chatgpt_base_url.clone(),
@@ -812,7 +858,9 @@ impl AccountRequestProcessor {
let auth_mode = auth_mode_to_api(auth.api_auth_mode());
let (reported_auth_method, token_opt) = if matches!(
auth,
CodexAuth::AgentIdentity(_) | CodexAuth::PersonalAccessToken(_)
CodexAuth::Headers(_)
| CodexAuth::AgentIdentity(_)
| CodexAuth::PersonalAccessToken(_)
) || include_token
&& permanent_refresh_failure
{
@@ -157,9 +157,13 @@ impl CatalogRequestProcessor {
&self,
params: ModelListParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
Self::list_models(self.thread_manager.clone(), params)
.await
.map(|response| Some(response.into()))
Self::list_models(
self.thread_manager.clone(),
self.config.http_client_factory(),
params,
)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn experimental_feature_list(
@@ -247,6 +251,7 @@ impl CatalogRequestProcessor {
async fn list_models(
thread_manager: Arc<ThreadManager>,
http_client_factory: codex_http_client::HttpClientFactory,
params: ModelListParams,
) -> Result<ModelListResponse, JSONRPCErrorError> {
let ModelListParams {
@@ -254,7 +259,12 @@ impl CatalogRequestProcessor {
cursor,
include_hidden,
} = params;
let models = supported_models(thread_manager, include_hidden.unwrap_or(false)).await;
let models = supported_models(
thread_manager,
include_hidden.unwrap_or(false),
http_client_factory,
)
.await;
let total = models.len();
if total == 0 {
@@ -96,9 +96,11 @@ impl ExternalAgentConfigRequestProcessor {
config_manager,
arg0_paths,
);
let migration_service =
ExternalAgentConfigService::new(codex_home, analytics_events_client.clone());
Self {
outgoing,
migration_service: ExternalAgentConfigService::new(codex_home),
migration_service,
session_importer,
thread_manager,
config_processor,
@@ -189,6 +189,7 @@ impl ExternalAgentSessionImporter {
&config.model,
/*allow_provider_model_fallback*/ false,
RefreshStrategy::Offline,
config.http_client_factory(),
)
.await;
let model_info = models_manager
@@ -230,7 +231,7 @@ impl ExternalAgentSessionImporter {
memory_mode,
},
};
rollout_items.retain(is_persisted_rollout_item);
rollout_items.retain(|item| is_persisted_rollout_item(item, ThreadHistoryMode::Legacy));
let title = title
.as_deref()
.and_then(codex_core::util::normalize_thread_name);
@@ -1,6 +1,7 @@
use super::*;
use crate::error_code::internal_error;
use crate::error_code::invalid_request;
use codex_analytics::PluginInstallSource;
use codex_app_server_protocol::PluginAvailability;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_app_server_protocol::PluginSharePrincipalRole;
@@ -1548,6 +1549,7 @@ impl PluginRequestProcessor {
&remote_marketplace_name,
/*plugin_id*/ None,
error_type,
/*sub_error_type*/ None,
err.to_string(),
);
remote_plugin_catalog_error_to_jsonrpc(
@@ -1591,11 +1593,13 @@ impl PluginRequestProcessor {
)
.map_err(|err| {
let error_type = remote_plugin_bundle_install_error_type(&err);
let sub_error_type = err.sub_error_type();
self.track_plugin_install_failed_for_remote_plugin(
&remote_plugin_id,
&actual_remote_marketplace_name,
Some(&resolved_plugin_id),
error_type,
sub_error_type,
err.to_string(),
);
remote_plugin_bundle_install_error_to_jsonrpc(err)
@@ -1608,11 +1612,13 @@ impl PluginRequestProcessor {
.await
.map_err(|err| {
let error_type = remote_plugin_bundle_install_error_type(&err);
let sub_error_type = err.sub_error_type();
self.track_plugin_install_failed_for_remote_plugin(
&remote_plugin_id,
&actual_remote_marketplace_name,
Some(&resolved_plugin_id),
error_type,
sub_error_type,
err.to_string(),
);
remote_plugin_bundle_install_error_to_jsonrpc(err)
@@ -1635,6 +1641,7 @@ impl PluginRequestProcessor {
&actual_remote_marketplace_name,
Some(&result.plugin_id),
error_type,
/*sub_error_type*/ None,
err.to_string(),
);
remote_plugin_catalog_error_to_jsonrpc(err, "install remote plugin")
@@ -1732,12 +1739,14 @@ impl PluginRequestProcessor {
marketplace_name: &str,
plugin_id: Option<&PluginId>,
error_type: &'static str,
sub_error_type: Option<String>,
error_message: String,
) {
tracing::warn!(
remote_plugin_id = %remote_plugin_id,
marketplace_name = %marketplace_name,
error_type = %error_type,
sub_error_type = sub_error_type.as_deref(),
error = %error_message,
"remote plugin install failed"
);
@@ -1752,8 +1761,11 @@ impl PluginRequestProcessor {
capability_summary: None,
}
};
self.analytics_events_client
.track_plugin_install_failed(plugin, error_type.to_string());
self.analytics_events_client.track_plugin_install_failed(
plugin,
PluginInstallSource::Manual,
error_type.to_string(),
);
}
async fn plugin_apps_needing_auth_for_install(
@@ -316,6 +316,9 @@ pub(super) async fn ensure_listener_task_running(
thread_state.track_current_turn_event(&event.id, &event.msg);
thread_state.experimental_raw_events
};
if matches!(&event.msg, EventMsg::RawResponseItem(_)) && !raw_events_enabled {
continue;
}
let subscribed_connection_ids = thread_state_manager
.subscribed_connection_ids(conversation_id)
.await;
@@ -325,19 +328,6 @@ pub(super) async fn ensure_listener_task_running(
conversation_id,
);
if let EventMsg::RawResponseItem(raw_response_item_event) = &event.msg
&& !raw_events_enabled
{
maybe_emit_hook_prompt_item_completed(
conversation_id,
&event.id,
&raw_response_item_event.item,
&thread_outgoing,
)
.await;
continue;
}
apply_bespoke_event_handling(
event.clone(),
conversation_id,
@@ -748,7 +738,7 @@ pub(crate) fn populate_thread_turns_from_history(
items: &[RolloutItem],
active_turn: Option<&Turn>,
) {
let mut turns = build_api_turns_from_rollout_items(items);
let mut turns = build_legacy_api_turns_from_rollout_items(items);
if let Some(active_turn) = active_turn {
merge_turn_history_with_active_turn(&mut turns, active_turn.clone());
}
@@ -164,6 +164,34 @@ fn merge_persisted_resume_metadata(
}
}
fn merge_persisted_approvals_reviewer(
thread_history: &InitialHistory,
request_overrides: Option<&HashMap<String, serde_json::Value>>,
typesafe_overrides: &mut ConfigOverrides,
) {
if typesafe_overrides.approvals_reviewer.is_some()
|| request_overrides.is_some_and(|overrides| overrides.contains_key("approvals_reviewer"))
{
return;
}
let InitialHistory::Resumed(resumed_history) = thread_history else {
return;
};
typesafe_overrides.approvals_reviewer =
resumed_history
.history
.iter()
.rev()
.find_map(|item| match item {
RolloutItem::TurnContext(turn_context) => turn_context.approvals_reviewer,
RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(event)) => {
Some(event.thread_settings.approvals_reviewer)
}
_ => None,
});
}
fn normalize_thread_list_cwd_filters(
cwd: Option<ThreadListCwdFilter>,
) -> Result<Option<Vec<PathBuf>>, JSONRPCErrorError> {
@@ -2311,7 +2339,7 @@ impl ThreadRequestProcessor {
let (mut thread, history) =
thread_from_stored_thread(stored_thread, fallback_provider, &self.config.cwd);
if include_turns && let Some(history) = history {
thread.turns = build_api_turns_from_rollout_items(&history.items);
thread.turns = build_legacy_api_turns_from_rollout_items(&history.items);
}
Ok(Some(thread))
}
@@ -2380,7 +2408,7 @@ impl ThreadRequestProcessor {
.load_history(/*include_archived*/ true)
.await
.map_err(|err| thread_read_history_load_error(thread_id, err))?;
thread.turns = build_api_turns_from_rollout_items(&history.items);
thread.turns = build_legacy_api_turns_from_rollout_items(&history.items);
}
Ok(())
@@ -2957,6 +2985,11 @@ impl ThreadRequestProcessor {
request_overrides: &mut Option<HashMap<String, serde_json::Value>>,
typesafe_overrides: &mut ConfigOverrides,
) -> Option<ThreadMetadata> {
merge_persisted_approvals_reviewer(
thread_history,
request_overrides.as_ref(),
typesafe_overrides,
);
let InitialHistory::Resumed(resumed_history) = thread_history else {
return None;
};
@@ -4074,7 +4107,7 @@ fn reconstruct_thread_turns_for_turns_list(
|| active_turn
.as_ref()
.is_some_and(|turn| matches!(turn.status, TurnStatus::InProgress));
let mut turns = build_api_turns_from_rollout_items(items);
let mut turns = build_legacy_api_turns_from_rollout_items(items);
normalize_thread_turns_status(&mut turns, loaded_status, has_live_in_progress_turn);
if let Some(active_turn) = active_turn {
merge_turn_history_with_active_turn(&mut turns, active_turn);
@@ -4447,10 +4480,7 @@ fn preview_from_rollout_items(items: &[RolloutItem]) -> String {
},
_ => None,
})
.map(|preview| match preview.find(USER_MESSAGE_BEGIN) {
Some(idx) => preview[idx + USER_MESSAGE_BEGIN.len()..].trim().to_string(),
None => preview,
})
.map(|preview| strip_user_message_prefix(preview.as_str()).to_string())
.unwrap_or_default()
}
@@ -32,7 +32,7 @@ pub(super) fn redact_thread_resume_payloads(turns: &mut [Turn]) {
}
true
}
ThreadItem::ImageGeneration { .. } => false,
ThreadItem::ImageGeneration(_) => false,
_ => true,
});
}
@@ -52,6 +52,7 @@ fn redacted_mcp_tool_call_result() -> McpToolCallResult {
#[cfg(test)]
mod tests {
use super::*;
use codex_app_server_protocol::ImageGenerationItem;
use codex_app_server_protocol::McpToolCallAppContext;
use codex_app_server_protocol::McpToolCallError;
use codex_app_server_protocol::McpToolCallStatus;
@@ -100,13 +101,13 @@ mod tests {
error: None,
duration_ms: Some(8),
},
ThreadItem::ImageGeneration {
ThreadItem::ImageGeneration(ImageGenerationItem {
id: "ig-1".to_string(),
status: "completed".to_string(),
revised_prompt: Some("revised".to_string()),
result: "base64-result".to_string(),
saved_path: Some(test_path_buf("/tmp/ig-1.png").abs()),
},
}),
]);
redact_thread_resume_payloads(&mut thread.turns);
@@ -97,10 +97,7 @@ fn extract_conversation_summary(
_ => None,
})?;
let preview = match preview.find(USER_MESSAGE_BEGIN) {
Some(idx) => preview[idx + USER_MESSAGE_BEGIN.len()..].trim(),
None => preview.as_str(),
};
let preview = strip_user_message_prefix(preview.as_str());
let timestamp = if session_meta.timestamp.is_empty() {
None
@@ -1,6 +1,7 @@
use super::*;
use anyhow::Result;
use codex_protocol::protocol::USER_MESSAGE_BEGIN;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::path::PathBuf;
+11 -1
View File
@@ -6,7 +6,6 @@ license.workspace = true
[lib]
path = "lib.rs"
test = false
doctest = false
[lints]
@@ -27,13 +26,24 @@ codex-protocol = { workspace = true }
codex-utils-cargo-bin = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true, features = [
"io-util",
"io-std",
"macros",
"net",
"process",
"rt-multi-thread",
"sync",
"test-util",
"time",
] }
tokio-util = { workspace = true }
url = { workspace = true }
uuid = { workspace = true }
wiremock = { workspace = true }
core_test_support = { path = "../../../core/tests/common" }
shlex = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
+3
View File
@@ -4,10 +4,12 @@ mod analytics_server;
mod auth_fixtures;
mod config;
mod json_logging;
mod local_websocket_exec_server;
mod mock_model_server;
mod models_cache;
mod responses;
mod rollout;
mod rpc_delay;
mod test_app_server;
pub use analytics_server::start_analytics_events_server;
@@ -49,6 +51,7 @@ use serde::de::DeserializeOwned;
pub use test_app_server::DEFAULT_CLIENT_NAME;
pub use test_app_server::DISABLE_PLUGIN_STARTUP_TASKS_ARG;
pub use test_app_server::TestAppServer;
pub use test_app_server::TestAppServerBuilder;
pub fn to_response<T: DeserializeOwned>(response: JSONRPCResponse) -> anyhow::Result<T> {
let value = serde_json::to_value(response.result)?;
@@ -0,0 +1,90 @@
use std::path::Path;
use std::process::Stdio;
use std::time::Duration;
use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
use tokio::io::AsyncBufReadExt;
use tokio::io::BufReader;
use tokio::process::Child;
use tokio::process::Command;
const START_TIMEOUT: Duration = Duration::from_secs(10);
#[cfg(target_os = "linux")]
const CODEX_LINUX_SANDBOX_EXE_ENV_VAR: &str = "CODEX_TEST_LINUX_SANDBOX_EXE";
/// Host-local exec-server fixture that exposes a WebSocket URL.
///
/// This is distinct from the ordinary local stdio executor: callers use it
/// when they need a socket transport they can interpose.
pub(crate) struct LocalWebsocketExecServer {
child: Child,
websocket_url: String,
}
impl LocalWebsocketExecServer {
pub(crate) async fn start(codex_home: &Path, exec_server_program: &Path) -> Result<Self> {
let mut command = Command::new(exec_server_program);
command.stdin(Stdio::null());
command.stdout(Stdio::piped());
command.stderr(Stdio::inherit());
command.current_dir(codex_home);
command.env("CODEX_HOME", codex_home);
#[cfg(target_os = "linux")]
command.env(
CODEX_LINUX_SANDBOX_EXE_ENV_VAR,
core_test_support::find_codex_linux_sandbox_exe()
.context("should find binary for delayed exec-server Linux sandbox helper")?,
);
command.kill_on_drop(true);
let child = command.spawn().context("start local exec-server fixture")?;
let mut exec_server = Self {
child,
websocket_url: String::new(),
};
let stdout = exec_server
.child
.stdout
.take()
.ok_or_else(|| anyhow!("local exec-server fixture stdout was not captured"))?;
let mut lines = BufReader::new(stdout).lines();
let deadline = tokio::time::Instant::now() + START_TIMEOUT;
exec_server.websocket_url = loop {
let remaining = deadline
.checked_duration_since(tokio::time::Instant::now())
.ok_or_else(|| anyhow!("timed out waiting for local exec-server listen URL"))?;
let line = tokio::time::timeout(remaining, lines.next_line())
.await
.map_err(|_| anyhow!("timed out waiting for local exec-server listen URL"))??
.ok_or_else(|| {
anyhow!("local exec-server exited before emitting its listen URL")
})?;
let listen_url = line.trim();
if listen_url.starts_with("ws://") {
break listen_url.to_string();
}
};
Ok(exec_server)
}
pub(crate) fn websocket_url(&self) -> &str {
&self.websocket_url
}
}
impl Drop for LocalWebsocketExecServer {
fn drop(&mut self) {
let _ = self.child.start_kill();
let start = std::time::Instant::now();
let timeout = Duration::from_secs(5);
while start.elapsed() < timeout {
match self.child.try_wait() {
Ok(Some(_)) => return,
Ok(None) => std::thread::sleep(Duration::from_millis(10)),
Err(_) => return,
}
}
}
}
+3 -2
View File
@@ -9,6 +9,7 @@ use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::TokenCountEvent;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::TokenUsageInfo;
use core_test_support::test_path_buf;
use serde_json::json;
use std::fs;
use std::fs::FileTimes;
@@ -189,7 +190,7 @@ fn create_fake_rollout_with_source_and_parent_thread_id(
forked_from_id: None,
parent_thread_id,
timestamp: meta_rfc3339.to_string(),
cwd: PathBuf::from("/"),
cwd: test_path_buf("/"),
originator: "codex".to_string(),
cli_version: "0.0.0".to_string(),
source,
@@ -279,7 +280,7 @@ pub fn create_fake_rollout_with_text_elements(
forked_from_id: None,
parent_thread_id: None,
timestamp: meta_rfc3339.to_string(),
cwd: PathBuf::from("/"),
cwd: test_path_buf("/"),
originator: "codex".to_string(),
cli_version: "0.0.0".to_string(),
source: SessionSource::Cli,
@@ -0,0 +1,157 @@
use std::io;
use std::time::Duration;
use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
use tokio::io::AsyncRead;
use tokio::io::AsyncReadExt;
use tokio::io::AsyncWrite;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpListener;
use tokio::net::TcpStream;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use tokio::task::JoinSet;
use tokio::time::Instant;
use tokio::time::sleep_until;
use tokio_util::task::AbortOnDropHandle;
use url::Host;
use url::Url;
const FORWARD_BUFFER_BYTES: usize = 64 * 1024;
const FORWARD_QUEUE_CHUNKS: usize = 16;
pub(crate) struct WebsocketDelayInterposer {
websocket_url: String,
accept_task: JoinHandle<()>,
}
impl WebsocketDelayInterposer {
pub(crate) async fn start(upstream_url: &str, added_delay: Duration) -> Result<Self> {
let upstream = websocket_authority(upstream_url)?;
let listener = TcpListener::bind("127.0.0.1:0")
.await
.context("bind RPC delay interposer")?;
let websocket_url = format!("ws://{}", listener.local_addr()?);
let accept_task = tokio::spawn(async move {
let mut connections = JoinSet::new();
loop {
tokio::select! {
accepted = listener.accept() => {
let Ok((downstream, _peer)) = accepted else {
break;
};
let upstream = upstream.clone();
connections.spawn(async move {
let Ok(upstream) = TcpStream::connect(upstream).await else {
return;
};
let _ = proxy_connection(downstream, upstream, added_delay).await;
});
}
_ = connections.join_next(), if !connections.is_empty() => {}
}
}
});
Ok(Self {
websocket_url,
accept_task,
})
}
pub(crate) fn websocket_url(&self) -> &str {
&self.websocket_url
}
}
impl Drop for WebsocketDelayInterposer {
fn drop(&mut self) {
self.accept_task.abort();
}
}
fn websocket_authority(websocket_url: &str) -> Result<String> {
let websocket_url = Url::parse(websocket_url).context("parse RPC delay upstream URL")?;
if websocket_url.scheme() != "ws" {
return Err(anyhow!("RPC delay requires a ws:// exec-server URL"));
}
let host = websocket_url
.host()
.ok_or_else(|| anyhow!("RPC delay exec-server URL has no host"))?;
let port = websocket_url
.port_or_known_default()
.ok_or_else(|| anyhow!("RPC delay exec-server URL has no port"))?;
let host = match host {
Host::Domain(host) => host.to_string(),
Host::Ipv4(host) => host.to_string(),
Host::Ipv6(host) => format!("[{host}]"),
};
Ok(format!("{host}:{port}"))
}
async fn proxy_connection(
downstream: TcpStream,
upstream: TcpStream,
added_delay: Duration,
) -> io::Result<()> {
let (downstream_read, downstream_write) = downstream.into_split();
let (upstream_read, upstream_write) = upstream.into_split();
let client_to_server = forward_direction(downstream_read, upstream_write, added_delay);
let server_to_client = forward_direction(upstream_read, downstream_write, added_delay);
tokio::try_join!(client_to_server, server_to_client)?;
Ok(())
}
async fn forward_direction<R, W>(
mut reader: R,
mut writer: W,
added_delay: Duration,
) -> io::Result<()>
where
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
// tokio::io::copy would wait before reading the next chunk, turning a
// fixed propagation delay into a bandwidth limit. Timestamping reads into
// a bounded queue lets close-together chunks emerge close together after
// the same delay while still applying backpressure.
let (tx, mut rx) = mpsc::channel::<DelayedChunk>(FORWARD_QUEUE_CHUNKS);
let reader_task = AbortOnDropHandle::new(tokio::spawn(async move {
loop {
let mut bytes = vec![0; FORWARD_BUFFER_BYTES];
let read = reader.read(&mut bytes).await?;
if read == 0 {
break;
}
bytes.truncate(read);
let chunk = DelayedChunk {
deliver_at: Instant::now() + added_delay,
bytes,
};
if tx.send(chunk).await.is_err() {
break;
}
}
Ok::<(), io::Error>(())
}));
while let Some(chunk) = rx.recv().await {
sleep_until(chunk.deliver_at).await;
writer.write_all(&chunk.bytes).await?;
}
writer.shutdown().await?;
reader_task
.await
.map_err(|err| io::Error::other(format!("RPC delay reader task failed: {err}")))??;
Ok(())
}
struct DelayedChunk {
deliver_at: Instant,
bytes: Vec<u8>,
}
#[cfg(test)]
#[path = "rpc_delay_tests.rs"]
mod tests;
@@ -0,0 +1,181 @@
use std::collections::VecDeque;
use std::io;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use std::task::Context;
use std::task::Poll;
use std::time::Duration;
use pretty_assertions::assert_eq;
use tokio::io::AsyncRead;
use tokio::io::AsyncReadExt;
use tokio::io::AsyncWrite;
use tokio::io::AsyncWriteExt;
use tokio::io::ReadBuf;
use tokio::io::duplex;
use tokio::net::TcpListener;
use tokio::net::TcpStream;
use tokio::time::advance;
use tokio::time::timeout;
use super::WebsocketDelayInterposer;
use super::forward_direction;
use super::websocket_authority;
#[tokio::test(start_paused = true)]
async fn delays_then_flushes_eof() -> anyhow::Result<()> {
let delay = Duration::from_millis(15);
let (mut input_writer, input_reader) = duplex(/*max_buf_size*/ 64);
let (output_writer, mut output_reader) = duplex(/*max_buf_size*/ 64);
let forward_task = tokio::spawn(forward_direction(input_reader, output_writer, delay));
input_writer.write_all(b"payload").await?;
input_writer.shutdown().await?;
tokio::task::yield_now().await;
let mut before_delay = [0; 1];
assert!(
timeout(Duration::ZERO, output_reader.read(&mut before_delay))
.await
.is_err()
);
advance(delay).await;
let mut output = Vec::new();
output_reader.read_to_end(&mut output).await?;
assert_eq!(output, b"payload");
forward_task.await??;
Ok(())
}
#[tokio::test(start_paused = true)]
async fn burst_chunks_share_one_deadline() -> anyhow::Result<()> {
let delay = Duration::from_millis(15);
let (mut input_writer, input_reader) = duplex(/*max_buf_size*/ 1);
let (output_writer, mut output_reader) = duplex(/*max_buf_size*/ 64);
let forward_task = tokio::spawn(forward_direction(input_reader, output_writer, delay));
input_writer.write_all(b"ab").await?;
input_writer.shutdown().await?;
tokio::task::yield_now().await;
advance(delay).await;
tokio::task::yield_now().await;
let mut output = [0; 2];
timeout(Duration::ZERO, output_reader.read_exact(&mut output)).await??;
assert_eq!(output, *b"ab");
forward_task.await??;
Ok(())
}
#[tokio::test]
async fn write_error_cancels_reader() {
let reader_dropped = Arc::new(AtomicBool::new(false));
let reader = PendingAfterChunkReader::new(Arc::clone(&reader_dropped));
let error = forward_direction(reader, FailingWriter, Duration::ZERO)
.await
.expect_err("failing writer should fail forwarding");
assert_eq!(error.kind(), io::ErrorKind::BrokenPipe);
for _ in 0..10 {
if reader_dropped.load(Ordering::Acquire) {
break;
}
tokio::task::yield_now().await;
}
assert!(reader_dropped.load(Ordering::Acquire));
}
#[tokio::test]
async fn zero_delay_loopback_forwards_and_closes_active_sockets() -> anyhow::Result<()> {
let upstream_listener = TcpListener::bind("127.0.0.1:0").await?;
let upstream_url = format!("ws://{}", upstream_listener.local_addr()?);
let upstream_task = tokio::spawn(async move {
let (mut upstream, _) = upstream_listener.accept().await?;
let mut request = [0; 5];
upstream.read_exact(&mut request).await?;
assert_eq!(request, *b"hello");
upstream.write_all(b"world").await?;
let mut after_drop = [0; 1];
let read = timeout(Duration::from_secs(1), upstream.read(&mut after_drop)).await??;
Ok::<usize, anyhow::Error>(read)
});
let interposer = WebsocketDelayInterposer::start(&upstream_url, Duration::ZERO).await?;
let mut downstream =
TcpStream::connect(websocket_authority(interposer.websocket_url())?).await?;
downstream.write_all(b"hello").await?;
let mut response = [0; 5];
downstream.read_exact(&mut response).await?;
assert_eq!(response, *b"world");
drop(interposer);
tokio::task::yield_now().await;
let mut after_drop = [0; 1];
let read = timeout(Duration::from_secs(1), downstream.read(&mut after_drop)).await??;
assert_eq!(read, 0);
assert_eq!(upstream_task.await??, 0);
Ok(())
}
struct PendingAfterChunkReader {
chunks: VecDeque<Vec<u8>>,
dropped: Arc<AtomicBool>,
}
impl PendingAfterChunkReader {
fn new(dropped: Arc<AtomicBool>) -> Self {
Self {
chunks: VecDeque::from([b"x".to_vec()]),
dropped,
}
}
}
impl AsyncRead for PendingAfterChunkReader {
fn poll_read(
mut self: Pin<&mut Self>,
_cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
let Some(chunk) = self.chunks.pop_front() else {
return Poll::Pending;
};
buf.put_slice(&chunk);
Poll::Ready(Ok(()))
}
}
impl Drop for PendingAfterChunkReader {
fn drop(&mut self) {
self.dropped.store(true, Ordering::Release);
}
}
struct FailingWriter;
impl AsyncWrite for FailingWriter {
fn poll_write(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,
_buf: &[u8],
) -> Poll<io::Result<usize>> {
Poll::Ready(Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"synthetic write failure",
)))
}
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Poll::Ready(Ok(()))
}
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Poll::Ready(Ok(()))
}
}
@@ -1,9 +1,11 @@
use std::collections::VecDeque;
use std::path::Path;
use std::path::PathBuf;
use std::process::ExitStatus;
use std::process::Stdio;
use std::sync::atomic::AtomicI64;
use std::sync::atomic::Ordering;
use std::time::Duration;
use tokio::io::AsyncBufReadExt;
use tokio::io::AsyncWriteExt;
use tokio::io::BufReader;
@@ -120,11 +122,15 @@ use codex_exec_server::CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID_ENV_VAR;
use codex_exec_server::CODEX_EXEC_SERVER_NOISE_REGISTRY_URL_ENV_VAR;
use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR;
use codex_login::default_client::CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR;
use core_test_support::is_remote_test_environment;
use core_test_support::test_codex::TestEnv;
use core_test_support::test_codex::test_env;
use tempfile::TempDir;
use tokio::process::Command;
use crate::json_logging::JsonLogCapture;
use crate::local_websocket_exec_server::LocalWebsocketExecServer;
use crate::rpc_delay::WebsocketDelayInterposer;
pub struct TestAppServer {
next_request_id: AtomicI64,
@@ -138,98 +144,49 @@ pub struct TestAppServer {
pending_messages: VecDeque<JSONRPCMessage>,
auto_env: Option<TestEnv>,
json_logs: JsonLogCapture,
codex_home: PathBuf,
// Fields drop in declaration order. Tear down the delayed child before
// removing an owned CODEX_HOME that may still be its cwd on Windows.
_delayed_exec_server: Option<(LocalWebsocketExecServer, WebsocketDelayInterposer)>,
_owned_codex_home: Option<TempDir>,
}
pub const DEFAULT_CLIENT_NAME: &str = "codex-app-server-tests";
pub const DISABLE_PLUGIN_STARTUP_TASKS_ARG: &str = "--disable-plugin-startup-tasks-for-tests";
const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_CONFIG";
const CODE_MODE_HOST_PATH_ENV_VAR: &str = "CODEX_CODE_MODE_HOST_PATH";
impl TestAppServer {
/// Starts building a server with a temporary CODEX_HOME and the standard
/// automatic test environment.
pub fn builder() -> TestAppServerBuilder {
TestAppServerBuilder {
codex_home: None,
environment: TestAppServerEnvironment::Auto,
program: None,
env_overrides: Vec::new(),
args: vec![DISABLE_PLUGIN_STARTUP_TASKS_ARG.to_string()],
exec_server_delay: None,
}
}
pub async fn wait_for_exit(&mut self) -> std::io::Result<ExitStatus> {
self.process.wait().await
}
pub async fn new(codex_home: &Path) -> anyhow::Result<Self> {
Self::new_with_env_and_args(codex_home, &[], &[DISABLE_PLUGIN_STARTUP_TASKS_ARG]).await
}
/// Starts an app server with the standard test environment and retains it
/// for the server's lifetime.
///
/// Local test runs explicitly remove `CODEX_EXEC_SERVER_URL`; Docker- and
/// Wine-backed runs set it to the remote fixture URL. Use
/// [`Self::auto_env_params`] or
/// [`Self::send_thread_start_request_with_auto_env`] to select the matching
/// target-native cwd in a thread. Because `environments.toml` overrides the
/// URL-based configuration, this helper rejects a `codex_home` containing
/// that file.
pub async fn new_with_auto_env(codex_home: &Path) -> anyhow::Result<Self> {
Self::new_with_auto_env_and_env(codex_home, &[]).await
}
/// Starts an auto-environment app server that emits JSON logs.
///
/// `rust_log` is the value to use for the `RUST_LOG` environment variable.
pub async fn new_with_auto_env_and_json_logging(
codex_home: &Path,
rust_log: impl Into<String>,
) -> anyhow::Result<Self> {
let rust_log = rust_log.into();
Self::new_with_auto_env_and_env(
codex_home,
&[
("LOG_FORMAT", Some("json")),
("RUST_LOG", Some(rust_log.as_str())),
],
)
.await
}
async fn new_with_auto_env_and_env(
codex_home: &Path,
extra_env_overrides: &[(&str, Option<&str>)],
) -> anyhow::Result<Self> {
let environments_toml = codex_home.join("environments.toml");
ensure!(
!environments_toml
.try_exists()
.with_context(|| format!("check whether {} exists", environments_toml.display()))?,
"new_with_auto_env cannot be used when {} exists",
environments_toml.display()
);
let auto_env = test_env().await?;
// Noise registry configuration takes precedence over the URL-based
// provider, so clear inherited values to keep the selection hermetic.
let mut env_overrides = vec![
(
CODEX_EXEC_SERVER_URL_ENV_VAR,
auto_env.environment().exec_server_url(),
),
(CODEX_EXEC_SERVER_NOISE_REGISTRY_URL_ENV_VAR, None),
(CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID_ENV_VAR, None),
(CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR, None),
(CODEX_EXEC_SERVER_NOISE_CHATGPT_ACCOUNT_ID_ENV_VAR, None),
];
env_overrides.extend_from_slice(extra_env_overrides);
let mut app_server = Self::new_with_env(codex_home, &env_overrides).await?;
app_server.auto_env = Some(auto_env);
Ok(app_server)
}
/// Returns the automatically selected test environment retained by this server.
///
/// Tests can use the environment to arrange target-native filesystem fixtures before starting
/// a thread. Returns an error unless this server was created with [`Self::new_with_auto_env`].
/// a thread. Returns an error unless the builder's automatic environment is enabled.
pub fn auto_env(&self) -> anyhow::Result<&TestEnv> {
self.auto_env
.as_ref()
.context("auto environment is unavailable; use TestAppServer::new_with_auto_env")
.context("auto environment is unavailable; enable it on TestAppServer::builder")
}
/// Returns app-server protocol parameters for the automatically selected
/// test environment. Returns an error unless this server was created with
/// [`Self::new_with_auto_env`].
/// test environment. Returns an error unless the builder's automatic
/// environment is enabled.
pub fn auto_env_params(&self) -> anyhow::Result<TurnEnvironmentParams> {
let selection = self.auto_env()?.selection();
Ok(TurnEnvironmentParams {
@@ -238,6 +195,11 @@ impl TestAppServer {
})
}
/// Returns the effective CODEX_HOME used by the child app-server.
pub fn codex_home(&self) -> &Path {
&self.codex_home
}
/// Waits for a JSON stderr event whose structured `event.name` field matches.
pub async fn wait_for_json_log_event(
&self,
@@ -260,77 +222,6 @@ impl TestAppServer {
self.json_logs.events()
}
pub async fn new_without_managed_config(codex_home: &Path) -> anyhow::Result<Self> {
Self::new_with_env(codex_home, &[(DISABLE_MANAGED_CONFIG_ENV_VAR, Some("1"))]).await
}
pub async fn new_without_managed_config_with_env(
codex_home: &Path,
env_overrides: &[(&str, Option<&str>)],
) -> anyhow::Result<Self> {
let mut all_env_overrides = vec![(DISABLE_MANAGED_CONFIG_ENV_VAR, Some("1"))];
all_env_overrides.extend_from_slice(env_overrides);
Self::new_with_env(codex_home, &all_env_overrides).await
}
pub async fn new_with_plugin_startup_tasks(codex_home: &Path) -> anyhow::Result<Self> {
Self::new_with_env_and_args(codex_home, &[], &[]).await
}
pub async fn new_with_env_and_plugin_startup_tasks(
codex_home: &Path,
env_overrides: &[(&str, Option<&str>)],
) -> anyhow::Result<Self> {
Self::new_with_env_and_args(codex_home, env_overrides, &[]).await
}
pub async fn new_with_args(codex_home: &Path, args: &[&str]) -> anyhow::Result<Self> {
let mut all_args = vec![DISABLE_PLUGIN_STARTUP_TASKS_ARG];
all_args.extend_from_slice(args);
Self::new_with_env_and_args(codex_home, &[], &all_args).await
}
/// Creates a new MCP process, allowing tests to override or remove
/// specific environment variables for the child process only.
///
/// Pass a tuple of (key, Some(value)) to set/override, or (key, None) to
/// remove a variable from the child's environment.
pub async fn new_with_env(
codex_home: &Path,
env_overrides: &[(&str, Option<&str>)],
) -> anyhow::Result<Self> {
Self::new_with_env_and_args(
codex_home,
env_overrides,
&[DISABLE_PLUGIN_STARTUP_TASKS_ARG],
)
.await
}
pub async fn new_with_program_and_env(
codex_home: &Path,
program: &Path,
env_overrides: &[(&str, Option<&str>)],
) -> anyhow::Result<Self> {
Self::new_with_program_env_and_args(
codex_home,
program,
env_overrides,
&[DISABLE_PLUGIN_STARTUP_TASKS_ARG],
)
.await
}
async fn new_with_env_and_args(
codex_home: &Path,
env_overrides: &[(&str, Option<&str>)],
args: &[&str],
) -> anyhow::Result<Self> {
let program = codex_utils_cargo_bin::cargo_bin("codex-app-server")
.context("should find binary for codex-app-server")?;
Self::new_with_program_env_and_args(codex_home, &program, env_overrides, args).await
}
async fn new_with_program_env_and_args(
codex_home: &Path,
program: &Path,
@@ -399,6 +290,9 @@ impl TestAppServer {
pending_messages: VecDeque::new(),
auto_env: None,
json_logs,
codex_home: codex_home.to_path_buf(),
_delayed_exec_server: None,
_owned_codex_home: None,
})
}
@@ -553,8 +447,8 @@ impl TestAppServer {
chatgpt_account_id,
chatgpt_plan_type,
};
let params = Some(serde_json::to_value(params)?);
self.send_request("account/login/start", params).await
self.send_login_account_request(serde_json::to_value(params)?)
.await
}
/// Send a `feedback/upload` JSON-RPC request.
@@ -575,9 +469,9 @@ impl TestAppServer {
self.send_request("thread/start", params).await
}
/// Sends a `thread/start` request selecting the environment provisioned by
/// [`Self::new_with_auto_env`]. Returns an error if `params` already select
/// environments so the caller cannot accidentally override the fixture.
/// Sends a `thread/start` request selecting the builder's automatic
/// environment. Returns an error if `params` already select environments
/// so the caller cannot accidentally override the fixture.
pub async fn send_thread_start_request_with_auto_env(
&mut self,
mut params: ThreadStartParams,
@@ -1420,6 +1314,14 @@ impl TestAppServer {
self.send_request("account/logout", /*params*/ None).await
}
/// Send an `account/login/start` JSON-RPC request.
pub async fn send_login_account_request(
&mut self,
params: serde_json::Value,
) -> anyhow::Result<i64> {
self.send_request("account/login/start", Some(params)).await
}
/// Send an `account/login/start` JSON-RPC request for API key login.
pub async fn send_login_account_api_key_request(
&mut self,
@@ -1429,7 +1331,7 @@ impl TestAppServer {
"type": "apiKey",
"apiKey": api_key,
});
self.send_request("account/login/start", Some(params)).await
self.send_login_account_request(params).await
}
/// Send an `account/login/start` JSON-RPC request for ChatGPT login.
@@ -1437,7 +1339,7 @@ impl TestAppServer {
let params = serde_json::json!({
"type": "chatgpt"
});
self.send_request("account/login/start", Some(params)).await
self.send_login_account_request(params).await
}
/// Send an `account/login/start` JSON-RPC request for ChatGPT device code login.
@@ -1445,7 +1347,7 @@ impl TestAppServer {
let params = serde_json::json!({
"type": "chatgptDeviceCode"
});
self.send_request("account/login/start", Some(params)).await
self.send_login_account_request(params).await
}
/// Send an `account/login/cancel` JSON-RPC request.
@@ -1792,6 +1694,221 @@ impl TestAppServer {
}
}
/// Builder for TestAppServer.
pub struct TestAppServerBuilder {
codex_home: Option<PathBuf>,
environment: TestAppServerEnvironment,
program: Option<PathBuf>,
env_overrides: Vec<(String, Option<String>)>,
args: Vec<String>,
exec_server_delay: Option<Duration>,
}
enum TestAppServerEnvironment {
Auto,
None,
}
impl TestAppServerBuilder {
/// Uses this existing CODEX_HOME instead of a temporary one.
pub fn with_codex_home(mut self, codex_home: &Path) -> Self {
self.codex_home = Some(codex_home.to_path_buf());
self
}
/// Starts app-server without the standard automatic test environment.
pub fn without_auto_env(mut self) -> Self {
self.environment = TestAppServerEnvironment::None;
self
}
/// Uses this app-server binary instead of the standard test binary.
pub fn with_program(mut self, program: &Path) -> Self {
self.program = Some(program.to_path_buf());
self
}
/// Adds command-line arguments after the default test arguments.
pub fn with_args(mut self, args: &[&str]) -> Self {
self.args
.extend(args.iter().map(|argument| (*argument).to_string()));
self
}
/// Enables startup tasks that the default test arguments disable.
pub fn with_plugin_startup_tasks(mut self) -> Self {
self.args
.retain(|argument| argument != DISABLE_PLUGIN_STARTUP_TASKS_ARG);
self
}
/// Adds child-process environment overrides.
///
/// Some values set variables and None values remove inherited variables.
pub fn with_env_overrides(mut self, env_overrides: &[(&str, Option<&str>)]) -> Self {
self.env_overrides
.extend(env_overrides.iter().map(|(key, value)| {
(
(*key).to_string(),
value.map(std::string::ToString::to_string),
)
}));
self
}
/// Prevents the child from loading managed configuration.
pub fn without_managed_config(self) -> Self {
self.with_env_overrides(&[(DISABLE_MANAGED_CONFIG_ENV_VAR, Some("1"))])
}
/// Configures the child to emit JSON logs at the requested Rust log level.
pub fn with_json_logging(self, rust_log: impl Into<String>) -> Self {
let rust_log = rust_log.into();
let mut builder = self.with_env_overrides(&[("LOG_FORMAT", Some("json"))]);
builder
.env_overrides
.push(("RUST_LOG".to_string(), Some(rust_log)));
builder
}
/// Adds this fixed one-way delay to the app-server/exec-server RPC stream.
/// A 15ms delay contributes roughly 30ms to a round trip.
pub fn with_exec_server_delay(mut self, exec_server_delay: Duration) -> Self {
self.exec_server_delay = Some(exec_server_delay);
self
}
/// Builds a server with a temporary CODEX_HOME and automatic environment
/// by default.
pub async fn build(self) -> anyhow::Result<TestAppServer> {
let Self {
codex_home,
environment,
program,
mut env_overrides,
args,
exec_server_delay,
} = self;
let (codex_home, owned_codex_home) = match codex_home {
Some(codex_home) => (codex_home, None),
None => {
let owned_codex_home = TempDir::new()?;
(
owned_codex_home.path().to_path_buf(),
Some(owned_codex_home),
)
}
};
let (auto_env, delayed_exec_server) = match environment {
TestAppServerEnvironment::Auto => {
let environments_toml = codex_home.join("environments.toml");
ensure!(
!environments_toml.try_exists().with_context(|| format!(
"check whether {} exists",
environments_toml.display()
))?,
"automatic environment cannot be used when {} exists",
environments_toml.display()
);
let (auto_env, delayed_exec_server) = match exec_server_delay {
Some(added_delay) => {
ensure!(
!is_remote_test_environment(),
"TestAppServer exec-server delay only supports the local test environment"
);
let exec_server_program =
codex_utils_cargo_bin::cargo_bin("exec-server")
.context("should find binary for delayed exec-server fixture")?;
// Local auto environments normally use stdio. Start a
// host-local WebSocket fixture so the delay interposer has a
// socket stream to wrap.
let local_websocket_exec_server =
LocalWebsocketExecServer::start(&codex_home, &exec_server_program)
.await?;
let interposer = WebsocketDelayInterposer::start(
local_websocket_exec_server.websocket_url(),
added_delay,
)
.await?;
let auto_env = TestEnv::local_with_exec_server_url(Some(
interposer.websocket_url().to_string(),
))
.await?;
(auto_env, Some((local_websocket_exec_server, interposer)))
}
None => (test_env().await?, None),
};
// Noise registry configuration takes precedence over the URL-based
// provider, so clear inherited values to keep the selection hermetic.
let mut auto_env_overrides = vec![
(
CODEX_EXEC_SERVER_URL_ENV_VAR.to_string(),
auto_env.environment().exec_server_url().map(str::to_string),
),
(
CODEX_EXEC_SERVER_NOISE_REGISTRY_URL_ENV_VAR.to_string(),
None,
),
(
CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID_ENV_VAR.to_string(),
None,
),
(CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR.to_string(), None),
(
CODEX_EXEC_SERVER_NOISE_CHATGPT_ACCOUNT_ID_ENV_VAR.to_string(),
None,
),
];
auto_env_overrides.append(&mut env_overrides);
env_overrides = auto_env_overrides;
(Some(auto_env), delayed_exec_server)
}
TestAppServerEnvironment::None => {
ensure!(
exec_server_delay.is_none(),
"exec-server delay requires the automatic test environment"
);
(None, None)
}
};
if !env_overrides
.iter()
.any(|(key, _)| key == CODE_MODE_HOST_PATH_ENV_VAR)
&& let Ok(code_mode_host_program) =
codex_utils_cargo_bin::cargo_bin("codex-code-mode-host")
{
env_overrides.insert(
0,
(
CODE_MODE_HOST_PATH_ENV_VAR.to_string(),
Some(code_mode_host_program.to_string_lossy().into_owned()),
),
);
}
let program = match program {
Some(program) => program,
None => codex_utils_cargo_bin::cargo_bin("codex-app-server")
.context("should find binary for codex-app-server")?,
};
let env_overrides = env_overrides
.iter()
.map(|(key, value)| (key.as_str(), value.as_deref()))
.collect::<Vec<_>>();
let args = args.iter().map(String::as_str).collect::<Vec<_>>();
let mut app_server = TestAppServer::new_with_program_env_and_args(
&codex_home,
&program,
&env_overrides,
&args,
)
.await?;
app_server.auto_env = auto_env;
app_server._owned_codex_home = owned_codex_home;
app_server._delayed_exec_server = delayed_exec_server;
Ok(app_server)
}
}
impl Drop for TestAppServer {
fn drop(&mut self) {
// These tests spawn a `codex-app-server` child process.
+59 -31
View File
@@ -115,8 +115,12 @@ async fn get_auth_status_no_auth() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path())?;
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -142,7 +146,11 @@ async fn get_auth_status_with_api_key() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path())?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
login_with_api_key_via_request(&mut mcp, "sk-test-key").await?;
@@ -186,15 +194,16 @@ async fn personal_access_token_without_email_supports_auth_status_and_account_re
.await;
let authapi_base_url = server.uri();
let mut mcp = TestAppServer::new_with_env(
codex_home.path(),
&[
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[
("OPENAI_API_KEY", None),
("CODEX_ACCESS_TOKEN", Some("at-test-token")),
("CODEX_AUTHAPI_BASE_URL", Some(authapi_base_url.as_str())),
],
)
.await?;
])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -256,7 +265,11 @@ async fn get_auth_status_with_api_key_when_auth_not_required() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml_custom_provider(codex_home.path(), /*requires_openai_auth*/ false)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
login_with_api_key_via_request(&mut mcp, "sk-test-key").await?;
@@ -289,7 +302,11 @@ async fn get_auth_status_with_api_key_no_include_token() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path())?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
login_with_api_key_via_request(&mut mcp, "sk-test-key").await?;
@@ -317,7 +334,11 @@ async fn get_auth_status_with_api_key_refresh_requested() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path())?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
login_with_api_key_via_request(&mut mcp, "sk-test-key").await?;
@@ -373,17 +394,18 @@ async fn get_auth_status_omits_token_after_permanent_refresh_failure() -> Result
.await;
let refresh_url = format!("{}/oauth/token", server.uri());
let mut mcp = TestAppServer::new_with_env(
codex_home.path(),
&[
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[
("OPENAI_API_KEY", None),
(
REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR,
Some(refresh_url.as_str()),
),
],
)
.await?;
])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -455,17 +477,18 @@ async fn get_auth_status_omits_token_after_proactive_refresh_failure() -> Result
.await;
let refresh_url = format!("{}/oauth/token", server.uri());
let mut mcp = TestAppServer::new_with_env(
codex_home.path(),
&[
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[
("OPENAI_API_KEY", None),
(
REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR,
Some(refresh_url.as_str()),
),
],
)
.await?;
])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -522,17 +545,18 @@ async fn get_auth_status_returns_token_after_proactive_refresh_recovery() -> Res
.await;
let refresh_url = format!("{}/oauth/token", server.uri());
let mut mcp = TestAppServer::new_with_env(
codex_home.path(),
&[
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[
("OPENAI_API_KEY", None),
(
REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR,
Some(refresh_url.as_str()),
),
],
)
.await?;
])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let failed_request_id = mcp
@@ -599,7 +623,11 @@ async fn login_api_key_rejected_when_forced_chatgpt() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml_forced_login(codex_home.path(), "chatgpt")?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -29,6 +29,7 @@ use codex_thread_store::InMemoryThreadStore;
use codex_thread_store::ThreadPersistenceMetadata;
use codex_thread_store::ThreadStore;
use codex_utils_absolute_path::AbsolutePathBuf;
use core_test_support::test_path_buf;
use pretty_assertions::assert_eq;
use std::path::Path;
use std::path::PathBuf;
@@ -53,7 +54,7 @@ fn expected_summary(conversation_id: ThreadId, path: PathBuf) -> ConversationSum
timestamp: Some(CREATED_AT_RFC3339.to_string()),
updated_at: Some(UPDATED_AT_RFC3339.to_string()),
model_provider: MODEL_PROVIDER.to_string(),
cwd: PathBuf::from("/"),
cwd: test_path_buf("/"),
cli_version: "0.0.0".to_string(),
source: SessionSource::Cli,
git_info: None,
@@ -92,7 +93,11 @@ async fn get_conversation_summary_by_thread_id_reads_rollout() -> Result<()> {
))?,
);
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -217,7 +222,11 @@ async fn get_conversation_summary_by_relative_rollout_path_resolves_from_codex_h
let relative_path = rollout_path.strip_prefix(codex_home.path())?.to_path_buf();
let expected = expected_summary(thread_id, normalized_canonical_path(rollout_path)?);
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -46,7 +46,11 @@ shell_snapshot = false
async fn initialized_mcp(codex_home: &TempDir) -> Result<TestAppServer> {
create_config_toml(codex_home.path())?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
Ok(mcp)
}
@@ -236,7 +240,11 @@ async fn test_fuzzy_file_search_sorts_and_includes_indices() -> Result<()> {
.to_string();
// Start MCP server and initialize.
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let root_path = root.path().to_string_lossy().to_string();
@@ -302,7 +310,11 @@ async fn test_fuzzy_file_search_accepts_cancellation_token() -> Result<()> {
std::fs::write(root.path().join("alpha.txt"), "contents")?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let root_path = root.path().to_string_lossy().to_string();
+5 -5
View File
@@ -68,11 +68,11 @@ async fn app_server_emits_structured_tool_call_timing_event() -> Result<()> {
"compact",
)?;
let mut app_server = TestAppServer::new_with_auto_env_and_json_logging(
codex_home.path(),
"warn,codex_core::tools::parallel=info",
)
.await?;
let mut app_server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.with_json_logging("warn,codex_core::tools::parallel=info")
.build()
.await?;
timeout(READ_TIMEOUT, app_server.initialize()).await??;
let thread_start_id = app_server
+259 -75
View File
@@ -57,6 +57,7 @@ use wiremock::matchers::path;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
const LOGIN_ISSUER_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER";
const LOGIN_OPEN_APP_URL_ENV_VAR: &str = "CODEX_APP_SERVER_DEV_OPEN_APP_URL";
const WORKSPACE_ID_ALLOWED: &str = "123e4567-e89b-42d3-a456-426614174000";
const WORKSPACE_ID_SECOND_ALLOWED: &str = "123e4567-e89b-42d3-a456-426614174001";
const WORKSPACE_ID_DISALLOWED: &str = "123e4567-e89b-42d3-a456-426614174002";
@@ -181,7 +182,7 @@ async fn mock_device_code_token_failure(server: &MockServer, status: u16) {
.await;
}
async fn mock_device_code_oauth_token(server: &MockServer, id_token: &str) {
async fn mock_oauth_token(server: &MockServer, id_token: &str) {
Mock::given(method("POST"))
.and(path("/oauth/token"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
@@ -206,8 +207,12 @@ async fn logout_account_removes_auth_and_notifies() -> Result<()> {
)?;
assert!(codex_home.path().join("auth.json").exists());
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let id = mcp.send_logout_account_request().await?;
@@ -274,8 +279,12 @@ async fn set_auth_token_updates_account_and_notifies() -> Result<()> {
.chatgpt_account_id(WORKSPACE_ID_EMBEDDED),
)?;
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let set_id = mcp
@@ -327,6 +336,27 @@ async fn set_auth_token_updates_account_and_notifies() -> Result<()> {
}
);
let logout_id = mcp.send_logout_account_request().await?;
let logout_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(logout_id)),
)
.await??;
let _: LogoutAccountResponse = to_response(logout_resp)?;
let get_id = mcp
.send_get_account_request(GetAccountParams {
refresh_token: false,
})
.await?;
let get_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(get_id)),
)
.await??;
let account: GetAccountResponse = to_response(get_resp)?;
assert_eq!(account.account, None);
Ok(())
}
@@ -349,8 +379,12 @@ async fn account_read_refresh_token_is_noop_in_external_mode() -> Result<()> {
.chatgpt_account_id(WORKSPACE_ID_EMBEDDED),
)?;
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let set_id = mcp
@@ -475,8 +509,11 @@ async fn external_auth_refreshes_on_unauthorized() -> Result<()> {
.chatgpt_account_id(WORKSPACE_ID_REFRESHED),
)?;
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let set_id = mcp
@@ -500,7 +537,7 @@ async fn external_auth_refreshes_on_unauthorized() -> Result<()> {
.await??;
let thread_req = mcp
.send_thread_start_request(codex_app_server_protocol::ThreadStartParams {
.send_thread_start_request_with_auto_env(codex_app_server_protocol::ThreadStartParams {
model: Some("mock-model".to_string()),
..Default::default()
})
@@ -583,8 +620,11 @@ async fn external_auth_refresh_error_fails_turn() -> Result<()> {
.chatgpt_account_id(WORKSPACE_ID_INITIAL),
)?;
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let set_id = mcp
@@ -608,7 +648,7 @@ async fn external_auth_refresh_error_fails_turn() -> Result<()> {
.await??;
let thread_req = mcp
.send_thread_start_request(codex_app_server_protocol::ThreadStartParams {
.send_thread_start_request_with_auto_env(codex_app_server_protocol::ThreadStartParams {
model: Some("mock-model".to_string()),
..Default::default()
})
@@ -707,8 +747,11 @@ async fn external_auth_refresh_mismatched_workspace_fails_turn() -> Result<()> {
.chatgpt_account_id(WORKSPACE_ID_DISALLOWED),
)?;
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let set_id = mcp
@@ -732,7 +775,7 @@ async fn external_auth_refresh_mismatched_workspace_fails_turn() -> Result<()> {
.await??;
let thread_req = mcp
.send_thread_start_request(codex_app_server_protocol::ThreadStartParams {
.send_thread_start_request_with_auto_env(codex_app_server_protocol::ThreadStartParams {
model: Some("mock-model".to_string()),
..Default::default()
})
@@ -824,8 +867,11 @@ async fn external_auth_refresh_invalid_access_token_fails_turn() -> Result<()> {
.chatgpt_account_id(WORKSPACE_ID_INITIAL),
)?;
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let set_id = mcp
@@ -849,7 +895,7 @@ async fn external_auth_refresh_invalid_access_token_fails_turn() -> Result<()> {
.await??;
let thread_req = mcp
.send_thread_start_request(codex_app_server_protocol::ThreadStartParams {
.send_thread_start_request_with_auto_env(codex_app_server_protocol::ThreadStartParams {
model: Some("mock-model".to_string()),
..Default::default()
})
@@ -918,7 +964,11 @@ async fn login_account_api_key_succeeds_and_notifies() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let req_id = mcp
@@ -972,7 +1022,11 @@ async fn login_account_api_key_rejected_when_forced_chatgpt() -> Result<()> {
},
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -1002,7 +1056,11 @@ async fn login_account_chatgpt_rejected_when_forced_api() -> Result<()> {
},
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp.send_login_account_chatgpt_request().await?;
@@ -1035,14 +1093,15 @@ async fn login_account_chatgpt_device_code_returns_error_when_disabled() -> Resu
mock_device_code_usercode_failure(&mock_server, /*status*/ 404).await;
let issuer = mock_server.uri();
let mut mcp = TestAppServer::new_with_env(
codex_home.path(),
&[
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[
("OPENAI_API_KEY", None),
(LOGIN_ISSUER_ENV_VAR, Some(issuer.as_str())),
],
)
.await?;
])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp.send_login_account_chatgpt_device_code_request().await?;
@@ -1097,17 +1156,18 @@ async fn login_account_chatgpt_device_code_succeeds_and_notifies() -> Result<()>
.plan_type("pro")
.chatgpt_account_id(WORKSPACE_ID_DEVICE),
)?;
mock_device_code_oauth_token(&mock_server, &id_token).await;
mock_oauth_token(&mock_server, &id_token).await;
let issuer = mock_server.uri();
let mut mcp = TestAppServer::new_with_env(
codex_home.path(),
&[
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[
("OPENAI_API_KEY", None),
(LOGIN_ISSUER_ENV_VAR, Some(issuer.as_str())),
],
)
.await?;
])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp.send_login_account_chatgpt_device_code_request().await?;
@@ -1177,14 +1237,15 @@ async fn login_account_chatgpt_device_code_failure_notifies_without_account_upda
mock_device_code_token_failure(&mock_server, /*status*/ 500).await;
let issuer = mock_server.uri();
let mut mcp = TestAppServer::new_with_env(
codex_home.path(),
&[
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[
("OPENAI_API_KEY", None),
(LOGIN_ISSUER_ENV_VAR, Some(issuer.as_str())),
],
)
.await?;
])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp.send_login_account_chatgpt_device_code_request().await?;
@@ -1252,14 +1313,15 @@ async fn login_account_chatgpt_device_code_can_be_cancelled() -> Result<()> {
mock_device_code_token_failure(&mock_server, /*status*/ 404).await;
let issuer = mock_server.uri();
let mut mcp = TestAppServer::new_with_env(
codex_home.path(),
&[
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[
("OPENAI_API_KEY", None),
(LOGIN_ISSUER_ENV_VAR, Some(issuer.as_str())),
],
)
.await?;
])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp.send_login_account_chatgpt_device_code_request().await?;
@@ -1325,7 +1387,11 @@ async fn login_account_chatgpt_start_can_be_cancelled() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp.send_login_account_chatgpt_request().await?;
@@ -1391,14 +1457,15 @@ async fn login_account_chatgpt_uses_debug_oauth_overrides() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?;
let mut mcp = TestAppServer::new_with_env(
codex_home.path(),
&[
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[
(CLIENT_ID_OVERRIDE_ENV_VAR, Some("staging-client")),
(LOGIN_ISSUER_ENV_VAR, Some("https://auth.example.com")),
],
)
.await?;
])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp.send_login_account_chatgpt_request().await?;
@@ -1436,6 +1503,78 @@ async fn login_account_chatgpt_uses_debug_oauth_overrides() -> Result<()> {
Ok(())
}
#[tokio::test]
// Serialize tests that launch the login server since it binds to a fixed port.
#[serial(login_port)]
async fn login_account_chatgpt_redirects_to_hosted_success_page() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?;
let mock_server = MockServer::start().await;
let id_token = encode_id_token(
&ChatGptIdTokenClaims::new()
.email("hosted@example.com")
.plan_type("pro")
.chatgpt_account_id(WORKSPACE_ID_EMBEDDED),
)?;
mock_oauth_token(&mock_server, &id_token).await;
let issuer = mock_server.uri();
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[
(LOGIN_ISSUER_ENV_VAR, Some(issuer.as_str())),
(
LOGIN_OPEN_APP_URL_ENV_VAR,
Some("http://localhost:3000/codex/open-app"),
),
])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_login_account_request(json!({
"type": "chatgpt",
"appBrand": "chatgpt",
"useHostedLoginSuccessPage": true,
}))
.await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let login: LoginAccountResponse = to_response(resp)?;
let LoginAccountResponse::Chatgpt { auth_url, .. } = login else {
bail!("unexpected login response: {login:?}");
};
let auth_url = Url::parse(&auth_url)?;
let callback_url = auth_url
.query_pairs()
.find_map(|(key, value)| (key == "redirect_uri").then(|| value.into_owned()))
.ok_or_else(|| anyhow::anyhow!("missing redirect_uri"))?;
let state = auth_url
.query_pairs()
.find_map(|(key, value)| (key == "state").then(|| value.into_owned()))
.ok_or_else(|| anyhow::anyhow!("missing state"))?;
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()?;
let response = client
.get(format!("{callback_url}?code=test-code&state={state}"))
.send()
.await?;
assert_eq!(response.status(), 302);
assert_eq!(
response.headers()["location"].to_str()?,
"http://localhost:3000/codex/open-app?source=login&app_brand=chatgpt"
);
Ok(())
}
#[tokio::test]
// Serialize tests that launch the login server since it binds to a fixed port.
#[serial(login_port)]
@@ -1443,7 +1582,11 @@ async fn set_auth_token_cancels_active_chatgpt_login() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
// Initiate the ChatGPT login flow
@@ -1518,7 +1661,11 @@ async fn login_account_chatgpt_includes_forced_workspace_query_param() -> Result
},
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp.send_login_account_chatgpt_request().await?;
@@ -1555,7 +1702,11 @@ async fn login_account_chatgpt_includes_forced_workspace_allowlist_query_param()
},
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp.send_login_account_chatgpt_request().await?;
@@ -1594,8 +1745,12 @@ async fn get_account_no_auth() -> Result<()> {
},
)?;
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let params = GetAccountParams {
@@ -1626,7 +1781,11 @@ async fn get_account_with_api_key() -> Result<()> {
},
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let req_id = mcp
@@ -1670,7 +1829,11 @@ async fn get_account_when_auth_not_required() -> Result<()> {
},
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let params = GetAccountParams {
@@ -1711,7 +1874,11 @@ region = "us-west-2"
},
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let params = GetAccountParams {
@@ -1754,7 +1921,11 @@ async fn get_account_with_managed_bedrock_provider() -> Result<()> {
AuthKeyringBackendKind::default(),
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -1799,8 +1970,12 @@ async fn get_account_with_chatgpt() -> Result<()> {
AuthCredentialsStoreMode::File,
)?;
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let params = GetAccountParams {
@@ -1842,8 +2017,12 @@ async fn get_account_with_chatgpt_without_email() -> Result<()> {
AuthCredentialsStoreMode::File,
)?;
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -1905,17 +2084,18 @@ async fn get_account_omits_chatgpt_after_permanent_refresh_failure() -> Result<(
.await;
let refresh_url = format!("{}/oauth/token", server.uri());
let mut mcp = TestAppServer::new_with_env(
codex_home.path(),
&[
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[
("OPENAI_API_KEY", None),
(
REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR,
Some(refresh_url.as_str()),
),
],
)
.await?;
])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let auth_status_request_id = mcp
@@ -1971,8 +2151,12 @@ async fn get_account_with_chatgpt_missing_plan_claim_returns_unknown() -> Result
AuthCredentialsStoreMode::File,
)?;
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let params = GetAccountParams {
+159 -14
View File
@@ -8,7 +8,9 @@ use std::time::Duration;
use anyhow::Result;
use anyhow::bail;
use app_test_support::ChatGptAuthFixture;
use app_test_support::ChatGptIdTokenClaims;
use app_test_support::TestAppServer;
use app_test_support::encode_id_token;
use app_test_support::to_response;
use app_test_support::write_chatgpt_auth;
use axum::Json;
@@ -29,6 +31,7 @@ use codex_app_server_protocol::AppsListParams;
use codex_app_server_protocol::AppsListResponse;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::LoginAccountResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ThreadStartParams;
@@ -63,7 +66,11 @@ const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
#[tokio::test]
async fn list_apps_returns_empty_when_connectors_disabled() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
@@ -129,7 +136,11 @@ async fn list_apps_returns_empty_with_api_key_auth() -> Result<()> {
AuthKeyringBackendKind::default(),
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -156,6 +167,93 @@ async fn list_apps_returns_empty_with_api_key_auth() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn list_apps_uses_external_chatgpt_auth() -> Result<()> {
let access_token = encode_id_token(
&ChatGptIdTokenClaims::new()
.email("external@example.com")
.plan_type("pro")
.chatgpt_account_id("account-123"),
)?;
let connectors = vec![AppInfo {
id: "beta".to_string(),
name: "Beta".to_string(),
description: Some("Beta connector".to_string()),
logo_url: None,
logo_url_dark: None,
icon_assets: None,
icon_dark_assets: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: None,
is_accessible: false,
is_enabled: true,
plugin_display_names: Vec::new(),
}];
let tools = vec![connector_tool("beta", "Beta App")?];
let (server_url, server_handle, _) = start_apps_server_with_delays_and_control_inner(
connectors,
tools,
Duration::ZERO,
Duration::ZERO,
/*workspace_plugins_enabled*/ true,
&access_token,
)
.await?;
let codex_home = TempDir::new()?;
write_connectors_config(codex_home.path(), &server_url)?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let login_id = mcp
.send_chatgpt_auth_tokens_login_request(
access_token,
"account-123".to_string(),
Some("pro".to_string()),
)
.await?;
let login_response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(login_id)),
)
.await??;
assert_eq!(
to_response::<LoginAccountResponse>(login_response)?,
LoginAccountResponse::ChatgptAuthTokens {}
);
let request_id = mcp
.send_apps_list_request(AppsListParams {
limit: None,
cursor: None,
thread_id: None,
force_refetch: true,
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let AppsListResponse { data, next_cursor } = to_response(response)?;
assert_eq!(data.len(), 1);
assert_eq!(data[0].id, "beta");
assert!(data[0].is_accessible);
assert!(next_cursor.is_none());
server_handle.abort();
let _ = server_handle.await;
Ok(())
}
#[tokio::test]
async fn list_apps_returns_empty_when_workspace_codex_plugins_disabled() -> Result<()> {
let connectors = vec![AppInfo {
@@ -193,7 +291,12 @@ async fn list_apps_returns_empty_when_workspace_codex_plugins_disabled() -> Resu
AuthCredentialsStoreMode::File,
)?;
let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.without_managed_config()
.build()
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -238,7 +341,11 @@ async fn list_apps_includes_plugin_apps_for_chatgpt_auth() -> Result<()> {
AuthCredentialsStoreMode::File,
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -298,7 +405,10 @@ async fn list_apps_uses_thread_feature_flag_when_thread_id_is_provided() -> Resu
AuthCredentialsStoreMode::File,
)?;
let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build()
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let start_request = mcp
@@ -411,7 +521,11 @@ async fn list_apps_keeps_apps_with_app_only_tools_accessible() -> Result<()> {
AuthCredentialsStoreMode::File,
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -486,7 +600,11 @@ enabled = false
AuthCredentialsStoreMode::File,
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -609,7 +727,11 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<(
AuthCredentialsStoreMode::File,
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -759,7 +881,11 @@ async fn list_apps_waits_for_accessible_data_before_emitting_directory_updates()
AuthCredentialsStoreMode::File,
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -871,7 +997,11 @@ async fn list_apps_does_not_emit_empty_interim_updates() -> Result<()> {
AuthCredentialsStoreMode::File,
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -986,7 +1116,11 @@ async fn list_apps_paginates_results() -> Result<()> {
AuthCredentialsStoreMode::File,
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let first_request = mcp
@@ -1112,7 +1246,11 @@ async fn list_apps_force_refetch_preserves_previous_cache_on_failure() -> Result
AuthCredentialsStoreMode::File,
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let initial_request = mcp
@@ -1242,7 +1380,11 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu
AuthCredentialsStoreMode::File,
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let warm_request = mcp
@@ -1557,6 +1699,7 @@ async fn start_apps_server_with_workspace_plugins_enabled(
Duration::ZERO,
Duration::ZERO,
workspace_plugins_enabled,
"chatgpt-token",
)
.await?;
Ok((server_url, server_handle))
@@ -1574,6 +1717,7 @@ async fn start_apps_server_with_delays_and_control(
directory_delay,
tools_delay,
/*workspace_plugins_enabled*/ true,
"chatgpt-token",
)
.await
}
@@ -1584,13 +1728,14 @@ async fn start_apps_server_with_delays_and_control_inner(
directory_delay: Duration,
tools_delay: Duration,
workspace_plugins_enabled: bool,
expected_bearer: &str,
) -> Result<(String, JoinHandle<()>, AppsServerControl)> {
let response = Arc::new(StdMutex::new(
json!({ "apps": connectors, "next_token": null }),
));
let tools = Arc::new(StdMutex::new(tools));
let state = AppsServerState {
expected_bearer: "Bearer chatgpt-token".to_string(),
expected_bearer: format!("Bearer {expected_bearer}"),
expected_account_id: "account-123".to_string(),
response: response.clone(),
directory_delay,
@@ -67,8 +67,11 @@ async fn attestation_generate_round_trip_adds_header_to_responses_websocket_hand
AuthCredentialsStoreMode::File,
)?;
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build()
.await?;
let initialized = timeout(
DEFAULT_READ_TIMEOUT,
mcp.initialize_with_capabilities(
@@ -91,7 +94,7 @@ async fn attestation_generate_round_trip_adds_header_to_responses_websocket_hand
};
let thread_request_id = mcp
.send_thread_start_request(ThreadStartParams::default())
.send_thread_start_request_with_auto_env(ThreadStartParams::default())
.await?;
let thread_response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
+48 -3
View File
@@ -10,14 +10,53 @@ use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::UserInput as V2UserInput;
use core_test_support::responses;
use core_test_support::skip_if_host_windows;
use core_test_support::skip_if_remote;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
use std::time::Duration;
use std::time::Instant;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
#[tokio::test]
async fn builder_interposes_fixed_delay_for_auto_env() -> Result<()> {
skip_if_host_windows!(Ok(()));
skip_if_remote!(Ok(()), "the fixed-delay fixture is local-only");
let codex_home = TempDir::new()?;
let requested_delay = Duration::from_secs(1);
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.with_exec_server_delay(requested_delay)
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
assert_eq!(
mcp.auto_env_params()?.environment_id,
codex_exec_server::REMOTE_ENVIRONMENT_ID
);
let thread_start = Instant::now();
let request_id = mcp
.send_thread_start_request_with_auto_env(ThreadStartParams::default())
.await?;
let _: JSONRPCResponse = timeout(
Duration::from_secs(60),
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let elapsed = thread_start.elapsed();
assert!(
elapsed >= requested_delay,
"thread/start completed in {elapsed:?}, below the requested {requested_delay:?} delay"
);
Ok(())
}
#[tokio::test]
async fn thread_start_with_auto_env_exposes_fixture_cwd_to_model() -> Result<()> {
let server = responses::start_mock_server().await;
@@ -41,7 +80,10 @@ async fn thread_start_with_auto_env_exposes_fixture_cwd_to_model() -> Result<()>
"compact",
)?;
let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let expected_environment = mcp.auto_env_params()?;
@@ -110,14 +152,17 @@ async fn auto_env_rejects_explicit_environment_config() -> Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(codex_home.path().join("environments.toml"), "")?;
let result = TestAppServer::new_with_auto_env(codex_home.path()).await;
let result = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build()
.await;
let Err(err) = result else {
anyhow::bail!("auto-env construction unexpectedly succeeded");
};
assert_eq!(
err.to_string(),
format!(
"new_with_auto_env cannot be used when {} exists",
"automatic environment cannot be used when {} exists",
codex_home.path().join("environments.toml").display()
)
);
@@ -59,7 +59,10 @@ async fn turn_start_forwards_client_metadata_to_responses_request_v2() -> Result
/*supports_websockets*/ false,
)?;
let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let thread_req = mcp
@@ -155,7 +158,11 @@ async fn turn_start_sends_fork_lineage_in_turn_metadata_for_thread_fork_v2() ->
/*git_info*/ None,
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let ThreadForkResponse { thread, .. } =
@@ -239,7 +246,11 @@ async fn review_start_sends_parent_lineage_in_turn_metadata_for_thread_fork_v2()
/*git_info*/ None,
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let ThreadForkResponse { thread, .. } =
@@ -339,7 +350,11 @@ async fn turn_start_sends_nested_subagent_lineage_after_cold_thread_resume_v2()
parent_thread_id,
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let resume_req = mcp
@@ -434,7 +449,10 @@ async fn turn_steer_updates_client_metadata_on_follow_up_responses_request_v2()
/*supports_websockets*/ false,
)?;
let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let thread_req = mcp
@@ -559,7 +577,10 @@ async fn turn_start_forwards_client_metadata_to_responses_websocket_request_body
/*supports_websockets*/ true,
)?;
let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let thread_req = mcp
@@ -28,7 +28,11 @@ const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
#[tokio::test]
async fn list_collaboration_modes_returns_presets() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
@@ -42,7 +42,11 @@ async fn command_exec_without_streams_can_be_terminated() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let process_id = "sleep-1".to_string();
@@ -92,7 +96,11 @@ async fn command_exec_without_process_id_keeps_buffered_compatibility() -> Resul
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let command_request_id = mcp
@@ -140,11 +148,12 @@ async fn command_exec_env_overrides_merge_with_server_environment_and_support_un
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let mut mcp = TestAppServer::new_with_env(
codex_home.path(),
&[("COMMAND_EXEC_BASELINE", Some("server"))],
)
.await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[("COMMAND_EXEC_BASELINE", Some("server"))])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let command_request_id = mcp
@@ -198,7 +207,11 @@ async fn command_exec_accepts_permission_profile() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let command_request_id = mcp
@@ -249,7 +262,11 @@ async fn command_exec_permission_profile_starts_selected_network_proxy() -> Resu
codex_home.path(),
/*default_permissions*/ None,
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let command_request_id = mcp
@@ -297,7 +314,11 @@ async fn command_exec_permission_profile_does_not_reuse_default_network_proxy()
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
insert_networked_permission_profile_config(codex_home.path(), Some("networked"))?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let command_request_id = mcp
@@ -355,7 +376,11 @@ async fn command_exec_permission_profile_project_roots_use_command_cwd() -> Resu
":workspace_roots" = "write"
"#,
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let command_request_id = mcp
@@ -406,11 +431,12 @@ async fn command_exec_returns_error_when_local_environment_is_disabled() -> Resu
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let mut mcp = TestAppServer::new_with_env(
codex_home.path(),
&[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))],
)
.await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let command_request_id = mcp
@@ -445,7 +471,11 @@ async fn command_exec_rejects_sandbox_policy_with_permission_profile() -> Result
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let command_request_id = mcp
@@ -483,7 +513,11 @@ async fn command_exec_rejects_disable_timeout_with_timeout_ms() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let command_request_id = mcp
@@ -521,7 +555,11 @@ async fn command_exec_rejects_disable_output_cap_with_output_bytes_cap() -> Resu
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let command_request_id = mcp
@@ -559,7 +597,11 @@ async fn command_exec_rejects_negative_timeout_ms() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let command_request_id = mcp
@@ -597,7 +639,11 @@ async fn command_exec_without_process_id_rejects_streaming() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let command_request_id = mcp
@@ -635,7 +681,11 @@ async fn command_exec_non_streaming_respects_output_cap() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let command_request_id = mcp
@@ -682,7 +732,11 @@ async fn command_exec_streaming_does_not_buffer_output() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let process_id = "stream-cap-1".to_string();
@@ -746,7 +800,11 @@ async fn command_exec_pipe_streams_output_and_accepts_write() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let process_id = "pipe-1".to_string();
@@ -822,7 +880,11 @@ async fn command_exec_tty_implies_streaming_and_reports_pty_output() -> Result<(
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let process_id = "tty-1".to_string();
@@ -893,7 +955,11 @@ async fn command_exec_tty_supports_initial_size_and_resize() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let process_id = "tty-size-1".to_string();
@@ -81,7 +81,10 @@ async fn auto_compaction_local_emits_started_and_completed_items() -> Result<()>
COMPACT_PROMPT,
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let thread_id = start_thread(&mut mcp).await?;
@@ -164,8 +167,11 @@ async fn auto_compaction_remote_emits_started_and_completed_items() -> Result<()
AuthCredentialsStoreMode::File,
)?;
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let thread_id = start_thread(&mut mcp).await?;
@@ -271,7 +277,10 @@ async fn thread_compact_start_triggers_compaction_and_returns_empty_response() -
COMPACT_PROMPT,
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let thread_id = start_thread(&mut mcp).await?;
@@ -321,7 +330,10 @@ async fn thread_compact_start_rejects_invalid_thread_id() -> Result<()> {
COMPACT_PROMPT,
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -357,7 +369,10 @@ async fn thread_compact_start_rejects_unknown_thread_id() -> Result<()> {
COMPACT_PROMPT,
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -379,7 +394,7 @@ async fn thread_compact_start_rejects_unknown_thread_id() -> Result<()> {
async fn start_thread(mcp: &mut TestAppServer) -> Result<String> {
let thread_id = mcp
.send_thread_start_request(ThreadStartParams {
.send_thread_start_request_with_auto_env(ThreadStartParams {
model: Some("mock-model".to_string()),
..Default::default()
})
@@ -55,7 +55,11 @@ async fn config_requirements_read_includes_allow_remote_control() -> Result<()>
codex_home.path().join("requirements.toml"),
"allow_remote_control = false\n",
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp.send_config_requirements_read_request().await?;
@@ -87,7 +91,11 @@ model_reasoning_effort = "medium"
service_tier = "fast"
"#,
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp.send_config_requirements_read_request().await?;
@@ -125,7 +133,11 @@ sandbox_mode = "workspace-write"
let codex_home_path = codex_home.path().canonicalize()?;
let user_file = AbsolutePathBuf::try_from(codex_home_path.join("config.toml"))?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -175,7 +187,11 @@ allowed_domains = ["example.com"]
let codex_home_path = codex_home.path().canonicalize()?;
let user_file = AbsolutePathBuf::try_from(codex_home_path.join("config.toml"))?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -246,7 +262,11 @@ forced_chatgpt_workspace_id = "{WORKSPACE_ID}"
),
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -285,7 +305,11 @@ forced_chatgpt_workspace_id = ["{WORKSPACE_ID_A}", "{WORKSPACE_ID_B}"]
),
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -327,7 +351,11 @@ location = { country = "US", city = "New York", timezone = "America/New_York" }
"#,
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -371,7 +399,11 @@ web_search = true
"#,
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -412,7 +444,11 @@ default_tools_approval_mode = "prompt"
let codex_home_path = codex_home.path().canonicalize()?;
let user_file = AbsolutePathBuf::try_from(codex_home_path.join("config.toml"))?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -536,7 +572,11 @@ width = 320
"#,
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -583,7 +623,11 @@ model_reasoning_effort = "high"
set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?;
let project_config = AbsolutePathBuf::try_from(project_config_dir)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -653,14 +697,15 @@ writable_roots = [{}]
let managed_path_str = managed_path.display().to_string();
let mut mcp = TestAppServer::new_with_env(
codex_home.path(),
&[(
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[(
"CODEX_APP_SERVER_MANAGED_CONFIG_PATH",
Some(&managed_path_str),
)],
)
.await?;
)])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -749,7 +794,11 @@ model = "gpt-old"
"#,
)?;
let mut mcp = TestAppServer::new(&codex_home).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(&codex_home)
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let read_id = mcp
@@ -810,7 +859,11 @@ async fn config_value_write_updates_desktop_settings() -> Result<()> {
let codex_home = temp_dir.path().canonicalize()?;
write_config(&temp_dir, "")?;
let mut mcp = TestAppServer::new(&codex_home).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(&codex_home)
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let write_id = mcp
@@ -859,7 +912,11 @@ model = "gpt-old"
"#,
)?;
let mut mcp = TestAppServer::new(&codex_home).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(&codex_home)
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let write_id = mcp
@@ -907,7 +964,11 @@ model = "gpt-old"
"#,
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let write_id = mcp
@@ -942,7 +1003,11 @@ async fn config_batch_write_applies_multiple_edits() -> Result<()> {
let codex_home = tmp_dir.path().canonicalize()?;
write_config(&tmp_dir, "")?;
let mut mcp = TestAppServer::new(&codex_home).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(&codex_home)
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let writable_root = test_tmp_path_buf();
@@ -1014,7 +1079,11 @@ model = "gpt-5.3-spark"
"#,
)?;
let mut mcp = TestAppServer::new(&codex_home).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(&codex_home)
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let batch_id = mcp
@@ -1070,7 +1139,11 @@ async fn config_batch_write_updates_multiple_desktop_settings() -> Result<()> {
let codex_home = tmp_dir.path().canonicalize()?;
write_config(&tmp_dir, "")?;
let mut mcp = TestAppServer::new(&codex_home).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(&codex_home)
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let batch_id = mcp

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