Compare commits

..

39 Commits

Author SHA1 Message Date
Peter Steinberger
ac505335e4 feat: add agent-scoped exec environments 2026-06-24 07:34:51 -07:00
狼哥
374076b5a8 fix(plugins): retain plugin tool registry after replacement (#82562)
Merged via squash.

Prepared head SHA: 1bcbbbfbc1
Co-authored-by: luoyanglang <238804951+luoyanglang@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-24 22:22:29 +08:00
杨浩宇0668001029
242fbf1a67 test(telegram): pass outbound sanitizer payload 2026-06-24 07:13:32 -07:00
杨浩宇0668001029
434d752dd6 fix(telegram): sanitize outbound tool traces 2026-06-24 07:13:32 -07:00
Ayaan Zaidi
3179692f0e fix(messages): apply response usage to followups 2026-06-24 07:12:33 -07:00
Peter Lindsey
6add1cc969 feat(messages): config-level default for the persistent /usage footer
Adds `messages.responseUsage` (precedence session -> channel -> config default
-> off) so the persistent /usage footer can default-on, with three distinct
states: explicit on (tokens/full), explicit off (persisted), and unset (inherit
the configured default).

Unifies effective-value resolution behind a single channel-aware resolver
`resolveEffectiveResponseUsage` used by reply rendering, the no-arg /usage
toggle, the ACP control, and the gateway session-row builder; the row builder's
`effectiveResponseUsage` is carried through sessions.changed events, chat
snapshots, and the UI row so live consumers never go stale. `/usage reset`
(aliases inherit/clear/default) clears the override to inherit; only explicit
off persists; a full session reset preserves the preference. ACP "Usage detail"
gains an "inherit" option for unset sessions. Docs/help/completions updated; "on"
documented as a legacy alias; config-doc baseline regenerated.
2026-06-24 07:12:33 -07:00
ly-wang19
cb13be375d fix(tasks): preserve both cron-run session key shapes during maintenance (#96352)
* fix(tasks): preserve both cron-run session key shapes during maintenance

Session-registry maintenance keeps running cron jobs' session rows, but
readRunningCronJobIds built the preserve-set with job.id.toLowerCase() only.
Cron-run session keys carry two job-segment shapes: main-session runs use the
slugified segment (normalizeCronLaneSegment, e.g. "daily-report") while
default-isolated runs use the raw lowercased id ("daily report", built from
cron:${job.id} via toAgentStoreSessionKey, which lowercases but does not
slugify). The lowercase-only matcher preserved isolated runs but pruned
main-session runs of any non-slug job id (e.g. "Daily Report") as stale.

Preserve both shapes (raw lowercased id and slugified segment). This is
strictly more-preserving, so no live running cron session is dropped. Adds a
regression test seeding both a slug main-session run and a raw isolated run for
a non-slug job id, asserting both survive while a non-running job's run is still
pruned.

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

* fix(tasks): match cron session keys to target shape

* fix(tasks): preserve active cron aliases across retargeting

* fix(tasks): retain explicit cron session aliases

---------

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 22:10:49 +08:00
Josh Lehman
acc2a0ee72 refactor: route boot session mapping through accessor (#96225) 2026-06-24 06:54:19 -07:00
Gio Della-Libera
704fc35043 Doctor: expose session lock findings (#84366)
Merged via squash.

Prepared head SHA: 93192bb7ab
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Reviewed-by: @giodl73-repo
2026-06-24 06:53:01 -07:00
Ayaan Zaidi
f1e38f2ed6 fix(telegram): narrow rich table alignment surface 2026-06-24 06:41:38 -07:00
zhang-guiping
d2933bbdb9 fix(telegram): refresh rich table SDK budget 2026-06-24 06:41:38 -07:00
张贵萍0668001030
2e124081af fix(telegram): preserve rich table styling 2026-06-24 06:41:38 -07:00
张贵萍0668001030
8150b76b6f fix(telegram): preserve rich table styling 2026-06-24 06:41:38 -07:00
张贵萍0668001030
77eb0fdbaa fix(telegram): preserve rich table styling 2026-06-24 06:41:38 -07:00
ly-wang19
f0be8e7b6e fix(duckduckgo): decode &amp; last in decodeHtmlEntities to avoid double-decoding (#96348)
* fix(duckduckgo): decode &amp; last in decodeHtmlEntities to avoid double-decoding

decodeHtmlEntities decoded &amp; FIRST, so result text that literally contains
an entity (e.g. a page title 'How to escape &lt; in HTML', which DuckDuckGo
returns double-encoded as '&amp;lt;') was re-decoded into markup: '&amp;lt;'
became '<' instead of the literal '&lt;', corrupting the titles, snippets, and
URLs the web-search tool returns to the model.

Reorder so &amp; is decoded last, matching the established convention elsewhere
in the codebase (msteams/inbound.ts, openai-transport-stream.ts,
launchd-plist.ts, doctor-session-snapshots.ts all decode &amp; last).
Behavior-preserving for all singly-encoded input.

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

* fix(duckduckgo): decode html entities in one pass

---------

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 21:35:27 +08:00
ly-wang19
80bd0003ce fix(msteams): decode &amp; last in stripHtmlFromTeamsMessage to avoid double-decoding (#96342)
stripHtmlFromTeamsMessage decoded &amp; FIRST, so literal entity text the
user typed (which Microsoft Graph returns double-encoded, e.g. &amp;lt;) got
re-decoded into markup: "The token is &amp;lt;APIKEY&amp;gt;" became
"The token is <APIKEY>" instead of the correct "The token is &lt;APIKEY&gt;".

Reorder so &amp; is decoded last, mirroring the documented ordering in
decodeHtmlEntities (inbound.ts), whose comment already states it 'must be last
to prevent double-decoding (e.g. &amp;lt; -> &lt; not <)'. Behavior-preserving
for all singly-encoded input; the existing entity test is unchanged.

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 21:34:40 +08:00
snowzlmbot
f3891e1335 fix(context-engine): avoid quarantining read-only discovery factories (#96357)
* fix(context-engine): ignore read-only discovery factories

* fix(context-engine): keep discovery registrations out of runtime probes

---------

Co-authored-by: snowzlmbot <snowzlmbot@users.noreply.github.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-24 21:33:49 +08:00
ly-wang19
bea3d292c7 fix(memory-core): keep short protected-glossary terms past the min-length gate (#96304)
PROTECTED_GLOSSARY exists to preserve short technical terms that generic
filtering would discard, but every glossary match still flowed through
normalizeConceptToken's per-script minimum-length gate. The 2-char latin
entries "kv" and "s3" were therefore never emitted as concept tags despite
being on the protect-list. Thread a fromGlossary flag so glossary matches
bypass only that length check; all other gates still apply.

Because that bypass lets short entries through, a bare substring match would
also surface them from inside longer words ("kv" in "mkv", "s3" in "css3").
Match ONLY the short entries (those below their script's min length) as
delimiter-bounded whole tokens; longer entries keep substring containment, so
the shipped behavior of "backup" tagging inside "backups" is preserved. CJK
entries (no word delimiters) always use substring matching. Positive
(standalone kv/s3) and negative (mkv/css3 substrings) regression tests cover
both directions, and the short-term-promotion stable-tags assertion gains "s3".

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 21:32:58 +08:00
Ayaan Zaidi
17066f2d7c fix(cron): preserve default toolsAllow markers safely 2026-06-24 06:26:52 -07:00
Cameron Beeley
9aea104cc8 fix(cron): stop stamping an unenforceable default toolsAllow cap on CLI runs
#91499 auto-stamps the creator's tool surface as a default toolsAllow cap
on agentTurn cron payloads whenever the creating session is tool-restricted
(a narrowing allow-policy or an explicit deny). CLI backends cannot enforce
a runtime toolsAllow — cli-runner/prepare.ts rejects any defined allow-list
— so every scheduled agentTurn that resolves to a CLI backend (e.g.
claude-cli) fails to start. This silently broke per-thread scheduled
continuations on CLI backends.

A CLI backend is not a runtime tool-policy boundary: it runs with its own
configured tool set, as the operator, on the local machine, and refuses a
runtime allow-list outright. An inherited default cap is therefore
unenforceable on a CLI backend. Decide at run time, where the backend is
known:

- Flag the default. capCronAgentTurnToolsAllow stamps toolsAllowIsDefault
  when it fills in the creator surface because the cron requested nothing
  (or a bare "*"). An explicit narrowing or empty allow-list is a real
  per-cron restriction and carries no flag.
- Drop only the default, only on CLI. The run-executor drops a flagged
  default in the CLI branch and lets the run proceed. An explicit per-cron
  restriction (no flag) is deliberately passed through, so prepare.ts still
  fails it closed and surfaces that the requested policy needs an embedded
  runtime. Embedded runs are untouched and keep the full cap enforced.
- Persist the flag. New nullable cron_jobs.payload_tools_allow_is_default
  column (additive ensureColumn migration + codec read/write) so the
  decision survives a gateway restart, plus toolsAllowIsDefault on the
  gateway-protocol agentTurn payload schema — the stamped payload is
  otherwise rejected by the contract's additionalProperties:false.
- Preserve the flag across updates. A no-toolsAllow update (reschedule,
  prompt edit) no longer carries the stored default forward as a literal
  value — that routed it through the explicit-narrowing branch, stripped the
  flag, and re-broke the job on CLI after the next restart. The default is
  re-derived (flag intact); an explicit restriction is still carried forward
  unflagged.

Net policy: on CLI only the unenforceable inherited default is relaxed;
explicit per-cron restrictions still fail closed; embedded backends are
unchanged.

Tests: run-executor drops the flagged default but propagates an explicit
restriction on CLI; cron-tool stamps/clears the flag across create and
update and preserves it across a no-toolsAllow update; store round-trips the
flag (and its absence) through SQLite.

Not covered: agentTurn crons created during the regression window carry a
flagless toolsAllow and remain fail-closed on CLI until recreated or updated
with an explicit toolsAllow.
2026-06-24 06:26:52 -07:00
Ayaan Zaidi
2aa9d67635 refactor(telegram): simplify rich email entity detection 2026-06-24 06:23:08 -07:00
Kelaw - Keshav's Agent
51eec3a757 fix(telegram): skip rich entity detection for oauth emails 2026-06-24 06:23:08 -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
208 changed files with 10966 additions and 961 deletions

View File

@@ -73,7 +73,7 @@ jobs:
- name: Create ClawSweeper dispatch token
id: token
if: ${{ env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true' }}
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ env.CLAWSWEEPER_APP_CLIENT_ID }}
private-key: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }}
@@ -102,7 +102,7 @@ jobs:
steps.comment_filter.outputs.is_command == 'true' &&
env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true'
}}
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ env.CLAWSWEEPER_APP_CLIENT_ID }}
private-key: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }}

View File

@@ -29,7 +29,7 @@ jobs:
submodules: false
- name: Setup Java
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
with:
distribution: temurin
java-version: "21"

View File

@@ -57,7 +57,7 @@ jobs:
- name: Create autoscrub app token
id: app-token
continue-on-error: true
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: "2729701"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
@@ -69,7 +69,7 @@ jobs:
id: app-token-fallback
continue-on-error: true
if: steps.app-token.outcome == 'failure'
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: "2971289"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}

View File

@@ -149,7 +149,7 @@ jobs:
- name: Run Codex docs agent
if: steps.gate.outputs.run_agent == 'true'
uses: openai/codex-action@10cb888d2ed3b99867f7e7ccff174a861a75aeb6
uses: openai/codex-action@e0fdf01220eb9a88167c4898839d273e3f2609d1
env:
DOCS_AGENT_BASE_SHA: ${{ steps.gate.outputs.review_base_sha }}
DOCS_AGENT_HEAD_SHA: ${{ steps.gate.outputs.review_head_sha }}

View File

@@ -445,7 +445,7 @@ jobs:
sudo chown -R codex:codex "$GITHUB_WORKSPACE"
- name: Run Codex Mantis Telegram agent
uses: openai/codex-action@10cb888d2ed3b99867f7e7ccff174a861a75aeb6
uses: openai/codex-action@e0fdf01220eb9a88167c4898839d273e3f2609d1
env:
BASELINE_REF: ${{ needs.resolve_request.outputs.baseline_ref }}
BASELINE_SHA: ${{ needs.validate_refs.outputs.baseline_revision }}

View File

@@ -275,7 +275,7 @@ jobs:
fi
- name: Run Codex maturity scorecard agent
uses: openai/codex-action@10cb888d2ed3b99867f7e7ccff174a861a75aeb6
uses: openai/codex-action@e0fdf01220eb9a88167c4898839d273e3f2609d1
env:
MATURITY_EVIDENCE_DIR: .artifacts/maturity-evidence
MATURITY_SCORES_PATH: qa/maturity-scores.yaml

View File

