mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-26 09:12:13 +08:00
Compare commits
68 Commits
omarshahin
...
codex/tool
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d44260b927 | ||
|
|
c588606a9b | ||
|
|
7c56877eb1 | ||
|
|
7844b08445 | ||
|
|
ae9474b5fd | ||
|
|
e4763b0631 | ||
|
|
af2b0a6118 | ||
|
|
2a484a3ff1 | ||
|
|
1069c60e1e | ||
|
|
9e68fb1178 | ||
|
|
ae06d846fa | ||
|
|
380f2749be | ||
|
|
20293036ca | ||
|
|
bfffc77bfc | ||
|
|
e9720c27fa | ||
|
|
8242923fe3 | ||
|
|
414c250af9 | ||
|
|
f65aca64fc | ||
|
|
a2725b6a24 | ||
|
|
63ee4cd240 | ||
|
|
599294b9af | ||
|
|
bd43c36bb1 | ||
|
|
560ecafa2d | ||
|
|
9666db607e | ||
|
|
9773cbafdb | ||
|
|
74214000bf | ||
|
|
33afb1ec70 | ||
|
|
d9034da0a6 | ||
|
|
4a503ed45e | ||
|
|
a96418c65f | ||
|
|
9d381d4530 | ||
|
|
52aef22909 | ||
|
|
60695c1215 | ||
|
|
d1a7d457e6 | ||
|
|
12345e4c9b | ||
|
|
f9cf00c351 | ||
|
|
fd66b44f5e | ||
|
|
2ab3b223ed | ||
|
|
9e3a917d9e | ||
|
|
487951f813 | ||
|
|
89b2db77d4 | ||
|
|
cf86a9799c | ||
|
|
0671c08900 | ||
|
|
89460288c4 | ||
|
|
93bb6e6c14 | ||
|
|
10acda0514 | ||
|
|
bf29f73f19 | ||
|
|
3875f678a0 | ||
|
|
c794608230 | ||
|
|
89acdd95dc | ||
|
|
82ccee027c | ||
|
|
c2d102b6ee | ||
|
|
7b9f4aefa2 | ||
|
|
d15e89a83e | ||
|
|
2fc260aa09 | ||
|
|
8739f1e17e | ||
|
|
d42b864219 | ||
|
|
ce0142f04e | ||
|
|
d4c151844a | ||
|
|
20a87e17f5 | ||
|
|
3dea94f4cb | ||
|
|
da15cf48bf | ||
|
|
54c0048d6c | ||
|
|
2ad2e4f2dc | ||
|
|
28a90b0e82 | ||
|
|
63874fa0d1 | ||
|
|
4d034639ad | ||
|
|
d9298a74be |
196
.agents/skills/openclaw-ci-limits/SKILL.md
Normal file
196
.agents/skills/openclaw-ci-limits/SKILL.md
Normal file
@@ -0,0 +1,196 @@
|
||||
---
|
||||
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.
|
||||
4
.agents/skills/openclaw-ci-limits/agents/openai.yaml
Normal file
4
.agents/skills/openclaw-ci-limits/agents/openai.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
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,6 +118,7 @@
|
||||
- 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: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
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: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
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: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
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: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
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: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
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: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
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: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
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: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
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: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
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: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
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: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
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: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
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: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
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: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
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: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
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: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
@@ -609,7 +609,6 @@ 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:
|
||||
@@ -643,9 +642,74 @@ 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
|
||||
@@ -665,6 +729,15 @@ 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,11 +151,39 @@ 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: ${{ inputs.target_ref || github.ref }}
|
||||
ref: ${{ steps.target.outputs.checkout_ref }}
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
39
CHANGELOG.md
39
CHANGELOG.md
@@ -2,6 +2,45 @@
|
||||
|
||||
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,11 +2,7 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 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.
|
||||
Maintenance update for the current OpenClaw Android release.
|
||||
|
||||
## 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.9
|
||||
OPENCLAW_ANDROID_VERSION_CODE=2026060901
|
||||
OPENCLAW_ANDROID_VERSION_NAME=2026.6.10
|
||||
OPENCLAW_ANDROID_VERSION_CODE=2026061001
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
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.
|
||||
Maintenance update for the current OpenClaw Android release.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"version": "2026.6.9",
|
||||
"versionCode": 2026060901
|
||||
"version": "2026.6.10",
|
||||
"versionCode": 2026061001
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 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.9
|
||||
OPENCLAW_MARKETING_VERSION = 2026.6.9
|
||||
OPENCLAW_IOS_VERSION = 2026.6.10
|
||||
OPENCLAW_MARKETING_VERSION = 2026.6.10
|
||||
OPENCLAW_BUILD_VERSION = 1
|
||||
|
||||
#include? "../build/Version.xcconfig"
|
||||
|
||||
@@ -5,6 +5,7 @@ require "fileutils"
|
||||
require "tmpdir"
|
||||
require "tempfile"
|
||||
require "cgi"
|
||||
require "digest/md5"
|
||||
|
||||
default_platform(:ios)
|
||||
|
||||
@@ -51,6 +52,10 @@ 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)
|
||||
@@ -83,10 +88,6 @@ 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) }
|
||||
@@ -780,6 +781,259 @@ 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(
|
||||
@@ -1092,11 +1346,11 @@ platform :ios do
|
||||
app_id = nil unless env_present?(app_id)
|
||||
|
||||
if screenshot_upload_requested?
|
||||
paths = screenshot_paths
|
||||
if paths.empty?
|
||||
screenshots_to_upload = app_store_screenshot_manifest
|
||||
if screenshots_to_upload.empty?
|
||||
UI.user_error!("DELIVER_SCREENSHOTS=1 but no PNG screenshots were found under apps/ios/fastlane/screenshots.")
|
||||
end
|
||||
validate_required_screenshots!(paths)
|
||||
validate_required_screenshots!(screenshots_to_upload.map(&:path))
|
||||
end
|
||||
|
||||
assert_no_app_review_notes_field_metadata!(File.join(__dir__, "metadata"))
|
||||
@@ -1117,10 +1371,10 @@ platform :ios do
|
||||
primary_category: "PRODUCTIVITY",
|
||||
secondary_category: "UTILITIES",
|
||||
metadata_path: metadata_path,
|
||||
skip_screenshots: !screenshot_upload_requested?,
|
||||
skip_screenshots: true,
|
||||
skip_metadata: skip_metadata,
|
||||
skip_binary_upload: true,
|
||||
overwrite_screenshots: screenshot_upload_requested?,
|
||||
overwrite_screenshots: false,
|
||||
app_review_attachment_file: app_review_attachment_file,
|
||||
skip_app_version_update: false,
|
||||
submit_for_review: false,
|
||||
@@ -1134,6 +1388,14 @@ 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"
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
Maintenance update for the current OpenClaw release.
|
||||
Maintenance update for the current OpenClaw beta release.
|
||||
|
||||
- Added Apple Watch controls for common agent actions.
|
||||
- Improved Gateway setup, notification settings, and share-extension identity handling.
|
||||
- Updated the Watch app integration for current Xcode compatibility.
|
||||
- Improved notification cleanup, Watch app compatibility, and native file input handling.
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "2026.6.9"
|
||||
"version": "2026.6.10"
|
||||
}
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.6.9</string>
|
||||
<string>2026.6.10</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>2026060900</string>
|
||||
<string>2026061000</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>OpenClaw</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
ee542300a1f9d5c23e772d47f2acfcc92ee0a4da210974306790bf2220b80277 config-baseline.json
|
||||
9246475f5771612a5fd12de38b153783c4a4cbb8b2682a5c40115916661c90f2 config-baseline.json
|
||||
6349131baaa1828f2a071f42e4d7b17c8966c59b6588c8a4c1a32ea5ea4dcd5e config-baseline.core.json
|
||||
de674ef01dad2828bb711a4648dc5a00f696f71c3c59004131d9475769bc1ff8 config-baseline.channel.json
|
||||
ce2a731077f0f0135b7eaf01b00a60abfa0d2776aba4be237491d492af0c8a02 config-baseline.plugin.json
|
||||
671979e86e4c4f59415d0a20879e838f9bbd883b3d29eeb02cb5131db8d187fe config-baseline.channel.json
|
||||
94529978588d6e3776a86780b22cf9ff46a6f9957f2f178d3829403fad451ca7 config-baseline.plugin.json
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
f7247b5bbfe3f96bffffd25a8be2f89b37999e36731f34a159ae21ded1cedd05 plugin-sdk-api-baseline.json
|
||||
ce88a53dadc194ceccc63f50146aee03a1a425f551117da826a21519d5bf80db plugin-sdk-api-baseline.jsonl
|
||||
212b76ef72779add8f18be4848e143e61b6ae42a1c7daeefdc42d91e0a1152e9 plugin-sdk-api-baseline.json
|
||||
976179e09e9e46a9b9259bd20ca1cafc8883c8e281a099a9aaa5fceab3c2983b plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -24,6 +24,14 @@ 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,15 +133,30 @@ 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, 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` |
|
||||
| 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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -488,7 +503,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 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.
|
||||
`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.
|
||||
|
||||
Manual dispatch accepts:
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ openclaw gateway restart
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
openclaw workboard list [--board <id>] [--status <status>] [--json]
|
||||
openclaw workboard list [--board <id>] [--status <status>] [--include-archived] [--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,11 +50,16 @@ 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 |
|
||||
| `--json` | Print the full card list as machine JSON |
|
||||
| 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.
|
||||
|
||||
## `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 agent-runtime-and-provider-execution.agent-turn-execution \
|
||||
--category channel-framework.conversation-routing-and-delivery \
|
||||
--provider-mode mock-openai \
|
||||
--output-dir .artifacts/qa-e2e/smoke-ci-profile-dispatch
|
||||
```
|
||||
@@ -178,10 +178,21 @@ 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, run:
|
||||
For a transport-real Matrix smoke lane that does not require model-provider
|
||||
credentials, run the fast profile with the deterministic mock OpenAI provider:
|
||||
|
||||
```bash
|
||||
pnpm openclaw qa matrix --profile fast --fail-fast
|
||||
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
|
||||
```
|
||||
|
||||
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>/`.
|
||||
@@ -201,9 +212,10 @@ 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 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
|
||||
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.
|
||||
|
||||
For transport-real Telegram, Discord, Slack, and WhatsApp smoke lanes:
|
||||
|
||||
@@ -966,6 +978,7 @@ 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:
|
||||
@@ -1023,6 +1036,7 @@ 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,6 +199,10 @@ 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,6 +20,7 @@ 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.
|
||||
@@ -740,17 +741,20 @@ Native dependency policy:
|
||||
- Command: `pnpm test:e2e:openshell`
|
||||
- File: `extensions/openshell/src/backend.e2e.test.ts`
|
||||
- Scope:
|
||||
- Starts an isolated OpenShell gateway on the host via Docker
|
||||
- Reuses an active local OpenShell gateway
|
||||
- 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
|
||||
- Uses isolated `HOME` / `XDG_CONFIG_HOME`, then destroys the test gateway and sandbox
|
||||
- Requires an active local OpenShell gateway and its config source
|
||||
- Uses isolated `HOME` / `XDG_CONFIG_HOME`, then destroys the test 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,6 +279,100 @@ 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,8 +103,65 @@ The harness advertises support for the canonical `github-copilot` provider
|
||||
|
||||
- `github-copilot`
|
||||
|
||||
Anything outside that set falls through `selection.ts`'s `auto_pi` branch back
|
||||
to PI.
|
||||
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.
|
||||
|
||||
## Auth
|
||||
|
||||
@@ -151,10 +208,11 @@ 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
|
||||
|
||||
@@ -163,9 +221,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? }`. When omitted, OpenClaw uses
|
||||
the agent's normal model selection and the harness verifies the resolved
|
||||
provider is in the supported set.
|
||||
- `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.
|
||||
- `reasoningEffort` — `"low" | "medium" | "high" | "xhigh"`. Maps from
|
||||
OpenClaw's `ThinkLevel` / `ReasoningLevel` resolution in
|
||||
`auto-reply/thinking.ts`.
|
||||
@@ -252,9 +310,9 @@ under `describe("runSideQuestion")`.
|
||||
|
||||
## Limitations
|
||||
|
||||
- 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 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 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,8 +186,12 @@ 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.toolKind`,
|
||||
`ctx.toolInputKind`, and diagnostic `ctx.trace`
|
||||
`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.
|
||||
|
||||
It can return:
|
||||
|
||||
|
||||
@@ -104,9 +104,12 @@ Anthropic's current public docs:
|
||||
|
||||
<Warning>
|
||||
Claude CLI reuse expects the OpenClaw process to run on the same host as the
|
||||
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
|
||||
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
|
||||
[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.9",
|
||||
"version": "2026.6.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/acpx",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.10",
|
||||
"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.9",
|
||||
"version": "2026.6.10",
|
||||
"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.9"
|
||||
"pluginApi": ">=2026.6.10"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.9",
|
||||
"openclawVersion": "2026.6.10",
|
||||
"staticAssets": [
|
||||
{
|
||||
"source": "./src/runtime-internals/mcp-proxy.mjs",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/admin-http-rpc",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.10",
|
||||
"private": true,
|
||||
"description": "OpenClaw admin HTTP RPC endpoint",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/alibaba-provider",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.10",
|
||||
"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.9",
|
||||
"version": "2026.6.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/amazon-bedrock-mantle-provider",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.10",
|
||||
"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.9",
|
||||
"version": "2026.6.10",
|
||||
"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.9"
|
||||
"pluginApi": ">=2026.6.10"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.9",
|
||||
"openclawVersion": "2026.6.10",
|
||||
"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.9",
|
||||
"version": "2026.6.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/amazon-bedrock-provider",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.10",
|
||||
"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.9",
|
||||
"version": "2026.6.10",
|
||||
"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.9"
|
||||
"pluginApi": ">=2026.6.10"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.9",
|
||||
"openclawVersion": "2026.6.10",
|
||||
"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.9",
|
||||
"version": "2026.6.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/anthropic-vertex-provider",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.10",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/vertex-sdk": "0.16.1"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/anthropic-vertex-provider",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.10",
|
||||
"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.9"
|
||||
"pluginApi": ">=2026.6.10"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.9",
|
||||
"openclawVersion": "2026.6.10",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/anthropic-provider",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.10",
|
||||
"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.9",
|
||||
"version": "2026.6.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/arcee-provider",
|
||||
"version": "2026.6.9"
|
||||
"version": "2026.6.10"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/arcee-provider",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.10",
|
||||
"description": "OpenClaw Arcee provider plugin.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -21,10 +21,10 @@
|
||||
"minHostVersion": ">=2026.6.8"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.9"
|
||||
"pluginApi": ">=2026.6.10"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.9",
|
||||
"openclawVersion": "2026.6.10",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/azure-speech",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.10",
|
||||
"private": true,
|
||||
"description": "OpenClaw Azure Speech plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/bonjour",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.10",
|
||||
"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.9",
|
||||
"version": "2026.6.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/brave-plugin",
|
||||
"version": "2026.6.9"
|
||||
"version": "2026.6.10"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/brave-plugin",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.10",
|
||||
"description": "OpenClaw Brave Search provider plugin for web search.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -21,10 +21,10 @@
|
||||
"allowInvalidConfigRecovery": true
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.9"
|
||||
"pluginApi": ">=2026.6.10"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.9"
|
||||
"openclawVersion": "2026.6.10"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/browser-plugin",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.10",
|
||||
"private": true,
|
||||
"description": "OpenClaw browser tool plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -779,6 +779,7 @@ 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();
|
||||
@@ -797,7 +798,13 @@ async function buildCdpRoleSnapshot(params: {
|
||||
params.nextRef.value += 1;
|
||||
node.ref = ref;
|
||||
node.nth = nth;
|
||||
refsByKey.set(key, [...(refsByKey.get(key) ?? []), ref]);
|
||||
const refsForKey = refsByKey.get(key);
|
||||
if (refsForKey) {
|
||||
refsForKey.push(ref);
|
||||
} else {
|
||||
refsByKey.set(key, [ref]);
|
||||
}
|
||||
nodesByRef.set(ref, node);
|
||||
refs[ref] = {
|
||||
role,
|
||||
...(node.name ? { name: node.name } : {}),
|
||||
@@ -813,7 +820,7 @@ async function buildCdpRoleSnapshot(params: {
|
||||
const ref = refList[0];
|
||||
if (ref) {
|
||||
delete refs[ref]?.nth;
|
||||
const node = tree.find((entry) => entry.ref === ref);
|
||||
const node = nodesByRef.get(ref);
|
||||
if (node) {
|
||||
delete node.nth;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,16 @@ 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,37 +131,42 @@ function removeNthFromNonDuplicates(refs: RoleRefMap, tracker: RoleNameTracker)
|
||||
|
||||
function compactTree(tree: string) {
|
||||
const lines = tree.split("\n");
|
||||
const result: string[] = [];
|
||||
const entries: Array<{ line: string; keep: boolean; hasRef: boolean; indent: number }> = [];
|
||||
const stack: Array<{ entry: (typeof entries)[number]; indent: number }> = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const line = lines[i];
|
||||
if (line.includes("[ref=")) {
|
||||
result.push(line);
|
||||
continue;
|
||||
const finishEntry = () => {
|
||||
const current = stack.pop();
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
if (line.includes(":") && !line.trimEnd().endsWith(":")) {
|
||||
result.push(line);
|
||||
continue;
|
||||
current.entry.keep ||= current.entry.hasRef;
|
||||
if (current.entry.hasRef && stack.length > 0) {
|
||||
stack[stack.length - 1].entry.hasRef = true;
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
for (const line of lines) {
|
||||
const indent = getIndentLevel(line);
|
||||
while (stack.length > 0 && stack[stack.length - 1].indent >= indent) {
|
||||
finishEntry();
|
||||
}
|
||||
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 result.join("\n");
|
||||
return entries
|
||||
.filter((entry) => entry.keep)
|
||||
.map((entry) => entry.line)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function processLine(
|
||||
|
||||
@@ -104,7 +104,12 @@ function buildStoredAriaRefs(
|
||||
const key = `${role}:${name ?? ""}`;
|
||||
const nth = counts.get(key) ?? 0;
|
||||
counts.set(key, nth + 1);
|
||||
refsByKey.set(key, [...(refsByKey.get(key) ?? []), node.ref]);
|
||||
const refsForKey = refsByKey.get(key);
|
||||
if (refsForKey) {
|
||||
refsForKey.push(node.ref);
|
||||
} else {
|
||||
refsByKey.set(key, [node.ref]);
|
||||
}
|
||||
refs[node.ref] = {
|
||||
role,
|
||||
...(name ? { name } : {}),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/byteplus-provider",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.10",
|
||||
"private": true,
|
||||
"description": "OpenClaw BytePlus provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/canvas-plugin",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.10",
|
||||
"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.9",
|
||||
"version": "2026.6.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/cerebras-provider",
|
||||
"version": "2026.6.9"
|
||||
"version": "2026.6.10"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/cerebras-provider",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.10",
|
||||
"description": "OpenClaw Cerebras provider plugin.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -21,10 +21,10 @@
|
||||
"minHostVersion": ">=2026.6.8"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.9"
|
||||
"pluginApi": ">=2026.6.10"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.9",
|
||||
"openclawVersion": "2026.6.10",
|
||||
"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.9",
|
||||
"version": "2026.6.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/chutes-provider",
|
||||
"version": "2026.6.9"
|
||||
"version": "2026.6.10"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/chutes-provider",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.10",
|
||||
"description": "OpenClaw Chutes.ai provider plugin.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -21,10 +21,10 @@
|
||||
"minHostVersion": ">=2026.6.8"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.9"
|
||||
"pluginApi": ">=2026.6.10"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.9",
|
||||
"openclawVersion": "2026.6.10",
|
||||
"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.9",
|
||||
"version": "2026.6.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/clickclack",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.10",
|
||||
"dependencies": {
|
||||
"ws": "8.21.0",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.6.9"
|
||||
"openclaw": ">=2026.6.10"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/clickclack",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.10",
|
||||
"description": "OpenClaw ClickClack channel plugin",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -17,7 +17,7 @@
|
||||
"openclaw": "2026.5.28"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.6.9"
|
||||
"openclaw": ">=2026.6.10"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
@@ -53,10 +53,10 @@
|
||||
"allowInvalidConfigRecovery": true
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.9"
|
||||
"pluginApi": ">=2026.6.10"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.9",
|
||||
"openclawVersion": "2026.6.10",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/cloudflare-ai-gateway-provider",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/cloudflare-ai-gateway-provider",
|
||||
"version": "2026.6.9"
|
||||
"version": "2026.6.10"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/cloudflare-ai-gateway-provider",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.10",
|
||||
"description": "OpenClaw Cloudflare AI Gateway provider plugin.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -21,10 +21,10 @@
|
||||
"minHostVersion": ">=2026.6.8"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.9"
|
||||
"pluginApi": ">=2026.6.10"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.9",
|
||||
"openclawVersion": "2026.6.10",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/codex-supervisor",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.10",
|
||||
"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.9",
|
||||
"version": "2026.6.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/codex",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.10",
|
||||
"dependencies": {
|
||||
"@openai/codex": "0.139.0",
|
||||
"typebox": "1.1.39",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/codex",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.10",
|
||||
"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.9"
|
||||
"pluginApi": ">=2026.6.10"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.9"
|
||||
"openclawVersion": "2026.6.10"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -12,7 +12,11 @@ import {
|
||||
type EmbeddedRunAttemptParams,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildApprovalResponse, handleCodexAppServerApprovalRequest } from "./approval-bridge.js";
|
||||
import {
|
||||
buildApprovalResponse,
|
||||
handleCodexAppServerApprovalRequest as handleCodexAppServerApprovalRequestImpl,
|
||||
} from "./approval-bridge.js";
|
||||
import { buildCodexToolHookRunContext } from "./tool-hook-context.js";
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/agent-harness-runtime", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("openclaw/plugin-sdk/agent-harness-runtime")>()),
|
||||
@@ -42,6 +46,20 @@ 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}`);
|
||||
@@ -243,6 +261,8 @@ describe("Codex app-server approval bridge", () => {
|
||||
ctx: {
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:session-1",
|
||||
messageProvider: "telegram",
|
||||
channel: "telegram",
|
||||
channelId: "chat-1",
|
||||
},
|
||||
});
|
||||
@@ -1164,11 +1184,18 @@ describe("Codex app-server approval bridge", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes prefixed channel targets for OpenClaw tool policy context", async () => {
|
||||
it("uses the caller-resolved hook context for approval fallback policy", async () => {
|
||||
const params = createParams();
|
||||
params.messageChannel = "telegram";
|
||||
params.messageProvider = "telegram";
|
||||
params.currentChannelId = "telegram:-100123";
|
||||
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";
|
||||
mockCallGatewayTool
|
||||
.mockResolvedValueOnce({ id: "plugin:approval-prefixed", status: "accepted" })
|
||||
.mockResolvedValueOnce({ id: "plugin:approval-prefixed", decision: "allow-once" });
|
||||
@@ -1182,6 +1209,27 @@ 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",
|
||||
});
|
||||
@@ -1189,11 +1237,29 @@ 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("telegram:-100123");
|
||||
expect(gatewayRequestPayload().turnSourceTo).toBe("discord:raw-target");
|
||||
});
|
||||
|
||||
it("denies command approvals before prompting when OpenClaw tool policy blocks", async () => {
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
*/
|
||||
import {
|
||||
type AgentApprovalEventData,
|
||||
buildAgentHookContextChannelFields,
|
||||
formatApprovalDisplayPath,
|
||||
hasNativeHookRelayInvocation,
|
||||
invokeNativeHookRelay,
|
||||
@@ -17,6 +16,7 @@ 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,6 +75,7 @@ export async function handleCodexAppServerApprovalRequest(params: {
|
||||
method: string;
|
||||
requestParams: JsonValue | undefined;
|
||||
paramsForRun: EmbeddedRunAttemptParams;
|
||||
toolHookContext: ToolHookRunContext;
|
||||
threadId: string;
|
||||
turnId: string;
|
||||
nativeHookRelay?: Pick<
|
||||
@@ -106,6 +107,7 @@ export async function handleCodexAppServerApprovalRequest(params: {
|
||||
method: params.method,
|
||||
requestParams,
|
||||
paramsForRun: params.paramsForRun,
|
||||
toolHookContext: params.toolHookContext,
|
||||
context,
|
||||
nativeHookRelay: params.nativeHookRelay,
|
||||
signal: params.signal,
|
||||
@@ -619,6 +621,7 @@ async function runOpenClawToolPolicyForApprovalRequest(params: {
|
||||
method: string;
|
||||
requestParams: JsonObject | undefined;
|
||||
paramsForRun: EmbeddedRunAttemptParams;
|
||||
toolHookContext: ToolHookRunContext;
|
||||
context: ApprovalContext;
|
||||
nativeHookRelay?: Pick<
|
||||
NativeHookRelayRegistrationHandle,
|
||||
@@ -652,13 +655,6 @@ 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,
|
||||
@@ -666,13 +662,9 @@ async function runOpenClawToolPolicyForApprovalRequest(params: {
|
||||
approvalMode: "request",
|
||||
signal: params.signal,
|
||||
ctx: {
|
||||
...(params.paramsForRun.agentId ? { agentId: params.paramsForRun.agentId } : {}),
|
||||
...params.toolHookContext,
|
||||
...(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,8 +944,16 @@ describe("Codex app-server dynamic tool build", () => {
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.disableTools = false;
|
||||
params.currentChannelId = "D123";
|
||||
params.messageChannel = "discord";
|
||||
params.messageProvider = "discord-voice";
|
||||
params.currentChannelId = "discord: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) => {
|
||||
@@ -956,9 +964,19 @@ describe("Codex app-server dynamic tool build", () => {
|
||||
await buildDynamicToolsForTest(params, workspaceDir, { sandbox: null as never });
|
||||
|
||||
expect(factoryOptions[0]).toMatchObject({
|
||||
currentChannelId: "D123",
|
||||
messageChannel: "discord",
|
||||
messageProvider: "discord",
|
||||
toolPolicyMessageProvider: "discord-voice",
|
||||
currentChannelId: "discord: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.messageTo,
|
||||
messageTo: params.currentMessagingTarget ?? params.messageTo,
|
||||
}).channelId;
|
||||
}
|
||||
|
||||
@@ -239,6 +239,7 @@ 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,
|
||||
@@ -249,6 +250,7 @@ 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,
|
||||
@@ -290,6 +292,7 @@ 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,
|
||||
|
||||
@@ -1846,6 +1846,17 @@ 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" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1949,6 +1960,17 @@ 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" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1975,6 +1997,17 @@ 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" } });
|
||||
@@ -1997,6 +2030,17 @@ 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",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ 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";
|
||||
@@ -53,13 +54,8 @@ import type {
|
||||
JsonValue,
|
||||
} from "./protocol.js";
|
||||
|
||||
type CodexDynamicToolHookContext = {
|
||||
agentId?: string;
|
||||
type CodexDynamicToolHookContext = ToolHookRunContext & {
|
||||
config?: EmbeddedRunAttemptParams["config"];
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
runId?: string;
|
||||
channelId?: string;
|
||||
currentChannelProvider?: string;
|
||||
currentChannelId?: string;
|
||||
currentMessagingTarget?: string;
|
||||
@@ -70,7 +66,7 @@ type CodexDynamicToolHookContext = {
|
||||
allocateToolOutcomeOrdinal?: EmbeddedRunAttemptParams["allocateToolOutcomeOrdinal"];
|
||||
};
|
||||
|
||||
type CodexToolResultHookContext = Omit<CodexDynamicToolHookContext, "config">;
|
||||
type CodexToolResultHookContext = ToolHookRunContext;
|
||||
|
||||
type ProjectedCodexDynamicTool = {
|
||||
tool: AnyAgentTool;
|
||||
@@ -310,11 +306,7 @@ export function createCodexDynamicToolBridge(params: {
|
||||
void runAgentHarnessAfterToolCallHook({
|
||||
toolName,
|
||||
toolCallId: call.callId,
|
||||
runId: toolResultHookContext.runId,
|
||||
agentId: toolResultHookContext.agentId,
|
||||
sessionId: toolResultHookContext.sessionId,
|
||||
sessionKey: toolResultHookContext.sessionKey,
|
||||
channelId: toolResultHookContext.channelId,
|
||||
...toolResultHookContext,
|
||||
startArgs: executedArgs,
|
||||
result,
|
||||
startedAt,
|
||||
@@ -407,11 +399,7 @@ export function createCodexDynamicToolBridge(params: {
|
||||
void runAgentHarnessAfterToolCallHook({
|
||||
toolName,
|
||||
toolCallId: call.callId,
|
||||
runId: toolResultHookContext.runId,
|
||||
agentId: toolResultHookContext.agentId,
|
||||
sessionId: toolResultHookContext.sessionId,
|
||||
sessionKey: toolResultHookContext.sessionKey,
|
||||
channelId: toolResultHookContext.channelId,
|
||||
...toolResultHookContext,
|
||||
startArgs: executedArgs,
|
||||
error: errorMessage,
|
||||
startedAt,
|
||||
@@ -702,13 +690,35 @@ function dedupeQuarantinedDynamicTools(
|
||||
function toToolResultHookContext(
|
||||
ctx: CodexDynamicToolHookContext | undefined,
|
||||
): CodexToolResultHookContext {
|
||||
const { agentId, sessionId, sessionKey, runId, channelId } = ctx ?? {};
|
||||
const {
|
||||
agentId,
|
||||
sessionId,
|
||||
sessionKey,
|
||||
runId,
|
||||
jobId,
|
||||
trace,
|
||||
trigger,
|
||||
messageProvider,
|
||||
channel,
|
||||
chatId,
|
||||
senderId,
|
||||
channelId,
|
||||
channelContext,
|
||||
} = 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 }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ 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,
|
||||
@@ -743,6 +744,47 @@ 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);
|
||||
@@ -2545,15 +2587,36 @@ describe("CodexAppServerEventProjector", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("emits after_tool_call observations for Codex-native tool item completions", async () => {
|
||||
it("keeps resolved hook identity authoritative for Codex-native tool completions", async () => {
|
||||
const afterToolCall = vi.fn();
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "after_tool_call", handler: afterToolCall }]),
|
||||
);
|
||||
const projector = await createProjector({
|
||||
const projectorParams = {
|
||||
...(await createParams()),
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:session-1",
|
||||
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" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await projector.handleNotification(
|
||||
@@ -2610,6 +2673,17 @@ 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,6 +18,7 @@ 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";
|
||||
@@ -65,6 +66,7 @@ export type CodexAppServerToolTelemetry = {
|
||||
|
||||
export type CodexAppServerEventProjectorOptions = {
|
||||
nativePostToolUseRelayEnabled?: boolean;
|
||||
toolHookContext?: ToolHookRunContext;
|
||||
onNativeToolResultRecorded?: () => void | Promise<void>;
|
||||
trajectoryRecorder?: CodexTrajectoryRecorder | null;
|
||||
};
|
||||
@@ -188,7 +190,6 @@ 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>();
|
||||
@@ -1028,6 +1029,9 @@ 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", {
|
||||
@@ -1037,13 +1041,19 @@ export class CodexAppServerEventProjector {
|
||||
}
|
||||
}
|
||||
|
||||
private recordNativeGeneratedMediaUrl(params: { itemId: string; mediaUrl: string }): void {
|
||||
if (this.nativeGeneratedMediaUrlsByItemId.has(params.itemId)) {
|
||||
private recordNativeGeneratedMediaUrl(params: {
|
||||
itemId: string;
|
||||
mediaUrl: string;
|
||||
replaceExisting?: boolean;
|
||||
}): void {
|
||||
if (
|
||||
this.nativeGeneratedMediaUrlsByItemId.has(params.itemId) &&
|
||||
params.replaceExisting !== true
|
||||
) {
|
||||
this.nativeGeneratedMediaItemIds.add(params.itemId);
|
||||
return;
|
||||
}
|
||||
this.nativeGeneratedMediaUrlsByItemId.set(params.itemId, params.mediaUrl);
|
||||
this.nativeGeneratedMediaUrls.add(params.mediaUrl);
|
||||
this.nativeGeneratedMediaItemIds.add(params.itemId);
|
||||
}
|
||||
|
||||
@@ -1052,7 +1062,7 @@ export class CodexAppServerEventProjector {
|
||||
toolTelemetry.toolMediaUrls?.map((url) => url.trim()).filter(Boolean) ?? [],
|
||||
);
|
||||
if ((toolTelemetry.messagingToolSentMediaUrls?.length ?? 0) === 0) {
|
||||
for (const mediaUrl of this.nativeGeneratedMediaUrls) {
|
||||
for (const mediaUrl of this.nativeGeneratedMediaUrlsByItemId.values()) {
|
||||
mediaUrls.add(mediaUrl);
|
||||
}
|
||||
}
|
||||
@@ -1366,6 +1376,9 @@ 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,6 +8,7 @@ import {
|
||||
type EmbeddedRunAttemptParams,
|
||||
type NativeHookRelayEvent,
|
||||
type NativeHookRelayRegistrationHandle,
|
||||
type ToolHookRunContext,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
addTimerTimeoutGraceMs,
|
||||
@@ -121,6 +122,7 @@ export function createCodexNativeHookRelay(params: {
|
||||
config: EmbeddedRunAttemptParams["config"];
|
||||
runId: string;
|
||||
channelId?: string;
|
||||
toolHookContext?: ToolHookRunContext;
|
||||
attemptTimeoutMs: number;
|
||||
startupTimeoutMs: number;
|
||||
turnStartTimeoutMs: number;
|
||||
@@ -146,6 +148,7 @@ 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,6 +91,9 @@ 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,
|
||||
@@ -1188,8 +1191,9 @@ 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 && found.size < params.childThreadIds.size) {
|
||||
while (stack.length > 0 && remaining.size > 0) {
|
||||
const dir = stack.pop()!;
|
||||
let entries: Array<{ name: string; isDirectory(): boolean; isFile(): boolean }>;
|
||||
try {
|
||||
@@ -1206,10 +1210,20 @@ async function findTranscriptPaths(params: {
|
||||
if (!entry.isFile() || !entry.name.endsWith(".jsonl")) {
|
||||
continue;
|
||||
}
|
||||
for (const childThreadId of params.childThreadIds) {
|
||||
if (!found.has(childThreadId) && entry.name.includes(childThreadId)) {
|
||||
const rolloutMatch = entry.name.match(CODEX_ROLLOUT_FILENAME_RE);
|
||||
if (rolloutMatch) {
|
||||
const childThreadId = rolloutMatch[1];
|
||||
if (remaining.delete(childThreadId)) {
|
||||
found.set(childThreadId, entryPath);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (const childThreadId of remaining) {
|
||||
if (entry.name.includes(childThreadId)) {
|
||||
found.set(childThreadId, entryPath);
|
||||
remaining.delete(childThreadId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1236,10 +1250,13 @@ async function findTranscriptPath(params: {
|
||||
stack.push(entryPath);
|
||||
continue;
|
||||
}
|
||||
const rolloutMatch = entry.name.match(CODEX_ROLLOUT_FILENAME_RE);
|
||||
if (
|
||||
entry.isFile() &&
|
||||
entry.name.endsWith(".jsonl") &&
|
||||
entry.name.includes(params.childThreadId)
|
||||
(rolloutMatch
|
||||
? rolloutMatch[1] === params.childThreadId
|
||||
: entry.name.includes(params.childThreadId))
|
||||
) {
|
||||
return entryPath;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
// 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,
|
||||
@@ -609,6 +606,21 @@ 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,9 +30,7 @@ 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,
|
||||
@@ -95,7 +93,15 @@ 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: {
|
||||
@@ -135,6 +141,22 @@ 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,6 +38,7 @@ 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 {
|
||||
@@ -248,6 +249,7 @@ import {
|
||||
type CodexAppServerThreadLifecycleBinding,
|
||||
type CodexContextEngineThreadBootstrapProjection,
|
||||
} from "./thread-lifecycle.js";
|
||||
import { buildCodexToolHookRunContext } from "./tool-hook-context.js";
|
||||
import {
|
||||
inferCodexDynamicToolMeta,
|
||||
resolveCodexToolProgressDetailMode,
|
||||
@@ -717,6 +719,14 @@ 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)) {
|
||||
@@ -832,12 +842,8 @@ export async function runCodexAppServerAttempt(
|
||||
}),
|
||||
directToolNames: resolveCodexDynamicToolDirectNames(params),
|
||||
hookContext: {
|
||||
agentId: sessionAgentId,
|
||||
...toolHookRunContext,
|
||||
config: params.config,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: sandboxSessionKey,
|
||||
runId: params.runId,
|
||||
channelId: hookChannelId,
|
||||
currentChannelProvider: resolveCodexMessageToolProvider(params),
|
||||
currentChannelId: params.currentChannelId,
|
||||
currentMessagingTarget: params.currentMessagingTarget,
|
||||
@@ -1444,6 +1450,7 @@ export async function runCodexAppServerAttempt(
|
||||
config: params.config,
|
||||
runId: params.runId,
|
||||
channelId: hookChannelId,
|
||||
toolHookContext: toolHookRunContext,
|
||||
attemptTimeoutMs: params.timeoutMs,
|
||||
startupTimeoutMs,
|
||||
turnStartTimeoutMs: params.timeoutMs,
|
||||
@@ -2150,6 +2157,7 @@ export async function runCodexAppServerAttempt(
|
||||
method: request.method,
|
||||
params: request.params,
|
||||
paramsForRun: params,
|
||||
toolHookContext: toolHookRunContext,
|
||||
threadId: thread.threadId,
|
||||
turnId,
|
||||
nativeHookRelay,
|
||||
@@ -2761,6 +2769,7 @@ export async function runCodexAppServerAttempt(
|
||||
nativePostToolUseRelayEnabled:
|
||||
nativeHookRelay?.allowedEvents.includes("post_tool_use") === true &&
|
||||
nativeHookRelay.shouldRelayEvent("post_tool_use"),
|
||||
toolHookContext: toolHookRunContext,
|
||||
trajectoryRecorder,
|
||||
onNativeToolResultRecorded: maybeAnnounceFastModeAutoOff,
|
||||
},
|
||||
@@ -3430,6 +3439,7 @@ function handleApprovalRequest(params: {
|
||||
method: string;
|
||||
params: JsonValue | undefined;
|
||||
paramsForRun: EmbeddedRunAttemptParams;
|
||||
toolHookContext: ToolHookRunContext;
|
||||
threadId: string;
|
||||
turnId: string;
|
||||
nativeHookRelay?: NativeHookRelayRegistrationHandle;
|
||||
@@ -3443,6 +3453,7 @@ 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,9 +861,12 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
).toMatchObject({
|
||||
agentId: "main",
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
sessionKey: "agent:main:runtime-policy",
|
||||
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");
|
||||
@@ -889,6 +892,7 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
runCodexAppServerSideQuestion(
|
||||
sideParams({
|
||||
sessionKey: "agent:main:session-1",
|
||||
sandboxSessionKey: "agent:main:runtime-policy",
|
||||
messageChannel: "discord",
|
||||
messageProvider: "discord-voice",
|
||||
currentChannelId: "discord:voice-room",
|
||||
@@ -971,6 +975,7 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
runCodexAppServerSideQuestion(
|
||||
sideParams({
|
||||
sessionKey: "agent:main:session-1",
|
||||
sandboxSessionKey: "agent:main:runtime-policy",
|
||||
messageChannel: "discord",
|
||||
messageProvider: "discord-voice",
|
||||
opts: { runId: "run-side-approval" },
|
||||
@@ -988,6 +993,7 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
threadId?: string;
|
||||
turnId?: string;
|
||||
paramsForRun?: { messageChannel?: string; messageProvider?: string };
|
||||
toolHookContext?: { sessionKey?: string };
|
||||
nativeHookRelay?: { relayId?: string; allowedEvents?: readonly string[] };
|
||||
}
|
||||
| undefined;
|
||||
@@ -1007,6 +1013,9 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
messageChannel: "discord",
|
||||
messageProvider: "discord-voice",
|
||||
},
|
||||
toolHookContext: {
|
||||
sessionKey: "agent:main:runtime-policy",
|
||||
},
|
||||
});
|
||||
expect(approvalArgs?.nativeHookRelay).toMatchObject({
|
||||
relayId: relayIdDuringFork,
|
||||
@@ -1482,6 +1491,14 @@ 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) => {
|
||||
@@ -1527,6 +1544,13 @@ 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" }],
|
||||
@@ -1610,14 +1634,29 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
expect(activeDiagnosticToolKeys(diagnosticEvents)).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("normalizes hook channel ids for side-thread dynamic tool requests", async () => {
|
||||
it("preserves requester identity while normalizing side-thread hook channels", async () => {
|
||||
const afterToolCall = vi.fn();
|
||||
const beforeToolCall = vi.fn((...args: unknown[]) => {
|
||||
const context = args[1] as { channelId?: string };
|
||||
expect(context.channelId).toBe("voice-room");
|
||||
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" },
|
||||
},
|
||||
});
|
||||
return undefined;
|
||||
});
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "before_tool_call", handler: beforeToolCall }]),
|
||||
createMockPluginRegistry([
|
||||
{ hookName: "before_tool_call", handler: beforeToolCall },
|
||||
{ hookName: "after_tool_call", handler: afterToolCall },
|
||||
]),
|
||||
);
|
||||
const client = createFakeClient();
|
||||
client.request.mockImplementation(async (method: string) => {
|
||||
@@ -1657,17 +1696,48 @@ 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({ hookChannelId: "voice-room" }),
|
||||
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(
|
||||
(mockCall(createOpenClawCodingToolsMock)[0] as { channelContext?: unknown }).channelContext,
|
||||
).toBeUndefined();
|
||||
expect(toolExecuteMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Codex plugin module implements side question behavior.
|
||||
import {
|
||||
buildAgentHookContextChannelFields,
|
||||
embeddedAgentLog,
|
||||
formatErrorMessage,
|
||||
resolveAgentDir,
|
||||
@@ -16,6 +15,7 @@ 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,6 +89,7 @@ import {
|
||||
resolveCodexBindingModelProviderFallback,
|
||||
resolveReasoningEffort,
|
||||
} from "./thread-lifecycle.js";
|
||||
import { buildCodexToolHookRunContext } from "./tool-hook-context.js";
|
||||
import { filterToolsForVisionInputs } from "./vision-tools.js";
|
||||
import {
|
||||
resolveCodexWebSearchPlan,
|
||||
@@ -206,9 +207,21 @@ 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: sideRunParams.sandboxSessionKey?.trim() || sideRunParams.sessionKey,
|
||||
sessionKey: toolHookSessionKey,
|
||||
sessionId: sideRunParams.sessionId,
|
||||
surface: "/btw side-question mode",
|
||||
});
|
||||
@@ -287,6 +300,7 @@ export async function runCodexAppServerSideQuestion(
|
||||
nativeToolSurfaceEnabled,
|
||||
nativeProviderWebSearchSupport,
|
||||
signal: runAbortController.signal,
|
||||
toolHookContext: toolHookRunContext,
|
||||
});
|
||||
removeRequestHandler = client.addRequestHandler(async (request) => {
|
||||
if (request.method === "account/chatgptAuthTokens/refresh") {
|
||||
@@ -319,19 +333,20 @@ 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;
|
||||
@@ -388,15 +403,11 @@ export async function runCodexAppServerSideQuestion(
|
||||
events: nativeHookRelayEvents,
|
||||
agentId: sessionAgentId,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionKey: toolHookRunContext.sessionKey,
|
||||
config: params.cfg,
|
||||
runId: sideRunParams.runId,
|
||||
channelId: buildAgentHookContextChannelFields({
|
||||
sessionKey: params.sessionKey,
|
||||
messageChannel: params.messageChannel,
|
||||
messageProvider: params.messageProvider,
|
||||
currentChannelId: params.currentChannelId,
|
||||
}).channelId,
|
||||
channelId: toolHookRunContext.channelId,
|
||||
toolHookContext: toolHookRunContext,
|
||||
requestTimeoutMs: appServer.requestTimeoutMs,
|
||||
completionTimeoutMs: Math.max(
|
||||
appServer.turnCompletionIdleTimeoutMs,
|
||||
@@ -419,12 +430,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,
|
||||
@@ -436,7 +447,7 @@ export async function runCodexAppServerSideQuestion(
|
||||
cwd,
|
||||
approvalPolicy,
|
||||
approvalsReviewer: modelScopedAppServer.approvalsReviewer,
|
||||
...(modelScopedAppServer.networkProxy ? {} : { sandbox }),
|
||||
...(modelScopedAppServer.networkProxy ? {} : { sandbox }),
|
||||
...(serviceTier ? { serviceTier } : {}),
|
||||
config: threadConfig,
|
||||
developerInstructions: SIDE_DEVELOPER_INSTRUCTIONS,
|
||||
@@ -542,6 +553,7 @@ function registerCodexSideNativeHookRelay(params: {
|
||||
config: EmbeddedRunAttemptParams["config"];
|
||||
runId: string;
|
||||
channelId?: string;
|
||||
toolHookContext?: ToolHookRunContext;
|
||||
requestTimeoutMs: number;
|
||||
completionTimeoutMs: number;
|
||||
signal: AbortSignal;
|
||||
@@ -557,6 +569,7 @@ 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,
|
||||
@@ -596,6 +609,7 @@ 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,
|
||||
@@ -616,6 +630,8 @@ 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,
|
||||
@@ -647,6 +663,7 @@ async function createCodexSideToolBridge(input: {
|
||||
nativeToolSurfaceEnabled: boolean;
|
||||
nativeProviderWebSearchSupport: CodexNativeWebSearchSupport;
|
||||
signal: AbortSignal;
|
||||
toolHookContext: ToolHookRunContext;
|
||||
}): Promise<{ toolBridge: CodexDynamicToolBridge; webSearchPlan: CodexWebSearchPlan }> {
|
||||
const runtimeModel =
|
||||
input.params.runtimeModel ??
|
||||
@@ -657,10 +674,7 @@ async function createCodexSideToolBridge(input: {
|
||||
const createOpenClawCodingTools = (await import("openclaw/plugin-sdk/agent-harness"))
|
||||
.createOpenClawCodingTools;
|
||||
const sandboxSessionKey =
|
||||
input.params.sandboxSessionKey?.trim() ||
|
||||
input.params.sessionKey?.trim() ||
|
||||
input.params.sessionId ||
|
||||
input.sessionAgentId;
|
||||
input.toolHookContext.sessionKey || input.params.sessionId || input.sessionAgentId;
|
||||
const sandbox = await resolveSandboxContext({
|
||||
config: input.params.cfg,
|
||||
sessionKey: sandboxSessionKey,
|
||||
@@ -696,6 +710,9 @@ 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,
|
||||
@@ -715,6 +732,8 @@ 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 }
|
||||
@@ -724,12 +743,7 @@ async function createCodexSideToolBridge(input: {
|
||||
? { senderIsOwner: input.params.senderIsOwner }
|
||||
: {}),
|
||||
...(input.params.currentChannelId ? { currentChannelId: input.params.currentChannelId } : {}),
|
||||
hookChannelId: buildAgentHookContextChannelFields({
|
||||
sessionKey: input.params.sessionKey,
|
||||
messageChannel: input.params.messageChannel,
|
||||
messageProvider: input.params.messageProvider,
|
||||
currentChannelId: input.params.currentChannelId,
|
||||
}).channelId,
|
||||
hookChannelId: input.toolHookContext.channelId,
|
||||
sandbox,
|
||||
emitBeforeToolCallDiagnostics: false,
|
||||
modelHasVision: runtimeModel.input?.includes("image") ?? false,
|
||||
@@ -757,25 +771,15 @@ 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: {
|
||||
agentId: input.sessionAgentId,
|
||||
...input.toolHookContext,
|
||||
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,
|
||||
|
||||
41
extensions/codex/src/app-server/tool-hook-context.ts
Normal file
41
extensions/codex/src/app-server/tool-hook-context.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/** 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.9",
|
||||
"version": "2026.6.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/cohere-provider",
|
||||
"version": "2026.6.9"
|
||||
"version": "2026.6.10"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/cohere-provider",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.10",
|
||||
"description": "OpenClaw Cohere provider plugin.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -21,10 +21,10 @@
|
||||
"minHostVersion": ">=2026.6.8"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.9"
|
||||
"pluginApi": ">=2026.6.10"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.9",
|
||||
"openclawVersion": "2026.6.10",
|
||||
"bundledDist": true
|
||||
},
|
||||
"release": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/comfy-provider",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.10",
|
||||
"private": true,
|
||||
"description": "OpenClaw ComfyUI provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/copilot-proxy",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.10",
|
||||
"private": true,
|
||||
"description": "OpenClaw Copilot Proxy provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -10,10 +10,11 @@ openclaw plugins install @openclaw/copilot
|
||||
|
||||
Restart the Gateway after installing or updating the plugin.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
See [GitHub Copilot agent runtime](../../docs/plugins/copilot.md) for
|
||||
configuration, the doctor contract, transcript mirroring, compaction, side
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copilot tests cover harness plugin behavior.
|
||||
import { attachModelProviderRequestTransport } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
initializeGlobalHookRunner,
|
||||
resetGlobalHookRunner,
|
||||
@@ -7,11 +8,12 @@ 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",
|
||||
@@ -22,6 +24,7 @@ const mocks = vi.hoisted(() => ({
|
||||
options: { copilotHome: "/tmp/copilot", useLoggedInUser: true },
|
||||
}) as any,
|
||||
),
|
||||
createCopilotByokProxy: vi.fn(),
|
||||
createCopilotClientPool: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -30,6 +33,10 @@ 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,
|
||||
}));
|
||||
@@ -86,6 +93,7 @@ describe("createCopilotAgentHarness", () => {
|
||||
beforeEach(() => {
|
||||
mocks.runCopilotAttempt.mockReset();
|
||||
mocks.resolvePoolAcquire.mockClear();
|
||||
mocks.createCopilotByokProxy.mockReset();
|
||||
mocks.createCopilotClientPool.mockReset();
|
||||
mocks.runCopilotAttempt.mockResolvedValue(ATTEMPT_RESULT);
|
||||
mocks.resolvePoolAcquire.mockReturnValue({
|
||||
@@ -98,6 +106,7 @@ describe("createCopilotAgentHarness", () => {
|
||||
options: { copilotHome: "/tmp/copilot", useLoggedInUser: true },
|
||||
});
|
||||
mocks.createCopilotClientPool.mockImplementation(() => makePoolMock());
|
||||
mocks.createCopilotByokProxy.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -180,26 +189,81 @@ describe("createCopilotAgentHarness", () => {
|
||||
).toEqual({ supported: true, priority: 100 });
|
||||
});
|
||||
|
||||
it("supports rejects providers outside the whitelist", () => {
|
||||
it("supports custom provider ids for BYOK model entries", () => {
|
||||
const harness = createCopilotAgentHarness();
|
||||
|
||||
expect(
|
||||
harness.supports({
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4.5",
|
||||
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: [],
|
||||
requestedRuntime: "copilot",
|
||||
}),
|
||||
).toEqual({
|
||||
supported: false,
|
||||
reason: "provider is not one of: github-copilot",
|
||||
reason:
|
||||
"provider is not a supported Copilot BYOK model (requires supported api, baseUrl, and no request transport policy overrides)",
|
||||
});
|
||||
// Legacy aspirational ids should not be claimed by the harness.
|
||||
for (const legacyId of ["github", "openclaw", "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) {
|
||||
expect(
|
||||
harness.supports({
|
||||
provider: legacyId,
|
||||
provider,
|
||||
modelId: "gpt-4.1",
|
||||
requestedRuntime: "copilot",
|
||||
providerOwnerStatus: "owned",
|
||||
providerOwnerPluginIds: ownerPluginIds,
|
||||
}),
|
||||
).toEqual({
|
||||
supported: false,
|
||||
@@ -208,6 +272,27 @@ 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);
|
||||
@@ -222,6 +307,18 @@ 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;
|
||||
@@ -1186,6 +1283,88 @@ 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) => {
|
||||
@@ -1886,6 +2065,148 @@ 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,6 +3,7 @@ import type { CopilotClient } from "@github/copilot-sdk";
|
||||
import {
|
||||
buildAgentHookContextChannelFields,
|
||||
compactWithSafetyTimeout,
|
||||
getModelProviderRequestTransport,
|
||||
resolveCompactionTimeoutMs,
|
||||
runAgentHarnessAfterCompactionHook,
|
||||
runAgentHarnessBeforeCompactionHook,
|
||||
@@ -15,7 +16,13 @@ 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 { resolveCopilotAuth } from "./src/auth-bridge.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 type {
|
||||
ClientCreateOptions,
|
||||
CopilotClientPool,
|
||||
@@ -52,7 +59,7 @@ interface TrackedSession {
|
||||
// replaces this entry via `onSessionEstablished`.
|
||||
compatKey: string;
|
||||
compactKey: string;
|
||||
authMode: "gitHubToken" | "useLoggedInUser";
|
||||
authMode: "gitHubToken" | "useLoggedInUser" | "byok";
|
||||
authProfileId?: string;
|
||||
authProfileVersion?: string;
|
||||
}
|
||||
@@ -88,7 +95,7 @@ export type CopilotSessionBinding = {
|
||||
sdkSessionId: string;
|
||||
compatKey: string;
|
||||
compactKey: string;
|
||||
authMode: "gitHubToken" | "useLoggedInUser";
|
||||
authMode: "gitHubToken" | "useLoggedInUser" | "byok";
|
||||
authProfileId?: string;
|
||||
authProfileVersion?: string;
|
||||
updatedAt: number;
|
||||
@@ -119,9 +126,9 @@ type CopilotSessionAuth = Pick<
|
||||
>;
|
||||
|
||||
function sessionAuthFields(auth: CopilotSessionAuth): CopilotSessionAuth {
|
||||
return auth.authMode === "gitHubToken"
|
||||
return auth.authMode === "gitHubToken" || auth.authMode === "byok"
|
||||
? {
|
||||
authMode: "gitHubToken",
|
||||
authMode: auth.authMode,
|
||||
authProfileId: auth.authProfileId,
|
||||
authProfileVersion: auth.authProfileVersion,
|
||||
}
|
||||
@@ -136,7 +143,7 @@ function sessionAuthMatches(stored: CopilotSessionAuth, current: CopilotSessionA
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
current.authMode === "gitHubToken" &&
|
||||
current.authMode === stored.authMode &&
|
||||
stored.authProfileId === current.authProfileId &&
|
||||
stored.authProfileVersion === current.authProfileVersion
|
||||
);
|
||||
@@ -154,8 +161,10 @@ function normalizeBinding(
|
||||
value.compatKey.trim() === "" ||
|
||||
typeof value.compactKey !== "string" ||
|
||||
value.compactKey.trim() === "" ||
|
||||
(value.authMode !== "gitHubToken" && value.authMode !== "useLoggedInUser") ||
|
||||
(value.authMode === "gitHubToken" &&
|
||||
(value.authMode !== "gitHubToken" &&
|
||||
value.authMode !== "byok" &&
|
||||
value.authMode !== "useLoggedInUser") ||
|
||||
((value.authMode === "gitHubToken" || value.authMode === "byok") &&
|
||||
(typeof value.authProfileId !== "string" ||
|
||||
value.authProfileId.trim() === "" ||
|
||||
typeof value.authProfileVersion !== "string" ||
|
||||
@@ -171,7 +180,7 @@ function normalizeBinding(
|
||||
compatKey: value.compatKey,
|
||||
compactKey: value.compactKey,
|
||||
authMode: value.authMode,
|
||||
...(value.authMode === "gitHubToken"
|
||||
...(value.authMode === "gitHubToken" || value.authMode === "byok"
|
||||
? {
|
||||
authProfileId: value.authProfileId,
|
||||
authProfileVersion: value.authProfileVersion,
|
||||
@@ -346,21 +355,88 @@ function computeSessionKey(
|
||||
copilotHome?: string;
|
||||
cwd?: string;
|
||||
modelId?: string;
|
||||
model?: string | { api?: string; id?: string; provider?: 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;
|
||||
};
|
||||
profileVersion?: string;
|
||||
resolvedApiKey?: string;
|
||||
sessionKey?: string;
|
||||
workspaceDir?: string;
|
||||
};
|
||||
const modelObj: { api?: string; id?: string; provider?: 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;
|
||||
} =
|
||||
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
|
||||
@@ -373,16 +449,63 @@ function computeSessionKey(
|
||||
let resolvedAgentId = "";
|
||||
let resolvedCopilotHome = "";
|
||||
try {
|
||||
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,
|
||||
});
|
||||
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,
|
||||
});
|
||||
})();
|
||||
resolvedAgentId = resolved.agentId;
|
||||
resolvedCopilotHome = resolved.copilotHome;
|
||||
authParts = [
|
||||
@@ -390,6 +513,9 @@ function computeSessionKey(
|
||||
`auth.profileId=${resolved.authProfileId ?? ""}`,
|
||||
`auth.profileVersion=${resolved.authProfileVersion ?? ""}`,
|
||||
];
|
||||
if (!options.includeAuth) {
|
||||
authParts = [];
|
||||
}
|
||||
} catch {
|
||||
authParts = ["auth=unresolvable"];
|
||||
}
|
||||
@@ -397,6 +523,9 @@ 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 ?? ""}`,
|
||||
@@ -407,6 +536,14 @@ 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 });
|
||||
}
|
||||
@@ -531,12 +668,38 @@ export function createCopilotAgentHarness(
|
||||
return { supported: false, reason: "copilot is opt-in only" };
|
||||
}
|
||||
const provider = ctx.provider.trim().toLowerCase();
|
||||
if (!COPILOT_PROVIDER_IDS.has(provider)) {
|
||||
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
|
||||
) {
|
||||
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 };
|
||||
},
|
||||
|
||||
@@ -549,11 +712,22 @@ 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;
|
||||
|
||||
@@ -611,10 +785,12 @@ export function createCopilotAgentHarness(
|
||||
pool,
|
||||
onSessionEstablished: openclawSessionId
|
||||
? ({
|
||||
compactionSessionConfig,
|
||||
sdkSessionId,
|
||||
pooledClient,
|
||||
sessionConfig,
|
||||
}: {
|
||||
compactionSessionConfig?: CopilotSessionConfig;
|
||||
sdkSessionId: string;
|
||||
pooledClient: PooledClient;
|
||||
sessionConfig: CopilotSessionConfig;
|
||||
@@ -626,7 +802,7 @@ export function createCopilotAgentHarness(
|
||||
compatKey: currentCompatKey,
|
||||
compactKey: currentCompactKey,
|
||||
poolKey: pooledClient.key,
|
||||
sessionConfig,
|
||||
sessionConfig: compactionSessionConfig ?? sessionConfig,
|
||||
...sessionAuthFields(poolAcquire.auth),
|
||||
});
|
||||
registerStoredBinding(options?.sessionStore, openclawSessionId, {
|
||||
@@ -768,8 +944,24 @@ export function createCopilotAgentHarness(
|
||||
const tracked = trackedSessions.get(openclawSessionId);
|
||||
const currentCompactKey = computeSessionCompactKey(params);
|
||||
const { resolvePoolAcquire } = await import("./src/attempt.js");
|
||||
const resolvedPoolAcquire = resolvePoolAcquire(params as never);
|
||||
const currentAuth = sessionAuthFields(resolvedPoolAcquire.auth);
|
||||
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 compatibleTracked =
|
||||
tracked?.compactKey === currentCompactKey && sessionAuthMatches(tracked, currentAuth)
|
||||
? tracked
|
||||
@@ -785,19 +977,32 @@ export function createCopilotAgentHarness(
|
||||
failure: { reason: "missing_thread_binding" },
|
||||
};
|
||||
}
|
||||
const poolAcquire = compatibleTracked
|
||||
? { key: compatibleTracked.poolKey, options: compatibleTracked.clientOptions }
|
||||
: resolvedPoolAcquire;
|
||||
const poolAcquire = {
|
||||
key: compatibleTracked.poolKey,
|
||||
options: compatibleTracked.clientOptions,
|
||||
};
|
||||
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({
|
||||
@@ -812,13 +1017,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: compatibleTracked.sessionConfig,
|
||||
sessionConfig,
|
||||
sdkSessionId: compatibleTracked.sdkSessionId,
|
||||
}),
|
||||
resolveCompactionTimeoutMs(
|
||||
@@ -852,6 +1057,7 @@ 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.9",
|
||||
"version": "2026.6.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/copilot",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.10",
|
||||
"dependencies": {
|
||||
"@github/copilot-sdk": "1.0.0-beta.9"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/copilot",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.10",
|
||||
"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.9"
|
||||
"pluginApi": ">=2026.6.10"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.9",
|
||||
"openclawVersion": "2026.6.10",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from "node:path";
|
||||
import type { CopilotClient, Tool as SdkTool } from "@github/copilot-sdk";
|
||||
import {
|
||||
abortAgentHarnessRun,
|
||||
attachModelProviderRequestTransport,
|
||||
queueAgentHarnessMessage,
|
||||
type AgentHarnessAttemptParams,
|
||||
type AgentHarnessAttemptResult,
|
||||
@@ -104,11 +105,12 @@ function createDeferred<T>() {
|
||||
function flushAsync() {
|
||||
// Pump enough microtasks for the attempt to settle past every
|
||||
// pre-createSession `await` in attempt.ts (resolvePoolAcquire,
|
||||
// resolveCopilotWorkspaceBootstrapContext, createSession, etc.).
|
||||
// BYOK proxy setup, 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);
|
||||
return tick().then(tick).then(tick).then(tick).then(tick);
|
||||
}
|
||||
|
||||
function waitForEventLoopTurn(): Promise<void> {
|
||||
@@ -338,7 +340,22 @@ describe("runCopilotAttempt", () => {
|
||||
return { sdkTools: [], sourceTools: [] };
|
||||
});
|
||||
|
||||
await runCopilotAttempt(makeParams(), {
|
||||
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, {
|
||||
createToolBridge,
|
||||
pool: makeFakePool(sdk),
|
||||
});
|
||||
@@ -385,7 +402,21 @@ describe("runCopilotAttempt", () => {
|
||||
toolCallId: "tool-call-1",
|
||||
toolName: "read",
|
||||
}),
|
||||
expect.objectContaining({ agentId: "agent-1", sessionId: "session-1" }),
|
||||
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" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1285,6 +1316,32 @@ 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);
|
||||
@@ -2338,6 +2395,152 @@ 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
|
||||
@@ -2401,6 +2604,37 @@ 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,7 +9,9 @@ import type {
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
buildAgentHookContextChannelFields,
|
||||
buildAgentHookContextOriginFields,
|
||||
detectAndLoadAgentHarnessPromptImages,
|
||||
getModelProviderRequestTransport,
|
||||
resolveAgentHarnessBeforePromptBuildResult,
|
||||
resolveAttemptFsWorkspaceOnly,
|
||||
resolveAttemptSpawnWorkspaceDir,
|
||||
@@ -27,7 +29,8 @@ import {
|
||||
clearActiveEmbeddedRun,
|
||||
setActiveEmbeddedRun,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { resolveCopilotAuth } from "./auth-bridge.js";
|
||||
import { createCopilotByokAuth, resolveCopilotAuth } from "./auth-bridge.js";
|
||||
import { createCopilotByokProxy } from "./byok-proxy.js";
|
||||
import {
|
||||
createInfiniteSessionConfig,
|
||||
type CopilotInfiniteSessionOptions,
|
||||
@@ -50,6 +53,7 @@ import {
|
||||
rejectAllPolicy,
|
||||
type CopilotPermissionPolicy,
|
||||
} from "./permission-bridge.js";
|
||||
import { resolveCopilotProvider, type ResolvedCopilotProvider } from "./provider-bridge.js";
|
||||
import {
|
||||
classifyResumeFailure,
|
||||
computeReplayMetadata,
|
||||
@@ -79,6 +83,7 @@ export type CopilotSessionConfig = Pick<
|
||||
| "model"
|
||||
| "onPermissionRequest"
|
||||
| "onUserInputRequest"
|
||||
| "provider"
|
||||
| "reasoningEffort"
|
||||
| "systemMessage"
|
||||
| "tools"
|
||||
@@ -115,7 +120,42 @@ type AttemptParamsLike = AgentHarnessAttemptParams & {
|
||||
// internal expansion. Symmetric to `EmbeddedRunAttemptParams.transcriptPrompt`.
|
||||
transcriptPrompt?: string;
|
||||
};
|
||||
type ModelRef = { api?: string; id: string; provider: 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;
|
||||
};
|
||||
|
||||
export type { AttemptParamsLike as CopilotPoolAcquireInput, ModelRef };
|
||||
export { SUPPORTED_PROVIDERS };
|
||||
@@ -142,6 +182,7 @@ export interface CopilotAttemptDeps {
|
||||
* attempt.
|
||||
*/
|
||||
onSessionEstablished?: (info: {
|
||||
compactionSessionConfig?: CopilotSessionConfig;
|
||||
sdkSessionId: string;
|
||||
pooledClient: PooledClient;
|
||||
sessionConfig: CopilotSessionConfig;
|
||||
@@ -228,6 +269,7 @@ function deferBackgroundCompactionCleanup(params: {
|
||||
bridge: ReturnType<typeof attachEventBridge>;
|
||||
handle: PooledClient;
|
||||
pool: CopilotClientPool;
|
||||
cleanupByokProxy?: () => Promise<void>;
|
||||
cleanupToolBridge?: () => void;
|
||||
finalizeNativeSubagents?: () => void;
|
||||
sdkSessionId?: string;
|
||||
@@ -260,6 +302,7 @@ 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);
|
||||
@@ -367,6 +410,25 @@ 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);
|
||||
|
||||
@@ -384,15 +446,18 @@ export async function runCopilotAttempt(
|
||||
);
|
||||
}
|
||||
|
||||
if (!SUPPORTED_PROVIDERS.has(modelRef.provider)) {
|
||||
try {
|
||||
resolveCopilotProvider({
|
||||
model: modelRef,
|
||||
resolvedApiKey: readString(params.resolvedApiKey),
|
||||
authProfileId: readString(params.authProfileId),
|
||||
});
|
||||
} catch (error) {
|
||||
return finishAttempt(
|
||||
createResult(input, {
|
||||
messagesSnapshot: messages,
|
||||
now,
|
||||
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)`,
|
||||
),
|
||||
promptError: createPromptError("model_not_supported", toError(error).message, error),
|
||||
sdkSessionId: undefined,
|
||||
sessionIdUsed: input.sessionId,
|
||||
}),
|
||||
@@ -549,6 +614,22 @@ 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
|
||||
@@ -562,9 +643,10 @@ export async function runCopilotAttempt(
|
||||
let sdkTools: SdkTool[];
|
||||
try {
|
||||
const toolBridge = await createToolBridge({
|
||||
allowModelTools: poolAcquire.provider.mode === "byok",
|
||||
modelProvider: modelRef.provider,
|
||||
modelId: modelRef.id,
|
||||
agentId: readString(params.agentId) ?? "copilot",
|
||||
agentId: sessionAgentId,
|
||||
sessionId: readString(input.sessionId) ?? "copilot-session",
|
||||
sessionKey: readString((input as { sessionKey?: unknown }).sessionKey),
|
||||
agentDir: readString(input.agentDir),
|
||||
@@ -590,11 +672,7 @@ export async function runCopilotAttempt(
|
||||
runAgentHarnessAfterToolCallHook({
|
||||
toolName,
|
||||
toolCallId,
|
||||
runId: input.runId,
|
||||
agentId: sessionAgentId,
|
||||
sessionId: input.sessionId,
|
||||
sessionKey: sandboxSessionKey,
|
||||
channelId: hookContext.channelId,
|
||||
...toolHookRunContext,
|
||||
startArgs: args,
|
||||
...(result !== undefined ? { result } : {}),
|
||||
...(error ? { error } : {}),
|
||||
@@ -692,6 +770,7 @@ export async function runCopilotAttempt(
|
||||
modelRef.id,
|
||||
sdkTools,
|
||||
poolAcquire.auth,
|
||||
sessionProvider,
|
||||
promptBuild.developerInstructions || undefined,
|
||||
effectiveWorkspaceDir,
|
||||
effectiveCwd,
|
||||
@@ -703,6 +782,25 @@ 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,
|
||||
@@ -749,7 +847,12 @@ export async function runCopilotAttempt(
|
||||
sessionIdUsed = sdkSessionId ?? input.sessionId;
|
||||
if (sdkSessionId && deps.onSessionEstablished) {
|
||||
try {
|
||||
deps.onSessionEstablished({ sdkSessionId, pooledClient: handle, sessionConfig });
|
||||
deps.onSessionEstablished({
|
||||
compactionSessionConfig,
|
||||
sdkSessionId,
|
||||
pooledClient: handle,
|
||||
sessionConfig,
|
||||
});
|
||||
} catch {
|
||||
// never let session-tracking callbacks break attempts
|
||||
}
|
||||
@@ -809,6 +912,7 @@ export async function runCopilotAttempt(
|
||||
const messageOptions = await createMessageOptions(attemptInput, {
|
||||
effectiveCwd,
|
||||
effectiveWorkspaceDir,
|
||||
provider: poolAcquire.provider,
|
||||
sandbox,
|
||||
workspaceOnly: effectiveFsWorkspaceOnly,
|
||||
});
|
||||
@@ -890,6 +994,7 @@ export async function runCopilotAttempt(
|
||||
awaitSessionIdle: !bridge.hasObservedSessionIdle(),
|
||||
bridge,
|
||||
cleanupToolBridge,
|
||||
cleanupByokProxy,
|
||||
finalizeNativeSubagents: () => nativeSubagentTaskMirror?.finalizeActiveRuns(),
|
||||
handle,
|
||||
pool: deps.pool,
|
||||
@@ -922,6 +1027,7 @@ export async function runCopilotAttempt(
|
||||
await bridge?.awaitAgentEventChain();
|
||||
nativeSubagentTaskMirror?.finalizeActiveRuns();
|
||||
cleanupToolBridge?.();
|
||||
await cleanupByokProxy?.();
|
||||
bridge?.detach();
|
||||
params.abortSignal?.removeEventListener("abort", onAbort);
|
||||
|
||||
@@ -1191,6 +1297,7 @@ function createSessionConfig(
|
||||
sdkModelId: string,
|
||||
sdkTools: SdkTool[],
|
||||
resolvedAuth: ReturnType<typeof resolveCopilotAuth>,
|
||||
resolvedProvider: ResolvedCopilotProvider,
|
||||
systemMessageContent: string | undefined,
|
||||
effectiveWorkspaceDir: string | undefined,
|
||||
effectiveCwd: string | undefined,
|
||||
@@ -1225,6 +1332,10 @@ 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.
|
||||
@@ -1314,14 +1425,28 @@ 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));
|
||||
return attachments.length > 0
|
||||
? { prompt: params.prompt, attachments }
|
||||
: { prompt: params.prompt };
|
||||
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;
|
||||
}
|
||||
|
||||
function createPromptImageAttachments(
|
||||
@@ -1488,18 +1613,35 @@ function readResolvedAttemptPath(value: unknown): string | undefined {
|
||||
}
|
||||
|
||||
export function resolveModelRef(params: AttemptParamsLike): ModelRef {
|
||||
const rawModel = params.model;
|
||||
const rawModel = (params as { runtimeModel?: unknown }).runtimeModel ?? params.model;
|
||||
if (rawModel && typeof rawModel === "object") {
|
||||
const model = rawModel as ModelRefInputObject;
|
||||
const requestTransport = getModelProviderRequestTransport(rawModel);
|
||||
const rawRequest = model.request;
|
||||
return {
|
||||
api: readString(rawModel.api),
|
||||
api: readString(model.api),
|
||||
id:
|
||||
readString(rawModel.id) ??
|
||||
readString(model.id) ??
|
||||
readString((params as { modelId?: unknown }).modelId) ??
|
||||
"unknown-model",
|
||||
provider:
|
||||
readString(rawModel.provider) ??
|
||||
readString(model.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 {
|
||||
@@ -1529,40 +1671,59 @@ export function resolvePoolAcquire(params: AttemptParamsLike): {
|
||||
* setting both.
|
||||
*/
|
||||
auth: ReturnType<typeof resolveCopilotAuth>;
|
||||
provider: ResolvedCopilotProvider;
|
||||
} {
|
||||
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.
|
||||
const model = resolveModelRef(params);
|
||||
const provider = resolveCopilotProvider({
|
||||
model,
|
||||
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: resolved.agentId,
|
||||
authMode: resolved.authMode,
|
||||
...(resolved.authMode === "gitHubToken"
|
||||
agentId: auth.agentId,
|
||||
authMode: auth.authMode,
|
||||
...(auth.authMode === "gitHubToken" || auth.authMode === "byok"
|
||||
? {
|
||||
authProfileId: resolved.authProfileId,
|
||||
authProfileVersion: resolved.authProfileVersion,
|
||||
authProfileId: auth.authProfileId,
|
||||
authProfileVersion: auth.authProfileVersion,
|
||||
}
|
||||
: {}),
|
||||
copilotHome: resolved.copilotHome,
|
||||
copilotHome: auth.copilotHome,
|
||||
},
|
||||
options: {
|
||||
copilotHome: resolved.copilotHome,
|
||||
gitHubToken: resolved.authMode === "gitHubToken" ? resolved.gitHubToken : undefined,
|
||||
useLoggedInUser: resolved.authMode === "useLoggedInUser",
|
||||
copilotHome: auth.copilotHome,
|
||||
...(auth.authMode === "gitHubToken" && auth.gitHubToken
|
||||
? { gitHubToken: auth.gitHubToken }
|
||||
: {}),
|
||||
useLoggedInUser: auth.authMode === "useLoggedInUser",
|
||||
},
|
||||
auth: resolved,
|
||||
auth,
|
||||
provider,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -54,12 +54,12 @@ export const COPILOT_DEFAULT_AGENT_ID = "copilot";
|
||||
|
||||
/** Resolved auth shape that the runtime / pool consumes. */
|
||||
export interface ResolvedCopilotAuth {
|
||||
authMode: "useLoggedInUser" | "gitHubToken";
|
||||
authMode: "useLoggedInUser" | "gitHubToken" | "byok";
|
||||
/** Present only when authMode is "gitHubToken". */
|
||||
gitHubToken?: string;
|
||||
/** Present only when authMode is "gitHubToken". */
|
||||
/** Present for token and BYOK auth modes. */
|
||||
authProfileId?: string;
|
||||
/** Present only when authMode is "gitHubToken". */
|
||||
/** Present for token and BYOK auth modes. */
|
||||
authProfileVersion?: string;
|
||||
/** Absolute, normalized path. */
|
||||
copilotHome: string;
|
||||
@@ -67,6 +67,33 @@ export interface ResolvedCopilotAuth {
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
export function createCopilotByokAuth(input: {
|
||||
agentId?: string;
|
||||
agentDir?: string;
|
||||
workspaceDir?: string;
|
||||
copilotHome?: string;
|
||||
authProfileId?: string;
|
||||
authProfileVersion?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
homeDir?: () => string;
|
||||
}): ResolvedCopilotAuth {
|
||||
const base = resolveCopilotAuth({
|
||||
agentId: input.agentId,
|
||||
agentDir: input.agentDir,
|
||||
workspaceDir: input.workspaceDir,
|
||||
copilotHome: input.copilotHome,
|
||||
env: input.env,
|
||||
homeDir: input.homeDir,
|
||||
auth: { useLoggedInUser: true },
|
||||
});
|
||||
return {
|
||||
...base,
|
||||
authMode: "byok",
|
||||
authProfileId: input.authProfileId?.trim() || "byok:resolved",
|
||||
authProfileVersion: input.authProfileVersion?.trim() || "byok:unfingerprinted",
|
||||
};
|
||||
}
|
||||
|
||||
export interface ResolveCopilotAuthInput {
|
||||
agentId?: string;
|
||||
agentDir?: string;
|
||||
|
||||
167
extensions/copilot/src/byok-proxy.test.ts
Normal file
167
extensions/copilot/src/byok-proxy.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
// Copilot BYOK proxy tests verify SDK-local transport is guarded outbound fetch.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createCopilotByokProxy } from "./byok-proxy.js";
|
||||
import { resolveCopilotProvider } from "./provider-bridge.js";
|
||||
|
||||
const ssrfRuntimeMock = vi.hoisted(() => ({
|
||||
fetchWithSsrFGuard: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("openclaw/plugin-sdk/ssrf-runtime")>()),
|
||||
fetchWithSsrFGuard: ssrfRuntimeMock.fetchWithSsrFGuard,
|
||||
}));
|
||||
|
||||
describe("createCopilotByokProxy", () => {
|
||||
afterEach(() => {
|
||||
ssrfRuntimeMock.fetchWithSsrFGuard.mockReset();
|
||||
});
|
||||
|
||||
it("presents a loopback SDK endpoint and forwards through guarded fetch", async () => {
|
||||
const release = vi.fn(async () => undefined);
|
||||
ssrfRuntimeMock.fetchWithSsrFGuard.mockResolvedValue({
|
||||
response: new Response("ok", {
|
||||
status: 201,
|
||||
headers: {
|
||||
"content-encoding": "gzip",
|
||||
"content-length": "999",
|
||||
"x-upstream": "yes",
|
||||
},
|
||||
}),
|
||||
release,
|
||||
});
|
||||
const resolvedProvider = resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "custom-proxy",
|
||||
api: "openai-responses",
|
||||
id: "proxy-model",
|
||||
baseUrl: "https://proxy.example/v1?routing=blue",
|
||||
},
|
||||
resolvedApiKey: "secret-key",
|
||||
});
|
||||
|
||||
const proxy = await createCopilotByokProxy(resolvedProvider);
|
||||
expect(proxy?.provider.provider?.baseUrl).toMatch(
|
||||
/^http:\/\/127\.0\.0\.1:\d+\/[a-f0-9]{24}\/v1$/,
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${proxy?.provider.provider?.baseUrl}/responses?trace=request`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: "Bearer secret-key",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ model: "proxy-model" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.headers.get("content-encoding")).toBeNull();
|
||||
expect(response.headers.get("content-length")).toBeNull();
|
||||
expect(response.headers.get("x-upstream")).toBe("yes");
|
||||
expect(await response.text()).toBe("ok");
|
||||
expect(ssrfRuntimeMock.fetchWithSsrFGuard).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
auditContext: "copilot-byok-provider",
|
||||
requireHttps: true,
|
||||
url: "https://proxy.example/v1/responses?routing=blue&trace=request",
|
||||
init: expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({
|
||||
"accept-encoding": "identity",
|
||||
authorization: "Bearer secret-key",
|
||||
"content-type": "application/json",
|
||||
}),
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
await proxy?.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("aborts in-flight upstream fetches when the proxy closes", async () => {
|
||||
let upstreamSignal: AbortSignal | undefined;
|
||||
ssrfRuntimeMock.fetchWithSsrFGuard.mockImplementation(async ({ init }: any) => {
|
||||
upstreamSignal = init.signal;
|
||||
await new Promise((_, reject) => {
|
||||
upstreamSignal?.addEventListener("abort", () => reject(new Error("upstream aborted")), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
throw new Error("unreachable");
|
||||
});
|
||||
const resolvedProvider = resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "custom-proxy",
|
||||
api: "openai-responses",
|
||||
id: "proxy-model",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
},
|
||||
});
|
||||
const proxy = await createCopilotByokProxy(resolvedProvider);
|
||||
|
||||
const responsePromise = fetch(`${proxy?.provider.provider?.baseUrl}/responses`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ model: "proxy-model" }),
|
||||
}).catch((error: unknown) => error);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(upstreamSignal).toBeDefined();
|
||||
});
|
||||
|
||||
await proxy?.close();
|
||||
|
||||
expect(upstreamSignal?.aborted).toBe(true);
|
||||
await responsePromise;
|
||||
});
|
||||
|
||||
it("accepts Azure SDK paths that are rebuilt from the proxy origin", async () => {
|
||||
ssrfRuntimeMock.fetchWithSsrFGuard.mockResolvedValue({
|
||||
response: new Response("azure-ok", { status: 200 }),
|
||||
release: vi.fn(async () => undefined),
|
||||
});
|
||||
const resolvedProvider = resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "custom-azure",
|
||||
api: "azure-openai-responses",
|
||||
id: "deployment-gpt",
|
||||
baseUrl: "https://example.openai.azure.com/openai/v1",
|
||||
},
|
||||
resolvedApiKey: "azure-key",
|
||||
});
|
||||
|
||||
const proxy = await createCopilotByokProxy(resolvedProvider);
|
||||
expect(proxy?.provider.provider?.baseUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${proxy?.provider.provider?.baseUrl}/openai/v1/responses?trace=request`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "api-key": "azure-key" },
|
||||
body: JSON.stringify({ model: "deployment-gpt" }),
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe("azure-ok");
|
||||
expect(ssrfRuntimeMock.fetchWithSsrFGuard).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
requireHttps: true,
|
||||
url: "https://example.openai.azure.com/openai/v1/responses?trace=request",
|
||||
init: expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"accept-encoding": "identity",
|
||||
"api-key": "azure-key",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await proxy?.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
269
extensions/copilot/src/byok-proxy.ts
Normal file
269
extensions/copilot/src/byok-proxy.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
// Copilot BYOK transport proxy keeps OpenClaw in charge of outbound network policy.
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { Readable } from "node:stream";
|
||||
import { finished } from "node:stream/promises";
|
||||
import type { ReadableStream as NodeReadableStream } from "node:stream/web";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import type { ResolvedCopilotProvider } from "./provider-bridge.js";
|
||||
|
||||
const LOOPBACK_HOST = "127.0.0.1";
|
||||
|
||||
export type CopilotByokProxyHandle = {
|
||||
close: () => Promise<void>;
|
||||
provider: ResolvedCopilotProvider;
|
||||
};
|
||||
|
||||
type HeaderValue = string | number | string[] | undefined;
|
||||
|
||||
export async function createCopilotByokProxy(
|
||||
resolvedProvider: ResolvedCopilotProvider,
|
||||
): Promise<CopilotByokProxyHandle | undefined> {
|
||||
if (resolvedProvider.mode !== "byok") {
|
||||
return undefined;
|
||||
}
|
||||
const providerConfig = resolvedProvider.provider;
|
||||
if (!providerConfig?.baseUrl) {
|
||||
throw new Error("[copilot-attempt] BYOK requires a provider baseUrl");
|
||||
}
|
||||
|
||||
const targetBaseUrl = new URL(providerConfig.baseUrl);
|
||||
const nonce = randomBytes(12).toString("hex");
|
||||
const targetPathPrefix = trimTrailingSlash(targetBaseUrl.pathname);
|
||||
const proxyPathPrefix = `/${nonce}${targetPathPrefix}`;
|
||||
const acceptsAzureSdkPaths = providerConfig.type === "azure";
|
||||
const activeFetches = new Set<AbortController>();
|
||||
const server = createServer((req, res) => {
|
||||
void handleProxyRequest(req, res, {
|
||||
acceptsAzureSdkPaths,
|
||||
activeFetches,
|
||||
proxyPathPrefix,
|
||||
targetBaseUrl,
|
||||
targetPathPrefix,
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, LOOPBACK_HOST, () => {
|
||||
server.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close();
|
||||
throw new Error("[copilot-attempt] failed to start BYOK network proxy");
|
||||
}
|
||||
|
||||
const proxyBaseUrl = `http://${LOOPBACK_HOST}:${address.port}${proxyPathPrefix}`;
|
||||
const sdkBaseUrl = acceptsAzureSdkPaths
|
||||
? `http://${LOOPBACK_HOST}:${address.port}`
|
||||
: proxyBaseUrl;
|
||||
return {
|
||||
provider: {
|
||||
...resolvedProvider,
|
||||
provider: {
|
||||
...providerConfig,
|
||||
baseUrl: sdkBaseUrl,
|
||||
},
|
||||
},
|
||||
close: async () => {
|
||||
for (const controller of activeFetches) {
|
||||
controller.abort();
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function handleProxyRequest(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
params: {
|
||||
acceptsAzureSdkPaths: boolean;
|
||||
activeFetches: Set<AbortController>;
|
||||
proxyPathPrefix: string;
|
||||
targetBaseUrl: URL;
|
||||
targetPathPrefix: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
let guarded: Awaited<ReturnType<typeof fetchWithSsrFGuard>> | undefined;
|
||||
const upstreamAbort = new AbortController();
|
||||
params.activeFetches.add(upstreamAbort);
|
||||
const abortUpstream = () => upstreamAbort.abort();
|
||||
req.on("aborted", abortUpstream);
|
||||
res.on("close", () => {
|
||||
if (!res.writableEnded) {
|
||||
abortUpstream();
|
||||
}
|
||||
});
|
||||
try {
|
||||
const url = resolveTargetUrl(req, params);
|
||||
if (!url) {
|
||||
res.writeHead(404);
|
||||
res.end("Not found");
|
||||
return;
|
||||
}
|
||||
const body = req.method === "GET" || req.method === "HEAD" ? undefined : await readBody(req);
|
||||
guarded = await fetchWithSsrFGuard({
|
||||
url: url.toString(),
|
||||
init: {
|
||||
method: req.method,
|
||||
headers: normalizeProxyRequestHeaders(req.headers),
|
||||
signal: upstreamAbort.signal,
|
||||
...(body ? { body: toFetchBody(body) } : {}),
|
||||
},
|
||||
auditContext: "copilot-byok-provider",
|
||||
requireHttps: true,
|
||||
});
|
||||
res.writeHead(
|
||||
guarded.response.status,
|
||||
guarded.response.statusText,
|
||||
normalizeProxyResponseHeaders(guarded.response.headers),
|
||||
);
|
||||
if (!guarded.response.body) {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
await finished(
|
||||
Readable.fromWeb(
|
||||
guarded.response.body as unknown as NodeReadableStream<Uint8Array>,
|
||||
).pipe(res),
|
||||
);
|
||||
} catch (error) {
|
||||
if (res.destroyed || res.writableEnded) {
|
||||
return;
|
||||
}
|
||||
if (res.headersSent) {
|
||||
res.destroy(error instanceof Error ? error : undefined);
|
||||
return;
|
||||
}
|
||||
res.writeHead(502);
|
||||
res.end(error instanceof Error ? error.message : "BYOK provider proxy failed");
|
||||
} finally {
|
||||
req.off("aborted", abortUpstream);
|
||||
params.activeFetches.delete(upstreamAbort);
|
||||
await guarded?.release().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTargetUrl(
|
||||
req: IncomingMessage,
|
||||
params: {
|
||||
acceptsAzureSdkPaths: boolean;
|
||||
proxyPathPrefix: string;
|
||||
targetBaseUrl: URL;
|
||||
targetPathPrefix: string;
|
||||
},
|
||||
): URL | undefined {
|
||||
const incomingUrl = new URL(req.url ?? "/", `http://${LOOPBACK_HOST}`);
|
||||
if (
|
||||
incomingUrl.pathname !== params.proxyPathPrefix &&
|
||||
!incomingUrl.pathname.startsWith(`${params.proxyPathPrefix}/`)
|
||||
) {
|
||||
return params.acceptsAzureSdkPaths && isAzureSdkProxyPath(incomingUrl.pathname)
|
||||
? resolveDirectTargetUrl(incomingUrl, params.targetBaseUrl)
|
||||
: undefined;
|
||||
}
|
||||
const suffix = incomingUrl.pathname.slice(params.proxyPathPrefix.length);
|
||||
const targetUrl = new URL(params.targetBaseUrl);
|
||||
targetUrl.pathname = `${params.targetPathPrefix}${suffix}` || "/";
|
||||
for (const [key, value] of incomingUrl.searchParams) {
|
||||
targetUrl.searchParams.append(key, value);
|
||||
}
|
||||
return targetUrl;
|
||||
}
|
||||
|
||||
function resolveDirectTargetUrl(incomingUrl: URL, targetBaseUrl: URL): URL {
|
||||
const targetUrl = new URL(targetBaseUrl);
|
||||
targetUrl.pathname = incomingUrl.pathname;
|
||||
for (const [key, value] of incomingUrl.searchParams) {
|
||||
targetUrl.searchParams.append(key, value);
|
||||
}
|
||||
return targetUrl;
|
||||
}
|
||||
|
||||
function isAzureSdkProxyPath(pathname: string): boolean {
|
||||
return pathname === "/openai" || pathname.startsWith("/openai/");
|
||||
}
|
||||
|
||||
async function readBody(req: IncomingMessage): Promise<Buffer | undefined> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
return chunks.length > 0 ? Buffer.concat(chunks) : undefined;
|
||||
}
|
||||
|
||||
function toFetchBody(body: Buffer): Uint8Array<ArrayBuffer> {
|
||||
const copy = new Uint8Array(body.byteLength);
|
||||
copy.set(body);
|
||||
return copy;
|
||||
}
|
||||
|
||||
function normalizeProxyRequestHeaders(headers: IncomingMessage["headers"]): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (isHopByHopHeader(key) || key.toLowerCase() === "accept-encoding") {
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeHeaderValue(value);
|
||||
if (normalized !== undefined) {
|
||||
out[key] = normalized;
|
||||
}
|
||||
}
|
||||
out["accept-encoding"] = "identity";
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeProxyResponseHeaders(headers: Headers): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
headers.forEach((value, key) => {
|
||||
if (!isHopByHopHeader(key) && !isContentEncodingHeader(key)) {
|
||||
out[key] = value;
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeHeaderValue(value: HeaderValue): string | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return Array.isArray(value) ? value.join(", ") : String(value);
|
||||
}
|
||||
|
||||
function isHopByHopHeader(key: string): boolean {
|
||||
switch (key.toLowerCase()) {
|
||||
case "connection":
|
||||
case "host":
|
||||
case "keep-alive":
|
||||
case "proxy-authenticate":
|
||||
case "proxy-authorization":
|
||||
case "te":
|
||||
case "trailer":
|
||||
case "transfer-encoding":
|
||||
case "upgrade":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isContentEncodingHeader(key: string): boolean {
|
||||
switch (key.toLowerCase()) {
|
||||
case "content-encoding":
|
||||
case "content-length":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function trimTrailingSlash(pathname: string): string {
|
||||
const trimmed = pathname.replace(/\/+$/, "");
|
||||
return trimmed === "" ? "" : trimmed;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ const REGISTERED_EVENT_TYPES = [
|
||||
"tool.execution_complete",
|
||||
"session.plan_changed",
|
||||
"exit_plan_mode.requested",
|
||||
"exit_plan_mode.completed",
|
||||
"subagent.started",
|
||||
"subagent.completed",
|
||||
"subagent.failed",
|
||||
@@ -149,6 +150,50 @@ describe("attachEventBridge", () => {
|
||||
expect(bridge.snapshot().assistantTexts).toEqual(["hello"]);
|
||||
});
|
||||
|
||||
it("ignores child assistant and usage events but keeps child tool side effects", async () => {
|
||||
const session = createFakeSession();
|
||||
const onAssistantDelta = vi.fn();
|
||||
const bridge = attachEventBridge(session, {
|
||||
getSdkSessionId: () => "sdk-session-id",
|
||||
isAborted: () => false,
|
||||
onAssistantDelta,
|
||||
});
|
||||
|
||||
session.emit("assistant.message_delta", {
|
||||
...makeEvent("assistant.message_delta", { deltaContent: "child", messageId: "child-msg" }),
|
||||
agentId: "child-1",
|
||||
} as SessionEvent);
|
||||
session.emit(
|
||||
"assistant.message_delta",
|
||||
makeEvent("assistant.message_delta", { deltaContent: "root", messageId: "root-msg" }),
|
||||
);
|
||||
session.emit("tool.execution_start", {
|
||||
...makeEvent("tool.execution_start", { toolCallId: "child-call", toolName: "write" }),
|
||||
agentId: "child-1",
|
||||
} as SessionEvent);
|
||||
session.emit("tool.execution_complete", {
|
||||
...makeEvent("tool.execution_complete", {
|
||||
result: { content: "child write" },
|
||||
success: true,
|
||||
toolCallId: "child-call",
|
||||
}),
|
||||
agentId: "child-1",
|
||||
} as SessionEvent);
|
||||
session.emit("assistant.usage", {
|
||||
...makeEvent("assistant.usage", { inputTokens: 99, outputTokens: 99 }),
|
||||
agentId: "child-1",
|
||||
} as SessionEvent);
|
||||
|
||||
expect(bridge.snapshot().assistantTexts).toEqual(["root"]);
|
||||
expect(bridge.snapshot().startedCount).toBe(0);
|
||||
expect(bridge.snapshot().toolMetas).toEqual([
|
||||
{ toolName: "write" },
|
||||
{ meta: "child write", toolName: "write" },
|
||||
]);
|
||||
await bridge.awaitDeltaChain();
|
||||
expect(onAssistantDelta).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("interleaved messageIds produce two ordered assistantTexts entries", () => {
|
||||
const session = createFakeSession();
|
||||
const bridge = attachEventBridge(session, {
|
||||
@@ -483,10 +528,18 @@ describe("attachEventBridge", () => {
|
||||
summary: "Plan ready",
|
||||
}),
|
||||
);
|
||||
session.emit(
|
||||
"exit_plan_mode.completed",
|
||||
makeEvent("exit_plan_mode.completed", {
|
||||
approved: true,
|
||||
requestId: "request-1",
|
||||
selectedAction: "approve",
|
||||
}),
|
||||
);
|
||||
|
||||
await bridge.awaitAgentEventChain();
|
||||
|
||||
expect(onAgentEvent).toHaveBeenCalledTimes(2);
|
||||
expect(onAgentEvent).toHaveBeenCalledTimes(3);
|
||||
expect(onAgentEvent).toHaveBeenNthCalledWith(1, {
|
||||
stream: "plan",
|
||||
data: {
|
||||
@@ -509,6 +562,17 @@ describe("attachEventBridge", () => {
|
||||
recommendedAction: "approve",
|
||||
},
|
||||
});
|
||||
expect(onAgentEvent).toHaveBeenNthCalledWith(3, {
|
||||
stream: "plan",
|
||||
data: {
|
||||
phase: "update",
|
||||
title: "Plan decision",
|
||||
source: "copilot-sdk",
|
||||
requestId: "request-1",
|
||||
approved: true,
|
||||
selectedAction: "approve",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards native Copilot subagent lifecycle events to the adapter", () => {
|
||||
|
||||
@@ -128,6 +128,9 @@ export function attachEventBridge(
|
||||
const unsubscribeFns: Array<() => void> = [];
|
||||
|
||||
registerListener(session, unsubscribeFns, "assistant.message_delta", (event) => {
|
||||
if (!isRootSessionEvent(event)) {
|
||||
return;
|
||||
}
|
||||
const messageId = readString(event.data.messageId) ?? "assistant-message";
|
||||
const delta = event.data.deltaContent;
|
||||
if (!delta) {
|
||||
@@ -162,6 +165,9 @@ export function attachEventBridge(
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "assistant.reasoning_delta", (event) => {
|
||||
if (!isRootSessionEvent(event)) {
|
||||
return;
|
||||
}
|
||||
const reasoningId = readString(event.data.reasoningId) ?? "assistant-reasoning";
|
||||
const delta = event.data.deltaContent;
|
||||
if (!delta) {
|
||||
@@ -175,6 +181,9 @@ export function attachEventBridge(
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "assistant.message", (event) => {
|
||||
if (!isRootSessionEvent(event)) {
|
||||
return;
|
||||
}
|
||||
lastAssistantEvent = event;
|
||||
const entry = ensureMessageAccumulator(messagesById, messageOrder, event.data.messageId);
|
||||
if (typeof event.data.content === "string" && event.data.content.length >= entry.text.length) {
|
||||
@@ -183,17 +192,24 @@ export function attachEventBridge(
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "assistant.usage", (event) => {
|
||||
if (!isRootSessionEvent(event)) {
|
||||
return;
|
||||
}
|
||||
usage = normalizeCopilotUsage(event.data);
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "tool.execution_start", (event) => {
|
||||
startedCount += 1;
|
||||
if (isRootSessionEvent(event)) {
|
||||
startedCount += 1;
|
||||
}
|
||||
toolNamesByCallId.set(event.data.toolCallId, event.data.toolName);
|
||||
toolMetas.push({ toolName: event.data.toolName });
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "tool.execution_complete", (event) => {
|
||||
completedCount += 1;
|
||||
if (isRootSessionEvent(event)) {
|
||||
completedCount += 1;
|
||||
}
|
||||
const toolName = toolNamesByCallId.get(event.data.toolCallId);
|
||||
const meta = event.data.success
|
||||
? (event.data.result?.detailedContent ?? event.data.result?.content)
|
||||
@@ -236,6 +252,25 @@ export function attachEventBridge(
|
||||
});
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "exit_plan_mode.completed", (event) => {
|
||||
enqueueAgentEvent({
|
||||
stream: "plan",
|
||||
data: {
|
||||
phase: "update",
|
||||
title: "Plan decision",
|
||||
source: "copilot-sdk",
|
||||
requestId: event.data.requestId,
|
||||
...(event.data.approved !== undefined ? { approved: event.data.approved } : {}),
|
||||
...(event.data.autoApproveEdits !== undefined
|
||||
? { autoApproveEdits: event.data.autoApproveEdits }
|
||||
: {}),
|
||||
...(event.data.feedback ? { feedback: event.data.feedback } : {}),
|
||||
...(event.data.selectedAction ? { selectedAction: event.data.selectedAction } : {}),
|
||||
...(event.agentId ? { agentId: event.agentId } : {}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "subagent.started", (event) => {
|
||||
forwardNativeSubagentEvent(event);
|
||||
});
|
||||
@@ -531,10 +566,14 @@ function isAssistantMessageEvent(
|
||||
return event?.type === "assistant.message";
|
||||
}
|
||||
|
||||
function isRootSessionEvent(event: { agentId?: string }): boolean {
|
||||
return event.agentId === undefined;
|
||||
}
|
||||
|
||||
function isRootCompactionEvent(event: { agentId?: string }): boolean {
|
||||
// SDK session events include subagent compaction; only root compaction
|
||||
// affects the pooled root session's cleanup and reuse lifecycle.
|
||||
return event.agentId === undefined;
|
||||
return isRootSessionEvent(event);
|
||||
}
|
||||
|
||||
function joinReasoning(order: string[], reasoningById: Map<string, string>): string {
|
||||
|
||||
376
extensions/copilot/src/provider-bridge.test.ts
Normal file
376
extensions/copilot/src/provider-bridge.test.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
// Copilot tests cover BYOK provider mapping behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
COPILOT_BYOK_PROVIDER_ERROR,
|
||||
COPILOT_BYOK_ENDPOINT_POLICY_ERROR,
|
||||
COPILOT_BYOK_TRANSPORT_POLICY_ERROR,
|
||||
resolveCopilotProvider,
|
||||
supportsCopilotByokProviderShape,
|
||||
} from "./provider-bridge.js";
|
||||
|
||||
describe("resolveCopilotProvider", () => {
|
||||
it("keeps the subscription provider on the native Copilot auth path", () => {
|
||||
expect(
|
||||
resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "github-copilot",
|
||||
api: "github-copilot",
|
||||
id: "gpt-5",
|
||||
baseUrl: "https://ignored.example",
|
||||
},
|
||||
resolvedApiKey: "ignored",
|
||||
}),
|
||||
).toEqual({ mode: "github-copilot" });
|
||||
});
|
||||
|
||||
it("maps OpenAI Responses BYOK with a bearer token and stable limits", () => {
|
||||
const result = resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "local-proxy",
|
||||
api: "openai-responses",
|
||||
id: "proxy-model",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
authHeader: true,
|
||||
contextTokens: 12_000,
|
||||
maxTokens: 512,
|
||||
headers: { "X-Trace": "test" },
|
||||
},
|
||||
resolvedApiKey: "secret-key",
|
||||
authProfileId: "local-proxy:main",
|
||||
});
|
||||
|
||||
expect(result.mode).toBe("byok");
|
||||
expect(result.authProfileId).toBe("local-proxy:main");
|
||||
expect(result.authProfileVersion).toMatch(/^sha256:/);
|
||||
expect(result.provider).toEqual({
|
||||
type: "openai",
|
||||
wireApi: "responses",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
modelId: "proxy-model",
|
||||
wireModel: "proxy-model",
|
||||
bearerToken: "secret-key",
|
||||
headers: { "X-Trace": "test" },
|
||||
maxPromptTokens: 12_000,
|
||||
maxOutputTokens: 512,
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults custom BYOK providers without an api to OpenAI Responses", () => {
|
||||
const result = resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "custom-proxy",
|
||||
id: "proxy-model",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
},
|
||||
resolvedApiKey: "secret-key",
|
||||
});
|
||||
|
||||
expect(result.provider).toMatchObject({
|
||||
type: "openai",
|
||||
wireApi: "responses",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
});
|
||||
expect(supportsCopilotByokProviderShape({ baseUrl: "https://proxy.example/v1" })).toBe(true);
|
||||
});
|
||||
|
||||
it("changes the BYOK compatibility fingerprint when token limits change", () => {
|
||||
const base = {
|
||||
provider: "custom-proxy",
|
||||
api: "openai-responses",
|
||||
id: "proxy-model",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
};
|
||||
|
||||
const small = resolveCopilotProvider({
|
||||
model: { ...base, contextTokens: 8_000, maxTokens: 512 },
|
||||
resolvedApiKey: "secret-key",
|
||||
});
|
||||
const large = resolveCopilotProvider({
|
||||
model: { ...base, contextTokens: 16_000, maxTokens: 1024 },
|
||||
resolvedApiKey: "secret-key",
|
||||
});
|
||||
|
||||
expect(small.authProfileVersion).not.toBe(large.authProfileVersion);
|
||||
});
|
||||
|
||||
it("maps Anthropic and Ollama-compatible APIs", () => {
|
||||
expect(
|
||||
resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "anthropic-proxy",
|
||||
api: "anthropic-messages",
|
||||
id: "claude",
|
||||
baseUrl: "https://anthropic.example",
|
||||
},
|
||||
}).provider,
|
||||
).toMatchObject({ type: "anthropic", baseUrl: "https://anthropic.example" });
|
||||
|
||||
expect(
|
||||
resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "ollama-compatible",
|
||||
api: "ollama",
|
||||
id: "qwen",
|
||||
baseUrl: "https://ollama-compatible.example/v1",
|
||||
},
|
||||
}).provider,
|
||||
).toMatchObject({ type: "openai", wireApi: "completions" });
|
||||
});
|
||||
|
||||
it("normalizes Azure OpenAI Responses config for the Copilot SDK provider contract", () => {
|
||||
const result = resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "custom-azure",
|
||||
api: "azure-openai-responses",
|
||||
id: "deployment-gpt",
|
||||
baseUrl: "https://example.openai.azure.com/openai/v1",
|
||||
azureApiVersion: "2025-01-01-preview",
|
||||
},
|
||||
resolvedApiKey: "azure-key",
|
||||
});
|
||||
|
||||
expect(result.provider).toEqual({
|
||||
type: "azure",
|
||||
wireApi: "responses",
|
||||
baseUrl: "https://example.openai.azure.com",
|
||||
modelId: "deployment-gpt",
|
||||
wireModel: "deployment-gpt",
|
||||
apiKey: "azure-key",
|
||||
azure: { apiVersion: "2025-01-01-preview" },
|
||||
});
|
||||
expect(
|
||||
resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "custom-azure",
|
||||
api: "azure-openai-responses",
|
||||
id: "deployment-gpt",
|
||||
baseUrl: "https://example.cognitiveservices.azure.com/openai/v1",
|
||||
},
|
||||
}).provider,
|
||||
).toMatchObject({
|
||||
type: "azure",
|
||||
baseUrl: "https://example.cognitiveservices.azure.com",
|
||||
});
|
||||
expect(
|
||||
resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "custom-azure",
|
||||
api: "azure-openai-responses",
|
||||
id: "deployment",
|
||||
baseUrl: "https://example.cognitiveservices.azure.com/openai/v1",
|
||||
},
|
||||
}).provider,
|
||||
).not.toHaveProperty("azure");
|
||||
expect(
|
||||
resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "custom-azure",
|
||||
api: "azure-openai-responses",
|
||||
id: "deployment-gpt",
|
||||
baseUrl: "https://project.services.ai.azure.com/api/projects/demo/openai/v1",
|
||||
},
|
||||
resolvedApiKey: "azure-key",
|
||||
}).provider,
|
||||
).toEqual({
|
||||
type: "openai",
|
||||
wireApi: "responses",
|
||||
baseUrl: "https://project.services.ai.azure.com/api/projects/demo/openai/v1",
|
||||
modelId: "deployment-gpt",
|
||||
wireModel: "deployment-gpt",
|
||||
apiKey: "azure-key",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not forward local auth markers or null no-auth headers", () => {
|
||||
const result = resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "local-proxy",
|
||||
api: "openai-completions",
|
||||
id: "local-model",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
authHeader: true,
|
||||
headers: {
|
||||
Authorization: null,
|
||||
"X-Local": "true",
|
||||
},
|
||||
},
|
||||
resolvedApiKey: "custom-local",
|
||||
});
|
||||
|
||||
expect(result.provider).toEqual({
|
||||
type: "openai",
|
||||
wireApi: "completions",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
modelId: "local-model",
|
||||
wireModel: "local-model",
|
||||
headers: { "X-Local": "true" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not synthesize SDK apiKey auth when request auth already prepared headers", () => {
|
||||
const result = resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "custom-header-proxy",
|
||||
api: "openai-responses",
|
||||
id: "proxy-model",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
headers: { "x-api-key": "header-secret" },
|
||||
requestAuthMode: "header",
|
||||
},
|
||||
resolvedApiKey: "header-secret",
|
||||
});
|
||||
|
||||
expect(result.provider).toEqual({
|
||||
type: "openai",
|
||||
wireApi: "responses",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
modelId: "proxy-model",
|
||||
wireModel: "proxy-model",
|
||||
headers: { "x-api-key": "header-secret" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects request transport policy the SDK provider config cannot enforce", () => {
|
||||
for (const model of [
|
||||
{ requestProxy: { mode: "env-proxy" } },
|
||||
{ requestTls: { ca: "ca-pem" } },
|
||||
{ requestAllowPrivateNetwork: false },
|
||||
]) {
|
||||
expect(() =>
|
||||
resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "custom-proxy",
|
||||
api: "openai-responses",
|
||||
id: "proxy-model",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
...model,
|
||||
},
|
||||
}),
|
||||
).toThrow(COPILOT_BYOK_TRANSPORT_POLICY_ERROR);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects BYOK endpoints blocked by OpenClaw SSRF policy", () => {
|
||||
for (const baseUrl of [
|
||||
"file://public.example/v1",
|
||||
"ftp://public.example/v1",
|
||||
"http://proxy.example/v1",
|
||||
"https://user:pass@proxy.example/v1",
|
||||
"https://proxy.example/v1?api_key=secret",
|
||||
"https://proxy.example/v1?x-api-key=secret",
|
||||
"https://proxy.example/v1?x-auth-token=secret",
|
||||
"https://proxy.example/v1?password=secret",
|
||||
"https://proxy.example/v1?client%5Fse%E2%80%8Bcret=secret",
|
||||
"http://169.254.169.254/v1",
|
||||
"http://metadata.google.internal/v1",
|
||||
"http://localhost:11434/v1",
|
||||
]) {
|
||||
expect(() =>
|
||||
resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "custom-proxy",
|
||||
api: "openai-responses",
|
||||
id: "proxy-model",
|
||||
baseUrl,
|
||||
},
|
||||
}),
|
||||
).toThrow(COPILOT_BYOK_ENDPOINT_POLICY_ERROR);
|
||||
}
|
||||
});
|
||||
|
||||
it("advertises support only for representable BYOK provider shapes", () => {
|
||||
expect(
|
||||
supportsCopilotByokProviderShape({
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
supportsCopilotByokProviderShape({
|
||||
api: "azure-openai-responses",
|
||||
baseUrl: "https://example.openai.azure.com/openai/v1",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
supportsCopilotByokProviderShape({
|
||||
api: "azure-openai-responses",
|
||||
baseUrl: "https://project.services.ai.azure.com/api/projects/demo/openai/v1",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
supportsCopilotByokProviderShape({
|
||||
api: "azure-openai-responses",
|
||||
baseUrl: "https://project.services.ai.azure.com/api/projects/demo",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
supportsCopilotByokProviderShape({
|
||||
api: "google-generative-ai",
|
||||
baseUrl: "https://google.example",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
supportsCopilotByokProviderShape({
|
||||
api: "openai-responses",
|
||||
baseUrl: "file://public.example/v1",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
supportsCopilotByokProviderShape({
|
||||
api: "openai-responses",
|
||||
baseUrl: "http://proxy.example/v1",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
supportsCopilotByokProviderShape({
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://user:pass@proxy.example/v1",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
supportsCopilotByokProviderShape({
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://proxy.example/v1?api_key=secret",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
supportsCopilotByokProviderShape({
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://proxy.example/v1?x-api-key=secret",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(supportsCopilotByokProviderShape({ api: "openai-responses" })).toBe(false);
|
||||
expect(
|
||||
supportsCopilotByokProviderShape({
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
requestProxy: { mode: "env-proxy" },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects provider APIs the SDK adapter cannot represent", () => {
|
||||
expect(() =>
|
||||
resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "google",
|
||||
api: "google-generative-ai",
|
||||
id: "gemini",
|
||||
baseUrl: "https://google.example",
|
||||
},
|
||||
}),
|
||||
).toThrow(COPILOT_BYOK_PROVIDER_ERROR);
|
||||
});
|
||||
|
||||
it("requires an endpoint for non-subscription providers", () => {
|
||||
expect(() =>
|
||||
resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "custom",
|
||||
api: "openai-completions",
|
||||
id: "model",
|
||||
},
|
||||
}),
|
||||
).toThrow(COPILOT_BYOK_PROVIDER_ERROR);
|
||||
});
|
||||
});
|
||||
339
extensions/copilot/src/provider-bridge.ts
Normal file
339
extensions/copilot/src/provider-bridge.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
// Copilot plugin module implements BYOK provider mapping.
|
||||
import type { ProviderConfig } from "@github/copilot-sdk";
|
||||
import { isNonSecretApiKeyMarker } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { tokenFingerprint } from "./auth-bridge.js";
|
||||
|
||||
export const COPILOT_BYOK_PROVIDER_ERROR =
|
||||
"[copilot-attempt] BYOK requires an OpenAI-compatible or Anthropic model api and a non-empty baseUrl";
|
||||
export const COPILOT_BYOK_TRANSPORT_POLICY_ERROR =
|
||||
"[copilot-attempt] BYOK does not support OpenClaw provider request proxy, TLS, or private-network policy overrides";
|
||||
export const COPILOT_BYOK_ENDPOINT_POLICY_ERROR =
|
||||
"[copilot-attempt] BYOK endpoint is blocked by OpenClaw SSRF policy";
|
||||
|
||||
const CREDENTIAL_QUERY_PARAM_NAMES = new Set([
|
||||
"accesstoken",
|
||||
"appsecret",
|
||||
"auth",
|
||||
"authtoken",
|
||||
"apikey",
|
||||
"authorization",
|
||||
"clientsecret",
|
||||
"code",
|
||||
"credential",
|
||||
"hooktoken",
|
||||
"idtoken",
|
||||
"jwt",
|
||||
"key",
|
||||
"pass",
|
||||
"passwd",
|
||||
"password",
|
||||
"privatekey",
|
||||
"refreshtoken",
|
||||
"secret",
|
||||
"session",
|
||||
"sig",
|
||||
"signature",
|
||||
"token",
|
||||
"xapikey",
|
||||
"xaccesstoken",
|
||||
"xamzsecuritytoken",
|
||||
"xamzsignature",
|
||||
"xauthtoken",
|
||||
]);
|
||||
const QUERY_PARAM_NAME_SEPARATOR_RE = /[\p{C}\p{Z}\u115F\u1160\u3164\uFFA0+]/gu;
|
||||
|
||||
export type CopilotProviderMode = "github-copilot" | "byok";
|
||||
|
||||
export type CopilotModelProviderInput = {
|
||||
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;
|
||||
};
|
||||
|
||||
export type ResolvedCopilotProvider = {
|
||||
mode: CopilotProviderMode;
|
||||
provider?: ProviderConfig;
|
||||
authProfileId?: string;
|
||||
authProfileVersion?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps OpenClaw's prepared model facts into the Copilot SDK's session-level
|
||||
* provider contract. The SDK owns the wire request; OpenClaw only supplies
|
||||
* the already-resolved endpoint, model, headers, and credential.
|
||||
*/
|
||||
export function resolveCopilotProvider(params: {
|
||||
model: CopilotModelProviderInput;
|
||||
resolvedApiKey?: string;
|
||||
authProfileId?: string;
|
||||
}): ResolvedCopilotProvider {
|
||||
if (params.model.provider.trim().toLowerCase() === "github-copilot") {
|
||||
return { mode: "github-copilot" };
|
||||
}
|
||||
|
||||
const baseUrl = readString(params.model.baseUrl);
|
||||
if (!baseUrl) {
|
||||
throw new Error(COPILOT_BYOK_PROVIDER_ERROR);
|
||||
}
|
||||
assertByokEndpointAllowed(baseUrl);
|
||||
if (hasUnsupportedTransportPolicy(params.model)) {
|
||||
throw new Error(COPILOT_BYOK_TRANSPORT_POLICY_ERROR);
|
||||
}
|
||||
|
||||
const api = readString(params.model.api)?.toLowerCase() ?? "openai-responses";
|
||||
const provider = resolveProviderType(api, baseUrl, params.model.azureApiVersion);
|
||||
const resolvedApiKey = resolveProviderCredential(params.resolvedApiKey);
|
||||
const headers = resolveProviderHeaders(params.model.headers);
|
||||
const requestAuthMode = readString(params.model.requestAuthMode)?.toLowerCase();
|
||||
const usePreparedRequestAuth =
|
||||
requestAuthMode !== undefined && requestAuthMode !== "provider-default";
|
||||
const providerConfig: ProviderConfig = {
|
||||
type: provider.type,
|
||||
...(provider.wireApi ? { wireApi: provider.wireApi } : {}),
|
||||
baseUrl: provider.baseUrl,
|
||||
modelId: params.model.id,
|
||||
wireModel: params.model.id,
|
||||
...(resolvedApiKey && !usePreparedRequestAuth
|
||||
? params.model.authHeader
|
||||
? { bearerToken: resolvedApiKey }
|
||||
: { apiKey: resolvedApiKey }
|
||||
: {}),
|
||||
...(headers ? { headers } : {}),
|
||||
...(provider.azure ? { azure: provider.azure } : {}),
|
||||
...((params.model.contextTokens ?? params.model.contextWindow)
|
||||
? { maxPromptTokens: params.model.contextTokens ?? params.model.contextWindow }
|
||||
: {}),
|
||||
...(params.model.maxTokens ? { maxOutputTokens: params.model.maxTokens } : {}),
|
||||
};
|
||||
const authProfileId = params.authProfileId?.trim() || `byok:${params.model.provider}`;
|
||||
const authProfileVersion = tokenFingerprint(
|
||||
stableSerialize({
|
||||
api,
|
||||
baseUrl: provider.baseUrl,
|
||||
azureApiVersion: provider.azure?.apiVersion,
|
||||
headers,
|
||||
authHeader: params.model.authHeader,
|
||||
requestAuthMode: params.model.requestAuthMode,
|
||||
apiKey: resolvedApiKey,
|
||||
modelId: params.model.id,
|
||||
maxPromptTokens: params.model.contextTokens ?? params.model.contextWindow,
|
||||
maxOutputTokens: params.model.maxTokens,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
mode: "byok",
|
||||
provider: providerConfig,
|
||||
authProfileId,
|
||||
authProfileVersion,
|
||||
};
|
||||
}
|
||||
|
||||
export function isCopilotByokUnsupportedProviderError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
(error.message === COPILOT_BYOK_PROVIDER_ERROR ||
|
||||
error.message === COPILOT_BYOK_TRANSPORT_POLICY_ERROR ||
|
||||
error.message === COPILOT_BYOK_ENDPOINT_POLICY_ERROR)
|
||||
);
|
||||
}
|
||||
|
||||
export function supportsCopilotByokProviderShape(
|
||||
model: Pick<
|
||||
CopilotModelProviderInput,
|
||||
"api" | "baseUrl" | "requestProxy" | "requestTls" | "requestAllowPrivateNetwork"
|
||||
>,
|
||||
): boolean {
|
||||
if (!readString(model.baseUrl) || hasUnsupportedTransportPolicy(model)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
resolveProviderType(
|
||||
readString(model.api)?.toLowerCase() ?? "openai-responses",
|
||||
readString(model.baseUrl)!,
|
||||
undefined,
|
||||
);
|
||||
assertByokEndpointHostAllowed(readString(model.baseUrl)!);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function hasUnsupportedTransportPolicy(
|
||||
model: Pick<
|
||||
CopilotModelProviderInput,
|
||||
"requestProxy" | "requestTls" | "requestAllowPrivateNetwork"
|
||||
>,
|
||||
): boolean {
|
||||
return (
|
||||
model.requestProxy !== undefined ||
|
||||
model.requestTls !== undefined ||
|
||||
model.requestAllowPrivateNetwork !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
function assertByokEndpointHostAllowed(baseUrl: string): void {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(baseUrl);
|
||||
} catch {
|
||||
throw new Error(COPILOT_BYOK_PROVIDER_ERROR);
|
||||
}
|
||||
if (url.protocol !== "https:") {
|
||||
throw new Error(COPILOT_BYOK_ENDPOINT_POLICY_ERROR);
|
||||
}
|
||||
if (url.username || url.password) {
|
||||
throw new Error(COPILOT_BYOK_ENDPOINT_POLICY_ERROR);
|
||||
}
|
||||
for (const key of url.searchParams.keys()) {
|
||||
if (CREDENTIAL_QUERY_PARAM_NAMES.has(normalizeCredentialQueryParamName(key))) {
|
||||
throw new Error(COPILOT_BYOK_ENDPOINT_POLICY_ERROR);
|
||||
}
|
||||
}
|
||||
const hostname = url.hostname.toLowerCase().replace(/\.+$/, "");
|
||||
if (isBlockedHostnameOrIp(hostname)) {
|
||||
throw new Error(COPILOT_BYOK_ENDPOINT_POLICY_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCredentialQueryParamName(name: string): string {
|
||||
const stripped = name.replace(QUERY_PARAM_NAME_SEPARATOR_RE, "");
|
||||
try {
|
||||
return decodeURIComponent(stripped)
|
||||
.replace(QUERY_PARAM_NAME_SEPARATOR_RE, "")
|
||||
.toLowerCase()
|
||||
.replace(/[-_]/g, "");
|
||||
} catch {
|
||||
return stripped.toLowerCase().replace(/[-_]/g, "");
|
||||
}
|
||||
}
|
||||
|
||||
function assertByokEndpointAllowed(baseUrl: string): void {
|
||||
assertByokEndpointHostAllowed(baseUrl);
|
||||
}
|
||||
|
||||
function resolveProviderType(
|
||||
api: string | undefined,
|
||||
baseUrl: string,
|
||||
azureApiVersion: string | undefined,
|
||||
): {
|
||||
type: NonNullable<ProviderConfig["type"]>;
|
||||
wireApi?: NonNullable<ProviderConfig["wireApi"]>;
|
||||
baseUrl: string;
|
||||
azure?: NonNullable<ProviderConfig["azure"]>;
|
||||
} {
|
||||
switch (api) {
|
||||
case "anthropic-messages":
|
||||
return { type: "anthropic", baseUrl };
|
||||
case "azure-openai-responses":
|
||||
return resolveAzureProviderType(baseUrl, azureApiVersion);
|
||||
case "openai-responses":
|
||||
return { type: "openai", wireApi: "responses", baseUrl };
|
||||
case "openai-completions":
|
||||
case "ollama":
|
||||
return { type: "openai", wireApi: "completions", baseUrl };
|
||||
default:
|
||||
throw new Error(COPILOT_BYOK_PROVIDER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAzureProviderType(
|
||||
baseUrl: string,
|
||||
apiVersion: string | undefined,
|
||||
): {
|
||||
type: NonNullable<ProviderConfig["type"]>;
|
||||
wireApi: NonNullable<ProviderConfig["wireApi"]>;
|
||||
baseUrl: string;
|
||||
azure?: NonNullable<ProviderConfig["azure"]>;
|
||||
} {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(baseUrl);
|
||||
} catch {
|
||||
throw new Error(COPILOT_BYOK_PROVIDER_ERROR);
|
||||
}
|
||||
if (isOpenAICompatibleAzureResponsesBaseUrl(url)) {
|
||||
return { type: "openai", wireApi: "responses", baseUrl };
|
||||
}
|
||||
if (!isTraditionalAzureOpenAIHost(url.hostname)) {
|
||||
throw new Error(COPILOT_BYOK_PROVIDER_ERROR);
|
||||
}
|
||||
url.pathname = "";
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
const resolvedApiVersion = readString(apiVersion);
|
||||
return {
|
||||
type: "azure",
|
||||
wireApi: "responses",
|
||||
baseUrl: url.toString().replace(/\/+$/, ""),
|
||||
...(resolvedApiVersion ? { azure: { apiVersion: resolvedApiVersion } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function isTraditionalAzureOpenAIHost(hostname: string): boolean {
|
||||
return (
|
||||
hostname.endsWith(".openai.azure.com") || hostname.endsWith(".cognitiveservices.azure.com")
|
||||
);
|
||||
}
|
||||
|
||||
function isOpenAICompatibleAzureResponsesBaseUrl(url: URL): boolean {
|
||||
if (isTraditionalAzureOpenAIHost(url.hostname)) {
|
||||
return false;
|
||||
}
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
const isFoundryHost =
|
||||
hostname.endsWith(".services.ai.azure.com") ||
|
||||
hostname.endsWith(".api.cognitive.microsoft.com");
|
||||
if (!isFoundryHost) {
|
||||
return false;
|
||||
}
|
||||
const normalizedPath = url.pathname.replace(/\/+$/, "");
|
||||
return normalizedPath === "/openai/v1" || normalizedPath.endsWith("/openai/v1");
|
||||
}
|
||||
|
||||
function stableSerialize(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map(stableSerialize).join(",")}]`;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return `{${Object.entries(value as Record<string, unknown>)
|
||||
.toSorted(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, entry]) => `${JSON.stringify(key)}:${stableSerialize(entry)}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value) ?? "null";
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function resolveProviderCredential(value: string | undefined): string | undefined {
|
||||
const credential = readString(value);
|
||||
return credential && !isNonSecretApiKeyMarker(credential) ? credential : undefined;
|
||||
}
|
||||
|
||||
function resolveProviderHeaders(
|
||||
headers: Record<string, string | null | undefined> | undefined,
|
||||
): Record<string, string> | undefined {
|
||||
if (!headers) {
|
||||
return undefined;
|
||||
}
|
||||
const resolved = Object.fromEntries(
|
||||
Object.entries(headers).filter(([, value]) => typeof value === "string"),
|
||||
) as Record<string, string>;
|
||||
return Object.keys(resolved).length > 0 ? resolved : undefined;
|
||||
}
|
||||
@@ -14,7 +14,7 @@ const POOL_DISPOSED_MESSAGE = "[copilot-pool] pool disposed";
|
||||
export interface PoolKey {
|
||||
readonly agentId: string;
|
||||
readonly copilotHome: string;
|
||||
readonly authMode: "useLoggedInUser" | "gitHubToken";
|
||||
readonly authMode: "useLoggedInUser" | "gitHubToken" | "byok";
|
||||
readonly authProfileId?: string;
|
||||
readonly authProfileVersion?: string;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
// Copilot tests cover tool bridge plugin behavior.
|
||||
import type { Tool as SdkTool, ToolInvocation, ToolResultObject } from "@github/copilot-sdk";
|
||||
import type { AnyAgentTool, SandboxContext } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
initializeGlobalHookRunner,
|
||||
resetGlobalHookRunner,
|
||||
} from "openclaw/plugin-sdk/hook-runtime";
|
||||
import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createCopilotToolBridge,
|
||||
convertOpenClawToolToSdkTool,
|
||||
supportsModelTools,
|
||||
testing,
|
||||
} from "./tool-bridge.js";
|
||||
|
||||
type FakeTool = AnyAgentTool & {
|
||||
@@ -77,6 +83,7 @@ function runSdkTool(tool: SdkTool, args: unknown, invocation = makeInvocation())
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetGlobalHookRunner();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -107,6 +114,29 @@ describe("createCopilotToolBridge", () => {
|
||||
expect(createOpenClawCodingTools).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("allows vetted BYOK providers to expose model tools", async () => {
|
||||
const sourceTools = [makeTool()];
|
||||
const createOpenClawCodingTools = vi.fn(async () => sourceTools);
|
||||
|
||||
const result = await createCopilotToolBridge({
|
||||
agentId: "agent-1",
|
||||
allowModelTools: true,
|
||||
createOpenClawCodingTools,
|
||||
modelId: "gpt-test",
|
||||
modelProvider: "custom-openai",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
expect(createOpenClawCodingTools).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
modelId: "gpt-test",
|
||||
modelProvider: "custom-openai",
|
||||
}),
|
||||
);
|
||||
expect(result.sourceTools).toEqual(sourceTools);
|
||||
expect(result.sdkTools.map((tool) => tool.name)).toEqual(["tool-a"]);
|
||||
});
|
||||
|
||||
it("forwards supported fields to injected createOpenClawCodingTools", async () => {
|
||||
const controller = new AbortController();
|
||||
const createOpenClawCodingTools = vi.fn(async () => [makeTool()]);
|
||||
@@ -286,6 +316,79 @@ describe("createCopilotToolBridge", () => {
|
||||
expect(result.sdkTools.map((tool) => tool.name)).toEqual(["exec", "wait"]);
|
||||
});
|
||||
|
||||
it("runs requester-aware policy before code-mode exec controls", async () => {
|
||||
const beforeToolCall = vi.fn(() => ({
|
||||
block: true,
|
||||
blockReason: "blocked before code-mode execution",
|
||||
}));
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "before_tool_call", handler: beforeToolCall }]),
|
||||
);
|
||||
const createOpenClawCodingTools = vi.fn(async () => [makeTool({ name: "read" })]);
|
||||
|
||||
const result = await createCopilotToolBridge({
|
||||
agentId: "agent-1",
|
||||
attemptParams: {
|
||||
config: { tools: { codeMode: true } },
|
||||
runId: "run-code-mode",
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:main",
|
||||
jobId: "job-1",
|
||||
trigger: "user",
|
||||
messageChannel: "slack",
|
||||
messageProvider: "slack-voice",
|
||||
currentChannelId: "slack:C123",
|
||||
senderId: "U123",
|
||||
channelContext: { sender: { id: "U123", displayName: "Ada" } },
|
||||
} as never,
|
||||
createOpenClawCodingTools,
|
||||
modelId: "gpt-4o",
|
||||
modelProvider: "github-copilot",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
const exec = result.sdkTools.find((tool) => tool.name === "exec");
|
||||
if (!exec) {
|
||||
throw new Error("missing code-mode exec control");
|
||||
}
|
||||
|
||||
await runSdkTool(
|
||||
exec,
|
||||
{ code: "return 1;" },
|
||||
makeInvocation({ toolCallId: "code-call-1", toolName: "exec" }),
|
||||
);
|
||||
|
||||
expect(beforeToolCall).toHaveBeenCalledTimes(1);
|
||||
expect(beforeToolCall).toHaveBeenCalledWith(
|
||||
{
|
||||
toolName: "exec",
|
||||
params: { code: "return 1;", command: "return 1;" },
|
||||
toolKind: "code_mode_exec",
|
||||
toolInputKind: "javascript",
|
||||
runId: "run-code-mode",
|
||||
toolCallId: "code-call-1",
|
||||
},
|
||||
{
|
||||
toolName: "exec",
|
||||
toolKind: "code_mode_exec",
|
||||
toolInputKind: "javascript",
|
||||
agentId: "agent-1",
|
||||
sessionKey: "agent:main:main",
|
||||
sessionId: "session-1",
|
||||
runId: "run-code-mode",
|
||||
jobId: "job-1",
|
||||
trigger: "user",
|
||||
messageProvider: "slack-voice",
|
||||
channel: "slack",
|
||||
senderId: "U123",
|
||||
toolCallId: "code-call-1",
|
||||
channelId: "C123",
|
||||
channelContext: {
|
||||
sender: { id: "U123", displayName: "Ada" },
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps code-mode controls visible when a narrow allowlist is active", async () => {
|
||||
const createOpenClawCodingTools = vi.fn(async () => [
|
||||
makeTool({ name: "fake_hidden" }),
|
||||
@@ -420,7 +523,13 @@ describe("createCopilotToolBridge", () => {
|
||||
currentMessagingTarget: "user:U123",
|
||||
currentThreadTs: "1700000000.000100",
|
||||
currentMessageId: "M-1",
|
||||
messageProvider: "slack",
|
||||
messageChannel: "slack",
|
||||
messageProvider: "slack-voice",
|
||||
chatId: "chat-1",
|
||||
channelContext: {
|
||||
sender: { id: "sender-1", displayName: "Ada" },
|
||||
chat: { id: "chat-1", kind: "channel" },
|
||||
},
|
||||
messageTo: "U-1",
|
||||
messageThreadId: "1700000000.000100",
|
||||
replyToMode: "first",
|
||||
@@ -454,7 +563,13 @@ describe("createCopilotToolBridge", () => {
|
||||
currentMessagingTarget: "user:U123",
|
||||
currentThreadTs: "1700000000.000100",
|
||||
currentMessageId: "M-1",
|
||||
messageProvider: "slack",
|
||||
messageChannel: "slack",
|
||||
messageProvider: "slack-voice",
|
||||
chatId: "chat-1",
|
||||
hookChannelContext: {
|
||||
sender: { id: "sender-1", displayName: "Ada" },
|
||||
chat: { id: "chat-1", kind: "channel" },
|
||||
},
|
||||
messageTo: "U-1",
|
||||
messageThreadId: "1700000000.000100",
|
||||
replyToMode: "first",
|
||||
@@ -462,6 +577,7 @@ describe("createCopilotToolBridge", () => {
|
||||
forceMessageTool: true,
|
||||
enableHeartbeatTool: true,
|
||||
});
|
||||
expect(opts.channelContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back messageProvider to attemptParams.messageChannel when messageProvider is absent (codex parity)", async () => {
|
||||
@@ -479,6 +595,63 @@ describe("createCopilotToolBridge", () => {
|
||||
expect(getOpts().messageProvider).toBe("telegram");
|
||||
});
|
||||
|
||||
it("uses messageTo when currentMessagingTarget is absent in tool hook routing", () => {
|
||||
const context = testing.buildCopilotToolHookContext({
|
||||
agentId: "agent-1",
|
||||
messageChannel: "slack",
|
||||
messageProvider: "slack",
|
||||
messageTo: "user:U-only",
|
||||
trigger: "user",
|
||||
});
|
||||
|
||||
expect(context).toMatchObject({
|
||||
channel: "slack",
|
||||
messageProvider: "slack",
|
||||
channelId: "U-only",
|
||||
turnSourceChannel: "slack",
|
||||
turnSourceTo: "user:U-only",
|
||||
});
|
||||
expect(context.chatId).toBeUndefined();
|
||||
expect(context.channelContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves per-agent loop detection overrides for generated code-mode controls", () => {
|
||||
const context = testing.buildCopilotToolHookContext({
|
||||
agentId: "agent-1",
|
||||
config: {
|
||||
tools: {
|
||||
loopDetection: {
|
||||
enabled: true,
|
||||
warningThreshold: 7,
|
||||
detectors: { genericRepeat: true },
|
||||
postCompactionGuard: { windowSize: 4 },
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "agent-1",
|
||||
tools: {
|
||||
loopDetection: {
|
||||
enabled: false,
|
||||
detectors: { pingPong: false },
|
||||
postCompactionGuard: { windowSize: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(context.loopDetection).toEqual({
|
||||
enabled: false,
|
||||
warningThreshold: 7,
|
||||
detectors: { genericRepeat: true, pingPong: false },
|
||||
postCompactionGuard: { windowSize: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards authProfileStore, runId, config, and run hooks (onToolOutcome) from attemptParams", async () => {
|
||||
const { createOpenClawCodingTools, getOpts } = captureCall();
|
||||
const authProfileStore = { kind: "fake-store" } as never;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user