mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
Merge e1c44cd34e into sapling-pr-archive-bolinfest
This commit is contained in:
@@ -2,9 +2,6 @@
|
||||
version = 1
|
||||
name = "codex"
|
||||
|
||||
[setup]
|
||||
script = "python ./.codex/environments/setup.py"
|
||||
|
||||
[[actions]]
|
||||
name = "Run"
|
||||
icon = "run"
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Set up ignored files that should be shared with Codex worktrees."""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from functools import cache
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@cache
|
||||
def worktree_paths() -> tuple[Path, Path]:
|
||||
script_dir = Path(__file__).resolve().parent
|
||||
worktree_root = git_path(script_dir / "../..", "--show-toplevel")
|
||||
common_git_dir = git_path(worktree_root, "--git-common-dir")
|
||||
return worktree_root, common_git_dir.parent
|
||||
|
||||
|
||||
def git_path(working_directory: Path, argument: str) -> Path:
|
||||
output = subprocess.check_output(
|
||||
[
|
||||
"git",
|
||||
"-C",
|
||||
str(working_directory),
|
||||
"rev-parse",
|
||||
"--path-format=absolute",
|
||||
argument,
|
||||
],
|
||||
text=True,
|
||||
)
|
||||
return Path(output.strip())
|
||||
|
||||
|
||||
def copy_from_main_worktree_to_worktree(repo_relative_path: str) -> None:
|
||||
relative_path = Path(repo_relative_path)
|
||||
if relative_path.is_absolute() or ".." in relative_path.parts:
|
||||
raise ValueError(f"path must be repository-relative: {repo_relative_path}")
|
||||
|
||||
worktree_root, main_worktree = worktree_paths()
|
||||
source_path = main_worktree / relative_path
|
||||
destination_path = worktree_root / relative_path
|
||||
|
||||
print(f" source: {source_path}")
|
||||
print(f" destination: {destination_path}")
|
||||
|
||||
if source_path == destination_path:
|
||||
print(" result: running in the main worktree; nothing to copy")
|
||||
elif destination_path.exists():
|
||||
print(" result: destination already exists; nothing to copy")
|
||||
elif not source_path.is_file():
|
||||
print(" result: source does not exist; nothing to copy")
|
||||
else:
|
||||
destination_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source_path, destination_path)
|
||||
print(f" result: copied {repo_relative_path}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("Codex environment setup:")
|
||||
# See codex-rs/docs/bazel.md for the repository's Bazel workflow.
|
||||
copy_from_main_worktree_to_worktree("user.bazelrc")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -9,6 +9,7 @@ Usage: build-codex-package-archive.sh \
|
||||
--entrypoint-dir <dir> \
|
||||
--archive-dir <dir> \
|
||||
[--bwrap-bin <path>] \
|
||||
[--code-mode-host-bin <path>] \
|
||||
[--zsh-manifest <path>] \
|
||||
[--codex-command-runner-bin <path>] \
|
||||
[--codex-windows-sandbox-setup-bin <path>] \
|
||||
@@ -23,6 +24,7 @@ archive_dir=""
|
||||
target_suffixed_entrypoint="false"
|
||||
resource_args=()
|
||||
bwrap_bin_provided="false"
|
||||
code_mode_host_bin_provided="false"
|
||||
command_runner_bin_provided="false"
|
||||
sandbox_setup_bin_provided="false"
|
||||
|
||||
@@ -49,6 +51,11 @@ while [[ $# -gt 0 ]]; do
|
||||
bwrap_bin_provided="true"
|
||||
shift 2
|
||||
;;
|
||||
--code-mode-host-bin)
|
||||
resource_args+=(--code-mode-host-bin "${2:?--code-mode-host-bin requires a value}")
|
||||
code_mode_host_bin_provided="true"
|
||||
shift 2
|
||||
;;
|
||||
--zsh-manifest)
|
||||
resource_args+=(--zsh-manifest "${2:?--zsh-manifest requires a value}")
|
||||
shift 2
|
||||
@@ -114,6 +121,11 @@ case "$target" in
|
||||
;;
|
||||
esac
|
||||
|
||||
code_mode_host_bin="${entrypoint_dir%/}/codex-code-mode-host${exe_suffix}"
|
||||
if [[ "$code_mode_host_bin_provided" == "false" && -f "$code_mode_host_bin" ]]; then
|
||||
resource_args+=(--code-mode-host-bin "$code_mode_host_bin")
|
||||
fi
|
||||
|
||||
entrypoint_name="$entrypoint"
|
||||
if [[ "$target_suffixed_entrypoint" == "true" ]]; then
|
||||
entrypoint_name="${entrypoint_name}-${target}"
|
||||
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
# nested submodules. Prefer the system git CLI across every Cargo invocation.
|
||||
env:
|
||||
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
|
||||
WINDOWS_BINARIES: "codex codex-responses-api-proxy codex-windows-sandbox-setup codex-command-runner codex-app-server"
|
||||
WINDOWS_BINARIES: "codex codex-code-mode-host codex-responses-api-proxy codex-windows-sandbox-setup codex-command-runner codex-app-server"
|
||||
|
||||
jobs:
|
||||
build-windows-binaries:
|
||||
@@ -28,14 +28,14 @@ jobs:
|
||||
- runner: windows-x64
|
||||
target: x86_64-pc-windows-msvc
|
||||
bundle: primary
|
||||
binaries: "codex codex-responses-api-proxy"
|
||||
binaries: "codex codex-code-mode-host codex-responses-api-proxy"
|
||||
runs_on:
|
||||
group: ${{ github.event.repository.name }}-runners
|
||||
labels: ${{ github.event.repository.name }}-windows-x64
|
||||
- runner: windows-arm64
|
||||
target: aarch64-pc-windows-msvc
|
||||
bundle: primary
|
||||
binaries: "codex codex-responses-api-proxy"
|
||||
binaries: "codex codex-code-mode-host codex-responses-api-proxy"
|
||||
runs_on:
|
||||
group: ${{ github.event.repository.name }}-runners
|
||||
labels: ${{ github.event.repository.name }}-windows-arm64
|
||||
@@ -56,14 +56,14 @@ jobs:
|
||||
- runner: windows-x64
|
||||
target: x86_64-pc-windows-msvc
|
||||
bundle: app-server
|
||||
binaries: "codex-app-server"
|
||||
binaries: "codex-app-server codex-code-mode-host"
|
||||
runs_on:
|
||||
group: ${{ github.event.repository.name }}-runners
|
||||
labels: ${{ github.event.repository.name }}-windows-x64
|
||||
- runner: windows-arm64
|
||||
target: aarch64-pc-windows-msvc
|
||||
bundle: app-server
|
||||
binaries: "codex-app-server"
|
||||
binaries: "codex-app-server codex-code-mode-host"
|
||||
runs_on:
|
||||
group: ${{ github.event.repository.name }}-runners
|
||||
labels: ${{ github.event.repository.name }}-windows-arm64
|
||||
|
||||
@@ -83,50 +83,50 @@ jobs:
|
||||
target: aarch64-apple-darwin
|
||||
bundle: primary
|
||||
artifact_name: aarch64-apple-darwin
|
||||
binaries: "codex codex-responses-api-proxy"
|
||||
binaries: "codex codex-code-mode-host codex-responses-api-proxy"
|
||||
build_dmg: "true"
|
||||
- runner: macos-15-xlarge
|
||||
target: aarch64-apple-darwin
|
||||
bundle: app-server
|
||||
artifact_name: aarch64-apple-darwin-app-server
|
||||
binaries: "codex-app-server"
|
||||
binaries: "codex-app-server codex-code-mode-host"
|
||||
build_dmg: "false"
|
||||
- runner: macos-15-xlarge
|
||||
target: x86_64-apple-darwin
|
||||
bundle: primary
|
||||
artifact_name: x86_64-apple-darwin
|
||||
binaries: "codex codex-responses-api-proxy"
|
||||
binaries: "codex codex-code-mode-host codex-responses-api-proxy"
|
||||
build_dmg: "true"
|
||||
- runner: macos-15-xlarge
|
||||
target: x86_64-apple-darwin
|
||||
bundle: app-server
|
||||
artifact_name: x86_64-apple-darwin-app-server
|
||||
binaries: "codex-app-server"
|
||||
binaries: "codex-app-server codex-code-mode-host"
|
||||
build_dmg: "false"
|
||||
# Release artifacts intentionally ship MUSL-linked Linux binaries.
|
||||
- runner: ${{ github.event.repository.name }}-linux-x64-xl
|
||||
target: x86_64-unknown-linux-musl
|
||||
bundle: primary
|
||||
artifact_name: x86_64-unknown-linux-musl
|
||||
binaries: "codex codex-responses-api-proxy bwrap"
|
||||
binaries: "codex codex-code-mode-host codex-responses-api-proxy bwrap"
|
||||
build_dmg: "false"
|
||||
- runner: ${{ github.event.repository.name }}-linux-x64-xl
|
||||
target: x86_64-unknown-linux-musl
|
||||
bundle: app-server
|
||||
artifact_name: x86_64-unknown-linux-musl-app-server
|
||||
binaries: "codex-app-server"
|
||||
binaries: "codex-app-server codex-code-mode-host"
|
||||
build_dmg: "false"
|
||||
- runner: ${{ github.event.repository.name }}-linux-arm64
|
||||
target: aarch64-unknown-linux-musl
|
||||
bundle: primary
|
||||
artifact_name: aarch64-unknown-linux-musl
|
||||
binaries: "codex codex-responses-api-proxy bwrap"
|
||||
binaries: "codex codex-code-mode-host codex-responses-api-proxy bwrap"
|
||||
build_dmg: "false"
|
||||
- runner: ${{ github.event.repository.name }}-linux-arm64
|
||||
target: aarch64-unknown-linux-musl
|
||||
bundle: app-server
|
||||
artifact_name: aarch64-unknown-linux-musl-app-server
|
||||
binaries: "codex-app-server"
|
||||
binaries: "codex-app-server codex-code-mode-host"
|
||||
build_dmg: "false"
|
||||
|
||||
steps:
|
||||
@@ -335,6 +335,11 @@ jobs:
|
||||
mkdir -p "$dest"
|
||||
|
||||
for binary in ${{ matrix.binaries }}; do
|
||||
# Both variants package the host, but only the primary bundle publishes
|
||||
# standalone binary archives to avoid duplicate release asset names.
|
||||
if [[ "${{ matrix.bundle }}" == "app-server" && "$binary" == "codex-code-mode-host" ]]; then
|
||||
continue
|
||||
fi
|
||||
cp "target/${{ matrix.target }}/release/${binary}" "$dest/${binary}-${{ matrix.target }}"
|
||||
if [[ "${{ matrix.target }}" == *linux* ]]; then
|
||||
cp "target/${{ matrix.target }}/release/${binary}.sigstore" \
|
||||
@@ -347,9 +352,13 @@ jobs:
|
||||
rm -rf "$bundle_root"
|
||||
mkdir -p "$bundle_root/codex-resources"
|
||||
cp "$dest/codex-${{ matrix.target }}" "$bundle_root/codex"
|
||||
cp "$dest/codex-code-mode-host-${{ matrix.target }}" "$bundle_root/codex-code-mode-host"
|
||||
cp "$dest/bwrap-${{ matrix.target }}" "$bundle_root/codex-resources/bwrap"
|
||||
chmod 0755 "$bundle_root/codex" "$bundle_root/codex-resources/bwrap"
|
||||
tar -C "$bundle_root" -cf - codex codex-resources/bwrap |
|
||||
chmod 0755 \
|
||||
"$bundle_root/codex" \
|
||||
"$bundle_root/codex-code-mode-host" \
|
||||
"$bundle_root/codex-resources/bwrap"
|
||||
tar -C "$bundle_root" -cf - codex codex-code-mode-host codex-resources/bwrap |
|
||||
zstd -T0 -19 -o "$dest/codex-${{ matrix.target }}-bundle.tar.zst"
|
||||
fi
|
||||
|
||||
@@ -497,19 +506,19 @@ jobs:
|
||||
- target: aarch64-apple-darwin
|
||||
bundle: primary
|
||||
artifact_name: aarch64-apple-darwin
|
||||
binaries: "codex codex-responses-api-proxy"
|
||||
binaries: "codex codex-code-mode-host codex-responses-api-proxy"
|
||||
- target: aarch64-apple-darwin
|
||||
bundle: app-server
|
||||
artifact_name: aarch64-apple-darwin-app-server
|
||||
binaries: "codex-app-server"
|
||||
binaries: "codex-app-server codex-code-mode-host"
|
||||
- target: x86_64-apple-darwin
|
||||
bundle: primary
|
||||
artifact_name: x86_64-apple-darwin
|
||||
binaries: "codex codex-responses-api-proxy"
|
||||
binaries: "codex codex-code-mode-host codex-responses-api-proxy"
|
||||
- target: x86_64-apple-darwin
|
||||
bundle: app-server
|
||||
artifact_name: x86_64-apple-darwin-app-server
|
||||
binaries: "codex-app-server"
|
||||
binaries: "codex-app-server codex-code-mode-host"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -615,22 +624,22 @@ jobs:
|
||||
- target: aarch64-apple-darwin
|
||||
bundle: primary
|
||||
artifact_name: aarch64-apple-darwin
|
||||
binaries: "codex codex-responses-api-proxy"
|
||||
binaries: "codex codex-code-mode-host codex-responses-api-proxy"
|
||||
build_dmg: "true"
|
||||
- target: aarch64-apple-darwin
|
||||
bundle: app-server
|
||||
artifact_name: aarch64-apple-darwin-app-server
|
||||
binaries: "codex-app-server"
|
||||
binaries: "codex-app-server codex-code-mode-host"
|
||||
build_dmg: "false"
|
||||
- target: x86_64-apple-darwin
|
||||
bundle: primary
|
||||
artifact_name: x86_64-apple-darwin
|
||||
binaries: "codex codex-responses-api-proxy"
|
||||
binaries: "codex codex-code-mode-host codex-responses-api-proxy"
|
||||
build_dmg: "true"
|
||||
- target: x86_64-apple-darwin
|
||||
bundle: app-server
|
||||
artifact_name: x86_64-apple-darwin-app-server
|
||||
binaries: "codex-app-server"
|
||||
binaries: "codex-app-server codex-code-mode-host"
|
||||
build_dmg: "false"
|
||||
|
||||
steps:
|
||||
@@ -707,6 +716,11 @@ jobs:
|
||||
mkdir -p "$dest"
|
||||
|
||||
for binary in ${{ matrix.binaries }}; do
|
||||
# Both variants package the host, but only the primary bundle publishes
|
||||
# standalone binary archives to avoid duplicate release asset names.
|
||||
if [[ "${{ matrix.bundle }}" == "app-server" && "$binary" == "codex-code-mode-host" ]]; then
|
||||
continue
|
||||
fi
|
||||
cp "target/${{ matrix.target }}/release/${binary}" "$dest/${binary}-${{ matrix.target }}"
|
||||
done
|
||||
|
||||
@@ -911,22 +925,22 @@ jobs:
|
||||
- target: aarch64-apple-darwin
|
||||
bundle: primary
|
||||
artifact_name: aarch64-apple-darwin
|
||||
binaries: "codex codex-responses-api-proxy"
|
||||
binaries: "codex codex-code-mode-host codex-responses-api-proxy"
|
||||
verify_dmg: "true"
|
||||
- target: aarch64-apple-darwin
|
||||
bundle: app-server
|
||||
artifact_name: aarch64-apple-darwin-app-server
|
||||
binaries: "codex-app-server"
|
||||
binaries: "codex-app-server codex-code-mode-host"
|
||||
verify_dmg: "false"
|
||||
- target: x86_64-apple-darwin
|
||||
bundle: primary
|
||||
artifact_name: x86_64-apple-darwin
|
||||
binaries: "codex codex-responses-api-proxy"
|
||||
binaries: "codex codex-code-mode-host codex-responses-api-proxy"
|
||||
verify_dmg: "true"
|
||||
- target: x86_64-apple-darwin
|
||||
bundle: app-server
|
||||
artifact_name: x86_64-apple-darwin-app-server
|
||||
binaries: "codex-app-server"
|
||||
binaries: "codex-app-server codex-code-mode-host"
|
||||
verify_dmg: "false"
|
||||
|
||||
steps:
|
||||
@@ -983,6 +997,12 @@ jobs:
|
||||
binary_path="${RUNNER_TEMP}/signed-binaries/${binary}"
|
||||
verify_signed_binary "$binary_path"
|
||||
|
||||
# The app-server package contains the host, but its standalone archives
|
||||
# are omitted above to avoid duplicate release asset names.
|
||||
if [[ "${{ matrix.bundle }}" == "app-server" && "$binary" == "codex-code-mode-host" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
direct_archive_dir="${RUNNER_TEMP}/direct-archive-${binary}-${target}"
|
||||
rm -rf "$direct_archive_dir"
|
||||
mkdir -p "$direct_archive_dir"
|
||||
@@ -1014,6 +1034,7 @@ jobs:
|
||||
mkdir -p "$package_dir"
|
||||
tar -xzf "${packaged_dir}/${package_stem}-${target}.tar.gz" -C "$package_dir"
|
||||
verify_signed_binary "${package_dir}/bin/${package_entrypoint}"
|
||||
verify_signed_binary "${package_dir}/bin/codex-code-mode-host"
|
||||
|
||||
if [[ "${{ matrix.verify_dmg }}" != "true" ]]; then
|
||||
exit 0
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
user.bazelrc
|
||||
Generated
+1
-1
@@ -804,7 +804,7 @@
|
||||
"critical-section_1.2.0": "{\"dependencies\":[],\"features\":{\"restore-state-bool\":[],\"restore-state-none\":[],\"restore-state-u16\":[],\"restore-state-u32\":[],\"restore-state-u64\":[],\"restore-state-u8\":[],\"restore-state-usize\":[],\"std\":[\"restore-state-bool\"]}}",
|
||||
"crossbeam-channel_0.5.15": "{\"dependencies\":[{\"default_features\":false,\"name\":\"crossbeam-utils\",\"req\":\"^0.8.18\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.13.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"signal-hook\",\"req\":\"^0.3\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"crossbeam-utils/std\"]}}",
|
||||
"crossbeam-deque_0.8.6": "{\"dependencies\":[{\"default_features\":false,\"name\":\"crossbeam-epoch\",\"req\":\"^0.9.17\"},{\"default_features\":false,\"name\":\"crossbeam-utils\",\"req\":\"^0.8.18\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"crossbeam-epoch/std\",\"crossbeam-utils/std\"]}}",
|
||||
"crossbeam-epoch_0.9.18": "{\"dependencies\":[{\"default_features\":false,\"name\":\"crossbeam-utils\",\"req\":\"^0.8.18\"},{\"name\":\"loom-crate\",\"optional\":true,\"package\":\"loom\",\"req\":\"^0.7.1\",\"target\":\"cfg(crossbeam_loom)\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"loom\":[\"loom-crate\",\"crossbeam-utils/loom\"],\"nightly\":[\"crossbeam-utils/nightly\"],\"std\":[\"alloc\",\"crossbeam-utils/std\"]}}",
|
||||
"crossbeam-epoch_0.9.20": "{\"dependencies\":[{\"default_features\":false,\"name\":\"crossbeam-utils\",\"req\":\"^0.8.18\"},{\"name\":\"loom-crate\",\"optional\":true,\"package\":\"loom\",\"req\":\"^0.7.1\",\"target\":\"cfg(crossbeam_loom)\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"loom\":[\"loom-crate\",\"crossbeam-utils/loom\"],\"nightly\":[\"crossbeam-utils/nightly\"],\"std\":[\"alloc\",\"crossbeam-utils/std\"]}}",
|
||||
"crossbeam-queue_0.3.12": "{\"dependencies\":[{\"default_features\":false,\"name\":\"crossbeam-utils\",\"req\":\"^0.8.18\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"nightly\":[\"crossbeam-utils/nightly\"],\"std\":[\"alloc\",\"crossbeam-utils/std\"]}}",
|
||||
"crossbeam-utils_0.8.21": "{\"dependencies\":[{\"name\":\"loom\",\"optional\":true,\"req\":\"^0.7.1\",\"target\":\"cfg(crossbeam_loom)\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{\"default\":[\"std\"],\"nightly\":[],\"std\":[]}}",
|
||||
"crossterm_winapi_0.9.1": "{\"dependencies\":[{\"features\":[\"winbase\",\"consoleapi\",\"processenv\",\"handleapi\",\"synchapi\",\"impl-default\"],\"name\":\"winapi\",\"req\":\"^0.3.8\",\"target\":\"cfg(windows)\"}],\"features\":{}}",
|
||||
|
||||
Generated
+42
-29
@@ -1936,6 +1936,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"chrono",
|
||||
"codex-client",
|
||||
"codex-http-client",
|
||||
"codex-protocol",
|
||||
"codex-utils-rustls-provider",
|
||||
"eventsource-stream",
|
||||
@@ -2262,7 +2263,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"codex-api",
|
||||
"codex-backend-openapi-models",
|
||||
"codex-client",
|
||||
"codex-http-client",
|
||||
"codex-login",
|
||||
"codex-model-provider",
|
||||
"codex-protocol",
|
||||
@@ -2392,33 +2393,12 @@ dependencies = [
|
||||
name = "codex-client"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"codex-utils-cargo-bin",
|
||||
"codex-utils-rustls-provider",
|
||||
"codex-http-client",
|
||||
"eventsource-stream",
|
||||
"futures",
|
||||
"http 1.4.0",
|
||||
"opentelemetry",
|
||||
"opentelemetry_sdk",
|
||||
"pretty_assertions",
|
||||
"rand 0.9.3",
|
||||
"rcgen",
|
||||
"reqwest 0.12.28",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"system-configuration",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-opentelemetry",
|
||||
"tracing-subscriber",
|
||||
"windows-sys 0.52.0",
|
||||
"zstd 0.13.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2452,11 +2432,11 @@ dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"clap",
|
||||
"codex-client",
|
||||
"codex-cloud-tasks-client",
|
||||
"codex-cloud-tasks-mock-client",
|
||||
"codex-core",
|
||||
"codex-git-utils",
|
||||
"codex-http-client",
|
||||
"codex-login",
|
||||
"codex-model-provider",
|
||||
"codex-tui",
|
||||
@@ -2526,6 +2506,7 @@ dependencies = [
|
||||
"codex-utils-cargo-bin",
|
||||
"pretty_assertions",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
]
|
||||
@@ -2915,9 +2896,9 @@ dependencies = [
|
||||
"bytes",
|
||||
"clatter",
|
||||
"codex-api",
|
||||
"codex-client",
|
||||
"codex-exec-server-protocol",
|
||||
"codex-file-system",
|
||||
"codex-http-client",
|
||||
"codex-network-proxy",
|
||||
"codex-otel",
|
||||
"codex-protocol",
|
||||
@@ -3213,6 +3194,37 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-http-client"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"codex-utils-cargo-bin",
|
||||
"codex-utils-rustls-provider",
|
||||
"futures",
|
||||
"http 1.4.0",
|
||||
"opentelemetry",
|
||||
"opentelemetry_sdk",
|
||||
"pretty_assertions",
|
||||
"rcgen",
|
||||
"reqwest 0.12.28",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"system-configuration",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-opentelemetry",
|
||||
"tracing-subscriber",
|
||||
"windows-sys 0.52.0",
|
||||
"zstd 0.13.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-image-generation-extension"
|
||||
version = "0.0.0"
|
||||
@@ -3264,6 +3276,7 @@ dependencies = [
|
||||
"clap",
|
||||
"codex-core",
|
||||
"codex-install-context",
|
||||
"codex-network-proxy",
|
||||
"codex-process-hardening",
|
||||
"codex-protocol",
|
||||
"codex-sandboxing",
|
||||
@@ -3303,8 +3316,8 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"codex-agent-identity",
|
||||
"codex-client",
|
||||
"codex-config",
|
||||
"codex-http-client",
|
||||
"codex-keyring-store",
|
||||
"codex-model-provider-info",
|
||||
"codex-otel",
|
||||
@@ -3518,8 +3531,8 @@ dependencies = [
|
||||
"codex-agent-identity",
|
||||
"codex-api",
|
||||
"codex-aws-auth",
|
||||
"codex-client",
|
||||
"codex-feedback",
|
||||
"codex-http-client",
|
||||
"codex-login",
|
||||
"codex-model-provider-info",
|
||||
"codex-models-manager",
|
||||
@@ -4821,9 +4834,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.18"
|
||||
version = "0.9.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
||||
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
@@ -41,6 +41,7 @@ members = [
|
||||
"core-plugins",
|
||||
"core-skills",
|
||||
"hooks",
|
||||
"http-client",
|
||||
"secrets",
|
||||
"exec",
|
||||
"file-system",
|
||||
@@ -164,6 +165,7 @@ codex-cloud-tasks-mock-client = { path = "cloud-tasks-mock-client" }
|
||||
codex-code-mode = { path = "code-mode" }
|
||||
codex-code-mode-protocol = { path = "code-mode-protocol" }
|
||||
codex-home = { path = "codex-home" }
|
||||
codex-http-client = { path = "http-client" }
|
||||
codex-config = { path = "config" }
|
||||
codex-connectors = { path = "connectors" }
|
||||
codex-connectors-extension = { path = "ext/connectors" }
|
||||
|
||||
@@ -78,9 +78,6 @@ pub use crate::remote::RemoteAppServerEndpoint;
|
||||
/// module exists so clients can remove a direct `codex-core` dependency
|
||||
/// while legacy startup/config paths are migrated to RPCs.
|
||||
pub mod legacy_core {
|
||||
pub use codex_core::check_execpolicy_for_warnings;
|
||||
pub use codex_core::format_exec_policy_error_with_source;
|
||||
|
||||
pub mod config {
|
||||
pub use codex_core::config::*;
|
||||
|
||||
|
||||
@@ -556,6 +556,13 @@
|
||||
},
|
||||
"ConsumeAccountRateLimitResetCreditParams": {
|
||||
"properties": {
|
||||
"creditId": {
|
||||
"description": "Opaque reset-credit identifier to redeem. When omitted, the backend selects the next available credit.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"idempotencyKey": {
|
||||
"description": "Identifies one logical reset attempt. A UUID is recommended; reuse the same value when retrying that attempt.",
|
||||
"type": "string"
|
||||
|
||||
+89
@@ -8688,6 +8688,13 @@
|
||||
"ConsumeAccountRateLimitResetCreditParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"creditId": {
|
||||
"description": "Opaque reset-credit identifier to redeem. When omitted, the backend selects the next available credit.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"idempotencyKey": {
|
||||
"description": "Identifies one logical reset attempt. A UUID is recommended; reuse the same value when retrying that attempt.",
|
||||
"type": "string"
|
||||
@@ -14350,6 +14357,14 @@
|
||||
},
|
||||
"source": {
|
||||
"$ref": "#/definitions/v2/PluginSource"
|
||||
},
|
||||
"version": {
|
||||
"default": null,
|
||||
"description": "Version advertised by the remote marketplace backend when available.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -14525,11 +14540,78 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"RateLimitResetCredit": {
|
||||
"properties": {
|
||||
"description": {
|
||||
"description": "Backend-provided display description for this credit, or `null` when unavailable.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"expiresAt": {
|
||||
"description": "Unix timestamp in seconds when the credit expires, or `null` if it does not expire.",
|
||||
"format": "int64",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"grantedAt": {
|
||||
"description": "Unix timestamp in seconds when the credit was granted.",
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"id": {
|
||||
"description": "Opaque backend identifier for this reset credit.",
|
||||
"type": "string"
|
||||
},
|
||||
"resetType": {
|
||||
"$ref": "#/definitions/v2/RateLimitResetType"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/v2/RateLimitResetCreditStatus"
|
||||
},
|
||||
"title": {
|
||||
"description": "Backend-provided display title for this credit, or `null` when unavailable.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"grantedAt",
|
||||
"id",
|
||||
"resetType",
|
||||
"status"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RateLimitResetCreditStatus": {
|
||||
"enum": [
|
||||
"available",
|
||||
"redeeming",
|
||||
"redeemed",
|
||||
"unknown"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"RateLimitResetCreditsSummary": {
|
||||
"properties": {
|
||||
"availableCount": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"credits": {
|
||||
"description": "Detail rows for available reset credits, when the backend provides them.\n\n`null` means only `availableCount` is known, while an empty array means details were fetched and no available credits were returned. The backend may cap this list, so its length can be less than `availableCount`.",
|
||||
"items": {
|
||||
"$ref": "#/definitions/v2/RateLimitResetCredit"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -14537,6 +14619,13 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RateLimitResetType": {
|
||||
"enum": [
|
||||
"codexRateLimits",
|
||||
"unknown"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"RateLimitSnapshot": {
|
||||
"properties": {
|
||||
"credits": {
|
||||
|
||||
+89
@@ -4928,6 +4928,13 @@
|
||||
"ConsumeAccountRateLimitResetCreditParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"creditId": {
|
||||
"description": "Opaque reset-credit identifier to redeem. When omitted, the backend selects the next available credit.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"idempotencyKey": {
|
||||
"description": "Identifies one logical reset attempt. A UUID is recommended; reuse the same value when retrying that attempt.",
|
||||
"type": "string"
|
||||
@@ -10754,6 +10761,14 @@
|
||||
},
|
||||
"source": {
|
||||
"$ref": "#/definitions/PluginSource"
|
||||
},
|
||||
"version": {
|
||||
"default": null,
|
||||
"description": "Version advertised by the remote marketplace backend when available.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -10929,11 +10944,78 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"RateLimitResetCredit": {
|
||||
"properties": {
|
||||
"description": {
|
||||
"description": "Backend-provided display description for this credit, or `null` when unavailable.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"expiresAt": {
|
||||
"description": "Unix timestamp in seconds when the credit expires, or `null` if it does not expire.",
|
||||
"format": "int64",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"grantedAt": {
|
||||
"description": "Unix timestamp in seconds when the credit was granted.",
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"id": {
|
||||
"description": "Opaque backend identifier for this reset credit.",
|
||||
"type": "string"
|
||||
},
|
||||
"resetType": {
|
||||
"$ref": "#/definitions/RateLimitResetType"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/RateLimitResetCreditStatus"
|
||||
},
|
||||
"title": {
|
||||
"description": "Backend-provided display title for this credit, or `null` when unavailable.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"grantedAt",
|
||||
"id",
|
||||
"resetType",
|
||||
"status"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RateLimitResetCreditStatus": {
|
||||
"enum": [
|
||||
"available",
|
||||
"redeeming",
|
||||
"redeemed",
|
||||
"unknown"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"RateLimitResetCreditsSummary": {
|
||||
"properties": {
|
||||
"availableCount": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"credits": {
|
||||
"description": "Detail rows for available reset credits, when the backend provides them.\n\n`null` means only `availableCount` is known, while an empty array means details were fetched and no available credits were returned. The backend may cap this list, so its length can be less than `availableCount`.",
|
||||
"items": {
|
||||
"$ref": "#/definitions/RateLimitResetCredit"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -10941,6 +11023,13 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RateLimitResetType": {
|
||||
"enum": [
|
||||
"codexRateLimits",
|
||||
"unknown"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"RateLimitSnapshot": {
|
||||
"properties": {
|
||||
"credits": {
|
||||
|
||||
+7
@@ -1,6 +1,13 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"creditId": {
|
||||
"description": "Opaque reset-credit identifier to redeem. When omitted, the backend selects the next available credit.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"idempotencyKey": {
|
||||
"description": "Identifies one logical reset attempt. A UUID is recommended; reuse the same value when retrying that attempt.",
|
||||
"type": "string"
|
||||
|
||||
+74
@@ -49,11 +49,78 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"RateLimitResetCredit": {
|
||||
"properties": {
|
||||
"description": {
|
||||
"description": "Backend-provided display description for this credit, or `null` when unavailable.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"expiresAt": {
|
||||
"description": "Unix timestamp in seconds when the credit expires, or `null` if it does not expire.",
|
||||
"format": "int64",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"grantedAt": {
|
||||
"description": "Unix timestamp in seconds when the credit was granted.",
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"id": {
|
||||
"description": "Opaque backend identifier for this reset credit.",
|
||||
"type": "string"
|
||||
},
|
||||
"resetType": {
|
||||
"$ref": "#/definitions/RateLimitResetType"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/RateLimitResetCreditStatus"
|
||||
},
|
||||
"title": {
|
||||
"description": "Backend-provided display title for this credit, or `null` when unavailable.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"grantedAt",
|
||||
"id",
|
||||
"resetType",
|
||||
"status"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RateLimitResetCreditStatus": {
|
||||
"enum": [
|
||||
"available",
|
||||
"redeeming",
|
||||
"redeemed",
|
||||
"unknown"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"RateLimitResetCreditsSummary": {
|
||||
"properties": {
|
||||
"availableCount": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"credits": {
|
||||
"description": "Detail rows for available reset credits, when the backend provides them.\n\n`null` means only `availableCount` is known, while an empty array means details were fetched and no available credits were returned. The backend may cap this list, so its length can be less than `availableCount`.",
|
||||
"items": {
|
||||
"$ref": "#/definitions/RateLimitResetCredit"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -61,6 +128,13 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RateLimitResetType": {
|
||||
"enum": [
|
||||
"codexRateLimits",
|
||||
"unknown"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"RateLimitSnapshot": {
|
||||
"properties": {
|
||||
"credits": {
|
||||
|
||||
@@ -540,6 +540,14 @@
|
||||
},
|
||||
"source": {
|
||||
"$ref": "#/definitions/PluginSource"
|
||||
},
|
||||
"version": {
|
||||
"default": null,
|
||||
"description": "Version advertised by the remote marketplace backend when available.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -540,6 +540,14 @@
|
||||
},
|
||||
"source": {
|
||||
"$ref": "#/definitions/PluginSource"
|
||||
},
|
||||
"version": {
|
||||
"default": null,
|
||||
"description": "Version advertised by the remote marketplace backend when available.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -680,6 +680,14 @@
|
||||
},
|
||||
"source": {
|
||||
"$ref": "#/definitions/PluginSource"
|
||||
},
|
||||
"version": {
|
||||
"default": null,
|
||||
"description": "Version advertised by the remote marketplace backend when available.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -496,6 +496,14 @@
|
||||
},
|
||||
"source": {
|
||||
"$ref": "#/definitions/PluginSource"
|
||||
},
|
||||
"version": {
|
||||
"default": null,
|
||||
"description": "Version advertised by the remote marketplace backend when available.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
Generated
+6
-1
@@ -7,4 +7,9 @@ export type ConsumeAccountRateLimitResetCreditParams = {
|
||||
* Identifies one logical reset attempt. A UUID is recommended; reuse the same value when
|
||||
* retrying that attempt.
|
||||
*/
|
||||
idempotencyKey: string, };
|
||||
idempotencyKey: string,
|
||||
/**
|
||||
* Opaque reset-credit identifier to redeem. When omitted, the backend selects the next
|
||||
* available credit.
|
||||
*/
|
||||
creditId?: string | null, };
|
||||
|
||||
@@ -13,6 +13,10 @@ export type PluginSummary = { id: string,
|
||||
* Backend remote plugin identifier when available.
|
||||
*/
|
||||
remotePluginId: string | null,
|
||||
/**
|
||||
* Version advertised by the remote marketplace backend when available.
|
||||
*/
|
||||
version: string | null,
|
||||
/**
|
||||
* Version of the locally materialized plugin package when available.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// 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 { RateLimitResetCreditStatus } from "./RateLimitResetCreditStatus";
|
||||
import type { RateLimitResetType } from "./RateLimitResetType";
|
||||
|
||||
export type RateLimitResetCredit = {
|
||||
/**
|
||||
* Opaque backend identifier for this reset credit.
|
||||
*/
|
||||
id: string, resetType: RateLimitResetType, status: RateLimitResetCreditStatus,
|
||||
/**
|
||||
* Unix timestamp in seconds when the credit was granted.
|
||||
*/
|
||||
grantedAt: number,
|
||||
/**
|
||||
* Unix timestamp in seconds when the credit expires, or `null` if it does not expire.
|
||||
*/
|
||||
expiresAt: number | null,
|
||||
/**
|
||||
* Backend-provided display title for this credit, or `null` when unavailable.
|
||||
*/
|
||||
title: string | null,
|
||||
/**
|
||||
* Backend-provided display description for this credit, or `null` when unavailable.
|
||||
*/
|
||||
description: string | null, };
|
||||
+5
@@ -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 RateLimitResetCreditStatus = "available" | "redeeming" | "redeemed" | "unknown";
|
||||
+10
-1
@@ -1,5 +1,14 @@
|
||||
// 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 { RateLimitResetCredit } from "./RateLimitResetCredit";
|
||||
|
||||
export type RateLimitResetCreditsSummary = { availableCount: bigint, };
|
||||
export type RateLimitResetCreditsSummary = { availableCount: bigint,
|
||||
/**
|
||||
* Detail rows for available reset credits, when the backend provides them.
|
||||
*
|
||||
* `null` means only `availableCount` is known, while an empty array means details were fetched
|
||||
* and no available credits were returned. The backend may cap this list, so its length can be
|
||||
* less than `availableCount`.
|
||||
*/
|
||||
credits: Array<RateLimitResetCredit> | null, };
|
||||
|
||||
@@ -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 RateLimitResetType = "codexRateLimits" | "unknown";
|
||||
@@ -335,7 +335,10 @@ export type { ProcessOutputDeltaNotification } from "./ProcessOutputDeltaNotific
|
||||
export type { ProcessOutputStream } from "./ProcessOutputStream";
|
||||
export type { ProcessTerminalSize } from "./ProcessTerminalSize";
|
||||
export type { RateLimitReachedType } from "./RateLimitReachedType";
|
||||
export type { RateLimitResetCredit } from "./RateLimitResetCredit";
|
||||
export type { RateLimitResetCreditStatus } from "./RateLimitResetCreditStatus";
|
||||
export type { RateLimitResetCreditsSummary } from "./RateLimitResetCreditsSummary";
|
||||
export type { RateLimitResetType } from "./RateLimitResetType";
|
||||
export type { RateLimitSnapshot } from "./RateLimitSnapshot";
|
||||
export type { RateLimitWindow } from "./RateLimitWindow";
|
||||
export type { RawResponseItemCompletedNotification } from "./RawResponseItemCompletedNotification";
|
||||
|
||||
@@ -3195,6 +3195,7 @@ mod tests {
|
||||
request_id: RequestId::Integer(9),
|
||||
params: v2::ThreadRealtimeStartParams {
|
||||
client_managed_handoffs: Some(true),
|
||||
flush_transcript_tail_on_session_end: Some(true),
|
||||
codex_responses_as_items: None,
|
||||
codex_response_item_prefix: None,
|
||||
codex_response_handoff_prefix: Some("silent context".to_string()),
|
||||
@@ -3216,6 +3217,7 @@ mod tests {
|
||||
"params": {
|
||||
"threadId": "thr_123",
|
||||
"clientManagedHandoffs": true,
|
||||
"flushTranscriptTailOnSessionEnd": true,
|
||||
"codexResponsesAsItems": null,
|
||||
"codexResponseItemPrefix": null,
|
||||
"codexResponseHandoffPrefix": "silent context",
|
||||
@@ -3240,6 +3242,7 @@ mod tests {
|
||||
request_id: RequestId::Integer(9),
|
||||
params: v2::ThreadRealtimeStartParams {
|
||||
client_managed_handoffs: None,
|
||||
flush_transcript_tail_on_session_end: None,
|
||||
codex_responses_as_items: None,
|
||||
codex_response_item_prefix: None,
|
||||
codex_response_handoff_prefix: None,
|
||||
@@ -3261,6 +3264,7 @@ mod tests {
|
||||
"params": {
|
||||
"threadId": "thr_123",
|
||||
"clientManagedHandoffs": null,
|
||||
"flushTranscriptTailOnSessionEnd": null,
|
||||
"codexResponsesAsItems": null,
|
||||
"codexResponseItemPrefix": null,
|
||||
"codexResponseHandoffPrefix": null,
|
||||
@@ -3280,6 +3284,7 @@ mod tests {
|
||||
request_id: RequestId::Integer(9),
|
||||
params: v2::ThreadRealtimeStartParams {
|
||||
client_managed_handoffs: None,
|
||||
flush_transcript_tail_on_session_end: None,
|
||||
codex_responses_as_items: None,
|
||||
codex_response_item_prefix: None,
|
||||
codex_response_handoff_prefix: None,
|
||||
@@ -3301,6 +3306,7 @@ mod tests {
|
||||
"params": {
|
||||
"threadId": "thr_123",
|
||||
"clientManagedHandoffs": null,
|
||||
"flushTranscriptTailOnSessionEnd": null,
|
||||
"codexResponsesAsItems": null,
|
||||
"codexResponseItemPrefix": null,
|
||||
"codexResponseHandoffPrefix": null,
|
||||
@@ -3518,6 +3524,7 @@ mod tests {
|
||||
request_id: RequestId::Integer(1),
|
||||
params: v2::ThreadRealtimeStartParams {
|
||||
client_managed_handoffs: None,
|
||||
flush_transcript_tail_on_session_end: None,
|
||||
codex_responses_as_items: None,
|
||||
codex_response_item_prefix: None,
|
||||
codex_response_handoff_prefix: None,
|
||||
|
||||
@@ -284,6 +284,52 @@ pub struct GetAccountRateLimitsResponse {
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct RateLimitResetCreditsSummary {
|
||||
pub available_count: i64,
|
||||
/// Detail rows for available reset credits, when the backend provides them.
|
||||
///
|
||||
/// `null` means only `availableCount` is known, while an empty array means details were fetched
|
||||
/// and no available credits were returned. The backend may cap this list, so its length can be
|
||||
/// less than `availableCount`.
|
||||
pub credits: Option<Vec<RateLimitResetCredit>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct RateLimitResetCredit {
|
||||
/// Opaque backend identifier for this reset credit.
|
||||
pub id: String,
|
||||
pub reset_type: RateLimitResetType,
|
||||
pub status: RateLimitResetCreditStatus,
|
||||
/// Unix timestamp in seconds when the credit was granted.
|
||||
#[ts(type = "number")]
|
||||
pub granted_at: i64,
|
||||
/// Unix timestamp in seconds when the credit expires, or `null` if it does not expire.
|
||||
#[ts(type = "number | null")]
|
||||
pub expires_at: Option<i64>,
|
||||
/// Backend-provided display title for this credit, or `null` when unavailable.
|
||||
pub title: Option<String>,
|
||||
/// Backend-provided display description for this credit, or `null` when unavailable.
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/", rename_all = "camelCase")]
|
||||
pub enum RateLimitResetType {
|
||||
CodexRateLimits,
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/", rename_all = "camelCase")]
|
||||
pub enum RateLimitResetCreditStatus {
|
||||
Available,
|
||||
Redeeming,
|
||||
Redeemed,
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
@@ -293,6 +339,10 @@ pub struct ConsumeAccountRateLimitResetCreditParams {
|
||||
/// Identifies one logical reset attempt. A UUID is recommended; reuse the same value when
|
||||
/// retrying that attempt.
|
||||
pub idempotency_key: String,
|
||||
/// Opaque reset-credit identifier to redeem. When omitted, the backend selects the next
|
||||
/// available credit.
|
||||
#[ts(optional = nullable)]
|
||||
pub credit_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
|
||||
@@ -600,6 +600,9 @@ pub struct PluginSummary {
|
||||
pub id: String,
|
||||
/// Backend remote plugin identifier when available.
|
||||
pub remote_plugin_id: Option<String>,
|
||||
/// Version advertised by the remote marketplace backend when available.
|
||||
#[serde(default)]
|
||||
pub version: Option<String>,
|
||||
/// Version of the locally materialized plugin package when available.
|
||||
#[serde(default)]
|
||||
pub local_version: Option<String>,
|
||||
|
||||
@@ -70,6 +70,10 @@ pub struct ThreadRealtimeStartParams {
|
||||
/// them automatically. Defaults to false.
|
||||
#[ts(optional = nullable)]
|
||||
pub client_managed_handoffs: Option<bool>,
|
||||
/// Routes any transcript tail remaining at session end through Codex. Defaults to false.
|
||||
/// TODO: Remove this rollout knob once transcript-tail flushing is always enabled.
|
||||
#[ts(optional = nullable)]
|
||||
pub flush_transcript_tail_on_session_end: Option<bool>,
|
||||
/// Sends automatic Codex responses as realtime conversation items instead of handoff appends.
|
||||
#[ts(optional = nullable)]
|
||||
pub codex_responses_as_items: Option<bool>,
|
||||
|
||||
@@ -3642,6 +3642,7 @@ fn plugin_share_list_response_serializes_share_items() {
|
||||
remote_plugin_id: Some(
|
||||
"plugins~Plugin_00000000000000000000000000000000".to_string(),
|
||||
),
|
||||
version: None,
|
||||
local_version: None,
|
||||
name: "gmail".to_string(),
|
||||
share_context: None,
|
||||
@@ -3663,6 +3664,7 @@ fn plugin_share_list_response_serializes_share_items() {
|
||||
"plugin": {
|
||||
"id": "gmail@openai-curated-remote",
|
||||
"remotePluginId": "plugins~Plugin_00000000000000000000000000000000",
|
||||
"version": null,
|
||||
"localVersion": null,
|
||||
"name": "gmail",
|
||||
"shareContext": null,
|
||||
|
||||
@@ -685,7 +685,7 @@ pub struct ThreadIncrementElicitationParams {
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ThreadIncrementElicitationResponse {
|
||||
/// Current out-of-band elicitation count after the increment.
|
||||
pub count: u64,
|
||||
pub count: i64,
|
||||
/// Whether timeout accounting is paused after applying the increment.
|
||||
pub paused: bool,
|
||||
}
|
||||
@@ -705,7 +705,7 @@ pub struct ThreadDecrementElicitationParams {
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ThreadDecrementElicitationResponse {
|
||||
/// Current out-of-band elicitation count after the decrement.
|
||||
pub count: u64,
|
||||
pub count: i64,
|
||||
/// Whether timeout accounting remains paused after applying the decrement.
|
||||
pub paused: bool,
|
||||
}
|
||||
|
||||
@@ -213,7 +213,7 @@ Example with notification opt-out:
|
||||
- `marketplace/add` — add a remote plugin marketplace from an HTTP(S) Git URL, SSH Git URL, or GitHub `owner/repo` shorthand, then persist it into the user marketplace config. Returns the installed root path plus whether the marketplace was already present.
|
||||
- `marketplace/remove` — remove a configured marketplace by name from the user marketplace config, and delete its installed marketplace root when one exists.
|
||||
- `marketplace/upgrade` — upgrade all configured Git plugin marketplaces, or one named marketplace when `marketplaceName` is provided. Returns selected marketplace names, upgraded roots, and per-marketplace errors.
|
||||
- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, plugin `availability` (`AVAILABLE` by default or `DISABLED_BY_ADMIN` for remote plugins blocked upstream), fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. Clients can explicitly request the remote `workspace-directory`, `shared-with-me`, or `created-by-me-remote` marketplace kinds. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**).
|
||||
- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, the remote marketplace `version` and locally materialized `localVersion` when available, plugin `availability` (`AVAILABLE` by default or `DISABLED_BY_ADMIN` for remote plugins blocked upstream), fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. Clients can explicitly request the remote `workspace-directory`, `shared-with-me`, or `created-by-me-remote` marketplace kinds. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**).
|
||||
- `plugin/installed` — list installed plugin rows plus any explicitly requested local install-suggestion plugin names, without fetching the broader remote catalog. Mention surfaces can use this narrower view when they need plugin mention payloads rather than plugin-page discovery data (**under development; do not call from production clients yet**).
|
||||
- `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/hooks/apps/MCP server names. Remote plugin details expose the canonical `shareUrl` supplied by the remote catalog when available; it is `null` for local plugins or when the catalog omits it. This field is separate from `summary.shareContext`, which continues to describe user and workspace sharing state. Returned plugin skills include their current `enabled` state after local config filtering; bundled hooks are returned as lightweight declaration summaries keyed for correlation with `hooks/list`. Use `plugin/install`'s `appsNeedingAuth` to drive post-install authentication and `app/list`'s `isAccessible` to determine current connector accessibility (**under development; do not call from production clients yet**).
|
||||
- `plugin/skill/read` — read remote plugin skill markdown on demand by `remoteMarketplaceName`, `remotePluginId`, and `skillName`. This lets clients preview uninstalled remote plugin skills without downloading the plugin bundle.
|
||||
@@ -1301,7 +1301,7 @@ Event notifications are the server-initiated event stream for thread lifecycles,
|
||||
|
||||
Thread realtime uses a separate thread-scoped notification surface. `thread/realtime/*` notifications are ephemeral transport events, not `ThreadItem`s, and are not returned by `thread/read`, `thread/resume`, or `thread/fork`.
|
||||
|
||||
Recoverable configuration and initialization warnings use the existing `configWarning` notification: `{ summary, details?, path?, range? }`. App-server may emit it during initialization for config parsing and related setup diagnostics.
|
||||
Recoverable configuration and initialization warnings use the existing `configWarning` notification: `{ summary, details?, path?, range? }`. App-server may emit it during initialization for config parsing and related setup diagnostics, or to the requesting connection during `thread/start` when that thread's exec-policy rules fail to parse.
|
||||
|
||||
Generic runtime warnings use the `warning` notification: `{ threadId?, message }`. App-server emits this for non-fatal warnings from the core event stream, including cases where not all enabled skills are included in the model-visible skills list for a session.
|
||||
|
||||
@@ -1929,8 +1929,8 @@ Codex supports these authentication modes. The current mode is surfaced in `acco
|
||||
- `account/login/cancel` — cancel a pending managed ChatGPT login by `loginId`.
|
||||
- `account/logout` — sign out; triggers `account/updated`.
|
||||
- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, `personalAccessToken`, or `null`) and includes the current ChatGPT `planType` when available.
|
||||
- `account/rateLimits/read` — fetch ChatGPT rate limits, an optional effective monthly credit limit, and the number of earned rate-limit resets currently available. Rate-limit updates arrive via `account/rateLimits/updated` (notify); the reset count is snapshot-only.
|
||||
- `account/rateLimitResetCredit/consume` — consume one earned reset using a caller-provided idempotency key.
|
||||
- `account/rateLimits/read` — fetch ChatGPT rate limits, an optional effective monthly credit limit, and the earned rate-limit resets currently available, including expiry details when provided by the backend. Rate-limit updates arrive via `account/rateLimits/updated` (notify); reset-credit data is snapshot-only.
|
||||
- `account/rateLimitResetCredit/consume` — consume one earned reset using a caller-provided idempotency key, optionally selecting a reset-credit ID returned by `account/rateLimits/read`.
|
||||
- `account/usage/read` — fetch ChatGPT account token-activity summary and daily buckets.
|
||||
- `account/workspaceMessages/read` — fetch active workspace messages, including workspace notification headlines when available.
|
||||
- `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change. This is a sparse rolling update; merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot.
|
||||
@@ -2032,7 +2032,30 @@ Field notes:
|
||||
|
||||
```json
|
||||
{ "method": "account/rateLimits/read", "id": 7 }
|
||||
{ "id": 7, "result": { "rateLimits": { "primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 }, "secondary": null, "rateLimitReachedType": null }, "rateLimitResetCredits": { "availableCount": 2 } } }
|
||||
{
|
||||
"id": 7,
|
||||
"result": {
|
||||
"rateLimits": {
|
||||
"primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 },
|
||||
"secondary": null,
|
||||
"rateLimitReachedType": null
|
||||
},
|
||||
"rateLimitResetCredits": {
|
||||
"availableCount": 2,
|
||||
"credits": [
|
||||
{
|
||||
"id": "RateLimitResetCredit_1",
|
||||
"resetType": "codexRateLimits",
|
||||
"status": "available",
|
||||
"grantedAt": 1781654400,
|
||||
"expiresAt": 1784246400,
|
||||
"title": "Full reset (Weekly + 5 hr)",
|
||||
"description": "Ready to redeem"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
{ "method": "account/rateLimits/updated", "params": { "rateLimits": { … } } }
|
||||
```
|
||||
|
||||
@@ -2043,23 +2066,27 @@ Field notes:
|
||||
- `resetsAt` is a Unix timestamp (seconds) for the next reset.
|
||||
- `rateLimitReachedType` identifies the backend-classified limit state when one has been reached.
|
||||
- `individualLimit` describes the effective monthly credit limit when available. In an `account/rateLimits/read` response, `null` means no monthly limit is available. In a sparse `account/rateLimits/updated` notification, nullable account metadata may be unavailable and does not clear a previously observed value.
|
||||
- `rateLimitResetCredits` contains the available earned-reset count when the backend provides it; otherwise it is `null`. Refetch `account/rateLimits/read` after consuming a reset.
|
||||
- `rateLimitResetCredits` contains the available earned-reset count when the backend provides it; otherwise it is `null`.
|
||||
- `rateLimitResetCredits.credits` is `null` when only the count is available. An empty array means details were fetched and no available credits were returned.
|
||||
- The backend may cap `rateLimitResetCredits.credits`, so `availableCount` is the authoritative total and can be greater than the number of detail rows.
|
||||
- Refetch `account/rateLimits/read` after consuming a reset.
|
||||
|
||||
### 8) Earned rate-limit resets (ChatGPT)
|
||||
|
||||
```json
|
||||
{ "method": "account/rateLimitResetCredit/consume", "id": 8, "params": { "idempotencyKey": "8ae96ff3-3425-4f4c-8772-b6fd61502868" } }
|
||||
{ "method": "account/rateLimitResetCredit/consume", "id": 8, "params": { "idempotencyKey": "8ae96ff3-3425-4f4c-8772-b6fd61502868", "creditId": "RateLimitResetCredit_1" } }
|
||||
{ "id": 8, "result": { "outcome": "reset" } }
|
||||
```
|
||||
|
||||
Field notes:
|
||||
|
||||
- `idempotencyKey` must be non-empty. A UUID is recommended for each logical redemption attempt; reuse the same value when retrying that attempt.
|
||||
- `creditId` is optional. When provided, it must be a non-empty opaque ID returned by `account/rateLimits/read`; when omitted, the backend selects the next available credit.
|
||||
- `reset` means a credit was consumed.
|
||||
- `alreadyRedeemed` means the same redemption completed previously. Treat it as an idempotent success and refresh account limits.
|
||||
- `nothingToReset` means there is no eligible rate-limit window to reset.
|
||||
- `noCredit` means the account has no earned reset credits available.
|
||||
- Refetch `account/rateLimits/read` after consuming a reset instead of inferring updated windows from this response.
|
||||
- Refetch `account/rateLimits/read` after consuming a reset instead of inferring updated state from this response.
|
||||
|
||||
### 9) Workspace messages (ChatGPT)
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ use codex_arg0::Arg0DispatchPaths;
|
||||
use codex_config::CloudConfigBundleLoader;
|
||||
use codex_config::LoaderOverrides;
|
||||
use codex_config::ThreadConfigLoader;
|
||||
use codex_core::check_execpolicy_for_warnings;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::resolve_installation_id;
|
||||
use codex_exec_server::EnvironmentManager;
|
||||
@@ -349,7 +350,16 @@ impl InProcessClientHandle {
|
||||
/// This function sends `initialize` followed by `initialized` before returning
|
||||
/// the handle, so callers receive a ready-to-use runtime. If initialize fails,
|
||||
/// the runtime is shut down and an `InvalidData` error is returned.
|
||||
pub async fn start(args: InProcessStartArgs) -> IoResult<InProcessClientHandle> {
|
||||
pub async fn start(mut args: InProcessStartArgs) -> IoResult<InProcessClientHandle> {
|
||||
if let Ok(Some(err)) = check_execpolicy_for_warnings(&args.config.config_layer_stack).await {
|
||||
let (path, range) = crate::exec_policy_warning_location(&err);
|
||||
args.config_warnings.push(ConfigWarningNotification {
|
||||
summary: "Error parsing rules; custom rules not applied.".to_string(),
|
||||
details: Some(err.to_string()),
|
||||
path,
|
||||
range,
|
||||
});
|
||||
}
|
||||
let initialize = args.initialize.clone();
|
||||
let client = start_uninitialized(args).await?;
|
||||
|
||||
|
||||
@@ -320,6 +320,16 @@ fn exec_policy_warning_location(err: &ExecPolicyError) -> (Option<String>, Optio
|
||||
}
|
||||
}
|
||||
|
||||
fn exec_policy_config_warning(err: &ExecPolicyError) -> ConfigWarningNotification {
|
||||
let (path, range) = exec_policy_warning_location(err);
|
||||
ConfigWarningNotification {
|
||||
summary: "Error parsing rules; custom rules not applied.".to_string(),
|
||||
details: Some(err.to_string()),
|
||||
path,
|
||||
range,
|
||||
}
|
||||
}
|
||||
|
||||
fn app_text_range(range: &CoreTextRange) -> AppTextRange {
|
||||
AppTextRange {
|
||||
start: AppTextPosition {
|
||||
@@ -612,14 +622,7 @@ pub async fn run_main_with_transport_options(
|
||||
}
|
||||
|
||||
if let Ok(Some(err)) = check_execpolicy_for_warnings(&config.config_layer_stack).await {
|
||||
let (path, range) = exec_policy_warning_location(&err);
|
||||
let message = ConfigWarningNotification {
|
||||
summary: "Error parsing rules; custom rules not applied.".to_string(),
|
||||
details: Some(err.to_string()),
|
||||
path,
|
||||
range,
|
||||
};
|
||||
config_warnings.push(message);
|
||||
config_warnings.push(exec_policy_config_warning(&err));
|
||||
}
|
||||
|
||||
if let Some(warning) = project_config_warning(&config) {
|
||||
|
||||
@@ -447,7 +447,7 @@ impl MessageProcessor {
|
||||
outgoing.clone(),
|
||||
analytics_events_client.clone(),
|
||||
Arc::clone(&config),
|
||||
config_warnings,
|
||||
config_warnings.clone(),
|
||||
rpc_transport,
|
||||
);
|
||||
let marketplace_processor = MarketplaceRequestProcessor::new(
|
||||
@@ -495,6 +495,7 @@ impl MessageProcessor {
|
||||
state_db.clone(),
|
||||
log_db,
|
||||
Arc::clone(&skills_watcher),
|
||||
config_warnings,
|
||||
);
|
||||
let turn_processor = TurnRequestProcessor::new(
|
||||
auth_manager.clone(),
|
||||
|
||||
@@ -156,7 +156,10 @@ use codex_app_server_protocol::PluginSource;
|
||||
use codex_app_server_protocol::PluginSummary;
|
||||
use codex_app_server_protocol::PluginUninstallParams;
|
||||
use codex_app_server_protocol::PluginUninstallResponse;
|
||||
use codex_app_server_protocol::RateLimitResetCredit;
|
||||
use codex_app_server_protocol::RateLimitResetCreditStatus;
|
||||
use codex_app_server_protocol::RateLimitResetCreditsSummary;
|
||||
use codex_app_server_protocol::RateLimitResetType;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ReviewDelivery as ApiReviewDelivery;
|
||||
use codex_app_server_protocol::ReviewStartParams;
|
||||
@@ -298,6 +301,8 @@ use codex_backend_client::CodexWorkspaceMessage as BackendWorkspaceMessage;
|
||||
use codex_backend_client::CodexWorkspaceMessageType as BackendWorkspaceMessageType;
|
||||
use codex_backend_client::CodexWorkspaceMessagesResponse as BackendWorkspaceMessagesResponse;
|
||||
use codex_backend_client::ConsumeRateLimitResetCreditCode as BackendConsumeRateLimitResetCreditCode;
|
||||
use codex_backend_client::RateLimitResetCreditDetails as BackendRateLimitResetCreditDetails;
|
||||
use codex_backend_client::RateLimitResetCreditsDetails as BackendRateLimitResetCreditsDetails;
|
||||
use codex_backend_client::RequestError as BackendRequestError;
|
||||
use codex_backend_client::TokenUsageProfile;
|
||||
use codex_chatgpt::connectors;
|
||||
|
||||
@@ -891,9 +891,11 @@ impl AccountRequestProcessor {
|
||||
let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth)
|
||||
.map_err(|err| internal_error(format!("failed to construct backend client: {err}")))?;
|
||||
|
||||
let response = client
|
||||
.get_rate_limits_with_reset_credits()
|
||||
.await
|
||||
let (response, detailed_rate_limit_reset_credits) = tokio::join!(
|
||||
client.get_rate_limits_with_reset_credits(),
|
||||
Self::detailed_rate_limit_reset_credits(&client),
|
||||
);
|
||||
let response = response
|
||||
.map_err(|err| internal_error(format!("failed to fetch codex rate limits: {err}")))?;
|
||||
if response.rate_limits.is_empty() {
|
||||
return Err(internal_error(
|
||||
@@ -920,6 +922,15 @@ impl AccountRequestProcessor {
|
||||
.cloned()
|
||||
.unwrap_or_else(|| response.rate_limits[0].clone());
|
||||
|
||||
let rate_limit_reset_credits = detailed_rate_limit_reset_credits.or_else(|| {
|
||||
response
|
||||
.rate_limit_reset_credits
|
||||
.map(|summary| RateLimitResetCreditsSummary {
|
||||
available_count: summary.available_count,
|
||||
credits: None,
|
||||
})
|
||||
});
|
||||
|
||||
Ok(GetAccountRateLimitsResponse {
|
||||
rate_limits: rate_limits.into(),
|
||||
rate_limits_by_limit_id: Some(
|
||||
@@ -928,11 +939,7 @@ impl AccountRequestProcessor {
|
||||
.map(|(limit_id, snapshot)| (limit_id, snapshot.into()))
|
||||
.collect(),
|
||||
),
|
||||
rate_limit_reset_credits: response.rate_limit_reset_credits.map(|summary| {
|
||||
RateLimitResetCreditsSummary {
|
||||
available_count: summary.available_count,
|
||||
}
|
||||
}),
|
||||
rate_limit_reset_credits,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,47 @@
|
||||
use super::*;
|
||||
|
||||
const RATE_LIMIT_RESET_REQUEST_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 10);
|
||||
const RATE_LIMIT_RESET_DETAILS_REQUEST_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 5);
|
||||
#[cfg(debug_assertions)]
|
||||
const RATE_LIMIT_RESET_REQUEST_TIMEOUT_ENV_VAR: &str =
|
||||
"CODEX_TEST_RATE_LIMIT_RESET_REQUEST_TIMEOUT_MS";
|
||||
|
||||
impl AccountRequestProcessor {
|
||||
pub(super) async fn detailed_rate_limit_reset_credits(
|
||||
client: &BackendClient,
|
||||
) -> Option<RateLimitResetCreditsSummary> {
|
||||
let details = match tokio::time::timeout(
|
||||
RATE_LIMIT_RESET_DETAILS_REQUEST_TIMEOUT,
|
||||
client.list_rate_limit_reset_credits(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(details)) => details,
|
||||
Ok(Err(err)) => {
|
||||
tracing::warn!(
|
||||
"failed to fetch rate limit reset credit details; falling back to the usage response: {err}"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!(
|
||||
"rate limit reset credit detail request timed out; falling back to the usage response"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
match rate_limit_reset_credits_from_backend(details) {
|
||||
Ok(summary) => Some(summary),
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"failed to parse rate limit reset credit details; falling back to the usage response: {err}"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn consume_account_rate_limit_reset_credit(
|
||||
&self,
|
||||
params: ConsumeAccountRateLimitResetCreditParams,
|
||||
@@ -13,6 +49,9 @@ impl AccountRequestProcessor {
|
||||
if params.idempotency_key.is_empty() {
|
||||
return Err(invalid_request("idempotencyKey must not be empty"));
|
||||
}
|
||||
if params.credit_id.as_deref().is_some_and(str::is_empty) {
|
||||
return Err(invalid_request("creditId must not be empty"));
|
||||
}
|
||||
|
||||
let client = self.rate_limit_reset_backend_client().await?;
|
||||
let request_timeout = RATE_LIMIT_RESET_REQUEST_TIMEOUT;
|
||||
@@ -22,10 +61,20 @@ impl AccountRequestProcessor {
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.map(Duration::from_millis)
|
||||
.unwrap_or(request_timeout);
|
||||
let response = tokio::time::timeout(
|
||||
request_timeout,
|
||||
client.consume_rate_limit_reset_credit(¶ms.idempotency_key),
|
||||
)
|
||||
let response = tokio::time::timeout(request_timeout, async {
|
||||
match params.credit_id.as_deref() {
|
||||
Some(credit_id) => {
|
||||
client
|
||||
.consume_rate_limit_reset_credit_by_id(¶ms.idempotency_key, credit_id)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
client
|
||||
.consume_rate_limit_reset_credit(¶ms.idempotency_key)
|
||||
.await
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| internal_error("rate limit reset consume timed out"))?
|
||||
.map_err(|err| internal_error(format!("failed to consume rate limit reset: {err}")))?;
|
||||
@@ -64,3 +113,56 @@ impl AccountRequestProcessor {
|
||||
.map_err(|err| internal_error(format!("failed to construct backend client: {err}")))
|
||||
}
|
||||
}
|
||||
|
||||
fn rate_limit_reset_credits_from_backend(
|
||||
details: BackendRateLimitResetCreditsDetails,
|
||||
) -> Result<RateLimitResetCreditsSummary, String> {
|
||||
let credits = details
|
||||
.credits
|
||||
.into_iter()
|
||||
.map(rate_limit_reset_credit_from_backend)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(RateLimitResetCreditsSummary {
|
||||
available_count: details.available_count,
|
||||
credits: Some(credits),
|
||||
})
|
||||
}
|
||||
|
||||
fn rate_limit_reset_credit_from_backend(
|
||||
credit: BackendRateLimitResetCreditDetails,
|
||||
) -> Result<RateLimitResetCredit, String> {
|
||||
let reset_type = match credit.reset_type.as_str() {
|
||||
"codex_rate_limits" => RateLimitResetType::CodexRateLimits,
|
||||
_ => RateLimitResetType::Unknown,
|
||||
};
|
||||
let status = match credit.status.as_str() {
|
||||
"available" => RateLimitResetCreditStatus::Available,
|
||||
"redeeming" => RateLimitResetCreditStatus::Redeeming,
|
||||
"redeemed" => RateLimitResetCreditStatus::Redeemed,
|
||||
_ => RateLimitResetCreditStatus::Unknown,
|
||||
};
|
||||
let granted_at = rate_limit_reset_credit_timestamp(&credit.granted_at)
|
||||
.map_err(|err| format!("invalid granted_at for credit `{}`: {err}", credit.id))?;
|
||||
let expires_at = credit
|
||||
.expires_at
|
||||
.as_deref()
|
||||
.map(rate_limit_reset_credit_timestamp)
|
||||
.transpose()
|
||||
.map_err(|err| format!("invalid expires_at for credit `{}`: {err}", credit.id))?;
|
||||
|
||||
Ok(RateLimitResetCredit {
|
||||
id: credit.id,
|
||||
reset_type,
|
||||
status,
|
||||
granted_at,
|
||||
expires_at,
|
||||
title: credit.title,
|
||||
description: credit.description,
|
||||
})
|
||||
}
|
||||
|
||||
fn rate_limit_reset_credit_timestamp(timestamp: &str) -> Result<i64, String> {
|
||||
DateTime::parse_from_rfc3339(timestamp)
|
||||
.map(|timestamp| timestamp.timestamp())
|
||||
.map_err(|err| format!("failed to parse timestamp `{timestamp}`: {err}"))
|
||||
}
|
||||
|
||||
@@ -155,6 +155,7 @@ fn convert_configured_marketplace_plugin_to_plugin_summary(
|
||||
PluginSummary {
|
||||
id: plugin.id,
|
||||
remote_plugin_id: None,
|
||||
version: None,
|
||||
local_version: plugin.local_version,
|
||||
installed: plugin.installed,
|
||||
enabled: plugin.enabled,
|
||||
@@ -1095,6 +1096,7 @@ impl PluginRequestProcessor {
|
||||
summary: PluginSummary {
|
||||
id: outcome.plugin.id,
|
||||
remote_plugin_id: None,
|
||||
version: None,
|
||||
local_version: outcome.plugin.local_version,
|
||||
name: outcome.plugin.name,
|
||||
share_context,
|
||||
@@ -2168,6 +2170,7 @@ fn remote_plugin_summary_to_info(summary: RemoteCatalogPluginSummary) -> PluginS
|
||||
PluginSummary {
|
||||
id: summary.id,
|
||||
remote_plugin_id: Some(summary.remote_plugin_id),
|
||||
version: summary.version,
|
||||
local_version: summary.local_version,
|
||||
name: summary.name,
|
||||
share_context: summary
|
||||
|
||||
@@ -364,6 +364,7 @@ pub(crate) struct ThreadRequestProcessor {
|
||||
pub(super) log_db: Option<LogDbLayer>,
|
||||
pub(super) background_tasks: TaskTracker,
|
||||
pub(super) skills_watcher: Arc<SkillsWatcher>,
|
||||
pub(super) initial_config_warnings: Arc<Vec<ConfigWarningNotification>>,
|
||||
}
|
||||
|
||||
/// Outcome of trying to satisfy a resume request from an already loaded thread.
|
||||
@@ -395,6 +396,7 @@ impl ThreadRequestProcessor {
|
||||
state_db: Option<StateDbHandle>,
|
||||
log_db: Option<LogDbLayer>,
|
||||
skills_watcher: Arc<SkillsWatcher>,
|
||||
initial_config_warnings: Vec<ConfigWarningNotification>,
|
||||
) -> Self {
|
||||
Self {
|
||||
auth_manager,
|
||||
@@ -413,6 +415,7 @@ impl ThreadRequestProcessor {
|
||||
log_db,
|
||||
background_tasks: TaskTracker::new(),
|
||||
skills_watcher,
|
||||
initial_config_warnings: Arc::new(initial_config_warnings),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -968,6 +971,7 @@ impl ThreadRequestProcessor {
|
||||
};
|
||||
let request_trace = request_context.request_trace();
|
||||
let config_manager = self.config_manager.clone();
|
||||
let initial_config_warnings = Arc::clone(&self.initial_config_warnings);
|
||||
let outgoing = Arc::clone(&listener_task_context.outgoing);
|
||||
let error_request_id = request_id.clone();
|
||||
let thread_start_task = async move {
|
||||
@@ -990,6 +994,7 @@ impl ThreadRequestProcessor {
|
||||
allow_provider_model_fallback,
|
||||
experimental_raw_events,
|
||||
request_trace,
|
||||
initial_config_warnings,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -1066,6 +1071,7 @@ impl ThreadRequestProcessor {
|
||||
allow_provider_model_fallback: bool,
|
||||
experimental_raw_events: bool,
|
||||
request_trace: Option<W3cTraceContext>,
|
||||
initial_config_warnings: Arc<Vec<ConfigWarningNotification>>,
|
||||
) -> Result<(), JSONRPCErrorError> {
|
||||
let thread_start_started_at = std::time::Instant::now();
|
||||
let requested_cwd = typesafe_overrides.cwd.clone();
|
||||
@@ -1139,6 +1145,21 @@ impl ThreadRequestProcessor {
|
||||
.map_err(|err| config_load_error(&err))?;
|
||||
}
|
||||
|
||||
if let Ok(Some(err)) =
|
||||
codex_core::check_execpolicy_for_warnings(&config.config_layer_stack).await
|
||||
{
|
||||
let notification = crate::exec_policy_config_warning(&err);
|
||||
if !initial_config_warnings.contains(¬ification) {
|
||||
listener_task_context
|
||||
.outgoing
|
||||
.send_server_notification_to_connections(
|
||||
&[request_id.connection_id],
|
||||
ServerNotification::ConfigWarning(notification),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
let environments = environments.unwrap_or_else(|| {
|
||||
listener_task_context
|
||||
.thread_manager
|
||||
|
||||
@@ -1002,6 +1002,9 @@ impl TurnRequestProcessor {
|
||||
thread.as_ref(),
|
||||
Op::RealtimeConversationStart(ConversationStartParams {
|
||||
client_managed_handoffs: params.client_managed_handoffs.unwrap_or(false),
|
||||
flush_transcript_tail_on_session_end: params
|
||||
.flush_transcript_tail_on_session_end
|
||||
.unwrap_or(false),
|
||||
codex_responses_as_items: params.codex_responses_as_items.unwrap_or(false),
|
||||
codex_response_item_prefix: params.codex_response_item_prefix,
|
||||
codex_response_handoff_prefix: params.codex_response_handoff_prefix,
|
||||
|
||||
@@ -7,6 +7,7 @@ use app_test_support::to_response;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use codex_app_server_protocol::ClientInfo;
|
||||
use codex_app_server_protocol::ConfigWarningNotification;
|
||||
use codex_app_server_protocol::InitializeParams;
|
||||
use codex_app_server_protocol::JSONRPCError;
|
||||
use codex_app_server_protocol::JSONRPCMessage;
|
||||
@@ -18,6 +19,8 @@ use codex_app_server_protocol::ThreadLoadedListParams;
|
||||
use codex_app_server_protocol::ThreadLoadedListResponse;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_core::config::set_project_trust_level;
|
||||
use codex_protocol::config_types::TrustLevel;
|
||||
use futures::SinkExt;
|
||||
use futures::StreamExt;
|
||||
use hmac::Hmac;
|
||||
@@ -104,6 +107,106 @@ async fn websocket_transport_routes_per_connection_handshake_and_responses() ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_start_routes_project_exec_policy_warning_to_requester() -> Result<()> {
|
||||
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri(), "never")?;
|
||||
|
||||
let project = TempDir::new()?;
|
||||
std::fs::create_dir(project.path().join(".git"))?;
|
||||
let rules_dir = project.path().join(".codex/rules");
|
||||
std::fs::create_dir_all(&rules_dir)?;
|
||||
let rules_path = rules_dir.join("broken.rules");
|
||||
std::fs::write(&rules_path, "prefix_rule(")?;
|
||||
set_project_trust_level(codex_home.path(), project.path(), TrustLevel::Trusted)?;
|
||||
|
||||
let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?;
|
||||
let mut requester = connect_websocket(bind_addr).await?;
|
||||
let mut other_client = connect_websocket(bind_addr).await?;
|
||||
|
||||
send_initialize_request(&mut requester, /*id*/ 1, "requester").await?;
|
||||
read_response_for_id(&mut requester, /*id*/ 1).await?;
|
||||
send_initialize_request(&mut other_client, /*id*/ 2, "other_client").await?;
|
||||
read_response_for_id(&mut other_client, /*id*/ 2).await?;
|
||||
|
||||
send_request(
|
||||
&mut requester,
|
||||
"thread/start",
|
||||
/*id*/ 3,
|
||||
Some(serde_json::to_value(ThreadStartParams {
|
||||
cwd: Some(project.path().display().to_string()),
|
||||
model: Some("mock-model".to_string()),
|
||||
..Default::default()
|
||||
})?),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let target_id = RequestId::Integer(3);
|
||||
let warning_summary = "Error parsing rules; custom rules not applied.";
|
||||
let is_exec_policy_warning = |notification: &JSONRPCNotification| {
|
||||
notification.method == "configWarning"
|
||||
&& notification
|
||||
.params
|
||||
.as_ref()
|
||||
.and_then(|params| params.get("summary"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
== Some(warning_summary)
|
||||
};
|
||||
let mut response = None;
|
||||
let mut warning = None;
|
||||
while response.is_none() || warning.is_none() {
|
||||
match read_jsonrpc_message(&mut requester).await? {
|
||||
JSONRPCMessage::Response(candidate) if candidate.id == target_id => {
|
||||
response = Some(candidate);
|
||||
}
|
||||
JSONRPCMessage::Notification(candidate) if is_exec_policy_warning(&candidate) => {
|
||||
warning = Some(candidate);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let _: ThreadStartResponse = to_response(response.context("missing thread/start response")?)?;
|
||||
let warning: ConfigWarningNotification = serde_json::from_value(
|
||||
warning
|
||||
.context("missing exec-policy configWarning")?
|
||||
.params
|
||||
.context("configWarning should include params")?,
|
||||
)?;
|
||||
assert_eq!(
|
||||
warning
|
||||
.path
|
||||
.as_deref()
|
||||
.map(Path::new)
|
||||
.and_then(Path::file_name),
|
||||
Some(std::ffi::OsStr::new("broken.rules"))
|
||||
);
|
||||
|
||||
match timeout(Duration::from_millis(250), async {
|
||||
loop {
|
||||
let message = read_jsonrpc_message(&mut other_client).await?;
|
||||
if let JSONRPCMessage::Notification(notification) = message
|
||||
&& is_exec_policy_warning(¬ification)
|
||||
{
|
||||
return Ok::<_, anyhow::Error>(notification);
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(_)) => bail!("exec-policy configWarning leaked to another connection"),
|
||||
Ok(Err(err)) => return Err(err),
|
||||
Err(_) => {}
|
||||
}
|
||||
|
||||
process
|
||||
.kill()
|
||||
.await
|
||||
.context("failed to stop websocket app-server process")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_transport_serves_health_endpoints_on_same_listener() -> Result<()> {
|
||||
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
|
||||
|
||||
@@ -82,6 +82,7 @@ async fn realtime_conversation_start_requires_experimental_api_capability() -> R
|
||||
let request_id = mcp
|
||||
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
|
||||
client_managed_handoffs: None,
|
||||
flush_transcript_tail_on_session_end: None,
|
||||
codex_responses_as_items: None,
|
||||
codex_response_item_prefix: None,
|
||||
codex_response_handoff_prefix: None,
|
||||
@@ -198,6 +199,7 @@ async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result<
|
||||
let request_id = mcp
|
||||
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
|
||||
client_managed_handoffs: None,
|
||||
flush_transcript_tail_on_session_end: None,
|
||||
codex_responses_as_items: None,
|
||||
codex_response_item_prefix: None,
|
||||
codex_response_handoff_prefix: None,
|
||||
|
||||
@@ -452,6 +452,7 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_
|
||||
plugins: vec![PluginSummary {
|
||||
id: "valid-plugin@valid-marketplace".to_string(),
|
||||
remote_plugin_id: None,
|
||||
version: None,
|
||||
local_version: None,
|
||||
name: "valid-plugin".to_string(),
|
||||
share_context: None,
|
||||
@@ -743,6 +744,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab
|
||||
PluginSummary {
|
||||
id: "valid-plugin@alternate-marketplace".to_string(),
|
||||
remote_plugin_id: None,
|
||||
version: None,
|
||||
local_version: None,
|
||||
name: "valid-plugin".to_string(),
|
||||
share_context: None,
|
||||
@@ -780,6 +782,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab
|
||||
PluginSummary {
|
||||
id: "missing-plugin@alternate-marketplace".to_string(),
|
||||
remote_plugin_id: None,
|
||||
version: None,
|
||||
local_version: None,
|
||||
name: "missing-plugin".to_string(),
|
||||
share_context: None,
|
||||
@@ -1683,6 +1686,7 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -
|
||||
"authentication_policy": "ON_USE",
|
||||
"status": "ENABLED",
|
||||
"release": {
|
||||
"version": "1.2.3",
|
||||
"display_name": "Linear",
|
||||
"description": "Track work in Linear",
|
||||
"app_ids": [],
|
||||
@@ -1831,6 +1835,10 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -
|
||||
);
|
||||
assert_eq!(remote_marketplace.plugins[0].name, "linear");
|
||||
assert_eq!(remote_marketplace.plugins[0].source, PluginSource::Remote);
|
||||
assert_eq!(
|
||||
remote_marketplace.plugins[0].version.as_deref(),
|
||||
Some("1.2.3")
|
||||
);
|
||||
assert_eq!(
|
||||
remote_marketplace.plugins[0].local_version.as_deref(),
|
||||
Some("1.2.3")
|
||||
@@ -2030,6 +2038,7 @@ async fn plugin_list_includes_openai_curated_remote_collection_when_remote_plugi
|
||||
"authentication_policy": "ON_USE",
|
||||
"status": "ENABLED",
|
||||
"release": {
|
||||
"version": "1.2.3",
|
||||
"display_name": "Linear",
|
||||
"description": "Track work in Linear",
|
||||
"app_ids": [],
|
||||
@@ -2090,6 +2099,7 @@ async fn plugin_list_includes_openai_curated_remote_collection_when_remote_plugi
|
||||
);
|
||||
assert_eq!(plugin.name, "linear");
|
||||
assert_eq!(plugin.source, PluginSource::Remote);
|
||||
assert_eq!(plugin.version.as_deref(), Some("1.2.3"));
|
||||
assert_eq!(plugin.installed, false);
|
||||
assert_eq!(plugin.enabled, false);
|
||||
|
||||
@@ -2492,16 +2502,25 @@ plugin_sharing = true
|
||||
marketplace
|
||||
.plugins
|
||||
.iter()
|
||||
.map(|plugin| (plugin.id.clone(), plugin.installed, plugin.enabled))
|
||||
.map(|plugin| {
|
||||
(
|
||||
plugin.id.clone(),
|
||||
plugin.version.clone(),
|
||||
plugin.installed,
|
||||
plugin.enabled,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
(
|
||||
"shared-linear@workspace-shared-with-me".to_string(),
|
||||
Some("1.2.3".to_string()),
|
||||
true,
|
||||
true
|
||||
),
|
||||
(
|
||||
"unlisted-linear@workspace-shared-with-me".to_string(),
|
||||
Some("1.2.3".to_string()),
|
||||
true,
|
||||
false
|
||||
)
|
||||
|
||||
@@ -171,6 +171,7 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> {
|
||||
plugin: PluginSummary {
|
||||
id: "demo-plugin@workspace-shared-with-me".to_string(),
|
||||
remote_plugin_id: Some("plugins_123".to_string()),
|
||||
version: Some("0.1.0".to_string()),
|
||||
local_version: Some("0.1.0".to_string()),
|
||||
name: "demo-plugin".to_string(),
|
||||
share_context: Some(expected_share_context("plugins_123")),
|
||||
@@ -574,6 +575,7 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> {
|
||||
plugin: PluginSummary {
|
||||
id: "demo-plugin@workspace-shared-with-me".to_string(),
|
||||
remote_plugin_id: Some("plugins_123".to_string()),
|
||||
version: Some("0.1.0".to_string()),
|
||||
local_version: Some("0.1.0".to_string()),
|
||||
name: "demo-plugin".to_string(),
|
||||
share_context: Some(expected_share_context("plugins_123")),
|
||||
@@ -1174,6 +1176,7 @@ async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> {
|
||||
plugin: PluginSummary {
|
||||
id: "demo-plugin@workspace-shared-with-me".to_string(),
|
||||
remote_plugin_id: Some("plugins_123".to_string()),
|
||||
version: Some("0.1.0".to_string()),
|
||||
local_version: Some("0.1.0".to_string()),
|
||||
name: "demo-plugin".to_string(),
|
||||
share_context: Some(expected_share_context("plugins_123")),
|
||||
|
||||
@@ -44,6 +44,7 @@ async fn consume_rate_limit_reset_credit_requires_chatgpt_auth() -> Result<()> {
|
||||
.send_consume_account_rate_limit_reset_credit_request(
|
||||
ConsumeAccountRateLimitResetCreditParams {
|
||||
idempotency_key: "request-1".to_string(),
|
||||
credit_id: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -120,6 +121,44 @@ async fn consume_account_rate_limit_reset_credit_maps_backend_outcomes() -> Resu
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn consume_account_rate_limit_reset_credit_forwards_selected_credit_id() -> Result<()> {
|
||||
let (codex_home, server) = chatgpt_test_context().await?;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/codex/rate-limit-reset-credits/consume"))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.and(body_json(json!({
|
||||
"redeem_request_id": "request-selected",
|
||||
"credit_id": "credit-123",
|
||||
})))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_json(json!({ "code": "reset", "windows_reset": 2 })),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let mut mcp = initialized_app_server(codex_home.path()).await?;
|
||||
let request_id = mcp
|
||||
.send_consume_account_rate_limit_reset_credit_request(
|
||||
ConsumeAccountRateLimitResetCreditParams {
|
||||
idempotency_key: "request-selected".to_string(),
|
||||
credit_id: Some("credit-123".to_string()),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
read_response::<ConsumeAccountRateLimitResetCreditResponse>(&mut mcp, request_id).await?,
|
||||
ConsumeAccountRateLimitResetCreditResponse {
|
||||
outcome: ConsumeAccountRateLimitResetCreditOutcome::Reset,
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn consume_account_rate_limit_reset_credit_rejects_empty_idempotency_key() -> Result<()> {
|
||||
let (codex_home, _server) = chatgpt_test_context().await?;
|
||||
@@ -129,6 +168,7 @@ async fn consume_account_rate_limit_reset_credit_rejects_empty_idempotency_key()
|
||||
.send_consume_account_rate_limit_reset_credit_request(
|
||||
ConsumeAccountRateLimitResetCreditParams {
|
||||
idempotency_key: String::new(),
|
||||
credit_id: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -139,6 +179,26 @@ async fn consume_account_rate_limit_reset_credit_rejects_empty_idempotency_key()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn consume_account_rate_limit_reset_credit_rejects_empty_credit_id() -> Result<()> {
|
||||
let (codex_home, _server) = chatgpt_test_context().await?;
|
||||
let mut mcp = initialized_app_server(codex_home.path()).await?;
|
||||
|
||||
let request_id = mcp
|
||||
.send_consume_account_rate_limit_reset_credit_request(
|
||||
ConsumeAccountRateLimitResetCreditParams {
|
||||
idempotency_key: "request-1".to_string(),
|
||||
credit_id: Some(String::new()),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let error = read_error_response(&mut mcp, request_id).await?;
|
||||
|
||||
assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE);
|
||||
assert_eq!(error.error.message, "creditId must not be empty");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn consume_account_rate_limit_reset_credit_surfaces_backend_failure() -> Result<()> {
|
||||
let (codex_home, server) = chatgpt_test_context().await?;
|
||||
@@ -244,6 +304,7 @@ async fn send_consume_reset_credit(mcp: &mut TestAppServer, idempotency_key: &st
|
||||
mcp.send_consume_account_rate_limit_reset_credit_request(
|
||||
ConsumeAccountRateLimitResetCreditParams {
|
||||
idempotency_key: idempotency_key.to_string(),
|
||||
credit_id: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -10,7 +10,10 @@ use codex_app_server_protocol::JSONRPCError;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::LoginAccountResponse;
|
||||
use codex_app_server_protocol::RateLimitReachedType;
|
||||
use codex_app_server_protocol::RateLimitResetCredit;
|
||||
use codex_app_server_protocol::RateLimitResetCreditStatus;
|
||||
use codex_app_server_protocol::RateLimitResetCreditsSummary;
|
||||
use codex_app_server_protocol::RateLimitResetType;
|
||||
use codex_app_server_protocol::RateLimitSnapshot;
|
||||
use codex_app_server_protocol::RateLimitWindow;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
@@ -109,6 +112,16 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
|
||||
let secondary_reset_timestamp = chrono::DateTime::parse_from_rfc3339("2025-01-01T01:00:00Z")
|
||||
.expect("parse secondary reset timestamp")
|
||||
.timestamp();
|
||||
let reset_credit_granted_at = chrono::DateTime::parse_from_rfc3339("2026-06-17T00:00:00Z")
|
||||
.expect("parse reset credit grant timestamp")
|
||||
.timestamp();
|
||||
let reset_credit_expires_at = chrono::DateTime::parse_from_rfc3339("2026-07-17T00:00:00Z")
|
||||
.expect("parse reset credit expiry timestamp")
|
||||
.timestamp();
|
||||
let second_reset_credit_granted_at =
|
||||
chrono::DateTime::parse_from_rfc3339("2026-06-18T00:00:00Z")
|
||||
.expect("parse second reset credit grant timestamp")
|
||||
.timestamp();
|
||||
let response_body = json!({
|
||||
"plan_type": "pro",
|
||||
"rate_limit": {
|
||||
@@ -170,6 +183,35 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/rate-limit-reset-credits"))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"credits": [
|
||||
{
|
||||
"id": "credit-1",
|
||||
"reset_type": "codex_rate_limits",
|
||||
"status": "available",
|
||||
"granted_at": "2026-06-17T00:00:00Z",
|
||||
"expires_at": "2026-07-17T00:00:00Z",
|
||||
"title": "Full reset (Weekly + 5 hr)",
|
||||
"description": "Ready to redeem"
|
||||
},
|
||||
{
|
||||
"id": "credit-2",
|
||||
"reset_type": "future_reset_type",
|
||||
"status": "future_status",
|
||||
"granted_at": "2026-06-18T00:00:00Z",
|
||||
"expires_at": null
|
||||
}
|
||||
],
|
||||
"available_count": 2,
|
||||
"total_earned_count": 4
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let mut mcp =
|
||||
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
|
||||
@@ -260,13 +302,98 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
|
||||
.into_iter()
|
||||
.collect(),
|
||||
),
|
||||
rate_limit_reset_credits: Some(RateLimitResetCreditsSummary { available_count: 3 }),
|
||||
rate_limit_reset_credits: Some(RateLimitResetCreditsSummary {
|
||||
available_count: 2,
|
||||
credits: Some(vec![
|
||||
RateLimitResetCredit {
|
||||
id: "credit-1".to_string(),
|
||||
reset_type: RateLimitResetType::CodexRateLimits,
|
||||
status: RateLimitResetCreditStatus::Available,
|
||||
granted_at: reset_credit_granted_at,
|
||||
expires_at: Some(reset_credit_expires_at),
|
||||
title: Some("Full reset (Weekly + 5 hr)".to_string()),
|
||||
description: Some("Ready to redeem".to_string()),
|
||||
},
|
||||
RateLimitResetCredit {
|
||||
id: "credit-2".to_string(),
|
||||
reset_type: RateLimitResetType::Unknown,
|
||||
status: RateLimitResetCreditStatus::Unknown,
|
||||
granted_at: second_reset_credit_granted_at,
|
||||
expires_at: None,
|
||||
title: None,
|
||||
description: None,
|
||||
},
|
||||
]),
|
||||
}),
|
||||
};
|
||||
assert_eq!(received, expected);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_account_rate_limits_preserves_count_when_reset_credit_details_fail() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("chatgpt-token")
|
||||
.account_id("account-123")
|
||||
.plan_type("pro"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
write_chatgpt_base_url(codex_home.path(), &server.uri())?;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/usage"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"plan_type": "pro",
|
||||
"rate_limit": {
|
||||
"allowed": true,
|
||||
"limit_reached": false,
|
||||
"primary_window": {
|
||||
"used_percent": 42,
|
||||
"limit_window_seconds": 3600,
|
||||
"reset_after_seconds": 120,
|
||||
"reset_at": 1735689720
|
||||
}
|
||||
},
|
||||
"rate_limit_reset_credits": { "available_count": 3 }
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/rate-limit-reset-credits"))
|
||||
.respond_with(ResponseTemplate::new(500).set_body_string("boom"))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let mut mcp =
|
||||
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp.send_get_account_rate_limits_request().await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let received: GetAccountRateLimitsResponse = to_response(response)?;
|
||||
|
||||
assert_eq!(
|
||||
received.rate_limit_reset_credits,
|
||||
Some(RateLimitResetCreditsSummary {
|
||||
available_count: 3,
|
||||
credits: None,
|
||||
})
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_add_credits_nudge_email_requires_auth() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
@@ -349,6 +349,7 @@ impl RealtimeE2eHarness {
|
||||
.mcp
|
||||
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
|
||||
client_managed_handoffs,
|
||||
flush_transcript_tail_on_session_end: None,
|
||||
thread_id: self.thread_id.clone(),
|
||||
codex_response_item_prefix: codex_responses_as_items
|
||||
.unwrap_or(false)
|
||||
@@ -410,6 +411,7 @@ impl RealtimeE2eHarness {
|
||||
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
|
||||
thread_id: self.thread_id.clone(),
|
||||
client_managed_handoffs: None,
|
||||
flush_transcript_tail_on_session_end: None,
|
||||
codex_response_item_prefix: codex_responses_as_items
|
||||
.unwrap_or(false)
|
||||
.then(|| RESPONSE_ITEM_PREFIX.to_string()),
|
||||
@@ -672,6 +674,7 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> {
|
||||
let start_request_id = mcp
|
||||
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
|
||||
client_managed_handoffs: None,
|
||||
flush_transcript_tail_on_session_end: None,
|
||||
codex_responses_as_items: None,
|
||||
codex_response_item_prefix: None,
|
||||
codex_response_handoff_prefix: None,
|
||||
@@ -963,6 +966,7 @@ async fn realtime_start_can_skip_startup_context() -> Result<()> {
|
||||
let start_request_id = mcp
|
||||
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
|
||||
client_managed_handoffs: None,
|
||||
flush_transcript_tail_on_session_end: None,
|
||||
codex_responses_as_items: None,
|
||||
codex_response_item_prefix: None,
|
||||
codex_response_handoff_prefix: None,
|
||||
@@ -1061,6 +1065,7 @@ async fn realtime_text_output_modality_requests_text_output_and_final_transcript
|
||||
let start_request_id = mcp
|
||||
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
|
||||
client_managed_handoffs: None,
|
||||
flush_transcript_tail_on_session_end: None,
|
||||
codex_responses_as_items: None,
|
||||
codex_response_item_prefix: None,
|
||||
codex_response_handoff_prefix: None,
|
||||
@@ -1242,6 +1247,7 @@ async fn realtime_conversation_stop_emits_closed_notification() -> Result<()> {
|
||||
let start_request_id = mcp
|
||||
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
|
||||
client_managed_handoffs: None,
|
||||
flush_transcript_tail_on_session_end: None,
|
||||
codex_responses_as_items: None,
|
||||
codex_response_item_prefix: None,
|
||||
codex_response_handoff_prefix: None,
|
||||
@@ -1346,6 +1352,7 @@ async fn realtime_webrtc_start_emits_sdp_notification() -> Result<()> {
|
||||
let start_request_id = mcp
|
||||
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
|
||||
client_managed_handoffs: None,
|
||||
flush_transcript_tail_on_session_end: None,
|
||||
codex_responses_as_items: None,
|
||||
codex_response_item_prefix: None,
|
||||
codex_response_handoff_prefix: None,
|
||||
@@ -2701,6 +2708,7 @@ async fn realtime_webrtc_start_surfaces_backend_error() -> Result<()> {
|
||||
let start_request_id = mcp
|
||||
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
|
||||
client_managed_handoffs: None,
|
||||
flush_transcript_tail_on_session_end: None,
|
||||
codex_responses_as_items: None,
|
||||
codex_response_item_prefix: None,
|
||||
codex_response_handoff_prefix: None,
|
||||
@@ -2767,6 +2775,7 @@ async fn realtime_conversation_requires_feature_flag() -> Result<()> {
|
||||
let start_request_id = mcp
|
||||
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
|
||||
client_managed_handoffs: None,
|
||||
flush_transcript_tail_on_session_end: None,
|
||||
codex_responses_as_items: None,
|
||||
codex_response_item_prefix: None,
|
||||
codex_response_handoff_prefix: None,
|
||||
|
||||
@@ -28,6 +28,7 @@ use codex_exec_server::LOCAL_ENVIRONMENT_ID;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::config_types::Settings;
|
||||
use codex_protocol::protocol::PLUGINS_INSTRUCTIONS_OPEN_TAG;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use core_test_support::process::wait_for_pid_file;
|
||||
@@ -235,7 +236,9 @@ async fn selected_capability_stack_tracks_environment_availability_and_resume()
|
||||
for request in &requests[1..4] {
|
||||
assert_selected_skill_is_injected(request, /*expected_count*/ 1);
|
||||
assert_selected_plugin_tools(request);
|
||||
assert_plugin_guidance_count(request, /*expected_count*/ 1);
|
||||
}
|
||||
assert_plugin_guidance_count(&requests[4], /*expected_count*/ 1);
|
||||
assert_selected_skill_is_injected(&requests[5], /*expected_count*/ 2);
|
||||
assert_selected_plugin_tools(&requests[5]);
|
||||
let output = requests[2].function_call_output(MCP_CALL_ID);
|
||||
@@ -398,7 +401,9 @@ async fn selected_capabilities_become_available_between_samples_in_one_turn() ->
|
||||
assert_eq!(3, requests.len());
|
||||
assert_selected_skill_catalog_available(&requests[1]);
|
||||
assert_selected_plugin_tools(&requests[1]);
|
||||
assert_plugin_guidance_count(&requests[1], /*expected_count*/ 1);
|
||||
assert_selected_plugin_tools(&requests[2]);
|
||||
assert_plugin_guidance_count(&requests[2], /*expected_count*/ 1);
|
||||
let output = requests[2].function_call_output(MCP_CALL_ID);
|
||||
let output = output["output"]
|
||||
.as_str()
|
||||
@@ -537,6 +542,7 @@ fn assert_selected_capabilities_absent(request: &ResponsesRequest) {
|
||||
.all(|text| !text.contains(SKILL_DESCRIPTION))
|
||||
);
|
||||
assert_selected_plugin_tools_absent(request);
|
||||
assert_plugin_guidance_count(request, /*expected_count*/ 0);
|
||||
}
|
||||
|
||||
fn assert_selected_plugin_tools_absent(request: &ResponsesRequest) {
|
||||
@@ -555,6 +561,17 @@ fn assert_selected_plugin_tools_absent(request: &ResponsesRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_plugin_guidance_count(request: &ResponsesRequest, expected_count: usize) {
|
||||
assert_eq!(
|
||||
expected_count,
|
||||
request
|
||||
.message_input_texts("developer")
|
||||
.into_iter()
|
||||
.filter(|text| text.starts_with(PLUGINS_INSTRUCTIONS_OPEN_TAG))
|
||||
.count()
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_selected_skill_is_injected(request: &ResponsesRequest, expected_count: usize) {
|
||||
assert_selected_skill_catalog_available(request);
|
||||
|
||||
|
||||
@@ -7,14 +7,18 @@ use app_test_support::create_mock_responses_server_repeating_assistant;
|
||||
use app_test_support::to_response;
|
||||
use app_test_support::write_chatgpt_auth;
|
||||
use codex_app_server_protocol::AskForApproval;
|
||||
use codex_app_server_protocol::ConfigWarningNotification;
|
||||
use codex_app_server_protocol::JSONRPCError;
|
||||
use codex_app_server_protocol::JSONRPCMessage;
|
||||
use codex_app_server_protocol::JSONRPCNotification;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::McpServerStartupState;
|
||||
use codex_app_server_protocol::McpServerStatusUpdatedNotification;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::SandboxMode;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::TextPosition;
|
||||
use codex_app_server_protocol::TextRange;
|
||||
use codex_app_server_protocol::ThreadHistoryMode;
|
||||
use codex_app_server_protocol::ThreadSource;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
@@ -54,6 +58,17 @@ use super::analytics::wait_for_analytics_payload;
|
||||
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
const INVALID_REQUEST_ERROR_CODE: i64 = -32600;
|
||||
const EXEC_POLICY_PARSE_WARNING_SUMMARY: &str = "Error parsing rules; custom rules not applied.";
|
||||
|
||||
fn is_exec_policy_config_warning(notification: &JSONRPCNotification) -> bool {
|
||||
notification.method == "configWarning"
|
||||
&& notification
|
||||
.params
|
||||
.as_ref()
|
||||
.and_then(|params| params.get("summary"))
|
||||
.and_then(Value::as_str)
|
||||
== Some(EXEC_POLICY_PARSE_WARNING_SUMMARY)
|
||||
}
|
||||
|
||||
async fn start_thread_with_model(
|
||||
mcp: &mut TestAppServer,
|
||||
@@ -104,6 +119,124 @@ model = "gpt-5.4-mini"
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_start_warns_for_exec_policy_parse_failure_after_initialize() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let rules_dir = codex_home.path().join("rules");
|
||||
std::fs::create_dir_all(&rules_dir)?;
|
||||
let rules_path = rules_dir.join("broken.rules");
|
||||
std::fs::write(&rules_path, "prefix_rule(")?;
|
||||
let rules_path = std::fs::canonicalize(rules_path)?;
|
||||
|
||||
let request_id = mcp
|
||||
.send_thread_start_request_with_auto_env(ThreadStartParams::default())
|
||||
.await?;
|
||||
let response = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let _: ThreadStartResponse = to_response(response)?;
|
||||
|
||||
let notification = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_matching_notification(
|
||||
"exec-policy configWarning",
|
||||
is_exec_policy_config_warning,
|
||||
),
|
||||
)
|
||||
.await??;
|
||||
let notification: ServerNotification = notification.try_into()?;
|
||||
let ServerNotification::ConfigWarning(warning) = notification else {
|
||||
anyhow::bail!("unexpected notification variant");
|
||||
};
|
||||
let ConfigWarningNotification {
|
||||
summary,
|
||||
details,
|
||||
path,
|
||||
range,
|
||||
} = warning;
|
||||
assert_eq!(
|
||||
(summary, range),
|
||||
(
|
||||
"Error parsing rules; custom rules not applied.".to_string(),
|
||||
Some(TextRange {
|
||||
start: TextPosition {
|
||||
line: 1,
|
||||
column: 13,
|
||||
},
|
||||
end: TextPosition {
|
||||
line: 1,
|
||||
column: 13,
|
||||
},
|
||||
}),
|
||||
)
|
||||
);
|
||||
let path = path.context("warning should include a path")?;
|
||||
assert_eq!(
|
||||
normalize_path_for_comparison(path),
|
||||
normalize_path_for_comparison(&rules_path)
|
||||
);
|
||||
let details = details.context("warning should include details")?;
|
||||
assert!(
|
||||
details.contains("failed to parse rules file") && details.contains("broken.rules"),
|
||||
"unexpected warning details: {details}"
|
||||
);
|
||||
assert!(
|
||||
details.contains("Parse error"),
|
||||
"unexpected warning details: {details}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_start_does_not_repeat_initialize_exec_policy_warning() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let rules_dir = codex_home.path().join("rules");
|
||||
std::fs::create_dir_all(&rules_dir)?;
|
||||
std::fs::write(rules_dir.join("broken.rules"), "prefix_rule(")?;
|
||||
|
||||
let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_matching_notification(
|
||||
"initialize exec-policy configWarning",
|
||||
is_exec_policy_config_warning,
|
||||
),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_thread_start_request_with_auto_env(ThreadStartParams::default())
|
||||
.await?;
|
||||
let response = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let _: ThreadStartResponse = to_response(response)?;
|
||||
|
||||
let duplicate_warning = timeout(
|
||||
std::time::Duration::from_millis(250),
|
||||
mcp.read_stream_until_matching_notification(
|
||||
"duplicate exec-policy configWarning",
|
||||
is_exec_policy_config_warning,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
duplicate_warning.is_err(),
|
||||
"thread/start repeated the initialize exec-policy warning"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_start_provider_model_fallback_uses_bedrock_static_catalog() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
@@ -19,7 +19,7 @@ serde_json = "1"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
codex-backend-openapi-models = { path = "../codex-backend-openapi-models" }
|
||||
codex-api = { workspace = true }
|
||||
codex-client = { workspace = true }
|
||||
codex-http-client = { workspace = true }
|
||||
codex-login = { workspace = true }
|
||||
codex-model-provider = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
|
||||
@@ -9,8 +9,8 @@ use crate::types::TokenUsageProfile;
|
||||
use crate::types::TurnAttemptsSiblingTurnsResponse;
|
||||
use anyhow::Result;
|
||||
use codex_api::SharedAuthProvider;
|
||||
use codex_client::build_reqwest_client_with_custom_ca;
|
||||
use codex_client::with_chatgpt_cloudflare_cookie_store;
|
||||
use codex_http_client::build_reqwest_client_with_custom_ca;
|
||||
use codex_http_client::with_chatgpt_cloudflare_cookie_store;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::default_client::get_codex_user_agent;
|
||||
use codex_protocol::account::PlanType as AccountPlanType;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use super::Client;
|
||||
use super::PathStyle;
|
||||
use crate::types::ConsumeRateLimitResetCreditResponse;
|
||||
use crate::types::RateLimitResetCreditsDetails;
|
||||
use crate::types::RateLimitStatusWithResetCredits;
|
||||
use crate::types::RateLimitsWithResetCredits;
|
||||
use anyhow::Result;
|
||||
@@ -13,6 +14,8 @@ use serde::Serialize;
|
||||
#[derive(Serialize)]
|
||||
struct ConsumeRateLimitResetCreditRequest<'a> {
|
||||
redeem_request_id: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
credit_id: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
@@ -31,9 +34,34 @@ impl Client {
|
||||
self.decode_json(&url, &ct, &body)
|
||||
}
|
||||
|
||||
pub async fn list_rate_limit_reset_credits(&self) -> Result<RateLimitResetCreditsDetails> {
|
||||
let url = self.rate_limit_reset_credits_url();
|
||||
let req = self.http.get(&url).headers(self.headers());
|
||||
let (body, ct) = self.exec_request(req, "GET", &url).await?;
|
||||
self.decode_json(&url, &ct, &body)
|
||||
}
|
||||
|
||||
pub async fn consume_rate_limit_reset_credit(
|
||||
&self,
|
||||
redeem_request_id: &str,
|
||||
) -> Result<ConsumeRateLimitResetCreditResponse> {
|
||||
self.consume_rate_limit_reset_credit_request(redeem_request_id, /*credit_id*/ None)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn consume_rate_limit_reset_credit_by_id(
|
||||
&self,
|
||||
redeem_request_id: &str,
|
||||
credit_id: &str,
|
||||
) -> Result<ConsumeRateLimitResetCreditResponse> {
|
||||
self.consume_rate_limit_reset_credit_request(redeem_request_id, Some(credit_id))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn consume_rate_limit_reset_credit_request(
|
||||
&self,
|
||||
redeem_request_id: &str,
|
||||
credit_id: Option<&str>,
|
||||
) -> Result<ConsumeRateLimitResetCreditResponse> {
|
||||
let url = self.consume_rate_limit_reset_credit_url();
|
||||
let req = self
|
||||
@@ -41,7 +69,10 @@ impl Client {
|
||||
.post(&url)
|
||||
.headers(self.headers())
|
||||
.header(CONTENT_TYPE, HeaderValue::from_static("application/json"))
|
||||
.json(&ConsumeRateLimitResetCreditRequest { redeem_request_id });
|
||||
.json(&ConsumeRateLimitResetCreditRequest {
|
||||
redeem_request_id,
|
||||
credit_id,
|
||||
});
|
||||
let (body, ct) = self.exec_request(req, "POST", &url).await?;
|
||||
self.decode_json(&url, &ct, &body)
|
||||
}
|
||||
@@ -53,6 +84,17 @@ impl Client {
|
||||
}
|
||||
}
|
||||
|
||||
fn rate_limit_reset_credits_url(&self) -> String {
|
||||
match self.path_style {
|
||||
PathStyle::CodexApi => {
|
||||
format!("{}/api/codex/rate-limit-reset-credits", self.base_url)
|
||||
}
|
||||
PathStyle::ChatGptApi => {
|
||||
format!("{}/wham/rate-limit-reset-credits", self.base_url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn consume_rate_limit_reset_credit_url(&self) -> String {
|
||||
match self.path_style {
|
||||
PathStyle::CodexApi => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use super::*;
|
||||
use crate::types::ConsumeRateLimitResetCreditCode;
|
||||
use crate::types::RateLimitResetCreditDetails;
|
||||
use crate::types::RateLimitResetCreditsDetails;
|
||||
use crate::types::RateLimitResetCreditsSummary;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
@@ -9,6 +11,10 @@ fn rate_limit_reset_contract_uses_expected_paths_and_payloads() {
|
||||
test_client("https://example.test", PathStyle::CodexApi).rate_limit_status_url(),
|
||||
"https://example.test/api/codex/usage"
|
||||
);
|
||||
assert_eq!(
|
||||
test_client("https://example.test", PathStyle::CodexApi).rate_limit_reset_credits_url(),
|
||||
"https://example.test/api/codex/rate-limit-reset-credits"
|
||||
);
|
||||
assert_eq!(
|
||||
test_client("https://example.test", PathStyle::CodexApi)
|
||||
.consume_rate_limit_reset_credit_url(),
|
||||
@@ -19,6 +25,11 @@ fn rate_limit_reset_contract_uses_expected_paths_and_payloads() {
|
||||
.rate_limit_status_url(),
|
||||
"https://chatgpt.com/backend-api/wham/usage"
|
||||
);
|
||||
assert_eq!(
|
||||
test_client("https://chatgpt.com/backend-api", PathStyle::ChatGptApi)
|
||||
.rate_limit_reset_credits_url(),
|
||||
"https://chatgpt.com/backend-api/wham/rate-limit-reset-credits"
|
||||
);
|
||||
assert_eq!(
|
||||
test_client("https://chatgpt.com/backend-api", PathStyle::ChatGptApi)
|
||||
.consume_rate_limit_reset_credit_url(),
|
||||
@@ -28,10 +39,22 @@ fn rate_limit_reset_contract_uses_expected_paths_and_payloads() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(ConsumeRateLimitResetCreditRequest {
|
||||
redeem_request_id: "redeem-123",
|
||||
credit_id: None,
|
||||
})
|
||||
.unwrap(),
|
||||
serde_json::json!({ "redeem_request_id": "redeem-123" })
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(ConsumeRateLimitResetCreditRequest {
|
||||
redeem_request_id: "redeem-456",
|
||||
credit_id: Some("credit-123"),
|
||||
})
|
||||
.unwrap(),
|
||||
serde_json::json!({
|
||||
"redeem_request_id": "redeem-456",
|
||||
"credit_id": "credit-123",
|
||||
})
|
||||
);
|
||||
|
||||
let status: RateLimitStatusWithResetCredits = serde_json::from_value(serde_json::json!({
|
||||
"plan_type": "plus",
|
||||
@@ -43,6 +66,60 @@ fn rate_limit_reset_contract_uses_expected_paths_and_payloads() {
|
||||
Some(RateLimitResetCreditsSummary { available_count: 3 })
|
||||
);
|
||||
|
||||
let details: RateLimitResetCreditsDetails = serde_json::from_value(serde_json::json!({
|
||||
"credits": [
|
||||
{
|
||||
"id": "credit-1",
|
||||
"reset_type": "codex_rate_limits",
|
||||
"status": "available",
|
||||
"granted_at": "2026-06-17T00:00:00Z",
|
||||
"expires_at": "2026-07-17T00:00:00Z",
|
||||
"redeem_started_at": null,
|
||||
"redeemed_at": null,
|
||||
"profile_image_url": "https://example.test/avatar.png",
|
||||
"profile_user_id": "@friend",
|
||||
"title": "Full reset (Weekly + 5 hr)",
|
||||
"description": "Ready to redeem"
|
||||
},
|
||||
{
|
||||
"id": "credit-2",
|
||||
"reset_type": "codex_rate_limits",
|
||||
"status": "available",
|
||||
"granted_at": "2026-06-18T00:00:00Z",
|
||||
"expires_at": null
|
||||
}
|
||||
],
|
||||
"available_count": 2,
|
||||
"total_earned_count": 4
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
details,
|
||||
RateLimitResetCreditsDetails {
|
||||
credits: vec![
|
||||
RateLimitResetCreditDetails {
|
||||
id: "credit-1".to_string(),
|
||||
reset_type: "codex_rate_limits".to_string(),
|
||||
status: "available".to_string(),
|
||||
granted_at: "2026-06-17T00:00:00Z".to_string(),
|
||||
expires_at: Some("2026-07-17T00:00:00Z".to_string()),
|
||||
title: Some("Full reset (Weekly + 5 hr)".to_string()),
|
||||
description: Some("Ready to redeem".to_string()),
|
||||
},
|
||||
RateLimitResetCreditDetails {
|
||||
id: "credit-2".to_string(),
|
||||
reset_type: "codex_rate_limits".to_string(),
|
||||
status: "available".to_string(),
|
||||
granted_at: "2026-06-18T00:00:00Z".to_string(),
|
||||
expires_at: None,
|
||||
title: None,
|
||||
description: None,
|
||||
},
|
||||
],
|
||||
available_count: 2,
|
||||
}
|
||||
);
|
||||
|
||||
let response: ConsumeRateLimitResetCreditResponse = serde_json::from_value(serde_json::json!({
|
||||
"code": "reset",
|
||||
"credit": { "id": "ignored-by-cli" },
|
||||
|
||||
@@ -18,6 +18,8 @@ pub use types::DeliveredConfigToml;
|
||||
pub use types::DeliveredRequirementsToml;
|
||||
pub use types::DeliveredTomlFragment;
|
||||
pub use types::PaginatedListTaskListItem;
|
||||
pub use types::RateLimitResetCreditDetails;
|
||||
pub use types::RateLimitResetCreditsDetails;
|
||||
pub use types::RateLimitResetCreditsSummary;
|
||||
pub use types::RateLimitsWithResetCredits;
|
||||
pub use types::TaskListItem;
|
||||
|
||||
@@ -23,6 +23,23 @@ pub struct RateLimitResetCreditsSummary {
|
||||
pub available_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
|
||||
pub struct RateLimitResetCreditsDetails {
|
||||
pub credits: Vec<RateLimitResetCreditDetails>,
|
||||
pub available_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
|
||||
pub struct RateLimitResetCreditDetails {
|
||||
pub id: String,
|
||||
pub reset_type: String,
|
||||
pub status: String,
|
||||
pub granted_at: String,
|
||||
pub expires_at: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RateLimitsWithResetCredits {
|
||||
pub rate_limits: Vec<RateLimitSnapshot>,
|
||||
|
||||
@@ -16,7 +16,7 @@ workspace = true
|
||||
anyhow = { workspace = true }
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
codex-client = { workspace = true }
|
||||
codex-http-client = { workspace = true }
|
||||
codex-cloud-tasks-client = { workspace = true }
|
||||
# TODO: codex-cloud-tasks-mock-client should be in dev-dependencies.
|
||||
codex-cloud-tasks-mock-client = { workspace = true }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use codex_client::build_reqwest_client_with_custom_ca;
|
||||
use codex_http_client::build_reqwest_client_with_custom_ca;
|
||||
use reqwest::header::CONTENT_TYPE;
|
||||
use reqwest::header::HeaderMap;
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -28,3 +28,4 @@ codex-protocol = { workspace = true }
|
||||
codex-utils-cargo-bin = { workspace = true }
|
||||
pretty_assertions = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
@@ -504,13 +504,17 @@ return;
|
||||
next_callback_event(&mut events_rx).await,
|
||||
CallbackEvent::Started("tool_call_slow".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
let mut closure_events = vec![
|
||||
next_callback_event(&mut events_rx).await,
|
||||
CallbackEvent::Cancelled("tool_call_slow".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
next_callback_event(&mut events_rx).await,
|
||||
CallbackEvent::CellClosed(running_cell_id.clone())
|
||||
];
|
||||
closure_events.sort_by(|left, right| format!("{left:?}").cmp(&format!("{right:?}")));
|
||||
assert_eq!(
|
||||
closure_events,
|
||||
vec![
|
||||
CallbackEvent::Cancelled("tool_call_slow".to_string()),
|
||||
CallbackEvent::CellClosed(running_cell_id.clone()),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
wait_task
|
||||
@@ -677,12 +681,9 @@ async fn oversized_initial_response_does_not_close_the_shared_host() {
|
||||
async fn child_process_loss_cleans_up_and_rebuilds_the_shared_host() {
|
||||
let host_program =
|
||||
codex_utils_cargo_bin::cargo_bin("codex-code-mode-host").expect("host binary");
|
||||
let proxy_dir =
|
||||
std::env::temp_dir().join(format!("codex-code-mode-host-loss-{}", std::process::id()));
|
||||
let proxy_program = proxy_dir.join("host-proxy.sh");
|
||||
let pid_path = proxy_dir.join("host.pid");
|
||||
let _ = std::fs::remove_dir_all(&proxy_dir);
|
||||
std::fs::create_dir_all(&proxy_dir).expect("create host proxy directory");
|
||||
let proxy_dir = tempfile::tempdir().expect("create host proxy directory");
|
||||
let proxy_program = proxy_dir.path().join("host-proxy.sh");
|
||||
let pid_path = proxy_dir.path().join("host.pid");
|
||||
std::fs::write(
|
||||
&proxy_program,
|
||||
format!(
|
||||
@@ -869,6 +870,4 @@ async fn child_process_loss_cleans_up_and_rebuilds_the_shared_host() {
|
||||
events_a.try_recv(),
|
||||
Err(mpsc::error::TryRecvError::Empty)
|
||||
));
|
||||
|
||||
std::fs::remove_dir_all(proxy_dir).expect("remove host proxy directory");
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ mod remote_session;
|
||||
mod runtime;
|
||||
mod service;
|
||||
mod session_runtime;
|
||||
mod v8_init;
|
||||
|
||||
pub(crate) type TaskFailureHandler = std::sync::Arc<dyn Fn(String) + Send + Sync>;
|
||||
|
||||
@@ -12,3 +13,5 @@ pub use remote_session::ProcessOwnedCodeModeSessionProvider;
|
||||
pub use service::InProcessCodeModeSession;
|
||||
pub use service::InProcessCodeModeSessionProvider;
|
||||
pub use service::NoopCodeModeSessionDelegate;
|
||||
pub use v8_init::V8JitMode;
|
||||
pub use v8_init::initialize_v8;
|
||||
|
||||
@@ -7,7 +7,6 @@ mod value;
|
||||
use std::collections::HashMap;
|
||||
use std::panic::AssertUnwindSafe;
|
||||
use std::panic::catch_unwind;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::mpsc as std_mpsc;
|
||||
use std::thread;
|
||||
|
||||
@@ -21,6 +20,7 @@ use serde_json::Value as JsonValue;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::TaskFailureHandler;
|
||||
use crate::v8_init::ensure_v8_initialized;
|
||||
|
||||
const EXIT_SENTINEL: &str = "__codex_code_mode_exit__";
|
||||
|
||||
@@ -84,7 +84,7 @@ pub(crate) fn spawn_runtime(
|
||||
),
|
||||
String,
|
||||
> {
|
||||
initialize_v8()?;
|
||||
ensure_v8_initialized()?;
|
||||
|
||||
let (command_tx, command_rx) = std_mpsc::channel();
|
||||
let (control_tx, control_rx) = std_mpsc::channel();
|
||||
@@ -165,22 +165,6 @@ pub(super) enum CompletionState {
|
||||
},
|
||||
}
|
||||
|
||||
fn initialize_v8() -> Result<(), String> {
|
||||
static PLATFORM: OnceLock<Result<v8::SharedRef<v8::Platform>, String>> = OnceLock::new();
|
||||
|
||||
match PLATFORM.get_or_init(|| {
|
||||
v8::icu::set_common_data_77(deno_core_icudata::ICU_DATA)
|
||||
.map_err(|error_code| format!("failed to initialize ICU data: {error_code}"))?;
|
||||
let platform = v8::new_default_platform(0, false).make_shared();
|
||||
v8::V8::initialize_platform(platform.clone());
|
||||
v8::V8::initialize();
|
||||
Ok(platform)
|
||||
}) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(error_text) => Err(error_text.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_runtime(
|
||||
config: RuntimeConfig,
|
||||
event_tx: mpsc::UnboundedSender<RuntimeEvent>,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Controls whether V8 may generate executable code at runtime.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub enum V8JitMode {
|
||||
#[default]
|
||||
Enabled,
|
||||
Disabled,
|
||||
}
|
||||
|
||||
struct V8Initialization {
|
||||
_platform: v8::SharedRef<v8::Platform>,
|
||||
jit_mode: V8JitMode,
|
||||
}
|
||||
|
||||
static V8_INITIALIZATION: OnceLock<Result<V8Initialization, String>> = OnceLock::new();
|
||||
|
||||
/// Initializes the process-wide V8 platform with the requested JIT mode.
|
||||
///
|
||||
/// Call this before executing any code-mode cells when JIT must be disabled.
|
||||
/// V8 cannot change JIT mode after initialization, so a later call requesting
|
||||
/// a different mode returns an error. Code mode initializes V8 with JIT enabled
|
||||
/// by default when this function has not been called explicitly.
|
||||
pub fn initialize_v8(jit_mode: V8JitMode) -> Result<(), String> {
|
||||
match V8_INITIALIZATION.get_or_init(|| initialize_v8_with_mode(jit_mode)) {
|
||||
Ok(initialization) if initialization.jit_mode == jit_mode => Ok(()),
|
||||
Ok(initialization) => Err(format!(
|
||||
"V8 was already initialized with JIT {}",
|
||||
initialization.jit_mode.description()
|
||||
)),
|
||||
Err(error_text) => Err(error_text.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_v8_initialized() -> Result<(), String> {
|
||||
match V8_INITIALIZATION.get_or_init(|| initialize_v8_with_mode(V8JitMode::Enabled)) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(error_text) => Err(error_text.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn initialize_v8_with_mode(jit_mode: V8JitMode) -> Result<V8Initialization, String> {
|
||||
v8::icu::set_common_data_77(deno_core_icudata::ICU_DATA)
|
||||
.map_err(|error_code| format!("failed to initialize ICU data: {error_code}"))?;
|
||||
match jit_mode {
|
||||
V8JitMode::Enabled => {}
|
||||
V8JitMode::Disabled => v8::V8::set_flags_from_string("--jitless"),
|
||||
}
|
||||
let platform = v8::new_default_platform(0, false).make_shared();
|
||||
v8::V8::initialize_platform(platform.clone());
|
||||
v8::V8::initialize();
|
||||
Ok(V8Initialization {
|
||||
_platform: platform,
|
||||
jit_mode,
|
||||
})
|
||||
}
|
||||
|
||||
impl V8JitMode {
|
||||
fn description(self) -> &'static str {
|
||||
match self {
|
||||
Self::Enabled => "enabled",
|
||||
Self::Disabled => "disabled",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
use codex_code_mode::ExecuteRequest;
|
||||
use codex_code_mode::InProcessCodeModeSession;
|
||||
use codex_code_mode::RuntimeResponse;
|
||||
use codex_code_mode::V8JitMode;
|
||||
use codex_code_mode::initialize_v8;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test]
|
||||
async fn code_mode_runs_with_jit_disabled() {
|
||||
initialize_v8(V8JitMode::Disabled).expect("initialize V8 without JIT");
|
||||
|
||||
let service = InProcessCodeModeSession::new();
|
||||
let started = service
|
||||
.execute(ExecuteRequest {
|
||||
tool_call_id: "call_1".to_string(),
|
||||
enabled_tools: Vec::new(),
|
||||
source: "21 * 2;".to_string(),
|
||||
yield_time_ms: None,
|
||||
max_output_tokens: None,
|
||||
})
|
||||
.await
|
||||
.expect("start code-mode cell");
|
||||
let cell_id = started.cell_id.clone();
|
||||
let response = started
|
||||
.initial_response()
|
||||
.await
|
||||
.expect("execute code-mode cell");
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
RuntimeResponse::Result {
|
||||
cell_id,
|
||||
content_items: Vec::new(),
|
||||
error_text: None,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
initialize_v8(V8JitMode::Enabled),
|
||||
Err("V8 was already initialized with JIT disabled".to_string())
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ base64 = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
codex-client = { workspace = true }
|
||||
codex-http-client = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-utils-rustls-provider = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
|
||||
@@ -121,21 +121,12 @@ pub struct SafetyBuffering {
|
||||
pub reasons: Vec<String>,
|
||||
#[serde(skip)]
|
||||
pub show_buffering_ui: bool,
|
||||
#[serde(skip)]
|
||||
#[serde(rename = "retry_model")]
|
||||
pub faster_model: Option<String>,
|
||||
}
|
||||
|
||||
impl SafetyBuffering {
|
||||
pub(crate) fn with_treatment(mut self, treatment: &SafetyBufferingTreatment) -> Self {
|
||||
self.show_buffering_ui = treatment.show_buffering_ui;
|
||||
self.faster_model.clone_from(&treatment.faster_model);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub(crate) struct SafetyBufferingTreatment {
|
||||
pub show_buffering_ui: bool,
|
||||
pub faster_model: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ use crate::endpoint::realtime_websocket::protocol::parse_realtime_event;
|
||||
use crate::error::ApiError;
|
||||
use crate::provider::Provider;
|
||||
use codex_client::backoff;
|
||||
use codex_client::maybe_build_rustls_client_config_with_custom_ca;
|
||||
use codex_http_client::maybe_build_rustls_client_config_with_custom_ca;
|
||||
use codex_protocol::protocol::ConversationTextRole;
|
||||
use codex_protocol::protocol::RealtimeTranscriptDelta;
|
||||
use codex_utils_rustls_provider::ensure_rustls_crypto_provider;
|
||||
@@ -391,6 +391,13 @@ impl RealtimeWebsocketWriter {
|
||||
}
|
||||
|
||||
impl RealtimeWebsocketEvents {
|
||||
pub async fn take_transcript_tail(&self) -> Vec<RealtimeTranscriptEntry> {
|
||||
let mut active_transcript = self.active_transcript.lock().await;
|
||||
let tail = active_transcript.entries[active_transcript.last_handoff_entry_count..].to_vec();
|
||||
active_transcript.last_handoff_entry_count = active_transcript.entries.len();
|
||||
tail
|
||||
}
|
||||
|
||||
pub async fn next_event(&self) -> Result<Option<RealtimeEvent>, ApiError> {
|
||||
if self.is_closed.load(Ordering::SeqCst) {
|
||||
return Ok(None);
|
||||
@@ -962,6 +969,56 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn takes_only_transcript_after_last_handoff_once() {
|
||||
let (_tx_message, rx_message) = async_channel::unbounded();
|
||||
let events = RealtimeWebsocketEvents {
|
||||
rx_message,
|
||||
active_transcript: Arc::new(Mutex::new(ActiveTranscriptState::default())),
|
||||
event_parser: RealtimeEventParser::V1,
|
||||
is_closed: Arc::new(AtomicBool::new(false)),
|
||||
};
|
||||
|
||||
assert_eq!(events.take_transcript_tail().await, vec![]);
|
||||
|
||||
let mut covered = RealtimeEvent::InputTranscriptDelta(RealtimeTranscriptDelta {
|
||||
delta: "already handed off".to_string(),
|
||||
});
|
||||
events.update_active_transcript(&mut covered).await;
|
||||
let mut handoff = RealtimeEvent::HandoffRequested(RealtimeHandoffRequested {
|
||||
handoff_id: "handoff_1".to_string(),
|
||||
item_id: "item_1".to_string(),
|
||||
input_transcript: "already handed off".to_string(),
|
||||
active_transcript: vec![],
|
||||
});
|
||||
events.update_active_transcript(&mut handoff).await;
|
||||
assert_eq!(
|
||||
handoff,
|
||||
RealtimeEvent::HandoffRequested(RealtimeHandoffRequested {
|
||||
handoff_id: "handoff_1".to_string(),
|
||||
item_id: "item_1".to_string(),
|
||||
input_transcript: "already handed off".to_string(),
|
||||
active_transcript: vec![RealtimeTranscriptEntry {
|
||||
role: "user".to_string(),
|
||||
text: "already handed off".to_string(),
|
||||
}],
|
||||
})
|
||||
);
|
||||
|
||||
let mut tail = RealtimeEvent::OutputTranscriptDelta(RealtimeTranscriptDelta {
|
||||
delta: "tail".to_string(),
|
||||
});
|
||||
events.update_active_transcript(&mut tail).await;
|
||||
assert_eq!(
|
||||
events.take_transcript_tail().await,
|
||||
vec![RealtimeTranscriptEntry {
|
||||
role: "assistant".to_string(),
|
||||
text: "tail".to_string(),
|
||||
}]
|
||||
);
|
||||
assert_eq!(events.take_transcript_tail().await, vec![]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_input_transcript_delta_event() {
|
||||
let payload = json!({
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::sse::ResponsesStreamEvent;
|
||||
use crate::sse::process_responses_event;
|
||||
use crate::telemetry::WebsocketTelemetry;
|
||||
use codex_client::TransportError;
|
||||
use codex_client::maybe_build_rustls_client_config_with_custom_ca;
|
||||
use codex_http_client::maybe_build_rustls_client_config_with_custom_ca;
|
||||
use codex_utils_rustls_provider::ensure_rustls_crypto_provider;
|
||||
use futures::SinkExt;
|
||||
use futures::StreamExt;
|
||||
@@ -689,17 +689,10 @@ async fn run_websocket_response_stream(
|
||||
{
|
||||
let _ = turn_state.set(response_turn_state);
|
||||
}
|
||||
if let Some(headers) = event.headers.as_ref().and_then(Value::as_object)
|
||||
&& let Some(treatment) =
|
||||
treatment_from_headers(&json_headers_to_http_headers(headers))
|
||||
{
|
||||
safety_buffering_treatment = treatment;
|
||||
}
|
||||
let model_verifications = event.model_verifications();
|
||||
let turn_moderation_metadata = event.turn_moderation_metadata();
|
||||
let safety_buffering = event
|
||||
.safety_buffering()
|
||||
.map(|buffering| buffering.with_treatment(&safety_buffering_treatment));
|
||||
let safety_buffering =
|
||||
safety_buffering_for_event(&event, &mut safety_buffering_treatment);
|
||||
if event.kind() == "codex.rate_limits" {
|
||||
if let Some(snapshot) = parse_rate_limit_event(&text) {
|
||||
let _ = tx_event.send(Ok(ResponseEvent::RateLimits(snapshot))).await;
|
||||
@@ -774,6 +767,19 @@ async fn run_websocket_response_stream(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn safety_buffering_for_event(
|
||||
event: &ResponsesStreamEvent,
|
||||
treatment: &mut SafetyBufferingTreatment,
|
||||
) -> Option<crate::common::SafetyBuffering> {
|
||||
if let Some(headers) = event.headers.as_ref().and_then(Value::as_object)
|
||||
&& let Some(updated_treatment) =
|
||||
treatment_from_headers(&json_headers_to_http_headers(headers))
|
||||
{
|
||||
*treatment = updated_treatment;
|
||||
}
|
||||
event.safety_buffering(treatment)
|
||||
}
|
||||
|
||||
async fn send_websocket_request(
|
||||
ws_stream: &WsStream,
|
||||
request_text: String,
|
||||
@@ -1039,4 +1045,75 @@ mod tests {
|
||||
Some(&HeaderValue::from_static("default-only"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_safety_buffering_uses_event_before_header_fallback() {
|
||||
let metadata: ResponsesStreamEvent = serde_json::from_value(json!({
|
||||
"type": "codex.response.metadata",
|
||||
"headers": {
|
||||
"x-codex-safety-buffering-enabled": "true",
|
||||
"x-codex-safety-buffering-faster-model": "gpt-fast-header"
|
||||
}
|
||||
}))
|
||||
.expect("deserialize treatment metadata");
|
||||
let event: ResponsesStreamEvent = serde_json::from_value(json!({
|
||||
"type": "response.output_text.delta",
|
||||
"safety_buffering": {
|
||||
"use_cases": ["cyber"],
|
||||
"reasons": ["user_risk"],
|
||||
"retry_model": "gpt-fast-wire"
|
||||
}
|
||||
}))
|
||||
.expect("deserialize safety buffering event");
|
||||
let mut treatment = SafetyBufferingTreatment::default();
|
||||
|
||||
assert!(safety_buffering_for_event(&metadata, &mut treatment).is_none());
|
||||
let buffering = safety_buffering_for_event(&event, &mut treatment)
|
||||
.expect("expected safety buffering payload");
|
||||
|
||||
assert_eq!(
|
||||
buffering,
|
||||
crate::common::SafetyBuffering {
|
||||
use_cases: vec!["cyber".to_string()],
|
||||
reasons: vec!["user_risk".to_string()],
|
||||
show_buffering_ui: true,
|
||||
faster_model: Some("gpt-fast-wire".to_string()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_safety_buffering_event_controls_visibility_when_header_disables_it() {
|
||||
let metadata: ResponsesStreamEvent = serde_json::from_value(json!({
|
||||
"type": "codex.response.metadata",
|
||||
"headers": {
|
||||
"x-codex-safety-buffering-enabled": "false",
|
||||
"x-codex-safety-buffering-faster-model": "gpt-fast-header"
|
||||
}
|
||||
}))
|
||||
.expect("deserialize treatment metadata");
|
||||
let event: ResponsesStreamEvent = serde_json::from_value(json!({
|
||||
"type": "response.output_text.delta",
|
||||
"safety_buffering": {
|
||||
"use_cases": ["cyber"],
|
||||
"reasons": ["user_risk"]
|
||||
}
|
||||
}))
|
||||
.expect("deserialize safety buffering event");
|
||||
let mut treatment = SafetyBufferingTreatment::default();
|
||||
|
||||
assert!(safety_buffering_for_event(&metadata, &mut treatment).is_none());
|
||||
let buffering = safety_buffering_for_event(&event, &mut treatment)
|
||||
.expect("expected safety buffering payload");
|
||||
|
||||
assert_eq!(
|
||||
buffering,
|
||||
crate::common::SafetyBuffering {
|
||||
use_cases: vec!["cyber".to_string()],
|
||||
reasons: vec!["user_risk".to_string()],
|
||||
show_buffering_ui: true,
|
||||
faster_model: Some("gpt-fast-header".to_string()),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::time::Duration;
|
||||
|
||||
use crate::AuthProvider;
|
||||
use bytes::Bytes;
|
||||
use codex_client::build_reqwest_client_with_custom_ca;
|
||||
use codex_http_client::build_reqwest_client_with_custom_ca;
|
||||
use futures::Stream;
|
||||
use reqwest::StatusCode;
|
||||
use reqwest::header::CONTENT_LENGTH;
|
||||
|
||||
@@ -6,23 +6,17 @@ pub(crate) const X_CODEX_SAFETY_BUFFERING_FASTER_MODEL_HEADER: &str =
|
||||
"x-codex-safety-buffering-faster-model";
|
||||
|
||||
pub(crate) fn treatment_from_headers(headers: &HeaderMap) -> Option<SafetyBufferingTreatment> {
|
||||
let show_buffering_ui = headers
|
||||
.get(X_CODEX_SAFETY_BUFFERING_ENABLED_HEADER)
|
||||
.and_then(|value| value.to_str().ok())?
|
||||
.eq_ignore_ascii_case("true");
|
||||
let faster_model = if show_buffering_ui {
|
||||
headers
|
||||
.get(X_CODEX_SAFETY_BUFFERING_FASTER_MODEL_HEADER)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_string)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if !headers.contains_key(X_CODEX_SAFETY_BUFFERING_ENABLED_HEADER)
|
||||
&& !headers.contains_key(X_CODEX_SAFETY_BUFFERING_FASTER_MODEL_HEADER)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let faster_model = headers
|
||||
.get(X_CODEX_SAFETY_BUFFERING_FASTER_MODEL_HEADER)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_string);
|
||||
|
||||
Some(SafetyBufferingTreatment {
|
||||
show_buffering_ui,
|
||||
faster_model,
|
||||
})
|
||||
Some(SafetyBufferingTreatment { faster_model })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -46,7 +40,26 @@ mod tests {
|
||||
assert_eq!(
|
||||
treatment_from_headers(&headers),
|
||||
Some(SafetyBufferingTreatment {
|
||||
show_buffering_ui: true,
|
||||
faster_model: Some("faster-model".to_string()),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffering_enabled_header_does_not_gate_the_faster_model_fallback() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
X_CODEX_SAFETY_BUFFERING_ENABLED_HEADER,
|
||||
HeaderValue::from_static("false"),
|
||||
);
|
||||
headers.insert(
|
||||
X_CODEX_SAFETY_BUFFERING_FASTER_MODEL_HEADER,
|
||||
HeaderValue::from_static("faster-model"),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
treatment_from_headers(&headers),
|
||||
Some(SafetyBufferingTreatment {
|
||||
faster_model: Some("faster-model".to_string()),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -231,8 +231,18 @@ impl ResponsesStreamEvent {
|
||||
.map(|metadata| TurnModerationMetadataEvent { metadata })
|
||||
}
|
||||
|
||||
pub(crate) fn safety_buffering(&self) -> Option<SafetyBuffering> {
|
||||
serde_json::from_value(self.safety_buffering.as_ref()?.clone()).ok()
|
||||
pub(crate) fn safety_buffering(
|
||||
&self,
|
||||
treatment: &SafetyBufferingTreatment,
|
||||
) -> Option<SafetyBuffering> {
|
||||
let value = self.safety_buffering.as_ref()?;
|
||||
let retry_model_present = value.as_object()?.contains_key("retry_model");
|
||||
let mut buffering: SafetyBuffering = serde_json::from_value(value.clone()).ok()?;
|
||||
buffering.show_buffering_ui = true;
|
||||
if !retry_model_present {
|
||||
buffering.faster_model.clone_from(&treatment.faster_model);
|
||||
}
|
||||
Some(buffering)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -515,9 +525,7 @@ async fn process_sse_with_treatment(
|
||||
};
|
||||
let model_verifications = event.model_verifications();
|
||||
let turn_moderation_metadata = event.turn_moderation_metadata();
|
||||
let safety_buffering = event
|
||||
.safety_buffering()
|
||||
.map(|buffering| buffering.with_treatment(&safety_buffering_treatment));
|
||||
let safety_buffering = event.safety_buffering(&safety_buffering_treatment);
|
||||
|
||||
if let Some(model) = event.response_model()
|
||||
&& last_server_model.as_deref() != Some(model.as_str())
|
||||
@@ -1354,7 +1362,8 @@ mod tests {
|
||||
"delta": "hello",
|
||||
"safety_buffering": {
|
||||
"use_cases": ["cyber"],
|
||||
"reasons": ["user_risk"]
|
||||
"reasons": ["user_risk"],
|
||||
"retry_model": "gpt-fast-wire"
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
@@ -1381,7 +1390,10 @@ mod tests {
|
||||
assert_matches!(
|
||||
&events[1],
|
||||
ResponseEvent::SafetyBuffering(buffering)
|
||||
if buffering.use_cases == ["cyber"] && buffering.reasons == ["user_risk"]
|
||||
if buffering.use_cases == ["cyber"]
|
||||
&& buffering.reasons == ["user_risk"]
|
||||
&& buffering.show_buffering_ui
|
||||
&& buffering.faster_model.as_deref() == Some("gpt-fast-wire")
|
||||
);
|
||||
assert_matches!(&events[2], ResponseEvent::OutputTextDelta(delta) if delta == "hello");
|
||||
assert_matches!(
|
||||
@@ -1398,6 +1410,46 @@ mod tests {
|
||||
assert_matches!(&events[6], ResponseEvent::Completed { response_id, .. } if response_id == "resp-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safety_buffering_prefers_wire_retry_model_and_only_falls_back_when_omitted() {
|
||||
let treatment = SafetyBufferingTreatment {
|
||||
faster_model: Some("gpt-fast-header".to_string()),
|
||||
};
|
||||
|
||||
for (retry_model, expected_faster_model) in [
|
||||
(None, Some("gpt-fast-header")),
|
||||
(Some(Value::Null), None),
|
||||
(Some(json!("gpt-fast-wire")), Some("gpt-fast-wire")),
|
||||
] {
|
||||
let mut event = json!({
|
||||
"type": "response.output_text.delta",
|
||||
"safety_buffering": {
|
||||
"use_cases": ["cyber"],
|
||||
"reasons": ["user_risk"]
|
||||
}
|
||||
});
|
||||
if let Some(retry_model) = retry_model {
|
||||
event["safety_buffering"]["retry_model"] = retry_model;
|
||||
}
|
||||
let event: ResponsesStreamEvent =
|
||||
serde_json::from_value(event).expect("deserialize safety buffering event");
|
||||
|
||||
let buffering = event
|
||||
.safety_buffering(&treatment)
|
||||
.expect("expected safety buffering payload");
|
||||
|
||||
assert_eq!(
|
||||
buffering,
|
||||
SafetyBuffering {
|
||||
use_cases: vec!["cyber".to_string()],
|
||||
reasons: vec!["user_risk".to_string()],
|
||||
show_buffering_ui: true,
|
||||
faster_model: expected_faster_model.map(str::to_string),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_stream_event_response_model_reads_top_level_headers() {
|
||||
let ev: ResponsesStreamEvent = serde_json::from_value(json!({
|
||||
|
||||
@@ -2,6 +2,5 @@ load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "codex-client",
|
||||
compile_data = glob(["tests/fixtures/**"]),
|
||||
crate_name = "codex_client",
|
||||
)
|
||||
|
||||
@@ -5,47 +5,15 @@ name = "codex-client"
|
||||
version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
bytes = { workspace = true }
|
||||
codex-http-client = { workspace = true }
|
||||
eventsource-stream = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
http = { workspace = true }
|
||||
opentelemetry = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["json", "rustls-tls-native-roots", "stream"] }
|
||||
rustls = { workspace = true }
|
||||
rustls-native-certs = { workspace = true }
|
||||
rustls-pki-types = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt", "time", "sync"] }
|
||||
tracing = { workspace = true }
|
||||
tracing-opentelemetry = { workspace = true }
|
||||
codex-utils-rustls-provider = { workspace = true }
|
||||
zstd = { workspace = true }
|
||||
|
||||
[target.'cfg(any(target_os = "windows", target_os = "macos"))'.dependencies]
|
||||
sha2 = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
system-configuration = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows-sys = { version = "0.52", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Networking_WinHttp",
|
||||
] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
codex-utils-cargo-bin = { workspace = true }
|
||||
opentelemetry_sdk = { workspace = true }
|
||||
pretty_assertions = { workspace = true }
|
||||
rcgen = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# codex-client
|
||||
|
||||
Generic transport layer that wraps HTTP requests, retries, and streaming primitives without any Codex/OpenAI awareness.
|
||||
Higher-level request policy layered on `codex-http-client` without any Codex/OpenAI API awareness.
|
||||
|
||||
- Defines `HttpTransport` and a default `ReqwestTransport` plus thin `Request`/`Response` types.
|
||||
- Provides retry utilities (`RetryPolicy`, `RetryOn`, `run_with_retry`, `backoff`) that callers plug into for unary and streaming calls.
|
||||
- Supplies the `sse_stream` helper to turn byte streams into raw SSE `data:` frames with idle timeouts and surfaced stream errors.
|
||||
- Consumed by higher-level crates like `codex-api`; it stays neutral on endpoints, headers, or API-specific error shapes.
|
||||
- Defines the request telemetry callback used by higher-level clients.
|
||||
- Re-exports the low-level HTTP types temporarily so consumers can migrate to `codex-http-client` incrementally.
|
||||
|
||||
@@ -1,49 +1,13 @@
|
||||
mod chatgpt_cloudflare_cookies;
|
||||
mod chatgpt_hosts;
|
||||
mod custom_ca;
|
||||
mod default_client;
|
||||
mod error;
|
||||
mod outbound_proxy;
|
||||
mod request;
|
||||
mod retry;
|
||||
mod sse;
|
||||
mod telemetry;
|
||||
mod transport;
|
||||
|
||||
pub use crate::chatgpt_cloudflare_cookies::with_chatgpt_cloudflare_cookie_store;
|
||||
pub use crate::chatgpt_hosts::is_allowed_chatgpt_host;
|
||||
pub use crate::custom_ca::BuildCustomCaTransportError;
|
||||
/// Test-only subprocess hook for custom CA coverage.
|
||||
///
|
||||
/// This stays public only so the `custom_ca_probe` binary target can reuse the shared helper. It
|
||||
/// is hidden from normal docs because ordinary callers should use
|
||||
/// [`build_reqwest_client_with_custom_ca`] instead.
|
||||
#[doc(hidden)]
|
||||
pub use crate::custom_ca::build_reqwest_client_for_subprocess_tests;
|
||||
pub use crate::custom_ca::build_reqwest_client_with_custom_ca;
|
||||
pub use crate::custom_ca::maybe_build_rustls_client_config_with_custom_ca;
|
||||
pub use crate::default_client::CodexHttpClient;
|
||||
pub use crate::default_client::CodexRequestBuilder;
|
||||
pub use crate::error::StreamError;
|
||||
pub use crate::error::TransportError;
|
||||
pub use crate::outbound_proxy::BuildRouteAwareHttpClientError;
|
||||
pub use crate::outbound_proxy::ClientRouteClass;
|
||||
pub use crate::outbound_proxy::OutboundProxyConfig;
|
||||
pub use crate::outbound_proxy::RouteFailureClass;
|
||||
pub use crate::outbound_proxy::build_reqwest_client_for_route;
|
||||
pub use crate::request::EncodedJsonBody;
|
||||
pub use crate::request::PreparedRequestBody;
|
||||
pub use crate::request::Request;
|
||||
pub use crate::request::RequestBody;
|
||||
pub use crate::request::RequestCompression;
|
||||
pub use crate::request::Response;
|
||||
pub use crate::retry::RetryOn;
|
||||
pub use crate::retry::RetryPolicy;
|
||||
pub use crate::retry::backoff;
|
||||
pub use crate::retry::run_with_retry;
|
||||
pub use crate::sse::sse_stream;
|
||||
pub use crate::telemetry::RequestTelemetry;
|
||||
pub use crate::transport::ByteStream;
|
||||
pub use crate::transport::HttpTransport;
|
||||
pub use crate::transport::ReqwestTransport;
|
||||
pub use crate::transport::StreamResponse;
|
||||
pub use codex_http_client::HttpClient as CodexHttpClient;
|
||||
pub use codex_http_client::RequestBuilder as CodexRequestBuilder;
|
||||
pub use codex_http_client::*;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::error::TransportError;
|
||||
use crate::request::Request;
|
||||
use codex_http_client::Request;
|
||||
use codex_http_client::TransportError;
|
||||
use rand::Rng;
|
||||
use std::future::Future;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::error::StreamError;
|
||||
use crate::transport::ByteStream;
|
||||
use codex_http_client::ByteStream;
|
||||
use codex_http_client::StreamError;
|
||||
use eventsource_stream::Eventsource;
|
||||
use futures::StreamExt;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::error::TransportError;
|
||||
use codex_http_client::TransportError;
|
||||
use http::StatusCode;
|
||||
use std::time::Duration;
|
||||
|
||||
|
||||
@@ -142,6 +142,7 @@ impl McpConnectionManager {
|
||||
tool_plugin_provenance: ToolPluginProvenance,
|
||||
auth: Option<&CodexAuth>,
|
||||
elicitation_reviewer: Option<ElicitationReviewerHandle>,
|
||||
elicitation_lifecycle: Option<crate::ElicitationLifecycle>,
|
||||
elicitation_router: ElicitationRequestRouter,
|
||||
) -> Self {
|
||||
let mut required_servers = mcp_servers
|
||||
@@ -157,6 +158,7 @@ impl McpConnectionManager {
|
||||
approval_policy.value(),
|
||||
initial_permission_profile,
|
||||
elicitation_reviewer,
|
||||
elicitation_lifecycle,
|
||||
elicitation_router,
|
||||
);
|
||||
let tool_plugin_provenance = Arc::new(tool_plugin_provenance);
|
||||
@@ -360,6 +362,7 @@ impl McpConnectionManager {
|
||||
approval_policy.value(),
|
||||
permission_profile.clone(),
|
||||
/*reviewer*/ None,
|
||||
/*lifecycle*/ None,
|
||||
ElicitationRequestRouter::default(),
|
||||
),
|
||||
startup_cancellation_token: CancellationToken::new(),
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::*;
|
||||
use crate::codex_apps_cache::CodexAppsToolsCache;
|
||||
use crate::codex_apps_cache::CodexAppsToolsCacheContext;
|
||||
use crate::declared_openai_file_input_param_names;
|
||||
use crate::elicitation::ElicitationLifecycle;
|
||||
use crate::elicitation::ElicitationRequestManager;
|
||||
use crate::elicitation::ElicitationRequestRouter;
|
||||
use crate::elicitation::elicitation_is_rejected_by_policy;
|
||||
@@ -323,6 +324,7 @@ async fn disabled_permissions_auto_accept_elicitation_with_empty_form_schema() {
|
||||
AskForApproval::Never,
|
||||
PermissionProfile::Disabled,
|
||||
/*reviewer*/ None,
|
||||
/*lifecycle*/ None,
|
||||
ElicitationRequestRouter::default(),
|
||||
);
|
||||
let (tx_event, _rx_event) = async_channel::bounded(1);
|
||||
@@ -359,6 +361,7 @@ async fn disabled_permissions_do_not_auto_accept_elicitation_with_requested_fiel
|
||||
AskForApproval::Never,
|
||||
PermissionProfile::Disabled,
|
||||
/*reviewer*/ None,
|
||||
/*lifecycle*/ None,
|
||||
ElicitationRequestRouter::default(),
|
||||
);
|
||||
let (tx_event, _rx_event) = async_channel::bounded(1);
|
||||
@@ -395,17 +398,35 @@ async fn disabled_permissions_do_not_auto_accept_elicitation_with_requested_fiel
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_elicitation_router_targets_the_exact_pending_request() {
|
||||
struct Registration(Arc<AtomicUsize>);
|
||||
|
||||
impl Drop for Registration {
|
||||
fn drop(&mut self) {
|
||||
self.0.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
let router = ElicitationRequestRouter::default();
|
||||
let outstanding = Arc::new(AtomicUsize::new(0));
|
||||
let lifecycle = ElicitationLifecycle::new({
|
||||
let outstanding = outstanding.clone();
|
||||
move || {
|
||||
outstanding.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
Registration(outstanding.clone())
|
||||
}
|
||||
});
|
||||
let manager_a = ElicitationRequestManager::new(
|
||||
AskForApproval::OnRequest,
|
||||
PermissionProfile::default(),
|
||||
/*reviewer*/ None,
|
||||
Some(lifecycle.clone()),
|
||||
router.clone(),
|
||||
);
|
||||
let manager_b = ElicitationRequestManager::new(
|
||||
AskForApproval::OnRequest,
|
||||
PermissionProfile::default(),
|
||||
/*reviewer*/ None,
|
||||
Some(lifecycle),
|
||||
router,
|
||||
);
|
||||
let (tx_event, rx_event) = async_channel::bounded(2);
|
||||
@@ -435,6 +456,7 @@ async fn shared_elicitation_router_targets_the_exact_pending_request() {
|
||||
else {
|
||||
panic!("expected elicitation request");
|
||||
};
|
||||
assert_eq!(outstanding.load(std::sync::atomic::Ordering::SeqCst), 2);
|
||||
let (
|
||||
codex_protocol::mcp::RequestId::String(request_a_id),
|
||||
codex_protocol::mcp::RequestId::String(request_b_id),
|
||||
@@ -485,6 +507,7 @@ async fn shared_elicitation_router_targets_the_exact_pending_request() {
|
||||
.expect("request B response"),
|
||||
response_b
|
||||
);
|
||||
assert_eq!(outstanding.load(std::sync::atomic::Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1539,6 +1562,7 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() {
|
||||
ToolPluginProvenance::default(),
|
||||
/*auth*/ None,
|
||||
/*elicitation_reviewer*/ None,
|
||||
/*elicitation_lifecycle*/ None,
|
||||
ElicitationRequestRouter::default(),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -52,6 +52,33 @@ pub trait ElicitationReviewer: Send + Sync {
|
||||
|
||||
pub type ElicitationReviewerHandle = Arc<dyn ElicitationReviewer>;
|
||||
|
||||
/// Holds an owner-provided registration while an MCP elicitation is waiting for a response.
|
||||
#[derive(Clone)]
|
||||
pub struct ElicitationLifecycle {
|
||||
register: Arc<dyn Fn() -> Box<dyn Send + Sync> + Send + Sync>,
|
||||
}
|
||||
|
||||
impl ElicitationLifecycle {
|
||||
pub fn new<T>(register: impl Fn() -> T + Send + Sync + 'static) -> Self
|
||||
where
|
||||
T: Send + Sync + 'static,
|
||||
{
|
||||
Self {
|
||||
register: Arc::new(move || Box::new(register())),
|
||||
}
|
||||
}
|
||||
|
||||
fn start(&self) -> ActiveElicitation {
|
||||
ActiveElicitation {
|
||||
_registration: (self.register)(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ActiveElicitation {
|
||||
_registration: Box<dyn Send + Sync>,
|
||||
}
|
||||
|
||||
/// Routes model-visible elicitation response tokens to their exact pending responders.
|
||||
///
|
||||
/// One router is shared by every MCP runtime created for a thread. The public response token is
|
||||
@@ -86,6 +113,7 @@ pub(crate) struct ElicitationRequestManager {
|
||||
pub(crate) permission_profile: Arc<StdMutex<PermissionProfile>>,
|
||||
auto_deny: Arc<StdMutex<bool>>,
|
||||
reviewer: Option<ElicitationReviewerHandle>,
|
||||
lifecycle: Option<ElicitationLifecycle>,
|
||||
}
|
||||
|
||||
impl ElicitationRequestManager {
|
||||
@@ -93,6 +121,7 @@ impl ElicitationRequestManager {
|
||||
approval_policy: AskForApproval,
|
||||
permission_profile: PermissionProfile,
|
||||
reviewer: Option<ElicitationReviewerHandle>,
|
||||
lifecycle: Option<ElicitationLifecycle>,
|
||||
router: ElicitationRequestRouter,
|
||||
) -> Self {
|
||||
Self {
|
||||
@@ -101,6 +130,7 @@ impl ElicitationRequestManager {
|
||||
permission_profile: Arc::new(StdMutex::new(permission_profile)),
|
||||
auto_deny: Arc::new(StdMutex::new(false)),
|
||||
reviewer,
|
||||
lifecycle,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +170,7 @@ impl ElicitationRequestManager {
|
||||
let permission_profile = self.permission_profile.clone();
|
||||
let auto_deny = self.auto_deny.clone();
|
||||
let reviewer = self.reviewer.clone();
|
||||
let lifecycle = self.lifecycle.clone();
|
||||
Box::new(move |id, elicitation| {
|
||||
let router = router.clone();
|
||||
let tx_event = tx_event.clone();
|
||||
@@ -148,6 +179,7 @@ impl ElicitationRequestManager {
|
||||
let permission_profile = permission_profile.clone();
|
||||
let auto_deny = auto_deny.clone();
|
||||
let reviewer = reviewer.clone();
|
||||
let lifecycle = lifecycle.clone();
|
||||
async move {
|
||||
let auto_deny = auto_deny
|
||||
.lock()
|
||||
@@ -249,6 +281,7 @@ impl ElicitationRequestManager {
|
||||
},
|
||||
};
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let _active_elicitation = lifecycle.as_ref().map(ElicitationLifecycle::start);
|
||||
{
|
||||
let mut lock = router.requests.lock().await;
|
||||
lock.insert((server_name.clone(), routed_request_id), tx);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub use connection_manager::McpConnectionManager;
|
||||
pub use connection_manager::tool_is_model_visible;
|
||||
pub use elicitation::ElicitationLifecycle;
|
||||
pub use elicitation::ElicitationRequestRouter;
|
||||
pub use elicitation::ElicitationReviewRequest;
|
||||
pub use elicitation::ElicitationReviewer;
|
||||
|
||||
@@ -339,6 +339,7 @@ pub async fn read_mcp_resource(
|
||||
tool_plugin_provenance(config),
|
||||
auth,
|
||||
/*elicitation_reviewer*/ None,
|
||||
/*elicitation_lifecycle*/ None,
|
||||
crate::elicitation::ElicitationRequestRouter::default(),
|
||||
)
|
||||
.await;
|
||||
@@ -416,6 +417,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail(
|
||||
tool_plugin_provenance,
|
||||
auth,
|
||||
/*elicitation_reviewer*/ None,
|
||||
/*elicitation_lifecycle*/ None,
|
||||
crate::elicitation::ElicitationRequestRouter::default(),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -777,6 +777,7 @@ fn remote_installed_plugin_in_marketplace(
|
||||
RemoteInstalledPlugin {
|
||||
marketplace_name: marketplace_name.to_string(),
|
||||
id: format!("plugins~Plugin_{name}"),
|
||||
version: None,
|
||||
name: name.to_string(),
|
||||
enabled: true,
|
||||
install_policy: codex_app_server_protocol::PluginInstallPolicy::Available,
|
||||
|
||||
@@ -148,6 +148,7 @@ pub enum RemoteMarketplaceSource {
|
||||
pub struct RemoteInstalledPlugin {
|
||||
pub marketplace_name: String,
|
||||
pub id: String,
|
||||
pub version: Option<String>,
|
||||
pub name: String,
|
||||
pub enabled: bool,
|
||||
pub install_policy: PluginInstallPolicy,
|
||||
@@ -161,6 +162,7 @@ pub struct RemoteInstalledPlugin {
|
||||
pub struct RemotePluginSummary {
|
||||
pub id: String,
|
||||
pub remote_plugin_id: String,
|
||||
pub version: Option<String>,
|
||||
pub local_version: Option<String>,
|
||||
pub name: String,
|
||||
pub share_context: Option<RemotePluginShareContext>,
|
||||
@@ -1041,6 +1043,7 @@ pub fn group_remote_installed_plugins_by_marketplaces(
|
||||
let plugin_summary = RemotePluginSummary {
|
||||
id: plugin_id.as_key(),
|
||||
remote_plugin_id: plugin.id.clone(),
|
||||
version: plugin.version.clone(),
|
||||
local_version: None,
|
||||
name: plugin.name.clone(),
|
||||
share_context: None,
|
||||
@@ -1472,6 +1475,7 @@ fn build_remote_plugin_summary(
|
||||
Ok(RemotePluginSummary {
|
||||
id: plugin_id.as_key(),
|
||||
remote_plugin_id: plugin.id.clone(),
|
||||
version: plugin.release.version.clone(),
|
||||
local_version: installed_plugin
|
||||
.and_then(|installed| installed.plugin.release.version.clone()),
|
||||
name: plugin.name.clone(),
|
||||
@@ -1554,6 +1558,7 @@ fn remote_installed_plugin_to_cache_entry(
|
||||
Ok(RemoteInstalledPlugin {
|
||||
marketplace_name: remote_plugin_canonical_marketplace_name(plugin)?.to_string(),
|
||||
id: plugin.id.clone(),
|
||||
version: plugin.release.version.clone(),
|
||||
name: plugin.name.clone(),
|
||||
enabled: installed_plugin.enabled,
|
||||
install_policy: plugin.installation_policy,
|
||||
|
||||
@@ -618,6 +618,7 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() {
|
||||
summary: RemotePluginSummary {
|
||||
id: "demo-plugin@workspace-shared-with-me".to_string(),
|
||||
remote_plugin_id: "plugins_123".to_string(),
|
||||
version: Some("0.1.0".to_string()),
|
||||
local_version: None,
|
||||
name: "demo-plugin".to_string(),
|
||||
share_context: Some(RemotePluginShareContext {
|
||||
@@ -658,6 +659,7 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() {
|
||||
summary: RemotePluginSummary {
|
||||
id: "demo-plugin@workspace-shared-with-me".to_string(),
|
||||
remote_plugin_id: "plugins_456".to_string(),
|
||||
version: Some("0.1.0".to_string()),
|
||||
local_version: Some("0.1.0".to_string()),
|
||||
name: "demo-plugin".to_string(),
|
||||
share_context: Some(RemotePluginShareContext {
|
||||
|
||||
@@ -143,6 +143,12 @@ pub struct EnvironmentSkillLoadOutcome {
|
||||
}
|
||||
|
||||
/// Discovers skills without converting environment-owned paths to host paths.
|
||||
#[tracing::instrument(
|
||||
name = "skills.environment.load",
|
||||
level = "info",
|
||||
skip_all,
|
||||
fields(skill_count = tracing::field::Empty)
|
||||
)]
|
||||
pub async fn load_environment_skills_from_root(
|
||||
file_system: &dyn ExecutorFileSystem,
|
||||
root: &PathUri,
|
||||
@@ -235,6 +241,7 @@ pub async fn load_environment_skills_from_root(
|
||||
warnings: vec![format!("failed to walk skills root {root}: {error:#}")],
|
||||
},
|
||||
};
|
||||
tracing::Span::current().record("skill_count", discovery.skills.len());
|
||||
outcome.warnings.extend(discovery.warnings);
|
||||
if discovery.skills.is_empty() {
|
||||
return outcome;
|
||||
|
||||
@@ -5,7 +5,10 @@ use codex_protocol::protocol::APPS_INSTRUCTIONS_CLOSE_TAG;
|
||||
use codex_protocol::protocol::APPS_INSTRUCTIONS_OPEN_TAG;
|
||||
|
||||
pub(crate) fn render_apps_section(connectors: &[AppInfo]) -> Option<String> {
|
||||
AppsInstructions::from_connectors(connectors).map(|instructions| instructions.render())
|
||||
connectors
|
||||
.iter()
|
||||
.any(|connector| connector.is_accessible && connector.is_enabled)
|
||||
.then(|| AppsInstructions.render())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -299,11 +299,11 @@ async fn forward_events(
|
||||
match event {
|
||||
Event {
|
||||
id: _,
|
||||
msg: EventMsg::TokenCount(_),
|
||||
} => {}
|
||||
Event {
|
||||
id: _,
|
||||
msg: EventMsg::SessionConfigured(_),
|
||||
msg:
|
||||
EventMsg::TokenCount(_)
|
||||
| EventMsg::SessionConfigured(_)
|
||||
| EventMsg::McpStartupUpdate(_)
|
||||
| EventMsg::McpStartupComplete(_),
|
||||
} => {}
|
||||
Event {
|
||||
id,
|
||||
|
||||
@@ -14,6 +14,9 @@ use codex_protocol::protocol::GuardianAssessmentAction;
|
||||
use codex_protocol::protocol::GuardianAssessmentStatus;
|
||||
use codex_protocol::protocol::GuardianCommandSource;
|
||||
use codex_protocol::protocol::McpInvocation;
|
||||
use codex_protocol::protocol::McpStartupCompleteEvent;
|
||||
use codex_protocol::protocol::McpStartupStatus;
|
||||
use codex_protocol::protocol::McpStartupUpdateEvent;
|
||||
use codex_protocol::protocol::RawResponseItemEvent;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_protocol::protocol::TurnAbortReason;
|
||||
@@ -34,8 +37,8 @@ use tokio::sync::watch;
|
||||
use tokio::time::timeout;
|
||||
|
||||
#[tokio::test]
|
||||
async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() {
|
||||
let (tx_events, rx_events) = bounded(1);
|
||||
async fn forward_events_filters_private_events_before_blocked_send_is_cancelled() {
|
||||
let (tx_events, rx_events) = bounded(SUBMISSION_CHANNEL_CAPACITY);
|
||||
let (tx_sub, rx_sub) = bounded(SUBMISSION_CHANNEL_CAPACITY);
|
||||
let (_agent_status_tx, agent_status) = watch::channel(AgentStatus::PendingInit);
|
||||
let (session, ctx, _rx_evt) = crate::session::tests::make_session_and_context_with_rx().await;
|
||||
@@ -71,33 +74,53 @@ async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() {
|
||||
cancel.clone(),
|
||||
));
|
||||
|
||||
tx_events
|
||||
.send(Event {
|
||||
id: "evt".to_string(),
|
||||
msg: EventMsg::RawResponseItem(RawResponseItemEvent {
|
||||
item: ResponseItem::CustomToolCall {
|
||||
id: None,
|
||||
status: None,
|
||||
call_id: "call-1".to_string(),
|
||||
name: "tool".to_string(),
|
||||
namespace: None,
|
||||
input: "{}".to_string(),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
},
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
for msg in [
|
||||
EventMsg::McpStartupUpdate(McpStartupUpdateEvent {
|
||||
server: "pending".to_string(),
|
||||
status: McpStartupStatus::Starting,
|
||||
}),
|
||||
EventMsg::McpStartupComplete(McpStartupCompleteEvent::default()),
|
||||
] {
|
||||
tx_events
|
||||
.send(Event {
|
||||
id: "delegate-startup".to_string(),
|
||||
msg,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let visible_msg = EventMsg::RawResponseItem(RawResponseItemEvent {
|
||||
item: ResponseItem::CustomToolCall {
|
||||
id: None,
|
||||
status: None,
|
||||
call_id: "call-1".to_string(),
|
||||
name: "tool".to_string(),
|
||||
namespace: None,
|
||||
input: "{}".to_string(),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
},
|
||||
});
|
||||
for id in ["visible-1", "visible-2", "blocked"] {
|
||||
tx_events
|
||||
.send(Event {
|
||||
id: id.to_string(),
|
||||
msg: visible_msg.clone(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
drop(tx_events);
|
||||
let received = rx_out.recv().await.expect("prefilled event missing");
|
||||
assert_eq!(received.id, "full");
|
||||
let received = rx_out.recv().await.expect("visible event missing");
|
||||
assert_eq!(received.id, "visible-1");
|
||||
cancel.cancel();
|
||||
timeout(std::time::Duration::from_millis(1000), forward)
|
||||
.await
|
||||
.expect("forward_events hung")
|
||||
.expect("forward_events join error");
|
||||
|
||||
let received = rx_out.recv().await.expect("prefilled event missing");
|
||||
assert_eq!("full", received.id);
|
||||
let mut ops = Vec::new();
|
||||
while let Ok(sub) = rx_sub.try_recv() {
|
||||
ops.push(sub.op);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::agent::AgentStatus;
|
||||
use crate::config::ConstraintResult;
|
||||
use crate::elicitation::ElicitationRegistration;
|
||||
use crate::session::Codex;
|
||||
use crate::session::SessionSettingsUpdate;
|
||||
use crate::session::SteerInputError;
|
||||
@@ -162,7 +163,13 @@ pub struct CodexThread {
|
||||
pub(crate) session_source: SessionSource,
|
||||
session_configured: SessionConfiguredEvent,
|
||||
rollout_path: Option<PathBuf>,
|
||||
out_of_band_elicitation_count: Mutex<u64>,
|
||||
out_of_band_elicitations: Mutex<OutOfBandElicitations>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct OutOfBandElicitations {
|
||||
count: i64,
|
||||
registration: Option<ElicitationRegistration>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
@@ -187,7 +194,7 @@ impl CodexThread {
|
||||
session_source,
|
||||
session_configured,
|
||||
rollout_path,
|
||||
out_of_band_elicitation_count: Mutex::new(0),
|
||||
out_of_band_elicitations: Mutex::new(OutOfBandElicitations::default()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -655,38 +662,30 @@ impl CodexThread {
|
||||
self.codex.enabled(feature)
|
||||
}
|
||||
|
||||
pub async fn increment_out_of_band_elicitation_count(&self) -> CodexResult<u64> {
|
||||
let mut guard = self.out_of_band_elicitation_count.lock().await;
|
||||
let was_zero = *guard == 0;
|
||||
*guard = guard.checked_add(1).ok_or_else(|| {
|
||||
pub async fn increment_out_of_band_elicitation_count(&self) -> CodexResult<i64> {
|
||||
let mut elicitations = self.out_of_band_elicitations.lock().await;
|
||||
let incremented = elicitations.count.checked_add(1).ok_or_else(|| {
|
||||
CodexErr::Fatal("out-of-band elicitation count overflowed".to_string())
|
||||
})?;
|
||||
|
||||
if was_zero {
|
||||
self.codex
|
||||
.session
|
||||
.set_out_of_band_elicitation_pause_state(/*paused*/ true);
|
||||
if elicitations.count == 0 {
|
||||
elicitations.registration = Some(self.codex.session.services.elicitations.register());
|
||||
}
|
||||
|
||||
Ok(*guard)
|
||||
elicitations.count = incremented;
|
||||
Ok(incremented)
|
||||
}
|
||||
|
||||
pub async fn decrement_out_of_band_elicitation_count(&self) -> CodexResult<u64> {
|
||||
let mut guard = self.out_of_band_elicitation_count.lock().await;
|
||||
if *guard == 0 {
|
||||
pub async fn decrement_out_of_band_elicitation_count(&self) -> CodexResult<i64> {
|
||||
let mut elicitations = self.out_of_band_elicitations.lock().await;
|
||||
if elicitations.count == 0 {
|
||||
return Err(CodexErr::InvalidRequest(
|
||||
"out-of-band elicitation count is already zero".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
*guard -= 1;
|
||||
let now_zero = *guard == 0;
|
||||
if now_zero {
|
||||
self.codex
|
||||
.session
|
||||
.set_out_of_band_elicitation_pause_state(/*paused*/ false);
|
||||
elicitations.count -= 1;
|
||||
if elicitations.count == 0 {
|
||||
elicitations.registration = None;
|
||||
}
|
||||
|
||||
Ok(*guard)
|
||||
Ok(elicitations.count)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ use codex_config::types::TuiNotificationSettings;
|
||||
use codex_config::types::TuiPetAnchor;
|
||||
use codex_config::types::UriBasedFileOpener;
|
||||
use codex_config::types::WindowsSandboxModeToml;
|
||||
use codex_core_plugins::PluginLoadOutcome;
|
||||
use codex_core_plugins::PluginsConfigInput;
|
||||
use codex_exec_server::ExecutorFileSystem;
|
||||
use codex_exec_server::LOCAL_FS;
|
||||
@@ -1528,6 +1529,14 @@ impl Config {
|
||||
) -> McpConfig {
|
||||
let plugins_input = self.plugins_config_input();
|
||||
let loaded_plugins = plugins_manager.plugins_for_config(&plugins_input).await;
|
||||
self.to_mcp_config_with_loaded_plugins(&loaded_plugins, additional_plugin_registrations)
|
||||
}
|
||||
|
||||
pub(crate) fn to_mcp_config_with_loaded_plugins(
|
||||
&self,
|
||||
loaded_plugins: &PluginLoadOutcome,
|
||||
additional_plugin_registrations: impl IntoIterator<Item = McpServerRegistration>,
|
||||
) -> McpConfig {
|
||||
let mut catalog = ResolvedMcpCatalog::builder();
|
||||
for (plugin_order, plugin) in loaded_plugins
|
||||
.plugins()
|
||||
|
||||
@@ -80,19 +80,6 @@ pub async fn list_accessible_connectors_from_mcp_tools(
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn list_accessible_and_enabled_connectors_from_manager(
|
||||
mcp_connection_manager: &McpConnectionManager,
|
||||
config: &Config,
|
||||
) -> Vec<AppInfo> {
|
||||
with_app_enabled_state(
|
||||
accessible_connectors_from_mcp_tools(&mcp_connection_manager.list_all_tools().await),
|
||||
config,
|
||||
)
|
||||
.into_iter()
|
||||
.filter(|connector| connector.is_accessible && connector.is_enabled)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[instrument(level = "trace", skip_all)]
|
||||
pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth(
|
||||
config: &Config,
|
||||
@@ -283,6 +270,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager(
|
||||
ToolPluginProvenance::default(),
|
||||
auth.as_ref(),
|
||||
/*elicitation_reviewer*/ None,
|
||||
/*elicitation_lifecycle*/ None,
|
||||
codex_mcp::ElicitationRequestRouter::default(),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use crate::connectors::AppInfo;
|
||||
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_protocol::protocol::APPS_INSTRUCTIONS_CLOSE_TAG;
|
||||
use codex_protocol::protocol::APPS_INSTRUCTIONS_OPEN_TAG;
|
||||
@@ -8,15 +7,6 @@ use super::ContextualUserFragment;
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct AppsInstructions;
|
||||
|
||||
impl AppsInstructions {
|
||||
pub(crate) fn from_connectors(connectors: &[AppInfo]) -> Option<Self> {
|
||||
connectors
|
||||
.iter()
|
||||
.any(|connector| connector.is_accessible && connector.is_enabled)
|
||||
.then_some(Self)
|
||||
}
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for AppsInstructions {
|
||||
fn role(&self) -> &'static str {
|
||||
"developer"
|
||||
|
||||
@@ -1,25 +1,10 @@
|
||||
use codex_plugin::PluginCapabilitySummary;
|
||||
use codex_protocol::protocol::PLUGINS_INSTRUCTIONS_CLOSE_TAG;
|
||||
use codex_protocol::protocol::PLUGINS_INSTRUCTIONS_OPEN_TAG;
|
||||
|
||||
use super::ContextualUserFragment;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct AvailablePluginsInstructions {
|
||||
plugins: Vec<PluginCapabilitySummary>,
|
||||
}
|
||||
|
||||
impl AvailablePluginsInstructions {
|
||||
pub(crate) fn from_plugins(plugins: &[PluginCapabilitySummary]) -> Option<Self> {
|
||||
if plugins.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Self {
|
||||
plugins: plugins.to_vec(),
|
||||
})
|
||||
}
|
||||
}
|
||||
pub(crate) struct AvailablePluginsInstructions;
|
||||
|
||||
impl ContextualUserFragment for AvailablePluginsInstructions {
|
||||
fn role(&self) -> &'static str {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
use super::PreviousSectionState;
|
||||
use super::WorldStateSection;
|
||||
use crate::context::AppsInstructions;
|
||||
use crate::context::ContextualUserFragment;
|
||||
|
||||
/// Whether generic Apps usage guidance should be visible to the model.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub(crate) struct AppsInstructionsState {
|
||||
available: bool,
|
||||
}
|
||||
|
||||
impl AppsInstructionsState {
|
||||
pub(crate) fn new(available: bool) -> Self {
|
||||
Self { available }
|
||||
}
|
||||
}
|
||||
|
||||
impl WorldStateSection for AppsInstructionsState {
|
||||
const ID: &'static str = "apps_instructions";
|
||||
type Snapshot = bool;
|
||||
|
||||
fn snapshot(&self) -> Self::Snapshot {
|
||||
self.available
|
||||
}
|
||||
|
||||
fn matches_legacy_fragment(role: &str, text: &str) -> bool {
|
||||
role == "developer" && AppsInstructions::matches_text(text)
|
||||
}
|
||||
|
||||
fn has_retained_fragment_matcher() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn matches_retained_fragment(role: &str, text: &str) -> bool {
|
||||
Self::matches_legacy_fragment(role, text)
|
||||
}
|
||||
|
||||
fn render_diff(
|
||||
&self,
|
||||
previous: PreviousSectionState<'_, Self::Snapshot>,
|
||||
) -> Option<Box<dyn ContextualUserFragment>> {
|
||||
if !self.available
|
||||
|| matches!(previous, PreviousSectionState::Known(previous) if *previous)
|
||||
|| matches!(previous, PreviousSectionState::Unknown)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Box::new(AppsInstructions))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "apps_instructions_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,69 @@
|
||||
use super::*;
|
||||
use crate::context::ContextualUserFragment;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn render(state: AppsInstructionsState, previous: PreviousSectionState<'_, bool>) -> Vec<String> {
|
||||
state
|
||||
.render_diff(previous)
|
||||
.into_iter()
|
||||
.map(|fragment| fragment.render())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_only_when_apps_become_available() {
|
||||
let unavailable = AppsInstructionsState::new(/*available*/ false);
|
||||
let available = AppsInstructionsState::new(/*available*/ true);
|
||||
let false_snapshot = false;
|
||||
let true_snapshot = true;
|
||||
|
||||
assert_eq!(
|
||||
render(unavailable, PreviousSectionState::Absent),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
assert_eq!(render(available, PreviousSectionState::Absent).len(), 1);
|
||||
assert_eq!(
|
||||
render(available, PreviousSectionState::Known(&false_snapshot)).len(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
render(available, PreviousSectionState::Known(&true_snapshot)),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
assert_eq!(
|
||||
render(unavailable, PreviousSectionState::Known(&true_snapshot)),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_guidance_is_not_injected_again() {
|
||||
let mut world_state = super::super::WorldState::default();
|
||||
world_state.add_section(AppsInstructionsState::new(/*available*/ true));
|
||||
let legacy: ResponseItem = ContextualUserFragment::into(AppsInstructions);
|
||||
|
||||
assert!(
|
||||
world_state
|
||||
.render_history_diff(/*previous*/ None, &[legacy])
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_guidance_is_restored_only_when_missing_from_history() {
|
||||
let mut world_state = super::super::WorldState::default();
|
||||
world_state.add_section(AppsInstructionsState::new(/*available*/ true));
|
||||
let snapshot = world_state.snapshot();
|
||||
let retained: ResponseItem = ContextualUserFragment::into(AppsInstructions);
|
||||
|
||||
assert_eq!(
|
||||
world_state.render_history_diff(Some(&snapshot), &[]).len(),
|
||||
1
|
||||
);
|
||||
assert!(
|
||||
world_state
|
||||
.render_history_diff(Some(&snapshot), &[retained])
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
mod agents_md;
|
||||
mod apps_instructions;
|
||||
mod environment;
|
||||
mod plugins_instructions;
|
||||
|
||||
use crate::context::ContextualUserFragment;
|
||||
use codex_extension_api::PreviousWorldStateSection;
|
||||
@@ -16,7 +18,9 @@ use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
|
||||
pub(crate) use agents_md::AgentsMdState;
|
||||
pub(crate) use apps_instructions::AppsInstructionsState;
|
||||
pub(crate) use environment::EnvironmentsState;
|
||||
pub(crate) use plugins_instructions::PluginsInstructionsState;
|
||||
|
||||
trait ErasedWorldStateSection: Send + Sync {
|
||||
fn snapshot(&self) -> Option<Value>;
|
||||
@@ -62,11 +66,11 @@ impl<S: WorldStateSection> ErasedWorldStateSection for S {
|
||||
}
|
||||
|
||||
fn has_retained_fragment_matcher(&self) -> bool {
|
||||
false
|
||||
S::has_retained_fragment_matcher()
|
||||
}
|
||||
|
||||
fn matches_retained_fragment(&self, _role: &str, _text: &str) -> bool {
|
||||
false
|
||||
fn matches_retained_fragment(&self, role: &str, text: &str) -> bool {
|
||||
S::matches_retained_fragment(role, text)
|
||||
}
|
||||
|
||||
fn render_diff(
|
||||
@@ -183,6 +187,16 @@ pub(crate) trait WorldStateSection: Send + Sync + 'static {
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether retained history must still contain this section's rendered fragment.
|
||||
fn has_retained_fragment_matcher() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Recognizes this section's rendered fragment in retained model history.
|
||||
fn matches_retained_fragment(_role: &str, _text: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn render_diff(
|
||||
&self,
|
||||
previous: PreviousSectionState<'_, Self::Snapshot>,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
use super::PreviousSectionState;
|
||||
use super::WorldStateSection;
|
||||
use crate::context::AvailablePluginsInstructions;
|
||||
use crate::context::ContextualUserFragment;
|
||||
|
||||
/// Whether generic plugin usage guidance should be visible to the model.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub(crate) struct PluginsInstructionsState {
|
||||
available: bool,
|
||||
}
|
||||
|
||||
impl PluginsInstructionsState {
|
||||
pub(crate) fn new(available: bool) -> Self {
|
||||
Self { available }
|
||||
}
|
||||
}
|
||||
|
||||
impl WorldStateSection for PluginsInstructionsState {
|
||||
const ID: &'static str = "plugins_instructions";
|
||||
type Snapshot = bool;
|
||||
|
||||
fn snapshot(&self) -> Self::Snapshot {
|
||||
self.available
|
||||
}
|
||||
|
||||
fn matches_legacy_fragment(role: &str, text: &str) -> bool {
|
||||
role == "developer" && AvailablePluginsInstructions::matches_text(text)
|
||||
}
|
||||
|
||||
fn has_retained_fragment_matcher() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn matches_retained_fragment(role: &str, text: &str) -> bool {
|
||||
Self::matches_legacy_fragment(role, text)
|
||||
}
|
||||
|
||||
fn render_diff(
|
||||
&self,
|
||||
previous: PreviousSectionState<'_, Self::Snapshot>,
|
||||
) -> Option<Box<dyn ContextualUserFragment>> {
|
||||
if !self.available
|
||||
|| matches!(previous, PreviousSectionState::Known(previous) if *previous)
|
||||
|| matches!(previous, PreviousSectionState::Unknown)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Box::new(AvailablePluginsInstructions))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "plugins_instructions_tests.rs"]
|
||||
mod tests;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user