mirror of
https://github.com/openai/codex.git
synced 2026-06-03 11:52:03 +00:00
Compare commits
17 Commits
won-fix-ap
...
bot/update
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1441b32d54 | ||
|
|
d36a3ead3c | ||
|
|
98a62a62ce | ||
|
|
1fd2a6d328 | ||
|
|
271d5cecf2 | ||
|
|
a28b32a835 | ||
|
|
6471f8b31a | ||
|
|
2d385e166c | ||
|
|
34dc08c214 | ||
|
|
bec21c7114 | ||
|
|
f752b25fc4 | ||
|
|
c6d76750e8 | ||
|
|
d55e5a9bde | ||
|
|
68e2c8ed69 | ||
|
|
e7039f9844 | ||
|
|
f6d64bd6ab | ||
|
|
c74be11672 |
46
.bazelrc
46
.bazelrc
@@ -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}"
|
||||
126
.github/scripts/run-bazel-ci.sh
vendored
126
.github/scripts/run-bazel-ci.sh
vendored
@@ -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
|
||||
|
||||
56
.github/scripts/run-bazel-query-ci.sh
vendored
56
.github/scripts/run-bazel-query-ci.sh
vendored
@@ -2,48 +2,17 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Run Bazel queries with the same CI startup settings as the main build/test
|
||||
# invocation so target-discovery queries can reuse the same Bazel server.
|
||||
# Run target-discovery queries with the same startup settings as the main
|
||||
# build/test invocation so they can reuse the same Bazel server. Queries only
|
||||
# enumerate labels, so they intentionally do not select CI or remote configs.
|
||||
|
||||
query_args=()
|
||||
windows_cross_compile=0
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--windows-cross-compile)
|
||||
windows_cross_compile=1
|
||||
shift
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
break
|
||||
;;
|
||||
*)
|
||||
query_args+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
echo "Usage: $0 [--windows-cross-compile] [<bazel query args>...] -- <query expression>" >&2
|
||||
if [[ $# -lt 2 || "${@: -2:1}" != "--" ]]; then
|
||||
echo "Usage: $0 [<bazel query args>...] -- <query expression>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
query_expression="$1"
|
||||
|
||||
ci_config=ci-linux
|
||||
case "${RUNNER_OS:-}" in
|
||||
macOS)
|
||||
ci_config=ci-macos
|
||||
;;
|
||||
Windows)
|
||||
if [[ $windows_cross_compile -eq 1 ]]; then
|
||||
ci_config=ci-windows-cross
|
||||
else
|
||||
ci_config=ci-windows
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
query_args=("${@:1:$#-2}")
|
||||
query_expression="${@: -1}"
|
||||
|
||||
bazel_startup_args=()
|
||||
if [[ -n "${BAZEL_OUTPUT_USER_ROOT:-}" ]]; then
|
||||
@@ -60,12 +29,6 @@ run_bazel() {
|
||||
}
|
||||
|
||||
bazel_query_args=(--noexperimental_remote_repo_contents_cache query)
|
||||
if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then
|
||||
bazel_query_args+=(
|
||||
"--config=${ci_config}"
|
||||
"--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}"
|
||||
)
|
||||
fi
|
||||
|
||||
if [[ -n "${BAZEL_REPO_CONTENTS_CACHE:-}" ]]; then
|
||||
bazel_query_args+=("--repo_contents_cache=${BAZEL_REPO_CONTENTS_CACHE}")
|
||||
@@ -75,7 +38,10 @@ if [[ -n "${BAZEL_REPOSITORY_CACHE:-}" ]]; then
|
||||
bazel_query_args+=("--repository_cache=${BAZEL_REPOSITORY_CACHE}")
|
||||
fi
|
||||
|
||||
bazel_query_args+=("${query_args[@]}" "$query_expression")
|
||||
if (( ${#query_args[@]} > 0 )); then
|
||||
bazel_query_args+=("${query_args[@]}")
|
||||
fi
|
||||
bazel_query_args+=("$query_expression")
|
||||
|
||||
if (( ${#bazel_startup_args[@]} > 0 )); then
|
||||
run_bazel "${bazel_startup_args[@]}" "${bazel_query_args[@]}"
|
||||
|
||||
147
.github/scripts/run_bazel_with_buildbuddy.py
vendored
Executable file
147
.github/scripts/run_bazel_with_buildbuddy.py
vendored
Executable file
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
OPENAI_REPOSITORY = "openai/codex"
|
||||
# Remote configurations select cache/BES/download endpoints. Their -rbe forms
|
||||
# also select the matching remote executor endpoint.
|
||||
GENERIC_REMOTE_CONFIG = "buildbuddy-generic"
|
||||
OPENAI_REMOTE_CONFIG = "buildbuddy-openai"
|
||||
# These CI configurations require remote build execution. The wrapper supplies
|
||||
# an RBE configuration, which also includes the common `remote` settings.
|
||||
REMOTE_EXECUTION_CONFIGS = {
|
||||
"--config=ci-linux",
|
||||
"--config=ci-macos",
|
||||
"--config=ci-v8",
|
||||
"--config=ci-windows-cross",
|
||||
}
|
||||
# Only authenticated workflow runs executing trusted upstream code may use the
|
||||
# OpenAI BuildBuddy host. A pull request event without proof that its head is
|
||||
# in the upstream repository fails closed to the generic host.
|
||||
def is_trusted_upstream_run(env: Mapping[str, str]) -> bool:
|
||||
# `GITHUB_REPOSITORY` is easy to set locally. Requiring GitHub's workflow
|
||||
# marker prevents a local command from opting itself into the OpenAI host.
|
||||
if (
|
||||
env.get("GITHUB_ACTIONS") != "true"
|
||||
or env.get("GITHUB_REPOSITORY") != OPENAI_REPOSITORY
|
||||
):
|
||||
return False
|
||||
# Non-PR workflow runs in `openai/codex` execute upstream refs, so they are
|
||||
# trusted. Fork code reaches these workflows only through pull requests.
|
||||
if env.get("GITHUB_EVENT_NAME") != "pull_request":
|
||||
return True
|
||||
|
||||
event_path = env.get("GITHUB_EVENT_PATH")
|
||||
if not event_path:
|
||||
return False
|
||||
try:
|
||||
event = json.loads(Path(event_path).read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return False
|
||||
|
||||
try:
|
||||
return event["pull_request"]["head"]["repo"]["fork"] is False
|
||||
except (KeyError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def uses_openai_host(env: Mapping[str, str]) -> bool:
|
||||
return bool(env.get("BUILDBUDDY_API_KEY")) and is_trusted_upstream_run(env)
|
||||
|
||||
|
||||
def uses_remote_execution(args: Sequence[str]) -> bool:
|
||||
try:
|
||||
separator_idx = args.index("--")
|
||||
except ValueError:
|
||||
separator_idx = len(args)
|
||||
return any(arg in REMOTE_EXECUTION_CONFIGS for arg in args[:separator_idx])
|
||||
|
||||
|
||||
def remote_config(args: Sequence[str], env: Mapping[str, str]) -> str | None:
|
||||
if not env.get("BUILDBUDDY_API_KEY"):
|
||||
return None
|
||||
|
||||
config = OPENAI_REMOTE_CONFIG if uses_openai_host(env) else GENERIC_REMOTE_CONFIG
|
||||
if uses_remote_execution(args):
|
||||
config += "-rbe"
|
||||
return config
|
||||
|
||||
|
||||
def bazel_args_without_remote_execution(args: Sequence[str]) -> list[str]:
|
||||
# Remote CI configs require BuildBuddy credentials. Removing them preserves
|
||||
# the local fallback used for fork pull requests.
|
||||
try:
|
||||
separator_idx = args.index("--")
|
||||
except ValueError:
|
||||
separator_idx = len(args)
|
||||
return [
|
||||
*(arg for arg in args[:separator_idx] if arg not in REMOTE_EXECUTION_CONFIGS),
|
||||
*args[separator_idx:],
|
||||
]
|
||||
|
||||
|
||||
def bazel_args_with_remote_config(
|
||||
args: Sequence[str], env: Mapping[str, str]
|
||||
) -> list[str]:
|
||||
config = remote_config(args, env)
|
||||
if config is None:
|
||||
return bazel_args_without_remote_execution(args)
|
||||
|
||||
# `remote_config()` returns a configuration only when this key is present.
|
||||
api_key = env["BUILDBUDDY_API_KEY"]
|
||||
remote_args = [
|
||||
f"--config={config}",
|
||||
f"--remote_header=x-buildbuddy-api-key={api_key}",
|
||||
]
|
||||
|
||||
# Insert immediately after the Bazel command. This keeps wrapper-added
|
||||
# options out of positional payloads and lets later CI configs override
|
||||
# shared RBE defaults such as the Windows cross-compilation exec platforms.
|
||||
insertion_idx = next(
|
||||
(idx + 1 for idx, arg in enumerate(args) if not arg.startswith("-")),
|
||||
len(args),
|
||||
)
|
||||
return [*args[:insertion_idx], *remote_args, *args[insertion_idx:]]
|
||||
|
||||
|
||||
def bazel_command(*args: str, env: Mapping[str, str] | None = None) -> list[str]:
|
||||
env = os.environ if env is None else env
|
||||
bazel = env.get("CODEX_BAZEL_BIN", "bazel")
|
||||
return [bazel, *bazel_args_with_remote_config(args, env)]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
config = remote_config(sys.argv[1:], os.environ)
|
||||
if config is None:
|
||||
print(
|
||||
"BuildBuddy key unavailable; using local Bazel configuration.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
host_description = (
|
||||
"OpenAI tenant" if uses_openai_host(os.environ) else "generic"
|
||||
)
|
||||
print(
|
||||
f"Using {host_description} BuildBuddy configuration: {config}.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
command = bazel_command(*sys.argv[1:])
|
||||
if os.name == "nt":
|
||||
# Windows CRT exec can split arguments containing spaces and lose the
|
||||
# eventual child exit status. Wait for Bazel and propagate its status.
|
||||
result = subprocess.run(command, check=False)
|
||||
raise SystemExit(result.returncode)
|
||||
|
||||
os.execvp(command[0], command)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
63
.github/scripts/rusty_v8_bazel.py
vendored
63
.github/scripts/rusty_v8_bazel.py
vendored
@@ -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")
|
||||
|
||||
214
.github/scripts/test_run_bazel_with_buildbuddy.py
vendored
Normal file
214
.github/scripts/test_run_bazel_with_buildbuddy.py
vendored
Normal file
@@ -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()
|
||||
49
.github/scripts/test_rusty_v8_bazel.py
vendored
49
.github/scripts/test_rusty_v8_bazel.py
vendored
@@ -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")
|
||||
|
||||
13
.github/workflows/bazel.yml
vendored
13
.github/workflows/bazel.yml
vendored
@@ -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
|
||||
|
||||
153
.github/workflows/python-sdk-release.yml
vendored
153
.github/workflows/python-sdk-release.yml
vendored
@@ -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,11 +175,6 @@ 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
|
||||
|
||||
# Build in a glibc Linux image so release type generation installs
|
||||
# the pinned manylinux runtime wheel.
|
||||
docker run --rm \
|
||||
|
||||
51
.github/workflows/rust-release-windows.yml
vendored
51
.github/workflows/rust-release-windows.yml
vendored
@@ -6,6 +6,19 @@ on:
|
||||
release-lto:
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
AZURE_TRUSTED_SIGNING_CLIENT_ID:
|
||||
required: true
|
||||
AZURE_TRUSTED_SIGNING_TENANT_ID:
|
||||
required: true
|
||||
AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID:
|
||||
required: true
|
||||
AZURE_TRUSTED_SIGNING_ENDPOINT:
|
||||
required: true
|
||||
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME:
|
||||
required: true
|
||||
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.
|
||||
@@ -26,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
|
||||
@@ -121,22 +132,10 @@ jobs:
|
||||
- name: Stage Windows binaries
|
||||
shell: bash
|
||||
run: |
|
||||
release_dir="target/${{ matrix.target }}/release"
|
||||
output_dir="$release_dir/staged-${{ matrix.bundle }}"
|
||||
output_dir="target/${{ matrix.target }}/release/staged-${{ matrix.bundle }}"
|
||||
mkdir -p "$output_dir"
|
||||
for binary in ${{ matrix.binaries }}; do
|
||||
pdb_name="${binary//-/_}"
|
||||
pdb_path="$release_dir/${pdb_name}.pdb"
|
||||
if [[ ! -f "$pdb_path" ]]; then
|
||||
pdb_path="$release_dir/${binary}.pdb"
|
||||
fi
|
||||
if [[ ! -f "$pdb_path" ]]; then
|
||||
echo "PDB for $binary not found at $release_dir/${pdb_name}.pdb or $release_dir/${binary}.pdb" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cp "$release_dir/${binary}.exe" "$output_dir/${binary}.exe"
|
||||
cp "$pdb_path" "$output_dir/${binary}.pdb"
|
||||
cp "target/${{ matrix.target }}/release/${binary}.exe" "$output_dir/${binary}.exe"
|
||||
done
|
||||
|
||||
- name: Upload Windows binaries
|
||||
@@ -151,9 +150,6 @@ jobs:
|
||||
- build-windows-binaries
|
||||
name: Build - ${{ matrix.runner }} - ${{ matrix.target }}
|
||||
runs-on: ${{ matrix.runs_on }}
|
||||
environment:
|
||||
name: azure-artifact-signing
|
||||
deployment: false
|
||||
timeout-minutes: 90
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -222,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: |
|
||||
|
||||
111
.github/workflows/rust-release.yml
vendored
111
.github/workflows/rust-release.yml
vendored
@@ -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
|
||||
@@ -865,6 +827,7 @@ jobs:
|
||||
uses: ./.github/workflows/rust-release-windows.yml
|
||||
with:
|
||||
release-lto: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'fat' }}
|
||||
secrets: inherit
|
||||
|
||||
argument-comment-lint-release-assets:
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }}
|
||||
@@ -928,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
|
||||
@@ -1068,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' \
|
||||
@@ -1153,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
|
||||
@@ -1438,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
.github/workflows/rusty-v8-release.yml
vendored
5
.github/workflows/rusty-v8-release.yml
vendored
@@ -191,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:
|
||||
|
||||
7
.github/workflows/v8-canary.yml
vendored
7
.github/workflows/v8-canary.yml
vendored
@@ -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"
|
||||
@@ -203,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:
|
||||
|
||||
10
codex-rs/Cargo.lock
generated
10
codex-rs/Cargo.lock
generated
@@ -2347,7 +2347,6 @@ dependencies = [
|
||||
name = "codex-cloud-config"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"codex-backend-client",
|
||||
@@ -3716,6 +3715,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",
|
||||
|
||||
@@ -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,22 +2936,42 @@ 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_pairing_start_with_experimental_api() -> Result<()> {
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -849,6 +849,18 @@ client_request_definitions! {
|
||||
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" {
|
||||
@@ -1994,6 +2006,29 @@ mod tests {
|
||||
"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]
|
||||
|
||||
@@ -62,6 +62,62 @@ pub struct RemoteControlPairingStartResponse {
|
||||
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/")]
|
||||
@@ -105,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!({})
|
||||
);
|
||||
}
|
||||
@@ -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,3 +1,4 @@
|
||||
use super::auth::RemoteControlConnectionAuth;
|
||||
use super::pairing_unavailable_error;
|
||||
use super::protocol::EnrollRemoteServerRequest;
|
||||
use super::protocol::EnrollRemoteServerResponse;
|
||||
@@ -6,7 +7,6 @@ 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;
|
||||
@@ -151,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,
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
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;
|
||||
@@ -17,6 +21,10 @@ 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;
|
||||
@@ -57,6 +65,7 @@ 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>,
|
||||
}
|
||||
@@ -146,7 +155,7 @@ impl RemoteControlHandle {
|
||||
if !*self.enabled_tx.borrow() {
|
||||
return Err(Self::pairing_disabled_error());
|
||||
}
|
||||
let mut auth = websocket::load_remote_control_auth(&self.auth_manager)
|
||||
let mut auth = load_remote_control_auth(&self.auth_manager)
|
||||
.await
|
||||
.map_err(|_| pairing_unavailable_error())?;
|
||||
let mut enrollment = {
|
||||
@@ -205,7 +214,7 @@ impl RemoteControlHandle {
|
||||
if !*self.enabled_tx.borrow() {
|
||||
return Err(Self::pairing_disabled_error());
|
||||
}
|
||||
let current_auth = websocket::load_remote_control_auth(&self.auth_manager)
|
||||
let current_auth = load_remote_control_auth(&self.auth_manager)
|
||||
.await
|
||||
.map_err(|_| pairing_unavailable_error())?;
|
||||
if current_auth.account_id != auth.account_id {
|
||||
@@ -214,6 +223,22 @@ impl RemoteControlHandle {
|
||||
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,
|
||||
@@ -255,7 +280,7 @@ impl RemoteControlHandle {
|
||||
async fn refresh_pairing_enrollment(
|
||||
current_enrollment: &CurrentRemoteControlEnrollment,
|
||||
auth_manager: &Arc<AuthManager>,
|
||||
auth: &mut enroll::RemoteControlConnectionAuth,
|
||||
auth: &mut auth::RemoteControlConnectionAuth,
|
||||
installation_id: &str,
|
||||
enrollment: &mut RemoteControlEnrollment,
|
||||
) -> io::Result<()> {
|
||||
@@ -265,10 +290,10 @@ async fn refresh_pairing_enrollment(
|
||||
}
|
||||
let mut auth_recovery = auth_manager.unauthorized_recovery();
|
||||
let mut auth_change_rx = auth_manager.auth_change_receiver();
|
||||
if !websocket::recover_remote_control_auth(&mut auth_recovery, &mut auth_change_rx).await {
|
||||
if !recover_remote_control_auth(&mut auth_recovery, &mut auth_change_rx).await {
|
||||
return Err(err);
|
||||
}
|
||||
*auth = websocket::load_remote_control_auth(auth_manager)
|
||||
*auth = load_remote_control_auth(auth_manager)
|
||||
.await
|
||||
.map_err(|_| pairing_unavailable_error())?;
|
||||
if auth.account_id != enrollment.account_id {
|
||||
@@ -440,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();
|
||||
@@ -508,6 +534,7 @@ 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,
|
||||
},
|
||||
|
||||
@@ -177,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,
|
||||
@@ -198,35 +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 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)?;
|
||||
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(),
|
||||
pair_url: pair_url.to_string(),
|
||||
})
|
||||
Ok(remote_control_url)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -60,6 +60,7 @@ 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";
|
||||
@@ -163,6 +164,7 @@ fn remote_control_handle_with_current_enrollment(
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -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:"));
|
||||
}
|
||||
@@ -13,9 +13,11 @@ 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;
|
||||
@@ -1172,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;
|
||||
@@ -1548,52 +1505,6 @@ async fn enroll_remote_control_server_if_missing(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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 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,
|
||||
@@ -1620,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;
|
||||
|
||||
@@ -211,6 +211,8 @@ Example with notification opt-out:
|
||||
- `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**).
|
||||
|
||||
@@ -922,6 +922,16 @@ impl MessageProcessor {
|
||||
.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()
|
||||
|
||||
@@ -3,6 +3,10 @@ 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;
|
||||
@@ -55,6 +59,26 @@ impl RemoteControlRequestProcessor {
|
||||
.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()
|
||||
@@ -74,5 +98,15 @@ fn map_pairing_start_error(err: io::Error) -> JSONRPCErrorError {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -46,3 +46,34 @@ fn pairing_start_maps_backend_failures_to_internal_error() {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[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(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,6 +67,8 @@ 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;
|
||||
@@ -654,6 +656,25 @@ impl TestAppServer {
|
||||
.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)?);
|
||||
|
||||
@@ -30,6 +30,12 @@ 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))]
|
||||
@@ -69,7 +75,7 @@ async fn standalone_image_generation_persists_image_and_returns_it_to_model() ->
|
||||
.await;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri())?;
|
||||
create_config_toml(codex_home.path(), &server.uri(), ImagegenTestMode::Direct)?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("access-chatgpt"),
|
||||
@@ -79,34 +85,7 @@ async fn standalone_image_generation_persists_image_and_returns_it_to_model() ->
|
||||
let mut mcp =
|
||||
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
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)?;
|
||||
start_image_generation_turn(&mut mcp).await?;
|
||||
|
||||
let completed = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
@@ -157,6 +136,110 @@ async fn standalone_image_generation_persists_image_and_returns_it_to_model() ->
|
||||
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> {
|
||||
@@ -187,7 +270,15 @@ async fn mount_image_response(server: &MockServer) {
|
||||
.await;
|
||||
}
|
||||
|
||||
fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> {
|
||||
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!(
|
||||
@@ -200,6 +291,7 @@ chatgpt_base_url = "{server_uri}"
|
||||
|
||||
[features]
|
||||
imagegenext = true
|
||||
{code_mode_only}
|
||||
|
||||
[model_providers.openai-custom]
|
||||
name = "OpenAI"
|
||||
@@ -17,7 +17,7 @@ mod experimental_feature_list;
|
||||
mod external_agent_config;
|
||||
mod fs;
|
||||
mod hooks_list;
|
||||
mod image_generation;
|
||||
mod imagegen_extension;
|
||||
mod initialize;
|
||||
mod marketplace_add;
|
||||
mod marketplace_remove;
|
||||
|
||||
@@ -8,6 +8,12 @@ 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;
|
||||
@@ -187,11 +193,130 @@ async fn remote_control_pairing_start_returns_pairing_artifacts() -> Result<()>
|
||||
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 = configured_remote_control_listener(codex_home).await?;
|
||||
@@ -303,6 +428,12 @@ impl Drop for BlockingRemoteControlBackend {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ClientManagementRemoteControlBackend {
|
||||
fn drop(&mut self) {
|
||||
self.server_task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
struct HttpRequest {
|
||||
request_line: String,
|
||||
reader: BufReader<TcpStream>,
|
||||
@@ -365,3 +496,16 @@ async fn respond_with_json(stream: TcpStream, body: serde_json::Value) -> Result
|
||||
.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(())
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ license.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
async-trait = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
codex-backend-client = { workspace = true }
|
||||
|
||||
136
codex-rs/cloud-config/src/backend.rs
Normal file
136
codex-rs/cloud-config/src/backend.rs
Normal file
@@ -0,0 +1,136 @@
|
||||
use codex_backend_client::Client as BackendClient;
|
||||
use codex_backend_client::ConfigBundleResponse;
|
||||
use codex_backend_client::DeliveredTomlFragment;
|
||||
use codex_config::CloudConfigBundle;
|
||||
use codex_config::CloudConfigFragment;
|
||||
use codex_config::CloudConfigTomlBundle;
|
||||
use codex_config::CloudRequirementsFragment;
|
||||
use codex_config::CloudRequirementsTomlBundle;
|
||||
use codex_login::CodexAuth;
|
||||
use std::future::Future;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum RetryableFailureKind {
|
||||
BackendClientInit,
|
||||
Request { status_code: Option<u16> },
|
||||
}
|
||||
|
||||
impl RetryableFailureKind {
|
||||
pub(crate) fn status_code(self) -> Option<u16> {
|
||||
match self {
|
||||
Self::BackendClientInit => None,
|
||||
Self::Request { status_code } => status_code,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum BundleRequestError {
|
||||
Retryable(RetryableFailureKind),
|
||||
Unauthorized {
|
||||
status_code: Option<u16>,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Retrieves one cloud config bundle from the backend.
|
||||
///
|
||||
/// Implementations should return the backend-selected bundle exactly as delivered and leave
|
||||
/// validation, caching, and config/requirements parsing decisions to the service layer.
|
||||
pub(crate) trait BundleClient: Send + Sync {
|
||||
fn get_bundle(
|
||||
&self,
|
||||
auth: &CodexAuth,
|
||||
) -> impl Future<Output = Result<CloudConfigBundle, BundleRequestError>> + Send;
|
||||
}
|
||||
|
||||
pub(crate) struct BackendBundleClient {
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl BackendBundleClient {
|
||||
pub(crate) fn new(base_url: String) -> Self {
|
||||
Self { base_url }
|
||||
}
|
||||
}
|
||||
|
||||
impl BundleClient for BackendBundleClient {
|
||||
async fn get_bundle(&self, auth: &CodexAuth) -> Result<CloudConfigBundle, BundleRequestError> {
|
||||
let client = BackendClient::from_auth(self.base_url.clone(), auth)
|
||||
.inspect_err(|err| {
|
||||
tracing::warn!(
|
||||
error = %err,
|
||||
"Failed to construct backend client for cloud config bundle"
|
||||
);
|
||||
})
|
||||
.map_err(|_| BundleRequestError::Retryable(RetryableFailureKind::BackendClientInit))?;
|
||||
|
||||
let response = client
|
||||
.get_config_bundle()
|
||||
.await
|
||||
.inspect_err(|err| {
|
||||
tracing::warn!(error = %err, "Failed to fetch cloud config bundle");
|
||||
})
|
||||
.map_err(|err| {
|
||||
let status_code = err.status().map(|status| status.as_u16());
|
||||
if err.is_unauthorized() {
|
||||
BundleRequestError::Unauthorized {
|
||||
status_code,
|
||||
message: err.to_string(),
|
||||
}
|
||||
} else {
|
||||
BundleRequestError::Retryable(RetryableFailureKind::Request { status_code })
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(bundle_from_response(response))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn bundle_from_response(response: ConfigBundleResponse) -> CloudConfigBundle {
|
||||
let config_toml = response
|
||||
.config_toml
|
||||
.flatten()
|
||||
.map(|config_toml| *config_toml)
|
||||
.and_then(|config_toml| config_toml.enterprise_managed.flatten())
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(config_fragment_from_delivered)
|
||||
.collect();
|
||||
let requirements_toml = response
|
||||
.requirements_toml
|
||||
.flatten()
|
||||
.map(|requirements_toml| *requirements_toml)
|
||||
.and_then(|requirements_toml| requirements_toml.enterprise_managed.flatten())
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(requirements_fragment_from_delivered)
|
||||
.collect();
|
||||
|
||||
CloudConfigBundle {
|
||||
config_toml: CloudConfigTomlBundle {
|
||||
enterprise_managed: config_toml,
|
||||
},
|
||||
requirements_toml: CloudRequirementsTomlBundle {
|
||||
enterprise_managed: requirements_toml,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn config_fragment_from_delivered(fragment: DeliveredTomlFragment) -> CloudConfigFragment {
|
||||
CloudConfigFragment {
|
||||
id: fragment.id,
|
||||
name: fragment.name,
|
||||
contents: fragment.contents,
|
||||
}
|
||||
}
|
||||
|
||||
fn requirements_fragment_from_delivered(
|
||||
fragment: DeliveredTomlFragment,
|
||||
) -> CloudRequirementsFragment {
|
||||
CloudRequirementsFragment {
|
||||
id: fragment.id,
|
||||
name: fragment.name,
|
||||
contents: fragment.contents,
|
||||
}
|
||||
}
|
||||
68
codex-rs/cloud-config/src/bundle_loader.rs
Normal file
68
codex-rs/cloud-config/src/bundle_loader.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
use crate::backend::BackendBundleClient;
|
||||
use crate::service::CLOUD_CONFIG_BUNDLE_TIMEOUT;
|
||||
use crate::service::CloudConfigBundleService;
|
||||
use codex_config::CloudConfigBundleLoadError;
|
||||
use codex_config::CloudConfigBundleLoadErrorCode;
|
||||
use codex_config::CloudConfigBundleLoader;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use codex_login::AuthManager;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::OnceLock;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
fn refresher_task_slot() -> &'static Mutex<Option<JoinHandle<()>>> {
|
||||
static REFRESHER_TASK: OnceLock<Mutex<Option<JoinHandle<()>>>> = OnceLock::new();
|
||||
REFRESHER_TASK.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
pub fn cloud_config_bundle_loader(
|
||||
auth_manager: Arc<AuthManager>,
|
||||
chatgpt_base_url: String,
|
||||
codex_home: PathBuf,
|
||||
) -> CloudConfigBundleLoader {
|
||||
let service = CloudConfigBundleService::new(
|
||||
auth_manager,
|
||||
Arc::new(BackendBundleClient::new(chatgpt_base_url)),
|
||||
codex_home,
|
||||
CLOUD_CONFIG_BUNDLE_TIMEOUT,
|
||||
);
|
||||
let refresh_service = service.clone();
|
||||
let task = tokio::spawn(async move { service.load_startup_bundle_with_timeout().await });
|
||||
let refresh_task =
|
||||
tokio::spawn(async move { refresh_service.refresh_cache_in_background().await });
|
||||
let mut refresher_guard = refresher_task_slot().lock().unwrap_or_else(|err| {
|
||||
tracing::warn!("cloud config bundle refresher task slot was poisoned");
|
||||
err.into_inner()
|
||||
});
|
||||
if let Some(existing_task) = refresher_guard.replace(refresh_task) {
|
||||
existing_task.abort();
|
||||
}
|
||||
CloudConfigBundleLoader::new(async move {
|
||||
task.await.map_err(|err| {
|
||||
tracing::error!(error = %err, "Cloud config bundle task failed");
|
||||
CloudConfigBundleLoadError::new(
|
||||
CloudConfigBundleLoadErrorCode::Internal,
|
||||
/*status_code*/ None,
|
||||
format!("cloud config bundle load failed: {err}"),
|
||||
)
|
||||
})?
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn cloud_config_bundle_loader_for_storage(
|
||||
codex_home: PathBuf,
|
||||
enable_codex_api_key_env: bool,
|
||||
credentials_store_mode: AuthCredentialsStoreMode,
|
||||
chatgpt_base_url: String,
|
||||
) -> CloudConfigBundleLoader {
|
||||
let auth_manager = AuthManager::shared(
|
||||
codex_home.clone(),
|
||||
enable_codex_api_key_env,
|
||||
credentials_store_mode,
|
||||
Some(chatgpt_base_url.clone()),
|
||||
)
|
||||
.await;
|
||||
cloud_config_bundle_loader(auth_manager, chatgpt_base_url, codex_home)
|
||||
}
|
||||
253
codex-rs/cloud-config/src/cache.rs
Normal file
253
codex-rs/cloud-config/src/cache.rs
Normal file
@@ -0,0 +1,253 @@
|
||||
//! Signed on-disk cache for cloud config bundles.
|
||||
//!
|
||||
//! The cache is scoped to the authenticated ChatGPT user and account, has a
|
||||
//! short TTL, and is HMAC-signed so malformed or edited files fail closed.
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use chrono::DateTime;
|
||||
use chrono::Duration as ChronoDuration;
|
||||
use chrono::Utc;
|
||||
use codex_config::AbsolutePathBuf;
|
||||
use codex_config::CloudConfigBundle;
|
||||
use hmac::Hmac;
|
||||
use hmac::Mac;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use sha2::Sha256;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use thiserror::Error;
|
||||
use tokio::fs;
|
||||
|
||||
const CLOUD_CONFIG_BUNDLE_CACHE_VERSION: u32 = 1;
|
||||
pub(super) const CLOUD_CONFIG_BUNDLE_CACHE_FILENAME: &str = "cloud-config-bundle-cache.json";
|
||||
const CLOUD_CONFIG_BUNDLE_CACHE_TTL: Duration = Duration::from_secs(30 * 60);
|
||||
const CLOUD_CONFIG_BUNDLE_CACHE_WRITE_HMAC_KEY: &[u8] =
|
||||
b"codex-cloud-config-bundle-cache-v1-6160ae70-bcfd-4ca8-a99b-40f73b3b072e";
|
||||
const CLOUD_CONFIG_BUNDLE_CACHE_READ_HMAC_KEYS: &[&[u8]] =
|
||||
&[CLOUD_CONFIG_BUNDLE_CACHE_WRITE_HMAC_KEY];
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(super) struct CloudConfigBundleCache {
|
||||
path: AbsolutePathBuf,
|
||||
}
|
||||
|
||||
impl CloudConfigBundleCache {
|
||||
pub(super) fn new(codex_home: AbsolutePathBuf) -> Self {
|
||||
Self {
|
||||
path: codex_home.join(CLOUD_CONFIG_BUNDLE_CACHE_FILENAME),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
pub(super) async fn load(
|
||||
&self,
|
||||
chatgpt_user_id: Option<&str>,
|
||||
account_id: Option<&str>,
|
||||
) -> Result<CloudConfigBundleCacheSignedPayload, CacheLoadStatus> {
|
||||
let (Some(chatgpt_user_id), Some(account_id)) = (chatgpt_user_id, account_id) else {
|
||||
return Err(CacheLoadStatus::AuthIdentityIncomplete);
|
||||
};
|
||||
|
||||
let bytes = match fs::read(&self.path).await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(err) => {
|
||||
if err.kind() != std::io::ErrorKind::NotFound {
|
||||
return Err(CacheLoadStatus::CacheReadFailed(err.to_string()));
|
||||
}
|
||||
return Err(CacheLoadStatus::CacheFileNotFound);
|
||||
}
|
||||
};
|
||||
|
||||
let cache_file: CloudConfigBundleCacheFile = match serde_json::from_slice(&bytes) {
|
||||
Ok(cache_file) => cache_file,
|
||||
Err(err) => {
|
||||
return Err(CacheLoadStatus::CacheParseFailed(err.to_string()));
|
||||
}
|
||||
};
|
||||
let payload_bytes = match cache_payload_bytes(&cache_file.signed_payload) {
|
||||
Some(payload_bytes) => payload_bytes,
|
||||
None => {
|
||||
return Err(CacheLoadStatus::CacheParseFailed(
|
||||
"failed to serialize cache payload".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
if !verify_cache_signature(&payload_bytes, &cache_file.signature) {
|
||||
return Err(CacheLoadStatus::CacheSignatureInvalid);
|
||||
}
|
||||
if cache_file.signed_payload.version != CLOUD_CONFIG_BUNDLE_CACHE_VERSION {
|
||||
return Err(CacheLoadStatus::CacheVersionUnsupported(
|
||||
cache_file.signed_payload.version,
|
||||
));
|
||||
}
|
||||
|
||||
let (Some(cached_chatgpt_user_id), Some(cached_account_id)) = (
|
||||
cache_file.signed_payload.chatgpt_user_id.as_deref(),
|
||||
cache_file.signed_payload.account_id.as_deref(),
|
||||
) else {
|
||||
return Err(CacheLoadStatus::CacheIdentityIncomplete);
|
||||
};
|
||||
|
||||
if cached_chatgpt_user_id != chatgpt_user_id || cached_account_id != account_id {
|
||||
return Err(CacheLoadStatus::CacheIdentityMismatch);
|
||||
}
|
||||
|
||||
if cache_file.signed_payload.expires_at <= Utc::now() {
|
||||
return Err(CacheLoadStatus::CacheExpired);
|
||||
}
|
||||
|
||||
Ok(cache_file.signed_payload)
|
||||
}
|
||||
|
||||
pub(super) fn log_load_status(&self, status: &CacheLoadStatus) {
|
||||
if matches!(status, CacheLoadStatus::CacheFileNotFound) {
|
||||
return;
|
||||
}
|
||||
|
||||
let warn = matches!(
|
||||
status,
|
||||
CacheLoadStatus::CacheReadFailed(_)
|
||||
| CacheLoadStatus::CacheParseFailed(_)
|
||||
| CacheLoadStatus::CacheSignatureInvalid
|
||||
);
|
||||
|
||||
if warn {
|
||||
tracing::warn!(path = %self.path.display(), "{status}");
|
||||
} else {
|
||||
tracing::info!(path = %self.path.display(), "{status}");
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn save(
|
||||
&self,
|
||||
chatgpt_user_id: Option<String>,
|
||||
account_id: Option<String>,
|
||||
bundle: CloudConfigBundle,
|
||||
) -> Result<(), CloudConfigBundleCacheError> {
|
||||
let now = Utc::now();
|
||||
let expires_at = now
|
||||
.checked_add_signed(
|
||||
ChronoDuration::from_std(CLOUD_CONFIG_BUNDLE_CACHE_TTL)
|
||||
.map_err(|_| CloudConfigBundleCacheError)?,
|
||||
)
|
||||
.ok_or(CloudConfigBundleCacheError)?;
|
||||
let signed_payload = CloudConfigBundleCacheSignedPayload {
|
||||
version: CLOUD_CONFIG_BUNDLE_CACHE_VERSION,
|
||||
cached_at: now,
|
||||
expires_at,
|
||||
chatgpt_user_id,
|
||||
account_id,
|
||||
bundle,
|
||||
};
|
||||
let payload_bytes =
|
||||
cache_payload_bytes(&signed_payload).ok_or(CloudConfigBundleCacheError)?;
|
||||
let serialized = serde_json::to_vec_pretty(&CloudConfigBundleCacheFile {
|
||||
signature: sign_cache_payload(&payload_bytes).ok_or(CloudConfigBundleCacheError)?,
|
||||
signed_payload,
|
||||
})
|
||||
.map_err(|_| CloudConfigBundleCacheError)?;
|
||||
|
||||
if let Some(parent) = self.path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.await
|
||||
.map_err(|_| CloudConfigBundleCacheError)?;
|
||||
}
|
||||
|
||||
fs::write(&self.path, serialized)
|
||||
.await
|
||||
.map_err(|_| CloudConfigBundleCacheError)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, Error, PartialEq)]
|
||||
pub(super) enum CacheLoadStatus {
|
||||
#[error("Skipping cloud config bundle cache read because auth identity is incomplete.")]
|
||||
AuthIdentityIncomplete,
|
||||
#[error("Cloud config bundle cache file not found.")]
|
||||
CacheFileNotFound,
|
||||
#[error("Failed to read cloud config bundle cache: {0}.")]
|
||||
CacheReadFailed(String),
|
||||
#[error("Failed to parse cloud config bundle cache: {0}.")]
|
||||
CacheParseFailed(String),
|
||||
#[error("Cloud config bundle cache failed signature verification.")]
|
||||
CacheSignatureInvalid,
|
||||
#[error("Ignoring cloud config bundle cache because cached identity is incomplete.")]
|
||||
CacheIdentityIncomplete,
|
||||
#[error("Ignoring cloud config bundle cache for different auth identity.")]
|
||||
CacheIdentityMismatch,
|
||||
#[error("Ignoring cloud config bundle cache with unsupported version {0}.")]
|
||||
CacheVersionUnsupported(u32),
|
||||
#[error("Cloud config bundle cache expired.")]
|
||||
CacheExpired,
|
||||
#[error("Ignoring cloud config bundle cache because the cached bundle is invalid.")]
|
||||
CacheInvalidBundle,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
#[error("failed to write cloud config bundle cache")]
|
||||
pub(super) struct CloudConfigBundleCacheError;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub(super) struct CloudConfigBundleCacheFile {
|
||||
pub(super) signed_payload: CloudConfigBundleCacheSignedPayload,
|
||||
pub(super) signature: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub(super) struct CloudConfigBundleCacheSignedPayload {
|
||||
pub(super) version: u32,
|
||||
pub(super) cached_at: DateTime<Utc>,
|
||||
pub(super) expires_at: DateTime<Utc>,
|
||||
pub(super) chatgpt_user_id: Option<String>,
|
||||
pub(super) account_id: Option<String>,
|
||||
pub(super) bundle: CloudConfigBundle,
|
||||
}
|
||||
|
||||
pub(super) fn cache_payload_bytes(
|
||||
payload: &CloudConfigBundleCacheSignedPayload,
|
||||
) -> Option<Vec<u8>> {
|
||||
serde_json::to_vec(&payload).ok()
|
||||
}
|
||||
|
||||
pub(super) fn sign_cache_payload(payload_bytes: &[u8]) -> Option<String> {
|
||||
let mut mac = HmacSha256::new_from_slice(CLOUD_CONFIG_BUNDLE_CACHE_WRITE_HMAC_KEY).ok()?;
|
||||
mac.update(payload_bytes);
|
||||
let signature = mac.finalize().into_bytes();
|
||||
Some(BASE64_STANDARD.encode(signature))
|
||||
}
|
||||
|
||||
pub(super) fn verify_cache_signature(payload_bytes: &[u8], signature: &str) -> bool {
|
||||
let signature_bytes = match BASE64_STANDARD.decode(signature) {
|
||||
Ok(signature_bytes) => signature_bytes,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
CLOUD_CONFIG_BUNDLE_CACHE_READ_HMAC_KEYS
|
||||
.iter()
|
||||
.any(|key| verify_cache_signature_with_key(payload_bytes, &signature_bytes, key))
|
||||
}
|
||||
|
||||
fn verify_cache_signature_with_key(
|
||||
payload_bytes: &[u8],
|
||||
signature_bytes: &[u8],
|
||||
key: &[u8],
|
||||
) -> bool {
|
||||
let mut mac = match HmacSha256::new_from_slice(key) {
|
||||
Ok(mac) => mac,
|
||||
Err(_) => return false,
|
||||
};
|
||||
mac.update(payload_bytes);
|
||||
mac.verify_slice(signature_bytes).is_ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "cache_tests.rs"]
|
||||
mod tests;
|
||||
206
codex-rs/cloud-config/src/cache_tests.rs
Normal file
206
codex-rs/cloud-config/src/cache_tests.rs
Normal file
@@ -0,0 +1,206 @@
|
||||
use super::*;
|
||||
use codex_config::AbsolutePathBuf;
|
||||
use codex_config::CloudConfigFragment;
|
||||
use codex_config::CloudConfigTomlBundle;
|
||||
use codex_config::CloudRequirementsFragment;
|
||||
use codex_config::CloudRequirementsTomlBundle;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::Path;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn test_bundle() -> CloudConfigBundle {
|
||||
CloudConfigBundle {
|
||||
config_toml: CloudConfigTomlBundle {
|
||||
enterprise_managed: vec![CloudConfigFragment {
|
||||
id: "cfg_1".to_string(),
|
||||
name: "Base config".to_string(),
|
||||
contents: "model = \"gpt-5\"".to_string(),
|
||||
}],
|
||||
},
|
||||
requirements_toml: CloudRequirementsTomlBundle {
|
||||
enterprise_managed: vec![CloudRequirementsFragment {
|
||||
id: "req_1".to_string(),
|
||||
name: "Base requirements".to_string(),
|
||||
contents: "allowed_approval_policies = [\"never\"]".to_string(),
|
||||
}],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn signed_cache_file(
|
||||
signed_payload: CloudConfigBundleCacheSignedPayload,
|
||||
) -> CloudConfigBundleCacheFile {
|
||||
let payload_bytes = cache_payload_bytes(&signed_payload).expect("payload bytes");
|
||||
CloudConfigBundleCacheFile {
|
||||
signature: sign_cache_payload(&payload_bytes).expect("signature"),
|
||||
signed_payload,
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_signed_payload() -> CloudConfigBundleCacheSignedPayload {
|
||||
let cached_at = Utc::now();
|
||||
CloudConfigBundleCacheSignedPayload {
|
||||
version: CLOUD_CONFIG_BUNDLE_CACHE_VERSION,
|
||||
cached_at,
|
||||
expires_at: cached_at + ChronoDuration::minutes(30),
|
||||
chatgpt_user_id: Some("user-12345".to_string()),
|
||||
account_id: Some("account-12345".to_string()),
|
||||
bundle: test_bundle(),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_cache_file(cache: &CloudConfigBundleCache, cache_file: &CloudConfigBundleCacheFile) {
|
||||
std::fs::write(
|
||||
cache.path(),
|
||||
serde_json::to_vec_pretty(cache_file).expect("serialize cache"),
|
||||
)
|
||||
.expect("write cache");
|
||||
}
|
||||
|
||||
fn create_test_cache(codex_home: &Path) -> CloudConfigBundleCache {
|
||||
CloudConfigBundleCache::new(AbsolutePathBuf::resolve_path_against_base(codex_home, "/"))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_writes_signed_payload_and_loads_for_matching_identity() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let cache = create_test_cache(codex_home.path());
|
||||
let bundle = test_bundle();
|
||||
|
||||
cache
|
||||
.save(
|
||||
Some("user-12345".to_string()),
|
||||
Some("account-12345".to_string()),
|
||||
bundle.clone(),
|
||||
)
|
||||
.await
|
||||
.expect("save cache");
|
||||
|
||||
let cache_file: CloudConfigBundleCacheFile =
|
||||
serde_json::from_slice(&std::fs::read(cache.path()).expect("read cache"))
|
||||
.expect("parse cache");
|
||||
assert!(
|
||||
cache_file.signed_payload.expires_at
|
||||
<= cache_file.signed_payload.cached_at + ChronoDuration::minutes(30)
|
||||
);
|
||||
assert!(cache_file.signed_payload.expires_at > cache_file.signed_payload.cached_at);
|
||||
assert_eq!(
|
||||
cache_file,
|
||||
signed_cache_file(CloudConfigBundleCacheSignedPayload {
|
||||
version: CLOUD_CONFIG_BUNDLE_CACHE_VERSION,
|
||||
cached_at: cache_file.signed_payload.cached_at,
|
||||
expires_at: cache_file.signed_payload.expires_at,
|
||||
chatgpt_user_id: Some("user-12345".to_string()),
|
||||
account_id: Some("account-12345".to_string()),
|
||||
bundle,
|
||||
})
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
cache.load(Some("user-12345"), Some("account-12345")).await,
|
||||
Ok(cache_file.signed_payload)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_rejects_missing_request_identity_before_reading_cache_file() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let cache = create_test_cache(codex_home.path());
|
||||
|
||||
assert_eq!(
|
||||
cache
|
||||
.load(/*chatgpt_user_id*/ None, Some("account-12345"))
|
||||
.await,
|
||||
Err(CacheLoadStatus::AuthIdentityIncomplete)
|
||||
);
|
||||
assert_eq!(
|
||||
cache.load(Some("user-12345"), /*account_id*/ None).await,
|
||||
Err(CacheLoadStatus::AuthIdentityIncomplete)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_reports_missing_and_malformed_cache_files() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let cache = create_test_cache(codex_home.path());
|
||||
|
||||
assert_eq!(
|
||||
cache.load(Some("user-12345"), Some("account-12345")).await,
|
||||
Err(CacheLoadStatus::CacheFileNotFound)
|
||||
);
|
||||
|
||||
std::fs::write(cache.path(), "{").expect("write malformed cache");
|
||||
assert!(matches!(
|
||||
cache.load(Some("user-12345"), Some("account-12345")).await,
|
||||
Err(CacheLoadStatus::CacheParseFailed(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_rejects_tampered_payload() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let cache = create_test_cache(codex_home.path());
|
||||
let mut cache_file = signed_cache_file(valid_signed_payload());
|
||||
cache_file
|
||||
.signed_payload
|
||||
.bundle
|
||||
.requirements_toml
|
||||
.enterprise_managed[0]
|
||||
.contents = "allowed_approval_policies = [\"on-request\"]".to_string();
|
||||
write_cache_file(&cache, &cache_file);
|
||||
|
||||
assert_eq!(
|
||||
cache.load(Some("user-12345"), Some("account-12345")).await,
|
||||
Err(CacheLoadStatus::CacheSignatureInvalid)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_rejects_cache_for_incomplete_or_different_identity() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let cache = create_test_cache(codex_home.path());
|
||||
let cache_file = signed_cache_file(valid_signed_payload());
|
||||
write_cache_file(&cache, &cache_file);
|
||||
|
||||
assert_eq!(
|
||||
cache.load(Some("user-99999"), Some("account-12345")).await,
|
||||
Err(CacheLoadStatus::CacheIdentityMismatch)
|
||||
);
|
||||
|
||||
let mut signed_payload = valid_signed_payload();
|
||||
signed_payload.chatgpt_user_id = None;
|
||||
write_cache_file(&cache, &signed_cache_file(signed_payload));
|
||||
|
||||
assert_eq!(
|
||||
cache.load(Some("user-12345"), Some("account-12345")).await,
|
||||
Err(CacheLoadStatus::CacheIdentityIncomplete)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_rejects_expired_cache() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let cache = create_test_cache(codex_home.path());
|
||||
let mut signed_payload = valid_signed_payload();
|
||||
signed_payload.expires_at = Utc::now() - ChronoDuration::seconds(1);
|
||||
write_cache_file(&cache, &signed_cache_file(signed_payload));
|
||||
|
||||
assert_eq!(
|
||||
cache.load(Some("user-12345"), Some("account-12345")).await,
|
||||
Err(CacheLoadStatus::CacheExpired)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_rejects_unsupported_cache_version() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let cache = create_test_cache(codex_home.path());
|
||||
let mut signed_payload = valid_signed_payload();
|
||||
signed_payload.version = 2;
|
||||
write_cache_file(&cache, &signed_cache_file(signed_payload));
|
||||
|
||||
assert_eq!(
|
||||
cache.load(Some("user-12345"), Some("account-12345")).await,
|
||||
Err(CacheLoadStatus::CacheVersionUnsupported(2))
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
95
codex-rs/cloud-config/src/metrics.rs
Normal file
95
codex-rs/cloud-config/src/metrics.rs
Normal file
@@ -0,0 +1,95 @@
|
||||
use codex_config::CloudConfigBundle;
|
||||
|
||||
const CLOUD_CONFIG_BUNDLE_FETCH_ATTEMPT_METRIC: &str = "codex.cloud_config_bundle.fetch_attempt";
|
||||
const CLOUD_CONFIG_BUNDLE_FETCH_FINAL_METRIC: &str = "codex.cloud_config_bundle.fetch_final";
|
||||
const CLOUD_CONFIG_BUNDLE_LOAD_METRIC: &str = "codex.cloud_config_bundle.load";
|
||||
|
||||
pub(crate) fn emit_fetch_attempt_metric(
|
||||
trigger: &str,
|
||||
attempt: usize,
|
||||
outcome: &str,
|
||||
status_code: Option<u16>,
|
||||
) {
|
||||
let attempt_tag = attempt.to_string();
|
||||
let status_code_tag = status_code_tag(status_code);
|
||||
emit_metric(
|
||||
CLOUD_CONFIG_BUNDLE_FETCH_ATTEMPT_METRIC,
|
||||
vec![
|
||||
("trigger", trigger.to_string()),
|
||||
("attempt", attempt_tag),
|
||||
("outcome", outcome.to_string()),
|
||||
("status_code", status_code_tag),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn emit_fetch_final_metric(
|
||||
trigger: &str,
|
||||
outcome: &str,
|
||||
reason: &str,
|
||||
attempt_count: usize,
|
||||
status_code: Option<u16>,
|
||||
bundle: Option<&CloudConfigBundle>,
|
||||
) {
|
||||
let attempt_count_tag = attempt_count.to_string();
|
||||
let status_code_tag = status_code_tag(status_code);
|
||||
emit_metric(
|
||||
CLOUD_CONFIG_BUNDLE_FETCH_FINAL_METRIC,
|
||||
vec![
|
||||
("trigger", trigger.to_string()),
|
||||
("outcome", outcome.to_string()),
|
||||
("reason", reason.to_string()),
|
||||
("attempt_count", attempt_count_tag),
|
||||
("status_code", status_code_tag),
|
||||
("bundle_shape", bundle_shape_tag(bundle)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn emit_load_metric(trigger: &str, outcome: &str, bundle: Option<&CloudConfigBundle>) {
|
||||
emit_metric(
|
||||
CLOUD_CONFIG_BUNDLE_LOAD_METRIC,
|
||||
vec![
|
||||
("trigger", trigger.to_string()),
|
||||
("outcome", outcome.to_string()),
|
||||
("bundle_shape", bundle_shape_tag(bundle)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn bundle_shape_tag(bundle: Option<&CloudConfigBundle>) -> String {
|
||||
let Some(bundle) = bundle else {
|
||||
return "none".to_string();
|
||||
};
|
||||
|
||||
let mut sources = Vec::new();
|
||||
if !bundle.config_toml.enterprise_managed.is_empty() {
|
||||
sources.push("enterprise_config");
|
||||
}
|
||||
if !bundle.requirements_toml.enterprise_managed.is_empty() {
|
||||
sources.push("enterprise_requirements");
|
||||
}
|
||||
|
||||
if sources.is_empty() {
|
||||
"empty".to_string()
|
||||
} else {
|
||||
sources.sort_unstable();
|
||||
sources.join(",")
|
||||
}
|
||||
}
|
||||
|
||||
fn status_code_tag(status_code: Option<u16>) -> String {
|
||||
status_code
|
||||
.map(|status_code| status_code.to_string())
|
||||
.unwrap_or_else(|| "none".to_string())
|
||||
}
|
||||
|
||||
fn emit_metric(metric_name: &str, tags: Vec<(&str, String)>) {
|
||||
if let Some(metrics) = codex_otel::global() {
|
||||
let tag_refs = tags
|
||||
.iter()
|
||||
.map(|(key, value)| (*key, value.as_str()))
|
||||
.collect::<Vec<_>>();
|
||||
let _ = metrics.counter(metric_name, /*inc*/ 1, &tag_refs);
|
||||
}
|
||||
}
|
||||
501
codex-rs/cloud-config/src/service.rs
Normal file
501
codex-rs/cloud-config/src/service.rs
Normal file
@@ -0,0 +1,501 @@
|
||||
//! Cloud config bundle lifecycle orchestration.
|
||||
//!
|
||||
//! Startup loads a single shared bundle from cache or backend, and a background
|
||||
//! refresher keeps the cache warm for future app starts without changing the
|
||||
//! already-snapshotted runtime config.
|
||||
|
||||
use crate::backend::BundleClient;
|
||||
use crate::backend::BundleRequestError;
|
||||
use crate::backend::RetryableFailureKind;
|
||||
use crate::cache::CacheLoadStatus;
|
||||
use crate::cache::CloudConfigBundleCache;
|
||||
use crate::metrics::emit_fetch_attempt_metric;
|
||||
use crate::metrics::emit_fetch_final_metric;
|
||||
use crate::metrics::emit_load_metric;
|
||||
use crate::validation::validate_bundle;
|
||||
use codex_config::AbsolutePathBuf;
|
||||
use codex_config::CloudConfigBundle;
|
||||
use codex_config::CloudConfigBundleLoadError;
|
||||
use codex_config::CloudConfigBundleLoadErrorCode;
|
||||
use codex_core::util::backoff;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::RefreshTokenError;
|
||||
use codex_login::UnauthorizedRecovery;
|
||||
use codex_protocol::account::PlanType;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
use tokio::time::sleep;
|
||||
use tokio::time::timeout;
|
||||
|
||||
pub(crate) const CLOUD_CONFIG_BUNDLE_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS: usize = 5;
|
||||
const CLOUD_CONFIG_BUNDLE_CACHE_REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60);
|
||||
const CLOUD_CONFIG_BUNDLE_LOAD_FAILED_MESSAGE: &str =
|
||||
"Failed to load cloud config bundle (workspace-managed policies).";
|
||||
const CLOUD_CONFIG_BUNDLE_AUTH_RECOVERY_FAILED_MESSAGE: &str = concat!(
|
||||
"Your authentication session could not be refreshed automatically. ",
|
||||
"Please log out and sign in again."
|
||||
);
|
||||
|
||||
fn auth_identity(auth: &CodexAuth) -> (Option<String>, Option<String>) {
|
||||
(auth.get_chatgpt_user_id(), auth.get_account_id())
|
||||
}
|
||||
|
||||
fn cloud_config_eligible_auth(auth: &CodexAuth) -> bool {
|
||||
let Some(plan_type) = auth.account_plan_type() else {
|
||||
return false;
|
||||
};
|
||||
auth.uses_codex_backend()
|
||||
&& (plan_type.is_business_like()
|
||||
|| matches!(plan_type, PlanType::Enterprise | PlanType::Edu))
|
||||
}
|
||||
|
||||
fn optional_bundle(bundle: CloudConfigBundle) -> Option<CloudConfigBundle> {
|
||||
if bundle.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(bundle)
|
||||
}
|
||||
}
|
||||
|
||||
enum CachedBundleLookup {
|
||||
Hit(Option<CloudConfigBundle>),
|
||||
Miss,
|
||||
}
|
||||
|
||||
enum UnauthorizedRecoveryAction {
|
||||
RetrySameAttempt,
|
||||
RetryNextAttempt,
|
||||
}
|
||||
|
||||
pub(crate) struct CloudConfigBundleService<C> {
|
||||
auth_manager: Arc<AuthManager>,
|
||||
client: Arc<C>,
|
||||
cache: CloudConfigBundleCache,
|
||||
codex_home: AbsolutePathBuf,
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
impl<C> Clone for CloudConfigBundleService<C> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
auth_manager: Arc::clone(&self.auth_manager),
|
||||
client: Arc::clone(&self.client),
|
||||
cache: self.cache.clone(),
|
||||
codex_home: self.codex_home.clone(),
|
||||
timeout: self.timeout,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<C> CloudConfigBundleService<C>
|
||||
where
|
||||
C: BundleClient + 'static,
|
||||
{
|
||||
pub(crate) fn new(
|
||||
auth_manager: Arc<AuthManager>,
|
||||
client: Arc<C>,
|
||||
codex_home: PathBuf,
|
||||
timeout: Duration,
|
||||
) -> Self {
|
||||
let codex_home = AbsolutePathBuf::resolve_path_against_base(codex_home, "/");
|
||||
Self {
|
||||
auth_manager,
|
||||
client,
|
||||
cache: CloudConfigBundleCache::new(codex_home.clone()),
|
||||
codex_home,
|
||||
timeout,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn load_startup_bundle_with_timeout(
|
||||
&self,
|
||||
) -> Result<Option<CloudConfigBundle>, CloudConfigBundleLoadError> {
|
||||
let _timer =
|
||||
codex_otel::start_global_timer("codex.cloud_config_bundle.fetch.duration_ms", &[]);
|
||||
let started_at = Instant::now();
|
||||
let load_result = timeout(self.timeout, self.load_startup_bundle())
|
||||
.await
|
||||
.inspect_err(|_| {
|
||||
let message = format!(
|
||||
"Timed out waiting for cloud config bundle after {}s",
|
||||
self.timeout.as_secs()
|
||||
);
|
||||
tracing::error!("{message}");
|
||||
emit_load_metric("startup", "error", /*bundle*/ None);
|
||||
})
|
||||
.map_err(|_| {
|
||||
CloudConfigBundleLoadError::new(
|
||||
CloudConfigBundleLoadErrorCode::Timeout,
|
||||
/*status_code*/ None,
|
||||
format!(
|
||||
"timed out waiting for cloud config bundle after {}s",
|
||||
self.timeout.as_secs()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
|
||||
let result = match load_result {
|
||||
Ok(result) => result,
|
||||
Err(err) => {
|
||||
emit_load_metric("startup", "error", /*bundle*/ None);
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
|
||||
match result.as_ref() {
|
||||
Some(bundle) => {
|
||||
tracing::info!(
|
||||
elapsed_ms = started_at.elapsed().as_millis(),
|
||||
config_fragments = bundle.config_toml.enterprise_managed.len(),
|
||||
requirements_fragments = bundle.requirements_toml.enterprise_managed.len(),
|
||||
"Cloud config bundle load completed"
|
||||
);
|
||||
emit_load_metric("startup", "success", Some(bundle));
|
||||
}
|
||||
None => {
|
||||
tracing::info!(
|
||||
elapsed_ms = started_at.elapsed().as_millis(),
|
||||
"Cloud config bundle load completed (none)"
|
||||
);
|
||||
emit_load_metric("startup", "success", /*bundle*/ None);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn load_startup_bundle(
|
||||
&self,
|
||||
) -> Result<Option<CloudConfigBundle>, CloudConfigBundleLoadError> {
|
||||
let Some(auth) = self.auth_manager.auth().await else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !cloud_config_eligible_auth(&auth) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Startup prefers a valid, identity-matched cache entry. The backend is
|
||||
// only consulted on cache miss or invalid cache contents.
|
||||
let (chatgpt_user_id, account_id) = auth_identity(&auth);
|
||||
match self
|
||||
.load_valid_cached_bundle(chatgpt_user_id.as_deref(), account_id.as_deref())
|
||||
.await
|
||||
{
|
||||
CachedBundleLookup::Hit(bundle) => return Ok(bundle),
|
||||
CachedBundleLookup::Miss => {}
|
||||
}
|
||||
|
||||
self.fetch_remote_bundle_and_update_cache_with_retries(auth, "startup")
|
||||
.await
|
||||
}
|
||||
|
||||
async fn load_valid_cached_bundle(
|
||||
&self,
|
||||
chatgpt_user_id: Option<&str>,
|
||||
account_id: Option<&str>,
|
||||
) -> CachedBundleLookup {
|
||||
match self.cache.load(chatgpt_user_id, account_id).await {
|
||||
Ok(signed_payload) => {
|
||||
if let Err(err) = validate_bundle(&signed_payload.bundle, &self.codex_home) {
|
||||
tracing::warn!(
|
||||
path = %self.cache.path().display(),
|
||||
error = %err,
|
||||
"Ignoring invalid cached cloud config bundle"
|
||||
);
|
||||
self.cache
|
||||
.log_load_status(&CacheLoadStatus::CacheInvalidBundle);
|
||||
CachedBundleLookup::Miss
|
||||
} else {
|
||||
tracing::info!(
|
||||
path = %self.cache.path().display(),
|
||||
"Using cached cloud config bundle"
|
||||
);
|
||||
CachedBundleLookup::Hit(optional_bundle(signed_payload.bundle))
|
||||
}
|
||||
}
|
||||
Err(cache_load_status) => {
|
||||
self.cache.log_load_status(&cache_load_status);
|
||||
CachedBundleLookup::Miss
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_remote_bundle_and_update_cache_with_retries(
|
||||
&self,
|
||||
mut auth: CodexAuth,
|
||||
trigger: &'static str,
|
||||
) -> Result<Option<CloudConfigBundle>, CloudConfigBundleLoadError> {
|
||||
let mut attempt = 1;
|
||||
let mut last_status_code: Option<u16> = None;
|
||||
let mut auth_recovery = self.auth_manager.unauthorized_recovery();
|
||||
|
||||
while attempt <= CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS {
|
||||
match self.client.get_bundle(&auth).await {
|
||||
Ok(bundle) => {
|
||||
return self
|
||||
.validate_and_cache_remote_bundle(&auth, trigger, attempt, bundle)
|
||||
.await;
|
||||
}
|
||||
Err(BundleRequestError::Retryable(status)) => {
|
||||
last_status_code = status.status_code();
|
||||
if self
|
||||
.retry_after_request_failure(trigger, attempt, status)
|
||||
.await
|
||||
{
|
||||
attempt += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
Err(BundleRequestError::Unauthorized {
|
||||
status_code,
|
||||
message,
|
||||
}) => {
|
||||
last_status_code = status_code;
|
||||
match self
|
||||
.handle_unauthorized(
|
||||
&mut auth,
|
||||
&mut auth_recovery,
|
||||
trigger,
|
||||
attempt,
|
||||
status_code,
|
||||
&message,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
UnauthorizedRecoveryAction::RetrySameAttempt => continue,
|
||||
UnauthorizedRecoveryAction::RetryNextAttempt => {
|
||||
attempt += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
emit_fetch_final_metric(
|
||||
trigger,
|
||||
"error",
|
||||
"request_retry_exhausted",
|
||||
CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS,
|
||||
last_status_code,
|
||||
/*bundle*/ None,
|
||||
);
|
||||
tracing::error!(
|
||||
path = %self.cache.path().display(),
|
||||
"{CLOUD_CONFIG_BUNDLE_LOAD_FAILED_MESSAGE}"
|
||||
);
|
||||
Err(CloudConfigBundleLoadError::new(
|
||||
CloudConfigBundleLoadErrorCode::RequestFailed,
|
||||
last_status_code,
|
||||
CLOUD_CONFIG_BUNDLE_LOAD_FAILED_MESSAGE,
|
||||
))
|
||||
}
|
||||
|
||||
async fn validate_and_cache_remote_bundle(
|
||||
&self,
|
||||
auth: &CodexAuth,
|
||||
trigger: &'static str,
|
||||
attempt: usize,
|
||||
bundle: CloudConfigBundle,
|
||||
) -> Result<Option<CloudConfigBundle>, CloudConfigBundleLoadError> {
|
||||
emit_fetch_attempt_metric(trigger, attempt, "success", /*status_code*/ None);
|
||||
if let Err(err) = validate_bundle(&bundle, &self.codex_home) {
|
||||
emit_fetch_final_metric(
|
||||
trigger,
|
||||
"error",
|
||||
"invalid_bundle",
|
||||
attempt,
|
||||
/*status_code*/ None,
|
||||
/*bundle*/ None,
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
let (chatgpt_user_id, account_id) = auth_identity(auth);
|
||||
if let Err(err) = self
|
||||
.cache
|
||||
.save(chatgpt_user_id, account_id, bundle.clone())
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
error = %err,
|
||||
"Failed to write cloud config bundle cache"
|
||||
);
|
||||
}
|
||||
|
||||
emit_fetch_final_metric(
|
||||
trigger,
|
||||
"success",
|
||||
"none",
|
||||
attempt,
|
||||
/*status_code*/ None,
|
||||
Some(&bundle),
|
||||
);
|
||||
Ok(optional_bundle(bundle))
|
||||
}
|
||||
|
||||
async fn retry_after_request_failure(
|
||||
&self,
|
||||
trigger: &'static str,
|
||||
attempt: usize,
|
||||
status: RetryableFailureKind,
|
||||
) -> bool {
|
||||
let status_code = status.status_code();
|
||||
emit_fetch_attempt_metric(trigger, attempt, "error", status_code);
|
||||
if attempt < CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS {
|
||||
tracing::warn!(
|
||||
status = ?status,
|
||||
attempt,
|
||||
max_attempts = CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS,
|
||||
"Failed to fetch cloud config bundle; retrying"
|
||||
);
|
||||
sleep(backoff(attempt as u64)).await;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_unauthorized(
|
||||
&self,
|
||||
auth: &mut CodexAuth,
|
||||
auth_recovery: &mut UnauthorizedRecovery,
|
||||
trigger: &'static str,
|
||||
attempt: usize,
|
||||
status_code: Option<u16>,
|
||||
message: &str,
|
||||
) -> Result<UnauthorizedRecoveryAction, CloudConfigBundleLoadError> {
|
||||
emit_fetch_attempt_metric(trigger, attempt, "unauthorized", status_code);
|
||||
if auth_recovery.has_next() {
|
||||
tracing::warn!(
|
||||
attempt,
|
||||
max_attempts = CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS,
|
||||
"Cloud config bundle request was unauthorized; attempting auth recovery"
|
||||
);
|
||||
match auth_recovery.next().await {
|
||||
Ok(_) => {
|
||||
let Some(refreshed_auth) = self.auth_manager.auth().await else {
|
||||
tracing::error!(
|
||||
"Auth recovery succeeded but no auth is available for cloud config bundle"
|
||||
);
|
||||
emit_fetch_final_metric(
|
||||
trigger,
|
||||
"error",
|
||||
"auth_recovery_missing_auth",
|
||||
attempt,
|
||||
status_code,
|
||||
/*bundle*/ None,
|
||||
);
|
||||
return Err(CloudConfigBundleLoadError::new(
|
||||
CloudConfigBundleLoadErrorCode::Auth,
|
||||
status_code,
|
||||
CLOUD_CONFIG_BUNDLE_AUTH_RECOVERY_FAILED_MESSAGE,
|
||||
));
|
||||
};
|
||||
*auth = refreshed_auth;
|
||||
return Ok(UnauthorizedRecoveryAction::RetrySameAttempt);
|
||||
}
|
||||
Err(RefreshTokenError::Permanent(failed)) => {
|
||||
tracing::warn!(
|
||||
error = %failed,
|
||||
"Failed to recover from unauthorized cloud config bundle request"
|
||||
);
|
||||
emit_fetch_final_metric(
|
||||
trigger,
|
||||
"error",
|
||||
"auth_recovery_unrecoverable",
|
||||
attempt,
|
||||
status_code,
|
||||
/*bundle*/ None,
|
||||
);
|
||||
return Err(CloudConfigBundleLoadError::new(
|
||||
CloudConfigBundleLoadErrorCode::Auth,
|
||||
status_code,
|
||||
failed.message,
|
||||
));
|
||||
}
|
||||
Err(RefreshTokenError::Transient(recovery_err)) => {
|
||||
if attempt < CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS {
|
||||
tracing::warn!(
|
||||
error = %recovery_err,
|
||||
attempt,
|
||||
max_attempts = CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS,
|
||||
"Failed to recover from unauthorized cloud config bundle request; retrying"
|
||||
);
|
||||
sleep(backoff(attempt as u64)).await;
|
||||
}
|
||||
return Ok(UnauthorizedRecoveryAction::RetryNextAttempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::warn!(
|
||||
error = %message,
|
||||
"Cloud config bundle request was unauthorized and no auth recovery is available"
|
||||
);
|
||||
emit_fetch_final_metric(
|
||||
trigger,
|
||||
"error",
|
||||
"auth_recovery_unavailable",
|
||||
attempt,
|
||||
status_code,
|
||||
/*bundle*/ None,
|
||||
);
|
||||
Err(CloudConfigBundleLoadError::new(
|
||||
CloudConfigBundleLoadErrorCode::Auth,
|
||||
status_code,
|
||||
CLOUD_CONFIG_BUNDLE_AUTH_RECOVERY_FAILED_MESSAGE,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) async fn refresh_cache_in_background(&self) {
|
||||
loop {
|
||||
sleep(CLOUD_CONFIG_BUNDLE_CACHE_REFRESH_INTERVAL).await;
|
||||
match timeout(self.timeout, self.refresh_cache_once()).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => break,
|
||||
Err(_) => {
|
||||
tracing::error!(
|
||||
"Timed out refreshing cloud config bundle cache from remote; keeping existing cache"
|
||||
);
|
||||
emit_load_metric("refresh", "error", /*bundle*/ None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn refresh_cache_once(&self) -> bool {
|
||||
let Some(auth) = self.auth_manager.auth().await else {
|
||||
return false;
|
||||
};
|
||||
if !cloud_config_eligible_auth(&auth) {
|
||||
return false;
|
||||
}
|
||||
|
||||
match self
|
||||
.fetch_remote_bundle_and_update_cache_with_retries(auth, "refresh")
|
||||
.await
|
||||
{
|
||||
Ok(bundle) => emit_load_metric("refresh", "success", bundle.as_ref()),
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
path = %self.cache.path().display(),
|
||||
error = %err,
|
||||
"Failed to refresh cloud config bundle cache from remote"
|
||||
);
|
||||
emit_load_metric("refresh", "error", /*bundle*/ None);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "service_tests.rs"]
|
||||
mod tests;
|
||||
1020
codex-rs/cloud-config/src/service_tests.rs
Normal file
1020
codex-rs/cloud-config/src/service_tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
34
codex-rs/cloud-config/src/validation.rs
Normal file
34
codex-rs/cloud-config/src/validation.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use codex_config::AbsolutePathBuf;
|
||||
use codex_config::CloudConfigBundle;
|
||||
use codex_config::CloudConfigBundleLayers;
|
||||
use codex_config::CloudConfigBundleLoadError;
|
||||
use codex_config::CloudConfigBundleLoadErrorCode;
|
||||
use codex_config::compose_requirements;
|
||||
|
||||
pub(crate) fn validate_bundle(
|
||||
bundle: &CloudConfigBundle,
|
||||
base_dir: &AbsolutePathBuf,
|
||||
) -> Result<(), CloudConfigBundleLoadError> {
|
||||
let bundle_layers =
|
||||
CloudConfigBundleLayers::from_bundle(bundle.clone(), base_dir).map_err(|err| {
|
||||
CloudConfigBundleLoadError::new(
|
||||
CloudConfigBundleLoadErrorCode::InvalidBundle,
|
||||
/*status_code*/ None,
|
||||
format!("invalid cloud config bundle: {err}"),
|
||||
)
|
||||
})?;
|
||||
let CloudConfigBundleLayers {
|
||||
enterprise_managed_config: _,
|
||||
enterprise_managed_requirements,
|
||||
} = bundle_layers;
|
||||
|
||||
compose_requirements(enterprise_managed_requirements).map_err(|err| {
|
||||
CloudConfigBundleLoadError::new(
|
||||
CloudConfigBundleLoadErrorCode::InvalidBundle,
|
||||
/*status_code*/ None,
|
||||
format!("invalid cloud config bundle: {err}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -724,36 +724,27 @@ impl ConfigToml {
|
||||
pub async fn derive_permission_profile(
|
||||
&self,
|
||||
sandbox_mode_override: Option<SandboxMode>,
|
||||
profile_sandbox_mode: Option<SandboxMode>,
|
||||
windows_sandbox_level: WindowsSandboxLevel,
|
||||
active_project: Option<&ProjectConfig>,
|
||||
permission_profile_constraint: Option<&crate::Constrained<PermissionProfile>>,
|
||||
) -> PermissionProfile {
|
||||
let sandbox_mode_was_explicit = sandbox_mode_override.is_some()
|
||||
|| profile_sandbox_mode.is_some()
|
||||
|| self.sandbox_mode.is_some();
|
||||
let resolved_sandbox_mode = sandbox_mode_override
|
||||
.or(profile_sandbox_mode)
|
||||
.or(self.sandbox_mode)
|
||||
.or(if sandbox_mode_was_explicit {
|
||||
None
|
||||
} else {
|
||||
let configured_sandbox_mode = sandbox_mode_override.or(self.sandbox_mode);
|
||||
let resolved_sandbox_mode = configured_sandbox_mode
|
||||
.or_else(|| {
|
||||
// If no sandbox_mode is set but this directory has a trust decision,
|
||||
// default to workspace-write except on unsandboxed Windows where we
|
||||
// default to read-only.
|
||||
active_project.and_then(|p| {
|
||||
if p.is_trusted() || p.is_untrusted() {
|
||||
active_project
|
||||
.filter(|project| project.is_trusted() || project.is_untrusted())
|
||||
.map(|_| {
|
||||
if cfg!(target_os = "windows")
|
||||
&& windows_sandbox_level == WindowsSandboxLevel::Disabled
|
||||
{
|
||||
Some(SandboxMode::ReadOnly)
|
||||
SandboxMode::ReadOnly
|
||||
} else {
|
||||
Some(SandboxMode::WorkspaceWrite)
|
||||
SandboxMode::WorkspaceWrite
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let effective_sandbox_mode = if cfg!(target_os = "windows")
|
||||
@@ -791,7 +782,7 @@ impl ConfigToml {
|
||||
},
|
||||
SandboxMode::DangerFullAccess => PermissionProfile::Disabled,
|
||||
};
|
||||
if !sandbox_mode_was_explicit
|
||||
if configured_sandbox_mode.is_none()
|
||||
&& let Some(constraint) = permission_profile_constraint
|
||||
&& let Err(err) = constraint.can_set(&permission_profile)
|
||||
{
|
||||
|
||||
@@ -32,7 +32,6 @@ use codex_config::permissions_toml::NetworkToml;
|
||||
use codex_config::permissions_toml::PermissionProfileToml;
|
||||
use codex_config::permissions_toml::PermissionsToml;
|
||||
use codex_config::permissions_toml::WorkspaceRootsToml;
|
||||
use codex_config::profile_toml::ConfigProfile;
|
||||
use codex_config::types::AppToolApproval;
|
||||
use codex_config::types::ApprovalsReviewer;
|
||||
use codex_config::types::BundledSkillsConfig;
|
||||
@@ -165,7 +164,6 @@ fn http_mcp(url: &str) -> McpServerConfig {
|
||||
async fn derive_legacy_sandbox_policy_for_test(
|
||||
cfg: &ConfigToml,
|
||||
sandbox_mode_override: Option<SandboxMode>,
|
||||
profile_sandbox_mode: Option<SandboxMode>,
|
||||
windows_sandbox_level: WindowsSandboxLevel,
|
||||
active_project: Option<&ProjectConfig>,
|
||||
permission_profile_constraint: Option<&Constrained<PermissionProfile>>,
|
||||
@@ -173,7 +171,6 @@ async fn derive_legacy_sandbox_policy_for_test(
|
||||
let permission_profile = cfg
|
||||
.derive_permission_profile(
|
||||
sandbox_mode_override,
|
||||
profile_sandbox_mode,
|
||||
windows_sandbox_level,
|
||||
active_project,
|
||||
permission_profile_constraint,
|
||||
@@ -2551,6 +2548,65 @@ async fn empty_config_defaults_to_builtin_profile_for_trusted_project() -> std::
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_config_defaults_to_builtin_profile_for_untrusted_project() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let cwd = TempDir::new()?;
|
||||
let project_key = cwd.path().to_string_lossy().to_string();
|
||||
|
||||
let config = Config::load_from_base_config_with_overrides(
|
||||
ConfigToml {
|
||||
projects: Some(HashMap::from([(
|
||||
project_key,
|
||||
ProjectConfig {
|
||||
trust_level: Some(TrustLevel::Untrusted),
|
||||
},
|
||||
)])),
|
||||
..Default::default()
|
||||
},
|
||||
ConfigOverrides {
|
||||
cwd: Some(cwd.path().to_path_buf()),
|
||||
..Default::default()
|
||||
},
|
||||
codex_home.abs(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let policy = config.permissions.file_system_sandbox_policy();
|
||||
assert_eq!(
|
||||
config
|
||||
.permissions
|
||||
.active_permission_profile()
|
||||
.as_ref()
|
||||
.map(|active| active.id.as_str()),
|
||||
Some(if cfg!(target_os = "windows") {
|
||||
BUILT_IN_PERMISSION_PROFILE_READ_ONLY
|
||||
} else {
|
||||
BUILT_IN_PERMISSION_PROFILE_WORKSPACE
|
||||
})
|
||||
);
|
||||
assert!(
|
||||
policy.can_read_path_with_cwd(cwd.path(), cwd.path()),
|
||||
"expected untrusted project fallback to allow reads, policy: {policy:?}"
|
||||
);
|
||||
if cfg!(target_os = "windows") {
|
||||
assert!(
|
||||
!policy.can_write_path_with_cwd(cwd.path(), cwd.path()),
|
||||
"expected untrusted project fallback to stay read-only without Windows sandbox support, policy: {policy:?}"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
policy.can_write_path_with_cwd(cwd.path(), cwd.path()),
|
||||
"expected untrusted project fallback to use :workspace, policy: {policy:?}"
|
||||
);
|
||||
assert!(
|
||||
!policy.can_write_path_with_cwd(&cwd.path().join(".codex"), cwd.path()),
|
||||
"expected :workspace metadata carveouts, policy: {policy:?}"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn implicit_builtin_workspace_profile_preserves_sandbox_workspace_write_settings()
|
||||
-> std::io::Result<()> {
|
||||
@@ -3530,7 +3586,6 @@ network_access = false # This should be ignored.
|
||||
let resolution = derive_legacy_sandbox_policy_for_test(
|
||||
&sandbox_full_access_cfg,
|
||||
sandbox_mode_override,
|
||||
/*profile_sandbox_mode*/ None,
|
||||
WindowsSandboxLevel::Disabled,
|
||||
/*active_project*/ None,
|
||||
/*permission_profile_constraint*/ None,
|
||||
@@ -3551,7 +3606,6 @@ network_access = true # This should be ignored.
|
||||
let resolution = derive_legacy_sandbox_policy_for_test(
|
||||
&sandbox_read_only_cfg,
|
||||
sandbox_mode_override,
|
||||
/*profile_sandbox_mode*/ None,
|
||||
WindowsSandboxLevel::Disabled,
|
||||
/*active_project*/ None,
|
||||
/*permission_profile_constraint*/ None,
|
||||
@@ -3583,7 +3637,6 @@ trust_level = "trusted"
|
||||
let resolution = derive_legacy_sandbox_policy_for_test(
|
||||
&sandbox_workspace_write_cfg,
|
||||
sandbox_mode_override,
|
||||
/*profile_sandbox_mode*/ None,
|
||||
WindowsSandboxLevel::Disabled,
|
||||
/*active_project*/ None,
|
||||
/*permission_profile_constraint*/ None,
|
||||
@@ -3623,7 +3676,6 @@ exclude_slash_tmp = true
|
||||
let resolution = derive_legacy_sandbox_policy_for_test(
|
||||
&sandbox_workspace_write_cfg,
|
||||
sandbox_mode_override,
|
||||
/*profile_sandbox_mode*/ None,
|
||||
WindowsSandboxLevel::Disabled,
|
||||
/*active_project*/ None,
|
||||
/*permission_profile_constraint*/ None,
|
||||
@@ -4998,38 +5050,6 @@ model = "gpt-project-local"
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unselected_profile_sandbox_mode_is_ignored() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut profiles = HashMap::new();
|
||||
profiles.insert(
|
||||
"work".to_string(),
|
||||
ConfigProfile {
|
||||
sandbox_mode: Some(SandboxMode::DangerFullAccess),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let cfg = ConfigToml {
|
||||
profiles,
|
||||
sandbox_mode: Some(SandboxMode::ReadOnly),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let config = Config::load_from_base_config_with_overrides(
|
||||
cfg,
|
||||
ConfigOverrides::default(),
|
||||
codex_home.abs(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
config.legacy_sandbox_policy(),
|
||||
SandboxPolicy::new_read_only_policy()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn feature_table_overrides_legacy_flags() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -8587,7 +8607,6 @@ trust_level = "untrusted"
|
||||
let resolution = derive_legacy_sandbox_policy_for_test(
|
||||
&cfg,
|
||||
/*sandbox_mode_override*/ None,
|
||||
/*profile_sandbox_mode*/ None,
|
||||
WindowsSandboxLevel::Disabled,
|
||||
Some(&active_project),
|
||||
/*permission_profile_constraint*/ None,
|
||||
@@ -8644,7 +8663,6 @@ async fn derive_sandbox_policy_falls_back_to_read_only_for_implicit_defaults() -
|
||||
let resolution = derive_legacy_sandbox_policy_for_test(
|
||||
&cfg,
|
||||
/*sandbox_mode_override*/ None,
|
||||
/*profile_sandbox_mode*/ None,
|
||||
WindowsSandboxLevel::Disabled,
|
||||
Some(&active_project),
|
||||
Some(&constrained),
|
||||
@@ -8697,7 +8715,6 @@ async fn derive_sandbox_policy_preserves_windows_downgrade_for_unsupported_fallb
|
||||
let resolution = derive_legacy_sandbox_policy_for_test(
|
||||
&cfg,
|
||||
/*sandbox_mode_override*/ None,
|
||||
/*profile_sandbox_mode*/ None,
|
||||
WindowsSandboxLevel::Disabled,
|
||||
Some(&active_project),
|
||||
Some(&constrained),
|
||||
|
||||
@@ -2936,7 +2936,6 @@ impl Config {
|
||||
let mut permission_profile = cfg
|
||||
.derive_permission_profile(
|
||||
sandbox_mode,
|
||||
/*profile_sandbox_mode*/ None,
|
||||
windows_sandbox_level,
|
||||
Some(&active_project),
|
||||
Some(&constrained_permission_profile),
|
||||
|
||||
@@ -20,7 +20,6 @@ use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::InitialHistory;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_protocol::protocol::TokenUsage;
|
||||
@@ -719,9 +718,7 @@ async fn run_review_on_session(
|
||||
.total_token_usage()
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
// The legacy SandboxPolicy should match the PermissionProfile.
|
||||
let guardian_permission_profile = PermissionProfile::read_only();
|
||||
let legacy_sandbox_policy = SandboxPolicy::new_read_only_policy();
|
||||
|
||||
let submit_result = run_before_review_deadline(
|
||||
deadline,
|
||||
@@ -736,7 +733,7 @@ async fn run_review_on_session(
|
||||
#[allow(deprecated)]
|
||||
cwd: Some(params.parent_turn.cwd.to_path_buf()),
|
||||
approval_policy: Some(AskForApproval::Never),
|
||||
sandbox_policy: Some(legacy_sandbox_policy),
|
||||
sandbox_policy: None,
|
||||
permission_profile: Some(guardian_permission_profile),
|
||||
summary: Some(params.reasoning_summary),
|
||||
personality: params.personality,
|
||||
|
||||
@@ -69,6 +69,8 @@ use codex_analytics::InvocationType;
|
||||
use codex_analytics::TurnResolvedConfigFact;
|
||||
use codex_analytics::build_track_events_context;
|
||||
use codex_async_utils::OrCancelExt;
|
||||
use codex_extension_api::TurnInputContext;
|
||||
use codex_extension_api::TurnInputEnvironment;
|
||||
use codex_features::Feature;
|
||||
use codex_git_utils::get_git_repo_root;
|
||||
use codex_git_utils::get_git_repo_root_with_fs;
|
||||
@@ -513,6 +515,9 @@ async fn build_skills_and_plugins(
|
||||
};
|
||||
let skills_outcome = turn_context.turn_skills.outcome.as_ref();
|
||||
let connector_slug_counts = build_connector_slug_counts(&available_connectors);
|
||||
let extension_injection_items =
|
||||
build_extension_turn_input_items(sess, turn_context, &user_input, cancellation_token)
|
||||
.await?;
|
||||
let skill_name_counts_lower =
|
||||
build_skill_name_counts(&skills_outcome.skills, &skills_outcome.disabled_paths).1;
|
||||
let mentioned_skills = collect_explicit_skill_mentions(
|
||||
@@ -588,9 +593,57 @@ async fn build_skills_and_plugins(
|
||||
|
||||
let mut injection_items = skill_items;
|
||||
injection_items.extend(plugin_items);
|
||||
injection_items.extend(extension_injection_items);
|
||||
Some((injection_items, explicitly_enabled_connectors))
|
||||
}
|
||||
|
||||
async fn build_extension_turn_input_items(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &TurnContext,
|
||||
user_input: &[UserInput],
|
||||
cancellation_token: &CancellationToken,
|
||||
) -> Option<Vec<ResponseItem>> {
|
||||
let contributors = sess.services.extensions.turn_input_contributors().to_vec();
|
||||
if contributors.is_empty() {
|
||||
return Some(Vec::new());
|
||||
}
|
||||
|
||||
let environments = turn_context
|
||||
.environments
|
||||
.turn_environments
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, environment)| TurnInputEnvironment {
|
||||
environment_id: environment.environment_id.clone(),
|
||||
cwd: environment.cwd.as_path().to_path_buf(),
|
||||
is_primary: index == 0,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let input = TurnInputContext {
|
||||
turn_id: turn_context.sub_id.to_string(),
|
||||
user_input: user_input.to_vec(),
|
||||
environments,
|
||||
};
|
||||
|
||||
let mut items = Vec::new();
|
||||
for contributor in contributors {
|
||||
let contributed_items = contributor
|
||||
.contribute(
|
||||
input.clone(),
|
||||
&sess.services.session_extension_data,
|
||||
&sess.services.thread_extension_data,
|
||||
turn_context.extension_data.as_ref(),
|
||||
)
|
||||
.or_cancel(cancellation_token)
|
||||
.await
|
||||
.ok()?;
|
||||
items.extend(contributed_items);
|
||||
}
|
||||
|
||||
Some(items)
|
||||
}
|
||||
|
||||
async fn track_turn_resolved_config_analytics(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
|
||||
@@ -210,15 +210,7 @@ fn build_model_visible_specs_and_registry(
|
||||
specs.push(spec_for_model_request(turn_context, exposure, spec));
|
||||
}
|
||||
}
|
||||
for spec in hosted_specs {
|
||||
if !is_hidden_by_code_mode_only(
|
||||
turn_context,
|
||||
&ToolName::plain(spec.name()),
|
||||
ToolExposure::Direct,
|
||||
) {
|
||||
specs.push(spec);
|
||||
}
|
||||
}
|
||||
specs.extend(hosted_specs);
|
||||
|
||||
let registry = ToolRegistry::from_tools(runtimes);
|
||||
let model_visible_specs = merge_into_namespaces(specs)
|
||||
@@ -267,6 +259,7 @@ fn hosted_model_tool_specs(context: &CoreToolPlanContext<'_>) -> Vec<ToolSpec> {
|
||||
}) {
|
||||
specs.push(web_search_tool);
|
||||
}
|
||||
// TODO: Remove hosted image generation once the standalone extension is ready.
|
||||
if image_generation_tool_enabled(turn_context)
|
||||
&& !standalone_image_generation_available(turn_context, context.extension_tool_executors)
|
||||
{
|
||||
|
||||
@@ -1167,7 +1167,16 @@ async fn code_mode_only_can_expose_namespaced_multi_agent_v2_as_normal_tools() {
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(plan.visible_names, vec!["exec", "wait", "agents"]);
|
||||
assert_eq!(
|
||||
plan.visible_names,
|
||||
vec![
|
||||
"exec",
|
||||
"wait",
|
||||
"agents",
|
||||
// Hosted Responses tools.
|
||||
"web_search",
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
!plan
|
||||
.namespace_function_names("agents")
|
||||
@@ -1235,6 +1244,32 @@ async fn hosted_tools_follow_provider_auth_model_and_config_gates() {
|
||||
}
|
||||
);
|
||||
|
||||
let code_mode_only = probe(|turn| {
|
||||
use_chatgpt_auth(turn);
|
||||
set_features(turn, &[Feature::CodeModeOnly, Feature::MultiAgentV2]);
|
||||
set_web_search_mode(turn, WebSearchMode::Live);
|
||||
turn.model_info.input_modalities = vec![InputModality::Image];
|
||||
})
|
||||
.await;
|
||||
assert_eq!(
|
||||
code_mode_only.visible_names,
|
||||
vec![
|
||||
// Code-mode entrypoints.
|
||||
codex_code_mode::PUBLIC_TOOL_NAME,
|
||||
codex_code_mode::WAIT_TOOL_NAME,
|
||||
// Multi-agent v2 tools.
|
||||
"spawn_agent",
|
||||
"send_message",
|
||||
"followup_task",
|
||||
"wait_agent",
|
||||
"close_agent",
|
||||
"list_agents",
|
||||
// Hosted Responses tools.
|
||||
"web_search",
|
||||
"image_generation",
|
||||
]
|
||||
);
|
||||
|
||||
let standalone_web_search_without_web_run = probe(|turn| {
|
||||
set_feature(turn, Feature::StandaloneWebSearch, /*enabled*/ true);
|
||||
set_web_search_mode(turn, WebSearchMode::Live);
|
||||
|
||||
@@ -5,6 +5,7 @@ use codex_login::CodexAuth;
|
||||
use codex_models_manager::manager::RefreshStrategy;
|
||||
use codex_models_manager::manager::SharedModelsManager;
|
||||
use codex_models_manager::model_info::model_info_from_slug;
|
||||
use codex_protocol::openai_models::InputModality;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_protocol::openai_models::ModelVisibility;
|
||||
@@ -165,12 +166,17 @@ async fn remote_tool_mode_selector_overrides_feature_flags() -> Result<()> {
|
||||
|
||||
let mut code_mode_only_model = remote_model("test-tool-mode-code-mode-only");
|
||||
code_mode_only_model.tool_mode = Some(ToolMode::CodeModeOnly);
|
||||
code_mode_only_model.input_modalities = vec![InputModality::Text, InputModality::Image];
|
||||
let code_mode_only_body = response_body_for_remote_model(code_mode_only_model, |_| {}).await?;
|
||||
assert_eq!(
|
||||
tool_names(&code_mode_only_body),
|
||||
vec![
|
||||
// Code-mode entrypoints.
|
||||
codex_code_mode::PUBLIC_TOOL_NAME.to_string(),
|
||||
codex_code_mode::WAIT_TOOL_NAME.to_string(),
|
||||
// Hosted Responses tools.
|
||||
"web_search".to_string(),
|
||||
"image_generation".to_string(),
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use codex_exec_server::LOCAL_ENVIRONMENT_ID;
|
||||
use codex_exec_server::REMOTE_ENVIRONMENT_ID;
|
||||
use codex_exec_server::RemoveOptions;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::models::FileSystemPermissions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::permissions::FileSystemAccessMode;
|
||||
use codex_protocol::permissions::FileSystemPath;
|
||||
@@ -22,6 +23,9 @@ use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::TurnEnvironmentSelection;
|
||||
use codex_protocol::request_permissions::PermissionGrantScope;
|
||||
use codex_protocol::request_permissions::RequestPermissionProfile;
|
||||
use codex_protocol::request_permissions::RequestPermissionsResponse;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use core_test_support::PathBufExt;
|
||||
@@ -345,6 +349,209 @@ async fn exec_command_routes_to_selected_remote_environment() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn remote_request_permissions_grant_unblocks_later_remote_exec() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
let Some(_remote_env) = get_remote_test_env() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let mut builder = test_codex().with_config(|config| {
|
||||
config.use_experimental_unified_exec_tool = true;
|
||||
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
config.approvals_reviewer = ApprovalsReviewer::User;
|
||||
config
|
||||
.features
|
||||
.enable(Feature::UnifiedExec)
|
||||
.expect("test config should allow feature update");
|
||||
config
|
||||
.features
|
||||
.enable(Feature::ExecPermissionApprovals)
|
||||
.expect("test config should allow feature update");
|
||||
config
|
||||
.features
|
||||
.enable(Feature::RequestPermissionsTool)
|
||||
.expect("test config should allow feature update");
|
||||
});
|
||||
let test = builder.build_with_remote_and_local_env(&server).await?;
|
||||
|
||||
let local_cwd = TempDir::new()?;
|
||||
let remote_cwd = PathBuf::from(format!(
|
||||
"/tmp/codex-remote-request-permissions-{}",
|
||||
SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis()
|
||||
))
|
||||
.abs();
|
||||
let relative_write_root = "granted";
|
||||
let relative_target_path = "granted/request-permissions-output.txt";
|
||||
let remote_write_root = remote_cwd.join(relative_write_root);
|
||||
let remote_target_path = remote_cwd.join(relative_target_path);
|
||||
let local_write_root = local_cwd.path().join(relative_write_root);
|
||||
let local_target_path = local_cwd.path().join(relative_target_path);
|
||||
fs::create_dir(&local_write_root)?;
|
||||
test.fs()
|
||||
.create_directory(
|
||||
&remote_write_root,
|
||||
CreateDirectoryOptions { recursive: true },
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let expected_permissions = RequestPermissionProfile {
|
||||
file_system: Some(FileSystemPermissions::from_read_write_roots(
|
||||
Some(vec![]),
|
||||
Some(vec![remote_write_root.clone()]),
|
||||
)),
|
||||
..RequestPermissionProfile::default()
|
||||
};
|
||||
let approved_response = RequestPermissionsResponse {
|
||||
permissions: expected_permissions.clone(),
|
||||
scope: PermissionGrantScope::Turn,
|
||||
strict_auto_review: false,
|
||||
};
|
||||
let command = format!(
|
||||
"printf 'remote-request-permissions-ok' > {relative_target_path} && cat {relative_target_path}"
|
||||
);
|
||||
let response_mock = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-request-permissions-remote-1"),
|
||||
ev_function_call(
|
||||
"permissions-call",
|
||||
"request_permissions",
|
||||
&json!({
|
||||
"environment_id": REMOTE_ENVIRONMENT_ID,
|
||||
"reason": "Allow writing inside the selected remote environment",
|
||||
"permissions": {
|
||||
"file_system": {
|
||||
"write": [relative_write_root],
|
||||
},
|
||||
},
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
ev_completed("resp-request-permissions-remote-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-request-permissions-remote-2"),
|
||||
ev_function_call(
|
||||
"exec-call",
|
||||
"exec_command",
|
||||
&json!({
|
||||
"shell": "/bin/sh",
|
||||
"cmd": command,
|
||||
"login": false,
|
||||
"yield_time_ms": 1_000,
|
||||
"environment_id": REMOTE_ENVIRONMENT_ID,
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
ev_completed("resp-request-permissions-remote-2"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-request-permissions-remote-3"),
|
||||
ev_assistant_message("msg-request-permissions-remote-1", "done"),
|
||||
ev_completed("resp-request-permissions-remote-3"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
submit_turn_with_approval_and_environments(
|
||||
&test,
|
||||
"request permissions, then write in the remote environment",
|
||||
vec![
|
||||
TurnEnvironmentSelection {
|
||||
environment_id: LOCAL_ENVIRONMENT_ID.to_string(),
|
||||
cwd: local_cwd.path().abs(),
|
||||
},
|
||||
TurnEnvironmentSelection {
|
||||
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
|
||||
cwd: remote_cwd.clone(),
|
||||
},
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let event = wait_for_event(&test.codex, |event| {
|
||||
matches!(
|
||||
event,
|
||||
EventMsg::RequestPermissions(_) | EventMsg::TurnComplete(_)
|
||||
)
|
||||
})
|
||||
.await;
|
||||
let EventMsg::RequestPermissions(request) = event else {
|
||||
panic!("expected remote request_permissions before completion: {event:?}");
|
||||
};
|
||||
assert_eq!(request.call_id, "permissions-call");
|
||||
assert_eq!(
|
||||
request.environment_id.as_deref(),
|
||||
Some(REMOTE_ENVIRONMENT_ID)
|
||||
);
|
||||
assert_eq!(request.cwd.as_ref(), Some(&remote_cwd));
|
||||
assert_eq!(request.permissions, expected_permissions);
|
||||
|
||||
test.codex
|
||||
.submit(Op::RequestPermissionsResponse {
|
||||
id: "permissions-call".to_string(),
|
||||
response: approved_response.clone(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let event = wait_for_event(&test.codex, |event| {
|
||||
matches!(
|
||||
event,
|
||||
EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_)
|
||||
)
|
||||
})
|
||||
.await;
|
||||
match event {
|
||||
EventMsg::TurnComplete(_) => {}
|
||||
EventMsg::ExecApprovalRequest(approval) => {
|
||||
panic!("remote request_permissions grant should preapprove exec: {approval:?}");
|
||||
}
|
||||
other => panic!("unexpected event: {other:?}"),
|
||||
}
|
||||
|
||||
let permissions_output: RequestPermissionsResponse = serde_json::from_str(
|
||||
&response_mock
|
||||
.function_call_output_text("permissions-call")
|
||||
.expect("expected request_permissions output"),
|
||||
)?;
|
||||
assert_eq!(permissions_output, approved_response);
|
||||
let exec_output = response_mock
|
||||
.function_call_output_text("exec-call")
|
||||
.expect("expected exec output");
|
||||
assert!(
|
||||
exec_output.contains("remote-request-permissions-ok"),
|
||||
"unexpected exec output: {exec_output}",
|
||||
);
|
||||
assert_eq!(
|
||||
test.fs()
|
||||
.read_file_text(&remote_target_path, /*sandbox*/ None)
|
||||
.await?,
|
||||
"remote-request-permissions-ok"
|
||||
);
|
||||
assert!(
|
||||
!local_target_path.exists(),
|
||||
"remote exec should not write through the local environment"
|
||||
);
|
||||
|
||||
test.fs()
|
||||
.remove(
|
||||
&remote_cwd,
|
||||
RemoveOptions {
|
||||
recursive: true,
|
||||
force: true,
|
||||
},
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn apply_patch_freeform_routes_to_selected_remote_environment() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -4,7 +4,7 @@ This repository uses Bazel to build the Rust workspace under `codex-rs`.
|
||||
Cargo remains the source of truth for crates and features, while Bazel
|
||||
provides hermetic builds, toolchains, and cross-platform artifacts.
|
||||
|
||||
As of 1/9/2026, this setup is still experimental as we stabilize it.
|
||||
As of 6/1/2026, this setup is still experimental as we stabilize it.
|
||||
|
||||
## High-level layout
|
||||
|
||||
@@ -20,6 +20,118 @@ As of 1/9/2026, this setup is still experimental as we stabilize it.
|
||||
makes some adjustments if the crate needs additional compile-time or runtime data,
|
||||
or other customizations.
|
||||
|
||||
## Running Bazel locally
|
||||
|
||||
The repository root `justfile` exposes the common Bazel entry points:
|
||||
|
||||
```bash
|
||||
just bazel-test
|
||||
just bazel-clippy
|
||||
```
|
||||
|
||||
Ordinary local `bazel` and `just` invocations run locally. BuildBuddy cache,
|
||||
build event upload, downloads, and remote execution are opt-in configurations.
|
||||
|
||||
## BuildBuddy
|
||||
|
||||
Codex uses BuildBuddy for a shared Bazel cache and remoted builds and tests. To use it
|
||||
to speed up your builds and tests you'll need to provide an API key and select a
|
||||
configuration.
|
||||
|
||||
### BuildBuddy API key
|
||||
|
||||
If you're an OpenAI employee, log in to https://openai.buildbuddy.io and use Google sign-in.
|
||||
|
||||
Create a BuildBuddy API key as described in BuildBuddy's [Authentication Guide][bb-auth-guide],
|
||||
then add it to `~/.bazelrc`:
|
||||
|
||||
```bazelrc
|
||||
# Local machine only; this file contains a BuildBuddy credential.
|
||||
common --remote_header=x-buildbuddy-api-key=<your-buildbuddy-api-key>
|
||||
```
|
||||
|
||||
Keeping the credential outside the workspace reduces the risk of accidentally
|
||||
committing it.
|
||||
|
||||
If you need different API keys for different projects, put the API key in
|
||||
`%workspace%/user.bazelrc` instead. The checked-in `.bazelrc` optionally imports
|
||||
that file, and `.gitignore` excludes it. Do not commit or share a file containing
|
||||
the credential.
|
||||
|
||||
[bb-auth-guide]: https://www.buildbuddy.io/docs/guide-auth/#managing-keys
|
||||
|
||||
### Selecting a remote build configuration
|
||||
|
||||
OpenAI employees should default to the OpenAI host with remote execution unless
|
||||
they have a reason to choose another configuration. Add the following configuration
|
||||
to `%workspace%/user.bazelrc`:
|
||||
|
||||
```bazelrc
|
||||
common --config=buildbuddy-openai-rbe
|
||||
```
|
||||
|
||||
OpenAI employees who don't want remote execution can use `buildbuddy-openai`. External users
|
||||
should use `buildbuddy-generic-rbe` or `buildbuddy-generic`. See below for details on these
|
||||
configurations.
|
||||
|
||||
### All remote configurations
|
||||
|
||||
GitHub Actions routes Bazel build and output-resolution commands through
|
||||
`.github/scripts/run_bazel_with_buildbuddy.py`. Higher-level helpers such as
|
||||
`.github/scripts/run-bazel-ci.sh` and `.github/scripts/rusty_v8_bazel.py`
|
||||
delegate remote configuration selection to that wrapper. The wrapper reads the
|
||||
GitHub Actions repository and event payload rather than relying on workflow
|
||||
files to duplicate tenant-selection logic.
|
||||
|
||||
Loading-phase target-discovery `bazel query` commands run locally because they
|
||||
only enumerate labels and do not need remote caches or execution.
|
||||
|
||||
The `Cache/BES` host is also used for remote downloads.
|
||||
|
||||
| Invocation/config | Key Required | Cache/BES | Build exec | Test exec |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `bazel ...` | No | None | Local | Local |
|
||||
| `bazel ... --config=buildbuddy-generic` | Yes | `remote.buildbuddy.io` | Local | Local |
|
||||
| `bazel ... --config=buildbuddy-generic-rbe` | Yes | `remote.buildbuddy.io` | Remote | Remote |
|
||||
| `bazel ... --config=buildbuddy-openai` | Yes | `openai.buildbuddy.io` | Local | Local |
|
||||
| `bazel ... --config=buildbuddy-openai-rbe` | Yes | `openai.buildbuddy.io` | Remote | Remote |
|
||||
|
||||
Without an API key, the wrapper removes remote CI configurations and runs
|
||||
locally. With a key, workflows choose the host as follows:
|
||||
|
||||
| Run | Key | Uses OpenAI BuildBuddy Host |
|
||||
| --- | --- | --- |
|
||||
| Push to `main` in `openai/codex` | Yes | Yes |
|
||||
| `workflow_dispatch` in `openai/codex` | Yes | Yes |
|
||||
| Same-repository pull request in `openai/codex` | Yes | Yes |
|
||||
| Fork pull request into `openai/codex` | No | No; local |
|
||||
| Push or `workflow_dispatch` in a fork with a key | Yes | No; generic host |
|
||||
| Pull request run in a fork repository with a key | Yes | No; generic host |
|
||||
|
||||
CI configurations determine whether builds and tests execute remotely:
|
||||
|
||||
| CI config | Remote config | Build exec | Test exec |
|
||||
| --- | --- | --- | --- |
|
||||
| `ci-linux` | `*-rbe` | Remote host | Remote host |
|
||||
| `ci-v8` | `*-rbe` | Remote host | Remote host |
|
||||
| `ci-macos` | `*-rbe` | Remote host | Local |
|
||||
| `ci-windows-cross` | `*-rbe` | Remote host | Local |
|
||||
| `ci-windows` | non-RBE | Local | Local |
|
||||
| Keyless CI fallback | none | Local | Local |
|
||||
|
||||
To exercise the generic remote configuration with your key:
|
||||
|
||||
```bash
|
||||
BUILDBUDDY_API_KEY=... GITHUB_REPOSITORY=my-fork/codex \
|
||||
./.github/scripts/run_bazel_with_buildbuddy.py \
|
||||
build --config=ci-linux //codex-rs/cli:codex
|
||||
```
|
||||
|
||||
The wrapper selects the OpenAI host only inside GitHub Actions for a trusted
|
||||
run in `openai/codex`. A missing or malformed pull request event
|
||||
payload fails closed to the generic host. For local OpenAI host access, use
|
||||
the `user.bazelrc` configuration above.
|
||||
|
||||
## Evolving the setup
|
||||
|
||||
When you add or change Rust dependencies, update the Cargo.toml/Cargo.lock as normal.
|
||||
|
||||
@@ -2,6 +2,7 @@ use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_protocol::protocol::TokenUsageInfo;
|
||||
use codex_tools::ToolCall;
|
||||
@@ -12,6 +13,7 @@ use crate::ExtensionData;
|
||||
mod prompt;
|
||||
mod thread_lifecycle;
|
||||
mod tool_lifecycle;
|
||||
mod turn_input;
|
||||
mod turn_lifecycle;
|
||||
|
||||
pub use prompt::PromptFragment;
|
||||
@@ -25,6 +27,8 @@ pub use tool_lifecycle::ToolCallSource;
|
||||
pub use tool_lifecycle::ToolFinishInput;
|
||||
pub use tool_lifecycle::ToolLifecycleFuture;
|
||||
pub use tool_lifecycle::ToolStartInput;
|
||||
pub use turn_input::TurnInputContext;
|
||||
pub use turn_input::TurnInputEnvironment;
|
||||
pub use turn_lifecycle::TurnAbortInput;
|
||||
pub use turn_lifecycle::TurnErrorInput;
|
||||
pub use turn_lifecycle::TurnStartInput;
|
||||
@@ -85,6 +89,25 @@ pub trait TurnLifecycleContributor: Send + Sync {
|
||||
async fn on_turn_error(&self, _input: TurnErrorInput<'_>) {}
|
||||
}
|
||||
|
||||
/// WARNING: DO NOT USE YET
|
||||
/// Extension contribution that can add turn-local model input.
|
||||
///
|
||||
/// Implementations should resolve only the model-visible input they own and
|
||||
/// must preserve authority boundaries for external resources. Expensive or
|
||||
/// host-specific dependencies belong on the extension value installed by the
|
||||
/// host, not in this input.
|
||||
#[async_trait::async_trait]
|
||||
pub trait TurnInputContributor: Send + Sync {
|
||||
/// Returns additional model input items for one submitted turn.
|
||||
async fn contribute(
|
||||
&self,
|
||||
input: TurnInputContext,
|
||||
session_store: &ExtensionData,
|
||||
thread_store: &ExtensionData,
|
||||
turn_store: &ExtensionData,
|
||||
) -> Vec<ResponseItem>;
|
||||
}
|
||||
|
||||
/// Contributor for host-owned configuration changes.
|
||||
///
|
||||
/// Implementations should treat the supplied values as immutable before/after
|
||||
|
||||
25
codex-rs/ext/extension-api/src/contributors/turn_input.rs
Normal file
25
codex-rs/ext/extension-api/src/contributors/turn_input.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_protocol::user_input::UserInput;
|
||||
|
||||
/// Host-owned turn environment summary visible to turn-input contributors.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TurnInputEnvironment {
|
||||
/// Stable host environment id used to route executor-scoped capabilities.
|
||||
pub environment_id: String,
|
||||
/// Effective working directory for this turn in the environment.
|
||||
pub cwd: PathBuf,
|
||||
/// Whether this is the primary environment for the turn.
|
||||
pub is_primary: bool,
|
||||
}
|
||||
|
||||
/// Turn facts supplied before the host records turn-local model input items.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TurnInputContext {
|
||||
/// Stable host-owned turn identifier.
|
||||
pub turn_id: String,
|
||||
/// User input submitted for this turn.
|
||||
pub user_input: Vec<UserInput>,
|
||||
/// Resolved turn environments, in host priority order.
|
||||
pub environments: Vec<TurnInputEnvironment>,
|
||||
}
|
||||
@@ -10,6 +10,7 @@ pub use capabilities::NoopExtensionEventSink;
|
||||
pub use capabilities::NoopResponseItemInjector;
|
||||
pub use capabilities::ResponseItemInjectionFuture;
|
||||
pub use capabilities::ResponseItemInjector;
|
||||
pub use codex_protocol::models::ResponseItem;
|
||||
pub use codex_tools::ConversationHistory;
|
||||
pub use codex_tools::ExtensionTurnItem;
|
||||
pub use codex_tools::FunctionCallError;
|
||||
@@ -46,6 +47,9 @@ pub use contributors::ToolLifecycleFuture;
|
||||
pub use contributors::ToolStartInput;
|
||||
pub use contributors::TurnAbortInput;
|
||||
pub use contributors::TurnErrorInput;
|
||||
pub use contributors::TurnInputContext;
|
||||
pub use contributors::TurnInputContributor;
|
||||
pub use contributors::TurnInputEnvironment;
|
||||
pub use contributors::TurnItemContributor;
|
||||
pub use contributors::TurnLifecycleContributor;
|
||||
pub use contributors::TurnStartInput;
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::ThreadLifecycleContributor;
|
||||
use crate::TokenUsageContributor;
|
||||
use crate::ToolContributor;
|
||||
use crate::ToolLifecycleContributor;
|
||||
use crate::TurnInputContributor;
|
||||
use crate::TurnItemContributor;
|
||||
use crate::TurnLifecycleContributor;
|
||||
|
||||
@@ -23,6 +24,7 @@ pub struct ExtensionRegistryBuilder<C: Sync> {
|
||||
config_contributors: Vec<Arc<dyn ConfigContributor<C>>>,
|
||||
token_usage_contributors: Vec<Arc<dyn TokenUsageContributor>>,
|
||||
context_contributors: Vec<Arc<dyn ContextContributor>>,
|
||||
turn_input_contributors: Vec<Arc<dyn TurnInputContributor>>,
|
||||
tool_contributors: Vec<Arc<dyn ToolContributor>>,
|
||||
tool_lifecycle_contributors: Vec<Arc<dyn ToolLifecycleContributor>>,
|
||||
turn_item_contributors: Vec<Arc<dyn TurnItemContributor>>,
|
||||
@@ -39,6 +41,7 @@ impl<C: Sync> Default for ExtensionRegistryBuilder<C> {
|
||||
token_usage_contributors: Vec::new(),
|
||||
approval_review_contributors: Vec::new(),
|
||||
context_contributors: Vec::new(),
|
||||
turn_input_contributors: Vec::new(),
|
||||
tool_contributors: Vec::new(),
|
||||
tool_lifecycle_contributors: Vec::new(),
|
||||
turn_item_contributors: Vec::new(),
|
||||
@@ -98,6 +101,11 @@ impl<C: Sync> ExtensionRegistryBuilder<C> {
|
||||
self.context_contributors.push(contributor);
|
||||
}
|
||||
|
||||
/// Registers one turn-input contributor.
|
||||
pub fn turn_input_contributor(&mut self, contributor: Arc<dyn TurnInputContributor>) {
|
||||
self.turn_input_contributors.push(contributor);
|
||||
}
|
||||
|
||||
/// Registers one native tool contributor.
|
||||
pub fn tool_contributor(&mut self, contributor: Arc<dyn ToolContributor>) {
|
||||
self.tool_contributors.push(contributor);
|
||||
@@ -123,6 +131,7 @@ impl<C: Sync> ExtensionRegistryBuilder<C> {
|
||||
token_usage_contributors: self.token_usage_contributors,
|
||||
approval_review_contributors: self.approval_review_contributors,
|
||||
context_contributors: self.context_contributors,
|
||||
turn_input_contributors: self.turn_input_contributors,
|
||||
tool_contributors: self.tool_contributors,
|
||||
tool_lifecycle_contributors: self.tool_lifecycle_contributors,
|
||||
turn_item_contributors: self.turn_item_contributors,
|
||||
@@ -138,6 +147,7 @@ pub struct ExtensionRegistry<C: Sync> {
|
||||
config_contributors: Vec<Arc<dyn ConfigContributor<C>>>,
|
||||
token_usage_contributors: Vec<Arc<dyn TokenUsageContributor>>,
|
||||
context_contributors: Vec<Arc<dyn ContextContributor>>,
|
||||
turn_input_contributors: Vec<Arc<dyn TurnInputContributor>>,
|
||||
tool_contributors: Vec<Arc<dyn ToolContributor>>,
|
||||
tool_lifecycle_contributors: Vec<Arc<dyn ToolLifecycleContributor>>,
|
||||
turn_item_contributors: Vec<Arc<dyn TurnItemContributor>>,
|
||||
@@ -195,6 +205,11 @@ impl<C: Sync> ExtensionRegistry<C> {
|
||||
&self.context_contributors
|
||||
}
|
||||
|
||||
/// Returns the registered turn-input contributors.
|
||||
pub fn turn_input_contributors(&self) -> &[Arc<dyn TurnInputContributor>] {
|
||||
&self.turn_input_contributors
|
||||
}
|
||||
|
||||
/// Returns the registered native tool contributors.
|
||||
pub fn tool_contributors(&self) -> &[Arc<dyn ToolContributor>] {
|
||||
&self.tool_contributors
|
||||
|
||||
@@ -77,9 +77,9 @@ impl ToolExecutor<ToolCall> for ImageGenerationTool {
|
||||
imagegen_tool_spec()
|
||||
}
|
||||
|
||||
/// Keeps this model-facing tool out of the nested code-mode tool surface.
|
||||
/// Exposes image generation directly and through the nested code-mode tool surface.
|
||||
fn exposure(&self) -> ToolExposure {
|
||||
ToolExposure::DirectModelOnly
|
||||
ToolExposure::Direct
|
||||
}
|
||||
|
||||
/// Executes the selected image operation and returns the completed image result.
|
||||
|
||||
6
codex-rs/ext/skills/BUILD.bazel
Normal file
6
codex-rs/ext/skills/BUILD.bazel
Normal file
@@ -0,0 +1,6 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "skills",
|
||||
crate_name = "codex_skills_extension",
|
||||
)
|
||||
19
codex-rs/ext/skills/Cargo.toml
Normal file
19
codex-rs/ext/skills/Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
name = "codex-skills-extension"
|
||||
version.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "codex_skills_extension"
|
||||
path = "src/lib.rs"
|
||||
test = false
|
||||
doctest = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
async-trait = { workspace = true }
|
||||
codex-core = { workspace = true }
|
||||
codex-extension-api = { workspace = true }
|
||||
86
codex-rs/ext/skills/src/catalog.rs
Normal file
86
codex-rs/ext/skills/src/catalog.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
/// Source authority that owns a skill package and must be used to read it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum SkillSourceKind {
|
||||
Host,
|
||||
Executor,
|
||||
Remote,
|
||||
}
|
||||
|
||||
/// Opaque authority identity for list/read routing.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct SkillAuthority {
|
||||
pub kind: SkillSourceKind,
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
impl SkillAuthority {
|
||||
pub fn new(kind: SkillSourceKind, id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
id: id.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Opaque package id. Callers should not parse local paths out of this value.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct SkillPackageId(pub String);
|
||||
|
||||
/// Opaque resource id inside a skill package.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct SkillResourceId(pub String);
|
||||
|
||||
/// Metadata shown in the always-visible skills catalog.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SkillCatalogEntry {
|
||||
pub id: SkillPackageId,
|
||||
pub authority: SkillAuthority,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub entrypoint: SkillResourceId,
|
||||
}
|
||||
|
||||
/// Merged catalog for one turn.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct SkillCatalog {
|
||||
pub entries: Vec<SkillCatalogEntry>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
impl SkillCatalog {
|
||||
pub fn extend(&mut self, other: SkillCatalog) {
|
||||
// TODO(skills-extension): dedupe by authority-bound id first, then
|
||||
// apply name precedence/conflict rules for user-facing mention
|
||||
// resolution. Names are not stable identities.
|
||||
self.entries.extend(other.entries);
|
||||
self.warnings.extend(other.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
/// Contents returned after resolving a skill resource through its owner.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SkillReadResult {
|
||||
pub resource: SkillResourceId,
|
||||
pub contents: String,
|
||||
}
|
||||
|
||||
/// Search results for a package whose files are not readable through ordinary
|
||||
/// executor filesystem access.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct SkillSearchResult {
|
||||
pub matches: Vec<SkillSearchMatch>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SkillSearchMatch {
|
||||
pub resource: SkillResourceId,
|
||||
pub title: String,
|
||||
pub snippet: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SkillProviderError {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
pub type SkillProviderResult<T> = Result<T, SkillProviderError>;
|
||||
140
codex-rs/ext/skills/src/extension.rs
Normal file
140
codex-rs/ext/skills/src/extension.rs
Normal file
@@ -0,0 +1,140 @@
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_core::config::Config;
|
||||
use codex_extension_api::ConfigContributor;
|
||||
use codex_extension_api::ContextContributor;
|
||||
use codex_extension_api::ExtensionData;
|
||||
use codex_extension_api::ExtensionRegistryBuilder;
|
||||
use codex_extension_api::PromptFragment;
|
||||
use codex_extension_api::ThreadLifecycleContributor;
|
||||
use codex_extension_api::ThreadStartInput;
|
||||
use codex_extension_api::TurnLifecycleContributor;
|
||||
use codex_extension_api::TurnStartInput;
|
||||
|
||||
use crate::provider::SkillListQuery;
|
||||
use crate::providers::SkillProviders;
|
||||
use crate::state::SkillsExtensionConfig;
|
||||
use crate::state::SkillsTurnState;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct SkillsExtension {
|
||||
providers: SkillProviders,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ThreadLifecycleContributor<Config> for SkillsExtension {
|
||||
async fn on_thread_start(&self, input: ThreadStartInput<'_, Config>) {
|
||||
// TODO(skills-extension): this is only the thread-level config snapshot.
|
||||
// Skills are loaded per turn today because cwd, plugin roots, config
|
||||
// layers, and the primary environment filesystem can change between
|
||||
// turns. The real migration needs a turn-preparation hook before model
|
||||
// input construction, not just thread startup.
|
||||
input
|
||||
.thread_store
|
||||
.insert(SkillsExtensionConfig::from_config(input.config));
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigContributor<Config> for SkillsExtension {
|
||||
fn on_config_changed(
|
||||
&self,
|
||||
_session_store: &ExtensionData,
|
||||
thread_store: &ExtensionData,
|
||||
_previous_config: &Config,
|
||||
new_config: &Config,
|
||||
) {
|
||||
// TODO(skills-extension): update any cached/listing state that depends
|
||||
// on skill config overrides, bundled skills, or include_instructions.
|
||||
thread_store.insert(SkillsExtensionConfig::from_config(new_config));
|
||||
}
|
||||
}
|
||||
|
||||
impl ContextContributor for SkillsExtension {
|
||||
fn contribute<'a>(
|
||||
&'a self,
|
||||
_session_store: &'a ExtensionData,
|
||||
thread_store: &'a ExtensionData,
|
||||
) -> Pin<Box<dyn Future<Output = Vec<PromptFragment>> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
let Some(config) = thread_store.get::<SkillsExtensionConfig>() else {
|
||||
return Vec::new();
|
||||
};
|
||||
if !config.include_instructions {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// TODO(skills-extension): render the available-skills developer
|
||||
// block from the merged per-turn SkillCatalog. This should
|
||||
// preserve the existing bounded metadata budget, root aliasing,
|
||||
// warning behavior, and telemetry side effects.
|
||||
//
|
||||
// TODO(skills-extension): avoid using raw PromptFragment strings
|
||||
// for final skills context if the extension API grows typed
|
||||
// contextual fragments. Existing skill blocks are typed so resume
|
||||
// and history filtering can recognize them reliably.
|
||||
//
|
||||
// TODO(skills-extension): ContextContributor currently cannot see
|
||||
// the turn_store, so it cannot read the per-turn catalog seeded by
|
||||
// the turn provider path below. This is the main extension-api gap
|
||||
// to close before skills can move out of codex-core.
|
||||
Vec::new()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl TurnLifecycleContributor for SkillsExtension {
|
||||
async fn on_turn_start(&self, input: TurnStartInput<'_>) {
|
||||
// TODO(skills-extension): replace this lifecycle callback with a real
|
||||
// turn-input contributor in codex-extension-api. This placeholder only
|
||||
// demonstrates where provider aggregation belongs; it cannot resolve
|
||||
// real skills because this hook does not receive cwd, executor
|
||||
// selections, effective plugins/materialized plugin skill roots,
|
||||
// connector slug counts, user input, cancellation, analytics, or a
|
||||
// response-item output channel.
|
||||
let query = SkillListQuery::placeholder_for_turn(input.turn_id);
|
||||
let catalog = self
|
||||
.providers
|
||||
.list_for_turn(query)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
input.turn_store.insert(SkillsTurnState {
|
||||
catalog,
|
||||
entrypoints_injected: false,
|
||||
});
|
||||
|
||||
// TODO(skills-extension): after catalog resolution, collect explicit
|
||||
// skill mentions from structured UserInput and text mentions.
|
||||
//
|
||||
// TODO(skills-extension): inject selected entrypoints as typed
|
||||
// contextual user fragments, preserving <skill>...</skill> history
|
||||
// recognition and bounded body size limits.
|
||||
//
|
||||
// TODO(skills-extension): move explicit $skill mention resolution,
|
||||
// SKILL.md reads, skill body injection, and MCP dependency prompting
|
||||
// out of codex-core's turn assembly once that hook exists.
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs the skills extension contributor sketch.
|
||||
///
|
||||
/// TODO(skills-extension): pass host capabilities here rather than letting the
|
||||
/// extension depend on Session. The final extension needs capability objects for
|
||||
/// loading skill roots, emitting warnings, tracking analytics, prompting for MCP
|
||||
/// dependency install, refreshing MCP servers, and serving app-server catalog
|
||||
/// requests.
|
||||
///
|
||||
/// TODO(skills-extension): plugin handling should stay outside the runtime
|
||||
/// skills model. Plugins are bundle/install units; once installed or refreshed,
|
||||
/// their skill descriptors/roots should be handed to this extension just like
|
||||
/// any other host-owned skill source.
|
||||
pub fn install(registry: &mut ExtensionRegistryBuilder<Config>) {
|
||||
let extension = Arc::new(SkillsExtension::default());
|
||||
registry.thread_lifecycle_contributor(extension.clone());
|
||||
registry.config_contributor(extension.clone());
|
||||
registry.prompt_contributor(extension.clone());
|
||||
registry.turn_lifecycle_contributor(extension);
|
||||
}
|
||||
7
codex-rs/ext/skills/src/lib.rs
Normal file
7
codex-rs/ext/skills/src/lib.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
pub mod catalog;
|
||||
mod extension;
|
||||
pub mod provider;
|
||||
mod providers;
|
||||
mod state;
|
||||
|
||||
pub use extension::install;
|
||||
63
codex-rs/ext/skills/src/provider.rs
Normal file
63
codex-rs/ext/skills/src/provider.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use std::future::Future;
|
||||
|
||||
use crate::catalog::SkillAuthority;
|
||||
use crate::catalog::SkillCatalog;
|
||||
use crate::catalog::SkillPackageId;
|
||||
use crate::catalog::SkillProviderResult;
|
||||
use crate::catalog::SkillReadResult;
|
||||
use crate::catalog::SkillResourceId;
|
||||
use crate::catalog::SkillSearchResult;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SkillListQuery {
|
||||
pub turn_id: String,
|
||||
pub executor_authorities: Vec<SkillAuthority>,
|
||||
pub include_host_skills: bool,
|
||||
pub include_remote_skills: bool,
|
||||
}
|
||||
|
||||
impl SkillListQuery {
|
||||
pub(crate) fn placeholder_for_turn(turn_id: &str) -> Self {
|
||||
Self {
|
||||
turn_id: turn_id.to_string(),
|
||||
executor_authorities: Vec::new(),
|
||||
include_host_skills: true,
|
||||
include_remote_skills: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SkillReadRequest {
|
||||
pub authority: SkillAuthority,
|
||||
pub resource: SkillResourceId,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SkillSearchRequest {
|
||||
pub authority: SkillAuthority,
|
||||
pub package: SkillPackageId,
|
||||
pub query: String,
|
||||
}
|
||||
|
||||
/// Source-specific skill catalog and resource access.
|
||||
///
|
||||
/// Implementations must preserve authority boundaries: a resource listed by a
|
||||
/// provider must be read or searched through the same provider/authority rather
|
||||
/// than converted into an ambient local path.
|
||||
pub trait SkillProvider: Send + Sync {
|
||||
fn list(
|
||||
&self,
|
||||
query: SkillListQuery,
|
||||
) -> impl Future<Output = SkillProviderResult<SkillCatalog>> + Send;
|
||||
|
||||
fn read(
|
||||
&self,
|
||||
request: SkillReadRequest,
|
||||
) -> impl Future<Output = SkillProviderResult<SkillReadResult>> + Send;
|
||||
|
||||
fn search(
|
||||
&self,
|
||||
request: SkillSearchRequest,
|
||||
) -> impl Future<Output = SkillProviderResult<SkillSearchResult>> + Send;
|
||||
}
|
||||
56
codex-rs/ext/skills/src/providers/executor.rs
Normal file
56
codex-rs/ext/skills/src/providers/executor.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use std::future;
|
||||
|
||||
use crate::catalog::SkillCatalog;
|
||||
use crate::catalog::SkillProviderResult;
|
||||
use crate::catalog::SkillReadResult;
|
||||
use crate::catalog::SkillSearchResult;
|
||||
use crate::provider::SkillListQuery;
|
||||
use crate::provider::SkillProvider;
|
||||
use crate::provider::SkillReadRequest;
|
||||
use crate::provider::SkillSearchRequest;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct ExecutorSkillProvider;
|
||||
|
||||
impl SkillProvider for ExecutorSkillProvider {
|
||||
fn list(
|
||||
&self,
|
||||
_query: SkillListQuery,
|
||||
) -> impl Future<Output = SkillProviderResult<SkillCatalog>> + Send {
|
||||
future::ready(Ok(SkillCatalog::default()))
|
||||
// TODO(skills-extension): list repo/workspace skills from each
|
||||
// executor authority selected for the turn.
|
||||
//
|
||||
// TODO(skills-extension): if the executor exposes filesystem reads,
|
||||
// preserve the existing SKILL.md discovery semantics. If CCA/no-FS
|
||||
// applies, query an executor catalog/read capability instead.
|
||||
//
|
||||
// TODO(skills-extension): include the executor/environment id in skill
|
||||
// identity so two executors with the same path/name do not collide.
|
||||
}
|
||||
|
||||
fn read(
|
||||
&self,
|
||||
request: SkillReadRequest,
|
||||
) -> impl Future<Output = SkillProviderResult<SkillReadResult>> + Send {
|
||||
future::ready(Err(crate::catalog::SkillProviderError {
|
||||
message: format!(
|
||||
"executor skill resource `{}` is not implemented",
|
||||
request.resource.0
|
||||
),
|
||||
}))
|
||||
// TODO(skills-extension): route reads back to the executor authority
|
||||
// that listed the resource. Do not mint local paths from remote or
|
||||
// non-filesystem executor resources.
|
||||
}
|
||||
|
||||
fn search(
|
||||
&self,
|
||||
_request: SkillSearchRequest,
|
||||
) -> impl Future<Output = SkillProviderResult<SkillSearchResult>> + Send {
|
||||
future::ready(Ok(SkillSearchResult::default()))
|
||||
// TODO(skills-extension): support search for executor skills only when
|
||||
// the executor offers a catalog/search API. For ordinary filesystem
|
||||
// executors, the model can keep using regular file reads/search tools.
|
||||
}
|
||||
}
|
||||
65
codex-rs/ext/skills/src/providers/host.rs
Normal file
65
codex-rs/ext/skills/src/providers/host.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
use std::future;
|
||||
|
||||
use crate::catalog::SkillCatalog;
|
||||
use crate::catalog::SkillProviderResult;
|
||||
use crate::catalog::SkillReadResult;
|
||||
use crate::catalog::SkillSearchResult;
|
||||
use crate::provider::SkillListQuery;
|
||||
use crate::provider::SkillProvider;
|
||||
use crate::provider::SkillReadRequest;
|
||||
use crate::provider::SkillSearchRequest;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct HostSkillProvider;
|
||||
|
||||
impl SkillProvider for HostSkillProvider {
|
||||
fn list(
|
||||
&self,
|
||||
_query: SkillListQuery,
|
||||
) -> impl Future<Output = SkillProviderResult<SkillCatalog>> + Send {
|
||||
future::ready(Ok(SkillCatalog::default()))
|
||||
// TODO(skills-extension): list bundled/system/user/plugin-installed
|
||||
// skills owned by the Codex host. This is the source for skills that
|
||||
// are not tied to a particular executor authority.
|
||||
//
|
||||
// TODO(skills-extension): plugins should be treated as packaging and
|
||||
// installation only. After a plugin is downloaded, cached, refreshed,
|
||||
// or installed, its skill roots/descriptors should enter this provider
|
||||
// and then use the normal skills catalog/read/injection code.
|
||||
//
|
||||
// TODO(skills-extension): remote skills that are materialized locally
|
||||
// by plugin install or explicit download should also hand off here
|
||||
// rather than remain remote-provider entries at runtime.
|
||||
//
|
||||
// TODO(skills-extension): keep current bundled system skill install or
|
||||
// replace it with embedded host assets so CCA/no-FS hosts do not depend
|
||||
// on local writable skill cache directories.
|
||||
}
|
||||
|
||||
fn read(
|
||||
&self,
|
||||
request: SkillReadRequest,
|
||||
) -> impl Future<Output = SkillProviderResult<SkillReadResult>> + Send {
|
||||
future::ready(Err(crate::catalog::SkillProviderError {
|
||||
message: format!(
|
||||
"host skill resource `{}` is not implemented",
|
||||
request.resource.0
|
||||
),
|
||||
}))
|
||||
// TODO(skills-extension): read host-owned entrypoints and supporting
|
||||
// resources by opaque id, not by assuming a local filesystem path.
|
||||
//
|
||||
// TODO(skills-extension): for plugin-installed skills, route reads
|
||||
// through the materialized plugin cache/root that produced the catalog
|
||||
// entry, while keeping the public id opaque and authority-bound.
|
||||
}
|
||||
|
||||
fn search(
|
||||
&self,
|
||||
_request: SkillSearchRequest,
|
||||
) -> impl Future<Output = SkillProviderResult<SkillSearchResult>> + Send {
|
||||
future::ready(Ok(SkillSearchResult::default()))
|
||||
// TODO(skills-extension): decide whether host skills need search, or
|
||||
// whether direct read by opaque resource id is enough.
|
||||
}
|
||||
}
|
||||
50
codex-rs/ext/skills/src/providers/mod.rs
Normal file
50
codex-rs/ext/skills/src/providers/mod.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
mod executor;
|
||||
mod host;
|
||||
mod remote;
|
||||
|
||||
use crate::catalog::SkillCatalog;
|
||||
use crate::catalog::SkillProviderResult;
|
||||
use crate::provider::SkillListQuery;
|
||||
use crate::provider::SkillProvider;
|
||||
|
||||
use executor::ExecutorSkillProvider;
|
||||
use host::HostSkillProvider;
|
||||
use remote::RemoteSkillProvider;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct SkillProviders {
|
||||
host: HostSkillProvider,
|
||||
executor: ExecutorSkillProvider,
|
||||
remote: RemoteSkillProvider,
|
||||
}
|
||||
|
||||
impl SkillProviders {
|
||||
pub(crate) async fn list_for_turn(
|
||||
&self,
|
||||
query: SkillListQuery,
|
||||
) -> SkillProviderResult<SkillCatalog> {
|
||||
let mut catalog = SkillCatalog::default();
|
||||
|
||||
if query.include_host_skills {
|
||||
catalog.extend(self.host.list(query.clone()).await?);
|
||||
}
|
||||
|
||||
if !query.executor_authorities.is_empty() {
|
||||
catalog.extend(self.executor.list(query.clone()).await?);
|
||||
}
|
||||
|
||||
if query.include_remote_skills {
|
||||
catalog.extend(self.remote.list(query).await?);
|
||||
}
|
||||
|
||||
// TODO(skills-extension): apply final merged-catalog policy here:
|
||||
// source precedence, duplicate name handling, disabled-skill rules,
|
||||
// product/session-source filtering, and telemetry for omitted entries.
|
||||
//
|
||||
// TODO(skills-extension): treat plugin-installed skills as ordinary
|
||||
// host catalog entries by this point. Plugin identity may remain useful
|
||||
// for display, auth, and uninstall flows, but mention resolution and
|
||||
// entrypoint injection should not special-case plugin packaging.
|
||||
Ok(catalog)
|
||||
}
|
||||
}
|
||||
58
codex-rs/ext/skills/src/providers/remote.rs
Normal file
58
codex-rs/ext/skills/src/providers/remote.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
use std::future;
|
||||
|
||||
use crate::catalog::SkillCatalog;
|
||||
use crate::catalog::SkillProviderResult;
|
||||
use crate::catalog::SkillReadResult;
|
||||
use crate::catalog::SkillSearchResult;
|
||||
use crate::provider::SkillListQuery;
|
||||
use crate::provider::SkillProvider;
|
||||
use crate::provider::SkillReadRequest;
|
||||
use crate::provider::SkillSearchRequest;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct RemoteSkillProvider;
|
||||
|
||||
impl SkillProvider for RemoteSkillProvider {
|
||||
fn list(
|
||||
&self,
|
||||
_query: SkillListQuery,
|
||||
) -> impl Future<Output = SkillProviderResult<SkillCatalog>> + Send {
|
||||
future::ready(Ok(SkillCatalog::default()))
|
||||
// TODO(skills-extension): list org/account/backend skills from a
|
||||
// remote catalog only when they are not installed/materialized into the
|
||||
// host. These skills should use opaque ids and backend authority, not
|
||||
// paths.
|
||||
//
|
||||
// TODO(skills-extension): if a remote skill is downloaded or installed
|
||||
// as part of a plugin-like bundle, hand it to HostSkillProvider for
|
||||
// runtime listing/read instead of keeping a separate remote runtime
|
||||
// path.
|
||||
//
|
||||
// TODO(skills-extension): decide how org policy and local enable/disable
|
||||
// rules combine when the backend supplies a managed skill catalog.
|
||||
}
|
||||
|
||||
fn read(
|
||||
&self,
|
||||
request: SkillReadRequest,
|
||||
) -> impl Future<Output = SkillProviderResult<SkillReadResult>> + Send {
|
||||
future::ready(Err(crate::catalog::SkillProviderError {
|
||||
message: format!(
|
||||
"remote skill resource `{}` is not implemented",
|
||||
request.resource.0
|
||||
),
|
||||
}))
|
||||
// TODO(skills-extension): read remote skill entrypoints and supporting
|
||||
// files through authenticated backend APIs.
|
||||
}
|
||||
|
||||
fn search(
|
||||
&self,
|
||||
_request: SkillSearchRequest,
|
||||
) -> impl Future<Output = SkillProviderResult<SkillSearchResult>> + Send {
|
||||
future::ready(Ok(SkillSearchResult::default()))
|
||||
// TODO(skills-extension): expose model-facing skills/search or resource
|
||||
// APIs for large remote packages so the model can progressively
|
||||
// disclose supporting files without filesystem access.
|
||||
}
|
||||
}
|
||||
24
codex-rs/ext/skills/src/state.rs
Normal file
24
codex-rs/ext/skills/src/state.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use codex_core::config::Config;
|
||||
|
||||
use crate::catalog::SkillCatalog;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct SkillsExtensionConfig {
|
||||
pub(crate) include_instructions: bool,
|
||||
pub(crate) bundled_skills_enabled: bool,
|
||||
}
|
||||
|
||||
impl SkillsExtensionConfig {
|
||||
pub(crate) fn from_config(config: &Config) -> Self {
|
||||
Self {
|
||||
include_instructions: config.include_skill_instructions,
|
||||
bundled_skills_enabled: config.bundled_skills_enabled(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct SkillsTurnState {
|
||||
pub(crate) catalog: SkillCatalog,
|
||||
pub(crate) entrypoints_injected: bool,
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
11
justfile
11
justfile
@@ -83,6 +83,12 @@ test *args:
|
||||
$env:RUST_MIN_STACK = "{{ rust_min_stack }}"; cargo nextest run --no-fail-fast @($args | Select-Object -Skip 1)
|
||||
just bench-smoke
|
||||
|
||||
# Run from the repository root so scripts that resolve paths from `cwd` see
|
||||
# the same layout they use in GitHub Actions.
|
||||
[no-cd]
|
||||
test-github-scripts:
|
||||
{{ python }} -m unittest discover -s {{ justfile_directory() }}/.github/scripts -p 'test_*.py'
|
||||
|
||||
# Run explicit workspace benchmark targets.
|
||||
bench *args:
|
||||
cargo bench --workspace --bench '*' {args}
|
||||
@@ -129,11 +135,8 @@ bazel-clippy:
|
||||
bazel-argument-comment-lint:
|
||||
bazel build --config=argument-comment-lint -- $({{ justfile_directory() }}/tools/argument-comment-lint/list-bazel-targets.sh)
|
||||
|
||||
bazel-remote-test:
|
||||
bazel test --test_tag_filters=-argument-comment-lint //... --config=remote --platforms=//:rbe --keep_going
|
||||
|
||||
build-for-release:
|
||||
bazel build //codex-rs/cli:release_binaries --config=remote
|
||||
bazel build //codex-rs/cli:release_binaries
|
||||
|
||||
# Run the MCP server
|
||||
mcp-server-run *args:
|
||||
|
||||
@@ -20,23 +20,13 @@ while [[ $# -gt 0 ]]; do
|
||||
done
|
||||
|
||||
# Resolve the dynamic targets before printing anything so callers do not
|
||||
# continue with a partial list if `bazel query` fails. Reuse the same CI Bazel
|
||||
# server settings as the subsequent build so Windows jobs do not cold-start a
|
||||
# second Bazel server just for target discovery.
|
||||
if [[ $windows_cross_compile -eq 1 ]]; then
|
||||
manual_rust_test_targets="$(
|
||||
./.github/scripts/run-bazel-query-ci.sh \
|
||||
--windows-cross-compile \
|
||||
--output=label \
|
||||
-- 'kind("rust_test rule", attr(tags, "manual", //codex-rs/... except //codex-rs/v8-poc/...))'
|
||||
)"
|
||||
else
|
||||
manual_rust_test_targets="$(
|
||||
./.github/scripts/run-bazel-query-ci.sh \
|
||||
--output=label \
|
||||
-- 'kind("rust_test rule", attr(tags, "manual", //codex-rs/... except //codex-rs/v8-poc/...))'
|
||||
)"
|
||||
fi
|
||||
# continue with a partial list if `bazel query` fails. Target discovery is
|
||||
# local on all platforms.
|
||||
manual_rust_test_targets="$(
|
||||
./.github/scripts/run-bazel-query-ci.sh \
|
||||
--output=label \
|
||||
-- 'kind("rust_test rule", attr(tags, "manual", //codex-rs/... except //codex-rs/v8-poc/...))'
|
||||
)"
|
||||
if [[ "${RUNNER_OS:-}" != "Windows" ]]; then
|
||||
# Non-Windows clippy jobs lint the native test binaries; the
|
||||
# Windows-cross binaries exist only for the fast Windows test leg.
|
||||
|
||||
Reference in New Issue
Block a user