mirror of
https://github.com/openai/codex.git
synced 2026-10-20 08:19:50 +00:00
Merge remote-tracking branch 'origin/main' into dev/winston/fix-29752
# Conflicts: # codex-rs/config/src/permissions_toml.rs
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package(default_visibility = ["//visibility:public"])
|
||||
|
||||
exports_files([
|
||||
"e2e_benchmark.bzl",
|
||||
])
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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"],
|
||||
)
|
||||
|
||||
Generated
+46
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
));
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
},
|
||||
))
|
||||
|
||||
@@ -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": [
|
||||
|
||||
+69
@@ -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",
|
||||
|
||||
+69
@@ -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
@@ -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, };
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user