Merge 5d531b0a3e into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2026-06-03 09:19:38 -07:00
committed by GitHub
145 changed files with 4077 additions and 952 deletions
+32 -14
View File
@@ -38,24 +38,50 @@ common:windows --test_env=WINDIR
common --test_env=RUST_MIN_STACK=8388608 # 8 MiB
common --test_output=errors
common --bes_results_url=https://app.buildbuddy.io/invocation/
common --bes_backend=grpcs://remote.buildbuddy.io
common --remote_cache=grpcs://remote.buildbuddy.io
common --remote_download_toplevel
common --nobuild_runfile_links
# These settings tune BuildBuddy/RBE behavior but do not contact a remote
# service unless a `buildbuddy-*` configuration below supplies an endpoint.
common --remote_download_toplevel
common --remote_timeout=3600
common --noexperimental_throttle_remote_action_building
common --experimental_remote_execution_keepalive
common --grpc_keepalive_time=30s
common --experimental_remote_downloader=grpcs://remote.buildbuddy.io
# Opt-in remote configurations selected by
# `.github/scripts/run_bazel_with_buildbuddy.py`. Plain Bazel commands do not
# contact BuildBuddy unless a user selects one of these configurations.
# Use the generic host for cache, BES, and downloads without remote execution.
common:buildbuddy-generic --bes_backend=grpcs://remote.buildbuddy.io
common:buildbuddy-generic --bes_results_url=https://app.buildbuddy.io/invocation/
common:buildbuddy-generic --remote_cache=grpcs://remote.buildbuddy.io
common:buildbuddy-generic --experimental_remote_downloader=grpcs://remote.buildbuddy.io
# Add remote execution on the generic host.
common:buildbuddy-generic-rbe --config=buildbuddy-generic
common:buildbuddy-generic-rbe --config=remote
common:buildbuddy-generic-rbe --remote_executor=grpcs://remote.buildbuddy.io
# Use the OpenAI tenant for cache, BES, and downloads without remote execution.
common:buildbuddy-openai --bes_backend=grpcs://openai.buildbuddy.io
common:buildbuddy-openai --bes_results_url=https://openai.buildbuddy.io/invocation/
common:buildbuddy-openai --remote_cache=grpcs://openai.buildbuddy.io
common:buildbuddy-openai --experimental_remote_downloader=grpcs://openai.buildbuddy.io
# Add remote execution on the OpenAI tenant.
common:buildbuddy-openai-rbe --config=buildbuddy-openai
common:buildbuddy-openai-rbe --config=remote
common:buildbuddy-openai-rbe --remote_executor=grpcs://openai.buildbuddy.io
# This limits both in-flight executions and concurrent downloads. Even with high number
# of jobs execution will still be limited by CPU cores, so this just pays a bit of
# memory in exchange for higher download concurrency.
common --jobs=30
# Shared remote execution policy. The endpoint-bearing `buildbuddy-*-rbe`
# configurations include this group; CI configs override TestRunner below
# when tests must remain local on their runner.
common:remote --strategy=remote
common:remote --extra_execution_platforms=//:rbe
common:remote --remote_executor=grpcs://remote.buildbuddy.io
common:remote --jobs=800
# TODO(team): Evaluate if this actually helps, zbarsky is not sure, everything seems bottlenecked on `core` either way.
# Enable pipelined compilation since we are not bound by local CPU count.
@@ -146,15 +172,11 @@ common:ci-windows --repo_contents_cache=D:/a/.cache/bazel-repo-contents-cache
# Linux crossbuilds don't work until we untangle the libc constraint mess.
common:ci-linux --config=ci-bazel
common:ci-linux --build_metadata=TAG_os=linux
common:ci-linux --config=remote
common:ci-linux --strategy=remote
common:ci-linux --platforms=//:rbe
# On mac, we can run all the build actions remotely but test actions locally.
common:ci-macos --config=ci-bazel
common:ci-macos --build_metadata=TAG_os=macos
common:ci-macos --config=remote
common:ci-macos --strategy=remote
common:ci-macos --strategy=TestRunner=darwin-sandbox,local
# On Windows, use Linux remote execution for build actions but keep test actions
@@ -162,9 +184,7 @@ common:ci-macos --strategy=TestRunner=darwin-sandbox,local
# still run against Windows binaries.
common:ci-windows-cross --config=ci-windows
common:ci-windows-cross --build_metadata=TAG_windows_cross_compile=true
common:ci-windows-cross --config=remote
common:ci-windows-cross --host_platform=//:rbe
common:ci-windows-cross --strategy=remote
common:ci-windows-cross --strategy=TestRunner=local
common:ci-windows-cross --local_test_jobs=4
common:ci-windows-cross --test_env=RUST_TEST_THREADS=1
@@ -180,8 +200,6 @@ common:ci-windows-cross --extra_toolchains=//:windows_gnullvm_tests_on_msvc_host
common:ci-v8 --config=ci
common:ci-v8 --build_metadata=TAG_workflow=v8
common:ci-v8 --build_metadata=TAG_os=linux
common:ci-v8 --config=remote
common:ci-v8 --strategy=remote
# Source-built Bazel V8 artifacts use the in-process sandbox by default. This
# does not affect Cargo's default prebuilt rusty_v8 path.
@@ -1,119 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: archive-release-symbols-and-strip-binaries.sh \
--target <rust-target> \
--artifact-name <artifact-name> \
--release-dir <dir> \
--archive-dir <dir> \
--binaries "<space-delimited binary basenames>"
EOF
}
target=""
artifact_name=""
release_dir=""
archive_dir=""
binaries=""
while [[ $# -gt 0 ]]; do
case "$1" in
--target)
target="${2:?--target requires a value}"
shift 2
;;
--artifact-name)
artifact_name="${2:?--artifact-name requires a value}"
shift 2
;;
--release-dir)
release_dir="${2:?--release-dir requires a value}"
shift 2
;;
--archive-dir)
archive_dir="${2:?--archive-dir requires a value}"
shift 2
;;
--binaries)
binaries="${2:?--binaries requires a value}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unexpected argument: $1" >&2
usage >&2
exit 1
;;
esac
done
if [[ -z "$target" || -z "$artifact_name" || -z "$release_dir" || -z "$archive_dir" || -z "$binaries" ]]; then
usage >&2
exit 1
fi
symbols_root="${RUNNER_TEMP:-/tmp}/codex-symbols-${artifact_name}"
symbols_dir="${symbols_root}/codex-symbols-${artifact_name}"
archive_path="${archive_dir%/}/codex-symbols-${artifact_name}.tar.gz"
rm -rf "$symbols_root"
mkdir -p "$symbols_dir" "$archive_dir"
read -r -a binary_names <<< "$binaries"
case "$target" in
*apple-darwin)
for binary in "${binary_names[@]}"; do
binary_path="${release_dir%/}/${binary}"
dsym_path="${binary_path}.dSYM"
if [[ ! -f "$binary_path" ]]; then
echo "Binary $binary_path not found" >&2
exit 1
fi
if [[ ! -d "$dsym_path" ]]; then
echo "dSYM $dsym_path not found" >&2
exit 1
fi
cp -RL "$dsym_path" "${symbols_dir}/${binary}.dSYM"
strip -S -x "$binary_path"
done
;;
*linux*)
objcopy_bin="${OBJCOPY:-objcopy}"
strip_bin="${STRIP:-strip}"
for binary in "${binary_names[@]}"; do
binary_path="${release_dir%/}/${binary}"
debug_path="${symbols_dir}/${binary}.debug"
if [[ ! -f "$binary_path" ]]; then
echo "Binary $binary_path not found" >&2
exit 1
fi
"$objcopy_bin" --only-keep-debug "$binary_path" "$debug_path"
"$strip_bin" --strip-debug --strip-unneeded "$binary_path"
"$objcopy_bin" --add-gnu-debuglink="$debug_path" "$binary_path"
done
;;
*windows*)
for binary in "${binary_names[@]}"; do
pdb_path="${release_dir%/}/${binary}.pdb"
if [[ ! -f "$pdb_path" ]]; then
echo "PDB $pdb_path not found" >&2
exit 1
fi
cp "$pdb_path" "${symbols_dir}/${binary}.pdb"
done
;;
*)
echo "No symbols packaging support for target: $target" >&2
exit 1
;;
esac
rm -f "$archive_path"
tar -C "$symbols_root" -czf "$archive_path" "codex-symbols-${artifact_name}"
+45 -81
View File
@@ -53,11 +53,20 @@ fi
run_bazel() {
if [[ "${RUNNER_OS:-}" == "Windows" ]]; then
MSYS2_ARG_CONV_EXCL='*' bazel "$@"
MSYS2_ARG_CONV_EXCL='*' "$(dirname "${BASH_SOURCE[0]}")/run_bazel_with_buildbuddy.py" "$@"
return
fi
bazel "$@"
"$(dirname "${BASH_SOURCE[0]}")/run_bazel_with_buildbuddy.py" "$@"
}
run_bazel_with_startup_args() {
if (( ${#bazel_startup_args[@]} > 0 )); then
run_bazel "${bazel_startup_args[@]}" "$@"
return
fi
run_bazel "$@"
}
ci_config=ci-linux
@@ -77,23 +86,16 @@ esac
print_bazel_test_log_tails() {
local console_log="$1"
local testlogs_dir
local -a bazel_info_cmd=(bazel)
local -a bazel_info_args=(info)
if (( ${#bazel_startup_args[@]} > 0 )); then
bazel_info_cmd+=("${bazel_startup_args[@]}")
fi
# `bazel info` needs the same CI config as the failed test invocation so
# platform-specific output roots match. On Windows, omitting `ci-windows`
# would point at `local_windows-fastbuild` even when the test ran with the
# MSVC host platform under `local_windows_msvc-fastbuild`.
if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then
bazel_info_args+=(
"--config=${ci_config}"
"--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}"
)
# `bazel info` needs the same CI config as the failed test invocation so
# platform-specific output roots match. On Windows, omitting `ci-windows`
# would point at `local_windows-fastbuild` even when the test ran with the
# MSVC host platform under `local_windows_msvc-fastbuild`.
bazel_info_args+=("--config=${ci_config}")
fi
# Only pass flags that affect Bazel's output-root selection or repository
# lookup. Test/build-only flags such as execution logs or remote download
# mode can make `bazel info` fail, which would hide the real test log path.
@@ -105,7 +107,7 @@ print_bazel_test_log_tails() {
esac
done
testlogs_dir="$(run_bazel "${bazel_info_cmd[@]:1}" \
testlogs_dir="$(run_bazel_with_startup_args \
--noexperimental_remote_repo_contents_cache \
"${bazel_info_args[@]}" \
bazel-testlogs 2>/dev/null || echo bazel-testlogs)"
@@ -254,8 +256,9 @@ if [[ ${#bazel_args[@]} -eq 0 || ${#bazel_targets[@]} -eq 0 ]]; then
fi
if [[ "${RUNNER_OS:-}" == "Windows" && $windows_cross_compile -eq 1 && -z "${BUILDBUDDY_API_KEY:-}" ]]; then
# Fork PRs do not receive the BuildBuddy secret needed for the remote
# cross-compile config. Preserve the previous local Windows build shape.
# Windows cross-compilation depends on authenticated RBE. Preserve the local
# Windows build shape when credentials are unavailable.
ci_config=ci-windows
windows_msvc_host_platform=1
fi
@@ -297,9 +300,9 @@ if [[ "${RUNNER_OS:-}" == "Windows" && $windows_cross_compile -eq 1 && -n "${BUI
fi
if [[ "${RUNNER_OS:-}" == "Windows" && $windows_cross_compile -eq 1 && -z "${BUILDBUDDY_API_KEY:-}" ]]; then
# The Windows cross-compile config depends on remote execution. Fork PRs do
# not receive the BuildBuddy secret, so fall back to the existing local build
# shape and keep its lower concurrency cap.
# The Windows cross-compile config depends on authenticated remote
# execution. When credentials are unavailable, keep the local build shape
# and its lower concurrency cap.
post_config_bazel_args+=(--jobs=8)
fi
@@ -377,70 +380,31 @@ fi
bazel_console_log="$(mktemp)"
trap 'rm -f "$bazel_console_log"' EXIT
bazel_cmd=(bazel)
if (( ${#bazel_startup_args[@]} > 0 )); then
bazel_cmd+=("${bazel_startup_args[@]}")
fi
bazel_run_args=(
"${bazel_args[@]}"
)
if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then
echo "BuildBuddy API key is available; using remote Bazel configuration."
# Work around Bazel 9 remote repo contents cache / overlay materialization failures
# seen in CI (for example "is not a symlink" or permission errors while
# materializing external repos such as rules_perl). We still use BuildBuddy for
# remote execution/cache; this only disables the startup-level repo contents cache.
bazel_run_args=(
"${bazel_args[@]}"
"--config=${ci_config}"
"--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}"
)
if (( ${#post_config_bazel_args[@]} > 0 )); then
bazel_run_args+=("${post_config_bazel_args[@]}")
fi
set +e
run_bazel "${bazel_cmd[@]:1}" \
--noexperimental_remote_repo_contents_cache \
"${bazel_run_args[@]}" \
-- \
"${bazel_targets[@]}" \
2>&1 | tee "$bazel_console_log"
bazel_status=${PIPESTATUS[0]}
set -e
bazel_run_args+=("--config=${ci_config}")
else
echo "BuildBuddy API key is not available; using local Bazel configuration."
# Keep fork/community PRs on Bazel but disable remote services that are
# configured in .bazelrc and require auth.
#
# Flag docs:
# - Command-line reference: https://bazel.build/reference/command-line-reference
# - Remote caching overview: https://bazel.build/remote/caching
# - Remote execution overview: https://bazel.build/remote/rbe
# - Build Event Protocol overview: https://bazel.build/remote/bep
#
# --noexperimental_remote_repo_contents_cache:
# disable remote repo contents cache enabled in .bazelrc startup options.
# https://bazel.build/reference/command-line-reference#startup_options-flag--experimental_remote_repo_contents_cache
# --remote_cache= and --remote_executor=:
# clear remote cache/execution endpoints configured in .bazelrc.
# https://bazel.build/reference/command-line-reference#common_options-flag--remote_cache
# https://bazel.build/reference/command-line-reference#common_options-flag--remote_executor
bazel_run_args=(
"${bazel_args[@]}"
--remote_cache=
--remote_executor=
)
if (( ${#post_config_bazel_args[@]} > 0 )); then
bazel_run_args+=("${post_config_bazel_args[@]}")
fi
set +e
run_bazel "${bazel_cmd[@]:1}" \
--noexperimental_remote_repo_contents_cache \
"${bazel_run_args[@]}" \
-- \
"${bazel_targets[@]}" \
2>&1 | tee "$bazel_console_log"
bazel_status=${PIPESTATUS[0]}
set -e
fi
if (( ${#post_config_bazel_args[@]} > 0 )); then
bazel_run_args+=("${post_config_bazel_args[@]}")
fi
set +e
# Work around Bazel 9 remote repo contents cache / overlay materialization
# failures seen in CI (for example "is not a symlink" or permission errors
# while materializing external repos such as rules_perl). This only disables
# the startup-level repo contents cache; keyed runs still use BuildBuddy.
run_bazel_with_startup_args \
--noexperimental_remote_repo_contents_cache \
"${bazel_run_args[@]}" \
-- \
"${bazel_targets[@]}" \
2>&1 | tee "$bazel_console_log"
bazel_status=${PIPESTATUS[0]}
set -e
if [[ ${bazel_status:-0} -ne 0 ]]; then
if [[ $print_failed_bazel_action_summary -eq 1 ]]; then
+11 -45
View File
@@ -2,48 +2,17 @@
set -euo pipefail
# Run Bazel queries with the same CI startup settings as the main build/test
# invocation so target-discovery queries can reuse the same Bazel server.
# Run target-discovery queries with the same startup settings as the main
# build/test invocation so they can reuse the same Bazel server. Queries only
# enumerate labels, so they intentionally do not select CI or remote configs.
query_args=()
windows_cross_compile=0
while [[ $# -gt 0 ]]; do
case "$1" in
--windows-cross-compile)
windows_cross_compile=1
shift
;;
--)
shift
break
;;
*)
query_args+=("$1")
shift
;;
esac
done
if [[ $# -ne 1 ]]; then
echo "Usage: $0 [--windows-cross-compile] [<bazel query args>...] -- <query expression>" >&2
if [[ $# -lt 2 || "${@: -2:1}" != "--" ]]; then
echo "Usage: $0 [<bazel query args>...] -- <query expression>" >&2
exit 1
fi
query_expression="$1"
ci_config=ci-linux
case "${RUNNER_OS:-}" in
macOS)
ci_config=ci-macos
;;
Windows)
if [[ $windows_cross_compile -eq 1 ]]; then
ci_config=ci-windows-cross
else
ci_config=ci-windows
fi
;;
esac
query_args=("${@:1:$#-2}")
query_expression="${@: -1}"
bazel_startup_args=()
if [[ -n "${BAZEL_OUTPUT_USER_ROOT:-}" ]]; then
@@ -60,12 +29,6 @@ run_bazel() {
}
bazel_query_args=(--noexperimental_remote_repo_contents_cache query)
if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then
bazel_query_args+=(
"--config=${ci_config}"
"--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}"
)
fi
if [[ -n "${BAZEL_REPO_CONTENTS_CACHE:-}" ]]; then
bazel_query_args+=("--repo_contents_cache=${BAZEL_REPO_CONTENTS_CACHE}")
@@ -75,7 +38,10 @@ if [[ -n "${BAZEL_REPOSITORY_CACHE:-}" ]]; then
bazel_query_args+=("--repository_cache=${BAZEL_REPOSITORY_CACHE}")
fi
bazel_query_args+=("${query_args[@]}" "$query_expression")
if (( ${#query_args[@]} > 0 )); then
bazel_query_args+=("${query_args[@]}")
fi
bazel_query_args+=("$query_expression")
if (( ${#bazel_startup_args[@]} > 0 )); then
run_bazel "${bazel_startup_args[@]}" "${bazel_query_args[@]}"
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env python3
import json
import os
import subprocess
import sys
from collections.abc import Mapping
from collections.abc import Sequence
from pathlib import Path
OPENAI_REPOSITORY = "openai/codex"
# Remote configurations select cache/BES/download endpoints. Their -rbe forms
# also select the matching remote executor endpoint.
GENERIC_REMOTE_CONFIG = "buildbuddy-generic"
OPENAI_REMOTE_CONFIG = "buildbuddy-openai"
# These CI configurations require remote build execution. The wrapper supplies
# an RBE configuration, which also includes the common `remote` settings.
REMOTE_EXECUTION_CONFIGS = {
"--config=ci-linux",
"--config=ci-macos",
"--config=ci-v8",
"--config=ci-windows-cross",
}
# Only authenticated workflow runs executing trusted upstream code may use the
# OpenAI BuildBuddy host. A pull request event without proof that its head is
# in the upstream repository fails closed to the generic host.
def is_trusted_upstream_run(env: Mapping[str, str]) -> bool:
# `GITHUB_REPOSITORY` is easy to set locally. Requiring GitHub's workflow
# marker prevents a local command from opting itself into the OpenAI host.
if (
env.get("GITHUB_ACTIONS") != "true"
or env.get("GITHUB_REPOSITORY") != OPENAI_REPOSITORY
):
return False
# Non-PR workflow runs in `openai/codex` execute upstream refs, so they are
# trusted. Fork code reaches these workflows only through pull requests.
if env.get("GITHUB_EVENT_NAME") != "pull_request":
return True
event_path = env.get("GITHUB_EVENT_PATH")
if not event_path:
return False
try:
event = json.loads(Path(event_path).read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return False
try:
return event["pull_request"]["head"]["repo"]["fork"] is False
except (KeyError, TypeError):
return False
def uses_openai_host(env: Mapping[str, str]) -> bool:
return bool(env.get("BUILDBUDDY_API_KEY")) and is_trusted_upstream_run(env)
def uses_remote_execution(args: Sequence[str]) -> bool:
try:
separator_idx = args.index("--")
except ValueError:
separator_idx = len(args)
return any(arg in REMOTE_EXECUTION_CONFIGS for arg in args[:separator_idx])
def remote_config(args: Sequence[str], env: Mapping[str, str]) -> str | None:
if not env.get("BUILDBUDDY_API_KEY"):
return None
config = OPENAI_REMOTE_CONFIG if uses_openai_host(env) else GENERIC_REMOTE_CONFIG
if uses_remote_execution(args):
config += "-rbe"
return config
def bazel_args_without_remote_execution(args: Sequence[str]) -> list[str]:
# Remote CI configs require BuildBuddy credentials. Removing them preserves
# the local fallback used for fork pull requests.
try:
separator_idx = args.index("--")
except ValueError:
separator_idx = len(args)
return [
*(arg for arg in args[:separator_idx] if arg not in REMOTE_EXECUTION_CONFIGS),
*args[separator_idx:],
]
def bazel_args_with_remote_config(
args: Sequence[str], env: Mapping[str, str]
) -> list[str]:
config = remote_config(args, env)
if config is None:
return bazel_args_without_remote_execution(args)
# `remote_config()` returns a configuration only when this key is present.
api_key = env["BUILDBUDDY_API_KEY"]
remote_args = [
f"--config={config}",
f"--remote_header=x-buildbuddy-api-key={api_key}",
]
# Insert immediately after the Bazel command. This keeps wrapper-added
# options out of positional payloads and lets later CI configs override
# shared RBE defaults such as the Windows cross-compilation exec platforms.
insertion_idx = next(
(idx + 1 for idx, arg in enumerate(args) if not arg.startswith("-")),
len(args),
)
return [*args[:insertion_idx], *remote_args, *args[insertion_idx:]]
def bazel_command(*args: str, env: Mapping[str, str] | None = None) -> list[str]:
env = os.environ if env is None else env
bazel = env.get("CODEX_BAZEL_BIN", "bazel")
return [bazel, *bazel_args_with_remote_config(args, env)]
def main() -> None:
config = remote_config(sys.argv[1:], os.environ)
if config is None:
print(
"BuildBuddy key unavailable; using local Bazel configuration.",
file=sys.stderr,
)
else:
host_description = (
"OpenAI tenant" if uses_openai_host(os.environ) else "generic"
)
print(
f"Using {host_description} BuildBuddy configuration: {config}.",
file=sys.stderr,
)
command = bazel_command(*sys.argv[1:])
if os.name == "nt":
# Windows CRT exec can split arguments containing spaces and lose the
# eventual child exit status. Wait for Bazel and propagate its status.
result = subprocess.run(command, check=False)
raise SystemExit(result.returncode)
os.execvp(command[0], command)
if __name__ == "__main__":
main()
+28 -35
View File
@@ -5,7 +5,6 @@ from __future__ import annotations
import argparse
import gzip
import hashlib
import os
import re
import shutil
import subprocess
@@ -13,6 +12,7 @@ import sys
import tomllib
from pathlib import Path
from run_bazel_with_buildbuddy import bazel_command
from rusty_v8_module_bazel import (
RustyV8ChecksumError,
check_module_bazel,
@@ -29,33 +29,22 @@ SANDBOX_ARTIFACT_PROFILE = "ptrcomp_sandbox_release"
ARTIFACT_BAZEL_CONFIGS = ["rusty-v8-upstream-libcxx"]
def bazel_remote_args() -> list[str]:
buildbuddy_api_key = os.environ.get("BUILDBUDDY_API_KEY")
if not buildbuddy_api_key:
return []
return [f"--remote_header=x-buildbuddy-api-key={buildbuddy_api_key}"]
def bazel_execroot() -> Path:
result = subprocess.run(
["bazel", "info", "execution_root"],
output = subprocess.check_output(
bazel_command("info", "execution_root"),
cwd=ROOT,
check=True,
capture_output=True,
text=True,
)
return Path(result.stdout.strip())
return Path(output.strip())
def bazel_output_base() -> Path:
result = subprocess.run(
["bazel", "info", "output_base"],
output = subprocess.check_output(
bazel_command("info", "output_base"),
cwd=ROOT,
check=True,
capture_output=True,
text=True,
)
return Path(result.stdout.strip())
return Path(output.strip())
def bazel_output_path(path: str) -> Path:
@@ -72,24 +61,22 @@ def bazel_output_files(
) -> list[Path]:
expression = "set(" + " ".join(labels) + ")"
bazel_configs = bazel_configs or []
result = subprocess.run(
[
"bazel",
output = subprocess.check_output(
bazel_command(
"cquery",
"-c",
compilation_mode,
f"--platforms=@llvm//platforms:{platform}",
*[f"--config={config}" for config in bazel_configs],
*bazel_remote_args(),
"--output=files",
expression,
],
),
cwd=ROOT,
check=True,
capture_output=True,
text=True,
)
return [bazel_output_path(line.strip()) for line in result.stdout.splitlines() if line.strip()]
return [
bazel_output_path(line.strip()) for line in output.splitlines() if line.strip()
]
def bazel_build(
@@ -102,17 +89,15 @@ def bazel_build(
bazel_configs = bazel_configs or []
download_args = ["--remote_download_toplevel"] if download_toplevel else []
subprocess.run(
[
"bazel",
bazel_command(
"build",
"-c",
compilation_mode,
f"--platforms=@llvm//platforms:{platform}",
*[f"--config={config}" for config in bazel_configs],
*bazel_remote_args(),
*download_args,
*labels,
],
),
cwd=ROOT,
check=True,
)
@@ -172,7 +157,7 @@ def resolved_v8_crate_version() -> str:
matches = sorted(
set(
re.findall(
r'https://static\.crates\.io/crates/v8/v8-([0-9]+\.[0-9]+\.[0-9]+)\.crate',
r"https://static\.crates\.io/crates/v8/v8-([0-9]+\.[0-9]+\.[0-9]+)\.crate",
module_bazel,
)
)
@@ -234,13 +219,17 @@ def stage_artifacts(
output_dir: Path,
sandbox: bool,
) -> None:
missing_paths = [str(path) for path in [lib_path, binding_path] if not path.exists()]
missing_paths = [
str(path) for path in [lib_path, binding_path] if not path.exists()
]
if missing_paths:
raise SystemExit(f"missing release outputs for {target}: {missing_paths}")
output_dir.mkdir(parents=True, exist_ok=True)
artifact_profile = SANDBOX_ARTIFACT_PROFILE if sandbox else RELEASE_ARTIFACT_PROFILE
staged_library = output_dir / staged_archive_name(target, lib_path, artifact_profile)
staged_library = output_dir / staged_archive_name(
target, lib_path, artifact_profile
)
staged_binding = output_dir / staged_binding_name(target, artifact_profile)
with lib_path.open("rb") as src, staged_library.open("wb") as dst:
@@ -270,7 +259,9 @@ def stage_artifacts(
def upstream_release_pair_paths(source_root: Path, target: str) -> tuple[Path, Path]:
lib_name = "rusty_v8.lib" if target.endswith("-pc-windows-msvc") else "librusty_v8.a"
lib_name = (
"rusty_v8.lib" if target.endswith("-pc-windows-msvc") else "librusty_v8.a"
)
gn_out = source_root / "target" / target / "release" / "gn_out"
return gn_out / "obj" / lib_name, gn_out / "src_binding.rs"
@@ -338,7 +329,9 @@ def parse_args() -> argparse.Namespace:
stage_upstream_release_pair_parser = subparsers.add_parser(
"stage-upstream-release-pair"
)
stage_upstream_release_pair_parser.add_argument("--source-root", type=Path, required=True)
stage_upstream_release_pair_parser.add_argument(
"--source-root", type=Path, required=True
)
stage_upstream_release_pair_parser.add_argument("--target", required=True)
stage_upstream_release_pair_parser.add_argument("--output-dir", required=True)
stage_upstream_release_pair_parser.add_argument("--sandbox", action="store_true")
@@ -0,0 +1,214 @@
#!/usr/bin/env python3
import json
import os
import subprocess
import sys
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
import run_bazel_with_buildbuddy
class RunBazelWithBuildBuddyTest(unittest.TestCase):
def github_env(
self,
temp_dir: str,
*,
repository: str = "openai/codex",
fork: bool = False,
event_name: str = "pull_request",
) -> dict[str, str]:
event_path = Path(temp_dir) / "event.json"
event_path.write_text(
json.dumps({"pull_request": {"head": {"repo": {"fork": fork}}}}),
encoding="utf-8",
)
return {
"BUILDBUDDY_API_KEY": "token",
"GITHUB_ACTIONS": "true",
"GITHUB_EVENT_NAME": event_name,
"GITHUB_EVENT_PATH": str(event_path),
"GITHUB_REPOSITORY": repository,
}
def test_keyless_invocation_drops_remote_ci_configuration(self) -> None:
self.assertIsNone(
run_bazel_with_buildbuddy.remote_config(
["build", "--config=ci-linux", "//codex-rs/cli:codex"],
{},
)
)
self.assertEqual(
run_bazel_with_buildbuddy.bazel_args_with_remote_config(
["build", "--config=ci-linux", "--", "//codex-rs/cli:codex"],
{},
),
["build", "--", "//codex-rs/cli:codex"],
)
def test_program_arguments_after_separator_do_not_select_or_lose_rbe(self) -> None:
args = ["run", "//codex-rs/cli:codex", "--", "--config=remote"]
self.assertEqual(
run_bazel_with_buildbuddy.bazel_args_with_remote_config(args, {}),
args,
)
self.assertEqual(
run_bazel_with_buildbuddy.remote_config(
args, {"BUILDBUDDY_API_KEY": "fork-token"}
),
"buildbuddy-generic",
)
def test_upstream_push_selects_openai_rbe_before_target_separator(self) -> None:
with TemporaryDirectory() as temp_dir:
env = self.github_env(temp_dir, event_name="push")
self.assertEqual(
run_bazel_with_buildbuddy.bazel_args_with_remote_config(
["build", "--config=ci-linux", "--", "//codex-rs/cli:codex"],
env,
),
[
"build",
"--config=buildbuddy-openai-rbe",
"--remote_header=x-buildbuddy-api-key=token",
"--config=ci-linux",
"--",
"//codex-rs/cli:codex",
],
)
def test_windows_cross_ci_configuration_follows_remote_configuration(self) -> None:
env = {"BUILDBUDDY_API_KEY": "fork-token"}
self.assertEqual(
run_bazel_with_buildbuddy.bazel_args_with_remote_config(
["build", "--config=ci-windows-cross", "//codex-rs/cli:codex"],
env,
),
[
"build",
"--config=buildbuddy-generic-rbe",
"--remote_header=x-buildbuddy-api-key=fork-token",
"--config=ci-windows-cross",
"//codex-rs/cli:codex",
],
)
def test_query_remote_configuration_is_inserted_before_expression(self) -> None:
expression = 'kind("rust_library rule", //codex-rs/...)'
env = {"BUILDBUDDY_API_KEY": "fork-token"}
for command in ("query", "cquery", "aquery"):
with self.subTest(command=command):
self.assertEqual(
run_bazel_with_buildbuddy.bazel_args_with_remote_config(
[
command,
"--config=ci-windows-cross",
"--output=label",
expression,
],
env,
),
[
command,
"--config=buildbuddy-generic-rbe",
"--remote_header=x-buildbuddy-api-key=fork-token",
"--config=ci-windows-cross",
"--output=label",
expression,
],
)
def test_same_repository_pull_request_selects_openai_host(self) -> None:
with TemporaryDirectory() as temp_dir:
self.assertEqual(
run_bazel_with_buildbuddy.remote_config(
["build", "--config=ci-v8"], self.github_env(temp_dir)
),
"buildbuddy-openai-rbe",
)
def test_fork_pull_request_cannot_select_openai_host(self) -> None:
with TemporaryDirectory() as temp_dir:
env = self.github_env(temp_dir, fork=True)
self.assertEqual(
run_bazel_with_buildbuddy.remote_config(
["build", "--config=ci-v8"], env
),
"buildbuddy-generic-rbe",
)
def test_run_in_fork_repository_cannot_select_openai_host(self) -> None:
with TemporaryDirectory() as temp_dir:
env = self.github_env(temp_dir, repository="contributor/codex")
self.assertEqual(
run_bazel_with_buildbuddy.remote_config(
["build", "--config=ci-v8"], env
),
"buildbuddy-generic-rbe",
)
def test_pull_request_without_readable_event_payload_fails_closed(self) -> None:
for event_path in (None, "missing-event.json"):
env = {
"BUILDBUDDY_API_KEY": "token",
"GITHUB_ACTIONS": "true",
"GITHUB_EVENT_NAME": "pull_request",
"GITHUB_REPOSITORY": "openai/codex",
}
if event_path is not None:
env["GITHUB_EVENT_PATH"] = event_path
with self.subTest(event_path=event_path):
self.assertEqual(
run_bazel_with_buildbuddy.remote_config(["build"], env),
"buildbuddy-generic",
)
def test_bazel_command_uses_configured_binary_locally(self) -> None:
self.assertEqual(
run_bazel_with_buildbuddy.bazel_command(
"info",
"execution_root",
env={"CODEX_BAZEL_BIN": "fake-bazel"},
),
["fake-bazel", "info", "execution_root"],
)
def test_main_preserves_spaced_argument_and_child_exit_status(self) -> None:
spaced_arg = (
r"--test_env=PATH=C:\Program Files\PowerShell\7;C:\Program Files\Git\bin"
)
child_code = (
f"import sys; sys.exit(37 if sys.argv[1] == {spaced_arg!r} else 91)"
)
env = os.environ.copy()
env["CODEX_BAZEL_BIN"] = sys.executable
env.pop("BUILDBUDDY_API_KEY", None)
result = subprocess.run(
[
sys.executable,
str(Path(run_bazel_with_buildbuddy.__file__)),
"-c",
child_code,
spaced_arg,
],
env=env,
check=False,
capture_output=True,
text=True,
)
self.assertEqual(result.returncode, 37, result.stderr)
if __name__ == "__main__":
unittest.main()
+35 -14
View File
@@ -88,24 +88,49 @@ class RustyV8BazelTest(unittest.TestCase):
),
)
def test_bazel_remote_args_include_buildbuddy_header_when_present(self) -> None:
with patch.dict(environ, {"BUILDBUDDY_API_KEY": "token"}, clear=False):
def test_bazel_commands_use_shared_buildbuddy_remote_config_library(self) -> None:
with patch.dict(environ, {}, clear=True):
self.assertEqual(
["--remote_header=x-buildbuddy-api-key=token"],
rusty_v8_bazel.bazel_remote_args(),
[
"bazel",
"build",
"//third_party/v8:release",
],
rusty_v8_bazel.bazel_command(
"build",
"--config=ci-v8",
"//third_party/v8:release",
),
)
with patch.dict(environ, {"BUILDBUDDY_API_KEY": "token"}, clear=True):
self.assertEqual(
[
"bazel",
"build",
"--config=buildbuddy-generic-rbe",
"--remote_header=x-buildbuddy-api-key=token",
"--config=ci-v8",
"//third_party/v8:release",
],
rusty_v8_bazel.bazel_command(
"build",
"--config=ci-v8",
"//third_party/v8:release",
),
)
with patch.dict(environ, {}, clear=True):
self.assertEqual([], rusty_v8_bazel.bazel_remote_args())
def test_release_pair_labels_and_staged_names_distinguish_sandbox_artifacts(self) -> None:
def test_release_pair_labels_and_staged_names_distinguish_sandbox_artifacts(
self,
) -> None:
self.assertEqual(
"//third_party/v8:rusty_v8_release_pair_x86_64_unknown_linux_musl",
rusty_v8_bazel.release_pair_label("x86_64-unknown-linux-musl"),
)
self.assertEqual(
"//third_party/v8:rusty_v8_sandbox_release_pair_x86_64_unknown_linux_musl",
rusty_v8_bazel.release_pair_label("x86_64-unknown-linux-musl", sandbox=True),
rusty_v8_bazel.release_pair_label(
"x86_64-unknown-linux-musl", sandbox=True
),
)
self.assertEqual(
"//third_party/v8:rusty_v8_sandbox_release_pair_x86_64_apple_darwin",
@@ -205,11 +230,7 @@ class RustyV8BazelTest(unittest.TestCase):
with TemporaryDirectory() as source_dir, TemporaryDirectory() as output_dir:
source_root = Path(source_dir)
gn_out = (
source_root
/ "target"
/ "x86_64-pc-windows-msvc"
/ "release"
/ "gn_out"
source_root / "target" / "x86_64-pc-windows-msvc" / "release" / "gn_out"
)
(gn_out / "obj").mkdir(parents=True)
(gn_out / "obj" / "rusty_v8.lib").write_bytes(b"archive")
+12 -1
View File
@@ -15,6 +15,7 @@ concurrency:
# See https://docs.github.com/en/actions/using-jobs/using-concurrency and https://docs.github.com/en/actions/learn-github-actions/contexts for more info.
group: concurrency-group::${{ github.workflow }}::${{ github.event.pull_request.number > 0 && format('pr-{0}', github.event.pull_request.number) || github.ref_name }}${{ github.ref_name == 'main' && format('::{0}', github.run_id) || ''}}
cancel-in-progress: ${{ github.ref_name != 'main' }}
jobs:
test:
# PRs use the sharded Windows cross-compiled test jobs below. Post-merge
@@ -55,12 +56,17 @@ jobs:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
- uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49
if: matrix.os == 'ubuntu-24.04' && matrix.target == 'x86_64-unknown-linux-gnu'
with:
tool: just
- name: Check rusty_v8 MODULE.bazel checksums
if: matrix.os == 'ubuntu-24.04' && matrix.target == 'x86_64-unknown-linux-gnu'
shell: bash
run: |
python3 .github/scripts/rusty_v8_bazel.py check-module-bazel
python3 -m unittest discover -s .github/scripts -p test_rusty_v8_bazel.py
just test-github-scripts
- name: Prepare Bazel CI
id: prepare_bazel
@@ -152,6 +158,11 @@ jobs:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
- name: Test BuildBuddy Bazel wrapper
if: matrix.shard == 1
shell: pwsh
run: python .github/scripts/test_run_bazel_with_buildbuddy.py
- name: Prepare Bazel CI
id: prepare_bazel
uses: ./.github/actions/prepare-bazel-ci
+2 -33
View File
@@ -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: |
+1 -40
View File
@@ -149,9 +149,6 @@ jobs:
# 2026-03-04: temporarily change releases to use thin LTO because
# Ubuntu ARM is timing out at 60 minutes.
CARGO_PROFILE_RELEASE_LTO: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'thin' }}
CARGO_PROFILE_RELEASE_DEBUG: full
CARGO_PROFILE_RELEASE_SPLIT_DEBUGINFO: ${{ contains(matrix.target, 'apple-darwin') && 'packed' || 'off' }}
CARGO_PROFILE_RELEASE_STRIP: "false"
# Use the git CLI instead of Cargo's libgit2 path for git dependencies.
# macOS release runners have intermittently failed to fetch nested
# submodules through SecureTransport/libgit2, especially libwebrtc's
@@ -252,7 +249,7 @@ jobs:
run: |
set -euo pipefail
sudo apt-get update -y
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends binutils pkg-config libcap-dev
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev
- uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0
with:
targets: ${{ matrix.target }}
@@ -311,10 +308,6 @@ jobs:
exit 1
fi
# Codex embeds this digest at build time and verifies the bundled
# bwrap resource before use. Strip bwrap before hashing so the digest
# covers the exact bytes that the release packages.
strip --strip-debug --strip-unneeded "$bwrap_path"
digest="$(sha256sum "$bwrap_path" | awk '{print $1}')"
echo "CODEX_BWRAP_SHA256=${digest}" >> "$GITHUB_ENV"
echo "Built bwrap ${bwrap_path} with sha256:${digest}"
@@ -328,11 +321,6 @@ jobs:
fi
build_args=()
for binary in ${{ matrix.binaries }}; do
# bwrap was built, finalized, and hashed before this build so
# Codex can embed the digest of the bytes that will be packaged.
if [[ "$binary" == "bwrap" ]]; then
continue
fi
build_args+=(--bin "$binary")
done
echo "CARGO_PROFILE_RELEASE_LTO: ${CARGO_PROFILE_RELEASE_LTO}"
@@ -345,32 +333,6 @@ jobs:
path: codex-rs/target/**/cargo-timings/cargo-timing.html
if-no-files-found: warn
- name: Build symbols archive and strip binaries
shell: bash
run: |
binaries=()
for binary in ${{ matrix.binaries }}; do
# bwrap is already stripped before hashing. Its symbols are not
# useful enough to justify a separate pre-Codex symbols pass.
if [[ "$binary" == "bwrap" ]]; then
continue
fi
binaries+=("$binary")
done
bash "${GITHUB_WORKSPACE}/.github/scripts/archive-release-symbols-and-strip-binaries.sh" \
--target "${{ matrix.target }}" \
--artifact-name "${{ matrix.artifact_name }}" \
--release-dir "target/${{ matrix.target }}/release" \
--archive-dir "symbols-dist/${{ matrix.artifact_name }}" \
--binaries "${binaries[*]}"
- name: Upload symbols archive
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: ${{ matrix.artifact_name }}-symbols
path: codex-rs/symbols-dist/${{ matrix.artifact_name }}/*
if-no-files-found: error
- if: ${{ runner.os == 'macOS' && env.SIGN_MACOS != 'true' }}
name: Stage unsigned macOS artifacts
shell: bash
@@ -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' \
+2 -3
View File
@@ -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:
+4 -3
View File
@@ -5,6 +5,7 @@ on:
paths:
- ".bazelrc"
- ".github/actions/setup-bazel-ci/**"
- ".github/scripts/run_bazel_with_buildbuddy.py"
- ".github/scripts/rusty_v8_bazel.py"
- ".github/scripts/rusty_v8_module_bazel.py"
- ".github/workflows/rusty-v8-release.yml"
@@ -23,6 +24,7 @@ on:
paths:
- ".bazelrc"
- ".github/actions/setup-bazel-ci/**"
- ".github/scripts/run_bazel_with_buildbuddy.py"
- ".github/scripts/rusty_v8_bazel.py"
- ".github/scripts/rusty_v8_module_bazel.py"
- ".github/workflows/rusty-v8-release.yml"
@@ -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
View File
@@ -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"
+27
View File
@@ -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"
+3
View File
@@ -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" }
+37 -1
View File
@@ -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, &params.environment_id)?;
let response = send_client_management_request(
auth_manager,
ClientManagementRequest::List {
url: &url,
params: &params,
},
"list remote control clients",
)
.await?;
let ClientManagementResponse {
status,
headers,
body,
} = response;
let body_preview = preview_remote_control_response_body(&body);
ensure_success_response(status, &headers, &url, &body_preview, "client list")?;
let response = serde_json::from_slice::<ListRemoteControlClientsResponse>(&body).map_err(
|err| {
io::Error::other(format!(
"failed to parse remote control client list response from `{url}`: HTTP {status}, {}, body: {body_preview}, decode error: {err}",
format_headers(&headers)
))
},
)?;
Ok(RemoteControlClientsListResponse {
data: response
.items
.into_iter()
.map(RemoteControlClient::try_from)
.collect::<io::Result<_>>()?,
next_cursor: response.cursor,
})
}
pub(super) async fn revoke_remote_control_client(
remote_control_url: &str,
auth_manager: &Arc<AuthManager>,
params: RemoteControlClientsRevokeParams,
) -> io::Result<RemoteControlClientsRevokeResponse> {
if params.environment_id.is_empty() {
return Err(io::Error::new(
ErrorKind::InvalidInput,
"remote control client revoke requires environmentId",
));
}
if params.client_id.is_empty() {
return Err(io::Error::new(
ErrorKind::InvalidInput,
"remote control client revoke requires clientId",
));
}
let mut url = environment_clients_url(remote_control_url, &params.environment_id)?;
url.path_segments_mut()
.map_err(|()| {
io::Error::new(
ErrorKind::InvalidInput,
"remote control URL cannot be a base",
)
})?
.push(&params.client_id);
let response = send_client_management_request(
auth_manager,
ClientManagementRequest::Revoke { url: &url },
"revoke remote control client",
)
.await?;
let ClientManagementResponse {
status,
headers,
body,
} = response;
let body_preview = preview_remote_control_response_body(&body);
ensure_success_response(status, &headers, &url, &body_preview, "client revoke")?;
Ok(RemoteControlClientsRevokeResponse {})
}
async fn send_client_management_request(
auth_manager: &Arc<AuthManager>,
request: ClientManagementRequest<'_>,
action: &str,
) -> io::Result<ClientManagementResponse> {
let mut auth_recovery = auth_manager.unauthorized_recovery();
let mut auth_change_rx = auth_manager.auth_change_receiver();
let auth = load_remote_control_auth(auth_manager).await?;
let response = send_client_management_request_once(&auth, &request, action).await?;
if response.status.as_u16() != 401
|| !recover_remote_control_auth(&mut auth_recovery, &mut auth_change_rx).await
{
return Ok(response);
}
let auth = load_remote_control_auth(auth_manager).await?;
send_client_management_request_once(&auth, &request, action).await
}
async fn send_client_management_request_once(
auth: &RemoteControlConnectionAuth,
request: &ClientManagementRequest<'_>,
action: &str,
) -> io::Result<ClientManagementResponse> {
let client = build_reqwest_client();
let mut auth_headers = HeaderMap::new();
auth.auth_provider.add_auth_headers(&mut auth_headers);
let request = match request {
ClientManagementRequest::List { url, params } => {
let mut query = Vec::new();
if let Some(cursor) = &params.cursor {
query.push(("cursor", cursor.clone()));
}
if let Some(limit) = params.limit {
query.push(("limit", limit.to_string()));
}
if let Some(order) = params.order {
query.push((
"order",
match order {
RemoteControlClientsListOrder::Asc => "asc",
RemoteControlClientsListOrder::Desc => "desc",
}
.to_string(),
));
}
client.get((*url).clone()).query(&query)
}
ClientManagementRequest::Revoke { url } => client.delete((*url).clone()),
};
let response = request
.timeout(REMOTE_CONTROL_CLIENT_MANAGEMENT_TIMEOUT)
.headers(auth_headers)
.header(REMOTE_CONTROL_ACCOUNT_ID_HEADER, &auth.account_id)
.send()
.await
.map_err(|err| io::Error::other(format!("failed to {action}: {err}")))?;
let headers = response.headers().clone();
let status = response.status();
let body = response
.bytes()
.await
.map_err(|err| io::Error::other(format!("failed to read {action} response: {err}")))?
.to_vec();
Ok(ClientManagementResponse {
status,
headers,
body,
})
}
fn ensure_success_response(
status: axum::http::StatusCode,
headers: &HeaderMap,
url: &Url,
body_preview: &str,
response_kind: &str,
) -> io::Result<()> {
if status.is_success() {
return Ok(());
}
let error_kind = match status.as_u16() {
400 => ErrorKind::InvalidInput,
401 | 403 => ErrorKind::PermissionDenied,
404 => ErrorKind::NotFound,
_ => ErrorKind::Other,
};
Err(io::Error::new(
error_kind,
format!(
"remote control {response_kind} failed at `{url}`: HTTP {status}, {}, body: {body_preview}",
format_headers(headers)
),
))
}
fn environment_clients_url(remote_control_url: &str, environment_id: &str) -> io::Result<Url> {
let mut url = normalize_remote_control_base_url(remote_control_url)?
.join("wham/remote/control/environments")
.map_err(io::Error::other)?;
url.path_segments_mut()
.map_err(|()| {
io::Error::new(
ErrorKind::InvalidInput,
"remote control URL cannot be a base",
)
})?
.push(environment_id)
.push("clients");
Ok(url)
}
impl TryFrom<RemoteControlClientResponse> for RemoteControlClient {
type Error = io::Error;
fn try_from(client: RemoteControlClientResponse) -> Result<Self, Self::Error> {
Ok(Self {
client_id: client.client_id,
display_name: client.display_name,
device_type: client.device_type,
platform: client.platform,
os_version: client.os_version,
device_model: client.device_model,
app_version: client.app_version,
last_seen_at: client
.last_seen_at
.map(|last_seen_at| {
OffsetDateTime::parse(&last_seen_at, &Rfc3339)
.map(OffsetDateTime::unix_timestamp)
.map_err(|err| {
io::Error::new(
ErrorKind::InvalidData,
format!(
"failed to parse remote control client last_seen_at `{last_seen_at}`: {err}"
),
)
})
})
.transpose()?,
})
}
}
@@ -1,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;
+2
View File
@@ -211,6 +211,8 @@ Example with notification opt-out:
- `remoteControl/disable` — experimental; disable remote control for the current app-server process and return the current remote-control status snapshot. This does not revoke already enrolled controller devices.
- `remoteControl/status/read` — experimental; read the current remote-control status snapshot. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `serverName` is the local machine name used by this app-server process; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled.
- `remoteControl/pairing/start` — experimental; start a short-lived remote-control pairing artifact for the current app-server process. Pass `manualCode: true` to also request a manual pairing code. Returns `pairingCode`, `manualPairingCode`, `environmentId`, and Unix-seconds `expiresAt`; app-server intentionally does not expose the backend `serverId`.
- `remoteControl/client/list` — experimental; list controller devices granted access to an environment. Pass `environmentId` and optional `cursor`, `limit`, and `order`; returns picker-oriented client metadata plus `nextCursor`. This signed-in account-management operation works while the local relay is disabled or unenrolled.
- `remoteControl/client/revoke` — experimental; revoke one controller device's grant for an environment. Pass `environmentId` and `clientId`; returns an empty object. This signed-in account-management operation works while the local relay is disabled or unenrolled.
- `remoteControl/status/changed` — notification emitted when the remote-control status or client-visible environment id changes. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `serverName` is the local machine name used by this app-server process; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled. Newly initialized app-server clients always receive the current status snapshot.
- `skills/config/write` — write user-level skill config by name or absolute path.
- `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, install MCPs if any, and return the effective plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**).
@@ -922,6 +922,16 @@ impl MessageProcessor {
.pairing_start(params)
.await
.map(|response| Some(response.into())),
ClientRequest::RemoteControlClientsList { params, .. } => self
.remote_control_processor
.clients_list(params)
.await
.map(|response| Some(response.into())),
ClientRequest::RemoteControlClientsRevoke { params, .. } => self
.remote_control_processor
.clients_revoke(params)
.await
.map(|response| Some(response.into())),
ClientRequest::ConfigRequirementsRead { params: _, .. } => self
.config_processor
.config_requirements_read()
@@ -3,6 +3,10 @@ use crate::error_code::invalid_request;
use crate::transport::RemoteControlHandle;
use crate::transport::RemoteControlUnavailable;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::RemoteControlClientsListParams;
use codex_app_server_protocol::RemoteControlClientsListResponse;
use codex_app_server_protocol::RemoteControlClientsRevokeParams;
use codex_app_server_protocol::RemoteControlClientsRevokeResponse;
use codex_app_server_protocol::RemoteControlDisableResponse;
use codex_app_server_protocol::RemoteControlEnableResponse;
use codex_app_server_protocol::RemoteControlPairingStartParams;
@@ -55,6 +59,26 @@ impl RemoteControlRequestProcessor {
.map_err(map_pairing_start_error)
}
pub(crate) async fn clients_list(
&self,
params: RemoteControlClientsListParams,
) -> Result<RemoteControlClientsListResponse, JSONRPCErrorError> {
self.handle()?
.list_clients(params)
.await
.map_err(map_client_management_error)
}
pub(crate) async fn clients_revoke(
&self,
params: RemoteControlClientsRevokeParams,
) -> Result<RemoteControlClientsRevokeResponse, JSONRPCErrorError> {
self.handle()?
.revoke_client(params)
.await
.map_err(map_client_management_error)
}
fn handle(&self) -> Result<&RemoteControlHandle, JSONRPCErrorError> {
self.remote_control_handle
.as_ref()
@@ -74,5 +98,15 @@ fn map_pairing_start_error(err: io::Error) -> JSONRPCErrorError {
}
}
fn map_client_management_error(err: io::Error) -> JSONRPCErrorError {
match err.kind() {
io::ErrorKind::InvalidInput
| io::ErrorKind::NotFound
| io::ErrorKind::PermissionDenied
| io::ErrorKind::WouldBlock => invalid_request(err.to_string()),
_ => internal_error(err.to_string()),
}
}
#[cfg(test)]
mod remote_control_processor_tests;
@@ -46,3 +46,34 @@ fn pairing_start_maps_backend_failures_to_internal_error() {
}
);
}
#[test]
fn client_management_maps_user_actionable_errors_to_invalid_request() {
for kind in [
io::ErrorKind::InvalidInput,
io::ErrorKind::NotFound,
io::ErrorKind::PermissionDenied,
io::ErrorKind::WouldBlock,
] {
assert_eq!(
map_client_management_error(io::Error::new(kind, "client management unavailable")),
JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
data: None,
message: "client management unavailable".to_string(),
}
);
}
}
#[test]
fn client_management_maps_backend_failures_to_internal_error() {
assert_eq!(
map_client_management_error(io::Error::other("client management failed")),
JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
data: None,
message: "client management failed".to_string(),
}
);
}
@@ -67,6 +67,8 @@ use codex_app_server_protocol::ProcessKillParams;
use codex_app_server_protocol::ProcessResizePtyParams;
use codex_app_server_protocol::ProcessSpawnParams;
use codex_app_server_protocol::ProcessWriteStdinParams;
use codex_app_server_protocol::RemoteControlClientsListParams;
use codex_app_server_protocol::RemoteControlClientsRevokeParams;
use codex_app_server_protocol::RemoteControlPairingStartParams;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ReviewStartParams;
@@ -654,6 +656,25 @@ impl TestAppServer {
.await
}
/// Send a `remoteControl/client/list` JSON-RPC request.
pub async fn send_remote_control_clients_list_request(
&mut self,
params: RemoteControlClientsListParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("remoteControl/client/list", params).await
}
/// Send a `remoteControl/client/revoke` JSON-RPC request.
pub async fn send_remote_control_clients_revoke_request(
&mut self,
params: RemoteControlClientsRevokeParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("remoteControl/client/revoke", params)
.await
}
/// Send an `app/list` JSON-RPC request.
pub async fn send_apps_list_request(&mut self, params: AppsListParams) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
@@ -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(())
}
+4
View File
@@ -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
View File
@@ -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<()> {
+2 -1
View File
@@ -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> {
+41 -56
View File
@@ -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");
+6
View File
@@ -0,0 +1,6 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "context-fragments",
crate_name = "codex_context_fragments",
)
+18
View File
@@ -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 }
@@ -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"
}
@@ -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;
}
+8
View File
@@ -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;
+1
View File
@@ -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 }
+2
View File
@@ -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;
@@ -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"
}
+1
View File
@@ -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 }
+2 -2
View File
@@ -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);
}
+3 -3
View File
@@ -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,
);
+1 -1
View File
@@ -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,
+1
View File
@@ -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(())
+1 -1
View File
@@ -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"
}
+6 -9
View File
@@ -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"
}
+1 -1
View File
@@ -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
View File
@@ -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();
+1 -1
View File
@@ -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"));
+1 -1
View File
@@ -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(),
+1 -1
View File
@@ -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()),
+5 -5
View File
@@ -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"
);
+2 -2
View File
@@ -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);
+7 -8
View File
@@ -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 {
+3 -3
View File
@@ -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 {
+1 -1
View File
@@ -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,
+6 -6
View File
@@ -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())
+3 -3
View File
@@ -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,
+29 -29
View File
@@ -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);
+65 -8
View File
@@ -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,
+1 -1
View File
@@ -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],
+4 -4
View File
@@ -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
+2 -2
View File
@@ -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(
+1 -1
View File
@@ -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