mirror of
https://github.com/openai/codex.git
synced 2026-10-29 19:08:43 +00:00
Merge remote-tracking branch 'origin/rhan/surface-updates' into rhan/emittance
# Conflicts: # codex-rs/core/src/codex.rs
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
---
|
||||
name: remote-tests
|
||||
description: How to run tests using remote executor.
|
||||
---
|
||||
|
||||
Some codex integration tests support a running against a remote executor.
|
||||
This means that when CODEX_TEST_REMOTE_ENV environment variable is set they will attempt to start an executor process in a docker container CODEX_TEST_REMOTE_ENV points to and use it in tests.
|
||||
|
||||
Docker container is built and initialized via ./scripts/test-remote-env.sh
|
||||
|
||||
Currently running remote tests is only supported on Linux, so you need to use a devbox to run them
|
||||
|
||||
You can list devboxes via `applied_devbox ls`, pick the one with `codex` in the name.
|
||||
Connect to devbox via `ssh <devbox_name>`.
|
||||
Reuse the same checkout of codex in `~/code/codex`. Reset files if needed. Multiple checkouts take longer to build and take up more space.
|
||||
Check whether the SHA and modified files are in sync between remote and local.
|
||||
@@ -132,9 +132,11 @@ runs:
|
||||
keychain_args+=(--keychain "${APPLE_CODESIGN_KEYCHAIN}")
|
||||
fi
|
||||
|
||||
entitlements_path="$GITHUB_ACTION_PATH/codex.entitlements.plist"
|
||||
|
||||
for binary in codex codex-responses-api-proxy; do
|
||||
path="codex-rs/target/${TARGET}/release/${binary}"
|
||||
codesign --force --options runtime --timestamp --sign "$APPLE_CODESIGN_IDENTITY" "${keychain_args[@]}" "$path"
|
||||
codesign --force --options runtime --timestamp --entitlements "$entitlements_path" --sign "$APPLE_CODESIGN_IDENTITY" "${keychain_args[@]}" "$path"
|
||||
done
|
||||
|
||||
- name: Notarize macOS binaries
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -447,6 +447,24 @@ jobs:
|
||||
echo "CFLAGS=${cflags}" >> "$GITHUB_ENV"
|
||||
echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV"
|
||||
|
||||
- if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl' }}
|
||||
name: Configure musl rusty_v8 artifact overrides
|
||||
env:
|
||||
TARGET: ${{ matrix.target }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="$(python3 "${GITHUB_WORKSPACE}/.github/scripts/rusty_v8_bazel.py" resolved-v8-crate-version)"
|
||||
release_tag="rusty-v8-v${version}"
|
||||
base_url="https://github.com/openai/codex/releases/download/${release_tag}"
|
||||
archive="https://github.com/openai/codex/releases/download/rusty-v8-v${version}/librusty_v8_release_${TARGET}.a.gz"
|
||||
binding_dir="${RUNNER_TEMP}/rusty_v8"
|
||||
binding_path="${binding_dir}/src_binding_release_${TARGET}.rs"
|
||||
mkdir -p "${binding_dir}"
|
||||
curl -fsSL "${base_url}/src_binding_release_${TARGET}.rs" -o "${binding_path}"
|
||||
echo "RUSTY_V8_ARCHIVE=${archive}" >> "$GITHUB_ENV"
|
||||
echo "RUSTY_V8_SRC_BINDING_PATH=${binding_path}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install cargo-chef
|
||||
if: ${{ matrix.profile == 'release' }}
|
||||
uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2
|
||||
@@ -527,7 +545,7 @@ jobs:
|
||||
key: apt-${{ matrix.runner }}-${{ matrix.target }}-v1
|
||||
|
||||
tests:
|
||||
name: Tests — ${{ matrix.runner }} - ${{ matrix.target }}
|
||||
name: Tests — ${{ matrix.runner }} - ${{ matrix.target }}${{ matrix.remote_env == 'true' && ' (remote)' || '' }}
|
||||
runs-on: ${{ matrix.runs_on || matrix.runner }}
|
||||
timeout-minutes: 30
|
||||
needs: changed
|
||||
@@ -553,6 +571,7 @@ jobs:
|
||||
- runner: ubuntu-24.04
|
||||
target: x86_64-unknown-linux-gnu
|
||||
profile: dev
|
||||
remote_env: "true"
|
||||
runs_on:
|
||||
group: codex-runners
|
||||
labels: codex-linux-x64
|
||||
@@ -590,6 +609,7 @@ jobs:
|
||||
sudo apt-get update -y
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev
|
||||
fi
|
||||
|
||||
# Some integration tests rely on DotSlash being installed.
|
||||
# See https://github.com/openai/codex/pull/7617.
|
||||
- name: Install DotSlash
|
||||
@@ -674,6 +694,15 @@ jobs:
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
fi
|
||||
|
||||
- name: Set up remote test env (Docker)
|
||||
if: ${{ runner.os == 'Linux' && matrix.remote_env == 'true' }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export CODEX_TEST_REMOTE_ENV_CONTAINER_NAME=codex-remote-test-env
|
||||
source "${GITHUB_WORKSPACE}/scripts/test-remote-env.sh"
|
||||
echo "CODEX_TEST_REMOTE_ENV=${CODEX_TEST_REMOTE_ENV}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: tests
|
||||
id: test
|
||||
run: cargo nextest run --all-features --no-fail-fast --target ${{ matrix.target }} --cargo-profile ci-test --timings
|
||||
@@ -726,6 +755,16 @@ jobs:
|
||||
echo '```';
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Tear down remote test env
|
||||
if: ${{ always() && runner.os == 'Linux' && matrix.remote_env == 'true' }}
|
||||
shell: bash
|
||||
run: |
|
||||
set +e
|
||||
if [[ "${{ steps.test.outcome }}" != "success" ]]; then
|
||||
docker logs codex-remote-test-env || true
|
||||
fi
|
||||
docker rm -f codex-remote-test-env >/dev/null 2>&1 || true
|
||||
|
||||
- name: verify tests passed
|
||||
if: steps.test.outcome == 'failure'
|
||||
run: |
|
||||
|
||||
@@ -210,6 +210,24 @@ jobs:
|
||||
echo "CFLAGS=${cflags}" >> "$GITHUB_ENV"
|
||||
echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV"
|
||||
|
||||
- if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl' }}
|
||||
name: Configure musl rusty_v8 artifact overrides
|
||||
env:
|
||||
TARGET: ${{ matrix.target }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="$(python3 "${GITHUB_WORKSPACE}/.github/scripts/rusty_v8_bazel.py" resolved-v8-crate-version)"
|
||||
release_tag="rusty-v8-v${version}"
|
||||
base_url="https://github.com/openai/codex/releases/download/${release_tag}"
|
||||
archive="https://github.com/openai/codex/releases/download/rusty-v8-v${version}/librusty_v8_release_${TARGET}.a.gz"
|
||||
binding_dir="${RUNNER_TEMP}/rusty_v8"
|
||||
binding_path="${binding_dir}/src_binding_release_${TARGET}.rs"
|
||||
mkdir -p "${binding_dir}"
|
||||
curl -fsSL "${base_url}/src_binding_release_${TARGET}.rs" -o "${binding_path}"
|
||||
echo "RUSTY_V8_ARCHIVE=${archive}" >> "$GITHUB_ENV"
|
||||
echo "RUSTY_V8_SRC_BINDING_PATH=${binding_path}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Cargo build
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
+138
-21
@@ -2,31 +2,41 @@ module(name = "codex")
|
||||
|
||||
bazel_dep(name = "bazel_skylib", version = "1.8.2")
|
||||
bazel_dep(name = "platforms", version = "1.0.0")
|
||||
bazel_dep(name = "llvm", version = "0.6.7")
|
||||
bazel_dep(name = "llvm", version = "0.6.8")
|
||||
|
||||
register_toolchains("@llvm//toolchain:all")
|
||||
|
||||
osx = use_extension("@llvm//extensions:osx.bzl", "osx")
|
||||
osx.framework(name = "ApplicationServices")
|
||||
osx.framework(name = "AppKit")
|
||||
osx.framework(name = "ColorSync")
|
||||
osx.framework(name = "CoreFoundation")
|
||||
osx.framework(name = "CoreGraphics")
|
||||
osx.framework(name = "CoreServices")
|
||||
osx.framework(name = "CoreText")
|
||||
osx.framework(name = "AudioToolbox")
|
||||
osx.framework(name = "CFNetwork")
|
||||
osx.framework(name = "FontServices")
|
||||
osx.framework(name = "AudioUnit")
|
||||
osx.framework(name = "CoreAudio")
|
||||
osx.framework(name = "CoreAudioTypes")
|
||||
osx.framework(name = "Foundation")
|
||||
osx.framework(name = "ImageIO")
|
||||
osx.framework(name = "IOKit")
|
||||
osx.framework(name = "Kernel")
|
||||
osx.framework(name = "OSLog")
|
||||
osx.framework(name = "Security")
|
||||
osx.framework(name = "SystemConfiguration")
|
||||
osx.from_archive(
|
||||
sha256 = "6a4922f89487a96d7054ec6ca5065bfddd9f1d017c74d82f1d79cecf7feb8228",
|
||||
strip_prefix = "Payload/Library/Developer/CommandLineTools/SDKs/MacOSX26.2.sdk",
|
||||
type = "pkg",
|
||||
urls = [
|
||||
"https://swcdn.apple.com/content/downloads/26/44/047-81934-A_28TPKM5SD1/ps6pk6dk4x02vgfa5qsctq6tgf23t5f0w2/CLTools_macOSNMOS_SDK.pkg",
|
||||
],
|
||||
)
|
||||
osx.frameworks(names = [
|
||||
"ApplicationServices",
|
||||
"AppKit",
|
||||
"ColorSync",
|
||||
"CoreFoundation",
|
||||
"CoreGraphics",
|
||||
"CoreServices",
|
||||
"CoreText",
|
||||
"AudioToolbox",
|
||||
"CFNetwork",
|
||||
"FontServices",
|
||||
"AudioUnit",
|
||||
"CoreAudio",
|
||||
"CoreAudioTypes",
|
||||
"Foundation",
|
||||
"ImageIO",
|
||||
"IOKit",
|
||||
"Kernel",
|
||||
"OSLog",
|
||||
"Security",
|
||||
"SystemConfiguration",
|
||||
])
|
||||
use_repo(osx, "macos_sdk")
|
||||
|
||||
# Needed to disable xcode...
|
||||
@@ -134,6 +144,33 @@ crate.annotation(
|
||||
)
|
||||
|
||||
http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
|
||||
http_file = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_file")
|
||||
new_local_repository = use_repo_rule("@bazel_tools//tools/build_defs/repo:local.bzl", "new_local_repository")
|
||||
|
||||
new_local_repository(
|
||||
name = "v8_targets",
|
||||
build_file = "//third_party/v8:BUILD.bazel",
|
||||
path = "third_party/v8",
|
||||
)
|
||||
|
||||
crate.annotation(
|
||||
build_script_data = [
|
||||
"@v8_targets//:rusty_v8_archive_for_target",
|
||||
"@v8_targets//:rusty_v8_binding_for_target",
|
||||
],
|
||||
build_script_env = {
|
||||
"RUSTY_V8_ARCHIVE": "$(execpath @v8_targets//:rusty_v8_archive_for_target)",
|
||||
"RUSTY_V8_SRC_BINDING_PATH": "$(execpath @v8_targets//:rusty_v8_binding_for_target)",
|
||||
},
|
||||
crate = "v8",
|
||||
gen_build_script = "on",
|
||||
patch_args = ["-p1"],
|
||||
patches = [
|
||||
"//patches:rusty_v8_prebuilt_out_dir.patch",
|
||||
],
|
||||
)
|
||||
|
||||
inject_repo(crate, "v8_targets")
|
||||
|
||||
llvm = use_extension("@llvm//extensions:llvm.bzl", "llvm")
|
||||
use_repo(llvm, "llvm-project")
|
||||
@@ -200,6 +237,86 @@ http_archive(
|
||||
urls = ["https://static.crates.io/crates/v8/v8-146.4.0.crate"],
|
||||
)
|
||||
|
||||
http_file(
|
||||
name = "rusty_v8_146_4_0_aarch64_apple_darwin_archive",
|
||||
downloaded_file_path = "librusty_v8_release_aarch64-apple-darwin.a.gz",
|
||||
urls = [
|
||||
"https://github.com/denoland/rusty_v8/releases/download/v146.4.0/librusty_v8_release_aarch64-apple-darwin.a.gz",
|
||||
],
|
||||
)
|
||||
|
||||
http_file(
|
||||
name = "rusty_v8_146_4_0_aarch64_unknown_linux_gnu_archive",
|
||||
downloaded_file_path = "librusty_v8_release_aarch64-unknown-linux-gnu.a.gz",
|
||||
urls = [
|
||||
"https://github.com/denoland/rusty_v8/releases/download/v146.4.0/librusty_v8_release_aarch64-unknown-linux-gnu.a.gz",
|
||||
],
|
||||
)
|
||||
|
||||
http_file(
|
||||
name = "rusty_v8_146_4_0_aarch64_pc_windows_msvc_archive",
|
||||
downloaded_file_path = "rusty_v8_release_aarch64-pc-windows-msvc.lib.gz",
|
||||
urls = [
|
||||
"https://github.com/denoland/rusty_v8/releases/download/v146.4.0/rusty_v8_release_aarch64-pc-windows-msvc.lib.gz",
|
||||
],
|
||||
)
|
||||
|
||||
http_file(
|
||||
name = "rusty_v8_146_4_0_x86_64_apple_darwin_archive",
|
||||
downloaded_file_path = "librusty_v8_release_x86_64-apple-darwin.a.gz",
|
||||
urls = [
|
||||
"https://github.com/denoland/rusty_v8/releases/download/v146.4.0/librusty_v8_release_x86_64-apple-darwin.a.gz",
|
||||
],
|
||||
)
|
||||
|
||||
http_file(
|
||||
name = "rusty_v8_146_4_0_x86_64_unknown_linux_gnu_archive",
|
||||
downloaded_file_path = "librusty_v8_release_x86_64-unknown-linux-gnu.a.gz",
|
||||
urls = [
|
||||
"https://github.com/denoland/rusty_v8/releases/download/v146.4.0/librusty_v8_release_x86_64-unknown-linux-gnu.a.gz",
|
||||
],
|
||||
)
|
||||
|
||||
http_file(
|
||||
name = "rusty_v8_146_4_0_x86_64_pc_windows_msvc_archive",
|
||||
downloaded_file_path = "rusty_v8_release_x86_64-pc-windows-msvc.lib.gz",
|
||||
urls = [
|
||||
"https://github.com/denoland/rusty_v8/releases/download/v146.4.0/rusty_v8_release_x86_64-pc-windows-msvc.lib.gz",
|
||||
],
|
||||
)
|
||||
|
||||
http_file(
|
||||
name = "rusty_v8_146_4_0_aarch64_unknown_linux_musl_archive",
|
||||
downloaded_file_path = "librusty_v8_release_aarch64-unknown-linux-musl.a.gz",
|
||||
urls = [
|
||||
"https://github.com/openai/codex/releases/download/rusty-v8-v146.4.0/librusty_v8_release_aarch64-unknown-linux-musl.a.gz",
|
||||
],
|
||||
)
|
||||
|
||||
http_file(
|
||||
name = "rusty_v8_146_4_0_aarch64_unknown_linux_musl_binding",
|
||||
downloaded_file_path = "src_binding_release_aarch64-unknown-linux-musl.rs",
|
||||
urls = [
|
||||
"https://github.com/openai/codex/releases/download/rusty-v8-v146.4.0/src_binding_release_aarch64-unknown-linux-musl.rs",
|
||||
],
|
||||
)
|
||||
|
||||
http_file(
|
||||
name = "rusty_v8_146_4_0_x86_64_unknown_linux_musl_archive",
|
||||
downloaded_file_path = "librusty_v8_release_x86_64-unknown-linux-musl.a.gz",
|
||||
urls = [
|
||||
"https://github.com/openai/codex/releases/download/rusty-v8-v146.4.0/librusty_v8_release_x86_64-unknown-linux-musl.a.gz",
|
||||
],
|
||||
)
|
||||
|
||||
http_file(
|
||||
name = "rusty_v8_146_4_0_x86_64_unknown_linux_musl_binding",
|
||||
downloaded_file_path = "src_binding_release_x86_64-unknown-linux-musl.rs",
|
||||
urls = [
|
||||
"https://github.com/openai/codex/releases/download/rusty-v8-v146.4.0/src_binding_release_x86_64-unknown-linux-musl.rs",
|
||||
],
|
||||
)
|
||||
|
||||
use_repo(crate, "crates")
|
||||
|
||||
bazel_dep(name = "libcap", version = "2.27.bcr.1")
|
||||
|
||||
Generated
+25
-5
File diff suppressed because one or more lines are too long
Generated
+265
-25
@@ -380,7 +380,7 @@ version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -391,7 +391,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -800,9 +800,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.15.4"
|
||||
version = "1.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256"
|
||||
checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"untrusted 0.7.1",
|
||||
@@ -811,9 +811,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.37.0"
|
||||
version = "0.39.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c34dda4df7017c8db52132f0f8a2e0f8161649d15723ed63fc00c82d0f2081a"
|
||||
checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
@@ -949,6 +949,8 @@ dependencies = [
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"itertools 0.13.0",
|
||||
"log",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
@@ -1152,6 +1154,16 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0"
|
||||
|
||||
[[package]]
|
||||
name = "calendrical_calculations"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a0b39595c6ee54a8d0900204ba4c401d0ab4eb45adaf07178e8d017541529e7"
|
||||
dependencies = [
|
||||
"core_maths",
|
||||
"displaydoc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cassowary"
|
||||
version = "0.3.0"
|
||||
@@ -1584,7 +1596,7 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"url",
|
||||
"which",
|
||||
"which 8.0.0",
|
||||
"wiremock",
|
||||
"zip",
|
||||
]
|
||||
@@ -1788,6 +1800,20 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-code-mode"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"v8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-config"
|
||||
version = "0.0.0"
|
||||
@@ -1845,6 +1871,7 @@ dependencies = [
|
||||
"codex-arg0",
|
||||
"codex-artifacts",
|
||||
"codex-async-utils",
|
||||
"codex-code-mode",
|
||||
"codex-config",
|
||||
"codex-connectors",
|
||||
"codex-exec-server",
|
||||
@@ -1930,7 +1957,7 @@ dependencies = [
|
||||
"url",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
"which",
|
||||
"which 8.0.0",
|
||||
"wildmatch",
|
||||
"windows-sys 0.52.0",
|
||||
"wiremock",
|
||||
@@ -2179,7 +2206,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"which",
|
||||
"which 8.0.0",
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
@@ -2432,7 +2459,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"urlencoding",
|
||||
"webbrowser",
|
||||
"which",
|
||||
"which 8.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2472,7 +2499,7 @@ dependencies = [
|
||||
"tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
"url",
|
||||
"which",
|
||||
"which 8.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2644,7 +2671,7 @@ dependencies = [
|
||||
"uuid",
|
||||
"vt100",
|
||||
"webbrowser",
|
||||
"which",
|
||||
"which 8.0.0",
|
||||
"windows-sys 0.52.0",
|
||||
"winsplit",
|
||||
]
|
||||
@@ -2736,7 +2763,7 @@ dependencies = [
|
||||
"uuid",
|
||||
"vt100",
|
||||
"webbrowser",
|
||||
"which",
|
||||
"which 8.0.0",
|
||||
"windows-sys 0.52.0",
|
||||
"winsplit",
|
||||
]
|
||||
@@ -2907,6 +2934,14 @@ dependencies = [
|
||||
"regex-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-v8-poc"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"pretty_assertions",
|
||||
"v8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-windows-sandbox"
|
||||
version = "0.0.0"
|
||||
@@ -3112,6 +3147,15 @@ version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "core_maths"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30"
|
||||
dependencies = [
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core_test_support"
|
||||
version = "0.0.0"
|
||||
@@ -3121,6 +3165,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"codex-arg0",
|
||||
"codex-core",
|
||||
"codex-exec-server",
|
||||
"codex-features",
|
||||
"codex-protocol",
|
||||
"codex-utils-absolute-path",
|
||||
@@ -3716,6 +3761,38 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "diplomat"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9adb46b05e2f53dcf6a7dfc242e4ce9eb60c369b6b6eb10826a01e93167f59c6"
|
||||
dependencies = [
|
||||
"diplomat_core",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "diplomat-runtime"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0569bd3caaf13829da7ee4e83dbf9197a0e1ecd72772da6d08f0b4c9285c8d29"
|
||||
|
||||
[[package]]
|
||||
name = "diplomat_core"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51731530ed7f2d4495019abc7df3744f53338e69e2863a6a64ae91821c763df1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde",
|
||||
"smallvec",
|
||||
"strck",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "6.0.0"
|
||||
@@ -3744,7 +3821,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users 0.5.2",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3989,7 +4066,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4322,6 +4399,16 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fslock"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04412b8935272e3a9bae6f48c7bfff74c2911f60525404edfdd28e49884c3bfb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.31"
|
||||
@@ -4550,6 +4637,15 @@ dependencies = [
|
||||
"regex-syntax 0.8.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gzip-header"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95cc527b92e6029a62960ad99aa8a6660faa4555fe5f731aab13aa6a921795a2"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.13"
|
||||
@@ -5007,6 +5103,28 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_calendar"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f0e52e009b6b16ba9c0693578796f2dd4aaa59a7f8f920423706714a89ac4e"
|
||||
dependencies = [
|
||||
"calendrical_calculations",
|
||||
"displaydoc",
|
||||
"icu_calendar_data",
|
||||
"icu_locale",
|
||||
"icu_locale_core",
|
||||
"icu_provider",
|
||||
"tinystr",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_calendar_data"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "527f04223b17edfe0bd43baf14a0cb1b017830db65f3950dc00224860a9a446d"
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.1.1"
|
||||
@@ -5393,7 +5511,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5441,6 +5559,12 @@ version = "1.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||
|
||||
[[package]]
|
||||
name = "ixdtf"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84de9d95a6d2547d9b77ee3f25fa0ee32e3c3a6484d47a55adebc0439c077992"
|
||||
|
||||
[[package]]
|
||||
name = "jiff"
|
||||
version = "0.2.18"
|
||||
@@ -6150,7 +6274,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6794,7 +6918,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7166,6 +7290,16 @@ dependencies = [
|
||||
"yansi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "3.4.0"
|
||||
@@ -7999,6 +8133,16 @@ dependencies = [
|
||||
"webpki-roots 1.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "resb"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a067ab3b5ca3b4dc307d0de9cf75f9f5e6ca9717b192b2f28a36c83e5de9e76"
|
||||
dependencies = [
|
||||
"potential_utf",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "resolv-conf"
|
||||
version = "0.7.6"
|
||||
@@ -8196,7 +8340,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.11.0",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8239,9 +8383,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.9"
|
||||
version = "0.103.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
||||
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
@@ -9379,6 +9523,15 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strck"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42316e70da376f3d113a68d138a60d8a9883c604fe97942721ec2068dab13a9f"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "streaming-iterator"
|
||||
version = "0.1.9"
|
||||
@@ -9601,9 +9754,9 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.44"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
|
||||
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
@@ -9620,7 +9773,40 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix 1.1.3",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "temporal_capi"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a151e402c2bdb6a3a2a2f3f225eddaead2e7ce7dd5d3fa2090deb11b17aa4ed8"
|
||||
dependencies = [
|
||||
"diplomat",
|
||||
"diplomat-runtime",
|
||||
"icu_calendar",
|
||||
"icu_locale",
|
||||
"num-traits",
|
||||
"temporal_rs",
|
||||
"timezone_provider",
|
||||
"writeable",
|
||||
"zoneinfo64",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "temporal_rs"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88afde3bd75d2fc68d77a914bece426aa08aa7649ffd0cdd4a11c3d4d33474d1"
|
||||
dependencies = [
|
||||
"core_maths",
|
||||
"icu_calendar",
|
||||
"icu_locale",
|
||||
"ixdtf",
|
||||
"num-traits",
|
||||
"timezone_provider",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9830,6 +10016,18 @@ dependencies = [
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "timezone_provider"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df9ba0000e9e73862f3e7ca1ff159e2ddf915c9d8bb11e38a7874760f445d993"
|
||||
dependencies = [
|
||||
"tinystr",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
"zoneinfo64",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-keccak"
|
||||
version = "2.0.2"
|
||||
@@ -10629,6 +10827,23 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "v8"
|
||||
version = "146.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d97bcac5cdc5a195a4813f1855a6bc658f240452aac36caa12fd6c6f16026ab1"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"bitflags 2.10.0",
|
||||
"fslock",
|
||||
"gzip-header",
|
||||
"home",
|
||||
"miniz_oxide",
|
||||
"paste",
|
||||
"temporal_capi",
|
||||
"which 6.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
@@ -10928,6 +11143,18 @@ version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||
|
||||
[[package]]
|
||||
name = "which"
|
||||
version = "6.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f"
|
||||
dependencies = [
|
||||
"either",
|
||||
"home",
|
||||
"rustix 0.38.44",
|
||||
"winsafe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "which"
|
||||
version = "8.0.0"
|
||||
@@ -10992,7 +11219,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11931,6 +12158,19 @@ version = "1.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445"
|
||||
|
||||
[[package]]
|
||||
name = "zoneinfo64"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb2e5597efbe7c421da8a7fd396b20b571704e787c21a272eecf35dfe9d386f0"
|
||||
dependencies = [
|
||||
"calendrical_calculations",
|
||||
"icu_locale_core",
|
||||
"potential_utf",
|
||||
"resb",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.3"
|
||||
|
||||
+7
-1
@@ -13,6 +13,7 @@ members = [
|
||||
"feedback",
|
||||
"features",
|
||||
"codex-backend-openapi-models",
|
||||
"code-mode",
|
||||
"cloud-requirements",
|
||||
"cloud-tasks",
|
||||
"cloud-tasks-client",
|
||||
@@ -45,6 +46,7 @@ members = [
|
||||
"otel",
|
||||
"tui",
|
||||
"tui_app_server",
|
||||
"v8-poc",
|
||||
"utils/absolute-path",
|
||||
"utils/cargo-bin",
|
||||
"utils/git",
|
||||
@@ -90,6 +92,7 @@ app_test_support = { path = "app-server/tests/common" }
|
||||
codex-ansi-escape = { path = "ansi-escape" }
|
||||
codex-api = { path = "codex-api" }
|
||||
codex-artifacts = { path = "artifacts" }
|
||||
codex-code-mode = { path = "code-mode" }
|
||||
codex-package-manager = { path = "package-manager" }
|
||||
codex-app-server = { path = "app-server" }
|
||||
codex-app-server-client = { path = "app-server-client" }
|
||||
@@ -137,6 +140,7 @@ codex-test-macros = { path = "test-macros" }
|
||||
codex-terminal-detection = { path = "terminal-detection" }
|
||||
codex-tui = { path = "tui" }
|
||||
codex-tui-app-server = { path = "tui_app_server" }
|
||||
codex-v8-poc = { path = "v8-poc" }
|
||||
codex-utils-absolute-path = { path = "utils/absolute-path" }
|
||||
codex-utils-approval-presets = { path = "utils/approval-presets" }
|
||||
codex-utils-cache = { path = "utils/cache" }
|
||||
@@ -245,6 +249,7 @@ regex-lite = "0.1.8"
|
||||
reqwest = "0.12"
|
||||
rmcp = { version = "0.15.0", default-features = false }
|
||||
runfiles = { git = "https://github.com/dzbarsky/rules_rust", rev = "b56cbaa8465e74127f1ea216f813cd377295ad81" }
|
||||
v8 = "=146.4.0"
|
||||
rustls = { version = "0.23", default-features = false, features = [
|
||||
"ring",
|
||||
"std",
|
||||
@@ -370,7 +375,8 @@ ignored = [
|
||||
"icu_provider",
|
||||
"openssl-sys",
|
||||
"codex-utils-readiness",
|
||||
"codex-secrets"
|
||||
"codex-secrets",
|
||||
"codex-v8-poc",
|
||||
]
|
||||
|
||||
[profile.release]
|
||||
|
||||
@@ -83,6 +83,9 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AgentPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"AppBranding": {
|
||||
"description": "EXPERIMENTAL - app metadata returned by app-list APIs.",
|
||||
"properties": {
|
||||
@@ -511,6 +514,28 @@
|
||||
],
|
||||
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
|
||||
"properties": {
|
||||
"activeTurnNotSteerable": {
|
||||
"properties": {
|
||||
"turnKind": {
|
||||
"$ref": "#/definitions/NonSteerableTurnKind"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"turnKind"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"activeTurnNotSteerable"
|
||||
],
|
||||
"title": "ActiveTurnNotSteerableCodexErrorInfo",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1620,6 +1645,13 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"NonSteerableTurnKind": {
|
||||
"enum": [
|
||||
"review",
|
||||
"compact"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PatchApplyStatus": {
|
||||
"enum": [
|
||||
"inProgress",
|
||||
@@ -1999,6 +2031,17 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"agent_path": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AgentPath"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"agent_role": {
|
||||
"default": null,
|
||||
"type": [
|
||||
@@ -3054,6 +3097,26 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadRealtimeTranscriptUpdatedNotification": {
|
||||
"description": "EXPERIMENTAL - flat transcript delta emitted whenever realtime transcript text changes.",
|
||||
"properties": {
|
||||
"role": {
|
||||
"type": "string"
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"role",
|
||||
"text",
|
||||
"threadId"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadStartedNotification": {
|
||||
"properties": {
|
||||
"thread": {
|
||||
@@ -4551,6 +4614,26 @@
|
||||
"title": "Thread/realtime/itemAddedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
"enum": [
|
||||
"thread/realtime/transcriptUpdated"
|
||||
],
|
||||
"title": "Thread/realtime/transcriptUpdatedNotificationMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/ThreadRealtimeTranscriptUpdatedNotification"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Thread/realtime/transcriptUpdatedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
|
||||
@@ -4274,6 +4274,26 @@
|
||||
"title": "Thread/realtime/itemAddedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
"enum": [
|
||||
"thread/realtime/transcriptUpdated"
|
||||
],
|
||||
"title": "Thread/realtime/transcriptUpdatedNotificationMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/v2/ThreadRealtimeTranscriptUpdatedNotification"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Thread/realtime/transcriptUpdatedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
@@ -4899,6 +4919,9 @@
|
||||
"title": "AgentMessageDeltaNotification",
|
||||
"type": "object"
|
||||
},
|
||||
"AgentPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"AnalyticsConfig": {
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
@@ -5635,6 +5658,28 @@
|
||||
],
|
||||
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
|
||||
"properties": {
|
||||
"activeTurnNotSteerable": {
|
||||
"properties": {
|
||||
"turnKind": {
|
||||
"$ref": "#/definitions/v2/NonSteerableTurnKind"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"turnKind"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"activeTurnNotSteerable"
|
||||
],
|
||||
"title": "ActiveTurnNotSteerableCodexErrorInfo",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -9092,6 +9137,13 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"NonSteerableTurnKind": {
|
||||
"enum": [
|
||||
"review",
|
||||
"compact"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"OverriddenMetadata": {
|
||||
"properties": {
|
||||
"effectiveValue": true,
|
||||
@@ -11547,6 +11599,17 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"agent_path": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/AgentPath"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"agent_role": {
|
||||
"default": null,
|
||||
"type": [
|
||||
@@ -13060,6 +13123,28 @@
|
||||
"title": "ThreadRealtimeStartedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadRealtimeTranscriptUpdatedNotification": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "EXPERIMENTAL - flat transcript delta emitted whenever realtime transcript text changes.",
|
||||
"properties": {
|
||||
"role": {
|
||||
"type": "string"
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"role",
|
||||
"text",
|
||||
"threadId"
|
||||
],
|
||||
"title": "ThreadRealtimeTranscriptUpdatedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadResumeParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nThe precedence is: history > path > thread_id. If using history or path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.",
|
||||
|
||||
@@ -139,6 +139,9 @@
|
||||
"title": "AgentMessageDeltaNotification",
|
||||
"type": "object"
|
||||
},
|
||||
"AgentPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"AnalyticsConfig": {
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
@@ -2248,6 +2251,28 @@
|
||||
],
|
||||
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
|
||||
"properties": {
|
||||
"activeTurnNotSteerable": {
|
||||
"properties": {
|
||||
"turnKind": {
|
||||
"$ref": "#/definitions/NonSteerableTurnKind"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"turnKind"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"activeTurnNotSteerable"
|
||||
],
|
||||
"title": "ActiveTurnNotSteerableCodexErrorInfo",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -5860,6 +5885,13 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"NonSteerableTurnKind": {
|
||||
"enum": [
|
||||
"review",
|
||||
"compact"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"OverriddenMetadata": {
|
||||
"properties": {
|
||||
"effectiveValue": true,
|
||||
@@ -8738,6 +8770,26 @@
|
||||
"title": "Thread/realtime/itemAddedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
"enum": [
|
||||
"thread/realtime/transcriptUpdated"
|
||||
],
|
||||
"title": "Thread/realtime/transcriptUpdatedNotificationMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/ThreadRealtimeTranscriptUpdatedNotification"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Thread/realtime/transcriptUpdatedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
@@ -9307,6 +9359,17 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"agent_path": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AgentPath"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"agent_role": {
|
||||
"default": null,
|
||||
"type": [
|
||||
@@ -10820,6 +10883,28 @@
|
||||
"title": "ThreadRealtimeStartedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadRealtimeTranscriptUpdatedNotification": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "EXPERIMENTAL - flat transcript delta emitted whenever realtime transcript text changes.",
|
||||
"properties": {
|
||||
"role": {
|
||||
"type": "string"
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"role",
|
||||
"text",
|
||||
"threadId"
|
||||
],
|
||||
"title": "ThreadRealtimeTranscriptUpdatedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadResumeParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nThe precedence is: history > path > thread_id. If using history or path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.",
|
||||
|
||||
@@ -112,9 +112,38 @@
|
||||
],
|
||||
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
|
||||
"properties": {
|
||||
"activeTurnNotSteerable": {
|
||||
"properties": {
|
||||
"turnKind": {
|
||||
"$ref": "#/definitions/NonSteerableTurnKind"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"turnKind"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"activeTurnNotSteerable"
|
||||
],
|
||||
"title": "ActiveTurnNotSteerableCodexErrorInfo",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"NonSteerableTurnKind": {
|
||||
"enum": [
|
||||
"review",
|
||||
"compact"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"TurnError": {
|
||||
"properties": {
|
||||
"additionalDetails": {
|
||||
|
||||
@@ -131,6 +131,28 @@
|
||||
],
|
||||
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
|
||||
"properties": {
|
||||
"activeTurnNotSteerable": {
|
||||
"properties": {
|
||||
"turnKind": {
|
||||
"$ref": "#/definitions/NonSteerableTurnKind"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"turnKind"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"activeTurnNotSteerable"
|
||||
],
|
||||
"title": "ActiveTurnNotSteerableCodexErrorInfo",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -494,6 +516,13 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"NonSteerableTurnKind": {
|
||||
"enum": [
|
||||
"review",
|
||||
"compact"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PatchApplyStatus": {
|
||||
"enum": [
|
||||
"inProgress",
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
|
||||
"type": "string"
|
||||
},
|
||||
"AgentPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"ApprovalsReviewer": {
|
||||
"description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.",
|
||||
"enum": [
|
||||
@@ -193,6 +196,28 @@
|
||||
],
|
||||
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
|
||||
"properties": {
|
||||
"activeTurnNotSteerable": {
|
||||
"properties": {
|
||||
"turnKind": {
|
||||
"$ref": "#/definitions/NonSteerableTurnKind"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"turnKind"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"activeTurnNotSteerable"
|
||||
],
|
||||
"title": "ActiveTurnNotSteerableCodexErrorInfo",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -586,6 +611,13 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"NonSteerableTurnKind": {
|
||||
"enum": [
|
||||
"review",
|
||||
"compact"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PatchApplyStatus": {
|
||||
"enum": [
|
||||
"inProgress",
|
||||
@@ -900,6 +932,17 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"agent_path": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AgentPath"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"agent_role": {
|
||||
"default": null,
|
||||
"type": [
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"AgentPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"ByteRange": {
|
||||
"properties": {
|
||||
"end": {
|
||||
@@ -131,6 +134,28 @@
|
||||
],
|
||||
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
|
||||
"properties": {
|
||||
"activeTurnNotSteerable": {
|
||||
"properties": {
|
||||
"turnKind": {
|
||||
"$ref": "#/definitions/NonSteerableTurnKind"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"turnKind"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"activeTurnNotSteerable"
|
||||
],
|
||||
"title": "ActiveTurnNotSteerableCodexErrorInfo",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -517,6 +542,13 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"NonSteerableTurnKind": {
|
||||
"enum": [
|
||||
"review",
|
||||
"compact"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PatchApplyStatus": {
|
||||
"enum": [
|
||||
"inProgress",
|
||||
@@ -658,6 +690,17 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"agent_path": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AgentPath"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"agent_role": {
|
||||
"default": null,
|
||||
"type": [
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"AgentPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"ByteRange": {
|
||||
"properties": {
|
||||
"end": {
|
||||
@@ -131,6 +134,28 @@
|
||||
],
|
||||
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
|
||||
"properties": {
|
||||
"activeTurnNotSteerable": {
|
||||
"properties": {
|
||||
"turnKind": {
|
||||
"$ref": "#/definitions/NonSteerableTurnKind"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"turnKind"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"activeTurnNotSteerable"
|
||||
],
|
||||
"title": "ActiveTurnNotSteerableCodexErrorInfo",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -517,6 +542,13 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"NonSteerableTurnKind": {
|
||||
"enum": [
|
||||
"review",
|
||||
"compact"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PatchApplyStatus": {
|
||||
"enum": [
|
||||
"inProgress",
|
||||
@@ -658,6 +690,17 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"agent_path": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AgentPath"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"agent_role": {
|
||||
"default": null,
|
||||
"type": [
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"AgentPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"ByteRange": {
|
||||
"properties": {
|
||||
"end": {
|
||||
@@ -131,6 +134,28 @@
|
||||
],
|
||||
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
|
||||
"properties": {
|
||||
"activeTurnNotSteerable": {
|
||||
"properties": {
|
||||
"turnKind": {
|
||||
"$ref": "#/definitions/NonSteerableTurnKind"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"turnKind"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"activeTurnNotSteerable"
|
||||
],
|
||||
"title": "ActiveTurnNotSteerableCodexErrorInfo",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -517,6 +542,13 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"NonSteerableTurnKind": {
|
||||
"enum": [
|
||||
"review",
|
||||
"compact"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PatchApplyStatus": {
|
||||
"enum": [
|
||||
"inProgress",
|
||||
@@ -658,6 +690,17 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"agent_path": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AgentPath"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"agent_role": {
|
||||
"default": null,
|
||||
"type": [
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "EXPERIMENTAL - flat transcript delta emitted whenever realtime transcript text changes.",
|
||||
"properties": {
|
||||
"role": {
|
||||
"type": "string"
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"role",
|
||||
"text",
|
||||
"threadId"
|
||||
],
|
||||
"title": "ThreadRealtimeTranscriptUpdatedNotification",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -5,6 +5,9 @@
|
||||
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
|
||||
"type": "string"
|
||||
},
|
||||
"AgentPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"ApprovalsReviewer": {
|
||||
"description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.",
|
||||
"enum": [
|
||||
@@ -193,6 +196,28 @@
|
||||
],
|
||||
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
|
||||
"properties": {
|
||||
"activeTurnNotSteerable": {
|
||||
"properties": {
|
||||
"turnKind": {
|
||||
"$ref": "#/definitions/NonSteerableTurnKind"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"turnKind"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"activeTurnNotSteerable"
|
||||
],
|
||||
"title": "ActiveTurnNotSteerableCodexErrorInfo",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -586,6 +611,13 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"NonSteerableTurnKind": {
|
||||
"enum": [
|
||||
"review",
|
||||
"compact"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PatchApplyStatus": {
|
||||
"enum": [
|
||||
"inProgress",
|
||||
@@ -900,6 +932,17 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"agent_path": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AgentPath"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"agent_role": {
|
||||
"default": null,
|
||||
"type": [
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"AgentPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"ByteRange": {
|
||||
"properties": {
|
||||
"end": {
|
||||
@@ -131,6 +134,28 @@
|
||||
],
|
||||
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
|
||||
"properties": {
|
||||
"activeTurnNotSteerable": {
|
||||
"properties": {
|
||||
"turnKind": {
|
||||
"$ref": "#/definitions/NonSteerableTurnKind"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"turnKind"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"activeTurnNotSteerable"
|
||||
],
|
||||
"title": "ActiveTurnNotSteerableCodexErrorInfo",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -517,6 +542,13 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"NonSteerableTurnKind": {
|
||||
"enum": [
|
||||
"review",
|
||||
"compact"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PatchApplyStatus": {
|
||||
"enum": [
|
||||
"inProgress",
|
||||
@@ -658,6 +690,17 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"agent_path": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AgentPath"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"agent_role": {
|
||||
"default": null,
|
||||
"type": [
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
|
||||
"type": "string"
|
||||
},
|
||||
"AgentPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"ApprovalsReviewer": {
|
||||
"description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.",
|
||||
"enum": [
|
||||
@@ -193,6 +196,28 @@
|
||||
],
|
||||
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
|
||||
"properties": {
|
||||
"activeTurnNotSteerable": {
|
||||
"properties": {
|
||||
"turnKind": {
|
||||
"$ref": "#/definitions/NonSteerableTurnKind"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"turnKind"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"activeTurnNotSteerable"
|
||||
],
|
||||
"title": "ActiveTurnNotSteerableCodexErrorInfo",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -586,6 +611,13 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"NonSteerableTurnKind": {
|
||||
"enum": [
|
||||
"review",
|
||||
"compact"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PatchApplyStatus": {
|
||||
"enum": [
|
||||
"inProgress",
|
||||
@@ -900,6 +932,17 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"agent_path": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AgentPath"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"agent_role": {
|
||||
"default": null,
|
||||
"type": [
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"AgentPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"ByteRange": {
|
||||
"properties": {
|
||||
"end": {
|
||||
@@ -131,6 +134,28 @@
|
||||
],
|
||||
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
|
||||
"properties": {
|
||||
"activeTurnNotSteerable": {
|
||||
"properties": {
|
||||
"turnKind": {
|
||||
"$ref": "#/definitions/NonSteerableTurnKind"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"turnKind"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"activeTurnNotSteerable"
|
||||
],
|
||||
"title": "ActiveTurnNotSteerableCodexErrorInfo",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -517,6 +542,13 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"NonSteerableTurnKind": {
|
||||
"enum": [
|
||||
"review",
|
||||
"compact"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PatchApplyStatus": {
|
||||
"enum": [
|
||||
"inProgress",
|
||||
@@ -658,6 +690,17 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"agent_path": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AgentPath"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"agent_role": {
|
||||
"default": null,
|
||||
"type": [
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"AgentPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"ByteRange": {
|
||||
"properties": {
|
||||
"end": {
|
||||
@@ -131,6 +134,28 @@
|
||||
],
|
||||
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
|
||||
"properties": {
|
||||
"activeTurnNotSteerable": {
|
||||
"properties": {
|
||||
"turnKind": {
|
||||
"$ref": "#/definitions/NonSteerableTurnKind"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"turnKind"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"activeTurnNotSteerable"
|
||||
],
|
||||
"title": "ActiveTurnNotSteerableCodexErrorInfo",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -517,6 +542,13 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"NonSteerableTurnKind": {
|
||||
"enum": [
|
||||
"review",
|
||||
"compact"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PatchApplyStatus": {
|
||||
"enum": [
|
||||
"inProgress",
|
||||
@@ -658,6 +690,17 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"agent_path": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AgentPath"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"agent_role": {
|
||||
"default": null,
|
||||
"type": [
|
||||
|
||||
@@ -131,6 +131,28 @@
|
||||
],
|
||||
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
|
||||
"properties": {
|
||||
"activeTurnNotSteerable": {
|
||||
"properties": {
|
||||
"turnKind": {
|
||||
"$ref": "#/definitions/NonSteerableTurnKind"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"turnKind"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"activeTurnNotSteerable"
|
||||
],
|
||||
"title": "ActiveTurnNotSteerableCodexErrorInfo",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -494,6 +516,13 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"NonSteerableTurnKind": {
|
||||
"enum": [
|
||||
"review",
|
||||
"compact"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PatchApplyStatus": {
|
||||
"enum": [
|
||||
"inProgress",
|
||||
|
||||
@@ -131,6 +131,28 @@
|
||||
],
|
||||
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
|
||||
"properties": {
|
||||
"activeTurnNotSteerable": {
|
||||
"properties": {
|
||||
"turnKind": {
|
||||
"$ref": "#/definitions/NonSteerableTurnKind"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"turnKind"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"activeTurnNotSteerable"
|
||||
],
|
||||
"title": "ActiveTurnNotSteerableCodexErrorInfo",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -494,6 +516,13 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"NonSteerableTurnKind": {
|
||||
"enum": [
|
||||
"review",
|
||||
"compact"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PatchApplyStatus": {
|
||||
"enum": [
|
||||
"inProgress",
|
||||
|
||||
@@ -131,6 +131,28 @@
|
||||
],
|
||||
"title": "ResponseTooManyFailedAttemptsCodexErrorInfo",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.",
|
||||
"properties": {
|
||||
"activeTurnNotSteerable": {
|
||||
"properties": {
|
||||
"turnKind": {
|
||||
"$ref": "#/definitions/NonSteerableTurnKind"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"turnKind"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"activeTurnNotSteerable"
|
||||
],
|
||||
"title": "ActiveTurnNotSteerableCodexErrorInfo",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -494,6 +516,13 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"NonSteerableTurnKind": {
|
||||
"enum": [
|
||||
"review",
|
||||
"compact"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PatchApplyStatus": {
|
||||
"enum": [
|
||||
"inProgress",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type AgentPath = string;
|
||||
@@ -41,6 +41,7 @@ import type { ThreadRealtimeErrorNotification } from "./v2/ThreadRealtimeErrorNo
|
||||
import type { ThreadRealtimeItemAddedNotification } from "./v2/ThreadRealtimeItemAddedNotification";
|
||||
import type { ThreadRealtimeOutputAudioDeltaNotification } from "./v2/ThreadRealtimeOutputAudioDeltaNotification";
|
||||
import type { ThreadRealtimeStartedNotification } from "./v2/ThreadRealtimeStartedNotification";
|
||||
import type { ThreadRealtimeTranscriptUpdatedNotification } from "./v2/ThreadRealtimeTranscriptUpdatedNotification";
|
||||
import type { ThreadStartedNotification } from "./v2/ThreadStartedNotification";
|
||||
import type { ThreadStatusChangedNotification } from "./v2/ThreadStatusChangedNotification";
|
||||
import type { ThreadTokenUsageUpdatedNotification } from "./v2/ThreadTokenUsageUpdatedNotification";
|
||||
@@ -55,4 +56,4 @@ import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldW
|
||||
/**
|
||||
* Notification sent from the server to the client.
|
||||
*/
|
||||
export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification };
|
||||
export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcriptUpdated", "params": ThreadRealtimeTranscriptUpdatedNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { AgentPath } from "./AgentPath";
|
||||
import type { ThreadId } from "./ThreadId";
|
||||
|
||||
export type SubAgentSource = "review" | "compact" | { "thread_spawn": { parent_thread_id: ThreadId, depth: number, agent_nickname: string | null, agent_role: string | null, } } | "memory_consolidation" | { "other": string };
|
||||
export type SubAgentSource = "review" | "compact" | { "thread_spawn": { parent_thread_id: ThreadId, depth: number, agent_path: AgentPath | null, agent_nickname: string | null, agent_role: string | null, } } | "memory_consolidation" | { "other": string };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
export type { AbsolutePathBuf } from "./AbsolutePathBuf";
|
||||
export type { AgentPath } from "./AgentPath";
|
||||
export type { ApplyPatchApprovalParams } from "./ApplyPatchApprovalParams";
|
||||
export type { ApplyPatchApprovalResponse } from "./ApplyPatchApprovalResponse";
|
||||
export type { AuthMode } from "./AuthMode";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { NonSteerableTurnKind } from "./NonSteerableTurnKind";
|
||||
|
||||
/**
|
||||
* This translation layer make sure that we expose codex error code in camel case.
|
||||
@@ -8,4 +9,4 @@
|
||||
* When an upstream HTTP status is available (for example, from the Responses API or a provider),
|
||||
* it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant.
|
||||
*/
|
||||
export type CodexErrorInfo = "contextWindowExceeded" | "usageLimitExceeded" | "serverOverloaded" | { "httpConnectionFailed": { httpStatusCode: number | null, } } | { "responseStreamConnectionFailed": { httpStatusCode: number | null, } } | "internalServerError" | "unauthorized" | "badRequest" | "threadRollbackFailed" | "sandboxError" | { "responseStreamDisconnected": { httpStatusCode: number | null, } } | { "responseTooManyFailedAttempts": { httpStatusCode: number | null, } } | "other";
|
||||
export type CodexErrorInfo = "contextWindowExceeded" | "usageLimitExceeded" | "serverOverloaded" | { "httpConnectionFailed": { httpStatusCode: number | null, } } | { "responseStreamConnectionFailed": { httpStatusCode: number | null, } } | "internalServerError" | "unauthorized" | "badRequest" | "threadRollbackFailed" | "sandboxError" | { "responseStreamDisconnected": { httpStatusCode: number | null, } } | { "responseTooManyFailedAttempts": { httpStatusCode: number | null, } } | { "activeTurnNotSteerable": { turnKind: NonSteerableTurnKind, } } | "other";
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type NonSteerableTurnKind = "review" | "compact";
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* EXPERIMENTAL - flat transcript delta emitted whenever realtime
|
||||
* transcript text changes.
|
||||
*/
|
||||
export type ThreadRealtimeTranscriptUpdatedNotification = { threadId: string, role: string, text: string, };
|
||||
@@ -194,6 +194,7 @@ export type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol";
|
||||
export type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment";
|
||||
export type { NetworkPolicyRuleAction } from "./NetworkPolicyRuleAction";
|
||||
export type { NetworkRequirements } from "./NetworkRequirements";
|
||||
export type { NonSteerableTurnKind } from "./NonSteerableTurnKind";
|
||||
export type { OverriddenMetadata } from "./OverriddenMetadata";
|
||||
export type { PatchApplyStatus } from "./PatchApplyStatus";
|
||||
export type { PatchChangeKind } from "./PatchChangeKind";
|
||||
@@ -281,6 +282,7 @@ export type { ThreadRealtimeErrorNotification } from "./ThreadRealtimeErrorNotif
|
||||
export type { ThreadRealtimeItemAddedNotification } from "./ThreadRealtimeItemAddedNotification";
|
||||
export type { ThreadRealtimeOutputAudioDeltaNotification } from "./ThreadRealtimeOutputAudioDeltaNotification";
|
||||
export type { ThreadRealtimeStartedNotification } from "./ThreadRealtimeStartedNotification";
|
||||
export type { ThreadRealtimeTranscriptUpdatedNotification } from "./ThreadRealtimeTranscriptUpdatedNotification";
|
||||
export type { ThreadResumeParams } from "./ThreadResumeParams";
|
||||
export type { ThreadResumeResponse } from "./ThreadResumeResponse";
|
||||
export type { ThreadRollbackParams } from "./ThreadRollbackParams";
|
||||
|
||||
@@ -923,6 +923,8 @@ server_notification_definitions! {
|
||||
ThreadRealtimeStarted => "thread/realtime/started" (v2::ThreadRealtimeStartedNotification),
|
||||
#[experimental("thread/realtime/itemAdded")]
|
||||
ThreadRealtimeItemAdded => "thread/realtime/itemAdded" (v2::ThreadRealtimeItemAddedNotification),
|
||||
#[experimental("thread/realtime/transcriptUpdated")]
|
||||
ThreadRealtimeTranscriptUpdated => "thread/realtime/transcriptUpdated" (v2::ThreadRealtimeTranscriptUpdatedNotification),
|
||||
#[experimental("thread/realtime/outputAudio/delta")]
|
||||
ThreadRealtimeOutputAudioDelta => "thread/realtime/outputAudio/delta" (v2::ThreadRealtimeOutputAudioDeltaNotification),
|
||||
#[experimental("thread/realtime/error")]
|
||||
|
||||
@@ -66,6 +66,7 @@ use codex_protocol::protocol::HookRunSummary as CoreHookRunSummary;
|
||||
use codex_protocol::protocol::HookScope as CoreHookScope;
|
||||
use codex_protocol::protocol::ModelRerouteReason as CoreModelRerouteReason;
|
||||
use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess;
|
||||
use codex_protocol::protocol::NonSteerableTurnKind as CoreNonSteerableTurnKind;
|
||||
use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus;
|
||||
use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot;
|
||||
use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow;
|
||||
@@ -128,6 +129,14 @@ macro_rules! v2_enum_from_core {
|
||||
};
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub enum NonSteerableTurnKind {
|
||||
Review,
|
||||
Compact,
|
||||
}
|
||||
|
||||
/// This translation layer make sure that we expose codex error code in camel case.
|
||||
///
|
||||
/// When an upstream HTTP status is available (for example, from the Responses API or a provider),
|
||||
@@ -167,6 +176,13 @@ pub enum CodexErrorInfo {
|
||||
#[ts(rename = "httpStatusCode")]
|
||||
http_status_code: Option<u16>,
|
||||
},
|
||||
/// Returned when `turn/start` or `turn/steer` is submitted while the current active turn
|
||||
/// cannot accept same-turn steering, for example `/review` or manual `/compact`.
|
||||
ActiveTurnNotSteerable {
|
||||
#[serde(rename = "turnKind")]
|
||||
#[ts(rename = "turnKind")]
|
||||
turn_kind: NonSteerableTurnKind,
|
||||
},
|
||||
Other,
|
||||
}
|
||||
|
||||
@@ -193,11 +209,25 @@ impl From<CoreCodexErrorInfo> for CodexErrorInfo {
|
||||
CoreCodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code } => {
|
||||
CodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code }
|
||||
}
|
||||
CoreCodexErrorInfo::ActiveTurnNotSteerable { turn_kind } => {
|
||||
CodexErrorInfo::ActiveTurnNotSteerable {
|
||||
turn_kind: turn_kind.into(),
|
||||
}
|
||||
}
|
||||
CoreCodexErrorInfo::Other => CodexErrorInfo::Other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CoreNonSteerableTurnKind> for NonSteerableTurnKind {
|
||||
fn from(value: CoreNonSteerableTurnKind) -> Self {
|
||||
match value {
|
||||
CoreNonSteerableTurnKind::Review => Self::Review,
|
||||
CoreNonSteerableTurnKind::Compact => Self::Compact,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS, ExperimentalApi,
|
||||
)]
|
||||
@@ -3787,6 +3817,17 @@ pub struct ThreadRealtimeItemAddedNotification {
|
||||
pub item: JsonValue,
|
||||
}
|
||||
|
||||
/// EXPERIMENTAL - flat transcript delta emitted whenever realtime
|
||||
/// transcript text changes.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ThreadRealtimeTranscriptUpdatedNotification {
|
||||
pub thread_id: String,
|
||||
pub role: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// EXPERIMENTAL - streamed output audio emitted by thread realtime.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -7821,6 +7862,22 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_error_info_serializes_active_turn_not_steerable_turn_kind_in_camel_case() {
|
||||
let value = CodexErrorInfo::ActiveTurnNotSteerable {
|
||||
turn_kind: NonSteerableTurnKind::Review,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(value).unwrap(),
|
||||
json!({
|
||||
"activeTurnNotSteerable": {
|
||||
"turnKind": "review"
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_tool_response_serializes_content_items() {
|
||||
let value = serde_json::to_value(DynamicToolCallResponse {
|
||||
|
||||
@@ -140,7 +140,7 @@ Example with notification opt-out:
|
||||
- `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted.
|
||||
- `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success.
|
||||
- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode".
|
||||
- `turn/steer` — add user input to an already in-flight turn without starting a new turn; returns the active `turnId` that accepted the input.
|
||||
- `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. Review and manual compaction turns reject `turn/steer`.
|
||||
- `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`.
|
||||
- `thread/realtime/start` — start a thread-scoped realtime session (experimental); returns `{}` and streams `thread/realtime/*` notifications.
|
||||
- `thread/realtime/appendAudio` — append an input audio chunk to the active realtime session (experimental); returns `{}`.
|
||||
@@ -575,8 +575,8 @@ Use `thread/backgroundTerminals/clean` to terminate all running background termi
|
||||
|
||||
### Example: Steer an active turn
|
||||
|
||||
Use `turn/steer` to append additional user input to the currently active turn. This does not emit
|
||||
`turn/started` and does not accept turn context overrides.
|
||||
Use `turn/steer` to append additional user input to the currently active regular turn. This does
|
||||
not emit `turn/started` and does not accept turn context overrides.
|
||||
|
||||
```json
|
||||
{ "method": "turn/steer", "id": 32, "params": {
|
||||
@@ -587,7 +587,9 @@ Use `turn/steer` to append additional user input to the currently active turn. T
|
||||
{ "id": 32, "result": { "turnId": "turn_456" } }
|
||||
```
|
||||
|
||||
`expectedTurnId` is required. If there is no active turn (or `expectedTurnId` does not match the active turn), the request fails with an `invalid request` error.
|
||||
`expectedTurnId` is required. If there is no active turn, `expectedTurnId` does not match the
|
||||
active turn, or the active turn kind does not accept same-turn steering (for example review or
|
||||
manual compaction), the request fails with an `invalid request` error.
|
||||
|
||||
### Example: Request a code review
|
||||
|
||||
@@ -825,7 +827,8 @@ The fuzzy file search session API emits per-query notifications:
|
||||
The thread realtime API emits thread-scoped notifications for session lifecycle and streaming media:
|
||||
|
||||
- `thread/realtime/started` — `{ threadId, sessionId }` once realtime starts for the thread (experimental).
|
||||
- `thread/realtime/itemAdded` — `{ threadId, item }` for non-audio realtime items (experimental). `item` is forwarded as raw JSON while the upstream websocket item schema remains unstable.
|
||||
- `thread/realtime/itemAdded` — `{ threadId, item }` for raw non-audio realtime items that do not have a dedicated typed app-server notification, including `handoff_request` (experimental). `item` is forwarded as raw JSON while the upstream websocket item schema remains unstable.
|
||||
- `thread/realtime/transcriptUpdated` — `{ threadId, role, text }` whenever realtime transcript text changes (experimental). This forwards the live transcript delta from that realtime event, not the full accumulated transcript.
|
||||
- `thread/realtime/outputAudio/delta` — `{ threadId, audio }` for streamed output audio chunks (experimental). `audio` uses camelCase fields (`data`, `sampleRate`, `numChannels`, `samplesPerChannel`).
|
||||
- `thread/realtime/error` — `{ threadId, message }` when realtime encounters a transport or backend error (experimental).
|
||||
- `thread/realtime/closed` — `{ threadId, reason }` when the realtime transport closes (experimental).
|
||||
@@ -917,6 +920,8 @@ There are additional item-specific events:
|
||||
- `ResponseStreamConnectionFailed { httpStatusCode? }`: failure to connect to the response SSE stream
|
||||
- `ResponseStreamDisconnected { httpStatusCode? }`: disconnect of the response SSE stream in the middle of a turn before completion
|
||||
- `ResponseTooManyFailedAttempts { httpStatusCode? }`
|
||||
- `ActiveTurnNotSteerable { turnKind }`: `turn/start` or `turn/steer` was submitted while the
|
||||
current active turn was not steerable, for example `/review` or manual `/compact`
|
||||
- `BadRequest`
|
||||
- `Unauthorized`
|
||||
- `SandboxError`
|
||||
|
||||
@@ -86,6 +86,7 @@ use codex_app_server_protocol::ThreadRealtimeErrorNotification;
|
||||
use codex_app_server_protocol::ThreadRealtimeItemAddedNotification;
|
||||
use codex_app_server_protocol::ThreadRealtimeOutputAudioDeltaNotification;
|
||||
use codex_app_server_protocol::ThreadRealtimeStartedNotification;
|
||||
use codex_app_server_protocol::ThreadRealtimeTranscriptUpdatedNotification;
|
||||
use codex_app_server_protocol::ThreadRollbackResponse;
|
||||
use codex_app_server_protocol::ThreadTokenUsage;
|
||||
use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification;
|
||||
@@ -397,8 +398,30 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
))
|
||||
.await;
|
||||
}
|
||||
RealtimeEvent::InputTranscriptDelta(_) => {}
|
||||
RealtimeEvent::OutputTranscriptDelta(_) => {}
|
||||
RealtimeEvent::InputTranscriptDelta(event) => {
|
||||
let notification = ThreadRealtimeTranscriptUpdatedNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
role: "user".to_string(),
|
||||
text: event.delta,
|
||||
};
|
||||
outgoing
|
||||
.send_server_notification(
|
||||
ServerNotification::ThreadRealtimeTranscriptUpdated(notification),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
RealtimeEvent::OutputTranscriptDelta(event) => {
|
||||
let notification = ThreadRealtimeTranscriptUpdatedNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
role: "assistant".to_string(),
|
||||
text: event.delta,
|
||||
};
|
||||
outgoing
|
||||
.send_server_notification(
|
||||
ServerNotification::ThreadRealtimeTranscriptUpdated(notification),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
RealtimeEvent::AudioOut(audio) => {
|
||||
let notification = ThreadRealtimeOutputAudioDeltaNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
|
||||
@@ -32,6 +32,7 @@ use codex_app_server_protocol::CancelLoginAccountParams;
|
||||
use codex_app_server_protocol::CancelLoginAccountResponse;
|
||||
use codex_app_server_protocol::CancelLoginAccountStatus;
|
||||
use codex_app_server_protocol::ClientRequest;
|
||||
use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo;
|
||||
use codex_app_server_protocol::CollaborationModeListParams;
|
||||
use codex_app_server_protocol::CollaborationModeListResponse;
|
||||
use codex_app_server_protocol::CommandExecParams;
|
||||
@@ -161,6 +162,7 @@ use codex_app_server_protocol::ThreadUnsubscribeParams;
|
||||
use codex_app_server_protocol::ThreadUnsubscribeResponse;
|
||||
use codex_app_server_protocol::ThreadUnsubscribeStatus;
|
||||
use codex_app_server_protocol::Turn;
|
||||
use codex_app_server_protocol::TurnError;
|
||||
use codex_app_server_protocol::TurnInterruptParams;
|
||||
use codex_app_server_protocol::TurnStartParams;
|
||||
use codex_app_server_protocol::TurnStartResponse;
|
||||
@@ -6131,24 +6133,57 @@ impl CodexMessageProcessor {
|
||||
self.outgoing.send_response(request_id, response).await;
|
||||
}
|
||||
Err(err) => {
|
||||
let (code, message) = match err {
|
||||
let (code, message, data) = match err {
|
||||
SteerInputError::NoActiveTurn(_) => (
|
||||
INVALID_REQUEST_ERROR_CODE,
|
||||
"no active turn to steer".to_string(),
|
||||
None,
|
||||
),
|
||||
SteerInputError::ExpectedTurnMismatch { expected, actual } => (
|
||||
INVALID_REQUEST_ERROR_CODE,
|
||||
format!("expected active turn id `{expected}` but found `{actual}`"),
|
||||
None,
|
||||
),
|
||||
SteerInputError::ActiveTurnNotSteerable { turn_kind } => {
|
||||
let message = match turn_kind {
|
||||
codex_protocol::protocol::NonSteerableTurnKind::Review => {
|
||||
"cannot steer a review turn".to_string()
|
||||
}
|
||||
codex_protocol::protocol::NonSteerableTurnKind::Compact => {
|
||||
"cannot steer a compact turn".to_string()
|
||||
}
|
||||
};
|
||||
let error = TurnError {
|
||||
message: message.clone(),
|
||||
codex_error_info: Some(
|
||||
AppServerCodexErrorInfo::ActiveTurnNotSteerable {
|
||||
turn_kind: turn_kind.into(),
|
||||
},
|
||||
),
|
||||
additional_details: None,
|
||||
};
|
||||
let data = match serde_json::to_value(error) {
|
||||
Ok(data) => Some(data),
|
||||
Err(error) => {
|
||||
tracing::error!(
|
||||
?error,
|
||||
"failed to serialize active-turn-not-steerable turn error"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
(INVALID_REQUEST_ERROR_CODE, message, data)
|
||||
}
|
||||
SteerInputError::EmptyInput => (
|
||||
INVALID_REQUEST_ERROR_CODE,
|
||||
"input must not be empty".to_string(),
|
||||
None,
|
||||
),
|
||||
};
|
||||
let error = JSONRPCErrorError {
|
||||
code,
|
||||
message,
|
||||
data: None,
|
||||
data,
|
||||
};
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
}
|
||||
@@ -8240,6 +8275,7 @@ fn with_thread_spawn_agent_metadata(
|
||||
codex_protocol::protocol::SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth,
|
||||
agent_path,
|
||||
agent_nickname: existing_agent_nickname,
|
||||
agent_role: existing_agent_role,
|
||||
},
|
||||
@@ -8247,6 +8283,7 @@ fn with_thread_spawn_agent_metadata(
|
||||
codex_protocol::protocol::SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth,
|
||||
agent_path,
|
||||
agent_nickname: agent_nickname.or(existing_agent_nickname),
|
||||
agent_role: agent_role.or(existing_agent_role),
|
||||
},
|
||||
@@ -8793,6 +8830,7 @@ mod tests {
|
||||
source: SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 1,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
}),
|
||||
@@ -8885,6 +8923,7 @@ mod tests {
|
||||
serde_json::to_string(&SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: ThreadId::from_string("ad7f0408-99b8-4f6e-a46f-bd0eec433370")?,
|
||||
depth: 1,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
}))?;
|
||||
|
||||
@@ -133,6 +133,7 @@ mod tests {
|
||||
let spawn = CoreSessionSource::SubAgent(CoreSubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 1,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
});
|
||||
|
||||
@@ -479,7 +479,7 @@ pub async fn run_main_with_transport(
|
||||
range: None,
|
||||
});
|
||||
}
|
||||
if let Some(warning) = codex_core::config::missing_system_bwrap_warning() {
|
||||
if let Some(warning) = codex_core::config::system_bwrap_warning() {
|
||||
config_warnings.push(ConfigWarningNotification {
|
||||
summary: warning,
|
||||
details: None,
|
||||
|
||||
@@ -53,6 +53,7 @@ use codex_core::ThreadManager;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config_loader::CloudRequirementsLoader;
|
||||
use codex_core::config_loader::LoaderOverrides;
|
||||
use codex_core::default_client::DEFAULT_ORIGINATOR;
|
||||
use codex_core::default_client::SetOriginatorError;
|
||||
use codex_core::default_client::USER_AGENT_SUFFIX;
|
||||
use codex_core::default_client::get_codex_user_agent;
|
||||
@@ -78,6 +79,7 @@ use toml::Value as TomlValue;
|
||||
use tracing::Instrument;
|
||||
|
||||
const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const TUI_APP_SERVER_CLIENT_NAME: &str = "codex-tui";
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ExternalAuthRefreshBridge {
|
||||
@@ -551,7 +553,14 @@ impl MessageProcessor {
|
||||
} = params.client_info;
|
||||
session.app_server_client_name = Some(name.clone());
|
||||
session.client_version = Some(version.clone());
|
||||
if let Err(error) = set_default_originator(name.clone()) {
|
||||
let originator = if name == TUI_APP_SERVER_CLIENT_NAME {
|
||||
// TODO: Remove this temporary workaround once app-server clients no longer
|
||||
// need to retain the legacy TUI `codex_cli_rs` originator behavior.
|
||||
DEFAULT_ORIGINATOR.to_string()
|
||||
} else {
|
||||
name.clone()
|
||||
};
|
||||
if let Err(error) = set_default_originator(originator) {
|
||||
match error {
|
||||
SetOriginatorError::InvalidHeaderValue => {
|
||||
let error = JSONRPCErrorError {
|
||||
|
||||
@@ -79,6 +79,7 @@ pub fn create_fake_rollout_with_source(
|
||||
originator: "codex".to_string(),
|
||||
cli_version: "0.0.0".to_string(),
|
||||
source,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
model_provider: model_provider.map(str::to_string),
|
||||
@@ -161,6 +162,7 @@ pub fn create_fake_rollout_with_text_elements(
|
||||
originator: "codex".to_string(),
|
||||
cli_version: "0.0.0".to_string(),
|
||||
source: SessionSource::Cli,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
model_provider: model_provider.map(str::to_string),
|
||||
|
||||
@@ -25,6 +25,7 @@ use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::header;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
use wiremock::matchers::query_param;
|
||||
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567";
|
||||
@@ -678,6 +679,7 @@ async fn plugin_list_force_remote_sync_reconciles_curated_plugin_state() -> Resu
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/plugins/featured"))
|
||||
.and(query_param("platform", "codex"))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(
|
||||
@@ -784,6 +786,7 @@ async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> {
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/plugins/featured"))
|
||||
.and(query_param("platform", "codex"))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(r#"["linear@openai-curated"]"#))
|
||||
@@ -850,6 +853,7 @@ async fn plugin_list_fetches_featured_plugin_ids_without_chatgpt_auth() -> Resul
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/plugins/featured"))
|
||||
.and(query_param("platform", "codex"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(r#"["linear@openai-curated"]"#))
|
||||
.mount(&server)
|
||||
.await;
|
||||
@@ -888,6 +892,7 @@ async fn plugin_list_uses_warmed_featured_plugin_ids_cache_on_first_request() ->
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/plugins/featured"))
|
||||
.and(query_param("platform", "codex"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(r#"["linear@openai-curated"]"#))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
|
||||
@@ -21,6 +21,7 @@ use codex_app_server_protocol::ThreadRealtimeStartResponse;
|
||||
use codex_app_server_protocol::ThreadRealtimeStartedNotification;
|
||||
use codex_app_server_protocol::ThreadRealtimeStopParams;
|
||||
use codex_app_server_protocol::ThreadRealtimeStopResponse;
|
||||
use codex_app_server_protocol::ThreadRealtimeTranscriptUpdatedNotification;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_features::FEATURES;
|
||||
@@ -66,6 +67,24 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> {
|
||||
"content": [{ "type": "text", "text": "hi" }]
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"type": "conversation.item.input_audio_transcription.delta",
|
||||
"delta": "delegate now"
|
||||
}),
|
||||
json!({
|
||||
"type": "response.output_text.delta",
|
||||
"delta": "working"
|
||||
}),
|
||||
json!({
|
||||
"type": "conversation.item.done",
|
||||
"item": {
|
||||
"id": "item_2",
|
||||
"type": "function_call",
|
||||
"name": "codex",
|
||||
"call_id": "handoff_1",
|
||||
"arguments": "{\"input_transcript\":\"delegate now\"}"
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"type": "error",
|
||||
"message": "upstream boom"
|
||||
@@ -180,6 +199,40 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> {
|
||||
assert_eq!(item_added.thread_id, output_audio.thread_id);
|
||||
assert_eq!(item_added.item["type"], json!("message"));
|
||||
|
||||
let first_transcript_update = read_notification::<ThreadRealtimeTranscriptUpdatedNotification>(
|
||||
&mut mcp,
|
||||
"thread/realtime/transcriptUpdated",
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(first_transcript_update.thread_id, output_audio.thread_id);
|
||||
assert_eq!(first_transcript_update.role, "user");
|
||||
assert_eq!(first_transcript_update.text, "delegate now");
|
||||
|
||||
let second_transcript_update =
|
||||
read_notification::<ThreadRealtimeTranscriptUpdatedNotification>(
|
||||
&mut mcp,
|
||||
"thread/realtime/transcriptUpdated",
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(second_transcript_update.thread_id, output_audio.thread_id);
|
||||
assert_eq!(second_transcript_update.role, "assistant");
|
||||
assert_eq!(second_transcript_update.text, "working");
|
||||
|
||||
let handoff_item_added = read_notification::<ThreadRealtimeItemAddedNotification>(
|
||||
&mut mcp,
|
||||
"thread/realtime/itemAdded",
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(handoff_item_added.thread_id, output_audio.thread_id);
|
||||
assert_eq!(handoff_item_added.item["type"], json!("handoff_request"));
|
||||
assert_eq!(handoff_item_added.item["handoff_id"], json!("handoff_1"));
|
||||
assert_eq!(handoff_item_added.item["item_id"], json!("item_2"));
|
||||
assert_eq!(
|
||||
handoff_item_added.item["input_transcript"],
|
||||
json!("delegate now")
|
||||
);
|
||||
assert_eq!(handoff_item_added.item["active_transcript"], json!([]));
|
||||
|
||||
let realtime_error =
|
||||
read_notification::<ThreadRealtimeErrorNotification>(&mut mcp, "thread/realtime/error")
|
||||
.await?;
|
||||
|
||||
@@ -661,6 +661,7 @@ async fn thread_list_filters_by_source_kind_subagent_thread_spawn() -> Result<()
|
||||
CoreSessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 1,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
}),
|
||||
@@ -724,6 +725,7 @@ async fn thread_list_filters_by_subagent_variant() -> Result<()> {
|
||||
CoreSessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 1,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
}),
|
||||
|
||||
@@ -322,6 +322,7 @@ stream_max_retries = 0
|
||||
originator: "codex".to_string(),
|
||||
cli_version: "0.0.0".to_string(),
|
||||
source: RolloutSessionSource::Cli,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
model_provider: Some("mock_provider".to_string()),
|
||||
|
||||
@@ -883,6 +883,24 @@ mod tests {
|
||||
account_id: Option<&str>,
|
||||
access_token: &str,
|
||||
refresh_token: &str,
|
||||
) -> serde_json::Value {
|
||||
chatgpt_auth_json_with_last_refresh(
|
||||
plan_type,
|
||||
chatgpt_user_id,
|
||||
account_id,
|
||||
access_token,
|
||||
refresh_token,
|
||||
"2025-01-01T00:00:00Z",
|
||||
)
|
||||
}
|
||||
|
||||
fn chatgpt_auth_json_with_last_refresh(
|
||||
plan_type: &str,
|
||||
chatgpt_user_id: Option<&str>,
|
||||
account_id: Option<&str>,
|
||||
access_token: &str,
|
||||
refresh_token: &str,
|
||||
last_refresh: &str,
|
||||
) -> serde_json::Value {
|
||||
chatgpt_auth_json_with_mode(
|
||||
plan_type,
|
||||
@@ -890,6 +908,7 @@ mod tests {
|
||||
account_id,
|
||||
access_token,
|
||||
refresh_token,
|
||||
last_refresh,
|
||||
None,
|
||||
)
|
||||
}
|
||||
@@ -900,6 +919,7 @@ mod tests {
|
||||
account_id: Option<&str>,
|
||||
access_token: &str,
|
||||
refresh_token: &str,
|
||||
last_refresh: &str,
|
||||
auth_mode: Option<&str>,
|
||||
) -> serde_json::Value {
|
||||
let header = json!({ "alg": "none", "typ": "JWT" });
|
||||
@@ -925,7 +945,7 @@ mod tests {
|
||||
"refresh_token": refresh_token,
|
||||
"account_id": account_id,
|
||||
},
|
||||
"last_refresh": "2025-01-01T00:00:00Z",
|
||||
"last_refresh": last_refresh,
|
||||
});
|
||||
if let Some(auth_mode) = auth_mode {
|
||||
auth_json["auth_mode"] = serde_json::Value::String(auth_mode.to_string());
|
||||
@@ -1262,24 +1282,43 @@ enabled = false
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_cloud_requirements_recovers_after_unauthorized_reload() {
|
||||
let auth = managed_auth_context(
|
||||
"business",
|
||||
Some("user-12345"),
|
||||
Some("account-12345"),
|
||||
"stale-access-token",
|
||||
"test-refresh-token",
|
||||
);
|
||||
let auth_home = tempdir().expect("tempdir");
|
||||
write_auth_json(
|
||||
auth._home.path(),
|
||||
chatgpt_auth_json(
|
||||
auth_home.path(),
|
||||
chatgpt_auth_json_with_last_refresh(
|
||||
"business",
|
||||
Some("user-12345"),
|
||||
Some("account-12345"),
|
||||
"stale-access-token",
|
||||
"test-refresh-token",
|
||||
// Keep auth "fresh" so the first request hits unauthorized recovery
|
||||
// instead of AuthManager::auth() proactively reloading from disk.
|
||||
"3025-01-01T00:00:00Z",
|
||||
),
|
||||
)
|
||||
.expect("write initial auth");
|
||||
let auth_manager = Arc::new(AuthManager::new(
|
||||
auth_home.path().to_path_buf(),
|
||||
false,
|
||||
AuthCredentialsStoreMode::File,
|
||||
));
|
||||
|
||||
write_auth_json(
|
||||
auth_home.path(),
|
||||
chatgpt_auth_json_with_last_refresh(
|
||||
"business",
|
||||
Some("user-12345"),
|
||||
Some("account-12345"),
|
||||
"fresh-access-token",
|
||||
"test-refresh-token",
|
||||
"3025-01-01T00:00:00Z",
|
||||
),
|
||||
)
|
||||
.expect("write refreshed auth");
|
||||
let auth = ManagedAuthContext {
|
||||
_home: auth_home,
|
||||
manager: auth_manager,
|
||||
};
|
||||
|
||||
let fetcher = Arc::new(TokenFetcher {
|
||||
expected_token: "fresh-access-token".to_string(),
|
||||
@@ -1314,24 +1353,41 @@ enabled = false
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_cloud_requirements_recovers_after_unauthorized_reload_updates_cache_identity() {
|
||||
let auth = managed_auth_context(
|
||||
"business",
|
||||
Some("user-12345"),
|
||||
Some("account-12345"),
|
||||
"stale-access-token",
|
||||
"test-refresh-token",
|
||||
);
|
||||
let auth_home = tempdir().expect("tempdir");
|
||||
write_auth_json(
|
||||
auth._home.path(),
|
||||
chatgpt_auth_json(
|
||||
auth_home.path(),
|
||||
chatgpt_auth_json_with_last_refresh(
|
||||
"business",
|
||||
Some("user-12345"),
|
||||
Some("account-12345"),
|
||||
"stale-access-token",
|
||||
"test-refresh-token",
|
||||
"3025-01-01T00:00:00Z",
|
||||
),
|
||||
)
|
||||
.expect("write initial auth");
|
||||
let auth_manager = Arc::new(AuthManager::new(
|
||||
auth_home.path().to_path_buf(),
|
||||
false,
|
||||
AuthCredentialsStoreMode::File,
|
||||
));
|
||||
|
||||
write_auth_json(
|
||||
auth_home.path(),
|
||||
chatgpt_auth_json_with_last_refresh(
|
||||
"business",
|
||||
Some("user-99999"),
|
||||
Some("account-12345"),
|
||||
"fresh-access-token",
|
||||
"test-refresh-token",
|
||||
"3025-01-01T00:00:00Z",
|
||||
),
|
||||
)
|
||||
.expect("write refreshed auth");
|
||||
let auth = ManagedAuthContext {
|
||||
_home: auth_home,
|
||||
manager: auth_manager,
|
||||
};
|
||||
|
||||
let fetcher = Arc::new(TokenFetcher {
|
||||
expected_token: "fresh-access-token".to_string(),
|
||||
@@ -1432,6 +1488,7 @@ enabled = false
|
||||
Some("account-12345"),
|
||||
"test-access-token",
|
||||
"test-refresh-token",
|
||||
"2025-01-01T00:00:00Z",
|
||||
Some("chatgptAuthTokens"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "code-mode",
|
||||
crate_name = "codex_code_mode",
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
name = "codex-code-mode"
|
||||
version.workspace = true
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
name = "codex_code_mode"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
async-trait = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt", "sync", "time"] }
|
||||
tokio-util = { workspace = true, features = ["rt"] }
|
||||
tracing = { workspace = true }
|
||||
v8 = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
@@ -0,0 +1,555 @@
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
use crate::PUBLIC_TOOL_NAME;
|
||||
|
||||
const MAX_JS_SAFE_INTEGER: u64 = (1_u64 << 53) - 1;
|
||||
const CODE_MODE_ONLY_PREFACE: &str =
|
||||
"Use `exec/wait` tool to run all other tools, do not attempt to use any other tools directly";
|
||||
const EXEC_DESCRIPTION_TEMPLATE: &str = r#"## exec
|
||||
- Runs raw JavaScript in an isolated context (no Node, no file system, or network access, no console).
|
||||
- Send raw JavaScript source text, not JSON, quoted strings, or markdown code fences.
|
||||
- You may optionally start the tool input with a first-line pragma like `// @exec: {"yield_time_ms": 10000, "max_output_tokens": 1000}`.
|
||||
- `yield_time_ms` asks `exec` to yield early after that many milliseconds if the script is still running.
|
||||
- `max_output_tokens` sets the token budget for direct `exec` results. By default the result is truncated to 10000 tokens.
|
||||
- All nested tools are available on the global `tools` object, for example `await tools.exec_command(...)`. Tool names are exposed as normalized JavaScript identifiers, for example `await tools.mcp__ologs__get_profile(...)`.
|
||||
- Tool methods take either string or object as parameter.
|
||||
- They return either a structured value or a string based on the description above.
|
||||
|
||||
- Global helpers:
|
||||
- `exit()`: Immediately ends the current script successfully (like an early return from the top level).
|
||||
- `text(value: string | number | boolean | undefined | null)`: Appends a text item. Non-string values are stringified with `JSON.stringify(...)` when possible.
|
||||
- `image(imageUrlOrItem: string | { image_url: string; detail?: "auto" | "low" | "high" | "original" | null })`: Appends an image item. `image_url` can be an HTTPS URL or a base64-encoded `data:` URL.
|
||||
- `store(key: string, value: any)`: stores a serializable value under a string key for later `exec` calls in the same session.
|
||||
- `load(key: string)`: returns the stored value for a string key, or `undefined` if it is missing.
|
||||
- `notify(value: string | number | boolean | undefined | null)`: immediately injects an extra `custom_tool_call_output` for the current `exec` call. Values are stringified like `text(...)`.
|
||||
- `ALL_TOOLS`: metadata for the enabled nested tools as `{ name, description }` entries.
|
||||
- `yield_control()`: yields the accumulated output to the model immediately while the script keeps running."#;
|
||||
const WAIT_DESCRIPTION_TEMPLATE: &str = r#"- Use `wait` only after `exec` returns `Script running with cell ID ...`.
|
||||
- `cell_id` identifies the running `exec` cell to resume.
|
||||
- `yield_time_ms` controls how long to wait for more output before yielding again. If omitted, `wait` uses its default wait timeout.
|
||||
- `max_tokens` limits how much new output this wait call returns.
|
||||
- `terminate: true` stops the running cell instead of waiting for more output.
|
||||
- `wait` returns only the new output since the last yield, or the final completion or termination result for that cell.
|
||||
- If the cell is still running, `wait` may yield again with the same `cell_id`.
|
||||
- If the cell has already finished, `wait` returns the completed result and closes the cell."#;
|
||||
|
||||
pub const CODE_MODE_PRAGMA_PREFIX: &str = "// @exec:";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CodeModeToolKind {
|
||||
Function,
|
||||
Freeform,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ToolDefinition {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub kind: CodeModeToolKind,
|
||||
pub input_schema: Option<JsonValue>,
|
||||
pub output_schema: Option<JsonValue>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CodeModeExecPragma {
|
||||
#[serde(default)]
|
||||
yield_time_ms: Option<u64>,
|
||||
#[serde(default)]
|
||||
max_output_tokens: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct ParsedExecSource {
|
||||
pub code: String,
|
||||
pub yield_time_ms: Option<u64>,
|
||||
pub max_output_tokens: Option<usize>,
|
||||
}
|
||||
|
||||
pub fn parse_exec_source(input: &str) -> Result<ParsedExecSource, String> {
|
||||
if input.trim().is_empty() {
|
||||
return Err(
|
||||
"exec expects raw JavaScript source text (non-empty). Provide JS only, optionally with first-line `// @exec: {\"yield_time_ms\": 10000, \"max_output_tokens\": 1000}`.".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut args = ParsedExecSource {
|
||||
code: input.to_string(),
|
||||
yield_time_ms: None,
|
||||
max_output_tokens: None,
|
||||
};
|
||||
|
||||
let mut lines = input.splitn(2, '\n');
|
||||
let first_line = lines.next().unwrap_or_default();
|
||||
let rest = lines.next().unwrap_or_default();
|
||||
let trimmed = first_line.trim_start();
|
||||
let Some(pragma) = trimmed.strip_prefix(CODE_MODE_PRAGMA_PREFIX) else {
|
||||
return Ok(args);
|
||||
};
|
||||
|
||||
if rest.trim().is_empty() {
|
||||
return Err(
|
||||
"exec pragma must be followed by JavaScript source on subsequent lines".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let directive = pragma.trim();
|
||||
if directive.is_empty() {
|
||||
return Err(
|
||||
"exec pragma must be a JSON object with supported fields `yield_time_ms` and `max_output_tokens`"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let value: serde_json::Value = serde_json::from_str(directive).map_err(|err| {
|
||||
format!(
|
||||
"exec pragma must be valid JSON with supported fields `yield_time_ms` and `max_output_tokens`: {err}"
|
||||
)
|
||||
})?;
|
||||
let object = value.as_object().ok_or_else(|| {
|
||||
"exec pragma must be a JSON object with supported fields `yield_time_ms` and `max_output_tokens`"
|
||||
.to_string()
|
||||
})?;
|
||||
for key in object.keys() {
|
||||
match key.as_str() {
|
||||
"yield_time_ms" | "max_output_tokens" => {}
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"exec pragma only supports `yield_time_ms` and `max_output_tokens`; got `{key}`"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let pragma: CodeModeExecPragma = serde_json::from_value(value).map_err(|err| {
|
||||
format!(
|
||||
"exec pragma fields `yield_time_ms` and `max_output_tokens` must be non-negative safe integers: {err}"
|
||||
)
|
||||
})?;
|
||||
if pragma
|
||||
.yield_time_ms
|
||||
.is_some_and(|yield_time_ms| yield_time_ms > MAX_JS_SAFE_INTEGER)
|
||||
{
|
||||
return Err(
|
||||
"exec pragma field `yield_time_ms` must be a non-negative safe integer".to_string(),
|
||||
);
|
||||
}
|
||||
if pragma.max_output_tokens.is_some_and(|max_output_tokens| {
|
||||
u64::try_from(max_output_tokens)
|
||||
.map(|max_output_tokens| max_output_tokens > MAX_JS_SAFE_INTEGER)
|
||||
.unwrap_or(true)
|
||||
}) {
|
||||
return Err(
|
||||
"exec pragma field `max_output_tokens` must be a non-negative safe integer".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
args.code = rest.to_string();
|
||||
args.yield_time_ms = pragma.yield_time_ms;
|
||||
args.max_output_tokens = pragma.max_output_tokens;
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
pub fn is_code_mode_nested_tool(tool_name: &str) -> bool {
|
||||
tool_name != crate::PUBLIC_TOOL_NAME && tool_name != crate::WAIT_TOOL_NAME
|
||||
}
|
||||
|
||||
pub fn build_exec_tool_description(
|
||||
enabled_tools: &[(String, String)],
|
||||
code_mode_only: bool,
|
||||
) -> String {
|
||||
if !code_mode_only {
|
||||
return EXEC_DESCRIPTION_TEMPLATE.to_string();
|
||||
}
|
||||
|
||||
let mut sections = vec![
|
||||
CODE_MODE_ONLY_PREFACE.to_string(),
|
||||
EXEC_DESCRIPTION_TEMPLATE.to_string(),
|
||||
];
|
||||
|
||||
if !enabled_tools.is_empty() {
|
||||
let nested_tool_reference = enabled_tools
|
||||
.iter()
|
||||
.map(|(name, nested_description)| {
|
||||
let global_name = normalize_code_mode_identifier(name);
|
||||
format!(
|
||||
"### `{global_name}` (`{name}`)\n{}",
|
||||
nested_description.trim()
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n");
|
||||
sections.push(nested_tool_reference);
|
||||
}
|
||||
|
||||
sections.join("\n\n")
|
||||
}
|
||||
|
||||
pub fn build_wait_tool_description() -> &'static str {
|
||||
WAIT_DESCRIPTION_TEMPLATE
|
||||
}
|
||||
|
||||
pub fn normalize_code_mode_identifier(tool_key: &str) -> String {
|
||||
let mut identifier = String::new();
|
||||
|
||||
for (index, ch) in tool_key.chars().enumerate() {
|
||||
let is_valid = if index == 0 {
|
||||
ch == '_' || ch == '$' || ch.is_ascii_alphabetic()
|
||||
} else {
|
||||
ch == '_' || ch == '$' || ch.is_ascii_alphanumeric()
|
||||
};
|
||||
|
||||
if is_valid {
|
||||
identifier.push(ch);
|
||||
} else {
|
||||
identifier.push('_');
|
||||
}
|
||||
}
|
||||
|
||||
if identifier.is_empty() {
|
||||
"_".to_string()
|
||||
} else {
|
||||
identifier
|
||||
}
|
||||
}
|
||||
|
||||
pub fn augment_tool_definition(mut definition: ToolDefinition) -> ToolDefinition {
|
||||
if definition.name != PUBLIC_TOOL_NAME {
|
||||
definition.description = append_code_mode_sample_for_definition(&definition);
|
||||
}
|
||||
definition
|
||||
}
|
||||
|
||||
pub fn enabled_tool_metadata(definition: &ToolDefinition) -> EnabledToolMetadata {
|
||||
EnabledToolMetadata {
|
||||
tool_name: definition.name.clone(),
|
||||
global_name: normalize_code_mode_identifier(&definition.name),
|
||||
description: definition.description.clone(),
|
||||
kind: definition.kind,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
pub struct EnabledToolMetadata {
|
||||
pub tool_name: String,
|
||||
pub global_name: String,
|
||||
pub description: String,
|
||||
pub kind: CodeModeToolKind,
|
||||
}
|
||||
|
||||
pub fn append_code_mode_sample(
|
||||
description: &str,
|
||||
tool_name: &str,
|
||||
input_name: &str,
|
||||
input_type: String,
|
||||
output_type: String,
|
||||
) -> String {
|
||||
let declaration = format!(
|
||||
"declare const tools: {{ {} }};",
|
||||
render_code_mode_tool_declaration(tool_name, input_name, input_type, output_type)
|
||||
);
|
||||
format!("{description}\n\nexec tool declaration:\n```ts\n{declaration}\n```")
|
||||
}
|
||||
|
||||
fn append_code_mode_sample_for_definition(definition: &ToolDefinition) -> String {
|
||||
let input_name = match definition.kind {
|
||||
CodeModeToolKind::Function => "args",
|
||||
CodeModeToolKind::Freeform => "input",
|
||||
};
|
||||
let input_type = match definition.kind {
|
||||
CodeModeToolKind::Function => definition
|
||||
.input_schema
|
||||
.as_ref()
|
||||
.map(render_json_schema_to_typescript)
|
||||
.unwrap_or_else(|| "unknown".to_string()),
|
||||
CodeModeToolKind::Freeform => "string".to_string(),
|
||||
};
|
||||
let output_type = definition
|
||||
.output_schema
|
||||
.as_ref()
|
||||
.map(render_json_schema_to_typescript)
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
append_code_mode_sample(
|
||||
&definition.description,
|
||||
&definition.name,
|
||||
input_name,
|
||||
input_type,
|
||||
output_type,
|
||||
)
|
||||
}
|
||||
|
||||
fn render_code_mode_tool_declaration(
|
||||
tool_name: &str,
|
||||
input_name: &str,
|
||||
input_type: String,
|
||||
output_type: String,
|
||||
) -> String {
|
||||
let tool_name = normalize_code_mode_identifier(tool_name);
|
||||
format!("{tool_name}({input_name}: {input_type}): Promise<{output_type}>;")
|
||||
}
|
||||
|
||||
pub fn render_json_schema_to_typescript(schema: &JsonValue) -> String {
|
||||
render_json_schema_to_typescript_inner(schema)
|
||||
}
|
||||
|
||||
fn render_json_schema_to_typescript_inner(schema: &JsonValue) -> String {
|
||||
match schema {
|
||||
JsonValue::Bool(true) => "unknown".to_string(),
|
||||
JsonValue::Bool(false) => "never".to_string(),
|
||||
JsonValue::Object(map) => {
|
||||
if let Some(value) = map.get("const") {
|
||||
return render_json_schema_literal(value);
|
||||
}
|
||||
|
||||
if let Some(values) = map.get("enum").and_then(JsonValue::as_array) {
|
||||
let rendered = values
|
||||
.iter()
|
||||
.map(render_json_schema_literal)
|
||||
.collect::<Vec<_>>();
|
||||
if !rendered.is_empty() {
|
||||
return rendered.join(" | ");
|
||||
}
|
||||
}
|
||||
|
||||
for key in ["anyOf", "oneOf"] {
|
||||
if let Some(variants) = map.get(key).and_then(JsonValue::as_array) {
|
||||
let rendered = variants
|
||||
.iter()
|
||||
.map(render_json_schema_to_typescript_inner)
|
||||
.collect::<Vec<_>>();
|
||||
if !rendered.is_empty() {
|
||||
return rendered.join(" | ");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(variants) = map.get("allOf").and_then(JsonValue::as_array) {
|
||||
let rendered = variants
|
||||
.iter()
|
||||
.map(render_json_schema_to_typescript_inner)
|
||||
.collect::<Vec<_>>();
|
||||
if !rendered.is_empty() {
|
||||
return rendered.join(" & ");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(schema_type) = map.get("type") {
|
||||
if let Some(types) = schema_type.as_array() {
|
||||
let rendered = types
|
||||
.iter()
|
||||
.filter_map(JsonValue::as_str)
|
||||
.map(|schema_type| render_json_schema_type_keyword(map, schema_type))
|
||||
.collect::<Vec<_>>();
|
||||
if !rendered.is_empty() {
|
||||
return rendered.join(" | ");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(schema_type) = schema_type.as_str() {
|
||||
return render_json_schema_type_keyword(map, schema_type);
|
||||
}
|
||||
}
|
||||
|
||||
if map.contains_key("properties")
|
||||
|| map.contains_key("additionalProperties")
|
||||
|| map.contains_key("required")
|
||||
{
|
||||
return render_json_schema_object(map);
|
||||
}
|
||||
|
||||
if map.contains_key("items") || map.contains_key("prefixItems") {
|
||||
return render_json_schema_array(map);
|
||||
}
|
||||
|
||||
"unknown".to_string()
|
||||
}
|
||||
_ => "unknown".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_json_schema_type_keyword(
|
||||
map: &serde_json::Map<String, JsonValue>,
|
||||
schema_type: &str,
|
||||
) -> String {
|
||||
match schema_type {
|
||||
"string" => "string".to_string(),
|
||||
"number" | "integer" => "number".to_string(),
|
||||
"boolean" => "boolean".to_string(),
|
||||
"null" => "null".to_string(),
|
||||
"array" => render_json_schema_array(map),
|
||||
"object" => render_json_schema_object(map),
|
||||
_ => "unknown".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_json_schema_array(map: &serde_json::Map<String, JsonValue>) -> String {
|
||||
if let Some(items) = map.get("items") {
|
||||
let item_type = render_json_schema_to_typescript_inner(items);
|
||||
return format!("Array<{item_type}>");
|
||||
}
|
||||
|
||||
if let Some(items) = map.get("prefixItems").and_then(JsonValue::as_array) {
|
||||
let item_types = items
|
||||
.iter()
|
||||
.map(render_json_schema_to_typescript_inner)
|
||||
.collect::<Vec<_>>();
|
||||
if !item_types.is_empty() {
|
||||
return format!("[{}]", item_types.join(", "));
|
||||
}
|
||||
}
|
||||
|
||||
"unknown[]".to_string()
|
||||
}
|
||||
|
||||
fn render_json_schema_object(map: &serde_json::Map<String, JsonValue>) -> String {
|
||||
let required = map
|
||||
.get("required")
|
||||
.and_then(JsonValue::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(JsonValue::as_str)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let properties = map
|
||||
.get("properties")
|
||||
.and_then(JsonValue::as_object)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut sorted_properties = properties.iter().collect::<Vec<_>>();
|
||||
sorted_properties.sort_unstable_by(|(name_a, _), (name_b, _)| name_a.cmp(name_b));
|
||||
let mut lines = sorted_properties
|
||||
.into_iter()
|
||||
.map(|(name, value)| {
|
||||
let optional = if required.iter().any(|required_name| required_name == name) {
|
||||
""
|
||||
} else {
|
||||
"?"
|
||||
};
|
||||
let property_name = render_json_schema_property_name(name);
|
||||
let property_type = render_json_schema_to_typescript_inner(value);
|
||||
format!("{property_name}{optional}: {property_type};")
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if let Some(additional_properties) = map.get("additionalProperties") {
|
||||
let property_type = match additional_properties {
|
||||
JsonValue::Bool(true) => Some("unknown".to_string()),
|
||||
JsonValue::Bool(false) => None,
|
||||
value => Some(render_json_schema_to_typescript_inner(value)),
|
||||
};
|
||||
|
||||
if let Some(property_type) = property_type {
|
||||
lines.push(format!("[key: string]: {property_type};"));
|
||||
}
|
||||
} else if properties.is_empty() {
|
||||
lines.push("[key: string]: unknown;".to_string());
|
||||
}
|
||||
|
||||
if lines.is_empty() {
|
||||
return "{}".to_string();
|
||||
}
|
||||
|
||||
format!("{{ {} }}", lines.join(" "))
|
||||
}
|
||||
|
||||
fn render_json_schema_property_name(name: &str) -> String {
|
||||
if normalize_code_mode_identifier(name) == name {
|
||||
name.to_string()
|
||||
} else {
|
||||
serde_json::to_string(name).unwrap_or_else(|_| format!("\"{}\"", name.replace('"', "\\\"")))
|
||||
}
|
||||
}
|
||||
|
||||
fn render_json_schema_literal(value: &JsonValue) -> String {
|
||||
serde_json::to_string(value).unwrap_or_else(|_| "unknown".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::CodeModeToolKind;
|
||||
use super::ParsedExecSource;
|
||||
use super::ToolDefinition;
|
||||
use super::augment_tool_definition;
|
||||
use super::build_exec_tool_description;
|
||||
use super::normalize_code_mode_identifier;
|
||||
use super::parse_exec_source;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn parse_exec_source_without_pragma() {
|
||||
assert_eq!(
|
||||
parse_exec_source("text('hi')").unwrap(),
|
||||
ParsedExecSource {
|
||||
code: "text('hi')".to_string(),
|
||||
yield_time_ms: None,
|
||||
max_output_tokens: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_exec_source_with_pragma() {
|
||||
assert_eq!(
|
||||
parse_exec_source("// @exec: {\"yield_time_ms\": 10}\ntext('hi')").unwrap(),
|
||||
ParsedExecSource {
|
||||
code: "text('hi')".to_string(),
|
||||
yield_time_ms: Some(10),
|
||||
max_output_tokens: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_identifier_rewrites_invalid_characters() {
|
||||
assert_eq!(
|
||||
"mcp__ologs__get_profile",
|
||||
normalize_code_mode_identifier("mcp__ologs__get_profile")
|
||||
);
|
||||
assert_eq!(
|
||||
"hidden_dynamic_tool",
|
||||
normalize_code_mode_identifier("hidden-dynamic-tool")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn augment_tool_definition_appends_typed_declaration() {
|
||||
let definition = ToolDefinition {
|
||||
name: "hidden_dynamic_tool".to_string(),
|
||||
description: "Test tool".to_string(),
|
||||
kind: CodeModeToolKind::Function,
|
||||
input_schema: Some(json!({
|
||||
"type": "object",
|
||||
"properties": { "city": { "type": "string" } },
|
||||
"required": ["city"],
|
||||
"additionalProperties": false
|
||||
})),
|
||||
output_schema: Some(json!({
|
||||
"type": "object",
|
||||
"properties": { "ok": { "type": "boolean" } },
|
||||
"required": ["ok"]
|
||||
})),
|
||||
};
|
||||
|
||||
let description = augment_tool_definition(definition).description;
|
||||
assert!(description.contains("declare const tools"));
|
||||
assert!(
|
||||
description.contains(
|
||||
"hidden_dynamic_tool(args: { city: string; }): Promise<{ ok: boolean; }>;"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_mode_only_description_includes_nested_tools() {
|
||||
let description =
|
||||
build_exec_tool_description(&[("foo".to_string(), "bar".to_string())], true);
|
||||
assert!(description.contains("### `foo` (`foo`)"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
mod description;
|
||||
mod response;
|
||||
mod runtime;
|
||||
mod service;
|
||||
|
||||
pub use description::CODE_MODE_PRAGMA_PREFIX;
|
||||
pub use description::CodeModeToolKind;
|
||||
pub use description::ToolDefinition;
|
||||
pub use description::append_code_mode_sample;
|
||||
pub use description::augment_tool_definition;
|
||||
pub use description::build_exec_tool_description;
|
||||
pub use description::build_wait_tool_description;
|
||||
pub use description::is_code_mode_nested_tool;
|
||||
pub use description::normalize_code_mode_identifier;
|
||||
pub use description::parse_exec_source;
|
||||
pub use description::render_json_schema_to_typescript;
|
||||
pub use response::FunctionCallOutputContentItem;
|
||||
pub use response::ImageDetail;
|
||||
pub use runtime::DEFAULT_EXEC_YIELD_TIME_MS;
|
||||
pub use runtime::DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL;
|
||||
pub use runtime::DEFAULT_WAIT_YIELD_TIME_MS;
|
||||
pub use runtime::ExecuteRequest;
|
||||
pub use runtime::RuntimeResponse;
|
||||
pub use runtime::WaitRequest;
|
||||
pub use service::CodeModeService;
|
||||
pub use service::CodeModeTurnHost;
|
||||
pub use service::CodeModeTurnWorker;
|
||||
|
||||
pub const PUBLIC_TOOL_NAME: &str = "exec";
|
||||
pub const WAIT_TOOL_NAME: &str = "wait";
|
||||
@@ -0,0 +1,24 @@
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ImageDetail {
|
||||
Auto,
|
||||
Low,
|
||||
High,
|
||||
Original,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum FunctionCallOutputContentItem {
|
||||
InputText {
|
||||
text: String,
|
||||
},
|
||||
InputImage {
|
||||
image_url: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
detail: Option<ImageDetail>,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
use crate::response::FunctionCallOutputContentItem;
|
||||
|
||||
use super::EXIT_SENTINEL;
|
||||
use super::RuntimeEvent;
|
||||
use super::RuntimeState;
|
||||
use super::value::json_to_v8;
|
||||
use super::value::normalize_output_image;
|
||||
use super::value::serialize_output_text;
|
||||
use super::value::throw_type_error;
|
||||
use super::value::v8_value_to_json;
|
||||
|
||||
pub(super) fn tool_callback(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
args: v8::FunctionCallbackArguments,
|
||||
mut retval: v8::ReturnValue<v8::Value>,
|
||||
) {
|
||||
let tool_name = args.data().to_rust_string_lossy(scope);
|
||||
let input = if args.length() == 0 {
|
||||
Ok(None)
|
||||
} else {
|
||||
v8_value_to_json(scope, args.get(0))
|
||||
};
|
||||
let input = match input {
|
||||
Ok(input) => input,
|
||||
Err(error_text) => {
|
||||
throw_type_error(scope, &error_text);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let Some(resolver) = v8::PromiseResolver::new(scope) else {
|
||||
throw_type_error(scope, "failed to create tool promise");
|
||||
return;
|
||||
};
|
||||
let promise = resolver.get_promise(scope);
|
||||
|
||||
let resolver = v8::Global::new(scope, resolver);
|
||||
let Some(state) = scope.get_slot_mut::<RuntimeState>() else {
|
||||
throw_type_error(scope, "runtime state unavailable");
|
||||
return;
|
||||
};
|
||||
let id = format!("tool-{}", state.next_tool_call_id);
|
||||
state.next_tool_call_id = state.next_tool_call_id.saturating_add(1);
|
||||
let event_tx = state.event_tx.clone();
|
||||
state.pending_tool_calls.insert(id.clone(), resolver);
|
||||
let _ = event_tx.send(RuntimeEvent::ToolCall {
|
||||
id,
|
||||
name: tool_name,
|
||||
input,
|
||||
});
|
||||
retval.set(promise.into());
|
||||
}
|
||||
|
||||
pub(super) fn text_callback(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
args: v8::FunctionCallbackArguments,
|
||||
mut retval: v8::ReturnValue<v8::Value>,
|
||||
) {
|
||||
let value = if args.length() == 0 {
|
||||
v8::undefined(scope).into()
|
||||
} else {
|
||||
args.get(0)
|
||||
};
|
||||
let text = match serialize_output_text(scope, value) {
|
||||
Ok(text) => text,
|
||||
Err(error_text) => {
|
||||
throw_type_error(scope, &error_text);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Some(state) = scope.get_slot::<RuntimeState>() {
|
||||
let _ = state.event_tx.send(RuntimeEvent::ContentItem(
|
||||
FunctionCallOutputContentItem::InputText { text },
|
||||
));
|
||||
}
|
||||
retval.set(v8::undefined(scope).into());
|
||||
}
|
||||
|
||||
pub(super) fn image_callback(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
args: v8::FunctionCallbackArguments,
|
||||
mut retval: v8::ReturnValue<v8::Value>,
|
||||
) {
|
||||
let value = if args.length() == 0 {
|
||||
v8::undefined(scope).into()
|
||||
} else {
|
||||
args.get(0)
|
||||
};
|
||||
let image_item = match normalize_output_image(scope, value) {
|
||||
Ok(image_item) => image_item,
|
||||
Err(()) => return,
|
||||
};
|
||||
if let Some(state) = scope.get_slot::<RuntimeState>() {
|
||||
let _ = state.event_tx.send(RuntimeEvent::ContentItem(image_item));
|
||||
}
|
||||
retval.set(v8::undefined(scope).into());
|
||||
}
|
||||
|
||||
pub(super) fn store_callback(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
args: v8::FunctionCallbackArguments,
|
||||
_retval: v8::ReturnValue<v8::Value>,
|
||||
) {
|
||||
let key = match args.get(0).to_string(scope) {
|
||||
Some(key) => key.to_rust_string_lossy(scope),
|
||||
None => {
|
||||
throw_type_error(scope, "store key must be a string");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let value = args.get(1);
|
||||
let serialized = match v8_value_to_json(scope, value) {
|
||||
Ok(Some(value)) => value,
|
||||
Ok(None) => {
|
||||
throw_type_error(
|
||||
scope,
|
||||
&format!("Unable to store {key:?}. Only plain serializable objects can be stored."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
Err(error_text) => {
|
||||
throw_type_error(scope, &error_text);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Some(state) = scope.get_slot_mut::<RuntimeState>() {
|
||||
state.stored_values.insert(key, serialized);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn load_callback(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
args: v8::FunctionCallbackArguments,
|
||||
mut retval: v8::ReturnValue<v8::Value>,
|
||||
) {
|
||||
let key = match args.get(0).to_string(scope) {
|
||||
Some(key) => key.to_rust_string_lossy(scope),
|
||||
None => {
|
||||
throw_type_error(scope, "load key must be a string");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let value = scope
|
||||
.get_slot::<RuntimeState>()
|
||||
.and_then(|state| state.stored_values.get(&key))
|
||||
.cloned();
|
||||
let Some(value) = value else {
|
||||
retval.set(v8::undefined(scope).into());
|
||||
return;
|
||||
};
|
||||
let Some(value) = json_to_v8(scope, &value) else {
|
||||
throw_type_error(scope, "failed to load stored value");
|
||||
return;
|
||||
};
|
||||
retval.set(value);
|
||||
}
|
||||
|
||||
pub(super) fn notify_callback(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
args: v8::FunctionCallbackArguments,
|
||||
mut retval: v8::ReturnValue<v8::Value>,
|
||||
) {
|
||||
let value = if args.length() == 0 {
|
||||
v8::undefined(scope).into()
|
||||
} else {
|
||||
args.get(0)
|
||||
};
|
||||
let text = match serialize_output_text(scope, value) {
|
||||
Ok(text) => text,
|
||||
Err(error_text) => {
|
||||
throw_type_error(scope, &error_text);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if text.trim().is_empty() {
|
||||
throw_type_error(scope, "notify expects non-empty text");
|
||||
return;
|
||||
}
|
||||
if let Some(state) = scope.get_slot::<RuntimeState>() {
|
||||
let _ = state.event_tx.send(RuntimeEvent::Notify {
|
||||
call_id: state.tool_call_id.clone(),
|
||||
text,
|
||||
});
|
||||
}
|
||||
retval.set(v8::undefined(scope).into());
|
||||
}
|
||||
|
||||
pub(super) fn yield_control_callback(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
_args: v8::FunctionCallbackArguments,
|
||||
_retval: v8::ReturnValue<v8::Value>,
|
||||
) {
|
||||
if let Some(state) = scope.get_slot::<RuntimeState>() {
|
||||
let _ = state.event_tx.send(RuntimeEvent::YieldRequested);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn exit_callback(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
_args: v8::FunctionCallbackArguments,
|
||||
_retval: v8::ReturnValue<v8::Value>,
|
||||
) {
|
||||
if let Some(state) = scope.get_slot_mut::<RuntimeState>() {
|
||||
state.exit_requested = true;
|
||||
}
|
||||
if let Some(error) = v8::String::new(scope, EXIT_SENTINEL) {
|
||||
scope.throw_exception(error.into());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
use super::RuntimeState;
|
||||
use super::callbacks::exit_callback;
|
||||
use super::callbacks::image_callback;
|
||||
use super::callbacks::load_callback;
|
||||
use super::callbacks::notify_callback;
|
||||
use super::callbacks::store_callback;
|
||||
use super::callbacks::text_callback;
|
||||
use super::callbacks::tool_callback;
|
||||
use super::callbacks::yield_control_callback;
|
||||
|
||||
pub(super) fn install_globals(scope: &mut v8::PinScope<'_, '_>) -> Result<(), String> {
|
||||
let global = scope.get_current_context().global(scope);
|
||||
let console = v8::String::new(scope, "console")
|
||||
.ok_or_else(|| "failed to allocate global `console`".to_string())?;
|
||||
if global.delete(scope, console.into()) != Some(true) {
|
||||
return Err("failed to remove global `console`".to_string());
|
||||
}
|
||||
|
||||
let tools = build_tools_object(scope)?;
|
||||
let all_tools = build_all_tools_value(scope)?;
|
||||
let text = helper_function(scope, "text", text_callback)?;
|
||||
let image = helper_function(scope, "image", image_callback)?;
|
||||
let store = helper_function(scope, "store", store_callback)?;
|
||||
let load = helper_function(scope, "load", load_callback)?;
|
||||
let notify = helper_function(scope, "notify", notify_callback)?;
|
||||
let yield_control = helper_function(scope, "yield_control", yield_control_callback)?;
|
||||
let exit = helper_function(scope, "exit", exit_callback)?;
|
||||
|
||||
set_global(scope, global, "tools", tools.into())?;
|
||||
set_global(scope, global, "ALL_TOOLS", all_tools)?;
|
||||
set_global(scope, global, "text", text.into())?;
|
||||
set_global(scope, global, "image", image.into())?;
|
||||
set_global(scope, global, "store", store.into())?;
|
||||
set_global(scope, global, "load", load.into())?;
|
||||
set_global(scope, global, "notify", notify.into())?;
|
||||
set_global(scope, global, "yield_control", yield_control.into())?;
|
||||
set_global(scope, global, "exit", exit.into())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_tools_object<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
) -> Result<v8::Local<'s, v8::Object>, String> {
|
||||
let tools = v8::Object::new(scope);
|
||||
let enabled_tools = scope
|
||||
.get_slot::<RuntimeState>()
|
||||
.map(|state| state.enabled_tools.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
for tool in enabled_tools {
|
||||
let name = v8::String::new(scope, &tool.global_name)
|
||||
.ok_or_else(|| "failed to allocate tool name".to_string())?;
|
||||
let function = tool_function(scope, &tool.tool_name)?;
|
||||
tools.set(scope, name.into(), function.into());
|
||||
}
|
||||
Ok(tools)
|
||||
}
|
||||
|
||||
fn build_all_tools_value<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
) -> Result<v8::Local<'s, v8::Value>, String> {
|
||||
let enabled_tools = scope
|
||||
.get_slot::<RuntimeState>()
|
||||
.map(|state| state.enabled_tools.clone())
|
||||
.unwrap_or_default();
|
||||
let array = v8::Array::new(scope, enabled_tools.len() as i32);
|
||||
let name_key = v8::String::new(scope, "name")
|
||||
.ok_or_else(|| "failed to allocate ALL_TOOLS name key".to_string())?;
|
||||
let description_key = v8::String::new(scope, "description")
|
||||
.ok_or_else(|| "failed to allocate ALL_TOOLS description key".to_string())?;
|
||||
|
||||
for (index, tool) in enabled_tools.iter().enumerate() {
|
||||
let item = v8::Object::new(scope);
|
||||
let name = v8::String::new(scope, &tool.global_name)
|
||||
.ok_or_else(|| "failed to allocate ALL_TOOLS name".to_string())?;
|
||||
let description = v8::String::new(scope, &tool.description)
|
||||
.ok_or_else(|| "failed to allocate ALL_TOOLS description".to_string())?;
|
||||
|
||||
if item.set(scope, name_key.into(), name.into()) != Some(true) {
|
||||
return Err("failed to set ALL_TOOLS name".to_string());
|
||||
}
|
||||
if item.set(scope, description_key.into(), description.into()) != Some(true) {
|
||||
return Err("failed to set ALL_TOOLS description".to_string());
|
||||
}
|
||||
if array.set_index(scope, index as u32, item.into()) != Some(true) {
|
||||
return Err("failed to append ALL_TOOLS metadata".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(array.into())
|
||||
}
|
||||
|
||||
fn helper_function<'s, F>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
name: &str,
|
||||
callback: F,
|
||||
) -> Result<v8::Local<'s, v8::Function>, String>
|
||||
where
|
||||
F: v8::MapFnTo<v8::FunctionCallback>,
|
||||
{
|
||||
let name =
|
||||
v8::String::new(scope, name).ok_or_else(|| "failed to allocate helper name".to_string())?;
|
||||
let template = v8::FunctionTemplate::builder(callback)
|
||||
.data(name.into())
|
||||
.build(scope);
|
||||
template
|
||||
.get_function(scope)
|
||||
.ok_or_else(|| "failed to create helper function".to_string())
|
||||
}
|
||||
|
||||
fn tool_function<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
tool_name: &str,
|
||||
) -> Result<v8::Local<'s, v8::Function>, String> {
|
||||
let data = v8::String::new(scope, tool_name)
|
||||
.ok_or_else(|| "failed to allocate tool callback data".to_string())?;
|
||||
let template = v8::FunctionTemplate::builder(tool_callback)
|
||||
.data(data.into())
|
||||
.build(scope);
|
||||
template
|
||||
.get_function(scope)
|
||||
.ok_or_else(|| "failed to create tool function".to_string())
|
||||
}
|
||||
|
||||
fn set_global<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
global: v8::Local<'s, v8::Object>,
|
||||
name: &str,
|
||||
value: v8::Local<'s, v8::Value>,
|
||||
) -> Result<(), String> {
|
||||
let key = v8::String::new(scope, name)
|
||||
.ok_or_else(|| format!("failed to allocate global `{name}`"))?;
|
||||
if global.set(scope, key.into(), value) == Some(true) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("failed to set global `{name}`"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
mod callbacks;
|
||||
mod globals;
|
||||
mod module_loader;
|
||||
mod value;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::mpsc as std_mpsc;
|
||||
use std::thread;
|
||||
|
||||
use serde_json::Value as JsonValue;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::description::EnabledToolMetadata;
|
||||
use crate::description::ToolDefinition;
|
||||
use crate::description::enabled_tool_metadata;
|
||||
use crate::response::FunctionCallOutputContentItem;
|
||||
|
||||
pub const DEFAULT_EXEC_YIELD_TIME_MS: u64 = 10_000;
|
||||
pub const DEFAULT_WAIT_YIELD_TIME_MS: u64 = 10_000;
|
||||
pub const DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL: usize = 10_000;
|
||||
const EXIT_SENTINEL: &str = "__codex_code_mode_exit__";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ExecuteRequest {
|
||||
pub tool_call_id: String,
|
||||
pub enabled_tools: Vec<ToolDefinition>,
|
||||
pub source: String,
|
||||
pub stored_values: HashMap<String, JsonValue>,
|
||||
pub yield_time_ms: Option<u64>,
|
||||
pub max_output_tokens: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WaitRequest {
|
||||
pub cell_id: String,
|
||||
pub yield_time_ms: u64,
|
||||
pub terminate: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum RuntimeResponse {
|
||||
Yielded {
|
||||
cell_id: String,
|
||||
content_items: Vec<FunctionCallOutputContentItem>,
|
||||
},
|
||||
Terminated {
|
||||
cell_id: String,
|
||||
content_items: Vec<FunctionCallOutputContentItem>,
|
||||
},
|
||||
Result {
|
||||
cell_id: String,
|
||||
content_items: Vec<FunctionCallOutputContentItem>,
|
||||
stored_values: HashMap<String, JsonValue>,
|
||||
error_text: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum TurnMessage {
|
||||
ToolCall {
|
||||
cell_id: String,
|
||||
id: String,
|
||||
name: String,
|
||||
input: Option<JsonValue>,
|
||||
},
|
||||
Notify {
|
||||
cell_id: String,
|
||||
call_id: String,
|
||||
text: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum RuntimeCommand {
|
||||
ToolResponse { id: String, result: JsonValue },
|
||||
ToolError { id: String, error_text: String },
|
||||
Terminate,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum RuntimeEvent {
|
||||
Started,
|
||||
ContentItem(FunctionCallOutputContentItem),
|
||||
YieldRequested,
|
||||
ToolCall {
|
||||
id: String,
|
||||
name: String,
|
||||
input: Option<JsonValue>,
|
||||
},
|
||||
Notify {
|
||||
call_id: String,
|
||||
text: String,
|
||||
},
|
||||
Result {
|
||||
stored_values: HashMap<String, JsonValue>,
|
||||
error_text: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
pub(crate) fn spawn_runtime(
|
||||
request: ExecuteRequest,
|
||||
event_tx: mpsc::UnboundedSender<RuntimeEvent>,
|
||||
) -> Result<(std_mpsc::Sender<RuntimeCommand>, v8::IsolateHandle), String> {
|
||||
let (command_tx, command_rx) = std_mpsc::channel();
|
||||
let (isolate_handle_tx, isolate_handle_rx) = std_mpsc::sync_channel(1);
|
||||
let enabled_tools = request
|
||||
.enabled_tools
|
||||
.iter()
|
||||
.map(enabled_tool_metadata)
|
||||
.collect::<Vec<_>>();
|
||||
let config = RuntimeConfig {
|
||||
tool_call_id: request.tool_call_id,
|
||||
enabled_tools,
|
||||
source: request.source,
|
||||
stored_values: request.stored_values,
|
||||
};
|
||||
|
||||
thread::spawn(move || {
|
||||
run_runtime(config, event_tx, command_rx, isolate_handle_tx);
|
||||
});
|
||||
|
||||
let isolate_handle = isolate_handle_rx
|
||||
.recv()
|
||||
.map_err(|_| "failed to initialize code mode runtime".to_string())?;
|
||||
Ok((command_tx, isolate_handle))
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct RuntimeConfig {
|
||||
tool_call_id: String,
|
||||
enabled_tools: Vec<EnabledToolMetadata>,
|
||||
source: String,
|
||||
stored_values: HashMap<String, JsonValue>,
|
||||
}
|
||||
|
||||
pub(super) struct RuntimeState {
|
||||
event_tx: mpsc::UnboundedSender<RuntimeEvent>,
|
||||
pending_tool_calls: HashMap<String, v8::Global<v8::PromiseResolver>>,
|
||||
stored_values: HashMap<String, JsonValue>,
|
||||
enabled_tools: Vec<EnabledToolMetadata>,
|
||||
next_tool_call_id: u64,
|
||||
tool_call_id: String,
|
||||
exit_requested: bool,
|
||||
}
|
||||
|
||||
pub(super) enum CompletionState {
|
||||
Pending,
|
||||
Completed {
|
||||
stored_values: HashMap<String, JsonValue>,
|
||||
error_text: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
fn initialize_v8() {
|
||||
static PLATFORM: OnceLock<v8::SharedRef<v8::Platform>> = OnceLock::new();
|
||||
|
||||
let _ = PLATFORM.get_or_init(|| {
|
||||
let platform = v8::new_default_platform(0, false).make_shared();
|
||||
v8::V8::initialize_platform(platform.clone());
|
||||
v8::V8::initialize();
|
||||
platform
|
||||
});
|
||||
}
|
||||
|
||||
fn run_runtime(
|
||||
config: RuntimeConfig,
|
||||
event_tx: mpsc::UnboundedSender<RuntimeEvent>,
|
||||
command_rx: std_mpsc::Receiver<RuntimeCommand>,
|
||||
isolate_handle_tx: std_mpsc::SyncSender<v8::IsolateHandle>,
|
||||
) {
|
||||
initialize_v8();
|
||||
|
||||
let isolate = &mut v8::Isolate::new(v8::CreateParams::default());
|
||||
let isolate_handle = isolate.thread_safe_handle();
|
||||
if isolate_handle_tx.send(isolate_handle).is_err() {
|
||||
return;
|
||||
}
|
||||
isolate.set_host_import_module_dynamically_callback(module_loader::dynamic_import_callback);
|
||||
|
||||
v8::scope!(let scope, isolate);
|
||||
let context = v8::Context::new(scope, Default::default());
|
||||
let scope = &mut v8::ContextScope::new(scope, context);
|
||||
|
||||
scope.set_slot(RuntimeState {
|
||||
event_tx: event_tx.clone(),
|
||||
pending_tool_calls: HashMap::new(),
|
||||
stored_values: config.stored_values,
|
||||
enabled_tools: config.enabled_tools,
|
||||
next_tool_call_id: 1,
|
||||
tool_call_id: config.tool_call_id,
|
||||
exit_requested: false,
|
||||
});
|
||||
|
||||
if let Err(error_text) = globals::install_globals(scope) {
|
||||
send_result(&event_tx, HashMap::new(), Some(error_text));
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = event_tx.send(RuntimeEvent::Started);
|
||||
|
||||
let pending_promise = match module_loader::evaluate_main_module(scope, &config.source) {
|
||||
Ok(pending_promise) => pending_promise,
|
||||
Err(error_text) => {
|
||||
capture_scope_send_error(scope, &event_tx, Some(error_text));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match module_loader::completion_state(scope, pending_promise.as_ref()) {
|
||||
CompletionState::Completed {
|
||||
stored_values,
|
||||
error_text,
|
||||
} => {
|
||||
send_result(&event_tx, stored_values, error_text);
|
||||
return;
|
||||
}
|
||||
CompletionState::Pending => {}
|
||||
}
|
||||
|
||||
let mut pending_promise = pending_promise;
|
||||
loop {
|
||||
let Ok(command) = command_rx.recv() else {
|
||||
break;
|
||||
};
|
||||
match command {
|
||||
RuntimeCommand::Terminate => break,
|
||||
RuntimeCommand::ToolResponse { id, result } => {
|
||||
if let Err(error_text) =
|
||||
module_loader::resolve_tool_response(scope, &id, Ok(result))
|
||||
{
|
||||
capture_scope_send_error(scope, &event_tx, Some(error_text));
|
||||
return;
|
||||
}
|
||||
}
|
||||
RuntimeCommand::ToolError { id, error_text } => {
|
||||
if let Err(runtime_error) =
|
||||
module_loader::resolve_tool_response(scope, &id, Err(error_text))
|
||||
{
|
||||
capture_scope_send_error(scope, &event_tx, Some(runtime_error));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scope.perform_microtask_checkpoint();
|
||||
match module_loader::completion_state(scope, pending_promise.as_ref()) {
|
||||
CompletionState::Completed {
|
||||
stored_values,
|
||||
error_text,
|
||||
} => {
|
||||
send_result(&event_tx, stored_values, error_text);
|
||||
return;
|
||||
}
|
||||
CompletionState::Pending => {}
|
||||
}
|
||||
|
||||
if let Some(promise) = pending_promise.as_ref() {
|
||||
let promise = v8::Local::new(scope, promise);
|
||||
if promise.state() != v8::PromiseState::Pending {
|
||||
pending_promise = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn capture_scope_send_error(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
event_tx: &mpsc::UnboundedSender<RuntimeEvent>,
|
||||
error_text: Option<String>,
|
||||
) {
|
||||
let stored_values = scope
|
||||
.get_slot::<RuntimeState>()
|
||||
.map(|state| state.stored_values.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
send_result(event_tx, stored_values, error_text);
|
||||
}
|
||||
|
||||
fn send_result(
|
||||
event_tx: &mpsc::UnboundedSender<RuntimeEvent>,
|
||||
stored_values: HashMap<String, JsonValue>,
|
||||
error_text: Option<String>,
|
||||
) {
|
||||
let _ = event_tx.send(RuntimeEvent::Result {
|
||||
stored_values,
|
||||
error_text,
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use super::ExecuteRequest;
|
||||
use super::RuntimeEvent;
|
||||
use super::spawn_runtime;
|
||||
|
||||
fn execute_request(source: &str) -> ExecuteRequest {
|
||||
ExecuteRequest {
|
||||
tool_call_id: "call_1".to_string(),
|
||||
enabled_tools: Vec::new(),
|
||||
source: source.to_string(),
|
||||
stored_values: HashMap::new(),
|
||||
yield_time_ms: Some(1),
|
||||
max_output_tokens: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminate_execution_stops_cpu_bound_module() {
|
||||
let (event_tx, mut event_rx) = mpsc::unbounded_channel();
|
||||
let (_runtime_tx, runtime_terminate_handle) =
|
||||
spawn_runtime(execute_request("while (true) {}"), event_tx).unwrap();
|
||||
|
||||
let started_event = tokio::time::timeout(Duration::from_secs(1), event_rx.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(matches!(started_event, RuntimeEvent::Started));
|
||||
|
||||
assert!(runtime_terminate_handle.terminate_execution());
|
||||
|
||||
let result_event = tokio::time::timeout(Duration::from_secs(1), event_rx.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let RuntimeEvent::Result {
|
||||
stored_values,
|
||||
error_text,
|
||||
} = result_event
|
||||
else {
|
||||
panic!("expected runtime result after termination");
|
||||
};
|
||||
assert_eq!(stored_values, HashMap::new());
|
||||
assert!(error_text.is_some());
|
||||
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_secs(1), event_rx.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
use super::CompletionState;
|
||||
use super::EXIT_SENTINEL;
|
||||
use super::RuntimeState;
|
||||
use super::value::json_to_v8;
|
||||
use super::value::value_to_error_text;
|
||||
|
||||
pub(super) fn evaluate_main_module(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
source_text: &str,
|
||||
) -> Result<Option<v8::Global<v8::Promise>>, String> {
|
||||
let tc = std::pin::pin!(v8::TryCatch::new(scope));
|
||||
let mut tc = tc.init();
|
||||
let source = v8::String::new(&tc, source_text)
|
||||
.ok_or_else(|| "failed to allocate exec source".to_string())?;
|
||||
let origin = script_origin(&mut tc, "exec_main.mjs")?;
|
||||
let mut source = v8::script_compiler::Source::new(source, Some(&origin));
|
||||
let module = v8::script_compiler::compile_module(&tc, &mut source).ok_or_else(|| {
|
||||
tc.exception()
|
||||
.map(|exception| value_to_error_text(&mut tc, exception))
|
||||
.unwrap_or_else(|| "unknown code mode exception".to_string())
|
||||
})?;
|
||||
module
|
||||
.instantiate_module(&tc, resolve_module_callback)
|
||||
.ok_or_else(|| {
|
||||
tc.exception()
|
||||
.map(|exception| value_to_error_text(&mut tc, exception))
|
||||
.unwrap_or_else(|| "unknown code mode exception".to_string())
|
||||
})?;
|
||||
let result = match module.evaluate(&tc) {
|
||||
Some(result) => result,
|
||||
None => {
|
||||
if let Some(exception) = tc.exception() {
|
||||
if is_exit_exception(&mut tc, exception) {
|
||||
return Ok(None);
|
||||
}
|
||||
return Err(value_to_error_text(&mut tc, exception));
|
||||
}
|
||||
return Err("unknown code mode exception".to_string());
|
||||
}
|
||||
};
|
||||
tc.perform_microtask_checkpoint();
|
||||
|
||||
if result.is_promise() {
|
||||
let promise = v8::Local::<v8::Promise>::try_from(result)
|
||||
.map_err(|_| "failed to read exec promise".to_string())?;
|
||||
return Ok(Some(v8::Global::new(&tc, promise)));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn is_exit_exception(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
exception: v8::Local<'_, v8::Value>,
|
||||
) -> bool {
|
||||
scope
|
||||
.get_slot::<RuntimeState>()
|
||||
.map(|state| state.exit_requested)
|
||||
.unwrap_or(false)
|
||||
&& exception.is_string()
|
||||
&& exception.to_rust_string_lossy(scope) == EXIT_SENTINEL
|
||||
}
|
||||
|
||||
pub(super) fn resolve_tool_response(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
id: &str,
|
||||
response: Result<JsonValue, String>,
|
||||
) -> Result<(), String> {
|
||||
let resolver = {
|
||||
let state = scope
|
||||
.get_slot_mut::<RuntimeState>()
|
||||
.ok_or_else(|| "runtime state unavailable".to_string())?;
|
||||
state.pending_tool_calls.remove(id)
|
||||
}
|
||||
.ok_or_else(|| format!("unknown tool call `{id}`"))?;
|
||||
|
||||
let tc = std::pin::pin!(v8::TryCatch::new(scope));
|
||||
let mut tc = tc.init();
|
||||
let resolver = v8::Local::new(&tc, &resolver);
|
||||
match response {
|
||||
Ok(result) => {
|
||||
let value = json_to_v8(&mut tc, &result)
|
||||
.ok_or_else(|| "failed to serialize tool response".to_string())?;
|
||||
resolver.resolve(&tc, value);
|
||||
}
|
||||
Err(error_text) => {
|
||||
let value = v8::String::new(&tc, &error_text)
|
||||
.ok_or_else(|| "failed to allocate tool error".to_string())?;
|
||||
resolver.reject(&tc, value.into());
|
||||
}
|
||||
}
|
||||
if tc.has_caught() {
|
||||
return Err(tc
|
||||
.exception()
|
||||
.map(|exception| value_to_error_text(&mut tc, exception))
|
||||
.unwrap_or_else(|| "unknown code mode exception".to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn completion_state(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
pending_promise: Option<&v8::Global<v8::Promise>>,
|
||||
) -> CompletionState {
|
||||
let stored_values = scope
|
||||
.get_slot::<RuntimeState>()
|
||||
.map(|state| state.stored_values.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let Some(pending_promise) = pending_promise else {
|
||||
return CompletionState::Completed {
|
||||
stored_values,
|
||||
error_text: None,
|
||||
};
|
||||
};
|
||||
|
||||
let promise = v8::Local::new(scope, pending_promise);
|
||||
match promise.state() {
|
||||
v8::PromiseState::Pending => CompletionState::Pending,
|
||||
v8::PromiseState::Fulfilled => CompletionState::Completed {
|
||||
stored_values,
|
||||
error_text: None,
|
||||
},
|
||||
v8::PromiseState::Rejected => {
|
||||
let result = promise.result(scope);
|
||||
let error_text = if is_exit_exception(scope, result) {
|
||||
None
|
||||
} else {
|
||||
Some(value_to_error_text(scope, result))
|
||||
};
|
||||
CompletionState::Completed {
|
||||
stored_values,
|
||||
error_text,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn script_origin<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
resource_name_: &str,
|
||||
) -> Result<v8::ScriptOrigin<'s>, String> {
|
||||
let resource_name = v8::String::new(scope, resource_name_)
|
||||
.ok_or_else(|| "failed to allocate script origin".to_string())?;
|
||||
let source_map_url = v8::String::new(scope, resource_name_)
|
||||
.ok_or_else(|| "failed to allocate source map url".to_string())?;
|
||||
Ok(v8::ScriptOrigin::new(
|
||||
scope,
|
||||
resource_name.into(),
|
||||
0,
|
||||
0,
|
||||
true,
|
||||
0,
|
||||
Some(source_map_url.into()),
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
None,
|
||||
))
|
||||
}
|
||||
|
||||
fn resolve_module_callback<'s>(
|
||||
context: v8::Local<'s, v8::Context>,
|
||||
specifier: v8::Local<'s, v8::String>,
|
||||
_import_attributes: v8::Local<'s, v8::FixedArray>,
|
||||
_referrer: v8::Local<'s, v8::Module>,
|
||||
) -> Option<v8::Local<'s, v8::Module>> {
|
||||
v8::callback_scope!(unsafe scope, context);
|
||||
let specifier = specifier.to_rust_string_lossy(scope);
|
||||
resolve_module(scope, &specifier)
|
||||
}
|
||||
|
||||
pub(super) fn dynamic_import_callback<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
_host_defined_options: v8::Local<'s, v8::Data>,
|
||||
_resource_name: v8::Local<'s, v8::Value>,
|
||||
specifier: v8::Local<'s, v8::String>,
|
||||
_import_attributes: v8::Local<'s, v8::FixedArray>,
|
||||
) -> Option<v8::Local<'s, v8::Promise>> {
|
||||
let specifier = specifier.to_rust_string_lossy(scope);
|
||||
let resolver = v8::PromiseResolver::new(scope)?;
|
||||
|
||||
match resolve_module(scope, &specifier) {
|
||||
Some(module) => {
|
||||
if module.get_status() == v8::ModuleStatus::Uninstantiated
|
||||
&& module
|
||||
.instantiate_module(scope, resolve_module_callback)
|
||||
.is_none()
|
||||
{
|
||||
let error = v8::String::new(scope, "failed to instantiate module")
|
||||
.map(Into::into)
|
||||
.unwrap_or_else(|| v8::undefined(scope).into());
|
||||
resolver.reject(scope, error);
|
||||
return Some(resolver.get_promise(scope));
|
||||
}
|
||||
if matches!(
|
||||
module.get_status(),
|
||||
v8::ModuleStatus::Instantiated | v8::ModuleStatus::Evaluated
|
||||
) && module.evaluate(scope).is_none()
|
||||
{
|
||||
let error = v8::String::new(scope, "failed to evaluate module")
|
||||
.map(Into::into)
|
||||
.unwrap_or_else(|| v8::undefined(scope).into());
|
||||
resolver.reject(scope, error);
|
||||
return Some(resolver.get_promise(scope));
|
||||
}
|
||||
let namespace = module.get_module_namespace();
|
||||
resolver.resolve(scope, namespace);
|
||||
Some(resolver.get_promise(scope))
|
||||
}
|
||||
None => {
|
||||
let error = v8::String::new(scope, "unsupported import in exec")
|
||||
.map(Into::into)
|
||||
.unwrap_or_else(|| v8::undefined(scope).into());
|
||||
resolver.reject(scope, error);
|
||||
Some(resolver.get_promise(scope))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_module<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
specifier: &str,
|
||||
) -> Option<v8::Local<'s, v8::Module>> {
|
||||
if let Some(message) =
|
||||
v8::String::new(scope, &format!("Unsupported import in exec: {specifier}"))
|
||||
{
|
||||
scope.throw_exception(message.into());
|
||||
} else {
|
||||
scope.throw_exception(v8::undefined(scope).into());
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
use crate::response::FunctionCallOutputContentItem;
|
||||
use crate::response::ImageDetail;
|
||||
|
||||
pub(super) fn serialize_output_text(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
value: v8::Local<'_, v8::Value>,
|
||||
) -> Result<String, String> {
|
||||
if value.is_undefined()
|
||||
|| value.is_null()
|
||||
|| value.is_boolean()
|
||||
|| value.is_number()
|
||||
|| value.is_big_int()
|
||||
|| value.is_string()
|
||||
{
|
||||
return Ok(value.to_rust_string_lossy(scope));
|
||||
}
|
||||
|
||||
let tc = std::pin::pin!(v8::TryCatch::new(scope));
|
||||
let mut tc = tc.init();
|
||||
if let Some(stringified) = v8::json::stringify(&tc, value) {
|
||||
return Ok(stringified.to_rust_string_lossy(&tc));
|
||||
}
|
||||
if tc.has_caught() {
|
||||
return Err(tc
|
||||
.exception()
|
||||
.map(|exception| value_to_error_text(&mut tc, exception))
|
||||
.unwrap_or_else(|| "unknown code mode exception".to_string()));
|
||||
}
|
||||
Ok(value.to_rust_string_lossy(&tc))
|
||||
}
|
||||
|
||||
pub(super) fn normalize_output_image(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
value: v8::Local<'_, v8::Value>,
|
||||
) -> Result<FunctionCallOutputContentItem, ()> {
|
||||
let result = (|| -> Result<FunctionCallOutputContentItem, String> {
|
||||
let (image_url, detail) = if value.is_string() {
|
||||
(value.to_rust_string_lossy(scope), None)
|
||||
} else if value.is_object() && !value.is_array() {
|
||||
let object = v8::Local::<v8::Object>::try_from(value).map_err(|_| {
|
||||
"image expects a non-empty image URL string or an object with image_url and optional detail".to_string()
|
||||
})?;
|
||||
let image_url_key = v8::String::new(scope, "image_url")
|
||||
.ok_or_else(|| "failed to allocate image helper keys".to_string())?;
|
||||
let detail_key = v8::String::new(scope, "detail")
|
||||
.ok_or_else(|| "failed to allocate image helper keys".to_string())?;
|
||||
let image_url = object
|
||||
.get(scope, image_url_key.into())
|
||||
.filter(|value| value.is_string())
|
||||
.map(|value| value.to_rust_string_lossy(scope))
|
||||
.ok_or_else(|| {
|
||||
"image expects a non-empty image URL string or an object with image_url and optional detail"
|
||||
.to_string()
|
||||
})?;
|
||||
let detail = match object.get(scope, detail_key.into()) {
|
||||
Some(value) if value.is_string() => Some(value.to_rust_string_lossy(scope)),
|
||||
Some(value) if value.is_null() || value.is_undefined() => None,
|
||||
Some(_) => return Err("image detail must be a string when provided".to_string()),
|
||||
None => None,
|
||||
};
|
||||
(image_url, detail)
|
||||
} else {
|
||||
return Err(
|
||||
"image expects a non-empty image URL string or an object with image_url and optional detail"
|
||||
.to_string(),
|
||||
);
|
||||
};
|
||||
|
||||
if image_url.is_empty() {
|
||||
return Err(
|
||||
"image expects a non-empty image URL string or an object with image_url and optional detail"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let lower = image_url.to_ascii_lowercase();
|
||||
if !(lower.starts_with("http://")
|
||||
|| lower.starts_with("https://")
|
||||
|| lower.starts_with("data:"))
|
||||
{
|
||||
return Err("image expects an http(s) or data URL".to_string());
|
||||
}
|
||||
|
||||
let detail = match detail {
|
||||
Some(detail) => {
|
||||
let normalized = detail.to_ascii_lowercase();
|
||||
Some(match normalized.as_str() {
|
||||
"auto" => ImageDetail::Auto,
|
||||
"low" => ImageDetail::Low,
|
||||
"high" => ImageDetail::High,
|
||||
"original" => ImageDetail::Original,
|
||||
_ => {
|
||||
return Err(
|
||||
"image detail must be one of: auto, low, high, original".to_string()
|
||||
);
|
||||
}
|
||||
})
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
Ok(FunctionCallOutputContentItem::InputImage { image_url, detail })
|
||||
})();
|
||||
|
||||
match result {
|
||||
Ok(item) => Ok(item),
|
||||
Err(error_text) => {
|
||||
throw_type_error(scope, &error_text);
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn v8_value_to_json(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
value: v8::Local<'_, v8::Value>,
|
||||
) -> Result<Option<JsonValue>, String> {
|
||||
let tc = std::pin::pin!(v8::TryCatch::new(scope));
|
||||
let mut tc = tc.init();
|
||||
let Some(stringified) = v8::json::stringify(&tc, value) else {
|
||||
if tc.has_caught() {
|
||||
return Err(tc
|
||||
.exception()
|
||||
.map(|exception| value_to_error_text(&mut tc, exception))
|
||||
.unwrap_or_else(|| "unknown code mode exception".to_string()));
|
||||
}
|
||||
return Ok(None);
|
||||
};
|
||||
serde_json::from_str(&stringified.to_rust_string_lossy(&tc))
|
||||
.map(Some)
|
||||
.map_err(|err| format!("failed to serialize JavaScript value: {err}"))
|
||||
}
|
||||
|
||||
pub(super) fn json_to_v8<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
value: &JsonValue,
|
||||
) -> Option<v8::Local<'s, v8::Value>> {
|
||||
let json = serde_json::to_string(value).ok()?;
|
||||
let json = v8::String::new(scope, &json)?;
|
||||
v8::json::parse(scope, json)
|
||||
}
|
||||
|
||||
pub(super) fn value_to_error_text(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
value: v8::Local<'_, v8::Value>,
|
||||
) -> String {
|
||||
if value.is_object()
|
||||
&& let Ok(object) = v8::Local::<v8::Object>::try_from(value)
|
||||
&& let Some(key) = v8::String::new(scope, "stack")
|
||||
&& let Some(stack) = object.get(scope, key.into())
|
||||
&& stack.is_string()
|
||||
{
|
||||
return stack.to_rust_string_lossy(scope);
|
||||
}
|
||||
value.to_rust_string_lossy(scope)
|
||||
}
|
||||
|
||||
pub(super) fn throw_type_error(scope: &mut v8::PinScope<'_, '_>, message: &str) {
|
||||
if let Some(message) = v8::String::new(scope, message) {
|
||||
scope.throw_exception(message.into());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,673 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value as JsonValue;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::FunctionCallOutputContentItem;
|
||||
use crate::runtime::DEFAULT_EXEC_YIELD_TIME_MS;
|
||||
use crate::runtime::ExecuteRequest;
|
||||
use crate::runtime::RuntimeCommand;
|
||||
use crate::runtime::RuntimeEvent;
|
||||
use crate::runtime::RuntimeResponse;
|
||||
use crate::runtime::TurnMessage;
|
||||
use crate::runtime::WaitRequest;
|
||||
use crate::runtime::spawn_runtime;
|
||||
|
||||
#[async_trait]
|
||||
pub trait CodeModeTurnHost: Send + Sync {
|
||||
async fn invoke_tool(
|
||||
&self,
|
||||
tool_name: String,
|
||||
input: Option<JsonValue>,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> Result<JsonValue, String>;
|
||||
|
||||
async fn notify(&self, call_id: String, cell_id: String, text: String) -> Result<(), String>;
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SessionHandle {
|
||||
control_tx: mpsc::UnboundedSender<SessionControlCommand>,
|
||||
runtime_tx: std::sync::mpsc::Sender<RuntimeCommand>,
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
stored_values: Mutex<HashMap<String, JsonValue>>,
|
||||
sessions: Mutex<HashMap<String, SessionHandle>>,
|
||||
turn_message_tx: mpsc::UnboundedSender<TurnMessage>,
|
||||
turn_message_rx: Arc<Mutex<mpsc::UnboundedReceiver<TurnMessage>>>,
|
||||
next_cell_id: AtomicU64,
|
||||
}
|
||||
|
||||
pub struct CodeModeService {
|
||||
inner: Arc<Inner>,
|
||||
}
|
||||
|
||||
impl CodeModeService {
|
||||
pub fn new() -> Self {
|
||||
let (turn_message_tx, turn_message_rx) = mpsc::unbounded_channel();
|
||||
|
||||
Self {
|
||||
inner: Arc::new(Inner {
|
||||
stored_values: Mutex::new(HashMap::new()),
|
||||
sessions: Mutex::new(HashMap::new()),
|
||||
turn_message_tx,
|
||||
turn_message_rx: Arc::new(Mutex::new(turn_message_rx)),
|
||||
next_cell_id: AtomicU64::new(1),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn stored_values(&self) -> HashMap<String, JsonValue> {
|
||||
self.inner.stored_values.lock().await.clone()
|
||||
}
|
||||
|
||||
pub async fn replace_stored_values(&self, values: HashMap<String, JsonValue>) {
|
||||
*self.inner.stored_values.lock().await = values;
|
||||
}
|
||||
|
||||
pub async fn execute(&self, request: ExecuteRequest) -> Result<RuntimeResponse, String> {
|
||||
let cell_id = self
|
||||
.inner
|
||||
.next_cell_id
|
||||
.fetch_add(1, Ordering::Relaxed)
|
||||
.to_string();
|
||||
let (event_tx, event_rx) = mpsc::unbounded_channel();
|
||||
let (runtime_tx, runtime_terminate_handle) = spawn_runtime(request.clone(), event_tx)?;
|
||||
let (control_tx, control_rx) = mpsc::unbounded_channel();
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
|
||||
self.inner.sessions.lock().await.insert(
|
||||
cell_id.clone(),
|
||||
SessionHandle {
|
||||
control_tx: control_tx.clone(),
|
||||
runtime_tx: runtime_tx.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
tokio::spawn(run_session_control(
|
||||
Arc::clone(&self.inner),
|
||||
SessionControlContext {
|
||||
cell_id: cell_id.clone(),
|
||||
runtime_tx,
|
||||
runtime_terminate_handle,
|
||||
},
|
||||
event_rx,
|
||||
control_rx,
|
||||
response_tx,
|
||||
request.yield_time_ms.unwrap_or(DEFAULT_EXEC_YIELD_TIME_MS),
|
||||
));
|
||||
|
||||
response_rx
|
||||
.await
|
||||
.map_err(|_| "exec runtime ended unexpectedly".to_string())
|
||||
}
|
||||
|
||||
pub async fn wait(&self, request: WaitRequest) -> Result<RuntimeResponse, String> {
|
||||
let cell_id = request.cell_id.clone();
|
||||
let handle = self
|
||||
.inner
|
||||
.sessions
|
||||
.lock()
|
||||
.await
|
||||
.get(&request.cell_id)
|
||||
.cloned();
|
||||
let Some(handle) = handle else {
|
||||
return Ok(missing_cell_response(cell_id));
|
||||
};
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
let control_message = if request.terminate {
|
||||
SessionControlCommand::Terminate { response_tx }
|
||||
} else {
|
||||
SessionControlCommand::Poll {
|
||||
yield_time_ms: request.yield_time_ms,
|
||||
response_tx,
|
||||
}
|
||||
};
|
||||
if handle.control_tx.send(control_message).is_err() {
|
||||
return Ok(missing_cell_response(cell_id));
|
||||
}
|
||||
match response_rx.await {
|
||||
Ok(response) => Ok(response),
|
||||
Err(_) => Ok(missing_cell_response(request.cell_id)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_turn_worker(&self, host: Arc<dyn CodeModeTurnHost>) -> CodeModeTurnWorker {
|
||||
let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
|
||||
let inner = Arc::clone(&self.inner);
|
||||
let turn_message_rx = Arc::clone(&self.inner.turn_message_rx);
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let next_message = tokio::select! {
|
||||
_ = &mut shutdown_rx => break,
|
||||
message = async {
|
||||
let mut turn_message_rx = turn_message_rx.lock().await;
|
||||
turn_message_rx.recv().await
|
||||
} => message,
|
||||
};
|
||||
let Some(next_message) = next_message else {
|
||||
break;
|
||||
};
|
||||
match next_message {
|
||||
TurnMessage::Notify {
|
||||
cell_id,
|
||||
call_id,
|
||||
text,
|
||||
} => {
|
||||
if let Err(err) = host.notify(call_id, cell_id.clone(), text).await {
|
||||
warn!(
|
||||
"failed to deliver code mode notification for cell {cell_id}: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
TurnMessage::ToolCall {
|
||||
cell_id,
|
||||
id,
|
||||
name,
|
||||
input,
|
||||
} => {
|
||||
let host = Arc::clone(&host);
|
||||
let inner = Arc::clone(&inner);
|
||||
tokio::spawn(async move {
|
||||
let response = host
|
||||
.invoke_tool(name, input, CancellationToken::new())
|
||||
.await;
|
||||
let runtime_tx = inner
|
||||
.sessions
|
||||
.lock()
|
||||
.await
|
||||
.get(&cell_id)
|
||||
.map(|handle| handle.runtime_tx.clone());
|
||||
let Some(runtime_tx) = runtime_tx else {
|
||||
return;
|
||||
};
|
||||
let command = match response {
|
||||
Ok(result) => RuntimeCommand::ToolResponse { id, result },
|
||||
Err(error_text) => RuntimeCommand::ToolError { id, error_text },
|
||||
};
|
||||
let _ = runtime_tx.send(command);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
CodeModeTurnWorker {
|
||||
shutdown_tx: Some(shutdown_tx),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CodeModeService {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CodeModeTurnWorker {
|
||||
shutdown_tx: Option<oneshot::Sender<()>>,
|
||||
}
|
||||
|
||||
impl Drop for CodeModeTurnWorker {
|
||||
fn drop(&mut self) {
|
||||
if let Some(shutdown_tx) = self.shutdown_tx.take() {
|
||||
let _ = shutdown_tx.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SessionControlCommand {
|
||||
Poll {
|
||||
yield_time_ms: u64,
|
||||
response_tx: oneshot::Sender<RuntimeResponse>,
|
||||
},
|
||||
Terminate {
|
||||
response_tx: oneshot::Sender<RuntimeResponse>,
|
||||
},
|
||||
}
|
||||
|
||||
struct PendingResult {
|
||||
content_items: Vec<FunctionCallOutputContentItem>,
|
||||
stored_values: HashMap<String, JsonValue>,
|
||||
error_text: Option<String>,
|
||||
}
|
||||
|
||||
struct SessionControlContext {
|
||||
cell_id: String,
|
||||
runtime_tx: std::sync::mpsc::Sender<RuntimeCommand>,
|
||||
runtime_terminate_handle: v8::IsolateHandle,
|
||||
}
|
||||
|
||||
fn missing_cell_response(cell_id: String) -> RuntimeResponse {
|
||||
RuntimeResponse::Result {
|
||||
error_text: Some(format!("exec cell {cell_id} not found")),
|
||||
cell_id,
|
||||
content_items: Vec::new(),
|
||||
stored_values: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn pending_result_response(cell_id: &str, result: PendingResult) -> RuntimeResponse {
|
||||
RuntimeResponse::Result {
|
||||
cell_id: cell_id.to_string(),
|
||||
content_items: result.content_items,
|
||||
stored_values: result.stored_values,
|
||||
error_text: result.error_text,
|
||||
}
|
||||
}
|
||||
|
||||
fn send_or_buffer_result(
|
||||
cell_id: &str,
|
||||
result: PendingResult,
|
||||
response_tx: &mut Option<oneshot::Sender<RuntimeResponse>>,
|
||||
pending_result: &mut Option<PendingResult>,
|
||||
) -> bool {
|
||||
if let Some(response_tx) = response_tx.take() {
|
||||
let _ = response_tx.send(pending_result_response(cell_id, result));
|
||||
return true;
|
||||
}
|
||||
|
||||
*pending_result = Some(result);
|
||||
false
|
||||
}
|
||||
|
||||
async fn run_session_control(
|
||||
inner: Arc<Inner>,
|
||||
context: SessionControlContext,
|
||||
mut event_rx: mpsc::UnboundedReceiver<RuntimeEvent>,
|
||||
mut control_rx: mpsc::UnboundedReceiver<SessionControlCommand>,
|
||||
initial_response_tx: oneshot::Sender<RuntimeResponse>,
|
||||
initial_yield_time_ms: u64,
|
||||
) {
|
||||
let SessionControlContext {
|
||||
cell_id,
|
||||
runtime_tx,
|
||||
runtime_terminate_handle,
|
||||
} = context;
|
||||
let mut content_items = Vec::new();
|
||||
let mut pending_result: Option<PendingResult> = None;
|
||||
let mut response_tx = Some(initial_response_tx);
|
||||
let mut termination_requested = false;
|
||||
let mut runtime_closed = false;
|
||||
let mut yield_timer: Option<std::pin::Pin<Box<tokio::time::Sleep>>> = None;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
maybe_event = async {
|
||||
if runtime_closed {
|
||||
std::future::pending::<Option<RuntimeEvent>>().await
|
||||
} else {
|
||||
event_rx.recv().await
|
||||
}
|
||||
} => {
|
||||
let Some(event) = maybe_event else {
|
||||
runtime_closed = true;
|
||||
if termination_requested {
|
||||
if let Some(response_tx) = response_tx.take() {
|
||||
let _ = response_tx.send(RuntimeResponse::Terminated {
|
||||
cell_id: cell_id.clone(),
|
||||
content_items: std::mem::take(&mut content_items),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
if pending_result.is_none() {
|
||||
let result = PendingResult {
|
||||
content_items: std::mem::take(&mut content_items),
|
||||
stored_values: HashMap::new(),
|
||||
error_text: Some("exec runtime ended unexpectedly".to_string()),
|
||||
};
|
||||
if send_or_buffer_result(
|
||||
&cell_id,
|
||||
result,
|
||||
&mut response_tx,
|
||||
&mut pending_result,
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
};
|
||||
match event {
|
||||
RuntimeEvent::Started => {
|
||||
yield_timer = Some(Box::pin(tokio::time::sleep(Duration::from_millis(initial_yield_time_ms))));
|
||||
}
|
||||
RuntimeEvent::ContentItem(item) => {
|
||||
content_items.push(item);
|
||||
}
|
||||
RuntimeEvent::YieldRequested => {
|
||||
yield_timer = None;
|
||||
if let Some(response_tx) = response_tx.take() {
|
||||
let _ = response_tx.send(RuntimeResponse::Yielded {
|
||||
cell_id: cell_id.clone(),
|
||||
content_items: std::mem::take(&mut content_items),
|
||||
});
|
||||
}
|
||||
}
|
||||
RuntimeEvent::Notify { call_id, text } => {
|
||||
let _ = inner.turn_message_tx.send(TurnMessage::Notify {
|
||||
cell_id: cell_id.clone(),
|
||||
call_id,
|
||||
text,
|
||||
});
|
||||
}
|
||||
RuntimeEvent::ToolCall { id, name, input } => {
|
||||
let _ = inner.turn_message_tx.send(TurnMessage::ToolCall {
|
||||
cell_id: cell_id.clone(),
|
||||
id,
|
||||
name,
|
||||
input,
|
||||
});
|
||||
}
|
||||
RuntimeEvent::Result {
|
||||
stored_values,
|
||||
error_text,
|
||||
} => {
|
||||
yield_timer = None;
|
||||
if termination_requested {
|
||||
if let Some(response_tx) = response_tx.take() {
|
||||
let _ = response_tx.send(RuntimeResponse::Terminated {
|
||||
cell_id: cell_id.clone(),
|
||||
content_items: std::mem::take(&mut content_items),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
let result = PendingResult {
|
||||
content_items: std::mem::take(&mut content_items),
|
||||
stored_values,
|
||||
error_text,
|
||||
};
|
||||
if send_or_buffer_result(
|
||||
&cell_id,
|
||||
result,
|
||||
&mut response_tx,
|
||||
&mut pending_result,
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
maybe_command = control_rx.recv() => {
|
||||
let Some(command) = maybe_command else {
|
||||
break;
|
||||
};
|
||||
match command {
|
||||
SessionControlCommand::Poll {
|
||||
yield_time_ms,
|
||||
response_tx: next_response_tx,
|
||||
} => {
|
||||
if let Some(result) = pending_result.take() {
|
||||
let _ = next_response_tx.send(pending_result_response(&cell_id, result));
|
||||
break;
|
||||
}
|
||||
response_tx = Some(next_response_tx);
|
||||
yield_timer = Some(Box::pin(tokio::time::sleep(Duration::from_millis(yield_time_ms))));
|
||||
}
|
||||
SessionControlCommand::Terminate { response_tx: next_response_tx } => {
|
||||
if let Some(result) = pending_result.take() {
|
||||
let _ = next_response_tx.send(pending_result_response(&cell_id, result));
|
||||
break;
|
||||
}
|
||||
|
||||
response_tx = Some(next_response_tx);
|
||||
termination_requested = true;
|
||||
yield_timer = None;
|
||||
let _ = runtime_tx.send(RuntimeCommand::Terminate);
|
||||
let _ = runtime_terminate_handle.terminate_execution();
|
||||
if runtime_closed {
|
||||
if let Some(response_tx) = response_tx.take() {
|
||||
let _ = response_tx.send(RuntimeResponse::Terminated {
|
||||
cell_id: cell_id.clone(),
|
||||
content_items: std::mem::take(&mut content_items),
|
||||
});
|
||||
}
|
||||
break;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = async {
|
||||
if let Some(yield_timer) = yield_timer.as_mut() {
|
||||
yield_timer.await;
|
||||
} else {
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
} => {
|
||||
yield_timer = None;
|
||||
if let Some(response_tx) = response_tx.take() {
|
||||
let _ = response_tx.send(RuntimeResponse::Yielded {
|
||||
cell_id: cell_id.clone(),
|
||||
content_items: std::mem::take(&mut content_items),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = runtime_tx.send(RuntimeCommand::Terminate);
|
||||
inner.sessions.lock().await.remove(&cell_id);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::time::Duration;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use super::CodeModeService;
|
||||
use super::Inner;
|
||||
use super::RuntimeCommand;
|
||||
use super::RuntimeResponse;
|
||||
use super::SessionControlCommand;
|
||||
use super::SessionControlContext;
|
||||
use super::run_session_control;
|
||||
use crate::FunctionCallOutputContentItem;
|
||||
use crate::runtime::ExecuteRequest;
|
||||
use crate::runtime::RuntimeEvent;
|
||||
use crate::runtime::spawn_runtime;
|
||||
|
||||
fn execute_request(source: &str) -> ExecuteRequest {
|
||||
ExecuteRequest {
|
||||
tool_call_id: "call_1".to_string(),
|
||||
enabled_tools: Vec::new(),
|
||||
source: source.to_string(),
|
||||
stored_values: HashMap::new(),
|
||||
yield_time_ms: Some(1),
|
||||
max_output_tokens: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn test_inner() -> Arc<Inner> {
|
||||
let (turn_message_tx, turn_message_rx) = mpsc::unbounded_channel();
|
||||
Arc::new(Inner {
|
||||
stored_values: Mutex::new(HashMap::new()),
|
||||
sessions: Mutex::new(HashMap::new()),
|
||||
turn_message_tx,
|
||||
turn_message_rx: Arc::new(Mutex::new(turn_message_rx)),
|
||||
next_cell_id: AtomicU64::new(1),
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn synchronous_exit_returns_successfully() {
|
||||
let service = CodeModeService::new();
|
||||
|
||||
let response = service
|
||||
.execute(ExecuteRequest {
|
||||
source: r#"text("before"); exit(); text("after");"#.to_string(),
|
||||
yield_time_ms: None,
|
||||
..execute_request("")
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
RuntimeResponse::Result {
|
||||
cell_id: "1".to_string(),
|
||||
content_items: vec![FunctionCallOutputContentItem::InputText {
|
||||
text: "before".to_string(),
|
||||
}],
|
||||
stored_values: HashMap::new(),
|
||||
error_text: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn v8_console_is_not_exposed_on_global_this() {
|
||||
let service = CodeModeService::new();
|
||||
|
||||
let response = service
|
||||
.execute(ExecuteRequest {
|
||||
source: r#"text(String(Object.hasOwn(globalThis, "console")));"#.to_string(),
|
||||
yield_time_ms: None,
|
||||
..execute_request("")
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
RuntimeResponse::Result {
|
||||
cell_id: "1".to_string(),
|
||||
content_items: vec![FunctionCallOutputContentItem::InputText {
|
||||
text: "false".to_string(),
|
||||
}],
|
||||
stored_values: HashMap::new(),
|
||||
error_text: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn output_helpers_return_undefined() {
|
||||
let service = CodeModeService::new();
|
||||
|
||||
let response = service
|
||||
.execute(ExecuteRequest {
|
||||
source: r#"
|
||||
const returnsUndefined = [
|
||||
text("first"),
|
||||
image("https://example.com/image.jpg"),
|
||||
notify("ping"),
|
||||
].map((value) => value === undefined);
|
||||
text(JSON.stringify(returnsUndefined));
|
||||
"#
|
||||
.to_string(),
|
||||
yield_time_ms: None,
|
||||
..execute_request("")
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
RuntimeResponse::Result {
|
||||
cell_id: "1".to_string(),
|
||||
content_items: vec![
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: "first".to_string(),
|
||||
},
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: "https://example.com/image.jpg".to_string(),
|
||||
detail: None,
|
||||
},
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: "[true,true,true]".to_string(),
|
||||
},
|
||||
],
|
||||
stored_values: HashMap::new(),
|
||||
error_text: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminate_waits_for_runtime_shutdown_before_responding() {
|
||||
let inner = test_inner();
|
||||
let (event_tx, event_rx) = mpsc::unbounded_channel();
|
||||
let (control_tx, control_rx) = mpsc::unbounded_channel();
|
||||
let (initial_response_tx, initial_response_rx) = oneshot::channel();
|
||||
let (runtime_event_tx, _runtime_event_rx) = mpsc::unbounded_channel();
|
||||
let (runtime_tx, runtime_terminate_handle) = spawn_runtime(
|
||||
ExecuteRequest {
|
||||
source: "await new Promise(() => {})".to_string(),
|
||||
yield_time_ms: None,
|
||||
..execute_request("")
|
||||
},
|
||||
runtime_event_tx,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
tokio::spawn(run_session_control(
|
||||
inner,
|
||||
SessionControlContext {
|
||||
cell_id: "cell-1".to_string(),
|
||||
runtime_tx: runtime_tx.clone(),
|
||||
runtime_terminate_handle,
|
||||
},
|
||||
event_rx,
|
||||
control_rx,
|
||||
initial_response_tx,
|
||||
60_000,
|
||||
));
|
||||
|
||||
event_tx.send(RuntimeEvent::Started).unwrap();
|
||||
event_tx.send(RuntimeEvent::YieldRequested).unwrap();
|
||||
assert_eq!(
|
||||
initial_response_rx.await.unwrap(),
|
||||
RuntimeResponse::Yielded {
|
||||
cell_id: "cell-1".to_string(),
|
||||
content_items: Vec::new(),
|
||||
}
|
||||
);
|
||||
|
||||
let (terminate_response_tx, terminate_response_rx) = oneshot::channel();
|
||||
control_tx
|
||||
.send(SessionControlCommand::Terminate {
|
||||
response_tx: terminate_response_tx,
|
||||
})
|
||||
.unwrap();
|
||||
let terminate_response = async { terminate_response_rx.await.unwrap() };
|
||||
tokio::pin!(terminate_response);
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(100), terminate_response.as_mut())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
drop(event_tx);
|
||||
|
||||
assert_eq!(
|
||||
terminate_response.await,
|
||||
RuntimeResponse::Terminated {
|
||||
cell_id: "cell-1".to_string(),
|
||||
content_items: Vec::new(),
|
||||
}
|
||||
);
|
||||
|
||||
let _ = runtime_tx.send(RuntimeCommand::Terminate);
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ codex-api = { workspace = true }
|
||||
codex-app-server-protocol = { workspace = true }
|
||||
codex-apply-patch = { workspace = true }
|
||||
codex-async-utils = { workspace = true }
|
||||
codex-code-mode = { workspace = true }
|
||||
codex-connectors = { workspace = true }
|
||||
codex-config = { workspace = true }
|
||||
codex-exec-server = { workspace = true }
|
||||
|
||||
@@ -61,9 +61,11 @@ cases like `/repo = write`, `/repo/a = none`, `/repo/a/b = write`, where the
|
||||
more specific writable child must reopen under a denied parent.
|
||||
|
||||
The Linux sandbox helper prefers `/usr/bin/bwrap` whenever it is available and
|
||||
falls back to the vendored bubblewrap path otherwise. When `/usr/bin/bwrap` is
|
||||
missing, Codex also surfaces a startup warning through its normal notification
|
||||
path instead of printing directly from the sandbox helper.
|
||||
supports the required argv-rewrite flags, and falls back to the vendored
|
||||
bubblewrap path compiled into the binary otherwise. When `/usr/bin/bwrap` is
|
||||
missing or too old to support the required flags, Codex also surfaces a startup
|
||||
warning through its normal notification path instead of printing directly from
|
||||
the sandbox helper.
|
||||
|
||||
### Windows
|
||||
|
||||
|
||||
@@ -425,15 +425,15 @@
|
||||
"multi_agent": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"multi_agent_v2": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"personality": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"plugins": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"powershell_utf8": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"prevent_idle_sleep": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -2034,15 +2034,15 @@
|
||||
"multi_agent": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"multi_agent_v2": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"personality": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"plugins": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"powershell_utf8": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"prevent_idle_sleep": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use codex_protocol::ThreadId;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Resolves a single tool-facing agent target to a thread id.
|
||||
pub(crate) async fn resolve_agent_target(
|
||||
session: &Arc<Session>,
|
||||
turn: &Arc<TurnContext>,
|
||||
target: &str,
|
||||
) -> Result<ThreadId, FunctionCallError> {
|
||||
register_session_root(session, turn);
|
||||
if let Ok(thread_id) = ThreadId::from_string(target) {
|
||||
return Ok(thread_id);
|
||||
}
|
||||
|
||||
session
|
||||
.services
|
||||
.agent_control
|
||||
.resolve_agent_reference(session.conversation_id, &turn.session_source, target)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
crate::error::CodexErr::UnsupportedOperation(message) => {
|
||||
FunctionCallError::RespondToModel(message)
|
||||
}
|
||||
other => FunctionCallError::RespondToModel(other.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolves multiple tool-facing agent targets to thread ids.
|
||||
pub(crate) async fn resolve_agent_targets(
|
||||
session: &Arc<Session>,
|
||||
turn: &Arc<TurnContext>,
|
||||
targets: Vec<String>,
|
||||
) -> Result<Vec<ThreadId>, FunctionCallError> {
|
||||
if targets.is_empty() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"agent targets must be non-empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut resolved = Vec::with_capacity(targets.len());
|
||||
for target in &targets {
|
||||
resolved.push(resolve_agent_target(session, turn, target).await?);
|
||||
}
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
fn register_session_root(session: &Arc<Session>, turn: &Arc<TurnContext>) {
|
||||
session
|
||||
.services
|
||||
.agent_control
|
||||
.register_session_root(session.conversation_id, &turn.session_source);
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
use crate::agent::AgentStatus;
|
||||
use crate::agent::guards::Guards;
|
||||
use crate::agent::inter_agent_instruction::InterAgentDelivery;
|
||||
use crate::agent::inter_agent_instruction::InterAgentInstruction;
|
||||
use crate::agent::registry::AgentMetadata;
|
||||
use crate::agent::registry::AgentRegistry;
|
||||
use crate::agent::role::DEFAULT_ROLE_NAME;
|
||||
use crate::agent::role::resolve_role_config;
|
||||
use crate::agent::status::is_final;
|
||||
@@ -15,6 +18,7 @@ use crate::shell_snapshot::ShellSnapshot;
|
||||
use crate::state_db;
|
||||
use crate::thread_manager::ThreadManagerState;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::AgentPath;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
@@ -41,6 +45,13 @@ pub(crate) struct SpawnAgentOptions {
|
||||
pub(crate) fork_parent_spawn_call_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct LiveAgent {
|
||||
pub(crate) thread_id: ThreadId,
|
||||
pub(crate) metadata: AgentMetadata,
|
||||
pub(crate) status: AgentStatus,
|
||||
}
|
||||
|
||||
fn default_agent_nickname_list() -> Vec<&'static str> {
|
||||
AGENT_NAMES
|
||||
.lines()
|
||||
@@ -69,16 +80,16 @@ fn agent_nickname_candidates(
|
||||
/// Control-plane handle for multi-agent operations.
|
||||
/// `AgentControl` is held by each session (via `SessionServices`). It provides capability to
|
||||
/// spawn new agents and the inter-agent communication layer.
|
||||
/// An `AgentControl` instance is shared per "user session" which means the same `AgentControl`
|
||||
/// is used for every sub-agent spawned by Codex. By doing so, we make sure the guards are
|
||||
/// scoped to a user session.
|
||||
/// An `AgentControl` instance is intended to be created at most once per root thread/session
|
||||
/// tree. That same `AgentControl` is then shared with every sub-agent spawned from that root,
|
||||
/// which keeps the registry scoped to that root thread rather than the entire `ThreadManager`.
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct AgentControl {
|
||||
/// Weak handle back to the global thread registry/state.
|
||||
/// This is `Weak` to avoid reference cycles and shadow persistence of the form
|
||||
/// `ThreadManagerState -> CodexThread -> Session -> SessionServices -> ThreadManagerState`.
|
||||
manager: Weak<ThreadManagerState>,
|
||||
state: Arc<Guards>,
|
||||
state: Arc<AgentRegistry>,
|
||||
}
|
||||
|
||||
impl AgentControl {
|
||||
@@ -97,17 +108,30 @@ impl AgentControl {
|
||||
items: Vec<UserInput>,
|
||||
session_source: Option<SessionSource>,
|
||||
) -> CodexResult<ThreadId> {
|
||||
self.spawn_agent_with_options(config, items, session_source, SpawnAgentOptions::default())
|
||||
.await
|
||||
Ok(self
|
||||
.spawn_agent_internal(config, items, session_source, SpawnAgentOptions::default())
|
||||
.await?
|
||||
.thread_id)
|
||||
}
|
||||
|
||||
pub(crate) async fn spawn_agent_with_options(
|
||||
pub(crate) async fn spawn_agent_with_metadata(
|
||||
&self,
|
||||
config: crate::config::Config,
|
||||
items: Vec<UserInput>,
|
||||
session_source: Option<SessionSource>,
|
||||
options: SpawnAgentOptions,
|
||||
) -> CodexResult<ThreadId> {
|
||||
) -> CodexResult<LiveAgent> {
|
||||
self.spawn_agent_internal(config, items, session_source, options)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn spawn_agent_internal(
|
||||
&self,
|
||||
config: crate::config::Config,
|
||||
items: Vec<UserInput>,
|
||||
session_source: Option<SessionSource>,
|
||||
options: SpawnAgentOptions,
|
||||
) -> CodexResult<LiveAgent> {
|
||||
let state = self.upgrade()?;
|
||||
let mut reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?;
|
||||
let inherited_shell_snapshot = self
|
||||
@@ -116,25 +140,26 @@ impl AgentControl {
|
||||
let inherited_exec_policy = self
|
||||
.inherited_exec_policy_for_source(&state, session_source.as_ref(), &config)
|
||||
.await;
|
||||
let session_source = match session_source {
|
||||
let (session_source, mut agent_metadata) = match session_source {
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth,
|
||||
agent_path,
|
||||
agent_role,
|
||||
..
|
||||
})) => {
|
||||
let candidate_names = agent_nickname_candidates(&config, agent_role.as_deref());
|
||||
let candidate_name_refs: Vec<&str> =
|
||||
candidate_names.iter().map(String::as_str).collect();
|
||||
let agent_nickname = reservation.reserve_agent_nickname(&candidate_name_refs)?;
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
let (session_source, agent_metadata) = self.prepare_thread_spawn(
|
||||
&mut reservation,
|
||||
&config,
|
||||
parent_thread_id,
|
||||
depth,
|
||||
agent_nickname: Some(agent_nickname),
|
||||
agent_path,
|
||||
agent_role,
|
||||
}))
|
||||
/*preferred_agent_nickname*/ None,
|
||||
)?;
|
||||
(Some(session_source), agent_metadata)
|
||||
}
|
||||
other => other,
|
||||
other => (other, AgentMetadata::default()),
|
||||
};
|
||||
let notification_source = session_source.clone();
|
||||
|
||||
@@ -217,7 +242,8 @@ impl AgentControl {
|
||||
}
|
||||
None => state.spawn_new_thread(config, self.clone()).await?,
|
||||
};
|
||||
reservation.commit(new_thread.thread_id);
|
||||
agent_metadata.agent_id = Some(new_thread.thread_id);
|
||||
reservation.commit(agent_metadata.clone());
|
||||
|
||||
// Notify a new thread has been created. This notification will be processed by clients
|
||||
// to subscribe or drain this newly created thread.
|
||||
@@ -232,9 +258,22 @@ impl AgentControl {
|
||||
.await;
|
||||
|
||||
self.send_input(new_thread.thread_id, items).await?;
|
||||
self.maybe_start_completion_watcher(new_thread.thread_id, notification_source);
|
||||
let child_reference = agent_metadata
|
||||
.agent_path
|
||||
.as_ref()
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| new_thread.thread_id.to_string());
|
||||
self.maybe_start_completion_watcher(
|
||||
new_thread.thread_id,
|
||||
notification_source,
|
||||
child_reference,
|
||||
);
|
||||
|
||||
Ok(new_thread.thread_id)
|
||||
Ok(LiveAgent {
|
||||
thread_id: new_thread.thread_id,
|
||||
metadata: agent_metadata,
|
||||
status: self.get_status(new_thread.thread_id).await,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resume an existing agent thread from a recorded rollout file.
|
||||
@@ -283,6 +322,7 @@ impl AgentControl {
|
||||
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: child_depth,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
});
|
||||
@@ -324,14 +364,14 @@ impl AgentControl {
|
||||
}
|
||||
let state = self.upgrade()?;
|
||||
let mut reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?;
|
||||
let session_source = match session_source {
|
||||
let (session_source, agent_metadata) = match session_source {
|
||||
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth,
|
||||
..
|
||||
agent_path,
|
||||
agent_role: _,
|
||||
agent_nickname: _,
|
||||
}) => {
|
||||
// Collab resume callers rebuild a placeholder ThreadSpawn source. Rehydrate the
|
||||
// stored nickname/role from sqlite when available; otherwise leave both unset.
|
||||
let (resumed_agent_nickname, resumed_agent_role) =
|
||||
if let Some(state_db_ctx) = state_db::get_state_db(&config).await {
|
||||
match state_db_ctx.get_thread(thread_id).await {
|
||||
@@ -341,27 +381,17 @@ impl AgentControl {
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
let reserved_agent_nickname = resumed_agent_nickname
|
||||
.as_deref()
|
||||
.map(|agent_nickname| {
|
||||
let candidate_names =
|
||||
agent_nickname_candidates(&config, resumed_agent_role.as_deref());
|
||||
let candidate_name_refs: Vec<&str> =
|
||||
candidate_names.iter().map(String::as_str).collect();
|
||||
reservation.reserve_agent_nickname_with_preference(
|
||||
&candidate_name_refs,
|
||||
Some(agent_nickname),
|
||||
)
|
||||
})
|
||||
.transpose()?;
|
||||
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
self.prepare_thread_spawn(
|
||||
&mut reservation,
|
||||
&config,
|
||||
parent_thread_id,
|
||||
depth,
|
||||
agent_nickname: reserved_agent_nickname,
|
||||
agent_role: resumed_agent_role,
|
||||
})
|
||||
agent_path,
|
||||
resumed_agent_role,
|
||||
resumed_agent_nickname,
|
||||
)?
|
||||
}
|
||||
other => other,
|
||||
other => (other, AgentMetadata::default()),
|
||||
};
|
||||
let notification_source = session_source.clone();
|
||||
let inherited_shell_snapshot = self
|
||||
@@ -393,13 +423,21 @@ impl AgentControl {
|
||||
inherited_exec_policy,
|
||||
)
|
||||
.await?;
|
||||
reservation.commit(resumed_thread.thread_id);
|
||||
let mut agent_metadata = agent_metadata;
|
||||
agent_metadata.agent_id = Some(resumed_thread.thread_id);
|
||||
reservation.commit(agent_metadata.clone());
|
||||
// Resumed threads are re-registered in-memory and need the same listener
|
||||
// attachment path as freshly spawned threads.
|
||||
state.notify_thread_created(resumed_thread.thread_id);
|
||||
let child_reference = agent_metadata
|
||||
.agent_path
|
||||
.as_ref()
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| resumed_thread.thread_id.to_string());
|
||||
self.maybe_start_completion_watcher(
|
||||
resumed_thread.thread_id,
|
||||
Some(notification_source.clone()),
|
||||
child_reference,
|
||||
);
|
||||
self.persist_thread_spawn_edge_for_source(
|
||||
resumed_thread.thread.as_ref(),
|
||||
@@ -418,20 +456,53 @@ impl AgentControl {
|
||||
items: Vec<UserInput>,
|
||||
) -> CodexResult<String> {
|
||||
let state = self.upgrade()?;
|
||||
let result = state
|
||||
.send_op(
|
||||
agent_id,
|
||||
Op::UserInput {
|
||||
items,
|
||||
final_output_json_schema: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
if matches!(result, Err(CodexErr::InternalAgentDied)) {
|
||||
let _ = state.remove_thread(&agent_id).await;
|
||||
self.state.release_spawned_thread(agent_id);
|
||||
}
|
||||
result
|
||||
self.handle_thread_request_result(
|
||||
agent_id,
|
||||
&state,
|
||||
state
|
||||
.send_op(
|
||||
agent_id,
|
||||
Op::UserInput {
|
||||
items,
|
||||
final_output_json_schema: None,
|
||||
},
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Append a prebuilt message to an existing agent thread outside the normal user-input path.
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn append_message(
|
||||
&self,
|
||||
agent_id: ThreadId,
|
||||
message: ResponseItem,
|
||||
) -> CodexResult<String> {
|
||||
let state = self.upgrade()?;
|
||||
self.handle_thread_request_result(
|
||||
agent_id,
|
||||
&state,
|
||||
state.append_message(agent_id, message).await,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn deliver_inter_agent_instruction(
|
||||
&self,
|
||||
agent_id: ThreadId,
|
||||
instruction: InterAgentInstruction,
|
||||
delivery: InterAgentDelivery,
|
||||
) -> CodexResult<String> {
|
||||
let state = self.upgrade()?;
|
||||
self.handle_thread_request_result(
|
||||
agent_id,
|
||||
&state,
|
||||
state
|
||||
.deliver_inter_agent_instruction(agent_id, instruction, delivery)
|
||||
.await,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Interrupt the current task for an existing agent thread.
|
||||
@@ -440,6 +511,19 @@ impl AgentControl {
|
||||
state.send_op(agent_id, Op::Interrupt).await
|
||||
}
|
||||
|
||||
async fn handle_thread_request_result(
|
||||
&self,
|
||||
agent_id: ThreadId,
|
||||
state: &Arc<ThreadManagerState>,
|
||||
result: CodexResult<String>,
|
||||
) -> CodexResult<String> {
|
||||
if matches!(result, Err(CodexErr::InternalAgentDied)) {
|
||||
let _ = state.remove_thread(&agent_id).await;
|
||||
self.state.release_spawned_thread(agent_id);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Submit a shutdown request for a live agent without marking it explicitly closed in
|
||||
/// persisted spawn-edge state.
|
||||
pub(crate) async fn shutdown_live_agent(&self, agent_id: ThreadId) -> CodexResult<String> {
|
||||
@@ -500,21 +584,18 @@ impl AgentControl {
|
||||
thread.agent_status().await
|
||||
}
|
||||
|
||||
pub(crate) async fn get_agent_nickname_and_role(
|
||||
pub(crate) fn register_session_root(
|
||||
&self,
|
||||
agent_id: ThreadId,
|
||||
) -> Option<(Option<String>, Option<String>)> {
|
||||
let Ok(state) = self.upgrade() else {
|
||||
return None;
|
||||
};
|
||||
let Ok(thread) = state.get_thread(agent_id).await else {
|
||||
return None;
|
||||
};
|
||||
let session_source = thread.config_snapshot().await.session_source;
|
||||
Some((
|
||||
session_source.get_nickname(),
|
||||
session_source.get_agent_role(),
|
||||
))
|
||||
current_thread_id: ThreadId,
|
||||
current_session_source: &SessionSource,
|
||||
) {
|
||||
if thread_spawn_parent_thread_id(current_session_source).is_none() {
|
||||
self.state.register_root_thread(current_thread_id);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn get_agent_metadata(&self, agent_id: ThreadId) -> Option<AgentMetadata> {
|
||||
self.state.agent_metadata_for_thread(agent_id)
|
||||
}
|
||||
|
||||
pub(crate) async fn get_agent_config_snapshot(
|
||||
@@ -530,6 +611,33 @@ impl AgentControl {
|
||||
Some(thread.config_snapshot().await)
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_agent_reference(
|
||||
&self,
|
||||
_current_thread_id: ThreadId,
|
||||
current_session_source: &SessionSource,
|
||||
agent_reference: &str,
|
||||
) -> CodexResult<ThreadId> {
|
||||
let current_agent_path = current_session_source
|
||||
.get_agent_path()
|
||||
.unwrap_or_else(AgentPath::root);
|
||||
let agent_path = current_agent_path
|
||||
.resolve(agent_reference)
|
||||
.map_err(CodexErr::UnsupportedOperation)?;
|
||||
if agent_path.is_root() {
|
||||
return Err(CodexErr::UnsupportedOperation(
|
||||
"root is not a spawned agent".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(thread_id) = self.state.agent_id_for_path(&agent_path) {
|
||||
return Ok(thread_id);
|
||||
}
|
||||
Err(CodexErr::UnsupportedOperation(format!(
|
||||
"live agent path `{}` not found",
|
||||
agent_path.as_str()
|
||||
)))
|
||||
}
|
||||
|
||||
/// Subscribe to status updates for `agent_id`, yielding the latest value and changes.
|
||||
pub(crate) async fn subscribe_status(
|
||||
&self,
|
||||
@@ -560,8 +668,13 @@ impl AgentControl {
|
||||
|
||||
agents
|
||||
.into_iter()
|
||||
.map(|(thread_id, nickname)| {
|
||||
format_subagent_context_line(&thread_id.to_string(), nickname.as_deref())
|
||||
.map(|(thread_id, metadata)| {
|
||||
let reference = metadata
|
||||
.agent_path
|
||||
.as_ref()
|
||||
.map(|agent_path| agent_path.name().to_string())
|
||||
.unwrap_or_else(|| thread_id.to_string());
|
||||
format_subagent_context_line(reference.as_str(), metadata.agent_nickname.as_deref())
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
@@ -575,6 +688,7 @@ impl AgentControl {
|
||||
&self,
|
||||
child_thread_id: ThreadId,
|
||||
session_source: Option<SessionSource>,
|
||||
child_reference: String,
|
||||
) {
|
||||
let Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id, ..
|
||||
@@ -610,13 +724,52 @@ impl AgentControl {
|
||||
};
|
||||
parent_thread
|
||||
.inject_user_message_without_turn(format_subagent_notification_message(
|
||||
&child_thread_id.to_string(),
|
||||
child_reference.as_str(),
|
||||
&status,
|
||||
))
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn prepare_thread_spawn(
|
||||
&self,
|
||||
reservation: &mut crate::agent::registry::SpawnReservation,
|
||||
config: &crate::config::Config,
|
||||
parent_thread_id: ThreadId,
|
||||
depth: i32,
|
||||
agent_path: Option<AgentPath>,
|
||||
agent_role: Option<String>,
|
||||
preferred_agent_nickname: Option<String>,
|
||||
) -> CodexResult<(SessionSource, AgentMetadata)> {
|
||||
if depth == 1 {
|
||||
self.state.register_root_thread(parent_thread_id);
|
||||
}
|
||||
if let Some(agent_path) = agent_path.as_ref() {
|
||||
reservation.reserve_agent_path(agent_path)?;
|
||||
}
|
||||
let candidate_names = agent_nickname_candidates(config, agent_role.as_deref());
|
||||
let candidate_name_refs: Vec<&str> = candidate_names.iter().map(String::as_str).collect();
|
||||
let agent_nickname = Some(reservation.reserve_agent_nickname_with_preference(
|
||||
&candidate_name_refs,
|
||||
preferred_agent_nickname.as_deref(),
|
||||
)?);
|
||||
let session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth,
|
||||
agent_path: agent_path.clone(),
|
||||
agent_nickname: agent_nickname.clone(),
|
||||
agent_role: agent_role.clone(),
|
||||
});
|
||||
let agent_metadata = AgentMetadata {
|
||||
agent_id: None,
|
||||
agent_path,
|
||||
agent_nickname,
|
||||
agent_role,
|
||||
};
|
||||
Ok((session_source, agent_metadata))
|
||||
}
|
||||
|
||||
fn upgrade(&self) -> CodexResult<Arc<ThreadManagerState>> {
|
||||
self.manager
|
||||
.upgrade()
|
||||
@@ -666,7 +819,7 @@ impl AgentControl {
|
||||
async fn open_thread_spawn_children(
|
||||
&self,
|
||||
parent_thread_id: ThreadId,
|
||||
) -> CodexResult<Vec<(ThreadId, Option<String>)>> {
|
||||
) -> CodexResult<Vec<(ThreadId, AgentMetadata)>> {
|
||||
let mut children_by_parent = self.live_thread_spawn_children().await?;
|
||||
Ok(children_by_parent
|
||||
.remove(&parent_thread_id)
|
||||
@@ -675,9 +828,9 @@ impl AgentControl {
|
||||
|
||||
async fn live_thread_spawn_children(
|
||||
&self,
|
||||
) -> CodexResult<HashMap<ThreadId, Vec<(ThreadId, Option<String>)>>> {
|
||||
) -> CodexResult<HashMap<ThreadId, Vec<(ThreadId, AgentMetadata)>>> {
|
||||
let state = self.upgrade()?;
|
||||
let mut children_by_parent = HashMap::<ThreadId, Vec<(ThreadId, Option<String>)>>::new();
|
||||
let mut children_by_parent = HashMap::<ThreadId, Vec<(ThreadId, AgentMetadata)>>::new();
|
||||
|
||||
for thread_id in state.list_thread_ids().await {
|
||||
let Ok(thread) = state.get_thread(thread_id).await else {
|
||||
@@ -691,11 +844,26 @@ impl AgentControl {
|
||||
children_by_parent
|
||||
.entry(parent_thread_id)
|
||||
.or_default()
|
||||
.push((thread_id, snapshot.session_source.get_nickname()));
|
||||
.push((
|
||||
thread_id,
|
||||
self.state
|
||||
.agent_metadata_for_thread(thread_id)
|
||||
.unwrap_or(AgentMetadata {
|
||||
agent_id: Some(thread_id),
|
||||
..Default::default()
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
for children in children_by_parent.values_mut() {
|
||||
children.sort_by(|left, right| left.0.to_string().cmp(&right.0.to_string()));
|
||||
children.sort_by(|left, right| {
|
||||
left.1
|
||||
.agent_path
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.cmp(right.1.agent_path.as_deref().unwrap_or_default())
|
||||
.then_with(|| left.0.to_string().cmp(&right.0.to_string()))
|
||||
});
|
||||
}
|
||||
|
||||
Ok(children_by_parent)
|
||||
|
||||
@@ -382,6 +382,61 @@ async fn send_input_submits_user_message() {
|
||||
assert_eq!(captured, Some(expected));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn append_message_records_assistant_message() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
let (thread_id, thread) = harness.start_thread().await;
|
||||
let message =
|
||||
"author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: hello from tests";
|
||||
|
||||
let submission_id = harness
|
||||
.control
|
||||
.append_message(
|
||||
thread_id,
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: message.to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("append_message should succeed");
|
||||
assert!(!submission_id.is_empty());
|
||||
|
||||
timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
let history_items = thread
|
||||
.codex
|
||||
.session
|
||||
.clone_history()
|
||||
.await
|
||||
.raw_items()
|
||||
.to_vec();
|
||||
let recorded = history_items.iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
ResponseItem::Message { role, content, .. }
|
||||
if role == "assistant"
|
||||
&& content.iter().any(|content_item| matches!(
|
||||
content_item,
|
||||
ContentItem::InputText { text } if text == message
|
||||
))
|
||||
)
|
||||
});
|
||||
if recorded {
|
||||
break;
|
||||
}
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("assistant message should be recorded");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_creates_thread_and_sends_prompt() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
@@ -443,12 +498,13 @@ async fn spawn_agent_can_fork_parent_thread_history() {
|
||||
|
||||
let child_thread_id = harness
|
||||
.control
|
||||
.spawn_agent_with_options(
|
||||
.spawn_agent_with_metadata(
|
||||
harness.config.clone(),
|
||||
text_input("child task"),
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 1,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
})),
|
||||
@@ -457,7 +513,8 @@ async fn spawn_agent_can_fork_parent_thread_history() {
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("forked spawn should succeed");
|
||||
.expect("forked spawn should succeed")
|
||||
.thread_id;
|
||||
|
||||
let child_thread = harness
|
||||
.manager
|
||||
@@ -526,12 +583,13 @@ async fn spawn_agent_fork_injects_output_for_parent_spawn_call() {
|
||||
|
||||
let child_thread_id = harness
|
||||
.control
|
||||
.spawn_agent_with_options(
|
||||
.spawn_agent_with_metadata(
|
||||
harness.config.clone(),
|
||||
text_input("child task"),
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 1,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
})),
|
||||
@@ -540,7 +598,8 @@ async fn spawn_agent_fork_injects_output_for_parent_spawn_call() {
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("forked spawn should succeed");
|
||||
.expect("forked spawn should succeed")
|
||||
.thread_id;
|
||||
|
||||
let child_thread = harness
|
||||
.manager
|
||||
@@ -596,12 +655,13 @@ async fn spawn_agent_fork_flushes_parent_rollout_before_loading_history() {
|
||||
|
||||
let child_thread_id = harness
|
||||
.control
|
||||
.spawn_agent_with_options(
|
||||
.spawn_agent_with_metadata(
|
||||
harness.config.clone(),
|
||||
text_input("child task"),
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 1,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
})),
|
||||
@@ -610,7 +670,8 @@ async fn spawn_agent_fork_flushes_parent_rollout_before_loading_history() {
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("forked spawn should flush parent rollout before loading history");
|
||||
.expect("forked spawn should flush parent rollout before loading history")
|
||||
.thread_id;
|
||||
|
||||
let child_thread = harness
|
||||
.manager
|
||||
@@ -855,6 +916,7 @@ async fn spawn_child_completion_notifies_parent_history() {
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 1,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: Some("explorer".to_string()),
|
||||
})),
|
||||
@@ -886,9 +948,11 @@ async fn completion_watcher_notifies_parent_when_child_is_missing() {
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 1,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: Some("explorer".to_string()),
|
||||
})),
|
||||
child_thread_id.to_string(),
|
||||
);
|
||||
|
||||
assert_eq!(wait_for_subagent_notification(&parent_thread).await, true);
|
||||
@@ -903,7 +967,7 @@ async fn completion_watcher_notifies_parent_when_child_is_missing() {
|
||||
assert_eq!(
|
||||
history_contains_text(
|
||||
&history_items,
|
||||
&format!("\"agent_id\":\"{child_thread_id}\"")
|
||||
&format!("\"agent_path\":\"{child_thread_id}\"")
|
||||
),
|
||||
true
|
||||
);
|
||||
@@ -926,6 +990,7 @@ async fn spawn_thread_subagent_gets_random_nickname_in_session_source() {
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 1,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: Some("explorer".to_string()),
|
||||
})),
|
||||
@@ -945,6 +1010,7 @@ async fn spawn_thread_subagent_gets_random_nickname_in_session_source() {
|
||||
depth,
|
||||
agent_nickname,
|
||||
agent_role,
|
||||
..
|
||||
}) = snapshot.session_source
|
||||
else {
|
||||
panic!("expected thread-spawn sub-agent source");
|
||||
@@ -976,6 +1042,7 @@ async fn spawn_thread_subagent_uses_role_specific_nickname_candidates() {
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 1,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: Some("researcher".to_string()),
|
||||
})),
|
||||
@@ -1018,6 +1085,8 @@ async fn resume_thread_subagent_restores_stored_nickname_and_role() {
|
||||
control,
|
||||
};
|
||||
let (parent_thread_id, _parent_thread) = harness.start_thread().await;
|
||||
let agent_path = AgentPath::from_string("/root/explorer".to_string())
|
||||
.expect("test agent path should be valid");
|
||||
|
||||
let child_thread_id = harness
|
||||
.control
|
||||
@@ -1027,6 +1096,7 @@ async fn resume_thread_subagent_restores_stored_nickname_and_role() {
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 1,
|
||||
agent_path: Some(agent_path.clone()),
|
||||
agent_nickname: None,
|
||||
agent_role: Some("explorer".to_string()),
|
||||
})),
|
||||
@@ -1095,6 +1165,7 @@ async fn resume_thread_subagent_restores_stored_nickname_and_role() {
|
||||
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 1,
|
||||
agent_path: Some(agent_path.clone()),
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
}),
|
||||
@@ -1113,14 +1184,17 @@ async fn resume_thread_subagent_restores_stored_nickname_and_role() {
|
||||
let SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: resumed_parent_thread_id,
|
||||
depth: resumed_depth,
|
||||
agent_path: resumed_agent_path,
|
||||
agent_nickname: resumed_nickname,
|
||||
agent_role: resumed_role,
|
||||
..
|
||||
}) = resumed_snapshot.session_source
|
||||
else {
|
||||
panic!("expected thread-spawn sub-agent source");
|
||||
};
|
||||
assert_eq!(resumed_parent_thread_id, parent_thread_id);
|
||||
assert_eq!(resumed_depth, 1);
|
||||
assert_eq!(resumed_agent_path, Some(agent_path));
|
||||
assert_eq!(resumed_nickname, Some(original_nickname));
|
||||
assert_eq!(resumed_role, Some("explorer".to_string()));
|
||||
|
||||
@@ -1206,6 +1280,7 @@ async fn shutdown_agent_tree_closes_live_descendants() {
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 1,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: Some("explorer".to_string()),
|
||||
})),
|
||||
@@ -1220,6 +1295,7 @@ async fn shutdown_agent_tree_closes_live_descendants() {
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: child_thread_id,
|
||||
depth: 2,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: Some("worker".to_string()),
|
||||
})),
|
||||
@@ -1289,6 +1365,7 @@ async fn shutdown_agent_tree_closes_descendants_when_started_at_child() {
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 1,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: Some("explorer".to_string()),
|
||||
})),
|
||||
@@ -1303,6 +1380,7 @@ async fn shutdown_agent_tree_closes_descendants_when_started_at_child() {
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: child_thread_id,
|
||||
depth: 2,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: Some("worker".to_string()),
|
||||
})),
|
||||
@@ -1378,6 +1456,7 @@ async fn resume_agent_from_rollout_does_not_reopen_closed_descendants() {
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 1,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: Some("explorer".to_string()),
|
||||
})),
|
||||
@@ -1392,6 +1471,7 @@ async fn resume_agent_from_rollout_does_not_reopen_closed_descendants() {
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: child_thread_id,
|
||||
depth: 2,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: Some("worker".to_string()),
|
||||
})),
|
||||
@@ -1471,6 +1551,7 @@ async fn resume_closed_child_reopens_open_descendants() {
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 1,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: Some("explorer".to_string()),
|
||||
})),
|
||||
@@ -1485,6 +1566,7 @@ async fn resume_closed_child_reopens_open_descendants() {
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: child_thread_id,
|
||||
depth: 2,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: Some("worker".to_string()),
|
||||
})),
|
||||
@@ -1524,6 +1606,7 @@ async fn resume_closed_child_reopens_open_descendants() {
|
||||
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 1,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
}),
|
||||
@@ -1565,6 +1648,7 @@ async fn resume_agent_from_rollout_reopens_open_descendants_after_manager_shutdo
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 1,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: Some("explorer".to_string()),
|
||||
})),
|
||||
@@ -1579,6 +1663,7 @@ async fn resume_agent_from_rollout_reopens_open_descendants_after_manager_shutdo
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: child_thread_id,
|
||||
depth: 2,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: Some("worker".to_string()),
|
||||
})),
|
||||
@@ -1654,6 +1739,7 @@ async fn resume_agent_from_rollout_uses_edge_data_when_descendant_metadata_sourc
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 1,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: Some("explorer".to_string()),
|
||||
})),
|
||||
@@ -1668,6 +1754,7 @@ async fn resume_agent_from_rollout_uses_edge_data_when_descendant_metadata_sourc
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: child_thread_id,
|
||||
depth: 2,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: Some("worker".to_string()),
|
||||
})),
|
||||
@@ -1705,6 +1792,7 @@ async fn resume_agent_from_rollout_uses_edge_data_when_descendant_metadata_sourc
|
||||
serde_json::to_string(&SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: ThreadId::new(),
|
||||
depth: 99,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: Some("worker".to_string()),
|
||||
}))
|
||||
@@ -1782,6 +1870,7 @@ async fn resume_agent_from_rollout_skips_descendants_when_parent_resume_fails()
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 1,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: Some("explorer".to_string()),
|
||||
})),
|
||||
@@ -1796,6 +1885,7 @@ async fn resume_agent_from_rollout_skips_descendants_when_parent_resume_fails()
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: child_thread_id,
|
||||
depth: 2,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: Some("worker".to_string()),
|
||||
})),
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[test]
|
||||
fn format_agent_nickname_adds_ordinals_after_reset() {
|
||||
assert_eq!(format_agent_nickname("Plato", 0), "Plato");
|
||||
assert_eq!(format_agent_nickname("Plato", 1), "Plato the 2nd");
|
||||
assert_eq!(format_agent_nickname("Plato", 2), "Plato the 3rd");
|
||||
assert_eq!(format_agent_nickname("Plato", 10), "Plato the 11th");
|
||||
assert_eq!(format_agent_nickname("Plato", 20), "Plato the 21st");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_depth_defaults_to_zero_for_root_sources() {
|
||||
assert_eq!(session_depth(&SessionSource::Cli), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_spawn_depth_increments_and_enforces_limit() {
|
||||
let session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: ThreadId::new(),
|
||||
depth: 1,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
});
|
||||
let child_depth = next_thread_spawn_depth(&session_source);
|
||||
assert_eq!(child_depth, 2);
|
||||
assert!(exceeds_thread_spawn_depth_limit(child_depth, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_thread_spawn_subagents_default_to_depth_zero() {
|
||||
let session_source = SessionSource::SubAgent(SubAgentSource::Review);
|
||||
assert_eq!(session_depth(&session_source), 0);
|
||||
assert_eq!(next_thread_spawn_depth(&session_source), 1);
|
||||
assert!(!exceeds_thread_spawn_depth_limit(1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reservation_drop_releases_slot() {
|
||||
let guards = Arc::new(Guards::default());
|
||||
let reservation = guards.reserve_spawn_slot(Some(1)).expect("reserve slot");
|
||||
drop(reservation);
|
||||
|
||||
let reservation = guards.reserve_spawn_slot(Some(1)).expect("slot released");
|
||||
drop(reservation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_holds_slot_until_release() {
|
||||
let guards = Arc::new(Guards::default());
|
||||
let reservation = guards.reserve_spawn_slot(Some(1)).expect("reserve slot");
|
||||
let thread_id = ThreadId::new();
|
||||
reservation.commit(thread_id);
|
||||
|
||||
let err = match guards.reserve_spawn_slot(Some(1)) {
|
||||
Ok(_) => panic!("limit should be enforced"),
|
||||
Err(err) => err,
|
||||
};
|
||||
let CodexErr::AgentLimitReached { max_threads } = err else {
|
||||
panic!("expected CodexErr::AgentLimitReached");
|
||||
};
|
||||
assert_eq!(max_threads, 1);
|
||||
|
||||
guards.release_spawned_thread(thread_id);
|
||||
let reservation = guards
|
||||
.reserve_spawn_slot(Some(1))
|
||||
.expect("slot released after thread removal");
|
||||
drop(reservation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_ignores_unknown_thread_id() {
|
||||
let guards = Arc::new(Guards::default());
|
||||
let reservation = guards.reserve_spawn_slot(Some(1)).expect("reserve slot");
|
||||
let thread_id = ThreadId::new();
|
||||
reservation.commit(thread_id);
|
||||
|
||||
guards.release_spawned_thread(ThreadId::new());
|
||||
|
||||
let err = match guards.reserve_spawn_slot(Some(1)) {
|
||||
Ok(_) => panic!("limit should still be enforced"),
|
||||
Err(err) => err,
|
||||
};
|
||||
let CodexErr::AgentLimitReached { max_threads } = err else {
|
||||
panic!("expected CodexErr::AgentLimitReached");
|
||||
};
|
||||
assert_eq!(max_threads, 1);
|
||||
|
||||
guards.release_spawned_thread(thread_id);
|
||||
let reservation = guards
|
||||
.reserve_spawn_slot(Some(1))
|
||||
.expect("slot released after real thread removal");
|
||||
drop(reservation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_is_idempotent_for_registered_threads() {
|
||||
let guards = Arc::new(Guards::default());
|
||||
let reservation = guards.reserve_spawn_slot(Some(1)).expect("reserve slot");
|
||||
let first_id = ThreadId::new();
|
||||
reservation.commit(first_id);
|
||||
|
||||
guards.release_spawned_thread(first_id);
|
||||
|
||||
let reservation = guards.reserve_spawn_slot(Some(1)).expect("slot reused");
|
||||
let second_id = ThreadId::new();
|
||||
reservation.commit(second_id);
|
||||
|
||||
guards.release_spawned_thread(first_id);
|
||||
|
||||
let err = match guards.reserve_spawn_slot(Some(1)) {
|
||||
Ok(_) => panic!("limit should still be enforced"),
|
||||
Err(err) => err,
|
||||
};
|
||||
let CodexErr::AgentLimitReached { max_threads } = err else {
|
||||
panic!("expected CodexErr::AgentLimitReached");
|
||||
};
|
||||
assert_eq!(max_threads, 1);
|
||||
|
||||
guards.release_spawned_thread(second_id);
|
||||
let reservation = guards
|
||||
.reserve_spawn_slot(Some(1))
|
||||
.expect("slot released after second thread removal");
|
||||
drop(reservation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_spawn_keeps_nickname_marked_used() {
|
||||
let guards = Arc::new(Guards::default());
|
||||
let mut reservation = guards.reserve_spawn_slot(None).expect("reserve slot");
|
||||
let agent_nickname = reservation
|
||||
.reserve_agent_nickname(&["alpha"])
|
||||
.expect("reserve agent name");
|
||||
assert_eq!(agent_nickname, "alpha");
|
||||
drop(reservation);
|
||||
|
||||
let mut reservation = guards.reserve_spawn_slot(None).expect("reserve slot");
|
||||
let agent_nickname = reservation
|
||||
.reserve_agent_nickname(&["alpha", "beta"])
|
||||
.expect("unused name should still be preferred");
|
||||
assert_eq!(agent_nickname, "beta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_nickname_resets_used_pool_when_exhausted() {
|
||||
let guards = Arc::new(Guards::default());
|
||||
let mut first = guards.reserve_spawn_slot(None).expect("reserve first slot");
|
||||
let first_name = first
|
||||
.reserve_agent_nickname(&["alpha"])
|
||||
.expect("reserve first agent name");
|
||||
let first_id = ThreadId::new();
|
||||
first.commit(first_id);
|
||||
assert_eq!(first_name, "alpha");
|
||||
|
||||
let mut second = guards
|
||||
.reserve_spawn_slot(None)
|
||||
.expect("reserve second slot");
|
||||
let second_name = second
|
||||
.reserve_agent_nickname(&["alpha"])
|
||||
.expect("name should be reused after pool reset");
|
||||
assert_eq!(second_name, "alpha the 2nd");
|
||||
let active_agents = guards
|
||||
.active_agents
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
assert_eq!(active_agents.nickname_reset_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn released_nickname_stays_used_until_pool_reset() {
|
||||
let guards = Arc::new(Guards::default());
|
||||
|
||||
let mut first = guards.reserve_spawn_slot(None).expect("reserve first slot");
|
||||
let first_name = first
|
||||
.reserve_agent_nickname(&["alpha"])
|
||||
.expect("reserve first agent name");
|
||||
let first_id = ThreadId::new();
|
||||
first.commit(first_id);
|
||||
assert_eq!(first_name, "alpha");
|
||||
|
||||
guards.release_spawned_thread(first_id);
|
||||
|
||||
let mut second = guards
|
||||
.reserve_spawn_slot(None)
|
||||
.expect("reserve second slot");
|
||||
let second_name = second
|
||||
.reserve_agent_nickname(&["alpha", "beta"])
|
||||
.expect("released name should still be marked used");
|
||||
assert_eq!(second_name, "beta");
|
||||
let second_id = ThreadId::new();
|
||||
second.commit(second_id);
|
||||
guards.release_spawned_thread(second_id);
|
||||
|
||||
let mut third = guards.reserve_spawn_slot(None).expect("reserve third slot");
|
||||
let third_name = third
|
||||
.reserve_agent_nickname(&["alpha", "beta"])
|
||||
.expect("pool reset should permit a duplicate");
|
||||
let expected_names = HashSet::from(["alpha the 2nd".to_string(), "beta the 2nd".to_string()]);
|
||||
assert!(expected_names.contains(&third_name));
|
||||
let active_agents = guards
|
||||
.active_agents
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
assert_eq!(active_agents.nickname_reset_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_resets_advance_the_ordinal_suffix() {
|
||||
let guards = Arc::new(Guards::default());
|
||||
|
||||
let mut first = guards.reserve_spawn_slot(None).expect("reserve first slot");
|
||||
let first_name = first
|
||||
.reserve_agent_nickname(&["Plato"])
|
||||
.expect("reserve first agent name");
|
||||
let first_id = ThreadId::new();
|
||||
first.commit(first_id);
|
||||
assert_eq!(first_name, "Plato");
|
||||
guards.release_spawned_thread(first_id);
|
||||
|
||||
let mut second = guards
|
||||
.reserve_spawn_slot(None)
|
||||
.expect("reserve second slot");
|
||||
let second_name = second
|
||||
.reserve_agent_nickname(&["Plato"])
|
||||
.expect("reserve second agent name");
|
||||
let second_id = ThreadId::new();
|
||||
second.commit(second_id);
|
||||
assert_eq!(second_name, "Plato the 2nd");
|
||||
guards.release_spawned_thread(second_id);
|
||||
|
||||
let mut third = guards.reserve_spawn_slot(None).expect("reserve third slot");
|
||||
let third_name = third
|
||||
.reserve_agent_nickname(&["Plato"])
|
||||
.expect("reserve third agent name");
|
||||
assert_eq!(third_name, "Plato the 3rd");
|
||||
let active_agents = guards
|
||||
.active_agents
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
assert_eq!(active_agents.nickname_reset_count, 2);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
use codex_protocol::AgentPath;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum InterAgentDelivery {
|
||||
CurrentTurn,
|
||||
NextTurn,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct InterAgentInstruction {
|
||||
author: AgentPath,
|
||||
recipient: AgentPath,
|
||||
other_recipients: Vec<AgentPath>,
|
||||
content: String,
|
||||
}
|
||||
|
||||
impl InterAgentInstruction {
|
||||
pub(crate) fn new(
|
||||
author: AgentPath,
|
||||
recipient: AgentPath,
|
||||
other_recipients: Vec<AgentPath>,
|
||||
content: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
author,
|
||||
recipient,
|
||||
other_recipients,
|
||||
content,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn to_response_item(&self) -> ResponseItem {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: self.as_text(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_message_content(content: &[ContentItem]) -> bool {
|
||||
content.iter().any(|content_item| match content_item {
|
||||
ContentItem::InputText { text } | ContentItem::OutputText { text } => {
|
||||
Self::is_instruction_text(text)
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
fn as_text(&self) -> String {
|
||||
let other_recipients = self
|
||||
.other_recipients
|
||||
.iter()
|
||||
.map(std::string::ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
format!(
|
||||
"author: {}\nrecipient: {}\nother_recipients: [{other_recipients}]\nContent: {}",
|
||||
self.author, self.recipient, self.content
|
||||
)
|
||||
}
|
||||
|
||||
fn is_instruction_text(text: &str) -> bool {
|
||||
text.starts_with("author: ")
|
||||
&& text.contains("\nrecipient: ")
|
||||
&& text.contains("\nother_recipients: [")
|
||||
&& text.contains("]\nContent: ")
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
pub(crate) mod agent_resolver;
|
||||
pub(crate) mod control;
|
||||
mod guards;
|
||||
pub(crate) mod inter_agent_instruction;
|
||||
mod registry;
|
||||
pub(crate) mod role;
|
||||
pub(crate) mod status;
|
||||
|
||||
pub(crate) use codex_protocol::protocol::AgentStatus;
|
||||
pub(crate) use control::AgentControl;
|
||||
pub(crate) use guards::exceeds_thread_spawn_depth_limit;
|
||||
pub(crate) use guards::next_thread_spawn_depth;
|
||||
pub(crate) use registry::exceeds_thread_spawn_depth_limit;
|
||||
pub(crate) use registry::next_thread_spawn_depth;
|
||||
pub(crate) use status::agent_status_from_event;
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
use crate::error::CodexErr;
|
||||
use crate::error::Result;
|
||||
use codex_protocol::AgentPath;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use rand::prelude::IndexedRandom;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::hash_map::Entry;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
@@ -18,19 +20,26 @@ use std::sync::atomic::Ordering;
|
||||
/// This structure is shared by all agents in the same user session (because the `AgentControl`
|
||||
/// is).
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Guards {
|
||||
pub(crate) struct AgentRegistry {
|
||||
active_agents: Mutex<ActiveAgents>,
|
||||
total_count: AtomicUsize,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ActiveAgents {
|
||||
threads_set: HashSet<ThreadId>,
|
||||
thread_agent_nicknames: HashMap<ThreadId, String>,
|
||||
agent_tree: HashMap<String, AgentMetadata>,
|
||||
used_agent_nicknames: HashSet<String>,
|
||||
nickname_reset_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct AgentMetadata {
|
||||
pub(crate) agent_id: Option<ThreadId>,
|
||||
pub(crate) agent_path: Option<AgentPath>,
|
||||
pub(crate) agent_nickname: Option<String>,
|
||||
pub(crate) agent_role: Option<String>,
|
||||
}
|
||||
|
||||
fn format_agent_nickname(name: &str, nickname_reset_count: usize) -> String {
|
||||
match nickname_reset_count {
|
||||
0 => name.to_string(),
|
||||
@@ -66,7 +75,7 @@ pub(crate) fn exceeds_thread_spawn_depth_limit(depth: i32, max_depth: i32) -> bo
|
||||
depth > max_depth
|
||||
}
|
||||
|
||||
impl Guards {
|
||||
impl AgentRegistry {
|
||||
pub(crate) fn reserve_spawn_slot(
|
||||
self: &Arc<Self>,
|
||||
max_threads: Option<usize>,
|
||||
@@ -82,38 +91,83 @@ impl Guards {
|
||||
state: Arc::clone(self),
|
||||
active: true,
|
||||
reserved_agent_nickname: None,
|
||||
reserved_agent_path: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn release_spawned_thread(&self, thread_id: ThreadId) {
|
||||
let removed = {
|
||||
let removed_counted_agent = {
|
||||
let mut active_agents = self
|
||||
.active_agents
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let removed = active_agents.threads_set.remove(&thread_id);
|
||||
active_agents.thread_agent_nicknames.remove(&thread_id);
|
||||
removed
|
||||
let removed_key = active_agents
|
||||
.agent_tree
|
||||
.iter()
|
||||
.find_map(|(key, metadata)| (metadata.agent_id == Some(thread_id)).then_some(key))
|
||||
.cloned();
|
||||
removed_key
|
||||
.and_then(|key| active_agents.agent_tree.remove(key.as_str()))
|
||||
.is_some_and(|metadata| {
|
||||
!metadata.agent_path.as_ref().is_some_and(AgentPath::is_root)
|
||||
})
|
||||
};
|
||||
if removed {
|
||||
if removed_counted_agent {
|
||||
self.total_count.fetch_sub(1, Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
|
||||
fn register_spawned_thread(&self, thread_id: ThreadId, agent_nickname: Option<String>) {
|
||||
pub(crate) fn register_root_thread(&self, thread_id: ThreadId) {
|
||||
let mut active_agents = self
|
||||
.active_agents
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
active_agents.threads_set.insert(thread_id);
|
||||
if let Some(agent_nickname) = agent_nickname {
|
||||
active_agents
|
||||
.used_agent_nicknames
|
||||
.insert(agent_nickname.clone());
|
||||
active_agents
|
||||
.thread_agent_nicknames
|
||||
.insert(thread_id, agent_nickname);
|
||||
active_agents
|
||||
.agent_tree
|
||||
.entry(AgentPath::ROOT.to_string())
|
||||
.or_insert_with(|| AgentMetadata {
|
||||
agent_id: Some(thread_id),
|
||||
agent_path: Some(AgentPath::root()),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn agent_id_for_path(&self, agent_path: &AgentPath) -> Option<ThreadId> {
|
||||
self.active_agents
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.agent_tree
|
||||
.get(agent_path.as_str())
|
||||
.and_then(|metadata| metadata.agent_id)
|
||||
}
|
||||
|
||||
pub(crate) fn agent_metadata_for_thread(&self, thread_id: ThreadId) -> Option<AgentMetadata> {
|
||||
self.active_agents
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.agent_tree
|
||||
.values()
|
||||
.find(|metadata| metadata.agent_id == Some(thread_id))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn register_spawned_thread(&self, agent_metadata: AgentMetadata) {
|
||||
let Some(thread_id) = agent_metadata.agent_id else {
|
||||
return;
|
||||
};
|
||||
let mut active_agents = self
|
||||
.active_agents
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let key = agent_metadata
|
||||
.agent_path
|
||||
.as_ref()
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| format!("thread:{thread_id}"));
|
||||
if let Some(agent_nickname) = agent_metadata.agent_nickname.clone() {
|
||||
active_agents.used_agent_nicknames.insert(agent_nickname);
|
||||
}
|
||||
active_agents.agent_tree.insert(key, agent_metadata);
|
||||
}
|
||||
|
||||
fn reserve_agent_nickname(&self, names: &[&str], preferred: Option<&str>) -> Option<String> {
|
||||
@@ -156,6 +210,39 @@ impl Guards {
|
||||
Some(agent_nickname)
|
||||
}
|
||||
|
||||
fn reserve_agent_path(&self, agent_path: &AgentPath) -> Result<()> {
|
||||
let mut active_agents = self
|
||||
.active_agents
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
match active_agents.agent_tree.entry(agent_path.to_string()) {
|
||||
Entry::Occupied(_) => Err(CodexErr::UnsupportedOperation(format!(
|
||||
"agent path `{agent_path}` already exists"
|
||||
))),
|
||||
Entry::Vacant(entry) => {
|
||||
entry.insert(AgentMetadata {
|
||||
agent_path: Some(agent_path.clone()),
|
||||
..Default::default()
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn release_reserved_agent_path(&self, agent_path: &AgentPath) {
|
||||
let mut active_agents = self
|
||||
.active_agents
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if active_agents
|
||||
.agent_tree
|
||||
.get(agent_path.as_str())
|
||||
.is_some_and(|metadata| metadata.agent_id.is_none())
|
||||
{
|
||||
active_agents.agent_tree.remove(agent_path.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
fn try_increment_spawned(&self, max_threads: usize) -> bool {
|
||||
let mut current = self.total_count.load(Ordering::Acquire);
|
||||
loop {
|
||||
@@ -176,16 +263,13 @@ impl Guards {
|
||||
}
|
||||
|
||||
pub(crate) struct SpawnReservation {
|
||||
state: Arc<Guards>,
|
||||
state: Arc<AgentRegistry>,
|
||||
active: bool,
|
||||
reserved_agent_nickname: Option<String>,
|
||||
reserved_agent_path: Option<AgentPath>,
|
||||
}
|
||||
|
||||
impl SpawnReservation {
|
||||
pub(crate) fn reserve_agent_nickname(&mut self, names: &[&str]) -> Result<String> {
|
||||
self.reserve_agent_nickname_with_preference(names, /*preferred*/ None)
|
||||
}
|
||||
|
||||
pub(crate) fn reserve_agent_nickname_with_preference(
|
||||
&mut self,
|
||||
names: &[&str],
|
||||
@@ -201,18 +285,16 @@ impl SpawnReservation {
|
||||
Ok(agent_nickname)
|
||||
}
|
||||
|
||||
pub(crate) fn commit(self, thread_id: ThreadId) {
|
||||
self.commit_with_agent_nickname(thread_id, /*agent_nickname*/ None);
|
||||
pub(crate) fn reserve_agent_path(&mut self, agent_path: &AgentPath) -> Result<()> {
|
||||
self.state.reserve_agent_path(agent_path)?;
|
||||
self.reserved_agent_path = Some(agent_path.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn commit_with_agent_nickname(
|
||||
mut self,
|
||||
thread_id: ThreadId,
|
||||
agent_nickname: Option<String>,
|
||||
) {
|
||||
let agent_nickname = self.reserved_agent_nickname.take().or(agent_nickname);
|
||||
self.state
|
||||
.register_spawned_thread(thread_id, agent_nickname);
|
||||
pub(crate) fn commit(mut self, agent_metadata: AgentMetadata) {
|
||||
self.reserved_agent_nickname = None;
|
||||
self.reserved_agent_path = None;
|
||||
self.state.register_spawned_thread(agent_metadata);
|
||||
self.active = false;
|
||||
}
|
||||
}
|
||||
@@ -220,11 +302,14 @@ impl SpawnReservation {
|
||||
impl Drop for SpawnReservation {
|
||||
fn drop(&mut self) {
|
||||
if self.active {
|
||||
if let Some(agent_path) = self.reserved_agent_path.take() {
|
||||
self.state.release_reserved_agent_path(&agent_path);
|
||||
}
|
||||
self.state.total_count.fetch_sub(1, Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "guards_tests.rs"]
|
||||
#[path = "registry_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,324 @@
|
||||
use super::*;
|
||||
use codex_protocol::AgentPath;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashSet;
|
||||
|
||||
fn agent_path(path: &str) -> AgentPath {
|
||||
AgentPath::try_from(path).expect("valid agent path")
|
||||
}
|
||||
|
||||
fn agent_metadata(thread_id: ThreadId) -> AgentMetadata {
|
||||
AgentMetadata {
|
||||
agent_id: Some(thread_id),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_agent_nickname_adds_ordinals_after_reset() {
|
||||
assert_eq!(format_agent_nickname("Plato", 0), "Plato");
|
||||
assert_eq!(format_agent_nickname("Plato", 1), "Plato the 2nd");
|
||||
assert_eq!(format_agent_nickname("Plato", 2), "Plato the 3rd");
|
||||
assert_eq!(format_agent_nickname("Plato", 10), "Plato the 11th");
|
||||
assert_eq!(format_agent_nickname("Plato", 20), "Plato the 21st");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_depth_defaults_to_zero_for_root_sources() {
|
||||
assert_eq!(session_depth(&SessionSource::Cli), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_spawn_depth_increments_and_enforces_limit() {
|
||||
let session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: ThreadId::new(),
|
||||
depth: 1,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
});
|
||||
let child_depth = next_thread_spawn_depth(&session_source);
|
||||
assert_eq!(child_depth, 2);
|
||||
assert!(exceeds_thread_spawn_depth_limit(child_depth, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_thread_spawn_subagents_default_to_depth_zero() {
|
||||
let session_source = SessionSource::SubAgent(SubAgentSource::Review);
|
||||
assert_eq!(session_depth(&session_source), 0);
|
||||
assert_eq!(next_thread_spawn_depth(&session_source), 1);
|
||||
assert!(!exceeds_thread_spawn_depth_limit(1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reservation_drop_releases_slot() {
|
||||
let registry = Arc::new(AgentRegistry::default());
|
||||
let reservation = registry.reserve_spawn_slot(Some(1)).expect("reserve slot");
|
||||
drop(reservation);
|
||||
|
||||
let reservation = registry.reserve_spawn_slot(Some(1)).expect("slot released");
|
||||
drop(reservation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_holds_slot_until_release() {
|
||||
let registry = Arc::new(AgentRegistry::default());
|
||||
let reservation = registry.reserve_spawn_slot(Some(1)).expect("reserve slot");
|
||||
let thread_id = ThreadId::new();
|
||||
reservation.commit(agent_metadata(thread_id));
|
||||
|
||||
let err = match registry.reserve_spawn_slot(Some(1)) {
|
||||
Ok(_) => panic!("limit should be enforced"),
|
||||
Err(err) => err,
|
||||
};
|
||||
let CodexErr::AgentLimitReached { max_threads } = err else {
|
||||
panic!("expected CodexErr::AgentLimitReached");
|
||||
};
|
||||
assert_eq!(max_threads, 1);
|
||||
|
||||
registry.release_spawned_thread(thread_id);
|
||||
let reservation = registry
|
||||
.reserve_spawn_slot(Some(1))
|
||||
.expect("slot released after thread removal");
|
||||
drop(reservation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_ignores_unknown_thread_id() {
|
||||
let registry = Arc::new(AgentRegistry::default());
|
||||
let reservation = registry.reserve_spawn_slot(Some(1)).expect("reserve slot");
|
||||
let thread_id = ThreadId::new();
|
||||
reservation.commit(agent_metadata(thread_id));
|
||||
|
||||
registry.release_spawned_thread(ThreadId::new());
|
||||
|
||||
let err = match registry.reserve_spawn_slot(Some(1)) {
|
||||
Ok(_) => panic!("limit should still be enforced"),
|
||||
Err(err) => err,
|
||||
};
|
||||
let CodexErr::AgentLimitReached { max_threads } = err else {
|
||||
panic!("expected CodexErr::AgentLimitReached");
|
||||
};
|
||||
assert_eq!(max_threads, 1);
|
||||
|
||||
registry.release_spawned_thread(thread_id);
|
||||
let reservation = registry
|
||||
.reserve_spawn_slot(Some(1))
|
||||
.expect("slot released after real thread removal");
|
||||
drop(reservation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_is_idempotent_for_registered_threads() {
|
||||
let registry = Arc::new(AgentRegistry::default());
|
||||
let reservation = registry.reserve_spawn_slot(Some(1)).expect("reserve slot");
|
||||
let first_id = ThreadId::new();
|
||||
reservation.commit(agent_metadata(first_id));
|
||||
|
||||
registry.release_spawned_thread(first_id);
|
||||
|
||||
let reservation = registry.reserve_spawn_slot(Some(1)).expect("slot reused");
|
||||
let second_id = ThreadId::new();
|
||||
reservation.commit(agent_metadata(second_id));
|
||||
|
||||
registry.release_spawned_thread(first_id);
|
||||
|
||||
let err = match registry.reserve_spawn_slot(Some(1)) {
|
||||
Ok(_) => panic!("limit should still be enforced"),
|
||||
Err(err) => err,
|
||||
};
|
||||
let CodexErr::AgentLimitReached { max_threads } = err else {
|
||||
panic!("expected CodexErr::AgentLimitReached");
|
||||
};
|
||||
assert_eq!(max_threads, 1);
|
||||
|
||||
registry.release_spawned_thread(second_id);
|
||||
let reservation = registry
|
||||
.reserve_spawn_slot(Some(1))
|
||||
.expect("slot released after second thread removal");
|
||||
drop(reservation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_spawn_keeps_nickname_marked_used() {
|
||||
let registry = Arc::new(AgentRegistry::default());
|
||||
let mut reservation = registry.reserve_spawn_slot(None).expect("reserve slot");
|
||||
let agent_nickname = reservation
|
||||
.reserve_agent_nickname_with_preference(&["alpha"], /*preferred*/ None)
|
||||
.expect("reserve agent name");
|
||||
assert_eq!(agent_nickname, "alpha");
|
||||
drop(reservation);
|
||||
|
||||
let mut reservation = registry.reserve_spawn_slot(None).expect("reserve slot");
|
||||
let agent_nickname = reservation
|
||||
.reserve_agent_nickname_with_preference(&["alpha", "beta"], /*preferred*/ None)
|
||||
.expect("unused name should still be preferred");
|
||||
assert_eq!(agent_nickname, "beta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_nickname_resets_used_pool_when_exhausted() {
|
||||
let registry = Arc::new(AgentRegistry::default());
|
||||
let mut first = registry
|
||||
.reserve_spawn_slot(None)
|
||||
.expect("reserve first slot");
|
||||
let first_name = first
|
||||
.reserve_agent_nickname_with_preference(&["alpha"], /*preferred*/ None)
|
||||
.expect("reserve first agent name");
|
||||
let first_id = ThreadId::new();
|
||||
first.commit(agent_metadata(first_id));
|
||||
assert_eq!(first_name, "alpha");
|
||||
|
||||
let mut second = registry
|
||||
.reserve_spawn_slot(None)
|
||||
.expect("reserve second slot");
|
||||
let second_name = second
|
||||
.reserve_agent_nickname_with_preference(&["alpha"], /*preferred*/ None)
|
||||
.expect("name should be reused after pool reset");
|
||||
assert_eq!(second_name, "alpha the 2nd");
|
||||
let active_agents = registry
|
||||
.active_agents
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
assert_eq!(active_agents.nickname_reset_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn released_nickname_stays_used_until_pool_reset() {
|
||||
let registry = Arc::new(AgentRegistry::default());
|
||||
|
||||
let mut first = registry
|
||||
.reserve_spawn_slot(None)
|
||||
.expect("reserve first slot");
|
||||
let first_name = first
|
||||
.reserve_agent_nickname_with_preference(&["alpha"], /*preferred*/ None)
|
||||
.expect("reserve first agent name");
|
||||
let first_id = ThreadId::new();
|
||||
first.commit(agent_metadata(first_id));
|
||||
assert_eq!(first_name, "alpha");
|
||||
|
||||
registry.release_spawned_thread(first_id);
|
||||
|
||||
let mut second = registry
|
||||
.reserve_spawn_slot(None)
|
||||
.expect("reserve second slot");
|
||||
let second_name = second
|
||||
.reserve_agent_nickname_with_preference(&["alpha", "beta"], /*preferred*/ None)
|
||||
.expect("released name should still be marked used");
|
||||
assert_eq!(second_name, "beta");
|
||||
let second_id = ThreadId::new();
|
||||
second.commit(agent_metadata(second_id));
|
||||
registry.release_spawned_thread(second_id);
|
||||
|
||||
let mut third = registry
|
||||
.reserve_spawn_slot(None)
|
||||
.expect("reserve third slot");
|
||||
let third_name = third
|
||||
.reserve_agent_nickname_with_preference(&["alpha", "beta"], /*preferred*/ None)
|
||||
.expect("pool reset should permit a duplicate");
|
||||
let expected_names = HashSet::from(["alpha the 2nd".to_string(), "beta the 2nd".to_string()]);
|
||||
assert!(expected_names.contains(&third_name));
|
||||
let active_agents = registry
|
||||
.active_agents
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
assert_eq!(active_agents.nickname_reset_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_resets_advance_the_ordinal_suffix() {
|
||||
let registry = Arc::new(AgentRegistry::default());
|
||||
|
||||
let mut first = registry
|
||||
.reserve_spawn_slot(None)
|
||||
.expect("reserve first slot");
|
||||
let first_name = first
|
||||
.reserve_agent_nickname_with_preference(&["Plato"], /*preferred*/ None)
|
||||
.expect("reserve first agent name");
|
||||
let first_id = ThreadId::new();
|
||||
first.commit(agent_metadata(first_id));
|
||||
assert_eq!(first_name, "Plato");
|
||||
registry.release_spawned_thread(first_id);
|
||||
|
||||
let mut second = registry
|
||||
.reserve_spawn_slot(None)
|
||||
.expect("reserve second slot");
|
||||
let second_name = second
|
||||
.reserve_agent_nickname_with_preference(&["Plato"], /*preferred*/ None)
|
||||
.expect("reserve second agent name");
|
||||
let second_id = ThreadId::new();
|
||||
second.commit(agent_metadata(second_id));
|
||||
assert_eq!(second_name, "Plato the 2nd");
|
||||
registry.release_spawned_thread(second_id);
|
||||
|
||||
let mut third = registry
|
||||
.reserve_spawn_slot(None)
|
||||
.expect("reserve third slot");
|
||||
let third_name = third
|
||||
.reserve_agent_nickname_with_preference(&["Plato"], /*preferred*/ None)
|
||||
.expect("reserve third agent name");
|
||||
assert_eq!(third_name, "Plato the 3rd");
|
||||
let active_agents = registry
|
||||
.active_agents
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
assert_eq!(active_agents.nickname_reset_count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_root_thread_indexes_root_path() {
|
||||
let registry = Arc::new(AgentRegistry::default());
|
||||
let root_thread_id = ThreadId::new();
|
||||
|
||||
registry.register_root_thread(root_thread_id);
|
||||
|
||||
assert_eq!(
|
||||
registry.agent_id_for_path(&AgentPath::root()),
|
||||
Some(root_thread_id)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_agent_path_is_released_when_spawn_fails() {
|
||||
let registry = Arc::new(AgentRegistry::default());
|
||||
let mut first = registry
|
||||
.reserve_spawn_slot(None)
|
||||
.expect("reserve first slot");
|
||||
first
|
||||
.reserve_agent_path(&agent_path("/root/researcher"))
|
||||
.expect("reserve first path");
|
||||
drop(first);
|
||||
|
||||
let mut second = registry
|
||||
.reserve_spawn_slot(None)
|
||||
.expect("reserve second slot");
|
||||
second
|
||||
.reserve_agent_path(&agent_path("/root/researcher"))
|
||||
.expect("dropped reservation should free the path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committed_agent_path_is_indexed_until_release() {
|
||||
let registry = Arc::new(AgentRegistry::default());
|
||||
let thread_id = ThreadId::new();
|
||||
let mut reservation = registry.reserve_spawn_slot(None).expect("reserve slot");
|
||||
reservation
|
||||
.reserve_agent_path(&agent_path("/root/researcher"))
|
||||
.expect("reserve path");
|
||||
reservation.commit(AgentMetadata {
|
||||
agent_id: Some(thread_id),
|
||||
agent_path: Some(agent_path("/root/researcher")),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
registry.agent_id_for_path(&agent_path("/root/researcher")),
|
||||
Some(thread_id)
|
||||
);
|
||||
|
||||
registry.release_spawned_thread(thread_id);
|
||||
assert_eq!(
|
||||
registry.agent_id_for_path(&agent_path("/root/researcher")),
|
||||
None
|
||||
);
|
||||
}
|
||||
@@ -99,6 +99,7 @@ pub(crate) async fn monitor_action(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
action: serde_json::Value,
|
||||
protection_client_callsite: &'static str,
|
||||
) -> ArcMonitorOutcome {
|
||||
let auth = match turn_context.auth_manager.as_ref() {
|
||||
Some(auth_manager) => match auth_manager.auth().await {
|
||||
@@ -138,7 +139,8 @@ pub(crate) async fn monitor_action(
|
||||
return ArcMonitorOutcome::Ok;
|
||||
}
|
||||
};
|
||||
let body = build_arc_monitor_request(sess, turn_context, action).await;
|
||||
let body =
|
||||
build_arc_monitor_request(sess, turn_context, action, protection_client_callsite).await;
|
||||
let client = build_reqwest_client();
|
||||
let mut request = client
|
||||
.post(&url)
|
||||
@@ -236,6 +238,7 @@ async fn build_arc_monitor_request(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
action: serde_json::Map<String, serde_json::Value>,
|
||||
protection_client_callsite: &'static str,
|
||||
) -> ArcMonitorRequest {
|
||||
let history = sess.clone_history().await;
|
||||
let mut messages = build_arc_monitor_messages(history.raw_items());
|
||||
@@ -254,7 +257,7 @@ async fn build_arc_monitor_request(
|
||||
codex_thread_id: conversation_id.clone(),
|
||||
codex_turn_id: turn_context.sub_id.clone(),
|
||||
conversation_id: Some(conversation_id),
|
||||
protection_client_callsite: None,
|
||||
protection_client_callsite: Some(protection_client_callsite.to_string()),
|
||||
},
|
||||
messages: Some(messages),
|
||||
input: None,
|
||||
|
||||
@@ -182,6 +182,7 @@ async fn build_arc_monitor_request_includes_relevant_history_and_null_policies()
|
||||
&turn_context,
|
||||
serde_json::from_value(serde_json::json!({ "tool": "mcp_tool_call" }))
|
||||
.expect("action should deserialize"),
|
||||
"normal",
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -192,7 +193,7 @@ async fn build_arc_monitor_request_includes_relevant_history_and_null_policies()
|
||||
codex_thread_id: session.conversation_id.to_string(),
|
||||
codex_turn_id: turn_context.sub_id.clone(),
|
||||
conversation_id: Some(session.conversation_id.to_string()),
|
||||
protection_client_callsite: None,
|
||||
protection_client_callsite: Some("normal".to_string()),
|
||||
},
|
||||
messages: Some(vec![
|
||||
ArcMonitorChatMessage {
|
||||
@@ -290,6 +291,7 @@ async fn monitor_action_posts_expected_arc_request() {
|
||||
"codex_thread_id": session.conversation_id.to_string(),
|
||||
"codex_turn_id": turn_context.sub_id.clone(),
|
||||
"conversation_id": session.conversation_id.to_string(),
|
||||
"protection_client_callsite": "normal",
|
||||
},
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
@@ -325,6 +327,7 @@ async fn monitor_action_posts_expected_arc_request() {
|
||||
&session,
|
||||
&turn_context,
|
||||
serde_json::json!({ "tool": "mcp_tool_call" }),
|
||||
"normal",
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -383,6 +386,7 @@ async fn monitor_action_uses_env_url_and_token_overrides() {
|
||||
&session,
|
||||
&turn_context,
|
||||
serde_json::json!({ "tool": "mcp_tool_call" }),
|
||||
"normal",
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -435,6 +439,7 @@ async fn monitor_action_rejects_legacy_response_fields() {
|
||||
&session,
|
||||
&turn_context,
|
||||
serde_json::json!({ "tool": "mcp_tool_call" }),
|
||||
"normal",
|
||||
)
|
||||
.await;
|
||||
|
||||
|
||||
+131
-57
@@ -189,9 +189,41 @@ mod rollout_reconstruction_tests;
|
||||
pub enum SteerInputError {
|
||||
NoActiveTurn(Vec<UserInput>),
|
||||
ExpectedTurnMismatch { expected: String, actual: String },
|
||||
ActiveTurnNotSteerable { turn_kind: NonSteerableTurnKind },
|
||||
EmptyInput,
|
||||
}
|
||||
|
||||
impl SteerInputError {
|
||||
fn to_error_event(&self) -> ErrorEvent {
|
||||
match self {
|
||||
Self::NoActiveTurn(_) => ErrorEvent {
|
||||
message: "no active turn to steer".to_string(),
|
||||
codex_error_info: Some(CodexErrorInfo::BadRequest),
|
||||
},
|
||||
Self::ExpectedTurnMismatch { expected, actual } => ErrorEvent {
|
||||
message: format!("expected active turn id `{expected}` but found `{actual}`"),
|
||||
codex_error_info: Some(CodexErrorInfo::BadRequest),
|
||||
},
|
||||
Self::ActiveTurnNotSteerable { turn_kind } => {
|
||||
let turn_kind_label = match turn_kind {
|
||||
NonSteerableTurnKind::Review => "review",
|
||||
NonSteerableTurnKind::Compact => "compact",
|
||||
};
|
||||
ErrorEvent {
|
||||
message: format!("cannot steer a {turn_kind_label} turn"),
|
||||
codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable {
|
||||
turn_kind: *turn_kind,
|
||||
}),
|
||||
}
|
||||
}
|
||||
Self::EmptyInput => ErrorEvent {
|
||||
message: "input must not be empty".to_string(),
|
||||
codex_error_info: Some(CodexErrorInfo::BadRequest),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Notes from the previous real user turn.
|
||||
///
|
||||
/// Conceptually this is the same role that `previous_model` used to fill, but
|
||||
@@ -336,6 +368,7 @@ use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
||||
use codex_protocol::protocol::CodexErrorInfo;
|
||||
use codex_protocol::protocol::InitialHistory;
|
||||
use codex_protocol::protocol::NonSteerableTurnKind;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_readiness::Readiness;
|
||||
@@ -768,6 +801,7 @@ pub(crate) struct Session {
|
||||
pending_mcp_server_refresh_config: Mutex<Option<McpServerRefreshConfig>>,
|
||||
pub(crate) conversation: Arc<RealtimeConversationManager>,
|
||||
pub(crate) active_turn: Mutex<Option<ActiveTurn>>,
|
||||
idle_pending_input: Mutex<Vec<ResponseInputItem>>,
|
||||
pub(crate) guardian_review_session: GuardianReviewSessionManager,
|
||||
pub(crate) services: SessionServices,
|
||||
js_repl: Arc<JsReplHandle>,
|
||||
@@ -779,6 +813,7 @@ pub(crate) struct TurnSkillsContext {
|
||||
pub(crate) outcome: Arc<SkillLoadOutcome>,
|
||||
pub(crate) implicit_invocation_seen_skills: Arc<Mutex<HashSet<String>>>,
|
||||
}
|
||||
|
||||
impl TurnSkillsContext {
|
||||
pub(crate) fn new(outcome: Arc<SkillLoadOutcome>) -> Self {
|
||||
Self {
|
||||
@@ -1865,6 +1900,7 @@ impl Session {
|
||||
pending_mcp_server_refresh_config: Mutex::new(None),
|
||||
conversation: Arc::new(RealtimeConversationManager::new()),
|
||||
active_turn: Mutex::new(None),
|
||||
idle_pending_input: Mutex::new(Vec::new()),
|
||||
guardian_review_session: GuardianReviewSessionManager::default(),
|
||||
services,
|
||||
js_repl,
|
||||
@@ -3488,6 +3524,7 @@ impl Session {
|
||||
DeveloperInstructions::from_policy(
|
||||
turn_context.sandbox_policy.get(),
|
||||
turn_context.approval_policy.value(),
|
||||
turn_context.config.approvals_reviewer,
|
||||
self.services.exec_policy.current().as_ref(),
|
||||
&turn_context.cwd,
|
||||
turn_context
|
||||
@@ -3907,6 +3944,21 @@ impl Session {
|
||||
});
|
||||
}
|
||||
|
||||
match active_turn.tasks.first().map(|(_, task)| task.kind) {
|
||||
Some(crate::state::TaskKind::Regular) => {}
|
||||
Some(crate::state::TaskKind::Review) => {
|
||||
return Err(SteerInputError::ActiveTurnNotSteerable {
|
||||
turn_kind: NonSteerableTurnKind::Review,
|
||||
});
|
||||
}
|
||||
Some(crate::state::TaskKind::Compact) => {
|
||||
return Err(SteerInputError::ActiveTurnNotSteerable {
|
||||
turn_kind: NonSteerableTurnKind::Compact,
|
||||
});
|
||||
}
|
||||
None => return Err(SteerInputError::NoActiveTurn(input)),
|
||||
}
|
||||
|
||||
let mut input_item: ResponseInputItem = input.into();
|
||||
if self.enabled(Feature::ItemMetadata) {
|
||||
stamp_user_message_type_on_input_item(&mut input_item, UserMessageType::PromptSteering);
|
||||
@@ -3917,7 +3969,7 @@ impl Session {
|
||||
Ok(active_turn_id.clone())
|
||||
}
|
||||
|
||||
/// Returns the input if there was no task running to inject into
|
||||
/// Returns the input if there was no task running to inject into.
|
||||
pub async fn inject_response_items(
|
||||
&self,
|
||||
input: Vec<ResponseInputItem>,
|
||||
@@ -3966,6 +4018,24 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue response items to be injected into the next active turn created for this session.
|
||||
pub(crate) async fn queue_response_items_for_next_turn(&self, items: Vec<ResponseInputItem>) {
|
||||
if items.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut idle_pending_input = self.idle_pending_input.lock().await;
|
||||
idle_pending_input.extend(items);
|
||||
}
|
||||
|
||||
pub(crate) async fn take_queued_response_items_for_next_turn(&self) -> Vec<ResponseInputItem> {
|
||||
std::mem::take(&mut *self.idle_pending_input.lock().await)
|
||||
}
|
||||
|
||||
pub(crate) async fn has_queued_response_items_for_next_turn(&self) -> bool {
|
||||
!self.idle_pending_input.lock().await.is_empty()
|
||||
}
|
||||
|
||||
pub async fn has_pending_input(&self) -> bool {
|
||||
let active = self.active_turn.lock().await;
|
||||
match active.as_ref() {
|
||||
@@ -4587,26 +4657,35 @@ mod handlers {
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
let Ok(current_context) = sess.new_turn_with_sub_id(sub_id, updates).await else {
|
||||
let Ok(current_context) = sess.new_turn_with_sub_id(sub_id.clone(), updates).await else {
|
||||
// new_turn_with_sub_id already emits the error event.
|
||||
return;
|
||||
};
|
||||
sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref())
|
||||
.await;
|
||||
current_context.session_telemetry.user_prompt(&items);
|
||||
|
||||
// Attempt to inject input into current task.
|
||||
if let Err(SteerInputError::NoActiveTurn(items)) =
|
||||
sess.steer_input(items, /*expected_turn_id*/ None).await
|
||||
match sess
|
||||
.steer_input(items.clone(), /*expected_turn_id*/ None)
|
||||
.await
|
||||
{
|
||||
sess.refresh_mcp_servers_if_requested(¤t_context)
|
||||
Ok(_) => current_context.session_telemetry.user_prompt(&items),
|
||||
Err(SteerInputError::NoActiveTurn(items)) => {
|
||||
current_context.session_telemetry.user_prompt(&items);
|
||||
sess.refresh_mcp_servers_if_requested(¤t_context)
|
||||
.await;
|
||||
sess.spawn_task(
|
||||
Arc::clone(¤t_context),
|
||||
items,
|
||||
crate::tasks::RegularTask::new(),
|
||||
)
|
||||
.await;
|
||||
sess.spawn_task(
|
||||
Arc::clone(¤t_context),
|
||||
items,
|
||||
crate::tasks::RegularTask::new(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(err) => {
|
||||
sess.send_event_raw(Event {
|
||||
id: sub_id,
|
||||
msg: EventMsg::Error(err.to_error_event()),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5445,7 +5524,7 @@ pub(crate) async fn run_turn(
|
||||
prewarmed_client_session: Option<ModelClientSession>,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> Option<String> {
|
||||
if input.is_empty() {
|
||||
if input.is_empty() && !sess.has_pending_input().await {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -5572,20 +5651,6 @@ pub(crate) async fn run_turn(
|
||||
&available_connectors,
|
||||
&skill_name_counts_lower,
|
||||
));
|
||||
// Explicit plugin mentions can make a plugin's enabled apps callable for
|
||||
// this turn without persisting those connectors as sticky user selections.
|
||||
let mut turn_enabled_connectors = explicitly_enabled_connectors.clone();
|
||||
turn_enabled_connectors.extend(
|
||||
mentioned_plugins
|
||||
.iter()
|
||||
.flat_map(|plugin| plugin.app_connector_ids.iter())
|
||||
.map(|connector_id| connector_id.0.clone())
|
||||
.filter(|connector_id| {
|
||||
available_connectors
|
||||
.iter()
|
||||
.any(|connector| connector.is_enabled && connector.id == *connector_id)
|
||||
}),
|
||||
);
|
||||
let connector_names_by_id = available_connectors
|
||||
.iter()
|
||||
.map(|connector| (connector.id.as_str(), connector.name.as_str()))
|
||||
@@ -5601,23 +5666,36 @@ pub(crate) async fn run_turn(
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut last_agent_message: Option<String> = None;
|
||||
if run_pending_session_start_hooks(&sess, &turn_context).await {
|
||||
return last_agent_message;
|
||||
return None;
|
||||
}
|
||||
let user_prompt_submit_outcome =
|
||||
run_user_prompt_submit_hooks(&sess, &turn_context, UserMessageItem::new(&input).message())
|
||||
.await;
|
||||
if user_prompt_submit_outcome.should_stop {
|
||||
record_additional_contexts(
|
||||
let additional_contexts = if input.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
let user_prompt_submit_outcome = run_user_prompt_submit_hooks(
|
||||
&sess,
|
||||
&turn_context,
|
||||
user_prompt_submit_outcome.additional_contexts,
|
||||
UserMessageItem::new(&input).message(),
|
||||
)
|
||||
.await;
|
||||
return last_agent_message;
|
||||
}
|
||||
let additional_contexts = user_prompt_submit_outcome.additional_contexts;
|
||||
if user_prompt_submit_outcome.should_stop {
|
||||
record_additional_contexts(
|
||||
&sess,
|
||||
&turn_context,
|
||||
user_prompt_submit_outcome.additional_contexts,
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
}
|
||||
let mut initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input.clone());
|
||||
if sess.enabled(Feature::ItemMetadata) {
|
||||
stamp_user_message_type_on_input_item(&mut initial_input_for_turn, UserMessageType::Prompt);
|
||||
}
|
||||
let response_item: ResponseItem = initial_input_for_turn.clone().into();
|
||||
sess.record_user_prompt_and_emit_turn_item(turn_context.as_ref(), &input, response_item)
|
||||
.await;
|
||||
user_prompt_submit_outcome.additional_contexts
|
||||
};
|
||||
sess.services
|
||||
.analytics_events_client
|
||||
.track_app_mentioned(tracking.clone(), mentioned_app_invocations);
|
||||
@@ -5628,23 +5706,17 @@ pub(crate) async fn run_turn(
|
||||
}
|
||||
sess.merge_connector_selection(explicitly_enabled_connectors.clone())
|
||||
.await;
|
||||
|
||||
let mut initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input.clone());
|
||||
if sess.enabled(Feature::ItemMetadata) {
|
||||
stamp_user_message_type_on_input_item(&mut initial_input_for_turn, UserMessageType::Prompt);
|
||||
}
|
||||
let response_item: ResponseItem = initial_input_for_turn.clone().into();
|
||||
sess.record_user_prompt_and_emit_turn_item(turn_context.as_ref(), &input, response_item)
|
||||
.await;
|
||||
record_additional_contexts(&sess, &turn_context, additional_contexts).await;
|
||||
// Track the previous-turn baseline from the regular user-turn path only so
|
||||
// standalone tasks (compact/shell/review/undo) cannot suppress future
|
||||
// model/realtime injections.
|
||||
sess.set_previous_turn_settings(Some(PreviousTurnSettings {
|
||||
model: turn_context.model_info.slug.clone(),
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
}))
|
||||
.await;
|
||||
if !input.is_empty() {
|
||||
// Track the previous-turn baseline from the regular user-turn path only so
|
||||
// standalone tasks (compact/shell/review/undo) cannot suppress future
|
||||
// model/realtime injections.
|
||||
sess.set_previous_turn_settings(Some(PreviousTurnSettings {
|
||||
model: turn_context.model_info.slug.clone(),
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
}))
|
||||
.await;
|
||||
}
|
||||
|
||||
if !skill_items.is_empty() {
|
||||
sess.record_conversation_items(&turn_context, &skill_items)
|
||||
@@ -5655,8 +5727,10 @@ pub(crate) async fn run_turn(
|
||||
.await;
|
||||
}
|
||||
|
||||
let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref());
|
||||
sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token())
|
||||
.await;
|
||||
let mut last_agent_message: Option<String> = None;
|
||||
let mut stop_hook_active = false;
|
||||
// Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Task which contains
|
||||
// many turns, from the perspective of the user, it is a single turn.
|
||||
@@ -5741,7 +5815,7 @@ pub(crate) async fn run_turn(
|
||||
&mut client_session,
|
||||
turn_metadata_header.as_deref(),
|
||||
sampling_request_input,
|
||||
&turn_enabled_connectors,
|
||||
&explicitly_enabled_connectors,
|
||||
skills_outcome,
|
||||
&mut server_model_warning_emitted_for_turn,
|
||||
cancellation_token.child_token(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use crate::context_manager::is_user_turn_boundary;
|
||||
|
||||
// Return value of `Session::reconstruct_history_from_rollout`, bundling the rebuilt history with
|
||||
// the resume/fork hydration metadata derived from the same replay.
|
||||
@@ -201,9 +202,12 @@ impl Session {
|
||||
);
|
||||
}
|
||||
}
|
||||
RolloutItem::ResponseItem(_)
|
||||
| RolloutItem::EventMsg(_)
|
||||
| RolloutItem::SessionMeta(_) => {}
|
||||
RolloutItem::ResponseItem(response_item) => {
|
||||
let active_segment =
|
||||
active_segment.get_or_insert_with(ActiveReplaySegment::default);
|
||||
active_segment.counts_as_user_turn |= is_user_turn_boundary(response_item);
|
||||
}
|
||||
RolloutItem::EventMsg(_) | RolloutItem::SessionMeta(_) => {}
|
||||
}
|
||||
|
||||
if base_replacement_history.is_some()
|
||||
|
||||
@@ -35,6 +35,18 @@ fn assistant_message(text: &str) -> ResponseItem {
|
||||
}
|
||||
}
|
||||
|
||||
fn inter_agent_assistant_message(text: &str) -> ResponseItem {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: text.to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previous_turn_settings()
|
||||
{
|
||||
@@ -432,6 +444,96 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
let first_context_item = turn_context.to_turn_context_item();
|
||||
let first_turn_id = first_context_item
|
||||
.turn_id
|
||||
.clone()
|
||||
.expect("turn context should have turn_id");
|
||||
let assistant_turn_id = "assistant-instruction-turn".to_string();
|
||||
let assistant_turn_context = TurnContextItem {
|
||||
turn_id: Some(assistant_turn_id.clone()),
|
||||
..first_context_item.clone()
|
||||
};
|
||||
let assistant_instruction = inter_agent_assistant_message(
|
||||
"author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue",
|
||||
);
|
||||
let assistant_reply = assistant_message("worker reply");
|
||||
|
||||
let rollout_items = vec![
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(
|
||||
codex_protocol::protocol::TurnStartedEvent {
|
||||
turn_id: first_turn_id.clone(),
|
||||
model_context_window: Some(128_000),
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
},
|
||||
)),
|
||||
RolloutItem::EventMsg(EventMsg::UserMessage(
|
||||
codex_protocol::protocol::UserMessageEvent {
|
||||
message: "turn 1 user".to_string(),
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
)),
|
||||
RolloutItem::TurnContext(first_context_item.clone()),
|
||||
RolloutItem::ResponseItem(user_message("turn 1 user")),
|
||||
RolloutItem::ResponseItem(assistant_message("turn 1 assistant")),
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(
|
||||
codex_protocol::protocol::TurnCompleteEvent {
|
||||
turn_id: first_turn_id,
|
||||
last_agent_message: None,
|
||||
},
|
||||
)),
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(
|
||||
codex_protocol::protocol::TurnStartedEvent {
|
||||
turn_id: assistant_turn_id.clone(),
|
||||
model_context_window: Some(128_000),
|
||||
collaboration_mode_kind: ModeKind::Default,
|
||||
},
|
||||
)),
|
||||
RolloutItem::TurnContext(assistant_turn_context),
|
||||
RolloutItem::ResponseItem(assistant_instruction),
|
||||
RolloutItem::ResponseItem(assistant_reply),
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(
|
||||
codex_protocol::protocol::TurnCompleteEvent {
|
||||
turn_id: assistant_turn_id,
|
||||
last_agent_message: None,
|
||||
},
|
||||
)),
|
||||
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(
|
||||
codex_protocol::protocol::ThreadRolledBackEvent { num_turns: 1 },
|
||||
)),
|
||||
];
|
||||
|
||||
let reconstructed = session
|
||||
.reconstruct_history_from_rollout(&turn_context, &rollout_items)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
reconstructed.history,
|
||||
vec![
|
||||
user_message("turn 1 user"),
|
||||
assistant_message("turn 1 assistant")
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
reconstructed.previous_turn_settings,
|
||||
Some(PreviousTurnSettings {
|
||||
model: turn_context.model_info.slug.clone(),
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(reconstructed.reference_context_item)
|
||||
.expect("serialize reconstructed reference context item"),
|
||||
serde_json::to_value(Some(first_context_item))
|
||||
.expect("serialize expected reference context item")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding_user_turns() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
|
||||
@@ -25,6 +25,7 @@ use codex_protocol::permissions::FileSystemPath;
|
||||
use codex_protocol::permissions::FileSystemSandboxEntry;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::permissions::FileSystemSpecialPath;
|
||||
use codex_protocol::protocol::NonSteerableTurnKind;
|
||||
use codex_protocol::protocol::ReadOnlyAccess;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::request_permissions::PermissionGrantScope;
|
||||
@@ -65,17 +66,31 @@ use codex_execpolicy::NetworkRuleProtocol;
|
||||
use codex_execpolicy::Policy;
|
||||
use codex_network_proxy::NetworkProxyConfig;
|
||||
use codex_otel::TelemetryAuthMode;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::config_types::Settings;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::DeveloperInstructions;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::ConversationAudioParams;
|
||||
use codex_protocol::protocol::RealtimeAudioFrame;
|
||||
use codex_protocol::protocol::Submission;
|
||||
use codex_protocol::protocol::W3cTraceContext;
|
||||
use core_test_support::context_snapshot;
|
||||
use core_test_support::context_snapshot::ContextSnapshotOptions;
|
||||
use core_test_support::context_snapshot::ContextSnapshotRenderMode;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::mount_sse_once;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::tracing::install_test_tracing;
|
||||
use core_test_support::wait_for_event;
|
||||
use opentelemetry::trace::TraceContextExt;
|
||||
use opentelemetry::trace::TraceId;
|
||||
use std::path::Path;
|
||||
@@ -645,6 +660,21 @@ fn filter_connectors_for_input_skips_disabled_connectors() {
|
||||
assert_eq!(selected, Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_connectors_for_input_skips_plugin_mentions() {
|
||||
let connectors = vec![make_connector("figma", "Figma")];
|
||||
let input = vec")];
|
||||
let explicitly_enabled_connectors = HashSet::new();
|
||||
let selected = filter_connectors_for_input(
|
||||
&connectors,
|
||||
&input,
|
||||
&explicitly_enabled_connectors,
|
||||
&HashMap::new(),
|
||||
);
|
||||
|
||||
assert_eq!(selected, Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_explicit_app_ids_from_skill_items_includes_linked_mentions() {
|
||||
let connectors = vec![make_connector("calendar", "Calendar")];
|
||||
@@ -1106,6 +1136,111 @@ async fn record_initial_history_reconstructs_forked_transcript() {
|
||||
assert_eq!(expected, history.raw_items());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result<()> {
|
||||
let server = start_mock_server().await;
|
||||
mount_sse_once(
|
||||
&server,
|
||||
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
|
||||
)
|
||||
.await;
|
||||
let first_forked_request = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex().with_config(|config| {
|
||||
config.permissions.approval_policy =
|
||||
codex_config::Constrained::allow_any(AskForApproval::OnRequest);
|
||||
});
|
||||
let initial = builder.build(&server).await?;
|
||||
let rollout_path = initial
|
||||
.session_configured
|
||||
.rollout_path
|
||||
.clone()
|
||||
.expect("rollout path");
|
||||
|
||||
initial
|
||||
.codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "fork seed".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let mut fork_config = initial.config.clone();
|
||||
fork_config.permissions.approval_policy =
|
||||
codex_config::Constrained::allow_any(AskForApproval::UnlessTrusted);
|
||||
let forked = initial
|
||||
.thread_manager
|
||||
.fork_thread(usize::MAX, fork_config, rollout_path, false, None)
|
||||
.await?;
|
||||
|
||||
let collaboration_mode = CollaborationMode {
|
||||
mode: ModeKind::Plan,
|
||||
settings: Settings {
|
||||
model: forked.session_configured.model.clone(),
|
||||
reasoning_effort: None,
|
||||
developer_instructions: Some("Fork turn collaboration instructions.".to_string()),
|
||||
},
|
||||
};
|
||||
forked
|
||||
.thread
|
||||
.submit(Op::OverrideTurnContext {
|
||||
cwd: None,
|
||||
approval_policy: Some(AskForApproval::Never),
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: None,
|
||||
windows_sandbox_level: None,
|
||||
model: None,
|
||||
effort: None,
|
||||
summary: None,
|
||||
service_tier: None,
|
||||
collaboration_mode: Some(collaboration_mode),
|
||||
personality: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
forked
|
||||
.thread
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "after fork".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&forked.thread, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let request = first_forked_request.single_request();
|
||||
let snapshot = context_snapshot::format_labeled_requests_snapshot(
|
||||
"First request after fork when fork startup changes approval policy and the first forked turn changes approval policy again and enters plan mode.",
|
||||
&[("First Forked Turn Request", &request)],
|
||||
&ContextSnapshotOptions::default()
|
||||
.render_mode(ContextSnapshotRenderMode::KindWithTextPrefix { max_chars: 96 })
|
||||
.strip_capability_instructions()
|
||||
.strip_agents_md_user_context(),
|
||||
);
|
||||
|
||||
let mut settings = insta::Settings::clone_current();
|
||||
settings.set_snapshot_path("snapshots");
|
||||
settings.set_prepend_module_to_snapshot(false);
|
||||
settings.bind(|| {
|
||||
insta::assert_snapshot!(
|
||||
"codex_core__codex_tests__fork_startup_context_then_first_turn_diff",
|
||||
snapshot
|
||||
);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn record_initial_history_forked_hydrates_previous_turn_settings() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
@@ -2553,6 +2688,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
pending_mcp_server_refresh_config: Mutex::new(None),
|
||||
conversation: Arc::new(RealtimeConversationManager::new()),
|
||||
active_turn: Mutex::new(None),
|
||||
idle_pending_input: Mutex::new(Vec::new()),
|
||||
guardian_review_session: crate::guardian::GuardianReviewSessionManager::default(),
|
||||
services,
|
||||
js_repl,
|
||||
@@ -3352,6 +3488,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
|
||||
pending_mcp_server_refresh_config: Mutex::new(None),
|
||||
conversation: Arc::new(RealtimeConversationManager::new()),
|
||||
active_turn: Mutex::new(None),
|
||||
idle_pending_input: Mutex::new(Vec::new()),
|
||||
guardian_review_session: crate::guardian::GuardianReviewSessionManager::default(),
|
||||
services,
|
||||
js_repl,
|
||||
@@ -4467,6 +4604,43 @@ async fn steer_input_enforces_expected_turn_id() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn steer_input_rejects_non_regular_turns() {
|
||||
for (task_kind, turn_kind) in [
|
||||
(TaskKind::Review, NonSteerableTurnKind::Review),
|
||||
(TaskKind::Compact, NonSteerableTurnKind::Compact),
|
||||
] {
|
||||
let (sess, _tc, _rx) = make_session_and_context_with_rx().await;
|
||||
let input = vec![UserInput::Text {
|
||||
text: "hello".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}];
|
||||
let turn_context = sess.new_default_turn_with_sub_id("turn".to_string()).await;
|
||||
sess.spawn_task(
|
||||
turn_context,
|
||||
input,
|
||||
NeverEndingTask {
|
||||
kind: task_kind,
|
||||
listen_to_cancellation_token: true,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let steer_input = vec![UserInput::Text {
|
||||
text: "steer".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}];
|
||||
let err = sess
|
||||
.steer_input(steer_input, /*expected_turn_id*/ None)
|
||||
.await
|
||||
.expect_err("steering a non-regular turn should fail");
|
||||
|
||||
assert_eq!(err, SteerInputError::ActiveTurnNotSteerable { turn_kind });
|
||||
|
||||
sess.abort_all_tasks(TurnAbortReason::Interrupted).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn steer_input_returns_active_turn_id() {
|
||||
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
|
||||
@@ -4637,6 +4811,32 @@ async fn prepend_pending_input_keeps_older_tail_ahead_of_newer_input() {
|
||||
assert_eq!(sess.get_pending_input().await, vec![later, newer]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queued_response_items_for_next_turn_move_into_next_active_turn() {
|
||||
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
|
||||
let queued_item = ResponseInputItem::Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "queued before wake".to_string(),
|
||||
}],
|
||||
};
|
||||
|
||||
sess.queue_response_items_for_next_turn(vec![queued_item.clone()])
|
||||
.await;
|
||||
|
||||
sess.spawn_task(
|
||||
Arc::clone(&tc),
|
||||
Vec::new(),
|
||||
NeverEndingTask {
|
||||
kind: TaskKind::Regular,
|
||||
listen_to_cancellation_token: false,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(sess.get_pending_input().await, vec![queued_item]);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn abort_review_task_emits_exited_then_aborted_and_records_history() {
|
||||
let (sess, tc, rx) = make_session_and_context_with_rx().await;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use crate::agent::AgentStatus;
|
||||
use crate::agent::inter_agent_instruction::InterAgentDelivery;
|
||||
use crate::agent::inter_agent_instruction::InterAgentInstruction;
|
||||
use crate::codex::Codex;
|
||||
use crate::codex::SteerInputError;
|
||||
use crate::config::ConstraintResult;
|
||||
@@ -122,30 +124,91 @@ impl CodexThread {
|
||||
|
||||
/// Records a user-role session-prefix message without creating a new user turn boundary.
|
||||
pub(crate) async fn inject_user_message_without_turn(&self, message: String) {
|
||||
let pending_item = ResponseInputItem::Message {
|
||||
let message = ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText { text: message }],
|
||||
metadata: None,
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
};
|
||||
let pending_items = vec![pending_item];
|
||||
let Err(items_without_active_turn) = self
|
||||
let pending_item = match pending_message_input_item(&message) {
|
||||
Ok(pending_item) => pending_item,
|
||||
Err(err) => {
|
||||
debug_assert!(false, "session-prefix message append should succeed: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if self
|
||||
.codex
|
||||
.session
|
||||
.inject_response_items(pending_items)
|
||||
.inject_response_items(vec![pending_item])
|
||||
.await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
.is_err()
|
||||
{
|
||||
let turn_context = self.codex.session.new_default_turn().await;
|
||||
self.codex
|
||||
.session
|
||||
.record_conversation_items(turn_context.as_ref(), &[message])
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
let turn_context = self.codex.session.new_default_turn().await;
|
||||
let items: Vec<ResponseItem> = items_without_active_turn
|
||||
.into_iter()
|
||||
.map(ResponseItem::from)
|
||||
.collect();
|
||||
/// Append a prebuilt message to the thread history without treating it as a user turn.
|
||||
///
|
||||
/// If the thread already has an active turn, the message is queued as pending input for that
|
||||
/// turn. Otherwise it is queued at session scope and a regular turn is started so the agent
|
||||
/// can consume that pending input through the normal turn pipeline.
|
||||
pub(crate) async fn append_message(&self, message: ResponseItem) -> CodexResult<String> {
|
||||
let submission_id = uuid::Uuid::new_v4().to_string();
|
||||
let pending_item = pending_message_input_item(&message)?;
|
||||
if let Err(items) = self
|
||||
.codex
|
||||
.session
|
||||
.inject_response_items(vec![pending_item])
|
||||
.await
|
||||
{
|
||||
self.codex
|
||||
.session
|
||||
.queue_response_items_for_next_turn(items)
|
||||
.await;
|
||||
self.codex
|
||||
.session
|
||||
.ensure_task_for_queued_response_items()
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(submission_id)
|
||||
}
|
||||
|
||||
pub(crate) async fn deliver_inter_agent_instruction(
|
||||
&self,
|
||||
instruction: InterAgentInstruction,
|
||||
delivery: InterAgentDelivery,
|
||||
) -> CodexResult<String> {
|
||||
let message = instruction.to_response_item();
|
||||
match delivery {
|
||||
InterAgentDelivery::CurrentTurn => self.append_message(message).await,
|
||||
InterAgentDelivery::NextTurn => self.queue_message_for_next_turn(message).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue a prebuilt message so the next turn records it before any submitted user input.
|
||||
pub(crate) async fn queue_message_for_next_turn(
|
||||
&self,
|
||||
message: ResponseItem,
|
||||
) -> CodexResult<String> {
|
||||
let submission_id = uuid::Uuid::new_v4().to_string();
|
||||
let pending_item = pending_message_input_item(&message)?;
|
||||
self.codex
|
||||
.session
|
||||
.record_conversation_items(turn_context.as_ref(), &items)
|
||||
.queue_response_items_for_next_turn(vec![pending_item])
|
||||
.await;
|
||||
self.codex
|
||||
.session
|
||||
.ensure_task_for_queued_response_items()
|
||||
.await;
|
||||
Ok(submission_id)
|
||||
}
|
||||
|
||||
pub fn rollout_path(&self) -> Option<PathBuf> {
|
||||
@@ -199,3 +262,15 @@ impl CodexThread {
|
||||
Ok(*guard)
|
||||
}
|
||||
}
|
||||
|
||||
fn pending_message_input_item(message: &ResponseItem) -> CodexResult<ResponseInputItem> {
|
||||
match message {
|
||||
ResponseItem::Message { role, content, .. } => Ok(ResponseInputItem::Message {
|
||||
role: role.clone(),
|
||||
content: content.clone(),
|
||||
}),
|
||||
_ => Err(CodexErr::InvalidRequest(
|
||||
"append_message only supports ResponseItem::Message".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5582,16 +5582,69 @@ shell_tool = true
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn missing_system_bwrap_warning_matches_system_bwrap_presence() {
|
||||
#[cfg(target_os = "linux")]
|
||||
assert_eq!(
|
||||
missing_system_bwrap_warning().is_some(),
|
||||
!Path::new("/usr/bin/bwrap").is_file()
|
||||
);
|
||||
fn system_bwrap_warning_reports_missing_system_bwrap() {
|
||||
let warning = system_bwrap_warning_for_path(Path::new("/definitely/not/a/bwrap"))
|
||||
.expect("missing system bwrap should emit a warning");
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
assert!(missing_system_bwrap_warning().is_none());
|
||||
assert!(warning.contains("could not find system bubblewrap"));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn system_bwrap_warning_reports_too_old_system_bwrap() {
|
||||
let fake_bwrap = write_fake_bwrap(
|
||||
r#"#!/bin/sh
|
||||
if [ "$1" = "--help" ]; then
|
||||
echo 'usage: bwrap [OPTION...] COMMAND'
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
"#,
|
||||
);
|
||||
let fake_bwrap_path: &Path = fake_bwrap.as_ref();
|
||||
let warning = system_bwrap_warning_for_path(fake_bwrap_path)
|
||||
.expect("old system bwrap should emit a warning");
|
||||
|
||||
assert!(warning.contains("too old to support `--argv0`"));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn system_bwrap_warning_skips_supported_system_bwrap() {
|
||||
let fake_bwrap = write_fake_bwrap(
|
||||
r#"#!/bin/sh
|
||||
if [ "$1" = "--help" ]; then
|
||||
echo ' --argv0 PROGRAM'
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
"#,
|
||||
);
|
||||
let fake_bwrap_path: &Path = fake_bwrap.as_ref();
|
||||
|
||||
assert_eq!(system_bwrap_warning_for_path(fake_bwrap_path), None);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[test]
|
||||
fn system_bwrap_warning_is_disabled_off_linux() {
|
||||
assert!(system_bwrap_warning().is_none());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn write_fake_bwrap(contents: &str) -> tempfile::TempPath {
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
// Linux rejects exec-ing a file that is still open for writing.
|
||||
let path = NamedTempFile::new().expect("temp file").into_temp_path();
|
||||
fs::write(&path, contents).expect("write fake bwrap");
|
||||
let permissions = fs::Permissions::from_mode(0o755);
|
||||
fs::set_permissions(&path, permissions).expect("chmod fake bwrap");
|
||||
path
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -5677,7 +5730,7 @@ approvals_reviewer = "guardian_subagent"
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn smart_approvals_alias_is_migrated_to_guardian_approval() -> std::io::Result<()> {
|
||||
async fn smart_approvals_alias_is_ignored() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
@@ -5692,23 +5745,19 @@ smart_approvals = true
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
assert!(config.features.enabled(Feature::GuardianApproval));
|
||||
assert_eq!(config.features.legacy_feature_usages().count(), 0);
|
||||
assert_eq!(
|
||||
config.approvals_reviewer,
|
||||
ApprovalsReviewer::GuardianSubagent
|
||||
);
|
||||
assert!(!config.features.enabled(Feature::GuardianApproval));
|
||||
assert_eq!(config.approvals_reviewer, ApprovalsReviewer::User);
|
||||
|
||||
let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?;
|
||||
assert!(serialized.contains("guardian_approval = true"));
|
||||
assert!(serialized.contains("approvals_reviewer = \"guardian_subagent\""));
|
||||
assert!(!serialized.contains("smart_approvals"));
|
||||
assert!(serialized.contains("smart_approvals = true"));
|
||||
assert!(!serialized.contains("guardian_approval"));
|
||||
assert!(!serialized.contains("approvals_reviewer"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn smart_approvals_alias_is_migrated_in_profiles() -> std::io::Result<()> {
|
||||
async fn smart_approvals_alias_is_ignored_in_profiles() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
@@ -5719,106 +5768,6 @@ smart_approvals = true
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
assert!(config.features.enabled(Feature::GuardianApproval));
|
||||
assert_eq!(config.features.legacy_feature_usages().count(), 0);
|
||||
assert_eq!(
|
||||
config.approvals_reviewer,
|
||||
ApprovalsReviewer::GuardianSubagent
|
||||
);
|
||||
|
||||
let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?;
|
||||
assert!(serialized.contains("[profiles.guardian.features]"));
|
||||
assert!(serialized.contains("guardian_approval = true"));
|
||||
assert!(serialized.contains("approvals_reviewer = \"guardian_subagent\""));
|
||||
assert!(!serialized.contains("smart_approvals"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn smart_approvals_alias_migration_preserves_disabled_profile_override() -> std::io::Result<()>
|
||||
{
|
||||
let codex_home = TempDir::new()?;
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
guardian_approval = true
|
||||
|
||||
[profiles.guardian.features]
|
||||
smart_approvals = false
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.harness_overrides(ConfigOverrides {
|
||||
config_profile: Some("guardian".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
assert!(!config.features.enabled(Feature::GuardianApproval));
|
||||
assert_eq!(config.features.legacy_feature_usages().count(), 0);
|
||||
assert_eq!(config.approvals_reviewer, ApprovalsReviewer::User);
|
||||
|
||||
let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?;
|
||||
assert!(serialized.contains("[profiles.guardian.features]"));
|
||||
assert!(serialized.contains("guardian_approval = false"));
|
||||
assert!(!serialized.contains("smart_approvals"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn smart_approvals_alias_migration_preserves_existing_approvals_reviewer()
|
||||
-> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
r#"approvals_reviewer = "user"
|
||||
|
||||
[features]
|
||||
smart_approvals = true
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
assert!(config.features.enabled(Feature::GuardianApproval));
|
||||
assert_eq!(config.approvals_reviewer, ApprovalsReviewer::User);
|
||||
|
||||
let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?;
|
||||
assert!(serialized.contains("guardian_approval = true"));
|
||||
assert!(serialized.contains("approvals_reviewer = \"user\""));
|
||||
assert!(!serialized.contains("smart_approvals"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn smart_approvals_alias_migration_does_not_override_canonical_disabled_flag()
|
||||
-> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
guardian_approval = false
|
||||
smart_approvals = true
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
@@ -5829,9 +5778,10 @@ smart_approvals = true
|
||||
assert_eq!(config.approvals_reviewer, ApprovalsReviewer::User);
|
||||
|
||||
let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?;
|
||||
assert!(serialized.contains("guardian_approval = false"));
|
||||
assert!(!serialized.contains("approvals_reviewer = \"guardian_subagent\""));
|
||||
assert!(!serialized.contains("smart_approvals"));
|
||||
assert!(serialized.contains("[profiles.guardian.features]"));
|
||||
assert!(serialized.contains("smart_approvals = true"));
|
||||
assert!(!serialized.contains("guardian_approval"));
|
||||
assert!(!serialized.contains("approvals_reviewer"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+46
-148
@@ -96,14 +96,16 @@ use std::collections::HashMap;
|
||||
use std::io::ErrorKind;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::process::Command;
|
||||
|
||||
use crate::config::permissions::compile_permission_profile;
|
||||
use crate::config::permissions::get_readable_roots_required_for_codex_runtime;
|
||||
use crate::config::permissions::network_proxy_config_from_profile_network;
|
||||
use crate::config::profile::ConfigProfile;
|
||||
use codex_network_proxy::NetworkProxyConfig;
|
||||
use toml::Value as TomlValue;
|
||||
use toml_edit::DocumentMut;
|
||||
use toml_edit::value;
|
||||
|
||||
pub(crate) mod agent_roles;
|
||||
pub mod edit;
|
||||
@@ -153,21 +155,46 @@ const RESERVED_MODEL_PROVIDER_IDS: [&str; 3] = [
|
||||
];
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn missing_system_bwrap_warning() -> Option<String> {
|
||||
if Path::new(SYSTEM_BWRAP_PATH).is_file() {
|
||||
None
|
||||
} else {
|
||||
Some(format!(
|
||||
"Codex could not find system bubblewrap at {SYSTEM_BWRAP_PATH}. Please install bubblewrap with your package manager. Codex will use the vendored bubblewrap in the meantime."
|
||||
))
|
||||
}
|
||||
pub fn system_bwrap_warning() -> Option<String> {
|
||||
system_bwrap_warning_for_path(Path::new(SYSTEM_BWRAP_PATH))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn missing_system_bwrap_warning() -> Option<String> {
|
||||
pub fn system_bwrap_warning() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn system_bwrap_warning_for_path(system_bwrap_path: &Path) -> Option<String> {
|
||||
if !system_bwrap_path.is_file() {
|
||||
return Some(format!(
|
||||
"Codex could not find system bubblewrap at {}. Please install bubblewrap with your package manager. Codex will use the vendored bubblewrap in the meantime.",
|
||||
system_bwrap_path.display()
|
||||
));
|
||||
}
|
||||
if system_bwrap_supports_argv0(system_bwrap_path) {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(format!(
|
||||
"Codex found system bubblewrap at {}, but it is too old to support `--argv0`. Please upgrade bubblewrap with your package manager. Codex will use the vendored bubblewrap in the meantime.",
|
||||
system_bwrap_path.display()
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn system_bwrap_supports_argv0(system_bwrap_path: &Path) -> bool {
|
||||
// bubblewrap added `--argv0` in v0.9.0:
|
||||
// https://github.com/containers/bubblewrap/releases/tag/v0.9.0
|
||||
let output = match Command::new(system_bwrap_path).arg("--help").output() {
|
||||
Ok(output) => output,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
stdout.contains("--argv0") || stderr.contains("--argv0")
|
||||
}
|
||||
|
||||
fn resolve_sqlite_home_env(resolved_cwd: &Path) -> Option<PathBuf> {
|
||||
let raw = std::env::var(codex_state::SQLITE_HOME_ENV).ok()?;
|
||||
let trimmed = raw.trim();
|
||||
@@ -651,9 +678,6 @@ impl ConfigBuilder {
|
||||
fallback_cwd,
|
||||
} = self;
|
||||
let codex_home = codex_home.map_or_else(find_codex_home, std::io::Result::Ok)?;
|
||||
if let Err(err) = maybe_migrate_smart_approvals_alias(&codex_home).await {
|
||||
tracing::warn!(error = %err, "failed to migrate smart_approvals feature alias");
|
||||
}
|
||||
let cli_overrides = cli_overrides.unwrap_or_default();
|
||||
let mut harness_overrides = harness_overrides.unwrap_or_default();
|
||||
let loader_overrides = loader_overrides.unwrap_or_default();
|
||||
@@ -701,111 +725,6 @@ impl ConfigBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
fn config_scope_segments(scope: &[String], key: &str) -> Vec<String> {
|
||||
let mut segments = scope.to_vec();
|
||||
segments.push(key.to_string());
|
||||
segments
|
||||
}
|
||||
|
||||
fn feature_scope_segments(scope: &[String], feature_key: &str) -> Vec<String> {
|
||||
let mut segments = scope.to_vec();
|
||||
segments.push("features".to_string());
|
||||
segments.push(feature_key.to_string());
|
||||
segments
|
||||
}
|
||||
|
||||
fn push_smart_approvals_alias_migration_edits(
|
||||
edits: &mut Vec<ConfigEdit>,
|
||||
scope: &[String],
|
||||
features: &FeaturesToml,
|
||||
approvals_reviewer_missing: bool,
|
||||
) {
|
||||
let Some(alias_enabled) = features.entries.get("smart_approvals").copied() else {
|
||||
return;
|
||||
};
|
||||
let canonical_enabled = features
|
||||
.entries
|
||||
.get("guardian_approval")
|
||||
.copied()
|
||||
.unwrap_or(alias_enabled);
|
||||
|
||||
if !features.entries.contains_key("guardian_approval") {
|
||||
edits.push(ConfigEdit::SetPath {
|
||||
segments: feature_scope_segments(scope, "guardian_approval"),
|
||||
value: value(alias_enabled),
|
||||
});
|
||||
}
|
||||
if canonical_enabled && approvals_reviewer_missing {
|
||||
edits.push(ConfigEdit::SetPath {
|
||||
segments: config_scope_segments(scope, "approvals_reviewer"),
|
||||
value: value(ApprovalsReviewer::GuardianSubagent.to_string()),
|
||||
});
|
||||
}
|
||||
edits.push(ConfigEdit::ClearPath {
|
||||
segments: feature_scope_segments(scope, "smart_approvals"),
|
||||
});
|
||||
}
|
||||
|
||||
/// Rewrites the legacy `smart_approvals` feature flag to
|
||||
/// `guardian_approval` in `config.toml` before normal config loading.
|
||||
///
|
||||
/// If the old key is present, this preserves its value by setting
|
||||
/// `guardian_approval = <alias value>` when the new key is not already present.
|
||||
/// Because the deprecated flag historically meant "turn guardian review on",
|
||||
/// this migration also backfills `approvals_reviewer = "guardian_subagent"`
|
||||
/// in the same scope when that reviewer is not already configured there and the
|
||||
/// migrated feature value is `true`.
|
||||
/// In all cases it removes the deprecated `smart_approvals` entry so future
|
||||
/// loads only see the canonical feature flag name.
|
||||
async fn maybe_migrate_smart_approvals_alias(codex_home: &Path) -> std::io::Result<bool> {
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
if !tokio::fs::try_exists(&config_path).await? {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let config_contents = tokio::fs::read_to_string(&config_path).await?;
|
||||
let Ok(config_toml) = toml::from_str::<ConfigToml>(&config_contents) else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let mut edits = Vec::new();
|
||||
|
||||
let root_scope = Vec::new();
|
||||
if let Some(features) = config_toml.features.as_ref() {
|
||||
push_smart_approvals_alias_migration_edits(
|
||||
&mut edits,
|
||||
&root_scope,
|
||||
features,
|
||||
config_toml.approvals_reviewer.is_none(),
|
||||
);
|
||||
}
|
||||
|
||||
for (profile_name, profile) in &config_toml.profiles {
|
||||
if let Some(features) = profile.features.as_ref() {
|
||||
let scope = vec!["profiles".to_string(), profile_name.clone()];
|
||||
push_smart_approvals_alias_migration_edits(
|
||||
&mut edits,
|
||||
&scope,
|
||||
features,
|
||||
profile.approvals_reviewer.is_none(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if edits.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
ConfigEditsBuilder::new(codex_home)
|
||||
.with_edits(edits)
|
||||
.apply()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
std::io::Error::other(format!("failed to migrate guardian_approval alias: {err}"))
|
||||
})?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// This is the preferred way to create an instance of [Config].
|
||||
pub async fn load_with_cli_overrides(
|
||||
@@ -867,9 +786,6 @@ pub async fn load_config_as_toml_with_cli_overrides(
|
||||
cwd: &AbsolutePathBuf,
|
||||
cli_overrides: Vec<(String, TomlValue)>,
|
||||
) -> std::io::Result<ConfigToml> {
|
||||
if let Err(err) = maybe_migrate_smart_approvals_alias(codex_home).await {
|
||||
tracing::warn!(error = %err, "failed to migrate smart_approvals feature alias");
|
||||
}
|
||||
let config_layer_stack = load_config_layers_state(
|
||||
codex_home,
|
||||
Some(cwd.clone()),
|
||||
@@ -1918,29 +1834,6 @@ fn resolve_permission_config_syntax(
|
||||
})
|
||||
}
|
||||
|
||||
fn add_additional_file_system_writes(
|
||||
file_system_sandbox_policy: &mut FileSystemSandboxPolicy,
|
||||
additional_writable_roots: &[AbsolutePathBuf],
|
||||
) {
|
||||
for path in additional_writable_roots {
|
||||
let exists = file_system_sandbox_policy.entries.iter().any(|entry| {
|
||||
matches!(
|
||||
&entry.path,
|
||||
codex_protocol::permissions::FileSystemPath::Path { path: existing }
|
||||
if existing == path && entry.access == codex_protocol::permissions::FileSystemAccessMode::Write
|
||||
)
|
||||
});
|
||||
if !exists {
|
||||
file_system_sandbox_policy.entries.push(
|
||||
codex_protocol::permissions::FileSystemSandboxEntry {
|
||||
path: codex_protocol::permissions::FileSystemPath::Path { path: path.clone() },
|
||||
access: codex_protocol::permissions::FileSystemAccessMode::Write,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional overrides for user configuration (e.g., from CLI flags).
|
||||
#[derive(Default, Debug, Clone)]
|
||||
pub struct ConfigOverrides {
|
||||
@@ -2309,10 +2202,8 @@ impl Config {
|
||||
let mut sandbox_policy = file_system_sandbox_policy
|
||||
.to_legacy_sandbox_policy(network_sandbox_policy, &resolved_cwd)?;
|
||||
if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) {
|
||||
add_additional_file_system_writes(
|
||||
&mut file_system_sandbox_policy,
|
||||
&additional_writable_roots,
|
||||
);
|
||||
file_system_sandbox_policy = file_system_sandbox_policy
|
||||
.with_additional_writable_roots(&resolved_cwd, &additional_writable_roots);
|
||||
sandbox_policy = file_system_sandbox_policy
|
||||
.to_legacy_sandbox_policy(network_sandbox_policy, &resolved_cwd)?;
|
||||
}
|
||||
@@ -2663,6 +2554,11 @@ impl Config {
|
||||
} else {
|
||||
network.enabled().then_some(network)
|
||||
};
|
||||
let helper_readable_roots = get_readable_roots_required_for_codex_runtime(
|
||||
&codex_home,
|
||||
zsh_path.as_ref(),
|
||||
main_execve_wrapper_exe.as_ref(),
|
||||
);
|
||||
let effective_sandbox_policy = constrained_sandbox_policy.value.get().clone();
|
||||
let effective_file_system_sandbox_policy =
|
||||
if effective_sandbox_policy == original_sandbox_policy {
|
||||
@@ -2673,6 +2569,8 @@ impl Config {
|
||||
&resolved_cwd,
|
||||
)
|
||||
};
|
||||
let effective_file_system_sandbox_policy = effective_file_system_sandbox_policy
|
||||
.with_additional_readable_roots(&resolved_cwd, &helper_readable_roots);
|
||||
let effective_network_sandbox_policy =
|
||||
if effective_sandbox_policy == original_sandbox_policy {
|
||||
network_sandbox_policy
|
||||
|
||||
@@ -190,6 +190,37 @@ pub(crate) fn compile_permission_profile(
|
||||
))
|
||||
}
|
||||
|
||||
/// Returns a list of paths that must be readable by shell tools in order
|
||||
/// for Codex to function. These should always be added to the
|
||||
/// `FileSystemSandboxPolicy` for a thread.
|
||||
pub(crate) fn get_readable_roots_required_for_codex_runtime(
|
||||
codex_home: &Path,
|
||||
zsh_path: Option<&PathBuf>,
|
||||
main_execve_wrapper_exe: Option<&PathBuf>,
|
||||
) -> Vec<AbsolutePathBuf> {
|
||||
let arg0_root = AbsolutePathBuf::from_absolute_path(codex_home.join("tmp").join("arg0")).ok();
|
||||
let zsh_path = zsh_path.and_then(|path| AbsolutePathBuf::from_absolute_path(path).ok());
|
||||
let execve_wrapper_root = main_execve_wrapper_exe.and_then(|path| {
|
||||
let path = AbsolutePathBuf::from_absolute_path(path).ok()?;
|
||||
if let Some(arg0_root) = arg0_root.as_ref()
|
||||
&& path.as_path().starts_with(arg0_root.as_path())
|
||||
{
|
||||
path.parent()
|
||||
} else {
|
||||
Some(path)
|
||||
}
|
||||
});
|
||||
|
||||
let mut readable_roots = Vec::new();
|
||||
if let Some(zsh_path) = zsh_path {
|
||||
readable_roots.push(zsh_path);
|
||||
}
|
||||
if let Some(execve_wrapper_root) = execve_wrapper_root {
|
||||
readable_roots.push(execve_wrapper_root);
|
||||
}
|
||||
readable_roots
|
||||
}
|
||||
|
||||
fn compile_network_sandbox_policy(network: Option<&NetworkToml>) -> NetworkSandboxPolicy {
|
||||
let Some(network) = network else {
|
||||
return NetworkSandboxPolicy::Restricted;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::config::ConfigOverrides;
|
||||
use crate::config::ConfigToml;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::BTreeMap;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn normalize_absolute_path_for_platform_simplifies_windows_verbatim_paths() {
|
||||
@@ -7,3 +13,66 @@ fn normalize_absolute_path_for_platform_simplifies_windows_verbatim_paths() {
|
||||
normalize_absolute_path_for_platform(r"\\?\D:\c\x\worktrees\2508\swift-base", true);
|
||||
assert_eq!(parsed, PathBuf::from(r"D:\c\x\worktrees\2508\swift-base"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restricted_read_implicitly_allows_helper_executables() -> std::io::Result<()> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let cwd = temp_dir.path().join("workspace");
|
||||
let codex_home = temp_dir.path().join(".codex");
|
||||
let zsh_path = temp_dir.path().join("runtime").join("zsh");
|
||||
let arg0_root = codex_home.join("tmp").join("arg0");
|
||||
let allowed_arg0_dir = arg0_root.join("codex-arg0-session");
|
||||
let sibling_arg0_dir = arg0_root.join("codex-arg0-other-session");
|
||||
let execve_wrapper = allowed_arg0_dir.join("codex-execve-wrapper");
|
||||
std::fs::create_dir_all(&cwd)?;
|
||||
std::fs::create_dir_all(zsh_path.parent().expect("zsh path should have parent"))?;
|
||||
std::fs::create_dir_all(&allowed_arg0_dir)?;
|
||||
std::fs::create_dir_all(&sibling_arg0_dir)?;
|
||||
std::fs::write(&zsh_path, "")?;
|
||||
std::fs::write(&execve_wrapper, "")?;
|
||||
|
||||
let config = Config::load_from_base_config_with_overrides(
|
||||
ConfigToml {
|
||||
default_permissions: Some("workspace".to_string()),
|
||||
permissions: Some(PermissionsToml {
|
||||
entries: BTreeMap::from([(
|
||||
"workspace".to_string(),
|
||||
PermissionProfileToml {
|
||||
filesystem: Some(FilesystemPermissionsToml {
|
||||
entries: BTreeMap::new(),
|
||||
}),
|
||||
network: None,
|
||||
},
|
||||
)]),
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
ConfigOverrides {
|
||||
cwd: Some(cwd.clone()),
|
||||
zsh_path: Some(zsh_path.clone()),
|
||||
main_execve_wrapper_exe: Some(execve_wrapper),
|
||||
..Default::default()
|
||||
},
|
||||
codex_home,
|
||||
)?;
|
||||
|
||||
let expected_zsh = AbsolutePathBuf::try_from(zsh_path)?;
|
||||
let expected_allowed_arg0_dir = AbsolutePathBuf::try_from(allowed_arg0_dir)?;
|
||||
let expected_sibling_arg0_dir = AbsolutePathBuf::try_from(sibling_arg0_dir)?;
|
||||
let policy = &config.permissions.file_system_sandbox_policy;
|
||||
|
||||
assert!(
|
||||
policy.can_read_path_with_cwd(expected_zsh.as_path(), &cwd),
|
||||
"expected zsh helper path to be readable, policy: {policy:?}"
|
||||
);
|
||||
assert!(
|
||||
policy.can_read_path_with_cwd(expected_allowed_arg0_dir.as_path(), &cwd),
|
||||
"expected active arg0 helper dir to be readable, policy: {policy:?}"
|
||||
);
|
||||
assert!(
|
||||
!policy.can_read_path_with_cwd(expected_sibling_arg0_dir.as_path(), &cwd),
|
||||
"expected sibling arg0 helper dir to remain unreadable, policy: {policy:?}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::agent::inter_agent_instruction::InterAgentInstruction;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::context_manager::normalize;
|
||||
use crate::event_mapping::is_contextual_user_message_content;
|
||||
@@ -204,9 +205,10 @@ impl ContextManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the last `num_turns` user turns from this history.
|
||||
/// Drop the last `num_turns` instruction turns from this history.
|
||||
///
|
||||
/// "User turns" are identified as `ResponseItem::Message` entries whose role is `"user"`.
|
||||
/// Instruction turns are history messages that should behave like a new prompt boundary:
|
||||
/// ordinary user messages and structured assistant inter-agent instructions.
|
||||
///
|
||||
/// This mirrors thread-rollback semantics:
|
||||
/// - `num_turns == 0` is a no-op
|
||||
@@ -248,7 +250,7 @@ impl ContextManager {
|
||||
}
|
||||
|
||||
fn get_non_last_reasoning_items_tokens(&self) -> i64 {
|
||||
// Get reasoning items excluding all the ones after the last user message.
|
||||
// Get reasoning items excluding all the ones after the last instruction boundary.
|
||||
let Some(last_user_index) = self.items.iter().rposition(is_user_turn_boundary) else {
|
||||
return 0;
|
||||
};
|
||||
@@ -631,7 +633,12 @@ pub(crate) fn is_user_turn_boundary(item: &ResponseItem) -> bool {
|
||||
return false;
|
||||
};
|
||||
|
||||
role == "user" && !is_contextual_user_message_content(content)
|
||||
(role == "user" && !is_contextual_user_message_content(content))
|
||||
|| (role == "assistant" && is_inter_agent_instruction_content(content))
|
||||
}
|
||||
|
||||
fn is_inter_agent_instruction_content(content: &[ContentItem]) -> bool {
|
||||
InterAgentInstruction::is_message_content(content)
|
||||
}
|
||||
|
||||
fn user_message_positions(items: &[ResponseItem]) -> Vec<usize> {
|
||||
|
||||
@@ -39,6 +39,18 @@ fn assistant_msg(text: &str) -> ResponseItem {
|
||||
}
|
||||
}
|
||||
|
||||
fn inter_agent_assistant_msg(text: &str) -> ResponseItem {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: text.to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn create_history_with_items(items: Vec<ResponseItem>) -> ContextManager {
|
||||
let mut h = ContextManager::new();
|
||||
// Use a generous but fixed token budget; tests only rely on truncation
|
||||
@@ -231,6 +243,35 @@ fn items_after_last_model_generated_tokens_are_zero_without_model_generated_item
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inter_agent_assistant_messages_are_turn_boundaries() {
|
||||
let item = inter_agent_assistant_msg(
|
||||
"author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue",
|
||||
);
|
||||
|
||||
assert!(is_user_turn_boundary(&item));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_last_n_user_turns_treats_inter_agent_assistant_messages_as_instruction_turns() {
|
||||
let first_turn = user_input_text_msg("first");
|
||||
let first_reply = assistant_msg("done");
|
||||
let inter_agent_turn = inter_agent_assistant_msg(
|
||||
"author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue",
|
||||
);
|
||||
let inter_agent_reply = assistant_msg("worker reply");
|
||||
let mut history = create_history_with_items(vec![
|
||||
first_turn.clone(),
|
||||
first_reply.clone(),
|
||||
inter_agent_turn,
|
||||
inter_agent_reply,
|
||||
]);
|
||||
|
||||
history.drop_last_n_user_turns(1);
|
||||
|
||||
assert_eq!(history.raw_items(), &vec![first_turn, first_reply]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_token_usage_includes_all_items_after_last_model_generated_item() {
|
||||
let mut history = create_history_with_items(vec![assistant_msg("already counted by API")]);
|
||||
|
||||
@@ -43,6 +43,7 @@ fn build_permissions_update_item(
|
||||
Some(DeveloperInstructions::from_policy(
|
||||
next.sandbox_policy.get(),
|
||||
next.approval_policy.value(),
|
||||
next.config.approvals_reviewer,
|
||||
exec_policy,
|
||||
&next.cwd,
|
||||
next.features.enabled(Feature::ExecPermissionApprovals),
|
||||
|
||||
@@ -72,7 +72,7 @@ fn parse_agent_message(
|
||||
let mut content: Vec<AgentMessageContent> = Vec::new();
|
||||
for content_item in message.iter() {
|
||||
match content_item {
|
||||
ContentItem::OutputText { text } => {
|
||||
ContentItem::InputText { text } | ContentItem::OutputText { text } => {
|
||||
content.push(AgentMessageContent::Text { text: text.clone() });
|
||||
}
|
||||
_ => {
|
||||
|
||||
@@ -97,6 +97,43 @@ fn skips_local_image_label_text() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_assistant_message_input_text_for_backward_compatibility() {
|
||||
let item = ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue"
|
||||
.to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
};
|
||||
|
||||
let turn_item = parse_turn_item(&item).expect("expected assistant message turn item");
|
||||
|
||||
match turn_item {
|
||||
TurnItem::AgentMessage(message) => {
|
||||
let rendered = message
|
||||
.content
|
||||
.into_iter()
|
||||
.map(|content| {
|
||||
let AgentMessageContent::Text { text } = content;
|
||||
text
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
rendered,
|
||||
vec![
|
||||
"author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue"
|
||||
.to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
other => panic!("expected TurnItem::AgentMessage, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_unnamed_image_label_text() {
|
||||
let image_url = "data:image/png;base64,abc".to_string();
|
||||
|
||||
@@ -549,7 +549,7 @@ pub fn render_decision_for_unmatched_command(
|
||||
|
||||
// On Windows, ReadOnly sandbox is not a real sandbox, so special-case it
|
||||
// here.
|
||||
let runtime_sandbox_provides_safety =
|
||||
let environment_lacks_sandbox_protections =
|
||||
cfg!(windows) && matches!(sandbox_policy, SandboxPolicy::ReadOnly { .. });
|
||||
|
||||
// If the command is flagged as dangerous or we have no sandbox protection,
|
||||
@@ -558,9 +558,20 @@ pub fn render_decision_for_unmatched_command(
|
||||
// We prefer to prompt the user rather than outright forbid the command,
|
||||
// but if the user has explicitly disabled prompts, we must
|
||||
// forbid the command.
|
||||
if command_might_be_dangerous(command) || runtime_sandbox_provides_safety {
|
||||
if command_might_be_dangerous(command) || environment_lacks_sandbox_protections {
|
||||
return match approval_policy {
|
||||
AskForApproval::Never => Decision::Forbidden,
|
||||
AskForApproval::Never => {
|
||||
let sandbox_is_explicitly_disabled = matches!(
|
||||
sandbox_policy,
|
||||
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. }
|
||||
);
|
||||
if sandbox_is_explicitly_disabled {
|
||||
// If the sandbox is explicitly disabled, we should allow the command to run
|
||||
Decision::Allow
|
||||
} else {
|
||||
Decision::Forbidden
|
||||
}
|
||||
}
|
||||
AskForApproval::OnFailure
|
||||
| AskForApproval::OnRequest
|
||||
| AskForApproval::UnlessTrusted
|
||||
|
||||
@@ -81,6 +81,10 @@ fn unrestricted_file_system_sandbox_policy() -> FileSystemSandboxPolicy {
|
||||
FileSystemSandboxPolicy::unrestricted()
|
||||
}
|
||||
|
||||
fn external_file_system_sandbox_policy() -> FileSystemSandboxPolicy {
|
||||
FileSystemSandboxPolicy::external_sandbox()
|
||||
}
|
||||
|
||||
async fn test_config() -> (TempDir, Config) {
|
||||
let home = TempDir::new().expect("create temp dir");
|
||||
let config = ConfigBuilder::default()
|
||||
@@ -503,39 +507,25 @@ async fn loads_policies_from_multiple_config_layers() -> anyhow::Result<()> {
|
||||
|
||||
#[tokio::test]
|
||||
async fn evaluates_bash_lc_inner_commands() {
|
||||
let policy_src = r#"
|
||||
prefix_rule(pattern=["rm"], decision="forbidden")
|
||||
"#;
|
||||
let mut parser = PolicyParser::new();
|
||||
parser
|
||||
.parse("test.rules", policy_src)
|
||||
.expect("parse policy");
|
||||
let policy = Arc::new(parser.build());
|
||||
|
||||
let forbidden_script = vec![
|
||||
"bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"rm -rf /some/important/folder".to_string(),
|
||||
];
|
||||
|
||||
let manager = ExecPolicyManager::new(policy);
|
||||
let requirement = manager
|
||||
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
|
||||
command: &forbidden_script,
|
||||
assert_exec_approval_requirement_for_command(
|
||||
ExecApprovalRequirementScenario {
|
||||
policy_src: Some(r#"prefix_rule(pattern=["rm"], decision="forbidden")"#.to_string()),
|
||||
command: vec![
|
||||
"bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"rm -rf /some/important/folder".to_string(),
|
||||
],
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
||||
file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(),
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
requirement,
|
||||
ExecApprovalRequirement::Forbidden {
|
||||
reason: "`bash -lc 'rm -rf /some/important/folder'` rejected: policy forbids commands starting with `rm`".to_string()
|
||||
}
|
||||
);
|
||||
},
|
||||
ExecApprovalRequirement::Forbidden {
|
||||
reason: "`bash -lc 'rm -rf /some/important/folder'` rejected: policy forbids commands starting with `rm`".to_string(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -558,163 +548,132 @@ fn commands_for_exec_policy_falls_back_for_whitespace_shell_script() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn evaluates_heredoc_script_against_prefix_rules() {
|
||||
let policy_src = r#"prefix_rule(pattern=["python3"], decision="allow")"#;
|
||||
let mut parser = PolicyParser::new();
|
||||
parser
|
||||
.parse("test.rules", policy_src)
|
||||
.expect("parse policy");
|
||||
let policy = Arc::new(parser.build());
|
||||
let command = vec![
|
||||
"bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"python3 <<'PY'\nprint('hello')\nPY".to_string(),
|
||||
];
|
||||
|
||||
let requirement = ExecPolicyManager::new(policy)
|
||||
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
|
||||
command: &command,
|
||||
assert_exec_approval_requirement_for_command(
|
||||
ExecApprovalRequirementScenario {
|
||||
policy_src: Some(r#"prefix_rule(pattern=["python3"], decision="allow")"#.to_string()),
|
||||
command,
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
|
||||
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
requirement,
|
||||
},
|
||||
ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: true,
|
||||
proposed_execpolicy_amendment: None,
|
||||
}
|
||||
);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn omits_auto_amendment_for_heredoc_fallback_prompts() {
|
||||
let command = vec![
|
||||
"bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"python3 <<'PY'\nprint('hello')\nPY".to_string(),
|
||||
];
|
||||
|
||||
let requirement = ExecPolicyManager::default()
|
||||
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
|
||||
command: &command,
|
||||
assert_exec_approval_requirement_for_command(
|
||||
ExecApprovalRequirementScenario {
|
||||
policy_src: None,
|
||||
command: vec![
|
||||
"bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"python3 <<'PY'\nprint('hello')\nPY".to_string(),
|
||||
],
|
||||
approval_policy: AskForApproval::UnlessTrusted,
|
||||
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
|
||||
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
requirement,
|
||||
},
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: None,
|
||||
}
|
||||
);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drops_requested_amendment_for_heredoc_fallback_prompts_when_it_wont_match() {
|
||||
let command = vec![
|
||||
"bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"python3 <<'PY'\nprint('hello')\nPY".to_string(),
|
||||
];
|
||||
let requested_prefix = vec!["python3".to_string(), "-m".to_string(), "pip".to_string()];
|
||||
|
||||
let requirement = ExecPolicyManager::default()
|
||||
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
|
||||
command: &command,
|
||||
assert_exec_approval_requirement_for_command(
|
||||
ExecApprovalRequirementScenario {
|
||||
policy_src: None,
|
||||
command: vec![
|
||||
"bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"python3 <<'PY'\nprint('hello')\nPY".to_string(),
|
||||
],
|
||||
approval_policy: AskForApproval::UnlessTrusted,
|
||||
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
|
||||
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: Some(requested_prefix.clone()),
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
requirement,
|
||||
prefix_rule: Some(vec![
|
||||
"python3".to_string(),
|
||||
"-m".to_string(),
|
||||
"pip".to_string(),
|
||||
]),
|
||||
},
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: None,
|
||||
}
|
||||
);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn justification_is_included_in_forbidden_exec_approval_requirement() {
|
||||
let policy_src = r#"
|
||||
assert_exec_approval_requirement_for_command(
|
||||
ExecApprovalRequirementScenario {
|
||||
policy_src: Some(
|
||||
r#"
|
||||
prefix_rule(
|
||||
pattern=["rm"],
|
||||
decision="forbidden",
|
||||
justification="destructive command",
|
||||
)
|
||||
"#;
|
||||
let mut parser = PolicyParser::new();
|
||||
parser
|
||||
.parse("test.rules", policy_src)
|
||||
.expect("parse policy");
|
||||
let policy = Arc::new(parser.build());
|
||||
|
||||
let manager = ExecPolicyManager::new(policy);
|
||||
let requirement = manager
|
||||
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
|
||||
command: &[
|
||||
"#
|
||||
.to_string(),
|
||||
),
|
||||
command: vec![
|
||||
"rm".to_string(),
|
||||
"-rf".to_string(),
|
||||
"/some/important/folder".to_string(),
|
||||
],
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
||||
file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(),
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
requirement,
|
||||
},
|
||||
ExecApprovalRequirement::Forbidden {
|
||||
reason: "`rm -rf /some/important/folder` rejected: destructive command".to_string()
|
||||
}
|
||||
);
|
||||
reason: "`rm -rf /some/important/folder` rejected: destructive command".to_string(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exec_approval_requirement_prefers_execpolicy_match() {
|
||||
let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#;
|
||||
let mut parser = PolicyParser::new();
|
||||
parser
|
||||
.parse("test.rules", policy_src)
|
||||
.expect("parse policy");
|
||||
let policy = Arc::new(parser.build());
|
||||
let command = vec!["rm".to_string()];
|
||||
|
||||
let manager = ExecPolicyManager::new(policy);
|
||||
let requirement = manager
|
||||
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
|
||||
command: &command,
|
||||
assert_exec_approval_requirement_for_command(
|
||||
ExecApprovalRequirementScenario {
|
||||
policy_src: Some(r#"prefix_rule(pattern=["rm"], decision="prompt")"#.to_string()),
|
||||
command: vec!["rm".to_string()],
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
||||
file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(),
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
requirement,
|
||||
},
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: Some("`rm` requires approval by policy".to_string()),
|
||||
proposed_execpolicy_amendment: None,
|
||||
}
|
||||
);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -727,31 +686,22 @@ host_executable(name = "git", paths = ["{git_path_literal}"])
|
||||
prefix_rule(pattern=["git"], decision="allow")
|
||||
"#
|
||||
);
|
||||
let mut parser = PolicyParser::new();
|
||||
parser
|
||||
.parse("test.rules", &policy_src)
|
||||
.expect("parse policy");
|
||||
let manager = ExecPolicyManager::new(Arc::new(parser.build()));
|
||||
let command = vec![git_path, "status".to_string()];
|
||||
|
||||
let requirement = manager
|
||||
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
|
||||
command: &command,
|
||||
assert_exec_approval_requirement_for_command(
|
||||
ExecApprovalRequirementScenario {
|
||||
policy_src: Some(policy_src),
|
||||
command: vec![git_path, "status".to_string()],
|
||||
approval_policy: AskForApproval::UnlessTrusted,
|
||||
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
|
||||
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
requirement,
|
||||
},
|
||||
ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: true,
|
||||
proposed_execpolicy_amendment: None,
|
||||
}
|
||||
);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -770,93 +720,71 @@ host_executable(name = "git", paths = ["{allowed_git_path_literal}"])
|
||||
prefix_rule(pattern=["git"], decision="prompt")
|
||||
"#
|
||||
);
|
||||
let mut parser = PolicyParser::new();
|
||||
parser
|
||||
.parse("test.rules", &policy_src)
|
||||
.expect("parse policy");
|
||||
let manager = ExecPolicyManager::new(Arc::new(parser.build()));
|
||||
let command = vec![disallowed_git_path, "status".to_string()];
|
||||
|
||||
let requirement = manager
|
||||
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
|
||||
command: &command,
|
||||
assert_exec_approval_requirement_for_command(
|
||||
ExecApprovalRequirementScenario {
|
||||
policy_src: Some(policy_src),
|
||||
command: vec![disallowed_git_path.clone(), "status".to_string()],
|
||||
approval_policy: AskForApproval::UnlessTrusted,
|
||||
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
|
||||
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
requirement,
|
||||
},
|
||||
ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: false,
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)),
|
||||
}
|
||||
);
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![
|
||||
disallowed_git_path,
|
||||
"status".to_string(),
|
||||
])),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn requested_prefix_rule_can_approve_absolute_path_commands() {
|
||||
let command = vec![
|
||||
host_program_path("cargo"),
|
||||
"install".to_string(),
|
||||
"cargo-insta".to_string(),
|
||||
];
|
||||
let manager = ExecPolicyManager::default();
|
||||
|
||||
let requirement = manager
|
||||
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
|
||||
command: &command,
|
||||
assert_exec_approval_requirement_for_command(
|
||||
ExecApprovalRequirementScenario {
|
||||
policy_src: None,
|
||||
command: vec![
|
||||
host_program_path("cargo"),
|
||||
"install".to_string(),
|
||||
"cargo-insta".to_string(),
|
||||
],
|
||||
approval_policy: AskForApproval::UnlessTrusted,
|
||||
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
|
||||
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]),
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
requirement,
|
||||
},
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![
|
||||
"cargo".to_string(),
|
||||
"install".to_string(),
|
||||
])),
|
||||
}
|
||||
);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exec_approval_requirement_respects_approval_policy() {
|
||||
let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#;
|
||||
let mut parser = PolicyParser::new();
|
||||
parser
|
||||
.parse("test.rules", policy_src)
|
||||
.expect("parse policy");
|
||||
let policy = Arc::new(parser.build());
|
||||
let command = vec!["rm".to_string()];
|
||||
|
||||
let manager = ExecPolicyManager::new(policy);
|
||||
let requirement = manager
|
||||
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
|
||||
command: &command,
|
||||
assert_exec_approval_requirement_for_command(
|
||||
ExecApprovalRequirementScenario {
|
||||
policy_src: Some(r#"prefix_rule(pattern=["rm"], decision="prompt")"#.to_string()),
|
||||
command: vec!["rm".to_string()],
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
||||
file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(),
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
requirement,
|
||||
},
|
||||
ExecApprovalRequirement::Forbidden {
|
||||
reason: PROMPT_CONFLICT_REASON.to_string()
|
||||
}
|
||||
);
|
||||
reason: PROMPT_CONFLICT_REASON.to_string(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -903,11 +831,10 @@ fn unmatched_on_request_uses_split_filesystem_policy_for_escalation_prompts() {
|
||||
#[tokio::test]
|
||||
async fn exec_approval_requirement_rejects_unmatched_sandbox_escalation_when_granular_sandbox_is_disabled()
|
||||
{
|
||||
let command = vec!["madeup-cmd".to_string()];
|
||||
|
||||
let requirement = ExecPolicyManager::default()
|
||||
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
|
||||
command: &command,
|
||||
assert_exec_approval_requirement_for_command(
|
||||
ExecApprovalRequirementScenario {
|
||||
policy_src: None,
|
||||
command: vec!["madeup-cmd".to_string()],
|
||||
approval_policy: AskForApproval::Granular(GranularApprovalConfig {
|
||||
sandbox_approval: false,
|
||||
rules: true,
|
||||
@@ -915,19 +842,16 @@ async fn exec_approval_requirement_rejects_unmatched_sandbox_escalation_when_gra
|
||||
request_permissions: true,
|
||||
mcp_elicitations: true,
|
||||
}),
|
||||
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
|
||||
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::RequireEscalated,
|
||||
prefix_rule: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
requirement,
|
||||
},
|
||||
ExecApprovalRequirement::Forbidden {
|
||||
reason: REJECT_SANDBOX_APPROVAL_REASON.to_string(),
|
||||
}
|
||||
);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1239,178 +1163,140 @@ async fn append_execpolicy_amendment_rejects_empty_prefix() {
|
||||
async fn proposed_execpolicy_amendment_is_present_for_single_command_without_policy_match() {
|
||||
let command = vec!["cargo".to_string(), "build".to_string()];
|
||||
|
||||
let manager = ExecPolicyManager::default();
|
||||
let requirement = manager
|
||||
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
|
||||
command: &command,
|
||||
assert_exec_approval_requirement_for_command(
|
||||
ExecApprovalRequirementScenario {
|
||||
policy_src: None,
|
||||
command: command.clone(),
|
||||
approval_policy: AskForApproval::UnlessTrusted,
|
||||
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
|
||||
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
requirement,
|
||||
},
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command))
|
||||
}
|
||||
);
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn proposed_execpolicy_amendment_is_omitted_when_policy_prompts() {
|
||||
let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#;
|
||||
let mut parser = PolicyParser::new();
|
||||
parser
|
||||
.parse("test.rules", policy_src)
|
||||
.expect("parse policy");
|
||||
let policy = Arc::new(parser.build());
|
||||
let command = vec!["rm".to_string()];
|
||||
|
||||
let manager = ExecPolicyManager::new(policy);
|
||||
let requirement = manager
|
||||
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
|
||||
command: &command,
|
||||
assert_exec_approval_requirement_for_command(
|
||||
ExecApprovalRequirementScenario {
|
||||
policy_src: Some(r#"prefix_rule(pattern=["rm"], decision="prompt")"#.to_string()),
|
||||
command: vec!["rm".to_string()],
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
||||
file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(),
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
requirement,
|
||||
},
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: Some("`rm` requires approval by policy".to_string()),
|
||||
proposed_execpolicy_amendment: None,
|
||||
}
|
||||
);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn proposed_execpolicy_amendment_is_present_for_multi_command_scripts() {
|
||||
let command = vec![
|
||||
"bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"cargo build && echo ok".to_string(),
|
||||
];
|
||||
let manager = ExecPolicyManager::default();
|
||||
let requirement = manager
|
||||
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
|
||||
command: &command,
|
||||
assert_exec_approval_requirement_for_command(
|
||||
ExecApprovalRequirementScenario {
|
||||
policy_src: None,
|
||||
command: vec![
|
||||
"bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"cargo build && echo ok".to_string(),
|
||||
],
|
||||
approval_policy: AskForApproval::UnlessTrusted,
|
||||
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
|
||||
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
requirement,
|
||||
},
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![
|
||||
"cargo".to_string(),
|
||||
"build".to_string()
|
||||
"build".to_string(),
|
||||
])),
|
||||
}
|
||||
);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn proposed_execpolicy_amendment_uses_first_no_match_in_multi_command_scripts() {
|
||||
let policy_src = r#"prefix_rule(pattern=["cat"], decision="allow")"#;
|
||||
let mut parser = PolicyParser::new();
|
||||
parser
|
||||
.parse("test.rules", policy_src)
|
||||
.expect("parse policy");
|
||||
let policy = Arc::new(parser.build());
|
||||
|
||||
let command = vec![
|
||||
"bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"cat && apple".to_string(),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
ExecPolicyManager::new(policy)
|
||||
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
|
||||
command: &command,
|
||||
approval_policy: AskForApproval::UnlessTrusted,
|
||||
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
|
||||
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
})
|
||||
.await,
|
||||
assert_exec_approval_requirement_for_command(
|
||||
ExecApprovalRequirementScenario {
|
||||
policy_src: Some(policy_src.to_string()),
|
||||
command,
|
||||
approval_policy: AskForApproval::UnlessTrusted,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
},
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![
|
||||
"apple".to_string()
|
||||
"apple".to_string(),
|
||||
])),
|
||||
}
|
||||
);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn proposed_execpolicy_amendment_is_present_when_heuristics_allow() {
|
||||
let command = vec!["echo".to_string(), "safe".to_string()];
|
||||
|
||||
let manager = ExecPolicyManager::default();
|
||||
let requirement = manager
|
||||
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
|
||||
command: &command,
|
||||
assert_exec_approval_requirement_for_command(
|
||||
ExecApprovalRequirementScenario {
|
||||
policy_src: None,
|
||||
command: command.clone(),
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
|
||||
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
requirement,
|
||||
},
|
||||
ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: false,
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)),
|
||||
}
|
||||
);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn proposed_execpolicy_amendment_is_suppressed_when_policy_matches_allow() {
|
||||
let policy_src = r#"prefix_rule(pattern=["echo"], decision="allow")"#;
|
||||
let mut parser = PolicyParser::new();
|
||||
parser
|
||||
.parse("test.rules", policy_src)
|
||||
.expect("parse policy");
|
||||
let policy = Arc::new(parser.build());
|
||||
let command = vec!["echo".to_string(), "safe".to_string()];
|
||||
|
||||
let manager = ExecPolicyManager::new(policy);
|
||||
let requirement = manager
|
||||
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
|
||||
command: &command,
|
||||
assert_exec_approval_requirement_for_command(
|
||||
ExecApprovalRequirementScenario {
|
||||
policy_src: Some(r#"prefix_rule(pattern=["echo"], decision="allow")"#.to_string()),
|
||||
command: vec!["echo".to_string(), "safe".to_string()],
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
|
||||
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
requirement,
|
||||
},
|
||||
ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: true,
|
||||
proposed_execpolicy_amendment: None,
|
||||
}
|
||||
);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
fn derive_requested_execpolicy_amendment_for_test(
|
||||
@@ -1560,25 +1446,23 @@ fn derive_requested_execpolicy_amendment_returns_none_when_policy_matches() {
|
||||
#[tokio::test]
|
||||
async fn dangerous_rm_rf_requires_approval_in_danger_full_access() {
|
||||
let command = vec_str(&["rm", "-rf", "/tmp/nonexistent"]);
|
||||
let manager = ExecPolicyManager::default();
|
||||
let requirement = manager
|
||||
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
|
||||
command: &command,
|
||||
|
||||
assert_exec_approval_requirement_for_command(
|
||||
ExecApprovalRequirementScenario {
|
||||
policy_src: None,
|
||||
command: command.clone(),
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
||||
file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(),
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
requirement,
|
||||
},
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)),
|
||||
}
|
||||
);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
fn vec_str(items: &[&str]) -> Vec<String> {
|
||||
@@ -1686,3 +1570,100 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() {
|
||||
(unless AskForApproval::Never is specified)."#
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dangerous_command_allowed_when_sandbox_is_explicitly_disabled() {
|
||||
let command = vec_str(&["rm", "-rf", "/tmp/nonexistent"]);
|
||||
assert_exec_approval_requirement_for_command(
|
||||
ExecApprovalRequirementScenario {
|
||||
policy_src: None,
|
||||
command,
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::ExternalSandbox {
|
||||
network_access: Default::default(),
|
||||
},
|
||||
file_system_sandbox_policy: external_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
},
|
||||
ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: false,
|
||||
proposed_execpolicy_amendment: Some(ExecPolicyAmendment {
|
||||
command: vec_str(&["rm", "-rf", "/tmp/nonexistent"]),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dangerous_command_forbidden_in_external_sandbox_when_policy_matches() {
|
||||
let command = vec_str(&["rm", "-rf", "/tmp/nonexistent"]);
|
||||
assert_exec_approval_requirement_for_command(
|
||||
ExecApprovalRequirementScenario {
|
||||
policy_src: Some("prefix_rule(pattern=['rm'], decision='prompt')".to_string()),
|
||||
command,
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::ExternalSandbox {
|
||||
network_access: Default::default(),
|
||||
},
|
||||
file_system_sandbox_policy: external_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
},
|
||||
ExecApprovalRequirement::Forbidden {
|
||||
reason: "approval required by policy, but AskForApproval is set to Never".to_string(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
struct ExecApprovalRequirementScenario {
|
||||
/// Source for the Starlark `.rules` file.
|
||||
policy_src: Option<String>,
|
||||
command: Vec<String>,
|
||||
approval_policy: AskForApproval,
|
||||
sandbox_policy: SandboxPolicy,
|
||||
file_system_sandbox_policy: FileSystemSandboxPolicy,
|
||||
sandbox_permissions: SandboxPermissions,
|
||||
prefix_rule: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
async fn assert_exec_approval_requirement_for_command(
|
||||
test: ExecApprovalRequirementScenario,
|
||||
expected_requirement: ExecApprovalRequirement,
|
||||
) {
|
||||
let ExecApprovalRequirementScenario {
|
||||
policy_src,
|
||||
command,
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
file_system_sandbox_policy,
|
||||
sandbox_permissions,
|
||||
prefix_rule,
|
||||
} = test;
|
||||
|
||||
let policy = match policy_src {
|
||||
Some(src) => {
|
||||
let mut parser = PolicyParser::new();
|
||||
parser
|
||||
.parse("test.rules", src.as_str())
|
||||
.expect("parse policy");
|
||||
Arc::new(parser.build())
|
||||
}
|
||||
None => Arc::new(Policy::empty()),
|
||||
};
|
||||
|
||||
let requirement = ExecPolicyManager::new(policy)
|
||||
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
|
||||
command: &command,
|
||||
approval_policy,
|
||||
sandbox_policy: &sandbox_policy,
|
||||
file_system_sandbox_policy: &file_system_sandbox_policy,
|
||||
sandbox_permissions,
|
||||
prefix_rule,
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(requirement, expected_requirement);
|
||||
}
|
||||
|
||||
@@ -457,6 +457,9 @@ const MCP_TOOL_APPROVAL_TOOL_TITLE_KEY: &str = "tool_title";
|
||||
const MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY: &str = "tool_description";
|
||||
const MCP_TOOL_APPROVAL_TOOL_PARAMS_KEY: &str = "tool_params";
|
||||
const MCP_TOOL_APPROVAL_TOOL_PARAMS_DISPLAY_KEY: &str = "tool_params_display";
|
||||
const MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_DEFAULT: &str = "mcp_tool_call__default";
|
||||
const MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_ALWAYS_ALLOW: &str = "mcp_tool_call__always_allow";
|
||||
const MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_FULL_ACCESS: &str = "mcp_tool_call__full_access";
|
||||
|
||||
pub(crate) fn is_mcp_tool_approval_question_id(question_id: &str) -> bool {
|
||||
question_id
|
||||
@@ -494,14 +497,22 @@ async fn maybe_request_mcp_tool_approval(
|
||||
let annotations = metadata.and_then(|metadata| metadata.annotations.as_ref());
|
||||
let approval_required = annotations.is_some_and(requires_mcp_tool_approval);
|
||||
let mut monitor_reason = None;
|
||||
let auto_approved_by_policy = approval_mode == AppToolApproval::Approve
|
||||
|| (approval_mode == AppToolApproval::Auto && is_full_access_mode(turn_context));
|
||||
|
||||
if approval_mode == AppToolApproval::Approve {
|
||||
if auto_approved_by_policy {
|
||||
if !approval_required {
|
||||
return None;
|
||||
}
|
||||
|
||||
match maybe_monitor_auto_approved_mcp_tool_call(sess, turn_context, invocation, metadata)
|
||||
.await
|
||||
match maybe_monitor_auto_approved_mcp_tool_call(
|
||||
sess,
|
||||
turn_context,
|
||||
invocation,
|
||||
metadata,
|
||||
approval_mode,
|
||||
)
|
||||
.await
|
||||
{
|
||||
ArcMonitorOutcome::Ok => return None,
|
||||
ArcMonitorOutcome::AskUser(reason) => {
|
||||
@@ -515,13 +526,8 @@ async fn maybe_request_mcp_tool_approval(
|
||||
}
|
||||
}
|
||||
|
||||
if approval_mode == AppToolApproval::Auto {
|
||||
if is_full_access_mode(turn_context) {
|
||||
return None;
|
||||
}
|
||||
if !approval_required {
|
||||
return None;
|
||||
}
|
||||
if approval_mode == AppToolApproval::Auto && !approval_required {
|
||||
return None;
|
||||
}
|
||||
|
||||
let session_approval_key = session_mcp_tool_approval_key(invocation, metadata, approval_mode);
|
||||
@@ -653,9 +659,16 @@ async fn maybe_monitor_auto_approved_mcp_tool_call(
|
||||
turn_context: &TurnContext,
|
||||
invocation: &McpInvocation,
|
||||
metadata: Option<&McpToolApprovalMetadata>,
|
||||
approval_mode: AppToolApproval,
|
||||
) -> ArcMonitorOutcome {
|
||||
let action = prepare_arc_request_action(invocation, metadata);
|
||||
monitor_action(sess, turn_context, action).await
|
||||
monitor_action(
|
||||
sess,
|
||||
turn_context,
|
||||
action,
|
||||
mcp_tool_approval_callsite_mode(approval_mode, turn_context),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn prepare_arc_request_action(
|
||||
@@ -749,6 +762,22 @@ fn is_full_access_mode(turn_context: &TurnContext) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn mcp_tool_approval_callsite_mode(
|
||||
approval_mode: AppToolApproval,
|
||||
turn_context: &TurnContext,
|
||||
) -> &'static str {
|
||||
match approval_mode {
|
||||
AppToolApproval::Approve => MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_ALWAYS_ALLOW,
|
||||
AppToolApproval::Auto | AppToolApproval::Prompt => {
|
||||
if approval_mode == AppToolApproval::Auto && is_full_access_mode(turn_context) {
|
||||
MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_FULL_ACCESS
|
||||
} else {
|
||||
MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_DEFAULT
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn lookup_mcp_tool_metadata(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
|
||||
@@ -776,6 +776,38 @@ fn approval_elicitation_meta_merges_session_and_always_persist_with_connector_so
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn approval_callsite_mode_distinguishes_default_always_allow_and_full_access() {
|
||||
let (_session, mut turn_context) = make_session_and_context().await;
|
||||
|
||||
assert_eq!(
|
||||
mcp_tool_approval_callsite_mode(AppToolApproval::Auto, &turn_context),
|
||||
"mcp_tool_call__default"
|
||||
);
|
||||
assert_eq!(
|
||||
mcp_tool_approval_callsite_mode(AppToolApproval::Prompt, &turn_context),
|
||||
"mcp_tool_call__default"
|
||||
);
|
||||
assert_eq!(
|
||||
mcp_tool_approval_callsite_mode(AppToolApproval::Approve, &turn_context),
|
||||
"mcp_tool_call__always_allow"
|
||||
);
|
||||
|
||||
turn_context
|
||||
.approval_policy
|
||||
.set(AskForApproval::Never)
|
||||
.expect("test setup should allow updating approval policy");
|
||||
turn_context
|
||||
.sandbox_policy
|
||||
.set(SandboxPolicy::DangerFullAccess)
|
||||
.expect("test setup should allow updating sandbox policy");
|
||||
|
||||
assert_eq!(
|
||||
mcp_tool_approval_callsite_mode(AppToolApproval::Auto, &turn_context),
|
||||
"mcp_tool_call__full_access"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declined_elicitation_response_stays_decline() {
|
||||
let response = parse_mcp_tool_approval_elicitation_response(
|
||||
@@ -1035,6 +1067,83 @@ async fn approve_mode_blocks_when_arc_returns_interrupt_for_model() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_access_auto_mode_blocks_when_arc_returns_interrupt_for_model() {
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/codex/safety/arc"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"outcome": "steer-model",
|
||||
"short_reason": "needs approval",
|
||||
"rationale": "high-risk action",
|
||||
"risk_score": 96,
|
||||
"risk_level": "critical",
|
||||
"evidence": [{
|
||||
"message": "dangerous_tool",
|
||||
"why": "high-risk action",
|
||||
}],
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let (session, mut turn_context) = make_session_and_context().await;
|
||||
turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth(
|
||||
crate::CodexAuth::create_dummy_chatgpt_auth_for_testing(),
|
||||
));
|
||||
turn_context
|
||||
.approval_policy
|
||||
.set(AskForApproval::Never)
|
||||
.expect("test setup should allow updating approval policy");
|
||||
turn_context
|
||||
.sandbox_policy
|
||||
.set(SandboxPolicy::DangerFullAccess)
|
||||
.expect("test setup should allow updating sandbox policy");
|
||||
let mut config = (*turn_context.config).clone();
|
||||
config.chatgpt_base_url = server.uri();
|
||||
turn_context.config = Arc::new(config);
|
||||
|
||||
let session = Arc::new(session);
|
||||
let turn_context = Arc::new(turn_context);
|
||||
let invocation = McpInvocation {
|
||||
server: CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
tool: "dangerous_tool".to_string(),
|
||||
arguments: Some(serde_json::json!({ "id": 1 })),
|
||||
};
|
||||
let metadata = McpToolApprovalMetadata {
|
||||
annotations: Some(annotations(Some(false), Some(true), Some(true))),
|
||||
connector_id: Some("calendar".to_string()),
|
||||
connector_name: Some("Calendar".to_string()),
|
||||
connector_description: Some("Manage events".to_string()),
|
||||
tool_title: Some("Dangerous Tool".to_string()),
|
||||
tool_description: Some("Performs a risky action.".to_string()),
|
||||
codex_apps_meta: None,
|
||||
};
|
||||
|
||||
let decision = maybe_request_mcp_tool_approval(
|
||||
&session,
|
||||
&turn_context,
|
||||
"call-2",
|
||||
&invocation,
|
||||
Some(&metadata),
|
||||
AppToolApproval::Auto,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
decision,
|
||||
Some(McpToolApprovalDecision::BlockedBySafetyMonitor(
|
||||
"Tool call was cancelled because of safety risks: high-risk action".to_string(),
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn approve_mode_routes_arc_ask_user_to_guardian_when_guardian_reviewer_is_enabled() {
|
||||
use wiremock::Mock;
|
||||
|
||||
@@ -38,6 +38,7 @@ async fn write_session_with_user_event(codex_home: &Path) -> io::Result<()> {
|
||||
originator: "test_originator".to_string(),
|
||||
cli_version: "test_version".to_string(),
|
||||
source: SessionSource::Cli,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
model_provider: None,
|
||||
|
||||
@@ -1,356 +0,0 @@
|
||||
use crate::default_client::build_reqwest_client;
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use std::fs;
|
||||
use std::io::Cursor;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use zip::ZipArchive;
|
||||
|
||||
const GITHUB_API_BASE_URL: &str = "https://api.github.com";
|
||||
const GITHUB_API_ACCEPT_HEADER: &str = "application/vnd.github+json";
|
||||
const GITHUB_API_VERSION_HEADER: &str = "2022-11-28";
|
||||
const OPENAI_PLUGINS_OWNER: &str = "openai";
|
||||
const OPENAI_PLUGINS_REPO: &str = "plugins";
|
||||
const CURATED_PLUGINS_RELATIVE_DIR: &str = ".tmp/plugins";
|
||||
const CURATED_PLUGINS_SHA_FILE: &str = ".tmp/plugins.sha";
|
||||
const CURATED_PLUGINS_HTTP_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GitHubRepositorySummary {
|
||||
default_branch: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GitHubGitRefSummary {
|
||||
object: GitHubGitRefObject,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GitHubGitRefObject {
|
||||
sha: String,
|
||||
}
|
||||
|
||||
pub(crate) fn curated_plugins_repo_path(codex_home: &Path) -> PathBuf {
|
||||
codex_home.join(CURATED_PLUGINS_RELATIVE_DIR)
|
||||
}
|
||||
|
||||
pub(crate) fn read_curated_plugins_sha(codex_home: &Path) -> Option<String> {
|
||||
read_sha_file(codex_home.join(CURATED_PLUGINS_SHA_FILE).as_path())
|
||||
}
|
||||
|
||||
pub(crate) fn sync_openai_plugins_repo(codex_home: &Path) -> Result<String, String> {
|
||||
sync_openai_plugins_repo_with_api_base_url(codex_home, GITHUB_API_BASE_URL)
|
||||
}
|
||||
|
||||
fn sync_openai_plugins_repo_with_api_base_url(
|
||||
codex_home: &Path,
|
||||
api_base_url: &str,
|
||||
) -> Result<String, String> {
|
||||
let repo_path = curated_plugins_repo_path(codex_home);
|
||||
let sha_path = codex_home.join(CURATED_PLUGINS_SHA_FILE);
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|err| format!("failed to create curated plugins sync runtime: {err}"))?;
|
||||
let remote_sha = runtime.block_on(fetch_curated_repo_remote_sha(api_base_url))?;
|
||||
let local_sha = read_sha_file(&sha_path);
|
||||
|
||||
if local_sha.as_deref() == Some(remote_sha.as_str()) && repo_path.is_dir() {
|
||||
return Ok(remote_sha);
|
||||
}
|
||||
|
||||
let Some(parent) = repo_path.parent() else {
|
||||
return Err(format!(
|
||||
"failed to determine curated plugins parent directory for {}",
|
||||
repo_path.display()
|
||||
));
|
||||
};
|
||||
fs::create_dir_all(parent).map_err(|err| {
|
||||
format!(
|
||||
"failed to create curated plugins parent directory {}: {err}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
let clone_dir = tempfile::Builder::new()
|
||||
.prefix("plugins-clone-")
|
||||
.tempdir_in(parent)
|
||||
.map_err(|err| {
|
||||
format!(
|
||||
"failed to create temporary curated plugins directory in {}: {err}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
let cloned_repo_path = clone_dir.path().join("repo");
|
||||
let zipball_bytes = runtime.block_on(fetch_curated_repo_zipball(api_base_url, &remote_sha))?;
|
||||
extract_zipball_to_dir(&zipball_bytes, &cloned_repo_path)?;
|
||||
|
||||
if !cloned_repo_path
|
||||
.join(".agents/plugins/marketplace.json")
|
||||
.is_file()
|
||||
{
|
||||
return Err(format!(
|
||||
"curated plugins archive missing marketplace manifest at {}",
|
||||
cloned_repo_path
|
||||
.join(".agents/plugins/marketplace.json")
|
||||
.display()
|
||||
));
|
||||
}
|
||||
|
||||
if repo_path.exists() {
|
||||
let backup_dir = tempfile::Builder::new()
|
||||
.prefix("plugins-backup-")
|
||||
.tempdir_in(parent)
|
||||
.map_err(|err| {
|
||||
format!(
|
||||
"failed to create curated plugins backup directory in {}: {err}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
let backup_repo_path = backup_dir.path().join("repo");
|
||||
|
||||
fs::rename(&repo_path, &backup_repo_path).map_err(|err| {
|
||||
format!(
|
||||
"failed to move previous curated plugins repo out of the way at {}: {err}",
|
||||
repo_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
if let Err(err) = fs::rename(&cloned_repo_path, &repo_path) {
|
||||
let rollback_result = fs::rename(&backup_repo_path, &repo_path);
|
||||
return match rollback_result {
|
||||
Ok(()) => Err(format!(
|
||||
"failed to activate new curated plugins repo at {}: {err}",
|
||||
repo_path.display()
|
||||
)),
|
||||
Err(rollback_err) => {
|
||||
let backup_path = backup_dir.keep().join("repo");
|
||||
Err(format!(
|
||||
"failed to activate new curated plugins repo at {}: {err}; failed to restore previous repo (left at {}): {rollback_err}",
|
||||
repo_path.display(),
|
||||
backup_path.display()
|
||||
))
|
||||
}
|
||||
};
|
||||
}
|
||||
} else {
|
||||
fs::rename(&cloned_repo_path, &repo_path).map_err(|err| {
|
||||
format!(
|
||||
"failed to activate curated plugins repo at {}: {err}",
|
||||
repo_path.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
if let Some(parent) = sha_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|err| {
|
||||
format!(
|
||||
"failed to create curated plugins sha directory {}: {err}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
fs::write(&sha_path, format!("{remote_sha}\n")).map_err(|err| {
|
||||
format!(
|
||||
"failed to write curated plugins sha file {}: {err}",
|
||||
sha_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(remote_sha)
|
||||
}
|
||||
|
||||
async fn fetch_curated_repo_remote_sha(api_base_url: &str) -> Result<String, String> {
|
||||
let api_base_url = api_base_url.trim_end_matches('/');
|
||||
let repo_url = format!("{api_base_url}/repos/{OPENAI_PLUGINS_OWNER}/{OPENAI_PLUGINS_REPO}");
|
||||
let client = build_reqwest_client();
|
||||
let repo_body = fetch_github_text(&client, &repo_url, "get curated plugins repository").await?;
|
||||
let repo_summary: GitHubRepositorySummary =
|
||||
serde_json::from_str(&repo_body).map_err(|err| {
|
||||
format!("failed to parse curated plugins repository response from {repo_url}: {err}")
|
||||
})?;
|
||||
if repo_summary.default_branch.is_empty() {
|
||||
return Err(format!(
|
||||
"curated plugins repository response from {repo_url} did not include a default branch"
|
||||
));
|
||||
}
|
||||
|
||||
let git_ref_url = format!("{repo_url}/git/ref/heads/{}", repo_summary.default_branch);
|
||||
let git_ref_body =
|
||||
fetch_github_text(&client, &git_ref_url, "get curated plugins HEAD ref").await?;
|
||||
let git_ref: GitHubGitRefSummary = serde_json::from_str(&git_ref_body).map_err(|err| {
|
||||
format!("failed to parse curated plugins ref response from {git_ref_url}: {err}")
|
||||
})?;
|
||||
if git_ref.object.sha.is_empty() {
|
||||
return Err(format!(
|
||||
"curated plugins ref response from {git_ref_url} did not include a HEAD sha"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(git_ref.object.sha)
|
||||
}
|
||||
|
||||
async fn fetch_curated_repo_zipball(
|
||||
api_base_url: &str,
|
||||
remote_sha: &str,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let api_base_url = api_base_url.trim_end_matches('/');
|
||||
let repo_url = format!("{api_base_url}/repos/{OPENAI_PLUGINS_OWNER}/{OPENAI_PLUGINS_REPO}");
|
||||
let zipball_url = format!("{repo_url}/zipball/{remote_sha}");
|
||||
let client = build_reqwest_client();
|
||||
fetch_github_bytes(&client, &zipball_url, "download curated plugins archive").await
|
||||
}
|
||||
|
||||
async fn fetch_github_text(client: &Client, url: &str, context: &str) -> Result<String, String> {
|
||||
let response = github_request(client, url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("failed to {context} from {url}: {err}"))?;
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
return Err(format!(
|
||||
"{context} from {url} failed with status {status}: {body}"
|
||||
));
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
async fn fetch_github_bytes(client: &Client, url: &str, context: &str) -> Result<Vec<u8>, String> {
|
||||
let response = github_request(client, url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("failed to {context} from {url}: {err}"))?;
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|err| format!("failed to read {context} response from {url}: {err}"))?;
|
||||
if !status.is_success() {
|
||||
let body_text = String::from_utf8_lossy(&body);
|
||||
return Err(format!(
|
||||
"{context} from {url} failed with status {status}: {body_text}"
|
||||
));
|
||||
}
|
||||
Ok(body.to_vec())
|
||||
}
|
||||
|
||||
fn github_request(client: &Client, url: &str) -> reqwest::RequestBuilder {
|
||||
client
|
||||
.get(url)
|
||||
.timeout(CURATED_PLUGINS_HTTP_TIMEOUT)
|
||||
.header("accept", GITHUB_API_ACCEPT_HEADER)
|
||||
.header("x-github-api-version", GITHUB_API_VERSION_HEADER)
|
||||
}
|
||||
|
||||
fn read_sha_file(sha_path: &Path) -> Option<String> {
|
||||
fs::read_to_string(sha_path)
|
||||
.ok()
|
||||
.map(|sha| sha.trim().to_string())
|
||||
.filter(|sha| !sha.is_empty())
|
||||
}
|
||||
|
||||
fn extract_zipball_to_dir(bytes: &[u8], destination: &Path) -> Result<(), String> {
|
||||
fs::create_dir_all(destination).map_err(|err| {
|
||||
format!(
|
||||
"failed to create curated plugins extraction directory {}: {err}",
|
||||
destination.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
let cursor = Cursor::new(bytes);
|
||||
let mut archive = ZipArchive::new(cursor)
|
||||
.map_err(|err| format!("failed to open curated plugins zip archive: {err}"))?;
|
||||
|
||||
for index in 0..archive.len() {
|
||||
let mut entry = archive
|
||||
.by_index(index)
|
||||
.map_err(|err| format!("failed to read curated plugins zip entry: {err}"))?;
|
||||
let Some(relative_path) = entry.enclosed_name() else {
|
||||
return Err(format!(
|
||||
"curated plugins zip entry `{}` escapes extraction root",
|
||||
entry.name()
|
||||
));
|
||||
};
|
||||
|
||||
let mut components = relative_path.components();
|
||||
let Some(Component::Normal(_)) = components.next() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let output_relative = components.fold(PathBuf::new(), |mut path, component| {
|
||||
if let Component::Normal(segment) = component {
|
||||
path.push(segment);
|
||||
}
|
||||
path
|
||||
});
|
||||
if output_relative.as_os_str().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let output_path = destination.join(&output_relative);
|
||||
if entry.is_dir() {
|
||||
fs::create_dir_all(&output_path).map_err(|err| {
|
||||
format!(
|
||||
"failed to create curated plugins directory {}: {err}",
|
||||
output_path.display()
|
||||
)
|
||||
})?;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(parent) = output_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|err| {
|
||||
format!(
|
||||
"failed to create curated plugins directory {}: {err}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
let mut output = fs::File::create(&output_path).map_err(|err| {
|
||||
format!(
|
||||
"failed to create curated plugins file {}: {err}",
|
||||
output_path.display()
|
||||
)
|
||||
})?;
|
||||
std::io::copy(&mut entry, &mut output).map_err(|err| {
|
||||
format!(
|
||||
"failed to write curated plugins file {}: {err}",
|
||||
output_path.display()
|
||||
)
|
||||
})?;
|
||||
apply_zip_permissions(&entry, &output_path)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn apply_zip_permissions(entry: &zip::read::ZipFile<'_>, output_path: &Path) -> Result<(), String> {
|
||||
let Some(mode) = entry.unix_mode() else {
|
||||
return Ok(());
|
||||
};
|
||||
fs::set_permissions(output_path, fs::Permissions::from_mode(mode)).map_err(|err| {
|
||||
format!(
|
||||
"failed to set permissions on curated plugins file {}: {err}",
|
||||
output_path.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn apply_zip_permissions(
|
||||
_entry: &zip::read::ZipFile<'_>,
|
||||
_output_path: &Path,
|
||||
) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "curated_repo_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,159 +0,0 @@
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::io::Write;
|
||||
use tempfile::tempdir;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
use zip::ZipWriter;
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
#[test]
|
||||
fn curated_plugins_repo_path_uses_codex_home_tmp_dir() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
assert_eq!(
|
||||
curated_plugins_repo_path(tmp.path()),
|
||||
tmp.path().join(".tmp/plugins")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_curated_plugins_sha_reads_trimmed_sha_file() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
fs::create_dir_all(tmp.path().join(".tmp")).expect("create tmp");
|
||||
fs::write(tmp.path().join(".tmp/plugins.sha"), "abc123\n").expect("write sha");
|
||||
|
||||
assert_eq!(
|
||||
read_curated_plugins_sha(tmp.path()).as_deref(),
|
||||
Some("abc123")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_openai_plugins_repo_downloads_zipball_and_records_sha() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let server = MockServer::start().await;
|
||||
let sha = "0123456789abcdef0123456789abcdef01234567";
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/openai/plugins"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"default_branch":"main"}"#))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/openai/plugins/git/ref/heads/main"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(format!(r#"{{"object":{{"sha":"{sha}"}}}}"#)),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/repos/openai/plugins/zipball/{sha}")))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "application/zip")
|
||||
.set_body_bytes(curated_repo_zipball_bytes(sha)),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let server_uri = server.uri();
|
||||
let tmp_path = tmp.path().to_path_buf();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
sync_openai_plugins_repo_with_api_base_url(tmp_path.as_path(), &server_uri)
|
||||
})
|
||||
.await
|
||||
.expect("sync task should join")
|
||||
.expect("sync should succeed");
|
||||
|
||||
let repo_path = curated_plugins_repo_path(tmp.path());
|
||||
assert!(repo_path.join(".agents/plugins/marketplace.json").is_file());
|
||||
assert!(
|
||||
repo_path
|
||||
.join("plugins/gmail/.codex-plugin/plugin.json")
|
||||
.is_file()
|
||||
);
|
||||
assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_openai_plugins_repo_skips_archive_download_when_sha_matches() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let repo_path = curated_plugins_repo_path(tmp.path());
|
||||
fs::create_dir_all(repo_path.join(".agents/plugins")).expect("create repo");
|
||||
fs::write(
|
||||
repo_path.join(".agents/plugins/marketplace.json"),
|
||||
r#"{"name":"openai-curated","plugins":[]}"#,
|
||||
)
|
||||
.expect("write marketplace");
|
||||
fs::create_dir_all(tmp.path().join(".tmp")).expect("create tmp");
|
||||
let sha = "fedcba9876543210fedcba9876543210fedcba98";
|
||||
fs::write(tmp.path().join(".tmp/plugins.sha"), format!("{sha}\n")).expect("write sha");
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/openai/plugins"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"default_branch":"main"}"#))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/openai/plugins/git/ref/heads/main"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(format!(r#"{{"object":{{"sha":"{sha}"}}}}"#)),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let server_uri = server.uri();
|
||||
let tmp_path = tmp.path().to_path_buf();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
sync_openai_plugins_repo_with_api_base_url(tmp_path.as_path(), &server_uri)
|
||||
})
|
||||
.await
|
||||
.expect("sync task should join")
|
||||
.expect("sync should succeed");
|
||||
|
||||
assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha));
|
||||
assert!(repo_path.join(".agents/plugins/marketplace.json").is_file());
|
||||
}
|
||||
|
||||
fn curated_repo_zipball_bytes(sha: &str) -> Vec<u8> {
|
||||
let cursor = Cursor::new(Vec::new());
|
||||
let mut writer = ZipWriter::new(cursor);
|
||||
let options = SimpleFileOptions::default();
|
||||
let root = format!("openai-plugins-{sha}");
|
||||
writer
|
||||
.start_file(format!("{root}/.agents/plugins/marketplace.json"), options)
|
||||
.expect("start marketplace entry");
|
||||
writer
|
||||
.write_all(
|
||||
br#"{
|
||||
"name": "openai-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "gmail",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/gmail"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.expect("write marketplace");
|
||||
writer
|
||||
.start_file(
|
||||
format!("{root}/plugins/gmail/.codex-plugin/plugin.json"),
|
||||
options,
|
||||
)
|
||||
.expect("start plugin manifest entry");
|
||||
writer
|
||||
.write_all(br#"{"name":"gmail"}"#)
|
||||
.expect("write plugin manifest");
|
||||
|
||||
writer.finish().expect("finish zip writer").into_inner()
|
||||
}
|
||||
@@ -60,6 +60,7 @@ use std::sync::RwLock;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Instant;
|
||||
use tokio::sync::Mutex;
|
||||
use toml_edit::value;
|
||||
use tracing::info;
|
||||
use tracing::warn;
|
||||
@@ -463,6 +464,7 @@ pub struct PluginsManager {
|
||||
store: PluginStore,
|
||||
featured_plugin_ids_cache: RwLock<Option<CachedFeaturedPluginIds>>,
|
||||
cached_enabled_outcome: RwLock<Option<PluginLoadOutcome>>,
|
||||
remote_sync_lock: Mutex<()>,
|
||||
restriction_product: Option<Product>,
|
||||
analytics_events_client: RwLock<Option<AnalyticsEventsClient>>,
|
||||
}
|
||||
@@ -488,6 +490,7 @@ impl PluginsManager {
|
||||
store: PluginStore::new(codex_home),
|
||||
featured_plugin_ids_cache: RwLock::new(None),
|
||||
cached_enabled_outcome: RwLock::new(None),
|
||||
remote_sync_lock: Mutex::new(()),
|
||||
restriction_product,
|
||||
analytics_events_client: RwLock::new(None),
|
||||
}
|
||||
@@ -619,7 +622,8 @@ impl PluginsManager {
|
||||
if let Some(featured_plugin_ids) = self.cached_featured_plugin_ids(&cache_key) {
|
||||
return Ok(featured_plugin_ids);
|
||||
}
|
||||
let featured_plugin_ids = fetch_remote_featured_plugin_ids(config, auth).await?;
|
||||
let featured_plugin_ids =
|
||||
fetch_remote_featured_plugin_ids(config, auth, self.restriction_product).await?;
|
||||
self.write_featured_plugin_ids_cache(cache_key, &featured_plugin_ids);
|
||||
Ok(featured_plugin_ids)
|
||||
}
|
||||
@@ -777,6 +781,8 @@ impl PluginsManager {
|
||||
auth: Option<&CodexAuth>,
|
||||
additive_only: bool,
|
||||
) -> Result<RemotePluginSyncResult, PluginRemoteSyncError> {
|
||||
let _remote_sync_guard = self.remote_sync_lock.lock().await;
|
||||
|
||||
if !config.features.enabled(Feature::Plugins) {
|
||||
return Ok(RemotePluginSyncResult::default());
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::header;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
use wiremock::matchers::query_param;
|
||||
|
||||
fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) {
|
||||
let plugin_root = root.join(dir_name);
|
||||
@@ -1899,6 +1900,74 @@ plugins = true
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn featured_plugin_ids_for_config_uses_restriction_product_query_param() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_file(
|
||||
&tmp.path().join(CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
"#,
|
||||
);
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/plugins/featured"))
|
||||
.and(query_param("platform", "chat"))
|
||||
.and(header("authorization", "Bearer Access Token"))
|
||||
.and(header("chatgpt-account-id", "account_id"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(r#"["chat-plugin"]"#))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let mut config = load_config(tmp.path(), tmp.path()).await;
|
||||
config.chatgpt_base_url = format!("{}/backend-api/", server.uri());
|
||||
let manager = PluginsManager::new_with_restriction_product(
|
||||
tmp.path().to_path_buf(),
|
||||
Some(Product::Chatgpt),
|
||||
);
|
||||
|
||||
let featured_plugin_ids = manager
|
||||
.featured_plugin_ids_for_config(
|
||||
&config,
|
||||
Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(featured_plugin_ids, vec!["chat-plugin".to_string()]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn featured_plugin_ids_for_config_defaults_query_param_to_codex() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_file(
|
||||
&tmp.path().join(CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
"#,
|
||||
);
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/plugins/featured"))
|
||||
.and(query_param("platform", "codex"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(r#"["codex-plugin"]"#))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let mut config = load_config(tmp.path(), tmp.path()).await;
|
||||
config.chatgpt_base_url = format!("{}/backend-api/", server.uri());
|
||||
let manager = PluginsManager::new_with_restriction_product(tmp.path().to_path_buf(), None);
|
||||
|
||||
let featured_plugin_ids = manager
|
||||
.featured_plugin_ids_for_config(&config, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(featured_plugin_ids, vec!["codex-plugin".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_curated_plugin_cache_replaces_existing_local_version_with_sha() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
mod curated_repo;
|
||||
mod discoverable;
|
||||
mod injection;
|
||||
mod manager;
|
||||
@@ -12,9 +11,6 @@ mod store;
|
||||
pub(crate) mod test_support;
|
||||
mod toggles;
|
||||
|
||||
pub(crate) use curated_repo::curated_plugins_repo_path;
|
||||
pub(crate) use curated_repo::read_curated_plugins_sha;
|
||||
pub(crate) use curated_repo::sync_openai_plugins_repo;
|
||||
pub(crate) use discoverable::list_tool_suggest_discoverable_plugins;
|
||||
pub(crate) use injection::build_plugin_injections;
|
||||
pub use manager::AppConnectorId;
|
||||
@@ -52,5 +48,8 @@ pub use remote::RemotePluginFetchError;
|
||||
pub use remote::fetch_remote_featured_plugin_ids;
|
||||
pub(crate) use render::render_explicit_plugin_instructions;
|
||||
pub(crate) use render::render_plugins_section;
|
||||
pub(crate) use startup_sync::curated_plugins_repo_path;
|
||||
pub(crate) use startup_sync::read_curated_plugins_sha;
|
||||
pub(crate) use startup_sync::sync_openai_plugins_repo;
|
||||
pub use store::PluginId;
|
||||
pub use toggles::collect_plugin_enabled_candidates;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::auth::CodexAuth;
|
||||
use crate::config::Config;
|
||||
use crate::default_client::build_reqwest_client;
|
||||
use codex_protocol::protocol::Product;
|
||||
use serde::Deserialize;
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
@@ -162,12 +163,17 @@ pub(crate) async fn fetch_remote_plugin_status(
|
||||
pub async fn fetch_remote_featured_plugin_ids(
|
||||
config: &Config,
|
||||
auth: Option<&CodexAuth>,
|
||||
product: Option<Product>,
|
||||
) -> Result<Vec<String>, RemotePluginFetchError> {
|
||||
let base_url = config.chatgpt_base_url.trim_end_matches('/');
|
||||
let url = format!("{base_url}/plugins/featured");
|
||||
let client = build_reqwest_client();
|
||||
let mut request = client
|
||||
.get(&url)
|
||||
.query(&[(
|
||||
"platform",
|
||||
product.unwrap_or(Product::Codex).to_app_platform(),
|
||||
)])
|
||||
.timeout(REMOTE_FEATURED_PLUGIN_FETCH_TIMEOUT);
|
||||
|
||||
if let Some(auth) = auth.filter(|auth| auth.is_chatgpt_auth()) {
|
||||
|
||||
@@ -1,19 +1,143 @@
|
||||
use crate::default_client::build_reqwest_client;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::process::Output;
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use tracing::info;
|
||||
use tracing::warn;
|
||||
use zip::ZipArchive;
|
||||
|
||||
use crate::AuthManager;
|
||||
use crate::config::Config;
|
||||
|
||||
use super::PluginsManager;
|
||||
|
||||
const GITHUB_API_BASE_URL: &str = "https://api.github.com";
|
||||
const GITHUB_API_ACCEPT_HEADER: &str = "application/vnd.github+json";
|
||||
const GITHUB_API_VERSION_HEADER: &str = "2022-11-28";
|
||||
const OPENAI_PLUGINS_OWNER: &str = "openai";
|
||||
const OPENAI_PLUGINS_REPO: &str = "plugins";
|
||||
const CURATED_PLUGINS_RELATIVE_DIR: &str = ".tmp/plugins";
|
||||
const CURATED_PLUGINS_SHA_FILE: &str = ".tmp/plugins.sha";
|
||||
const CURATED_PLUGINS_GIT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const CURATED_PLUGINS_HTTP_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE: &str = ".tmp/app-server-remote-plugin-sync-v1";
|
||||
const STARTUP_REMOTE_PLUGIN_SYNC_PREREQUISITE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GitHubRepositorySummary {
|
||||
default_branch: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GitHubGitRefSummary {
|
||||
object: GitHubGitRefObject,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GitHubGitRefObject {
|
||||
sha: String,
|
||||
}
|
||||
|
||||
pub(crate) fn curated_plugins_repo_path(codex_home: &Path) -> PathBuf {
|
||||
codex_home.join(CURATED_PLUGINS_RELATIVE_DIR)
|
||||
}
|
||||
|
||||
pub(crate) fn read_curated_plugins_sha(codex_home: &Path) -> Option<String> {
|
||||
read_sha_file(codex_home.join(CURATED_PLUGINS_SHA_FILE).as_path())
|
||||
}
|
||||
|
||||
pub(crate) fn sync_openai_plugins_repo(codex_home: &Path) -> Result<String, String> {
|
||||
sync_openai_plugins_repo_with_transport_overrides(codex_home, "git", GITHUB_API_BASE_URL)
|
||||
}
|
||||
|
||||
fn sync_openai_plugins_repo_with_transport_overrides(
|
||||
codex_home: &Path,
|
||||
git_binary: &str,
|
||||
api_base_url: &str,
|
||||
) -> Result<String, String> {
|
||||
match sync_openai_plugins_repo_via_git(codex_home, git_binary) {
|
||||
Ok(remote_sha) => Ok(remote_sha),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
error = %err,
|
||||
git_binary,
|
||||
"git sync failed for curated plugin sync; falling back to GitHub HTTP"
|
||||
);
|
||||
sync_openai_plugins_repo_via_http(codex_home, api_base_url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_openai_plugins_repo_via_git(codex_home: &Path, git_binary: &str) -> Result<String, String> {
|
||||
let repo_path = curated_plugins_repo_path(codex_home);
|
||||
let sha_path = codex_home.join(CURATED_PLUGINS_SHA_FILE);
|
||||
let remote_sha = git_ls_remote_head_sha(git_binary)?;
|
||||
let local_sha = read_local_git_or_sha_file(&repo_path, &sha_path, git_binary);
|
||||
|
||||
if local_sha.as_deref() == Some(remote_sha.as_str()) && repo_path.join(".git").is_dir() {
|
||||
return Ok(remote_sha);
|
||||
}
|
||||
|
||||
let cloned_repo_path = prepare_curated_repo_parent_and_temp_dir(&repo_path)?;
|
||||
let clone_output = run_git_command_with_timeout(
|
||||
Command::new(git_binary)
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.arg("clone")
|
||||
.arg("--depth")
|
||||
.arg("1")
|
||||
.arg("https://github.com/openai/plugins.git")
|
||||
.arg(&cloned_repo_path),
|
||||
"git clone curated plugins repo",
|
||||
CURATED_PLUGINS_GIT_TIMEOUT,
|
||||
)?;
|
||||
ensure_git_success(&clone_output, "git clone curated plugins repo")?;
|
||||
|
||||
let cloned_sha = git_head_sha(&cloned_repo_path, git_binary)?;
|
||||
if cloned_sha != remote_sha {
|
||||
return Err(format!(
|
||||
"curated plugins clone HEAD mismatch: expected {remote_sha}, got {cloned_sha}"
|
||||
));
|
||||
}
|
||||
|
||||
ensure_marketplace_manifest_exists(&cloned_repo_path)?;
|
||||
activate_curated_repo(&repo_path, &cloned_repo_path)?;
|
||||
write_curated_plugins_sha(&sha_path, &remote_sha)?;
|
||||
Ok(remote_sha)
|
||||
}
|
||||
|
||||
fn sync_openai_plugins_repo_via_http(
|
||||
codex_home: &Path,
|
||||
api_base_url: &str,
|
||||
) -> Result<String, String> {
|
||||
let repo_path = curated_plugins_repo_path(codex_home);
|
||||
let sha_path = codex_home.join(CURATED_PLUGINS_SHA_FILE);
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|err| format!("failed to create curated plugins sync runtime: {err}"))?;
|
||||
let remote_sha = runtime.block_on(fetch_curated_repo_remote_sha(api_base_url))?;
|
||||
let local_sha = read_sha_file(&sha_path);
|
||||
|
||||
if local_sha.as_deref() == Some(remote_sha.as_str()) && repo_path.is_dir() {
|
||||
return Ok(remote_sha);
|
||||
}
|
||||
|
||||
let cloned_repo_path = prepare_curated_repo_parent_and_temp_dir(&repo_path)?;
|
||||
let zipball_bytes = runtime.block_on(fetch_curated_repo_zipball(api_base_url, &remote_sha))?;
|
||||
extract_zipball_to_dir(&zipball_bytes, &cloned_repo_path)?;
|
||||
ensure_marketplace_manifest_exists(&cloned_repo_path)?;
|
||||
activate_curated_repo(&repo_path, &cloned_repo_path)?;
|
||||
write_curated_plugins_sha(&sha_path, &remote_sha)?;
|
||||
Ok(remote_sha)
|
||||
}
|
||||
|
||||
pub(super) fn start_startup_remote_plugin_sync_once(
|
||||
manager: Arc<PluginsManager>,
|
||||
codex_home: PathBuf,
|
||||
@@ -103,93 +227,438 @@ async fn write_startup_remote_plugin_sync_marker(codex_home: &Path) -> std::io::
|
||||
tokio::fs::write(marker_path, b"ok\n").await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::auth::CodexAuth;
|
||||
use crate::config::CONFIG_TOML_FILE;
|
||||
use crate::plugins::curated_plugins_repo_path;
|
||||
use crate::plugins::test_support::TEST_CURATED_PLUGIN_SHA;
|
||||
use crate::plugins::test_support::write_curated_plugin_sha;
|
||||
use crate::plugins::test_support::write_file;
|
||||
use crate::plugins::test_support::write_openai_curated_marketplace;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::tempdir;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::header;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
fn prepare_curated_repo_parent_and_temp_dir(repo_path: &Path) -> Result<PathBuf, String> {
|
||||
let Some(parent) = repo_path.parent() else {
|
||||
return Err(format!(
|
||||
"failed to determine curated plugins parent directory for {}",
|
||||
repo_path.display()
|
||||
));
|
||||
};
|
||||
std::fs::create_dir_all(parent).map_err(|err| {
|
||||
format!(
|
||||
"failed to create curated plugins parent directory {}: {err}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_remote_plugin_sync_writes_marker_and_reconciles_state() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let curated_root = curated_plugins_repo_path(tmp.path());
|
||||
write_openai_curated_marketplace(&curated_root, &["linear"]);
|
||||
write_curated_plugin_sha(tmp.path());
|
||||
write_file(
|
||||
&tmp.path().join(CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
let clone_dir = tempfile::Builder::new()
|
||||
.prefix("plugins-clone-")
|
||||
.tempdir_in(parent)
|
||||
.map_err(|err| {
|
||||
format!(
|
||||
"failed to create temporary curated plugins directory in {}: {err}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
Ok(clone_dir.keep())
|
||||
}
|
||||
|
||||
[plugins."linear@openai-curated"]
|
||||
enabled = false
|
||||
"#,
|
||||
);
|
||||
fn ensure_marketplace_manifest_exists(repo_path: &Path) -> Result<(), String> {
|
||||
if repo_path.join(".agents/plugins/marketplace.json").is_file() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(format!(
|
||||
"curated plugins archive missing marketplace manifest at {}",
|
||||
repo_path.join(".agents/plugins/marketplace.json").display()
|
||||
))
|
||||
}
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/plugins/list"))
|
||||
.and(header("authorization", "Bearer Access Token"))
|
||||
.and(header("chatgpt-account-id", "account_id"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(
|
||||
r#"[
|
||||
{"id":"1","name":"linear","marketplace_name":"openai-curated","version":"1.0.0","enabled":true}
|
||||
]"#,
|
||||
))
|
||||
.mount(&server)
|
||||
.await;
|
||||
fn activate_curated_repo(repo_path: &Path, staged_repo_path: &Path) -> Result<(), String> {
|
||||
if repo_path.exists() {
|
||||
let parent = repo_path.parent().ok_or_else(|| {
|
||||
format!(
|
||||
"failed to determine curated plugins parent directory for {}",
|
||||
repo_path.display()
|
||||
)
|
||||
})?;
|
||||
let backup_dir = tempfile::Builder::new()
|
||||
.prefix("plugins-backup-")
|
||||
.tempdir_in(parent)
|
||||
.map_err(|err| {
|
||||
format!(
|
||||
"failed to create curated plugins backup directory in {}: {err}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
let backup_repo_path = backup_dir.path().join("repo");
|
||||
|
||||
let mut config = crate::plugins::test_support::load_plugins_config(tmp.path()).await;
|
||||
config.chatgpt_base_url = format!("{}/backend-api/", server.uri());
|
||||
let manager = Arc::new(PluginsManager::new(tmp.path().to_path_buf()));
|
||||
let auth_manager =
|
||||
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
||||
std::fs::rename(repo_path, &backup_repo_path).map_err(|err| {
|
||||
format!(
|
||||
"failed to move previous curated plugins repo out of the way at {}: {err}",
|
||||
repo_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
start_startup_remote_plugin_sync_once(
|
||||
Arc::clone(&manager),
|
||||
tmp.path().to_path_buf(),
|
||||
config,
|
||||
auth_manager,
|
||||
);
|
||||
|
||||
let marker_path = tmp.path().join(STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE);
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
if marker_path.is_file() {
|
||||
break;
|
||||
if let Err(err) = std::fs::rename(staged_repo_path, repo_path) {
|
||||
let rollback_result = std::fs::rename(&backup_repo_path, repo_path);
|
||||
return match rollback_result {
|
||||
Ok(()) => Err(format!(
|
||||
"failed to activate new curated plugins repo at {}: {err}",
|
||||
repo_path.display()
|
||||
)),
|
||||
Err(rollback_err) => {
|
||||
let backup_path = backup_dir.keep().join("repo");
|
||||
Err(format!(
|
||||
"failed to activate new curated plugins repo at {}: {err}; failed to restore previous repo (left at {}): {rollback_err}",
|
||||
repo_path.display(),
|
||||
backup_path.display()
|
||||
))
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
};
|
||||
}
|
||||
} else {
|
||||
std::fs::rename(staged_repo_path, repo_path).map_err(|err| {
|
||||
format!(
|
||||
"failed to activate curated plugins repo at {}: {err}",
|
||||
repo_path.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_curated_plugins_sha(sha_path: &Path, remote_sha: &str) -> Result<(), String> {
|
||||
if let Some(parent) = sha_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|err| {
|
||||
format!(
|
||||
"failed to create curated plugins sha directory {}: {err}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
std::fs::write(sha_path, format!("{remote_sha}\n")).map_err(|err| {
|
||||
format!(
|
||||
"failed to write curated plugins sha file {}: {err}",
|
||||
sha_path.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn read_local_git_or_sha_file(
|
||||
repo_path: &Path,
|
||||
sha_path: &Path,
|
||||
git_binary: &str,
|
||||
) -> Option<String> {
|
||||
if repo_path.join(".git").is_dir()
|
||||
&& let Ok(sha) = git_head_sha(repo_path, git_binary)
|
||||
{
|
||||
return Some(sha);
|
||||
}
|
||||
|
||||
read_sha_file(sha_path)
|
||||
}
|
||||
|
||||
fn git_ls_remote_head_sha(git_binary: &str) -> Result<String, String> {
|
||||
let output = run_git_command_with_timeout(
|
||||
Command::new(git_binary)
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.arg("ls-remote")
|
||||
.arg("https://github.com/openai/plugins.git")
|
||||
.arg("HEAD"),
|
||||
"git ls-remote curated plugins repo",
|
||||
CURATED_PLUGINS_GIT_TIMEOUT,
|
||||
)?;
|
||||
ensure_git_success(&output, "git ls-remote curated plugins repo")?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let Some(first_line) = stdout.lines().next() else {
|
||||
return Err("git ls-remote returned empty output for curated plugins repo".to_string());
|
||||
};
|
||||
let Some((sha, _)) = first_line.split_once('\t') else {
|
||||
return Err(format!(
|
||||
"unexpected git ls-remote output for curated plugins repo: {first_line}"
|
||||
));
|
||||
};
|
||||
if sha.is_empty() {
|
||||
return Err("git ls-remote returned empty sha for curated plugins repo".to_string());
|
||||
}
|
||||
Ok(sha.to_string())
|
||||
}
|
||||
|
||||
fn git_head_sha(repo_path: &Path, git_binary: &str) -> Result<String, String> {
|
||||
let output = Command::new(git_binary)
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.arg("-C")
|
||||
.arg(repo_path)
|
||||
.arg("rev-parse")
|
||||
.arg("HEAD")
|
||||
.output()
|
||||
.map_err(|err| {
|
||||
format!(
|
||||
"failed to run git rev-parse HEAD in {}: {err}",
|
||||
repo_path.display()
|
||||
)
|
||||
})?;
|
||||
ensure_git_success(&output, "git rev-parse HEAD")?;
|
||||
|
||||
let sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if sha.is_empty() {
|
||||
return Err(format!(
|
||||
"git rev-parse HEAD returned empty output in {}",
|
||||
repo_path.display()
|
||||
));
|
||||
}
|
||||
Ok(sha)
|
||||
}
|
||||
|
||||
fn run_git_command_with_timeout(
|
||||
command: &mut Command,
|
||||
context: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<Output, String> {
|
||||
let mut child = command
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|err| format!("failed to run {context}: {err}"))?;
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => {
|
||||
return child
|
||||
.wait_with_output()
|
||||
.map_err(|err| format!("failed to wait for {context}: {err}"));
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("marker should be written");
|
||||
Ok(None) => {}
|
||||
Err(err) => return Err(format!("failed to poll {context}: {err}")),
|
||||
}
|
||||
|
||||
assert!(
|
||||
tmp.path()
|
||||
.join(format!(
|
||||
"plugins/cache/openai-curated/linear/{TEST_CURATED_PLUGIN_SHA}"
|
||||
if start.elapsed() >= timeout {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => {
|
||||
return child
|
||||
.wait_with_output()
|
||||
.map_err(|err| format!("failed to wait for {context}: {err}"));
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(err) => return Err(format!("failed to poll {context}: {err}")),
|
||||
}
|
||||
|
||||
let _ = child.kill();
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.map_err(|err| format!("failed to wait for {context} after timeout: {err}"))?;
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
return if stderr.is_empty() {
|
||||
Err(format!("{context} timed out after {}s", timeout.as_secs()))
|
||||
} else {
|
||||
Err(format!(
|
||||
"{context} timed out after {}s: {stderr}",
|
||||
timeout.as_secs()
|
||||
))
|
||||
.is_dir()
|
||||
);
|
||||
let config = std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE))
|
||||
.expect("config should exist");
|
||||
assert!(config.contains(r#"[plugins."linear@openai-curated"]"#));
|
||||
assert!(config.contains("enabled = true"));
|
||||
};
|
||||
}
|
||||
|
||||
let marker_contents =
|
||||
std::fs::read_to_string(marker_path).expect("marker should be readable");
|
||||
assert_eq!(marker_contents, "ok\n");
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_git_success(output: &Output, context: &str) -> Result<(), String> {
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
if stderr.is_empty() {
|
||||
Err(format!("{context} failed with status {}", output.status))
|
||||
} else {
|
||||
Err(format!(
|
||||
"{context} failed with status {}: {stderr}",
|
||||
output.status
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_curated_repo_remote_sha(api_base_url: &str) -> Result<String, String> {
|
||||
let api_base_url = api_base_url.trim_end_matches('/');
|
||||
let repo_url = format!("{api_base_url}/repos/{OPENAI_PLUGINS_OWNER}/{OPENAI_PLUGINS_REPO}");
|
||||
let client = build_reqwest_client();
|
||||
let repo_body = fetch_github_text(&client, &repo_url, "get curated plugins repository").await?;
|
||||
let repo_summary: GitHubRepositorySummary =
|
||||
serde_json::from_str(&repo_body).map_err(|err| {
|
||||
format!("failed to parse curated plugins repository response from {repo_url}: {err}")
|
||||
})?;
|
||||
if repo_summary.default_branch.is_empty() {
|
||||
return Err(format!(
|
||||
"curated plugins repository response from {repo_url} did not include a default branch"
|
||||
));
|
||||
}
|
||||
|
||||
let git_ref_url = format!("{repo_url}/git/ref/heads/{}", repo_summary.default_branch);
|
||||
let git_ref_body =
|
||||
fetch_github_text(&client, &git_ref_url, "get curated plugins HEAD ref").await?;
|
||||
let git_ref: GitHubGitRefSummary = serde_json::from_str(&git_ref_body).map_err(|err| {
|
||||
format!("failed to parse curated plugins ref response from {git_ref_url}: {err}")
|
||||
})?;
|
||||
if git_ref.object.sha.is_empty() {
|
||||
return Err(format!(
|
||||
"curated plugins ref response from {git_ref_url} did not include a HEAD sha"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(git_ref.object.sha)
|
||||
}
|
||||
|
||||
async fn fetch_curated_repo_zipball(
|
||||
api_base_url: &str,
|
||||
remote_sha: &str,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let api_base_url = api_base_url.trim_end_matches('/');
|
||||
let repo_url = format!("{api_base_url}/repos/{OPENAI_PLUGINS_OWNER}/{OPENAI_PLUGINS_REPO}");
|
||||
let zipball_url = format!("{repo_url}/zipball/{remote_sha}");
|
||||
let client = build_reqwest_client();
|
||||
fetch_github_bytes(&client, &zipball_url, "download curated plugins archive").await
|
||||
}
|
||||
|
||||
async fn fetch_github_text(client: &Client, url: &str, context: &str) -> Result<String, String> {
|
||||
let response = github_request(client, url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("failed to {context} from {url}: {err}"))?;
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
return Err(format!(
|
||||
"{context} from {url} failed with status {status}: {body}"
|
||||
));
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
async fn fetch_github_bytes(client: &Client, url: &str, context: &str) -> Result<Vec<u8>, String> {
|
||||
let response = github_request(client, url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("failed to {context} from {url}: {err}"))?;
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|err| format!("failed to read {context} response from {url}: {err}"))?;
|
||||
if !status.is_success() {
|
||||
let body_text = String::from_utf8_lossy(&body);
|
||||
return Err(format!(
|
||||
"{context} from {url} failed with status {status}: {body_text}"
|
||||
));
|
||||
}
|
||||
Ok(body.to_vec())
|
||||
}
|
||||
|
||||
fn github_request(client: &Client, url: &str) -> reqwest::RequestBuilder {
|
||||
client
|
||||
.get(url)
|
||||
.timeout(CURATED_PLUGINS_HTTP_TIMEOUT)
|
||||
.header("accept", GITHUB_API_ACCEPT_HEADER)
|
||||
.header("x-github-api-version", GITHUB_API_VERSION_HEADER)
|
||||
}
|
||||
|
||||
fn read_sha_file(sha_path: &Path) -> Option<String> {
|
||||
std::fs::read_to_string(sha_path)
|
||||
.ok()
|
||||
.map(|sha| sha.trim().to_string())
|
||||
.filter(|sha| !sha.is_empty())
|
||||
}
|
||||
|
||||
fn extract_zipball_to_dir(bytes: &[u8], destination: &Path) -> Result<(), String> {
|
||||
std::fs::create_dir_all(destination).map_err(|err| {
|
||||
format!(
|
||||
"failed to create curated plugins extraction directory {}: {err}",
|
||||
destination.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
let cursor = std::io::Cursor::new(bytes);
|
||||
let mut archive = ZipArchive::new(cursor)
|
||||
.map_err(|err| format!("failed to open curated plugins zip archive: {err}"))?;
|
||||
|
||||
for index in 0..archive.len() {
|
||||
let mut entry = archive
|
||||
.by_index(index)
|
||||
.map_err(|err| format!("failed to read curated plugins zip entry: {err}"))?;
|
||||
let Some(relative_path) = entry.enclosed_name() else {
|
||||
return Err(format!(
|
||||
"curated plugins zip entry `{}` escapes extraction root",
|
||||
entry.name()
|
||||
));
|
||||
};
|
||||
|
||||
let mut components = relative_path.components();
|
||||
let Some(std::path::Component::Normal(_)) = components.next() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let output_relative = components.fold(PathBuf::new(), |mut path, component| {
|
||||
if let std::path::Component::Normal(segment) = component {
|
||||
path.push(segment);
|
||||
}
|
||||
path
|
||||
});
|
||||
if output_relative.as_os_str().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let output_path = destination.join(&output_relative);
|
||||
if entry.is_dir() {
|
||||
std::fs::create_dir_all(&output_path).map_err(|err| {
|
||||
format!(
|
||||
"failed to create curated plugins directory {}: {err}",
|
||||
output_path.display()
|
||||
)
|
||||
})?;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(parent) = output_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|err| {
|
||||
format!(
|
||||
"failed to create curated plugins directory {}: {err}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
let mut output = std::fs::File::create(&output_path).map_err(|err| {
|
||||
format!(
|
||||
"failed to create curated plugins file {}: {err}",
|
||||
output_path.display()
|
||||
)
|
||||
})?;
|
||||
std::io::copy(&mut entry, &mut output).map_err(|err| {
|
||||
format!(
|
||||
"failed to write curated plugins file {}: {err}",
|
||||
output_path.display()
|
||||
)
|
||||
})?;
|
||||
apply_zip_permissions(&entry, &output_path)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn apply_zip_permissions(entry: &zip::read::ZipFile<'_>, output_path: &Path) -> Result<(), String> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let Some(mode) = entry.unix_mode() else {
|
||||
return Ok(());
|
||||
};
|
||||
std::fs::set_permissions(output_path, std::fs::Permissions::from_mode(mode)).map_err(|err| {
|
||||
format!(
|
||||
"failed to set permissions on curated plugins file {}: {err}",
|
||||
output_path.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn apply_zip_permissions(
|
||||
_entry: &zip::read::ZipFile<'_>,
|
||||
_output_path: &Path,
|
||||
) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "startup_sync_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
use super::*;
|
||||
use crate::auth::CodexAuth;
|
||||
use crate::config::CONFIG_TOML_FILE;
|
||||
use crate::plugins::test_support::TEST_CURATED_PLUGIN_SHA;
|
||||
use crate::plugins::test_support::write_curated_plugin_sha;
|
||||
use crate::plugins::test_support::write_file;
|
||||
use crate::plugins::test_support::write_openai_curated_marketplace;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::io::Write;
|
||||
use tempfile::tempdir;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::header;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
use zip::ZipWriter;
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
#[test]
|
||||
fn curated_plugins_repo_path_uses_codex_home_tmp_dir() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
assert_eq!(
|
||||
curated_plugins_repo_path(tmp.path()),
|
||||
tmp.path().join(".tmp/plugins")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_curated_plugins_sha_reads_trimmed_sha_file() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
std::fs::create_dir_all(tmp.path().join(".tmp")).expect("create tmp");
|
||||
std::fs::write(tmp.path().join(".tmp/plugins.sha"), "abc123\n").expect("write sha");
|
||||
|
||||
assert_eq!(
|
||||
read_curated_plugins_sha(tmp.path()).as_deref(),
|
||||
Some("abc123")
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn sync_openai_plugins_repo_prefers_git_when_available() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let bin_dir = tempfile::Builder::new()
|
||||
.prefix("fake-git-")
|
||||
.tempdir()
|
||||
.expect("tempdir");
|
||||
let git_path = bin_dir.path().join("git");
|
||||
let sha = "0123456789abcdef0123456789abcdef01234567";
|
||||
|
||||
std::fs::write(
|
||||
&git_path,
|
||||
format!(
|
||||
r#"#!/bin/sh
|
||||
if [ "$1" = "ls-remote" ]; then
|
||||
printf '%s\tHEAD\n' "{sha}"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$1" = "clone" ]; then
|
||||
dest="$5"
|
||||
mkdir -p "$dest/.git" "$dest/.agents/plugins" "$dest/plugins/gmail/.codex-plugin"
|
||||
cat > "$dest/.agents/plugins/marketplace.json" <<'EOF'
|
||||
{{"name":"openai-curated","plugins":[{{"name":"gmail","source":{{"source":"local","path":"./plugins/gmail"}}}}]}}
|
||||
EOF
|
||||
printf '%s\n' '{{"name":"gmail"}}' > "$dest/plugins/gmail/.codex-plugin/plugin.json"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$1" = "-C" ] && [ "$3" = "rev-parse" ] && [ "$4" = "HEAD" ]; then
|
||||
printf '%s\n' "{sha}"
|
||||
exit 0
|
||||
fi
|
||||
echo "unexpected git invocation: $@" >&2
|
||||
exit 1
|
||||
"#
|
||||
),
|
||||
)
|
||||
.expect("write fake git");
|
||||
let mut permissions = std::fs::metadata(&git_path)
|
||||
.expect("metadata")
|
||||
.permissions();
|
||||
permissions.set_mode(0o755);
|
||||
std::fs::set_permissions(&git_path, permissions).expect("chmod");
|
||||
|
||||
let synced_sha = sync_openai_plugins_repo_with_transport_overrides(
|
||||
tmp.path(),
|
||||
git_path.to_str().expect("utf8 path"),
|
||||
"http://127.0.0.1:9",
|
||||
)
|
||||
.expect("git sync should succeed");
|
||||
|
||||
assert_eq!(synced_sha, sha);
|
||||
assert!(curated_plugins_repo_path(tmp.path()).join(".git").is_dir());
|
||||
assert!(
|
||||
curated_plugins_repo_path(tmp.path())
|
||||
.join(".agents/plugins/marketplace.json")
|
||||
.is_file()
|
||||
);
|
||||
assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_openai_plugins_repo_falls_back_to_http_when_git_is_unavailable() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let server = MockServer::start().await;
|
||||
let sha = "0123456789abcdef0123456789abcdef01234567";
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/openai/plugins"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"default_branch":"main"}"#))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/openai/plugins/git/ref/heads/main"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(format!(r#"{{"object":{{"sha":"{sha}"}}}}"#)),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/repos/openai/plugins/zipball/{sha}")))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "application/zip")
|
||||
.set_body_bytes(curated_repo_zipball_bytes(sha)),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let server_uri = server.uri();
|
||||
let tmp_path = tmp.path().to_path_buf();
|
||||
let synced_sha = tokio::task::spawn_blocking(move || {
|
||||
sync_openai_plugins_repo_with_transport_overrides(
|
||||
tmp_path.as_path(),
|
||||
"missing-git-for-test",
|
||||
&server_uri,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.expect("sync task should join")
|
||||
.expect("fallback sync should succeed");
|
||||
|
||||
let repo_path = curated_plugins_repo_path(tmp.path());
|
||||
assert_eq!(synced_sha, sha);
|
||||
assert!(repo_path.join(".agents/plugins/marketplace.json").is_file());
|
||||
assert!(
|
||||
repo_path
|
||||
.join("plugins/gmail/.codex-plugin/plugin.json")
|
||||
.is_file()
|
||||
);
|
||||
assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn sync_openai_plugins_repo_falls_back_to_http_when_git_sync_fails() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let bin_dir = tempfile::Builder::new()
|
||||
.prefix("fake-git-fail-")
|
||||
.tempdir()
|
||||
.expect("tempdir");
|
||||
let git_path = bin_dir.path().join("git");
|
||||
let sha = "0123456789abcdef0123456789abcdef01234567";
|
||||
|
||||
std::fs::write(
|
||||
&git_path,
|
||||
r#"#!/bin/sh
|
||||
echo "simulated git failure" >&2
|
||||
exit 1
|
||||
"#,
|
||||
)
|
||||
.expect("write fake git");
|
||||
let mut permissions = std::fs::metadata(&git_path)
|
||||
.expect("metadata")
|
||||
.permissions();
|
||||
permissions.set_mode(0o755);
|
||||
std::fs::set_permissions(&git_path, permissions).expect("chmod");
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/openai/plugins"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"default_branch":"main"}"#))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/openai/plugins/git/ref/heads/main"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(format!(r#"{{"object":{{"sha":"{sha}"}}}}"#)),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/repos/openai/plugins/zipball/{sha}")))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "application/zip")
|
||||
.set_body_bytes(curated_repo_zipball_bytes(sha)),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let server_uri = server.uri();
|
||||
let tmp_path = tmp.path().to_path_buf();
|
||||
let synced_sha = tokio::task::spawn_blocking(move || {
|
||||
sync_openai_plugins_repo_with_transport_overrides(
|
||||
tmp_path.as_path(),
|
||||
git_path.to_str().expect("utf8 path"),
|
||||
&server_uri,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.expect("sync task should join")
|
||||
.expect("fallback sync should succeed");
|
||||
|
||||
let repo_path = curated_plugins_repo_path(tmp.path());
|
||||
assert_eq!(synced_sha, sha);
|
||||
assert!(repo_path.join(".agents/plugins/marketplace.json").is_file());
|
||||
assert!(
|
||||
repo_path
|
||||
.join("plugins/gmail/.codex-plugin/plugin.json")
|
||||
.is_file()
|
||||
);
|
||||
assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_openai_plugins_repo_skips_archive_download_when_sha_matches() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let repo_path = curated_plugins_repo_path(tmp.path());
|
||||
std::fs::create_dir_all(repo_path.join(".agents/plugins")).expect("create repo");
|
||||
std::fs::write(
|
||||
repo_path.join(".agents/plugins/marketplace.json"),
|
||||
r#"{"name":"openai-curated","plugins":[]}"#,
|
||||
)
|
||||
.expect("write marketplace");
|
||||
std::fs::create_dir_all(tmp.path().join(".tmp")).expect("create tmp");
|
||||
let sha = "fedcba9876543210fedcba9876543210fedcba98";
|
||||
std::fs::write(tmp.path().join(".tmp/plugins.sha"), format!("{sha}\n")).expect("write sha");
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/openai/plugins"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"default_branch":"main"}"#))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/openai/plugins/git/ref/heads/main"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(format!(r#"{{"object":{{"sha":"{sha}"}}}}"#)),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let server_uri = server.uri();
|
||||
let tmp_path = tmp.path().to_path_buf();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
sync_openai_plugins_repo_with_transport_overrides(
|
||||
tmp_path.as_path(),
|
||||
"missing-git-for-test",
|
||||
&server_uri,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.expect("sync task should join")
|
||||
.expect("sync should succeed");
|
||||
|
||||
assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha));
|
||||
assert!(repo_path.join(".agents/plugins/marketplace.json").is_file());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_remote_plugin_sync_writes_marker_and_reconciles_state() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let curated_root = curated_plugins_repo_path(tmp.path());
|
||||
write_openai_curated_marketplace(&curated_root, &["linear"]);
|
||||
write_curated_plugin_sha(tmp.path());
|
||||
write_file(
|
||||
&tmp.path().join(CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
|
||||
[plugins."linear@openai-curated"]
|
||||
enabled = false
|
||||
"#,
|
||||
);
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/plugins/list"))
|
||||
.and(header("authorization", "Bearer Access Token"))
|
||||
.and(header("chatgpt-account-id", "account_id"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(
|
||||
r#"[
|
||||
{"id":"1","name":"linear","marketplace_name":"openai-curated","version":"1.0.0","enabled":true}
|
||||
]"#,
|
||||
))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let mut config = crate::plugins::test_support::load_plugins_config(tmp.path()).await;
|
||||
config.chatgpt_base_url = format!("{}/backend-api/", server.uri());
|
||||
let manager = Arc::new(PluginsManager::new(tmp.path().to_path_buf()));
|
||||
let auth_manager =
|
||||
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
||||
|
||||
start_startup_remote_plugin_sync_once(
|
||||
Arc::clone(&manager),
|
||||
tmp.path().to_path_buf(),
|
||||
config,
|
||||
auth_manager,
|
||||
);
|
||||
|
||||
let marker_path = tmp.path().join(STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE);
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
if marker_path.is_file() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("marker should be written");
|
||||
|
||||
assert!(
|
||||
tmp.path()
|
||||
.join(format!(
|
||||
"plugins/cache/openai-curated/linear/{TEST_CURATED_PLUGIN_SHA}"
|
||||
))
|
||||
.is_dir()
|
||||
);
|
||||
let config =
|
||||
std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("config should exist");
|
||||
assert!(config.contains(r#"[plugins."linear@openai-curated"]"#));
|
||||
assert!(config.contains("enabled = true"));
|
||||
|
||||
let marker_contents = std::fs::read_to_string(marker_path).expect("marker should be readable");
|
||||
assert_eq!(marker_contents, "ok\n");
|
||||
}
|
||||
|
||||
fn curated_repo_zipball_bytes(sha: &str) -> Vec<u8> {
|
||||
let cursor = std::io::Cursor::new(Vec::new());
|
||||
let mut writer = ZipWriter::new(cursor);
|
||||
let options = SimpleFileOptions::default();
|
||||
let root = format!("openai-plugins-{sha}");
|
||||
writer
|
||||
.start_file(format!("{root}/.agents/plugins/marketplace.json"), options)
|
||||
.expect("start marketplace entry");
|
||||
writer
|
||||
.write_all(
|
||||
br#"{
|
||||
"name": "openai-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "gmail",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/gmail"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.expect("write marketplace");
|
||||
writer
|
||||
.start_file(
|
||||
format!("{root}/plugins/gmail/.codex-plugin/plugin.json"),
|
||||
options,
|
||||
)
|
||||
.expect("start plugin manifest entry");
|
||||
writer
|
||||
.write_all(br#"{"name":"gmail"}"#)
|
||||
.expect("write plugin manifest");
|
||||
|
||||
writer.finish().expect("finish zip writer").into_inner()
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user