mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-22 06:52:07 +08:00
Compare commits
18 Commits
codex/refa
...
v2026.6.9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c645ec4555 | ||
|
|
c04126a2cb | ||
|
|
99f3728c72 | ||
|
|
aee871bddc | ||
|
|
b86293f11b | ||
|
|
55ee540c74 | ||
|
|
4ddd6709fc | ||
|
|
0fafe13576 | ||
|
|
437164c08c | ||
|
|
1ae6081cc7 | ||
|
|
3346bd9a2f | ||
|
|
aec0210374 | ||
|
|
b53f625c0c | ||
|
|
4ac985d1b7 | ||
|
|
dd2901dec4 | ||
|
|
41fd6f66b9 | ||
|
|
5d62f03219 | ||
|
|
dced53e25e |
@@ -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,12 @@ 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) =>
|
||||
|
||||
51
.github/workflows/clawsweeper-dispatch.yml
vendored
51
.github/workflows/clawsweeper-dispatch.yml
vendored
@@ -18,16 +18,15 @@ permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.event_name == 'push' && format('clawsweeper-dispatch-{0}-{1}', github.repository, github.ref) || format('clawsweeper-dispatch-{0}-{1}', github.repository, github.event.issue.number || github.event.pull_request.number || github.run_id) }}
|
||||
cancel-in-progress: ${{ github.event_name == 'push' || github.event.action == 'edited' || github.event.action == 'synchronize' || github.event.action == 'ready_for_review' }}
|
||||
group: clawsweeper-dispatch-${{ github.repository }}-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event.action == 'edited' || github.event.action == 'synchronize' || github.event.action == 'ready_for_review' }}
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
${{
|
||||
(github.event_name != 'issue_comment' ||
|
||||
(github.actor != 'clawsweeper[bot]' && github.actor != 'openclaw-clawsweeper[bot]')) &&
|
||||
github.event_name == 'issue_comment' ||
|
||||
!(
|
||||
endsWith(github.actor, '[bot]') &&
|
||||
(github.event.action == 'labeled' || github.event.action == 'unlabeled')
|
||||
@@ -42,34 +41,6 @@ jobs:
|
||||
if: ${{ github.event.action == 'labeled' || github.event.action == 'unlabeled' }}
|
||||
run: sleep 20
|
||||
|
||||
- name: Debounce main push dispatch
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
run: sleep 45
|
||||
|
||||
- name: Install GitHub API backoff helper
|
||||
run: |
|
||||
cat > "$RUNNER_TEMP/github-api-backoff.sh" <<'BASH'
|
||||
gh_api_with_retry() {
|
||||
local attempt output status lower_output
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if output="$(gh api "$@" 2>&1)"; then
|
||||
printf '%s\n' "$output"
|
||||
return 0
|
||||
fi
|
||||
status=$?
|
||||
lower_output="${output,,}"
|
||||
if [[ "$lower_output" != *"rate limit"* && "$output" != *"HTTP 429"* ]]; then
|
||||
printf '%s\n' "$output" >&2
|
||||
return "$status"
|
||||
fi
|
||||
echo "::warning::GitHub API throttled ClawSweeper dispatch on attempt ${attempt}; retrying after backoff." >&2
|
||||
sleep $((attempt * attempt * 5))
|
||||
done
|
||||
printf '%s\n' "$output" >&2
|
||||
return "$status"
|
||||
}
|
||||
BASH
|
||||
|
||||
- name: Create ClawSweeper dispatch token
|
||||
id: token
|
||||
if: ${{ env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true' }}
|
||||
@@ -106,7 +77,6 @@ jobs:
|
||||
echo "::notice::Skipping GitHub activity dispatch because no ClawSweeper app token is configured."
|
||||
exit 0
|
||||
fi
|
||||
. "$RUNNER_TEMP/github-api-backoff.sh"
|
||||
activity="$(jq -c \
|
||||
--arg target_repo "$TARGET_REPO" \
|
||||
--arg event_name "$SOURCE_EVENT" \
|
||||
@@ -173,7 +143,7 @@ jobs:
|
||||
' "$GITHUB_EVENT_PATH")"
|
||||
payload="$(jq -nc --argjson activity "$activity" \
|
||||
'{event_type:"github_activity",client_payload:{activity:$activity}}')"
|
||||
if gh_api_with_retry repos/openclaw/clawsweeper/dispatches \
|
||||
if gh api repos/openclaw/clawsweeper/dispatches \
|
||||
--method POST \
|
||||
--input - <<< "$payload"; then
|
||||
echo "Dispatched GitHub activity to ClawSweeper."
|
||||
@@ -195,7 +165,6 @@ jobs:
|
||||
echo "::notice::Skipping ClawSweeper dispatch because no ClawSweeper app token is configured. Not falling back to a maintainer token."
|
||||
exit 0
|
||||
fi
|
||||
. "$RUNNER_TEMP/github-api-backoff.sh"
|
||||
payload="$(jq -nc \
|
||||
--arg target_repo "$TARGET_REPO" \
|
||||
--argjson item_number "$ITEM_NUMBER" \
|
||||
@@ -204,7 +173,7 @@ jobs:
|
||||
--arg source_action "$SOURCE_ACTION" \
|
||||
--argjson supersedes_in_progress "$SUPERSEDES_IN_PROGRESS" \
|
||||
'{event_type:"clawsweeper_item",client_payload:{target_repo:$target_repo,item_number:$item_number,item_kind:$item_kind,source_event:$source_event,source_action:$source_action,supersedes_in_progress:$supersedes_in_progress}}')"
|
||||
if gh_api_with_retry repos/openclaw/clawsweeper/dispatches \
|
||||
if gh api repos/openclaw/clawsweeper/dispatches \
|
||||
--method POST \
|
||||
--input - <<< "$payload"; then
|
||||
echo "Dispatched ClawSweeper review."
|
||||
@@ -229,7 +198,6 @@ jobs:
|
||||
echo "::notice::Skipping ClawSweeper comment dispatch because no ClawSweeper app token is configured."
|
||||
exit 0
|
||||
fi
|
||||
. "$RUNNER_TEMP/github-api-backoff.sh"
|
||||
body_file="$RUNNER_TEMP/clawsweeper-comment-body.txt"
|
||||
printf '%s\n' "$COMMENT_BODY" > "$body_file"
|
||||
if ! grep -Eiq '(^|[[:space:]])@(clawsweeper|openclaw-clawsweeper)\b(\[bot\])?|(^|[[:space:]])/(clawsweeper|review|automerge|autoclose)\b' "$body_file"; then
|
||||
@@ -238,7 +206,7 @@ jobs:
|
||||
fi
|
||||
if [ -n "$TARGET_TOKEN" ]; then
|
||||
err="$(mktemp)"
|
||||
if GH_TOKEN="$TARGET_TOKEN" gh_api_with_retry -X POST \
|
||||
if GH_TOKEN="$TARGET_TOKEN" gh api -X POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"repos/$TARGET_REPO/issues/comments/$COMMENT_ID/reactions" \
|
||||
-f content="eyes" 2>"$err" >/dev/null; then
|
||||
@@ -265,7 +233,7 @@ jobs:
|
||||
"Command router queued. I will update this comment with the next step.")"
|
||||
status_payload="$(jq -nc --arg body "$status_body" '{body:$body}')"
|
||||
status_err="$(mktemp)"
|
||||
if status_response="$(GH_TOKEN="$TARGET_TOKEN" gh_api_with_retry \
|
||||
if status_response="$(GH_TOKEN="$TARGET_TOKEN" gh api \
|
||||
"repos/$TARGET_REPO/issues/$ITEM_NUMBER/comments" \
|
||||
--method POST \
|
||||
--input - <<< "$status_payload" 2>"$status_err")"; then
|
||||
@@ -286,7 +254,7 @@ jobs:
|
||||
--arg source_event "issue_comment" \
|
||||
--arg source_action "$SOURCE_ACTION" \
|
||||
'{event_type:"clawsweeper_comment",client_payload:({target_repo:$target_repo,item_number:$item_number,comment_id:$comment_id,source_event:$source_event,source_action:$source_action,max_comments:"1"} + (if $status_comment_id != "" then {status_comment_id:($status_comment_id|tonumber)} else {} end))}')"
|
||||
if GH_TOKEN="$DISPATCH_TOKEN" gh_api_with_retry repos/openclaw/clawsweeper/dispatches \
|
||||
if GH_TOKEN="$DISPATCH_TOKEN" gh api repos/openclaw/clawsweeper/dispatches \
|
||||
--method POST \
|
||||
--input - <<< "$payload"; then
|
||||
echo "Dispatched ClawSweeper comment router."
|
||||
@@ -308,7 +276,6 @@ jobs:
|
||||
echo "::notice::Skipping ClawSweeper commit dispatch because no ClawSweeper app token is configured. Not falling back to a maintainer token."
|
||||
exit 0
|
||||
fi
|
||||
. "$RUNNER_TEMP/github-api-backoff.sh"
|
||||
case "$CREATE_CHECKS" in
|
||||
true|TRUE|1|yes|YES|on|ON) create_checks=true ;;
|
||||
*) create_checks=false ;;
|
||||
@@ -320,7 +287,7 @@ jobs:
|
||||
--arg ref "$SOURCE_REF" \
|
||||
--argjson create_checks "$create_checks" \
|
||||
'{event_type:"clawsweeper_commit_review",client_payload:{target_repo:$target_repo,before_sha:$before_sha,after_sha:$after_sha,ref:$ref,enabled:true,create_checks:$create_checks}}')"
|
||||
if gh_api_with_retry repos/openclaw/clawsweeper/dispatches \
|
||||
if gh api repos/openclaw/clawsweeper/dispatches \
|
||||
--method POST \
|
||||
--input - <<< "$payload"; then
|
||||
echo "Dispatched ClawSweeper commit review."
|
||||
|
||||
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
@@ -33,7 +33,7 @@ on:
|
||||
|
||||
concurrency:
|
||||
group: codeql-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && format('manual-{0}', github.run_id) || github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('ref-{0}', github.ref) }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
@@ -23,8 +23,8 @@ permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: control-ui-locale-refresh-${{ github.event_name == 'push' && github.ref || github.event_name == 'workflow_dispatch' && format('manual-{0}', github.run_id) || github.event_name == 'release' && format('release-{0}', github.event.release.tag_name) || format('{0}-{1}', github.event_name, github.run_id) }}
|
||||
cancel-in-progress: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
group: control-ui-locale-refresh
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
plan:
|
||||
|
||||
4
.github/workflows/docs-sync-publish.yml
vendored
4
.github/workflows/docs-sync-publish.yml
vendored
@@ -13,10 +13,6 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: docs-sync-publish-${{ github.event_name == 'workflow_dispatch' && format('manual-{0}', github.run_id) || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
sync-publish-repo:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
107
.github/workflows/full-release-validation.yml
vendored
107
.github/workflows/full-release-validation.yml
vendored
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -23,8 +23,8 @@ permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: openclaw-stable-main-closeout-${{ github.event_name == 'workflow_dispatch' && (inputs.tag || github.run_id) || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
group: openclaw-stable-main-closeout
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
resolve:
|
||||
@@ -43,30 +43,6 @@ jobs:
|
||||
should_closeout: ${{ steps.inputs.outputs.should_closeout }}
|
||||
tag: ${{ steps.inputs.outputs.tag }}
|
||||
steps:
|
||||
- name: Install GitHub API backoff helper
|
||||
run: |
|
||||
cat > "$RUNNER_TEMP/github-api-backoff.sh" <<'BASH'
|
||||
gh_with_retry() {
|
||||
local attempt output status lower_output
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if output="$(gh "$@" 2>&1)"; then
|
||||
printf '%s\n' "$output"
|
||||
return 0
|
||||
fi
|
||||
status=$?
|
||||
lower_output="${output,,}"
|
||||
if [[ "$lower_output" != *"rate limit"* && "$output" != *"HTTP 429"* ]]; then
|
||||
printf '%s\n' "$output" >&2
|
||||
return "$status"
|
||||
fi
|
||||
echo "::warning::GitHub API throttled stable closeout on attempt ${attempt}; retrying after backoff." >&2
|
||||
sleep $((attempt * attempt * 5))
|
||||
done
|
||||
printf '%s\n' "$output" >&2
|
||||
return "$status"
|
||||
}
|
||||
BASH
|
||||
|
||||
- name: Checkout pushed main
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
@@ -86,13 +62,9 @@ jobs:
|
||||
TRIGGER_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$EVENT_NAME" == "push" ]]; then
|
||||
sleep 45
|
||||
fi
|
||||
. "$RUNNER_TEMP/github-api-backoff.sh"
|
||||
if [[ "$EVENT_NAME" == "push" ]]; then
|
||||
main_ref="$TRIGGER_SHA"
|
||||
tag="$(gh_with_retry release list --repo "$GITHUB_REPOSITORY" --exclude-drafts --limit 100 \
|
||||
tag="$(gh release list --repo "$GITHUB_REPOSITORY" --exclude-drafts --limit 100 \
|
||||
--json tagName,isPrerelease,publishedAt \
|
||||
--jq '[.[] | select(.isPrerelease | not) | select(.tagName | test("^v[0-9]{4}\\.[0-9]+\\.[0-9]+(-[0-9]+)?$"))] | sort_by(.publishedAt) | last | .tagName // empty')"
|
||||
if [[ -z "$tag" ]]; then
|
||||
@@ -119,7 +91,7 @@ jobs:
|
||||
tag_package_content="$RUNNER_TEMP/tag-package-content.b64"
|
||||
tag_package_read=false
|
||||
for attempt in 1 2 3; do
|
||||
if gh_with_retry api "repos/$GITHUB_REPOSITORY/contents/package.json?ref=$tag" \
|
||||
if gh api "repos/$GITHUB_REPOSITORY/contents/package.json?ref=$tag" \
|
||||
--jq '.content' > "$tag_package_content"; then
|
||||
tag_package_read=true
|
||||
break
|
||||
@@ -154,7 +126,7 @@ jobs:
|
||||
closeout_checksum_asset="${closeout_asset}.sha256"
|
||||
closeout_dir="$RUNNER_TEMP/release-closeout-evidence"
|
||||
mkdir -p "$closeout_dir"
|
||||
gh_with_retry release download "$tag" --repo "$GITHUB_REPOSITORY" \
|
||||
gh release download "$tag" --repo "$GITHUB_REPOSITORY" \
|
||||
--pattern "$closeout_asset" --pattern "$closeout_checksum_asset" --dir "$closeout_dir" || true
|
||||
closeout_json_path="$closeout_dir/$closeout_asset"
|
||||
closeout_checksum_path="$closeout_dir/$closeout_checksum_asset"
|
||||
@@ -210,11 +182,8 @@ jobs:
|
||||
fi
|
||||
evidence_dir="$RUNNER_TEMP/release-postpublish-evidence"
|
||||
mkdir -p "$evidence_dir"
|
||||
gh_with_retry release download "$evidence_source_tag" --repo "$GITHUB_REPOSITORY" \
|
||||
--pattern "$evidence_asset" --pattern "$evidence_checksum_asset" --dir "$evidence_dir" || true
|
||||
evidence_path="$evidence_dir/$evidence_asset"
|
||||
evidence_checksum_path="$evidence_dir/$evidence_checksum_asset"
|
||||
if [[ ! -f "$evidence_path" || ! -f "$evidence_checksum_path" ]]; then
|
||||
if ! gh release download "$evidence_source_tag" --repo "$GITHUB_REPOSITORY" \
|
||||
--pattern "$evidence_asset" --pattern "$evidence_checksum_asset" --dir "$evidence_dir"; then
|
||||
if [[ "$EVENT_NAME" == "push" ]]; then
|
||||
echo "Stable closeout skipped: $evidence_source_tag predates immutable postpublish evidence." >&2
|
||||
echo "should_closeout=false" >> "$GITHUB_OUTPUT"
|
||||
@@ -223,6 +192,7 @@ jobs:
|
||||
echo "Stable closeout is required for $tag, but immutable postpublish evidence from $evidence_source_tag is missing." >&2
|
||||
exit 1
|
||||
fi
|
||||
evidence_path="$evidence_dir/$evidence_asset"
|
||||
if ! (
|
||||
cd "$evidence_dir"
|
||||
sha256sum --strict --status -c "$evidence_checksum_asset"
|
||||
@@ -302,30 +272,6 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Install GitHub API backoff helper
|
||||
run: |
|
||||
cat > "$RUNNER_TEMP/github-api-backoff.sh" <<'BASH'
|
||||
gh_with_retry() {
|
||||
local attempt output status lower_output
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if output="$(gh "$@" 2>&1)"; then
|
||||
printf '%s\n' "$output"
|
||||
return 0
|
||||
fi
|
||||
status=$?
|
||||
lower_output="${output,,}"
|
||||
if [[ "$lower_output" != *"rate limit"* && "$output" != *"HTTP 429"* ]]; then
|
||||
printf '%s\n' "$output" >&2
|
||||
return "$status"
|
||||
fi
|
||||
echo "::warning::GitHub API throttled stable closeout on attempt ${attempt}; retrying after backoff." >&2
|
||||
sleep $((attempt * attempt * 5))
|
||||
done
|
||||
printf '%s\n' "$output" >&2
|
||||
return "$status"
|
||||
}
|
||||
BASH
|
||||
|
||||
- name: Verify release workflow evidence
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -333,8 +279,7 @@ jobs:
|
||||
RELEASE_PUBLISH_RUN_ID: ${{ needs.resolve.outputs.release_publish_run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
. "$RUNNER_TEMP/github-api-backoff.sh"
|
||||
gh_with_retry run view "$FULL_RELEASE_VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" \
|
||||
gh run view "$FULL_RELEASE_VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" \
|
||||
--json workflowName,event,status,conclusion \
|
||||
> "$RUNNER_TEMP/full-release-validation-run.json"
|
||||
node --input-type=module - "$RUNNER_TEMP/full-release-validation-run.json" <<'NODE'
|
||||
@@ -351,7 +296,7 @@ jobs:
|
||||
}
|
||||
}
|
||||
NODE
|
||||
gh_with_retry run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" \
|
||||
gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" \
|
||||
--json workflowName,event,status,conclusion \
|
||||
> "$RUNNER_TEMP/release-publish-run.json"
|
||||
node --input-type=module - "$RUNNER_TEMP/release-publish-run.json" <<'NODE'
|
||||
@@ -372,7 +317,7 @@ jobs:
|
||||
manifest_dir="$RUNNER_TEMP/full-release-validation-manifest"
|
||||
rm -rf "$manifest_dir"
|
||||
mkdir -p "$manifest_dir"
|
||||
gh_with_retry run download "$FULL_RELEASE_VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" \
|
||||
gh run download "$FULL_RELEASE_VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" \
|
||||
--name "full-release-validation-${FULL_RELEASE_VALIDATION_RUN_ID}" \
|
||||
--dir "$manifest_dir"
|
||||
tag_sha="$(git -C "$GITHUB_WORKSPACE/release-tag" rev-parse HEAD)"
|
||||
@@ -401,8 +346,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "$CLOSEOUT_DIR"
|
||||
. "$RUNNER_TEMP/github-api-backoff.sh"
|
||||
gh_with_retry release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
|
||||
gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
|
||||
--json tagName,isDraft,isPrerelease,assets \
|
||||
> "$CLOSEOUT_DIR/github-release.json"
|
||||
node scripts/verify-stable-main-closeout.mjs \
|
||||
@@ -428,23 +372,21 @@ jobs:
|
||||
CLOSEOUT_DIR: ${{ runner.temp }}/openclaw-stable-main-closeout
|
||||
run: |
|
||||
set -euo pipefail
|
||||
. "$RUNNER_TEMP/github-api-backoff.sh"
|
||||
release_version="${RELEASE_TAG#v}"
|
||||
attach_or_verify() {
|
||||
local source_path="$1"
|
||||
local asset_name="$2"
|
||||
local existing_dir="$CLOSEOUT_DIR/existing-${asset_name}"
|
||||
mkdir -p "$existing_dir"
|
||||
gh_with_retry release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
|
||||
--pattern "$asset_name" --dir "$existing_dir" || true
|
||||
if [[ -f "$existing_dir/$asset_name" ]]; then
|
||||
if gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
|
||||
--pattern "$asset_name" --dir "$existing_dir"; then
|
||||
cmp --silent "$source_path" "$existing_dir/$asset_name" || {
|
||||
echo "Existing release asset $asset_name differs from closeout evidence." >&2
|
||||
exit 1
|
||||
}
|
||||
return
|
||||
fi
|
||||
gh_with_retry release upload "$RELEASE_TAG" "$source_path#$asset_name" --repo "$GITHUB_REPOSITORY"
|
||||
gh release upload "$RELEASE_TAG" "$source_path#$asset_name" --repo "$GITHUB_REPOSITORY"
|
||||
}
|
||||
attach_or_verify \
|
||||
"$CLOSEOUT_DIR/stable-main-closeout.json" \
|
||||
|
||||
4
.github/workflows/plugin-npm-release.yml
vendored
4
.github/workflows/plugin-npm-release.yml
vendored
@@ -38,8 +38,8 @@ on:
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: plugin-npm-release-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
group: plugin-npm-release-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
2
.github/workflows/sandbox-common-smoke.yml
vendored
2
.github/workflows/sandbox-common-smoke.yml
vendored
@@ -19,7 +19,7 @@ permissions:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
67
CHANGELOG.md
67
CHANGELOG.md
@@ -6,34 +6,34 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Highlights
|
||||
|
||||
- **Richer Telegram delivery:** Telegram now sends rich HTML, preserves rich markdown and sticker paths, renders progress drafts and command output more faithfully, and keeps mentions and spooled handlers on the right delivery path. (#93286, #93164, #93124, #93364, #93130, #93088, #93281) Thanks @obviyus, @vincentkoc, @goutamadwant, @kesslerio, @NianJiuZst, @SweetSophia, @Marvinthebored, and @aaajiao.
|
||||
- **Richer Telegram delivery:** Telegram now sends rich HTML, preserves rich markdown and sticker paths, renders progress drafts and command output more faithfully, normalizes HTML tables safely, and keeps mentions and spooled handlers on the right delivery path. (#93286, #93164, #93124, #93364, #93130, #93088, #93281, #94891, #94856) Thanks @obviyus, @vincentkoc, @goutamadwant, @kesslerio, @NianJiuZst, @SweetSophia, @Marvinthebored, @aaajiao, @zhangqueping, and @jairrab.
|
||||
- **More dependable agent recovery:** retries, terminal outcomes, usage after compaction, session history repair, and reply reconciliation now keep more interrupted or partial turns moving toward a visible final result. (#92191, #93073, #93228, #93084, #93469, #93291, #90943) Thanks @ai-hpc, @lml2468, @fuller-stack-dev, @Hollychou924, @leno23, @de1tydev, @425072024, @wuwahe3, @drvoss, @yetval, @sandieman2, and @vincentkoc.
|
||||
- **A stronger Codex integration:** Codex gains automatic plugin approvals, GPT-5.3 Spark OAuth routing, remote-node `exec` as a dynamic tool, and more reliable app-server teardown and terminal outcomes. (#92625, #89133, #93654, #91767, #93287) Thanks @kevinslin, @VACInc, @vincentkoc, @JPKay-AI, and @aliahnaf2013-max.
|
||||
- **Standalone official provider plugins:** external provider packages are now first-class npm releases, externally installed channel plugins load at Gateway startup, and StepFun is intentionally npm-only because its ClawHub package name is unavailable. (#93470) Thanks @sunlit-deng, @cxdnicole, and @vincentkoc.
|
||||
- **Standalone official provider plugins:** external provider packages are now first-class npm releases, externally installed channel plugins load at Gateway startup, and StepFun is available from npm and ClawHub. (#93470) Thanks @sunlit-deng, @cxdnicole, and @vincentkoc.
|
||||
- **More capable web and native clients:** the Control UI adds a session workspace rail and extension health, iOS adds Watch controls, and Android shows chat context. (#92856, #91952, #93387, #92837) Thanks @Solvely-Colin, @jalehman, @joshavant, and @Tosko4.
|
||||
- **More useful search and skills:** Codex Hosted Search is available, key-free search providers remain deliberate opt-ins, and ClawHub skill installs retain verified source provenance. (#93446, #93616, #93283, #93506) Thanks @fuller-stack-dev, @davemorin, @momothemage, @nmccready-tars, and @vincentkoc.
|
||||
|
||||
### Changes
|
||||
|
||||
- Providers and auth: add Codex Hosted Search, improve Gemini CLI OAuth behind proxies, and keep external provider onboarding on current choices and package metadata. (#93446, #92815) Thanks @fuller-stack-dev, @yetval, @EvetteYoung, and @vincentkoc.
|
||||
- Plugins and installs: externalized official providers publish as independent npm packages, Gateway discovers installed channel plugins at startup, and StepFun installs exclusively from npm. (#93470) Thanks @sunlit-deng, @cxdnicole, and @vincentkoc.
|
||||
- Plugins and installs: externalized official providers publish as independent npm packages, Gateway discovers installed channel plugins at startup, and StepFun installs from npm or ClawHub. (#93470) Thanks @sunlit-deng, @cxdnicole, and @vincentkoc.
|
||||
- Dashboard and mobile: add a session workspace rail, plugin health in status, compact cron lists, and iOS Watch controls. (#92856, #91952, #93395, #93387) Thanks @Solvely-Colin, @jalehman, @yu-xin-c, @centralpc, @joshavant, and @vincentkoc.
|
||||
- Codex and skills: add automatic plugin approvals, preserve ClawHub skill provenance, and expose remote-node execution to Codex when a node is connected. (#92625, #93283, #93654) Thanks @kevinslin, @momothemage, @nmccready-tars, @vincentkoc, and @JPKay-AI.
|
||||
- QA and release engineering: QA scenarios now use YAML, with broader profile evidence and release coverage for the plugin and channel matrix.
|
||||
- Codex, observability, and skills: add automatic plugin approvals and SecretRefs, preserve ClawHub skill provenance, add OpenTelemetry log export, and expose remote-node execution to Codex when a node is connected. (#92625, #94324, #93283, #94561, #93654) Thanks @kevinslin, @kevinlin-openai, @momothemage, @nmccready-tars, @jesse-merhi, @vincentkoc, and @JPKay-AI.
|
||||
- QA and release engineering: QA scenarios now use YAML, with broader profile evidence and release coverage for the plugin and channel matrix. Thanks @vincentkoc.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Security and privacy: redact secrets from debug/config output, block internal HTTP session overrides, audit open-DM tool exposure, and retain plugin write ownership checks. (#93333, #88496, #93443, #92883, #93353) Thanks @Alix-007, @jason-allen-oneal, @coygeek, @RichardCao, @yu-xin-c, @cjg20ss, @eleqtrizit, and @vincentkoc.
|
||||
- Agent and session runtime: retry thinking-only and empty post-tool turns, prevent duplicate hook execution, preserve fresh usage through compaction, and repair partial JSON/history artifacts. (#92191, #93073, #93009, #93084, #93469) Thanks @ai-hpc, @lml2468, @fuller-stack-dev, @zenglingbiao, @dertbv, @Hollychou924, @leno23, @de1tydev, @425072024, @wuwahe3, @drvoss, and @vincentkoc.
|
||||
- Channels and replies: fix Telegram rich delivery and ingress recovery, preserve WhatsApp auth and media error reporting, keep Mattermost thread replies intact, and harden Discord action handling. (#93286, #93364, #93281, #93076, #93334, #93424, #93488) Thanks @obviyus, @NianJiuZst, @mcaxtr, @rushindrasinha, @amknight, @lzyyzznl, @darealgege, and @vincentkoc.
|
||||
- Agent and session runtime: retry thinking-only and empty post-tool turns, prevent duplicate hook execution, preserve pending subagent delivery, preserve fresh usage through compaction, and repair partial JSON/history artifacts. (#92191, #93073, #93009, #93084, #93469, #94349, #92383, #94257) Thanks @ai-hpc, @lml2468, @fuller-stack-dev, @zenglingbiao, @dertbv, @Hollychou924, @leno23, @de1tydev, @425072024, @wuwahe3, @drvoss, @vincentkoc, @sallyom, @oiGaDio, @Hidetsugu55, and @Nas01010101.
|
||||
- Channels and replies: fix Telegram rich delivery, table rendering, action-error handling, and ingress recovery; preserve command progress detail across channel adapters; retain WhatsApp opening text after a media failure; keep Mattermost thread replies intact; and harden Discord action handling. (#93286, #93364, #93281, #93076, #93334, #93424, #93488, #94868, #94891, #94856, #94810, #93823) Thanks @obviyus, @NianJiuZst, @mcaxtr, @rushindrasinha, @amknight, @lzyyzznl, @darealgege, @vincentkoc, @zhangqueping, @jairrab, @ZOOWH, @parveshsaini, and @yetval.
|
||||
- Storage and migrations: avoid SQLite WAL on network filesystems, clean reindex artifacts, keep setup state out of workspace dot-directories, and import default-agent auth profiles into SQLite. (#93454, #92891, #93182, #93295, #93520, #93156) Thanks @vincentkoc, @ZengWen-DT, @Zeng-wen, @potterdigital, @Alix-007, @Pick-cat, @sallyom, @1qh, and @Tazio7.
|
||||
- Provider and model behavior: fix Gemini CLI proxy OAuth, restore Codex Spark OAuth routing, correct Bedrock embedding model IDs, and preserve configured defaults in embedded runs. (#92815, #89133, #93452, #93428) Thanks @yetval, @EvetteYoung, @VACInc, @LiuwqGit, @aleck31, @zenglingbiao, @danielgerlag, and @vincentkoc.
|
||||
- CLI, TUI, and apps: accept global flags after subcommands, keep terminal output and activity indicators visible, preserve CJK IME composition, and refresh stale UI state. (#93455, #93460, #93006, #93427, #93498, #93606) Thanks @ooiuuii, @Alix-007, @ZengWen-DT, @Zeng-wen, @AlethiaQuizForge, @Zhaoqj2016, @liuhao1024, @BrianClaw1955, @vincentkoc, and @NicoBoom13.
|
||||
- Operations and updates: harden official plugin recovery, restart managed Gateways after failed update handoff, avoid Node-specific npm prefixes, and keep package validation paths reliable. (#93325, #92111, #93650) Thanks @vincentkoc, @yetval, @ofan, and @yaanfpv.
|
||||
- Operations and updates: harden official plugin recovery, restart managed Gateways after failed update handoff, keep safe cron delivery defaults, avoid Node-specific npm prefixes, and keep package validation paths reliable. (#93325, #92111, #93650, #94453, #91685) Thanks @vincentkoc, @yetval, @ofan, @yaanfpv, @jincheng-xydt, @sallyom, @davectr, and @nxmxbbd.
|
||||
|
||||
### Complete contribution record
|
||||
|
||||
This audited record covers the complete v2026.6.8..HEAD~1 history: 375 merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.
|
||||
This audited record covers the complete v2026.6.8..HEAD history: 422 merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.
|
||||
|
||||
#### Pull requests
|
||||
|
||||
@@ -175,7 +175,7 @@ This audited record covers the complete v2026.6.8..HEAD~1 history: 375 merged PR
|
||||
- **PR #90003** feat(policy): cover exec approvals artifact. Thanks @giodl73-repo.
|
||||
- **PR #93448** fix(guards): allow auth profile sqlite reader. Thanks @amknight.
|
||||
- **PR #93424** fix(mattermost): keep message tool replies in threads. Thanks @amknight and @vincentkoc.
|
||||
- **PR #93418** fix(telegram): forward Bot API 10.1 rich_message content to agent. Related #93410. Thanks @xzh-xydt and @vincentkoc and @0pen7ech.
|
||||
- **PR #93418** fix(telegram): forward Bot API 10.1 rich_message content to agent. Related #93410. Thanks @xzh-icenter and @vincentkoc and @0pen7ech.
|
||||
- **PR #93175** test(qa): taxonomy profiles: includeAllCategories for release profile, update some coverage. Thanks @RomneyDa.
|
||||
- **PR #93456** fix(agents): handle string assistant message content. Thanks @vincentkoc.
|
||||
- **PR #93441** fix(outbound): ignore schema-padded poll metadata on send. Related #43015. Thanks @weichengdeng and @charzhou.
|
||||
@@ -412,6 +412,53 @@ This audited record covers the complete v2026.6.8..HEAD~1 history: 375 merged PR
|
||||
- **PR #94658** test(sqlite): use shared temp directory helper. Thanks @vincentkoc.
|
||||
- **PR #92135** fix(openai-embedding): preserve openai/ prefix for non-native base URLs. Related #92124. Thanks @xialonglee and @Kambrian.
|
||||
- **PR #93737** refactor: add session maintenance transaction seam. Thanks @jalehman.
|
||||
- **PR #93685** refactor(auto-reply): add lifecycle storage seams. Thanks @jalehman.
|
||||
- **PR #94349** fix(agents): preserve pending subagent completion announces. Related #93323. Thanks @sallyom and @oiGaDio.
|
||||
- **PR #93174** test: fold channel message flows into qa e2e. Thanks @RomneyDa.
|
||||
- **PR #94093** Prevent Codex thread rotation from losing next-step context. Thanks @VACInc.
|
||||
- **PR #53920** fix(scripts): avoid mutating tracked auth-monitor template during setup. Thanks @JackWuGlobal.
|
||||
- **PR #94702** Standardize QA coverage IDs on dotted names. Thanks @RomneyDa.
|
||||
- **PR #81825** fix(skills/1password): stop forcing tmux for desktop app auth (#52540). Thanks @koshaji and @tylerbittner.
|
||||
- **PR #94725** fix(doctor): warn on volatile SQLite state. Thanks @vincentkoc.
|
||||
- **PR #88551** fix(agents): skip auth gate for CLI-owned transport. Thanks @yu-xin-c.
|
||||
- **PR #88581** feat(commands): add /name to rename the current session from chat. Thanks @BSG2000.
|
||||
- **PR #94324** feat(codex): support app-server SecretRefs. Thanks @kevinlin-openai and @kevinslin.
|
||||
- **PR #90882** fix: add self-knowledge docs rule to system prompt. Related #90713. Thanks @SutraHsing.
|
||||
- **PR #94684** fix: #80507 show dry-run output for message send/poll. Thanks @lzyyzznl and @YB0y.
|
||||
- **PR #93823** fix(whatsapp): keep opening text chunk when first media fails on multi-chunk reply. Thanks @yetval.
|
||||
- **PR #89203** refactor: route SDK session compatibility through seam. Thanks @jalehman.
|
||||
- **PR #94453** fix: default cron runMode to "due" instead of "force" (#94270). Thanks @jincheng-xydt and @sallyom and @davectr.
|
||||
- **PR #94746** fix(note): prevent clack from re-breaking copy-sensitive tokens. Related #94730. Thanks @xzh-icenter and @berkgungor.
|
||||
- **PR #89904** refactor: route sdk session compatibility through accessor. Thanks @jalehman.
|
||||
- **PR #86719** fix(skills): retarget stale plugin skill symlinks. Related #85925. Thanks @stevenepalmer and @shakkernerd.
|
||||
- **PR #94337** fix(tui): show 0 not ? for fresh-session context tokens in footer. Thanks @mushuiyu886.
|
||||
- **PR #94539** fix(android): group settings by intent. Thanks @Tosko4.
|
||||
- **PR #92383** fix(gateway): never return an empty chat.history transcript. Thanks @Hidetsugu55.
|
||||
- **PR #92574** test(browser): cover action-input CLI request bodies. Related #83877. Thanks @yu-xin-c and @davinci282828.
|
||||
- **PR #92873** test(diffs): add viewerState, toolbar toggle, shadow root, and hydrateProps tests (fixes #83915). Thanks @liuhao1024 and @davinci282828.
|
||||
- **PR #94257** fix(sessions): preserve Media\* index alignment when reading user-turn fields. Thanks @Nas01010101.
|
||||
- **PR #94756** fix(codex): bound turn/start text when context budget is non-positive. Related #94748. Thanks @Nas01010101.
|
||||
- **PR #94729** fix(skills/trello): add curl to requires.bins to match body examples (fixes #94727). Thanks @liuhao1024 and @berkgungor.
|
||||
- **PR #94790** feat(slack): log INFO receipt for inbound app_mention events. Related #94691. Thanks @ZengWen-DT and @BryceMurray.
|
||||
- **PR #81696** fix: guard tool event callbacks (AI-assisted). Thanks @enjoylife1243.
|
||||
- **PR #94809** chore: forward-port alpha release fixes.
|
||||
- **PR #94612** fix(macos): open NSOpenPanel for embedded Control UI file inputs (#94468). Thanks @bbblending and @DINGDANGMAOUP.
|
||||
- **PR #89806** fix(feishu): avoid axios interceptor internals. Related #83913. Thanks @sweetcornna and @davinci282828.
|
||||
- **PR #91923** fix(ios): clean up notification settings state. Thanks @zats.
|
||||
- **PR #91345** fix: suggest close CLI commands. Related #83999. Thanks @glenn-agent and @HannesOberreiter.
|
||||
- **PR #94561** Add stdout diagnostics OTEL log exporter. Thanks @jesse-merhi.
|
||||
- **PR #91013** fix(gateway): ignore stale abort markers for fresh chat events. Related #91012. Thanks @nxmxbbd.
|
||||
- **PR #89279** fix(tasks): deliver ACP completions to bound Discord threads. Related #84022. Thanks @anyech and @h-mascot.
|
||||
- **PR #91656** test(cron): expand parseAbsoluteTimeMs test coverage to 39 cases. Related #91654. Thanks @SpecialLeon.
|
||||
- **PR #94810** fix(telegram): classify sendChatAction 401 by structured error_code, not bare substring match. Related #94787. Thanks @ZOOWH and @parveshsaini.
|
||||
- **PR #94737** fix(reply): clarify provider internal error copy. Thanks @snowzlmbot.
|
||||
- **PR #94868** fix(channels): preserve command progress detail. Thanks @vincentkoc.
|
||||
- **PR #94891** fix(telegram): send progress previews as html text. Thanks @obviyus.
|
||||
- **PR #94683** fix(outbound): keep direct-only targets out of group sessions. Related #92384. Thanks @scotthuang and @haiwei01.
|
||||
- **PR #92477** fix: migrate watch app to single-target app (Xcode 27+ compat). Thanks @zats and @joshavant.
|
||||
- **PR #94812** test(perf): compare saved CLI startup benchmarks. Thanks @FelixIsaac.
|
||||
- **PR #94856** fix(telegram): normalize all HTML tables before entity-escaping in rich messages. Related #94317. Thanks @zhangqueping and @jairrab.
|
||||
- **PR #91685** fix(cron): refuse keyless implicit isolated cron delivery inherited from shared agent-main bucket. Thanks @nxmxbbd.
|
||||
|
||||
## 2026.6.8
|
||||
|
||||
|
||||
@@ -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.9
|
||||
OPENCLAW_ANDROID_VERSION_CODE=2026060901
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"version": "2026.6.2",
|
||||
"versionCode": 2026060201
|
||||
"version": "2026.6.9",
|
||||
"versionCode": 2026060901
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# OpenClaw iOS Changelog
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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.9
|
||||
OPENCLAW_MARKETING_VERSION = 2026.6.9
|
||||
OPENCLAW_BUILD_VERSION = 1
|
||||
|
||||
#include? "../build/Version.xcconfig"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
OpenClaw is now available on iPhone.
|
||||
Maintenance update for the current OpenClaw 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.
|
||||
- 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.
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "2026.6.2"
|
||||
"version": "2026.6.9"
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -128,9 +128,14 @@ const config = {
|
||||
"**/*.test-utils.ts",
|
||||
"test/helpers/live-image-probe.ts",
|
||||
"src/secrets/credential-matrix.ts",
|
||||
"src/gateway/live-tool-probe-utils.ts",
|
||||
"src/gateway/server.auth.shared.ts",
|
||||
"src/shared/text/assistant-visible-text.ts",
|
||||
bundledPluginFile("telegram", "src/bot/reply-threading.ts"),
|
||||
bundledPluginFile("telegram", "src/draft-chunking.ts"),
|
||||
bundledPluginFile("msteams", "src/conversation-store-memory.ts"),
|
||||
bundledPluginFile("msteams", "src/polls-store-memory.ts"),
|
||||
bundledPluginFile("voice-call", "src/providers/index.ts"),
|
||||
],
|
||||
ignore: ["packages/*/dist/**"],
|
||||
workspaces: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
ac06b6c20a93a8543ec1bd3748ef4f7bdae5006839dd93b3fff874d0da4244aa config-baseline.json
|
||||
e7965566fdaedef445bcd562141f4f3ea1a499cf8ea5956418af7c98049bf242 config-baseline.core.json
|
||||
24f11880cec619997ff93d303c32431bf4fb2bfefb56c9f0ece35ff91b329a80 config-baseline.json
|
||||
2923c1120c0369aeca6646cd67f7264590c6a1f4e5bc3157a04d7661324c6868 config-baseline.core.json
|
||||
2d735389858305509528e74329b6f8c65d311e1471c3b4e91dc17aaab8e63a80 config-baseline.channel.json
|
||||
0039da0cf2ba2845b37db52c4cf3a0f25e367cf3d2d507c5d6f8a5e5bdfdc4d4 config-baseline.plugin.json
|
||||
d2e2114f1cd43dc894fe1a4836677b42a2a5af825537d6c4a932da832d58a590 config-baseline.plugin.json
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
6f442c09ff2fa618f6f68cc866091a713d2c730090380dd726a9845f4d0fd9bd plugin-sdk-api-baseline.json
|
||||
d6b1929a42117759a3d0908fb68866e721ee7f0840279dce905a975b461c5b67 plugin-sdk-api-baseline.jsonl
|
||||
d5a8dc906d615f081799783ffda46b48dac43c3de9ddcb7c4b95311031fbe80e plugin-sdk-api-baseline.json
|
||||
47d93e8b79e5d5fd0ef0a607a831a5b205c94e759a48401ce4a34da98e42b93d plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -172,12 +172,10 @@ A finding includes:
|
||||
| `ocPath` | Precise `oc://` address when a check can point to one. |
|
||||
| `fixHint` | Suggested operator action or repair summary. |
|
||||
|
||||
Modernized core doctor checks stay attached to the ordered doctor contribution
|
||||
that owns their human `doctor` / `doctor --fix` behavior. The shared structured
|
||||
health registry is the extension point: bundled and plugin-backed checks run
|
||||
after core doctor checks once their owning package registers them in the active
|
||||
command path. The `openclaw/plugin-sdk/health` subpath exposes the same
|
||||
contract for those extension consumers.
|
||||
This release registers the modernized core doctor checks on the structured
|
||||
health path. The `openclaw/plugin-sdk/health` subpath exposes the same
|
||||
contract for bundled follow-up consumers, but plugin-backed checks only run
|
||||
after their owning package registers them in the active command path.
|
||||
|
||||
## Check Selection
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -79,9 +79,9 @@ Pin one model (or one provider) to the harness:
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "github-copilot/auto",
|
||||
model: "github-copilot/gpt-5.5",
|
||||
models: {
|
||||
"github-copilot/auto": {
|
||||
"github-copilot/gpt-5.5": {
|
||||
agentRuntime: { id: "copilot" },
|
||||
},
|
||||
},
|
||||
@@ -95,10 +95,6 @@ when only that model should be routed through the harness; set
|
||||
`agentRuntime.id` on a provider when every model under that provider should
|
||||
use it.
|
||||
|
||||
`github-copilot/auto` is the portable starting point. Named Copilot models are
|
||||
account- and organization-policy-dependent, so only pin one after confirming
|
||||
that the authenticated Copilot CLI exposes it.
|
||||
|
||||
## Supported providers
|
||||
|
||||
The harness advertises support for the canonical `github-copilot` provider
|
||||
@@ -173,9 +169,8 @@ The harness reads its config from per-attempt input
|
||||
- `infiniteSessionConfig` — optional override for the SDK
|
||||
`infiniteSessions` block driven by `harness.compact`. Defaults are safe to
|
||||
leave as-is.
|
||||
- `hooksConfig` — optional native Copilot SDK `SessionHooks` compatibility
|
||||
config for tool/MCP, user-prompt, session, and error callbacks.
|
||||
It is separate from OpenClaw's portable lifecycle hooks.
|
||||
- `hooksConfig` — optional bridge config exposing OpenClaw
|
||||
before/after-message-write hooks to the SDK loop.
|
||||
- `permissionPolicy` — optional override for the SDK's
|
||||
`onPermissionRequest` handler used for built-in SDK tool kinds
|
||||
(`shell`, `write`, `read`, `url`, `mcp`, `memory`, `hook`). Defaults
|
||||
@@ -186,14 +181,6 @@ The harness reads its config from per-attempt input
|
||||
wrapped `execute()`. See [Permissions and ask_user](#permissions-and-ask_user).
|
||||
- `enableSessionTelemetry` — optional SDK session telemetry flag.
|
||||
|
||||
OpenClaw plugin hooks do not need Copilot-specific attempt configuration. The
|
||||
harness runs `before_prompt_build` (and the legacy `before_agent_start`
|
||||
compatibility hook), `llm_input`, `llm_output`, and `agent_end` through the
|
||||
standard harness helpers. Successful SDK compactions also run
|
||||
`before_compaction` and `after_compaction`. Bridged OpenClaw tools continue to
|
||||
run `before_tool_call` and report `after_tool_call`; `hooksConfig` remains for
|
||||
native SDK-only callbacks that have no portable equivalent.
|
||||
|
||||
Nothing in the rest of OpenClaw needs to know about these fields. Other
|
||||
plugins, channels, and core code only see the standard
|
||||
`AgentHarnessAttemptParams` / `AgentHarnessAttemptResult` shape.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -185,17 +185,6 @@ field; OpenClaw does not infer it from assistant prose. The helper intentionally
|
||||
leaves prompt errors, in-flight turns, and intentional silent replies such as
|
||||
`NO_REPLY` unclassified.
|
||||
|
||||
### Agent-end side effects
|
||||
|
||||
Native harnesses must call `runAgentEndSideEffects(...)` from
|
||||
`openclaw/plugin-sdk/agent-harness-runtime` after they finalize an attempt. It
|
||||
dispatches the portable `agent_end` hook and OpenClaw's research capture without
|
||||
delaying interactive replies. Use `awaitAgentEndSideEffects(...)` for local,
|
||||
non-interactive runs where the attempt must not resolve until those side effects
|
||||
finish. Both helpers accept the same `{ event, ctx }` payload as
|
||||
`runAgentHarnessAgentEndHook(...)`; their failures do not alter the completed
|
||||
attempt result.
|
||||
|
||||
### Native Codex harness mode
|
||||
|
||||
The bundled `codex` harness is the native Codex mode for embedded OpenClaw
|
||||
|
||||
@@ -166,9 +166,7 @@ two-party event loops that do not go through the shared inbound reply runner.
|
||||
|
||||
Prefer `getSessionEntry(...)`, `listSessionEntries(...)`, `patchSessionEntry(...)`, or `upsertSessionEntry(...)` for session workflows. These helpers address sessions by agent/session identity so plugins do not depend on the legacy `sessions.json` storage shape. Use `preserveActivity: true` for metadata-only patches that should not refresh session activity, and `replaceEntry: true` only when the callback returns a complete entry and deleted fields must stay deleted.
|
||||
|
||||
For transcript reads and writes, import `openclaw/plugin-sdk/session-transcript-runtime` and use `resolveSessionTranscriptIdentity(...)`, `resolveSessionTranscriptTarget(...)`, `readSessionTranscriptEvents(...)`, `appendSessionTranscriptMessageByIdentity(...)`, `publishSessionTranscriptUpdateByIdentity(...)`, or `withSessionTranscriptWriteLock(...)` with `{ agentId, sessionKey, sessionId }`. These APIs let plugins identify a transcript, read its events, append messages, publish updates, and run related operations under the same transcript write lock. Pass `sessionFile` only when adapting code that already receives an active transcript artifact and needs each helper to operate on that same artifact.
|
||||
|
||||
`loadSessionStore(...)`, `saveSessionStore(...)`, `updateSessionStore(...)`, and `resolveSessionFilePath(...)` are compatibility helpers for plugins that still intentionally depend on the legacy whole-store or transcript-file shape. New plugin code must not use those helpers, and existing callers should migrate to entry helpers.
|
||||
`loadSessionStore(...)`, `saveSessionStore(...)`, `updateSessionStore(...)`, and `resolveSessionFilePath(...)` are kept only during the transition before SQLite migration for plugins that still intentionally depend on the legacy whole-store or transcript-file shape. New plugin code must not use those helpers, and existing callers must migrate to entry helpers before the SQLite storage flip.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="api.runtime.agent.defaults">
|
||||
|
||||
@@ -248,7 +248,6 @@ usage endpoint failed or returned no usable usage data.
|
||||
| `plugin-sdk/reply-reference` | `createReplyReferencePlanner` |
|
||||
| `plugin-sdk/reply-chunking` | Narrow text/markdown chunking helpers |
|
||||
| `plugin-sdk/session-store-runtime` | Session workflow helpers (`getSessionEntry`, `listSessionEntries`, `patchSessionEntry`, `upsertSessionEntry`), legacy session store path/session-key helpers, updated-at reads, and transition-only whole-store/file-path compatibility helpers |
|
||||
| `plugin-sdk/session-transcript-runtime` | Transcript identity, scoped target/read/write helpers, update publishing, write locks, and transcript memory hit keys |
|
||||
| `plugin-sdk/sqlite-runtime` | Focused SQLite agent-schema, path, and transaction helpers for first-party runtime |
|
||||
| `plugin-sdk/cron-store-runtime` | Cron store path/load/save helpers |
|
||||
| `plugin-sdk/state-paths` | State/OAuth dir path helpers |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
4
extensions/acpx/npm-shrinkwrap.json
generated
4
extensions/acpx/npm-shrinkwrap.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/acpx",
|
||||
"version": "2026.6.8",
|
||||
"version": "2026.6.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/acpx",
|
||||
"version": "2026.6.8",
|
||||
"version": "2026.6.9",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/claude-agent-acp": "0.39.0",
|
||||
"@zed-industries/codex-acp": "0.15.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/acpx",
|
||||
"version": "2026.6.8",
|
||||
"version": "2026.6.9",
|
||||
"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.9"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.8",
|
||||
"openclawVersion": "2026.6.9",
|
||||
"staticAssets": [
|
||||
{
|
||||
"source": "./src/runtime-internals/mcp-proxy.mjs",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/admin-http-rpc",
|
||||
"version": "2026.6.8",
|
||||
"version": "2026.6.9",
|
||||
"private": true,
|
||||
"description": "OpenClaw admin HTTP RPC endpoint",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/alibaba-provider",
|
||||
"version": "2026.6.8",
|
||||
"version": "2026.6.9",
|
||||
"private": true,
|
||||
"description": "OpenClaw Alibaba Model Studio video provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/amazon-bedrock-mantle-provider",
|
||||
"version": "2026.6.8",
|
||||
"version": "2026.6.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/amazon-bedrock-mantle-provider",
|
||||
"version": "2026.6.8",
|
||||
"version": "2026.6.9",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.100.1",
|
||||
"@aws/bedrock-token-generator": "1.1.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/amazon-bedrock-mantle-provider",
|
||||
"version": "2026.6.8",
|
||||
"version": "2026.6.9",
|
||||
"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.9"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.8",
|
||||
"openclawVersion": "2026.6.9",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
|
||||
@@ -6,6 +6,8 @@ type SharedIniFileLoader = {
|
||||
loadSharedConfigFiles(init?: { ignoreCache?: boolean }): Promise<unknown>;
|
||||
};
|
||||
|
||||
let sharedIniFileLoaderForTest: SharedIniFileLoader | null | undefined;
|
||||
|
||||
function hasStaticAwsCredentialEnv(env: NodeJS.ProcessEnv): boolean {
|
||||
return Boolean(env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY);
|
||||
}
|
||||
@@ -19,6 +21,12 @@ export function shouldRefreshAwsSharedConfigCacheForBedrock(env: NodeJS.ProcessE
|
||||
}
|
||||
|
||||
async function loadSharedIniFileLoader(): Promise<SharedIniFileLoader> {
|
||||
if (sharedIniFileLoaderForTest !== undefined) {
|
||||
if (!sharedIniFileLoaderForTest) {
|
||||
throw new Error("AWS shared INI file loader unavailable");
|
||||
}
|
||||
return sharedIniFileLoaderForTest;
|
||||
}
|
||||
return (await import("@smithy/shared-ini-file-loader")) as SharedIniFileLoader;
|
||||
}
|
||||
|
||||
@@ -32,3 +40,10 @@ export async function refreshAwsSharedConfigCacheForBedrock(
|
||||
const loader = await loadSharedIniFileLoader();
|
||||
await loader.loadSharedConfigFiles({ ignoreCache: true });
|
||||
}
|
||||
|
||||
/** Override the shared INI loader for Bedrock credential-refresh tests. */
|
||||
export function setAwsSharedIniFileLoaderForTest(
|
||||
loader: SharedIniFileLoader | null | undefined,
|
||||
): void {
|
||||
sharedIniFileLoaderForTest = loader;
|
||||
}
|
||||
|
||||
@@ -9,9 +9,14 @@ import {
|
||||
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { withEnvAsync } from "openclaw/plugin-sdk/test-env";
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { setAwsSharedIniFileLoaderForTest } from "./aws-credential-refresh.js";
|
||||
import { supportsBedrockPromptCaching } from "./bedrock-options.js";
|
||||
import { resetBedrockDiscoveryCacheForTest } from "./discovery.js";
|
||||
import amazonBedrockPlugin from "./index.js";
|
||||
import {
|
||||
resetBedrockAppProfileCacheEligibilityForTest,
|
||||
setBedrockAppProfileControlPlaneForTest,
|
||||
} from "./register.sync.runtime.js";
|
||||
|
||||
type BedrockClientResult =
|
||||
| {
|
||||
@@ -91,10 +96,6 @@ vi.mock("@aws-sdk/client-bedrock", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@smithy/shared-ini-file-loader", () => ({
|
||||
loadSharedConfigFiles: refreshSharedConfigCache,
|
||||
}));
|
||||
|
||||
type RegisteredProviderPlugin = Awaited<ReturnType<typeof registerSingleProviderPlugin>>;
|
||||
|
||||
/** Register the amazon-bedrock plugin with an optional pluginConfig override. */
|
||||
@@ -148,8 +149,6 @@ const ANTHROPIC_MODEL_DESCRIPTOR = {
|
||||
|
||||
const APP_INFERENCE_PROFILE_ARN =
|
||||
"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/my-claude-profile";
|
||||
const OPUS_APP_INFERENCE_PROFILE_ARN =
|
||||
"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/opus-temperature-profile";
|
||||
const APP_INFERENCE_PROFILE_DESCRIPTOR = {
|
||||
api: "openai-completions",
|
||||
provider: "amazon-bedrock",
|
||||
@@ -268,12 +267,26 @@ describe("amazon-bedrock provider plugin", () => {
|
||||
inferenceProfileGetResults.length = 0;
|
||||
bedrockClientConfigs.length = 0;
|
||||
refreshSharedConfigCache.mockClear();
|
||||
setAwsSharedIniFileLoaderForTest({ loadSharedConfigFiles: refreshSharedConfigCache });
|
||||
sendBedrockCommand.mockClear();
|
||||
resetBedrockDiscoveryCacheForTest();
|
||||
resetBedrockAppProfileCacheEligibilityForTest();
|
||||
setBedrockAppProfileControlPlaneForTest((region) => ({
|
||||
async getInferenceProfile(input) {
|
||||
class GetInferenceProfileCommand {
|
||||
constructor(readonly inputLocal: Record<string, unknown> = {}) {}
|
||||
}
|
||||
bedrockClientConfigs.push(region ? { region } : {});
|
||||
return await sendBedrockCommand(new GetInferenceProfileCommand(input));
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setBedrockAppProfileControlPlaneForTest(undefined);
|
||||
setAwsSharedIniFileLoaderForTest(undefined);
|
||||
resetBedrockDiscoveryCacheForTest();
|
||||
resetBedrockAppProfileCacheEligibilityForTest();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@@ -1488,8 +1501,8 @@ describe("amazon-bedrock provider plugin", () => {
|
||||
|
||||
await callWrappedStreamWithPayload(
|
||||
provider,
|
||||
OPUS_APP_INFERENCE_PROFILE_ARN,
|
||||
makeAppInferenceProfileDescriptor(OPUS_APP_INFERENCE_PROFILE_ARN),
|
||||
APP_INFERENCE_PROFILE_ARN,
|
||||
APP_INFERENCE_PROFILE_DESCRIPTOR,
|
||||
{ temperature: 0.3, maxTokens: 10, cacheRetention: "short" },
|
||||
payload,
|
||||
);
|
||||
|
||||
4
extensions/amazon-bedrock/npm-shrinkwrap.json
generated
4
extensions/amazon-bedrock/npm-shrinkwrap.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/amazon-bedrock-provider",
|
||||
"version": "2026.6.8",
|
||||
"version": "2026.6.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/amazon-bedrock-provider",
|
||||
"version": "2026.6.8",
|
||||
"version": "2026.6.9",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-bedrock": "3.1056.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "3.1056.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/amazon-bedrock-provider",
|
||||
"version": "2026.6.8",
|
||||
"version": "2026.6.9",
|
||||
"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.9"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.8",
|
||||
"openclawVersion": "2026.6.9",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
|
||||
@@ -254,7 +254,27 @@ type BedrockControlPlane = {
|
||||
}) => Promise<BedrockGetInferenceProfileResponse>;
|
||||
};
|
||||
|
||||
type BedrockControlPlaneFactory = (region: string | undefined) => BedrockControlPlane;
|
||||
|
||||
let bedrockControlPlaneOverride: BedrockControlPlaneFactory | undefined;
|
||||
|
||||
/** Reset app-profile prompt-cache eligibility state for tests. */
|
||||
export function resetBedrockAppProfileCacheEligibilityForTest(): void {
|
||||
appProfileTraitsCache.clear();
|
||||
}
|
||||
|
||||
/** Override Bedrock app-profile control-plane checks for tests. */
|
||||
export function setBedrockAppProfileControlPlaneForTest(
|
||||
controlPlane: BedrockControlPlaneFactory | undefined,
|
||||
): void {
|
||||
bedrockControlPlaneOverride = controlPlane;
|
||||
resetBedrockAppProfileCacheEligibilityForTest();
|
||||
}
|
||||
|
||||
async function createBedrockControlPlane(region: string | undefined): Promise<BedrockControlPlane> {
|
||||
if (bedrockControlPlaneOverride) {
|
||||
return bedrockControlPlaneOverride(region);
|
||||
}
|
||||
await refreshAwsSharedConfigCacheForBedrock();
|
||||
const { BedrockClient, GetInferenceProfileCommand } = await import("@aws-sdk/client-bedrock");
|
||||
const client = new BedrockClient(region ? { region } : {});
|
||||
|
||||
4
extensions/anthropic-vertex/npm-shrinkwrap.json
generated
4
extensions/anthropic-vertex/npm-shrinkwrap.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/anthropic-vertex-provider",
|
||||
"version": "2026.6.8",
|
||||
"version": "2026.6.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/anthropic-vertex-provider",
|
||||
"version": "2026.6.8",
|
||||
"version": "2026.6.9",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/vertex-sdk": "0.16.1"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/anthropic-vertex-provider",
|
||||
"version": "2026.6.8",
|
||||
"version": "2026.6.9",
|
||||
"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.9"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.8",
|
||||
"openclawVersion": "2026.6.9",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/anthropic-provider",
|
||||
"version": "2026.6.8",
|
||||
"version": "2026.6.9",
|
||||
"private": true,
|
||||
"description": "OpenClaw Anthropic provider plugin",
|
||||
"type": "module",
|
||||
|
||||
4
extensions/arcee/npm-shrinkwrap.json
generated
4
extensions/arcee/npm-shrinkwrap.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/arcee-provider",
|
||||
"version": "2026.6.8",
|
||||
"version": "2026.6.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/arcee-provider",
|
||||
"version": "2026.6.8"
|
||||
"version": "2026.6.9"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/arcee-provider",
|
||||
"version": "2026.6.8",
|
||||
"version": "2026.6.9",
|
||||
"description": "OpenClaw Arcee provider plugin.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -21,10 +21,10 @@
|
||||
"minHostVersion": ">=2026.6.8"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.8"
|
||||
"pluginApi": ">=2026.6.9"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.8",
|
||||
"openclawVersion": "2026.6.9",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/azure-speech",
|
||||
"version": "2026.6.8",
|
||||
"version": "2026.6.9",
|
||||
"private": true,
|
||||
"description": "OpenClaw Azure Speech plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/bonjour",
|
||||
"version": "2026.6.8",
|
||||
"version": "2026.6.9",
|
||||
"description": "OpenClaw Bonjour/mDNS gateway discovery",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
4
extensions/brave/npm-shrinkwrap.json
generated
4
extensions/brave/npm-shrinkwrap.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/brave-plugin",
|
||||
"version": "2026.6.8",
|
||||
"version": "2026.6.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/brave-plugin",
|
||||
"version": "2026.6.8"
|
||||
"version": "2026.6.9"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/brave-plugin",
|
||||
"version": "2026.6.8",
|
||||
"version": "2026.6.9",
|
||||
"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.9"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.8"
|
||||
"openclawVersion": "2026.6.9"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/browser-plugin",
|
||||
"version": "2026.6.8",
|
||||
"version": "2026.6.9",
|
||||
"private": true,
|
||||
"description": "OpenClaw browser tool plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/byteplus-provider",
|
||||
"version": "2026.6.8",
|
||||
"version": "2026.6.9",
|
||||
"private": true,
|
||||
"description": "OpenClaw BytePlus provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/canvas-plugin",
|
||||
"version": "2026.6.8",
|
||||
"version": "2026.6.9",
|
||||
"private": true,
|
||||
"description": "OpenClaw Canvas plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
export interface PnpmRunnerParams {
|
||||
comSpec?: string;
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
nodeArgs?: string[];
|
||||
nodeExecPath?: string;
|
||||
npmExecPath?: string;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
* Cross-platform pnpm command resolver used by Canvas build scripts.
|
||||
*/
|
||||
import { accessSync, closeSync, constants, openSync, readSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const WINDOWS_UNSAFE_CMD_CHARS_RE = /[&|<>%\r\n]/;
|
||||
const PNPM_EXECUTABLE_RE = /^pnpm(?:-cli)?(?:\.(?:[cm]?js|cmd|exe))?$/;
|
||||
@@ -49,56 +48,13 @@ function isExecutableFile(value) {
|
||||
}
|
||||
}
|
||||
|
||||
function isFile(value) {
|
||||
try {
|
||||
return statSync(value).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePathEnvKey(env) {
|
||||
return Object.keys(env).find((key) => key.toLowerCase() === "path") ?? "PATH";
|
||||
}
|
||||
|
||||
function findExecutableOnPath(command, envPath, platform, env, cwd) {
|
||||
if (typeof envPath !== "string" || envPath.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const extensions =
|
||||
platform === "win32"
|
||||
? (env[Object.keys(env).find((key) => key.toLowerCase() === "pathext") ?? "PATHEXT"] ??
|
||||
".COM;.EXE;.BAT;.CMD")
|
||||
.split(";")
|
||||
.filter(Boolean)
|
||||
.map((extension) => extension.toLowerCase())
|
||||
: [""];
|
||||
const pathImpl = platform === "win32" ? path.win32 : path;
|
||||
const pathDelimiter = platform === "win32" ? ";" : path.delimiter;
|
||||
for (const directory of envPath.split(pathDelimiter)) {
|
||||
if (!directory) {
|
||||
continue;
|
||||
}
|
||||
const resolvedDirectory = pathImpl.isAbsolute(directory)
|
||||
? directory
|
||||
: pathImpl.resolve(cwd, directory);
|
||||
for (const extension of extensions) {
|
||||
const candidate = pathImpl.join(resolvedDirectory, `${command}${extension}`);
|
||||
if ((platform === "win32" ? isFile(candidate) : isExecutableFile(candidate))) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isNodeRunnablePnpmExecPath(value) {
|
||||
if (!isPnpmExecPath(value)) {
|
||||
return false;
|
||||
}
|
||||
const { extension } = inspectExecutablePath(value);
|
||||
if (NODE_RUNNABLE_EXTENSIONS.has(extension)) {
|
||||
return isFile(value);
|
||||
return true;
|
||||
}
|
||||
if (extension.length > 0) {
|
||||
return false;
|
||||
@@ -173,22 +129,6 @@ export function resolvePnpmRunner(params = {}) {
|
||||
|
||||
const pnpmArgs = params.pnpmArgs ?? [];
|
||||
const platform = params.platform ?? process.platform;
|
||||
const env = params.env ?? process.env;
|
||||
const envPath = env[platform === "win32" ? resolvePathEnvKey(env) : "PATH"];
|
||||
const cwd = params.cwd ?? process.cwd();
|
||||
const pnpmPath = findExecutableOnPath("pnpm", envPath, platform, env, cwd);
|
||||
if (pnpmPath) {
|
||||
return platform === "win32"
|
||||
? windowsCmdSpec(pnpmPath, pnpmArgs, params.comSpec ?? process.env.ComSpec ?? "cmd.exe")
|
||||
: { args: pnpmArgs, command: pnpmPath, shell: false };
|
||||
}
|
||||
const corepackPath = findExecutableOnPath("corepack", envPath, platform, env, cwd);
|
||||
if (corepackPath) {
|
||||
const args = ["pnpm", ...pnpmArgs];
|
||||
return platform === "win32"
|
||||
? windowsCmdSpec(corepackPath, args, params.comSpec ?? process.env.ComSpec ?? "cmd.exe")
|
||||
: { args, command: corepackPath, shell: false };
|
||||
}
|
||||
if (platform === "win32") {
|
||||
return windowsCmdSpec("pnpm.cmd", pnpmArgs, params.comSpec ?? process.env.ComSpec ?? "cmd.exe");
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ describe("canvas pnpm runner", () => {
|
||||
try {
|
||||
expect(
|
||||
resolvePnpmRunner({
|
||||
env: { PATH: "" },
|
||||
npmExecPath,
|
||||
platform: "darwin",
|
||||
pnpmArgs: ["exec", "rolldown", "-c"],
|
||||
@@ -41,7 +40,6 @@ describe("canvas pnpm runner", () => {
|
||||
try {
|
||||
expect(
|
||||
resolvePnpmRunner({
|
||||
env: { PATH: "" },
|
||||
npmExecPath,
|
||||
platform: "darwin",
|
||||
pnpmArgs: ["exec", "rolldown", "-c"],
|
||||
@@ -55,79 +53,4 @@ describe("canvas pnpm runner", () => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
posixIt("uses Corepack when pnpm is not directly available on PATH", () => {
|
||||
const tempDir = mkdtempSync(path.join(os.tmpdir(), "canvas-pnpm-runner-corepack-"));
|
||||
const corepackPath = path.join(tempDir, "corepack");
|
||||
writeFileSync(corepackPath, "#!/bin/sh\nexit 0\n");
|
||||
chmodSync(corepackPath, 0o755);
|
||||
|
||||
try {
|
||||
expect(
|
||||
resolvePnpmRunner({
|
||||
env: { PATH: tempDir },
|
||||
npmExecPath: "",
|
||||
platform: "darwin",
|
||||
pnpmArgs: ["exec", "rolldown", "-c"],
|
||||
}),
|
||||
).toEqual({
|
||||
args: ["pnpm", "exec", "rolldown", "-c"],
|
||||
command: corepackPath,
|
||||
shell: false,
|
||||
});
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
posixIt("ignores a missing pnpm JS npm_execpath before checking PATH", () => {
|
||||
const tempDir = mkdtempSync(path.join(os.tmpdir(), "canvas-pnpm-runner-missing-"));
|
||||
const corepackPath = path.join(tempDir, "corepack");
|
||||
writeFileSync(corepackPath, "#!/bin/sh\nexit 0\n");
|
||||
chmodSync(corepackPath, 0o755);
|
||||
|
||||
try {
|
||||
expect(
|
||||
resolvePnpmRunner({
|
||||
env: { PATH: tempDir },
|
||||
npmExecPath: path.join(tempDir, "missing-pnpm.mjs"),
|
||||
platform: "darwin",
|
||||
pnpmArgs: ["exec", "rolldown", "-c"],
|
||||
}),
|
||||
).toEqual({
|
||||
args: ["pnpm", "exec", "rolldown", "-c"],
|
||||
command: corepackPath,
|
||||
shell: false,
|
||||
});
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
posixIt("prefers a direct pnpm executable over Corepack", () => {
|
||||
const tempDir = mkdtempSync(path.join(os.tmpdir(), "canvas-pnpm-runner-path-"));
|
||||
const pnpmPath = path.join(tempDir, "pnpm");
|
||||
const corepackPath = path.join(tempDir, "corepack");
|
||||
writeFileSync(pnpmPath, "#!/bin/sh\nexit 0\n");
|
||||
writeFileSync(corepackPath, "#!/bin/sh\nexit 0\n");
|
||||
chmodSync(pnpmPath, 0o755);
|
||||
chmodSync(corepackPath, 0o755);
|
||||
|
||||
try {
|
||||
expect(
|
||||
resolvePnpmRunner({
|
||||
env: { PATH: tempDir },
|
||||
npmExecPath: "",
|
||||
platform: "darwin",
|
||||
pnpmArgs: ["exec", "rolldown", "-c"],
|
||||
}),
|
||||
).toEqual({
|
||||
args: ["exec", "rolldown", "-c"],
|
||||
command: pnpmPath,
|
||||
shell: false,
|
||||
});
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
4
extensions/cerebras/npm-shrinkwrap.json
generated
4
extensions/cerebras/npm-shrinkwrap.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/cerebras-provider",
|
||||
"version": "2026.6.8",
|
||||
"version": "2026.6.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/cerebras-provider",
|
||||
"version": "2026.6.8"
|
||||
"version": "2026.6.9"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/cerebras-provider",
|
||||
"version": "2026.6.8",
|
||||
"version": "2026.6.9",
|
||||
"description": "OpenClaw Cerebras provider plugin.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -21,10 +21,10 @@
|
||||
"minHostVersion": ">=2026.6.8"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.8"
|
||||
"pluginApi": ">=2026.6.9"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.8",
|
||||
"openclawVersion": "2026.6.9",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
|
||||
4
extensions/chutes/npm-shrinkwrap.json
generated
4
extensions/chutes/npm-shrinkwrap.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/chutes-provider",
|
||||
"version": "2026.6.8",
|
||||
"version": "2026.6.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/chutes-provider",
|
||||
"version": "2026.6.8"
|
||||
"version": "2026.6.9"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { loginChutes } from "./oauth.js";
|
||||
|
||||
function boundedErrorResponse(body: string, status = 500): {
|
||||
function boundedErrorResponse(
|
||||
body: string,
|
||||
status = 500,
|
||||
): {
|
||||
response: Response;
|
||||
cancel: ReturnType<typeof vi.fn>;
|
||||
releaseLock: ReturnType<typeof vi.fn>;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/chutes-provider",
|
||||
"version": "2026.6.8",
|
||||
"version": "2026.6.9",
|
||||
"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.9"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.8",
|
||||
"openclawVersion": "2026.6.9",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/clickclack",
|
||||
"version": "2026.6.8",
|
||||
"version": "2026.6.9",
|
||||
"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.9"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/cloudflare-ai-gateway-provider",
|
||||
"version": "2026.6.8",
|
||||
"version": "2026.6.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/cloudflare-ai-gateway-provider",
|
||||
"version": "2026.6.8"
|
||||
"version": "2026.6.9"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/cloudflare-ai-gateway-provider",
|
||||
"version": "2026.6.8",
|
||||
"version": "2026.6.9",
|
||||
"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.9"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.8",
|
||||
"openclawVersion": "2026.6.9",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/codex-supervisor",
|
||||
"version": "2026.6.8",
|
||||
"version": "2026.6.9",
|
||||
"private": true,
|
||||
"description": "OpenClaw Codex app-server fleet supervision plugin.",
|
||||
"type": "module",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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";
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
4
extensions/codex/npm-shrinkwrap.json
generated
4
extensions/codex/npm-shrinkwrap.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/codex",
|
||||
"version": "2026.6.8",
|
||||
"version": "2026.6.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/codex",
|
||||
"version": "2026.6.8",
|
||||
"version": "2026.6.9",
|
||||
"dependencies": {
|
||||
"@openai/codex": "0.139.0",
|
||||
"typebox": "1.1.39",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/codex",
|
||||
"version": "2026.6.8",
|
||||
"version": "2026.6.9",
|
||||
"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.9"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.8"
|
||||
"openclawVersion": "2026.6.9"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -586,51 +586,6 @@ export function prependCodexOpenClawPromptContext(
|
||||
return [context?.trim(), deliverySection, promptSection].filter(Boolean).join("\n\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the surviving user-request portion of an input range after delivery
|
||||
* metadata has been relocated before the request.
|
||||
*/
|
||||
export function resolveCodexDeliveryHintPreservedInputRange(params: {
|
||||
prompt: string;
|
||||
promptInputRange: { start: number; end: number } | undefined;
|
||||
decoratedPrompt: string;
|
||||
}): { start: number; end: number } | undefined {
|
||||
const { prompt, promptInputRange, decoratedPrompt } = params;
|
||||
const { deliveryHint, prompt: promptWithoutDeliveryHint } = splitLeadingCodexDeliveryHint(prompt);
|
||||
if (
|
||||
!deliveryHint ||
|
||||
!promptInputRange ||
|
||||
promptInputRange.start < 0 ||
|
||||
promptInputRange.end < promptInputRange.start ||
|
||||
promptInputRange.end > prompt.length ||
|
||||
!decoratedPrompt.endsWith(promptWithoutDeliveryHint)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const promptWithoutDeliveryHintStart = prompt.length - promptWithoutDeliveryHint.length;
|
||||
const inputStart = Math.max(promptInputRange.start, promptWithoutDeliveryHintStart);
|
||||
const inputEnd = Math.max(
|
||||
inputStart,
|
||||
Math.min(
|
||||
promptInputRange.end,
|
||||
promptWithoutDeliveryHint.length + promptWithoutDeliveryHintStart,
|
||||
),
|
||||
);
|
||||
const decoratedPromptSuffixStart = decoratedPrompt.length - promptWithoutDeliveryHint.length;
|
||||
const requestHeader = "Current user request:\n";
|
||||
const requestHeaderStart = decoratedPromptSuffixStart - requestHeader.length;
|
||||
// Delivery metadata moves outside the request, so retain the remaining input
|
||||
// span rather than treating the original, now non-contiguous range as valid.
|
||||
return {
|
||||
start:
|
||||
inputStart === promptWithoutDeliveryHintStart &&
|
||||
decoratedPrompt.slice(requestHeaderStart, decoratedPromptSuffixStart) === requestHeader
|
||||
? requestHeaderStart
|
||||
: decoratedPromptSuffixStart + inputStart - promptWithoutDeliveryHintStart,
|
||||
end: decoratedPromptSuffixStart + inputEnd - promptWithoutDeliveryHintStart,
|
||||
};
|
||||
}
|
||||
|
||||
function splitLeadingCodexDeliveryHint(prompt: string): {
|
||||
deliveryHint?: string;
|
||||
prompt: string;
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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,
|
||||
@@ -211,6 +216,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 +277,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 +419,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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
50
extensions/codex/src/app-server/client-factory.ts
Normal file
50
extensions/codex/src/app-server/client-factory.ts
Normal 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,
|
||||
}),
|
||||
);
|
||||
@@ -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" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user