mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-26 01:01:58 +08:00
Compare commits
89 Commits
omarshahin
...
codex/agen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac505335e4 | ||
|
|
374076b5a8 | ||
|
|
242fbf1a67 | ||
|
|
434d752dd6 | ||
|
|
3179692f0e | ||
|
|
6add1cc969 | ||
|
|
cb13be375d | ||
|
|
acc2a0ee72 | ||
|
|
704fc35043 | ||
|
|
f1e38f2ed6 | ||
|
|
d2933bbdb9 | ||
|
|
2e124081af | ||
|
|
8150b76b6f | ||
|
|
77eb0fdbaa | ||
|
|
f0be8e7b6e | ||
|
|
80bd0003ce | ||
|
|
f3891e1335 | ||
|
|
bea3d292c7 | ||
|
|
17066f2d7c | ||
|
|
9aea104cc8 | ||
|
|
2aa9d67635 | ||
|
|
51eec3a757 | ||
|
|
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
|
||||
6349131baaa1828f2a071f42e4d7b17c8966c59b6588c8a4c1a32ea5ea4dcd5e config-baseline.core.json
|
||||
de674ef01dad2828bb711a4648dc5a00f696f71c3c59004131d9475769bc1ff8 config-baseline.channel.json
|
||||
ce2a731077f0f0135b7eaf01b00a60abfa0d2776aba4be237491d492af0c8a02 config-baseline.plugin.json
|
||||
1b953a19c347a27a0f9e856f23769b0c48d051354be4c88778c215231817fe8a config-baseline.json
|
||||
f3fcfb358d8b8a1f0fa8676090339ff8df1b28ef6c7e80705a979a5c70e2a323 config-baseline.core.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
|
||||
0418a175983d6e17f535ebb49d07371ceed57c7002f8991113d548f02b1d17d1 plugin-sdk-api-baseline.json
|
||||
319e947cff12d9c2c5781b6f97f9b6b1c4f8a251dc1e87703c534a37614325cf 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)
|
||||
|
||||
@@ -30,6 +30,68 @@ title: "Usage tracking"
|
||||
- CLI: `openclaw channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).
|
||||
- macOS menu bar: "Usage" section under Context (only if available).
|
||||
|
||||
## Default usage footer mode
|
||||
|
||||
`/usage off|tokens|full` sets the footer for a session and is remembered for that
|
||||
session. `messages.responseUsage` seeds that mode for sessions that have not
|
||||
chosen one, so the footer can be on by default without typing `/usage` each time.
|
||||
|
||||
Set one mode for every channel, or a per-channel map with a `default` fallback:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"messages": {
|
||||
"responseUsage": "tokens",
|
||||
// or: { "default": "off", "discord": "full" }
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Three distinct session states
|
||||
|
||||
A session's `responseUsage` field has three representable states, each with
|
||||
different semantics:
|
||||
|
||||
| State | Stored value | Effective mode |
|
||||
| ------------------- | ------------------------------- | --------------------------------------------------------------------- |
|
||||
| **Unset / inherit** | `undefined` (absent) | Falls through to `messages.responseUsage` config default, then `off`. |
|
||||
| **Explicit off** | `"off"` (stored) | Always off — a non-off config default cannot re-enable the footer. |
|
||||
| **Explicit on** | `"tokens"` or `"full"` (stored) | That mode, regardless of config default. |
|
||||
|
||||
### Precedence
|
||||
|
||||
Effective mode = session override → channel config entry → `default` → `off`.
|
||||
|
||||
An explicit `/usage off` is **persisted** as the literal value `"off"` in the
|
||||
session, not the same as "unset." This means a non-off `messages.responseUsage`
|
||||
default cannot turn the footer back on once the user has explicitly disabled it.
|
||||
|
||||
### Resetting vs. turning off
|
||||
|
||||
- `/usage off` — forces the footer off and persists that choice. A configured
|
||||
non-off default cannot override this.
|
||||
- `/usage reset` (aliases: `inherit`, `clear`, `default`) — clears the session
|
||||
override. The session then **inherits** the effective config default
|
||||
(`messages.responseUsage`). If no default is configured, the footer is off
|
||||
(unchanged from before). Use this to "go back to default" without explicitly
|
||||
turning the footer on.
|
||||
- A full session reset (`/reset` or `/new`) or a session rollover **preserves**
|
||||
the explicit usage-mode preference so the user's display choice survives
|
||||
session rollovers. Only `/usage reset` (and its aliases) actually clears the
|
||||
override.
|
||||
|
||||
### Toggle behavior
|
||||
|
||||
`/usage` with no arguments cycles: off → tokens → full → off. The starting point
|
||||
for the cycle is the **effective** current mode (session override falling through
|
||||
to the config default when unset), so the cycle is always consistent with what
|
||||
the user sees in the footer.
|
||||
|
||||
### Config
|
||||
|
||||
With no config the prior behavior holds (footer off until `/usage`). Use
|
||||
`/usage reset` to clear a session override and re-inherit the configured default.
|
||||
|
||||
## Custom `/usage full` footer
|
||||
|
||||
`/usage full` shows a built-in compact footer with model, reasoning, fast/slow,
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -204,6 +204,55 @@ Controls elevated exec access outside the sandbox:
|
||||
}
|
||||
```
|
||||
|
||||
Agent entries can inject an environment only into their own `exec` child
|
||||
processes. Use a SecretRef for credentials and set `inheritHostEnv: false` when the
|
||||
Gateway process environment must not be inherited:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "referrals",
|
||||
tools: {
|
||||
exec: {
|
||||
inheritHostEnv: false,
|
||||
env: {
|
||||
GREENHOUSE_TOKEN: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "REFERRALS_GREENHOUSE_TOKEN",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
`agents.list[].tools.exec.env` applies to `exec` only; it does not mutate
|
||||
`process.env` or automatically inject credentials into model-provider or plugin
|
||||
APIs. Trusted in-process plugin code can still inspect the materialized runtime
|
||||
config, so this is not a plugin isolation boundary.
|
||||
Configured values override same-named per-call values from the model. Trusted
|
||||
`resolve_exec_env` hook output and channel context are applied afterward. Host
|
||||
exec still rejects `PATH` and dangerous runtime/startup keys. Sandbox exec
|
||||
already starts from a minimal environment. With `inheritHostEnv: false`,
|
||||
Gateway exec also skips login-shell PATH discovery and cached shell-startup
|
||||
state; configure `pathPrepend` or absolute commands when needed. For
|
||||
`host: "node"`, configure scoped environment and inheritance isolation on the
|
||||
node host. Both this map and `inheritHostEnv: false` are rejected because the
|
||||
Gateway cannot clear the remote service environment or safely hold a scoped
|
||||
credential back during remote approval preparation.
|
||||
|
||||
Treat this map as credential-bearing configuration: every command the agent can
|
||||
run can read and exfiltrate these values, and command output can reveal them.
|
||||
Plaintext values are reported by `openclaw secrets audit`; prefer SecretRefs.
|
||||
Already-running background commands retain the environment captured when they
|
||||
started after a config or secret reload.
|
||||
|
||||
### `tools.loopDetection`
|
||||
|
||||
Tool-loop safety checks are **disabled by default**. Set `enabled: true` to activate detection. Settings can be defined globally in `tools.loopDetection` and overridden per-agent at `agents.list[].tools.loopDetection`.
|
||||
|
||||
@@ -525,6 +525,47 @@ the config fields that accept SecretRefs.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## Per-agent exec environment variables
|
||||
|
||||
`agents.list[].tools.exec.env` supports SecretInput values, so a credential can
|
||||
be resolved during Gateway activation and injected only into that agent's
|
||||
`exec` child processes:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "referrals",
|
||||
tools: {
|
||||
exec: {
|
||||
inheritHostEnv: false,
|
||||
env: {
|
||||
GREENHOUSE_TOKEN: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "REFERRALS_GREENHOUSE_TOKEN",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
This surface is exec-specific. It does not mutate the Gateway process
|
||||
environment or automatically inject credentials into model-provider or plugin
|
||||
APIs. Trusted in-process plugin code can inspect the materialized runtime
|
||||
config. An unresolved active ref fails Gateway activation. SecretRefs are
|
||||
materialized in the Gateway's protected in-memory config snapshot, so this
|
||||
scopes subprocess injection rather than creating a same-process or same-OS-user
|
||||
security boundary. Every command available to the agent can read these values,
|
||||
command output can reveal them, and plaintext entries are reported by
|
||||
`openclaw secrets audit`. Configure scoped environment on a node host itself;
|
||||
agent exec env is rejected for `host: "node"`.
|
||||
|
||||
## MCP server environment variables
|
||||
|
||||
MCP server env vars configured via `plugins.entries.acpx.config.mcpServers` support SecretInput. This keeps API keys and tokens out of plaintext config:
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ Scope intent:
|
||||
- `agents.defaults.memorySearch.remote.apiKey`
|
||||
- `agents.list[].tts.providers.*.apiKey`
|
||||
- `agents.list[].memorySearch.remote.apiKey`
|
||||
- `agents.list[].tools.exec.env.*`
|
||||
- `talk.providers.*.apiKey`
|
||||
- `talk.realtime.providers.*.apiKey`
|
||||
- `messages.tts.providers.*.apiKey`
|
||||
|
||||
@@ -29,6 +29,13 @@
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "agents.list[].tools.exec.env.*",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "agents.list[].tools.exec.env.*",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "agents.list[].tts.providers.*.apiKey",
|
||||
"configFile": "openclaw.json",
|
||||
|
||||
@@ -76,6 +76,8 @@ Use these in chat:
|
||||
configured for the active model.
|
||||
- `/usage off|tokens|full` → appends a **per-response usage footer** to every reply.
|
||||
- Persists per session (stored as `responseUsage`).
|
||||
- `/usage reset` (aliases: `inherit`, `clear`, `default`) — clears the session
|
||||
override so the session re-inherits the configured default.
|
||||
- `/usage full` shows estimated cost only when OpenClaw has usage metadata and
|
||||
local pricing for the active model. Otherwise it shows tokens only.
|
||||
- `/usage cost` → shows a local cost summary from OpenClaw session logs.
|
||||
|
||||
@@ -22,7 +22,8 @@ Working directory for the command.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="env" type="object">
|
||||
Key/value environment overrides merged on top of the inherited environment.
|
||||
Key/value environment overrides. Per-agent configured values are applied after
|
||||
these model-supplied values.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="yieldMs" type="number" default="10000">
|
||||
@@ -89,6 +90,7 @@ Notes:
|
||||
`$OPENCLAW_STATE_DIR/cache/shell-snapshots/`, then sources that snapshot before each exec command.
|
||||
Secret-looking variables are excluded; sandbox and node exec do not use this snapshot. Set
|
||||
`OPENCLAW_EXEC_SHELL_SNAPSHOT=0` in the Gateway process environment to disable this snapshot path.
|
||||
Per-agent `tools.exec.inheritHostEnv: false` also disables it.
|
||||
- Host execution (`gateway`/`node`) rejects `env.PATH` and loader overrides (`LD_*`/`DYLD_*`) to
|
||||
prevent binary hijacking or injected code.
|
||||
- OpenClaw sets `OPENCLAW_SHELL=exec` in the spawned command environment (including PTY and sandbox execution) so shell/profile rules can detect exec-tool context.
|
||||
@@ -113,6 +115,8 @@ Notes:
|
||||
- `tools.exec.notifyOnExit` (default: true): when true, backgrounded exec sessions enqueue a system event and request a heartbeat on exit.
|
||||
- `tools.exec.approvalRunningNoticeMs` (default: 10000): emit a single "running" notice when an approval-gated exec runs longer than this (0 disables).
|
||||
- `tools.exec.timeoutSec` (default: 1800): default per-command exec timeout in seconds. Per-call `timeout` overrides it; per-call `timeout: 0` disables the exec process timeout.
|
||||
- `agents.list[].tools.exec.env`: credential-oriented environment values injected only into that agent's gateway/sandbox exec children. Values support SecretRefs; node-host exec rejects this map.
|
||||
- `agents.list[].tools.exec.inheritHostEnv` (default: true): set false to omit the Gateway process environment and shell-startup snapshot from Gateway-hosted exec. This is rejected for `host=node`; sandbox exec is already minimal.
|
||||
- `tools.exec.host` (default: `auto`; resolves to `sandbox` when sandbox runtime is active, `gateway` otherwise)
|
||||
- `tools.exec.security` (default: `deny` for sandbox, `full` for gateway + node when unset)
|
||||
- `tools.exec.ask` (default: `off`)
|
||||
@@ -141,7 +145,9 @@ Example:
|
||||
|
||||
### PATH handling
|
||||
|
||||
- `host=gateway`: merges your login-shell `PATH` into the exec environment. `env.PATH` overrides are
|
||||
- `host=gateway`: normally merges your login-shell `PATH` into the exec environment. With
|
||||
`agents.list[].tools.exec.inheritHostEnv: false`, this merge is skipped; use an absolute command or
|
||||
`tools.exec.pathPrepend`. `env.PATH` overrides are
|
||||
rejected for host execution. The daemon itself still runs with a minimal `PATH`:
|
||||
- macOS: `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`, `/bin`
|
||||
- Linux: `/usr/local/bin`, `/usr/bin`, `/bin`
|
||||
|
||||
@@ -240,7 +240,7 @@ plugins.
|
||||
| `/tasks` | List active/recent background tasks for the current session |
|
||||
| `/context [list\|detail\|map\|json]` | Explain how context is assembled |
|
||||
| `/whoami` | Show your sender id. Alias: `/id` |
|
||||
| `/usage off\|tokens\|full\|cost` | Control the per-response usage footer or print a local cost summary |
|
||||
| `/usage off\|tokens\|full\|reset\|cost` | Control the per-response usage footer (`reset`/`inherit`/`clear`/`default` clears the session override to re-inherit the configured default) or print a local cost summary |
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Skills, allowlists, approvals">
|
||||
|
||||
@@ -126,7 +126,7 @@ Session controls:
|
||||
- `/verbose <on|full|off>`
|
||||
- `/trace <on|off>`
|
||||
- `/reasoning <on|off|stream>`
|
||||
- `/usage <off|tokens|full>`
|
||||
- `/usage <off|tokens|full|reset>` (`reset`/`inherit`/`clear`/`default` clears the session override)
|
||||
- `/goal [status] | /goal start <objective> | /goal pause|resume|complete|block|clear`
|
||||
- `/elevated <on|off|ask|full>` (alias: `/elev`)
|
||||
- `/activation <mention|always>`
|
||||
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -188,7 +188,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 +1027,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 +1039,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 +1060,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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> {
|
||||
@@ -2338,6 +2340,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 +2549,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);
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
import {
|
||||
buildAgentHookContextChannelFields,
|
||||
detectAndLoadAgentHarnessPromptImages,
|
||||
getModelProviderRequestTransport,
|
||||
resolveAgentHarnessBeforePromptBuildResult,
|
||||
resolveAttemptFsWorkspaceOnly,
|
||||
resolveAttemptSpawnWorkspaceDir,
|
||||
@@ -27,7 +28,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 +52,7 @@ import {
|
||||
rejectAllPolicy,
|
||||
type CopilotPermissionPolicy,
|
||||
} from "./permission-bridge.js";
|
||||
import { resolveCopilotProvider, type ResolvedCopilotProvider } from "./provider-bridge.js";
|
||||
import {
|
||||
classifyResumeFailure,
|
||||
computeReplayMetadata,
|
||||
@@ -79,6 +82,7 @@ export type CopilotSessionConfig = Pick<
|
||||
| "model"
|
||||
| "onPermissionRequest"
|
||||
| "onUserInputRequest"
|
||||
| "provider"
|
||||
| "reasoningEffort"
|
||||
| "systemMessage"
|
||||
| "tools"
|
||||
@@ -115,7 +119,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 +181,7 @@ export interface CopilotAttemptDeps {
|
||||
* attempt.
|
||||
*/
|
||||
onSessionEstablished?: (info: {
|
||||
compactionSessionConfig?: CopilotSessionConfig;
|
||||
sdkSessionId: string;
|
||||
pooledClient: PooledClient;
|
||||
sessionConfig: CopilotSessionConfig;
|
||||
@@ -228,6 +268,7 @@ function deferBackgroundCompactionCleanup(params: {
|
||||
bridge: ReturnType<typeof attachEventBridge>;
|
||||
handle: PooledClient;
|
||||
pool: CopilotClientPool;
|
||||
cleanupByokProxy?: () => Promise<void>;
|
||||
cleanupToolBridge?: () => void;
|
||||
finalizeNativeSubagents?: () => void;
|
||||
sdkSessionId?: string;
|
||||
@@ -260,6 +301,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);
|
||||
@@ -384,15 +426,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 +594,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,6 +623,7 @@ 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",
|
||||
@@ -692,6 +754,7 @@ export async function runCopilotAttempt(
|
||||
modelRef.id,
|
||||
sdkTools,
|
||||
poolAcquire.auth,
|
||||
sessionProvider,
|
||||
promptBuild.developerInstructions || undefined,
|
||||
effectiveWorkspaceDir,
|
||||
effectiveCwd,
|
||||
@@ -703,6 +766,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 +831,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 +896,7 @@ export async function runCopilotAttempt(
|
||||
const messageOptions = await createMessageOptions(attemptInput, {
|
||||
effectiveCwd,
|
||||
effectiveWorkspaceDir,
|
||||
provider: poolAcquire.provider,
|
||||
sandbox,
|
||||
workspaceOnly: effectiveFsWorkspaceOnly,
|
||||
});
|
||||
@@ -890,6 +978,7 @@ export async function runCopilotAttempt(
|
||||
awaitSessionIdle: !bridge.hasObservedSessionIdle(),
|
||||
bridge,
|
||||
cleanupToolBridge,
|
||||
cleanupByokProxy,
|
||||
finalizeNativeSubagents: () => nativeSubagentTaskMirror?.finalizeActiveRuns(),
|
||||
handle,
|
||||
pool: deps.pool,
|
||||
@@ -922,6 +1011,7 @@ export async function runCopilotAttempt(
|
||||
await bridge?.awaitAgentEventChain();
|
||||
nativeSubagentTaskMirror?.finalizeActiveRuns();
|
||||
cleanupToolBridge?.();
|
||||
await cleanupByokProxy?.();
|
||||
bridge?.detach();
|
||||
params.abortSignal?.removeEventListener("abort", onAbort);
|
||||
|
||||
@@ -1191,6 +1281,7 @@ function createSessionConfig(
|
||||
sdkModelId: string,
|
||||
sdkTools: SdkTool[],
|
||||
resolvedAuth: ReturnType<typeof resolveCopilotAuth>,
|
||||
resolvedProvider: ResolvedCopilotProvider,
|
||||
systemMessageContent: string | undefined,
|
||||
effectiveWorkspaceDir: string | undefined,
|
||||
effectiveCwd: string | undefined,
|
||||
@@ -1225,6 +1316,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 +1409,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 +1597,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 +1655,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;
|
||||
}
|
||||
|
||||
@@ -107,6 +107,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()]);
|
||||
|
||||
@@ -69,6 +69,7 @@ export type CopilotToolCompletion = {
|
||||
};
|
||||
|
||||
export interface CopilotToolBridgeInput {
|
||||
allowModelTools?: boolean;
|
||||
modelProvider: string;
|
||||
modelId: string;
|
||||
agentId: string;
|
||||
@@ -151,7 +152,7 @@ export function supportsModelTools(modelProvider: string): boolean {
|
||||
export async function createCopilotToolBridge(
|
||||
input: CopilotToolBridgeInput,
|
||||
): Promise<CopilotToolBridge> {
|
||||
if (!supportsModelTools(input.modelProvider)) {
|
||||
if (!input.allowModelTools && !supportsModelTools(input.modelProvider)) {
|
||||
return { sdkTools: [], sourceTools: [] };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/deepgram-provider",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.10",
|
||||
"private": true,
|
||||
"description": "OpenClaw Deepgram media-understanding provider",
|
||||
"type": "module",
|
||||
|
||||
4
extensions/deepinfra/npm-shrinkwrap.json
generated
4
extensions/deepinfra/npm-shrinkwrap.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/deepinfra-provider",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/deepinfra-provider",
|
||||
"version": "2026.6.9"
|
||||
"version": "2026.6.10"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/deepinfra-provider",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.10",
|
||||
"description": "OpenClaw DeepInfra 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/deepseek/npm-shrinkwrap.json
generated
4
extensions/deepseek/npm-shrinkwrap.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/deepseek-provider",
|
||||
"version": "2026.6.9",
|
||||
"version": "2026.6.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/deepseek-provider",
|
||||
"version": "2026.6.9"
|
||||
"version": "2026.6.10"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user