mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-25 00:34:16 +08:00
Compare commits
3 Commits
codex/tool
...
codex/mess
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d72b96b845 | ||
|
|
19ceca6b55 | ||
|
|
ee79a31ba5 |
@@ -1,196 +0,0 @@
|
||||
---
|
||||
name: openclaw-ci-limits
|
||||
description: Manage OpenClaw GitHub Actions and Blacksmith CI capacity, runner-registration budgets, fanout caps, main-push debounce, shard sizing, hosted-runner offload, queue health, and safe ramp-down/ramp-up changes. Use when tuning `.github/workflows/*`, `docs/ci.md`, CI runner labels, matrix `max-parallel`, ClawSweeper/Blacksmith burst protection, CodeQL runner placement, or investigating slow/queued OpenClaw CI.
|
||||
---
|
||||
|
||||
# OpenClaw CI Limits
|
||||
|
||||
Use this skill for CI capacity changes, not ordinary test failure triage. The
|
||||
goal is to keep OpenClaw fast while staying below GitHub's self-hosted runner
|
||||
registration edge limit.
|
||||
|
||||
## Core Facts
|
||||
|
||||
- The scarce resource is Blacksmith runner registrations, not Blacksmith vCPU
|
||||
capacity.
|
||||
- GitHub runner registrations are capped at 1,500 per 5 minutes per repository,
|
||||
organization, or enterprise. The `openclaw` organization shares one bucket.
|
||||
- Core REST quota does not draw down this bucket. Check
|
||||
`actions_runner_registration` separately; core quota can be healthy while
|
||||
runner registration is throttled.
|
||||
- Use 1,000 registrations per 5 minutes as the operating target. Leave the last
|
||||
third for other repos, retries, and burst overlap.
|
||||
- Jobs that route, notify, summarize, choose shards, or run short CodeQL quality
|
||||
scans should stay on GitHub-hosted runners unless measured evidence says
|
||||
Blacksmith is required.
|
||||
|
||||
## First Checks
|
||||
|
||||
Before changing CI, collect current pressure:
|
||||
|
||||
```bash
|
||||
ghx api rate_limit --jq '{core:.resources.core,graphql:.resources.graphql,search:.resources.search,actions_runner_registration:.resources.actions_runner_registration}'
|
||||
ghx run list -R openclaw/openclaw --limit 20 --json databaseId,status,conclusion,workflowName,event,headBranch,createdAt,updatedAt,url
|
||||
ghx run list -R openclaw/clawsweeper --limit 20 --json databaseId,status,conclusion,workflowName,event,headBranch,createdAt,updatedAt,url
|
||||
curl -fsS https://clawsweeper.openclaw.ai/api/status | jq '{generated_at,fleet,diagnostics:{errors:.diagnostics.errors}}'
|
||||
curl -fsS https://clawsweeper.openclaw.ai/api/exact-review-queue | jq '.'
|
||||
node scripts/ci-run-timings.mjs --latest-main
|
||||
node scripts/ci-run-timings.mjs --recent 10
|
||||
```
|
||||
|
||||
Read:
|
||||
|
||||
- `.github/workflows/ci.yml`
|
||||
- `.github/workflows/codeql-critical-quality.yml`
|
||||
- `docs/ci.md`
|
||||
- `test/scripts/ci-workflow-guards.test.ts`
|
||||
- touched planner files under `scripts/lib/*ci*`, `scripts/lib/*test-plan*`, or
|
||||
`scripts/ci-changed-scope.mjs`
|
||||
|
||||
## Diagnose The Bottleneck
|
||||
|
||||
Classify the issue before changing caps:
|
||||
|
||||
- **Runner-registration throttle:** many jobs queued before runner assignment,
|
||||
Blacksmith/GitHub reports 403/429 or spam-style 422 responses from
|
||||
`generate-jitconfig`, and API core quota is still healthy. Treat 422 as this
|
||||
signal only when the request payload is otherwise valid. Fix burstiness and
|
||||
Blacksmith job count.
|
||||
- **Blacksmith capacity:** Blacksmith dashboard shows actual concurrency caps or
|
||||
unavailable capacity. Do not solve this with GitHub workflow fanout alone.
|
||||
- **OpenClaw test runtime:** jobs start quickly but one lane dominates wall time.
|
||||
Use `$openclaw-test-performance` instead of runner tuning.
|
||||
- **Real failing CI:** one job fails after starting. Use `$github:gh-fix-ci` or
|
||||
`$openclaw-testing`, not this skill.
|
||||
- **ClawSweeper backlog:** exact-review queue grows while CI is healthy. Tune
|
||||
ClawSweeper workers in `openclaw/clawsweeper`, not OpenClaw CI.
|
||||
|
||||
## Registration Budget Math
|
||||
|
||||
Estimate worst-case registrations for a change before editing:
|
||||
|
||||
```text
|
||||
new Blacksmith registrations ~= number of Blacksmith jobs that can become queued
|
||||
inside one 5 minute window
|
||||
```
|
||||
|
||||
For matrix jobs, count every row that can start in the 5-minute window.
|
||||
`strategy.max-parallel` only caps simultaneous rows; short rows can turn over
|
||||
and register more runners before the window resets. Use job duration, retries,
|
||||
and queue turnover to justify any lower estimate. Add non-matrix Blacksmith jobs
|
||||
such as `preflight`, `security-fast`, `build-artifacts`, and platform lanes.
|
||||
|
||||
For repeated pushes, multiply by the number of runs expected to reach
|
||||
Blacksmith admission in the same 5-minute window, including runs canceled after
|
||||
admission. The debounce only suppresses pushes that arrive while
|
||||
`runner-admission` is still sleeping; once Blacksmith jobs register, those
|
||||
registrations are spent even if a later push cancels the run. If timing is
|
||||
uncertain, count every sequential push in the window.
|
||||
|
||||
Reject a change unless the org-level worst case stays below 1,000 registrations
|
||||
per 5 minutes with headroom for ClawSweeper, ClawHub, Clownfish, OpenClaw RTT,
|
||||
and Clawbench.
|
||||
|
||||
## Safe Levers
|
||||
|
||||
Prefer these in order:
|
||||
|
||||
1. Add or preserve concurrency groups that cancel superseded PR and canonical
|
||||
`main` runs before Blacksmith work starts.
|
||||
2. Keep the `runner-admission` hosted debounce for canonical `main` pushes.
|
||||
Change `OPENCLAW_MAIN_CI_DEBOUNCE_SECONDS` only with evidence.
|
||||
3. Move high-frequency, short, non-build jobs to `ubuntu-24.04`.
|
||||
4. Reduce matrix rows by bundling related tests inside one runner job when the
|
||||
combined job stays under timeout and keeps useful failure names.
|
||||
5. Lower `strategy.max-parallel` for bursty Blacksmith matrices.
|
||||
6. Right-size runners from timing evidence. Use fewer/larger jobs only when
|
||||
elapsed time improves enough to justify registration count.
|
||||
7. Split truly slow tests with `$openclaw-test-performance`; do not hide a slow
|
||||
test problem by registering more runners.
|
||||
|
||||
Do not:
|
||||
|
||||
- add another Blacksmith installation expecting a higher registration bucket;
|
||||
- move CodeQL Critical Quality back to Blacksmith;
|
||||
- raise all `max-parallel` values at once;
|
||||
- make manual `workflow_dispatch` runs cancel normal push/PR validation;
|
||||
- delete coverage just to reduce runner count;
|
||||
- treat cancelled superseded runs as failures without checking the newest run
|
||||
for the same ref.
|
||||
|
||||
## Current OpenClaw Knobs
|
||||
|
||||
These are intentionally guarded by `test/scripts/ci-workflow-guards.test.ts`:
|
||||
|
||||
- `CI` concurrency key version and `cancel-in-progress` for PRs and canonical
|
||||
`main` pushes.
|
||||
- `runner-admission` on `ubuntu-24.04` with
|
||||
`OPENCLAW_MAIN_CI_DEBOUNCE_SECONDS=90`.
|
||||
- `preflight` and `security-fast` needing `runner-admission`.
|
||||
- CI matrix caps: fast/check lanes at 8, compact Node PR plan at current caps,
|
||||
Windows and Android at 2.
|
||||
- `build-artifacts` on `blacksmith-16vcpu-ubuntu-2404`.
|
||||
- lower-weight Node/check shards on `blacksmith-4vcpu-ubuntu-2404`.
|
||||
- heavy retained Linux/Android shards on `blacksmith-8vcpu-ubuntu-2404`.
|
||||
- CodeQL Critical Quality on `ubuntu-24.04` with no `blacksmith-` labels.
|
||||
|
||||
When changing one knob, update `docs/ci.md` and the guard test in the same PR.
|
||||
|
||||
## Validation
|
||||
|
||||
For workflow-only or docs/skill-only changes in a Codex worktree:
|
||||
|
||||
```bash
|
||||
node scripts/run-vitest.mjs test/scripts/ci-workflow-guards.test.ts
|
||||
node scripts/check-workflows.mjs
|
||||
node scripts/docs-list.js
|
||||
./node_modules/.bin/oxfmt --check .github/workflows/ci.yml .github/workflows/codeql-critical-quality.yml docs/ci.md test/scripts/ci-workflow-guards.test.ts .agents/skills/openclaw-ci-limits/SKILL.md .agents/skills/openclaw-ci-limits/agents/openai.yaml
|
||||
git diff --check
|
||||
```
|
||||
|
||||
If `pnpm docs:list` tries to reconcile dependencies in a linked Codex worktree,
|
||||
stop and use `node scripts/docs-list.js`.
|
||||
|
||||
For a PR before requesting maintainer approval:
|
||||
|
||||
```bash
|
||||
.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
|
||||
ghx pr checks <pr> -R openclaw/openclaw --watch --interval 15
|
||||
```
|
||||
|
||||
Use hosted exact-head gates for CI workflow tuning. Do not burn local
|
||||
`pnpm test` on unrelated full-suite proof.
|
||||
|
||||
Only after the maintainer explicitly asks you to prepare or land the PR, run the
|
||||
repo-native mutating wrapper:
|
||||
|
||||
```bash
|
||||
scripts/pr review-init <pr>
|
||||
scripts/pr review-artifacts-init <pr>
|
||||
scripts/pr review-validate-artifacts <pr>
|
||||
OPENCLAW_TESTBOX=1 scripts/pr prepare-run <pr>
|
||||
```
|
||||
|
||||
`prepare-run` can push a prepared commit to the PR branch. Only run
|
||||
`scripts/pr merge-run <pr>` after the maintainer has explicitly asked you to
|
||||
land the PR. Both commands mutate GitHub state.
|
||||
|
||||
## Post-Land Monitoring
|
||||
|
||||
After merge, watch at least one fresh main cycle and the adjacent repos:
|
||||
|
||||
```bash
|
||||
ghx run list -R openclaw/openclaw --limit 20 --json databaseId,status,conclusion,workflowName,event,headBranch,createdAt,updatedAt,url
|
||||
for repo in openclaw/clawsweeper openclaw/clawhub openclaw/clownfish openclaw/openclaw-rtt openclaw/clawbench; do
|
||||
ghx run list -R "$repo" --limit 12 --json databaseId,status,conclusion,workflowName,event,headBranch,createdAt,updatedAt,url
|
||||
done
|
||||
curl -fsS https://clawsweeper.openclaw.ai/api/exact-review-queue | jq '.'
|
||||
```
|
||||
|
||||
Report:
|
||||
|
||||
- exact PR/commit landed;
|
||||
- expected registration reduction or added headroom;
|
||||
- CI run status and slowest/queued jobs;
|
||||
- ClawSweeper queue pending, dispatching, leased, oldest pending age;
|
||||
- any real failures that remain outside runner registration.
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "OpenClaw CI Limits"
|
||||
short_description: "Tune OpenClaw CI fanout and runner budgets"
|
||||
default_prompt: "Use $openclaw-ci-limits to inspect OpenClaw CI pressure, tune runner-registration fanout safely, and document the exact validation before landing."
|
||||
1
.github/labeler.yml
vendored
1
.github/labeler.yml
vendored
@@ -118,7 +118,6 @@
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/qa-lab/**"
|
||||
- "qa/scenarios/**"
|
||||
- "docs/maturity/**"
|
||||
- "docs/concepts/qa-e2e-automation.md"
|
||||
- "docs/concepts/personal-agent-benchmark-pack.md"
|
||||
- "docs/channels/qa-channel.md"
|
||||
|
||||
32
.github/workflows/codeql-critical-quality.yml
vendored
32
.github/workflows/codeql-critical-quality.yml
vendored
@@ -152,7 +152,7 @@ jobs:
|
||||
quality-shards:
|
||||
name: Select Critical Quality shards
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
agent: ${{ steps.detect.outputs.agent }}
|
||||
@@ -333,7 +333,7 @@ jobs:
|
||||
name: Critical Quality (core-auth-secrets)
|
||||
needs: quality-shards
|
||||
if: ${{ needs.quality-shards.outputs.core_auth_secrets == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'core-auth-secrets') }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -356,7 +356,7 @@ jobs:
|
||||
name: Critical Quality (config-boundary)
|
||||
needs: quality-shards
|
||||
if: ${{ needs.quality-shards.outputs.config == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'config-boundary') }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -379,7 +379,7 @@ jobs:
|
||||
name: Critical Quality (gateway-runtime-boundary)
|
||||
needs: quality-shards
|
||||
if: ${{ needs.quality-shards.outputs.gateway == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'gateway-runtime-boundary') }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -402,7 +402,7 @@ jobs:
|
||||
name: Critical Quality (channel-runtime-boundary)
|
||||
needs: quality-shards
|
||||
if: ${{ needs.quality-shards.outputs.channel == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'channel-runtime-boundary') }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -425,7 +425,7 @@ jobs:
|
||||
name: Critical Quality (network-runtime-boundary)
|
||||
needs: quality-shards
|
||||
if: ${{ needs.quality-shards.outputs.network_runtime == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'network-runtime-boundary') }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -509,7 +509,7 @@ jobs:
|
||||
name: Critical Quality (agent-runtime-boundary)
|
||||
needs: quality-shards
|
||||
if: ${{ needs.quality-shards.outputs.agent == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'agent-runtime-boundary') }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -532,7 +532,7 @@ jobs:
|
||||
name: Critical Quality (mcp-process-runtime-boundary)
|
||||
needs: quality-shards
|
||||
if: ${{ needs.quality-shards.outputs.mcp_process == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'mcp-process-runtime-boundary') }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -555,7 +555,7 @@ jobs:
|
||||
name: Critical Quality (memory-runtime-boundary)
|
||||
needs: quality-shards
|
||||
if: ${{ needs.quality-shards.outputs.memory == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'memory-runtime-boundary') }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -578,7 +578,7 @@ jobs:
|
||||
name: Critical Quality (session-diagnostics-boundary)
|
||||
needs: quality-shards
|
||||
if: ${{ needs.quality-shards.outputs.session_diagnostics == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'session-diagnostics-boundary') }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -601,7 +601,7 @@ jobs:
|
||||
name: Critical Quality (plugin-sdk-reply-runtime)
|
||||
needs: quality-shards
|
||||
if: ${{ needs.quality-shards.outputs.plugin_sdk_reply == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'plugin-sdk-reply-runtime') }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -624,7 +624,7 @@ jobs:
|
||||
name: Critical Quality (provider-runtime-boundary)
|
||||
needs: quality-shards
|
||||
if: ${{ needs.quality-shards.outputs.provider == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'provider-runtime-boundary') }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -646,7 +646,7 @@ jobs:
|
||||
ui-control-plane:
|
||||
name: Critical Quality (ui-control-plane)
|
||||
if: ${{ github.event_name != 'pull_request' && (github.event_name != 'workflow_dispatch' || inputs.profile == 'all') }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -668,7 +668,7 @@ jobs:
|
||||
web-media-runtime-boundary:
|
||||
name: Critical Quality (web-media-runtime-boundary)
|
||||
if: ${{ github.event_name != 'pull_request' && (github.event_name != 'workflow_dispatch' || inputs.profile == 'all') }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -691,7 +691,7 @@ jobs:
|
||||
name: Critical Quality (plugin-boundary)
|
||||
needs: quality-shards
|
||||
if: ${{ needs.quality-shards.outputs.plugin == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'plugin-boundary') }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -714,7 +714,7 @@ jobs:
|
||||
name: Critical Quality (plugin-sdk-package-contract)
|
||||
needs: quality-shards
|
||||
if: ${{ needs.quality-shards.outputs.plugin_sdk_package == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'pull_request' || github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == 'plugin-sdk-package-contract') }}
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
@@ -609,6 +609,7 @@ jobs:
|
||||
requires_repo_e2e: true
|
||||
requires_live_suites: false
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENCLAW_E2E_WORKERS: "1"
|
||||
OPENCLAW_VITEST_MAX_WORKERS: "1"
|
||||
steps:
|
||||
@@ -642,74 +643,9 @@ jobs:
|
||||
set -euo pipefail
|
||||
case "${{ matrix.suite_id }}" in
|
||||
openshell-e2e)
|
||||
echo "OPENCLAW_E2E_OPENSHELL_CONFIG_HOME=$HOME/.config" >> "$GITHUB_ENV"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Install OpenShell CLI
|
||||
if: |
|
||||
(inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id) &&
|
||||
matrix.suite_id == 'openshell-e2e'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export OPENSHELL_VERSION=v0.0.68
|
||||
curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/d64542f69d06694cbd203b64929d286dd0533bbb/install.sh | sh
|
||||
openshell --version
|
||||
|
||||
- name: Bootstrap OpenShell gateway
|
||||
if: |
|
||||
(inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id) &&
|
||||
matrix.suite_id == 'openshell-e2e'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mtls_dir="$HOME/.config/openshell/gateways/openshell/mtls"
|
||||
gateway_tls_dir="$RUNNER_TEMP/openshell-gateway-certs"
|
||||
fallback_pid=""
|
||||
if ! openshell --gateway openshell sandbox list >/dev/null 2>&1; then
|
||||
rm -rf "$gateway_tls_dir"
|
||||
openshell-gateway generate-certs \
|
||||
--output-dir "$gateway_tls_dir" \
|
||||
--server-san 127.0.0.1 \
|
||||
--server-san localhost \
|
||||
--server-san host.openshell.internal
|
||||
rm -rf "$mtls_dir"
|
||||
mkdir -p "$mtls_dir"
|
||||
cp "$gateway_tls_dir/ca.crt" "$mtls_dir/ca.crt"
|
||||
cp "$gateway_tls_dir/client/tls.crt" "$mtls_dir/tls.crt"
|
||||
cp "$gateway_tls_dir/client/tls.key" "$mtls_dir/tls.key"
|
||||
openshell gateway remove openshell >/dev/null 2>&1 || true
|
||||
OPENSHELL_LOCAL_TLS_DIR="$gateway_tls_dir" nohup openshell-gateway \
|
||||
--bind-address 0.0.0.0 \
|
||||
--port 17670 \
|
||||
--drivers docker \
|
||||
--tls-cert "$gateway_tls_dir/server/tls.crt" \
|
||||
--tls-key "$gateway_tls_dir/server/tls.key" \
|
||||
--tls-client-ca "$mtls_dir/ca.crt" \
|
||||
>"$RUNNER_TEMP/openshell-gateway.log" 2>&1 &
|
||||
fallback_pid=$!
|
||||
echo "OPENCLAW_OPENSHELL_FALLBACK_PID=$fallback_pid" >> "$GITHUB_ENV"
|
||||
for _ in $(seq 1 30); do
|
||||
if openshell gateway add --local --name openshell https://127.0.0.1:17670; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
openshell gateway select openshell
|
||||
for _ in $(seq 1 60); do
|
||||
if openshell --gateway openshell sandbox list >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
if [[ -z "$fallback_pid" ]]; then
|
||||
echo "OPENCLAW_OPENSHELL_FALLBACK_PID=" >> "$GITHUB_ENV"
|
||||
fi
|
||||
openshell --gateway openshell sandbox list >/dev/null
|
||||
openshell gateway list
|
||||
|
||||
- name: Validate suite credentials
|
||||
if: inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id
|
||||
shell: bash
|
||||
@@ -729,15 +665,6 @@ jobs:
|
||||
(inputs.live_suite_filter == '' || inputs.live_suite_filter == matrix.suite_id)
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
- name: Stop fallback OpenShell gateway
|
||||
if: always() && matrix.suite_id == 'openshell-e2e'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -n "${OPENCLAW_OPENSHELL_FALLBACK_PID:-}" ]]; then
|
||||
kill "$OPENCLAW_OPENSHELL_FALLBACK_PID" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
validate_docker_e2e:
|
||||
needs: [validate_selected_ref, prepare_docker_e2e_image, plan_release_workflow_matrices]
|
||||
if: inputs.include_release_path_suites && inputs.docker_lanes == '' && needs.plan_release_workflow_matrices.outputs.docker_e2e_count != '0'
|
||||
|
||||
30
.github/workflows/openclaw-performance.yml
vendored
30
.github/workflows/openclaw-performance.yml
vendored
@@ -151,39 +151,11 @@ jobs:
|
||||
echo "present=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Resolve OpenClaw target ref
|
||||
id: target
|
||||
if: steps.lane.outputs.run == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TARGET_REF_INPUT: ${{ inputs.target_ref }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
requested="${TARGET_REF_INPUT:-}"
|
||||
if [[ -z "$requested" ]]; then
|
||||
echo "checkout_ref=${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
|
||||
echo "tested_ref=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
encoded_ref="$(node -e 'process.stdout.write(encodeURIComponent(process.argv[1]))' "$requested")"
|
||||
if ! resolved_sha="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${encoded_ref}" --jq '.sha')"; then
|
||||
echo "::error::Unable to resolve OpenClaw target_ref '${requested}'." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "$resolved_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "::error::OpenClaw target_ref '${requested}' resolved to invalid SHA '${resolved_sha}'." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "checkout_ref=${resolved_sha}" >> "$GITHUB_OUTPUT"
|
||||
echo "tested_ref=${requested}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout OpenClaw
|
||||
if: steps.lane.outputs.run == 'true'
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
ref: ${{ steps.target.outputs.checkout_ref }}
|
||||
ref: ${{ inputs.target_ref || github.ref }}
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
39
CHANGELOG.md
39
CHANGELOG.md
@@ -2,45 +2,6 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.6.10
|
||||
|
||||
### Highlights
|
||||
|
||||
- **Automatic fast mode for talks:** OpenClaw can enable fast mode for short conversational turns, then return to normal mode for longer runs with bounded fallback and delivery behavior. (#85104) Thanks @alexph-dev and @vincentkoc.
|
||||
- **More reliable model routing:** Zai model synthesis, GLM overload failover, and native reasoning-level selection now follow the active model catalog more consistently. (#94461, #93241, #94067, #94136) Thanks @Pandah97, @chrysb, @0xghost42, @zhengli0922, @openperf, @civiltox, and @BorClaw.
|
||||
- **Safer session and channel state:** channel switches reset stale origin fields, and cron delivery awareness stays attached to the target session. (#95328, #93580) Thanks @ZengWen-DT, @jalehman, @gorkem2020, and @scotthuang.
|
||||
- **Trusted policies survive hook composition:** composed hook registries keep the trusted tool policies required by approval-sensitive flows. (#94545) Thanks @jesse-merhi.
|
||||
|
||||
### Changes
|
||||
|
||||
- **Agent and channel runtime:** fast-mode state now survives retries, fallback transitions, progress events, and embedded/CLI/ACP normalization; session and channel routing retain the current target and delivery context. (#85104, #93580, #95328) Thanks @alexph-dev, @vincentkoc, @scotthuang, @ZengWen-DT, @jalehman, and @gorkem2020.
|
||||
- **Provider behavior:** model catalogs now supply the correct Zai base URL, overload classification, and native reasoning controls for live-discovered models. (#94461, #93241, #94067, #94136) Thanks @Pandah97, @chrysb, @0xghost42, @zhengli0922, @openperf, @civiltox, and @BorClaw.
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Fast-mode and policy correctness:** fallback cutoffs and reset notices are bounded, repeated progress events remain visible, Codex service-tier state is normalized, and trusted policies are not lost when hook registries are composed. (#85104, #94545) Thanks @alexph-dev, @vincentkoc, and @jesse-merhi.
|
||||
- **Model and delivery edge cases:** Zai and GLM failover paths use the right runtime metadata, while stale channel-origin state no longer leaks across session changes. (#94461, #93241, #95328) Thanks @Pandah97, @chrysb, @0xghost42, @zhengli0922, @ZengWen-DT, @jalehman, and @gorkem2020.
|
||||
- **Provider plugin onboarding:** setup refreshes provider plugin registry metadata after installing setup-selected provider plugins, so auth continuation uses the newly installed provider instead of stale registry state. (#95792) Thanks @snowzlmbot.
|
||||
|
||||
### Complete contribution record
|
||||
|
||||
This audited record covers the complete v2026.6.9..HEAD history: 12 merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.
|
||||
|
||||
#### Pull requests
|
||||
|
||||
- **PR #86627** Keep core doctor health in contribution order. Thanks @giodl73-repo.
|
||||
- **PR #93580** fix: preserve cron delivery awareness for target sessions. Thanks @scotthuang and @jalehman.
|
||||
- **PR #95030** refactor: add SDK transcript identity target API. Thanks @jalehman.
|
||||
- **PR #94838** refactor(copilot): complete harness lifecycle parity. Thanks @vincentkoc.
|
||||
- **PR #95328** fix(sessions): reset stale per-channel origin fields on channel switch. Related #95325. Thanks @ZengWen-DT and @jalehman and @gorkem2020.
|
||||
- **PR #94461** fix(zai): fall back to manifest baseUrl for synthesized GLM-5 models. Related #94269. Thanks @Pandah97 and @chrysb.
|
||||
- **PR #93241** fix(agents): classify Zhipu GLM overload as overloaded for failover. Related #93211. Thanks @0xghost42 and @zhengli0922.
|
||||
- **PR #94067** fix(channels): resolve native /think menu levels via runtime catalog for live-discovered models. Related #93835. Thanks @openperf and @civiltox.
|
||||
- **PR #94136** fix(zai): expose GLM-5.2 reasoning levels [AI-assisted]. Thanks @BorClaw.
|
||||
- **PR #85104** feat: fast talks auto mode. Related #85087. Thanks @alexph-dev.
|
||||
- **PR #94545** fix: keep trusted policies with hook registry. Thanks @jesse-merhi.
|
||||
- **PR #95792** fix(onboard): refresh provider plugin registry after setup installs. Related #95765. Thanks @snowzlmbot.
|
||||
|
||||
## 2026.6.9
|
||||
|
||||
### Highlights
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
Maintenance update for the current OpenClaw Android release.
|
||||
## 2026.6.9 - 2026-06-23
|
||||
|
||||
Adds settings detail panels, refreshes the Android overview controls, and routes exec approvals into the in-app inbox.
|
||||
|
||||
Improves chat acknowledgement handling, gateway pairing readiness, microphone foreground-service behavior, and release screenshot reliability.
|
||||
|
||||
## 2026.6.2 - 2026-06-02
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# Source of truth: apps/android/version.json
|
||||
# Generated by scripts/android-sync-versioning.ts.
|
||||
|
||||
OPENCLAW_ANDROID_VERSION_NAME=2026.6.10
|
||||
OPENCLAW_ANDROID_VERSION_CODE=2026061001
|
||||
OPENCLAW_ANDROID_VERSION_NAME=2026.6.9
|
||||
OPENCLAW_ANDROID_VERSION_CODE=2026060901
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
Maintenance update for the current OpenClaw Android release.
|
||||
Adds settings detail panels, refreshes the Android overview controls, and routes exec approvals into the in-app inbox.
|
||||
|
||||
Improves chat acknowledgement handling, gateway pairing readiness, microphone foreground-service behavior, and release screenshot reliability.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"version": "2026.6.10",
|
||||
"versionCode": 2026061001
|
||||
"version": "2026.6.9",
|
||||
"versionCode": 2026060901
|
||||
}
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
# OpenClaw iOS Changelog
|
||||
|
||||
## 2026.6.10 - 2026-06-21
|
||||
|
||||
Maintenance update for the current OpenClaw beta release.
|
||||
|
||||
- Improved notification cleanup, Watch app compatibility, and native file input handling.
|
||||
|
||||
## 2026.6.9 - 2026-06-20
|
||||
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// Source of truth: apps/ios/version.json
|
||||
// Generated by scripts/ios-sync-versioning.ts.
|
||||
|
||||
OPENCLAW_IOS_VERSION = 2026.6.10
|
||||
OPENCLAW_MARKETING_VERSION = 2026.6.10
|
||||
OPENCLAW_IOS_VERSION = 2026.6.9
|
||||
OPENCLAW_MARKETING_VERSION = 2026.6.9
|
||||
OPENCLAW_BUILD_VERSION = 1
|
||||
|
||||
#include? "../build/Version.xcconfig"
|
||||
|
||||
@@ -68,9 +68,9 @@ Release behavior:
|
||||
- App Store release uses manual `Apple Distribution` signing with profile names pinned in `apps/ios/Config/AppStoreSigning.json`.
|
||||
- Fastlane owns one-time Developer Portal setup, encrypted `match` signing sync to the repo/branch pinned in `apps/ios/Config/AppStoreSigning.json`, and release handling.
|
||||
- App Store release also switches the app to `OpenClawPushMode=appStore`, which derives relay transport, official distribution, the canonical production relay, production APNs, production relay profile, `appleStrict` proof, and the App-Attest-capable entitlement file.
|
||||
- `pnpm ios:release:upload` generates App Store screenshots, uploads release notes, and attaches `apps/ios/APP-REVIEW-NOTES.md` as a rendered PDF before archiving and uploading the IPA.
|
||||
- `pnpm ios:release:upload` generates App Store screenshots and uploads release notes before archiving and uploading the IPA.
|
||||
- The release archive is validated before upload by inspecting the exported IPA's signed entitlements, embedded App Store profile, and push mode. The upload fails if the IPA is not an App Store production relay build.
|
||||
- App Review submission is manual in App Store Connect. The release lane uploads a build, public metadata, and the App Review PDF attachment, but it does not submit for review or upload the App Store Connect `Notes` field.
|
||||
- App Review submission is manual in App Store Connect. The release lane uploads a build and metadata, but does not submit for review.
|
||||
- The release flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
|
||||
- `apps/ios/version.json` is the pinned iOS release version source.
|
||||
- `apps/ios/CHANGELOG.md` is the iOS-only changelog and release-note source.
|
||||
@@ -178,7 +178,7 @@ pnpm ios:release:upload
|
||||
- verifies synced iOS versioning artifacts
|
||||
- resolves the next App Store Connect build number for that short version
|
||||
- generates deterministic App Store screenshots
|
||||
- uploads release notes, screenshots, and the App Review PDF attachment to the editable App Store version
|
||||
- uploads release notes and screenshots to the editable App Store version
|
||||
- generates `apps/ios/build/AppStoreRelease.xcconfig`
|
||||
- archives `OpenClaw`
|
||||
- validates the exported IPA's push mode, signed entitlements, and embedded App Store profile
|
||||
|
||||
@@ -96,7 +96,7 @@ Pinned iOS version `2026.4.10` maps to:
|
||||
- creates or verifies Developer Portal bundle IDs/services through Fastlane `produce`
|
||||
- syncs encrypted App Store signing assets with Fastlane `match`
|
||||
- increments App Store Connect build numbers for the pinned short version
|
||||
- uploads screenshots, release notes, and the rendered App Review PDF attachment before archiving a release build
|
||||
- uploads screenshots and release notes before archiving a release build
|
||||
|
||||
## Release-note resolution order
|
||||
|
||||
@@ -156,4 +156,4 @@ Fastlane and Xcode should consume only the pinned iOS version from `apps/ios/ver
|
||||
|
||||
Changing `package.json.version` alone must not change the iOS app version until a maintainer explicitly runs the pin step.
|
||||
|
||||
App Review submission must remain manual. Automation may create/update the editable App Store version, upload screenshots, upload release notes, upload the App Review PDF attachment, and upload builds, but it should not upload the App Store Connect `Notes` field or submit a build for review.
|
||||
App Review submission must remain manual. Automation may create/update the editable App Store version, upload screenshots, upload release notes, and upload builds, but it should not submit a build for review.
|
||||
|
||||
@@ -5,7 +5,6 @@ require "fileutils"
|
||||
require "tmpdir"
|
||||
require "tempfile"
|
||||
require "cgi"
|
||||
require "digest/md5"
|
||||
|
||||
default_platform(:ios)
|
||||
|
||||
@@ -48,14 +47,6 @@ PUBLIC_METADATA_FILENAMES = [
|
||||
"subtitle.txt",
|
||||
"support_url.txt"
|
||||
].freeze
|
||||
APP_REVIEW_NOTES_METADATA_FILENAMES = [
|
||||
"notes.txt",
|
||||
"review_notes.txt"
|
||||
].freeze
|
||||
APP_STORE_SCREENSHOT_LIMIT_PER_SET = 10
|
||||
APP_STORE_SCREENSHOT_SET_DELETE_TIMEOUT_SECONDS = 120
|
||||
APP_STORE_SCREENSHOT_PROCESSING_TIMEOUT_SECONDS = 3600
|
||||
APP_STORE_SCREENSHOT_PROCESSING_POLL_SECONDS = 5
|
||||
|
||||
def load_env_file(path)
|
||||
return unless File.exist?(path)
|
||||
@@ -88,6 +79,10 @@ def release_notes_upload_requested?
|
||||
ENV["DELIVER_RELEASE_NOTES"] == "1"
|
||||
end
|
||||
|
||||
def screenshot_paths
|
||||
Dir[File.join(__dir__, "screenshots", "**", "*.png")]
|
||||
end
|
||||
|
||||
def validate_required_screenshots!(paths)
|
||||
missing_families = REQUIRED_SCREENSHOT_FAMILIES.filter_map do |name, pattern|
|
||||
name unless paths.any? { |path| File.basename(path).match?(pattern) }
|
||||
@@ -737,37 +732,6 @@ def release_notes_metadata_path
|
||||
temp_root
|
||||
end
|
||||
|
||||
def app_review_notes_markdown_path
|
||||
File.join(ios_root, "APP-REVIEW-NOTES.md")
|
||||
end
|
||||
|
||||
def app_review_notes_pdf_path
|
||||
File.join(ios_root, "build", "app-review", "APP-REVIEW-NOTES.pdf")
|
||||
end
|
||||
|
||||
def generate_app_review_notes_pdf!
|
||||
source = app_review_notes_markdown_path
|
||||
UI.user_error!("Missing App Review notes at #{source}.") unless File.exist?(source)
|
||||
|
||||
output = app_review_notes_pdf_path
|
||||
FileUtils.mkdir_p(File.dirname(output))
|
||||
sh(shell_join(["xcrun", "swift", File.join(repo_root, "scripts", "ios-app-review-notes-pdf.swift"), source, output]))
|
||||
output
|
||||
end
|
||||
|
||||
def assert_no_app_review_notes_field_metadata!(metadata_path)
|
||||
notes_dir = File.join(metadata_path, "review_information")
|
||||
APP_REVIEW_NOTES_METADATA_FILENAMES.each do |filename|
|
||||
path = File.join(notes_dir, filename)
|
||||
next unless File.exist?(path)
|
||||
|
||||
UI.user_error!(
|
||||
"Refusing to upload App Review Notes metadata from #{path}. " \
|
||||
"Maintain the App Store Connect Notes field manually so the live setup code is not stored in this repo."
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def public_metadata_path
|
||||
source = File.join(__dir__, "metadata")
|
||||
temp_root = Dir.mktmpdir("openclaw-app-store-metadata")
|
||||
@@ -781,259 +745,6 @@ def public_metadata_path
|
||||
temp_root
|
||||
end
|
||||
|
||||
def app_store_screenshot_root
|
||||
File.join(__dir__, "screenshots")
|
||||
end
|
||||
|
||||
def app_store_screenshot_manifest
|
||||
require "deliver/loader"
|
||||
|
||||
Deliver::Loader.load_app_screenshots(app_store_screenshot_root, false)
|
||||
end
|
||||
|
||||
def resolve_app_store_connect_app(app_identifier:, app_id:)
|
||||
require "spaceship"
|
||||
|
||||
app = if env_present?(app_id) && !env_present?(app_identifier)
|
||||
Spaceship::ConnectAPI::App.get(app_id: app_id)
|
||||
else
|
||||
Spaceship::ConnectAPI::App.find(app_identifier || APP_STORE_APP_IDENTIFIER)
|
||||
end
|
||||
UI.user_error!("Could not find App Store Connect app #{app_identifier || app_id || APP_STORE_APP_IDENTIFIER}.") unless app
|
||||
app
|
||||
end
|
||||
|
||||
def resolve_app_store_connect_version(app:, short_version:)
|
||||
version = app.get_edit_app_store_version(platform: Spaceship::ConnectAPI::Platform::IOS)
|
||||
UI.user_error!("Could not find an editable App Store Connect version for #{app.name}.") unless version
|
||||
if version.version_string != short_version
|
||||
UI.user_error!(
|
||||
"Editable App Store Connect version mismatch for #{app.name}: expected #{short_version}, got #{version.version_string}."
|
||||
)
|
||||
end
|
||||
version
|
||||
end
|
||||
|
||||
def app_store_screenshot_sets_for_display_type(localization:, display_type:)
|
||||
localization
|
||||
.get_app_screenshot_sets(includes: "appScreenshots")
|
||||
.select { |set| set.screenshot_display_type == display_type }
|
||||
end
|
||||
|
||||
def clear_app_store_screenshot_sets!(localization:)
|
||||
existing_sets = localization.get_app_screenshot_sets(includes: "appScreenshots")
|
||||
return if existing_sets.empty?
|
||||
|
||||
existing_sets.each do |set|
|
||||
UI.message("Deleting existing #{localization.locale} #{set.screenshot_display_type} screenshot set #{set.id}.")
|
||||
set.delete!
|
||||
end
|
||||
|
||||
deadline = Time.now + APP_STORE_SCREENSHOT_SET_DELETE_TIMEOUT_SECONDS
|
||||
loop do
|
||||
sets = localization.get_app_screenshot_sets(includes: "appScreenshots")
|
||||
return if sets.empty?
|
||||
|
||||
if Time.now >= deadline
|
||||
UI.user_error!(
|
||||
"Timed out waiting for App Store Connect to delete #{localization.locale} screenshot sets: #{sets.map(&:id).join(', ')}."
|
||||
)
|
||||
end
|
||||
sleep(3)
|
||||
end
|
||||
end
|
||||
|
||||
def app_store_screenshot_expected_rows(screenshots)
|
||||
screenshots.map do |screenshot|
|
||||
{
|
||||
checksum: Digest::MD5.file(screenshot.path).hexdigest,
|
||||
file_name: File.basename(screenshot.path)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def app_store_screenshot_actual_rows(app_screenshot_set)
|
||||
(app_screenshot_set.app_screenshots || []).map do |screenshot|
|
||||
{
|
||||
checksum: screenshot.source_file_checksum,
|
||||
file_name: screenshot.file_name,
|
||||
state: (screenshot.asset_delivery_state || {})["state"]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def format_app_store_screenshot_rows(rows)
|
||||
rows.map do |row|
|
||||
[row[:file_name], row[:checksum], row[:state]].compact.join(" ")
|
||||
end.join(", ")
|
||||
end
|
||||
|
||||
def app_store_screenshot_processing_timeout_seconds
|
||||
raw = ENV["DELIVER_SCREENSHOT_PROCESSING_TIMEOUT"].to_s.strip
|
||||
return APP_STORE_SCREENSHOT_PROCESSING_TIMEOUT_SECONDS if raw.empty?
|
||||
|
||||
unless raw.match?(/\A\d+\z/) && raw.to_i.positive?
|
||||
UI.user_error!("Invalid DELIVER_SCREENSHOT_PROCESSING_TIMEOUT '#{raw}'. Expected a positive number of seconds.")
|
||||
end
|
||||
raw.to_i
|
||||
end
|
||||
|
||||
def app_store_screenshot_state_counts(screenshots)
|
||||
screenshots.each_with_object({}) do |screenshot, counts|
|
||||
state = (screenshot.asset_delivery_state || {})["state"] || "UNKNOWN"
|
||||
counts[state] ||= 0
|
||||
counts[state] += 1
|
||||
end
|
||||
end
|
||||
|
||||
def wait_for_app_store_screenshots_processing!(screenshot_ids:, locale:, display_type:)
|
||||
timeout_seconds = app_store_screenshot_processing_timeout_seconds
|
||||
deadline = Time.now + timeout_seconds
|
||||
loop do
|
||||
screenshots = screenshot_ids.map do |screenshot_id|
|
||||
Spaceship::ConnectAPI.get_app_screenshot(app_screenshot_id: screenshot_id).first
|
||||
end
|
||||
|
||||
failed = screenshots.select(&:error?)
|
||||
unless failed.empty?
|
||||
details = failed.map { |screenshot| "#{screenshot.file_name}: #{screenshot.error_messages.join(', ')}" }
|
||||
UI.user_error!("App Store Connect failed processing #{locale} #{display_type} screenshots: #{details.join('; ')}.")
|
||||
end
|
||||
return screenshots if screenshots.all?(&:complete?)
|
||||
|
||||
if Time.now >= deadline
|
||||
states = app_store_screenshot_state_counts(screenshots)
|
||||
UI.user_error!(
|
||||
"Timed out after #{timeout_seconds}s waiting for App Store Connect to process #{locale} #{display_type} screenshots: #{states}."
|
||||
)
|
||||
end
|
||||
|
||||
UI.verbose("Waiting for #{locale} #{display_type} screenshots to finish processing: #{app_store_screenshot_state_counts(screenshots)}.")
|
||||
sleep(APP_STORE_SCREENSHOT_PROCESSING_POLL_SECONDS)
|
||||
end
|
||||
end
|
||||
|
||||
def validate_app_store_screenshot_target_counts!(screenshots_by_target)
|
||||
screenshots_by_target.each do |(locale, display_type), screenshots|
|
||||
next if screenshots.length <= APP_STORE_SCREENSHOT_LIMIT_PER_SET
|
||||
|
||||
UI.user_error!(
|
||||
"Found #{screenshots.length} screenshots for #{locale} #{display_type}; App Store Connect allows #{APP_STORE_SCREENSHOT_LIMIT_PER_SET}."
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def verify_app_store_screenshot_set!(app_screenshot_set:, screenshots:, locale:, display_type:)
|
||||
expected = app_store_screenshot_expected_rows(screenshots)
|
||||
timeout_seconds = app_store_screenshot_processing_timeout_seconds
|
||||
deadline = Time.now + timeout_seconds
|
||||
actual = []
|
||||
|
||||
loop do
|
||||
app_screenshot_set = Spaceship::ConnectAPI::AppScreenshotSet.get(app_screenshot_set_id: app_screenshot_set.id)
|
||||
actual = app_store_screenshot_actual_rows(app_screenshot_set)
|
||||
actual_identity = actual.map { |row| { checksum: row[:checksum], file_name: row[:file_name] } }
|
||||
incomplete = actual.reject { |row| row[:state] == "COMPLETE" }
|
||||
|
||||
return if actual_identity == expected && incomplete.empty?
|
||||
|
||||
if actual.length > expected.length
|
||||
UI.user_error!(
|
||||
"App Store Connect screenshot verification failed for #{locale} #{display_type}. " \
|
||||
"Expected: #{format_app_store_screenshot_rows(expected)}. " \
|
||||
"Actual: #{format_app_store_screenshot_rows(actual)}."
|
||||
)
|
||||
end
|
||||
|
||||
if Time.now >= deadline
|
||||
UI.user_error!(
|
||||
"Timed out after #{timeout_seconds}s waiting for App Store Connect screenshot verification for #{locale} #{display_type}. " \
|
||||
"Expected: #{format_app_store_screenshot_rows(expected)}. " \
|
||||
"Actual: #{format_app_store_screenshot_rows(actual)}."
|
||||
)
|
||||
end
|
||||
|
||||
UI.verbose(
|
||||
"Waiting for App Store Connect screenshot verification for #{locale} #{display_type}: " \
|
||||
"#{format_app_store_screenshot_rows(actual)}."
|
||||
)
|
||||
sleep(APP_STORE_SCREENSHOT_PROCESSING_POLL_SECONDS)
|
||||
end
|
||||
end
|
||||
|
||||
def replace_app_store_screenshot_set!(localization:, display_type:, screenshots:)
|
||||
existing_sets = app_store_screenshot_sets_for_display_type(localization: localization, display_type: display_type)
|
||||
unless existing_sets.empty?
|
||||
UI.user_error!(
|
||||
"App Store Connect still has #{localization.locale} #{display_type} screenshot sets after reset: #{existing_sets.map(&:id).join(', ')}."
|
||||
)
|
||||
end
|
||||
|
||||
UI.message("Creating #{localization.locale} #{display_type} screenshot set.")
|
||||
app_screenshot_set = localization.create_app_screenshot_set(attributes: { screenshotDisplayType: display_type })
|
||||
uploaded_ids = screenshots.map.with_index do |screenshot, index|
|
||||
started_at = Time.now
|
||||
uploaded = app_screenshot_set.upload_screenshot(path: screenshot.path, wait_for_processing: false)
|
||||
UI.message(
|
||||
"Uploaded #{localization.locale} #{display_type} screenshot #{index + 1}/#{screenshots.length}: " \
|
||||
"#{File.basename(screenshot.path)} (#{(Time.now - started_at).round(1)}s)."
|
||||
)
|
||||
uploaded.id
|
||||
end
|
||||
wait_for_app_store_screenshots_processing!(
|
||||
screenshot_ids: uploaded_ids,
|
||||
locale: localization.locale,
|
||||
display_type: display_type
|
||||
)
|
||||
|
||||
app_screenshot_set = Spaceship::ConnectAPI::AppScreenshotSet.get(app_screenshot_set_id: app_screenshot_set.id)
|
||||
app_screenshot_set = app_screenshot_set.reorder_screenshots(app_screenshot_ids: uploaded_ids)
|
||||
verify_app_store_screenshot_set!(
|
||||
app_screenshot_set: app_screenshot_set,
|
||||
screenshots: screenshots,
|
||||
locale: localization.locale,
|
||||
display_type: display_type
|
||||
)
|
||||
end
|
||||
|
||||
# Fastlane deliver can duplicate complete screenshots when its verification retry
|
||||
# runs before App Store Connect consistently lists processed assets. Keep the
|
||||
# screenshot write path serial and assert the remote set equals the local files.
|
||||
def upload_app_store_screenshots_deterministically!(app_identifier:, app_id:, short_version:, screenshots:)
|
||||
app = resolve_app_store_connect_app(app_identifier: app_identifier, app_id: app_id)
|
||||
version = resolve_app_store_connect_version(app: app, short_version: short_version)
|
||||
localizations_by_locale = version.get_app_store_version_localizations.each_with_object({}) do |localization, index|
|
||||
index[localization.locale] = localization
|
||||
end
|
||||
|
||||
screenshots_by_target = screenshots
|
||||
.sort_by { |screenshot| [screenshot.language.to_s, screenshot.display_type.to_s, File.basename(screenshot.path)] }
|
||||
.group_by { |screenshot| [screenshot.language, screenshot.display_type] }
|
||||
validate_app_store_screenshot_target_counts!(screenshots_by_target)
|
||||
|
||||
missing_locales = screenshots_by_target.keys.map(&:first).uniq.reject { |locale| localizations_by_locale.key?(locale) }
|
||||
unless missing_locales.empty?
|
||||
UI.user_error!(
|
||||
"App Store Connect localizations are missing for screenshot locales #{missing_locales.join(', ')}. " \
|
||||
"Upload metadata for these locales before uploading screenshots."
|
||||
)
|
||||
end
|
||||
|
||||
screenshots_by_target.keys.map(&:first).uniq.each do |locale|
|
||||
clear_app_store_screenshot_sets!(localization: localizations_by_locale.fetch(locale))
|
||||
end
|
||||
|
||||
screenshots_by_target.each do |(locale, display_type), target_screenshots|
|
||||
replace_app_store_screenshot_set!(
|
||||
localization: localizations_by_locale.fetch(locale),
|
||||
display_type: display_type,
|
||||
screenshots: target_screenshots
|
||||
)
|
||||
end
|
||||
|
||||
UI.success("Uploaded and verified #{screenshots.length} App Store screenshots for #{short_version}.")
|
||||
end
|
||||
|
||||
def read_ios_version_metadata
|
||||
script_path = File.join(repo_root, "scripts", "ios-version.ts")
|
||||
stdout, stderr, status = Open3.capture3(
|
||||
@@ -1303,7 +1014,7 @@ platform :ios do
|
||||
ENV.delete("XCODE_XCCONFIG_FILE")
|
||||
end
|
||||
|
||||
desc "Generate screenshots, update App Store metadata and review attachment, then upload an App Store build"
|
||||
desc "Generate screenshots, update App Store version metadata, then upload an App Store build"
|
||||
lane :release_upload do
|
||||
unless ENV["OPENCLAW_IOS_RELEASE_WRAPPER"] == "1"
|
||||
UI.user_error!("Use `pnpm ios:release:upload`; direct Fastlane TestFlight upload is disabled.")
|
||||
@@ -1333,7 +1044,7 @@ platform :ios do
|
||||
ENV.delete("XCODE_XCCONFIG_FILE")
|
||||
end
|
||||
|
||||
desc "Upload App Store metadata, App Review PDF attachment, and optionally screenshots"
|
||||
desc "Upload App Store metadata (and optionally screenshots)"
|
||||
lane :metadata do
|
||||
install_ready_for_review_edit_state_lookup!
|
||||
sync_ios_versioning!
|
||||
@@ -1346,22 +1057,19 @@ platform :ios do
|
||||
app_id = nil unless env_present?(app_id)
|
||||
|
||||
if screenshot_upload_requested?
|
||||
screenshots_to_upload = app_store_screenshot_manifest
|
||||
if screenshots_to_upload.empty?
|
||||
paths = screenshot_paths
|
||||
if paths.empty?
|
||||
UI.user_error!("DELIVER_SCREENSHOTS=1 but no PNG screenshots were found under apps/ios/fastlane/screenshots.")
|
||||
end
|
||||
validate_required_screenshots!(screenshots_to_upload.map(&:path))
|
||||
validate_required_screenshots!(paths)
|
||||
end
|
||||
|
||||
assert_no_app_review_notes_field_metadata!(File.join(__dir__, "metadata"))
|
||||
metadata_path = public_metadata_path
|
||||
skip_metadata = ENV["DELIVER_METADATA"] != "1"
|
||||
if release_notes_upload_requested? && skip_metadata
|
||||
metadata_path = release_notes_metadata_path
|
||||
skip_metadata = false
|
||||
end
|
||||
assert_no_app_review_notes_field_metadata!(metadata_path) unless skip_metadata
|
||||
app_review_attachment_file = skip_metadata ? nil : generate_app_review_notes_pdf!
|
||||
|
||||
deliver_options = {
|
||||
api_key: api_key,
|
||||
@@ -1371,11 +1079,10 @@ platform :ios do
|
||||
primary_category: "PRODUCTIVITY",
|
||||
secondary_category: "UTILITIES",
|
||||
metadata_path: metadata_path,
|
||||
skip_screenshots: true,
|
||||
skip_screenshots: !screenshot_upload_requested?,
|
||||
skip_metadata: skip_metadata,
|
||||
skip_binary_upload: true,
|
||||
overwrite_screenshots: false,
|
||||
app_review_attachment_file: app_review_attachment_file,
|
||||
overwrite_screenshots: screenshot_upload_requested?,
|
||||
skip_app_version_update: false,
|
||||
submit_for_review: false,
|
||||
run_precheck_before_submit: false
|
||||
@@ -1388,14 +1095,6 @@ platform :ios do
|
||||
end
|
||||
|
||||
deliver(**deliver_options)
|
||||
if screenshot_upload_requested?
|
||||
upload_app_store_screenshots_deterministically!(
|
||||
app_identifier: app_identifier,
|
||||
app_id: app_id,
|
||||
short_version: version_metadata[:short_version],
|
||||
screenshots: screenshots_to_upload
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
desc "Generate deterministic iOS screenshots for App Store metadata"
|
||||
|
||||
@@ -169,5 +169,5 @@ Versioning rules:
|
||||
- Local App Store signing uses a temporary generated xcconfig with profile names from `apps/ios/Config/AppStoreSigning.json` and leaves local development signing overrides untouched
|
||||
- App Store release uses `OpenClawPushMode=appStore`, which derives the canonical production hosted relay, production APNs, production relay profile, and `appleStrict` proof. The release lane rejects custom production relay URL overrides.
|
||||
- The exported IPA is validated before upload by inspecting its push mode, signed entitlements, and embedded App Store profile.
|
||||
- `pnpm ios:release:upload` generates and uploads screenshots, release notes, and the App Review PDF attachment before archiving, then uploads the IPA without submitting it for App Review or uploading the App Store Connect `Notes` field
|
||||
- `pnpm ios:release:upload` generates and uploads screenshots and release notes before archiving, then uploads the IPA without submitting it for App Review
|
||||
- See `apps/ios/VERSIONING.md` for the detailed workflow
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
This directory is used by `fastlane deliver` for App Store Connect text metadata.
|
||||
|
||||
## Upload public metadata and App Review attachment
|
||||
## Upload metadata only
|
||||
|
||||
```bash
|
||||
cd apps/ios
|
||||
@@ -10,9 +10,9 @@ APP_STORE_CONNECT_APP_ID=YOUR_APP_STORE_CONNECT_APP_ID \
|
||||
DELIVER_METADATA=1 fastlane ios metadata
|
||||
```
|
||||
|
||||
## Release notes and App Review attachment
|
||||
## Release notes only
|
||||
|
||||
`pnpm ios:release:upload` uses this mode before archiving so the editable App Store version has current release notes and the App Review PDF attachment without rewriting all metadata:
|
||||
`pnpm ios:release:upload` uses this mode before archiving so the editable App Store version has current release notes without rewriting all metadata:
|
||||
|
||||
```bash
|
||||
cd apps/ios
|
||||
@@ -46,12 +46,11 @@ Or set `APP_STORE_CONNECT_API_KEY_PATH`.
|
||||
|
||||
- Locale files live under `metadata/en-US/`.
|
||||
- `release_notes.txt` is generated from `apps/ios/CHANGELOG.md`; after changelog updates, run `pnpm ios:version:sync`.
|
||||
- `apps/ios/APP-REVIEW-NOTES.md` is rendered to `apps/ios/build/app-review/APP-REVIEW-NOTES.pdf` and uploaded as the App Review attachment when metadata is uploaded.
|
||||
- Release notes resolve from `## <pinned iOS version>` first, then fall back to `## Unreleased` while a TestFlight train is still in progress.
|
||||
- When starting a new production release train, pin the iOS version first with `pnpm ios:version:pin -- --from-gateway`.
|
||||
- The release upload flow uploads release notes, screenshots, and the App Review PDF attachment before the IPA, and never submits for App Review.
|
||||
- The release upload flow uploads release notes and screenshots before the IPA, and never submits for App Review.
|
||||
- `privacy_url.txt` is set to `https://openclaw.ai/privacy`.
|
||||
- If app lookup fails in `deliver`, set one of:
|
||||
- `APP_STORE_CONNECT_APP_IDENTIFIER` (bundle ID)
|
||||
- `APP_STORE_CONNECT_APP_ID` (numeric App Store Connect app ID, e.g. from `/apps/<id>/...` URL)
|
||||
- App Review submission is manual. Keep review contact, demo account, and the App Store Connect `Notes` field outside this repo and enter them directly in App Store Connect when submitting for review. Do not add `metadata/review_information/notes.txt`; the lane refuses to upload that field.
|
||||
- App Review submission is manual. Keep review contact, demo account, and reviewer notes outside this repo and enter them directly in App Store Connect when submitting for review.
|
||||
|
||||
@@ -5,7 +5,7 @@ Pair this iOS app with your OpenClaw Gateway to use your iPhone as a secure node
|
||||
What you can do:
|
||||
- Pair with your private OpenClaw Gateway by QR code or setup code
|
||||
- Chat with your assistant from iPhone
|
||||
- Use realtime Talk mode and background Talk
|
||||
- Use realtime Talk mode and push-to-talk
|
||||
- Review Gateway action approvals from your iPhone
|
||||
- Share text, links, and media directly from iOS into OpenClaw
|
||||
- Enable device capabilities such as camera, screen, location, photos, contacts, calendar, and reminders when you choose
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
Maintenance update for the current OpenClaw beta release.
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
- Improved notification cleanup, Watch app compatibility, and native file input handling.
|
||||
- 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.10"
|
||||
"version": "2026.6.9"
|
||||
}
|
||||
|
||||
@@ -108,6 +108,24 @@
|
||||
"revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df",
|
||||
"version" : "1.6.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftui-math",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/gonzalezreal/swiftui-math",
|
||||
"state" : {
|
||||
"revision" : "0b5c2cfaaec8d6193db206f675048eeb5ce95f71",
|
||||
"version" : "0.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "textual",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/gonzalezreal/textual",
|
||||
"state" : {
|
||||
"revision" : "5b06b811c0f5313b6b84bbef98c635a630638c38",
|
||||
"version" : "0.3.1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
|
||||
@@ -20,7 +20,6 @@ let package = Package(
|
||||
.package(url: "https://github.com/apple/swift-log.git", from: "1.10.1"),
|
||||
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.0"),
|
||||
.package(url: "https://github.com/steipete/Peekaboo.git", exact: "3.5.2"),
|
||||
.package(url: "https://github.com/pointfreeco/swift-concurrency-extras", from: "1.3.1"),
|
||||
.package(path: "../shared/OpenClawKit"),
|
||||
.package(path: "../swabble"),
|
||||
],
|
||||
@@ -55,7 +54,6 @@ let package = Package(
|
||||
.product(name: "Sparkle", package: "Sparkle"),
|
||||
.product(name: "PeekabooBridge", package: "Peekaboo"),
|
||||
.product(name: "PeekabooAutomationKit", package: "Peekaboo"),
|
||||
.product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"),
|
||||
],
|
||||
exclude: [
|
||||
"Resources/Info.plist",
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.6.10</string>
|
||||
<string>2026.6.9</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>2026061000</string>
|
||||
<string>2026060900</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>OpenClaw</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -19,6 +19,7 @@ let package = Package(
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/steipete/ElevenLabsKit", exact: "0.1.1"),
|
||||
.package(url: "https://github.com/gonzalezreal/textual", exact: "0.3.1"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
@@ -44,6 +45,10 @@ let package = Package(
|
||||
name: "OpenClawChatUI",
|
||||
dependencies: [
|
||||
"OpenClawKit",
|
||||
.product(
|
||||
name: "Textual",
|
||||
package: "textual",
|
||||
condition: .when(platforms: [.macOS, .iOS])),
|
||||
],
|
||||
path: "Sources/OpenClawChatUI",
|
||||
swiftSettings: [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Textual
|
||||
|
||||
public enum ChatMarkdownVariant: String, CaseIterable, Sendable {
|
||||
case standard
|
||||
@@ -22,28 +22,46 @@ struct ChatMarkdownRenderer: View {
|
||||
var body: some View {
|
||||
let processed = ChatMarkdownPreprocessor.preprocess(markdown: self.text)
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(self.markdownText(processed.cleaned))
|
||||
.font(self.font)
|
||||
.foregroundStyle(self.textColor)
|
||||
.tint(self.linkColor)
|
||||
.textSelection(.enabled)
|
||||
.lineSpacing(self.variant == .compact ? 2 : 4)
|
||||
StructuredText(markdown: processed.cleaned)
|
||||
.modifier(ChatMarkdownStyle(
|
||||
variant: self.variant,
|
||||
context: self.context,
|
||||
font: self.font,
|
||||
textColor: self.textColor))
|
||||
|
||||
if !processed.images.isEmpty {
|
||||
InlineImageList(images: processed.images)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var linkColor: Color {
|
||||
self.context == .user ? self.textColor : OpenClawChatTheme.accent
|
||||
private struct ChatMarkdownStyle: ViewModifier {
|
||||
let variant: ChatMarkdownVariant
|
||||
let context: ChatMarkdownRenderer.Context
|
||||
let font: Font
|
||||
let textColor: Color
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
Group {
|
||||
if self.variant == .compact {
|
||||
content.textual.structuredTextStyle(.default)
|
||||
} else {
|
||||
content.textual.structuredTextStyle(.gitHub)
|
||||
}
|
||||
}
|
||||
.font(self.font)
|
||||
.foregroundStyle(self.textColor)
|
||||
.textual.inlineStyle(self.inlineStyle)
|
||||
.textual.textSelection(.enabled)
|
||||
}
|
||||
|
||||
private func markdownText(_ markdown: String) -> AttributedString {
|
||||
let options = AttributedString.MarkdownParsingOptions(
|
||||
interpretedSyntax: .full,
|
||||
failurePolicy: .returnPartiallyParsedIfPossible)
|
||||
return (try? AttributedString(markdown: markdown, options: options)) ?? AttributedString(markdown)
|
||||
private var inlineStyle: InlineStyle {
|
||||
let linkColor: Color = self.context == .user ? self.textColor : OpenClawChatTheme.accent
|
||||
let codeScale: CGFloat = self.variant == .compact ? 0.85 : 0.9
|
||||
return InlineStyle()
|
||||
.code(.monospaced, .fontScale(codeScale))
|
||||
.link(.foregroundColor(linkColor))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
9246475f5771612a5fd12de38b153783c4a4cbb8b2682a5c40115916661c90f2 config-baseline.json
|
||||
ee542300a1f9d5c23e772d47f2acfcc92ee0a4da210974306790bf2220b80277 config-baseline.json
|
||||
6349131baaa1828f2a071f42e4d7b17c8966c59b6588c8a4c1a32ea5ea4dcd5e config-baseline.core.json
|
||||
671979e86e4c4f59415d0a20879e838f9bbd883b3d29eeb02cb5131db8d187fe config-baseline.channel.json
|
||||
94529978588d6e3776a86780b22cf9ff46a6f9957f2f178d3829403fad451ca7 config-baseline.plugin.json
|
||||
de674ef01dad2828bb711a4648dc5a00f696f71c3c59004131d9475769bc1ff8 config-baseline.channel.json
|
||||
ce2a731077f0f0135b7eaf01b00a60abfa0d2776aba4be237491d492af0c8a02 config-baseline.plugin.json
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
212b76ef72779add8f18be4848e143e61b6ae42a1c7daeefdc42d91e0a1152e9 plugin-sdk-api-baseline.json
|
||||
976179e09e9e46a9b9259bd20ca1cafc8883c8e281a099a9aaa5fceab3c2983b plugin-sdk-api-baseline.jsonl
|
||||
f7247b5bbfe3f96bffffd25a8be2f89b37999e36731f34a159ae21ded1cedd05 plugin-sdk-api-baseline.json
|
||||
ce88a53dadc194ceccc63f50146aee03a1a425f551117da826a21519d5bf80db plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -24,14 +24,6 @@ This directory owns docs authoring, Mintlify link rules, and docs i18n policy.
|
||||
- `scripts/docs-sync-publish.mjs` excludes and prunes `docs/internal/**` from the public `openclaw/docs` publish repo if a page is force-added later.
|
||||
- Internal docs may mention repo paths, private app names, 1Password item names, and runbooks, but never include secret values.
|
||||
|
||||
## Maturity Scorecard Editing
|
||||
|
||||
`taxonomy.yaml` and `qa/maturity-scores.yaml` are the source inputs; generated maturity docs under `docs/maturity/` are projections and should not be hand-edited for score, LTS, taxonomy, QA profile, or evidence tables.
|
||||
`scripts/qa/render-maturity-docs.ts` owns generation; use `pnpm maturity:render` to refresh committed docs and `pnpm maturity:check` to verify them.
|
||||
`.github/workflows/maturity-scorecard.yml` renders artifact previews and can open generated-doc PRs; `.github/workflows/openclaw-release-checks.yml` dispatches it for release QA.
|
||||
Keep deterministic `qa-evidence.json.scorecard` data in GitHub Actions artifacts unless a maintainer explicitly asks for a sanitized committed projection.
|
||||
Human overrides must change source state in a PR and explain the reason plus public or redacted evidence.
|
||||
|
||||
## Docs i18n
|
||||
|
||||
- Foreign-language docs are not maintained in this repo. The generated publish output lives in the separate `openclaw/docs` repo (often cloned locally as `../openclaw-docs`).
|
||||
|
||||
35
docs/ci.md
35
docs/ci.md
@@ -133,30 +133,15 @@ gh workflow run full-release-validation.yml --ref main -f ref=<branch-or-sha>
|
||||
|
||||
## Runners
|
||||
|
||||
| Runner | Jobs |
|
||||
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `ubuntu-24.04` | Manual CI dispatch and non-canonical repository fallbacks, CodeQL JavaScript/actions quality scans, workflow-sanity, labeler, auto-response, docs workflows outside CI, and install-smoke preflight so the Blacksmith matrix can queue earlier |
|
||||
| `blacksmith-4vcpu-ubuntu-2404` | `preflight`, `security-fast`, lower-weight extension shards, `checks-fast-core`, plugin/channel contract shards, most bundled/lower-weight Linux Node shards, `check-guards`, `check-prod-types`, `check-test-types`, selected `check-additional-*` shards, and `check-dependencies` |
|
||||
| `blacksmith-8vcpu-ubuntu-2404` | Retained heavy Linux Node suites, boundary/extension-heavy `check-additional-*` shards, and `android` |
|
||||
| `blacksmith-16vcpu-ubuntu-2404` | `build-artifacts`, `check-lint` (CPU-sensitive enough that 8 vCPU cost more than they saved); install-smoke Docker builds (32-vCPU queue time cost more than it saved) |
|
||||
| `blacksmith-8vcpu-windows-2025` | `checks-windows` |
|
||||
| `blacksmith-6vcpu-macos-15` | `macos-node` on `openclaw/openclaw`; forks fall back to `macos-15` |
|
||||
| `blacksmith-12vcpu-macos-26` | `macos-swift` and `ios-build` on `openclaw/openclaw`; forks fall back to `macos-26` |
|
||||
|
||||
## Runner registration budget
|
||||
|
||||
GitHub caps self-hosted runner registrations at 1,500 runners per 5 minutes per
|
||||
repository, organization, or enterprise. The limit is shared by all Blacksmith
|
||||
runner registrations in the `openclaw` organization, so adding another
|
||||
Blacksmith installation does not add a new bucket.
|
||||
|
||||
Treat Blacksmith labels as the scarce resource for burst control. Jobs that
|
||||
only route, notify, summarize, select shards, or run short CodeQL scans should
|
||||
stay on GitHub-hosted runners unless they have measured Blacksmith-specific
|
||||
needs. Any new Blacksmith matrix, larger `max-parallel`, or high-frequency
|
||||
workflow must show its worst-case registration count and keep the org-level
|
||||
target below 1,000 registrations per 5 minutes, leaving headroom for concurrent
|
||||
repositories and retried jobs.
|
||||
| Runner | Jobs |
|
||||
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ubuntu-24.04` | Manual CI dispatch and non-canonical repository fallbacks, workflow-sanity, labeler, auto-response, docs workflows outside CI, and install-smoke preflight so the Blacksmith matrix can queue earlier |
|
||||
| `blacksmith-4vcpu-ubuntu-2404` | `CodeQL Critical Quality`, `preflight`, `security-fast`, lower-weight extension shards, `checks-fast-core`, plugin/channel contract shards, most bundled/lower-weight Linux Node shards, `check-guards`, `check-prod-types`, `check-test-types`, selected `check-additional-*` shards, and `check-dependencies` |
|
||||
| `blacksmith-8vcpu-ubuntu-2404` | Retained heavy Linux Node suites, boundary/extension-heavy `check-additional-*` shards, and `android` |
|
||||
| `blacksmith-16vcpu-ubuntu-2404` | `build-artifacts`, `check-lint` (CPU-sensitive enough that 8 vCPU cost more than they saved); install-smoke Docker builds (32-vCPU queue time cost more than it saved) |
|
||||
| `blacksmith-8vcpu-windows-2025` | `checks-windows` |
|
||||
| `blacksmith-6vcpu-macos-15` | `macos-node` on `openclaw/openclaw`; forks fall back to `macos-15` |
|
||||
| `blacksmith-12vcpu-macos-26` | `macos-swift` and `ios-build` on `openclaw/openclaw`; forks fall back to `macos-26` |
|
||||
|
||||
Canonical-repo CI keeps Blacksmith as the default runner path for normal push and pull-request runs. `workflow_dispatch` and non-canonical repository runs use GitHub-hosted runners, but normal canonical runs do not currently probe Blacksmith queue health or automatically fall back to GitHub-hosted labels when Blacksmith is unavailable.
|
||||
|
||||
@@ -503,7 +488,7 @@ The pull request guard stays light: it only starts for changes under `.github/ac
|
||||
|
||||
### Critical Quality categories
|
||||
|
||||
`CodeQL Critical Quality` is the matching non-security shard. It runs only error-severity, non-security JavaScript/TypeScript quality queries over narrow high-value surfaces on GitHub-hosted Linux runners so quality scans do not spend Blacksmith runner-registration budget. Its pull request guard is intentionally smaller than the scheduled profile: non-draft PRs only run the matching `agent-runtime-boundary`, `config-boundary`, `core-auth-secrets`, `channel-runtime-boundary`, `gateway-runtime-boundary`, `memory-runtime-boundary`, `mcp-process-runtime-boundary`, `provider-runtime-boundary`, `session-diagnostics-boundary`, `plugin-boundary`, `plugin-sdk-package-contract`, and `plugin-sdk-reply-runtime` shards for agent command/model/tool execution and reply dispatch code, config schema/migration/IO code, auth/secrets/sandbox/security code, core channel and bundled channel plugin runtime, gateway protocol/server-method, memory runtime/SDK glue, MCP/process/outbound delivery, provider runtime/model catalog, session diagnostics/delivery queues, plugin loader, Plugin SDK/package-contract, or Plugin SDK reply runtime changes. CodeQL config and quality workflow changes run all twelve PR quality shards.
|
||||
`CodeQL Critical Quality` is the matching non-security shard. It runs only error-severity, non-security JavaScript/TypeScript quality queries over narrow high-value surfaces on the smaller Blacksmith Linux runner. Its pull request guard is intentionally smaller than the scheduled profile: non-draft PRs only run the matching `agent-runtime-boundary`, `config-boundary`, `core-auth-secrets`, `channel-runtime-boundary`, `gateway-runtime-boundary`, `memory-runtime-boundary`, `mcp-process-runtime-boundary`, `provider-runtime-boundary`, `session-diagnostics-boundary`, `plugin-boundary`, `plugin-sdk-package-contract`, and `plugin-sdk-reply-runtime` shards for agent command/model/tool execution and reply dispatch code, config schema/migration/IO code, auth/secrets/sandbox/security code, core channel and bundled channel plugin runtime, gateway protocol/server-method, memory runtime/SDK glue, MCP/process/outbound delivery, provider runtime/model catalog, session diagnostics/delivery queues, plugin loader, Plugin SDK/package-contract, or Plugin SDK reply runtime changes. CodeQL config and quality workflow changes run all twelve PR quality shards.
|
||||
|
||||
Manual dispatch accepts:
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ openclaw gateway restart
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
openclaw workboard list [--board <id>] [--status <status>] [--include-archived] [--json]
|
||||
openclaw workboard list [--board <id>] [--status <status>] [--json]
|
||||
openclaw workboard create <title...> [--notes <text>] [--status <status>] [--priority <priority>] [--agent <id>] [--board <id>] [--labels <items>] [--json]
|
||||
openclaw workboard show <id> [--json]
|
||||
openclaw workboard dispatch [--url <url>] [--token <token>] [--timeout <ms>] [--json]
|
||||
@@ -50,16 +50,11 @@ Columns are id prefix, status, priority, board id, optional agent id, and title.
|
||||
|
||||
Flags:
|
||||
|
||||
| Flag | Purpose |
|
||||
| -------------------- | --------------------------------------------- |
|
||||
| `--board <id>` | Limit results to one board namespace |
|
||||
| `--status <status>` | Limit results to one Workboard status |
|
||||
| `--include-archived` | Include archived cards in compact text output |
|
||||
| `--json` | Print the full card list as machine JSON |
|
||||
|
||||
Compact text output hides archived cards by default so the CLI matches the
|
||||
`/workboard list` command. Pass `--include-archived` to show them. JSON output
|
||||
keeps the full card list, including archived cards, for existing automation.
|
||||
| Flag | Purpose |
|
||||
| ------------------- | ---------------------------------------- |
|
||||
| `--board <id>` | Limit results to one board namespace |
|
||||
| `--status <status>` | Limit results to one Workboard status |
|
||||
| `--json` | Print the full card list as machine JSON |
|
||||
|
||||
## `create`
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ Slim evidence omits per-entry `execution` and sets `evidenceMode: "slim"`;
|
||||
```bash
|
||||
pnpm openclaw qa run \
|
||||
--qa-profile smoke-ci \
|
||||
--category channel-framework.conversation-routing-and-delivery \
|
||||
--category agent-runtime-and-provider-execution.agent-turn-execution \
|
||||
--provider-mode mock-openai \
|
||||
--output-dir .artifacts/qa-e2e/smoke-ci-profile-dispatch
|
||||
```
|
||||
@@ -178,21 +178,10 @@ QA Lab, so package Docker release lanes do not run `qa` commands. Use
|
||||
`pnpm qa:observability:smoke` from a built source checkout when changing
|
||||
diagnostics instrumentation.
|
||||
|
||||
For a transport-real Matrix smoke lane that does not require model-provider
|
||||
credentials, run the fast profile with the deterministic mock OpenAI provider:
|
||||
For a transport-real Matrix smoke lane, run:
|
||||
|
||||
```bash
|
||||
OPENCLAW_QA_MATRIX_NO_REPLY_WINDOW_MS=3000 \
|
||||
pnpm openclaw qa matrix --provider-mode mock-openai --profile fast --fail-fast
|
||||
```
|
||||
|
||||
For the live-frontier provider lane, supply OpenAI-compatible credentials
|
||||
explicitly:
|
||||
|
||||
```bash
|
||||
OPENCLAW_LIVE_OPENAI_KEY="${OPENAI_API_KEY}" \
|
||||
OPENCLAW_QA_MATRIX_NO_REPLY_WINDOW_MS=3000 \
|
||||
pnpm openclaw qa matrix --provider-mode live-frontier --profile fast --fail-fast
|
||||
pnpm openclaw qa matrix --profile fast --fail-fast
|
||||
```
|
||||
|
||||
The full CLI reference, profile/scenario catalog, env vars, and artifact layout for this lane live in [Matrix QA](/concepts/qa-matrix). At a glance: it provisions a disposable Tuwunel homeserver in Docker, registers temporary driver/SUT/observer users, runs the real Matrix plugin inside a child QA gateway scoped to that transport (no `qa-channel`), then writes a Markdown report, JSON summary, observed-events artifact, and combined output log under `.artifacts/qa-e2e/matrix-<timestamp>/`.
|
||||
@@ -212,10 +201,9 @@ environment. That viewer profile is only for visual capture; the pass/fail
|
||||
decision still comes from the Discord REST oracle.
|
||||
|
||||
CI uses the same command surface in `.github/workflows/qa-live-transports-convex.yml`.
|
||||
Scheduled and default manual runs execute the fast Matrix profile with
|
||||
QA-provided live-frontier credentials, `--fast`, and
|
||||
`OPENCLAW_QA_MATRIX_NO_REPLY_WINDOW_MS=3000`. Manual `matrix_profile=all` fans
|
||||
out into the five profile shards.
|
||||
Scheduled and default manual runs execute the fast Matrix profile with live
|
||||
frontier credentials, `--fast`, and `OPENCLAW_QA_MATRIX_NO_REPLY_WINDOW_MS=3000`.
|
||||
Manual `matrix_profile=all` fans out into the five profile shards.
|
||||
|
||||
For transport-real Telegram, Discord, Slack, and WhatsApp smoke lanes:
|
||||
|
||||
@@ -978,7 +966,6 @@ output and whose artifact paths are resolved relative to that producer
|
||||
`qa run --qa-profile`, the same `qa-evidence.json` also includes the profile
|
||||
scorecard summary for the selected taxonomy categories.
|
||||
Treat it as a discovery aid, not a gate replacement; the selected scenario still needs the right provider mode, live transport, Multipass, Testbox, or release lane for the behavior under test.
|
||||
For scorecard context, see [Maturity scorecard](/maturity/scorecard).
|
||||
|
||||
For character and style checks, run the same scenario across multiple live model
|
||||
refs and write a judged Markdown report:
|
||||
@@ -1036,7 +1023,6 @@ When no `--judge-model` is passed, the judges default to
|
||||
## Related docs
|
||||
|
||||
- [Matrix QA](/concepts/qa-matrix)
|
||||
- [Maturity scorecard](/maturity/scorecard)
|
||||
- [Personal agent benchmark pack](/concepts/personal-agent-benchmark-pack)
|
||||
- [QA Channel](/channels/qa-channel)
|
||||
- [Testing](/help/testing)
|
||||
|
||||
@@ -199,10 +199,6 @@ claude auth status --text
|
||||
openclaw models auth login --provider anthropic --method cli --set-default
|
||||
```
|
||||
|
||||
Docker installs need Claude Code installed and logged in inside the persisted
|
||||
container home, not only on the host. See
|
||||
[Claude CLI backend in Docker](/install/docker#claude-cli-backend-in-docker).
|
||||
|
||||
Use `agents.defaults.cliBackends.claude-cli.command` only when the `claude`
|
||||
binary is not already on `PATH`.
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ of Docker runners. This doc is a "how we test" guide:
|
||||
|
||||
- [QA overview](/concepts/qa-e2e-automation) - architecture, command surface, scenario authoring.
|
||||
- [Matrix QA](/concepts/qa-matrix) - reference for `pnpm openclaw qa matrix`.
|
||||
- [Maturity scorecard](/maturity/scorecard) - how release QA evidence supports stability and LTS decisions.
|
||||
- [QA channel](/channels/qa-channel) - the synthetic transport plugin used by repo-backed scenarios.
|
||||
|
||||
This page covers running the regular test suites and Docker/Parallels runners. The QA-specific runners section below ([QA-specific runners](#qa-specific-runners)) lists the concrete `qa` invocations and points back at the references above.
|
||||
@@ -741,20 +740,17 @@ Native dependency policy:
|
||||
- Command: `pnpm test:e2e:openshell`
|
||||
- File: `extensions/openshell/src/backend.e2e.test.ts`
|
||||
- Scope:
|
||||
- Reuses an active local OpenShell gateway
|
||||
- Starts an isolated OpenShell gateway on the host via Docker
|
||||
- Creates a sandbox from a temporary local Dockerfile
|
||||
- Exercises OpenClaw's OpenShell backend over real `sandbox ssh-config` + SSH exec
|
||||
- Verifies remote-canonical filesystem behavior through the sandbox fs bridge
|
||||
- Expectations:
|
||||
- Opt-in only; not part of the default `pnpm test:e2e` run
|
||||
- Requires a local `openshell` CLI plus a working Docker daemon
|
||||
- Requires an active local OpenShell gateway and its config source
|
||||
- Uses isolated `HOME` / `XDG_CONFIG_HOME`, then destroys the test sandbox
|
||||
- Uses isolated `HOME` / `XDG_CONFIG_HOME`, then destroys the test gateway and sandbox
|
||||
- Useful overrides:
|
||||
- `OPENCLAW_E2E_OPENSHELL=1` to enable the test when running the broader e2e suite manually
|
||||
- `OPENCLAW_E2E_OPENSHELL_COMMAND=/path/to/openshell` to point at a non-default CLI binary or wrapper script
|
||||
- `OPENCLAW_E2E_OPENSHELL_CONFIG_HOME=/path/to/config` to expose the registered gateway config to the isolated test
|
||||
- `OPENCLAW_E2E_OPENSHELL_HOST_IP=172.18.0.1` to override the Docker gateway IP used by the host policy fixture
|
||||
|
||||
### Live (real providers + real models)
|
||||
|
||||
|
||||
@@ -279,100 +279,6 @@ If you use your own Compose file or `docker run` command, add the same host
|
||||
mapping yourself, for example
|
||||
`--add-host=host.docker.internal:host-gateway`.
|
||||
|
||||
### Claude CLI backend in Docker
|
||||
|
||||
The official OpenClaw Docker image does not pre-install Claude Code. Install and
|
||||
log in to Claude Code inside the container user that runs OpenClaw, then persist
|
||||
that container home so image upgrades do not erase the binary or Claude auth
|
||||
state.
|
||||
|
||||
For new Docker installs, enable a persistent `/home/node` volume before running
|
||||
setup:
|
||||
|
||||
```bash
|
||||
export OPENCLAW_IMAGE="ghcr.io/openclaw/openclaw:latest"
|
||||
export OPENCLAW_HOME_VOLUME="openclaw_home"
|
||||
./scripts/docker/setup.sh
|
||||
```
|
||||
|
||||
For an existing Docker install, stop the stack first and reload the current
|
||||
Docker `.env` values before rerunning setup. The setup script does not read
|
||||
`.env` on its own; it rewrites `.env` from the current shell and defaults. For
|
||||
the generated `.env`, run:
|
||||
|
||||
```bash
|
||||
set -a
|
||||
. ./.env
|
||||
set +a
|
||||
export OPENCLAW_HOME_VOLUME="${OPENCLAW_HOME_VOLUME:-openclaw_home}"
|
||||
./scripts/docker/setup.sh
|
||||
```
|
||||
|
||||
If your `.env` contains values your shell cannot source, manually re-export the
|
||||
existing values you rely on first, such as `OPENCLAW_IMAGE`, ports, bind mode,
|
||||
custom paths, `OPENCLAW_EXTRA_MOUNTS`, sandbox, and skip-onboarding settings.
|
||||
The generated overlay mounts the home volume for both `openclaw-gateway` and
|
||||
`openclaw-cli`.
|
||||
|
||||
Run the remaining commands with the generated Compose overlay so both services
|
||||
mount the persisted home. If your setup also uses `docker-compose.override.yml`,
|
||||
include it before `docker-compose.extra.yml`.
|
||||
|
||||
Install Claude Code in that persisted home:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.extra.yml run --rm \
|
||||
--entrypoint sh openclaw-cli -lc \
|
||||
'curl -fsSL https://claude.ai/install.sh | bash'
|
||||
```
|
||||
|
||||
The native installer writes the `claude` binary under
|
||||
`/home/node/.local/bin/claude`. Tell OpenClaw to use that container path:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.extra.yml run --rm \
|
||||
openclaw-cli config set \
|
||||
agents.defaults.cliBackends.claude-cli.command \
|
||||
/home/node/.local/bin/claude
|
||||
```
|
||||
|
||||
Log in and verify from inside the same persisted container home:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.extra.yml run --rm \
|
||||
--entrypoint /home/node/.local/bin/claude openclaw-cli auth login
|
||||
docker compose -f docker-compose.yml -f docker-compose.extra.yml run --rm \
|
||||
--entrypoint /home/node/.local/bin/claude openclaw-cli auth status --text
|
||||
docker compose -f docker-compose.yml -f docker-compose.extra.yml run --rm \
|
||||
openclaw-cli models auth login \
|
||||
--provider anthropic --method cli --set-default
|
||||
docker compose -f docker-compose.yml -f docker-compose.extra.yml run --rm \
|
||||
openclaw-cli models list --provider anthropic
|
||||
```
|
||||
|
||||
After that, you can use the bundled `claude-cli` backend:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.extra.yml run --rm \
|
||||
openclaw-cli agent \
|
||||
--agent main \
|
||||
--model claude-cli/claude-sonnet-4-6 \
|
||||
--message "Say hello from Docker Claude CLI"
|
||||
```
|
||||
|
||||
`OPENCLAW_HOME_VOLUME` persists the native Claude Code install under
|
||||
`/home/node/.local/bin` and `/home/node/.local/share/claude`, plus Claude Code
|
||||
settings and auth state under `/home/node/.claude` and `/home/node/.claude.json`.
|
||||
Persisting only `/home/node/.openclaw` is not enough for Claude CLI reuse. If
|
||||
you use `OPENCLAW_EXTRA_MOUNTS` instead of a home volume, mount all of those
|
||||
Claude paths into both Docker services.
|
||||
|
||||
<Note>
|
||||
For shared production automation or predictable Anthropic billing, prefer the
|
||||
Anthropic API-key path. Claude CLI reuse follows Claude Code's installed
|
||||
version, account login, billing, and update behavior.
|
||||
</Note>
|
||||
|
||||
### Bonjour / mDNS
|
||||
|
||||
Docker bridge networking usually does not forward Bonjour/mDNS multicast
|
||||
|
||||
@@ -103,65 +103,8 @@ The harness advertises support for the canonical `github-copilot` provider
|
||||
|
||||
- `github-copilot`
|
||||
|
||||
It also supports custom `models.providers` entries when the selected model has
|
||||
a non-empty `baseUrl` and one of these API shapes:
|
||||
|
||||
- `openai-responses`
|
||||
- `openai-completions`
|
||||
- `ollama` (OpenAI-compatible completions)
|
||||
- `azure-openai-responses`
|
||||
- `anthropic-messages`
|
||||
|
||||
Native provider ids such as `openai`, `anthropic`, `google`, and `ollama` remain
|
||||
owned by their native runtimes. Use a distinct custom provider id when routing
|
||||
an endpoint through Copilot BYOK.
|
||||
|
||||
Copilot BYOK endpoints must be public-network HTTPS URLs. The harness gives the
|
||||
Copilot SDK a per-attempt loopback proxy URL, then forwards provider traffic
|
||||
through OpenClaw's guarded fetch path so DNS pinning and SSRF policy stay
|
||||
owned by OpenClaw. Use the native OpenClaw runtime for local Ollama, LM Studio,
|
||||
or LAN model servers.
|
||||
|
||||
## BYOK
|
||||
|
||||
Copilot BYOK uses the SDK's session-level custom provider contract. OpenClaw
|
||||
passes the resolved model endpoint, API key, bearer-token mode, headers, model
|
||||
id, and context/output limits without moving provider transport logic into
|
||||
core.
|
||||
|
||||
For example:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "custom-proxy/llama-3.1-8b",
|
||||
models: {
|
||||
"custom-proxy/llama-3.1-8b": {
|
||||
agentRuntime: { id: "copilot" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
mode: "merge",
|
||||
providers: {
|
||||
"custom-proxy": {
|
||||
baseUrl: "https://api.example.com/v1",
|
||||
apiKey: "${CUSTOM_PROXY_API_KEY}",
|
||||
api: "openai-responses",
|
||||
authHeader: true,
|
||||
models: [{ id: "llama-3.1-8b", name: "Llama 3.1 8B" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
BYOK sessions are separately keyed from subscription sessions and from other
|
||||
endpoints or credential fingerprints. Rotating the key, headers, model, or
|
||||
endpoint creates a fresh Copilot SDK session instead of resuming incompatible
|
||||
state.
|
||||
Anything outside that set falls through `selection.ts`'s `auto_pi` branch back
|
||||
to PI.
|
||||
|
||||
## Auth
|
||||
|
||||
@@ -208,11 +151,10 @@ Override with `copilotHome: <path>` on the attempt input when you need a
|
||||
custom location (for example, a shared mount for migration).
|
||||
|
||||
Live harness tests use `OPENCLAW_COPILOT_AGENT_LIVE_TOKEN` when a direct token
|
||||
is needed. The shared live-test setup intentionally scrubs
|
||||
`COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, and `GITHUB_TOKEN` after staging real auth
|
||||
profiles into the isolated test home, so passing a `gh auth token` value
|
||||
through the dedicated live-test variable avoids false skips without exposing
|
||||
the token to unrelated suites.
|
||||
is needed. The shared live-test setup intentionally scrubs `COPILOT_GITHUB_TOKEN`,
|
||||
`GH_TOKEN`, and `GITHUB_TOKEN` after staging real auth profiles into the isolated
|
||||
test home, so passing a `gh auth token` value through the dedicated live-test
|
||||
variable avoids false skips without exposing the token to unrelated suites.
|
||||
|
||||
## Configuration surface
|
||||
|
||||
@@ -221,9 +163,9 @@ The harness reads its config from per-attempt input
|
||||
`extensions/copilot/src/`:
|
||||
|
||||
- `copilotHome` — per-agent CLI state directory (defaults documented above).
|
||||
- `model` — string or `{ provider, id, api?, baseUrl?, headers?, authHeader? }`.
|
||||
When omitted, OpenClaw uses the agent's normal model selection and the
|
||||
harness verifies the resolved provider is supported.
|
||||
- `model` — string or `{ provider, id, api? }`. When omitted, OpenClaw uses
|
||||
the agent's normal model selection and the harness verifies the resolved
|
||||
provider is in the supported set.
|
||||
- `reasoningEffort` — `"low" | "medium" | "high" | "xhigh"`. Maps from
|
||||
OpenClaw's `ThinkLevel` / `ReasoningLevel` resolution in
|
||||
`auto-reply/thinking.ts`.
|
||||
@@ -310,9 +252,9 @@ under `describe("runSideQuestion")`.
|
||||
|
||||
## Limitations
|
||||
|
||||
- The harness claims `github-copilot` plus unowned custom BYOK provider ids.
|
||||
Manifest-owned native provider ids stay on their owning runtime even when
|
||||
`agentRuntime.id` is forced to `copilot`.
|
||||
- The harness only claims the canonical `github-copilot` provider at MVP.
|
||||
Additional providers (BYOK or otherwise) should land in follow-up PRs that
|
||||
ship the adapter alongside the wire-up.
|
||||
- The harness does not deliver TUI; PI's TUI is unaffected and remains the
|
||||
fallback for whatever runtimes do not have a peer surface.
|
||||
- PI session state is not migrated when an agent switches to `copilot`.
|
||||
|
||||
@@ -186,12 +186,8 @@ file.
|
||||
- optional `event.runId`
|
||||
- optional `event.toolCallId`
|
||||
- context fields such as `ctx.agentId`, `ctx.sessionKey`, `ctx.sessionId`,
|
||||
`ctx.runId`, `ctx.jobId` (set on cron-driven runs), `ctx.trigger`,
|
||||
`ctx.toolKind`, `ctx.toolInputKind`, and diagnostic `ctx.trace`
|
||||
- for channel-originated calls, origin fields such as `ctx.channel`,
|
||||
`ctx.messageProvider`, `ctx.channelId`, `ctx.chatId`, `ctx.senderId`, and
|
||||
extensible `ctx.channelContext` sender/chat metadata. These use the same
|
||||
identity semantics described below for agent hook contexts.
|
||||
`ctx.runId`, `ctx.jobId` (set on cron-driven runs), `ctx.toolKind`,
|
||||
`ctx.toolInputKind`, and diagnostic `ctx.trace`
|
||||
|
||||
It can return:
|
||||
|
||||
|
||||
@@ -104,12 +104,9 @@ Anthropic's current public docs:
|
||||
|
||||
<Warning>
|
||||
Claude CLI reuse expects the OpenClaw process to run on the same host as the
|
||||
Claude CLI login. Docker installs can persist a container home and log in to
|
||||
Claude Code there; see
|
||||
[Claude CLI backend in Docker](/install/docker#claude-cli-backend-in-docker).
|
||||
Other container installs such as [Podman](/install/podman) do not mount host
|
||||
`~/.claude` into setup or runtime; use an Anthropic API key there, or choose
|
||||
a provider with OpenClaw-managed OAuth such as
|
||||
Claude CLI login. Container installs such as [Podman](/install/podman) do
|
||||
not mount host `~/.claude` into setup or runtime; use an Anthropic API key
|
||||
there, or choose a provider with OpenClaw-managed OAuth such as
|
||||
[OpenAI Codex](/providers/openai).
|
||||
</Warning>
|
||||
|
||||
|
||||
4
extensions/acpx/npm-shrinkwrap.json
generated
4
extensions/acpx/npm-shrinkwrap.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/acpx",
|
||||
"version": "2026.6.10",
|
||||
"version": "2026.6.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/acpx",
|
||||
"version": "2026.6.10",
|
||||
"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.10",
|
||||
"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.10"
|
||||
"pluginApi": ">=2026.6.9"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.10",
|
||||
"openclawVersion": "2026.6.9",
|
||||
"staticAssets": [
|
||||
{
|
||||
"source": "./src/runtime-internals/mcp-proxy.mjs",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/admin-http-rpc",
|
||||
"version": "2026.6.10",
|
||||
"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.10",
|
||||
"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.10",
|
||||
"version": "2026.6.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/amazon-bedrock-mantle-provider",
|
||||
"version": "2026.6.10",
|
||||
"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.10",
|
||||
"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.10"
|
||||
"pluginApi": ">=2026.6.9"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.10",
|
||||
"openclawVersion": "2026.6.9",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
|
||||
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.10",
|
||||
"version": "2026.6.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/amazon-bedrock-provider",
|
||||
"version": "2026.6.10",
|
||||
"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.10",
|
||||
"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.10"
|
||||
"pluginApi": ">=2026.6.9"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.10",
|
||||
"openclawVersion": "2026.6.9",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
|
||||
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.10",
|
||||
"version": "2026.6.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/anthropic-vertex-provider",
|
||||
"version": "2026.6.10",
|
||||
"version": "2026.6.9",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/vertex-sdk": "0.16.1"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/anthropic-vertex-provider",
|
||||
"version": "2026.6.10",
|
||||
"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.10"
|
||||
"pluginApi": ">=2026.6.9"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.10",
|
||||
"openclawVersion": "2026.6.9",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/anthropic-provider",
|
||||
"version": "2026.6.10",
|
||||
"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.10",
|
||||
"version": "2026.6.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/arcee-provider",
|
||||
"version": "2026.6.10"
|
||||
"version": "2026.6.9"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/arcee-provider",
|
||||
"version": "2026.6.10",
|
||||
"version": "2026.6.9",
|
||||
"description": "OpenClaw Arcee provider plugin.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -21,10 +21,10 @@
|
||||
"minHostVersion": ">=2026.6.8"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.10"
|
||||
"pluginApi": ">=2026.6.9"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.10",
|
||||
"openclawVersion": "2026.6.9",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/azure-speech",
|
||||
"version": "2026.6.10",
|
||||
"version": "2026.6.9",
|
||||
"private": true,
|
||||
"description": "OpenClaw Azure Speech plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/bonjour",
|
||||
"version": "2026.6.10",
|
||||
"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.10",
|
||||
"version": "2026.6.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/brave-plugin",
|
||||
"version": "2026.6.10"
|
||||
"version": "2026.6.9"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/brave-plugin",
|
||||
"version": "2026.6.10",
|
||||
"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.10"
|
||||
"pluginApi": ">=2026.6.9"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.10"
|
||||
"openclawVersion": "2026.6.9"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/browser-plugin",
|
||||
"version": "2026.6.10",
|
||||
"version": "2026.6.9",
|
||||
"private": true,
|
||||
"description": "OpenClaw browser tool plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -779,7 +779,6 @@ async function buildCdpRoleSnapshot(params: {
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
const refsByKey = new Map<string, string[]>();
|
||||
const nodesByRef = new Map<string, RoleTreeNode>();
|
||||
const refs: Record<string, CdpRoleRef> = {};
|
||||
for (const node of tree) {
|
||||
const role = node.role.toLowerCase();
|
||||
@@ -798,13 +797,7 @@ async function buildCdpRoleSnapshot(params: {
|
||||
params.nextRef.value += 1;
|
||||
node.ref = ref;
|
||||
node.nth = nth;
|
||||
const refsForKey = refsByKey.get(key);
|
||||
if (refsForKey) {
|
||||
refsForKey.push(ref);
|
||||
} else {
|
||||
refsByKey.set(key, [ref]);
|
||||
}
|
||||
nodesByRef.set(ref, node);
|
||||
refsByKey.set(key, [...(refsByKey.get(key) ?? []), ref]);
|
||||
refs[ref] = {
|
||||
role,
|
||||
...(node.name ? { name: node.name } : {}),
|
||||
@@ -820,7 +813,7 @@ async function buildCdpRoleSnapshot(params: {
|
||||
const ref = refList[0];
|
||||
if (ref) {
|
||||
delete refs[ref]?.nth;
|
||||
const node = nodesByRef.get(ref);
|
||||
const node = tree.find((entry) => entry.ref === ref);
|
||||
if (node) {
|
||||
delete node.nth;
|
||||
}
|
||||
|
||||
@@ -46,16 +46,6 @@ describe("pw-role-snapshot", () => {
|
||||
expect(res.snapshot).not.toContain("button");
|
||||
});
|
||||
|
||||
it("keeps named branches with refs and drops empty branches when compact", () => {
|
||||
const aria = ['- list "Menu":', ' - button "Save"', '- list "Empty":', " - generic"].join(
|
||||
"\n",
|
||||
);
|
||||
|
||||
const res = buildRoleSnapshotFromAriaSnapshot(aria, { compact: true });
|
||||
|
||||
expect(res.snapshot).toBe('- list "Menu":\n - button "Save" [ref=e1]');
|
||||
});
|
||||
|
||||
it("computes stats", () => {
|
||||
const aria = ['- button "OK"', '- button "Cancel"'].join("\n");
|
||||
const res = buildRoleSnapshotFromAriaSnapshot(aria);
|
||||
|
||||
@@ -131,42 +131,37 @@ function removeNthFromNonDuplicates(refs: RoleRefMap, tracker: RoleNameTracker)
|
||||
|
||||
function compactTree(tree: string) {
|
||||
const lines = tree.split("\n");
|
||||
const entries: Array<{ line: string; keep: boolean; hasRef: boolean; indent: number }> = [];
|
||||
const stack: Array<{ entry: (typeof entries)[number]; indent: number }> = [];
|
||||
const result: string[] = [];
|
||||
|
||||
const finishEntry = () => {
|
||||
const current = stack.pop();
|
||||
if (!current) {
|
||||
return;
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const line = lines[i];
|
||||
if (line.includes("[ref=")) {
|
||||
result.push(line);
|
||||
continue;
|
||||
}
|
||||
current.entry.keep ||= current.entry.hasRef;
|
||||
if (current.entry.hasRef && stack.length > 0) {
|
||||
stack[stack.length - 1].entry.hasRef = true;
|
||||
if (line.includes(":") && !line.trimEnd().endsWith(":")) {
|
||||
result.push(line);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const indent = getIndentLevel(line);
|
||||
while (stack.length > 0 && stack[stack.length - 1].indent >= indent) {
|
||||
finishEntry();
|
||||
const currentIndent = getIndentLevel(line);
|
||||
let hasRelevantChildren = false;
|
||||
for (let j = i + 1; j < lines.length; j += 1) {
|
||||
const childIndent = getIndentLevel(lines[j]);
|
||||
if (childIndent <= currentIndent) {
|
||||
break;
|
||||
}
|
||||
if (lines[j]?.includes("[ref=")) {
|
||||
hasRelevantChildren = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasRelevantChildren) {
|
||||
result.push(line);
|
||||
}
|
||||
const entry = {
|
||||
line,
|
||||
keep: line.includes("[ref=") || (line.includes(":") && !line.trimEnd().endsWith(":")),
|
||||
hasRef: line.includes("[ref="),
|
||||
indent,
|
||||
};
|
||||
entries.push(entry);
|
||||
stack.push({ entry, indent });
|
||||
}
|
||||
while (stack.length > 0) {
|
||||
finishEntry();
|
||||
}
|
||||
|
||||
return entries
|
||||
.filter((entry) => entry.keep)
|
||||
.map((entry) => entry.line)
|
||||
.join("\n");
|
||||
return result.join("\n");
|
||||
}
|
||||
|
||||
function processLine(
|
||||
|
||||
@@ -104,12 +104,7 @@ function buildStoredAriaRefs(
|
||||
const key = `${role}:${name ?? ""}`;
|
||||
const nth = counts.get(key) ?? 0;
|
||||
counts.set(key, nth + 1);
|
||||
const refsForKey = refsByKey.get(key);
|
||||
if (refsForKey) {
|
||||
refsForKey.push(node.ref);
|
||||
} else {
|
||||
refsByKey.set(key, [node.ref]);
|
||||
}
|
||||
refsByKey.set(key, [...(refsByKey.get(key) ?? []), node.ref]);
|
||||
refs[node.ref] = {
|
||||
role,
|
||||
...(name ? { name } : {}),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/byteplus-provider",
|
||||
"version": "2026.6.10",
|
||||
"version": "2026.6.9",
|
||||
"private": true,
|
||||
"description": "OpenClaw BytePlus provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/canvas-plugin",
|
||||
"version": "2026.6.10",
|
||||
"version": "2026.6.9",
|
||||
"private": true,
|
||||
"description": "OpenClaw Canvas plugin",
|
||||
"type": "module",
|
||||
|
||||
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.10",
|
||||
"version": "2026.6.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/cerebras-provider",
|
||||
"version": "2026.6.10"
|
||||
"version": "2026.6.9"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/cerebras-provider",
|
||||
"version": "2026.6.10",
|
||||
"version": "2026.6.9",
|
||||
"description": "OpenClaw Cerebras provider plugin.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -21,10 +21,10 @@
|
||||
"minHostVersion": ">=2026.6.8"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.10"
|
||||
"pluginApi": ">=2026.6.9"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.10",
|
||||
"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.10",
|
||||
"version": "2026.6.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/chutes-provider",
|
||||
"version": "2026.6.10"
|
||||
"version": "2026.6.9"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/chutes-provider",
|
||||
"version": "2026.6.10",
|
||||
"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.10"
|
||||
"pluginApi": ">=2026.6.9"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.10",
|
||||
"openclawVersion": "2026.6.9",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
|
||||
6
extensions/clickclack/npm-shrinkwrap.json
generated
6
extensions/clickclack/npm-shrinkwrap.json
generated
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"name": "@openclaw/clickclack",
|
||||
"version": "2026.6.10",
|
||||
"version": "2026.6.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/clickclack",
|
||||
"version": "2026.6.10",
|
||||
"version": "2026.6.9",
|
||||
"dependencies": {
|
||||
"ws": "8.21.0",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.6.10"
|
||||
"openclaw": ">=2026.6.9"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/clickclack",
|
||||
"version": "2026.6.10",
|
||||
"version": "2026.6.9",
|
||||
"description": "OpenClaw ClickClack channel plugin",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -17,7 +17,7 @@
|
||||
"openclaw": "2026.5.28"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.6.10"
|
||||
"openclaw": ">=2026.6.9"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
@@ -53,10 +53,10 @@
|
||||
"allowInvalidConfigRecovery": true
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.10"
|
||||
"pluginApi": ">=2026.6.9"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.10",
|
||||
"openclawVersion": "2026.6.9",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/cloudflare-ai-gateway-provider",
|
||||
"version": "2026.6.10",
|
||||
"version": "2026.6.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/cloudflare-ai-gateway-provider",
|
||||
"version": "2026.6.10"
|
||||
"version": "2026.6.9"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/cloudflare-ai-gateway-provider",
|
||||
"version": "2026.6.10",
|
||||
"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.10"
|
||||
"pluginApi": ">=2026.6.9"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.10",
|
||||
"openclawVersion": "2026.6.9",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/codex-supervisor",
|
||||
"version": "2026.6.10",
|
||||
"version": "2026.6.9",
|
||||
"private": true,
|
||||
"description": "OpenClaw Codex app-server fleet supervision plugin.",
|
||||
"type": "module",
|
||||
|
||||
4
extensions/codex/npm-shrinkwrap.json
generated
4
extensions/codex/npm-shrinkwrap.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/codex",
|
||||
"version": "2026.6.10",
|
||||
"version": "2026.6.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/codex",
|
||||
"version": "2026.6.10",
|
||||
"version": "2026.6.9",
|
||||
"dependencies": {
|
||||
"@openai/codex": "0.139.0",
|
||||
"typebox": "1.1.39",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/codex",
|
||||
"version": "2026.6.10",
|
||||
"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.10"
|
||||
"pluginApi": ">=2026.6.9"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.10"
|
||||
"openclawVersion": "2026.6.9"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -12,11 +12,7 @@ import {
|
||||
type EmbeddedRunAttemptParams,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildApprovalResponse,
|
||||
handleCodexAppServerApprovalRequest as handleCodexAppServerApprovalRequestImpl,
|
||||
} from "./approval-bridge.js";
|
||||
import { buildCodexToolHookRunContext } from "./tool-hook-context.js";
|
||||
import { buildApprovalResponse, handleCodexAppServerApprovalRequest } from "./approval-bridge.js";
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/agent-harness-runtime", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("openclaw/plugin-sdk/agent-harness-runtime")>()),
|
||||
@@ -46,20 +42,6 @@ const mockResolveNativeHookRelayDeferredToolApproval = vi.mocked(
|
||||
const mockReviewExecRequestWithConfiguredModel = vi.mocked(reviewExecRequestWithConfiguredModel);
|
||||
const mockRunBeforeToolCallHook = vi.mocked(runBeforeToolCallHook);
|
||||
|
||||
type ApprovalRequestParams = Parameters<typeof handleCodexAppServerApprovalRequestImpl>[0];
|
||||
|
||||
function handleCodexAppServerApprovalRequest(
|
||||
params: Omit<ApprovalRequestParams, "toolHookContext"> & {
|
||||
toolHookContext?: ApprovalRequestParams["toolHookContext"];
|
||||
},
|
||||
) {
|
||||
return handleCodexAppServerApprovalRequestImpl({
|
||||
...params,
|
||||
toolHookContext:
|
||||
params.toolHookContext ?? buildCodexToolHookRunContext({ attempt: params.paramsForRun }),
|
||||
});
|
||||
}
|
||||
|
||||
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error(`Expected ${label}`);
|
||||
@@ -261,8 +243,6 @@ describe("Codex app-server approval bridge", () => {
|
||||
ctx: {
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:session-1",
|
||||
messageProvider: "telegram",
|
||||
channel: "telegram",
|
||||
channelId: "chat-1",
|
||||
},
|
||||
});
|
||||
@@ -1184,18 +1164,11 @@ describe("Codex app-server approval bridge", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the caller-resolved hook context for approval fallback policy", async () => {
|
||||
it("normalizes prefixed channel targets for OpenClaw tool policy context", async () => {
|
||||
const params = createParams();
|
||||
params.agentId = "raw-agent";
|
||||
params.sessionId = "raw-session";
|
||||
params.sessionKey = "agent:raw:session";
|
||||
params.runId = "raw-run";
|
||||
params.messageChannel = "discord";
|
||||
params.messageProvider = "discord";
|
||||
params.currentChannelId = "discord:raw-target";
|
||||
params.jobId = "raw-job";
|
||||
params.senderId = "raw-user";
|
||||
params.chatId = "raw-chat";
|
||||
params.messageChannel = "telegram";
|
||||
params.messageProvider = "telegram";
|
||||
params.currentChannelId = "telegram:-100123";
|
||||
mockCallGatewayTool
|
||||
.mockResolvedValueOnce({ id: "plugin:approval-prefixed", status: "accepted" })
|
||||
.mockResolvedValueOnce({ id: "plugin:approval-prefixed", decision: "allow-once" });
|
||||
@@ -1209,27 +1182,6 @@ describe("Codex app-server approval bridge", () => {
|
||||
command: "pnpm test extensions/codex/src/app-server",
|
||||
},
|
||||
paramsForRun: params,
|
||||
toolHookContext: {
|
||||
agentId: "resolved-agent",
|
||||
sessionId: "resolved-session",
|
||||
sessionKey: "agent:resolved:session",
|
||||
runId: "resolved-run",
|
||||
jobId: "resolved-job",
|
||||
trigger: "user",
|
||||
messageProvider: "telegram-voice",
|
||||
channel: "telegram",
|
||||
channelId: "-100123",
|
||||
chatId: "native-chat-1",
|
||||
senderId: "user-1",
|
||||
channelContext: {
|
||||
sender: {
|
||||
id: "user-1",
|
||||
displayName: "Ada",
|
||||
providerUserId: "provider-user-1",
|
||||
},
|
||||
chat: { id: "native-chat-1", providerThreadKey: "thread-key-1" },
|
||||
},
|
||||
},
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
});
|
||||
@@ -1237,29 +1189,11 @@ describe("Codex app-server approval bridge", () => {
|
||||
expect(mockRunBeforeToolCallHook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ctx: expect.objectContaining({
|
||||
agentId: "resolved-agent",
|
||||
sessionId: "resolved-session",
|
||||
sessionKey: "agent:resolved:session",
|
||||
runId: "resolved-run",
|
||||
jobId: "resolved-job",
|
||||
trigger: "user",
|
||||
messageProvider: "telegram-voice",
|
||||
channel: "telegram",
|
||||
channelId: "-100123",
|
||||
chatId: "native-chat-1",
|
||||
senderId: "user-1",
|
||||
channelContext: {
|
||||
sender: {
|
||||
id: "user-1",
|
||||
displayName: "Ada",
|
||||
providerUserId: "provider-user-1",
|
||||
},
|
||||
chat: { id: "native-chat-1", providerThreadKey: "thread-key-1" },
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(gatewayRequestPayload().turnSourceTo).toBe("discord:raw-target");
|
||||
expect(gatewayRequestPayload().turnSourceTo).toBe("telegram:-100123");
|
||||
});
|
||||
|
||||
it("denies command approvals before prompting when OpenClaw tool policy blocks", async () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
*/
|
||||
import {
|
||||
type AgentApprovalEventData,
|
||||
buildAgentHookContextChannelFields,
|
||||
formatApprovalDisplayPath,
|
||||
hasNativeHookRelayInvocation,
|
||||
invokeNativeHookRelay,
|
||||
@@ -16,7 +17,6 @@ import {
|
||||
type NativeHookRelayProcessResponse,
|
||||
type NativeHookRelayRegistrationHandle,
|
||||
runBeforeToolCallHook,
|
||||
type ToolHookRunContext,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
|
||||
import { normalizeTrimmedStringList } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
@@ -75,7 +75,6 @@ export async function handleCodexAppServerApprovalRequest(params: {
|
||||
method: string;
|
||||
requestParams: JsonValue | undefined;
|
||||
paramsForRun: EmbeddedRunAttemptParams;
|
||||
toolHookContext: ToolHookRunContext;
|
||||
threadId: string;
|
||||
turnId: string;
|
||||
nativeHookRelay?: Pick<
|
||||
@@ -107,7 +106,6 @@ export async function handleCodexAppServerApprovalRequest(params: {
|
||||
method: params.method,
|
||||
requestParams,
|
||||
paramsForRun: params.paramsForRun,
|
||||
toolHookContext: params.toolHookContext,
|
||||
context,
|
||||
nativeHookRelay: params.nativeHookRelay,
|
||||
signal: params.signal,
|
||||
@@ -621,7 +619,6 @@ async function runOpenClawToolPolicyForApprovalRequest(params: {
|
||||
method: string;
|
||||
requestParams: JsonObject | undefined;
|
||||
paramsForRun: EmbeddedRunAttemptParams;
|
||||
toolHookContext: ToolHookRunContext;
|
||||
context: ApprovalContext;
|
||||
nativeHookRelay?: Pick<
|
||||
NativeHookRelayRegistrationHandle,
|
||||
@@ -655,6 +652,13 @@ async function runOpenClawToolPolicyForApprovalRequest(params: {
|
||||
if (nativeRelayOutcome?.handled) {
|
||||
return { outcome: "no-decision" };
|
||||
}
|
||||
const hookChannelId = buildAgentHookContextChannelFields({
|
||||
sessionKey: params.paramsForRun.sessionKey,
|
||||
messageChannel: params.paramsForRun.messageChannel,
|
||||
messageProvider: params.paramsForRun.messageProvider,
|
||||
currentChannelId: params.paramsForRun.currentChannelId,
|
||||
messageTo: params.paramsForRun.messageTo,
|
||||
}).channelId;
|
||||
const outcome = await runBeforeToolCallHook({
|
||||
toolName: policyRequest.toolName,
|
||||
params: policyRequest.params,
|
||||
@@ -662,9 +666,13 @@ async function runOpenClawToolPolicyForApprovalRequest(params: {
|
||||
approvalMode: "request",
|
||||
signal: params.signal,
|
||||
ctx: {
|
||||
...params.toolHookContext,
|
||||
...(params.paramsForRun.agentId ? { agentId: params.paramsForRun.agentId } : {}),
|
||||
...(params.paramsForRun.config ? { config: params.paramsForRun.config } : {}),
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(params.paramsForRun.sessionKey ? { sessionKey: params.paramsForRun.sessionKey } : {}),
|
||||
...(params.paramsForRun.sessionId ? { sessionId: params.paramsForRun.sessionId } : {}),
|
||||
...(params.paramsForRun.runId ? { runId: params.paramsForRun.runId } : {}),
|
||||
...(hookChannelId ? { channelId: hookChannelId } : {}),
|
||||
},
|
||||
});
|
||||
if (outcome.blocked) {
|
||||
|
||||
@@ -944,16 +944,8 @@ describe("Codex app-server dynamic tool build", () => {
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.disableTools = false;
|
||||
params.messageChannel = "discord";
|
||||
params.messageProvider = "discord-voice";
|
||||
params.currentChannelId = "discord:D123";
|
||||
params.currentChannelId = "D123";
|
||||
params.currentMessagingTarget = "user:U123";
|
||||
params.chatId = "chat-123";
|
||||
params.senderId = "user-123";
|
||||
params.channelContext = {
|
||||
sender: { id: "user-123" },
|
||||
chat: { id: "chat-123" },
|
||||
};
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
const factoryOptions: unknown[] = [];
|
||||
setOpenClawCodingToolsFactoryForTests((options) => {
|
||||
@@ -964,19 +956,9 @@ describe("Codex app-server dynamic tool build", () => {
|
||||
await buildDynamicToolsForTest(params, workspaceDir, { sandbox: null as never });
|
||||
|
||||
expect(factoryOptions[0]).toMatchObject({
|
||||
messageChannel: "discord",
|
||||
messageProvider: "discord",
|
||||
toolPolicyMessageProvider: "discord-voice",
|
||||
currentChannelId: "discord:D123",
|
||||
currentChannelId: "D123",
|
||||
currentMessagingTarget: "user:U123",
|
||||
chatId: "chat-123",
|
||||
senderId: "user-123",
|
||||
hookChannelContext: {
|
||||
sender: { id: "user-123" },
|
||||
chat: { id: "chat-123" },
|
||||
},
|
||||
});
|
||||
expect((factoryOptions[0] as { channelContext?: unknown }).channelContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes the approval reviewer device into Codex dynamic tools", async () => {
|
||||
|
||||
@@ -125,7 +125,7 @@ export function resolveCodexAppServerHookChannelId(
|
||||
messageChannel: params.messageChannel,
|
||||
messageProvider: params.messageProvider,
|
||||
currentChannelId: params.currentChannelId,
|
||||
messageTo: params.currentMessagingTarget ?? params.messageTo,
|
||||
messageTo: params.messageTo,
|
||||
}).channelId;
|
||||
}
|
||||
|
||||
@@ -239,7 +239,6 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
elevated: params.bashElevated,
|
||||
},
|
||||
sandbox: input.sandbox,
|
||||
messageChannel: params.messageChannel,
|
||||
messageProvider: resolveCodexMessageToolProvider(params),
|
||||
toolPolicyMessageProvider: params.messageProvider ?? params.messageChannel,
|
||||
agentAccountId: params.agentAccountId,
|
||||
@@ -250,7 +249,6 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
groupSpace: params.groupSpace,
|
||||
spawnedBy: params.spawnedBy,
|
||||
senderId: params.senderId,
|
||||
hookChannelContext: params.channelContext,
|
||||
senderName: params.senderName,
|
||||
senderUsername: params.senderUsername,
|
||||
senderE164: params.senderE164,
|
||||
@@ -292,7 +290,6 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
),
|
||||
suppressManagedWebSearch: false,
|
||||
currentChannelId: params.currentChannelId,
|
||||
chatId: params.chatId,
|
||||
currentMessagingTarget: params.currentMessagingTarget,
|
||||
hookChannelId: resolveCodexAppServerHookChannelId(params, input.sandboxSessionKey),
|
||||
currentThreadTs: params.currentThreadTs,
|
||||
|
||||
@@ -1102,6 +1102,426 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks delivered message-tool-only source replies as terminal", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", { messageId: "imessage-6264" }),
|
||||
{ sourceReplyDeliveryMode: "message_tool_only" },
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
message: "visible reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("keeps message-tool-only source replies terminal when middleware redacts receipt details", async () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
registry.agentToolResultMiddlewares.push({
|
||||
pluginId: "receipt-redactor",
|
||||
pluginName: "Receipt redactor",
|
||||
rawHandler: () => undefined,
|
||||
handler: (event: { result: AgentToolResult<unknown> }) => ({
|
||||
result: {
|
||||
content: event.result.content,
|
||||
details: { redacted: true },
|
||||
},
|
||||
}),
|
||||
runtimes: ["codex"],
|
||||
source: "test",
|
||||
});
|
||||
setActivePluginRegistry(registry);
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", {
|
||||
receipt: {
|
||||
primaryPlatformMessageId: "imessage-6264",
|
||||
platformMessageIds: ["imessage-6264"],
|
||||
},
|
||||
}),
|
||||
{ sourceReplyDeliveryMode: "message_tool_only" },
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
message: "visible reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("does not treat target telemetry alone as delivered message-tool-only source reply evidence", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent."), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "chat-1",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
message: "visible reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(bridge.telemetry.messagingToolSentTargets).toEqual([
|
||||
expect.objectContaining({
|
||||
tool: "message",
|
||||
provider: "imessage",
|
||||
to: "chat-1",
|
||||
text: "visible reply",
|
||||
}),
|
||||
]);
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps message-tool-only source replies terminal for explicit current source routes", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", { ok: true, messageId: "imessage-853" }),
|
||||
{
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:+12069106512",
|
||||
currentMessagingTarget: "+12069106512",
|
||||
},
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "853",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("keeps message-tool-only source replies terminal when the reply receipt matches the current message id", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", {
|
||||
ok: true,
|
||||
messageId: "provider-message-1",
|
||||
repliedTo: "provider-guid-857",
|
||||
}),
|
||||
{
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:any;-;+12069106512",
|
||||
currentMessageId: "provider-guid-857",
|
||||
},
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "857",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(bridge.telemetry.messagingToolSentTargets).toEqual([
|
||||
expect.objectContaining({
|
||||
tool: "message",
|
||||
provider: "imessage",
|
||||
to: "+12069106512",
|
||||
text: "visible reply",
|
||||
}),
|
||||
]);
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("keeps message-tool-only source replies terminal when a text receipt matches the current message id", async () => {
|
||||
const receiptText = JSON.stringify({
|
||||
ok: true,
|
||||
messageId: "provider-message-1",
|
||||
repliedTo: "provider-guid-861",
|
||||
});
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult(receiptText), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:any;-;+12069106512",
|
||||
currentMessageId: "provider-guid-861",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "861",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText(receiptText));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("keeps message-tool-only source replies terminal for explicit native target segments", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:any;-;+12069106512",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "863",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("keeps message-tool-only source replies terminal when the provider is only in the current channel id", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelId: "imessage:any;-;+12069106512",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "865",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("records message-tool-owned terminal replies as delivered source replies", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
{
|
||||
...textToolResult("Sent.", { ok: true }),
|
||||
terminate: true,
|
||||
} as AgentToolResult<unknown>,
|
||||
{ sourceReplyDeliveryMode: "message_tool_only" },
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "867",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("does not treat bare send telemetry as delivered message-tool-only source reply evidence", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent."), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
message: "visible reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(bridge.telemetry.didSendViaMessagingTool).toBe(true);
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not let prior message-send telemetry terminate a later non-delivery tool result", async () => {
|
||||
const execute = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(textToolResult("Sent.", { messageId: "source-reply-1" }))
|
||||
.mockResolvedValueOnce(textToolResult("No message sent.", { ok: true }));
|
||||
const bridge = createCodexDynamicToolBridge({
|
||||
tools: [createTool({ name: "message", execute })],
|
||||
signal: new AbortController().signal,
|
||||
hookContext: { sourceReplyDeliveryMode: "message_tool_only" },
|
||||
});
|
||||
|
||||
const firstResult = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
message: "visible reply",
|
||||
});
|
||||
const secondResult = await bridge.handleToolCall({
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
callId: "call-2",
|
||||
namespace: null,
|
||||
tool: "message",
|
||||
arguments: { action: "inspect" },
|
||||
});
|
||||
|
||||
expect(firstResult.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didSendViaMessagingTool).toBe(true);
|
||||
expect(secondResult).toEqual(expectInputText("No message sent."));
|
||||
expect(secondResult.terminate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not mark explicit message-tool sends as terminal source replies", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", { messageId: "other-chat-message" }),
|
||||
{ sourceReplyDeliveryMode: "message_tool_only" },
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
target: "channel:other",
|
||||
message: "cross-channel reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not mark mismatched explicit message-tool sends as terminal source replies", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent."), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:+12069106512",
|
||||
currentMessagingTarget: "+12069106512",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "slack",
|
||||
target: "+12069106512",
|
||||
messageId: "853",
|
||||
message: "cross-provider reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not mark same-target sibling-thread replies as terminal source replies", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "slack",
|
||||
currentChannelId: "slack:C123",
|
||||
currentMessagingTarget: "C123",
|
||||
currentThreadId: "171.222",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "slack",
|
||||
target: "C123",
|
||||
threadId: "171.333",
|
||||
message: "sibling thread reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not mark implicit-target sibling-thread replies as terminal source replies", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "slack",
|
||||
currentChannelId: "slack:C123",
|
||||
currentMessagingTarget: "C123",
|
||||
currentThreadId: "171.222",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "slack",
|
||||
threadId: "171.333",
|
||||
message: "sibling thread reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not mark top-level source replies with explicit thread routes as terminal", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "slack",
|
||||
currentChannelId: "slack:C123",
|
||||
currentMessagingTarget: "C123",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "slack",
|
||||
target: "C123",
|
||||
threadId: "171.333",
|
||||
message: "thread reply from top-level source",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not let matching reply receipts override explicit non-source routes", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", {
|
||||
ok: true,
|
||||
messageId: "other-chat-message",
|
||||
repliedTo: "provider-guid-853",
|
||||
}),
|
||||
{
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:+12069106512",
|
||||
currentMessagingTarget: "+12069106512",
|
||||
currentMessageId: "provider-guid-853",
|
||||
},
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "other-chat",
|
||||
message: "cross-channel reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not record messaging side effects when the send fails", async () => {
|
||||
const tool = createTool({
|
||||
name: "message",
|
||||
@@ -1846,17 +2266,6 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:agent-1:session-1",
|
||||
runId: "run-1",
|
||||
jobId: "job-1",
|
||||
trigger: "user",
|
||||
messageProvider: "discord-voice",
|
||||
channel: "discord",
|
||||
chatId: "channel-1",
|
||||
senderId: "user-1",
|
||||
channelId: "channel-1",
|
||||
channelContext: {
|
||||
sender: { id: "user-1", displayName: "Ada" },
|
||||
chat: { id: "channel-1" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1960,17 +2369,6 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:agent-1:session-1",
|
||||
runId: "run-1",
|
||||
jobId: "job-1",
|
||||
trigger: "user",
|
||||
messageProvider: "discord-voice",
|
||||
channel: "discord",
|
||||
chatId: "channel-1",
|
||||
senderId: "user-1",
|
||||
channelId: "channel-1",
|
||||
channelContext: {
|
||||
sender: { id: "user-1", displayName: "Ada" },
|
||||
chat: { id: "channel-1" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1997,17 +2395,6 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:agent-1:session-1",
|
||||
runId: "run-1",
|
||||
jobId: "job-1",
|
||||
trigger: "user",
|
||||
messageProvider: "discord-voice",
|
||||
channel: "discord",
|
||||
chatId: "channel-1",
|
||||
senderId: "user-1",
|
||||
channelId: "channel-1",
|
||||
channelContext: {
|
||||
sender: { id: "user-1", displayName: "Ada" },
|
||||
chat: { id: "channel-1" },
|
||||
},
|
||||
toolCallId: "call-1",
|
||||
});
|
||||
expectExecuteCall(execute, { callId: "call-1", args: { command: "pwd", mode: "safe" } });
|
||||
@@ -2030,17 +2417,6 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:agent-1:session-1",
|
||||
runId: "run-1",
|
||||
jobId: "job-1",
|
||||
trigger: "user",
|
||||
messageProvider: "discord-voice",
|
||||
channel: "discord",
|
||||
chatId: "channel-1",
|
||||
senderId: "user-1",
|
||||
channelId: "channel-1",
|
||||
channelContext: {
|
||||
sender: { id: "user-1", displayName: "Ada" },
|
||||
chat: { id: "channel-1" },
|
||||
},
|
||||
toolCallId: "call-1",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
getChannelAgentToolMeta,
|
||||
getPluginToolMeta,
|
||||
type EmbeddedRunAttemptParams,
|
||||
isDeliveredMessageToolOnlySourceReplyResult,
|
||||
isReplaySafeToolCall,
|
||||
isToolWrappedWithBeforeToolCallHook,
|
||||
isToolResultError,
|
||||
@@ -32,7 +33,6 @@ import {
|
||||
type HeartbeatToolResponse,
|
||||
type MessagingToolSend,
|
||||
type MessagingToolSourceReplyPayload,
|
||||
type ToolHookRunContext,
|
||||
wrapToolWithBeforeToolCallHook,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { emitTrustedDiagnosticEvent } from "openclaw/plugin-sdk/diagnostic-runtime";
|
||||
@@ -54,19 +54,26 @@ import type {
|
||||
JsonValue,
|
||||
} from "./protocol.js";
|
||||
|
||||
type CodexDynamicToolHookContext = ToolHookRunContext & {
|
||||
type CodexDynamicToolHookContext = {
|
||||
agentId?: string;
|
||||
config?: EmbeddedRunAttemptParams["config"];
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
runId?: string;
|
||||
channelId?: string;
|
||||
currentChannelProvider?: string;
|
||||
currentChannelId?: string;
|
||||
currentMessagingTarget?: string;
|
||||
currentMessageId?: string | number;
|
||||
currentThreadId?: string;
|
||||
replyToMode?: "off" | "first" | "all" | "batched";
|
||||
hasRepliedRef?: { value: boolean };
|
||||
sourceReplyDeliveryMode?: EmbeddedRunAttemptParams["sourceReplyDeliveryMode"];
|
||||
onToolOutcome?: EmbeddedRunAttemptParams["onToolOutcome"];
|
||||
allocateToolOutcomeOrdinal?: EmbeddedRunAttemptParams["allocateToolOutcomeOrdinal"];
|
||||
};
|
||||
|
||||
type CodexToolResultHookContext = ToolHookRunContext;
|
||||
type CodexToolResultHookContext = Omit<CodexDynamicToolHookContext, "config">;
|
||||
|
||||
type ProjectedCodexDynamicTool = {
|
||||
tool: AnyAgentTool;
|
||||
@@ -96,6 +103,218 @@ function applyCurrentMessageProvider(
|
||||
return { ...args, provider };
|
||||
}
|
||||
|
||||
function normalizeRouteToken(value: string | number | undefined): string | undefined {
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? String(value) : undefined;
|
||||
}
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
return normalized ? normalized : undefined;
|
||||
}
|
||||
|
||||
function sourceRouteTokens(hookContext: CodexDynamicToolHookContext | undefined): Set<string> {
|
||||
const tokens = new Set<string>();
|
||||
const currentTarget = normalizeRouteToken(hookContext?.currentMessagingTarget);
|
||||
const currentChannel = normalizeRouteToken(hookContext?.currentChannelId);
|
||||
const currentProvider = normalizeRouteToken(hookContext?.currentChannelProvider);
|
||||
if (currentTarget) {
|
||||
tokens.add(currentTarget);
|
||||
}
|
||||
if (currentChannel) {
|
||||
tokens.add(currentChannel);
|
||||
}
|
||||
const channelPrefixIndex = currentChannel?.indexOf(":") ?? -1;
|
||||
if (channelPrefixIndex >= 0 && currentChannel) {
|
||||
const unprefixedChannel = currentChannel.slice(channelPrefixIndex + 1);
|
||||
if (unprefixedChannel) {
|
||||
tokens.add(unprefixedChannel);
|
||||
for (const segment of unprefixedChannel.split(/[;,]/u)) {
|
||||
const token = normalizeRouteToken(segment);
|
||||
if (token) {
|
||||
tokens.add(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentProvider && currentChannel?.startsWith(`${currentProvider}:`)) {
|
||||
const unprefixedChannel = currentChannel.slice(currentProvider.length + 1);
|
||||
if (unprefixedChannel) {
|
||||
tokens.add(unprefixedChannel);
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function routeTokenMatchesSource(
|
||||
token: string | undefined,
|
||||
hookContext: CodexDynamicToolHookContext | undefined,
|
||||
): boolean {
|
||||
const normalized = normalizeRouteToken(token);
|
||||
return normalized !== undefined && sourceRouteTokens(hookContext).has(normalized);
|
||||
}
|
||||
|
||||
function routeProviderMatchesSource(
|
||||
provider: string | undefined,
|
||||
hookContext: CodexDynamicToolHookContext | undefined,
|
||||
): boolean {
|
||||
const normalized = normalizeRouteToken(provider);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
const currentProvider = normalizeRouteToken(hookContext?.currentChannelProvider);
|
||||
const currentChannel = normalizeRouteToken(hookContext?.currentChannelId);
|
||||
return currentProvider === normalized || currentChannel?.startsWith(`${normalized}:`) === true;
|
||||
}
|
||||
|
||||
function routeTokenMatchesCurrentMessage(
|
||||
token: string | number | undefined,
|
||||
hookContext: CodexDynamicToolHookContext | undefined,
|
||||
): boolean {
|
||||
const normalized = normalizeRouteToken(token);
|
||||
return (
|
||||
normalized !== undefined && normalized === normalizeRouteToken(hookContext?.currentMessageId)
|
||||
);
|
||||
}
|
||||
|
||||
function readRouteToken(record: Record<string, unknown>, key: string): string | number | undefined {
|
||||
const value = record[key];
|
||||
return typeof value === "string" || typeof value === "number" ? value : undefined;
|
||||
}
|
||||
|
||||
function explicitRouteTokensMismatchCurrent(
|
||||
args: Record<string, unknown>,
|
||||
keys: readonly string[],
|
||||
currentToken: string | number | undefined,
|
||||
): boolean {
|
||||
const normalizedCurrent = normalizeRouteToken(currentToken);
|
||||
if (!normalizedCurrent) {
|
||||
return false;
|
||||
}
|
||||
return keys.some((key) => {
|
||||
const normalized = normalizeRouteToken(readRouteToken(args, key));
|
||||
return normalized !== undefined && normalized !== normalizedCurrent;
|
||||
});
|
||||
}
|
||||
|
||||
function explicitThreadRouteTargetsNonSource(
|
||||
args: Record<string, unknown>,
|
||||
hookContext: CodexDynamicToolHookContext | undefined,
|
||||
messagingTarget: MessagingToolSend | undefined,
|
||||
): boolean {
|
||||
const normalizedCurrentThread = normalizeRouteToken(hookContext?.currentThreadId);
|
||||
const explicitThreadTokens = [
|
||||
...EXPLICIT_MESSAGE_THREAD_KEYS.map((key) => normalizeRouteToken(readRouteToken(args, key))),
|
||||
normalizeRouteToken(messagingTarget?.threadId),
|
||||
].filter((value): value is string => value !== undefined);
|
||||
|
||||
if (explicitThreadTokens.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
normalizedCurrentThread === undefined ||
|
||||
explicitThreadTokens.some((value) => value !== normalizedCurrentThread)
|
||||
);
|
||||
}
|
||||
|
||||
function replyReceiptMatchesCurrentMessage(
|
||||
value: unknown,
|
||||
hookContext: CodexDynamicToolHookContext | undefined,
|
||||
depth = 0,
|
||||
): boolean {
|
||||
if (depth > 4 || value === null) {
|
||||
return false;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || !["{", "["].includes(trimmed[0] ?? "")) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return replyReceiptMatchesCurrentMessage(JSON.parse(trimmed), hookContext, depth + 1);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.some((item) => replyReceiptMatchesCurrentMessage(item, hookContext, depth + 1));
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
for (const key of ["repliedTo", "replyTo", "replyToId", "replyToIdFull"]) {
|
||||
if (
|
||||
routeTokenMatchesCurrentMessage(
|
||||
typeof record[key] === "string" ? record[key] : undefined,
|
||||
hookContext,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (const key of [
|
||||
"content",
|
||||
"details",
|
||||
"payload",
|
||||
"receipt",
|
||||
"result",
|
||||
"results",
|
||||
"sendResult",
|
||||
"text",
|
||||
]) {
|
||||
if (replyReceiptMatchesCurrentMessage(record[key], hookContext, depth + 1)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasExplicitNonSourceMessageRoute(
|
||||
args: Record<string, unknown>,
|
||||
hookContext: CodexDynamicToolHookContext | undefined,
|
||||
messagingTarget: MessagingToolSend | undefined,
|
||||
): boolean {
|
||||
const currentProvider = normalizeRouteToken(hookContext?.currentChannelProvider);
|
||||
for (const key of EXPLICIT_MESSAGE_PROVIDER_KEYS) {
|
||||
const provider = normalizeRouteToken(typeof args[key] === "string" ? args[key] : undefined);
|
||||
if (
|
||||
provider &&
|
||||
currentProvider !== provider &&
|
||||
!routeProviderMatchesSource(provider, hookContext)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const targetValues = [
|
||||
...EXPLICIT_MESSAGE_TARGET_KEYS.map((key) =>
|
||||
typeof args[key] === "string" ? args[key] : undefined,
|
||||
),
|
||||
...(Array.isArray(args.targets)
|
||||
? args.targets.map((value) => (typeof value === "string" ? value : undefined))
|
||||
: []),
|
||||
].filter((value): value is string => normalizeRouteToken(value) !== undefined);
|
||||
if (explicitThreadRouteTargetsNonSource(args, hookContext, messagingTarget)) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
explicitRouteTokensMismatchCurrent(
|
||||
args,
|
||||
EXPLICIT_MESSAGE_REPLY_KEYS,
|
||||
hookContext?.currentMessageId,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (targetValues.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (targetValues.some((value) => !routeTokenMatchesSource(value, hookContext))) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
messagingTarget?.to !== undefined && !routeTokenMatchesSource(messagingTarget.to, hookContext)
|
||||
);
|
||||
}
|
||||
|
||||
/** Runtime bridge returned to Codex app-server attempt code. */
|
||||
export type CodexDynamicToolBridge = {
|
||||
availableSpecs: CodexDynamicToolSpec[];
|
||||
@@ -110,6 +329,7 @@ export type CodexDynamicToolBridge = {
|
||||
) => Promise<CodexDynamicToolCallResponse>;
|
||||
telemetry: {
|
||||
didSendViaMessagingTool: boolean;
|
||||
didDeliverSourceReplyViaMessageTool: boolean;
|
||||
messagingToolSentTexts: string[];
|
||||
messagingToolSentMediaUrls: string[];
|
||||
messagingToolSentTargets: MessagingToolSend[];
|
||||
@@ -128,6 +348,10 @@ export const CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE = "openclaw";
|
||||
// Keep OpenClaw session spawning searchable in Codex mode so Codex's native
|
||||
// spawn_agent remains the primary Codex subagent surface.
|
||||
const ALWAYS_DIRECT_DYNAMIC_TOOL_NAMES = new Set(["sessions_yield"]);
|
||||
const EXPLICIT_MESSAGE_PROVIDER_KEYS = ["channel", "provider"];
|
||||
const EXPLICIT_MESSAGE_TARGET_KEYS = ["target", "to", "channelId"];
|
||||
const EXPLICIT_MESSAGE_THREAD_KEYS = ["threadId", "thread_id", "messageThreadId", "topicId"];
|
||||
const EXPLICIT_MESSAGE_REPLY_KEYS = ["replyTo", "replyToId", "replyToIdFull"];
|
||||
const DEFAULT_CODEX_DYNAMIC_TOOL_RESULT_MAX_CHARS = 16_000;
|
||||
|
||||
/**
|
||||
@@ -172,6 +396,7 @@ export function createCodexDynamicToolBridge(params: {
|
||||
emitQuarantinedDynamicToolDiagnostics(quarantinedTools, params.hookContext);
|
||||
const telemetry: CodexDynamicToolBridge["telemetry"] = {
|
||||
didSendViaMessagingTool: false,
|
||||
didDeliverSourceReplyViaMessageTool: false,
|
||||
messagingToolSentTexts: [],
|
||||
messagingToolSentMediaUrls: [],
|
||||
messagingToolSentTargets: [],
|
||||
@@ -306,7 +531,11 @@ export function createCodexDynamicToolBridge(params: {
|
||||
void runAgentHarnessAfterToolCallHook({
|
||||
toolName,
|
||||
toolCallId: call.callId,
|
||||
...toolResultHookContext,
|
||||
runId: toolResultHookContext.runId,
|
||||
agentId: toolResultHookContext.agentId,
|
||||
sessionId: toolResultHookContext.sessionId,
|
||||
sessionKey: toolResultHookContext.sessionKey,
|
||||
channelId: toolResultHookContext.channelId,
|
||||
startArgs: executedArgs,
|
||||
result,
|
||||
startedAt,
|
||||
@@ -325,10 +554,9 @@ export function createCodexDynamicToolBridge(params: {
|
||||
executedArgs,
|
||||
params.hookContext?.currentChannelProvider,
|
||||
);
|
||||
const messagingTarget =
|
||||
isMessagingTool(toolName) && isMessagingToolSendAction(toolName, executedArgs)
|
||||
? extractMessagingToolSend(toolName, messagingTelemetryArgs, messagingContext)
|
||||
: undefined;
|
||||
const messagingTarget = isMessagingTool(toolName)
|
||||
? extractMessagingToolSend(toolName, messagingTelemetryArgs, messagingContext)
|
||||
: undefined;
|
||||
const confirmedMessagingTarget =
|
||||
!rawIsError && messagingTarget
|
||||
? extractMessagingToolSendResult(messagingTarget, telemetryRawResult)
|
||||
@@ -350,12 +578,46 @@ export function createCodexDynamicToolBridge(params: {
|
||||
},
|
||||
terminalType,
|
||||
);
|
||||
const blocksSourceReplyTermination = hasExplicitNonSourceMessageRoute(
|
||||
executedArgs,
|
||||
params.hookContext,
|
||||
confirmedMessagingTarget,
|
||||
);
|
||||
const deliveredSourceReply = isDeliveredMessageToolOnlySourceReplyResult({
|
||||
sourceReplyDeliveryMode: params.hookContext?.sourceReplyDeliveryMode,
|
||||
toolName,
|
||||
args: executedArgs,
|
||||
result,
|
||||
hookResult: rawResult,
|
||||
isError: resultIsError,
|
||||
allowExplicitSourceRoute: !blocksSourceReplyTermination,
|
||||
});
|
||||
const receiptConfirmedSourceReply =
|
||||
params.hookContext?.sourceReplyDeliveryMode === "message_tool_only" &&
|
||||
toolName === "message" &&
|
||||
normalizeRouteToken(
|
||||
typeof executedArgs.action === "string" ? executedArgs.action : undefined,
|
||||
) === "reply" &&
|
||||
!resultIsError &&
|
||||
!blocksSourceReplyTermination &&
|
||||
(replyReceiptMatchesCurrentMessage(rawResult, params.hookContext) ||
|
||||
replyReceiptMatchesCurrentMessage(result, params.hookContext));
|
||||
const toolConfirmedSourceReply =
|
||||
params.hookContext?.sourceReplyDeliveryMode === "message_tool_only" &&
|
||||
toolName === "message" &&
|
||||
!resultIsError &&
|
||||
(rawResult.terminate === true || result.terminate === true);
|
||||
if (deliveredSourceReply || receiptConfirmedSourceReply || toolConfirmedSourceReply) {
|
||||
telemetry.didDeliverSourceReplyViaMessageTool = true;
|
||||
}
|
||||
withDynamicToolTermination(
|
||||
response,
|
||||
rawResult.terminate === true ||
|
||||
result.terminate === true ||
|
||||
isToolResultYield(rawResult) ||
|
||||
isToolResultYield(result),
|
||||
isToolResultYield(result) ||
|
||||
deliveredSourceReply ||
|
||||
receiptConfirmedSourceReply,
|
||||
);
|
||||
const asyncStarted =
|
||||
isAsyncStartedToolResult(rawResult) || isAsyncStartedToolResult(result);
|
||||
@@ -399,7 +661,11 @@ export function createCodexDynamicToolBridge(params: {
|
||||
void runAgentHarnessAfterToolCallHook({
|
||||
toolName,
|
||||
toolCallId: call.callId,
|
||||
...toolResultHookContext,
|
||||
runId: toolResultHookContext.runId,
|
||||
agentId: toolResultHookContext.agentId,
|
||||
sessionId: toolResultHookContext.sessionId,
|
||||
sessionKey: toolResultHookContext.sessionKey,
|
||||
channelId: toolResultHookContext.channelId,
|
||||
startArgs: executedArgs,
|
||||
error: errorMessage,
|
||||
startedAt,
|
||||
@@ -690,35 +956,13 @@ function dedupeQuarantinedDynamicTools(
|
||||
function toToolResultHookContext(
|
||||
ctx: CodexDynamicToolHookContext | undefined,
|
||||
): CodexToolResultHookContext {
|
||||
const {
|
||||
agentId,
|
||||
sessionId,
|
||||
sessionKey,
|
||||
runId,
|
||||
jobId,
|
||||
trace,
|
||||
trigger,
|
||||
messageProvider,
|
||||
channel,
|
||||
chatId,
|
||||
senderId,
|
||||
channelId,
|
||||
channelContext,
|
||||
} = ctx ?? {};
|
||||
const { agentId, sessionId, sessionKey, runId, channelId } = ctx ?? {};
|
||||
return {
|
||||
...(agentId && { agentId }),
|
||||
...(sessionId && { sessionId }),
|
||||
...(sessionKey && { sessionKey }),
|
||||
...(runId && { runId }),
|
||||
...(jobId && { jobId }),
|
||||
...(trace && { trace }),
|
||||
...(trigger && { trigger }),
|
||||
...(messageProvider && { messageProvider }),
|
||||
...(channel && { channel }),
|
||||
...(chatId && { chatId }),
|
||||
...(senderId && { senderId }),
|
||||
...(channelId && { channelId }),
|
||||
...(channelContext && { channelContext }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -813,7 +1057,7 @@ function collectToolTelemetry(params: {
|
||||
}
|
||||
if (
|
||||
!isMessagingTool(params.toolName) ||
|
||||
!isMessagingToolSendAction(params.toolName, params.args)
|
||||
(!isMessagingToolSendAction(params.toolName, params.args) && !params.messagingTarget)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
resetGlobalHookRunner,
|
||||
} from "openclaw/plugin-sdk/hook-runtime";
|
||||
import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { withTempDir } from "openclaw/plugin-sdk/test-env";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
CodexAppServerEventProjector,
|
||||
@@ -744,47 +743,6 @@ describe("CodexAppServerEventProjector", () => {
|
||||
expect(result.toolMediaUrls?.[0]).not.toBe(savedPath);
|
||||
});
|
||||
|
||||
it("prefers gateway-managed image media when the typed event arrives first", async () => {
|
||||
await withTempDir("openclaw-codex-media-state-", async (stateDir) => {
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
|
||||
const projector = await createProjector();
|
||||
const savedPath = "/home/dev-user/.codex/generated_images/session-1/ig_123.png";
|
||||
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("item/completed", {
|
||||
item: {
|
||||
type: "imageGeneration",
|
||||
id: "ig_123",
|
||||
status: "completed",
|
||||
revisedPrompt: "A tiny blue square",
|
||||
result: tinyPngBase64,
|
||||
savedPath,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("rawResponseItem/completed", {
|
||||
item: {
|
||||
type: "image_generation_call",
|
||||
id: "ig_123",
|
||||
status: "generating",
|
||||
result: tinyPngBase64,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = projector.buildResult(buildEmptyToolTelemetry());
|
||||
const mediaUrl = result.toolMediaUrls?.[0];
|
||||
|
||||
expect(result.toolMediaUrls).toHaveLength(1);
|
||||
expect(mediaUrl).not.toBe(savedPath);
|
||||
expect(mediaUrl).toContain(`${path.sep}media${path.sep}tool-image-generation${path.sep}`);
|
||||
await expect(fs.readFile(mediaUrl ?? "")).resolves.toEqual(
|
||||
Buffer.from(tinyPngBase64, "base64"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves distinct raw image-generation items with identical image bytes", async () => {
|
||||
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-media-state-"));
|
||||
tempDirs.add(stateDir);
|
||||
@@ -836,6 +794,19 @@ describe("CodexAppServerEventProjector", () => {
|
||||
expect(result.toolMediaUrls).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("propagates message-tool-only source reply delivery telemetry", async () => {
|
||||
const projector = await createProjector();
|
||||
|
||||
const result = projector.buildResult({
|
||||
...buildEmptyToolTelemetry(),
|
||||
didSendViaMessagingTool: true,
|
||||
didDeliverSourceReplyViaMessageTool: true,
|
||||
});
|
||||
|
||||
expect(result.didSendViaMessagingTool).toBe(true);
|
||||
expect(result.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
});
|
||||
|
||||
it("does not promote repeated tool progress text to the final assistant reply", async () => {
|
||||
const onToolResult = vi.fn();
|
||||
const projector = await createProjector({
|
||||
@@ -2587,36 +2558,15 @@ describe("CodexAppServerEventProjector", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps resolved hook identity authoritative for Codex-native tool completions", async () => {
|
||||
it("emits after_tool_call observations for Codex-native tool item completions", async () => {
|
||||
const afterToolCall = vi.fn();
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "after_tool_call", handler: afterToolCall }]),
|
||||
);
|
||||
const projectorParams = {
|
||||
const projector = await createProjector({
|
||||
...(await createParams()),
|
||||
agentId: "raw-agent",
|
||||
sessionId: "raw-session",
|
||||
sessionKey: "agent:raw:session-1",
|
||||
runId: "raw-run",
|
||||
};
|
||||
const projector = await createProjector(projectorParams, {
|
||||
toolHookContext: {
|
||||
agentId: "main",
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
runId: "run-1",
|
||||
jobId: "job-1",
|
||||
trigger: "user",
|
||||
messageProvider: "discord-voice",
|
||||
channel: "discord",
|
||||
chatId: "channel-1",
|
||||
senderId: "user-1",
|
||||
channelId: "channel-1",
|
||||
channelContext: {
|
||||
sender: { id: "user-1" },
|
||||
chat: { id: "channel-1" },
|
||||
},
|
||||
},
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:session-1",
|
||||
});
|
||||
|
||||
await projector.handleNotification(
|
||||
@@ -2673,17 +2623,6 @@ describe("CodexAppServerEventProjector", () => {
|
||||
expect(context.sessionId).toBe("session-1");
|
||||
expect(context.sessionKey).toBe("agent:main:session-1");
|
||||
expect(context.runId).toBe("run-1");
|
||||
expect(context.jobId).toBe("job-1");
|
||||
expect(context.trigger).toBe("user");
|
||||
expect(context.messageProvider).toBe("discord-voice");
|
||||
expect(context.channel).toBe("discord");
|
||||
expect(context.chatId).toBe("channel-1");
|
||||
expect(context.senderId).toBe("user-1");
|
||||
expect(context.channelId).toBe("channel-1");
|
||||
expect(context.channelContext).toEqual({
|
||||
sender: { id: "user-1" },
|
||||
chat: { id: "channel-1" },
|
||||
});
|
||||
expect(context.toolName).toBe("bash");
|
||||
expect(context.toolCallId).toBe("cmd-observed");
|
||||
});
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
type HeartbeatToolResponse,
|
||||
type MessagingToolSend,
|
||||
type MessagingToolSourceReplyPayload,
|
||||
type ToolHookRunContext,
|
||||
type ToolProgressDetailMode,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { emitTrustedDiagnosticEvent } from "openclaw/plugin-sdk/diagnostic-runtime";
|
||||
@@ -54,6 +53,7 @@ import { attachCodexMirrorIdentity, buildCodexUserPromptMessage } from "./transc
|
||||
|
||||
export type CodexAppServerToolTelemetry = {
|
||||
didSendViaMessagingTool: boolean;
|
||||
didDeliverSourceReplyViaMessageTool?: boolean;
|
||||
messagingToolSentTexts: string[];
|
||||
messagingToolSentMediaUrls: string[];
|
||||
messagingToolSentTargets: MessagingToolSend[];
|
||||
@@ -66,7 +66,6 @@ export type CodexAppServerToolTelemetry = {
|
||||
|
||||
export type CodexAppServerEventProjectorOptions = {
|
||||
nativePostToolUseRelayEnabled?: boolean;
|
||||
toolHookContext?: ToolHookRunContext;
|
||||
onNativeToolResultRecorded?: () => void | Promise<void>;
|
||||
trajectoryRecorder?: CodexTrajectoryRecorder | null;
|
||||
};
|
||||
@@ -190,6 +189,7 @@ export class CodexAppServerEventProjector {
|
||||
private readonly toolTrajectoryItemsById = new Map<string, CodexThreadItem>();
|
||||
private readonly transcriptToolProgressCallIds = new Set<string>();
|
||||
private lastNativeToolError: EmbeddedRunAttemptResult["lastToolError"];
|
||||
private readonly nativeGeneratedMediaUrls = new Set<string>();
|
||||
private readonly nativeGeneratedMediaItemIds = new Set<string>();
|
||||
private readonly nativeGeneratedMediaUrlsByItemId = new Map<string, string>();
|
||||
private readonly diagnosticToolStartedAtByItem = new Map<string, number>();
|
||||
@@ -413,6 +413,8 @@ export class CodexAppServerEventProjector {
|
||||
currentAttemptAssistant,
|
||||
...(this.lastNativeToolError ? { lastToolError: this.lastNativeToolError } : {}),
|
||||
didSendViaMessagingTool: toolTelemetry.didSendViaMessagingTool,
|
||||
didDeliverSourceReplyViaMessageTool:
|
||||
toolTelemetry.didDeliverSourceReplyViaMessageTool === true,
|
||||
messagingToolSentTexts: toolTelemetry.messagingToolSentTexts,
|
||||
messagingToolSentMediaUrls: toolTelemetry.messagingToolSentMediaUrls,
|
||||
messagingToolSentTargets: toolTelemetry.messagingToolSentTargets,
|
||||
@@ -1029,9 +1031,6 @@ export class CodexAppServerEventProjector {
|
||||
this.recordNativeGeneratedMediaUrl({
|
||||
itemId,
|
||||
mediaUrl: saved.path,
|
||||
// The typed savedPath may belong to a remote app-server host. Always
|
||||
// prefer the copy persisted into this gateway's managed media root.
|
||||
replaceExisting: true,
|
||||
});
|
||||
} catch (error) {
|
||||
embeddedAgentLog.warn("codex app-server raw image generation result save failed", {
|
||||
@@ -1041,19 +1040,13 @@ export class CodexAppServerEventProjector {
|
||||
}
|
||||
}
|
||||
|
||||
private recordNativeGeneratedMediaUrl(params: {
|
||||
itemId: string;
|
||||
mediaUrl: string;
|
||||
replaceExisting?: boolean;
|
||||
}): void {
|
||||
if (
|
||||
this.nativeGeneratedMediaUrlsByItemId.has(params.itemId) &&
|
||||
params.replaceExisting !== true
|
||||
) {
|
||||
private recordNativeGeneratedMediaUrl(params: { itemId: string; mediaUrl: string }): void {
|
||||
if (this.nativeGeneratedMediaUrlsByItemId.has(params.itemId)) {
|
||||
this.nativeGeneratedMediaItemIds.add(params.itemId);
|
||||
return;
|
||||
}
|
||||
this.nativeGeneratedMediaUrlsByItemId.set(params.itemId, params.mediaUrl);
|
||||
this.nativeGeneratedMediaUrls.add(params.mediaUrl);
|
||||
this.nativeGeneratedMediaItemIds.add(params.itemId);
|
||||
}
|
||||
|
||||
@@ -1062,7 +1055,7 @@ export class CodexAppServerEventProjector {
|
||||
toolTelemetry.toolMediaUrls?.map((url) => url.trim()).filter(Boolean) ?? [],
|
||||
);
|
||||
if ((toolTelemetry.messagingToolSentMediaUrls?.length ?? 0) === 0) {
|
||||
for (const mediaUrl of this.nativeGeneratedMediaUrlsByItemId.values()) {
|
||||
for (const mediaUrl of this.nativeGeneratedMediaUrls) {
|
||||
mediaUrls.add(mediaUrl);
|
||||
}
|
||||
}
|
||||
@@ -1376,9 +1369,6 @@ export class CodexAppServerEventProjector {
|
||||
agentId: this.params.agentId,
|
||||
sessionId: this.params.sessionId,
|
||||
sessionKey: this.params.sessionKey,
|
||||
// The attempt boundary resolves aliases and sandbox session identity once.
|
||||
// Keep that canonical snapshot authoritative over optional raw projector params.
|
||||
...this.options.toolHookContext,
|
||||
startArgs: itemToolArgs(item) ?? {},
|
||||
...(result !== undefined ? { result } : {}),
|
||||
...(error ? { error } : {}),
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
type EmbeddedRunAttemptParams,
|
||||
type NativeHookRelayEvent,
|
||||
type NativeHookRelayRegistrationHandle,
|
||||
type ToolHookRunContext,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
addTimerTimeoutGraceMs,
|
||||
@@ -122,7 +121,6 @@ export function createCodexNativeHookRelay(params: {
|
||||
config: EmbeddedRunAttemptParams["config"];
|
||||
runId: string;
|
||||
channelId?: string;
|
||||
toolHookContext?: ToolHookRunContext;
|
||||
attemptTimeoutMs: number;
|
||||
startupTimeoutMs: number;
|
||||
turnStartTimeoutMs: number;
|
||||
@@ -148,7 +146,6 @@ export function createCodexNativeHookRelay(params: {
|
||||
...(params.config ? { config: params.config } : {}),
|
||||
runId: params.runId,
|
||||
...(params.channelId ? { channelId: params.channelId } : {}),
|
||||
...(params.toolHookContext ? { toolHookContext: params.toolHookContext } : {}),
|
||||
allowedEvents: params.events,
|
||||
ttlMs: resolveCodexNativeHookRelayTtlMs({
|
||||
explicitTtlMs: params.options?.ttlMs,
|
||||
|
||||
@@ -91,9 +91,6 @@ const DEFAULT_COMPLETION_DELIVERY_RETRY_DELAYS_MS = [
|
||||
];
|
||||
const DEFAULT_TASK_ROW_RECONCILE_INTERVAL_MS = 10_000;
|
||||
const RECENT_TERMINAL_TASK_RECONCILE_GRACE_MS = 60_000;
|
||||
// Codex's recorder uses this filename contract; non-canonical names keep the
|
||||
// legacy substring fallback for older or test-created transcript files.
|
||||
const CODEX_ROLLOUT_FILENAME_RE = /^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-(.+)\.jsonl$/u;
|
||||
|
||||
const defaultRuntime: NativeSubagentMonitorRuntime = {
|
||||
createAgentHarnessTaskRuntime,
|
||||
@@ -1191,9 +1188,8 @@ async function findTranscriptPaths(params: {
|
||||
}): Promise<Map<string, string>> {
|
||||
const sessionsDir = path.join(params.codexHome, "sessions");
|
||||
const found = new Map<string, string>();
|
||||
const remaining = new Set(params.childThreadIds);
|
||||
const stack = [sessionsDir];
|
||||
while (stack.length > 0 && remaining.size > 0) {
|
||||
while (stack.length > 0 && found.size < params.childThreadIds.size) {
|
||||
const dir = stack.pop()!;
|
||||
let entries: Array<{ name: string; isDirectory(): boolean; isFile(): boolean }>;
|
||||
try {
|
||||
@@ -1210,20 +1206,10 @@ async function findTranscriptPaths(params: {
|
||||
if (!entry.isFile() || !entry.name.endsWith(".jsonl")) {
|
||||
continue;
|
||||
}
|
||||
const rolloutMatch = entry.name.match(CODEX_ROLLOUT_FILENAME_RE);
|
||||
if (rolloutMatch) {
|
||||
const childThreadId = rolloutMatch[1];
|
||||
if (remaining.delete(childThreadId)) {
|
||||
for (const childThreadId of params.childThreadIds) {
|
||||
if (!found.has(childThreadId) && entry.name.includes(childThreadId)) {
|
||||
found.set(childThreadId, entryPath);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (const childThreadId of remaining) {
|
||||
if (entry.name.includes(childThreadId)) {
|
||||
found.set(childThreadId, entryPath);
|
||||
remaining.delete(childThreadId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1250,13 +1236,10 @@ async function findTranscriptPath(params: {
|
||||
stack.push(entryPath);
|
||||
continue;
|
||||
}
|
||||
const rolloutMatch = entry.name.match(CODEX_ROLLOUT_FILENAME_RE);
|
||||
if (
|
||||
entry.isFile() &&
|
||||
entry.name.endsWith(".jsonl") &&
|
||||
(rolloutMatch
|
||||
? rolloutMatch[1] === params.childThreadId
|
||||
: entry.name.includes(params.childThreadId))
|
||||
entry.name.includes(params.childThreadId)
|
||||
) {
|
||||
return entryPath;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// Codex tests cover run attemptynamic tools plugin behavior.
|
||||
import path from "node:path";
|
||||
import { onAgentEvent, type AgentEventPayload } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
onAgentEvent,
|
||||
type AgentEventPayload,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
emitTrustedDiagnosticEvent,
|
||||
onInternalDiagnosticEvent,
|
||||
@@ -606,21 +609,6 @@ describe("runCodexAppServerAttempt dynamic tools", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("prefers the current messaging target for hook channel fallback", () => {
|
||||
const params = createParams(
|
||||
path.join(tempDir, "session.jsonl"),
|
||||
path.join(tempDir, "workspace"),
|
||||
);
|
||||
params.messageChannel = "telegram";
|
||||
params.messageProvider = "telegram";
|
||||
params.messageTo = "telegram:stale-target";
|
||||
params.currentMessagingTarget = "telegram:current-target";
|
||||
|
||||
expect(testing.resolveCodexAppServerHookChannelId(params, "agent:main:session-1")).toBe(
|
||||
"current-target",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes normalized channel context to app-server dynamic tool result hooks", async () => {
|
||||
const afterToolCall = vi.fn();
|
||||
initializeGlobalHookRunner(
|
||||
|
||||
@@ -30,7 +30,9 @@ const DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT = JSON.stringify({
|
||||
web_search: "disabled",
|
||||
});
|
||||
|
||||
function writeCodexAppServerBinding(...args: Parameters<typeof writeRawCodexAppServerBinding>) {
|
||||
function writeCodexAppServerBinding(
|
||||
...args: Parameters<typeof writeRawCodexAppServerBinding>
|
||||
) {
|
||||
const [sessionFile, binding, lookup] = args;
|
||||
return writeRawCodexAppServerBinding(
|
||||
sessionFile,
|
||||
@@ -93,15 +95,7 @@ describe("runCodexAppServerAttempt native hook relay", () => {
|
||||
const harness = createStartedThreadHarness();
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.messageChannel = "discord";
|
||||
params.messageProvider = "discord-voice";
|
||||
params.currentChannelId = "channel:target";
|
||||
params.trigger = "user";
|
||||
params.senderId = "user-1";
|
||||
params.chatId = "native-target";
|
||||
params.channelContext = {
|
||||
sender: { id: "user-1", providerUserId: "discord-user-1" },
|
||||
chat: { id: "native-target", guildId: "guild-1" },
|
||||
};
|
||||
|
||||
const run = runCodexAppServerAttempt(params, {
|
||||
nativeHookRelay: {
|
||||
@@ -141,22 +135,6 @@ describe("runCodexAppServerAttempt native hook relay", () => {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
autoApprove: true,
|
||||
toolHookContext: {
|
||||
agentId: "main",
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
runId: "run-1",
|
||||
trigger: "user",
|
||||
messageProvider: "discord-voice",
|
||||
channel: "discord",
|
||||
channelId: "target",
|
||||
chatId: "native-target",
|
||||
senderId: "user-1",
|
||||
channelContext: {
|
||||
sender: { id: "user-1", providerUserId: "discord-user-1" },
|
||||
chat: { id: "native-target", guildId: "guild-1" },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(approvalArgs?.nativeHookRelay).toMatchObject({
|
||||
relayId,
|
||||
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
type EmbeddedRunAttemptResult,
|
||||
type NativeHookRelayEvent,
|
||||
type NativeHookRelayRegistrationHandle,
|
||||
type ToolHookRunContext,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import {
|
||||
@@ -249,7 +248,6 @@ import {
|
||||
type CodexAppServerThreadLifecycleBinding,
|
||||
type CodexContextEngineThreadBootstrapProjection,
|
||||
} from "./thread-lifecycle.js";
|
||||
import { buildCodexToolHookRunContext } from "./tool-hook-context.js";
|
||||
import {
|
||||
inferCodexDynamicToolMeta,
|
||||
resolveCodexToolProgressDetailMode,
|
||||
@@ -719,14 +717,6 @@ export async function runCodexAppServerAttempt(
|
||||
});
|
||||
}
|
||||
const hookChannelId = resolveCodexAppServerHookChannelId(params, sandboxSessionKey);
|
||||
const toolHookRunContext = buildCodexToolHookRunContext({
|
||||
attempt: params,
|
||||
agentId: sessionAgentId,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: sandboxSessionKey,
|
||||
runId: params.runId,
|
||||
channelId: hookChannelId,
|
||||
});
|
||||
preDynamicStartupStages.mark("context-engine-support");
|
||||
const preDynamicSummary = preDynamicStartupStages.snapshot();
|
||||
if (shouldWarnCodexDynamicToolBuildStageSummary(preDynamicSummary)) {
|
||||
@@ -842,14 +832,20 @@ export async function runCodexAppServerAttempt(
|
||||
}),
|
||||
directToolNames: resolveCodexDynamicToolDirectNames(params),
|
||||
hookContext: {
|
||||
...toolHookRunContext,
|
||||
agentId: sessionAgentId,
|
||||
config: params.config,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: sandboxSessionKey,
|
||||
runId: params.runId,
|
||||
channelId: hookChannelId,
|
||||
currentChannelProvider: resolveCodexMessageToolProvider(params),
|
||||
currentChannelId: params.currentChannelId,
|
||||
currentMessagingTarget: params.currentMessagingTarget,
|
||||
currentMessageId: params.currentMessageId,
|
||||
currentThreadId: params.currentThreadTs,
|
||||
replyToMode: params.replyToMode,
|
||||
hasRepliedRef: params.hasRepliedRef,
|
||||
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
|
||||
onToolOutcome: onCodexToolOutcome,
|
||||
allocateToolOutcomeOrdinal: allocateCodexToolOutcomeOrdinal,
|
||||
},
|
||||
@@ -1450,7 +1446,6 @@ export async function runCodexAppServerAttempt(
|
||||
config: params.config,
|
||||
runId: params.runId,
|
||||
channelId: hookChannelId,
|
||||
toolHookContext: toolHookRunContext,
|
||||
attemptTimeoutMs: params.timeoutMs,
|
||||
startupTimeoutMs,
|
||||
turnStartTimeoutMs: params.timeoutMs,
|
||||
@@ -2157,7 +2152,6 @@ export async function runCodexAppServerAttempt(
|
||||
method: request.method,
|
||||
params: request.params,
|
||||
paramsForRun: params,
|
||||
toolHookContext: toolHookRunContext,
|
||||
threadId: thread.threadId,
|
||||
turnId,
|
||||
nativeHookRelay,
|
||||
@@ -2769,7 +2763,6 @@ export async function runCodexAppServerAttempt(
|
||||
nativePostToolUseRelayEnabled:
|
||||
nativeHookRelay?.allowedEvents.includes("post_tool_use") === true &&
|
||||
nativeHookRelay.shouldRelayEvent("post_tool_use"),
|
||||
toolHookContext: toolHookRunContext,
|
||||
trajectoryRecorder,
|
||||
onNativeToolResultRecorded: maybeAnnounceFastModeAutoOff,
|
||||
},
|
||||
@@ -3439,7 +3432,6 @@ function handleApprovalRequest(params: {
|
||||
method: string;
|
||||
params: JsonValue | undefined;
|
||||
paramsForRun: EmbeddedRunAttemptParams;
|
||||
toolHookContext: ToolHookRunContext;
|
||||
threadId: string;
|
||||
turnId: string;
|
||||
nativeHookRelay?: NativeHookRelayRegistrationHandle;
|
||||
@@ -3453,7 +3445,6 @@ function handleApprovalRequest(params: {
|
||||
method: params.method,
|
||||
requestParams: params.params,
|
||||
paramsForRun: params.paramsForRun,
|
||||
toolHookContext: params.toolHookContext,
|
||||
threadId: params.threadId,
|
||||
turnId: params.turnId,
|
||||
nativeHookRelay: params.nativeHookRelay,
|
||||
|
||||
@@ -861,12 +861,9 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
).toMatchObject({
|
||||
agentId: "main",
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:runtime-policy",
|
||||
sessionKey: "agent:main:session-1",
|
||||
runId: "run-side-1",
|
||||
channelId: "voice-room",
|
||||
toolHookContext: {
|
||||
sessionKey: "agent:main:runtime-policy",
|
||||
},
|
||||
allowedEvents: ["pre_tool_use", "post_tool_use", "before_agent_finalize"],
|
||||
});
|
||||
return threadResult("side-thread");
|
||||
@@ -892,7 +889,6 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
runCodexAppServerSideQuestion(
|
||||
sideParams({
|
||||
sessionKey: "agent:main:session-1",
|
||||
sandboxSessionKey: "agent:main:runtime-policy",
|
||||
messageChannel: "discord",
|
||||
messageProvider: "discord-voice",
|
||||
currentChannelId: "discord:voice-room",
|
||||
@@ -975,7 +971,6 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
runCodexAppServerSideQuestion(
|
||||
sideParams({
|
||||
sessionKey: "agent:main:session-1",
|
||||
sandboxSessionKey: "agent:main:runtime-policy",
|
||||
messageChannel: "discord",
|
||||
messageProvider: "discord-voice",
|
||||
opts: { runId: "run-side-approval" },
|
||||
@@ -993,7 +988,6 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
threadId?: string;
|
||||
turnId?: string;
|
||||
paramsForRun?: { messageChannel?: string; messageProvider?: string };
|
||||
toolHookContext?: { sessionKey?: string };
|
||||
nativeHookRelay?: { relayId?: string; allowedEvents?: readonly string[] };
|
||||
}
|
||||
| undefined;
|
||||
@@ -1013,9 +1007,6 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
messageChannel: "discord",
|
||||
messageProvider: "discord-voice",
|
||||
},
|
||||
toolHookContext: {
|
||||
sessionKey: "agent:main:runtime-policy",
|
||||
},
|
||||
});
|
||||
expect(approvalArgs?.nativeHookRelay).toMatchObject({
|
||||
relayId: relayIdDuringFork,
|
||||
@@ -1491,14 +1482,6 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
});
|
||||
|
||||
it("bridges side-thread dynamic tool requests to OpenClaw tools", async () => {
|
||||
const beforeToolCall = vi.fn();
|
||||
const afterToolCall = vi.fn();
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([
|
||||
{ hookName: "before_tool_call", handler: beforeToolCall },
|
||||
{ hookName: "after_tool_call", handler: afterToolCall },
|
||||
]),
|
||||
);
|
||||
const client = createFakeClient();
|
||||
let toolResponse: unknown;
|
||||
client.request.mockImplementation(async (method: string) => {
|
||||
@@ -1544,13 +1527,6 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
expect(toolArguments).toEqual({ topic: "AGENTS.md" });
|
||||
expect(toolSignal).toBeInstanceOf(AbortSignal);
|
||||
expect(toolOptions).toBeUndefined();
|
||||
expect(beforeToolCall).toHaveBeenCalledTimes(1);
|
||||
expect(mockCall(beforeToolCall)[1]).toMatchObject({ sessionKey: "session-1" });
|
||||
await vi.waitFor(() => expect(afterToolCall).toHaveBeenCalledTimes(1));
|
||||
expect(mockCall(afterToolCall)[1]).toMatchObject({ sessionKey: "session-1" });
|
||||
expect(createOpenClawCodingToolsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sessionKey: "session-1" }),
|
||||
);
|
||||
expect(toolResponse).toEqual({
|
||||
success: true,
|
||||
contentItems: [{ type: "inputText", text: "tool output" }],
|
||||
@@ -1634,29 +1610,14 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
expect(activeDiagnosticToolKeys(diagnosticEvents)).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("preserves requester identity while normalizing side-thread hook channels", async () => {
|
||||
const afterToolCall = vi.fn();
|
||||
it("normalizes hook channel ids for side-thread dynamic tool requests", async () => {
|
||||
const beforeToolCall = vi.fn((...args: unknown[]) => {
|
||||
const context = args[1] as Record<string, unknown>;
|
||||
expect(context).toMatchObject({
|
||||
sessionKey: "agent:main:runtime-policy",
|
||||
messageProvider: "discord-voice",
|
||||
channel: "discord",
|
||||
channelId: "voice-room",
|
||||
chatId: "native-voice-chat",
|
||||
senderId: "sender-1",
|
||||
channelContext: {
|
||||
sender: { id: "sender-1", providerUserId: "discord-user-1" },
|
||||
chat: { id: "native-voice-chat", guildId: "guild-1" },
|
||||
},
|
||||
});
|
||||
const context = args[1] as { channelId?: string };
|
||||
expect(context.channelId).toBe("voice-room");
|
||||
return undefined;
|
||||
});
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([
|
||||
{ hookName: "before_tool_call", handler: beforeToolCall },
|
||||
{ hookName: "after_tool_call", handler: afterToolCall },
|
||||
]),
|
||||
createMockPluginRegistry([{ hookName: "before_tool_call", handler: beforeToolCall }]),
|
||||
);
|
||||
const client = createFakeClient();
|
||||
client.request.mockImplementation(async (method: string) => {
|
||||
@@ -1696,48 +1657,17 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
await expect(
|
||||
runCodexAppServerSideQuestion(
|
||||
sideParams({
|
||||
sessionKey: "agent:main:conversation",
|
||||
sandboxSessionKey: "agent:main:runtime-policy",
|
||||
messageChannel: "discord",
|
||||
messageProvider: "discord-voice",
|
||||
currentChannelId: "discord:voice-room",
|
||||
chatId: "native-voice-chat",
|
||||
senderId: "sender-1",
|
||||
channelContext: {
|
||||
sender: { id: "sender-1", providerUserId: "discord-user-1" },
|
||||
chat: { id: "native-voice-chat", guildId: "guild-1" },
|
||||
},
|
||||
}),
|
||||
),
|
||||
).resolves.toEqual({ text: "Tool answer." });
|
||||
|
||||
expect(beforeToolCall).toHaveBeenCalledTimes(1);
|
||||
await vi.waitFor(() => expect(afterToolCall).toHaveBeenCalledTimes(1));
|
||||
expect(mockCall(afterToolCall)[1]).toMatchObject({
|
||||
sessionKey: "agent:main:runtime-policy",
|
||||
messageProvider: "discord-voice",
|
||||
channel: "discord",
|
||||
channelId: "voice-room",
|
||||
chatId: "native-voice-chat",
|
||||
});
|
||||
expect(createOpenClawCodingToolsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionKey: "agent:main:runtime-policy",
|
||||
runSessionKey: "agent:main:conversation",
|
||||
messageChannel: "discord",
|
||||
messageProvider: "discord",
|
||||
toolPolicyMessageProvider: "discord-voice",
|
||||
hookChannelId: "voice-room",
|
||||
chatId: "native-voice-chat",
|
||||
hookChannelContext: {
|
||||
sender: { id: "sender-1", providerUserId: "discord-user-1" },
|
||||
chat: { id: "native-voice-chat", guildId: "guild-1" },
|
||||
},
|
||||
}),
|
||||
expect.objectContaining({ hookChannelId: "voice-room" }),
|
||||
);
|
||||
expect(
|
||||
(mockCall(createOpenClawCodingToolsMock)[0] as { channelContext?: unknown }).channelContext,
|
||||
).toBeUndefined();
|
||||
expect(toolExecuteMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Codex plugin module implements side question behavior.
|
||||
import {
|
||||
buildAgentHookContextChannelFields,
|
||||
embeddedAgentLog,
|
||||
formatErrorMessage,
|
||||
resolveAgentDir,
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
type EmbeddedRunAttemptParams,
|
||||
type NativeHookRelayEvent,
|
||||
type NativeHookRelayRegistrationHandle,
|
||||
type ToolHookRunContext,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { loadExecApprovals } from "openclaw/plugin-sdk/exec-approvals-runtime";
|
||||
import { resolveCodexAppServerForModelProvider } from "./app-server-policy.js";
|
||||
@@ -89,7 +89,6 @@ import {
|
||||
resolveCodexBindingModelProviderFallback,
|
||||
resolveReasoningEffort,
|
||||
} from "./thread-lifecycle.js";
|
||||
import { buildCodexToolHookRunContext } from "./tool-hook-context.js";
|
||||
import { filterToolsForVisionInputs } from "./vision-tools.js";
|
||||
import {
|
||||
resolveCodexWebSearchPlan,
|
||||
@@ -207,21 +206,9 @@ export async function runCodexAppServerSideQuestion(
|
||||
});
|
||||
const cwd = binding.cwd || params.workspaceDir || process.cwd();
|
||||
const sideRunParams = buildSideRunAttemptParams(params, { cwd, authProfileId });
|
||||
const toolHookSessionKey =
|
||||
sideRunParams.sandboxSessionKey?.trim() ||
|
||||
sideRunParams.sessionKey?.trim() ||
|
||||
sideRunParams.sessionId ||
|
||||
sessionAgentId;
|
||||
const toolHookRunContext = buildCodexToolHookRunContext({
|
||||
attempt: sideRunParams,
|
||||
agentId: sessionAgentId,
|
||||
sessionId: sideRunParams.sessionId,
|
||||
sessionKey: toolHookSessionKey,
|
||||
runId: sideRunParams.runId,
|
||||
});
|
||||
const nativeExecutionBlock = resolveCodexNativeExecutionBlock({
|
||||
config: sideRunParams.config,
|
||||
sessionKey: toolHookSessionKey,
|
||||
sessionKey: sideRunParams.sandboxSessionKey?.trim() || sideRunParams.sessionKey,
|
||||
sessionId: sideRunParams.sessionId,
|
||||
surface: "/btw side-question mode",
|
||||
});
|
||||
@@ -300,7 +287,6 @@ export async function runCodexAppServerSideQuestion(
|
||||
nativeToolSurfaceEnabled,
|
||||
nativeProviderWebSearchSupport,
|
||||
signal: runAbortController.signal,
|
||||
toolHookContext: toolHookRunContext,
|
||||
});
|
||||
removeRequestHandler = client.addRequestHandler(async (request) => {
|
||||
if (request.method === "account/chatgptAuthTokens/refresh") {
|
||||
@@ -333,20 +319,19 @@ export async function runCodexAppServerSideQuestion(
|
||||
method: request.method,
|
||||
requestParams: request.params,
|
||||
paramsForRun: sideRunParams,
|
||||
toolHookContext: toolHookRunContext,
|
||||
threadId: childThreadId,
|
||||
turnId,
|
||||
nativeHookRelay,
|
||||
execPolicy,
|
||||
execReviewerAgentId: sessionAgentId,
|
||||
internalExecAutoReview: modelScopedAppServer.approvalsReviewer === "user",
|
||||
autoApprove: shouldAutoApproveCodexAppServerApprovals({
|
||||
approvalPolicy,
|
||||
networkProxy: modelScopedAppServer.networkProxy,
|
||||
sandbox,
|
||||
}),
|
||||
signal: runAbortController.signal,
|
||||
});
|
||||
execPolicy,
|
||||
execReviewerAgentId: sessionAgentId,
|
||||
internalExecAutoReview: modelScopedAppServer.approvalsReviewer === "user",
|
||||
autoApprove: shouldAutoApproveCodexAppServerApprovals({
|
||||
approvalPolicy,
|
||||
networkProxy: modelScopedAppServer.networkProxy,
|
||||
sandbox,
|
||||
}),
|
||||
signal: runAbortController.signal,
|
||||
});
|
||||
}
|
||||
if (request.method !== "item/tool/call") {
|
||||
return undefined;
|
||||
@@ -403,11 +388,15 @@ export async function runCodexAppServerSideQuestion(
|
||||
events: nativeHookRelayEvents,
|
||||
agentId: sessionAgentId,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: toolHookRunContext.sessionKey,
|
||||
sessionKey: params.sessionKey,
|
||||
config: params.cfg,
|
||||
runId: sideRunParams.runId,
|
||||
channelId: toolHookRunContext.channelId,
|
||||
toolHookContext: toolHookRunContext,
|
||||
channelId: buildAgentHookContextChannelFields({
|
||||
sessionKey: params.sessionKey,
|
||||
messageChannel: params.messageChannel,
|
||||
messageProvider: params.messageProvider,
|
||||
currentChannelId: params.currentChannelId,
|
||||
}).channelId,
|
||||
requestTimeoutMs: appServer.requestTimeoutMs,
|
||||
completionTimeoutMs: Math.max(
|
||||
appServer.turnCompletionIdleTimeoutMs,
|
||||
@@ -430,12 +419,12 @@ export async function runCodexAppServerSideQuestion(
|
||||
nativeCodeModeEnabled: nativeToolSurfaceEnabled,
|
||||
nativeCodeModeOnlyEnabled: appServer.codeModeOnly,
|
||||
});
|
||||
const threadConfig =
|
||||
mergeCodexThreadConfigs(
|
||||
nativeHookRelayConfig,
|
||||
runtimeThreadConfig,
|
||||
modelScopedAppServer.networkProxy?.configPatch,
|
||||
) ?? runtimeThreadConfig;
|
||||
const threadConfig =
|
||||
mergeCodexThreadConfigs(
|
||||
nativeHookRelayConfig,
|
||||
runtimeThreadConfig,
|
||||
modelScopedAppServer.networkProxy?.configPatch,
|
||||
) ?? runtimeThreadConfig;
|
||||
const forkResponse = assertCodexThreadForkResponse(
|
||||
await forkCodexSideThread(
|
||||
client,
|
||||
@@ -447,7 +436,7 @@ export async function runCodexAppServerSideQuestion(
|
||||
cwd,
|
||||
approvalPolicy,
|
||||
approvalsReviewer: modelScopedAppServer.approvalsReviewer,
|
||||
...(modelScopedAppServer.networkProxy ? {} : { sandbox }),
|
||||
...(modelScopedAppServer.networkProxy ? {} : { sandbox }),
|
||||
...(serviceTier ? { serviceTier } : {}),
|
||||
config: threadConfig,
|
||||
developerInstructions: SIDE_DEVELOPER_INSTRUCTIONS,
|
||||
@@ -553,7 +542,6 @@ function registerCodexSideNativeHookRelay(params: {
|
||||
config: EmbeddedRunAttemptParams["config"];
|
||||
runId: string;
|
||||
channelId?: string;
|
||||
toolHookContext?: ToolHookRunContext;
|
||||
requestTimeoutMs: number;
|
||||
completionTimeoutMs: number;
|
||||
signal: AbortSignal;
|
||||
@@ -569,7 +557,6 @@ function registerCodexSideNativeHookRelay(params: {
|
||||
...(params.config ? { config: params.config } : {}),
|
||||
runId: params.runId,
|
||||
...(params.channelId ? { channelId: params.channelId } : {}),
|
||||
...(params.toolHookContext ? { toolHookContext: params.toolHookContext } : {}),
|
||||
allowedEvents: params.events,
|
||||
ttlMs: resolveCodexSideNativeHookRelayTtlMs({
|
||||
explicitTtlMs: params.options.ttlMs,
|
||||
@@ -609,7 +596,6 @@ function buildSideRunAttemptParams(
|
||||
provider: params.provider,
|
||||
modelId: params.model,
|
||||
model: params.runtimeModel ?? ({ id: params.model, provider: params.provider } as never),
|
||||
trigger: "user" as const,
|
||||
sessionId: params.sessionId,
|
||||
sessionFile: params.sessionFile,
|
||||
sessionKey: params.sessionKey,
|
||||
@@ -630,8 +616,6 @@ function buildSideRunAttemptParams(
|
||||
...(params.senderUsername !== undefined ? { senderUsername: params.senderUsername } : {}),
|
||||
...(params.senderE164 !== undefined ? { senderE164: params.senderE164 } : {}),
|
||||
...(params.senderIsOwner !== undefined ? { senderIsOwner: params.senderIsOwner } : {}),
|
||||
...(params.chatId ? { chatId: params.chatId } : {}),
|
||||
...(params.channelContext ? { channelContext: params.channelContext } : {}),
|
||||
...(params.currentChannelId ? { currentChannelId: params.currentChannelId } : {}),
|
||||
...(params.toolsAllow ? { toolsAllow: params.toolsAllow } : {}),
|
||||
workspaceDir: options.cwd,
|
||||
@@ -663,7 +647,6 @@ async function createCodexSideToolBridge(input: {
|
||||
nativeToolSurfaceEnabled: boolean;
|
||||
nativeProviderWebSearchSupport: CodexNativeWebSearchSupport;
|
||||
signal: AbortSignal;
|
||||
toolHookContext: ToolHookRunContext;
|
||||
}): Promise<{ toolBridge: CodexDynamicToolBridge; webSearchPlan: CodexWebSearchPlan }> {
|
||||
const runtimeModel =
|
||||
input.params.runtimeModel ??
|
||||
@@ -674,7 +657,10 @@ async function createCodexSideToolBridge(input: {
|
||||
const createOpenClawCodingTools = (await import("openclaw/plugin-sdk/agent-harness"))
|
||||
.createOpenClawCodingTools;
|
||||
const sandboxSessionKey =
|
||||
input.toolHookContext.sessionKey || input.params.sessionId || input.sessionAgentId;
|
||||
input.params.sandboxSessionKey?.trim() ||
|
||||
input.params.sessionKey?.trim() ||
|
||||
input.params.sessionId ||
|
||||
input.sessionAgentId;
|
||||
const sandbox = await resolveSandboxContext({
|
||||
config: input.params.cfg,
|
||||
sessionKey: sandboxSessionKey,
|
||||
@@ -710,9 +696,6 @@ async function createCodexSideToolBridge(input: {
|
||||
workspaceDir: input.cwd,
|
||||
}),
|
||||
suppressManagedWebSearch: false,
|
||||
trigger: input.toolHookContext.trigger,
|
||||
jobId: input.toolHookContext.jobId,
|
||||
messageChannel: input.params.messageChannel,
|
||||
...(input.params.messageProvider || input.params.messageChannel
|
||||
? {
|
||||
messageProvider: messageToolProvider,
|
||||
@@ -732,8 +715,6 @@ async function createCodexSideToolBridge(input: {
|
||||
...(input.params.memberRoleIds ? { memberRoleIds: input.params.memberRoleIds } : {}),
|
||||
...(input.params.spawnedBy !== undefined ? { spawnedBy: input.params.spawnedBy } : {}),
|
||||
...(input.params.senderId !== undefined ? { senderId: input.params.senderId } : {}),
|
||||
chatId: input.toolHookContext.chatId,
|
||||
hookChannelContext: input.toolHookContext.channelContext,
|
||||
...(input.params.senderName !== undefined ? { senderName: input.params.senderName } : {}),
|
||||
...(input.params.senderUsername !== undefined
|
||||
? { senderUsername: input.params.senderUsername }
|
||||
@@ -743,7 +724,12 @@ async function createCodexSideToolBridge(input: {
|
||||
? { senderIsOwner: input.params.senderIsOwner }
|
||||
: {}),
|
||||
...(input.params.currentChannelId ? { currentChannelId: input.params.currentChannelId } : {}),
|
||||
hookChannelId: input.toolHookContext.channelId,
|
||||
hookChannelId: buildAgentHookContextChannelFields({
|
||||
sessionKey: input.params.sessionKey,
|
||||
messageChannel: input.params.messageChannel,
|
||||
messageProvider: input.params.messageProvider,
|
||||
currentChannelId: input.params.currentChannelId,
|
||||
}).channelId,
|
||||
sandbox,
|
||||
emitBeforeToolCallDiagnostics: false,
|
||||
modelHasVision: runtimeModel.input?.includes("image") ?? false,
|
||||
@@ -771,15 +757,25 @@ async function createCodexSideToolBridge(input: {
|
||||
})
|
||||
: requestedWebSearchPlan;
|
||||
const exposedTools = tools.filter((tool) => tool.name !== "web_search");
|
||||
const hookChannelFields = buildAgentHookContextChannelFields({
|
||||
sessionKey: input.params.sessionKey,
|
||||
messageChannel: input.params.messageChannel,
|
||||
messageProvider: input.params.messageProvider,
|
||||
currentChannelId: input.params.currentChannelId,
|
||||
});
|
||||
return {
|
||||
toolBridge: createCodexDynamicToolBridge({
|
||||
tools: exposedTools,
|
||||
signal: input.signal,
|
||||
loading: resolveCodexDynamicToolsLoading(input.pluginConfig),
|
||||
hookContext: {
|
||||
...input.toolHookContext,
|
||||
agentId: input.sessionAgentId,
|
||||
config: input.params.cfg,
|
||||
sessionId: input.params.sessionId,
|
||||
sessionKey: input.params.sessionKey,
|
||||
runId: input.params.opts?.runId ?? `codex-btw:${input.params.sessionId}`,
|
||||
currentChannelProvider: messageToolProvider,
|
||||
...hookChannelFields,
|
||||
},
|
||||
}),
|
||||
webSearchPlan,
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
/** Builds one canonical requester-origin snapshot for Codex tool hook paths. */
|
||||
import {
|
||||
buildAgentHookContextOriginFields,
|
||||
type EmbeddedRunAttemptParams,
|
||||
type ToolHookRunContext,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
|
||||
/** Build the plain run metadata shared by Codex before/after tool hook owners. */
|
||||
export function buildCodexToolHookRunContext(params: {
|
||||
attempt: EmbeddedRunAttemptParams;
|
||||
agentId?: string;
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
runId?: string;
|
||||
channelId?: string;
|
||||
}): ToolHookRunContext {
|
||||
const attempt = params.attempt;
|
||||
const agentId = params.agentId ?? attempt.agentId;
|
||||
const sessionKey = params.sessionKey ?? attempt.sessionKey;
|
||||
const sessionId = params.sessionId ?? attempt.sessionId;
|
||||
const runId = params.runId ?? attempt.runId;
|
||||
return {
|
||||
...(agentId ? { agentId } : {}),
|
||||
...(sessionKey ? { sessionKey } : {}),
|
||||
...(sessionId ? { sessionId } : {}),
|
||||
...(runId ? { runId } : {}),
|
||||
...(attempt.jobId ? { jobId: attempt.jobId } : {}),
|
||||
...(attempt.trigger ? { trigger: attempt.trigger } : {}),
|
||||
...buildAgentHookContextOriginFields({
|
||||
sessionKey,
|
||||
messageChannel: attempt.messageChannel,
|
||||
messageProvider: attempt.messageProvider ?? attempt.messageChannel,
|
||||
currentChannelId: params.channelId ?? attempt.currentChannelId,
|
||||
messageTo: attempt.currentMessagingTarget ?? attempt.messageTo,
|
||||
trigger: attempt.trigger,
|
||||
senderId: attempt.senderId,
|
||||
chatId: attempt.chatId,
|
||||
channelContext: attempt.channelContext,
|
||||
}),
|
||||
};
|
||||
}
|
||||
4
extensions/cohere/npm-shrinkwrap.json
generated
4
extensions/cohere/npm-shrinkwrap.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/cohere-provider",
|
||||
"version": "2026.6.10",
|
||||
"version": "2026.6.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/cohere-provider",
|
||||
"version": "2026.6.10"
|
||||
"version": "2026.6.9"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/cohere-provider",
|
||||
"version": "2026.6.10",
|
||||
"version": "2026.6.9",
|
||||
"description": "OpenClaw Cohere provider plugin.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -21,10 +21,10 @@
|
||||
"minHostVersion": ">=2026.6.8"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.10"
|
||||
"pluginApi": ">=2026.6.9"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.10",
|
||||
"openclawVersion": "2026.6.9",
|
||||
"bundledDist": true
|
||||
},
|
||||
"release": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/comfy-provider",
|
||||
"version": "2026.6.10",
|
||||
"version": "2026.6.9",
|
||||
"private": true,
|
||||
"description": "OpenClaw ComfyUI provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/copilot-proxy",
|
||||
"version": "2026.6.10",
|
||||
"version": "2026.6.9",
|
||||
"private": true,
|
||||
"description": "OpenClaw Copilot Proxy provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -10,11 +10,10 @@ openclaw plugins install @openclaw/copilot
|
||||
|
||||
Restart the Gateway after installing or updating the plugin.
|
||||
|
||||
The harness claims the canonical subscription `github-copilot` provider plus
|
||||
custom BYOK provider entries that the Copilot SDK can represent. Manifest-owned
|
||||
native provider ids stay with their owning runtimes. The harness is opt-in only:
|
||||
selection requires explicit `agentRuntime.id: "copilot"` on a model or provider
|
||||
entry; `auto` never picks it. PI remains the default embedded runtime.
|
||||
The harness claims the canonical subscription `github-copilot` provider and
|
||||
is opt-in only — selection requires explicit `agentRuntime.id: "copilot"`
|
||||
on a model or provider entry; `auto` never picks it. PI remains the default
|
||||
embedded runtime.
|
||||
|
||||
See [GitHub Copilot agent runtime](../../docs/plugins/copilot.md) for
|
||||
configuration, the doctor contract, transcript mirroring, compaction, side
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// Copilot tests cover harness plugin behavior.
|
||||
import { attachModelProviderRequestTransport } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
initializeGlobalHookRunner,
|
||||
resetGlobalHookRunner,
|
||||
@@ -8,12 +7,11 @@ import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtim
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CopilotClientPool } from "./harness.js";
|
||||
import { createCopilotAgentHarness, type CopilotSessionBinding } from "./harness.js";
|
||||
import { COPILOT_BYOK_PROVIDER_ERROR } from "./src/provider-bridge.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
runCopilotAttempt: vi.fn(),
|
||||
resolvePoolAcquire: vi.fn(
|
||||
(_params: any) =>
|
||||
() =>
|
||||
({
|
||||
auth: {
|
||||
agentId: "test",
|
||||
@@ -24,7 +22,6 @@ const mocks = vi.hoisted(() => ({
|
||||
options: { copilotHome: "/tmp/copilot", useLoggedInUser: true },
|
||||
}) as any,
|
||||
),
|
||||
createCopilotByokProxy: vi.fn(),
|
||||
createCopilotClientPool: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -33,10 +30,6 @@ vi.mock("./src/attempt.js", () => ({
|
||||
runCopilotAttempt: mocks.runCopilotAttempt,
|
||||
}));
|
||||
|
||||
vi.mock("./src/byok-proxy.js", () => ({
|
||||
createCopilotByokProxy: mocks.createCopilotByokProxy,
|
||||
}));
|
||||
|
||||
vi.mock("./src/runtime.js", () => ({
|
||||
createCopilotClientPool: mocks.createCopilotClientPool,
|
||||
}));
|
||||
@@ -93,7 +86,6 @@ describe("createCopilotAgentHarness", () => {
|
||||
beforeEach(() => {
|
||||
mocks.runCopilotAttempt.mockReset();
|
||||
mocks.resolvePoolAcquire.mockClear();
|
||||
mocks.createCopilotByokProxy.mockReset();
|
||||
mocks.createCopilotClientPool.mockReset();
|
||||
mocks.runCopilotAttempt.mockResolvedValue(ATTEMPT_RESULT);
|
||||
mocks.resolvePoolAcquire.mockReturnValue({
|
||||
@@ -106,7 +98,6 @@ describe("createCopilotAgentHarness", () => {
|
||||
options: { copilotHome: "/tmp/copilot", useLoggedInUser: true },
|
||||
});
|
||||
mocks.createCopilotClientPool.mockImplementation(() => makePoolMock());
|
||||
mocks.createCopilotByokProxy.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -189,81 +180,26 @@ describe("createCopilotAgentHarness", () => {
|
||||
).toEqual({ supported: true, priority: 100 });
|
||||
});
|
||||
|
||||
it("supports custom provider ids for BYOK model entries", () => {
|
||||
it("supports rejects providers outside the whitelist", () => {
|
||||
const harness = createCopilotAgentHarness();
|
||||
|
||||
expect(
|
||||
harness.supports({
|
||||
provider: "custom-proxy",
|
||||
modelId: "llama-3.1-8b",
|
||||
modelProvider: {
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
},
|
||||
providerOwnerStatus: "unowned",
|
||||
providerOwnerPluginIds: [],
|
||||
requestedRuntime: "copilot",
|
||||
}),
|
||||
).toEqual({ supported: true, priority: 100 });
|
||||
});
|
||||
|
||||
it("supports rejects custom provider ids without a supported BYOK model shape", () => {
|
||||
const harness = createCopilotAgentHarness();
|
||||
|
||||
expect(
|
||||
harness.supports({
|
||||
provider: "custom-proxy",
|
||||
modelId: "llama-3.1-8b",
|
||||
providerOwnerStatus: "unowned",
|
||||
providerOwnerPluginIds: [],
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4.5",
|
||||
requestedRuntime: "copilot",
|
||||
}),
|
||||
).toEqual({
|
||||
supported: false,
|
||||
reason:
|
||||
"provider is not a supported Copilot BYOK model (requires supported api, baseUrl, and no request transport policy overrides)",
|
||||
reason: "provider is not one of: github-copilot",
|
||||
});
|
||||
expect(
|
||||
harness.supports({
|
||||
provider: "custom-proxy",
|
||||
modelId: "llama-3.1-8b",
|
||||
modelProvider: {
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
request: { proxy: { mode: "env-proxy" } },
|
||||
},
|
||||
providerOwnerStatus: "unowned",
|
||||
providerOwnerPluginIds: [],
|
||||
requestedRuntime: "copilot",
|
||||
}),
|
||||
).toEqual({
|
||||
supported: false,
|
||||
reason:
|
||||
"provider is not a supported Copilot BYOK model (requires supported api, baseUrl, and no request transport policy overrides)",
|
||||
});
|
||||
});
|
||||
|
||||
it("supports rejects manifest-owned providers outside the whitelist", () => {
|
||||
const harness = createCopilotAgentHarness();
|
||||
|
||||
for (const [provider, ownerPluginIds] of [
|
||||
["anthropic", ["anthropic"]],
|
||||
["azure-openai-responses", ["openai"]],
|
||||
["deepinfra", ["deepinfra"]],
|
||||
["fireworks", ["fireworks"]],
|
||||
["github", ["github"]],
|
||||
["openclaw", ["openclaw"]],
|
||||
["sglang", ["sglang"]],
|
||||
["together", ["together"]],
|
||||
["vllm", ["vllm"]],
|
||||
] as const) {
|
||||
// Legacy aspirational ids should not be claimed by the harness.
|
||||
for (const legacyId of ["github", "openclaw", "copilot"]) {
|
||||
expect(
|
||||
harness.supports({
|
||||
provider,
|
||||
provider: legacyId,
|
||||
modelId: "gpt-4.1",
|
||||
requestedRuntime: "copilot",
|
||||
providerOwnerStatus: "owned",
|
||||
providerOwnerPluginIds: ownerPluginIds,
|
||||
}),
|
||||
).toEqual({
|
||||
supported: false,
|
||||
@@ -272,27 +208,6 @@ describe("createCopilotAgentHarness", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("supports rejects ambiguous custom provider ownership", () => {
|
||||
const harness = createCopilotAgentHarness();
|
||||
|
||||
expect(
|
||||
harness.supports({
|
||||
provider: "custom-proxy",
|
||||
modelId: "proxy-model",
|
||||
modelProvider: {
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
},
|
||||
requestedRuntime: "copilot",
|
||||
providerOwnerStatus: "ambiguous",
|
||||
providerOwnerPluginIds: ["first-owner", "second-owner"],
|
||||
}),
|
||||
).toEqual({
|
||||
supported: false,
|
||||
reason: "provider is not one of: github-copilot",
|
||||
});
|
||||
});
|
||||
|
||||
it("runAttempt lazy-imports attempt by waiting until invocation to create a pool", async () => {
|
||||
const pool = makePoolMock();
|
||||
mocks.createCopilotClientPool.mockReturnValue(pool);
|
||||
@@ -307,18 +222,6 @@ describe("createCopilotAgentHarness", () => {
|
||||
expect(mocks.runCopilotAttempt).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps invalid BYOK provider configuration on the structured attempt path", async () => {
|
||||
const pool = makePoolMock();
|
||||
mocks.createCopilotClientPool.mockReturnValue(pool);
|
||||
mocks.resolvePoolAcquire.mockImplementationOnce(() => {
|
||||
throw new Error(COPILOT_BYOK_PROVIDER_ERROR);
|
||||
});
|
||||
const harness = createCopilotAgentHarness();
|
||||
|
||||
await expect(harness.runAttempt(ATTEMPT_PARAMS)).resolves.toBe(ATTEMPT_RESULT);
|
||||
expect(mocks.runCopilotAttempt).toHaveBeenCalledWith(ATTEMPT_PARAMS, { pool });
|
||||
});
|
||||
|
||||
it("runAttempt creates one pool lazily and reuses it across two attempts on the same harness", async () => {
|
||||
const pool = makePoolMock();
|
||||
const firstResult = { attempt: 1 } as any;
|
||||
@@ -1283,88 +1186,6 @@ describe("createCopilotAgentHarness", () => {
|
||||
expect(secondCallParams.initialReplayState?.sdkSessionId).toBe("sdk-sess-sqlite");
|
||||
});
|
||||
|
||||
it("persists BYOK session compatibility with endpoint fingerprints instead of raw URLs", async () => {
|
||||
const sessionStore = makeSessionStoreMock();
|
||||
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
|
||||
deps.onSessionEstablished?.({
|
||||
sdkSessionId: "sdk-sess-byok",
|
||||
pooledClient: { key: {} as any, client: { deleteSession: vi.fn() } as any },
|
||||
sessionConfig: TEST_SESSION_CONFIG,
|
||||
});
|
||||
return ATTEMPT_RESULT;
|
||||
});
|
||||
const harness = createCopilotAgentHarness({
|
||||
pool: makePoolMock(),
|
||||
sessionStore: sessionStore.store,
|
||||
});
|
||||
|
||||
await harness.runAttempt(
|
||||
makeAttemptParams({
|
||||
provider: "custom-proxy",
|
||||
model: {
|
||||
provider: "custom-proxy",
|
||||
id: "proxy-model",
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://proxy.example/v1?routing=blue",
|
||||
},
|
||||
auth: undefined,
|
||||
authProfileId: "custom-proxy:main",
|
||||
resolvedApiKey: "byok-token",
|
||||
}),
|
||||
);
|
||||
|
||||
const stored = sessionStore.entries.get("oc-sess-reuse");
|
||||
expect(stored?.compatKey).toContain("baseUrlFingerprint=sha256:");
|
||||
expect(stored?.compatKey).not.toContain("proxy.example");
|
||||
expect(stored?.compatKey).not.toContain("routing=blue");
|
||||
});
|
||||
|
||||
it("does not reuse BYOK sessions when attached request auth mode changes", async () => {
|
||||
const pool = makePoolMock();
|
||||
const model = {
|
||||
provider: "custom-proxy",
|
||||
id: "proxy-model",
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
};
|
||||
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
|
||||
deps.onSessionEstablished?.({
|
||||
sdkSessionId: "sdk-sess-byok",
|
||||
pooledClient: { key: {} as any, client: { deleteSession: vi.fn() } as any },
|
||||
sessionConfig: TEST_SESSION_CONFIG,
|
||||
});
|
||||
return ATTEMPT_RESULT;
|
||||
});
|
||||
const harness = createCopilotAgentHarness({ pool });
|
||||
|
||||
await harness.runAttempt(
|
||||
makeAttemptParams({
|
||||
provider: "custom-proxy",
|
||||
model: attachModelProviderRequestTransport(model, { auth: { mode: "provider-default" } }),
|
||||
auth: undefined,
|
||||
authProfileId: "custom-proxy:main",
|
||||
resolvedApiKey: "byok-token",
|
||||
}),
|
||||
);
|
||||
await harness.runAttempt(
|
||||
makeAttemptParams({
|
||||
runId: "t2",
|
||||
provider: "custom-proxy",
|
||||
model: attachModelProviderRequestTransport(model, {
|
||||
auth: { mode: "header", headerName: "x-api-key", value: "byok-token" },
|
||||
}),
|
||||
auth: undefined,
|
||||
authProfileId: "custom-proxy:main",
|
||||
resolvedApiKey: "byok-token",
|
||||
}),
|
||||
);
|
||||
|
||||
const secondCallParams = mocks.runCopilotAttempt.mock.calls[1]?.[0] as {
|
||||
initialReplayState?: { sdkSessionId?: string };
|
||||
};
|
||||
expect(secondCallParams.initialReplayState?.sdkSessionId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resumes shipped schema v1 plugin-state bindings for attempts", async () => {
|
||||
const sessionStore = makeSessionStoreMock();
|
||||
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
|
||||
@@ -2065,148 +1886,6 @@ describe("createCopilotAgentHarness", () => {
|
||||
expect(matchingResult?.compacted).toBe(true);
|
||||
});
|
||||
|
||||
it("compacts tracked BYOK sessions from production compact params with a fresh proxy", async () => {
|
||||
const compact = vi.fn(async () => ({
|
||||
success: true,
|
||||
tokensRemoved: 45,
|
||||
messagesRemoved: 2,
|
||||
}));
|
||||
const resumeSession = vi.fn(async () => ({
|
||||
disconnect: vi.fn(async () => undefined),
|
||||
rpc: { history: { compact } },
|
||||
}));
|
||||
const pool = makePoolMock();
|
||||
const acquire = vi.fn(async () => ({
|
||||
key: {} as any,
|
||||
client: { deleteSession: vi.fn(), resumeSession } as any,
|
||||
}));
|
||||
pool.acquire = acquire;
|
||||
pool.release = vi.fn(async () => undefined);
|
||||
const trackedRuntimeModel = {
|
||||
provider: "local-proxy",
|
||||
id: "proxy-model",
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
};
|
||||
mocks.resolvePoolAcquire.mockImplementation((params: any) => {
|
||||
const runtimeModel = params.runtimeModel ?? params.model;
|
||||
if (!runtimeModel?.baseUrl) {
|
||||
throw new Error(COPILOT_BYOK_PROVIDER_ERROR);
|
||||
}
|
||||
return {
|
||||
auth: {
|
||||
agentId: "test",
|
||||
authMode: "byok",
|
||||
authProfileId: "byok:local-proxy",
|
||||
authProfileVersion:
|
||||
runtimeModel.baseUrl === trackedRuntimeModel.baseUrl
|
||||
? "sha256:provider"
|
||||
: "sha256:rotated",
|
||||
copilotHome: "/copilot-home",
|
||||
},
|
||||
key: { agentId: "test", authMode: "byok", copilotHome: "/copilot-home" },
|
||||
options: { copilotHome: "/copilot-home" },
|
||||
};
|
||||
});
|
||||
const closeByokProxy = vi.fn(async () => undefined);
|
||||
mocks.createCopilotByokProxy.mockImplementation(async (provider: any) => ({
|
||||
close: closeByokProxy,
|
||||
provider: {
|
||||
...provider,
|
||||
provider: {
|
||||
...provider.provider,
|
||||
baseUrl: "http://127.0.0.1:49152/proxy/v1",
|
||||
},
|
||||
},
|
||||
}));
|
||||
const trackedProvider = {
|
||||
type: "openai" as const,
|
||||
wireApi: "responses" as const,
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
modelId: "proxy-model",
|
||||
wireModel: "proxy-model",
|
||||
};
|
||||
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
|
||||
deps.onSessionEstablished?.({
|
||||
compactionSessionConfig: {
|
||||
...TEST_SESSION_CONFIG,
|
||||
provider: trackedProvider,
|
||||
},
|
||||
sdkSessionId: "sdk-sess-byok",
|
||||
pooledClient: {
|
||||
key: {} as any,
|
||||
client: { deleteSession: vi.fn(), resumeSession } as any,
|
||||
},
|
||||
sessionConfig: TEST_SESSION_CONFIG,
|
||||
});
|
||||
return ATTEMPT_RESULT;
|
||||
});
|
||||
const harness = createCopilotAgentHarness({ pool });
|
||||
|
||||
await harness.runAttempt(
|
||||
makeCompactParams({
|
||||
model: trackedRuntimeModel,
|
||||
provider: "local-proxy",
|
||||
authProfileId: "byok:local-proxy",
|
||||
resolvedApiKey: "byok-token",
|
||||
sessionId: "oc-sess-byok",
|
||||
}),
|
||||
);
|
||||
mocks.resolvePoolAcquire.mockClear();
|
||||
|
||||
const rotatedResult = await harness.compact?.(
|
||||
makeCompactParams({
|
||||
model: "proxy-model",
|
||||
runtimeModel: {
|
||||
...trackedRuntimeModel,
|
||||
baseUrl: "https://rotated.example/v1",
|
||||
},
|
||||
provider: "local-proxy",
|
||||
authProfileId: "byok:local-proxy",
|
||||
sessionId: "oc-sess-byok",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mocks.resolvePoolAcquire).toHaveBeenCalledTimes(1);
|
||||
expect(resumeSession).not.toHaveBeenCalled();
|
||||
expect(rotatedResult).toEqual({
|
||||
ok: false,
|
||||
compacted: false,
|
||||
reason: "missing_thread_binding",
|
||||
failure: { reason: "missing_thread_binding" },
|
||||
});
|
||||
mocks.resolvePoolAcquire.mockClear();
|
||||
|
||||
const result = await harness.compact?.(
|
||||
makeCompactParams({
|
||||
model: "proxy-model",
|
||||
runtimeModel: trackedRuntimeModel,
|
||||
provider: "local-proxy",
|
||||
authProfileId: "byok:local-proxy",
|
||||
sessionId: "oc-sess-byok",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mocks.resolvePoolAcquire).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.createCopilotByokProxy).toHaveBeenCalledWith({
|
||||
mode: "byok",
|
||||
provider: trackedProvider,
|
||||
});
|
||||
expect(resumeSession).toHaveBeenCalledWith(
|
||||
"sdk-sess-byok",
|
||||
expect.objectContaining({
|
||||
continuePendingWork: false,
|
||||
model: "gpt-4.1",
|
||||
provider: expect.objectContaining({
|
||||
baseUrl: "http://127.0.0.1:49152/proxy/v1",
|
||||
}),
|
||||
suppressResumeEvent: true,
|
||||
}),
|
||||
);
|
||||
expect(closeByokProxy).toHaveBeenCalledTimes(1);
|
||||
expect(result?.compacted).toBe(true);
|
||||
});
|
||||
|
||||
it("does not compact a tracked SDK session after model changes", async () => {
|
||||
const resumeSession = vi.fn();
|
||||
const pool = makePoolMock();
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { CopilotClient } from "@github/copilot-sdk";
|
||||
import {
|
||||
buildAgentHookContextChannelFields,
|
||||
compactWithSafetyTimeout,
|
||||
getModelProviderRequestTransport,
|
||||
resolveCompactionTimeoutMs,
|
||||
runAgentHarnessAfterCompactionHook,
|
||||
runAgentHarnessBeforeCompactionHook,
|
||||
@@ -16,13 +15,7 @@ import {
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import type { CopilotSessionConfig } from "./src/attempt.js";
|
||||
import { createCopilotByokAuth, resolveCopilotAuth, tokenFingerprint } from "./src/auth-bridge.js";
|
||||
import { createCopilotByokProxy } from "./src/byok-proxy.js";
|
||||
import {
|
||||
isCopilotByokUnsupportedProviderError,
|
||||
resolveCopilotProvider,
|
||||
supportsCopilotByokProviderShape,
|
||||
} from "./src/provider-bridge.js";
|
||||
import { resolveCopilotAuth } from "./src/auth-bridge.js";
|
||||
import type {
|
||||
ClientCreateOptions,
|
||||
CopilotClientPool,
|
||||
@@ -59,7 +52,7 @@ interface TrackedSession {
|
||||
// replaces this entry via `onSessionEstablished`.
|
||||
compatKey: string;
|
||||
compactKey: string;
|
||||
authMode: "gitHubToken" | "useLoggedInUser" | "byok";
|
||||
authMode: "gitHubToken" | "useLoggedInUser";
|
||||
authProfileId?: string;
|
||||
authProfileVersion?: string;
|
||||
}
|
||||
@@ -95,7 +88,7 @@ export type CopilotSessionBinding = {
|
||||
sdkSessionId: string;
|
||||
compatKey: string;
|
||||
compactKey: string;
|
||||
authMode: "gitHubToken" | "useLoggedInUser" | "byok";
|
||||
authMode: "gitHubToken" | "useLoggedInUser";
|
||||
authProfileId?: string;
|
||||
authProfileVersion?: string;
|
||||
updatedAt: number;
|
||||
@@ -126,9 +119,9 @@ type CopilotSessionAuth = Pick<
|
||||
>;
|
||||
|
||||
function sessionAuthFields(auth: CopilotSessionAuth): CopilotSessionAuth {
|
||||
return auth.authMode === "gitHubToken" || auth.authMode === "byok"
|
||||
return auth.authMode === "gitHubToken"
|
||||
? {
|
||||
authMode: auth.authMode,
|
||||
authMode: "gitHubToken",
|
||||
authProfileId: auth.authProfileId,
|
||||
authProfileVersion: auth.authProfileVersion,
|
||||
}
|
||||
@@ -143,7 +136,7 @@ function sessionAuthMatches(stored: CopilotSessionAuth, current: CopilotSessionA
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
current.authMode === stored.authMode &&
|
||||
current.authMode === "gitHubToken" &&
|
||||
stored.authProfileId === current.authProfileId &&
|
||||
stored.authProfileVersion === current.authProfileVersion
|
||||
);
|
||||
@@ -161,10 +154,8 @@ function normalizeBinding(
|
||||
value.compatKey.trim() === "" ||
|
||||
typeof value.compactKey !== "string" ||
|
||||
value.compactKey.trim() === "" ||
|
||||
(value.authMode !== "gitHubToken" &&
|
||||
value.authMode !== "byok" &&
|
||||
value.authMode !== "useLoggedInUser") ||
|
||||
((value.authMode === "gitHubToken" || value.authMode === "byok") &&
|
||||
(value.authMode !== "gitHubToken" && value.authMode !== "useLoggedInUser") ||
|
||||
(value.authMode === "gitHubToken" &&
|
||||
(typeof value.authProfileId !== "string" ||
|
||||
value.authProfileId.trim() === "" ||
|
||||
typeof value.authProfileVersion !== "string" ||
|
||||
@@ -180,7 +171,7 @@ function normalizeBinding(
|
||||
compatKey: value.compatKey,
|
||||
compactKey: value.compactKey,
|
||||
authMode: value.authMode,
|
||||
...(value.authMode === "gitHubToken" || value.authMode === "byok"
|
||||
...(value.authMode === "gitHubToken"
|
||||
? {
|
||||
authProfileId: value.authProfileId,
|
||||
authProfileVersion: value.authProfileVersion,
|
||||
@@ -355,88 +346,21 @@ function computeSessionKey(
|
||||
copilotHome?: string;
|
||||
cwd?: string;
|
||||
modelId?: string;
|
||||
model?:
|
||||
| {
|
||||
api?: string;
|
||||
id?: string;
|
||||
provider?: string;
|
||||
baseUrl?: string;
|
||||
azureApiVersion?: string;
|
||||
headers?: Record<string, string | null | undefined>;
|
||||
authHeader?: boolean;
|
||||
params?: Record<string, unknown>;
|
||||
request?: {
|
||||
auth?: { mode?: unknown };
|
||||
proxy?: unknown;
|
||||
tls?: unknown;
|
||||
allowPrivateNetwork?: unknown;
|
||||
};
|
||||
contextTokens?: number;
|
||||
contextWindow?: number;
|
||||
maxTokens?: number;
|
||||
}
|
||||
| string;
|
||||
runtimeModel?: {
|
||||
api?: string;
|
||||
id?: string;
|
||||
provider?: string;
|
||||
baseUrl?: string;
|
||||
azureApiVersion?: string;
|
||||
headers?: Record<string, string | null | undefined>;
|
||||
authHeader?: boolean;
|
||||
params?: Record<string, unknown>;
|
||||
request?: {
|
||||
auth?: { mode?: unknown };
|
||||
proxy?: unknown;
|
||||
tls?: unknown;
|
||||
allowPrivateNetwork?: unknown;
|
||||
};
|
||||
contextTokens?: number;
|
||||
contextWindow?: number;
|
||||
maxTokens?: number;
|
||||
};
|
||||
model?: string | { api?: string; id?: string; provider?: string };
|
||||
profileVersion?: string;
|
||||
resolvedApiKey?: string;
|
||||
sessionKey?: string;
|
||||
workspaceDir?: string;
|
||||
};
|
||||
const modelObj: {
|
||||
api?: string;
|
||||
id?: string;
|
||||
provider?: string;
|
||||
baseUrl?: string;
|
||||
azureApiVersion?: string;
|
||||
headers?: Record<string, string | null | undefined>;
|
||||
authHeader?: boolean;
|
||||
params?: Record<string, unknown>;
|
||||
request?: {
|
||||
auth?: { mode?: unknown };
|
||||
proxy?: unknown;
|
||||
tls?: unknown;
|
||||
allowPrivateNetwork?: unknown;
|
||||
};
|
||||
contextTokens?: number;
|
||||
contextWindow?: number;
|
||||
maxTokens?: number;
|
||||
} =
|
||||
const modelObj: { api?: string; id?: string; provider?: string } =
|
||||
p.model && typeof p.model === "object"
|
||||
? p.model
|
||||
: p.runtimeModel && typeof p.runtimeModel === "object"
|
||||
? p.runtimeModel
|
||||
: { id: typeof p.model === "string" ? p.model : undefined };
|
||||
const provider = modelObj.provider ?? (typeof p.provider === "string" ? p.provider : "");
|
||||
const modelId =
|
||||
modelObj.id ??
|
||||
(typeof p.modelId === "string" ? p.modelId : undefined) ??
|
||||
(typeof p.model === "string" ? p.model : "");
|
||||
const requestTransport =
|
||||
p.model && typeof p.model === "object" ? getModelProviderRequestTransport(p.model) : undefined;
|
||||
const requestAuthMode = readSessionString(
|
||||
requestTransport?.auth?.mode ?? modelObj.request?.auth?.mode,
|
||||
);
|
||||
const azureApiVersion = readSessionString(
|
||||
modelObj.azureApiVersion ?? modelObj.params?.azureApiVersion,
|
||||
);
|
||||
// resolveCopilotAuth can throw when an explicit `auth.gitHubToken`
|
||||
// is supplied without profileId + profileVersion (the existing
|
||||
// pool-key safety invariant). That same error would surface
|
||||
@@ -449,63 +373,16 @@ function computeSessionKey(
|
||||
let resolvedAgentId = "";
|
||||
let resolvedCopilotHome = "";
|
||||
try {
|
||||
const resolved = !options.includeAuth
|
||||
? resolveCopilotAuth({
|
||||
agentId:
|
||||
typeof p.agentId === "string" ? p.agentId : readAgentIdFromSessionKey(p.sessionKey),
|
||||
agentDir: typeof p.agentDir === "string" ? p.agentDir : undefined,
|
||||
workspaceDir: typeof p.workspaceDir === "string" ? p.workspaceDir : undefined,
|
||||
copilotHome: typeof p.copilotHome === "string" ? p.copilotHome : undefined,
|
||||
auth: { useLoggedInUser: true },
|
||||
})
|
||||
: (() => {
|
||||
const modelProvider = resolveCopilotProvider({
|
||||
model: {
|
||||
api: modelObj.api,
|
||||
id: modelId,
|
||||
provider,
|
||||
baseUrl: modelObj.baseUrl,
|
||||
azureApiVersion,
|
||||
headers: modelObj.headers,
|
||||
authHeader: modelObj.authHeader,
|
||||
requestAuthMode,
|
||||
requestProxy: requestTransport?.proxy ?? modelObj.request?.proxy,
|
||||
requestTls: requestTransport?.tls ?? modelObj.request?.tls,
|
||||
requestAllowPrivateNetwork:
|
||||
requestTransport?.allowPrivateNetwork ?? modelObj.request?.allowPrivateNetwork,
|
||||
contextTokens: modelObj.contextTokens,
|
||||
contextWindow: modelObj.contextWindow,
|
||||
maxTokens: modelObj.maxTokens,
|
||||
},
|
||||
resolvedApiKey: typeof p.resolvedApiKey === "string" ? p.resolvedApiKey : undefined,
|
||||
authProfileId: typeof p.authProfileId === "string" ? p.authProfileId : undefined,
|
||||
});
|
||||
return modelProvider.mode === "byok"
|
||||
? createCopilotByokAuth({
|
||||
agentId:
|
||||
typeof p.agentId === "string"
|
||||
? p.agentId
|
||||
: readAgentIdFromSessionKey(p.sessionKey),
|
||||
agentDir: typeof p.agentDir === "string" ? p.agentDir : undefined,
|
||||
workspaceDir: typeof p.workspaceDir === "string" ? p.workspaceDir : undefined,
|
||||
copilotHome: typeof p.copilotHome === "string" ? p.copilotHome : undefined,
|
||||
authProfileId: modelProvider.authProfileId,
|
||||
authProfileVersion: modelProvider.authProfileVersion,
|
||||
})
|
||||
: resolveCopilotAuth({
|
||||
agentId:
|
||||
typeof p.agentId === "string"
|
||||
? p.agentId
|
||||
: readAgentIdFromSessionKey(p.sessionKey),
|
||||
agentDir: typeof p.agentDir === "string" ? p.agentDir : undefined,
|
||||
workspaceDir: typeof p.workspaceDir === "string" ? p.workspaceDir : undefined,
|
||||
copilotHome: typeof p.copilotHome === "string" ? p.copilotHome : undefined,
|
||||
auth: p.auth,
|
||||
resolvedApiKey: typeof p.resolvedApiKey === "string" ? p.resolvedApiKey : undefined,
|
||||
authProfileId: typeof p.authProfileId === "string" ? p.authProfileId : undefined,
|
||||
profileVersion: typeof p.profileVersion === "string" ? p.profileVersion : undefined,
|
||||
});
|
||||
})();
|
||||
const resolved = resolveCopilotAuth({
|
||||
agentId: typeof p.agentId === "string" ? p.agentId : readAgentIdFromSessionKey(p.sessionKey),
|
||||
agentDir: typeof p.agentDir === "string" ? p.agentDir : undefined,
|
||||
workspaceDir: typeof p.workspaceDir === "string" ? p.workspaceDir : undefined,
|
||||
copilotHome: typeof p.copilotHome === "string" ? p.copilotHome : undefined,
|
||||
auth: p.auth,
|
||||
resolvedApiKey: typeof p.resolvedApiKey === "string" ? p.resolvedApiKey : undefined,
|
||||
authProfileId: typeof p.authProfileId === "string" ? p.authProfileId : undefined,
|
||||
profileVersion: typeof p.profileVersion === "string" ? p.profileVersion : undefined,
|
||||
});
|
||||
resolvedAgentId = resolved.agentId;
|
||||
resolvedCopilotHome = resolved.copilotHome;
|
||||
authParts = [
|
||||
@@ -513,9 +390,6 @@ function computeSessionKey(
|
||||
`auth.profileId=${resolved.authProfileId ?? ""}`,
|
||||
`auth.profileVersion=${resolved.authProfileVersion ?? ""}`,
|
||||
];
|
||||
if (!options.includeAuth) {
|
||||
authParts = [];
|
||||
}
|
||||
} catch {
|
||||
authParts = ["auth=unresolvable"];
|
||||
}
|
||||
@@ -523,9 +397,6 @@ function computeSessionKey(
|
||||
`provider=${provider}`,
|
||||
`model=${modelId}`,
|
||||
...(options.includeApi ? [`api=${modelObj.api ?? ""}`] : []),
|
||||
...(options.includeApi
|
||||
? [`baseUrlFingerprint=${fingerprintSessionValue(modelObj.baseUrl)}`]
|
||||
: []),
|
||||
`cwd=${p.cwd ?? p.workspaceDir ?? ""}`,
|
||||
`agentId=${resolvedAgentId}`,
|
||||
`agentDir=${p.agentDir ?? ""}`,
|
||||
@@ -536,14 +407,6 @@ function computeSessionKey(
|
||||
return parts.join("|");
|
||||
}
|
||||
|
||||
function readSessionString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function fingerprintSessionValue(value: unknown): string {
|
||||
return typeof value === "string" && value ? tokenFingerprint(value) : "";
|
||||
}
|
||||
|
||||
function computeSessionCompatKey(params: CopilotSessionCompatParams): string {
|
||||
return computeSessionKey(params, { includeApi: true, includeAuth: true });
|
||||
}
|
||||
@@ -668,38 +531,12 @@ export function createCopilotAgentHarness(
|
||||
return { supported: false, reason: "copilot is opt-in only" };
|
||||
}
|
||||
const provider = ctx.provider.trim().toLowerCase();
|
||||
if (!provider) {
|
||||
return { supported: false, reason: "provider is required" };
|
||||
}
|
||||
if (COPILOT_PROVIDER_IDS.has(provider)) {
|
||||
return { supported: true, priority: 100 };
|
||||
}
|
||||
const providerOwnerPluginIds = ctx.providerOwnerPluginIds;
|
||||
if (
|
||||
ctx.providerOwnerStatus !== "unowned" ||
|
||||
!providerOwnerPluginIds ||
|
||||
providerOwnerPluginIds.length > 0
|
||||
) {
|
||||
if (!COPILOT_PROVIDER_IDS.has(provider)) {
|
||||
return {
|
||||
supported: false,
|
||||
reason: `provider is not one of: ${[...COPILOT_PROVIDER_IDS].toSorted().join(", ")}`,
|
||||
};
|
||||
}
|
||||
if (
|
||||
!supportsCopilotByokProviderShape({
|
||||
api: ctx.modelProvider?.api,
|
||||
baseUrl: ctx.modelProvider?.baseUrl,
|
||||
requestProxy: ctx.modelProvider?.request?.proxy,
|
||||
requestTls: ctx.modelProvider?.request?.tls,
|
||||
requestAllowPrivateNetwork: ctx.modelProvider?.request?.allowPrivateNetwork,
|
||||
})
|
||||
) {
|
||||
return {
|
||||
supported: false,
|
||||
reason:
|
||||
"provider is not a supported Copilot BYOK model (requires supported api, baseUrl, and no request transport policy overrides)",
|
||||
};
|
||||
}
|
||||
return { supported: true, priority: 100 };
|
||||
},
|
||||
|
||||
@@ -712,22 +549,11 @@ export function createCopilotAgentHarness(
|
||||
if (disposed) {
|
||||
throw new Error("[copilot] harness was disposed while starting an attempt");
|
||||
}
|
||||
const poolAcquire = resolvePoolAcquire(params as never);
|
||||
const pool = await getPool();
|
||||
if (disposed) {
|
||||
throw new Error("[copilot] harness was disposed while starting an attempt");
|
||||
}
|
||||
let poolAcquire: ReturnType<typeof resolvePoolAcquire>;
|
||||
try {
|
||||
poolAcquire = resolvePoolAcquire(params as never);
|
||||
} catch (error) {
|
||||
// Keep invalid forced BYOK model configuration on the normal attempt
|
||||
// result path so callers receive `model_not_supported` instead of an
|
||||
// uncaught harness rejection. Other auth/pool errors remain fatal.
|
||||
if (isCopilotByokUnsupportedProviderError(error)) {
|
||||
return runCopilotAttempt(params, { pool });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const openclawSessionId =
|
||||
typeof params.sessionId === "string" ? params.sessionId : undefined;
|
||||
|
||||
@@ -785,12 +611,10 @@ export function createCopilotAgentHarness(
|
||||
pool,
|
||||
onSessionEstablished: openclawSessionId
|
||||
? ({
|
||||
compactionSessionConfig,
|
||||
sdkSessionId,
|
||||
pooledClient,
|
||||
sessionConfig,
|
||||
}: {
|
||||
compactionSessionConfig?: CopilotSessionConfig;
|
||||
sdkSessionId: string;
|
||||
pooledClient: PooledClient;
|
||||
sessionConfig: CopilotSessionConfig;
|
||||
@@ -802,7 +626,7 @@ export function createCopilotAgentHarness(
|
||||
compatKey: currentCompatKey,
|
||||
compactKey: currentCompactKey,
|
||||
poolKey: pooledClient.key,
|
||||
sessionConfig: compactionSessionConfig ?? sessionConfig,
|
||||
sessionConfig,
|
||||
...sessionAuthFields(poolAcquire.auth),
|
||||
});
|
||||
registerStoredBinding(options?.sessionStore, openclawSessionId, {
|
||||
@@ -944,24 +768,8 @@ export function createCopilotAgentHarness(
|
||||
const tracked = trackedSessions.get(openclawSessionId);
|
||||
const currentCompactKey = computeSessionCompactKey(params);
|
||||
const { resolvePoolAcquire } = await import("./src/attempt.js");
|
||||
let resolvedPoolAcquire: ReturnType<typeof resolvePoolAcquire> | undefined;
|
||||
let currentAuth: CopilotSessionAuth | undefined;
|
||||
try {
|
||||
resolvedPoolAcquire = resolvePoolAcquire(params as never);
|
||||
} catch (error) {
|
||||
if (isCopilotByokUnsupportedProviderError(error)) {
|
||||
return {
|
||||
ok: false,
|
||||
compacted: false,
|
||||
reason: "missing_thread_binding",
|
||||
failure: { reason: "missing_thread_binding" },
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!currentAuth) {
|
||||
currentAuth = sessionAuthFields(resolvedPoolAcquire.auth);
|
||||
}
|
||||
const resolvedPoolAcquire = resolvePoolAcquire(params as never);
|
||||
const currentAuth = sessionAuthFields(resolvedPoolAcquire.auth);
|
||||
const compatibleTracked =
|
||||
tracked?.compactKey === currentCompactKey && sessionAuthMatches(tracked, currentAuth)
|
||||
? tracked
|
||||
@@ -977,32 +785,19 @@ export function createCopilotAgentHarness(
|
||||
failure: { reason: "missing_thread_binding" },
|
||||
};
|
||||
}
|
||||
const poolAcquire = {
|
||||
key: compatibleTracked.poolKey,
|
||||
options: compatibleTracked.clientOptions,
|
||||
};
|
||||
const poolAcquire = compatibleTracked
|
||||
? { key: compatibleTracked.poolKey, options: compatibleTracked.clientOptions }
|
||||
: resolvedPoolAcquire;
|
||||
let compactResult: CopilotHistoryCompactResult;
|
||||
let handle: PooledClient | undefined;
|
||||
let pool: CopilotClientPool | undefined;
|
||||
let activeSdkSession: CopilotHistoryCompactSession | undefined;
|
||||
let cleanupByokProxy: (() => Promise<void>) | undefined;
|
||||
const hookContext = buildCopilotCompactionHookContext(params);
|
||||
try {
|
||||
throwIfAborted(params.abortSignal);
|
||||
pool = await getPool();
|
||||
handle = await pool.acquire(poolAcquire.key, poolAcquire.options);
|
||||
const client = handle.client;
|
||||
const byokProxy =
|
||||
compatibleTracked.authMode === "byok" && compatibleTracked.sessionConfig.provider
|
||||
? await createCopilotByokProxy({
|
||||
mode: "byok",
|
||||
provider: compatibleTracked.sessionConfig.provider,
|
||||
})
|
||||
: undefined;
|
||||
cleanupByokProxy = byokProxy?.close;
|
||||
const sessionConfig = byokProxy?.provider.provider
|
||||
? { ...compatibleTracked.sessionConfig, provider: byokProxy.provider.provider }
|
||||
: compatibleTracked.sessionConfig;
|
||||
// Manual compaction resumes a distinct SDK session, bypassing the attempt event bridge.
|
||||
// Run the portable lifecycle hook here so both compaction paths stay observable.
|
||||
await runAgentHarnessBeforeCompactionHook({
|
||||
@@ -1017,13 +812,13 @@ export function createCopilotAgentHarness(
|
||||
customInstructions: params.customInstructions,
|
||||
gitHubToken:
|
||||
compatibleTracked?.clientOptions.gitHubToken ??
|
||||
(resolvedPoolAcquire?.auth.authMode === "gitHubToken"
|
||||
(resolvedPoolAcquire.auth.authMode === "gitHubToken"
|
||||
? resolvedPoolAcquire.auth.gitHubToken
|
||||
: undefined),
|
||||
onSession: (session) => {
|
||||
activeSdkSession = session;
|
||||
},
|
||||
sessionConfig,
|
||||
sessionConfig: compatibleTracked.sessionConfig,
|
||||
sdkSessionId: compatibleTracked.sdkSessionId,
|
||||
}),
|
||||
resolveCompactionTimeoutMs(
|
||||
@@ -1057,7 +852,6 @@ export function createCopilotAgentHarness(
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
await cleanupByokProxy?.();
|
||||
if (pool && handle) {
|
||||
try {
|
||||
await pool.release(handle);
|
||||
|
||||
4
extensions/copilot/npm-shrinkwrap.json
generated
4
extensions/copilot/npm-shrinkwrap.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/copilot",
|
||||
"version": "2026.6.10",
|
||||
"version": "2026.6.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/copilot",
|
||||
"version": "2026.6.10",
|
||||
"version": "2026.6.9",
|
||||
"dependencies": {
|
||||
"@github/copilot-sdk": "1.0.0-beta.9"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/copilot",
|
||||
"version": "2026.6.10",
|
||||
"version": "2026.6.9",
|
||||
"description": "OpenClaw GitHub Copilot agent runtime plugin (registers a `github-copilot` AgentHarness backed by @github/copilot-sdk over JSON-RPC to the GitHub Copilot CLI)",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -25,10 +25,10 @@
|
||||
"minHostVersion": ">=2026.5.28"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.10"
|
||||
"pluginApi": ">=2026.6.9"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.10",
|
||||
"openclawVersion": "2026.6.9",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
|
||||
@@ -5,7 +5,6 @@ import path from "node:path";
|
||||
import type { CopilotClient, Tool as SdkTool } from "@github/copilot-sdk";
|
||||
import {
|
||||
abortAgentHarnessRun,
|
||||
attachModelProviderRequestTransport,
|
||||
queueAgentHarnessMessage,
|
||||
type AgentHarnessAttemptParams,
|
||||
type AgentHarnessAttemptResult,
|
||||
@@ -105,12 +104,11 @@ function createDeferred<T>() {
|
||||
function flushAsync() {
|
||||
// Pump enough microtasks for the attempt to settle past every
|
||||
// pre-createSession `await` in attempt.ts (resolvePoolAcquire,
|
||||
// BYOK proxy setup, resolveCopilotWorkspaceBootstrapContext,
|
||||
// createSession, etc.).
|
||||
// resolveCopilotWorkspaceBootstrapContext, createSession, etc.).
|
||||
// Each chained `then` is one tick; tests rely on this to observe
|
||||
// `sdk.sessions[0]` being populated before they emit deltas.
|
||||
const tick = () => Promise.resolve();
|
||||
return tick().then(tick).then(tick).then(tick).then(tick);
|
||||
return tick().then(tick).then(tick);
|
||||
}
|
||||
|
||||
function waitForEventLoopTurn(): Promise<void> {
|
||||
@@ -340,22 +338,7 @@ describe("runCopilotAttempt", () => {
|
||||
return { sdkTools: [], sourceTools: [] };
|
||||
});
|
||||
|
||||
const params = makeParams();
|
||||
Object.assign(params, {
|
||||
jobId: "job-1",
|
||||
trigger: "user",
|
||||
messageChannel: "slack",
|
||||
messageProvider: "slack-voice",
|
||||
currentChannelId: "C123",
|
||||
chatId: "C123",
|
||||
senderId: "U123",
|
||||
channelContext: {
|
||||
sender: { id: "U123", displayName: "Ada" },
|
||||
chat: { id: "C123" },
|
||||
},
|
||||
});
|
||||
|
||||
await runCopilotAttempt(params, {
|
||||
await runCopilotAttempt(makeParams(), {
|
||||
createToolBridge,
|
||||
pool: makeFakePool(sdk),
|
||||
});
|
||||
@@ -402,21 +385,7 @@ describe("runCopilotAttempt", () => {
|
||||
toolCallId: "tool-call-1",
|
||||
toolName: "read",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
agentId: "agent-1",
|
||||
sessionId: "session-1",
|
||||
jobId: "job-1",
|
||||
trigger: "user",
|
||||
messageProvider: "slack-voice",
|
||||
channel: "slack",
|
||||
chatId: "C123",
|
||||
senderId: "U123",
|
||||
channelId: "C123",
|
||||
channelContext: {
|
||||
sender: { id: "U123", displayName: "Ada" },
|
||||
chat: { id: "C123" },
|
||||
},
|
||||
}),
|
||||
expect.objectContaining({ agentId: "agent-1", sessionId: "session-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1316,32 +1285,6 @@ describe("runCopilotAttempt", () => {
|
||||
).toBe(sdkTools);
|
||||
});
|
||||
|
||||
it("passes the session-resolved agent id to the tool bridge", async () => {
|
||||
const sdk = makeFakeSdk();
|
||||
const pool = makeFakePool(sdk);
|
||||
const createToolBridge = vi.fn(async () => ({ sdkTools: [], sourceTools: [] }));
|
||||
|
||||
await runCopilotAttempt(
|
||||
makeParams({
|
||||
agentId: undefined,
|
||||
sessionKey: "agent:beta:main",
|
||||
config: {
|
||||
agents: {
|
||||
list: [{ id: "main" }, { id: "beta" }],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
{ createToolBridge, pool },
|
||||
);
|
||||
|
||||
expect(createToolBridge).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentId: "beta",
|
||||
sessionKey: "agent:beta:main",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("F6: sessionRef is populated after createSession so the tool bridge's onYield can abort the live SDK session", async () => {
|
||||
const sdk = makeFakeSdk();
|
||||
const pool = makeFakePool(sdk);
|
||||
@@ -2395,152 +2338,6 @@ describe("runCopilotAttempt", () => {
|
||||
expect(options.useLoggedInUser).toBe(false);
|
||||
});
|
||||
|
||||
it("pool keying: BYOK does not resolve unrelated GitHub auth", async () => {
|
||||
const sdk = makeFakeSdk();
|
||||
const pool = makeFakePool(sdk);
|
||||
|
||||
await runCopilotAttempt(
|
||||
makeParams({
|
||||
auth: { gitHubToken: "unrelated-token" } as never,
|
||||
model: {
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://api.example.test/v1",
|
||||
id: "gpt-test",
|
||||
provider: "custom-openai",
|
||||
} as never,
|
||||
resolvedApiKey: "byok-token",
|
||||
authProfileId: "custom-openai:main",
|
||||
} as never),
|
||||
{ pool },
|
||||
);
|
||||
|
||||
const key = (vi.mocked(pool["acquire"]).mock.calls[0] as unknown[] | undefined)?.[0] as {
|
||||
authMode: string;
|
||||
authProfileId?: string;
|
||||
};
|
||||
const options = (vi.mocked(pool["acquire"]).mock.calls[0] as unknown[] | undefined)?.[1] as {
|
||||
gitHubToken?: string;
|
||||
useLoggedInUser?: boolean;
|
||||
};
|
||||
const cfg = (sdk.createSession.mock.calls[0] as unknown[] | undefined)?.[0] as {
|
||||
provider?: { apiKey?: string; baseUrl?: string };
|
||||
};
|
||||
|
||||
expect(key.authMode).toBe("byok");
|
||||
expect(key.authProfileId).toBe("custom-openai:main");
|
||||
expect(options.gitHubToken).toBeUndefined();
|
||||
expect(options.useLoggedInUser).toBe(false);
|
||||
expect(cfg.provider).toEqual(
|
||||
expect.objectContaining({
|
||||
apiKey: "byok-token",
|
||||
baseUrl: expect.stringMatching(/^http:\/\/127\.0\.0\.1:\d+\/[a-f0-9]{24}\/v1$/),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards BYOK provider headers on the model request turn", async () => {
|
||||
const sdk = makeFakeSdk();
|
||||
const pool = makeFakePool(sdk);
|
||||
|
||||
await runCopilotAttempt(
|
||||
makeParams({
|
||||
model: {
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://anthropic.example.test",
|
||||
headers: {
|
||||
"X-Tenant": "tenant-a",
|
||||
"X-Trace": "trace-1",
|
||||
},
|
||||
id: "claude-test",
|
||||
provider: "anthropic-proxy",
|
||||
} as never,
|
||||
resolvedApiKey: "byok-token",
|
||||
authProfileId: "anthropic-proxy:main",
|
||||
} as never),
|
||||
{ pool },
|
||||
);
|
||||
|
||||
const cfg = (sdk.createSession.mock.calls[0] as unknown[] | undefined)?.[0] as {
|
||||
provider?: { headers?: Record<string, string> };
|
||||
};
|
||||
const sendOptions = sdk.sessions[0]?.sendAndWait.mock.calls[0]?.[0] as {
|
||||
requestHeaders?: Record<string, string>;
|
||||
};
|
||||
expect(cfg.provider?.headers).toEqual({
|
||||
"X-Tenant": "tenant-a",
|
||||
"X-Trace": "trace-1",
|
||||
});
|
||||
expect(sendOptions.requestHeaders).toEqual({
|
||||
"X-Tenant": "tenant-a",
|
||||
"X-Trace": "trace-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves prepared BYOK header-auth without synthesizing SDK apiKey auth", async () => {
|
||||
const sdk = makeFakeSdk();
|
||||
const pool = makeFakePool(sdk);
|
||||
const model = attachModelProviderRequestTransport(
|
||||
{
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://proxy.example.test/v1",
|
||||
headers: { "x-api-key": "header-secret" },
|
||||
id: "gpt-test",
|
||||
provider: "custom-header-proxy",
|
||||
},
|
||||
{ auth: { mode: "header", headerName: "x-api-key", value: "header-secret" } },
|
||||
);
|
||||
|
||||
await runCopilotAttempt(
|
||||
makeParams({
|
||||
model: model as never,
|
||||
resolvedApiKey: "header-secret",
|
||||
authProfileId: "custom-header-proxy:main",
|
||||
} as never),
|
||||
{ pool },
|
||||
);
|
||||
|
||||
const cfg = (sdk.createSession.mock.calls[0] as unknown[] | undefined)?.[0] as {
|
||||
provider?: { apiKey?: string; headers?: Record<string, string> };
|
||||
};
|
||||
const sendOptions = sdk.sessions[0]?.sendAndWait.mock.calls[0]?.[0] as {
|
||||
requestHeaders?: Record<string, string>;
|
||||
};
|
||||
expect(cfg.provider).toEqual(
|
||||
expect.objectContaining({
|
||||
headers: { "x-api-key": "header-secret" },
|
||||
}),
|
||||
);
|
||||
expect(cfg.provider).not.toHaveProperty("apiKey");
|
||||
expect(sendOptions.requestHeaders).toEqual({ "x-api-key": "header-secret" });
|
||||
});
|
||||
|
||||
it("rejects BYOK providers with request transport policy overrides before creating a SDK session", async () => {
|
||||
const sdk = makeFakeSdk();
|
||||
const pool = makeFakePool(sdk);
|
||||
const model = attachModelProviderRequestTransport(
|
||||
{
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://proxy.example.test/v1",
|
||||
id: "gpt-test",
|
||||
provider: "custom-header-proxy",
|
||||
},
|
||||
{ proxy: { mode: "env-proxy" } },
|
||||
);
|
||||
|
||||
const result = await runCopilotAttempt(
|
||||
makeParams({
|
||||
model: model as never,
|
||||
resolvedApiKey: "header-secret",
|
||||
authProfileId: "custom-header-proxy:main",
|
||||
} as never),
|
||||
{ pool },
|
||||
);
|
||||
|
||||
expect(getPromptErrorCode(result)).toBe("model_not_supported");
|
||||
expect((result.promptError as Error | undefined)?.message).toContain("request proxy");
|
||||
expect(sdk.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("session-level gitHubToken (independent of client-level)", () => {
|
||||
// The SDK contract (@github/copilot-sdk/dist/types.d.ts:1168-1178)
|
||||
// makes `SessionConfig.gitHubToken` independent of the client-level
|
||||
@@ -2604,37 +2401,6 @@ describe("runCopilotAttempt", () => {
|
||||
expect(resumeCfg.gitHubToken).toBe("contract-token-resume");
|
||||
});
|
||||
|
||||
it("BYOK provider config is forwarded to resumeSession", async () => {
|
||||
const sdk = makeFakeSdk();
|
||||
const pool = makeFakePool(sdk);
|
||||
|
||||
await runCopilotAttempt(
|
||||
makeParams({
|
||||
auth: { gitHubToken: "unrelated-token" } as never,
|
||||
model: {
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://api.example.test/v1",
|
||||
id: "gpt-test",
|
||||
provider: "custom-openai",
|
||||
} as never,
|
||||
resolvedApiKey: "byok-token",
|
||||
authProfileId: "custom-openai:main",
|
||||
initialReplayState: { sdkSessionId: "resume-target" } as never,
|
||||
} as never),
|
||||
{ pool },
|
||||
);
|
||||
|
||||
const resumeCfg = sdk.resumeSession.mock.calls[0]?.[1] as {
|
||||
provider?: { apiKey?: string; baseUrl?: string };
|
||||
};
|
||||
expect(resumeCfg.provider).toEqual(
|
||||
expect.objectContaining({
|
||||
apiKey: "byok-token",
|
||||
baseUrl: expect.stringMatching(/^http:\/\/127\.0\.0\.1:\d+\/[a-f0-9]{24}\/v1$/),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("SessionConfig.gitHubToken is omitted when useLoggedInUser is the resolved mode", async () => {
|
||||
const sdk = makeFakeSdk();
|
||||
const pool = makeFakePool(sdk);
|
||||
|
||||
@@ -9,9 +9,7 @@ import type {
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
buildAgentHookContextChannelFields,
|
||||
buildAgentHookContextOriginFields,
|
||||
detectAndLoadAgentHarnessPromptImages,
|
||||
getModelProviderRequestTransport,
|
||||
resolveAgentHarnessBeforePromptBuildResult,
|
||||
resolveAttemptFsWorkspaceOnly,
|
||||
resolveAttemptSpawnWorkspaceDir,
|
||||
@@ -29,8 +27,7 @@ import {
|
||||
clearActiveEmbeddedRun,
|
||||
setActiveEmbeddedRun,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { createCopilotByokAuth, resolveCopilotAuth } from "./auth-bridge.js";
|
||||
import { createCopilotByokProxy } from "./byok-proxy.js";
|
||||
import { resolveCopilotAuth } from "./auth-bridge.js";
|
||||
import {
|
||||
createInfiniteSessionConfig,
|
||||
type CopilotInfiniteSessionOptions,
|
||||
@@ -53,7 +50,6 @@ import {
|
||||
rejectAllPolicy,
|
||||
type CopilotPermissionPolicy,
|
||||
} from "./permission-bridge.js";
|
||||
import { resolveCopilotProvider, type ResolvedCopilotProvider } from "./provider-bridge.js";
|
||||
import {
|
||||
classifyResumeFailure,
|
||||
computeReplayMetadata,
|
||||
@@ -83,7 +79,6 @@ export type CopilotSessionConfig = Pick<
|
||||
| "model"
|
||||
| "onPermissionRequest"
|
||||
| "onUserInputRequest"
|
||||
| "provider"
|
||||
| "reasoningEffort"
|
||||
| "systemMessage"
|
||||
| "tools"
|
||||
@@ -120,42 +115,7 @@ type AttemptParamsLike = AgentHarnessAttemptParams & {
|
||||
// internal expansion. Symmetric to `EmbeddedRunAttemptParams.transcriptPrompt`.
|
||||
transcriptPrompt?: string;
|
||||
};
|
||||
type ModelRef = {
|
||||
api?: string;
|
||||
id: string;
|
||||
provider: string;
|
||||
baseUrl?: string;
|
||||
azureApiVersion?: string;
|
||||
headers?: Record<string, string | null | undefined>;
|
||||
authHeader?: boolean;
|
||||
requestAuthMode?: string;
|
||||
requestProxy?: unknown;
|
||||
requestTls?: unknown;
|
||||
requestAllowPrivateNetwork?: unknown;
|
||||
contextTokens?: number;
|
||||
contextWindow?: number;
|
||||
maxTokens?: number;
|
||||
};
|
||||
|
||||
type ModelRefInputObject = {
|
||||
api?: unknown;
|
||||
id?: unknown;
|
||||
provider?: unknown;
|
||||
baseUrl?: unknown;
|
||||
azureApiVersion?: unknown;
|
||||
params?: { azureApiVersion?: unknown };
|
||||
headers?: ModelRef["headers"];
|
||||
authHeader?: boolean;
|
||||
request?: {
|
||||
auth?: { mode?: unknown };
|
||||
proxy?: unknown;
|
||||
tls?: unknown;
|
||||
allowPrivateNetwork?: unknown;
|
||||
};
|
||||
contextTokens?: number;
|
||||
contextWindow?: number;
|
||||
maxTokens?: number;
|
||||
};
|
||||
type ModelRef = { api?: string; id: string; provider: string };
|
||||
|
||||
export type { AttemptParamsLike as CopilotPoolAcquireInput, ModelRef };
|
||||
export { SUPPORTED_PROVIDERS };
|
||||
@@ -182,7 +142,6 @@ export interface CopilotAttemptDeps {
|
||||
* attempt.
|
||||
*/
|
||||
onSessionEstablished?: (info: {
|
||||
compactionSessionConfig?: CopilotSessionConfig;
|
||||
sdkSessionId: string;
|
||||
pooledClient: PooledClient;
|
||||
sessionConfig: CopilotSessionConfig;
|
||||
@@ -269,7 +228,6 @@ function deferBackgroundCompactionCleanup(params: {
|
||||
bridge: ReturnType<typeof attachEventBridge>;
|
||||
handle: PooledClient;
|
||||
pool: CopilotClientPool;
|
||||
cleanupByokProxy?: () => Promise<void>;
|
||||
cleanupToolBridge?: () => void;
|
||||
finalizeNativeSubagents?: () => void;
|
||||
sdkSessionId?: string;
|
||||
@@ -302,7 +260,6 @@ function deferBackgroundCompactionCleanup(params: {
|
||||
// The attempt has already returned its timeout result.
|
||||
}
|
||||
params.cleanupToolBridge?.();
|
||||
await params.cleanupByokProxy?.();
|
||||
if (outcome !== "completed" && params.sdkSessionId) {
|
||||
try {
|
||||
await params.handle.client.deleteSession(params.sdkSessionId);
|
||||
@@ -410,25 +367,6 @@ export async function runCopilotAttempt(
|
||||
...hookContextWindowFields,
|
||||
...buildAgentHookContextChannelFields(input),
|
||||
};
|
||||
const toolHookRunContext = {
|
||||
runId: input.runId,
|
||||
jobId: input.jobId,
|
||||
agentId: sessionAgentId,
|
||||
sessionKey: sandboxSessionKey,
|
||||
sessionId: input.sessionId,
|
||||
trigger: input.trigger,
|
||||
...buildAgentHookContextOriginFields({
|
||||
sessionKey: sandboxSessionKey,
|
||||
messageChannel: input.messageChannel,
|
||||
messageProvider: input.messageProvider ?? input.messageChannel,
|
||||
currentChannelId: input.currentChannelId,
|
||||
messageTo: input.currentMessagingTarget ?? input.messageTo,
|
||||
trigger: input.trigger,
|
||||
senderId: input.senderId,
|
||||
chatId: input.chatId,
|
||||
channelContext: input.channelContext,
|
||||
}),
|
||||
};
|
||||
const finishAttempt = (result: AgentHarnessAttemptResult) =>
|
||||
finalizeCopilotAttempt(input, result, hookContext, attemptStartedAt, now);
|
||||
|
||||
@@ -446,18 +384,15 @@ export async function runCopilotAttempt(
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
resolveCopilotProvider({
|
||||
model: modelRef,
|
||||
resolvedApiKey: readString(params.resolvedApiKey),
|
||||
authProfileId: readString(params.authProfileId),
|
||||
});
|
||||
} catch (error) {
|
||||
if (!SUPPORTED_PROVIDERS.has(modelRef.provider)) {
|
||||
return finishAttempt(
|
||||
createResult(input, {
|
||||
messagesSnapshot: messages,
|
||||
now,
|
||||
promptError: createPromptError("model_not_supported", toError(error).message, error),
|
||||
promptError: createPromptError(
|
||||
"model_not_supported",
|
||||
`[copilot-attempt] provider ${modelRef.provider} is not supported at MVP (subscription Copilot models only; BYOK arrives via byok-mapping-skeleton)`,
|
||||
),
|
||||
sdkSessionId: undefined,
|
||||
sessionIdUsed: input.sessionId,
|
||||
}),
|
||||
@@ -614,22 +549,6 @@ export async function runCopilotAttempt(
|
||||
})
|
||||
: undefined;
|
||||
const poolAcquire = resolvePoolAcquire(input);
|
||||
let byokProxy: Awaited<ReturnType<typeof createCopilotByokProxy>>;
|
||||
try {
|
||||
byokProxy = await createCopilotByokProxy(poolAcquire.provider);
|
||||
} catch (error) {
|
||||
return finishAttempt(
|
||||
createResult(input, {
|
||||
messagesSnapshot: messages,
|
||||
now,
|
||||
promptError: createPromptError("model_not_supported", toError(error).message, error),
|
||||
sdkSessionId: undefined,
|
||||
sessionIdUsed: input.sessionId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const cleanupByokProxy = byokProxy?.close;
|
||||
const sessionProvider = byokProxy?.provider ?? poolAcquire.provider;
|
||||
|
||||
// Mutable session holder shared with the tool bridge so onYield
|
||||
// (raised inside wrapped-tool execution) can route to the live SDK
|
||||
@@ -643,10 +562,9 @@ export async function runCopilotAttempt(
|
||||
let sdkTools: SdkTool[];
|
||||
try {
|
||||
const toolBridge = await createToolBridge({
|
||||
allowModelTools: poolAcquire.provider.mode === "byok",
|
||||
modelProvider: modelRef.provider,
|
||||
modelId: modelRef.id,
|
||||
agentId: sessionAgentId,
|
||||
agentId: readString(params.agentId) ?? "copilot",
|
||||
sessionId: readString(input.sessionId) ?? "copilot-session",
|
||||
sessionKey: readString((input as { sessionKey?: unknown }).sessionKey),
|
||||
agentDir: readString(input.agentDir),
|
||||
@@ -672,7 +590,11 @@ export async function runCopilotAttempt(
|
||||
runAgentHarnessAfterToolCallHook({
|
||||
toolName,
|
||||
toolCallId,
|
||||
...toolHookRunContext,
|
||||
runId: input.runId,
|
||||
agentId: sessionAgentId,
|
||||
sessionId: input.sessionId,
|
||||
sessionKey: sandboxSessionKey,
|
||||
channelId: hookContext.channelId,
|
||||
startArgs: args,
|
||||
...(result !== undefined ? { result } : {}),
|
||||
...(error ? { error } : {}),
|
||||
@@ -770,7 +692,6 @@ export async function runCopilotAttempt(
|
||||
modelRef.id,
|
||||
sdkTools,
|
||||
poolAcquire.auth,
|
||||
sessionProvider,
|
||||
promptBuild.developerInstructions || undefined,
|
||||
effectiveWorkspaceDir,
|
||||
effectiveCwd,
|
||||
@@ -782,25 +703,6 @@ export async function runCopilotAttempt(
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
const compactionSessionConfig = byokProxy
|
||||
? createSessionConfig(
|
||||
attemptInput,
|
||||
modelRef.id,
|
||||
sdkTools,
|
||||
poolAcquire.auth,
|
||||
poolAcquire.provider,
|
||||
promptBuild.developerInstructions || undefined,
|
||||
effectiveWorkspaceDir,
|
||||
effectiveCwd,
|
||||
userInputBridge.onUserInputRequest,
|
||||
hasNativePromptHook
|
||||
? {
|
||||
onUserPromptSubmitted: ({ additionalContext, prompt }) =>
|
||||
emitLlmInput(prompt, additionalContext),
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
: sessionConfig;
|
||||
const replayDecision = decideReplayAction({
|
||||
sdkSessionId: input.initialReplayState?.sdkSessionId,
|
||||
replayInvalid: input.initialReplayState?.replayInvalid,
|
||||
@@ -847,12 +749,7 @@ export async function runCopilotAttempt(
|
||||
sessionIdUsed = sdkSessionId ?? input.sessionId;
|
||||
if (sdkSessionId && deps.onSessionEstablished) {
|
||||
try {
|
||||
deps.onSessionEstablished({
|
||||
compactionSessionConfig,
|
||||
sdkSessionId,
|
||||
pooledClient: handle,
|
||||
sessionConfig,
|
||||
});
|
||||
deps.onSessionEstablished({ sdkSessionId, pooledClient: handle, sessionConfig });
|
||||
} catch {
|
||||
// never let session-tracking callbacks break attempts
|
||||
}
|
||||
@@ -912,7 +809,6 @@ export async function runCopilotAttempt(
|
||||
const messageOptions = await createMessageOptions(attemptInput, {
|
||||
effectiveCwd,
|
||||
effectiveWorkspaceDir,
|
||||
provider: poolAcquire.provider,
|
||||
sandbox,
|
||||
workspaceOnly: effectiveFsWorkspaceOnly,
|
||||
});
|
||||
@@ -994,7 +890,6 @@ export async function runCopilotAttempt(
|
||||
awaitSessionIdle: !bridge.hasObservedSessionIdle(),
|
||||
bridge,
|
||||
cleanupToolBridge,
|
||||
cleanupByokProxy,
|
||||
finalizeNativeSubagents: () => nativeSubagentTaskMirror?.finalizeActiveRuns(),
|
||||
handle,
|
||||
pool: deps.pool,
|
||||
@@ -1027,7 +922,6 @@ export async function runCopilotAttempt(
|
||||
await bridge?.awaitAgentEventChain();
|
||||
nativeSubagentTaskMirror?.finalizeActiveRuns();
|
||||
cleanupToolBridge?.();
|
||||
await cleanupByokProxy?.();
|
||||
bridge?.detach();
|
||||
params.abortSignal?.removeEventListener("abort", onAbort);
|
||||
|
||||
@@ -1297,7 +1191,6 @@ function createSessionConfig(
|
||||
sdkModelId: string,
|
||||
sdkTools: SdkTool[],
|
||||
resolvedAuth: ReturnType<typeof resolveCopilotAuth>,
|
||||
resolvedProvider: ResolvedCopilotProvider,
|
||||
systemMessageContent: string | undefined,
|
||||
effectiveWorkspaceDir: string | undefined,
|
||||
effectiveCwd: string | undefined,
|
||||
@@ -1332,10 +1225,6 @@ function createSessionConfig(
|
||||
// Registers the SDK ask_user bridge. The bridge itself owns pending
|
||||
// reply routing so generic mid-run steering still fails closed.
|
||||
onUserInputRequest,
|
||||
// The SDK's ResumeSessionConfig declaration omits ProviderConfig, but its
|
||||
// client forwards config.provider on both session.create and session.resume.
|
||||
// Keep one session config so BYOK resume/compaction stays on the same wire.
|
||||
...(resolvedProvider.provider ? { provider: resolvedProvider.provider } : {}),
|
||||
// Preserve the shipped native SDK hook contract. These callbacks expose
|
||||
// Copilot-specific events and decisions that generic lifecycle hooks do
|
||||
// not model.
|
||||
@@ -1425,28 +1314,14 @@ async function createMessageOptions(
|
||||
context: {
|
||||
effectiveCwd: string | undefined;
|
||||
effectiveWorkspaceDir: string | undefined;
|
||||
provider: ResolvedCopilotProvider;
|
||||
sandbox: SandboxContext | null;
|
||||
workspaceOnly: boolean;
|
||||
},
|
||||
): Promise<MessageOptions> {
|
||||
const attachments = createPromptImageAttachments(await resolvePromptImages(params, context));
|
||||
const requestHeaders = resolveProviderRequestHeaders(context.provider);
|
||||
return {
|
||||
prompt: params.prompt,
|
||||
...(attachments.length > 0 ? { attachments } : {}),
|
||||
// The SDK declares session-level provider headers, but its Anthropic
|
||||
// runtime path consumes per-turn requestHeaders. Mirror them here so BYOK
|
||||
// tenant/proxy headers survive every supported adapter.
|
||||
...(requestHeaders ? { requestHeaders } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveProviderRequestHeaders(
|
||||
provider: ResolvedCopilotProvider,
|
||||
): Record<string, string> | undefined {
|
||||
const headers = provider.provider?.headers;
|
||||
return headers && Object.keys(headers).length > 0 ? { ...headers } : undefined;
|
||||
return attachments.length > 0
|
||||
? { prompt: params.prompt, attachments }
|
||||
: { prompt: params.prompt };
|
||||
}
|
||||
|
||||
function createPromptImageAttachments(
|
||||
@@ -1613,35 +1488,18 @@ function readResolvedAttemptPath(value: unknown): string | undefined {
|
||||
}
|
||||
|
||||
export function resolveModelRef(params: AttemptParamsLike): ModelRef {
|
||||
const rawModel = (params as { runtimeModel?: unknown }).runtimeModel ?? params.model;
|
||||
const rawModel = params.model;
|
||||
if (rawModel && typeof rawModel === "object") {
|
||||
const model = rawModel as ModelRefInputObject;
|
||||
const requestTransport = getModelProviderRequestTransport(rawModel);
|
||||
const rawRequest = model.request;
|
||||
return {
|
||||
api: readString(model.api),
|
||||
api: readString(rawModel.api),
|
||||
id:
|
||||
readString(model.id) ??
|
||||
readString(rawModel.id) ??
|
||||
readString((params as { modelId?: unknown }).modelId) ??
|
||||
"unknown-model",
|
||||
provider:
|
||||
readString(model.provider) ??
|
||||
readString(rawModel.provider) ??
|
||||
readString((params as { provider?: unknown }).provider) ??
|
||||
"unknown-provider",
|
||||
baseUrl: readString(model.baseUrl),
|
||||
azureApiVersion: readString(
|
||||
model.azureApiVersion ?? model.params?.azureApiVersion,
|
||||
),
|
||||
headers: model.headers,
|
||||
authHeader: model.authHeader,
|
||||
requestAuthMode: readString(requestTransport?.auth?.mode ?? rawRequest?.auth?.mode),
|
||||
requestProxy: requestTransport?.proxy ?? rawRequest?.proxy,
|
||||
requestTls: requestTransport?.tls ?? rawRequest?.tls,
|
||||
requestAllowPrivateNetwork:
|
||||
requestTransport?.allowPrivateNetwork ?? rawRequest?.allowPrivateNetwork,
|
||||
contextTokens: model.contextTokens,
|
||||
contextWindow: model.contextWindow,
|
||||
maxTokens: model.maxTokens,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -1671,59 +1529,40 @@ export function resolvePoolAcquire(params: AttemptParamsLike): {
|
||||
* setting both.
|
||||
*/
|
||||
auth: ReturnType<typeof resolveCopilotAuth>;
|
||||
provider: ResolvedCopilotProvider;
|
||||
} {
|
||||
const model = resolveModelRef(params);
|
||||
const provider = resolveCopilotProvider({
|
||||
model,
|
||||
const resolved = resolveCopilotAuth({
|
||||
agentId: readString(params.agentId),
|
||||
agentDir: readString(params.agentDir),
|
||||
workspaceDir: readString(params.workspaceDir),
|
||||
copilotHome: readString(params.copilotHome),
|
||||
auth: params.auth,
|
||||
// Contract-resolved auth (EmbeddedRunAttemptParams): the production
|
||||
// main path for agents with a configured `github-copilot` auth
|
||||
// profile. Falling through to env / useLoggedInUser when absent
|
||||
// keeps the direct-CLI / dogfood paths working unchanged.
|
||||
resolvedApiKey: readString(params.resolvedApiKey),
|
||||
authProfileId: readString(params.authProfileId),
|
||||
profileVersion: readString(params.profileVersion),
|
||||
});
|
||||
const auth =
|
||||
provider.mode === "byok"
|
||||
? createCopilotByokAuth({
|
||||
agentId: readString(params.agentId),
|
||||
agentDir: readString(params.agentDir),
|
||||
workspaceDir: readString(params.workspaceDir),
|
||||
copilotHome: readString(params.copilotHome),
|
||||
authProfileId: provider.authProfileId,
|
||||
authProfileVersion: provider.authProfileVersion,
|
||||
})
|
||||
: resolveCopilotAuth({
|
||||
agentId: readString(params.agentId),
|
||||
agentDir: readString(params.agentDir),
|
||||
workspaceDir: readString(params.workspaceDir),
|
||||
copilotHome: readString(params.copilotHome),
|
||||
auth: params.auth,
|
||||
// Contract-resolved auth (EmbeddedRunAttemptParams): the production
|
||||
// main path for agents with a configured `github-copilot` auth
|
||||
// profile. Falling through to env / useLoggedInUser when absent
|
||||
// keeps the direct-CLI / dogfood paths working unchanged.
|
||||
resolvedApiKey: readString(params.resolvedApiKey),
|
||||
authProfileId: readString(params.authProfileId),
|
||||
profileVersion: readString(params.profileVersion),
|
||||
});
|
||||
|
||||
return {
|
||||
key: {
|
||||
agentId: auth.agentId,
|
||||
authMode: auth.authMode,
|
||||
...(auth.authMode === "gitHubToken" || auth.authMode === "byok"
|
||||
agentId: resolved.agentId,
|
||||
authMode: resolved.authMode,
|
||||
...(resolved.authMode === "gitHubToken"
|
||||
? {
|
||||
authProfileId: auth.authProfileId,
|
||||
authProfileVersion: auth.authProfileVersion,
|
||||
authProfileId: resolved.authProfileId,
|
||||
authProfileVersion: resolved.authProfileVersion,
|
||||
}
|
||||
: {}),
|
||||
copilotHome: auth.copilotHome,
|
||||
copilotHome: resolved.copilotHome,
|
||||
},
|
||||
options: {
|
||||
copilotHome: auth.copilotHome,
|
||||
...(auth.authMode === "gitHubToken" && auth.gitHubToken
|
||||
? { gitHubToken: auth.gitHubToken }
|
||||
: {}),
|
||||
useLoggedInUser: auth.authMode === "useLoggedInUser",
|
||||
copilotHome: resolved.copilotHome,
|
||||
gitHubToken: resolved.authMode === "gitHubToken" ? resolved.gitHubToken : undefined,
|
||||
useLoggedInUser: resolved.authMode === "useLoggedInUser",
|
||||
},
|
||||
auth,
|
||||
provider,
|
||||
auth: resolved,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user