mirror of
https://github.com/openai/codex.git
synced 2026-10-27 18:48:37 +00:00
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:
@@ -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}"
|
||||
@@ -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
|
||||
|
||||
@@ -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[@]}"
|
||||
|
||||
Executable
+147
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Generated
+13
-3
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
+8
-1
@@ -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
@@ -1590,6 +1590,13 @@
|
||||
"cwd": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"environmentId": {
|
||||
"default": null,
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"itemId": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1998,4 +2005,4 @@
|
||||
}
|
||||
],
|
||||
"title": "ServerRequest"
|
||||
}
|
||||
}
|
||||
|
||||
+54
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+46
@@ -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": [
|
||||
{
|
||||
|
||||
+36
@@ -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": [
|
||||
{
|
||||
|
||||
+35
@@ -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": {
|
||||
|
||||
+7
@@ -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, } });
|
||||
|
||||
+1
-1
@@ -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, };
|
||||
|
||||
+5
@@ -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, ¶ms.environment_id)?;
|
||||
let response = send_client_management_request(
|
||||
auth_manager,
|
||||
ClientManagementRequest::List {
|
||||
url: &url,
|
||||
params: ¶ms,
|
||||
},
|
||||
"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, ¶ms.environment_id)?;
|
||||
url.path_segments_mut()
|
||||
.map_err(|()| {
|
||||
io::Error::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"remote control URL cannot be a base",
|
||||
)
|
||||
})?
|
||||
.push(¶ms.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) = ¶ms.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,
|
||||
¤t_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,
|
||||
¤t_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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
+79
@@ -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
@@ -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>(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,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
Reference in New Issue
Block a user