mirror of
https://github.com/openai/codex.git
synced 2026-11-09 20:50:33 +00:00
Merge branch 'main' into jif/wal-config
This commit is contained in:
@@ -105,9 +105,9 @@ common:ci --disk_cache=
|
||||
# Shared config for the main Bazel CI workflow.
|
||||
common:ci-bazel --config=ci
|
||||
common:ci-bazel --build_metadata=TAG_workflow=bazel
|
||||
# Bazel CI cross-compiles in several legs, and the V8-backed code-mode tests
|
||||
# are not stable in that setup yet. Keep running the rest of the Rust
|
||||
# integration suites through the workspace-root launcher.
|
||||
# Keep code-mode integration cases out of ordinary Bazel legs. The
|
||||
# Windows-cross config below re-enables them after generating its Windows V8
|
||||
# snapshot on the Windows runner.
|
||||
common:ci-bazel --test_env=CODEX_BAZEL_TEST_SKIP_FILTERS=suite::code_mode::
|
||||
|
||||
# Shared config for Bazel-backed Rust linting.
|
||||
@@ -186,12 +186,16 @@ common:ci-windows-cross --config=ci-windows
|
||||
common:ci-windows-cross --build_metadata=TAG_windows_cross_compile=true
|
||||
common:ci-windows-cross --host_platform=//:rbe
|
||||
common:ci-windows-cross --strategy=TestRunner=local
|
||||
# V8 embeds IsolateData offsets in snapshot builtins; Windows snapshots must be
|
||||
# generated by a Windows mksnapshot binary rather than the Linux RBE host tool.
|
||||
common:ci-windows-cross --strategy=V8Mksnapshot=local
|
||||
common:ci-windows-cross --local_test_jobs=4
|
||||
common:ci-windows-cross --test_env=RUST_TEST_THREADS=1
|
||||
# Native Windows CI still covers the PowerShell tests. The cross-built gnullvm
|
||||
# binaries currently hang in PowerShell AST parser tests when those binaries are
|
||||
# run on the Windows runner.
|
||||
common:ci-windows-cross --test_env=CODEX_BAZEL_TEST_SKIP_FILTERS=suite::code_mode::,powershell
|
||||
# run on the Windows runner. Keep V8-backed code-mode tests enabled except for
|
||||
# the hidden dynamic-tool callback test, which currently times out on Windows.
|
||||
common:ci-windows-cross --test_env=CODEX_BAZEL_TEST_SKIP_FILTERS=powershell,suite::code_mode::code_mode_can_call_hidden_dynamic_tools
|
||||
common:ci-windows-cross --platforms=//:windows_x86_64_gnullvm
|
||||
common:ci-windows-cross --extra_execution_platforms=//:rbe,//:windows_x86_64_msvc
|
||||
common:ci-windows-cross --extra_toolchains=//:windows_gnullvm_tests_on_msvc_host_toolchain
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
name: pushing-ci-changes
|
||||
description: Pushing GitHub Actions changes, resolving push rejection, requesting upload exceptions.
|
||||
---
|
||||
|
||||
The Codex repository prevents anyone from uploading changes to its CI configuration unless they've
|
||||
been granted a temporary role.
|
||||
|
||||
In order to push changes to `.github/**/*.yml` and related files you'll need the user to read
|
||||
go/workflow-approvals and request an approval through that flow. It is not possible for you to
|
||||
make progress by requesting an exemption yourself.
|
||||
|
||||
If you know you're about to push changes that would fail due to these restrictions you should still
|
||||
try to push anyways to confirm the user's account doesn't already have the necessary approval.
|
||||
|
||||
If you encounter a failed push due to these restrictions, share the go/workflow-approvals link with
|
||||
the user and ask them to return control to you once the approval has propagated to GitHub.
|
||||
@@ -4,7 +4,8 @@ set -euo pipefail
|
||||
|
||||
# Run target-discovery queries with the same startup settings as the main
|
||||
# build/test invocation so they can reuse the same Bazel server. Queries only
|
||||
# enumerate labels, so they intentionally do not select CI or remote configs.
|
||||
# enumerate labels, so they intentionally do not select a CI build/test config
|
||||
# or remote execution.
|
||||
|
||||
if [[ $# -lt 2 || "${@: -2:1}" != "--" ]]; then
|
||||
echo "Usage: $0 [<bazel query args>...] -- <query expression>" >&2
|
||||
@@ -14,21 +15,16 @@ fi
|
||||
query_args=("${@:1:$#-2}")
|
||||
query_expression="${@: -1}"
|
||||
|
||||
bazel_startup_args=()
|
||||
if [[ -n "${BAZEL_OUTPUT_USER_ROOT:-}" ]]; then
|
||||
bazel_startup_args+=("--output_user_root=${BAZEL_OUTPUT_USER_ROOT}")
|
||||
fi
|
||||
|
||||
run_bazel() {
|
||||
if [[ "${RUNNER_OS:-}" == "Windows" ]]; then
|
||||
MSYS2_ARG_CONV_EXCL='*' bazel "$@"
|
||||
MSYS2_ARG_CONV_EXCL='*' "$(dirname "${BASH_SOURCE[0]}")/run_bazel_with_buildbuddy.py" "$@"
|
||||
return
|
||||
fi
|
||||
|
||||
bazel "$@"
|
||||
"$(dirname "${BASH_SOURCE[0]}")/run_bazel_with_buildbuddy.py" "$@"
|
||||
}
|
||||
|
||||
bazel_query_args=(--noexperimental_remote_repo_contents_cache query)
|
||||
bazel_query_args=(query)
|
||||
|
||||
if [[ -n "${BAZEL_REPO_CONTENTS_CACHE:-}" ]]; then
|
||||
bazel_query_args+=("--repo_contents_cache=${BAZEL_REPO_CONTENTS_CACHE}")
|
||||
@@ -43,8 +39,4 @@ if (( ${#query_args[@]} > 0 )); then
|
||||
fi
|
||||
bazel_query_args+=("$query_expression")
|
||||
|
||||
if (( ${#bazel_startup_args[@]} > 0 )); then
|
||||
run_bazel "${bazel_startup_args[@]}" "${bazel_query_args[@]}"
|
||||
else
|
||||
run_bazel "${bazel_query_args[@]}"
|
||||
fi
|
||||
run_bazel "${bazel_query_args[@]}"
|
||||
|
||||
@@ -22,6 +22,47 @@ REMOTE_EXECUTION_CONFIGS = {
|
||||
"--config=ci-v8",
|
||||
"--config=ci-windows-cross",
|
||||
}
|
||||
# Honor either explicit setting so the wrapper never overrides the caller's
|
||||
# choice when it supplies the CI default below.
|
||||
REMOTE_REPO_CONTENTS_CACHE_STARTUP_OPTIONS = {
|
||||
"--experimental_remote_repo_contents_cache",
|
||||
"--noexperimental_remote_repo_contents_cache",
|
||||
}
|
||||
|
||||
|
||||
def startup_args(args: Sequence[str], env: Mapping[str, str]) -> list[str]:
|
||||
"""Return shared startup options that are missing from a Bazel invocation.
|
||||
|
||||
Bazel startup options must precede the command, and changing them restarts
|
||||
the server and discards its analysis cache. GitHub Actions invokes Bazel
|
||||
through several helpers, so normalize their startup options here while
|
||||
preserving any explicit choice made by the caller.
|
||||
"""
|
||||
command_idx = next(
|
||||
(idx for idx, arg in enumerate(args) if not arg.startswith("-")),
|
||||
len(args),
|
||||
)
|
||||
configured_startup_args = args[:command_idx]
|
||||
injected_args = []
|
||||
|
||||
output_user_root = env.get("BAZEL_OUTPUT_USER_ROOT")
|
||||
if output_user_root and not any(
|
||||
arg.startswith("--output_user_root=") for arg in configured_startup_args
|
||||
):
|
||||
injected_args.append(f"--output_user_root={output_user_root}")
|
||||
|
||||
if env.get("GITHUB_ACTIONS") == "true" and not any(
|
||||
arg in REMOTE_REPO_CONTENTS_CACHE_STARTUP_OPTIONS
|
||||
for arg in configured_startup_args
|
||||
):
|
||||
# Work around Bazel 9 overlay materialization failures seen in CI. This
|
||||
# disables only the startup-level repo contents cache; keyed runs still
|
||||
# use BuildBuddy.
|
||||
injected_args.append("--noexperimental_remote_repo_contents_cache")
|
||||
|
||||
return injected_args
|
||||
|
||||
|
||||
# Only authenticated workflow runs executing trusted upstream code may use the
|
||||
# OpenAI BuildBuddy host. A pull request event without proof that its head is
|
||||
# in the upstream repository fails closed to the generic host.
|
||||
@@ -114,7 +155,7 @@ def bazel_args_with_remote_config(
|
||||
def bazel_command(*args: str, env: Mapping[str, str] | None = None) -> list[str]:
|
||||
env = os.environ if env is None else env
|
||||
bazel = env.get("CODEX_BAZEL_BIN", "bazel")
|
||||
return [bazel, *bazel_args_with_remote_config(args, env)]
|
||||
return [bazel, *startup_args(args, env), *bazel_args_with_remote_config(args, env)]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
@@ -182,6 +182,38 @@ class RunBazelWithBuildBuddyTest(unittest.TestCase):
|
||||
["fake-bazel", "info", "execution_root"],
|
||||
)
|
||||
|
||||
def test_bazel_command_normalizes_github_actions_startup_options(self) -> None:
|
||||
env = {
|
||||
"BAZEL_OUTPUT_USER_ROOT": "/tmp/bazel-output",
|
||||
"GITHUB_ACTIONS": "true",
|
||||
}
|
||||
|
||||
self.assertEqual(
|
||||
run_bazel_with_buildbuddy.bazel_command("build", "//codex-rs/...", env=env),
|
||||
[
|
||||
"bazel",
|
||||
"--output_user_root=/tmp/bazel-output",
|
||||
"--noexperimental_remote_repo_contents_cache",
|
||||
"build",
|
||||
"//codex-rs/...",
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
run_bazel_with_buildbuddy.bazel_command(
|
||||
"--experimental_remote_repo_contents_cache",
|
||||
"build",
|
||||
"//codex-rs/...",
|
||||
env=env,
|
||||
),
|
||||
[
|
||||
"bazel",
|
||||
"--output_user_root=/tmp/bazel-output",
|
||||
"--experimental_remote_repo_contents_cache",
|
||||
"build",
|
||||
"//codex-rs/...",
|
||||
],
|
||||
)
|
||||
|
||||
def test_main_preserves_spaced_argument_and_child_exit_status(self) -> None:
|
||||
spaced_arg = (
|
||||
r"--test_env=PATH=C:\Program Files\PowerShell\7;C:\Program Files\Git\bin"
|
||||
@@ -191,7 +223,9 @@ class RunBazelWithBuildBuddyTest(unittest.TestCase):
|
||||
)
|
||||
env = os.environ.copy()
|
||||
env["CODEX_BAZEL_BIN"] = sys.executable
|
||||
env.pop("BAZEL_OUTPUT_USER_ROOT", None)
|
||||
env.pop("BUILDBUDDY_API_KEY", None)
|
||||
env.pop("GITHUB_ACTIONS", None)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
|
||||
+13
-13
@@ -20,8 +20,9 @@ jobs:
|
||||
test:
|
||||
# PRs use the sharded Windows cross-compiled test jobs below. Post-merge
|
||||
# pushes to main also run the native Windows test job for broader Windows
|
||||
# signal without putting PR latency back on the critical path. Cargo CI
|
||||
# owns V8/code-mode test coverage for now.
|
||||
# signal without putting PR latency back on the critical path. When
|
||||
# authenticated RBE is available, the Windows-cross shards exercise the
|
||||
# source-built V8/code-mode targets.
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -91,9 +92,9 @@ jobs:
|
||||
# path. V8 consumers under `//codex-rs/...` still participate
|
||||
# transitively through `//...`.
|
||||
-//third_party/v8:all
|
||||
# V8-backed code-mode tests are covered by Cargo CI. Bazel CI
|
||||
# cross-compiles in several legs, and those tests are not stable in
|
||||
# that setup yet.
|
||||
# Keep V8-backed code-mode tests out of the ordinary macOS/Linux
|
||||
# legs; authenticated Windows-cross shards below exercise the
|
||||
# source-built gnullvm V8 path.
|
||||
-//codex-rs/code-mode:code-mode-unit-tests
|
||||
-//codex-rs/v8-poc:v8-poc-unit-tests
|
||||
)
|
||||
@@ -135,9 +136,9 @@ jobs:
|
||||
key: ${{ steps.prepare_bazel.outputs.repository-cache-key }}
|
||||
|
||||
test-windows-shard:
|
||||
# Split the Windows Bazel test leg across separate Windows
|
||||
# hosts. Each shard still uses Linux RBE for build actions, but the test
|
||||
# execution itself happens on its own Windows runner.
|
||||
# Split the Windows Bazel test leg across separate Windows hosts. Jobs with
|
||||
# BuildBuddy credentials use Linux RBE for build actions; test execution
|
||||
# remains on a Windows runner.
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -183,9 +184,9 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
bazel_test_query='tests(//...) except tests(//third_party/v8:all) except //codex-rs/code-mode:code-mode-unit-tests except //codex-rs/v8-poc:v8-poc-unit-tests except attr(tags, "manual", tests(//...))'
|
||||
bazel_test_query='tests(//...) except tests(//third_party/v8:all) except attr(tags, "manual", tests(//...))'
|
||||
mapfile -t bazel_targets < <(
|
||||
MSYS2_ARG_CONV_EXCL='*' bazel query --output=label "${bazel_test_query}" \
|
||||
./.github/scripts/run-bazel-query-ci.sh --output=label -- "${bazel_test_query}" \
|
||||
| LC_ALL=C sort
|
||||
)
|
||||
|
||||
@@ -289,9 +290,8 @@ jobs:
|
||||
# path. V8 consumers under `//codex-rs/...` still participate
|
||||
# transitively through `//...`.
|
||||
-//third_party/v8:all
|
||||
# Keep this aligned with the main Bazel job. The native Windows
|
||||
# job preserves broad post-merge coverage, but code-mode/V8 tests
|
||||
# are covered by Cargo CI rather than Bazel for now.
|
||||
# Keep this job broad and cheap; authenticated Windows-cross jobs
|
||||
# add source-built V8-backed code-mode coverage.
|
||||
-//codex-rs/code-mode:code-mode-unit-tests
|
||||
-//codex-rs/v8-poc:v8-poc-unit-tests
|
||||
)
|
||||
|
||||
@@ -167,8 +167,6 @@ jobs:
|
||||
USE_SCCACHE: ${{ (startsWith(matrix.runner, 'windows') || (matrix.runner == 'macos-15-xlarge' && matrix.target == 'x86_64-apple-darwin')) && 'false' || 'true' }}
|
||||
CARGO_INCREMENTAL: "0"
|
||||
SCCACHE_CACHE_SIZE: 10G
|
||||
# In rust-ci, representative release-profile checks use thin LTO for faster feedback.
|
||||
CARGO_PROFILE_RELEASE_LTO: ${{ matrix.profile == 'release' && 'thin' || 'fat' }}
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
@@ -2,23 +2,6 @@ name: rust-release-windows
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
release-lto:
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
AZURE_TRUSTED_SIGNING_CLIENT_ID:
|
||||
required: true
|
||||
AZURE_TRUSTED_SIGNING_TENANT_ID:
|
||||
required: true
|
||||
AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID:
|
||||
required: true
|
||||
AZURE_TRUSTED_SIGNING_ENDPOINT:
|
||||
required: true
|
||||
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME:
|
||||
required: true
|
||||
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME:
|
||||
required: true
|
||||
|
||||
# Cargo's libgit2 transport has been flaky when fetching git dependencies with
|
||||
# nested submodules. Prefer the system git CLI across every Cargo invocation.
|
||||
@@ -29,17 +12,14 @@ jobs:
|
||||
build-windows-binaries:
|
||||
name: Build Windows binaries - ${{ matrix.runner }} - ${{ matrix.target }} - ${{ matrix.bundle }}
|
||||
runs-on: ${{ matrix.runs_on }}
|
||||
# Windows release builds can exceed an hour on fat-LTO mainline releases,
|
||||
# so keep the timeout aligned with the top-level release build headroom.
|
||||
# Windows release builds can exceed an hour, so keep the timeout aligned
|
||||
# with the top-level release build headroom.
|
||||
timeout-minutes: 90
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: codex-rs
|
||||
env:
|
||||
CARGO_PROFILE_RELEASE_LTO: ${{ inputs.release-lto }}
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -150,6 +130,9 @@ jobs:
|
||||
- build-windows-binaries
|
||||
name: Build - ${{ matrix.runner }} - ${{ matrix.target }}
|
||||
runs-on: ${{ matrix.runs_on }}
|
||||
environment:
|
||||
name: azure-artifact-signing
|
||||
deployment: false
|
||||
timeout-minutes: 90
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -211,12 +194,12 @@ jobs:
|
||||
with:
|
||||
target: ${{ matrix.target }}
|
||||
binaries: ${{ env.WINDOWS_BINARIES }}
|
||||
client-id: ${{ secrets.AZURE_TRUSTED_SIGNING_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TRUSTED_SIGNING_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID }}
|
||||
endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
|
||||
account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
|
||||
certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }}
|
||||
client-id: ${{ secrets.AZURE_ARTIFACT_SIGNING_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_ARTIFACT_SIGNING_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_ARTIFACT_SIGNING_SUBSCRIPTION_ID }}
|
||||
endpoint: ${{ secrets.AZURE_ARTIFACT_SIGNING_ENDPOINT }}
|
||||
account-name: ${{ secrets.AZURE_ARTIFACT_SIGNING_ACCOUNT_NAME }}
|
||||
certificate-profile-name: ${{ secrets.AZURE_ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME }}
|
||||
|
||||
- name: Stage artifacts
|
||||
shell: bash
|
||||
|
||||
@@ -149,9 +149,6 @@ jobs:
|
||||
run:
|
||||
working-directory: codex-rs
|
||||
env:
|
||||
# 2026-03-04: temporarily change releases to use thin LTO because
|
||||
# Ubuntu ARM is timing out at 60 minutes.
|
||||
CARGO_PROFILE_RELEASE_LTO: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'thin' }}
|
||||
# Use the git CLI instead of Cargo's libgit2 path for git dependencies.
|
||||
# macOS release runners have intermittently failed to fetch nested
|
||||
# submodules through SecureTransport/libgit2, especially libwebrtc's
|
||||
@@ -325,7 +322,6 @@ jobs:
|
||||
for binary in ${{ matrix.binaries }}; do
|
||||
build_args+=(--bin "$binary")
|
||||
done
|
||||
echo "CARGO_PROFILE_RELEASE_LTO: ${CARGO_PROFILE_RELEASE_LTO}"
|
||||
cargo build --target "$target" --release --timings "${build_args[@]}"
|
||||
|
||||
- name: Upload Cargo timings
|
||||
@@ -1330,8 +1326,6 @@ jobs:
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode == 'build_unsigned' }}
|
||||
needs: tag-check
|
||||
uses: ./.github/workflows/rust-release-windows.yml
|
||||
with:
|
||||
release-lto: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'fat' }}
|
||||
secrets: inherit
|
||||
|
||||
argument-comment-lint-release-assets:
|
||||
@@ -1952,6 +1946,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
environment:
|
||||
name: mainline-release-winget
|
||||
deployment: false
|
||||
|
||||
steps:
|
||||
- name: Publish to WinGet
|
||||
|
||||
Generated
+7
@@ -2789,6 +2789,7 @@ dependencies = [
|
||||
"codex-file-system",
|
||||
"codex-protocol",
|
||||
"codex-sandboxing",
|
||||
"codex-shell-command",
|
||||
"codex-test-binary-support",
|
||||
"codex-utils-absolute-path",
|
||||
"codex-utils-pty",
|
||||
@@ -3052,6 +3053,7 @@ dependencies = [
|
||||
"codex-model-provider-info",
|
||||
"codex-protocol",
|
||||
"codex-tools",
|
||||
"codex-utils-absolute-path",
|
||||
"http 1.4.0",
|
||||
"pretty_assertions",
|
||||
"schemars 0.8.22",
|
||||
@@ -3293,6 +3295,7 @@ name = "codex-message-history"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"codex-config",
|
||||
"memchr",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3407,6 +3410,7 @@ dependencies = [
|
||||
"codex-core",
|
||||
"codex-model-provider-info",
|
||||
"futures",
|
||||
"memchr",
|
||||
"pretty_assertions",
|
||||
"reqwest 0.12.28",
|
||||
"semver",
|
||||
@@ -3576,6 +3580,7 @@ dependencies = [
|
||||
"codex-utils-pty",
|
||||
"futures",
|
||||
"keyring",
|
||||
"memchr",
|
||||
"oauth2",
|
||||
"pretty_assertions",
|
||||
"reqwest 0.13.4",
|
||||
@@ -3593,6 +3598,7 @@ dependencies = [
|
||||
"urlencoding",
|
||||
"webbrowser",
|
||||
"which 8.0.0",
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3687,6 +3693,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"codex-protocol",
|
||||
"codex-utils-absolute-path",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"pretty_assertions",
|
||||
"regex",
|
||||
|
||||
+2
-1
@@ -318,6 +318,7 @@ libc = "0.2.182"
|
||||
log = "0.4"
|
||||
lru = "0.16.3"
|
||||
maplit = "1.0.2"
|
||||
memchr = "2.7.6"
|
||||
mime_guess = "2.0.5"
|
||||
multimap = "0.10.0"
|
||||
notify = "8.2.0"
|
||||
@@ -500,7 +501,7 @@ debug = "none"
|
||||
strip = "symbols"
|
||||
|
||||
[profile.release]
|
||||
lto = "fat"
|
||||
lto = "thin"
|
||||
split-debuginfo = "off"
|
||||
# Because we bundle some of these executables with the TypeScript CLI, we
|
||||
# remove everything to make the binary as small as possible.
|
||||
|
||||
+2
-98
@@ -1,99 +1,3 @@
|
||||
# Codex CLI (Rust Implementation)
|
||||
# Codex CLI
|
||||
|
||||
We provide Codex CLI as a standalone executable to ensure a zero-dependency install.
|
||||
|
||||
## Installing Codex
|
||||
|
||||
Today, the easiest way to install Codex is via `npm`:
|
||||
|
||||
```shell
|
||||
npm i -g @openai/codex
|
||||
codex
|
||||
```
|
||||
|
||||
You can also install via Homebrew (`brew install --cask codex`) or download a platform-specific release directly from our [GitHub Releases](https://github.com/openai/codex/releases).
|
||||
|
||||
## Documentation quickstart
|
||||
|
||||
- First run with Codex? Start with [`docs/getting-started.md`](../docs/getting-started.md) (links to the walkthrough for prompts, keyboard shortcuts, and session management).
|
||||
- Want deeper control? See [`docs/config.md`](../docs/config.md) and [`docs/install.md`](../docs/install.md).
|
||||
|
||||
## What's new in the Rust CLI
|
||||
|
||||
The Rust implementation is now the maintained Codex CLI and serves as the default experience. It includes a number of features that the legacy TypeScript CLI never supported.
|
||||
|
||||
### Config
|
||||
|
||||
Codex supports a rich set of configuration options. Note that the Rust CLI uses `config.toml` instead of `config.json`. See [`docs/config.md`](../docs/config.md) for details.
|
||||
|
||||
### Model Context Protocol Support
|
||||
|
||||
#### MCP client
|
||||
|
||||
Codex CLI functions as an MCP client that allows the Codex CLI and IDE extension to connect to MCP servers on startup. See the [`configuration documentation`](../docs/config.md#connecting-to-mcp-servers) for details.
|
||||
|
||||
#### MCP server (experimental)
|
||||
|
||||
Codex can be launched as an MCP _server_ by running `codex mcp-server`. This allows _other_ MCP clients to use Codex as a tool for another agent.
|
||||
|
||||
Use the [`@modelcontextprotocol/inspector`](https://github.com/modelcontextprotocol/inspector) to try it out:
|
||||
|
||||
```shell
|
||||
npx @modelcontextprotocol/inspector codex mcp-server
|
||||
```
|
||||
|
||||
Use `codex mcp` to add/list/get/remove MCP server launchers defined in `config.toml`, and `codex mcp-server` to run the MCP server directly.
|
||||
|
||||
### Notifications
|
||||
|
||||
You can enable notifications by configuring a script that is run whenever the agent finishes a turn. The [notify documentation](../docs/config.md#notify) includes a detailed example that explains how to get desktop notifications via [terminal-notifier](https://github.com/julienXX/terminal-notifier) on macOS. When Codex detects that it is running under WSL 2 inside Windows Terminal (`WT_SESSION` is set), the TUI automatically falls back to native Windows toast notifications so approval prompts and completed turns surface even though Windows Terminal does not implement OSC 9.
|
||||
|
||||
### `codex exec` to run Codex programmatically/non-interactively
|
||||
|
||||
To run Codex non-interactively, run `codex exec PROMPT` (you can also pass the prompt via `stdin`) and Codex will work on your task until it decides that it is done and exits. If you provide both a prompt argument and piped stdin, Codex appends stdin as a `<stdin>` block after the prompt so patterns like `echo "my output" | codex exec "Summarize this concisely"` work naturally. Output is printed to the terminal directly. You can set the `RUST_LOG` environment variable to see more about what's going on.
|
||||
Use `codex exec --ephemeral ...` to run without persisting session rollout files to disk.
|
||||
|
||||
### Experimenting with the Codex Sandbox
|
||||
|
||||
To test to see what happens when a command is run under the sandbox provided by Codex, use the `sandbox` subcommand in Codex CLI:
|
||||
|
||||
```
|
||||
# Uses the sandbox implementation for the current host OS:
|
||||
# Seatbelt on macOS, the Linux sandbox on Linux, and Windows restricted token on Windows.
|
||||
codex sandbox [COMMAND]...
|
||||
|
||||
# macOS-only diagnostic option
|
||||
codex sandbox --log-denials [COMMAND]...
|
||||
```
|
||||
|
||||
`codex sandbox` also accepts `--profile NAME` (`-p NAME`) to layer
|
||||
`$CODEX_HOME/NAME.config.toml` onto the base user config for the sandboxed
|
||||
command.
|
||||
|
||||
### Selecting a sandbox policy via `--sandbox`
|
||||
|
||||
The Rust CLI exposes a dedicated `--sandbox` (`-s`) flag that lets you pick the sandbox policy **without** having to reach for the generic `-c/--config` option:
|
||||
|
||||
```shell
|
||||
# Run Codex with the default, read-only sandbox
|
||||
codex --sandbox read-only
|
||||
|
||||
# Allow the agent to write within the current workspace while still blocking network access
|
||||
codex --sandbox workspace-write
|
||||
|
||||
# Danger! Disable sandboxing entirely (only do this if you are already running in a container or other isolated env)
|
||||
codex --sandbox danger-full-access
|
||||
```
|
||||
|
||||
In `workspace-write`, Codex also includes `~/.codex/memories` in its writable roots so memory maintenance does not require an extra approval.
|
||||
|
||||
## Code Organization
|
||||
|
||||
This folder is the root of a Cargo workspace. It contains quite a bit of experimental code, but here are the key crates:
|
||||
|
||||
- [`core/`](./core) contains the business logic for Codex. Ultimately, we hope this becomes a library crate that is generally useful for building other Rust/native applications that use Codex.
|
||||
- [`exec/`](./exec) "headless" CLI for use in automation.
|
||||
- [`tui/`](./tui) CLI that launches a fullscreen TUI built with [Ratatui](https://ratatui.rs/).
|
||||
- [`cli/`](./cli) CLI multitool that provides the aforementioned CLIs via subcommands.
|
||||
|
||||
If you want to contribute or inspect behavior in detail, start by reading the module-level `README.md` files under each crate and run the project workspace from the top-level `codex-rs` directory so shared config, features, and build scripts stay aligned.
|
||||
[**Codex CLI Documentation**](https://developers.openai.com/codex/cli)
|
||||
|
||||
@@ -1347,6 +1347,7 @@ fn thread_initialized_event_serializes_expected_shape() {
|
||||
initialization_mode: ThreadInitializationMode::New,
|
||||
subagent_source: None,
|
||||
parent_thread_id: None,
|
||||
forked_from_thread_id: None,
|
||||
created_at: 1,
|
||||
},
|
||||
});
|
||||
@@ -1379,6 +1380,7 @@ fn thread_initialized_event_serializes_expected_shape() {
|
||||
"initialization_mode": "new",
|
||||
"subagent_source": null,
|
||||
"parent_thread_id": null,
|
||||
"forked_from_thread_id": null,
|
||||
"created_at": 1
|
||||
}
|
||||
})
|
||||
@@ -2429,6 +2431,7 @@ fn subagent_thread_started_review_serializes_expected_shape() {
|
||||
session_id: "session-root".to_string(),
|
||||
thread_id: "thread-review".to_string(),
|
||||
parent_thread_id: None,
|
||||
forked_from_thread_id: None,
|
||||
product_client_id: "codex-tui".to_string(),
|
||||
client_name: "codex-tui".to_string(),
|
||||
client_version: "1.0.0".to_string(),
|
||||
@@ -2461,18 +2464,26 @@ fn subagent_thread_started_review_serializes_expected_shape() {
|
||||
assert_eq!(payload["event_params"]["initialization_mode"], "new");
|
||||
assert_eq!(payload["event_params"]["subagent_source"], "review");
|
||||
assert_eq!(payload["event_params"]["parent_thread_id"], json!(null));
|
||||
assert_eq!(
|
||||
payload["event_params"]["forked_from_thread_id"],
|
||||
json!(null)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_thread_started_thread_spawn_serializes_parent_thread_id() {
|
||||
fn subagent_thread_started_thread_spawn_serializes_thread_lineage() {
|
||||
let parent_thread_id =
|
||||
codex_protocol::ThreadId::from_string("11111111-1111-1111-1111-111111111111")
|
||||
.expect("valid thread id");
|
||||
let forked_from_thread_id =
|
||||
codex_protocol::ThreadId::from_string("22222222-2222-4222-8222-222222222222")
|
||||
.expect("valid thread id");
|
||||
let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request(
|
||||
SubAgentThreadStartedInput {
|
||||
session_id: "session-root".to_string(),
|
||||
thread_id: "thread-spawn".to_string(),
|
||||
parent_thread_id: Some(parent_thread_id.to_string()),
|
||||
forked_from_thread_id: Some(forked_from_thread_id.to_string()),
|
||||
product_client_id: "codex-tui".to_string(),
|
||||
client_name: "codex-tui".to_string(),
|
||||
client_version: "1.0.0".to_string(),
|
||||
@@ -2497,6 +2508,10 @@ fn subagent_thread_started_thread_spawn_serializes_parent_thread_id() {
|
||||
payload["event_params"]["parent_thread_id"],
|
||||
"11111111-1111-1111-1111-111111111111"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["event_params"]["forked_from_thread_id"],
|
||||
"22222222-2222-4222-8222-222222222222"
|
||||
);
|
||||
assert_eq!(payload["event_params"]["session_id"], "session-root");
|
||||
}
|
||||
|
||||
@@ -2507,6 +2522,7 @@ fn subagent_thread_started_memory_consolidation_serializes_expected_shape() {
|
||||
session_id: "session-root".to_string(),
|
||||
thread_id: "thread-memory".to_string(),
|
||||
parent_thread_id: None,
|
||||
forked_from_thread_id: None,
|
||||
product_client_id: "codex-tui".to_string(),
|
||||
client_name: "codex-tui".to_string(),
|
||||
client_version: "1.0.0".to_string(),
|
||||
@@ -2533,6 +2549,7 @@ fn subagent_thread_started_other_serializes_expected_shape() {
|
||||
session_id: "session-root".to_string(),
|
||||
thread_id: "thread-guardian".to_string(),
|
||||
parent_thread_id: None,
|
||||
forked_from_thread_id: None,
|
||||
product_client_id: "codex-tui".to_string(),
|
||||
client_name: "codex-tui".to_string(),
|
||||
client_version: "1.0.0".to_string(),
|
||||
@@ -2558,6 +2575,7 @@ fn subagent_thread_started_other_serializes_explicit_parent_thread_id() {
|
||||
session_id: "session-root".to_string(),
|
||||
thread_id: "thread-guardian".to_string(),
|
||||
parent_thread_id: Some(parent_thread_id.to_string()),
|
||||
forked_from_thread_id: None,
|
||||
product_client_id: "codex-tui".to_string(),
|
||||
client_name: "codex-tui".to_string(),
|
||||
client_version: "1.0.0".to_string(),
|
||||
@@ -2588,6 +2606,7 @@ async fn subagent_thread_started_publishes_without_initialize() {
|
||||
session_id: "session-root".to_string(),
|
||||
thread_id: "thread-review".to_string(),
|
||||
parent_thread_id: None,
|
||||
forked_from_thread_id: None,
|
||||
product_client_id: "codex-tui".to_string(),
|
||||
client_name: "codex-tui".to_string(),
|
||||
client_version: "1.0.0".to_string(),
|
||||
@@ -2662,6 +2681,7 @@ async fn subagent_thread_started_inherits_parent_connection_for_new_thread() {
|
||||
session_id: "session-root".to_string(),
|
||||
thread_id: "thread-review".to_string(),
|
||||
parent_thread_id: Some(parent_thread_id.to_string()),
|
||||
forked_from_thread_id: None,
|
||||
product_client_id: "parent-client".to_string(),
|
||||
client_name: "parent-client".to_string(),
|
||||
client_version: "1.0.0".to_string(),
|
||||
@@ -2732,6 +2752,7 @@ async fn subagent_tool_items_inherit_parent_connection_metadata() {
|
||||
session_id: "session-root".to_string(),
|
||||
thread_id: "thread-subagent".to_string(),
|
||||
parent_thread_id: Some("thread-1".to_string()),
|
||||
forked_from_thread_id: None,
|
||||
product_client_id: "codex-tui".to_string(),
|
||||
client_name: "codex-tui".to_string(),
|
||||
client_version: "1.0.0".to_string(),
|
||||
|
||||
@@ -157,6 +157,7 @@ pub(crate) struct ThreadInitializedEventParams {
|
||||
pub(crate) initialization_mode: ThreadInitializationMode,
|
||||
pub(crate) subagent_source: Option<String>,
|
||||
pub(crate) parent_thread_id: Option<String>,
|
||||
pub(crate) forked_from_thread_id: Option<String>,
|
||||
pub(crate) created_at: u64,
|
||||
}
|
||||
|
||||
@@ -1060,6 +1061,7 @@ pub(crate) fn subagent_thread_started_event_request(
|
||||
initialization_mode: ThreadInitializationMode::New,
|
||||
subagent_source: Some(subagent_source_name(&input.subagent_source)),
|
||||
parent_thread_id: input.parent_thread_id,
|
||||
forked_from_thread_id: input.forked_from_thread_id,
|
||||
created_at: input.created_at,
|
||||
};
|
||||
ThreadInitializedEvent {
|
||||
|
||||
@@ -348,6 +348,7 @@ pub struct SubAgentThreadStartedInput {
|
||||
pub session_id: String,
|
||||
pub thread_id: String,
|
||||
pub parent_thread_id: Option<String>,
|
||||
pub forked_from_thread_id: Option<String>,
|
||||
pub product_client_id: String,
|
||||
pub client_name: String,
|
||||
pub client_version: String,
|
||||
|
||||
@@ -1268,6 +1268,7 @@ impl AnalyticsReducer {
|
||||
let session_id = thread.session_id;
|
||||
let thread_id = thread.id;
|
||||
let parent_thread_id = thread.parent_thread_id;
|
||||
let forked_from_thread_id = thread.forked_from_id;
|
||||
let Some(connection_state) = self.connections.get(&connection_id) else {
|
||||
return;
|
||||
};
|
||||
@@ -1299,6 +1300,7 @@ impl AnalyticsReducer {
|
||||
initialization_mode,
|
||||
subagent_source: thread_metadata.subagent_source.clone(),
|
||||
parent_thread_id: thread_metadata.parent_thread_id,
|
||||
forked_from_thread_id,
|
||||
created_at: u64::try_from(thread.created_at).unwrap_or_default(),
|
||||
},
|
||||
},
|
||||
|
||||
+57
-9
@@ -34,6 +34,30 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"AgentMessageInputContent": {
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"encrypted_content": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"encrypted_content"
|
||||
],
|
||||
"title": "EncryptedContentAgentMessageInputContentType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"encrypted_content",
|
||||
"type"
|
||||
],
|
||||
"title": "EncryptedContentAgentMessageInputContent",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ApprovalsReviewer": {
|
||||
"description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.",
|
||||
"enum": [
|
||||
@@ -1971,15 +1995,8 @@
|
||||
"type": "string"
|
||||
},
|
||||
"ReasoningEffort": {
|
||||
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
|
||||
"enum": [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
],
|
||||
"description": "A non-empty reasoning effort value advertised by the model.",
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"ReasoningItemContent": {
|
||||
@@ -2127,6 +2144,37 @@
|
||||
"title": "MessageResponseItem",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"author": {
|
||||
"type": "string"
|
||||
},
|
||||
"content": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/AgentMessageInputContent"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"recipient": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"agent_message"
|
||||
],
|
||||
"title": "AgentMessageResponseItemType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"author",
|
||||
"content",
|
||||
"recipient",
|
||||
"type"
|
||||
],
|
||||
"title": "AgentMessageResponseItem",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"content": {
|
||||
|
||||
+3
-3
@@ -288,8 +288,8 @@
|
||||
"environmentId": {
|
||||
"default": null,
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"itemId": {
|
||||
@@ -326,4 +326,4 @@
|
||||
],
|
||||
"title": "PermissionsRequestApprovalParams",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
@@ -2777,15 +2777,8 @@
|
||||
"type": "string"
|
||||
},
|
||||
"ReasoningEffort": {
|
||||
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
|
||||
"enum": [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
],
|
||||
"description": "A non-empty reasoning effort value advertised by the model.",
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"ReasoningSummary": {
|
||||
@@ -4916,6 +4909,23 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"TurnModerationMetadataNotification": {
|
||||
"properties": {
|
||||
"metadata": true,
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
},
|
||||
"turnId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"metadata",
|
||||
"threadId",
|
||||
"turnId"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"TurnPlanStep": {
|
||||
"properties": {
|
||||
"status": {
|
||||
@@ -6255,6 +6265,26 @@
|
||||
"title": "Model/verificationNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
"enum": [
|
||||
"turn/moderationMetadata"
|
||||
],
|
||||
"title": "Turn/moderationMetadataNotificationMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/TurnModerationMetadataNotification"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Turn/moderationMetadataNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
|
||||
+3
-3
@@ -1593,8 +1593,8 @@
|
||||
"environmentId": {
|
||||
"default": null,
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"itemId": {
|
||||
@@ -2005,4 +2005,4 @@
|
||||
}
|
||||
],
|
||||
"title": "ServerRequest"
|
||||
}
|
||||
}
|
||||
+169
-12
@@ -3786,8 +3786,8 @@
|
||||
"environmentId": {
|
||||
"default": null,
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"itemId": {
|
||||
@@ -4904,6 +4904,26 @@
|
||||
"title": "Model/verificationNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
"enum": [
|
||||
"turn/moderationMetadata"
|
||||
],
|
||||
"title": "Turn/moderationMetadataNotificationMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/v2/TurnModerationMetadataNotification"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Turn/moderationMetadataNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
@@ -5900,6 +5920,30 @@
|
||||
"title": "AgentMessageDeltaNotification",
|
||||
"type": "object"
|
||||
},
|
||||
"AgentMessageInputContent": {
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"encrypted_content": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"encrypted_content"
|
||||
],
|
||||
"title": "EncryptedContentAgentMessageInputContentType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"encrypted_content",
|
||||
"type"
|
||||
],
|
||||
"title": "EncryptedContentAgentMessageInputContent",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"AgentPath": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -6278,6 +6322,69 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AppTemplateSummary": {
|
||||
"properties": {
|
||||
"canonicalConnectorId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"description": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"logoUrl": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"logoUrlDark": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"materializedAppIds": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"reason": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/AppTemplateUnavailableReason"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"templateId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"materializedAppIds",
|
||||
"name",
|
||||
"templateId"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AppTemplateUnavailableReason": {
|
||||
"enum": [
|
||||
"NOT_CONFIGURED_FOR_WORKSPACE",
|
||||
"NO_ACTIVE_WORKSPACE"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"AppToolApproval": {
|
||||
"enum": [
|
||||
"auto",
|
||||
@@ -12168,6 +12275,12 @@
|
||||
},
|
||||
"PluginDetail": {
|
||||
"properties": {
|
||||
"appTemplates": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/v2/AppTemplateSummary"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"apps": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/v2/AppSummary"
|
||||
@@ -12216,6 +12329,7 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"appTemplates",
|
||||
"apps",
|
||||
"hooks",
|
||||
"marketplaceName",
|
||||
@@ -13525,15 +13639,8 @@
|
||||
"type": "object"
|
||||
},
|
||||
"ReasoningEffort": {
|
||||
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
|
||||
"enum": [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
],
|
||||
"description": "A non-empty reasoning effort value advertised by the model.",
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"ReasoningEffortOption": {
|
||||
@@ -13987,6 +14094,37 @@
|
||||
"title": "MessageResponseItem",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"author": {
|
||||
"type": "string"
|
||||
},
|
||||
"content": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/v2/AgentMessageInputContent"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"recipient": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"agent_message"
|
||||
],
|
||||
"title": "AgentMessageResponseItemType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"author",
|
||||
"content",
|
||||
"recipient",
|
||||
"type"
|
||||
],
|
||||
"title": "AgentMessageResponseItem",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"content": {
|
||||
@@ -18483,6 +18621,25 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"TurnModerationMetadataNotification": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"metadata": true,
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
},
|
||||
"turnId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"metadata",
|
||||
"threadId",
|
||||
"turnId"
|
||||
],
|
||||
"title": "TurnModerationMetadataNotification",
|
||||
"type": "object"
|
||||
},
|
||||
"TurnPlanStep": {
|
||||
"properties": {
|
||||
"status": {
|
||||
@@ -19239,4 +19396,4 @@
|
||||
},
|
||||
"title": "CodexAppServerProtocol",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
+166
-9
@@ -265,6 +265,30 @@
|
||||
"title": "AgentMessageDeltaNotification",
|
||||
"type": "object"
|
||||
},
|
||||
"AgentMessageInputContent": {
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"encrypted_content": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"encrypted_content"
|
||||
],
|
||||
"title": "EncryptedContentAgentMessageInputContentType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"encrypted_content",
|
||||
"type"
|
||||
],
|
||||
"title": "EncryptedContentAgentMessageInputContent",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"AgentPath": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -643,6 +667,69 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AppTemplateSummary": {
|
||||
"properties": {
|
||||
"canonicalConnectorId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"description": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"logoUrl": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"logoUrlDark": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"materializedAppIds": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"reason": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AppTemplateUnavailableReason"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"templateId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"materializedAppIds",
|
||||
"name",
|
||||
"templateId"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AppTemplateUnavailableReason": {
|
||||
"enum": [
|
||||
"NOT_CONFIGURED_FOR_WORKSPACE",
|
||||
"NO_ACTIVE_WORKSPACE"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"AppToolApproval": {
|
||||
"enum": [
|
||||
"auto",
|
||||
@@ -8690,6 +8777,12 @@
|
||||
},
|
||||
"PluginDetail": {
|
||||
"properties": {
|
||||
"appTemplates": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/AppTemplateSummary"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"apps": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/AppSummary"
|
||||
@@ -8738,6 +8831,7 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"appTemplates",
|
||||
"apps",
|
||||
"hooks",
|
||||
"marketplaceName",
|
||||
@@ -10047,15 +10141,8 @@
|
||||
"type": "object"
|
||||
},
|
||||
"ReasoningEffort": {
|
||||
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
|
||||
"enum": [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
],
|
||||
"description": "A non-empty reasoning effort value advertised by the model.",
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"ReasoningEffortOption": {
|
||||
@@ -10509,6 +10596,37 @@
|
||||
"title": "MessageResponseItem",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"author": {
|
||||
"type": "string"
|
||||
},
|
||||
"content": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/AgentMessageInputContent"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"recipient": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"agent_message"
|
||||
],
|
||||
"title": "AgentMessageResponseItemType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"author",
|
||||
"content",
|
||||
"recipient",
|
||||
"type"
|
||||
],
|
||||
"title": "AgentMessageResponseItem",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"content": {
|
||||
@@ -12302,6 +12420,26 @@
|
||||
"title": "Model/verificationNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
"enum": [
|
||||
"turn/moderationMetadata"
|
||||
],
|
||||
"title": "Turn/moderationMetadataNotificationMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/TurnModerationMetadataNotification"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Turn/moderationMetadataNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
@@ -16300,6 +16438,25 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"TurnModerationMetadataNotification": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"metadata": true,
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
},
|
||||
"turnId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"metadata",
|
||||
"threadId",
|
||||
"turnId"
|
||||
],
|
||||
"title": "TurnModerationMetadataNotification",
|
||||
"type": "object"
|
||||
},
|
||||
"TurnPlanStep": {
|
||||
"properties": {
|
||||
"status": {
|
||||
|
||||
@@ -667,15 +667,8 @@
|
||||
"type": "string"
|
||||
},
|
||||
"ReasoningEffort": {
|
||||
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
|
||||
"enum": [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
],
|
||||
"description": "A non-empty reasoning effort value advertised by the model.",
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"ReasoningSummary": {
|
||||
|
||||
+2
-9
@@ -462,15 +462,8 @@
|
||||
]
|
||||
},
|
||||
"ReasoningEffort": {
|
||||
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
|
||||
"enum": [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
],
|
||||
"description": "A non-empty reasoning effort value advertised by the model.",
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"TextElement": {
|
||||
|
||||
@@ -462,15 +462,8 @@
|
||||
]
|
||||
},
|
||||
"ReasoningEffort": {
|
||||
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
|
||||
"enum": [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
],
|
||||
"description": "A non-empty reasoning effort value advertised by the model.",
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"TextElement": {
|
||||
|
||||
@@ -185,15 +185,8 @@
|
||||
"type": "object"
|
||||
},
|
||||
"ReasoningEffort": {
|
||||
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
|
||||
"enum": [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
],
|
||||
"description": "A non-empty reasoning effort value advertised by the model.",
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"ReasoningEffortOption": {
|
||||
|
||||
@@ -37,6 +37,69 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AppTemplateSummary": {
|
||||
"properties": {
|
||||
"canonicalConnectorId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"description": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"logoUrl": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"logoUrlDark": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"materializedAppIds": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"reason": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AppTemplateUnavailableReason"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"templateId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"materializedAppIds",
|
||||
"name",
|
||||
"templateId"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AppTemplateUnavailableReason": {
|
||||
"enum": [
|
||||
"NOT_CONFIGURED_FOR_WORKSPACE",
|
||||
"NO_ACTIVE_WORKSPACE"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"HookEventName": {
|
||||
"enum": [
|
||||
"preToolUse",
|
||||
@@ -78,6 +141,12 @@
|
||||
},
|
||||
"PluginDetail": {
|
||||
"properties": {
|
||||
"appTemplates": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/AppTemplateSummary"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"apps": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/AppSummary"
|
||||
@@ -126,6 +195,7 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"appTemplates",
|
||||
"apps",
|
||||
"hooks",
|
||||
"marketplaceName",
|
||||
|
||||
+55
@@ -1,6 +1,30 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"AgentMessageInputContent": {
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"encrypted_content": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"encrypted_content"
|
||||
],
|
||||
"title": "EncryptedContentAgentMessageInputContentType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"encrypted_content",
|
||||
"type"
|
||||
],
|
||||
"title": "EncryptedContentAgentMessageInputContent",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ContentItem": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -369,6 +393,37 @@
|
||||
"title": "MessageResponseItem",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"author": {
|
||||
"type": "string"
|
||||
},
|
||||
"content": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/AgentMessageInputContent"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"recipient": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"agent_message"
|
||||
],
|
||||
"title": "AgentMessageResponseItemType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"author",
|
||||
"content",
|
||||
"recipient",
|
||||
"type"
|
||||
],
|
||||
"title": "AgentMessageResponseItem",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"content": {
|
||||
|
||||
@@ -606,15 +606,8 @@
|
||||
]
|
||||
},
|
||||
"ReasoningEffort": {
|
||||
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
|
||||
"enum": [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
],
|
||||
"description": "A non-empty reasoning effort value advertised by the model.",
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"TextElement": {
|
||||
|
||||
@@ -718,15 +718,8 @@
|
||||
]
|
||||
},
|
||||
"ReasoningEffort": {
|
||||
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
|
||||
"enum": [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
],
|
||||
"description": "A non-empty reasoning effort value advertised by the model.",
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"SandboxPolicy": {
|
||||
|
||||
@@ -632,15 +632,8 @@
|
||||
]
|
||||
},
|
||||
"ReasoningEffort": {
|
||||
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
|
||||
"enum": [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
],
|
||||
"description": "A non-empty reasoning effort value advertised by the model.",
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"SessionSource": {
|
||||
|
||||
+2
-9
@@ -632,15 +632,8 @@
|
||||
]
|
||||
},
|
||||
"ReasoningEffort": {
|
||||
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
|
||||
"enum": [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
],
|
||||
"description": "A non-empty reasoning effort value advertised by the model.",
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"SessionSource": {
|
||||
|
||||
@@ -632,15 +632,8 @@
|
||||
]
|
||||
},
|
||||
"ReasoningEffort": {
|
||||
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
|
||||
"enum": [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
],
|
||||
"description": "A non-empty reasoning effort value advertised by the model.",
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"SessionSource": {
|
||||
|
||||
@@ -1,6 +1,30 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"AgentMessageInputContent": {
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"encrypted_content": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"encrypted_content"
|
||||
],
|
||||
"title": "EncryptedContentAgentMessageInputContentType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"encrypted_content",
|
||||
"type"
|
||||
],
|
||||
"title": "EncryptedContentAgentMessageInputContent",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ApprovalsReviewer": {
|
||||
"description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.",
|
||||
"enum": [
|
||||
@@ -436,6 +460,37 @@
|
||||
"title": "MessageResponseItem",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"author": {
|
||||
"type": "string"
|
||||
},
|
||||
"content": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/AgentMessageInputContent"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"recipient": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"agent_message"
|
||||
],
|
||||
"title": "AgentMessageResponseItemType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"author",
|
||||
"content",
|
||||
"recipient",
|
||||
"type"
|
||||
],
|
||||
"title": "AgentMessageResponseItem",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"content": {
|
||||
|
||||
@@ -718,15 +718,8 @@
|
||||
]
|
||||
},
|
||||
"ReasoningEffort": {
|
||||
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
|
||||
"enum": [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
],
|
||||
"description": "A non-empty reasoning effort value advertised by the model.",
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"SandboxPolicy": {
|
||||
|
||||
@@ -632,15 +632,8 @@
|
||||
]
|
||||
},
|
||||
"ReasoningEffort": {
|
||||
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
|
||||
"enum": [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
],
|
||||
"description": "A non-empty reasoning effort value advertised by the model.",
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"SessionSource": {
|
||||
|
||||
+2
-9
@@ -124,15 +124,8 @@
|
||||
"type": "string"
|
||||
},
|
||||
"ReasoningEffort": {
|
||||
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
|
||||
"enum": [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
],
|
||||
"description": "A non-empty reasoning effort value advertised by the model.",
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"ReasoningSummary": {
|
||||
|
||||
@@ -718,15 +718,8 @@
|
||||
]
|
||||
},
|
||||
"ReasoningEffort": {
|
||||
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
|
||||
"enum": [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
],
|
||||
"description": "A non-empty reasoning effort value advertised by the model.",
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"SandboxPolicy": {
|
||||
|
||||
+2
-9
@@ -632,15 +632,8 @@
|
||||
]
|
||||
},
|
||||
"ReasoningEffort": {
|
||||
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
|
||||
"enum": [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
],
|
||||
"description": "A non-empty reasoning effort value advertised by the model.",
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"SessionSource": {
|
||||
|
||||
@@ -632,15 +632,8 @@
|
||||
]
|
||||
},
|
||||
"ReasoningEffort": {
|
||||
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
|
||||
"enum": [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
],
|
||||
"description": "A non-empty reasoning effort value advertised by the model.",
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"SessionSource": {
|
||||
|
||||
+2
-9
@@ -606,15 +606,8 @@
|
||||
]
|
||||
},
|
||||
"ReasoningEffort": {
|
||||
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
|
||||
"enum": [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
],
|
||||
"description": "A non-empty reasoning effort value advertised by the model.",
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"TextElement": {
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"metadata": true,
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
},
|
||||
"turnId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"metadata",
|
||||
"threadId",
|
||||
"turnId"
|
||||
],
|
||||
"title": "TurnModerationMetadataNotification",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -154,15 +154,8 @@
|
||||
"type": "string"
|
||||
},
|
||||
"ReasoningEffort": {
|
||||
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
|
||||
"enum": [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
],
|
||||
"description": "A non-empty reasoning effort value advertised by the model.",
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"ReasoningSummary": {
|
||||
|
||||
@@ -606,15 +606,8 @@
|
||||
]
|
||||
},
|
||||
"ReasoningEffort": {
|
||||
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
|
||||
"enum": [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
],
|
||||
"description": "A non-empty reasoning effort value advertised by the model.",
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"TextElement": {
|
||||
|
||||
@@ -606,15 +606,8 @@
|
||||
]
|
||||
},
|
||||
"ReasoningEffort": {
|
||||
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
|
||||
"enum": [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh"
|
||||
],
|
||||
"description": "A non-empty reasoning effort value advertised by the model.",
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"TextElement": {
|
||||
|
||||
@@ -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 AgentMessageInputContent = { "type": "encrypted_content", encrypted_content: string, };
|
||||
@@ -5,4 +5,4 @@
|
||||
/**
|
||||
* See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning
|
||||
*/
|
||||
export type ReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
||||
export type ReasoningEffort = string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { AgentMessageInputContent } from "./AgentMessageInputContent";
|
||||
import type { ContentItem } from "./ContentItem";
|
||||
import type { FunctionCallOutputBody } from "./FunctionCallOutputBody";
|
||||
import type { LocalShellAction } from "./LocalShellAction";
|
||||
@@ -10,7 +11,7 @@ import type { ReasoningItemContent } from "./ReasoningItemContent";
|
||||
import type { ReasoningItemReasoningSummary } from "./ReasoningItemReasoningSummary";
|
||||
import type { WebSearchAction } from "./WebSearchAction";
|
||||
|
||||
export type ResponseItem = { "type": "message", role: string, content: Array<ContentItem>, phase?: MessagePhase, } | { "type": "reasoning", summary: Array<ReasoningItemReasoningSummary>, content?: Array<ReasoningItemContent>, encrypted_content: string | null, } | { "type": "local_shell_call",
|
||||
export type ResponseItem = { "type": "message", role: string, content: Array<ContentItem>, phase?: MessagePhase, } | { "type": "agent_message", author: string, recipient: string, content: Array<AgentMessageInputContent>, } | { "type": "reasoning", summary: Array<ReasoningItemReasoningSummary>, content?: Array<ReasoningItemContent>, encrypted_content: string | null, } | { "type": "local_shell_call",
|
||||
/**
|
||||
* Set when using the Responses API.
|
||||
*/
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,7 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
export type { AbsolutePathBuf } from "./AbsolutePathBuf";
|
||||
export type { AgentMessageInputContent } from "./AgentMessageInputContent";
|
||||
export type { AgentPath } from "./AgentPath";
|
||||
export type { ApplyPatchApprovalParams } from "./ApplyPatchApprovalParams";
|
||||
export type { ApplyPatchApprovalResponse } from "./ApplyPatchApprovalResponse";
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// 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 { AppTemplateUnavailableReason } from "./AppTemplateUnavailableReason";
|
||||
|
||||
export type AppTemplateSummary = { templateId: string, name: string, description: string | null, canonicalConnectorId: string | null, logoUrl: string | null, logoUrlDark: string | null, materializedAppIds: Array<string>, reason: AppTemplateUnavailableReason | 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 AppTemplateUnavailableReason = "NOT_CONFIGURED_FOR_WORKSPACE" | "NO_ACTIVE_WORKSPACE";
|
||||
@@ -3,8 +3,9 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
|
||||
import type { AppSummary } from "./AppSummary";
|
||||
import type { AppTemplateSummary } from "./AppTemplateSummary";
|
||||
import type { PluginHookSummary } from "./PluginHookSummary";
|
||||
import type { PluginSummary } from "./PluginSummary";
|
||||
import type { SkillSummary } from "./SkillSummary";
|
||||
|
||||
export type PluginDetail = { marketplaceName: string, marketplacePath: AbsolutePathBuf | null, summary: PluginSummary, description: string | null, skills: Array<SkillSummary>, hooks: Array<PluginHookSummary>, apps: Array<AppSummary>, mcpServers: Array<string>, };
|
||||
export type PluginDetail = { marketplaceName: string, marketplacePath: AbsolutePathBuf | null, summary: PluginSummary, description: string | null, skills: Array<SkillSummary>, hooks: Array<PluginHookSummary>, apps: Array<AppSummary>, appTemplates: Array<AppTemplateSummary>, mcpServers: Array<string>, };
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
// 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 { JsonValue } from "../serde_json/JsonValue";
|
||||
|
||||
export type TurnModerationMetadataNotification = { threadId: string, turnId: string, metadata: JsonValue, };
|
||||
@@ -21,6 +21,8 @@ export type { AppMetadata } from "./AppMetadata";
|
||||
export type { AppReview } from "./AppReview";
|
||||
export type { AppScreenshot } from "./AppScreenshot";
|
||||
export type { AppSummary } from "./AppSummary";
|
||||
export type { AppTemplateSummary } from "./AppTemplateSummary";
|
||||
export type { AppTemplateUnavailableReason } from "./AppTemplateUnavailableReason";
|
||||
export type { AppToolApproval } from "./AppToolApproval";
|
||||
export type { AppToolsConfig } from "./AppToolsConfig";
|
||||
export type { ApprovalsReviewer } from "./ApprovalsReviewer";
|
||||
@@ -445,6 +447,7 @@ export type { TurnError } from "./TurnError";
|
||||
export type { TurnInterruptParams } from "./TurnInterruptParams";
|
||||
export type { TurnInterruptResponse } from "./TurnInterruptResponse";
|
||||
export type { TurnItemsView } from "./TurnItemsView";
|
||||
export type { TurnModerationMetadataNotification } from "./TurnModerationMetadataNotification";
|
||||
export type { TurnPlanStep } from "./TurnPlanStep";
|
||||
export type { TurnPlanStepStatus } from "./TurnPlanStepStatus";
|
||||
export type { TurnPlanUpdatedNotification } from "./TurnPlanUpdatedNotification";
|
||||
|
||||
@@ -1549,6 +1549,8 @@ server_notification_definitions! {
|
||||
ContextCompacted => "thread/compacted" (v2::ContextCompactedNotification),
|
||||
ModelRerouted => "model/rerouted" (v2::ModelReroutedNotification),
|
||||
ModelVerification => "model/verification" (v2::ModelVerificationNotification),
|
||||
#[experimental("turn/moderationMetadata")]
|
||||
TurnModerationMetadata => "turn/moderationMetadata" (v2::TurnModerationMetadataNotification),
|
||||
Warning => "warning" (v2::WarningNotification),
|
||||
GuardianWarning => "guardianWarning" (v2::GuardianWarningNotification),
|
||||
DeprecationNotice => "deprecationNotice" (v2::DeprecationNoticeNotification),
|
||||
@@ -3222,6 +3224,21 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_moderation_metadata_notification_is_marked_experimental() {
|
||||
let notification =
|
||||
ServerNotification::TurnModerationMetadata(v2::TurnModerationMetadataNotification {
|
||||
thread_id: "thr_123".to_string(),
|
||||
turn_id: "turn_123".to_string(),
|
||||
metadata: json!({"presentation": "inline"}),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
crate::experimental_api::ExperimentalApi::experimental_reason(¬ification),
|
||||
Some("turn/moderationMetadata")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_realtime_started_notification_is_marked_experimental() {
|
||||
let notification =
|
||||
|
||||
@@ -621,7 +621,7 @@ impl ThreadHistoryBuilder {
|
||||
receiver_thread_ids: Vec::new(),
|
||||
prompt: Some(payload.prompt.clone()),
|
||||
model: Some(payload.model.clone()),
|
||||
reasoning_effort: Some(payload.reasoning_effort),
|
||||
reasoning_effort: Some(payload.reasoning_effort.clone()),
|
||||
agents_states: HashMap::new(),
|
||||
};
|
||||
self.upsert_item_in_current_turn(item);
|
||||
@@ -656,7 +656,7 @@ impl ThreadHistoryBuilder {
|
||||
receiver_thread_ids,
|
||||
prompt: Some(payload.prompt.clone()),
|
||||
model: Some(payload.model.clone()),
|
||||
reasoning_effort: Some(payload.reasoning_effort),
|
||||
reasoning_effort: Some(payload.reasoning_effort.clone()),
|
||||
agents_states,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use codex_protocol::protocol::ModelVerification as CoreModelVerification;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value as JsonValue;
|
||||
use ts_rs::TS;
|
||||
|
||||
v2_enum_from_core!(
|
||||
@@ -152,3 +153,12 @@ pub struct ModelVerificationNotification {
|
||||
pub turn_id: String,
|
||||
pub verifications: Vec<ModelVerification>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct TurnModerationMetadataNotification {
|
||||
pub thread_id: String,
|
||||
pub turn_id: String,
|
||||
pub metadata: JsonValue,
|
||||
}
|
||||
|
||||
@@ -642,9 +642,32 @@ pub struct PluginDetail {
|
||||
pub skills: Vec<SkillSummary>,
|
||||
pub hooks: Vec<PluginHookSummary>,
|
||||
pub apps: Vec<AppSummary>,
|
||||
pub app_templates: Vec<AppTemplateSummary>,
|
||||
pub mcp_servers: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub enum AppTemplateUnavailableReason {
|
||||
NotConfiguredForWorkspace,
|
||||
NoActiveWorkspace,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct AppTemplateSummary {
|
||||
pub template_id: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub canonical_connector_id: Option<String>,
|
||||
pub logo_url: Option<String>,
|
||||
pub logo_url_dark: Option<String>,
|
||||
pub materialized_app_ids: Vec<String>,
|
||||
pub reason: Option<AppTemplateUnavailableReason>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
|
||||
@@ -9,7 +9,10 @@ mod websocket;
|
||||
use self::auth::load_remote_control_auth;
|
||||
use self::auth::recover_remote_control_auth;
|
||||
use self::enroll::RemoteControlEnrollment;
|
||||
use self::enroll::enroll_remote_control_server;
|
||||
use self::enroll::load_persisted_remote_control_enrollment;
|
||||
use self::enroll::refresh_remote_control_server;
|
||||
use self::enroll::update_persisted_remote_control_enrollment;
|
||||
use crate::transport::remote_control::websocket::RemoteControlChannels;
|
||||
use crate::transport::remote_control::websocket::RemoteControlStatusPublisher;
|
||||
use crate::transport::remote_control::websocket::RemoteControlWebsocket;
|
||||
@@ -36,9 +39,13 @@ use gethostname::gethostname;
|
||||
use std::error::Error;
|
||||
use std::fmt;
|
||||
use std::io;
|
||||
use std::ops::Deref;
|
||||
use std::ops::DerefMut;
|
||||
use std::panic::AssertUnwindSafe;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use tokio::sync::Semaphore;
|
||||
use tokio::sync::SemaphorePermit;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio::sync::watch;
|
||||
@@ -65,12 +72,81 @@ pub struct RemoteControlHandle {
|
||||
enabled_tx: Arc<watch::Sender<bool>>,
|
||||
status_tx: Arc<watch::Sender<RemoteControlStatusChangedNotification>>,
|
||||
state_db_available: bool,
|
||||
state_db: Option<Arc<StateRuntime>>,
|
||||
remote_control_url: String,
|
||||
current_enrollment: CurrentRemoteControlEnrollment,
|
||||
pairing_persistence_key: RemoteControlPairingPersistenceKey,
|
||||
pairing_persistence_key_required: bool,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
}
|
||||
|
||||
type CurrentRemoteControlEnrollment = Arc<StdMutex<Option<RemoteControlEnrollment>>>;
|
||||
// Pairing and websocket connect share one selected server so they cannot enroll or clear
|
||||
// different persisted rows while either path is awaiting backend I/O.
|
||||
type CurrentRemoteControlEnrollment = Arc<RemoteControlEnrollmentState>;
|
||||
type RemoteControlPairingPersistenceKey = watch::Sender<Option<String>>;
|
||||
|
||||
struct RemoteControlEnrollmentState {
|
||||
enrollment: StdMutex<Option<RemoteControlEnrollment>>,
|
||||
lock: Semaphore,
|
||||
}
|
||||
|
||||
impl RemoteControlEnrollmentState {
|
||||
fn new(enrollment: Option<RemoteControlEnrollment>) -> Self {
|
||||
Self {
|
||||
enrollment: StdMutex::new(enrollment),
|
||||
lock: Semaphore::new(1),
|
||||
}
|
||||
}
|
||||
|
||||
async fn lock(&self) -> RemoteControlEnrollmentLease<'_> {
|
||||
let permit = match self.lock.acquire().await {
|
||||
Ok(permit) => permit,
|
||||
Err(_) => unreachable!("remote control enrollment lock should stay open"),
|
||||
};
|
||||
RemoteControlEnrollmentLease {
|
||||
state: self,
|
||||
enrollment: self.snapshot(),
|
||||
_permit: permit,
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot(&self) -> Option<RemoteControlEnrollment> {
|
||||
self.enrollment
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
struct RemoteControlEnrollmentLease<'a> {
|
||||
state: &'a RemoteControlEnrollmentState,
|
||||
enrollment: Option<RemoteControlEnrollment>,
|
||||
_permit: SemaphorePermit<'a>,
|
||||
}
|
||||
|
||||
impl Deref for RemoteControlEnrollmentLease<'_> {
|
||||
type Target = Option<RemoteControlEnrollment>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.enrollment
|
||||
}
|
||||
}
|
||||
|
||||
impl DerefMut for RemoteControlEnrollmentLease<'_> {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.enrollment
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RemoteControlEnrollmentLease<'_> {
|
||||
fn drop(&mut self) {
|
||||
*self
|
||||
.state
|
||||
.enrollment
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = self.enrollment.take();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct RemoteControlUnavailable;
|
||||
@@ -126,8 +202,6 @@ impl RemoteControlHandle {
|
||||
*state = false;
|
||||
changed
|
||||
});
|
||||
clear_current_enrollment(&self.current_enrollment);
|
||||
|
||||
let status = self.status();
|
||||
info!(
|
||||
enabled_changed,
|
||||
@@ -151,28 +225,30 @@ impl RemoteControlHandle {
|
||||
pub async fn start_pairing(
|
||||
&self,
|
||||
params: RemoteControlPairingStartParams,
|
||||
app_server_client_name: Option<&str>,
|
||||
) -> io::Result<RemoteControlPairingStartResponse> {
|
||||
if !*self.enabled_tx.borrow() {
|
||||
return Err(Self::pairing_disabled_error());
|
||||
}
|
||||
let mut auth = load_remote_control_auth(&self.auth_manager)
|
||||
.await
|
||||
.map_err(|_| pairing_unavailable_error())?;
|
||||
let mut enrollment = {
|
||||
let current_enrollment = self
|
||||
.current_enrollment
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
current_enrollment
|
||||
.as_ref()
|
||||
.filter(|enrollment| enrollment.account_id == auth.account_id)
|
||||
.cloned()
|
||||
}
|
||||
.ok_or_else(pairing_unavailable_error)?;
|
||||
let installation_id = self.status().installation_id;
|
||||
let status = self.status();
|
||||
let installation_id = status.installation_id;
|
||||
let app_server_client_name = self.pairing_persistence_key(app_server_client_name)?;
|
||||
let app_server_client_name = app_server_client_name.as_deref();
|
||||
let mut current_enrollment = self.current_enrollment.lock().await;
|
||||
let mut enrollment = self
|
||||
.load_or_enroll_pairing_server(
|
||||
&mut current_enrollment,
|
||||
&mut auth,
|
||||
&installation_id,
|
||||
&status.server_name,
|
||||
app_server_client_name,
|
||||
)
|
||||
.await?;
|
||||
if enrollment.should_refresh_server_token() {
|
||||
refresh_pairing_enrollment(
|
||||
&self.current_enrollment,
|
||||
&mut current_enrollment,
|
||||
self.state_db.as_deref(),
|
||||
app_server_client_name,
|
||||
&self.auth_manager,
|
||||
&mut auth,
|
||||
&installation_id,
|
||||
@@ -185,9 +261,11 @@ impl RemoteControlHandle {
|
||||
};
|
||||
let pairing_response = match enrollment.start_pairing(pairing_request()).await {
|
||||
Err(err) if err.kind() == io::ErrorKind::PermissionDenied => {
|
||||
clear_pairing_server_token(&self.current_enrollment, &mut enrollment)?;
|
||||
clear_pairing_server_token(&mut current_enrollment, &mut enrollment)?;
|
||||
refresh_pairing_enrollment(
|
||||
&self.current_enrollment,
|
||||
&mut current_enrollment,
|
||||
self.state_db.as_deref(),
|
||||
app_server_client_name,
|
||||
&self.auth_manager,
|
||||
&mut auth,
|
||||
&installation_id,
|
||||
@@ -196,24 +274,46 @@ impl RemoteControlHandle {
|
||||
.await?;
|
||||
enrollment.start_pairing(pairing_request()).await
|
||||
}
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => {
|
||||
clear_pairing_enrollment(
|
||||
&mut current_enrollment,
|
||||
self.state_db.as_deref(),
|
||||
app_server_client_name,
|
||||
&enrollment,
|
||||
)
|
||||
.await;
|
||||
enrollment = self
|
||||
.load_or_enroll_pairing_server(
|
||||
&mut current_enrollment,
|
||||
&mut auth,
|
||||
&installation_id,
|
||||
&status.server_name,
|
||||
app_server_client_name,
|
||||
)
|
||||
.await?;
|
||||
enrollment.start_pairing(pairing_request()).await
|
||||
}
|
||||
pairing_response => pairing_response,
|
||||
};
|
||||
if let Err(err) = &pairing_response {
|
||||
match err.kind() {
|
||||
io::ErrorKind::NotFound => {
|
||||
clear_current_enrollment_if_matches(&self.current_enrollment, &enrollment);
|
||||
clear_pairing_enrollment(
|
||||
&mut current_enrollment,
|
||||
self.state_db.as_deref(),
|
||||
app_server_client_name,
|
||||
&enrollment,
|
||||
)
|
||||
.await;
|
||||
return Err(pairing_unavailable_error());
|
||||
}
|
||||
io::ErrorKind::PermissionDenied => {
|
||||
clear_pairing_server_token(&self.current_enrollment, &mut enrollment)?;
|
||||
clear_pairing_server_token(&mut current_enrollment, &mut enrollment)?;
|
||||
return Err(pairing_unavailable_error());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !*self.enabled_tx.borrow() {
|
||||
return Err(Self::pairing_disabled_error());
|
||||
}
|
||||
let current_auth = load_remote_control_auth(&self.auth_manager)
|
||||
.await
|
||||
.map_err(|_| pairing_unavailable_error())?;
|
||||
@@ -223,6 +323,74 @@ impl RemoteControlHandle {
|
||||
pairing_response
|
||||
}
|
||||
|
||||
async fn load_or_enroll_pairing_server(
|
||||
&self,
|
||||
current_enrollment: &mut Option<RemoteControlEnrollment>,
|
||||
auth: &mut auth::RemoteControlConnectionAuth,
|
||||
installation_id: &str,
|
||||
server_name: &str,
|
||||
app_server_client_name: Option<&str>,
|
||||
) -> io::Result<RemoteControlEnrollment> {
|
||||
if let Some(enrollment) = current_enrollment
|
||||
.as_ref()
|
||||
.filter(|enrollment| enrollment.account_id == auth.account_id)
|
||||
.cloned()
|
||||
{
|
||||
return Ok(enrollment);
|
||||
}
|
||||
|
||||
let remote_control_target = normalize_remote_control_url(&self.remote_control_url)?;
|
||||
let state_db = self
|
||||
.state_db
|
||||
.as_deref()
|
||||
.ok_or_else(pairing_unavailable_error)?;
|
||||
if let Some(mut enrollment) = load_persisted_remote_control_enrollment(
|
||||
Some(state_db),
|
||||
&remote_control_target,
|
||||
&auth.account_id,
|
||||
app_server_client_name,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
enrollment.server_name = server_name.to_string();
|
||||
publish_current_enrollment(current_enrollment, &enrollment);
|
||||
return Ok(enrollment);
|
||||
}
|
||||
|
||||
let enrollment = enroll_pairing_server(
|
||||
&self.auth_manager,
|
||||
auth,
|
||||
&remote_control_target,
|
||||
installation_id,
|
||||
server_name,
|
||||
)
|
||||
.await?;
|
||||
update_persisted_remote_control_enrollment(
|
||||
Some(state_db),
|
||||
&remote_control_target,
|
||||
&auth.account_id,
|
||||
app_server_client_name,
|
||||
Some(&enrollment),
|
||||
)
|
||||
.await?;
|
||||
publish_current_enrollment(current_enrollment, &enrollment);
|
||||
Ok(enrollment)
|
||||
}
|
||||
|
||||
fn pairing_persistence_key(
|
||||
&self,
|
||||
app_server_client_name: Option<&str>,
|
||||
) -> io::Result<Option<String>> {
|
||||
if self.pairing_persistence_key_required && self.pairing_persistence_key.borrow().is_none()
|
||||
{
|
||||
let app_server_client_name =
|
||||
app_server_client_name.ok_or_else(pairing_unavailable_error)?;
|
||||
self.pairing_persistence_key
|
||||
.send_replace(Some(app_server_client_name.to_string()));
|
||||
}
|
||||
Ok(self.pairing_persistence_key.borrow().clone())
|
||||
}
|
||||
|
||||
pub async fn list_clients(
|
||||
&self,
|
||||
params: RemoteControlClientsListParams,
|
||||
@@ -239,13 +407,6 @@ impl RemoteControlHandle {
|
||||
.await
|
||||
}
|
||||
|
||||
fn pairing_disabled_error() -> io::Error {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"remote control pairing requires remote control to be enabled",
|
||||
)
|
||||
}
|
||||
|
||||
fn publish_status(
|
||||
&self,
|
||||
connection_status: RemoteControlConnectionStatus,
|
||||
@@ -277,8 +438,36 @@ impl RemoteControlHandle {
|
||||
}
|
||||
}
|
||||
|
||||
async fn enroll_pairing_server(
|
||||
auth_manager: &Arc<AuthManager>,
|
||||
auth: &mut auth::RemoteControlConnectionAuth,
|
||||
remote_control_target: &protocol::RemoteControlTarget,
|
||||
installation_id: &str,
|
||||
server_name: &str,
|
||||
) -> io::Result<RemoteControlEnrollment> {
|
||||
match enroll_remote_control_server(remote_control_target, auth, installation_id, server_name)
|
||||
.await
|
||||
{
|
||||
Ok(enrollment) => return Ok(enrollment),
|
||||
Err(err) if err.kind() == io::ErrorKind::PermissionDenied => {
|
||||
let mut auth_recovery = auth_manager.unauthorized_recovery();
|
||||
let mut auth_change_rx = auth_manager.auth_change_receiver();
|
||||
if !recover_remote_control_auth(&mut auth_recovery, &mut auth_change_rx).await {
|
||||
return Err(err);
|
||||
}
|
||||
*auth = load_remote_control_auth(auth_manager)
|
||||
.await
|
||||
.map_err(|_| pairing_unavailable_error())?;
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
enroll_remote_control_server(remote_control_target, auth, installation_id, server_name).await
|
||||
}
|
||||
|
||||
async fn refresh_pairing_enrollment(
|
||||
current_enrollment: &CurrentRemoteControlEnrollment,
|
||||
current_enrollment: &mut Option<RemoteControlEnrollment>,
|
||||
state_db: Option<&StateRuntime>,
|
||||
app_server_client_name: Option<&str>,
|
||||
auth_manager: &Arc<AuthManager>,
|
||||
auth: &mut auth::RemoteControlConnectionAuth,
|
||||
installation_id: &str,
|
||||
@@ -286,7 +475,14 @@ async fn refresh_pairing_enrollment(
|
||||
) -> io::Result<()> {
|
||||
if let Err(err) = refresh_remote_control_server(auth, installation_id, enrollment).await {
|
||||
if err.kind() != io::ErrorKind::PermissionDenied {
|
||||
return handle_pairing_refresh_error(current_enrollment, enrollment, err);
|
||||
return handle_pairing_refresh_error(
|
||||
current_enrollment,
|
||||
state_db,
|
||||
app_server_client_name,
|
||||
enrollment,
|
||||
err,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let mut auth_recovery = auth_manager.unauthorized_recovery();
|
||||
let mut auth_change_rx = auth_manager.auth_change_receiver();
|
||||
@@ -300,7 +496,14 @@ async fn refresh_pairing_enrollment(
|
||||
return Err(pairing_unavailable_error());
|
||||
}
|
||||
if let Err(err) = refresh_remote_control_server(auth, installation_id, enrollment).await {
|
||||
return handle_pairing_refresh_error(current_enrollment, enrollment, err);
|
||||
return handle_pairing_refresh_error(
|
||||
current_enrollment,
|
||||
state_db,
|
||||
app_server_client_name,
|
||||
enrollment,
|
||||
err,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
if replace_current_enrollment(current_enrollment, enrollment) {
|
||||
@@ -310,21 +513,54 @@ async fn refresh_pairing_enrollment(
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_pairing_refresh_error(
|
||||
current_enrollment: &CurrentRemoteControlEnrollment,
|
||||
async fn handle_pairing_refresh_error(
|
||||
current_enrollment: &mut Option<RemoteControlEnrollment>,
|
||||
state_db: Option<&StateRuntime>,
|
||||
app_server_client_name: Option<&str>,
|
||||
enrollment: &RemoteControlEnrollment,
|
||||
err: io::Error,
|
||||
) -> io::Result<()> {
|
||||
if err.kind() == io::ErrorKind::NotFound {
|
||||
clear_current_enrollment_if_matches(current_enrollment, enrollment);
|
||||
clear_pairing_enrollment(
|
||||
current_enrollment,
|
||||
state_db,
|
||||
app_server_client_name,
|
||||
enrollment,
|
||||
)
|
||||
.await;
|
||||
Err(pairing_unavailable_error())
|
||||
} else {
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
|
||||
async fn clear_pairing_enrollment(
|
||||
current_enrollment: &mut Option<RemoteControlEnrollment>,
|
||||
state_db: Option<&StateRuntime>,
|
||||
app_server_client_name: Option<&str>,
|
||||
enrollment: &RemoteControlEnrollment,
|
||||
) {
|
||||
if !clear_current_enrollment_if_matches(current_enrollment, enrollment) {
|
||||
return;
|
||||
}
|
||||
let Some(state_db) = state_db else {
|
||||
return;
|
||||
};
|
||||
if let Err(err) = update_persisted_remote_control_enrollment(
|
||||
Some(state_db),
|
||||
&enrollment.remote_control_target,
|
||||
&enrollment.account_id,
|
||||
app_server_client_name,
|
||||
/*enrollment*/ None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("failed to clear stale pairing enrollment: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_pairing_server_token(
|
||||
current_enrollment: &CurrentRemoteControlEnrollment,
|
||||
current_enrollment: &mut Option<RemoteControlEnrollment>,
|
||||
enrollment: &mut RemoteControlEnrollment,
|
||||
) -> io::Result<()> {
|
||||
enrollment.clear_server_token();
|
||||
@@ -359,27 +595,16 @@ fn remote_control_status_with_connection_status(
|
||||
}
|
||||
|
||||
fn publish_current_enrollment(
|
||||
current_enrollment: &CurrentRemoteControlEnrollment,
|
||||
current_enrollment: &mut Option<RemoteControlEnrollment>,
|
||||
enrollment: &RemoteControlEnrollment,
|
||||
) {
|
||||
*current_enrollment
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(enrollment.clone());
|
||||
}
|
||||
|
||||
fn clear_current_enrollment(current_enrollment: &CurrentRemoteControlEnrollment) {
|
||||
*current_enrollment
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
|
||||
*current_enrollment = Some(enrollment.clone());
|
||||
}
|
||||
|
||||
fn replace_current_enrollment(
|
||||
current_enrollment: &CurrentRemoteControlEnrollment,
|
||||
current_enrollment: &mut Option<RemoteControlEnrollment>,
|
||||
enrollment: &RemoteControlEnrollment,
|
||||
) -> bool {
|
||||
let mut current_enrollment = current_enrollment
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if !current_enrollment
|
||||
.as_ref()
|
||||
.is_some_and(|current| same_remote_control_enrollment(current, enrollment))
|
||||
@@ -391,17 +616,17 @@ fn replace_current_enrollment(
|
||||
}
|
||||
|
||||
fn clear_current_enrollment_if_matches(
|
||||
current_enrollment: &CurrentRemoteControlEnrollment,
|
||||
current_enrollment: &mut Option<RemoteControlEnrollment>,
|
||||
enrollment: &RemoteControlEnrollment,
|
||||
) {
|
||||
let mut current_enrollment = current_enrollment
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
) -> bool {
|
||||
if current_enrollment
|
||||
.as_ref()
|
||||
.is_some_and(|current| same_remote_control_enrollment(current, enrollment))
|
||||
{
|
||||
*current_enrollment = None;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,9 +663,13 @@ pub async fn start_remote_control(
|
||||
};
|
||||
|
||||
let (enabled_tx, enabled_rx) = watch::channel(initial_enabled);
|
||||
let current_enrollment = Arc::new(StdMutex::new(None));
|
||||
let current_enrollment = Arc::new(RemoteControlEnrollmentState::new(/*enrollment*/ None));
|
||||
let websocket_current_enrollment = current_enrollment.clone();
|
||||
let pairing_persistence_key_required = app_server_client_name_rx.is_some();
|
||||
let (pairing_persistence_key, _pairing_persistence_key_rx) = watch::channel(None);
|
||||
let websocket_pairing_persistence_key = pairing_persistence_key.clone();
|
||||
let handle_auth_manager = auth_manager.clone();
|
||||
let handle_state_db = state_db.clone();
|
||||
let server_name = gethostname().to_string_lossy().trim().to_string();
|
||||
let remote_control_url = config.remote_control_url;
|
||||
let installation_id = config.installation_id;
|
||||
@@ -490,6 +719,7 @@ pub async fn start_remote_control(
|
||||
transport_event_tx,
|
||||
status_publisher,
|
||||
current_enrollment: websocket_current_enrollment,
|
||||
pairing_persistence_key: websocket_pairing_persistence_key,
|
||||
},
|
||||
shutdown_token,
|
||||
enabled_rx,
|
||||
@@ -534,8 +764,11 @@ pub async fn start_remote_control(
|
||||
enabled_tx: Arc::new(enabled_tx),
|
||||
status_tx: Arc::new(status_tx),
|
||||
state_db_available,
|
||||
state_db: handle_state_db,
|
||||
remote_control_url: handle_remote_control_url,
|
||||
current_enrollment,
|
||||
pairing_persistence_key,
|
||||
pairing_persistence_key_required,
|
||||
auth_manager: handle_auth_manager,
|
||||
},
|
||||
))
|
||||
|
||||
@@ -40,7 +40,6 @@ use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use tempfile::TempDir;
|
||||
use time::OffsetDateTime;
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
@@ -148,24 +147,29 @@ fn remote_control_handle_with_current_enrollment(
|
||||
});
|
||||
let remote_control_target = normalize_remote_control_url(remote_control_url)
|
||||
.expect("remote control target should normalize");
|
||||
let current_enrollment = Arc::new(StdMutex::new(Some(RemoteControlEnrollment {
|
||||
remote_control_target,
|
||||
account_id: "account_id".to_string(),
|
||||
environment_id: "env_test".to_string(),
|
||||
server_id: "srv_e_test".to_string(),
|
||||
server_name: test_server_name(),
|
||||
remote_control_token: Some(TEST_REMOTE_CONTROL_SERVER_TOKEN.to_string()),
|
||||
expires_at: Some(
|
||||
OffsetDateTime::from_unix_timestamp(33_336_362_096)
|
||||
.expect("future timestamp should parse"),
|
||||
),
|
||||
})));
|
||||
let current_enrollment = Arc::new(RemoteControlEnrollmentState::new(Some(
|
||||
RemoteControlEnrollment {
|
||||
remote_control_target,
|
||||
account_id: "account_id".to_string(),
|
||||
environment_id: "env_test".to_string(),
|
||||
server_id: "srv_e_test".to_string(),
|
||||
server_name: test_server_name(),
|
||||
remote_control_token: Some(TEST_REMOTE_CONTROL_SERVER_TOKEN.to_string()),
|
||||
expires_at: Some(
|
||||
OffsetDateTime::from_unix_timestamp(33_336_362_096)
|
||||
.expect("future timestamp should parse"),
|
||||
),
|
||||
},
|
||||
)));
|
||||
RemoteControlHandle {
|
||||
enabled_tx: Arc::new(enabled_tx),
|
||||
status_tx: Arc::new(status_tx),
|
||||
state_db_available: true,
|
||||
state_db: None,
|
||||
remote_control_url: remote_control_url.to_string(),
|
||||
current_enrollment,
|
||||
pairing_persistence_key: watch::channel(None).0,
|
||||
pairing_persistence_key_required: false,
|
||||
auth_manager,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,8 +24,11 @@ fn client_management_handle(
|
||||
enabled_tx: Arc::new(enabled_tx),
|
||||
status_tx: Arc::new(status_tx),
|
||||
state_db_available: false,
|
||||
state_db: None,
|
||||
remote_control_url,
|
||||
current_enrollment: Arc::new(StdMutex::new(None)),
|
||||
current_enrollment: Arc::new(RemoteControlEnrollmentState::new(/*enrollment*/ None)),
|
||||
pairing_persistence_key: watch::channel(None).0,
|
||||
pairing_persistence_key_required: false,
|
||||
auth_manager,
|
||||
}
|
||||
}
|
||||
|
||||
+142
-14
@@ -131,13 +131,16 @@ async fn remote_control_handle_starts_pairing_before_websocket_connects() {
|
||||
remote_handle
|
||||
.current_enrollment
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.await
|
||||
.as_mut()
|
||||
.expect("current enrollment should exist")
|
||||
.expires_at = Some(OffsetDateTime::now_utc() + time::Duration::seconds(29));
|
||||
|
||||
let response = remote_handle
|
||||
.start_pairing(RemoteControlPairingStartParams { manual_code: true })
|
||||
.start_pairing(
|
||||
RemoteControlPairingStartParams { manual_code: true },
|
||||
/*app_server_client_name*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("pairing should use the current server before websocket connect");
|
||||
server_task.await.expect("server task should finish");
|
||||
@@ -219,7 +222,10 @@ async fn remote_control_handle_refreshes_after_pairing_auth_failure() {
|
||||
);
|
||||
|
||||
let response = remote_handle
|
||||
.start_pairing(RemoteControlPairingStartParams::default())
|
||||
.start_pairing(
|
||||
RemoteControlPairingStartParams::default(),
|
||||
/*app_server_client_name*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("pairing should refresh after server token auth failure");
|
||||
server_task.await.expect("server task should finish");
|
||||
@@ -332,13 +338,16 @@ async fn remote_control_handle_recovers_auth_before_refreshing_pairing() {
|
||||
remote_handle
|
||||
.current_enrollment
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.await
|
||||
.as_mut()
|
||||
.expect("current enrollment should exist")
|
||||
.expires_at = Some(OffsetDateTime::now_utc() + time::Duration::seconds(29));
|
||||
|
||||
let response = remote_handle
|
||||
.start_pairing(RemoteControlPairingStartParams::default())
|
||||
.start_pairing(
|
||||
RemoteControlPairingStartParams::default(),
|
||||
/*app_server_client_name*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("pairing should refresh after auth recovery");
|
||||
server_task.await.expect("server task should finish");
|
||||
@@ -414,21 +423,137 @@ async fn start_remote_control_pairing_preserves_expiry_parse_error_context() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_control_handle_disable_clears_current_enrollment() {
|
||||
async fn remote_control_handle_disable_keeps_current_enrollment() {
|
||||
let remote_handle = remote_control_handle_with_current_enrollment(
|
||||
TEST_REMOTE_CONTROL_URL,
|
||||
remote_control_auth_manager(),
|
||||
);
|
||||
|
||||
remote_handle.disable();
|
||||
remote_handle.enable().expect("enable should succeed");
|
||||
assert!(
|
||||
remote_handle.current_enrollment.lock().await.is_some(),
|
||||
"disabled remote control should keep the selected pairing server"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_control_handle_reenrolls_after_stale_pairing_enrollment() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("listener should bind");
|
||||
let remote_control_url = remote_control_url_for_listener(&listener);
|
||||
let codex_home = TempDir::new().expect("temp dir should create");
|
||||
let state_db = remote_control_state_runtime(&codex_home).await;
|
||||
let mut remote_handle = remote_control_handle_with_current_enrollment(
|
||||
&remote_control_url,
|
||||
remote_control_auth_manager_with_home(&codex_home),
|
||||
);
|
||||
remote_handle.state_db = Some(state_db.clone());
|
||||
remote_handle.disable();
|
||||
let stale_enrollment = remote_handle
|
||||
.current_enrollment
|
||||
.lock()
|
||||
.await
|
||||
.clone()
|
||||
.expect("current enrollment should exist");
|
||||
let remote_control_target = stale_enrollment.remote_control_target.clone();
|
||||
let refreshed_enrollment = RemoteControlEnrollment {
|
||||
remote_control_target: remote_control_target.clone(),
|
||||
account_id: "account_id".to_string(),
|
||||
environment_id: "env_refreshed".to_string(),
|
||||
server_id: "srv_e_refreshed".to_string(),
|
||||
server_name: test_server_name(),
|
||||
remote_control_token: None,
|
||||
expires_at: None,
|
||||
};
|
||||
update_persisted_remote_control_enrollment(
|
||||
Some(state_db.as_ref()),
|
||||
&remote_control_target,
|
||||
"account_id",
|
||||
/*app_server_client_name*/ None,
|
||||
Some(&stale_enrollment),
|
||||
)
|
||||
.await
|
||||
.expect("stale enrollment should save");
|
||||
let server_refreshed_enrollment = refreshed_enrollment.clone();
|
||||
let server_task = tokio::spawn(async move {
|
||||
let stale_pairing_request = accept_http_request(&listener).await;
|
||||
assert_eq!(
|
||||
stale_pairing_request.request_line,
|
||||
"POST /backend-api/wham/remote/control/server/pair HTTP/1.1"
|
||||
);
|
||||
assert_eq!(
|
||||
stale_pairing_request.headers.get("authorization"),
|
||||
Some(&format!("Bearer {TEST_REMOTE_CONTROL_SERVER_TOKEN}"))
|
||||
);
|
||||
respond_with_status(stale_pairing_request.stream, "404 Not Found", "").await;
|
||||
|
||||
let enroll_request = accept_http_request(&listener).await;
|
||||
assert_eq!(
|
||||
enroll_request.request_line,
|
||||
"POST /backend-api/wham/remote/control/server/enroll HTTP/1.1"
|
||||
);
|
||||
respond_with_json(
|
||||
enroll_request.stream,
|
||||
remote_control_server_token_response(
|
||||
&server_refreshed_enrollment.server_id,
|
||||
&server_refreshed_enrollment.environment_id,
|
||||
TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
||||
let refreshed_pairing_request = accept_http_request(&listener).await;
|
||||
assert_eq!(
|
||||
refreshed_pairing_request.request_line,
|
||||
"POST /backend-api/wham/remote/control/server/pair HTTP/1.1"
|
||||
);
|
||||
assert_eq!(
|
||||
refreshed_pairing_request.headers.get("authorization"),
|
||||
Some(&format!(
|
||||
"Bearer {TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN}"
|
||||
))
|
||||
);
|
||||
respond_with_json(
|
||||
refreshed_pairing_request.stream,
|
||||
json!({
|
||||
"pairing_code": "pairing-code",
|
||||
"manual_pairing_code": "ABCD-EFGH",
|
||||
"server_id": server_refreshed_enrollment.server_id,
|
||||
"environment_id": server_refreshed_enrollment.environment_id,
|
||||
"expires_at": "3026-05-22T12:34:56Z",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
let response = remote_handle
|
||||
.start_pairing(
|
||||
RemoteControlPairingStartParams::default(),
|
||||
/*app_server_client_name*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("pairing should re-enroll after stale enrollment");
|
||||
server_task.await.expect("server task should finish");
|
||||
|
||||
assert_eq!(
|
||||
remote_handle
|
||||
.start_pairing(RemoteControlPairingStartParams::default())
|
||||
.await
|
||||
.expect_err("re-enabled remote control should wait for a current server")
|
||||
.to_string(),
|
||||
"remote control pairing is unavailable until enrollment completes"
|
||||
response,
|
||||
RemoteControlPairingStartResponse {
|
||||
pairing_code: "pairing-code".to_string(),
|
||||
manual_pairing_code: Some("ABCD-EFGH".to_string()),
|
||||
environment_id: "env_refreshed".to_string(),
|
||||
expires_at: 33_336_362_096,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
load_persisted_remote_control_enrollment(
|
||||
Some(state_db.as_ref()),
|
||||
&remote_control_target,
|
||||
"account_id",
|
||||
/*app_server_client_name*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("refreshed enrollment should load"),
|
||||
Some(refreshed_enrollment)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -458,7 +583,10 @@ async fn remote_control_handle_discards_pairing_response_after_auth_change() {
|
||||
let remote_handle = remote_handle.clone();
|
||||
async move {
|
||||
remote_handle
|
||||
.start_pairing(RemoteControlPairingStartParams::default())
|
||||
.start_pairing(
|
||||
RemoteControlPairingStartParams::default(),
|
||||
/*app_server_client_name*/ None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
use super::CurrentRemoteControlEnrollment;
|
||||
use super::clear_current_enrollment;
|
||||
use super::RemoteControlPairingPersistenceKey;
|
||||
use super::protocol::ClientEnvelope;
|
||||
use super::protocol::ClientEvent;
|
||||
use super::protocol::ClientId;
|
||||
use super::protocol::RemoteControlTarget;
|
||||
use super::protocol::ServerEnvelope;
|
||||
use super::protocol::StreamId;
|
||||
use super::publish_current_enrollment;
|
||||
use super::remote_control_status_with_connection_status;
|
||||
use super::same_remote_control_enrollment;
|
||||
use super::segment::ClientSegmentObservation;
|
||||
use super::segment::ClientSegmentReassembler;
|
||||
use super::segment::REMOTE_CONTROL_SEGMENT_MAX_BYTES;
|
||||
@@ -55,6 +55,9 @@ use tokio_tungstenite::connect_async;
|
||||
use tokio_tungstenite::tungstenite;
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
#[cfg(test)]
|
||||
use super::RemoteControlEnrollmentState;
|
||||
use tracing::error;
|
||||
use tracing::info;
|
||||
use tracing::warn;
|
||||
@@ -252,10 +255,10 @@ pub(crate) struct RemoteControlWebsocket {
|
||||
status_publisher: RemoteControlStatusPublisher,
|
||||
shutdown_token: CancellationToken,
|
||||
reconnect_attempt: u64,
|
||||
enrollment: Option<RemoteControlEnrollment>,
|
||||
auth_recovery: UnauthorizedRecovery,
|
||||
auth_change_rx: watch::Receiver<u64>,
|
||||
current_enrollment: CurrentRemoteControlEnrollment,
|
||||
pairing_persistence_key: RemoteControlPairingPersistenceKey,
|
||||
client_tracker: Arc<Mutex<ClientTracker>>,
|
||||
state: Arc<Mutex<WebsocketState>>,
|
||||
server_event_rx: Arc<Mutex<mpsc::Receiver<super::QueuedServerEnvelope>>>,
|
||||
@@ -294,6 +297,7 @@ pub(super) struct RemoteControlChannels {
|
||||
pub(super) transport_event_tx: mpsc::Sender<TransportEvent>,
|
||||
pub(super) status_publisher: RemoteControlStatusPublisher,
|
||||
pub(super) current_enrollment: CurrentRemoteControlEnrollment,
|
||||
pub(super) pairing_persistence_key: RemoteControlPairingPersistenceKey,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -407,10 +411,10 @@ impl RemoteControlWebsocket {
|
||||
status_publisher: channels.status_publisher,
|
||||
shutdown_token,
|
||||
reconnect_attempt: 0,
|
||||
enrollment: None,
|
||||
auth_recovery,
|
||||
auth_change_rx,
|
||||
current_enrollment: channels.current_enrollment,
|
||||
pairing_persistence_key: channels.pairing_persistence_key,
|
||||
client_tracker: Arc::new(Mutex::new(client_tracker)),
|
||||
state: Arc::new(Mutex::new(WebsocketState {
|
||||
outbound_buffer,
|
||||
@@ -457,6 +461,8 @@ impl RemoteControlWebsocket {
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.pairing_persistence_key
|
||||
.send_replace(app_server_client_name.clone());
|
||||
|
||||
loop {
|
||||
if !self.wait_until_enabled().await {
|
||||
@@ -579,15 +585,15 @@ impl RemoteControlWebsocket {
|
||||
|
||||
loop {
|
||||
let subscribe_cursor = self.state.lock().await.subscribe_cursor.clone();
|
||||
let enrollment = self.enrollment.as_ref();
|
||||
let enrollment = self.current_enrollment.snapshot();
|
||||
info!(
|
||||
websocket_url = %remote_control_target.websocket_url,
|
||||
installation_id = %self.installation_id,
|
||||
server_name = %self.server_name,
|
||||
reconnect_attempt = self.reconnect_attempt.saturating_add(1),
|
||||
has_enrollment = enrollment.is_some(),
|
||||
server_id = ?enrollment.map(|enrollment| enrollment.server_id.as_str()),
|
||||
environment_id = ?enrollment.map(|enrollment| enrollment.environment_id.as_str()),
|
||||
server_id = ?enrollment.as_ref().map(|enrollment| enrollment.server_id.as_str()),
|
||||
environment_id = ?enrollment.as_ref().map(|enrollment| enrollment.environment_id.as_str()),
|
||||
subscribe_cursor_present = subscribe_cursor.is_some(),
|
||||
app_server_client_name = ?app_server_client_name,
|
||||
"connecting to app-server remote control websocket"
|
||||
@@ -611,15 +617,17 @@ impl RemoteControlWebsocket {
|
||||
}
|
||||
return ConnectOutcome::Disabled;
|
||||
}
|
||||
connect_result = connect_remote_control_websocket(
|
||||
&remote_control_target,
|
||||
self.state_db.as_deref(),
|
||||
auth_context,
|
||||
&mut self.enrollment,
|
||||
connect_options,
|
||||
&self.status_publisher,
|
||||
&self.current_enrollment,
|
||||
) => connect_result,
|
||||
connect_result = async {
|
||||
connect_remote_control_websocket(
|
||||
&remote_control_target,
|
||||
self.state_db.as_deref(),
|
||||
auth_context,
|
||||
&self.current_enrollment,
|
||||
connect_options,
|
||||
&self.status_publisher,
|
||||
)
|
||||
.await
|
||||
} => connect_result,
|
||||
};
|
||||
|
||||
match connect_result {
|
||||
@@ -631,13 +639,13 @@ impl RemoteControlWebsocket {
|
||||
self.auth_recovery = self.auth_manager.unauthorized_recovery();
|
||||
self.status_publisher
|
||||
.publish_status(RemoteControlConnectionStatus::Connected);
|
||||
let enrollment = self.enrollment.as_ref();
|
||||
let enrollment = self.current_enrollment.snapshot();
|
||||
info!(
|
||||
websocket_url = %remote_control_target.websocket_url,
|
||||
installation_id = %self.installation_id,
|
||||
server_name = %self.server_name,
|
||||
server_id = ?enrollment.map(|enrollment| enrollment.server_id.as_str()),
|
||||
environment_id = ?enrollment.map(|enrollment| enrollment.environment_id.as_str()),
|
||||
server_id = ?enrollment.as_ref().map(|enrollment| enrollment.server_id.as_str()),
|
||||
environment_id = ?enrollment.as_ref().map(|enrollment| enrollment.environment_id.as_str()),
|
||||
subscribe_cursor_present = subscribe_cursor.is_some(),
|
||||
response_headers = %format_headers(response.headers()),
|
||||
"connected to app-server remote control websocket"
|
||||
@@ -656,7 +664,7 @@ impl RemoteControlWebsocket {
|
||||
let reconnect_attempt = self.reconnect_attempt.saturating_add(1);
|
||||
let (reconnect_delay, reconnect_backoff_reset) =
|
||||
next_reconnect_delay(&mut self.reconnect_attempt);
|
||||
let enrollment = self.enrollment.as_ref();
|
||||
let enrollment = self.current_enrollment.snapshot();
|
||||
warn!(
|
||||
websocket_url = %remote_control_target.websocket_url,
|
||||
installation_id = %self.installation_id,
|
||||
@@ -667,8 +675,8 @@ impl RemoteControlWebsocket {
|
||||
reconnect_delay = ?reconnect_delay,
|
||||
reconnect_backoff_reset,
|
||||
has_enrollment = enrollment.is_some(),
|
||||
server_id = ?enrollment.map(|enrollment| enrollment.server_id.as_str()),
|
||||
environment_id = ?enrollment.map(|enrollment| enrollment.environment_id.as_str()),
|
||||
server_id = ?enrollment.as_ref().map(|enrollment| enrollment.server_id.as_str()),
|
||||
environment_id = ?enrollment.as_ref().map(|enrollment| enrollment.environment_id.as_str()),
|
||||
subscribe_cursor_present = subscribe_cursor.is_some(),
|
||||
"failed to connect to app-server remote control websocket"
|
||||
);
|
||||
@@ -1189,19 +1197,108 @@ pub(super) async fn connect_remote_control_websocket(
|
||||
remote_control_target: &RemoteControlTarget,
|
||||
state_db: Option<&StateRuntime>,
|
||||
mut auth_context: RemoteControlAuthContext<'_>,
|
||||
enrollment: &mut Option<RemoteControlEnrollment>,
|
||||
current_enrollment: &CurrentRemoteControlEnrollment,
|
||||
connect_options: RemoteControlConnectOptions<'_>,
|
||||
status_publisher: &RemoteControlStatusPublisher,
|
||||
current_enrollment: &CurrentRemoteControlEnrollment,
|
||||
) -> io::Result<(
|
||||
WebSocketStream<MaybeTlsStream<TcpStream>>,
|
||||
tungstenite::http::Response<()>,
|
||||
)> {
|
||||
ensure_rustls_crypto_provider();
|
||||
|
||||
let (auth, enrollment) = {
|
||||
let mut current_enrollment = current_enrollment.lock().await;
|
||||
let auth = prepare_remote_control_enrollment(
|
||||
remote_control_target,
|
||||
state_db,
|
||||
&mut auth_context,
|
||||
&mut current_enrollment,
|
||||
connect_options,
|
||||
status_publisher,
|
||||
)
|
||||
.await?;
|
||||
let enrollment = current_enrollment.as_ref().cloned().ok_or_else(|| {
|
||||
io::Error::other("missing remote control enrollment after enrollment step")
|
||||
})?;
|
||||
(auth, enrollment)
|
||||
};
|
||||
let request = build_remote_control_websocket_request(
|
||||
&remote_control_target.websocket_url,
|
||||
&enrollment,
|
||||
connect_options.installation_id,
|
||||
connect_options.subscribe_cursor,
|
||||
)?;
|
||||
|
||||
let websocket_connect_result = tokio::time::timeout(
|
||||
REMOTE_CONTROL_WEBSOCKET_CONNECT_TIMEOUT,
|
||||
connect_async(request),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
io::Error::new(
|
||||
ErrorKind::TimedOut,
|
||||
format!(
|
||||
"timed out connecting to remote control websocket at `{}` after {:?}",
|
||||
remote_control_target.websocket_url, REMOTE_CONTROL_WEBSOCKET_CONNECT_TIMEOUT
|
||||
),
|
||||
)
|
||||
})?;
|
||||
|
||||
match websocket_connect_result {
|
||||
Ok((websocket_stream, response)) => Ok((websocket_stream, response.map(|_| ()))),
|
||||
Err(err) => {
|
||||
match &err {
|
||||
tungstenite::Error::Http(response) if response.status().as_u16() == 404 => {
|
||||
info!(
|
||||
"remote control websocket returned HTTP 404; clearing stale enrollment before re-enrolling: websocket_url={}, account_id={}, server_id={}, environment_id={}",
|
||||
remote_control_target.websocket_url,
|
||||
auth.account_id,
|
||||
enrollment.server_id,
|
||||
enrollment.environment_id
|
||||
);
|
||||
clear_remote_control_enrollment_if_matches(
|
||||
state_db,
|
||||
remote_control_target,
|
||||
&auth.account_id,
|
||||
connect_options.app_server_client_name,
|
||||
current_enrollment,
|
||||
&enrollment,
|
||||
status_publisher,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
tungstenite::Error::Http(response)
|
||||
if matches!(response.status().as_u16(), 401 | 403) =>
|
||||
{
|
||||
clear_remote_control_server_token_if_matches(current_enrollment, &enrollment)
|
||||
.await?;
|
||||
return Err(io::Error::other(format!(
|
||||
"remote control websocket auth failed with HTTP {}; refreshing server token before reconnect",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Err(io::Error::other(
|
||||
format_remote_control_websocket_connect_error(
|
||||
&remote_control_target.websocket_url,
|
||||
&err,
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn prepare_remote_control_enrollment(
|
||||
remote_control_target: &RemoteControlTarget,
|
||||
state_db: Option<&StateRuntime>,
|
||||
auth_context: &mut RemoteControlAuthContext<'_>,
|
||||
enrollment: &mut Option<RemoteControlEnrollment>,
|
||||
connect_options: RemoteControlConnectOptions<'_>,
|
||||
status_publisher: &RemoteControlStatusPublisher,
|
||||
) -> io::Result<RemoteControlConnectionAuth> {
|
||||
let Some(state_db) = state_db else {
|
||||
*enrollment = None;
|
||||
clear_current_enrollment(current_enrollment);
|
||||
return Err(io::Error::new(
|
||||
ErrorKind::NotFound,
|
||||
"remote control requires sqlite state db",
|
||||
@@ -1214,7 +1311,6 @@ pub(super) async fn connect_remote_control_websocket(
|
||||
if err.kind() == ErrorKind::PermissionDenied {
|
||||
*enrollment = None;
|
||||
status_publisher.publish_environment_id(/*environment_id*/ None);
|
||||
clear_current_enrollment(current_enrollment);
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
@@ -1231,7 +1327,6 @@ pub(super) async fn connect_remote_control_websocket(
|
||||
);
|
||||
*enrollment = None;
|
||||
status_publisher.publish_environment_id(/*environment_id*/ None);
|
||||
clear_current_enrollment(current_enrollment);
|
||||
}
|
||||
if let Some(enrollment) = enrollment.as_mut() {
|
||||
enrollment.remote_control_target = remote_control_target.clone();
|
||||
@@ -1262,7 +1357,7 @@ pub(super) async fn connect_remote_control_websocket(
|
||||
remote_control_target,
|
||||
state_db,
|
||||
&auth,
|
||||
&mut auth_context,
|
||||
auth_context,
|
||||
enrollment,
|
||||
connect_options,
|
||||
status_publisher,
|
||||
@@ -1307,14 +1402,13 @@ pub(super) async fn connect_remote_control_websocket(
|
||||
connect_options.app_server_client_name,
|
||||
enrollment,
|
||||
status_publisher,
|
||||
current_enrollment,
|
||||
)
|
||||
.await;
|
||||
enroll_remote_control_server_if_missing(
|
||||
remote_control_target,
|
||||
state_db,
|
||||
&auth,
|
||||
&mut auth_context,
|
||||
auth_context,
|
||||
enrollment,
|
||||
connect_options,
|
||||
status_publisher,
|
||||
@@ -1337,82 +1431,7 @@ pub(super) async fn connect_remote_control_websocket(
|
||||
}
|
||||
}
|
||||
|
||||
let enrollment_ref = enrollment.as_ref().ok_or_else(|| {
|
||||
io::Error::other("missing remote control enrollment after enrollment step")
|
||||
})?;
|
||||
publish_current_enrollment(current_enrollment, enrollment_ref);
|
||||
let request = build_remote_control_websocket_request(
|
||||
&remote_control_target.websocket_url,
|
||||
enrollment_ref,
|
||||
connect_options.installation_id,
|
||||
connect_options.subscribe_cursor,
|
||||
)?;
|
||||
|
||||
let websocket_connect_result = tokio::time::timeout(
|
||||
REMOTE_CONTROL_WEBSOCKET_CONNECT_TIMEOUT,
|
||||
connect_async(request),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
io::Error::new(
|
||||
ErrorKind::TimedOut,
|
||||
format!(
|
||||
"timed out connecting to remote control websocket at `{}` after {:?}",
|
||||
remote_control_target.websocket_url, REMOTE_CONTROL_WEBSOCKET_CONNECT_TIMEOUT
|
||||
),
|
||||
)
|
||||
})?;
|
||||
|
||||
match websocket_connect_result {
|
||||
Ok((websocket_stream, response)) => Ok((websocket_stream, response.map(|_| ()))),
|
||||
Err(err) => {
|
||||
match &err {
|
||||
tungstenite::Error::Http(response) if response.status().as_u16() == 404 => {
|
||||
info!(
|
||||
"remote control websocket returned HTTP 404; clearing stale enrollment before re-enrolling: websocket_url={}, account_id={}, server_id={}, environment_id={}",
|
||||
remote_control_target.websocket_url,
|
||||
auth.account_id,
|
||||
enrollment_ref.server_id,
|
||||
enrollment_ref.environment_id
|
||||
);
|
||||
clear_remote_control_enrollment(
|
||||
state_db,
|
||||
remote_control_target,
|
||||
&auth.account_id,
|
||||
connect_options.app_server_client_name,
|
||||
enrollment,
|
||||
status_publisher,
|
||||
current_enrollment,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
tungstenite::Error::Http(response)
|
||||
if matches!(response.status().as_u16(), 401 | 403) =>
|
||||
{
|
||||
enrollment
|
||||
.as_mut()
|
||||
.ok_or_else(|| {
|
||||
io::Error::other(
|
||||
"missing remote control enrollment after websocket auth failure",
|
||||
)
|
||||
})?
|
||||
.clear_server_token();
|
||||
clear_current_enrollment(current_enrollment);
|
||||
return Err(io::Error::other(format!(
|
||||
"remote control websocket auth failed with HTTP {}; refreshing server token before reconnect",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Err(io::Error::other(
|
||||
format_remote_control_websocket_connect_error(
|
||||
&remote_control_target.websocket_url,
|
||||
&err,
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
Ok(auth)
|
||||
}
|
||||
|
||||
async fn clear_remote_control_enrollment(
|
||||
@@ -1422,7 +1441,6 @@ async fn clear_remote_control_enrollment(
|
||||
app_server_client_name: Option<&str>,
|
||||
enrollment: &mut Option<RemoteControlEnrollment>,
|
||||
status_publisher: &RemoteControlStatusPublisher,
|
||||
current_enrollment: &CurrentRemoteControlEnrollment,
|
||||
) {
|
||||
if let Err(clear_err) = update_persisted_remote_control_enrollment(
|
||||
Some(state_db),
|
||||
@@ -1437,7 +1455,51 @@ async fn clear_remote_control_enrollment(
|
||||
}
|
||||
*enrollment = None;
|
||||
status_publisher.publish_environment_id(/*environment_id*/ None);
|
||||
clear_current_enrollment(current_enrollment);
|
||||
}
|
||||
|
||||
async fn clear_remote_control_enrollment_if_matches(
|
||||
state_db: Option<&StateRuntime>,
|
||||
remote_control_target: &RemoteControlTarget,
|
||||
account_id: &str,
|
||||
app_server_client_name: Option<&str>,
|
||||
current_enrollment: &CurrentRemoteControlEnrollment,
|
||||
enrollment: &RemoteControlEnrollment,
|
||||
status_publisher: &RemoteControlStatusPublisher,
|
||||
) {
|
||||
let Some(state_db) = state_db else {
|
||||
return;
|
||||
};
|
||||
let mut current_enrollment = current_enrollment.lock().await;
|
||||
if !current_enrollment
|
||||
.as_ref()
|
||||
.is_some_and(|current| same_remote_control_enrollment(current, enrollment))
|
||||
{
|
||||
return;
|
||||
}
|
||||
clear_remote_control_enrollment(
|
||||
state_db,
|
||||
remote_control_target,
|
||||
account_id,
|
||||
app_server_client_name,
|
||||
&mut current_enrollment,
|
||||
status_publisher,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn clear_remote_control_server_token_if_matches(
|
||||
current_enrollment: &CurrentRemoteControlEnrollment,
|
||||
enrollment: &RemoteControlEnrollment,
|
||||
) -> io::Result<()> {
|
||||
let mut current_enrollment = current_enrollment.lock().await;
|
||||
current_enrollment
|
||||
.as_mut()
|
||||
.filter(|current| same_remote_control_enrollment(current, enrollment))
|
||||
.ok_or_else(|| {
|
||||
io::Error::other("missing remote control enrollment after websocket auth failure")
|
||||
})?
|
||||
.clear_server_token();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn enroll_remote_control_server_if_missing(
|
||||
@@ -1585,8 +1647,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn test_current_enrollment() -> CurrentRemoteControlEnrollment {
|
||||
Arc::new(std::sync::Mutex::new(None))
|
||||
fn test_current_enrollment(
|
||||
enrollment: Option<RemoteControlEnrollment>,
|
||||
) -> CurrentRemoteControlEnrollment {
|
||||
Arc::new(RemoteControlEnrollmentState::new(enrollment))
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1739,10 +1803,9 @@ mod tests {
|
||||
let auth_manager = remote_control_auth_manager();
|
||||
let mut auth_recovery = auth_manager.unauthorized_recovery();
|
||||
let mut auth_change_rx = auth_manager.auth_change_receiver();
|
||||
let mut enrollment = Some(remote_control_enrollment(Some(
|
||||
let current_enrollment = test_current_enrollment(Some(remote_control_enrollment(Some(
|
||||
TEST_REMOTE_CONTROL_SERVER_TOKEN,
|
||||
)));
|
||||
let current_enrollment = test_current_enrollment();
|
||||
))));
|
||||
let (status_publisher, status_rx) = remote_control_status_channel();
|
||||
|
||||
let err = match connect_remote_control_websocket(
|
||||
@@ -1753,7 +1816,7 @@ mod tests {
|
||||
auth_recovery: &mut auth_recovery,
|
||||
auth_change_rx: &mut auth_change_rx,
|
||||
},
|
||||
&mut enrollment,
|
||||
¤t_enrollment,
|
||||
RemoteControlConnectOptions {
|
||||
installation_id: TEST_INSTALLATION_ID,
|
||||
server_name: "test-server",
|
||||
@@ -1761,7 +1824,6 @@ mod tests {
|
||||
app_server_client_name: None,
|
||||
},
|
||||
&status_publisher,
|
||||
¤t_enrollment,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -1771,12 +1833,7 @@ mod tests {
|
||||
|
||||
server_task.await.expect("server task should succeed");
|
||||
assert_eq!(err.to_string(), expected_error);
|
||||
assert!(
|
||||
current_enrollment
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.is_some()
|
||||
);
|
||||
assert!(current_enrollment.lock().await.is_some());
|
||||
assert_eq!(
|
||||
status_rx.borrow().clone(),
|
||||
RemoteControlStatusChangedNotification {
|
||||
@@ -1801,10 +1858,9 @@ mod tests {
|
||||
let auth_manager = remote_control_auth_manager();
|
||||
let mut auth_recovery = auth_manager.unauthorized_recovery();
|
||||
let mut auth_change_rx = auth_manager.auth_change_receiver();
|
||||
let mut enrollment = Some(remote_control_enrollment(Some(
|
||||
let current_enrollment = test_current_enrollment(Some(remote_control_enrollment(Some(
|
||||
TEST_REMOTE_CONTROL_SERVER_TOKEN,
|
||||
)));
|
||||
let current_enrollment = test_current_enrollment();
|
||||
))));
|
||||
let (status_publisher, status_rx) = remote_control_status_channel();
|
||||
|
||||
let server_task = tokio::spawn(async move {
|
||||
@@ -1824,7 +1880,7 @@ mod tests {
|
||||
auth_recovery: &mut auth_recovery,
|
||||
auth_change_rx: &mut auth_change_rx,
|
||||
},
|
||||
&mut enrollment,
|
||||
¤t_enrollment,
|
||||
RemoteControlConnectOptions {
|
||||
installation_id: TEST_INSTALLATION_ID,
|
||||
server_name: "test-server",
|
||||
@@ -1832,7 +1888,6 @@ mod tests {
|
||||
app_server_client_name: None,
|
||||
},
|
||||
&status_publisher,
|
||||
¤t_enrollment,
|
||||
)
|
||||
.await
|
||||
.expect_err("unauthorized response should fail the websocket connect");
|
||||
@@ -1853,13 +1908,7 @@ mod tests {
|
||||
);
|
||||
let mut expected_enrollment = remote_control_enrollment(/*remote_control_token*/ None);
|
||||
expected_enrollment.remote_control_target = remote_control_target;
|
||||
assert_eq!(enrollment, Some(expected_enrollment));
|
||||
assert!(
|
||||
current_enrollment
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(*current_enrollment.lock().await, Some(expected_enrollment));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1896,7 +1945,7 @@ mod tests {
|
||||
.await;
|
||||
let mut auth_recovery = auth_manager.unauthorized_recovery();
|
||||
let mut auth_change_rx = auth_manager.auth_change_receiver();
|
||||
let mut enrollment = None;
|
||||
let current_enrollment = test_current_enrollment(/*enrollment*/ None);
|
||||
let (status_publisher, status_rx) = remote_control_status_channel();
|
||||
save_auth(
|
||||
codex_home.path(),
|
||||
@@ -1913,7 +1962,7 @@ mod tests {
|
||||
auth_recovery: &mut auth_recovery,
|
||||
auth_change_rx: &mut auth_change_rx,
|
||||
},
|
||||
&mut enrollment,
|
||||
¤t_enrollment,
|
||||
RemoteControlConnectOptions {
|
||||
installation_id: TEST_INSTALLATION_ID,
|
||||
server_name: "test-server",
|
||||
@@ -1921,7 +1970,6 @@ mod tests {
|
||||
app_server_client_name: None,
|
||||
},
|
||||
&status_publisher,
|
||||
&test_current_enrollment(),
|
||||
)
|
||||
.await
|
||||
.expect_err("unauthorized enrollment should fail the websocket connect");
|
||||
@@ -1989,9 +2037,9 @@ mod tests {
|
||||
.await;
|
||||
let mut auth_recovery = auth_manager.unauthorized_recovery();
|
||||
let mut auth_change_rx = auth_manager.auth_change_receiver();
|
||||
let mut enrollment = Some(remote_control_enrollment(
|
||||
let current_enrollment = test_current_enrollment(Some(remote_control_enrollment(
|
||||
/*remote_control_token*/ None,
|
||||
));
|
||||
)));
|
||||
let (status_publisher, status_rx) = remote_control_status_channel();
|
||||
save_auth(
|
||||
codex_home.path(),
|
||||
@@ -2008,7 +2056,7 @@ mod tests {
|
||||
auth_recovery: &mut auth_recovery,
|
||||
auth_change_rx: &mut auth_change_rx,
|
||||
},
|
||||
&mut enrollment,
|
||||
¤t_enrollment,
|
||||
RemoteControlConnectOptions {
|
||||
installation_id: TEST_INSTALLATION_ID,
|
||||
server_name: "test-server",
|
||||
@@ -2016,7 +2064,6 @@ mod tests {
|
||||
app_server_client_name: None,
|
||||
},
|
||||
&status_publisher,
|
||||
&test_current_enrollment(),
|
||||
)
|
||||
.await
|
||||
.expect_err("unauthorized refresh should fail the websocket connect");
|
||||
@@ -2061,9 +2108,9 @@ mod tests {
|
||||
let auth_manager = remote_control_auth_manager();
|
||||
let mut auth_recovery = auth_manager.unauthorized_recovery();
|
||||
let mut auth_change_rx = auth_manager.auth_change_receiver();
|
||||
let mut enrollment = Some(remote_control_enrollment(Some(
|
||||
let current_enrollment = test_current_enrollment(Some(remote_control_enrollment(Some(
|
||||
TEST_REMOTE_CONTROL_SERVER_TOKEN,
|
||||
)));
|
||||
))));
|
||||
let (status_publisher, _status_rx) = remote_control_status_channel();
|
||||
|
||||
let err = connect_remote_control_websocket(
|
||||
@@ -2074,7 +2121,7 @@ mod tests {
|
||||
auth_recovery: &mut auth_recovery,
|
||||
auth_change_rx: &mut auth_change_rx,
|
||||
},
|
||||
&mut enrollment,
|
||||
¤t_enrollment,
|
||||
RemoteControlConnectOptions {
|
||||
installation_id: TEST_INSTALLATION_ID,
|
||||
server_name: "test-server",
|
||||
@@ -2082,14 +2129,13 @@ mod tests {
|
||||
app_server_client_name: None,
|
||||
},
|
||||
&status_publisher,
|
||||
&test_current_enrollment(),
|
||||
)
|
||||
.await
|
||||
.expect_err("missing sqlite state db should fail remote control");
|
||||
|
||||
assert_eq!(err.kind(), ErrorKind::NotFound);
|
||||
assert_eq!(err.to_string(), "remote control requires sqlite state db");
|
||||
assert_eq!(enrollment, None);
|
||||
assert_eq!(*current_enrollment.lock().await, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2107,9 +2153,9 @@ mod tests {
|
||||
.await;
|
||||
let mut auth_recovery = auth_manager.unauthorized_recovery();
|
||||
let mut auth_change_rx = auth_manager.auth_change_receiver();
|
||||
let mut enrollment = Some(remote_control_enrollment(Some(
|
||||
let current_enrollment = test_current_enrollment(Some(remote_control_enrollment(Some(
|
||||
TEST_REMOTE_CONTROL_SERVER_TOKEN,
|
||||
)));
|
||||
))));
|
||||
let (status_publisher, mut status_rx) = remote_control_status_channel();
|
||||
status_publisher.publish_environment_id(Some("env_test".to_string()));
|
||||
status_rx
|
||||
@@ -2125,7 +2171,7 @@ mod tests {
|
||||
auth_recovery: &mut auth_recovery,
|
||||
auth_change_rx: &mut auth_change_rx,
|
||||
},
|
||||
&mut enrollment,
|
||||
¤t_enrollment,
|
||||
RemoteControlConnectOptions {
|
||||
installation_id: TEST_INSTALLATION_ID,
|
||||
server_name: "test-server",
|
||||
@@ -2133,7 +2179,6 @@ mod tests {
|
||||
app_server_client_name: None,
|
||||
},
|
||||
&status_publisher,
|
||||
&test_current_enrollment(),
|
||||
)
|
||||
.await
|
||||
.expect_err("missing auth should fail remote control");
|
||||
@@ -2143,7 +2188,7 @@ mod tests {
|
||||
err.to_string(),
|
||||
"remote control requires ChatGPT authentication"
|
||||
);
|
||||
assert_eq!(enrollment, None);
|
||||
assert_eq!(*current_enrollment.lock().await, None);
|
||||
assert_eq!(
|
||||
status_rx.borrow().clone(),
|
||||
RemoteControlStatusChangedNotification {
|
||||
@@ -2185,7 +2230,8 @@ mod tests {
|
||||
RemoteControlChannels {
|
||||
transport_event_tx,
|
||||
status_publisher,
|
||||
current_enrollment: test_current_enrollment(),
|
||||
current_enrollment: test_current_enrollment(/*enrollment*/ None),
|
||||
pairing_persistence_key: watch::channel(None).0,
|
||||
},
|
||||
shutdown_token,
|
||||
enabled_rx,
|
||||
|
||||
@@ -188,11 +188,11 @@ Example with notification opt-out:
|
||||
- `fs/watch` — subscribe this connection to filesystem change notifications for an absolute file or directory path and caller-provided `watchId`; returns the canonicalized `path`.
|
||||
- `fs/unwatch` — stop sending notifications for a prior `fs/watch`; returns `{}`.
|
||||
- `fs/changed` — notification emitted when watched paths change, including the `watchId` and `changedPaths`.
|
||||
- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, `additionalSpeedTiers`, `serviceTiers`, optional `defaultServiceTier`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata.
|
||||
- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with model-advertised string reasoning effort options in the catalog's intended progression order, `additionalSpeedTiers`, `serviceTiers`, optional `defaultServiceTier`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. Clients should preserve the `supportedReasoningEfforts` array order rather than deriving order from the effort names.
|
||||
- `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider.
|
||||
- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. Pass `threadId` when showing feature state for an existing loaded thread so `enabled` is computed from that thread's refreshed config, including project-local config for the thread's cwd; if omitted, the server uses its default config resolution context. For non-beta flags, `displayName`/`description`/`announcement` are `null`.
|
||||
- `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text, using cursor pagination. Pass `cwd` when the caller needs project-local `[permissions.<id>]` entries to be included in the current catalog view.
|
||||
- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for currently supported feature keys. For each feature, precedence is: cloud requirements > --enable <feature_name> > config.toml > experimentalFeature/enablement/set (new) > code default.
|
||||
- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for currently supported feature keys. For each feature, precedence is: cloud requirements > --enable <feature_name> > config.toml > experimentalFeature/enablement/set (new) > code default. Invalid keys will be ignored.
|
||||
- `environment/add` — experimental; add or replace a named remote environment by `environmentId` and `execServerUrl` for later selection by `thread/start` or `turn/start`; returns `{}` and does not change the default environment.
|
||||
- `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). Built-in presets do not select a model; the Plan preset selects medium reasoning effort. This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly.
|
||||
- `skills/list` — list skills for one or more `cwd` values (optional `forceReload`).
|
||||
@@ -1244,6 +1244,7 @@ The app-server streams JSON-RPC notifications while a turn is running. Each turn
|
||||
- `turn/plan/updated` — `{ turnId, explanation?, plan }` whenever the agent shares or changes its plan; each `plan` entry is `{ step, status }` with `status` in `pending`, `inProgress`, or `completed`.
|
||||
- `model/rerouted` — `{ threadId, turnId, fromModel, toModel, reason }` when the backend reroutes a request to a different model (for example, due to high-risk cyber safety checks).
|
||||
- `model/verification` — `{ threadId, turnId, verifications }` when the backend flags additional account verification, such as `trustedAccessForCyber`.
|
||||
- `turn/moderationMetadata` — experimental; `{ threadId, turnId, metadata }` when a first-party backend supplies turn-scoped moderation metadata for client-side presentation.
|
||||
|
||||
Today both notifications carry an empty `items` array even when item events were streamed; rely on `item/*` notifications for the canonical item list until this is fixed.
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ use codex_app_server_protocol::TurnDiffUpdatedNotification;
|
||||
use codex_app_server_protocol::TurnError;
|
||||
use codex_app_server_protocol::TurnInterruptResponse;
|
||||
use codex_app_server_protocol::TurnItemsView;
|
||||
use codex_app_server_protocol::TurnModerationMetadataNotification;
|
||||
use codex_app_server_protocol::TurnPlanStep;
|
||||
use codex_app_server_protocol::TurnPlanUpdatedNotification;
|
||||
use codex_app_server_protocol::TurnStartedNotification;
|
||||
@@ -337,6 +338,16 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
.send_server_notification(ServerNotification::ModelVerification(notification))
|
||||
.await;
|
||||
}
|
||||
EventMsg::TurnModerationMetadata(event) => {
|
||||
let notification = TurnModerationMetadataNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id: event_turn_id.clone(),
|
||||
metadata: event.metadata,
|
||||
};
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::TurnModerationMetadata(notification))
|
||||
.await;
|
||||
}
|
||||
EventMsg::RealtimeConversationStarted(event) => {
|
||||
let notification = ThreadRealtimeStartedNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
|
||||
@@ -316,9 +316,8 @@ impl ExternalAgentConfigService {
|
||||
Some(self.external_agent_home.as_path()),
|
||||
mcp_settings.as_ref(),
|
||||
)?;
|
||||
let mcp_server_names = migrated_mcp_server_names(&migrated_mcp);
|
||||
let mut mcp_server_names = migrated_mcp_server_names(&migrated_mcp);
|
||||
if !is_empty_toml_table(&migrated_mcp) {
|
||||
let mut should_include = true;
|
||||
if target_config.exists() {
|
||||
let existing_raw = fs::read_to_string(&target_config)?;
|
||||
let mut existing = if existing_raw.trim().is_empty() {
|
||||
@@ -328,10 +327,10 @@ impl ExternalAgentConfigService {
|
||||
invalid_data_error(format!("invalid existing config.toml: {err}"))
|
||||
})?
|
||||
};
|
||||
should_include = merge_missing_toml_values(&mut existing, &migrated_mcp)?;
|
||||
mcp_server_names = merge_missing_mcp_servers(&mut existing, &migrated_mcp)?;
|
||||
}
|
||||
|
||||
if should_include {
|
||||
if !mcp_server_names.is_empty() {
|
||||
items.push(ExternalAgentConfigMigrationItem {
|
||||
item_type: ExternalAgentConfigMigrationItemType::McpServerConfig,
|
||||
description: format!(
|
||||
@@ -341,7 +340,7 @@ impl ExternalAgentConfigService {
|
||||
),
|
||||
cwd: cwd.clone(),
|
||||
details: Some(MigrationDetails {
|
||||
mcp_servers: named_migrations(mcp_server_names.clone()),
|
||||
mcp_servers: named_migrations(mcp_server_names),
|
||||
..Default::default()
|
||||
}),
|
||||
});
|
||||
@@ -863,7 +862,7 @@ impl ExternalAgentConfigService {
|
||||
toml::from_str::<TomlValue>(&existing_raw)
|
||||
.map_err(|err| invalid_data_error(format!("invalid existing config.toml: {err}")))?
|
||||
};
|
||||
if merge_missing_toml_values(&mut existing, &migrated)? {
|
||||
if !merge_missing_mcp_servers(&mut existing, &migrated)?.is_empty() {
|
||||
write_toml_file(&target_config, &existing)?;
|
||||
}
|
||||
Ok(())
|
||||
@@ -1538,6 +1537,43 @@ fn merge_missing_toml_values(existing: &mut TomlValue, incoming: &TomlValue) ->
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_missing_mcp_servers(
|
||||
existing: &mut TomlValue,
|
||||
incoming: &TomlValue,
|
||||
) -> io::Result<Vec<String>> {
|
||||
let existing_root = existing
|
||||
.as_table_mut()
|
||||
.ok_or_else(|| invalid_data_error("expected existing config to be a TOML table"))?;
|
||||
let incoming_root = incoming
|
||||
.as_table()
|
||||
.ok_or_else(|| invalid_data_error("expected migrated MCP config to be a TOML table"))?;
|
||||
let Some(incoming_servers) = incoming_root.get("mcp_servers") else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let incoming_servers = incoming_servers
|
||||
.as_table()
|
||||
.ok_or_else(|| invalid_data_error("expected migrated MCP servers to be a TOML table"))?;
|
||||
let Some(existing_servers) = existing_root.get_mut("mcp_servers") else {
|
||||
existing_root.insert(
|
||||
"mcp_servers".to_string(),
|
||||
TomlValue::Table(incoming_servers.clone()),
|
||||
);
|
||||
return Ok(incoming_servers.keys().cloned().collect());
|
||||
};
|
||||
let Some(existing_servers) = existing_servers.as_table_mut() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
let mut merged_server_names = Vec::new();
|
||||
for (server_name, incoming_server) in incoming_servers {
|
||||
if !existing_servers.contains_key(server_name) {
|
||||
existing_servers.insert(server_name.clone(), incoming_server.clone());
|
||||
merged_server_names.push(server_name.clone());
|
||||
}
|
||||
}
|
||||
Ok(merged_server_names)
|
||||
}
|
||||
|
||||
fn write_toml_file(path: &Path, value: &TomlValue) -> io::Result<()> {
|
||||
let serialized = toml::to_string_pretty(value)
|
||||
.map_err(|err| invalid_data_error(format!("failed to serialize config.toml: {err}")))?;
|
||||
|
||||
@@ -638,6 +638,124 @@ Research with Codex carefully."""
|
||||
assert_eq!(agent, expected_agent);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn import_repo_mcp_preserves_existing_same_named_server() {
|
||||
let root = TempDir::new().expect("create tempdir");
|
||||
let repo_root = root.path().join("repo");
|
||||
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
|
||||
fs::write(
|
||||
repo_root.join(".mcp.json"),
|
||||
r#"{
|
||||
"mcpServers": {
|
||||
"mixedTransport": {
|
||||
"command": "mcp-remote-proxy",
|
||||
"args": [
|
||||
"https://example.com/mixed-transport",
|
||||
"--transport",
|
||||
"http"
|
||||
],
|
||||
"url": "https://example.com/mixed-transport"
|
||||
}
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("write mcp");
|
||||
fs::create_dir_all(repo_root.join(".codex")).expect("create codex dir");
|
||||
let existing_config = r#"[mcp_servers.mixedTransport]
|
||||
url = "https://example.com/mixed-transport"
|
||||
"#;
|
||||
fs::write(
|
||||
repo_root.join(".codex").join("config.toml"),
|
||||
existing_config,
|
||||
)
|
||||
.expect("write config");
|
||||
|
||||
let service = service_for_paths(
|
||||
root.path().join(EXTERNAL_AGENT_DIR),
|
||||
root.path().join(".codex"),
|
||||
);
|
||||
assert_eq!(
|
||||
service
|
||||
.detect(ExternalAgentConfigDetectOptions {
|
||||
include_home: false,
|
||||
cwds: Some(vec![repo_root.clone()]),
|
||||
})
|
||||
.await
|
||||
.expect("detect"),
|
||||
Vec::<ExternalAgentConfigMigrationItem>::new()
|
||||
);
|
||||
|
||||
service
|
||||
.import(vec![ExternalAgentConfigMigrationItem {
|
||||
item_type: ExternalAgentConfigMigrationItemType::McpServerConfig,
|
||||
description: String::new(),
|
||||
cwd: Some(repo_root.clone()),
|
||||
details: None,
|
||||
}])
|
||||
.await
|
||||
.expect("import");
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"),
|
||||
existing_config
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detect_repo_mcp_lists_only_missing_servers() {
|
||||
let root = TempDir::new().expect("create tempdir");
|
||||
let repo_root = root.path().join("repo");
|
||||
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
|
||||
fs::write(
|
||||
repo_root.join(".mcp.json"),
|
||||
r#"{
|
||||
"mcpServers": {
|
||||
"docs": {"command": "docs-server"},
|
||||
"mixedTransport": {"command": "mcp-remote-proxy"}
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("write mcp");
|
||||
fs::create_dir_all(repo_root.join(".codex")).expect("create codex dir");
|
||||
fs::write(
|
||||
repo_root.join(".codex").join("config.toml"),
|
||||
r#"[mcp_servers.mixedTransport]
|
||||
url = "https://example.com/mixed-transport"
|
||||
"#,
|
||||
)
|
||||
.expect("write config");
|
||||
|
||||
let items = service_for_paths(
|
||||
root.path().join(EXTERNAL_AGENT_DIR),
|
||||
root.path().join(".codex"),
|
||||
)
|
||||
.detect(ExternalAgentConfigDetectOptions {
|
||||
include_home: false,
|
||||
cwds: Some(vec![repo_root.clone()]),
|
||||
})
|
||||
.await
|
||||
.expect("detect");
|
||||
|
||||
assert_eq!(
|
||||
items,
|
||||
vec![ExternalAgentConfigMigrationItem {
|
||||
item_type: ExternalAgentConfigMigrationItemType::McpServerConfig,
|
||||
description: format!(
|
||||
"Migrate MCP servers from {} into {}",
|
||||
repo_root.display(),
|
||||
repo_root.join(".codex").join("config.toml").display()
|
||||
),
|
||||
cwd: Some(repo_root),
|
||||
details: Some(MigrationDetails {
|
||||
mcp_servers: vec![NamedMigration {
|
||||
name: "docs".to_string(),
|
||||
}],
|
||||
..Default::default()
|
||||
}),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn import_home_migrates_supported_config_fields_skills_and_agents_md() {
|
||||
let (_root, external_agent_home, codex_home) = fixture_paths();
|
||||
|
||||
@@ -19,6 +19,9 @@ const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_C
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(version)]
|
||||
struct AppServerArgs {
|
||||
#[command(flatten)]
|
||||
config_overrides: CliConfigOverrides,
|
||||
|
||||
/// Transport endpoint URL. Supported values: `stdio://` (default),
|
||||
/// `unix://`, `unix://PATH`, `ws://IP:PORT`, `off`.
|
||||
#[arg(
|
||||
@@ -58,6 +61,7 @@ struct AppServerArgs {
|
||||
fn main() -> anyhow::Result<()> {
|
||||
arg0_dispatch_or_else(|arg0_paths: Arg0DispatchPaths| async move {
|
||||
let AppServerArgs {
|
||||
config_overrides,
|
||||
listen,
|
||||
session_source,
|
||||
auth,
|
||||
@@ -84,7 +88,7 @@ fn main() -> anyhow::Result<()> {
|
||||
|
||||
run_main_with_transport_options(
|
||||
arg0_paths,
|
||||
CliConfigOverrides::default(),
|
||||
config_overrides,
|
||||
loader_overrides,
|
||||
strict_config,
|
||||
/*default_analytics_enabled*/ false,
|
||||
@@ -123,3 +127,7 @@ fn managed_config_path_from_debug_env() -> Option<PathBuf> {
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "main_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
use super::AppServerArgs;
|
||||
use clap::Parser;
|
||||
use pretty_assertions::assert_eq;
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
#[test]
|
||||
fn app_server_accepts_cli_config_overrides() {
|
||||
let args = AppServerArgs::try_parse_from([
|
||||
"codex-app-server",
|
||||
"-c",
|
||||
"model=\"gpt-5-codex\"",
|
||||
"--config",
|
||||
"sandbox_mode=\"read-only\"",
|
||||
"--listen",
|
||||
"off",
|
||||
])
|
||||
.expect("parse app-server args");
|
||||
|
||||
let parsed_overrides = args
|
||||
.config_overrides
|
||||
.parse_overrides()
|
||||
.expect("parse config overrides");
|
||||
|
||||
assert_eq!(
|
||||
parsed_overrides,
|
||||
vec![
|
||||
(
|
||||
"model".to_string(),
|
||||
TomlValue::String("gpt-5-codex".to_string()),
|
||||
),
|
||||
(
|
||||
"sandbox_mode".to_string(),
|
||||
TomlValue::String("read-only".to_string()),
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -455,14 +455,13 @@ impl MessageProcessor {
|
||||
.plugins_manager()
|
||||
.maybe_start_plugin_startup_tasks_for_config(
|
||||
&config.plugins_config_input(),
|
||||
auth_manager.clone(),
|
||||
auth_manager,
|
||||
Some(on_effective_plugins_changed),
|
||||
);
|
||||
}
|
||||
let config_processor = ConfigRequestProcessor::new(
|
||||
outgoing.clone(),
|
||||
config_manager.clone(),
|
||||
auth_manager,
|
||||
thread_manager.clone(),
|
||||
analytics_events_client,
|
||||
);
|
||||
@@ -919,7 +918,7 @@ impl MessageProcessor {
|
||||
.map(|response| Some(response.into())),
|
||||
ClientRequest::RemoteControlPairingStart { params, .. } => self
|
||||
.remote_control_processor
|
||||
.pairing_start(params)
|
||||
.pairing_start(params, app_server_client_name.as_deref())
|
||||
.await
|
||||
.map(|response| Some(response.into())),
|
||||
ClientRequest::RemoteControlClientsList { params, .. } => self
|
||||
|
||||
@@ -62,10 +62,10 @@ fn reasoning_efforts_from_preset(
|
||||
efforts: Vec<ReasoningEffortPreset>,
|
||||
) -> Vec<ReasoningEffortOption> {
|
||||
efforts
|
||||
.iter()
|
||||
.into_iter()
|
||||
.map(|preset| ReasoningEffortOption {
|
||||
reasoning_effort: preset.effort,
|
||||
description: preset.description.to_string(),
|
||||
description: preset.description,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -725,6 +725,7 @@ mod tests {
|
||||
use codex_app_server_protocol::RateLimitWindow;
|
||||
use codex_app_server_protocol::ServerResponse;
|
||||
use codex_app_server_protocol::ToolRequestUserInputParams;
|
||||
use codex_app_server_protocol::TurnModerationMetadataNotification;
|
||||
use codex_protocol::ThreadId;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
@@ -951,6 +952,31 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_turn_moderation_metadata_notification_serialization() {
|
||||
let notification =
|
||||
ServerNotification::TurnModerationMetadata(TurnModerationMetadataNotification {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
metadata: json!({"presentation": "inline"}),
|
||||
});
|
||||
|
||||
let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification);
|
||||
assert_eq!(
|
||||
json!({
|
||||
"method": "turn/moderationMetadata",
|
||||
"params": {
|
||||
"threadId": "thread-1",
|
||||
"turnId": "turn-1",
|
||||
"metadata": {"presentation": "inline"},
|
||||
},
|
||||
}),
|
||||
serde_json::to_value(jsonrpc_notification)
|
||||
.expect("ensure the notification serializes correctly"),
|
||||
"ensure the notification serializes correctly"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_request_response_from_result_decodes_typed_response() {
|
||||
let request = ServerRequest::CommandExecutionRequestApproval {
|
||||
|
||||
@@ -30,6 +30,8 @@ use codex_app_server_protocol::AdditionalContextKind;
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use codex_app_server_protocol::AppListUpdatedNotification;
|
||||
use codex_app_server_protocol::AppSummary;
|
||||
use codex_app_server_protocol::AppTemplateSummary;
|
||||
use codex_app_server_protocol::AppTemplateUnavailableReason;
|
||||
use codex_app_server_protocol::AppsListParams;
|
||||
use codex_app_server_protocol::AppsListResponse;
|
||||
use codex_app_server_protocol::AskForApproval;
|
||||
@@ -303,7 +305,6 @@ use codex_core::windows_sandbox::WindowsSandboxSetupRequest;
|
||||
use codex_core::windows_sandbox::sandbox_setup_is_complete;
|
||||
use codex_core_plugins::PluginInstallError as CorePluginInstallError;
|
||||
use codex_core_plugins::PluginInstallRequest;
|
||||
use codex_core_plugins::PluginLoadOutcome;
|
||||
use codex_core_plugins::PluginReadRequest;
|
||||
use codex_core_plugins::PluginUninstallError as CorePluginUninstallError;
|
||||
use codex_core_plugins::loader::load_plugin_apps;
|
||||
|
||||
@@ -645,20 +645,20 @@ impl CatalogRequestProcessor {
|
||||
.await;
|
||||
let plugins_enabled =
|
||||
config.features.enabled(Feature::Plugins) && workspace_codex_plugins_enabled;
|
||||
let plugin_outcome = if plugins_enabled {
|
||||
let plugin_hooks = if plugins_enabled {
|
||||
let plugins_input = config.plugins_config_input();
|
||||
plugins_manager
|
||||
.plugins_for_layer_stack(&config.config_layer_stack, &plugins_input)
|
||||
.plugin_hooks_for_layer_stack(&config.config_layer_stack, &plugins_input)
|
||||
.await
|
||||
} else {
|
||||
PluginLoadOutcome::default()
|
||||
codex_core_plugins::PluginHookLoadOutcome::default()
|
||||
};
|
||||
let hooks = codex_hooks::list_hooks(codex_hooks::HooksConfig {
|
||||
feature_enabled: config.features.enabled(Feature::CodexHooks),
|
||||
bypass_hook_trust: config.bypass_hook_trust,
|
||||
config_layer_stack: Some(config.config_layer_stack),
|
||||
plugin_hook_sources: plugin_outcome.effective_plugin_hook_sources(),
|
||||
plugin_hook_load_warnings: plugin_outcome.effective_plugin_hook_warnings(),
|
||||
plugin_hook_sources: plugin_hooks.hook_sources,
|
||||
plugin_hook_load_warnings: plugin_hooks.hook_load_warnings,
|
||||
..Default::default()
|
||||
});
|
||||
data.push(codex_app_server_protocol::HooksListEntry {
|
||||
|
||||
@@ -7,7 +7,6 @@ use crate::error_code::invalid_request;
|
||||
use crate::outgoing_message::ConnectionRequestId;
|
||||
use crate::outgoing_message::OutgoingMessageSender;
|
||||
use codex_analytics::AnalyticsEventsClient;
|
||||
use codex_app_server_protocol::AppListUpdatedNotification;
|
||||
use codex_app_server_protocol::ClientResponsePayload;
|
||||
use codex_app_server_protocol::ComputerUseRequirements;
|
||||
use codex_app_server_protocol::ConfigBatchWriteParams;
|
||||
@@ -29,9 +28,7 @@ use codex_app_server_protocol::NetworkDomainPermission;
|
||||
use codex_app_server_protocol::NetworkRequirements;
|
||||
use codex_app_server_protocol::NetworkUnixSocketPermission;
|
||||
use codex_app_server_protocol::SandboxMode;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::WindowsSandboxSetupMode;
|
||||
use codex_chatgpt::connectors;
|
||||
use codex_config::ConfigRequirementsToml;
|
||||
use codex_config::HookEventsToml;
|
||||
use codex_config::HookHandlerConfig as CoreHookHandlerConfig;
|
||||
@@ -42,7 +39,6 @@ use codex_config::SandboxModeRequirement as CoreSandboxModeRequirement;
|
||||
use codex_core::ThreadManager;
|
||||
use codex_features::canonical_feature_for_key;
|
||||
use codex_features::feature_for_key;
|
||||
use codex_login::AuthManager;
|
||||
use codex_model_provider::create_model_provider;
|
||||
use codex_plugin::PluginId;
|
||||
use codex_protocol::config_types::WebSearchMode;
|
||||
@@ -50,21 +46,18 @@ use serde_json::json;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[
|
||||
"apps",
|
||||
"auth_elicitation",
|
||||
"memories",
|
||||
"mentions_v2",
|
||||
"plugins",
|
||||
"remote_control",
|
||||
"remote_plugin",
|
||||
"tool_suggest",
|
||||
"tool_call_mcp_elicitation",
|
||||
];
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ConfigRequestProcessor {
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
config_manager: ConfigManager,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
thread_manager: Arc<ThreadManager>,
|
||||
analytics_events_client: AnalyticsEventsClient,
|
||||
}
|
||||
@@ -73,14 +66,12 @@ impl ConfigRequestProcessor {
|
||||
pub(crate) fn new(
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
config_manager: ConfigManager,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
thread_manager: Arc<ThreadManager>,
|
||||
analytics_events_client: AnalyticsEventsClient,
|
||||
) -> Self {
|
||||
Self {
|
||||
outgoing,
|
||||
config_manager,
|
||||
auth_manager,
|
||||
thread_manager,
|
||||
analytics_events_client,
|
||||
}
|
||||
@@ -151,7 +142,6 @@ impl ConfigRequestProcessor {
|
||||
request_id: ConnectionRequestId,
|
||||
params: ExperimentalFeatureEnablementSetParams,
|
||||
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
||||
let should_refresh_apps_list = params.enablement.get("apps").copied() == Some(true);
|
||||
let response = self
|
||||
.handle_config_mutation_result(self.set_experimental_feature_enablement(params).await)
|
||||
.await?;
|
||||
@@ -161,10 +151,6 @@ impl ConfigRequestProcessor {
|
||||
ClientResponsePayload::ExperimentalFeatureEnablementSet(response),
|
||||
)
|
||||
.await;
|
||||
if should_refresh_apps_list {
|
||||
self.refresh_apps_list_after_experimental_feature_enablement_set()
|
||||
.await;
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
@@ -195,71 +181,6 @@ impl ConfigRequestProcessor {
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn refresh_apps_list_after_experimental_feature_enablement_set(&self) {
|
||||
let config = match self.load_latest_config(/*fallback_cwd*/ None).await {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"failed to load config for apps list refresh after experimental feature enablement: {}",
|
||||
error.message
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let auth = self.auth_manager.auth().await;
|
||||
if !config.features.apps_enabled_for_auth(
|
||||
auth.as_ref()
|
||||
.is_some_and(codex_login::CodexAuth::uses_codex_backend),
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let outgoing = Arc::clone(&self.outgoing);
|
||||
let environment_manager = self.thread_manager.environment_manager();
|
||||
tokio::spawn(async move {
|
||||
let (all_connectors_result, accessible_connectors_result) = tokio::join!(
|
||||
connectors::list_all_connectors_with_options(&config, /*force_refetch*/ true),
|
||||
connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager(
|
||||
&config,
|
||||
/*force_refetch*/ true,
|
||||
Arc::clone(&environment_manager),
|
||||
),
|
||||
);
|
||||
let all_connectors = match all_connectors_result {
|
||||
Ok(connectors) => connectors,
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"failed to force-refresh directory apps after experimental feature enablement: {err:#}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let accessible_connectors = match accessible_connectors_result {
|
||||
Ok(status) => status.connectors,
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"failed to force-refresh accessible apps after experimental feature enablement: {err:#}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let data = connectors::with_app_enabled_state(
|
||||
connectors::merge_connectors_with_accessible(
|
||||
all_connectors,
|
||||
accessible_connectors,
|
||||
/*all_connectors_loaded*/ true,
|
||||
),
|
||||
&config,
|
||||
);
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::AppListUpdated(
|
||||
AppListUpdatedNotification { data },
|
||||
))
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
async fn load_latest_config(
|
||||
&self,
|
||||
fallback_cwd: Option<PathBuf>,
|
||||
@@ -317,28 +238,19 @@ impl ConfigRequestProcessor {
|
||||
&self,
|
||||
params: ExperimentalFeatureEnablementSetParams,
|
||||
) -> Result<ExperimentalFeatureEnablementSetResponse, JSONRPCErrorError> {
|
||||
let ExperimentalFeatureEnablementSetParams { enablement } = params;
|
||||
for key in enablement.keys() {
|
||||
if canonical_feature_for_key(key).is_some() {
|
||||
if SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.contains(&key.as_str()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return Err(invalid_request(format!(
|
||||
"unsupported feature enablement `{key}`: currently supported features are {}",
|
||||
SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.join(", ")
|
||||
)));
|
||||
let ExperimentalFeatureEnablementSetParams { mut enablement } = params;
|
||||
let mut invalid_keys = Vec::new();
|
||||
enablement.retain(|key, _| {
|
||||
let valid = canonical_feature_for_key(key).is_some()
|
||||
&& SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.contains(&key.as_str());
|
||||
if !valid {
|
||||
invalid_keys.push(key.clone());
|
||||
}
|
||||
|
||||
let message = if let Some(feature) = feature_for_key(key) {
|
||||
format!(
|
||||
"invalid feature enablement `{key}`: use canonical feature key `{}`",
|
||||
feature.key()
|
||||
)
|
||||
} else {
|
||||
format!("invalid feature enablement `{key}`")
|
||||
};
|
||||
return Err(invalid_request(message));
|
||||
valid
|
||||
});
|
||||
if !invalid_keys.is_empty() {
|
||||
let invalid_keys = invalid_keys.join(", ");
|
||||
tracing::warn!("ignoring invalid experimental feature enablement keys: {invalid_keys}");
|
||||
}
|
||||
|
||||
if enablement.is_empty() {
|
||||
|
||||
@@ -8,6 +8,7 @@ use codex_app_server_protocol::PluginShareTargetRole;
|
||||
use codex_config::types::McpServerConfig;
|
||||
use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME;
|
||||
use codex_core_plugins::remote::REMOTE_GLOBAL_MARKETPLACE_NAME;
|
||||
use codex_core_plugins::remote::RemoteAppTemplateUnavailableReason;
|
||||
use codex_core_plugins::remote::RemotePluginScope;
|
||||
use codex_core_plugins::remote::is_valid_remote_plugin_id;
|
||||
use codex_core_plugins::remote::validate_remote_plugin_id;
|
||||
@@ -1062,6 +1063,7 @@ impl PluginRequestProcessor {
|
||||
})
|
||||
.collect(),
|
||||
apps: app_summaries,
|
||||
app_templates: Vec::new(),
|
||||
mcp_servers: outcome.plugin.mcp_server_names,
|
||||
}
|
||||
}
|
||||
@@ -2007,6 +2009,28 @@ fn remote_plugin_detail_to_info(
|
||||
detail: RemoteCatalogPluginDetail,
|
||||
apps: Vec<AppSummary>,
|
||||
) -> PluginDetail {
|
||||
let app_templates = detail
|
||||
.app_templates
|
||||
.into_iter()
|
||||
.map(|template| AppTemplateSummary {
|
||||
template_id: template.template_id,
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
canonical_connector_id: template.canonical_connector_id,
|
||||
logo_url: template.logo_url,
|
||||
logo_url_dark: template.logo_url_dark,
|
||||
materialized_app_ids: template.materialized_app_ids,
|
||||
reason: template.reason.map(|reason| match reason {
|
||||
RemoteAppTemplateUnavailableReason::NotConfiguredForWorkspace => {
|
||||
AppTemplateUnavailableReason::NotConfiguredForWorkspace
|
||||
}
|
||||
RemoteAppTemplateUnavailableReason::NoActiveWorkspace => {
|
||||
AppTemplateUnavailableReason::NoActiveWorkspace
|
||||
}
|
||||
}),
|
||||
})
|
||||
.collect();
|
||||
|
||||
PluginDetail {
|
||||
marketplace_name: detail.marketplace_name,
|
||||
marketplace_path: None,
|
||||
@@ -2026,7 +2050,8 @@ fn remote_plugin_detail_to_info(
|
||||
.collect(),
|
||||
hooks: Vec::new(),
|
||||
apps,
|
||||
mcp_servers: Vec::new(),
|
||||
app_templates,
|
||||
mcp_servers: detail.mcp_servers,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,9 +52,10 @@ impl RemoteControlRequestProcessor {
|
||||
pub(crate) async fn pairing_start(
|
||||
&self,
|
||||
params: RemoteControlPairingStartParams,
|
||||
app_server_client_name: Option<&str>,
|
||||
) -> Result<RemoteControlPairingStartResponse, JSONRPCErrorError> {
|
||||
self.handle()?
|
||||
.start_pairing(params)
|
||||
.start_pairing(params, app_server_client_name)
|
||||
.await
|
||||
.map_err(map_pairing_start_error)
|
||||
}
|
||||
|
||||
+4
-1
@@ -6,7 +6,10 @@ use pretty_assertions::assert_eq;
|
||||
#[tokio::test]
|
||||
async fn pairing_start_returns_internal_error_when_remote_control_is_unavailable() {
|
||||
let err = RemoteControlRequestProcessor::new(/*remote_control_handle*/ None)
|
||||
.pairing_start(RemoteControlPairingStartParams::default())
|
||||
.pairing_start(
|
||||
RemoteControlPairingStartParams::default(),
|
||||
/*app_server_client_name*/ None,
|
||||
)
|
||||
.await
|
||||
.expect_err("missing remote control should fail pairing");
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@ fn merge_persisted_resume_metadata(
|
||||
typesafe_overrides.model = persisted_metadata.model.clone();
|
||||
typesafe_overrides.model_provider = Some(persisted_metadata.model_provider.clone());
|
||||
|
||||
if let Some(reasoning_effort) = persisted_metadata.reasoning_effort {
|
||||
if let Some(reasoning_effort) = persisted_metadata.reasoning_effort.as_ref() {
|
||||
request_overrides.get_or_insert_with(HashMap::new).insert(
|
||||
"model_reasoning_effort".to_string(),
|
||||
serde_json::Value::String(reasoning_effort.to_string()),
|
||||
@@ -659,12 +659,6 @@ impl ThreadRequestProcessor {
|
||||
.map(|response| Some(response.into()))
|
||||
}
|
||||
|
||||
async fn instruction_sources_from_config(config: &Config) -> Vec<AbsolutePathBuf> {
|
||||
codex_core::AgentsMdManager::new(config)
|
||||
.instruction_sources(LOCAL_FS.as_ref())
|
||||
.await
|
||||
}
|
||||
|
||||
async fn load_thread(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
@@ -1043,7 +1037,6 @@ impl ThreadRequestProcessor {
|
||||
.map_err(|err| config_load_error(&err))?;
|
||||
}
|
||||
|
||||
let instruction_sources = Self::instruction_sources_from_config(&config).await;
|
||||
let environments = environments.unwrap_or_else(|| {
|
||||
listener_task_context
|
||||
.thread_manager
|
||||
@@ -1113,6 +1106,7 @@ impl ThreadRequestProcessor {
|
||||
)
|
||||
.await?;
|
||||
|
||||
let instruction_sources = thread.instruction_sources().await;
|
||||
let config_snapshot = thread
|
||||
.config_snapshot()
|
||||
.instrument(tracing::info_span!(
|
||||
@@ -2524,13 +2518,12 @@ impl ThreadRequestProcessor {
|
||||
}
|
||||
};
|
||||
|
||||
let instruction_sources = Self::instruction_sources_from_config(&config).await;
|
||||
let response_history = thread_history.clone();
|
||||
|
||||
match self
|
||||
.thread_manager
|
||||
.resume_thread_with_history(
|
||||
config.clone(),
|
||||
config,
|
||||
thread_history,
|
||||
self.auth_manager.clone(),
|
||||
self.request_trace_context(&request_id).await,
|
||||
@@ -2553,6 +2546,7 @@ impl ThreadRequestProcessor {
|
||||
self.outgoing.send_error(request_id, err).await;
|
||||
return Ok(());
|
||||
}
|
||||
let instruction_sources = codex_thread.instruction_sources().await;
|
||||
let SessionConfiguredEvent { rollout_path, .. } = session_configured;
|
||||
let Some(rollout_path) = rollout_path else {
|
||||
let error =
|
||||
@@ -2853,10 +2847,7 @@ impl ThreadRequestProcessor {
|
||||
/*include_turns*/ false,
|
||||
);
|
||||
thread_summary.session_id = existing_thread.session_configured().session_id.to_string();
|
||||
let mut config_for_instruction_sources = self.config.as_ref().clone();
|
||||
config_for_instruction_sources.cwd = config_snapshot.cwd.clone();
|
||||
let instruction_sources =
|
||||
Self::instruction_sources_from_config(&config_for_instruction_sources).await;
|
||||
let instruction_sources = existing_thread.instruction_sources().await;
|
||||
|
||||
let listener_command_tx = {
|
||||
let thread_state = thread_state.lock().await;
|
||||
@@ -3233,7 +3224,6 @@ impl ThreadRequestProcessor {
|
||||
.map_err(|err| config_load_error(&err))?;
|
||||
|
||||
let fallback_model_provider = config.model_provider_id.clone();
|
||||
let instruction_sources = Self::instruction_sources_from_config(&config).await;
|
||||
|
||||
let NewThread {
|
||||
thread_id,
|
||||
@@ -3284,6 +3274,8 @@ impl ThreadRequestProcessor {
|
||||
.map_err(|err| core_thread_write_error("inherit source thread name", err))?;
|
||||
}
|
||||
|
||||
let instruction_sources = forked_thread.instruction_sources().await;
|
||||
|
||||
// Auto-attach a conversation listener when forking a thread.
|
||||
log_listener_attach_result(
|
||||
self.ensure_conversation_listener(
|
||||
|
||||
@@ -701,6 +701,7 @@ mod thread_processor_behavior_tests {
|
||||
},
|
||||
},
|
||||
session_source: SessionSource::Cli,
|
||||
forked_from_thread_id: None,
|
||||
parent_thread_id: None,
|
||||
thread_source: None,
|
||||
};
|
||||
|
||||
@@ -203,7 +203,7 @@ pub(crate) fn thread_settings_from_config_snapshot(
|
||||
model: config_snapshot.model.clone(),
|
||||
model_provider: config_snapshot.model_provider_id.clone(),
|
||||
service_tier: config_snapshot.service_tier.clone(),
|
||||
effort: config_snapshot.reasoning_effort,
|
||||
effort: config_snapshot.reasoning_effort.clone(),
|
||||
summary: config_snapshot.reasoning_summary,
|
||||
collaboration_mode: config_snapshot.collaboration_mode.clone(),
|
||||
personality: config_snapshot.personality,
|
||||
|
||||
@@ -629,7 +629,7 @@ impl TurnRequestProcessor {
|
||||
profile_workspace_roots: profile_workspace_roots.clone(),
|
||||
windows_sandbox_level: None,
|
||||
model: model.clone(),
|
||||
effort,
|
||||
effort: effort.clone(),
|
||||
summary,
|
||||
service_tier: service_tier.clone(),
|
||||
collaboration_mode: collaboration_mode.clone(),
|
||||
|
||||
@@ -18,7 +18,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo {
|
||||
slug: preset.id.clone(),
|
||||
display_name: preset.display_name.clone(),
|
||||
description: Some(preset.description.clone()),
|
||||
default_reasoning_level: Some(preset.default_reasoning_effort),
|
||||
default_reasoning_level: Some(preset.default_reasoning_effort.clone()),
|
||||
supported_reasoning_levels: preset.supported_reasoning_efforts.clone(),
|
||||
shell_type: ConfigShellToolType::ShellCommand,
|
||||
visibility: if preset.show_in_picker {
|
||||
@@ -52,6 +52,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo {
|
||||
input_modalities: default_input_modalities(),
|
||||
used_fallback_model_metadata: false,
|
||||
supports_search_tool: false,
|
||||
use_responses_lite: false,
|
||||
auto_review_model_override: None,
|
||||
tool_mode: None,
|
||||
multi_agent_version: None,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
@@ -28,7 +27,6 @@ use codex_app_server_protocol::AppScreenshot;
|
||||
use codex_app_server_protocol::AppsListParams;
|
||||
use codex_app_server_protocol::AppsListResponse;
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams;
|
||||
use codex_app_server_protocol::JSONRPCError;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
@@ -1335,108 +1333,6 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn experimental_feature_enablement_set_refreshes_apps_list_when_apps_turn_on() -> Result<()> {
|
||||
let initial_connectors = vec![AppInfo {
|
||||
id: "alpha".to_string(),
|
||||
name: "Alpha".to_string(),
|
||||
description: Some("Alpha v1".to_string()),
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}];
|
||||
let (server_url, server_handle, server_control) = start_apps_server_with_delays_and_control(
|
||||
initial_connectors,
|
||||
Vec::new(),
|
||||
Duration::ZERO,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
write_connectors_config(codex_home.path(), &server_url)?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("chatgpt-token")
|
||||
.account_id("account-123")
|
||||
.chatgpt_user_id("user-enable-refresh")
|
||||
.chatgpt_account_id("account-123"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let disable_request = mcp
|
||||
.send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams {
|
||||
enablement: BTreeMap::from([("apps".to_string(), false)]),
|
||||
})
|
||||
.await?;
|
||||
let _disable_response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(disable_request)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
server_control.set_connectors(vec![AppInfo {
|
||||
id: "alpha".to_string(),
|
||||
name: "Alpha".to_string(),
|
||||
description: Some("Alpha v2".to_string()),
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}]);
|
||||
server_control.set_tools(vec![connector_tool("alpha", "Alpha App")?]);
|
||||
|
||||
let enable_request = mcp
|
||||
.send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams {
|
||||
enablement: BTreeMap::from([("apps".to_string(), true)]),
|
||||
})
|
||||
.await?;
|
||||
let _enable_response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(enable_request)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let update = read_app_list_updated_notification(&mut mcp).await?;
|
||||
assert_eq!(
|
||||
update.data,
|
||||
vec![AppInfo {
|
||||
id: "alpha".to_string(),
|
||||
name: "Alpha".to_string(),
|
||||
description: Some("Alpha v2".to_string()),
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}]
|
||||
);
|
||||
|
||||
server_handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_app_list_updated_notification(
|
||||
mcp: &mut TestAppServer,
|
||||
) -> Result<AppListUpdatedNotification> {
|
||||
|
||||
@@ -267,13 +267,15 @@ async fn experimental_feature_enablement_set_applies_to_global_and_thread_config
|
||||
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let actual =
|
||||
set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)]))
|
||||
.await?;
|
||||
let actual = set_experimental_feature_enablement(
|
||||
&mut mcp,
|
||||
BTreeMap::from([("auth_elicitation".to_string(), true)]),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
actual,
|
||||
ExperimentalFeatureEnablementSetResponse {
|
||||
enablement: BTreeMap::from([("apps".to_string(), true)]),
|
||||
enablement: BTreeMap::from([("auth_elicitation".to_string(), true)]),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -284,7 +286,7 @@ async fn experimental_feature_enablement_set_applies_to_global_and_thread_config
|
||||
config
|
||||
.additional
|
||||
.get("features")
|
||||
.and_then(|features| features.get("apps")),
|
||||
.and_then(|features| features.get("auth_elicitation")),
|
||||
Some(&json!(true))
|
||||
);
|
||||
}
|
||||
@@ -333,16 +335,18 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re
|
||||
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)]))
|
||||
.await?;
|
||||
set_experimental_feature_enablement(
|
||||
&mut mcp,
|
||||
BTreeMap::from([("mentions_v2".to_string(), true)]),
|
||||
)
|
||||
.await?;
|
||||
let actual = set_experimental_feature_enablement(
|
||||
&mut mcp,
|
||||
BTreeMap::from([
|
||||
("auth_elicitation".to_string(), true),
|
||||
("memories".to_string(), true),
|
||||
("plugins".to_string(), true),
|
||||
("remote_plugin".to_string(), true),
|
||||
("tool_suggest".to_string(), true),
|
||||
("tool_call_mcp_elicitation".to_string(), false),
|
||||
("tool_suggest".to_string(), false),
|
||||
]),
|
||||
)
|
||||
.await?;
|
||||
@@ -351,11 +355,10 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re
|
||||
actual,
|
||||
ExperimentalFeatureEnablementSetResponse {
|
||||
enablement: BTreeMap::from([
|
||||
("auth_elicitation".to_string(), true),
|
||||
("memories".to_string(), true),
|
||||
("plugins".to_string(), true),
|
||||
("remote_plugin".to_string(), true),
|
||||
("tool_suggest".to_string(), true),
|
||||
("tool_call_mcp_elicitation".to_string(), false),
|
||||
("tool_suggest".to_string(), false),
|
||||
]),
|
||||
}
|
||||
);
|
||||
@@ -366,7 +369,14 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re
|
||||
config
|
||||
.additional
|
||||
.get("features")
|
||||
.and_then(|features| features.get("apps")),
|
||||
.and_then(|features| features.get("mentions_v2")),
|
||||
Some(&json!(true))
|
||||
);
|
||||
assert_eq!(
|
||||
config
|
||||
.additional
|
||||
.get("features")
|
||||
.and_then(|features| features.get("auth_elicitation")),
|
||||
Some(&json!(true))
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -376,13 +386,6 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re
|
||||
.and_then(|features| features.get("memories")),
|
||||
Some(&json!(true))
|
||||
);
|
||||
assert_eq!(
|
||||
config
|
||||
.additional
|
||||
.get("features")
|
||||
.and_then(|features| features.get("plugins")),
|
||||
Some(&json!(true))
|
||||
);
|
||||
assert_eq!(
|
||||
config
|
||||
.additional
|
||||
@@ -395,13 +398,6 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re
|
||||
.additional
|
||||
.get("features")
|
||||
.and_then(|features| features.get("tool_suggest")),
|
||||
Some(&json!(true))
|
||||
);
|
||||
assert_eq!(
|
||||
config
|
||||
.additional
|
||||
.get("features")
|
||||
.and_then(|features| features.get("tool_call_mcp_elicitation")),
|
||||
Some(&json!(false))
|
||||
);
|
||||
|
||||
@@ -432,8 +428,11 @@ async fn experimental_feature_enablement_set_empty_map_is_no_op() -> Result<()>
|
||||
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)]))
|
||||
.await?;
|
||||
set_experimental_feature_enablement(
|
||||
&mut mcp,
|
||||
BTreeMap::from([("mentions_v2".to_string(), true)]),
|
||||
)
|
||||
.await?;
|
||||
let actual = set_experimental_feature_enablement(&mut mcp, BTreeMap::new()).await?;
|
||||
|
||||
assert_eq!(
|
||||
@@ -449,7 +448,7 @@ async fn experimental_feature_enablement_set_empty_map_is_no_op() -> Result<()>
|
||||
config
|
||||
.additional
|
||||
.get("features")
|
||||
.and_then(|features| features.get("apps")),
|
||||
.and_then(|features| features.get("mentions_v2")),
|
||||
Some(&json!(true))
|
||||
);
|
||||
|
||||
@@ -457,36 +456,30 @@ async fn experimental_feature_enablement_set_empty_map_is_no_op() -> Result<()>
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn experimental_feature_enablement_set_rejects_non_allowlisted_feature() -> Result<()> {
|
||||
async fn experimental_feature_enablement_set_ignores_invalid_features() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams {
|
||||
enablement: BTreeMap::from([("personality".to_string(), true)]),
|
||||
})
|
||||
.await?;
|
||||
let JSONRPCError { error, .. } = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
let actual = set_experimental_feature_enablement(
|
||||
&mut mcp,
|
||||
BTreeMap::from([
|
||||
("apps".to_string(), false),
|
||||
("auth_elicitation".to_string(), true),
|
||||
("connectors".to_string(), false),
|
||||
("personality".to_string(), false),
|
||||
("plugins".to_string(), false),
|
||||
("tool_call_mcp_elicitation".to_string(), false),
|
||||
("unknown_feature".to_string(), true),
|
||||
]),
|
||||
)
|
||||
.await??;
|
||||
.await?;
|
||||
|
||||
assert_eq!(error.code, -32600);
|
||||
assert!(
|
||||
error
|
||||
.message
|
||||
.contains("unsupported feature enablement `personality`"),
|
||||
"{}",
|
||||
error.message
|
||||
);
|
||||
assert!(
|
||||
error.message.contains(
|
||||
"apps, memories, mentions_v2, plugins, remote_control, remote_plugin, tool_suggest, tool_call_mcp_elicitation"
|
||||
),
|
||||
"{}",
|
||||
error.message
|
||||
assert_eq!(
|
||||
actual,
|
||||
ExperimentalFeatureEnablementSetResponse {
|
||||
enablement: BTreeMap::from([("auth_elicitation".to_string(), true)]),
|
||||
}
|
||||
);
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -44,7 +44,7 @@ const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
#[tokio::test]
|
||||
async fn standalone_image_generation_persists_image_and_returns_it_to_model() -> Result<()> {
|
||||
async fn standalone_image_generation_returns_saved_path_hint_to_model() -> Result<()> {
|
||||
let call_id = "image-run-1";
|
||||
let server = responses::start_mock_server().await;
|
||||
mount_image_response(&server).await;
|
||||
@@ -124,7 +124,13 @@ async fn standalone_image_generation_persists_image_and_returns_it_to_model() ->
|
||||
"detail": "high",
|
||||
})
|
||||
);
|
||||
assert_eq!(output["output"].as_array().map(Vec::len), Some(1));
|
||||
let output_hint = output["output"][1]["text"]
|
||||
.as_str()
|
||||
.context("image output should include model-visible path hint")?;
|
||||
assert!(
|
||||
output_hint.contains(&saved_path.display().to_string()),
|
||||
"output hint should identify the path core saved"
|
||||
);
|
||||
assert!(
|
||||
!requests[1]
|
||||
.message_input_texts("developer")
|
||||
@@ -199,7 +205,7 @@ const result = await tools.image_gen__imagegen({
|
||||
action: "generate",
|
||||
prompt: "paint a blue whale",
|
||||
});
|
||||
image(result);
|
||||
generatedImage(result);
|
||||
"#,
|
||||
),
|
||||
responses::ev_completed("resp-1"),
|
||||
@@ -246,7 +252,12 @@ image(result);
|
||||
"detail": "high",
|
||||
})
|
||||
);
|
||||
assert_eq!(output["output"].as_array().map(Vec::len), Some(2));
|
||||
assert!(
|
||||
output["output"][2]["text"]
|
||||
.as_str()
|
||||
.is_some_and(|text| text.contains("Generated images are saved"))
|
||||
);
|
||||
assert_eq!(output["output"].as_array().map(Vec::len), Some(3));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Error;
|
||||
use anyhow::Result;
|
||||
use app_test_support::ChatGptAuthFixture;
|
||||
use app_test_support::TestAppServer;
|
||||
@@ -48,11 +49,11 @@ fn model_from_preset(preset: &ModelPreset) -> Model {
|
||||
.supported_reasoning_efforts
|
||||
.iter()
|
||||
.map(|preset| ReasoningEffortOption {
|
||||
reasoning_effort: preset.effort,
|
||||
reasoning_effort: preset.effort.clone(),
|
||||
description: preset.description.clone(),
|
||||
})
|
||||
.collect(),
|
||||
default_reasoning_effort: preset.default_reasoning_effort,
|
||||
default_reasoning_effort: preset.default_reasoning_effort.clone(),
|
||||
input_modalities: preset.input_modalities.clone(),
|
||||
// `write_models_cache()` round-trips through a simplified ModelInfo fixture that does not
|
||||
// preserve personality placeholders in base instructions, so app-server list results from
|
||||
@@ -164,10 +165,11 @@ async fn list_models_uses_chatgpt_remote_catalog_as_source_of_truth() -> Result<
|
||||
"slug": "chatgpt-remote-only",
|
||||
"display_name": "ChatGPT Remote Only",
|
||||
"description": "Remote-only model for app-server model/list coverage",
|
||||
"default_reasoning_level": "medium",
|
||||
"default_reasoning_level": "max",
|
||||
"supported_reasoning_levels": [
|
||||
{"effort": "low", "description": "low"},
|
||||
{"effort": "medium", "description": "medium"}
|
||||
{"effort": "max", "description": "Maximum"},
|
||||
{"effort": "low", "description": "Low"},
|
||||
{"effort": "focused", "description": "Focused"}
|
||||
],
|
||||
"shell_type": "shell_command",
|
||||
"visibility": "list",
|
||||
@@ -238,10 +240,24 @@ openai_base_url = "{server_uri}/v1"
|
||||
} = to_response::<ModelListResponse>(response)?;
|
||||
let mut expected_presets: Vec<ModelPreset> = vec![remote_model.into()];
|
||||
ModelPreset::mark_default_by_picker_visibility(&mut expected_presets);
|
||||
let expected_items = expected_presets
|
||||
let mut expected_items = expected_presets
|
||||
.iter()
|
||||
.map(model_from_preset)
|
||||
.collect::<Vec<_>>();
|
||||
expected_items[0].supported_reasoning_efforts = vec![
|
||||
ReasoningEffortOption {
|
||||
reasoning_effort: "max".parse().map_err(Error::msg)?,
|
||||
description: "Maximum".to_string(),
|
||||
},
|
||||
ReasoningEffortOption {
|
||||
reasoning_effort: "low".parse().map_err(Error::msg)?,
|
||||
description: "Low".to_string(),
|
||||
},
|
||||
ReasoningEffortOption {
|
||||
reasoning_effort: "focused".parse().map_err(Error::msg)?,
|
||||
description: "Focused".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
assert_eq!(items, expected_items);
|
||||
assert!(next_cursor.is_none());
|
||||
|
||||
@@ -17,6 +17,8 @@ use axum::http::Uri;
|
||||
use axum::http::header::AUTHORIZATION;
|
||||
use axum::routing::get;
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use codex_app_server_protocol::AppTemplateSummary;
|
||||
use codex_app_server_protocol::AppTemplateUnavailableReason;
|
||||
use codex_app_server_protocol::HookEventName;
|
||||
use codex_app_server_protocol::JSONRPCError;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
@@ -122,7 +124,7 @@ async fn plugin_read_rejects_multiple_read_sources() -> Result<()> {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_is_disabled() -> Result<()> {
|
||||
async fn plugin_read_returns_remote_mcp_servers_when_uninstalled() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let server = MockServer::start().await;
|
||||
std::fs::write(
|
||||
@@ -148,22 +150,31 @@ plugins = true
|
||||
|
||||
let detail_body = r#"{
|
||||
"id": "plugins~Plugin_00000000000000000000000000000000",
|
||||
"name": "linear",
|
||||
"name": "example-plugin",
|
||||
"scope": "GLOBAL",
|
||||
"installation_policy": "AVAILABLE",
|
||||
"authentication_policy": "ON_USE",
|
||||
"release": {
|
||||
"display_name": "Linear",
|
||||
"description": "Track work in Linear",
|
||||
"version": "1.2.1",
|
||||
"display_name": "Example Plugin",
|
||||
"description": "Example plugin",
|
||||
"app_ids": [],
|
||||
"keywords": [],
|
||||
"interface": {
|
||||
"short_description": "Plan and track work",
|
||||
"short_description": "Example plugin",
|
||||
"capabilities": [],
|
||||
"default_prompt": "Use the legacy Linear prompt",
|
||||
"default_prompt": "Use the legacy example prompt",
|
||||
"default_prompts": []
|
||||
},
|
||||
"skills": []
|
||||
"skills": [],
|
||||
"mcp_servers": [
|
||||
{
|
||||
"key": "example-server",
|
||||
"metadata": {
|
||||
"command": "example-mcp"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}"#;
|
||||
let installed_body = r#"{
|
||||
@@ -211,12 +222,15 @@ plugins = true
|
||||
let response: PluginReadResponse = to_response(response)?;
|
||||
|
||||
assert_eq!(response.plugin.marketplace_name, "openai-curated-remote");
|
||||
assert_eq!(response.plugin.summary.id, "linear@openai-curated-remote");
|
||||
assert_eq!(
|
||||
response.plugin.summary.id,
|
||||
"example-plugin@openai-curated-remote"
|
||||
);
|
||||
assert_eq!(
|
||||
response.plugin.summary.remote_plugin_id.as_deref(),
|
||||
Some("plugins~Plugin_00000000000000000000000000000000")
|
||||
);
|
||||
assert_eq!(response.plugin.summary.name, "linear");
|
||||
assert_eq!(response.plugin.summary.name, "example-plugin");
|
||||
assert_eq!(response.plugin.summary.source, PluginSource::Remote);
|
||||
assert_eq!(response.plugin.summary.share_context, None);
|
||||
assert_eq!(
|
||||
@@ -226,7 +240,11 @@ plugins = true
|
||||
.interface
|
||||
.as_ref()
|
||||
.and_then(|interface| interface.default_prompt.clone()),
|
||||
Some(vec!["Use the legacy Linear prompt".to_string()])
|
||||
Some(vec!["Use the legacy example prompt".to_string()])
|
||||
);
|
||||
assert_eq!(
|
||||
response.plugin.mcp_servers,
|
||||
vec!["example-server".to_string()]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -411,6 +429,28 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() ->
|
||||
"display_name": "Linear",
|
||||
"description": "Track work in Linear",
|
||||
"app_ids": [],
|
||||
"app_templates": [
|
||||
{
|
||||
"template_id": "templated_apps_GitHubEnterprise",
|
||||
"name": "GitHub Enterprise",
|
||||
"description": "Connect GitHub Enterprise",
|
||||
"canonical_connector_id": "github_enterprise",
|
||||
"logo_url": "https://example.com/ghe-light.png",
|
||||
"logo_url_dark": "https://example.com/ghe-dark.png",
|
||||
"materialized_app_ids": ["asdk_app_ghe"],
|
||||
"reason": null
|
||||
},
|
||||
{
|
||||
"template_id": "templated_apps_Databricks",
|
||||
"name": "Databricks",
|
||||
"description": null,
|
||||
"canonical_connector_id": null,
|
||||
"logo_url": null,
|
||||
"logo_url_dark": null,
|
||||
"materialized_app_ids": [],
|
||||
"reason": "NOT_CONFIGURED_FOR_WORKSPACE"
|
||||
}
|
||||
],
|
||||
"keywords": ["issue-tracking", "project management"],
|
||||
"interface": {
|
||||
"short_description": "Plan and track work",
|
||||
@@ -548,6 +588,31 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() ->
|
||||
assert_eq!(response.plugin.skills[0].path, None);
|
||||
assert_eq!(response.plugin.skills[0].enabled, false);
|
||||
assert_eq!(response.plugin.apps.len(), 0);
|
||||
assert_eq!(
|
||||
response.plugin.app_templates,
|
||||
vec![
|
||||
AppTemplateSummary {
|
||||
template_id: "templated_apps_GitHubEnterprise".to_string(),
|
||||
name: "GitHub Enterprise".to_string(),
|
||||
description: Some("Connect GitHub Enterprise".to_string()),
|
||||
canonical_connector_id: Some("github_enterprise".to_string()),
|
||||
logo_url: Some("https://example.com/ghe-light.png".to_string()),
|
||||
logo_url_dark: Some("https://example.com/ghe-dark.png".to_string()),
|
||||
materialized_app_ids: vec!["asdk_app_ghe".to_string()],
|
||||
reason: None,
|
||||
},
|
||||
AppTemplateSummary {
|
||||
template_id: "templated_apps_Databricks".to_string(),
|
||||
name: "Databricks".to_string(),
|
||||
description: None,
|
||||
canonical_connector_id: None,
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
materialized_app_ids: Vec::new(),
|
||||
reason: Some(AppTemplateUnavailableReason::NotConfiguredForWorkspace),
|
||||
},
|
||||
]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -193,6 +193,42 @@ async fn remote_control_pairing_start_returns_pairing_artifacts() -> Result<()>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_control_pairing_start_returns_pairing_artifacts_while_disabled() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut backend = PairingRemoteControlBackend::start(codex_home.path()).await?;
|
||||
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_remote_control_pairing_start_request(RemoteControlPairingStartParams {
|
||||
manual_code: true,
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(
|
||||
timeout(DEFAULT_TIMEOUT, backend.wait_for_enroll_request()).await??,
|
||||
"POST /backend-api/wham/remote/control/server/enroll HTTP/1.1"
|
||||
);
|
||||
assert_eq!(response.result.get("serverId"), None);
|
||||
let received: RemoteControlPairingStartResponse = to_response(response)?;
|
||||
|
||||
assert_eq!(
|
||||
received,
|
||||
RemoteControlPairingStartResponse {
|
||||
pairing_code: "pairing-code".to_string(),
|
||||
manual_pairing_code: Some("ABCD-EFGH".to_string()),
|
||||
environment_id: "environment-id".to_string(),
|
||||
expires_at: 33_336_362_096,
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_control_client_management_works_while_disabled() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -376,8 +412,12 @@ impl PairingRemoteControlBackend {
|
||||
)
|
||||
.await?;
|
||||
|
||||
let _websocket_request = read_http_request(&listener).await?;
|
||||
let pair_http_request = read_http_request(&listener).await?;
|
||||
let request_after_enroll = read_http_request(&listener).await?;
|
||||
let pair_http_request = if request_after_enroll.request_line.starts_with("GET ") {
|
||||
read_http_request(&listener).await?
|
||||
} else {
|
||||
request_after_enroll
|
||||
};
|
||||
respond_with_json(
|
||||
pair_http_request.reader.into_inner(),
|
||||
serde_json::json!({
|
||||
|
||||
@@ -15,6 +15,7 @@ use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_app_server_protocol::TurnModerationMetadataNotification;
|
||||
use codex_app_server_protocol::TurnStartParams;
|
||||
use codex_app_server_protocol::TurnStartResponse;
|
||||
use codex_app_server_protocol::UserInput;
|
||||
@@ -309,6 +310,87 @@ async fn model_verification_emits_typed_notification_and_warning_v2() -> Result<
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_moderation_metadata_emits_typed_notification_v2() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let body = responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
serde_json::json!({
|
||||
"type": "response.metadata",
|
||||
"sequence_number": 1,
|
||||
"response_id": "resp-1",
|
||||
"metadata": {
|
||||
"openai_chatgpt_moderation_metadata": {
|
||||
"presentation": "inline"
|
||||
}
|
||||
}
|
||||
}),
|
||||
responses::ev_assistant_message("msg-1", "Done"),
|
||||
responses::ev_completed("resp-1"),
|
||||
]);
|
||||
let response = responses::sse_response(body);
|
||||
let _response_mock = responses::mount_response_once(&server, response).await;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri())?;
|
||||
|
||||
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let thread_req = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
model: Some(REQUESTED_MODEL.to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let thread_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
|
||||
|
||||
let turn_req = mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: thread.id.clone(),
|
||||
client_user_message_id: None,
|
||||
input: vec![UserInput::Text {
|
||||
text: "trigger moderation metadata".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let turn_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
|
||||
)
|
||||
.await??;
|
||||
let turn_start: TurnStartResponse = to_response(turn_resp)?;
|
||||
|
||||
let notification = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("turn/moderationMetadata"),
|
||||
)
|
||||
.await??;
|
||||
let metadata: TurnModerationMetadataNotification =
|
||||
serde_json::from_value(notification.params.ok_or_else(|| {
|
||||
anyhow::anyhow!("turn/moderationMetadata notifications must include params")
|
||||
})?)?;
|
||||
assert_eq!(
|
||||
metadata,
|
||||
TurnModerationMetadataNotification {
|
||||
thread_id: thread.id,
|
||||
turn_id: turn_start.turn.id,
|
||||
metadata: serde_json::json!({"presentation": "inline"}),
|
||||
}
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn collect_turn_notifications_and_validate_no_warning_item(
|
||||
mcp: &mut TestAppServer,
|
||||
) -> Result<ModelReroutedNotification> {
|
||||
|
||||
@@ -490,6 +490,13 @@ async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> {
|
||||
"forked",
|
||||
"user",
|
||||
);
|
||||
assert_eq!(
|
||||
event["event_params"]["forked_from_thread_id"],
|
||||
thread
|
||||
.forked_from_id
|
||||
.as_deref()
|
||||
.expect("forked thread has a source thread")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -256,6 +256,82 @@ async fn thread_resume_with_empty_path_uses_running_thread_id() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_resume_running_thread_uses_cached_instruction_sources() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri())?;
|
||||
let workspace = TempDir::new()?;
|
||||
let project_agents = workspace.path().join("AGENTS.md");
|
||||
std::fs::write(&project_agents, "project instructions")?;
|
||||
|
||||
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let start_id = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
cwd: Some(workspace.path().display().to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let start_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(start_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse {
|
||||
thread,
|
||||
instruction_sources,
|
||||
..
|
||||
} = to_response::<ThreadStartResponse>(start_resp)?;
|
||||
let project_agents = AbsolutePathBuf::try_from(project_agents)?;
|
||||
assert_eq!(instruction_sources, vec![project_agents.clone()]);
|
||||
|
||||
let turn_id = mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: thread.id.clone(),
|
||||
client_user_message_id: None,
|
||||
input: vec![UserInput::Text {
|
||||
text: "materialize rollout".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(turn_id)),
|
||||
)
|
||||
.await??;
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
std::fs::remove_file(project_agents.as_path())?;
|
||||
|
||||
let resume_id = mcp
|
||||
.send_thread_resume_request(ThreadResumeParams {
|
||||
thread_id: thread.id,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let resume_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(resume_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadResumeResponse {
|
||||
instruction_sources,
|
||||
..
|
||||
} = to_response::<ThreadResumeResponse>(resume_resp)?;
|
||||
|
||||
assert_eq!(instruction_sources, vec![project_agents]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_start_updates_runtime_workspace_roots_for_loaded_thread() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
|
||||
@@ -348,7 +348,7 @@ async fn thread_start_response_includes_loaded_instruction_sources() -> Result<(
|
||||
.collect::<Vec<_>>();
|
||||
let expected_instruction_sources = vec![
|
||||
std::fs::canonicalize(global_agents_path)?,
|
||||
std::fs::canonicalize(project_agents_path)?,
|
||||
project_agents_path,
|
||||
]
|
||||
.into_iter()
|
||||
.map(normalize_path_for_comparison)
|
||||
@@ -359,6 +359,83 @@ async fn thread_start_response_includes_loaded_instruction_sources() -> Result<(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_start_response_excludes_empty_project_instruction_source() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?;
|
||||
let global_agents_path = codex_home.path().join("AGENTS.md");
|
||||
std::fs::write(&global_agents_path, "global instructions")?;
|
||||
let workspace = TempDir::new()?;
|
||||
let project_agents_path = workspace.path().join("AGENTS.md");
|
||||
std::fs::write(project_agents_path, "")?;
|
||||
|
||||
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
cwd: Some(workspace.path().display().to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse {
|
||||
instruction_sources,
|
||||
..
|
||||
} = to_response::<ThreadStartResponse>(response)?;
|
||||
|
||||
let instruction_sources = instruction_sources
|
||||
.into_iter()
|
||||
.map(normalize_path_for_comparison)
|
||||
.collect::<Vec<_>>();
|
||||
let expected_instruction_sources = vec![normalize_path_for_comparison(std::fs::canonicalize(
|
||||
global_agents_path,
|
||||
)?)];
|
||||
|
||||
assert_eq!(instruction_sources, expected_instruction_sources);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_start_without_selected_environment_excludes_instruction_sources() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?;
|
||||
std::fs::write(codex_home.path().join("AGENTS.md"), "global instructions")?;
|
||||
let workspace = TempDir::new()?;
|
||||
std::fs::write(workspace.path().join("AGENTS.md"), "project instructions")?;
|
||||
|
||||
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
cwd: Some(workspace.path().display().to_string()),
|
||||
environments: Some(Vec::new()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse {
|
||||
instruction_sources,
|
||||
..
|
||||
} = to_response::<ThreadStartResponse>(response)?;
|
||||
|
||||
assert!(instruction_sources.is_empty());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn normalize_path_for_comparison(path: impl AsRef<Path>) -> PathBuf {
|
||||
let path = path.as_ref();
|
||||
|
||||
@@ -20,6 +20,7 @@ use codex_plugin::PluginId;
|
||||
use codex_plugin::validate_plugin_segment;
|
||||
use codex_utils_cli::CliConfigOverrides;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -177,9 +178,14 @@ pub async fn run_plugin_list(
|
||||
.is_none_or(|name| marketplace.name == *name)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let marketplace_sources = configured_marketplace_sources(&plugins_input);
|
||||
|
||||
if args.json {
|
||||
let output = JsonPluginListOutput::from_marketplaces(marketplaces, args.available);
|
||||
let output = JsonPluginListOutput::from_marketplaces(
|
||||
marketplaces,
|
||||
args.available,
|
||||
&marketplace_sources,
|
||||
);
|
||||
println!("{}", serde_json::to_string_pretty(&output)?);
|
||||
return Ok(());
|
||||
}
|
||||
@@ -269,13 +275,19 @@ impl JsonPluginListOutput {
|
||||
fn from_marketplaces(
|
||||
marketplaces: Vec<codex_core_plugins::ConfiguredMarketplace>,
|
||||
include_available: bool,
|
||||
marketplace_sources: &HashMap<String, JsonMarketplaceSource>,
|
||||
) -> Self {
|
||||
let mut installed = Vec::new();
|
||||
let mut available = Vec::new();
|
||||
|
||||
for marketplace in marketplaces {
|
||||
let marketplace_source = marketplace_sources.get(&marketplace.name).cloned();
|
||||
for plugin in marketplace.plugins {
|
||||
let entry = JsonPluginListEntry::from_configured_plugin(&marketplace.name, plugin);
|
||||
let entry = JsonPluginListEntry::from_configured_plugin(
|
||||
&marketplace.name,
|
||||
marketplace_source.clone(),
|
||||
plugin,
|
||||
);
|
||||
if entry.installed {
|
||||
installed.push(entry);
|
||||
} else if include_available {
|
||||
@@ -301,6 +313,8 @@ struct JsonPluginListEntry {
|
||||
installed: bool,
|
||||
enabled: bool,
|
||||
source: JsonPluginSource,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
marketplace_source: Option<JsonMarketplaceSource>,
|
||||
install_policy: &'static str,
|
||||
auth_policy: &'static str,
|
||||
}
|
||||
@@ -308,6 +322,7 @@ struct JsonPluginListEntry {
|
||||
impl JsonPluginListEntry {
|
||||
fn from_configured_plugin(
|
||||
marketplace_name: &str,
|
||||
marketplace_source: Option<JsonMarketplaceSource>,
|
||||
plugin: codex_core_plugins::ConfiguredMarketplacePlugin,
|
||||
) -> Self {
|
||||
let version = plugin.installed_version.or(plugin.local_version);
|
||||
@@ -319,6 +334,7 @@ impl JsonPluginListEntry {
|
||||
installed: plugin.installed,
|
||||
enabled: plugin.enabled,
|
||||
source: JsonPluginSource::from_marketplace_source(plugin.source),
|
||||
marketplace_source,
|
||||
install_policy: install_policy_label(plugin.policy.installation),
|
||||
auth_policy: auth_policy_label(plugin.policy.authentication),
|
||||
}
|
||||
@@ -375,6 +391,44 @@ impl JsonPluginSource {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct JsonMarketplaceSource {
|
||||
source_type: String,
|
||||
source: String,
|
||||
}
|
||||
|
||||
fn configured_marketplace_sources(
|
||||
plugins_input: &PluginsConfigInput,
|
||||
) -> HashMap<String, JsonMarketplaceSource> {
|
||||
let Some(user_config) = plugins_input.config_layer_stack.effective_user_config() else {
|
||||
return HashMap::new();
|
||||
};
|
||||
let Some(marketplaces) = user_config
|
||||
.get("marketplaces")
|
||||
.and_then(toml::Value::as_table)
|
||||
else {
|
||||
return HashMap::new();
|
||||
};
|
||||
|
||||
marketplaces
|
||||
.iter()
|
||||
.filter_map(|(marketplace_name, marketplace)| {
|
||||
let source_type = marketplace
|
||||
.get("source_type")
|
||||
.and_then(toml::Value::as_str)?;
|
||||
let source = marketplace.get("source").and_then(toml::Value::as_str)?;
|
||||
Some((
|
||||
marketplace_name.clone(),
|
||||
JsonMarketplaceSource {
|
||||
source_type: source_type.to_string(),
|
||||
source: source.to_string(),
|
||||
},
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn install_policy_label(policy: MarketplacePluginInstallPolicy) -> &'static str {
|
||||
match policy {
|
||||
MarketplacePluginInstallPolicy::NotAvailable => "NOT_AVAILABLE",
|
||||
|
||||
@@ -2,6 +2,7 @@ use anyhow::Result;
|
||||
use codex_config::CONFIG_TOML_FILE;
|
||||
use codex_config::MarketplaceConfigUpdate;
|
||||
use codex_config::record_user_marketplace;
|
||||
use codex_utils_absolute_path::canonicalize_existing_preserving_symlinks;
|
||||
use predicates::prelude::PredicateBooleanExt;
|
||||
use predicates::str::contains;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -495,6 +496,7 @@ async fn plugin_list_prints_plugins_in_a_table() -> Result<()> {
|
||||
async fn plugin_list_json_prints_available_plugins_when_requested() -> Result<()> {
|
||||
let (codex_home, source) = setup_local_marketplace()?;
|
||||
let plugin_path = source.path().join("plugins").join("sample");
|
||||
let source_path = source.path().to_string_lossy().into_owned();
|
||||
|
||||
let assert = codex_command(codex_home.path())?
|
||||
.args(["plugin", "list", "--available", "--json"])
|
||||
@@ -519,6 +521,69 @@ async fn plugin_list_json_prints_available_plugins_when_requested() -> Result<()
|
||||
"source": "local",
|
||||
"path": plugin_path.display().to_string(),
|
||||
},
|
||||
"marketplaceSource": {
|
||||
"sourceType": "local",
|
||||
"source": source_path,
|
||||
},
|
||||
"installPolicy": "AVAILABLE",
|
||||
"authPolicy": "ON_INSTALL",
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_list_json_includes_configured_git_marketplace_source() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let marketplace_root = codex_home
|
||||
.path()
|
||||
.join(".tmp")
|
||||
.join("marketplaces")
|
||||
.join("debug");
|
||||
write_plugins_enabled_config(codex_home.path())?;
|
||||
write_marketplace_source(&marketplace_root)?;
|
||||
let update = MarketplaceConfigUpdate {
|
||||
last_updated: "2026-06-04T08:39:49Z",
|
||||
last_revision: Some("abc123"),
|
||||
source_type: "git",
|
||||
source: "https://example.com/acme/agent-skills.git",
|
||||
ref_name: None,
|
||||
sparse_paths: &[],
|
||||
};
|
||||
record_user_marketplace(codex_home.path(), "debug", &update)?;
|
||||
let plugin_path = marketplace_root.join("plugins").join("sample");
|
||||
let normalized_plugin_path = canonicalize_existing_preserving_symlinks(&plugin_path)?;
|
||||
|
||||
let assert = codex_command(codex_home.path())?
|
||||
.args(["plugin", "list", "--available", "--json"])
|
||||
.assert()
|
||||
.success();
|
||||
let stdout = assert.get_output().stdout.as_slice();
|
||||
let actual: serde_json::Value = serde_json::from_slice(stdout)?;
|
||||
|
||||
assert_eq!(
|
||||
actual,
|
||||
json!({
|
||||
"installed": [],
|
||||
"available": [
|
||||
{
|
||||
"pluginId": "sample@debug",
|
||||
"name": "sample",
|
||||
"marketplaceName": "debug",
|
||||
"version": "1.2.3",
|
||||
"installed": false,
|
||||
"enabled": false,
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": normalized_plugin_path.display().to_string(),
|
||||
},
|
||||
"marketplaceSource": {
|
||||
"sourceType": "git",
|
||||
"source": "https://example.com/acme/agent-skills.git",
|
||||
},
|
||||
"installPolicy": "AVAILABLE",
|
||||
"authPolicy": "ON_INSTALL",
|
||||
},
|
||||
@@ -533,6 +598,7 @@ async fn plugin_list_json_prints_available_plugins_when_requested() -> Result<()
|
||||
async fn plugin_list_json_prints_installed_plugins() -> Result<()> {
|
||||
let (codex_home, source) = setup_local_marketplace()?;
|
||||
let plugin_path = source.path().join("plugins").join("sample");
|
||||
let source_path = source.path().to_string_lossy().into_owned();
|
||||
|
||||
codex_command(codex_home.path())?
|
||||
.args(["plugin", "add", "sample@debug"])
|
||||
@@ -561,6 +627,10 @@ async fn plugin_list_json_prints_installed_plugins() -> Result<()> {
|
||||
"source": "local",
|
||||
"path": plugin_path.display().to_string(),
|
||||
},
|
||||
"marketplaceSource": {
|
||||
"sourceType": "local",
|
||||
"source": source_path,
|
||||
},
|
||||
"installPolicy": "AVAILABLE",
|
||||
"authPolicy": "ON_INSTALL",
|
||||
},
|
||||
|
||||
@@ -25,6 +25,7 @@ const EXEC_DESCRIPTION_TEMPLATE: &str = r#"Run JavaScript code to orchestrate/co
|
||||
- `exit()`: Immediately ends the current script successfully (like an early return from the top level).
|
||||
- `text(value: string | number | boolean | undefined | null)`: Appends a text item. Non-string values are stringified with `JSON.stringify(...)` when possible.
|
||||
- `image(imageUrlOrItem: string | { image_url: string; detail?: "auto" | "low" | "high" | "original" | null } | ImageContent, detail?: "auto" | "low" | "high" | "original" | null)`: Appends an image item. `image_url` can be an HTTPS URL or a base64-encoded `data:` URL. To forward an MCP tool image, pass an individual `ImageContent` block from `result.content`, for example `image(result.content[0])`. MCP image blocks may request detail with `_meta: { "codex/imageDetail": "original" }`. When provided, the second `detail` argument overrides any detail embedded in the first argument.
|
||||
- `generatedImage(result: { image_url: string; output_hint?: string })`: Appends an image-generation result and its optional output hint.
|
||||
- `store(key: string, value: any)`: stores a serializable value under a string key for later `exec` calls in the same session.
|
||||
- `load(key: string)`: returns the stored value for a string key, or `undefined` if it is missing.
|
||||
- `notify(value: string | number | boolean | undefined | null)`: immediately injects an extra `custom_tool_call_output` for the current `exec` call. Values are stringified like `text(...)`.
|
||||
|
||||
@@ -129,6 +129,58 @@ pub(super) fn image_callback(
|
||||
retval.set(v8::undefined(scope).into());
|
||||
}
|
||||
|
||||
pub(super) fn generated_image_callback(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
args: v8::FunctionCallbackArguments,
|
||||
mut retval: v8::ReturnValue<v8::Value>,
|
||||
) {
|
||||
let value = if args.length() == 0 {
|
||||
v8::undefined(scope).into()
|
||||
} else {
|
||||
args.get(0)
|
||||
};
|
||||
let output_hint = match generated_image_output_hint(scope, value) {
|
||||
Ok(output_hint) => output_hint,
|
||||
Err(error_text) => {
|
||||
throw_type_error(scope, &error_text);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let image_item = match normalize_output_image(scope, value, /*detail_override*/ None) {
|
||||
Ok(image_item) => image_item,
|
||||
Err(()) => return,
|
||||
};
|
||||
if let Some(state) = scope.get_slot::<RuntimeState>() {
|
||||
let _ = state.event_tx.send(RuntimeEvent::ContentItem(image_item));
|
||||
if let Some(text) = output_hint {
|
||||
let _ = state.event_tx.send(RuntimeEvent::ContentItem(
|
||||
FunctionCallOutputContentItem::InputText { text },
|
||||
));
|
||||
}
|
||||
}
|
||||
retval.set(v8::undefined(scope).into());
|
||||
}
|
||||
|
||||
fn generated_image_output_hint(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
value: v8::Local<'_, v8::Value>,
|
||||
) -> Result<Option<String>, String> {
|
||||
let object = v8::Local::<v8::Object>::try_from(value)
|
||||
.map_err(|_| "generatedImage expects an image generation result object".to_string())?;
|
||||
let key = v8::String::new(scope, "output_hint")
|
||||
.ok_or_else(|| "failed to allocate generatedImage helper keys".to_string())?;
|
||||
let output_hint = object
|
||||
.get(scope, key.into())
|
||||
.ok_or_else(|| "failed to read generatedImage output_hint".to_string())?;
|
||||
if output_hint.is_undefined() {
|
||||
return Ok(None);
|
||||
}
|
||||
if !output_hint.is_string() {
|
||||
return Err("generatedImage output_hint must be a string when provided".to_string());
|
||||
}
|
||||
Ok(Some(output_hint.to_rust_string_lossy(scope)))
|
||||
}
|
||||
|
||||
pub(super) fn store_callback(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
args: v8::FunctionCallbackArguments,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::RuntimeState;
|
||||
use super::callbacks::clear_timeout_callback;
|
||||
use super::callbacks::exit_callback;
|
||||
use super::callbacks::generated_image_callback;
|
||||
use super::callbacks::image_callback;
|
||||
use super::callbacks::load_callback;
|
||||
use super::callbacks::notify_callback;
|
||||
@@ -23,6 +24,7 @@ pub(super) fn install_globals(scope: &mut v8::PinScope<'_, '_>) -> Result<(), St
|
||||
let set_timeout = helper_function(scope, "setTimeout", set_timeout_callback)?;
|
||||
let text = helper_function(scope, "text", text_callback)?;
|
||||
let image = helper_function(scope, "image", image_callback)?;
|
||||
let generated_image = helper_function(scope, "generatedImage", generated_image_callback)?;
|
||||
let store = helper_function(scope, "store", store_callback)?;
|
||||
let load = helper_function(scope, "load", load_callback)?;
|
||||
let notify = helper_function(scope, "notify", notify_callback)?;
|
||||
@@ -35,6 +37,7 @@ pub(super) fn install_globals(scope: &mut v8::PinScope<'_, '_>) -> Result<(), St
|
||||
set_global(scope, global, "setTimeout", set_timeout.into())?;
|
||||
set_global(scope, global, "text", text.into())?;
|
||||
set_global(scope, global, "image", image.into())?;
|
||||
set_global(scope, global, "generatedImage", generated_image.into())?;
|
||||
set_global(scope, global, "store", store.into())?;
|
||||
set_global(scope, global, "load", load.into())?;
|
||||
set_global(scope, global, "notify", notify.into())?;
|
||||
|
||||
@@ -1563,6 +1563,44 @@ image({
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generated_image_helper_appends_image_and_output_hint() {
|
||||
let service = CodeModeService::new();
|
||||
|
||||
let response = execute(
|
||||
&service,
|
||||
ExecuteRequest {
|
||||
source: r#"
|
||||
generatedImage({
|
||||
image_url: "https://example.com/image.jpg",
|
||||
output_hint: "generated image save hint",
|
||||
});
|
||||
"#
|
||||
.to_string(),
|
||||
yield_time_ms: None,
|
||||
..execute_request("")
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
RuntimeResponse::Result {
|
||||
cell_id: cell_id("1"),
|
||||
content_items: vec![
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: "https://example.com/image.jpg".to_string(),
|
||||
detail: Some(crate::DEFAULT_IMAGE_DETAIL),
|
||||
},
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: "generated image save hint".to_string(),
|
||||
},
|
||||
],
|
||||
error_text: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn image_helper_second_arg_overrides_explicit_object_detail() {
|
||||
let service = CodeModeService::new();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user