Compare commits

..

58 Commits

Author SHA1 Message Date
Vincent Koc
87b40c7160 test(release): align beta2 validation expectations 2026-06-22 16:11:19 +08:00
Vincent Koc
b8108e4794 test(telegram): add progress preview helper 2026-06-22 15:55:14 +08:00
Vincent Koc
dec86febb6 chore(release): refresh beta2 validation baselines 2026-06-22 15:44:39 +08:00
Vincent Koc
a2e555338a test(agents): cover fallback probe attempt position
(cherry picked from commit 2c3519c1d7)
2026-06-22 15:21:33 +08:00
Vincent Koc
8bb6472c4d fix(channels): preserve keyed progress state updates
(cherry picked from commit e114001cca)
2026-06-22 15:12:10 +08:00
Vincent Koc
e18d316734 test(agents): cover fallback attempt position
(cherry picked from commit 3ff59df960)
2026-06-22 15:12:10 +08:00
Vincent Koc
cfb07d0d3a fix(codex): satisfy app-server callback contract 2026-06-22 14:57:09 +08:00
Vincent Koc
8608fa2046 test: model command progress start events 2026-06-22 14:34:07 +08:00
Vincent Koc
3f67778c52 docs(changelog): align 2026.6.10 notes with 6.9 stable 2026-06-22 13:43:28 +08:00
Vincent Koc
cbef591210 chore(release): prepare 2026.6.10-beta.2 2026-06-22 13:37:36 +08:00
Jesse Merhi
de982a0224 fix: keep trusted policies with hook registry (#94545)
* fix: keep trusted policies with hook registry

* fix: compose trusted policies with live hooks

* fix: preserve trusted policy order

* test: update hook runner mock

* fix: keep hook policy registry internal
2026-06-22 10:47:52 +08:00
Vincent Koc
684e440137 fix: preserve fast auto delivery semantics 2026-06-22 09:34:41 +08:00
Vincent Koc
f4d93c855b fix: gate fallback fast reset notices 2026-06-22 09:27:31 +08:00
Vincent Koc
c4694f84ff fix: type fallback state for CLI runs 2026-06-22 09:19:34 +08:00
Vincent Koc
6c29f88913 fix: carry fast mode fallback state through runners 2026-06-22 09:19:07 +08:00
Vincent Koc
7f5423ca97 fix: complete fast mode fallback and status wiring 2026-06-22 09:09:48 +08:00
Vincent Koc
9509aa063c fix: preserve normalization and ACP fast mode contracts 2026-06-21 22:29:49 +08:00
Vincent Koc
20aec98554 fix: mark embedded auto fast mode explicitly 2026-06-21 22:18:39 +08:00
Vincent Koc
d1e190fbe8 fix: preserve fast mode across retries 2026-06-21 22:16:04 +08:00
Vincent Koc
2d42e52ac5 fix: preserve repeated channel progress events 2026-06-21 21:56:16 +08:00
Vincent Koc
075091d0ca fix: resolve fallback fast cutoff per model 2026-06-21 21:49:05 +08:00
Vincent Koc
cd32d9ff91 fix(codex): normalize cleared service tier in bindings 2026-06-21 21:43:41 +08:00
Vincent Koc
8845f2fd61 fix: clear Codex tier and render auto fast status 2026-06-21 21:42:02 +08:00
Vincent Koc
e4bf1e6774 test: align fast auto forward-port with current fixtures 2026-06-21 21:40:30 +08:00
Vincent Koc
f5ff56640e fix: remove stale fast auto event import 2026-06-21 21:37:48 +08:00
Vincent Koc
9540a69b24 feat: forward-port fast talks auto mode (#85104) 2026-06-21 21:35:44 +08:00
Tideclaw
d91c1607c4 fix: refresh plugin sdk surface budget 2026-06-21 15:20:37 +08:00
Vincent Koc
cf13590c1c chore(release): prepare 2026.6.10-beta.1 2026-06-21 13:21:20 +08:00
Vincent Koc
83785a6e79 fix(build): kill tsdown trees on windows 2026-06-21 07:09:21 +02:00
Vincent Koc
195890f815 fix(dev): kill tui pty watch trees on windows 2026-06-21 07:06:48 +02:00
Vincent Koc
0f8df48a91 fix(release): forward-port 2026.6.9 closeout fixes 2026-06-21 13:05:11 +08:00
Vincent Koc
7b28b73e78 fix(e2e): preserve secret proof windows tree cleanup 2026-06-21 06:59:18 +02:00
Vincent Koc
3650766f26 fix(rpc): kill kitchen sink trees on windows 2026-06-21 06:53:14 +02:00
Vincent Koc
eb03d0ee2b chore(deadcode): share session cost totals 2026-06-21 12:52:23 +08:00
Vincent Koc
38c8b0c196 fix(crabbox): kill telegram proof trees on windows 2026-06-21 06:47:25 +02:00
Vincent Koc
0030a192c8 fix(qa): kill telegram credential trees on windows 2026-06-21 06:42:35 +02:00
Vincent Koc
34806b39cd fix(package): kill candidate resolver trees on windows 2026-06-21 06:37:51 +02:00
Vincent Koc
b0f21f8af7 fix(scripts): kill boundary prep trees on windows 2026-06-21 06:34:10 +02:00
Vincent Koc
5af318b95d fix(testing): kill group report trees on windows 2026-06-21 06:29:14 +02:00
Vincent Koc
8d2e6d7686 fix(scripts): kill run-with-env trees on windows 2026-06-21 06:24:47 +02:00
Vincent Koc
b039e949b6 chore(release): close out 2026.6.9 2026-06-21 12:24:15 +08:00
Vincent Koc
2ca5b7c93e fix(mac): retry DMG detach 2026-06-21 12:24:15 +08:00
Vincent Koc
51d1789cea fix(release): terminate command descendants on signal 2026-06-21 12:24:15 +08:00
Vincent Koc
89e73240a1 test(release): align full validation workflow contract 2026-06-21 12:24:15 +08:00
Vincent Koc
f3ee317f71 fix(ci): deduplicate release Telegram validation 2026-06-21 12:24:15 +08:00
Vincent Koc
ecb82f1be9 fix(plugins): restore StepFun ClawHub release 2026-06-21 12:24:14 +08:00
Vincent Koc
f1a48dac18 test(release): stabilize validation contracts 2026-06-21 12:24:14 +08:00
Vincent Koc
cba9c02095 chore(deadcode): share plugin snapshot fingerprint 2026-06-21 12:23:31 +08:00
Peter Steinberger
715dc718fc fix(opencode-go): align Kimi input with runtime 2026-06-21 00:22:52 -04:00
Vincent Koc
85f71f4c8f fix(scripts): kill managed child trees on windows 2026-06-21 06:19:23 +02:00
Peter Steinberger
66f84a9bf1 fix(opencode-go): add current Go models
Co-authored-by: samson1357924 <samson1357924@gmail.com>
2026-06-21 00:06:55 -04:00
Zak
50c2cc6a45 fix(zai): expose GLM-5.2 reasoning levels [AI-assisted] (#94136)
Merged via squash.

Prepared head SHA: 432214158a
Co-authored-by: BorClaw <268442329+BorClaw@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-20 23:57:49 -04:00
Chunyue Wang
e3ccf8743f fix(channels): resolve native /think menu levels via runtime catalog for live-discovered models (#94067)
Merged via squash.

Prepared head SHA: 079347b8b8
Co-authored-by: openperf <80630709+openperf@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-20 23:56:53 -04:00
Vincent Koc
d4c2fa7aed chore(deadcode): drop unused sdk specifier helper 2026-06-21 11:50:51 +08:00
Nik
6c1041339d fix(agents): classify Zhipu GLM overload as overloaded for failover (#93241)
Merged via squash.

Prepared head SHA: db79e94c89
Co-authored-by: 0xghost42 <151941421+0xghost42@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-20 23:49:39 -04:00
huangjianxiong
db54a3268b fix(zai): fall back to manifest baseUrl for synthesized GLM-5 models (#94461)
Merged via squash.

Prepared head SHA: 445a418187
Co-authored-by: Pandah97 <80405497+Pandah97@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-20 23:49:08 -04:00
Vincent Koc
9750d887f5 chore(deadcode): dedupe plugin lookup table types 2026-06-21 11:08:34 +08:00
Vincent Koc
fce586538a chore(deadcode): drop unused i18n config type 2026-06-21 10:45:56 +08:00
628 changed files with 21005 additions and 26870 deletions

View File

@@ -4,6 +4,7 @@ import { execFileSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
const repo = "openclaw/openclaw";
const commitAssociationQueryBatchSize = 20;
const excludedHandles = new Set(["openclaw", "clawsweeper", "claude", "codex", "steipete"]);
const nonEditorialTypes = new Set([
"build",
@@ -618,13 +619,25 @@ function graphql(query) {
let lastError;
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
return githubApi(["graphql", "-f", `query=${query}`]).data;
const response = githubApi(["graphql", "-f", `query=${query}`]);
if (response?.data && typeof response.data === "object") {
return response.data;
}
const errors = Array.isArray(response?.errors)
? response.errors.map((error) => error?.message).filter(Boolean)
: [];
const detail = [...errors, response?.message].filter(Boolean).join("\n");
throw new Error(
detail
? `GitHub GraphQL response did not include data:\n${detail}`
: "GitHub GraphQL response did not include data.",
);
} catch (error) {
lastError = error;
const message = [error?.message, error?.stdout, error?.stderr].filter(Boolean).join("\n");
// Historical ranges batch hundreds of objects; only retry transient transport failures.
if (
!/(?:operation timed out|ECONNRESET|ETIMEDOUT|EAI_AGAIN|TLS handshake timeout|stream error: .*CANCEL|unexpected end of JSON input|upstream connect error|connection termination|error connecting to api\.github\.com|Unexpected token '<')/i.test(
!/(?:operation timed out|ECONNRESET|ETIMEDOUT|EAI_AGAIN|TLS handshake timeout|stream error: .*CANCEL|unexpected end of JSON input|upstream connect error|connection termination|connection reset by peer|error connecting to api\.github\.com|Unexpected token '<'|something went wrong|temporarily unavailable|internal server error|rate limit)/i.test(
message,
)
) {
@@ -657,8 +670,8 @@ function resolveAssociatedPullRequests(commitHashes, targetTimestamp) {
pending.push({ commitHash, cursor: connection.pageInfo.endCursor });
}
}
for (let index = 0; index < commitHashes.length; index += 40) {
const chunk = commitHashes.slice(index, index + 40);
for (let index = 0; index < commitHashes.length; index += commitAssociationQueryBatchSize) {
const chunk = commitHashes.slice(index, index + commitAssociationQueryBatchSize);
const fields = chunk
.map(
(hash, offset) =>

View File

@@ -70,7 +70,7 @@ on:
default: ""
type: string
npm_telegram_package_spec:
description: Optional published package spec for the package Telegram E2E lane
description: Optional published package spec for the focused package Telegram E2E rerun
required: false
default: ""
type: string
@@ -95,7 +95,7 @@ on:
default: ""
type: string
npm_telegram_provider_mode:
description: Provider mode for the package Telegram E2E lane
description: Provider mode for the focused package Telegram E2E rerun
required: false
default: mock-openai
type: choice
@@ -103,7 +103,7 @@ on:
- mock-openai
- live-frontier
npm_telegram_scenario:
description: Optional comma-separated Telegram scenario ids for the package Telegram lane
description: Optional comma-separated Telegram scenario ids for the focused package Telegram E2E rerun
required: false
default: ""
type: string
@@ -200,14 +200,16 @@ jobs:
if [[ -n "${RELEASE_PACKAGE_SPEC// }" ]]; then
echo "- Published release package: \`${RELEASE_PACKAGE_SPEC}\`"
fi
if [[ -n "${NPM_TELEGRAM_PACKAGE_SPEC// }" ]]; then
if [[ "$RERUN_GROUP" == "npm-telegram" && -n "${NPM_TELEGRAM_PACKAGE_SPEC// }" ]]; then
echo "- Published-package Telegram E2E: \`${NPM_TELEGRAM_PACKAGE_SPEC}\`"
elif [[ -n "${RELEASE_PACKAGE_SPEC// }" ]]; then
elif [[ "$RERUN_GROUP" == "npm-telegram" && -n "${RELEASE_PACKAGE_SPEC// }" ]]; then
echo "- Published-package Telegram E2E: \`${RELEASE_PACKAGE_SPEC}\`"
elif [[ "$RERUN_GROUP" == "all" && "$RELEASE_PROFILE" == "full" ]]; then
echo "- Package Telegram E2E: parent \`release-package-under-test\` artifact"
elif [[ "$RERUN_GROUP" == "npm-telegram" ]]; then
echo "- Package Telegram E2E: focused rerun requires \`release_package_spec\` or \`npm_telegram_package_spec\`"
elif [[ "$RERUN_GROUP" == "all" || "$RERUN_GROUP" == "release-checks" || "$RERUN_GROUP" == "package" ]]; then
echo "- Package Telegram E2E: OpenClaw Release Checks Package Acceptance"
else
echo "- Package Telegram E2E: skipped unless \`release_profile=full\`, \`release_package_spec\`, or \`npm_telegram_package_spec\` is provided"
echo "- Package Telegram E2E: skipped by rerun group"
fi
if [[ -n "${EVIDENCE_PACKAGE_SPEC// }" ]]; then
echo "- Private evidence package proof: \`${EVIDENCE_PACKAGE_SPEC}\`"
@@ -764,80 +766,10 @@ jobs:
dispatch_and_wait openclaw-release-checks.yml "${args[@]}"
prepare_release_package:
name: Prepare release package artifact
needs: [resolve_target, docker_runtime_assets_preflight]
if: ${{ always() && needs.resolve_target.result == 'success' && inputs.npm_telegram_package_spec == '' && inputs.release_package_spec == '' && inputs.rerun_group == 'all' && inputs.release_profile == 'full' && needs.docker_runtime_assets_preflight.result == 'success' }}
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
contents: read
packages: write
outputs:
artifact_name: ${{ steps.artifact.outputs.name }}
package_sha256: ${{ steps.package.outputs.sha256 }}
package_version: ${{ steps.package.outputs.package_version }}
source_sha: ${{ steps.package.outputs.source_sha }}
steps:
- name: Checkout trusted workflow ref
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: true
ref: ${{ github.ref_name }}
fetch-depth: 0
- name: Set artifact metadata
id: artifact
run: echo "name=release-package-under-test" >> "$GITHUB_OUTPUT"
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
install-bun: "true"
install-deps: "false"
- name: Resolve release package artifact
id: package
shell: bash
env:
PACKAGE_REF: ${{ needs.resolve_target.outputs.sha }}
run: |
set -euo pipefail
node scripts/resolve-openclaw-package-candidate.mjs \
--source ref \
--package-ref "$PACKAGE_REF" \
--output-dir .artifacts/docker-e2e-package \
--output-name openclaw-current.tgz \
--metadata .artifacts/docker-e2e-package/package-candidate.json \
--github-output "$GITHUB_OUTPUT"
digest="$(node -p "JSON.parse(require('fs').readFileSync('.artifacts/docker-e2e-package/package-candidate.json', 'utf8')).sha256")"
version="$(node -p "JSON.parse(require('fs').readFileSync('.artifacts/docker-e2e-package/package-candidate.json', 'utf8')).version")"
source_sha="$(node -p "JSON.parse(require('fs').readFileSync('.artifacts/docker-e2e-package/package-candidate.json', 'utf8')).packageSourceSha")"
echo "source_sha=$source_sha" >> "$GITHUB_OUTPUT"
{
echo "## Release package artifact"
echo
echo "- Artifact: \`release-package-under-test\`"
echo "- Package ref: \`$PACKAGE_REF\`"
echo "- SHA-256: \`$digest\`"
echo "- Version: \`$version\`"
echo "- Source SHA: \`$source_sha\`"
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload release package artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: release-package-under-test
path: |
.artifacts/docker-e2e-package/openclaw-current.tgz
.artifacts/docker-e2e-package/package-candidate.json
if-no-files-found: error
npm_telegram:
name: Run package Telegram E2E
needs: [resolve_target, prepare_release_package]
if: ${{ always() && contains(fromJSON('["all","npm-telegram"]'), inputs.rerun_group) && (inputs.npm_telegram_package_spec != '' || inputs.release_package_spec != '' || (inputs.rerun_group == 'all' && inputs.release_profile == 'full')) }}
needs: [resolve_target]
if: ${{ always() && needs.resolve_target.result == 'success' && inputs.rerun_group == 'npm-telegram' && (inputs.npm_telegram_package_spec != '' || inputs.release_package_spec != '') }}
continue-on-error: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }}
runs-on: ubuntu-24.04
timeout-minutes: ${{ inputs.release_profile == 'full' && 360 || 60 }}
@@ -853,8 +785,6 @@ jobs:
CHILD_WORKFLOW_REF: ${{ github.ref_name }}
TARGET_SHA: ${{ needs.resolve_target.outputs.sha }}
PACKAGE_SPEC: ${{ inputs.npm_telegram_package_spec || inputs.release_package_spec }}
PACKAGE_ARTIFACT_NAME: ${{ needs.prepare_release_package.outputs.artifact_name }}
PREPARE_PACKAGE_RESULT: ${{ needs.prepare_release_package.result }}
PROVIDER_MODE: ${{ inputs.npm_telegram_provider_mode }}
SCENARIO: ${{ inputs.npm_telegram_scenario }}
run: |
@@ -883,18 +813,7 @@ jobs:
return "$status"
}
args=(-f package_spec="${PACKAGE_SPEC:-openclaw@beta}" -f harness_ref="$TARGET_SHA" -f provider_mode="$PROVIDER_MODE")
if [[ -z "${PACKAGE_SPEC// }" ]]; then
if [[ "$PREPARE_PACKAGE_RESULT" != "success" || -z "${PACKAGE_ARTIFACT_NAME// }" ]]; then
echo "Full release Telegram requires either npm_telegram_package_spec or a prepared release-package-under-test artifact." >&2
exit 1
fi
args+=(
-f package_artifact_name="$PACKAGE_ARTIFACT_NAME"
-f package_artifact_run_id="${GITHUB_RUN_ID}"
-f package_label="full-release-${TARGET_SHA:0:12}"
)
fi
args=(-f package_spec="$PACKAGE_SPEC" -f harness_ref="$TARGET_SHA" -f provider_mode="$PROVIDER_MODE")
if [[ -n "${SCENARIO// }" ]]; then
args+=(-f scenario="$SCENARIO")
fi

View File

@@ -717,7 +717,6 @@ jobs:
published_upgrade_survivor_baselines: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'last-stable-4 2026.4.23 2026.5.2 2026.4.15' || '' }}
published_upgrade_survivor_scenarios: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'reported-issues' || '' }}
telegram_mode: mock-openai
telegram_scenarios: telegram-help-command,telegram-commands-command,telegram-tools-compact-command,telegram-whoami-command,telegram-status-command,telegram-other-bot-command-gating,telegram-context-command,telegram-mentioned-message-reply,telegram-long-final-reuses-preview,telegram-mention-gating
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}

View File

@@ -129,11 +129,28 @@ jobs:
trusted_config="$RUNNER_TEMP/pre-commit-base.yaml"
trusted_zizmor_config="$RUNNER_TEMP/zizmor-base.yml"
if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then
timeout --signal=TERM --kill-after=10s 30s git fetch --no-tags --depth=1 origin \
"+${BASE_SHA}:refs/remotes/origin/security-base" ||
fetch_base_ref() {
local ref="$1"
local target="$2"
local fetch_status
for attempt in 1 2 3; do
timeout --signal=TERM --kill-after=10s 30s git fetch --no-tags --depth=1 origin \
"+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}"
"+${ref}:${target}" && return 0
fetch_status="$?"
if [ "$fetch_status" != "124" ] && [ "$fetch_status" != "137" ]; then
return "$fetch_status"
fi
if [ "$attempt" = "3" ]; then
return "$fetch_status"
fi
echo "::warning::trusted base fetch for '$ref' timed out on attempt $attempt; retrying"
sleep 5
done
}
if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then
fetch_base_ref "$BASE_SHA" "refs/remotes/origin/security-base" ||
fetch_base_ref "refs/heads/${BASE_REF}" "refs/remotes/origin/${BASE_REF}"
fi
if git cat-file -e "${BASE_SHA}:.pre-commit-config.yaml" 2>/dev/null; then

View File

@@ -2,6 +2,43 @@
Docs: https://docs.openclaw.ai
## 2026.6.10
### Highlights
- **Automatic fast mode for talks:** OpenClaw can enable fast mode for short conversational turns, then return to normal mode for longer runs with bounded fallback and delivery behavior. (#85104) Thanks @alexph-dev and @vincentkoc.
- **More reliable model routing:** Zai model synthesis, GLM overload failover, and native reasoning-level selection now follow the active model catalog more consistently. (#94461, #93241, #94067, #94136) Thanks @Pandah97, @chrysb, @0xghost42, @zhengli0922, @openperf, @civiltox, and @BorClaw.
- **Safer session and channel state:** channel switches reset stale origin fields, and cron delivery awareness stays attached to the target session. (#95328, #93580) Thanks @ZengWen-DT, @jalehman, @gorkem2020, and @scotthuang.
- **Trusted policies survive hook composition:** composed hook registries keep the trusted tool policies required by approval-sensitive flows. (#94545) Thanks @jesse-merhi.
### Changes
- **Agent and channel runtime:** fast-mode state now survives retries, fallback transitions, progress events, and embedded/CLI/ACP normalization; session and channel routing retain the current target and delivery context. (#85104, #93580, #95328) Thanks @alexph-dev, @vincentkoc, @scotthuang, @ZengWen-DT, @jalehman, and @gorkem2020.
- **Provider behavior:** model catalogs now supply the correct Zai base URL, overload classification, and native reasoning controls for live-discovered models. (#94461, #93241, #94067, #94136) Thanks @Pandah97, @chrysb, @0xghost42, @zhengli0922, @openperf, @civiltox, and @BorClaw.
### Fixes
- **Fast-mode and policy correctness:** fallback cutoffs and reset notices are bounded, repeated progress events remain visible, Codex service-tier state is normalized, and trusted policies are not lost when hook registries are composed. (#85104, #94545) Thanks @alexph-dev, @vincentkoc, and @jesse-merhi.
- **Model and delivery edge cases:** Zai and GLM failover paths use the right runtime metadata, while stale channel-origin state no longer leaks across session changes. (#94461, #93241, #95328) Thanks @Pandah97, @chrysb, @0xghost42, @zhengli0922, @ZengWen-DT, @jalehman, and @gorkem2020.
### Complete contribution record
This audited record covers the complete v2026.6.9..HEAD history: 11 merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.
#### Pull requests
- **PR #86627** Keep core doctor health in contribution order. Thanks @giodl73-repo.
- **PR #93580** fix: preserve cron delivery awareness for target sessions. Thanks @scotthuang and @jalehman.
- **PR #95030** refactor: add SDK transcript identity target API. Thanks @jalehman.
- **PR #94838** refactor(copilot): complete harness lifecycle parity. Thanks @vincentkoc.
- **PR #95328** fix(sessions): reset stale per-channel origin fields on channel switch. Related #95325. Thanks @ZengWen-DT and @jalehman and @gorkem2020.
- **PR #94461** fix(zai): fall back to manifest baseUrl for synthesized GLM-5 models. Related #94269. Thanks @Pandah97 and @chrysb.
- **PR #93241** fix(agents): classify Zhipu GLM overload as overloaded for failover. Related #93211. Thanks @0xghost42 and @zhengli0922.
- **PR #94067** fix(channels): resolve native /think menu levels via runtime catalog for live-discovered models. Related #93835. Thanks @openperf and @civiltox.
- **PR #94136** fix(zai): expose GLM-5.2 reasoning levels [AI-assisted]. Thanks @BorClaw.
- **PR #85104** feat: fast talks auto mode. Related #85087. Thanks @alexph-dev.
- **PR #94545** fix: keep trusted policies with hook registry. Thanks @jesse-merhi.
## 2026.6.9
### Highlights

View File

@@ -2,5 +2,5 @@
# Source of truth: apps/android/version.json
# Generated by scripts/android-sync-versioning.ts.
OPENCLAW_ANDROID_VERSION_NAME=2026.6.2
OPENCLAW_ANDROID_VERSION_CODE=2026060201
OPENCLAW_ANDROID_VERSION_NAME=2026.6.10
OPENCLAW_ANDROID_VERSION_CODE=2026061001

View File

@@ -1,3 +1 @@
OpenClaw is now available on Android.
Connect to your OpenClaw Gateway to chat with your assistant, use realtime Talk mode, review approvals, and bring Android device capabilities like camera, location, screen, and notifications into your private automation workflows.
Maintenance update for the current OpenClaw Android release.

View File

@@ -1,4 +1,4 @@
{
"version": "2026.6.2",
"versionCode": 2026060201
"version": "2026.6.10",
"versionCode": 2026061001
}

View File

@@ -1,5 +1,19 @@
# OpenClaw iOS Changelog
## 2026.6.10 - 2026-06-21
Maintenance update for the current OpenClaw beta release.
- Improved notification cleanup, Watch app compatibility, and native file input handling.
## 2026.6.9 - 2026-06-20
Maintenance update for the current OpenClaw release.
- Added Apple Watch controls for common agent actions.
- Improved Gateway setup, notification settings, and share-extension identity handling.
- Updated the Watch app integration for current Xcode compatibility.
## 2026.6.2 - 2026-06-02
OpenClaw is now available on iPhone.

View File

@@ -2,8 +2,8 @@
// Source of truth: apps/ios/version.json
// Generated by scripts/ios-sync-versioning.ts.
OPENCLAW_IOS_VERSION = 2026.6.2
OPENCLAW_MARKETING_VERSION = 2026.6.2
OPENCLAW_IOS_VERSION = 2026.6.10
OPENCLAW_MARKETING_VERSION = 2026.6.10
OPENCLAW_BUILD_VERSION = 1
#include? "../build/Version.xcconfig"

View File

@@ -1,3 +1,3 @@
OpenClaw is now available on iPhone.
Maintenance update for the current OpenClaw beta release.
Connect to your OpenClaw Gateway to chat with your assistant, use realtime Talk mode, review approvals, share content from iOS, and bring device capabilities like camera, location, screen, and notifications into your private automation workflows.
- Improved notification cleanup, Watch app compatibility, and native file input handling.

View File

@@ -1,3 +1,3 @@
{
"version": "2026.6.2"
"version": "2026.6.10"
}

View File

@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.6.2</string>
<string>2026.6.9</string>
<key>CFBundleVersion</key>
<string>2026060200</string>
<string>2026060900</string>
<key>CFBundleIconFile</key>
<string>OpenClaw</string>
<key>CFBundleURLTypes</key>

View File

@@ -7273,7 +7273,9 @@ public struct ChatSendParams: Codable, Sendable {
public let sessionid: String?
public let message: String
public let thinking: String?
public let fastmode: Bool?
public let fastmodevalue: AnyCodable?
public var fastmode: Bool? { fastmodevalue?.value as? Bool }
public let fastautoonseconds: Int?
public let deliver: Bool?
public let originatingchannel: String?
public let originatingto: String?
@@ -7286,6 +7288,46 @@ public struct ChatSendParams: Codable, Sendable {
public let suppresscommandinterpretation: Bool?
public let idempotencykey: String
public init(
sessionkey: String,
agentid: String? = nil,
sessionid: String?,
message: String,
thinking: String?,
fastmodevalue: AnyCodable?,
fastautoonseconds: Int?,
deliver: Bool?,
originatingchannel: String?,
originatingto: String?,
originatingaccountid: String?,
originatingthreadid: String?,
attachments: [AnyCodable]?,
timeoutms: Int?,
systeminputprovenance: [String: AnyCodable]?,
systemprovenancereceipt: String?,
suppresscommandinterpretation: Bool?,
idempotencykey: String)
{
self.sessionkey = sessionkey
self.agentid = agentid
self.sessionid = sessionid
self.message = message
self.thinking = thinking
self.fastmodevalue = fastmodevalue
self.fastautoonseconds = fastautoonseconds
self.deliver = deliver
self.originatingchannel = originatingchannel
self.originatingto = originatingto
self.originatingaccountid = originatingaccountid
self.originatingthreadid = originatingthreadid
self.attachments = attachments
self.timeoutms = timeoutms
self.systeminputprovenance = systeminputprovenance
self.systemprovenancereceipt = systemprovenancereceipt
self.suppresscommandinterpretation = suppresscommandinterpretation
self.idempotencykey = idempotencykey
}
public init(
sessionkey: String,
agentid: String? = nil,
@@ -7305,23 +7347,25 @@ public struct ChatSendParams: Codable, Sendable {
suppresscommandinterpretation: Bool?,
idempotencykey: String)
{
self.sessionkey = sessionkey
self.agentid = agentid
self.sessionid = sessionid
self.message = message
self.thinking = thinking
self.fastmode = fastmode
self.deliver = deliver
self.originatingchannel = originatingchannel
self.originatingto = originatingto
self.originatingaccountid = originatingaccountid
self.originatingthreadid = originatingthreadid
self.attachments = attachments
self.timeoutms = timeoutms
self.systeminputprovenance = systeminputprovenance
self.systemprovenancereceipt = systemprovenancereceipt
self.suppresscommandinterpretation = suppresscommandinterpretation
self.idempotencykey = idempotencykey
self.init(
sessionkey: sessionkey,
agentid: agentid,
sessionid: sessionid,
message: message,
thinking: thinking,
fastmodevalue: fastmode.map { AnyCodable($0) },
fastautoonseconds: nil,
deliver: deliver,
originatingchannel: originatingchannel,
originatingto: originatingto,
originatingaccountid: originatingaccountid,
originatingthreadid: originatingthreadid,
attachments: attachments,
timeoutms: timeoutms,
systeminputprovenance: systeminputprovenance,
systemprovenancereceipt: systemprovenancereceipt,
suppresscommandinterpretation: suppresscommandinterpretation,
idempotencykey: idempotencykey)
}
private enum CodingKeys: String, CodingKey {
@@ -7330,7 +7374,8 @@ public struct ChatSendParams: Codable, Sendable {
case sessionid = "sessionId"
case message
case thinking
case fastmode = "fastMode"
case fastmodevalue = "fastMode"
case fastautoonseconds = "fastAutoOnSeconds"
case deliver
case originatingchannel = "originatingChannel"
case originatingto = "originatingTo"

View File

@@ -1,4 +1,4 @@
ac06b6c20a93a8543ec1bd3748ef4f7bdae5006839dd93b3fff874d0da4244aa config-baseline.json
e7965566fdaedef445bcd562141f4f3ea1a499cf8ea5956418af7c98049bf242 config-baseline.core.json
7ce9a1d8d4a69eab05d700241472db66f268f20c144692dc69d38ba22f06d741 config-baseline.json
5d355e16324e0e68d6e034e135e487947f67397a99a6ff91949c6aa07645e982 config-baseline.core.json
2d735389858305509528e74329b6f8c65d311e1471c3b4e91dc17aaab8e63a80 config-baseline.channel.json
0039da0cf2ba2845b37db52c4cf3a0f25e367cf3d2d507c5d6f8a5e5bdfdc4d4 config-baseline.plugin.json
d2e2114f1cd43dc894fe1a4836677b42a2a5af825537d6c4a932da832d58a590 config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
6f442c09ff2fa618f6f68cc866091a713d2c730090380dd726a9845f4d0fd9bd plugin-sdk-api-baseline.json
d6b1929a42117759a3d0908fb68866e721ee7f0840279dce905a975b461c5b67 plugin-sdk-api-baseline.jsonl
4bbf1636d7e0b410fffa3d12a366371b2f66524f7c730fdc4de789d0f482aeb2 plugin-sdk-api-baseline.json
c5194a29911287897eee44c3579878bda552a6a7baa135bba9c719fa2ded9ab8 plugin-sdk-api-baseline.jsonl

View File

@@ -183,7 +183,7 @@ Model-selection precedence for isolated jobs is:
3. User-selected stored cron session model override
4. Agent/default model selection
Fast mode follows the resolved live selection too. If the selected model config has `params.fastMode`, isolated cron uses that by default. A stored session `fastMode` override still wins over config in either direction.
Fast mode follows the resolved live selection too. If the selected model config has `params.fastMode`, isolated cron uses that by default. A stored session `fastMode` override still wins over config in either direction. Auto mode uses the selected model's `params.fastAutoOnSeconds` cutoff when present, defaulting to 60 seconds.
If an isolated run hits a live model-switch handoff, cron retries with the switched provider/model and persists that live selection for the active run before retrying. When the switch also carries a new auth profile, cron persists that auth profile override for the active run too. Retries are bounded: after the initial attempt plus 2 switch retries, cron aborts instead of looping forever.

View File

@@ -177,7 +177,7 @@ Every lane uploads GitHub artifacts. When `CLAWGRIT_REPORTS_TOKEN` is configured
## Full Release Validation
`Full Release Validation` is the manual umbrella workflow for "run everything before release." It accepts a branch, tag, or full commit SHA, dispatches the manual `CI` workflow with that target, dispatches `Plugin Prerelease` for release-only plugin/package/static/Docker proof, and dispatches `OpenClaw Release Checks` for install smoke, package acceptance, cross-OS package checks, QA Lab parity, Matrix, and Telegram lanes. Stable and full profiles always include exhaustive live/E2E and Docker release-path soak coverage; the beta profile can opt in with `run_release_soak=true`. With `rerun_group=all` and `release_profile=full`, it also runs `NPM Telegram Beta E2E` against the `release-package-under-test` artifact from release checks. After publishing, pass `release_package_spec` to reuse the shipped npm package across release checks, Package Acceptance, Docker, cross-OS, and Telegram without rebuilding. Use `npm_telegram_package_spec` only when Telegram must prove a different package. The Codex plugin live package lane uses the same selected state by default: published `release_package_spec=openclaw@<tag>` derives `codex_plugin_spec=npm:@openclaw/codex@<tag>`, while SHA/artifact runs pack `extensions/codex` from the selected ref. Set `codex_plugin_spec` explicitly for custom plugin sources such as `npm:`, `npm-pack:`, or `git:` specs.
`Full Release Validation` is the manual umbrella workflow for "run everything before release." It accepts a branch, tag, or full commit SHA, dispatches the manual `CI` workflow with that target, dispatches `Plugin Prerelease` for release-only plugin/package/static/Docker proof, and dispatches `OpenClaw Release Checks` for install smoke, package acceptance, cross-OS package checks, QA Lab parity, Matrix, and Telegram lanes. Stable and full profiles always include exhaustive live/E2E and Docker release-path soak coverage; the beta profile can opt in with `run_release_soak=true`. The canonical package Telegram E2E runs inside Package Acceptance, so a full candidate does not start a duplicate live poller. After publishing, pass `release_package_spec` to reuse the shipped npm package across release checks, Package Acceptance, Docker, cross-OS, and Telegram without rebuilding. Use `npm_telegram_package_spec` only for a focused published-package Telegram rerun. The Codex plugin live package lane uses the same selected state by default: published `release_package_spec=openclaw@<tag>` derives `codex_plugin_spec=npm:@openclaw/codex@<tag>`, while SHA/artifact runs pack `extensions/codex` from the selected ref. Set `codex_plugin_spec` explicitly for custom plugin sources such as `npm:`, `npm-pack:`, or `git:` specs.
See [Full release validation](/reference/full-release-validation) for the
stage matrix, exact workflow job names, profile differences, artifacts, and

View File

@@ -197,7 +197,7 @@ Isolated cron resolves the active model in this order:
### Fast mode
Isolated cron fast mode follows the resolved live model selection. Model config `params.fastMode` applies by default, but a stored session `fastMode` override still wins over config.
Isolated cron fast mode follows the resolved live model selection. Model config `params.fastMode` applies by default, but a stored session `fastMode` override still wins over config. When the resolved mode is `auto`, the cutoff uses the selected model's `params.fastAutoOnSeconds` value, defaulting to 60 seconds.
### Live model switch retries

View File

@@ -1098,7 +1098,7 @@ for provider examples and precedence.
- `skills`: optional per-agent skill allowlist. If omitted, the agent inherits `agents.defaults.skills` when set; an explicit list replaces defaults instead of merging, and `[]` means no skills.
- `thinkingDefault`: optional per-agent default thinking level (`off | minimal | low | medium | high | xhigh | adaptive | max`). Overrides `agents.defaults.thinkingDefault` for this agent when no per-message or session override is set. The selected provider/model profile controls which values are valid; for Google Gemini, `adaptive` keeps provider-owned dynamic thinking (`thinkingLevel` omitted on Gemini 3/3.1, `thinkingBudget: -1` on Gemini 2.5).
- `reasoningDefault`: optional per-agent default reasoning visibility (`on | off | stream`). Overrides `agents.defaults.reasoningDefault` for this agent when no per-message or session reasoning override is set.
- `fastModeDefault`: optional per-agent default for fast mode (`true | false`). Applies when no per-message or session fast-mode override is set.
- `fastModeDefault`: optional per-agent default for fast mode (`"auto" | true | false`). Applies when no per-message or session fast-mode override is set.
- `models`: optional per-agent model catalog/runtime overrides keyed by full `provider/model` ids. Use `models["provider/model"].agentRuntime` for per-agent runtime exceptions.
- `runtime`: optional per-agent runtime descriptor. Use `type: "acp"` with `runtime.acp` defaults (`agent`, `backend`, `mode`, `cwd`) when the agent should default to ACP harness sessions.
- `identity.avatar`: workspace-relative path, `http(s)` URL, or `data:` URI.

View File

@@ -445,6 +445,7 @@ enumeration of `src/gateway/server-methods/*.ts`.
- `sessions.get` returns the full stored session row.
- Chat execution still uses `chat.history`, `chat.send`, `chat.abort`, and `chat.inject`. `chat.history` is display-normalized for UI clients: inline directive tags are stripped from visible text, plain-text tool-call XML payloads (including `<tool_call>...</tool_call>`, `<function_call>...</function_call>`, `<tool_calls>...</tool_calls>`, `<function_calls>...</function_calls>`, and truncated tool-call blocks) and leaked ASCII/full-width model control tokens are stripped, pure silent-token assistant rows such as exact `NO_REPLY` / `no_reply` are omitted, and oversized rows can be replaced with placeholders.
- `chat.message.get` is the additive bounded full-message reader for a single visible transcript entry. Clients pass `sessionKey`, optional `agentId` when the session selection is agent-scoped, plus a transcript `messageId` previously surfaced through `chat.history`, and the Gateway returns the same display-normalized projection without the lightweight history truncation cap when the stored entry is still available and not oversized.
- `chat.send` accepts one-turn `fastMode: "auto"` to use fast mode for model calls started before the auto cutoff, then start later retry, fallback, tool-result, or continuation calls without fast mode. The cutoff defaults to 60 seconds and can be configured per model with `agents.defaults.models["<provider>/<model>"].params.fastAutoOnSeconds`. A `chat.send` caller can pass one-turn `fastAutoOnSeconds` to override the cutoff for that request.
</Accordion>

View File

@@ -174,6 +174,7 @@ troubleshooting, see the main [FAQ](/help/faq).
- **Per session:** send `/fast on` while the session is using `openai/gpt-5.5`.
- **Per model default:** set `agents.defaults.models["openai/gpt-5.5"].params.fastMode` to `true`.
- **Automatic cutoff:** use `/fast auto` or `params.fastMode: "auto"` to start new model calls fast until the auto cutoff, then start later retry, fallback, tool-result, or continuation calls without fast mode. The cutoff defaults to 60 seconds; set `params.fastAutoOnSeconds` on the active model to change it.
Example:
@@ -184,7 +185,8 @@ troubleshooting, see the main [FAQ](/help/faq).
models: {
"openai/gpt-5.5": {
params: {
fastMode: true,
fastMode: "auto",
fastAutoOnSeconds: 30,
},
},
},
@@ -193,7 +195,7 @@ troubleshooting, see the main [FAQ](/help/faq).
}
```
For OpenAI, fast mode maps to `service_tier = "priority"` on supported native Responses requests. Session `/fast` overrides beat config defaults.
For OpenAI, fast mode maps to `service_tier = "priority"` on supported native Responses requests. Session `/fast` overrides beat config defaults. Codex app-server turns can only receive the tier at turn start, so `auto` applies on the next OpenClaw-started model turn rather than inside one already-running app-server turn.
See [Thinking and fast mode](/tools/thinking) and [OpenAI fast mode](/providers/openai#fast-mode).

View File

@@ -143,39 +143,12 @@ The native Codex app-server harness supports context engines that require
pre-prompt assembly. Generic CLI backends, including `codex-cli`, do not provide
that host capability.
Codex thread bindings live in OpenClaw's SQLite plugin state and use the stable
agent-scoped OpenClaw session key, or an opaque conversation-binding id, as
their owner. Physical session ids fence delayed cleanup but may rotate without
losing the Codex thread. Context-engine compaction adopts the successor id
before continuing native Codex compaction. The bounded store rejects a new
binding at its safety limit instead of evicting an existing thread's continuity
record.
Conversation binds create or resume their Codex thread on the first bound
message after channel approval; an abandoned approval consumes no thread row.
That first message carries the prepared thread directly into its turn.
Subsequent messages use a metadata-only resume to subscribe the shared client,
then unsubscribe after the turn completes.
The runtime does not poll transcript-adjacent binding files. Upgrades from
releases that used `*.jsonl.codex-app-server.json` sidecars migrate them during
normal startup preflight. `openclaw doctor --fix` can run the same migration
manually.
Successfully matched sidecars are archived before the new runtime resumes their
threads. Migration imports durable thread ownership only; it does not infer
Codex context usage from OpenClaw counters or crawl Codex rollout files. For
agent-session harness bindings, the next resume attempts to restore a cached
native snapshot when Codex has one, and ongoing turns persist the current-context
usage reported by app-server notifications, not the cumulative thread lifetime
total. Conversation bindings
keep metadata-only resumes and leave continuity and compaction with the native
Codex thread. Conflicting or ambiguous sidecars stay in place with a warning for
operator review.
For Codex-backed agents, `/compact` starts native Codex app-server compaction on
the bound thread. OpenClaw bounds the request-acceptance RPC but does not wait
for compaction completion, restart the shared app-server, or fall back to a
context-engine or public OpenAI summarizer. If the native Codex thread binding
is missing or stale, the command fails closed so the operator sees the real
runtime boundary instead of silently switching compaction backends.
the bound thread. OpenClaw does not wait for completion, impose an OpenClaw
timeout, restart the shared app-server, or fall back to a context-engine or
public OpenAI summarizer. If the native Codex thread binding is missing or
stale, the command fails closed so the operator sees the real runtime boundary
instead of silently switching compaction backends.
```json5
{

View File

@@ -291,7 +291,7 @@ Each entry lists the package, distribution route, and description.
- **[slack](/plugins/reference/slack)** (`@openclaw/slack`) - npm; ClawHub. OpenClaw Slack channel plugin for channels, DMs, commands, and app events.
- **[stepfun](/plugins/reference/stepfun)** (`@openclaw/stepfun-provider`) - npm. Adds StepFun, StepFun Plan model provider support to OpenClaw.
- **[stepfun](/plugins/reference/stepfun)** (`@openclaw/stepfun-provider`) - npm; ClawHub: `clawhub:@openclaw/stepfun-provider`. Adds StepFun, StepFun Plan model provider support to OpenClaw.
- **[synology-chat](/plugins/reference/synology-chat)** (`@openclaw/synology-chat`) - npm; ClawHub. Synology Chat channel plugin for OpenClaw channels and direct messages.

View File

@@ -12,7 +12,7 @@ Adds StepFun, StepFun Plan model provider support to OpenClaw.
## Distribution
- Package: `@openclaw/stepfun-provider`
- Install route: npm
- Install route: npm; ClawHub: `clawhub:@openclaw/stepfun-provider`
## Surface

View File

@@ -915,17 +915,17 @@ the Server-side compaction accordion below.
<Accordion title="Fast mode">
OpenClaw exposes a shared fast-mode toggle for `openai/*`:
- **Chat/UI:** `/fast status|on|off`
- **Chat/UI:** `/fast status|auto|on|off`
- **Config:** `agents.defaults.models["<provider>/<model>"].params.fastMode`
When enabled, OpenClaw maps fast mode to OpenAI priority processing (`service_tier = "priority"`). Existing `service_tier` values are preserved, and fast mode does not rewrite `reasoning` or `text.verbosity`.
When enabled, OpenClaw maps fast mode to OpenAI priority processing (`service_tier = "priority"`). Existing `service_tier` values are preserved, and fast mode does not rewrite `reasoning` or `text.verbosity`. `fastMode: "auto"` starts new model calls fast until the auto cutoff, then starts later retry, fallback, tool-result, or continuation calls without fast mode. The cutoff defaults to 60 seconds; set `params.fastAutoOnSeconds` on the active model to change it.
```json5
{
agents: {
defaults: {
models: {
"openai/gpt-5.5": { params: { fastMode: true } },
"openai/gpt-5.5": { params: { fastMode: "auto", fastAutoOnSeconds: 30 } },
},
},
},

View File

@@ -28,8 +28,10 @@ The provider includes:
| ------------------------------- | --------------------- |
| `opencode-go/glm-5` | GLM-5 |
| `opencode-go/glm-5.1` | GLM-5.1 |
| `opencode-go/glm-5.2` | GLM-5.2 |
| `opencode-go/kimi-k2.5` | Kimi K2.5 |
| `opencode-go/kimi-k2.6` | Kimi K2.6 (3x limits) |
| `opencode-go/kimi-k2.7-code` | Kimi K2.7 Code |
| `opencode-go/deepseek-v4-pro` | DeepSeek V4 Pro |
| `opencode-go/deepseek-v4-flash` | DeepSeek V4 Flash |
| `opencode-go/mimo-v2-omni` | MiMo V2 Omni |
@@ -39,6 +41,8 @@ The provider includes:
| `opencode-go/qwen3.5-plus` | Qwen3.5 Plus |
| `opencode-go/qwen3.6-plus` | Qwen3.6 Plus |
GLM-5.2 uses a 1M-token context window and supports up to 131K output tokens.
## Getting started
<Tabs>

View File

@@ -126,6 +126,11 @@ The manifest-backed catalog currently includes:
GLM models are available as `zai/<model>` (example: `zai/glm-5`).
</Tip>
<Tip>
GLM-5.2 supports `off`, `low`, `high`, and `max` thinking levels. OpenClaw maps
`low` and `high` to Z.AI high reasoning effort, and `max` to max effort.
</Tip>
<Note>
Coding Plan setup defaults to `zai/glm-5.2`; general API setup keeps
`zai/glm-5.1`. Endpoint auto-detection falls back to `glm-5.1` or `glm-4.7`

View File

@@ -228,9 +228,9 @@ release state.
`OpenClaw Release Checks` for install smoke, package acceptance, cross-OS
package checks, QA Lab parity, Matrix, and Telegram lanes. Stable and full
runs always include exhaustive live/E2E and Docker release-path soak;
`run_release_soak=true` is retained for an explicit beta soak. With
`release_profile=full` and `rerun_group=all`, it also runs package Telegram
E2E against the `release-package-under-test` artifact from release checks.
`run_release_soak=true` is retained for an explicit beta soak. Package
Acceptance provides the canonical package Telegram E2E during candidate
validation, avoiding a second concurrent live poller.
Provide `release_package_spec` after publishing a beta to reuse the shipped
npm package across release checks, Package Acceptance, and package Telegram
E2E without rebuilding the release tarball. Provide
@@ -460,20 +460,16 @@ gh workflow run full-release-validation.yml \
```
The workflow resolves the target ref, dispatches manual `CI` with
`target_ref=<release-ref>`, dispatches `OpenClaw Release Checks`, prepares a
parent `release-package-under-test` artifact for package-facing checks, and
dispatches standalone package Telegram E2E when `release_profile=full` with
`rerun_group=all` or when `release_package_spec` or
`npm_telegram_package_spec` is set. `OpenClaw Release
Checks` then fans out install smoke, cross-OS release checks, live/E2E Docker
release-path coverage when soak is enabled, Package Acceptance with Telegram
package QA, QA Lab parity, live Matrix, and live Telegram. A full/all run is
only acceptable when the `Full Release Validation` summary shows `normal_ci`,
`plugin_prerelease`, and `release_checks` as successful, unless a focused rerun
intentionally skipped the separate `Plugin Prerelease` child. In full/all mode,
the `npm_telegram` child must also be successful; outside full/all it is skipped
unless a published `release_package_spec` or `npm_telegram_package_spec` was
provided. The final
`target_ref=<release-ref>`, then dispatches `OpenClaw Release Checks`.
`OpenClaw Release Checks` fans out install smoke, cross-OS release checks,
live/E2E Docker release-path coverage when soak is enabled, Package Acceptance
with the canonical Telegram package E2E, QA Lab parity, live Matrix, and live
Telegram. A full/all run is only acceptable when the `Full Release Validation`
summary shows `normal_ci`, `plugin_prerelease`, and `release_checks` as
successful, unless a focused rerun intentionally skipped the separate `Plugin
Prerelease` child. Use the standalone `npm-telegram` child only for a focused
published-package rerun with `release_package_spec` or
`npm_telegram_package_spec`. The final
verifier summary includes slowest-job tables for each child run, so the release
manager can see the current critical path without downloading logs.
See [Full release validation](/reference/full-release-validation) for the
@@ -558,8 +554,8 @@ runs only the release-only plugin child, `release-checks` runs every release
box, and the narrower release groups are `install-smoke`, `cross-os`,
`live-e2e`, `package`, `qa`, `qa-parity`, `qa-live`, and `npm-telegram`.
Focused `npm-telegram` reruns require `release_package_spec` or
`npm_telegram_package_spec`; full/all runs with `release_profile=full` use the
release-checks package artifact. Focused
`npm_telegram_package_spec`; full/all runs use the canonical package Telegram
E2E inside Package Acceptance. Focused
cross-OS reruns can add `cross_os_suite_filter=windows/packaged-upgrade` or
another OS/suite filter. QA release-check failures block normal release
validation, including required OpenClaw dynamic tool drift in the standard tier.

View File

@@ -53,8 +53,7 @@ that plugin, then runs Codex CLI preflight and same-session OpenAI agent turns.
| Vitest and normal CI | **Job:** `Run normal full CI`<br />**Child workflow:** `CI`<br />**Proves:** manual full CI graph against the target ref, including Linux Node lanes, bundled plugin shards, plugin and channel contract shards, Node 22 compatibility, `check-*`, `check-additional-*`, built-artifact smoke checks, docs checks, Python skills, Windows, macOS, Control UI i18n, and Android via the umbrella.<br />**Rerun:** `rerun_group=ci`. |
| Plugin prerelease | **Job:** `Run plugin prerelease validation`<br />**Child workflow:** `Plugin Prerelease`<br />**Proves:** release-only plugin static checks, agentic plugin coverage, full extension batch shards, plugin prerelease Docker lanes, and a non-blocking `plugin-inspector-advisory` artifact for compatibility triage.<br />**Rerun:** `rerun_group=plugin-prerelease`. |
| Release checks | **Job:** `Run release/live/Docker/QA validation`<br />**Child workflow:** `OpenClaw Release Checks`<br />**Proves:** install smoke, cross-OS package checks, Package Acceptance, QA Lab parity, live Matrix, and live Telegram. Stable and full profiles also run exhaustive live/E2E suites and Docker release-path chunks; beta can opt in with `run_release_soak=true`.<br />**Rerun:** `rerun_group=release-checks` or a narrower release-checks handle. |
| Package artifact | **Job:** `Prepare release package artifact`<br />**Child workflow:** none<br />**Proves:** creates the parent `release-package-under-test` tarball early enough for package-facing checks that do not need to wait for `OpenClaw Release Checks`.<br />**Rerun:** rerun the umbrella or provide `release_package_spec` for published-package reruns. |
| Package Telegram | **Job:** `Run package Telegram E2E`<br />**Child workflow:** `NPM Telegram Beta E2E`<br />**Proves:** parent-artifact-backed Telegram package proof for `rerun_group=all` with `release_profile=full`, or published-package Telegram proof when `release_package_spec` or `npm_telegram_package_spec` is set.<br />**Rerun:** `rerun_group=npm-telegram` with `release_package_spec` or `npm_telegram_package_spec`. |
| Package Telegram | **Job:** `Run package Telegram E2E`<br />**Child workflow:** `NPM Telegram Beta E2E`<br />**Proves:** a focused published-package Telegram E2E when `release_package_spec` or `npm_telegram_package_spec` is set. Full candidate validation uses the canonical Package Acceptance Telegram E2E instead.<br />**Rerun:** `rerun_group=npm-telegram` with `release_package_spec` or `npm_telegram_package_spec`. |
| Umbrella verifier | **Job:** `Verify full validation`<br />**Child workflow:** none<br />**Proves:** re-checks recorded child run conclusions and appends slowest-job tables from child workflows.<br />**Rerun:** rerun only this job after rerunning a failed child to green. |
For `ref=main` and `rerun_group=all`, a newer umbrella supersedes an older one.
@@ -76,7 +75,7 @@ or Docker-facing stages need it.
| Cross-OS | **Job:** `cross_os_release_checks`<br />**Backing workflow:** `OpenClaw Cross-OS Release Checks (Reusable)`<br />**Tests:** fresh and upgrade lanes on Linux, Windows, and macOS for the selected provider and mode, using the candidate tarball plus a baseline package.<br />**Rerun:** `rerun_group=cross-os`. |
| Repo and live E2E | **Job:** `Run repo/live E2E validation`<br />**Backing workflow:** `OpenClaw Live And E2E Checks (Reusable)`<br />**Tests:** repository E2E, live cache, OpenAI websocket streaming, native live provider and plugin shards, and Docker-backed live model/backend/gateway harnesses selected by `release_profile`.<br />**Runs:** `run_release_soak=true`, `release_profile=full`, or focused `rerun_group=live-e2e`.<br />**Rerun:** `rerun_group=live-e2e`, optionally with `live_suite_filter`. |
| Docker release path | **Job:** `Run Docker release-path validation`<br />**Backing workflow:** `OpenClaw Live And E2E Checks (Reusable)`<br />**Tests:** release-path Docker chunks against the shared package artifact.<br />**Runs:** `run_release_soak=true`, `release_profile=full`, or focused `rerun_group=live-e2e`.<br />**Rerun:** `rerun_group=live-e2e`. |
| Package Acceptance | **Job:** `Run package acceptance`<br />**Backing workflow:** `Package Acceptance`<br />**Tests:** offline plugin package fixtures, plugin update, mock-OpenAI Telegram package acceptance, and published-upgrade survivor checks against the same tarball. Blocking release checks use the default latest published baseline; soak checks expand to every stable npm release at or after `2026.4.23` plus reported-issue fixtures.<br />**Rerun:** `rerun_group=package`. |
| Package Acceptance | **Job:** `Run package acceptance`<br />**Backing workflow:** `Package Acceptance`<br />**Tests:** offline plugin package fixtures, plugin update, the canonical mock-OpenAI Telegram package E2E, and published-upgrade survivor checks against the same tarball. Blocking release checks use the default latest published baseline; soak checks expand to every stable npm release at or after `2026.4.23` plus reported-issue fixtures.<br />**Rerun:** `rerun_group=package`. |
| QA parity | **Job:** `Run QA Lab parity lane` and `Run QA Lab parity report`<br />**Backing workflow:** direct jobs<br />**Tests:** candidate and baseline agentic parity packs, then the parity report.<br />**Rerun:** `rerun_group=qa-parity` or `rerun_group=qa`. |
| QA live Matrix | **Job:** `Run QA Lab live Matrix lane`<br />**Backing workflow:** direct job<br />**Tests:** fast live Matrix QA profile in the `qa-live-shared` environment.<br />**Rerun:** `rerun_group=qa-live` or `rerun_group=qa`. |
| QA live Telegram | **Job:** `Run QA Lab live Telegram lane`<br />**Backing workflow:** direct job<br />**Tests:** live Telegram QA with Convex CI credential leases.<br />**Rerun:** `rerun_group=qa-live` or `rerun_group=qa`. |
@@ -107,9 +106,9 @@ commands with package artifact and image reuse inputs when available.
It does not remove normal full CI, Plugin Prerelease, install smoke, package
acceptance, or QA Lab. Stable and full profiles always run exhaustive repo/live
E2E and Docker release-path soak coverage. The beta profile can opt in with
`run_release_soak=true`. The full profile also makes the umbrella run package
Telegram E2E against the parent release package artifact when `rerun_group=all`,
so a full pre-publish candidate does not silently skip that Telegram package lane.
`run_release_soak=true`. Package Acceptance supplies the canonical package
Telegram E2E for every full candidate, so the umbrella does not duplicate that
live poller.
| Profile | Intended use | Included live/provider coverage |
| --------- | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -189,7 +188,7 @@ workflow first, then rerun the smallest matching handle above.
Useful artifacts:
- `release-package-under-test` from the Full Release Validation parent and `OpenClaw Release Checks`
- `release-package-under-test` from `OpenClaw Release Checks`
- Docker release-path artifacts under `.artifacts/docker-tests/`
- Package Acceptance `package-under-test` and Docker acceptance artifacts
- Cross-OS release-check artifacts for each OS and suite

View File

@@ -198,7 +198,7 @@ plugins.
| `/think <level\|default>` | Set the thinking level or clear the session override. Aliases: `/thinking`, `/t` |
| `/verbose on\|off\|full` | Toggle verbose output. Alias: `/v` |
| `/trace on\|off` | Toggle plugin trace output for the current session |
| `/fast [status\|on\|off\|default]` | Show, set, or clear fast mode |
| `/fast [status\|auto\|on\|off\|default]` | Show, set, or clear fast mode |
| `/reasoning [on\|off\|stream]` | Toggle reasoning visibility. Alias: `/reason` |
| `/elevated [on\|off\|ask\|full]` | Toggle elevated mode. Alias: `/elev` |
| `/exec host=<auto\|sandbox\|gateway\|node> security=<deny\|allowlist\|full> ask=<off\|on-miss\|always> node=<id>` | Show or set exec defaults |
@@ -211,7 +211,7 @@ plugins.
<Accordion title="verbose / trace / fast / reasoning safety">
- `/verbose` is for debugging — keep it **off** in normal use.
- `/trace` reveals only plugin-owned trace/debug lines; normal verbose chatter stays off.
- `/fast on|off` persists a session override; use the Sessions UI `inherit` option to clear it.
- `/fast auto|on|off` persists a session override; use the Sessions UI `inherit` option to clear it.
- `/fast` is provider-specific: OpenAI/Codex map it to `service_tier=priority`; direct Anthropic requests map it to `service_tier=auto` or `standard_only`.
- `/reasoning`, `/verbose`, and `/trace` are risky in group settings — they may reveal internal reasoning or plugin diagnostics. Keep them off in group chats.

View File

@@ -34,7 +34,7 @@ title: "Thinking levels"
- Stale configured OpenRouter Hunter Alpha refs skip proxy reasoning injection because that retired route could return final answer text through reasoning fields.
- Google Gemini maps `/think adaptive` to Gemini's provider-owned dynamic thinking. Gemini 3 requests omit a fixed `thinkingLevel`, while Gemini 2.5 requests send `thinkingBudget: -1`; fixed levels still map to the closest Gemini `thinkingLevel` or budget for that model family.
- MiniMax M2.x (`minimax/MiniMax-M2*`) on the Anthropic-compatible streaming path defaults to `thinking: { type: "disabled" }` unless you explicitly set thinking in model params or request params. This avoids leaked `reasoning_content` deltas from M2.x's non-native Anthropic stream format. MiniMax-M3 (and M3.x) is exempt: M3 emits proper Anthropic thinking blocks and returns empty content when thinking is disabled, so OpenClaw keeps M3 on the provider's omitted/adaptive thinking path.
- Z.AI (`zai/*`) only supports binary thinking (`on`/`off`). Any non-`off` level is treated as `on` (mapped to `low`).
- Z.AI (`zai/*`) is binary (`on`/`off`) for most GLM models. GLM-5.2 is the exception: it exposes `/think off|low|high|max`, maps `low` and `high` to Z.AI `reasoning_effort: "high"`, and maps `max` to `reasoning_effort: "max"`.
- Moonshot Kimi K2.7 Code (`moonshot/kimi-k2.7-code`) always thinks. Its profile exposes only `on`, and OpenClaw omits the outbound `thinking` field as required by Moonshot. Other `moonshot/*` models map `/think off` to `thinking: { type: "disabled" }` and any non-`off` level to `thinking: { type: "enabled" }`. When thinking is enabled, Moonshot only accepts `tool_choice` `auto|none`; OpenClaw normalizes incompatible values to `auto`.
## Resolution order
@@ -60,21 +60,22 @@ title: "Thinking levels"
## Fast mode (/fast)
- Levels: `on|off|default`.
- Directive-only message toggles a session fast-mode override and replies `Fast mode enabled.` / `Fast mode disabled.`. Use `/fast default` to clear the session override and inherit the configured default; aliases include `inherit`, `clear`, `reset`, and `unpin`.
- Levels: `auto|on|off|default`.
- Directive-only message toggles a session fast-mode override and replies `Fast mode set to auto.`, `Fast mode enabled.`, or `Fast mode disabled.`. Use `/fast default` to clear the session override and inherit the configured default; aliases include `inherit`, `clear`, `reset`, and `unpin`.
- Send `/fast` (or `/fast status`) with no mode to see the current effective fast-mode state.
- OpenClaw resolves fast mode in this order:
1. Inline/directive-only `/fast on|off` override (`/fast default` clears this layer)
1. Inline/directive-only `/fast auto|on|off` override (`/fast default` clears this layer)
2. Session override
3. Per-agent default (`agents.list[].fastModeDefault`)
4. Per-model config: `agents.defaults.models["<provider>/<model>"].params.fastMode`
5. Fallback: `off`
- `auto` keeps the session/config mode as auto but resolves each new model call independently. Calls that start before the auto cutoff have fast mode enabled; later retry, fallback, tool-result, or continuation calls start with fast mode disabled. The cutoff defaults to 60 seconds; set `agents.defaults.models["<provider>/<model>"].params.fastAutoOnSeconds` on the active model to change it.
- For `openai/*`, fast mode maps to OpenAI priority processing by sending `service_tier=priority` on supported Responses requests.
- For Codex-backed `openai/*` models, fast mode sends the same `service_tier=priority` flag on Codex Responses. OpenClaw keeps one shared `/fast` toggle across both auth paths.
- For Codex-backed `openai/*` / `openai-codex/*` models, fast mode sends the same `service_tier=priority` flag on Codex Responses. Native Codex app-server turns receive the tier only on `turn/start` or thread start/resume, so `auto` cannot retier one already-running app-server turn; it applies to the next model turn OpenClaw starts.
- For direct public `anthropic/*` requests, including OAuth-authenticated traffic sent to `api.anthropic.com`, fast mode maps to Anthropic service tiers: `/fast on` sets `service_tier=auto`, `/fast off` sets `service_tier=standard_only`.
- For `minimax/*` on the Anthropic-compatible path, `/fast on` (or `params.fastMode: true`) rewrites `MiniMax-M2.7` to `MiniMax-M2.7-highspeed`.
- Explicit Anthropic `serviceTier` / `service_tier` model params override the fast-mode default when both are set. OpenClaw still skips Anthropic service-tier injection for non-Anthropic proxy base URLs.
- `/status` shows `Fast` only when fast mode is enabled.
- `/status` shows `Fast` when fast mode is enabled and `Fast:auto` when the configured mode is auto.
## Verbose directives (/verbose or /v)

View File

@@ -1,12 +1,12 @@
{
"name": "@openclaw/acpx",
"version": "2026.6.8",
"version": "2026.6.10-beta.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/acpx",
"version": "2026.6.8",
"version": "2026.6.10-beta.2",
"dependencies": {
"@agentclientprotocol/claude-agent-acp": "0.39.0",
"@zed-industries/codex-acp": "0.15.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/acpx",
"version": "2026.6.8",
"version": "2026.6.10-beta.2",
"description": "OpenClaw ACP runtime backend with plugin-owned session and transport management.",
"repository": {
"type": "git",
@@ -26,10 +26,10 @@
"minHostVersion": ">=2026.4.25"
},
"compat": {
"pluginApi": ">=2026.6.8"
"pluginApi": ">=2026.6.10-beta.2"
},
"build": {
"openclawVersion": "2026.6.8",
"openclawVersion": "2026.6.10-beta.2",
"staticAssets": [
{
"source": "./src/runtime-internals/mcp-proxy.mjs",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/admin-http-rpc",
"version": "2026.6.8",
"version": "2026.6.10-beta.2",
"private": true,
"description": "OpenClaw admin HTTP RPC endpoint",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/alibaba-provider",
"version": "2026.6.8",
"version": "2026.6.10-beta.2",
"private": true,
"description": "OpenClaw Alibaba Model Studio video provider plugin",
"type": "module",

View File

@@ -1,12 +1,12 @@
{
"name": "@openclaw/amazon-bedrock-mantle-provider",
"version": "2026.6.8",
"version": "2026.6.10-beta.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/amazon-bedrock-mantle-provider",
"version": "2026.6.8",
"version": "2026.6.10-beta.2",
"dependencies": {
"@anthropic-ai/sdk": "0.100.1",
"@aws/bedrock-token-generator": "1.1.0"

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/amazon-bedrock-mantle-provider",
"version": "2026.6.8",
"version": "2026.6.10-beta.2",
"description": "OpenClaw Amazon Bedrock Mantle provider plugin for OpenAI-compatible model routing.",
"repository": {
"type": "git",
@@ -24,10 +24,10 @@
"minHostVersion": ">=2026.5.12-beta.1"
},
"compat": {
"pluginApi": ">=2026.6.8"
"pluginApi": ">=2026.6.10-beta.2"
},
"build": {
"openclawVersion": "2026.6.8",
"openclawVersion": "2026.6.10-beta.2",
"bundledDist": false
},
"release": {

View File

@@ -1,12 +1,12 @@
{
"name": "@openclaw/amazon-bedrock-provider",
"version": "2026.6.8",
"version": "2026.6.10-beta.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/amazon-bedrock-provider",
"version": "2026.6.8",
"version": "2026.6.10-beta.2",
"dependencies": {
"@aws-sdk/client-bedrock": "3.1056.0",
"@aws-sdk/client-bedrock-runtime": "3.1056.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/amazon-bedrock-provider",
"version": "2026.6.8",
"version": "2026.6.10-beta.2",
"description": "OpenClaw Amazon Bedrock provider plugin with model discovery, embeddings, and guardrail support.",
"repository": {
"type": "git",
@@ -28,10 +28,10 @@
"minHostVersion": ">=2026.5.12-beta.1"
},
"compat": {
"pluginApi": ">=2026.6.8"
"pluginApi": ">=2026.6.10-beta.2"
},
"build": {
"openclawVersion": "2026.6.8",
"openclawVersion": "2026.6.10-beta.2",
"bundledDist": false
},
"release": {

View File

@@ -1,12 +1,12 @@
{
"name": "@openclaw/anthropic-vertex-provider",
"version": "2026.6.8",
"version": "2026.6.10-beta.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/anthropic-vertex-provider",
"version": "2026.6.8",
"version": "2026.6.10-beta.2",
"dependencies": {
"@anthropic-ai/vertex-sdk": "0.16.1"
}

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/anthropic-vertex-provider",
"version": "2026.6.8",
"version": "2026.6.10-beta.2",
"description": "OpenClaw Anthropic Vertex provider plugin for Claude models on Google Vertex AI.",
"repository": {
"type": "git",
@@ -23,10 +23,10 @@
"minHostVersion": ">=2026.5.12-beta.1"
},
"compat": {
"pluginApi": ">=2026.6.8"
"pluginApi": ">=2026.6.10-beta.2"
},
"build": {
"openclawVersion": "2026.6.8",
"openclawVersion": "2026.6.10-beta.2",
"bundledDist": false
},
"release": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/anthropic-provider",
"version": "2026.6.8",
"version": "2026.6.10-beta.2",
"private": true,
"description": "OpenClaw Anthropic provider plugin",
"type": "module",

View File

@@ -8,6 +8,7 @@ import {
createAnthropicServiceTierWrapper,
createAnthropicThinkingPrefillWrapper,
resolveAnthropicBetas,
resolveAnthropicFastMode,
wrapAnthropicProviderStream,
} from "./stream-wrappers.js";
@@ -172,6 +173,10 @@ describe("anthropic stream wrappers", () => {
expect(captured.headers?.["anthropic-beta"]).toContain(OAUTH_BETA);
expect(captured.headers?.["anthropic-beta"]).not.toContain(CONTEXT_1M_BETA);
});
it("ignores unresolved auto fast mode at the provider boundary", () => {
expect(resolveAnthropicFastMode({ fastMode: "auto" })).toBeUndefined();
});
});
describe("createAnthropicThinkingPrefillWrapper", () => {
@@ -282,6 +287,19 @@ describe("Anthropic service_tier payload wrappers", () => {
expect(payload?.service_tier).toBe("standard_only");
});
it("fast mode resolves dynamic service_tier for each stream call", () => {
let enabled = true;
const first = runPayloadWrapper({ apiKey: "sk-ant-api03-test-key" }, (base) =>
createAnthropicFastModeWrapper(base, () => enabled),
);
enabled = false;
const second = runPayloadWrapper({ apiKey: "sk-ant-api03-test-key" }, (base) =>
createAnthropicFastModeWrapper(base, () => enabled),
);
expect(first?.service_tier).toBe("auto");
expect(second?.service_tier).toBe("standard_only");
});
it("explicit service tier injects service_tier=standard_only for regular API keys", () => {
const payload = serviceTierWrapperCases[1].run({
apiKey: "sk-ant-api03-test-key",

View File

@@ -44,6 +44,7 @@ const OPENCLAW_OAUTH_ANTHROPIC_BETAS = [
] as const;
type AnthropicServiceTier = "auto" | "standard_only";
type DynamicFastMode = boolean | (() => boolean | undefined);
function isAnthropic1MModel(modelId: string): boolean {
const normalized = normalizeLowercaseStringOrEmpty(modelId);
@@ -157,9 +158,20 @@ export function createAnthropicBetaHeadersWrapper(
/** Wrap a stream function with the Anthropic fast-mode service tier. */
export function createAnthropicFastModeWrapper(
baseStreamFn: StreamFn | undefined,
enabled: boolean,
enabled: DynamicFastMode,
): StreamFn {
return createAnthropicServiceTierWrapper(baseStreamFn, resolveAnthropicFastServiceTier(enabled));
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
const resolved = typeof enabled === "function" ? enabled() : enabled;
if (resolved === undefined) {
return underlying(model, context, options);
}
return createAnthropicServiceTierWrapper(underlying, resolveAnthropicFastServiceTier(resolved))(
model,
context,
options,
);
};
}
/** Wrap a stream function with an explicit Anthropic service tier when allowed. */
@@ -204,9 +216,12 @@ export function createAnthropicThinkingPrefillWrapper(
export function resolveAnthropicFastMode(
extraParams: Record<string, unknown> | undefined,
): boolean | undefined {
return normalizeFastMode(
(extraParams?.fastMode ?? extraParams?.fast_mode) as string | boolean | null | undefined,
);
const raw = extraParams?.fastMode ?? extraParams?.fast_mode;
const fastMode =
typeof raw === "function"
? normalizeFastMode((raw as () => unknown)() as string | boolean | null | undefined)
: normalizeFastMode(raw as string | boolean | null | undefined);
return fastMode === "auto" ? undefined : fastMode;
}
/** Resolve Anthropic service tier from model extra params. */
@@ -232,7 +247,9 @@ export function wrapAnthropicProviderStream(
hasConfiguredAnthropicBeta(ctx.extraParams) ||
(ctx.extraParams?.context1m === true && isAnthropic1MModel(ctx.modelId));
const serviceTier = resolveAnthropicServiceTier(ctx.extraParams);
const fastMode = resolveAnthropicFastMode(ctx.extraParams);
const hasFastModeParam =
ctx.extraParams !== undefined &&
(Object.hasOwn(ctx.extraParams, "fastMode") || Object.hasOwn(ctx.extraParams, "fast_mode"));
return composeProviderStreamWrappers(
ctx.streamFn,
needsAnthropicBetaWrapper
@@ -241,8 +258,9 @@ export function wrapAnthropicProviderStream(
serviceTier
? (streamFn) => createAnthropicServiceTierWrapper(streamFn, serviceTier)
: undefined,
fastMode !== undefined
? (streamFn) => createAnthropicFastModeWrapper(streamFn, fastMode)
hasFastModeParam
? (streamFn) =>
createAnthropicFastModeWrapper(streamFn, () => resolveAnthropicFastMode(ctx.extraParams))
: undefined,
(streamFn) => createAnthropicThinkingPrefillWrapper(streamFn),
);

View File

@@ -1,12 +1,12 @@
{
"name": "@openclaw/arcee-provider",
"version": "2026.6.8",
"version": "2026.6.10-beta.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/arcee-provider",
"version": "2026.6.8"
"version": "2026.6.10-beta.2"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/arcee-provider",
"version": "2026.6.8",
"version": "2026.6.10-beta.2",
"description": "OpenClaw Arcee provider plugin.",
"repository": {
"type": "git",
@@ -21,10 +21,10 @@
"minHostVersion": ">=2026.6.8"
},
"compat": {
"pluginApi": ">=2026.6.8"
"pluginApi": ">=2026.6.10-beta.2"
},
"build": {
"openclawVersion": "2026.6.8",
"openclawVersion": "2026.6.10-beta.2",
"bundledDist": false
},
"release": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/azure-speech",
"version": "2026.6.8",
"version": "2026.6.10-beta.2",
"private": true,
"description": "OpenClaw Azure Speech plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/bonjour",
"version": "2026.6.8",
"version": "2026.6.10-beta.2",
"description": "OpenClaw Bonjour/mDNS gateway discovery",
"type": "module",
"dependencies": {

View File

@@ -1,12 +1,12 @@
{
"name": "@openclaw/brave-plugin",
"version": "2026.6.8",
"version": "2026.6.10-beta.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/brave-plugin",
"version": "2026.6.8"
"version": "2026.6.10-beta.2"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/brave-plugin",
"version": "2026.6.8",
"version": "2026.6.10-beta.2",
"description": "OpenClaw Brave Search provider plugin for web search.",
"repository": {
"type": "git",
@@ -21,10 +21,10 @@
"allowInvalidConfigRecovery": true
},
"compat": {
"pluginApi": ">=2026.6.8"
"pluginApi": ">=2026.6.10-beta.2"
},
"build": {
"openclawVersion": "2026.6.8"
"openclawVersion": "2026.6.10-beta.2"
},
"release": {
"publishToClawHub": true,

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/browser-plugin",
"version": "2026.6.8",
"version": "2026.6.10-beta.2",
"private": true,
"description": "OpenClaw browser tool plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/byteplus-provider",
"version": "2026.6.8",
"version": "2026.6.10-beta.2",
"private": true,
"description": "OpenClaw BytePlus provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/canvas-plugin",
"version": "2026.6.8",
"version": "2026.6.10-beta.2",
"private": true,
"description": "OpenClaw Canvas plugin",
"type": "module",

View File

@@ -1,12 +1,12 @@
{
"name": "@openclaw/cerebras-provider",
"version": "2026.6.8",
"version": "2026.6.10-beta.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/cerebras-provider",
"version": "2026.6.8"
"version": "2026.6.10-beta.2"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/cerebras-provider",
"version": "2026.6.8",
"version": "2026.6.10-beta.2",
"description": "OpenClaw Cerebras provider plugin.",
"repository": {
"type": "git",
@@ -21,10 +21,10 @@
"minHostVersion": ">=2026.6.8"
},
"compat": {
"pluginApi": ">=2026.6.8"
"pluginApi": ">=2026.6.10-beta.2"
},
"build": {
"openclawVersion": "2026.6.8",
"openclawVersion": "2026.6.10-beta.2",
"bundledDist": false
},
"release": {

View File

@@ -1,12 +1,12 @@
{
"name": "@openclaw/chutes-provider",
"version": "2026.6.8",
"version": "2026.6.10-beta.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/chutes-provider",
"version": "2026.6.8"
"version": "2026.6.10-beta.2"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/chutes-provider",
"version": "2026.6.8",
"version": "2026.6.10-beta.2",
"description": "OpenClaw Chutes.ai provider plugin.",
"repository": {
"type": "git",
@@ -21,10 +21,10 @@
"minHostVersion": ">=2026.6.8"
},
"compat": {
"pluginApi": ">=2026.6.8"
"pluginApi": ">=2026.6.10-beta.2"
},
"build": {
"openclawVersion": "2026.6.8",
"openclawVersion": "2026.6.10-beta.2",
"bundledDist": false
},
"release": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/clickclack",
"version": "2026.6.8",
"version": "2026.6.10-beta.2",
"private": true,
"description": "OpenClaw ClickClack channel plugin",
"type": "module",
@@ -18,7 +18,7 @@
"openclaw": "2026.5.28"
},
"peerDependencies": {
"openclaw": ">=2026.6.8"
"openclaw": ">=2026.6.10-beta.2"
},
"peerDependenciesMeta": {
"openclaw": {

View File

@@ -1,12 +1,12 @@
{
"name": "@openclaw/cloudflare-ai-gateway-provider",
"version": "2026.6.8",
"version": "2026.6.10-beta.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/cloudflare-ai-gateway-provider",
"version": "2026.6.8"
"version": "2026.6.10-beta.2"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/cloudflare-ai-gateway-provider",
"version": "2026.6.8",
"version": "2026.6.10-beta.2",
"description": "OpenClaw Cloudflare AI Gateway provider plugin.",
"repository": {
"type": "git",
@@ -21,10 +21,10 @@
"minHostVersion": ">=2026.6.8"
},
"compat": {
"pluginApi": ">=2026.6.8"
"pluginApi": ">=2026.6.10-beta.2"
},
"build": {
"openclawVersion": "2026.6.8",
"openclawVersion": "2026.6.10-beta.2",
"bundledDist": false
},
"release": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/codex-supervisor",
"version": "2026.6.8",
"version": "2026.6.10-beta.2",
"private": true,
"description": "OpenClaw Codex app-server fleet supervision plugin.",
"type": "module",

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,7 @@
/** Doctor contract hooks for Codex config, state migration, and route ownership. */
/**
* Doctor contract hooks for Codex plugin config migrations and session-route
* ownership warnings.
*/
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { DoctorSessionRouteStateOwner } from "openclaw/plugin-sdk/runtime-doctor";
@@ -48,7 +51,9 @@ export const legacyConfigRules: LegacyConfigRule[] = [
},
];
/** Removes retired Codex plugin config keys while preserving unrelated config. */
/**
* Removes retired Codex plugin config keys while preserving unrelated config.
*/
export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }): {
config: OpenClawConfig;
changes: string[];
@@ -66,9 +71,10 @@ export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }):
const nextConfig = structuredClone(cfg) as OpenClawConfig & {
plugins?: Record<string, unknown>;
};
const nextPluginConfig = asRecord(
asRecord(asRecord(asRecord(nextConfig.plugins)?.entries)?.codex)?.config,
);
const nextPlugins = asRecord(nextConfig.plugins);
const nextEntries = asRecord(nextPlugins?.entries);
const nextEntry = asRecord(nextEntries?.codex);
const nextPluginConfig = asRecord(nextEntry?.config);
if (!nextPluginConfig) {
return { config: cfg, changes: [] };
}
@@ -115,5 +121,3 @@ export const sessionRouteStateOwners: DoctorSessionRouteStateOwner[] = [
authProfilePrefixes: ["codex:", "codex-cli:", "openai-codex:"],
},
];
export { stateMigrations } from "./src/migration/session-binding-sidecars.js";

View File

@@ -1,18 +1,9 @@
// Codex tests cover harness plugin behavior.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { createCodexAppServerAgentHarness } from "./harness.js";
import {
createCodexTestBindingStore,
testCodexAppServerBindingStore,
} from "./src/app-server/session-binding.test-helpers.js";
describe("Codex agent harness supports()", () => {
const harness = createCodexAppServerAgentHarness({
bindingStore: testCodexAppServerBindingStore,
});
const harness = createCodexAppServerAgentHarness();
it("supports the canonical codex virtual provider", () => {
expect(harness.supports({ provider: "codex", requestedRuntime: "codex" })).toEqual({
@@ -49,149 +40,8 @@ describe("Codex agent harness supports()", () => {
});
it("honors explicit provider id overrides", () => {
const narrowHarness = createCodexAppServerAgentHarness({
bindingStore: testCodexAppServerBindingStore,
providerIds: ["codex"],
});
const narrowHarness = createCodexAppServerAgentHarness({ providerIds: ["codex"] });
const result = narrowHarness.supports({ provider: "openai", requestedRuntime: "codex" });
expect(result.supported).toBe(false);
});
});
describe("Codex agent harness reset", () => {
it("uses the host agent for global session keys", async () => {
const bindingStore = createCodexTestBindingStore();
const harness = createCodexAppServerAgentHarness({ bindingStore });
const identity = {
kind: "session" as const,
agentId: "work",
sessionId: "session-1",
sessionKey: "global",
};
await bindingStore.mutate(identity, {
kind: "set",
binding: { threadId: "thread-work", cwd: "/repo" },
});
await harness.reset?.({
agentId: "work",
sessionId: "session-1",
sessionKey: "global",
reason: "reset",
});
await expect(bindingStore.read(identity)).resolves.toBeUndefined();
await expect(
bindingStore.mutate(identity, {
kind: "set",
binding: { threadId: "thread-stale", cwd: "/stale" },
}),
).resolves.toBe(false);
const nextIdentity = { ...identity, sessionId: "session-2" };
await expect(
bindingStore.mutate(nextIdentity, {
kind: "set",
binding: { threadId: "thread-next", cwd: "/next" },
}),
).resolves.toBe(false);
await expect(
bindingStore.mutate(nextIdentity, {
kind: "reclaim-generation",
expectedPreviousSessionId: identity.sessionId,
}),
).resolves.toBe(true);
await expect(
bindingStore.mutate(nextIdentity, {
kind: "set",
binding: { threadId: "thread-next", cwd: "/next" },
}),
).resolves.toBe(true);
await expect(bindingStore.read(nextIdentity)).resolves.toMatchObject({
threadId: "thread-next",
});
});
it("accepts an absent binding but rejects a mismatched reset generation", async () => {
const bindingStore = createCodexTestBindingStore();
const harness = createCodexAppServerAgentHarness({ bindingStore });
const current = {
kind: "session" as const,
agentId: "main",
sessionId: "session-1",
sessionKey: "agent:main:main",
};
await expect(
harness.reset?.({
agentId: "main",
sessionId: "missing-session",
sessionKey: "agent:main:missing",
reason: "reset",
}),
).resolves.toBeUndefined();
await bindingStore.mutate(current, {
kind: "set",
binding: { threadId: "thread-1", cwd: "/repo" },
});
await expect(
harness.reset?.({
agentId: "main",
sessionId: "session-2",
sessionKey: current.sessionKey,
reason: "reset",
}),
).rejects.toThrow("binding generation changed");
await expect(bindingStore.read(current)).resolves.toMatchObject({ threadId: "thread-1" });
});
it("reclaims a stale generation left while the Codex plugin was unavailable", async () => {
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-reset-"));
const storePath = path.join(stateDir, "sessions.json");
const sessionKey = "agent:main:main";
await fs.writeFile(
storePath,
JSON.stringify({
[sessionKey]: {
sessionId: "session-2",
updatedAt: Date.now(),
},
}),
"utf8",
);
const bindingStore = createCodexTestBindingStore();
const harness = createCodexAppServerAgentHarness({
bindingStore,
resolveConfig: () => ({ session: { store: storePath } }),
});
const stale = {
kind: "session" as const,
agentId: "main",
sessionId: "session-1",
sessionKey,
};
await bindingStore.mutate(stale, {
kind: "set",
binding: { threadId: "thread-stale", cwd: "/repo" },
});
await expect(
harness.reset?.({
agentId: "main",
sessionId: "session-2",
sessionKey,
reason: "reset",
}),
).resolves.toBeUndefined();
const current = { ...stale, sessionId: "session-2" };
await expect(bindingStore.read(current)).resolves.toBeUndefined();
await expect(
bindingStore.mutate(current, {
kind: "set",
binding: { threadId: "thread-delayed", cwd: "/repo" },
}),
).resolves.toBe(false);
await fs.rm(stateDir, { recursive: true, force: true });
});
});

View File

@@ -7,13 +7,11 @@ import type {
AgentHarnessCompactResult,
ContextEngineHostCapability,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type {
CodexAppServerListModelsOptions,
CodexAppServerModel,
CodexAppServerModelListResult,
} from "./src/app-server/models.js";
import type { CodexAppServerBindingStore } from "./src/app-server/session-binding.js";
const DEFAULT_CODEX_HARNESS_PROVIDER_IDS = new Set(["codex", "openai"]);
const CODEX_APP_SERVER_CONTEXT_ENGINE_HOST_CAPABILITIES = [
@@ -39,14 +37,12 @@ type CodexAppServerAgentHarness = AgentHarness & {
* Creates the Codex app-server harness used for attempts, side questions,
* compaction, reset, and disposal.
*/
export function createCodexAppServerAgentHarness(options: {
export function createCodexAppServerAgentHarness(options?: {
id?: string;
label?: string;
providerIds?: Iterable<string>;
pluginConfig?: unknown;
resolvePluginConfig?: () => unknown;
resolveConfig?: () => OpenClawConfig | undefined;
bindingStore: CodexAppServerBindingStore;
}): AgentHarness {
const providerIds = new Set(
[...(options?.providerIds ?? DEFAULT_CODEX_HARNESS_PROVIDER_IDS)].map((id) =>
@@ -75,7 +71,6 @@ export function createCodexAppServerAgentHarness(options: {
// cold provider catalog reads do not pull in the whole Codex runtime.
const { runCodexAppServerAttempt } = await import("./src/app-server/run-attempt.js");
return runCodexAppServerAttempt(params, {
bindingStore: options.bindingStore,
pluginConfig: options?.resolvePluginConfig?.() ?? options?.pluginConfig,
nativeHookRelay: { enabled: true },
});
@@ -83,7 +78,6 @@ export function createCodexAppServerAgentHarness(options: {
runSideQuestion: async (params) => {
const { runCodexAppServerSideQuestion } = await import("./src/app-server/side-question.js");
return runCodexAppServerSideQuestion(params, {
bindingStore: options.bindingStore,
pluginConfig: options?.resolvePluginConfig?.() ?? options?.pluginConfig,
nativeHookRelay: { enabled: true },
});
@@ -91,43 +85,20 @@ export function createCodexAppServerAgentHarness(options: {
compact: async (params) => {
const { maybeCompactCodexAppServerSession } = await import("./src/app-server/compact.js");
return maybeCompactCodexAppServerSession(params, {
bindingStore: options.bindingStore,
pluginConfig: options?.resolvePluginConfig?.() ?? options?.pluginConfig,
});
},
compactAfterContextEngine: async (params) => {
const { maybeCompactCodexAppServerSession } = await import("./src/app-server/compact.js");
return maybeCompactCodexAppServerSession(params, {
bindingStore: options.bindingStore,
pluginConfig: options?.resolvePluginConfig?.() ?? options?.pluginConfig,
allowNonManualNativeRequest: true,
});
},
reset: async (params) => {
if (params.sessionId) {
const { reclaimCurrentCodexSessionGeneration, sessionBindingIdentity } =
await import("./src/app-server/session-binding.js");
const identity = sessionBindingIdentity({
agentId: params.agentId,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
});
let retired = await options.bindingStore.retireSessionGeneration(identity);
if (retired === "conflict") {
const reclaimed = await reclaimCurrentCodexSessionGeneration({
bindingStore: options.bindingStore,
identity,
config: options.resolveConfig?.(),
});
if (reclaimed) {
retired = await options.bindingStore.retireSessionGeneration(identity);
}
}
if (retired === "conflict") {
throw new Error(
`Codex binding generation changed before session ${params.sessionId} could reset`,
);
}
if (params.sessionFile) {
const { clearCodexAppServerBinding } = await import("./src/app-server/session-binding.js");
await clearCodexAppServerBinding(params.sessionFile);
}
},
dispose: async () => {

View File

@@ -4,30 +4,10 @@ import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import { describe, expect, it, vi } from "vitest";
import { createCodexAppServerAgentHarness } from "./harness.js";
import plugin from "./index.js";
import {
createCodexAppServerBindingStore,
sessionBindingIdentity,
} from "./src/app-server/session-binding.js";
import {
createCodexTestBindingStateStore,
testCodexAppServerBindingStore,
} from "./src/app-server/session-binding.test-helpers.js";
const runCodexAppServerAttemptMock = vi.hoisted(() => vi.fn());
const runCodexAppServerSideQuestionMock = vi.hoisted(() => vi.fn());
function createCodexTestRuntime(
current?: () => unknown,
stateStore = createCodexTestBindingStateStore(),
) {
return {
...(current ? { config: { current } } : {}),
state: {
openSyncKeyedStore: () => stateStore,
},
} as never;
}
vi.mock("./src/app-server/run-attempt.js", () => ({
runCodexAppServerAttempt: runCodexAppServerAttemptMock,
}));
@@ -60,6 +40,7 @@ describe("codex plugin", () => {
const registerProvider = vi.fn();
const registerWebSearchProvider = vi.fn();
const on = vi.fn();
const onConversationBindingResolved = vi.fn();
plugin.register(
createTestPluginApi({
@@ -68,7 +49,7 @@ describe("codex plugin", () => {
source: "test",
config: {},
pluginConfig: {},
runtime: createCodexTestRuntime(),
runtime: {} as never,
registerAgentHarness,
registerCommand,
registerMediaUnderstandingProvider,
@@ -76,6 +57,7 @@ describe("codex plugin", () => {
registerProvider,
registerWebSearchProvider,
on,
onConversationBindingResolved,
}),
);
@@ -85,6 +67,9 @@ describe("codex plugin", () => {
| Record<string, unknown>
| undefined;
const inboundClaimRegistration = mockCall(on) as [unknown, unknown] | undefined;
const bindingResolvedRegistration = mockCall(onConversationBindingResolved) as
| [unknown]
| undefined;
expect(providerRegistration.id).toBe("codex");
expect(providerRegistration.label).toBe("Codex");
@@ -118,12 +103,33 @@ describe("codex plugin", () => {
expect(migrationRegistration?.label).toBe("Codex");
expect(inboundClaimRegistration?.[0]).toBe("inbound_claim");
expect(typeof inboundClaimRegistration?.[1]).toBe("function");
expect(typeof bindingResolvedRegistration?.[0]).toBe("function");
});
it("registers with capture APIs that do not expose conversation binding hooks yet", () => {
const registerProvider = vi.fn();
const api = createTestPluginApi({
id: "codex",
name: "Codex",
source: "test",
config: {},
pluginConfig: {},
runtime: {} as never,
registerAgentHarness: vi.fn(),
registerCommand: vi.fn(),
registerMediaUnderstandingProvider: vi.fn(),
registerProvider,
on: vi.fn(),
});
delete (api as { onConversationBindingResolved?: unknown }).onConversationBindingResolved;
plugin.register(api);
expect(registerProvider).toHaveBeenCalledTimes(1);
expect((mockCallArg(registerProvider) as { id?: string } | undefined)?.id).toBe("codex");
});
it("claims the Codex routing providers by default", () => {
const harness = createCodexAppServerAgentHarness({
bindingStore: testCodexAppServerBindingStore,
});
const harness = createCodexAppServerAgentHarness();
expect(harness.deliveryDefaults?.sourceVisibleReplies).toBe("message_tool");
expect(
@@ -144,196 +150,8 @@ describe("codex plugin", () => {
expect(unsupported.supported).toBe(false);
});
it("clears only ended session binding rows in the owning agent scope", async () => {
const stateStore = createCodexTestBindingStateStore();
const bindingStore = createCodexAppServerBindingStore(stateStore);
const on = vi.fn();
plugin.register(
createTestPluginApi({
id: "codex",
name: "Codex",
source: "test",
config: {},
pluginConfig: {},
runtime: createCodexTestRuntime(undefined, stateStore),
registerAgentHarness: vi.fn(),
registerCommand: vi.fn(),
registerMediaUnderstandingProvider: vi.fn(),
registerMigrationProvider: vi.fn(),
registerProvider: vi.fn(),
on,
}),
);
const sessionEnd = on.mock.calls.find(([name]) => name === "session_end")?.[1] as
| ((
event: { sessionId: string; sessionKey?: string; reason?: string },
ctx: { agentId?: string; sessionId: string; sessionKey?: string },
) => Promise<void>)
| undefined;
if (!sessionEnd) {
throw new Error("missing Codex session_end hook");
}
const identity = sessionBindingIdentity({
agentId: "worker",
sessionId: "session-1",
sessionKey: "agent:worker:session-1",
});
const setBinding = () =>
bindingStore.mutate(identity, {
kind: "set",
binding: { threadId: "thread-1", cwd: "/repo" },
});
for (const reason of ["shutdown", "restart", "compaction", "unknown"] as const) {
await setBinding();
await sessionEnd(
{ sessionId: "session-1", sessionKey: "agent:worker:session-1", reason },
{ agentId: "worker", sessionId: "session-1" },
);
await expect(bindingStore.read(identity)).resolves.toMatchObject({
threadId: "thread-1",
});
}
for (const reason of ["new", "reset", "idle", "daily", "deleted"] as const) {
await setBinding();
await sessionEnd(
{ sessionId: "session-1", sessionKey: "agent:worker:session-1", reason },
{ agentId: "worker", sessionId: "session-1" },
);
await expect(bindingStore.read(identity)).resolves.toBeUndefined();
}
});
it("adopts compaction successors before delayed lifecycle cleanup", async () => {
const stateStore = createCodexTestBindingStateStore();
const bindingStore = createCodexAppServerBindingStore(stateStore);
const on = vi.fn();
plugin.register(
createTestPluginApi({
id: "codex",
name: "Codex",
source: "test",
config: {},
pluginConfig: {},
runtime: createCodexTestRuntime(undefined, stateStore),
registerAgentHarness: vi.fn(),
registerCommand: vi.fn(),
registerMediaUnderstandingProvider: vi.fn(),
registerMigrationProvider: vi.fn(),
registerProvider: vi.fn(),
on,
}),
);
const afterCompaction = on.mock.calls.find(([name]) => name === "after_compaction")?.[1] as
| ((
event: {
messageCount: number;
compactedCount: number;
previousSessionId?: string;
},
ctx: { agentId?: string; sessionId?: string; sessionKey?: string },
) => Promise<void>)
| undefined;
const sessionEnd = on.mock.calls.find(([name]) => name === "session_end")?.[1] as
| ((
event: { sessionId: string; sessionKey?: string; reason?: string },
ctx: { agentId?: string; sessionId: string; sessionKey?: string },
) => Promise<void>)
| undefined;
if (!afterCompaction || !sessionEnd) {
throw new Error("missing Codex compaction lifecycle hooks");
}
const sessionKey = "agent:worker:telegram:chat-1";
const previous = sessionBindingIdentity({
agentId: "worker",
sessionId: "session-1",
sessionKey,
});
const successor = sessionBindingIdentity({
agentId: "worker",
sessionId: "session-2",
sessionKey,
});
const newest = sessionBindingIdentity({
agentId: "worker",
sessionId: "session-3",
sessionKey,
});
await bindingStore.mutate(previous, {
kind: "set",
binding: { threadId: "thread-1", cwd: "/repo" },
});
await afterCompaction(
{ messageCount: 1, compactedCount: 1, previousSessionId: "session-1" },
{ agentId: "worker", sessionId: "session-2", sessionKey },
);
await expect(bindingStore.read(previous)).resolves.toBeUndefined();
await expect(bindingStore.read(successor)).resolves.toMatchObject({ threadId: "thread-1" });
await afterCompaction(
{ messageCount: 1, compactedCount: 1, previousSessionId: "session-2" },
{ agentId: "worker", sessionId: "session-3", sessionKey },
);
await afterCompaction(
{ messageCount: 1, compactedCount: 1, previousSessionId: "session-1" },
{ agentId: "worker", sessionId: "session-2", sessionKey },
);
await expect(bindingStore.read(successor)).resolves.toBeUndefined();
await expect(bindingStore.read(newest)).resolves.toMatchObject({ threadId: "thread-1" });
await sessionEnd(
{ sessionId: "session-1", sessionKey, reason: "reset" },
{ agentId: "worker", sessionId: "session-1", sessionKey },
);
await sessionEnd(
{ sessionId: "session-2", sessionKey, reason: "compaction" },
{ agentId: "worker", sessionId: "session-2", sessionKey },
);
await expect(bindingStore.read(newest)).resolves.toMatchObject({ threadId: "thread-1" });
expect(stateStore.entries()).toHaveLength(1);
});
it("ignores compaction for a session without a Codex binding", async () => {
const warn = vi.fn();
const on = vi.fn();
plugin.register(
createTestPluginApi({
id: "codex",
name: "Codex",
source: "test",
config: {},
pluginConfig: {},
logger: { debug: vi.fn(), info: vi.fn(), warn, error: vi.fn() },
runtime: createCodexTestRuntime(),
registerAgentHarness: vi.fn(),
registerCommand: vi.fn(),
registerMediaUnderstandingProvider: vi.fn(),
registerMigrationProvider: vi.fn(),
registerProvider: vi.fn(),
on,
}),
);
const afterCompaction = on.mock.calls.find(([name]) => name === "after_compaction")?.[1] as
| ((event: object, ctx: { sessionId?: string; sessionKey?: string }) => Promise<void>)
| undefined;
if (!afterCompaction) {
throw new Error("missing Codex after_compaction hook");
}
await afterCompaction(
{ previousSessionId: "session-1" },
{ sessionId: "session-2", sessionKey: "agent:main:main" },
);
expect(warn).not.toHaveBeenCalled();
});
it("enables the native hook relay for public Codex app-server attempts", async () => {
const harness = createCodexAppServerAgentHarness({
bindingStore: testCodexAppServerBindingStore,
pluginConfig: { appServer: {} },
});
const harness = createCodexAppServerAgentHarness({ pluginConfig: { appServer: {} } });
const result = { success: true };
runCodexAppServerAttemptMock.mockResolvedValueOnce(result);
@@ -342,7 +160,6 @@ describe("codex plugin", () => {
expect(runCodexAppServerAttemptMock).toHaveBeenCalledWith(
{ prompt: "hello" },
{
bindingStore: testCodexAppServerBindingStore,
pluginConfig: { appServer: {} },
nativeHookRelay: { enabled: true },
},
@@ -377,7 +194,11 @@ describe("codex plugin", () => {
source: "test",
config: {},
pluginConfig: { codexPlugins: { enabled: false } },
runtime: createCodexTestRuntime(() => liveConfig),
runtime: {
config: {
current: () => liveConfig,
},
} as never,
registerAgentHarness,
registerCommand: vi.fn(),
registerMediaUnderstandingProvider: vi.fn(),
@@ -397,49 +218,14 @@ describe("codex plugin", () => {
expect(runCodexAppServerAttemptMock).toHaveBeenCalledWith(
{ prompt: "calendar" },
{
bindingStore: expect.any(Object),
pluginConfig: liveConfig.plugins.entries.codex.config,
nativeHookRelay: { enabled: true },
},
);
});
it("does not resurrect startup Codex config after the live entry is removed", async () => {
const registerAgentHarness = vi.fn();
plugin.register(
createTestPluginApi({
id: "codex",
name: "Codex",
source: "test",
config: {},
pluginConfig: { appServer: { mode: "yolo" } },
runtime: createCodexTestRuntime(() => ({ plugins: { entries: {} } })),
registerAgentHarness,
registerCommand: vi.fn(),
registerMediaUnderstandingProvider: vi.fn(),
registerMigrationProvider: vi.fn(),
registerProvider: vi.fn(),
on: vi.fn(),
}),
);
const harness = mockCallArg(registerAgentHarness) as ReturnType<
typeof createCodexAppServerAgentHarness
>;
runCodexAppServerAttemptMock.mockResolvedValueOnce({ success: true });
await harness.runAttempt({ prompt: "default policy" } as never);
expect(runCodexAppServerAttemptMock).toHaveBeenCalledWith(
{ prompt: "default policy" },
expect.objectContaining({ pluginConfig: undefined }),
);
});
it("enables the native hook relay for public Codex side questions", async () => {
const harness = createCodexAppServerAgentHarness({
bindingStore: testCodexAppServerBindingStore,
pluginConfig: { appServer: {} },
});
const harness = createCodexAppServerAgentHarness({ pluginConfig: { appServer: {} } });
const runSideQuestion = harness["runSideQuestion"];
const result = { text: "ok" };
runCodexAppServerSideQuestionMock.mockResolvedValueOnce(result);
@@ -452,7 +238,6 @@ describe("codex plugin", () => {
expect(runCodexAppServerSideQuestionMock).toHaveBeenCalledWith(
{ question: "btw" },
{
bindingStore: testCodexAppServerBindingStore,
pluginConfig: { appServer: {} },
nativeHookRelay: { enabled: true },
},

View File

@@ -4,72 +4,48 @@
*/
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { mutateConfigFile } from "openclaw/plugin-sdk/config-mutation";
import {
resolveLivePluginConfigObject,
resolvePluginConfigObject,
} from "openclaw/plugin-sdk/plugin-config-runtime";
import { resolveLivePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { createCodexAppServerAgentHarness } from "./harness.js";
import { buildCodexMediaUnderstandingProvider } from "./media-understanding-provider.js";
import { buildCodexProvider } from "./provider.js";
import {
CODEX_APP_SERVER_BINDING_MAX_ENTRIES,
CODEX_APP_SERVER_BINDING_NAMESPACE,
createLazyCodexAppServerBindingStore,
type StoredCodexAppServerBinding,
} from "./src/app-server/session-binding-store.js";
import type { CodexPluginsConfigBlock } from "./src/command-plugins-management.js";
import { createCodexCommand } from "./src/commands.js";
import {
handleCodexConversationBindingResolved,
handleCodexConversationInboundClaim,
} from "./src/conversation-binding.js";
import { buildCodexMigrationProvider } from "./src/migration/provider.js";
import {
createCodexCliSessionNodeHostCommands,
createCodexCliSessionNodeInvokePolicies,
} from "./src/node-cli-session-registration.js";
listCodexCliSessionsOnNode,
resumeCodexCliSessionOnNode,
resolveCodexCliSessionForBindingOnNode,
} from "./src/node-cli-sessions.js";
import { createCodexWebSearchProvider } from "./src/web-search-provider.js";
const ENDED_SESSION_REASONS: ReadonlySet<string> = new Set([
"new",
"reset",
"idle",
"daily",
"deleted",
]);
export default definePluginEntry({
id: "codex",
name: "Codex",
description: "Codex app-server harness and Codex-managed GPT model catalog.",
register(api) {
const runtimeConfigLoader = api.runtime.config?.current
? () => api.runtime.config?.current() as OpenClawConfig
: undefined;
const resolveCurrentConfig = () => runtimeConfigLoader?.();
const loadNodeCliSessions = () => import("./src/node-cli-sessions.js");
const resolveCurrentConfig = () =>
api.runtime.config?.current ? (api.runtime.config.current() as OpenClawConfig) : undefined;
const resolveCurrentPluginConfig = () =>
// Codex plugin config can change at runtime; resolve from live config for
// harness attempts and binding claims instead of keeping startup values.
resolveLivePluginConfigObject(
runtimeConfigLoader,
resolveCurrentConfig,
"codex",
api.pluginConfig as Record<string, unknown>,
);
const bindingStore = createLazyCodexAppServerBindingStore(
api.runtime.state.openSyncKeyedStore<StoredCodexAppServerBinding>({
namespace: CODEX_APP_SERVER_BINDING_NAMESPACE,
maxEntries: CODEX_APP_SERVER_BINDING_MAX_ENTRIES,
overflowPolicy: "reject-new",
}),
);
) ?? api.pluginConfig;
api.registerAgentHarness(
createCodexAppServerAgentHarness({
bindingStore,
resolveConfig: resolveCurrentConfig,
resolvePluginConfig: resolveCurrentPluginConfig,
}),
createCodexAppServerAgentHarness({ resolvePluginConfig: resolveCurrentPluginConfig }),
);
api.registerProvider(buildCodexProvider({ pluginConfig: api.pluginConfig }));
api.registerMediaUnderstandingProvider(
buildCodexMediaUnderstandingProvider({ resolvePluginConfig: resolveCurrentPluginConfig }),
buildCodexMediaUnderstandingProvider({ pluginConfig: api.pluginConfig }),
);
api.registerWebSearchProvider(
createCodexWebSearchProvider({ resolvePluginConfig: resolveCurrentPluginConfig }),
@@ -83,43 +59,43 @@ export default definePluginEntry({
}
api.registerCommand(
createCodexCommand({
resolvePluginConfig: resolveCurrentPluginConfig,
pluginConfig: api.pluginConfig,
deps: {
bindingStore,
listCodexCliSessionsOnNode: async (params) =>
await (
await loadNodeCliSessions()
).listCodexCliSessionsOnNode({
runtime: api.runtime,
...params,
}),
resolveCodexCliSessionForBindingOnNode: async (params) =>
await (
await loadNodeCliSessions()
).resolveCodexCliSessionForBindingOnNode({
runtime: api.runtime,
...params,
}),
listCodexCliSessionsOnNode: (params) =>
listCodexCliSessionsOnNode({ runtime: api.runtime, ...params }),
resolveCodexCliSessionForBindingOnNode: (params) =>
resolveCodexCliSessionForBindingOnNode({ runtime: api.runtime, ...params }),
codexPluginsManagementIo: {
readConfig: () => {
const current = (api.runtime.config?.current?.() ?? {}) as OpenClawConfig;
const codexPlugins = resolvePluginConfigObject(current, "codex")?.codexPlugins;
if (
!codexPlugins ||
typeof codexPlugins !== "object" ||
Array.isArray(codexPlugins)
) {
const plugins = (current as Record<string, unknown>).plugins;
if (!plugins || typeof plugins !== "object") {
return Promise.resolve({});
}
const block = codexPlugins as Record<string, unknown>;
const declared = block.plugins;
const entries = (plugins as Record<string, unknown>).entries;
if (!entries || typeof entries !== "object") {
return Promise.resolve({});
}
const codexEntry = (entries as Record<string, unknown>).codex;
if (!codexEntry || typeof codexEntry !== "object") {
return Promise.resolve({});
}
const config = (codexEntry as Record<string, unknown>).config;
if (!config || typeof config !== "object") {
return Promise.resolve({});
}
const codexPlugins = (config as Record<string, unknown>).codexPlugins;
if (!codexPlugins || typeof codexPlugins !== "object") {
return Promise.resolve({});
}
const declared = (codexPlugins as Record<string, unknown>).plugins;
if (!declared || typeof declared !== "object") {
return Promise.resolve({
enabled: block.enabled === true,
enabled: (codexPlugins as Record<string, unknown>).enabled === true,
});
}
return Promise.resolve({
enabled: block.enabled === true,
enabled: (codexPlugins as Record<string, unknown>).enabled === true,
plugins: declared as Record<string, never>,
});
},
@@ -129,12 +105,17 @@ export default definePluginEntry({
// Create the nested plugin config path on demand so codex
// plugin commands can enable/update Codex-managed plugins.
const root = draft as Record<string, unknown>;
const pluginsBlock = (root.plugins ??= {}) as Record<string, unknown>;
const entries = (pluginsBlock.entries ??= {}) as Record<string, unknown>;
const codexEntry = (entries.codex ??= {}) as Record<string, unknown>;
const config = (codexEntry.config ??= {}) as Record<string, unknown>;
const codexPlugins = (config.codexPlugins ??= {}) as Record<string, unknown>;
codexPlugins.plugins ??= {};
root.plugins = (root.plugins ?? {}) as Record<string, unknown>;
const pluginsBlock = root.plugins as Record<string, unknown>;
pluginsBlock.entries = (pluginsBlock.entries ?? {}) as Record<string, unknown>;
const entries = pluginsBlock.entries as Record<string, unknown>;
entries.codex = (entries.codex ?? {}) as Record<string, unknown>;
const codexEntry = entries.codex as Record<string, unknown>;
codexEntry.config = (codexEntry.config ?? {}) as Record<string, unknown>;
const config = codexEntry.config as Record<string, unknown>;
config.codexPlugins = (config.codexPlugins ?? {}) as Record<string, unknown>;
const codexPlugins = config.codexPlugins as Record<string, unknown>;
codexPlugins.plugins = (codexPlugins.plugins ?? {}) as Record<string, unknown>;
update(codexPlugins as CodexPluginsConfigBlock);
},
});
@@ -143,58 +124,14 @@ export default definePluginEntry({
},
}),
);
api.on("inbound_claim", async (event, ctx) => {
const { handleCodexConversationInboundClaim } = await import("./src/conversation-binding.js");
return await handleCodexConversationInboundClaim(event, ctx, {
bindingStore,
api.on("inbound_claim", (event, ctx) =>
handleCodexConversationInboundClaim(event, ctx, {
pluginConfig: resolveCurrentPluginConfig(),
config: resolveCurrentConfig(),
resumeCodexCliSessionOnNode: async (params) =>
await (
await loadNodeCliSessions()
).resumeCodexCliSessionOnNode({
runtime: api.runtime,
...params,
}),
});
});
api.on("after_compaction", async (event, ctx) => {
const previousSessionId = event.previousSessionId?.trim();
const sessionId = ctx.sessionId?.trim();
if (!previousSessionId || !sessionId || previousSessionId === sessionId) {
return;
}
const config = resolveCurrentConfig();
const sessionKey = ctx.sessionKey?.trim();
const { sessionBindingIdentity } = await import("./src/app-server/session-binding.js");
const identity = sessionBindingIdentity({
sessionId,
...(sessionKey ? { sessionKey } : {}),
...(ctx.agentId ? { agentId: ctx.agentId } : {}),
...(config ? { config } : {}),
});
const adopted = await bindingStore.adoptSessionGeneration(identity, previousSessionId);
if (adopted === "conflict") {
api.logger.warn?.(
`codex: could not adopt compacted session generation ${sessionId} (${adopted}); secondary native compaction will skip`,
);
}
});
api.on("session_end", async (event, ctx) => {
if (!event.reason || !ENDED_SESSION_REASONS.has(event.reason)) {
return;
}
const sessionKey = event.sessionKey ?? ctx.sessionKey;
const config = resolveCurrentConfig();
const { sessionBindingIdentity } = await import("./src/app-server/session-binding.js");
await bindingStore.retireSessionGeneration(
sessionBindingIdentity({
sessionId: event.sessionId,
...(sessionKey ? { sessionKey } : {}),
...(ctx.agentId ? { agentId: ctx.agentId } : {}),
...(config ? { config } : {}),
}),
);
});
resumeCodexCliSessionOnNode: (params) =>
resumeCodexCliSessionOnNode({ runtime: api.runtime, ...params }),
}),
);
api.onConversationBindingResolved?.(handleCodexConversationBindingResolved);
},
});

View File

@@ -2,25 +2,8 @@
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildCodexMediaUnderstandingProvider } from "./media-understanding-provider.js";
import { CodexAppServerRpcError, type CodexAppServerClient } from "./src/app-server/client.js";
import type { CodexAppServerClient } from "./src/app-server/client.js";
import type { CodexServerNotification, JsonValue } from "./src/app-server/protocol.js";
import { adaptCodexTestClientFactory } from "./src/app-server/test-support.js";
const EXPECTED_MEDIA_THREAD_CONFIG = {
project_doc_max_bytes: 0,
web_search: "disabled",
"tools.experimental_request_user_input.enabled": false,
"features.hooks": false,
"features.multi_agent": false,
"features.apps": false,
"features.plugins": false,
"features.image_generation": false,
"features.skill_mcp_dependency_install": false,
"features.memories": false,
"features.goals": false,
"features.code_mode": false,
"features.code_mode_only": false,
};
const sharedClientMocks = vi.hoisted(() => ({
createIsolatedCodexAppServerClient: vi.fn(),
@@ -102,15 +85,13 @@ function createFakeClient(options?: {
inputModalities?: string[];
completeWithItems?: boolean;
notifyError?: string;
approvalRequestMethod?: string;
responseText?: string;
turnStartError?: Error;
preBindNotificationCount?: number;
interruptError?: Error;
unsubscribeError?: Error;
}) {
const notifications = new Set<(notification: CodexServerNotification) => void>();
const closeHandlers = new Set<() => void>();
const requestHandlers = new Set<(request: { method: string }) => JsonValue | undefined>();
const requests: Array<{ method: string; params?: JsonValue }> = [];
const approvalResponses: JsonValue[] = [];
const request = vi.fn(async (method: string, params?: JsonValue) => {
requests.push({ method, params });
if (method === "model/list") {
@@ -123,60 +104,51 @@ function createFakeClient(options?: {
return threadStartResult();
}
if (method === "turn/start") {
if (options?.turnStartError) {
throw options.turnStartError;
}
if (options?.preBindNotificationCount) {
for (let index = 0; index < options.preBindNotificationCount; index += 1) {
for (const notify of notifications) {
notify({
method: "item/started",
params: { threadId: "thread-1", turnId: "turn-1" },
});
if (options?.approvalRequestMethod) {
for (const handler of requestHandlers) {
const response = handler({ method: options.approvalRequestMethod });
if (response !== undefined) {
approvalResponses.push(response);
}
}
return turnStartResult();
}
const emitTurnNotifications = () => {
if (options?.notifyError) {
for (const notify of notifications) {
notify({
method: "error",
params: {
threadId: "thread-1",
turnId: "turn-1",
error: {
message: options.notifyError,
codexErrorInfo: null,
additionalDetails: null,
},
willRetry: false,
if (options?.notifyError) {
for (const notify of notifications) {
notify({
method: "error",
params: {
threadId: "thread-1",
turnId: "turn-1",
error: {
message: options.notifyError,
codexErrorInfo: null,
additionalDetails: null,
},
});
}
} else if (!options?.completeWithItems) {
for (const notify of notifications) {
notify({
method: "item/agentMessage/delta",
params: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "msg-1",
delta: options?.responseText ?? "A red square.",
},
});
notify({
method: "turn/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
turn: turnStartResult("completed").turn,
},
});
}
willRetry: false,
},
});
}
};
emitTurnNotifications();
} else if (!options?.completeWithItems) {
for (const notify of notifications) {
notify({
method: "item/agentMessage/delta",
params: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "msg-1",
delta: options?.responseText ?? "A red square.",
},
});
notify({
method: "turn/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
turn: turnStartResult("completed").turn,
},
});
}
}
return turnStartResult(
options?.completeWithItems ? "completed" : "inProgress",
options?.completeWithItems
@@ -192,12 +164,6 @@ function createFakeClient(options?: {
: [],
);
}
if (method === "turn/interrupt" && options?.interruptError) {
throw options.interruptError;
}
if (method === "thread/unsubscribe" && options?.unsubscribeError) {
throw options.unsubscribeError;
}
return {};
});
@@ -207,17 +173,14 @@ function createFakeClient(options?: {
notifications.add(handler);
return () => notifications.delete(handler);
},
addRequestHandler() {
return () => undefined;
},
addCloseHandler(handler: () => void) {
closeHandlers.add(handler);
return () => closeHandlers.delete(handler);
addRequestHandler(handler: (request: { method: string }) => JsonValue | undefined) {
requestHandlers.add(handler);
return () => requestHandlers.delete(handler);
},
close: vi.fn(),
} as unknown as CodexAppServerClient;
return { client, requests };
return { client, requests, approvalResponses };
}
describe("codex media understanding provider", () => {
@@ -229,9 +192,11 @@ describe("codex media understanding provider", () => {
it("runs image understanding through a bounded Codex app-server turn", async () => {
const { client, requests } = createFakeClient();
const clientFactory = vi.fn(async () => client);
const clientFactory = vi.fn(
async (_startOptions, _authProfileId, _agentDir, _config) => client,
);
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: adaptCodexTestClientFactory(clientFactory),
clientFactory,
});
const cfg = {
auth: {
@@ -254,33 +219,42 @@ describe("codex media understanding provider", () => {
});
expect(result).toEqual({ text: "A red square.", model: "gpt-5.4" });
expect(requests.map((entry) => entry.method)).toEqual([
"model/list",
"thread/start",
"turn/start",
]);
expect(clientFactory).toHaveBeenCalledWith(
expect.any(Object),
undefined,
"/tmp/openclaw-agent",
cfg,
expect.objectContaining({ timeoutMs: 30_000 }),
{ timeoutMs: 30_000 },
);
expect(requests.map((entry) => entry.method)).toEqual([
"model/list",
"thread/start",
"turn/start",
"thread/unsubscribe",
]);
expect(requests[0]?.params).toEqual({ limit: 100, cursor: null, includeHidden: true });
expect(requests[1]?.params).toEqual({
model: "gpt-5.4",
modelProvider: "openai",
cwd: "/tmp/openclaw-agent/codex-media-home",
approvalPolicy: "never",
cwd: "/tmp/openclaw-agent",
approvalPolicy: "on-request",
sandbox: "read-only",
serviceName: "OpenClaw",
personality: "none",
developerInstructions:
"You are OpenClaw's bounded image-understanding worker. Describe only the provided image content. Do not call tools, edit files, or ask follow-up questions.",
config: EXPECTED_MEDIA_THREAD_CONFIG,
config: {
"features.apps": false,
"features.code_mode": false,
"features.code_mode_only": false,
"features.image_generation": false,
"features.multi_agent": false,
"features.plugins": false,
"features.standalone_web_search": false,
web_search: "disabled",
},
environments: [],
dynamicTools: [],
experimentalRawEvents: true,
ephemeral: true,
persistExtendedHistory: false,
});
expect(requests[2]?.params).toEqual({
threadId: "thread-1",
@@ -288,6 +262,9 @@ describe("codex media understanding provider", () => {
{ type: "text", text: "Describe briefly.", text_elements: [] },
{ type: "image", url: "data:image/png;base64,aW1hZ2UtYnl0ZXM=" },
],
cwd: "/tmp/openclaw-agent",
approvalPolicy: "on-request",
model: "gpt-5.4",
effort: "low",
});
});
@@ -295,12 +272,8 @@ describe("codex media understanding provider", () => {
it("treats a blank agent directory as absent when starting the app-server", async () => {
const { client, requests } = createFakeClient();
const clientFactory = vi.fn(async () => client);
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: adaptCodexTestClientFactory(clientFactory),
});
const cfg = {
agents: { list: [{ id: "main", agentDir: "/tmp/openclaw-default-agent" }] },
};
const provider = buildCodexMediaUnderstandingProvider({ clientFactory });
const cfg = {};
await provider.describeImage?.({
buffer: Buffer.from("image-bytes"),
@@ -313,16 +286,11 @@ describe("codex media understanding provider", () => {
agentDir: " ",
});
expect(clientFactory).toHaveBeenCalledWith(
expect.any(Object),
undefined,
"/tmp/openclaw-default-agent",
cfg,
expect.any(Object),
);
expect(requests[1]?.params).toEqual(
expect.objectContaining({ cwd: "/tmp/openclaw-default-agent/codex-media-home" }),
);
expect(clientFactory).toHaveBeenCalledWith(expect.any(Object), undefined, undefined, cfg, {
timeoutMs: 30_000,
});
expect(requests[1]?.params).toEqual(expect.objectContaining({ cwd: process.cwd() }));
expect(requests[2]?.params).toEqual(expect.objectContaining({ cwd: process.cwd() }));
});
it("preserves configured WebSocket transport for media turns", async () => {
@@ -402,7 +370,7 @@ describe("codex media understanding provider", () => {
try {
const { client } = createFakeClient();
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
clientFactory: async () => client,
});
const result = await provider.describeImage?.({
@@ -425,97 +393,33 @@ describe("codex media understanding provider", () => {
}
});
it("starts the media deadline before client acquisition", async () => {
vi.useFakeTimers();
it("declines approval requests during image understanding", async () => {
const { client, approvalResponses } = createFakeClient({
approvalRequestMethod: "item/permissions/requestApproval",
});
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: adaptCodexTestClientFactory(
async () => await new Promise<CodexAppServerClient>(() => {}),
),
clientFactory: async () => client,
});
const description = provider.describeImage?.({
buffer: Buffer.from("image-bytes"),
fileName: "image.png",
mime: "image/png",
provider: "codex",
model: "gpt-5.4",
timeoutMs: 100,
cfg: {},
agentDir: "/tmp/openclaw-agent",
});
const rejected = expect(description).rejects.toThrow(
"Codex app-server image understanding timed out",
);
await vi.advanceTimersByTimeAsync(100);
await rejected;
});
it("retires a media client lease that resolves after its deadline", async () => {
let resolveLease!: (lease: {
client: CodexAppServerClient;
release: () => void;
abandon: () => Promise<void>;
}) => void;
const pendingLease = new Promise<{
client: CodexAppServerClient;
release: () => void;
abandon: () => Promise<void>;
}>((resolve) => {
resolveLease = resolve;
});
const clientLeaseFactory = vi.fn(async () => await pendingLease);
const provider = buildCodexMediaUnderstandingProvider({ clientLeaseFactory });
const description = provider.describeImage?.({
buffer: Buffer.from("image-bytes"),
fileName: "image.png",
mime: "image/png",
provider: "codex",
model: "gpt-5.4",
timeoutMs: 5,
cfg: {},
agentDir: "/tmp/openclaw-agent",
});
await expect(description).rejects.toThrow("Codex app-server image understanding timed out");
const { client } = createFakeClient();
const release = vi.fn();
const abandon = vi.fn(async () => undefined);
resolveLease({ client, release, abandon });
await vi.waitFor(() => expect(abandon).toHaveBeenCalledOnce());
expect(release).not.toHaveBeenCalled();
});
it("releases the bounded route between isolated media calls", async () => {
const { client, requests } = createFakeClient();
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
});
const request = {
await provider.describeImage?.({
buffer: Buffer.from("image-bytes"),
fileName: "image.png",
mime: "image/png",
provider: "codex",
model: "gpt-5.4",
prompt: "Describe briefly.",
timeoutMs: 30_000,
cfg: {},
agentDir: "/tmp/openclaw-agent",
};
});
const first = await provider.describeImage?.(request);
const second = await provider.describeImage?.(request);
expect(first?.text).toBe("A red square.");
expect(second?.text).toBe("A red square.");
expect(requests.filter((entry) => entry.method === "model/list")).toHaveLength(2);
expect(requests.filter((entry) => entry.method === "thread/start")).toHaveLength(2);
expect(approvalResponses).toEqual([{ permissions: {}, scope: "turn" }]);
});
it("extracts text from terminal turn items", async () => {
const { client } = createFakeClient({ completeWithItems: true });
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
clientFactory: async () => client,
});
const result = await provider.describeImages?.({
@@ -534,7 +438,7 @@ describe("codex media understanding provider", () => {
it("rejects text-only Codex app-server models before starting a turn", async () => {
const { client, requests } = createFakeClient({ inputModalities: ["text"] });
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
clientFactory: async () => client,
});
await expect(
@@ -555,7 +459,7 @@ describe("codex media understanding provider", () => {
it("surfaces Codex app-server turn errors", async () => {
const { client } = createFakeClient({ notifyError: "vision unavailable" });
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
clientFactory: async () => client,
});
await expect(
@@ -572,107 +476,12 @@ describe("codex media understanding provider", () => {
).rejects.toThrow("vision unavailable");
});
it.each([
{
name: "structured rejection",
error: new CodexAppServerRpcError({ message: "turn rejected" }, "turn/start"),
abandonCount: 0,
},
{
name: "ambiguous timeout",
error: new Error("turn/start timed out"),
abandonCount: 1,
},
])("handles $name with exact media lease ownership", async ({ error, abandonCount }) => {
const { client } = createFakeClient({ turnStartError: error });
const release = vi.fn();
const abandon = vi.fn(async () => undefined);
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: async () => ({ client, release, abandon }),
});
await expect(
provider.describeImage?.({
buffer: Buffer.from("image-bytes"),
fileName: "image.png",
mime: "image/png",
provider: "codex",
model: "gpt-5.4",
timeoutMs: 30_000,
cfg: {},
agentDir: "/tmp/openclaw-agent",
}),
).rejects.toBe(error);
expect(abandon).toHaveBeenCalledTimes(abandonCount);
expect(release).toHaveBeenCalledTimes(1);
});
it("retires the media client when thread cleanup is unconfirmed", async () => {
const { client } = createFakeClient({ unsubscribeError: new Error("unsubscribe failed") });
const release = vi.fn();
const abandon = vi.fn(async () => undefined);
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: async () => ({ client, release, abandon }),
});
await expect(
provider.describeImage?.({
buffer: Buffer.from("image-bytes"),
fileName: "image.png",
mime: "image/png",
provider: "codex",
model: "gpt-5.4",
timeoutMs: 30_000,
cfg: {},
agentDir: "/tmp/openclaw-agent",
}),
).resolves.toEqual({ text: "A red square.", model: "gpt-5.4" });
expect(abandon).toHaveBeenCalledOnce();
expect(release).not.toHaveBeenCalled();
});
it("retires the media client when an accepted turn cannot be interrupted", async () => {
const { client, requests } = createFakeClient({
preBindNotificationCount: 257,
interruptError: new Error("interrupt timeout"),
});
const release = vi.fn();
const abandon = vi.fn(async () => undefined);
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: async () => ({ client, release, abandon }),
});
await expect(
provider.describeImage?.({
buffer: Buffer.from("image-bytes"),
fileName: "image.png",
mime: "image/png",
provider: "codex",
model: "gpt-5.4",
timeoutMs: 30_000,
cfg: {},
agentDir: "/tmp/openclaw-agent",
}),
).rejects.toThrow("pre-bind notification buffer exceeded 256 entries");
expect(requests.map((entry) => entry.method)).toEqual([
"model/list",
"thread/start",
"turn/start",
"turn/interrupt",
]);
expect(abandon).toHaveBeenCalledOnce();
expect(release).not.toHaveBeenCalled();
});
it("runs structured extraction through the same bounded Codex app-server path", async () => {
const { client, requests } = createFakeClient({
responseText: '{"summary":"red square","tags":["shape"]}',
});
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
clientFactory: async () => client,
});
const result = await provider.extractStructured?.({
@@ -713,21 +522,31 @@ describe("codex media understanding provider", () => {
"model/list",
"thread/start",
"turn/start",
"thread/unsubscribe",
]);
expect(requests[1]?.params).toEqual({
model: "gpt-5.4",
modelProvider: "openai",
cwd: "/tmp/openclaw-agent/codex-media-home",
approvalPolicy: "never",
cwd: "/tmp/openclaw-agent",
approvalPolicy: "on-request",
sandbox: "read-only",
serviceName: "OpenClaw",
personality: "none",
developerInstructions:
"You are OpenClaw's bounded structured-extraction worker. Return only the requested extraction. Do not call tools, edit files, ask follow-up questions, or include secrets.",
config: EXPECTED_MEDIA_THREAD_CONFIG,
config: {
"features.apps": false,
"features.code_mode": false,
"features.code_mode_only": false,
"features.image_generation": false,
"features.multi_agent": false,
"features.plugins": false,
"features.standalone_web_search": false,
web_search: "disabled",
},
environments: [],
dynamicTools: [],
experimentalRawEvents: true,
ephemeral: true,
persistExtendedHistory: false,
});
const turnParams = requests[2]?.params as
| {
@@ -740,9 +559,9 @@ describe("codex media understanding provider", () => {
}
| undefined;
expect(turnParams?.threadId).toBe("thread-1");
expect(turnParams?.approvalPolicy).toBeUndefined();
expect(turnParams?.model).toBeUndefined();
expect(turnParams?.cwd).toBeUndefined();
expect(turnParams?.approvalPolicy).toBe("on-request");
expect(turnParams?.model).toBe("gpt-5.4");
expect(turnParams?.cwd).toBe("/tmp/openclaw-agent");
expect(turnParams?.effort).toBe("low");
expect(turnParams?.input).toHaveLength(3);
expect(turnParams?.input?.[0]?.type).toBe("text");
@@ -765,7 +584,7 @@ describe("codex media understanding provider", () => {
responseText: '{"summary":"only text"}',
});
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
clientFactory: async () => client,
});
await expect(
@@ -785,7 +604,7 @@ describe("codex media understanding provider", () => {
it("returns a controlled error when structured JSON parsing fails", async () => {
const { client } = createFakeClient({ responseText: "not json" });
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
clientFactory: async () => client,
});
await expect(
@@ -814,7 +633,7 @@ describe("codex media understanding provider", () => {
responseText: '{"summary":123,"tags":["shape"]}',
});
const provider = buildCodexMediaUnderstandingProvider({
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
clientFactory: async () => client,
});
await expect(

View File

@@ -1,35 +1,216 @@
/** Lazy registration facade for Codex-backed media understanding. */
import type { MediaUnderstandingProvider } from "openclaw/plugin-sdk/media-understanding";
/**
* Codex-backed media understanding provider for bounded image description and
* structured extraction turns.
*/
import {
type JsonSchemaObject,
validateJsonSchemaValue,
} from "openclaw/plugin-sdk/json-schema-runtime";
import type {
ImagesDescriptionRequest,
ImagesDescriptionResult,
MediaUnderstandingProvider,
StructuredExtractionRequest,
StructuredExtractionResult,
} from "openclaw/plugin-sdk/media-understanding";
import { CODEX_PROVIDER_ID, FALLBACK_CODEX_MODELS } from "./provider-catalog.js";
import type { CodexAppServerClientLeaseFactory } from "./src/app-server/shared-client.js";
import {
runBoundedCodexAppServerTurn,
type CodexBoundedTurnOptions,
} from "./src/app-server/bounded-turn.js";
import type { CodexUserInput } from "./src/app-server/protocol.js";
const DEFAULT_CODEX_IMAGE_MODEL =
FALLBACK_CODEX_MODELS.find((model) => model.inputModalities.includes("image"))?.id ??
FALLBACK_CODEX_MODELS[0]?.id;
const DEFAULT_CODEX_IMAGE_PROMPT = "Describe the image.";
/** Dependencies and plugin config for Codex media-understanding calls. */
export type CodexMediaUnderstandingProviderOptions = {
pluginConfig?: unknown;
resolvePluginConfig?: () => unknown;
clientLeaseFactory?: CodexAppServerClientLeaseFactory;
};
export type CodexMediaUnderstandingProviderOptions = CodexBoundedTurnOptions;
/** Builds a provider whose app-server implementation loads on first use. */
/**
* Builds the media-understanding provider that delegates image tasks to an
* isolated Codex app-server session.
*/
export function buildCodexMediaUnderstandingProvider(
options: CodexMediaUnderstandingProviderOptions = {},
): MediaUnderstandingProvider {
let runtime: Promise<typeof import("./src/media-understanding-provider.runtime.js")> | undefined;
const load = () => (runtime ??= import("./src/media-understanding-provider.runtime.js"));
return {
id: CODEX_PROVIDER_ID,
capabilities: ["image"],
...(DEFAULT_CODEX_IMAGE_MODEL ? { defaultModels: { image: DEFAULT_CODEX_IMAGE_MODEL } } : {}),
describeImage: async ({ buffer, fileName, mime, ...request }) =>
await (
await load()
).describeCodexImages({ ...request, images: [{ buffer, fileName, mime }] }, options),
describeImages: async (request) => await (await load()).describeCodexImages(request, options),
extractStructured: async (request) =>
await (await load()).extractCodexStructured(request, options),
describeImage: async (req) =>
describeCodexImages(
{
images: [
{
buffer: req.buffer,
fileName: req.fileName,
mime: req.mime,
},
],
provider: req.provider,
model: req.model,
prompt: req.prompt,
maxTokens: req.maxTokens,
timeoutMs: req.timeoutMs,
profile: req.profile,
preferredProfile: req.preferredProfile,
authStore: req.authStore,
agentDir: req.agentDir,
cfg: req.cfg,
},
options,
),
describeImages: async (req) => describeCodexImages(req, options),
extractStructured: async (req) => extractCodexStructured(req, options),
};
}
async function describeCodexImages(
req: ImagesDescriptionRequest,
options: CodexMediaUnderstandingProviderOptions,
): Promise<ImagesDescriptionResult> {
const model = req.model.trim();
if (!model) {
throw new Error("Codex image understanding requires model id.");
}
const { text } = await runBoundedCodexAppServerTurn({
config: req.cfg,
model: { mode: "required", id: model },
profile: req.profile,
timeoutMs: req.timeoutMs,
agentDir: req.agentDir,
authProfileStore: req.authStore,
options,
taskLabel: "image understanding",
developerInstructions:
"You are OpenClaw's bounded image-understanding worker. Describe only the provided image content. Do not call tools, edit files, or ask follow-up questions.",
input: [
{ type: "text", text: buildCodexImagePrompt(req), text_elements: [] },
...req.images.map((image) => ({
type: "image" as const,
url: `data:${image.mime ?? "image/png"};base64,${image.buffer.toString("base64")}`,
})),
],
requiredModalities: ["text", "image"],
isolation: "configured-transport",
});
return { text, model };
}
async function extractCodexStructured(
req: StructuredExtractionRequest,
options: CodexMediaUnderstandingProviderOptions,
): Promise<StructuredExtractionResult> {
const model = req.model.trim();
if (!model) {
throw new Error("Codex structured extraction requires model id.");
}
const instructions = req.instructions.trim();
if (!instructions) {
throw new Error("Codex structured extraction requires instructions.");
}
if (req.input.length === 0) {
throw new Error("Codex structured extraction requires at least one input.");
}
if (!req.input.some((entry) => entry.type === "image")) {
throw new Error("Codex structured extraction requires at least one image input.");
}
const { text } = await runBoundedCodexAppServerTurn({
config: req.cfg,
model: { mode: "required", id: model },
profile: req.profile,
timeoutMs: req.timeoutMs,
agentDir: req.agentDir,
authProfileStore: req.authStore,
options,
taskLabel: "structured extraction",
developerInstructions:
"You are OpenClaw's bounded structured-extraction worker. Return only the requested extraction. Do not call tools, edit files, ask follow-up questions, or include secrets.",
input: buildCodexStructuredInput(req),
requiredModalities: requiredStructuredModalities(),
isolation: "configured-transport",
});
return normalizeStructuredExtractionResult({ text, model, provider: req.provider, req });
}
function buildCodexImagePrompt(req: ImagesDescriptionRequest): string {
const prompt = req.prompt?.trim() || DEFAULT_CODEX_IMAGE_PROMPT;
if (req.images.length <= 1) {
return prompt;
}
return `${prompt}\n\nAnalyze all ${req.images.length} images together.`;
}
function requiredStructuredModalities(): string[] {
return ["text", "image"];
}
function buildCodexStructuredInput(req: StructuredExtractionRequest): CodexUserInput[] {
return [
{ type: "text", text: buildStructuredExtractionPrompt(req), text_elements: [] },
...req.input.map((entry) => {
if (entry.type === "text") {
return { type: "text" as const, text: entry.text, text_elements: [] };
}
return {
type: "image" as const,
url: `data:${entry.mime ?? "image/png"};base64,${entry.buffer.toString("base64")}`,
};
}),
];
}
function buildStructuredExtractionPrompt(req: StructuredExtractionRequest): string {
return [
req.instructions.trim(),
req.schemaName ? `Schema name: ${req.schemaName}` : undefined,
req.jsonSchema ? `JSON schema:\n${JSON.stringify(req.jsonSchema)}` : undefined,
req.jsonMode === false
? "Return the extraction as concise text."
: "Return valid JSON only. Do not wrap the JSON in Markdown fences.",
]
.filter((part): part is string => Boolean(part))
.join("\n\n");
}
function isJsonSchemaObject(value: unknown): value is JsonSchemaObject {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function normalizeStructuredExtractionResult(params: {
text: string;
model: string;
provider: string;
req: StructuredExtractionRequest;
}): StructuredExtractionResult {
const result: StructuredExtractionResult = {
text: params.text,
model: params.model,
provider: params.provider,
contentType: params.req.jsonMode === false ? "text" : "json",
};
if (params.req.jsonMode !== false) {
try {
result.parsed = JSON.parse(params.text);
} catch {
throw new Error("Codex structured extraction returned invalid JSON.");
}
if (isJsonSchemaObject(params.req.jsonSchema)) {
const validation = validateJsonSchemaValue({
schema: params.req.jsonSchema,
cacheKey: "codex.media-understanding.extractStructured",
value: result.parsed,
cache: false,
});
if (!validation.ok) {
const message = validation.errors.map((error) => error.text).join("; ") || "invalid";
throw new Error(`Codex structured extraction JSON did not match schema: ${message}`);
}
result.parsed = validation.value;
}
}
return result;
}

View File

@@ -1,12 +1,12 @@
{
"name": "@openclaw/codex",
"version": "2026.6.8",
"version": "2026.6.10-beta.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/codex",
"version": "2026.6.8",
"version": "2026.6.10-beta.2",
"dependencies": {
"@openai/codex": "0.139.0",
"typebox": "1.1.39",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/codex",
"version": "2026.6.8",
"version": "2026.6.10-beta.2",
"description": "OpenClaw Codex app-server harness and model provider plugin with a Codex-managed GPT catalog.",
"repository": {
"type": "git",
@@ -34,10 +34,10 @@
]
},
"compat": {
"pluginApi": ">=2026.6.8"
"pluginApi": ">=2026.6.10-beta.2"
},
"build": {
"openclawVersion": "2026.6.8"
"openclawVersion": "2026.6.10-beta.2"
},
"release": {
"publishToClawHub": true,

View File

@@ -4,10 +4,10 @@ import { CODEX_GPT5_BEHAVIOR_CONTRACT } from "./prompt-overlay.js";
import { codexProviderDiscovery } from "./provider-discovery.js";
import { buildCodexProvider, buildCodexProviderCatalog } from "./provider.js";
import { CodexAppServerClient } from "./src/app-server/client.js";
import type { listAllCodexAppServerModels } from "./src/app-server/models.js";
import type { listCodexAppServerModels } from "./src/app-server/models.js";
import {
createIsolatedCodexAppServerClient,
leaseSharedCodexAppServerClient,
getSharedCodexAppServerClient,
resetSharedCodexAppServerClientForTests,
} from "./src/app-server/shared-client.js";
@@ -26,8 +26,7 @@ function createFakeCodexClient(): CodexAppServerClient {
return {
initialize: vi.fn(async () => undefined),
request: vi.fn(async () => ({ data: [] })),
addNotificationHandler: vi.fn(() => () => undefined),
addRequestHandler: vi.fn(() => () => undefined),
setActiveSharedLeaseCountProviderForUnscopedNotifications: vi.fn(),
addCloseHandler: vi.fn(() => () => undefined),
close: vi.fn(),
} as unknown as CodexAppServerClient;
@@ -40,7 +39,7 @@ const TEST_CODEX_APP_SERVER_CONFIG = {
};
async function listTestCodexAppServerModels(
options: Parameters<typeof listAllCodexAppServerModels>[0] = {},
options: Parameters<typeof listCodexAppServerModels>[0] = {},
) {
expect(options.sharedClient).toBe(false);
const client = await createIsolatedCodexAppServerClient({
@@ -184,33 +183,45 @@ describe("codex provider", () => {
expect(resultProvider?.models.map((model) => model.id)).toEqual(["gpt-5.4"]);
});
it("delegates all-page discovery to one model lister call", async () => {
const listModels = vi.fn(async () => ({
models: [
{
id: "gpt-5.4",
model: "gpt-5.4",
hidden: false,
inputModalities: ["text", "image"],
supportedReasoningEfforts: ["medium"],
},
{
id: "gpt-5.5",
model: "gpt-5.5",
hidden: false,
inputModalities: ["text"],
supportedReasoningEfforts: [],
},
],
}));
it("pages through live discovery before building the provider catalog", async () => {
const listModels = vi
.fn()
.mockResolvedValueOnce({
models: [
{
id: "gpt-5.4",
model: "gpt-5.4",
hidden: false,
inputModalities: ["text", "image"],
supportedReasoningEfforts: ["medium"],
},
],
nextCursor: "page-2",
})
.mockResolvedValueOnce({
models: [
{
id: "gpt-5.5",
model: "gpt-5.5",
hidden: false,
inputModalities: ["text"],
supportedReasoningEfforts: [],
},
],
});
const result = await buildCodexProviderCatalog({
env: {},
listModels,
});
expect(listModels).toHaveBeenCalledTimes(1);
expectRecordFields(mockCallArg(listModels, 0), {
cursor: undefined,
limit: 100,
sharedClient: false,
});
expectRecordFields(mockCallArg(listModels, 1), {
cursor: "page-2",
limit: 100,
sharedClient: false,
});
@@ -266,7 +277,7 @@ describe("codex provider", () => {
.mockReturnValueOnce(activeClient)
.mockReturnValueOnce(discoveryClient);
await leaseSharedCodexAppServerClient({
await getSharedCodexAppServerClient({
startOptions: {
transport: "stdio",
command: "/tmp/openclaw-test-codex",

View File

@@ -18,11 +18,16 @@ import {
CODEX_PROVIDER_ID,
FALLBACK_CODEX_MODELS,
} from "./provider-catalog.js";
import type { CodexAppServerStartOptions } from "./src/app-server/config.js";
import {
type CodexAppServerStartOptions,
readCodexPluginConfig,
resolveCodexAppServerRuntimeOptions,
} from "./src/app-server/config.js";
import type {
CodexAppServerModel,
CodexAppServerModelListResult,
} from "./src/app-server/models.js";
import { buildCodexAppServerUsageSnapshot } from "./src/app-server/rate-limits.js";
const DEFAULT_DISCOVERY_TIMEOUT_MS = 2500;
const LIVE_DISCOVERY_ENV = "OPENCLAW_CODEX_DISCOVERY_LIVE";
@@ -34,6 +39,7 @@ const codexCatalogLog = createSubsystemLogger("codex/catalog");
type CodexModelLister = (options: {
timeoutMs: number;
limit?: number;
cursor?: string;
startOptions?: CodexAppServerStartOptions;
sharedClient?: boolean;
}) => Promise<CodexAppServerModelListResult>;
@@ -117,11 +123,6 @@ export function buildCodexProvider(options: BuildCodexProviderOptions = {}): Pro
}
const runtimePluginConfig = resolvePluginConfigObject(ctx.config, CODEX_PROVIDER_ID);
const pluginConfig = runtimePluginConfig ?? (ctx.config ? undefined : options.pluginConfig);
const [{ resolveCodexAppServerRuntimeOptions }, { buildCodexAppServerUsageSnapshot }] =
await Promise.all([
import("./src/app-server/config.js"),
import("./src/app-server/rate-limits.js"),
]);
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
const rateLimits = await (options.readRateLimits ?? requestCodexAppServerRateLimitsLazy)({
timeoutMs: ctx.timeoutMs,
@@ -155,15 +156,13 @@ export function buildCodexProvider(options: BuildCodexProviderOptions = {}): Pro
export async function buildCodexProviderCatalog(
options: BuildCatalogOptions = {},
): Promise<{ provider: ModelProviderConfig }> {
const { readCodexPluginConfig, resolveCodexAppServerRuntimeOptions } =
await import("./src/app-server/config.js");
const config = readCodexPluginConfig(options.pluginConfig);
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig: options.pluginConfig });
const timeoutMs = normalizeTimeoutMs(config.discovery?.timeoutMs);
let discovered: CodexAppServerModel[] = [];
if (config.discovery?.enabled !== false && !shouldSkipLiveDiscovery(options.env)) {
discovered = await listModelsBestEffort({
listModels: options.listModels ?? listAllCodexAppServerModelsLazy,
listModels: options.listModels ?? listCodexAppServerModelsLazy,
timeoutMs,
startOptions: appServer.start,
onDiscoveryFailure: options.onDiscoveryFailure,
@@ -201,14 +200,22 @@ async function listModelsBestEffort(params: {
onDiscoveryFailure?: (error: unknown) => void;
}): Promise<CodexAppServerModel[]> {
try {
// The all-pages helper keeps one app-server client alive across pagination.
const result = await params.listModels({
timeoutMs: params.timeoutMs,
limit: MODEL_DISCOVERY_PAGE_LIMIT,
startOptions: params.startOptions,
sharedClient: false,
});
return result.models.filter((model) => !model.hidden);
const models: CodexAppServerModel[] = [];
let cursor: string | undefined;
do {
// App-server model listing is paginated; collect every visible model so
// aliases and picker rows match the current Codex account.
const result = await params.listModels({
timeoutMs: params.timeoutMs,
limit: MODEL_DISCOVERY_PAGE_LIMIT,
cursor,
startOptions: params.startOptions,
sharedClient: false,
});
models.push(...result.models.filter((model) => !model.hidden));
cursor = result.nextCursor;
} while (cursor);
return models;
} catch (error) {
params.onDiscoveryFailure?.(error);
codexCatalogLog.debug("codex model discovery failed; using fallback catalog", {
@@ -218,14 +225,15 @@ async function listModelsBestEffort(params: {
}
}
async function listAllCodexAppServerModelsLazy(options: {
async function listCodexAppServerModelsLazy(options: {
timeoutMs: number;
limit?: number;
cursor?: string;
startOptions?: CodexAppServerStartOptions;
sharedClient?: boolean;
}): Promise<CodexAppServerModelListResult> {
const { listAllCodexAppServerModels } = await import("./src/app-server/models.js");
return listAllCodexAppServerModels(options);
const { listCodexAppServerModels } = await import("./src/app-server/models.js");
return listCodexAppServerModels(options);
}
async function requestCodexAppServerRateLimitsLazy(options: {

View File

@@ -1,6 +1,9 @@
// Codex tests cover app server policy plugin behavior.
import { describe, expect, it } from "vitest";
import { resolveCodexAppServerForOpenClawToolPolicy } from "./app-server-policy.js";
import {
resolveCodexAppServerForModelProvider,
resolveCodexAppServerForOpenClawToolPolicy,
} from "./app-server-policy.js";
import { readCodexPluginConfig, resolveCodexAppServerRuntimeOptions } from "./config.js";
describe("Codex app-server policy", () => {
@@ -66,4 +69,143 @@ describe("Codex app-server policy", () => {
expect(explicitEnv.approvalPolicy).toBe("never");
expect(explicitRequirements.approvalPolicy).toBe("never");
});
it("keeps model-backed reviewers for explicit OpenAI model providers", () => {
const appServer = resolveCodexAppServerRuntimeOptions({
env: {},
requirementsToml: null,
execMode: "auto",
modelProvider: "openai",
});
expect(
resolveCodexAppServerForModelProvider({
appServer,
provider: "codex",
model: "openai/gpt-5.5",
}).approvalsReviewer,
).toBe("auto_review");
expect(
resolveCodexAppServerForModelProvider({
appServer,
provider: "codex",
model: "gpt-5.5",
}).approvalsReviewer,
).toBe("user");
expect(
resolveCodexAppServerForModelProvider({ appServer, provider: "openai" }).approvalsReviewer,
).toBe("auto_review");
});
it("uses human approval for OpenAI-compatible custom endpoints", () => {
const appServer = resolveCodexAppServerRuntimeOptions({
env: {},
requirementsToml: null,
execMode: "auto",
modelProvider: "openai",
model: "gpt-5.5",
config: {
models: {
providers: {
openai: {
baseUrl: "http://localhost:8080/v1",
models: [],
},
},
},
},
});
expect(appServer.approvalsReviewer).toBe("user");
expect(
resolveCodexAppServerForModelProvider({
appServer,
provider: "openai",
model: "gpt-5.5",
config: {
models: {
providers: {
openai: {
baseUrl: "http://localhost:8080/v1",
models: [],
},
},
},
},
}).approvalsReviewer,
).toBe("user");
});
it("uses human approval instead of Codex Guardian for custom model providers", () => {
const appServer = resolveCodexAppServerRuntimeOptions({
env: {},
requirementsToml: null,
execMode: "auto",
modelProvider: "openai",
});
const resolved = resolveCodexAppServerForModelProvider({
appServer,
provider: "lmstudio",
});
const vendorPrefixedModel = resolveCodexAppServerForModelProvider({
appServer,
provider: "openrouter",
model: "openai/gpt-5.5",
});
expect(appServer.approvalsReviewer).toBe("auto_review");
expect(resolved.approvalPolicy).toBe("on-request");
expect(resolved.sandbox).toBe("workspace-write");
expect(resolved.approvalsReviewer).toBe("user");
expect(vendorPrefixedModel.approvalsReviewer).toBe("user");
});
it("infers custom providers from provider-qualified model refs", () => {
const appServer = resolveCodexAppServerRuntimeOptions({
env: {},
requirementsToml: null,
execMode: "auto",
});
expect(
resolveCodexAppServerForModelProvider({
appServer,
model: "lmstudio/local-model",
}).approvalsReviewer,
).toBe("user");
});
it("uses provider-qualified model refs to override broad native provider wrappers", () => {
const appServer = resolveCodexAppServerRuntimeOptions({
env: {},
requirementsToml: null,
execMode: "auto",
});
expect(
resolveCodexAppServerForModelProvider({
appServer,
provider: "codex",
model: "lmstudio/local-model",
}).approvalsReviewer,
).toBe("user");
});
it("downgrades legacy guardian_subagent for custom model providers", () => {
const appServer = resolveCodexAppServerRuntimeOptions({
env: {},
requirementsToml: null,
pluginConfig: {
appServer: {
mode: "guardian",
approvalsReviewer: "guardian_subagent",
},
},
});
expect(
resolveCodexAppServerForModelProvider({ appServer, provider: "local" }).approvalsReviewer,
).toBe("user");
});
});

View File

@@ -2,10 +2,11 @@
* Policy promotion for Codex app-server runs that can safely use OpenClaw tool
* approvals.
*/
import type {
CodexAppServerRuntimeOptions,
CodexPluginConfig,
OpenClawExecPolicyForCodexAppServer,
import {
canUseCodexModelBackedApprovalsReviewerForModel,
type CodexAppServerRuntimeOptions,
type CodexPluginConfig,
type OpenClawExecPolicyForCodexAppServer,
} from "./config.js";
/**
@@ -44,6 +45,35 @@ export function resolveCodexAppServerForOpenClawToolPolicy(params: {
};
}
export function resolveCodexAppServerForModelProvider(params: {
appServer: CodexAppServerRuntimeOptions;
provider?: string;
model?: string;
config?: Parameters<typeof canUseCodexModelBackedApprovalsReviewerForModel>[0]["config"];
env?: NodeJS.ProcessEnv;
agentDir?: string;
codexConfigToml?: string | null;
}): CodexAppServerRuntimeOptions {
const explicitProvider = normalizeModelBackedReviewerProvider(params.provider);
if (
!isCodexModelBackedApprovalsReviewer(params.appServer.approvalsReviewer) ||
canUseCodexModelBackedApprovalsReviewerForModel({
modelProvider: explicitProvider,
model: params.model,
config: params.config,
env: params.env,
agentDir: params.agentDir,
codexConfigToml: params.codexConfigToml,
})
) {
return params.appServer;
}
return {
...params.appServer,
approvalsReviewer: "user",
};
}
function isCodexAppServerPolicyMode(value: unknown): boolean {
return value === "guardian" || value === "yolo";
}
@@ -53,3 +83,12 @@ function isCodexAppServerApprovalPolicy(value: unknown): boolean {
value === "never" || value === "on-request" || value === "on-failure" || value === "untrusted"
);
}
function isCodexModelBackedApprovalsReviewer(value: string): boolean {
return value === "auto_review" || value === "guardian_subagent";
}
function normalizeModelBackedReviewerProvider(provider: string | undefined): string | undefined {
const normalized = provider?.trim().toLowerCase();
return normalized || undefined;
}

View File

@@ -285,7 +285,8 @@ function matchesCurrentTurn(
if (!requestParams) {
return false;
}
const requestThreadId = readString(requestParams, "threadId");
const requestThreadId =
readString(requestParams, "threadId") ?? readString(requestParams, "conversationId");
const requestTurnId = readString(requestParams, "turnId");
return requestThreadId === threadId && requestTurnId === turnId;
}

View File

@@ -2,41 +2,10 @@
import { describe, expect, it, vi } from "vitest";
import {
interruptCodexTurnBestEffort,
runCodexTurnStartWithLease,
settleCodexAppServerClientLease,
unsubscribeCodexThreadBestEffort,
validateCodexThreadCreationResponse,
} from "./attempt-client-cleanup.js";
import { CodexAppServerRpcError } from "./client.js";
describe("Codex app-server attempt client cleanup", () => {
it("keeps the client lease after a structured turn-start rejection", async () => {
const abandon = vi.fn(async () => undefined);
const error = new CodexAppServerRpcError({ message: "turn rejected" }, "turn/start");
await expect(
runCodexTurnStartWithLease({ abandon } as never, async () => {
throw error;
}),
).rejects.toBe(error);
expect(abandon).not.toHaveBeenCalled();
});
it("abandons only the exact client lease after an ambiguous turn-start timeout", async () => {
const abandon = vi.fn(async () => undefined);
const otherAbandon = vi.fn(async () => undefined);
await expect(
runCodexTurnStartWithLease({ abandon } as never, async () => {
throw new Error("turn/start timed out");
}),
).rejects.toThrow("turn/start timed out");
expect(abandon).toHaveBeenCalledTimes(1);
expect(otherAbandon).not.toHaveBeenCalled();
});
it("interrupts turns with optional request timeout", () => {
const request = vi.fn(async () => ({}));
@@ -53,58 +22,7 @@ describe("Codex app-server attempt client cleanup", () => {
);
});
it("unsubscribes a retained thread when its create response is malformed", async () => {
const request = vi.fn(async () => ({}));
const abandon = vi.fn(async () => undefined);
const invalidResponse = { thread: { id: "thread-1" } };
await expect(
validateCodexThreadCreationResponse(
{ client: { request } as never, abandon },
invalidResponse,
() => {
throw new Error("invalid thread/start response");
},
),
).rejects.toThrow("invalid thread/start response");
expect(request).toHaveBeenCalledWith(
"thread/unsubscribe",
{ threadId: "thread-1" },
{ timeoutMs: 5_000 },
);
expect(abandon).not.toHaveBeenCalled();
});
it.each([
["omits the retained thread id", {}, vi.fn(async () => ({}))],
[
"cannot confirm unsubscribe",
{ thread: { id: "thread-1" } },
vi.fn(async () => {
throw new Error("connection lost");
}),
],
])(
"retires the client when a malformed create response %s",
async (_label, response, request) => {
const abandon = vi.fn(async () => undefined);
await expect(
validateCodexThreadCreationResponse(
{ client: { request } as never, abandon },
response,
() => {
throw new Error("invalid thread/start response");
},
),
).rejects.toThrow("subscription could not be released");
expect(abandon).toHaveBeenCalledOnce();
},
);
it("reports unsubscribe cleanup failures", async () => {
it("swallows unsubscribe cleanup failures", async () => {
const request = vi.fn(async () => {
throw new Error("already gone");
});
@@ -114,7 +32,7 @@ describe("Codex app-server attempt client cleanup", () => {
threadId: "thread-1",
timeoutMs: 123,
}),
).resolves.toBe(false);
).resolves.toBeUndefined();
expect(request).toHaveBeenCalledWith(
"thread/unsubscribe",
@@ -122,31 +40,4 @@ describe("Codex app-server attempt client cleanup", () => {
{ timeoutMs: 123 },
);
});
it("returns leases only after thread cleanup is confirmed", async () => {
const release = vi.fn();
const abandon = vi.fn(async () => undefined);
await settleCodexAppServerClientLease(
{ client: { request: vi.fn(async () => ({})) }, release, abandon } as never,
{ threadId: "thread-ok", timeoutMs: 123 },
);
expect(release).toHaveBeenCalledOnce();
expect(abandon).not.toHaveBeenCalled();
release.mockClear();
await settleCodexAppServerClientLease(
{
client: {
request: vi.fn(async () => {
throw new Error("unsubscribe failed");
}),
},
release,
abandon,
} as never,
{ threadId: "thread-stale", timeoutMs: 123 },
);
expect(release).not.toHaveBeenCalled();
expect(abandon).toHaveBeenCalledOnce();
});
});

View File

@@ -2,124 +2,60 @@
* Best-effort cleanup helpers for Codex app-server startup attempts and turns.
*/
import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness-runtime";
import { CodexAppServerRpcError, type CodexAppServerClient } from "./client.js";
import { isJsonObject, readCodexThreadCreationResponseId } from "./protocol.js";
import type { CodexAppServerClientLease } from "./shared-client.js";
import type { CodexAppServerClient } from "./client.js";
import {
clearSharedCodexAppServerClientIfCurrent,
clearSharedCodexAppServerClientIfCurrentAndUnclaimed,
retireSharedCodexAppServerClientIfCurrent,
} from "./shared-client.js";
/** Timeout for best-effort app-server turn interruption during cleanup. */
export const CODEX_APP_SERVER_INTERRUPT_TIMEOUT_MS = 5_000;
/** Timeout for best-effort thread unsubscribe during cleanup. */
export const CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS = 5_000;
/** The connection's thread-subscription ownership can no longer be proven. */
export class CodexAppServerUnsafeSubscriptionError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = "CodexAppServerUnsafeSubscriptionError";
async function closeClientAndWaitIfAvailable(client: CodexAppServerClient): Promise<void> {
const closeable = client as {
close?: CodexAppServerClient["close"];
closeAndWait?: CodexAppServerClient["closeAndWait"];
};
if (typeof closeable.closeAndWait === "function") {
await closeable.closeAndWait();
return;
}
closeable.close?.();
}
export function isCodexAppServerUnsafeSubscriptionError(
error: unknown,
): error is CodexAppServerUnsafeSubscriptionError {
return error instanceof CodexAppServerUnsafeSubscriptionError;
}
/** A resume response may only describe the thread this connection retained. */
export function assertCodexThreadResumeSubscription(
requestedThreadId: string,
returnedThreadId: string,
): void {
if (returnedThreadId !== requestedThreadId) {
throw new CodexAppServerUnsafeSubscriptionError(
`Codex thread/resume returned ${returnedThreadId} for ${requestedThreadId}`,
);
export async function closeCodexStartupClientBestEffort(
client: CodexAppServerClient | undefined,
): Promise<void> {
if (!client) {
return;
}
}
/** Retires the exact client lease when turn acceptance is ambiguous. */
export async function runCodexTurnStartWithLease<T>(
lease: CodexAppServerClientLease,
startTurn: () => Promise<T>,
): Promise<T> {
try {
return await startTurn();
} catch (error) {
// Structured RPC rejection happens before Codex accepts the turn. Transport,
// timeout, and abort failures may hide an accepted turn with an unknown id.
if (!(error instanceof CodexAppServerRpcError)) {
await lease.abandon();
const unclaimedSharedClient = clearSharedCodexAppServerClientIfCurrentAndUnclaimed(client);
if (unclaimedSharedClient.closed) {
await closeClientAndWaitIfAvailable(client);
return;
}
if (unclaimedSharedClient.found) {
const retired = retireSharedCodexAppServerClientIfCurrent(client);
if (retired?.closed) {
await closeClientAndWaitIfAvailable(client);
}
throw error;
return;
}
}
/** Retries once when native work wins the race immediately before turn/start. */
export async function runCodexTurnStartWithNativeTurnRetry<T>(params: {
startTurn: () => Promise<T>;
waitForActiveTurnCompletion: () => Promise<boolean>;
afterActiveTurnCompletion?: () => Promise<void>;
onRetry?: () => void;
}): Promise<T> {
try {
return await params.startTurn();
} catch (error) {
if (!isCodexActiveTurnNotSteerableError(error)) {
throw error;
const retiredSharedClient = retireSharedCodexAppServerClientIfCurrent(client);
if (retiredSharedClient) {
if (retiredSharedClient.closed) {
await closeClientAndWaitIfAvailable(client);
}
params.onRetry?.();
if (!(await params.waitForActiveTurnCompletion())) {
throw error;
}
await params.afterActiveTurnCompletion?.();
return await params.startTurn();
return;
}
}
/** True for Codex's structured rejection when native work already owns the thread. */
export function isCodexActiveTurnNotSteerableError(error: unknown): boolean {
if (!(error instanceof CodexAppServerRpcError) || !isJsonObject(error.data)) {
return false;
}
const info = error.data.codexErrorInfo;
return isJsonObject(info) && isJsonObject(info.activeTurnNotSteerable);
}
/** Validates a create response and retires the client unless cleanup is confirmed. */
export async function validateCodexThreadCreationResponse<T>(
owner: {
client: CodexAppServerClient;
abandon: () => Promise<void>;
},
response: unknown,
validate: (value: unknown) => T,
): Promise<T> {
try {
return validate(response);
} catch (error) {
const threadId = readCodexThreadCreationResponseId(response);
const released = threadId
? await unsubscribeCodexThreadBestEffort(owner.client, {
threadId,
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
})
: false;
if (released) {
throw error;
}
try {
await owner.abandon();
} catch (abandonError) {
throw new CodexAppServerUnsafeSubscriptionError(
"Codex thread creation response was invalid and its client could not be retired",
{ cause: abandonError },
);
}
throw new CodexAppServerUnsafeSubscriptionError(
"Codex thread creation response was invalid and its subscription could not be released",
{ cause: error },
);
if (clearSharedCodexAppServerClientIfCurrent(client)) {
await closeClientAndWaitIfAvailable(client);
return;
}
await closeClientAndWaitIfAvailable(client);
}
/** Sends a turn interrupt without blocking abort cleanup on app-server errors. */
@@ -148,56 +84,28 @@ export function interruptCodexTurnBestEffort(
}
}
/** Unsubscribes from a thread and reports whether wire cleanup was confirmed. */
/** Unsubscribes from a thread while swallowing cleanup-only failures. */
export async function unsubscribeCodexThreadBestEffort(
client: CodexAppServerClient,
params: {
threadId: string;
timeoutMs: number;
},
): Promise<boolean> {
): Promise<void> {
try {
await client.request(
"thread/unsubscribe",
{ threadId: params.threadId },
{ timeoutMs: params.timeoutMs },
);
return true;
} catch (error) {
embeddedAgentLog.debug("codex app-server thread unsubscribe cleanup failed", {
threadId: params.threadId,
error,
});
return false;
}
}
/** Returns one exact client lease to the pool only after subscription cleanup succeeds. */
export async function settleCodexAppServerClientLease(
lease: CodexAppServerClientLease,
params: {
threadId?: string;
timeoutMs: number;
abandon?: boolean;
},
): Promise<void> {
if (params.abandon) {
await lease.abandon();
return;
}
if (
params.threadId &&
!(await unsubscribeCodexThreadBestEffort(lease.client, {
threadId: params.threadId,
timeoutMs: params.timeoutMs,
}))
) {
await lease.abandon();
return;
}
lease.release();
}
/**
* Retires the shared client after a timed-out turn so later runs do not reuse a
* potentially wedged app-server connection.
@@ -208,9 +116,10 @@ export async function retireCodexAppServerClientAfterTimedOutTurn(
threadId: string;
turnId: string;
reason: string;
abandonClientLease: () => Promise<void>;
},
): Promise<void> {
const retiredSharedClient = retireSharedCodexAppServerClientIfCurrent(client);
const detachedSharedClient = Boolean(retiredSharedClient);
interruptCodexTurnBestEffort(client, {
threadId: params.threadId,
turnId: params.turnId,
@@ -220,10 +129,28 @@ export async function retireCodexAppServerClientAfterTimedOutTurn(
threadId: params.threadId,
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
});
await params.abandonClientLease();
let closedClient = retiredSharedClient?.closed ?? false;
if (!detachedSharedClient) {
const close = (client as { close?: () => void }).close;
if (typeof close === "function") {
try {
close.call(client);
closedClient = true;
} catch (error) {
embeddedAgentLog.debug("codex app-server client close failed during timeout cleanup", {
threadId: params.threadId,
turnId: params.turnId,
error,
});
}
}
}
embeddedAgentLog.warn("codex app-server client retired after timed-out turn", {
threadId: params.threadId,
turnId: params.turnId,
reason: params.reason,
detachedSharedClient,
closedClient,
activeSharedClientLeases: retiredSharedClient?.activeLeases ?? 0,
});
}

View File

@@ -9,6 +9,7 @@ import {
isFileChangePatchUpdatedNotification,
isAssistantCommentaryCompletionNotification,
isNativeToolProgressNotification,
isNativeResponseStreamDeltaNotification,
isPendingOpenClawDynamicToolCompletionNotification,
isRawAssistantProgressNotification,
isRawReasoningCompletionNotification,
@@ -16,6 +17,7 @@ import {
isReasoningProgressNotification,
isReasoningItemCompletionNotification,
isRetryableErrorNotification,
isTurnNotification,
readCodexNotificationItem,
readNotificationItemId,
shouldDisarmAssistantCompletionIdleWatch,
@@ -23,7 +25,6 @@ import {
} from "./attempt-notifications.js";
import { CODEX_POST_REASONING_REPLY_IDLE_TIMEOUT_MS } from "./attempt-timeouts.js";
import type { CodexAttemptTurnWatchController } from "./attempt-turn-watches.js";
import { isCodexNotificationForTurn } from "./notification-correlation.js";
import type { CodexServerNotification } from "./protocol.js";
type CodexExecutionPhase =
@@ -69,7 +70,7 @@ export function isTerminalCodexTurnNotificationForTurn(params: {
turnId: string;
currentPromptTexts: string[];
}): boolean {
if (!isCodexNotificationForTurn(params.notification.params, params.threadId, params.turnId)) {
if (!isTurnNotification(params.notification.params, params.threadId, params.turnId)) {
return false;
}
return (
@@ -104,15 +105,16 @@ export function applyCodexTurnNotificationState(params: {
turnCrossedToolHandoff: boolean;
} {
const { notification, turnWatches } = params;
const isCurrentTurnNotification = isCodexNotificationForTurn(
const isCurrentTurnNotification = isTurnNotification(
notification.params,
params.threadId,
params.turnId,
);
const isTurnCompletion = notification.method === "turn/completed" && isCurrentTurnNotification;
const isNativeResponseStreamDelta = isNativeResponseStreamDeltaNotification(notification);
let turnCrossedToolHandoff = params.turnCrossedToolHandoff;
if (isCurrentTurnNotification) {
if (isCurrentTurnNotification && !isNativeResponseStreamDelta) {
turnWatches.touchActivity(`notification:${notification.method}`, {
details: describeNotificationActivity(notification),
attemptProgress: true,
@@ -248,6 +250,7 @@ export function applyCodexTurnNotificationState(params: {
!turnWatches.isCompletionIdleWatchPinnedByTerminalError() &&
notification.method !== "turn/completed" &&
isCurrentTurnNotification &&
!isNativeResponseStreamDelta &&
!trackedDynamicToolCompletion &&
!rawToolOutputCompletion &&
!postToolProgressNeedsTerminalGuard &&

View File

@@ -1,6 +1,11 @@
/**
* Predicates and readers for Codex app-server notification envelopes.
*/
import { asBoolean } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
describeCodexNotificationCorrelation,
isCodexNotificationForTurn,
} from "./notification-correlation.js";
import {
isJsonObject,
type CodexServerNotification,
@@ -184,7 +189,23 @@ export function isRawToolOutputCompletionNotification(
return false;
}
const item = isJsonObject(notification.params.item) ? notification.params.item : undefined;
return item ? readString(item, "type") === "custom_tool_call_output" : false;
switch (item ? readString(item, "type") : undefined) {
case "custom_tool_call_output":
case "function_call_output":
return true;
default:
return false;
}
}
export function isRawFunctionToolOutputCompletionNotification(
notification: CodexServerNotification,
): boolean {
if (notification.method !== "rawResponseItem/completed" || !isJsonObject(notification.params)) {
return false;
}
const item = isJsonObject(notification.params.item) ? notification.params.item : undefined;
return item ? readString(item, "type") === "function_call_output" : false;
}
/** Returns true for progress on Codex-native tool item types. */
@@ -211,6 +232,13 @@ export function isNativeToolProgressNotification(notification: CodexServerNotifi
}
}
/** Returns true for raw native response stream delta events. */
export function isNativeResponseStreamDeltaNotification(
notification: CodexServerNotification,
): boolean {
return notification.method.startsWith("response.") && notification.method.endsWith(".delta");
}
/** Returns true for file-change patch update notifications. */
export function isFileChangePatchUpdatedNotification(
notification: CodexServerNotification,
@@ -265,9 +293,74 @@ function readRawAssistantTextPreview(item: JsonObject): string | undefined {
return text.length > 240 ? `${text.slice(0, 237)}...` : text;
}
/** Returns true when notification params correlate to a specific thread/turn. */
export function isTurnNotification(
value: JsonValue | undefined,
threadId: string,
turnId: string,
): boolean {
return isCodexNotificationForTurn(value, threadId, turnId);
}
/** Returns true when a correlated notification belongs to another active run. */
export function isCodexNotificationOutsideActiveRun(
correlation: ReturnType<typeof describeCodexNotificationCorrelation>,
): boolean {
const hasThreadScope = Boolean(correlation.threadId || correlation.nestedTurnThreadId);
if (!hasThreadScope) {
return false;
}
if (!correlation.matchesActiveThread) {
return true;
}
const hasTurnScope = Boolean(correlation.turnId || correlation.nestedTurnId);
return hasTurnScope && correlation.matchesActiveTurn === false;
}
/** Checks request params that must contain the current thread and turn ids. */
export function isCurrentThreadTurnRequestParams(
value: JsonValue | undefined,
threadId: string,
turnId: string,
): boolean {
if (!isJsonObject(value)) {
return false;
}
return readString(value, "threadId") === threadId && readString(value, "turnId") === turnId;
}
/** Checks approval request params, accepting `conversationId` as thread id. */
export function isCurrentApprovalTurnRequestParams(
value: JsonValue | undefined,
threadId: string,
turnId: string,
): boolean {
if (!isJsonObject(value)) {
return false;
}
const requestThreadId = readString(value, "threadId") ?? readString(value, "conversationId");
return requestThreadId === threadId && readString(value, "turnId") === turnId;
}
/** Checks request params where `turnId` may be omitted or null for the thread. */
export function isCurrentThreadOptionalTurnRequestParams(
value: JsonValue | undefined,
threadId: string,
turnId: string,
): boolean {
if (!isJsonObject(value) || readString(value, "threadId") !== threadId) {
return false;
}
const requestTurnId = value.turnId;
return requestTurnId === null || requestTurnId === undefined || requestTurnId === turnId;
}
/** Returns true for app-server error notifications that will retry. */
export function isRetryableErrorNotification(value: JsonValue | undefined): boolean {
return isJsonObject(value) && value.willRetry === true;
if (!isJsonObject(value)) {
return false;
}
return readBoolean(value, "willRetry") === true || readBoolean(value, "will_retry") === true;
}
/** Returns true for terminal app-server thread status strings. */
@@ -342,6 +435,10 @@ function readString(record: JsonObject, key: string): string | undefined {
return typeof value === "string" ? value : undefined;
}
function readBoolean(record: JsonObject, key: string): boolean | undefined {
return asBoolean(record[key]);
}
/** Reads a typed Codex item from notification params when id/type are present. */
export function readCodexNotificationItem(
params: JsonValue | undefined,

View File

@@ -9,16 +9,13 @@ import type {
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { startCodexAttemptThread } from "./attempt-startup.js";
import { defaultLeasedCodexAppServerClientFactory } from "./client-factory.js";
import { CodexAppServerClient } from "./client.js";
import { type CodexPluginConfig, resolveCodexAppServerRuntimeOptions } from "./config.js";
import { threadStartResult } from "./run-attempt-test-harness.js";
import {
resetCodexTestBindingStore,
testCodexAppServerBindingStore,
} from "./session-binding.test-helpers.js";
import {
leaseSharedCodexAppServerClient,
resetSharedCodexAppServerClientForTests,
clearSharedCodexAppServerClient,
getLeasedSharedCodexAppServerClient,
releaseLeasedSharedCodexAppServerClient,
} from "./shared-client.js";
import { createClientHarness, createCodexTestModel } from "./test-support.js";
@@ -88,10 +85,12 @@ function startThreadWithHarness(
signal = new AbortController().signal,
overrides?: {
pluginConfig?: CodexPluginConfig;
attemptClientFactory?: (
harness: ClientHarness,
) => Parameters<typeof startCodexAttemptThread>[0]["attemptClientFactory"];
harness?: ClientHarness;
paths?: AttemptPaths;
skipStartSpy?: boolean;
onThreadReserved?: Parameters<typeof startCodexAttemptThread>[0]["onThreadReserved"];
},
) {
const harness = overrides?.harness ?? createClientHarness();
@@ -102,7 +101,8 @@ function startThreadWithHarness(
const effectivePluginConfig = overrides?.pluginConfig ?? pluginConfig;
const run = startCodexAttemptThread({
bindingStore: testCodexAppServerBindingStore,
attemptClientFactory:
overrides?.attemptClientFactory?.(harness) ?? defaultLeasedCodexAppServerClientFactory,
appServer: resolveCodexAppServerRuntimeOptions({ pluginConfig: effectivePluginConfig }),
pluginConfig: effectivePluginConfig,
computerUseConfig: effectivePluginConfig.computerUse ?? { enabled: false },
@@ -125,11 +125,10 @@ function startThreadWithHarness(
sandboxExecServerEnabled: false,
sandbox: null,
contextEngineProjection: undefined,
startupTokenGuard: {},
startupTimeoutMs,
signal,
onStartupTimeout: vi.fn(),
onThreadReserved: overrides?.onThreadReserved,
spawnedBy: undefined,
});
return { harness, run };
@@ -171,13 +170,12 @@ describe("startCodexAttemptThread", () => {
vi.useRealTimers();
vi.stubEnv("CODEX_API_KEY", "");
vi.stubEnv("OPENAI_API_KEY", "");
resetCodexTestBindingStore();
resetSharedCodexAppServerClientForTests();
clearSharedCodexAppServerClient();
});
afterEach(async () => {
vi.useRealTimers();
resetSharedCodexAppServerClientForTests();
clearSharedCodexAppServerClient();
vi.restoreAllMocks();
vi.unstubAllEnvs();
for (const root of tempRoots) {
@@ -186,7 +184,7 @@ describe("startCodexAttemptThread", () => {
tempRoots.clear();
});
it("keeps the shared app-server reusable after a structured startup rejection", async () => {
it("clears the shared app-server when top-level thread startup fails with an app error", async () => {
const { harness, run } = startThreadWithHarness(5_000);
await answerInitialize(harness);
const threadStart = await waitForThreadStart(harness);
@@ -196,57 +194,25 @@ describe("startCodexAttemptThread", () => {
});
await expect(run).rejects.toThrow("Invalid bearer token");
expect(harness.process.stdin.destroyed).toBe(false);
});
it("retires the client when malformed startup cleanup cannot be confirmed", async () => {
const { harness, run } = startThreadWithHarness(5_000);
await answerInitialize(harness);
const threadStart = await waitForThreadStart(harness);
harness.send({ id: threadStart.id, result: { thread: { id: "thread-malformed" } } });
const unsubscribe = await waitForRequest(harness, "thread/unsubscribe");
harness.send({
id: unsubscribe.id,
error: { code: -32000, message: "unsubscribe failed" },
});
await expect(run).rejects.toThrow("subscription could not be released");
expect(harness.process.stdin.destroyed).toBe(true);
});
it("retires the client when route cleanup cannot release the subscription", async () => {
const { harness, run } = startThreadWithHarness(5_000, undefined, {
onThreadReserved: () => {
throw new Error("route integration failed");
},
});
await answerInitialize(harness);
const threadStart = await waitForThreadStart(harness);
harness.send({ id: threadStart.id, result: threadStartResult("thread-route-failed") });
const unsubscribe = await waitForRequest(harness, "thread/unsubscribe");
harness.send({
id: unsubscribe.id,
error: { code: -32000, message: "unsubscribe failed" },
});
await expect(run).rejects.toThrow("Codex startup subscription cleanup failed");
expect(harness.process.stdin.destroyed).toBe(true);
});
it("does not retire a peer-owned client after a structured startup rejection", async () => {
it("retires a failed startup client after another active lease releases", async () => {
const retained = createClientHarness();
const startSpy = vi.spyOn(CodexAppServerClient, "start").mockReturnValue(retained.client);
const replacement = createClientHarness();
const startSpy = vi
.spyOn(CodexAppServerClient, "start")
.mockReturnValueOnce(retained.client)
.mockReturnValueOnce(replacement.client);
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
const paths = createAttemptPaths();
const retainedLeasePromise = leaseSharedCodexAppServerClient({
const retainedLease = getLeasedSharedCodexAppServerClient({
startOptions: appServer.start,
agentDir: paths.agentDir,
preparedAuth: {},
});
await answerInitialize(retained);
const retainedLease = await retainedLeasePromise;
expect(retainedLease.client).toBe(retained.client);
await expect(retainedLease).resolves.toBe(retained.client);
const { run } = startThreadWithHarness(5_000, new AbortController().signal, {
harness: retained,
@@ -262,16 +228,17 @@ describe("startCodexAttemptThread", () => {
await expect(run).rejects.toThrow("Invalid bearer token");
expect(retained.process.stdin.destroyed).toBe(false);
retainedLease.release();
const nextLeasePromise = leaseSharedCodexAppServerClient({
expect(releaseLeasedSharedCodexAppServerClient(retained.client)).toBe(true);
await vi.waitFor(() => expect(retained.process.stdin.destroyed).toBe(true));
const replacementLease = getLeasedSharedCodexAppServerClient({
startOptions: appServer.start,
agentDir: paths.agentDir,
preparedAuth: {},
});
const nextLease = await nextLeasePromise;
expect(nextLease.client).toBe(retained.client);
expect(startSpy).toHaveBeenCalledTimes(1);
nextLease.release();
await answerInitialize(replacement);
await expect(replacementLease).resolves.toBe(replacement.client);
expect(startSpy).toHaveBeenCalledTimes(2);
expect(releaseLeasedSharedCodexAppServerClient(replacement.client)).toBe(true);
});
it("clears the shared app-server when startup abandons an in-flight thread request", async () => {
@@ -293,20 +260,18 @@ describe("startCodexAttemptThread", () => {
expect(harness.stdinDestroyed).toBe(true);
});
it("retires abandoned thread startup even when another lease shares the client", async () => {
it("aborts abandoned thread startup when another lease keeps the shared app-server alive", async () => {
const retained = createClientHarness();
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(retained.client);
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
const paths = createAttemptPaths();
const retainedLeasePromise = leaseSharedCodexAppServerClient({
const retainedLease = getLeasedSharedCodexAppServerClient({
startOptions: appServer.start,
agentDir: paths.agentDir,
preparedAuth: {},
});
await answerInitialize(retained);
const retainedLease = await retainedLeasePromise;
expect(retainedLease.client).toBe(retained.client);
await expect(retainedLease).resolves.toBe(retained.client);
const { run } = startThreadWithHarness(100, new AbortController().signal, {
harness: retained,
@@ -317,9 +282,11 @@ describe("startCodexAttemptThread", () => {
const threadStart = await waitForThreadStart(retained);
await rejected;
expect(threadStart.id).toBeDefined();
expect(retained.process.stdin.destroyed).toBe(true);
retainedLease.release();
expect(retained.process.stdin.destroyed).toBe(false);
retained.send({ id: threadStart.id, result: { threadId: "late-thread" } });
expect(releaseLeasedSharedCodexAppServerClient(retained.client)).toBe(true);
await vi.waitFor(() => expect(retained.process.stdin.destroyed).toBe(true));
});
it("closes the shared app-server when startup times out during initialize", async () => {
@@ -344,37 +311,45 @@ describe("startCodexAttemptThread", () => {
).toBe(false);
});
it("releases a late startup lease without retiring a peer-owned initializing client", async () => {
const harness = createClientHarness();
const startSpy = vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
const paths = createAttemptPaths();
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
const peerPromise = leaseSharedCodexAppServerClient({
startOptions: appServer.start,
agentDir: paths.agentDir,
preparedAuth: {},
it("closes a startup client that arrives after startup timeout", async () => {
let observedFactoryOptions:
| {
onStartedClient?: (client: CodexAppServerClient) => void;
abandonSignal?: AbortSignal;
}
| undefined;
let resolveFactoryDone: () => void = () => undefined;
const factoryDone = new Promise<void>((resolve) => {
resolveFactoryDone = resolve;
});
const { run } = startThreadWithHarness(100, new AbortController().signal, {
harness,
paths,
skipStartSpy: true,
const { harness, run } = startThreadWithHarness(100, new AbortController().signal, {
attemptClientFactory:
(factoryHarness) => async (_startOptions, _authProfileId, _agentDir, _config, options) => {
try {
observedFactoryOptions = options;
await new Promise<void>((resolve) => {
setTimeout(resolve, 250);
});
options?.onStartedClient?.(factoryHarness.client);
return factoryHarness.client;
} finally {
resolveFactoryDone();
}
},
});
const rejected = expect(run).rejects.toThrow("codex app-server startup timed out");
await expect(run).rejects.toThrow("codex app-server startup timed out");
expect(harness.stdinDestroyed).toBe(false);
await answerInitialize(harness);
const peer = await peerPromise;
expect(peer.client).toBe(harness.client);
await new Promise<void>((resolve) => {
setImmediate(resolve);
await rejected;
await factoryDone;
await vi.waitFor(() => expect(harness.stdinDestroyed).toBe(true), {
interval: 1,
timeout: 2_000,
});
expect(startSpy).toHaveBeenCalledTimes(1);
expect(
readHarnessMessages(harness.writes).some((write) => write.method === "thread/start"),
).toBe(false);
await peer.abandon();
expect(harness.stdinDestroyed).toBe(true);
expect(observedFactoryOptions?.onStartedClient).toBeTypeOf("function");
expect(observedFactoryOptions?.abandonSignal?.aborted).toBe(true);
});
it("clears the shared app-server when cancellation abandons an in-flight thread request", async () => {

View File

@@ -11,15 +11,10 @@ import {
type resolveSandboxContext,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { defaultCodexAppInventoryCache } from "./app-inventory-cache.js";
import {
CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
CodexAppServerUnsafeSubscriptionError,
isCodexAppServerUnsafeSubscriptionError,
unsubscribeCodexThreadBestEffort,
} from "./attempt-client-cleanup.js";
import { closeCodexStartupClientBestEffort } from "./attempt-client-cleanup.js";
import { buildCodexPluginThreadConfigEligibilityLogData } from "./attempt-diagnostics.js";
import { withCodexStartupTimeout } from "./attempt-timeouts.js";
import { ensureCodexAppServerClientRuntime } from "./client-runtime.js";
import type { CodexAppServerClientFactory } from "./client-factory.js";
import { isCodexAppServerConnectionClosedError, type CodexAppServerClient } from "./client.js";
import { ensureCodexComputerUse } from "./computer-use.js";
import {
@@ -57,23 +52,16 @@ import {
releaseCodexSandboxExecServerEnvironment,
type CodexSandboxExecEnvironment,
} from "./sandbox-exec-server.js";
import type { CodexAppServerBindingStore } from "./session-binding.js";
import {
leaseSharedCodexAppServerClient,
type CodexAppServerClientLease,
type CodexAppServerClientLeaseFactory,
clearSharedCodexAppServerClientIfCurrent,
releaseLeasedSharedCodexAppServerClient,
} from "./shared-client.js";
import type { CodexAppServerStartupTokenGuard } from "./startup-binding.js";
import {
startOrResumeThread,
type CodexAppServerThreadLifecycleBinding,
type CodexContextEngineThreadBootstrapProjection,
} from "./thread-lifecycle.js";
import {
getCodexAppServerTurnRouter,
type CodexAppServerTurnRouter,
type CodexThreadRouteReservation,
} from "./turn-router.js";
import type { CodexNativeWebSearchSupport } from "./web-search.js";
const CODEX_APP_SERVER_STARTUP_CONNECTION_CLOSE_MAX_ATTEMPTS = 3;
@@ -81,15 +69,14 @@ type CodexSandboxContext = Awaited<ReturnType<typeof resolveSandboxContext>>;
/** Resources and bindings returned after a Codex attempt thread starts. */
export type StartCodexAttemptThreadResult = {
turnRouter: CodexAppServerTurnRouter;
turnRoute: CodexThreadRouteReservation;
client: CodexAppServerClient;
thread: CodexAppServerThreadLifecycleBinding;
pluginAppServer: CodexAppServerRuntimeOptions;
sandboxEnvironment: CodexSandboxExecEnvironment | undefined;
environmentSelection: CodexTurnEnvironmentParams[] | undefined;
executionCwd: string;
sandboxPolicy: CodexSandboxPolicy | undefined;
clientLease: CodexAppServerClientLease;
mcpElicitationDelegationRequired: boolean;
releaseSharedClientLease: () => void;
restartContextEngineCodexThread: () => Promise<CodexAppServerThreadLifecycleBinding>;
};
@@ -98,8 +85,7 @@ export type StartCodexAttemptThreadResult = {
* run loop must later release.
*/
export async function startCodexAttemptThread(params: {
bindingStore: CodexAppServerBindingStore;
clientLeaseFactory?: CodexAppServerClientLeaseFactory;
attemptClientFactory: CodexAppServerClientFactory;
appServer: CodexAppServerRuntimeOptions;
pluginConfig: CodexPluginConfig;
computerUseConfig: CodexComputerUseConfig;
@@ -125,26 +111,18 @@ export async function startCodexAttemptThread(params: {
sandboxExecServerEnabled: boolean;
sandbox: CodexSandboxContext;
contextEngineProjection: CodexContextEngineThreadBootstrapProjection | undefined;
expectedResumeThreadId?: string;
startupTokenGuard: CodexAppServerStartupTokenGuard;
startupTimeoutMs: number;
signal: AbortSignal;
onStartupTimeout: () => void | Promise<void>;
onThreadReserved?: (client: CodexAppServerClient, threadId: string) => () => void;
spawnedBy: EmbeddedRunAttemptParams["spawnedBy"];
}): Promise<StartCodexAttemptThreadResult> {
let mcpElicitationDelegationRequired = false;
let sharedClientLease: CodexAppServerClientLease | undefined;
let pluginAppServer = params.appServer;
let releaseSharedClientLease: (() => void) | undefined;
let startupClientForAbandonedRequestCleanup: CodexAppServerClient | undefined;
let releaseStartupResourcesOnTimeout: (() => Promise<void>) | undefined;
let startupAbandoned = false;
const startupAbandonController = new AbortController();
const abandonStartupAcquire = () => startupAbandonController.abort();
const abandonStartupClient = async () => {
const lease = sharedClientLease;
sharedClientLease = undefined;
if (lease) {
await lease.abandon();
}
};
params.signal.addEventListener("abort", abandonStartupAcquire, { once: true });
try {
const startupResult = await withCodexStartupTimeout({
@@ -155,7 +133,10 @@ export async function startCodexAttemptThread(params: {
startupAbandonController.abort();
await params.onStartupTimeout();
await releaseStartupResourcesOnTimeout?.();
await abandonStartupClient();
releaseSharedClientLease?.();
releaseSharedClientLease = undefined;
await closeCodexStartupClientBestEffort(startupClientForAbandonedRequestCleanup);
startupClientForAbandonedRequestCleanup = undefined;
},
operation: async () => {
const threadConfig = mergeCodexThreadConfigs(
@@ -172,9 +153,8 @@ export async function startCodexAttemptThread(params: {
const resolvedPluginPolicy = pluginThreadConfigRequired
? resolveCodexPluginsPolicy(pluginThreadConfigPluginConfig)
: undefined;
const computerUseMcpElicitationDelegationRequired =
params.computerUseConfig.enabled === true;
mcpElicitationDelegationRequired =
const computerUseMcpElicitationDelegationRequired = params.computerUseConfig.enabled;
const mcpElicitationDelegationRequired =
resolvedPluginPolicy?.enabled === true || computerUseMcpElicitationDelegationRequired;
const enabledPluginConfigKeys = resolvedPluginPolicy
? resolvedPluginPolicy.pluginPolicies
@@ -182,48 +162,55 @@ export async function startCodexAttemptThread(params: {
.map((plugin) => plugin.configKey)
.toSorted()
: undefined;
const pluginAppServer = mcpElicitationDelegationRequired
pluginAppServer = mcpElicitationDelegationRequired
? {
...params.appServer,
approvalPolicy: withMcpElicitationsApprovalPolicy(params.appServer.approvalPolicy),
}
: params.appServer;
let attemptedClientAbandoned = false;
let attemptedClient: CodexAppServerClient | undefined;
const startupAttempt = async () => {
let startupClientLease: CodexAppServerClientLease | undefined;
let clientWorkStarted = false;
attemptedClientAbandoned = false;
let startupClientLease: (() => void) | undefined;
let startupClient: CodexAppServerClient | undefined;
let startupAttemptError: unknown;
let startupAttemptSucceeded = false;
try {
startupClientLease = await (
params.clientLeaseFactory ?? leaseSharedCodexAppServerClient
)({
startOptions: params.appServer.start,
authProfileId: params.startupAuthProfileId,
agentDir: params.agentDir,
config: params.config,
preparedAuth: {
profileId: params.startupAuthProfileId,
cacheKey: params.startupAuthAccountCacheKey ?? params.startupEnvApiKeyCacheKey,
startupClient = await params.attemptClientFactory(
params.appServer.start,
params.startupAuthProfileId,
params.agentDir,
params.config,
{
onStartedClient: (client) => {
// Timeout cleanup may fire before the client factory resolves;
// close any late-arriving client instead of leaking a lease.
startupClientForAbandonedRequestCleanup = client;
if (startupAbandoned || startupAbandonController.signal.aborted) {
void closeCodexStartupClientBestEffort(client);
}
},
abandonSignal: startupAbandonController.signal,
},
abandonSignal: startupAbandonController.signal,
});
const activeStartupLease = startupClientLease;
const activeStartupClient = activeStartupLease.client;
sharedClientLease = startupClientLease;
);
const activeStartupClient = startupClient;
let startupClientLeaseReleased = false;
startupClientLease = () => {
if (startupClientLeaseReleased) {
return;
}
startupClientLeaseReleased = true;
releaseLeasedSharedCodexAppServerClient(activeStartupClient);
};
releaseSharedClientLease = startupClientLease;
attemptedClient = activeStartupClient;
startupClientForAbandonedRequestCleanup = activeStartupClient;
if (startupAbandoned) {
throw new Error("codex app-server startup timed out");
}
if (startupAbandonController.signal.aborted) {
throw new Error("codex app-server startup aborted");
}
clientWorkStarted = true;
ensureCodexAppServerClientRuntime(activeStartupClient, {
agentDir: params.agentDir,
authProfileId: params.startupAuthProfileId,
config: params.config,
});
const turnRouter = getCodexAppServerTurnRouter(activeStartupClient);
await ensureCodexComputerUse({
client: activeStartupClient,
pluginConfig: params.pluginConfig,
@@ -290,6 +277,7 @@ export async function startCodexAttemptThread(params: {
: undefined;
startupSandboxEnvironmentAcquired = Boolean(startupSandboxEnvironment);
if (startupAbandonController.signal.aborted) {
await releaseStartupSandboxEnvironment();
throw new Error("codex app-server startup aborted");
}
if (
@@ -320,57 +308,9 @@ export async function startCodexAttemptThread(params: {
const startupSandboxPolicy = startupSandboxEnvironment
? resolveCodexExternalSandboxPolicyForOpenClawSandbox(params.sandbox)
: undefined;
let startupReservation:
| { route: CodexThreadRouteReservation; release: () => void }
| undefined;
const reserveStartupThread = (threadId: string) => {
if (startupReservation) {
if (startupReservation.route.threadId !== threadId) {
throw new Error(
`codex app-server reserved ${startupReservation.route.threadId} but started ${threadId}`,
);
}
return { release: startupReservation.release };
}
const route = turnRouter.reserveThread({
threadId,
releaseOn: params.signal,
});
let releaseIntegration: (() => void) | undefined;
try {
releaseIntegration = params.onThreadReserved?.(activeStartupClient, threadId);
} catch (error) {
route.release();
throw error;
}
let released = false;
const release = () => {
if (released) {
return;
}
released = true;
if (startupReservation?.route === route) {
startupReservation = undefined;
}
route.release();
releaseIntegration?.();
};
startupReservation = { route, release };
return { release };
};
const releaseStartupResources = async () => {
startupReservation?.release();
await releaseStartupSandboxEnvironment();
};
releaseStartupResourcesOnTimeout = releaseStartupResources;
const buildThreadLifecycleParams = (
signal: AbortSignal,
options: { freshStartOnly?: boolean } = {},
) =>
const buildThreadLifecycleParams = (signal: AbortSignal) =>
({
client: activeStartupClient,
abandonClient: activeStartupLease.abandon,
bindingStore: params.bindingStore,
params: params.buildAttemptParams(),
agentId: params.sessionAgentId,
cwd: startupExecutionCwd,
@@ -392,13 +332,7 @@ export async function startCodexAttemptThread(params: {
environmentSelection: startupEnvironmentSelection,
appServerRuntimeFingerprint,
contextEngineProjection: params.contextEngineProjection,
freshStartOnly: options.freshStartOnly,
expectedResumeThreadId: options.freshStartOnly
? undefined
: params.expectedResumeThreadId,
signal,
reserveResumeThread: options.freshStartOnly ? undefined : reserveStartupThread,
startupTokenGuard: params.startupTokenGuard,
pluginThreadConfig: pluginThreadConfigRequired
? {
enabled: true,
@@ -422,65 +356,57 @@ export async function startCodexAttemptThread(params: {
const startupThread = await startOrResumeThread(
buildThreadLifecycleParams(startupAbandonController.signal),
);
try {
reserveStartupThread(startupThread.threadId);
} catch (error) {
const unsubscribed = await unsubscribeCodexThreadBestEffort(activeStartupClient, {
threadId: startupThread.threadId,
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
});
if (!unsubscribed) {
throw new CodexAppServerUnsafeSubscriptionError(
"Codex startup subscription cleanup failed",
{ cause: error },
);
}
throw error;
}
if (startupAbandonController.signal.aborted) {
await releaseStartupSandboxEnvironment();
throw new Error("codex app-server startup aborted");
}
if (!startupReservation) {
throw new Error("codex app-server startup did not reserve its thread route");
}
startupSandboxEnvironmentAcquired = false;
startupAttemptSucceeded = true;
return {
turnRouter,
turnRoute: startupReservation.route,
client: activeStartupClient,
thread: startupThread,
sandboxEnvironment: startupSandboxEnvironment,
environmentSelection: startupEnvironmentSelection,
executionCwd: startupExecutionCwd,
sandboxPolicy: startupSandboxPolicy,
restartContextEngineCodexThread: () =>
startOrResumeThread(
buildThreadLifecycleParams(params.signal, { freshStartOnly: true }),
),
startOrResumeThread(buildThreadLifecycleParams(params.signal)),
};
} catch (error) {
await releaseStartupResources();
await releaseStartupSandboxEnvironment();
throw error;
} finally {
if (releaseStartupResourcesOnTimeout === releaseStartupResources) {
if (releaseStartupResourcesOnTimeout === releaseStartupSandboxEnvironment) {
releaseStartupResourcesOnTimeout = undefined;
}
}
} catch (error) {
if (sharedClientLease === startupClientLease) {
sharedClientLease = undefined;
}
const shouldAbandonStartupClient =
clientWorkStarted &&
(startupAbandoned ||
params.signal.aborted ||
isIndeterminateCodexStartupFailure(error));
if (shouldAbandonStartupClient) {
attemptedClientAbandoned = true;
await startupClientLease?.abandon();
} else {
startupClientLease?.release();
}
startupAttemptError = error;
throw error;
} finally {
if (!startupAttemptSucceeded) {
if (releaseSharedClientLease === startupClientLease) {
releaseSharedClientLease = undefined;
}
startupClientLease?.();
if (startupAbandoned || params.signal.aborted) {
if (startupClientForAbandonedRequestCleanup === startupClient) {
startupClientForAbandonedRequestCleanup = undefined;
}
await closeCodexStartupClientBestEffort(startupClient);
} else if (
shouldClearSharedClientAfterStartupRace(startupAttemptError) ||
shouldClearSharedClientAfterStartupFailure({
error: startupAttemptError,
spawnedBy: params.spawnedBy,
})
) {
if (startupClientForAbandonedRequestCleanup === startupClient) {
startupClientForAbandonedRequestCleanup = undefined;
}
await closeCodexStartupClientBestEffort(startupClient);
}
}
}
};
@@ -495,13 +421,18 @@ export async function startCodexAttemptThread(params: {
if (params.signal.aborted || !isCodexAppServerConnectionClosedError(error)) {
throw error;
}
const failedClient = attemptedClient;
const clearedSharedClient = clearSharedCodexAppServerClientIfCurrent(failedClient);
if (startupClientForAbandonedRequestCleanup === failedClient) {
startupClientForAbandonedRequestCleanup = undefined;
}
if (attempt >= CODEX_APP_SERVER_STARTUP_CONNECTION_CLOSE_MAX_ATTEMPTS) {
embeddedAgentLog.warn(
"codex app-server connection closed during startup; retries exhausted",
{
attempt,
maxAttempts: CODEX_APP_SERVER_STARTUP_CONNECTION_CLOSE_MAX_ATTEMPTS,
abandonedSharedClient: attemptedClientAbandoned,
clearedSharedClient,
error: formatErrorMessage(error),
},
);
@@ -513,7 +444,7 @@ export async function startCodexAttemptThread(params: {
attempt,
nextAttempt: attempt + 1,
maxAttempts: CODEX_APP_SERVER_STARTUP_CONNECTION_CLOSE_MAX_ATTEMPTS,
abandonedSharedClient: attemptedClientAbandoned,
clearedSharedClient,
error: formatErrorMessage(error),
},
);
@@ -522,21 +453,32 @@ export async function startCodexAttemptThread(params: {
throw new Error("codex app-server startup retry loop exited unexpectedly");
},
});
const completedSharedClientLease = sharedClientLease;
if (!completedSharedClientLease) {
startupClientForAbandonedRequestCleanup = undefined;
if (!releaseSharedClientLease) {
throw new Error("codex app-server startup succeeded without a shared client lease");
}
sharedClientLease = undefined;
return {
...startupResult,
mcpElicitationDelegationRequired,
clientLease: completedSharedClientLease,
pluginAppServer,
releaseSharedClientLease,
};
} catch (error) {
const shouldAbandonStartupClient =
params.signal.aborted || isIndeterminateCodexStartupFailure(error);
if (shouldAbandonStartupClient) {
await abandonStartupClient();
if (params.signal.aborted || shouldClearSharedClientAfterStartupAbandon(error)) {
releaseSharedClientLease?.();
releaseSharedClientLease = undefined;
await closeCodexStartupClientBestEffort(startupClientForAbandonedRequestCleanup);
startupClientForAbandonedRequestCleanup = undefined;
} else if (
shouldClearSharedClientAfterStartupRace(error) ||
shouldClearSharedClientAfterStartupFailure({
error,
spawnedBy: params.spawnedBy,
})
) {
releaseSharedClientLease?.();
releaseSharedClientLease = undefined;
await closeCodexStartupClientBestEffort(startupClientForAbandonedRequestCleanup);
startupClientForAbandonedRequestCleanup = undefined;
}
throw error;
} finally {
@@ -544,13 +486,30 @@ export async function startCodexAttemptThread(params: {
}
}
function isIndeterminateCodexStartupFailure(error: unknown): boolean {
function shouldClearSharedClientAfterStartupAbandon(error: unknown): boolean {
return (
isCodexAppServerUnsafeSubscriptionError(error) ||
isCodexAppServerConnectionClosedError(error) ||
(error instanceof Error &&
(error.message.endsWith(" timed out") ||
error.message.endsWith(" aborted") ||
error.message.includes("write EPIPE")))
error instanceof Error &&
(error.message === "codex app-server startup timed out" ||
error.message === "codex app-server startup aborted")
);
}
function shouldClearSharedClientAfterStartupRace(error: unknown): boolean {
return (
error instanceof Error &&
(shouldClearSharedClientAfterStartupAbandon(error) || error.message.endsWith(" timed out"))
);
}
function shouldClearSharedClientAfterStartupFailure(params: {
error: unknown;
spawnedBy: EmbeddedRunAttemptParams["spawnedBy"];
}): boolean {
if (!(params.error instanceof Error)) {
return !params.spawnedBy;
}
if (params.error.message.includes("write EPIPE")) {
return true;
}
return !params.spawnedBy;
}

View File

@@ -159,39 +159,6 @@ describe("Codex app-server attempt timeouts", () => {
expect(events).toEqual(["cleanup-start", "cleanup-done"]);
});
it("keeps the timeout result when startup resolves during timeout cleanup", async () => {
vi.useFakeTimers();
const events: string[] = [];
let resolveOperation!: (value: string) => void;
let finishCleanup!: () => void;
const run = withCodexStartupTimeout({
timeoutMs: 10,
signal: new AbortController().signal,
onTimeout: async () => {
events.push("cleanup-start");
await new Promise<void>((resolve) => {
finishCleanup = resolve;
});
events.push("cleanup-done");
},
operation: () =>
new Promise<string>((resolve) => {
resolveOperation = resolve;
}),
});
const rejected = expect(run).rejects.toThrow("codex app-server startup timed out");
await vi.advanceTimersByTimeAsync(10);
expect(events).toEqual(["cleanup-start"]);
resolveOperation("late-ready");
await Promise.resolve();
expect(events).toEqual(["cleanup-start"]);
finishCleanup();
await rejected;
expect(events).toEqual(["cleanup-start", "cleanup-done"]);
});
it("rejects startup timeout when aborted before completion", async () => {
vi.useFakeTimers();
const controller = new AbortController();

View File

@@ -52,13 +52,13 @@ export async function withCodexStartupTimeout<T>(params: {
};
timeout = setTimeout(() => {
timeoutError = new Error("codex app-server startup timed out");
rejectOnce(timeoutError);
timeoutCleanup = Promise.resolve()
.then(() => params.onTimeout?.())
.then(
() => undefined,
() => undefined,
);
timeoutCleanup = Promise.resolve(params.onTimeout?.()).then(
() => undefined,
() => undefined,
);
void timeoutCleanup.finally(() => {
rejectOnce(timeoutError!);
});
}, params.timeoutMs);
const abortListener = () => rejectOnce(new Error("codex app-server startup aborted"));
params.signal.addEventListener("abort", abortListener, { once: true });

View File

@@ -29,7 +29,7 @@ describe("Codex app-server attempt turn watches", () => {
const progress: string[] = [];
const diagnostics: string[] = [];
const controller = createCodexAttemptTurnWatchController({
getThreadId: () => "thread-1",
threadId: "thread-1",
signal: abortController.signal,
getTurnId: () => "turn-1",
isCompleted: () => completed,

View File

@@ -29,7 +29,7 @@ export type CodexAttemptTurnWatchController = ReturnType<
* notifications and tool handoffs progress.
*/
export function createCodexAttemptTurnWatchController(params: {
getThreadId: () => string;
threadId: string;
signal: AbortSignal;
getTurnId: () => string | undefined;
isCompleted: () => boolean;
@@ -79,7 +79,6 @@ export function createCodexAttemptTurnWatchController(params: {
const turnTerminalIdleTimeoutMs = resolveTimerTimeoutMs(params.turnTerminalIdleTimeoutMs, 1);
const interruptTimeoutMs = resolveTimerTimeoutMs(params.interruptTimeoutMs, 1);
const resolveWatchTimeoutMs = (timeoutMs: number) => resolveTimerTimeoutMs(timeoutMs, 1);
const currentThreadId = () => params.getThreadId();
const clearCompletionIdleTimer = () => {
if (completionIdleTimer) {
@@ -228,7 +227,7 @@ export function createCodexAttemptTurnWatchController(params: {
clearTerminalIdleTimer();
const turnId = params.getTurnId();
params.onRecordEvent("turn.assistant_completion_idle_release", {
threadId: currentThreadId(),
threadId: params.threadId,
turnId,
idleMs,
timeoutMs: turnAssistantCompletionIdleTimeoutMs,
@@ -237,7 +236,7 @@ export function createCodexAttemptTurnWatchController(params: {
embeddedAgentLog.warn(
"codex app-server turn released after completed assistant item without terminal event",
{
threadId: currentThreadId(),
threadId: params.threadId,
turnId,
idleMs,
timeoutMs: turnAssistantCompletionIdleTimeoutMs,
@@ -246,7 +245,7 @@ export function createCodexAttemptTurnWatchController(params: {
);
if (turnId) {
params.onInterruptTurn({
threadId: currentThreadId(),
threadId: params.threadId,
turnId,
timeoutMs: interruptTimeoutMs,
});
@@ -279,7 +278,7 @@ export function createCodexAttemptTurnWatchController(params: {
params.onTimeout(timeout);
params.onMarkTimedOut();
params.onRecordEvent("turn.progress_idle_timeout", {
threadId: currentThreadId(),
threadId: params.threadId,
turnId: params.getTurnId(),
idleMs,
timeoutMs: timeout.timeoutMs,
@@ -287,7 +286,7 @@ export function createCodexAttemptTurnWatchController(params: {
...timeout.details,
});
embeddedAgentLog.warn("codex app-server turn idle timed out waiting for progress", {
threadId: currentThreadId(),
threadId: params.threadId,
turnId: params.getTurnId(),
idleMs,
timeoutMs: timeout.timeoutMs,
@@ -332,7 +331,7 @@ export function createCodexAttemptTurnWatchController(params: {
params.onTimeout(timeout);
params.onMarkTimedOut();
params.onRecordEvent("turn.completion_idle_timeout", {
threadId: currentThreadId(),
threadId: params.threadId,
turnId: params.getTurnId(),
idleMs,
timeoutMs,
@@ -340,7 +339,7 @@ export function createCodexAttemptTurnWatchController(params: {
...timeout.details,
});
embeddedAgentLog.warn("codex app-server turn idle timed out waiting for completion", {
threadId: currentThreadId(),
threadId: params.threadId,
turnId: params.getTurnId(),
idleMs,
timeoutMs,
@@ -375,7 +374,7 @@ export function createCodexAttemptTurnWatchController(params: {
params.onTimeout(timeout);
params.onMarkTimedOut();
params.onRecordEvent("turn.terminal_idle_timeout", {
threadId: currentThreadId(),
threadId: params.threadId,
turnId: params.getTurnId(),
idleMs,
timeoutMs: timeout.timeoutMs,
@@ -383,7 +382,7 @@ export function createCodexAttemptTurnWatchController(params: {
...timeout.details,
});
embeddedAgentLog.warn("codex app-server turn idle timed out waiting for terminal event", {
threadId: currentThreadId(),
threadId: params.threadId,
turnId: params.getTurnId(),
idleMs,
timeoutMs: timeout.timeoutMs,
@@ -458,11 +457,9 @@ export function createCodexAttemptTurnWatchController(params: {
details?: Record<string, unknown>;
attemptProgress?: boolean;
attemptTimeoutMs?: number;
receivedAtMs?: number;
},
) => {
const now = Date.now();
completionLastActivityAt = Math.min(now, options?.receivedAtMs ?? now);
completionLastActivityAt = Date.now();
completionLastActivityReason = `notification:${method}`;
if (options?.details !== undefined) {
completionLastActivityDetails = options.details;

View File

@@ -3,61 +3,45 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
abortAgentHarnessRun,
abortAndDrainAgentHarnessRun,
type EmbeddedRunAttemptParams,
} from "openclaw/plugin-sdk/agent-harness";
import { AUTH_PROFILE_RUNTIME_CONTRACT } from "openclaw/plugin-sdk/agent-runtime-test-contracts";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CodexAppServerClientFactory } from "./client-factory.js";
import { runCodexAppServerAttempt as runCodexAppServerAttemptImpl } from "./run-attempt.js";
import {
readCodexAppServerBinding,
registerCodexTestSessionIdentity,
resetCodexTestBindingStore,
testCodexAppServerBindingStore,
writeCodexAppServerBinding,
} from "./session-binding.test-helpers.js";
import type { CodexAppServerClientLeaseFactory } from "./shared-client.js";
import {
adaptCodexTestClientFactory,
createCodexTestModel,
type CodexTestAppServerClientFactory,
} from "./test-support.js";
writeCodexAppServerBinding as writeRawCodexAppServerBinding,
} from "./session-binding.js";
import { createCodexTestModel } from "./test-support.js";
let codexAppServerClientLeaseFactoryForTest: CodexAppServerClientLeaseFactory | undefined;
let codexAppServerClientFactoryForTest: CodexAppServerClientFactory | undefined;
type RunCodexAppServerAttemptImplOptions = NonNullable<
type RunCodexAppServerAttemptOptions = NonNullable<
Parameters<typeof runCodexAppServerAttemptImpl>[1]
>;
type RunCodexAppServerAttemptOptions = Omit<RunCodexAppServerAttemptImplOptions, "bindingStore"> & {
bindingStore?: RunCodexAppServerAttemptImplOptions["bindingStore"];
};
function setCodexAppServerClientFactoryForTest(factory: CodexTestAppServerClientFactory): void {
codexAppServerClientLeaseFactoryForTest = adaptCodexTestClientFactory(factory);
function setCodexAppServerClientFactoryForTest(factory: CodexAppServerClientFactory): void {
codexAppServerClientFactoryForTest = factory;
}
function resetCodexAppServerClientFactoryForTest(): void {
codexAppServerClientLeaseFactoryForTest = undefined;
codexAppServerClientFactoryForTest = undefined;
}
function runCodexAppServerAttempt(
params: EmbeddedRunAttemptParams,
options: RunCodexAppServerAttemptOptions = {},
) {
const clientLeaseFactory = options.clientLeaseFactory ?? codexAppServerClientLeaseFactoryForTest;
return runCodexAppServerAttemptImpl(params, {
...options,
bindingStore: options.bindingStore ?? testCodexAppServerBindingStore,
...(clientLeaseFactory ? { clientLeaseFactory } : {}),
});
const clientFactory = options.clientFactory ?? codexAppServerClientFactoryForTest;
return runCodexAppServerAttemptImpl(
params,
clientFactory ? { ...options, clientFactory } : options,
);
}
function createParams(sessionFile: string, workspaceDir: string): EmbeddedRunAttemptParams {
registerCodexTestSessionIdentity(
sessionFile,
AUTH_PROFILE_RUNTIME_CONTRACT.sessionId,
AUTH_PROFILE_RUNTIME_CONTRACT.sessionKey,
);
return {
prompt: AUTH_PROFILE_RUNTIME_CONTRACT.workspacePrompt,
sessionId: AUTH_PROFILE_RUNTIME_CONTRACT.sessionId,
@@ -81,10 +65,9 @@ const DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT = JSON.stringify({
"features.standalone_web_search": false,
web_search: "disabled",
});
const APP_SERVER_START_WAIT = { interval: 1, timeout: 5_000 } as const;
function writeCodexAppServerBinding(
...args: Parameters<typeof writeRawCodexAppServerBinding>
) {
function writeCodexAppServerBinding(...args: Parameters<typeof writeRawCodexAppServerBinding>) {
const [sessionFile, binding, lookup] = args;
return writeRawCodexAppServerBinding(
sessionFile,
@@ -164,8 +147,7 @@ function createCodexAuthProfileHarness(params: { startMethod: "thread/start" | "
const seenAuthProfileIds: Array<string | undefined> = [];
const seenAgentDirs: Array<string | undefined> = [];
const requests: Array<{ method: string; params: unknown }> = [];
const notificationHandlers = new Set<(notification: unknown) => Promise<void> | void>();
const requestHandlers = new Set<(request: unknown) => unknown>();
let notify: (notification: unknown) => Promise<void> = async () => undefined;
setCodexAppServerClientFactoryForTest(async (_startOptions, authProfileId, agentDir) => {
seenAuthProfileIds.push(authProfileId);
seenAgentDirs.push(agentDir);
@@ -181,28 +163,19 @@ function createCodexAuthProfileHarness(params: { startMethod: "thread/start" | "
}
throw new Error(`unexpected method: ${method}`);
}),
addNotificationHandler: (handler: (notification: unknown) => Promise<void> | void) => {
notificationHandlers.add(handler);
return () => notificationHandlers.delete(handler);
addNotificationHandler: (handler: (notification: unknown) => Promise<void>) => {
notify = handler;
return () => undefined;
},
addRequestHandler: (handler: (request: unknown) => unknown) => {
requestHandlers.add(handler);
return () => requestHandlers.delete(handler);
},
addCloseHandler: () => () => undefined,
addRequestHandler: () => () => undefined,
} as never;
});
const notify = async (notification: unknown) => {
await Promise.all(
[...notificationHandlers].map((handler) => Promise.resolve(handler(notification))),
);
};
return {
seenAuthProfileIds,
seenAgentDirs,
async waitForMethod(method: string) {
await vi.waitFor(() => expect(requests.map((entry) => entry.method)).toContain(method), {
interval: 1,
...APP_SERVER_START_WAIT,
});
},
async completeTurn() {
@@ -222,14 +195,19 @@ describe("Auth profile runtime contract - Codex app-server adapter", () => {
let tmpDir: string;
beforeEach(async () => {
resetCodexTestBindingStore();
vi.useRealTimers();
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-auth-contract-"));
});
afterEach(async () => {
vi.useRealTimers();
abortAgentHarnessRun(AUTH_PROFILE_RUNTIME_CONTRACT.sessionId);
await abortAndDrainAgentHarnessRun({
sessionId: AUTH_PROFILE_RUNTIME_CONTRACT.sessionId,
sessionKey: AUTH_PROFILE_RUNTIME_CONTRACT.sessionKey,
settleMs: 1_000,
forceClear: true,
reason: "test_cleanup",
});
resetCodexAppServerClientFactoryForTest();
await fs.rm(tmpDir, { recursive: true, force: true });
});
@@ -247,7 +225,7 @@ describe("Auth profile runtime contract - Codex app-server adapter", () => {
expect(harness.seenAuthProfileIds).toEqual([
AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId,
]),
{ interval: 1 },
APP_SERVER_START_WAIT,
);
expect(harness.seenAgentDirs).toEqual([tmpDir]);
await harness.waitForMethod("turn/start");
@@ -258,7 +236,6 @@ describe("Auth profile runtime contract - Codex app-server adapter", () => {
it("reuses a bound OpenAI Codex auth profile when resume params omit authProfileId", async () => {
const harness = createCodexAuthProfileHarness({ startMethod: "thread/resume" });
const sessionFile = path.join(tmpDir, "session.jsonl");
const params = createParams(sessionFile, tmpDir);
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-auth-contract",
cwd: tmpDir,
@@ -266,6 +243,7 @@ describe("Auth profile runtime contract - Codex app-server adapter", () => {
dynamicToolsFingerprint: "[]",
});
// authProfileId is intentionally omitted to exercise the resume-bound profile path.
const params = createParams(sessionFile, tmpDir);
const run = runCodexAppServerAttempt(params);
await vi.waitFor(
@@ -273,7 +251,7 @@ describe("Auth profile runtime contract - Codex app-server adapter", () => {
expect(harness.seenAuthProfileIds).toEqual([
AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId,
]),
{ interval: 1 },
APP_SERVER_START_WAIT,
);
await harness.waitForMethod("turn/start");
await harness.completeTurn();
@@ -283,13 +261,13 @@ describe("Auth profile runtime contract - Codex app-server adapter", () => {
it("prefers an explicit runtime auth profile over a stale persisted binding", async () => {
const harness = createCodexAuthProfileHarness({ startMethod: "thread/resume" });
const sessionFile = path.join(tmpDir, "session.jsonl");
const params = createParams(sessionFile, tmpDir);
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-auth-contract",
cwd: tmpDir,
authProfileId: "openai:stale",
dynamicToolsFingerprint: "[]",
});
const params = createParams(sessionFile, tmpDir);
params.authProfileId = AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId;
const run = runCodexAppServerAttempt(params);
@@ -298,7 +276,7 @@ describe("Auth profile runtime contract - Codex app-server adapter", () => {
expect(harness.seenAuthProfileIds).toEqual([
AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId,
]),
{ interval: 1 },
APP_SERVER_START_WAIT,
);
await harness.waitForMethod("turn/start");
await harness.completeTurn();

View File

@@ -5,6 +5,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import { resolvePreferredOpenClawTmpDir, withTempWorkspace } from "openclaw/plugin-sdk/temp-path";
import { readCodexNotificationItem } from "./attempt-notifications.js";
import type { CodexAppServerClientFactory } from "./client-factory.js";
import type { CodexAppServerClient } from "./client.js";
import { resolveCodexAppServerRuntimeOptions } from "./config.js";
import { readModelListResult } from "./models.js";
@@ -26,10 +27,6 @@ import {
type JsonObject,
type JsonValue,
} from "./protocol.js";
import type {
CodexAppServerClientLease,
CodexAppServerClientLeaseFactory,
} from "./shared-client.js";
import { buildCodexRuntimeThreadConfig } from "./thread-lifecycle.js";
const CODEX_PRIVATE_STDIO_ARGS = ["app-server", "--listen", "stdio://"];
@@ -49,7 +46,7 @@ const CODEX_PRIVATE_BOUNDED_THREAD_CONFIG: JsonObject = {
export type CodexBoundedTurnOptions = {
pluginConfig?: unknown;
clientFactory?: CodexAppServerClientLeaseFactory;
clientFactory?: CodexAppServerClientFactory;
};
export type CodexBoundedTurnResult = {
@@ -121,17 +118,11 @@ async function runBoundedCodexAppServerTurnInWorkspace(
const startOptions = workspace.codexHome
? buildPrivateCodexAppServerStartOptions(appServer.start, workspace.codexHome)
: appServer.start;
let lease: CodexAppServerClientLease | undefined;
const ownsClient = !params.options.clientFactory;
const client = params.options.clientFactory
? ((lease = await params.options.clientFactory({
startOptions,
? await params.options.clientFactory(startOptions, params.profile, agentDir, params.config, {
timeoutMs,
authProfileId: params.profile,
agentDir,
authProfileStore: params.authProfileStore,
config: params.config,
})),
lease.client)
})
: await import("./shared-client.js").then(({ createIsolatedCodexAppServerClient }) =>
createIsolatedCodexAppServerClient({
startOptions,
@@ -217,9 +208,7 @@ async function runBoundedCodexAppServerTurnInWorkspace(
} finally {
clearTimeout(timeout);
params.signal?.removeEventListener("abort", abortFromCaller);
if (lease) {
lease.release();
} else {
if (ownsClient) {
client.close();
}
}

View File

@@ -0,0 +1,50 @@
/**
* Lazy factories for shared and leased Codex app-server clients.
*/
import type { resolveCodexAppServerAuthProfileIdForAgent } from "./auth-bridge.js";
import type { CodexAppServerClient } from "./client.js";
import type { CodexAppServerStartOptions } from "./config.js";
type AuthProfileOrderConfig = Parameters<
typeof resolveCodexAppServerAuthProfileIdForAgent
>[0]["config"];
/** Factory signature used by Codex attempt startup to acquire a client. */
export type CodexAppServerClientFactory = (
startOptions?: CodexAppServerStartOptions,
authProfileId?: string,
agentDir?: string,
config?: AuthProfileOrderConfig,
options?: {
onStartedClient?: (client: CodexAppServerClient) => void;
abandonSignal?: AbortSignal;
timeoutMs?: number;
},
) => Promise<CodexAppServerClient>;
let sharedClientModulePromise: Promise<typeof import("./shared-client.js")> | null = null;
const loadSharedClientModule = async () => {
sharedClientModulePromise ??= import("./shared-client.js");
return await sharedClientModulePromise;
};
/** Returns a leased shared client so startup can release ownership explicitly. */
export const defaultLeasedCodexAppServerClientFactory: CodexAppServerClientFactory = (
startOptions,
authProfileId,
agentDir,
config,
options,
) =>
loadSharedClientModule().then(({ getLeasedSharedCodexAppServerClient }) =>
getLeasedSharedCodexAppServerClient({
startOptions,
authProfileId,
agentDir,
config,
onStartedClient: options?.onStartedClient,
abandonSignal: options?.abandonSignal,
timeoutMs: options?.timeoutMs,
}),
);

View File

@@ -1,78 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { CodexAppServerClient } from "./client.js";
import { createClientHarness } from "./test-support.js";
const mocks = vi.hoisted(() => ({
refreshAuth: vi.fn(async () => ({ accessToken: "refreshed", chatgptAccountId: "account" })),
mergeRateLimitUpdate: vi.fn(),
}));
vi.mock("./auth-bridge.js", () => ({
refreshCodexAppServerAuthTokens: mocks.refreshAuth,
}));
vi.mock("./rate-limit-cache.js", () => ({
mergeCodexRateLimitsUpdate: mocks.mergeRateLimitUpdate,
}));
const { ensureCodexAppServerClientRuntime } = await import("./client-runtime.js");
describe("Codex app-server client runtime", () => {
const clients: CodexAppServerClient[] = [];
afterEach(() => {
for (const client of clients) {
client.close();
}
clients.length = 0;
mocks.refreshAuth.mockClear();
mocks.mergeRateLimitUpdate.mockClear();
});
it("installs shared handlers once per physical client", async () => {
const harness = createClientHarness();
clients.push(harness.client);
const context = {
agentDir: "/tmp/agent",
authProfileId: "openai:default",
config: {},
};
const updatedContext = {
...context,
authProfileStore: { version: 1 as const, profiles: {} },
config: { models: { mode: "merge" as const } },
};
const addNotificationHandler = vi.spyOn(harness.client, "addNotificationHandler");
const addRequestHandler = vi.spyOn(harness.client, "addRequestHandler");
const addCloseHandler = vi.spyOn(harness.client, "addCloseHandler");
ensureCodexAppServerClientRuntime(harness.client, context);
ensureCodexAppServerClientRuntime(harness.client, updatedContext);
expect(addNotificationHandler).toHaveBeenCalledTimes(1);
expect(addRequestHandler).toHaveBeenCalledTimes(1);
expect(addCloseHandler).not.toHaveBeenCalled();
harness.send({
method: "account/rateLimits/updated",
params: { rateLimits: { primary: { usedPercent: 12 } } },
});
harness.send({
id: "refresh-1",
method: "account/chatgptAuthTokens/refresh",
params: { reason: "expired" },
});
await vi.waitFor(() => expect(mocks.mergeRateLimitUpdate).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(mocks.refreshAuth).toHaveBeenCalledTimes(1));
expect(mocks.refreshAuth).toHaveBeenCalledWith(updatedContext);
expect(mocks.mergeRateLimitUpdate).toHaveBeenCalledWith(harness.client, {
rateLimits: { primary: { usedPercent: 12 } },
});
await vi.waitFor(() =>
expect(harness.writes.map((line) => JSON.parse(line) as unknown)).toContainEqual({
id: "refresh-1",
result: { accessToken: "refreshed", chatgptAccountId: "account" },
}),
);
});
});

View File

@@ -1,50 +0,0 @@
/** Client-scoped Codex auth and account observers. */
import { refreshCodexAppServerAuthTokens } from "./auth-bridge.js";
import type { CodexAppServerClient } from "./client.js";
import type { JsonValue } from "./protocol.js";
import { mergeCodexRateLimitsUpdate } from "./rate-limit-cache.js";
import type { CodexAppServerAuthProfileLookup } from "./session-binding.js";
type ClientRuntimeContext = Omit<CodexAppServerAuthProfileLookup, "agentDir"> & {
agentDir: string;
};
type ClientRuntime = {
context: ClientRuntimeContext;
};
const configuredClients = new WeakMap<CodexAppServerClient, ClientRuntime>();
/** Installs one auth-refresh handler and one rate-limit observer per physical client. */
export function ensureCodexAppServerClientRuntime(
client: CodexAppServerClient,
context: ClientRuntimeContext,
): void {
const existing = configuredClients.get(client);
if (existing) {
// Shared-client keys already isolate agent/auth identity. Keep config fresh
// without installing another physical-client handler set.
existing.context = context;
return;
}
const runtime: ClientRuntime = { context };
configuredClients.set(client, runtime);
client.addRequestHandler(async (request) => {
if (request.method !== "account/chatgptAuthTokens/refresh") {
return undefined;
}
return (await refreshCodexAppServerAuthTokens({
agentDir: runtime.context.agentDir,
authProfileId: runtime.context.authProfileId,
...(runtime.context.authProfileStore
? { authProfileStore: runtime.context.authProfileStore }
: {}),
config: runtime.context.config,
})) as unknown as JsonValue;
});
client.addNotificationHandler((notification) => {
if (notification.method === "account/rateLimits/updated") {
mergeCodexRateLimitsUpdate(client, notification.params);
}
});
}

View File

@@ -50,78 +50,6 @@ describe("CodexAppServerClient", () => {
expect(outbound.method).toBe("model/list");
});
it("keeps a shared thread subscribed until every local owner releases it", async () => {
const harness = createClientHarness();
clients.push(harness.client);
const firstResume = harness.client.request("thread/resume", { threadId: "thread-1" });
const secondResume = harness.client.request("thread/resume", { threadId: "thread-1" });
const [firstRequest, secondRequest] = harness.writes.map((line) => JSON.parse(line)) as Array<{
id: number;
}>;
const resumeResult = {
thread: { id: "thread-1", cwd: "/tmp", status: { type: "idle" } },
model: "gpt-5.5",
};
harness.send({ id: firstRequest?.id, result: resumeResult });
harness.send({ id: secondRequest?.id, result: resumeResult });
await Promise.all([firstResume, secondResume]);
await expect(
harness.client.request("thread/unsubscribe", { threadId: "thread-1" }),
).resolves.toEqual({ status: "unsubscribed" });
expect(harness.writes).toHaveLength(2);
const finalRelease = harness.client.request("thread/unsubscribe", {
threadId: "thread-1",
});
const releaseRequest = JSON.parse(harness.writes[2] ?? "{}") as { id?: number };
harness.send({ id: releaseRequest.id, result: { status: "unsubscribed" } });
await expect(finalRelease).resolves.toEqual({ status: "unsubscribed" });
expect(harness.writes).toHaveLength(3);
});
it("pairs written resume failures without retaining pre-aborted requests", async () => {
const harness = createClientHarness();
clients.push(harness.client);
const firstResume = harness.client.request("thread/resume", { threadId: "thread-1" });
const firstRequest = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
harness.send({
id: firstRequest.id,
result: {
thread: { id: "thread-1", cwd: "/tmp", status: { type: "idle" } },
model: "gpt-5.5",
},
});
await firstResume;
const failedResume = harness.client.request("thread/resume", { threadId: "thread-1" });
const failedRequest = JSON.parse(harness.writes[1] ?? "{}") as { id?: number };
harness.send({ id: failedRequest.id, error: { code: -32000, message: "resume failed" } });
await expect(failedResume).rejects.toThrow("resume failed");
await expect(
harness.client.request("thread/unsubscribe", { threadId: "thread-1" }),
).resolves.toEqual({ status: "unsubscribed" });
expect(harness.writes).toHaveLength(2);
const controller = new AbortController();
controller.abort();
await expect(
harness.client.request(
"thread/resume",
{ threadId: "thread-1" },
{ signal: controller.signal },
),
).rejects.toThrow("thread/resume aborted");
const unsubscribe = harness.client.request("thread/unsubscribe", { threadId: "thread-1" });
expect(harness.writes).toHaveLength(3);
const unsubscribeRequest = JSON.parse(harness.writes[2] ?? "{}") as { id?: number };
harness.send({ id: unsubscribeRequest.id, result: { status: "unsubscribed" } });
await expect(unsubscribe).resolves.toEqual({ status: "unsubscribed" });
});
it("removes unpaired surrogate code units from outbound JSON-RPC strings", async () => {
const harness = createClientHarness();
clients.push(harness.client);
@@ -142,9 +70,9 @@ describe("CodexAppServerClient", () => {
expect(outbound.params?.nested).toEqual(["lowend", "emoji 🙈 ok"]);
harness.send({
id: JSON.parse(harness.writes[0] ?? "{}").id,
result: { thread: { id: "thread-1" } },
result: { threadId: "thread-1" },
});
await expect(request).resolves.toEqual({ thread: { id: "thread-1" } });
await expect(request).resolves.toEqual({ threadId: "thread-1" });
});
it("logs a redacted preview for malformed app-server messages", async () => {
@@ -212,30 +140,6 @@ describe("CodexAppServerClient", () => {
expect(warn).not.toHaveBeenCalled();
});
it("contains synchronous notification handler failures and continues fanout", async () => {
const warn = vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
const harness = createClientHarness();
clients.push(harness.client);
const laterHandler = vi.fn();
harness.client.addNotificationHandler(() => {
throw new Error("handler exploded");
});
harness.client.addNotificationHandler(laterHandler);
expect(() =>
harness.send({
method: "item/commandExecution/outputDelta",
params: { delta: "still routed" },
}),
).not.toThrow();
await vi.waitFor(() => expect(laterHandler).toHaveBeenCalledTimes(1));
expect(warn).toHaveBeenCalledWith(
"codex app-server notification handler failed",
expect.objectContaining({ error: expect.any(Error) }),
);
});
it("preserves JSON-RPC error codes", async () => {
const harness = createClientHarness();
clients.push(harness.client);
@@ -316,95 +220,6 @@ describe("CodexAppServerClient", () => {
expect(harness.writes).toHaveLength(1);
});
it.each([
{
method: "thread/start" as const,
params: {},
abandonment: "timeout" as const,
expectedError: "thread/start timed out",
},
{
method: "thread/fork" as const,
params: { threadId: "parent-thread" },
abandonment: "abort" as const,
expectedError: "thread/fork aborted",
},
])("unsubscribes a late successful $method after local $abandonment", async (testCase) => {
vi.useFakeTimers();
const harness = createClientHarness();
clients.push(harness.client);
const controller = new AbortController();
const options =
testCase.abandonment === "timeout" ? { timeoutMs: 1 } : { signal: controller.signal };
const request = harness.client.request(testCase.method, testCase.params, options);
const outbound = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
const rejected = expect(request).rejects.toThrow(testCase.expectedError);
if (testCase.abandonment === "timeout") {
await vi.advanceTimersByTimeAsync(100);
} else {
controller.abort();
}
await rejected;
harness.send({ id: outbound.id, result: { thread: { id: "late-thread" } } });
expect(JSON.parse(harness.writes[1] ?? "{}")).toEqual({
id: expect.any(Number),
method: "thread/unsubscribe",
params: { threadId: "late-thread" },
});
});
it("closes when a late thread creation subscription cannot be released", async () => {
const harness = createClientHarness();
clients.push(harness.client);
const controller = new AbortController();
const request = harness.client.request("thread/start", {}, { signal: controller.signal });
const outbound = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
const rejected = expect(request).rejects.toThrow("thread/start aborted");
controller.abort();
await rejected;
harness.send({ id: outbound.id, result: { thread: { id: "late-thread" } } });
const unsubscribe = JSON.parse(harness.writes[1] ?? "{}") as { id?: number };
harness.send({
id: unsubscribe.id,
error: { code: -32_000, message: "unsubscribe failed" },
});
await vi.waitFor(() => expect(harness.stdinDestroyed).toBe(true));
});
it("does not unsubscribe a late rejected thread creation", async () => {
const harness = createClientHarness();
clients.push(harness.client);
const controller = new AbortController();
const request = harness.client.request("thread/start", {}, { signal: controller.signal });
const outbound = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
const rejected = expect(request).rejects.toThrow("thread/start aborted");
controller.abort();
await rejected;
harness.send({ id: outbound.id, error: { code: -32000, message: "start failed" } });
expect(harness.writes).toHaveLength(1);
});
it("closes after the bounded late-creation cleanup ledger fills", async () => {
const harness = createClientHarness();
clients.push(harness.client);
for (let index = 0; index < 129; index += 1) {
const controller = new AbortController();
const request = harness.client.request("thread/start", {}, { signal: controller.signal });
const rejected = expect(request).rejects.toThrow("thread/start aborted");
controller.abort();
await rejected;
}
expect(harness.stdinDestroyed).toBe(true);
});
it("initializes with the required client version", async () => {
const { harness, initializing, outbound } = startInitialize();
harness.send({
@@ -701,26 +516,6 @@ describe("CodexAppServerClient", () => {
});
});
it.each(["execCommandApproval", "applyPatchApproval"])(
"fails closed for unhandled legacy %s requests",
async (method) => {
const harness = createClientHarness();
clients.push(harness.client);
harness.send({
id: "legacy-approval-1",
method,
params: { conversationId: "thread-1" },
});
await vi.waitFor(() => expect(harness.writes.length).toBe(1));
expect(JSON.parse(harness.writes[0] ?? "{}")).toEqual({
id: "legacy-approval-1",
result: { decision: "denied" },
});
},
);
it("fails closed for unhandled native app-server approvals", async () => {
const harness = createClientHarness();
clients.push(harness.client);
@@ -738,41 +533,6 @@ describe("CodexAppServerClient", () => {
});
});
it.each([
[
"item/tool/call",
{
contentItems: [
{
type: "inputText",
text: "OpenClaw did not register a handler for this app-server tool call.",
},
],
success: false,
},
],
["item/permissions/requestApproval", { permissions: {}, scope: "turn" }],
["mcpServer/elicitation/request", { action: "decline" }],
[
"item/future/requestApproval",
{
decision: "decline",
reason: "OpenClaw codex app-server bridge does not grant unknown native approvals.",
},
],
])("fails closed for an unhandled %s request", async (method, expected) => {
const harness = createClientHarness();
clients.push(harness.client);
harness.send({ id: "unhandled-1", method, params: { threadId: "thread-1" } });
await vi.waitFor(() => expect(harness.writes.length).toBe(1));
expect(JSON.parse(harness.writes[0] ?? "{}")).toEqual({
id: "unhandled-1",
result: expected,
});
});
it("only treats known Codex app-server approval methods as approvals", () => {
expect(isCodexAppServerApprovalRequest("item/commandExecution/requestApproval")).toBe(true);
expect(isCodexAppServerApprovalRequest("item/fileChange/requestApproval")).toBe(true);

View File

@@ -12,7 +12,6 @@ import {
type CodexInitializeParams,
type CodexInitializeResponse,
isRpcResponse,
readCodexThreadCreationResponseId,
type CodexServerNotification,
type JsonValue,
type RpcMessage,
@@ -35,8 +34,6 @@ const CODEX_APP_SERVER_PARSE_BUFFER_MAX = 1_000_000;
const CODEX_APP_SERVER_PARSE_BUFFER_MAX_LINES = 1_000;
const CODEX_DYNAMIC_TOOL_SERVER_REQUEST_TIMEOUT_MS = 600_000;
const CODEX_APP_SERVER_STDERR_TAIL_MAX = 2_000;
const CODEX_APP_SERVER_ABANDONED_THREAD_CREATION_MAX = 128;
const CODEX_APP_SERVER_LATE_THREAD_CLEANUP_TIMEOUT_MS = 5_000;
const UNPAIRED_SURROGATE_RE =
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
@@ -123,10 +120,7 @@ export class CodexAppServerClient {
private readonly requestHandlers = new Set<CodexServerRequestHandler>();
private readonly notificationHandlers = new Set<CodexServerNotificationHandler>();
private readonly closeHandlers = new Set<(client: CodexAppServerClient) => void>();
private readonly threadSubscriptionOwners = new Map<string, number>();
// Codex may finish a locally abandoned create request. Remember its RPC id
// until response/close so the unknown thread subscription can be released.
private readonly abandonedThreadCreationRequestIds = new Set<number | string>();
private activeSharedLeaseCountProvider: (() => number | undefined) | undefined;
private nextId = 1;
private initialized = false;
private closed = false;
@@ -247,27 +241,11 @@ export class CodexAppServerClient {
if (options.signal?.aborted) {
return Promise.reject(new Error(`${method} aborted`));
}
const requestedThreadId = readRequestThreadId(params);
if (
method === "thread/unsubscribe" &&
requestedThreadId &&
this.releaseThreadSubscriptionOwner(requestedThreadId)
) {
// Codex subscriptions are connection-wide sets. A logical owner can
// release without silencing another turn on the same physical client.
return Promise.resolve({ status: "unsubscribed" } as unknown as T);
}
if (method === "thread/resume" && requestedThreadId) {
// Every resume attempt owns one release, even if the response times out
// or aborts: Codex may have subscribed before OpenClaw saw the outcome.
this.retainThreadSubscriptionOwner(requestedThreadId);
}
const id = this.nextId++;
const message: RpcRequest = { id, method, params: params as JsonValue | undefined };
return new Promise<T>((resolve, reject) => {
let timeout: ReturnType<typeof setTimeout> | undefined;
let cleanupAbort: (() => void) | undefined;
let requestWritten = false;
const cleanup = () => {
if (timeout) {
clearTimeout(timeout);
@@ -276,37 +254,23 @@ export class CodexAppServerClient {
cleanupAbort?.();
cleanupAbort = undefined;
};
const rejectPending = (error: Error, rememberLateThreadCreation = false) => {
const rejectPending = (error: Error) => {
if (!this.pending.has(id)) {
return;
}
this.pending.delete(id);
if (rememberLateThreadCreation && isThreadCreationRequest(method)) {
if (
this.abandonedThreadCreationRequestIds.size >=
CODEX_APP_SERVER_ABANDONED_THREAD_CREATION_MAX
) {
// Lost create responses can hide server subscriptions. Once the
// bounded cleanup ledger fills, closing is the only safe release.
this.closeWithError(
new Error("codex app-server abandoned thread creation limit exceeded"),
);
} else {
this.abandonedThreadCreationRequestIds.add(id);
}
}
cleanup();
reject(error);
};
if (options.timeoutMs && Number.isFinite(options.timeoutMs) && options.timeoutMs > 0) {
timeout = setTimeout(
() => rejectPending(new Error(`${method} timed out`), true),
() => rejectPending(new Error(`${method} timed out`)),
Math.max(100, options.timeoutMs),
);
timeout.unref?.();
}
if (options.signal) {
const abortListener = () => rejectPending(new Error(`${method} aborted`), requestWritten);
const abortListener = () => rejectPending(new Error(`${method} aborted`));
options.signal.addEventListener("abort", abortListener, { once: true });
cleanupAbort = () => options.signal?.removeEventListener("abort", abortListener);
}
@@ -314,12 +278,6 @@ export class CodexAppServerClient {
method,
resolve: (value) => {
cleanup();
if (method === "thread/start" || method === "thread/fork") {
const threadId = readCodexThreadCreationResponseId(value);
if (threadId) {
this.retainThreadSubscriptionOwner(threadId);
}
}
resolve(value as T);
},
reject: (error) => {
@@ -333,7 +291,6 @@ export class CodexAppServerClient {
return;
}
try {
requestWritten = true;
this.writeMessage(message, (error) => rejectPending(error));
} catch (error) {
rejectPending(error instanceof Error ? error : new Error(String(error)));
@@ -358,6 +315,18 @@ export class CodexAppServerClient {
return () => this.notificationHandlers.delete(handler);
}
/** Installs a lease-count provider used to route unscoped notifications. */
setActiveSharedLeaseCountProviderForUnscopedNotifications(
provider: (() => number | undefined) | undefined,
): void {
this.activeSharedLeaseCountProvider = provider;
}
/** Reads the active shared-client lease count when available. */
getActiveSharedLeaseCountForUnscopedNotifications(): number | undefined {
return this.activeSharedLeaseCountProvider?.();
}
/** Registers a close handler and returns its disposer. */
addCloseHandler(handler: (client: CodexAppServerClient) => void): () => void {
this.closeHandlers.add(handler);
@@ -476,15 +445,6 @@ export class CodexAppServerClient {
}
private handleResponse(response: RpcResponse): void {
if (this.abandonedThreadCreationRequestIds.delete(response.id)) {
if (!response.error) {
const threadId = readCodexThreadCreationResponseId(response.result);
if (threadId) {
this.unsubscribeLateThreadCreation(threadId);
}
}
return;
}
const pending = this.pending.get(response.id);
if (!pending) {
return;
@@ -562,14 +522,7 @@ export class CodexAppServerClient {
private handleNotification(notification: CodexServerNotification): void {
for (const handler of this.notificationHandlers) {
let result: Promise<void> | void;
try {
result = handler(notification);
} catch (error) {
embeddedAgentLog.warn("codex app-server notification handler failed", { error });
continue;
}
Promise.resolve(result).catch((error: unknown) => {
Promise.resolve(handler(notification)).catch((error: unknown) => {
embeddedAgentLog.warn("codex app-server notification handler failed", { error });
});
}
@@ -587,54 +540,11 @@ export class CodexAppServerClient {
}
this.closed = true;
this.closeError = error;
this.threadSubscriptionOwners.clear();
this.abandonedThreadCreationRequestIds.clear();
this.lines.close();
this.rejectPendingRequests(error);
return true;
}
private unsubscribeLateThreadCreation(threadId: string): void {
// This late response never registered a local owner. Track the wire
// release anyway; an unconfirmed cleanup makes this client unsafe to pool.
void this.request(
"thread/unsubscribe",
{ threadId },
{ timeoutMs: CODEX_APP_SERVER_LATE_THREAD_CLEANUP_TIMEOUT_MS },
).catch((error: unknown) => {
embeddedAgentLog.debug("codex app-server late thread unsubscribe failed", {
threadId,
error,
});
this.closeWithError(
new Error(`Codex late thread subscription could not be released: ${threadId}`, {
cause: error,
}),
);
});
}
private retainThreadSubscriptionOwner(threadId: string): void {
this.threadSubscriptionOwners.set(
threadId,
(this.threadSubscriptionOwners.get(threadId) ?? 0) + 1,
);
}
/** Returns true when another local owner still needs the wire subscription. */
private releaseThreadSubscriptionOwner(threadId: string): boolean {
const owners = this.threadSubscriptionOwners.get(threadId);
if (owners === undefined) {
return false;
}
if (owners > 1) {
this.threadSubscriptionOwners.set(threadId, owners - 1);
return true;
}
this.threadSubscriptionOwners.delete(threadId);
return false;
}
private rejectPendingRequests(error: Error): void {
for (const pending of this.pending.values()) {
pending.cleanup();
@@ -647,17 +557,6 @@ export class CodexAppServerClient {
}
}
function readRequestThreadId(value: unknown): string | undefined {
if (!isJsonObject(value) || typeof value.threadId !== "string") {
return undefined;
}
return value.threadId.trim() || undefined;
}
function isThreadCreationRequest(method: string): boolean {
return method === "thread/start" || method === "thread/fork";
}
function defaultServerRequestResponse(
request: Required<Pick<RpcRequest, "id" | "method">> & { params?: JsonValue },
): JsonValue {
@@ -672,9 +571,6 @@ function defaultServerRequestResponse(
success: false,
};
}
if (request.method === "execCommandApproval" || request.method === "applyPatchApproval") {
return { decision: "denied" };
}
if (
request.method === "item/commandExecution/requestApproval" ||
request.method === "item/fileChange/requestApproval"
@@ -690,12 +586,6 @@ function defaultServerRequestResponse(
reason: "OpenClaw codex app-server bridge does not grant native approvals yet.",
};
}
if (request.method.includes("requestApproval")) {
return {
decision: "decline",
reason: "OpenClaw codex app-server bridge does not grant unknown native approvals.",
};
}
if (request.method === "item/tool/requestUserInput") {
return {
answers: {},

File diff suppressed because it is too large Load Diff

View File

@@ -7,396 +7,145 @@ import {
type EmbeddedAgentCompactResult,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
isCodexAppServerUnsafeSubscriptionError,
settleCodexAppServerClientLease,
} from "./attempt-client-cleanup.js";
import { readCodexNotificationItem } from "./attempt-notifications.js";
import { resolveCodexTurnTerminalIdleTimeoutMs } from "./attempt-timeouts.js";
import { CodexAppServerRpcError } from "./client.js";
defaultLeasedCodexAppServerClientFactory,
type CodexAppServerClientFactory,
} from "./client-factory.js";
import { resolveCodexAppServerRuntimeOptions } from "./config.js";
import { isJsonObject, type JsonObject, type JsonValue } from "./protocol.js";
import type { JsonObject } from "./protocol.js";
import { resolveCodexNativeExecutionBlock } from "./sandbox-guard.js";
import {
CODEX_APP_SERVER_BINDING_GUARDED_REQUEST_TIMEOUT_MS,
sessionBindingIdentity,
type CodexAppServerBindingIdentity,
type CodexAppServerBindingStore,
readCodexAppServerBinding,
withCodexAppServerBindingLock,
writeCodexAppServerBinding,
type CodexAppServerThreadBinding,
} from "./session-binding.js";
import {
leaseSharedCodexAppServerClient,
type CodexAppServerClientLease,
type CodexAppServerClientLeaseFactory,
type CodexAppServerClientOptions,
} from "./shared-client.js";
import { resumeCodexAppServerThread } from "./thread-resume.js";
import { withTimeout } from "./timeout.js";
import {
getCodexAppServerTurnRouter,
isCodexTerminalTurnNotification,
type CodexNativeTurnCompletionWatch,
type CodexThreadRouteReservation,
} from "./turn-router.js";
import { releaseLeasedSharedCodexAppServerClient } from "./shared-client.js";
const warnedIgnoredCompactionOverrides = new Set<string>();
type CodexAppServerCompactOptions = {
bindingStore: CodexAppServerBindingStore;
pluginConfig?: unknown;
clientLeaseFactory?: CodexAppServerClientLeaseFactory;
clientFactory?: CodexAppServerClientFactory;
allowNonManualNativeRequest?: boolean;
};
class CodexNativeTurnBindingChangedError extends Error {}
type CodexNativeTurnRequest = {
bindingStore: CodexAppServerBindingStore;
bindingIdentity: CodexAppServerBindingIdentity;
expectedBinding: CodexAppServerThreadBinding;
pluginConfig?: unknown;
authProfileId?: string;
agentDir?: string;
config?: CodexAppServerClientOptions["config"];
abortSignal?: AbortSignal;
clientLeaseFactory?: CodexAppServerClientLeaseFactory;
};
export type CodexNativeTurnKind = "compact" | "review";
/** Starts one native Codex turn and retains its app-server owner through completion. */
export async function requestCodexNativeTurnForBinding(
params: CodexNativeTurnRequest,
kind: CodexNativeTurnKind,
): Promise<void> {
const isCompaction = kind === "compact";
const label = isCompaction ? "compaction" : "review";
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig });
const requestTimeoutMs = Math.min(
appServer.requestTimeoutMs,
CODEX_APP_SERVER_BINDING_GUARDED_REQUEST_TIMEOUT_MS,
);
await params.bindingStore.withLease(params.bindingIdentity, async () => {
const currentBinding = await params.bindingStore.read(params.bindingIdentity);
if (!currentBinding || !isSameNativeTurnBinding(currentBinding, params.expectedBinding)) {
throw new CodexNativeTurnBindingChangedError(
`Codex thread binding changed before native ${label}`,
);
}
const clientLease = await (params.clientLeaseFactory ?? leaseSharedCodexAppServerClient)({
startOptions: appServer.start,
authProfileId: params.authProfileId ?? currentBinding.authProfileId,
agentDir: params.agentDir,
config: params.config,
abandonSignal: params.abortSignal,
timeoutMs: appServer.requestTimeoutMs,
});
const client = clientLease.client;
let subscribedThreadId: string | undefined;
let abandonClient = false;
let lifecycleTransferred = false;
let awaitingNativeTurnStart = false;
const terminalTurnsBeforeWatch = new Set<string>();
let route: CodexThreadRouteReservation | undefined;
let completionWatch: CodexNativeTurnCompletionWatch | undefined;
let observedContextCompaction = false;
let bindingInvalidated = false;
let resolveNativeTurnStarted!: () => void;
const nativeTurnStarted = new Promise<void>((resolve) => {
resolveNativeTurnStarted = resolve;
});
try {
const router = getCodexAppServerTurnRouter(client);
route = router.reserveThread({
threadId: currentBinding.threadId,
onNotificationReceived: (notification, scope) => {
const contextCompactionStarted =
isCompaction &&
Boolean(scope.turnId) &&
notification.method === "item/started" &&
readCodexNotificationItem(notification.params)?.type === "contextCompaction";
if (contextCompactionStarted) {
observedContextCompaction = true;
}
if (!awaitingNativeTurnStart || !scope.turnId) {
return;
}
if (isCodexTerminalTurnNotification(notification)) {
terminalTurnsBeforeWatch.add(scope.turnId);
}
if (contextCompactionStarted) {
completionWatch ??= router.watchNativeTurnCompletion({
threadId: currentBinding.threadId,
turnId: scope.turnId,
timeoutMs: resolveCodexTurnTerminalIdleTimeoutMs(undefined),
});
resolveNativeTurnStarted();
}
},
onNotification: () => undefined,
});
throwIfCodexNativeTurnAborted(params.abortSignal, kind);
let resumed;
try {
subscribedThreadId = currentBinding.threadId;
resumed = await resumeCodexAppServerThread({
client,
abandonClient: clientLease.abandon,
request: {
threadId: currentBinding.threadId,
excludeTurns: true,
persistExtendedHistory: true,
},
timeoutMs: requestTimeoutMs,
signal: params.abortSignal,
});
} catch (error) {
abandonClient = isCodexAppServerUnsafeSubscriptionError(error);
throw error;
}
const invalidateNativeContextBinding = async () => {
if (bindingInvalidated) {
return;
}
const invalidated = await params.bindingStore.mutate(params.bindingIdentity, {
kind: "invalidate-native-context",
threadId: currentBinding.threadId,
...(isCompaction ? { invalidateContextEngineProjection: true as const } : {}),
});
if (!invalidated) {
throw new CodexNativeTurnBindingChangedError(
`Codex thread binding changed before native ${label}`,
);
}
bindingInvalidated = true;
};
if (isCompaction && observedContextCompaction) {
await invalidateNativeContextBinding();
}
if (resumed.thread.status?.type === "active") {
throw new Error(
`Codex thread already has an active turn; retry ${label} after it finishes`,
);
}
throwIfCodexNativeTurnAborted(params.abortSignal, kind);
await invalidateNativeContextBinding();
awaitingNativeTurnStart = true;
let requestResult: JsonValue | undefined;
try {
requestResult = await client.request(
isCompaction ? "thread/compact/start" : "review/start",
isCompaction
? { threadId: currentBinding.threadId }
: { threadId: currentBinding.threadId, target: { type: "uncommittedChanges" } },
{ timeoutMs: requestTimeoutMs },
);
} catch (error) {
const requestRejected = error instanceof CodexAppServerRpcError;
if (requestRejected) {
// A structured rejection proves this request did not start a native
// turn. Preserve only compaction already observed on the same thread.
completionWatch?.cancel();
completionWatch = undefined;
if (!isCompaction || !observedContextCompaction) {
const restored = await params.bindingStore.mutate(params.bindingIdentity, {
kind: "set",
binding: currentBinding,
});
if (!restored) {
throw new Error(`Codex thread binding changed after native ${label} was rejected`, {
cause: error,
});
}
}
throw error;
}
if (completionWatch) {
embeddedAgentLog.debug(`codex app-server ${kind} request failed after startup`, {
threadId: currentBinding.threadId,
error,
});
} else {
abandonClient = true;
throw error;
}
}
if (!isCompaction) {
try {
const review = assertCodexReviewStartResponse(requestResult);
if (review.reviewThreadId !== currentBinding.threadId) {
throw new Error(
`Codex review/start returned ${review.reviewThreadId} for inline review on ${currentBinding.threadId}`,
);
}
completionWatch = terminalTurnsBeforeWatch.has(review.turnId)
? { completion: Promise.resolve(true), cancel: () => undefined }
: router.watchNativeTurnCompletion({
threadId: currentBinding.threadId,
turnId: review.turnId,
timeoutMs: resolveCodexTurnTerminalIdleTimeoutMs(undefined),
});
} catch (error) {
abandonClient = true;
throw error;
}
} else if (!completionWatch) {
try {
await waitForCodexNativeTurnStart({
started: nativeTurnStarted,
routeSignal: route.signal,
timeoutMs: requestTimeoutMs,
threadId: currentBinding.threadId,
kind,
});
} catch (error) {
// Codex accepted Op::Compact, so missing startup confirmation is
// ambiguous. Keep facts invalidated and retire this connection.
abandonClient = true;
throw error;
}
}
awaitingNativeTurnStart = false;
route.release();
route = undefined;
const transferredWatch = completionWatch;
if (!transferredWatch) {
abandonClient = true;
throw new Error(
`codex app-server ${kind} turn started without a turn id for thread ${currentBinding.threadId}`,
);
}
completionWatch = undefined;
lifecycleTransferred = true;
monitorCodexNativeTurn({
completionWatch: transferredWatch,
clientLease,
subscribedThreadId,
threadId: currentBinding.threadId,
kind,
});
} finally {
if (!lifecycleTransferred) {
completionWatch?.cancel();
route?.release();
await settleCodexAppServerClientLease(clientLease, {
threadId: subscribedThreadId,
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
abandon: abandonClient,
});
}
}
});
}
function assertCodexReviewStartResponse(value: JsonValue | undefined): {
turnId: string;
reviewThreadId: string;
} {
if (
!isJsonObject(value) ||
!isJsonObject(value.turn) ||
typeof value.turn.id !== "string" ||
!value.turn.id.trim() ||
typeof value.reviewThreadId !== "string" ||
!value.reviewThreadId.trim()
) {
throw new Error("invalid Codex review/start response");
}
return { turnId: value.turn.id, reviewThreadId: value.reviewThreadId };
}
function monitorCodexNativeTurn(params: {
completionWatch: CodexNativeTurnCompletionWatch;
clientLease: CodexAppServerClientLease;
subscribedThreadId?: string;
threadId: string;
kind: CodexNativeTurnKind;
}): void {
void (async () => {
const completed = await params.completionWatch.completion;
await settleCodexAppServerClientLease(params.clientLease, {
threadId: params.subscribedThreadId,
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
abandon: !completed,
});
if (!completed) {
embeddedAgentLog.warn(`codex app-server ${params.kind} turn lost terminal confirmation`, {
threadId: params.threadId,
});
}
})().catch(async (error: unknown) => {
await params.clientLease.abandon().catch(() => undefined);
embeddedAgentLog.warn(`codex app-server ${params.kind} turn cleanup failed`, {
threadId: params.threadId,
error,
});
});
}
function throwIfCodexNativeTurnAborted(
signal: AbortSignal | undefined,
kind: CodexNativeTurnKind,
): void {
if (!signal?.aborted) {
return;
}
if (signal.reason instanceof Error) {
throw signal.reason;
}
throw new Error(`codex app-server ${kind} aborted before native turn startup`, {
cause: signal.reason,
});
}
async function waitForCodexNativeTurnStart(params: {
started: Promise<void>;
routeSignal: AbortSignal;
timeoutMs: number;
threadId: string;
kind: CodexNativeTurnKind;
}): Promise<void> {
const signal = params.routeSignal;
let removeAbort: (() => void) | undefined;
const aborted = new Promise<never>((_resolve, reject) => {
const onAbort = () => reject(asNativeTurnAbortError(signal));
signal.addEventListener("abort", onAbort, { once: true });
removeAbort = () => signal.removeEventListener("abort", onAbort);
if (signal.aborted) {
onAbort();
}
});
try {
await withTimeout(
Promise.race([params.started, aborted]),
params.timeoutMs,
`codex app-server ${params.kind} turn did not start for thread ${params.threadId}`,
);
} finally {
removeAbort?.();
}
}
function asNativeTurnAbortError(signal: AbortSignal): Error {
return signal.reason instanceof Error
? signal.reason
: new Error("codex app-server native turn startup aborted", { cause: signal.reason });
}
/**
* Starts native Codex compaction for a manually requested bound session, or
* reports why Codex-owned automatic compaction should handle the trigger.
*/
export async function maybeCompactCodexAppServerSession(
params: CompactEmbeddedAgentSessionParams,
options: CodexAppServerCompactOptions,
options: CodexAppServerCompactOptions = {},
): Promise<EmbeddedAgentCompactResult | undefined> {
warnIfIgnoringOpenClawCompactionOverrides(params);
// Codex owns automatic context-pressure compaction for Codex runtime sessions.
// This entry point starts native Codex compaction for the bound thread and
// returns immediately; Codex applies the compaction inside its app-server.
return compactCodexNativeThread(params, options);
}
function warnIfIgnoringOpenClawCompactionOverrides(
params: CompactEmbeddedAgentSessionParams,
): void {
const ignoredConfig = readIgnoredCompactionOverridePaths(params);
if (ignoredConfig.length === 0) {
return;
}
const warningKey = ignoredConfig.join("\0");
if (warnedIgnoredCompactionOverrides.has(warningKey)) {
return;
}
warnedIgnoredCompactionOverrides.add(warningKey);
embeddedAgentLog.warn(
"ignoring OpenClaw compaction overrides for Codex app-server compaction; Codex uses native server-side compaction",
{
sessionId: params.sessionId,
sessionKey: params.sessionKey,
ignoredConfig,
},
);
}
function readIgnoredCompactionOverridePaths(params: CompactEmbeddedAgentSessionParams): string[] {
const ignored = new Set<string>();
for (const entry of readCompactionOverrideEntries(params)) {
const localProvider =
typeof entry.record.provider === "string" ? entry.record.provider.trim() : "";
const inheritedProvider =
!localProvider && typeof entry.inheritedRecord?.provider === "string"
? entry.inheritedRecord.provider.trim()
: "";
const providerPath = localProvider
? `${entry.path}.compaction.provider`
: inheritedProvider && entry.inheritedPath
? `${entry.inheritedPath}.compaction.provider`
: undefined;
if (typeof entry.record.model === "string" && entry.record.model.trim()) {
ignored.add(`${entry.path}.compaction.model`);
}
if (providerPath) {
ignored.add(providerPath);
}
}
return [...ignored];
}
function readCompactionOverrideEntries(params: CompactEmbeddedAgentSessionParams): Array<{
path: string;
record: Record<string, unknown>;
inheritedRecord?: Record<string, unknown>;
inheritedPath?: string;
}> {
const entries: Array<{
path: string;
record: Record<string, unknown>;
inheritedRecord?: Record<string, unknown>;
inheritedPath?: string;
}> = [];
const defaultCompaction = readRecord(readRecord(params.config?.agents)?.defaults)?.compaction;
const defaultRecord = readRecord(defaultCompaction);
if (defaultRecord) {
entries.push({ path: "agents.defaults", record: defaultRecord });
}
const agentId = readAgentIdFromSessionKey(params.sessionKey ?? params.sandboxSessionKey);
if (!agentId) {
return entries;
}
const agents = Array.isArray(params.config?.agents?.list) ? params.config.agents.list : [];
const activeAgent = agents.find((agent) => {
const id = typeof agent?.id === "string" ? agent.id.trim().toLowerCase() : "";
return id === agentId;
});
const agentCompaction = readRecord(activeAgent)?.compaction;
const agentRecord = readRecord(agentCompaction);
if (agentRecord) {
entries.push({
path: `agents.list.${agentId}`,
record: agentRecord,
inheritedRecord: defaultRecord,
inheritedPath: "agents.defaults",
});
}
return entries;
}
function readAgentIdFromSessionKey(sessionKey: string | undefined): string | undefined {
const parts = sessionKey?.trim().toLowerCase().split(":").filter(Boolean) ?? [];
if (parts.length < 3 || parts[0] !== "agent") {
return undefined;
}
return parts[1]?.trim() || undefined;
}
function readRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
async function compactCodexNativeThread(
params: CompactEmbeddedAgentSessionParams,
options: CodexAppServerCompactOptions,
options: CodexAppServerCompactOptions = {},
): Promise<EmbeddedAgentCompactResult | undefined> {
if (params.trigger !== "manual" && !options.allowNonManualNativeRequest) {
embeddedAgentLog.info("skipping codex app-server compaction for non-manual trigger", {
@@ -423,7 +172,6 @@ async function compactCodexNativeThread(
}
const nativeExecutionBlock = resolveCodexNativeExecutionBlock({
config: params.config,
agentId: params.agentId,
sessionKey: params.sandboxSessionKey ?? params.sessionKey,
sessionId: params.sessionId,
surface: "native compaction",
@@ -431,20 +179,17 @@ async function compactCodexNativeThread(
if (nativeExecutionBlock) {
return { ok: false, compacted: false, reason: nativeExecutionBlock };
}
const bindingIdentity: CodexAppServerBindingIdentity = sessionBindingIdentity({
sessionId: params.sessionId,
sessionKey: params.sessionKey,
agentId: params.agentId,
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig: options.pluginConfig });
const initialBinding = await readCodexAppServerBinding(params.sessionFile, {
config: params.config,
});
const initialBinding = await options.bindingStore.read(bindingIdentity);
if (!initialBinding?.threadId) {
return failedCodexThreadBindingCompactionResult(params, {
reason: "no codex app-server thread binding",
recovery: "missing_thread_binding",
});
}
const binding = initialBinding;
let binding = initialBinding;
const requestedAuthProfileId = params.authProfileId?.trim() || undefined;
if (
requestedAuthProfileId &&
@@ -455,42 +200,85 @@ async function compactCodexNativeThread(
// with another profile risks operating on a different Codex account.
return { ok: false, compacted: false, reason: "auth profile mismatch for session binding" };
}
if (options.allowNonManualNativeRequest && params.abortSignal?.aborted) {
const currentBinding = await options.bindingStore.read(bindingIdentity);
return skippedCodexNativeCompactionResult(params, {
reason: "codex app-server compaction aborted before native compaction",
code: "aborted_before_native_compaction",
expectedThreadId: binding.threadId,
currentThreadId: currentBinding?.threadId,
});
}
const shouldReleaseDefaultLease = !options.clientFactory;
const clientFactory = options.clientFactory ?? defaultLeasedCodexAppServerClientFactory;
const client = await clientFactory(
appServer.start,
requestedAuthProfileId ?? binding.authProfileId,
params.agentDir,
params.config,
);
try {
await requestCodexNativeTurnForBinding(
{
bindingIdentity,
bindingStore: options.bindingStore,
expectedBinding: binding,
pluginConfig: options.pluginConfig,
authProfileId: requestedAuthProfileId,
agentDir: params.agentDir,
config: params.config,
abortSignal: params.abortSignal,
clientLeaseFactory: options.clientLeaseFactory,
},
"compact",
);
if (options.allowNonManualNativeRequest) {
const guardedResult = await withCodexAppServerBindingLock(params.sessionFile, async () => {
const currentBinding = await readCodexAppServerBinding(params.sessionFile, {
config: params.config,
});
if (params.abortSignal?.aborted) {
return {
started: false as const,
result: skippedCodexNativeCompactionResult(params, {
reason: "codex app-server compaction aborted before native compaction",
code: "aborted_before_native_compaction",
expectedThreadId: binding.threadId,
currentThreadId: currentBinding?.threadId,
}),
};
}
if (!currentBinding || !isSameNativeCompactionBinding(currentBinding, binding)) {
embeddedAgentLog.warn(
"skipping codex app-server compaction because the thread binding changed",
{
sessionId: params.sessionId,
sessionKey: params.sessionKey,
expectedThreadId: binding.threadId,
currentThreadId: currentBinding?.threadId,
},
);
return {
started: false as const,
result: skippedCodexNativeCompactionResult(params, {
reason: "codex app-server binding changed before native compaction",
code: "binding_changed_before_native_compaction",
expectedThreadId: binding.threadId,
currentThreadId: currentBinding?.threadId,
}),
};
}
binding = currentBinding;
await clearContextEngineProjectionBeforeNativeCompaction({
sessionId: params.sessionId,
sessionFile: params.sessionFile,
binding,
config: params.config,
});
await client.request(
"thread/compact/start",
{
threadId: binding.threadId,
},
{
timeoutMs: Math.min(
appServer.requestTimeoutMs,
CODEX_APP_SERVER_BINDING_GUARDED_REQUEST_TIMEOUT_MS,
),
},
);
return { started: true as const };
});
if (!guardedResult.started) {
return guardedResult.result;
}
} else {
await client.request("thread/compact/start", {
threadId: binding.threadId,
});
}
embeddedAgentLog.info("started codex app-server compaction", {
sessionId: params.sessionId,
threadId: binding.threadId,
});
} catch (error) {
if (
options.allowNonManualNativeRequest &&
error instanceof CodexNativeTurnBindingChangedError
) {
const latestBinding = await options.bindingStore.read(bindingIdentity);
return skippedBindingChangeResult(params, binding.threadId, latestBinding?.threadId);
}
if (isCodexThreadNotFoundError(error)) {
return failedCodexThreadBindingCompactionResult(params, {
threadId: binding.threadId,
@@ -509,6 +297,10 @@ async function compactCodexNativeThread(
compacted: false,
reason: formatCompactionError(error),
};
} finally {
if (shouldReleaseDefaultLease) {
releaseLeasedSharedCodexAppServerClient(client);
}
}
const resultDetails: JsonObject = {
backend: "codex-app-server",
@@ -534,25 +326,6 @@ async function compactCodexNativeThread(
};
}
function skippedBindingChangeResult(
params: CompactEmbeddedAgentSessionParams,
expectedThreadId: string,
currentThreadId: string | undefined,
): EmbeddedAgentCompactResult {
embeddedAgentLog.warn("skipping codex app-server compaction because the thread binding changed", {
sessionId: params.sessionId,
sessionKey: params.sessionKey,
expectedThreadId,
currentThreadId,
});
return skippedCodexNativeCompactionResult(params, {
reason: "codex app-server binding changed before native compaction",
code: "binding_changed_before_native_compaction",
expectedThreadId,
currentThreadId,
});
}
function skippedCodexNativeCompactionResult(
params: CompactEmbeddedAgentSessionParams,
skipped: {
@@ -609,7 +382,39 @@ function failedCodexThreadBindingCompactionResult(
};
}
function isSameNativeTurnBinding(
async function clearContextEngineProjectionBeforeNativeCompaction(params: {
sessionId: string;
sessionFile: string;
binding: CodexAppServerThreadBinding;
config: CompactEmbeddedAgentSessionParams["config"];
}): Promise<void> {
const contextEngineBinding = params.binding.contextEngine;
if (!contextEngineBinding?.projection) {
return;
}
// Native Codex compaction mutates the thread history outside the projection
// guard. Clear only the projection marker so the next turn reprojects context.
await writeCodexAppServerBinding(
params.sessionFile,
{
...params.binding,
contextEngine: {
...contextEngineBinding,
projection: undefined,
},
createdAt: params.binding.createdAt,
},
{ config: params.config },
);
embeddedAgentLog.info("cleared codex context-engine projection before native compaction", {
sessionId: params.sessionId,
threadId: params.binding.threadId,
previousEpoch: contextEngineBinding.projection.epoch,
previousFingerprint: contextEngineBinding.projection.fingerprint,
});
}
function isSameNativeCompactionBinding(
current: CodexAppServerThreadBinding,
expected: CodexAppServerThreadBinding,
): boolean {

View File

@@ -1,7 +1,5 @@
// Codex tests cover config plugin behavior.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { describe, expect, it, vi } from "vitest";
import {
@@ -202,7 +200,7 @@ describe("Codex app-server config", () => {
},
unix_sockets: {
"/tmp/mock-proxy.sock": "allow",
"/tmp/blocked.sock": "deny",
"/tmp/blocked.sock": "none",
},
proxy_url: "http://127.0.0.1:3128",
socks_url: "socks5h://127.0.0.1:8081",
@@ -560,6 +558,7 @@ describe("Codex app-server config", () => {
const switchedLocalModel = resolveCodexModelBackedReviewerPolicyContext({
model: "lmstudio/local-model",
bindingModel: "gpt-5.5",
nativeAuthProfile: true,
});
expect(switchedLocalModel).toEqual({
modelProvider: "lmstudio",
@@ -746,39 +745,6 @@ describe("Codex app-server config", () => {
});
});
it("reloads Codex config.toml policy when Codex can reload it", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-config-"));
const codexHome = path.join(agentDir, "codex-home");
const configPath = path.join(codexHome, "config.toml");
await fs.mkdir(codexHome);
try {
await fs.writeFile(configPath, 'openai_base_url = "http://localhost:8080/v1"\n');
const context = { modelProvider: "openai", model: "gpt-5.5", agentDir };
expect(canUseCodexModelBackedApprovalsReviewerForModel(context)).toBe(false);
await fs.writeFile(configPath, 'openai_base_url = "https://api.openai.com/v1"\n');
expect(canUseCodexModelBackedApprovalsReviewerForModel(context)).toBe(true);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("observes a Codex config.toml created after the first policy check", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-config-"));
const codexHome = path.join(agentDir, "codex-home");
const configPath = path.join(codexHome, "config.toml");
await fs.mkdir(codexHome);
try {
const context = { modelProvider: "openai", model: "gpt-5.5", agentDir };
expect(canUseCodexModelBackedApprovalsReviewerForModel(context)).toBe(true);
await fs.writeFile(configPath, 'openai_base_url = "http://localhost:8080/v1"\n');
expect(canUseCodexModelBackedApprovalsReviewerForModel(context)).toBe(false);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("forces prompting when explicit no-prompt config cannot use model-backed review", () => {
const runtime = resolveRuntimeForTest({
pluginConfig: {
@@ -976,8 +942,8 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
env: {},
modelProvider: "openai",
requirementsPath: "/custom/codex/requirements.toml",
readRequirementsFile: (requirementsPath) => {
readPaths.push(requirementsPath);
readRequirementsFile: (path) => {
readPaths.push(path);
return 'allowed_sandbox_modes = ["read-only", "workspace-write"]\n';
},
});
@@ -997,8 +963,8 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
env: { ProgramData: "D:\\ManagedData" },
modelProvider: "openai",
platform: "win32",
readRequirementsFile: (requirementsPath) => {
readPaths.push(requirementsPath);
readRequirementsFile: (path) => {
readPaths.push(path);
return 'allowed_sandbox_modes = ["read-only", "workspace-write"]\n';
},
});

View File

@@ -188,15 +188,10 @@ export type CodexAppServerRuntimeOptions = {
approvalPolicySource?: CodexAppServerApprovalPolicySource;
sandbox: CodexAppServerSandboxMode;
approvalsReviewer: CodexAppServerApprovalsReviewer;
serviceTier?: CodexServiceTier;
serviceTier?: CodexServiceTier | null;
networkProxy?: ResolvedCodexAppServerNetworkProxyConfig;
};
export type CodexAppServerRuntimeResolution = {
appServer: CodexAppServerRuntimeOptions;
modelBackedReviewerAvailable: boolean;
};
export type CodexModelBackedReviewerContext = {
modelProvider?: string;
model?: string;
@@ -337,9 +332,7 @@ const codexAppServerNetworkProxySchema = z
baseProfile: z.enum(["read-only", "workspace"]).optional(),
mode: z.enum(["limited", "full"]).optional(),
domains: z.record(z.string(), codexAppServerNetworkProxyDomainPermissionSchema).optional(),
unixSockets: z
.record(z.string(), codexAppServerNetworkProxyUnixSocketPermissionSchema)
.optional(),
unixSockets: z.record(z.string(), codexAppServerNetworkProxyUnixSocketPermissionSchema).optional(),
proxyUrl: z.string().trim().min(1).optional(),
socksUrl: z.string().trim().min(1).optional(),
enableSocks5: z.boolean().optional(),
@@ -508,34 +501,25 @@ function resolveCodexPluginDestructivePolicy(policy: CodexPluginDestructivePolic
};
}
type CodexAppServerRuntimeParams = {
pluginConfig?: unknown;
execMode?: OpenClawExecMode;
execPolicy?: OpenClawExecPolicyForCodexAppServer;
modelProvider?: string;
model?: string;
config?: ProviderAuthAliasConfig;
env?: NodeJS.ProcessEnv;
agentDir?: string;
codexConfigToml?: string | null;
requirementsToml?: string | null;
requirementsPath?: string;
readRequirementsFile?: (path: string) => string | undefined;
platform?: NodeJS.Platform;
hostName?: string;
openClawSandboxActive?: boolean;
};
export function resolveCodexAppServerRuntimeOptions(
params: CodexAppServerRuntimeParams = {},
params: {
pluginConfig?: unknown;
execMode?: OpenClawExecMode;
execPolicy?: OpenClawExecPolicyForCodexAppServer;
modelProvider?: string;
model?: string;
config?: ProviderAuthAliasConfig;
env?: NodeJS.ProcessEnv;
agentDir?: string;
codexConfigToml?: string | null;
requirementsToml?: string | null;
requirementsPath?: string;
readRequirementsFile?: (path: string) => string | undefined;
platform?: NodeJS.Platform;
hostName?: string;
openClawSandboxActive?: boolean;
} = {},
): CodexAppServerRuntimeOptions {
return resolveCodexAppServerRuntime(params).appServer;
}
/** Resolves runtime options and the model-policy fact computed with them. */
export function resolveCodexAppServerRuntime(
params: CodexAppServerRuntimeParams = {},
): CodexAppServerRuntimeResolution {
const env = params.env ?? process.env;
const config = readCodexPluginConfig(params.pluginConfig).appServer ?? {};
const transport = resolveTransport(config.transport);
@@ -675,46 +659,43 @@ export function resolveCodexAppServerRuntime(
: "implicit";
return {
modelBackedReviewerAvailable: canUseModelBackedReviewer,
appServer: {
start: {
transport,
command,
commandSource,
args: args.length > 0 ? args : ["app-server", "--listen", "stdio://"],
...(url ? { url } : {}),
...(authToken ? { authToken } : {}),
headers,
...(transport === "stdio" && clearEnv.length > 0 ? { clearEnv } : {}),
},
connectionClass,
remoteAppsSubstrate,
...(remoteWorkspaceRoot ? { remoteWorkspaceRoot } : {}),
codeModeOnly: config.codeModeOnly === true,
requestTimeoutMs: normalizePositiveNumber(config.requestTimeoutMs, 60_000),
turnCompletionIdleTimeoutMs: normalizePositiveNumber(
config.turnCompletionIdleTimeoutMs,
60_000,
),
...(config.postToolRawAssistantCompletionIdleTimeoutMs !== undefined
? {
postToolRawAssistantCompletionIdleTimeoutMs: normalizePositiveNumber(
config.postToolRawAssistantCompletionIdleTimeoutMs,
60_000,
),
}
: {}),
approvalPolicy: forcedPolicy?.approvalPolicy ?? approvalPolicy,
approvalPolicySource,
sandbox: resolvedSandbox,
approvalsReviewer:
forcedPolicy?.approvalsReviewer ??
explicitApprovalsReviewer ??
defaultPolicy?.approvalsReviewer ??
(policyMode === "guardian" ? "auto_review" : "user"),
...resolveCodexAppServerNetworkProxy(config.networkProxy, resolvedSandbox),
...(serviceTier ? { serviceTier } : {}),
start: {
transport,
command,
commandSource,
args: args.length > 0 ? args : ["app-server", "--listen", "stdio://"],
...(url ? { url } : {}),
...(authToken ? { authToken } : {}),
headers,
...(transport === "stdio" && clearEnv.length > 0 ? { clearEnv } : {}),
},
connectionClass,
remoteAppsSubstrate,
...(remoteWorkspaceRoot ? { remoteWorkspaceRoot } : {}),
codeModeOnly: config.codeModeOnly === true,
requestTimeoutMs: normalizePositiveNumber(config.requestTimeoutMs, 60_000),
turnCompletionIdleTimeoutMs: normalizePositiveNumber(
config.turnCompletionIdleTimeoutMs,
60_000,
),
...(config.postToolRawAssistantCompletionIdleTimeoutMs !== undefined
? {
postToolRawAssistantCompletionIdleTimeoutMs: normalizePositiveNumber(
config.postToolRawAssistantCompletionIdleTimeoutMs,
60_000,
),
}
: {}),
approvalPolicy: forcedPolicy?.approvalPolicy ?? approvalPolicy,
approvalPolicySource,
sandbox: resolvedSandbox,
approvalsReviewer:
forcedPolicy?.approvalsReviewer ??
explicitApprovalsReviewer ??
defaultPolicy?.approvalsReviewer ??
(policyMode === "guardian" ? "auto_review" : "user"),
...(serviceTier ? { serviceTier } : {}),
...resolveCodexAppServerNetworkProxy(config.networkProxy, resolvedSandbox),
};
}
@@ -786,6 +767,7 @@ export function resolveCodexModelBackedReviewerPolicyContext(params: {
model?: string;
bindingModelProvider?: string;
bindingModel?: string;
nativeAuthProfile?: boolean;
}): CodexModelBackedReviewerContext {
const provider = params.provider?.trim();
if (provider && provider.toLowerCase() !== "codex") {
@@ -817,7 +799,7 @@ export function resolveCodexModelBackedReviewerPolicyContext(params: {
};
}
return {
modelProvider: undefined,
modelProvider: params.nativeAuthProfile === true ? "openai" : undefined,
model: params.model ?? params.bindingModel,
};
}
@@ -884,7 +866,6 @@ export function codexAppServerStartOptionsKey(
options: CodexAppServerStartOptions,
params: {
authProfileId?: string;
authAccountCacheKey?: string;
agentDir?: string;
fallbackApiKeyCacheKey?: string;
} = {},
@@ -904,7 +885,6 @@ export function codexAppServerStartOptionsKey(
.map(([key, value]) => [key, hashSecretForKey(value, `env:${key}`)]),
clearEnv: [...(options.clearEnv ?? [])].toSorted(),
authProfileId: params.authProfileId ?? null,
authAccountCacheKey: params.authAccountCacheKey ?? null,
agentDir: params.agentDir ?? null,
fallbackApiKeyCacheKey: params.fallbackApiKeyCacheKey ?? null,
});
@@ -944,7 +924,7 @@ function resolveCodexAppServerNetworkProxy(
enabled: true,
mode: config.mode,
domains: normalizeNetworkProxyPermissionMap(config.domains),
unix_sockets: normalizeNetworkProxyUnixSocketPermissionMap(config.unixSockets),
unix_sockets: normalizeNetworkProxyPermissionMap(config.unixSockets),
proxy_url: readNonEmptyString(config.proxyUrl),
socks_url: readNonEmptyString(config.socksUrl),
enable_socks5: config.enableSocks5,
@@ -999,20 +979,6 @@ export function fingerprintCodexAppServerNetworkProxyConfigPatch(configPatch: Js
return createHash("sha256").update(stableStringifyJson(configPatch)).digest("hex");
}
function normalizeNetworkProxyUnixSocketPermissionMap(
value: Record<string, CodexAppServerNetworkProxyUnixSocketPermission> | undefined,
): Record<string, "allow" | "deny"> | undefined {
const normalized = normalizeNetworkProxyPermissionMap(value);
return normalized
? Object.fromEntries(
Object.entries(normalized).map(([socketPath, permission]) => [
socketPath,
permission === "none" ? "deny" : permission,
]),
)
: undefined;
}
function normalizeNetworkProxyPermissionMap<TPermission extends string>(
value: Record<string, TPermission> | undefined,
): Record<string, TPermission> | undefined {

View File

@@ -11,10 +11,11 @@ import {
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
addSandboxShellDynamicToolsIfAvailable,
buildDynamicTools,
filterCodexDynamicToolsForAllowlist,
hasWildcardCodexToolsAllow,
includeForcedCodexDynamicToolAllow,
prepareDynamicToolCatalog,
mapCodexAppServerRemoteWorkspacePath,
resetOpenClawCodingToolsFactoryForTests,
resolveCodexAppServerExecutionCwd,
resolveOpenClawCodingToolsSessionKeys,
@@ -22,7 +23,6 @@ import {
setOpenClawCodingToolsFactoryForTests,
shouldEnableCodexAppServerNativeToolSurface,
shouldForceMessageTool,
type OpenClawCodingToolsFactory,
} from "./dynamic-tool-build.js";
import {
filterCodexDynamicTools,
@@ -106,13 +106,13 @@ function createRuntimeDynamicTool(name: string): RuntimeDynamicToolForTest {
async function buildDynamicToolsForTest(
params: EmbeddedRunAttemptParams,
workspaceDir: string,
options: Partial<Parameters<typeof prepareDynamicToolCatalog>[0]> = {},
options: Partial<Parameters<typeof buildDynamicTools>[0]> = {},
) {
const sandboxSessionKey = params.sessionKey;
if (!sandboxSessionKey) {
throw new Error("createParams must provide a sessionKey for Codex dynamic tool tests.");
}
const catalog = await prepareDynamicToolCatalog({
return buildDynamicTools({
params,
resolvedWorkspace: workspaceDir,
effectiveWorkspace: workspaceDir,
@@ -125,7 +125,6 @@ async function buildDynamicToolsForTest(
onYieldDetected: () => undefined,
...options,
});
return catalog.tools;
}
describe("Codex app-server dynamic tool build", () => {
@@ -228,51 +227,197 @@ describe("Codex app-server dynamic tool build", () => {
]);
});
it("prepares runtime and durable tool views from one OpenClaw catalog", async () => {
const messageTool = createRuntimeDynamicTool("message");
const webSearchTool = createRuntimeDynamicTool("web_search");
const heartbeatTool = createRuntimeDynamicTool("heartbeat_respond");
const factory = vi.fn<OpenClawCodingToolsFactory>((options) => [
messageTool,
webSearchTool,
...(options?.enableHeartbeatTool ? [heartbeatTool] : []),
]);
setOpenClawCodingToolsFactoryForTests(factory);
const sessionFile = path.join(tempDir, "session.jsonl");
it("removes managed web_search when domain-restricted Codex hosted search is active", async () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
params.disableTools = false;
const runtimePlan = createCodexRuntimePlanFixture();
params.runtimePlan = {
...runtimePlan,
params.runtimePlan = createCodexRuntimePlanFixture();
params.config = {
tools: {
normalize: (tools: Array<{ name: string }>) =>
tools.filter((tool) => tool.name === "message"),
logDiagnostics: () => undefined,
web: {
search: { openaiCodex: { allowedDomains: ["example.com"] } },
},
},
} as unknown as NonNullable<EmbeddedRunAttemptParams["runtimePlan"]>;
} as never;
setOpenClawCodingToolsFactoryForTests(() => [
createRuntimeDynamicTool("web_search"),
createRuntimeDynamicTool("message"),
]);
let webSearchAllowed = false;
const catalog = await prepareDynamicToolCatalog({
params,
resolvedWorkspace: workspaceDir,
effectiveWorkspace: workspaceDir,
sandboxSessionKey: params.sessionKey ?? "agent:main:session-1",
sandbox: { enabled: false, backendId: "docker" } as never,
nativeToolSurfaceEnabled: true,
runAbortController: new AbortController(),
sessionAgentId: "main",
pluginConfig: {},
onYieldDetected: () => undefined,
const tools = await buildDynamicToolsForTest(params, workspaceDir, {
onWebSearchPolicyResolved: (allowed) => {
webSearchAllowed = allowed;
},
});
expect(factory).toHaveBeenCalledTimes(1);
expect(factory.mock.calls[0]?.[0]?.enableHeartbeatTool).toBe(true);
expect(catalog.tools.map((tool) => tool.name)).toEqual(["message"]);
expect(catalog.registeredTools.map((tool) => tool.name)).toEqual([
"message",
"web_search",
"heartbeat_respond",
expect(tools.map((tool) => tool.name)).toEqual(["message"]);
expect(webSearchAllowed).toBe(true);
});
it("reports hosted search denied when effective tool policy removes web_search", async () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
setOpenClawCodingToolsFactoryForTests(() => [createRuntimeDynamicTool("message")]);
let webSearchAllowed = true;
const tools = await buildDynamicToolsForTest(params, workspaceDir, {
onWebSearchPolicyResolved: (allowed) => {
webSearchAllowed = allowed;
},
});
expect(tools.map((tool) => tool.name)).toEqual(["message"]);
expect(webSearchAllowed).toBe(false);
});
it("separates persistent search policy from a runtime toolsAllow restriction", async () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
params.toolsAllow = ["message"];
setOpenClawCodingToolsFactoryForTests(() => [
createRuntimeDynamicTool("web_search"),
createRuntimeDynamicTool("message"),
]);
let persistentWebSearchAllowed = false;
let webSearchAllowed = true;
const tools = await buildDynamicToolsForTest(params, workspaceDir, {
onPersistentWebSearchPolicyResolved: (allowed) => {
persistentWebSearchAllowed = allowed;
},
onWebSearchPolicyResolved: (allowed) => {
webSearchAllowed = allowed;
},
});
expect(tools.map((tool) => tool.name)).toEqual(["message"]);
expect(persistentWebSearchAllowed).toBe(true);
expect(webSearchAllowed).toBe(false);
});
it("keeps persistent search denied when runtime toolsAllow also excludes it", async () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
params.toolsAllow = ["message"];
setOpenClawCodingToolsFactoryForTests(() => [createRuntimeDynamicTool("message")]);
let persistentWebSearchAllowed = true;
let webSearchAllowed = true;
const tools = await buildDynamicToolsForTest(params, workspaceDir, {
onPersistentWebSearchPolicyResolved: (allowed) => {
persistentWebSearchAllowed = allowed;
},
onWebSearchPolicyResolved: (allowed) => {
webSearchAllowed = allowed;
},
});
expect(tools.map((tool) => tool.name)).toEqual(["message"]);
expect(persistentWebSearchAllowed).toBe(false);
expect(webSearchAllowed).toBe(false);
});
it("treats sender-scoped web_search denial as transient", async () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
params.senderId = "restricted-sender";
params.config = {
tools: {
toolsBySender: {
"id:restricted-sender": { deny: ["web_search"] },
},
},
} as never;
setOpenClawCodingToolsFactoryForTests(() => [createRuntimeDynamicTool("message")]);
let persistentWebSearchAllowed = false;
let webSearchAllowed = true;
const tools = await buildDynamicToolsForTest(params, workspaceDir, {
onPersistentWebSearchPolicyResolved: (allowed) => {
persistentWebSearchAllowed = allowed;
},
onWebSearchPolicyResolved: (allowed) => {
webSearchAllowed = allowed;
},
});
expect(tools.map((tool) => tool.name)).toEqual(["message"]);
expect(persistentWebSearchAllowed).toBe(true);
expect(webSearchAllowed).toBe(false);
});
it("keeps persistent search denied when global and sender policy both deny it", async () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
params.senderId = "restricted-sender";
params.config = {
tools: {
deny: ["web_search"],
toolsBySender: {
"id:restricted-sender": { deny: ["web_search"] },
},
},
} as never;
setOpenClawCodingToolsFactoryForTests(() => [createRuntimeDynamicTool("message")]);
let persistentWebSearchAllowed = true;
await buildDynamicToolsForTest(params, workspaceDir, {
onPersistentWebSearchPolicyResolved: (allowed) => {
persistentWebSearchAllowed = allowed;
},
});
expect(persistentWebSearchAllowed).toBe(false);
});
it("keeps managed web_search when a managed provider is explicitly selected", async () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
params.config = {
tools: {
web: {
search: { provider: "brave" },
},
},
} as never;
setOpenClawCodingToolsFactoryForTests(() => [
createRuntimeDynamicTool("web_search"),
createRuntimeDynamicTool("message"),
]);
const tools = await buildDynamicToolsForTest(params, workspaceDir);
expect(tools.map((tool) => tool.name)).toEqual(["web_search", "message"]);
});
it("keeps managed web_search when the active Codex provider lacks hosted search", async () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
setOpenClawCodingToolsFactoryForTests(() => [
createRuntimeDynamicTool("web_search"),
createRuntimeDynamicTool("message"),
]);
const tools = await buildDynamicToolsForTest(params, workspaceDir, {
nativeProviderWebSearchSupport: "unsupported",
});
expect(tools.map((tool) => tool.name)).toEqual(["web_search", "message"]);
});
it("applies additional Codex dynamic tool excludes without exposing Codex-native tools", () => {

View File

@@ -46,9 +46,6 @@ type OpenClawExecOptions = NonNullable<OpenClawCodingToolsOptions["exec"]>;
export type OpenClawCodingToolsFactory =
(typeof import("openclaw/plugin-sdk/agent-harness"))["createOpenClawCodingTools"];
type OpenClawDynamicTool = ReturnType<OpenClawCodingToolsFactory>[number];
type OpenClawDynamicToolProjection = ReturnType<
typeof filterProviderNormalizableTools<OpenClawDynamicTool>
>;
type OpenClawSandboxContext = Awaited<ReturnType<typeof resolveSandboxContext>>;
type CodexDynamicToolBuildEvent = Parameters<
NonNullable<EmbeddedRunAttemptParams["onAgentEvent"]>
@@ -63,7 +60,9 @@ const CODEX_NATIVE_SANDBOX_TOOL_REQUIREMENTS = [
"apply_patch",
] as const;
const CODEX_MEMORY_FLUSH_DYNAMIC_TOOL_ALLOW = new Set(["read", "write"]);
const CODEX_HEARTBEAT_DYNAMIC_TOOL_NAME = "heartbeat_respond";
const CODEX_NODE_EXEC_DYNAMIC_TOOL_NAME = "node_exec";
const CODEX_NODE_PROCESS_DYNAMIC_TOOL_NAME = "node_process";
const CODEX_NODE_EXEC_HIDDEN_PARAMETER_NAMES = new Set(["host", "security", "ask", "node"]);
/** Runtime inputs needed to derive the exact Codex dynamic tool surface for a turn. */
export type DynamicToolBuildParams = {
@@ -79,6 +78,9 @@ export type DynamicToolBuildParams = {
sessionAgentId: string;
pluginConfig: CodexPluginConfig;
profilerEnabled?: boolean;
forceHeartbeatTool?: boolean;
ignoreDisableMessageTool?: boolean;
ignoreRuntimePlan?: boolean;
onYieldDetected: () => void;
onCodexAppServerEvent?: (event: CodexDynamicToolBuildEvent) => void;
onPersistentWebSearchPolicyResolved?: (allowed: boolean) => void;
@@ -141,11 +143,6 @@ type CodexDynamicToolBuildStageSummary = {
stages: CodexDynamicToolBuildStageTiming[];
};
type CodexDynamicToolBuildStageTracker = {
mark: (name: string) => void;
snapshot: () => CodexDynamicToolBuildStageSummary;
};
const CODEX_DYNAMIC_TOOL_BUILD_WARN_TOTAL_MS = 1_000;
const CODEX_DYNAMIC_TOOL_BUILD_WARN_STAGE_MS = 500;
@@ -207,42 +204,26 @@ export function formatCodexDynamicToolBuildStageSummary(
: "none";
}
/** Builds the turn-visible and durable registration views from one OpenClaw tool catalog. */
export async function prepareDynamicToolCatalog(input: DynamicToolBuildParams): Promise<{
tools: OpenClawDynamicTool[];
registeredTools: OpenClawDynamicTool[];
}> {
/** Builds, filters, and normalizes Codex-compatible runtime tools for a single turn. */
export async function buildDynamicTools(input: DynamicToolBuildParams) {
const { params } = input;
if (params.disableTools || !supportsModelTools(params.model)) {
return { tools: [], registeredTools: [] };
const messagePolicyParams = input.ignoreDisableMessageTool
? { ...params, disableMessageTool: false }
: params;
if (params.disableTools) {
input.onWebSearchPolicyResolved?.(false);
return [];
}
if (!supportsModelTools(params.model)) {
input.onPersistentWebSearchPolicyResolved?.(false);
input.onWebSearchPolicyResolved?.(false);
return [];
}
// Dynamic tool construction is on the reply hot path, so per-stage
// Date.now/span bookkeeping runs only when the Codex profiler flag is set.
const toolBuildStages = createCodexDynamicToolBuildStageTracker({
enabled: input.profilerEnabled,
});
// The durable schema must include heartbeat_respond across normal and heartbeat
// turns. Build that superset once, then hide it only from normal turn exposure.
const allTools = await buildOpenClawDynamicToolSource(input, toolBuildStages);
const readableTools = filterProviderNormalizableTools(allTools);
toolBuildStages.mark("provider-normalization");
const tools = projectDynamicTools(input, readableTools, toolBuildStages, {
excludeHeartbeatTool: params.trigger !== "heartbeat",
phase: "runtime-tools",
stagePrefix: "runtime",
});
const registeredTools = projectDynamicTools(input, readableTools, toolBuildStages, {
ignoreRuntimePlan: true,
phase: "registered-tools",
reportDiagnostics: false,
stagePrefix: "registered",
});
return { tools, registeredTools };
}
async function buildOpenClawDynamicToolSource(
input: DynamicToolBuildParams,
toolBuildStages: CodexDynamicToolBuildStageTracker,
): Promise<OpenClawDynamicTool[]> {
const { params } = input;
const modelHasVision = params.model.input?.includes("image") ?? false;
const agentDir = params.agentDir ?? resolveAgentDir(params.config ?? {}, input.sessionAgentId);
const agentHarness = await import("openclaw/plugin-sdk/agent-harness");
@@ -321,10 +302,10 @@ async function buildOpenClawDynamicToolSource(
requireExplicitMessageTarget:
params.requireExplicitMessageTarget ?? isSubagentSessionKey(params.sessionKey),
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
disableMessageTool: params.disableMessageTool,
forceMessageTool: shouldForceMessageTool(params),
enableHeartbeatTool: true,
forceHeartbeatTool: true,
disableMessageTool: input.ignoreDisableMessageTool ? false : params.disableMessageTool,
forceMessageTool: shouldForceMessageTool(messagePolicyParams),
enableHeartbeatTool: params.trigger === "heartbeat" || input.forceHeartbeatTool === true,
forceHeartbeatTool: params.trigger === "heartbeat" || input.forceHeartbeatTool === true,
onYield: (message) => {
input.onYieldDetected();
input.onCodexAppServerEvent?.({
@@ -339,30 +320,16 @@ async function buildOpenClawDynamicToolSource(
allocateToolOutcomeOrdinal: params.allocateToolOutcomeOrdinal,
});
toolBuildStages.mark("create-openclaw-coding-tools");
return allTools;
}
function projectDynamicTools(
input: DynamicToolBuildParams,
source: OpenClawDynamicToolProjection,
toolBuildStages: CodexDynamicToolBuildStageTracker,
options: {
excludeHeartbeatTool?: boolean;
ignoreRuntimePlan?: boolean;
phase?: "runtime-tools" | "registered-tools";
reportDiagnostics?: boolean;
stagePrefix?: string;
} = {},
): OpenClawDynamicTool[] {
const { params } = input;
const markStage = (name: string) =>
toolBuildStages.mark(options.stagePrefix ? `${options.stagePrefix}-${name}` : name);
const preNormalizationDiagnostics: RuntimeToolSchemaDiagnostic[] = [...source.diagnostics];
const readableAllTools = [...source.tools].filter(
(tool) =>
!options.excludeHeartbeatTool ||
normalizeCodexDynamicToolName(tool.name) !== CODEX_HEARTBEAT_DYNAMIC_TOOL_NAME,
);
const preNormalizationDiagnostics: RuntimeToolSchemaDiagnostic[] = [];
const readableAllToolProjection = filterProviderNormalizableTools(allTools);
preNormalizationDiagnostics.push(...readableAllToolProjection.diagnostics);
const webSearchPlan = resolveCodexWebSearchPlan({
config: params.config,
disableTools: params.disableTools,
nativeToolSurfaceEnabled: input.nativeToolSurfaceEnabled,
nativeProviderWebSearchSupport: input.nativeProviderWebSearchSupport,
});
const readableAllTools = [...readableAllToolProjection.tools];
const codexFilteredTools = addNodeShellDynamicToolsIfNeeded(
addSandboxShellDynamicToolsIfAvailable(
isCodexMemoryFlushRun(params)
@@ -375,18 +342,51 @@ function projectDynamicTools(
input,
nativeExecutionPolicy,
);
markStage("codex-filtering");
const modelHasVision = params.model.input?.includes("image") ?? false;
toolBuildStages.mark("codex-filtering");
const visionFilteredTools = filterToolsForVisionInputs(codexFilteredTools, {
modelHasVision,
hasInboundImages: (params.images?.length ?? 0) > 0,
});
markStage("vision-filtering");
const toolsAllow = includeForcedCodexDynamicToolAllow(params.toolsAllow, params);
toolBuildStages.mark("vision-filtering");
const webSearchPresent = visionFilteredTools.some((tool) => tool.name === "web_search");
const webSearchPolicy = agentHarness.resolveWebSearchToolPolicy({
config: params.config,
modelProvider: params.model.provider,
modelId: params.modelId,
agentId: input.sessionAgentId,
sessionKey: input.sandboxSessionKey,
sandboxToolPolicy: input.sandbox?.tools,
messageProvider: resolveCodexMessageToolProvider(params),
agentAccountId: params.agentAccountId,
groupId: params.groupId,
groupChannel: params.groupChannel,
groupSpace: params.groupSpace,
spawnedBy: params.spawnedBy,
senderId: params.senderId,
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
});
const senderScopedWebSearchRestriction =
!webSearchPolicy.allowed && webSearchPolicy.persistentAllowed;
const transientWebSearchRestriction =
senderScopedWebSearchRestriction || isCodexMemoryFlushRun(params);
const persistentCodexWebSearchSurface =
params.config?.tools?.web?.search?.enabled !== false &&
!(input.pluginConfig.codexDynamicToolsExclude ?? []).some(
(name) => normalizeCodexDynamicToolName(name) === "web_search",
);
input.onPersistentWebSearchPolicyResolved?.(
webSearchPresent ||
(persistentCodexWebSearchSurface &&
transientWebSearchRestriction &&
webSearchPolicy.persistentAllowed),
);
const toolsAllow = includeForcedCodexDynamicToolAllow(params.toolsAllow, messagePolicyParams);
const filteredTools = filterCodexDynamicToolsForAllowlist(visionFilteredTools, toolsAllow);
markStage("allowlist-filter");
toolBuildStages.mark("allowlist-filter");
const normalizedTools = normalizeAgentRuntimeTools({
runtimePlan: options.ignoreRuntimePlan ? undefined : params.runtimePlan,
runtimePlan: input.ignoreRuntimePlan ? undefined : params.runtimePlan,
tools: filteredTools,
provider: params.provider,
config: params.config,
@@ -395,14 +395,17 @@ function projectDynamicTools(
modelId: params.modelId,
modelApi: params.model.api,
model: params.model,
// Registration is a projection of the already-prepared catalog. Never
// activate another provider runtime while constructing its durable schema.
allowProviderRuntimePluginLoad: options.ignoreRuntimePlan ? false : undefined,
onPreNormalizationSchemaDiagnostics: (diagnostics) =>
preNormalizationDiagnostics.push(...diagnostics),
});
markStage("runtime-normalization");
if (options.reportDiagnostics !== false && preNormalizationDiagnostics.length > 0) {
toolBuildStages.mark("runtime-normalization");
// Resolve policy before hiding the managed tool. Hosted search follows the
// same effective policy, while only one search implementation is exposed.
input.onWebSearchPolicyResolved?.(normalizedTools.some((tool) => tool.name === "web_search"));
const exposedTools = webSearchPlan.suppressManagedWebSearch
? normalizedTools.filter((tool) => tool.name !== "web_search")
: normalizedTools;
if (preNormalizationDiagnostics.length > 0) {
embeddedAgentLog.warn(
`codex app-server quarantined ${preNormalizationDiagnostics.length} unsupported runtime tool schema${preNormalizationDiagnostics.length === 1 ? "" : "s"} before dynamic tool registration`,
{
@@ -419,7 +422,7 @@ function projectDynamicTools(
}
const summary = toolBuildStages.snapshot();
if (shouldWarnCodexDynamicToolBuildStageSummary(summary)) {
const phase = options.phase ?? "runtime-tools";
const phase = input.forceHeartbeatTool ? "registered-tools" : "runtime-tools";
embeddedAgentLog.warn(
`codex app-server dynamic tool build timings runId=${params.runId} sessionId=${params.sessionId} phase=${phase} totalMs=${summary.totalMs} stages=${formatCodexDynamicToolBuildStageSummary(summary)}`,
{
@@ -432,8 +435,9 @@ function projectDynamicTools(
codexFilteredToolCount: codexFilteredTools.length,
visionFilteredToolCount: visionFilteredTools.length,
filteredToolCount: filteredTools.length,
normalizedToolCount: normalizedTools.length,
ignoreRuntimePlan: options.ignoreRuntimePlan === true,
normalizedToolCount: exposedTools.length,
forceHeartbeatTool: input.forceHeartbeatTool === true,
ignoreRuntimePlan: input.ignoreRuntimePlan === true,
nativeToolSurfaceEnabled: input.nativeToolSurfaceEnabled === true,
},
);

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