mirror of
https://github.com/openai/codex.git
synced 2026-10-20 17:38:45 +00:00
Merge 5d531b0a3e into sapling-pr-archive-bolinfest
This commit is contained in:
@@ -38,24 +38,50 @@ common:windows --test_env=WINDIR
|
||||
common --test_env=RUST_MIN_STACK=8388608 # 8 MiB
|
||||
|
||||
common --test_output=errors
|
||||
common --bes_results_url=https://app.buildbuddy.io/invocation/
|
||||
common --bes_backend=grpcs://remote.buildbuddy.io
|
||||
common --remote_cache=grpcs://remote.buildbuddy.io
|
||||
common --remote_download_toplevel
|
||||
common --nobuild_runfile_links
|
||||
# These settings tune BuildBuddy/RBE behavior but do not contact a remote
|
||||
# service unless a `buildbuddy-*` configuration below supplies an endpoint.
|
||||
common --remote_download_toplevel
|
||||
common --remote_timeout=3600
|
||||
common --noexperimental_throttle_remote_action_building
|
||||
common --experimental_remote_execution_keepalive
|
||||
common --grpc_keepalive_time=30s
|
||||
common --experimental_remote_downloader=grpcs://remote.buildbuddy.io
|
||||
|
||||
# Opt-in remote configurations selected by
|
||||
# `.github/scripts/run_bazel_with_buildbuddy.py`. Plain Bazel commands do not
|
||||
# contact BuildBuddy unless a user selects one of these configurations.
|
||||
# Use the generic host for cache, BES, and downloads without remote execution.
|
||||
common:buildbuddy-generic --bes_backend=grpcs://remote.buildbuddy.io
|
||||
common:buildbuddy-generic --bes_results_url=https://app.buildbuddy.io/invocation/
|
||||
common:buildbuddy-generic --remote_cache=grpcs://remote.buildbuddy.io
|
||||
common:buildbuddy-generic --experimental_remote_downloader=grpcs://remote.buildbuddy.io
|
||||
|
||||
# Add remote execution on the generic host.
|
||||
common:buildbuddy-generic-rbe --config=buildbuddy-generic
|
||||
common:buildbuddy-generic-rbe --config=remote
|
||||
common:buildbuddy-generic-rbe --remote_executor=grpcs://remote.buildbuddy.io
|
||||
|
||||
# Use the OpenAI tenant for cache, BES, and downloads without remote execution.
|
||||
common:buildbuddy-openai --bes_backend=grpcs://openai.buildbuddy.io
|
||||
common:buildbuddy-openai --bes_results_url=https://openai.buildbuddy.io/invocation/
|
||||
common:buildbuddy-openai --remote_cache=grpcs://openai.buildbuddy.io
|
||||
common:buildbuddy-openai --experimental_remote_downloader=grpcs://openai.buildbuddy.io
|
||||
|
||||
# Add remote execution on the OpenAI tenant.
|
||||
common:buildbuddy-openai-rbe --config=buildbuddy-openai
|
||||
common:buildbuddy-openai-rbe --config=remote
|
||||
common:buildbuddy-openai-rbe --remote_executor=grpcs://openai.buildbuddy.io
|
||||
|
||||
# This limits both in-flight executions and concurrent downloads. Even with high number
|
||||
# of jobs execution will still be limited by CPU cores, so this just pays a bit of
|
||||
# memory in exchange for higher download concurrency.
|
||||
common --jobs=30
|
||||
|
||||
# Shared remote execution policy. The endpoint-bearing `buildbuddy-*-rbe`
|
||||
# configurations include this group; CI configs override TestRunner below
|
||||
# when tests must remain local on their runner.
|
||||
common:remote --strategy=remote
|
||||
common:remote --extra_execution_platforms=//:rbe
|
||||
common:remote --remote_executor=grpcs://remote.buildbuddy.io
|
||||
common:remote --jobs=800
|
||||
# TODO(team): Evaluate if this actually helps, zbarsky is not sure, everything seems bottlenecked on `core` either way.
|
||||
# Enable pipelined compilation since we are not bound by local CPU count.
|
||||
@@ -146,15 +172,11 @@ common:ci-windows --repo_contents_cache=D:/a/.cache/bazel-repo-contents-cache
|
||||
# Linux crossbuilds don't work until we untangle the libc constraint mess.
|
||||
common:ci-linux --config=ci-bazel
|
||||
common:ci-linux --build_metadata=TAG_os=linux
|
||||
common:ci-linux --config=remote
|
||||
common:ci-linux --strategy=remote
|
||||
common:ci-linux --platforms=//:rbe
|
||||
|
||||
# On mac, we can run all the build actions remotely but test actions locally.
|
||||
common:ci-macos --config=ci-bazel
|
||||
common:ci-macos --build_metadata=TAG_os=macos
|
||||
common:ci-macos --config=remote
|
||||
common:ci-macos --strategy=remote
|
||||
common:ci-macos --strategy=TestRunner=darwin-sandbox,local
|
||||
|
||||
# On Windows, use Linux remote execution for build actions but keep test actions
|
||||
@@ -162,9 +184,7 @@ common:ci-macos --strategy=TestRunner=darwin-sandbox,local
|
||||
# still run against Windows binaries.
|
||||
common:ci-windows-cross --config=ci-windows
|
||||
common:ci-windows-cross --build_metadata=TAG_windows_cross_compile=true
|
||||
common:ci-windows-cross --config=remote
|
||||
common:ci-windows-cross --host_platform=//:rbe
|
||||
common:ci-windows-cross --strategy=remote
|
||||
common:ci-windows-cross --strategy=TestRunner=local
|
||||
common:ci-windows-cross --local_test_jobs=4
|
||||
common:ci-windows-cross --test_env=RUST_TEST_THREADS=1
|
||||
@@ -180,8 +200,6 @@ common:ci-windows-cross --extra_toolchains=//:windows_gnullvm_tests_on_msvc_host
|
||||
common:ci-v8 --config=ci
|
||||
common:ci-v8 --build_metadata=TAG_workflow=v8
|
||||
common:ci-v8 --build_metadata=TAG_os=linux
|
||||
common:ci-v8 --config=remote
|
||||
common:ci-v8 --strategy=remote
|
||||
|
||||
# Source-built Bazel V8 artifacts use the in-process sandbox by default. This
|
||||
# does not affect Cargo's default prebuilt rusty_v8 path.
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: archive-release-symbols-and-strip-binaries.sh \
|
||||
--target <rust-target> \
|
||||
--artifact-name <artifact-name> \
|
||||
--release-dir <dir> \
|
||||
--archive-dir <dir> \
|
||||
--binaries "<space-delimited binary basenames>"
|
||||
EOF
|
||||
}
|
||||
|
||||
target=""
|
||||
artifact_name=""
|
||||
release_dir=""
|
||||
archive_dir=""
|
||||
binaries=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--target)
|
||||
target="${2:?--target requires a value}"
|
||||
shift 2
|
||||
;;
|
||||
--artifact-name)
|
||||
artifact_name="${2:?--artifact-name requires a value}"
|
||||
shift 2
|
||||
;;
|
||||
--release-dir)
|
||||
release_dir="${2:?--release-dir requires a value}"
|
||||
shift 2
|
||||
;;
|
||||
--archive-dir)
|
||||
archive_dir="${2:?--archive-dir requires a value}"
|
||||
shift 2
|
||||
;;
|
||||
--binaries)
|
||||
binaries="${2:?--binaries requires a value}"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unexpected argument: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$target" || -z "$artifact_name" || -z "$release_dir" || -z "$archive_dir" || -z "$binaries" ]]; then
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
symbols_root="${RUNNER_TEMP:-/tmp}/codex-symbols-${artifact_name}"
|
||||
symbols_dir="${symbols_root}/codex-symbols-${artifact_name}"
|
||||
archive_path="${archive_dir%/}/codex-symbols-${artifact_name}.tar.gz"
|
||||
rm -rf "$symbols_root"
|
||||
mkdir -p "$symbols_dir" "$archive_dir"
|
||||
read -r -a binary_names <<< "$binaries"
|
||||
|
||||
case "$target" in
|
||||
*apple-darwin)
|
||||
for binary in "${binary_names[@]}"; do
|
||||
binary_path="${release_dir%/}/${binary}"
|
||||
dsym_path="${binary_path}.dSYM"
|
||||
if [[ ! -f "$binary_path" ]]; then
|
||||
echo "Binary $binary_path not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -d "$dsym_path" ]]; then
|
||||
echo "dSYM $dsym_path not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cp -RL "$dsym_path" "${symbols_dir}/${binary}.dSYM"
|
||||
strip -S -x "$binary_path"
|
||||
done
|
||||
;;
|
||||
*linux*)
|
||||
objcopy_bin="${OBJCOPY:-objcopy}"
|
||||
strip_bin="${STRIP:-strip}"
|
||||
for binary in "${binary_names[@]}"; do
|
||||
binary_path="${release_dir%/}/${binary}"
|
||||
debug_path="${symbols_dir}/${binary}.debug"
|
||||
if [[ ! -f "$binary_path" ]]; then
|
||||
echo "Binary $binary_path not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
"$objcopy_bin" --only-keep-debug "$binary_path" "$debug_path"
|
||||
"$strip_bin" --strip-debug --strip-unneeded "$binary_path"
|
||||
"$objcopy_bin" --add-gnu-debuglink="$debug_path" "$binary_path"
|
||||
done
|
||||
;;
|
||||
*windows*)
|
||||
for binary in "${binary_names[@]}"; do
|
||||
pdb_path="${release_dir%/}/${binary}.pdb"
|
||||
if [[ ! -f "$pdb_path" ]]; then
|
||||
echo "PDB $pdb_path not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cp "$pdb_path" "${symbols_dir}/${binary}.pdb"
|
||||
done
|
||||
;;
|
||||
*)
|
||||
echo "No symbols packaging support for target: $target" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
rm -f "$archive_path"
|
||||
tar -C "$symbols_root" -czf "$archive_path" "codex-symbols-${artifact_name}"
|
||||
@@ -53,11 +53,20 @@ fi
|
||||
|
||||
run_bazel() {
|
||||
if [[ "${RUNNER_OS:-}" == "Windows" ]]; then
|
||||
MSYS2_ARG_CONV_EXCL='*' bazel "$@"
|
||||
MSYS2_ARG_CONV_EXCL='*' "$(dirname "${BASH_SOURCE[0]}")/run_bazel_with_buildbuddy.py" "$@"
|
||||
return
|
||||
fi
|
||||
|
||||
bazel "$@"
|
||||
"$(dirname "${BASH_SOURCE[0]}")/run_bazel_with_buildbuddy.py" "$@"
|
||||
}
|
||||
|
||||
run_bazel_with_startup_args() {
|
||||
if (( ${#bazel_startup_args[@]} > 0 )); then
|
||||
run_bazel "${bazel_startup_args[@]}" "$@"
|
||||
return
|
||||
fi
|
||||
|
||||
run_bazel "$@"
|
||||
}
|
||||
|
||||
ci_config=ci-linux
|
||||
@@ -77,23 +86,16 @@ esac
|
||||
print_bazel_test_log_tails() {
|
||||
local console_log="$1"
|
||||
local testlogs_dir
|
||||
local -a bazel_info_cmd=(bazel)
|
||||
|
||||
local -a bazel_info_args=(info)
|
||||
|
||||
if (( ${#bazel_startup_args[@]} > 0 )); then
|
||||
bazel_info_cmd+=("${bazel_startup_args[@]}")
|
||||
fi
|
||||
|
||||
# `bazel info` needs the same CI config as the failed test invocation so
|
||||
# platform-specific output roots match. On Windows, omitting `ci-windows`
|
||||
# would point at `local_windows-fastbuild` even when the test ran with the
|
||||
# MSVC host platform under `local_windows_msvc-fastbuild`.
|
||||
if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then
|
||||
bazel_info_args+=(
|
||||
"--config=${ci_config}"
|
||||
"--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}"
|
||||
)
|
||||
# `bazel info` needs the same CI config as the failed test invocation so
|
||||
# platform-specific output roots match. On Windows, omitting `ci-windows`
|
||||
# would point at `local_windows-fastbuild` even when the test ran with the
|
||||
# MSVC host platform under `local_windows_msvc-fastbuild`.
|
||||
bazel_info_args+=("--config=${ci_config}")
|
||||
fi
|
||||
|
||||
# Only pass flags that affect Bazel's output-root selection or repository
|
||||
# lookup. Test/build-only flags such as execution logs or remote download
|
||||
# mode can make `bazel info` fail, which would hide the real test log path.
|
||||
@@ -105,7 +107,7 @@ print_bazel_test_log_tails() {
|
||||
esac
|
||||
done
|
||||
|
||||
testlogs_dir="$(run_bazel "${bazel_info_cmd[@]:1}" \
|
||||
testlogs_dir="$(run_bazel_with_startup_args \
|
||||
--noexperimental_remote_repo_contents_cache \
|
||||
"${bazel_info_args[@]}" \
|
||||
bazel-testlogs 2>/dev/null || echo bazel-testlogs)"
|
||||
@@ -254,8 +256,9 @@ if [[ ${#bazel_args[@]} -eq 0 || ${#bazel_targets[@]} -eq 0 ]]; then
|
||||
fi
|
||||
|
||||
if [[ "${RUNNER_OS:-}" == "Windows" && $windows_cross_compile -eq 1 && -z "${BUILDBUDDY_API_KEY:-}" ]]; then
|
||||
# Fork PRs do not receive the BuildBuddy secret needed for the remote
|
||||
# cross-compile config. Preserve the previous local Windows build shape.
|
||||
# Windows cross-compilation depends on authenticated RBE. Preserve the local
|
||||
# Windows build shape when credentials are unavailable.
|
||||
ci_config=ci-windows
|
||||
windows_msvc_host_platform=1
|
||||
fi
|
||||
|
||||
@@ -297,9 +300,9 @@ if [[ "${RUNNER_OS:-}" == "Windows" && $windows_cross_compile -eq 1 && -n "${BUI
|
||||
fi
|
||||
|
||||
if [[ "${RUNNER_OS:-}" == "Windows" && $windows_cross_compile -eq 1 && -z "${BUILDBUDDY_API_KEY:-}" ]]; then
|
||||
# The Windows cross-compile config depends on remote execution. Fork PRs do
|
||||
# not receive the BuildBuddy secret, so fall back to the existing local build
|
||||
# shape and keep its lower concurrency cap.
|
||||
# The Windows cross-compile config depends on authenticated remote
|
||||
# execution. When credentials are unavailable, keep the local build shape
|
||||
# and its lower concurrency cap.
|
||||
post_config_bazel_args+=(--jobs=8)
|
||||
fi
|
||||
|
||||
@@ -377,70 +380,31 @@ fi
|
||||
bazel_console_log="$(mktemp)"
|
||||
trap 'rm -f "$bazel_console_log"' EXIT
|
||||
|
||||
bazel_cmd=(bazel)
|
||||
if (( ${#bazel_startup_args[@]} > 0 )); then
|
||||
bazel_cmd+=("${bazel_startup_args[@]}")
|
||||
fi
|
||||
|
||||
bazel_run_args=(
|
||||
"${bazel_args[@]}"
|
||||
)
|
||||
if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then
|
||||
echo "BuildBuddy API key is available; using remote Bazel configuration."
|
||||
# Work around Bazel 9 remote repo contents cache / overlay materialization failures
|
||||
# seen in CI (for example "is not a symlink" or permission errors while
|
||||
# materializing external repos such as rules_perl). We still use BuildBuddy for
|
||||
# remote execution/cache; this only disables the startup-level repo contents cache.
|
||||
bazel_run_args=(
|
||||
"${bazel_args[@]}"
|
||||
"--config=${ci_config}"
|
||||
"--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}"
|
||||
)
|
||||
if (( ${#post_config_bazel_args[@]} > 0 )); then
|
||||
bazel_run_args+=("${post_config_bazel_args[@]}")
|
||||
fi
|
||||
set +e
|
||||
run_bazel "${bazel_cmd[@]:1}" \
|
||||
--noexperimental_remote_repo_contents_cache \
|
||||
"${bazel_run_args[@]}" \
|
||||
-- \
|
||||
"${bazel_targets[@]}" \
|
||||
2>&1 | tee "$bazel_console_log"
|
||||
bazel_status=${PIPESTATUS[0]}
|
||||
set -e
|
||||
bazel_run_args+=("--config=${ci_config}")
|
||||
else
|
||||
echo "BuildBuddy API key is not available; using local Bazel configuration."
|
||||
# Keep fork/community PRs on Bazel but disable remote services that are
|
||||
# configured in .bazelrc and require auth.
|
||||
#
|
||||
# Flag docs:
|
||||
# - Command-line reference: https://bazel.build/reference/command-line-reference
|
||||
# - Remote caching overview: https://bazel.build/remote/caching
|
||||
# - Remote execution overview: https://bazel.build/remote/rbe
|
||||
# - Build Event Protocol overview: https://bazel.build/remote/bep
|
||||
#
|
||||
# --noexperimental_remote_repo_contents_cache:
|
||||
# disable remote repo contents cache enabled in .bazelrc startup options.
|
||||
# https://bazel.build/reference/command-line-reference#startup_options-flag--experimental_remote_repo_contents_cache
|
||||
# --remote_cache= and --remote_executor=:
|
||||
# clear remote cache/execution endpoints configured in .bazelrc.
|
||||
# https://bazel.build/reference/command-line-reference#common_options-flag--remote_cache
|
||||
# https://bazel.build/reference/command-line-reference#common_options-flag--remote_executor
|
||||
bazel_run_args=(
|
||||
"${bazel_args[@]}"
|
||||
--remote_cache=
|
||||
--remote_executor=
|
||||
)
|
||||
if (( ${#post_config_bazel_args[@]} > 0 )); then
|
||||
bazel_run_args+=("${post_config_bazel_args[@]}")
|
||||
fi
|
||||
set +e
|
||||
run_bazel "${bazel_cmd[@]:1}" \
|
||||
--noexperimental_remote_repo_contents_cache \
|
||||
"${bazel_run_args[@]}" \
|
||||
-- \
|
||||
"${bazel_targets[@]}" \
|
||||
2>&1 | tee "$bazel_console_log"
|
||||
bazel_status=${PIPESTATUS[0]}
|
||||
set -e
|
||||
fi
|
||||
if (( ${#post_config_bazel_args[@]} > 0 )); then
|
||||
bazel_run_args+=("${post_config_bazel_args[@]}")
|
||||
fi
|
||||
set +e
|
||||
# Work around Bazel 9 remote repo contents cache / overlay materialization
|
||||
# failures seen in CI (for example "is not a symlink" or permission errors
|
||||
# while materializing external repos such as rules_perl). This only disables
|
||||
# the startup-level repo contents cache; keyed runs still use BuildBuddy.
|
||||
run_bazel_with_startup_args \
|
||||
--noexperimental_remote_repo_contents_cache \
|
||||
"${bazel_run_args[@]}" \
|
||||
-- \
|
||||
"${bazel_targets[@]}" \
|
||||
2>&1 | tee "$bazel_console_log"
|
||||
bazel_status=${PIPESTATUS[0]}
|
||||
set -e
|
||||
|
||||
if [[ ${bazel_status:-0} -ne 0 ]]; then
|
||||
if [[ $print_failed_bazel_action_summary -eq 1 ]]; then
|
||||
|
||||
@@ -2,48 +2,17 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Run Bazel queries with the same CI startup settings as the main build/test
|
||||
# invocation so target-discovery queries can reuse the same Bazel server.
|
||||
# Run target-discovery queries with the same startup settings as the main
|
||||
# build/test invocation so they can reuse the same Bazel server. Queries only
|
||||
# enumerate labels, so they intentionally do not select CI or remote configs.
|
||||
|
||||
query_args=()
|
||||
windows_cross_compile=0
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--windows-cross-compile)
|
||||
windows_cross_compile=1
|
||||
shift
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
break
|
||||
;;
|
||||
*)
|
||||
query_args+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
echo "Usage: $0 [--windows-cross-compile] [<bazel query args>...] -- <query expression>" >&2
|
||||
if [[ $# -lt 2 || "${@: -2:1}" != "--" ]]; then
|
||||
echo "Usage: $0 [<bazel query args>...] -- <query expression>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
query_expression="$1"
|
||||
|
||||
ci_config=ci-linux
|
||||
case "${RUNNER_OS:-}" in
|
||||
macOS)
|
||||
ci_config=ci-macos
|
||||
;;
|
||||
Windows)
|
||||
if [[ $windows_cross_compile -eq 1 ]]; then
|
||||
ci_config=ci-windows-cross
|
||||
else
|
||||
ci_config=ci-windows
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
query_args=("${@:1:$#-2}")
|
||||
query_expression="${@: -1}"
|
||||
|
||||
bazel_startup_args=()
|
||||
if [[ -n "${BAZEL_OUTPUT_USER_ROOT:-}" ]]; then
|
||||
@@ -60,12 +29,6 @@ run_bazel() {
|
||||
}
|
||||
|
||||
bazel_query_args=(--noexperimental_remote_repo_contents_cache query)
|
||||
if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then
|
||||
bazel_query_args+=(
|
||||
"--config=${ci_config}"
|
||||
"--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}"
|
||||
)
|
||||
fi
|
||||
|
||||
if [[ -n "${BAZEL_REPO_CONTENTS_CACHE:-}" ]]; then
|
||||
bazel_query_args+=("--repo_contents_cache=${BAZEL_REPO_CONTENTS_CACHE}")
|
||||
@@ -75,7 +38,10 @@ if [[ -n "${BAZEL_REPOSITORY_CACHE:-}" ]]; then
|
||||
bazel_query_args+=("--repository_cache=${BAZEL_REPOSITORY_CACHE}")
|
||||
fi
|
||||
|
||||
bazel_query_args+=("${query_args[@]}" "$query_expression")
|
||||
if (( ${#query_args[@]} > 0 )); then
|
||||
bazel_query_args+=("${query_args[@]}")
|
||||
fi
|
||||
bazel_query_args+=("$query_expression")
|
||||
|
||||
if (( ${#bazel_startup_args[@]} > 0 )); then
|
||||
run_bazel "${bazel_startup_args[@]}" "${bazel_query_args[@]}"
|
||||
|
||||
Executable
+147
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
OPENAI_REPOSITORY = "openai/codex"
|
||||
# Remote configurations select cache/BES/download endpoints. Their -rbe forms
|
||||
# also select the matching remote executor endpoint.
|
||||
GENERIC_REMOTE_CONFIG = "buildbuddy-generic"
|
||||
OPENAI_REMOTE_CONFIG = "buildbuddy-openai"
|
||||
# These CI configurations require remote build execution. The wrapper supplies
|
||||
# an RBE configuration, which also includes the common `remote` settings.
|
||||
REMOTE_EXECUTION_CONFIGS = {
|
||||
"--config=ci-linux",
|
||||
"--config=ci-macos",
|
||||
"--config=ci-v8",
|
||||
"--config=ci-windows-cross",
|
||||
}
|
||||
# Only authenticated workflow runs executing trusted upstream code may use the
|
||||
# OpenAI BuildBuddy host. A pull request event without proof that its head is
|
||||
# in the upstream repository fails closed to the generic host.
|
||||
def is_trusted_upstream_run(env: Mapping[str, str]) -> bool:
|
||||
# `GITHUB_REPOSITORY` is easy to set locally. Requiring GitHub's workflow
|
||||
# marker prevents a local command from opting itself into the OpenAI host.
|
||||
if (
|
||||
env.get("GITHUB_ACTIONS") != "true"
|
||||
or env.get("GITHUB_REPOSITORY") != OPENAI_REPOSITORY
|
||||
):
|
||||
return False
|
||||
# Non-PR workflow runs in `openai/codex` execute upstream refs, so they are
|
||||
# trusted. Fork code reaches these workflows only through pull requests.
|
||||
if env.get("GITHUB_EVENT_NAME") != "pull_request":
|
||||
return True
|
||||
|
||||
event_path = env.get("GITHUB_EVENT_PATH")
|
||||
if not event_path:
|
||||
return False
|
||||
try:
|
||||
event = json.loads(Path(event_path).read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return False
|
||||
|
||||
try:
|
||||
return event["pull_request"]["head"]["repo"]["fork"] is False
|
||||
except (KeyError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def uses_openai_host(env: Mapping[str, str]) -> bool:
|
||||
return bool(env.get("BUILDBUDDY_API_KEY")) and is_trusted_upstream_run(env)
|
||||
|
||||
|
||||
def uses_remote_execution(args: Sequence[str]) -> bool:
|
||||
try:
|
||||
separator_idx = args.index("--")
|
||||
except ValueError:
|
||||
separator_idx = len(args)
|
||||
return any(arg in REMOTE_EXECUTION_CONFIGS for arg in args[:separator_idx])
|
||||
|
||||
|
||||
def remote_config(args: Sequence[str], env: Mapping[str, str]) -> str | None:
|
||||
if not env.get("BUILDBUDDY_API_KEY"):
|
||||
return None
|
||||
|
||||
config = OPENAI_REMOTE_CONFIG if uses_openai_host(env) else GENERIC_REMOTE_CONFIG
|
||||
if uses_remote_execution(args):
|
||||
config += "-rbe"
|
||||
return config
|
||||
|
||||
|
||||
def bazel_args_without_remote_execution(args: Sequence[str]) -> list[str]:
|
||||
# Remote CI configs require BuildBuddy credentials. Removing them preserves
|
||||
# the local fallback used for fork pull requests.
|
||||
try:
|
||||
separator_idx = args.index("--")
|
||||
except ValueError:
|
||||
separator_idx = len(args)
|
||||
return [
|
||||
*(arg for arg in args[:separator_idx] if arg not in REMOTE_EXECUTION_CONFIGS),
|
||||
*args[separator_idx:],
|
||||
]
|
||||
|
||||
|
||||
def bazel_args_with_remote_config(
|
||||
args: Sequence[str], env: Mapping[str, str]
|
||||
) -> list[str]:
|
||||
config = remote_config(args, env)
|
||||
if config is None:
|
||||
return bazel_args_without_remote_execution(args)
|
||||
|
||||
# `remote_config()` returns a configuration only when this key is present.
|
||||
api_key = env["BUILDBUDDY_API_KEY"]
|
||||
remote_args = [
|
||||
f"--config={config}",
|
||||
f"--remote_header=x-buildbuddy-api-key={api_key}",
|
||||
]
|
||||
|
||||
# Insert immediately after the Bazel command. This keeps wrapper-added
|
||||
# options out of positional payloads and lets later CI configs override
|
||||
# shared RBE defaults such as the Windows cross-compilation exec platforms.
|
||||
insertion_idx = next(
|
||||
(idx + 1 for idx, arg in enumerate(args) if not arg.startswith("-")),
|
||||
len(args),
|
||||
)
|
||||
return [*args[:insertion_idx], *remote_args, *args[insertion_idx:]]
|
||||
|
||||
|
||||
def bazel_command(*args: str, env: Mapping[str, str] | None = None) -> list[str]:
|
||||
env = os.environ if env is None else env
|
||||
bazel = env.get("CODEX_BAZEL_BIN", "bazel")
|
||||
return [bazel, *bazel_args_with_remote_config(args, env)]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
config = remote_config(sys.argv[1:], os.environ)
|
||||
if config is None:
|
||||
print(
|
||||
"BuildBuddy key unavailable; using local Bazel configuration.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
host_description = (
|
||||
"OpenAI tenant" if uses_openai_host(os.environ) else "generic"
|
||||
)
|
||||
print(
|
||||
f"Using {host_description} BuildBuddy configuration: {config}.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
command = bazel_command(*sys.argv[1:])
|
||||
if os.name == "nt":
|
||||
# Windows CRT exec can split arguments containing spaces and lose the
|
||||
# eventual child exit status. Wait for Bazel and propagate its status.
|
||||
result = subprocess.run(command, check=False)
|
||||
raise SystemExit(result.returncode)
|
||||
|
||||
os.execvp(command[0], command)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import gzip
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -13,6 +12,7 @@ import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
from run_bazel_with_buildbuddy import bazel_command
|
||||
from rusty_v8_module_bazel import (
|
||||
RustyV8ChecksumError,
|
||||
check_module_bazel,
|
||||
@@ -29,33 +29,22 @@ SANDBOX_ARTIFACT_PROFILE = "ptrcomp_sandbox_release"
|
||||
ARTIFACT_BAZEL_CONFIGS = ["rusty-v8-upstream-libcxx"]
|
||||
|
||||
|
||||
def bazel_remote_args() -> list[str]:
|
||||
buildbuddy_api_key = os.environ.get("BUILDBUDDY_API_KEY")
|
||||
if not buildbuddy_api_key:
|
||||
return []
|
||||
return [f"--remote_header=x-buildbuddy-api-key={buildbuddy_api_key}"]
|
||||
|
||||
|
||||
def bazel_execroot() -> Path:
|
||||
result = subprocess.run(
|
||||
["bazel", "info", "execution_root"],
|
||||
output = subprocess.check_output(
|
||||
bazel_command("info", "execution_root"),
|
||||
cwd=ROOT,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return Path(result.stdout.strip())
|
||||
return Path(output.strip())
|
||||
|
||||
|
||||
def bazel_output_base() -> Path:
|
||||
result = subprocess.run(
|
||||
["bazel", "info", "output_base"],
|
||||
output = subprocess.check_output(
|
||||
bazel_command("info", "output_base"),
|
||||
cwd=ROOT,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return Path(result.stdout.strip())
|
||||
return Path(output.strip())
|
||||
|
||||
|
||||
def bazel_output_path(path: str) -> Path:
|
||||
@@ -72,24 +61,22 @@ def bazel_output_files(
|
||||
) -> list[Path]:
|
||||
expression = "set(" + " ".join(labels) + ")"
|
||||
bazel_configs = bazel_configs or []
|
||||
result = subprocess.run(
|
||||
[
|
||||
"bazel",
|
||||
output = subprocess.check_output(
|
||||
bazel_command(
|
||||
"cquery",
|
||||
"-c",
|
||||
compilation_mode,
|
||||
f"--platforms=@llvm//platforms:{platform}",
|
||||
*[f"--config={config}" for config in bazel_configs],
|
||||
*bazel_remote_args(),
|
||||
"--output=files",
|
||||
expression,
|
||||
],
|
||||
),
|
||||
cwd=ROOT,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return [bazel_output_path(line.strip()) for line in result.stdout.splitlines() if line.strip()]
|
||||
return [
|
||||
bazel_output_path(line.strip()) for line in output.splitlines() if line.strip()
|
||||
]
|
||||
|
||||
|
||||
def bazel_build(
|
||||
@@ -102,17 +89,15 @@ def bazel_build(
|
||||
bazel_configs = bazel_configs or []
|
||||
download_args = ["--remote_download_toplevel"] if download_toplevel else []
|
||||
subprocess.run(
|
||||
[
|
||||
"bazel",
|
||||
bazel_command(
|
||||
"build",
|
||||
"-c",
|
||||
compilation_mode,
|
||||
f"--platforms=@llvm//platforms:{platform}",
|
||||
*[f"--config={config}" for config in bazel_configs],
|
||||
*bazel_remote_args(),
|
||||
*download_args,
|
||||
*labels,
|
||||
],
|
||||
),
|
||||
cwd=ROOT,
|
||||
check=True,
|
||||
)
|
||||
@@ -172,7 +157,7 @@ def resolved_v8_crate_version() -> str:
|
||||
matches = sorted(
|
||||
set(
|
||||
re.findall(
|
||||
r'https://static\.crates\.io/crates/v8/v8-([0-9]+\.[0-9]+\.[0-9]+)\.crate',
|
||||
r"https://static\.crates\.io/crates/v8/v8-([0-9]+\.[0-9]+\.[0-9]+)\.crate",
|
||||
module_bazel,
|
||||
)
|
||||
)
|
||||
@@ -234,13 +219,17 @@ def stage_artifacts(
|
||||
output_dir: Path,
|
||||
sandbox: bool,
|
||||
) -> None:
|
||||
missing_paths = [str(path) for path in [lib_path, binding_path] if not path.exists()]
|
||||
missing_paths = [
|
||||
str(path) for path in [lib_path, binding_path] if not path.exists()
|
||||
]
|
||||
if missing_paths:
|
||||
raise SystemExit(f"missing release outputs for {target}: {missing_paths}")
|
||||
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
artifact_profile = SANDBOX_ARTIFACT_PROFILE if sandbox else RELEASE_ARTIFACT_PROFILE
|
||||
staged_library = output_dir / staged_archive_name(target, lib_path, artifact_profile)
|
||||
staged_library = output_dir / staged_archive_name(
|
||||
target, lib_path, artifact_profile
|
||||
)
|
||||
staged_binding = output_dir / staged_binding_name(target, artifact_profile)
|
||||
|
||||
with lib_path.open("rb") as src, staged_library.open("wb") as dst:
|
||||
@@ -270,7 +259,9 @@ def stage_artifacts(
|
||||
|
||||
|
||||
def upstream_release_pair_paths(source_root: Path, target: str) -> tuple[Path, Path]:
|
||||
lib_name = "rusty_v8.lib" if target.endswith("-pc-windows-msvc") else "librusty_v8.a"
|
||||
lib_name = (
|
||||
"rusty_v8.lib" if target.endswith("-pc-windows-msvc") else "librusty_v8.a"
|
||||
)
|
||||
gn_out = source_root / "target" / target / "release" / "gn_out"
|
||||
return gn_out / "obj" / lib_name, gn_out / "src_binding.rs"
|
||||
|
||||
@@ -338,7 +329,9 @@ def parse_args() -> argparse.Namespace:
|
||||
stage_upstream_release_pair_parser = subparsers.add_parser(
|
||||
"stage-upstream-release-pair"
|
||||
)
|
||||
stage_upstream_release_pair_parser.add_argument("--source-root", type=Path, required=True)
|
||||
stage_upstream_release_pair_parser.add_argument(
|
||||
"--source-root", type=Path, required=True
|
||||
)
|
||||
stage_upstream_release_pair_parser.add_argument("--target", required=True)
|
||||
stage_upstream_release_pair_parser.add_argument("--output-dir", required=True)
|
||||
stage_upstream_release_pair_parser.add_argument("--sandbox", action="store_true")
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
import run_bazel_with_buildbuddy
|
||||
|
||||
|
||||
class RunBazelWithBuildBuddyTest(unittest.TestCase):
|
||||
def github_env(
|
||||
self,
|
||||
temp_dir: str,
|
||||
*,
|
||||
repository: str = "openai/codex",
|
||||
fork: bool = False,
|
||||
event_name: str = "pull_request",
|
||||
) -> dict[str, str]:
|
||||
event_path = Path(temp_dir) / "event.json"
|
||||
event_path.write_text(
|
||||
json.dumps({"pull_request": {"head": {"repo": {"fork": fork}}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return {
|
||||
"BUILDBUDDY_API_KEY": "token",
|
||||
"GITHUB_ACTIONS": "true",
|
||||
"GITHUB_EVENT_NAME": event_name,
|
||||
"GITHUB_EVENT_PATH": str(event_path),
|
||||
"GITHUB_REPOSITORY": repository,
|
||||
}
|
||||
|
||||
def test_keyless_invocation_drops_remote_ci_configuration(self) -> None:
|
||||
self.assertIsNone(
|
||||
run_bazel_with_buildbuddy.remote_config(
|
||||
["build", "--config=ci-linux", "//codex-rs/cli:codex"],
|
||||
{},
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
run_bazel_with_buildbuddy.bazel_args_with_remote_config(
|
||||
["build", "--config=ci-linux", "--", "//codex-rs/cli:codex"],
|
||||
{},
|
||||
),
|
||||
["build", "--", "//codex-rs/cli:codex"],
|
||||
)
|
||||
|
||||
def test_program_arguments_after_separator_do_not_select_or_lose_rbe(self) -> None:
|
||||
args = ["run", "//codex-rs/cli:codex", "--", "--config=remote"]
|
||||
|
||||
self.assertEqual(
|
||||
run_bazel_with_buildbuddy.bazel_args_with_remote_config(args, {}),
|
||||
args,
|
||||
)
|
||||
self.assertEqual(
|
||||
run_bazel_with_buildbuddy.remote_config(
|
||||
args, {"BUILDBUDDY_API_KEY": "fork-token"}
|
||||
),
|
||||
"buildbuddy-generic",
|
||||
)
|
||||
|
||||
def test_upstream_push_selects_openai_rbe_before_target_separator(self) -> None:
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
env = self.github_env(temp_dir, event_name="push")
|
||||
|
||||
self.assertEqual(
|
||||
run_bazel_with_buildbuddy.bazel_args_with_remote_config(
|
||||
["build", "--config=ci-linux", "--", "//codex-rs/cli:codex"],
|
||||
env,
|
||||
),
|
||||
[
|
||||
"build",
|
||||
"--config=buildbuddy-openai-rbe",
|
||||
"--remote_header=x-buildbuddy-api-key=token",
|
||||
"--config=ci-linux",
|
||||
"--",
|
||||
"//codex-rs/cli:codex",
|
||||
],
|
||||
)
|
||||
|
||||
def test_windows_cross_ci_configuration_follows_remote_configuration(self) -> None:
|
||||
env = {"BUILDBUDDY_API_KEY": "fork-token"}
|
||||
|
||||
self.assertEqual(
|
||||
run_bazel_with_buildbuddy.bazel_args_with_remote_config(
|
||||
["build", "--config=ci-windows-cross", "//codex-rs/cli:codex"],
|
||||
env,
|
||||
),
|
||||
[
|
||||
"build",
|
||||
"--config=buildbuddy-generic-rbe",
|
||||
"--remote_header=x-buildbuddy-api-key=fork-token",
|
||||
"--config=ci-windows-cross",
|
||||
"//codex-rs/cli:codex",
|
||||
],
|
||||
)
|
||||
|
||||
def test_query_remote_configuration_is_inserted_before_expression(self) -> None:
|
||||
expression = 'kind("rust_library rule", //codex-rs/...)'
|
||||
env = {"BUILDBUDDY_API_KEY": "fork-token"}
|
||||
|
||||
for command in ("query", "cquery", "aquery"):
|
||||
with self.subTest(command=command):
|
||||
self.assertEqual(
|
||||
run_bazel_with_buildbuddy.bazel_args_with_remote_config(
|
||||
[
|
||||
command,
|
||||
"--config=ci-windows-cross",
|
||||
"--output=label",
|
||||
expression,
|
||||
],
|
||||
env,
|
||||
),
|
||||
[
|
||||
command,
|
||||
"--config=buildbuddy-generic-rbe",
|
||||
"--remote_header=x-buildbuddy-api-key=fork-token",
|
||||
"--config=ci-windows-cross",
|
||||
"--output=label",
|
||||
expression,
|
||||
],
|
||||
)
|
||||
|
||||
def test_same_repository_pull_request_selects_openai_host(self) -> None:
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
self.assertEqual(
|
||||
run_bazel_with_buildbuddy.remote_config(
|
||||
["build", "--config=ci-v8"], self.github_env(temp_dir)
|
||||
),
|
||||
"buildbuddy-openai-rbe",
|
||||
)
|
||||
|
||||
def test_fork_pull_request_cannot_select_openai_host(self) -> None:
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
env = self.github_env(temp_dir, fork=True)
|
||||
|
||||
self.assertEqual(
|
||||
run_bazel_with_buildbuddy.remote_config(
|
||||
["build", "--config=ci-v8"], env
|
||||
),
|
||||
"buildbuddy-generic-rbe",
|
||||
)
|
||||
|
||||
def test_run_in_fork_repository_cannot_select_openai_host(self) -> None:
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
env = self.github_env(temp_dir, repository="contributor/codex")
|
||||
|
||||
self.assertEqual(
|
||||
run_bazel_with_buildbuddy.remote_config(
|
||||
["build", "--config=ci-v8"], env
|
||||
),
|
||||
"buildbuddy-generic-rbe",
|
||||
)
|
||||
|
||||
def test_pull_request_without_readable_event_payload_fails_closed(self) -> None:
|
||||
for event_path in (None, "missing-event.json"):
|
||||
env = {
|
||||
"BUILDBUDDY_API_KEY": "token",
|
||||
"GITHUB_ACTIONS": "true",
|
||||
"GITHUB_EVENT_NAME": "pull_request",
|
||||
"GITHUB_REPOSITORY": "openai/codex",
|
||||
}
|
||||
if event_path is not None:
|
||||
env["GITHUB_EVENT_PATH"] = event_path
|
||||
|
||||
with self.subTest(event_path=event_path):
|
||||
self.assertEqual(
|
||||
run_bazel_with_buildbuddy.remote_config(["build"], env),
|
||||
"buildbuddy-generic",
|
||||
)
|
||||
|
||||
def test_bazel_command_uses_configured_binary_locally(self) -> None:
|
||||
self.assertEqual(
|
||||
run_bazel_with_buildbuddy.bazel_command(
|
||||
"info",
|
||||
"execution_root",
|
||||
env={"CODEX_BAZEL_BIN": "fake-bazel"},
|
||||
),
|
||||
["fake-bazel", "info", "execution_root"],
|
||||
)
|
||||
|
||||
def test_main_preserves_spaced_argument_and_child_exit_status(self) -> None:
|
||||
spaced_arg = (
|
||||
r"--test_env=PATH=C:\Program Files\PowerShell\7;C:\Program Files\Git\bin"
|
||||
)
|
||||
child_code = (
|
||||
f"import sys; sys.exit(37 if sys.argv[1] == {spaced_arg!r} else 91)"
|
||||
)
|
||||
env = os.environ.copy()
|
||||
env["CODEX_BAZEL_BIN"] = sys.executable
|
||||
env.pop("BUILDBUDDY_API_KEY", None)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(Path(run_bazel_with_buildbuddy.__file__)),
|
||||
"-c",
|
||||
child_code,
|
||||
spaced_arg,
|
||||
],
|
||||
env=env,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 37, result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -88,24 +88,49 @@ class RustyV8BazelTest(unittest.TestCase):
|
||||
),
|
||||
)
|
||||
|
||||
def test_bazel_remote_args_include_buildbuddy_header_when_present(self) -> None:
|
||||
with patch.dict(environ, {"BUILDBUDDY_API_KEY": "token"}, clear=False):
|
||||
def test_bazel_commands_use_shared_buildbuddy_remote_config_library(self) -> None:
|
||||
with patch.dict(environ, {}, clear=True):
|
||||
self.assertEqual(
|
||||
["--remote_header=x-buildbuddy-api-key=token"],
|
||||
rusty_v8_bazel.bazel_remote_args(),
|
||||
[
|
||||
"bazel",
|
||||
"build",
|
||||
"//third_party/v8:release",
|
||||
],
|
||||
rusty_v8_bazel.bazel_command(
|
||||
"build",
|
||||
"--config=ci-v8",
|
||||
"//third_party/v8:release",
|
||||
),
|
||||
)
|
||||
with patch.dict(environ, {"BUILDBUDDY_API_KEY": "token"}, clear=True):
|
||||
self.assertEqual(
|
||||
[
|
||||
"bazel",
|
||||
"build",
|
||||
"--config=buildbuddy-generic-rbe",
|
||||
"--remote_header=x-buildbuddy-api-key=token",
|
||||
"--config=ci-v8",
|
||||
"//third_party/v8:release",
|
||||
],
|
||||
rusty_v8_bazel.bazel_command(
|
||||
"build",
|
||||
"--config=ci-v8",
|
||||
"//third_party/v8:release",
|
||||
),
|
||||
)
|
||||
|
||||
with patch.dict(environ, {}, clear=True):
|
||||
self.assertEqual([], rusty_v8_bazel.bazel_remote_args())
|
||||
|
||||
def test_release_pair_labels_and_staged_names_distinguish_sandbox_artifacts(self) -> None:
|
||||
def test_release_pair_labels_and_staged_names_distinguish_sandbox_artifacts(
|
||||
self,
|
||||
) -> None:
|
||||
self.assertEqual(
|
||||
"//third_party/v8:rusty_v8_release_pair_x86_64_unknown_linux_musl",
|
||||
rusty_v8_bazel.release_pair_label("x86_64-unknown-linux-musl"),
|
||||
)
|
||||
self.assertEqual(
|
||||
"//third_party/v8:rusty_v8_sandbox_release_pair_x86_64_unknown_linux_musl",
|
||||
rusty_v8_bazel.release_pair_label("x86_64-unknown-linux-musl", sandbox=True),
|
||||
rusty_v8_bazel.release_pair_label(
|
||||
"x86_64-unknown-linux-musl", sandbox=True
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
"//third_party/v8:rusty_v8_sandbox_release_pair_x86_64_apple_darwin",
|
||||
@@ -205,11 +230,7 @@ class RustyV8BazelTest(unittest.TestCase):
|
||||
with TemporaryDirectory() as source_dir, TemporaryDirectory() as output_dir:
|
||||
source_root = Path(source_dir)
|
||||
gn_out = (
|
||||
source_root
|
||||
/ "target"
|
||||
/ "x86_64-pc-windows-msvc"
|
||||
/ "release"
|
||||
/ "gn_out"
|
||||
source_root / "target" / "x86_64-pc-windows-msvc" / "release" / "gn_out"
|
||||
)
|
||||
(gn_out / "obj").mkdir(parents=True)
|
||||
(gn_out / "obj" / "rusty_v8.lib").write_bytes(b"archive")
|
||||
|
||||
@@ -15,6 +15,7 @@ concurrency:
|
||||
# See https://docs.github.com/en/actions/using-jobs/using-concurrency and https://docs.github.com/en/actions/learn-github-actions/contexts for more info.
|
||||
group: concurrency-group::${{ github.workflow }}::${{ github.event.pull_request.number > 0 && format('pr-{0}', github.event.pull_request.number) || github.ref_name }}${{ github.ref_name == 'main' && format('::{0}', github.run_id) || ''}}
|
||||
cancel-in-progress: ${{ github.ref_name != 'main' }}
|
||||
|
||||
jobs:
|
||||
test:
|
||||
# PRs use the sharded Windows cross-compiled test jobs below. Post-merge
|
||||
@@ -55,12 +56,17 @@ jobs:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49
|
||||
if: matrix.os == 'ubuntu-24.04' && matrix.target == 'x86_64-unknown-linux-gnu'
|
||||
with:
|
||||
tool: just
|
||||
|
||||
- name: Check rusty_v8 MODULE.bazel checksums
|
||||
if: matrix.os == 'ubuntu-24.04' && matrix.target == 'x86_64-unknown-linux-gnu'
|
||||
shell: bash
|
||||
run: |
|
||||
python3 .github/scripts/rusty_v8_bazel.py check-module-bazel
|
||||
python3 -m unittest discover -s .github/scripts -p test_rusty_v8_bazel.py
|
||||
just test-github-scripts
|
||||
|
||||
- name: Prepare Bazel CI
|
||||
id: prepare_bazel
|
||||
@@ -152,6 +158,11 @@ jobs:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Test BuildBuddy Bazel wrapper
|
||||
if: matrix.shard == 1
|
||||
shell: pwsh
|
||||
run: python .github/scripts/test_run_bazel_with_buildbuddy.py
|
||||
|
||||
- name: Prepare Bazel CI
|
||||
id: prepare_bazel
|
||||
uses: ./.github/actions/prepare-bazel-ci
|
||||
|
||||
@@ -39,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
|
||||
@@ -134,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
|
||||
@@ -232,23 +218,6 @@ jobs:
|
||||
account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
|
||||
certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }}
|
||||
|
||||
- name: Build symbols archive
|
||||
shell: bash
|
||||
run: |
|
||||
bash "${GITHUB_WORKSPACE}/.github/scripts/archive-release-symbols-and-strip-binaries.sh" \
|
||||
--target "${{ matrix.target }}" \
|
||||
--artifact-name "${{ matrix.target }}" \
|
||||
--release-dir "target/${{ matrix.target }}/release" \
|
||||
--archive-dir "symbols-dist/${{ matrix.target }}" \
|
||||
--binaries "${WINDOWS_BINARIES}"
|
||||
|
||||
- name: Upload symbols archive
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: ${{ matrix.target }}-symbols
|
||||
path: codex-rs/symbols-dist/${{ matrix.target }}/*
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Stage artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -149,9 +149,6 @@ jobs:
|
||||
# 2026-03-04: temporarily change releases to use thin LTO because
|
||||
# Ubuntu ARM is timing out at 60 minutes.
|
||||
CARGO_PROFILE_RELEASE_LTO: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'thin' }}
|
||||
CARGO_PROFILE_RELEASE_DEBUG: full
|
||||
CARGO_PROFILE_RELEASE_SPLIT_DEBUGINFO: ${{ contains(matrix.target, 'apple-darwin') && 'packed' || 'off' }}
|
||||
CARGO_PROFILE_RELEASE_STRIP: "false"
|
||||
# Use the git CLI instead of Cargo's libgit2 path for git dependencies.
|
||||
# macOS release runners have intermittently failed to fetch nested
|
||||
# submodules through SecureTransport/libgit2, especially libwebrtc's
|
||||
@@ -252,7 +249,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update -y
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends binutils pkg-config libcap-dev
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev
|
||||
- uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
@@ -311,10 +308,6 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Codex embeds this digest at build time and verifies the bundled
|
||||
# bwrap resource before use. Strip bwrap before hashing so the digest
|
||||
# covers the exact bytes that the release packages.
|
||||
strip --strip-debug --strip-unneeded "$bwrap_path"
|
||||
digest="$(sha256sum "$bwrap_path" | awk '{print $1}')"
|
||||
echo "CODEX_BWRAP_SHA256=${digest}" >> "$GITHUB_ENV"
|
||||
echo "Built bwrap ${bwrap_path} with sha256:${digest}"
|
||||
@@ -328,11 +321,6 @@ jobs:
|
||||
fi
|
||||
build_args=()
|
||||
for binary in ${{ matrix.binaries }}; do
|
||||
# bwrap was built, finalized, and hashed before this build so
|
||||
# Codex can embed the digest of the bytes that will be packaged.
|
||||
if [[ "$binary" == "bwrap" ]]; then
|
||||
continue
|
||||
fi
|
||||
build_args+=(--bin "$binary")
|
||||
done
|
||||
echo "CARGO_PROFILE_RELEASE_LTO: ${CARGO_PROFILE_RELEASE_LTO}"
|
||||
@@ -345,32 +333,6 @@ jobs:
|
||||
path: codex-rs/target/**/cargo-timings/cargo-timing.html
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Build symbols archive and strip binaries
|
||||
shell: bash
|
||||
run: |
|
||||
binaries=()
|
||||
for binary in ${{ matrix.binaries }}; do
|
||||
# bwrap is already stripped before hashing. Its symbols are not
|
||||
# useful enough to justify a separate pre-Codex symbols pass.
|
||||
if [[ "$binary" == "bwrap" ]]; then
|
||||
continue
|
||||
fi
|
||||
binaries+=("$binary")
|
||||
done
|
||||
bash "${GITHUB_WORKSPACE}/.github/scripts/archive-release-symbols-and-strip-binaries.sh" \
|
||||
--target "${{ matrix.target }}" \
|
||||
--artifact-name "${{ matrix.artifact_name }}" \
|
||||
--release-dir "target/${{ matrix.target }}/release" \
|
||||
--archive-dir "symbols-dist/${{ matrix.artifact_name }}" \
|
||||
--binaries "${binaries[*]}"
|
||||
|
||||
- name: Upload symbols archive
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: ${{ matrix.artifact_name }}-symbols
|
||||
path: codex-rs/symbols-dist/${{ matrix.artifact_name }}/*
|
||||
if-no-files-found: error
|
||||
|
||||
- if: ${{ runner.os == 'macOS' && env.SIGN_MACOS != 'true' }}
|
||||
name: Stage unsigned macOS artifacts
|
||||
shell: bash
|
||||
@@ -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' \
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
+4
-27
@@ -83,21 +83,14 @@ const legacyBinaryPath = (vendorRoot) =>
|
||||
path.join(vendorRoot, targetTriple, "codex", codexBinaryName);
|
||||
|
||||
function resolveNativePackage(vendorRoot) {
|
||||
const packageRoot = path.join(vendorRoot, targetTriple);
|
||||
const binaryPath = packageBinaryPath(vendorRoot);
|
||||
if (existsSync(binaryPath)) {
|
||||
return {
|
||||
binaryPath,
|
||||
pathDir: path.join(packageRoot, "codex-path"),
|
||||
};
|
||||
return binaryPath;
|
||||
}
|
||||
|
||||
const legacyPath = legacyBinaryPath(vendorRoot);
|
||||
if (existsSync(legacyPath)) {
|
||||
return {
|
||||
binaryPath: legacyPath,
|
||||
pathDir: path.join(packageRoot, "path"),
|
||||
};
|
||||
return legacyPath;
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -124,7 +117,7 @@ if (!nativePackage) {
|
||||
);
|
||||
}
|
||||
|
||||
const { binaryPath, pathDir } = nativePackage;
|
||||
const binaryPath = nativePackage;
|
||||
|
||||
// Use an asynchronous spawn instead of spawnSync so that Node is able to
|
||||
// respond to signals (e.g. Ctrl-C / SIGINT) while the native binary is
|
||||
@@ -132,16 +125,6 @@ const { binaryPath, pathDir } = nativePackage;
|
||||
// and guarantees that when either the child terminates or the parent
|
||||
// receives a fatal signal, both processes exit in a predictable manner.
|
||||
|
||||
function getUpdatedPath(newDirs) {
|
||||
const pathSep = process.platform === "win32" ? ";" : ":";
|
||||
const existingPath = process.env.PATH || "";
|
||||
const updatedPath = [
|
||||
...newDirs,
|
||||
...existingPath.split(pathSep).filter(Boolean),
|
||||
].join(pathSep);
|
||||
return updatedPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use heuristics to detect the package manager that was used to install Codex
|
||||
* in order to give the user a hint about how to update it.
|
||||
@@ -167,13 +150,7 @@ function detectPackageManager() {
|
||||
return userAgent ? "npm" : null;
|
||||
}
|
||||
|
||||
const additionalDirs = [];
|
||||
if (existsSync(pathDir)) {
|
||||
additionalDirs.push(pathDir);
|
||||
}
|
||||
const updatedPath = getUpdatedPath(additionalDirs);
|
||||
|
||||
const env = { ...process.env, PATH: updatedPath };
|
||||
const env = { ...process.env };
|
||||
const packageManagerEnvVar =
|
||||
detectPackageManager() === "bun"
|
||||
? "CODEX_MANAGED_BY_BUN"
|
||||
|
||||
Generated
+27
@@ -2145,12 +2145,14 @@ dependencies = [
|
||||
"anyhow",
|
||||
"codex-apply-patch",
|
||||
"codex-exec-server",
|
||||
"codex-install-context",
|
||||
"codex-linux-sandbox",
|
||||
"codex-sandboxing",
|
||||
"codex-shell-escalation",
|
||||
"codex-utils-absolute-path",
|
||||
"codex-utils-home-dir",
|
||||
"dotenvy",
|
||||
"pretty_assertions",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
]
|
||||
@@ -2506,6 +2508,14 @@ dependencies = [
|
||||
"urlencoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-context-fragments"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"codex-protocol",
|
||||
"codex-utils-string",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-core"
|
||||
version = "0.0.0"
|
||||
@@ -2528,6 +2538,7 @@ dependencies = [
|
||||
"codex-code-mode",
|
||||
"codex-config",
|
||||
"codex-connectors",
|
||||
"codex-context-fragments",
|
||||
"codex-core-plugins",
|
||||
"codex-core-skills",
|
||||
"codex-exec-server",
|
||||
@@ -2692,6 +2703,7 @@ dependencies = [
|
||||
"codex-analytics",
|
||||
"codex-app-server-protocol",
|
||||
"codex-config",
|
||||
"codex-context-fragments",
|
||||
"codex-exec-server",
|
||||
"codex-login",
|
||||
"codex-model-provider",
|
||||
@@ -2853,6 +2865,7 @@ name = "codex-extension-api"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"codex-context-fragments",
|
||||
"codex-protocol",
|
||||
"codex-tools",
|
||||
]
|
||||
@@ -3458,6 +3471,7 @@ name = "codex-prompts"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"codex-context-fragments",
|
||||
"codex-execpolicy",
|
||||
"codex-git-utils",
|
||||
"codex-protocol",
|
||||
@@ -3715,6 +3729,19 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-skills-extension"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"codex-core",
|
||||
"codex-core-skills",
|
||||
"codex-extension-api",
|
||||
"codex-protocol",
|
||||
"pretty_assertions",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-state"
|
||||
version = "0.0.0"
|
||||
|
||||
@@ -29,6 +29,7 @@ members = [
|
||||
"collaboration-mode-templates",
|
||||
"connectors",
|
||||
"config",
|
||||
"context-fragments",
|
||||
"shell-command",
|
||||
"shell-escalation",
|
||||
"skills",
|
||||
@@ -48,6 +49,7 @@ members = [
|
||||
"ext/guardian",
|
||||
"ext/image-generation",
|
||||
"ext/memories",
|
||||
"ext/skills",
|
||||
"ext/web-search",
|
||||
"external-agent-migration",
|
||||
"external-agent-sessions",
|
||||
@@ -155,6 +157,7 @@ codex-cloud-tasks-mock-client = { path = "cloud-tasks-mock-client" }
|
||||
codex-code-mode = { path = "code-mode" }
|
||||
codex-config = { path = "config" }
|
||||
codex-connectors = { path = "connectors" }
|
||||
codex-context-fragments = { path = "context-fragments" }
|
||||
codex-core = { path = "core" }
|
||||
codex-core-api = { path = "core-api" }
|
||||
codex-core-plugins = { path = "core-plugins" }
|
||||
|
||||
@@ -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;
|
||||
|
||||
+31
@@ -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)?);
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ workspace = true
|
||||
anyhow = { workspace = true }
|
||||
codex-apply-patch = { workspace = true }
|
||||
codex-exec-server = { workspace = true }
|
||||
codex-install-context = { workspace = true }
|
||||
codex-linux-sandbox = { workspace = true }
|
||||
codex-sandboxing = { workspace = true }
|
||||
codex-shell-escalation = { workspace = true }
|
||||
@@ -24,3 +25,6 @@ codex-utils-home-dir = { workspace = true }
|
||||
dotenvy = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread"] }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
|
||||
+83
-21
@@ -1,3 +1,4 @@
|
||||
use std::ffi::OsString;
|
||||
use std::fs::File;
|
||||
use std::future::Future;
|
||||
use std::path::Path;
|
||||
@@ -5,6 +6,7 @@ use std::path::PathBuf;
|
||||
|
||||
use codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1;
|
||||
use codex_exec_server::CODEX_FS_HELPER_ARG1;
|
||||
use codex_install_context::InstallContext;
|
||||
use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0;
|
||||
use codex_utils_home_dir::find_codex_home;
|
||||
#[cfg(unix)]
|
||||
@@ -285,10 +287,11 @@ where
|
||||
/// - WINDOWS: `apply_patch.bat` batch script to invoke the current executable
|
||||
/// with the hidden `--codex-run-as-apply-patch` flag.
|
||||
///
|
||||
/// This temporary directory is prepended to the PATH environment variable so
|
||||
/// that `apply_patch` can be on the PATH without requiring the user to
|
||||
/// install a separate `apply_patch` executable, simplifying the deployment of
|
||||
/// Codex CLI.
|
||||
/// This temporary directory, followed by the package-managed `codex-path`
|
||||
/// directory when present, is prepended to the PATH environment variable so
|
||||
/// that `apply_patch` and bundled package helpers can be on the PATH without
|
||||
/// requiring the user to install separate executables, simplifying the
|
||||
/// deployment of Codex CLI.
|
||||
/// Note: In debug builds the temp-dir guard is disabled to ease local testing.
|
||||
///
|
||||
/// IMPORTANT: This function modifies the PATH environment variable, so it MUST
|
||||
@@ -371,23 +374,8 @@ pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result<Arg0PathEntryGu
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
const PATH_SEPARATOR: &str = ":";
|
||||
|
||||
#[cfg(windows)]
|
||||
const PATH_SEPARATOR: &str = ";";
|
||||
|
||||
let updated_path_env_var = match std::env::var_os("PATH") {
|
||||
Some(existing_path) => {
|
||||
let mut path_env_var =
|
||||
std::ffi::OsString::with_capacity(path.as_os_str().len() + 1 + existing_path.len());
|
||||
path_env_var.push(path);
|
||||
path_env_var.push(PATH_SEPARATOR);
|
||||
path_env_var.push(existing_path);
|
||||
path_env_var
|
||||
}
|
||||
None => path.as_os_str().to_owned(),
|
||||
};
|
||||
let updated_path_env_var =
|
||||
path_env_with_codex_entries(path, InstallContext::current(), std::env::var_os("PATH"));
|
||||
|
||||
unsafe {
|
||||
std::env::set_var("PATH", updated_path_env_var);
|
||||
@@ -420,6 +408,39 @@ pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result<Arg0PathEntryGu
|
||||
Ok(Arg0PathEntryGuard::new(temp_dir, lock_file, paths))
|
||||
}
|
||||
|
||||
fn path_env_with_codex_entries(
|
||||
arg0_dir: &Path,
|
||||
install_context: &InstallContext,
|
||||
existing_path: Option<OsString>,
|
||||
) -> OsString {
|
||||
#[cfg(unix)]
|
||||
const PATH_SEPARATOR: &str = ":";
|
||||
|
||||
#[cfg(windows)]
|
||||
const PATH_SEPARATOR: &str = ";";
|
||||
|
||||
let package_path_dir = install_context
|
||||
.package_layout
|
||||
.as_ref()
|
||||
.and_then(|package_layout| package_layout.path_dir.as_ref());
|
||||
let capacity = arg0_dir.as_os_str().len()
|
||||
+ package_path_dir.map_or(0, |path_dir| 1 + path_dir.as_os_str().len())
|
||||
+ existing_path
|
||||
.as_ref()
|
||||
.map_or(0, |existing_path| 1 + existing_path.len());
|
||||
let mut path_env_var = OsString::with_capacity(capacity);
|
||||
path_env_var.push(arg0_dir);
|
||||
if let Some(path_dir) = package_path_dir {
|
||||
path_env_var.push(PATH_SEPARATOR);
|
||||
path_env_var.push(path_dir.as_path());
|
||||
}
|
||||
if let Some(existing_path) = existing_path {
|
||||
path_env_var.push(PATH_SEPARATOR);
|
||||
path_env_var.push(existing_path);
|
||||
}
|
||||
path_env_var
|
||||
}
|
||||
|
||||
fn janitor_cleanup(temp_root: &Path) -> std::io::Result<()> {
|
||||
let entries = match std::fs::read_dir(temp_root) {
|
||||
Ok(entries) => entries,
|
||||
@@ -475,6 +496,11 @@ mod tests {
|
||||
use super::run_main_with_arg0_guard;
|
||||
#[cfg(unix)]
|
||||
use anyhow::ensure;
|
||||
use codex_install_context::CodexPackageLayout;
|
||||
use codex_install_context::InstallContext;
|
||||
use codex_install_context::InstallMethod;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use std::fs::File;
|
||||
use std::path::Path;
|
||||
@@ -513,6 +539,42 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_env_includes_arg0_dir_package_path_dir_and_existing_path() -> anyhow::Result<()> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let arg0_dir = temp_dir.path().join("arg0");
|
||||
let package_dir = temp_dir.path().join("package");
|
||||
let bin_dir = package_dir.join("bin");
|
||||
let path_dir = package_dir.join("codex-path");
|
||||
let existing_dir = temp_dir.path().join("existing-bin");
|
||||
fs::create_dir_all(&arg0_dir)?;
|
||||
fs::create_dir_all(&bin_dir)?;
|
||||
fs::create_dir_all(&path_dir)?;
|
||||
fs::create_dir_all(&existing_dir)?;
|
||||
let path_dir = AbsolutePathBuf::from_absolute_path(path_dir.canonicalize()?)?;
|
||||
let install_context = InstallContext {
|
||||
method: InstallMethod::Other,
|
||||
package_layout: Some(CodexPackageLayout {
|
||||
package_dir: AbsolutePathBuf::from_absolute_path(package_dir.canonicalize()?)?,
|
||||
bin_dir: AbsolutePathBuf::from_absolute_path(bin_dir.canonicalize()?)?,
|
||||
resources_dir: None,
|
||||
path_dir: Some(path_dir.clone()),
|
||||
}),
|
||||
};
|
||||
|
||||
let updated_path = super::path_env_with_codex_entries(
|
||||
&arg0_dir,
|
||||
&install_context,
|
||||
Some(existing_dir.as_os_str().to_owned()),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
std::env::split_paths(&updated_path).collect::<Vec<_>>(),
|
||||
vec![arg0_dir, path_dir.as_path().to_path_buf(), existing_dir,],
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_main_with_arg0_guard_keeps_aliases_alive_until_main_returns() -> anyhow::Result<()> {
|
||||
|
||||
@@ -49,7 +49,8 @@ fn cloud_config_eligible_auth(auth: &CodexAuth) -> bool {
|
||||
return false;
|
||||
};
|
||||
auth.uses_codex_backend()
|
||||
&& (plan_type.is_business_like() || matches!(plan_type, PlanType::Enterprise))
|
||||
&& (plan_type.is_business_like()
|
||||
|| matches!(plan_type, PlanType::Enterprise | PlanType::Edu))
|
||||
}
|
||||
|
||||
fn optional_bundle(bundle: CloudConfigBundle) -> Option<CloudConfigBundle> {
|
||||
|
||||
@@ -351,7 +351,7 @@ async fn get_bundle_skips_non_chatgpt_auth() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_bundle_skips_non_business_or_enterprise_plan() {
|
||||
async fn get_bundle_skips_individual_plan() {
|
||||
let fetcher = Arc::new(StaticBundleClient::new(test_bundle()));
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let service = CloudConfigBundleService::new(
|
||||
@@ -365,6 +365,46 @@ async fn get_bundle_skips_non_business_or_enterprise_plan() {
|
||||
assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_bundle_allows_eligible_workspace_plans_and_writes_cache() {
|
||||
for plan_type in [
|
||||
"business",
|
||||
"enterprise_cbp_usage_based",
|
||||
"enterprise",
|
||||
"hc",
|
||||
"edu",
|
||||
"education",
|
||||
] {
|
||||
let bundle = test_bundle();
|
||||
let fetcher = Arc::new(StaticBundleClient::new(bundle.clone()));
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let service = CloudConfigBundleService::new(
|
||||
auth_manager_with_plan(plan_type).await,
|
||||
fetcher.clone(),
|
||||
codex_home.path().to_path_buf(),
|
||||
CLOUD_CONFIG_BUNDLE_TIMEOUT,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
service.load_startup_bundle().await,
|
||||
Ok(Some(bundle)),
|
||||
"plan_type: {plan_type}"
|
||||
);
|
||||
assert_eq!(
|
||||
fetcher.request_count.load(Ordering::SeqCst),
|
||||
1,
|
||||
"plan_type: {plan_type}"
|
||||
);
|
||||
assert!(
|
||||
codex_home
|
||||
.path()
|
||||
.join(CLOUD_CONFIG_BUNDLE_CACHE_FILENAME)
|
||||
.exists(),
|
||||
"plan_type: {plan_type}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_bundle_skips_team_like_usage_based_plan() {
|
||||
let fetcher = Arc::new(StaticBundleClient::new(test_bundle()));
|
||||
@@ -380,31 +420,6 @@ async fn get_bundle_skips_team_like_usage_based_plan() {
|
||||
assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_bundle_allows_business_plan_and_writes_cache() {
|
||||
let bundle = test_bundle();
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let fetcher = Arc::new(StaticBundleClient::new(bundle.clone()));
|
||||
let service = CloudConfigBundleService::new(
|
||||
auth_manager_with_plan("business").await,
|
||||
fetcher.clone(),
|
||||
codex_home.path().to_path_buf(),
|
||||
CLOUD_CONFIG_BUNDLE_TIMEOUT,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
service.load_startup_bundle().await,
|
||||
Ok(Some(bundle.clone()))
|
||||
);
|
||||
assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1);
|
||||
assert!(
|
||||
codex_home
|
||||
.path()
|
||||
.join(CLOUD_CONFIG_BUNDLE_CACHE_FILENAME)
|
||||
.exists()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_bundle_rejects_invalid_remote_bundle_before_cache_write() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
@@ -468,36 +483,6 @@ async fn get_bundle_ignores_invalid_cache_and_refetches() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_bundle_allows_business_like_usage_based_plan() {
|
||||
let fetcher = Arc::new(StaticBundleClient::new(test_bundle()));
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let service = CloudConfigBundleService::new(
|
||||
auth_manager_with_plan("enterprise_cbp_usage_based").await,
|
||||
fetcher.clone(),
|
||||
codex_home.path().to_path_buf(),
|
||||
CLOUD_CONFIG_BUNDLE_TIMEOUT,
|
||||
);
|
||||
|
||||
assert_eq!(service.load_startup_bundle().await, Ok(Some(test_bundle())));
|
||||
assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_bundle_allows_hc_plan_as_enterprise() {
|
||||
let fetcher = Arc::new(StaticBundleClient::new(test_bundle()));
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let service = CloudConfigBundleService::new(
|
||||
auth_manager_with_plan("hc").await,
|
||||
fetcher.clone(),
|
||||
codex_home.path().to_path_buf(),
|
||||
CLOUD_CONFIG_BUNDLE_TIMEOUT,
|
||||
);
|
||||
|
||||
assert_eq!(service.load_startup_bundle().await, Ok(Some(test_bundle())));
|
||||
assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_bundle_empty_response_is_success_and_cached() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "context-fragments",
|
||||
crate_name = "codex_context_fragments",
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
name = "codex-context-fragments"
|
||||
version.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "codex_context_fragments"
|
||||
path = "src/lib.rs"
|
||||
test = false
|
||||
doctest = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
codex-protocol = { workspace = true }
|
||||
codex-utils-string = { workspace = true }
|
||||
+8
-7
@@ -1,24 +1,25 @@
|
||||
use super::ContextualUserFragment;
|
||||
use codex_utils_string::truncate_middle_with_token_budget;
|
||||
|
||||
use crate::ContextualUserFragment;
|
||||
|
||||
const MAX_ADDITIONAL_CONTEXT_VALUE_TOKENS: usize = 1_000;
|
||||
const ADDITIONAL_CONTEXT_END_MARKER_SUFFIX: &str = ">";
|
||||
const ADDITIONAL_CONTEXT_START_MARKER_PREFIX: &str = "<external_";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct AdditionalContextUserFragment {
|
||||
pub struct AdditionalContextUserFragment {
|
||||
key: String,
|
||||
value: String,
|
||||
}
|
||||
|
||||
impl AdditionalContextUserFragment {
|
||||
pub(crate) fn new(key: String, value: String) -> Self {
|
||||
pub fn new(key: String, value: String) -> Self {
|
||||
Self { key, value }
|
||||
}
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for AdditionalContextUserFragment {
|
||||
fn role() -> &'static str {
|
||||
fn role(&self) -> &'static str {
|
||||
"user"
|
||||
}
|
||||
|
||||
@@ -52,19 +53,19 @@ impl ContextualUserFragment for AdditionalContextUserFragment {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct AdditionalContextDeveloperFragment {
|
||||
pub struct AdditionalContextDeveloperFragment {
|
||||
key: String,
|
||||
value: String,
|
||||
}
|
||||
|
||||
impl AdditionalContextDeveloperFragment {
|
||||
pub(crate) fn new(key: String, value: String) -> Self {
|
||||
pub fn new(key: String, value: String) -> Self {
|
||||
Self { key, value }
|
||||
}
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for AdditionalContextDeveloperFragment {
|
||||
fn role() -> &'static str {
|
||||
fn role(&self) -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
+26
-12
@@ -1,28 +1,33 @@
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use std::marker::PhantomData;
|
||||
|
||||
/// Type-erased registration for a contextual user fragment.
|
||||
///
|
||||
/// Implementations are used by context filtering code to recognize injected
|
||||
/// fragments without constructing the concrete context payload.
|
||||
pub(crate) trait FragmentRegistration: Sync {
|
||||
pub trait FragmentRegistration: Sync {
|
||||
fn matches_text(&self, text: &str) -> bool;
|
||||
}
|
||||
|
||||
pub(crate) struct FragmentRegistrationProxy<T> {
|
||||
_marker: PhantomData<fn() -> T>,
|
||||
pub struct FragmentRegistrationProxy<T> {
|
||||
_marker: std::marker::PhantomData<fn() -> T>,
|
||||
}
|
||||
|
||||
impl<T> FragmentRegistrationProxy<T> {
|
||||
pub(crate) const fn new() -> Self {
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
_marker: PhantomData,
|
||||
_marker: std::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Default for FragmentRegistrationProxy<T> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ContextualUserFragment> FragmentRegistration for FragmentRegistrationProxy<T> {
|
||||
fn matches_text(&self, text: &str) -> bool {
|
||||
T::matches_text(text)
|
||||
@@ -39,9 +44,7 @@ impl<T: ContextualUserFragment> FragmentRegistration for FragmentRegistrationPro
|
||||
/// in which case the default helpers render only the body and never match
|
||||
/// arbitrary text.
|
||||
pub trait ContextualUserFragment {
|
||||
fn role() -> &'static str
|
||||
where
|
||||
Self: Sized;
|
||||
fn role(&self) -> &'static str;
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str);
|
||||
|
||||
@@ -75,7 +78,18 @@ pub trait ContextualUserFragment {
|
||||
{
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: Self::role().to_string(),
|
||||
role: self.role().to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: self.render(),
|
||||
}],
|
||||
phase: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn into_boxed_response_item(self: Box<Self>) -> ResponseItem {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: self.role().to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: self.render(),
|
||||
}],
|
||||
@@ -88,7 +102,7 @@ pub trait ContextualUserFragment {
|
||||
Self: Sized,
|
||||
{
|
||||
ResponseInputItem::Message {
|
||||
role: Self::role().to_string(),
|
||||
role: self.role().to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: self.render(),
|
||||
}],
|
||||
@@ -97,7 +111,7 @@ pub trait ContextualUserFragment {
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_marked_text(start_marker: &str, end_marker: &str, text: &str) -> bool {
|
||||
pub(crate) fn matches_marked_text(start_marker: &str, end_marker: &str, text: &str) -> bool {
|
||||
if start_marker.is_empty() || end_marker.is_empty() {
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
mod additional_context;
|
||||
mod fragment;
|
||||
|
||||
pub use additional_context::AdditionalContextDeveloperFragment;
|
||||
pub use additional_context::AdditionalContextUserFragment;
|
||||
pub use fragment::ContextualUserFragment;
|
||||
pub use fragment::FragmentRegistration;
|
||||
pub use fragment::FragmentRegistrationProxy;
|
||||
@@ -17,6 +17,7 @@ anyhow = { workspace = true }
|
||||
codex-analytics = { workspace = true }
|
||||
codex-app-server-protocol = { workspace = true }
|
||||
codex-config = { workspace = true }
|
||||
codex-context-fragments = { workspace = true }
|
||||
codex-exec-server = { workspace = true }
|
||||
codex-login = { workspace = true }
|
||||
codex-model-provider = { workspace = true }
|
||||
|
||||
@@ -7,6 +7,7 @@ mod mention_counts;
|
||||
pub mod model;
|
||||
pub mod remote;
|
||||
pub mod render;
|
||||
mod skill_instructions;
|
||||
pub mod system;
|
||||
|
||||
pub(crate) use invocation_utils::build_implicit_skill_path_indexes;
|
||||
@@ -29,3 +30,4 @@ pub use render::SkillRenderReport;
|
||||
pub use render::build_available_skills;
|
||||
pub use render::default_skill_metadata_budget;
|
||||
pub use render::render_available_skills_body;
|
||||
pub use skill_instructions::SkillInstructions;
|
||||
|
||||
+7
-7
@@ -1,12 +1,12 @@
|
||||
use codex_core_skills::injection::SkillInjection;
|
||||
use codex_context_fragments::ContextualUserFragment;
|
||||
|
||||
use super::ContextualUserFragment;
|
||||
use crate::injection::SkillInjection;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct SkillInstructions {
|
||||
pub(crate) name: String,
|
||||
pub(crate) path: String,
|
||||
pub(crate) contents: String,
|
||||
pub struct SkillInstructions {
|
||||
name: String,
|
||||
path: String,
|
||||
contents: String,
|
||||
}
|
||||
|
||||
impl From<&SkillInjection> for SkillInstructions {
|
||||
@@ -20,7 +20,7 @@ impl From<&SkillInjection> for SkillInstructions {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for SkillInstructions {
|
||||
fn role() -> &'static str {
|
||||
fn role(&self) -> &'static str {
|
||||
"user"
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ codex-apply-patch = { workspace = true }
|
||||
codex-async-utils = { workspace = true }
|
||||
codex-code-mode = { workspace = true }
|
||||
codex-connectors = { workspace = true }
|
||||
codex-context-fragments = { workspace = true }
|
||||
codex-config = { workspace = true }
|
||||
codex-core-plugins = { workspace = true }
|
||||
codex-core-skills = { workspace = true }
|
||||
|
||||
@@ -18,7 +18,7 @@ pub(crate) async fn resolve_agent_target(
|
||||
session
|
||||
.services
|
||||
.agent_control
|
||||
.resolve_agent_reference(session.conversation_id, &turn.session_source, target)
|
||||
.resolve_agent_reference(session.thread_id, &turn.session_source, target)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
codex_protocol::error::CodexErr::UnsupportedOperation(message) => {
|
||||
@@ -32,5 +32,5 @@ fn register_session_root(session: &Arc<Session>, turn: &Arc<TurnContext>) {
|
||||
session
|
||||
.services
|
||||
.agent_control
|
||||
.register_session_root(session.conversation_id, turn.parent_thread_id);
|
||||
.register_session_root(session.thread_id, turn.parent_thread_id);
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ pub(crate) async fn run_codex_thread_interactive(
|
||||
conversation_history,
|
||||
session_source: SessionSource::SubAgent(subagent_source.clone()),
|
||||
forked_from_thread_id,
|
||||
parent_thread_id: Some(parent_session.conversation_id),
|
||||
parent_thread_id: Some(parent_session.thread_id),
|
||||
thread_source: Some(ThreadSource::Subagent),
|
||||
agent_control: parent_session.services.agent_control.clone(),
|
||||
dynamic_tools: Vec::new(),
|
||||
@@ -116,8 +116,8 @@ pub(crate) async fn run_codex_thread_interactive(
|
||||
&parent_session.services.analytics_events_client,
|
||||
client_metadata,
|
||||
codex.session.session_id(),
|
||||
codex.session.conversation_id,
|
||||
Some(parent_session.conversation_id),
|
||||
codex.session.thread_id,
|
||||
Some(parent_session.thread_id),
|
||||
thread_config,
|
||||
subagent_source,
|
||||
);
|
||||
|
||||
@@ -327,7 +327,7 @@ impl CompactionAnalyticsAttempt {
|
||||
) -> Self {
|
||||
let active_context_tokens_before = sess.get_total_token_usage().await;
|
||||
Self {
|
||||
thread_id: sess.conversation_id.to_string(),
|
||||
thread_id: sess.thread_id.to_string(),
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
trigger,
|
||||
reason,
|
||||
|
||||
@@ -9914,6 +9914,7 @@ enabled = true
|
||||
.unwrap_or_default()
|
||||
.contains("maximum concurrency"),
|
||||
);
|
||||
assert!(config.multi_agent_v2.hide_spawn_agent_metadata);
|
||||
assert!(config.multi_agent_v2.non_code_mode_only);
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -1059,7 +1059,7 @@ impl Default for MultiAgentV2Config {
|
||||
DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT.to_string(),
|
||||
),
|
||||
tool_namespace: None,
|
||||
hide_spawn_agent_metadata: false,
|
||||
hide_spawn_agent_metadata: true,
|
||||
non_code_mode_only: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ impl ApprovedCommandPrefixSaved {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for ApprovedCommandPrefixSaved {
|
||||
fn role() -> &'static str {
|
||||
fn role(&self) -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ impl AppsInstructions {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for AppsInstructions {
|
||||
fn role() -> &'static str {
|
||||
fn role(&self) -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ impl AvailablePluginsInstructions {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for AvailablePluginsInstructions {
|
||||
fn role() -> &'static str {
|
||||
fn role(&self) -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ impl From<AvailableSkills> for AvailableSkillsInstructions {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for AvailableSkillsInstructions {
|
||||
fn role() -> &'static str {
|
||||
fn role(&self) -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ impl CollaborationModeInstructions {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for CollaborationModeInstructions {
|
||||
fn role() -> &'static str {
|
||||
fn role(&self) -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
|
||||
@@ -523,7 +523,7 @@ fn workspace_roots_from_turn_context_item(
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for EnvironmentContext {
|
||||
fn role() -> &'static str {
|
||||
fn role(&self) -> &'static str {
|
||||
"user"
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ use super::ContextualUserFragment;
|
||||
pub(crate) struct GuardianFollowupReviewReminder;
|
||||
|
||||
impl ContextualUserFragment for GuardianFollowupReviewReminder {
|
||||
fn role() -> &'static str {
|
||||
fn role(&self) -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ impl HookAdditionalContext {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for HookAdditionalContext {
|
||||
fn role() -> &'static str {
|
||||
fn role(&self) -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ impl ImageGenerationInstructions {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for ImageGenerationInstructions {
|
||||
fn role() -> &'static str {
|
||||
fn role(&self) -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ impl InternalModelContextFragment {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for InternalModelContextFragment {
|
||||
fn role() -> &'static str {
|
||||
fn role(&self) -> &'static str {
|
||||
"user"
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ use super::ContextualUserFragment;
|
||||
pub(crate) struct LegacyApplyPatchExecCommandWarning;
|
||||
|
||||
impl ContextualUserFragment for LegacyApplyPatchExecCommandWarning {
|
||||
fn role() -> &'static str {
|
||||
fn role(&self) -> &'static str {
|
||||
"user"
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ use super::ContextualUserFragment;
|
||||
pub(crate) struct LegacyModelMismatchWarning;
|
||||
|
||||
impl ContextualUserFragment for LegacyModelMismatchWarning {
|
||||
fn role() -> &'static str {
|
||||
fn role(&self) -> &'static str {
|
||||
"user"
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ use super::ContextualUserFragment;
|
||||
pub(crate) struct LegacyUnifiedExecProcessLimitWarning;
|
||||
|
||||
impl ContextualUserFragment for LegacyUnifiedExecProcessLimitWarning {
|
||||
fn role() -> &'static str {
|
||||
fn role(&self) -> &'static str {
|
||||
"user"
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,6 @@ mod available_skills_instructions;
|
||||
mod collaboration_mode_instructions;
|
||||
mod contextual_user_message;
|
||||
mod environment_context;
|
||||
mod fragment;
|
||||
mod fragments;
|
||||
mod guardian_followup_review_reminder;
|
||||
mod hook_additional_context;
|
||||
mod image_generation_instructions;
|
||||
@@ -24,7 +22,6 @@ mod plugin_instructions;
|
||||
mod realtime_end_instructions;
|
||||
mod realtime_start_instructions;
|
||||
mod realtime_start_with_instructions;
|
||||
mod skill_instructions;
|
||||
mod subagent_notification;
|
||||
mod turn_aborted;
|
||||
mod user_instructions;
|
||||
@@ -34,15 +31,16 @@ pub(crate) use approved_command_prefix_saved::ApprovedCommandPrefixSaved;
|
||||
pub(crate) use apps_instructions::AppsInstructions;
|
||||
pub(crate) use available_plugins_instructions::AvailablePluginsInstructions;
|
||||
pub(crate) use available_skills_instructions::AvailableSkillsInstructions;
|
||||
pub(crate) use codex_context_fragments::AdditionalContextDeveloperFragment;
|
||||
pub(crate) use codex_context_fragments::AdditionalContextUserFragment;
|
||||
pub use codex_context_fragments::ContextualUserFragment;
|
||||
pub(crate) use codex_context_fragments::FragmentRegistration;
|
||||
pub(crate) use codex_context_fragments::FragmentRegistrationProxy;
|
||||
pub(crate) use codex_core_skills::SkillInstructions;
|
||||
pub(crate) use collaboration_mode_instructions::CollaborationModeInstructions;
|
||||
pub(crate) use contextual_user_message::is_contextual_user_fragment;
|
||||
pub(crate) use contextual_user_message::parse_visible_hook_prompt_message;
|
||||
pub(crate) use environment_context::EnvironmentContext;
|
||||
pub use fragment::ContextualUserFragment;
|
||||
pub(crate) use fragment::FragmentRegistration;
|
||||
pub(crate) use fragment::FragmentRegistrationProxy;
|
||||
pub(crate) use fragments::AdditionalContextDeveloperFragment;
|
||||
pub(crate) use fragments::AdditionalContextUserFragment;
|
||||
pub(crate) use guardian_followup_review_reminder::GuardianFollowupReviewReminder;
|
||||
pub(crate) use hook_additional_context::HookAdditionalContext;
|
||||
pub(crate) use image_generation_instructions::ImageGenerationInstructions;
|
||||
@@ -60,7 +58,6 @@ pub(crate) use plugin_instructions::PluginInstructions;
|
||||
pub(crate) use realtime_end_instructions::RealtimeEndInstructions;
|
||||
pub(crate) use realtime_start_instructions::RealtimeStartInstructions;
|
||||
pub(crate) use realtime_start_with_instructions::RealtimeStartWithInstructions;
|
||||
pub(crate) use skill_instructions::SkillInstructions;
|
||||
pub(crate) use subagent_notification::SubagentNotification;
|
||||
pub(crate) use turn_aborted::TurnAborted;
|
||||
pub(crate) use user_instructions::UserInstructions;
|
||||
|
||||
@@ -14,7 +14,7 @@ impl ModelSwitchInstructions {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for ModelSwitchInstructions {
|
||||
fn role() -> &'static str {
|
||||
fn role(&self) -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ impl NetworkRuleSaved {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for NetworkRuleSaved {
|
||||
fn role() -> &'static str {
|
||||
fn role(&self) -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1 @@
|
||||
use super::ContextualUserFragment;
|
||||
|
||||
pub use codex_prompts::PermissionsInstructions;
|
||||
|
||||
impl ContextualUserFragment for PermissionsInstructions {
|
||||
fn role() -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
("<permissions instructions>", "</permissions instructions>")
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
PermissionsInstructions::body(self)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ impl PersonalitySpecInstructions {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for PersonalitySpecInstructions {
|
||||
fn role() -> &'static str {
|
||||
fn role(&self) -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ impl PluginInstructions {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for PluginInstructions {
|
||||
fn role() -> &'static str {
|
||||
fn role(&self) -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ impl RealtimeEndInstructions {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for RealtimeEndInstructions {
|
||||
fn role() -> &'static str {
|
||||
fn role(&self) -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ use codex_protocol::protocol::REALTIME_CONVERSATION_OPEN_TAG;
|
||||
pub(crate) struct RealtimeStartInstructions;
|
||||
|
||||
impl ContextualUserFragment for RealtimeStartInstructions {
|
||||
fn role() -> &'static str {
|
||||
fn role(&self) -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ impl RealtimeStartWithInstructions {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for RealtimeStartWithInstructions {
|
||||
fn role() -> &'static str {
|
||||
fn role(&self) -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ impl SubagentNotification {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for SubagentNotification {
|
||||
fn role() -> &'static str {
|
||||
fn role(&self) -> &'static str {
|
||||
"user"
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ impl TurnAborted {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for TurnAborted {
|
||||
fn role() -> &'static str {
|
||||
fn role(&self) -> &'static str {
|
||||
"user"
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ pub(crate) struct UserInstructions {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for UserInstructions {
|
||||
fn role() -> &'static str {
|
||||
fn role(&self) -> &'static str {
|
||||
"user"
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ impl UserShellCommand {
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for UserShellCommand {
|
||||
fn role() -> &'static str {
|
||||
fn role(&self) -> &'static str {
|
||||
"user"
|
||||
}
|
||||
|
||||
|
||||
+27
-31
@@ -403,7 +403,7 @@ impl Session {
|
||||
let state_db = self.require_state_db_for_thread_goals().await?;
|
||||
state_db
|
||||
.thread_goals()
|
||||
.get_thread_goal(self.conversation_id)
|
||||
.get_thread_goal(self.thread_id)
|
||||
.await
|
||||
.map(|goal| goal.map(protocol_goal_from_state))
|
||||
}
|
||||
@@ -442,14 +442,14 @@ impl Session {
|
||||
let goal = if let Some(objective) = objective.as_deref() {
|
||||
let existing_goal = state_db
|
||||
.thread_goals()
|
||||
.get_thread_goal(self.conversation_id)
|
||||
.get_thread_goal(self.thread_id)
|
||||
.await?;
|
||||
previous_status = existing_goal.as_ref().map(|goal| goal.status);
|
||||
if let Some(existing_goal) = existing_goal.as_ref() {
|
||||
state_db
|
||||
.thread_goals()
|
||||
.update_thread_goal(
|
||||
self.conversation_id,
|
||||
self.thread_id,
|
||||
codex_state::GoalUpdate {
|
||||
objective: Some(objective.to_string()),
|
||||
status: status.map(state_goal_status_from_protocol),
|
||||
@@ -461,7 +461,7 @@ impl Session {
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"cannot update goal for thread {}: no goal exists",
|
||||
self.conversation_id
|
||||
self.thread_id
|
||||
)
|
||||
})?
|
||||
} else {
|
||||
@@ -469,7 +469,7 @@ impl Session {
|
||||
state_db
|
||||
.thread_goals()
|
||||
.replace_thread_goal(
|
||||
self.conversation_id,
|
||||
self.thread_id,
|
||||
objective,
|
||||
status
|
||||
.map(state_goal_status_from_protocol)
|
||||
@@ -481,7 +481,7 @@ impl Session {
|
||||
} else {
|
||||
let existing_goal = state_db
|
||||
.thread_goals()
|
||||
.get_thread_goal(self.conversation_id)
|
||||
.get_thread_goal(self.thread_id)
|
||||
.await?;
|
||||
previous_status = existing_goal.as_ref().map(|goal| goal.status);
|
||||
let expected_goal_id = existing_goal.map(|goal| goal.goal_id);
|
||||
@@ -489,7 +489,7 @@ impl Session {
|
||||
state_db
|
||||
.thread_goals()
|
||||
.update_thread_goal(
|
||||
self.conversation_id,
|
||||
self.thread_id,
|
||||
codex_state::GoalUpdate {
|
||||
objective: None,
|
||||
status,
|
||||
@@ -501,7 +501,7 @@ impl Session {
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"cannot update goal for thread {}: no goal exists",
|
||||
self.conversation_id
|
||||
self.thread_id
|
||||
)
|
||||
})?
|
||||
};
|
||||
@@ -509,7 +509,7 @@ impl Session {
|
||||
if objective.is_some() {
|
||||
set_thread_preview_from_goal_objective(
|
||||
&state_db,
|
||||
self.conversation_id,
|
||||
self.thread_id,
|
||||
goal.objective.as_str(),
|
||||
)
|
||||
.await;
|
||||
@@ -546,7 +546,7 @@ impl Session {
|
||||
self.send_event(
|
||||
turn_context,
|
||||
EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent {
|
||||
thread_id: self.conversation_id,
|
||||
thread_id: self.thread_id,
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
goal: goal.clone(),
|
||||
}),
|
||||
@@ -582,7 +582,7 @@ impl Session {
|
||||
let goal = state_db
|
||||
.thread_goals()
|
||||
.insert_thread_goal(
|
||||
self.conversation_id,
|
||||
self.thread_id,
|
||||
objective,
|
||||
codex_state::ThreadGoalStatus::Active,
|
||||
token_budget,
|
||||
@@ -591,16 +591,12 @@ impl Session {
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"cannot create a new goal because thread {} already has a goal",
|
||||
self.conversation_id
|
||||
self.thread_id
|
||||
)
|
||||
})?;
|
||||
|
||||
set_thread_preview_from_goal_objective(
|
||||
&state_db,
|
||||
self.conversation_id,
|
||||
goal.objective.as_str(),
|
||||
)
|
||||
.await;
|
||||
set_thread_preview_from_goal_objective(&state_db, self.thread_id, goal.objective.as_str())
|
||||
.await;
|
||||
let goal_id = goal.goal_id.clone();
|
||||
self.emit_goal_created_metric();
|
||||
let goal = protocol_goal_from_state(goal);
|
||||
@@ -617,7 +613,7 @@ impl Session {
|
||||
self.send_event(
|
||||
turn_context,
|
||||
EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent {
|
||||
thread_id: self.conversation_id,
|
||||
thread_id: self.thread_id,
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
goal: goal.clone(),
|
||||
}),
|
||||
@@ -799,7 +795,7 @@ impl Session {
|
||||
) -> anyhow::Result<Option<codex_state::ThreadGoalStatus>> {
|
||||
let goal = state_db
|
||||
.thread_goals()
|
||||
.get_thread_goal(self.conversation_id)
|
||||
.get_thread_goal(self.thread_id)
|
||||
.await?;
|
||||
Ok(goal.and_then(|goal| {
|
||||
expected_goal_id
|
||||
@@ -843,7 +839,7 @@ impl Session {
|
||||
};
|
||||
match state_db
|
||||
.thread_goals()
|
||||
.get_thread_goal(self.conversation_id)
|
||||
.get_thread_goal(self.thread_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(goal))
|
||||
@@ -981,7 +977,7 @@ impl Session {
|
||||
let outcome = state_db
|
||||
.thread_goals()
|
||||
.account_thread_goal_usage(
|
||||
self.conversation_id,
|
||||
self.thread_id,
|
||||
time_delta_seconds,
|
||||
token_delta,
|
||||
codex_state::GoalAccountingMode::ActiveOnly,
|
||||
@@ -1043,7 +1039,7 @@ impl Session {
|
||||
self.send_event(
|
||||
turn_context,
|
||||
EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent {
|
||||
thread_id: self.conversation_id,
|
||||
thread_id: self.thread_id,
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
goal: goal.clone(),
|
||||
}),
|
||||
@@ -1106,7 +1102,7 @@ impl Session {
|
||||
match state_db
|
||||
.thread_goals()
|
||||
.account_thread_goal_usage(
|
||||
self.conversation_id,
|
||||
self.thread_id,
|
||||
time_delta_seconds,
|
||||
/*token_delta*/ 0,
|
||||
mode,
|
||||
@@ -1174,7 +1170,7 @@ impl Session {
|
||||
.await?;
|
||||
let Some(goal) = state_db
|
||||
.thread_goals()
|
||||
.usage_limit_active_thread_goal(self.conversation_id)
|
||||
.usage_limit_active_thread_goal(self.thread_id)
|
||||
.await?
|
||||
else {
|
||||
return Ok(());
|
||||
@@ -1186,7 +1182,7 @@ impl Session {
|
||||
self.send_event(
|
||||
turn_context,
|
||||
EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent {
|
||||
thread_id: self.conversation_id,
|
||||
thread_id: self.thread_id,
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
goal,
|
||||
}),
|
||||
@@ -1217,7 +1213,7 @@ impl Session {
|
||||
};
|
||||
let Some(goal) = state_db
|
||||
.thread_goals()
|
||||
.get_thread_goal(self.conversation_id)
|
||||
.get_thread_goal(self.thread_id)
|
||||
.await?
|
||||
else {
|
||||
self.clear_stopped_thread_goal_runtime_state().await;
|
||||
@@ -1269,7 +1265,7 @@ impl Session {
|
||||
let goal_is_current = match self.state_db_for_thread_goals().await {
|
||||
Ok(Some(state_db)) => match state_db
|
||||
.thread_goals()
|
||||
.get_thread_goal(self.conversation_id)
|
||||
.get_thread_goal(self.thread_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(goal))
|
||||
@@ -1367,7 +1363,7 @@ impl Session {
|
||||
};
|
||||
let goal = match state_db
|
||||
.thread_goals()
|
||||
.get_thread_goal(self.conversation_id)
|
||||
.get_thread_goal(self.thread_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(goal)) => goal,
|
||||
@@ -1430,7 +1426,7 @@ impl Session {
|
||||
};
|
||||
|
||||
let thread_metadata_present = state_db
|
||||
.get_thread(self.conversation_id)
|
||||
.get_thread(self.thread_id)
|
||||
.await
|
||||
.context("failed to read thread metadata before reconciling thread goals")?
|
||||
.is_some();
|
||||
@@ -1453,7 +1449,7 @@ impl Session {
|
||||
)
|
||||
.await;
|
||||
let thread_metadata_present = state_db
|
||||
.get_thread(self.conversation_id)
|
||||
.get_thread(self.thread_id)
|
||||
.await
|
||||
.context("failed to read thread metadata after reconciling thread goals")?
|
||||
.is_some();
|
||||
|
||||
@@ -186,7 +186,7 @@ pub(crate) async fn build_guardian_prompt_items_with_parent_turn(
|
||||
push_text(headings.transcript_end.to_string());
|
||||
push_text(format!(
|
||||
"Reviewed Codex session id: {}\n",
|
||||
session.conversation_id
|
||||
session.thread_id
|
||||
));
|
||||
if let Some(note) = omission_note {
|
||||
push_text(format!("\n{note}\n"));
|
||||
|
||||
@@ -265,7 +265,7 @@ async fn run_guardian_review(
|
||||
let action_summary = guardian_assessment_action(&request);
|
||||
let reviewed_action = guardian_reviewed_action(&request);
|
||||
let review_tracking = GuardianReviewTrackContext::new(
|
||||
session.conversation_id.to_string(),
|
||||
session.thread_id.to_string(),
|
||||
assessment_turn_id.clone(),
|
||||
review_id.clone(),
|
||||
target_item_id.clone(),
|
||||
|
||||
@@ -662,7 +662,7 @@ async fn run_review_on_session(
|
||||
None
|
||||
};
|
||||
let mut analytics_result = GuardianReviewAnalyticsResult::from_session(
|
||||
review_session.codex.session.conversation_id.to_string(),
|
||||
review_session.codex.session.thread_id.to_string(),
|
||||
guardian_session_kind,
|
||||
params.model.clone(),
|
||||
guardian_reasoning_effort.map(|effort| effort.to_string()),
|
||||
|
||||
@@ -176,7 +176,7 @@ async fn guardian_test_session_and_turn_with_base_url(
|
||||
base_url: &str,
|
||||
) -> (Arc<Session>, Arc<TurnContext>) {
|
||||
let (mut session, mut turn) = crate::session::tests::make_session_and_context().await;
|
||||
session.conversation_id = fixed_guardian_parent_session_id();
|
||||
session.thread_id = fixed_guardian_parent_session_id();
|
||||
let mut config = (*turn.config).clone();
|
||||
config.model_provider.base_url = Some(format!("{base_url}/v1"));
|
||||
config.user_instructions = None;
|
||||
@@ -365,7 +365,7 @@ async fn build_guardian_prompt_full_mode_preserves_initial_review_format() -> an
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn build_guardian_prompt_includes_parent_turn_denied_reads() -> anyhow::Result<()> {
|
||||
let (mut session, mut turn) = crate::session::tests::make_session_and_context().await;
|
||||
session.conversation_id = fixed_guardian_parent_session_id();
|
||||
session.thread_id = fixed_guardian_parent_session_id();
|
||||
let denied_root = test_path_buf("/repo/private").abs();
|
||||
let denied_glob = test_path_buf("/repo/private/**").display().to_string();
|
||||
turn.permission_profile = PermissionProfile::from_runtime_permissions(
|
||||
@@ -1415,7 +1415,7 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot()
|
||||
.await;
|
||||
|
||||
let (mut session, mut turn) = crate::session::tests::make_session_and_context().await;
|
||||
session.conversation_id = fixed_guardian_parent_session_id();
|
||||
session.thread_id = fixed_guardian_parent_session_id();
|
||||
let temp_cwd = TempDir::new()?;
|
||||
let mut config = (*turn.config).clone();
|
||||
config.cwd = temp_cwd.abs();
|
||||
@@ -1464,7 +1464,7 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot()
|
||||
.as_deref()
|
||||
.expect("guardian thread id");
|
||||
assert_eq!(assessment.outcome, GuardianAssessmentOutcome::Allow);
|
||||
assert_ne!(guardian_thread_id, session.conversation_id.to_string());
|
||||
assert_ne!(guardian_thread_id, session.thread_id.to_string());
|
||||
ThreadId::from_string(guardian_thread_id).expect("guardian thread id should be a valid UUID");
|
||||
assert!(matches!(
|
||||
metadata.guardian_session_kind,
|
||||
@@ -1567,7 +1567,7 @@ async fn build_guardian_prompt_items_includes_parent_session_id() -> anyhow::Res
|
||||
assert!(
|
||||
prompt_text.contains(&format!(
|
||||
">>> TRANSCRIPT END\nReviewed Codex session id: {}\n",
|
||||
session.conversation_id
|
||||
session.thread_id
|
||||
)),
|
||||
"guardian prompt should expose the parent session id immediately after the transcript end"
|
||||
);
|
||||
|
||||
@@ -451,7 +451,7 @@ pub(crate) async fn run_legacy_after_agent_hook(
|
||||
triggered_at: chrono::Utc::now(),
|
||||
hook_event: codex_hooks::HookEvent::AfterAgent {
|
||||
event: codex_hooks::HookEventAfterAgent {
|
||||
thread_id: sess.conversation_id,
|
||||
thread_id: sess.thread_id,
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
input_messages,
|
||||
last_assistant_message,
|
||||
@@ -655,7 +655,7 @@ fn track_hook_completed_analytics(
|
||||
completed: &HookCompletedEvent,
|
||||
) {
|
||||
let (tracking, hook) =
|
||||
hook_run_analytics_payload(sess.conversation_id.to_string(), turn_context, completed);
|
||||
hook_run_analytics_payload(sess.thread_id.to_string(), turn_context, completed);
|
||||
sess.services
|
||||
.analytics_events_client
|
||||
.track_hook_run(tracking, hook);
|
||||
|
||||
@@ -468,8 +468,8 @@ fn mcp_tool_call_span(
|
||||
mcp.connector.name = fields.connector_name.unwrap_or(""),
|
||||
tool.name = fields.tool_name,
|
||||
tool.call_id = fields.call_id,
|
||||
conversation.id = %session.conversation_id,
|
||||
session.id = %session.conversation_id,
|
||||
conversation.id = %session.thread_id,
|
||||
session.id = %session.thread_id,
|
||||
turn.id = turn_context.sub_id.as_str(),
|
||||
server.address = Empty,
|
||||
server.port = Empty,
|
||||
@@ -554,8 +554,7 @@ async fn execute_mcp_tool_call(
|
||||
metadata: Option<&McpToolApprovalMetadata>,
|
||||
request_meta: Option<JsonValue>,
|
||||
) -> Result<CallToolResult, String> {
|
||||
let request_meta =
|
||||
with_mcp_tool_call_thread_id_meta(request_meta, &sess.conversation_id.to_string());
|
||||
let request_meta = with_mcp_tool_call_thread_id_meta(request_meta, &sess.thread_id.to_string());
|
||||
let request_meta = augment_mcp_tool_request_meta_with_sandbox_state(
|
||||
sess,
|
||||
turn_context,
|
||||
@@ -645,7 +644,7 @@ async fn maybe_request_codex_apps_auth_elicitation(
|
||||
|
||||
let request_id = rmcp::model::RequestId::String(plan.elicitation.elicitation_id.clone().into());
|
||||
let params = McpServerElicitationRequestParams {
|
||||
thread_id: sess.conversation_id.to_string(),
|
||||
thread_id: sess.thread_id.to_string(),
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
request: McpServerElicitationRequest::Url {
|
||||
@@ -766,7 +765,7 @@ async fn maybe_mark_thread_memory_mode_polluted(
|
||||
}
|
||||
state_db::mark_thread_memory_mode_polluted(
|
||||
sess.services.state_db.as_deref(),
|
||||
sess.conversation_id,
|
||||
sess.thread_id,
|
||||
"mcp_tool_call",
|
||||
)
|
||||
.await;
|
||||
@@ -945,7 +944,7 @@ async fn maybe_track_codex_app_used(
|
||||
|
||||
let tracking = build_track_events_context(
|
||||
turn_context.model_info.slug.clone(),
|
||||
sess.conversation_id.to_string(),
|
||||
sess.thread_id.to_string(),
|
||||
turn_context.sub_id.clone(),
|
||||
);
|
||||
sess.services.analytics_events_client.track_app_used(
|
||||
@@ -1615,7 +1614,7 @@ fn build_mcp_tool_approval_elicitation_request(
|
||||
.unwrap_or_else(|| request.question.question.clone());
|
||||
|
||||
McpServerElicitationRequestParams {
|
||||
thread_id: sess.conversation_id.to_string(),
|
||||
thread_id: sess.thread_id.to_string(),
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
server_name: request.server.to_string(),
|
||||
request: McpServerElicitationRequest::Form {
|
||||
|
||||
@@ -138,7 +138,7 @@ async fn execute_mcp_tool_call_records_replayable_correlation() -> anyhow::Resul
|
||||
.rollout_thread_trace
|
||||
.start_tool_dispatch_trace(|| {
|
||||
Some(ToolDispatchInvocation {
|
||||
thread_id: session.conversation_id.to_string(),
|
||||
thread_id: session.thread_id.to_string(),
|
||||
codex_turn_id: turn_context.sub_id.clone(),
|
||||
tool_call_id: "mcp-call".to_string(),
|
||||
tool_name: "search".to_string(),
|
||||
@@ -280,7 +280,7 @@ fn attach_trace_bundle(
|
||||
codex_rollout_trace::ThreadTraceContext::start_root_in_root_for_test(
|
||||
root,
|
||||
ThreadStartedTraceMetadata {
|
||||
thread_id: session.conversation_id.to_string(),
|
||||
thread_id: session.thread_id.to_string(),
|
||||
agent_path: "/root".to_string(),
|
||||
task_name: None,
|
||||
nickname: None,
|
||||
@@ -646,7 +646,7 @@ async fn approval_elicitation_request_uses_message_override_and_preserves_tool_p
|
||||
assert_eq!(
|
||||
request,
|
||||
McpServerElicitationRequestParams {
|
||||
thread_id: session.conversation_id.to_string(),
|
||||
thread_id: session.thread_id.to_string(),
|
||||
turn_id: Some(turn_context.sub_id),
|
||||
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
request: McpServerElicitationRequest::Form {
|
||||
|
||||
@@ -716,7 +716,7 @@ pub(crate) async fn build_realtime_session_config(
|
||||
Ok(RealtimeSessionConfig {
|
||||
instructions: prompt,
|
||||
model,
|
||||
session_id: Some(realtime_session_id.unwrap_or_else(|| sess.conversation_id.to_string())),
|
||||
session_id: Some(realtime_session_id.unwrap_or_else(|| sess.thread_id.to_string())),
|
||||
event_parser,
|
||||
session_mode,
|
||||
output_modality,
|
||||
|
||||
@@ -654,7 +654,7 @@ impl Codex {
|
||||
error!("Failed to create session: {e:#}");
|
||||
map_session_init_error(&e, &config.codex_home)
|
||||
})?;
|
||||
let thread_id = session.conversation_id;
|
||||
let thread_id = session.thread_id;
|
||||
|
||||
// This task will run until Op::Shutdown is received.
|
||||
let session_for_loop = Arc::clone(&session);
|
||||
@@ -1369,7 +1369,7 @@ impl Session {
|
||||
|
||||
ShellSnapshot::refresh_snapshot(
|
||||
codex_home.clone(),
|
||||
self.conversation_id,
|
||||
self.thread_id,
|
||||
next_cwd.clone(),
|
||||
self.services.user_shell.as_ref().clone(),
|
||||
self.services.shell_snapshot_tx.clone(),
|
||||
@@ -1640,7 +1640,7 @@ impl Session {
|
||||
self.services
|
||||
.analytics_events_client
|
||||
.track_turn_codex_error(TurnCodexErrorFact::from_codex_err(
|
||||
self.conversation_id.to_string(),
|
||||
self.thread_id.to_string(),
|
||||
turn_context.sub_id.clone(),
|
||||
error,
|
||||
));
|
||||
@@ -1819,7 +1819,7 @@ impl Session {
|
||||
self.send_event(
|
||||
turn_context,
|
||||
EventMsg::ItemStarted(ItemStartedEvent {
|
||||
thread_id: self.conversation_id,
|
||||
thread_id: self.thread_id,
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
item: item.clone(),
|
||||
started_at_ms: now_unix_timestamp_ms(),
|
||||
@@ -1837,7 +1837,7 @@ impl Session {
|
||||
self.send_event(
|
||||
turn_context,
|
||||
EventMsg::ItemCompleted(ItemCompletedEvent {
|
||||
thread_id: self.conversation_id,
|
||||
thread_id: self.thread_id,
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
item,
|
||||
completed_at_ms: now_unix_timestamp_ms(),
|
||||
@@ -2900,7 +2900,7 @@ impl Session {
|
||||
let subagents = self
|
||||
.services
|
||||
.agent_control
|
||||
.format_environment_context_subagents(self.conversation_id)
|
||||
.format_environment_context_subagents(self.thread_id)
|
||||
.await;
|
||||
contextual_user_sections.push(
|
||||
crate::context::EnvironmentContext::from_turn_context(turn_context, shell.as_ref())
|
||||
|
||||
@@ -19,7 +19,7 @@ use tokio::sync::Semaphore;
|
||||
///
|
||||
/// A session has at most 1 running task at a time, and can be interrupted by user input.
|
||||
pub(crate) struct Session {
|
||||
pub(crate) conversation_id: ThreadId,
|
||||
pub(crate) thread_id: ThreadId,
|
||||
pub(crate) installation_id: String,
|
||||
pub(super) tx_event: Sender<Event>,
|
||||
pub(super) agent_status: watch::Sender<AgentStatus>,
|
||||
@@ -465,7 +465,7 @@ async fn warm_plugins_and_skills_for_session_init(
|
||||
impl Session {
|
||||
/// Returns the concrete identity for this thread.
|
||||
pub(crate) fn thread_id(&self) -> ThreadId {
|
||||
self.conversation_id
|
||||
self.thread_id
|
||||
}
|
||||
|
||||
/// Returns the identity shared by the root thread and all descendant threads.
|
||||
@@ -1055,7 +1055,7 @@ impl Session {
|
||||
watch::channel(false);
|
||||
|
||||
let sess = Arc::new(Session {
|
||||
conversation_id: thread_id,
|
||||
thread_id,
|
||||
installation_id,
|
||||
tx_event: tx_event.clone(),
|
||||
agent_status,
|
||||
|
||||
@@ -344,7 +344,7 @@ async fn request_mcp_server_elicitation_auto_accepts_when_auto_deny_is_enabled()
|
||||
turn_context.as_ref(),
|
||||
RequestId::String("request-1".into()),
|
||||
McpServerElicitationRequestParams {
|
||||
thread_id: session.conversation_id.to_string(),
|
||||
thread_id: session.thread_id.to_string(),
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
server_name: "codex_apps".to_string(),
|
||||
request: McpServerElicitationRequest::Form {
|
||||
@@ -1333,7 +1333,7 @@ async fn reload_user_config_layer_refreshes_hooks() -> anyhow::Result<()> {
|
||||
}))?;
|
||||
|
||||
let request = codex_hooks::SessionStartRequest {
|
||||
session_id: session.conversation_id,
|
||||
session_id: session.thread_id,
|
||||
cwd: session.get_config().await.cwd.clone(),
|
||||
transcript_path: None,
|
||||
model: "gpt-5.2".to_string(),
|
||||
@@ -1440,7 +1440,7 @@ async fn refresh_runtime_config_refreshes_hooks() -> anyhow::Result<()> {
|
||||
std::fs::write(&config_toml_path, toml::to_string(&trusted_user_config)?)?;
|
||||
|
||||
let request = codex_hooks::SessionStartRequest {
|
||||
session_id: session.conversation_id,
|
||||
session_id: session.thread_id,
|
||||
cwd: session.get_config().await.cwd.clone(),
|
||||
transcript_path: None,
|
||||
model: "gpt-5.2".to_string(),
|
||||
@@ -1999,7 +1999,7 @@ async fn record_token_usage_info_notifies_extension_contributors() {
|
||||
let expected = vec![
|
||||
RecordedTokenUsage {
|
||||
session_level_id: session.session_id().to_string(),
|
||||
thread_level_id: session.conversation_id.to_string(),
|
||||
thread_level_id: session.thread_id.to_string(),
|
||||
turn_level_id: turn_context.sub_id.clone(),
|
||||
token_usage: TokenUsageInfo {
|
||||
total_token_usage: first_usage.clone(),
|
||||
@@ -2011,7 +2011,7 @@ async fn record_token_usage_info_notifies_extension_contributors() {
|
||||
},
|
||||
RecordedTokenUsage {
|
||||
session_level_id: session.session_id().to_string(),
|
||||
thread_level_id: session.conversation_id.to_string(),
|
||||
thread_level_id: session.thread_id.to_string(),
|
||||
turn_level_id: turn_context.sub_id.clone(),
|
||||
token_usage: TokenUsageInfo {
|
||||
total_token_usage: expected_total_usage,
|
||||
@@ -2100,7 +2100,7 @@ async fn turn_start_lifecycle_exposes_turn_metadata_and_token_baseline() {
|
||||
|
||||
let expected = RecordedTurnStart {
|
||||
session_level_id: session.session_id().to_string(),
|
||||
thread_level_id: session.conversation_id.to_string(),
|
||||
thread_level_id: session.thread_id.to_string(),
|
||||
turn_level_id: turn_context.sub_id.clone(),
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
collaboration_mode: turn_context.collaboration_mode.clone(),
|
||||
@@ -2188,7 +2188,7 @@ async fn turn_error_lifecycle_exposes_error_and_stores() {
|
||||
|
||||
let expected = RecordedTurnError {
|
||||
session_level_id: session.session_id().to_string(),
|
||||
thread_level_id: session.conversation_id.to_string(),
|
||||
thread_level_id: session.thread_id.to_string(),
|
||||
turn_level_id: turn_context.sub_id.clone(),
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
error: CodexErrorInfo::UsageLimitExceeded,
|
||||
@@ -3539,7 +3539,7 @@ async fn attach_thread_persistence(session: &mut Session) -> PathBuf {
|
||||
let live_thread = LiveThread::create(
|
||||
Arc::clone(&session.services.thread_store),
|
||||
CreateThreadParams {
|
||||
thread_id: session.conversation_id,
|
||||
thread_id: session.thread_id,
|
||||
forked_from_id: None,
|
||||
parent_thread_id: None,
|
||||
source: SessionSource::Exec,
|
||||
@@ -4820,7 +4820,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
);
|
||||
|
||||
let session = Session {
|
||||
conversation_id: thread_id,
|
||||
thread_id,
|
||||
installation_id: "11111111-1111-4111-8111-111111111111".to_string(),
|
||||
tx_event,
|
||||
agent_status: agent_status_tx,
|
||||
@@ -6282,7 +6282,7 @@ async fn shutdown_complete_does_not_append_to_thread_store_after_shutdown() {
|
||||
let live_thread = LiveThread::create(
|
||||
Arc::clone(&thread_store),
|
||||
CreateThreadParams {
|
||||
thread_id: session.conversation_id,
|
||||
thread_id: session.thread_id,
|
||||
forked_from_id: None,
|
||||
parent_thread_id: None,
|
||||
source: SessionSource::Exec,
|
||||
@@ -6347,7 +6347,7 @@ async fn submission_loop_channel_close_emits_thread_stop_lifecycle() {
|
||||
let mut builder = codex_extension_api::ExtensionRegistryBuilder::<crate::config::Config>::new();
|
||||
builder.thread_lifecycle_contributor(Arc::new(ThreadStopRecorder {
|
||||
calls: Arc::clone(&calls),
|
||||
expected_thread_id: session.conversation_id,
|
||||
expected_thread_id: session.thread_id,
|
||||
}));
|
||||
session.services.extensions = Arc::new(builder.build());
|
||||
session
|
||||
@@ -6409,7 +6409,7 @@ async fn submission_loop_channel_close_aborts_active_turn_before_thread_stop_lif
|
||||
let calls = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let recorder = Arc::new(LifecycleRecorder {
|
||||
calls: Arc::clone(&calls),
|
||||
expected_thread_id: session.conversation_id,
|
||||
expected_thread_id: session.thread_id,
|
||||
expected_turn_id: turn_context.sub_id.clone(),
|
||||
});
|
||||
let mut builder = codex_extension_api::ExtensionRegistryBuilder::<crate::config::Config>::new();
|
||||
@@ -6910,7 +6910,7 @@ where
|
||||
));
|
||||
|
||||
let session = Arc::new(Session {
|
||||
conversation_id: thread_id,
|
||||
thread_id,
|
||||
installation_id: "11111111-1111-4111-8111-111111111111".to_string(),
|
||||
tx_event,
|
||||
agent_status: agent_status_tx,
|
||||
@@ -6976,7 +6976,7 @@ async fn upsert_goal_test_thread(session: &Session) {
|
||||
.state_db()
|
||||
.expect("goal test session should have a state db");
|
||||
let mut builder = codex_state::ThreadMetadataBuilder::new(
|
||||
session.conversation_id,
|
||||
session.thread_id,
|
||||
config
|
||||
.codex_home
|
||||
.join("goal-test-rollout.jsonl")
|
||||
@@ -7797,7 +7797,7 @@ async fn handle_output_item_done_records_image_save_history_message() {
|
||||
let call_id = "ig_history_records_message";
|
||||
let expected_saved_path = crate::stream_events_utils::image_generation_artifact_path(
|
||||
&turn_context.config.codex_home,
|
||||
&session.conversation_id.to_string(),
|
||||
&session.thread_id.to_string(),
|
||||
call_id,
|
||||
);
|
||||
let _ = std::fs::remove_file(&expected_saved_path);
|
||||
@@ -7824,7 +7824,7 @@ async fn handle_output_item_done_records_image_save_history_message() {
|
||||
let history = session.clone_history().await;
|
||||
let image_output_path = crate::stream_events_utils::image_generation_artifact_path(
|
||||
&turn_context.config.codex_home,
|
||||
&session.conversation_id.to_string(),
|
||||
&session.thread_id.to_string(),
|
||||
"<image_id>",
|
||||
);
|
||||
let image_output_dir = image_output_path
|
||||
@@ -7852,7 +7852,7 @@ async fn handle_output_item_done_skips_image_save_message_when_save_fails() {
|
||||
let call_id = "ig_history_no_message";
|
||||
let expected_saved_path = crate::stream_events_utils::image_generation_artifact_path(
|
||||
&turn_context.config.codex_home,
|
||||
&session.conversation_id.to_string(),
|
||||
&session.thread_id.to_string(),
|
||||
call_id,
|
||||
);
|
||||
let _ = std::fs::remove_file(&expected_saved_path);
|
||||
@@ -8698,7 +8698,7 @@ async fn task_finish_emits_thread_idle_lifecycle_after_active_turn_clears() {
|
||||
builder.thread_lifecycle_contributor(Arc::new(ThreadIdleRecorder {
|
||||
calls: Arc::clone(&calls),
|
||||
idle_tx,
|
||||
expected_thread_id: session.conversation_id,
|
||||
expected_thread_id: session.thread_id,
|
||||
}));
|
||||
session.services.extensions = Arc::new(builder.build());
|
||||
|
||||
@@ -9321,7 +9321,7 @@ async fn create_thread_goal_fills_empty_thread_preview() -> anyhow::Result<()> {
|
||||
.iter()
|
||||
.map(|thread| thread.id)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(vec![sess.conversation_id], ids);
|
||||
assert_eq!(vec![sess.thread_id], ids);
|
||||
assert_eq!(
|
||||
Some("Keep improving the benchmark"),
|
||||
page.items[0].preview.as_deref()
|
||||
@@ -9401,7 +9401,7 @@ async fn budget_limited_accounting_steers_active_turn_without_aborting() -> anyh
|
||||
let state_db = goal_test_state_db(sess.as_ref()).await?;
|
||||
let goal = state_db
|
||||
.thread_goals()
|
||||
.get_thread_goal(sess.conversation_id)
|
||||
.get_thread_goal(sess.thread_id)
|
||||
.await?
|
||||
.expect("goal should remain persisted after accounting");
|
||||
assert_eq!(codex_state::ThreadGoalStatus::BudgetLimited, goal.status);
|
||||
@@ -9425,7 +9425,7 @@ async fn budget_limited_accounting_steers_active_turn_without_aborting() -> anyh
|
||||
|
||||
let goal = state_db
|
||||
.thread_goals()
|
||||
.get_thread_goal(sess.conversation_id)
|
||||
.get_thread_goal(sess.thread_id)
|
||||
.await?
|
||||
.expect("goal should remain persisted after follow-up accounting");
|
||||
assert_eq!(codex_state::ThreadGoalStatus::BudgetLimited, goal.status);
|
||||
@@ -9473,7 +9473,7 @@ async fn usage_limit_runtime_stops_active_goal_and_prevents_idle_continuation()
|
||||
let state_db = goal_test_state_db(sess.as_ref()).await?;
|
||||
let goal = state_db
|
||||
.thread_goals()
|
||||
.get_thread_goal(sess.conversation_id)
|
||||
.get_thread_goal(sess.thread_id)
|
||||
.await?
|
||||
.expect("goal should remain persisted after usage limiting");
|
||||
assert_eq!(codex_state::ThreadGoalStatus::UsageLimited, goal.status);
|
||||
@@ -9516,7 +9516,7 @@ async fn external_goal_mutation_accounts_active_turn_before_status_change() -> a
|
||||
let state_db = goal_test_state_db(sess.as_ref()).await?;
|
||||
let goal = state_db
|
||||
.thread_goals()
|
||||
.get_thread_goal(sess.conversation_id)
|
||||
.get_thread_goal(sess.thread_id)
|
||||
.await?
|
||||
.expect("goal should remain persisted");
|
||||
assert_eq!(70, goal.tokens_used);
|
||||
@@ -9526,7 +9526,7 @@ async fn external_goal_mutation_accounts_active_turn_before_status_change() -> a
|
||||
let updated_goal = state_db
|
||||
.thread_goals()
|
||||
.update_thread_goal(
|
||||
sess.conversation_id,
|
||||
sess.thread_id,
|
||||
codex_state::GoalUpdate {
|
||||
objective: None,
|
||||
status: Some(codex_state::ThreadGoalStatus::Complete),
|
||||
@@ -9547,7 +9547,7 @@ async fn external_goal_mutation_accounts_active_turn_before_status_change() -> a
|
||||
assert!(sess.active_turn.lock().await.is_some());
|
||||
let goal = state_db
|
||||
.thread_goals()
|
||||
.get_thread_goal(sess.conversation_id)
|
||||
.get_thread_goal(sess.thread_id)
|
||||
.await?
|
||||
.expect("goal should remain persisted");
|
||||
assert_eq!(codex_state::ThreadGoalStatus::Complete, goal.status);
|
||||
@@ -9575,7 +9575,7 @@ async fn external_objective_change_steers_active_turn() -> anyhow::Result<()> {
|
||||
let old_goal = state_db
|
||||
.thread_goals()
|
||||
.replace_thread_goal(
|
||||
sess.conversation_id,
|
||||
sess.thread_id,
|
||||
"Keep improving the benchmark",
|
||||
codex_state::ThreadGoalStatus::Active,
|
||||
/*token_budget*/ Some(10_000),
|
||||
@@ -9584,7 +9584,7 @@ async fn external_objective_change_steers_active_turn() -> anyhow::Result<()> {
|
||||
let new_goal = state_db
|
||||
.thread_goals()
|
||||
.replace_thread_goal(
|
||||
sess.conversation_id,
|
||||
sess.thread_id,
|
||||
"Write a concise benchmark summary",
|
||||
codex_state::ThreadGoalStatus::Active,
|
||||
/*token_budget*/ Some(10_000),
|
||||
@@ -9642,7 +9642,7 @@ async fn external_active_goal_set_marks_current_turn_for_accounting() -> anyhow:
|
||||
let goal = state_db
|
||||
.thread_goals()
|
||||
.replace_thread_goal(
|
||||
sess.conversation_id,
|
||||
sess.thread_id,
|
||||
"Keep improving the benchmark",
|
||||
codex_state::ThreadGoalStatus::Active,
|
||||
/*token_budget*/ None,
|
||||
@@ -9675,7 +9675,7 @@ async fn external_active_goal_set_marks_current_turn_for_accounting() -> anyhow:
|
||||
|
||||
let goal = state_db
|
||||
.thread_goals()
|
||||
.get_thread_goal(sess.conversation_id)
|
||||
.get_thread_goal(sess.thread_id)
|
||||
.await?
|
||||
.expect("goal should remain persisted");
|
||||
assert_eq!(codex_state::ThreadGoalStatus::Active, goal.status);
|
||||
|
||||
@@ -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;
|
||||
@@ -467,7 +469,7 @@ async fn build_skills_and_plugins(
|
||||
.collect::<Vec<_>>();
|
||||
let tracking = build_track_events_context(
|
||||
turn_context.model_info.slug.clone(),
|
||||
sess.conversation_id.to_string(),
|
||||
sess.thread_id.to_string(),
|
||||
turn_context.sub_id.clone(),
|
||||
);
|
||||
let loaded_plugins = sess
|
||||
@@ -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,61 @@ 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_fragments = 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_fragments
|
||||
.into_iter()
|
||||
.map(ContextualUserFragment::into_boxed_response_item),
|
||||
);
|
||||
}
|
||||
|
||||
Some(items)
|
||||
}
|
||||
|
||||
async fn track_turn_resolved_config_analytics(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
@@ -608,7 +665,7 @@ async fn track_turn_resolved_config_analytics(
|
||||
.analytics_events_client
|
||||
.track_turn_resolved_config(TurnResolvedConfigFact {
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
thread_id: sess.conversation_id.to_string(),
|
||||
thread_id: sess.thread_id.to_string(),
|
||||
num_input_images: input
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
@@ -1249,7 +1306,7 @@ impl ProposedPlanItemState {
|
||||
return;
|
||||
}
|
||||
let event = PlanDeltaEvent {
|
||||
thread_id: sess.conversation_id.to_string(),
|
||||
thread_id: sess.thread_id.to_string(),
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
item_id: self.item_id.clone(),
|
||||
delta: delta.to_string(),
|
||||
@@ -1425,7 +1482,7 @@ async fn handle_plan_segments(
|
||||
maybe_emit_pending_agent_message_start(sess, turn_context, state, item_id).await;
|
||||
|
||||
let event = AgentMessageContentDeltaEvent {
|
||||
thread_id: sess.conversation_id.to_string(),
|
||||
thread_id: sess.thread_id.to_string(),
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
item_id: item_id.to_string(),
|
||||
delta,
|
||||
@@ -1479,7 +1536,7 @@ async fn emit_streamed_assistant_text_delta(
|
||||
return;
|
||||
}
|
||||
let event = AgentMessageContentDeltaEvent {
|
||||
thread_id: sess.conversation_id.to_string(),
|
||||
thread_id: sess.thread_id.to_string(),
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
item_id: item_id.to_string(),
|
||||
delta: parsed.visible_text,
|
||||
@@ -2045,7 +2102,7 @@ async fn try_run_sampling_request(
|
||||
.await;
|
||||
} else {
|
||||
let event = AgentMessageContentDeltaEvent {
|
||||
thread_id: sess.conversation_id.to_string(),
|
||||
thread_id: sess.thread_id.to_string(),
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
item_id,
|
||||
delta,
|
||||
@@ -2084,7 +2141,7 @@ async fn try_run_sampling_request(
|
||||
continue;
|
||||
}
|
||||
let event = ReasoningContentDeltaEvent {
|
||||
thread_id: sess.conversation_id.to_string(),
|
||||
thread_id: sess.thread_id.to_string(),
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
item_id: active.id(),
|
||||
delta,
|
||||
@@ -2120,7 +2177,7 @@ async fn try_run_sampling_request(
|
||||
continue;
|
||||
}
|
||||
let event = ReasoningRawContentDeltaEvent {
|
||||
thread_id: sess.conversation_id.to_string(),
|
||||
thread_id: sess.thread_id.to_string(),
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
item_id: active.id(),
|
||||
delta,
|
||||
|
||||
@@ -100,7 +100,7 @@ pub(crate) async fn maybe_emit_implicit_skill_invocation(
|
||||
.track_skill_invocations(
|
||||
build_track_events_context(
|
||||
turn_context.model_info.slug.clone(),
|
||||
sess.conversation_id.to_string(),
|
||||
sess.thread_id.to_string(),
|
||||
turn_context.sub_id.clone(),
|
||||
),
|
||||
vec![invocation],
|
||||
|
||||
@@ -132,7 +132,7 @@ pub(crate) async fn persist_image_generation_item(
|
||||
image_item: &mut ImageGenerationItem,
|
||||
) -> Option<AbsolutePathBuf> {
|
||||
image_item.saved_path = None;
|
||||
let session_id = sess.conversation_id.to_string();
|
||||
let session_id = sess.thread_id.to_string();
|
||||
match save_image_generation_result(
|
||||
&turn_context.config.codex_home,
|
||||
&session_id,
|
||||
@@ -172,7 +172,7 @@ async fn record_image_generation_instructions(
|
||||
if image_item.saved_path.is_none() {
|
||||
return;
|
||||
}
|
||||
let session_id = sess.conversation_id.to_string();
|
||||
let session_id = sess.thread_id.to_string();
|
||||
let image_output_path =
|
||||
image_generation_artifact_path(&turn_context.config.codex_home, &session_id, "<image_id>");
|
||||
let image_output_dir = image_output_path
|
||||
@@ -263,7 +263,7 @@ pub(crate) async fn mark_thread_memory_mode_polluted_if_external_context(
|
||||
}
|
||||
state_db::mark_thread_memory_mode_polluted(
|
||||
sess.services.state_db.as_deref(),
|
||||
sess.conversation_id,
|
||||
sess.thread_id,
|
||||
"record_completed_response_item",
|
||||
)
|
||||
.await;
|
||||
@@ -422,7 +422,7 @@ pub(crate) async fn handle_output_item_done(
|
||||
|
||||
let payload_preview = call.payload.log_payload().into_owned();
|
||||
tracing::info!(
|
||||
thread_id = %ctx.sess.conversation_id,
|
||||
thread_id = %ctx.sess.thread_id,
|
||||
"ToolCall: {} {}",
|
||||
call.tool_name,
|
||||
payload_preview
|
||||
|
||||
@@ -383,7 +383,7 @@ impl Session {
|
||||
let task_span = info_span!(
|
||||
"turn",
|
||||
otel.name = span_name,
|
||||
thread.id = %self.conversation_id,
|
||||
thread.id = %self.thread_id,
|
||||
turn.id = %turn_context.sub_id,
|
||||
model = %turn_context.model_info.slug,
|
||||
codex.turn.reasoning_effort = %reasoning_effort,
|
||||
@@ -708,7 +708,7 @@ impl Session {
|
||||
.analytics_events_client
|
||||
.track_turn_token_usage(TurnTokenUsageFact {
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
thread_id: self.conversation_id.to_string(),
|
||||
thread_id: self.thread_id.to_string(),
|
||||
token_usage: turn_token_usage.clone(),
|
||||
});
|
||||
self.services.session_telemetry.histogram(
|
||||
|
||||
@@ -126,7 +126,7 @@ pub(crate) async fn execute_user_shell_command(
|
||||
let display_command = session_shell.derive_exec_args(&command, use_login_shell);
|
||||
let mut exec_env_map = create_env(
|
||||
&turn_context.shell_environment_policy,
|
||||
Some(session.conversation_id),
|
||||
Some(session.thread_id),
|
||||
);
|
||||
if exec_env_map.contains_key(PROXY_ACTIVE_ENV_KEY) {
|
||||
strip_managed_proxy_env(&mut exec_env_map);
|
||||
|
||||
@@ -211,7 +211,7 @@ async fn run_agent_job_loop(
|
||||
"agent_job:{job_id}"
|
||||
)))),
|
||||
SpawnAgentOptions {
|
||||
parent_thread_id: Some(session.conversation_id),
|
||||
parent_thread_id: Some(session.thread_id),
|
||||
environments: Some(turn.environments.to_selections()),
|
||||
..Default::default()
|
||||
},
|
||||
|
||||
@@ -61,7 +61,7 @@ pub async fn handle(
|
||||
));
|
||||
}
|
||||
let db = required_state_db(&session)?;
|
||||
let reporting_thread_id = session.conversation_id.to_string();
|
||||
let reporting_thread_id = session.thread_id.to_string();
|
||||
let accepted = db
|
||||
.report_agent_job_item_result(
|
||||
args.job_id.as_str(),
|
||||
|
||||
@@ -486,7 +486,7 @@ mod tests {
|
||||
let (session, turn, rx) = crate::session::tests::make_session_and_context_with_rx().await;
|
||||
let expected_path = crate::stream_events_utils::image_generation_artifact_path(
|
||||
&turn.config.codex_home,
|
||||
&session.conversation_id.to_string(),
|
||||
&session.thread_id.to_string(),
|
||||
"call-image",
|
||||
);
|
||||
let invocation = ToolInvocation {
|
||||
|
||||
@@ -53,7 +53,7 @@ async fn handle_close_agent(
|
||||
CollabCloseBeginEvent {
|
||||
call_id: call_id.clone(),
|
||||
started_at_ms: now_unix_timestamp_ms(),
|
||||
sender_thread_id: session.conversation_id,
|
||||
sender_thread_id: session.thread_id,
|
||||
receiver_thread_id: agent_id,
|
||||
}
|
||||
.into(),
|
||||
@@ -77,7 +77,7 @@ async fn handle_close_agent(
|
||||
CollabCloseEndEvent {
|
||||
call_id: call_id.clone(),
|
||||
completed_at_ms: now_unix_timestamp_ms(),
|
||||
sender_thread_id: session.conversation_id,
|
||||
sender_thread_id: session.thread_id(),
|
||||
receiver_thread_id: agent_id,
|
||||
receiver_agent_nickname: receiver_agent.agent_nickname.clone(),
|
||||
receiver_agent_role: receiver_agent.agent_role.clone(),
|
||||
@@ -99,7 +99,7 @@ async fn handle_close_agent(
|
||||
CollabCloseEndEvent {
|
||||
call_id,
|
||||
completed_at_ms: now_unix_timestamp_ms(),
|
||||
sender_thread_id: session.conversation_id,
|
||||
sender_thread_id: session.thread_id,
|
||||
receiver_thread_id: agent_id,
|
||||
receiver_agent_nickname: receiver_agent.agent_nickname,
|
||||
receiver_agent_role: receiver_agent.agent_role,
|
||||
|
||||
@@ -66,7 +66,7 @@ async fn handle_resume_agent(
|
||||
CollabResumeBeginEvent {
|
||||
call_id: call_id.clone(),
|
||||
started_at_ms: now_unix_timestamp_ms(),
|
||||
sender_thread_id: session.conversation_id,
|
||||
sender_thread_id: session.thread_id,
|
||||
receiver_thread_id,
|
||||
receiver_agent_nickname: receiver_agent.agent_nickname.clone(),
|
||||
receiver_agent_role: receiver_agent.agent_role.clone(),
|
||||
@@ -122,7 +122,7 @@ async fn handle_resume_agent(
|
||||
CollabResumeEndEvent {
|
||||
call_id,
|
||||
completed_at_ms: now_unix_timestamp_ms(),
|
||||
sender_thread_id: session.conversation_id,
|
||||
sender_thread_id: session.thread_id(),
|
||||
receiver_thread_id,
|
||||
receiver_agent_nickname: receiver_agent.agent_nickname,
|
||||
receiver_agent_role: receiver_agent.agent_role,
|
||||
@@ -186,7 +186,7 @@ async fn try_resume_closed_agent(
|
||||
config,
|
||||
receiver_thread_id,
|
||||
thread_spawn_source(
|
||||
session.conversation_id,
|
||||
session.thread_id(),
|
||||
&turn.session_source,
|
||||
child_depth,
|
||||
/*agent_role*/ None,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user