Merge remote-tracking branch 'origin/main' into nornagon/codex/thread-artifacts-runtime

# Conflicts:
#	codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts
#	codex-rs/core/src/tools/spec_plan.rs
#	codex-rs/thread-store/src/in_memory.rs
#	codex-rs/thread-store/src/lib.rs
#	codex-rs/thread-store/src/local/update_thread_metadata.rs
#	codex-rs/thread-store/src/types.rs
This commit is contained in:
Jeremy Rose
2026-05-14 17:16:51 -07:00
828 changed files with 61257 additions and 24086 deletions
+1
View File
@@ -1,5 +1,6 @@
# Core crate ownership.
/codex-rs/core/ @openai/codex-core-agent-team
/codex-rs/ext/extension-api/ @openai/codex-core-agent-team
# Keep ownership changes reviewed by the same team.
/.github/CODEOWNERS @openai/codex-core-agent-team
+12
View File
@@ -11,6 +11,8 @@ body:
Make sure you are running the [latest](https://npmjs.com/package/@openai/codex) version of Codex CLI. The bug you are experiencing may already have been fixed.
If your version supports it, please run `codex doctor --json` and paste the output in the "Codex doctor report" field below. This helps us diagnose install, config, auth, terminal, MCP, network, and local state issues.
- type: input
id: version
attributes:
@@ -43,6 +45,16 @@ body:
description: |
Also note any multiplexer in use (screen / tmux / zellij).
E.g., VS Code, Terminal.app, iTerm2, Ghostty, Windows Terminal (WSL / PowerShell)
- type: textarea
id: doctor
attributes:
label: Codex doctor report
description: |
If available, run `codex doctor --json` and paste the full output here.
The report is designed to redact secrets, but please review it before submitting.
If your Codex version does not support `doctor`, write `not available`.
render: json
- type: textarea
id: actual
attributes:
+124
View File
@@ -0,0 +1,124 @@
{
"outputs": {
"codex-unsigned": {
"platforms": {
"macos-aarch64": {
"regex": "^codex-aarch64-apple-darwin-unsigned\\.zst$",
"path": "codex"
},
"macos-x86_64": {
"regex": "^codex-x86_64-apple-darwin-unsigned\\.zst$",
"path": "codex"
},
"linux-x86_64": {
"regex": "^codex-x86_64-unknown-linux-musl-bundle\\.tar\\.zst$",
"path": "codex"
},
"linux-aarch64": {
"regex": "^codex-aarch64-unknown-linux-musl-bundle\\.tar\\.zst$",
"path": "codex"
},
"windows-x86_64": {
"regex": "^codex-x86_64-pc-windows-msvc\\.exe\\.zst$",
"path": "codex.exe"
},
"windows-aarch64": {
"regex": "^codex-aarch64-pc-windows-msvc\\.exe\\.zst$",
"path": "codex.exe"
}
}
},
"codex-app-server-unsigned": {
"platforms": {
"macos-aarch64": {
"regex": "^codex-app-server-aarch64-apple-darwin-unsigned\\.zst$",
"path": "codex-app-server"
},
"macos-x86_64": {
"regex": "^codex-app-server-x86_64-apple-darwin-unsigned\\.zst$",
"path": "codex-app-server"
},
"linux-x86_64": {
"regex": "^codex-app-server-x86_64-unknown-linux-musl\\.zst$",
"path": "codex-app-server"
},
"linux-aarch64": {
"regex": "^codex-app-server-aarch64-unknown-linux-musl\\.zst$",
"path": "codex-app-server"
},
"windows-x86_64": {
"regex": "^codex-app-server-x86_64-pc-windows-msvc\\.exe\\.zst$",
"path": "codex-app-server.exe"
},
"windows-aarch64": {
"regex": "^codex-app-server-aarch64-pc-windows-msvc\\.exe\\.zst$",
"path": "codex-app-server.exe"
}
}
},
"codex-responses-api-proxy-unsigned": {
"platforms": {
"macos-aarch64": {
"regex": "^codex-responses-api-proxy-aarch64-apple-darwin-unsigned\\.zst$",
"path": "codex-responses-api-proxy"
},
"macos-x86_64": {
"regex": "^codex-responses-api-proxy-x86_64-apple-darwin-unsigned\\.zst$",
"path": "codex-responses-api-proxy"
},
"linux-x86_64": {
"regex": "^codex-responses-api-proxy-x86_64-unknown-linux-musl\\.zst$",
"path": "codex-responses-api-proxy"
},
"linux-aarch64": {
"regex": "^codex-responses-api-proxy-aarch64-unknown-linux-musl\\.zst$",
"path": "codex-responses-api-proxy"
},
"windows-x86_64": {
"regex": "^codex-responses-api-proxy-x86_64-pc-windows-msvc\\.exe\\.zst$",
"path": "codex-responses-api-proxy.exe"
},
"windows-aarch64": {
"regex": "^codex-responses-api-proxy-aarch64-pc-windows-msvc\\.exe\\.zst$",
"path": "codex-responses-api-proxy.exe"
}
}
},
"bwrap": {
"platforms": {
"linux-x86_64": {
"regex": "^bwrap-x86_64-unknown-linux-musl\\.zst$",
"path": "bwrap"
},
"linux-aarch64": {
"regex": "^bwrap-aarch64-unknown-linux-musl\\.zst$",
"path": "bwrap"
}
}
},
"codex-command-runner": {
"platforms": {
"windows-x86_64": {
"regex": "^codex-command-runner-x86_64-pc-windows-msvc\\.exe\\.zst$",
"path": "codex-command-runner.exe"
},
"windows-aarch64": {
"regex": "^codex-command-runner-aarch64-pc-windows-msvc\\.exe\\.zst$",
"path": "codex-command-runner.exe"
}
}
},
"codex-windows-sandbox-setup": {
"platforms": {
"windows-x86_64": {
"regex": "^codex-windows-sandbox-setup-x86_64-pc-windows-msvc\\.exe\\.zst$",
"path": "codex-windows-sandbox-setup.exe"
},
"windows-aarch64": {
"regex": "^codex-windows-sandbox-setup-aarch64-pc-windows-msvc\\.exe\\.zst$",
"path": "codex-windows-sandbox-setup.exe"
}
}
}
}
}
+116 -17
View File
@@ -17,10 +17,10 @@ concurrency:
cancel-in-progress: ${{ github.ref_name != 'main' }}
jobs:
test:
# PRs use a fast Windows cross-compiled test leg for pre-merge signal.
# Post-merge pushes to main also run the native Windows test job below for
# broader Windows signal without putting PR latency back on the critical
# path. Cargo CI owns V8/code-mode test coverage for now.
# 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.
timeout-minutes: 30
strategy:
fail-fast: false
@@ -44,12 +44,6 @@ jobs:
# - os: ubuntu-24.04-arm
# target: aarch64-unknown-linux-gnu
# Windows fast path: build the windows-gnullvm binaries with Linux
# RBE, then run the resulting Windows tests on the Windows runner.
# Cargo CI preserves V8/code-mode coverage while Bazel CI keeps broad
# non-code-mode signal.
- os: windows-latest
target: x86_64-pc-windows-gnullvm
runs-on: ${{ matrix.os }}
# Configure a human readable name for each job
@@ -108,13 +102,6 @@ jobs:
--test_verbose_timeout_warnings
--build_metadata=COMMIT_SHA=${GITHUB_SHA}
)
if [[ "${RUNNER_OS}" == "Windows" ]]; then
bazel_wrapper_args+=(
--windows-cross-compile
--remote-download-toplevel
)
fi
./.github/scripts/run-bazel-ci.sh \
"${bazel_wrapper_args[@]}" \
-- \
@@ -141,6 +128,118 @@ jobs:
path: ${{ steps.prepare_bazel.outputs.repository-cache-path }}
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.
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
shard:
- 1
- 2
- 3
- 4
runs-on: windows-latest
name: Bazel test on windows-latest for x86_64-pc-windows-gnullvm shard ${{ matrix.shard }}/4
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
- name: Prepare Bazel CI
id: prepare_bazel
uses: ./.github/actions/prepare-bazel-ci
with:
target: x86_64-pc-windows-gnullvm
# Reuse the former monolithic Windows test cache for restores. Do
# not save it from every shard below; duplicate uploads would sit on
# the PR-blocking critical path after the useful test work is done.
cache-scope: bazel-test
install-test-prereqs: "true"
- name: bazel test shard
env:
BAZEL_TEST_SHARD: ${{ matrix.shard }}
BAZEL_TEST_SHARD_COUNT: 4
BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }}
shell: bash
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(//...))'
mapfile -t bazel_targets < <(
MSYS2_ARG_CONV_EXCL='*' bazel query --output=label "${bazel_test_query}" \
| LC_ALL=C sort
)
selected_targets=()
for bazel_target in "${bazel_targets[@]}"; do
target_bucket="$(
printf '%s\n' "${bazel_target}" \
| cksum \
| awk -v shard_count="${BAZEL_TEST_SHARD_COUNT}" '{ print ($1 % shard_count) + 1 }'
)"
if [[ "${target_bucket}" == "${BAZEL_TEST_SHARD}" ]]; then
selected_targets+=("${bazel_target}")
fi
done
if [[ ${#selected_targets[@]} -eq 0 ]]; then
echo "No Bazel test targets selected for Windows shard ${BAZEL_TEST_SHARD}/${BAZEL_TEST_SHARD_COUNT}." >&2
exit 1
fi
echo "Selected ${#selected_targets[@]} of ${#bazel_targets[@]} Bazel test targets for Windows shard ${BAZEL_TEST_SHARD}/${BAZEL_TEST_SHARD_COUNT}."
bazel_test_args=(
test
--skip_incompatible_explicit_targets
--test_tag_filters=-argument-comment-lint
--test_verbose_timeout_warnings
--build_metadata=COMMIT_SHA=${GITHUB_SHA}
--build_metadata=TAG_windows_test_shard=${BAZEL_TEST_SHARD}
)
./.github/scripts/run-bazel-ci.sh \
--print-failed-action-summary \
--print-failed-test-logs \
--windows-cross-compile \
--remote-download-toplevel \
-- \
"${bazel_test_args[@]}" \
-- \
"${selected_targets[@]}"
- name: Upload Bazel execution logs
if: always() && !cancelled()
continue-on-error: true
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: bazel-execution-logs-test-x86_64-pc-windows-gnullvm-shard-${{ matrix.shard }}
path: ${{ runner.temp }}/bazel-execution-logs
if-no-files-found: ignore
test-windows:
# Preserve the existing required-check surface while the real work happens
# in the sharded Windows jobs above.
if: always()
needs: test-windows-shard
runs-on: ubuntu-24.04
name: Bazel test on windows-latest for x86_64-pc-windows-gnullvm
steps:
- name: Confirm Windows Bazel test shards passed
shell: bash
run: |
if [[ "${{ needs.test-windows-shard.result }}" != "success" ]]; then
echo "Windows Bazel test shards finished with result: ${{ needs.test-windows-shard.result }}" >&2
exit 1
fi
test-windows-native-main:
# Native Windows Bazel tests are slower and frequently approach the
# 30-minute PR budget. Run this only for post-merge commits to main and give
-153
View File
@@ -1,153 +0,0 @@
name: Issue Labeler
on:
issues:
types:
- opened
- labeled
jobs:
gather-labels:
name: Generate label suggestions
# Prevent runs on forks (requires OpenAI API key, wastes Actions minutes)
if: github.repository == 'openai/codex' && (github.event.action == 'opened' || (github.event.action == 'labeled' && github.event.label.name == 'codex-label'))
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
codex_output: ${{ steps.codex.outputs.final-message }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- id: codex
uses: openai/codex-action@5c3f4ccdb2b8790f73d6b21751ac00e602aa0c02 # v1.7
with:
openai-api-key: ${{ secrets.CODEX_OPENAI_API_KEY }}
allow-users: "*"
prompt: |
You are an assistant that reviews GitHub issues for the repository.
Your job is to choose the most appropriate labels for the issue described later in this prompt.
Follow these rules:
- Add one (and only one) of the following three labels to distinguish the type of issue. Default to "bug" if unsure.
1. bug — Reproducible defects in Codex products (CLI, VS Code extension, web, auth).
2. enhancement — Feature requests or usability improvements that ask for new capabilities, better ergonomics, or quality-of-life tweaks.
3. documentation — Updates or corrections needed in docs/README/config references (broken links, missing examples, outdated keys, clarification requests).
- If applicable, add one of the following labels to specify which sub-product or product surface the issue relates to.
1. CLI — the Codex command line interface.
2. extension — VS Code (or other IDE) extension-specific issues.
3. app - Issues related to the Codex desktop application.
4. codex-web — Issues targeting the Codex web UI/Cloud experience.
5. github-action — Issues with the Codex GitHub action.
6. iOS — Issues with the Codex iOS app.
- Additionally add zero or more of the following labels that are relevant to the issue content. Prefer a small set of precise labels over many broad ones.
- For agent-area issues, prefer the most specific applicable label. Use "agent" only as a fallback for agent-related issues that do not fit a more specific agent-area label. Prefer "app-server" over "session" or "config" when the issue is about app-server protocol, API, RPC, schema, launch, or bridge behavior. Use "memory" for agentic memory storage/retrieval and "performance" for high process memory utilization or memory leaks.
1. windows-os — Bugs or friction specific to Windows environments (always when PowerShell is mentioned, path handling, copy/paste, OS-specific auth or tooling failures).
2. mcp — Topics involving Model Context Protocol servers/clients.
3. mcp-server — Problems related to the codex mcp-server command, where codex runs as an MCP server.
4. azure — Problems or requests tied to Azure OpenAI deployments.
5. model-behavior — Undesirable LLM behavior: forgetting goals, refusing work, hallucinating environment details, quota misreports, or other reasoning/performance anomalies.
6. code-review — Issues related to the code review feature or functionality.
7. safety-check - Issues related to cyber risk detection or trusted access verification.
8. auth - Problems related to authentication, login, or access tokens.
9. exec - Problems related to the "codex exec" command or functionality.
10. hooks - Problems related to event hooks
11. context - Problems related to compaction, context windows, or available context reporting.
12. skills - Problems related to skills or plugins
13. custom-model - Problems that involve using custom model providers, local models, or OSS models.
14. rate-limits - Problems related to token limits, rate limits, or token usage reporting.
15. sandbox - Issues related to local sandbox environments or tool call approvals to override sandbox restrictions.
16. tool-calls - Problems related to specific tool call invocations including unexpected errors, failures, or hangs.
17. TUI - Problems with the terminal user interface (TUI) including keyboard shortcuts, copy & pasting, menus, or screen update issues.
18. app-server - Issues involving the app-server protocol or interfaces, including SDK/API payloads, thread/* and turn/* RPCs, app-server launch behavior, external app/controller bridges, and app-server protocol/schema behavior.
19. connectivity - Network connectivity or endpoint issues, including reconnecting messages, stream dropped/disconnected errors, websocket/SSE/transport failures, timeout/network/VPN/proxy/API endpoint failures, and related retry behavior.
20. subagent - Issues involving subagents, sub-agents, or multi-agent behavior, including spawn_agent, wait_agent, close_agent, worker/explorer roles, delegation, agent teams, lifecycle, model/config inheritance, quotas, and orchestration.
21. session - Issues involving session or thread management, including resume, fork, archive, rename/title, thread history, rollout persistence, compaction, checkpoints, retention, and cross-session state.
22. config - Issues involving config.toml, config keys, config key merging, config updates, profiles, hooks config, project config, agent role TOMLs, instruction/personality config, and config schema behavior.
23. plan - Issues involving plan mode, planning workflows, or plan-specific tools/behavior.
24. computer-use - Issues involving agentic computer use or SkyComputerUseService.
25. browser - Issues involving agentic browser use, IAB, or the built-in browser within the Codex app.
26. memory - Issues involving agentic memory storage and retrieval.
27. imagen - Issues involving image generation.
28. remote - Issues involving remote access, remote control, or SSH.
29. performance - Issues involving slow, laggy performance, high memory utilization, or memory leaks.
30. automations - Issues involving scheduled automation tasks or heartbeats.
31. pets - Issues involving pets avatars and animations.
32. agent - Fallback only for core agent loop or agent-related issues that do not fit app-server, connectivity, subagent, session, config, plan, computer-use, browser, memory, imagen, remote, performance, automations, or pets.
Issue number: ${{ github.event.issue.number }}
Issue title:
${{ github.event.issue.title }}
Issue body:
${{ github.event.issue.body }}
Repository full name:
${{ github.repository }}
output-schema: |
{
"type": "object",
"properties": {
"labels": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": ["labels"],
"additionalProperties": false
}
apply-labels:
name: Apply labels from Codex output
needs: gather-labels
if: ${{ needs.gather-labels.result != 'skipped' }}
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
CODEX_OUTPUT: ${{ needs.gather-labels.outputs.codex_output }}
steps:
- name: Apply labels
run: |
json=${CODEX_OUTPUT//$'\r'/}
if [ -z "$json" ]; then
echo "Codex produced no output. Skipping label application."
exit 0
fi
if ! printf '%s' "$json" | jq -e 'type == "object" and (.labels | type == "array")' >/dev/null 2>&1; then
echo "Codex output did not include a labels array. Raw output: $json"
exit 0
fi
labels=$(printf '%s' "$json" | jq -r '.labels[] | tostring')
if [ -z "$labels" ]; then
echo "Codex returned an empty array. Nothing to do."
exit 0
fi
cmd=(gh issue edit "$ISSUE_NUMBER")
while IFS= read -r label; do
cmd+=(--add-label "$label")
done <<< "$labels"
"${cmd[@]}" || true
- name: Remove codex-label trigger
if: ${{ always() && github.event.action == 'labeled' && github.event.label.name == 'codex-label' }}
run: |
gh issue edit "$ISSUE_NUMBER" --remove-label codex-label || true
echo "Attempted to remove label: codex-label"
@@ -16,6 +16,9 @@ jobs:
prepare:
# Prevent scheduled runs on forks (no secrets, wastes Actions minutes)
if: github.repository == 'openai/codex'
environment:
name: rust-release-prepare
deployment: false
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+116 -10
View File
@@ -10,6 +10,13 @@ on:
push:
tags:
- "rust-v*.*.*"
workflow_dispatch:
inputs:
sign_macos:
description: "Sign and notarize macOS release artifacts."
required: false
type: boolean
default: true
concurrency:
group: ${{ github.workflow }}
@@ -25,10 +32,17 @@ jobs:
- uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0
- name: Validate tag matches Cargo.toml version
shell: bash
env:
SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' || inputs.sign_macos }}
run: |
set -euo pipefail
echo "::group::Tag validation"
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${SIGN_MACOS}" == "true" ]]; then
echo "❌ Manual rust-release runs must set sign_macos=false"
exit 1
fi
# 1. Must be a tag and match the regex
[[ "${GITHUB_REF_TYPE}" == "tag" ]] \
|| { echo "❌ Not a tag push"; exit 1; }
@@ -64,6 +78,7 @@ jobs:
# 2026-03-04: temporarily change releases to use thin LTO because
# Ubuntu ARM is timing out at 60 minutes.
CARGO_PROFILE_RELEASE_LTO: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'thin' }}
SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' || inputs.sign_macos }}
strategy:
fail-fast: false
@@ -295,6 +310,39 @@ jobs:
path: codex-rs/target/**/cargo-timings/cargo-timing.html
if-no-files-found: warn
- if: ${{ runner.os == 'macOS' }}
name: Stage unsigned macOS artifacts
shell: bash
run: |
set -euo pipefail
target="${{ matrix.target }}"
release_dir="target/${target}/release"
dest="unsigned-dist/${target}"
mkdir -p "$dest"
for binary in ${{ matrix.binaries }}; do
binary_path="${release_dir}/${binary}"
unsigned_name="${binary}-${target}-unsigned"
unsigned_path="${dest}/${unsigned_name}"
if [[ ! -f "${binary_path}" ]]; then
echo "Binary ${binary_path} not found"
exit 1
fi
cp "${binary_path}" "${unsigned_path}"
tar -C "$dest" -czf "${unsigned_path}.tar.gz" "${unsigned_name}"
zstd -T0 -19 --rm "${unsigned_path}"
done
- if: ${{ runner.os == 'macOS' }}
name: Upload unsigned macOS artifacts
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: ${{ matrix.artifact_name }}-unsigned
path: codex-rs/unsigned-dist/${{ matrix.target }}/*
if-no-files-found: error
- if: ${{ contains(matrix.target, 'linux') }}
name: Cosign Linux artifacts
uses: ./.github/actions/linux-code-sign
@@ -303,7 +351,7 @@ jobs:
artifacts-dir: ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release
binaries: ${{ matrix.binaries }}
- if: ${{ runner.os == 'macOS' }}
- if: ${{ runner.os == 'macOS' && env.SIGN_MACOS == 'true' }}
name: MacOS code signing (binaries)
uses: ./.github/actions/macos-code-sign
with:
@@ -317,7 +365,7 @@ jobs:
apple-notarization-key-id: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }}
apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }}
- if: ${{ runner.os == 'macOS' && matrix.build_dmg == 'true' }}
- if: ${{ runner.os == 'macOS' && matrix.build_dmg == 'true' && env.SIGN_MACOS == 'true' }}
name: Build macOS dmg
shell: bash
run: |
@@ -357,7 +405,7 @@ jobs:
exit 1
fi
- if: ${{ runner.os == 'macOS' && matrix.build_dmg == 'true' }}
- if: ${{ runner.os == 'macOS' && matrix.build_dmg == 'true' && env.SIGN_MACOS == 'true' }}
name: MacOS code signing (dmg)
uses: ./.github/actions/macos-code-sign
with:
@@ -371,6 +419,7 @@ jobs:
apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }}
- name: Stage artifacts
if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }}
shell: bash
run: |
dest="dist/${{ matrix.target }}"
@@ -400,7 +449,7 @@ jobs:
fi
- name: Build Python runtime wheel
if: ${{ matrix.bundle == 'primary' }}
if: ${{ matrix.bundle == 'primary' && (runner.os != 'macOS' || env.SIGN_MACOS == 'true') }}
shell: bash
run: |
set -euo pipefail
@@ -451,7 +500,7 @@ jobs:
"${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir"
- name: Upload Python runtime wheel
if: ${{ matrix.bundle == 'primary' }}
if: ${{ matrix.bundle == 'primary' && (runner.os != 'macOS' || env.SIGN_MACOS == 'true') }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: python-runtime-wheel-${{ matrix.target }}
@@ -459,6 +508,7 @@ jobs:
if-no-files-found: error
- name: Compress artifacts
if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }}
shell: bash
run: |
# Path that contains the uncompressed binaries for the current
@@ -495,6 +545,7 @@ jobs:
done
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }}
with:
name: ${{ matrix.artifact_name }}
# Upload the per-binary .zst files, .tar.gz equivalents, and any
@@ -532,9 +583,12 @@ jobs:
permissions:
contents: write
actions: read
env:
SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' || inputs.sign_macos }}
outputs:
version: ${{ steps.release_name.outputs.name }}
tag: ${{ github.ref_name }}
sign_macos: ${{ steps.release_mode.outputs.sign_macos }}
should_publish_npm: ${{ steps.npm_publish_settings.outputs.should_publish }}
npm_tag: ${{ steps.npm_publish_settings.outputs.npm_tag }}
should_publish_python_runtime: ${{ steps.python_runtime_publish_settings.outputs.should_publish }}
@@ -545,6 +599,11 @@ jobs:
with:
persist-credentials: false
- name: Define release mode
id: release_mode
run: |
echo "sign_macos=${SIGN_MACOS}" >> "$GITHUB_OUTPUT"
- name: Generate release notes from tag commit message
id: release_notes
shell: bash
@@ -572,6 +631,24 @@ jobs:
- name: List
run: ls -R dist/
- name: Prune artifacts excluded from unsigned macOS release
if: ${{ env.SIGN_MACOS == 'false' }}
run: |
find dist -mindepth 1 -maxdepth 1 -type d \
! -name '*-apple-darwin*-unsigned' \
! -name 'aarch64-unknown-linux-musl' \
! -name 'aarch64-unknown-linux-musl-app-server' \
! -name 'x86_64-unknown-linux-musl' \
! -name 'x86_64-unknown-linux-musl-app-server' \
! -name 'aarch64-pc-windows-msvc' \
! -name 'x86_64-pc-windows-msvc' \
-exec rm -rf {} +
if ! find dist -type f -name '*-apple-darwin*-unsigned*' | grep -q .; then
echo "No unsigned macOS artifacts found in downloaded workflow artifacts."
exit 1
fi
- name: Delete entries from dist/ that should not go in the release
run: |
rm -rf dist/windows-binaries*
@@ -603,6 +680,12 @@ jobs:
set -euo pipefail
version="${VERSION}"
if [[ "${SIGN_MACOS}" != "true" ]]; then
echo "should_publish=false" >> "$GITHUB_OUTPUT"
echo "npm_tag=" >> "$GITHUB_OUTPUT"
exit 0
fi
if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "should_publish=true" >> "$GITHUB_OUTPUT"
echo "npm_tag=" >> "$GITHUB_OUTPUT"
@@ -622,6 +705,11 @@ jobs:
set -euo pipefail
version="${VERSION}"
if [[ "${SIGN_MACOS}" != "true" ]]; then
echo "should_publish=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "should_publish=true" >> "$GITHUB_OUTPUT"
elif [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then
@@ -631,21 +719,25 @@ jobs:
fi
- name: Setup pnpm
if: ${{ env.SIGN_MACOS == 'true' }}
uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5
with:
run_install: false
- name: Setup Node.js for npm packaging
if: ${{ env.SIGN_MACOS == 'true' }}
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
- name: Install dependencies
if: ${{ env.SIGN_MACOS == 'true' }}
run: pnpm install --frozen-lockfile
# stage_npm_packages.py requires DotSlash when staging releases.
- uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2
- name: Stage npm packages
if: ${{ env.SIGN_MACOS == 'true' }}
env:
GH_TOKEN: ${{ github.token }}
RELEASE_VERSION: ${{ steps.release_name.outputs.name }}
@@ -657,6 +749,7 @@ jobs:
--package codex-sdk
- name: Stage installer scripts
if: ${{ env.SIGN_MACOS == 'true' }}
run: |
cp scripts/install/install.sh dist/install.sh
cp scripts/install/install.ps1 dist/install.ps1
@@ -668,25 +761,37 @@ jobs:
tag_name: ${{ github.ref_name }}
body_path: ${{ steps.release_notes.outputs.path }}
files: dist/**
make_latest: ${{ env.SIGN_MACOS == 'true' && !contains(steps.release_name.outputs.name, '-') }}
# Mark as prerelease only when the version has a suffix after x.y.z
# (e.g. -alpha, -beta). Otherwise publish a normal release.
prerelease: ${{ contains(steps.release_name.outputs.name, '-') }}
- uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2
- if: ${{ env.SIGN_MACOS == 'true' }}
uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag: ${{ github.ref_name }}
config: .github/dotslash-config.json
- uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2
- if: ${{ env.SIGN_MACOS == 'false' }}
uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag: ${{ github.ref_name }}
config: .github/dotslash-unsigned-config.json
- if: ${{ env.SIGN_MACOS == 'true' }}
uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag: ${{ github.ref_name }}
config: .github/dotslash-zsh-config.json
- uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2
- if: ${{ env.SIGN_MACOS == 'true' }}
uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@@ -696,7 +801,7 @@ jobs:
- name: Trigger developers.openai.com deploy
# Only trigger the deploy if the release is not a pre-release.
# The deploy is used to update the developers.openai.com website with the new config schema json file.
if: ${{ !contains(steps.release_name.outputs.name, '-') }}
if: ${{ env.SIGN_MACOS == 'true' && !contains(steps.release_name.outputs.name, '-') }}
continue-on-error: true
env:
DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL: ${{ secrets.DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL }}
@@ -910,7 +1015,7 @@ jobs:
needs: release
# Only publish stable/mainline releases to WinGet; pre-releases include a
# '-' in the semver string (e.g., 1.2.3-alpha.1).
if: ${{ !contains(needs.release.outputs.version, '-') }}
if: ${{ needs.release.outputs.sign_macos == 'true' && !contains(needs.release.outputs.version, '-') }}
# This job only invokes a GitHub Action to open/update the winget-pkgs PR;
# it does not execute Windows-only tooling, so Linux is sufficient.
runs-on: ubuntu-latest
@@ -930,6 +1035,7 @@ jobs:
update-branch:
name: Update latest-alpha-cli branch
if: ${{ needs.release.outputs.sign_macos == 'true' }}
permissions:
contents: write
needs: release
+2
View File
@@ -36,6 +36,8 @@ jobs:
python -m venv /tmp/uv
/tmp/uv/bin/python -m pip install uv==0.11.3
/tmp/uv/bin/uv sync --extra dev --frozen
/tmp/uv/bin/uv run --extra dev ruff check --output-format=github .
/tmp/uv/bin/uv run --extra dev ruff format --check .
/tmp/uv/bin/uv run --extra dev pytest
'
+1
View File
@@ -1,6 +1,7 @@
{
"recommendations": [
"rust-lang.rust-analyzer",
"charliermarsh.ruff",
"tamasfe.even-better-toml",
"vadimcn.vscode-lldb",
+8
View File
@@ -12,6 +12,14 @@
"editor.defaultFormatter": "tamasfe.even-better-toml",
"editor.formatOnSave": true,
},
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.ruff": "explicit",
"source.organizeImports.ruff": "explicit",
},
},
// Array order for options in ~/.codex/config.toml such as `notify` and the
// `args` for an MCP server is significant, so we disable reordering.
"evenBetterToml.formatter.reorderArrays": false,
+1 -1
View File
@@ -887,7 +887,6 @@
"enum-as-inner_0.6.1": "{\"dependencies\":[{\"name\":\"heck\",\"req\":\"^0.5\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"name\":\"syn\",\"req\":\"^2.0\"}],\"features\":{}}",
"enumflags2_0.7.12": "{\"dependencies\":[{\"name\":\"enumflags2_derive\",\"req\":\"=0.7.12\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.0\"}],\"features\":{\"std\":[]}}",
"enumflags2_derive_0.7.12": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"parsing\",\"printing\",\"derive\",\"proc-macro\"],\"name\":\"syn\",\"req\":\"^2.0\"}],\"features\":{}}",
"env-flags_0.1.1": "{\"dependencies\":[],\"features\":{}}",
"env_filter_1.0.0": "{\"dependencies\":[{\"features\":[\"std\"],\"name\":\"log\",\"req\":\"^0.4.8\"},{\"default_features\":false,\"features\":[\"std\",\"perf\"],\"name\":\"regex\",\"optional\":true,\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6\"}],\"features\":{\"default\":[\"regex\"],\"regex\":[\"dep:regex\"]}}",
"env_filter_1.0.1": "{\"dependencies\":[{\"features\":[\"std\"],\"name\":\"log\",\"req\":\"^0.4.29\"},{\"default_features\":false,\"features\":[\"std\",\"perf\"],\"name\":\"regex\",\"optional\":true,\"req\":\"^1.12.3\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"regex\"],\"regex\":[\"dep:regex\"]}}",
"env_home_0.1.0": "{\"dependencies\":[],\"features\":{}}",
@@ -1483,6 +1482,7 @@
"serde_derive_1.0.228": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"proc-macro\"],\"name\":\"proc-macro2\",\"req\":\"^1.0.74\"},{\"default_features\":false,\"features\":[\"proc-macro\"],\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"clone-impls\",\"derive\",\"parsing\",\"printing\",\"proc-macro\"],\"name\":\"syn\",\"req\":\"^2.0.81\"}],\"features\":{\"default\":[],\"deserialize_in_place\":[]}}",
"serde_derive_internals_0.29.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.74\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"default_features\":false,\"features\":[\"clone-impls\",\"derive\",\"parsing\",\"printing\"],\"name\":\"syn\",\"req\":\"^2.0.46\"}],\"features\":{}}",
"serde_html_form_0.3.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert_matches2\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"divan\",\"req\":\"^0.1.11\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"form_urlencoded\",\"req\":\"^1.0.1\"},{\"default_features\":false,\"name\":\"indexmap\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.45.0\"},{\"name\":\"itoa\",\"req\":\"^1.0.1\"},{\"name\":\"ryu\",\"optional\":true,\"req\":\"^1.0.9\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.221\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_core\",\"req\":\"^1.0.221\"},{\"kind\":\"dev\",\"name\":\"serde_urlencoded\",\"req\":\"^0.7.1\"}],\"features\":{\"default\":[\"ryu\",\"std\"],\"std\":[]}}",
"serde_ignored_0.1.14": "{\"dependencies\":[{\"default_features\":false,\"name\":\"serde\",\"req\":\"^1.0.220\",\"target\":\"cfg(any())\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_core\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.110\"}],\"features\":{}}",
"serde_json_1.0.149": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1.0.11\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.2.3\"},{\"kind\":\"dev\",\"name\":\"indoc\",\"req\":\"^2.0.2\"},{\"name\":\"itoa\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"ref-cast\",\"req\":\"^1.0.18\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.13\"},{\"default_features\":false,\"name\":\"serde\",\"req\":\"^1.0.220\",\"target\":\"cfg(any())\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.194\"},{\"kind\":\"dev\",\"name\":\"serde_bytes\",\"req\":\"^0.11.10\"},{\"default_features\":false,\"name\":\"serde_core\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.166\"},{\"kind\":\"dev\",\"name\":\"serde_stacker\",\"req\":\"^0.1.8\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.108\"},{\"name\":\"zmij\",\"req\":\"^1.0\"}],\"features\":{\"alloc\":[\"serde_core/alloc\"],\"arbitrary_precision\":[],\"default\":[\"std\"],\"float_roundtrip\":[],\"preserve_order\":[\"indexmap\",\"std\"],\"raw_value\":[],\"std\":[\"memchr/std\",\"serde_core/std\"],\"unbounded_depth\":[]}}",
"serde_path_to_error_0.1.20": "{\"dependencies\":[{\"name\":\"itoa\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde\",\"req\":\"^1.0.220\",\"target\":\"cfg(any())\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_core\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.100\"}],\"features\":{}}",
"serde_repr_0.1.20": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.74\"},{\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.13\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.166\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.100\"},{\"name\":\"syn\",\"req\":\"^2.0.46\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.81\"}],\"features\":{}}",
+2 -1
View File
@@ -2,7 +2,7 @@
// Unified entry point for the Codex CLI.
import { spawn } from "node:child_process";
import { existsSync } from "fs";
import { existsSync, realpathSync } from "fs";
import { createRequire } from "node:module";
import path from "path";
import { fileURLToPath } from "url";
@@ -171,6 +171,7 @@ const packageManagerEnvVar =
? "CODEX_MANAGED_BY_BUN"
: "CODEX_MANAGED_BY_NPM";
env[packageManagerEnvVar] = "1";
env.CODEX_MANAGED_PACKAGE_ROOT = realpathSync(path.join(__dirname, ".."));
const child = spawn(binaryPath, process.argv.slice(2), {
stdio: "inherit",
+86 -36
View File
@@ -1145,6 +1145,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
dependencies = [
"axum-core",
"base64 0.22.1",
"bytes",
"form_urlencoded",
"futures-util",
@@ -1163,8 +1164,10 @@ dependencies = [
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sha1",
"sync_wrapper",
"tokio",
"tokio-tungstenite",
"tower",
"tower-layer",
"tower-service",
@@ -1899,8 +1902,8 @@ dependencies = [
"codex-feedback",
"codex-file-search",
"codex-file-watcher",
"codex-git-attribution",
"codex-git-utils",
"codex-guardian",
"codex-hooks",
"codex-login",
"codex-mcp",
@@ -1918,7 +1921,6 @@ dependencies = [
"codex-state",
"codex-thread-store",
"codex-tools",
"codex-uds",
"codex-utils-absolute-path",
"codex-utils-cargo-bin",
"codex-utils-cli",
@@ -1927,13 +1929,16 @@ dependencies = [
"core_test_support",
"flate2",
"futures",
"hmac",
"opentelemetry",
"opentelemetry_sdk",
"pretty_assertions",
"reqwest",
"rmcp",
"serde",
"serde_json",
"serial_test",
"sha2",
"shlex",
"tar",
"tempfile",
@@ -1964,6 +1969,8 @@ dependencies = [
"codex-exec-server",
"codex-feedback",
"codex-protocol",
"codex-uds",
"codex-utils-absolute-path",
"codex-utils-rustls-provider",
"futures",
"pretty_assertions",
@@ -2050,8 +2057,11 @@ dependencies = [
name = "codex-app-server-transport"
version = "0.0.0"
dependencies = [
"anyhow",
"axum",
"base64 0.22.1",
"chrono",
"clap",
"codex-api",
"codex-app-server-protocol",
"codex-config",
@@ -2062,12 +2072,18 @@ dependencies = [
"codex-uds",
"codex-utils-absolute-path",
"codex-utils-rustls-provider",
"constant_time_eq 0.3.1",
"futures",
"gethostname",
"hmac",
"jsonwebtoken",
"owo-colors",
"pretty_assertions",
"serde",
"serde_json",
"sha2",
"tempfile",
"time",
"tokio",
"tokio-tungstenite",
"tokio-util",
@@ -2204,6 +2220,7 @@ dependencies = [
"assert_matches",
"clap",
"clap_complete",
"codex-api",
"codex-app-server",
"codex-app-server-daemon",
"codex-app-server-protocol",
@@ -2218,11 +2235,14 @@ dependencies = [
"codex-exec-server",
"codex-execpolicy",
"codex-features",
"codex-install-context",
"codex-login",
"codex-mcp",
"codex-mcp-server",
"codex-memories-write",
"codex-model-provider",
"codex-models-manager",
"codex-plugin",
"codex-protocol",
"codex-responses-api-proxy",
"codex-rmcp-client",
@@ -2237,11 +2257,14 @@ dependencies = [
"codex-utils-cli",
"codex-utils-path",
"codex-windows-sandbox",
"crossterm",
"http 1.4.0",
"libc",
"owo-colors",
"predicates",
"pretty_assertions",
"regex-lite",
"serde",
"serde_json",
"sqlx",
"supports-color 3.0.2",
@@ -2415,6 +2438,7 @@ dependencies = [
"prost 0.14.3",
"schemars 0.8.22",
"serde",
"serde_ignored",
"serde_json",
"serde_path_to_error",
"sha2",
@@ -2501,7 +2525,6 @@ dependencies = [
"codex-terminal-detection",
"codex-test-binary-support",
"codex-thread-store",
"codex-tool-api",
"codex-tools",
"codex-utils-absolute-path",
"codex-utils-cache",
@@ -2512,7 +2535,6 @@ dependencies = [
"codex-utils-path",
"codex-utils-plugins",
"codex-utils-pty",
"codex-utils-readiness",
"codex-utils-stream-parser",
"codex-utils-string",
"codex-utils-template",
@@ -2522,7 +2544,6 @@ dependencies = [
"ctor 0.6.3",
"dirs",
"dunce",
"env-flags",
"eventsource-stream",
"futures",
"http 1.4.0",
@@ -2722,6 +2743,7 @@ dependencies = [
"anyhow",
"arc-swap",
"async-trait",
"axum",
"base64 0.22.1",
"bytes",
"codex-app-server-protocol",
@@ -2732,9 +2754,11 @@ dependencies = [
"codex-test-binary-support",
"codex-utils-absolute-path",
"codex-utils-pty",
"codex-utils-rustls-provider",
"ctor 0.6.3",
"futures",
"pretty_assertions",
"prost 0.14.3",
"reqwest",
"serde",
"serde_json",
@@ -2802,7 +2826,7 @@ name = "codex-extension-api"
version = "0.0.0"
dependencies = [
"codex-protocol",
"codex-tool-api",
"codex-tools",
]
[[package]]
@@ -2894,16 +2918,6 @@ dependencies = [
"tracing",
]
[[package]]
name = "codex-git-attribution"
version = "0.0.0"
dependencies = [
"codex-core",
"codex-extension-api",
"codex-features",
"pretty_assertions",
]
[[package]]
name = "codex-git-utils"
version = "0.0.0"
@@ -2928,6 +2942,15 @@ dependencies = [
"walkdir",
]
[[package]]
name = "codex-guardian"
version = "0.0.0"
dependencies = [
"codex-core",
"codex-extension-api",
"codex-protocol",
]
[[package]]
name = "codex-hooks"
version = "0.0.0"
@@ -3111,6 +3134,27 @@ dependencies = [
"wiremock",
]
[[package]]
name = "codex-memories-extension"
version = "0.0.0"
dependencies = [
"async-trait",
"codex-core",
"codex-extension-api",
"codex-features",
"codex-memories-read",
"codex-tools",
"codex-utils-absolute-path",
"codex-utils-output-truncation",
"pretty_assertions",
"schemars 0.8.22",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.18",
"tokio",
]
[[package]]
name = "codex-memories-mcp"
version = "0.0.0"
@@ -3435,6 +3479,7 @@ version = "0.0.0"
dependencies = [
"anyhow",
"axum",
"base64 0.22.1",
"bytes",
"codex-api",
"codex-client",
@@ -3498,6 +3543,7 @@ dependencies = [
"anyhow",
"codex-code-mode",
"codex-protocol",
"http 1.4.0",
"pretty_assertions",
"serde",
"serde_json",
@@ -3670,6 +3716,7 @@ dependencies = [
"codex-protocol",
"codex-rollout",
"codex-state",
"codex-utils-path",
"pretty_assertions",
"serde",
"serde_json",
@@ -3680,29 +3727,23 @@ dependencies = [
"uuid",
]
[[package]]
name = "codex-tool-api"
version = "0.0.0"
dependencies = [
"pretty_assertions",
"serde_json",
"thiserror 2.0.18",
]
[[package]]
name = "codex-tools"
version = "0.0.0"
dependencies = [
"async-trait",
"codex-app-server-protocol",
"codex-code-mode",
"codex-features",
"codex-protocol",
"codex-utils-absolute-path",
"codex-utils-pty",
"codex-utils-string",
"pretty_assertions",
"rmcp",
"serde",
"serde_json",
"thiserror 2.0.18",
"tracing",
]
@@ -3744,6 +3785,7 @@ dependencies = [
"codex-protocol",
"codex-realtime-webrtc",
"codex-rollout",
"codex-sandboxing",
"codex-shell-command",
"codex-state",
"codex-terminal-detection",
@@ -3753,15 +3795,16 @@ dependencies = [
"codex-utils-cli",
"codex-utils-elapsed",
"codex-utils-fuzzy-match",
"codex-utils-home-dir",
"codex-utils-oss",
"codex-utils-path",
"codex-utils-plugins",
"codex-utils-pty",
"codex-utils-sandbox-summary",
"codex-utils-sleep-inhibitor",
"codex-utils-string",
"codex-windows-sandbox",
"color-eyre",
"core_test_support",
"cpal",
"crossterm",
"derive_more 2.1.1",
@@ -3785,6 +3828,7 @@ dependencies = [
"serde",
"serde_json",
"serial_test",
"sha2",
"shlex",
"strum 0.27.2",
"strum_macros 0.28.0",
@@ -3811,6 +3855,7 @@ dependencies = [
"which 8.0.0",
"windows-sys 0.52.0",
"winsplit",
"wiremock",
]
[[package]]
@@ -3870,6 +3915,7 @@ version = "0.0.0"
dependencies = [
"clap",
"codex-protocol",
"codex-shell-command",
"pretty_assertions",
"serde",
"toml 0.9.11+spec-1.1.0",
@@ -5359,12 +5405,6 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "env-flags"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbfd0e7fc632dec5e6c9396a27bc9f9975b4e039720e1fd3e34021d3ce28c415"
[[package]]
name = "env_filter"
version = "1.0.0"
@@ -5416,7 +5456,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -8964,7 +9004,7 @@ version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
dependencies = [
"base64 0.21.7",
"base64 0.22.1",
"chrono",
"getrandom 0.2.17",
"http 1.4.0",
@@ -9432,7 +9472,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.45.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -11603,6 +11643,16 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_ignored"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "serde_json"
version = "1.0.149"
@@ -14076,7 +14126,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.61.2",
]
[[package]]
+7 -6
View File
@@ -45,7 +45,8 @@ members = [
"execpolicy",
"execpolicy-legacy",
"ext/extension-api",
"ext/git-attribution",
"ext/guardian",
"ext/memories",
"external-agent-migration",
"external-agent-sessions",
"keyring-store",
@@ -107,7 +108,6 @@ members = [
"test-binary-support",
"thread-manager-sample",
"thread-store",
"tool-api",
"uds",
"codex-experimental-api-macros",
"plugin",
@@ -162,7 +162,7 @@ codex-file-system = { path = "file-system" }
codex-exec-server = { path = "exec-server" }
codex-execpolicy = { path = "execpolicy" }
codex-extension-api = { path = "ext/extension-api" }
codex-git-attribution = { path = "ext/git-attribution" }
codex-guardian = { path = "ext/guardian" }
codex-external-agent-migration = { path = "external-agent-migration" }
codex-external-agent-sessions = { path = "external-agent-sessions" }
codex-experimental-api-macros = { path = "codex-experimental-api-macros" }
@@ -178,6 +178,7 @@ codex-linux-sandbox = { path = "linux-sandbox" }
codex-lmstudio = { path = "lmstudio" }
codex-login = { path = "login" }
codex-message-history = { path = "message-history" }
codex-memories-extension = { path = "ext/memories" }
codex-memories-read = { path = "memories/read" }
codex-memories-write = { path = "memories/write" }
codex-mcp = { path = "codex-mcp" }
@@ -207,7 +208,6 @@ codex-stdio-to-uds = { path = "stdio-to-uds" }
codex-terminal-detection = { path = "terminal-detection" }
codex-test-binary-support = { path = "test-binary-support" }
codex-thread-store = { path = "thread-store" }
codex-tool-api = { path = "tool-api" }
codex-tools = { path = "tools" }
codex-tui = { path = "tui" }
codex-uds = { path = "uds" }
@@ -226,7 +226,6 @@ codex-utils-output-truncation = { path = "utils/output-truncation" }
codex-utils-path = { path = "utils/path-utils" }
codex-utils-plugins = { path = "utils/plugins" }
codex-utils-pty = { path = "utils/pty" }
codex-utils-readiness = { path = "utils/readiness" }
codex-utils-rustls-provider = { path = "utils/rustls-provider" }
codex-utils-sandbox-summary = { path = "utils/sandbox-summary" }
codex-utils-sleep-inhibitor = { path = "utils/sleep-inhibitor" }
@@ -264,6 +263,7 @@ chrono = "0.4.43"
clap = "4"
clap_complete = "4"
color-eyre = "0.6.3"
constant_time_eq = "0.3.1"
crossbeam-channel = "0.5.15"
crypto_box = { version = "0.9.1", features = ["seal"] }
crossterm = "0.28.1"
@@ -278,7 +278,6 @@ dotenvy = "0.15.7"
dunce = "1.0.4"
ed25519-dalek = { version = "2.2.0", features = ["pkcs8"] }
encoding_rs = "0.8.35"
env-flags = "0.1.1"
env_logger = "0.11.9"
eventsource-stream = "0.2.3"
flate2 = "1.1.8"
@@ -351,6 +350,7 @@ seccompiler = "0.5.0"
semver = "1.0"
sentry = "0.46.0"
serde = "1"
serde_ignored = "0.1.14"
serde_json = "1"
serde_path_to_error = "0.1.20"
serde_with = "3.17"
@@ -470,6 +470,7 @@ unwrap_used = "deny"
[workspace.metadata.cargo-shear]
ignored = [
"codex-agent-graph-store",
"codex-memories-extension",
"icu_provider",
"openssl-sys",
"codex-v8-poc",
+32 -74
View File
@@ -7,9 +7,6 @@ use codex_git_utils::get_git_remote_urls_assume_git_repo;
use sha1::Digest;
use std::path::Path;
const ACCEPTED_LINE_FINGERPRINT_EVENT_TARGET_BYTES: usize = 2 * 1024 * 1024;
const ACCEPTED_LINE_FINGERPRINT_EVENT_FIXED_BYTES: usize = 1024;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AcceptedLineFingerprintSummary {
pub accepted_added_lines: u64,
@@ -97,39 +94,38 @@ pub fn fingerprint_hash(domain: &str, value: &str) -> String {
pub(crate) fn accepted_line_fingerprint_event_requests(
input: AcceptedLineFingerprintEventInput,
) -> Vec<TrackEventRequest> {
let chunks = accepted_line_fingerprint_chunks(input.line_fingerprints);
chunks
.into_iter()
.enumerate()
.map(|(index, line_fingerprints)| {
let is_first_chunk = index == 0;
TrackEventRequest::AcceptedLineFingerprints(Box::new(
CodexAcceptedLineFingerprintsEventRequest {
event_type: "codex_accepted_line_fingerprints",
event_params: CodexAcceptedLineFingerprintsEventParams {
event_type: input.event_type,
turn_id: input.turn_id.clone(),
thread_id: input.thread_id.clone(),
product_surface: input.product_surface.clone(),
model_slug: input.model_slug.clone(),
completed_at: input.completed_at,
repo_hash: input.repo_hash.clone(),
accepted_added_lines: if is_first_chunk {
input.accepted_added_lines
} else {
0
},
accepted_deleted_lines: if is_first_chunk {
input.accepted_deleted_lines
} else {
0
},
line_fingerprints,
},
},
))
})
.collect()
let AcceptedLineFingerprintEventInput {
event_type,
turn_id,
thread_id,
product_surface,
model_slug,
completed_at,
repo_hash,
accepted_added_lines,
accepted_deleted_lines,
line_fingerprints: _line_fingerprints,
} = input;
vec![TrackEventRequest::AcceptedLineFingerprints(Box::new(
CodexAcceptedLineFingerprintsEventRequest {
event_type: "codex_accepted_line_fingerprints",
event_params: CodexAcceptedLineFingerprintsEventParams {
event_type,
turn_id,
thread_id,
product_surface,
model_slug,
completed_at,
repo_hash,
accepted_added_lines,
accepted_deleted_lines,
// Keep computing local fingerprints for parsing tests and future attribution,
// but do not upload path/line hashes in the analytics event payload.
line_fingerprints: Vec::new(),
},
},
))]
}
pub async fn accepted_line_repo_hash_for_cwd(cwd: &Path) -> Option<String> {
@@ -172,44 +168,6 @@ fn normalize_effective_line(line: &str) -> Option<String> {
Some(normalized)
}
fn accepted_line_fingerprint_chunks(
line_fingerprints: Vec<AcceptedLineFingerprint>,
) -> Vec<Vec<AcceptedLineFingerprint>> {
if line_fingerprints.is_empty() {
return vec![Vec::new()];
}
let mut chunks = Vec::new();
let mut current = Vec::new();
let mut current_bytes = ACCEPTED_LINE_FINGERPRINT_EVENT_FIXED_BYTES;
for fingerprint in line_fingerprints {
let item_bytes = accepted_line_fingerprint_json_bytes(&fingerprint);
let separator_bytes = usize::from(!current.is_empty());
if !current.is_empty()
&& current_bytes + separator_bytes + item_bytes
> ACCEPTED_LINE_FINGERPRINT_EVENT_TARGET_BYTES
{
chunks.push(current);
current = Vec::new();
current_bytes = ACCEPTED_LINE_FINGERPRINT_EVENT_FIXED_BYTES;
}
current_bytes += usize::from(!current.is_empty()) + item_bytes;
current.push(fingerprint);
}
if !current.is_empty() {
chunks.push(current);
}
chunks
}
fn accepted_line_fingerprint_json_bytes(fingerprint: &AcceptedLineFingerprint) -> usize {
// {"path_hash":"...","line_hash":"..."} plus one byte of array comma
// accounted for by the caller when needed.
32 + fingerprint.path_hash.len() + fingerprint.line_hash.len()
}
#[cfg(test)]
mod tests {
use super::*;
+600 -39
View File
@@ -11,18 +11,25 @@ use crate::events::CodexCompactionEventRequest;
use crate::events::CodexHookRunEventRequest;
use crate::events::CodexPluginEventRequest;
use crate::events::CodexPluginUsedEventRequest;
use crate::events::CodexReviewEventParams;
use crate::events::CodexReviewEventRequest;
use crate::events::CodexRuntimeMetadata;
use crate::events::CodexToolItemEventBase;
use crate::events::CodexTurnEventRequest;
use crate::events::FinalApprovalOutcome;
use crate::events::GuardianApprovalRequestSource;
use crate::events::GuardianReviewDecision;
use crate::events::GuardianReviewEventParams;
use crate::events::GuardianReviewFailureReason;
use crate::events::GuardianReviewTerminalStatus;
use crate::events::GuardianReviewedAction;
use crate::events::ReviewResolution;
use crate::events::ReviewStatus;
use crate::events::ReviewSubjectKind;
use crate::events::ReviewTrigger;
use crate::events::Reviewer;
use crate::events::ThreadInitializedEvent;
use crate::events::ThreadInitializedEventParams;
use crate::events::ToolItemFinalApprovalOutcome;
use crate::events::ToolItemTerminalStatus;
use crate::events::TrackEventRequest;
use crate::events::codex_app_metadata;
@@ -30,7 +37,6 @@ use crate::events::codex_hook_run_metadata;
use crate::events::codex_plugin_metadata;
use crate::events::codex_plugin_used_metadata;
use crate::events::subagent_thread_started_event_request;
use crate::facts::AcceptedLineFingerprint;
use crate::facts::AnalyticsFact;
use crate::facts::AnalyticsJsonRpcError;
use crate::facts::AppInvocation;
@@ -72,20 +78,32 @@ use codex_app_server_protocol::CodexErrorInfo;
use codex_app_server_protocol::CollabAgentTool;
use codex_app_server_protocol::CollabAgentToolCallStatus;
use codex_app_server_protocol::CommandAction;
use codex_app_server_protocol::CommandExecutionApprovalDecision;
use codex_app_server_protocol::CommandExecutionRequestApprovalParams;
use codex_app_server_protocol::CommandExecutionRequestApprovalResponse;
use codex_app_server_protocol::CommandExecutionSource;
use codex_app_server_protocol::CommandExecutionStatus;
use codex_app_server_protocol::DynamicToolCallStatus;
use codex_app_server_protocol::GuardianApprovalReview;
use codex_app_server_protocol::GuardianApprovalReviewAction;
use codex_app_server_protocol::GuardianApprovalReviewStatus;
use codex_app_server_protocol::GuardianCommandSource as AppServerGuardianCommandSource;
use codex_app_server_protocol::InitializeCapabilities;
use codex_app_server_protocol::InitializeParams;
use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::ItemGuardianApprovalReviewCompletedNotification;
use codex_app_server_protocol::ItemStartedNotification;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::McpToolCallStatus;
use codex_app_server_protocol::NonSteerableTurnKind;
use codex_app_server_protocol::PatchApplyStatus;
use codex_app_server_protocol::PermissionsRequestApprovalParams;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::RequestPermissionProfile;
use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ServerResponse;
use codex_app_server_protocol::SessionSource as AppServerSessionSource;
use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadArchiveParams;
@@ -114,6 +132,7 @@ use codex_plugin::PluginTelemetryMetadata;
use codex_protocol::approvals::NetworkApprovalProtocol;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::ModeKind;
use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions;
use codex_protocol::models::PermissionProfile as CorePermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::HookEventName;
@@ -124,6 +143,9 @@ use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::protocol::ThreadSource;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope;
use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile;
use codex_protocol::request_permissions::RequestPermissionsResponse as CoreRequestPermissionsResponse;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::test_path_buf;
use pretty_assertions::assert_eq;
@@ -612,7 +634,7 @@ async fn ingest_turn_prerequisites(
}
}
async fn ingest_tool_review_prerequisites(
async fn ingest_review_prerequisites(
reducer: &mut AnalyticsReducer,
events: &mut Vec<TrackEventRequest>,
) {
@@ -634,6 +656,58 @@ async fn ingest_tool_review_prerequisites(
events.clear();
}
async fn ingest_completed_command_execution_item(
reducer: &mut AnalyticsReducer,
events: &mut Vec<TrackEventRequest>,
thread_id: &str,
item_id: &str,
) {
reducer
.ingest(
AnalyticsFact::Notification(Box::new(sample_turn_started_notification(
thread_id, "turn-1",
))),
events,
)
.await;
reducer
.ingest(
AnalyticsFact::Notification(Box::new(ServerNotification::ItemStarted(
ItemStartedNotification {
thread_id: thread_id.to_string(),
turn_id: "turn-1".to_string(),
started_at_ms: 1_000,
item: sample_command_execution_item_with_id(
item_id,
CommandExecutionStatus::InProgress,
/*exit_code*/ None,
/*duration_ms*/ None,
),
},
))),
events,
)
.await;
reducer
.ingest(
AnalyticsFact::Notification(Box::new(ServerNotification::ItemCompleted(
ItemCompletedNotification {
thread_id: thread_id.to_string(),
turn_id: "turn-1".to_string(),
completed_at_ms: 1_042,
item: sample_command_execution_item_with_id(
item_id,
CommandExecutionStatus::Completed,
Some(0),
Some(42),
),
},
))),
events,
)
.await;
}
fn sample_initialize_fact(connection_id: u64) -> AnalyticsFact {
AnalyticsFact::Initialize {
connection_id,
@@ -664,9 +738,18 @@ fn sample_command_execution_item(
status: CommandExecutionStatus,
exit_code: Option<i32>,
duration_ms: Option<i64>,
) -> ThreadItem {
sample_command_execution_item_with_id("item-1", status, exit_code, duration_ms)
}
fn sample_command_execution_item_with_id(
id: &str,
status: CommandExecutionStatus,
exit_code: Option<i32>,
duration_ms: Option<i64>,
) -> ThreadItem {
ThreadItem::CommandExecution {
id: "item-1".to_string(),
id: id.to_string(),
command: "echo hi".to_string(),
cwd: test_path_buf("/tmp").abs(),
process_id: Some("pid-1".to_string()),
@@ -697,6 +780,98 @@ fn sample_command_execution_item_with_actions(
item
}
fn sample_command_approval_request(request_id: i64, approval_id: Option<&str>) -> ServerRequest {
ServerRequest::CommandExecutionRequestApproval {
request_id: RequestId::Integer(request_id),
params: CommandExecutionRequestApprovalParams {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
item_id: "item-1".to_string(),
started_at_ms: 1_000,
approval_id: approval_id.map(str::to_string),
reason: None,
network_approval_context: None,
command: Some("echo hi".to_string()),
cwd: None,
command_actions: None,
additional_permissions: None,
proposed_execpolicy_amendment: None,
proposed_network_policy_amendments: None,
available_decisions: None,
},
}
}
fn sample_command_approval_response(
request_id: i64,
decision: CommandExecutionApprovalDecision,
) -> ServerResponse {
ServerResponse::CommandExecutionRequestApproval {
request_id: RequestId::Integer(request_id),
response: CommandExecutionRequestApprovalResponse { decision },
}
}
fn sample_permissions_approval_request(request_id: i64) -> ServerRequest {
ServerRequest::PermissionsRequestApproval {
request_id: RequestId::Integer(request_id),
params: PermissionsRequestApprovalParams {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
item_id: "permissions-1".to_string(),
started_at_ms: 1_000,
cwd: test_path_buf("/tmp").abs(),
reason: Some("need network".to_string()),
permissions: RequestPermissionProfile {
network: Some(codex_app_server_protocol::AdditionalNetworkPermissions {
enabled: Some(true),
}),
file_system: None,
},
},
}
}
fn sample_effective_permissions_approval_response(
permissions: CoreRequestPermissionProfile,
scope: CorePermissionGrantScope,
) -> CoreRequestPermissionsResponse {
CoreRequestPermissionsResponse {
permissions,
scope,
strict_auto_review: false,
}
}
fn sample_guardian_review_completed(
review_id: &str,
target_item_id: Option<&str>,
status: GuardianApprovalReviewStatus,
) -> ServerNotification {
ServerNotification::ItemGuardianApprovalReviewCompleted(
ItemGuardianApprovalReviewCompletedNotification {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
started_at_ms: 1_000,
completed_at_ms: 1_042,
review_id: review_id.to_string(),
target_item_id: target_item_id.map(str::to_string),
decision_source: codex_app_server_protocol::AutoReviewDecisionSource::Agent,
review: GuardianApprovalReview {
status,
risk_level: None,
user_authorization: None,
rationale: None,
},
action: GuardianApprovalReviewAction::Command {
source: AppServerGuardianCommandSource::Shell,
command: "echo hi".to_string(),
cwd: test_path_buf("/tmp").abs(),
},
},
)
}
fn expected_absolute_path(path: &PathBuf) -> String {
std::fs::canonicalize(path)
.unwrap_or_else(|_| path.to_path_buf())
@@ -852,10 +1027,7 @@ fn accepted_line_fingerprints_event_serializes_expected_shape() {
repo_hash: Some("repo-hash-1".to_string()),
accepted_added_lines: 42,
accepted_deleted_lines: 40,
line_fingerprints: vec![AcceptedLineFingerprint {
path_hash: "path-hash-1".to_string(),
line_hash: "line-hash-1".to_string(),
}],
line_fingerprints: Vec::new(),
},
},
));
@@ -876,19 +1048,14 @@ fn accepted_line_fingerprints_event_serializes_expected_shape() {
"repo_hash": "repo-hash-1",
"accepted_added_lines": 42,
"accepted_deleted_lines": 40,
"line_fingerprints": [
{
"path_hash": "path-hash-1",
"line_hash": "line-hash-1"
}
]
"line_fingerprints": []
}
})
);
}
#[tokio::test]
async fn reducer_chunks_large_accepted_line_fingerprint_events_without_repeating_counts() {
async fn reducer_emits_large_accepted_line_aggregates_without_fingerprints() {
let mut reducer = AnalyticsReducer::default();
let mut events = Vec::new();
@@ -948,22 +1115,14 @@ index 1111111..2222222
_ => None,
})
.collect::<Vec<_>>();
assert!(accepted_line_events.len() > 1);
let mut total_fingerprints = 0;
for (index, event) in accepted_line_events.iter().enumerate() {
assert_eq!(event.event_params.turn_id, "turn-2");
assert_eq!(event.event_params.thread_id, "thread-2");
total_fingerprints += event.event_params.line_fingerprints.len();
if index == 0 {
assert_eq!(event.event_params.accepted_added_lines, 20_000);
assert_eq!(event.event_params.accepted_deleted_lines, 0);
} else {
assert_eq!(event.event_params.accepted_added_lines, 0);
assert_eq!(event.event_params.accepted_deleted_lines, 0);
}
assert!(serde_json::to_vec(event).expect("serialize chunk").len() < 2_100_000);
}
assert_eq!(total_fingerprints, 20_000);
assert_eq!(accepted_line_events.len(), 1);
let event = accepted_line_events[0];
assert_eq!(event.event_params.turn_id, "turn-2");
assert_eq!(event.event_params.thread_id, "thread-2");
assert_eq!(event.event_params.accepted_added_lines, 20_000);
assert_eq!(event.event_params.accepted_deleted_lines, 0);
assert!(event.event_params.line_fingerprints.is_empty());
assert!(serde_json::to_vec(event).expect("serialize event").len() < 2_100_000);
}
#[tokio::test]
@@ -1030,11 +1189,7 @@ index 1111111..2222222
assert_eq!(accepted_line_events.len(), 1);
let event = accepted_line_events[0];
assert_eq!(event.event_params.accepted_added_lines, 1);
assert_eq!(event.event_params.line_fingerprints.len(), 1);
assert_eq!(
event.event_params.line_fingerprints[0].line_hash,
crate::fingerprint_hash("line", "let latest_value = 2;")
);
assert!(event.event_params.line_fingerprints.is_empty());
}
#[test]
@@ -1233,7 +1388,7 @@ fn command_execution_event_serializes_expected_shape() {
review_count: 0,
guardian_review_count: 0,
user_review_count: 0,
final_approval_outcome: ToolItemFinalApprovalOutcome::NotNeeded,
final_approval_outcome: FinalApprovalOutcome::NotNeeded,
terminal_status: ToolItemTerminalStatus::Completed,
failure_kind: None,
requested_additional_permissions: false,
@@ -1299,6 +1454,82 @@ fn command_execution_event_serializes_expected_shape() {
);
}
#[test]
fn review_event_serializes_expected_shape() {
let event = TrackEventRequest::ReviewEvent(CodexReviewEventRequest {
event_type: "codex_review_event",
event_params: CodexReviewEventParams {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
item_id: None,
review_id: "review-1".to_string(),
app_server_client: CodexAppServerClientMetadata {
product_client_id: "codex_tui".to_string(),
client_name: Some("codex-tui".to_string()),
client_version: Some("1.2.3".to_string()),
rpc_transport: AppServerRpcTransport::Websocket,
experimental_api_enabled: Some(true),
},
runtime: CodexRuntimeMetadata {
codex_rs_version: "0.99.0".to_string(),
runtime_os: "macos".to_string(),
runtime_os_version: "15.3.1".to_string(),
runtime_arch: "aarch64".to_string(),
},
thread_source: Some(ThreadSource::Subagent),
subagent_source: Some("thread_spawn".to_string()),
parent_thread_id: Some("parent-thread-1".to_string()),
subject_kind: ReviewSubjectKind::NetworkAccess,
subject_name: "network_access".to_string(),
reviewer: Reviewer::User,
trigger: ReviewTrigger::NetworkPolicyDenial,
status: ReviewStatus::Approved,
resolution: ReviewResolution::NetworkPolicyAmendment,
started_at_ms: 123,
completed_at_ms: 125,
duration_ms: Some(2),
},
});
let payload = serde_json::to_value(&event).expect("serialize review event");
assert_eq!(
payload,
json!({
"event_type": "codex_review_event",
"event_params": {
"thread_id": "thread-1",
"turn_id": "turn-1",
"item_id": null,
"review_id": "review-1",
"app_server_client": {
"product_client_id": "codex_tui",
"client_name": "codex-tui",
"client_version": "1.2.3",
"rpc_transport": "websocket",
"experimental_api_enabled": true
},
"runtime": {
"codex_rs_version": "0.99.0",
"runtime_os": "macos",
"runtime_os_version": "15.3.1",
"runtime_arch": "aarch64"
},
"thread_source": "subagent",
"subagent_source": "thread_spawn",
"parent_thread_id": "parent-thread-1",
"subject_kind": "network_access",
"subject_name": "network_access",
"reviewer": "user",
"trigger": "network_policy_denial",
"status": "approved",
"resolution": "network_policy_amendment",
"started_at_ms": 123,
"completed_at_ms": 125,
"duration_ms": 2
}
})
);
}
#[tokio::test]
async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialized() {
let mut reducer = AnalyticsReducer::default();
@@ -1713,7 +1944,7 @@ async fn item_lifecycle_notifications_publish_command_execution_event() {
let mut reducer = AnalyticsReducer::default();
let mut events = Vec::new();
ingest_tool_review_prerequisites(&mut reducer, &mut events).await;
ingest_review_prerequisites(&mut reducer, &mut events).await;
reducer
.ingest(
AnalyticsFact::Notification(Box::new(sample_turn_started_notification(
@@ -1824,6 +2055,336 @@ async fn item_lifecycle_notifications_publish_command_execution_event() {
assert_eq!(payload[0]["event_params"]["thread_source"], "user");
}
#[tokio::test]
async fn command_execution_approval_response_publishes_user_review_event() {
let mut reducer = AnalyticsReducer::default();
let mut events = Vec::new();
ingest_review_prerequisites(&mut reducer, &mut events).await;
reducer
.ingest(
AnalyticsFact::ServerRequest {
connection_id: 7,
request: Box::new(sample_command_approval_request(
/*request_id*/ 41, /*approval_id*/ None,
)),
},
&mut events,
)
.await;
assert!(events.is_empty());
reducer
.ingest(
AnalyticsFact::ServerResponse {
completed_at_ms: 1_042,
response: Box::new(sample_command_approval_response(
/*request_id*/ 41,
CommandExecutionApprovalDecision::Accept,
)),
},
&mut events,
)
.await;
let payload = serde_json::to_value(&events).expect("serialize events");
assert_eq!(payload.as_array().expect("events array").len(), 1);
assert_eq!(payload[0]["event_type"], "codex_review_event");
assert_eq!(payload[0]["event_params"]["thread_id"], "thread-1");
assert_eq!(payload[0]["event_params"]["turn_id"], "turn-1");
assert_eq!(payload[0]["event_params"]["item_id"], "item-1");
assert_eq!(payload[0]["event_params"]["review_id"], "user:41");
assert_eq!(payload[0]["event_params"]["thread_source"], "user");
assert_eq!(
payload[0]["event_params"]["subject_kind"],
"command_execution"
);
assert_eq!(
payload[0]["event_params"]["subject_name"],
"command_execution"
);
assert_eq!(payload[0]["event_params"]["reviewer"], "user");
assert_eq!(payload[0]["event_params"]["trigger"], "initial");
assert_eq!(payload[0]["event_params"]["status"], "approved");
assert_eq!(payload[0]["event_params"]["started_at_ms"], 1_000);
assert_eq!(payload[0]["event_params"]["completed_at_ms"], 1_042);
assert_eq!(payload[0]["event_params"]["duration_ms"], 42);
}
#[tokio::test]
async fn permissions_reviews_emit_events_without_denormalizing_onto_tool_items() {
let mut reducer = AnalyticsReducer::default();
let mut events = Vec::new();
ingest_review_prerequisites(&mut reducer, &mut events).await;
reducer
.ingest(
AnalyticsFact::ServerRequest {
connection_id: 7,
request: Box::new(sample_permissions_approval_request(/*request_id*/ 51)),
},
&mut events,
)
.await;
assert!(events.is_empty());
reducer
.ingest(
AnalyticsFact::EffectivePermissionsApprovalResponse {
completed_at_ms: 1_042,
request_id: RequestId::Integer(51),
response: Box::new(sample_effective_permissions_approval_response(
CoreRequestPermissionProfile::default(),
CorePermissionGrantScope::Turn,
)),
},
&mut events,
)
.await;
let payload = serde_json::to_value(&events).expect("serialize events");
assert_eq!(payload.as_array().expect("events array").len(), 1);
assert_eq!(payload[0]["event_type"], "codex_review_event");
assert_eq!(payload[0]["event_params"]["review_id"], "user:51");
assert_eq!(payload[0]["event_params"]["subject_kind"], "permissions");
assert_eq!(payload[0]["event_params"]["reviewer"], "user");
assert_eq!(payload[0]["event_params"]["status"], "denied");
assert_eq!(payload[0]["event_params"]["resolution"], "none");
events.clear();
ingest_completed_command_execution_item(&mut reducer, &mut events, "thread-1", "permissions-1")
.await;
let payload = serde_json::to_value(&events[0]).expect("serialize tool item event");
assert_eq!(payload["event_params"]["item_id"], "permissions-1");
assert_eq!(payload["event_params"]["review_count"], 0);
assert_eq!(payload["event_params"]["user_review_count"], 0);
assert_eq!(payload["event_params"]["guardian_review_count"], 0);
}
#[tokio::test]
async fn effective_session_permissions_response_publishes_session_user_review_event() {
let mut reducer = AnalyticsReducer::default();
let mut events = Vec::new();
ingest_review_prerequisites(&mut reducer, &mut events).await;
reducer
.ingest(
AnalyticsFact::ServerRequest {
connection_id: 7,
request: Box::new(sample_permissions_approval_request(/*request_id*/ 52)),
},
&mut events,
)
.await;
reducer
.ingest(
AnalyticsFact::EffectivePermissionsApprovalResponse {
completed_at_ms: 1_042,
request_id: RequestId::Integer(52),
response: Box::new(sample_effective_permissions_approval_response(
CoreRequestPermissionProfile {
network: Some(CoreNetworkPermissions {
enabled: Some(true),
}),
file_system: None,
},
CorePermissionGrantScope::Session,
)),
},
&mut events,
)
.await;
let payload = serde_json::to_value(&events).expect("serialize events");
assert_eq!(payload.as_array().expect("events array").len(), 1);
assert_eq!(payload[0]["event_type"], "codex_review_event");
assert_eq!(payload[0]["event_params"]["review_id"], "user:52");
assert_eq!(payload[0]["event_params"]["subject_kind"], "permissions");
assert_eq!(payload[0]["event_params"]["reviewer"], "user");
assert_eq!(payload[0]["event_params"]["status"], "approved");
assert_eq!(payload[0]["event_params"]["resolution"], "session_approval");
}
#[tokio::test]
async fn aborted_server_request_publishes_aborted_user_review_event_once() {
let mut reducer = AnalyticsReducer::default();
let mut events = Vec::new();
ingest_review_prerequisites(&mut reducer, &mut events).await;
reducer
.ingest(
AnalyticsFact::ServerRequest {
connection_id: 7,
request: Box::new(sample_command_approval_request(
/*request_id*/ 61, /*approval_id*/ None,
)),
},
&mut events,
)
.await;
reducer
.ingest(
AnalyticsFact::ServerRequestAborted {
completed_at_ms: 1_042,
request_id: RequestId::Integer(61),
},
&mut events,
)
.await;
let payload = serde_json::to_value(&events).expect("serialize events");
assert_eq!(payload.as_array().expect("events array").len(), 1);
assert_eq!(payload[0]["event_params"]["review_id"], "user:61");
assert_eq!(payload[0]["event_params"]["status"], "aborted");
assert_eq!(payload[0]["event_params"]["resolution"], "none");
events.clear();
reducer
.ingest(
AnalyticsFact::ServerResponse {
completed_at_ms: 1_043,
response: Box::new(sample_command_approval_response(
/*request_id*/ 61,
CommandExecutionApprovalDecision::Accept,
)),
},
&mut events,
)
.await;
assert!(events.is_empty());
}
#[tokio::test]
async fn guardian_completed_notification_publishes_review_event_with_thread_metadata() {
let mut reducer = AnalyticsReducer::default();
let mut events = Vec::new();
ingest_review_prerequisites(&mut reducer, &mut events).await;
reducer
.ingest(
AnalyticsFact::Notification(Box::new(sample_guardian_review_completed(
"guardian-review-1",
Some("item-1"),
GuardianApprovalReviewStatus::Denied,
))),
&mut events,
)
.await;
let payload = serde_json::to_value(&events[0]).expect("serialize review event");
assert_eq!(payload["event_type"], "codex_review_event");
assert_eq!(payload["event_params"]["review_id"], "guardian-review-1");
assert_eq!(payload["event_params"]["item_id"], "item-1");
assert_eq!(payload["event_params"]["thread_source"], "user");
assert_eq!(payload["event_params"]["subject_kind"], "command_execution");
assert_eq!(payload["event_params"]["reviewer"], "guardian");
assert_eq!(payload["event_params"]["status"], "denied");
assert_eq!(payload["event_params"]["started_at_ms"], 1_000);
assert_eq!(payload["event_params"]["completed_at_ms"], 1_042);
assert_eq!(payload["event_params"]["duration_ms"], 42);
}
#[tokio::test]
async fn terminal_reviews_denormalize_counts_onto_tool_item_events() {
let mut reducer = AnalyticsReducer::default();
let mut events = Vec::new();
ingest_review_prerequisites(&mut reducer, &mut events).await;
reducer
.ingest(
AnalyticsFact::ServerRequest {
connection_id: 7,
request: Box::new(sample_command_approval_request(
/*request_id*/ 71, /*approval_id*/ None,
)),
},
&mut events,
)
.await;
reducer
.ingest(
AnalyticsFact::ServerResponse {
completed_at_ms: 1_042,
response: Box::new(sample_command_approval_response(
/*request_id*/ 71,
CommandExecutionApprovalDecision::AcceptForSession,
)),
},
&mut events,
)
.await;
events.clear();
ingest_completed_command_execution_item(&mut reducer, &mut events, "thread-1", "item-1").await;
let payload = serde_json::to_value(&events[0]).expect("serialize tool item event");
assert_eq!(payload["event_params"]["review_count"], 1);
assert_eq!(payload["event_params"]["user_review_count"], 1);
assert_eq!(payload["event_params"]["guardian_review_count"], 0);
assert_eq!(
payload["event_params"]["final_approval_outcome"],
"user_approved_for_session"
);
}
#[tokio::test]
async fn item_review_summaries_do_not_cross_threads_with_reused_item_ids() {
let mut reducer = AnalyticsReducer::default();
let mut events = Vec::new();
ingest_review_prerequisites(&mut reducer, &mut events).await;
reducer
.ingest(
AnalyticsFact::ClientResponse {
connection_id: 7,
request_id: RequestId::Integer(2),
response: Box::new(sample_thread_start_response(
"thread-2", /*ephemeral*/ false, "gpt-5",
)),
},
&mut events,
)
.await;
events.clear();
reducer
.ingest(
AnalyticsFact::ServerRequest {
connection_id: 7,
request: Box::new(sample_command_approval_request(
/*request_id*/ 72, /*approval_id*/ None,
)),
},
&mut events,
)
.await;
reducer
.ingest(
AnalyticsFact::ServerResponse {
completed_at_ms: 1_042,
response: Box::new(sample_command_approval_response(
/*request_id*/ 72,
CommandExecutionApprovalDecision::Accept,
)),
},
&mut events,
)
.await;
events.clear();
ingest_completed_command_execution_item(&mut reducer, &mut events, "thread-2", "item-1").await;
let payload = serde_json::to_value(&events[0]).expect("serialize tool item event");
assert_eq!(payload["event_params"]["thread_id"], "thread-2");
assert_eq!(payload["event_params"]["item_id"], "item-1");
assert_eq!(payload["event_params"]["review_count"], 0);
assert_eq!(payload["event_params"]["user_review_count"], 0);
assert_eq!(payload["event_params"]["guardian_review_count"], 0);
assert_eq!(payload["event_params"]["final_approval_outcome"], "unknown");
}
#[test]
fn subagent_thread_started_review_serializes_expected_shape() {
let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request(
@@ -2112,7 +2673,7 @@ async fn subagent_tool_items_inherit_parent_connection_metadata() {
let mut reducer = AnalyticsReducer::default();
let mut events = Vec::new();
ingest_tool_review_prerequisites(&mut reducer, &mut events).await;
ingest_review_prerequisites(&mut reducer, &mut events).await;
reducer
.ingest(
AnalyticsFact::Custom(CustomAnalyticsFact::SubAgentThreadStarted(
+23 -1
View File
@@ -33,6 +33,7 @@ use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_login::default_client::create_client;
use codex_plugin::PluginTelemetryMetadata;
use codex_protocol::request_permissions::RequestPermissionsResponse;
use std::collections::HashSet;
use std::sync::Arc;
use std::sync::Mutex;
@@ -172,9 +173,10 @@ impl AnalyticsEventsClient {
&self,
tracking: &GuardianReviewTrackContext,
result: GuardianReviewAnalyticsResult,
completed_at_ms: u64,
) {
self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::GuardianReview(
Box::new(tracking.event_params(result)),
Box::new(tracking.event_params(result, completed_at_ms)),
)));
}
@@ -348,6 +350,26 @@ impl AnalyticsEventsClient {
});
}
pub fn track_effective_permissions_approval_response(
&self,
completed_at_ms: u64,
request_id: RequestId,
response: RequestPermissionsResponse,
) {
self.record_fact(AnalyticsFact::EffectivePermissionsApprovalResponse {
completed_at_ms,
request_id,
response: Box::new(response),
});
}
pub fn track_server_request_aborted(&self, completed_at_ms: u64, request_id: RequestId) {
self.record_fact(AnalyticsFact::ServerRequestAborted {
completed_at_ms,
request_id,
});
}
pub fn track_notification(&self, notification: ServerNotification) {
if !matches!(
notification,
+1 -5
View File
@@ -6,7 +6,6 @@ use crate::events::CodexAcceptedLineFingerprintsEventRequest;
use crate::events::SkillInvocationEventParams;
use crate::events::SkillInvocationEventRequest;
use crate::events::TrackEventRequest;
use crate::facts::AcceptedLineFingerprint;
use crate::facts::AnalyticsFact;
use crate::facts::InvocationType;
use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer;
@@ -53,10 +52,7 @@ fn sample_accepted_line_fingerprint_event(thread_id: &str) -> TrackEventRequest
repo_hash: None,
accepted_added_lines: 1,
accepted_deleted_lines: 0,
line_fingerprints: vec![AcceptedLineFingerprint {
path_hash: "path-hash".to_string(),
line_hash: "line-hash".to_string(),
}],
line_fingerprints: Vec::new(),
},
},
))
+6 -6
View File
@@ -20,7 +20,6 @@ use crate::facts::TurnSteerRejectionReason;
use crate::facts::TurnSteerResult;
use crate::facts::TurnSubmissionType;
use crate::now_unix_millis;
use crate::now_unix_seconds;
use codex_app_server_protocol::CodexErrorInfo;
use codex_app_server_protocol::CommandExecutionSource;
use codex_login::default_client::originator;
@@ -320,6 +319,7 @@ impl GuardianReviewTrackContext {
pub(crate) fn event_params(
&self,
result: GuardianReviewAnalyticsResult,
completed_at_ms: u64,
) -> GuardianReviewEventParams {
GuardianReviewEventParams {
thread_id: self.thread_id.clone(),
@@ -346,7 +346,7 @@ impl GuardianReviewTrackContext {
time_to_first_token_ms: result.time_to_first_token_ms,
completion_latency_ms: Some(self.started_instant.elapsed().as_millis() as u64),
started_at: self.started_at_ms / 1_000,
completed_at: Some(now_unix_seconds()),
completed_at: Some(completed_at_ms / 1_000),
input_tokens: result.token_usage.as_ref().map(|usage| usage.input_tokens),
cached_input_tokens: result
.token_usage
@@ -429,7 +429,7 @@ pub(crate) struct GuardianReviewEventPayload {
#[allow(dead_code)]
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum ToolItemFinalApprovalOutcome {
pub(crate) enum FinalApprovalOutcome {
Unknown,
NotNeeded,
ConfigAllowed,
@@ -486,7 +486,7 @@ pub(crate) struct CodexToolItemEventBase {
pub(crate) review_count: u64,
pub(crate) guardian_review_count: u64,
pub(crate) user_review_count: u64,
pub(crate) final_approval_outcome: ToolItemFinalApprovalOutcome,
pub(crate) final_approval_outcome: FinalApprovalOutcome,
pub(crate) terminal_status: ToolItemTerminalStatus,
pub(crate) failure_kind: Option<ToolItemFailureKind>,
pub(crate) requested_additional_permissions: bool,
@@ -553,8 +553,8 @@ pub(crate) struct CodexReviewEventParams {
pub(crate) thread_source: Option<ThreadSource>,
pub(crate) subagent_source: Option<String>,
pub(crate) parent_thread_id: Option<String>,
pub(crate) tool_kind: ReviewSubjectKind,
pub(crate) tool_name: String,
pub(crate) subject_kind: ReviewSubjectKind,
pub(crate) subject_name: String,
pub(crate) reviewer: Reviewer,
pub(crate) trigger: ReviewTrigger,
pub(crate) status: ReviewStatus,
+10
View File
@@ -25,6 +25,7 @@ use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SkillScope;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::request_permissions::RequestPermissionsResponse;
use serde::Serialize;
use std::path::PathBuf;
@@ -305,6 +306,15 @@ pub(crate) enum AnalyticsFact {
completed_at_ms: u64,
response: Box<ServerResponse>,
},
EffectivePermissionsApprovalResponse {
completed_at_ms: u64,
request_id: RequestId,
response: Box<RequestPermissionsResponse>,
},
ServerRequestAborted {
completed_at_ms: u64,
request_id: RequestId,
},
Notification(Box<ServerNotification>),
// Facts that do not naturally exist on the app-server protocol surface, or
// would require non-trivial protocol reshaping on this branch.
+691 -34
View File
@@ -22,6 +22,8 @@ use crate::events::CodexMcpToolCallEventParams;
use crate::events::CodexMcpToolCallEventRequest;
use crate::events::CodexPluginEventRequest;
use crate::events::CodexPluginUsedEventRequest;
use crate::events::CodexReviewEventParams;
use crate::events::CodexReviewEventRequest;
use crate::events::CodexRuntimeMetadata;
use crate::events::CodexToolItemEventBase;
use crate::events::CodexTurnEventParams;
@@ -30,15 +32,20 @@ use crate::events::CodexTurnSteerEventParams;
use crate::events::CodexTurnSteerEventRequest;
use crate::events::CodexWebSearchEventParams;
use crate::events::CodexWebSearchEventRequest;
use crate::events::FinalApprovalOutcome;
use crate::events::GuardianReviewEventParams;
use crate::events::GuardianReviewEventPayload;
use crate::events::GuardianReviewEventRequest;
use crate::events::ReviewResolution;
use crate::events::ReviewStatus;
use crate::events::ReviewSubjectKind;
use crate::events::ReviewTrigger;
use crate::events::Reviewer;
use crate::events::SkillInvocationEventParams;
use crate::events::SkillInvocationEventRequest;
use crate::events::ThreadInitializedEvent;
use crate::events::ThreadInitializedEventParams;
use crate::events::ToolItemFailureKind;
use crate::events::ToolItemFinalApprovalOutcome;
use crate::events::ToolItemTerminalStatus;
use crate::events::TrackEventRequest;
use crate::events::WebSearchActionKind;
@@ -80,16 +87,24 @@ use codex_app_server_protocol::CollabAgentStatus;
use codex_app_server_protocol::CollabAgentTool;
use codex_app_server_protocol::CollabAgentToolCallStatus;
use codex_app_server_protocol::CommandAction;
use codex_app_server_protocol::CommandExecutionApprovalDecision;
use codex_app_server_protocol::CommandExecutionSource;
use codex_app_server_protocol::CommandExecutionStatus;
use codex_app_server_protocol::DynamicToolCallOutputContentItem;
use codex_app_server_protocol::DynamicToolCallStatus;
use codex_app_server_protocol::FileChangeApprovalDecision;
use codex_app_server_protocol::GuardianApprovalReviewAction;
use codex_app_server_protocol::GuardianApprovalReviewStatus;
use codex_app_server_protocol::InitializeParams;
use codex_app_server_protocol::McpToolCallStatus;
use codex_app_server_protocol::NetworkPolicyRuleAction;
use codex_app_server_protocol::PatchApplyStatus;
use codex_app_server_protocol::PatchChangeKind;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::RequestPermissionProfile;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ServerResponse;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::TurnSteerResponse;
use codex_app_server_protocol::UserInput;
@@ -105,6 +120,8 @@ use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SkillScope;
use codex_protocol::protocol::ThreadSource;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope;
use codex_protocol::request_permissions::RequestPermissionsResponse as CoreRequestPermissionsResponse;
use sha1::Digest;
use std::collections::HashMap;
use std::path::Path;
@@ -117,6 +134,8 @@ pub(crate) struct AnalyticsReducer {
connections: HashMap<u64, ConnectionState>,
threads: HashMap<String, ThreadAnalyticsState>,
tool_items_started_at_ms: HashMap<ToolItemKey, u64>,
pending_reviews: HashMap<RequestId, PendingReviewState>,
item_review_summaries: HashMap<ToolItemKey, ItemReviewSummary>,
}
struct ConnectionState {
@@ -150,6 +169,16 @@ impl<'a> AnalyticsDropSite<'a> {
}
}
fn review(input: &'a PendingReviewState) -> Self {
Self {
event_name: "review",
thread_id: &input.thread_id,
turn_id: Some(&input.turn_id),
review_id: Some(&input.review_id),
item_id: input.item_id.as_deref(),
}
}
fn compaction(input: &'a CodexCompactionEvent) -> Self {
Self {
event_name: "compaction",
@@ -200,6 +229,30 @@ enum MissingAnalyticsContext {
ThreadMetadata,
}
#[derive(Clone)]
struct PendingReviewState {
thread_id: String,
turn_id: String,
item_id: Option<String>,
review_id: String,
subject_kind: ReviewSubjectKind,
subject_name: String,
trigger: ReviewTrigger,
started_at_ms: u64,
requested_additional_permissions: bool,
requested_network_access: bool,
}
#[derive(Clone, Default)]
struct ItemReviewSummary {
review_count: u64,
guardian_review_count: u64,
user_review_count: u64,
final_approval_outcome: Option<FinalApprovalOutcome>,
requested_additional_permissions: bool,
requested_network_access: bool,
}
#[derive(Clone)]
struct ThreadMetadataState {
thread_source: Option<ThreadSource>,
@@ -363,13 +416,35 @@ impl AnalyticsReducer {
self.ingest_notification(*notification, out).await;
}
AnalyticsFact::ServerRequest {
connection_id: _connection_id,
request: _request,
} => {}
connection_id,
request,
} => {
self.ingest_server_request(connection_id, *request);
}
AnalyticsFact::ServerResponse {
response: _response,
..
} => {}
completed_at_ms,
response,
} => {
self.ingest_server_response(completed_at_ms, *response, out);
}
AnalyticsFact::EffectivePermissionsApprovalResponse {
completed_at_ms,
request_id,
response,
} => {
self.ingest_effective_permissions_approval_response(
completed_at_ms,
request_id,
*response,
out,
);
}
AnalyticsFact::ServerRequestAborted {
completed_at_ms,
request_id,
} => {
self.ingest_server_request_aborted(completed_at_ms, request_id, out);
}
AnalyticsFact::Custom(input) => match input {
CustomAnalyticsFact::SubAgentThreadStarted(input) => {
self.ingest_subagent_thread_started(input, out);
@@ -740,6 +815,207 @@ impl AnalyticsReducer {
}
}
fn ingest_server_request(&mut self, _connection_id: u64, request: ServerRequest) {
match request {
ServerRequest::CommandExecutionRequestApproval { request_id, params } => {
let is_network_access_review = params.network_approval_context.is_some();
let requested_network_access = is_network_access_review
|| params
.proposed_network_policy_amendments
.as_ref()
.is_some_and(|amendments| !amendments.is_empty())
|| params
.additional_permissions
.as_ref()
.and_then(|permissions| permissions.network.as_ref())
.and_then(|network| network.enabled)
.unwrap_or(false);
let requested_additional_permissions = params.additional_permissions.is_some();
let trigger = if params.approval_id.is_some() {
ReviewTrigger::ExecveIntercept
} else if requested_network_access {
ReviewTrigger::NetworkPolicyDenial
} else if requested_additional_permissions {
ReviewTrigger::SandboxDenial
} else {
ReviewTrigger::Initial
};
let Some(started_at_ms) = option_i64_to_u64(Some(params.started_at_ms)) else {
return;
};
self.pending_reviews.insert(
request_id.clone(),
PendingReviewState {
thread_id: params.thread_id,
turn_id: params.turn_id,
item_id: Some(params.item_id),
review_id: user_review_id(&request_id),
subject_kind: if is_network_access_review {
ReviewSubjectKind::NetworkAccess
} else {
ReviewSubjectKind::CommandExecution
},
subject_name: if is_network_access_review {
"network_access".to_string()
} else {
"command_execution".to_string()
},
trigger,
started_at_ms,
requested_additional_permissions,
requested_network_access,
},
);
}
ServerRequest::FileChangeRequestApproval { request_id, params } => {
let requested_additional_permissions = params.grant_root.is_some();
let Some(started_at_ms) = option_i64_to_u64(Some(params.started_at_ms)) else {
return;
};
self.pending_reviews.insert(
request_id.clone(),
PendingReviewState {
thread_id: params.thread_id,
turn_id: params.turn_id,
item_id: Some(params.item_id),
review_id: user_review_id(&request_id),
subject_kind: ReviewSubjectKind::FileChange,
subject_name: "apply_patch".to_string(),
trigger: if requested_additional_permissions {
ReviewTrigger::SandboxDenial
} else {
ReviewTrigger::Initial
},
started_at_ms,
requested_additional_permissions,
requested_network_access: false,
},
);
}
ServerRequest::PermissionsRequestApproval { request_id, params } => {
let requested_network_access = params
.permissions
.network
.as_ref()
.and_then(|network| network.enabled)
.unwrap_or(false);
let requested_additional_permissions =
requested_network_access || params.permissions.file_system.is_some();
let trigger = if requested_network_access {
ReviewTrigger::NetworkPolicyDenial
} else if requested_additional_permissions {
ReviewTrigger::SandboxDenial
} else {
ReviewTrigger::Initial
};
let Some(started_at_ms) = option_i64_to_u64(Some(params.started_at_ms)) else {
return;
};
self.pending_reviews.insert(
request_id.clone(),
PendingReviewState {
thread_id: params.thread_id,
turn_id: params.turn_id,
item_id: Some(params.item_id),
review_id: user_review_id(&request_id),
subject_kind: ReviewSubjectKind::Permissions,
subject_name: "permissions".to_string(),
trigger,
started_at_ms,
requested_additional_permissions,
requested_network_access,
},
);
}
_ => {}
}
}
fn ingest_server_response(
&mut self,
completed_at_ms: u64,
response: ServerResponse,
out: &mut Vec<TrackEventRequest>,
) {
match response {
ServerResponse::CommandExecutionRequestApproval {
request_id,
response,
} => {
let Some(pending_review) = self.pending_reviews.remove(&request_id) else {
return;
};
let (status, resolution) = command_execution_review_result(response.decision);
self.emit_review_event(
pending_review,
Reviewer::User,
status,
resolution,
completed_at_ms,
out,
);
}
ServerResponse::FileChangeRequestApproval {
request_id,
response,
} => {
let Some(pending_review) = self.pending_reviews.remove(&request_id) else {
return;
};
let (status, resolution) = file_change_review_result(response.decision);
self.emit_review_event(
pending_review,
Reviewer::User,
status,
resolution,
completed_at_ms,
out,
);
}
_ => {}
}
}
fn ingest_effective_permissions_approval_response(
&mut self,
completed_at_ms: u64,
request_id: RequestId,
response: CoreRequestPermissionsResponse,
out: &mut Vec<TrackEventRequest>,
) {
let Some(pending_review) = self.pending_reviews.remove(&request_id) else {
return;
};
let (status, resolution) = effective_permissions_review_result(&response);
self.emit_review_event(
pending_review,
Reviewer::User,
status,
resolution,
completed_at_ms,
out,
);
}
fn ingest_server_request_aborted(
&mut self,
completed_at_ms: u64,
request_id: RequestId,
out: &mut Vec<TrackEventRequest>,
) {
let Some(pending_review) = self.pending_reviews.remove(&request_id) else {
return;
};
self.emit_review_event(
pending_review,
Reviewer::User,
ReviewStatus::Aborted,
ReviewResolution::None,
completed_at_ms,
out,
);
}
fn ingest_error_response(
&mut self,
connection_id: u64,
@@ -850,17 +1126,25 @@ impl AnalyticsReducer {
else {
return;
};
if let Some(event) = tool_item_event(
&notification.thread_id,
&notification.turn_id,
&notification.item,
if let Some(event) = tool_item_event(ToolItemEventInput {
thread_id: &notification.thread_id,
turn_id: &notification.turn_id,
item: &notification.item,
started_at_ms,
completed_at_ms,
connection_state,
thread_metadata,
) {
review_summary: self.item_review_summaries.get(&key),
}) {
out.push(event);
}
self.item_review_summaries.remove(&key);
}
ServerNotification::ItemGuardianApprovalReviewStarted(notification) => {
let _ = notification;
}
ServerNotification::ItemGuardianApprovalReviewCompleted(notification) => {
self.ingest_guardian_review_completed(notification, out);
}
ServerNotification::TurnStarted(notification) => {
let turn_state = self.turns.entry(notification.turn.id).or_insert(TurnState {
@@ -1003,6 +1287,48 @@ impl AnalyticsReducer {
)));
}
fn ingest_guardian_review_completed(
&mut self,
notification: codex_app_server_protocol::ItemGuardianApprovalReviewCompletedNotification,
out: &mut Vec<TrackEventRequest>,
) {
let Some((status, resolution)) = guardian_review_result(notification.review.status) else {
return;
};
let (subject_kind, subject_name, trigger) =
guardian_review_subject_metadata(&notification.action);
let Some(started_at_ms) = option_i64_to_u64(Some(notification.started_at_ms)) else {
return;
};
let pending_review = PendingReviewState {
thread_id: notification.thread_id,
turn_id: notification.turn_id,
item_id: notification.target_item_id,
review_id: notification.review_id,
subject_kind,
subject_name,
trigger,
started_at_ms,
requested_additional_permissions: guardian_review_requested_additional_permissions(
&notification.action,
),
requested_network_access: guardian_review_requested_network_access(
&notification.action,
),
};
let Some(completed_at_ms) = option_i64_to_u64(Some(notification.completed_at_ms)) else {
return;
};
self.emit_review_event(
pending_review,
Reviewer::Guardian,
status,
resolution,
completed_at_ms,
out,
);
}
fn ingest_turn_steer_response(
&mut self,
connection_id: u64,
@@ -1068,6 +1394,73 @@ impl AnalyticsReducer {
}));
}
fn emit_review_event(
&mut self,
pending_review: PendingReviewState,
reviewer: Reviewer,
status: ReviewStatus,
resolution: ReviewResolution,
completed_at_ms: u64,
out: &mut Vec<TrackEventRequest>,
) {
if let Some(item_key) = item_review_summary_key(&pending_review) {
self.record_item_review_summary(
item_key,
reviewer,
status,
resolution,
&pending_review,
);
}
let Some((connection_state, thread_metadata)) =
self.thread_context_or_warn(AnalyticsDropSite::review(&pending_review))
else {
return;
};
out.push(TrackEventRequest::ReviewEvent(CodexReviewEventRequest {
event_type: "codex_review_event",
event_params: CodexReviewEventParams {
thread_id: pending_review.thread_id,
turn_id: pending_review.turn_id,
item_id: pending_review.item_id,
review_id: pending_review.review_id,
app_server_client: connection_state.app_server_client.clone(),
runtime: connection_state.runtime.clone(),
thread_source: thread_metadata.thread_source,
subagent_source: thread_metadata.subagent_source.clone(),
parent_thread_id: thread_metadata.parent_thread_id.clone(),
subject_kind: pending_review.subject_kind,
subject_name: pending_review.subject_name,
reviewer,
trigger: pending_review.trigger,
status,
resolution,
started_at_ms: pending_review.started_at_ms,
completed_at_ms,
duration_ms: observed_duration_ms(pending_review.started_at_ms, completed_at_ms),
},
}));
}
fn record_item_review_summary(
&mut self,
item_key: ToolItemKey,
reviewer: Reviewer,
status: ReviewStatus,
resolution: ReviewResolution,
pending_review: &PendingReviewState,
) {
let summary = self.item_review_summaries.entry(item_key).or_default();
summary.review_count += 1;
match reviewer {
Reviewer::Guardian => summary.guardian_review_count += 1,
Reviewer::User => summary.user_review_count += 1,
}
summary.final_approval_outcome = Some(final_approval_outcome(reviewer, status, resolution));
summary.requested_additional_permissions |= pending_review.requested_additional_permissions;
summary.requested_network_access |= pending_review.requested_network_access;
}
async fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec<TrackEventRequest>) {
let Some(turn_state) = self.turns.get(turn_id) else {
return;
@@ -1204,21 +1597,41 @@ fn tracked_tool_item_id(item: &ThreadItem) -> Option<&str> {
}
}
fn tool_item_event(
thread_id: &str,
turn_id: &str,
item: &ThreadItem,
fn item_review_summary_key(pending_review: &PendingReviewState) -> Option<ToolItemKey> {
match pending_review.subject_kind {
ReviewSubjectKind::CommandExecution
| ReviewSubjectKind::FileChange
| ReviewSubjectKind::McpToolCall => Some(ToolItemKey {
thread_id: pending_review.thread_id.clone(),
turn_id: pending_review.turn_id.clone(),
item_id: pending_review.item_id.clone()?,
}),
ReviewSubjectKind::Permissions | ReviewSubjectKind::NetworkAccess => None,
}
}
struct ToolItemEventInput<'a> {
thread_id: &'a str,
turn_id: &'a str,
item: &'a ThreadItem,
started_at_ms: u64,
completed_at_ms: u64,
connection_state: &ConnectionState,
thread_metadata: &ThreadMetadataState,
) -> Option<TrackEventRequest> {
let context = ToolItemContext {
connection_state: &'a ConnectionState,
thread_metadata: &'a ThreadMetadataState,
review_summary: Option<&'a ItemReviewSummary>,
}
fn tool_item_event(input: ToolItemEventInput<'_>) -> Option<TrackEventRequest> {
let ToolItemEventInput {
thread_id,
turn_id,
item,
started_at_ms,
completed_at_ms,
connection_state,
thread_metadata,
};
review_summary,
} = input;
match item {
ThreadItem::CommandExecution {
id,
@@ -1241,7 +1654,13 @@ fn tool_item_event(
failure_kind,
execution_duration_ms: option_i64_to_u64(*duration_ms),
},
context,
ToolItemContext {
started_at_ms,
completed_at_ms,
connection_state,
thread_metadata,
review_summary,
},
);
Some(TrackEventRequest::CommandExecution(
CodexCommandExecutionEventRequest {
@@ -1276,7 +1695,13 @@ fn tool_item_event(
failure_kind,
execution_duration_ms: None,
},
context,
ToolItemContext {
started_at_ms,
completed_at_ms,
connection_state,
thread_metadata,
review_summary,
},
);
Some(TrackEventRequest::FileChange(CodexFileChangeEventRequest {
event_type: "codex_file_change_event",
@@ -1310,7 +1735,13 @@ fn tool_item_event(
failure_kind,
execution_duration_ms: option_i64_to_u64(*duration_ms),
},
context,
ToolItemContext {
started_at_ms,
completed_at_ms,
connection_state,
thread_metadata,
review_summary,
},
);
Some(TrackEventRequest::McpToolCall(
CodexMcpToolCallEventRequest {
@@ -1347,7 +1778,13 @@ fn tool_item_event(
failure_kind,
execution_duration_ms: option_i64_to_u64(*duration_ms),
},
context,
ToolItemContext {
started_at_ms,
completed_at_ms,
connection_state,
thread_metadata,
review_summary,
},
);
Some(TrackEventRequest::DynamicToolCall(
CodexDynamicToolCallEventRequest {
@@ -1385,7 +1822,13 @@ fn tool_item_event(
failure_kind,
execution_duration_ms: None,
},
context,
ToolItemContext {
started_at_ms,
completed_at_ms,
connection_state,
thread_metadata,
review_summary,
},
);
Some(TrackEventRequest::CollabAgentToolCall(
CodexCollabAgentToolCallEventRequest {
@@ -1434,7 +1877,13 @@ fn tool_item_event(
failure_kind: None,
execution_duration_ms: None,
},
context,
ToolItemContext {
started_at_ms,
completed_at_ms,
connection_state,
thread_metadata,
review_summary,
},
);
Some(TrackEventRequest::WebSearch(CodexWebSearchEventRequest {
event_type: "codex_web_search_event",
@@ -1464,7 +1913,13 @@ fn tool_item_event(
failure_kind,
execution_duration_ms: None,
},
context,
ToolItemContext {
started_at_ms,
completed_at_ms,
connection_state,
thread_metadata,
review_summary,
},
);
Some(TrackEventRequest::ImageGeneration(
CodexImageGenerationEventRequest {
@@ -1518,6 +1973,7 @@ struct ToolItemContext<'a> {
completed_at_ms: u64,
connection_state: &'a ConnectionState,
thread_metadata: &'a ThreadMetadataState,
review_summary: Option<&'a ItemReviewSummary>,
}
fn tool_item_base(
@@ -1529,6 +1985,7 @@ fn tool_item_base(
context: ToolItemContext<'_>,
) -> CodexToolItemEventBase {
let thread_metadata = context.thread_metadata;
let review_summary = context.review_summary.cloned().unwrap_or_default();
CodexToolItemEventBase {
thread_id: thread_id.to_string(),
turn_id: turn_id.to_string(),
@@ -1546,14 +2003,16 @@ fn tool_item_base(
// full upstream execution time.
duration_ms: observed_duration_ms(context.started_at_ms, context.completed_at_ms),
execution_duration_ms: outcome.execution_duration_ms,
review_count: 0,
guardian_review_count: 0,
user_review_count: 0,
final_approval_outcome: ToolItemFinalApprovalOutcome::Unknown,
review_count: review_summary.review_count,
guardian_review_count: review_summary.guardian_review_count,
user_review_count: review_summary.user_review_count,
final_approval_outcome: review_summary
.final_approval_outcome
.unwrap_or(FinalApprovalOutcome::Unknown),
terminal_status: outcome.terminal_status,
failure_kind: outcome.failure_kind,
requested_additional_permissions: false,
requested_network_access: false,
requested_additional_permissions: review_summary.requested_additional_permissions,
requested_network_access: review_summary.requested_network_access,
}
}
@@ -1561,6 +2020,195 @@ fn observed_duration_ms(started_at_ms: u64, completed_at_ms: u64) -> Option<u64>
completed_at_ms.checked_sub(started_at_ms)
}
fn user_review_id(request_id: &RequestId) -> String {
format!("user:{request_id}")
}
fn command_execution_review_result(
decision: CommandExecutionApprovalDecision,
) -> (ReviewStatus, ReviewResolution) {
match decision {
CommandExecutionApprovalDecision::Accept => {
(ReviewStatus::Approved, ReviewResolution::None)
}
CommandExecutionApprovalDecision::AcceptForSession => {
(ReviewStatus::Approved, ReviewResolution::SessionApproval)
}
CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment { .. } => (
ReviewStatus::Approved,
ReviewResolution::ExecPolicyAmendment,
),
CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment {
network_policy_amendment,
} => match network_policy_amendment.action {
NetworkPolicyRuleAction::Allow => (
ReviewStatus::Approved,
ReviewResolution::NetworkPolicyAmendment,
),
NetworkPolicyRuleAction::Deny => (
ReviewStatus::Denied,
ReviewResolution::NetworkPolicyAmendment,
),
},
CommandExecutionApprovalDecision::Decline => (ReviewStatus::Denied, ReviewResolution::None),
CommandExecutionApprovalDecision::Cancel => (ReviewStatus::Aborted, ReviewResolution::None),
}
}
fn file_change_review_result(
decision: FileChangeApprovalDecision,
) -> (ReviewStatus, ReviewResolution) {
match decision {
FileChangeApprovalDecision::Accept => (ReviewStatus::Approved, ReviewResolution::None),
FileChangeApprovalDecision::AcceptForSession => {
(ReviewStatus::Approved, ReviewResolution::SessionApproval)
}
FileChangeApprovalDecision::Decline => (ReviewStatus::Denied, ReviewResolution::None),
FileChangeApprovalDecision::Cancel => (ReviewStatus::Aborted, ReviewResolution::None),
}
}
fn effective_permissions_review_result(
response: &CoreRequestPermissionsResponse,
) -> (ReviewStatus, ReviewResolution) {
if response.permissions.is_empty() {
return (ReviewStatus::Denied, ReviewResolution::None);
}
match response.scope {
CorePermissionGrantScope::Turn => (ReviewStatus::Approved, ReviewResolution::None),
CorePermissionGrantScope::Session => {
(ReviewStatus::Approved, ReviewResolution::SessionApproval)
}
}
}
fn guardian_review_result(
status: GuardianApprovalReviewStatus,
) -> Option<(ReviewStatus, ReviewResolution)> {
match status {
GuardianApprovalReviewStatus::InProgress => None,
GuardianApprovalReviewStatus::Approved => {
Some((ReviewStatus::Approved, ReviewResolution::None))
}
GuardianApprovalReviewStatus::Denied => {
Some((ReviewStatus::Denied, ReviewResolution::None))
}
GuardianApprovalReviewStatus::TimedOut => {
Some((ReviewStatus::TimedOut, ReviewResolution::None))
}
GuardianApprovalReviewStatus::Aborted => {
Some((ReviewStatus::Aborted, ReviewResolution::None))
}
}
}
fn guardian_review_subject_metadata(
action: &GuardianApprovalReviewAction,
) -> (ReviewSubjectKind, String, ReviewTrigger) {
match action {
GuardianApprovalReviewAction::Command { .. } => (
ReviewSubjectKind::CommandExecution,
"command_execution".to_string(),
ReviewTrigger::Initial,
),
GuardianApprovalReviewAction::Execve { .. } => (
ReviewSubjectKind::CommandExecution,
"command_execution".to_string(),
ReviewTrigger::ExecveIntercept,
),
GuardianApprovalReviewAction::ApplyPatch { .. } => (
ReviewSubjectKind::FileChange,
"apply_patch".to_string(),
ReviewTrigger::SandboxDenial,
),
GuardianApprovalReviewAction::NetworkAccess { .. } => (
ReviewSubjectKind::NetworkAccess,
"network_access".to_string(),
ReviewTrigger::NetworkPolicyDenial,
),
GuardianApprovalReviewAction::RequestPermissions { permissions, .. } => {
let requested_network_access = permissions
.network
.as_ref()
.and_then(|network| network.enabled)
.unwrap_or(false);
let trigger = if requested_network_access {
ReviewTrigger::NetworkPolicyDenial
} else if permissions.file_system.is_some() {
ReviewTrigger::SandboxDenial
} else {
ReviewTrigger::Initial
};
(
ReviewSubjectKind::Permissions,
"permissions".to_string(),
trigger,
)
}
GuardianApprovalReviewAction::McpToolCall { tool_name, .. } => (
ReviewSubjectKind::McpToolCall,
tool_name.clone(),
ReviewTrigger::Initial,
),
}
}
fn guardian_review_requested_additional_permissions(action: &GuardianApprovalReviewAction) -> bool {
match action {
GuardianApprovalReviewAction::ApplyPatch { .. }
| GuardianApprovalReviewAction::NetworkAccess { .. } => true,
GuardianApprovalReviewAction::RequestPermissions { permissions, .. } => {
guardian_review_request_permissions_network_enabled(permissions)
|| permissions.file_system.is_some()
}
GuardianApprovalReviewAction::Command { .. }
| GuardianApprovalReviewAction::Execve { .. }
| GuardianApprovalReviewAction::McpToolCall { .. } => false,
}
}
fn guardian_review_requested_network_access(action: &GuardianApprovalReviewAction) -> bool {
match action {
GuardianApprovalReviewAction::NetworkAccess { .. } => true,
GuardianApprovalReviewAction::RequestPermissions { permissions, .. } => {
guardian_review_request_permissions_network_enabled(permissions)
}
GuardianApprovalReviewAction::ApplyPatch { .. }
| GuardianApprovalReviewAction::Command { .. }
| GuardianApprovalReviewAction::Execve { .. }
| GuardianApprovalReviewAction::McpToolCall { .. } => false,
}
}
fn guardian_review_request_permissions_network_enabled(
permissions: &RequestPermissionProfile,
) -> bool {
permissions
.network
.as_ref()
.and_then(|network| network.enabled)
.unwrap_or(false)
}
fn final_approval_outcome(
reviewer: Reviewer,
status: ReviewStatus,
resolution: ReviewResolution,
) -> FinalApprovalOutcome {
match (reviewer, status, resolution) {
(Reviewer::Guardian, ReviewStatus::Approved, _) => FinalApprovalOutcome::GuardianApproved,
(Reviewer::Guardian, ReviewStatus::Denied, _) => FinalApprovalOutcome::GuardianDenied,
(Reviewer::Guardian, _, _) => FinalApprovalOutcome::GuardianAborted,
(Reviewer::User, ReviewStatus::Approved, ReviewResolution::SessionApproval) => {
FinalApprovalOutcome::UserApprovedForSession
}
(Reviewer::User, ReviewStatus::Approved, _) => FinalApprovalOutcome::UserApproved,
(Reviewer::User, ReviewStatus::Denied, _) => FinalApprovalOutcome::UserDenied,
(Reviewer::User, _, _) => FinalApprovalOutcome::UserAborted,
}
}
fn command_execution_tool_name(source: CommandExecutionSource) -> &'static str {
match source {
CommandExecutionSource::UnifiedExecStartup
@@ -1990,4 +2638,13 @@ mod tests {
"external_sandbox"
);
}
#[test]
fn guardian_review_result_maps_terminal_statuses() {
assert!(guardian_review_result(GuardianApprovalReviewStatus::InProgress).is_none());
assert!(matches!(
guardian_review_result(GuardianApprovalReviewStatus::TimedOut),
Some((ReviewStatus::TimedOut, ReviewResolution::None))
));
}
}
+2
View File
@@ -21,6 +21,8 @@ codex-core = { workspace = true }
codex-exec-server = { workspace = true }
codex-feedback = { workspace = true }
codex-protocol = { workspace = true }
codex-uds = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-rustls-provider = { workspace = true }
futures = { workspace = true }
serde = { workspace = true }
+107 -22
View File
@@ -25,6 +25,7 @@ use std::io::Result as IoResult;
use std::sync::Arc;
use std::time::Duration;
pub use codex_app_server::app_server_control_socket_path;
pub use codex_app_server::in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY;
pub use codex_app_server::in_process::InProcessServerEvent;
use codex_app_server::in_process::InProcessStartArgs;
@@ -61,6 +62,7 @@ use tracing::warn;
pub use crate::remote::RemoteAppServerClient;
pub use crate::remote::RemoteAppServerConnectArgs;
pub use crate::remote::RemoteAppServerEndpoint;
/// Transitional access to core-only embedded app-server types.
///
@@ -334,6 +336,8 @@ pub struct InProcessClientStartArgs {
pub cli_overrides: Vec<(String, TomlValue)>,
/// Loader override knobs used by config API paths.
pub loader_overrides: LoaderOverrides,
/// Whether config API paths should reject unknown config fields.
pub strict_config: bool,
/// Preloaded cloud requirements provider.
pub cloud_requirements: CloudRequirementsLoader,
/// Feedback sink used by app-server/core telemetry and logs.
@@ -400,6 +404,7 @@ impl InProcessClientStartArgs {
config: self.config,
cli_overrides: self.cli_overrides,
loader_overrides: self.loader_overrides,
strict_config: self.strict_config,
cloud_requirements: self.cloud_requirements,
thread_config_loader,
feedback: self.feedback,
@@ -952,6 +957,8 @@ mod tests {
use codex_app_server_protocol::ToolRequestUserInputQuestion;
use codex_core::config::ConfigBuilder;
use codex_core::init_state_db;
use codex_uds::UnixListener;
use codex_utils_absolute_path::AbsolutePathBuf;
use futures::SinkExt;
use futures::StreamExt;
use pretty_assertions::assert_eq;
@@ -961,6 +968,7 @@ mod tests {
use tokio::net::TcpListener;
use tokio::time::Duration;
use tokio::time::timeout;
use tokio_tungstenite::accept_async;
use tokio_tungstenite::accept_hdr_async;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::handshake::server::Request as WebSocketRequest;
@@ -1025,6 +1033,7 @@ mod tests {
config,
cli_overrides: Vec::new(),
loader_overrides: LoaderOverrides::default(),
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
feedback: CodexFeedback::new(),
log_db: None,
@@ -1100,9 +1109,10 @@ mod tests {
format!("ws://{addr}")
}
async fn expect_remote_initialize(
websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>,
) {
async fn expect_remote_initialize<S>(websocket: &mut tokio_tungstenite::WebSocketStream<S>)
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
let JSONRPCMessage::Request(request) = read_websocket_message(websocket).await else {
panic!("expected initialize request");
};
@@ -1123,9 +1133,12 @@ mod tests {
assert_eq!(notification.method, "initialized");
}
async fn read_websocket_message(
websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>,
) -> JSONRPCMessage {
async fn read_websocket_message<S>(
websocket: &mut tokio_tungstenite::WebSocketStream<S>,
) -> JSONRPCMessage
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
loop {
let frame = websocket
.next()
@@ -1145,10 +1158,12 @@ mod tests {
}
}
async fn write_websocket_message(
websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>,
async fn write_websocket_message<S>(
websocket: &mut tokio_tungstenite::WebSocketStream<S>,
message: JSONRPCMessage,
) {
) where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
websocket
.send(Message::Text(
serde_json::to_string(&message)
@@ -1213,8 +1228,10 @@ mod tests {
fn test_remote_connect_args(websocket_url: String) -> RemoteAppServerConnectArgs {
RemoteAppServerConnectArgs {
websocket_url,
auth_token: None,
endpoint: RemoteAppServerEndpoint::WebSocket {
websocket_url,
auth_token: None,
},
client_name: "codex-app-server-client-test".to_string(),
client_version: "0.0.0-test".to_string(),
experimental_api: true,
@@ -1453,6 +1470,64 @@ mod tests {
client.shutdown().await.expect("shutdown should complete");
}
#[tokio::test]
async fn remote_unix_socket_typed_request_roundtrip_works() {
let socket_dir = TempDir::new().expect("socket dir");
let socket_path = AbsolutePathBuf::from_absolute_path(socket_dir.path().join("codex.sock"))
.expect("socket path should resolve");
let mut listener = UnixListener::bind(socket_path.as_path())
.await
.expect("listener should bind");
tokio::spawn(async move {
let stream = listener.accept().await.expect("accept should succeed");
let mut websocket = accept_async(stream)
.await
.expect("websocket upgrade should succeed");
expect_remote_initialize(&mut websocket).await;
let JSONRPCMessage::Request(request) = read_websocket_message(&mut websocket).await
else {
panic!("expected account/read request");
};
assert_eq!(request.method, "account/read");
write_websocket_message(
&mut websocket,
JSONRPCMessage::Response(JSONRPCResponse {
id: request.id,
result: serde_json::to_value(GetAccountResponse {
account: None,
requires_openai_auth: false,
})
.expect("response should serialize"),
}),
)
.await;
websocket.close(None).await.expect("close should succeed");
});
let client = RemoteAppServerClient::connect(RemoteAppServerConnectArgs {
endpoint: RemoteAppServerEndpoint::UnixSocket { socket_path },
client_name: "codex-app-server-client-test".to_string(),
client_version: "0.0.0-test".to_string(),
experimental_api: true,
opt_out_notification_methods: Vec::new(),
channel_capacity: 8,
})
.await
.expect("remote client should connect");
let response: GetAccountResponse = client
.request_typed(ClientRequest::GetAccount {
request_id: RequestId::Integer(1),
params: codex_app_server_protocol::GetAccountParams {
refresh_token: false,
},
})
.await
.expect("typed request should succeed");
assert_eq!(response.account, None);
client.shutdown().await.expect("shutdown should complete");
}
#[tokio::test]
async fn remote_typed_request_accepts_large_single_frame_response() {
let padding = "x".repeat((17 << 20) + 1024);
@@ -1514,8 +1589,15 @@ mod tests {
)
.await;
let client = RemoteAppServerClient::connect(RemoteAppServerConnectArgs {
auth_token: Some(auth_token),
..test_remote_connect_args(websocket_url)
endpoint: RemoteAppServerEndpoint::WebSocket {
websocket_url,
auth_token: Some(auth_token),
},
client_name: "codex-app-server-client-test".to_string(),
client_version: "0.0.0-test".to_string(),
experimental_api: true,
opt_out_notification_methods: Vec::new(),
channel_capacity: 8,
})
.await
.expect("remote client should connect");
@@ -1526,9 +1608,15 @@ mod tests {
#[tokio::test]
async fn remote_connect_rejects_non_loopback_ws_when_auth_configured() {
let result = RemoteAppServerClient::connect(RemoteAppServerConnectArgs {
websocket_url: "ws://example.com:4500".to_string(),
auth_token: Some("remote-bearer-token".to_string()),
..test_remote_connect_args("ws://127.0.0.1:1".to_string())
endpoint: RemoteAppServerEndpoint::WebSocket {
websocket_url: "ws://example.com:4500".to_string(),
auth_token: Some("remote-bearer-token".to_string()),
},
client_name: "codex-app-server-client-test".to_string(),
client_version: "0.0.0-test".to_string(),
experimental_api: true,
opt_out_notification_methods: Vec::new(),
channel_capacity: 8,
})
.await;
let err = match result {
@@ -1706,13 +1794,8 @@ mod tests {
})
.await;
let mut client = RemoteAppServerClient::connect(RemoteAppServerConnectArgs {
websocket_url,
auth_token: None,
client_name: "codex-app-server-client-test".to_string(),
client_version: "0.0.0-test".to_string(),
experimental_api: true,
opt_out_notification_methods: Vec::new(),
channel_capacity: 1,
..test_remote_connect_args(websocket_url)
})
.await
.expect("remote client should connect");
@@ -2109,6 +2192,7 @@ mod tests {
config: config.clone(),
cli_overrides: Vec::new(),
loader_overrides: LoaderOverrides::default(),
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
feedback: CodexFeedback::new(),
log_db: None,
@@ -2149,6 +2233,7 @@ mod tests {
config: Arc::new(config),
cli_overrides: Vec::new(),
loader_overrides: LoaderOverrides::default(),
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
feedback: CodexFeedback::new(),
log_db: None,
+226 -99
View File
@@ -1,11 +1,13 @@
/*
This module implements the websocket-backed app-server client transport.
This module implements the remote app-server client transport.
It owns the remote connection lifecycle, including the initialize/initialized
handshake, JSON-RPC request/response routing, server-request resolution, and
notification streaming. The rest of the crate uses the same `AppServerEvent`
surface for both in-process and remote transports, so callers such as the TUI
can switch between them without changing their higher-level session logic.
notification streaming. Remote connections always carry WebSocket frames, over
either TCP WebSocket URLs or local Unix sockets. The rest of the crate uses the
same `AppServerEvent` surface for both in-process and remote transports, so
callers such as the TUI can switch between them without changing their
higher-level session logic.
*/
use std::collections::HashMap;
@@ -35,17 +37,23 @@ use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::Result as JsonRpcResult;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
use codex_uds::UnixStream;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_rustls_provider::ensure_rustls_crypto_provider;
use futures::SinkExt;
use futures::StreamExt;
use serde::de::DeserializeOwned;
use tokio::io::AsyncRead;
use tokio::io::AsyncWrite;
use tokio::net::TcpStream;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use tokio::time::timeout;
use tokio_tungstenite::MaybeTlsStream;
use tokio_tungstenite::WebSocketStream;
use tokio_tungstenite::client_async_with_config;
use tokio_tungstenite::connect_async_with_config;
use tokio_tungstenite::tungstenite::Error as TungsteniteError;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::http::HeaderValue;
@@ -57,18 +65,30 @@ use url::Url;
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const INITIALIZE_TIMEOUT: Duration = Duration::from_secs(10);
const REMOTE_APP_SERVER_MAX_WEBSOCKET_MESSAGE_SIZE: usize = 128 << 20;
// Tungstenite still needs an HTTP request URI for the WebSocket handshake;
// the bytes travel over the Unix socket, not TCP.
const UDS_WEBSOCKET_HANDSHAKE_URL: &str = "ws://localhost/rpc";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RemoteAppServerEndpoint {
WebSocket {
websocket_url: String,
auth_token: Option<String>,
},
UnixSocket {
socket_path: AbsolutePathBuf,
},
}
#[derive(Debug, Clone)]
pub struct RemoteAppServerConnectArgs {
pub websocket_url: String,
pub auth_token: Option<String>,
pub endpoint: RemoteAppServerEndpoint,
pub client_name: String,
pub client_version: String,
pub experimental_api: bool,
pub opt_out_notification_methods: Vec<String>,
pub channel_capacity: usize,
}
impl RemoteAppServerConnectArgs {
fn initialize_params(&self) -> InitializeParams {
let capabilities = InitializeCapabilities {
@@ -141,69 +161,39 @@ pub struct RemoteAppServerRequestHandle {
impl RemoteAppServerClient {
pub async fn connect(args: RemoteAppServerConnectArgs) -> IoResult<Self> {
let channel_capacity = args.channel_capacity.max(1);
let websocket_url = args.websocket_url.clone();
let url = Url::parse(&websocket_url).map_err(|err| {
IoError::new(
ErrorKind::InvalidInput,
format!("invalid websocket URL `{websocket_url}`: {err}"),
)
})?;
if args.auth_token.is_some() && !websocket_url_supports_auth_token(&url) {
return Err(IoError::new(
ErrorKind::InvalidInput,
format!(
"remote auth tokens require `wss://` or loopback `ws://` URLs; got `{websocket_url}`"
),
));
let initialize_params = args.initialize_params();
match args.endpoint {
RemoteAppServerEndpoint::WebSocket {
websocket_url,
auth_token,
} => {
let (endpoint, stream) =
connect_websocket_endpoint(websocket_url, auth_token).await?;
Self::connect_with_stream(channel_capacity, endpoint, stream, initialize_params)
.await
}
RemoteAppServerEndpoint::UnixSocket { socket_path } => {
let (endpoint, stream) = connect_unix_socket_endpoint(socket_path).await?;
Self::connect_with_stream(channel_capacity, endpoint, stream, initialize_params)
.await
}
}
let mut request = url.as_str().into_client_request().map_err(|err| {
IoError::new(
ErrorKind::InvalidInput,
format!("invalid websocket URL `{websocket_url}`: {err}"),
)
})?;
if let Some(auth_token) = args.auth_token.as_deref() {
let header_value =
HeaderValue::from_str(&format!("Bearer {auth_token}")).map_err(|err| {
IoError::new(
ErrorKind::InvalidInput,
format!("invalid remote authorization header value: {err}"),
)
})?;
request.headers_mut().insert(AUTHORIZATION, header_value);
}
ensure_rustls_crypto_provider();
// Remote resume responses can legitimately carry large thread histories.
// Keep a bounded cap, but raise it above tungstenite's 16 MiB frame default.
let websocket_config = WebSocketConfig::default()
.max_frame_size(Some(REMOTE_APP_SERVER_MAX_WEBSOCKET_MESSAGE_SIZE))
.max_message_size(Some(REMOTE_APP_SERVER_MAX_WEBSOCKET_MESSAGE_SIZE));
let stream = timeout(
CONNECT_TIMEOUT,
connect_async_with_config(
request,
Some(websocket_config),
/*disable_nagle*/ false,
),
)
.await
.map_err(|_| {
IoError::new(
ErrorKind::TimedOut,
format!("timed out connecting to remote app server at `{websocket_url}`"),
)
})?
.map(|(stream, _response)| stream)
.map_err(|err| {
IoError::other(format!(
"failed to connect to remote app server at `{websocket_url}`: {err}"
))
})?;
}
async fn connect_with_stream<S>(
channel_capacity: usize,
endpoint: String,
stream: WebSocketStream<S>,
initialize_params: InitializeParams,
) -> IoResult<Self>
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
let mut stream = stream;
let pending_events = initialize_remote_connection(
&mut stream,
&websocket_url,
args.initialize_params(),
&endpoint,
initialize_params,
INITIALIZE_TIMEOUT,
)
.await?;
@@ -235,13 +225,13 @@ impl RemoteAppServerClient {
if let Err(err) = write_jsonrpc_message(
&mut stream,
JSONRPCMessage::Request(jsonrpc_request_from_client_request(*request)),
&websocket_url,
&endpoint,
)
.await
{
let err_message = err.to_string();
let message = format!(
"remote app server at `{websocket_url}` write failed: {err_message}"
"remote app server at `{endpoint}` write failed: {err_message}"
);
if let Some(response_tx) = pending_requests.remove(&request_id) {
let _ = response_tx.send(Err(err));
@@ -262,7 +252,7 @@ impl RemoteAppServerClient {
JSONRPCMessage::Notification(
jsonrpc_notification_from_client_notification(notification),
),
&websocket_url,
&endpoint,
)
.await;
let _ = response_tx.send(result);
@@ -278,7 +268,7 @@ impl RemoteAppServerClient {
id: request_id,
result,
}),
&websocket_url,
&endpoint,
)
.await;
let _ = response_tx.send(result);
@@ -294,16 +284,20 @@ impl RemoteAppServerClient {
error,
id: request_id,
}),
&websocket_url,
&endpoint,
)
.await;
let _ = response_tx.send(result);
}
RemoteClientCommand::Shutdown { response_tx } => {
let close_result = stream.close(None).await.map_err(|err| {
IoError::other(format!(
"failed to close websocket app server `{websocket_url}`: {err}"
))
let close_result = stream.close(None).await.or_else(|err| {
if websocket_close_error_is_already_closed(&err) {
Ok(())
} else {
Err(IoError::other(format!(
"failed to close websocket app server `{endpoint}`: {err}"
)))
}
});
let _ = response_tx.send(close_result);
break;
@@ -364,13 +358,13 @@ impl RemoteAppServerClient {
},
id: request_id,
}),
&websocket_url,
&endpoint,
)
.await
{
let err_message = reject_err.to_string();
let message = format!(
"remote app server at `{websocket_url}` write failed: {err_message}"
"remote app server at `{endpoint}` write failed: {err_message}"
);
let _ = deliver_event(
&event_tx,
@@ -387,7 +381,7 @@ impl RemoteAppServerClient {
}
Err(err) => {
let message = format!(
"remote app server at `{websocket_url}` sent invalid JSON-RPC: {err}"
"remote app server at `{endpoint}` sent invalid JSON-RPC: {err}"
);
let _ = deliver_event(
&event_tx,
@@ -408,7 +402,7 @@ impl RemoteAppServerClient {
.filter(|reason| !reason.is_empty())
.unwrap_or_else(|| "connection closed".to_string());
let message = format!(
"remote app server at `{websocket_url}` disconnected: {reason}"
"remote app server at `{endpoint}` disconnected: {reason}"
);
let _ = deliver_event(
&event_tx,
@@ -428,7 +422,7 @@ impl RemoteAppServerClient {
| Some(Ok(Message::Frame(_))) => {}
Some(Err(err)) => {
let message = format!(
"remote app server at `{websocket_url}` transport failed: {err}"
"remote app server at `{endpoint}` transport failed: {err}"
);
let _ = deliver_event(
&event_tx,
@@ -441,7 +435,7 @@ impl RemoteAppServerClient {
}
None => {
let message = format!(
"remote app server at `{websocket_url}` closed the connection"
"remote app server at `{endpoint}` closed the connection"
);
let _ = deliver_event(
&event_tx,
@@ -678,12 +672,131 @@ impl RemoteAppServerRequestHandle {
}
}
async fn initialize_remote_connection(
stream: &mut WebSocketStream<MaybeTlsStream<TcpStream>>,
websocket_url: &str,
async fn connect_websocket_endpoint(
websocket_url: String,
auth_token: Option<String>,
) -> IoResult<(String, WebSocketStream<MaybeTlsStream<TcpStream>>)> {
let url = Url::parse(&websocket_url).map_err(|err| {
IoError::new(
ErrorKind::InvalidInput,
format!("invalid websocket URL `{websocket_url}`: {err}"),
)
})?;
if auth_token.is_some() && !websocket_url_supports_auth_token(&url) {
return Err(IoError::new(
ErrorKind::InvalidInput,
format!(
"remote auth tokens require `wss://` or loopback `ws://` URLs; got `{websocket_url}`"
),
));
}
let mut request = url.as_str().into_client_request().map_err(|err| {
IoError::new(
ErrorKind::InvalidInput,
format!("invalid websocket URL `{websocket_url}`: {err}"),
)
})?;
if let Some(auth_token) = auth_token.as_deref() {
let header_value =
HeaderValue::from_str(&format!("Bearer {auth_token}")).map_err(|err| {
IoError::new(
ErrorKind::InvalidInput,
format!("invalid remote authorization header value: {err}"),
)
})?;
request.headers_mut().insert(AUTHORIZATION, header_value);
}
ensure_rustls_crypto_provider();
let websocket_config = remote_websocket_config();
let stream = timeout(
CONNECT_TIMEOUT,
connect_async_with_config(
request,
Some(websocket_config),
/*disable_nagle*/ false,
),
)
.await
.map_err(|_| {
IoError::new(
ErrorKind::TimedOut,
format!("timed out connecting to remote app server at `{websocket_url}`"),
)
})?
.map(|(stream, _response)| stream)
.map_err(|err| {
IoError::other(format!(
"failed to connect to remote app server at `{websocket_url}`: {err}"
))
})?;
Ok((websocket_url, stream))
}
async fn connect_unix_socket_endpoint(
socket_path: AbsolutePathBuf,
) -> IoResult<(String, WebSocketStream<UnixStream>)> {
let endpoint = format!("unix://{}", socket_path.display());
let request = UDS_WEBSOCKET_HANDSHAKE_URL
.into_client_request()
.map_err(|err| {
IoError::new(
ErrorKind::InvalidInput,
format!("invalid UDS websocket handshake URL: {err}"),
)
})?;
let stream = timeout(CONNECT_TIMEOUT, UnixStream::connect(socket_path.as_path()))
.await
.map_err(|_| {
IoError::new(
ErrorKind::TimedOut,
format!("timed out connecting to remote app server at `{endpoint}`"),
)
})?
.map_err(|err| {
IoError::other(format!(
"failed to connect to remote app server at `{endpoint}`: {err}"
))
})?;
let websocket_config = remote_websocket_config();
let stream = timeout(
CONNECT_TIMEOUT,
client_async_with_config(request, stream, Some(websocket_config)),
)
.await
.map_err(|_| {
IoError::new(
ErrorKind::TimedOut,
format!("timed out upgrading remote app server at `{endpoint}`"),
)
})?
.map(|(stream, _response)| stream)
.map_err(|err| {
IoError::other(format!(
"failed to upgrade remote app server at `{endpoint}`: {err}"
))
})?;
Ok((endpoint, stream))
}
fn remote_websocket_config() -> WebSocketConfig {
WebSocketConfig::default()
.max_frame_size(Some(REMOTE_APP_SERVER_MAX_WEBSOCKET_MESSAGE_SIZE))
.max_message_size(Some(REMOTE_APP_SERVER_MAX_WEBSOCKET_MESSAGE_SIZE))
}
async fn initialize_remote_connection<S>(
stream: &mut WebSocketStream<S>,
endpoint: &str,
params: InitializeParams,
initialize_timeout: Duration,
) -> IoResult<Vec<AppServerEvent>> {
) -> IoResult<Vec<AppServerEvent>>
where
S: AsyncRead + AsyncWrite + Unpin,
{
let initialize_request_id = RequestId::String("initialize".to_string());
let mut pending_events = Vec::new();
write_jsonrpc_message(
@@ -694,7 +807,7 @@ async fn initialize_remote_connection(
params,
},
)),
websocket_url,
endpoint,
)
.await?;
@@ -704,7 +817,7 @@ async fn initialize_remote_connection(
Some(Ok(Message::Text(text))) => {
let message = serde_json::from_str::<JSONRPCMessage>(&text).map_err(|err| {
IoError::other(format!(
"remote app server at `{websocket_url}` sent invalid initialize response: {err}"
"remote app server at `{endpoint}` sent invalid initialize response: {err}"
))
})?;
match message {
@@ -713,7 +826,7 @@ async fn initialize_remote_connection(
}
JSONRPCMessage::Error(error) if error.id == initialize_request_id => {
break Err(IoError::other(format!(
"remote app server at `{websocket_url}` rejected initialize: {}",
"remote app server at `{endpoint}` rejected initialize: {}",
error.error.message
)));
}
@@ -743,7 +856,7 @@ async fn initialize_remote_connection(
},
id: request_id,
}),
websocket_url,
endpoint,
)
.await?;
}
@@ -765,19 +878,19 @@ async fn initialize_remote_connection(
break Err(IoError::new(
ErrorKind::ConnectionAborted,
format!(
"remote app server at `{websocket_url}` closed during initialize: {reason}"
"remote app server at `{endpoint}` closed during initialize: {reason}"
),
));
}
Some(Err(err)) => {
break Err(IoError::other(format!(
"remote app server at `{websocket_url}` transport failed during initialize: {err}"
"remote app server at `{endpoint}` transport failed during initialize: {err}"
)));
}
None => {
break Err(IoError::new(
ErrorKind::UnexpectedEof,
format!("remote app server at `{websocket_url}` closed during initialize"),
format!("remote app server at `{endpoint}` closed during initialize"),
));
}
}
@@ -787,7 +900,7 @@ async fn initialize_remote_connection(
.map_err(|_| {
IoError::new(
ErrorKind::TimedOut,
format!("timed out waiting for initialize response from `{websocket_url}`"),
format!("timed out waiting for initialize response from `{endpoint}`"),
)
})??;
@@ -796,7 +909,7 @@ async fn initialize_remote_connection(
JSONRPCMessage::Notification(jsonrpc_notification_from_client_notification(
ClientNotification::Initialized,
)),
websocket_url,
endpoint,
)
.await?;
@@ -850,21 +963,35 @@ fn jsonrpc_notification_from_client_notification(
}
}
async fn write_jsonrpc_message(
stream: &mut WebSocketStream<MaybeTlsStream<TcpStream>>,
async fn write_jsonrpc_message<S>(
stream: &mut WebSocketStream<S>,
message: JSONRPCMessage,
websocket_url: &str,
) -> IoResult<()> {
endpoint: &str,
) -> IoResult<()>
where
S: AsyncRead + AsyncWrite + Unpin,
{
let payload = serde_json::to_string(&message).map_err(IoError::other)?;
stream
.send(Message::Text(payload.into()))
.await
.map_err(|err| {
IoError::other(format!(
"failed to write websocket message to `{websocket_url}`: {err}"
"failed to write websocket message to `{endpoint}`: {err}"
))
})
}
fn websocket_close_error_is_already_closed(err: &TungsteniteError) -> bool {
match err {
TungsteniteError::ConnectionClosed | TungsteniteError::AlreadyClosed => true,
TungsteniteError::Io(err) => matches!(
err.kind(),
ErrorKind::BrokenPipe | ErrorKind::ConnectionReset | ErrorKind::NotConnected
),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
+4
View File
@@ -92,6 +92,10 @@ JSON-RPC initialize handshake on the Unix control socket.
for future starts. If a managed app-server is already running, they restart it
so the new setting takes effect immediately.
Top-level `codex remote-control` bootstraps with `--remote-control` when the
updater loop is not running. Otherwise it enables remote control and starts the
daemon normally.
`stop` sends a graceful termination request first, then sends a second
termination signal after the grace window if the process is still alive.
@@ -349,13 +349,7 @@ impl PidBackend {
match self.command_kind {
PidCommandKind::AppServer {
remote_control_enabled: true,
} => vec![
"--enable",
"remote_control",
"app-server",
"--listen",
"unix://",
],
} => vec!["app-server", "--remote-control", "--listen", "unix://"],
PidCommandKind::AppServer {
remote_control_enabled: false,
} => vec!["app-server", "--listen", "unix://"],
@@ -156,3 +156,17 @@ fn update_loop_uses_hidden_app_server_subcommand() {
vec!["app-server", "daemon", "pid-update-loop"]
);
}
#[test]
fn app_server_remote_control_uses_runtime_flag() {
let backend = PidBackend::new(
"codex".into(),
"app-server.pid".into(),
/*remote_control_enabled*/ true,
);
assert_eq!(
backend.command_args(),
vec!["app-server", "--remote-control", "--listen", "unix://"]
);
}
+91 -3
View File
@@ -89,6 +89,13 @@ pub struct BootstrapOutput {
pub app_server_version: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(untagged)]
pub enum RemoteControlStartOutput {
Bootstrap(BootstrapOutput),
Start(LifecycleOutput),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RemoteControlMode {
Enabled,
@@ -165,6 +172,13 @@ pub async fn bootstrap(options: BootstrapOptions) -> Result<BootstrapOutput> {
Daemon::from_environment()?.bootstrap(options).await
}
pub async fn ensure_remote_control_started() -> Result<RemoteControlStartOutput> {
ensure_supported_platform()?;
Daemon::from_environment()?
.ensure_remote_control_started()
.await
}
pub async fn set_remote_control(mode: RemoteControlMode) -> Result<RemoteControlOutput> {
ensure_supported_platform()?;
Daemon::from_environment()?.set_remote_control(mode).await
@@ -397,8 +411,34 @@ impl Daemon {
self.bootstrap_locked(options).await
}
async fn ensure_remote_control_started(&self) -> Result<RemoteControlStartOutput> {
let _operation_lock = self.acquire_operation_lock().await?;
let settings = self.load_settings().await?;
if self.is_bootstrapped(&settings).await? {
let _ = self
.set_remote_control_locked(RemoteControlMode::Enabled)
.await?;
let output = self.start().await?;
return Ok(RemoteControlStartOutput::Start(output));
}
let output = self
.bootstrap_locked(BootstrapOptions {
remote_control_enabled: true,
})
.await?;
Ok(RemoteControlStartOutput::Bootstrap(output))
}
async fn set_remote_control(&self, mode: RemoteControlMode) -> Result<RemoteControlOutput> {
let _operation_lock = self.acquire_operation_lock().await?;
self.set_remote_control_locked(mode).await
}
async fn set_remote_control_locked(
&self,
mode: RemoteControlMode,
) -> Result<RemoteControlOutput> {
let previous_settings = self.load_settings().await?;
let mut settings = previous_settings.clone();
let remote_control_enabled = mode.is_enabled();
@@ -517,14 +557,23 @@ impl Daemon {
backend.start().await
}
async fn is_bootstrapped(&self, settings: &DaemonSettings) -> Result<bool> {
let updater = backend::pid_update_loop_backend(self.backend_paths(settings));
updater.is_starting_or_running().await
}
fn ensure_managed_codex_bin(&self) -> Result<()> {
if self.managed_codex_bin.is_file() {
return Ok(());
}
let managed_codex_path = self.managed_codex_bin.display();
Err(anyhow!(
"managed standalone Codex install not found at {}; install Codex first",
self.managed_codex_bin.display()
"managed standalone Codex install not found at {managed_codex_path}\n\n\
This command requires the standalone install managed by the Codex installer, because \
the daemon starts and updates app-server from that fixed path.\n\n\
Install it with:\n curl -fsSL https://chatgpt.com/codex/install.sh | sh\n\n\
Then rerun the command you just tried."
))
}
@@ -687,8 +736,12 @@ fn try_lock_file(_file: &tokio::fs::File) -> Result<bool> {
mod tests {
use pretty_assertions::assert_eq;
use super::BackendKind;
use super::BootstrapOutput;
use super::BootstrapStatus;
use super::LifecycleOutput;
use super::LifecycleStatus;
use super::RemoteControlStartOutput;
use super::RemoteControlStatus;
use super::RestartDecision;
use super::RestartIfRunningOutcome;
@@ -776,7 +829,7 @@ mod tests {
restart_decision(
RestartMode::Always,
/*info*/ None,
/*managed_version*/ None
/*managed_version*/ None,
),
],
[
@@ -787,4 +840,39 @@ mod tests {
]
);
}
#[test]
fn remote_control_start_output_serializes_inner_output_without_tag() {
let lifecycle_output = LifecycleOutput {
status: LifecycleStatus::AlreadyRunning,
backend: Some(BackendKind::Pid),
pid: None,
socket_path: "codex.sock".into(),
cli_version: Some("1.2.3".to_string()),
app_server_version: Some("1.2.4".to_string()),
};
let output = RemoteControlStartOutput::Start(lifecycle_output.clone());
assert_eq!(
serde_json::to_value(output).expect("serialize"),
serde_json::to_value(lifecycle_output).expect("serialize")
);
let bootstrap_output = BootstrapOutput {
status: BootstrapStatus::Bootstrapped,
backend: BackendKind::Pid,
auto_update_enabled: true,
remote_control_enabled: true,
managed_codex_path: "codex".into(),
socket_path: "codex.sock".into(),
cli_version: "1.2.3".to_string(),
app_server_version: "1.2.4".to_string(),
};
let output = RemoteControlStartOutput::Bootstrap(bootstrap_output.clone());
assert_eq!(
serde_json::to_value(output).expect("serialize"),
serde_json::to_value(bootstrap_output).expect("serialize")
);
}
}
@@ -2015,6 +2015,17 @@
],
"type": "object"
},
"PluginShareCheckoutParams": {
"properties": {
"remotePluginId": {
"type": "string"
}
},
"required": [
"remotePluginId"
],
"type": "object"
},
"PluginShareDeleteParams": {
"properties": {
"remotePluginId": {
@@ -5127,6 +5138,30 @@
"title": "Plugin/share/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"plugin/share/checkout"
],
"title": "Plugin/share/checkoutRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/PluginShareCheckoutParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Plugin/share/checkoutRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -901,6 +901,30 @@
"title": "Plugin/share/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"plugin/share/checkout"
],
"title": "Plugin/share/checkoutRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/PluginShareCheckoutParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Plugin/share/checkoutRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -7146,9 +7170,13 @@
]
},
"forced_chatgpt_workspace_id": {
"type": [
"string",
"null"
"anyOf": [
{
"$ref": "#/definitions/v2/ForcedChatgptWorkspaceIds"
},
{
"type": "null"
}
]
},
"forced_login_method": {
@@ -7443,6 +7471,13 @@
],
"description": "This is the path to the user's config.toml file, though it is not guaranteed to exist."
},
"profile": {
"description": "Name of the selected profile-v2 config layered on top of the base user config, when this layer represents one.",
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"user"
@@ -7584,6 +7619,12 @@
},
"ConfigRequirements": {
"properties": {
"allowManagedHooksOnly": {
"type": [
"boolean",
"null"
]
},
"allowedApprovalPolicies": {
"items": {
"$ref": "#/definitions/v2/AskForApproval"
@@ -7768,6 +7809,12 @@
"command": {
"type": "string"
},
"commandWindows": {
"type": [
"string",
"null"
]
},
"statusMessage": {
"type": [
"string",
@@ -8698,6 +8745,20 @@
],
"type": "object"
},
"ForcedChatgptWorkspaceIds": {
"anyOf": [
{
"type": "string"
},
{
"items": {
"type": "string"
},
"type": "array"
}
],
"description": "Backward-compatible API shape for ChatGPT workspace login restrictions."
},
"ForcedLoginMethod": {
"enum": [
"chatgpt",
@@ -12300,6 +12361,58 @@
"title": "PluginReadResponse",
"type": "object"
},
"PluginShareCheckoutParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"remotePluginId": {
"type": "string"
}
},
"required": [
"remotePluginId"
],
"title": "PluginShareCheckoutParams",
"type": "object"
},
"PluginShareCheckoutResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"marketplaceName": {
"type": "string"
},
"marketplacePath": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"pluginId": {
"type": "string"
},
"pluginName": {
"type": "string"
},
"pluginPath": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"remotePluginId": {
"type": "string"
},
"remoteVersion": {
"type": [
"string",
"null"
]
}
},
"required": [
"marketplaceName",
"marketplacePath",
"pluginId",
"pluginName",
"pluginPath",
"remotePluginId"
],
"title": "PluginShareCheckoutResponse",
"type": "object"
},
"PluginShareContext": {
"properties": {
"creatorAccountUserId": {
@@ -12327,6 +12440,14 @@
"remotePluginId": {
"type": "string"
},
"remoteVersion": {
"default": null,
"description": "Version of the remote shared plugin release when available.",
"type": [
"string",
"null"
]
},
"sharePrincipals": {
"items": {
"$ref": "#/definitions/v2/PluginSharePrincipal"
@@ -12743,9 +12864,24 @@
},
"type": "array"
},
"localVersion": {
"default": null,
"description": "Version of the locally materialized plugin package when available.",
"type": [
"string",
"null"
]
},
"name": {
"type": "string"
},
"remotePluginId": {
"description": "Backend remote plugin identifier when available.",
"type": [
"string",
"null"
]
},
"shareContext": {
"anyOf": [
{
@@ -17719,12 +17855,6 @@
},
"ToolsV2": {
"properties": {
"view_image": {
"type": [
"boolean",
"null"
]
},
"web_search": {
"anyOf": [
{
@@ -1641,6 +1641,30 @@
"title": "Plugin/share/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"plugin/share/checkout"
],
"title": "Plugin/share/checkoutRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/PluginShareCheckoutParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Plugin/share/checkoutRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -3515,9 +3539,13 @@
]
},
"forced_chatgpt_workspace_id": {
"type": [
"string",
"null"
"anyOf": [
{
"$ref": "#/definitions/ForcedChatgptWorkspaceIds"
},
{
"type": "null"
}
]
},
"forced_login_method": {
@@ -3812,6 +3840,13 @@
],
"description": "This is the path to the user's config.toml file, though it is not guaranteed to exist."
},
"profile": {
"description": "Name of the selected profile-v2 config layered on top of the base user config, when this layer represents one.",
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"user"
@@ -3953,6 +3988,12 @@
},
"ConfigRequirements": {
"properties": {
"allowManagedHooksOnly": {
"type": [
"boolean",
"null"
]
},
"allowedApprovalPolicies": {
"items": {
"$ref": "#/definitions/AskForApproval"
@@ -4137,6 +4178,12 @@
"command": {
"type": "string"
},
"commandWindows": {
"type": [
"string",
"null"
]
},
"statusMessage": {
"type": [
"string",
@@ -5067,6 +5114,20 @@
],
"type": "object"
},
"ForcedChatgptWorkspaceIds": {
"anyOf": [
{
"type": "string"
},
{
"items": {
"type": "string"
},
"type": "array"
}
],
"description": "Backward-compatible API shape for ChatGPT workspace login restrictions."
},
"ForcedLoginMethod": {
"enum": [
"chatgpt",
@@ -8829,6 +8890,58 @@
"title": "PluginReadResponse",
"type": "object"
},
"PluginShareCheckoutParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"remotePluginId": {
"type": "string"
}
},
"required": [
"remotePluginId"
],
"title": "PluginShareCheckoutParams",
"type": "object"
},
"PluginShareCheckoutResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"marketplaceName": {
"type": "string"
},
"marketplacePath": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"pluginId": {
"type": "string"
},
"pluginName": {
"type": "string"
},
"pluginPath": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"remotePluginId": {
"type": "string"
},
"remoteVersion": {
"type": [
"string",
"null"
]
}
},
"required": [
"marketplaceName",
"marketplacePath",
"pluginId",
"pluginName",
"pluginPath",
"remotePluginId"
],
"title": "PluginShareCheckoutResponse",
"type": "object"
},
"PluginShareContext": {
"properties": {
"creatorAccountUserId": {
@@ -8856,6 +8969,14 @@
"remotePluginId": {
"type": "string"
},
"remoteVersion": {
"default": null,
"description": "Version of the remote shared plugin release when available.",
"type": [
"string",
"null"
]
},
"sharePrincipals": {
"items": {
"$ref": "#/definitions/PluginSharePrincipal"
@@ -9272,9 +9393,24 @@
},
"type": "array"
},
"localVersion": {
"default": null,
"description": "Version of the locally materialized plugin package when available.",
"type": [
"string",
"null"
]
},
"name": {
"type": "string"
},
"remotePluginId": {
"description": "Backend remote plugin identifier when available.",
"type": [
"string",
"null"
]
},
"shareContext": {
"anyOf": [
{
@@ -15543,12 +15679,6 @@
},
"ToolsV2": {
"properties": {
"view_image": {
"type": [
"boolean",
"null"
]
},
"web_search": {
"anyOf": [
{
@@ -235,9 +235,13 @@
]
},
"forced_chatgpt_workspace_id": {
"type": [
"string",
"null"
"anyOf": [
{
"$ref": "#/definitions/ForcedChatgptWorkspaceIds"
},
{
"type": "null"
}
]
},
"forced_login_method": {
@@ -482,6 +486,13 @@
],
"description": "This is the path to the user's config.toml file, though it is not guaranteed to exist."
},
"profile": {
"description": "Name of the selected profile-v2 config layered on top of the base user config, when this layer represents one.",
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"user"
@@ -574,6 +585,20 @@
}
]
},
"ForcedChatgptWorkspaceIds": {
"anyOf": [
{
"type": "string"
},
{
"items": {
"type": "string"
},
"type": "array"
}
],
"description": "Backward-compatible API shape for ChatGPT workspace login restrictions."
},
"ForcedLoginMethod": {
"enum": [
"chatgpt",
@@ -748,12 +773,6 @@
},
"ToolsV2": {
"properties": {
"view_image": {
"type": [
"boolean",
"null"
]
},
"web_search": {
"anyOf": [
{
@@ -62,6 +62,12 @@
},
"ConfigRequirements": {
"properties": {
"allowManagedHooksOnly": {
"type": [
"boolean",
"null"
]
},
"allowedApprovalPolicies": {
"items": {
"$ref": "#/definitions/AskForApproval"
@@ -121,6 +127,12 @@
"command": {
"type": "string"
},
"commandWindows": {
"type": [
"string",
"null"
]
},
"statusMessage": {
"type": [
"string",
@@ -84,6 +84,13 @@
],
"description": "This is the path to the user's config.toml file, though it is not guaranteed to exist."
},
"profile": {
"description": "Name of the selected profile-v2 config layered on top of the base user config, when this layer represents one.",
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"user"
@@ -259,6 +259,14 @@
"remotePluginId": {
"type": "string"
},
"remoteVersion": {
"default": null,
"description": "Version of the remote shared plugin release when available.",
"type": [
"string",
"null"
]
},
"sharePrincipals": {
"items": {
"$ref": "#/definitions/PluginSharePrincipal"
@@ -449,9 +457,24 @@
},
"type": "array"
},
"localVersion": {
"default": null,
"description": "Version of the locally materialized plugin package when available.",
"type": [
"string",
"null"
]
},
"name": {
"type": "string"
},
"remotePluginId": {
"description": "Backend remote plugin identifier when available.",
"type": [
"string",
"null"
]
},
"shareContext": {
"anyOf": [
{
@@ -313,6 +313,14 @@
"remotePluginId": {
"type": "string"
},
"remoteVersion": {
"default": null,
"description": "Version of the remote shared plugin release when available.",
"type": [
"string",
"null"
]
},
"sharePrincipals": {
"items": {
"$ref": "#/definitions/PluginSharePrincipal"
@@ -503,9 +511,24 @@
},
"type": "array"
},
"localVersion": {
"default": null,
"description": "Version of the locally materialized plugin package when available.",
"type": [
"string",
"null"
]
},
"name": {
"type": "string"
},
"remotePluginId": {
"description": "Backend remote plugin identifier when available.",
"type": [
"string",
"null"
]
},
"shareContext": {
"anyOf": [
{
@@ -0,0 +1,13 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"remotePluginId": {
"type": "string"
}
},
"required": [
"remotePluginId"
],
"title": "PluginShareCheckoutParams",
"type": "object"
}
@@ -0,0 +1,45 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
}
},
"properties": {
"marketplaceName": {
"type": "string"
},
"marketplacePath": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"pluginId": {
"type": "string"
},
"pluginName": {
"type": "string"
},
"pluginPath": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"remotePluginId": {
"type": "string"
},
"remoteVersion": {
"type": [
"string",
"null"
]
}
},
"required": [
"marketplaceName",
"marketplacePath",
"pluginId",
"pluginName",
"pluginPath",
"remotePluginId"
],
"title": "PluginShareCheckoutResponse",
"type": "object"
}
@@ -194,6 +194,14 @@
"remotePluginId": {
"type": "string"
},
"remoteVersion": {
"default": null,
"description": "Version of the remote shared plugin release when available.",
"type": [
"string",
"null"
]
},
"sharePrincipals": {
"items": {
"$ref": "#/definitions/PluginSharePrincipal"
@@ -405,9 +413,24 @@
},
"type": "array"
},
"localVersion": {
"default": null,
"description": "Version of the locally materialized plugin package when available.",
"type": [
"string",
"null"
]
},
"name": {
"type": "string"
},
"remotePluginId": {
"description": "Backend remote plugin identifier when available.",
"type": [
"string",
"null"
]
},
"shareContext": {
"anyOf": [
{
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -10,6 +10,7 @@ import type { JsonValue } from "../serde_json/JsonValue";
import type { AnalyticsConfig } from "./AnalyticsConfig";
import type { ApprovalsReviewer } from "./ApprovalsReviewer";
import type { AskForApproval } from "./AskForApproval";
import type { ForcedChatgptWorkspaceIds } from "./ForcedChatgptWorkspaceIds";
import type { ProfileV2 } from "./ProfileV2";
import type { SandboxMode } from "./SandboxMode";
import type { SandboxWorkspaceWrite } from "./SandboxWorkspaceWrite";
@@ -19,4 +20,4 @@ export type Config = {model: string | null, review_model: string | null, model_c
* [UNSTABLE] Optional default for where approval requests are routed for
* review.
*/
approvals_reviewer: ApprovalsReviewer | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: string | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, profile: string | null, profiles: { [key in string]?: ProfileV2 }, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, service_tier: string | null, analytics: AnalyticsConfig | null} & ({ [key in string]?: number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null });
approvals_reviewer: ApprovalsReviewer | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: ForcedChatgptWorkspaceIds | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, profile: string | null, profiles: { [key in string]?: ProfileV2 }, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, service_tier: string | null, analytics: AnalyticsConfig | null} & ({ [key in string]?: number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null });
@@ -13,4 +13,9 @@ file: AbsolutePathBuf, } | { "type": "user",
* This is the path to the user's config.toml file, though it is not
* guaranteed to exist.
*/
file: AbsolutePathBuf, } | { "type": "project", dotCodexFolder: AbsolutePathBuf, } | { "type": "sessionFlags" } | { "type": "legacyManagedConfigTomlFromFile", file: AbsolutePathBuf, } | { "type": "legacyManagedConfigTomlFromMdm" };
file: AbsolutePathBuf,
/**
* Name of the selected profile-v2 config layered on top of the base
* user config, when this layer represents one.
*/
profile: string | null, } | { "type": "project", dotCodexFolder: AbsolutePathBuf, } | { "type": "sessionFlags" } | { "type": "legacyManagedConfigTomlFromFile", file: AbsolutePathBuf, } | { "type": "legacyManagedConfigTomlFromMdm" };
@@ -6,4 +6,4 @@ import type { AskForApproval } from "./AskForApproval";
import type { ResidencyRequirement } from "./ResidencyRequirement";
import type { SandboxMode } from "./SandboxMode";
export type ConfigRequirements = {allowedApprovalPolicies: Array<AskForApproval> | null, allowedSandboxModes: Array<SandboxMode> | null, allowedWebSearchModes: Array<WebSearchMode> | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null};
export type ConfigRequirements = {allowedApprovalPolicies: Array<AskForApproval> | null, allowedSandboxModes: Array<SandboxMode> | null, allowedWebSearchModes: Array<WebSearchMode> | null, allowManagedHooksOnly: boolean | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null};
@@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ConfiguredHookHandler = { "type": "command", command: string, timeoutSec: bigint | null, async: boolean, statusMessage: string | null, } | { "type": "prompt", } | { "type": "agent", };
export type ConfiguredHookHandler = { "type": "command", command: string, commandWindows: string | null, timeoutSec: bigint | null, async: boolean, statusMessage: string | null, } | { "type": "prompt", } | { "type": "agent", };
@@ -0,0 +1,8 @@
// 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.
/**
* Backward-compatible API shape for ChatGPT workspace login restrictions.
*/
export type ForcedChatgptWorkspaceIds = string | Array<string>;
@@ -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 PluginShareCheckoutParams = { remotePluginId: string, };
@@ -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 { AbsolutePathBuf } from "../AbsolutePathBuf";
export type PluginShareCheckoutResponse = { remotePluginId: string, pluginId: string, pluginName: string, pluginPath: AbsolutePathBuf, marketplaceName: string, marketplacePath: AbsolutePathBuf, remoteVersion: string | null, };
@@ -4,4 +4,8 @@
import type { PluginShareDiscoverability } from "./PluginShareDiscoverability";
import type { PluginSharePrincipal } from "./PluginSharePrincipal";
export type PluginShareContext = { remotePluginId: string, discoverability: PluginShareDiscoverability | null, shareUrl: string | null, creatorAccountUserId: string | null, creatorName: string | null, sharePrincipals: Array<PluginSharePrincipal> | null, };
export type PluginShareContext = { remotePluginId: string,
/**
* Version of the remote shared plugin release when available.
*/
remoteVersion: string | null, discoverability: PluginShareDiscoverability | null, shareUrl: string | null, creatorAccountUserId: string | null, creatorName: string | null, sharePrincipals: Array<PluginSharePrincipal> | null, };
@@ -8,7 +8,15 @@ import type { PluginInterface } from "./PluginInterface";
import type { PluginShareContext } from "./PluginShareContext";
import type { PluginSource } from "./PluginSource";
export type PluginSummary = { id: string, name: string,
export type PluginSummary = { id: string,
/**
* Backend remote plugin identifier when available.
*/
remotePluginId: string | null,
/**
* Version of the locally materialized plugin package when available.
*/
localVersion: string | null, name: string,
/**
* Remote sharing context associated with this plugin when available.
*/
@@ -3,4 +3,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { WebSearchToolConfig } from "../WebSearchToolConfig";
export type ToolsV2 = { web_search: WebSearchToolConfig | null, view_image: boolean | null, };
export type ToolsV2 = { web_search: WebSearchToolConfig | null, };
@@ -113,6 +113,7 @@ export type { FileSystemPath } from "./FileSystemPath";
export type { FileSystemSandboxEntry } from "./FileSystemSandboxEntry";
export type { FileSystemSpecialPath } from "./FileSystemSpecialPath";
export type { FileUpdateChange } from "./FileUpdateChange";
export type { ForcedChatgptWorkspaceIds } from "./ForcedChatgptWorkspaceIds";
export type { FsChangedNotification } from "./FsChangedNotification";
export type { FsCopyParams } from "./FsCopyParams";
export type { FsCopyResponse } from "./FsCopyResponse";
@@ -277,6 +278,8 @@ export type { PluginListResponse } from "./PluginListResponse";
export type { PluginMarketplaceEntry } from "./PluginMarketplaceEntry";
export type { PluginReadParams } from "./PluginReadParams";
export type { PluginReadResponse } from "./PluginReadResponse";
export type { PluginShareCheckoutParams } from "./PluginShareCheckoutParams";
export type { PluginShareCheckoutResponse } from "./PluginShareCheckoutResponse";
export type { PluginShareContext } from "./PluginShareContext";
export type { PluginShareDeleteParams } from "./PluginShareDeleteParams";
export type { PluginShareDeleteResponse } from "./PluginShareDeleteResponse";
@@ -626,12 +626,12 @@ client_request_definitions! {
},
PluginList => "plugin/list" {
params: v2::PluginListParams,
serialization: global_shared_read("config"),
serialization: None,
response: v2::PluginListResponse,
},
PluginRead => "plugin/read" {
params: v2::PluginReadParams,
serialization: global("config"),
serialization: None,
response: v2::PluginReadResponse,
},
PluginSkillRead => "plugin/skill/read" {
@@ -654,6 +654,11 @@ client_request_definitions! {
serialization: global("config"),
response: v2::PluginShareListResponse,
},
PluginShareCheckout => "plugin/share/checkout" {
params: v2::PluginShareCheckoutParams,
serialization: global("config"),
response: v2::PluginShareCheckoutResponse,
},
PluginShareDelete => "plugin/share/delete" {
params: v2::PluginShareDeleteParams,
serialization: global("config"),
@@ -799,6 +804,18 @@ client_request_definitions! {
serialization: global("config"),
response: v2::ExperimentalFeatureEnablementSetResponse,
},
#[experimental("remoteControl/enable")]
RemoteControlEnable => "remoteControl/enable" {
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
serialization: global("remote-control"),
response: v2::RemoteControlEnableResponse,
},
#[experimental("remoteControl/disable")]
RemoteControlDisable => "remoteControl/disable" {
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
serialization: global("remote-control"),
response: v2::RemoteControlDisableResponse,
},
#[experimental("collaborationMode/list")]
/// Lists collaboration mode presets.
CollaborationModeList => "collaborationMode/list" {
@@ -1689,10 +1706,17 @@ mod tests {
marketplace_kinds: None,
},
};
assert_eq!(
plugin_list.serialization_scope(),
Some(ClientRequestSerializationScope::GlobalSharedRead("config"))
);
assert_eq!(plugin_list.serialization_scope(), None);
let plugin_read = ClientRequest::PluginRead {
request_id: request_id(),
params: v2::PluginReadParams {
marketplace_path: Some(absolute_path("/tmp/marketplace")),
remote_marketplace_name: None,
plugin_name: "plugin-a".to_string(),
},
};
assert_eq!(plugin_read.serialization_scope(), None);
let plugin_uninstall = ClientRequest::PluginUninstall {
request_id: request_id(),
@@ -22,6 +22,7 @@ use serde::Serialize;
use ts_rs::TS;
use crate::protocol::common::AuthMode;
use crate::protocol::v2::ForcedChatgptWorkspaceIds;
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
@@ -201,7 +202,7 @@ pub struct UserSavedConfig {
pub approval_policy: Option<AskForApproval>,
pub sandbox_mode: Option<SandboxMode>,
pub sandbox_settings: Option<SandboxSettings>,
pub forced_chatgpt_workspace_id: Option<String>,
pub forced_chatgpt_workspace_id: Option<ForcedChatgptWorkspaceIds>,
pub forced_login_method: Option<ForcedLoginMethod>,
pub model: Option<String>,
pub model_reasoning_effort: Option<ReasoningEffort>,
@@ -228,7 +229,6 @@ pub struct Profile {
#[serde(rename_all = "camelCase")]
pub struct Tools {
pub web_search: Option<bool>,
pub view_image: Option<bool>,
}
#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, JsonSchema, TS)]
@@ -51,6 +51,10 @@ pub enum ConfigLayerSource {
/// This is the path to the user's config.toml file, though it is not
/// guaranteed to exist.
file: AbsolutePathBuf,
/// Name of the selected profile-v2 config layered on top of the base
/// user config, when this layer represents one.
profile: Option<String>,
},
/// Path to a .codex/ folder within a project. There could be multiple of
@@ -84,7 +88,13 @@ impl ConfigLayerSource {
match self {
ConfigLayerSource::Mdm { .. } => 0,
ConfigLayerSource::System { .. } => 10,
ConfigLayerSource::User { .. } => 20,
ConfigLayerSource::User { profile, .. } => {
if profile.is_some() {
21
} else {
20
}
}
ConfigLayerSource::Project { .. } => 25,
ConfigLayerSource::SessionFlags => 30,
ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => 40,
@@ -120,7 +130,6 @@ pub struct SandboxWorkspaceWrite {
#[ts(export_to = "v2/")]
pub struct ToolsV2 {
pub web_search: Option<WebSearchToolConfig>,
pub view_image: Option<bool>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)]
@@ -216,6 +225,24 @@ pub struct AppsConfig {
pub apps: HashMap<String, AppConfig>,
}
/// Backward-compatible API shape for ChatGPT workspace login restrictions.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(untagged)]
#[ts(export_to = "v2/")]
pub enum ForcedChatgptWorkspaceIds {
Single(String),
Multiple(Vec<String>),
}
impl ForcedChatgptWorkspaceIds {
pub fn into_vec(self) -> Vec<String> {
match self {
Self::Single(value) => vec![value],
Self::Multiple(values) => values,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)]
#[serde(rename_all = "snake_case")]
#[ts(export_to = "v2/")]
@@ -233,7 +260,7 @@ pub struct Config {
pub approvals_reviewer: Option<ApprovalsReviewer>,
pub sandbox_mode: Option<SandboxMode>,
pub sandbox_workspace_write: Option<SandboxWorkspaceWrite>,
pub forced_chatgpt_workspace_id: Option<String>,
pub forced_chatgpt_workspace_id: Option<ForcedChatgptWorkspaceIds>,
pub forced_login_method: Option<ForcedLoginMethod>,
pub web_search: Option<WebSearchMode>,
pub tools: Option<ToolsV2>,
@@ -357,6 +384,7 @@ pub struct ConfigRequirements {
pub allowed_approvals_reviewers: Option<Vec<ApprovalsReviewer>>,
pub allowed_sandbox_modes: Option<Vec<SandboxMode>>,
pub allowed_web_search_modes: Option<Vec<WebSearchMode>>,
pub allow_managed_hooks_only: Option<bool>,
pub feature_requirements: Option<BTreeMap<String, bool>>,
#[experimental("configRequirements/read.hooks")]
pub hooks: Option<ManagedHooksRequirements>,
@@ -413,6 +441,9 @@ pub enum ConfiguredHookHandler {
#[ts(rename = "command")]
Command {
command: String,
#[serde(rename = "commandWindows")]
#[ts(rename = "commandWindows")]
command_windows: Option<String>,
#[serde(rename = "timeoutSec")]
#[ts(rename = "timeoutSec")]
timeout_sec: Option<u64>,
@@ -242,6 +242,26 @@ pub struct PluginShareListResponse {
pub data: Vec<PluginShareListItem>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginShareCheckoutParams {
pub remote_plugin_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginShareCheckoutResponse {
pub remote_plugin_id: String,
pub plugin_id: String,
pub plugin_name: String,
pub plugin_path: AbsolutePathBuf,
pub marketplace_name: String,
pub marketplace_path: AbsolutePathBuf,
pub remote_version: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -538,6 +558,11 @@ pub enum PluginAvailability {
#[ts(export_to = "v2/")]
pub struct PluginSummary {
pub id: String,
/// Backend remote plugin identifier when available.
pub remote_plugin_id: Option<String>,
/// Version of the locally materialized plugin package when available.
#[serde(default)]
pub local_version: Option<String>,
pub name: String,
/// Remote sharing context associated with this plugin when available.
pub share_context: Option<PluginShareContext>,
@@ -559,6 +584,9 @@ pub struct PluginSummary {
#[ts(export_to = "v2/")]
pub struct PluginShareContext {
pub remote_plugin_id: String,
/// Version of the remote shared plugin release when available.
#[serde(default)]
pub remote_version: Option<String>,
pub discoverability: Option<PluginShareDiscoverability>,
pub share_url: Option<String>,
pub creator_account_user_id: Option<String>,
@@ -13,6 +13,24 @@ pub struct RemoteControlStatusChangedNotification {
pub environment_id: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct RemoteControlEnableResponse {
pub status: RemoteControlConnectionStatus,
pub installation_id: String,
pub environment_id: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct RemoteControlDisableResponse {
pub status: RemoteControlConnectionStatus,
pub installation_id: String,
pub environment_id: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase", export_to = "v2/")]
@@ -22,3 +40,33 @@ pub enum RemoteControlConnectionStatus {
Connected,
Errored,
}
impl From<RemoteControlStatusChangedNotification> for RemoteControlEnableResponse {
fn from(notification: RemoteControlStatusChangedNotification) -> Self {
let RemoteControlStatusChangedNotification {
status,
installation_id,
environment_id,
} = notification;
Self {
status,
installation_id,
environment_id,
}
}
}
impl From<RemoteControlStatusChangedNotification> for RemoteControlDisableResponse {
fn from(notification: RemoteControlStatusChangedNotification) -> Self {
let RemoteControlStatusChangedNotification {
status,
installation_id,
environment_id,
} = notification;
Self {
status,
installation_id,
environment_id,
}
}
}
@@ -1688,6 +1688,7 @@ fn config_requirements_granular_allowed_approval_policy_is_marked_experimental()
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
feature_requirements: None,
hooks: None,
enforce_residency: None,
@@ -2986,6 +2987,52 @@ fn plugin_share_params_and_response_serialization_use_camel_case_fields() {
PluginShareListParams {},
);
assert_eq!(
serde_json::to_value(PluginShareCheckoutParams {
remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(),
})
.unwrap(),
json!({
"remotePluginId": "plugins~Plugin_00000000000000000000000000000000",
}),
);
let plugin_path = if cfg!(windows) {
r"C:\Users\me\plugins\gmail"
} else {
"/Users/me/plugins/gmail"
};
let plugin_path = AbsolutePathBuf::try_from(PathBuf::from(plugin_path)).unwrap();
let plugin_path_json = plugin_path.as_path().display().to_string();
let marketplace_path = if cfg!(windows) {
r"C:\Users\me\.agents\plugins\marketplace.json"
} else {
"/Users/me/.agents/plugins/marketplace.json"
};
let marketplace_path = AbsolutePathBuf::try_from(PathBuf::from(marketplace_path)).unwrap();
let marketplace_path_json = marketplace_path.as_path().display().to_string();
assert_eq!(
serde_json::to_value(PluginShareCheckoutResponse {
remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(),
plugin_id: "gmail@codex-curated".to_string(),
plugin_name: "gmail".to_string(),
plugin_path,
marketplace_name: "codex-curated".to_string(),
marketplace_path,
remote_version: Some("1.2.3".to_string()),
})
.unwrap(),
json!({
"remotePluginId": "plugins~Plugin_00000000000000000000000000000000",
"pluginId": "gmail@codex-curated",
"pluginName": "gmail",
"pluginPath": plugin_path_json,
"marketplaceName": "codex-curated",
"marketplacePath": marketplace_path_json,
"remoteVersion": "1.2.3",
}),
);
assert_eq!(
serde_json::to_value(PluginShareDeleteParams {
remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(),
@@ -3003,7 +3050,11 @@ fn plugin_share_list_response_serializes_share_items() {
serde_json::to_value(PluginShareListResponse {
data: vec![PluginShareListItem {
plugin: PluginSummary {
id: "plugins~Plugin_00000000000000000000000000000000".to_string(),
id: "gmail@chatgpt-global".to_string(),
remote_plugin_id: Some(
"plugins~Plugin_00000000000000000000000000000000".to_string(),
),
local_version: None,
name: "gmail".to_string(),
share_context: None,
source: PluginSource::Remote,
@@ -3022,7 +3073,9 @@ fn plugin_share_list_response_serializes_share_items() {
json!({
"data": [{
"plugin": {
"id": "plugins~Plugin_00000000000000000000000000000000",
"id": "gmail@chatgpt-global",
"remotePluginId": "plugins~Plugin_00000000000000000000000000000000",
"localVersion": null,
"name": "gmail",
"shareContext": null,
"source": { "type": "remote" },
@@ -3055,6 +3108,7 @@ fn plugin_summary_defaults_missing_availability_to_available() {
.unwrap();
assert_eq!(summary.availability, PluginAvailability::Available);
assert_eq!(summary.local_version, None);
assert_eq!(summary.share_context, None);
}
+14
View File
@@ -13,7 +13,15 @@ doctest = false
workspace = true
[dependencies]
anyhow = { workspace = true }
axum = { workspace = true, default-features = false, features = [
"http1",
"json",
"tokio",
"ws",
] }
base64 = { workspace = true }
clap = { workspace = true, features = ["derive"] }
codex-api = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-core = { workspace = true }
@@ -23,10 +31,16 @@ codex-state = { workspace = true }
codex-uds = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-rustls-provider = { workspace = true }
constant_time_eq = { workspace = true }
futures = { workspace = true }
gethostname = { workspace = true }
hmac = { workspace = true }
jsonwebtoken = { workspace = true }
owo-colors = { workspace = true, features = ["supports-colors"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha2 = { workspace = true }
time = { workspace = true }
tokio = { workspace = true, features = [
"io-std",
"macros",
+3
View File
@@ -12,8 +12,11 @@ pub use transport::CHANNEL_CAPACITY;
pub use transport::ConnectionOrigin;
pub use transport::RemoteControlHandle;
pub use transport::RemoteControlStartConfig;
pub use transport::RemoteControlUnavailable;
pub use transport::TransportEvent;
pub use transport::app_server_control_socket_path;
pub use transport::auth;
pub use transport::start_control_socket_acceptor;
pub use transport::start_remote_control;
pub use transport::start_stdio_connection;
pub use transport::start_websocket_acceptor;
@@ -0,0 +1,751 @@
use anyhow::Context;
use axum::http::HeaderMap;
use axum::http::StatusCode;
use axum::http::header::AUTHORIZATION;
use clap::Args;
use clap::ValueEnum;
use codex_utils_absolute_path::AbsolutePathBuf;
use constant_time_eq::constant_time_eq_32;
use jsonwebtoken::Algorithm;
use jsonwebtoken::DecodingKey;
use jsonwebtoken::Validation;
use jsonwebtoken::decode;
use serde::Deserialize;
use sha2::Digest;
use sha2::Sha256;
use std::io;
use std::io::ErrorKind;
use std::net::SocketAddr;
use std::path::Path;
use std::path::PathBuf;
use time::OffsetDateTime;
const DEFAULT_MAX_CLOCK_SKEW_SECONDS: u64 = 30;
const MIN_SIGNED_BEARER_SECRET_BYTES: usize = 32;
const INVALID_AUTHORIZATION_HEADER_MESSAGE: &str = "invalid authorization header";
#[derive(Debug, Clone, Default, PartialEq, Eq, Args)]
pub struct AppServerWebsocketAuthArgs {
/// Websocket auth mode for non-loopback listeners.
#[arg(long = "ws-auth", value_name = "MODE", value_enum)]
pub ws_auth: Option<WebsocketAuthCliMode>,
/// Absolute path to the capability-token file.
#[arg(long = "ws-token-file", value_name = "PATH")]
pub ws_token_file: Option<PathBuf>,
/// Hex-encoded SHA-256 digest of the capability token.
#[arg(long = "ws-token-sha256", value_name = "HEX")]
pub ws_token_sha256: Option<String>,
/// Absolute path to the shared secret file for signed JWT bearer tokens.
#[arg(long = "ws-shared-secret-file", value_name = "PATH")]
pub ws_shared_secret_file: Option<PathBuf>,
/// Expected issuer for signed JWT bearer tokens.
#[arg(long = "ws-issuer", value_name = "ISSUER")]
pub ws_issuer: Option<String>,
/// Expected audience for signed JWT bearer tokens.
#[arg(long = "ws-audience", value_name = "AUDIENCE")]
pub ws_audience: Option<String>,
/// Maximum clock skew when validating signed JWT bearer tokens.
#[arg(long = "ws-max-clock-skew-seconds", value_name = "SECONDS")]
pub ws_max_clock_skew_seconds: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum WebsocketAuthCliMode {
CapabilityToken,
SignedBearerToken,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AppServerWebsocketAuthSettings {
pub config: Option<AppServerWebsocketAuthConfig>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AppServerWebsocketAuthConfig {
CapabilityToken {
source: AppServerWebsocketCapabilityTokenSource,
},
SignedBearerToken {
shared_secret_file: AbsolutePathBuf,
issuer: Option<String>,
audience: Option<String>,
max_clock_skew_seconds: u64,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AppServerWebsocketCapabilityTokenSource {
TokenFile { token_file: AbsolutePathBuf },
TokenSha256 { token_sha256: [u8; 32] },
}
#[derive(Clone, Debug, Default)]
pub struct WebsocketAuthPolicy {
pub(crate) mode: Option<WebsocketAuthMode>,
}
#[derive(Clone, Debug)]
pub(crate) enum WebsocketAuthMode {
CapabilityToken {
token_sha256: [u8; 32],
},
SignedBearerToken {
shared_secret: Vec<u8>,
issuer: Option<String>,
audience: Option<String>,
max_clock_skew_seconds: i64,
},
}
#[derive(Debug)]
pub(crate) struct WebsocketAuthError {
status_code: StatusCode,
message: &'static str,
}
#[derive(Deserialize)]
struct JwtClaims {
exp: i64,
nbf: Option<i64>,
iss: Option<String>,
aud: Option<JwtAudienceClaim>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum JwtAudienceClaim {
Single(String),
Multiple(Vec<String>),
}
impl WebsocketAuthError {
pub(crate) fn status_code(&self) -> StatusCode {
self.status_code
}
pub(crate) fn message(&self) -> &'static str {
self.message
}
}
impl AppServerWebsocketAuthArgs {
pub fn try_into_settings(self) -> anyhow::Result<AppServerWebsocketAuthSettings> {
let normalize = |value: Option<String>| {
value.and_then(|value| {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
})
};
let config = match self.ws_auth {
Some(WebsocketAuthCliMode::CapabilityToken) => {
if self.ws_shared_secret_file.is_some()
|| self.ws_issuer.is_some()
|| self.ws_audience.is_some()
|| self.ws_max_clock_skew_seconds.is_some()
{
anyhow::bail!(
"`--ws-shared-secret-file`, `--ws-issuer`, `--ws-audience`, and `--ws-max-clock-skew-seconds` require `--ws-auth signed-bearer-token`"
);
}
let source = match (self.ws_token_file, self.ws_token_sha256) {
(Some(_), Some(_)) => {
anyhow::bail!(
"`--ws-token-file` and `--ws-token-sha256` are mutually exclusive"
);
}
(Some(token_file), None) => {
AppServerWebsocketCapabilityTokenSource::TokenFile {
token_file: absolute_path_arg("--ws-token-file", token_file)?,
}
}
(None, Some(token_sha256)) => {
AppServerWebsocketCapabilityTokenSource::TokenSha256 {
token_sha256: sha256_digest_arg("--ws-token-sha256", &token_sha256)?,
}
}
(None, None) => {
anyhow::bail!(
"`--ws-token-file` or `--ws-token-sha256` is required when `--ws-auth capability-token` is set"
);
}
};
Some(AppServerWebsocketAuthConfig::CapabilityToken { source })
}
Some(WebsocketAuthCliMode::SignedBearerToken) => {
if self.ws_token_file.is_some() || self.ws_token_sha256.is_some() {
anyhow::bail!(
"`--ws-token-file` and `--ws-token-sha256` require `--ws-auth capability-token`, not `signed-bearer-token`"
);
}
let shared_secret_file = self.ws_shared_secret_file.context(
"`--ws-shared-secret-file` is required when `--ws-auth signed-bearer-token` is set",
)?;
Some(AppServerWebsocketAuthConfig::SignedBearerToken {
shared_secret_file: absolute_path_arg(
"--ws-shared-secret-file",
shared_secret_file,
)?,
issuer: normalize(self.ws_issuer),
audience: normalize(self.ws_audience),
max_clock_skew_seconds: self
.ws_max_clock_skew_seconds
.unwrap_or(DEFAULT_MAX_CLOCK_SKEW_SECONDS),
})
}
None => {
if self.ws_token_file.is_some()
|| self.ws_token_sha256.is_some()
|| self.ws_shared_secret_file.is_some()
|| self.ws_issuer.is_some()
|| self.ws_audience.is_some()
|| self.ws_max_clock_skew_seconds.is_some()
{
anyhow::bail!(
"websocket auth flags require `--ws-auth capability-token` or `--ws-auth signed-bearer-token`"
);
}
None
}
};
Ok(AppServerWebsocketAuthSettings { config })
}
}
pub fn policy_from_settings(
settings: &AppServerWebsocketAuthSettings,
) -> io::Result<WebsocketAuthPolicy> {
let mode = match settings.config.as_ref() {
Some(AppServerWebsocketAuthConfig::CapabilityToken { source }) => match source {
AppServerWebsocketCapabilityTokenSource::TokenFile { token_file } => {
let token = read_trimmed_secret(token_file.as_ref())?;
Some(WebsocketAuthMode::CapabilityToken {
token_sha256: sha256_digest(token.as_bytes()),
})
}
AppServerWebsocketCapabilityTokenSource::TokenSha256 { token_sha256 } => {
Some(WebsocketAuthMode::CapabilityToken {
token_sha256: *token_sha256,
})
}
},
Some(AppServerWebsocketAuthConfig::SignedBearerToken {
shared_secret_file,
issuer,
audience,
max_clock_skew_seconds,
}) => {
let shared_secret = read_trimmed_secret(shared_secret_file.as_ref())?.into_bytes();
validate_signed_bearer_secret(shared_secret_file.as_ref(), &shared_secret)?;
let max_clock_skew_seconds = i64::try_from(*max_clock_skew_seconds).map_err(|_| {
io::Error::new(
ErrorKind::InvalidInput,
"websocket auth clock skew must fit in a signed 64-bit integer",
)
})?;
Some(WebsocketAuthMode::SignedBearerToken {
shared_secret,
issuer: issuer.clone(),
audience: audience.clone(),
max_clock_skew_seconds,
})
}
None => None,
};
Ok(WebsocketAuthPolicy { mode })
}
pub(crate) fn is_unauthenticated_non_loopback_listener(
bind_address: SocketAddr,
policy: &WebsocketAuthPolicy,
) -> bool {
!bind_address.ip().is_loopback() && policy.mode.is_none()
}
pub(crate) fn authorize_upgrade(
headers: &HeaderMap,
policy: &WebsocketAuthPolicy,
) -> Result<(), WebsocketAuthError> {
let Some(mode) = policy.mode.as_ref() else {
return Ok(());
};
let token = bearer_token_from_headers(headers)?;
match mode {
WebsocketAuthMode::CapabilityToken { token_sha256 } => {
let actual_sha256 = sha256_digest(token.as_bytes());
if constant_time_eq_32(token_sha256, &actual_sha256) {
Ok(())
} else {
Err(unauthorized("invalid websocket bearer token"))
}
}
WebsocketAuthMode::SignedBearerToken {
shared_secret,
issuer,
audience,
max_clock_skew_seconds,
} => verify_signed_bearer_token(
token,
shared_secret,
issuer.as_deref(),
audience.as_deref(),
*max_clock_skew_seconds,
),
}
}
fn verify_signed_bearer_token(
token: &str,
shared_secret: &[u8],
issuer: Option<&str>,
audience: Option<&str>,
max_clock_skew_seconds: i64,
) -> Result<(), WebsocketAuthError> {
let claims = decode_jwt_claims(token, shared_secret)?;
validate_jwt_claims(&claims, issuer, audience, max_clock_skew_seconds)
}
fn decode_jwt_claims(token: &str, shared_secret: &[u8]) -> Result<JwtClaims, WebsocketAuthError> {
let mut validation = Validation::new(Algorithm::HS256);
validation.required_spec_claims.clear();
validation.validate_exp = false;
validation.validate_nbf = false;
validation.validate_aud = false;
decode::<JwtClaims>(token, &DecodingKey::from_secret(shared_secret), &validation)
.map(|token_data| token_data.claims)
.map_err(|_| unauthorized("invalid websocket jwt"))
}
fn validate_jwt_claims(
claims: &JwtClaims,
issuer: Option<&str>,
audience: Option<&str>,
max_clock_skew_seconds: i64,
) -> Result<(), WebsocketAuthError> {
let now = OffsetDateTime::now_utc().unix_timestamp();
if now > claims.exp.saturating_add(max_clock_skew_seconds) {
return Err(unauthorized("expired websocket jwt"));
}
if let Some(nbf) = claims.nbf
&& now < nbf.saturating_sub(max_clock_skew_seconds)
{
return Err(unauthorized("websocket jwt is not valid yet"));
}
if let Some(expected_issuer) = issuer
&& claims.iss.as_deref() != Some(expected_issuer)
{
return Err(unauthorized("websocket jwt issuer mismatch"));
}
if let Some(expected_audience) = audience
&& !audience_matches(claims.aud.as_ref(), expected_audience)
{
return Err(unauthorized("websocket jwt audience mismatch"));
}
Ok(())
}
fn audience_matches(audience: Option<&JwtAudienceClaim>, expected_audience: &str) -> bool {
match audience {
Some(JwtAudienceClaim::Single(actual)) => actual == expected_audience,
Some(JwtAudienceClaim::Multiple(actual)) => {
actual.iter().any(|audience| audience == expected_audience)
}
None => false,
}
}
fn bearer_token_from_headers(headers: &HeaderMap) -> Result<&str, WebsocketAuthError> {
let raw_header = headers
.get(AUTHORIZATION)
.ok_or_else(|| unauthorized("missing websocket bearer token"))?;
let header = raw_header
.to_str()
.map_err(|_| unauthorized(INVALID_AUTHORIZATION_HEADER_MESSAGE))?;
let Some((scheme, token)) = header.split_once(' ') else {
return Err(unauthorized(INVALID_AUTHORIZATION_HEADER_MESSAGE));
};
if !scheme.eq_ignore_ascii_case("Bearer") {
return Err(unauthorized(INVALID_AUTHORIZATION_HEADER_MESSAGE));
}
let token = token.trim();
if token.is_empty() {
return Err(unauthorized(INVALID_AUTHORIZATION_HEADER_MESSAGE));
}
Ok(token)
}
fn validate_signed_bearer_secret(path: &Path, shared_secret: &[u8]) -> io::Result<()> {
if shared_secret.len() < MIN_SIGNED_BEARER_SECRET_BYTES {
return Err(io::Error::new(
ErrorKind::InvalidInput,
format!(
"signed websocket bearer secret {} must be at least {MIN_SIGNED_BEARER_SECRET_BYTES} bytes",
path.display()
),
));
}
Ok(())
}
fn read_trimmed_secret(path: &std::path::Path) -> io::Result<String> {
let raw = std::fs::read_to_string(path).map_err(|err| {
io::Error::new(
err.kind(),
format!(
"failed to read websocket auth secret {}: {err}",
path.display()
),
)
})?;
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(io::Error::new(
ErrorKind::InvalidInput,
format!("websocket auth secret {} must not be empty", path.display()),
));
}
Ok(trimmed.to_string())
}
fn absolute_path_arg(flag_name: &str, path: PathBuf) -> anyhow::Result<AbsolutePathBuf> {
AbsolutePathBuf::try_from(path).with_context(|| format!("{flag_name} must be an absolute path"))
}
fn sha256_digest_arg(flag_name: &str, value: &str) -> anyhow::Result<[u8; 32]> {
let trimmed = value.trim();
if trimmed.len() != 64 {
anyhow::bail!("{flag_name} must be a 64-character hex SHA-256 digest");
}
let mut digest = [0u8; 32];
for (index, pair) in trimmed.as_bytes().chunks_exact(2).enumerate() {
let high = hex_nibble(flag_name, pair[0])?;
let low = hex_nibble(flag_name, pair[1])?;
digest[index] = (high << 4) | low;
}
Ok(digest)
}
fn hex_nibble(flag_name: &str, byte: u8) -> anyhow::Result<u8> {
match byte {
b'0'..=b'9' => Ok(byte - b'0'),
b'a'..=b'f' => Ok(byte - b'a' + 10),
b'A'..=b'F' => Ok(byte - b'A' + 10),
_ => anyhow::bail!("{flag_name} must be a 64-character hex SHA-256 digest"),
}
}
fn sha256_digest(input: &[u8]) -> [u8; 32] {
let mut digest = [0u8; 32];
digest.copy_from_slice(&Sha256::digest(input));
digest
}
fn unauthorized(message: &'static str) -> WebsocketAuthError {
WebsocketAuthError {
status_code: StatusCode::UNAUTHORIZED,
message,
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::HeaderValue;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use hmac::Hmac;
use hmac::Mac;
use serde_json::json;
type HmacSha256 = Hmac<Sha256>;
fn signed_token(shared_secret: &[u8], claims: serde_json::Value) -> String {
let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"HS256","typ":"JWT"}"#);
let claims_segment = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap());
let payload = format!("{header}.{claims_segment}");
let mut mac = HmacSha256::new_from_slice(shared_secret).unwrap();
mac.update(payload.as_bytes());
let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
format!("{payload}.{signature}")
}
#[test]
fn detects_unauthenticated_non_loopback_listener() {
let policy = WebsocketAuthPolicy::default();
assert!(is_unauthenticated_non_loopback_listener(
"0.0.0.0:8765".parse().unwrap(),
&policy,
));
assert!(!is_unauthenticated_non_loopback_listener(
"127.0.0.1:8765".parse().unwrap(),
&policy,
));
assert!(!is_unauthenticated_non_loopback_listener(
"0.0.0.0:8765".parse().unwrap(),
&WebsocketAuthPolicy {
mode: Some(WebsocketAuthMode::CapabilityToken {
token_sha256: [0u8; 32],
}),
},
));
}
#[test]
fn capability_token_args_require_token_file_or_hash() {
let err = AppServerWebsocketAuthArgs {
ws_auth: Some(WebsocketAuthCliMode::CapabilityToken),
..Default::default()
}
.try_into_settings()
.expect_err("capability-token mode should require a token source");
assert!(
err.to_string().contains("--ws-token-file")
&& err.to_string().contains("--ws-token-sha256"),
"unexpected error: {err}"
);
}
#[test]
fn capability_token_args_accept_token_hash() {
let settings = AppServerWebsocketAuthArgs {
ws_auth: Some(WebsocketAuthCliMode::CapabilityToken),
ws_token_sha256: Some("ab".repeat(32)),
..Default::default()
}
.try_into_settings()
.expect("capability-token hash args should parse");
assert_eq!(
settings,
AppServerWebsocketAuthSettings {
config: Some(AppServerWebsocketAuthConfig::CapabilityToken {
source: AppServerWebsocketCapabilityTokenSource::TokenSha256 {
token_sha256: [0xab; 32],
},
}),
}
);
}
#[test]
fn capability_token_args_reject_multiple_token_sources() {
let err = AppServerWebsocketAuthArgs {
ws_auth: Some(WebsocketAuthCliMode::CapabilityToken),
ws_token_file: Some(PathBuf::from("/tmp/token")),
ws_token_sha256: Some("ab".repeat(32)),
..Default::default()
}
.try_into_settings()
.expect_err("capability-token mode should reject multiple token sources");
assert!(
err.to_string().contains("mutually exclusive"),
"unexpected error: {err}"
);
}
#[test]
fn capability_token_args_reject_malformed_token_hash() {
let err = AppServerWebsocketAuthArgs {
ws_auth: Some(WebsocketAuthCliMode::CapabilityToken),
ws_token_sha256: Some("not-a-sha256".to_string()),
..Default::default()
}
.try_into_settings()
.expect_err("capability-token mode should reject malformed token hashes");
assert!(
err.to_string().contains("64-character hex"),
"unexpected error: {err}"
);
}
#[test]
fn capability_token_hash_policy_authorizes_matching_bearer_token() {
let settings = AppServerWebsocketAuthSettings {
config: Some(AppServerWebsocketAuthConfig::CapabilityToken {
source: AppServerWebsocketCapabilityTokenSource::TokenSha256 {
token_sha256: sha256_digest(b"super-secret-token"),
},
}),
};
let policy = policy_from_settings(&settings).expect("hash policy should build");
let mut headers = HeaderMap::new();
headers.insert(
AUTHORIZATION,
HeaderValue::from_static("Bearer super-secret-token"),
);
authorize_upgrade(&headers, &policy).expect("matching token should authorize");
headers.insert(
AUTHORIZATION,
HeaderValue::from_static("Bearer wrong-token"),
);
let err = authorize_upgrade(&headers, &policy).expect_err("wrong token should fail");
assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED);
}
#[test]
fn signed_bearer_args_require_mode_when_mode_specific_flags_are_set() {
let err = AppServerWebsocketAuthArgs {
ws_shared_secret_file: Some(PathBuf::from("/tmp/secret")),
..Default::default()
}
.try_into_settings()
.expect_err("mode-specific flags should require --ws-auth");
assert!(
err.to_string().contains("websocket auth flags require"),
"unexpected error: {err}"
);
}
#[test]
fn signed_bearer_args_default_clock_skew_and_trim_optional_claims() {
let settings = AppServerWebsocketAuthArgs {
ws_auth: Some(WebsocketAuthCliMode::SignedBearerToken),
ws_shared_secret_file: Some(PathBuf::from("/tmp/secret")),
ws_issuer: Some(" issuer ".to_string()),
ws_audience: Some(" ".to_string()),
..Default::default()
}
.try_into_settings()
.expect("signed bearer args should parse");
assert_eq!(
settings,
AppServerWebsocketAuthSettings {
config: Some(AppServerWebsocketAuthConfig::SignedBearerToken {
shared_secret_file: AbsolutePathBuf::from_absolute_path("/tmp/secret")
.expect("absolute path"),
issuer: Some("issuer".to_string()),
audience: None,
max_clock_skew_seconds: DEFAULT_MAX_CLOCK_SKEW_SECONDS,
}),
}
);
}
#[test]
fn signed_bearer_token_verification_rejects_tampering() {
let shared_secret = b"0123456789abcdef0123456789abcdef";
let token = signed_token(
shared_secret,
json!({
"exp": OffsetDateTime::now_utc().unix_timestamp() + 60,
}),
);
let tampered = token.replace(".eyJleHAi", ".eyJleHBi");
let err = verify_signed_bearer_token(
&tampered,
shared_secret,
/*issuer*/ None,
/*audience*/ None,
/*max_clock_skew_seconds*/ 30,
)
.expect_err("tampered jwt should fail");
assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED);
}
#[test]
fn signed_bearer_token_verification_accepts_valid_token() {
let shared_secret = b"0123456789abcdef0123456789abcdef";
let token = signed_token(
shared_secret,
json!({
"exp": OffsetDateTime::now_utc().unix_timestamp() + 60,
"iss": "issuer",
"aud": "audience",
}),
);
verify_signed_bearer_token(
&token,
shared_secret,
Some("issuer"),
Some("audience"),
/*max_clock_skew_seconds*/ 30,
)
.expect("valid signed token should verify");
}
#[test]
fn signed_bearer_token_verification_accepts_multiple_audiences() {
let shared_secret = b"0123456789abcdef0123456789abcdef";
let token = signed_token(
shared_secret,
json!({
"exp": OffsetDateTime::now_utc().unix_timestamp() + 60,
"aud": ["other-audience", "audience"],
}),
);
verify_signed_bearer_token(
&token,
shared_secret,
/*issuer*/ None,
Some("audience"),
/*max_clock_skew_seconds*/ 30,
)
.expect("jwt audience arrays should verify");
}
#[test]
fn signed_bearer_token_verification_rejects_alg_none_tokens() {
let claims_segment = URL_SAFE_NO_PAD.encode(
serde_json::to_vec(&json!({
"exp": OffsetDateTime::now_utc().unix_timestamp() + 60,
}))
.unwrap(),
);
let header_segment = URL_SAFE_NO_PAD.encode(br#"{"alg":"none","typ":"JWT"}"#);
let token = format!("{header_segment}.{claims_segment}.");
let err = verify_signed_bearer_token(
&token,
b"0123456789abcdef0123456789abcdef",
/*issuer*/ None,
/*audience*/ None,
/*max_clock_skew_seconds*/ 30,
)
.expect_err("alg=none jwt should be rejected");
assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED);
}
#[test]
fn signed_bearer_token_verification_rejects_missing_exp() {
let shared_secret = b"0123456789abcdef0123456789abcdef";
let token = signed_token(
shared_secret,
json!({
"iss": "issuer",
}),
);
let err = verify_signed_bearer_token(
&token,
shared_secret,
/*issuer*/ None,
/*audience*/ None,
/*max_clock_skew_seconds*/ 30,
)
.expect_err("jwt without exp should be rejected");
assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED);
}
#[test]
fn validate_signed_bearer_secret_rejects_short_secret() {
let err = validate_signed_bearer_secret(Path::new("/tmp/secret"), b"too-short")
.expect_err("short shared secret should be rejected");
assert_eq!(err.kind(), ErrorKind::InvalidInput);
assert!(
err.to_string().contains("must be at least 32 bytes"),
"unexpected error: {err}"
);
}
}
@@ -1,3 +1,5 @@
pub mod auth;
use crate::outgoing_message::ConnectionId;
use crate::outgoing_message::OutgoingError;
use crate::outgoing_message::OutgoingMessage;
@@ -6,6 +8,7 @@ use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::JSONRPCMessage;
use codex_core::config::find_codex_home;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::net::SocketAddr;
use std::path::Path;
use std::str::FromStr;
use std::sync::atomic::AtomicU64;
@@ -29,9 +32,11 @@ mod websocket;
pub use remote_control::RemoteControlHandle;
pub use remote_control::RemoteControlStartConfig;
pub use remote_control::RemoteControlUnavailable;
pub use remote_control::start_remote_control;
pub use stdio::start_stdio_connection;
pub use unix_socket::start_control_socket_acceptor;
pub use websocket::start_websocket_acceptor;
const OVERLOADED_ERROR_CODE: i64 = -32001;
@@ -50,6 +55,7 @@ pub fn app_server_control_socket_path(codex_home: &Path) -> std::io::Result<Abso
pub enum AppServerTransport {
Stdio,
UnixSocket { socket_path: AbsolutePathBuf },
WebSocket { bind_address: SocketAddr },
Off,
}
@@ -57,6 +63,7 @@ pub enum AppServerTransport {
pub enum AppServerTransportParseError {
UnsupportedListenUrl(String),
InvalidUnixSocketPath { listen_url: String, message: String },
InvalidWebSocketListenUrl(String),
}
impl std::fmt::Display for AppServerTransportParseError {
@@ -64,7 +71,7 @@ impl std::fmt::Display for AppServerTransportParseError {
match self {
AppServerTransportParseError::UnsupportedListenUrl(listen_url) => write!(
f,
"unsupported --listen URL `{listen_url}`; expected `stdio://`, `unix://`, `unix://PATH`, or `off`"
"unsupported --listen URL `{listen_url}`; expected `stdio://`, `unix://`, `unix://PATH`, `ws://IP:PORT`, or `off`"
),
AppServerTransportParseError::InvalidUnixSocketPath {
listen_url,
@@ -73,6 +80,10 @@ impl std::fmt::Display for AppServerTransportParseError {
f,
"invalid unix socket --listen URL `{listen_url}`; failed to resolve socket path: {message}"
),
AppServerTransportParseError::InvalidWebSocketListenUrl(listen_url) => write!(
f,
"invalid websocket --listen URL `{listen_url}`; expected `ws://IP:PORT`"
),
}
}
}
@@ -116,6 +127,13 @@ impl AppServerTransport {
return Ok(Self::Off);
}
if let Some(socket_addr) = listen_url.strip_prefix("ws://") {
let bind_address = socket_addr.parse::<SocketAddr>().map_err(|_| {
AppServerTransportParseError::InvalidWebSocketListenUrl(listen_url.to_string())
})?;
return Ok(Self::WebSocket { bind_address });
}
Err(AppServerTransportParseError::UnsupportedListenUrl(
listen_url.to_string(),
))
@@ -151,7 +169,7 @@ pub enum TransportEvent {
pub enum ConnectionOrigin {
Stdio,
InProcess,
UnixSocket,
WebSocket,
RemoteControl,
}
@@ -1,6 +1,7 @@
use super::protocol::EnrollRemoteServerRequest;
use super::protocol::EnrollRemoteServerResponse;
use super::protocol::RemoteControlTarget;
use axum::http::HeaderMap;
use codex_api::SharedAuthProvider;
use codex_login::default_client::build_reqwest_client;
use codex_state::RemoteControlEnrollmentRecord;
@@ -8,7 +9,6 @@ use codex_state::StateRuntime;
use gethostname::gethostname;
use std::io;
use std::io::ErrorKind;
use tokio_tungstenite::tungstenite::http::HeaderMap;
use tracing::info;
use tracing::warn;
@@ -19,6 +19,8 @@ use codex_app_server_protocol::RemoteControlConnectionStatus;
use codex_app_server_protocol::RemoteControlStatusChangedNotification;
use codex_login::AuthManager;
use codex_state::StateRuntime;
use std::error::Error;
use std::fmt;
use std::io;
use std::sync::Arc;
use tokio::sync::mpsc;
@@ -47,23 +49,87 @@ pub struct RemoteControlHandle {
state_db_available: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RemoteControlUnavailable;
impl fmt::Display for RemoteControlUnavailable {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"remote control cannot be enabled because sqlite state db is unavailable"
)
}
}
impl Error for RemoteControlUnavailable {}
impl RemoteControlHandle {
pub fn set_enabled(&self, enabled: bool) {
let requested_enabled = enabled;
let enabled = enabled && self.state_db_available;
if requested_enabled && !self.state_db_available {
pub fn enable(
&self,
) -> Result<RemoteControlStatusChangedNotification, RemoteControlUnavailable> {
if !self.state_db_available {
warn!("remote control cannot be enabled because sqlite state db is unavailable");
return Err(RemoteControlUnavailable);
}
self.enabled_tx.send_if_modified(|state| {
let changed = *state != enabled;
*state = enabled;
let changed = !*state;
*state = true;
changed
});
let status = self.status();
if matches!(
status.status,
RemoteControlConnectionStatus::Connected | RemoteControlConnectionStatus::Connecting
) {
return Ok(status);
}
Ok(self.publish_status(RemoteControlConnectionStatus::Connecting))
}
pub fn disable(&self) -> RemoteControlStatusChangedNotification {
self.enabled_tx.send_if_modified(|state| {
let changed = *state;
*state = false;
changed
});
self.publish_status(RemoteControlConnectionStatus::Disabled)
}
pub fn status(&self) -> RemoteControlStatusChangedNotification {
self.status_tx.borrow().clone()
}
pub fn status_receiver(&self) -> watch::Receiver<RemoteControlStatusChangedNotification> {
self.status_tx.subscribe()
}
fn publish_status(
&self,
connection_status: RemoteControlConnectionStatus,
) -> RemoteControlStatusChangedNotification {
self.status_tx.send_if_modified(|status| {
let next_status = RemoteControlStatusChangedNotification {
status: connection_status,
installation_id: status.installation_id.clone(),
environment_id: if connection_status == RemoteControlConnectionStatus::Disabled {
None
} else {
status.environment_id.clone()
},
};
if *status == next_status {
return false;
}
*status = next_status;
true
});
self.status()
}
}
pub async fn start_remote_control(
@@ -639,7 +639,10 @@ async fn remote_control_start_reports_missing_state_db_as_disabled_when_enabled(
.await
.expect_err("remote control should not connect without sqlite state db");
remote_handle.set_enabled(/*enabled*/ true);
assert_eq!(
remote_handle.enable().expect_err("enable should fail"),
super::RemoteControlUnavailable
);
timeout(Duration::from_millis(100), listener.accept())
.await
.expect_err("remote control should remain disabled without sqlite state db");
@@ -655,7 +658,7 @@ async fn remote_control_start_reports_missing_state_db_as_disabled_when_enabled(
}
#[tokio::test]
async fn remote_control_handle_set_enabled_stops_and_restarts_connections() {
async fn remote_control_handle_enable_disable_stops_and_restarts_connections() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
@@ -701,7 +704,14 @@ async fn remote_control_handle_set_enabled_stops_and_restarts_connections() {
)
.await;
remote_handle.set_enabled(/*enabled*/ false);
assert_eq!(
remote_handle.disable(),
RemoteControlStatusChangedNotification {
status: RemoteControlConnectionStatus::Disabled,
installation_id: TEST_INSTALLATION_ID.to_string(),
environment_id: None,
}
);
expect_remote_control_status_snapshot(
&mut status_rx,
RemoteControlStatusChangedNotification {
@@ -718,13 +728,20 @@ async fn remote_control_handle_set_enabled_stops_and_restarts_connections() {
.await
.expect_err("disabled remote control should not reconnect");
remote_handle.set_enabled(/*enabled*/ true);
assert_eq!(
remote_handle.enable().expect("enable should succeed"),
RemoteControlStatusChangedNotification {
status: RemoteControlConnectionStatus::Connecting,
installation_id: TEST_INSTALLATION_ID.to_string(),
environment_id: None,
}
);
expect_remote_control_status_snapshot(
&mut status_rx,
RemoteControlStatusChangedNotification {
status: RemoteControlConnectionStatus::Connecting,
installation_id: TEST_INSTALLATION_ID.to_string(),
environment_id: Some("env_test".to_string()),
environment_id: None,
},
)
.await;
@@ -19,6 +19,7 @@ use super::segment::ClientSegmentObservation;
use super::segment::ClientSegmentReassembler;
use super::segment::REMOTE_CONTROL_SEGMENT_MAX_BYTES;
use super::segment::split_server_envelope_for_transport;
use axum::http::HeaderValue;
use base64::Engine;
use codex_app_server_protocol::RemoteControlConnectionStatus;
use codex_app_server_protocol::RemoteControlStatusChangedNotification;
@@ -47,7 +48,6 @@ use tokio_tungstenite::WebSocketStream;
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::http::HeaderValue;
use tokio_util::sync::CancellationToken;
use tracing::error;
use tracing::info;
@@ -52,14 +52,6 @@ fn listen_unix_socket_accepts_relative_custom_path() {
);
}
#[test]
fn listen_websocket_url_is_not_supported() {
assert!(matches!(
AppServerTransport::from_listen_url("ws://127.0.0.1:0"),
Err(super::AppServerTransportParseError::UnsupportedListenUrl(_))
));
}
#[tokio::test]
async fn control_socket_acceptor_upgrades_and_forwards_websocket_text_messages_and_pings() {
let temp_dir = tempfile::TempDir::new().expect("temp dir");
@@ -1,17 +1,46 @@
use super::CHANNEL_CAPACITY;
use super::ConnectionOrigin;
use super::TransportEvent;
use super::auth::WebsocketAuthPolicy;
use super::auth::authorize_upgrade;
use super::auth::is_unauthenticated_non_loopback_listener;
use super::forward_incoming_message;
use super::next_connection_id;
use super::serialize_outgoing_message;
use crate::outgoing_message::ConnectionId;
use crate::outgoing_message::QueuedOutgoingMessage;
use axum::Router;
use axum::body::Body;
use axum::body::Bytes;
use axum::extract::ConnectInfo;
use axum::extract::State;
use axum::extract::ws::Message as AxumWebSocketMessage;
use axum::extract::ws::WebSocketUpgrade;
use axum::http::HeaderMap;
use axum::http::Request;
use axum::http::StatusCode;
use axum::http::header::ORIGIN;
use axum::middleware;
use axum::middleware::Next;
use axum::response::IntoResponse;
use axum::response::Response;
use axum::routing::any;
use axum::routing::get;
use futures::SinkExt;
use futures::StreamExt;
use owo_colors::OwoColorize;
use owo_colors::Stream;
use owo_colors::Style;
use std::io::Result as IoResult;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::net::TcpListener;
use tokio::sync::mpsc;
use tokio_tungstenite::tungstenite::Bytes;
use tokio::task::JoinHandle;
use tokio_tungstenite::tungstenite::Message as TungsteniteWebSocketMessage;
use tokio_util::sync::CancellationToken;
use tracing::error;
use tracing::info;
use tracing::warn;
/// WebSocket clients can briefly lag behind normal turn output bursts while the
@@ -19,6 +48,127 @@ use tracing::warn;
const WEBSOCKET_OUTBOUND_CHANNEL_CAPACITY: usize = 32 * 1024;
const _: () = assert!(WEBSOCKET_OUTBOUND_CHANNEL_CAPACITY > CHANNEL_CAPACITY);
fn colorize(text: &str, style: Style) -> String {
text.if_supports_color(Stream::Stderr, |value| value.style(style))
.to_string()
}
#[allow(clippy::print_stderr)]
fn print_websocket_startup_banner(addr: SocketAddr) {
let title = colorize("codex app-server (WebSockets)", Style::new().bold().cyan());
let listening_label = colorize("listening on:", Style::new().dimmed());
let listen_url = colorize(&format!("ws://{addr}"), Style::new().green());
let ready_label = colorize("readyz:", Style::new().dimmed());
let ready_url = colorize(&format!("http://{addr}/readyz"), Style::new().green());
let health_label = colorize("healthz:", Style::new().dimmed());
let health_url = colorize(&format!("http://{addr}/healthz"), Style::new().green());
let note_label = colorize("note:", Style::new().dimmed());
eprintln!("{title}");
eprintln!(" {listening_label} {listen_url}");
eprintln!(" {ready_label} {ready_url}");
eprintln!(" {health_label} {health_url}");
if addr.ip().is_loopback() {
eprintln!(
" {note_label} binds localhost only (use SSH port-forwarding for remote access)"
);
} else {
eprintln!(" {note_label} websocket auth is required for non-localhost listeners");
}
}
#[derive(Clone)]
struct WebSocketListenerState {
transport_event_tx: mpsc::Sender<TransportEvent>,
auth_policy: Arc<WebsocketAuthPolicy>,
}
async fn health_check_handler() -> StatusCode {
StatusCode::OK
}
async fn reject_requests_with_origin_header(
request: Request<Body>,
next: Next,
) -> Result<Response, StatusCode> {
if request.headers().contains_key(ORIGIN) {
warn!(
method = %request.method(),
uri = %request.uri(),
"rejecting websocket listener request with Origin header"
);
Err(StatusCode::FORBIDDEN)
} else {
Ok(next.run(request).await)
}
}
async fn websocket_upgrade_handler(
websocket: WebSocketUpgrade,
ConnectInfo(peer_addr): ConnectInfo<SocketAddr>,
State(state): State<WebSocketListenerState>,
headers: HeaderMap,
) -> impl IntoResponse {
if let Err(err) = authorize_upgrade(&headers, state.auth_policy.as_ref()) {
warn!(
%peer_addr,
message = err.message(),
"rejecting websocket client during upgrade"
);
return (err.status_code(), err.message()).into_response();
}
info!(%peer_addr, "websocket client connected");
websocket
.on_upgrade(move |stream| async move {
let (websocket_writer, websocket_reader) = stream.split();
run_websocket_connection(websocket_writer, websocket_reader, state.transport_event_tx)
.await;
})
.into_response()
}
pub async fn start_websocket_acceptor(
bind_address: SocketAddr,
transport_event_tx: mpsc::Sender<TransportEvent>,
shutdown_token: CancellationToken,
auth_policy: WebsocketAuthPolicy,
) -> IoResult<JoinHandle<()>> {
if is_unauthenticated_non_loopback_listener(bind_address, &auth_policy) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"refusing to start non-loopback websocket listener {bind_address} without auth; configure `--ws-auth capability-token` or `--ws-auth signed-bearer-token`"
),
));
}
let listener = TcpListener::bind(bind_address).await?;
let local_addr = listener.local_addr()?;
print_websocket_startup_banner(local_addr);
info!("app-server websocket listening on ws://{local_addr}");
let router = Router::new()
.route("/readyz", get(health_check_handler))
.route("/healthz", get(health_check_handler))
.fallback(any(websocket_upgrade_handler))
.layer(middleware::from_fn(reject_requests_with_origin_header))
.with_state(WebSocketListenerState {
transport_event_tx,
auth_policy: Arc::new(auth_policy),
});
let server = axum::serve(
listener,
router.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(async move {
shutdown_token.cancelled().await;
});
Ok(tokio::spawn(async move {
if let Err(err) = server.await {
error!("websocket acceptor failed: {err}");
}
info!("websocket acceptor shutting down");
}))
}
pub(crate) async fn run_websocket_connection<M, SinkError, StreamError>(
websocket_writer: impl futures::sink::Sink<M, Error = SinkError> + Send + 'static,
websocket_reader: impl futures::stream::Stream<Item = Result<M, StreamError>> + Send + 'static,
@@ -36,7 +186,7 @@ pub(crate) async fn run_websocket_connection<M, SinkError, StreamError>(
if transport_event_tx
.send(TransportEvent::ConnectionOpened {
connection_id,
origin: ConnectionOrigin::UnixSocket,
origin: ConnectionOrigin::WebSocket,
writer: writer_tx,
disconnect_sender: Some(disconnect_token.clone()),
})
@@ -95,6 +245,26 @@ pub(crate) trait AppServerWebSocketMessage: Sized {
fn into_incoming(self) -> Option<IncomingWebSocketMessage>;
}
impl AppServerWebSocketMessage for AxumWebSocketMessage {
fn text(text: String) -> Self {
Self::Text(text.into())
}
fn pong(payload: Bytes) -> Self {
Self::Pong(payload)
}
fn into_incoming(self) -> Option<IncomingWebSocketMessage> {
Some(match self {
Self::Text(text) => IncomingWebSocketMessage::Text(text.to_string()),
Self::Binary(_) => IncomingWebSocketMessage::Binary,
Self::Ping(payload) => IncomingWebSocketMessage::Ping(payload),
Self::Pong(_) => IncomingWebSocketMessage::Pong,
Self::Close(_) => IncomingWebSocketMessage::Close,
})
}
}
impl AppServerWebSocketMessage for TungsteniteWebSocketMessage {
fn text(text: String) -> Self {
Self::Text(text.into())
+5 -2
View File
@@ -28,6 +28,7 @@ axum = { workspace = true, default-features = false, features = [
"http1",
"json",
"tokio",
"ws",
] }
codex-analytics = { workspace = true }
codex-arg0 = { workspace = true }
@@ -40,7 +41,7 @@ codex-extension-api = { workspace = true }
codex-external-agent-migration = { workspace = true }
codex-external-agent-sessions = { workspace = true }
codex-features = { workspace = true }
codex-git-attribution = { workspace = true }
codex-guardian = { workspace = true }
codex-git-utils = { workspace = true }
codex-file-watcher = { workspace = true }
codex-hooks = { workspace = true }
@@ -98,20 +99,22 @@ axum = { workspace = true, default-features = false, features = [
"tokio",
] }
base64 = { workspace = true }
codex-uds = { workspace = true }
codex-model-provider-info = { workspace = true }
codex-utils-cargo-bin = { workspace = true }
core_test_support = { workspace = true }
flate2 = { workspace = true }
hmac = { workspace = true }
opentelemetry = { workspace = true }
opentelemetry_sdk = { workspace = true }
pretty_assertions = { workspace = true }
reqwest = { workspace = true, features = ["rustls-tls"] }
rmcp = { workspace = true, default-features = false, features = [
"elicitation",
"server",
"transport-streamable-http-server",
] }
serial_test = { workspace = true }
sha2 = { workspace = true }
shlex = { workspace = true }
tar = { workspace = true }
tokio-tungstenite = { workspace = true }
+15 -2
View File
@@ -24,9 +24,18 @@ Similar to [MCP](https://modelcontextprotocol.io/), `codex app-server` supports
Supported transports:
- stdio (`--listen stdio://`, default): newline-delimited JSON (JSONL)
- websocket (`--listen ws://IP:PORT`): one JSON-RPC message per websocket text frame (**experimental / unsupported**)
- unix socket (`--listen unix://` or `--listen unix://PATH`): websocket connections over `$CODEX_HOME/app-server-control/app-server-control.sock` or a custom socket path, using the standard HTTP Upgrade handshake
- off (`--listen off`): do not expose a local transport
When running with `--listen ws://IP:PORT`, the same listener also serves basic HTTP health probes:
- `GET /readyz` returns `200 OK` once the listener is accepting new connections.
- `GET /healthz` returns `200 OK` when no `Origin` header is present.
- Any request carrying an `Origin` header is rejected with `403 Forbidden`.
Websocket transport is currently experimental and unsupported. Do not rely on it for production workloads.
The unix socket transport is intended for local app-server control-plane clients. `codex app-server proxy`
opens exactly one raw stream connection to `$CODEX_HOME/app-server-control/app-server-control.sock`
by default, or to `--sock PATH` when provided, and proxies bytes between that socket and stdin/stdout.
@@ -180,7 +189,7 @@ Example with notification opt-out:
- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, `additionalSpeedTiers`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata.
- `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. For non-beta flags, `displayName`/`description`/`announcement` are `null`.
- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `memories`, `plugins`, `remote_control`, `tool_search`, `tool_suggest`, `tool_call_mcp_elicitation`). 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 the currently supported feature keys (`apps`, `memories`, `plugins`, `tool_search`, `tool_suggest`, `tool_call_mcp_elicitation`). For each feature, precedence is: cloud requirements > --enable <feature_name> > config.toml > experimentalFeature/enablement/set (new) > code default.
- `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`).
@@ -193,6 +202,8 @@ Example with notification opt-out:
- `plugin/skill/read` — read remote plugin skill markdown on demand by `remoteMarketplaceName`, `remotePluginId`, and `skillName`. This lets clients preview uninstalled remote plugin skills without downloading the plugin bundle.
- `skills/changed` — notification emitted when watched local skill files change.
- `app/list` — list available apps.
- `remoteControl/enable` — experimental; enable remote control for the current app-server process and return the current remote-control status snapshot. The caller is responsible for persisting the desired setting outside app-server.
- `remoteControl/disable` — experimental; disable remote control for the current app-server process and return the current remote-control status snapshot. This does not revoke already enrolled controller devices.
- `remoteControl/status/changed` — notification emitted when the remote-control status or client-visible environment id changes. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled. Newly initialized app-server clients always receive the current status snapshot.
- `skills/config/write` — write user-level skill config by name or absolute path.
- `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, install MCPs if any, and return the effective plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**).
@@ -210,7 +221,7 @@ Example with notification opt-out:
- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin/session `details` returned by detect. When a request includes migration items, the server emits `externalAgentConfig/import/completed` once after the full import finishes (immediately after the response when everything completed synchronously, or after background imports finish).
- `config/value/write` — write a single config key/value to the user's config.toml on disk.
- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads.
- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`.
- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), lifecycle hook lockdown (`allowManagedHooksOnly`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`.
### Example: Start or resume a thread
@@ -1537,6 +1548,8 @@ To enable or disable a skill by name:
Use `hooks/list` to fetch discovered hooks for one or more `cwds`. Each result is evaluated with that `cwd`'s effective config, so feature gates and discovered config layers can differ within a single response.
For linked Git worktrees, project hook declarations come from the matching `.codex/` folders in the root checkout rather than from divergent hook declarations stored only in the linked worktree. This keeps each repo on one authoritative project-hook definition and one trust state.
Hooks are returned even when disabled so clients can render and re-enable them. User-controlled state lives under `hooks.state`. Managed hooks are non-configurable, and user entries for managed hook keys are ignored during loading.
For unmanaged hooks, `currentHash` and `trustStatus` describe whether the current definition is first-seen, approved, or changed since approval. Only trusted unmanaged hooks become runnable. Hook keys combine the source identity with a trailing event/group/handler selector that is currently positional.
@@ -1,7 +1,7 @@
//! Tracing helpers shared by socket and in-process app-server entry points.
//!
//! The in-process path intentionally reuses the same span shape as JSON-RPC
//! transports so request telemetry stays comparable across stdio, unix socket,
//! transports so request telemetry stays comparable across stdio, websocket,
//! and embedded callers. [`typed_request_span`] is the in-process counterpart
//! of [`request_span`] and stamps `rpc.transport` as `"in-process"` while
//! deriving client identity from the typed [`ClientRequest`] rather than
@@ -86,6 +86,7 @@ fn transport_name(transport: &AppServerTransport) -> &'static str {
match transport {
AppServerTransport::Stdio => "stdio",
AppServerTransport::UnixSocket { .. } => "unix_socket",
AppServerTransport::WebSocket { .. } => "websocket",
AppServerTransport::Off => "off",
}
}
@@ -772,6 +772,7 @@ pub(crate) async fn apply_bespoke_event_handling(
requested_permissions,
request_cwd,
pending_request_id,
outgoing,
receiver: rx,
request_permissions_guard: permission_guard,
};
@@ -1745,11 +1746,12 @@ async fn on_request_permissions_response(
requested_permissions,
request_cwd,
pending_request_id,
outgoing,
receiver,
request_permissions_guard,
} = pending_response;
let response = receiver.await;
resolve_server_request_on_thread_listener(&thread_state, pending_request_id).await;
resolve_server_request_on_thread_listener(&thread_state, pending_request_id.clone()).await;
drop(request_permissions_guard);
let Some(response) = request_permissions_response_from_client_result(
requested_permissions,
@@ -1758,6 +1760,7 @@ async fn on_request_permissions_response(
) else {
return;
};
outgoing.track_effective_permissions_approval_response(pending_request_id, response.clone());
if let Err(err) = conversation
.submit(Op::RequestPermissionsResponse {
@@ -1775,6 +1778,7 @@ struct PendingRequestPermissionsResponse {
requested_permissions: CoreRequestPermissionProfile,
request_cwd: AbsolutePathBuf,
pending_request_id: RequestId,
outgoing: ThreadScopedOutgoingMessageSender,
receiver: oneshot::Receiver<ClientRequestResult>,
request_permissions_guard: ThreadWatchActiveGuard,
}
@@ -507,7 +507,7 @@ impl ExternalAgentConfigService {
Ok(config) => {
let configured_plugin_ids = config
.config_layer_stack
.get_user_layer()
.get_active_user_layer()
.and_then(|user_layer| user_layer.config.get("plugins"))
.and_then(|plugins| {
match plugins.clone().try_into::<HashMap<String, PluginConfig>>() {
+23 -1
View File
@@ -30,6 +30,7 @@ pub(crate) struct ConfigManager {
cli_overrides: Arc<RwLock<Vec<(String, TomlValue)>>>,
runtime_feature_enablement: Arc<RwLock<BTreeMap<String, bool>>>,
loader_overrides: LoaderOverrides,
strict_config: bool,
cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>,
arg0_paths: Arg0DispatchPaths,
thread_config_loader: Arc<RwLock<Arc<dyn ThreadConfigLoader>>>,
@@ -40,6 +41,7 @@ impl ConfigManager {
codex_home: PathBuf,
cli_overrides: Vec<(String, TomlValue)>,
loader_overrides: LoaderOverrides,
strict_config: bool,
cloud_requirements: CloudRequirementsLoader,
arg0_paths: Arg0DispatchPaths,
thread_config_loader: Arc<dyn ThreadConfigLoader>,
@@ -49,6 +51,7 @@ impl ConfigManager {
cli_overrides: Arc::new(RwLock::new(cli_overrides)),
runtime_feature_enablement: Arc::new(RwLock::new(BTreeMap::new())),
loader_overrides,
strict_config,
cloud_requirements: Arc::new(RwLock::new(cloud_requirements)),
arg0_paths,
thread_config_loader: Arc::new(RwLock::new(thread_config_loader)),
@@ -59,6 +62,10 @@ impl ConfigManager {
self.codex_home.as_path()
}
pub(crate) fn user_config_path(&self) -> std::io::Result<AbsolutePathBuf> {
self.loader_overrides.user_config_path(self.codex_home())
}
pub(crate) fn current_cli_overrides(&self) -> Vec<(String, TomlValue)> {
self.cli_overrides
.read()
@@ -161,6 +168,16 @@ impl ConfigManager {
self.current_cli_overrides(),
)
.await?;
if self.loader_overrides.user_config_path.is_some()
|| self.loader_overrides.user_config_profile.is_some()
{
let user_config_path = self.loader_overrides.user_config_path(self.codex_home())?;
config.config_layer_stack = config.config_layer_stack.with_user_config_profile(
&user_config_path,
self.loader_overrides.user_config_profile.as_ref(),
TomlValue::Table(toml::map::Map::new()),
);
}
self.apply_runtime_feature_enablement(&mut config);
self.apply_arg0_paths(&mut config);
Ok(config)
@@ -217,6 +234,7 @@ impl ConfigManager {
.codex_home(self.codex_home.clone())
.cli_overrides(merged_cli_overrides)
.loader_overrides(self.loader_overrides.clone())
.strict_config(self.strict_config)
.harness_overrides(typesafe_overrides)
.fallback_cwd(fallback_cwd)
.cloud_requirements(self.current_cloud_requirements())
@@ -245,7 +263,10 @@ impl ConfigManager {
&self.codex_home,
cwd,
&self.current_cli_overrides(),
self.loader_overrides.clone(),
codex_config::ConfigLoadOptions {
loader_overrides: self.loader_overrides.clone(),
strict_config: self.strict_config,
},
self.current_cloud_requirements(),
thread_config_loader.as_ref(),
)
@@ -280,6 +301,7 @@ impl ConfigManager {
codex_home,
cli_overrides,
loader_overrides,
/*strict_config*/ false,
cloud_requirements,
Arg0DispatchPaths::default(),
Arc::new(codex_config::NoopThreadConfigLoader),
@@ -196,8 +196,9 @@ impl ConfigManager {
expected_version: Option<String>,
edits: Vec<(String, JsonValue, MergeStrategy)>,
) -> Result<ConfigWriteResponse, ConfigManagerError> {
let allowed_path =
AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, self.codex_home());
let allowed_path = self
.user_config_path()
.map_err(|err| ConfigManagerError::io("failed to resolve user config path", err))?;
let provided_path = match file_path {
Some(path) => AbsolutePathBuf::from_absolute_path(PathBuf::from(path))
.map_err(|err| ConfigManagerError::io("failed to resolve user config path", err))?,
@@ -215,7 +216,7 @@ impl ConfigManager {
.load_thread_agnostic_config()
.await
.map_err(|err| ConfigManagerError::io("failed to load configuration", err))?;
let user_layer = match layers.get_user_layer() {
let user_layer = match layers.get_active_user_layer() {
Some(layer) => Cow::Borrowed(layer),
None => Cow::Owned(create_empty_user_layer(&allowed_path).await?),
};
@@ -305,7 +306,7 @@ impl ConfigManager {
})?;
if !config_edits.is_empty() {
ConfigEditsBuilder::new(self.codex_home())
ConfigEditsBuilder::for_config_path(provided_path.as_path())
.with_edits(config_edits)
.apply()
.await
@@ -321,7 +322,7 @@ impl ConfigManager {
Ok(ConfigWriteResponse {
status,
version: updated_layers
.get_user_layer()
.get_active_user_layer()
.ok_or_else(|| {
ConfigManagerError::write(
ConfigWriteErrorCode::UserLayerNotFound,
@@ -375,6 +376,7 @@ async fn create_empty_user_layer(
Ok(ConfigLayerEntry::new(
ConfigLayerSource::User {
file: config_toml.clone(),
profile: None,
},
toml_value,
))
@@ -574,7 +576,7 @@ fn override_message(layer: &ConfigLayerSource) -> String {
dot_codex_folder.display(),
),
ConfigLayerSource::SessionFlags => "Overridden by session flags".to_string(),
ConfigLayerSource::User { file } => {
ConfigLayerSource::User { file, .. } => {
format!("Overridden by user config: {}", file.display())
}
ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => {
@@ -594,7 +596,7 @@ fn compute_override_metadata(
effective: &TomlValue,
segments: &[String],
) -> Option<OverriddenMetadata> {
let user_value = match layers.get_user_layer() {
let user_value = match layers.get_active_user_layer() {
Some(user_layer) => value_at_path(&user_layer.config, segments),
None => return None,
};
@@ -293,7 +293,8 @@ async fn read_includes_origins_and_layers() {
assert_eq!(
layers.get(1).unwrap().name,
ConfigLayerSource::User {
file: user_file.clone()
file: user_file.clone(),
profile: None,
}
);
assert!(matches!(
@@ -454,6 +455,80 @@ async fn write_value_defaults_to_user_config_path() {
);
}
#[tokio::test]
async fn write_value_defaults_to_selected_user_config_path() {
let tmp = tempdir().expect("tempdir");
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"gpt-main\"").unwrap();
let selected_path = tmp.path().join("work.config.toml");
std::fs::write(&selected_path, "").unwrap();
let mut loader_overrides =
LoaderOverrides::with_managed_config_path_for_tests(tmp.path().join("managed_config.toml"));
loader_overrides.user_config_path =
Some(AbsolutePathBuf::from_absolute_path(&selected_path).expect("selected config path"));
loader_overrides.user_config_profile = Some("work".parse().expect("profile-v2 name"));
let service = ConfigManager::new_for_tests(
tmp.path().to_path_buf(),
vec![],
loader_overrides,
CloudRequirementsLoader::default(),
);
service
.write_value(ConfigValueWriteParams {
file_path: None,
key_path: "model".to_string(),
value: serde_json::json!("gpt-work"),
merge_strategy: MergeStrategy::Replace,
expected_version: None,
})
.await
.expect("write succeeds");
assert_eq!(
std::fs::read_to_string(&selected_path).expect("read selected config"),
"model = \"gpt-work\"\n"
);
assert_eq!(
std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read main config"),
"model = \"gpt-main\""
);
}
#[tokio::test]
async fn load_default_config_preserves_selected_user_config_path_after_load_error() {
let tmp = tempdir().expect("tempdir");
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"gpt-main\"").unwrap();
let selected_path = tmp.path().join("work.config.toml");
std::fs::write(&selected_path, "not valid toml").unwrap();
let selected_file =
AbsolutePathBuf::from_absolute_path(&selected_path).expect("selected config path");
let mut loader_overrides =
LoaderOverrides::with_managed_config_path_for_tests(tmp.path().join("managed_config.toml"));
loader_overrides.user_config_path = Some(selected_file.clone());
loader_overrides.user_config_profile = Some("work".parse().expect("profile-v2 name"));
let service = ConfigManager::new_for_tests(
tmp.path().to_path_buf(),
vec![],
loader_overrides,
CloudRequirementsLoader::default(),
);
service
.load_latest_config(/*fallback_cwd*/ None)
.await
.expect_err("selected config should fail to load");
let config = service
.load_default_config()
.await
.expect("default config loads after selected config error");
assert_eq!(
config.config_layer_stack.get_user_config_file(),
Some(&selected_file)
);
}
#[tokio::test]
async fn invalid_user_value_rejected_even_if_overridden_by_managed() {
let tmp = tempdir().expect("tempdir");
@@ -665,7 +740,10 @@ async fn read_reports_managed_overrides_user_and_session_flags() {
assert_eq!(layers.get(1).unwrap().name, ConfigLayerSource::SessionFlags);
assert_eq!(
layers.get(2).unwrap().name,
ConfigLayerSource::User { file: user_file }
ConfigLayerSource::User {
file: user_file,
profile: None
}
);
}
+31 -2
View File
@@ -1,11 +1,40 @@
use std::sync::Arc;
use std::sync::Weak;
use codex_core::NewThread;
use codex_core::StartThreadOptions;
use codex_core::ThreadManager;
use codex_core::config::Config;
use codex_extension_api::AgentSpawnFuture;
use codex_extension_api::AgentSpawner;
use codex_extension_api::ExtensionRegistry;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_protocol::ThreadId;
use codex_protocol::error::CodexErr;
pub(crate) fn thread_extensions() -> Arc<ExtensionRegistry<Config>> {
pub(crate) fn thread_extensions<S>(guardian_agent_spawner: S) -> Arc<ExtensionRegistry<Config>>
where
S: AgentSpawner<StartThreadOptions, Spawned = NewThread, Error = CodexErr> + 'static,
{
let mut builder = ExtensionRegistryBuilder::<Config>::new();
codex_git_attribution::install(&mut builder);
codex_guardian::install(&mut builder, guardian_agent_spawner);
Arc::new(builder.build())
}
pub(crate) fn guardian_agent_spawner(
thread_manager: Weak<ThreadManager>,
) -> impl AgentSpawner<StartThreadOptions, Spawned = NewThread, Error = CodexErr> {
move |forked_from_thread_id: ThreadId,
options: StartThreadOptions|
-> AgentSpawnFuture<'static, NewThread, CodexErr> {
let thread_manager = thread_manager.clone();
Box::pin(async move {
let thread_manager = thread_manager.upgrade().ok_or_else(|| {
CodexErr::UnsupportedOperation("thread manager dropped".to_string())
})?;
thread_manager
.spawn_subagent(forked_from_thread_id, options)
.await
})
}
}
+4
View File
@@ -119,6 +119,8 @@ pub struct InProcessStartArgs {
pub cli_overrides: Vec<(String, TomlValue)>,
/// Loader override knobs used by config API paths.
pub loader_overrides: LoaderOverrides,
/// Whether config API paths should reject unknown config fields.
pub strict_config: bool,
/// Preloaded cloud requirements provider.
pub cloud_requirements: CloudRequirementsLoader,
/// Loader used to fetch typed thread config sources before a thread starts.
@@ -409,6 +411,7 @@ async fn start_uninitialized(args: InProcessStartArgs) -> IoResult<InProcessClie
args.config.codex_home.to_path_buf(),
args.cli_overrides,
args.loader_overrides,
args.strict_config,
args.cloud_requirements,
args.arg0_paths.clone(),
args.thread_config_loader,
@@ -765,6 +768,7 @@ mod tests {
config,
cli_overrides: Vec::new(),
loader_overrides: LoaderOverrides::default(),
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
+48 -38
View File
@@ -8,7 +8,6 @@ use codex_config::RemoteThreadConfigLoader;
use codex_config::ThreadConfigLoader;
use codex_core::config::Config;
use codex_core::resolve_installation_id;
use codex_features::Feature;
use codex_login::AuthManager;
use codex_utils_cli::CliConfigOverrides;
use std::collections::HashMap;
@@ -32,10 +31,12 @@ use crate::transport::ConnectionState;
use crate::transport::OutboundConnectionState;
use crate::transport::RemoteControlStartConfig;
use crate::transport::TransportEvent;
use crate::transport::auth::policy_from_settings;
use crate::transport::route_outgoing_envelope;
use crate::transport::start_control_socket_acceptor;
use crate::transport::start_remote_control;
use crate::transport::start_stdio_connection;
use crate::transport::start_websocket_acceptor;
use codex_analytics::AppServerRpcTransport;
use codex_app_server_protocol::ConfigLayerSource;
use codex_app_server_protocol::ConfigWarningNotification;
@@ -102,6 +103,9 @@ pub use crate::error_code::INPUT_TOO_LARGE_ERROR_CODE;
pub use crate::error_code::INVALID_PARAMS_ERROR_CODE;
pub use crate::transport::AppServerTransport;
pub use crate::transport::app_server_control_socket_path;
pub use crate::transport::auth::AppServerWebsocketAuthArgs;
pub use crate::transport::auth::AppServerWebsocketAuthSettings;
pub use crate::transport::auth::WebsocketAuthCliMode;
const LOG_FORMAT_ENV_VAR: &str = "LOG_FORMAT";
const OTEL_SERVICE_NAME: &str = "codex-app-server";
@@ -123,7 +127,7 @@ fn configured_thread_config_loader(config: &Config) -> Arc<dyn ThreadConfigLoade
/// Control-plane messages from the processor/transport side to the outbound router task.
///
/// `run_main_with_transport` now uses two loops/tasks:
/// `run_main_with_transport_options` uses two loops/tasks:
/// - processor loop: handles incoming JSON-RPC and request dispatch
/// - outbound loop: performs potentially slow writes to per-connection writers
///
@@ -370,15 +374,19 @@ pub async fn run_main(
arg0_paths: Arg0DispatchPaths,
cli_config_overrides: CliConfigOverrides,
loader_overrides: LoaderOverrides,
strict_config: bool,
default_analytics_enabled: bool,
) -> IoResult<()> {
run_main_with_transport(
run_main_with_transport_options(
arg0_paths,
cli_config_overrides,
loader_overrides,
strict_config,
default_analytics_enabled,
AppServerTransport::Stdio,
SessionSource::VSCode,
AppServerWebsocketAuthSettings::default(),
AppServerRuntimeOptions::default(),
)
.await
}
@@ -392,44 +400,28 @@ pub enum PluginStartupTasks {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AppServerRuntimeOptions {
pub plugin_startup_tasks: PluginStartupTasks,
pub remote_control_enabled: bool,
}
impl Default for AppServerRuntimeOptions {
fn default() -> Self {
Self {
plugin_startup_tasks: PluginStartupTasks::Start,
remote_control_enabled: false,
}
}
}
pub async fn run_main_with_transport(
arg0_paths: Arg0DispatchPaths,
cli_config_overrides: CliConfigOverrides,
loader_overrides: LoaderOverrides,
default_analytics_enabled: bool,
transport: AppServerTransport,
session_source: SessionSource,
) -> IoResult<()> {
run_main_with_transport_options(
arg0_paths,
cli_config_overrides,
loader_overrides,
default_analytics_enabled,
transport,
session_source,
AppServerRuntimeOptions::default(),
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn run_main_with_transport_options(
arg0_paths: Arg0DispatchPaths,
cli_config_overrides: CliConfigOverrides,
loader_overrides: LoaderOverrides,
strict_config: bool,
default_analytics_enabled: bool,
transport: AppServerTransport,
session_source: SessionSource,
auth: AppServerWebsocketAuthSettings,
runtime_options: AppServerRuntimeOptions,
) -> IoResult<()> {
let (transport_event_tx, mut transport_event_rx) =
@@ -462,6 +454,7 @@ pub async fn run_main_with_transport_options(
codex_home.to_path_buf(),
cli_kv_overrides.clone(),
loader_overrides,
strict_config,
Default::default(),
arg0_paths.clone(),
Arc::new(NoopThreadConfigLoader),
@@ -490,6 +483,10 @@ pub async fn run_main_with_transport_options(
{
Ok(config) => (config, true),
Err(err) => {
if strict_config {
return Err(err);
}
let message = config_warning_from_error("Invalid configuration; using defaults.", &err);
config_warnings.push(message);
(
@@ -518,9 +515,16 @@ pub async fn run_main_with_transport_options(
})?;
codex_core::otel_init::record_process_start(otel.as_ref(), OTEL_SERVICE_NAME);
codex_core::otel_init::install_sqlite_telemetry(otel.as_ref(), OTEL_SERVICE_NAME);
let state_db_result = rollout_state_db::try_init(&config).await;
let state_db_init_error = state_db_result.as_ref().err().map(ToString::to_string);
let state_db = state_db_result.ok();
let state_db = match rollout_state_db::try_init(&config).await {
Ok(state_db) => Some(state_db),
Err(err) => {
let state_db_path = codex_state::state_db_path(config.sqlite_home.as_path());
return Err(std::io::Error::other(format!(
"failed to initialize sqlite state db at {}: {err}",
state_db_path.display()
)));
}
};
if should_run_personality_migration {
let effective_toml = config.config_layer_stack.effective_config();
@@ -637,10 +641,6 @@ pub async fn run_main_with_transport_options(
}
}
let installation_id = resolve_installation_id(&config.codex_home).await?;
if let Some(err) = &state_db_init_error {
error!("failed to initialize sqlite state db: {err}");
}
let transport_shutdown_token = CancellationToken::new();
let mut transport_accept_handles = Vec::<JoinHandle<()>>::new();
@@ -669,21 +669,31 @@ pub async fn run_main_with_transport_options(
.await?;
transport_accept_handles.push(accept_handle);
}
AppServerTransport::WebSocket { bind_address } => {
let accept_handle = start_websocket_acceptor(
*bind_address,
transport_event_tx.clone(),
transport_shutdown_token.clone(),
policy_from_settings(&auth)?,
)
.await?;
transport_accept_handles.push(accept_handle);
}
AppServerTransport::Off => {}
}
let auth_manager =
AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await;
let remote_control_config_enabled = config.features.enabled(Feature::RemoteControl);
let remote_control_enabled = remote_control_config_enabled && state_db.is_some();
if remote_control_config_enabled && state_db.is_none() {
let remote_control_requested = runtime_options.remote_control_enabled;
let remote_control_enabled = remote_control_requested && state_db.is_some();
if remote_control_requested && state_db.is_none() {
error!("remote control disabled because sqlite state db is unavailable");
}
if transport_accept_handles.is_empty() && !remote_control_enabled {
return Err(std::io::Error::new(
ErrorKind::InvalidInput,
if remote_control_config_enabled && state_db.is_none() {
if remote_control_requested && state_db.is_none() {
"no transport configured; remote control disabled because sqlite state db is unavailable"
} else {
"no transport configured; use --listen or enable remote control"
@@ -740,7 +750,7 @@ pub async fn run_main_with_transport_options(
}
OutboundControlEvent::DisconnectAll => {
info!(
"disconnecting {} outbound connection(s) for graceful restart",
"disconnecting {} outbound websocket connection(s) for graceful restart",
outbound_connections.len()
);
for connection_state in outbound_connections.values() {
@@ -1068,9 +1078,9 @@ pub async fn run_main_with_transport_options(
fn analytics_rpc_transport(transport: &AppServerTransport) -> AppServerRpcTransport {
match transport {
AppServerTransport::Stdio => AppServerRpcTransport::Stdio,
AppServerTransport::UnixSocket { .. } | AppServerTransport::Off => {
AppServerRpcTransport::Websocket
}
AppServerTransport::UnixSocket { .. }
| AppServerTransport::WebSocket { .. }
| AppServerTransport::Off => AppServerRpcTransport::Websocket,
}
}
+28 -5
View File
@@ -1,6 +1,7 @@
use clap::Parser;
use codex_app_server::AppServerRuntimeOptions;
use codex_app_server::AppServerTransport;
use codex_app_server::AppServerWebsocketAuthArgs;
use codex_app_server::PluginStartupTasks;
use codex_app_server::run_main_with_transport_options;
use codex_arg0::Arg0DispatchPaths;
@@ -18,7 +19,7 @@ const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_C
#[derive(Debug, Parser)]
struct AppServerArgs {
/// Transport endpoint URL. Supported values: `stdio://` (default),
/// `unix://`, `unix://PATH`, `off`.
/// `unix://`, `unix://PATH`, `ws://IP:PORT`, `off`.
#[arg(
long = "listen",
value_name = "URL",
@@ -35,16 +36,35 @@ struct AppServerArgs {
)]
session_source: SessionSource,
#[command(flatten)]
auth: AppServerWebsocketAuthArgs,
/// Fail if config.toml contains unknown configuration fields.
#[arg(long = "strict-config", default_value_t = false)]
strict_config: bool,
/// Hidden debug-only test hook used by integration tests that spawn the
/// production app-server binary.
#[cfg(debug_assertions)]
#[arg(long = "disable-plugin-startup-tasks-for-tests", hide = true)]
disable_plugin_startup_tasks_for_tests: bool,
/// Enable remote control for this app-server process.
#[arg(long = "remote-control", hide = true)]
remote_control: bool,
}
fn main() -> anyhow::Result<()> {
arg0_dispatch_or_else(|arg0_paths: Arg0DispatchPaths| async move {
let args = AppServerArgs::parse();
let AppServerArgs {
listen,
session_source,
auth,
strict_config,
#[cfg(debug_assertions)]
disable_plugin_startup_tasks_for_tests,
remote_control,
} = AppServerArgs::parse();
let loader_overrides = if disable_managed_config_from_debug_env() {
LoaderOverrides::without_managed_config_for_tests()
} else {
@@ -52,21 +72,24 @@ fn main() -> anyhow::Result<()> {
.map(LoaderOverrides::with_managed_config_path_for_tests)
.unwrap_or_default()
};
let transport = args.listen;
let session_source = args.session_source;
let transport = listen;
let auth = auth.try_into_settings()?;
let mut runtime_options = AppServerRuntimeOptions::default();
#[cfg(debug_assertions)]
if args.disable_plugin_startup_tasks_for_tests {
if disable_plugin_startup_tasks_for_tests {
runtime_options.plugin_startup_tasks = PluginStartupTasks::Skip;
}
runtime_options.remote_control_enabled = remote_control;
run_main_with_transport_options(
arg0_paths,
CliConfigOverrides::default(),
loader_overrides,
strict_config,
/*default_analytics_enabled*/ false,
transport,
session_source,
auth,
runtime_options,
)
.await?;
+16 -12
View File
@@ -99,6 +99,7 @@ async fn queue_refresh(
#[cfg(test)]
mod tests {
use super::*;
use crate::extensions::guardian_agent_spawner;
use crate::extensions::thread_extensions;
use async_trait::async_trait;
use codex_arg0::Arg0DispatchPaths;
@@ -179,18 +180,20 @@ mod tests {
.await
.expect("refresh tests require state db");
let thread_store = thread_store_from_config(&good_config, Some(state_db.clone()));
let thread_manager = Arc::new(ThreadManager::new(
&good_config,
auth_manager,
SessionSource::Exec,
Arc::new(EnvironmentManager::default_for_tests()),
thread_extensions(),
/*analytics_events_client*/ None,
thread_store,
Some(state_db.clone()),
"11111111-1111-4111-8111-111111111111".to_string(),
/*attestation_provider*/ None,
));
let thread_manager = Arc::new_cyclic(|thread_manager| {
ThreadManager::new(
&good_config,
auth_manager,
SessionSource::Exec,
Arc::new(EnvironmentManager::default_for_tests()),
thread_extensions(guardian_agent_spawner(thread_manager.clone())),
/*analytics_events_client*/ None,
thread_store,
Some(state_db.clone()),
"11111111-1111-4111-8111-111111111111".to_string(),
/*attestation_provider*/ None,
)
});
thread_manager.start_thread(good_config).await?;
thread_manager.start_thread(bad_config).await?;
@@ -204,6 +207,7 @@ mod tests {
temp_dir.path().to_path_buf(),
Vec::new(),
LoaderOverrides::without_managed_config_for_tests(),
/*strict_config*/ false,
CloudRequirementsLoader::default(),
Arg0DispatchPaths::default(),
loader.clone(),
+33 -16
View File
@@ -8,6 +8,7 @@ use crate::attestation::app_server_attestation_provider;
use crate::config_manager::ConfigManager;
use crate::connection_rpc_gate::ConnectionRpcGate;
use crate::error_code::invalid_request;
use crate::extensions::guardian_agent_spawner;
use crate::extensions::thread_extensions;
use crate::fs_watch::FsWatchManager;
use crate::outgoing_message::ConnectionId;
@@ -29,6 +30,7 @@ use crate::request_processors::MarketplaceRequestProcessor;
use crate::request_processors::McpRequestProcessor;
use crate::request_processors::PluginRequestProcessor;
use crate::request_processors::ProcessExecRequestProcessor;
use crate::request_processors::RemoteControlRequestProcessor;
use crate::request_processors::SearchRequestProcessor;
use crate::request_processors::ThreadGoalRequestProcessor;
use crate::request_processors::ThreadRequestProcessor;
@@ -172,6 +174,7 @@ pub(crate) struct MessageProcessor {
marketplace_processor: MarketplaceRequestProcessor,
mcp_processor: McpRequestProcessor,
plugin_processor: PluginRequestProcessor,
remote_control_processor: RemoteControlRequestProcessor,
search_processor: SearchRequestProcessor,
thread_goal_processor: ThreadGoalRequestProcessor,
thread_processor: ThreadRequestProcessor,
@@ -298,21 +301,23 @@ impl MessageProcessor {
// affect per-thread behavior, but they must not move newly started,
// resumed, or forked threads to a different persistence backend/root.
let thread_store = codex_core::thread_store_from_config(config.as_ref(), state_db.clone());
let thread_manager = Arc::new(ThreadManager::new(
config.as_ref(),
auth_manager.clone(),
session_source,
environment_manager,
thread_extensions(),
Some(analytics_events_client.clone()),
Arc::clone(&thread_store),
state_db.clone(),
installation_id,
Some(app_server_attestation_provider(
outgoing.clone(),
thread_state_manager.clone(),
)),
));
let thread_manager = Arc::new_cyclic(|thread_manager| {
ThreadManager::new(
config.as_ref(),
auth_manager.clone(),
session_source,
environment_manager,
thread_extensions(guardian_agent_spawner(thread_manager.clone())),
Some(analytics_events_client.clone()),
Arc::clone(&thread_store),
state_db.clone(),
installation_id,
Some(app_server_attestation_provider(
outgoing.clone(),
thread_state_manager.clone(),
)),
)
});
thread_manager
.plugins_manager()
.set_analytics_events_client(analytics_events_client.clone());
@@ -386,6 +391,7 @@ impl MessageProcessor {
config_manager.clone(),
workspace_settings_cache,
);
let remote_control_processor = RemoteControlRequestProcessor::new(remote_control_handle);
let search_processor = SearchRequestProcessor::new(outgoing.clone());
let thread_goal_processor = ThreadGoalRequestProcessor::new(
Arc::clone(&thread_manager),
@@ -443,7 +449,6 @@ impl MessageProcessor {
auth_manager,
thread_manager.clone(),
analytics_events_client,
remote_control_handle,
);
let external_agent_config_processor = ExternalAgentConfigRequestProcessor::new(
outgoing.clone(),
@@ -485,6 +490,7 @@ impl MessageProcessor {
marketplace_processor,
mcp_processor,
plugin_processor,
remote_control_processor,
search_processor,
thread_goal_processor,
thread_processor,
@@ -883,6 +889,14 @@ impl MessageProcessor {
.experimental_feature_enablement_set(request_id.clone(), params)
.await
}
ClientRequest::RemoteControlEnable { .. } => self
.remote_control_processor
.enable()
.map(|response| Some(response.into())),
ClientRequest::RemoteControlDisable { .. } => self
.remote_control_processor
.disable()
.map(|response| Some(response.into())),
ClientRequest::ConfigRequirementsRead { params: _, .. } => self
.config_processor
.config_requirements_read()
@@ -1103,6 +1117,9 @@ impl MessageProcessor {
ClientRequest::PluginShareList { params, .. } => {
self.plugin_processor.plugin_share_list(params).await
}
ClientRequest::PluginShareCheckout { params, .. } => {
self.plugin_processor.plugin_share_checkout(params).await
}
ClientRequest::PluginShareDelete { params, .. } => {
self.plugin_processor.plugin_share_delete(params).await
}
@@ -239,6 +239,7 @@ async fn build_test_processor(
config.codex_home.to_path_buf(),
Vec::new(),
LoaderOverrides::default(),
/*strict_config*/ false,
CloudRequirementsLoader::default(),
Arg0DispatchPaths::default(),
Arc::new(codex_config::NoopThreadConfigLoader),
+45 -14
View File
@@ -13,9 +13,11 @@ use codex_app_server_protocol::Result;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ServerRequestPayload;
use codex_app_server_protocol::ServerResponse;
use codex_otel::span_w3c_trace_context;
use codex_protocol::ThreadId;
use codex_protocol::protocol::W3cTraceContext;
use codex_protocol::request_permissions::RequestPermissionsResponse;
use tokio::sync::Mutex;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
@@ -141,6 +143,20 @@ impl ThreadScopedOutgoingMessageSender {
.await
}
pub(crate) fn track_effective_permissions_approval_response(
&self,
request_id: RequestId,
response: RequestPermissionsResponse,
) {
self.outgoing
.analytics_events_client
.track_effective_permissions_approval_response(
now_unix_timestamp_ms(),
request_id,
response,
);
}
pub(crate) async fn send_server_notification(&self, notification: ServerNotification) {
self.outgoing
.analytics_events_client
@@ -360,7 +376,9 @@ impl OutgoingMessageSender {
match entry {
Some((id, entry)) => {
let completed_at_ms = now_unix_timestamp_ms();
if let Ok(response) = entry.request.response_from_result(result.clone()) {
if let Ok(response) = entry.request.response_from_result(result.clone())
&& !matches!(response, ServerResponse::PermissionsRequestApproval { .. })
{
self.analytics_events_client
.track_server_response(completed_at_ms, response);
}
@@ -380,6 +398,8 @@ impl OutgoingMessageSender {
match entry {
Some((id, entry)) => {
warn!("client responded with error for {id:?}: {error:?}");
self.analytics_events_client
.track_server_request_aborted(now_unix_timestamp_ms(), id.clone());
if let Err(err) = entry.callback.send(Err(error)) {
warn!("could not notify callback for {id:?} due to: {err:?}");
}
@@ -391,7 +411,14 @@ impl OutgoingMessageSender {
}
pub(crate) async fn cancel_request(&self, id: &RequestId) -> bool {
self.take_request_callback(id).await.is_some()
let entry = self.take_request_callback(id).await;
if let Some((request_id, _entry)) = entry {
self.analytics_events_client
.track_server_request_aborted(now_unix_timestamp_ms(), request_id);
true
} else {
false
}
}
pub(crate) async fn cancel_all_requests(&self, error: Option<JSONRPCErrorError>) {
@@ -403,12 +430,14 @@ impl OutgoingMessageSender {
.collect::<Vec<_>>()
};
if let Some(error) = error {
for entry in entries {
if let Err(err) = entry.callback.send(Err(error.clone())) {
let request_id = entry.request.id();
warn!("could not notify callback for {request_id:?} due to: {err:?}");
}
for entry in entries {
self.analytics_events_client
.track_server_request_aborted(now_unix_timestamp_ms(), entry.request.id().clone());
if let Some(error) = error.as_ref()
&& let Err(err) = entry.callback.send(Err(error.clone()))
{
let request_id = entry.request.id();
warn!("could not notify callback for {request_id:?} due to: {err:?}");
}
}
}
@@ -459,12 +488,14 @@ impl OutgoingMessageSender {
entries
};
if let Some(error) = error {
for entry in entries {
if let Err(err) = entry.callback.send(Err(error.clone())) {
let request_id = entry.request.id();
warn!("could not notify callback for {request_id:?} due to: {err:?}",);
}
for entry in entries {
self.analytics_events_client
.track_server_request_aborted(now_unix_timestamp_ms(), entry.request.id().clone());
if let Some(error) = error.as_ref()
&& let Err(err) = entry.callback.send(Err(error.clone()))
{
let request_id = entry.request.id();
warn!("could not notify callback for {request_id:?} due to: {err:?}",);
}
}
}
@@ -115,6 +115,8 @@ use codex_app_server_protocol::PluginListResponse;
use codex_app_server_protocol::PluginMarketplaceEntry;
use codex_app_server_protocol::PluginReadParams;
use codex_app_server_protocol::PluginReadResponse;
use codex_app_server_protocol::PluginShareCheckoutParams;
use codex_app_server_protocol::PluginShareCheckoutResponse;
use codex_app_server_protocol::PluginShareContext;
use codex_app_server_protocol::PluginShareDeleteParams;
use codex_app_server_protocol::PluginShareDeleteResponse;
@@ -410,7 +412,6 @@ use codex_thread_store::ThreadMetadataPatch as StoreThreadMetadataPatch;
use codex_thread_store::ThreadSortKey as StoreThreadSortKey;
use codex_thread_store::ThreadStore;
use codex_thread_store::ThreadStoreError;
use codex_thread_store::UpdateThreadMetadataParams as StoreUpdateThreadMetadataParams;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP;
use std::collections::HashMap;
@@ -447,6 +448,7 @@ mod command_exec_processor;
mod config_processor;
mod environment_processor;
mod external_agent_config_processor;
mod feedback_doctor_report;
mod feedback_processor;
mod fs_processor;
mod git_processor;
@@ -455,6 +457,7 @@ mod marketplace_processor;
mod mcp_processor;
mod plugins;
mod process_exec_processor;
mod remote_control_processor;
mod search;
mod thread_processor;
mod token_usage_replay;
@@ -476,6 +479,7 @@ pub(crate) use marketplace_processor::MarketplaceRequestProcessor;
pub(crate) use mcp_processor::McpRequestProcessor;
pub(crate) use plugins::PluginRequestProcessor;
pub(crate) use process_exec_processor::ProcessExecRequestProcessor;
pub(crate) use remote_control_processor::RemoteControlRequestProcessor;
pub(crate) use search::SearchRequestProcessor;
pub(crate) use thread_goal_processor::ThreadGoalRequestProcessor;
pub(crate) use thread_processor::ThreadRequestProcessor;
@@ -2,6 +2,8 @@ use super::*;
// Duration before a browser ChatGPT login attempt is abandoned.
const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60);
// The override is intentionally available only in debug builds, matching the login path below.
#[cfg(debug_assertions)]
const LOGIN_ISSUER_OVERRIDE_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER";
enum ActiveLogin {
@@ -568,11 +570,11 @@ impl AccountRequestProcessor {
}
}
if let Some(expected_workspace) = self.config.forced_chatgpt_workspace_id.as_deref()
&& chatgpt_account_id != expected_workspace
if let Some(expected_workspaces) = self.config.forced_chatgpt_workspace_id.as_deref()
&& !expected_workspaces.contains(&chatgpt_account_id)
{
return Err(invalid_request(format!(
"External auth must use workspace {expected_workspace}, but received {chatgpt_account_id:?}."
"External auth must use one of workspace(s) {expected_workspaces:?}, but received {chatgpt_account_id:?}.",
)));
}
@@ -525,6 +525,7 @@ impl CatalogRequestProcessor {
};
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(),
@@ -6,7 +6,6 @@ use crate::error_code::internal_error;
use crate::error_code::invalid_request;
use crate::outgoing_message::ConnectionRequestId;
use crate::outgoing_message::OutgoingMessageSender;
use crate::transport::RemoteControlHandle;
use codex_analytics::AnalyticsEventsClient;
use codex_app_server_protocol::AppListUpdatedNotification;
use codex_app_server_protocol::ClientResponsePayload;
@@ -39,7 +38,6 @@ use codex_config::MatcherGroup as CoreMatcherGroup;
use codex_config::ResidencyRequirement as CoreResidencyRequirement;
use codex_config::SandboxModeRequirement as CoreSandboxModeRequirement;
use codex_core::ThreadManager;
use codex_features::Feature;
use codex_features::canonical_feature_for_key;
use codex_features::feature_for_key;
use codex_login::AuthManager;
@@ -67,7 +65,6 @@ pub(crate) struct ConfigRequestProcessor {
auth_manager: Arc<AuthManager>,
thread_manager: Arc<ThreadManager>,
analytics_events_client: AnalyticsEventsClient,
remote_control_handle: Option<RemoteControlHandle>,
}
impl ConfigRequestProcessor {
@@ -77,7 +74,6 @@ impl ConfigRequestProcessor {
auth_manager: Arc<AuthManager>,
thread_manager: Arc<ThreadManager>,
analytics_events_client: AnalyticsEventsClient,
remote_control_handle: Option<RemoteControlHandle>,
) -> Self {
Self {
outgoing,
@@ -85,7 +81,6 @@ impl ConfigRequestProcessor {
auth_manager,
thread_manager,
analytics_events_client,
remote_control_handle,
}
}
@@ -187,21 +182,6 @@ impl ConfigRequestProcessor {
pub(crate) async fn handle_config_mutation(&self) {
self.thread_manager.plugins_manager().clear_cache();
self.thread_manager.skills_manager().clear_cache();
let Some(remote_control_handle) = &self.remote_control_handle else {
return;
};
match self.load_latest_config(/*fallback_cwd*/ None).await {
Ok(config) => {
remote_control_handle.set_enabled(config.features.enabled(Feature::RemoteControl));
}
Err(error) => {
tracing::warn!(
"failed to load config for remote control enablement refresh after config mutation: {}",
error.message
);
}
}
}
async fn handle_config_mutation_result<T>(
@@ -449,6 +429,7 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR
}
normalized
}),
allow_managed_hooks_only: requirements.allow_managed_hooks_only,
feature_requirements: requirements
.feature_requirements
.map(|requirements| requirements.entries),
@@ -515,11 +496,13 @@ fn map_hook_handler_to_api(handler: CoreHookHandlerConfig) -> ConfiguredHookHand
match handler {
CoreHookHandlerConfig::Command {
command,
command_windows,
timeout_sec,
r#async,
status_message,
} => ConfiguredHookHandler::Command {
command,
command_windows,
timeout_sec,
r#async,
status_message,
@@ -629,3 +612,21 @@ fn config_write_error(code: ConfigWriteErrorCode, message: impl Into<String>) ->
}));
error
}
#[cfg(test)]
mod tests {
use super::map_requirements_toml_to_api;
use codex_config::ConfigRequirementsToml;
use pretty_assertions::assert_eq;
#[test]
fn requirements_api_includes_allow_managed_hooks_only() {
let mapped = map_requirements_toml_to_api(ConfigRequirementsToml {
allow_managed_hooks_only: Some(true),
..ConfigRequirementsToml::default()
});
assert_eq!(mapped.allow_managed_hooks_only, Some(true));
assert_eq!(mapped.hooks, None);
}
}
@@ -323,7 +323,7 @@ impl ExternalAgentConfigRequestProcessor {
.thread
.update_thread_metadata(
ThreadMetadataPatch {
name: Some(name),
name: Some(Some(name)),
..Default::default()
},
/*include_archived*/ false,
@@ -0,0 +1,212 @@
//! Builds a redacted doctor report attachment for feedback uploads.
//!
//! Feedback upload should never depend on doctor succeeding. This module runs
//! the configured Codex executable as a subprocess, accepts only valid JSON from
//! `codex doctor --json`, derives a small set of Sentry tags, and otherwise
//! skips the attachment with a warning. Keeping the report generation out of the
//! app-server process avoids sharing doctor internals across crates while still
//! attaching exactly the same JSON a user could copy from the CLI.
use std::collections::BTreeMap;
use std::time::Duration;
use codex_core::config::Config;
use codex_feedback::DOCTOR_REPORT_ATTACHMENT_FILENAME;
use codex_feedback::FeedbackAttachment;
use serde_json::Value;
use tokio::process::Command;
use tokio::time::timeout;
use tracing::warn;
const DOCTOR_FEEDBACK_REPORT_TIMEOUT: Duration = Duration::from_secs(25);
const MAX_DOCTOR_TAG_VALUE_LEN: usize = 256;
/// Redacted doctor report data that can be merged into a feedback upload.
pub(crate) struct DoctorFeedbackReport {
/// JSON support report to upload as `codex-doctor-report.json`.
pub(crate) attachment: FeedbackAttachment,
/// Low-cardinality Sentry tags derived from the report status and check ids.
pub(crate) tags: BTreeMap<String, String>,
}
/// Runs `codex doctor --json` and returns a best-effort feedback attachment.
///
/// Failure to spawn Codex, finish before the timeout, or parse JSON means the
/// feedback upload proceeds without the doctor report. Callers should merge the
/// returned tags without overriding explicit client-provided tags.
pub(crate) async fn doctor_feedback_report(config: &Config) -> Option<DoctorFeedbackReport> {
let executable = config
.codex_self_exe
.clone()
.or_else(|| std::env::current_exe().ok())?;
let mut command = Command::new(&executable);
command.arg("doctor").arg("--json");
command.kill_on_drop(/*kill_on_drop*/ true);
let output = match timeout(DOCTOR_FEEDBACK_REPORT_TIMEOUT, command.output()).await {
Ok(Ok(output)) => output,
Ok(Err(err)) => {
warn!(
executable = %executable.display(),
error = %err,
"failed to run doctor report for feedback; skipping attachment"
);
return None;
}
Err(_) => {
warn!(
executable = %executable.display(),
"timed out running doctor report for feedback; skipping attachment"
);
return None;
}
};
let stdout = String::from_utf8_lossy(&output.stdout);
let Some(json_start) = stdout.find('{') else {
warn!(
executable = %executable.display(),
status = %output.status,
stderr = %String::from_utf8_lossy(&output.stderr),
"doctor report for feedback did not produce JSON; skipping attachment"
);
return None;
};
let json = stdout[json_start..].trim();
let report: Value = match serde_json::from_str(json) {
Ok(report) => report,
Err(err) => {
warn!(
executable = %executable.display(),
status = %output.status,
error = %err,
"doctor report for feedback was not valid JSON; skipping attachment"
);
return None;
}
};
let pretty = serde_json::to_vec_pretty(&report).unwrap_or_else(|_| json.as_bytes().to_vec());
Some(DoctorFeedbackReport {
tags: doctor_report_tags(&report),
attachment: FeedbackAttachment {
filename: DOCTOR_REPORT_ATTACHMENT_FILENAME.to_string(),
content_type: Some("application/json".to_string()),
buffer: pretty,
},
})
}
fn doctor_report_tags(report: &Value) -> BTreeMap<String, String> {
let mut tags = BTreeMap::new();
if let Some(overall_status) = report.get("overallStatus").and_then(Value::as_str) {
tags.insert(
"doctor_overall_status".to_string(),
truncate_tag_value(overall_status),
);
}
let mut ok_count = 0usize;
let mut warning_count = 0usize;
let mut fail_count = 0usize;
let mut failed_checks = Vec::new();
let mut warning_checks = Vec::new();
if let Some(checks) = report.get("checks") {
for check in check_values(checks) {
let status = check.get("status").and_then(Value::as_str);
let id = check.get("id").and_then(Value::as_str).unwrap_or("unknown");
match status {
Some("ok") => ok_count += 1,
Some("warning") => {
warning_count += 1;
warning_checks.push(id.to_string());
}
Some("fail") => {
fail_count += 1;
failed_checks.push(id.to_string());
}
_ => {}
}
}
}
tags.insert("doctor_ok_count".to_string(), ok_count.to_string());
tags.insert(
"doctor_warning_count".to_string(),
warning_count.to_string(),
);
tags.insert("doctor_fail_count".to_string(), fail_count.to_string());
if !failed_checks.is_empty() {
tags.insert(
"doctor_failed_checks".to_string(),
truncate_tag_value(&failed_checks.join(",")),
);
}
if !warning_checks.is_empty() {
tags.insert(
"doctor_warning_checks".to_string(),
truncate_tag_value(&warning_checks.join(",")),
);
}
tags
}
/// Iterates checks from both the current keyed JSON shape and older array reports.
fn check_values(checks: &Value) -> Box<dyn Iterator<Item = &Value> + '_> {
match checks {
Value::Array(values) => Box::new(values.iter()),
Value::Object(values) => Box::new(values.values()),
_ => Box::new(std::iter::empty()),
}
}
fn truncate_tag_value(value: &str) -> String {
if value.chars().count() <= MAX_DOCTOR_TAG_VALUE_LEN {
return value.to_string();
}
let prefix = value
.chars()
.take(MAX_DOCTOR_TAG_VALUE_LEN.saturating_sub(3))
.collect::<String>();
format!("{prefix}...")
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use serde_json::json;
#[test]
fn doctor_report_tags_summarize_status_counts() {
let report = json!({
"overallStatus": "fail",
"checks": {
"runtime.provenance": {"id": "runtime.provenance", "status": "ok"},
"websocket.reachability": {
"id": "websocket.reachability",
"status": "warning"
},
"auth.credentials": {"id": "auth.credentials", "status": "fail"}
}
});
let tags = doctor_report_tags(&report);
let expected = BTreeMap::from([
("doctor_fail_count".to_string(), "1".to_string()),
(
"doctor_failed_checks".to_string(),
"auth.credentials".to_string(),
),
("doctor_ok_count".to_string(), "1".to_string()),
("doctor_overall_status".to_string(), "fail".to_string()),
(
"doctor_warning_checks".to_string(),
"websocket.reachability".to_string(),
),
("doctor_warning_count".to_string(), "1".to_string()),
]);
assert_eq!(tags, expected);
}
}
@@ -56,6 +56,7 @@ impl FeedbackRequestProcessor {
extra_log_files,
tags,
} = params;
let mut upload_tags = tags.unwrap_or_default();
let conversation_id = match thread_id.as_deref() {
Some(thread_id) => match ThreadId::from_string(thread_id) {
@@ -197,14 +198,27 @@ impl FeedbackRequestProcessor {
}
}
let mut extra_attachments = Vec::new();
if include_logs
&& let Some(doctor_report) =
super::feedback_doctor_report::doctor_feedback_report(&self.config).await
{
extra_attachments.push(doctor_report.attachment);
for (key, value) in doctor_report.tags {
upload_tags.entry(key).or_insert(value);
}
}
let session_source = self.thread_manager.session_source();
let upload_result = tokio::task::spawn_blocking(move || {
let tags = (!upload_tags.is_empty()).then_some(&upload_tags);
snapshot.upload_feedback(FeedbackUploadOptions {
classification: &classification,
reason: reason.as_deref(),
tags: tags.as_ref(),
tags,
include_logs,
extra_attachments: &extra_attachments,
extra_attachment_paths: &attachment_paths,
session_source: Some(session_source),
logs_override: sqlite_feedback_logs,
@@ -13,7 +13,7 @@ use super::*;
use crate::message_processor::ConnectionSessionState;
use crate::message_processor::InitializedConnectionSessionState;
const DAEMON_PROBE_CLIENT_NAME: &str = "codex_app_server_daemon";
const NON_ORIGINATING_CLIENT_NAMES: &[&str] = &["codex_app_server_daemon", "codex-backend"];
#[derive(Clone)]
pub(crate) struct InitializeRequestProcessor {
@@ -92,7 +92,7 @@ impl InitializeRequestProcessor {
}
let originator = name.clone();
let user_agent_suffix = format!("{name}; {version}");
let mutates_global_identity = name != DAEMON_PROBE_CLIENT_NAME;
let mutates_global_identity = !NON_ORIGINATING_CLIENT_NAMES.contains(&name.as_str());
let codex_home = self.config.codex_home.clone();
if session
.initialize(InitializedConnectionSessionState {
@@ -160,6 +160,7 @@ impl McpRequestProcessor {
http_headers,
env_http_headers,
&resolved_scopes.scopes,
server.oauth_client_id(),
server.oauth_resource.as_deref(),
timeout_secs,
config.mcp_oauth_callback_port,
@@ -111,6 +111,7 @@ fn share_context_for_source(
.cloned()
.map(|remote_plugin_id| PluginShareContext {
remote_plugin_id,
remote_version: None,
discoverability: None,
share_url: None,
creator_account_user_id: None,
@@ -312,6 +313,15 @@ impl PluginRequestProcessor {
.map(|response| Some(response.into()))
}
pub(crate) async fn plugin_share_checkout(
&self,
params: PluginShareCheckoutParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.plugin_share_checkout_response(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn plugin_share_delete(
&self,
params: PluginShareDeleteParams,
@@ -440,14 +450,15 @@ impl PluginRequestProcessor {
return Ok(empty_response());
}
let plugins_input = config.plugins_config_input();
let (mut data, marketplace_load_errors) = if include_local {
if include_local || marketplace_kinds.contains(&PluginListMarketplaceKind::SharedWithMe) {
plugins_manager.maybe_start_plugin_list_background_tasks_for_config(
&plugins_input,
auth.clone(),
&roots,
Some(self.effective_plugins_changed_callback()),
);
}
let (mut data, marketplace_load_errors) = if include_local {
let config_for_marketplace_listing = plugins_input.clone();
let plugins_manager_for_marketplace_listing = plugins_manager.clone();
let shared_plugin_ids_by_local_path = load_shared_plugin_ids_by_local_path(&config)?;
@@ -482,6 +493,8 @@ impl PluginRequestProcessor {
);
PluginSummary {
id: plugin.id,
remote_plugin_id: None,
local_version: plugin.local_version,
installed: plugin.installed,
enabled: plugin.enabled,
name: plugin.name,
@@ -532,7 +545,9 @@ impl PluginRequestProcessor {
if marketplace_kinds.contains(&PluginListMarketplaceKind::WorkspaceDirectory) {
remote_sources.push(RemoteMarketplaceSource::WorkspaceDirectory);
}
if marketplace_kinds.contains(&PluginListMarketplaceKind::SharedWithMe) {
if marketplace_kinds.contains(&PluginListMarketplaceKind::SharedWithMe)
&& config.features.enabled(Feature::PluginSharing)
{
remote_sources.push(RemoteMarketplaceSource::SharedWithMe);
}
if !remote_sources.is_empty() {
@@ -672,17 +687,21 @@ impl PluginRequestProcessor {
)
.await
{
Ok(Some(remote_share_context))
if remote_share_context.share_principals.is_some() =>
{
Some(remote_plugin_share_context_to_info(remote_share_context))
}
Ok(Some(_)) => {
warn!(
remote_plugin_id = %context.remote_plugin_id,
"remote shared plugin detail did not include share principals; returning local share mapping context"
);
Some(context)
Ok(Some(remote_share_context)) => {
if remote_share_context.share_principals.is_some() {
Some(remote_plugin_share_context_to_info(remote_share_context))
} else {
let remote_version = remote_share_context.remote_version;
let remote_plugin_id = context.remote_plugin_id.clone();
warn!(
remote_plugin_id = %remote_plugin_id,
"remote shared plugin detail did not include share principals; returning local share mapping context with remote version"
);
Some(PluginShareContext {
remote_version,
..context
})
}
}
Ok(None) => {
warn!(
@@ -723,6 +742,8 @@ impl PluginRequestProcessor {
marketplace_path: outcome.marketplace_path,
summary: PluginSummary {
id: outcome.plugin.id,
remote_plugin_id: None,
local_version: outcome.plugin.local_version,
name: outcome.plugin.name,
share_context,
source: marketplace_plugin_source_to_info(outcome.plugin.source),
@@ -838,6 +859,9 @@ impl PluginRequestProcessor {
params: PluginShareSaveParams,
) -> Result<PluginShareSaveResponse, JSONRPCErrorError> {
let (config, auth) = self.load_plugin_share_config_and_auth().await?;
if !config.features.enabled(Feature::PluginSharing) {
return Err(invalid_request("plugin sharing is disabled"));
}
let PluginShareSaveParams {
plugin_path,
remote_plugin_id,
@@ -893,6 +917,9 @@ impl PluginRequestProcessor {
params: PluginShareUpdateTargetsParams,
) -> Result<PluginShareUpdateTargetsResponse, JSONRPCErrorError> {
let (config, auth) = self.load_plugin_share_config_and_auth().await?;
if !config.features.enabled(Feature::PluginSharing) {
return Err(invalid_request("plugin sharing is disabled"));
}
let PluginShareUpdateTargetsParams {
remote_plugin_id,
discoverability,
@@ -959,6 +986,42 @@ impl PluginRequestProcessor {
Ok(PluginShareListResponse { data })
}
async fn plugin_share_checkout_response(
&self,
params: PluginShareCheckoutParams,
) -> Result<PluginShareCheckoutResponse, JSONRPCErrorError> {
let (config, auth) = self.load_plugin_share_config_and_auth().await?;
if !config.features.enabled(Feature::PluginSharing) {
return Err(invalid_request("plugin sharing is disabled"));
}
let PluginShareCheckoutParams { remote_plugin_id } = params;
if remote_plugin_id.is_empty() || !is_valid_remote_plugin_id(&remote_plugin_id) {
return Err(invalid_request("invalid remote plugin id"));
}
let remote_plugin_service_config = RemotePluginServiceConfig {
chatgpt_base_url: config.chatgpt_base_url.clone(),
};
let result = codex_core_plugins::remote::checkout_remote_plugin_share(
&remote_plugin_service_config,
auth.as_ref(),
config.codex_home.as_path(),
&remote_plugin_id,
)
.await
.map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "checkout plugin share"))?;
self.clear_plugin_related_caches();
Ok(PluginShareCheckoutResponse {
remote_plugin_id: result.remote_plugin_id,
plugin_id: result.plugin_id,
plugin_name: result.plugin_name,
plugin_path: result.plugin_path,
marketplace_name: result.marketplace_name,
marketplace_path: result.marketplace_path,
remote_version: result.remote_version,
})
}
async fn plugin_share_delete_response(
&self,
params: PluginShareDeleteParams,
@@ -1107,7 +1170,6 @@ impl PluginRequestProcessor {
)
})?;
if remote_detail.summary.availability == PluginAvailability::DisabledByAdmin {
let remote_plugin_id = &remote_detail.summary.id;
return Err(invalid_request(format!(
"remote plugin {remote_plugin_id} is disabled by admin"
)));
@@ -1284,6 +1346,7 @@ impl PluginRequestProcessor {
let notification_name = name.clone();
tokio::spawn(async move {
let oauth_client_id = server.oauth_client_id();
let first_attempt = perform_oauth_login_silent(
&name,
&oauth_config.url,
@@ -1291,6 +1354,7 @@ impl PluginRequestProcessor {
oauth_config.http_headers.clone(),
oauth_config.env_http_headers.clone(),
&resolved_scopes.scopes,
oauth_client_id,
server.oauth_resource.as_deref(),
callback_port,
callback_url.as_deref(),
@@ -1306,6 +1370,7 @@ impl PluginRequestProcessor {
oauth_config.http_headers,
oauth_config.env_http_headers,
&[],
oauth_client_id,
server.oauth_resource.as_deref(),
callback_port,
callback_url.as_deref(),
@@ -1587,6 +1652,8 @@ fn remote_marketplace_to_info(marketplace: RemoteMarketplace) -> PluginMarketpla
fn remote_plugin_summary_to_info(summary: RemoteCatalogPluginSummary) -> PluginSummary {
PluginSummary {
id: summary.id,
remote_plugin_id: Some(summary.remote_plugin_id),
local_version: None,
name: summary.name,
share_context: summary
.share_context
@@ -1607,6 +1674,7 @@ fn remote_plugin_share_context_to_info(
) -> PluginShareContext {
PluginShareContext {
remote_plugin_id: context.remote_plugin_id,
remote_version: context.remote_version,
discoverability: Some(remote_plugin_share_discoverability_to_info(
context.discoverability,
)),
@@ -1678,6 +1746,7 @@ fn remote_plugin_catalog_error_to_jsonrpc(
invalid_request(message)
}
RemotePluginCatalogError::InvalidPluginPath { .. }
| RemotePluginCatalogError::PluginShareCheckoutNotAvailable { .. }
| RemotePluginCatalogError::ArchiveTooLarge { .. }
| RemotePluginCatalogError::UnknownMarketplace { .. } => invalid_request(message),
RemotePluginCatalogError::AuthToken(_)
@@ -0,0 +1,43 @@
use crate::error_code::internal_error;
use crate::error_code::invalid_request;
use crate::transport::RemoteControlHandle;
use crate::transport::RemoteControlUnavailable;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::RemoteControlDisableResponse;
use codex_app_server_protocol::RemoteControlEnableResponse;
#[derive(Clone)]
pub(crate) struct RemoteControlRequestProcessor {
remote_control_handle: Option<RemoteControlHandle>,
}
impl RemoteControlRequestProcessor {
pub(crate) fn new(remote_control_handle: Option<RemoteControlHandle>) -> Self {
Self {
remote_control_handle,
}
}
pub(crate) fn enable(&self) -> Result<RemoteControlEnableResponse, JSONRPCErrorError> {
let handle = self.handle()?;
handle
.enable()
.map(RemoteControlEnableResponse::from)
.map_err(map_unavailable)
}
pub(crate) fn disable(&self) -> Result<RemoteControlDisableResponse, JSONRPCErrorError> {
let handle = self.handle()?;
Ok(RemoteControlDisableResponse::from(handle.disable()))
}
fn handle(&self) -> Result<&RemoteControlHandle, JSONRPCErrorError> {
self.remote_control_handle
.as_ref()
.ok_or_else(|| internal_error("remote control is unavailable for this app-server"))
}
}
fn map_unavailable(err: RemoteControlUnavailable) -> JSONRPCErrorError {
invalid_request(err.to_string())
}
@@ -1,5 +1,7 @@
use super::*;
use crate::error_code::method_not_found;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
const THREAD_LIST_DEFAULT_LIMIT: usize = 25;
const THREAD_LIST_MAX_LIMIT: usize = 100;
@@ -969,6 +971,7 @@ impl ThreadRequestProcessor {
experimental_raw_events: bool,
request_trace: Option<W3cTraceContext>,
) -> Result<(), JSONRPCErrorError> {
let thread_start_started_at = std::time::Instant::now();
let requested_cwd = typesafe_overrides.cwd.clone();
let mut config = config_manager
.load_with_overrides(config_overrides.clone(), typesafe_overrides.clone())
@@ -1063,6 +1066,7 @@ impl ThreadRequestProcessor {
.collect()
};
let core_dynamic_tool_count = core_dynamic_tools.len();
let create_thread_started_at = std::time::Instant::now();
let NewThread {
thread_id,
thread,
@@ -1097,6 +1101,12 @@ impl ThreadRequestProcessor {
CodexErr::InvalidRequest(message) => invalid_request(message),
err => internal_error(format!("error creating thread: {err}")),
})?;
let session_telemetry = thread.session_telemetry();
session_telemetry.record_startup_phase(
"thread_start_create_thread",
create_thread_started_at.elapsed(),
Some("ready"),
);
Self::set_app_server_client_info(
thread.as_ref(),
@@ -1198,6 +1208,11 @@ impl ThreadRequestProcessor {
otel.name = "app_server.thread_start.notify_started",
))
.await;
session_telemetry.record_startup_phase(
"thread_start_total",
thread_start_started_at.elapsed(),
Some("ready"),
);
Ok(())
}
@@ -1426,17 +1441,17 @@ impl ThreadRequestProcessor {
};
let _thread_list_state_permit = self.acquire_thread_list_state_permit().await?;
self.thread_store
.update_thread_metadata(StoreUpdateThreadMetadataParams {
self.thread_manager
.update_thread_metadata(
thread_id,
patch: StoreThreadMetadataPatch {
name: Some(name.clone()),
StoreThreadMetadataPatch {
name: Some(Some(name.clone())),
..Default::default()
},
include_archived: false,
})
/*include_archived*/ false,
)
.await
.map_err(|err| thread_store_write_error("set thread name", err))?;
.map_err(|err| core_thread_write_error("set thread name", err))?;
Ok((
ThreadSetNameResponse {},
@@ -1455,33 +1470,17 @@ impl ThreadRequestProcessor {
let thread_id = ThreadId::from_string(&thread_id)
.map_err(|err| invalid_request(format!("invalid thread id: {err}")))?;
if let Ok(thread) = self.thread_manager.get_thread(thread_id).await {
if thread.config_snapshot().await.ephemeral {
return Err(invalid_request(format!(
"ephemeral thread does not support memory mode updates: {thread_id}"
)));
}
thread
.set_thread_memory_mode(mode.to_core())
.await
.map_err(|err| {
internal_error(format!("failed to set thread memory mode: {err}"))
})?;
return Ok(ThreadMemoryModeSetResponse {});
}
self.thread_store
.update_thread_metadata(StoreUpdateThreadMetadataParams {
self.thread_manager
.update_thread_metadata(
thread_id,
patch: StoreThreadMetadataPatch {
StoreThreadMetadataPatch {
memory_mode: Some(mode.to_core()),
..Default::default()
},
include_archived: false,
})
/*include_archived*/ false,
)
.await
.map_err(|err| thread_store_write_error("set thread memory mode", err))?;
.map_err(|err| core_thread_write_error("set thread memory mode", err))?;
Ok(ThreadMemoryModeSetResponse {})
}
@@ -1547,35 +1546,19 @@ impl ThreadRequestProcessor {
..Default::default()
};
let loaded_thread = self.thread_manager.get_thread(thread_uuid).await.ok();
let updated_thread = {
let _thread_list_state_permit = self.acquire_thread_list_state_permit().await?;
if let Some(loaded_thread) = loaded_thread.as_ref() {
if loaded_thread.config_snapshot().await.ephemeral {
return Err(invalid_request(format!(
"ephemeral thread does not support metadata updates: {thread_id}"
)));
}
loaded_thread
.update_thread_metadata(patch, /*include_archived*/ true)
.await
} else {
self.thread_store
.update_thread_metadata(StoreUpdateThreadMetadataParams {
thread_id: thread_uuid,
patch,
include_archived: true,
})
.await
}
.map_err(|err| thread_store_write_error("update thread metadata", err))?
self.thread_manager
.update_thread_metadata(thread_uuid, patch, /*include_archived*/ true)
.await
.map_err(|err| core_thread_write_error("update thread metadata", err))?
};
let (mut thread, _) = thread_from_stored_thread(
updated_thread,
self.config.model_provider_id.as_str(),
&self.config.cwd,
);
if let Some(loaded_thread) = loaded_thread.as_ref() {
if let Ok(loaded_thread) = self.thread_manager.get_thread(thread_uuid).await {
thread.session_id = loaded_thread.session_configured().session_id.to_string();
}
self.attach_thread_name(thread_uuid, &mut thread).await;
@@ -1603,7 +1586,7 @@ impl ThreadRequestProcessor {
artifact,
})
.await
.map_err(|err| thread_store_write_error("create thread artifact", err))?;
.map_err(|err| thread_store_artifact_error("create thread artifact", err))?;
self.notify_thread_artifacts_updated(thread_id).await?;
Ok(ThreadArtifactCreateResponse {
artifact: thread_artifact_from_stored_artifact(artifact),
@@ -1618,7 +1601,7 @@ impl ThreadRequestProcessor {
.thread_store
.list_thread_artifacts(StoreListThreadArtifactsParams { thread_id })
.await
.map_err(|err| thread_store_write_error("list thread artifacts", err))?
.map_err(|err| thread_store_artifact_error("list thread artifacts", err))?
.into_iter()
.map(thread_artifact_from_stored_artifact)
.collect();
@@ -3761,15 +3744,13 @@ fn conversation_summary_rollout_path_read_error(
}
}
fn thread_store_write_error(operation: &str, err: ThreadStoreError) -> JSONRPCErrorError {
fn core_thread_write_error(operation: &str, err: CodexErr) -> JSONRPCErrorError {
match err {
ThreadStoreError::ThreadNotFound { thread_id } => {
CodexErr::ThreadNotFound(thread_id) => {
invalid_request(format!("thread not found: {thread_id}"))
}
ThreadStoreError::InvalidRequest { message } => invalid_request(message),
ThreadStoreError::Unsupported { operation } => {
unsupported_thread_store_operation(operation)
}
CodexErr::InvalidRequest(message) => invalid_request(message),
CodexErr::UnsupportedOperation(message) => method_not_found(message),
err => internal_error(format!("failed to {operation}: {err}")),
}
}
@@ -3784,6 +3765,19 @@ fn thread_store_archive_error(operation: &str, err: ThreadStoreError) -> JSONRPC
}
}
fn thread_store_artifact_error(operation: &str, err: ThreadStoreError) -> JSONRPCErrorError {
match err {
ThreadStoreError::InvalidRequest { message } => invalid_request(message),
ThreadStoreError::Unsupported {
operation: unsupported_operation,
} => unsupported_thread_store_operation(unsupported_operation),
ThreadStoreError::ThreadNotFound { thread_id } => {
invalid_request(format!("thread not found: {thread_id}"))
}
err => internal_error(format!("failed to {operation}: {err}")),
}
}
fn set_thread_name_from_title(thread: &mut Thread, title: String) {
if title.trim().is_empty() || thread.preview.trim() == title.trim() {
return;
@@ -4024,7 +4018,9 @@ fn requested_permissions_trust_project(overrides: &ConfigOverrides, cwd: &Path)
if matches!(
overrides.default_permissions.as_deref(),
Some(":workspace" | ":danger-no-sandbox")
Some(
BUILT_IN_PERMISSION_PROFILE_WORKSPACE | BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS
)
) {
return true;
}
@@ -62,6 +62,9 @@ mod thread_processor_behavior_tests {
use codex_model_provider_info::ModelProviderInfo;
use codex_model_provider_info::WireApi;
use codex_protocol::ThreadId;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
@@ -499,14 +502,16 @@ mod thread_processor_behavior_tests {
));
assert!(requested_permissions_trust_project(
&ConfigOverrides {
default_permissions: Some(":workspace".to_string()),
default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()),
..Default::default()
},
cwd.as_path()
));
assert!(requested_permissions_trust_project(
&ConfigOverrides {
default_permissions: Some(":danger-no-sandbox".to_string()),
default_permissions: Some(
BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string()
),
..Default::default()
},
cwd.as_path()
@@ -520,7 +525,7 @@ mod thread_processor_behavior_tests {
));
assert!(!requested_permissions_trust_project(
&ConfigOverrides {
default_permissions: Some(":read-only".to_string()),
default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string()),
..Default::default()
},
cwd.as_path()
@@ -614,6 +619,7 @@ mod thread_processor_behavior_tests {
temp_dir.path().to_path_buf(),
Vec::new(),
LoaderOverrides::default(),
/*strict_config*/ false,
CloudRequirementsLoader::default(),
Arg0DispatchPaths::default(),
Arc::new(StaticThreadConfigLoader::new(vec![
+3
View File
@@ -20,11 +20,14 @@ pub(crate) use codex_app_server_transport::OutgoingMessage;
pub(crate) use codex_app_server_transport::QueuedOutgoingMessage;
pub(crate) use codex_app_server_transport::RemoteControlHandle;
pub(crate) use codex_app_server_transport::RemoteControlStartConfig;
pub(crate) use codex_app_server_transport::RemoteControlUnavailable;
pub(crate) use codex_app_server_transport::TransportEvent;
pub use codex_app_server_transport::app_server_control_socket_path;
pub use codex_app_server_transport::auth;
pub(crate) use codex_app_server_transport::start_control_socket_acceptor;
pub(crate) use codex_app_server_transport::start_remote_control;
pub(crate) use codex_app_server_transport::start_stdio_connection;
pub(crate) use codex_app_server_transport::start_websocket_acceptor;
pub(crate) struct ConnectionState {
pub(crate) outbound_initialized: Arc<AtomicBool>,
@@ -570,6 +570,18 @@ impl McpProcess {
.await
}
/// Send a `remoteControl/enable` JSON-RPC request.
pub async fn send_remote_control_enable_request(&mut self) -> anyhow::Result<i64> {
self.send_request("remoteControl/enable", /*params*/ None)
.await
}
/// Send a `remoteControl/disable` JSON-RPC request.
pub async fn send_remote_control_disable_request(&mut self) -> anyhow::Result<i64> {
self.send_request("remoteControl/disable", /*params*/ None)
.await
}
/// Send an `app/list` JSON-RPC request.
pub async fn send_apps_list_request(&mut self, params: AppsListParams) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
@@ -149,6 +149,7 @@ async fn get_conversation_summary_by_thread_id_reads_pathless_store_thread() ->
config: Arc::new(config),
cli_overrides: Vec::new(),
loader_overrides,
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
+1
View File
@@ -1,4 +1,5 @@
mod auth;
mod conversation_summary;
mod fuzzy_file_search;
mod strict_config;
mod v2;

Some files were not shown because too many files have changed in this diff Show More