Compare commits

..

68 Commits

Author SHA1 Message Date
Peter Steinberger
d44260b927 feat: expose requester origin to tool policy hooks 2026-06-24 06:42:56 -07:00
Josh Lehman
c588606a9b refactor: route checkpoint mutations through accessor (#96222) 2026-06-24 06:15:09 -07:00
Vincent Koc
7c56877eb1 test(lmstudio): fix model load response mocks 2026-06-24 21:14:28 +08:00
Alix-007
7844b08445 fix(lmstudio): bound model load success response body to prevent OOM (#96042)
The /api/v1/models/load success path read the response with an unbounded
await response.json(), so a misbehaving or compromised LM Studio server
could stream an arbitrarily large JSON body that is fully buffered into
memory before any size check. Read it through the shared byte-capped
readProviderJsonResponse helper instead (16 MiB provider-JSON cap, cancels
the stream on overflow, wraps malformed JSON), matching the discovery path
and the already-bounded error body.

Migrate the model fetch/load test mocks to real Response objects (the
bounded readers need a real body stream) and add a regression test that
streams an oversized success body and asserts a bounded error plus stream
cancellation.

Label: security
2026-06-24 09:03:02 -04:00
palomyates516-alt
ae9474b5fd fix(video): skip delivering tasks in active-task prompt guard (#96018)
Merged via squash.

Prepared head SHA: cbf32de95e
Co-authored-by: palomyates516-alt <231502129+palomyates516-alt@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-24 20:37:11 +08:00
Vincent Koc
e4763b0631 fix(crabbox): bootstrap WSL2 package proof 2026-06-24 20:18:01 +08:00
Alexzhu
af2b0a6118 Keep agent web_search on runtime provider resolution (#88684)
Merged via squash.

Prepared head SHA: bf13efd818
Co-authored-by: alexzhu0 <178769291+alexzhu0@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-24 20:05:08 +08:00
SunnyShu
2a484a3ff1 [AI] fix(sessions): set liveModelSwitchPending when switching to default with runtime-only fields (#96318)
When a session's model comes from steering/fallback runtime fields
(entry.modelProvider/entry.model) rather than explicit override fields,
switching back to the default model via /model default would not set
liveModelSwitchPending. The isDefault branch in applyModelOverrideToSessionEntry
only sets selectionUpdated when it deletes override fields — but when no
override fields exist, selectionUpdated stays false, preventing the
liveModelSwitchPending flag from being set at the gate condition.

Fix: after the runtime alignment check, set selectionUpdated when
selection.isDefault and runtime fields are misaligned, so that
liveModelSwitchPending is properly set for the pending live switch.

Adds test coverage for this previously untested scenario.

Related to #96269

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-24 19:51:37 +08:00
ly-wang19
1069c60e1e fix(slack): truncate on code-point boundaries to avoid splitting surrogate pairs (#96382)
truncateSlackText sliced by UTF-16 code unit ('trimmed.slice(0, max - 1)'), so an
emoji or other astral character straddling the limit was cut in half, leaving a
lone high surrogate before the ellipsis — e.g. truncateSlackText('abc😀def', 5)
returned 'abc\uD83D…' instead of 'abc…'. That invalid half-character is sent in
live Slack payloads (message text and Block Kit section/button/header/option
labels, which truncate at limits as small as 75).

Use the repo's canonical sliceUtf16Safe (already re-exported from
plugin-sdk/text-utility-runtime, the module slack code imports from) so a
straddling pair is dropped whole. Behavior is byte-identical for all-BMP input.

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:30:29 +08:00
Zaid
9e68fb1178 docs(docker): document Claude CLI persistence (#96380)
Summary:
- The branch adds Docker-specific Claude CLI persistence guidance and cross-links it from the CLI backend and Anthropic provider docs.
- PR surface: Docs +101. Total +101 across 3 files.
- Reproducibility: not applicable. as a bug reproduction. Source inspection confirms the current docs gap and the PR examples match existing Docker, config, and Claude CLI backend contracts.

Automerge notes:
- No ClawSweeper repair was needed after automerge opt-in.

Validation:
- ClawSweeper review passed for head ad95482074.
- Required merge gates passed before the squash merge.

Prepared head SHA: ad95482074
Review: https://github.com/openclaw/openclaw/pull/96380#issuecomment-4788612433

Co-authored-by: zaidazmi <zaidazmi27@gmail.com>
Approved-by: takhoffman
2026-06-24 11:29:52 +00:00
Vincent Koc
ae06d846fa docs(qa): clarify Matrix smoke provider mode 2026-06-24 19:02:57 +08:00
miorbnli
380f2749be fix(tools-manager): require clean exit in commandExists (#96361)
Summary:
- The PR changes the agent tools manager to treat spawned-but-nonzero fd/rg probes as missing and adds regression tests for non-zero and zero spawn status.
- PR surface: Source +3, Tests +27. Total +30 across 2 files.
- Reproducibility: yes. Current main ignores non-zero `spawnSync.status`, and a live Node probe confirms a spawned child can exit non-zero while leaving `error` unset.

Automerge notes:
- No ClawSweeper repair was needed after automerge opt-in.

Validation:
- ClawSweeper review passed for head 377d560eff.
- Required merge gates passed before the squash merge.

Prepared head SHA: 377d560eff
Review: https://github.com/openclaw/openclaw/pull/96361#issuecomment-4788071605

Co-authored-by: liyuanbin <li.yuanbin1@xydigit.com>
Co-authored-by: Claude <noreply@anthropic.com>
Approved-by: takhoffman
2026-06-24 10:59:36 +00:00
Vincent Koc
20293036ca fix(sdk): refresh API baseline hash 2026-06-24 18:58:08 +08:00
Vincent Koc
bfffc77bfc feat(copilot): add BYOK provider parity 2026-06-24 18:29:56 +08:00
Vincent Koc
e9720c27fa fix(qa): accept Codex capped read evidence (#96366) 2026-06-24 18:07:13 +08:00
Vincent Koc
8242923fe3 fix(qa): allow async runtime fixture starts 2026-06-24 17:52:16 +08:00
mushuiyu886
414c250af9 fix #95495: [Bug]: 2026.6.9 silently relocates memory store with no migration, forcing a full re-embed (1499 files) with zero upgrade-time warning (#95631)
* fix(memory): import legacy sidecar indexes into agent db

* fix(memory): move legacy sidecar import to doctor migration

* fix(memory): restore sidecar vector rows during doctor migration

* fix(memory): keep legacy sidecar when skipping import

* fix(memory): keep legacy sidecar import within extension boundary

* fix(memory-core): keep legacy sidecar migration retry-safe

* fix(memory-core): backfill sidecar FTS rows

* fix(memory-core): preserve sidecar when vector import defers

* fix(memory-core): cover custom sidecar migrations

* fix(memory-core): keep legacy config migration under doctor

* fix(memory-core): reject sidecar metadata conflicts

* fix(memory-core): keep partial legacy config sidecars

* fix(memory-core): preserve partial config retries

* fix(memory-core): keep partial config task migrations

* fix(memory-core): avoid phantom sidecar agents

* fix(memory-core): reject incomplete sidecar indexes

* fix(memory-core): keep malformed sidecars retryable

* fix(doctor): use canonical state dir for plugin migrations

* fix(memory-core): honor disabled vector sidecar migration

* fix(memory-core): treat provider-none sidecars as fts-only

* fix(memory-core): preserve setup-failed sidecars

* test(memory-core): use non-mutating sort assertions

* test(memory-core): compare sorted chunk ids

* test(memory-core): compare sorted chunk ids

* test(memory-core): stringify sorted chunk ids

* fix(qa): skip chromium bootstrap for explicit browser channels

* fix(qa): skip chromium bootstrap for explicit browser channels

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-24 17:47:44 +08:00
Vincent Koc
f65aca64fc fix(qa): issue unique mock tool call ids (#96338) 2026-06-24 16:50:15 +08:00
mmyzwl
a2725b6a24 #94162: Performance: bundle-tools loading adds 6-7s latency on every agent request (#94230)
* perf(mcp): parallelize MCP server connections in getCatalog to reduce prep latency

Every agent request incurred 6-7s of prep latency because bundle-tools
connected to configured MCP servers sequentially, one at a time. With
4-5 MCP servers at ~1.5s each (default tools/list timeout), the total
was the sum of all servers' connection times.

Fix: split getCatalog() into two phases:
1. Synchronous pre-computation of safe server names (fast, sequential)
2. Async connection + tool listing (parallelized via Promise.allSettled)

Now MCP servers connect and list tools concurrently, reducing the total
latency from the sum of all servers to roughly the slowest single server.
Each server still has its own error handling — individual failures are
gracefully demoted to diagnostics, not fatal to the catalog.

Prep stage timing change:
  Before: bundle-tools = sum(connection + listTools) for each server
  After:  bundle-tools = max(connection + listTools) across all servers

Closes #94162

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(mcp): add missing braces for eslint curly rule

Two if-statements lacked braces, failing the CI check-lint job.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(mcp): add deterministic regression test for parallel catalog loading

- Add focused timing test that proves parallel MCP catalog loading
  completes in max(server delays) not sum(server delays)
- Test creates 3 slow stdio MCP servers (200/400/600ms delays) and
  asserts wall time < sum(delays) to verify parallelism
- Would fail under the original sequential for-await loop
- Add standalone scripts/repro-94162-timing.mjs for documentation

Part of #94162

* fix(agents): bound MCP catalog fanout

* fix: harden bundle MCP catalog session lifecycle

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: mmyzwl <mmyzwl@users.noreply.github.com>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-24 16:20:23 +08:00
machine3at
63ee4cd240 fix(wiki): wiki_get and wiki compile miss nested source files (#96022)
* fix(wiki): discover nested source files in QUERY_DIRS

Two functions in the memory-wiki extension — listWikiMarkdownFiles
(wiki_get runtime lookup) and collectMarkdownFiles (wiki compile
indexing) — used fs.readdir without { recursive: true }. Nested
source files (e.g. sources/audi/car.md) were silently invisible to
both wiki_get and wiki compile.

Add recursive: true and adjust path construction using
entry.parentPath so nested .md files in all QUERY_DIRS are
discovered while preserving the index.md exclusion and backward
compatibility with flat vaults.

* fix(wiki): remove entry.path fallback, only parentPath is typed on Dirent

* fix(wiki): add recursive scan to status.ts and add nested-file regression tests

* fix(wiki): use toSorted instead of sort to pass lint

* style(memory-wiki): format recursive discovery fix

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-24 16:19:14 +08:00
ly-wang19
599294b9af fix(acp-core): never return undefined from stringifyNonErrorCause (#96270)
`stringifyNonErrorCause` is typed `string`, but its `try` returned
`JSON.stringify(value)`, which is `undefined` for functions, symbols, and
undefined causes — leaking undefined to callers that format nested ACP runtime
failures and expect a string. Fall back to a tag string when stringify yields
undefined, matching the already-correct sibling at `src/infra/errors.ts`.

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:06:45 +08:00
Dallin Romney
bd43c36bb1 test(qa): log effective channel driver in progress (#96327) 2026-06-24 01:04:52 -07:00
ly-wang19
560ecafa2d fix(model-param-b): match both adjacent <num>b tokens sharing one delimiter (#96288)
inferParamBFromIdOrName used a consuming trailing boundary `b(?:[^a-z0-9]|$)`,
so when two `<num>b` parameter tokens are separated by a single delimiter
("8b 70b", "8b-70b"), the first match ate the shared delimiter and the second
token's required leading boundary had nothing to match, silently skipping it —
returning the first (often smaller) size instead of the largest. Make the
trailing boundary a non-consuming lookahead.

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:51:55 +08:00
Dallin Romney
9666db607e test(qa): clean up smoke taxonomy profile (#96320) 2026-06-24 00:43:00 -07:00
ly-wang19
9773cbafdb fix(msteams): use valid PascalCase Adaptive Card enums for the welcome heading (#96290)
* fix(msteams): use valid PascalCase Adaptive Card enums for the welcome heading

The welcome card heading TextBlock used weight "bolder" and size "medium"
(lowercase). Adaptive Card TextWeight/TextSize enums are case-sensitive
PascalCase ("Bolder"/"Medium"); Teams falls back to Default for unrecognized
values, so the "Hi! I'm <bot>." greeting rendered unstyled. Use the correct
casing, matching the sibling polls/presentation cards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(msteams): use valid PascalCase Adaptive Card enums for the welcome heading

---------

Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-24 15:39:38 +08:00
Vincent Koc
74214000bf fix(release): preserve npm pack json output 2026-06-24 09:19:00 +02:00
Parvesh Saini
33afb1ec70 fix(commitments): keep table columns aligned when an id or scope is truncated (#95923) 2026-06-24 15:18:49 +08:00
Vincent Koc
d9034da0a6 fix(openshell): upload staged workspace contents 2026-06-24 15:07:48 +08:00
Dallin Romney
4a503ed45e docs: add maturity docs routes (#91483) 2026-06-23 23:59:47 -07:00
Vincent Koc
a96418c65f fix(qa): isolate OTEL collector telemetry port 2026-06-24 08:11:43 +02:00
Vincent Koc
9d381d4530 docs(testing): document openshell e2e prerequisites 2026-06-24 14:07:30 +08:00
Vincent Koc
52aef22909 ci(openshell): provision gateway for e2e 2026-06-24 14:07:30 +08:00
Vincent Koc
60695c1215 test(openshell): align e2e with current cli 2026-06-24 14:07:30 +08:00
Vincent Koc
d1a7d457e6 fix(openshell): preserve uploaded workspace root 2026-06-24 14:07:30 +08:00
Vincent Koc
12345e4c9b fix(qa): launch control ui flows with runnable chromium 2026-06-24 14:02:11 +08:00
Vincent Koc
f9cf00c351 docs(skills): add OpenClaw CI limits runbook (#96302) 2026-06-24 13:55:21 +08:00
Vincent Koc
fd66b44f5e fix(qa): recover Playwright Chromium on Ubuntu 26 2026-06-24 13:24:43 +08:00
Vincent Koc
2ab3b223ed test(gateway): stabilize suite bind defaults 2026-06-24 12:41:06 +08:00
dongdong
9e3a917d9e fix(auto-reply): align channel intro wording with chat_type (#96244)
* fix(auto-reply): use channel wording for chat_type=channel

* test(auto-reply): update channel wording fixture

* fix(auto-reply): align tool-only channel guidance

* test(auto-reply): refresh prompt snapshot

---------

Co-authored-by: Jasmine Zhang <jasminezhang@JasminedeMac-mini.local>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-24 12:27:04 +08:00
Sally O'Malley
487951f813 fix(compaction): route codex oauth compaction natively (#95831)
Signed-off-by: sallyom <somalley@redhat.com>
2026-06-24 00:16:01 -04:00
xdhuangyandi
89b2db77d4 fix: avoid O(N²) shallow-copy in mapSensitivePaths schema traversal (#55018)
* fix: avoid O(N²) shallow-copy in mapSensitivePaths schema traversal

* fix(config): preserve schema hint map contract

---------

Co-authored-by: 黄炎帝 <huangyandi@xiaohongshu.com>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-24 12:12:48 +08:00
Alexander Zogheb
cf86a9799c fix(agents): run heartbeat_prompt_contribution on harness prompt builds (#96233)
* fix(agents): run heartbeat_prompt_contribution on harness prompt builds

Harness runtimes (e.g. the Codex app-server) assemble the prompt through
resolveAgentHarnessBeforePromptBuildResult rather than the embedded runner's
resolvePromptBuildHookResult. The harness helper ran before_prompt_build and
before_agent_start but never invoked heartbeat_prompt_contribution, so that hook
silently no-ops on those runtimes: plugins that contribute heartbeat context via
the documented hook get nothing on heartbeat turns.

Invoke heartbeat_prompt_contribution from the harness helper too, gated on
ctx.trigger === "heartbeat", merging its prepend/append context ahead of the
before_prompt_build / before_agent_start contributions (matching the embedded
path's ordering). before_prompt_build appendContext is already honored here, so
no change is needed for boot-style append contributions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(agents): preserve heartbeat hook ordering

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-24 12:03:25 +08:00
Vincent Koc
0671c08900 chore(release): close out 2026.6.10 on main (#96271)
* chore(release): close out 2026.6.10 on main

* chore(release): align native app metadata for 2026.6.10

* chore(release): sync Android 2026.6.10 notes

* docs(changelog): preserve 2026.6.9 history

* docs(changelog): preserve 2026.6.9 history
2026-06-24 11:51:14 +08:00
Vincent Koc
89460288c4 ci: move codeql quality off blacksmith (#96258) 2026-06-24 11:48:32 +08:00
Shakker
93bb6e6c14 test: route operator approval env setup 2026-06-24 04:45:51 +01:00
Shakker
10acda0514 fix: route approval e2e env setup 2026-06-24 04:42:48 +01:00
Shakker
bf29f73f19 test: scope chat cli home fixture 2026-06-24 04:39:03 +01:00
Shakker
3875f678a0 fix: restore chat media state env via helper 2026-06-24 04:35:05 +01:00
Shakker
c794608230 test: scope preauth env override 2026-06-24 04:30:41 +01:00
Shakker
89acdd95dc fix: restore supervisor hint env via helper 2026-06-24 04:28:13 +01:00
Yuval Dinodia
82ccee027c fix(exec): preserve turn-source routing target in approval followups for plugin channels (#96140)
* fix(exec): preserve turn-source routing target in approval followups for plugin channels

When an async exec approval is resolved and the originating session is
resumed, buildAgentFollowupArgs forwarded the turn-source to/accountId/threadId
only for built-in deliverable channels or gateway-internal channels. For an
external channel plugin whose channel is not in the in-process deliverable set,
the followup dispatched channel alone and dropped the recipient, so the resumed
agent reply routed to webchat instead of the originating channel.

Forward the turn-source routing fields whenever the resolved delivery target is
not used, matching how the channel itself is already preserved, so the gateway
can route the post-approval reply back to the originating channel.

Fixes #96103

* fix(exec): normalize followup thread routing

* fix(exec): normalize followup thread routing

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-24 11:28:03 +08:00
Shakker
c2d102b6ee test: scope post-attach sentinel env 2026-06-24 04:19:31 +01:00
dongdong
7b9f4aefa2 fix(nextcloud-talk): ignore signed non-message webhook events (#96243)
* fix(nextcloud-talk): ignore non-message webhook events

* fix(nextcloud-talk): acknowledge lifecycle webhook events

---------

Co-authored-by: Jasmine Zhang <jasminezhang@JasminedeMac-mini.local>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-24 11:15:48 +08:00
Wynne668
d15e89a83e fix(workboard): hide archived cards in CLI list by default (#94562)
* fix(workboard): hide archived cards in CLI list by default

The `openclaw workboard list` CLI printed soft-archived cards, while the
`workboard_list` agent tool and the `/workboard list` command both hide
cards with `metadata.archivedAt` set unless archives are requested. Users
who archived cards still saw them in CLI output and assumed archive failed.

Filter archived cards by default in the CLI list handler and add an
`--include-archived` flag mirroring the tool's `includeArchived` option, so
all three list surfaces share one default. Docs updated to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(workboard): preserve json list archive visibility

* fix(workboard): preserve json list archive visibility

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-24 10:57:06 +08:00
sunlit-deng
2fc260aa09 fix(ports): route isPortBusy through checkPortInUse to catch IPv4-only occupants (#94949)
* fix(ports): route isPortBusy through checkPortInUse to catch IPv4-only occupants

* fix(ports): treat PortUsageStatus unknown as busy in isPortBusy

Per ClawSweeper review: checkPortInUse returns 'unknown' when every host
probe fails for a non-EADDRINUSE reason. Treating unknown as 'not busy'
could cause forceFreePortAndWait to exit before lsof/fuser inspects the
port. Conservative fix: only 'free' means not busy; everything else
(busy or unknown) triggers further inspection.

* fix(ports): reuse canonical multi-address probe

* fix(ports): reuse canonical multi-address probe

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-24 10:45:06 +08:00
joshavant
8739f1e17e fix(ios): wait for screenshot checksum propagation 2026-06-23 21:32:26 -05:00
Vincent Koc
d42b864219 fix(qa): accept pnpm separator for lab up (#96246) 2026-06-24 10:22:56 +08:00
mikasa
ce0142f04e fix #92582: Bug: doctor falsely warns local memory embeddings are not ready (#95393)
* fix(doctor): ignore skipped local embedding probe

* fix(doctor): keep skipped local model diagnostics

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-24 10:04:12 +08:00
Vincent Koc
d4c151844a fix(ci): resolve performance target refs before checkout 2026-06-24 09:51:08 +08:00
pick-cat
20a87e17f5 fix(gateway): resolve plugin-registered gateway methods through live registry (#94154)
Merged via squash.

Prepared head SHA: c65cac4e46
Co-authored-by: Pick-cat <266665499+Pick-cat@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-24 09:49:25 +08:00
joshavant
3dea94f4cb fix(ios): make screenshot upload deterministic 2026-06-23 20:48:45 -05:00
Vincent Koc
da15cf48bf fix(maint): keep PR landing on squash 2026-06-24 09:28:03 +08:00
Vincent Koc
54c0048d6c perf(reply): hoist direct-send fragment index 2026-06-24 09:24:02 +08:00
Vincent Koc
2ad2e4f2dc perf(codex): index rollout transcript ids 2026-06-24 09:23:42 +08:00
Vincent Koc
28a90b0e82 perf(browser): index role snapshot references 2026-06-24 09:23:19 +08:00
clawsweeper[bot]
63874fa0d1 fix: UI glitch: config is not visible (#96145)
Summary:
- The branch tracks effective Settings Config Form/Raw mode, resets `.config-content` scroll when that mode changes, and adds a browser regression test for the retained-scroll transition.
- PR surface: Source +9, Tests +30. Total +39 across 2 files.
- Reproducibility: yes. at source level: current main resets `.config-content` for section navigation but not  ... ro in this read-only pass, but the source PR includes after-fix browser proof for the same branch behavior.

Automerge notes:
- No ClawSweeper repair was needed after automerge opt-in.

Validation:
- ClawSweeper review passed for head a6ea91e6ed.
- Required merge gates passed before the squash merge.

Prepared head SHA: a6ea91e6ed
Review: https://github.com/openclaw/openclaw/pull/96145#issuecomment-4784983447

Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: sunlit-deng <253064511+sunlit-deng@users.noreply.github.com>
Approved-by: takhoffman
2026-06-24 01:18:03 +00:00
Vincent Koc
4d034639ad fix(crabbox): require Xcode for macOS proof 2026-06-24 09:01:42 +08:00
Sarah Fortune
d9298a74be fix(codex): prefer gateway-managed generated images 2026-06-23 17:47:04 -07:00
435 changed files with 12625 additions and 2014 deletions

View 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.

View 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
View File

@@ -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"

View File

@@ -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

View File

@@ -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'

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

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

View File

@@ -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.

View File

@@ -1,4 +1,4 @@
{
"version": "2026.6.9",
"versionCode": 2026060901
"version": "2026.6.10",
"versionCode": 2026061001
}

View File

@@ -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.

View File

@@ -2,8 +2,8 @@
// Source of truth: apps/ios/version.json
// Generated by scripts/ios-sync-versioning.ts.
OPENCLAW_IOS_VERSION = 2026.6.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"

View File

@@ -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"

View File

@@ -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.

View File

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

View File

@@ -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>

View File

@@ -1,4 +1,4 @@
ee542300a1f9d5c23e772d47f2acfcc92ee0a4da210974306790bf2220b80277 config-baseline.json
9246475f5771612a5fd12de38b153783c4a4cbb8b2682a5c40115916661c90f2 config-baseline.json
6349131baaa1828f2a071f42e4d7b17c8966c59b6588c8a4c1a32ea5ea4dcd5e config-baseline.core.json
de674ef01dad2828bb711a4648dc5a00f696f71c3c59004131d9475769bc1ff8 config-baseline.channel.json
ce2a731077f0f0135b7eaf01b00a60abfa0d2776aba4be237491d492af0c8a02 config-baseline.plugin.json
671979e86e4c4f59415d0a20879e838f9bbd883b3d29eeb02cb5131db8d187fe config-baseline.channel.json
94529978588d6e3776a86780b22cf9ff46a6f9957f2f178d3829403fad451ca7 config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
f7247b5bbfe3f96bffffd25a8be2f89b37999e36731f34a159ae21ded1cedd05 plugin-sdk-api-baseline.json
ce88a53dadc194ceccc63f50146aee03a1a425f551117da826a21519d5bf80db plugin-sdk-api-baseline.jsonl
212b76ef72779add8f18be4848e143e61b6ae42a1c7daeefdc42d91e0a1152e9 plugin-sdk-api-baseline.json
976179e09e9e46a9b9259bd20ca1cafc8883c8e281a099a9aaa5fceab3c2983b plugin-sdk-api-baseline.jsonl

View File

@@ -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`).

View File

@@ -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:

View File

@@ -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`

View File

@@ -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)

View File

@@ -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`.

View File

@@ -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)

View File

@@ -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

View File

@@ -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`.

View File

@@ -186,8 +186,12 @@ file.
- optional `event.runId`
- optional `event.toolCallId`
- context fields such as `ctx.agentId`, `ctx.sessionKey`, `ctx.sessionId`,
`ctx.runId`, `ctx.jobId` (set on cron-driven runs), `ctx.toolKind`,
`ctx.toolInputKind`, and diagnostic `ctx.trace`
`ctx.runId`, `ctx.jobId` (set on cron-driven runs), `ctx.trigger`,
`ctx.toolKind`, `ctx.toolInputKind`, and diagnostic `ctx.trace`
- for channel-originated calls, origin fields such as `ctx.channel`,
`ctx.messageProvider`, `ctx.channelId`, `ctx.chatId`, `ctx.senderId`, and
extensible `ctx.channelContext` sender/chat metadata. These use the same
identity semantics described below for agent hook contexts.
It can return:

View File

@@ -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>

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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"

View File

@@ -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": {

View File

@@ -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",

View File

@@ -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": {

View File

@@ -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"
}

View File

@@ -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": {

View File

@@ -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",

View File

@@ -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"
}
}
}

View File

@@ -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": {

View File

@@ -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",

View File

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

View File

@@ -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"
}
}
}

View File

@@ -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,

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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(

View File

@@ -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 } : {}),

View File

@@ -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",

View File

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

View File

@@ -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"
}
}
}

View File

@@ -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": {

View File

@@ -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"
}
}
}

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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"
}
}
}

View File

@@ -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": {

View File

@@ -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",

View File

@@ -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",

View File

@@ -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,

View File

@@ -12,7 +12,11 @@ import {
type EmbeddedRunAttemptParams,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { buildApprovalResponse, handleCodexAppServerApprovalRequest } from "./approval-bridge.js";
import {
buildApprovalResponse,
handleCodexAppServerApprovalRequest as handleCodexAppServerApprovalRequestImpl,
} from "./approval-bridge.js";
import { buildCodexToolHookRunContext } from "./tool-hook-context.js";
vi.mock("openclaw/plugin-sdk/agent-harness-runtime", async (importOriginal) => ({
...(await importOriginal<typeof import("openclaw/plugin-sdk/agent-harness-runtime")>()),
@@ -42,6 +46,20 @@ const mockResolveNativeHookRelayDeferredToolApproval = vi.mocked(
const mockReviewExecRequestWithConfiguredModel = vi.mocked(reviewExecRequestWithConfiguredModel);
const mockRunBeforeToolCallHook = vi.mocked(runBeforeToolCallHook);
type ApprovalRequestParams = Parameters<typeof handleCodexAppServerApprovalRequestImpl>[0];
function handleCodexAppServerApprovalRequest(
params: Omit<ApprovalRequestParams, "toolHookContext"> & {
toolHookContext?: ApprovalRequestParams["toolHookContext"];
},
) {
return handleCodexAppServerApprovalRequestImpl({
...params,
toolHookContext:
params.toolHookContext ?? buildCodexToolHookRunContext({ attempt: params.paramsForRun }),
});
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`Expected ${label}`);
@@ -243,6 +261,8 @@ describe("Codex app-server approval bridge", () => {
ctx: {
agentId: "main",
sessionKey: "agent:main:session-1",
messageProvider: "telegram",
channel: "telegram",
channelId: "chat-1",
},
});
@@ -1164,11 +1184,18 @@ describe("Codex app-server approval bridge", () => {
});
});
it("normalizes prefixed channel targets for OpenClaw tool policy context", async () => {
it("uses the caller-resolved hook context for approval fallback policy", async () => {
const params = createParams();
params.messageChannel = "telegram";
params.messageProvider = "telegram";
params.currentChannelId = "telegram:-100123";
params.agentId = "raw-agent";
params.sessionId = "raw-session";
params.sessionKey = "agent:raw:session";
params.runId = "raw-run";
params.messageChannel = "discord";
params.messageProvider = "discord";
params.currentChannelId = "discord:raw-target";
params.jobId = "raw-job";
params.senderId = "raw-user";
params.chatId = "raw-chat";
mockCallGatewayTool
.mockResolvedValueOnce({ id: "plugin:approval-prefixed", status: "accepted" })
.mockResolvedValueOnce({ id: "plugin:approval-prefixed", decision: "allow-once" });
@@ -1182,6 +1209,27 @@ describe("Codex app-server approval bridge", () => {
command: "pnpm test extensions/codex/src/app-server",
},
paramsForRun: params,
toolHookContext: {
agentId: "resolved-agent",
sessionId: "resolved-session",
sessionKey: "agent:resolved:session",
runId: "resolved-run",
jobId: "resolved-job",
trigger: "user",
messageProvider: "telegram-voice",
channel: "telegram",
channelId: "-100123",
chatId: "native-chat-1",
senderId: "user-1",
channelContext: {
sender: {
id: "user-1",
displayName: "Ada",
providerUserId: "provider-user-1",
},
chat: { id: "native-chat-1", providerThreadKey: "thread-key-1" },
},
},
threadId: "thread-1",
turnId: "turn-1",
});
@@ -1189,11 +1237,29 @@ describe("Codex app-server approval bridge", () => {
expect(mockRunBeforeToolCallHook).toHaveBeenCalledWith(
expect.objectContaining({
ctx: expect.objectContaining({
agentId: "resolved-agent",
sessionId: "resolved-session",
sessionKey: "agent:resolved:session",
runId: "resolved-run",
jobId: "resolved-job",
trigger: "user",
messageProvider: "telegram-voice",
channel: "telegram",
channelId: "-100123",
chatId: "native-chat-1",
senderId: "user-1",
channelContext: {
sender: {
id: "user-1",
displayName: "Ada",
providerUserId: "provider-user-1",
},
chat: { id: "native-chat-1", providerThreadKey: "thread-key-1" },
},
}),
}),
);
expect(gatewayRequestPayload().turnSourceTo).toBe("telegram:-100123");
expect(gatewayRequestPayload().turnSourceTo).toBe("discord:raw-target");
});
it("denies command approvals before prompting when OpenClaw tool policy blocks", async () => {

View File

@@ -8,7 +8,6 @@ import {
*/
import {
type AgentApprovalEventData,
buildAgentHookContextChannelFields,
formatApprovalDisplayPath,
hasNativeHookRelayInvocation,
invokeNativeHookRelay,
@@ -17,6 +16,7 @@ import {
type NativeHookRelayProcessResponse,
type NativeHookRelayRegistrationHandle,
runBeforeToolCallHook,
type ToolHookRunContext,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import { normalizeTrimmedStringList } from "openclaw/plugin-sdk/string-coerce-runtime";
@@ -75,6 +75,7 @@ export async function handleCodexAppServerApprovalRequest(params: {
method: string;
requestParams: JsonValue | undefined;
paramsForRun: EmbeddedRunAttemptParams;
toolHookContext: ToolHookRunContext;
threadId: string;
turnId: string;
nativeHookRelay?: Pick<
@@ -106,6 +107,7 @@ export async function handleCodexAppServerApprovalRequest(params: {
method: params.method,
requestParams,
paramsForRun: params.paramsForRun,
toolHookContext: params.toolHookContext,
context,
nativeHookRelay: params.nativeHookRelay,
signal: params.signal,
@@ -619,6 +621,7 @@ async function runOpenClawToolPolicyForApprovalRequest(params: {
method: string;
requestParams: JsonObject | undefined;
paramsForRun: EmbeddedRunAttemptParams;
toolHookContext: ToolHookRunContext;
context: ApprovalContext;
nativeHookRelay?: Pick<
NativeHookRelayRegistrationHandle,
@@ -652,13 +655,6 @@ async function runOpenClawToolPolicyForApprovalRequest(params: {
if (nativeRelayOutcome?.handled) {
return { outcome: "no-decision" };
}
const hookChannelId = buildAgentHookContextChannelFields({
sessionKey: params.paramsForRun.sessionKey,
messageChannel: params.paramsForRun.messageChannel,
messageProvider: params.paramsForRun.messageProvider,
currentChannelId: params.paramsForRun.currentChannelId,
messageTo: params.paramsForRun.messageTo,
}).channelId;
const outcome = await runBeforeToolCallHook({
toolName: policyRequest.toolName,
params: policyRequest.params,
@@ -666,13 +662,9 @@ async function runOpenClawToolPolicyForApprovalRequest(params: {
approvalMode: "request",
signal: params.signal,
ctx: {
...(params.paramsForRun.agentId ? { agentId: params.paramsForRun.agentId } : {}),
...params.toolHookContext,
...(params.paramsForRun.config ? { config: params.paramsForRun.config } : {}),
...(cwd ? { cwd } : {}),
...(params.paramsForRun.sessionKey ? { sessionKey: params.paramsForRun.sessionKey } : {}),
...(params.paramsForRun.sessionId ? { sessionId: params.paramsForRun.sessionId } : {}),
...(params.paramsForRun.runId ? { runId: params.paramsForRun.runId } : {}),
...(hookChannelId ? { channelId: hookChannelId } : {}),
},
});
if (outcome.blocked) {

View File

@@ -944,8 +944,16 @@ describe("Codex app-server dynamic tool build", () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
params.currentChannelId = "D123";
params.messageChannel = "discord";
params.messageProvider = "discord-voice";
params.currentChannelId = "discord:D123";
params.currentMessagingTarget = "user:U123";
params.chatId = "chat-123";
params.senderId = "user-123";
params.channelContext = {
sender: { id: "user-123" },
chat: { id: "chat-123" },
};
params.runtimePlan = createCodexRuntimePlanFixture();
const factoryOptions: unknown[] = [];
setOpenClawCodingToolsFactoryForTests((options) => {
@@ -956,9 +964,19 @@ describe("Codex app-server dynamic tool build", () => {
await buildDynamicToolsForTest(params, workspaceDir, { sandbox: null as never });
expect(factoryOptions[0]).toMatchObject({
currentChannelId: "D123",
messageChannel: "discord",
messageProvider: "discord",
toolPolicyMessageProvider: "discord-voice",
currentChannelId: "discord:D123",
currentMessagingTarget: "user:U123",
chatId: "chat-123",
senderId: "user-123",
hookChannelContext: {
sender: { id: "user-123" },
chat: { id: "chat-123" },
},
});
expect((factoryOptions[0] as { channelContext?: unknown }).channelContext).toBeUndefined();
});
it("passes the approval reviewer device into Codex dynamic tools", async () => {

View File

@@ -125,7 +125,7 @@ export function resolveCodexAppServerHookChannelId(
messageChannel: params.messageChannel,
messageProvider: params.messageProvider,
currentChannelId: params.currentChannelId,
messageTo: params.messageTo,
messageTo: params.currentMessagingTarget ?? params.messageTo,
}).channelId;
}
@@ -239,6 +239,7 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
elevated: params.bashElevated,
},
sandbox: input.sandbox,
messageChannel: params.messageChannel,
messageProvider: resolveCodexMessageToolProvider(params),
toolPolicyMessageProvider: params.messageProvider ?? params.messageChannel,
agentAccountId: params.agentAccountId,
@@ -249,6 +250,7 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
groupSpace: params.groupSpace,
spawnedBy: params.spawnedBy,
senderId: params.senderId,
hookChannelContext: params.channelContext,
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
@@ -290,6 +292,7 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
),
suppressManagedWebSearch: false,
currentChannelId: params.currentChannelId,
chatId: params.chatId,
currentMessagingTarget: params.currentMessagingTarget,
hookChannelId: resolveCodexAppServerHookChannelId(params, input.sandboxSessionKey),
currentThreadTs: params.currentThreadTs,

View File

@@ -1846,6 +1846,17 @@ describe("createCodexDynamicToolBridge", () => {
sessionId: "session-1",
sessionKey: "agent:agent-1:session-1",
runId: "run-1",
jobId: "job-1",
trigger: "user",
messageProvider: "discord-voice",
channel: "discord",
chatId: "channel-1",
senderId: "user-1",
channelId: "channel-1",
channelContext: {
sender: { id: "user-1", displayName: "Ada" },
chat: { id: "channel-1" },
},
},
});
@@ -1949,6 +1960,17 @@ describe("createCodexDynamicToolBridge", () => {
sessionId: "session-1",
sessionKey: "agent:agent-1:session-1",
runId: "run-1",
jobId: "job-1",
trigger: "user",
messageProvider: "discord-voice",
channel: "discord",
chatId: "channel-1",
senderId: "user-1",
channelId: "channel-1",
channelContext: {
sender: { id: "user-1", displayName: "Ada" },
chat: { id: "channel-1" },
},
},
});
@@ -1975,6 +1997,17 @@ describe("createCodexDynamicToolBridge", () => {
sessionId: "session-1",
sessionKey: "agent:agent-1:session-1",
runId: "run-1",
jobId: "job-1",
trigger: "user",
messageProvider: "discord-voice",
channel: "discord",
chatId: "channel-1",
senderId: "user-1",
channelId: "channel-1",
channelContext: {
sender: { id: "user-1", displayName: "Ada" },
chat: { id: "channel-1" },
},
toolCallId: "call-1",
});
expectExecuteCall(execute, { callId: "call-1", args: { command: "pwd", mode: "safe" } });
@@ -1997,6 +2030,17 @@ describe("createCodexDynamicToolBridge", () => {
sessionId: "session-1",
sessionKey: "agent:agent-1:session-1",
runId: "run-1",
jobId: "job-1",
trigger: "user",
messageProvider: "discord-voice",
channel: "discord",
chatId: "channel-1",
senderId: "user-1",
channelId: "channel-1",
channelContext: {
sender: { id: "user-1", displayName: "Ada" },
chat: { id: "channel-1" },
},
toolCallId: "call-1",
});
});

View File

@@ -32,6 +32,7 @@ import {
type HeartbeatToolResponse,
type MessagingToolSend,
type MessagingToolSourceReplyPayload,
type ToolHookRunContext,
wrapToolWithBeforeToolCallHook,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { emitTrustedDiagnosticEvent } from "openclaw/plugin-sdk/diagnostic-runtime";
@@ -53,13 +54,8 @@ import type {
JsonValue,
} from "./protocol.js";
type CodexDynamicToolHookContext = {
agentId?: string;
type CodexDynamicToolHookContext = ToolHookRunContext & {
config?: EmbeddedRunAttemptParams["config"];
sessionId?: string;
sessionKey?: string;
runId?: string;
channelId?: string;
currentChannelProvider?: string;
currentChannelId?: string;
currentMessagingTarget?: string;
@@ -70,7 +66,7 @@ type CodexDynamicToolHookContext = {
allocateToolOutcomeOrdinal?: EmbeddedRunAttemptParams["allocateToolOutcomeOrdinal"];
};
type CodexToolResultHookContext = Omit<CodexDynamicToolHookContext, "config">;
type CodexToolResultHookContext = ToolHookRunContext;
type ProjectedCodexDynamicTool = {
tool: AnyAgentTool;
@@ -310,11 +306,7 @@ export function createCodexDynamicToolBridge(params: {
void runAgentHarnessAfterToolCallHook({
toolName,
toolCallId: call.callId,
runId: toolResultHookContext.runId,
agentId: toolResultHookContext.agentId,
sessionId: toolResultHookContext.sessionId,
sessionKey: toolResultHookContext.sessionKey,
channelId: toolResultHookContext.channelId,
...toolResultHookContext,
startArgs: executedArgs,
result,
startedAt,
@@ -407,11 +399,7 @@ export function createCodexDynamicToolBridge(params: {
void runAgentHarnessAfterToolCallHook({
toolName,
toolCallId: call.callId,
runId: toolResultHookContext.runId,
agentId: toolResultHookContext.agentId,
sessionId: toolResultHookContext.sessionId,
sessionKey: toolResultHookContext.sessionKey,
channelId: toolResultHookContext.channelId,
...toolResultHookContext,
startArgs: executedArgs,
error: errorMessage,
startedAt,
@@ -702,13 +690,35 @@ function dedupeQuarantinedDynamicTools(
function toToolResultHookContext(
ctx: CodexDynamicToolHookContext | undefined,
): CodexToolResultHookContext {
const { agentId, sessionId, sessionKey, runId, channelId } = ctx ?? {};
const {
agentId,
sessionId,
sessionKey,
runId,
jobId,
trace,
trigger,
messageProvider,
channel,
chatId,
senderId,
channelId,
channelContext,
} = ctx ?? {};
return {
...(agentId && { agentId }),
...(sessionId && { sessionId }),
...(sessionKey && { sessionKey }),
...(runId && { runId }),
...(jobId && { jobId }),
...(trace && { trace }),
...(trigger && { trigger }),
...(messageProvider && { messageProvider }),
...(channel && { channel }),
...(chatId && { chatId }),
...(senderId && { senderId }),
...(channelId && { channelId }),
...(channelContext && { channelContext }),
};
}

View File

@@ -18,6 +18,7 @@ import {
resetGlobalHookRunner,
} from "openclaw/plugin-sdk/hook-runtime";
import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime";
import { withTempDir } from "openclaw/plugin-sdk/test-env";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
CodexAppServerEventProjector,
@@ -743,6 +744,47 @@ describe("CodexAppServerEventProjector", () => {
expect(result.toolMediaUrls?.[0]).not.toBe(savedPath);
});
it("prefers gateway-managed image media when the typed event arrives first", async () => {
await withTempDir("openclaw-codex-media-state-", async (stateDir) => {
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
const projector = await createProjector();
const savedPath = "/home/dev-user/.codex/generated_images/session-1/ig_123.png";
await projector.handleNotification(
forCurrentTurn("item/completed", {
item: {
type: "imageGeneration",
id: "ig_123",
status: "completed",
revisedPrompt: "A tiny blue square",
result: tinyPngBase64,
savedPath,
},
}),
);
await projector.handleNotification(
forCurrentTurn("rawResponseItem/completed", {
item: {
type: "image_generation_call",
id: "ig_123",
status: "generating",
result: tinyPngBase64,
},
}),
);
const result = projector.buildResult(buildEmptyToolTelemetry());
const mediaUrl = result.toolMediaUrls?.[0];
expect(result.toolMediaUrls).toHaveLength(1);
expect(mediaUrl).not.toBe(savedPath);
expect(mediaUrl).toContain(`${path.sep}media${path.sep}tool-image-generation${path.sep}`);
await expect(fs.readFile(mediaUrl ?? "")).resolves.toEqual(
Buffer.from(tinyPngBase64, "base64"),
);
});
});
it("preserves distinct raw image-generation items with identical image bytes", async () => {
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-media-state-"));
tempDirs.add(stateDir);
@@ -2545,15 +2587,36 @@ describe("CodexAppServerEventProjector", () => {
});
});
it("emits after_tool_call observations for Codex-native tool item completions", async () => {
it("keeps resolved hook identity authoritative for Codex-native tool completions", async () => {
const afterToolCall = vi.fn();
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "after_tool_call", handler: afterToolCall }]),
);
const projector = await createProjector({
const projectorParams = {
...(await createParams()),
agentId: "main",
sessionKey: "agent:main:session-1",
agentId: "raw-agent",
sessionId: "raw-session",
sessionKey: "agent:raw:session-1",
runId: "raw-run",
};
const projector = await createProjector(projectorParams, {
toolHookContext: {
agentId: "main",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
runId: "run-1",
jobId: "job-1",
trigger: "user",
messageProvider: "discord-voice",
channel: "discord",
chatId: "channel-1",
senderId: "user-1",
channelId: "channel-1",
channelContext: {
sender: { id: "user-1" },
chat: { id: "channel-1" },
},
},
});
await projector.handleNotification(
@@ -2610,6 +2673,17 @@ describe("CodexAppServerEventProjector", () => {
expect(context.sessionId).toBe("session-1");
expect(context.sessionKey).toBe("agent:main:session-1");
expect(context.runId).toBe("run-1");
expect(context.jobId).toBe("job-1");
expect(context.trigger).toBe("user");
expect(context.messageProvider).toBe("discord-voice");
expect(context.channel).toBe("discord");
expect(context.chatId).toBe("channel-1");
expect(context.senderId).toBe("user-1");
expect(context.channelId).toBe("channel-1");
expect(context.channelContext).toEqual({
sender: { id: "user-1" },
chat: { id: "channel-1" },
});
expect(context.toolName).toBe("bash");
expect(context.toolCallId).toBe("cmd-observed");
});

View File

@@ -18,6 +18,7 @@ import {
type HeartbeatToolResponse,
type MessagingToolSend,
type MessagingToolSourceReplyPayload,
type ToolHookRunContext,
type ToolProgressDetailMode,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { emitTrustedDiagnosticEvent } from "openclaw/plugin-sdk/diagnostic-runtime";
@@ -65,6 +66,7 @@ export type CodexAppServerToolTelemetry = {
export type CodexAppServerEventProjectorOptions = {
nativePostToolUseRelayEnabled?: boolean;
toolHookContext?: ToolHookRunContext;
onNativeToolResultRecorded?: () => void | Promise<void>;
trajectoryRecorder?: CodexTrajectoryRecorder | null;
};
@@ -188,7 +190,6 @@ export class CodexAppServerEventProjector {
private readonly toolTrajectoryItemsById = new Map<string, CodexThreadItem>();
private readonly transcriptToolProgressCallIds = new Set<string>();
private lastNativeToolError: EmbeddedRunAttemptResult["lastToolError"];
private readonly nativeGeneratedMediaUrls = new Set<string>();
private readonly nativeGeneratedMediaItemIds = new Set<string>();
private readonly nativeGeneratedMediaUrlsByItemId = new Map<string, string>();
private readonly diagnosticToolStartedAtByItem = new Map<string, number>();
@@ -1028,6 +1029,9 @@ export class CodexAppServerEventProjector {
this.recordNativeGeneratedMediaUrl({
itemId,
mediaUrl: saved.path,
// The typed savedPath may belong to a remote app-server host. Always
// prefer the copy persisted into this gateway's managed media root.
replaceExisting: true,
});
} catch (error) {
embeddedAgentLog.warn("codex app-server raw image generation result save failed", {
@@ -1037,13 +1041,19 @@ export class CodexAppServerEventProjector {
}
}
private recordNativeGeneratedMediaUrl(params: { itemId: string; mediaUrl: string }): void {
if (this.nativeGeneratedMediaUrlsByItemId.has(params.itemId)) {
private recordNativeGeneratedMediaUrl(params: {
itemId: string;
mediaUrl: string;
replaceExisting?: boolean;
}): void {
if (
this.nativeGeneratedMediaUrlsByItemId.has(params.itemId) &&
params.replaceExisting !== true
) {
this.nativeGeneratedMediaItemIds.add(params.itemId);
return;
}
this.nativeGeneratedMediaUrlsByItemId.set(params.itemId, params.mediaUrl);
this.nativeGeneratedMediaUrls.add(params.mediaUrl);
this.nativeGeneratedMediaItemIds.add(params.itemId);
}
@@ -1052,7 +1062,7 @@ export class CodexAppServerEventProjector {
toolTelemetry.toolMediaUrls?.map((url) => url.trim()).filter(Boolean) ?? [],
);
if ((toolTelemetry.messagingToolSentMediaUrls?.length ?? 0) === 0) {
for (const mediaUrl of this.nativeGeneratedMediaUrls) {
for (const mediaUrl of this.nativeGeneratedMediaUrlsByItemId.values()) {
mediaUrls.add(mediaUrl);
}
}
@@ -1366,6 +1376,9 @@ export class CodexAppServerEventProjector {
agentId: this.params.agentId,
sessionId: this.params.sessionId,
sessionKey: this.params.sessionKey,
// The attempt boundary resolves aliases and sandbox session identity once.
// Keep that canonical snapshot authoritative over optional raw projector params.
...this.options.toolHookContext,
startArgs: itemToolArgs(item) ?? {},
...(result !== undefined ? { result } : {}),
...(error ? { error } : {}),

View File

@@ -8,6 +8,7 @@ import {
type EmbeddedRunAttemptParams,
type NativeHookRelayEvent,
type NativeHookRelayRegistrationHandle,
type ToolHookRunContext,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
addTimerTimeoutGraceMs,
@@ -121,6 +122,7 @@ export function createCodexNativeHookRelay(params: {
config: EmbeddedRunAttemptParams["config"];
runId: string;
channelId?: string;
toolHookContext?: ToolHookRunContext;
attemptTimeoutMs: number;
startupTimeoutMs: number;
turnStartTimeoutMs: number;
@@ -146,6 +148,7 @@ export function createCodexNativeHookRelay(params: {
...(params.config ? { config: params.config } : {}),
runId: params.runId,
...(params.channelId ? { channelId: params.channelId } : {}),
...(params.toolHookContext ? { toolHookContext: params.toolHookContext } : {}),
allowedEvents: params.events,
ttlMs: resolveCodexNativeHookRelayTtlMs({
explicitTtlMs: params.options?.ttlMs,

View File

@@ -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;
}

View File

@@ -1,9 +1,6 @@
// Codex tests cover run attemptynamic tools plugin behavior.
import path from "node:path";
import {
onAgentEvent,
type AgentEventPayload,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { onAgentEvent, type AgentEventPayload } from "openclaw/plugin-sdk/agent-harness-runtime";
import {
emitTrustedDiagnosticEvent,
onInternalDiagnosticEvent,
@@ -609,6 +606,21 @@ describe("runCodexAppServerAttempt dynamic tools", () => {
}
});
it("prefers the current messaging target for hook channel fallback", () => {
const params = createParams(
path.join(tempDir, "session.jsonl"),
path.join(tempDir, "workspace"),
);
params.messageChannel = "telegram";
params.messageProvider = "telegram";
params.messageTo = "telegram:stale-target";
params.currentMessagingTarget = "telegram:current-target";
expect(testing.resolveCodexAppServerHookChannelId(params, "agent:main:session-1")).toBe(
"current-target",
);
});
it("passes normalized channel context to app-server dynamic tool result hooks", async () => {
const afterToolCall = vi.fn();
initializeGlobalHookRunner(

View File

@@ -30,9 +30,7 @@ const DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT = JSON.stringify({
web_search: "disabled",
});
function writeCodexAppServerBinding(
...args: Parameters<typeof writeRawCodexAppServerBinding>
) {
function writeCodexAppServerBinding(...args: Parameters<typeof writeRawCodexAppServerBinding>) {
const [sessionFile, binding, lookup] = args;
return writeRawCodexAppServerBinding(
sessionFile,
@@ -95,7 +93,15 @@ describe("runCodexAppServerAttempt native hook relay", () => {
const harness = createStartedThreadHarness();
const params = createParams(sessionFile, workspaceDir);
params.messageChannel = "discord";
params.messageProvider = "discord-voice";
params.currentChannelId = "channel:target";
params.trigger = "user";
params.senderId = "user-1";
params.chatId = "native-target";
params.channelContext = {
sender: { id: "user-1", providerUserId: "discord-user-1" },
chat: { id: "native-target", guildId: "guild-1" },
};
const run = runCodexAppServerAttempt(params, {
nativeHookRelay: {
@@ -135,6 +141,22 @@ describe("runCodexAppServerAttempt native hook relay", () => {
threadId: "thread-1",
turnId: "turn-1",
autoApprove: true,
toolHookContext: {
agentId: "main",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
runId: "run-1",
trigger: "user",
messageProvider: "discord-voice",
channel: "discord",
channelId: "target",
chatId: "native-target",
senderId: "user-1",
channelContext: {
sender: { id: "user-1", providerUserId: "discord-user-1" },
chat: { id: "native-target", guildId: "guild-1" },
},
},
});
expect(approvalArgs?.nativeHookRelay).toMatchObject({
relayId,

View File

@@ -38,6 +38,7 @@ import {
type EmbeddedRunAttemptResult,
type NativeHookRelayEvent,
type NativeHookRelayRegistrationHandle,
type ToolHookRunContext,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime";
import {
@@ -248,6 +249,7 @@ import {
type CodexAppServerThreadLifecycleBinding,
type CodexContextEngineThreadBootstrapProjection,
} from "./thread-lifecycle.js";
import { buildCodexToolHookRunContext } from "./tool-hook-context.js";
import {
inferCodexDynamicToolMeta,
resolveCodexToolProgressDetailMode,
@@ -717,6 +719,14 @@ export async function runCodexAppServerAttempt(
});
}
const hookChannelId = resolveCodexAppServerHookChannelId(params, sandboxSessionKey);
const toolHookRunContext = buildCodexToolHookRunContext({
attempt: params,
agentId: sessionAgentId,
sessionId: params.sessionId,
sessionKey: sandboxSessionKey,
runId: params.runId,
channelId: hookChannelId,
});
preDynamicStartupStages.mark("context-engine-support");
const preDynamicSummary = preDynamicStartupStages.snapshot();
if (shouldWarnCodexDynamicToolBuildStageSummary(preDynamicSummary)) {
@@ -832,12 +842,8 @@ export async function runCodexAppServerAttempt(
}),
directToolNames: resolveCodexDynamicToolDirectNames(params),
hookContext: {
agentId: sessionAgentId,
...toolHookRunContext,
config: params.config,
sessionId: params.sessionId,
sessionKey: sandboxSessionKey,
runId: params.runId,
channelId: hookChannelId,
currentChannelProvider: resolveCodexMessageToolProvider(params),
currentChannelId: params.currentChannelId,
currentMessagingTarget: params.currentMessagingTarget,
@@ -1444,6 +1450,7 @@ export async function runCodexAppServerAttempt(
config: params.config,
runId: params.runId,
channelId: hookChannelId,
toolHookContext: toolHookRunContext,
attemptTimeoutMs: params.timeoutMs,
startupTimeoutMs,
turnStartTimeoutMs: params.timeoutMs,
@@ -2150,6 +2157,7 @@ export async function runCodexAppServerAttempt(
method: request.method,
params: request.params,
paramsForRun: params,
toolHookContext: toolHookRunContext,
threadId: thread.threadId,
turnId,
nativeHookRelay,
@@ -2761,6 +2769,7 @@ export async function runCodexAppServerAttempt(
nativePostToolUseRelayEnabled:
nativeHookRelay?.allowedEvents.includes("post_tool_use") === true &&
nativeHookRelay.shouldRelayEvent("post_tool_use"),
toolHookContext: toolHookRunContext,
trajectoryRecorder,
onNativeToolResultRecorded: maybeAnnounceFastModeAutoOff,
},
@@ -3430,6 +3439,7 @@ function handleApprovalRequest(params: {
method: string;
params: JsonValue | undefined;
paramsForRun: EmbeddedRunAttemptParams;
toolHookContext: ToolHookRunContext;
threadId: string;
turnId: string;
nativeHookRelay?: NativeHookRelayRegistrationHandle;
@@ -3443,6 +3453,7 @@ function handleApprovalRequest(params: {
method: params.method,
requestParams: params.params,
paramsForRun: params.paramsForRun,
toolHookContext: params.toolHookContext,
threadId: params.threadId,
turnId: params.turnId,
nativeHookRelay: params.nativeHookRelay,

View File

@@ -861,9 +861,12 @@ describe("runCodexAppServerSideQuestion", () => {
).toMatchObject({
agentId: "main",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionKey: "agent:main:runtime-policy",
runId: "run-side-1",
channelId: "voice-room",
toolHookContext: {
sessionKey: "agent:main:runtime-policy",
},
allowedEvents: ["pre_tool_use", "post_tool_use", "before_agent_finalize"],
});
return threadResult("side-thread");
@@ -889,6 +892,7 @@ describe("runCodexAppServerSideQuestion", () => {
runCodexAppServerSideQuestion(
sideParams({
sessionKey: "agent:main:session-1",
sandboxSessionKey: "agent:main:runtime-policy",
messageChannel: "discord",
messageProvider: "discord-voice",
currentChannelId: "discord:voice-room",
@@ -971,6 +975,7 @@ describe("runCodexAppServerSideQuestion", () => {
runCodexAppServerSideQuestion(
sideParams({
sessionKey: "agent:main:session-1",
sandboxSessionKey: "agent:main:runtime-policy",
messageChannel: "discord",
messageProvider: "discord-voice",
opts: { runId: "run-side-approval" },
@@ -988,6 +993,7 @@ describe("runCodexAppServerSideQuestion", () => {
threadId?: string;
turnId?: string;
paramsForRun?: { messageChannel?: string; messageProvider?: string };
toolHookContext?: { sessionKey?: string };
nativeHookRelay?: { relayId?: string; allowedEvents?: readonly string[] };
}
| undefined;
@@ -1007,6 +1013,9 @@ describe("runCodexAppServerSideQuestion", () => {
messageChannel: "discord",
messageProvider: "discord-voice",
},
toolHookContext: {
sessionKey: "agent:main:runtime-policy",
},
});
expect(approvalArgs?.nativeHookRelay).toMatchObject({
relayId: relayIdDuringFork,
@@ -1482,6 +1491,14 @@ describe("runCodexAppServerSideQuestion", () => {
});
it("bridges side-thread dynamic tool requests to OpenClaw tools", async () => {
const beforeToolCall = vi.fn();
const afterToolCall = vi.fn();
initializeGlobalHookRunner(
createMockPluginRegistry([
{ hookName: "before_tool_call", handler: beforeToolCall },
{ hookName: "after_tool_call", handler: afterToolCall },
]),
);
const client = createFakeClient();
let toolResponse: unknown;
client.request.mockImplementation(async (method: string) => {
@@ -1527,6 +1544,13 @@ describe("runCodexAppServerSideQuestion", () => {
expect(toolArguments).toEqual({ topic: "AGENTS.md" });
expect(toolSignal).toBeInstanceOf(AbortSignal);
expect(toolOptions).toBeUndefined();
expect(beforeToolCall).toHaveBeenCalledTimes(1);
expect(mockCall(beforeToolCall)[1]).toMatchObject({ sessionKey: "session-1" });
await vi.waitFor(() => expect(afterToolCall).toHaveBeenCalledTimes(1));
expect(mockCall(afterToolCall)[1]).toMatchObject({ sessionKey: "session-1" });
expect(createOpenClawCodingToolsMock).toHaveBeenCalledWith(
expect.objectContaining({ sessionKey: "session-1" }),
);
expect(toolResponse).toEqual({
success: true,
contentItems: [{ type: "inputText", text: "tool output" }],
@@ -1610,14 +1634,29 @@ describe("runCodexAppServerSideQuestion", () => {
expect(activeDiagnosticToolKeys(diagnosticEvents)).toEqual(new Set());
});
it("normalizes hook channel ids for side-thread dynamic tool requests", async () => {
it("preserves requester identity while normalizing side-thread hook channels", async () => {
const afterToolCall = vi.fn();
const beforeToolCall = vi.fn((...args: unknown[]) => {
const context = args[1] as { channelId?: string };
expect(context.channelId).toBe("voice-room");
const context = args[1] as Record<string, unknown>;
expect(context).toMatchObject({
sessionKey: "agent:main:runtime-policy",
messageProvider: "discord-voice",
channel: "discord",
channelId: "voice-room",
chatId: "native-voice-chat",
senderId: "sender-1",
channelContext: {
sender: { id: "sender-1", providerUserId: "discord-user-1" },
chat: { id: "native-voice-chat", guildId: "guild-1" },
},
});
return undefined;
});
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "before_tool_call", handler: beforeToolCall }]),
createMockPluginRegistry([
{ hookName: "before_tool_call", handler: beforeToolCall },
{ hookName: "after_tool_call", handler: afterToolCall },
]),
);
const client = createFakeClient();
client.request.mockImplementation(async (method: string) => {
@@ -1657,17 +1696,48 @@ describe("runCodexAppServerSideQuestion", () => {
await expect(
runCodexAppServerSideQuestion(
sideParams({
sessionKey: "agent:main:conversation",
sandboxSessionKey: "agent:main:runtime-policy",
messageChannel: "discord",
messageProvider: "discord-voice",
currentChannelId: "discord:voice-room",
chatId: "native-voice-chat",
senderId: "sender-1",
channelContext: {
sender: { id: "sender-1", providerUserId: "discord-user-1" },
chat: { id: "native-voice-chat", guildId: "guild-1" },
},
}),
),
).resolves.toEqual({ text: "Tool answer." });
expect(beforeToolCall).toHaveBeenCalledTimes(1);
await vi.waitFor(() => expect(afterToolCall).toHaveBeenCalledTimes(1));
expect(mockCall(afterToolCall)[1]).toMatchObject({
sessionKey: "agent:main:runtime-policy",
messageProvider: "discord-voice",
channel: "discord",
channelId: "voice-room",
chatId: "native-voice-chat",
});
expect(createOpenClawCodingToolsMock).toHaveBeenCalledWith(
expect.objectContaining({ hookChannelId: "voice-room" }),
expect.objectContaining({
sessionKey: "agent:main:runtime-policy",
runSessionKey: "agent:main:conversation",
messageChannel: "discord",
messageProvider: "discord",
toolPolicyMessageProvider: "discord-voice",
hookChannelId: "voice-room",
chatId: "native-voice-chat",
hookChannelContext: {
sender: { id: "sender-1", providerUserId: "discord-user-1" },
chat: { id: "native-voice-chat", guildId: "guild-1" },
},
}),
);
expect(
(mockCall(createOpenClawCodingToolsMock)[0] as { channelContext?: unknown }).channelContext,
).toBeUndefined();
expect(toolExecuteMock).toHaveBeenCalledTimes(1);
});

View File

@@ -1,6 +1,5 @@
// Codex plugin module implements side question behavior.
import {
buildAgentHookContextChannelFields,
embeddedAgentLog,
formatErrorMessage,
resolveAgentDir,
@@ -16,6 +15,7 @@ import {
type EmbeddedRunAttemptParams,
type NativeHookRelayEvent,
type NativeHookRelayRegistrationHandle,
type ToolHookRunContext,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { loadExecApprovals } from "openclaw/plugin-sdk/exec-approvals-runtime";
import { resolveCodexAppServerForModelProvider } from "./app-server-policy.js";
@@ -89,6 +89,7 @@ import {
resolveCodexBindingModelProviderFallback,
resolveReasoningEffort,
} from "./thread-lifecycle.js";
import { buildCodexToolHookRunContext } from "./tool-hook-context.js";
import { filterToolsForVisionInputs } from "./vision-tools.js";
import {
resolveCodexWebSearchPlan,
@@ -206,9 +207,21 @@ export async function runCodexAppServerSideQuestion(
});
const cwd = binding.cwd || params.workspaceDir || process.cwd();
const sideRunParams = buildSideRunAttemptParams(params, { cwd, authProfileId });
const toolHookSessionKey =
sideRunParams.sandboxSessionKey?.trim() ||
sideRunParams.sessionKey?.trim() ||
sideRunParams.sessionId ||
sessionAgentId;
const toolHookRunContext = buildCodexToolHookRunContext({
attempt: sideRunParams,
agentId: sessionAgentId,
sessionId: sideRunParams.sessionId,
sessionKey: toolHookSessionKey,
runId: sideRunParams.runId,
});
const nativeExecutionBlock = resolveCodexNativeExecutionBlock({
config: sideRunParams.config,
sessionKey: sideRunParams.sandboxSessionKey?.trim() || sideRunParams.sessionKey,
sessionKey: toolHookSessionKey,
sessionId: sideRunParams.sessionId,
surface: "/btw side-question mode",
});
@@ -287,6 +300,7 @@ export async function runCodexAppServerSideQuestion(
nativeToolSurfaceEnabled,
nativeProviderWebSearchSupport,
signal: runAbortController.signal,
toolHookContext: toolHookRunContext,
});
removeRequestHandler = client.addRequestHandler(async (request) => {
if (request.method === "account/chatgptAuthTokens/refresh") {
@@ -319,19 +333,20 @@ export async function runCodexAppServerSideQuestion(
method: request.method,
requestParams: request.params,
paramsForRun: sideRunParams,
toolHookContext: toolHookRunContext,
threadId: childThreadId,
turnId,
nativeHookRelay,
execPolicy,
execReviewerAgentId: sessionAgentId,
internalExecAutoReview: modelScopedAppServer.approvalsReviewer === "user",
autoApprove: shouldAutoApproveCodexAppServerApprovals({
approvalPolicy,
networkProxy: modelScopedAppServer.networkProxy,
sandbox,
}),
signal: runAbortController.signal,
});
execPolicy,
execReviewerAgentId: sessionAgentId,
internalExecAutoReview: modelScopedAppServer.approvalsReviewer === "user",
autoApprove: shouldAutoApproveCodexAppServerApprovals({
approvalPolicy,
networkProxy: modelScopedAppServer.networkProxy,
sandbox,
}),
signal: runAbortController.signal,
});
}
if (request.method !== "item/tool/call") {
return undefined;
@@ -388,15 +403,11 @@ export async function runCodexAppServerSideQuestion(
events: nativeHookRelayEvents,
agentId: sessionAgentId,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
sessionKey: toolHookRunContext.sessionKey,
config: params.cfg,
runId: sideRunParams.runId,
channelId: buildAgentHookContextChannelFields({
sessionKey: params.sessionKey,
messageChannel: params.messageChannel,
messageProvider: params.messageProvider,
currentChannelId: params.currentChannelId,
}).channelId,
channelId: toolHookRunContext.channelId,
toolHookContext: toolHookRunContext,
requestTimeoutMs: appServer.requestTimeoutMs,
completionTimeoutMs: Math.max(
appServer.turnCompletionIdleTimeoutMs,
@@ -419,12 +430,12 @@ export async function runCodexAppServerSideQuestion(
nativeCodeModeEnabled: nativeToolSurfaceEnabled,
nativeCodeModeOnlyEnabled: appServer.codeModeOnly,
});
const threadConfig =
mergeCodexThreadConfigs(
nativeHookRelayConfig,
runtimeThreadConfig,
modelScopedAppServer.networkProxy?.configPatch,
) ?? runtimeThreadConfig;
const threadConfig =
mergeCodexThreadConfigs(
nativeHookRelayConfig,
runtimeThreadConfig,
modelScopedAppServer.networkProxy?.configPatch,
) ?? runtimeThreadConfig;
const forkResponse = assertCodexThreadForkResponse(
await forkCodexSideThread(
client,
@@ -436,7 +447,7 @@ export async function runCodexAppServerSideQuestion(
cwd,
approvalPolicy,
approvalsReviewer: modelScopedAppServer.approvalsReviewer,
...(modelScopedAppServer.networkProxy ? {} : { sandbox }),
...(modelScopedAppServer.networkProxy ? {} : { sandbox }),
...(serviceTier ? { serviceTier } : {}),
config: threadConfig,
developerInstructions: SIDE_DEVELOPER_INSTRUCTIONS,
@@ -542,6 +553,7 @@ function registerCodexSideNativeHookRelay(params: {
config: EmbeddedRunAttemptParams["config"];
runId: string;
channelId?: string;
toolHookContext?: ToolHookRunContext;
requestTimeoutMs: number;
completionTimeoutMs: number;
signal: AbortSignal;
@@ -557,6 +569,7 @@ function registerCodexSideNativeHookRelay(params: {
...(params.config ? { config: params.config } : {}),
runId: params.runId,
...(params.channelId ? { channelId: params.channelId } : {}),
...(params.toolHookContext ? { toolHookContext: params.toolHookContext } : {}),
allowedEvents: params.events,
ttlMs: resolveCodexSideNativeHookRelayTtlMs({
explicitTtlMs: params.options.ttlMs,
@@ -596,6 +609,7 @@ function buildSideRunAttemptParams(
provider: params.provider,
modelId: params.model,
model: params.runtimeModel ?? ({ id: params.model, provider: params.provider } as never),
trigger: "user" as const,
sessionId: params.sessionId,
sessionFile: params.sessionFile,
sessionKey: params.sessionKey,
@@ -616,6 +630,8 @@ function buildSideRunAttemptParams(
...(params.senderUsername !== undefined ? { senderUsername: params.senderUsername } : {}),
...(params.senderE164 !== undefined ? { senderE164: params.senderE164 } : {}),
...(params.senderIsOwner !== undefined ? { senderIsOwner: params.senderIsOwner } : {}),
...(params.chatId ? { chatId: params.chatId } : {}),
...(params.channelContext ? { channelContext: params.channelContext } : {}),
...(params.currentChannelId ? { currentChannelId: params.currentChannelId } : {}),
...(params.toolsAllow ? { toolsAllow: params.toolsAllow } : {}),
workspaceDir: options.cwd,
@@ -647,6 +663,7 @@ async function createCodexSideToolBridge(input: {
nativeToolSurfaceEnabled: boolean;
nativeProviderWebSearchSupport: CodexNativeWebSearchSupport;
signal: AbortSignal;
toolHookContext: ToolHookRunContext;
}): Promise<{ toolBridge: CodexDynamicToolBridge; webSearchPlan: CodexWebSearchPlan }> {
const runtimeModel =
input.params.runtimeModel ??
@@ -657,10 +674,7 @@ async function createCodexSideToolBridge(input: {
const createOpenClawCodingTools = (await import("openclaw/plugin-sdk/agent-harness"))
.createOpenClawCodingTools;
const sandboxSessionKey =
input.params.sandboxSessionKey?.trim() ||
input.params.sessionKey?.trim() ||
input.params.sessionId ||
input.sessionAgentId;
input.toolHookContext.sessionKey || input.params.sessionId || input.sessionAgentId;
const sandbox = await resolveSandboxContext({
config: input.params.cfg,
sessionKey: sandboxSessionKey,
@@ -696,6 +710,9 @@ async function createCodexSideToolBridge(input: {
workspaceDir: input.cwd,
}),
suppressManagedWebSearch: false,
trigger: input.toolHookContext.trigger,
jobId: input.toolHookContext.jobId,
messageChannel: input.params.messageChannel,
...(input.params.messageProvider || input.params.messageChannel
? {
messageProvider: messageToolProvider,
@@ -715,6 +732,8 @@ async function createCodexSideToolBridge(input: {
...(input.params.memberRoleIds ? { memberRoleIds: input.params.memberRoleIds } : {}),
...(input.params.spawnedBy !== undefined ? { spawnedBy: input.params.spawnedBy } : {}),
...(input.params.senderId !== undefined ? { senderId: input.params.senderId } : {}),
chatId: input.toolHookContext.chatId,
hookChannelContext: input.toolHookContext.channelContext,
...(input.params.senderName !== undefined ? { senderName: input.params.senderName } : {}),
...(input.params.senderUsername !== undefined
? { senderUsername: input.params.senderUsername }
@@ -724,12 +743,7 @@ async function createCodexSideToolBridge(input: {
? { senderIsOwner: input.params.senderIsOwner }
: {}),
...(input.params.currentChannelId ? { currentChannelId: input.params.currentChannelId } : {}),
hookChannelId: buildAgentHookContextChannelFields({
sessionKey: input.params.sessionKey,
messageChannel: input.params.messageChannel,
messageProvider: input.params.messageProvider,
currentChannelId: input.params.currentChannelId,
}).channelId,
hookChannelId: input.toolHookContext.channelId,
sandbox,
emitBeforeToolCallDiagnostics: false,
modelHasVision: runtimeModel.input?.includes("image") ?? false,
@@ -757,25 +771,15 @@ async function createCodexSideToolBridge(input: {
})
: requestedWebSearchPlan;
const exposedTools = tools.filter((tool) => tool.name !== "web_search");
const hookChannelFields = buildAgentHookContextChannelFields({
sessionKey: input.params.sessionKey,
messageChannel: input.params.messageChannel,
messageProvider: input.params.messageProvider,
currentChannelId: input.params.currentChannelId,
});
return {
toolBridge: createCodexDynamicToolBridge({
tools: exposedTools,
signal: input.signal,
loading: resolveCodexDynamicToolsLoading(input.pluginConfig),
hookContext: {
agentId: input.sessionAgentId,
...input.toolHookContext,
config: input.params.cfg,
sessionId: input.params.sessionId,
sessionKey: input.params.sessionKey,
runId: input.params.opts?.runId ?? `codex-btw:${input.params.sessionId}`,
currentChannelProvider: messageToolProvider,
...hookChannelFields,
},
}),
webSearchPlan,

View File

@@ -0,0 +1,41 @@
/** Builds one canonical requester-origin snapshot for Codex tool hook paths. */
import {
buildAgentHookContextOriginFields,
type EmbeddedRunAttemptParams,
type ToolHookRunContext,
} from "openclaw/plugin-sdk/agent-harness-runtime";
/** Build the plain run metadata shared by Codex before/after tool hook owners. */
export function buildCodexToolHookRunContext(params: {
attempt: EmbeddedRunAttemptParams;
agentId?: string;
sessionId?: string;
sessionKey?: string;
runId?: string;
channelId?: string;
}): ToolHookRunContext {
const attempt = params.attempt;
const agentId = params.agentId ?? attempt.agentId;
const sessionKey = params.sessionKey ?? attempt.sessionKey;
const sessionId = params.sessionId ?? attempt.sessionId;
const runId = params.runId ?? attempt.runId;
return {
...(agentId ? { agentId } : {}),
...(sessionKey ? { sessionKey } : {}),
...(sessionId ? { sessionId } : {}),
...(runId ? { runId } : {}),
...(attempt.jobId ? { jobId: attempt.jobId } : {}),
...(attempt.trigger ? { trigger: attempt.trigger } : {}),
...buildAgentHookContextOriginFields({
sessionKey,
messageChannel: attempt.messageChannel,
messageProvider: attempt.messageProvider ?? attempt.messageChannel,
currentChannelId: params.channelId ?? attempt.currentChannelId,
messageTo: attempt.currentMessagingTarget ?? attempt.messageTo,
trigger: attempt.trigger,
senderId: attempt.senderId,
chatId: attempt.chatId,
channelContext: attempt.channelContext,
}),
};
}

View File

@@ -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"
}
}
}

View File

@@ -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": {

View File

@@ -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",

View File

@@ -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",

View File

@@ -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

View File

@@ -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();

View File

@@ -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);

View File

@@ -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"
}

View File

@@ -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": {

View File

@@ -5,6 +5,7 @@ import path from "node:path";
import type { CopilotClient, Tool as SdkTool } from "@github/copilot-sdk";
import {
abortAgentHarnessRun,
attachModelProviderRequestTransport,
queueAgentHarnessMessage,
type AgentHarnessAttemptParams,
type AgentHarnessAttemptResult,
@@ -104,11 +105,12 @@ function createDeferred<T>() {
function flushAsync() {
// Pump enough microtasks for the attempt to settle past every
// pre-createSession `await` in attempt.ts (resolvePoolAcquire,
// resolveCopilotWorkspaceBootstrapContext, createSession, etc.).
// BYOK proxy setup, resolveCopilotWorkspaceBootstrapContext,
// createSession, etc.).
// Each chained `then` is one tick; tests rely on this to observe
// `sdk.sessions[0]` being populated before they emit deltas.
const tick = () => Promise.resolve();
return tick().then(tick).then(tick);
return tick().then(tick).then(tick).then(tick).then(tick);
}
function waitForEventLoopTurn(): Promise<void> {
@@ -338,7 +340,22 @@ describe("runCopilotAttempt", () => {
return { sdkTools: [], sourceTools: [] };
});
await runCopilotAttempt(makeParams(), {
const params = makeParams();
Object.assign(params, {
jobId: "job-1",
trigger: "user",
messageChannel: "slack",
messageProvider: "slack-voice",
currentChannelId: "C123",
chatId: "C123",
senderId: "U123",
channelContext: {
sender: { id: "U123", displayName: "Ada" },
chat: { id: "C123" },
},
});
await runCopilotAttempt(params, {
createToolBridge,
pool: makeFakePool(sdk),
});
@@ -385,7 +402,21 @@ describe("runCopilotAttempt", () => {
toolCallId: "tool-call-1",
toolName: "read",
}),
expect.objectContaining({ agentId: "agent-1", sessionId: "session-1" }),
expect.objectContaining({
agentId: "agent-1",
sessionId: "session-1",
jobId: "job-1",
trigger: "user",
messageProvider: "slack-voice",
channel: "slack",
chatId: "C123",
senderId: "U123",
channelId: "C123",
channelContext: {
sender: { id: "U123", displayName: "Ada" },
chat: { id: "C123" },
},
}),
);
});
@@ -1285,6 +1316,32 @@ describe("runCopilotAttempt", () => {
).toBe(sdkTools);
});
it("passes the session-resolved agent id to the tool bridge", async () => {
const sdk = makeFakeSdk();
const pool = makeFakePool(sdk);
const createToolBridge = vi.fn(async () => ({ sdkTools: [], sourceTools: [] }));
await runCopilotAttempt(
makeParams({
agentId: undefined,
sessionKey: "agent:beta:main",
config: {
agents: {
list: [{ id: "main" }, { id: "beta" }],
},
} as never,
}),
{ createToolBridge, pool },
);
expect(createToolBridge).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "beta",
sessionKey: "agent:beta:main",
}),
);
});
it("F6: sessionRef is populated after createSession so the tool bridge's onYield can abort the live SDK session", async () => {
const sdk = makeFakeSdk();
const pool = makeFakePool(sdk);
@@ -2338,6 +2395,152 @@ describe("runCopilotAttempt", () => {
expect(options.useLoggedInUser).toBe(false);
});
it("pool keying: BYOK does not resolve unrelated GitHub auth", async () => {
const sdk = makeFakeSdk();
const pool = makeFakePool(sdk);
await runCopilotAttempt(
makeParams({
auth: { gitHubToken: "unrelated-token" } as never,
model: {
api: "openai-responses",
baseUrl: "https://api.example.test/v1",
id: "gpt-test",
provider: "custom-openai",
} as never,
resolvedApiKey: "byok-token",
authProfileId: "custom-openai:main",
} as never),
{ pool },
);
const key = (vi.mocked(pool["acquire"]).mock.calls[0] as unknown[] | undefined)?.[0] as {
authMode: string;
authProfileId?: string;
};
const options = (vi.mocked(pool["acquire"]).mock.calls[0] as unknown[] | undefined)?.[1] as {
gitHubToken?: string;
useLoggedInUser?: boolean;
};
const cfg = (sdk.createSession.mock.calls[0] as unknown[] | undefined)?.[0] as {
provider?: { apiKey?: string; baseUrl?: string };
};
expect(key.authMode).toBe("byok");
expect(key.authProfileId).toBe("custom-openai:main");
expect(options.gitHubToken).toBeUndefined();
expect(options.useLoggedInUser).toBe(false);
expect(cfg.provider).toEqual(
expect.objectContaining({
apiKey: "byok-token",
baseUrl: expect.stringMatching(/^http:\/\/127\.0\.0\.1:\d+\/[a-f0-9]{24}\/v1$/),
}),
);
});
it("forwards BYOK provider headers on the model request turn", async () => {
const sdk = makeFakeSdk();
const pool = makeFakePool(sdk);
await runCopilotAttempt(
makeParams({
model: {
api: "anthropic-messages",
baseUrl: "https://anthropic.example.test",
headers: {
"X-Tenant": "tenant-a",
"X-Trace": "trace-1",
},
id: "claude-test",
provider: "anthropic-proxy",
} as never,
resolvedApiKey: "byok-token",
authProfileId: "anthropic-proxy:main",
} as never),
{ pool },
);
const cfg = (sdk.createSession.mock.calls[0] as unknown[] | undefined)?.[0] as {
provider?: { headers?: Record<string, string> };
};
const sendOptions = sdk.sessions[0]?.sendAndWait.mock.calls[0]?.[0] as {
requestHeaders?: Record<string, string>;
};
expect(cfg.provider?.headers).toEqual({
"X-Tenant": "tenant-a",
"X-Trace": "trace-1",
});
expect(sendOptions.requestHeaders).toEqual({
"X-Tenant": "tenant-a",
"X-Trace": "trace-1",
});
});
it("preserves prepared BYOK header-auth without synthesizing SDK apiKey auth", async () => {
const sdk = makeFakeSdk();
const pool = makeFakePool(sdk);
const model = attachModelProviderRequestTransport(
{
api: "openai-responses",
baseUrl: "https://proxy.example.test/v1",
headers: { "x-api-key": "header-secret" },
id: "gpt-test",
provider: "custom-header-proxy",
},
{ auth: { mode: "header", headerName: "x-api-key", value: "header-secret" } },
);
await runCopilotAttempt(
makeParams({
model: model as never,
resolvedApiKey: "header-secret",
authProfileId: "custom-header-proxy:main",
} as never),
{ pool },
);
const cfg = (sdk.createSession.mock.calls[0] as unknown[] | undefined)?.[0] as {
provider?: { apiKey?: string; headers?: Record<string, string> };
};
const sendOptions = sdk.sessions[0]?.sendAndWait.mock.calls[0]?.[0] as {
requestHeaders?: Record<string, string>;
};
expect(cfg.provider).toEqual(
expect.objectContaining({
headers: { "x-api-key": "header-secret" },
}),
);
expect(cfg.provider).not.toHaveProperty("apiKey");
expect(sendOptions.requestHeaders).toEqual({ "x-api-key": "header-secret" });
});
it("rejects BYOK providers with request transport policy overrides before creating a SDK session", async () => {
const sdk = makeFakeSdk();
const pool = makeFakePool(sdk);
const model = attachModelProviderRequestTransport(
{
api: "openai-responses",
baseUrl: "https://proxy.example.test/v1",
id: "gpt-test",
provider: "custom-header-proxy",
},
{ proxy: { mode: "env-proxy" } },
);
const result = await runCopilotAttempt(
makeParams({
model: model as never,
resolvedApiKey: "header-secret",
authProfileId: "custom-header-proxy:main",
} as never),
{ pool },
);
expect(getPromptErrorCode(result)).toBe("model_not_supported");
expect((result.promptError as Error | undefined)?.message).toContain("request proxy");
expect(sdk.createSession).not.toHaveBeenCalled();
});
describe("session-level gitHubToken (independent of client-level)", () => {
// The SDK contract (@github/copilot-sdk/dist/types.d.ts:1168-1178)
// makes `SessionConfig.gitHubToken` independent of the client-level
@@ -2401,6 +2604,37 @@ describe("runCopilotAttempt", () => {
expect(resumeCfg.gitHubToken).toBe("contract-token-resume");
});
it("BYOK provider config is forwarded to resumeSession", async () => {
const sdk = makeFakeSdk();
const pool = makeFakePool(sdk);
await runCopilotAttempt(
makeParams({
auth: { gitHubToken: "unrelated-token" } as never,
model: {
api: "openai-responses",
baseUrl: "https://api.example.test/v1",
id: "gpt-test",
provider: "custom-openai",
} as never,
resolvedApiKey: "byok-token",
authProfileId: "custom-openai:main",
initialReplayState: { sdkSessionId: "resume-target" } as never,
} as never),
{ pool },
);
const resumeCfg = sdk.resumeSession.mock.calls[0]?.[1] as {
provider?: { apiKey?: string; baseUrl?: string };
};
expect(resumeCfg.provider).toEqual(
expect.objectContaining({
apiKey: "byok-token",
baseUrl: expect.stringMatching(/^http:\/\/127\.0\.0\.1:\d+\/[a-f0-9]{24}\/v1$/),
}),
);
});
it("SessionConfig.gitHubToken is omitted when useLoggedInUser is the resolved mode", async () => {
const sdk = makeFakeSdk();
const pool = makeFakePool(sdk);

View File

@@ -9,7 +9,9 @@ import type {
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
buildAgentHookContextChannelFields,
buildAgentHookContextOriginFields,
detectAndLoadAgentHarnessPromptImages,
getModelProviderRequestTransport,
resolveAgentHarnessBeforePromptBuildResult,
resolveAttemptFsWorkspaceOnly,
resolveAttemptSpawnWorkspaceDir,
@@ -27,7 +29,8 @@ import {
clearActiveEmbeddedRun,
setActiveEmbeddedRun,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { resolveCopilotAuth } from "./auth-bridge.js";
import { createCopilotByokAuth, resolveCopilotAuth } from "./auth-bridge.js";
import { createCopilotByokProxy } from "./byok-proxy.js";
import {
createInfiniteSessionConfig,
type CopilotInfiniteSessionOptions,
@@ -50,6 +53,7 @@ import {
rejectAllPolicy,
type CopilotPermissionPolicy,
} from "./permission-bridge.js";
import { resolveCopilotProvider, type ResolvedCopilotProvider } from "./provider-bridge.js";
import {
classifyResumeFailure,
computeReplayMetadata,
@@ -79,6 +83,7 @@ export type CopilotSessionConfig = Pick<
| "model"
| "onPermissionRequest"
| "onUserInputRequest"
| "provider"
| "reasoningEffort"
| "systemMessage"
| "tools"
@@ -115,7 +120,42 @@ type AttemptParamsLike = AgentHarnessAttemptParams & {
// internal expansion. Symmetric to `EmbeddedRunAttemptParams.transcriptPrompt`.
transcriptPrompt?: string;
};
type ModelRef = { api?: string; id: string; provider: string };
type ModelRef = {
api?: string;
id: string;
provider: string;
baseUrl?: string;
azureApiVersion?: string;
headers?: Record<string, string | null | undefined>;
authHeader?: boolean;
requestAuthMode?: string;
requestProxy?: unknown;
requestTls?: unknown;
requestAllowPrivateNetwork?: unknown;
contextTokens?: number;
contextWindow?: number;
maxTokens?: number;
};
type ModelRefInputObject = {
api?: unknown;
id?: unknown;
provider?: unknown;
baseUrl?: unknown;
azureApiVersion?: unknown;
params?: { azureApiVersion?: unknown };
headers?: ModelRef["headers"];
authHeader?: boolean;
request?: {
auth?: { mode?: unknown };
proxy?: unknown;
tls?: unknown;
allowPrivateNetwork?: unknown;
};
contextTokens?: number;
contextWindow?: number;
maxTokens?: number;
};
export type { AttemptParamsLike as CopilotPoolAcquireInput, ModelRef };
export { SUPPORTED_PROVIDERS };
@@ -142,6 +182,7 @@ export interface CopilotAttemptDeps {
* attempt.
*/
onSessionEstablished?: (info: {
compactionSessionConfig?: CopilotSessionConfig;
sdkSessionId: string;
pooledClient: PooledClient;
sessionConfig: CopilotSessionConfig;
@@ -228,6 +269,7 @@ function deferBackgroundCompactionCleanup(params: {
bridge: ReturnType<typeof attachEventBridge>;
handle: PooledClient;
pool: CopilotClientPool;
cleanupByokProxy?: () => Promise<void>;
cleanupToolBridge?: () => void;
finalizeNativeSubagents?: () => void;
sdkSessionId?: string;
@@ -260,6 +302,7 @@ function deferBackgroundCompactionCleanup(params: {
// The attempt has already returned its timeout result.
}
params.cleanupToolBridge?.();
await params.cleanupByokProxy?.();
if (outcome !== "completed" && params.sdkSessionId) {
try {
await params.handle.client.deleteSession(params.sdkSessionId);
@@ -367,6 +410,25 @@ export async function runCopilotAttempt(
...hookContextWindowFields,
...buildAgentHookContextChannelFields(input),
};
const toolHookRunContext = {
runId: input.runId,
jobId: input.jobId,
agentId: sessionAgentId,
sessionKey: sandboxSessionKey,
sessionId: input.sessionId,
trigger: input.trigger,
...buildAgentHookContextOriginFields({
sessionKey: sandboxSessionKey,
messageChannel: input.messageChannel,
messageProvider: input.messageProvider ?? input.messageChannel,
currentChannelId: input.currentChannelId,
messageTo: input.currentMessagingTarget ?? input.messageTo,
trigger: input.trigger,
senderId: input.senderId,
chatId: input.chatId,
channelContext: input.channelContext,
}),
};
const finishAttempt = (result: AgentHarnessAttemptResult) =>
finalizeCopilotAttempt(input, result, hookContext, attemptStartedAt, now);
@@ -384,15 +446,18 @@ export async function runCopilotAttempt(
);
}
if (!SUPPORTED_PROVIDERS.has(modelRef.provider)) {
try {
resolveCopilotProvider({
model: modelRef,
resolvedApiKey: readString(params.resolvedApiKey),
authProfileId: readString(params.authProfileId),
});
} catch (error) {
return finishAttempt(
createResult(input, {
messagesSnapshot: messages,
now,
promptError: createPromptError(
"model_not_supported",
`[copilot-attempt] provider ${modelRef.provider} is not supported at MVP (subscription Copilot models only; BYOK arrives via byok-mapping-skeleton)`,
),
promptError: createPromptError("model_not_supported", toError(error).message, error),
sdkSessionId: undefined,
sessionIdUsed: input.sessionId,
}),
@@ -549,6 +614,22 @@ export async function runCopilotAttempt(
})
: undefined;
const poolAcquire = resolvePoolAcquire(input);
let byokProxy: Awaited<ReturnType<typeof createCopilotByokProxy>>;
try {
byokProxy = await createCopilotByokProxy(poolAcquire.provider);
} catch (error) {
return finishAttempt(
createResult(input, {
messagesSnapshot: messages,
now,
promptError: createPromptError("model_not_supported", toError(error).message, error),
sdkSessionId: undefined,
sessionIdUsed: input.sessionId,
}),
);
}
const cleanupByokProxy = byokProxy?.close;
const sessionProvider = byokProxy?.provider ?? poolAcquire.provider;
// Mutable session holder shared with the tool bridge so onYield
// (raised inside wrapped-tool execution) can route to the live SDK
@@ -562,9 +643,10 @@ export async function runCopilotAttempt(
let sdkTools: SdkTool[];
try {
const toolBridge = await createToolBridge({
allowModelTools: poolAcquire.provider.mode === "byok",
modelProvider: modelRef.provider,
modelId: modelRef.id,
agentId: readString(params.agentId) ?? "copilot",
agentId: sessionAgentId,
sessionId: readString(input.sessionId) ?? "copilot-session",
sessionKey: readString((input as { sessionKey?: unknown }).sessionKey),
agentDir: readString(input.agentDir),
@@ -590,11 +672,7 @@ export async function runCopilotAttempt(
runAgentHarnessAfterToolCallHook({
toolName,
toolCallId,
runId: input.runId,
agentId: sessionAgentId,
sessionId: input.sessionId,
sessionKey: sandboxSessionKey,
channelId: hookContext.channelId,
...toolHookRunContext,
startArgs: args,
...(result !== undefined ? { result } : {}),
...(error ? { error } : {}),
@@ -692,6 +770,7 @@ export async function runCopilotAttempt(
modelRef.id,
sdkTools,
poolAcquire.auth,
sessionProvider,
promptBuild.developerInstructions || undefined,
effectiveWorkspaceDir,
effectiveCwd,
@@ -703,6 +782,25 @@ export async function runCopilotAttempt(
}
: undefined,
);
const compactionSessionConfig = byokProxy
? createSessionConfig(
attemptInput,
modelRef.id,
sdkTools,
poolAcquire.auth,
poolAcquire.provider,
promptBuild.developerInstructions || undefined,
effectiveWorkspaceDir,
effectiveCwd,
userInputBridge.onUserInputRequest,
hasNativePromptHook
? {
onUserPromptSubmitted: ({ additionalContext, prompt }) =>
emitLlmInput(prompt, additionalContext),
}
: undefined,
)
: sessionConfig;
const replayDecision = decideReplayAction({
sdkSessionId: input.initialReplayState?.sdkSessionId,
replayInvalid: input.initialReplayState?.replayInvalid,
@@ -749,7 +847,12 @@ export async function runCopilotAttempt(
sessionIdUsed = sdkSessionId ?? input.sessionId;
if (sdkSessionId && deps.onSessionEstablished) {
try {
deps.onSessionEstablished({ sdkSessionId, pooledClient: handle, sessionConfig });
deps.onSessionEstablished({
compactionSessionConfig,
sdkSessionId,
pooledClient: handle,
sessionConfig,
});
} catch {
// never let session-tracking callbacks break attempts
}
@@ -809,6 +912,7 @@ export async function runCopilotAttempt(
const messageOptions = await createMessageOptions(attemptInput, {
effectiveCwd,
effectiveWorkspaceDir,
provider: poolAcquire.provider,
sandbox,
workspaceOnly: effectiveFsWorkspaceOnly,
});
@@ -890,6 +994,7 @@ export async function runCopilotAttempt(
awaitSessionIdle: !bridge.hasObservedSessionIdle(),
bridge,
cleanupToolBridge,
cleanupByokProxy,
finalizeNativeSubagents: () => nativeSubagentTaskMirror?.finalizeActiveRuns(),
handle,
pool: deps.pool,
@@ -922,6 +1027,7 @@ export async function runCopilotAttempt(
await bridge?.awaitAgentEventChain();
nativeSubagentTaskMirror?.finalizeActiveRuns();
cleanupToolBridge?.();
await cleanupByokProxy?.();
bridge?.detach();
params.abortSignal?.removeEventListener("abort", onAbort);
@@ -1191,6 +1297,7 @@ function createSessionConfig(
sdkModelId: string,
sdkTools: SdkTool[],
resolvedAuth: ReturnType<typeof resolveCopilotAuth>,
resolvedProvider: ResolvedCopilotProvider,
systemMessageContent: string | undefined,
effectiveWorkspaceDir: string | undefined,
effectiveCwd: string | undefined,
@@ -1225,6 +1332,10 @@ function createSessionConfig(
// Registers the SDK ask_user bridge. The bridge itself owns pending
// reply routing so generic mid-run steering still fails closed.
onUserInputRequest,
// The SDK's ResumeSessionConfig declaration omits ProviderConfig, but its
// client forwards config.provider on both session.create and session.resume.
// Keep one session config so BYOK resume/compaction stays on the same wire.
...(resolvedProvider.provider ? { provider: resolvedProvider.provider } : {}),
// Preserve the shipped native SDK hook contract. These callbacks expose
// Copilot-specific events and decisions that generic lifecycle hooks do
// not model.
@@ -1314,14 +1425,28 @@ async function createMessageOptions(
context: {
effectiveCwd: string | undefined;
effectiveWorkspaceDir: string | undefined;
provider: ResolvedCopilotProvider;
sandbox: SandboxContext | null;
workspaceOnly: boolean;
},
): Promise<MessageOptions> {
const attachments = createPromptImageAttachments(await resolvePromptImages(params, context));
return attachments.length > 0
? { prompt: params.prompt, attachments }
: { prompt: params.prompt };
const requestHeaders = resolveProviderRequestHeaders(context.provider);
return {
prompt: params.prompt,
...(attachments.length > 0 ? { attachments } : {}),
// The SDK declares session-level provider headers, but its Anthropic
// runtime path consumes per-turn requestHeaders. Mirror them here so BYOK
// tenant/proxy headers survive every supported adapter.
...(requestHeaders ? { requestHeaders } : {}),
};
}
function resolveProviderRequestHeaders(
provider: ResolvedCopilotProvider,
): Record<string, string> | undefined {
const headers = provider.provider?.headers;
return headers && Object.keys(headers).length > 0 ? { ...headers } : undefined;
}
function createPromptImageAttachments(
@@ -1488,18 +1613,35 @@ function readResolvedAttemptPath(value: unknown): string | undefined {
}
export function resolveModelRef(params: AttemptParamsLike): ModelRef {
const rawModel = params.model;
const rawModel = (params as { runtimeModel?: unknown }).runtimeModel ?? params.model;
if (rawModel && typeof rawModel === "object") {
const model = rawModel as ModelRefInputObject;
const requestTransport = getModelProviderRequestTransport(rawModel);
const rawRequest = model.request;
return {
api: readString(rawModel.api),
api: readString(model.api),
id:
readString(rawModel.id) ??
readString(model.id) ??
readString((params as { modelId?: unknown }).modelId) ??
"unknown-model",
provider:
readString(rawModel.provider) ??
readString(model.provider) ??
readString((params as { provider?: unknown }).provider) ??
"unknown-provider",
baseUrl: readString(model.baseUrl),
azureApiVersion: readString(
model.azureApiVersion ?? model.params?.azureApiVersion,
),
headers: model.headers,
authHeader: model.authHeader,
requestAuthMode: readString(requestTransport?.auth?.mode ?? rawRequest?.auth?.mode),
requestProxy: requestTransport?.proxy ?? rawRequest?.proxy,
requestTls: requestTransport?.tls ?? rawRequest?.tls,
requestAllowPrivateNetwork:
requestTransport?.allowPrivateNetwork ?? rawRequest?.allowPrivateNetwork,
contextTokens: model.contextTokens,
contextWindow: model.contextWindow,
maxTokens: model.maxTokens,
};
}
return {
@@ -1529,40 +1671,59 @@ export function resolvePoolAcquire(params: AttemptParamsLike): {
* setting both.
*/
auth: ReturnType<typeof resolveCopilotAuth>;
provider: ResolvedCopilotProvider;
} {
const resolved = resolveCopilotAuth({
agentId: readString(params.agentId),
agentDir: readString(params.agentDir),
workspaceDir: readString(params.workspaceDir),
copilotHome: readString(params.copilotHome),
auth: params.auth,
// Contract-resolved auth (EmbeddedRunAttemptParams): the production
// main path for agents with a configured `github-copilot` auth
// profile. Falling through to env / useLoggedInUser when absent
// keeps the direct-CLI / dogfood paths working unchanged.
const model = resolveModelRef(params);
const provider = resolveCopilotProvider({
model,
resolvedApiKey: readString(params.resolvedApiKey),
authProfileId: readString(params.authProfileId),
profileVersion: readString(params.profileVersion),
});
const auth =
provider.mode === "byok"
? createCopilotByokAuth({
agentId: readString(params.agentId),
agentDir: readString(params.agentDir),
workspaceDir: readString(params.workspaceDir),
copilotHome: readString(params.copilotHome),
authProfileId: provider.authProfileId,
authProfileVersion: provider.authProfileVersion,
})
: resolveCopilotAuth({
agentId: readString(params.agentId),
agentDir: readString(params.agentDir),
workspaceDir: readString(params.workspaceDir),
copilotHome: readString(params.copilotHome),
auth: params.auth,
// Contract-resolved auth (EmbeddedRunAttemptParams): the production
// main path for agents with a configured `github-copilot` auth
// profile. Falling through to env / useLoggedInUser when absent
// keeps the direct-CLI / dogfood paths working unchanged.
resolvedApiKey: readString(params.resolvedApiKey),
authProfileId: readString(params.authProfileId),
profileVersion: readString(params.profileVersion),
});
return {
key: {
agentId: resolved.agentId,
authMode: resolved.authMode,
...(resolved.authMode === "gitHubToken"
agentId: auth.agentId,
authMode: auth.authMode,
...(auth.authMode === "gitHubToken" || auth.authMode === "byok"
? {
authProfileId: resolved.authProfileId,
authProfileVersion: resolved.authProfileVersion,
authProfileId: auth.authProfileId,
authProfileVersion: auth.authProfileVersion,
}
: {}),
copilotHome: resolved.copilotHome,
copilotHome: auth.copilotHome,
},
options: {
copilotHome: resolved.copilotHome,
gitHubToken: resolved.authMode === "gitHubToken" ? resolved.gitHubToken : undefined,
useLoggedInUser: resolved.authMode === "useLoggedInUser",
copilotHome: auth.copilotHome,
...(auth.authMode === "gitHubToken" && auth.gitHubToken
? { gitHubToken: auth.gitHubToken }
: {}),
useLoggedInUser: auth.authMode === "useLoggedInUser",
},
auth: resolved,
auth,
provider,
};
}

View File

@@ -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;

View 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();
}
});
});

View 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;
}

View File

@@ -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", () => {

View File

@@ -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 {

View 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);
});
});

View 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;
}

View File

@@ -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;
}

View File

@@ -1,11 +1,17 @@
// Copilot tests cover tool bridge plugin behavior.
import type { Tool as SdkTool, ToolInvocation, ToolResultObject } from "@github/copilot-sdk";
import type { AnyAgentTool, SandboxContext } from "openclaw/plugin-sdk/agent-harness-runtime";
import {
initializeGlobalHookRunner,
resetGlobalHookRunner,
} from "openclaw/plugin-sdk/hook-runtime";
import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createCopilotToolBridge,
convertOpenClawToolToSdkTool,
supportsModelTools,
testing,
} from "./tool-bridge.js";
type FakeTool = AnyAgentTool & {
@@ -77,6 +83,7 @@ function runSdkTool(tool: SdkTool, args: unknown, invocation = makeInvocation())
}
afterEach(() => {
resetGlobalHookRunner();
vi.restoreAllMocks();
});
@@ -107,6 +114,29 @@ describe("createCopilotToolBridge", () => {
expect(createOpenClawCodingTools).toHaveBeenCalledTimes(0);
});
it("allows vetted BYOK providers to expose model tools", async () => {
const sourceTools = [makeTool()];
const createOpenClawCodingTools = vi.fn(async () => sourceTools);
const result = await createCopilotToolBridge({
agentId: "agent-1",
allowModelTools: true,
createOpenClawCodingTools,
modelId: "gpt-test",
modelProvider: "custom-openai",
sessionId: "session-1",
});
expect(createOpenClawCodingTools).toHaveBeenCalledWith(
expect.objectContaining({
modelId: "gpt-test",
modelProvider: "custom-openai",
}),
);
expect(result.sourceTools).toEqual(sourceTools);
expect(result.sdkTools.map((tool) => tool.name)).toEqual(["tool-a"]);
});
it("forwards supported fields to injected createOpenClawCodingTools", async () => {
const controller = new AbortController();
const createOpenClawCodingTools = vi.fn(async () => [makeTool()]);
@@ -286,6 +316,79 @@ describe("createCopilotToolBridge", () => {
expect(result.sdkTools.map((tool) => tool.name)).toEqual(["exec", "wait"]);
});
it("runs requester-aware policy before code-mode exec controls", async () => {
const beforeToolCall = vi.fn(() => ({
block: true,
blockReason: "blocked before code-mode execution",
}));
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "before_tool_call", handler: beforeToolCall }]),
);
const createOpenClawCodingTools = vi.fn(async () => [makeTool({ name: "read" })]);
const result = await createCopilotToolBridge({
agentId: "agent-1",
attemptParams: {
config: { tools: { codeMode: true } },
runId: "run-code-mode",
sessionId: "session-1",
sessionKey: "agent:main:main",
jobId: "job-1",
trigger: "user",
messageChannel: "slack",
messageProvider: "slack-voice",
currentChannelId: "slack:C123",
senderId: "U123",
channelContext: { sender: { id: "U123", displayName: "Ada" } },
} as never,
createOpenClawCodingTools,
modelId: "gpt-4o",
modelProvider: "github-copilot",
sessionId: "session-1",
});
const exec = result.sdkTools.find((tool) => tool.name === "exec");
if (!exec) {
throw new Error("missing code-mode exec control");
}
await runSdkTool(
exec,
{ code: "return 1;" },
makeInvocation({ toolCallId: "code-call-1", toolName: "exec" }),
);
expect(beforeToolCall).toHaveBeenCalledTimes(1);
expect(beforeToolCall).toHaveBeenCalledWith(
{
toolName: "exec",
params: { code: "return 1;", command: "return 1;" },
toolKind: "code_mode_exec",
toolInputKind: "javascript",
runId: "run-code-mode",
toolCallId: "code-call-1",
},
{
toolName: "exec",
toolKind: "code_mode_exec",
toolInputKind: "javascript",
agentId: "agent-1",
sessionKey: "agent:main:main",
sessionId: "session-1",
runId: "run-code-mode",
jobId: "job-1",
trigger: "user",
messageProvider: "slack-voice",
channel: "slack",
senderId: "U123",
toolCallId: "code-call-1",
channelId: "C123",
channelContext: {
sender: { id: "U123", displayName: "Ada" },
},
},
);
});
it("keeps code-mode controls visible when a narrow allowlist is active", async () => {
const createOpenClawCodingTools = vi.fn(async () => [
makeTool({ name: "fake_hidden" }),
@@ -420,7 +523,13 @@ describe("createCopilotToolBridge", () => {
currentMessagingTarget: "user:U123",
currentThreadTs: "1700000000.000100",
currentMessageId: "M-1",
messageProvider: "slack",
messageChannel: "slack",
messageProvider: "slack-voice",
chatId: "chat-1",
channelContext: {
sender: { id: "sender-1", displayName: "Ada" },
chat: { id: "chat-1", kind: "channel" },
},
messageTo: "U-1",
messageThreadId: "1700000000.000100",
replyToMode: "first",
@@ -454,7 +563,13 @@ describe("createCopilotToolBridge", () => {
currentMessagingTarget: "user:U123",
currentThreadTs: "1700000000.000100",
currentMessageId: "M-1",
messageProvider: "slack",
messageChannel: "slack",
messageProvider: "slack-voice",
chatId: "chat-1",
hookChannelContext: {
sender: { id: "sender-1", displayName: "Ada" },
chat: { id: "chat-1", kind: "channel" },
},
messageTo: "U-1",
messageThreadId: "1700000000.000100",
replyToMode: "first",
@@ -462,6 +577,7 @@ describe("createCopilotToolBridge", () => {
forceMessageTool: true,
enableHeartbeatTool: true,
});
expect(opts.channelContext).toBeUndefined();
});
it("falls back messageProvider to attemptParams.messageChannel when messageProvider is absent (codex parity)", async () => {
@@ -479,6 +595,63 @@ describe("createCopilotToolBridge", () => {
expect(getOpts().messageProvider).toBe("telegram");
});
it("uses messageTo when currentMessagingTarget is absent in tool hook routing", () => {
const context = testing.buildCopilotToolHookContext({
agentId: "agent-1",
messageChannel: "slack",
messageProvider: "slack",
messageTo: "user:U-only",
trigger: "user",
});
expect(context).toMatchObject({
channel: "slack",
messageProvider: "slack",
channelId: "U-only",
turnSourceChannel: "slack",
turnSourceTo: "user:U-only",
});
expect(context.chatId).toBeUndefined();
expect(context.channelContext).toBeUndefined();
});
it("resolves per-agent loop detection overrides for generated code-mode controls", () => {
const context = testing.buildCopilotToolHookContext({
agentId: "agent-1",
config: {
tools: {
loopDetection: {
enabled: true,
warningThreshold: 7,
detectors: { genericRepeat: true },
postCompactionGuard: { windowSize: 4 },
},
},
agents: {
list: [
{
id: "agent-1",
tools: {
loopDetection: {
enabled: false,
detectors: { pingPong: false },
postCompactionGuard: { windowSize: 2 },
},
},
},
],
},
},
});
expect(context.loopDetection).toEqual({
enabled: false,
warningThreshold: 7,
detectors: { genericRepeat: true, pingPong: false },
postCompactionGuard: { windowSize: 2 },
});
});
it("forwards authProfileStore, runId, config, and run hooks (onToolOutcome) from attemptParams", async () => {
const { createOpenClawCodingTools, getOpts } = captureCall();
const authProfileStore = { kind: "fake-store" } as never;

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