Merge branch 'dev/winston/mitm-command-ca-bundles' into dev/winston/credentialed-routes-e2e

# Conflicts:
#	codex-rs/backend-client/src/client.rs
#	codex-rs/backend-client/src/lib.rs
This commit is contained in:
Winston Howes
2026-06-03 19:39:58 -07:00
329 changed files with 15716 additions and 5353 deletions
+32 -14
View File
@@ -38,24 +38,50 @@ common:windows --test_env=WINDIR
common --test_env=RUST_MIN_STACK=8388608 # 8 MiB
common --test_output=errors
common --bes_results_url=https://app.buildbuddy.io/invocation/
common --bes_backend=grpcs://remote.buildbuddy.io
common --remote_cache=grpcs://remote.buildbuddy.io
common --remote_download_toplevel
common --nobuild_runfile_links
# These settings tune BuildBuddy/RBE behavior but do not contact a remote
# service unless a `buildbuddy-*` configuration below supplies an endpoint.
common --remote_download_toplevel
common --remote_timeout=3600
common --noexperimental_throttle_remote_action_building
common --experimental_remote_execution_keepalive
common --grpc_keepalive_time=30s
common --experimental_remote_downloader=grpcs://remote.buildbuddy.io
# Opt-in remote configurations selected by
# `.github/scripts/run_bazel_with_buildbuddy.py`. Plain Bazel commands do not
# contact BuildBuddy unless a user selects one of these configurations.
# Use the generic host for cache, BES, and downloads without remote execution.
common:buildbuddy-generic --bes_backend=grpcs://remote.buildbuddy.io
common:buildbuddy-generic --bes_results_url=https://app.buildbuddy.io/invocation/
common:buildbuddy-generic --remote_cache=grpcs://remote.buildbuddy.io
common:buildbuddy-generic --experimental_remote_downloader=grpcs://remote.buildbuddy.io
# Add remote execution on the generic host.
common:buildbuddy-generic-rbe --config=buildbuddy-generic
common:buildbuddy-generic-rbe --config=remote
common:buildbuddy-generic-rbe --remote_executor=grpcs://remote.buildbuddy.io
# Use the OpenAI tenant for cache, BES, and downloads without remote execution.
common:buildbuddy-openai --bes_backend=grpcs://openai.buildbuddy.io
common:buildbuddy-openai --bes_results_url=https://openai.buildbuddy.io/invocation/
common:buildbuddy-openai --remote_cache=grpcs://openai.buildbuddy.io
common:buildbuddy-openai --experimental_remote_downloader=grpcs://openai.buildbuddy.io
# Add remote execution on the OpenAI tenant.
common:buildbuddy-openai-rbe --config=buildbuddy-openai
common:buildbuddy-openai-rbe --config=remote
common:buildbuddy-openai-rbe --remote_executor=grpcs://openai.buildbuddy.io
# This limits both in-flight executions and concurrent downloads. Even with high number
# of jobs execution will still be limited by CPU cores, so this just pays a bit of
# memory in exchange for higher download concurrency.
common --jobs=30
# Shared remote execution policy. The endpoint-bearing `buildbuddy-*-rbe`
# configurations include this group; CI configs override TestRunner below
# when tests must remain local on their runner.
common:remote --strategy=remote
common:remote --extra_execution_platforms=//:rbe
common:remote --remote_executor=grpcs://remote.buildbuddy.io
common:remote --jobs=800
# TODO(team): Evaluate if this actually helps, zbarsky is not sure, everything seems bottlenecked on `core` either way.
# Enable pipelined compilation since we are not bound by local CPU count.
@@ -146,15 +172,11 @@ common:ci-windows --repo_contents_cache=D:/a/.cache/bazel-repo-contents-cache
# Linux crossbuilds don't work until we untangle the libc constraint mess.
common:ci-linux --config=ci-bazel
common:ci-linux --build_metadata=TAG_os=linux
common:ci-linux --config=remote
common:ci-linux --strategy=remote
common:ci-linux --platforms=//:rbe
# On mac, we can run all the build actions remotely but test actions locally.
common:ci-macos --config=ci-bazel
common:ci-macos --build_metadata=TAG_os=macos
common:ci-macos --config=remote
common:ci-macos --strategy=remote
common:ci-macos --strategy=TestRunner=darwin-sandbox,local
# On Windows, use Linux remote execution for build actions but keep test actions
@@ -162,9 +184,7 @@ common:ci-macos --strategy=TestRunner=darwin-sandbox,local
# still run against Windows binaries.
common:ci-windows-cross --config=ci-windows
common:ci-windows-cross --build_metadata=TAG_windows_cross_compile=true
common:ci-windows-cross --config=remote
common:ci-windows-cross --host_platform=//:rbe
common:ci-windows-cross --strategy=remote
common:ci-windows-cross --strategy=TestRunner=local
common:ci-windows-cross --local_test_jobs=4
common:ci-windows-cross --test_env=RUST_TEST_THREADS=1
@@ -180,8 +200,6 @@ common:ci-windows-cross --extra_toolchains=//:windows_gnullvm_tests_on_msvc_host
common:ci-v8 --config=ci
common:ci-v8 --build_metadata=TAG_workflow=v8
common:ci-v8 --build_metadata=TAG_os=linux
common:ci-v8 --config=remote
common:ci-v8 --strategy=remote
# Source-built Bazel V8 artifacts use the in-process sandbox by default. This
# does not affect Cargo's default prebuilt rusty_v8 path.
@@ -1,119 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: archive-release-symbols-and-strip-binaries.sh \
--target <rust-target> \
--artifact-name <artifact-name> \
--release-dir <dir> \
--archive-dir <dir> \
--binaries "<space-delimited binary basenames>"
EOF
}
target=""
artifact_name=""
release_dir=""
archive_dir=""
binaries=""
while [[ $# -gt 0 ]]; do
case "$1" in
--target)
target="${2:?--target requires a value}"
shift 2
;;
--artifact-name)
artifact_name="${2:?--artifact-name requires a value}"
shift 2
;;
--release-dir)
release_dir="${2:?--release-dir requires a value}"
shift 2
;;
--archive-dir)
archive_dir="${2:?--archive-dir requires a value}"
shift 2
;;
--binaries)
binaries="${2:?--binaries requires a value}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unexpected argument: $1" >&2
usage >&2
exit 1
;;
esac
done
if [[ -z "$target" || -z "$artifact_name" || -z "$release_dir" || -z "$archive_dir" || -z "$binaries" ]]; then
usage >&2
exit 1
fi
symbols_root="${RUNNER_TEMP:-/tmp}/codex-symbols-${artifact_name}"
symbols_dir="${symbols_root}/codex-symbols-${artifact_name}"
archive_path="${archive_dir%/}/codex-symbols-${artifact_name}.tar.gz"
rm -rf "$symbols_root"
mkdir -p "$symbols_dir" "$archive_dir"
read -r -a binary_names <<< "$binaries"
case "$target" in
*apple-darwin)
for binary in "${binary_names[@]}"; do
binary_path="${release_dir%/}/${binary}"
dsym_path="${binary_path}.dSYM"
if [[ ! -f "$binary_path" ]]; then
echo "Binary $binary_path not found" >&2
exit 1
fi
if [[ ! -d "$dsym_path" ]]; then
echo "dSYM $dsym_path not found" >&2
exit 1
fi
cp -RL "$dsym_path" "${symbols_dir}/${binary}.dSYM"
strip -S -x "$binary_path"
done
;;
*linux*)
objcopy_bin="${OBJCOPY:-objcopy}"
strip_bin="${STRIP:-strip}"
for binary in "${binary_names[@]}"; do
binary_path="${release_dir%/}/${binary}"
debug_path="${symbols_dir}/${binary}.debug"
if [[ ! -f "$binary_path" ]]; then
echo "Binary $binary_path not found" >&2
exit 1
fi
"$objcopy_bin" --only-keep-debug "$binary_path" "$debug_path"
"$strip_bin" --strip-debug --strip-unneeded "$binary_path"
"$objcopy_bin" --add-gnu-debuglink="$debug_path" "$binary_path"
done
;;
*windows*)
for binary in "${binary_names[@]}"; do
pdb_path="${release_dir%/}/${binary}.pdb"
if [[ ! -f "$pdb_path" ]]; then
echo "PDB $pdb_path not found" >&2
exit 1
fi
cp "$pdb_path" "${symbols_dir}/${binary}.pdb"
done
;;
*)
echo "No symbols packaging support for target: $target" >&2
exit 1
;;
esac
rm -f "$archive_path"
tar -C "$symbols_root" -czf "$archive_path" "codex-symbols-${artifact_name}"
+45 -81
View File
@@ -53,11 +53,20 @@ fi
run_bazel() {
if [[ "${RUNNER_OS:-}" == "Windows" ]]; then
MSYS2_ARG_CONV_EXCL='*' bazel "$@"
MSYS2_ARG_CONV_EXCL='*' "$(dirname "${BASH_SOURCE[0]}")/run_bazel_with_buildbuddy.py" "$@"
return
fi
bazel "$@"
"$(dirname "${BASH_SOURCE[0]}")/run_bazel_with_buildbuddy.py" "$@"
}
run_bazel_with_startup_args() {
if (( ${#bazel_startup_args[@]} > 0 )); then
run_bazel "${bazel_startup_args[@]}" "$@"
return
fi
run_bazel "$@"
}
ci_config=ci-linux
@@ -77,23 +86,16 @@ esac
print_bazel_test_log_tails() {
local console_log="$1"
local testlogs_dir
local -a bazel_info_cmd=(bazel)
local -a bazel_info_args=(info)
if (( ${#bazel_startup_args[@]} > 0 )); then
bazel_info_cmd+=("${bazel_startup_args[@]}")
fi
# `bazel info` needs the same CI config as the failed test invocation so
# platform-specific output roots match. On Windows, omitting `ci-windows`
# would point at `local_windows-fastbuild` even when the test ran with the
# MSVC host platform under `local_windows_msvc-fastbuild`.
if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then
bazel_info_args+=(
"--config=${ci_config}"
"--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}"
)
# `bazel info` needs the same CI config as the failed test invocation so
# platform-specific output roots match. On Windows, omitting `ci-windows`
# would point at `local_windows-fastbuild` even when the test ran with the
# MSVC host platform under `local_windows_msvc-fastbuild`.
bazel_info_args+=("--config=${ci_config}")
fi
# Only pass flags that affect Bazel's output-root selection or repository
# lookup. Test/build-only flags such as execution logs or remote download
# mode can make `bazel info` fail, which would hide the real test log path.
@@ -105,7 +107,7 @@ print_bazel_test_log_tails() {
esac
done
testlogs_dir="$(run_bazel "${bazel_info_cmd[@]:1}" \
testlogs_dir="$(run_bazel_with_startup_args \
--noexperimental_remote_repo_contents_cache \
"${bazel_info_args[@]}" \
bazel-testlogs 2>/dev/null || echo bazel-testlogs)"
@@ -254,8 +256,9 @@ if [[ ${#bazel_args[@]} -eq 0 || ${#bazel_targets[@]} -eq 0 ]]; then
fi
if [[ "${RUNNER_OS:-}" == "Windows" && $windows_cross_compile -eq 1 && -z "${BUILDBUDDY_API_KEY:-}" ]]; then
# Fork PRs do not receive the BuildBuddy secret needed for the remote
# cross-compile config. Preserve the previous local Windows build shape.
# Windows cross-compilation depends on authenticated RBE. Preserve the local
# Windows build shape when credentials are unavailable.
ci_config=ci-windows
windows_msvc_host_platform=1
fi
@@ -297,9 +300,9 @@ if [[ "${RUNNER_OS:-}" == "Windows" && $windows_cross_compile -eq 1 && -n "${BUI
fi
if [[ "${RUNNER_OS:-}" == "Windows" && $windows_cross_compile -eq 1 && -z "${BUILDBUDDY_API_KEY:-}" ]]; then
# The Windows cross-compile config depends on remote execution. Fork PRs do
# not receive the BuildBuddy secret, so fall back to the existing local build
# shape and keep its lower concurrency cap.
# The Windows cross-compile config depends on authenticated remote
# execution. When credentials are unavailable, keep the local build shape
# and its lower concurrency cap.
post_config_bazel_args+=(--jobs=8)
fi
@@ -377,70 +380,31 @@ fi
bazel_console_log="$(mktemp)"
trap 'rm -f "$bazel_console_log"' EXIT
bazel_cmd=(bazel)
if (( ${#bazel_startup_args[@]} > 0 )); then
bazel_cmd+=("${bazel_startup_args[@]}")
fi
bazel_run_args=(
"${bazel_args[@]}"
)
if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then
echo "BuildBuddy API key is available; using remote Bazel configuration."
# Work around Bazel 9 remote repo contents cache / overlay materialization failures
# seen in CI (for example "is not a symlink" or permission errors while
# materializing external repos such as rules_perl). We still use BuildBuddy for
# remote execution/cache; this only disables the startup-level repo contents cache.
bazel_run_args=(
"${bazel_args[@]}"
"--config=${ci_config}"
"--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}"
)
if (( ${#post_config_bazel_args[@]} > 0 )); then
bazel_run_args+=("${post_config_bazel_args[@]}")
fi
set +e
run_bazel "${bazel_cmd[@]:1}" \
--noexperimental_remote_repo_contents_cache \
"${bazel_run_args[@]}" \
-- \
"${bazel_targets[@]}" \
2>&1 | tee "$bazel_console_log"
bazel_status=${PIPESTATUS[0]}
set -e
bazel_run_args+=("--config=${ci_config}")
else
echo "BuildBuddy API key is not available; using local Bazel configuration."
# Keep fork/community PRs on Bazel but disable remote services that are
# configured in .bazelrc and require auth.
#
# Flag docs:
# - Command-line reference: https://bazel.build/reference/command-line-reference
# - Remote caching overview: https://bazel.build/remote/caching
# - Remote execution overview: https://bazel.build/remote/rbe
# - Build Event Protocol overview: https://bazel.build/remote/bep
#
# --noexperimental_remote_repo_contents_cache:
# disable remote repo contents cache enabled in .bazelrc startup options.
# https://bazel.build/reference/command-line-reference#startup_options-flag--experimental_remote_repo_contents_cache
# --remote_cache= and --remote_executor=:
# clear remote cache/execution endpoints configured in .bazelrc.
# https://bazel.build/reference/command-line-reference#common_options-flag--remote_cache
# https://bazel.build/reference/command-line-reference#common_options-flag--remote_executor
bazel_run_args=(
"${bazel_args[@]}"
--remote_cache=
--remote_executor=
)
if (( ${#post_config_bazel_args[@]} > 0 )); then
bazel_run_args+=("${post_config_bazel_args[@]}")
fi
set +e
run_bazel "${bazel_cmd[@]:1}" \
--noexperimental_remote_repo_contents_cache \
"${bazel_run_args[@]}" \
-- \
"${bazel_targets[@]}" \
2>&1 | tee "$bazel_console_log"
bazel_status=${PIPESTATUS[0]}
set -e
fi
if (( ${#post_config_bazel_args[@]} > 0 )); then
bazel_run_args+=("${post_config_bazel_args[@]}")
fi
set +e
# Work around Bazel 9 remote repo contents cache / overlay materialization
# failures seen in CI (for example "is not a symlink" or permission errors
# while materializing external repos such as rules_perl). This only disables
# the startup-level repo contents cache; keyed runs still use BuildBuddy.
run_bazel_with_startup_args \
--noexperimental_remote_repo_contents_cache \
"${bazel_run_args[@]}" \
-- \
"${bazel_targets[@]}" \
2>&1 | tee "$bazel_console_log"
bazel_status=${PIPESTATUS[0]}
set -e
if [[ ${bazel_status:-0} -ne 0 ]]; then
if [[ $print_failed_bazel_action_summary -eq 1 ]]; then
+11 -45
View File
@@ -2,48 +2,17 @@
set -euo pipefail
# Run Bazel queries with the same CI startup settings as the main build/test
# invocation so target-discovery queries can reuse the same Bazel server.
# Run target-discovery queries with the same startup settings as the main
# build/test invocation so they can reuse the same Bazel server. Queries only
# enumerate labels, so they intentionally do not select CI or remote configs.
query_args=()
windows_cross_compile=0
while [[ $# -gt 0 ]]; do
case "$1" in
--windows-cross-compile)
windows_cross_compile=1
shift
;;
--)
shift
break
;;
*)
query_args+=("$1")
shift
;;
esac
done
if [[ $# -ne 1 ]]; then
echo "Usage: $0 [--windows-cross-compile] [<bazel query args>...] -- <query expression>" >&2
if [[ $# -lt 2 || "${@: -2:1}" != "--" ]]; then
echo "Usage: $0 [<bazel query args>...] -- <query expression>" >&2
exit 1
fi
query_expression="$1"
ci_config=ci-linux
case "${RUNNER_OS:-}" in
macOS)
ci_config=ci-macos
;;
Windows)
if [[ $windows_cross_compile -eq 1 ]]; then
ci_config=ci-windows-cross
else
ci_config=ci-windows
fi
;;
esac
query_args=("${@:1:$#-2}")
query_expression="${@: -1}"
bazel_startup_args=()
if [[ -n "${BAZEL_OUTPUT_USER_ROOT:-}" ]]; then
@@ -60,12 +29,6 @@ run_bazel() {
}
bazel_query_args=(--noexperimental_remote_repo_contents_cache query)
if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then
bazel_query_args+=(
"--config=${ci_config}"
"--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}"
)
fi
if [[ -n "${BAZEL_REPO_CONTENTS_CACHE:-}" ]]; then
bazel_query_args+=("--repo_contents_cache=${BAZEL_REPO_CONTENTS_CACHE}")
@@ -75,7 +38,10 @@ if [[ -n "${BAZEL_REPOSITORY_CACHE:-}" ]]; then
bazel_query_args+=("--repository_cache=${BAZEL_REPOSITORY_CACHE}")
fi
bazel_query_args+=("${query_args[@]}" "$query_expression")
if (( ${#query_args[@]} > 0 )); then
bazel_query_args+=("${query_args[@]}")
fi
bazel_query_args+=("$query_expression")
if (( ${#bazel_startup_args[@]} > 0 )); then
run_bazel "${bazel_startup_args[@]}" "${bazel_query_args[@]}"
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env python3
import json
import os
import subprocess
import sys
from collections.abc import Mapping
from collections.abc import Sequence
from pathlib import Path
OPENAI_REPOSITORY = "openai/codex"
# Remote configurations select cache/BES/download endpoints. Their -rbe forms
# also select the matching remote executor endpoint.
GENERIC_REMOTE_CONFIG = "buildbuddy-generic"
OPENAI_REMOTE_CONFIG = "buildbuddy-openai"
# These CI configurations require remote build execution. The wrapper supplies
# an RBE configuration, which also includes the common `remote` settings.
REMOTE_EXECUTION_CONFIGS = {
"--config=ci-linux",
"--config=ci-macos",
"--config=ci-v8",
"--config=ci-windows-cross",
}
# Only authenticated workflow runs executing trusted upstream code may use the
# OpenAI BuildBuddy host. A pull request event without proof that its head is
# in the upstream repository fails closed to the generic host.
def is_trusted_upstream_run(env: Mapping[str, str]) -> bool:
# `GITHUB_REPOSITORY` is easy to set locally. Requiring GitHub's workflow
# marker prevents a local command from opting itself into the OpenAI host.
if (
env.get("GITHUB_ACTIONS") != "true"
or env.get("GITHUB_REPOSITORY") != OPENAI_REPOSITORY
):
return False
# Non-PR workflow runs in `openai/codex` execute upstream refs, so they are
# trusted. Fork code reaches these workflows only through pull requests.
if env.get("GITHUB_EVENT_NAME") != "pull_request":
return True
event_path = env.get("GITHUB_EVENT_PATH")
if not event_path:
return False
try:
event = json.loads(Path(event_path).read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return False
try:
return event["pull_request"]["head"]["repo"]["fork"] is False
except (KeyError, TypeError):
return False
def uses_openai_host(env: Mapping[str, str]) -> bool:
return bool(env.get("BUILDBUDDY_API_KEY")) and is_trusted_upstream_run(env)
def uses_remote_execution(args: Sequence[str]) -> bool:
try:
separator_idx = args.index("--")
except ValueError:
separator_idx = len(args)
return any(arg in REMOTE_EXECUTION_CONFIGS for arg in args[:separator_idx])
def remote_config(args: Sequence[str], env: Mapping[str, str]) -> str | None:
if not env.get("BUILDBUDDY_API_KEY"):
return None
config = OPENAI_REMOTE_CONFIG if uses_openai_host(env) else GENERIC_REMOTE_CONFIG
if uses_remote_execution(args):
config += "-rbe"
return config
def bazel_args_without_remote_execution(args: Sequence[str]) -> list[str]:
# Remote CI configs require BuildBuddy credentials. Removing them preserves
# the local fallback used for fork pull requests.
try:
separator_idx = args.index("--")
except ValueError:
separator_idx = len(args)
return [
*(arg for arg in args[:separator_idx] if arg not in REMOTE_EXECUTION_CONFIGS),
*args[separator_idx:],
]
def bazel_args_with_remote_config(
args: Sequence[str], env: Mapping[str, str]
) -> list[str]:
config = remote_config(args, env)
if config is None:
return bazel_args_without_remote_execution(args)
# `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.
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]:
env = os.environ if env is None else env
bazel = env.get("CODEX_BAZEL_BIN", "bazel")
return [bazel, *bazel_args_with_remote_config(args, env)]
def main() -> None:
config = remote_config(sys.argv[1:], os.environ)
if config is None:
print(
"BuildBuddy key unavailable; using local Bazel configuration.",
file=sys.stderr,
)
else:
host_description = (
"OpenAI tenant" if uses_openai_host(os.environ) else "generic"
)
print(
f"Using {host_description} BuildBuddy configuration: {config}.",
file=sys.stderr,
)
command = bazel_command(*sys.argv[1:])
if os.name == "nt":
# Windows CRT exec can split arguments containing spaces and lose the
# eventual child exit status. Wait for Bazel and propagate its status.
result = subprocess.run(command, check=False)
raise SystemExit(result.returncode)
os.execvp(command[0], command)
if __name__ == "__main__":
main()
+28 -35
View File
@@ -5,7 +5,6 @@ from __future__ import annotations
import argparse
import gzip
import hashlib
import os
import re
import shutil
import subprocess
@@ -13,6 +12,7 @@ import sys
import tomllib
from pathlib import Path
from run_bazel_with_buildbuddy import bazel_command
from rusty_v8_module_bazel import (
RustyV8ChecksumError,
check_module_bazel,
@@ -29,33 +29,22 @@ SANDBOX_ARTIFACT_PROFILE = "ptrcomp_sandbox_release"
ARTIFACT_BAZEL_CONFIGS = ["rusty-v8-upstream-libcxx"]
def bazel_remote_args() -> list[str]:
buildbuddy_api_key = os.environ.get("BUILDBUDDY_API_KEY")
if not buildbuddy_api_key:
return []
return [f"--remote_header=x-buildbuddy-api-key={buildbuddy_api_key}"]
def bazel_execroot() -> Path:
result = subprocess.run(
["bazel", "info", "execution_root"],
output = subprocess.check_output(
bazel_command("info", "execution_root"),
cwd=ROOT,
check=True,
capture_output=True,
text=True,
)
return Path(result.stdout.strip())
return Path(output.strip())
def bazel_output_base() -> Path:
result = subprocess.run(
["bazel", "info", "output_base"],
output = subprocess.check_output(
bazel_command("info", "output_base"),
cwd=ROOT,
check=True,
capture_output=True,
text=True,
)
return Path(result.stdout.strip())
return Path(output.strip())
def bazel_output_path(path: str) -> Path:
@@ -72,24 +61,22 @@ def bazel_output_files(
) -> list[Path]:
expression = "set(" + " ".join(labels) + ")"
bazel_configs = bazel_configs or []
result = subprocess.run(
[
"bazel",
output = subprocess.check_output(
bazel_command(
"cquery",
"-c",
compilation_mode,
f"--platforms=@llvm//platforms:{platform}",
*[f"--config={config}" for config in bazel_configs],
*bazel_remote_args(),
"--output=files",
expression,
],
),
cwd=ROOT,
check=True,
capture_output=True,
text=True,
)
return [bazel_output_path(line.strip()) for line in result.stdout.splitlines() if line.strip()]
return [
bazel_output_path(line.strip()) for line in output.splitlines() if line.strip()
]
def bazel_build(
@@ -102,17 +89,15 @@ def bazel_build(
bazel_configs = bazel_configs or []
download_args = ["--remote_download_toplevel"] if download_toplevel else []
subprocess.run(
[
"bazel",
bazel_command(
"build",
"-c",
compilation_mode,
f"--platforms=@llvm//platforms:{platform}",
*[f"--config={config}" for config in bazel_configs],
*bazel_remote_args(),
*download_args,
*labels,
],
),
cwd=ROOT,
check=True,
)
@@ -172,7 +157,7 @@ def resolved_v8_crate_version() -> str:
matches = sorted(
set(
re.findall(
r'https://static\.crates\.io/crates/v8/v8-([0-9]+\.[0-9]+\.[0-9]+)\.crate',
r"https://static\.crates\.io/crates/v8/v8-([0-9]+\.[0-9]+\.[0-9]+)\.crate",
module_bazel,
)
)
@@ -234,13 +219,17 @@ def stage_artifacts(
output_dir: Path,
sandbox: bool,
) -> None:
missing_paths = [str(path) for path in [lib_path, binding_path] if not path.exists()]
missing_paths = [
str(path) for path in [lib_path, binding_path] if not path.exists()
]
if missing_paths:
raise SystemExit(f"missing release outputs for {target}: {missing_paths}")
output_dir.mkdir(parents=True, exist_ok=True)
artifact_profile = SANDBOX_ARTIFACT_PROFILE if sandbox else RELEASE_ARTIFACT_PROFILE
staged_library = output_dir / staged_archive_name(target, lib_path, artifact_profile)
staged_library = output_dir / staged_archive_name(
target, lib_path, artifact_profile
)
staged_binding = output_dir / staged_binding_name(target, artifact_profile)
with lib_path.open("rb") as src, staged_library.open("wb") as dst:
@@ -270,7 +259,9 @@ def stage_artifacts(
def upstream_release_pair_paths(source_root: Path, target: str) -> tuple[Path, Path]:
lib_name = "rusty_v8.lib" if target.endswith("-pc-windows-msvc") else "librusty_v8.a"
lib_name = (
"rusty_v8.lib" if target.endswith("-pc-windows-msvc") else "librusty_v8.a"
)
gn_out = source_root / "target" / target / "release" / "gn_out"
return gn_out / "obj" / lib_name, gn_out / "src_binding.rs"
@@ -338,7 +329,9 @@ def parse_args() -> argparse.Namespace:
stage_upstream_release_pair_parser = subparsers.add_parser(
"stage-upstream-release-pair"
)
stage_upstream_release_pair_parser.add_argument("--source-root", type=Path, required=True)
stage_upstream_release_pair_parser.add_argument(
"--source-root", 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)
stage_upstream_release_pair_parser.add_argument("--sandbox", action="store_true")
@@ -0,0 +1,214 @@
#!/usr/bin/env python3
import json
import os
import subprocess
import sys
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
import run_bazel_with_buildbuddy
class RunBazelWithBuildBuddyTest(unittest.TestCase):
def github_env(
self,
temp_dir: str,
*,
repository: str = "openai/codex",
fork: bool = False,
event_name: str = "pull_request",
) -> dict[str, str]:
event_path = Path(temp_dir) / "event.json"
event_path.write_text(
json.dumps({"pull_request": {"head": {"repo": {"fork": fork}}}}),
encoding="utf-8",
)
return {
"BUILDBUDDY_API_KEY": "token",
"GITHUB_ACTIONS": "true",
"GITHUB_EVENT_NAME": event_name,
"GITHUB_EVENT_PATH": str(event_path),
"GITHUB_REPOSITORY": repository,
}
def test_keyless_invocation_drops_remote_ci_configuration(self) -> None:
self.assertIsNone(
run_bazel_with_buildbuddy.remote_config(
["build", "--config=ci-linux", "//codex-rs/cli:codex"],
{},
)
)
self.assertEqual(
run_bazel_with_buildbuddy.bazel_args_with_remote_config(
["build", "--config=ci-linux", "--", "//codex-rs/cli:codex"],
{},
),
["build", "--", "//codex-rs/cli:codex"],
)
def test_program_arguments_after_separator_do_not_select_or_lose_rbe(self) -> None:
args = ["run", "//codex-rs/cli:codex", "--", "--config=remote"]
self.assertEqual(
run_bazel_with_buildbuddy.bazel_args_with_remote_config(args, {}),
args,
)
self.assertEqual(
run_bazel_with_buildbuddy.remote_config(
args, {"BUILDBUDDY_API_KEY": "fork-token"}
),
"buildbuddy-generic",
)
def test_upstream_push_selects_openai_rbe_before_target_separator(self) -> None:
with TemporaryDirectory() as temp_dir:
env = self.github_env(temp_dir, event_name="push")
self.assertEqual(
run_bazel_with_buildbuddy.bazel_args_with_remote_config(
["build", "--config=ci-linux", "--", "//codex-rs/cli:codex"],
env,
),
[
"build",
"--config=buildbuddy-openai-rbe",
"--remote_header=x-buildbuddy-api-key=token",
"--config=ci-linux",
"--",
"//codex-rs/cli:codex",
],
)
def test_windows_cross_ci_configuration_follows_remote_configuration(self) -> None:
env = {"BUILDBUDDY_API_KEY": "fork-token"}
self.assertEqual(
run_bazel_with_buildbuddy.bazel_args_with_remote_config(
["build", "--config=ci-windows-cross", "//codex-rs/cli:codex"],
env,
),
[
"build",
"--config=buildbuddy-generic-rbe",
"--remote_header=x-buildbuddy-api-key=fork-token",
"--config=ci-windows-cross",
"//codex-rs/cli:codex",
],
)
def test_query_remote_configuration_is_inserted_before_expression(self) -> None:
expression = 'kind("rust_library rule", //codex-rs/...)'
env = {"BUILDBUDDY_API_KEY": "fork-token"}
for command in ("query", "cquery", "aquery"):
with self.subTest(command=command):
self.assertEqual(
run_bazel_with_buildbuddy.bazel_args_with_remote_config(
[
command,
"--config=ci-windows-cross",
"--output=label",
expression,
],
env,
),
[
command,
"--config=buildbuddy-generic-rbe",
"--remote_header=x-buildbuddy-api-key=fork-token",
"--config=ci-windows-cross",
"--output=label",
expression,
],
)
def test_same_repository_pull_request_selects_openai_host(self) -> None:
with TemporaryDirectory() as temp_dir:
self.assertEqual(
run_bazel_with_buildbuddy.remote_config(
["build", "--config=ci-v8"], self.github_env(temp_dir)
),
"buildbuddy-openai-rbe",
)
def test_fork_pull_request_cannot_select_openai_host(self) -> None:
with TemporaryDirectory() as temp_dir:
env = self.github_env(temp_dir, fork=True)
self.assertEqual(
run_bazel_with_buildbuddy.remote_config(
["build", "--config=ci-v8"], env
),
"buildbuddy-generic-rbe",
)
def test_run_in_fork_repository_cannot_select_openai_host(self) -> None:
with TemporaryDirectory() as temp_dir:
env = self.github_env(temp_dir, repository="contributor/codex")
self.assertEqual(
run_bazel_with_buildbuddy.remote_config(
["build", "--config=ci-v8"], env
),
"buildbuddy-generic-rbe",
)
def test_pull_request_without_readable_event_payload_fails_closed(self) -> None:
for event_path in (None, "missing-event.json"):
env = {
"BUILDBUDDY_API_KEY": "token",
"GITHUB_ACTIONS": "true",
"GITHUB_EVENT_NAME": "pull_request",
"GITHUB_REPOSITORY": "openai/codex",
}
if event_path is not None:
env["GITHUB_EVENT_PATH"] = event_path
with self.subTest(event_path=event_path):
self.assertEqual(
run_bazel_with_buildbuddy.remote_config(["build"], env),
"buildbuddy-generic",
)
def test_bazel_command_uses_configured_binary_locally(self) -> None:
self.assertEqual(
run_bazel_with_buildbuddy.bazel_command(
"info",
"execution_root",
env={"CODEX_BAZEL_BIN": "fake-bazel"},
),
["fake-bazel", "info", "execution_root"],
)
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"
)
child_code = (
f"import sys; sys.exit(37 if sys.argv[1] == {spaced_arg!r} else 91)"
)
env = os.environ.copy()
env["CODEX_BAZEL_BIN"] = sys.executable
env.pop("BUILDBUDDY_API_KEY", None)
result = subprocess.run(
[
sys.executable,
str(Path(run_bazel_with_buildbuddy.__file__)),
"-c",
child_code,
spaced_arg,
],
env=env,
check=False,
capture_output=True,
text=True,
)
self.assertEqual(result.returncode, 37, result.stderr)
if __name__ == "__main__":
unittest.main()
+35 -14
View File
@@ -88,24 +88,49 @@ class RustyV8BazelTest(unittest.TestCase):
),
)
def test_bazel_remote_args_include_buildbuddy_header_when_present(self) -> None:
with patch.dict(environ, {"BUILDBUDDY_API_KEY": "token"}, clear=False):
def test_bazel_commands_use_shared_buildbuddy_remote_config_library(self) -> None:
with patch.dict(environ, {}, clear=True):
self.assertEqual(
["--remote_header=x-buildbuddy-api-key=token"],
rusty_v8_bazel.bazel_remote_args(),
[
"bazel",
"build",
"//third_party/v8:release",
],
rusty_v8_bazel.bazel_command(
"build",
"--config=ci-v8",
"//third_party/v8:release",
),
)
with patch.dict(environ, {"BUILDBUDDY_API_KEY": "token"}, clear=True):
self.assertEqual(
[
"bazel",
"build",
"--config=buildbuddy-generic-rbe",
"--remote_header=x-buildbuddy-api-key=token",
"--config=ci-v8",
"//third_party/v8:release",
],
rusty_v8_bazel.bazel_command(
"build",
"--config=ci-v8",
"//third_party/v8:release",
),
)
with patch.dict(environ, {}, clear=True):
self.assertEqual([], rusty_v8_bazel.bazel_remote_args())
def test_release_pair_labels_and_staged_names_distinguish_sandbox_artifacts(self) -> None:
def test_release_pair_labels_and_staged_names_distinguish_sandbox_artifacts(
self,
) -> None:
self.assertEqual(
"//third_party/v8:rusty_v8_release_pair_x86_64_unknown_linux_musl",
rusty_v8_bazel.release_pair_label("x86_64-unknown-linux-musl"),
)
self.assertEqual(
"//third_party/v8:rusty_v8_sandbox_release_pair_x86_64_unknown_linux_musl",
rusty_v8_bazel.release_pair_label("x86_64-unknown-linux-musl", sandbox=True),
rusty_v8_bazel.release_pair_label(
"x86_64-unknown-linux-musl", sandbox=True
),
)
self.assertEqual(
"//third_party/v8:rusty_v8_sandbox_release_pair_x86_64_apple_darwin",
@@ -205,11 +230,7 @@ class RustyV8BazelTest(unittest.TestCase):
with TemporaryDirectory() as source_dir, TemporaryDirectory() as output_dir:
source_root = Path(source_dir)
gn_out = (
source_root
/ "target"
/ "x86_64-pc-windows-msvc"
/ "release"
/ "gn_out"
source_root / "target" / "x86_64-pc-windows-msvc" / "release" / "gn_out"
)
(gn_out / "obj").mkdir(parents=True)
(gn_out / "obj" / "rusty_v8.lib").write_bytes(b"archive")
+12 -1
View File
@@ -15,6 +15,7 @@ concurrency:
# See https://docs.github.com/en/actions/using-jobs/using-concurrency and https://docs.github.com/en/actions/learn-github-actions/contexts for more info.
group: concurrency-group::${{ github.workflow }}::${{ github.event.pull_request.number > 0 && format('pr-{0}', github.event.pull_request.number) || github.ref_name }}${{ github.ref_name == 'main' && format('::{0}', github.run_id) || ''}}
cancel-in-progress: ${{ github.ref_name != 'main' }}
jobs:
test:
# PRs use the sharded Windows cross-compiled test jobs below. Post-merge
@@ -55,12 +56,17 @@ jobs:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
- uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49
if: matrix.os == 'ubuntu-24.04' && matrix.target == 'x86_64-unknown-linux-gnu'
with:
tool: just
- name: Check rusty_v8 MODULE.bazel checksums
if: matrix.os == 'ubuntu-24.04' && matrix.target == 'x86_64-unknown-linux-gnu'
shell: bash
run: |
python3 .github/scripts/rusty_v8_bazel.py check-module-bazel
python3 -m unittest discover -s .github/scripts -p test_rusty_v8_bazel.py
just test-github-scripts
- name: Prepare Bazel CI
id: prepare_bazel
@@ -152,6 +158,11 @@ jobs:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
- name: Test BuildBuddy Bazel wrapper
if: matrix.shard == 1
shell: pwsh
run: python .github/scripts/test_run_bazel_with_buildbuddy.py
- name: Prepare Bazel CI
id: prepare_bazel
uses: ./.github/actions/prepare-bazel-ci
+5
View File
@@ -6,6 +6,11 @@ on:
branches:
- main
# Cargo's libgit2 transport has been flaky when fetching git dependencies with
# nested submodules. Prefer the system git CLI across every Cargo invocation.
env:
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
jobs:
cargo-deny:
runs-on: ubuntu-latest
+3 -3
View File
@@ -76,13 +76,13 @@ jobs:
- uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49
with:
tool: just
tool: just@1.51.0
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
version: "0.11.3"
- name: Ruff format Python scripts (run `just fmt` to fix)
run: just fmt-scripts-check
- name: Check formatting (run `just fmt` to fix)
run: just fmt-check
- name: Prettier (run `pnpm run format:fix` to fix)
run: pnpm run format
+150 -9
View File
@@ -4,15 +4,161 @@ on:
push:
tags:
- "python-v*"
workflow_dispatch:
inputs:
runtime_version:
description: "Runtime version to publish before updating the SDK pin, for example 0.136.0 or 0.136.0a2."
required: true
type: string
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
jobs:
build-python-sdk:
# Publish the platform-specific Python runtime wheels before building the SDK
# package that pins them, or explicitly before updating the SDK runtime pin.
# PyPI project configuration must trust this workflow and job for publishing.
publish-python-runtime:
if: github.repository == 'openai/codex'
name: publish-python-runtime
runs-on: ubuntu-latest
environment: pypi
permissions:
contents: read
id-token: write # Required for PyPI trusted publishing.
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Validate SDK tag and resolve Python runtime release
id: python_runtime
shell: bash
env:
REQUESTED_RUNTIME_VERSION: ${{ inputs.runtime_version }}
run: |
set -euo pipefail
python3 - <<'PY'
import os
import re
import tomllib
from pathlib import Path
event_name = os.environ["GITHUB_EVENT_NAME"]
if event_name == "workflow_dispatch":
python_version = os.environ["REQUESTED_RUNTIME_VERSION"]
elif event_name == "push":
sdk_version = os.environ["GITHUB_REF_NAME"].removeprefix("python-v")
if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+b[0-9]+", sdk_version):
raise SystemExit(
"Python SDK release tags must identify a beta release, "
"for example python-v0.1.0b1."
)
pyproject = tomllib.loads(Path("sdk/python/pyproject.toml").read_text())
prefix = "openai-codex-cli-bin=="
versions = [
dependency.removeprefix(prefix)
for dependency in pyproject["project"]["dependencies"]
if dependency.startswith(prefix)
]
if len(versions) != 1:
raise SystemExit(f"Expected exactly one pinned {prefix} dependency, found {versions}")
python_version = versions[0]
else:
raise SystemExit(f"Unsupported workflow event: {event_name}")
if match := re.fullmatch(r"([0-9]+\.[0-9]+\.[0-9]+)a([0-9]+)", python_version):
release_version = f"{match.group(1)}-alpha.{match.group(2)}"
elif re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", python_version):
release_version = python_version
else:
raise SystemExit(
"Python runtime version must be stable or a numbered alpha, "
f"for example 0.136.0 or 0.136.0a2; found {python_version}"
)
with Path(os.environ["GITHUB_OUTPUT"]).open("a") as output:
print(f"python_version={python_version}", file=output)
print(f"release_tag=rust-v{release_version}", file=output)
PY
- name: Download Python runtime release artifacts
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PYTHON_RUNTIME_VERSION: ${{ steps.python_runtime.outputs.python_version }}
RELEASE_TAG: ${{ steps.python_runtime.outputs.release_tag }}
run: |
set -euo pipefail
mkdir -p dist/python-runtime dist/python-runtime-packages
gh release download "$RELEASE_TAG" \
--repo "${GITHUB_REPOSITORY}" \
--pattern "openai_codex_cli_bin-${PYTHON_RUNTIME_VERSION}-*.whl" \
--dir dist/python-runtime
gh release download "$RELEASE_TAG" \
--repo "${GITHUB_REPOSITORY}" \
--pattern "codex-package-*-unknown-linux-musl.tar.gz" \
--dir dist/python-runtime-packages
shopt -s nullglob
wheels=(dist/python-runtime/*.whl)
if [[ "${#wheels[@]}" -ne 6 ]]; then
echo "Expected 6 Python runtime wheels for ${PYTHON_RUNTIME_VERSION}, found ${#wheels[@]}."
exit 1
fi
packages=(dist/python-runtime-packages/*.tar.gz)
if [[ "${#packages[@]}" -ne 2 ]]; then
echo "Expected 2 Linux package archives for ${PYTHON_RUNTIME_VERSION}, found ${#packages[@]}."
exit 1
fi
- name: Build musllinux Python runtime wheels
env:
RELEASE_TAG: ${{ steps.python_runtime.outputs.release_tag }}
run: |
set -euo pipefail
python3 -m venv "${RUNNER_TEMP}/python-runtime-build-venv"
"${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m pip install build
while read -r target platform_tag; do
stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${target}-${platform_tag}"
python3 sdk/python/scripts/update_sdk_artifacts.py \
stage-runtime \
"$stage_dir" \
"dist/python-runtime-packages/codex-package-${target}.tar.gz" \
--codex-version "$RELEASE_TAG" \
--platform-tag "$platform_tag"
"${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build \
--wheel \
--outdir dist/python-runtime \
"$stage_dir"
done <<'EOF'
aarch64-unknown-linux-musl musllinux_1_1_aarch64
x86_64-unknown-linux-musl musllinux_1_1_x86_64
EOF
shopt -s nullglob
wheels=(dist/python-runtime/*.whl)
if [[ "${#wheels[@]}" -ne 8 ]]; then
echo "Expected 8 Python runtime wheels, found ${#wheels[@]}."
exit 1
fi
ls -lh dist/python-runtime
- name: Publish Python runtime wheels to PyPI
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
with:
packages-dir: dist/python-runtime
skip-existing: true
build-python-sdk:
if: github.event_name == 'push' && github.repository == 'openai/codex'
name: build-python-sdk
needs: publish-python-runtime
runs-on: ubuntu-latest
permissions:
contents: read
@@ -29,13 +175,8 @@ jobs:
set -euo pipefail
sdk_version="${GITHUB_REF_NAME#python-v}"
if [[ ! "${sdk_version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+b[0-9]+$ ]]; then
echo "Python SDK release tags must identify a beta release, for example python-v0.1.0b1."
exit 1
fi
# The pinned runtime currently publishes a musllinux Linux wheel.
# Build in Alpine so release type generation installs that wheel.
# Build in a glibc Linux image so release type generation installs
# the pinned manylinux runtime wheel.
docker run --rm \
--user "$(id -u):$(id -g)" \
-e HOME=/tmp/codex-python-sdk-home \
@@ -46,7 +187,7 @@ jobs:
-v "${GITHUB_WORKSPACE}:${GITHUB_WORKSPACE}" \
-v "${RUNNER_TEMP}:${RUNNER_TEMP}" \
-w "${GITHUB_WORKSPACE}/sdk/python" \
python:3.12-alpine \
python:3.12-slim \
sh -euxc '
python -m venv /tmp/release-tools
/tmp/release-tools/bin/python -m pip install build twine uv==0.11.3
+5
View File
@@ -3,6 +3,11 @@ on:
pull_request: {}
workflow_dispatch:
# Cargo's libgit2 transport has been flaky when fetching git dependencies with
# nested submodules. Prefer the system git CLI across every Cargo invocation.
env:
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
jobs:
# --- Detect what changed so the fast PR workflow only runs relevant jobs ----
changed:
@@ -7,6 +7,11 @@ on:
required: true
type: boolean
# Cargo's libgit2 transport has been flaky when fetching git dependencies with
# nested submodules. Prefer the system git CLI across every Cargo invocation.
env:
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
jobs:
skip:
if: ${{ !inputs.publish }}
+5 -20
View File
@@ -20,6 +20,11 @@ on:
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME:
required: true
# Cargo's libgit2 transport has been flaky when fetching git dependencies with
# nested submodules. Prefer the system git CLI across every Cargo invocation.
env:
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
jobs:
build-windows-binaries:
name: Build Windows binaries - ${{ matrix.runner }} - ${{ matrix.target }} - ${{ matrix.bundle }}
@@ -34,8 +39,6 @@ jobs:
working-directory: codex-rs
env:
CARGO_PROFILE_RELEASE_LTO: ${{ inputs.release-lto }}
CARGO_PROFILE_RELEASE_DEBUG: full
CARGO_PROFILE_RELEASE_STRIP: "false"
strategy:
fail-fast: false
@@ -133,7 +136,6 @@ jobs:
mkdir -p "$output_dir"
for binary in ${{ matrix.binaries }}; do
cp "target/${{ matrix.target }}/release/${binary}.exe" "$output_dir/${binary}.exe"
cp "target/${{ matrix.target }}/release/${binary}.pdb" "$output_dir/${binary}.pdb"
done
- name: Upload Windows binaries
@@ -216,23 +218,6 @@ jobs:
account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }}
- 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" \
--archive-dir "symbols-dist/${{ matrix.target }}" \
--binaries "${WINDOWS_BINARIES}"
- name: Upload symbols archive
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: ${{ matrix.target }}-symbols
path: codex-rs/symbols-dist/${{ matrix.target }}/*
if-no-files-found: error
- name: Stage artifacts
shell: bash
run: |
+1 -109
View File
@@ -149,9 +149,6 @@ jobs:
# 2026-03-04: temporarily change releases to use thin LTO because
# Ubuntu ARM is timing out at 60 minutes.
CARGO_PROFILE_RELEASE_LTO: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'thin' }}
CARGO_PROFILE_RELEASE_DEBUG: full
CARGO_PROFILE_RELEASE_SPLIT_DEBUGINFO: ${{ contains(matrix.target, 'apple-darwin') && 'packed' || 'off' }}
CARGO_PROFILE_RELEASE_STRIP: "false"
# Use the git CLI instead of Cargo's libgit2 path for git dependencies.
# macOS release runners have intermittently failed to fetch nested
# submodules through SecureTransport/libgit2, especially libwebrtc's
@@ -252,7 +249,7 @@ jobs:
run: |
set -euo pipefail
sudo apt-get update -y
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends binutils pkg-config libcap-dev
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev
- uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0
with:
targets: ${{ matrix.target }}
@@ -311,10 +308,6 @@ jobs:
exit 1
fi
# Codex embeds this digest at build time and verifies the bundled
# bwrap resource before use. Strip bwrap before hashing so the digest
# covers the exact bytes that the release packages.
strip --strip-debug --strip-unneeded "$bwrap_path"
digest="$(sha256sum "$bwrap_path" | awk '{print $1}')"
echo "CODEX_BWRAP_SHA256=${digest}" >> "$GITHUB_ENV"
echo "Built bwrap ${bwrap_path} with sha256:${digest}"
@@ -328,11 +321,6 @@ jobs:
fi
build_args=()
for binary in ${{ matrix.binaries }}; do
# bwrap was built, finalized, and hashed before this build so
# Codex can embed the digest of the bytes that will be packaged.
if [[ "$binary" == "bwrap" ]]; then
continue
fi
build_args+=(--bin "$binary")
done
echo "CARGO_PROFILE_RELEASE_LTO: ${CARGO_PROFILE_RELEASE_LTO}"
@@ -345,32 +333,6 @@ jobs:
path: codex-rs/target/**/cargo-timings/cargo-timing.html
if-no-files-found: warn
- name: Build symbols archive and strip binaries
shell: bash
run: |
binaries=()
for binary in ${{ matrix.binaries }}; do
# bwrap is already stripped before hashing. Its symbols are not
# useful enough to justify a separate pre-Codex symbols pass.
if [[ "$binary" == "bwrap" ]]; then
continue
fi
binaries+=("$binary")
done
bash "${GITHUB_WORKSPACE}/.github/scripts/archive-release-symbols-and-strip-binaries.sh" \
--target "${{ matrix.target }}" \
--artifact-name "${{ matrix.artifact_name }}" \
--release-dir "target/${{ matrix.target }}/release" \
--archive-dir "symbols-dist/${{ matrix.artifact_name }}" \
--binaries "${binaries[*]}"
- name: Upload symbols archive
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: ${{ matrix.artifact_name }}-symbols
path: codex-rs/symbols-dist/${{ matrix.artifact_name }}/*
if-no-files-found: error
- if: ${{ runner.os == 'macOS' && env.SIGN_MACOS != 'true' }}
name: Stage unsigned macOS artifacts
shell: bash
@@ -929,7 +891,6 @@ jobs:
sign_macos: ${{ steps.release_mode.outputs.sign_macos }}
should_publish_npm: ${{ steps.npm_publish_settings.outputs.should_publish }}
npm_tag: ${{ steps.npm_publish_settings.outputs.npm_tag }}
should_publish_python_runtime: ${{ steps.python_runtime_publish_settings.outputs.should_publish }}
steps:
- name: Checkout repository
@@ -1069,7 +1030,6 @@ jobs:
run: |
find dist -mindepth 1 -maxdepth 1 -type d \
! -name '*-apple-darwin*-unsigned' \
! -name '*-symbols' \
! -name 'aarch64-unknown-linux-musl' \
! -name 'aarch64-unknown-linux-musl-app-server' \
! -name 'x86_64-unknown-linux-musl' \
@@ -1154,27 +1114,6 @@ jobs:
echo "npm_tag=" >> "$GITHUB_OUTPUT"
fi
- name: Determine Python runtime publish settings
id: python_runtime_publish_settings
env:
VERSION: ${{ steps.release_name.outputs.name }}
run: |
set -euo pipefail
version="${VERSION}"
if [[ "${SIGN_MACOS}" != "true" ]]; then
echo "should_publish=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "should_publish=true" >> "$GITHUB_OUTPUT"
elif [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then
echo "should_publish=true" >> "$GITHUB_OUTPUT"
else
echo "should_publish=false" >> "$GITHUB_OUTPUT"
fi
- name: Setup pnpm
if: ${{ env.SIGN_MACOS == 'true' }}
uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5
@@ -1439,53 +1378,6 @@ jobs:
exit "${publish_status}"
done
# Publish the platform-specific Python runtime wheels using PyPI trusted publishing.
# PyPI project configuration must trust this workflow and job. Keep this
# non-blocking while the Python runtime publishing path is new; failures still
# need release follow-up, but should not invalidate the Rust release itself.
publish-python-runtime:
# Publish to PyPI for stable releases and alpha pre-releases with numeric suffixes.
if: >-
${{
!cancelled() &&
needs.release.result == 'success' &&
needs.release.outputs.should_publish_python_runtime == 'true'
}}
name: publish-python-runtime
needs: release
runs-on: ubuntu-latest
continue-on-error: true
environment: pypi
permissions:
id-token: write # Required for PyPI trusted publishing.
contents: read
steps:
- name: Download Python runtime wheels from release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ needs.release.outputs.tag }}
RELEASE_VERSION: ${{ needs.release.outputs.version }}
run: |
set -euo pipefail
python_version="$RELEASE_VERSION"
python_version="${python_version/-alpha./a}"
python_version="${python_version/-beta./b}"
python_version="${python_version/-rc./rc}"
mkdir -p dist/python-runtime
gh release download "$RELEASE_TAG" \
--repo "${GITHUB_REPOSITORY}" \
--pattern "openai_codex_cli_bin-${python_version}-*.whl" \
--dir dist/python-runtime
ls -lh dist/python-runtime
- name: Publish Python runtime wheels to PyPI
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
with:
packages-dir: dist/python-runtime
skip-existing: true
deploy-dev-website:
name: Trigger developers.openai.com deploy
needs: release
+7 -3
View File
@@ -5,6 +5,11 @@ on:
tags:
- "rusty-v8-v*.*.*"
# Cargo's libgit2 transport has been flaky when fetching git dependencies with
# nested submodules. Prefer the system git CLI for Cargo smoke tests.
env:
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
concurrency:
group: ${{ github.workflow }}::${{ github.ref_name }}
cancel-in-progress: false
@@ -186,11 +191,10 @@ jobs:
bazel_args+=(--config=v8-release-compat)
fi
bazel \
./.github/scripts/run_bazel_with_buildbuddy.py \
--noexperimental_remote_repo_contents_cache \
"${bazel_args[@]}" \
"--config=${{ matrix.bazel_config }}" \
"--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}"
"--config=${{ matrix.bazel_config }}"
- name: Stage release pair
env:
+3 -3
View File
@@ -23,15 +23,15 @@ jobs:
run: |
set -euo pipefail
# Run inside Alpine so dependency resolution exercises the pinned
# runtime wheel on the same Linux wheel family that CI installs.
# Run inside a glibc Linux image so dependency resolution exercises
# the pinned manylinux runtime wheel that users install.
docker run --rm \
--user "$(id -u):$(id -g)" \
-e HOME=/tmp/codex-python-sdk-home \
-e UV_LINK_MODE=copy \
-v "${GITHUB_WORKSPACE}:${GITHUB_WORKSPACE}" \
-w "${GITHUB_WORKSPACE}/sdk/python" \
python:3.12-alpine \
python:3.12-slim \
sh -euxc '
python -m venv /tmp/uv
/tmp/uv/bin/python -m pip install uv==0.11.3
+9 -3
View File
@@ -5,6 +5,7 @@ on:
paths:
- ".bazelrc"
- ".github/actions/setup-bazel-ci/**"
- ".github/scripts/run_bazel_with_buildbuddy.py"
- ".github/scripts/rusty_v8_bazel.py"
- ".github/scripts/rusty_v8_module_bazel.py"
- ".github/workflows/rusty-v8-release.yml"
@@ -23,6 +24,7 @@ on:
paths:
- ".bazelrc"
- ".github/actions/setup-bazel-ci/**"
- ".github/scripts/run_bazel_with_buildbuddy.py"
- ".github/scripts/rusty_v8_bazel.py"
- ".github/scripts/rusty_v8_module_bazel.py"
- ".github/workflows/rusty-v8-release.yml"
@@ -37,6 +39,11 @@ on:
- "third_party/v8/**"
workflow_dispatch:
# Cargo's libgit2 transport has been flaky when fetching git dependencies with
# nested submodules. Prefer the system git CLI for Cargo builds and smoke tests.
env:
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
concurrency:
group: ${{ github.workflow }}::${{ github.event.pull_request.number > 0 && format('pr-{0}', github.event.pull_request.number) || github.ref_name }}
cancel-in-progress: ${{ github.ref_name != 'main' }}
@@ -198,11 +205,10 @@ jobs:
bazel_args+=(--config=v8-release-compat)
fi
bazel \
./.github/scripts/run_bazel_with_buildbuddy.py \
--noexperimental_remote_repo_contents_cache \
"${bazel_args[@]}" \
"--config=${{ matrix.bazel_config }}" \
"--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}"
"--config=${{ matrix.bazel_config }}"
- name: Stage release pair
env:
+43
View File
@@ -76,6 +76,49 @@ Particularly when introducing a new concept/feature/API, before adding to `codex
Likewise, when reviewing code, do not hesitate to push back on PRs that would unnecessarily add code to `codex-core`.
## Code Review Rules
### Model visible context
Codex maintains a context (history of messages) that is sent to the model in inference requests.
1. No history rewrite - the context must be built up incrementally.
2. Avoid frequent changes to context that cause cache misses.
3. No unbounded items - everything injected in the model context must have a bounded size and a hard cap.
4. No items larger than 10K tokens.
5. Highlight new individual items that can cross >1k tokens as P0. These need an additional manual review.
6. All injected fragments must be defined as structs in `core/context` and implement ContextualUserFragment trait
### Breaking changes
Search for breaking changes in external integration surfaces:
- app-server APIs
- CLI parameters
- configuration loading
- resuming sessions from existing rollouts
### Test authoring guidance
For agent changes prefer integration tests over unit tests. Integration tests are under `core/suite` and use `test_codex` to set up a test instance of codex.
Features that change the agent logic MUST add an integration test:
- Provide a list of major logic changes and user-facing behaviors that need to be tested.
If unit tests are needed, put them in a dedicated test file (\*\_tests.rs).
Avoid test-only functions in the main implementation.
Check whether there are existing helpers to make tests more streamlined and readable.
### Change size guidance (800 lines)
Unless the change is mechanical the total number of changed lines should not exceed 800 lines.
For complex logic changes the size should be under 500 lines.
If the change is larger, explore whether it can be split into reviewable stages and identify the smallest coherent stage to land first.
Base the staging suggestion on the actual diff, dependencies, and affected call sites.
## TUI style conventions
See `codex-rs/tui/styles.md`.
+13 -3
View File
@@ -2347,7 +2347,6 @@ dependencies = [
name = "codex-cloud-config"
version = "0.0.0"
dependencies = [
"async-trait",
"base64 0.22.1",
"chrono",
"codex-backend-client",
@@ -2356,7 +2355,6 @@ dependencies = [
"codex-login",
"codex-otel",
"codex-protocol",
"codex-utils-absolute-path",
"hmac 0.12.1",
"pretty_assertions",
"serde",
@@ -2365,7 +2363,6 @@ dependencies = [
"tempfile",
"thiserror 2.0.18",
"tokio",
"toml 0.9.11+spec-1.1.0",
"tracing",
]
@@ -3367,6 +3364,7 @@ dependencies = [
"codex-utils-home-dir",
"codex-utils-rustls-provider",
"globset",
"libc",
"pretty_assertions",
"rama-core",
"rama-http",
@@ -3377,6 +3375,8 @@ dependencies = [
"rama-tls-rustls",
"rama-unix",
"rustls-native-certs",
"schannel",
"security-framework 3.5.1",
"serde",
"serde_json",
"sha2 0.10.9",
@@ -3386,6 +3386,7 @@ dependencies = [
"tokio",
"tracing",
"url",
"windows-sys 0.52.0",
]
[[package]]
@@ -3719,6 +3720,15 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "codex-skills-extension"
version = "0.0.0"
dependencies = [
"async-trait",
"codex-core",
"codex-extension-api",
]
[[package]]
name = "codex-state"
version = "0.0.0"
+1
View File
@@ -48,6 +48,7 @@ members = [
"ext/guardian",
"ext/image-generation",
"ext/memories",
"ext/skills",
"ext/web-search",
"external-agent-migration",
"external-agent-sessions",
@@ -62,6 +62,7 @@ use crate::facts::SkillInvokedInput;
use crate::facts::SubAgentThreadStartedInput;
use crate::facts::ThreadInitializationMode;
use crate::facts::TrackEventsContext;
use crate::facts::TurnCodexErrorFact;
use crate::facts::TurnResolvedConfigFact;
use crate::facts::TurnStatus;
use crate::facts::TurnSteerRequestError;
@@ -132,6 +133,7 @@ use codex_plugin::PluginTelemetryMetadata;
use codex_protocol::approvals::NetworkApprovalProtocol;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::ModeKind;
use codex_protocol::error::CodexErr;
use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions;
use codex_protocol::models::PermissionProfile as CorePermissionProfile;
use codex_protocol::protocol::AskForApproval;
@@ -389,6 +391,7 @@ fn sample_turn_resolved_config(thread_id: &str, turn_id: &str) -> TurnResolvedCo
sandbox_network_access: true,
collaboration_mode: ModeKind::Plan,
personality: None,
workspace_kind: None,
is_first_turn: true,
}
}
@@ -833,6 +836,7 @@ fn sample_permissions_approval_request(request_id: i64) -> ServerRequest {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
item_id: "permissions-1".to_string(),
environment_id: None,
started_at_ms: 1_000,
cwd: test_path_buf("/tmp").abs(),
reason: Some("need network".to_string()),
@@ -3252,10 +3256,14 @@ fn turn_event_serializes_expected_shape() {
sandbox_network_access: true,
collaboration_mode: Some("plan"),
personality: Some("pragmatic".to_string()),
workspace_kind: Some("projectless".to_string()),
num_input_images: 2,
is_first_turn: true,
status: Some(TurnStatus::Completed),
turn_error: None,
codex_error_kind: None,
codex_error_subreason: None,
codex_error_http_status_code: None,
steer_count: Some(0),
total_tool_call_count: None,
shell_command_count: None,
@@ -3314,10 +3322,14 @@ fn turn_event_serializes_expected_shape() {
"sandbox_network_access": true,
"collaboration_mode": "plan",
"personality": "pragmatic",
"workspace_kind": "projectless",
"num_input_images": 2,
"is_first_turn": true,
"status": "completed",
"turn_error": null,
"codex_error_kind": null,
"codex_error_subreason": null,
"codex_error_http_status_code": null,
"steer_count": 0,
"total_tool_call_count": null,
"shell_command_count": null,
@@ -3631,6 +3643,7 @@ async fn turn_lifecycle_emits_turn_event() {
);
assert!(payload["event_params"].get("product_client_id").is_none());
assert_eq!(payload["event_params"]["ephemeral"], json!(false));
assert_eq!(payload["event_params"]["workspace_kind"], json!(null));
assert_eq!(payload["event_params"]["num_input_images"], json!(1));
assert_eq!(payload["event_params"]["status"], json!("completed"));
assert_eq!(payload["event_params"]["steer_count"], json!(0));
@@ -3983,6 +3996,18 @@ async fn turn_lifecycle_emits_failed_turn_event() {
/*include_token_usage*/ false,
)
.await;
reducer
.ingest(
AnalyticsFact::Custom(CustomAnalyticsFact::TurnCodexError(Box::new(
TurnCodexErrorFact::from_codex_err(
"thread-2".to_string(),
"turn-2".to_string(),
&CodexErr::InvalidRequest("unknown turn environment id `env-2`".to_string()),
),
))),
&mut out,
)
.await;
reducer
.ingest(
AnalyticsFact::Notification(Box::new(sample_turn_completed_notification(
@@ -3999,6 +4024,18 @@ async fn turn_lifecycle_emits_failed_turn_event() {
let payload = serde_json::to_value(&out[0]).expect("serialize turn event");
assert_eq!(payload["event_params"]["status"], json!("failed"));
assert_eq!(payload["event_params"]["turn_error"], json!("badRequest"));
assert_eq!(
payload["event_params"]["codex_error_kind"],
json!("invalid_request")
);
assert_eq!(
payload["event_params"]["codex_error_subreason"],
json!("unknown turn environment id `env-2`")
);
assert_eq!(
payload["event_params"]["codex_error_http_status_code"],
json!(null)
);
}
#[tokio::test]
@@ -4031,6 +4068,7 @@ async fn turn_lifecycle_emits_interrupted_turn_event_without_error() {
let payload = serde_json::to_value(&out[0]).expect("serialize turn event");
assert_eq!(payload["event_params"]["status"], json!("interrupted"));
assert_eq!(payload["event_params"]["turn_error"], json!(null));
assert_eq!(payload["event_params"]["codex_error_kind"], json!(null));
}
#[tokio::test]
+7
View File
@@ -18,6 +18,7 @@ use crate::facts::SkillInvocation;
use crate::facts::SkillInvokedInput;
use crate::facts::SubAgentThreadStartedInput;
use crate::facts::TrackEventsContext;
use crate::facts::TurnCodexErrorFact;
use crate::facts::TurnResolvedConfigFact;
use crate::facts::TurnTokenUsageFact;
use crate::reducer::AnalyticsReducer;
@@ -256,6 +257,12 @@ impl AnalyticsEventsClient {
)));
}
pub fn track_turn_codex_error(&self, fact: TurnCodexErrorFact) {
self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnCodexError(
Box::new(fact),
)));
}
pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) {
self.record_fact(AnalyticsFact::Custom(
CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput {
+5
View File
@@ -3,6 +3,7 @@ use std::time::Instant;
use crate::facts::AcceptedLineFingerprint;
use crate::facts::AppInvocation;
use crate::facts::CodexCompactionEvent;
use crate::facts::CodexErrKind;
use crate::facts::CompactionImplementation;
use crate::facts::CompactionPhase;
use crate::facts::CompactionReason;
@@ -793,10 +794,14 @@ pub(crate) struct CodexTurnEventParams {
pub(crate) sandbox_network_access: bool,
pub(crate) collaboration_mode: Option<&'static str>,
pub(crate) personality: Option<String>,
pub(crate) workspace_kind: Option<String>,
pub(crate) num_input_images: usize,
pub(crate) is_first_turn: bool,
pub(crate) status: Option<TurnStatus>,
pub(crate) turn_error: Option<CodexErrorInfo>,
pub(crate) codex_error_kind: Option<CodexErrKind>,
pub(crate) codex_error_subreason: Option<String>,
pub(crate) codex_error_http_status_code: Option<u16>,
pub(crate) steer_count: Option<usize>,
pub(crate) total_tool_call_count: Option<usize>,
pub(crate) shell_command_count: Option<usize>,
+147
View File
@@ -15,6 +15,7 @@ use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::error::CodexErr;
use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::AskForApproval;
@@ -29,6 +30,9 @@ use codex_protocol::request_permissions::RequestPermissionsResponse;
use serde::Serialize;
use std::path::PathBuf;
const INVALID_REQUEST_SUBREASON_MAX_BYTES: usize = 512;
const INVALID_REQUEST_SUBREASON_TRUNCATION_SUFFIX: &str = "...";
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct AcceptedLineFingerprint {
pub path_hash: String,
@@ -81,6 +85,7 @@ pub struct TurnResolvedConfigFact {
pub sandbox_network_access: bool,
pub collaboration_mode: ModeKind,
pub personality: Option<Personality>,
pub workspace_kind: Option<String>,
pub is_first_turn: bool,
}
@@ -99,6 +104,147 @@ pub struct TurnTokenUsageFact {
pub token_usage: TokenUsage,
}
#[derive(Clone)]
pub struct TurnCodexErrorFact {
pub(crate) turn_id: String,
pub(crate) thread_id: String,
pub(crate) error: TurnCodexError,
}
impl TurnCodexErrorFact {
pub fn from_codex_err(thread_id: String, turn_id: String, error: &CodexErr) -> Self {
Self {
turn_id,
thread_id,
error: TurnCodexError::from_codex_err(error),
}
}
}
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum CodexErrKind {
TurnAborted,
Stream,
ContextWindowExceeded,
ThreadNotFound,
AgentLimitReached,
SessionConfiguredNotFirstEvent,
Timeout,
RequestTimeout,
Spawn,
Interrupted,
UnexpectedStatus,
InvalidRequest,
InvalidImageRequest,
UsageLimitReached,
ServerOverloaded,
CyberPolicy,
ResponseStreamFailed,
ConnectionFailed,
QuotaExceeded,
UsageNotIncluded,
InternalServerError,
RetryLimit,
InternalAgentDied,
Sandbox,
LandlockSandboxExecutableNotProvided,
UnsupportedOperation,
RefreshTokenFailed,
Fatal,
Io,
Json,
#[cfg(target_os = "linux")]
LandlockRuleset,
#[cfg(target_os = "linux")]
LandlockPathFd,
TokioJoin,
EnvVar,
}
#[derive(Clone)]
pub(crate) struct TurnCodexError {
pub(crate) kind: CodexErrKind,
pub(crate) subreason: Option<String>,
pub(crate) http_status_code: Option<u16>,
}
impl TurnCodexError {
fn from_codex_err(error: &CodexErr) -> Self {
Self {
kind: error.into(),
subreason: match error {
CodexErr::InvalidRequest(message) => {
// InvalidRequest can contain raw provider response bodies, so bound the
// analytics copy without changing the source CodexErr.
let subreason = if message.len() <= INVALID_REQUEST_SUBREASON_MAX_BYTES {
message.clone()
} else {
let truncated_len = message.floor_char_boundary(
INVALID_REQUEST_SUBREASON_MAX_BYTES
.saturating_sub(INVALID_REQUEST_SUBREASON_TRUNCATION_SUFFIX.len()),
);
format!(
"{}{INVALID_REQUEST_SUBREASON_TRUNCATION_SUFFIX}",
&message[..truncated_len]
)
};
Some(subreason)
}
_ => None,
},
http_status_code: error.http_status_code_value(),
}
}
}
impl From<&CodexErr> for CodexErrKind {
fn from(error: &CodexErr) -> Self {
match error {
CodexErr::TurnAborted => CodexErrKind::TurnAborted,
CodexErr::Stream(..) => CodexErrKind::Stream,
CodexErr::ContextWindowExceeded => CodexErrKind::ContextWindowExceeded,
CodexErr::ThreadNotFound(_) => CodexErrKind::ThreadNotFound,
CodexErr::AgentLimitReached { .. } => CodexErrKind::AgentLimitReached,
CodexErr::SessionConfiguredNotFirstEvent => {
CodexErrKind::SessionConfiguredNotFirstEvent
}
CodexErr::Timeout => CodexErrKind::Timeout,
CodexErr::RequestTimeout => CodexErrKind::RequestTimeout,
CodexErr::Spawn => CodexErrKind::Spawn,
CodexErr::Interrupted => CodexErrKind::Interrupted,
CodexErr::UnexpectedStatus(_) => CodexErrKind::UnexpectedStatus,
CodexErr::InvalidRequest(_) => CodexErrKind::InvalidRequest,
CodexErr::InvalidImageRequest() => CodexErrKind::InvalidImageRequest,
CodexErr::UsageLimitReached(_) => CodexErrKind::UsageLimitReached,
CodexErr::ServerOverloaded => CodexErrKind::ServerOverloaded,
CodexErr::CyberPolicy { .. } => CodexErrKind::CyberPolicy,
CodexErr::ResponseStreamFailed(_) => CodexErrKind::ResponseStreamFailed,
CodexErr::ConnectionFailed(_) => CodexErrKind::ConnectionFailed,
CodexErr::QuotaExceeded => CodexErrKind::QuotaExceeded,
CodexErr::UsageNotIncluded => CodexErrKind::UsageNotIncluded,
CodexErr::InternalServerError => CodexErrKind::InternalServerError,
CodexErr::RetryLimit(_) => CodexErrKind::RetryLimit,
CodexErr::InternalAgentDied => CodexErrKind::InternalAgentDied,
CodexErr::Sandbox(_) => CodexErrKind::Sandbox,
CodexErr::LandlockSandboxExecutableNotProvided => {
CodexErrKind::LandlockSandboxExecutableNotProvided
}
CodexErr::UnsupportedOperation(_) => CodexErrKind::UnsupportedOperation,
CodexErr::RefreshTokenFailed(_) => CodexErrKind::RefreshTokenFailed,
CodexErr::Fatal(_) => CodexErrKind::Fatal,
CodexErr::Io(_) => CodexErrKind::Io,
CodexErr::Json(_) => CodexErrKind::Json,
#[cfg(target_os = "linux")]
CodexErr::LandlockRuleset(_) => CodexErrKind::LandlockRuleset,
#[cfg(target_os = "linux")]
CodexErr::LandlockPathFd(_) => CodexErrKind::LandlockPathFd,
CodexErr::TokioJoin(_) => CodexErrKind::TokioJoin,
CodexErr::EnvVar(_) => CodexErrKind::EnvVar,
}
}
}
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum TurnStatus {
@@ -329,6 +475,7 @@ pub(crate) enum CustomAnalyticsFact {
GuardianReview(Box<GuardianReviewEventParams>),
TurnResolvedConfig(Box<TurnResolvedConfigFact>),
TurnTokenUsage(Box<TurnTokenUsageFact>),
TurnCodexError(Box<TurnCodexErrorFact>),
SkillInvoked(SkillInvokedInput),
AppMentioned(AppMentionedInput),
AppUsed(AppUsedInput),
+1
View File
@@ -38,6 +38,7 @@ pub use facts::SkillInvocation;
pub use facts::SubAgentThreadStartedInput;
pub use facts::ThreadInitializationMode;
pub use facts::TrackEventsContext;
pub use facts::TurnCodexErrorFact;
pub use facts::TurnResolvedConfigFact;
pub use facts::TurnStatus;
pub use facts::TurnSteerRejectionReason;
+41
View File
@@ -70,6 +70,8 @@ use crate::facts::PluginUsedInput;
use crate::facts::SkillInvokedInput;
use crate::facts::SubAgentThreadStartedInput;
use crate::facts::ThreadInitializationMode;
use crate::facts::TurnCodexError;
use crate::facts::TurnCodexErrorFact;
use crate::facts::TurnResolvedConfigFact;
use crate::facts::TurnStatus;
use crate::facts::TurnSteerRejectionReason;
@@ -322,6 +324,7 @@ struct TurnState {
started_at: Option<u64>,
token_usage: Option<TokenUsage>,
completed: Option<CompletedTurnState>,
codex_error: Option<TurnCodexError>,
latest_diff: Option<String>,
steer_count: usize,
tool_counts: TurnToolCounts,
@@ -461,6 +464,9 @@ impl AnalyticsReducer {
CustomAnalyticsFact::TurnTokenUsage(input) => {
self.ingest_turn_token_usage(*input, out).await;
}
CustomAnalyticsFact::TurnCodexError(input) => {
self.ingest_turn_codex_error(*input);
}
CustomAnalyticsFact::SkillInvoked(input) => {
self.ingest_skill_invoked(input, out).await;
}
@@ -606,6 +612,7 @@ impl AnalyticsReducer {
started_at: None,
token_usage: None,
completed: None,
codex_error: None,
latest_diff: None,
steer_count: 0,
tool_counts: TurnToolCounts::default(),
@@ -630,6 +637,7 @@ impl AnalyticsReducer {
started_at: None,
token_usage: None,
completed: None,
codex_error: None,
latest_diff: None,
steer_count: 0,
tool_counts: TurnToolCounts::default(),
@@ -639,6 +647,29 @@ impl AnalyticsReducer {
self.maybe_emit_turn_event(&turn_id, out).await;
}
fn ingest_turn_codex_error(&mut self, input: TurnCodexErrorFact) {
let TurnCodexErrorFact {
turn_id,
thread_id,
error,
} = input;
let turn_state = self.turns.entry(turn_id).or_insert(TurnState {
connection_id: None,
thread_id: None,
num_input_images: None,
resolved_config: None,
started_at: None,
token_usage: None,
completed: None,
codex_error: None,
latest_diff: None,
steer_count: 0,
tool_counts: TurnToolCounts::default(),
});
turn_state.thread_id.get_or_insert(thread_id);
turn_state.codex_error = Some(error);
}
async fn ingest_skill_invoked(
&mut self,
input: SkillInvokedInput,
@@ -795,6 +826,7 @@ impl AnalyticsReducer {
started_at: None,
token_usage: None,
completed: None,
codex_error: None,
latest_diff: None,
steer_count: 0,
tool_counts: TurnToolCounts::default(),
@@ -1154,6 +1186,7 @@ impl AnalyticsReducer {
started_at: None,
token_usage: None,
completed: None,
codex_error: None,
latest_diff: None,
steer_count: 0,
tool_counts: TurnToolCounts::default(),
@@ -1175,6 +1208,7 @@ impl AnalyticsReducer {
started_at: None,
token_usage: None,
completed: None,
codex_error: None,
latest_diff: None,
steer_count: 0,
tool_counts: TurnToolCounts::default(),
@@ -1194,6 +1228,7 @@ impl AnalyticsReducer {
started_at: None,
token_usage: None,
completed: None,
codex_error: None,
latest_diff: None,
steer_count: 0,
tool_counts: TurnToolCounts::default(),
@@ -2448,9 +2483,11 @@ fn codex_turn_event_params(
sandbox_network_access,
collaboration_mode,
personality,
workspace_kind,
is_first_turn,
} = resolved_config;
let token_usage = turn_state.token_usage.clone();
let codex_error = turn_state.codex_error.as_ref();
CodexTurnEventParams {
thread_id,
session_id: thread_metadata.session_id.clone(),
@@ -2479,10 +2516,14 @@ fn codex_turn_event_params(
sandbox_network_access,
collaboration_mode: Some(collaboration_mode_mode(collaboration_mode)),
personality: personality_mode(personality),
workspace_kind,
num_input_images,
is_first_turn,
status: completed.status,
turn_error: completed.turn_error,
codex_error_kind: codex_error.map(|error| error.kind),
codex_error_subreason: codex_error.and_then(|error| error.subreason.clone()),
codex_error_http_status_code: codex_error.and_then(|error| error.http_status_code),
steer_count: Some(turn_state.steer_count),
total_tool_call_count: Some(turn_state.tool_counts.total),
shell_command_count: Some(turn_state.tool_counts.shell_command),
+7 -7
View File
@@ -43,7 +43,7 @@ use codex_app_server_protocol::Result as JsonRpcResult;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
use codex_arg0::Arg0DispatchPaths;
use codex_config::CloudRequirementsLoader;
use codex_config::CloudConfigBundleLoader;
use codex_config::LoaderOverrides;
use codex_config::NoopThreadConfigLoader;
use codex_config::RemoteThreadConfigLoader;
@@ -339,8 +339,8 @@ pub struct InProcessClientStartArgs {
pub loader_overrides: LoaderOverrides,
/// Whether config API paths should reject unknown config fields.
pub strict_config: bool,
/// Preloaded cloud requirements provider.
pub cloud_requirements: CloudRequirementsLoader,
/// Preloaded cloud config bundle provider.
pub cloud_config_bundle: CloudConfigBundleLoader,
/// Feedback sink used by app-server/core telemetry and logs.
pub feedback: CodexFeedback,
/// SQLite tracing layer used to flush recently emitted logs before feedback upload.
@@ -406,7 +406,7 @@ impl InProcessClientStartArgs {
cli_overrides: self.cli_overrides,
loader_overrides: self.loader_overrides,
strict_config: self.strict_config,
cloud_requirements: self.cloud_requirements,
cloud_config_bundle: self.cloud_config_bundle,
thread_config_loader,
feedback: self.feedback,
log_db: self.log_db,
@@ -1035,7 +1035,7 @@ mod tests {
cli_overrides: Vec::new(),
loader_overrides: LoaderOverrides::default(),
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
cloud_config_bundle: CloudConfigBundleLoader::default(),
feedback: CodexFeedback::new(),
log_db: None,
state_db: Some(state_db),
@@ -2199,7 +2199,7 @@ mod tests {
cli_overrides: Vec::new(),
loader_overrides: LoaderOverrides::default(),
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
cloud_config_bundle: CloudConfigBundleLoader::default(),
feedback: CodexFeedback::new(),
log_db: None,
state_db: None,
@@ -2240,7 +2240,7 @@ mod tests {
cli_overrides: Vec::new(),
loader_overrides: LoaderOverrides::default(),
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
cloud_config_bundle: CloudConfigBundleLoader::default(),
feedback: CodexFeedback::new(),
log_db: None,
state_db: None,
@@ -285,6 +285,13 @@
"cwd": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"environmentId": {
"default": null,
"type": [
"null",
"string"
]
},
"itemId": {
"type": "string"
},
@@ -319,4 +326,4 @@
],
"title": "PermissionsRequestApprovalParams",
"type": "object"
}
}
@@ -29,6 +29,7 @@
"type": "object"
},
"AccountRateLimitsUpdatedNotification": {
"description": "Sparse rolling rate-limit update.\n\nClients should merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot. Nullable account metadata may be unavailable in a rolling update and does not clear a previously observed value.",
"properties": {
"rateLimits": {
"$ref": "#/definitions/RateLimitSnapshot"
@@ -2677,6 +2678,16 @@
}
]
},
"individualLimit": {
"anyOf": [
{
"$ref": "#/definitions/SpendControlLimitSnapshot"
},
{
"type": "null"
}
]
},
"limitId": {
"type": [
"string",
@@ -3135,6 +3146,31 @@
"description": "Notification emitted when watched local skill files change.\n\nTreat this as an invalidation signal and re-run `skills/list` with the client's current parameters when refreshed skill metadata is needed.",
"type": "object"
},
"SpendControlLimitSnapshot": {
"properties": {
"limit": {
"type": "string"
},
"remainingPercent": {
"format": "int32",
"type": "integer"
},
"resetsAt": {
"format": "int64",
"type": "integer"
},
"used": {
"type": "string"
}
},
"required": [
"limit",
"remainingPercent",
"resetsAt",
"used"
],
"type": "object"
},
"SubAgentSource": {
"oneOf": [
{
+8 -1
View File
@@ -1590,6 +1590,13 @@
"cwd": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"environmentId": {
"default": null,
"type": [
"null",
"string"
]
},
"itemId": {
"type": "string"
},
@@ -1998,4 +2005,4 @@
}
],
"title": "ServerRequest"
}
}
@@ -3783,6 +3783,13 @@
"cwd": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"environmentId": {
"default": null,
"type": [
"null",
"string"
]
},
"itemId": {
"type": "string"
},
@@ -5720,6 +5727,7 @@
},
"AccountRateLimitsUpdatedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Sparse rolling rate-limit update.\n\nClients should merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot. Nullable account metadata may be unavailable in a rolling update and does not clear a previously observed value.",
"properties": {
"rateLimits": {
"$ref": "#/definitions/v2/RateLimitSnapshot"
@@ -5951,6 +5959,16 @@
},
"AppConfig": {
"properties": {
"approvals_reviewer": {
"anyOf": [
{
"$ref": "#/definitions/v2/ApprovalsReviewer"
},
{
"type": "null"
}
]
},
"default_tools_approval_mode": {
"anyOf": [
{
@@ -13327,6 +13345,16 @@
}
]
},
"individualLimit": {
"anyOf": [
{
"$ref": "#/definitions/v2/SpendControlLimitSnapshot"
},
{
"type": "null"
}
]
},
"limitId": {
"type": [
"string",
@@ -15269,6 +15297,31 @@
],
"type": "string"
},
"SpendControlLimitSnapshot": {
"properties": {
"limit": {
"type": "string"
},
"remainingPercent": {
"format": "int32",
"type": "integer"
},
"resetsAt": {
"format": "int64",
"type": "integer"
},
"used": {
"type": "string"
}
},
"required": [
"limit",
"remainingPercent",
"resetsAt",
"used"
],
"type": "object"
},
"SubAgentSource": {
"oneOf": [
{
@@ -19186,4 +19239,4 @@
},
"title": "CodexAppServerProtocol",
"type": "object"
}
}
@@ -92,6 +92,7 @@
},
"AccountRateLimitsUpdatedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Sparse rolling rate-limit update.\n\nClients should merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot. Nullable account metadata may be unavailable in a rolling update and does not clear a previously observed value.",
"properties": {
"rateLimits": {
"$ref": "#/definitions/RateLimitSnapshot"
@@ -323,6 +324,16 @@
},
"AppConfig": {
"properties": {
"approvals_reviewer": {
"anyOf": [
{
"$ref": "#/definitions/ApprovalsReviewer"
},
{
"type": "null"
}
]
},
"default_tools_approval_mode": {
"anyOf": [
{
@@ -9856,6 +9867,16 @@
}
]
},
"individualLimit": {
"anyOf": [
{
"$ref": "#/definitions/SpendControlLimitSnapshot"
},
{
"type": "null"
}
]
},
"limitId": {
"type": [
"string",
@@ -13093,6 +13114,31 @@
],
"type": "string"
},
"SpendControlLimitSnapshot": {
"properties": {
"limit": {
"type": "string"
},
"remainingPercent": {
"format": "int32",
"type": "integer"
},
"resetsAt": {
"format": "int64",
"type": "integer"
},
"used": {
"type": "string"
}
},
"required": [
"limit",
"remainingPercent",
"resetsAt",
"used"
],
"type": "object"
},
"SubAgentSource": {
"oneOf": [
{
@@ -61,6 +61,16 @@
}
]
},
"individualLimit": {
"anyOf": [
{
"$ref": "#/definitions/SpendControlLimitSnapshot"
},
{
"type": "null"
}
]
},
"limitId": {
"type": [
"string",
@@ -141,8 +151,34 @@
"usedPercent"
],
"type": "object"
},
"SpendControlLimitSnapshot": {
"properties": {
"limit": {
"type": "string"
},
"remainingPercent": {
"format": "int32",
"type": "integer"
},
"resetsAt": {
"format": "int64",
"type": "integer"
},
"used": {
"type": "string"
}
},
"required": [
"limit",
"remainingPercent",
"resetsAt",
"used"
],
"type": "object"
}
},
"description": "Sparse rolling rate-limit update.\n\nClients should merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot. Nullable account metadata may be unavailable in a rolling update and does not clear a previously observed value.",
"properties": {
"rateLimits": {
"$ref": "#/definitions/RateLimitSnapshot"
@@ -19,6 +19,16 @@
},
"AppConfig": {
"properties": {
"approvals_reviewer": {
"anyOf": [
{
"$ref": "#/definitions/ApprovalsReviewer"
},
{
"type": "null"
}
]
},
"default_tools_approval_mode": {
"anyOf": [
{
@@ -61,6 +61,16 @@
}
]
},
"individualLimit": {
"anyOf": [
{
"$ref": "#/definitions/SpendControlLimitSnapshot"
},
{
"type": "null"
}
]
},
"limitId": {
"type": [
"string",
@@ -141,6 +151,31 @@
"usedPercent"
],
"type": "object"
},
"SpendControlLimitSnapshot": {
"properties": {
"limit": {
"type": "string"
},
"remainingPercent": {
"format": "int32",
"type": "integer"
},
"resetsAt": {
"format": "int64",
"type": "integer"
},
"used": {
"type": "string"
}
},
"required": [
"limit",
"remainingPercent",
"resetsAt",
"used"
],
"type": "object"
}
},
"properties": {
@@ -3,4 +3,11 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { RateLimitSnapshot } from "./RateLimitSnapshot";
/**
* Sparse rolling rate-limit update.
*
* Clients should merge available values into the most recent `account/rateLimits/read` response
* or refetch that snapshot. Nullable account metadata may be unavailable in a rolling update and
* does not clear a previously observed value.
*/
export type AccountRateLimitsUpdatedNotification = { rateLimits: RateLimitSnapshot, };
@@ -3,6 +3,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AppToolApproval } from "./AppToolApproval";
import type { AppToolsConfig } from "./AppToolsConfig";
import type { ApprovalsReviewer } from "./ApprovalsReviewer";
import type { AppsDefaultConfig } from "./AppsDefaultConfig";
export type AppsConfig = { _default: AppsDefaultConfig | null, } & ({ [key in string]?: { enabled: boolean, destructive_enabled: boolean | null, open_world_enabled: boolean | null, default_tools_approval_mode: AppToolApproval | null, default_tools_enabled: boolean | null, tools: AppToolsConfig | null, } });
export type AppsConfig = { _default: AppsDefaultConfig | null, } & ({ [key in string]?: { enabled: boolean, approvals_reviewer: ApprovalsReviewer | null, destructive_enabled: boolean | null, open_world_enabled: boolean | null, default_tools_approval_mode: AppToolApproval | null, default_tools_enabled: boolean | null, tools: AppToolsConfig | null, } });
@@ -4,7 +4,7 @@
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { RequestPermissionProfile } from "./RequestPermissionProfile";
export type PermissionsRequestApprovalParams = { threadId: string, turnId: string, itemId: string,
export type PermissionsRequestApprovalParams = { threadId: string, turnId: string, itemId: string, environmentId: string | null,
/**
* Unix timestamp (in milliseconds) when this approval request started.
*/
@@ -5,5 +5,6 @@ import type { PlanType } from "../PlanType";
import type { CreditsSnapshot } from "./CreditsSnapshot";
import type { RateLimitReachedType } from "./RateLimitReachedType";
import type { RateLimitWindow } from "./RateLimitWindow";
import type { SpendControlLimitSnapshot } from "./SpendControlLimitSnapshot";
export type RateLimitSnapshot = { limitId: string | null, limitName: string | null, primary: RateLimitWindow | null, secondary: RateLimitWindow | null, credits: CreditsSnapshot | null, planType: PlanType | null, rateLimitReachedType: RateLimitReachedType | null, };
export type RateLimitSnapshot = { limitId: string | null, limitName: string | null, primary: RateLimitWindow | null, secondary: RateLimitWindow | null, credits: CreditsSnapshot | null, individualLimit: SpendControlLimitSnapshot | null, planType: PlanType | null, rateLimitReachedType: RateLimitReachedType | null, };
@@ -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 SpendControlLimitSnapshot = { limit: string, used: string, remainingPercent: number, resetsAt: number, };
@@ -350,6 +350,7 @@ export type { SkillsListEntry } from "./SkillsListEntry";
export type { SkillsListParams } from "./SkillsListParams";
export type { SkillsListResponse } from "./SkillsListResponse";
export type { SortDirection } from "./SortDirection";
export type { SpendControlLimitSnapshot } from "./SpendControlLimitSnapshot";
export type { SubagentMigration } from "./SubagentMigration";
export type { TerminalInteractionNotification } from "./TerminalInteractionNotification";
export type { TextElement } from "./TextElement";
@@ -39,6 +39,8 @@ use ts_rs::TS;
pub(crate) const GENERATED_TS_HEADER: &str = "// GENERATED CODE! DO NOT MODIFY BY HAND!\n\n";
const IGNORED_DEFINITIONS: &[&str] = &["Option<()>"];
const JSON_V1_ALLOWLIST: &[&str] = &["InitializeParams", "InitializeResponse"];
const EXPERIMENTAL_CLIENT_METHOD_DEPENDENCY_TYPES: &[&str] =
&["RemoteControlClient", "RemoteControlClientsListOrder"];
const SPECIAL_DEFINITIONS: &[&str] = &[
"ClientNotification",
"ClientRequest",
@@ -554,6 +556,7 @@ fn experimental_method_types() -> HashSet<String> {
let mut type_names = HashSet::new();
collect_experimental_type_names(EXPERIMENTAL_CLIENT_METHOD_PARAM_TYPES, &mut type_names);
collect_experimental_type_names(EXPERIMENTAL_CLIENT_METHOD_RESPONSE_TYPES, &mut type_names);
collect_experimental_type_names(EXPERIMENTAL_CLIENT_METHOD_DEPENDENCY_TYPES, &mut type_names);
type_names
}
@@ -2132,6 +2135,14 @@ mod tests {
fixture_tree.contains_key(Path::new("v2/MockExperimentalMethodResponse.ts")),
false
);
assert_eq!(
fixture_tree.contains_key(Path::new("v2/RemoteControlClient.ts")),
false
);
assert_eq!(
fixture_tree.contains_key(Path::new("v2/RemoteControlClientsListOrder.ts")),
false
);
let mut undefined_offenders = Vec::new();
let mut optional_nullable_offenders = BTreeSet::new();
@@ -2847,6 +2858,11 @@ permissionProfile?: string | null};
flat_v2_bundle_json.contains("MockExperimentalMethodResponse"),
false
);
assert_eq!(flat_v2_bundle_json.contains("RemoteControlClient"), false);
assert_eq!(
flat_v2_bundle_json.contains("RemoteControlClientsListOrder"),
false
);
assert_eq!(flat_v2_bundle_json.contains("#/definitions/v2/"), false);
assert_eq!(
flat_v2_bundle_json.contains("\"title\": \"CodexAppServerProtocolV2\""),
@@ -2920,6 +2936,45 @@ permissionProfile?: string | null};
.exists(),
false
);
assert_eq!(
output_dir
.join("v2")
.join("RemoteControlClient.json")
.exists(),
false
);
assert_eq!(
output_dir
.join("v2")
.join("RemoteControlClientsListOrder.json")
.exists(),
false
);
let _cleanup = fs::remove_dir_all(&output_dir);
Ok(())
}
#[test]
fn generate_json_includes_remote_control_methods_with_experimental_api() -> Result<()> {
let output_dir = std::env::temp_dir().join(format!("codex_schema_{}", Uuid::now_v7()));
fs::create_dir(&output_dir)?;
generate_json_with_experimental(&output_dir, /*experimental_api*/ true)?;
let client_request_json = fs::read_to_string(output_dir.join("ClientRequest.json"))?;
assert!(client_request_json.contains("remoteControl/pairing/start"));
assert!(client_request_json.contains("remoteControl/client/list"));
assert!(client_request_json.contains("remoteControl/client/revoke"));
for schema in [
"RemoteControlPairingStartParams.json",
"RemoteControlPairingStartResponse.json",
"RemoteControlClientsListParams.json",
"RemoteControlClientsListResponse.json",
"RemoteControlClientsRevokeParams.json",
"RemoteControlClientsRevokeResponse.json",
] {
assert!(output_dir.join("v2").join(schema).exists());
}
let _cleanup = fs::remove_dir_all(&output_dir);
Ok(())
@@ -843,6 +843,24 @@ client_request_definitions! {
serialization: global_shared_read("remote-control"),
response: v2::RemoteControlStatusReadResponse,
},
#[experimental("remoteControl/pairing/start")]
RemoteControlPairingStart => "remoteControl/pairing/start" {
params: v2::RemoteControlPairingStartParams,
serialization: global("remote-control-pairing"),
response: v2::RemoteControlPairingStartResponse,
},
#[experimental("remoteControl/client/list")]
RemoteControlClientsList => "remoteControl/client/list" {
params: v2::RemoteControlClientsListParams,
serialization: global_shared_read("remote-control-clients"),
response: v2::RemoteControlClientsListResponse,
},
#[experimental("remoteControl/client/revoke")]
RemoteControlClientsRevoke => "remoteControl/client/revoke" {
params: v2::RemoteControlClientsRevokeParams,
serialization: global("remote-control-clients"),
response: v2::RemoteControlClientsRevokeResponse,
},
#[experimental("collaborationMode/list")]
/// Lists collaboration mode presets.
CollaborationModeList => "collaborationMode/list" {
@@ -1977,6 +1995,40 @@ mod tests {
},
};
assert_eq!(mcp_resource_read.serialization_scope(), None);
let remote_control_pairing_start = ClientRequest::RemoteControlPairingStart {
request_id: request_id(),
params: v2::RemoteControlPairingStartParams::default(),
};
assert_eq!(
remote_control_pairing_start.serialization_scope(),
Some(ClientRequestSerializationScope::Global(
"remote-control-pairing"
))
);
let remote_control_clients_list = ClientRequest::RemoteControlClientsList {
request_id: request_id(),
params: v2::RemoteControlClientsListParams::default(),
};
assert_eq!(
remote_control_clients_list.serialization_scope(),
Some(ClientRequestSerializationScope::GlobalSharedRead(
"remote-control-clients"
))
);
let remote_control_clients_revoke = ClientRequest::RemoteControlClientsRevoke {
request_id: request_id(),
params: v2::RemoteControlClientsRevokeParams {
environment_id: "environment-id".to_string(),
client_id: "client-id".to_string(),
},
};
assert_eq!(
remote_control_clients_revoke.serialization_scope(),
Some(ClientRequestSerializationScope::Global(
"remote-control-clients"
))
);
}
#[test]
@@ -6,6 +6,7 @@ use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot;
use codex_protocol::protocol::RateLimitReachedType as CoreRateLimitReachedType;
use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot;
use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow;
use codex_protocol::protocol::SpendControlLimitSnapshot as CoreSpendControlLimitSnapshot;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
@@ -247,6 +248,11 @@ pub struct AccountUpdatedNotification {
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
/// Sparse rolling rate-limit update.
///
/// Clients should merge available values into the most recent `account/rateLimits/read` response
/// or refetch that snapshot. Nullable account metadata may be unavailable in a rolling update and
/// does not clear a previously observed value.
pub struct AccountRateLimitsUpdatedNotification {
pub rate_limits: RateLimitSnapshot,
}
@@ -260,6 +266,7 @@ pub struct RateLimitSnapshot {
pub primary: Option<RateLimitWindow>,
pub secondary: Option<RateLimitWindow>,
pub credits: Option<CreditsSnapshot>,
pub individual_limit: Option<SpendControlLimitSnapshot>,
pub plan_type: Option<PlanType>,
pub rate_limit_reached_type: Option<RateLimitReachedType>,
}
@@ -272,6 +279,7 @@ impl From<CoreRateLimitSnapshot> for RateLimitSnapshot {
primary: value.primary.map(RateLimitWindow::from),
secondary: value.secondary.map(RateLimitWindow::from),
credits: value.credits.map(CreditsSnapshot::from),
individual_limit: value.individual_limit.map(SpendControlLimitSnapshot::from),
plan_type: value.plan_type,
rate_limit_reached_type: value
.rate_limit_reached_type
@@ -371,6 +379,28 @@ impl From<CoreCreditsSnapshot> for CreditsSnapshot {
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct SpendControlLimitSnapshot {
pub limit: String,
pub used: String,
pub remaining_percent: i32,
#[ts(type = "number")]
pub resets_at: i64,
}
impl From<CoreSpendControlLimitSnapshot> for SpendControlLimitSnapshot {
fn from(value: CoreSpendControlLimitSnapshot) -> Self {
Self {
limit: value.limit,
used: value.used,
remaining_percent: value.remaining_percent,
resets_at: value.resets_at,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -200,6 +200,7 @@ pub struct AppToolsConfig {
pub struct AppConfig {
#[serde(default = "default_enabled")]
pub enabled: bool,
pub approvals_reviewer: Option<ApprovalsReviewer>,
pub destructive_enabled: Option<bool>,
pub open_world_enabled: Option<bool>,
pub default_tools_approval_mode: Option<AppToolApproval>,
@@ -663,6 +663,8 @@ pub struct PermissionsRequestApprovalParams {
pub thread_id: String,
pub turn_id: String,
pub item_id: String,
#[serde(default)]
pub environment_id: Option<String>,
/// Unix timestamp (in milliseconds) when this approval request started.
#[ts(type = "number")]
pub started_at_ms: i64,
@@ -44,6 +44,80 @@ pub struct RemoteControlStatusReadResponse {
pub environment_id: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct RemoteControlPairingStartParams {
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub manual_code: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct RemoteControlPairingStartResponse {
pub pairing_code: String,
pub manual_pairing_code: Option<String>,
pub environment_id: String,
pub expires_at: i64,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct RemoteControlClientsListParams {
pub environment_id: String,
#[ts(optional = nullable)]
pub cursor: Option<String>,
#[ts(optional = nullable)]
pub limit: Option<u32>,
#[ts(optional = nullable)]
pub order: Option<RemoteControlClientsListOrder>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase", export_to = "v2/")]
pub enum RemoteControlClientsListOrder {
Asc,
Desc,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct RemoteControlClientsListResponse {
pub data: Vec<RemoteControlClient>,
pub next_cursor: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct RemoteControlClient {
pub client_id: String,
pub display_name: Option<String>,
pub device_type: Option<String>,
pub platform: Option<String>,
pub os_version: Option<String>,
pub device_model: Option<String>,
pub app_version: Option<String>,
pub last_seen_at: Option<i64>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct RemoteControlClientsRevokeParams {
pub environment_id: String,
pub client_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct RemoteControlClientsRevokeResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase", export_to = "v2/")]
@@ -87,3 +161,7 @@ impl From<RemoteControlStatusChangedNotification> for RemoteControlDisableRespon
}
}
}
#[cfg(test)]
#[path = "remote_control_tests.rs"]
mod tests;
@@ -0,0 +1,50 @@
use super::*;
use pretty_assertions::assert_eq;
use serde_json::json;
#[test]
fn remote_control_clients_list_params_serialize_nullable_optional_fields() {
assert_eq!(
serde_json::to_value(RemoteControlClientsListParams {
environment_id: "env-123".to_string(),
cursor: None,
limit: None,
order: None,
})
.expect("params should serialize"),
json!({
"environmentId": "env-123",
"cursor": null,
"limit": null,
"order": null,
})
);
}
#[test]
fn remote_control_clients_list_params_deserialize_camel_case_fields() {
assert_eq!(
serde_json::from_value::<RemoteControlClientsListParams>(json!({
"environmentId": "env-123",
"cursor": "cursor-123",
"limit": 10,
"order": "asc",
}))
.expect("params should deserialize"),
RemoteControlClientsListParams {
environment_id: "env-123".to_string(),
cursor: Some("cursor-123".to_string()),
limit: Some(10),
order: Some(RemoteControlClientsListOrder::Asc),
}
);
}
#[test]
fn remote_control_clients_revoke_response_serializes_as_empty_object() {
assert_eq!(
serde_json::to_value(RemoteControlClientsRevokeResponse {})
.expect("response should serialize"),
json!({})
);
}
@@ -400,6 +400,7 @@ fn permissions_request_approval_uses_request_permission_profile() {
"threadId": "thr_123",
"turnId": "turn_123",
"itemId": "call_123",
"environmentId": "remote",
"startedAtMs": 1,
"cwd": absolute_path_string("repo"),
"reason": "Select a workspace root",
@@ -416,6 +417,7 @@ fn permissions_request_approval_uses_request_permission_profile() {
.expect("permissions request should deserialize");
assert_eq!(params.cwd, absolute_path("repo"));
assert_eq!(params.environment_id.as_deref(), Some("remote"));
assert_eq!(
params.permissions,
RequestPermissionProfile {
@@ -0,0 +1,105 @@
use codex_api::SharedAuthProvider;
use codex_login::AuthManager;
use codex_login::UnauthorizedRecovery;
use std::io;
use std::io::ErrorKind;
use std::sync::Arc;
use tokio::sync::watch;
use tracing::info;
use tracing::warn;
pub(super) struct RemoteControlConnectionAuth {
pub(super) auth_provider: SharedAuthProvider,
pub(super) account_id: String,
}
pub(super) async fn load_remote_control_auth(
auth_manager: &Arc<AuthManager>,
) -> io::Result<RemoteControlConnectionAuth> {
let mut reloaded = false;
let auth = loop {
let Some(auth) = auth_manager.auth().await else {
if reloaded {
return Err(io::Error::new(
ErrorKind::PermissionDenied,
"remote control requires ChatGPT authentication",
));
}
auth_manager.reload().await;
reloaded = true;
continue;
};
if !auth.uses_codex_backend() {
break auth;
}
if auth.get_account_id().is_none() && !reloaded {
auth_manager.reload().await;
reloaded = true;
continue;
}
break auth;
};
if !auth.uses_codex_backend() {
return Err(io::Error::new(
ErrorKind::PermissionDenied,
"remote control requires ChatGPT authentication; API key auth is not supported",
));
}
Ok(RemoteControlConnectionAuth {
auth_provider: codex_model_provider::auth_provider_from_auth(&auth),
account_id: auth.get_account_id().ok_or_else(|| {
io::Error::new(
ErrorKind::WouldBlock,
"remote control enrollment is waiting for a ChatGPT account id",
)
})?,
})
}
pub(super) async fn recover_remote_control_auth(
auth_recovery: &mut UnauthorizedRecovery,
auth_change_rx: &mut watch::Receiver<u64>,
) -> bool {
if !auth_recovery.has_next() {
return false;
}
let mode = auth_recovery.mode_name();
let step = auth_recovery.step_name();
let auth_change_revision_before_recovery = *auth_change_rx.borrow();
match auth_recovery.next().await {
Ok(step_result) => {
if step_result.auth_state_changed() == Some(true) {
mark_recovery_auth_change_seen(
auth_change_rx,
auth_change_revision_before_recovery,
);
}
info!(
"remote control auth recovery succeeded: mode={mode}, step={step}, auth_state_changed={:?}",
step_result.auth_state_changed()
);
true
}
Err(err) => {
warn!("remote control auth recovery failed: mode={mode}, step={step}: {err}");
false
}
}
}
pub(super) fn mark_recovery_auth_change_seen(
auth_change_rx: &mut watch::Receiver<u64>,
auth_change_revision_before_recovery: u64,
) {
let auth_change_revision_after_recovery = *auth_change_rx.borrow();
if auth_change_revision_after_recovery == auth_change_revision_before_recovery.wrapping_add(1) {
// Recovery updated the same watch that wakes the outer reconnect
// loop. Mark only that single revision seen; if more revisions
// arrived while recovery was in flight, leave them pending so the
// reconnect loop still reacts to the later external auth change.
auth_change_rx.borrow_and_update();
}
}
@@ -0,0 +1,307 @@
use super::auth::RemoteControlConnectionAuth;
use super::auth::load_remote_control_auth;
use super::auth::recover_remote_control_auth;
use super::enroll::REMOTE_CONTROL_ACCOUNT_ID_HEADER;
use super::enroll::format_headers;
use super::enroll::preview_remote_control_response_body;
use super::protocol::normalize_remote_control_base_url;
use axum::http::HeaderMap;
use codex_app_server_protocol::RemoteControlClient;
use codex_app_server_protocol::RemoteControlClientsListOrder;
use codex_app_server_protocol::RemoteControlClientsListParams;
use codex_app_server_protocol::RemoteControlClientsListResponse;
use codex_app_server_protocol::RemoteControlClientsRevokeParams;
use codex_app_server_protocol::RemoteControlClientsRevokeResponse;
use codex_login::AuthManager;
use codex_login::default_client::build_reqwest_client;
use serde::Deserialize;
use std::io;
use std::io::ErrorKind;
use std::sync::Arc;
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use url::Url;
const REMOTE_CONTROL_CLIENT_MANAGEMENT_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(30);
#[derive(Debug, Deserialize)]
struct ListRemoteControlClientsResponse {
items: Vec<RemoteControlClientResponse>,
#[serde(default)]
cursor: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RemoteControlClientResponse {
client_id: String,
#[serde(default)]
display_name: Option<String>,
#[serde(default)]
device_type: Option<String>,
#[serde(default)]
platform: Option<String>,
#[serde(default)]
os_version: Option<String>,
#[serde(default)]
device_model: Option<String>,
#[serde(default)]
app_version: Option<String>,
#[serde(default)]
last_seen_at: Option<String>,
}
enum ClientManagementRequest<'a> {
List {
url: &'a Url,
params: &'a RemoteControlClientsListParams,
},
Revoke {
url: &'a Url,
},
}
struct ClientManagementResponse {
status: axum::http::StatusCode,
headers: HeaderMap,
body: Vec<u8>,
}
pub(super) async fn list_remote_control_clients(
remote_control_url: &str,
auth_manager: &Arc<AuthManager>,
params: RemoteControlClientsListParams,
) -> io::Result<RemoteControlClientsListResponse> {
if params.environment_id.is_empty() {
return Err(io::Error::new(
ErrorKind::InvalidInput,
"remote control client list requires environmentId",
));
}
if params
.limit
.is_some_and(|limit| !(1..=100).contains(&limit))
{
return Err(io::Error::new(
ErrorKind::InvalidInput,
"remote control client list limit must be between 1 and 100",
));
}
let url = environment_clients_url(remote_control_url, &params.environment_id)?;
let response = send_client_management_request(
auth_manager,
ClientManagementRequest::List {
url: &url,
params: &params,
},
"list remote control clients",
)
.await?;
let ClientManagementResponse {
status,
headers,
body,
} = response;
let body_preview = preview_remote_control_response_body(&body);
ensure_success_response(status, &headers, &url, &body_preview, "client list")?;
let response = serde_json::from_slice::<ListRemoteControlClientsResponse>(&body).map_err(
|err| {
io::Error::other(format!(
"failed to parse remote control client list response from `{url}`: HTTP {status}, {}, body: {body_preview}, decode error: {err}",
format_headers(&headers)
))
},
)?;
Ok(RemoteControlClientsListResponse {
data: response
.items
.into_iter()
.map(RemoteControlClient::try_from)
.collect::<io::Result<_>>()?,
next_cursor: response.cursor,
})
}
pub(super) async fn revoke_remote_control_client(
remote_control_url: &str,
auth_manager: &Arc<AuthManager>,
params: RemoteControlClientsRevokeParams,
) -> io::Result<RemoteControlClientsRevokeResponse> {
if params.environment_id.is_empty() {
return Err(io::Error::new(
ErrorKind::InvalidInput,
"remote control client revoke requires environmentId",
));
}
if params.client_id.is_empty() {
return Err(io::Error::new(
ErrorKind::InvalidInput,
"remote control client revoke requires clientId",
));
}
let mut url = environment_clients_url(remote_control_url, &params.environment_id)?;
url.path_segments_mut()
.map_err(|()| {
io::Error::new(
ErrorKind::InvalidInput,
"remote control URL cannot be a base",
)
})?
.push(&params.client_id);
let response = send_client_management_request(
auth_manager,
ClientManagementRequest::Revoke { url: &url },
"revoke remote control client",
)
.await?;
let ClientManagementResponse {
status,
headers,
body,
} = response;
let body_preview = preview_remote_control_response_body(&body);
ensure_success_response(status, &headers, &url, &body_preview, "client revoke")?;
Ok(RemoteControlClientsRevokeResponse {})
}
async fn send_client_management_request(
auth_manager: &Arc<AuthManager>,
request: ClientManagementRequest<'_>,
action: &str,
) -> io::Result<ClientManagementResponse> {
let mut auth_recovery = auth_manager.unauthorized_recovery();
let mut auth_change_rx = auth_manager.auth_change_receiver();
let auth = load_remote_control_auth(auth_manager).await?;
let response = send_client_management_request_once(&auth, &request, action).await?;
if response.status.as_u16() != 401
|| !recover_remote_control_auth(&mut auth_recovery, &mut auth_change_rx).await
{
return Ok(response);
}
let auth = load_remote_control_auth(auth_manager).await?;
send_client_management_request_once(&auth, &request, action).await
}
async fn send_client_management_request_once(
auth: &RemoteControlConnectionAuth,
request: &ClientManagementRequest<'_>,
action: &str,
) -> io::Result<ClientManagementResponse> {
let client = build_reqwest_client();
let mut auth_headers = HeaderMap::new();
auth.auth_provider.add_auth_headers(&mut auth_headers);
let request = match request {
ClientManagementRequest::List { url, params } => {
let mut query = Vec::new();
if let Some(cursor) = &params.cursor {
query.push(("cursor", cursor.clone()));
}
if let Some(limit) = params.limit {
query.push(("limit", limit.to_string()));
}
if let Some(order) = params.order {
query.push((
"order",
match order {
RemoteControlClientsListOrder::Asc => "asc",
RemoteControlClientsListOrder::Desc => "desc",
}
.to_string(),
));
}
client.get((*url).clone()).query(&query)
}
ClientManagementRequest::Revoke { url } => client.delete((*url).clone()),
};
let response = request
.timeout(REMOTE_CONTROL_CLIENT_MANAGEMENT_TIMEOUT)
.headers(auth_headers)
.header(REMOTE_CONTROL_ACCOUNT_ID_HEADER, &auth.account_id)
.send()
.await
.map_err(|err| io::Error::other(format!("failed to {action}: {err}")))?;
let headers = response.headers().clone();
let status = response.status();
let body = response
.bytes()
.await
.map_err(|err| io::Error::other(format!("failed to read {action} response: {err}")))?
.to_vec();
Ok(ClientManagementResponse {
status,
headers,
body,
})
}
fn ensure_success_response(
status: axum::http::StatusCode,
headers: &HeaderMap,
url: &Url,
body_preview: &str,
response_kind: &str,
) -> io::Result<()> {
if status.is_success() {
return Ok(());
}
let error_kind = match status.as_u16() {
400 => ErrorKind::InvalidInput,
401 | 403 => ErrorKind::PermissionDenied,
404 => ErrorKind::NotFound,
_ => ErrorKind::Other,
};
Err(io::Error::new(
error_kind,
format!(
"remote control {response_kind} failed at `{url}`: HTTP {status}, {}, body: {body_preview}",
format_headers(headers)
),
))
}
fn environment_clients_url(remote_control_url: &str, environment_id: &str) -> io::Result<Url> {
let mut url = normalize_remote_control_base_url(remote_control_url)?
.join("wham/remote/control/environments")
.map_err(io::Error::other)?;
url.path_segments_mut()
.map_err(|()| {
io::Error::new(
ErrorKind::InvalidInput,
"remote control URL cannot be a base",
)
})?
.push(environment_id)
.push("clients");
Ok(url)
}
impl TryFrom<RemoteControlClientResponse> for RemoteControlClient {
type Error = io::Error;
fn try_from(client: RemoteControlClientResponse) -> Result<Self, Self::Error> {
Ok(Self {
client_id: client.client_id,
display_name: client.display_name,
device_type: client.device_type,
platform: client.platform,
os_version: client.os_version,
device_model: client.device_model,
app_version: client.app_version,
last_seen_at: client
.last_seen_at
.map(|last_seen_at| {
OffsetDateTime::parse(&last_seen_at, &Rfc3339)
.map(OffsetDateTime::unix_timestamp)
.map_err(|err| {
io::Error::new(
ErrorKind::InvalidData,
format!(
"failed to parse remote control client last_seen_at `{last_seen_at}`: {err}"
),
)
})
})
.transpose()?,
})
}
}
@@ -1,9 +1,13 @@
use super::auth::RemoteControlConnectionAuth;
use super::pairing_unavailable_error;
use super::protocol::EnrollRemoteServerRequest;
use super::protocol::EnrollRemoteServerResponse;
use super::protocol::RefreshRemoteServerRequest;
use super::protocol::RemoteControlTarget;
use super::protocol::StartRemoteControlPairingRequest;
use super::protocol::StartRemoteControlPairingResponse;
use axum::http::HeaderMap;
use codex_api::SharedAuthProvider;
use codex_app_server_protocol::RemoteControlPairingStartResponse;
use codex_login::default_client::build_reqwest_client;
use codex_state::RemoteControlEnrollmentRecord;
use codex_state::StateRuntime;
@@ -17,6 +21,7 @@ use tracing::info;
use tracing::warn;
const REMOTE_CONTROL_ENROLL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
const REMOTE_CONTROL_PAIRING_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
const REMOTE_CONTROL_RESPONSE_BODY_MAX_BYTES: usize = 4096;
const REMOTE_CONTROL_SERVER_TOKEN_REFRESH_SKEW_SECS: i64 = 30;
@@ -28,6 +33,7 @@ pub(super) const REMOTE_CONTROL_INSTALLATION_ID_HEADER: &str = "x-codex-installa
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct RemoteControlEnrollment {
pub(super) remote_control_target: RemoteControlTarget,
pub(super) account_id: String,
pub(super) environment_id: String,
pub(super) server_id: String,
@@ -37,6 +43,99 @@ pub(super) struct RemoteControlEnrollment {
}
impl RemoteControlEnrollment {
pub(super) async fn start_pairing(
&self,
request: StartRemoteControlPairingRequest,
) -> io::Result<RemoteControlPairingStartResponse> {
if self.should_refresh_server_token() {
return Err(pairing_unavailable_error());
}
let remote_control_token = self
.remote_control_token
.as_deref()
.ok_or_else(pairing_unavailable_error)?;
let response = build_reqwest_client()
.post(&self.remote_control_target.pair_url)
.timeout(REMOTE_CONTROL_PAIRING_TIMEOUT)
.bearer_auth(remote_control_token)
.json(&request)
.send()
.await
.map_err(|err| {
io::Error::other(format!(
"failed to start remote control pairing at `{}`: {err}",
self.remote_control_target.pair_url
))
})?;
let headers = response.headers().clone();
let status = response.status();
let body = response.bytes().await.map_err(|err| {
io::Error::other(format!(
"failed to read remote control pairing response from `{}`: {err}",
self.remote_control_target.pair_url
))
})?;
let body_preview = preview_remote_control_response_body(&body);
if !status.is_success() {
let error_kind = match status.as_u16() {
401 | 403 => ErrorKind::PermissionDenied,
404 => ErrorKind::NotFound,
_ => ErrorKind::Other,
};
return Err(io::Error::new(
error_kind,
format!(
"remote control pairing failed at `{}`: HTTP {status}, {}, body: {body_preview}",
self.remote_control_target.pair_url,
format_headers(&headers)
),
));
}
let pairing = serde_json::from_slice::<StartRemoteControlPairingResponse>(&body).map_err(
|err| {
io::Error::other(format!(
"failed to parse remote control pairing response from `{}`: HTTP {status}, {}, body: {body_preview}, decode error: {err}",
self.remote_control_target.pair_url,
format_headers(&headers)
))
},
)?;
let StartRemoteControlPairingResponse {
pairing_code,
manual_pairing_code,
server_id,
environment_id,
expires_at,
} = pairing;
if server_id != self.server_id || environment_id != self.environment_id {
return Err(io::Error::other(format!(
"remote control pairing returned mismatched enrollment: expected server_id={}, environment_id={}; got server_id={}, environment_id={}",
self.server_id, self.environment_id, server_id, environment_id
)));
}
let expires_at = OffsetDateTime::parse(&expires_at, &Rfc3339)
.map_err(|err| {
io::Error::new(
ErrorKind::InvalidData,
format!(
"failed to parse remote control pairing response from `{}`: HTTP {status}, {}, body: {body_preview}, expires_at parse error: {err}",
self.remote_control_target.pair_url,
format_headers(&headers)
),
)
})?
.unix_timestamp();
Ok(RemoteControlPairingStartResponse {
pairing_code,
manual_pairing_code,
environment_id,
expires_at,
})
}
pub(super) fn should_refresh_server_token(&self) -> bool {
self.remote_control_token.is_none()
|| self.expires_at.is_none_or(|expires_at| {
@@ -52,11 +151,6 @@ impl RemoteControlEnrollment {
}
}
pub(super) struct RemoteControlConnectionAuth {
pub(super) auth_provider: SharedAuthProvider,
pub(super) account_id: String,
}
pub(super) async fn load_persisted_remote_control_enrollment(
state_db: Option<&StateRuntime>,
remote_control_target: &RemoteControlTarget,
@@ -101,6 +195,7 @@ pub(super) async fn load_persisted_remote_control_enrollment(
enrollment.environment_id
);
Ok(Some(RemoteControlEnrollment {
remote_control_target: remote_control_target.clone(),
account_id: enrollment.account_id,
environment_id: enrollment.environment_id,
server_id: enrollment.server_id,
@@ -211,8 +306,14 @@ fn redact_remote_control_response_body(body: &str) -> String {
let Some(body_object) = body_json.as_object_mut() else {
return body.to_string();
};
if let Some(remote_control_token) = body_object.get_mut("remote_control_token") {
*remote_control_token = serde_json::Value::String("<redacted>".to_string());
for sensitive_field in [
"remote_control_token",
"pairing_code",
"manual_pairing_code",
] {
if let Some(value) = body_object.get_mut(sensitive_field) {
*value = serde_json::Value::String("<redacted>".to_string());
}
}
body_json.to_string()
}
@@ -254,6 +355,7 @@ pub(super) async fn enroll_remote_control_server(
)
.await?;
let mut enrollment = RemoteControlEnrollment {
remote_control_target: remote_control_target.clone(),
account_id: auth.account_id.clone(),
environment_id: enrollment_response.environment_id,
server_id: enrollment_response.server_id,
@@ -271,18 +373,17 @@ pub(super) async fn enroll_remote_control_server(
}
pub(super) async fn refresh_remote_control_server(
remote_control_target: &RemoteControlTarget,
auth: &RemoteControlConnectionAuth,
installation_id: &str,
enrollment: &mut RemoteControlEnrollment,
) -> io::Result<()> {
let refresh_url = &remote_control_target.refresh_url;
let refresh_url = enrollment.remote_control_target.refresh_url.clone();
let request = RefreshRemoteServerRequest {
server_id: enrollment.server_id.clone(),
installation_id: installation_id.to_string(),
};
let refreshed = send_remote_control_server_request::<_, EnrollRemoteServerResponse>(
refresh_url,
&refresh_url,
auth,
installation_id,
&request,
@@ -304,7 +405,7 @@ pub(super) async fn refresh_remote_control_server(
update_remote_control_server_token(
enrollment,
refresh_url,
&refresh_url,
refreshed.remote_control_token,
refreshed.expires_at,
)
@@ -412,6 +513,8 @@ mod tests {
#[test]
fn remote_control_enrollment_refreshes_server_token_before_expiry() {
let expires_soon = RemoteControlEnrollment {
remote_control_target: normalize_remote_control_url("http://localhost/backend-api/")
.expect("target should normalize"),
account_id: "account-a".to_string(),
environment_id: "env_first".to_string(),
server_id: "srv_e_first".to_string(),
@@ -433,12 +536,14 @@ mod tests {
fn preview_remote_control_response_body_redacts_server_token() {
assert_eq!(
serde_json::from_str::<serde_json::Value>(&preview_remote_control_response_body(
br#"{"server_id":"srv_e_test","remote_control_token":"secret"}"#
br#"{"server_id":"srv_e_test","remote_control_token":"secret","pairing_code":"pairing-code","manual_pairing_code":"ABCD-EFGH"}"#
))
.expect("redacted response preview should stay valid json"),
json!({
"server_id": "srv_e_test",
"remote_control_token": "<redacted>",
"pairing_code": "<redacted>",
"manual_pairing_code": "<redacted>",
})
);
}
@@ -453,6 +558,7 @@ mod tests {
normalize_remote_control_url("https://api.chatgpt-staging.com/other/control")
.expect("second target should parse");
let first_enrollment = RemoteControlEnrollment {
remote_control_target: first_target.clone(),
account_id: "account-a".to_string(),
environment_id: "env_first".to_string(),
server_id: "srv_e_first".to_string(),
@@ -461,6 +567,7 @@ mod tests {
expires_at: None,
};
let second_enrollment = RemoteControlEnrollment {
remote_control_target: second_target.clone(),
account_id: "account-a".to_string(),
environment_id: "env_second".to_string(),
server_id: "srv_e_second".to_string(),
@@ -533,6 +640,7 @@ mod tests {
normalize_remote_control_url("https://api.chatgpt-staging.com/other/control")
.expect("second target should parse");
let first_enrollment = RemoteControlEnrollment {
remote_control_target: first_target.clone(),
account_id: "account-a".to_string(),
environment_id: "env_first".to_string(),
server_id: "srv_e_first".to_string(),
@@ -541,6 +649,7 @@ mod tests {
expires_at: None,
};
let second_enrollment = RemoteControlEnrollment {
remote_control_target: second_target.clone(),
account_id: "account-a".to_string(),
environment_id: "env_second".to_string(),
server_id: "srv_e_second".to_string(),
@@ -1,9 +1,15 @@
mod auth;
mod client_tracker;
mod clients;
mod enroll;
mod protocol;
mod segment;
mod websocket;
use self::auth::load_remote_control_auth;
use self::auth::recover_remote_control_auth;
use self::enroll::RemoteControlEnrollment;
use self::enroll::refresh_remote_control_server;
use crate::transport::remote_control::websocket::RemoteControlChannels;
use crate::transport::remote_control::websocket::RemoteControlStatusPublisher;
use crate::transport::remote_control::websocket::RemoteControlWebsocket;
@@ -15,7 +21,13 @@ use self::protocol::normalize_remote_control_url;
use super::CHANNEL_CAPACITY;
use super::TransportEvent;
use super::next_connection_id;
use codex_app_server_protocol::RemoteControlClientsListParams;
use codex_app_server_protocol::RemoteControlClientsListResponse;
use codex_app_server_protocol::RemoteControlClientsRevokeParams;
use codex_app_server_protocol::RemoteControlClientsRevokeResponse;
use codex_app_server_protocol::RemoteControlConnectionStatus;
use codex_app_server_protocol::RemoteControlPairingStartParams;
use codex_app_server_protocol::RemoteControlPairingStartResponse;
use codex_app_server_protocol::RemoteControlStatusChangedNotification;
use codex_login::AuthManager;
use codex_state::StateRuntime;
@@ -26,6 +38,7 @@ use std::fmt;
use std::io;
use std::panic::AssertUnwindSafe;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use tokio::sync::watch;
@@ -52,8 +65,13 @@ pub struct RemoteControlHandle {
enabled_tx: Arc<watch::Sender<bool>>,
status_tx: Arc<watch::Sender<RemoteControlStatusChangedNotification>>,
state_db_available: bool,
remote_control_url: String,
current_enrollment: CurrentRemoteControlEnrollment,
auth_manager: Arc<AuthManager>,
}
type CurrentRemoteControlEnrollment = Arc<StdMutex<Option<RemoteControlEnrollment>>>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RemoteControlUnavailable;
@@ -108,6 +126,7 @@ impl RemoteControlHandle {
*state = false;
changed
});
clear_current_enrollment(&self.current_enrollment);
let status = self.status();
info!(
@@ -129,6 +148,104 @@ impl RemoteControlHandle {
self.status_tx.subscribe()
}
pub async fn start_pairing(
&self,
params: RemoteControlPairingStartParams,
) -> io::Result<RemoteControlPairingStartResponse> {
if !*self.enabled_tx.borrow() {
return Err(Self::pairing_disabled_error());
}
let mut auth = load_remote_control_auth(&self.auth_manager)
.await
.map_err(|_| pairing_unavailable_error())?;
let mut enrollment = {
let current_enrollment = self
.current_enrollment
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
current_enrollment
.as_ref()
.filter(|enrollment| enrollment.account_id == auth.account_id)
.cloned()
}
.ok_or_else(pairing_unavailable_error)?;
let installation_id = self.status().installation_id;
if enrollment.should_refresh_server_token() {
refresh_pairing_enrollment(
&self.current_enrollment,
&self.auth_manager,
&mut auth,
&installation_id,
&mut enrollment,
)
.await?;
}
let pairing_request = || protocol::StartRemoteControlPairingRequest {
manual_code: params.manual_code,
};
let pairing_response = match enrollment.start_pairing(pairing_request()).await {
Err(err) if err.kind() == io::ErrorKind::PermissionDenied => {
clear_pairing_server_token(&self.current_enrollment, &mut enrollment)?;
refresh_pairing_enrollment(
&self.current_enrollment,
&self.auth_manager,
&mut auth,
&installation_id,
&mut enrollment,
)
.await?;
enrollment.start_pairing(pairing_request()).await
}
pairing_response => pairing_response,
};
if let Err(err) = &pairing_response {
match err.kind() {
io::ErrorKind::NotFound => {
clear_current_enrollment_if_matches(&self.current_enrollment, &enrollment);
return Err(pairing_unavailable_error());
}
io::ErrorKind::PermissionDenied => {
clear_pairing_server_token(&self.current_enrollment, &mut enrollment)?;
return Err(pairing_unavailable_error());
}
_ => {}
}
}
if !*self.enabled_tx.borrow() {
return Err(Self::pairing_disabled_error());
}
let current_auth = load_remote_control_auth(&self.auth_manager)
.await
.map_err(|_| pairing_unavailable_error())?;
if current_auth.account_id != auth.account_id {
return Err(pairing_unavailable_error());
}
pairing_response
}
pub async fn list_clients(
&self,
params: RemoteControlClientsListParams,
) -> io::Result<RemoteControlClientsListResponse> {
clients::list_remote_control_clients(&self.remote_control_url, &self.auth_manager, params)
.await
}
pub async fn revoke_client(
&self,
params: RemoteControlClientsRevokeParams,
) -> io::Result<RemoteControlClientsRevokeResponse> {
clients::revoke_remote_control_client(&self.remote_control_url, &self.auth_manager, params)
.await
}
fn pairing_disabled_error() -> io::Error {
io::Error::new(
io::ErrorKind::InvalidInput,
"remote control pairing requires remote control to be enabled",
)
}
fn publish_status(
&self,
connection_status: RemoteControlConnectionStatus,
@@ -160,6 +277,71 @@ impl RemoteControlHandle {
}
}
async fn refresh_pairing_enrollment(
current_enrollment: &CurrentRemoteControlEnrollment,
auth_manager: &Arc<AuthManager>,
auth: &mut auth::RemoteControlConnectionAuth,
installation_id: &str,
enrollment: &mut RemoteControlEnrollment,
) -> io::Result<()> {
if let Err(err) = refresh_remote_control_server(auth, installation_id, enrollment).await {
if err.kind() != io::ErrorKind::PermissionDenied {
return handle_pairing_refresh_error(current_enrollment, enrollment, err);
}
let mut auth_recovery = auth_manager.unauthorized_recovery();
let mut auth_change_rx = auth_manager.auth_change_receiver();
if !recover_remote_control_auth(&mut auth_recovery, &mut auth_change_rx).await {
return Err(err);
}
*auth = load_remote_control_auth(auth_manager)
.await
.map_err(|_| pairing_unavailable_error())?;
if auth.account_id != enrollment.account_id {
return Err(pairing_unavailable_error());
}
if let Err(err) = refresh_remote_control_server(auth, installation_id, enrollment).await {
return handle_pairing_refresh_error(current_enrollment, enrollment, err);
}
}
if replace_current_enrollment(current_enrollment, enrollment) {
Ok(())
} else {
Err(pairing_unavailable_error())
}
}
fn handle_pairing_refresh_error(
current_enrollment: &CurrentRemoteControlEnrollment,
enrollment: &RemoteControlEnrollment,
err: io::Error,
) -> io::Result<()> {
if err.kind() == io::ErrorKind::NotFound {
clear_current_enrollment_if_matches(current_enrollment, enrollment);
Err(pairing_unavailable_error())
} else {
Err(err)
}
}
fn clear_pairing_server_token(
current_enrollment: &CurrentRemoteControlEnrollment,
enrollment: &mut RemoteControlEnrollment,
) -> io::Result<()> {
enrollment.clear_server_token();
if replace_current_enrollment(current_enrollment, enrollment) {
Ok(())
} else {
Err(pairing_unavailable_error())
}
}
fn pairing_unavailable_error() -> io::Error {
io::Error::new(
io::ErrorKind::InvalidInput,
"remote control pairing is unavailable until enrollment completes",
)
}
fn remote_control_status_with_connection_status(
status: &RemoteControlStatusChangedNotification,
connection_status: RemoteControlConnectionStatus,
@@ -176,6 +358,64 @@ fn remote_control_status_with_connection_status(
}
}
fn publish_current_enrollment(
current_enrollment: &CurrentRemoteControlEnrollment,
enrollment: &RemoteControlEnrollment,
) {
*current_enrollment
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(enrollment.clone());
}
fn clear_current_enrollment(current_enrollment: &CurrentRemoteControlEnrollment) {
*current_enrollment
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
}
fn replace_current_enrollment(
current_enrollment: &CurrentRemoteControlEnrollment,
enrollment: &RemoteControlEnrollment,
) -> bool {
let mut current_enrollment = current_enrollment
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if !current_enrollment
.as_ref()
.is_some_and(|current| same_remote_control_enrollment(current, enrollment))
{
return false;
}
*current_enrollment = Some(enrollment.clone());
true
}
fn clear_current_enrollment_if_matches(
current_enrollment: &CurrentRemoteControlEnrollment,
enrollment: &RemoteControlEnrollment,
) {
let mut current_enrollment = current_enrollment
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if current_enrollment
.as_ref()
.is_some_and(|current| same_remote_control_enrollment(current, enrollment))
{
*current_enrollment = None;
}
}
fn same_remote_control_enrollment(
left: &RemoteControlEnrollment,
right: &RemoteControlEnrollment,
) -> bool {
// A refresh rotates only the bearer. Pairing remains current while the same persisted server
// record is still selected for the current account.
left.account_id == right.account_id
&& left.server_id == right.server_id
&& left.environment_id == right.environment_id
}
pub async fn start_remote_control(
config: RemoteControlStartConfig,
state_db: Option<Arc<StateRuntime>>,
@@ -198,6 +438,9 @@ pub async fn start_remote_control(
};
let (enabled_tx, enabled_rx) = watch::channel(initial_enabled);
let current_enrollment = Arc::new(StdMutex::new(None));
let websocket_current_enrollment = current_enrollment.clone();
let handle_auth_manager = auth_manager.clone();
let server_name = gethostname().to_string_lossy().trim().to_string();
let remote_control_url = config.remote_control_url;
let installation_id = config.installation_id;
@@ -222,6 +465,7 @@ pub async fn start_remote_control(
"starting app-server remote control websocket task"
);
let remote_control_url_for_log = remote_control_url.clone();
let handle_remote_control_url = remote_control_url.clone();
let installation_id_for_log = installation_id.clone();
let server_name_for_log = server_name.clone();
let shutdown_token_for_log = shutdown_token.clone();
@@ -245,6 +489,7 @@ pub async fn start_remote_control(
RemoteControlChannels {
transport_event_tx,
status_publisher,
current_enrollment: websocket_current_enrollment,
},
shutdown_token,
enabled_rx,
@@ -289,6 +534,9 @@ pub async fn start_remote_control(
enabled_tx: Arc::new(enabled_tx),
status_tx: Arc::new(status_tx),
state_db_available,
remote_control_url: handle_remote_control_url,
current_enrollment,
auth_manager: handle_auth_manager,
},
))
}
@@ -12,6 +12,7 @@ pub(super) struct RemoteControlTarget {
pub(super) websocket_url: String,
pub(super) enroll_url: String,
pub(super) refresh_url: String,
pub(super) pair_url: String,
}
#[derive(Debug, Serialize)]
@@ -37,6 +38,20 @@ pub(super) struct RefreshRemoteServerRequest {
pub(super) installation_id: String,
}
#[derive(Debug, Serialize)]
pub(super) struct StartRemoteControlPairingRequest {
pub(super) manual_code: bool,
}
#[derive(Debug, Deserialize)]
pub(super) struct StartRemoteControlPairingResponse {
pub(super) pairing_code: String,
pub(super) manual_pairing_code: Option<String>,
pub(super) server_id: String,
pub(super) environment_id: String,
pub(super) expires_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ClientId(pub String);
@@ -162,6 +177,48 @@ fn is_localhost(host: &Option<Host<&str>>) -> bool {
pub(super) fn normalize_remote_control_url(
remote_control_url: &str,
) -> io::Result<RemoteControlTarget> {
let remote_control_url = normalize_remote_control_base_url(remote_control_url)?;
let map_url_parse_error = |err: url::ParseError| -> io::Error {
io::Error::new(
ErrorKind::InvalidInput,
format!("invalid remote control URL `{remote_control_url}`: {err}"),
)
};
let enroll_url = remote_control_url
.join("wham/remote/control/server/enroll")
.map_err(map_url_parse_error)?;
let refresh_url = remote_control_url
.join("wham/remote/control/server/refresh")
.map_err(map_url_parse_error)?;
let pair_url = remote_control_url
.join("wham/remote/control/server/pair")
.map_err(map_url_parse_error)?;
let mut websocket_url = remote_control_url
.join("wham/remote/control/server")
.map_err(map_url_parse_error)?;
websocket_url
.set_scheme(if enroll_url.scheme() == "https" {
"wss"
} else {
"ws"
})
.map_err(|()| {
io::Error::new(
ErrorKind::InvalidInput,
format!("invalid remote control URL `{remote_control_url}`"),
)
})?;
Ok(RemoteControlTarget {
websocket_url: websocket_url.to_string(),
enroll_url: enroll_url.to_string(),
refresh_url: refresh_url.to_string(),
pair_url: pair_url.to_string(),
})
}
pub(super) fn normalize_remote_control_base_url(remote_control_url: &str) -> io::Result<Url> {
let map_url_parse_error = |err: url::ParseError| -> io::Error {
io::Error::new(
ErrorKind::InvalidInput,
@@ -183,31 +240,14 @@ pub(super) fn normalize_remote_control_url(
remote_control_url.set_path(&normalized_path);
}
let enroll_url = remote_control_url
.join("wham/remote/control/server/enroll")
.map_err(map_url_parse_error)?;
let refresh_url = remote_control_url
.join("wham/remote/control/server/refresh")
.map_err(map_url_parse_error)?;
let mut websocket_url = remote_control_url
.join("wham/remote/control/server")
.map_err(map_url_parse_error)?;
let host = enroll_url.host();
match enroll_url.scheme() {
"https" if is_localhost(&host) || is_allowed_remote_control_chatgpt_host(&host) => {
websocket_url.set_scheme("wss").map_err(map_scheme_error)?;
}
"http" if is_localhost(&host) => {
websocket_url.set_scheme("ws").map_err(map_scheme_error)?;
}
let host = remote_control_url.host();
match remote_control_url.scheme() {
"https" if is_localhost(&host) || is_allowed_remote_control_chatgpt_host(&host) => {}
"http" if is_localhost(&host) => {}
_ => return Err(map_scheme_error(())),
}
Ok(RemoteControlTarget {
websocket_url: websocket_url.to_string(),
enroll_url: enroll_url.to_string(),
refresh_url: refresh_url.to_string(),
})
Ok(remote_control_url)
}
#[cfg(test)]
@@ -227,6 +267,8 @@ mod tests {
.to_string(),
refresh_url: "https://chatgpt.com/backend-api/wham/remote/control/server/refresh"
.to_string(),
pair_url: "https://chatgpt.com/backend-api/wham/remote/control/server/pair"
.to_string(),
}
);
assert_eq!(
@@ -242,6 +284,9 @@ mod tests {
refresh_url:
"https://api.chatgpt-staging.com/backend-api/wham/remote/control/server/refresh"
.to_string(),
pair_url:
"https://api.chatgpt-staging.com/backend-api/wham/remote/control/server/pair"
.to_string(),
}
);
}
@@ -258,6 +303,8 @@ mod tests {
.to_string(),
refresh_url: "http://localhost:8080/backend-api/wham/remote/control/server/refresh"
.to_string(),
pair_url: "http://localhost:8080/backend-api/wham/remote/control/server/pair"
.to_string(),
}
);
assert_eq!(
@@ -271,6 +318,8 @@ mod tests {
refresh_url:
"https://localhost:8443/backend-api/wham/remote/control/server/refresh"
.to_string(),
pair_url: "https://localhost:8443/backend-api/wham/remote/control/server/pair"
.to_string(),
}
);
}
@@ -20,6 +20,7 @@ use codex_app_server_protocol::AuthMode;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::RemoteControlConnectionStatus;
use codex_app_server_protocol::RemoteControlPairingStartParams;
use codex_app_server_protocol::RemoteControlStatusChangedNotification;
use codex_app_server_protocol::ServerNotification;
use codex_config::types::AuthCredentialsStoreMode;
@@ -39,7 +40,9 @@ use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::BTreeMap;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use tempfile::TempDir;
use time::OffsetDateTime;
use tokio::io::AsyncBufReadExt;
use tokio::io::AsyncReadExt;
use tokio::io::AsyncWriteExt;
@@ -57,7 +60,11 @@ use tokio_tungstenite::accept_hdr_async;
use tokio_tungstenite::tungstenite;
use tokio_util::sync::CancellationToken;
mod clients_tests;
mod pairing_tests;
const TEST_INSTALLATION_ID: &str = "11111111-1111-4111-8111-111111111111";
const TEST_REMOTE_CONTROL_URL: &str = "http://127.0.0.1:1/backend-api/wham/remote/control";
const TEST_REMOTE_CONTROL_SERVER_TOKEN: &str = "Remote Control Token";
const TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN: &str = "Refreshed Remote Control Token";
const TEST_REMOTE_CONTROL_SERVER_TOKEN_EXPIRES_AT: &str = "2999-01-01T00:00:00Z";
@@ -128,6 +135,41 @@ fn test_server_name() -> String {
gethostname().to_string_lossy().trim().to_string()
}
fn remote_control_handle_with_current_enrollment(
remote_control_url: &str,
auth_manager: Arc<AuthManager>,
) -> RemoteControlHandle {
let (enabled_tx, _enabled_rx) = watch::channel(/*init*/ true);
let (status_tx, _status_rx) = watch::channel(RemoteControlStatusChangedNotification {
status: RemoteControlConnectionStatus::Connecting,
server_name: test_server_name(),
installation_id: TEST_INSTALLATION_ID.to_string(),
environment_id: Some("env_test".to_string()),
});
let remote_control_target = normalize_remote_control_url(remote_control_url)
.expect("remote control target should normalize");
let current_enrollment = Arc::new(StdMutex::new(Some(RemoteControlEnrollment {
remote_control_target,
account_id: "account_id".to_string(),
environment_id: "env_test".to_string(),
server_id: "srv_e_test".to_string(),
server_name: test_server_name(),
remote_control_token: Some(TEST_REMOTE_CONTROL_SERVER_TOKEN.to_string()),
expires_at: Some(
OffsetDateTime::from_unix_timestamp(33_336_362_096)
.expect("future timestamp should parse"),
),
})));
RemoteControlHandle {
enabled_tx: Arc::new(enabled_tx),
status_tx: Arc::new(status_tx),
state_db_available: true,
remote_control_url: remote_control_url.to_string(),
current_enrollment,
auth_manager,
}
}
fn remote_control_server_token_response(
server_id: &str,
environment_id: &str,
@@ -1313,6 +1355,7 @@ async fn remote_control_http_mode_refreshes_persisted_enrollment_before_connecti
let remote_control_target =
normalize_remote_control_url(&remote_control_url).expect("target should parse");
let persisted_enrollment = RemoteControlEnrollment {
remote_control_target: remote_control_target.clone(),
account_id: "account_id".to_string(),
environment_id: "env_persisted".to_string(),
server_id: "srv_e_persisted".to_string(),
@@ -1418,6 +1461,7 @@ async fn remote_control_stdio_mode_waits_for_client_name_before_connecting() {
normalize_remote_control_url(&remote_control_url).expect("target should parse");
let app_server_client_name = "stdio-client";
let persisted_enrollment = RemoteControlEnrollment {
remote_control_target: remote_control_target.clone(),
account_id: "account_id".to_string(),
environment_id: "env_persisted".to_string(),
server_id: "srv_e_persisted".to_string(),
@@ -1505,7 +1549,10 @@ async fn remote_control_waits_for_account_id_before_enrolling() {
)
.await;
let expected_server_name = gethostname().to_string_lossy().trim().to_string();
let expected_remote_control_target = normalize_remote_control_url(&remote_control_url)
.expect("remote control target should normalize");
let expected_enrollment = RemoteControlEnrollment {
remote_control_target: expected_remote_control_target,
account_id: "account_id".to_string(),
environment_id: "env_ready".to_string(),
server_id: "srv_e_ready".to_string(),
@@ -1583,6 +1630,7 @@ async fn remote_control_http_mode_reenrolls_when_refresh_reports_stale_enrollmen
normalize_remote_control_url(&remote_control_url).expect("target should parse");
let expected_server_name = gethostname().to_string_lossy().trim().to_string();
let stale_enrollment = RemoteControlEnrollment {
remote_control_target: remote_control_target.clone(),
account_id: "account_id".to_string(),
environment_id: "env_stale".to_string(),
server_id: "srv_e_stale".to_string(),
@@ -1591,6 +1639,7 @@ async fn remote_control_http_mode_reenrolls_when_refresh_reports_stale_enrollmen
expires_at: None,
};
let refreshed_enrollment = RemoteControlEnrollment {
remote_control_target: remote_control_target.clone(),
account_id: "account_id".to_string(),
environment_id: "env_refreshed".to_string(),
server_id: "srv_e_refreshed".to_string(),
@@ -1700,6 +1749,7 @@ async fn remote_control_http_mode_clears_stale_persisted_enrollment_after_404()
normalize_remote_control_url(&remote_control_url).expect("target should parse");
let expected_server_name = gethostname().to_string_lossy().trim().to_string();
let stale_enrollment = RemoteControlEnrollment {
remote_control_target: remote_control_target.clone(),
account_id: "account_id".to_string(),
environment_id: "env_stale".to_string(),
server_id: "srv_e_stale".to_string(),
@@ -1708,6 +1758,7 @@ async fn remote_control_http_mode_clears_stale_persisted_enrollment_after_404()
expires_at: None,
};
let refreshed_enrollment = RemoteControlEnrollment {
remote_control_target: remote_control_target.clone(),
account_id: "account_id".to_string(),
environment_id: "env_refreshed".to_string(),
server_id: "srv_e_refreshed".to_string(),
@@ -0,0 +1,359 @@
use super::super::clients::list_remote_control_clients;
use super::super::clients::revoke_remote_control_client;
use super::*;
use codex_app_server_protocol::RemoteControlClient;
use codex_app_server_protocol::RemoteControlClientsListOrder;
use codex_app_server_protocol::RemoteControlClientsListParams;
use codex_app_server_protocol::RemoteControlClientsListResponse;
use codex_app_server_protocol::RemoteControlClientsRevokeParams;
use codex_app_server_protocol::RemoteControlClientsRevokeResponse;
use pretty_assertions::assert_eq;
fn client_management_handle(
remote_control_url: String,
auth_manager: Arc<AuthManager>,
) -> RemoteControlHandle {
let (enabled_tx, _enabled_rx) = watch::channel(/*init*/ false);
let (status_tx, _status_rx) = watch::channel(RemoteControlStatusChangedNotification {
status: RemoteControlConnectionStatus::Disabled,
server_name: test_server_name(),
installation_id: TEST_INSTALLATION_ID.to_string(),
environment_id: None,
});
RemoteControlHandle {
enabled_tx: Arc::new(enabled_tx),
status_tx: Arc::new(status_tx),
state_db_available: false,
remote_control_url,
current_enrollment: Arc::new(StdMutex::new(None)),
auth_manager,
}
}
fn empty_client_list() -> serde_json::Value {
json!({
"items": [],
"cursor": null,
})
}
#[tokio::test]
async fn remote_control_handle_lists_clients_while_disabled() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let remote_control_url = remote_control_url_for_listener(&listener);
let server_task = tokio::spawn(async move {
let request = accept_http_request(&listener).await;
assert_eq!(
request.request_line,
"GET /backend-api/wham/remote/control/environments/env%20%2F%3F/clients?cursor=cursor+%2F%3F&limit=10&order=asc HTTP/1.1"
);
assert_eq!(
request.headers.get("authorization"),
Some(&"Bearer Access Token".to_string())
);
assert_eq!(
request.headers.get(REMOTE_CONTROL_ACCOUNT_ID_HEADER),
Some(&"account_id".to_string())
);
respond_with_json(
request.stream,
json!({
"items": [{
"client_id": "client-123",
"account_user_id": "user-123",
"enrollment_status": "enrolled_device_key",
"display_name": "Anton Phone",
"device_type": "phone",
"platform": "ios",
"os_version": "19.0",
"device_model": "iPhone",
"app_version": "1.2.3",
"last_seen_at": "2026-03-05T07:00:00Z",
"last_seen_city": "San Francisco",
}],
"cursor": "next-cursor",
}),
)
.await;
});
let handle = client_management_handle(remote_control_url, remote_control_auth_manager());
let response = handle
.list_clients(RemoteControlClientsListParams {
environment_id: "env /?".to_string(),
cursor: Some("cursor /?".to_string()),
limit: Some(10),
order: Some(RemoteControlClientsListOrder::Asc),
})
.await
.expect("client list should succeed while remote control is disabled");
server_task.await.expect("server task should finish");
assert_eq!(
response,
RemoteControlClientsListResponse {
data: vec![RemoteControlClient {
client_id: "client-123".to_string(),
display_name: Some("Anton Phone".to_string()),
device_type: Some("phone".to_string()),
platform: Some("ios".to_string()),
os_version: Some("19.0".to_string()),
device_model: Some("iPhone".to_string()),
app_version: Some("1.2.3".to_string()),
last_seen_at: Some(1_772_694_000),
}],
next_cursor: Some("next-cursor".to_string()),
}
);
}
#[tokio::test]
async fn remote_control_handle_revokes_client_while_disabled() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let remote_control_url = remote_control_url_for_listener(&listener);
let server_task = tokio::spawn(async move {
let request = accept_http_request(&listener).await;
assert_eq!(
request.request_line,
"DELETE /backend-api/wham/remote/control/environments/env%20%2F%3F/clients/client%20%2F%3F HTTP/1.1"
);
respond_with_status(request.stream, "204 No Content", "").await;
});
let handle = client_management_handle(remote_control_url, remote_control_auth_manager());
let response = handle
.revoke_client(RemoteControlClientsRevokeParams {
environment_id: "env /?".to_string(),
client_id: "client /?".to_string(),
})
.await
.expect("client revoke should succeed while remote control is disabled");
server_task.await.expect("server task should finish");
assert_eq!(response, RemoteControlClientsRevokeResponse {});
}
#[tokio::test]
async fn list_remote_control_clients_recovers_auth_after_unauthorized() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let remote_control_url = remote_control_url_for_listener(&listener);
let server_task = tokio::spawn(async move {
let stale_request = accept_http_request(&listener).await;
assert_eq!(
stale_request.headers.get("authorization"),
Some(&"Bearer stale-token".to_string())
);
respond_with_status(stale_request.stream, "401 Unauthorized", "").await;
let recovered_request = accept_http_request(&listener).await;
assert_eq!(
recovered_request.headers.get("authorization"),
Some(&"Bearer fresh-token".to_string())
);
respond_with_json(recovered_request.stream, empty_client_list()).await;
});
let codex_home = TempDir::new().expect("temp dir should create");
let mut stale_auth = remote_control_auth_dot_json(Some("account_id"));
stale_auth
.tokens
.as_mut()
.expect("stale auth should include tokens")
.access_token = "stale-token".to_string();
save_auth(
codex_home.path(),
&stale_auth,
AuthCredentialsStoreMode::File,
)
.expect("stale auth should save");
let auth_manager = AuthManager::shared(
codex_home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*chatgpt_base_url*/ None,
)
.await;
let mut fresh_auth = remote_control_auth_dot_json(Some("account_id"));
fresh_auth
.tokens
.as_mut()
.expect("fresh auth should include tokens")
.access_token = "fresh-token".to_string();
save_auth(
codex_home.path(),
&fresh_auth,
AuthCredentialsStoreMode::File,
)
.expect("fresh auth should save");
let response = list_remote_control_clients(
&remote_control_url,
&auth_manager,
RemoteControlClientsListParams {
environment_id: "env-123".to_string(),
..Default::default()
},
)
.await
.expect("client list should recover auth");
server_task.await.expect("server task should finish");
assert_eq!(
response,
RemoteControlClientsListResponse {
data: Vec::new(),
next_cursor: None,
}
);
}
#[tokio::test]
async fn list_remote_control_clients_retries_unauthorized_only_once() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let remote_control_url = remote_control_url_for_listener(&listener);
let server_task = tokio::spawn(async move {
let stale_request = accept_http_request(&listener).await;
assert_eq!(
stale_request.headers.get("authorization"),
Some(&"Bearer stale-token".to_string())
);
respond_with_status(stale_request.stream, "401 Unauthorized", "").await;
let recovered_request = accept_http_request(&listener).await;
assert_eq!(
recovered_request.headers.get("authorization"),
Some(&"Bearer fresh-token".to_string())
);
respond_with_status(recovered_request.stream, "401 Unauthorized", "").await;
assert!(
timeout(Duration::from_millis(100), accept_http_request(&listener))
.await
.is_err()
);
});
let codex_home = TempDir::new().expect("temp dir should create");
let mut stale_auth = remote_control_auth_dot_json(Some("account_id"));
stale_auth
.tokens
.as_mut()
.expect("stale auth should include tokens")
.access_token = "stale-token".to_string();
save_auth(
codex_home.path(),
&stale_auth,
AuthCredentialsStoreMode::File,
)
.expect("stale auth should save");
let auth_manager = AuthManager::shared(
codex_home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*chatgpt_base_url*/ None,
)
.await;
let mut fresh_auth = remote_control_auth_dot_json(Some("account_id"));
fresh_auth
.tokens
.as_mut()
.expect("fresh auth should include tokens")
.access_token = "fresh-token".to_string();
save_auth(
codex_home.path(),
&fresh_auth,
AuthCredentialsStoreMode::File,
)
.expect("fresh auth should save");
let err = list_remote_control_clients(
&remote_control_url,
&auth_manager,
RemoteControlClientsListParams {
environment_id: "env-123".to_string(),
..Default::default()
},
)
.await
.expect_err("second unauthorized response should fail");
server_task.await.expect("server task should finish");
assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
}
#[tokio::test]
async fn revoke_remote_control_client_does_not_retry_forbidden() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let remote_control_url = remote_control_url_for_listener(&listener);
let server_task = tokio::spawn(async move {
let request = accept_http_request(&listener).await;
respond_with_status_and_headers(
request.stream,
"403 Forbidden",
&[("x-request-id", "request-123"), ("cf-ray", "ray-123")],
"forbidden",
)
.await;
});
let err = revoke_remote_control_client(
&remote_control_url,
&remote_control_auth_manager(),
RemoteControlClientsRevokeParams {
environment_id: "env-123".to_string(),
client_id: "client-123".to_string(),
},
)
.await
.expect_err("forbidden revoke should fail");
server_task.await.expect("server task should finish");
assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
assert_eq!(
err.to_string(),
format!(
"remote control client revoke failed at `{remote_control_url}wham/remote/control/environments/env-123/clients/client-123`: HTTP 403 Forbidden, request-id: request-123, cf-ray: ray-123, body: forbidden"
)
);
}
#[tokio::test]
async fn list_remote_control_clients_preserves_decode_error_context() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let remote_control_url = remote_control_url_for_listener(&listener);
let server_task = tokio::spawn(async move {
let request = accept_http_request(&listener).await;
respond_with_status(request.stream, "200 OK", "{").await;
});
let err = list_remote_control_clients(
&remote_control_url,
&remote_control_auth_manager(),
RemoteControlClientsListParams {
environment_id: "env-123".to_string(),
..Default::default()
},
)
.await
.expect_err("malformed client list should fail");
server_task.await.expect("server task should finish");
assert!(
err.to_string().contains(
"failed to parse remote control client list response from `http://127.0.0.1:"
)
);
assert!(err.to_string().contains("HTTP 200 OK"));
assert!(err.to_string().contains("body: {"));
assert!(err.to_string().contains("decode error:"));
}
@@ -0,0 +1,494 @@
use super::super::protocol::StartRemoteControlPairingRequest;
use super::*;
use pretty_assertions::assert_eq;
fn remote_control_enrollment(
remote_control_url: &str,
remote_control_token: &str,
) -> RemoteControlEnrollment {
RemoteControlEnrollment {
remote_control_target: normalize_remote_control_url(remote_control_url)
.expect("target should normalize"),
account_id: "account-id".to_string(),
environment_id: "environment-id".to_string(),
server_id: "server-id".to_string(),
server_name: "server-name".to_string(),
remote_control_token: Some(remote_control_token.to_string()),
expires_at: Some(
OffsetDateTime::from_unix_timestamp(33_336_362_096)
.expect("future timestamp should parse"),
),
}
}
async fn pairing_error(status: &'static str, body: &'static str) -> (String, String) {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let remote_control_url = remote_control_url_for_listener(&listener);
let expected_pair_url = normalize_remote_control_url(&remote_control_url)
.expect("target should normalize")
.pair_url;
let server_task = tokio::spawn(async move {
let pairing_request = accept_http_request(&listener).await;
respond_with_status_and_headers(
pairing_request.stream,
status,
&[("x-request-id", "request-123"), ("cf-ray", "ray-123")],
body,
)
.await;
});
let err = remote_control_enrollment(&remote_control_url, "remote-control-token")
.start_pairing(StartRemoteControlPairingRequest { manual_code: false })
.await
.expect_err("pairing should fail");
server_task.await.expect("server task should finish");
(err.to_string(), expected_pair_url)
}
async fn pairing_response_error(body: serde_json::Value) -> String {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let remote_control_url = remote_control_url_for_listener(&listener);
let server_task = tokio::spawn(async move {
let pairing_request = accept_http_request(&listener).await;
respond_with_json(pairing_request.stream, body).await;
});
let err = remote_control_enrollment(&remote_control_url, "remote-control-token")
.start_pairing(StartRemoteControlPairingRequest { manual_code: false })
.await
.expect_err("pairing should fail");
server_task.await.expect("server task should finish");
err.to_string()
}
#[tokio::test]
async fn remote_control_handle_starts_pairing_before_websocket_connects() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let remote_control_url = remote_control_url_for_listener(&listener);
let server_task = tokio::spawn(async move {
let refresh_request = accept_http_request(&listener).await;
assert_eq!(
refresh_request.request_line,
"POST /backend-api/wham/remote/control/server/refresh HTTP/1.1"
);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&refresh_request.body)
.expect("refresh request body should deserialize"),
json!({
"server_id": "srv_e_test",
"installation_id": TEST_INSTALLATION_ID,
})
);
respond_with_json(
refresh_request.stream,
remote_control_server_token_response(
"srv_e_test",
"env_test",
TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN,
),
)
.await;
let pairing_request = accept_http_request(&listener).await;
assert_eq!(
pairing_request.request_line,
"POST /backend-api/wham/remote/control/server/pair HTTP/1.1"
);
assert_eq!(
pairing_request.headers.get("authorization"),
Some(&format!(
"Bearer {TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN}"
))
);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&pairing_request.body)
.expect("pairing request body should deserialize"),
json!({ "manual_code": true })
);
respond_with_json(
pairing_request.stream,
json!({
"pairing_code": "pairing-code",
"manual_pairing_code": "ABCD-EFGH",
"server_id": "srv_e_test",
"environment_id": "env_test",
"expires_at": "3026-05-22T12:34:56Z",
}),
)
.await;
});
let remote_handle = remote_control_handle_with_current_enrollment(
&remote_control_url,
remote_control_auth_manager(),
);
remote_handle
.current_enrollment
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.as_mut()
.expect("current enrollment should exist")
.expires_at = Some(OffsetDateTime::now_utc() + time::Duration::seconds(29));
let response = remote_handle
.start_pairing(RemoteControlPairingStartParams { manual_code: true })
.await
.expect("pairing should use the current server before websocket connect");
server_task.await.expect("server task should finish");
assert_eq!(
response,
RemoteControlPairingStartResponse {
pairing_code: "pairing-code".to_string(),
manual_pairing_code: Some("ABCD-EFGH".to_string()),
environment_id: "env_test".to_string(),
expires_at: 33_336_362_096,
}
);
}
#[tokio::test]
async fn remote_control_handle_refreshes_after_pairing_auth_failure() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let remote_control_url = remote_control_url_for_listener(&listener);
let server_task = tokio::spawn(async move {
let stale_pairing_request = accept_http_request(&listener).await;
assert_eq!(
stale_pairing_request.request_line,
"POST /backend-api/wham/remote/control/server/pair HTTP/1.1"
);
assert_eq!(
stale_pairing_request.headers.get("authorization"),
Some(&format!("Bearer {TEST_REMOTE_CONTROL_SERVER_TOKEN}"))
);
respond_with_status(stale_pairing_request.stream, "401 Unauthorized", "").await;
let refresh_request = accept_http_request(&listener).await;
assert_eq!(
refresh_request.request_line,
"POST /backend-api/wham/remote/control/server/refresh HTTP/1.1"
);
assert_eq!(
refresh_request.headers.get("authorization"),
Some(&"Bearer Access Token".to_string())
);
respond_with_json(
refresh_request.stream,
remote_control_server_token_response(
"srv_e_test",
"env_test",
TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN,
),
)
.await;
let refreshed_pairing_request = accept_http_request(&listener).await;
assert_eq!(
refreshed_pairing_request.request_line,
"POST /backend-api/wham/remote/control/server/pair HTTP/1.1"
);
assert_eq!(
refreshed_pairing_request.headers.get("authorization"),
Some(&format!(
"Bearer {TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN}"
))
);
respond_with_json(
refreshed_pairing_request.stream,
json!({
"pairing_code": "pairing-code",
"manual_pairing_code": "ABCD-EFGH",
"server_id": "srv_e_test",
"environment_id": "env_test",
"expires_at": "3026-05-22T12:34:56Z",
}),
)
.await;
});
let remote_handle = remote_control_handle_with_current_enrollment(
&remote_control_url,
remote_control_auth_manager(),
);
let response = remote_handle
.start_pairing(RemoteControlPairingStartParams::default())
.await
.expect("pairing should refresh after server token auth failure");
server_task.await.expect("server task should finish");
assert_eq!(
response,
RemoteControlPairingStartResponse {
pairing_code: "pairing-code".to_string(),
manual_pairing_code: Some("ABCD-EFGH".to_string()),
environment_id: "env_test".to_string(),
expires_at: 33_336_362_096,
}
);
}
#[tokio::test]
async fn remote_control_handle_recovers_auth_before_refreshing_pairing() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let remote_control_url = remote_control_url_for_listener(&listener);
let server_task = tokio::spawn(async move {
let stale_refresh_request = accept_http_request(&listener).await;
assert_eq!(
stale_refresh_request.request_line,
"POST /backend-api/wham/remote/control/server/refresh HTTP/1.1"
);
assert_eq!(
stale_refresh_request.headers.get("authorization"),
Some(&"Bearer stale-token".to_string())
);
respond_with_status(stale_refresh_request.stream, "401 Unauthorized", "").await;
let recovered_refresh_request = accept_http_request(&listener).await;
assert_eq!(
recovered_refresh_request.request_line,
"POST /backend-api/wham/remote/control/server/refresh HTTP/1.1"
);
assert_eq!(
recovered_refresh_request.headers.get("authorization"),
Some(&"Bearer fresh-token".to_string())
);
respond_with_json(
recovered_refresh_request.stream,
remote_control_server_token_response(
"srv_e_test",
"env_test",
TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN,
),
)
.await;
let pairing_request = accept_http_request(&listener).await;
assert_eq!(
pairing_request.request_line,
"POST /backend-api/wham/remote/control/server/pair HTTP/1.1"
);
assert_eq!(
pairing_request.headers.get("authorization"),
Some(&format!(
"Bearer {TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN}"
))
);
respond_with_json(
pairing_request.stream,
json!({
"pairing_code": "pairing-code",
"manual_pairing_code": "ABCD-EFGH",
"server_id": "srv_e_test",
"environment_id": "env_test",
"expires_at": "3026-05-22T12:34:56Z",
}),
)
.await;
});
let codex_home = TempDir::new().expect("temp dir should create");
let mut stale_auth = remote_control_auth_dot_json(Some("account_id"));
stale_auth
.tokens
.as_mut()
.expect("stale auth should include tokens")
.access_token = "stale-token".to_string();
save_auth(
codex_home.path(),
&stale_auth,
AuthCredentialsStoreMode::File,
)
.expect("stale auth should save");
let auth_manager = AuthManager::shared(
codex_home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*chatgpt_base_url*/ None,
)
.await;
let mut fresh_auth = remote_control_auth_dot_json(Some("account_id"));
fresh_auth
.tokens
.as_mut()
.expect("fresh auth should include tokens")
.access_token = "fresh-token".to_string();
save_auth(
codex_home.path(),
&fresh_auth,
AuthCredentialsStoreMode::File,
)
.expect("fresh auth should save");
let remote_handle =
remote_control_handle_with_current_enrollment(&remote_control_url, auth_manager);
remote_handle
.current_enrollment
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.as_mut()
.expect("current enrollment should exist")
.expires_at = Some(OffsetDateTime::now_utc() + time::Duration::seconds(29));
let response = remote_handle
.start_pairing(RemoteControlPairingStartParams::default())
.await
.expect("pairing should refresh after auth recovery");
server_task.await.expect("server task should finish");
assert_eq!(
response,
RemoteControlPairingStartResponse {
pairing_code: "pairing-code".to_string(),
manual_pairing_code: Some("ABCD-EFGH".to_string()),
environment_id: "env_test".to_string(),
expires_at: 33_336_362_096,
}
);
}
#[tokio::test]
async fn start_remote_control_pairing_preserves_backend_error_context() {
let (err, expected_pair_url) =
pairing_error("503 Service Unavailable", "pairing unavailable").await;
assert_eq!(
err,
format!(
"remote control pairing failed at `{expected_pair_url}`: HTTP 503 Service Unavailable, request-id: request-123, cf-ray: ray-123, body: pairing unavailable"
)
);
}
#[tokio::test]
async fn start_remote_control_pairing_preserves_decode_error_context() {
let (err, expected_pair_url) = pairing_error("200 OK", "{").await;
assert!(err.contains(&format!(
"failed to parse remote control pairing response from `{expected_pair_url}`: HTTP 200 OK"
)));
assert!(err.contains("request-id: request-123"));
assert!(err.contains("cf-ray: ray-123"));
assert!(err.contains("body: {"));
assert!(err.contains("decode error:"));
}
#[tokio::test]
async fn start_remote_control_pairing_rejects_mismatched_backend_enrollment() {
assert_eq!(
pairing_response_error(json!({
"pairing_code": "pairing-code",
"manual_pairing_code": "ABCD-EFGH",
"server_id": "other-server-id",
"environment_id": "other-environment-id",
"expires_at": "3026-05-22T12:34:56Z",
}))
.await,
"remote control pairing returned mismatched enrollment: expected server_id=server-id, environment_id=environment-id; got server_id=other-server-id, environment_id=other-environment-id"
);
}
#[tokio::test]
async fn start_remote_control_pairing_preserves_expiry_parse_error_context() {
let err = pairing_response_error(json!({
"pairing_code": "pairing-code",
"manual_pairing_code": "ABCD-EFGH",
"server_id": "server-id",
"environment_id": "environment-id",
"expires_at": "not-a-timestamp",
}))
.await;
assert!(err.contains("failed to parse remote control pairing response"));
assert!(err.contains("HTTP 200 OK"));
assert!(err.contains("request-id: <none>"));
assert!(err.contains("cf-ray: <none>"));
assert!(err.contains("\"expires_at\":\"not-a-timestamp\""));
assert!(err.contains("expires_at parse error:"));
}
#[tokio::test]
async fn remote_control_handle_disable_clears_current_enrollment() {
let remote_handle = remote_control_handle_with_current_enrollment(
TEST_REMOTE_CONTROL_URL,
remote_control_auth_manager(),
);
remote_handle.disable();
remote_handle.enable().expect("enable should succeed");
assert_eq!(
remote_handle
.start_pairing(RemoteControlPairingStartParams::default())
.await
.expect_err("re-enabled remote control should wait for a current server")
.to_string(),
"remote control pairing is unavailable until enrollment completes"
);
}
#[tokio::test]
async fn remote_control_handle_discards_pairing_response_after_auth_change() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let remote_control_url = remote_control_url_for_listener(&listener);
let codex_home = TempDir::new().expect("temp dir should create");
save_auth(
codex_home.path(),
&remote_control_auth_dot_json(Some("account_id")),
AuthCredentialsStoreMode::File,
)
.expect("initial auth should save");
let auth_manager = AuthManager::shared(
codex_home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*chatgpt_base_url*/ None,
)
.await;
let remote_handle =
remote_control_handle_with_current_enrollment(&remote_control_url, auth_manager.clone());
let pairing_task = tokio::spawn({
let remote_handle = remote_handle.clone();
async move {
remote_handle
.start_pairing(RemoteControlPairingStartParams::default())
.await
}
});
let pairing_request = accept_http_request(&listener).await;
save_auth(
codex_home.path(),
&remote_control_auth_dot_json(Some("next_account_id")),
AuthCredentialsStoreMode::File,
)
.expect("next auth should save");
auth_manager.reload().await;
respond_with_json(
pairing_request.stream,
json!({
"pairing_code": "stale-pairing-code",
"manual_pairing_code": "ABCD-EFGH",
"server_id": "srv_e_test",
"environment_id": "env_test",
"expires_at": "3026-05-22T12:34:56Z",
}),
)
.await;
assert_eq!(
pairing_task
.await
.expect("pairing task should join")
.expect_err("stale pairing response should be discarded")
.to_string(),
"remote control pairing is unavailable until enrollment completes"
);
}
@@ -1,7 +1,23 @@
use super::CurrentRemoteControlEnrollment;
use super::clear_current_enrollment;
use super::protocol::ClientEnvelope;
use super::protocol::ClientEvent;
use super::protocol::ClientId;
use super::protocol::RemoteControlTarget;
use super::protocol::ServerEnvelope;
use super::protocol::StreamId;
use super::publish_current_enrollment;
use super::remote_control_status_with_connection_status;
use super::segment::ClientSegmentObservation;
use super::segment::ClientSegmentReassembler;
use super::segment::REMOTE_CONTROL_SEGMENT_MAX_BYTES;
use super::segment::split_server_envelope_for_transport;
use crate::transport::TransportEvent;
use crate::transport::remote_control::auth::RemoteControlConnectionAuth;
use crate::transport::remote_control::auth::load_remote_control_auth;
use crate::transport::remote_control::auth::recover_remote_control_auth;
use crate::transport::remote_control::client_tracker::ClientTracker;
use crate::transport::remote_control::client_tracker::REMOTE_CONTROL_IDLE_SWEEP_INTERVAL;
use crate::transport::remote_control::enroll::RemoteControlConnectionAuth;
use crate::transport::remote_control::enroll::RemoteControlEnrollment;
use crate::transport::remote_control::enroll::enroll_remote_control_server;
use crate::transport::remote_control::enroll::format_headers;
@@ -9,18 +25,6 @@ use crate::transport::remote_control::enroll::load_persisted_remote_control_enro
use crate::transport::remote_control::enroll::preview_remote_control_response_body;
use crate::transport::remote_control::enroll::refresh_remote_control_server;
use crate::transport::remote_control::enroll::update_persisted_remote_control_enrollment;
use super::protocol::ClientEnvelope;
use super::protocol::ClientEvent;
use super::protocol::ClientId;
use super::protocol::RemoteControlTarget;
use super::protocol::ServerEnvelope;
use super::protocol::StreamId;
use super::remote_control_status_with_connection_status;
use super::segment::ClientSegmentObservation;
use super::segment::ClientSegmentReassembler;
use super::segment::REMOTE_CONTROL_SEGMENT_MAX_BYTES;
use super::segment::split_server_envelope_for_transport;
use axum::http::HeaderValue;
use base64::Engine;
use codex_app_server_protocol::RemoteControlConnectionStatus;
@@ -251,6 +255,7 @@ pub(crate) struct RemoteControlWebsocket {
enrollment: Option<RemoteControlEnrollment>,
auth_recovery: UnauthorizedRecovery,
auth_change_rx: watch::Receiver<u64>,
current_enrollment: CurrentRemoteControlEnrollment,
client_tracker: Arc<Mutex<ClientTracker>>,
state: Arc<Mutex<WebsocketState>>,
server_event_rx: Arc<Mutex<mpsc::Receiver<super::QueuedServerEnvelope>>>,
@@ -288,6 +293,7 @@ enum ConnectionEndReason {
pub(super) struct RemoteControlChannels {
pub(super) transport_event_tx: mpsc::Sender<TransportEvent>,
pub(super) status_publisher: RemoteControlStatusPublisher,
pub(super) current_enrollment: CurrentRemoteControlEnrollment,
}
#[derive(Clone)]
@@ -404,6 +410,7 @@ impl RemoteControlWebsocket {
enrollment: None,
auth_recovery,
auth_change_rx,
current_enrollment: channels.current_enrollment,
client_tracker: Arc::new(Mutex::new(client_tracker)),
state: Arc::new(Mutex::new(WebsocketState {
outbound_buffer,
@@ -611,6 +618,7 @@ impl RemoteControlWebsocket {
&mut self.enrollment,
connect_options,
&self.status_publisher,
&self.current_enrollment,
) => connect_result,
};
@@ -1166,51 +1174,6 @@ fn build_remote_control_websocket_request(
Ok(request)
}
pub(crate) async fn load_remote_control_auth(
auth_manager: &Arc<AuthManager>,
) -> io::Result<RemoteControlConnectionAuth> {
let mut reloaded = false;
let auth = loop {
let Some(auth) = auth_manager.auth().await else {
if reloaded {
return Err(io::Error::new(
ErrorKind::PermissionDenied,
"remote control requires ChatGPT authentication",
));
}
auth_manager.reload().await;
reloaded = true;
continue;
};
if !auth.uses_codex_backend() {
break auth;
}
if auth.get_account_id().is_none() && !reloaded {
auth_manager.reload().await;
reloaded = true;
continue;
}
break auth;
};
if !auth.uses_codex_backend() {
return Err(io::Error::new(
ErrorKind::PermissionDenied,
"remote control requires ChatGPT authentication; API key auth is not supported",
));
}
Ok(RemoteControlConnectionAuth {
auth_provider: codex_model_provider::auth_provider_from_auth(&auth),
account_id: auth.get_account_id().ok_or_else(|| {
io::Error::new(
ErrorKind::WouldBlock,
"remote control enrollment is waiting for a ChatGPT account id",
)
})?,
})
}
fn next_reconnect_delay(reconnect_attempt: &mut u64) -> (std::time::Duration, bool) {
let reconnect_delay = backoff(*reconnect_attempt).min(REMOTE_CONTROL_RECONNECT_BACKOFF_CAP);
let reconnect_backoff_reset = reconnect_delay == REMOTE_CONTROL_RECONNECT_BACKOFF_CAP;
@@ -1229,6 +1192,7 @@ pub(super) async fn connect_remote_control_websocket(
enrollment: &mut Option<RemoteControlEnrollment>,
connect_options: RemoteControlConnectOptions<'_>,
status_publisher: &RemoteControlStatusPublisher,
current_enrollment: &CurrentRemoteControlEnrollment,
) -> io::Result<(
WebSocketStream<MaybeTlsStream<TcpStream>>,
tungstenite::http::Response<()>,
@@ -1237,6 +1201,7 @@ pub(super) async fn connect_remote_control_websocket(
let Some(state_db) = state_db else {
*enrollment = None;
clear_current_enrollment(current_enrollment);
return Err(io::Error::new(
ErrorKind::NotFound,
"remote control requires sqlite state db",
@@ -1249,6 +1214,7 @@ pub(super) async fn connect_remote_control_websocket(
if err.kind() == ErrorKind::PermissionDenied {
*enrollment = None;
status_publisher.publish_environment_id(/*environment_id*/ None);
clear_current_enrollment(current_enrollment);
}
return Err(err);
}
@@ -1265,6 +1231,10 @@ pub(super) async fn connect_remote_control_websocket(
);
*enrollment = None;
status_publisher.publish_environment_id(/*environment_id*/ None);
clear_current_enrollment(current_enrollment);
}
if let Some(enrollment) = enrollment.as_mut() {
enrollment.remote_control_target = remote_control_target.clone();
}
if let Some(enrollment) = enrollment.as_ref() {
@@ -1321,13 +1291,8 @@ pub(super) async fn connect_remote_control_websocket(
let enrollment_ref = enrollment.as_mut().ok_or_else(|| {
io::Error::other("missing remote control enrollment before server refresh")
})?;
match refresh_remote_control_server(
remote_control_target,
&auth,
connect_options.installation_id,
enrollment_ref,
)
.await
match refresh_remote_control_server(&auth, connect_options.installation_id, enrollment_ref)
.await
{
Ok(()) => {}
Err(err) if err.kind() == ErrorKind::NotFound => {
@@ -1342,6 +1307,7 @@ pub(super) async fn connect_remote_control_websocket(
connect_options.app_server_client_name,
enrollment,
status_publisher,
current_enrollment,
)
.await;
enroll_remote_control_server_if_missing(
@@ -1374,6 +1340,7 @@ pub(super) async fn connect_remote_control_websocket(
let enrollment_ref = enrollment.as_ref().ok_or_else(|| {
io::Error::other("missing remote control enrollment after enrollment step")
})?;
publish_current_enrollment(current_enrollment, enrollment_ref);
let request = build_remote_control_websocket_request(
&remote_control_target.websocket_url,
enrollment_ref,
@@ -1415,6 +1382,7 @@ pub(super) async fn connect_remote_control_websocket(
connect_options.app_server_client_name,
enrollment,
status_publisher,
current_enrollment,
)
.await;
}
@@ -1429,6 +1397,7 @@ pub(super) async fn connect_remote_control_websocket(
)
})?
.clear_server_token();
clear_current_enrollment(current_enrollment);
return Err(io::Error::other(format!(
"remote control websocket auth failed with HTTP {}; refreshing server token before reconnect",
response.status()
@@ -1453,6 +1422,7 @@ async fn clear_remote_control_enrollment(
app_server_client_name: Option<&str>,
enrollment: &mut Option<RemoteControlEnrollment>,
status_publisher: &RemoteControlStatusPublisher,
current_enrollment: &CurrentRemoteControlEnrollment,
) {
if let Err(clear_err) = update_persisted_remote_control_enrollment(
Some(state_db),
@@ -1467,6 +1437,7 @@ async fn clear_remote_control_enrollment(
}
*enrollment = None;
status_publisher.publish_environment_id(/*environment_id*/ None);
clear_current_enrollment(current_enrollment);
}
async fn enroll_remote_control_server_if_missing(
@@ -1534,52 +1505,6 @@ async fn enroll_remote_control_server_if_missing(
Ok(())
}
async fn recover_remote_control_auth(
auth_recovery: &mut UnauthorizedRecovery,
auth_change_rx: &mut watch::Receiver<u64>,
) -> bool {
if !auth_recovery.has_next() {
return false;
}
let mode = auth_recovery.mode_name();
let step = auth_recovery.step_name();
let auth_change_revision_before_recovery = *auth_change_rx.borrow();
match auth_recovery.next().await {
Ok(step_result) => {
if step_result.auth_state_changed() == Some(true) {
mark_recovery_auth_change_seen(
auth_change_rx,
auth_change_revision_before_recovery,
);
}
info!(
"remote control websocket auth recovery succeeded: mode={mode}, step={step}, auth_state_changed={:?}",
step_result.auth_state_changed()
);
true
}
Err(err) => {
warn!("remote control websocket auth recovery failed: mode={mode}, step={step}: {err}");
false
}
}
}
fn mark_recovery_auth_change_seen(
auth_change_rx: &mut watch::Receiver<u64>,
auth_change_revision_before_recovery: u64,
) {
let auth_change_revision_after_recovery = *auth_change_rx.borrow();
if auth_change_revision_after_recovery == auth_change_revision_before_recovery.wrapping_add(1) {
// Recovery updated the same watch that wakes the outer reconnect
// loop. Mark only that single revision seen; if more revisions
// arrived while recovery was in flight, leave them pending so the
// reconnect loop still reacts to the later external auth change.
auth_change_rx.borrow_and_update();
}
}
fn format_remote_control_websocket_connect_error(
websocket_url: &str,
err: &tungstenite::Error,
@@ -1606,6 +1531,7 @@ mod tests {
use super::*;
use crate::outgoing_message::OutgoingMessage;
use crate::transport::remote_control::ServerEvent;
use crate::transport::remote_control::auth::mark_recovery_auth_change_seen;
use crate::transport::remote_control::protocol::StreamId;
use crate::transport::remote_control::protocol::normalize_remote_control_url;
use chrono::Utc;
@@ -1647,6 +1573,8 @@ mod tests {
fn remote_control_enrollment(remote_control_token: Option<&str>) -> RemoteControlEnrollment {
RemoteControlEnrollment {
remote_control_target: normalize_remote_control_url("http://localhost/backend-api/")
.expect("target should normalize"),
account_id: "account_id".to_string(),
environment_id: "env_test".to_string(),
server_id: "srv_e_test".to_string(),
@@ -1657,6 +1585,10 @@ mod tests {
}
}
fn test_current_enrollment() -> CurrentRemoteControlEnrollment {
Arc::new(std::sync::Mutex::new(None))
}
#[test]
fn next_reconnect_delay_resets_after_cap() {
let mut reconnect_attempt = 9;
@@ -1810,6 +1742,7 @@ mod tests {
let mut enrollment = Some(remote_control_enrollment(Some(
TEST_REMOTE_CONTROL_SERVER_TOKEN,
)));
let current_enrollment = test_current_enrollment();
let (status_publisher, status_rx) = remote_control_status_channel();
let err = match connect_remote_control_websocket(
@@ -1828,6 +1761,7 @@ mod tests {
app_server_client_name: None,
},
&status_publisher,
&current_enrollment,
)
.await
{
@@ -1837,6 +1771,12 @@ mod tests {
server_task.await.expect("server task should succeed");
assert_eq!(err.to_string(), expected_error);
assert!(
current_enrollment
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_some()
);
assert_eq!(
status_rx.borrow().clone(),
RemoteControlStatusChangedNotification {
@@ -1864,6 +1804,7 @@ mod tests {
let mut enrollment = Some(remote_control_enrollment(Some(
TEST_REMOTE_CONTROL_SERVER_TOKEN,
)));
let current_enrollment = test_current_enrollment();
let (status_publisher, status_rx) = remote_control_status_channel();
let server_task = tokio::spawn(async move {
@@ -1891,6 +1832,7 @@ mod tests {
app_server_client_name: None,
},
&status_publisher,
&current_enrollment,
)
.await
.expect_err("unauthorized response should fail the websocket connect");
@@ -1909,11 +1851,14 @@ mod tests {
err.to_string(),
"remote control websocket auth failed with HTTP 401 Unauthorized; refreshing server token before reconnect"
);
assert_eq!(
enrollment,
Some(remote_control_enrollment(
/*remote_control_token*/ None
))
let mut expected_enrollment = remote_control_enrollment(/*remote_control_token*/ None);
expected_enrollment.remote_control_target = remote_control_target;
assert_eq!(enrollment, Some(expected_enrollment));
assert!(
current_enrollment
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_none()
);
}
@@ -1976,6 +1921,7 @@ mod tests {
app_server_client_name: None,
},
&status_publisher,
&test_current_enrollment(),
)
.await
.expect_err("unauthorized enrollment should fail the websocket connect");
@@ -2070,6 +2016,7 @@ mod tests {
app_server_client_name: None,
},
&status_publisher,
&test_current_enrollment(),
)
.await
.expect_err("unauthorized refresh should fail the websocket connect");
@@ -2135,6 +2082,7 @@ mod tests {
app_server_client_name: None,
},
&status_publisher,
&test_current_enrollment(),
)
.await
.expect_err("missing sqlite state db should fail remote control");
@@ -2185,6 +2133,7 @@ mod tests {
app_server_client_name: None,
},
&status_publisher,
&test_current_enrollment(),
)
.await
.expect_err("missing auth should fail remote control");
@@ -2236,6 +2185,7 @@ mod tests {
RemoteControlChannels {
transport_event_tx,
status_publisher,
current_enrollment: test_current_enrollment(),
},
shutdown_token,
enabled_rx,
+22 -3
View File
@@ -210,6 +210,9 @@ Example with notification opt-out:
- `remoteControl/enable` — experimental; enable remote control for the current app-server process and return the current remote-control status snapshot. The caller is responsible for persisting the desired setting outside app-server.
- `remoteControl/disable` — experimental; disable remote control for the current app-server process and return the current remote-control status snapshot. This does not revoke already enrolled controller devices.
- `remoteControl/status/read` — experimental; read the current remote-control status snapshot. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `serverName` is the local machine name used by this app-server process; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled.
- `remoteControl/pairing/start` — experimental; start a short-lived remote-control pairing artifact for the current app-server process. Pass `manualCode: true` to also request a manual pairing code. Returns `pairingCode`, `manualPairingCode`, `environmentId`, and Unix-seconds `expiresAt`; app-server intentionally does not expose the backend `serverId`.
- `remoteControl/client/list` — experimental; list controller devices granted access to an environment. Pass `environmentId` and optional `cursor`, `limit`, and `order`; returns picker-oriented client metadata plus `nextCursor`. This signed-in account-management operation works while the local relay is disabled or unenrolled.
- `remoteControl/client/revoke` — experimental; revoke one controller device's grant for an environment. Pass `environmentId` and `clientId`; returns an empty object. This signed-in account-management operation works while the local relay is disabled or unenrolled.
- `remoteControl/status/changed` — notification emitted when the remote-control status or client-visible environment id changes. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `serverName` is the local machine name used by this app-server process; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled. Newly initialized app-server clients always receive the current status snapshot.
- `skills/config/write` — write user-level skill config by name or absolute path.
- `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, install MCPs if any, and return the effective plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**).
@@ -1378,7 +1381,7 @@ the client can offer session-scoped and/or persistent approval choices.
### Permission requests
The built-in `request_permissions` tool sends an `item/permissions/requestApproval` JSON-RPC request to the client with the requested permission profile. This v2 payload mirrors the command-execution `additionalPermissions` shape: it can request network access and additional filesystem access. The `cwd` field identifies the directory used to resolve project-root permissions and relative deny globs.
The built-in `request_permissions` tool sends an `item/permissions/requestApproval` JSON-RPC request to the client with the requested permission profile. This v2 payload mirrors the command-execution `additionalPermissions` shape: it can request network access and additional filesystem access. The `environmentId` and `cwd` fields identify the environment and directory used to resolve project-root permissions and relative deny globs.
```json
{
@@ -1388,6 +1391,7 @@ The built-in `request_permissions` tool sends an `item/permissions/requestApprov
"threadId": "thr_123",
"turnId": "turn_123",
"itemId": "call_123",
"environmentId": "local",
"cwd": "/Users/me/project",
"reason": "Select a workspace root",
"permissions": {
@@ -1715,6 +1719,20 @@ The server also emits `app/list/updated` notifications whenever either source (a
}
```
Connected apps may override the thread's approval reviewer in `config.toml`.
When omitted, the app inherits the top-level `approvals_reviewer` value:
```toml
approvals_reviewer = "auto_review"
[apps.demo-app]
approvals_reviewer = "user"
```
Setting the app value to `"user"` routes its approval prompts to the user
instead of Guardian; setting it to `"auto_review"` opts that app into Guardian
review when allowed by configuration requirements.
Invoke an app by inserting `$<app-slug>` in the text input. The slug is derived from the app name and lowercased with non-alphanumeric characters replaced by `-` (for example, "Demo App" becomes `$demo-app`). Add a `mention` input item (recommended) so the server uses the exact `app://<connector-id>` path rather than guessing by name. Plugins use the same `mention` item shape, but with `plugin://<plugin-name>@<marketplace-name>` paths from `plugin/installed` or `plugin/list`.
Example:
@@ -1759,8 +1777,8 @@ Codex supports these authentication modes. The current mode is surfaced in `acco
- `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`, or `null`) and includes the current ChatGPT `planType` when available.
- `account/rateLimits/read` — fetch ChatGPT rate limits; updates arrive via `account/rateLimits/updated` (notify).
- `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change.
- `account/rateLimits/read` — fetch ChatGPT rate limits and an optional effective monthly credit limit; updates arrive via `account/rateLimits/updated` (notify).
- `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change. This is a sparse rolling update; merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot.
- `account/sendAddCreditsNudgeEmail` — ask ChatGPT to email the workspace owner about depleted credits or a reached usage limit.
- `mcpServer/oauthLogin/completed` (notify) — emitted after a `mcpServer/oauth/login` flow finishes for a server; payload includes `{ name, success, error? }`.
- `mcpServer/startupStatus/updated` (notify) — emitted when a configured MCP server's startup status changes for a loaded thread; payload includes `{ name, status, error }` where `status` is `starting`, `ready`, `failed`, or `cancelled`.
@@ -1864,6 +1882,7 @@ Field notes:
- `windowDurationMins` is the quota window length.
- `resetsAt` is a Unix timestamp (seconds) for the next reset.
- `rateLimitReachedType` identifies the backend-classified limit state when one has been reached.
- `individualLimit` describes the effective monthly credit limit when available. In an `account/rateLimits/read` response, `null` means no monthly limit is available. In a sparse `account/rateLimits/updated` notification, nullable account metadata may be unavailable and does not clear a previously observed value.
### 8) Notify a workspace owner about a limit
@@ -761,6 +761,7 @@ pub(crate) async fn apply_bespoke_event_handling(
thread_id: conversation_id.to_string(),
turn_id: request.turn_id.clone(),
item_id: request.call_id.clone(),
environment_id: request.environment_id.clone(),
started_at_ms: request.started_at_ms,
cwd: request_cwd.clone(),
reason: request.reason,
@@ -3546,6 +3547,7 @@ mod tests {
unlimited: false,
balance: Some("5".to_string()),
}),
individual_limit: None,
plan_type: None,
rate_limit_reached_type: None,
};
+16 -16
View File
@@ -1,6 +1,6 @@
use codex_arg0::Arg0DispatchPaths;
use codex_cloud_config::cloud_requirements_loader;
use codex_config::CloudRequirementsLoader;
use codex_cloud_config::cloud_config_bundle_loader;
use codex_config::CloudConfigBundleLoader;
use codex_config::ConfigLayerStack;
use codex_config::LoaderOverrides;
use codex_config::ThreadConfigLoader;
@@ -31,7 +31,7 @@ pub(crate) struct ConfigManager {
runtime_feature_enablement: Arc<RwLock<BTreeMap<String, bool>>>,
loader_overrides: LoaderOverrides,
strict_config: bool,
cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>,
cloud_config_bundle: Arc<RwLock<CloudConfigBundleLoader>>,
arg0_paths: Arg0DispatchPaths,
thread_config_loader: Arc<RwLock<Arc<dyn ThreadConfigLoader>>>,
}
@@ -42,7 +42,7 @@ impl ConfigManager {
cli_overrides: Vec<(String, TomlValue)>,
loader_overrides: LoaderOverrides,
strict_config: bool,
cloud_requirements: CloudRequirementsLoader,
cloud_config_bundle: CloudConfigBundleLoader,
arg0_paths: Arg0DispatchPaths,
thread_config_loader: Arc<dyn ThreadConfigLoader>,
) -> Self {
@@ -52,7 +52,7 @@ impl ConfigManager {
runtime_feature_enablement: Arc::new(RwLock::new(BTreeMap::new())),
loader_overrides,
strict_config,
cloud_requirements: Arc::new(RwLock::new(cloud_requirements)),
cloud_config_bundle: Arc::new(RwLock::new(cloud_config_bundle)),
arg0_paths,
thread_config_loader: Arc::new(RwLock::new(thread_config_loader)),
}
@@ -73,8 +73,8 @@ impl ConfigManager {
.unwrap_or_default()
}
pub(crate) fn current_cloud_requirements(&self) -> CloudRequirementsLoader {
self.cloud_requirements
pub(crate) fn current_cloud_config_bundle(&self) -> CloudConfigBundleLoader {
self.cloud_config_bundle
.read()
.map(|guard| guard.clone())
.unwrap_or_default()
@@ -90,17 +90,17 @@ impl ConfigManager {
Ok(())
}
pub(crate) fn replace_cloud_requirements_loader(
pub(crate) fn replace_cloud_config_bundle_loader(
&self,
auth_manager: Arc<AuthManager>,
chatgpt_base_url: String,
) {
let loader =
cloud_requirements_loader(auth_manager, chatgpt_base_url, self.codex_home.clone());
if let Ok(mut guard) = self.cloud_requirements.write() {
cloud_config_bundle_loader(auth_manager, chatgpt_base_url, self.codex_home.clone());
if let Ok(mut guard) = self.cloud_config_bundle.write() {
*guard = loader;
} else {
warn!("failed to update cloud requirements loader");
warn!("failed to update cloud config bundle loader");
}
}
@@ -245,7 +245,7 @@ impl ConfigManager {
.strict_config(self.strict_config)
.harness_overrides(typesafe_overrides)
.fallback_cwd(fallback_cwd)
.cloud_requirements(self.current_cloud_requirements())
.cloud_config_bundle(self.current_cloud_config_bundle())
.thread_config_loader(self.current_thread_config_loader())
.build()
.await?;
@@ -274,8 +274,8 @@ impl ConfigManager {
codex_config::ConfigLoadOptions {
loader_overrides: self.loader_overrides.clone(),
strict_config: self.strict_config,
cloud_config_bundle: self.current_cloud_config_bundle(),
},
self.current_cloud_requirements(),
thread_config_loader.as_ref(),
)
.await
@@ -303,14 +303,14 @@ impl ConfigManager {
codex_home: PathBuf,
cli_overrides: Vec<(String, TomlValue)>,
loader_overrides: LoaderOverrides,
cloud_requirements: CloudRequirementsLoader,
cloud_config_bundle: CloudConfigBundleLoader,
) -> Self {
Self::new(
codex_home,
cli_overrides,
loader_overrides,
/*strict_config*/ false,
cloud_requirements,
cloud_config_bundle,
Arg0DispatchPaths::default(),
Arc::new(codex_config::NoopThreadConfigLoader),
)
@@ -322,7 +322,7 @@ impl ConfigManager {
codex_home,
Vec::new(),
LoaderOverrides::without_managed_config_for_tests(),
CloudRequirementsLoader::default(),
CloudConfigBundleLoader::default(),
)
}
}
@@ -4,12 +4,11 @@ use codex_app_server_protocol::AppConfig;
use codex_app_server_protocol::AppToolApproval;
use codex_app_server_protocol::AppsConfig;
use codex_app_server_protocol::AskForApproval;
use codex_config::CloudRequirementsLoader;
use codex_config::FeatureRequirementsToml;
use codex_config::CloudConfigBundleLoader;
use codex_config::LoaderOverrides;
use codex_config::test_support::CloudConfigBundleFixture;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
use tempfile::tempdir;
#[test]
@@ -284,6 +283,7 @@ async fn write_value_supports_nested_app_paths() -> Result<()> {
"app1".to_string(),
AppConfig {
enabled: false,
approvals_reviewer: None,
destructive_enabled: None,
open_world_enabled: None,
default_tools_approval_mode: Some(AppToolApproval::Prompt),
@@ -355,7 +355,7 @@ async fn read_includes_origins_and_layers() {
tmp.path().to_path_buf(),
vec![],
LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()),
CloudRequirementsLoader::default(),
CloudConfigBundleLoader::default(),
);
let response = service
@@ -434,7 +434,7 @@ writable_roots = ["~/code"]
tmp.path().to_path_buf(),
vec![],
loader_overrides,
CloudRequirementsLoader::default(),
CloudConfigBundleLoader::default(),
);
let response = service
@@ -474,7 +474,7 @@ async fn write_value_reports_override() {
tmp.path().to_path_buf(),
vec![],
LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()),
CloudRequirementsLoader::default(),
CloudConfigBundleLoader::default(),
);
let result = service
@@ -577,7 +577,7 @@ async fn write_value_defaults_to_selected_user_config_path() {
tmp.path().to_path_buf(),
vec![],
loader_overrides,
CloudRequirementsLoader::default(),
CloudConfigBundleLoader::default(),
);
service
.write_value(ConfigValueWriteParams {
@@ -617,7 +617,7 @@ async fn load_default_config_preserves_selected_user_config_path_after_load_erro
tmp.path().to_path_buf(),
vec![],
loader_overrides,
CloudRequirementsLoader::default(),
CloudConfigBundleLoader::default(),
);
service
@@ -647,7 +647,7 @@ async fn invalid_user_value_rejected_even_if_overridden_by_managed() {
tmp.path().to_path_buf(),
vec![],
LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()),
CloudRequirementsLoader::default(),
CloudConfigBundleLoader::default(),
);
let error = service
@@ -707,14 +707,12 @@ async fn write_value_rejects_feature_requirement_conflict() {
tmp.path().to_path_buf(),
vec![],
LoaderOverrides::without_managed_config_for_tests(),
CloudRequirementsLoader::new(async {
Ok(Some(ConfigRequirementsToml {
feature_requirements: Some(FeatureRequirementsToml {
entries: BTreeMap::from([("personality".to_string(), true)]),
}),
..Default::default()
}))
}),
CloudConfigBundleFixture::loader_with_enterprise_requirement(
r#"
[features]
personality = true
"#,
),
);
let error = service
@@ -764,7 +762,7 @@ async fn read_reports_managed_overrides_user_and_session_flags() {
tmp.path().to_path_buf(),
cli_overrides,
LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()),
CloudRequirementsLoader::default(),
CloudConfigBundleLoader::default(),
);
let response = service
@@ -820,7 +818,7 @@ async fn write_value_reports_managed_override() {
tmp.path().to_path_buf(),
vec![],
LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()),
CloudRequirementsLoader::default(),
CloudConfigBundleLoader::default(),
);
let result = service
+5 -5
View File
@@ -77,7 +77,7 @@ use codex_app_server_protocol::Result;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
use codex_arg0::Arg0DispatchPaths;
use codex_config::CloudRequirementsLoader;
use codex_config::CloudConfigBundleLoader;
use codex_config::LoaderOverrides;
use codex_config::ThreadConfigLoader;
use codex_core::config::Config;
@@ -124,8 +124,8 @@ pub struct InProcessStartArgs {
pub loader_overrides: LoaderOverrides,
/// Whether config API paths should reject unknown config fields.
pub strict_config: bool,
/// Preloaded cloud requirements provider.
pub cloud_requirements: CloudRequirementsLoader,
/// Preloaded cloud config bundle provider.
pub cloud_config_bundle: CloudConfigBundleLoader,
/// Loader used to fetch typed thread config sources before a thread starts.
pub thread_config_loader: Arc<dyn ThreadConfigLoader>,
/// Feedback sink used by app-server/core telemetry and logs.
@@ -415,7 +415,7 @@ async fn start_uninitialized(args: InProcessStartArgs) -> IoResult<InProcessClie
args.cli_overrides,
args.loader_overrides,
args.strict_config,
args.cloud_requirements,
args.cloud_config_bundle,
args.arg0_paths.clone(),
args.thread_config_loader,
);
@@ -772,7 +772,7 @@ mod tests {
cli_overrides: Vec::new(),
loader_overrides: LoaderOverrides::default(),
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
cloud_config_bundle: CloudConfigBundleLoader::default(),
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
log_db: None,
+6 -3
View File
@@ -473,11 +473,14 @@ pub async fn run_main_with_transport_options(
.replace_thread_config_loader(Arc::clone(&discovered_thread_config_loader));
let auth_manager =
AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await;
config_manager.replace_cloud_requirements_loader(auth_manager, config.chatgpt_base_url);
config_manager
.replace_cloud_config_bundle_loader(auth_manager, config.chatgpt_base_url);
}
Err(err) => {
warn!(error = %err, "Failed to preload config for cloud requirements");
// TODO(gt): Make cloud requirements preload failures blocking once we can fail-closed.
warn!(error = %err, "Failed to preload config for cloud config bundle");
// TODO: Decide whether bootstrap config preload failures should block startup.
// If this fails, we cannot install cloud/thread config loaders, so non-strict
// startup may continue without managed cloud config.
}
};
let mut config_warnings = Vec::new();
+2 -2
View File
@@ -103,7 +103,7 @@ mod tests {
use crate::extensions::thread_extensions;
use async_trait::async_trait;
use codex_arg0::Arg0DispatchPaths;
use codex_config::CloudRequirementsLoader;
use codex_config::CloudConfigBundleLoader;
use codex_config::LoaderOverrides;
use codex_config::ThreadConfigContext;
use codex_config::ThreadConfigLoadError;
@@ -213,7 +213,7 @@ mod tests {
Vec::new(),
LoaderOverrides::without_managed_config_for_tests(),
/*strict_config*/ false,
CloudRequirementsLoader::default(),
CloudConfigBundleLoader::default(),
Arg0DispatchPaths::default(),
loader.clone(),
);
@@ -917,6 +917,21 @@ impl MessageProcessor {
.remote_control_processor
.status_read()
.map(|response| Some(response.into())),
ClientRequest::RemoteControlPairingStart { params, .. } => self
.remote_control_processor
.pairing_start(params)
.await
.map(|response| Some(response.into())),
ClientRequest::RemoteControlClientsList { params, .. } => self
.remote_control_processor
.clients_list(params)
.await
.map(|response| Some(response.into())),
ClientRequest::RemoteControlClientsRevoke { params, .. } => self
.remote_control_processor
.clients_revoke(params)
.await
.map(|response| Some(response.into())),
ClientRequest::ConfigRequirementsRead { params: _, .. } => self
.config_processor
.config_requirements_read()
@@ -23,7 +23,7 @@ use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::UserInput;
use codex_arg0::Arg0DispatchPaths;
use codex_config::CloudRequirementsLoader;
use codex_config::CloudConfigBundleLoader;
use codex_config::LoaderOverrides;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
@@ -240,7 +240,7 @@ async fn build_test_processor(
Vec::new(),
LoaderOverrides::default(),
/*strict_config*/ false,
CloudRequirementsLoader::default(),
CloudConfigBundleLoader::default(),
Arg0DispatchPaths::default(),
Arc::new(codex_config::NoopThreadConfigLoader),
);
@@ -798,6 +798,7 @@ mod tests {
}),
secondary: None,
credits: None,
individual_limit: None,
plan_type: Some(PlanType::Plus),
rate_limit_reached_type: None,
},
@@ -818,6 +819,7 @@ mod tests {
},
"secondary": null,
"credits": null,
"individualLimit": null,
"planType": "plus",
"rateLimitReachedType": null
}
@@ -266,8 +266,8 @@ use codex_backend_client::AddCreditsNudgeCreditType as BackendAddCreditsNudgeCre
use codex_backend_client::Client as BackendClient;
use codex_chatgpt::connectors;
use codex_chatgpt::workspace_settings;
use codex_config::CloudRequirementsLoadError;
use codex_config::CloudRequirementsLoadErrorCode;
use codex_config::CloudConfigBundleLoadError;
use codex_config::CloudConfigBundleLoadErrorCode;
use codex_config::ConfigLayerStack;
use codex_config::loader::project_trust_key;
use codex_config::types::McpServerTransportConfig;
@@ -586,7 +586,7 @@ impl AccountRequestProcessor {
)
.map_err(|err| internal_error(format!("failed to set external auth: {err}")))?;
self.auth_manager.reload().await;
self.config_manager.replace_cloud_requirements_loader(
self.config_manager.replace_cloud_config_bundle_loader(
self.auth_manager.clone(),
self.config.chatgpt_base_url.clone(),
);
@@ -645,7 +645,7 @@ impl AccountRequestProcessor {
let auth_manager = thread_manager.auth_manager();
auth_manager.reload().await;
config_manager
.replace_cloud_requirements_loader(auth_manager.clone(), chatgpt_base_url);
.replace_cloud_config_bundle_loader(auth_manager.clone(), chatgpt_base_url);
config_manager
.sync_default_client_residency_requirement()
.await;
@@ -1,11 +1,11 @@
use super::*;
fn cloud_requirements_load_error(err: &std::io::Error) -> Option<&CloudRequirementsLoadError> {
fn cloud_config_bundle_load_error(err: &std::io::Error) -> Option<&CloudConfigBundleLoadError> {
let mut current: Option<&(dyn std::error::Error + 'static)> = err
.get_ref()
.map(|source| source as &(dyn std::error::Error + 'static));
while let Some(source) = current {
if let Some(cloud_error) = source.downcast_ref::<CloudRequirementsLoadError>() {
if let Some(cloud_error) = source.downcast_ref::<CloudConfigBundleLoadError>() {
return Some(cloud_error);
}
current = source.source();
@@ -14,16 +14,16 @@ fn cloud_requirements_load_error(err: &std::io::Error) -> Option<&CloudRequireme
}
pub(super) fn config_load_error(err: &std::io::Error) -> JSONRPCErrorError {
let data = cloud_requirements_load_error(err).map(|cloud_error| {
let data = cloud_config_bundle_load_error(err).map(|cloud_error| {
let mut data = serde_json::json!({
"reason": "cloudRequirements",
"reason": "cloudConfigBundle",
"errorCode": format!("{:?}", cloud_error.code()),
"detail": cloud_error.to_string(),
});
if let Some(status_code) = cloud_error.status_code() {
data["statusCode"] = serde_json::json!(status_code);
}
if cloud_error.code() == CloudRequirementsLoadErrorCode::Auth {
if cloud_error.code() == CloudConfigBundleLoadErrorCode::Auth {
data["action"] = serde_json::json!("relogin");
}
data
@@ -662,6 +662,7 @@ impl PluginRequestProcessor {
&remote_plugin_service_config,
auth.as_ref(),
&remote_sources,
/*global_catalog_cache_path*/ Some(config.codex_home.as_path()),
)
.await
{
@@ -3,9 +3,16 @@ use crate::error_code::invalid_request;
use crate::transport::RemoteControlHandle;
use crate::transport::RemoteControlUnavailable;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::RemoteControlClientsListParams;
use codex_app_server_protocol::RemoteControlClientsListResponse;
use codex_app_server_protocol::RemoteControlClientsRevokeParams;
use codex_app_server_protocol::RemoteControlClientsRevokeResponse;
use codex_app_server_protocol::RemoteControlDisableResponse;
use codex_app_server_protocol::RemoteControlEnableResponse;
use codex_app_server_protocol::RemoteControlPairingStartParams;
use codex_app_server_protocol::RemoteControlPairingStartResponse;
use codex_app_server_protocol::RemoteControlStatusReadResponse;
use std::io;
#[derive(Clone)]
pub(crate) struct RemoteControlRequestProcessor {
@@ -42,6 +49,36 @@ impl RemoteControlRequestProcessor {
})
}
pub(crate) async fn pairing_start(
&self,
params: RemoteControlPairingStartParams,
) -> Result<RemoteControlPairingStartResponse, JSONRPCErrorError> {
self.handle()?
.start_pairing(params)
.await
.map_err(map_pairing_start_error)
}
pub(crate) async fn clients_list(
&self,
params: RemoteControlClientsListParams,
) -> Result<RemoteControlClientsListResponse, JSONRPCErrorError> {
self.handle()?
.list_clients(params)
.await
.map_err(map_client_management_error)
}
pub(crate) async fn clients_revoke(
&self,
params: RemoteControlClientsRevokeParams,
) -> Result<RemoteControlClientsRevokeResponse, JSONRPCErrorError> {
self.handle()?
.revoke_client(params)
.await
.map_err(map_client_management_error)
}
fn handle(&self) -> Result<&RemoteControlHandle, JSONRPCErrorError> {
self.remote_control_handle
.as_ref()
@@ -52,3 +89,24 @@ impl RemoteControlRequestProcessor {
fn map_unavailable(err: RemoteControlUnavailable) -> JSONRPCErrorError {
invalid_request(err.to_string())
}
fn map_pairing_start_error(err: io::Error) -> JSONRPCErrorError {
if err.kind() == io::ErrorKind::InvalidInput {
invalid_request(err.to_string())
} else {
internal_error(err.to_string())
}
}
fn map_client_management_error(err: io::Error) -> JSONRPCErrorError {
match err.kind() {
io::ErrorKind::InvalidInput
| io::ErrorKind::NotFound
| io::ErrorKind::PermissionDenied
| io::ErrorKind::WouldBlock => invalid_request(err.to_string()),
_ => internal_error(err.to_string()),
}
}
#[cfg(test)]
mod remote_control_processor_tests;
@@ -0,0 +1,79 @@
use super::*;
use crate::error_code::INTERNAL_ERROR_CODE;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn pairing_start_returns_internal_error_when_remote_control_is_unavailable() {
let err = RemoteControlRequestProcessor::new(/*remote_control_handle*/ None)
.pairing_start(RemoteControlPairingStartParams::default())
.await
.expect_err("missing remote control should fail pairing");
assert_eq!(
err,
JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
data: None,
message: "remote control is unavailable for this app-server".to_string(),
}
);
}
#[test]
fn pairing_start_maps_invalid_input_to_invalid_request() {
assert_eq!(
map_pairing_start_error(io::Error::new(
io::ErrorKind::InvalidInput,
"remote control pairing is unavailable",
)),
JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
data: None,
message: "remote control pairing is unavailable".to_string(),
}
);
}
#[test]
fn pairing_start_maps_backend_failures_to_internal_error() {
assert_eq!(
map_pairing_start_error(io::Error::other("remote control pairing failed")),
JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
data: None,
message: "remote control pairing failed".to_string(),
}
);
}
#[test]
fn client_management_maps_user_actionable_errors_to_invalid_request() {
for kind in [
io::ErrorKind::InvalidInput,
io::ErrorKind::NotFound,
io::ErrorKind::PermissionDenied,
io::ErrorKind::WouldBlock,
] {
assert_eq!(
map_client_management_error(io::Error::new(kind, "client management unavailable")),
JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
data: None,
message: "client management unavailable".to_string(),
}
);
}
}
#[test]
fn client_management_maps_backend_failures_to_internal_error() {
assert_eq!(
map_client_management_error(io::Error::other("client management failed")),
JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
data: None,
message: "client management failed".to_string(),
}
);
}
@@ -2766,7 +2766,7 @@ impl ThreadRequestProcessor {
.as_ref()
.or(source_thread.rollout_path.as_ref());
if let (Some(requested_path), Some(active_path)) = (params.path.as_ref(), active_path)
&& requested_path != active_path
&& !path_utils::paths_match_after_normalization(requested_path, active_path)
{
return Err(invalid_request(format!(
"cannot resume running thread {existing_thread_id} with stale path: requested `{}`, active `{}`",
@@ -54,7 +54,7 @@ mod thread_processor_behavior_tests {
use codex_app_server_protocol::ServerRequestPayload;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ToolRequestUserInputParams;
use codex_config::CloudRequirementsLoader;
use codex_config::CloudConfigBundleLoader;
use codex_config::LoaderOverrides;
use codex_config::SessionThreadConfig;
use codex_config::StaticThreadConfigLoader;
@@ -508,9 +508,9 @@ mod thread_processor_behavior_tests {
}
#[test]
fn config_load_error_marks_cloud_requirements_failures_for_relogin() {
let err = std::io::Error::other(CloudRequirementsLoadError::new(
CloudRequirementsLoadErrorCode::Auth,
fn config_load_error_marks_cloud_config_bundle_failures_for_relogin() {
let err = std::io::Error::other(CloudConfigBundleLoadError::new(
CloudConfigBundleLoadErrorCode::Auth,
Some(401),
"Your authentication session could not be refreshed automatically. Please log out and sign in again.",
));
@@ -520,7 +520,7 @@ mod thread_processor_behavior_tests {
assert_eq!(
error.data,
Some(json!({
"reason": "cloudRequirements",
"reason": "cloudConfigBundle",
"errorCode": "Auth",
"action": "relogin",
"statusCode": 401,
@@ -535,7 +535,7 @@ mod thread_processor_behavior_tests {
}
#[test]
fn config_load_error_leaves_non_cloud_requirements_failures_unmarked() {
fn config_load_error_leaves_non_cloud_config_bundle_failures_unmarked() {
let err = std::io::Error::other("required MCP servers failed to initialize");
let error = config_load_error(&err);
@@ -549,11 +549,11 @@ mod thread_processor_behavior_tests {
}
#[test]
fn config_load_error_marks_non_auth_cloud_requirements_failures_without_relogin() {
let err = std::io::Error::other(CloudRequirementsLoadError::new(
CloudRequirementsLoadErrorCode::RequestFailed,
fn config_load_error_marks_non_auth_cloud_config_bundle_failures_without_relogin() {
let err = std::io::Error::other(CloudConfigBundleLoadError::new(
CloudConfigBundleLoadErrorCode::RequestFailed,
/*status_code*/ None,
"Failed to load cloud requirements (workspace-managed policies).",
"Failed to load cloud config bundle (workspace-managed policies).",
));
let error = config_load_error(&err);
@@ -561,9 +561,29 @@ mod thread_processor_behavior_tests {
assert_eq!(
error.data,
Some(json!({
"reason": "cloudRequirements",
"reason": "cloudConfigBundle",
"errorCode": "RequestFailed",
"detail": "Failed to load cloud requirements (workspace-managed policies).",
"detail": "Failed to load cloud config bundle (workspace-managed policies).",
}))
);
}
#[test]
fn config_load_error_marks_invalid_cloud_config_bundle_failures_without_relogin() {
let err = std::io::Error::other(CloudConfigBundleLoadError::new(
CloudConfigBundleLoadErrorCode::InvalidBundle,
/*status_code*/ None,
"invalid cloud config bundle: invalid cloud config fragment Base policy (cfg_123)",
));
let error = config_load_error(&err);
assert_eq!(
error.data,
Some(json!({
"reason": "cloudConfigBundle",
"errorCode": "InvalidBundle",
"detail": "invalid cloud config bundle: invalid cloud config fragment Base policy (cfg_123)",
}))
);
}
@@ -595,7 +615,7 @@ mod thread_processor_behavior_tests {
Vec::new(),
LoaderOverrides::default(),
/*strict_config*/ false,
CloudRequirementsLoader::default(),
CloudConfigBundleLoader::default(),
Arg0DispatchPaths::default(),
Arc::new(StaticThreadConfigLoader::new(vec![
ThreadConfigSource::Session(SessionThreadConfig {
@@ -54,6 +54,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo {
supports_search_tool: false,
auto_review_model_override: None,
tool_mode: None,
multi_agent_version: None,
}
}
@@ -194,6 +194,7 @@ fn create_fake_rollout_with_source_and_parent_thread_id(
base_instructions: None,
dynamic_tools: None,
memory_mode: None,
multi_agent_version: None,
};
let payload = serde_json::to_value(SessionMetaLine {
meta,
@@ -279,6 +280,7 @@ pub fn create_fake_rollout_with_text_elements(
base_instructions: None,
dynamic_tools: None,
memory_mode: None,
multi_agent_version: None,
};
let payload = serde_json::to_value(SessionMetaLine {
meta,
@@ -67,6 +67,9 @@ use codex_app_server_protocol::ProcessKillParams;
use codex_app_server_protocol::ProcessResizePtyParams;
use codex_app_server_protocol::ProcessSpawnParams;
use codex_app_server_protocol::ProcessWriteStdinParams;
use codex_app_server_protocol::RemoteControlClientsListParams;
use codex_app_server_protocol::RemoteControlClientsRevokeParams;
use codex_app_server_protocol::RemoteControlPairingStartParams;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ReviewStartParams;
use codex_app_server_protocol::SendAddCreditsNudgeEmailParams;
@@ -643,6 +646,35 @@ impl TestAppServer {
.await
}
/// Send a `remoteControl/pairing/start` JSON-RPC request.
pub async fn send_remote_control_pairing_start_request(
&mut self,
params: RemoteControlPairingStartParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("remoteControl/pairing/start", params)
.await
}
/// Send a `remoteControl/client/list` JSON-RPC request.
pub async fn send_remote_control_clients_list_request(
&mut self,
params: RemoteControlClientsListParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("remoteControl/client/list", params).await
}
/// Send a `remoteControl/client/revoke` JSON-RPC request.
pub async fn send_remote_control_clients_revoke_request(
&mut self,
params: RemoteControlClientsRevokeParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("remoteControl/client/revoke", params)
.await
}
/// Send an `app/list` JSON-RPC request.
pub async fn send_apps_list_request(&mut self, params: AppsListParams) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
@@ -15,7 +15,7 @@ use codex_app_server_protocol::InitializeParams;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_arg0::Arg0DispatchPaths;
use codex_config::CloudRequirementsLoader;
use codex_config::CloudConfigBundleLoader;
use codex_config::LoaderOverrides;
use codex_core::config::ConfigBuilder;
use codex_exec_server::EnvironmentManager;
@@ -128,6 +128,7 @@ async fn get_conversation_summary_by_thread_id_reads_pathless_store_thread() ->
thread_source: None,
base_instructions: BaseInstructions::default(),
dynamic_tools: Vec::new(),
multi_agent_version: None,
metadata: ThreadPersistenceMetadata {
cwd: None,
model_provider: "test-provider".to_string(),
@@ -149,7 +150,7 @@ async fn get_conversation_summary_by_thread_id_reads_pathless_store_thread() ->
cli_overrides: Vec::new(),
loader_overrides,
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
cloud_config_bundle: CloudConfigBundleLoader::default(),
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
log_db: None,
@@ -5,6 +5,7 @@ use app_test_support::test_tmp_path_buf;
use app_test_support::to_response;
use codex_app_server_protocol::AppConfig;
use codex_app_server_protocol::AppToolApproval;
use codex_app_server_protocol::ApprovalsReviewer;
use codex_app_server_protocol::AppsConfig;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::ConfigBatchWriteParams;
@@ -333,6 +334,7 @@ async fn config_read_includes_apps() -> Result<()> {
r#"
[apps.app1]
enabled = false
approvals_reviewer = "user"
destructive_enabled = false
default_tools_approval_mode = "prompt"
"#,
@@ -368,6 +370,7 @@ default_tools_approval_mode = "prompt"
"app1".to_string(),
AppConfig {
enabled: false,
approvals_reviewer: Some(ApprovalsReviewer::User),
destructive_enabled: Some(false),
open_world_enabled: None,
default_tools_approval_mode: Some(AppToolApproval::Prompt),
@@ -384,6 +387,16 @@ default_tools_approval_mode = "prompt"
profile: None,
}
);
assert_eq!(
origins
.get("apps.app1.approvals_reviewer")
.expect("origin")
.name,
ConfigLayerSource::User {
file: user_file.clone(),
profile: None,
}
);
assert_eq!(
origins
.get("apps.app1.destructive_enabled")
@@ -0,0 +1,307 @@
use std::path::Path;
use std::time::Duration;
use anyhow::Context;
use anyhow::Result;
use app_test_support::ChatGptAuthFixture;
use app_test_support::TestAppServer;
use app_test_support::to_response;
use app_test_support::write_chatgpt_auth;
use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_config::types::AuthCredentialsStoreMode;
use core_test_support::responses;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
use tokio::time::timeout;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
const RESULT: &str = "cG5n";
#[derive(Clone, Copy)]
enum ImagegenTestMode {
Direct,
CodeModeOnly,
}
// macOS and Windows Bazel CI can spend tens of seconds starting app-server
// subprocesses or processing test RPCs under load.
#[cfg(any(target_os = "macos", windows))]
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60);
#[cfg(not(any(target_os = "macos", windows)))]
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
#[tokio::test]
async fn standalone_image_generation_persists_image_and_returns_it_to_model() -> Result<()> {
let call_id = "image-run-1";
let server = responses::start_mock_server().await;
mount_image_response(&server).await;
let response_mock = responses::mount_sse_sequence(
&server,
vec![
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_function_call_with_namespace(
call_id,
"image_gen",
"imagegen",
&json!({
"action": "generate",
"prompt": "paint a blue whale",
})
.to_string(),
),
responses::ev_completed("resp-1"),
]),
responses::sse(vec![
responses::ev_assistant_message("msg-1", "Done"),
responses::ev_completed("resp-2"),
]),
],
)
.await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), ImagegenTestMode::Direct)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("access-chatgpt"),
AuthCredentialsStoreMode::File,
)?;
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
start_image_generation_turn(&mut mcp).await?;
let completed = timeout(
DEFAULT_READ_TIMEOUT,
wait_for_image_generation_completed(&mut mcp),
)
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let ThreadItem::ImageGeneration {
status,
revised_prompt,
result,
saved_path: Some(saved_path),
..
} = completed.item
else {
panic!("expected completed image generation item with saved path");
};
assert_eq!(status, "completed");
assert_eq!(revised_prompt.as_deref(), Some("paint a blue whale"));
assert_eq!(result, RESULT);
assert_eq!(std::fs::read(&saved_path)?, b"png");
let requests = response_mock.requests();
assert_eq!(requests.len(), 2);
let output = requests[1].function_call_output(call_id);
assert_eq!(
output["output"][0],
json!({
"type": "input_image",
"image_url": format!("data:image/png;base64,{RESULT}"),
"detail": "high",
})
);
assert_eq!(output["output"].as_array().map(Vec::len), Some(1));
assert!(
!requests[1]
.message_input_texts("developer")
.iter()
.any(|text| text.contains("Generated images are saved to")),
"standalone image generation should not emit the legacy developer-message hint"
);
Ok(())
}
#[cfg_attr(windows, ignore = "covered by Linux and macOS CI")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn standalone_image_generation_is_callable_from_code_mode_only() -> Result<()> {
let call_id = "code-mode-image-run-1";
let server = responses::start_mock_server().await;
mount_image_response(&server).await;
let response_mock = responses::mount_sse_sequence(
&server,
vec![
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_custom_tool_call(
call_id,
"exec",
r#"
const result = await tools.image_gen__imagegen({
action: "generate",
prompt: "paint a blue whale",
});
image(result);
"#,
),
responses::ev_completed("resp-1"),
]),
responses::sse(vec![
responses::ev_assistant_message("msg-1", "Done"),
responses::ev_completed("resp-2"),
]),
],
)
.await;
let codex_home = TempDir::new()?;
create_config_toml(
codex_home.path(),
&server.uri(),
ImagegenTestMode::CodeModeOnly,
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("access-chatgpt"),
AuthCredentialsStoreMode::File,
)?;
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
start_image_generation_turn(&mut mcp).await?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let requests = response_mock.requests();
assert_eq!(requests.len(), 2);
assert!(requests[0].body_contains_text("image_gen__imagegen"));
let output = requests[1].custom_tool_call_output(call_id);
assert_eq!(
output["output"][1],
json!({
"type": "input_image",
"image_url": format!("data:image/png;base64,{RESULT}"),
"detail": "high",
})
);
assert_eq!(output["output"].as_array().map(Vec::len), Some(2));
Ok(())
}
async fn start_image_generation_turn(mcp: &mut TestAppServer) -> Result<()> {
let thread_req = mcp
.send_thread_start_request(ThreadStartParams::default())
.await?;
let thread_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id,
client_user_message_id: None,
input: vec![V2UserInput::Text {
text: "Generate an image".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
let turn_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
)
.await??;
let _turn: TurnStartResponse = to_response::<TurnStartResponse>(turn_resp)?;
Ok(())
}
async fn wait_for_image_generation_completed(
mcp: &mut TestAppServer,
) -> Result<ItemCompletedNotification> {
loop {
let notification = mcp
.read_stream_until_notification_message("item/completed")
.await?;
let completed: ItemCompletedNotification = serde_json::from_value(
notification
.params
.context("item/completed notification should include params")?,
)?;
if matches!(&completed.item, ThreadItem::ImageGeneration { .. }) {
return Ok(completed);
}
}
}
async fn mount_image_response(server: &MockServer) {
Mock::given(method("POST"))
.and(path("/api/codex/images/generations"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"created": 1,
"data": [{"b64_json": RESULT}],
})))
.expect(1)
.mount(server)
.await;
}
fn create_config_toml(
codex_home: &Path,
server_uri: &str,
mode: ImagegenTestMode,
) -> std::io::Result<()> {
let code_mode_only = match mode {
ImagegenTestMode::Direct => "",
ImagegenTestMode::CodeModeOnly => "code_mode_only = true",
};
std::fs::write(
codex_home.join("config.toml"),
format!(
r#"
model = "mock-model"
approval_policy = "never"
sandbox_mode = "read-only"
model_provider = "openai-custom"
chatgpt_base_url = "{server_uri}"
[features]
imagegenext = true
{code_mode_only}
[model_providers.openai-custom]
name = "OpenAI"
base_url = "{server_uri}/api/codex"
wire_api = "responses"
request_max_retries = 0
stream_max_retries = 0
supports_websockets = false
requires_openai_auth = true
"#
),
)
}
@@ -20,7 +20,7 @@ use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_arg0::Arg0DispatchPaths;
use codex_config::CloudRequirementsLoader;
use codex_config::CloudConfigBundleLoader;
use codex_config::LoaderOverrides;
use codex_config::types::AuthCredentialsStoreMode;
use codex_core::config::ConfigBuilder;
@@ -201,7 +201,7 @@ async fn mcp_resource_read_returns_error_for_unknown_thread() -> Result<()> {
cli_overrides: Vec::new(),
loader_overrides,
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
cloud_config_bundle: CloudConfigBundleLoader::default(),
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
log_db: None,
@@ -17,6 +17,7 @@ mod experimental_feature_list;
mod external_agent_config;
mod fs;
mod hooks_list;
mod imagegen_extension;
mod initialize;
mod marketplace_add;
mod marketplace_remove;
@@ -1900,6 +1900,23 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -
"project management".to_string()
]
);
let cache_files = std::fs::read_dir(codex_home.path().join("cache/remote_plugin_catalog"))?
.map(|entry| entry.map(|entry| entry.path()))
.collect::<Result<Vec<_>, _>>()?;
assert_eq!(cache_files.len(), 1);
let cached_catalog: serde_json::Value =
serde_json::from_slice(&std::fs::read(&cache_files[0])?)?;
assert_eq!(cached_catalog["schema_version"], serde_json::json!(1));
let cached_plugin_ids = cached_catalog["plugins"]
.as_array()
.expect("cached plugins should be an array")
.iter()
.map(|plugin| plugin["id"].as_str().expect("cached plugin id").to_string())
.collect::<Vec<_>>();
assert_eq!(
cached_plugin_ids,
vec!["plugins~Plugin_00000000000000000000000000000000".to_string()]
);
assert_eq!(response.featured_plugin_ids, Vec::<String>::new());
assert!(
!server
@@ -15,6 +15,7 @@ use codex_app_server_protocol::RateLimitWindow;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SendAddCreditsNudgeEmailParams;
use codex_app_server_protocol::SendAddCreditsNudgeEmailResponse;
use codex_app_server_protocol::SpendControlLimitSnapshot;
use codex_config::types::AuthCredentialsStoreMode;
use codex_protocol::account::PlanType as AccountPlanType;
use pretty_assertions::assert_eq;
@@ -128,6 +129,19 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
"rate_limit_reached_type": {
"type": "workspace_member_usage_limit_reached",
},
"spend_control": {
"reached": false,
"individual_limit": {
"source": "workspace_spend_controls",
"limit": "25000",
"used": "8000",
"remaining": "17000",
"used_percent": 32,
"remaining_percent": 68,
"reset_after_seconds": 43200,
"reset_at": secondary_reset_timestamp,
}
},
"additional_rate_limits": [
{
"limit_name": "codex_other",
@@ -183,6 +197,12 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
resets_at: Some(secondary_reset_timestamp),
}),
credits: None,
individual_limit: Some(SpendControlLimitSnapshot {
limit: "25000".to_string(),
used: "8000".to_string(),
remaining_percent: 68,
resets_at: secondary_reset_timestamp,
}),
plan_type: Some(AccountPlanType::Pro),
rate_limit_reached_type: Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached),
},
@@ -204,6 +224,12 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
resets_at: Some(secondary_reset_timestamp),
}),
credits: None,
individual_limit: Some(SpendControlLimitSnapshot {
limit: "25000".to_string(),
used: "8000".to_string(),
remaining_percent: 68,
resets_at: secondary_reset_timestamp,
}),
plan_type: Some(AccountPlanType::Pro),
rate_limit_reached_type: Some(
RateLimitReachedType::WorkspaceMemberUsageLimitReached,
@@ -222,6 +248,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
}),
secondary: None,
credits: None,
individual_limit: None,
plan_type: Some(AccountPlanType::Pro),
rate_limit_reached_type: None,
},
@@ -8,15 +8,24 @@ use app_test_support::to_response;
use app_test_support::write_chatgpt_auth;
use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RemoteControlClient;
use codex_app_server_protocol::RemoteControlClientsListOrder;
use codex_app_server_protocol::RemoteControlClientsListParams;
use codex_app_server_protocol::RemoteControlClientsListResponse;
use codex_app_server_protocol::RemoteControlClientsRevokeParams;
use codex_app_server_protocol::RemoteControlClientsRevokeResponse;
use codex_app_server_protocol::RemoteControlConnectionStatus;
use codex_app_server_protocol::RemoteControlDisableResponse;
use codex_app_server_protocol::RemoteControlEnableResponse;
use codex_app_server_protocol::RemoteControlPairingStartParams;
use codex_app_server_protocol::RemoteControlPairingStartResponse;
use codex_app_server_protocol::RemoteControlStatusReadResponse;
use codex_app_server_protocol::RequestId;
use codex_config::types::AuthCredentialsStoreMode;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::io::AsyncBufReadExt;
use tokio::io::AsyncWriteExt;
use tokio::io::BufReader;
use tokio::net::TcpListener;
use tokio::net::TcpStream;
@@ -125,27 +134,192 @@ async fn remote_control_status_read_returns_connecting_status_after_enable() ->
Ok(())
}
#[tokio::test]
async fn remote_control_pairing_start_returns_pairing_artifacts() -> Result<()> {
let codex_home = TempDir::new()?;
let mut backend = PairingRemoteControlBackend::start(codex_home.path()).await?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp.send_remote_control_enable_request().await?;
let _: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(
timeout(DEFAULT_TIMEOUT, backend.wait_for_enroll_request()).await??,
"POST /backend-api/wham/remote/control/server/enroll HTTP/1.1"
);
timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_matching_notification(
"remoteControl/status/changed enrolled",
|notification| {
notification.method == "remoteControl/status/changed"
&& notification
.params
.as_ref()
.and_then(|params| params.get("environmentId"))
.and_then(serde_json::Value::as_str)
== Some("environment-id")
},
),
)
.await??;
let request_id = mcp
.send_remote_control_pairing_start_request(RemoteControlPairingStartParams {
manual_code: true,
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(response.result.get("serverId"), None);
let received: RemoteControlPairingStartResponse = to_response(response)?;
assert_eq!(
received,
RemoteControlPairingStartResponse {
pairing_code: "pairing-code".to_string(),
manual_pairing_code: Some("ABCD-EFGH".to_string()),
environment_id: "environment-id".to_string(),
expires_at: 33_336_362_096,
}
);
Ok(())
}
#[tokio::test]
async fn remote_control_client_management_works_while_disabled() -> Result<()> {
let codex_home = TempDir::new()?;
let mut backend = ClientManagementRemoteControlBackend::start(codex_home.path()).await?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_remote_control_clients_list_request(RemoteControlClientsListParams {
environment_id: "environment-id".to_string(),
cursor: Some("cursor-id".to_string()),
limit: Some(10),
order: Some(RemoteControlClientsListOrder::Desc),
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let received: RemoteControlClientsListResponse = to_response(response)?;
assert_eq!(
received,
RemoteControlClientsListResponse {
data: vec![RemoteControlClient {
client_id: "client-id".to_string(),
display_name: Some("Anton Phone".to_string()),
device_type: Some("phone".to_string()),
platform: Some("ios".to_string()),
os_version: Some("19.0".to_string()),
device_model: Some("iPhone".to_string()),
app_version: Some("1.2.3".to_string()),
last_seen_at: Some(1_772_694_000),
}],
next_cursor: Some("next-cursor".to_string()),
}
);
let request_id = mcp
.send_remote_control_clients_revoke_request(RemoteControlClientsRevokeParams {
environment_id: "environment-id".to_string(),
client_id: "client-id".to_string(),
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let received: RemoteControlClientsRevokeResponse = to_response(response)?;
assert_eq!(received, RemoteControlClientsRevokeResponse {});
assert_eq!(
timeout(DEFAULT_TIMEOUT, backend.wait_for_requests()).await??,
vec![
"GET /backend-api/wham/remote/control/environments/environment-id/clients?cursor=cursor-id&limit=10&order=desc HTTP/1.1".to_string(),
"DELETE /backend-api/wham/remote/control/environments/environment-id/clients/client-id HTTP/1.1".to_string(),
]
);
Ok(())
}
struct BlockingRemoteControlBackend {
enroll_request_rx: Option<oneshot::Receiver<Result<String>>>,
server_task: JoinHandle<()>,
}
struct ClientManagementRemoteControlBackend {
requests_rx: Option<oneshot::Receiver<Result<Vec<String>>>>,
server_task: JoinHandle<()>,
}
impl ClientManagementRemoteControlBackend {
async fn start(codex_home: &std::path::Path) -> Result<Self> {
let listener = configured_remote_control_listener(codex_home).await?;
let (requests_tx, requests_rx) = oneshot::channel();
let server_task = tokio::spawn(async move {
let result = async {
let list_request = read_http_request(&listener).await?;
let list_request_line = list_request.request_line;
respond_with_json(
list_request.reader.into_inner(),
serde_json::json!({
"items": [{
"client_id": "client-id",
"account_user_id": "user-id",
"enrollment_status": "enrolled_device_key",
"display_name": "Anton Phone",
"device_type": "phone",
"platform": "ios",
"os_version": "19.0",
"device_model": "iPhone",
"app_version": "1.2.3",
"last_seen_at": "2026-03-05T07:00:00Z",
"last_seen_city": "San Francisco",
}],
"cursor": "next-cursor",
}),
)
.await?;
let revoke_request = read_http_request(&listener).await?;
let revoke_request_line = revoke_request.request_line;
respond_with_status(revoke_request.reader.into_inner(), "204 No Content", "")
.await?;
Ok(vec![list_request_line, revoke_request_line])
}
.await;
let _ = requests_tx.send(result);
});
Ok(Self {
requests_rx: Some(requests_rx),
server_task,
})
}
async fn wait_for_requests(&mut self) -> Result<Vec<String>> {
self.requests_rx
.take()
.context("requests should only be awaited once")?
.await?
}
}
impl BlockingRemoteControlBackend {
async fn start(codex_home: &std::path::Path) -> Result<Self> {
let listener = TcpListener::bind("127.0.0.1:0").await?;
let remote_control_url = format!("http://{}/backend-api/", listener.local_addr()?);
write_mock_responses_config_toml_with_chatgpt_base_url(
codex_home,
&remote_control_url,
&remote_control_url,
)?;
write_chatgpt_auth(
codex_home,
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account_id")
.chatgpt_account_id("account_id"),
AuthCredentialsStoreMode::File,
)?;
let listener = configured_remote_control_listener(codex_home).await?;
let (enroll_request_tx, enroll_request_rx) = oneshot::channel();
let server_task = tokio::spawn(async move {
@@ -175,19 +349,125 @@ impl BlockingRemoteControlBackend {
}
}
struct PairingRemoteControlBackend {
enroll_request_rx: Option<oneshot::Receiver<Result<String>>>,
server_task: JoinHandle<()>,
}
impl PairingRemoteControlBackend {
async fn start(codex_home: &std::path::Path) -> Result<Self> {
let listener = configured_remote_control_listener(codex_home).await?;
let (enroll_request_tx, enroll_request_rx) = oneshot::channel();
let server_task = tokio::spawn(async move {
let mut enroll_request_tx = Some(enroll_request_tx);
let result = async {
let enroll_request = read_http_request(&listener).await?;
if let Some(enroll_request_tx) = enroll_request_tx.take() {
let _ = enroll_request_tx.send(Ok(enroll_request.request_line.clone()));
}
respond_with_json(
enroll_request.reader.into_inner(),
serde_json::json!({
"server_id": "server-id",
"environment_id": "environment-id",
"remote_control_token": "remote-control-token",
"expires_at": "3026-05-22T12:34:56Z",
}),
)
.await?;
let _websocket_request = read_http_request(&listener).await?;
let pair_http_request = read_http_request(&listener).await?;
respond_with_json(
pair_http_request.reader.into_inner(),
serde_json::json!({
"pairing_code": "pairing-code",
"manual_pairing_code": "ABCD-EFGH",
"server_id": "server-id",
"environment_id": "environment-id",
"expires_at": "3026-05-22T12:34:56Z",
}),
)
.await?;
std::future::pending::<()>().await;
Ok::<(), anyhow::Error>(())
}
.await;
if let Err(err) = result {
let err = err.to_string();
if let Some(enroll_request_tx) = enroll_request_tx {
let _ = enroll_request_tx.send(Err(anyhow::anyhow!(err)));
}
}
});
Ok(Self {
enroll_request_rx: Some(enroll_request_rx),
server_task,
})
}
async fn wait_for_enroll_request(&mut self) -> Result<String> {
self.enroll_request_rx
.take()
.context("enroll request should only be awaited once")?
.await?
}
}
impl Drop for PairingRemoteControlBackend {
fn drop(&mut self) {
self.server_task.abort();
}
}
impl Drop for BlockingRemoteControlBackend {
fn drop(&mut self) {
self.server_task.abort();
}
}
impl Drop for ClientManagementRemoteControlBackend {
fn drop(&mut self) {
self.server_task.abort();
}
}
struct HttpRequest {
request_line: String,
reader: BufReader<TcpStream>,
}
async fn configured_remote_control_listener(codex_home: &std::path::Path) -> Result<TcpListener> {
let listener = TcpListener::bind("127.0.0.1:0").await?;
let remote_control_url = format!("http://{}/backend-api/", listener.local_addr()?);
write_mock_responses_config_toml_with_chatgpt_base_url(
codex_home,
&remote_control_url,
&remote_control_url,
)?;
write_chatgpt_auth(
codex_home,
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account_id")
.chatgpt_account_id("account_id"),
AuthCredentialsStoreMode::File,
)?;
Ok(listener)
}
async fn read_enroll_request(listener: TcpListener) -> Result<(String, BufReader<TcpStream>)> {
let request = read_http_request(&listener).await?;
Ok((request.request_line, request.reader))
}
async fn read_http_request(listener: &TcpListener) -> Result<HttpRequest> {
let (stream, _) = listener.accept().await?;
let mut reader = BufReader::new(stream);
let mut request_line = String::new();
reader.read_line(&mut request_line).await?;
loop {
let mut line = String::new();
reader.read_line(&mut line).await?;
@@ -196,5 +476,36 @@ async fn read_enroll_request(listener: TcpListener) -> Result<(String, BufReader
}
}
Ok((request_line.trim_end().to_string(), reader))
Ok(HttpRequest {
request_line: request_line.trim_end().to_string(),
reader,
})
}
async fn respond_with_json(stream: TcpStream, body: serde_json::Value) -> Result<()> {
let body = body.to_string();
let mut stream = stream;
stream
.write_all(
format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
)
.as_bytes(),
)
.await?;
Ok(())
}
async fn respond_with_status(mut stream: TcpStream, status: &str, body: &str) -> Result<()> {
stream
.write_all(
format!(
"HTTP/1.1 {status}\r\ncontent-type: text/plain\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
)
.as_bytes(),
)
.await?;
Ok(())
}
@@ -34,7 +34,7 @@ use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_arg0::Arg0DispatchPaths;
use codex_config::CloudRequirementsLoader;
use codex_config::CloudConfigBundleLoader;
use codex_config::LoaderOverrides;
use codex_config::NoopThreadConfigLoader;
use codex_core::config::ConfigBuilder;
@@ -76,7 +76,7 @@ async fn thread_start_with_non_local_thread_store_does_not_create_local_persiste
cli_overrides: Vec::new(),
loader_overrides,
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
cloud_config_bundle: CloudConfigBundleLoader::default(),
thread_config_loader: Arc::new(NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
log_db: None,
@@ -30,6 +30,10 @@ use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::UserInput;
use codex_config::types::AuthCredentialsStoreMode;
use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR;
use codex_protocol::protocol::MultiAgentVersion;
use codex_protocol::protocol::RolloutItem;
use codex_rollout::append_rollout_item_to_path;
use codex_rollout::read_session_meta_line;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
@@ -82,6 +86,9 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> {
"expected original rollout to exist at {}",
original_path.display()
);
let mut session_meta = read_session_meta_line(&original_path).await?;
session_meta.meta.multi_agent_version = Some(MultiAgentVersion::V1);
append_rollout_item_to_path(&original_path, &RolloutItem::SessionMeta(session_meta)).await?;
let original_contents = std::fs::read_to_string(&original_path)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
@@ -503,10 +510,10 @@ async fn thread_fork_with_empty_path_uses_thread_id() -> Result<()> {
}
#[tokio::test]
async fn thread_fork_surfaces_cloud_requirements_load_errors() -> Result<()> {
async fn thread_fork_surfaces_cloud_config_bundle_load_errors() -> Result<()> {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/wham/config/requirements"))
.and(path("/backend-api/wham/config/bundle"))
.respond_with(
ResponseTemplate::new(401)
.insert_header("content-type", "text/html")
@@ -587,7 +594,7 @@ async fn thread_fork_surfaces_cloud_requirements_load_errors() -> Result<()> {
assert_eq!(
fork_err.error.data,
Some(json!({
"reason": "cloudRequirements",
"reason": "cloudConfigBundle",
"errorCode": "Auth",
"action": "relogin",
"statusCode": 401,
@@ -41,7 +41,7 @@ use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::UserInput;
use codex_arg0::Arg0DispatchPaths;
use codex_config::CloudRequirementsLoader;
use codex_config::CloudConfigBundleLoader;
use codex_config::LoaderOverrides;
use codex_core::ARCHIVED_SESSIONS_SUBDIR;
use codex_core::config::ConfigBuilder;
@@ -376,7 +376,7 @@ async fn thread_turns_list_reads_store_history_without_rollout_path() -> Result<
cli_overrides: Vec::new(),
loader_overrides,
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
cloud_config_bundle: CloudConfigBundleLoader::default(),
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
log_db: None,
@@ -442,7 +442,7 @@ async fn thread_read_loaded_include_turns_reads_store_history_without_rollout_pa
cli_overrides: Vec::new(),
loader_overrides,
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
cloud_config_bundle: CloudConfigBundleLoader::default(),
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
log_db: None,
@@ -528,7 +528,7 @@ async fn thread_list_includes_store_thread_without_rollout_path() -> Result<()>
cli_overrides: Vec::new(),
loader_overrides,
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
cloud_config_bundle: CloudConfigBundleLoader::default(),
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
log_db: None,
@@ -1362,6 +1362,7 @@ async fn seed_pathless_store_thread(
thread_source: None,
base_instructions: BaseInstructions::default(),
dynamic_tools: Vec::new(),
multi_agent_version: None,
metadata: ThreadPersistenceMetadata {
cwd: None,
model_provider: "test-provider".to_string(),
@@ -63,6 +63,8 @@ use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ImageGenerationEndEvent;
use codex_protocol::protocol::McpInvocation;
use codex_protocol::protocol::McpToolCallEndEvent;
use codex_protocol::protocol::MultiAgentVersion;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SessionMeta;
use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::SessionSource as RolloutSessionSource;
@@ -74,6 +76,8 @@ use codex_protocol::protocol::TurnAbortedEvent;
use codex_protocol::protocol::TurnStartedEvent;
use codex_protocol::user_input::ByteRange;
use codex_protocol::user_input::TextElement;
use codex_rollout::append_rollout_item_to_path;
use codex_rollout::read_session_meta_line;
use codex_state::StateRuntime;
use codex_utils_absolute_path::AbsolutePathBuf;
use core_test_support::responses;
@@ -1805,6 +1809,7 @@ stream_max_retries = 0
base_instructions: None,
dynamic_tools: None,
memory_mode: None,
multi_agent_version: None,
};
std::fs::write(
&rollout_path,
@@ -2013,7 +2018,7 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is
async fn thread_resume_defers_updated_at_until_turn_start() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
let rollout = setup_rollout_fixture(codex_home.path(), &server.uri())?;
let rollout = setup_rollout_fixture(codex_home.path(), &server.uri()).await?;
let thread_id = rollout.conversation_id.clone();
let mut mcp = TestAppServer::new(codex_home.path()).await?;
@@ -2405,6 +2410,36 @@ async fn thread_resume_rejects_mismatched_path_for_running_thread_id() -> Result
)
.await??;
#[cfg(windows)]
{
let active_path = thread.path.as_ref().expect("thread should have path");
let active_path_display = active_path.as_os_str().to_string_lossy();
let equivalent_path = if let Some(path) = active_path_display.strip_prefix(r"\\?\UNC\") {
PathBuf::from(format!(r"\\{path}"))
} else if let Some(path) = active_path_display.strip_prefix(r"\\?\") {
PathBuf::from(path)
} else if let Some(path) = active_path_display.strip_prefix(r"\\") {
PathBuf::from(format!(r"\\?\UNC\{path}"))
} else {
PathBuf::from(format!(r"\\?\{active_path_display}"))
};
let normalized_resume_id = primary
.send_thread_resume_request(ThreadResumeParams {
thread_id: thread_id.clone(),
path: Some(equivalent_path),
..Default::default()
})
.await?;
let normalized_resume_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_response_message(RequestId::Integer(normalized_resume_id)),
)
.await??;
let ThreadResumeResponse { thread, .. } =
to_response::<ThreadResumeResponse>(normalized_resume_resp)?;
assert_eq!(thread.id, thread_id);
}
let stale_thread_id = Uuid::new_v4().to_string();
let stale_path = rollout_path(codex_home.path(), "2025-01-01T00-00-00", &stale_thread_id);
std::fs::create_dir_all(stale_path.parent().expect("stale path parent"))?;
@@ -3051,7 +3086,7 @@ async fn thread_resume_with_overrides_defers_updated_at_until_turn_start() -> Re
async fn thread_resume_fails_when_required_mcp_server_fails_to_initialize() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
let rollout = setup_rollout_fixture(codex_home.path(), &server.uri())?;
let rollout = setup_rollout_fixture(codex_home.path(), &server.uri()).await?;
create_config_toml_with_required_broken_mcp(codex_home.path(), &server.uri())?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
@@ -3086,10 +3121,10 @@ async fn thread_resume_fails_when_required_mcp_server_fails_to_initialize() -> R
}
#[tokio::test]
async fn thread_resume_surfaces_cloud_requirements_load_errors() -> Result<()> {
async fn thread_resume_surfaces_cloud_config_bundle_load_errors() -> Result<()> {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/wham/config/requirements"))
.and(path("/backend-api/wham/config/bundle"))
.respond_with(
ResponseTemplate::new(401)
.insert_header("content-type", "text/html")
@@ -3166,7 +3201,7 @@ async fn thread_resume_surfaces_cloud_requirements_load_errors() -> Result<()> {
assert_eq!(
err.error.data,
Some(json!({
"reason": "cloudRequirements",
"reason": "cloudConfigBundle",
"errorCode": "Auth",
"action": "relogin",
"statusCode": 401,
@@ -3614,7 +3649,7 @@ struct RolloutFixture {
before_modified: std::time::SystemTime,
}
fn setup_rollout_fixture(codex_home: &Path, server_uri: &str) -> Result<RolloutFixture> {
async fn setup_rollout_fixture(codex_home: &Path, server_uri: &str) -> Result<RolloutFixture> {
create_config_toml(codex_home, server_uri)?;
let preview = "Saved user message";
@@ -3631,6 +3666,10 @@ fn setup_rollout_fixture(codex_home: &Path, server_uri: &str) -> Result<RolloutF
/*git_info*/ None,
)?;
let rollout_file_path = rollout_path(codex_home, filename_ts, &conversation_id);
let mut session_meta = read_session_meta_line(&rollout_file_path).await?;
session_meta.meta.multi_agent_version = Some(MultiAgentVersion::V1);
append_rollout_item_to_path(&rollout_file_path, &RolloutItem::SessionMeta(session_meta))
.await?;
set_rollout_mtime(rollout_file_path.as_path(), expected_updated_at_rfc3339)?;
let before_modified = std::fs::metadata(&rollout_file_path)?.modified()?;
Ok(RolloutFixture {
@@ -718,10 +718,10 @@ async fn thread_start_emits_mcp_server_status_updated_notifications() -> Result<
}
#[tokio::test]
async fn thread_start_surfaces_cloud_requirements_load_errors() -> Result<()> {
async fn thread_start_surfaces_cloud_config_bundle_load_errors() -> Result<()> {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/wham/config/requirements"))
.and(path("/backend-api/wham/config/bundle"))
.respond_with(
ResponseTemplate::new(401)
.insert_header("content-type", "text/html")
@@ -788,7 +788,7 @@ async fn thread_start_surfaces_cloud_requirements_load_errors() -> Result<()> {
assert_eq!(
err.error.data,
Some(json!({
"reason": "cloudRequirements",
"reason": "cloudConfigBundle",
"errorCode": "Auth",
"action": "relogin",
"statusCode": 401,
@@ -22,7 +22,7 @@ use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::UserInput;
use codex_arg0::Arg0DispatchPaths;
use codex_config::CloudRequirementsLoader;
use codex_config::CloudConfigBundleLoader;
use codex_config::LoaderOverrides;
use codex_core::config::ConfigBuilder;
use codex_core::find_archived_thread_path_by_id_str;
@@ -215,6 +215,7 @@ async fn thread_unarchive_preserves_pathless_store_metadata() -> Result<()> {
thread_source: None,
base_instructions: BaseInstructions::default(),
dynamic_tools: Vec::new(),
multi_agent_version: None,
metadata: ThreadPersistenceMetadata {
cwd: None,
model_provider: "test-provider".to_string(),
@@ -246,7 +247,7 @@ async fn thread_unarchive_preserves_pathless_store_metadata() -> Result<()> {
cli_overrides: Vec::new(),
loader_overrides,
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
cloud_config_bundle: CloudConfigBundleLoader::default(),
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
log_db: None,
@@ -855,6 +855,10 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> {
url: "https://example.com/a.png".to_string(),
detail: None,
}],
responsesapi_client_metadata: Some(HashMap::from([(
"workspace_kind".to_string(),
"projectless".to_string(),
)])),
..Default::default()
})
.await?;
@@ -882,6 +886,7 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> {
assert_eq!(event["event_params"]["model"], "mock-model");
assert_eq!(event["event_params"]["model_provider"], "mock_provider");
assert_eq!(event["event_params"]["sandbox_policy"], "read_only");
assert_eq!(event["event_params"]["workspace_kind"], "projectless");
assert_eq!(event["event_params"]["ephemeral"], false);
assert_eq!(event["event_params"]["thread_source"], "user");
assert_eq!(event["event_params"]["initialization_mode"], "new");
+24 -12
View File
@@ -160,12 +160,13 @@ pub fn arg0_dispatch() -> Option<Arg0PathEntryGuard> {
/// [`codex_linux_sandbox::run_main`] (which never returns). Otherwise we:
///
/// 1. Load `.env` values from `~/.codex/.env` before creating any threads.
/// 2. Construct a Tokio multi-thread runtime.
/// 3. Capture the current executable path and derive the
/// 2. Spawn a main runtime thread with a controlled stack size.
/// 3. Construct a Tokio multi-thread runtime.
/// 4. Capture the current executable path and derive the
/// `codex-linux-sandbox` helper path (falling back to the current
/// executable if needed) so children can re-invoke the sandbox when running
/// on Linux.
/// 4. Execute the provided async `main_fn` inside that runtime, forwarding any
/// 5. Execute the provided async `main_fn` inside that runtime, forwarding any
/// error. Note that `main_fn` receives [`Arg0DispatchPaths`], which
/// contains the helper executable paths needed to construct
/// [`codex_core::config::Config`].
@@ -174,22 +175,33 @@ pub fn arg0_dispatch() -> Option<Arg0PathEntryGuard> {
/// in this workspace that depends on these helper CLIs.
pub fn arg0_dispatch_or_else<F, Fut>(main_fn: F) -> anyhow::Result<()>
where
F: FnOnce(Arg0DispatchPaths) -> Fut,
F: FnOnce(Arg0DispatchPaths) -> Fut + Send + 'static,
Fut: Future<Output = anyhow::Result<()>>,
{
// Retain the TempDir so it exists for the lifetime of the invocation of
// this executable. Admittedly, we could invoke `keep()` on it, but it
// would be nice to avoid leaving temporary directories behind, if possible.
let path_entry_guard = arg0_dispatch();
let current_exe = std::env::current_exe().ok();
// Regular invocation – create a Tokio runtime and execute the provided
// async entry-point.
let runtime = build_runtime()?;
runtime.block_on(run_main_with_arg0_guard(
path_entry_guard,
std::env::current_exe().ok(),
main_fn,
))
// Regular invocation. Run the async entry point on a thread with the same
// stack budget as Tokio workers; `Runtime::block_on` otherwise runs the
// top-level future on the caller's OS stack.
let handle = std::thread::Builder::new()
.name("codex-main".to_string())
.stack_size(TOKIO_WORKER_STACK_SIZE_BYTES)
.spawn(move || {
let runtime = build_runtime()?;
runtime.block_on(run_main_with_arg0_guard(
path_entry_guard,
current_exe,
main_fn,
))
})?;
match handle.join() {
Ok(result) => result,
Err(payload) => std::panic::resume_unwind(payload),
}
}
async fn run_main_with_arg0_guard<F, Fut>(
+53 -19
View File
@@ -1,6 +1,5 @@
use crate::types::CodeTaskDetailsResponse;
use crate::types::ConfigBundleResponse;
use crate::types::ConfigFileResponse;
use crate::types::ListCredentialRoutesResponse;
use crate::types::PaginatedListTaskListItem;
use crate::types::RateLimitReachedKind as BackendRateLimitReachedKind;
@@ -17,6 +16,7 @@ use codex_protocol::protocol::CreditsSnapshot;
use codex_protocol::protocol::RateLimitReachedType;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::RateLimitWindow;
use codex_protocol::protocol::SpendControlLimitSnapshot;
use reqwest::StatusCode;
use reqwest::header::CONTENT_TYPE;
use reqwest::header::HeaderMap;
@@ -398,23 +398,6 @@ impl Client {
self.decode_json::<TurnAttemptsSiblingTurnsResponse>(&url, &ct, &body)
}
/// Fetch the managed requirements file from codex-backend.
///
/// `GET /api/codex/config/requirements` (Codex API style) or
/// `GET /wham/config/requirements` (ChatGPT backend-api style).
pub async fn get_config_requirements_file(
&self,
) -> std::result::Result<ConfigFileResponse, RequestError> {
let url = match self.path_style {
PathStyle::CodexApi => format!("{}/api/codex/config/requirements", self.base_url),
PathStyle::ChatGptApi => format!("{}/wham/config/requirements", self.base_url),
};
let req = self.http.get(&url).headers(self.headers());
let (body, ct) = self.exec_request_detailed(req, "GET", &url).await?;
self.decode_json::<ConfigFileResponse>(&url, &ct, &body)
.map_err(RequestError::from)
}
pub async fn list_credential_routes(&self) -> Result<ListCredentialRoutesResponse> {
let url = self.credential_routes_url();
let req = self.http.get(&url).headers(self.headers());
@@ -432,7 +415,6 @@ impl Client {
pub fn credential_routes_proxy_auth_headers(&self) -> HeaderMap {
self.auth_headers()
}
/// Fetch the selected cloud-managed config bundle from codex-backend.
///
/// `GET /api/codex/config/bundle` (Codex API style) or
@@ -494,11 +476,17 @@ impl Client {
.rate_limit_reached_type
.flatten()
.and_then(|details| Self::map_rate_limit_reached_type(details.kind));
let individual_limit = payload
.spend_control
.flatten()
.and_then(|details| details.individual_limit.flatten())
.map(|details| Self::map_individual_limit(*details));
let mut snapshots = vec![Self::make_rate_limit_snapshot(
Some("codex".to_string()),
/*limit_name*/ None,
payload.rate_limit.flatten().map(|details| *details),
payload.credits.flatten().map(|details| *details),
individual_limit,
plan_type,
rate_limit_reached_type,
)];
@@ -509,6 +497,7 @@ impl Client {
Some(details.limit_name),
details.rate_limit.flatten().map(|rate_limit| *rate_limit),
/*credits*/ None,
/*individual_limit*/ None,
plan_type,
/*rate_limit_reached_type*/ None,
)
@@ -522,6 +511,7 @@ impl Client {
limit_name: Option<String>,
rate_limit: Option<crate::types::RateLimitStatusDetails>,
credits: Option<crate::types::CreditStatusDetails>,
individual_limit: Option<SpendControlLimitSnapshot>,
plan_type: Option<AccountPlanType>,
rate_limit_reached_type: Option<RateLimitReachedType>,
) -> RateLimitSnapshot {
@@ -538,6 +528,7 @@ impl Client {
primary,
secondary,
credits: Self::map_credits(credits),
individual_limit,
plan_type,
rate_limit_reached_type,
}
@@ -613,6 +604,17 @@ impl Client {
})
}
fn map_individual_limit(
details: crate::types::SpendControlLimitDetails,
) -> SpendControlLimitSnapshot {
SpendControlLimitSnapshot {
limit: details.limit,
used: details.used,
remaining_percent: details.remaining_percent,
resets_at: i64::from(details.reset_at),
}
}
fn map_plan_type(plan_type: crate::types::PlanType) -> AccountPlanType {
match plan_type {
crate::types::PlanType::Free => AccountPlanType::Free,
@@ -707,6 +709,23 @@ mod tests {
balance: Some(Some("9.99".to_string())),
..Default::default()
}))),
spend_control: Some(Some(Box::new(
codex_backend_openapi_models::models::SpendControlStatusDetails {
reached: false,
individual_limit: Some(Some(Box::new(
crate::types::SpendControlLimitDetails {
source: None,
limit: "25000".to_string(),
used: "8000".to_string(),
remaining: "17000".to_string(),
used_percent: 32,
remaining_percent: 68,
reset_after_seconds: 3600,
reset_at: 789,
},
))),
},
))),
rate_limit_reached_type: Some(Some(BackendRateLimitReachedType {
kind: RateLimitReachedKind::WorkspaceMemberCreditsDepleted,
})),
@@ -738,6 +757,15 @@ mod tests {
snapshots[0].rate_limit_reached_type,
Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted)
);
assert_eq!(
snapshots[0].individual_limit,
Some(SpendControlLimitSnapshot {
limit: "25000".to_string(),
used: "8000".to_string(),
remaining_percent: 68,
resets_at: 789,
})
);
assert_eq!(snapshots[1].limit_id.as_deref(), Some("codex_other"));
assert_eq!(snapshots[1].limit_name.as_deref(), Some("codex_other"));
@@ -746,6 +774,7 @@ mod tests {
Some(70.0)
);
assert_eq!(snapshots[1].credits, None);
assert_eq!(snapshots[1].individual_limit, None);
assert_eq!(snapshots[1].plan_type, Some(AccountPlanType::Pro));
assert_eq!(snapshots[1].rate_limit_reached_type, None);
}
@@ -761,6 +790,7 @@ mod tests {
rate_limit: None,
}])),
credits: None,
spend_control: None,
rate_limit_reached_type: None,
};
@@ -786,6 +816,7 @@ mod tests {
}),
secondary: None,
credits: None,
individual_limit: None,
plan_type: Some(AccountPlanType::Pro),
rate_limit_reached_type: None,
},
@@ -799,6 +830,7 @@ mod tests {
}),
secondary: None,
credits: None,
individual_limit: None,
plan_type: Some(AccountPlanType::Pro),
rate_limit_reached_type: None,
},
@@ -843,6 +875,7 @@ mod tests {
plan_type: crate::types::PlanType::Plus,
rate_limit: None,
credits: None,
spend_control: None,
additional_rate_limits: None,
rate_limit_reached_type: Some(Some(BackendRateLimitReachedType { kind })),
};
@@ -858,6 +891,7 @@ mod tests {
plan_type: crate::types::PlanType::Plus,
rate_limit: None,
credits: None,
spend_control: None,
additional_rate_limits: None,
rate_limit_reached_type: None,
};
-1
View File
@@ -7,7 +7,6 @@ pub use client::RequestError;
pub use types::CodeTaskDetailsResponse;
pub use types::CodeTaskDetailsResponseExt;
pub use types::ConfigBundleResponse;
pub use types::ConfigFileResponse;
pub use types::CredentialRouteAuthType;
pub use types::DeliveredConfigToml;
pub use types::DeliveredRequirementsToml;
+1 -1
View File
@@ -1,5 +1,4 @@
pub use codex_backend_openapi_models::models::ConfigBundleResponse;
pub use codex_backend_openapi_models::models::ConfigFileResponse;
pub use codex_backend_openapi_models::models::CreditStatusDetails;
pub use codex_backend_openapi_models::models::DeliveredConfigToml;
pub use codex_backend_openapi_models::models::DeliveredRequirementsToml;
@@ -10,6 +9,7 @@ pub use codex_backend_openapi_models::models::RateLimitReachedKind;
pub use codex_backend_openapi_models::models::RateLimitStatusDetails;
pub use codex_backend_openapi_models::models::RateLimitStatusPayload;
pub use codex_backend_openapi_models::models::RateLimitWindowSnapshot;
pub use codex_backend_openapi_models::models::SpendControlLimitDetails;
pub use codex_backend_openapi_models::models::TaskListItem;
use serde::Deserialize;

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