@@ -129,7 +129,7 @@ jobs:
- name: Run Codex test performance agent
if: steps.gate.outputs.run_agent == 'true'
uses: openai/codex-action@10cb888d2ed3b99867f7e7ccff174a861a75aeb6
uses: openai/codex-action@e0fdf01220eb9a88167c4898839d273e3f2609d1
with:
openai-api-key: ${{ secrets.OPENCLAW_TEST_PERF_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
prompt-file: .github/codex/prompts/test-performance-agent.md

View File

@@ -1,4 +1,4 @@
9246475f5771612a5fd12de38b153783c4a4cbb8b2682a5c40115916661c90f2 config-baseline.json
6349131baaa1828f2a071f42e4d7b17c8966c59b6588c8a4c1a32ea5ea4dcd5e config-baseline.core.json
1b953a19c347a27a0f9e856f23769b0c48d051354be4c88778c215231817fe8a config-baseline.json
f3fcfb358d8b8a1f0fa8676090339ff8df1b28ef6c7e80705a979a5c70e2a323 config-baseline.core.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
0418a175983d6e17f535ebb49d07371ceed57c7002f8991113d548f02b1d17d1 plugin-sdk-api-baseline.json
319e947cff12d9c2c5781b6f97f9b6b1c4f8a251dc1e87703c534a37614325cf plugin-sdk-api-baseline.jsonl

View File

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

View File

@@ -30,6 +30,68 @@ title: "Usage tracking"
- CLI: `openclaw channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).
- macOS menu bar: "Usage" section under Context (only if available).
## Default usage footer mode
`/usage off|tokens|full` sets the footer for a session and is remembered for that
session. `messages.responseUsage` seeds that mode for sessions that have not
chosen one, so the footer can be on by default without typing `/usage` each time.
Set one mode for every channel, or a per-channel map with a `default` fallback:
```jsonc
{
"messages": {
"responseUsage": "tokens",
// or: { "default": "off", "discord": "full" }
},
}
```
### Three distinct session states
A session's `responseUsage` field has three representable states, each with
different semantics:
| State | Stored value | Effective mode |
| ------------------- | ------------------------------- | --------------------------------------------------------------------- |
| **Unset / inherit** | `undefined` (absent) | Falls through to `messages.responseUsage` config default, then `off`. |
| **Explicit off** | `"off"` (stored) | Always off — a non-off config default cannot re-enable the footer. |
| **Explicit on** | `"tokens"` or `"full"` (stored) | That mode, regardless of config default. |
### Precedence
Effective mode = session override → channel config entry → `default``off`.
An explicit `/usage off` is **persisted** as the literal value `"off"` in the
session, not the same as "unset." This means a non-off `messages.responseUsage`
default cannot turn the footer back on once the user has explicitly disabled it.
### Resetting vs. turning off
- `/usage off` — forces the footer off and persists that choice. A configured
non-off default cannot override this.
- `/usage reset` (aliases: `inherit`, `clear`, `default`) — clears the session
override. The session then **inherits** the effective config default
(`messages.responseUsage`). If no default is configured, the footer is off
(unchanged from before). Use this to "go back to default" without explicitly
turning the footer on.
- A full session reset (`/reset` or `/new`) or a session rollover **preserves**
the explicit usage-mode preference so the user's display choice survives
session rollovers. Only `/usage reset` (and its aliases) actually clears the
override.
### Toggle behavior
`/usage` with no arguments cycles: off → tokens → full → off. The starting point
for the cycle is the **effective** current mode (session override falling through
to the config default when unset), so the cycle is always consistent with what
the user sees in the footer.
### Config
With no config the prior behavior holds (footer off until `/usage`). Use
`/usage reset` to clear a session override and re-inherit the configured default.
## Custom `/usage full` footer
`/usage full` shows a built-in compact footer with model, reasoning, fast/slow,

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

@@ -204,6 +204,55 @@ Controls elevated exec access outside the sandbox:
}
```
Agent entries can inject an environment only into their own `exec` child
processes. Use a SecretRef for credentials and set `inheritHostEnv: false` when the
Gateway process environment must not be inherited:
```json5
{
agents: {
list: [
{
id: "referrals",
tools: {
exec: {
inheritHostEnv: false,
env: {
GREENHOUSE_TOKEN: {
source: "env",
provider: "default",
id: "REFERRALS_GREENHOUSE_TOKEN",
},
},
},
},
},
],
},
}
```
`agents.list[].tools.exec.env` applies to `exec` only; it does not mutate
`process.env` or automatically inject credentials into model-provider or plugin
APIs. Trusted in-process plugin code can still inspect the materialized runtime
config, so this is not a plugin isolation boundary.
Configured values override same-named per-call values from the model. Trusted
`resolve_exec_env` hook output and channel context are applied afterward. Host
exec still rejects `PATH` and dangerous runtime/startup keys. Sandbox exec
already starts from a minimal environment. With `inheritHostEnv: false`,
Gateway exec also skips login-shell PATH discovery and cached shell-startup
state; configure `pathPrepend` or absolute commands when needed. For
`host: "node"`, configure scoped environment and inheritance isolation on the
node host. Both this map and `inheritHostEnv: false` are rejected because the
Gateway cannot clear the remote service environment or safely hold a scoped
credential back during remote approval preparation.
Treat this map as credential-bearing configuration: every command the agent can
run can read and exfiltrate these values, and command output can reveal them.
Plaintext values are reported by `openclaw secrets audit`; prefer SecretRefs.
Already-running background commands retain the environment captured when they
started after a config or secret reload.
### `tools.loopDetection`
Tool-loop safety checks are **disabled by default**. Set `enabled: true` to activate detection. Settings can be defined globally in `tools.loopDetection` and overridden per-agent at `agents.list[].tools.loopDetection`.

View File

@@ -525,6 +525,47 @@ the config fields that accept SecretRefs.
</Accordion>
</AccordionGroup>
## Per-agent exec environment variables
`agents.list[].tools.exec.env` supports SecretInput values, so a credential can
be resolved during Gateway activation and injected only into that agent's
`exec` child processes:
```json5
{
agents: {
list: [
{
id: "referrals",
tools: {
exec: {
inheritHostEnv: false,
env: {
GREENHOUSE_TOKEN: {
source: "env",
provider: "default",
id: "REFERRALS_GREENHOUSE_TOKEN",
},
},
},
},
},
],
},
}
```
This surface is exec-specific. It does not mutate the Gateway process
environment or automatically inject credentials into model-provider or plugin
APIs. Trusted in-process plugin code can inspect the materialized runtime
config. An unresolved active ref fails Gateway activation. SecretRefs are
materialized in the Gateway's protected in-memory config snapshot, so this
scopes subprocess injection rather than creating a same-process or same-OS-user
security boundary. Every command available to the agent can read these values,
command output can reveal them, and plaintext entries are reported by
`openclaw secrets audit`. Configure scoped environment on a node host itself;
agent exec env is rejected for `host: "node"`.
## MCP server environment variables
MCP server env vars configured via `plugins.entries.acpx.config.mcpServers` support SecretInput. This keeps API keys and tokens out of plaintext config:

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

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

@@ -37,6 +37,7 @@ Scope intent:
- `agents.defaults.memorySearch.remote.apiKey`
- `agents.list[].tts.providers.*.apiKey`
- `agents.list[].memorySearch.remote.apiKey`
- `agents.list[].tools.exec.env.*`
- `talk.providers.*.apiKey`
- `talk.realtime.providers.*.apiKey`
- `messages.tts.providers.*.apiKey`

View File

@@ -29,6 +29,13 @@
"secretShape": "secret_input",
"optIn": true
},
{
"id": "agents.list[].tools.exec.env.*",
"configFile": "openclaw.json",
"path": "agents.list[].tools.exec.env.*",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "agents.list[].tts.providers.*.apiKey",
"configFile": "openclaw.json",

View File

@@ -76,6 +76,8 @@ Use these in chat:
configured for the active model.
- `/usage off|tokens|full` → appends a **per-response usage footer** to every reply.
- Persists per session (stored as `responseUsage`).
- `/usage reset` (aliases: `inherit`, `clear`, `default`) — clears the session
override so the session re-inherits the configured default.
- `/usage full` shows estimated cost only when OpenClaw has usage metadata and
local pricing for the active model. Otherwise it shows tokens only.
- `/usage cost` → shows a local cost summary from OpenClaw session logs.

View File

@@ -22,7 +22,8 @@ Working directory for the command.
</ParamField>
<ParamField path="env" type="object">
Key/value environment overrides merged on top of the inherited environment.
Key/value environment overrides. Per-agent configured values are applied after
these model-supplied values.
</ParamField>
<ParamField path="yieldMs" type="number" default="10000">
@@ -89,6 +90,7 @@ Notes:
`$OPENCLAW_STATE_DIR/cache/shell-snapshots/`, then sources that snapshot before each exec command.
Secret-looking variables are excluded; sandbox and node exec do not use this snapshot. Set
`OPENCLAW_EXEC_SHELL_SNAPSHOT=0` in the Gateway process environment to disable this snapshot path.
Per-agent `tools.exec.inheritHostEnv: false` also disables it.
- Host execution (`gateway`/`node`) rejects `env.PATH` and loader overrides (`LD_*`/`DYLD_*`) to
prevent binary hijacking or injected code.
- OpenClaw sets `OPENCLAW_SHELL=exec` in the spawned command environment (including PTY and sandbox execution) so shell/profile rules can detect exec-tool context.
@@ -113,6 +115,8 @@ Notes:
- `tools.exec.notifyOnExit` (default: true): when true, backgrounded exec sessions enqueue a system event and request a heartbeat on exit.
- `tools.exec.approvalRunningNoticeMs` (default: 10000): emit a single "running" notice when an approval-gated exec runs longer than this (0 disables).
- `tools.exec.timeoutSec` (default: 1800): default per-command exec timeout in seconds. Per-call `timeout` overrides it; per-call `timeout: 0` disables the exec process timeout.
- `agents.list[].tools.exec.env`: credential-oriented environment values injected only into that agent's gateway/sandbox exec children. Values support SecretRefs; node-host exec rejects this map.
- `agents.list[].tools.exec.inheritHostEnv` (default: true): set false to omit the Gateway process environment and shell-startup snapshot from Gateway-hosted exec. This is rejected for `host=node`; sandbox exec is already minimal.
- `tools.exec.host` (default: `auto`; resolves to `sandbox` when sandbox runtime is active, `gateway` otherwise)
- `tools.exec.security` (default: `deny` for sandbox, `full` for gateway + node when unset)
- `tools.exec.ask` (default: `off`)
@@ -141,7 +145,9 @@ Example:
### PATH handling
- `host=gateway`: merges your login-shell `PATH` into the exec environment. `env.PATH` overrides are
- `host=gateway`: normally merges your login-shell `PATH` into the exec environment. With
`agents.list[].tools.exec.inheritHostEnv: false`, this merge is skipped; use an absolute command or
`tools.exec.pathPrepend`. `env.PATH` overrides are
rejected for host execution. The daemon itself still runs with a minimal `PATH`:
- macOS: `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`, `/bin`
- Linux: `/usr/local/bin`, `/usr/bin`, `/bin`

View File

@@ -240,7 +240,7 @@ plugins.
| `/tasks` | List active/recent background tasks for the current session |
| `/context [list\|detail\|map\|json]` | Explain how context is assembled |
| `/whoami` | Show your sender id. Alias: `/id` |
| `/usage off\|tokens\|full\|cost` | Control the per-response usage footer or print a local cost summary |
| `/usage off\|tokens\|full\|reset\|cost` | Control the per-response usage footer (`reset`/`inherit`/`clear`/`default` clears the session override to re-inherit the configured default) or print a local cost summary |
</Accordion>
<Accordion title="Skills, allowlists, approvals">

View File

@@ -126,7 +126,7 @@ Session controls:
- `/verbose <on|full|off>`
- `/trace <on|off>`
- `/reasoning <on|off|stream>`
- `/usage <off|tokens|full>`
- `/usage <off|tokens|full|reset>` (`reset`/`inherit`/`clear`/`default` clears the session override)
- `/goal [status] | /goal start <objective> | /goal pause|resume|complete|block|clear`
- `/elevated <on|off|ask|full>` (alias: `/elev`)
- `/activation <mention|always>`

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

@@ -5,6 +5,7 @@ import path from "node:path";
import type { CopilotClient, Tool as SdkTool } from "@github/copilot-sdk";
import {
abortAgentHarnessRun,
attachModelProviderRequestTransport,
queueAgentHarnessMessage,
type AgentHarnessAttemptParams,
type AgentHarnessAttemptResult,
@@ -104,11 +105,12 @@ function createDeferred<T>() {
function flushAsync() {
// Pump enough microtasks for the attempt to settle past every
// pre-createSession `await` in attempt.ts (resolvePoolAcquire,
// resolveCopilotWorkspaceBootstrapContext, createSession, etc.).
// BYOK proxy setup, resolveCopilotWorkspaceBootstrapContext,
// createSession, etc.).
// Each chained `then` is one tick; tests rely on this to observe
// `sdk.sessions[0]` being populated before they emit deltas.
const tick = () => Promise.resolve();
return tick().then(tick).then(tick);
return tick().then(tick).then(tick).then(tick).then(tick);
}
function waitForEventLoopTurn(): Promise<void> {
@@ -2338,6 +2340,152 @@ describe("runCopilotAttempt", () => {
expect(options.useLoggedInUser).toBe(false);
});
it("pool keying: BYOK does not resolve unrelated GitHub auth", async () => {
const sdk = makeFakeSdk();
const pool = makeFakePool(sdk);
await runCopilotAttempt(
makeParams({
auth: { gitHubToken: "unrelated-token" } as never,
model: {
api: "openai-responses",
baseUrl: "https://api.example.test/v1",
id: "gpt-test",
provider: "custom-openai",
} as never,
resolvedApiKey: "byok-token",
authProfileId: "custom-openai:main",
} as never),
{ pool },
);
const key = (vi.mocked(pool["acquire"]).mock.calls[0] as unknown[] | undefined)?.[0] as {
authMode: string;
authProfileId?: string;
};
const options = (vi.mocked(pool["acquire"]).mock.calls[0] as unknown[] | undefined)?.[1] as {
gitHubToken?: string;
useLoggedInUser?: boolean;
};
const cfg = (sdk.createSession.mock.calls[0] as unknown[] | undefined)?.[0] as {
provider?: { apiKey?: string; baseUrl?: string };
};
expect(key.authMode).toBe("byok");
expect(key.authProfileId).toBe("custom-openai:main");
expect(options.gitHubToken).toBeUndefined();
expect(options.useLoggedInUser).toBe(false);
expect(cfg.provider).toEqual(
expect.objectContaining({
apiKey: "byok-token",
baseUrl: expect.stringMatching(/^http:\/\/127\.0\.0\.1:\d+\/[a-f0-9]{24}\/v1$/),
}),
);
});
it("forwards BYOK provider headers on the model request turn", async () => {
const sdk = makeFakeSdk();
const pool = makeFakePool(sdk);
await runCopilotAttempt(
makeParams({
model: {
api: "anthropic-messages",
baseUrl: "https://anthropic.example.test",
headers: {
"X-Tenant": "tenant-a",
"X-Trace": "trace-1",
},
id: "claude-test",
provider: "anthropic-proxy",
} as never,
resolvedApiKey: "byok-token",
authProfileId: "anthropic-proxy:main",
} as never),
{ pool },
);
const cfg = (sdk.createSession.mock.calls[0] as unknown[] | undefined)?.[0] as {
provider?: { headers?: Record<string, string> };
};
const sendOptions = sdk.sessions[0]?.sendAndWait.mock.calls[0]?.[0] as {
requestHeaders?: Record<string, string>;
};
expect(cfg.provider?.headers).toEqual({
"X-Tenant": "tenant-a",
"X-Trace": "trace-1",
});
expect(sendOptions.requestHeaders).toEqual({
"X-Tenant": "tenant-a",
"X-Trace": "trace-1",
});
});
it("preserves prepared BYOK header-auth without synthesizing SDK apiKey auth", async () => {
const sdk = makeFakeSdk();
const pool = makeFakePool(sdk);
const model = attachModelProviderRequestTransport(
{
api: "openai-responses",
baseUrl: "https://proxy.example.test/v1",
headers: { "x-api-key": "header-secret" },
id: "gpt-test",
provider: "custom-header-proxy",
},
{ auth: { mode: "header", headerName: "x-api-key", value: "header-secret" } },
);
await runCopilotAttempt(
makeParams({
model: model as never,
resolvedApiKey: "header-secret",
authProfileId: "custom-header-proxy:main",
} as never),
{ pool },
);
const cfg = (sdk.createSession.mock.calls[0] as unknown[] | undefined)?.[0] as {
provider?: { apiKey?: string; headers?: Record<string, string> };
};
const sendOptions = sdk.sessions[0]?.sendAndWait.mock.calls[0]?.[0] as {
requestHeaders?: Record<string, string>;
};
expect(cfg.provider).toEqual(
expect.objectContaining({
headers: { "x-api-key": "header-secret" },
}),
);
expect(cfg.provider).not.toHaveProperty("apiKey");
expect(sendOptions.requestHeaders).toEqual({ "x-api-key": "header-secret" });
});
it("rejects BYOK providers with request transport policy overrides before creating a SDK session", async () => {
const sdk = makeFakeSdk();
const pool = makeFakePool(sdk);
const model = attachModelProviderRequestTransport(
{
api: "openai-responses",
baseUrl: "https://proxy.example.test/v1",
id: "gpt-test",
provider: "custom-header-proxy",
},
{ proxy: { mode: "env-proxy" } },
);
const result = await runCopilotAttempt(
makeParams({
model: model as never,
resolvedApiKey: "header-secret",
authProfileId: "custom-header-proxy:main",
} as never),
{ pool },
);
expect(getPromptErrorCode(result)).toBe("model_not_supported");
expect((result.promptError as Error | undefined)?.message).toContain("request proxy");
expect(sdk.createSession).not.toHaveBeenCalled();
});
describe("session-level gitHubToken (independent of client-level)", () => {
// The SDK contract (@github/copilot-sdk/dist/types.d.ts:1168-1178)
// makes `SessionConfig.gitHubToken` independent of the client-level
@@ -2401,6 +2549,37 @@ describe("runCopilotAttempt", () => {
expect(resumeCfg.gitHubToken).toBe("contract-token-resume");
});
it("BYOK provider config is forwarded to resumeSession", async () => {
const sdk = makeFakeSdk();
const pool = makeFakePool(sdk);
await runCopilotAttempt(
makeParams({
auth: { gitHubToken: "unrelated-token" } as never,
model: {
api: "openai-responses",
baseUrl: "https://api.example.test/v1",
id: "gpt-test",
provider: "custom-openai",
} as never,
resolvedApiKey: "byok-token",
authProfileId: "custom-openai:main",
initialReplayState: { sdkSessionId: "resume-target" } as never,
} as never),
{ pool },
);
const resumeCfg = sdk.resumeSession.mock.calls[0]?.[1] as {
provider?: { apiKey?: string; baseUrl?: string };
};
expect(resumeCfg.provider).toEqual(
expect.objectContaining({
apiKey: "byok-token",
baseUrl: expect.stringMatching(/^http:\/\/127\.0\.0\.1:\d+\/[a-f0-9]{24}\/v1$/),
}),
);
});
it("SessionConfig.gitHubToken is omitted when useLoggedInUser is the resolved mode", async () => {
const sdk = makeFakeSdk();
const pool = makeFakePool(sdk);

View File

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

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

@@ -107,6 +107,29 @@ describe("createCopilotToolBridge", () => {
expect(createOpenClawCodingTools).toHaveBeenCalledTimes(0);
});
it("allows vetted BYOK providers to expose model tools", async () => {
const sourceTools = [makeTool()];
const createOpenClawCodingTools = vi.fn(async () => sourceTools);
const result = await createCopilotToolBridge({
agentId: "agent-1",
allowModelTools: true,
createOpenClawCodingTools,
modelId: "gpt-test",
modelProvider: "custom-openai",
sessionId: "session-1",
});
expect(createOpenClawCodingTools).toHaveBeenCalledWith(
expect.objectContaining({
modelId: "gpt-test",
modelProvider: "custom-openai",
}),
);
expect(result.sourceTools).toEqual(sourceTools);
expect(result.sdkTools.map((tool) => tool.name)).toEqual(["tool-a"]);
});
it("forwards supported fields to injected createOpenClawCodingTools", async () => {
const controller = new AbortController();
const createOpenClawCodingTools = vi.fn(async () => [makeTool()]);

View File

@@ -69,6 +69,7 @@ export type CopilotToolCompletion = {
};
export interface CopilotToolBridgeInput {
allowModelTools?: boolean;
modelProvider: string;
modelId: string;
agentId: string;
@@ -151,7 +152,7 @@ export function supportsModelTools(modelProvider: string): boolean {
export async function createCopilotToolBridge(
input: CopilotToolBridgeInput,
): Promise<CopilotToolBridge> {
if (!supportsModelTools(input.modelProvider)) {
if (!input.allowModelTools && !supportsModelTools(input.modelProvider)) {
return { sdkTools: [], sourceTools: [] };
}

View File

@@ -36,21 +36,49 @@ type DuckDuckGoResult = {
};
function decodeHtmlEntities(text: string): string {
return text
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&#39;/g, "'")
.replace(/&#x27;/g, "'")
.replace(/&#x2F;/g, "/")
.replace(/&nbsp;/g, " ")
.replace(/&ndash;/g, "-")
.replace(/&mdash;/g, "--")
.replace(/&hellip;/g, "...")
.replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
.replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(Number.parseInt(code, 16)));
return text.replace(
/&(?:lt|gt|quot|apos|#39|#x27|#x2F|nbsp|ndash|mdash|hellip|amp|#\d+|#x[0-9a-f]+);/gi,
(entity) => {
const normalized = entity.toLowerCase();
if (normalized === "&lt;") {
return "<";
}
if (normalized === "&gt;") {
return ">";
}
if (normalized === "&quot;") {
return '"';
}
if (normalized === "&apos;" || normalized === "&#39;" || normalized === "&#x27;") {
return "'";
}
if (normalized === "&#x2f;") {
return "/";
}
if (normalized === "&nbsp;") {
return " ";
}
if (normalized === "&ndash;") {
return "-";
}
if (normalized === "&mdash;") {
return "--";
}
if (normalized === "&hellip;") {
return "...";
}
if (normalized === "&amp;") {
return "&";
}
if (normalized.startsWith("&#x")) {
return String.fromCodePoint(Number.parseInt(normalized.slice(3, -1), 16));
}
if (normalized.startsWith("&#")) {
return String.fromCodePoint(Number.parseInt(normalized.slice(2, -1), 10));
}
return entity;
},
);
}
function stripHtml(html: string): string {

View File

@@ -186,6 +186,17 @@ describe("duckduckgo web search provider", () => {
);
});
it("does not double-decode escaped entities (decodes &amp; last)", () => {
// A result whose text literally shows "&lt;" arrives double-encoded as
// "&amp;lt;". Decoding &amp; first would re-decode it into "<", corrupting
// the snippet; &amp; must be decoded last.
expect(ddgClientTesting.decodeHtmlEntities("How to escape &amp;lt; in HTML")).toBe(
"How to escape &lt; in HTML",
);
expect(ddgClientTesting.decodeHtmlEntities("a&amp;#39;b")).toBe("a&#39;b");
expect(ddgClientTesting.decodeHtmlEntities("a&#x26;amp;b")).toBe("a&amp;b");
});
it("parses results when href appears before class", () => {
const html = `
<a href="https://duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com" class="result__a">

View File

@@ -3,6 +3,7 @@ import { createSubsystemLogger } from "openclaw/plugin-sdk/logging-core";
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import {
readProviderJsonArrayFieldResponse,
readProviderJsonResponse,
readResponseTextLimited,
} from "openclaw/plugin-sdk/provider-http";
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared";
@@ -285,12 +286,13 @@ export async function ensureLmstudioModelLoaded(params: {
`LM Studio model load failed (${response.status})${body ? `: ${body}` : ""}`,
);
}
let payload: LmstudioLoadResponse;
try {
payload = (await response.json()) as LmstudioLoadResponse;
} catch (cause) {
throw new Error("LM Studio model load returned malformed JSON", { cause });
}
// Read the success body through the shared byte-capped reader so a misbehaving
// or compromised LM Studio server cannot stream an unbounded JSON payload into
// memory before we parse it. Malformed JSON is wrapped with our own label.
const payload = await readProviderJsonResponse<LmstudioLoadResponse>(
response,
"LM Studio model load",
);
if (typeof payload.status === "string" && payload.status.toLowerCase() !== "loaded") {
throw new Error(`LM Studio model load returned unexpected status: ${payload.status}`);
}

View File

@@ -582,7 +582,53 @@ describe("lmstudio-models", () => {
baseUrl: "http://localhost:1234/v1",
modelKey: "qwen3-8b-instruct",
}),
).rejects.toThrow("LM Studio model load returned malformed JSON");
).rejects.toThrow("LM Studio model load: malformed JSON response");
});
it("bounds oversized model load success bodies", async () => {
// A misbehaving server may stream an unbounded success JSON body; the load
// path must stop reading at the byte cap instead of buffering it all.
let canceled = false;
let bytesEmitted = 0;
const oversizedStream = new ReadableStream<Uint8Array>({
pull(controller) {
// Far exceeds the 16 MiB provider JSON cap if read to completion.
if (bytesEmitted >= 32 * 1024 * 1024) {
controller.close();
return;
}
bytesEmitted += 64 * 1024;
controller.enqueue(new Uint8Array(64 * 1024).fill(0x61));
},
cancel() {
canceled = true;
},
});
const fetchMock = vi.fn(async (url: string | URL) => {
if (String(url).endsWith("/api/v1/models")) {
return jsonResponse({
models: [{ type: "llm", key: "qwen3-8b-instruct", loaded_instances: [] }],
});
}
if (String(url).endsWith("/api/v1/models/load")) {
return new Response(oversizedStream, {
status: 200,
headers: { "content-type": "application/json" },
});
}
throw new Error(`Unexpected fetch URL: ${String(url)}`);
});
vi.stubGlobal("fetch", asFetch(fetchMock));
const error = await ensureLmstudioModelLoaded({
baseUrl: "http://localhost:1234/v1",
modelKey: "qwen3-8b-instruct",
}).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toMatch(/JSON response exceeds \d+ bytes/);
expect(canceled).toBe(true);
expect(bytesEmitted).toBeLessThan(32 * 1024 * 1024);
});
it("bounds model load error bodies", async () => {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,30 @@ describe("concept vocabulary", () => {
expect(tags).not.toContain("2026-04-04.md");
});
it("preserves short protected-glossary terms past the latin minimum-length gate", () => {
const tags = deriveConceptTags({
path: "memory/2026-04-04.md",
snippet: "Store the session in kv and back up to s3 nightly.",
});
// "kv" and "s3" are 2-char latin glossary entries that the generic min-length-3 gate would drop.
expect(tags).toContain("kv");
expect(tags).toContain("s3");
});
it("does not surface short glossary terms that only appear inside longer words", () => {
const tags = deriveConceptTags({
path: "memory/2026-04-04.md",
snippet: "Played the mkv recording and tuned the css3 layout.",
});
// "kv"/"s3" are substrings of "mkv"/"css3"; whole-word matching must not emit them as tags.
expect(tags).not.toContain("kv");
expect(tags).not.toContain("s3");
expect(tags).toContain("mkv");
expect(tags).toContain("css3");
});
it("extracts protected and segmented CJK concept tags", () => {
const tags = deriveConceptTags({
path: "memory/2026-04-04.md",

View File

@@ -330,7 +330,7 @@ function isKanaOnlyToken(value: string): boolean {
);
}
function normalizeConceptToken(rawToken: string): string | null {
function normalizeConceptToken(rawToken: string, fromGlossary = false): string | null {
const normalized = normalizeLowercaseStringOrEmpty(
rawToken
.normalize("NFKC")
@@ -348,7 +348,9 @@ function normalizeConceptToken(rawToken: string): string | null {
return null;
}
const script = classifyConceptTagScript(normalized);
if (normalized.length < minimumTokenLengthForScript(script)) {
// Glossary entries are an explicit allowlist of short technical terms (e.g. "kv", "s3"); they
// bypass the per-script minimum length that would otherwise discard them.
if (!fromGlossary && normalized.length < minimumTokenLengthForScript(script)) {
return null;
}
if (isKanaOnlyToken(normalized) && normalized.length < 3) {
@@ -360,14 +362,43 @@ function normalizeConceptToken(rawToken: string): string | null {
return normalized;
}
// Only entries shorter than their script's minimum token length rely on the glossary bypass, and
// only those need whole-word matching so they don't fire inside longer words ("kv" in "mkv"). Longer
// entries keep substring containment (the shipped behavior, e.g. "backup" tagging inside "backups").
// Precomputed so derive() does not reclassify on every call.
const GLOSSARY_ENTRIES = PROTECTED_GLOSSARY.map((entry) => ({
entry,
wholeWord: entry.length < minimumTokenLengthForScript(classifyConceptTagScript(entry)),
}));
function isAlphanumericAt(source: string, index: number): boolean {
const ch = source[index];
return ch !== undefined && LETTER_OR_NUMBER_RE.test(ch);
}
// True when `entry` occurs as a delimiter-bounded token, not inside a longer word. Keeps short
// glossary entries like "kv"/"s3" from firing inside "mkv"/"css3" once they bypass the length gate.
function includesStandaloneTerm(source: string, entry: string): boolean {
let from = source.indexOf(entry);
while (from !== -1) {
if (!isAlphanumericAt(source, from - 1) && !isAlphanumericAt(source, from + entry.length)) {
return true;
}
from = source.indexOf(entry, from + 1);
}
return false;
}
function collectGlossaryMatches(source: string): string[] {
const normalizedSource = normalizeLowercaseStringOrEmpty(source.normalize("NFKC"));
const matches: string[] = [];
for (const entry of PROTECTED_GLOSSARY) {
if (!normalizedSource.includes(entry)) {
continue;
for (const { entry, wholeWord } of GLOSSARY_ENTRIES) {
const present = wholeWord
? includesStandaloneTerm(normalizedSource, entry)
: normalizedSource.includes(entry);
if (present) {
matches.push(entry);
}
matches.push(entry);
}
return matches;
}
@@ -385,8 +416,13 @@ function collectSegmentTokens(source: string): string[] {
return source.split(/[^\p{L}\p{N}]+/u).filter(Boolean);
}
function pushNormalizedTag(tags: string[], rawToken: string, limit: number): void {
const normalized = normalizeConceptToken(rawToken);
function pushNormalizedTag(
tags: string[],
rawToken: string,
limit: number,
fromGlossary = false,
): void {
const normalized = normalizeConceptToken(rawToken, fromGlossary);
if (!normalized || tags.includes(normalized)) {
return;
}
@@ -410,14 +446,17 @@ export function deriveConceptTags(params: {
}
const tags: string[] = [];
for (const rawToken of [
...collectGlossaryMatches(source),
...collectCompoundTokens(source),
...collectSegmentTokens(source),
]) {
pushNormalizedTag(tags, rawToken, limit);
if (tags.length >= limit) {
break;
const tokenSources: Array<{ tokens: string[]; fromGlossary: boolean }> = [
{ tokens: collectGlossaryMatches(source), fromGlossary: true },
{ tokens: collectCompoundTokens(source), fromGlossary: false },
{ tokens: collectSegmentTokens(source), fromGlossary: false },
];
for (const { tokens, fromGlossary } of tokenSources) {
for (const rawToken of tokens) {
pushNormalizedTag(tags, rawToken, limit, fromGlossary);
if (tags.length >= limit) {
return tags;
}
}
}
return tags;

View File

@@ -3189,7 +3189,9 @@ describe("short-term promotion", () => {
path: "memory/2026-04-03.md",
snippet: "Move backups to S3 Glacier and sync QMD router notes.",
}),
).toStrictEqual(["backup", "backups", "glacier", "qmd", "router", "sync"]);
// "s3" is a protected-glossary term; it now surfaces as a standalone token past the
// per-script min-length gate (the longer terms still match as substrings).
).toStrictEqual(["backup", "backups", "glacier", "qmd", "router", "s3", "sync"]);
});
it("extracts multilingual concept tags across latin and cjk snippets", () => {

View File

@@ -37,6 +37,15 @@ describe("stripHtmlFromTeamsMessage", () => {
);
});
it("does not double-decode escaped entities (decodes &amp; last)", () => {
// Graph encodes literally-typed entity text by escaping its '&' to '&amp;'.
// Decoding '&amp;' first would re-decode the now-bare '&lt;'/'&gt;' into
// angle brackets, corrupting the user's literal text.
expect(stripHtmlFromTeamsMessage("The token is &amp;lt;APIKEY&amp;gt;")).toBe(
"The token is &lt;APIKEY&gt;",
);
});
it("normalizes multiple whitespace to single space", () => {
expect(stripHtmlFromTeamsMessage("hello world")).toBe("hello world");
});

View File

@@ -35,14 +35,16 @@ export function stripHtmlFromTeamsMessage(html: string): string {
let text = html.replace(/<at[^>]*>(.*?)<\/at>/gi, "@$1");
// Strip remaining HTML tags.
text = text.replace(/<[^>]*>/g, " ");
// Decode common HTML entities.
// Decode common HTML entities. &amp; must be decoded LAST to prevent
// double-decoding (e.g. &amp;lt; → &lt; not <), matching decodeHtmlEntities
// in inbound.ts.
text = text
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, " ");
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&");
// Normalize whitespace.
return text.replace(/\s+/g, " ").trim();
}

View File

@@ -1014,6 +1014,27 @@ describe("qa mock openai server", () => {
expect(firstPayload.output?.[0]?.call_id).not.toBe(secondPayload.output?.[0]?.call_id);
});
it("uses unique ids for repeated identical tool calls", async () => {
const server = await startMockServer();
const body = {
stream: false,
model: "gpt-5.5",
input: [makeUserInput("Read QA_KICKOFF_TASK.md, then answer with exactly QA-READ-OK.")],
};
const first = await expectResponsesJson<{ output?: Array<{ call_id?: string }> }>(server, body);
const second = await expectResponsesJson<{ output?: Array<{ call_id?: string }> }>(
server,
body,
);
const firstCallId = first.output?.[0]?.call_id;
const secondCallId = second.output?.[0]?.call_id;
expect(firstCallId).toMatch(/^call_mock_read_/);
expect(secondCallId).toMatch(/^call_mock_read_/);
expect(firstCallId).not.toBe(secondCallId);
});
it("continues repo-contract followthrough when a retry user item follows tool output", async () => {
const server = await startQaMockOpenAiServer({
host: "127.0.0.1",

View File

@@ -10,6 +10,8 @@ import { writeJson } from "../shared/http-json.js";
type ResponsesInputItem = Record<string, unknown>;
let mockFunctionCallSequence = 0;
type StreamEvent =
| { type: "response.output_item.added"; item: Record<string, unknown> }
| {
@@ -773,8 +775,10 @@ function buildMockFunctionCall(name: string, args: Record<string, unknown>) {
.update(serialized)
.digest("hex")
.slice(0, 10);
const callId = `call_mock_${name}_${callSuffix}`;
const itemId = `fc_mock_${name}_${callSuffix}`;
const sequence = ++mockFunctionCallSequence;
const uniqueSuffix = `${callSuffix}_${sequence}`;
const callId = `call_mock_${name}_${uniqueSuffix}`;
const itemId = `fc_mock_${name}_${uniqueSuffix}`;
const item = {
type: "function_call",
id: itemId,
@@ -786,7 +790,7 @@ function buildMockFunctionCall(name: string, args: Record<string, unknown>) {
callId,
item,
itemId,
responseId: `resp_mock_${name}_${callSuffix}`,
responseId: `resp_mock_${name}_${uniqueSuffix}`,
serialized,
};
}

View File

@@ -268,6 +268,115 @@ describe("runtime tool fixture", () => {
expect(details).toContain("read live provider failure planned args");
});
it("allows async live runtime tool fixtures to prove the happy path with the planned call", async () => {
const env = await makeEnv();
await writeQaSessionTranscript(env, "agent:qa:runtime-tool:image_generate:happy", [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "call-image-happy",
name: "image_generate",
input: { prompt: "QA lighthouse runtime parity fixture" },
},
],
},
]);
await writeQaSessionTranscript(env, "agent:qa:runtime-tool:image_generate:failure", [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "call-image-failure",
name: "image_generate",
input: { __qaFailureMode: "denied-input" },
},
],
},
{
role: "tool",
toolName: "image_generate",
tool_call_id: "call-image-failure",
isError: true,
content: "denied-input",
},
]);
const details = await runRuntimeToolFixture(
env,
{
toolName: "image_generate",
toolCoverage: {
bucket: "openclaw-dynamic-integration",
expectedLayer: "openclaw-dynamic",
},
happyPathOutputRequired: false,
},
{
createSession: vi.fn(async (_env, _label, key) => key!),
readEffectiveTools: vi.fn(async () => new Set(["image_generate"])),
runAgentPrompt: vi.fn(async () => ({})),
fetchJson: vi.fn(),
ensureImageGenerationConfigured: vi.fn(),
},
);
expect(details).toContain(
"image_generate live provider happy direct output not required for this async fixture",
);
expect(details).toContain("image_generate live provider failure planned args");
});
it("still requires async live runtime tool fixtures to call the happy-path tool", async () => {
const env = await makeEnv();
await writeQaSessionTranscript(env, "agent:qa:runtime-tool:image_generate:happy", [
{ role: "assistant", content: "I can start image generation later." },
]);
await writeQaSessionTranscript(env, "agent:qa:runtime-tool:image_generate:failure", [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "call-image-failure",
name: "image_generate",
input: { __qaFailureMode: "denied-input" },
},
],
},
{
role: "tool",
toolName: "image_generate",
tool_call_id: "call-image-failure",
isError: true,
content: "denied-input",
},
]);
await expect(
runRuntimeToolFixture(
env,
{
toolName: "image_generate",
toolCoverage: {
bucket: "openclaw-dynamic-integration",
expectedLayer: "openclaw-dynamic",
},
happyPathOutputRequired: false,
},
{
createSession: vi.fn(async (_env, _label, key) => key!),
readEffectiveTools: vi.fn(async () => new Set(["image_generate"])),
runAgentPrompt: vi.fn(async () => ({})),
fetchJson: vi.fn(),
ensureImageGenerationConfigured: vi.fn(),
},
),
).rejects.toThrow("expected live happy-path tool call for image_generate");
});
it("requires live failure fixtures to produce failure-shaped tool output", async () => {
const env = await makeEnv();
await writeQaSessionTranscript(env, "agent:qa:runtime-tool:read:happy", [
@@ -445,6 +554,70 @@ describe("runtime tool fixture", () => {
expect(details).toContain("mock provider happy planned args (diagnostic only)");
});
it("reports Codex-native async planned-only happy fixtures without dereferencing missing output", async () => {
const env = await makeEnv({
mock: { baseUrl: "http://127.0.0.1:9999" },
gateway: {
baseUrl: "http://127.0.0.1:1",
tempRoot: "",
workspaceDir: "",
runtimeEnv: { OPENCLAW_QA_FORCE_RUNTIME: "codex" },
call: vi.fn(),
},
});
env.gateway.tempRoot = env.repoRoot;
env.gateway.workspaceDir = env.repoRoot;
const fetchJson = vi
.fn()
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{
allInputText: "target=image_generate",
plannedToolCallId: "call-image-happy",
plannedToolName: "image_generate",
plannedToolArgs: { prompt: "QA lighthouse runtime parity fixture" },
},
{
allInputText: "failure target=image_generate",
plannedToolCallId: "call-image-failure",
plannedToolName: "image_generate",
plannedToolArgs: { __qaFailureMode: "denied-input" },
},
{
allInputText: "failure target=image_generate",
toolOutputCallId: "call-image-failure",
toolOutput: "Error: denied-input",
},
]);
const details = await runRuntimeToolFixture(
env,
{
toolName: "image_generate",
toolCoverage: {
bucket: "codex-native-workspace",
expectedLayer: "codex-native-workspace",
reason: "Codex owns image generation natively in this fixture.",
},
promptSnippet: "target=image_generate",
failurePromptSnippet: "failure target=image_generate",
happyPathOutputRequired: false,
},
{
createSession: vi.fn(async (_env, _label, key) => key!),
readEffectiveTools: vi.fn(async () => new Set<string>()),
runAgentPrompt: vi.fn(async () => ({})),
fetchJson,
ensureImageGenerationConfigured: vi.fn(),
},
);
expect(details).toContain("codex-native-workspace image_generate");
expect(details).toContain('"prompt":"QA lighthouse runtime parity fixture"');
expect(details).toContain('"__qaFailureMode":"denied-input"');
});
it("requires mock runtime tool fixtures to produce tool output", async () => {
const env = await makeEnv({
mock: { baseUrl: "http://127.0.0.1:9999" },
@@ -492,6 +665,61 @@ describe("runtime tool fixture", () => {
).rejects.toThrow("expected mock happy-path tool output for read");
});
it("allows async mock runtime tool fixtures to prove the happy path with the planned call", async () => {
const env = await makeEnv({
mock: { baseUrl: "http://127.0.0.1:9999" },
});
const fetchJson = vi
.fn()
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{
allInputText: "target=image_generate",
plannedToolCallId: "call-image-happy",
plannedToolName: "image_generate",
plannedToolArgs: { prompt: "QA lighthouse runtime parity fixture" },
},
{
allInputText: "failure target=image_generate",
plannedToolCallId: "call-image-failure",
plannedToolName: "image_generate",
plannedToolArgs: { __qaFailureMode: "denied-input" },
},
{
allInputText: "failure target=image_generate",
toolOutputCallId: "call-image-failure",
toolOutput: "Error: denied-input",
},
]);
const details = await runRuntimeToolFixture(
env,
{
toolName: "image_generate",
toolCoverage: {
bucket: "openclaw-dynamic-integration",
expectedLayer: "openclaw-dynamic",
},
promptSnippet: "target=image_generate",
failurePromptSnippet: "failure target=image_generate",
happyPathOutputRequired: false,
},
{
createSession: vi.fn(async (_env, _label, key) => key!),
readEffectiveTools: vi.fn(async () => new Set(["image_generate"])),
runAgentPrompt: vi.fn(async () => ({})),
fetchJson,
ensureImageGenerationConfigured: vi.fn(),
},
);
expect(details).toContain(
"image_generate mock provider happy direct output not required for this async fixture",
);
expect(details).toContain('"prompt":"QA lighthouse runtime parity fixture"');
expect(details).toContain('"__qaFailureMode":"denied-input"');
});
it("accepts mock runtime tool fixtures only after planned calls return output", async () => {
const env = await makeEnv({
mock: { baseUrl: "http://127.0.0.1:9999" },

View File

@@ -13,6 +13,7 @@ type QaRuntimeToolFixtureConfig = Record<string, unknown> & {
failurePrompt?: unknown;
promptSnippet?: unknown;
failurePromptSnippet?: unknown;
happyPathOutputRequired?: unknown;
ensureImageGeneration?: unknown;
expectedAvailable?: unknown;
toolCoverage?: unknown;
@@ -627,6 +628,7 @@ export async function runRuntimeToolFixture(
config.failurePromptSnippet,
`failure target=${toolName}`,
);
const happyPathOutputRequired = readBoolean(config.happyPathOutputRequired, true);
const requestCountBefore = env.mock
? readQaRuntimeToolFixtureRequests(await deps.fetchJson(`${env.mock.baseUrl}/debug/requests`))
.length
@@ -650,16 +652,22 @@ export async function runRuntimeToolFixture(
toolName,
});
if (!happyRequest.outputRequest) {
if (isKnownHarnessGap(config.knownHarnessGap)) {
return formatKnownHarnessGapDetails(toolName, config);
const happyPlannedOnly = happyRequest.plannedRequest && !happyPathOutputRequired;
if (happyPlannedOnly) {
// Async runtime tools prove the start call here; completion is covered
// by their task lifecycle scenarios.
} else {
if (isKnownHarnessGap(config.knownHarnessGap)) {
return formatKnownHarnessGapDetails(toolName, config);
}
throw new Error(
happyRequest.plannedRequest
? `expected live happy-path tool output for ${toolName}`
: `expected live happy-path tool call for ${toolName}`,
);
}
throw new Error(
happyRequest.plannedRequest
? `expected live happy-path tool output for ${toolName}`
: `expected live happy-path tool call for ${toolName}`,
);
}
if (happyRequest.outputRequest.structuredFailure) {
if (happyRequest.outputRequest?.structuredFailure) {
if (isKnownHarnessGap(config.knownHarnessGap)) {
return formatKnownHarnessGapDetails(toolName, config);
}
@@ -688,8 +696,13 @@ export async function runRuntimeToolFixture(
}
return [
`${toolName} live provider happy planned args (diagnostic only): ${JSON.stringify(happyRequest.plannedRequest?.args ?? {})}`,
happyPathOutputRequired
? undefined
: `${toolName} live provider happy direct output not required for this async fixture`,
`${toolName} live provider failure planned args (diagnostic only): ${JSON.stringify(failureRequest.plannedRequest?.args ?? {})}`,
].join("\n");
]
.filter(Boolean)
.join("\n");
}
const requests = readQaRuntimeToolFixtureRequests(
@@ -709,7 +722,10 @@ export async function runRuntimeToolFixture(
excludedPromptSnippet: failurePromptSnippet,
toolName,
});
if (!happyRequest) {
// Async runtime tools prove the start call here; completion is covered by
// their task lifecycle scenarios.
const happyPlannedOnly = Boolean(happyPlannedRequest && !happyPathOutputRequired);
if (!happyRequest && !happyPlannedOnly) {
if (dynamicExposureIntentionallyExcluded) {
return formatCodexNativeWorkspaceDetails({
toolName,
@@ -727,7 +743,7 @@ export async function runRuntimeToolFixture(
: `expected mock happy-path request for ${toolName}`,
);
}
if (requestHasHappyPathFailureToolOutput(happyRequest.outputRequest)) {
if (happyRequest && requestHasHappyPathFailureToolOutput(happyRequest.outputRequest)) {
if (isKnownHarnessGap(config.knownHarnessGap)) {
return formatKnownHarnessGapDetails(toolName, config);
}
@@ -776,13 +792,18 @@ export async function runRuntimeToolFixture(
toolName,
tools,
reason: metadata.reason,
happyRequest: happyRequest.plannedRequest,
happyRequest: happyRequest?.plannedRequest ?? happyPlannedRequest,
failureRequest: failureRequest.plannedRequest,
});
}
return [
`${toolName} mock provider happy planned args (diagnostic only): ${formatPlannedToolArgs(happyRequest.plannedRequest.plannedToolArgs)}`,
`${toolName} mock provider happy planned args (diagnostic only): ${formatPlannedToolArgs((happyRequest?.plannedRequest ?? happyPlannedRequest)?.plannedToolArgs)}`,
happyPathOutputRequired
? undefined
: `${toolName} mock provider happy direct output not required for this async fixture`,
`${toolName} mock provider failure planned args (diagnostic only): ${formatPlannedToolArgs(failureRequest.plannedRequest.plannedToolArgs)}`,
].join("\n");
]
.filter(Boolean)
.join("\n");
}

View File

@@ -190,6 +190,7 @@ describe("qa web runtime", () => {
await qaWebOpenPage({ url: "http://127.0.0.1:3000/chat", channel: "chrome" });
const launchOptions = requireLaunchOptions();
expect(spawnSync).not.toHaveBeenCalled();
expect(launchOptions?.channel).toBe("chrome");
expect(launchOptions?.executablePath).toBeUndefined();
await closeQaWebSessions();

View File

@@ -141,7 +141,9 @@ function buildChromiumLaunchOptions(params: QaWebOpenPageParams) {
export async function qaWebOpenPage(params: QaWebOpenPageParams) {
const timeoutMs = resolveTimeoutMs(params.timeoutMs);
ensureChromiumAvailable(params.repoRoot ?? process.cwd());
if (!params.channel) {
ensureChromiumAvailable(params.repoRoot ?? process.cwd());
}
const browser = await chromium.launch(buildChromiumLaunchOptions(params));
const context = await browser.newContext({
ignoreHTTPSErrors: true,

View File

@@ -0,0 +1,27 @@
// Slack tests cover truncate plugin behavior.
import { describe, expect, it } from "vitest";
import { truncateSlackText } from "./truncate.js";
describe("truncateSlackText", () => {
it("drops a surrogate-pair emoji whole when it straddles the limit", () => {
// "abc😀def": 😀 (U+1F600) sits at the cut point. Slicing by UTF-16 code unit
// would keep only its high surrogate — a lone \uD83D — before the ellipsis,
// which serializes to an invalid character in the Slack payload.
const out = truncateSlackText("abc😀def", 5);
expect(out).toBe("abc…");
// No dangling high surrogate (a high surrogate not followed by a low one).
expect(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(out)).toBe(false);
});
it("truncates plain BMP text unchanged", () => {
expect(truncateSlackText("hello world", 5)).toBe("hell…");
});
it("keeps an emoji that fits before the cut", () => {
expect(truncateSlackText("😀abcdef", 5)).toBe("😀ab…");
});
it("returns the trimmed input unchanged when it fits", () => {
expect(truncateSlackText("ab😀cd", 10)).toBe("ab😀cd");
});
});

View File

@@ -1,11 +1,16 @@
// Slack plugin module implements truncate behavior.
import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
export function truncateSlackText(value: string, max: number): string {
const trimmed = value.trim();
if (trimmed.length <= max) {
return trimmed;
}
// Slice on a code-point boundary so a surrogate pair (emoji / astral char)
// straddling the limit is dropped whole, instead of leaving a lone surrogate
// half that serializes to an invalid `\uD83D` in the Slack payload.
if (max <= 1) {
return trimmed.slice(0, max);
return sliceUtf16Safe(trimmed, 0, max);
}
return `${trimmed.slice(0, max - 1)}`;
return `${sliceUtf16Safe(trimmed, 0, max - 1)}`;
}

View File

@@ -712,7 +712,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
const preview = renderText?.("| A | B |\n| --- | --- |\n| 1 | 2 |");
expect(preview?.richMessage).toEqual(
expect.objectContaining({
html: expect.stringContaining("<table>"),
html: expect.stringContaining("<table bordered striped>"),
}),
);
});

View File

@@ -1239,6 +1239,33 @@ describe("deliverReplies", () => {
expect(mockCallArg(sendRichMessage, 1, 0)).not.toHaveProperty("reply_to_message_id");
});
it("skips rich entity detection for reply text with provider-prefixed email addresses", async () => {
const runtime = createRuntime();
const sendMessage = vi.fn().mockResolvedValue({
message_id: 11,
chat: { id: "123" },
});
const bot = createBot({ sendMessage });
const oauthProfileText =
"OAuth profile: openai:keshavbotagent@gmail.com (keshavbotagent@gmail.com)";
await deliverWith({
replies: [{ text: oauthProfileText }],
runtime,
bot,
richMessages: true,
});
const raw = bot.api.raw as unknown as {
sendRichMessage: ReturnType<typeof vi.fn>;
};
const richMessage = raw.sendRichMessage.mock.calls[0]?.[0]?.rich_message;
expect(richMessage).toEqual({
html: oauthProfileText,
skip_entity_detection: true,
});
});
it("uses legacy reply id when selected reply target differs from quote source", async () => {
const runtime = createRuntime();
const sendMessage = vi.fn().mockResolvedValue({

View File

@@ -690,6 +690,24 @@ describe("createTelegramDraftStream", () => {
expect(api.editMessageText).not.toHaveBeenCalled();
});
it("skips rich entity detection for draft text with provider-prefixed email addresses", async () => {
const api = createMockDraftApi();
const stream = createDraftStream(api, { richMessages: true });
const oauthProfileText =
"OAuth profile: openai:keshavbotagent@gmail.com (keshavbotagent@gmail.com)";
stream.update(oauthProfileText);
await stream.flush();
expect(api.raw.sendRichMessage).toHaveBeenCalledWith({
chat_id: 123,
rich_message: {
html: oauthProfileText,
skip_entity_detection: true,
},
});
});
it("keeps rich preview html out of plain preview gating", async () => {
const api = createMockDraftApi();
const stream = createDraftStream(api, { richMessages: true, minInitialChars: 10 });

View File

@@ -254,7 +254,7 @@ describe("markdownToTelegramHtml", () => {
`| ${Array.from({ length: columns }, (_, index) => String(index + 1)).join(" | ")} |`,
].join("\n");
expect(markdownToTelegramRichHtml(table(20))).toContain("<table>");
expect(markdownToTelegramRichHtml(table(20))).toContain("<table bordered striped>");
expect(markdownToTelegramRichHtml(table(21))).toContain("<pre><code>");
expect(markdownToTelegramRichHtml(table(2), { tableMode: "code" })).toContain("<pre><code>");
expect(markdownToTelegramRichHtml(table(2), { tableMode: "code" })).not.toContain("<table>");
@@ -295,6 +295,19 @@ describe("markdownToTelegramHtml", () => {
expect(html).toContain('<td><a href="https://example.com">docs</a></td>');
});
it("preserves markdown table column alignment in rich tables", () => {
const html = markdownToTelegramRichHtml(
"| Feature | Status | Count |\n| :--- | :---: | ---: |\n| Rich tables | Fixed | 2 |",
);
expect(html).toContain('<th align="left">Feature</th>');
expect(html).toContain('<th align="center">Status</th>');
expect(html).toContain('<th align="right">Count</th>');
expect(html).toContain('<td align="left">Rich tables</td>');
expect(html).toContain('<td align="center">Fixed</td>');
expect(html).toContain('<td align="right">2</td>');
});
it("does not auto-linkify bare URLs when entity detection is skipped", () => {
expect(markdownToTelegramRichHtml("https://example.com", { skipEntityDetection: true })).toBe(
"https://example.com",

View File

@@ -346,6 +346,8 @@ type TelegramHtmlTagSupport = {
attrPatterns: ReadonlyMap<string, RegExp>;
};
type TelegramTableAlignment = NonNullable<MarkdownTableMeta["aligns"]>[number];
const TELEGRAM_LEGACY_HTML_TAG_SUPPORT: TelegramHtmlTagSupport = {
simpleTags: TELEGRAM_SIMPLE_HTML_TAGS,
attrPatterns: TELEGRAM_ATTR_HTML_TAG_PATTERNS,
@@ -972,19 +974,25 @@ function renderTelegramRichHtmlTable(table: MarkdownTableMeta): string {
}
const renderCellValue = (cell: MarkdownTableCell | undefined) =>
cell ? renderTelegramHtml(cell) : "";
const renderCell = (tag: "td" | "th", value: MarkdownTableCell | undefined) =>
`<${tag}>${renderCellValue(value)}</${tag}>`;
const renderCell = (
tag: "td" | "th",
value: MarkdownTableCell | undefined,
align: TelegramTableAlignment | undefined,
) => {
const alignAttr = align ? ` align="${align}"` : "";
return `<${tag}${alignAttr}>${renderCellValue(value)}</${tag}>`;
};
const head = table.headers.length
? `<thead><tr>${table.headerCells.map((cell) => renderCell("th", cell)).join("")}</tr></thead>`
? `<thead><tr>${table.headerCells.map((cell, index) => renderCell("th", cell, table.aligns?.[index])).join("")}</tr></thead>`
: "";
const bodyRows = table.rowCells
.map(
(row) =>
`<tr>${Array.from({ length: columnCount }, (_value, index) => renderCell("td", row[index])).join("")}</tr>`,
`<tr>${Array.from({ length: columnCount }, (_value, index) => renderCell("td", row[index], table.aligns?.[index])).join("")}</tr>`,
)
.join("");
const body = bodyRows ? `<tbody>${bodyRows}</tbody>` : "";
return `<table>${head}${body}</table>\n\n`;
return `<table bordered striped>${head}${body}</table>\n\n`;
}
function renderTelegramRichHtmlDocument(

View File

@@ -2,6 +2,7 @@
import type { OutboundDeliveryFormattingOptions } from "openclaw/plugin-sdk/channel-outbound";
import {
resolveOutboundSendDep,
sanitizeForPlainText,
type OutboundSendDeps,
} from "openclaw/plugin-sdk/channel-outbound";
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-send-result";
@@ -19,6 +20,7 @@ import {
sendPayloadMediaSequenceOrFallback,
} from "openclaw/plugin-sdk/reply-payload";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking";
import type { TelegramInlineButtons } from "./button-types.js";
import { resolveTelegramInlineButtons } from "./button-types.js";
import { splitTelegramHtmlChunks } from "./format.js";
@@ -198,6 +200,7 @@ export function createTelegramOutboundAdapter(
chunkerMode: "markdown",
extractMarkdownImages: true,
textChunkLimit: TELEGRAM_TEXT_CHUNK_LIMIT,
sanitizeText: ({ text }) => sanitizeForPlainText(sanitizeAssistantVisibleText(text)),
shouldSuppressLocalPayloadPrompt: options.shouldSuppressLocalPayloadPrompt,
beforeDeliverPayload: options.beforeDeliverPayload,
shouldTreatDeliveredTextAsVisible: options.shouldTreatDeliveredTextAsVisible,

View File

@@ -96,6 +96,16 @@ type TelegramApiWithRichRaw = Bot["api"] & {
raw?: TelegramRichRawApi;
};
const TELEGRAM_RICH_EMAIL_TOKEN_RE =
/[A-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?(?:\.[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?)+/iu;
function shouldSkipTelegramRichEntityDetection(
text: string,
options?: Pick<TelegramRichMessageOptions, "skipEntityDetection">,
): boolean {
return options?.skipEntityDetection === true || TELEGRAM_RICH_EMAIL_TOKEN_RE.test(text);
}
export function getTelegramRichRawApi(api: Bot["api"]): TelegramRichRawApi {
const raw = (api as TelegramApiWithRichRaw).raw;
if (raw) {
@@ -164,7 +174,11 @@ export function buildTelegramRichMarkdown(
markdown: string,
options?: TelegramRichMessageOptions,
): TelegramInputRichMessage {
return buildTelegramRichHtml(markdownToTelegramRichHtml(markdown, options), options);
const richOptions = {
...options,
skipEntityDetection: shouldSkipTelegramRichEntityDetection(markdown, options),
};
return buildTelegramRichHtml(markdownToTelegramRichHtml(markdown, richOptions), richOptions);
}
export function buildTelegramRichHtml(
@@ -172,7 +186,7 @@ export function buildTelegramRichHtml(
options?: TelegramRichMessageOptions,
): TelegramInputRichMessage {
const safeHtml = prepareTelegramRichHtml(html);
return options?.skipEntityDetection === true
return shouldSkipTelegramRichEntityDetection(safeHtml, options)
? { html: safeHtml, skip_entity_detection: true }
: { html: safeHtml };
}
@@ -418,13 +432,14 @@ export function splitTelegramRichMessageTextChunks(params: {
tableMode?: MarkdownTableMode;
skipEntityDetection?: boolean;
}): TelegramRichTextChunk[] {
const markdownOptions = {
tableMode: params.tableMode,
skipEntityDetection: shouldSkipTelegramRichEntityDetection(params.text, {
skipEntityDetection: params.skipEntityDetection,
}),
};
const renderMarkdownChunk = (chunk: string) =>
prepareTelegramRichHtml(
markdownToTelegramRichHtml(chunk, {
tableMode: params.tableMode,
skipEntityDetection: params.skipEntityDetection,
}),
);
prepareTelegramRichHtml(markdownToTelegramRichHtml(chunk, markdownOptions));
const htmlChunks =
params.textMode === "html"
? splitPreparedTelegramRichHtml({

View File

@@ -953,7 +953,32 @@ describe("sendMessageTelegram", () => {
expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1);
const richMessage = botRawApi.sendRichMessage.mock.calls[0]?.[0]?.rich_message;
expect(richMessage?.html).toContain("<table>");
expect(richMessage?.html).toContain("<table bordered striped>");
});
it("skips rich entity detection for provider-prefixed email text", async () => {
botApi.sendMessage.mockResolvedValue({ message_id: 45, chat: { id: "123" } });
const oauthProfileText =
"OAuth profile: openai:keshavbotagent@gmail.com (keshavbotagent@gmail.com)";
await sendMessageTelegram("123", oauthProfileText, {
cfg: {
channels: {
telegram: {
richMessages: true,
},
},
},
token: "tok",
});
expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1);
const richMessage = botRawApi.sendRichMessage.mock.calls[0]?.[0]?.rich_message;
expect(richMessage).toEqual({
html: oauthProfileText,
skip_entity_detection: true,
});
expect(richMessage?.html).not.toContain("mailto:");
});
it.each([

View File

@@ -29,10 +29,23 @@ describe("telegramPlugin outbound", () => {
expect(telegramOutbound.presentationCapabilities?.limits?.text?.markdownDialect).toBe(
"markdown",
);
expect(telegramOutbound.sanitizeText).toBeUndefined();
expect(telegramOutbound.pollMaxOptions).toBe(10);
});
it("strips assistant-visible tool traces before outbound delivery", () => {
clearTelegramRuntime();
const text = 'Done.\n⚠ 🛠️ `search "Pipeline" in ~/.openclaw/workspace-* (agent)` failed';
expect(telegramOutbound.sanitizeText?.({ text, payload: { text } })).toBe("Done.");
});
it("preserves ordinary outbound text while sanitizing", () => {
clearTelegramRuntime();
const text = "The pipeline has 3 deals.";
expect(telegramOutbound.sanitizeText?.({ text, payload: { text } })).toBe(text);
});
it("preserves explicit HTML parse mode before chunking", () => {
clearTelegramRuntime();
const text = "<b>hi</b>";

View File

@@ -27,6 +27,9 @@ function cronAgentTurnPayloadSchema(params: {
allowUnsafeExternalContent: Type.Optional(Type.Boolean()),
lightContext: Type.Optional(Type.Boolean()),
toolsAllow: Type.Optional(params.toolsAllow),
// Server-managed marker for auto-stamped defaults; persisted so CLI cron
// runs can drop only the cap that was never user-explicit.
toolsAllowIsDefault: Type.Optional(Type.Boolean()),
},
{ additionalProperties: false },
);

View File

@@ -78,9 +78,12 @@ function createStyleSpan(params: MarkdownStyleSpan): MarkdownStyleSpan {
return span;
}
type MarkdownTableAlignment = "left" | "center" | "right";
export type MarkdownTableData = {
headers: string[];
rows: string[][];
aligns?: (MarkdownTableAlignment | undefined)[];
};
export type MarkdownTableCell = {
@@ -113,6 +116,7 @@ type TableCell = MarkdownTableCell;
type TableState = {
headers: TableCell[];
rows: TableCell[][];
aligns: (MarkdownTableAlignment | undefined)[];
currentRow: TableCell[];
currentCell: RenderTarget | null;
inHeader: boolean;
@@ -172,6 +176,20 @@ function getAttr(token: MarkdownToken, name: string): string | null {
return null;
}
function markdownTableAlignmentFromToken(token: MarkdownToken): MarkdownTableAlignment | undefined {
const value = getAttr(token, "style") ?? "";
if (/text-align\s*:\s*left/i.test(value)) {
return "left";
}
if (/text-align\s*:\s*center/i.test(value)) {
return "center";
}
if (/text-align\s*:\s*right/i.test(value)) {
return "right";
}
return undefined;
}
function createTextToken(base: MarkdownToken, content: string): MarkdownToken {
return { ...base, type: "text", content, children: undefined };
}
@@ -432,6 +450,7 @@ function initTableState(): TableState {
return {
headers: [],
rows: [],
aligns: [],
currentRow: [],
currentCell: null,
inHeader: false,
@@ -517,13 +536,15 @@ function collectTableBlock(state: RenderState) {
}
const headerCells = state.table.headers.map(trimCell);
const rowCells = state.table.rows.map((row) => row.map(trimCell));
state.collectedTables.push({
const table = {
headers: headerCells.map((cell) => cell.text),
rows: rowCells.map((row) => row.map((cell) => cell.text)),
headerCells,
rowCells,
placeholderOffset: state.text.length,
});
...(state.table.aligns.some(Boolean) ? { aligns: [...state.table.aligns] } : {}),
};
state.collectedTables.push(table);
}
function appendTableBulletValue(
@@ -874,6 +895,10 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void {
case "td_open":
if (state.table) {
state.table.currentCell = initRenderTarget();
if (token.type === "th_open" && state.table.inHeader) {
state.table.aligns[state.table.currentRow.length] =
markdownTableAlignmentFromToken(token);
}
}
break;
case "th_close":

View File

@@ -1,4 +1,7 @@
// Memory schema tests cover canonical table creation and shipped-name migration.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { describe, expect, it } from "vitest";
import { ensureMemoryIndexSchema } from "./memory-schema.js";
@@ -87,6 +90,79 @@ describe("memory index schema", () => {
}
});
it("does not import a legacy sidecar memory database during schema startup", () => {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-memory-sidecar-"));
const legacyPath = path.join(rootDir, "memory", "main.sqlite");
const agentPath = path.join(rootDir, "agents", "main", "agent", "openclaw-agent.sqlite");
fs.mkdirSync(path.dirname(legacyPath), { recursive: true });
fs.mkdirSync(path.dirname(agentPath), { recursive: true });
const legacyDb = new DatabaseSync(legacyPath);
try {
legacyDb.exec(`
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE files (
path TEXT PRIMARY KEY,
source TEXT NOT NULL DEFAULT 'memory',
hash TEXT NOT NULL,
mtime INTEGER NOT NULL,
size INTEGER NOT NULL
);
CREATE TABLE chunks (
id TEXT PRIMARY KEY,
path TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'memory',
start_line INTEGER NOT NULL,
end_line INTEGER NOT NULL,
hash TEXT NOT NULL,
model TEXT NOT NULL,
text TEXT NOT NULL,
embedding TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE embedding_cache (
provider TEXT NOT NULL,
model TEXT NOT NULL,
provider_key TEXT NOT NULL,
hash TEXT NOT NULL,
embedding TEXT NOT NULL,
dims INTEGER,
updated_at INTEGER NOT NULL,
PRIMARY KEY (provider, model, provider_key, hash)
);
INSERT INTO meta VALUES ('memory_index_meta_v1', '{"vectorDims":3}');
INSERT INTO files VALUES ('MEMORY.md', 'memory', 'file-hash', 10, 20);
INSERT INTO chunks VALUES (
'chunk-1', 'MEMORY.md', 'memory', 1, 2, 'chunk-hash', 'embed-model',
'remember this', '[1,0,0]', 30
);
INSERT INTO embedding_cache VALUES (
'openai', 'embed-model', 'key', 'chunk-hash', '[1,0,0]', 3, 40
);
`);
} finally {
legacyDb.close();
}
const db = new DatabaseSync(agentPath);
try {
const result = ensureMemoryIndexSchema({
db,
cacheEnabled: true,
ftsEnabled: true,
});
expect(result.ftsAvailable).toBe(true);
expect(db.prepare("SELECT * FROM memory_index_sources").all()).toEqual([]);
expect(db.prepare("SELECT id, text FROM memory_index_chunks").all()).toEqual([]);
expect(db.prepare("SELECT id, text FROM memory_index_chunks_fts").all()).toEqual([]);
expect(db.prepare("SELECT provider, hash FROM memory_embedding_cache").all()).toEqual([]);
expect(fs.existsSync(legacyPath)).toBe(true);
} finally {
db.close();
fs.rmSync(rootDir, { recursive: true, force: true });
}
});
it("stores source records with the same path in separate sources", () => {
const db = new DatabaseSync(":memory:");
try {

View File

@@ -23,13 +23,20 @@ const LEGACY_MEMORY_INDEX_TRIGGERS = [
const MEMORY_INDEX_SOURCE_COLUMNS = ["path", "source", "hash", "mtime", "size"] as const;
function tableColumns(db: DatabaseSync, tableName: string, schema = "main"): Set<string> {
const rows = db.prepare(`PRAGMA ${schema}.table_info(${tableName})`).all() as Array<{
name?: unknown;
}>;
return new Set(rows.flatMap((row) => (typeof row.name === "string" ? [row.name] : [])));
}
function tableHasExactColumns(
db: DatabaseSync,
tableName: string,
expected: readonly string[],
schema = "main",
): boolean {
const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name?: unknown }>;
const columns = new Set(rows.flatMap((row) => (typeof row.name === "string" ? [row.name] : [])));
const columns = tableColumns(db, tableName, schema);
return columns.size === expected.length && expected.every((column) => columns.has(column));
}
@@ -107,139 +114,157 @@ function migrateCanonicalMemoryIndexSourcesPrimaryKey(db: DatabaseSync): void {
}
}
function hasLegacyMemoryIndexTables(db: DatabaseSync, schema = "main"): boolean {
return (
tableHasExactColumns(db, "meta", ["key", "value"], schema) &&
tableHasExactColumns(db, "files", ["path", "source", "hash", "mtime", "size"], schema) &&
tableHasExactColumns(
db,
"chunks",
[
"id",
"path",
"source",
"start_line",
"end_line",
"hash",
"model",
"text",
"embedding",
"updated_at",
],
schema,
)
);
}
function hasLegacyEmbeddingCacheTable(db: DatabaseSync, schema = "main"): boolean {
return tableHasExactColumns(
db,
"embedding_cache",
["provider", "model", "provider_key", "hash", "embedding", "dims", "updated_at"],
schema,
);
}
function copyLegacyMemoryIndexRows(
db: DatabaseSync,
schema: string,
preservedEmbeddingCacheTable?: string,
): void {
db.exec(`
INSERT OR IGNORE INTO main.${MEMORY_INDEX_META_TABLE} (key, value)
SELECT key, value FROM ${schema}.meta;
INSERT OR IGNORE INTO main.${MEMORY_INDEX_SOURCES_TABLE} (path, source, hash, mtime, size)
SELECT path, source, hash, mtime, size FROM ${schema}.files;
INSERT OR IGNORE INTO main.${MEMORY_INDEX_CHUNKS_TABLE} (
id, path, source, start_line, end_line, hash, model, text, embedding, updated_at
)
SELECT id, path, source, start_line, end_line, hash, model, text, embedding, updated_at
FROM ${schema}.chunks;
`);
assertLegacyRowsCopied(
db,
`SELECT COUNT(*) AS missing
FROM ${schema}.meta AS legacy
WHERE NOT EXISTS (
SELECT 1 FROM main.${MEMORY_INDEX_META_TABLE} AS canonical
WHERE canonical.key = legacy.key AND canonical.value IS legacy.value
)`,
"meta",
);
assertLegacyRowsCopied(
db,
`SELECT COUNT(*) AS missing
FROM ${schema}.files AS legacy
WHERE NOT EXISTS (
SELECT 1 FROM main.${MEMORY_INDEX_SOURCES_TABLE} AS canonical
WHERE canonical.path = legacy.path
AND canonical.source IS legacy.source
AND canonical.hash IS legacy.hash
AND canonical.mtime IS legacy.mtime
AND canonical.size IS legacy.size
)`,
"files",
);
assertLegacyRowsCopied(
db,
`SELECT COUNT(*) AS missing
FROM ${schema}.chunks AS legacy
WHERE NOT EXISTS (
SELECT 1 FROM main.${MEMORY_INDEX_CHUNKS_TABLE} AS canonical
WHERE canonical.id = legacy.id
AND canonical.path IS legacy.path
AND canonical.source IS legacy.source
AND canonical.start_line IS legacy.start_line
AND canonical.end_line IS legacy.end_line
AND canonical.hash IS legacy.hash
AND canonical.model IS legacy.model
AND canonical.text IS legacy.text
AND canonical.embedding IS legacy.embedding
AND canonical.updated_at IS legacy.updated_at
)`,
"chunks",
);
if (
preservedEmbeddingCacheTable !== "embedding_cache" &&
hasLegacyEmbeddingCacheTable(db, schema)
) {
db.exec(`
CREATE TABLE IF NOT EXISTS main.${MEMORY_EMBEDDING_CACHE_TABLE} (
provider TEXT NOT NULL,
model TEXT NOT NULL,
provider_key TEXT NOT NULL,
hash TEXT NOT NULL,
embedding TEXT NOT NULL,
dims INTEGER,
updated_at INTEGER NOT NULL,
PRIMARY KEY (provider, model, provider_key, hash)
);
INSERT OR IGNORE INTO main.${MEMORY_EMBEDDING_CACHE_TABLE} (
provider, model, provider_key, hash, embedding, dims, updated_at
)
SELECT provider, model, provider_key, hash, embedding, dims, updated_at
FROM ${schema}.embedding_cache;
`);
assertLegacyRowsCopied(
db,
`SELECT COUNT(*) AS missing
FROM ${schema}.embedding_cache AS legacy
WHERE NOT EXISTS (
SELECT 1 FROM main.${MEMORY_EMBEDDING_CACHE_TABLE} AS canonical
WHERE canonical.provider = legacy.provider
AND canonical.model = legacy.model
AND canonical.provider_key = legacy.provider_key
AND canonical.hash = legacy.hash
AND canonical.embedding IS legacy.embedding
AND canonical.dims IS legacy.dims
AND canonical.updated_at IS legacy.updated_at
)`,
"embedding_cache",
);
}
}
function migrateLegacyMemoryIndexTables(
db: DatabaseSync,
preservedEmbeddingCacheTable?: string,
): void {
const hasLegacyCoreTables =
tableHasExactColumns(db, "meta", ["key", "value"]) &&
tableHasExactColumns(db, "files", ["path", "source", "hash", "mtime", "size"]) &&
tableHasExactColumns(db, "chunks", [
"id",
"path",
"source",
"start_line",
"end_line",
"hash",
"model",
"text",
"embedding",
"updated_at",
]);
if (!hasLegacyCoreTables) {
if (!hasLegacyMemoryIndexTables(db)) {
return;
}
db.exec("SAVEPOINT migrate_legacy_memory_index_tables");
try {
db.exec(`
INSERT OR IGNORE INTO ${MEMORY_INDEX_META_TABLE} (key, value)
SELECT key, value FROM meta;
INSERT OR IGNORE INTO ${MEMORY_INDEX_SOURCES_TABLE} (path, source, hash, mtime, size)
SELECT path, source, hash, mtime, size FROM files;
INSERT OR IGNORE INTO ${MEMORY_INDEX_CHUNKS_TABLE} (
id, path, source, start_line, end_line, hash, model, text, embedding, updated_at
)
SELECT id, path, source, start_line, end_line, hash, model, text, embedding, updated_at
FROM chunks;
`);
assertLegacyRowsCopied(
db,
`SELECT COUNT(*) AS missing
FROM meta AS legacy
WHERE NOT EXISTS (
SELECT 1 FROM ${MEMORY_INDEX_META_TABLE} AS canonical
WHERE canonical.key = legacy.key AND canonical.value IS legacy.value
)`,
"meta",
);
assertLegacyRowsCopied(
db,
`SELECT COUNT(*) AS missing
FROM files AS legacy
WHERE NOT EXISTS (
SELECT 1 FROM ${MEMORY_INDEX_SOURCES_TABLE} AS canonical
WHERE canonical.path = legacy.path
AND canonical.source IS legacy.source
AND canonical.hash IS legacy.hash
AND canonical.mtime IS legacy.mtime
AND canonical.size IS legacy.size
)`,
"files",
);
assertLegacyRowsCopied(
db,
`SELECT COUNT(*) AS missing
FROM chunks AS legacy
WHERE NOT EXISTS (
SELECT 1 FROM ${MEMORY_INDEX_CHUNKS_TABLE} AS canonical
WHERE canonical.id = legacy.id
AND canonical.path IS legacy.path
AND canonical.source IS legacy.source
AND canonical.start_line IS legacy.start_line
AND canonical.end_line IS legacy.end_line
AND canonical.hash IS legacy.hash
AND canonical.model IS legacy.model
AND canonical.text IS legacy.text
AND canonical.embedding IS legacy.embedding
AND canonical.updated_at IS legacy.updated_at
)`,
"chunks",
);
if (
preservedEmbeddingCacheTable !== "embedding_cache" &&
tableHasExactColumns(db, "embedding_cache", [
"provider",
"model",
"provider_key",
"hash",
"embedding",
"dims",
"updated_at",
])
) {
db.exec(`
CREATE TABLE IF NOT EXISTS ${MEMORY_EMBEDDING_CACHE_TABLE} (
provider TEXT NOT NULL,
model TEXT NOT NULL,
provider_key TEXT NOT NULL,
hash TEXT NOT NULL,
embedding TEXT NOT NULL,
dims INTEGER,
updated_at INTEGER NOT NULL,
PRIMARY KEY (provider, model, provider_key, hash)
);
INSERT OR IGNORE INTO ${MEMORY_EMBEDDING_CACHE_TABLE} (
provider, model, provider_key, hash, embedding, dims, updated_at
)
SELECT provider, model, provider_key, hash, embedding, dims, updated_at
FROM embedding_cache;
`);
assertLegacyRowsCopied(
db,
`SELECT COUNT(*) AS missing
FROM embedding_cache AS legacy
WHERE NOT EXISTS (
SELECT 1 FROM ${MEMORY_EMBEDDING_CACHE_TABLE} AS canonical
WHERE canonical.provider = legacy.provider
AND canonical.model = legacy.model
AND canonical.provider_key = legacy.provider_key
AND canonical.hash = legacy.hash
AND canonical.embedding IS legacy.embedding
AND canonical.dims IS legacy.dims
AND canonical.updated_at IS legacy.updated_at
)`,
"embedding_cache",
);
copyLegacyMemoryIndexRows(db, "main", preservedEmbeddingCacheTable);
if (preservedEmbeddingCacheTable !== "embedding_cache" && hasLegacyEmbeddingCacheTable(db)) {
db.exec("DROP TABLE embedding_cache");
}
for (const trigger of LEGACY_MEMORY_INDEX_TRIGGERS) {
db.exec(`DROP TRIGGER IF EXISTS ${trigger}`);
}
// FTS/vector tables are derived from canonical chunk rows. FTS can be
// removed here; sqlite-vec cleanup waits until that extension is loaded.
db.exec(`
DROP TABLE IF EXISTS chunks_fts;
DROP TABLE chunks;

View File

@@ -85,7 +85,7 @@ flow:
value:
expr: "env.mock ? [...(await fetchJson(`${env.mock.baseUrl}/debug/requests`))] : []"
- assert:
expr: "!env.mock || debugRequests.some((request) => request.plannedToolName === 'read' && request.plannedToolArgs?.path === config.fixtureFile && String(request.allInputText ?? '').includes(config.cacheEvidenceNeedle) && String(request.allInputText ?? '').includes('[Read output capped at 50KB') && String(request.allInputText ?? '').length >= 50000)"
expr: "!env.mock || debugRequests.some((request, index) => request.plannedToolName === 'read' && request.plannedToolArgs?.path === config.fixtureFile && typeof request.plannedToolCallId === 'string' && debugRequests.slice(index + 1).some((result, resultOffset) => result.toolOutputCallId === request.plannedToolCallId && String(result.toolOutput ?? '').includes(config.cacheEvidenceNeedle) && (String(result.toolOutput ?? '').includes('[Read output capped at 50KB') || (String(result.toolOutput ?? '').includes('...(truncated)...') && String(result.toolOutput ?? '').length <= 13000)) && debugRequests.slice(index + resultOffset + 2).some((followup) => followup.plannedToolName === 'read' && followup.plannedToolArgs?.path === config.fixtureFile && String(followup.allInputText ?? '').includes(config.cacheEvidenceNeedle) && (String(followup.allInputText ?? '').includes('[Read output capped at 50KB') || String(followup.allInputText ?? '').includes('...(truncated)...')))))"
message:
expr: "`large capped read tool result was not observed: ${JSON.stringify(debugRequests.slice(-8).map((request) => ({ plannedToolName: request.plannedToolName ?? null, plannedToolArgs: request.plannedToolArgs ?? null, allInputLength: String(request.allInputText ?? '').length, hasNeedle: String(request.allInputText ?? '').includes(config.cacheEvidenceNeedle), hasReadCap: String(request.allInputText ?? '').includes('[Read output capped at 50KB') })))}`"
expr: "`large capped read tool result was not observed: ${JSON.stringify(debugRequests.slice(-8).map((request) => ({ plannedToolName: request.plannedToolName ?? null, plannedToolArgs: request.plannedToolArgs ?? null, plannedToolCallId: request.plannedToolCallId ?? null, toolOutputCallId: request.toolOutputCallId ?? null, toolOutputLength: String(request.toolOutput ?? '').length, toolOutputHasNeedle: String(request.toolOutput ?? '').includes(config.cacheEvidenceNeedle), toolOutputHasReadCap: String(request.toolOutput ?? '').includes('[Read output capped at 50KB'), toolOutputHasCodexTruncation: String(request.toolOutput ?? '').includes('...(truncated)...'), inputHasNeedle: String(request.allInputText ?? '').includes(config.cacheEvidenceNeedle), inputHasReadCap: String(request.allInputText ?? '').includes('[Read output capped at 50KB'), inputHasCodexTruncation: String(request.allInputText ?? '').includes('...(truncated)...') })))}`"
detailsExpr: "outbound?.text ?? config.hitMarker"

View File

@@ -39,6 +39,7 @@ scenario:
reason: image_generate is an OpenClaw integration tool whose happy path yields for async completion, so standard direct call/result parity would compare different lifecycle phases.
promptSnippet: "target=image_generate"
failurePromptSnippet: "failure target=image_generate"
happyPathOutputRequired: false
flow:
steps:

View File

@@ -107,6 +107,7 @@ export const migratedSessionAccessorFiles = new Set([
"src/gateway/sessions-history-http.ts",
"src/gateway/session-utils.ts",
"src/gateway/managed-image-attachments.ts",
"src/gateway/boot.ts",
"src/gateway/server-methods/artifacts.ts",
"src/gateway/server-methods/chat.ts",
"src/gateway/sessions-resolve.ts",
@@ -163,7 +164,9 @@ export const migratedSessionAccessorWriteFiles = new Set([
"src/auto-reply/reply/session-usage.ts",
"src/commands/tasks.ts",
"src/config/sessions/cleanup-service.ts",
"src/gateway/boot.ts",
"src/gateway/server-node-events.ts",
"src/gateway/session-compaction-checkpoints.ts",
"src/plugins/host-hook-cleanup.ts",
"src/plugins/host-hook-state.ts",
"src/tui/embedded-backend.ts",

View File

@@ -1056,8 +1056,8 @@ function commandNeedsAwsMacosPackageManager(commandArgs, options = {}) {
return true;
}
if (commandArgs.length === 1) {
return shellCommandWordCandidates(commandArgs[0]).some(
(words) => commandWordsNeedAwsMacosPackageManager(words, options),
return shellCommandWordCandidates(commandArgs[0]).some((words) =>
commandWordsNeedAwsMacosPackageManager(words, options),
);
}
return commandWordsNeedAwsMacosPackageManager(normalizedCommandWords(commandArgs), options);
@@ -1964,7 +1964,7 @@ function remoteGitBootstrapForChangedGate(changedGateBase) {
}
function injectRemoteChangedGateEnvironment(commandArgs) {
if (commandArgs[0] !== "run" || isWindowsRemoteTarget(commandArgs)) {
if (commandArgs[0] !== "run" || isNativeWindowsRemoteTarget(commandArgs)) {
return commandArgs;
}
@@ -2055,6 +2055,16 @@ function isAwsMacosRemoteTarget(commandArgs, providerName) {
);
}
function isBrokeredWsl2RemoteTarget(commandArgs, providerName) {
const canonicalProvider = providerAliases.get(providerName) ?? providerName;
return (
commandArgs[0] === "run" &&
(canonicalProvider === "aws" || canonicalProvider === "azure") &&
isWindowsRemoteTarget(commandArgs) &&
optionValue(commandArgs, "--windows-mode") === "wsl2"
);
}
function isHydratedNativeWindowsProvider(providerName) {
return providerName === "aws" || providerName === "azure";
}
@@ -2141,6 +2151,31 @@ function injectRemoteChangedGateGitBootstrap(commandArgs, changedGateBase) {
return normalizedArgs;
}
function remotePosixJsEnvBootstrap() {
return [
"openclaw_crabbox_env() {",
"openclaw_env_args=();",
"openclaw_env_ignore=0;",
"openclaw_env_path_seen=0;",
'while [ "$#" -gt 0 ]; do',
'case "$1" in',
'-i|--ignore-environment) openclaw_env_ignore=1; openclaw_env_args+=("$1"); shift ;;',
'-S|--split-string|-S*|--split-string=*) command env "${openclaw_env_args[@]}" "$@"; return ;;',
'-[!-]*i*) openclaw_env_ignore=1; openclaw_env_args+=("$1"); shift ;;',
'-u|--unset|-C|--chdir) openclaw_env_args+=("$1"); shift; if [ "$#" -gt 0 ]; then openclaw_env_args+=("$1"); shift; fi ;;',
'--unset=*|--chdir=*) openclaw_env_args+=("$1"); shift ;;',
'PATH=*) if [ "$openclaw_env_ignore" = "1" ]; then openclaw_env_args+=("PATH=${OPENCLAW_CRABBOX_BOOTSTRAP_PATH:-$PATH}:${1#PATH=}"); else openclaw_env_args+=("$1"); fi; openclaw_env_path_seen=1; shift ;;',
'[A-Za-z_]*=*) openclaw_env_args+=("$1"); shift ;;',
'--) openclaw_env_args+=("--"); shift; break ;;',
"*) break ;;",
"esac;",
"done;",
'if [ "$openclaw_env_ignore" = "1" ] && [ "$openclaw_env_path_seen" = "0" ]; then openclaw_env_args+=("PATH=${OPENCLAW_CRABBOX_BOOTSTRAP_PATH:-$PATH}"); fi;',
'command env "${openclaw_env_args[@]}" "$@";',
"};",
];
}
function remoteAwsMacosJsBootstrap({ packageManager = false, bun = false } = {}) {
const nodeVersion = process.env.OPENCLAW_CRABBOX_MACOS_NODE_VERSION?.trim() || "24.15.0";
const bootstrap = [
@@ -2192,26 +2227,7 @@ function remoteAwsMacosJsBootstrap({ packageManager = false, bun = false } = {})
"release_install_lock;",
"fi;",
"node --version >&2 || return 1;",
"openclaw_crabbox_env() {",
"openclaw_env_args=();",
"openclaw_env_ignore=0;",
"openclaw_env_path_seen=0;",
'while [ "$#" -gt 0 ]; do',
'case "$1" in',
'-i|--ignore-environment) openclaw_env_ignore=1; openclaw_env_args+=("$1"); shift ;;',
'-S|--split-string|-S*|--split-string=*) command env "${openclaw_env_args[@]}" "$@"; return ;;',
'-[!-]*i*) openclaw_env_ignore=1; openclaw_env_args+=("$1"); shift ;;',
'-u|--unset|-C|--chdir) openclaw_env_args+=("$1"); shift; if [ "$#" -gt 0 ]; then openclaw_env_args+=("$1"); shift; fi ;;',
'--unset=*|--chdir=*) openclaw_env_args+=("$1"); shift ;;',
'PATH=*) if [ "$openclaw_env_ignore" = "1" ]; then openclaw_env_args+=("PATH=${OPENCLAW_CRABBOX_BOOTSTRAP_PATH:-$PATH}:${1#PATH=}"); else openclaw_env_args+=("$1"); fi; openclaw_env_path_seen=1; shift ;;',
'[A-Za-z_]*=*) openclaw_env_args+=("$1"); shift ;;',
'--) openclaw_env_args+=("--"); shift; break ;;',
"*) break ;;",
"esac;",
"done;",
'if [ "$openclaw_env_ignore" = "1" ] && [ "$openclaw_env_path_seen" = "0" ]; then openclaw_env_args+=("PATH=${OPENCLAW_CRABBOX_BOOTSTRAP_PATH:-$PATH}"); fi;',
'command env "${openclaw_env_args[@]}" "$@";',
"};",
...remotePosixJsEnvBootstrap(),
];
if (packageManager) {
bootstrap.push(
@@ -2264,6 +2280,71 @@ function remoteAwsMacosJsBootstrap({ packageManager = false, bun = false } = {})
return bootstrap.join(" ");
}
function remoteWsl2JsBootstrap({ packageManager = false } = {}) {
const nodeVersion = process.env.OPENCLAW_CRABBOX_WSL2_NODE_VERSION?.trim() || "24.15.0";
const bootstrap = [
"openclaw_crabbox_bootstrap_wsl2_js() {",
'tool_root="${OPENCLAW_CRABBOX_WSL2_TOOLCHAIN_DIR:-$HOME/.openclaw-crabbox-toolchain}";',
`node_version=${shellQuote(nodeVersion)};`,
'arch="$(uname -m)";',
'case "$arch" in arm64|aarch64) node_arch=arm64 ;; x86_64|amd64) node_arch=x64 ;; *) echo "unsupported WSL2 arch: $arch" >&2; return 2 ;; esac;',
'if [ -z "${TMPDIR:-}" ]; then export TMPDIR="/tmp"; fi;',
'if [ ! -d "$TMPDIR" ]; then mkdir -p "$TMPDIR" 2>/dev/null || export TMPDIR="/tmp"; fi;',
'if [ ! -d "$TMPDIR" ]; then echo "usable TMPDIR not found: $TMPDIR" >&2; return 1; fi;',
'node_dir="$tool_root/node-v${node_version}-linux-${node_arch}";',
'ready_marker="$node_dir/.openclaw-crabbox-node-ready";',
'export PATH="$node_dir/bin:$PATH";',
'if [ ! -x "$node_dir/bin/node" ] || [ ! -f "$ready_marker" ]; then',
'mkdir -p "$tool_root" || { status=$?; return "$status"; };',
'install_lock="$tool_root/.node-${node_version}-${node_arch}.lock";',
"lock_acquired=0;",
"lock_deadline=$((SECONDS + 300));",
"while true; do",
'if mkdir "$install_lock" 2>/dev/null; then lock_acquired=1; printf "%s\\n" "$$" >"$install_lock/pid" || { status=$?; rm -rf "$install_lock"; return "$status"; }; break; fi;',
'if [ -x "$node_dir/bin/node" ] && [ -f "$ready_marker" ]; then break; fi;',
'if [ "$SECONDS" -ge "$lock_deadline" ]; then',
'lock_pid="$(cat "$install_lock/pid" 2>/dev/null || true)";',
'if [ -n "$lock_pid" ] && kill -0 "$lock_pid" 2>/dev/null; then echo "timed out waiting for active WSL2 Node toolchain install lock: $install_lock pid=$lock_pid" >&2; return 1; fi;',
'echo "reclaiming stale WSL2 Node toolchain install lock: $install_lock" >&2;',
'rm -rf "$install_lock" || return 1;',
"lock_deadline=$((SECONDS + 300));",
"fi;",
"sleep 1;",
"done;",
'release_install_lock() { if [ "$lock_acquired" = "1" ]; then rm -rf "$install_lock" 2>/dev/null || true; fi; };',
'if [ ! -x "$node_dir/bin/node" ] || [ ! -f "$ready_marker" ]; then',
'tmp_dir="$(mktemp -d)" || { release_install_lock; return 1; };',
'pkg="node-v${node_version}-linux-${node_arch}.tar.gz";',
'base_url="https://nodejs.org/dist/v${node_version}";',
'curl -fsSL --connect-timeout 10 --max-time 300 --retry 2 --retry-delay 2 -o "$tmp_dir/$pkg" "$base_url/$pkg" || { status=$?; release_install_lock; rm -rf "$tmp_dir"; return "$status"; };',
'curl -fsSL --connect-timeout 10 --max-time 60 --retry 2 --retry-delay 2 -o "$tmp_dir/SHASUMS256.txt" "$base_url/SHASUMS256.txt" || { status=$?; release_install_lock; rm -rf "$tmp_dir"; return "$status"; };',
'(cd "$tmp_dir" && grep " $pkg$" SHASUMS256.txt | sha256sum -c -) || { status=$?; release_install_lock; rm -rf "$tmp_dir"; return "$status"; };',
'rm -rf "$node_dir" || { status=$?; release_install_lock; rm -rf "$tmp_dir"; return "$status"; };',
'tar -xzf "$tmp_dir/$pkg" -C "$tool_root" || { status=$?; release_install_lock; rm -rf "$tmp_dir"; return "$status"; };',
'touch "$ready_marker" || { status=$?; release_install_lock; rm -rf "$tmp_dir"; return "$status"; };',
'rm -rf "$tmp_dir";',
"fi;",
"release_install_lock;",
"fi;",
"node --version >&2 || return 1;",
...remotePosixJsEnvBootstrap(),
];
if (packageManager) {
bootstrap.push(
'export COREPACK_HOME="${COREPACK_HOME:-$tool_root/corepack}";',
'export PNPM_HOME="${PNPM_HOME:-$tool_root/pnpm-home}";',
'mkdir -p "$COREPACK_HOME" "$PNPM_HOME" || return 1;',
'export PATH="$PNPM_HOME:$PATH";',
'corepack enable --install-directory "$PNPM_HOME" || return 1;',
"pnpm --version >&2;",
"if [ -f pnpm-lock.yaml ] && [ ! -f node_modules/.modules.yaml ]; then pnpm install --frozen-lockfile || return 1; fi;",
);
}
bootstrap.push('export OPENCLAW_CRABBOX_BOOTSTRAP_PATH="$PATH";');
bootstrap.push("};", "openclaw_crabbox_bootstrap_wsl2_js");
return bootstrap.join(" ");
}
function scopedAwsMacosEnvCommand(commandArgs) {
if (commandArgs.length <= 1 || !isSupportedSystemEnvCommand(commandArgs[0])) {
return null;
@@ -2280,11 +2361,7 @@ function scopedAwsMacosEnvCommand(commandArgs) {
commandWordsNeedAwsMacosPackageManager(targetWords);
const needsRuntime = jsRuntimeEntrypoints.has(targetEntrypoint);
const needsBun = awsMacosBunEntrypoints.has(targetEntrypoint);
if (
!needsRuntime &&
!needsPackageManager &&
!needsBun
) {
if (!needsRuntime && !needsPackageManager && !needsBun) {
return null;
}
@@ -2521,6 +2598,73 @@ function readLeadingShellWord(command, start) {
return word ? { word, end: command.length } : null;
}
function remoteWsl2JsBootstrapRequirements(commandArgs) {
const runArgs = runCommandArgs(commandArgs);
const directScopedEnvCommand = hasOption(commandArgs, "--shell")
? null
: scopedAwsMacosEnvCommand(runArgs);
const shellScopedEnvCommand =
hasOption(commandArgs, "--shell") && runArgs.length === 1
? scopedAwsMacosShellEnvCommand(runArgs[0])
: null;
const scopedEnvCommand = directScopedEnvCommand ?? shellScopedEnvCommand;
const packageManagerFallbackNeeded = scopedEnvCommand
? commandNeedsAwsMacosPackageManager(runArgs)
: commandNeedsAwsMacosPackageManager(runArgs, { canShimIgnoreEnvironment: false });
const packageManagerNeeded = scopedEnvCommand?.packageManager || packageManagerFallbackNeeded;
const runtimeEntrypoint =
scopedEnvCommand?.runtimeEntrypoint || commandRuntimeEntrypoint(runArgs);
const runtimeNeeded =
runtimeEntrypoint && !awsMacosBunEntrypoints.has(runtimeEntrypoint) ? runtimeEntrypoint : "";
return {
scopedEnvCommand,
packageManager: packageManagerNeeded,
runtimeEntrypoint: runtimeNeeded,
};
}
function prepareRemoteWsl2JsBootstrapScript(commandArgs, providerName) {
const requirements = remoteWsl2JsBootstrapRequirements(commandArgs);
if (
!isBrokeredWsl2RemoteTarget(commandArgs, providerName) ||
(!requirements.runtimeEntrypoint && !requirements.packageManager)
) {
return { args: commandArgs, cleanup: () => {}, prepared: false };
}
const { start, optionEnd } = runCommandBounds(commandArgs);
if (start < 0) {
return { args: commandArgs, cleanup: () => {}, prepared: false };
}
const scriptRoot = mkdtempSync(resolve(tmpdir(), "openclaw-crabbox-wsl2-script-"));
const scriptPath = resolve(scriptRoot, "script.sh");
const remoteCommand = commandArgs.slice(start);
const originalShellCommand =
requirements.scopedEnvCommand?.shellCommand ??
(hasOption(commandArgs, "--shell") && remoteCommand.length === 1
? remoteCommand[0]
: shellJoin(remoteCommand));
const script = `${remoteWsl2JsBootstrap({
packageManager: requirements.packageManager,
})} || exit $?\n{ ${originalShellCommand}\n}\n`;
writeFileSync(scriptPath, script, "utf8");
chmodSync(scriptPath, 0o700);
const normalizedArgs = commandArgs.slice(0, optionEnd);
if (!hasOption(normalizedArgs, "--no-hydrate")) {
normalizedArgs.push("--no-hydrate");
}
normalizedArgs.push("--script", scriptPath);
return {
args: normalizedArgs,
cleanup: () => rmSync(scriptRoot, { recursive: true, force: true }),
prepared: true,
};
}
function injectRemoteAwsMacosJsBootstrap(commandArgs, providerName) {
const runArgs = runCommandArgs(commandArgs);
const directScopedEnvCommand = hasOption(commandArgs, "--shell")
@@ -2531,12 +2675,10 @@ function injectRemoteAwsMacosJsBootstrap(commandArgs, providerName) {
? scopedAwsMacosShellEnvCommand(runArgs[0])
: null;
const scopedEnvCommand = directScopedEnvCommand ?? shellScopedEnvCommand;
const packageManagerFallbackNeeded =
scopedEnvCommand
? commandNeedsAwsMacosPackageManager(runArgs)
: commandNeedsAwsMacosPackageManager(runArgs, { canShimIgnoreEnvironment: false });
const packageManagerNeeded =
scopedEnvCommand?.packageManager || packageManagerFallbackNeeded;
const packageManagerFallbackNeeded = scopedEnvCommand
? commandNeedsAwsMacosPackageManager(runArgs)
: commandNeedsAwsMacosPackageManager(runArgs, { canShimIgnoreEnvironment: false });
const packageManagerNeeded = scopedEnvCommand?.packageManager || packageManagerFallbackNeeded;
const bunNeeded = scopedEnvCommand?.bun || commandNeedsAwsMacosBun(runArgs);
const runtimeEntrypoint =
scopedEnvCommand?.runtimeEntrypoint || commandRuntimeEntrypoint(runArgs);
@@ -3182,6 +3324,7 @@ let remoteChangedGateBase = "";
const scriptBootstrap = prepareAwsMacosScriptStdinBootstrap(normalizedArgs, provider);
normalizedArgs = scriptBootstrap.args;
const scriptStdinPrepared = scriptBootstrap.prepared;
let wsl2ScriptBootstrap = { args: normalizedArgs, cleanup: () => {}, prepared: false };
try {
if (shouldUseFullCheckoutForCleanRemoteSync(normalizedArgs, provider)) {
const runWords = runCommandArgs(normalizedArgs);
@@ -3212,6 +3355,7 @@ function cleanupOnce() {
}
cleanupDone = true;
stopFullCheckoutKeepalive();
wsl2ScriptBootstrap.cleanup();
scriptBootstrap.cleanup();
preserveTemporaryCrabboxRuns();
cleanupChildCwd();
@@ -3237,6 +3381,14 @@ if (
);
}
}
if (normalizedArgs[0] === "run" && isBrokeredWsl2RemoteTarget(normalizedArgs, provider)) {
const wsl2Requirements = remoteWsl2JsBootstrapRequirements(normalizedArgs);
if (wsl2Requirements.runtimeEntrypoint || wsl2Requirements.packageManager) {
console.error(
`[crabbox] provider=${provider} WSL2 raw boxes may lack Node/Corepack/pnpm for ${wsl2Requirements.runtimeEntrypoint || "package-manager"}; using no-hydrate pinned user-local JavaScript tooling before the command`,
);
}
}
const childEnv = { ...process.env };
if (
@@ -3265,11 +3417,20 @@ const remoteMarkedArgs = injectRemoteChangedGateEnvironment(normalizedArgs);
const remoteMarkedNeedsAwsMacosSwift =
isAwsMacosRemoteTarget(remoteMarkedArgs, provider) &&
commandNeedsAwsMacosSwiftToolchain(runCommandArgs(remoteMarkedArgs));
try {
wsl2ScriptBootstrap = prepareRemoteWsl2JsBootstrapScript(
childCwd === repoRoot ? remoteMarkedArgs : absolutizeLocalRunPaths(remoteMarkedArgs),
provider,
);
} catch (error) {
cleanupOnce();
throw error;
}
const childArgs =
childCwd === repoRoot
? injectRemoteWindowsHydratedNodeModulesBootstrap(
injectRemoteAwsMacosSwiftBootstrap(
injectRemoteAwsMacosJsBootstrap(remoteMarkedArgs, provider),
injectRemoteAwsMacosJsBootstrap(wsl2ScriptBootstrap.args, provider),
provider,
remoteMarkedNeedsAwsMacosSwift,
),
@@ -3278,7 +3439,7 @@ const childArgs =
: injectRemoteChangedGateGitBootstrap(
injectRemoteWindowsHydratedNodeModulesBootstrap(
injectRemoteAwsMacosSwiftBootstrap(
injectRemoteAwsMacosJsBootstrap(absolutizeLocalRunPaths(remoteMarkedArgs), provider),
injectRemoteAwsMacosJsBootstrap(wsl2ScriptBootstrap.args, provider),
provider,
remoteMarkedNeedsAwsMacosSwift,
),

View File

@@ -138,7 +138,7 @@ const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({
"secret-file-runtime": 1,
"security-runtime": 7,
"agent-harness": 7,
"agent-harness-runtime": 7,
"agent-harness-runtime": 11,
types: 6,
"agent-config-primitives": 2,
"command-auth": 81,
@@ -202,8 +202,8 @@ let publicDeprecatedExportsByEntrypointBudget;
try {
budgets = {
publicEntrypoints: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS", 322),
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10377),
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5206),
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10381),
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5210),
publicDeprecatedExports: readBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS",
3247,

View File

@@ -16,7 +16,7 @@ const BASE_AVAILABLE_COMMANDS: AvailableCommand[] = [
{ name: "subagents", description: "List or manage sub-agents." },
{ name: "config", description: "Read or write config (owner-only)." },
{ name: "debug", description: "Set runtime-only overrides (owner-only)." },
{ name: "usage", description: "Toggle usage footer (off|tokens|full)." },
{ name: "usage", description: "Toggle usage footer (off|tokens|full|reset). 'reset'/'inherit'/'clear'/'default' clears the session override to re-inherit the configured default." },
{ name: "stop", description: "Stop the current run." },
{ name: "restart", description: "Restart the gateway (if enabled)." },
{ name: "activation", description: "Set group activation (mention|always)." },

View File

@@ -221,9 +221,9 @@ export function buildSessionPresentation(params: {
id: ACP_RESPONSE_USAGE_CONFIG_ID,
name: "Usage detail",
description:
"Controls how much usage information OpenClaw attaches to responses for the session.",
currentValue: normalizeOptionalString(row.responseUsage) || "off",
values: ["off", "tokens", "full"],
"Controls how much usage information OpenClaw attaches to responses for the session. 'inherit' follows the configured default; 'off' explicitly disables it for this session.",
currentValue: normalizeOptionalString(row.responseUsage) || "inherit",
values: ["inherit", "off", "tokens", "full"],
}),
buildSelectConfigOption({
id: ACP_ELEVATED_LEVEL_CONFIG_ID,

View File

@@ -358,4 +358,106 @@ describe("acp setSessionConfigOption bridge behavior", () => {
sessionStore.clearAllSessionsForTest();
});
it('maps response_usage "inherit" selection to sessions.patch with responseUsage: null', async () => {
const sessionStore = createInMemorySessionStore();
const connection = createAcpConnection();
const request = vi.fn(async (method: string, _params?: unknown) => {
if (method === "sessions.list") {
return {
ts: Date.now(),
path: "/tmp/sessions.json",
count: 1,
defaults: { modelProvider: null, model: null, contextTokens: null },
sessions: [
{
key: "usage-inherit-session",
kind: "direct",
updatedAt: Date.now(),
thinkingLevel: "minimal",
modelProvider: "openai",
model: "gpt-5.4",
responseUsage: "tokens",
},
],
};
}
if (method === "sessions.patch") {
expect(requireRecord(_params, "sessions.patch params")).toMatchObject({
key: "usage-inherit-session",
responseUsage: null,
});
}
return { ok: true };
}) as GatewayClient["request"];
const agent = new AcpGatewayAgent(connection, createAcpGateway(request), {
sessionStore,
});
await agent.loadSession(createLoadSessionRequest("usage-inherit-session"));
const result = await agent.setSessionConfigOption(
createSetSessionConfigOptionRequest("usage-inherit-session", "response_usage", "inherit"),
);
// After selecting "inherit", the ACP config option should report "inherit" (unset).
expectConfigOption(result.configOptions, "response_usage", { currentValue: "inherit" });
expect(
(request as unknown as MockCallSource).mock.calls.some(
([method]) => method === "sessions.patch",
),
).toBe(true);
sessionStore.clearAllSessionsForTest();
});
it('maps response_usage "off" selection to sessions.patch with responseUsage: "off"', async () => {
const sessionStore = createInMemorySessionStore();
const connection = createAcpConnection();
const request = vi.fn(async (method: string, _params?: unknown) => {
if (method === "sessions.list") {
return {
ts: Date.now(),
path: "/tmp/sessions.json",
count: 1,
defaults: { modelProvider: null, model: null, contextTokens: null },
sessions: [
{
key: "usage-off-session",
kind: "direct",
updatedAt: Date.now(),
thinkingLevel: "minimal",
modelProvider: "openai",
model: "gpt-5.4",
},
],
};
}
if (method === "sessions.patch") {
expect(requireRecord(_params, "sessions.patch params")).toMatchObject({
key: "usage-off-session",
responseUsage: "off",
});
}
return { ok: true };
}) as GatewayClient["request"];
const agent = new AcpGatewayAgent(connection, createAcpGateway(request), {
sessionStore,
});
await agent.loadSession(createLoadSessionRequest("usage-off-session"));
const result = await agent.setSessionConfigOption(
createSetSessionConfigOptionRequest("usage-off-session", "response_usage", "off"),
);
expectConfigOption(result.configOptions, "response_usage", { currentValue: "off" });
expect(
(request as unknown as MockCallSource).mock.calls.some(
([method]) => method === "sessions.patch",
),
).toBe(true);
sessionStore.clearAllSessionsForTest();
});
});

View File

@@ -98,7 +98,8 @@ describe("acp session UX bridge behavior", () => {
});
expectConfigOption(result.configOptions, "verbose_level", { currentValue: "off" });
expectConfigOption(result.configOptions, "reasoning_level", { currentValue: "off" });
expectConfigOption(result.configOptions, "response_usage", { currentValue: "off" });
// Unset session inherits the configured default → control reads "inherit", not "off".
expectConfigOption(result.configOptions, "response_usage", { currentValue: "inherit" });
expectConfigOption(result.configOptions, "elevated_level", { currentValue: "off" });
sessionStore.clearAllSessionsForTest();

View File

@@ -801,8 +801,7 @@ export class AcpGatewayAgent implements Agent {
const promptKey = this.pendingPromptKey(params.sessionId, runId);
if (
isGatewayCloseError(err) &&
(this.getPendingPrompt(params.sessionId, runId) ||
this.settlingPromptKeys.has(promptKey))
(this.getPendingPrompt(params.sessionId, runId) || this.settlingPromptKeys.has(promptKey))
) {
return;
}
@@ -1592,7 +1591,7 @@ export class AcpGatewayAgent implements Agent {
value: string | boolean,
): {
overrides: Partial<GatewaySessionPresentationRow>;
patch?: Record<string, string | boolean>;
patch?: Record<string, string | boolean | null>;
} {
if (typeof value !== "string") {
throw new Error(
@@ -1630,11 +1629,13 @@ export class AcpGatewayAgent implements Agent {
patch: { reasoningLevel: value },
overrides: { reasoningLevel: value },
};
case ACP_RESPONSE_USAGE_CONFIG_ID:
case ACP_RESPONSE_USAGE_CONFIG_ID: {
const next = value === "inherit" ? null : value;
return {
patch: { responseUsage: value },
overrides: { responseUsage: value as GatewaySessionPresentationRow["responseUsage"] },
patch: { responseUsage: next },
overrides: { responseUsage: next as GatewaySessionPresentationRow["responseUsage"] },
};
}
case ACP_ELEVATED_LEVEL_CONFIG_ID:
return {
patch: { elevatedLevel: value },

View File

@@ -46,6 +46,11 @@ function requireExecTool(tools: ReturnType<typeof createOpenClawCodingTools>) {
return execTool;
}
function printEnvCommand(key: string): string {
const script = `process.stdout.write(process.env[${JSON.stringify(key)}] ?? "missing")`;
return `${JSON.stringify(process.execPath)} -e ${JSON.stringify(script)}`;
}
describe("Agent-specific exec tool defaults", () => {
beforeEach(() => {
setActivePluginRegistry(createSessionConversationTestRegistry());
@@ -291,4 +296,191 @@ describe("Agent-specific exec tool defaults", () => {
const details = result?.details as { status?: string } | undefined;
expect(details?.status).toBe("completed");
});
it("injects configured env only into the selected agent and can drop inherited env", async () => {
if (process.platform === "win32") {
return;
}
const key = "OPENCLAW_TEST_AGENT_SCOPED_EXEC_ENV";
const previous = process.env[key];
process.env[key] = "gateway-value";
try {
const cfg: OpenClawConfig = {
tools: { exec: { host: "gateway", security: "full", ask: "off" } },
agents: {
list: [
{
id: "referrals",
tools: {
exec: {
inheritHostEnv: false,
env: { [key]: "agent-value" },
},
},
},
{
id: "helper",
tools: { exec: { inheritHostEnv: false } },
},
],
},
};
const referralsExec = requireExecTool(
createOpenClawCodingTools({
config: cfg,
agentId: "referrals",
workspaceDir: "/tmp/test-referrals-env",
agentDir: "/tmp/agent-referrals-env",
}),
);
const referralsResult = await referralsExec.execute("call-referrals-env", {
command: printEnvCommand(key),
env: { [key]: "model-value" },
});
expect((referralsResult.content[0] as { text?: string }).text).toContain("agent-value");
const helperExec = requireExecTool(
createOpenClawCodingTools({
config: cfg,
agentId: "helper",
workspaceDir: "/tmp/test-helper-env",
agentDir: "/tmp/agent-helper-env",
}),
);
const helperResult = await helperExec.execute("call-helper-env", {
command: printEnvCommand(key),
});
expect((helperResult.content[0] as { text?: string }).text).toContain("missing");
} finally {
if (previous === undefined) {
delete process.env[key];
} else {
process.env[key] = previous;
}
}
});
it("keeps dangerous configured host env keys behind the existing security filter", async () => {
const execTool = requireExecTool(
createOpenClawCodingTools({
config: {
tools: { exec: { host: "gateway", security: "full", ask: "off" } },
agents: {
list: [
{
id: "ops",
tools: { exec: { env: { PATH: "/tmp/untrusted" } } },
},
],
},
},
agentId: "ops",
workspaceDir: "/tmp/test-ops-env-filter",
agentDir: "/tmp/agent-ops-env-filter",
}),
);
await expect(
execTool.execute("call-ops-env-filter", { command: "echo blocked" }),
).rejects.toThrow("PATH is controlled by tools.exec.pathPrepend");
});
it("allows source-config tool inspection but rejects unresolved SecretRefs on execution", async () => {
const execTool = requireExecTool(
createOpenClawCodingTools({
config: {
tools: { exec: { host: "gateway", security: "full", ask: "off" } },
agents: {
list: [
{
id: "ops",
tools: {
exec: {
env: {
SCOPED_CREDENTIAL: {
source: "env",
provider: "default",
id: "OPS_SCOPED_CREDENTIAL",
},
},
},
},
},
],
},
},
agentId: "ops",
workspaceDir: "/tmp/test-ops-unresolved-env",
agentDir: "/tmp/agent-ops-unresolved-env",
}),
);
await expect(
execTool.execute("call-ops-unresolved-env", { command: "echo blocked" }),
).rejects.toThrow("contains an unresolved SecretRef");
});
it("rejects attempts to spoof trusted channel context through per-call env", async () => {
const execTool = requireExecTool(
createOpenClawCodingTools({
config: { tools: { exec: { host: "gateway", security: "full", ask: "off" } } },
agentId: "ops",
workspaceDir: "/tmp/test-ops-channel-context-env",
agentDir: "/tmp/agent-ops-channel-context-env",
}),
);
await expect(
execTool.execute("call-ops-channel-context-env", {
command: "echo blocked",
env: { OPENCLAW_CHANNEL_CONTEXT: "spoofed" },
}),
).rejects.toThrow("reserved for trusted channel context");
});
it("rejects host-env minimization when effective exec host is a remote node", async () => {
const execTool = requireExecTool(
createOpenClawCodingTools({
config: {
tools: { exec: { host: "node", security: "full", ask: "off" } },
agents: {
list: [{ id: "ops", tools: { exec: { inheritHostEnv: false } } }],
},
},
agentId: "ops",
workspaceDir: "/tmp/test-ops-node-env",
agentDir: "/tmp/agent-ops-node-env",
}),
);
await expect(
execTool.execute("call-ops-node-env", { command: "echo blocked" }),
).rejects.toThrow("configure environment isolation on the node host");
});
it("rejects agent-scoped env before remote-node preparation", async () => {
const execTool = requireExecTool(
createOpenClawCodingTools({
config: {
tools: { exec: { host: "node", security: "full", ask: "always" } },
agents: {
list: [
{
id: "ops",
tools: { exec: { env: { SCOPED_TOKEN: "must-stay-on-gateway" } } },
},
],
},
},
agentId: "ops",
workspaceDir: "/tmp/test-ops-node-scoped-env",
agentDir: "/tmp/agent-ops-node-scoped-env",
}),
);
await expect(
execTool.execute("call-ops-node-scoped-env", { command: "echo blocked" }),
).rejects.toThrow("configure scoped environment on the node host");
});
});

View File

@@ -347,6 +347,8 @@ function resolveExecConfig(params: { cfg?: OpenClawConfig; agentId?: string }) {
security: layeredPolicy.security,
ask: layeredPolicy.ask,
node: agentExec?.node ?? globalExec?.node,
env: agentExec?.env,
inheritHostEnv: agentExec?.inheritHostEnv,
pathPrepend: agentExec?.pathPrepend ?? globalExec?.pathPrepend,
safeBins: agentExec?.safeBins ?? globalExec?.safeBins,
strictInlineEval: agentExec?.strictInlineEval ?? globalExec?.strictInlineEval,
@@ -815,6 +817,8 @@ export function createOpenClawCodingTools(options?: {
reviewer: options?.exec?.reviewer ?? execConfig.reviewer,
trigger: options?.trigger,
node: options?.exec?.node ?? execConfig.node,
env: options?.exec?.env ?? execConfig.env,
inheritHostEnv: options?.exec?.inheritHostEnv ?? execConfig.inheritHostEnv,
pathPrepend: options?.exec?.pathPrepend ?? execConfig.pathPrepend,
safeBins: options?.exec?.safeBins ?? execConfig.safeBins,
strictInlineEval: options?.exec?.strictInlineEval ?? execConfig.strictInlineEval,

View File

@@ -4,9 +4,26 @@
* by sandboxed exec calls.
*/
import { describe, expect, it } from "vitest";
import { buildDockerExecArgs } from "./bash-tools.shared.js";
import { buildDockerExecArgs, buildSandboxEnv } from "./bash-tools.shared.js";
describe("buildDockerExecArgs", () => {
it("keeps case-distinct sandbox variables separate from PATH and HOME", () => {
const env = buildSandboxEnv({
defaultPath: "/usr/bin:/bin",
containerWorkdir: "/workspace",
sandboxEnv: { path: "lower-path", home: "lower-home" },
paramsEnv: { Path: "mixed-path" },
});
expect(env).toMatchObject({
PATH: "/usr/bin:/bin",
HOME: "/workspace",
path: "lower-path",
home: "lower-home",
Path: "mixed-path",
});
});
it("prepends custom PATH after login shell sourcing to preserve both custom and system tools", () => {
const args = buildDockerExecArgs({
containerName: "test-container",

View File

@@ -60,6 +60,7 @@ function restoreProcessPlatformForTest(): void {
type ApprovalRequestPayload = {
approvalReviewerDeviceIds?: string[];
commandSpans?: Array<{ startIndex: number; endIndex: number }>;
env?: Record<string, string>;
};
function requireApprovalRequestPayload(callIndex: number): ApprovalRequestPayload {
@@ -177,6 +178,24 @@ describe("exec approval requests", () => {
expect(payload?.approvalReviewerDeviceIds).toEqual(["device-ios-reviewer"]);
});
it("sends only value-free env metadata for gateway approval registration", async () => {
vi.mocked(callGatewayTool).mockResolvedValue({ id: "approval-id", expiresAtMs: 1234 });
await registerExecApprovalRequestForHost({
approvalId: "approval-id",
command: "echo hi",
env: { SCOPED_TOKEN: "do-not-serialize", REGION: "us-east-1" },
workdir: "/tmp/project",
host: "gateway",
security: "allowlist",
ask: "always",
});
const payload = requireApprovalRequestPayload(0);
expect(payload.env).toEqual({ SCOPED_TOKEN: "", REGION: "" });
expect(JSON.stringify(payload)).not.toContain("do-not-serialize");
});
it("does not generate command spans by default", async () => {
vi.mocked(callGatewayTool).mockResolvedValue({ id: "approval-id", expiresAtMs: 1234 });

View File

@@ -300,7 +300,10 @@ async function buildHostApprovalDecisionParams(
command: params.command,
commandArgv: params.commandArgv,
systemRunPlan: params.systemRunPlan,
env: params.env,
env:
params.host === "node" || params.env === undefined
? params.env
: Object.fromEntries(Object.keys(params.env).map((key) => [key, ""])),
cwd: params.workdir,
nodeId: params.nodeId,
host: params.host,

View File

@@ -75,6 +75,7 @@ type ProcessGatewayAllowlistParams = {
workdir: string;
env: Record<string, string>;
pathPrepend?: string[];
useShellSnapshot?: boolean;
requestedEnv?: Record<string, string>;
pty: boolean;
timeoutSec?: number;
@@ -958,6 +959,7 @@ export async function processGatewayAllowlist(
workdir: params.workdir,
env: params.env,
pathPrepend: params.pathPrepend,
useShellSnapshot: params.useShellSnapshot,
sandbox: undefined,
containerWorkdir: null,
usePty: params.pty,

View File

@@ -11,6 +11,7 @@ const enqueueSystemEventMock = vi.hoisted(() => vi.fn());
const supervisorMock = vi.hoisted(() => ({
spawn: vi.fn(),
}));
const maybeWrapCommandWithShellSnapshotMock = vi.hoisted(() => vi.fn());
vi.mock("../infra/heartbeat-wake.js", () => ({
requestHeartbeat: requestHeartbeatMock,
@@ -26,6 +27,10 @@ vi.mock("../process/supervisor/index.js", () => ({
}),
}));
vi.mock("./shell-snapshot.js", () => ({
maybeWrapCommandWithShellSnapshot: maybeWrapCommandWithShellSnapshotMock,
}));
let markBackgrounded: typeof import("./bash-process-registry.js").markBackgrounded;
let buildExecExitOutcome: typeof import("./bash-tools.exec-runtime.js").buildExecExitOutcome;
let detectCursorKeyMode: typeof import("./bash-tools.exec-runtime.js").detectCursorKeyMode;
@@ -50,6 +55,10 @@ beforeEach(() => {
requestHeartbeatMock.mockClear();
enqueueSystemEventMock.mockClear();
supervisorMock.spawn.mockReset();
maybeWrapCommandWithShellSnapshotMock.mockReset();
maybeWrapCommandWithShellSnapshotMock.mockImplementation(
async ({ command }: { command: string }) => command,
);
});
function expectExecTarget(
@@ -582,6 +591,42 @@ describe("buildExecExitOutcome", () => {
});
describe("runExecProcess POSIX command wrapper", () => {
it("skips shell startup snapshots when host env inheritance is disabled", async () => {
supervisorMock.spawn.mockResolvedValueOnce({
runId: "mock-run",
startedAtMs: Date.now(),
wait: async () => ({
reason: "exit",
exitCode: 0,
exitSignal: null,
durationMs: 0,
stdout: "",
stderr: "",
timedOut: false,
noOutputTimedOut: false,
}),
cancel: vi.fn(),
});
await runExecProcess({
command: "echo isolated",
workdir: process.platform === "win32" ? "C:\\tmp" : "/tmp",
env: {},
useShellSnapshot: false,
usePty: false,
warnings: [],
maxOutput: 1000,
pendingMaxOutput: 1000,
notifyOnExit: false,
timeoutSec: null,
});
expect(maybeWrapCommandWithShellSnapshotMock).not.toHaveBeenCalled();
const spawnCall = supervisorMock.spawn.mock.calls[0]?.[0];
const command = spawnCall?.argv?.join(" ") ?? spawnCall?.ptyCommand ?? "";
expect(command).toContain("echo isolated");
});
it("normalizes non-finite and oversized exec timeouts before spawning", async () => {
supervisorMock.spawn.mockResolvedValue({
runId: "mock-run",

View File

@@ -580,6 +580,8 @@ export async function runExecProcess(opts: {
workdir: string;
env: Record<string, string>;
pathPrepend?: string[];
/** Whether to restore the Gateway user's cached shell startup state. */
useShellSnapshot?: boolean;
sandbox?: BashSandboxConfig;
containerWorkdir?: string | null;
usePty: boolean;
@@ -764,13 +766,16 @@ export async function runExecProcess(opts: {
shellRuntimeEnv,
opts.pathPrepend,
);
const commandWithShellSnapshot = await maybeWrapCommandWithShellSnapshot({
command: commandWithPathPrepend,
shell,
shellArgs,
cwd: opts.workdir,
env: shellRuntimeEnv,
});
const commandWithShellSnapshot =
opts.useShellSnapshot === false
? commandWithPathPrepend
: await maybeWrapCommandWithShellSnapshot({
command: commandWithPathPrepend,
shell,
shellArgs,
cwd: opts.workdir,
env: shellRuntimeEnv,
});
const childArgv = [shell, ...shellArgs, commandWithShellSnapshot];
if (opts.usePty) {

View File

@@ -29,6 +29,10 @@ export type ExecToolDefaults = {
ask?: ExecAsk;
trigger?: string;
node?: string;
/** Trusted, operator-configured environment scoped to this agent's exec children. */
env?: Record<string, unknown>;
/** Inherit the Gateway process environment for Gateway-hosted exec (default: true). */
inheritHostEnv?: boolean;
pathPrepend?: string[];
safeBins?: string[];
strictInlineEval?: boolean;

View File

@@ -33,7 +33,9 @@ const mocks = vi.hoisted(() => ({
requestedEnv?: Record<string, string>;
}>,
spawnInputs: [] as Array<{
argv?: string[];
env?: Record<string, string>;
ptyCommand?: string;
}>,
}));
@@ -84,8 +86,17 @@ vi.mock("./bash-tools.exec-host-node.js", () => ({
vi.mock("../process/supervisor/index.js", () => ({
getProcessSupervisor: () => ({
spawn: async (input: { env?: Record<string, string>; onStdout?: (chunk: string) => void }) => {
mocks.spawnInputs.push({ env: input.env ? { ...input.env } : undefined });
spawn: async (input: {
argv?: string[];
env?: Record<string, string>;
onStdout?: (chunk: string) => void;
ptyCommand?: string;
}) => {
mocks.spawnInputs.push({
argv: input.argv ? [...input.argv] : undefined,
env: input.env ? { ...input.env } : undefined,
ptyCommand: input.ptyCommand,
});
input.onStdout?.("ok\n");
return {
runId: "mock-run",
@@ -230,6 +241,90 @@ describe("exec resolve_exec_env hook wiring", () => {
});
});
it("applies inherited, model, agent, and plugin precedence across key casing", async () => {
const inheritedKey = "BREX_CASE_SCOPED_TOKEN";
const previous = process.env[inheritedKey];
process.env[inheritedKey] = "inherited";
installResolveExecEnvHook({ brex_case_scoped_token: "plugin" });
try {
const tool = createExecTool({
host: "gateway",
security: "full",
ask: "off",
env: { Brex_Case_Scoped_Token: "agent" },
});
await tool.execute("call-case-precedence", {
command: "echo ok",
env: { BREX_CASE_SCOPED_TOKEN: "model" },
yieldMs: 120_000,
});
const requestedMatches = Object.entries(mocks.gatewayParams[0]?.requestedEnv ?? {}).filter(
([key]) => key.toUpperCase() === inheritedKey,
);
const effectiveMatches = Object.entries(mocks.gatewayParams[0]?.env ?? {}).filter(
([key]) => key.toUpperCase() === inheritedKey,
);
expect(requestedMatches).toEqual([["brex_case_scoped_token", "plugin"]]);
expect(effectiveMatches).toEqual([["brex_case_scoped_token", "plugin"]]);
} finally {
if (previous === undefined) {
delete process.env[inheritedKey];
} else {
process.env[inheritedKey] = previous;
}
}
});
it.each(["gateway", "node"] as const)(
"drops stale inherited channel context for %s exec without turn context",
async (host) => {
const previous = process.env[CHANNEL_CONTEXT_ENV_KEY];
process.env[CHANNEL_CONTEXT_ENV_KEY] = "stale-channel-context";
try {
const tool = createExecTool({ host, security: "full", ask: "off" });
await tool.execute(`call-stale-context-${host}`, {
command: "echo ok",
yieldMs: 120_000,
});
const effectiveEnv =
host === "node" ? mocks.nodeHostParams[0]?.env : mocks.gatewayParams[0]?.env;
expect(effectiveEnv).not.toHaveProperty(CHANNEL_CONTEXT_ENV_KEY);
} finally {
if (previous === undefined) {
delete process.env[CHANNEL_CONTEXT_ENV_KEY];
} else {
process.env[CHANNEL_CONTEXT_ENV_KEY] = previous;
}
}
},
);
it("drops stale sandbox channel context when the turn has no channel context", async () => {
const tool = createExecTool({
host: "sandbox",
security: "full",
ask: "off",
cwd: process.cwd(),
sandbox: {
containerName: "openclaw-test-sandbox",
workspaceDir: process.cwd(),
containerWorkdir: "/workspace",
env: { [CHANNEL_CONTEXT_ENV_KEY]: "stale-sandbox-context" },
},
});
await tool.execute("call-stale-sandbox-context", {
command: "echo ok",
yieldMs: 120_000,
});
expect(JSON.stringify(mocks.spawnInputs[0])).not.toContain("stale-sandbox-context");
expect(JSON.stringify(mocks.spawnInputs[0])).not.toContain(CHANNEL_CONTEXT_ENV_KEY);
});
it("forwards filtered plugin env to node host requests", async () => {
installResolveExecEnvHook({
NODE_HOST_SAFE: "yes",

View File

@@ -34,8 +34,14 @@ import {
isDangerousHostEnvVarName,
normalizeHostOverrideEnvVarKey,
sanitizeHostExecEnvWithDiagnostics,
setCaseInsensitiveEnvValue,
validateConfiguredExecEnvKey,
} from "../infra/host-env-security.js";
import { OPENCLAW_CLI_ENV_VAR } from "../infra/openclaw-exec-env.js";
import {
OPENCLAW_CHANNEL_CONTEXT_ENV_VAR,
OPENCLAW_CLI_ENV_VAR,
} from "../infra/openclaw-exec-env.js";
import { isBlockedObjectKey } from "../infra/prototype-keys.js";
import {
getShellPathFromLoginShell,
resolveShellEnvFallbackTimeoutMs,
@@ -109,7 +115,7 @@ type ExecToolArgs = Record<string, unknown> & {
node?: string;
};
const CHANNEL_CONTEXT_ENV_KEY = "OPENCLAW_CHANNEL_CONTEXT";
const CHANNEL_CONTEXT_ENV_KEY = OPENCLAW_CHANNEL_CONTEXT_ENV_VAR;
function buildSubprocessChannelContext(
channelContext: PluginHookChannelContext | undefined,
@@ -152,23 +158,88 @@ function filterPluginExecEnv(rawEnv: Record<string, string>): Record<string, str
const env: Record<string, string> = {};
for (const [rawKey, value] of Object.entries(rawEnv)) {
const key = normalizeHostOverrideEnvVarKey(rawKey);
if (!key) {
if (!key || isBlockedObjectKey(key)) {
continue;
}
const upperKey = key.toUpperCase();
if (
upperKey === "PATH" ||
upperKey === OPENCLAW_CLI_ENV_VAR ||
upperKey === CHANNEL_CONTEXT_ENV_KEY ||
isDangerousHostEnvVarName(upperKey) ||
isDangerousHostEnvOverrideVarName(upperKey)
) {
continue;
}
env[key] = value;
setCaseInsensitiveEnvValue(env, key, value);
}
return Object.keys(env).length > 0 ? env : undefined;
}
function resolveMaterializedExecEnv(
env: Record<string, unknown> | undefined,
): Record<string, string> | undefined {
if (!env) {
return undefined;
}
const resolved: Record<string, string> = {};
const seen = new Set<string>();
for (const [key, value] of Object.entries(env)) {
const validation = validateConfiguredExecEnvKey(key);
if (!validation.ok) {
throw new Error(`agents.list[].tools.exec.env.${key} ${validation.reason}`);
}
if (seen.has(validation.caseFoldedKey)) {
throw new Error(
`agents.list[].tools.exec.env contains duplicate key ${JSON.stringify(key)} (case-insensitive)`,
);
}
seen.add(validation.caseFoldedKey);
if (typeof value !== "string") {
throw new Error(
`agents.list[].tools.exec.env.${key} contains an unresolved SecretRef; use the active runtime config snapshot`,
);
}
setCaseInsensitiveEnvValue(resolved, validation.key, value);
}
return resolved;
}
function mergeExecEnvLayers(
...layers: Array<Record<string, string> | undefined>
): Record<string, string> | undefined {
const merged: Record<string, string> = {};
let hasLayer = false;
for (const layer of layers) {
if (layer === undefined) {
continue;
}
hasLayer = true;
for (const [key, value] of Object.entries(layer)) {
if (isBlockedObjectKey(key)) {
throw new Error(`Security Violation: Environment variable '${key}' is forbidden.`);
}
setCaseInsensitiveEnvValue(merged, key, value);
}
}
return hasLayer ? merged : undefined;
}
function applyTrustedChannelContextEnv(
env: Record<string, string>,
channelContextEnv: Record<string, string> | undefined,
): void {
for (const key of Object.keys(env)) {
if (key.trim().toUpperCase() === CHANNEL_CONTEXT_ENV_KEY) {
delete env[key];
}
}
const trustedValue = channelContextEnv?.[CHANNEL_CONTEXT_ENV_KEY];
if (trustedValue !== undefined) {
env[CHANNEL_CONTEXT_ENV_KEY] = trustedValue;
}
}
function markResolveExecEnvPrepared<T extends ExecToolArgs>(
params: T,
state: ResolvedExecEnvPreparedState = {},
@@ -1597,15 +1668,34 @@ export function createExecTool(
}
await rejectUnsafeExecControlShellCommand(params.command);
const inheritedBaseEnv = coerceEnv(process.env);
const hasConfiguredEnv = Object.keys(defaults?.env ?? {}).length > 0;
if (host === "node" && (defaults?.inheritHostEnv === false || hasConfiguredEnv)) {
throw new Error(
hasConfiguredEnv
? "agents.list[].tools.exec.env is not supported for host=node; configure scoped environment on the node host"
: "tools.exec.inheritHostEnv=false is not supported for host=node; configure environment isolation on the node host",
);
}
const configuredEnv = resolveMaterializedExecEnv(defaults?.env);
for (const source of [params.env, configuredEnv]) {
if (
source &&
Object.keys(source).some((key) => key.trim().toUpperCase() === CHANNEL_CONTEXT_ENV_KEY)
) {
throw new Error(
`Security Violation: Environment variable '${CHANNEL_CONTEXT_ENV_KEY}' is reserved for trusted channel context.`,
);
}
}
const inheritedBaseEnv = defaults?.inheritHostEnv === false ? {} : coerceEnv(process.env);
const resolvedExecEnvState = getResolvedExecEnvPreparedState(params);
const channelContextEnv = buildChannelContextEnv(defaults?.channelContext);
const requestedEnv: Record<string, string> | undefined =
params.env !== undefined ||
resolvedExecEnvState?.pluginEnv !== undefined ||
channelContextEnv !== undefined
? { ...params.env, ...resolvedExecEnvState?.pluginEnv, ...channelContextEnv }
: undefined;
const requestedEnv = mergeExecEnvLayers(
params.env,
configuredEnv,
resolvedExecEnvState?.pluginEnv,
channelContextEnv,
);
const hostEnvResult =
host === "sandbox"
? null
@@ -1658,8 +1748,14 @@ export function createExecTool(
containerWorkdir: containerWorkdir ?? sandbox.containerWorkdir,
})
: (hostEnvResult?.env ?? inheritedBaseEnv);
applyTrustedChannelContextEnv(env, channelContextEnv);
if (!sandbox && host === "gateway" && !requestedEnv?.PATH) {
if (
!sandbox &&
host === "gateway" &&
defaults?.inheritHostEnv !== false &&
!requestedEnv?.PATH
) {
const shellPath = getShellPathFromLoginShell({
env: process.env,
timeoutMs: resolveShellEnvFallbackTimeoutMs(process.env),
@@ -1722,6 +1818,7 @@ export function createExecTool(
workdir,
env,
pathPrepend: defaultPathPrepend,
useShellSnapshot: defaults?.inheritHostEnv !== false,
requestedEnv,
pty: params.pty === true && !sandbox,
timeoutSec: params.timeout,
@@ -1785,6 +1882,7 @@ export function createExecTool(
workdir,
env,
pathPrepend: defaultPathPrepend,
useShellSnapshot: defaults?.inheritHostEnv !== false,
sandbox,
containerWorkdir,
usePty,

View File

@@ -8,6 +8,7 @@ import fs from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
import { parseStrictInteger } from "@openclaw/normalization-core/number-coercion";
import { isBlockedObjectKey } from "../infra/prototype-keys.js";
import { sliceUtf16Safe } from "../utils.js";
import { assertSandboxPath } from "./sandbox-paths.js";
import type { SandboxBackendExecSpec } from "./sandbox/backend-handle.types.js";
@@ -46,10 +47,14 @@ export function buildSandboxEnv(params: {
HOME: params.containerWorkdir,
};
for (const [key, value] of Object.entries(params.sandboxEnv ?? {})) {
env[key] = value;
if (!isBlockedObjectKey(key)) {
env[key] = value;
}
}
for (const [key, value] of Object.entries(params.paramsEnv ?? {})) {
env[key] = value;
if (!isBlockedObjectKey(key)) {
env[key] = value;
}
}
return env;
}

View File

@@ -6,6 +6,7 @@ import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js";
import type { ChatType } from "../../channels/chat-type.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { ContextEngine, ContextEngineRuntimeContext } from "../../context-engine/types.js";
import type { Model } from "openclaw/plugin-sdk/llm";
import type { CommandQueueEnqueueFn } from "../../process/command-queue.types.js";
import type { SkillSnapshot } from "../../skills/types.js";
import type { ExecElevatedDefaults, ExecToolDefaults } from "../bash-tools.exec-types.js";
@@ -57,6 +58,8 @@ export type CompactEmbeddedAgentSessionParams = {
senderIsOwner?: boolean;
provider?: string;
model?: string;
/** Caller-resolved model/provider shape used by native harness compactors. */
runtimeModel?: Model;
/** Effective model fallback chain for this session attempt. Undefined uses config defaults. */
modelFallbacksOverride?: string[];
/** Optional caller-resolved context engine for harness-owned compaction. */

View File

@@ -5,6 +5,7 @@ import { formatErrorMessage } from "../../infra/errors.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { parseAgentSessionKey } from "../../routing/session-key.js";
import { resolveUserPath } from "../../utils.js";
import type { Model } from "openclaw/plugin-sdk/llm";
import { isDefaultAgentRuntimeId, normalizeOptionalAgentRuntimeId } from "../agent-runtime-id.js";
import { resolveAgentDir, resolveSessionAgentIds } from "../agent-scope.js";
import type { CompactEmbeddedAgentSessionParams } from "../embedded-agent-runner/compact.types.js";
@@ -62,19 +63,13 @@ function resolveHarnessCompactIdentity(params: CompactEmbeddedAgentSessionParams
async function resolveHarnessCompactApiKey(params: {
agentDir: string;
compactParams: CompactEmbeddedAgentSessionParams;
}): Promise<string | undefined> {
}): Promise<{ apiKey?: string; runtimeModel?: Model }> {
const { agentDir, compactParams } = params;
const existing = compactParams.resolvedApiKey?.trim();
if (existing) {
return existing;
}
if (
!compactParams.authProfileId?.trim() ||
!compactParams.provider?.trim() ||
!compactParams.model?.trim()
) {
return undefined;
if (!compactParams.provider?.trim() || !compactParams.model?.trim()) {
return existing ? { apiKey: existing } : {};
}
const authProfileId = compactParams.authProfileId?.trim() || undefined;
const workspaceDir = resolveUserPath(compactParams.workspaceDir);
const { model } = await resolveModelAsync(
compactParams.provider,
@@ -82,21 +77,34 @@ async function resolveHarnessCompactApiKey(params: {
agentDir,
compactParams.config,
{
authProfileId: compactParams.authProfileId,
authProfileId,
workspaceDir,
},
);
if (!model) {
return undefined;
return existing ? { apiKey: existing } : {};
}
if (existing) {
return { apiKey: existing, runtimeModel: model };
}
try {
const apiKeyInfo = await getApiKeyForModel({
model,
cfg: compactParams.config,
profileId: authProfileId,
agentDir,
workspaceDir,
});
return {
apiKey: apiKeyInfo.apiKey?.trim() || undefined,
runtimeModel: model,
};
} catch (err) {
log.debug("agent harness compaction credential lookup failed", {
error: formatErrorMessage(err),
});
return { runtimeModel: model };
}
const apiKeyInfo = await getApiKeyForModel({
model,
cfg: compactParams.config,
profileId: compactParams.authProfileId,
agentDir,
workspaceDir,
});
return apiKeyInfo.apiKey?.trim() || undefined;
}
/** Runs harness-provided compaction when the selected runtime supports it. */
@@ -169,20 +177,28 @@ export async function maybeCompactAgentHarnessSession(
agentDir: compactIdentity.agentDir,
agentId: compactIdentity.agentId,
};
let resolvedApiKey: string | undefined;
let resolvedApiKey = compactParams.resolvedApiKey?.trim() || undefined;
let runtimeModel: Model | undefined;
try {
resolvedApiKey = await resolveHarnessCompactApiKey({
const resolved = await resolveHarnessCompactApiKey({
agentDir: compactIdentity.agentDir,
compactParams,
});
resolvedApiKey = resolved.apiKey;
runtimeModel = resolved.runtimeModel;
} catch (err) {
log.debug("agent harness compaction credential lookup failed", {
error: formatErrorMessage(err),
});
}
const resolvedCompactParams = resolvedApiKey
? { ...compactParams, resolvedApiKey }
: compactParams;
const resolvedCompactParams =
resolvedApiKey || runtimeModel
? {
...compactParams,
...(resolvedApiKey ? { resolvedApiKey } : {}),
...(runtimeModel ? { runtimeModel } : {}),
}
: compactParams;
if (shouldCompactAfterContextEngine) {
return internalHarness.compactAfterContextEngine?.(resolvedCompactParams);
}

View File

@@ -31,6 +31,9 @@ const compactAuthMocks = vi.hoisted(() => ({
getApiKeyForModel: vi.fn(),
resolveModelAsync: vi.fn(),
}));
const providerOwnerMocks = vi.hoisted(() => ({
resolveProviderRefOwnership: vi.fn(),
}));
vi.mock("./builtin-openclaw.js", () => ({
createOpenClawAgentHarness: (): AgentHarness => ({
@@ -47,6 +50,9 @@ vi.mock("../model-auth.js", () => ({
vi.mock("../embedded-agent-runner/model.js", () => ({
resolveModelAsync: compactAuthMocks.resolveModelAsync,
}));
vi.mock("../../plugins/providers.js", () => ({
resolveProviderRefOwnership: providerOwnerMocks.resolveProviderRefOwnership,
}));
const originalRuntime = process.env.OPENCLAW_AGENT_RUNTIME;
@@ -56,6 +62,8 @@ beforeEach(() => {
model: { id: "gpt-5.5", provider: "openai" },
});
compactAuthMocks.getApiKeyForModel.mockResolvedValue({ apiKey: "test-key" });
providerOwnerMocks.resolveProviderRefOwnership.mockReset();
providerOwnerMocks.resolveProviderRefOwnership.mockReturnValue({ status: "unowned" });
cliBackendsTesting.setDepsForTest({
resolvePluginSetupRegistry: () => ({
providers: [],
@@ -87,6 +95,7 @@ afterEach(() => {
agentRunAttempt.mockClear();
compactAuthMocks.resolveModelAsync.mockReset();
compactAuthMocks.getApiKeyForModel.mockReset();
providerOwnerMocks.resolveProviderRefOwnership.mockReset();
if (originalRuntime == null) {
delete process.env.OPENCLAW_AGENT_RUNTIME;
} else {
@@ -639,6 +648,141 @@ describe("selectAgentHarness", () => {
expect(supports).toHaveBeenCalledTimes(1);
});
it("passes manifest provider owners into plugin support checks", () => {
providerOwnerMocks.resolveProviderRefOwnership.mockReturnValue({
status: "owned",
pluginIds: ["anthropic"],
});
const supports = vi.fn(() => ({
supported: false as const,
reason: "provider is owned by a native plugin",
}));
const config = providerRuntimeConfig("anthropic", "copilot");
registerAgentHarness({
id: "copilot",
label: "Copilot",
supports,
runAttempt: vi.fn(async () => createAttemptResult("copilot")),
});
expect(() =>
selectAgentHarness({
provider: "anthropic",
modelId: "claude-sonnet-4.6",
config,
agentHarnessRuntimeOverride: "copilot",
}),
).toThrow("provider is owned by a native plugin");
expect(providerOwnerMocks.resolveProviderRefOwnership).toHaveBeenCalledWith({
provider: "anthropic",
config,
});
expect(supports).toHaveBeenCalledWith(
expect.objectContaining({
provider: "anthropic",
modelId: "claude-sonnet-4.6",
requestedRuntime: "copilot",
providerOwnerStatus: "owned",
providerOwnerPluginIds: ["anthropic"],
}),
);
});
it("passes ambiguous provider ownership into plugin support checks", () => {
providerOwnerMocks.resolveProviderRefOwnership.mockReturnValue({
status: "ambiguous",
pluginIds: ["first-owner", "second-owner"],
});
const supports = vi.fn(() => ({
supported: false as const,
reason: "provider ownership is ambiguous",
}));
const config = providerRuntimeConfig("custom-proxy", "copilot");
registerAgentHarness({
id: "copilot",
label: "Copilot",
supports,
runAttempt: vi.fn(async () => createAttemptResult("copilot")),
});
expect(() =>
selectAgentHarness({
provider: "custom-proxy",
modelId: "proxy-model",
config,
agentHarnessRuntimeOverride: "copilot",
}),
).toThrow("provider ownership is ambiguous");
expect(supports).toHaveBeenCalledWith(
expect.objectContaining({
provider: "custom-proxy",
providerOwnerStatus: "ambiguous",
providerOwnerPluginIds: ["first-owner", "second-owner"],
}),
);
});
it("passes resolved provider model shape into plugin support checks", () => {
const supports = vi.fn(() => ({
supported: false as const,
reason: "unsupported test provider",
}));
const config = {
models: {
providers: {
"custom-proxy": {
api: "openai-completions",
baseUrl: "https://provider.example/v1",
request: { auth: { mode: "provider-default" as const } },
agentRuntime: { id: "copilot" },
models: [
{
id: "gpt-test",
name: "GPT Test",
api: "openai-responses",
baseUrl: "https://model.example/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8_192,
maxTokens: 1_024,
},
],
},
},
},
} as OpenClawConfig;
registerAgentHarness({
id: "copilot",
label: "Copilot",
supports,
runAttempt: vi.fn(async () => createAttemptResult("copilot")),
});
expect(() =>
selectAgentHarness({
provider: "custom-proxy",
modelId: "gpt-test",
config,
agentHarnessRuntimeOverride: "copilot",
}),
).toThrow("unsupported test provider");
expect(supports).toHaveBeenCalledWith(
expect.objectContaining({
provider: "custom-proxy",
modelId: "gpt-test",
modelProvider: expect.objectContaining({
api: "openai-responses",
baseUrl: "https://model.example/v1",
request: { auth: { mode: "provider-default" } },
}),
}),
);
});
it("honors explicit OpenClaw runtime overrides when selecting a harness", async () => {
registerSuccessfulCodexHarness();
@@ -649,6 +793,7 @@ describe("selectAgentHarness", () => {
});
expect(harness.id).toBe("openclaw");
expect(providerOwnerMocks.resolveProviderRefOwnership).not.toHaveBeenCalled();
const result = await runAgentHarnessAttempt({
...createAttemptParams(),
@@ -833,6 +978,7 @@ describe("selectAgentHarness", () => {
workspaceDir: "/tmp/workspace",
provider: "openai",
model: "gpt-5.5",
authProfileId: "main-profile",
agentHarnessId: "codex",
config: {
agents: {
@@ -850,6 +996,11 @@ describe("selectAgentHarness", () => {
expect(compact.mock.calls[0]?.[0]).toMatchObject({
agentDir: "/tmp/main-agent",
agentId: "main",
resolvedApiKey: "test-key",
runtimeModel: {
id: "gpt-5.5",
provider: "openai",
},
});
});
@@ -991,6 +1142,118 @@ describe("selectAgentHarness", () => {
);
});
it("preserves resolved compaction credentials when model lookup fails", async () => {
compactAuthMocks.resolveModelAsync.mockRejectedValue(new Error("model lookup unavailable"));
const compact = vi.fn<NonNullable<AgentHarness["compact"]>>(async () => ({
ok: true,
compacted: false,
}));
registerAgentHarness(
{
id: "copilot",
label: "Copilot",
supports: (ctx) =>
ctx.provider === "local-proxy"
? { supported: true, priority: 100 }
: { supported: false },
runAttempt: vi.fn(async () => createAttemptResult("copilot")),
compact,
},
{ ownerPluginId: "copilot" },
);
await expect(
maybeCompactAgentHarnessSession({
sessionId: "session-1",
sessionKey: "agent:main:main",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp/workspace",
provider: "local-proxy",
model: "proxy-model",
resolvedApiKey: "already-resolved",
agentHarnessId: "copilot",
}),
).resolves.toEqual({ ok: true, compacted: false });
expect(compactAuthMocks.getApiKeyForModel).not.toHaveBeenCalled();
expect(compact).toHaveBeenCalledWith(
expect.objectContaining({
resolvedApiKey: "already-resolved",
}),
);
});
it("passes runtime model and default credentials to compaction when auth profile id is absent", async () => {
compactAuthMocks.resolveModelAsync.mockResolvedValue({
model: {
id: "proxy-model",
provider: "local-proxy",
api: "openai-responses",
baseUrl: "https://proxy.example/v1",
},
});
const compact = vi.fn<NonNullable<AgentHarness["compact"]>>(async () => ({
ok: true,
compacted: false,
}));
registerAgentHarness(
{
id: "copilot",
label: "Copilot",
supports: (ctx) =>
ctx.provider === "local-proxy"
? { supported: true, priority: 100 }
: { supported: false },
runAttempt: vi.fn(async () => createAttemptResult("copilot")),
compact,
},
{ ownerPluginId: "copilot" },
);
await expect(
maybeCompactAgentHarnessSession({
sessionId: "session-1",
sessionKey: "agent:main:main",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp/workspace",
provider: "local-proxy",
model: "proxy-model",
agentHarnessId: "copilot",
}),
).resolves.toEqual({ ok: true, compacted: false });
expect(compactAuthMocks.resolveModelAsync).toHaveBeenCalledWith(
"local-proxy",
"proxy-model",
expect.any(String),
undefined,
expect.objectContaining({
authProfileId: undefined,
workspaceDir: "/tmp/workspace",
}),
);
expect(compactAuthMocks.getApiKeyForModel).toHaveBeenCalledWith(
expect.objectContaining({
agentDir: expect.any(String),
model: expect.objectContaining({
baseUrl: "https://proxy.example/v1",
id: "proxy-model",
}),
profileId: undefined,
workspaceDir: "/tmp/workspace",
}),
);
expect(compact).toHaveBeenCalledWith(
expect.objectContaining({
resolvedApiKey: "test-key",
runtimeModel: expect.objectContaining({
baseUrl: "https://proxy.example/v1",
id: "proxy-model",
}),
}),
);
});
it("does not compact a selected plugin harness through OpenClaw when the plugin has no compactor", async () => {
registerFailingCodexHarness();

View File

@@ -1,3 +1,4 @@
import { findNormalizedProviderValue } from "@openclaw/model-catalog-core/provider-id";
/**
* Selects and invokes native agent harnesses for embedded run attempts.
*/
@@ -11,6 +12,7 @@ import {
} from "../../infra/diagnostic-trace-context.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { resolveProviderRefOwnership } from "../../plugins/providers.js";
import { isDefaultAgentRuntimeId, normalizeOptionalAgentRuntimeId } from "../agent-runtime-id.js";
import {
resolveEffectiveToolPolicy,
@@ -43,7 +45,7 @@ import {
type AgentHarnessPolicy,
} from "./policy.js";
import { getRegisteredAgentHarness, listRegisteredAgentHarnesses } from "./registry.js";
import type { AgentHarness, AgentHarnessSupport } from "./types.js";
import type { AgentHarness, AgentHarnessSupport, AgentHarnessSupportContext } from "./types.js";
const log = createSubsystemLogger("agents/harness");
export { resolveAgentHarnessPolicy } from "./policy.js";
@@ -153,6 +155,56 @@ function compareHarnessSupport(
return left.harness.id.localeCompare(right.harness.id);
}
function buildAgentHarnessSupportContext(params: {
provider: string;
modelId?: string;
requestedRuntime: AgentHarnessSupportContext["requestedRuntime"];
config?: OpenClawConfig;
}): AgentHarnessSupportContext {
const providerOwnership = resolveProviderRefOwnership({
provider: params.provider,
config: params.config,
});
return {
provider: params.provider,
modelId: params.modelId,
modelProvider: buildAgentHarnessSupportModelProvider(params),
requestedRuntime: params.requestedRuntime,
providerOwnerStatus: providerOwnership.status,
providerOwnerPluginIds:
providerOwnership.status === "unowned" ? [] : providerOwnership.pluginIds,
};
}
function buildAgentHarnessSupportModelProvider(params: {
provider: string;
modelId?: string;
config?: OpenClawConfig;
}): AgentHarnessSupportContext["modelProvider"] {
const providerConfig = findNormalizedProviderValue(
params.config?.models?.providers,
params.provider,
);
if (!providerConfig) {
return undefined;
}
const modelConfig = params.modelId
? providerConfig.models?.find((entry) => entry.id === params.modelId)
: undefined;
return {
api: modelConfig?.api ?? providerConfig.api ?? "openai-responses",
baseUrl: modelConfig?.baseUrl ?? providerConfig.baseUrl,
azureApiVersion: readStringParam(
modelConfig?.params?.azureApiVersion ?? providerConfig.params?.azureApiVersion,
),
request: providerConfig.request,
};
}
function readStringParam(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
export function selectAgentHarness(params: {
provider: string;
modelId?: string;
@@ -200,11 +252,13 @@ function selectAgentHarnessDecision(params: {
if (runtime !== "auto") {
const forced = pluginHarnesses.find((entry) => entry.id === runtime);
if (forced) {
const support = forced.supports({
const supportContext = buildAgentHarnessSupportContext({
provider: params.provider,
modelId: params.modelId,
requestedRuntime: runtime,
config: params.config,
});
const support = forced.supports(supportContext);
if (support.supported) {
return buildSelectionDecision({
harness: forced,
@@ -261,14 +315,21 @@ function selectAgentHarnessDecision(params: {
throw new MissingAgentHarnessError(runtime);
}
const candidates = pluginHarnesses.map((harness) => ({
harness,
support: harness.supports({
provider: params.provider,
modelId: params.modelId,
requestedRuntime: runtime,
}),
}));
const candidates =
pluginHarnesses.length > 0
? (() => {
const supportContext = buildAgentHarnessSupportContext({
provider: params.provider,
modelId: params.modelId,
requestedRuntime: runtime,
config: params.config,
});
return pluginHarnesses.map((harness) => ({
harness,
support: harness.supports(supportContext),
}));
})()
: [];
const supported = candidates
.filter(
(

View File

@@ -4,7 +4,20 @@
export type AgentHarnessSupportContext = {
provider: string;
modelId?: string;
modelProvider?: {
api?: string;
baseUrl?: string;
azureApiVersion?: string;
request?: {
auth?: { mode?: unknown };
proxy?: unknown;
tls?: unknown;
allowPrivateNetwork?: unknown;
};
};
requestedRuntime: import("../agent-runtime-id.js").EmbeddedAgentRuntime;
providerOwnerStatus?: "unowned" | "owned" | "ambiguous";
providerOwnerPluginIds?: readonly string[];
};
export type AgentHarnessSupport =

View File

@@ -73,6 +73,7 @@ function cleanedLockForPath(lockPath: string): SessionLockInspection {
ageMs: 1_000,
stale: true,
staleReasons: ["dead-pid"],
removable: true,
removed: true,
};
}

View File

@@ -0,0 +1,126 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { TaskRecord } from "../tasks/task-registry.types.js";
import {
buildActiveMediaGenerationTaskPromptContextForSession,
findActiveMediaGenerationTaskForSession,
findDuplicateGuardMediaGenerationTaskForSession,
listActiveMediaGenerationTasksForSession,
MEDIA_GENERATION_DELIVERING_COMPLETION_PROGRESS,
resetRecentMediaGenerationDuplicateGuardsForTests,
} from "./media-generation-task-status-shared.js";
const taskRuntimeInternalMocks = vi.hoisted(() => ({
listFreshTasksForOwnerKey: vi.fn(),
}));
vi.mock("../tasks/runtime-internal.js", () => taskRuntimeInternalMocks);
function makeTask(overrides: Partial<TaskRecord> = {}): TaskRecord {
const now = Date.now();
return {
taskId: "task-1",
runtime: "cli",
taskKind: "video-generate",
sourceId: "video-generate:byteplus",
requesterSessionKey: "session/A",
ownerKey: "session/A",
scopeKind: "session",
runId: "run-1",
task: "generate clip 01",
status: "running",
deliveryStatus: "not_applicable",
notifyPolicy: "silent",
createdAt: now,
startedAt: now,
lastEventAt: now,
...overrides,
};
}
beforeEach(() => {
resetRecentMediaGenerationDuplicateGuardsForTests();
taskRuntimeInternalMocks.listFreshTasksForOwnerKey.mockReset();
});
describe("media generation delivery-phase prompt guard", () => {
it("does not warn about a task waiting only for completion delivery", () => {
taskRuntimeInternalMocks.listFreshTasksForOwnerKey.mockReturnValue([
makeTask({ progressSummary: MEDIA_GENERATION_DELIVERING_COMPLETION_PROGRESS }),
]);
expect(
buildActiveMediaGenerationTaskPromptContextForSession({
sessionKey: "session/A",
taskKind: "video-generate",
sourcePrefix: "video-generate",
nounLabel: "video",
toolName: "video_generate",
completionLabel: "video",
}),
).toBeUndefined();
});
it("still warns while media generation is running", () => {
taskRuntimeInternalMocks.listFreshTasksForOwnerKey.mockReturnValue([
makeTask({ progressSummary: "Generating video" }),
]);
expect(
buildActiveMediaGenerationTaskPromptContextForSession({
sessionKey: "session/A",
taskKind: "video-generate",
sourcePrefix: "video-generate",
nounLabel: "video",
toolName: "video_generate",
completionLabel: "video",
}),
).toContain("Do not call `video_generate` again for the same request");
});
it("keeps delivery-phase tasks available to duplicate/status lookups", () => {
const task = makeTask({ progressSummary: MEDIA_GENERATION_DELIVERING_COMPLETION_PROGRESS });
taskRuntimeInternalMocks.listFreshTasksForOwnerKey.mockReturnValue([task]);
expect(
listActiveMediaGenerationTasksForSession({
sessionKey: "session/A",
taskKind: "video-generate",
sourcePrefix: "video-generate",
}),
).toEqual([task]);
expect(
findActiveMediaGenerationTaskForSession({
sessionKey: "session/A",
taskKind: "video-generate",
sourcePrefix: "video-generate",
}),
).toEqual(task);
});
it("blocks the same prompt while allowing a distinct prompt", () => {
const task = makeTask({
task: "generate clip 01",
progressSummary: MEDIA_GENERATION_DELIVERING_COMPLETION_PROGRESS,
});
taskRuntimeInternalMocks.listFreshTasksForOwnerKey.mockReturnValue([task]);
expect(
findDuplicateGuardMediaGenerationTaskForSession({
sessionKey: "session/A",
taskKind: "video-generate",
sourcePrefix: "video-generate",
taskLabel: "generate clip 01",
maxAgeMs: 120_000,
}),
).toEqual(task);
expect(
findDuplicateGuardMediaGenerationTaskForSession({
sessionKey: "session/A",
taskKind: "video-generate",
sourcePrefix: "video-generate",
taskLabel: "generate clip 02",
maxAgeMs: 120_000,
}),
).toBeUndefined();
});
});

View File

@@ -14,6 +14,10 @@ import type { TaskRecord } from "../tasks/task-registry.types.js";
import { buildSessionAsyncTaskStatusDetails } from "./session-async-task-status.js";
import { stableStringify } from "./stable-stringify.js";
/** Marks media as ready while requester delivery is still being confirmed. */
export const MEDIA_GENERATION_DELIVERING_COMPLETION_PROGRESS =
"Generated media; delivering completion";
type RecentMediaGenerationTaskStart = {
task: TaskRecord;
requestKey?: string;
@@ -299,6 +303,7 @@ export function findActiveMediaGenerationTaskForSession(params: {
taskKind: string;
sourcePrefix: string;
taskLabel?: string;
excludeDeliveringCompletion?: boolean;
}): TaskRecord | undefined {
return listActiveMediaGenerationTasksForSession(params)[0];
}
@@ -309,6 +314,7 @@ export function listActiveMediaGenerationTasksForSession(params: {
taskKind: string;
sourcePrefix: string;
taskLabel?: string;
excludeDeliveringCompletion?: boolean;
}): TaskRecord[] {
const sessionKey = normalizeOptionalString(params.sessionKey);
if (!sessionKey) {
@@ -331,6 +337,12 @@ export function listActiveMediaGenerationTasksForSession(params: {
if (taskLabel && !mediaGenerationTaskLabelMatches(task, taskLabel)) {
return false;
}
if (
params.excludeDeliveringCompletion &&
task.progressSummary === MEDIA_GENERATION_DELIVERING_COMPLETION_PROGRESS
) {
return false;
}
return true;
});
return [
@@ -456,6 +468,7 @@ export function buildActiveMediaGenerationTaskPromptContextForSession(params: {
sessionKey: params.sessionKey,
taskKind: params.taskKind,
sourcePrefix: params.sourcePrefix,
excludeDeliveringCompletion: true,
});
if (!task) {
return undefined;

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