Compare commits

..

17 Commits

Author SHA1 Message Date
aibrahim-oai
1441b32d54 Update models.json 2026-06-03 05:37:54 +00:00
Shijie Rao
d36a3ead3c revert: publish release symbol artifacts (#25988)
revert https://github.com/openai/codex/pull/25916 and
https://github.com/openai/codex/pull/25649.
2026-06-02 17:34:04 -07:00
Anton Panasenko
98a62a62ce feat(app-server): add remote control client management RPCs (#25785)
## Why

Remote-control clients need to list and revoke controller-device grants
without enabling or enrolling the local relay. These are signed-in
account-management operations, so coupling them to websocket, pairing,
enrollment, or persisted relay state would prevent clients from managing
stale grants from the picker.

Related enhancement request: N/A. This adds the Codex app-server surface
for the planned upstream environment-scoped revoke endpoint.

## What Changed

- Added experimental app-server v2 RPCs:
  - `remoteControl/client/list`
  - `remoteControl/client/revoke`
- Added picker-oriented protocol types and standard generated schema
fixtures. The list response intentionally omits backend account id,
enrollment status, and location fields.
- Added `app-server-transport/src/transport/remote_control/clients.rs`
for environment-scoped GET and DELETE requests. It builds escaped URL
path segments, forwards optional pagination query fields, sends ChatGPT
auth plus `chatgpt-account-id`, converts RFC3339 `last_seen_at` values
to Unix seconds, accepts `204 No Content` revoke responses, and retries
once after a `401`.
- Extracted shared ChatGPT auth loading and recovery into
`app-server-transport/src/transport/remote_control/auth.rs` so
websocket, pairing, and client management use the same account-auth
boundary.
- Retained the configured remote-control base URL on
`RemoteControlHandle` and resolve management URLs lazily, preserving
deferred validation while relay startup is disabled.
- Registered list as `global_shared_read("remote-control-clients")` and
revoke as `global("remote-control-clients")`.

## Verification

- Added transport coverage proving list and revoke work while relay
state is disabled, IDs are escaped, picker-only fields are returned,
timestamps are converted, revoke accepts `204`, auth headers are
forwarded, `401` retries exactly once, `403` is not retried, and
malformed list payloads retain decode context.
- Added an app-server integration test proving both JSON-RPC methods
work before relay enablement and successful revoke returns `{}`.
- Regenerated and validated experimental and standard app-server schema
fixtures.
2026-06-02 17:01:02 -07:00
joeflorencio-openai
1fd2a6d328 Allow EDU accounts to fetch cloud config bundles (#25963)
## Summary

Allow EDU ChatGPT workspaces to fetch cloud config bundles. The existing
cloud config eligibility gate only allowed business-like and enterprise
plans, which meant EDU admins could configure managed policies in the UI
but the Codex client would skip fetching them.

This keeps individual/pro and team-like usage-based plans excluded, and
adds service-level coverage for both `edu` and `education` plan aliases.

## Validation

- `just fmt`
- `just test -p codex-cloud-config`
- Built the Codex app locally, created a new EDU ChatGPT workspace, and
verified config bundles can be fetched and are properly applied.
2026-06-02 16:41:48 -07:00
jif
271d5cecf2 feat: add extension turn-input contributors (#25959)
## Disclaimer
Do not use for now

## Why

Extensions can already contribute prompt fragments and request same-turn
item injection, but there was no host-owned hook for contributing
structured `ResponseItem`s while Codex is assembling a new turn's
initial model input. This change adds that seam so extensions can attach
turn-local input that depends on the submitted user input and resolved
turn environments without routing through prompt text or late injection.

## What changed

- add `TurnInputContributor` to `codex_extension_api` and export the new
`TurnInputContext` / `TurnInputEnvironment` types it receives
- teach `ExtensionRegistry` to register and expose turn-input
contributors alongside the existing extension hooks
- call registered turn-input contributors from
`core/src/session/turn.rs` while building the initial injected input for
a turn, then append their returned `ResponseItem`s after the skill and
plugin injections
2026-06-03 01:33:31 +02:00
Michael Bolin
a28b32a835 config: express implicit sandbox defaults as permission profiles (#25926)
## Why

`PermissionProfile` is becoming the default way to represent Codex
permissions, but the implicit default behavior should stay the same for
now:

- trusted projects use `:workspace`
- untrusted projects also use `:workspace`
- roots without a trust decision use `:read-only`
- unsandboxed Windows falls back to `:read-only`

This keeps the existing sandbox semantics while making silent config
defaults observable as built-in permission profiles instead of treating
the legacy `SandboxPolicy` projection as the primary shape.

## What Changed

- Refactored legacy sandbox derivation to resolve the configured sandbox
mode once, then apply the implicit project fallback only when no sandbox
mode was configured.
- Preserved the existing trust-decision fallback: trusted and untrusted
projects default to workspace-write where supported.
- Added empty-config coverage asserting that an untrusted project
resolves to the built-in active permission profile (`:workspace` outside
unsandboxed Windows).

## Verification

- `just fmt`
- `just test -p codex-core 'config::'`
- `just test -p codex-config`

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/25926).
* __->__ #25926
2026-06-02 16:26:36 -07:00
Adam Perry @ OpenAI
6471f8b31a [codex] Fix Windows BuildBuddy Bazel wrapper execution (#25915)
## Why

#25156 moved Bazel CI launches into a shared Python wrapper. On Windows,
launching Bazel with `os.execvp` can split the spaced
`--test_env=PATH=...` argument and fail to propagate the eventual Bazel
exit status, allowing jobs to pass without running tests. This reapplies
the wrapper after #25909 with a Windows-safe launch path.

## What changed

Use a waited `subprocess.run` launch on Windows while preserving
`os.execvp` on Unix. Add a process-level regression test for spaced
arguments and child exit status, and run it on Windows Bazel shard 1.

## Experiment

To confirm Bazel was actually invoking tests, patch `87b61d0be6`
temporarily added an intentionally failing `codex-core` unit test. Bazel
failed on that sentinel on all three major platforms:

- [Linux Bazel
test](https://github.com/openai/codex/actions/runs/26841132773/job/79151062486)
- [macOS Bazel
test](https://github.com/openai/codex/actions/runs/26841132773/job/79151062362)
- [Windows Bazel test shard
1/4](https://github.com/openai/codex/actions/runs/26841132773/job/79151062155)

The sentinel was removed after collecting this evidence. Windows Bazel
[clippy](https://github.com/openai/codex/actions/runs/26841132773/job/79151062914)
and [release
verification](https://github.com/openai/codex/actions/runs/26841132773/job/79151062739)
also passed.

## Validation

After removing the sentinel, `just test -p codex-core` no longer
reported it. The local run retained two unrelated environment-specific
failures.
2026-06-02 16:22:32 -07:00
jif
2d385e166c feat: add skills extension scaffold (#25953)
## Disclaimer
This is only here for iteration purpose! Do not make any code rely on
this

## Why

Skills still live behind `codex-core` discovery and injection paths, but
the extension system needs an authority-aware home before that logic can
move. This adds that boundary without changing current skills behavior,
and keeps host, executor, and remote skills distinct so future
list/read/search flows do not collapse back to ambient local paths.

## What changed

- Add the `codex-skills-extension` workspace/Bazel crate under
`ext/skills`.
- Define the initial catalog, authority, provider, and turn-state types
for authority-bound skill packages and resources.
- Register placeholder thread/config/prompt/turn lifecycle contributors
plus host, executor, and remote provider aggregation points.
- Capture the remaining extraction work as TODOs, including the missing
extension API hooks needed for per-turn catalog construction and typed
skill injection.
- Keep plugins outside the runtime skills model: plugin-installed skills
are treated as materialized host-owned skill sources once available.

## Verification

- Not run locally.
2026-06-03 01:10:26 +02:00
Ahmed Ibrahim
34dc08c214 [codex] Publish Python runtime wheels with Python SDK releases (#25906)
## Summary
- stop publishing Python runtime wheels as a side effect of Rust
releases
- publish runtime wheels from the Python SDK release workflow, either
explicitly before updating the SDK pin or immediately before a
`python-v*` SDK release
- resolve the runtime release from the requested version or the SDK
package's exact `openai-codex-cli-bin` pin
- build two musllinux-tagged wheels from the Rust-release Linux package
archives alongside the six existing runtime wheels
- validate SDK beta tags before any PyPI write

## Release configuration
- update the `openai-codex-cli-bin` PyPI trusted publisher to trust
`.github/workflows/python-sdk-release.yml` and the
`publish-python-runtime` job

## Pin update flow
- run the `python-sdk-release` workflow manually with the new runtime
version before opening or updating the SDK pin PR
- after the pin lands, a `python-v*` SDK tag republishes with
`skip-existing: true` before publishing the SDK package

## Validation
- ran `just fmt`
- validated the edited workflow YAML
- validated the embedded `publish-python-runtime` Bash with `bash -n`
- validated manual `0.136.0 -> rust-v0.136.0` mapping
- validated tag-driven `python-v0.1.0b3 -> 0.132.0 -> rust-v0.132.0`
mapping
- validated rejection of an invalid SDK tag before publication
- confirmed `rust-v0.136.0` contains the two required Linux package
archives
- CI will provide the full test signal
2026-06-02 15:41:53 -07:00
Won Park
bec21c7114 Expose standalone image generation in code mode (#25923)
## Why

Standalone image generation remained top-level-only in code-mode
sessions.

## What changed

- Change imagegen exposure from `DirectModelOnly` to `Direct`.
- Keep direct-mode access while enabling nested code-mode access.
- Add a focused regression test for the exposure contract.

## Validation

- `just test -p codex-image-generation-extension`
2026-06-02 22:27:52 +00:00
Shijie Rao
f752b25fc4 Revert "Use environment secrets for Azure signing" (#25948)
Reverts openai/codex#24859
2026-06-02 15:12:07 -07:00
Michael Bolin
c6d76750e8 config: remove dead profile sandbox fallback (#25943)
## Why

`profile_sandbox_mode` was left over from the old selected legacy
profile path. Production now always derives permissions without that
value, and legacy profile contents are ignored, so keeping a parameter
that is always `None` makes `derive_permission_profile` look like it
still supports a fallback that no longer exists.

## What Changed

- Removed the `profile_sandbox_mode` argument from
`ConfigToml::derive_permission_profile`.
- Updated the production caller and legacy sandbox-policy test helper to
match.
- Dropped the stale unselected legacy-profile sandbox test that only
protected the removed fallback shape.

## Verification

- `just test -p codex-config`
- `just test -p codex-core 'config::'`


---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/25943).
* #25926
* __->__ #25943
2026-06-02 22:05:04 +00:00
jif
d55e5a9bde Add remote request permissions integration coverage (#25867)
## Stack

1. #25850 - Key request-permission grants by environment: stores and
applies sticky permission grants per environment id.
2. #25858 - Add `environmentId` to `request_permissions`: lets the model
target a selected environment and resolves relative permission paths
against it.
3. #25862 - Propagate permission approval environment id: carries the
selected environment id through approval events, app-server requests,
TUI prompts, and delegate forwarding.
4. This PR (#25867) - Add remote request permissions integration
coverage: verifies the selected remote environment across request,
approval, grant reuse, and exec.

This PR is stacked on #25862 and should be reviewed after #25850,
#25858, and #25862.

## Why

The environment-scoped permission stack needs one end-to-end check that
exercises the CCA-shaped path, not only unit-level parsing. This
verifies that a model-sent `environmentId` on `request_permissions`
reaches the approval event, stores the grant under the selected
environment, and is reused by a later tool call in that same
environment.

## What Changed

- Adds a remote executor integration test for `request_permissions` with
`environmentId: remote` and a relative write root.
- Asserts the permission event reports the remote environment and cwd,
and that the normalized grant resolves under the remote cwd.
- Approves the grant, then runs a remote `exec_command` without explicit
per-call permissions and verifies it completes without another exec
approval and writes only in the remote filesystem.

## Verification

- Not run locally per instruction.
- `git diff --check`
2026-06-02 23:55:08 +02:00
Ahmed Ibrahim
68e2c8ed69 [codex] Keep hosted tools visible in code-only mode (#25890)
## Why

`code_mode_only` moved ordinary runtime tools behind `exec`, but it also
hid hosted Responses tools. Hosted `web_search` and `image_generation`
do not have a nested `exec` runtime path, so code-only sessions lost
those capabilities entirely even when their existing provider, auth,
model, and configuration gates passed.

## What changed

- Keep hosted Responses tools top-level in `code_mode_only` sessions
after their existing gates pass.
- Preserve the existing nested-tool behavior for ordinary runtimes and
the direct-only behavior for multi-agent v2 tools.
- Add planner coverage for `code_mode_only` with default multi-agent v2
settings, hosted live web search, and hosted image generation.

## Verification

- Added focused regression coverage in
`codex-rs/core/src/tools/spec_plan_tests.rs`.
- Left execution to CI per repository workflow.
2026-06-02 14:50:16 -07:00
joeflorencio-openai
e7039f9844 Split cloud config bundle service modules (#25668)
## Summary

- Splits the monolithic `codex-cloud-config` implementation into focused
modules.
- Keeps behavior unchanged from the preceding config bundle runtime
switch.

## Details

This is the reviewability follow-up after the lineage-preserving
migration PRs. The split separates backend transport, loader
construction, cache handling, metrics, validation, service
orchestration, and focused tests into named files.

Verification: `just fmt`; `just test -p codex-cloud-config`.
2026-06-02 14:30:12 -07:00
Michael Bolin
f6d64bd6ab core: stop passing legacy SandboxPolicy to guardian reviews (#25911)
## Why

Guardian review turns already submit a read-only `PermissionProfile`,
which is the permissions model the runtime should honor. Passing the
equivalent legacy `SandboxPolicy` through `ThreadSettingsOverrides`
keeps two representations of the same read-only constraint alive on this
path and makes the guardian flow depend on compatibility plumbing that
is being phased out.

## What Changed

- Set `sandbox_policy` to `None` when the guardian review session
submits its child `Op::UserInput`.
- Keep `permission_profile: Some(PermissionProfile::read_only())` and
`approval_policy: Some(AskForApproval::Never)`, so the guardian review
remains read-only and cannot request approvals.
- Remove the now-unused `SandboxPolicy` import and redundant comment
from `codex-rs/core/src/guardian/review_session.rs`.

## Verification

Not run locally; this is a narrow cleanup of redundant thread-settings
override state.
2026-06-02 14:16:12 -07:00
joeflorencio-openai
c74be11672 fix: update image generation test helper rename (#25938)
## Summary
- update the app-server image generation integration test to use
`TestAppServer`
- completes the test helper rename from #25701 for this newer test file

## Validation
- `cargo fmt -- --config imports_granularity=Item`
- `cargo check -p codex-app-server --test all`

Note: `just fmt` ran Rust formatting but failed on Python/SDK formatting
because the sandbox could not access the local `uv` cache.
2026-06-02 14:11:20 -07:00
75 changed files with 5600 additions and 3117 deletions

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.

View File

@@ -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}"

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

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
.github/scripts/run_bazel_with_buildbuddy.py vendored Executable file
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()

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")

View File

@@ -0,0 +1,214 @@
#!/usr/bin/env python3
import json
import os
import subprocess
import sys
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
import run_bazel_with_buildbuddy
class RunBazelWithBuildBuddyTest(unittest.TestCase):
def github_env(
self,
temp_dir: str,
*,
repository: str = "openai/codex",
fork: bool = False,
event_name: str = "pull_request",
) -> dict[str, str]:
event_path = Path(temp_dir) / "event.json"
event_path.write_text(
json.dumps({"pull_request": {"head": {"repo": {"fork": fork}}}}),
encoding="utf-8",
)
return {
"BUILDBUDDY_API_KEY": "token",
"GITHUB_ACTIONS": "true",
"GITHUB_EVENT_NAME": event_name,
"GITHUB_EVENT_PATH": str(event_path),
"GITHUB_REPOSITORY": repository,
}
def test_keyless_invocation_drops_remote_ci_configuration(self) -> None:
self.assertIsNone(
run_bazel_with_buildbuddy.remote_config(
["build", "--config=ci-linux", "//codex-rs/cli:codex"],
{},
)
)
self.assertEqual(
run_bazel_with_buildbuddy.bazel_args_with_remote_config(
["build", "--config=ci-linux", "--", "//codex-rs/cli:codex"],
{},
),
["build", "--", "//codex-rs/cli:codex"],
)
def test_program_arguments_after_separator_do_not_select_or_lose_rbe(self) -> None:
args = ["run", "//codex-rs/cli:codex", "--", "--config=remote"]
self.assertEqual(
run_bazel_with_buildbuddy.bazel_args_with_remote_config(args, {}),
args,
)
self.assertEqual(
run_bazel_with_buildbuddy.remote_config(
args, {"BUILDBUDDY_API_KEY": "fork-token"}
),
"buildbuddy-generic",
)
def test_upstream_push_selects_openai_rbe_before_target_separator(self) -> None:
with TemporaryDirectory() as temp_dir:
env = self.github_env(temp_dir, event_name="push")
self.assertEqual(
run_bazel_with_buildbuddy.bazel_args_with_remote_config(
["build", "--config=ci-linux", "--", "//codex-rs/cli:codex"],
env,
),
[
"build",
"--config=buildbuddy-openai-rbe",
"--remote_header=x-buildbuddy-api-key=token",
"--config=ci-linux",
"--",
"//codex-rs/cli:codex",
],
)
def test_windows_cross_ci_configuration_follows_remote_configuration(self) -> None:
env = {"BUILDBUDDY_API_KEY": "fork-token"}
self.assertEqual(
run_bazel_with_buildbuddy.bazel_args_with_remote_config(
["build", "--config=ci-windows-cross", "//codex-rs/cli:codex"],
env,
),
[
"build",
"--config=buildbuddy-generic-rbe",
"--remote_header=x-buildbuddy-api-key=fork-token",
"--config=ci-windows-cross",
"//codex-rs/cli:codex",
],
)
def test_query_remote_configuration_is_inserted_before_expression(self) -> None:
expression = 'kind("rust_library rule", //codex-rs/...)'
env = {"BUILDBUDDY_API_KEY": "fork-token"}
for command in ("query", "cquery", "aquery"):
with self.subTest(command=command):
self.assertEqual(
run_bazel_with_buildbuddy.bazel_args_with_remote_config(
[
command,
"--config=ci-windows-cross",
"--output=label",
expression,
],
env,
),
[
command,
"--config=buildbuddy-generic-rbe",
"--remote_header=x-buildbuddy-api-key=fork-token",
"--config=ci-windows-cross",
"--output=label",
expression,
],
)
def test_same_repository_pull_request_selects_openai_host(self) -> None:
with TemporaryDirectory() as temp_dir:
self.assertEqual(
run_bazel_with_buildbuddy.remote_config(
["build", "--config=ci-v8"], self.github_env(temp_dir)
),
"buildbuddy-openai-rbe",
)
def test_fork_pull_request_cannot_select_openai_host(self) -> None:
with TemporaryDirectory() as temp_dir:
env = self.github_env(temp_dir, fork=True)
self.assertEqual(
run_bazel_with_buildbuddy.remote_config(
["build", "--config=ci-v8"], env
),
"buildbuddy-generic-rbe",
)
def test_run_in_fork_repository_cannot_select_openai_host(self) -> None:
with TemporaryDirectory() as temp_dir:
env = self.github_env(temp_dir, repository="contributor/codex")
self.assertEqual(
run_bazel_with_buildbuddy.remote_config(
["build", "--config=ci-v8"], env
),
"buildbuddy-generic-rbe",
)
def test_pull_request_without_readable_event_payload_fails_closed(self) -> None:
for event_path in (None, "missing-event.json"):
env = {
"BUILDBUDDY_API_KEY": "token",
"GITHUB_ACTIONS": "true",
"GITHUB_EVENT_NAME": "pull_request",
"GITHUB_REPOSITORY": "openai/codex",
}
if event_path is not None:
env["GITHUB_EVENT_PATH"] = event_path
with self.subTest(event_path=event_path):
self.assertEqual(
run_bazel_with_buildbuddy.remote_config(["build"], env),
"buildbuddy-generic",
)
def test_bazel_command_uses_configured_binary_locally(self) -> None:
self.assertEqual(
run_bazel_with_buildbuddy.bazel_command(
"info",
"execution_root",
env={"CODEX_BAZEL_BIN": "fake-bazel"},
),
["fake-bazel", "info", "execution_root"],
)
def test_main_preserves_spaced_argument_and_child_exit_status(self) -> None:
spaced_arg = (
r"--test_env=PATH=C:\Program Files\PowerShell\7;C:\Program Files\Git\bin"
)
child_code = (
f"import sys; sys.exit(37 if sys.argv[1] == {spaced_arg!r} else 91)"
)
env = os.environ.copy()
env["CODEX_BAZEL_BIN"] = sys.executable
env.pop("BUILDBUDDY_API_KEY", None)
result = subprocess.run(
[
sys.executable,
str(Path(run_bazel_with_buildbuddy.__file__)),
"-c",
child_code,
spaced_arg,
],
env=env,
check=False,
capture_output=True,
text=True,
)
self.assertEqual(result.returncode, 37, result.stderr)
if __name__ == "__main__":
unittest.main()

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")

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

View File

@@ -4,15 +4,161 @@ on:
push:
tags:
- "python-v*"
workflow_dispatch:
inputs:
runtime_version:
description: "Runtime version to publish before updating the SDK pin, for example 0.136.0 or 0.136.0a2."
required: true
type: string
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
jobs:
build-python-sdk:
# Publish the platform-specific Python runtime wheels before building the SDK
# package that pins them, or explicitly before updating the SDK runtime pin.
# PyPI project configuration must trust this workflow and job for publishing.
publish-python-runtime:
if: github.repository == 'openai/codex'
name: publish-python-runtime
runs-on: ubuntu-latest
environment: pypi
permissions:
contents: read
id-token: write # Required for PyPI trusted publishing.
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Validate SDK tag and resolve Python runtime release
id: python_runtime
shell: bash
env:
REQUESTED_RUNTIME_VERSION: ${{ inputs.runtime_version }}
run: |
set -euo pipefail
python3 - <<'PY'
import os
import re
import tomllib
from pathlib import Path
event_name = os.environ["GITHUB_EVENT_NAME"]
if event_name == "workflow_dispatch":
python_version = os.environ["REQUESTED_RUNTIME_VERSION"]
elif event_name == "push":
sdk_version = os.environ["GITHUB_REF_NAME"].removeprefix("python-v")
if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+b[0-9]+", sdk_version):
raise SystemExit(
"Python SDK release tags must identify a beta release, "
"for example python-v0.1.0b1."
)
pyproject = tomllib.loads(Path("sdk/python/pyproject.toml").read_text())
prefix = "openai-codex-cli-bin=="
versions = [
dependency.removeprefix(prefix)
for dependency in pyproject["project"]["dependencies"]
if dependency.startswith(prefix)
]
if len(versions) != 1:
raise SystemExit(f"Expected exactly one pinned {prefix} dependency, found {versions}")
python_version = versions[0]
else:
raise SystemExit(f"Unsupported workflow event: {event_name}")
if match := re.fullmatch(r"([0-9]+\.[0-9]+\.[0-9]+)a([0-9]+)", python_version):
release_version = f"{match.group(1)}-alpha.{match.group(2)}"
elif re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", python_version):
release_version = python_version
else:
raise SystemExit(
"Python runtime version must be stable or a numbered alpha, "
f"for example 0.136.0 or 0.136.0a2; found {python_version}"
)
with Path(os.environ["GITHUB_OUTPUT"]).open("a") as output:
print(f"python_version={python_version}", file=output)
print(f"release_tag=rust-v{release_version}", file=output)
PY
- name: Download Python runtime release artifacts
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PYTHON_RUNTIME_VERSION: ${{ steps.python_runtime.outputs.python_version }}
RELEASE_TAG: ${{ steps.python_runtime.outputs.release_tag }}
run: |
set -euo pipefail
mkdir -p dist/python-runtime dist/python-runtime-packages
gh release download "$RELEASE_TAG" \
--repo "${GITHUB_REPOSITORY}" \
--pattern "openai_codex_cli_bin-${PYTHON_RUNTIME_VERSION}-*.whl" \
--dir dist/python-runtime
gh release download "$RELEASE_TAG" \
--repo "${GITHUB_REPOSITORY}" \
--pattern "codex-package-*-unknown-linux-musl.tar.gz" \
--dir dist/python-runtime-packages
shopt -s nullglob
wheels=(dist/python-runtime/*.whl)
if [[ "${#wheels[@]}" -ne 6 ]]; then
echo "Expected 6 Python runtime wheels for ${PYTHON_RUNTIME_VERSION}, found ${#wheels[@]}."
exit 1
fi
packages=(dist/python-runtime-packages/*.tar.gz)
if [[ "${#packages[@]}" -ne 2 ]]; then
echo "Expected 2 Linux package archives for ${PYTHON_RUNTIME_VERSION}, found ${#packages[@]}."
exit 1
fi
- name: Build musllinux Python runtime wheels
env:
RELEASE_TAG: ${{ steps.python_runtime.outputs.release_tag }}
run: |
set -euo pipefail
python3 -m venv "${RUNNER_TEMP}/python-runtime-build-venv"
"${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m pip install build
while read -r target platform_tag; do
stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${target}-${platform_tag}"
python3 sdk/python/scripts/update_sdk_artifacts.py \
stage-runtime \
"$stage_dir" \
"dist/python-runtime-packages/codex-package-${target}.tar.gz" \
--codex-version "$RELEASE_TAG" \
--platform-tag "$platform_tag"
"${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build \
--wheel \
--outdir dist/python-runtime \
"$stage_dir"
done <<'EOF'
aarch64-unknown-linux-musl musllinux_1_1_aarch64
x86_64-unknown-linux-musl musllinux_1_1_x86_64
EOF
shopt -s nullglob
wheels=(dist/python-runtime/*.whl)
if [[ "${#wheels[@]}" -ne 8 ]]; then
echo "Expected 8 Python runtime wheels, found ${#wheels[@]}."
exit 1
fi
ls -lh dist/python-runtime
- name: Publish Python runtime wheels to PyPI
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
with:
packages-dir: dist/python-runtime
skip-existing: true
build-python-sdk:
if: github.event_name == 'push' && github.repository == 'openai/codex'
name: build-python-sdk
needs: publish-python-runtime
runs-on: ubuntu-latest
permissions:
contents: read
@@ -29,11 +175,6 @@ jobs:
set -euo pipefail
sdk_version="${GITHUB_REF_NAME#python-v}"
if [[ ! "${sdk_version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+b[0-9]+$ ]]; then
echo "Python SDK release tags must identify a beta release, for example python-v0.1.0b1."
exit 1
fi
# Build in a glibc Linux image so release type generation installs
# the pinned manylinux runtime wheel.
docker run --rm \

View File

@@ -6,6 +6,19 @@ on:
release-lto:
required: true
type: string
secrets:
AZURE_TRUSTED_SIGNING_CLIENT_ID:
required: true
AZURE_TRUSTED_SIGNING_TENANT_ID:
required: true
AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID:
required: true
AZURE_TRUSTED_SIGNING_ENDPOINT:
required: true
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME:
required: true
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME:
required: true
# Cargo's libgit2 transport has been flaky when fetching git dependencies with
# nested submodules. Prefer the system git CLI across every Cargo invocation.
@@ -26,8 +39,6 @@ jobs:
working-directory: codex-rs
env:
CARGO_PROFILE_RELEASE_LTO: ${{ inputs.release-lto }}
CARGO_PROFILE_RELEASE_DEBUG: full
CARGO_PROFILE_RELEASE_STRIP: "false"
strategy:
fail-fast: false
@@ -121,22 +132,10 @@ jobs:
- name: Stage Windows binaries
shell: bash
run: |
release_dir="target/${{ matrix.target }}/release"
output_dir="$release_dir/staged-${{ matrix.bundle }}"
output_dir="target/${{ matrix.target }}/release/staged-${{ matrix.bundle }}"
mkdir -p "$output_dir"
for binary in ${{ matrix.binaries }}; do
pdb_name="${binary//-/_}"
pdb_path="$release_dir/${pdb_name}.pdb"
if [[ ! -f "$pdb_path" ]]; then
pdb_path="$release_dir/${binary}.pdb"
fi
if [[ ! -f "$pdb_path" ]]; then
echo "PDB for $binary not found at $release_dir/${pdb_name}.pdb or $release_dir/${binary}.pdb" >&2
exit 1
fi
cp "$release_dir/${binary}.exe" "$output_dir/${binary}.exe"
cp "$pdb_path" "$output_dir/${binary}.pdb"
cp "target/${{ matrix.target }}/release/${binary}.exe" "$output_dir/${binary}.exe"
done
- name: Upload Windows binaries
@@ -151,9 +150,6 @@ jobs:
- build-windows-binaries
name: Build - ${{ matrix.runner }} - ${{ matrix.target }}
runs-on: ${{ matrix.runs_on }}
environment:
name: azure-artifact-signing
deployment: false
timeout-minutes: 90
permissions:
contents: read
@@ -222,23 +218,6 @@ jobs:
account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }}
- name: Build symbols archive
shell: bash
run: |
bash "${GITHUB_WORKSPACE}/.github/scripts/archive-release-symbols-and-strip-binaries.sh" \
--target "${{ matrix.target }}" \
--artifact-name "${{ matrix.target }}" \
--release-dir "target/${{ matrix.target }}/release" \
--archive-dir "symbols-dist/${{ matrix.target }}" \
--binaries "${WINDOWS_BINARIES}"
- name: Upload symbols archive
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: ${{ matrix.target }}-symbols
path: codex-rs/symbols-dist/${{ matrix.target }}/*
if-no-files-found: error
- name: Stage artifacts
shell: bash
run: |

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
@@ -865,6 +827,7 @@ jobs:
uses: ./.github/workflows/rust-release-windows.yml
with:
release-lto: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'fat' }}
secrets: inherit
argument-comment-lint-release-assets:
if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }}
@@ -928,7 +891,6 @@ jobs:
sign_macos: ${{ steps.release_mode.outputs.sign_macos }}
should_publish_npm: ${{ steps.npm_publish_settings.outputs.should_publish }}
npm_tag: ${{ steps.npm_publish_settings.outputs.npm_tag }}
should_publish_python_runtime: ${{ steps.python_runtime_publish_settings.outputs.should_publish }}
steps:
- name: Checkout repository
@@ -1068,7 +1030,6 @@ jobs:
run: |
find dist -mindepth 1 -maxdepth 1 -type d \
! -name '*-apple-darwin*-unsigned' \
! -name '*-symbols' \
! -name 'aarch64-unknown-linux-musl' \
! -name 'aarch64-unknown-linux-musl-app-server' \
! -name 'x86_64-unknown-linux-musl' \
@@ -1153,27 +1114,6 @@ jobs:
echo "npm_tag=" >> "$GITHUB_OUTPUT"
fi
- name: Determine Python runtime publish settings
id: python_runtime_publish_settings
env:
VERSION: ${{ steps.release_name.outputs.name }}
run: |
set -euo pipefail
version="${VERSION}"
if [[ "${SIGN_MACOS}" != "true" ]]; then
echo "should_publish=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "should_publish=true" >> "$GITHUB_OUTPUT"
elif [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then
echo "should_publish=true" >> "$GITHUB_OUTPUT"
else
echo "should_publish=false" >> "$GITHUB_OUTPUT"
fi
- name: Setup pnpm
if: ${{ env.SIGN_MACOS == 'true' }}
uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5
@@ -1438,53 +1378,6 @@ jobs:
exit "${publish_status}"
done
# Publish the platform-specific Python runtime wheels using PyPI trusted publishing.
# PyPI project configuration must trust this workflow and job. Keep this
# non-blocking while the Python runtime publishing path is new; failures still
# need release follow-up, but should not invalidate the Rust release itself.
publish-python-runtime:
# Publish to PyPI for stable releases and alpha pre-releases with numeric suffixes.
if: >-
${{
!cancelled() &&
needs.release.result == 'success' &&
needs.release.outputs.should_publish_python_runtime == 'true'
}}
name: publish-python-runtime
needs: release
runs-on: ubuntu-latest
continue-on-error: true
environment: pypi
permissions:
id-token: write # Required for PyPI trusted publishing.
contents: read
steps:
- name: Download Python runtime wheels from release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ needs.release.outputs.tag }}
RELEASE_VERSION: ${{ needs.release.outputs.version }}
run: |
set -euo pipefail
python_version="$RELEASE_VERSION"
python_version="${python_version/-alpha./a}"
python_version="${python_version/-beta./b}"
python_version="${python_version/-rc./rc}"
mkdir -p dist/python-runtime
gh release download "$RELEASE_TAG" \
--repo "${GITHUB_REPOSITORY}" \
--pattern "openai_codex_cli_bin-${python_version}-*.whl" \
--dir dist/python-runtime
ls -lh dist/python-runtime
- name: Publish Python runtime wheels to PyPI
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
with:
packages-dir: dist/python-runtime
skip-existing: true
deploy-dev-website:
name: Trigger developers.openai.com deploy
needs: release

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:

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:

10
codex-rs/Cargo.lock generated
View File

@@ -2347,7 +2347,6 @@ dependencies = [
name = "codex-cloud-config"
version = "0.0.0"
dependencies = [
"async-trait",
"base64 0.22.1",
"chrono",
"codex-backend-client",
@@ -3716,6 +3715,15 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "codex-skills-extension"
version = "0.0.0"
dependencies = [
"async-trait",
"codex-core",
"codex-extension-api",
]
[[package]]
name = "codex-state"
version = "0.0.0"

View File

@@ -48,6 +48,7 @@ members = [
"ext/guardian",
"ext/image-generation",
"ext/memories",
"ext/skills",
"ext/web-search",
"external-agent-migration",
"external-agent-sessions",

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());
}

View File

@@ -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]

View File

@@ -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;

View File

@@ -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!({})
);
}

View File

@@ -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();
}
}

View File

@@ -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()?,
})
}
}

View File

@@ -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,

View File

@@ -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,
},

View File

@@ -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)]

View File

@@ -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,
}

View File

@@ -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:"));
}

View File

@@ -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;

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**).

View File

@@ -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()

View File

@@ -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;

View File

@@ -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(),
}
);
}

View File

@@ -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)?);

View File

@@ -30,6 +30,12 @@ use wiremock::matchers::path;
const RESULT: &str = "cG5n";
#[derive(Clone, Copy)]
enum ImagegenTestMode {
Direct,
CodeModeOnly,
}
// macOS and Windows Bazel CI can spend tens of seconds starting app-server
// subprocesses or processing test RPCs under load.
#[cfg(any(target_os = "macos", windows))]
@@ -69,7 +75,7 @@ async fn standalone_image_generation_persists_image_and_returns_it_to_model() ->
.await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
create_config_toml(codex_home.path(), &server.uri(), ImagegenTestMode::Direct)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("access-chatgpt"),
@@ -79,34 +85,7 @@ async fn standalone_image_generation_persists_image_and_returns_it_to_model() ->
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let thread_req = mcp
.send_thread_start_request(ThreadStartParams::default())
.await?;
let thread_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id,
client_user_message_id: None,
input: vec![V2UserInput::Text {
text: "Generate an image".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
let turn_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
)
.await??;
let _turn: TurnStartResponse = to_response::<TurnStartResponse>(turn_resp)?;
start_image_generation_turn(&mut mcp).await?;
let completed = timeout(
DEFAULT_READ_TIMEOUT,
@@ -157,6 +136,110 @@ async fn standalone_image_generation_persists_image_and_returns_it_to_model() ->
Ok(())
}
#[cfg_attr(windows, ignore = "covered by Linux and macOS CI")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn standalone_image_generation_is_callable_from_code_mode_only() -> Result<()> {
let call_id = "code-mode-image-run-1";
let server = responses::start_mock_server().await;
mount_image_response(&server).await;
let response_mock = responses::mount_sse_sequence(
&server,
vec![
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_custom_tool_call(
call_id,
"exec",
r#"
const result = await tools.image_gen__imagegen({
action: "generate",
prompt: "paint a blue whale",
});
image(result);
"#,
),
responses::ev_completed("resp-1"),
]),
responses::sse(vec![
responses::ev_assistant_message("msg-1", "Done"),
responses::ev_completed("resp-2"),
]),
],
)
.await;
let codex_home = TempDir::new()?;
create_config_toml(
codex_home.path(),
&server.uri(),
ImagegenTestMode::CodeModeOnly,
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("access-chatgpt"),
AuthCredentialsStoreMode::File,
)?;
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
start_image_generation_turn(&mut mcp).await?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let requests = response_mock.requests();
assert_eq!(requests.len(), 2);
assert!(requests[0].body_contains_text("image_gen__imagegen"));
let output = requests[1].custom_tool_call_output(call_id);
assert_eq!(
output["output"][1],
json!({
"type": "input_image",
"image_url": format!("data:image/png;base64,{RESULT}"),
"detail": "high",
})
);
assert_eq!(output["output"].as_array().map(Vec::len), Some(2));
Ok(())
}
async fn start_image_generation_turn(mcp: &mut TestAppServer) -> Result<()> {
let thread_req = mcp
.send_thread_start_request(ThreadStartParams::default())
.await?;
let thread_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id,
client_user_message_id: None,
input: vec![V2UserInput::Text {
text: "Generate an image".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
let turn_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
)
.await??;
let _turn: TurnStartResponse = to_response::<TurnStartResponse>(turn_resp)?;
Ok(())
}
async fn wait_for_image_generation_completed(
mcp: &mut TestAppServer,
) -> Result<ItemCompletedNotification> {
@@ -187,7 +270,15 @@ async fn mount_image_response(server: &MockServer) {
.await;
}
fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> {
fn create_config_toml(
codex_home: &Path,
server_uri: &str,
mode: ImagegenTestMode,
) -> std::io::Result<()> {
let code_mode_only = match mode {
ImagegenTestMode::Direct => "",
ImagegenTestMode::CodeModeOnly => "code_mode_only = true",
};
std::fs::write(
codex_home.join("config.toml"),
format!(
@@ -200,6 +291,7 @@ chatgpt_base_url = "{server_uri}"
[features]
imagegenext = true
{code_mode_only}
[model_providers.openai-custom]
name = "OpenAI"

View File

@@ -17,7 +17,7 @@ mod experimental_feature_list;
mod external_agent_config;
mod fs;
mod hooks_list;
mod image_generation;
mod imagegen_extension;
mod initialize;
mod marketplace_add;
mod marketplace_remove;

View File

@@ -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(())
}

View File

@@ -8,7 +8,6 @@ license.workspace = true
workspace = true
[dependencies]
async-trait = { workspace = true }
base64 = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
codex-backend-client = { workspace = true }

View File

@@ -0,0 +1,136 @@
use codex_backend_client::Client as BackendClient;
use codex_backend_client::ConfigBundleResponse;
use codex_backend_client::DeliveredTomlFragment;
use codex_config::CloudConfigBundle;
use codex_config::CloudConfigFragment;
use codex_config::CloudConfigTomlBundle;
use codex_config::CloudRequirementsFragment;
use codex_config::CloudRequirementsTomlBundle;
use codex_login::CodexAuth;
use std::future::Future;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum RetryableFailureKind {
BackendClientInit,
Request { status_code: Option<u16> },
}
impl RetryableFailureKind {
pub(crate) fn status_code(self) -> Option<u16> {
match self {
Self::BackendClientInit => None,
Self::Request { status_code } => status_code,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum BundleRequestError {
Retryable(RetryableFailureKind),
Unauthorized {
status_code: Option<u16>,
message: String,
},
}
/// Retrieves one cloud config bundle from the backend.
///
/// Implementations should return the backend-selected bundle exactly as delivered and leave
/// validation, caching, and config/requirements parsing decisions to the service layer.
pub(crate) trait BundleClient: Send + Sync {
fn get_bundle(
&self,
auth: &CodexAuth,
) -> impl Future<Output = Result<CloudConfigBundle, BundleRequestError>> + Send;
}
pub(crate) struct BackendBundleClient {
base_url: String,
}
impl BackendBundleClient {
pub(crate) fn new(base_url: String) -> Self {
Self { base_url }
}
}
impl BundleClient for BackendBundleClient {
async fn get_bundle(&self, auth: &CodexAuth) -> Result<CloudConfigBundle, BundleRequestError> {
let client = BackendClient::from_auth(self.base_url.clone(), auth)
.inspect_err(|err| {
tracing::warn!(
error = %err,
"Failed to construct backend client for cloud config bundle"
);
})
.map_err(|_| BundleRequestError::Retryable(RetryableFailureKind::BackendClientInit))?;
let response = client
.get_config_bundle()
.await
.inspect_err(|err| {
tracing::warn!(error = %err, "Failed to fetch cloud config bundle");
})
.map_err(|err| {
let status_code = err.status().map(|status| status.as_u16());
if err.is_unauthorized() {
BundleRequestError::Unauthorized {
status_code,
message: err.to_string(),
}
} else {
BundleRequestError::Retryable(RetryableFailureKind::Request { status_code })
}
})?;
Ok(bundle_from_response(response))
}
}
pub(crate) fn bundle_from_response(response: ConfigBundleResponse) -> CloudConfigBundle {
let config_toml = response
.config_toml
.flatten()
.map(|config_toml| *config_toml)
.and_then(|config_toml| config_toml.enterprise_managed.flatten())
.unwrap_or_default()
.into_iter()
.map(config_fragment_from_delivered)
.collect();
let requirements_toml = response
.requirements_toml
.flatten()
.map(|requirements_toml| *requirements_toml)
.and_then(|requirements_toml| requirements_toml.enterprise_managed.flatten())
.unwrap_or_default()
.into_iter()
.map(requirements_fragment_from_delivered)
.collect();
CloudConfigBundle {
config_toml: CloudConfigTomlBundle {
enterprise_managed: config_toml,
},
requirements_toml: CloudRequirementsTomlBundle {
enterprise_managed: requirements_toml,
},
}
}
fn config_fragment_from_delivered(fragment: DeliveredTomlFragment) -> CloudConfigFragment {
CloudConfigFragment {
id: fragment.id,
name: fragment.name,
contents: fragment.contents,
}
}
fn requirements_fragment_from_delivered(
fragment: DeliveredTomlFragment,
) -> CloudRequirementsFragment {
CloudRequirementsFragment {
id: fragment.id,
name: fragment.name,
contents: fragment.contents,
}
}

View File

@@ -0,0 +1,68 @@
use crate::backend::BackendBundleClient;
use crate::service::CLOUD_CONFIG_BUNDLE_TIMEOUT;
use crate::service::CloudConfigBundleService;
use codex_config::CloudConfigBundleLoadError;
use codex_config::CloudConfigBundleLoadErrorCode;
use codex_config::CloudConfigBundleLoader;
use codex_config::types::AuthCredentialsStoreMode;
use codex_login::AuthManager;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::OnceLock;
use tokio::task::JoinHandle;
fn refresher_task_slot() -> &'static Mutex<Option<JoinHandle<()>>> {
static REFRESHER_TASK: OnceLock<Mutex<Option<JoinHandle<()>>>> = OnceLock::new();
REFRESHER_TASK.get_or_init(|| Mutex::new(None))
}
pub fn cloud_config_bundle_loader(
auth_manager: Arc<AuthManager>,
chatgpt_base_url: String,
codex_home: PathBuf,
) -> CloudConfigBundleLoader {
let service = CloudConfigBundleService::new(
auth_manager,
Arc::new(BackendBundleClient::new(chatgpt_base_url)),
codex_home,
CLOUD_CONFIG_BUNDLE_TIMEOUT,
);
let refresh_service = service.clone();
let task = tokio::spawn(async move { service.load_startup_bundle_with_timeout().await });
let refresh_task =
tokio::spawn(async move { refresh_service.refresh_cache_in_background().await });
let mut refresher_guard = refresher_task_slot().lock().unwrap_or_else(|err| {
tracing::warn!("cloud config bundle refresher task slot was poisoned");
err.into_inner()
});
if let Some(existing_task) = refresher_guard.replace(refresh_task) {
existing_task.abort();
}
CloudConfigBundleLoader::new(async move {
task.await.map_err(|err| {
tracing::error!(error = %err, "Cloud config bundle task failed");
CloudConfigBundleLoadError::new(
CloudConfigBundleLoadErrorCode::Internal,
/*status_code*/ None,
format!("cloud config bundle load failed: {err}"),
)
})?
})
}
pub async fn cloud_config_bundle_loader_for_storage(
codex_home: PathBuf,
enable_codex_api_key_env: bool,
credentials_store_mode: AuthCredentialsStoreMode,
chatgpt_base_url: String,
) -> CloudConfigBundleLoader {
let auth_manager = AuthManager::shared(
codex_home.clone(),
enable_codex_api_key_env,
credentials_store_mode,
Some(chatgpt_base_url.clone()),
)
.await;
cloud_config_bundle_loader(auth_manager, chatgpt_base_url, codex_home)
}

View File

@@ -0,0 +1,253 @@
//! Signed on-disk cache for cloud config bundles.
//!
//! The cache is scoped to the authenticated ChatGPT user and account, has a
//! short TTL, and is HMAC-signed so malformed or edited files fail closed.
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use chrono::DateTime;
use chrono::Duration as ChronoDuration;
use chrono::Utc;
use codex_config::AbsolutePathBuf;
use codex_config::CloudConfigBundle;
use hmac::Hmac;
use hmac::Mac;
use serde::Deserialize;
use serde::Serialize;
use sha2::Sha256;
use std::path::Path;
use std::time::Duration;
use thiserror::Error;
use tokio::fs;
const CLOUD_CONFIG_BUNDLE_CACHE_VERSION: u32 = 1;
pub(super) const CLOUD_CONFIG_BUNDLE_CACHE_FILENAME: &str = "cloud-config-bundle-cache.json";
const CLOUD_CONFIG_BUNDLE_CACHE_TTL: Duration = Duration::from_secs(30 * 60);
const CLOUD_CONFIG_BUNDLE_CACHE_WRITE_HMAC_KEY: &[u8] =
b"codex-cloud-config-bundle-cache-v1-6160ae70-bcfd-4ca8-a99b-40f73b3b072e";
const CLOUD_CONFIG_BUNDLE_CACHE_READ_HMAC_KEYS: &[&[u8]] =
&[CLOUD_CONFIG_BUNDLE_CACHE_WRITE_HMAC_KEY];
type HmacSha256 = Hmac<Sha256>;
#[derive(Clone)]
pub(super) struct CloudConfigBundleCache {
path: AbsolutePathBuf,
}
impl CloudConfigBundleCache {
pub(super) fn new(codex_home: AbsolutePathBuf) -> Self {
Self {
path: codex_home.join(CLOUD_CONFIG_BUNDLE_CACHE_FILENAME),
}
}
pub(super) fn path(&self) -> &Path {
&self.path
}
pub(super) async fn load(
&self,
chatgpt_user_id: Option<&str>,
account_id: Option<&str>,
) -> Result<CloudConfigBundleCacheSignedPayload, CacheLoadStatus> {
let (Some(chatgpt_user_id), Some(account_id)) = (chatgpt_user_id, account_id) else {
return Err(CacheLoadStatus::AuthIdentityIncomplete);
};
let bytes = match fs::read(&self.path).await {
Ok(bytes) => bytes,
Err(err) => {
if err.kind() != std::io::ErrorKind::NotFound {
return Err(CacheLoadStatus::CacheReadFailed(err.to_string()));
}
return Err(CacheLoadStatus::CacheFileNotFound);
}
};
let cache_file: CloudConfigBundleCacheFile = match serde_json::from_slice(&bytes) {
Ok(cache_file) => cache_file,
Err(err) => {
return Err(CacheLoadStatus::CacheParseFailed(err.to_string()));
}
};
let payload_bytes = match cache_payload_bytes(&cache_file.signed_payload) {
Some(payload_bytes) => payload_bytes,
None => {
return Err(CacheLoadStatus::CacheParseFailed(
"failed to serialize cache payload".to_string(),
));
}
};
if !verify_cache_signature(&payload_bytes, &cache_file.signature) {
return Err(CacheLoadStatus::CacheSignatureInvalid);
}
if cache_file.signed_payload.version != CLOUD_CONFIG_BUNDLE_CACHE_VERSION {
return Err(CacheLoadStatus::CacheVersionUnsupported(
cache_file.signed_payload.version,
));
}
let (Some(cached_chatgpt_user_id), Some(cached_account_id)) = (
cache_file.signed_payload.chatgpt_user_id.as_deref(),
cache_file.signed_payload.account_id.as_deref(),
) else {
return Err(CacheLoadStatus::CacheIdentityIncomplete);
};
if cached_chatgpt_user_id != chatgpt_user_id || cached_account_id != account_id {
return Err(CacheLoadStatus::CacheIdentityMismatch);
}
if cache_file.signed_payload.expires_at <= Utc::now() {
return Err(CacheLoadStatus::CacheExpired);
}
Ok(cache_file.signed_payload)
}
pub(super) fn log_load_status(&self, status: &CacheLoadStatus) {
if matches!(status, CacheLoadStatus::CacheFileNotFound) {
return;
}
let warn = matches!(
status,
CacheLoadStatus::CacheReadFailed(_)
| CacheLoadStatus::CacheParseFailed(_)
| CacheLoadStatus::CacheSignatureInvalid
);
if warn {
tracing::warn!(path = %self.path.display(), "{status}");
} else {
tracing::info!(path = %self.path.display(), "{status}");
}
}
pub(super) async fn save(
&self,
chatgpt_user_id: Option<String>,
account_id: Option<String>,
bundle: CloudConfigBundle,
) -> Result<(), CloudConfigBundleCacheError> {
let now = Utc::now();
let expires_at = now
.checked_add_signed(
ChronoDuration::from_std(CLOUD_CONFIG_BUNDLE_CACHE_TTL)
.map_err(|_| CloudConfigBundleCacheError)?,
)
.ok_or(CloudConfigBundleCacheError)?;
let signed_payload = CloudConfigBundleCacheSignedPayload {
version: CLOUD_CONFIG_BUNDLE_CACHE_VERSION,
cached_at: now,
expires_at,
chatgpt_user_id,
account_id,
bundle,
};
let payload_bytes =
cache_payload_bytes(&signed_payload).ok_or(CloudConfigBundleCacheError)?;
let serialized = serde_json::to_vec_pretty(&CloudConfigBundleCacheFile {
signature: sign_cache_payload(&payload_bytes).ok_or(CloudConfigBundleCacheError)?,
signed_payload,
})
.map_err(|_| CloudConfigBundleCacheError)?;
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent)
.await
.map_err(|_| CloudConfigBundleCacheError)?;
}
fs::write(&self.path, serialized)
.await
.map_err(|_| CloudConfigBundleCacheError)?;
Ok(())
}
}
#[derive(Clone, Debug, Eq, Error, PartialEq)]
pub(super) enum CacheLoadStatus {
#[error("Skipping cloud config bundle cache read because auth identity is incomplete.")]
AuthIdentityIncomplete,
#[error("Cloud config bundle cache file not found.")]
CacheFileNotFound,
#[error("Failed to read cloud config bundle cache: {0}.")]
CacheReadFailed(String),
#[error("Failed to parse cloud config bundle cache: {0}.")]
CacheParseFailed(String),
#[error("Cloud config bundle cache failed signature verification.")]
CacheSignatureInvalid,
#[error("Ignoring cloud config bundle cache because cached identity is incomplete.")]
CacheIdentityIncomplete,
#[error("Ignoring cloud config bundle cache for different auth identity.")]
CacheIdentityMismatch,
#[error("Ignoring cloud config bundle cache with unsupported version {0}.")]
CacheVersionUnsupported(u32),
#[error("Cloud config bundle cache expired.")]
CacheExpired,
#[error("Ignoring cloud config bundle cache because the cached bundle is invalid.")]
CacheInvalidBundle,
}
#[derive(Debug, Error)]
#[error("failed to write cloud config bundle cache")]
pub(super) struct CloudConfigBundleCacheError;
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub(super) struct CloudConfigBundleCacheFile {
pub(super) signed_payload: CloudConfigBundleCacheSignedPayload,
pub(super) signature: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub(super) struct CloudConfigBundleCacheSignedPayload {
pub(super) version: u32,
pub(super) cached_at: DateTime<Utc>,
pub(super) expires_at: DateTime<Utc>,
pub(super) chatgpt_user_id: Option<String>,
pub(super) account_id: Option<String>,
pub(super) bundle: CloudConfigBundle,
}
pub(super) fn cache_payload_bytes(
payload: &CloudConfigBundleCacheSignedPayload,
) -> Option<Vec<u8>> {
serde_json::to_vec(&payload).ok()
}
pub(super) fn sign_cache_payload(payload_bytes: &[u8]) -> Option<String> {
let mut mac = HmacSha256::new_from_slice(CLOUD_CONFIG_BUNDLE_CACHE_WRITE_HMAC_KEY).ok()?;
mac.update(payload_bytes);
let signature = mac.finalize().into_bytes();
Some(BASE64_STANDARD.encode(signature))
}
pub(super) fn verify_cache_signature(payload_bytes: &[u8], signature: &str) -> bool {
let signature_bytes = match BASE64_STANDARD.decode(signature) {
Ok(signature_bytes) => signature_bytes,
Err(_) => return false,
};
CLOUD_CONFIG_BUNDLE_CACHE_READ_HMAC_KEYS
.iter()
.any(|key| verify_cache_signature_with_key(payload_bytes, &signature_bytes, key))
}
fn verify_cache_signature_with_key(
payload_bytes: &[u8],
signature_bytes: &[u8],
key: &[u8],
) -> bool {
let mut mac = match HmacSha256::new_from_slice(key) {
Ok(mac) => mac,
Err(_) => return false,
};
mac.update(payload_bytes);
mac.verify_slice(signature_bytes).is_ok()
}
#[cfg(test)]
#[path = "cache_tests.rs"]
mod tests;

View File

@@ -0,0 +1,206 @@
use super::*;
use codex_config::AbsolutePathBuf;
use codex_config::CloudConfigFragment;
use codex_config::CloudConfigTomlBundle;
use codex_config::CloudRequirementsFragment;
use codex_config::CloudRequirementsTomlBundle;
use pretty_assertions::assert_eq;
use std::path::Path;
use tempfile::tempdir;
fn test_bundle() -> CloudConfigBundle {
CloudConfigBundle {
config_toml: CloudConfigTomlBundle {
enterprise_managed: vec![CloudConfigFragment {
id: "cfg_1".to_string(),
name: "Base config".to_string(),
contents: "model = \"gpt-5\"".to_string(),
}],
},
requirements_toml: CloudRequirementsTomlBundle {
enterprise_managed: vec![CloudRequirementsFragment {
id: "req_1".to_string(),
name: "Base requirements".to_string(),
contents: "allowed_approval_policies = [\"never\"]".to_string(),
}],
},
}
}
fn signed_cache_file(
signed_payload: CloudConfigBundleCacheSignedPayload,
) -> CloudConfigBundleCacheFile {
let payload_bytes = cache_payload_bytes(&signed_payload).expect("payload bytes");
CloudConfigBundleCacheFile {
signature: sign_cache_payload(&payload_bytes).expect("signature"),
signed_payload,
}
}
fn valid_signed_payload() -> CloudConfigBundleCacheSignedPayload {
let cached_at = Utc::now();
CloudConfigBundleCacheSignedPayload {
version: CLOUD_CONFIG_BUNDLE_CACHE_VERSION,
cached_at,
expires_at: cached_at + ChronoDuration::minutes(30),
chatgpt_user_id: Some("user-12345".to_string()),
account_id: Some("account-12345".to_string()),
bundle: test_bundle(),
}
}
fn write_cache_file(cache: &CloudConfigBundleCache, cache_file: &CloudConfigBundleCacheFile) {
std::fs::write(
cache.path(),
serde_json::to_vec_pretty(cache_file).expect("serialize cache"),
)
.expect("write cache");
}
fn create_test_cache(codex_home: &Path) -> CloudConfigBundleCache {
CloudConfigBundleCache::new(AbsolutePathBuf::resolve_path_against_base(codex_home, "/"))
}
#[tokio::test]
async fn save_writes_signed_payload_and_loads_for_matching_identity() {
let codex_home = tempdir().expect("tempdir");
let cache = create_test_cache(codex_home.path());
let bundle = test_bundle();
cache
.save(
Some("user-12345".to_string()),
Some("account-12345".to_string()),
bundle.clone(),
)
.await
.expect("save cache");
let cache_file: CloudConfigBundleCacheFile =
serde_json::from_slice(&std::fs::read(cache.path()).expect("read cache"))
.expect("parse cache");
assert!(
cache_file.signed_payload.expires_at
<= cache_file.signed_payload.cached_at + ChronoDuration::minutes(30)
);
assert!(cache_file.signed_payload.expires_at > cache_file.signed_payload.cached_at);
assert_eq!(
cache_file,
signed_cache_file(CloudConfigBundleCacheSignedPayload {
version: CLOUD_CONFIG_BUNDLE_CACHE_VERSION,
cached_at: cache_file.signed_payload.cached_at,
expires_at: cache_file.signed_payload.expires_at,
chatgpt_user_id: Some("user-12345".to_string()),
account_id: Some("account-12345".to_string()),
bundle,
})
);
assert_eq!(
cache.load(Some("user-12345"), Some("account-12345")).await,
Ok(cache_file.signed_payload)
);
}
#[tokio::test]
async fn load_rejects_missing_request_identity_before_reading_cache_file() {
let codex_home = tempdir().expect("tempdir");
let cache = create_test_cache(codex_home.path());
assert_eq!(
cache
.load(/*chatgpt_user_id*/ None, Some("account-12345"))
.await,
Err(CacheLoadStatus::AuthIdentityIncomplete)
);
assert_eq!(
cache.load(Some("user-12345"), /*account_id*/ None).await,
Err(CacheLoadStatus::AuthIdentityIncomplete)
);
}
#[tokio::test]
async fn load_reports_missing_and_malformed_cache_files() {
let codex_home = tempdir().expect("tempdir");
let cache = create_test_cache(codex_home.path());
assert_eq!(
cache.load(Some("user-12345"), Some("account-12345")).await,
Err(CacheLoadStatus::CacheFileNotFound)
);
std::fs::write(cache.path(), "{").expect("write malformed cache");
assert!(matches!(
cache.load(Some("user-12345"), Some("account-12345")).await,
Err(CacheLoadStatus::CacheParseFailed(_))
));
}
#[tokio::test]
async fn load_rejects_tampered_payload() {
let codex_home = tempdir().expect("tempdir");
let cache = create_test_cache(codex_home.path());
let mut cache_file = signed_cache_file(valid_signed_payload());
cache_file
.signed_payload
.bundle
.requirements_toml
.enterprise_managed[0]
.contents = "allowed_approval_policies = [\"on-request\"]".to_string();
write_cache_file(&cache, &cache_file);
assert_eq!(
cache.load(Some("user-12345"), Some("account-12345")).await,
Err(CacheLoadStatus::CacheSignatureInvalid)
);
}
#[tokio::test]
async fn load_rejects_cache_for_incomplete_or_different_identity() {
let codex_home = tempdir().expect("tempdir");
let cache = create_test_cache(codex_home.path());
let cache_file = signed_cache_file(valid_signed_payload());
write_cache_file(&cache, &cache_file);
assert_eq!(
cache.load(Some("user-99999"), Some("account-12345")).await,
Err(CacheLoadStatus::CacheIdentityMismatch)
);
let mut signed_payload = valid_signed_payload();
signed_payload.chatgpt_user_id = None;
write_cache_file(&cache, &signed_cache_file(signed_payload));
assert_eq!(
cache.load(Some("user-12345"), Some("account-12345")).await,
Err(CacheLoadStatus::CacheIdentityIncomplete)
);
}
#[tokio::test]
async fn load_rejects_expired_cache() {
let codex_home = tempdir().expect("tempdir");
let cache = create_test_cache(codex_home.path());
let mut signed_payload = valid_signed_payload();
signed_payload.expires_at = Utc::now() - ChronoDuration::seconds(1);
write_cache_file(&cache, &signed_cache_file(signed_payload));
assert_eq!(
cache.load(Some("user-12345"), Some("account-12345")).await,
Err(CacheLoadStatus::CacheExpired)
);
}
#[tokio::test]
async fn load_rejects_unsupported_cache_version() {
let codex_home = tempdir().expect("tempdir");
let cache = create_test_cache(codex_home.path());
let mut signed_payload = valid_signed_payload();
signed_payload.version = 2;
write_cache_file(&cache, &signed_cache_file(signed_payload));
assert_eq!(
cache.load(Some("user-12345"), Some("account-12345")).await,
Err(CacheLoadStatus::CacheVersionUnsupported(2))
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,95 @@
use codex_config::CloudConfigBundle;
const CLOUD_CONFIG_BUNDLE_FETCH_ATTEMPT_METRIC: &str = "codex.cloud_config_bundle.fetch_attempt";
const CLOUD_CONFIG_BUNDLE_FETCH_FINAL_METRIC: &str = "codex.cloud_config_bundle.fetch_final";
const CLOUD_CONFIG_BUNDLE_LOAD_METRIC: &str = "codex.cloud_config_bundle.load";
pub(crate) fn emit_fetch_attempt_metric(
trigger: &str,
attempt: usize,
outcome: &str,
status_code: Option<u16>,
) {
let attempt_tag = attempt.to_string();
let status_code_tag = status_code_tag(status_code);
emit_metric(
CLOUD_CONFIG_BUNDLE_FETCH_ATTEMPT_METRIC,
vec![
("trigger", trigger.to_string()),
("attempt", attempt_tag),
("outcome", outcome.to_string()),
("status_code", status_code_tag),
],
);
}
pub(crate) fn emit_fetch_final_metric(
trigger: &str,
outcome: &str,
reason: &str,
attempt_count: usize,
status_code: Option<u16>,
bundle: Option<&CloudConfigBundle>,
) {
let attempt_count_tag = attempt_count.to_string();
let status_code_tag = status_code_tag(status_code);
emit_metric(
CLOUD_CONFIG_BUNDLE_FETCH_FINAL_METRIC,
vec![
("trigger", trigger.to_string()),
("outcome", outcome.to_string()),
("reason", reason.to_string()),
("attempt_count", attempt_count_tag),
("status_code", status_code_tag),
("bundle_shape", bundle_shape_tag(bundle)),
],
);
}
pub(crate) fn emit_load_metric(trigger: &str, outcome: &str, bundle: Option<&CloudConfigBundle>) {
emit_metric(
CLOUD_CONFIG_BUNDLE_LOAD_METRIC,
vec![
("trigger", trigger.to_string()),
("outcome", outcome.to_string()),
("bundle_shape", bundle_shape_tag(bundle)),
],
);
}
pub(crate) fn bundle_shape_tag(bundle: Option<&CloudConfigBundle>) -> String {
let Some(bundle) = bundle else {
return "none".to_string();
};
let mut sources = Vec::new();
if !bundle.config_toml.enterprise_managed.is_empty() {
sources.push("enterprise_config");
}
if !bundle.requirements_toml.enterprise_managed.is_empty() {
sources.push("enterprise_requirements");
}
if sources.is_empty() {
"empty".to_string()
} else {
sources.sort_unstable();
sources.join(",")
}
}
fn status_code_tag(status_code: Option<u16>) -> String {
status_code
.map(|status_code| status_code.to_string())
.unwrap_or_else(|| "none".to_string())
}
fn emit_metric(metric_name: &str, tags: Vec<(&str, String)>) {
if let Some(metrics) = codex_otel::global() {
let tag_refs = tags
.iter()
.map(|(key, value)| (*key, value.as_str()))
.collect::<Vec<_>>();
let _ = metrics.counter(metric_name, /*inc*/ 1, &tag_refs);
}
}

View File

@@ -0,0 +1,501 @@
//! Cloud config bundle lifecycle orchestration.
//!
//! Startup loads a single shared bundle from cache or backend, and a background
//! refresher keeps the cache warm for future app starts without changing the
//! already-snapshotted runtime config.
use crate::backend::BundleClient;
use crate::backend::BundleRequestError;
use crate::backend::RetryableFailureKind;
use crate::cache::CacheLoadStatus;
use crate::cache::CloudConfigBundleCache;
use crate::metrics::emit_fetch_attempt_metric;
use crate::metrics::emit_fetch_final_metric;
use crate::metrics::emit_load_metric;
use crate::validation::validate_bundle;
use codex_config::AbsolutePathBuf;
use codex_config::CloudConfigBundle;
use codex_config::CloudConfigBundleLoadError;
use codex_config::CloudConfigBundleLoadErrorCode;
use codex_core::util::backoff;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_login::RefreshTokenError;
use codex_login::UnauthorizedRecovery;
use codex_protocol::account::PlanType;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
use tokio::time::sleep;
use tokio::time::timeout;
pub(crate) const CLOUD_CONFIG_BUNDLE_TIMEOUT: Duration = Duration::from_secs(15);
const CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS: usize = 5;
const CLOUD_CONFIG_BUNDLE_CACHE_REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60);
const CLOUD_CONFIG_BUNDLE_LOAD_FAILED_MESSAGE: &str =
"Failed to load cloud config bundle (workspace-managed policies).";
const CLOUD_CONFIG_BUNDLE_AUTH_RECOVERY_FAILED_MESSAGE: &str = concat!(
"Your authentication session could not be refreshed automatically. ",
"Please log out and sign in again."
);
fn auth_identity(auth: &CodexAuth) -> (Option<String>, Option<String>) {
(auth.get_chatgpt_user_id(), auth.get_account_id())
}
fn cloud_config_eligible_auth(auth: &CodexAuth) -> bool {
let Some(plan_type) = auth.account_plan_type() else {
return false;
};
auth.uses_codex_backend()
&& (plan_type.is_business_like()
|| matches!(plan_type, PlanType::Enterprise | PlanType::Edu))
}
fn optional_bundle(bundle: CloudConfigBundle) -> Option<CloudConfigBundle> {
if bundle.is_empty() {
None
} else {
Some(bundle)
}
}
enum CachedBundleLookup {
Hit(Option<CloudConfigBundle>),
Miss,
}
enum UnauthorizedRecoveryAction {
RetrySameAttempt,
RetryNextAttempt,
}
pub(crate) struct CloudConfigBundleService<C> {
auth_manager: Arc<AuthManager>,
client: Arc<C>,
cache: CloudConfigBundleCache,
codex_home: AbsolutePathBuf,
timeout: Duration,
}
impl<C> Clone for CloudConfigBundleService<C> {
fn clone(&self) -> Self {
Self {
auth_manager: Arc::clone(&self.auth_manager),
client: Arc::clone(&self.client),
cache: self.cache.clone(),
codex_home: self.codex_home.clone(),
timeout: self.timeout,
}
}
}
impl<C> CloudConfigBundleService<C>
where
C: BundleClient + 'static,
{
pub(crate) fn new(
auth_manager: Arc<AuthManager>,
client: Arc<C>,
codex_home: PathBuf,
timeout: Duration,
) -> Self {
let codex_home = AbsolutePathBuf::resolve_path_against_base(codex_home, "/");
Self {
auth_manager,
client,
cache: CloudConfigBundleCache::new(codex_home.clone()),
codex_home,
timeout,
}
}
pub(crate) async fn load_startup_bundle_with_timeout(
&self,
) -> Result<Option<CloudConfigBundle>, CloudConfigBundleLoadError> {
let _timer =
codex_otel::start_global_timer("codex.cloud_config_bundle.fetch.duration_ms", &[]);
let started_at = Instant::now();
let load_result = timeout(self.timeout, self.load_startup_bundle())
.await
.inspect_err(|_| {
let message = format!(
"Timed out waiting for cloud config bundle after {}s",
self.timeout.as_secs()
);
tracing::error!("{message}");
emit_load_metric("startup", "error", /*bundle*/ None);
})
.map_err(|_| {
CloudConfigBundleLoadError::new(
CloudConfigBundleLoadErrorCode::Timeout,
/*status_code*/ None,
format!(
"timed out waiting for cloud config bundle after {}s",
self.timeout.as_secs()
),
)
})?;
let result = match load_result {
Ok(result) => result,
Err(err) => {
emit_load_metric("startup", "error", /*bundle*/ None);
return Err(err);
}
};
match result.as_ref() {
Some(bundle) => {
tracing::info!(
elapsed_ms = started_at.elapsed().as_millis(),
config_fragments = bundle.config_toml.enterprise_managed.len(),
requirements_fragments = bundle.requirements_toml.enterprise_managed.len(),
"Cloud config bundle load completed"
);
emit_load_metric("startup", "success", Some(bundle));
}
None => {
tracing::info!(
elapsed_ms = started_at.elapsed().as_millis(),
"Cloud config bundle load completed (none)"
);
emit_load_metric("startup", "success", /*bundle*/ None);
}
}
Ok(result)
}
async fn load_startup_bundle(
&self,
) -> Result<Option<CloudConfigBundle>, CloudConfigBundleLoadError> {
let Some(auth) = self.auth_manager.auth().await else {
return Ok(None);
};
if !cloud_config_eligible_auth(&auth) {
return Ok(None);
}
// Startup prefers a valid, identity-matched cache entry. The backend is
// only consulted on cache miss or invalid cache contents.
let (chatgpt_user_id, account_id) = auth_identity(&auth);
match self
.load_valid_cached_bundle(chatgpt_user_id.as_deref(), account_id.as_deref())
.await
{
CachedBundleLookup::Hit(bundle) => return Ok(bundle),
CachedBundleLookup::Miss => {}
}
self.fetch_remote_bundle_and_update_cache_with_retries(auth, "startup")
.await
}
async fn load_valid_cached_bundle(
&self,
chatgpt_user_id: Option<&str>,
account_id: Option<&str>,
) -> CachedBundleLookup {
match self.cache.load(chatgpt_user_id, account_id).await {
Ok(signed_payload) => {
if let Err(err) = validate_bundle(&signed_payload.bundle, &self.codex_home) {
tracing::warn!(
path = %self.cache.path().display(),
error = %err,
"Ignoring invalid cached cloud config bundle"
);
self.cache
.log_load_status(&CacheLoadStatus::CacheInvalidBundle);
CachedBundleLookup::Miss
} else {
tracing::info!(
path = %self.cache.path().display(),
"Using cached cloud config bundle"
);
CachedBundleLookup::Hit(optional_bundle(signed_payload.bundle))
}
}
Err(cache_load_status) => {
self.cache.log_load_status(&cache_load_status);
CachedBundleLookup::Miss
}
}
}
async fn fetch_remote_bundle_and_update_cache_with_retries(
&self,
mut auth: CodexAuth,
trigger: &'static str,
) -> Result<Option<CloudConfigBundle>, CloudConfigBundleLoadError> {
let mut attempt = 1;
let mut last_status_code: Option<u16> = None;
let mut auth_recovery = self.auth_manager.unauthorized_recovery();
while attempt <= CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS {
match self.client.get_bundle(&auth).await {
Ok(bundle) => {
return self
.validate_and_cache_remote_bundle(&auth, trigger, attempt, bundle)
.await;
}
Err(BundleRequestError::Retryable(status)) => {
last_status_code = status.status_code();
if self
.retry_after_request_failure(trigger, attempt, status)
.await
{
attempt += 1;
continue;
}
}
Err(BundleRequestError::Unauthorized {
status_code,
message,
}) => {
last_status_code = status_code;
match self
.handle_unauthorized(
&mut auth,
&mut auth_recovery,
trigger,
attempt,
status_code,
&message,
)
.await?
{
UnauthorizedRecoveryAction::RetrySameAttempt => continue,
UnauthorizedRecoveryAction::RetryNextAttempt => {
attempt += 1;
continue;
}
}
}
}
break;
}
emit_fetch_final_metric(
trigger,
"error",
"request_retry_exhausted",
CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS,
last_status_code,
/*bundle*/ None,
);
tracing::error!(
path = %self.cache.path().display(),
"{CLOUD_CONFIG_BUNDLE_LOAD_FAILED_MESSAGE}"
);
Err(CloudConfigBundleLoadError::new(
CloudConfigBundleLoadErrorCode::RequestFailed,
last_status_code,
CLOUD_CONFIG_BUNDLE_LOAD_FAILED_MESSAGE,
))
}
async fn validate_and_cache_remote_bundle(
&self,
auth: &CodexAuth,
trigger: &'static str,
attempt: usize,
bundle: CloudConfigBundle,
) -> Result<Option<CloudConfigBundle>, CloudConfigBundleLoadError> {
emit_fetch_attempt_metric(trigger, attempt, "success", /*status_code*/ None);
if let Err(err) = validate_bundle(&bundle, &self.codex_home) {
emit_fetch_final_metric(
trigger,
"error",
"invalid_bundle",
attempt,
/*status_code*/ None,
/*bundle*/ None,
);
return Err(err);
}
let (chatgpt_user_id, account_id) = auth_identity(auth);
if let Err(err) = self
.cache
.save(chatgpt_user_id, account_id, bundle.clone())
.await
{
tracing::warn!(
error = %err,
"Failed to write cloud config bundle cache"
);
}
emit_fetch_final_metric(
trigger,
"success",
"none",
attempt,
/*status_code*/ None,
Some(&bundle),
);
Ok(optional_bundle(bundle))
}
async fn retry_after_request_failure(
&self,
trigger: &'static str,
attempt: usize,
status: RetryableFailureKind,
) -> bool {
let status_code = status.status_code();
emit_fetch_attempt_metric(trigger, attempt, "error", status_code);
if attempt < CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS {
tracing::warn!(
status = ?status,
attempt,
max_attempts = CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS,
"Failed to fetch cloud config bundle; retrying"
);
sleep(backoff(attempt as u64)).await;
true
} else {
false
}
}
async fn handle_unauthorized(
&self,
auth: &mut CodexAuth,
auth_recovery: &mut UnauthorizedRecovery,
trigger: &'static str,
attempt: usize,
status_code: Option<u16>,
message: &str,
) -> Result<UnauthorizedRecoveryAction, CloudConfigBundleLoadError> {
emit_fetch_attempt_metric(trigger, attempt, "unauthorized", status_code);
if auth_recovery.has_next() {
tracing::warn!(
attempt,
max_attempts = CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS,
"Cloud config bundle request was unauthorized; attempting auth recovery"
);
match auth_recovery.next().await {
Ok(_) => {
let Some(refreshed_auth) = self.auth_manager.auth().await else {
tracing::error!(
"Auth recovery succeeded but no auth is available for cloud config bundle"
);
emit_fetch_final_metric(
trigger,
"error",
"auth_recovery_missing_auth",
attempt,
status_code,
/*bundle*/ None,
);
return Err(CloudConfigBundleLoadError::new(
CloudConfigBundleLoadErrorCode::Auth,
status_code,
CLOUD_CONFIG_BUNDLE_AUTH_RECOVERY_FAILED_MESSAGE,
));
};
*auth = refreshed_auth;
return Ok(UnauthorizedRecoveryAction::RetrySameAttempt);
}
Err(RefreshTokenError::Permanent(failed)) => {
tracing::warn!(
error = %failed,
"Failed to recover from unauthorized cloud config bundle request"
);
emit_fetch_final_metric(
trigger,
"error",
"auth_recovery_unrecoverable",
attempt,
status_code,
/*bundle*/ None,
);
return Err(CloudConfigBundleLoadError::new(
CloudConfigBundleLoadErrorCode::Auth,
status_code,
failed.message,
));
}
Err(RefreshTokenError::Transient(recovery_err)) => {
if attempt < CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS {
tracing::warn!(
error = %recovery_err,
attempt,
max_attempts = CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS,
"Failed to recover from unauthorized cloud config bundle request; retrying"
);
sleep(backoff(attempt as u64)).await;
}
return Ok(UnauthorizedRecoveryAction::RetryNextAttempt);
}
}
}
tracing::warn!(
error = %message,
"Cloud config bundle request was unauthorized and no auth recovery is available"
);
emit_fetch_final_metric(
trigger,
"error",
"auth_recovery_unavailable",
attempt,
status_code,
/*bundle*/ None,
);
Err(CloudConfigBundleLoadError::new(
CloudConfigBundleLoadErrorCode::Auth,
status_code,
CLOUD_CONFIG_BUNDLE_AUTH_RECOVERY_FAILED_MESSAGE,
))
}
pub(crate) async fn refresh_cache_in_background(&self) {
loop {
sleep(CLOUD_CONFIG_BUNDLE_CACHE_REFRESH_INTERVAL).await;
match timeout(self.timeout, self.refresh_cache_once()).await {
Ok(true) => {}
Ok(false) => break,
Err(_) => {
tracing::error!(
"Timed out refreshing cloud config bundle cache from remote; keeping existing cache"
);
emit_load_metric("refresh", "error", /*bundle*/ None);
}
}
}
}
async fn refresh_cache_once(&self) -> bool {
let Some(auth) = self.auth_manager.auth().await else {
return false;
};
if !cloud_config_eligible_auth(&auth) {
return false;
}
match self
.fetch_remote_bundle_and_update_cache_with_retries(auth, "refresh")
.await
{
Ok(bundle) => emit_load_metric("refresh", "success", bundle.as_ref()),
Err(err) => {
tracing::error!(
path = %self.cache.path().display(),
error = %err,
"Failed to refresh cloud config bundle cache from remote"
);
emit_load_metric("refresh", "error", /*bundle*/ None);
}
}
true
}
}
#[cfg(test)]
#[path = "service_tests.rs"]
mod tests;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
use codex_config::AbsolutePathBuf;
use codex_config::CloudConfigBundle;
use codex_config::CloudConfigBundleLayers;
use codex_config::CloudConfigBundleLoadError;
use codex_config::CloudConfigBundleLoadErrorCode;
use codex_config::compose_requirements;
pub(crate) fn validate_bundle(
bundle: &CloudConfigBundle,
base_dir: &AbsolutePathBuf,
) -> Result<(), CloudConfigBundleLoadError> {
let bundle_layers =
CloudConfigBundleLayers::from_bundle(bundle.clone(), base_dir).map_err(|err| {
CloudConfigBundleLoadError::new(
CloudConfigBundleLoadErrorCode::InvalidBundle,
/*status_code*/ None,
format!("invalid cloud config bundle: {err}"),
)
})?;
let CloudConfigBundleLayers {
enterprise_managed_config: _,
enterprise_managed_requirements,
} = bundle_layers;
compose_requirements(enterprise_managed_requirements).map_err(|err| {
CloudConfigBundleLoadError::new(
CloudConfigBundleLoadErrorCode::InvalidBundle,
/*status_code*/ None,
format!("invalid cloud config bundle: {err}"),
)
})?;
Ok(())
}

View File

@@ -724,36 +724,27 @@ impl ConfigToml {
pub async fn derive_permission_profile(
&self,
sandbox_mode_override: Option<SandboxMode>,
profile_sandbox_mode: Option<SandboxMode>,
windows_sandbox_level: WindowsSandboxLevel,
active_project: Option<&ProjectConfig>,
permission_profile_constraint: Option<&crate::Constrained<PermissionProfile>>,
) -> PermissionProfile {
let sandbox_mode_was_explicit = sandbox_mode_override.is_some()
|| profile_sandbox_mode.is_some()
|| self.sandbox_mode.is_some();
let resolved_sandbox_mode = sandbox_mode_override
.or(profile_sandbox_mode)
.or(self.sandbox_mode)
.or(if sandbox_mode_was_explicit {
None
} else {
let configured_sandbox_mode = sandbox_mode_override.or(self.sandbox_mode);
let resolved_sandbox_mode = configured_sandbox_mode
.or_else(|| {
// If no sandbox_mode is set but this directory has a trust decision,
// default to workspace-write except on unsandboxed Windows where we
// default to read-only.
active_project.and_then(|p| {
if p.is_trusted() || p.is_untrusted() {
active_project
.filter(|project| project.is_trusted() || project.is_untrusted())
.map(|_| {
if cfg!(target_os = "windows")
&& windows_sandbox_level == WindowsSandboxLevel::Disabled
{
Some(SandboxMode::ReadOnly)
SandboxMode::ReadOnly
} else {
Some(SandboxMode::WorkspaceWrite)
SandboxMode::WorkspaceWrite
}
} else {
None
}
})
})
})
.unwrap_or_default();
let effective_sandbox_mode = if cfg!(target_os = "windows")
@@ -791,7 +782,7 @@ impl ConfigToml {
},
SandboxMode::DangerFullAccess => PermissionProfile::Disabled,
};
if !sandbox_mode_was_explicit
if configured_sandbox_mode.is_none()
&& let Some(constraint) = permission_profile_constraint
&& let Err(err) = constraint.can_set(&permission_profile)
{

View File

@@ -32,7 +32,6 @@ use codex_config::permissions_toml::NetworkToml;
use codex_config::permissions_toml::PermissionProfileToml;
use codex_config::permissions_toml::PermissionsToml;
use codex_config::permissions_toml::WorkspaceRootsToml;
use codex_config::profile_toml::ConfigProfile;
use codex_config::types::AppToolApproval;
use codex_config::types::ApprovalsReviewer;
use codex_config::types::BundledSkillsConfig;
@@ -165,7 +164,6 @@ fn http_mcp(url: &str) -> McpServerConfig {
async fn derive_legacy_sandbox_policy_for_test(
cfg: &ConfigToml,
sandbox_mode_override: Option<SandboxMode>,
profile_sandbox_mode: Option<SandboxMode>,
windows_sandbox_level: WindowsSandboxLevel,
active_project: Option<&ProjectConfig>,
permission_profile_constraint: Option<&Constrained<PermissionProfile>>,
@@ -173,7 +171,6 @@ async fn derive_legacy_sandbox_policy_for_test(
let permission_profile = cfg
.derive_permission_profile(
sandbox_mode_override,
profile_sandbox_mode,
windows_sandbox_level,
active_project,
permission_profile_constraint,
@@ -2551,6 +2548,65 @@ async fn empty_config_defaults_to_builtin_profile_for_trusted_project() -> std::
Ok(())
}
#[tokio::test]
async fn empty_config_defaults_to_builtin_profile_for_untrusted_project() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let project_key = cwd.path().to_string_lossy().to_string();
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
projects: Some(HashMap::from([(
project_key,
ProjectConfig {
trust_level: Some(TrustLevel::Untrusted),
},
)])),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
)
.await?;
let policy = config.permissions.file_system_sandbox_policy();
assert_eq!(
config
.permissions
.active_permission_profile()
.as_ref()
.map(|active| active.id.as_str()),
Some(if cfg!(target_os = "windows") {
BUILT_IN_PERMISSION_PROFILE_READ_ONLY
} else {
BUILT_IN_PERMISSION_PROFILE_WORKSPACE
})
);
assert!(
policy.can_read_path_with_cwd(cwd.path(), cwd.path()),
"expected untrusted project fallback to allow reads, policy: {policy:?}"
);
if cfg!(target_os = "windows") {
assert!(
!policy.can_write_path_with_cwd(cwd.path(), cwd.path()),
"expected untrusted project fallback to stay read-only without Windows sandbox support, policy: {policy:?}"
);
} else {
assert!(
policy.can_write_path_with_cwd(cwd.path(), cwd.path()),
"expected untrusted project fallback to use :workspace, policy: {policy:?}"
);
assert!(
!policy.can_write_path_with_cwd(&cwd.path().join(".codex"), cwd.path()),
"expected :workspace metadata carveouts, policy: {policy:?}"
);
}
Ok(())
}
#[tokio::test]
async fn implicit_builtin_workspace_profile_preserves_sandbox_workspace_write_settings()
-> std::io::Result<()> {
@@ -3530,7 +3586,6 @@ network_access = false # This should be ignored.
let resolution = derive_legacy_sandbox_policy_for_test(
&sandbox_full_access_cfg,
sandbox_mode_override,
/*profile_sandbox_mode*/ None,
WindowsSandboxLevel::Disabled,
/*active_project*/ None,
/*permission_profile_constraint*/ None,
@@ -3551,7 +3606,6 @@ network_access = true # This should be ignored.
let resolution = derive_legacy_sandbox_policy_for_test(
&sandbox_read_only_cfg,
sandbox_mode_override,
/*profile_sandbox_mode*/ None,
WindowsSandboxLevel::Disabled,
/*active_project*/ None,
/*permission_profile_constraint*/ None,
@@ -3583,7 +3637,6 @@ trust_level = "trusted"
let resolution = derive_legacy_sandbox_policy_for_test(
&sandbox_workspace_write_cfg,
sandbox_mode_override,
/*profile_sandbox_mode*/ None,
WindowsSandboxLevel::Disabled,
/*active_project*/ None,
/*permission_profile_constraint*/ None,
@@ -3623,7 +3676,6 @@ exclude_slash_tmp = true
let resolution = derive_legacy_sandbox_policy_for_test(
&sandbox_workspace_write_cfg,
sandbox_mode_override,
/*profile_sandbox_mode*/ None,
WindowsSandboxLevel::Disabled,
/*active_project*/ None,
/*permission_profile_constraint*/ None,
@@ -4998,38 +5050,6 @@ model = "gpt-project-local"
Ok(())
}
#[tokio::test]
async fn unselected_profile_sandbox_mode_is_ignored() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let mut profiles = HashMap::new();
profiles.insert(
"work".to_string(),
ConfigProfile {
sandbox_mode: Some(SandboxMode::DangerFullAccess),
..Default::default()
},
);
let cfg = ConfigToml {
profiles,
sandbox_mode: Some(SandboxMode::ReadOnly),
..Default::default()
};
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(
config.legacy_sandbox_policy(),
SandboxPolicy::new_read_only_policy()
);
Ok(())
}
#[tokio::test]
async fn feature_table_overrides_legacy_flags() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
@@ -8587,7 +8607,6 @@ trust_level = "untrusted"
let resolution = derive_legacy_sandbox_policy_for_test(
&cfg,
/*sandbox_mode_override*/ None,
/*profile_sandbox_mode*/ None,
WindowsSandboxLevel::Disabled,
Some(&active_project),
/*permission_profile_constraint*/ None,
@@ -8644,7 +8663,6 @@ async fn derive_sandbox_policy_falls_back_to_read_only_for_implicit_defaults() -
let resolution = derive_legacy_sandbox_policy_for_test(
&cfg,
/*sandbox_mode_override*/ None,
/*profile_sandbox_mode*/ None,
WindowsSandboxLevel::Disabled,
Some(&active_project),
Some(&constrained),
@@ -8697,7 +8715,6 @@ async fn derive_sandbox_policy_preserves_windows_downgrade_for_unsupported_fallb
let resolution = derive_legacy_sandbox_policy_for_test(
&cfg,
/*sandbox_mode_override*/ None,
/*profile_sandbox_mode*/ None,
WindowsSandboxLevel::Disabled,
Some(&active_project),
Some(&constrained),

View File

@@ -2936,7 +2936,6 @@ impl Config {
let mut permission_profile = cfg
.derive_permission_profile(
sandbox_mode,
/*profile_sandbox_mode*/ None,
windows_sandbox_level,
Some(&active_project),
Some(&constrained_permission_profile),

View File

@@ -20,7 +20,6 @@ use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::protocol::TokenUsage;
@@ -719,9 +718,7 @@ async fn run_review_on_session(
.total_token_usage()
.await
.unwrap_or_default();
// The legacy SandboxPolicy should match the PermissionProfile.
let guardian_permission_profile = PermissionProfile::read_only();
let legacy_sandbox_policy = SandboxPolicy::new_read_only_policy();
let submit_result = run_before_review_deadline(
deadline,
@@ -736,7 +733,7 @@ async fn run_review_on_session(
#[allow(deprecated)]
cwd: Some(params.parent_turn.cwd.to_path_buf()),
approval_policy: Some(AskForApproval::Never),
sandbox_policy: Some(legacy_sandbox_policy),
sandbox_policy: None,
permission_profile: Some(guardian_permission_profile),
summary: Some(params.reasoning_summary),
personality: params.personality,

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;
@@ -513,6 +515,9 @@ async fn build_skills_and_plugins(
};
let skills_outcome = turn_context.turn_skills.outcome.as_ref();
let connector_slug_counts = build_connector_slug_counts(&available_connectors);
let extension_injection_items =
build_extension_turn_input_items(sess, turn_context, &user_input, cancellation_token)
.await?;
let skill_name_counts_lower =
build_skill_name_counts(&skills_outcome.skills, &skills_outcome.disabled_paths).1;
let mentioned_skills = collect_explicit_skill_mentions(
@@ -588,9 +593,57 @@ async fn build_skills_and_plugins(
let mut injection_items = skill_items;
injection_items.extend(plugin_items);
injection_items.extend(extension_injection_items);
Some((injection_items, explicitly_enabled_connectors))
}
async fn build_extension_turn_input_items(
sess: &Arc<Session>,
turn_context: &TurnContext,
user_input: &[UserInput],
cancellation_token: &CancellationToken,
) -> Option<Vec<ResponseItem>> {
let contributors = sess.services.extensions.turn_input_contributors().to_vec();
if contributors.is_empty() {
return Some(Vec::new());
}
let environments = turn_context
.environments
.turn_environments
.iter()
.enumerate()
.map(|(index, environment)| TurnInputEnvironment {
environment_id: environment.environment_id.clone(),
cwd: environment.cwd.as_path().to_path_buf(),
is_primary: index == 0,
})
.collect::<Vec<_>>();
let input = TurnInputContext {
turn_id: turn_context.sub_id.to_string(),
user_input: user_input.to_vec(),
environments,
};
let mut items = Vec::new();
for contributor in contributors {
let contributed_items = contributor
.contribute(
input.clone(),
&sess.services.session_extension_data,
&sess.services.thread_extension_data,
turn_context.extension_data.as_ref(),
)
.or_cancel(cancellation_token)
.await
.ok()?;
items.extend(contributed_items);
}
Some(items)
}
async fn track_turn_resolved_config_analytics(
sess: &Session,
turn_context: &TurnContext,

View File

@@ -210,15 +210,7 @@ fn build_model_visible_specs_and_registry(
specs.push(spec_for_model_request(turn_context, exposure, spec));
}
}
for spec in hosted_specs {
if !is_hidden_by_code_mode_only(
turn_context,
&ToolName::plain(spec.name()),
ToolExposure::Direct,
) {
specs.push(spec);
}
}
specs.extend(hosted_specs);
let registry = ToolRegistry::from_tools(runtimes);
let model_visible_specs = merge_into_namespaces(specs)
@@ -267,6 +259,7 @@ fn hosted_model_tool_specs(context: &CoreToolPlanContext<'_>) -> Vec<ToolSpec> {
}) {
specs.push(web_search_tool);
}
// TODO: Remove hosted image generation once the standalone extension is ready.
if image_generation_tool_enabled(turn_context)
&& !standalone_image_generation_available(turn_context, context.extension_tool_executors)
{

View File

@@ -1167,7 +1167,16 @@ async fn code_mode_only_can_expose_namespaced_multi_agent_v2_as_normal_tools() {
})
.await;
assert_eq!(plan.visible_names, vec!["exec", "wait", "agents"]);
assert_eq!(
plan.visible_names,
vec![
"exec",
"wait",
"agents",
// Hosted Responses tools.
"web_search",
]
);
assert!(
!plan
.namespace_function_names("agents")
@@ -1235,6 +1244,32 @@ async fn hosted_tools_follow_provider_auth_model_and_config_gates() {
}
);
let code_mode_only = probe(|turn| {
use_chatgpt_auth(turn);
set_features(turn, &[Feature::CodeModeOnly, Feature::MultiAgentV2]);
set_web_search_mode(turn, WebSearchMode::Live);
turn.model_info.input_modalities = vec![InputModality::Image];
})
.await;
assert_eq!(
code_mode_only.visible_names,
vec![
// Code-mode entrypoints.
codex_code_mode::PUBLIC_TOOL_NAME,
codex_code_mode::WAIT_TOOL_NAME,
// Multi-agent v2 tools.
"spawn_agent",
"send_message",
"followup_task",
"wait_agent",
"close_agent",
"list_agents",
// Hosted Responses tools.
"web_search",
"image_generation",
]
);
let standalone_web_search_without_web_run = probe(|turn| {
set_feature(turn, Feature::StandaloneWebSearch, /*enabled*/ true);
set_web_search_mode(turn, WebSearchMode::Live);

View File

@@ -5,6 +5,7 @@ use codex_login::CodexAuth;
use codex_models_manager::manager::RefreshStrategy;
use codex_models_manager::manager::SharedModelsManager;
use codex_models_manager::model_info::model_info_from_slug;
use codex_protocol::openai_models::InputModality;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ModelVisibility;
@@ -165,12 +166,17 @@ async fn remote_tool_mode_selector_overrides_feature_flags() -> Result<()> {
let mut code_mode_only_model = remote_model("test-tool-mode-code-mode-only");
code_mode_only_model.tool_mode = Some(ToolMode::CodeModeOnly);
code_mode_only_model.input_modalities = vec![InputModality::Text, InputModality::Image];
let code_mode_only_body = response_body_for_remote_model(code_mode_only_model, |_| {}).await?;
assert_eq!(
tool_names(&code_mode_only_body),
vec![
// Code-mode entrypoints.
codex_code_mode::PUBLIC_TOOL_NAME.to_string(),
codex_code_mode::WAIT_TOOL_NAME.to_string(),
// Hosted Responses tools.
"web_search".to_string(),
"image_generation".to_string(),
]
);

View File

@@ -9,6 +9,7 @@ use codex_exec_server::LOCAL_ENVIRONMENT_ID;
use codex_exec_server::REMOTE_ENVIRONMENT_ID;
use codex_exec_server::RemoveOptions;
use codex_features::Feature;
use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
@@ -22,6 +23,9 @@ use codex_protocol::protocol::Op;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::TurnEnvironmentSelection;
use codex_protocol::request_permissions::PermissionGrantScope;
use codex_protocol::request_permissions::RequestPermissionProfile;
use codex_protocol::request_permissions::RequestPermissionsResponse;
use codex_protocol::user_input::UserInput;
use codex_utils_absolute_path::AbsolutePathBuf;
use core_test_support::PathBufExt;
@@ -345,6 +349,209 @@ async fn exec_command_routes_to_selected_remote_environment() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn remote_request_permissions_grant_unblocks_later_remote_exec() -> Result<()> {
skip_if_no_network!(Ok(()));
let Some(_remote_env) = get_remote_test_env() else {
return Ok(());
};
let server = start_mock_server().await;
let mut builder = test_codex().with_config(|config| {
config.use_experimental_unified_exec_tool = true;
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
config.approvals_reviewer = ApprovalsReviewer::User;
config
.features
.enable(Feature::UnifiedExec)
.expect("test config should allow feature update");
config
.features
.enable(Feature::ExecPermissionApprovals)
.expect("test config should allow feature update");
config
.features
.enable(Feature::RequestPermissionsTool)
.expect("test config should allow feature update");
});
let test = builder.build_with_remote_and_local_env(&server).await?;
let local_cwd = TempDir::new()?;
let remote_cwd = PathBuf::from(format!(
"/tmp/codex-remote-request-permissions-{}",
SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis()
))
.abs();
let relative_write_root = "granted";
let relative_target_path = "granted/request-permissions-output.txt";
let remote_write_root = remote_cwd.join(relative_write_root);
let remote_target_path = remote_cwd.join(relative_target_path);
let local_write_root = local_cwd.path().join(relative_write_root);
let local_target_path = local_cwd.path().join(relative_target_path);
fs::create_dir(&local_write_root)?;
test.fs()
.create_directory(
&remote_write_root,
CreateDirectoryOptions { recursive: true },
/*sandbox*/ None,
)
.await?;
let expected_permissions = RequestPermissionProfile {
file_system: Some(FileSystemPermissions::from_read_write_roots(
Some(vec![]),
Some(vec![remote_write_root.clone()]),
)),
..RequestPermissionProfile::default()
};
let approved_response = RequestPermissionsResponse {
permissions: expected_permissions.clone(),
scope: PermissionGrantScope::Turn,
strict_auto_review: false,
};
let command = format!(
"printf 'remote-request-permissions-ok' > {relative_target_path} && cat {relative_target_path}"
);
let response_mock = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-request-permissions-remote-1"),
ev_function_call(
"permissions-call",
"request_permissions",
&json!({
"environment_id": REMOTE_ENVIRONMENT_ID,
"reason": "Allow writing inside the selected remote environment",
"permissions": {
"file_system": {
"write": [relative_write_root],
},
},
})
.to_string(),
),
ev_completed("resp-request-permissions-remote-1"),
]),
sse(vec![
ev_response_created("resp-request-permissions-remote-2"),
ev_function_call(
"exec-call",
"exec_command",
&json!({
"shell": "/bin/sh",
"cmd": command,
"login": false,
"yield_time_ms": 1_000,
"environment_id": REMOTE_ENVIRONMENT_ID,
})
.to_string(),
),
ev_completed("resp-request-permissions-remote-2"),
]),
sse(vec![
ev_response_created("resp-request-permissions-remote-3"),
ev_assistant_message("msg-request-permissions-remote-1", "done"),
ev_completed("resp-request-permissions-remote-3"),
]),
],
)
.await;
submit_turn_with_approval_and_environments(
&test,
"request permissions, then write in the remote environment",
vec![
TurnEnvironmentSelection {
environment_id: LOCAL_ENVIRONMENT_ID.to_string(),
cwd: local_cwd.path().abs(),
},
TurnEnvironmentSelection {
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
cwd: remote_cwd.clone(),
},
],
)
.await?;
let event = wait_for_event(&test.codex, |event| {
matches!(
event,
EventMsg::RequestPermissions(_) | EventMsg::TurnComplete(_)
)
})
.await;
let EventMsg::RequestPermissions(request) = event else {
panic!("expected remote request_permissions before completion: {event:?}");
};
assert_eq!(request.call_id, "permissions-call");
assert_eq!(
request.environment_id.as_deref(),
Some(REMOTE_ENVIRONMENT_ID)
);
assert_eq!(request.cwd.as_ref(), Some(&remote_cwd));
assert_eq!(request.permissions, expected_permissions);
test.codex
.submit(Op::RequestPermissionsResponse {
id: "permissions-call".to_string(),
response: approved_response.clone(),
})
.await?;
let event = wait_for_event(&test.codex, |event| {
matches!(
event,
EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_)
)
})
.await;
match event {
EventMsg::TurnComplete(_) => {}
EventMsg::ExecApprovalRequest(approval) => {
panic!("remote request_permissions grant should preapprove exec: {approval:?}");
}
other => panic!("unexpected event: {other:?}"),
}
let permissions_output: RequestPermissionsResponse = serde_json::from_str(
&response_mock
.function_call_output_text("permissions-call")
.expect("expected request_permissions output"),
)?;
assert_eq!(permissions_output, approved_response);
let exec_output = response_mock
.function_call_output_text("exec-call")
.expect("expected exec output");
assert!(
exec_output.contains("remote-request-permissions-ok"),
"unexpected exec output: {exec_output}",
);
assert_eq!(
test.fs()
.read_file_text(&remote_target_path, /*sandbox*/ None)
.await?,
"remote-request-permissions-ok"
);
assert!(
!local_target_path.exists(),
"remote exec should not write through the local environment"
);
test.fs()
.remove(
&remote_cwd,
RemoveOptions {
recursive: true,
force: true,
},
/*sandbox*/ None,
)
.await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apply_patch_freeform_routes_to_selected_remote_environment() -> Result<()> {
skip_if_no_network!(Ok(()));

View File

@@ -4,7 +4,7 @@ This repository uses Bazel to build the Rust workspace under `codex-rs`.
Cargo remains the source of truth for crates and features, while Bazel
provides hermetic builds, toolchains, and cross-platform artifacts.
As of 1/9/2026, this setup is still experimental as we stabilize it.
As of 6/1/2026, this setup is still experimental as we stabilize it.
## High-level layout
@@ -20,6 +20,118 @@ As of 1/9/2026, this setup is still experimental as we stabilize it.
makes some adjustments if the crate needs additional compile-time or runtime data,
or other customizations.
## Running Bazel locally
The repository root `justfile` exposes the common Bazel entry points:
```bash
just bazel-test
just bazel-clippy
```
Ordinary local `bazel` and `just` invocations run locally. BuildBuddy cache,
build event upload, downloads, and remote execution are opt-in configurations.
## BuildBuddy
Codex uses BuildBuddy for a shared Bazel cache and remoted builds and tests. To use it
to speed up your builds and tests you'll need to provide an API key and select a
configuration.
### BuildBuddy API key
If you're an OpenAI employee, log in to https://openai.buildbuddy.io and use Google sign-in.
Create a BuildBuddy API key as described in BuildBuddy's [Authentication Guide][bb-auth-guide],
then add it to `~/.bazelrc`:
```bazelrc
# Local machine only; this file contains a BuildBuddy credential.
common --remote_header=x-buildbuddy-api-key=<your-buildbuddy-api-key>
```
Keeping the credential outside the workspace reduces the risk of accidentally
committing it.
If you need different API keys for different projects, put the API key in
`%workspace%/user.bazelrc` instead. The checked-in `.bazelrc` optionally imports
that file, and `.gitignore` excludes it. Do not commit or share a file containing
the credential.
[bb-auth-guide]: https://www.buildbuddy.io/docs/guide-auth/#managing-keys
### Selecting a remote build configuration
OpenAI employees should default to the OpenAI host with remote execution unless
they have a reason to choose another configuration. Add the following configuration
to `%workspace%/user.bazelrc`:
```bazelrc
common --config=buildbuddy-openai-rbe
```
OpenAI employees who don't want remote execution can use `buildbuddy-openai`. External users
should use `buildbuddy-generic-rbe` or `buildbuddy-generic`. See below for details on these
configurations.
### All remote configurations
GitHub Actions routes Bazel build and output-resolution commands through
`.github/scripts/run_bazel_with_buildbuddy.py`. Higher-level helpers such as
`.github/scripts/run-bazel-ci.sh` and `.github/scripts/rusty_v8_bazel.py`
delegate remote configuration selection to that wrapper. The wrapper reads the
GitHub Actions repository and event payload rather than relying on workflow
files to duplicate tenant-selection logic.
Loading-phase target-discovery `bazel query` commands run locally because they
only enumerate labels and do not need remote caches or execution.
The `Cache/BES` host is also used for remote downloads.
| Invocation/config | Key Required | Cache/BES | Build exec | Test exec |
| --- | --- | --- | --- | --- |
| `bazel ...` | No | None | Local | Local |
| `bazel ... --config=buildbuddy-generic` | Yes | `remote.buildbuddy.io` | Local | Local |
| `bazel ... --config=buildbuddy-generic-rbe` | Yes | `remote.buildbuddy.io` | Remote | Remote |
| `bazel ... --config=buildbuddy-openai` | Yes | `openai.buildbuddy.io` | Local | Local |
| `bazel ... --config=buildbuddy-openai-rbe` | Yes | `openai.buildbuddy.io` | Remote | Remote |
Without an API key, the wrapper removes remote CI configurations and runs
locally. With a key, workflows choose the host as follows:
| Run | Key | Uses OpenAI BuildBuddy Host |
| --- | --- | --- |
| Push to `main` in `openai/codex` | Yes | Yes |
| `workflow_dispatch` in `openai/codex` | Yes | Yes |
| Same-repository pull request in `openai/codex` | Yes | Yes |
| Fork pull request into `openai/codex` | No | No; local |
| Push or `workflow_dispatch` in a fork with a key | Yes | No; generic host |
| Pull request run in a fork repository with a key | Yes | No; generic host |
CI configurations determine whether builds and tests execute remotely:
| CI config | Remote config | Build exec | Test exec |
| --- | --- | --- | --- |
| `ci-linux` | `*-rbe` | Remote host | Remote host |
| `ci-v8` | `*-rbe` | Remote host | Remote host |
| `ci-macos` | `*-rbe` | Remote host | Local |
| `ci-windows-cross` | `*-rbe` | Remote host | Local |
| `ci-windows` | non-RBE | Local | Local |
| Keyless CI fallback | none | Local | Local |
To exercise the generic remote configuration with your key:
```bash
BUILDBUDDY_API_KEY=... GITHUB_REPOSITORY=my-fork/codex \
./.github/scripts/run_bazel_with_buildbuddy.py \
build --config=ci-linux //codex-rs/cli:codex
```
The wrapper selects the OpenAI host only inside GitHub Actions for a trusted
run in `openai/codex`. A missing or malformed pull request event
payload fails closed to the generic host. For local OpenAI host access, use
the `user.bazelrc` configuration above.
## Evolving the setup
When you add or change Rust dependencies, update the Cargo.toml/Cargo.lock as normal.

View File

@@ -2,6 +2,7 @@ use std::future::Future;
use std::sync::Arc;
use codex_protocol::items::TurnItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::TokenUsageInfo;
use codex_tools::ToolCall;
@@ -12,6 +13,7 @@ use crate::ExtensionData;
mod prompt;
mod thread_lifecycle;
mod tool_lifecycle;
mod turn_input;
mod turn_lifecycle;
pub use prompt::PromptFragment;
@@ -25,6 +27,8 @@ pub use tool_lifecycle::ToolCallSource;
pub use tool_lifecycle::ToolFinishInput;
pub use tool_lifecycle::ToolLifecycleFuture;
pub use tool_lifecycle::ToolStartInput;
pub use turn_input::TurnInputContext;
pub use turn_input::TurnInputEnvironment;
pub use turn_lifecycle::TurnAbortInput;
pub use turn_lifecycle::TurnErrorInput;
pub use turn_lifecycle::TurnStartInput;
@@ -85,6 +89,25 @@ pub trait TurnLifecycleContributor: Send + Sync {
async fn on_turn_error(&self, _input: TurnErrorInput<'_>) {}
}
/// WARNING: DO NOT USE YET
/// Extension contribution that can add turn-local model input.
///
/// Implementations should resolve only the model-visible input they own and
/// must preserve authority boundaries for external resources. Expensive or
/// host-specific dependencies belong on the extension value installed by the
/// host, not in this input.
#[async_trait::async_trait]
pub trait TurnInputContributor: Send + Sync {
/// Returns additional model input items for one submitted turn.
async fn contribute(
&self,
input: TurnInputContext,
session_store: &ExtensionData,
thread_store: &ExtensionData,
turn_store: &ExtensionData,
) -> Vec<ResponseItem>;
}
/// Contributor for host-owned configuration changes.
///
/// Implementations should treat the supplied values as immutable before/after

View File

@@ -0,0 +1,25 @@
use std::path::PathBuf;
use codex_protocol::user_input::UserInput;
/// Host-owned turn environment summary visible to turn-input contributors.
#[derive(Debug, Clone)]
pub struct TurnInputEnvironment {
/// Stable host environment id used to route executor-scoped capabilities.
pub environment_id: String,
/// Effective working directory for this turn in the environment.
pub cwd: PathBuf,
/// Whether this is the primary environment for the turn.
pub is_primary: bool,
}
/// Turn facts supplied before the host records turn-local model input items.
#[derive(Debug, Clone)]
pub struct TurnInputContext {
/// Stable host-owned turn identifier.
pub turn_id: String,
/// User input submitted for this turn.
pub user_input: Vec<UserInput>,
/// Resolved turn environments, in host priority order.
pub environments: Vec<TurnInputEnvironment>,
}

View File

@@ -10,6 +10,7 @@ pub use capabilities::NoopExtensionEventSink;
pub use capabilities::NoopResponseItemInjector;
pub use capabilities::ResponseItemInjectionFuture;
pub use capabilities::ResponseItemInjector;
pub use codex_protocol::models::ResponseItem;
pub use codex_tools::ConversationHistory;
pub use codex_tools::ExtensionTurnItem;
pub use codex_tools::FunctionCallError;
@@ -46,6 +47,9 @@ pub use contributors::ToolLifecycleFuture;
pub use contributors::ToolStartInput;
pub use contributors::TurnAbortInput;
pub use contributors::TurnErrorInput;
pub use contributors::TurnInputContext;
pub use contributors::TurnInputContributor;
pub use contributors::TurnInputEnvironment;
pub use contributors::TurnItemContributor;
pub use contributors::TurnLifecycleContributor;
pub use contributors::TurnStartInput;

View File

@@ -12,6 +12,7 @@ use crate::ThreadLifecycleContributor;
use crate::TokenUsageContributor;
use crate::ToolContributor;
use crate::ToolLifecycleContributor;
use crate::TurnInputContributor;
use crate::TurnItemContributor;
use crate::TurnLifecycleContributor;
@@ -23,6 +24,7 @@ pub struct ExtensionRegistryBuilder<C: Sync> {
config_contributors: Vec<Arc<dyn ConfigContributor<C>>>,
token_usage_contributors: Vec<Arc<dyn TokenUsageContributor>>,
context_contributors: Vec<Arc<dyn ContextContributor>>,
turn_input_contributors: Vec<Arc<dyn TurnInputContributor>>,
tool_contributors: Vec<Arc<dyn ToolContributor>>,
tool_lifecycle_contributors: Vec<Arc<dyn ToolLifecycleContributor>>,
turn_item_contributors: Vec<Arc<dyn TurnItemContributor>>,
@@ -39,6 +41,7 @@ impl<C: Sync> Default for ExtensionRegistryBuilder<C> {
token_usage_contributors: Vec::new(),
approval_review_contributors: Vec::new(),
context_contributors: Vec::new(),
turn_input_contributors: Vec::new(),
tool_contributors: Vec::new(),
tool_lifecycle_contributors: Vec::new(),
turn_item_contributors: Vec::new(),
@@ -98,6 +101,11 @@ impl<C: Sync> ExtensionRegistryBuilder<C> {
self.context_contributors.push(contributor);
}
/// Registers one turn-input contributor.
pub fn turn_input_contributor(&mut self, contributor: Arc<dyn TurnInputContributor>) {
self.turn_input_contributors.push(contributor);
}
/// Registers one native tool contributor.
pub fn tool_contributor(&mut self, contributor: Arc<dyn ToolContributor>) {
self.tool_contributors.push(contributor);
@@ -123,6 +131,7 @@ impl<C: Sync> ExtensionRegistryBuilder<C> {
token_usage_contributors: self.token_usage_contributors,
approval_review_contributors: self.approval_review_contributors,
context_contributors: self.context_contributors,
turn_input_contributors: self.turn_input_contributors,
tool_contributors: self.tool_contributors,
tool_lifecycle_contributors: self.tool_lifecycle_contributors,
turn_item_contributors: self.turn_item_contributors,
@@ -138,6 +147,7 @@ pub struct ExtensionRegistry<C: Sync> {
config_contributors: Vec<Arc<dyn ConfigContributor<C>>>,
token_usage_contributors: Vec<Arc<dyn TokenUsageContributor>>,
context_contributors: Vec<Arc<dyn ContextContributor>>,
turn_input_contributors: Vec<Arc<dyn TurnInputContributor>>,
tool_contributors: Vec<Arc<dyn ToolContributor>>,
tool_lifecycle_contributors: Vec<Arc<dyn ToolLifecycleContributor>>,
turn_item_contributors: Vec<Arc<dyn TurnItemContributor>>,
@@ -195,6 +205,11 @@ impl<C: Sync> ExtensionRegistry<C> {
&self.context_contributors
}
/// Returns the registered turn-input contributors.
pub fn turn_input_contributors(&self) -> &[Arc<dyn TurnInputContributor>] {
&self.turn_input_contributors
}
/// Returns the registered native tool contributors.
pub fn tool_contributors(&self) -> &[Arc<dyn ToolContributor>] {
&self.tool_contributors

View File

@@ -77,9 +77,9 @@ impl ToolExecutor<ToolCall> for ImageGenerationTool {
imagegen_tool_spec()
}
/// Keeps this model-facing tool out of the nested code-mode tool surface.
/// Exposes image generation directly and through the nested code-mode tool surface.
fn exposure(&self) -> ToolExposure {
ToolExposure::DirectModelOnly
ToolExposure::Direct
}
/// Executes the selected image operation and returns the completed image result.

View File

@@ -0,0 +1,6 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "skills",
crate_name = "codex_skills_extension",
)

View File

@@ -0,0 +1,19 @@
[package]
edition.workspace = true
license.workspace = true
name = "codex-skills-extension"
version.workspace = true
[lib]
name = "codex_skills_extension"
path = "src/lib.rs"
test = false
doctest = false
[lints]
workspace = true
[dependencies]
async-trait = { workspace = true }
codex-core = { workspace = true }
codex-extension-api = { workspace = true }

View File

@@ -0,0 +1,86 @@
/// Source authority that owns a skill package and must be used to read it.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum SkillSourceKind {
Host,
Executor,
Remote,
}
/// Opaque authority identity for list/read routing.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct SkillAuthority {
pub kind: SkillSourceKind,
pub id: String,
}
impl SkillAuthority {
pub fn new(kind: SkillSourceKind, id: impl Into<String>) -> Self {
Self {
kind,
id: id.into(),
}
}
}
/// Opaque package id. Callers should not parse local paths out of this value.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct SkillPackageId(pub String);
/// Opaque resource id inside a skill package.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct SkillResourceId(pub String);
/// Metadata shown in the always-visible skills catalog.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SkillCatalogEntry {
pub id: SkillPackageId,
pub authority: SkillAuthority,
pub name: String,
pub description: String,
pub entrypoint: SkillResourceId,
}
/// Merged catalog for one turn.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SkillCatalog {
pub entries: Vec<SkillCatalogEntry>,
pub warnings: Vec<String>,
}
impl SkillCatalog {
pub fn extend(&mut self, other: SkillCatalog) {
// TODO(skills-extension): dedupe by authority-bound id first, then
// apply name precedence/conflict rules for user-facing mention
// resolution. Names are not stable identities.
self.entries.extend(other.entries);
self.warnings.extend(other.warnings);
}
}
/// Contents returned after resolving a skill resource through its owner.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SkillReadResult {
pub resource: SkillResourceId,
pub contents: String,
}
/// Search results for a package whose files are not readable through ordinary
/// executor filesystem access.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SkillSearchResult {
pub matches: Vec<SkillSearchMatch>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SkillSearchMatch {
pub resource: SkillResourceId,
pub title: String,
pub snippet: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SkillProviderError {
pub message: String,
}
pub type SkillProviderResult<T> = Result<T, SkillProviderError>;

View File

@@ -0,0 +1,140 @@
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use codex_core::config::Config;
use codex_extension_api::ConfigContributor;
use codex_extension_api::ContextContributor;
use codex_extension_api::ExtensionData;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::PromptFragment;
use codex_extension_api::ThreadLifecycleContributor;
use codex_extension_api::ThreadStartInput;
use codex_extension_api::TurnLifecycleContributor;
use codex_extension_api::TurnStartInput;
use crate::provider::SkillListQuery;
use crate::providers::SkillProviders;
use crate::state::SkillsExtensionConfig;
use crate::state::SkillsTurnState;
#[derive(Clone, Debug, Default)]
struct SkillsExtension {
providers: SkillProviders,
}
#[async_trait::async_trait]
impl ThreadLifecycleContributor<Config> for SkillsExtension {
async fn on_thread_start(&self, input: ThreadStartInput<'_, Config>) {
// TODO(skills-extension): this is only the thread-level config snapshot.
// Skills are loaded per turn today because cwd, plugin roots, config
// layers, and the primary environment filesystem can change between
// turns. The real migration needs a turn-preparation hook before model
// input construction, not just thread startup.
input
.thread_store
.insert(SkillsExtensionConfig::from_config(input.config));
}
}
impl ConfigContributor<Config> for SkillsExtension {
fn on_config_changed(
&self,
_session_store: &ExtensionData,
thread_store: &ExtensionData,
_previous_config: &Config,
new_config: &Config,
) {
// TODO(skills-extension): update any cached/listing state that depends
// on skill config overrides, bundled skills, or include_instructions.
thread_store.insert(SkillsExtensionConfig::from_config(new_config));
}
}
impl ContextContributor for SkillsExtension {
fn contribute<'a>(
&'a self,
_session_store: &'a ExtensionData,
thread_store: &'a ExtensionData,
) -> Pin<Box<dyn Future<Output = Vec<PromptFragment>> + Send + 'a>> {
Box::pin(async move {
let Some(config) = thread_store.get::<SkillsExtensionConfig>() else {
return Vec::new();
};
if !config.include_instructions {
return Vec::new();
}
// TODO(skills-extension): render the available-skills developer
// block from the merged per-turn SkillCatalog. This should
// preserve the existing bounded metadata budget, root aliasing,
// warning behavior, and telemetry side effects.
//
// TODO(skills-extension): avoid using raw PromptFragment strings
// for final skills context if the extension API grows typed
// contextual fragments. Existing skill blocks are typed so resume
// and history filtering can recognize them reliably.
//
// TODO(skills-extension): ContextContributor currently cannot see
// the turn_store, so it cannot read the per-turn catalog seeded by
// the turn provider path below. This is the main extension-api gap
// to close before skills can move out of codex-core.
Vec::new()
})
}
}
#[async_trait::async_trait]
impl TurnLifecycleContributor for SkillsExtension {
async fn on_turn_start(&self, input: TurnStartInput<'_>) {
// TODO(skills-extension): replace this lifecycle callback with a real
// turn-input contributor in codex-extension-api. This placeholder only
// demonstrates where provider aggregation belongs; it cannot resolve
// real skills because this hook does not receive cwd, executor
// selections, effective plugins/materialized plugin skill roots,
// connector slug counts, user input, cancellation, analytics, or a
// response-item output channel.
let query = SkillListQuery::placeholder_for_turn(input.turn_id);
let catalog = self
.providers
.list_for_turn(query)
.await
.unwrap_or_default();
input.turn_store.insert(SkillsTurnState {
catalog,
entrypoints_injected: false,
});
// TODO(skills-extension): after catalog resolution, collect explicit
// skill mentions from structured UserInput and text mentions.
//
// TODO(skills-extension): inject selected entrypoints as typed
// contextual user fragments, preserving <skill>...</skill> history
// recognition and bounded body size limits.
//
// TODO(skills-extension): move explicit $skill mention resolution,
// SKILL.md reads, skill body injection, and MCP dependency prompting
// out of codex-core's turn assembly once that hook exists.
}
}
/// Installs the skills extension contributor sketch.
///
/// TODO(skills-extension): pass host capabilities here rather than letting the
/// extension depend on Session. The final extension needs capability objects for
/// loading skill roots, emitting warnings, tracking analytics, prompting for MCP
/// dependency install, refreshing MCP servers, and serving app-server catalog
/// requests.
///
/// TODO(skills-extension): plugin handling should stay outside the runtime
/// skills model. Plugins are bundle/install units; once installed or refreshed,
/// their skill descriptors/roots should be handed to this extension just like
/// any other host-owned skill source.
pub fn install(registry: &mut ExtensionRegistryBuilder<Config>) {
let extension = Arc::new(SkillsExtension::default());
registry.thread_lifecycle_contributor(extension.clone());
registry.config_contributor(extension.clone());
registry.prompt_contributor(extension.clone());
registry.turn_lifecycle_contributor(extension);
}

View File

@@ -0,0 +1,7 @@
pub mod catalog;
mod extension;
pub mod provider;
mod providers;
mod state;
pub use extension::install;

View File

@@ -0,0 +1,63 @@
use std::future::Future;
use crate::catalog::SkillAuthority;
use crate::catalog::SkillCatalog;
use crate::catalog::SkillPackageId;
use crate::catalog::SkillProviderResult;
use crate::catalog::SkillReadResult;
use crate::catalog::SkillResourceId;
use crate::catalog::SkillSearchResult;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SkillListQuery {
pub turn_id: String,
pub executor_authorities: Vec<SkillAuthority>,
pub include_host_skills: bool,
pub include_remote_skills: bool,
}
impl SkillListQuery {
pub(crate) fn placeholder_for_turn(turn_id: &str) -> Self {
Self {
turn_id: turn_id.to_string(),
executor_authorities: Vec::new(),
include_host_skills: true,
include_remote_skills: true,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SkillReadRequest {
pub authority: SkillAuthority,
pub resource: SkillResourceId,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SkillSearchRequest {
pub authority: SkillAuthority,
pub package: SkillPackageId,
pub query: String,
}
/// Source-specific skill catalog and resource access.
///
/// Implementations must preserve authority boundaries: a resource listed by a
/// provider must be read or searched through the same provider/authority rather
/// than converted into an ambient local path.
pub trait SkillProvider: Send + Sync {
fn list(
&self,
query: SkillListQuery,
) -> impl Future<Output = SkillProviderResult<SkillCatalog>> + Send;
fn read(
&self,
request: SkillReadRequest,
) -> impl Future<Output = SkillProviderResult<SkillReadResult>> + Send;
fn search(
&self,
request: SkillSearchRequest,
) -> impl Future<Output = SkillProviderResult<SkillSearchResult>> + Send;
}

View File

@@ -0,0 +1,56 @@
use std::future;
use crate::catalog::SkillCatalog;
use crate::catalog::SkillProviderResult;
use crate::catalog::SkillReadResult;
use crate::catalog::SkillSearchResult;
use crate::provider::SkillListQuery;
use crate::provider::SkillProvider;
use crate::provider::SkillReadRequest;
use crate::provider::SkillSearchRequest;
#[derive(Clone, Debug, Default)]
pub(crate) struct ExecutorSkillProvider;
impl SkillProvider for ExecutorSkillProvider {
fn list(
&self,
_query: SkillListQuery,
) -> impl Future<Output = SkillProviderResult<SkillCatalog>> + Send {
future::ready(Ok(SkillCatalog::default()))
// TODO(skills-extension): list repo/workspace skills from each
// executor authority selected for the turn.
//
// TODO(skills-extension): if the executor exposes filesystem reads,
// preserve the existing SKILL.md discovery semantics. If CCA/no-FS
// applies, query an executor catalog/read capability instead.
//
// TODO(skills-extension): include the executor/environment id in skill
// identity so two executors with the same path/name do not collide.
}
fn read(
&self,
request: SkillReadRequest,
) -> impl Future<Output = SkillProviderResult<SkillReadResult>> + Send {
future::ready(Err(crate::catalog::SkillProviderError {
message: format!(
"executor skill resource `{}` is not implemented",
request.resource.0
),
}))
// TODO(skills-extension): route reads back to the executor authority
// that listed the resource. Do not mint local paths from remote or
// non-filesystem executor resources.
}
fn search(
&self,
_request: SkillSearchRequest,
) -> impl Future<Output = SkillProviderResult<SkillSearchResult>> + Send {
future::ready(Ok(SkillSearchResult::default()))
// TODO(skills-extension): support search for executor skills only when
// the executor offers a catalog/search API. For ordinary filesystem
// executors, the model can keep using regular file reads/search tools.
}
}

View File

@@ -0,0 +1,65 @@
use std::future;
use crate::catalog::SkillCatalog;
use crate::catalog::SkillProviderResult;
use crate::catalog::SkillReadResult;
use crate::catalog::SkillSearchResult;
use crate::provider::SkillListQuery;
use crate::provider::SkillProvider;
use crate::provider::SkillReadRequest;
use crate::provider::SkillSearchRequest;
#[derive(Clone, Debug, Default)]
pub(crate) struct HostSkillProvider;
impl SkillProvider for HostSkillProvider {
fn list(
&self,
_query: SkillListQuery,
) -> impl Future<Output = SkillProviderResult<SkillCatalog>> + Send {
future::ready(Ok(SkillCatalog::default()))
// TODO(skills-extension): list bundled/system/user/plugin-installed
// skills owned by the Codex host. This is the source for skills that
// are not tied to a particular executor authority.
//
// TODO(skills-extension): plugins should be treated as packaging and
// installation only. After a plugin is downloaded, cached, refreshed,
// or installed, its skill roots/descriptors should enter this provider
// and then use the normal skills catalog/read/injection code.
//
// TODO(skills-extension): remote skills that are materialized locally
// by plugin install or explicit download should also hand off here
// rather than remain remote-provider entries at runtime.
//
// TODO(skills-extension): keep current bundled system skill install or
// replace it with embedded host assets so CCA/no-FS hosts do not depend
// on local writable skill cache directories.
}
fn read(
&self,
request: SkillReadRequest,
) -> impl Future<Output = SkillProviderResult<SkillReadResult>> + Send {
future::ready(Err(crate::catalog::SkillProviderError {
message: format!(
"host skill resource `{}` is not implemented",
request.resource.0
),
}))
// TODO(skills-extension): read host-owned entrypoints and supporting
// resources by opaque id, not by assuming a local filesystem path.
//
// TODO(skills-extension): for plugin-installed skills, route reads
// through the materialized plugin cache/root that produced the catalog
// entry, while keeping the public id opaque and authority-bound.
}
fn search(
&self,
_request: SkillSearchRequest,
) -> impl Future<Output = SkillProviderResult<SkillSearchResult>> + Send {
future::ready(Ok(SkillSearchResult::default()))
// TODO(skills-extension): decide whether host skills need search, or
// whether direct read by opaque resource id is enough.
}
}

View File

@@ -0,0 +1,50 @@
mod executor;
mod host;
mod remote;
use crate::catalog::SkillCatalog;
use crate::catalog::SkillProviderResult;
use crate::provider::SkillListQuery;
use crate::provider::SkillProvider;
use executor::ExecutorSkillProvider;
use host::HostSkillProvider;
use remote::RemoteSkillProvider;
#[derive(Clone, Debug, Default)]
pub(crate) struct SkillProviders {
host: HostSkillProvider,
executor: ExecutorSkillProvider,
remote: RemoteSkillProvider,
}
impl SkillProviders {
pub(crate) async fn list_for_turn(
&self,
query: SkillListQuery,
) -> SkillProviderResult<SkillCatalog> {
let mut catalog = SkillCatalog::default();
if query.include_host_skills {
catalog.extend(self.host.list(query.clone()).await?);
}
if !query.executor_authorities.is_empty() {
catalog.extend(self.executor.list(query.clone()).await?);
}
if query.include_remote_skills {
catalog.extend(self.remote.list(query).await?);
}
// TODO(skills-extension): apply final merged-catalog policy here:
// source precedence, duplicate name handling, disabled-skill rules,
// product/session-source filtering, and telemetry for omitted entries.
//
// TODO(skills-extension): treat plugin-installed skills as ordinary
// host catalog entries by this point. Plugin identity may remain useful
// for display, auth, and uninstall flows, but mention resolution and
// entrypoint injection should not special-case plugin packaging.
Ok(catalog)
}
}

View File

@@ -0,0 +1,58 @@
use std::future;
use crate::catalog::SkillCatalog;
use crate::catalog::SkillProviderResult;
use crate::catalog::SkillReadResult;
use crate::catalog::SkillSearchResult;
use crate::provider::SkillListQuery;
use crate::provider::SkillProvider;
use crate::provider::SkillReadRequest;
use crate::provider::SkillSearchRequest;
#[derive(Clone, Debug, Default)]
pub(crate) struct RemoteSkillProvider;
impl SkillProvider for RemoteSkillProvider {
fn list(
&self,
_query: SkillListQuery,
) -> impl Future<Output = SkillProviderResult<SkillCatalog>> + Send {
future::ready(Ok(SkillCatalog::default()))
// TODO(skills-extension): list org/account/backend skills from a
// remote catalog only when they are not installed/materialized into the
// host. These skills should use opaque ids and backend authority, not
// paths.
//
// TODO(skills-extension): if a remote skill is downloaded or installed
// as part of a plugin-like bundle, hand it to HostSkillProvider for
// runtime listing/read instead of keeping a separate remote runtime
// path.
//
// TODO(skills-extension): decide how org policy and local enable/disable
// rules combine when the backend supplies a managed skill catalog.
}
fn read(
&self,
request: SkillReadRequest,
) -> impl Future<Output = SkillProviderResult<SkillReadResult>> + Send {
future::ready(Err(crate::catalog::SkillProviderError {
message: format!(
"remote skill resource `{}` is not implemented",
request.resource.0
),
}))
// TODO(skills-extension): read remote skill entrypoints and supporting
// files through authenticated backend APIs.
}
fn search(
&self,
_request: SkillSearchRequest,
) -> impl Future<Output = SkillProviderResult<SkillSearchResult>> + Send {
future::ready(Ok(SkillSearchResult::default()))
// TODO(skills-extension): expose model-facing skills/search or resource
// APIs for large remote packages so the model can progressively
// disclose supporting files without filesystem access.
}
}

View File

@@ -0,0 +1,24 @@
use codex_core::config::Config;
use crate::catalog::SkillCatalog;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct SkillsExtensionConfig {
pub(crate) include_instructions: bool,
pub(crate) bundled_skills_enabled: bool,
}
impl SkillsExtensionConfig {
pub(crate) fn from_config(config: &Config) -> Self {
Self {
include_instructions: config.include_skill_instructions,
bundled_skills_enabled: config.bundled_skills_enabled(),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct SkillsTurnState {
pub(crate) catalog: SkillCatalog,
pub(crate) entrypoints_injected: bool,
}

File diff suppressed because one or more lines are too long

View File

@@ -83,6 +83,12 @@ test *args:
$env:RUST_MIN_STACK = "{{ rust_min_stack }}"; cargo nextest run --no-fail-fast @($args | Select-Object -Skip 1)
just bench-smoke
# Run from the repository root so scripts that resolve paths from `cwd` see
# the same layout they use in GitHub Actions.
[no-cd]
test-github-scripts:
{{ python }} -m unittest discover -s {{ justfile_directory() }}/.github/scripts -p 'test_*.py'
# Run explicit workspace benchmark targets.
bench *args:
cargo bench --workspace --bench '*' {args}
@@ -129,11 +135,8 @@ bazel-clippy:
bazel-argument-comment-lint:
bazel build --config=argument-comment-lint -- $({{ justfile_directory() }}/tools/argument-comment-lint/list-bazel-targets.sh)
bazel-remote-test:
bazel test --test_tag_filters=-argument-comment-lint //... --config=remote --platforms=//:rbe --keep_going
build-for-release:
bazel build //codex-rs/cli:release_binaries --config=remote
bazel build //codex-rs/cli:release_binaries
# Run the MCP server
mcp-server-run *args:

View File

@@ -20,23 +20,13 @@ while [[ $# -gt 0 ]]; do
done
# Resolve the dynamic targets before printing anything so callers do not
# continue with a partial list if `bazel query` fails. Reuse the same CI Bazel
# server settings as the subsequent build so Windows jobs do not cold-start a
# second Bazel server just for target discovery.
if [[ $windows_cross_compile -eq 1 ]]; then
manual_rust_test_targets="$(
./.github/scripts/run-bazel-query-ci.sh \
--windows-cross-compile \
--output=label \
-- 'kind("rust_test rule", attr(tags, "manual", //codex-rs/... except //codex-rs/v8-poc/...))'
)"
else
manual_rust_test_targets="$(
./.github/scripts/run-bazel-query-ci.sh \
--output=label \
-- 'kind("rust_test rule", attr(tags, "manual", //codex-rs/... except //codex-rs/v8-poc/...))'
)"
fi
# continue with a partial list if `bazel query` fails. Target discovery is
# local on all platforms.
manual_rust_test_targets="$(
./.github/scripts/run-bazel-query-ci.sh \
--output=label \
-- 'kind("rust_test rule", attr(tags, "manual", //codex-rs/... except //codex-rs/v8-poc/...))'
)"
if [[ "${RUNNER_OS:-}" != "Windows" ]]; then
# Non-Windows clippy jobs lint the native test binaries; the
# Windows-cross binaries exist only for the fast Windows test leg.