mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-25 08:42:35 +08:00
Compare commits
39 Commits
dependabot
...
codex/agen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac505335e4 | ||
|
|
374076b5a8 | ||
|
|
242fbf1a67 | ||
|
|
434d752dd6 | ||
|
|
3179692f0e | ||
|
|
6add1cc969 | ||
|
|
cb13be375d | ||
|
|
acc2a0ee72 | ||
|
|
704fc35043 | ||
|
|
f1e38f2ed6 | ||
|
|
d2933bbdb9 | ||
|
|
2e124081af | ||
|
|
8150b76b6f | ||
|
|
77eb0fdbaa | ||
|
|
f0be8e7b6e | ||
|
|
80bd0003ce | ||
|
|
f3891e1335 | ||
|
|
bea3d292c7 | ||
|
|
17066f2d7c | ||
|
|
9aea104cc8 | ||
|
|
2aa9d67635 | ||
|
|
51eec3a757 | ||
|
|
c588606a9b | ||
|
|
7c56877eb1 | ||
|
|
7844b08445 | ||
|
|
ae9474b5fd | ||
|
|
e4763b0631 | ||
|
|
af2b0a6118 | ||
|
|
2a484a3ff1 | ||
|
|
1069c60e1e | ||
|
|
9e68fb1178 | ||
|
|
ae06d846fa | ||
|
|
380f2749be | ||
|
|
20293036ca | ||
|
|
bfffc77bfc | ||
|
|
e9720c27fa | ||
|
|
8242923fe3 | ||
|
|
414c250af9 | ||
|
|
f65aca64fc |
4
.github/workflows/clawsweeper-dispatch.yml
vendored
4
.github/workflows/clawsweeper-dispatch.yml
vendored
@@ -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 }}
|
||||
|
||||
@@ -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"
|
||||
|
||||
4
.github/workflows/dependency-guard.yml
vendored
4
.github/workflows/dependency-guard.yml
vendored
@@ -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 }}
|
||||
|
||||
2
.github/workflows/docs-agent.yml
vendored
2
.github/workflows/docs-agent.yml
vendored
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
2
.github/workflows/maturity-scorecard.yml
vendored
2
.github/workflows/maturity-scorecard.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/test-performance-agent.yml
vendored
2
.github/workflows/test-performance-agent.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -30,6 +30,68 @@ title: "Usage tracking"
|
||||
- CLI: `openclaw channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).
|
||||
- macOS menu bar: "Usage" section under Context (only if available).
|
||||
|
||||
## Default usage footer mode
|
||||
|
||||
`/usage off|tokens|full` sets the footer for a session and is remembered for that
|
||||
session. `messages.responseUsage` seeds that mode for sessions that have not
|
||||
chosen one, so the footer can be on by default without typing `/usage` each time.
|
||||
|
||||
Set one mode for every channel, or a per-channel map with a `default` fallback:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"messages": {
|
||||
"responseUsage": "tokens",
|
||||
// or: { "default": "off", "discord": "full" }
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Three distinct session states
|
||||
|
||||
A session's `responseUsage` field has three representable states, each with
|
||||
different semantics:
|
||||
|
||||
| State | Stored value | Effective mode |
|
||||
| ------------------- | ------------------------------- | --------------------------------------------------------------------- |
|
||||
| **Unset / inherit** | `undefined` (absent) | Falls through to `messages.responseUsage` config default, then `off`. |
|
||||
| **Explicit off** | `"off"` (stored) | Always off — a non-off config default cannot re-enable the footer. |
|
||||
| **Explicit on** | `"tokens"` or `"full"` (stored) | That mode, regardless of config default. |
|
||||
|
||||
### Precedence
|
||||
|
||||
Effective mode = session override → channel config entry → `default` → `off`.
|
||||
|
||||
An explicit `/usage off` is **persisted** as the literal value `"off"` in the
|
||||
session, not the same as "unset." This means a non-off `messages.responseUsage`
|
||||
default cannot turn the footer back on once the user has explicitly disabled it.
|
||||
|
||||
### Resetting vs. turning off
|
||||
|
||||
- `/usage off` — forces the footer off and persists that choice. A configured
|
||||
non-off default cannot override this.
|
||||
- `/usage reset` (aliases: `inherit`, `clear`, `default`) — clears the session
|
||||
override. The session then **inherits** the effective config default
|
||||
(`messages.responseUsage`). If no default is configured, the footer is off
|
||||
(unchanged from before). Use this to "go back to default" without explicitly
|
||||
turning the footer on.
|
||||
- A full session reset (`/reset` or `/new`) or a session rollover **preserves**
|
||||
the explicit usage-mode preference so the user's display choice survives
|
||||
session rollovers. Only `/usage reset` (and its aliases) actually clears the
|
||||
override.
|
||||
|
||||
### Toggle behavior
|
||||
|
||||
`/usage` with no arguments cycles: off → tokens → full → off. The starting point
|
||||
for the cycle is the **effective** current mode (session override falling through
|
||||
to the config default when unset), so the cycle is always consistent with what
|
||||
the user sees in the footer.
|
||||
|
||||
### Config
|
||||
|
||||
With no config the prior behavior holds (footer off until `/usage`). Use
|
||||
`/usage reset` to clear a session override and re-inherit the configured default.
|
||||
|
||||
## Custom `/usage full` footer
|
||||
|
||||
`/usage full` shows a built-in compact footer with model, reasoning, fast/slow,
|
||||
|
||||
@@ -199,6 +199,10 @@ claude auth status --text
|
||||
openclaw models auth login --provider anthropic --method cli --set-default
|
||||
```
|
||||
|
||||
Docker installs need Claude Code installed and logged in inside the persisted
|
||||
container home, not only on the host. See
|
||||
[Claude CLI backend in Docker](/install/docker#claude-cli-backend-in-docker).
|
||||
|
||||
Use `agents.defaults.cliBackends.claude-cli.command` only when the `claude`
|
||||
binary is not already on `PATH`.
|
||||
|
||||
|
||||
@@ -204,6 +204,55 @@ Controls elevated exec access outside the sandbox:
|
||||
}
|
||||
```
|
||||
|
||||
Agent entries can inject an environment only into their own `exec` child
|
||||
processes. Use a SecretRef for credentials and set `inheritHostEnv: false` when the
|
||||
Gateway process environment must not be inherited:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "referrals",
|
||||
tools: {
|
||||
exec: {
|
||||
inheritHostEnv: false,
|
||||
env: {
|
||||
GREENHOUSE_TOKEN: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "REFERRALS_GREENHOUSE_TOKEN",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
`agents.list[].tools.exec.env` applies to `exec` only; it does not mutate
|
||||
`process.env` or automatically inject credentials into model-provider or plugin
|
||||
APIs. Trusted in-process plugin code can still inspect the materialized runtime
|
||||
config, so this is not a plugin isolation boundary.
|
||||
Configured values override same-named per-call values from the model. Trusted
|
||||
`resolve_exec_env` hook output and channel context are applied afterward. Host
|
||||
exec still rejects `PATH` and dangerous runtime/startup keys. Sandbox exec
|
||||
already starts from a minimal environment. With `inheritHostEnv: false`,
|
||||
Gateway exec also skips login-shell PATH discovery and cached shell-startup
|
||||
state; configure `pathPrepend` or absolute commands when needed. For
|
||||
`host: "node"`, configure scoped environment and inheritance isolation on the
|
||||
node host. Both this map and `inheritHostEnv: false` are rejected because the
|
||||
Gateway cannot clear the remote service environment or safely hold a scoped
|
||||
credential back during remote approval preparation.
|
||||
|
||||
Treat this map as credential-bearing configuration: every command the agent can
|
||||
run can read and exfiltrate these values, and command output can reveal them.
|
||||
Plaintext values are reported by `openclaw secrets audit`; prefer SecretRefs.
|
||||
Already-running background commands retain the environment captured when they
|
||||
started after a config or secret reload.
|
||||
|
||||
### `tools.loopDetection`
|
||||
|
||||
Tool-loop safety checks are **disabled by default**. Set `enabled: true` to activate detection. Settings can be defined globally in `tools.loopDetection` and overridden per-agent at `agents.list[].tools.loopDetection`.
|
||||
|
||||
@@ -525,6 +525,47 @@ the config fields that accept SecretRefs.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## Per-agent exec environment variables
|
||||
|
||||
`agents.list[].tools.exec.env` supports SecretInput values, so a credential can
|
||||
be resolved during Gateway activation and injected only into that agent's
|
||||
`exec` child processes:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "referrals",
|
||||
tools: {
|
||||
exec: {
|
||||
inheritHostEnv: false,
|
||||
env: {
|
||||
GREENHOUSE_TOKEN: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "REFERRALS_GREENHOUSE_TOKEN",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
This surface is exec-specific. It does not mutate the Gateway process
|
||||
environment or automatically inject credentials into model-provider or plugin
|
||||
APIs. Trusted in-process plugin code can inspect the materialized runtime
|
||||
config. An unresolved active ref fails Gateway activation. SecretRefs are
|
||||
materialized in the Gateway's protected in-memory config snapshot, so this
|
||||
scopes subprocess injection rather than creating a same-process or same-OS-user
|
||||
security boundary. Every command available to the agent can read these values,
|
||||
command output can reveal them, and plaintext entries are reported by
|
||||
`openclaw secrets audit`. Configure scoped environment on a node host itself;
|
||||
agent exec env is rejected for `host: "node"`.
|
||||
|
||||
## MCP server environment variables
|
||||
|
||||
MCP server env vars configured via `plugins.entries.acpx.config.mcpServers` support SecretInput. This keeps API keys and tokens out of plaintext config:
|
||||
|
||||
@@ -279,6 +279,100 @@ If you use your own Compose file or `docker run` command, add the same host
|
||||
mapping yourself, for example
|
||||
`--add-host=host.docker.internal:host-gateway`.
|
||||
|
||||
### Claude CLI backend in Docker
|
||||
|
||||
The official OpenClaw Docker image does not pre-install Claude Code. Install and
|
||||
log in to Claude Code inside the container user that runs OpenClaw, then persist
|
||||
that container home so image upgrades do not erase the binary or Claude auth
|
||||
state.
|
||||
|
||||
For new Docker installs, enable a persistent `/home/node` volume before running
|
||||
setup:
|
||||
|
||||
```bash
|
||||
export OPENCLAW_IMAGE="ghcr.io/openclaw/openclaw:latest"
|
||||
export OPENCLAW_HOME_VOLUME="openclaw_home"
|
||||
./scripts/docker/setup.sh
|
||||
```
|
||||
|
||||
For an existing Docker install, stop the stack first and reload the current
|
||||
Docker `.env` values before rerunning setup. The setup script does not read
|
||||
`.env` on its own; it rewrites `.env` from the current shell and defaults. For
|
||||
the generated `.env`, run:
|
||||
|
||||
```bash
|
||||
set -a
|
||||
. ./.env
|
||||
set +a
|
||||
export OPENCLAW_HOME_VOLUME="${OPENCLAW_HOME_VOLUME:-openclaw_home}"
|
||||
./scripts/docker/setup.sh
|
||||
```
|
||||
|
||||
If your `.env` contains values your shell cannot source, manually re-export the
|
||||
existing values you rely on first, such as `OPENCLAW_IMAGE`, ports, bind mode,
|
||||
custom paths, `OPENCLAW_EXTRA_MOUNTS`, sandbox, and skip-onboarding settings.
|
||||
The generated overlay mounts the home volume for both `openclaw-gateway` and
|
||||
`openclaw-cli`.
|
||||
|
||||
Run the remaining commands with the generated Compose overlay so both services
|
||||
mount the persisted home. If your setup also uses `docker-compose.override.yml`,
|
||||
include it before `docker-compose.extra.yml`.
|
||||
|
||||
Install Claude Code in that persisted home:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.extra.yml run --rm \
|
||||
--entrypoint sh openclaw-cli -lc \
|
||||
'curl -fsSL https://claude.ai/install.sh | bash'
|
||||
```
|
||||
|
||||
The native installer writes the `claude` binary under
|
||||
`/home/node/.local/bin/claude`. Tell OpenClaw to use that container path:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.extra.yml run --rm \
|
||||
openclaw-cli config set \
|
||||
agents.defaults.cliBackends.claude-cli.command \
|
||||
/home/node/.local/bin/claude
|
||||
```
|
||||
|
||||
Log in and verify from inside the same persisted container home:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.extra.yml run --rm \
|
||||
--entrypoint /home/node/.local/bin/claude openclaw-cli auth login
|
||||
docker compose -f docker-compose.yml -f docker-compose.extra.yml run --rm \
|
||||
--entrypoint /home/node/.local/bin/claude openclaw-cli auth status --text
|
||||
docker compose -f docker-compose.yml -f docker-compose.extra.yml run --rm \
|
||||
openclaw-cli models auth login \
|
||||
--provider anthropic --method cli --set-default
|
||||
docker compose -f docker-compose.yml -f docker-compose.extra.yml run --rm \
|
||||
openclaw-cli models list --provider anthropic
|
||||
```
|
||||
|
||||
After that, you can use the bundled `claude-cli` backend:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.extra.yml run --rm \
|
||||
openclaw-cli agent \
|
||||
--agent main \
|
||||
--model claude-cli/claude-sonnet-4-6 \
|
||||
--message "Say hello from Docker Claude CLI"
|
||||
```
|
||||
|
||||
`OPENCLAW_HOME_VOLUME` persists the native Claude Code install under
|
||||
`/home/node/.local/bin` and `/home/node/.local/share/claude`, plus Claude Code
|
||||
settings and auth state under `/home/node/.claude` and `/home/node/.claude.json`.
|
||||
Persisting only `/home/node/.openclaw` is not enough for Claude CLI reuse. If
|
||||
you use `OPENCLAW_EXTRA_MOUNTS` instead of a home volume, mount all of those
|
||||
Claude paths into both Docker services.
|
||||
|
||||
<Note>
|
||||
For shared production automation or predictable Anthropic billing, prefer the
|
||||
Anthropic API-key path. Claude CLI reuse follows Claude Code's installed
|
||||
version, account login, billing, and update behavior.
|
||||
</Note>
|
||||
|
||||
### Bonjour / mDNS
|
||||
|
||||
Docker bridge networking usually does not forward Bonjour/mDNS multicast
|
||||
|
||||
@@ -103,8 +103,65 @@ The harness advertises support for the canonical `github-copilot` provider
|
||||
|
||||
- `github-copilot`
|
||||
|
||||
Anything outside that set falls through `selection.ts`'s `auto_pi` branch back
|
||||
to PI.
|
||||
It also supports custom `models.providers` entries when the selected model has
|
||||
a non-empty `baseUrl` and one of these API shapes:
|
||||
|
||||
- `openai-responses`
|
||||
- `openai-completions`
|
||||
- `ollama` (OpenAI-compatible completions)
|
||||
- `azure-openai-responses`
|
||||
- `anthropic-messages`
|
||||
|
||||
Native provider ids such as `openai`, `anthropic`, `google`, and `ollama` remain
|
||||
owned by their native runtimes. Use a distinct custom provider id when routing
|
||||
an endpoint through Copilot BYOK.
|
||||
|
||||
Copilot BYOK endpoints must be public-network HTTPS URLs. The harness gives the
|
||||
Copilot SDK a per-attempt loopback proxy URL, then forwards provider traffic
|
||||
through OpenClaw's guarded fetch path so DNS pinning and SSRF policy stay
|
||||
owned by OpenClaw. Use the native OpenClaw runtime for local Ollama, LM Studio,
|
||||
or LAN model servers.
|
||||
|
||||
## BYOK
|
||||
|
||||
Copilot BYOK uses the SDK's session-level custom provider contract. OpenClaw
|
||||
passes the resolved model endpoint, API key, bearer-token mode, headers, model
|
||||
id, and context/output limits without moving provider transport logic into
|
||||
core.
|
||||
|
||||
For example:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "custom-proxy/llama-3.1-8b",
|
||||
models: {
|
||||
"custom-proxy/llama-3.1-8b": {
|
||||
agentRuntime: { id: "copilot" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
mode: "merge",
|
||||
providers: {
|
||||
"custom-proxy": {
|
||||
baseUrl: "https://api.example.com/v1",
|
||||
apiKey: "${CUSTOM_PROXY_API_KEY}",
|
||||
api: "openai-responses",
|
||||
authHeader: true,
|
||||
models: [{ id: "llama-3.1-8b", name: "Llama 3.1 8B" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
BYOK sessions are separately keyed from subscription sessions and from other
|
||||
endpoints or credential fingerprints. Rotating the key, headers, model, or
|
||||
endpoint creates a fresh Copilot SDK session instead of resuming incompatible
|
||||
state.
|
||||
|
||||
## Auth
|
||||
|
||||
@@ -151,10 +208,11 @@ Override with `copilotHome: <path>` on the attempt input when you need a
|
||||
custom location (for example, a shared mount for migration).
|
||||
|
||||
Live harness tests use `OPENCLAW_COPILOT_AGENT_LIVE_TOKEN` when a direct token
|
||||
is needed. The shared live-test setup intentionally scrubs `COPILOT_GITHUB_TOKEN`,
|
||||
`GH_TOKEN`, and `GITHUB_TOKEN` after staging real auth profiles into the isolated
|
||||
test home, so passing a `gh auth token` value through the dedicated live-test
|
||||
variable avoids false skips without exposing the token to unrelated suites.
|
||||
is needed. The shared live-test setup intentionally scrubs
|
||||
`COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, and `GITHUB_TOKEN` after staging real auth
|
||||
profiles into the isolated test home, so passing a `gh auth token` value
|
||||
through the dedicated live-test variable avoids false skips without exposing
|
||||
the token to unrelated suites.
|
||||
|
||||
## Configuration surface
|
||||
|
||||
@@ -163,9 +221,9 @@ The harness reads its config from per-attempt input
|
||||
`extensions/copilot/src/`:
|
||||
|
||||
- `copilotHome` — per-agent CLI state directory (defaults documented above).
|
||||
- `model` — string or `{ provider, id, api? }`. When omitted, OpenClaw uses
|
||||
the agent's normal model selection and the harness verifies the resolved
|
||||
provider is in the supported set.
|
||||
- `model` — string or `{ provider, id, api?, baseUrl?, headers?, authHeader? }`.
|
||||
When omitted, OpenClaw uses the agent's normal model selection and the
|
||||
harness verifies the resolved provider is supported.
|
||||
- `reasoningEffort` — `"low" | "medium" | "high" | "xhigh"`. Maps from
|
||||
OpenClaw's `ThinkLevel` / `ReasoningLevel` resolution in
|
||||
`auto-reply/thinking.ts`.
|
||||
@@ -252,9 +310,9 @@ under `describe("runSideQuestion")`.
|
||||
|
||||
## Limitations
|
||||
|
||||
- The harness only claims the canonical `github-copilot` provider at MVP.
|
||||
Additional providers (BYOK or otherwise) should land in follow-up PRs that
|
||||
ship the adapter alongside the wire-up.
|
||||
- The harness claims `github-copilot` plus unowned custom BYOK provider ids.
|
||||
Manifest-owned native provider ids stay on their owning runtime even when
|
||||
`agentRuntime.id` is forced to `copilot`.
|
||||
- The harness does not deliver TUI; PI's TUI is unaffected and remains the
|
||||
fallback for whatever runtimes do not have a peer surface.
|
||||
- PI session state is not migrated when an agent switches to `copilot`.
|
||||
|
||||
@@ -104,9 +104,12 @@ Anthropic's current public docs:
|
||||
|
||||
<Warning>
|
||||
Claude CLI reuse expects the OpenClaw process to run on the same host as the
|
||||
Claude CLI login. Container installs such as [Podman](/install/podman) do
|
||||
not mount host `~/.claude` into setup or runtime; use an Anthropic API key
|
||||
there, or choose a provider with OpenClaw-managed OAuth such as
|
||||
Claude CLI login. Docker installs can persist a container home and log in to
|
||||
Claude Code there; see
|
||||
[Claude CLI backend in Docker](/install/docker#claude-cli-backend-in-docker).
|
||||
Other container installs such as [Podman](/install/podman) do not mount host
|
||||
`~/.claude` into setup or runtime; use an Anthropic API key there, or choose
|
||||
a provider with OpenClaw-managed OAuth such as
|
||||
[OpenAI Codex](/providers/openai).
|
||||
</Warning>
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ Scope intent:
|
||||
- `agents.defaults.memorySearch.remote.apiKey`
|
||||
- `agents.list[].tts.providers.*.apiKey`
|
||||
- `agents.list[].memorySearch.remote.apiKey`
|
||||
- `agents.list[].tools.exec.env.*`
|
||||
- `talk.providers.*.apiKey`
|
||||
- `talk.realtime.providers.*.apiKey`
|
||||
- `messages.tts.providers.*.apiKey`
|
||||
|
||||
@@ -29,6 +29,13 @@
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "agents.list[].tools.exec.env.*",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "agents.list[].tools.exec.env.*",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "agents.list[].tts.providers.*.apiKey",
|
||||
"configFile": "openclaw.json",
|
||||
|
||||
@@ -76,6 +76,8 @@ Use these in chat:
|
||||
configured for the active model.
|
||||
- `/usage off|tokens|full` → appends a **per-response usage footer** to every reply.
|
||||
- Persists per session (stored as `responseUsage`).
|
||||
- `/usage reset` (aliases: `inherit`, `clear`, `default`) — clears the session
|
||||
override so the session re-inherits the configured default.
|
||||
- `/usage full` shows estimated cost only when OpenClaw has usage metadata and
|
||||
local pricing for the active model. Otherwise it shows tokens only.
|
||||
- `/usage cost` → shows a local cost summary from OpenClaw session logs.
|
||||
|
||||
@@ -22,7 +22,8 @@ Working directory for the command.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="env" type="object">
|
||||
Key/value environment overrides merged on top of the inherited environment.
|
||||
Key/value environment overrides. Per-agent configured values are applied after
|
||||
these model-supplied values.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="yieldMs" type="number" default="10000">
|
||||
@@ -89,6 +90,7 @@ Notes:
|
||||
`$OPENCLAW_STATE_DIR/cache/shell-snapshots/`, then sources that snapshot before each exec command.
|
||||
Secret-looking variables are excluded; sandbox and node exec do not use this snapshot. Set
|
||||
`OPENCLAW_EXEC_SHELL_SNAPSHOT=0` in the Gateway process environment to disable this snapshot path.
|
||||
Per-agent `tools.exec.inheritHostEnv: false` also disables it.
|
||||
- Host execution (`gateway`/`node`) rejects `env.PATH` and loader overrides (`LD_*`/`DYLD_*`) to
|
||||
prevent binary hijacking or injected code.
|
||||
- OpenClaw sets `OPENCLAW_SHELL=exec` in the spawned command environment (including PTY and sandbox execution) so shell/profile rules can detect exec-tool context.
|
||||
@@ -113,6 +115,8 @@ Notes:
|
||||
- `tools.exec.notifyOnExit` (default: true): when true, backgrounded exec sessions enqueue a system event and request a heartbeat on exit.
|
||||
- `tools.exec.approvalRunningNoticeMs` (default: 10000): emit a single "running" notice when an approval-gated exec runs longer than this (0 disables).
|
||||
- `tools.exec.timeoutSec` (default: 1800): default per-command exec timeout in seconds. Per-call `timeout` overrides it; per-call `timeout: 0` disables the exec process timeout.
|
||||
- `agents.list[].tools.exec.env`: credential-oriented environment values injected only into that agent's gateway/sandbox exec children. Values support SecretRefs; node-host exec rejects this map.
|
||||
- `agents.list[].tools.exec.inheritHostEnv` (default: true): set false to omit the Gateway process environment and shell-startup snapshot from Gateway-hosted exec. This is rejected for `host=node`; sandbox exec is already minimal.
|
||||
- `tools.exec.host` (default: `auto`; resolves to `sandbox` when sandbox runtime is active, `gateway` otherwise)
|
||||
- `tools.exec.security` (default: `deny` for sandbox, `full` for gateway + node when unset)
|
||||
- `tools.exec.ask` (default: `off`)
|
||||
@@ -141,7 +145,9 @@ Example:
|
||||
|
||||
### PATH handling
|
||||
|
||||
- `host=gateway`: merges your login-shell `PATH` into the exec environment. `env.PATH` overrides are
|
||||
- `host=gateway`: normally merges your login-shell `PATH` into the exec environment. With
|
||||
`agents.list[].tools.exec.inheritHostEnv: false`, this merge is skipped; use an absolute command or
|
||||
`tools.exec.pathPrepend`. `env.PATH` overrides are
|
||||
rejected for host execution. The daemon itself still runs with a minimal `PATH`:
|
||||
- macOS: `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`, `/bin`
|
||||
- Linux: `/usr/local/bin`, `/usr/bin`, `/bin`
|
||||
|
||||
@@ -240,7 +240,7 @@ plugins.
|
||||
| `/tasks` | List active/recent background tasks for the current session |
|
||||
| `/context [list\|detail\|map\|json]` | Explain how context is assembled |
|
||||
| `/whoami` | Show your sender id. Alias: `/id` |
|
||||
| `/usage off\|tokens\|full\|cost` | Control the per-response usage footer or print a local cost summary |
|
||||
| `/usage off\|tokens\|full\|reset\|cost` | Control the per-response usage footer (`reset`/`inherit`/`clear`/`default` clears the session override to re-inherit the configured default) or print a local cost summary |
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Skills, allowlists, approvals">
|
||||
|
||||
@@ -126,7 +126,7 @@ Session controls:
|
||||
- `/verbose <on|full|off>`
|
||||
- `/trace <on|off>`
|
||||
- `/reasoning <on|off|stream>`
|
||||
- `/usage <off|tokens|full>`
|
||||
- `/usage <off|tokens|full|reset>` (`reset`/`inherit`/`clear`/`default` clears the session override)
|
||||
- `/goal [status] | /goal start <objective> | /goal pause|resume|complete|block|clear`
|
||||
- `/elevated <on|off|ask|full>` (alias: `/elev`)
|
||||
- `/activation <mention|always>`
|
||||
|
||||
@@ -10,10 +10,11 @@ openclaw plugins install @openclaw/copilot
|
||||
|
||||
Restart the Gateway after installing or updating the plugin.
|
||||
|
||||
The harness claims the canonical subscription `github-copilot` provider and
|
||||
is opt-in only — selection requires explicit `agentRuntime.id: "copilot"`
|
||||
on a model or provider entry; `auto` never picks it. PI remains the default
|
||||
embedded runtime.
|
||||
The harness claims the canonical subscription `github-copilot` provider plus
|
||||
custom BYOK provider entries that the Copilot SDK can represent. Manifest-owned
|
||||
native provider ids stay with their owning runtimes. The harness is opt-in only:
|
||||
selection requires explicit `agentRuntime.id: "copilot"` on a model or provider
|
||||
entry; `auto` never picks it. PI remains the default embedded runtime.
|
||||
|
||||
See [GitHub Copilot agent runtime](../../docs/plugins/copilot.md) for
|
||||
configuration, the doctor contract, transcript mirroring, compaction, side
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Copilot tests cover harness plugin behavior.
|
||||
import { attachModelProviderRequestTransport } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
initializeGlobalHookRunner,
|
||||
resetGlobalHookRunner,
|
||||
@@ -7,11 +8,12 @@ import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtim
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CopilotClientPool } from "./harness.js";
|
||||
import { createCopilotAgentHarness, type CopilotSessionBinding } from "./harness.js";
|
||||
import { COPILOT_BYOK_PROVIDER_ERROR } from "./src/provider-bridge.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
runCopilotAttempt: vi.fn(),
|
||||
resolvePoolAcquire: vi.fn(
|
||||
() =>
|
||||
(_params: any) =>
|
||||
({
|
||||
auth: {
|
||||
agentId: "test",
|
||||
@@ -22,6 +24,7 @@ const mocks = vi.hoisted(() => ({
|
||||
options: { copilotHome: "/tmp/copilot", useLoggedInUser: true },
|
||||
}) as any,
|
||||
),
|
||||
createCopilotByokProxy: vi.fn(),
|
||||
createCopilotClientPool: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -30,6 +33,10 @@ vi.mock("./src/attempt.js", () => ({
|
||||
runCopilotAttempt: mocks.runCopilotAttempt,
|
||||
}));
|
||||
|
||||
vi.mock("./src/byok-proxy.js", () => ({
|
||||
createCopilotByokProxy: mocks.createCopilotByokProxy,
|
||||
}));
|
||||
|
||||
vi.mock("./src/runtime.js", () => ({
|
||||
createCopilotClientPool: mocks.createCopilotClientPool,
|
||||
}));
|
||||
@@ -86,6 +93,7 @@ describe("createCopilotAgentHarness", () => {
|
||||
beforeEach(() => {
|
||||
mocks.runCopilotAttempt.mockReset();
|
||||
mocks.resolvePoolAcquire.mockClear();
|
||||
mocks.createCopilotByokProxy.mockReset();
|
||||
mocks.createCopilotClientPool.mockReset();
|
||||
mocks.runCopilotAttempt.mockResolvedValue(ATTEMPT_RESULT);
|
||||
mocks.resolvePoolAcquire.mockReturnValue({
|
||||
@@ -98,6 +106,7 @@ describe("createCopilotAgentHarness", () => {
|
||||
options: { copilotHome: "/tmp/copilot", useLoggedInUser: true },
|
||||
});
|
||||
mocks.createCopilotClientPool.mockImplementation(() => makePoolMock());
|
||||
mocks.createCopilotByokProxy.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -180,26 +189,81 @@ describe("createCopilotAgentHarness", () => {
|
||||
).toEqual({ supported: true, priority: 100 });
|
||||
});
|
||||
|
||||
it("supports rejects providers outside the whitelist", () => {
|
||||
it("supports custom provider ids for BYOK model entries", () => {
|
||||
const harness = createCopilotAgentHarness();
|
||||
|
||||
expect(
|
||||
harness.supports({
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4.5",
|
||||
provider: "custom-proxy",
|
||||
modelId: "llama-3.1-8b",
|
||||
modelProvider: {
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
},
|
||||
providerOwnerStatus: "unowned",
|
||||
providerOwnerPluginIds: [],
|
||||
requestedRuntime: "copilot",
|
||||
}),
|
||||
).toEqual({ supported: true, priority: 100 });
|
||||
});
|
||||
|
||||
it("supports rejects custom provider ids without a supported BYOK model shape", () => {
|
||||
const harness = createCopilotAgentHarness();
|
||||
|
||||
expect(
|
||||
harness.supports({
|
||||
provider: "custom-proxy",
|
||||
modelId: "llama-3.1-8b",
|
||||
providerOwnerStatus: "unowned",
|
||||
providerOwnerPluginIds: [],
|
||||
requestedRuntime: "copilot",
|
||||
}),
|
||||
).toEqual({
|
||||
supported: false,
|
||||
reason: "provider is not one of: github-copilot",
|
||||
reason:
|
||||
"provider is not a supported Copilot BYOK model (requires supported api, baseUrl, and no request transport policy overrides)",
|
||||
});
|
||||
// Legacy aspirational ids should not be claimed by the harness.
|
||||
for (const legacyId of ["github", "openclaw", "copilot"]) {
|
||||
expect(
|
||||
harness.supports({
|
||||
provider: "custom-proxy",
|
||||
modelId: "llama-3.1-8b",
|
||||
modelProvider: {
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
request: { proxy: { mode: "env-proxy" } },
|
||||
},
|
||||
providerOwnerStatus: "unowned",
|
||||
providerOwnerPluginIds: [],
|
||||
requestedRuntime: "copilot",
|
||||
}),
|
||||
).toEqual({
|
||||
supported: false,
|
||||
reason:
|
||||
"provider is not a supported Copilot BYOK model (requires supported api, baseUrl, and no request transport policy overrides)",
|
||||
});
|
||||
});
|
||||
|
||||
it("supports rejects manifest-owned providers outside the whitelist", () => {
|
||||
const harness = createCopilotAgentHarness();
|
||||
|
||||
for (const [provider, ownerPluginIds] of [
|
||||
["anthropic", ["anthropic"]],
|
||||
["azure-openai-responses", ["openai"]],
|
||||
["deepinfra", ["deepinfra"]],
|
||||
["fireworks", ["fireworks"]],
|
||||
["github", ["github"]],
|
||||
["openclaw", ["openclaw"]],
|
||||
["sglang", ["sglang"]],
|
||||
["together", ["together"]],
|
||||
["vllm", ["vllm"]],
|
||||
] as const) {
|
||||
expect(
|
||||
harness.supports({
|
||||
provider: legacyId,
|
||||
provider,
|
||||
modelId: "gpt-4.1",
|
||||
requestedRuntime: "copilot",
|
||||
providerOwnerStatus: "owned",
|
||||
providerOwnerPluginIds: ownerPluginIds,
|
||||
}),
|
||||
).toEqual({
|
||||
supported: false,
|
||||
@@ -208,6 +272,27 @@ describe("createCopilotAgentHarness", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("supports rejects ambiguous custom provider ownership", () => {
|
||||
const harness = createCopilotAgentHarness();
|
||||
|
||||
expect(
|
||||
harness.supports({
|
||||
provider: "custom-proxy",
|
||||
modelId: "proxy-model",
|
||||
modelProvider: {
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
},
|
||||
requestedRuntime: "copilot",
|
||||
providerOwnerStatus: "ambiguous",
|
||||
providerOwnerPluginIds: ["first-owner", "second-owner"],
|
||||
}),
|
||||
).toEqual({
|
||||
supported: false,
|
||||
reason: "provider is not one of: github-copilot",
|
||||
});
|
||||
});
|
||||
|
||||
it("runAttempt lazy-imports attempt by waiting until invocation to create a pool", async () => {
|
||||
const pool = makePoolMock();
|
||||
mocks.createCopilotClientPool.mockReturnValue(pool);
|
||||
@@ -222,6 +307,18 @@ describe("createCopilotAgentHarness", () => {
|
||||
expect(mocks.runCopilotAttempt).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps invalid BYOK provider configuration on the structured attempt path", async () => {
|
||||
const pool = makePoolMock();
|
||||
mocks.createCopilotClientPool.mockReturnValue(pool);
|
||||
mocks.resolvePoolAcquire.mockImplementationOnce(() => {
|
||||
throw new Error(COPILOT_BYOK_PROVIDER_ERROR);
|
||||
});
|
||||
const harness = createCopilotAgentHarness();
|
||||
|
||||
await expect(harness.runAttempt(ATTEMPT_PARAMS)).resolves.toBe(ATTEMPT_RESULT);
|
||||
expect(mocks.runCopilotAttempt).toHaveBeenCalledWith(ATTEMPT_PARAMS, { pool });
|
||||
});
|
||||
|
||||
it("runAttempt creates one pool lazily and reuses it across two attempts on the same harness", async () => {
|
||||
const pool = makePoolMock();
|
||||
const firstResult = { attempt: 1 } as any;
|
||||
@@ -1186,6 +1283,88 @@ describe("createCopilotAgentHarness", () => {
|
||||
expect(secondCallParams.initialReplayState?.sdkSessionId).toBe("sdk-sess-sqlite");
|
||||
});
|
||||
|
||||
it("persists BYOK session compatibility with endpoint fingerprints instead of raw URLs", async () => {
|
||||
const sessionStore = makeSessionStoreMock();
|
||||
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
|
||||
deps.onSessionEstablished?.({
|
||||
sdkSessionId: "sdk-sess-byok",
|
||||
pooledClient: { key: {} as any, client: { deleteSession: vi.fn() } as any },
|
||||
sessionConfig: TEST_SESSION_CONFIG,
|
||||
});
|
||||
return ATTEMPT_RESULT;
|
||||
});
|
||||
const harness = createCopilotAgentHarness({
|
||||
pool: makePoolMock(),
|
||||
sessionStore: sessionStore.store,
|
||||
});
|
||||
|
||||
await harness.runAttempt(
|
||||
makeAttemptParams({
|
||||
provider: "custom-proxy",
|
||||
model: {
|
||||
provider: "custom-proxy",
|
||||
id: "proxy-model",
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://proxy.example/v1?routing=blue",
|
||||
},
|
||||
auth: undefined,
|
||||
authProfileId: "custom-proxy:main",
|
||||
resolvedApiKey: "byok-token",
|
||||
}),
|
||||
);
|
||||
|
||||
const stored = sessionStore.entries.get("oc-sess-reuse");
|
||||
expect(stored?.compatKey).toContain("baseUrlFingerprint=sha256:");
|
||||
expect(stored?.compatKey).not.toContain("proxy.example");
|
||||
expect(stored?.compatKey).not.toContain("routing=blue");
|
||||
});
|
||||
|
||||
it("does not reuse BYOK sessions when attached request auth mode changes", async () => {
|
||||
const pool = makePoolMock();
|
||||
const model = {
|
||||
provider: "custom-proxy",
|
||||
id: "proxy-model",
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
};
|
||||
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
|
||||
deps.onSessionEstablished?.({
|
||||
sdkSessionId: "sdk-sess-byok",
|
||||
pooledClient: { key: {} as any, client: { deleteSession: vi.fn() } as any },
|
||||
sessionConfig: TEST_SESSION_CONFIG,
|
||||
});
|
||||
return ATTEMPT_RESULT;
|
||||
});
|
||||
const harness = createCopilotAgentHarness({ pool });
|
||||
|
||||
await harness.runAttempt(
|
||||
makeAttemptParams({
|
||||
provider: "custom-proxy",
|
||||
model: attachModelProviderRequestTransport(model, { auth: { mode: "provider-default" } }),
|
||||
auth: undefined,
|
||||
authProfileId: "custom-proxy:main",
|
||||
resolvedApiKey: "byok-token",
|
||||
}),
|
||||
);
|
||||
await harness.runAttempt(
|
||||
makeAttemptParams({
|
||||
runId: "t2",
|
||||
provider: "custom-proxy",
|
||||
model: attachModelProviderRequestTransport(model, {
|
||||
auth: { mode: "header", headerName: "x-api-key", value: "byok-token" },
|
||||
}),
|
||||
auth: undefined,
|
||||
authProfileId: "custom-proxy:main",
|
||||
resolvedApiKey: "byok-token",
|
||||
}),
|
||||
);
|
||||
|
||||
const secondCallParams = mocks.runCopilotAttempt.mock.calls[1]?.[0] as {
|
||||
initialReplayState?: { sdkSessionId?: string };
|
||||
};
|
||||
expect(secondCallParams.initialReplayState?.sdkSessionId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resumes shipped schema v1 plugin-state bindings for attempts", async () => {
|
||||
const sessionStore = makeSessionStoreMock();
|
||||
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
|
||||
@@ -1886,6 +2065,148 @@ describe("createCopilotAgentHarness", () => {
|
||||
expect(matchingResult?.compacted).toBe(true);
|
||||
});
|
||||
|
||||
it("compacts tracked BYOK sessions from production compact params with a fresh proxy", async () => {
|
||||
const compact = vi.fn(async () => ({
|
||||
success: true,
|
||||
tokensRemoved: 45,
|
||||
messagesRemoved: 2,
|
||||
}));
|
||||
const resumeSession = vi.fn(async () => ({
|
||||
disconnect: vi.fn(async () => undefined),
|
||||
rpc: { history: { compact } },
|
||||
}));
|
||||
const pool = makePoolMock();
|
||||
const acquire = vi.fn(async () => ({
|
||||
key: {} as any,
|
||||
client: { deleteSession: vi.fn(), resumeSession } as any,
|
||||
}));
|
||||
pool.acquire = acquire;
|
||||
pool.release = vi.fn(async () => undefined);
|
||||
const trackedRuntimeModel = {
|
||||
provider: "local-proxy",
|
||||
id: "proxy-model",
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
};
|
||||
mocks.resolvePoolAcquire.mockImplementation((params: any) => {
|
||||
const runtimeModel = params.runtimeModel ?? params.model;
|
||||
if (!runtimeModel?.baseUrl) {
|
||||
throw new Error(COPILOT_BYOK_PROVIDER_ERROR);
|
||||
}
|
||||
return {
|
||||
auth: {
|
||||
agentId: "test",
|
||||
authMode: "byok",
|
||||
authProfileId: "byok:local-proxy",
|
||||
authProfileVersion:
|
||||
runtimeModel.baseUrl === trackedRuntimeModel.baseUrl
|
||||
? "sha256:provider"
|
||||
: "sha256:rotated",
|
||||
copilotHome: "/copilot-home",
|
||||
},
|
||||
key: { agentId: "test", authMode: "byok", copilotHome: "/copilot-home" },
|
||||
options: { copilotHome: "/copilot-home" },
|
||||
};
|
||||
});
|
||||
const closeByokProxy = vi.fn(async () => undefined);
|
||||
mocks.createCopilotByokProxy.mockImplementation(async (provider: any) => ({
|
||||
close: closeByokProxy,
|
||||
provider: {
|
||||
...provider,
|
||||
provider: {
|
||||
...provider.provider,
|
||||
baseUrl: "http://127.0.0.1:49152/proxy/v1",
|
||||
},
|
||||
},
|
||||
}));
|
||||
const trackedProvider = {
|
||||
type: "openai" as const,
|
||||
wireApi: "responses" as const,
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
modelId: "proxy-model",
|
||||
wireModel: "proxy-model",
|
||||
};
|
||||
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
|
||||
deps.onSessionEstablished?.({
|
||||
compactionSessionConfig: {
|
||||
...TEST_SESSION_CONFIG,
|
||||
provider: trackedProvider,
|
||||
},
|
||||
sdkSessionId: "sdk-sess-byok",
|
||||
pooledClient: {
|
||||
key: {} as any,
|
||||
client: { deleteSession: vi.fn(), resumeSession } as any,
|
||||
},
|
||||
sessionConfig: TEST_SESSION_CONFIG,
|
||||
});
|
||||
return ATTEMPT_RESULT;
|
||||
});
|
||||
const harness = createCopilotAgentHarness({ pool });
|
||||
|
||||
await harness.runAttempt(
|
||||
makeCompactParams({
|
||||
model: trackedRuntimeModel,
|
||||
provider: "local-proxy",
|
||||
authProfileId: "byok:local-proxy",
|
||||
resolvedApiKey: "byok-token",
|
||||
sessionId: "oc-sess-byok",
|
||||
}),
|
||||
);
|
||||
mocks.resolvePoolAcquire.mockClear();
|
||||
|
||||
const rotatedResult = await harness.compact?.(
|
||||
makeCompactParams({
|
||||
model: "proxy-model",
|
||||
runtimeModel: {
|
||||
...trackedRuntimeModel,
|
||||
baseUrl: "https://rotated.example/v1",
|
||||
},
|
||||
provider: "local-proxy",
|
||||
authProfileId: "byok:local-proxy",
|
||||
sessionId: "oc-sess-byok",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mocks.resolvePoolAcquire).toHaveBeenCalledTimes(1);
|
||||
expect(resumeSession).not.toHaveBeenCalled();
|
||||
expect(rotatedResult).toEqual({
|
||||
ok: false,
|
||||
compacted: false,
|
||||
reason: "missing_thread_binding",
|
||||
failure: { reason: "missing_thread_binding" },
|
||||
});
|
||||
mocks.resolvePoolAcquire.mockClear();
|
||||
|
||||
const result = await harness.compact?.(
|
||||
makeCompactParams({
|
||||
model: "proxy-model",
|
||||
runtimeModel: trackedRuntimeModel,
|
||||
provider: "local-proxy",
|
||||
authProfileId: "byok:local-proxy",
|
||||
sessionId: "oc-sess-byok",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mocks.resolvePoolAcquire).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.createCopilotByokProxy).toHaveBeenCalledWith({
|
||||
mode: "byok",
|
||||
provider: trackedProvider,
|
||||
});
|
||||
expect(resumeSession).toHaveBeenCalledWith(
|
||||
"sdk-sess-byok",
|
||||
expect.objectContaining({
|
||||
continuePendingWork: false,
|
||||
model: "gpt-4.1",
|
||||
provider: expect.objectContaining({
|
||||
baseUrl: "http://127.0.0.1:49152/proxy/v1",
|
||||
}),
|
||||
suppressResumeEvent: true,
|
||||
}),
|
||||
);
|
||||
expect(closeByokProxy).toHaveBeenCalledTimes(1);
|
||||
expect(result?.compacted).toBe(true);
|
||||
});
|
||||
|
||||
it("does not compact a tracked SDK session after model changes", async () => {
|
||||
const resumeSession = vi.fn();
|
||||
const pool = makePoolMock();
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { CopilotClient } from "@github/copilot-sdk";
|
||||
import {
|
||||
buildAgentHookContextChannelFields,
|
||||
compactWithSafetyTimeout,
|
||||
getModelProviderRequestTransport,
|
||||
resolveCompactionTimeoutMs,
|
||||
runAgentHarnessAfterCompactionHook,
|
||||
runAgentHarnessBeforeCompactionHook,
|
||||
@@ -15,7 +16,13 @@ import {
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import type { CopilotSessionConfig } from "./src/attempt.js";
|
||||
import { resolveCopilotAuth } from "./src/auth-bridge.js";
|
||||
import { createCopilotByokAuth, resolveCopilotAuth, tokenFingerprint } from "./src/auth-bridge.js";
|
||||
import { createCopilotByokProxy } from "./src/byok-proxy.js";
|
||||
import {
|
||||
isCopilotByokUnsupportedProviderError,
|
||||
resolveCopilotProvider,
|
||||
supportsCopilotByokProviderShape,
|
||||
} from "./src/provider-bridge.js";
|
||||
import type {
|
||||
ClientCreateOptions,
|
||||
CopilotClientPool,
|
||||
@@ -52,7 +59,7 @@ interface TrackedSession {
|
||||
// replaces this entry via `onSessionEstablished`.
|
||||
compatKey: string;
|
||||
compactKey: string;
|
||||
authMode: "gitHubToken" | "useLoggedInUser";
|
||||
authMode: "gitHubToken" | "useLoggedInUser" | "byok";
|
||||
authProfileId?: string;
|
||||
authProfileVersion?: string;
|
||||
}
|
||||
@@ -88,7 +95,7 @@ export type CopilotSessionBinding = {
|
||||
sdkSessionId: string;
|
||||
compatKey: string;
|
||||
compactKey: string;
|
||||
authMode: "gitHubToken" | "useLoggedInUser";
|
||||
authMode: "gitHubToken" | "useLoggedInUser" | "byok";
|
||||
authProfileId?: string;
|
||||
authProfileVersion?: string;
|
||||
updatedAt: number;
|
||||
@@ -119,9 +126,9 @@ type CopilotSessionAuth = Pick<
|
||||
>;
|
||||
|
||||
function sessionAuthFields(auth: CopilotSessionAuth): CopilotSessionAuth {
|
||||
return auth.authMode === "gitHubToken"
|
||||
return auth.authMode === "gitHubToken" || auth.authMode === "byok"
|
||||
? {
|
||||
authMode: "gitHubToken",
|
||||
authMode: auth.authMode,
|
||||
authProfileId: auth.authProfileId,
|
||||
authProfileVersion: auth.authProfileVersion,
|
||||
}
|
||||
@@ -136,7 +143,7 @@ function sessionAuthMatches(stored: CopilotSessionAuth, current: CopilotSessionA
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
current.authMode === "gitHubToken" &&
|
||||
current.authMode === stored.authMode &&
|
||||
stored.authProfileId === current.authProfileId &&
|
||||
stored.authProfileVersion === current.authProfileVersion
|
||||
);
|
||||
@@ -154,8 +161,10 @@ function normalizeBinding(
|
||||
value.compatKey.trim() === "" ||
|
||||
typeof value.compactKey !== "string" ||
|
||||
value.compactKey.trim() === "" ||
|
||||
(value.authMode !== "gitHubToken" && value.authMode !== "useLoggedInUser") ||
|
||||
(value.authMode === "gitHubToken" &&
|
||||
(value.authMode !== "gitHubToken" &&
|
||||
value.authMode !== "byok" &&
|
||||
value.authMode !== "useLoggedInUser") ||
|
||||
((value.authMode === "gitHubToken" || value.authMode === "byok") &&
|
||||
(typeof value.authProfileId !== "string" ||
|
||||
value.authProfileId.trim() === "" ||
|
||||
typeof value.authProfileVersion !== "string" ||
|
||||
@@ -171,7 +180,7 @@ function normalizeBinding(
|
||||
compatKey: value.compatKey,
|
||||
compactKey: value.compactKey,
|
||||
authMode: value.authMode,
|
||||
...(value.authMode === "gitHubToken"
|
||||
...(value.authMode === "gitHubToken" || value.authMode === "byok"
|
||||
? {
|
||||
authProfileId: value.authProfileId,
|
||||
authProfileVersion: value.authProfileVersion,
|
||||
@@ -346,21 +355,88 @@ function computeSessionKey(
|
||||
copilotHome?: string;
|
||||
cwd?: string;
|
||||
modelId?: string;
|
||||
model?: string | { api?: string; id?: string; provider?: string };
|
||||
model?:
|
||||
| {
|
||||
api?: string;
|
||||
id?: string;
|
||||
provider?: string;
|
||||
baseUrl?: string;
|
||||
azureApiVersion?: string;
|
||||
headers?: Record<string, string | null | undefined>;
|
||||
authHeader?: boolean;
|
||||
params?: Record<string, unknown>;
|
||||
request?: {
|
||||
auth?: { mode?: unknown };
|
||||
proxy?: unknown;
|
||||
tls?: unknown;
|
||||
allowPrivateNetwork?: unknown;
|
||||
};
|
||||
contextTokens?: number;
|
||||
contextWindow?: number;
|
||||
maxTokens?: number;
|
||||
}
|
||||
| string;
|
||||
runtimeModel?: {
|
||||
api?: string;
|
||||
id?: string;
|
||||
provider?: string;
|
||||
baseUrl?: string;
|
||||
azureApiVersion?: string;
|
||||
headers?: Record<string, string | null | undefined>;
|
||||
authHeader?: boolean;
|
||||
params?: Record<string, unknown>;
|
||||
request?: {
|
||||
auth?: { mode?: unknown };
|
||||
proxy?: unknown;
|
||||
tls?: unknown;
|
||||
allowPrivateNetwork?: unknown;
|
||||
};
|
||||
contextTokens?: number;
|
||||
contextWindow?: number;
|
||||
maxTokens?: number;
|
||||
};
|
||||
profileVersion?: string;
|
||||
resolvedApiKey?: string;
|
||||
sessionKey?: string;
|
||||
workspaceDir?: string;
|
||||
};
|
||||
const modelObj: { api?: string; id?: string; provider?: string } =
|
||||
const modelObj: {
|
||||
api?: string;
|
||||
id?: string;
|
||||
provider?: string;
|
||||
baseUrl?: string;
|
||||
azureApiVersion?: string;
|
||||
headers?: Record<string, string | null | undefined>;
|
||||
authHeader?: boolean;
|
||||
params?: Record<string, unknown>;
|
||||
request?: {
|
||||
auth?: { mode?: unknown };
|
||||
proxy?: unknown;
|
||||
tls?: unknown;
|
||||
allowPrivateNetwork?: unknown;
|
||||
};
|
||||
contextTokens?: number;
|
||||
contextWindow?: number;
|
||||
maxTokens?: number;
|
||||
} =
|
||||
p.model && typeof p.model === "object"
|
||||
? p.model
|
||||
: p.runtimeModel && typeof p.runtimeModel === "object"
|
||||
? p.runtimeModel
|
||||
: { id: typeof p.model === "string" ? p.model : undefined };
|
||||
const provider = modelObj.provider ?? (typeof p.provider === "string" ? p.provider : "");
|
||||
const modelId =
|
||||
modelObj.id ??
|
||||
(typeof p.modelId === "string" ? p.modelId : undefined) ??
|
||||
(typeof p.model === "string" ? p.model : "");
|
||||
const requestTransport =
|
||||
p.model && typeof p.model === "object" ? getModelProviderRequestTransport(p.model) : undefined;
|
||||
const requestAuthMode = readSessionString(
|
||||
requestTransport?.auth?.mode ?? modelObj.request?.auth?.mode,
|
||||
);
|
||||
const azureApiVersion = readSessionString(
|
||||
modelObj.azureApiVersion ?? modelObj.params?.azureApiVersion,
|
||||
);
|
||||
// resolveCopilotAuth can throw when an explicit `auth.gitHubToken`
|
||||
// is supplied without profileId + profileVersion (the existing
|
||||
// pool-key safety invariant). That same error would surface
|
||||
@@ -373,16 +449,63 @@ function computeSessionKey(
|
||||
let resolvedAgentId = "";
|
||||
let resolvedCopilotHome = "";
|
||||
try {
|
||||
const resolved = resolveCopilotAuth({
|
||||
agentId: typeof p.agentId === "string" ? p.agentId : readAgentIdFromSessionKey(p.sessionKey),
|
||||
agentDir: typeof p.agentDir === "string" ? p.agentDir : undefined,
|
||||
workspaceDir: typeof p.workspaceDir === "string" ? p.workspaceDir : undefined,
|
||||
copilotHome: typeof p.copilotHome === "string" ? p.copilotHome : undefined,
|
||||
auth: p.auth,
|
||||
resolvedApiKey: typeof p.resolvedApiKey === "string" ? p.resolvedApiKey : undefined,
|
||||
authProfileId: typeof p.authProfileId === "string" ? p.authProfileId : undefined,
|
||||
profileVersion: typeof p.profileVersion === "string" ? p.profileVersion : undefined,
|
||||
});
|
||||
const resolved = !options.includeAuth
|
||||
? resolveCopilotAuth({
|
||||
agentId:
|
||||
typeof p.agentId === "string" ? p.agentId : readAgentIdFromSessionKey(p.sessionKey),
|
||||
agentDir: typeof p.agentDir === "string" ? p.agentDir : undefined,
|
||||
workspaceDir: typeof p.workspaceDir === "string" ? p.workspaceDir : undefined,
|
||||
copilotHome: typeof p.copilotHome === "string" ? p.copilotHome : undefined,
|
||||
auth: { useLoggedInUser: true },
|
||||
})
|
||||
: (() => {
|
||||
const modelProvider = resolveCopilotProvider({
|
||||
model: {
|
||||
api: modelObj.api,
|
||||
id: modelId,
|
||||
provider,
|
||||
baseUrl: modelObj.baseUrl,
|
||||
azureApiVersion,
|
||||
headers: modelObj.headers,
|
||||
authHeader: modelObj.authHeader,
|
||||
requestAuthMode,
|
||||
requestProxy: requestTransport?.proxy ?? modelObj.request?.proxy,
|
||||
requestTls: requestTransport?.tls ?? modelObj.request?.tls,
|
||||
requestAllowPrivateNetwork:
|
||||
requestTransport?.allowPrivateNetwork ?? modelObj.request?.allowPrivateNetwork,
|
||||
contextTokens: modelObj.contextTokens,
|
||||
contextWindow: modelObj.contextWindow,
|
||||
maxTokens: modelObj.maxTokens,
|
||||
},
|
||||
resolvedApiKey: typeof p.resolvedApiKey === "string" ? p.resolvedApiKey : undefined,
|
||||
authProfileId: typeof p.authProfileId === "string" ? p.authProfileId : undefined,
|
||||
});
|
||||
return modelProvider.mode === "byok"
|
||||
? createCopilotByokAuth({
|
||||
agentId:
|
||||
typeof p.agentId === "string"
|
||||
? p.agentId
|
||||
: readAgentIdFromSessionKey(p.sessionKey),
|
||||
agentDir: typeof p.agentDir === "string" ? p.agentDir : undefined,
|
||||
workspaceDir: typeof p.workspaceDir === "string" ? p.workspaceDir : undefined,
|
||||
copilotHome: typeof p.copilotHome === "string" ? p.copilotHome : undefined,
|
||||
authProfileId: modelProvider.authProfileId,
|
||||
authProfileVersion: modelProvider.authProfileVersion,
|
||||
})
|
||||
: resolveCopilotAuth({
|
||||
agentId:
|
||||
typeof p.agentId === "string"
|
||||
? p.agentId
|
||||
: readAgentIdFromSessionKey(p.sessionKey),
|
||||
agentDir: typeof p.agentDir === "string" ? p.agentDir : undefined,
|
||||
workspaceDir: typeof p.workspaceDir === "string" ? p.workspaceDir : undefined,
|
||||
copilotHome: typeof p.copilotHome === "string" ? p.copilotHome : undefined,
|
||||
auth: p.auth,
|
||||
resolvedApiKey: typeof p.resolvedApiKey === "string" ? p.resolvedApiKey : undefined,
|
||||
authProfileId: typeof p.authProfileId === "string" ? p.authProfileId : undefined,
|
||||
profileVersion: typeof p.profileVersion === "string" ? p.profileVersion : undefined,
|
||||
});
|
||||
})();
|
||||
resolvedAgentId = resolved.agentId;
|
||||
resolvedCopilotHome = resolved.copilotHome;
|
||||
authParts = [
|
||||
@@ -390,6 +513,9 @@ function computeSessionKey(
|
||||
`auth.profileId=${resolved.authProfileId ?? ""}`,
|
||||
`auth.profileVersion=${resolved.authProfileVersion ?? ""}`,
|
||||
];
|
||||
if (!options.includeAuth) {
|
||||
authParts = [];
|
||||
}
|
||||
} catch {
|
||||
authParts = ["auth=unresolvable"];
|
||||
}
|
||||
@@ -397,6 +523,9 @@ function computeSessionKey(
|
||||
`provider=${provider}`,
|
||||
`model=${modelId}`,
|
||||
...(options.includeApi ? [`api=${modelObj.api ?? ""}`] : []),
|
||||
...(options.includeApi
|
||||
? [`baseUrlFingerprint=${fingerprintSessionValue(modelObj.baseUrl)}`]
|
||||
: []),
|
||||
`cwd=${p.cwd ?? p.workspaceDir ?? ""}`,
|
||||
`agentId=${resolvedAgentId}`,
|
||||
`agentDir=${p.agentDir ?? ""}`,
|
||||
@@ -407,6 +536,14 @@ function computeSessionKey(
|
||||
return parts.join("|");
|
||||
}
|
||||
|
||||
function readSessionString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function fingerprintSessionValue(value: unknown): string {
|
||||
return typeof value === "string" && value ? tokenFingerprint(value) : "";
|
||||
}
|
||||
|
||||
function computeSessionCompatKey(params: CopilotSessionCompatParams): string {
|
||||
return computeSessionKey(params, { includeApi: true, includeAuth: true });
|
||||
}
|
||||
@@ -531,12 +668,38 @@ export function createCopilotAgentHarness(
|
||||
return { supported: false, reason: "copilot is opt-in only" };
|
||||
}
|
||||
const provider = ctx.provider.trim().toLowerCase();
|
||||
if (!COPILOT_PROVIDER_IDS.has(provider)) {
|
||||
if (!provider) {
|
||||
return { supported: false, reason: "provider is required" };
|
||||
}
|
||||
if (COPILOT_PROVIDER_IDS.has(provider)) {
|
||||
return { supported: true, priority: 100 };
|
||||
}
|
||||
const providerOwnerPluginIds = ctx.providerOwnerPluginIds;
|
||||
if (
|
||||
ctx.providerOwnerStatus !== "unowned" ||
|
||||
!providerOwnerPluginIds ||
|
||||
providerOwnerPluginIds.length > 0
|
||||
) {
|
||||
return {
|
||||
supported: false,
|
||||
reason: `provider is not one of: ${[...COPILOT_PROVIDER_IDS].toSorted().join(", ")}`,
|
||||
};
|
||||
}
|
||||
if (
|
||||
!supportsCopilotByokProviderShape({
|
||||
api: ctx.modelProvider?.api,
|
||||
baseUrl: ctx.modelProvider?.baseUrl,
|
||||
requestProxy: ctx.modelProvider?.request?.proxy,
|
||||
requestTls: ctx.modelProvider?.request?.tls,
|
||||
requestAllowPrivateNetwork: ctx.modelProvider?.request?.allowPrivateNetwork,
|
||||
})
|
||||
) {
|
||||
return {
|
||||
supported: false,
|
||||
reason:
|
||||
"provider is not a supported Copilot BYOK model (requires supported api, baseUrl, and no request transport policy overrides)",
|
||||
};
|
||||
}
|
||||
return { supported: true, priority: 100 };
|
||||
},
|
||||
|
||||
@@ -549,11 +712,22 @@ export function createCopilotAgentHarness(
|
||||
if (disposed) {
|
||||
throw new Error("[copilot] harness was disposed while starting an attempt");
|
||||
}
|
||||
const poolAcquire = resolvePoolAcquire(params as never);
|
||||
const pool = await getPool();
|
||||
if (disposed) {
|
||||
throw new Error("[copilot] harness was disposed while starting an attempt");
|
||||
}
|
||||
let poolAcquire: ReturnType<typeof resolvePoolAcquire>;
|
||||
try {
|
||||
poolAcquire = resolvePoolAcquire(params as never);
|
||||
} catch (error) {
|
||||
// Keep invalid forced BYOK model configuration on the normal attempt
|
||||
// result path so callers receive `model_not_supported` instead of an
|
||||
// uncaught harness rejection. Other auth/pool errors remain fatal.
|
||||
if (isCopilotByokUnsupportedProviderError(error)) {
|
||||
return runCopilotAttempt(params, { pool });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const openclawSessionId =
|
||||
typeof params.sessionId === "string" ? params.sessionId : undefined;
|
||||
|
||||
@@ -611,10 +785,12 @@ export function createCopilotAgentHarness(
|
||||
pool,
|
||||
onSessionEstablished: openclawSessionId
|
||||
? ({
|
||||
compactionSessionConfig,
|
||||
sdkSessionId,
|
||||
pooledClient,
|
||||
sessionConfig,
|
||||
}: {
|
||||
compactionSessionConfig?: CopilotSessionConfig;
|
||||
sdkSessionId: string;
|
||||
pooledClient: PooledClient;
|
||||
sessionConfig: CopilotSessionConfig;
|
||||
@@ -626,7 +802,7 @@ export function createCopilotAgentHarness(
|
||||
compatKey: currentCompatKey,
|
||||
compactKey: currentCompactKey,
|
||||
poolKey: pooledClient.key,
|
||||
sessionConfig,
|
||||
sessionConfig: compactionSessionConfig ?? sessionConfig,
|
||||
...sessionAuthFields(poolAcquire.auth),
|
||||
});
|
||||
registerStoredBinding(options?.sessionStore, openclawSessionId, {
|
||||
@@ -768,8 +944,24 @@ export function createCopilotAgentHarness(
|
||||
const tracked = trackedSessions.get(openclawSessionId);
|
||||
const currentCompactKey = computeSessionCompactKey(params);
|
||||
const { resolvePoolAcquire } = await import("./src/attempt.js");
|
||||
const resolvedPoolAcquire = resolvePoolAcquire(params as never);
|
||||
const currentAuth = sessionAuthFields(resolvedPoolAcquire.auth);
|
||||
let resolvedPoolAcquire: ReturnType<typeof resolvePoolAcquire> | undefined;
|
||||
let currentAuth: CopilotSessionAuth | undefined;
|
||||
try {
|
||||
resolvedPoolAcquire = resolvePoolAcquire(params as never);
|
||||
} catch (error) {
|
||||
if (isCopilotByokUnsupportedProviderError(error)) {
|
||||
return {
|
||||
ok: false,
|
||||
compacted: false,
|
||||
reason: "missing_thread_binding",
|
||||
failure: { reason: "missing_thread_binding" },
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!currentAuth) {
|
||||
currentAuth = sessionAuthFields(resolvedPoolAcquire.auth);
|
||||
}
|
||||
const compatibleTracked =
|
||||
tracked?.compactKey === currentCompactKey && sessionAuthMatches(tracked, currentAuth)
|
||||
? tracked
|
||||
@@ -785,19 +977,32 @@ export function createCopilotAgentHarness(
|
||||
failure: { reason: "missing_thread_binding" },
|
||||
};
|
||||
}
|
||||
const poolAcquire = compatibleTracked
|
||||
? { key: compatibleTracked.poolKey, options: compatibleTracked.clientOptions }
|
||||
: resolvedPoolAcquire;
|
||||
const poolAcquire = {
|
||||
key: compatibleTracked.poolKey,
|
||||
options: compatibleTracked.clientOptions,
|
||||
};
|
||||
let compactResult: CopilotHistoryCompactResult;
|
||||
let handle: PooledClient | undefined;
|
||||
let pool: CopilotClientPool | undefined;
|
||||
let activeSdkSession: CopilotHistoryCompactSession | undefined;
|
||||
let cleanupByokProxy: (() => Promise<void>) | undefined;
|
||||
const hookContext = buildCopilotCompactionHookContext(params);
|
||||
try {
|
||||
throwIfAborted(params.abortSignal);
|
||||
pool = await getPool();
|
||||
handle = await pool.acquire(poolAcquire.key, poolAcquire.options);
|
||||
const client = handle.client;
|
||||
const byokProxy =
|
||||
compatibleTracked.authMode === "byok" && compatibleTracked.sessionConfig.provider
|
||||
? await createCopilotByokProxy({
|
||||
mode: "byok",
|
||||
provider: compatibleTracked.sessionConfig.provider,
|
||||
})
|
||||
: undefined;
|
||||
cleanupByokProxy = byokProxy?.close;
|
||||
const sessionConfig = byokProxy?.provider.provider
|
||||
? { ...compatibleTracked.sessionConfig, provider: byokProxy.provider.provider }
|
||||
: compatibleTracked.sessionConfig;
|
||||
// Manual compaction resumes a distinct SDK session, bypassing the attempt event bridge.
|
||||
// Run the portable lifecycle hook here so both compaction paths stay observable.
|
||||
await runAgentHarnessBeforeCompactionHook({
|
||||
@@ -812,13 +1017,13 @@ export function createCopilotAgentHarness(
|
||||
customInstructions: params.customInstructions,
|
||||
gitHubToken:
|
||||
compatibleTracked?.clientOptions.gitHubToken ??
|
||||
(resolvedPoolAcquire.auth.authMode === "gitHubToken"
|
||||
(resolvedPoolAcquire?.auth.authMode === "gitHubToken"
|
||||
? resolvedPoolAcquire.auth.gitHubToken
|
||||
: undefined),
|
||||
onSession: (session) => {
|
||||
activeSdkSession = session;
|
||||
},
|
||||
sessionConfig: compatibleTracked.sessionConfig,
|
||||
sessionConfig,
|
||||
sdkSessionId: compatibleTracked.sdkSessionId,
|
||||
}),
|
||||
resolveCompactionTimeoutMs(
|
||||
@@ -852,6 +1057,7 @@ export function createCopilotAgentHarness(
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
await cleanupByokProxy?.();
|
||||
if (pool && handle) {
|
||||
try {
|
||||
await pool.release(handle);
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from "node:path";
|
||||
import type { CopilotClient, Tool as SdkTool } from "@github/copilot-sdk";
|
||||
import {
|
||||
abortAgentHarnessRun,
|
||||
attachModelProviderRequestTransport,
|
||||
queueAgentHarnessMessage,
|
||||
type AgentHarnessAttemptParams,
|
||||
type AgentHarnessAttemptResult,
|
||||
@@ -104,11 +105,12 @@ function createDeferred<T>() {
|
||||
function flushAsync() {
|
||||
// Pump enough microtasks for the attempt to settle past every
|
||||
// pre-createSession `await` in attempt.ts (resolvePoolAcquire,
|
||||
// resolveCopilotWorkspaceBootstrapContext, createSession, etc.).
|
||||
// BYOK proxy setup, resolveCopilotWorkspaceBootstrapContext,
|
||||
// createSession, etc.).
|
||||
// Each chained `then` is one tick; tests rely on this to observe
|
||||
// `sdk.sessions[0]` being populated before they emit deltas.
|
||||
const tick = () => Promise.resolve();
|
||||
return tick().then(tick).then(tick);
|
||||
return tick().then(tick).then(tick).then(tick).then(tick);
|
||||
}
|
||||
|
||||
function waitForEventLoopTurn(): Promise<void> {
|
||||
@@ -2338,6 +2340,152 @@ describe("runCopilotAttempt", () => {
|
||||
expect(options.useLoggedInUser).toBe(false);
|
||||
});
|
||||
|
||||
it("pool keying: BYOK does not resolve unrelated GitHub auth", async () => {
|
||||
const sdk = makeFakeSdk();
|
||||
const pool = makeFakePool(sdk);
|
||||
|
||||
await runCopilotAttempt(
|
||||
makeParams({
|
||||
auth: { gitHubToken: "unrelated-token" } as never,
|
||||
model: {
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://api.example.test/v1",
|
||||
id: "gpt-test",
|
||||
provider: "custom-openai",
|
||||
} as never,
|
||||
resolvedApiKey: "byok-token",
|
||||
authProfileId: "custom-openai:main",
|
||||
} as never),
|
||||
{ pool },
|
||||
);
|
||||
|
||||
const key = (vi.mocked(pool["acquire"]).mock.calls[0] as unknown[] | undefined)?.[0] as {
|
||||
authMode: string;
|
||||
authProfileId?: string;
|
||||
};
|
||||
const options = (vi.mocked(pool["acquire"]).mock.calls[0] as unknown[] | undefined)?.[1] as {
|
||||
gitHubToken?: string;
|
||||
useLoggedInUser?: boolean;
|
||||
};
|
||||
const cfg = (sdk.createSession.mock.calls[0] as unknown[] | undefined)?.[0] as {
|
||||
provider?: { apiKey?: string; baseUrl?: string };
|
||||
};
|
||||
|
||||
expect(key.authMode).toBe("byok");
|
||||
expect(key.authProfileId).toBe("custom-openai:main");
|
||||
expect(options.gitHubToken).toBeUndefined();
|
||||
expect(options.useLoggedInUser).toBe(false);
|
||||
expect(cfg.provider).toEqual(
|
||||
expect.objectContaining({
|
||||
apiKey: "byok-token",
|
||||
baseUrl: expect.stringMatching(/^http:\/\/127\.0\.0\.1:\d+\/[a-f0-9]{24}\/v1$/),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards BYOK provider headers on the model request turn", async () => {
|
||||
const sdk = makeFakeSdk();
|
||||
const pool = makeFakePool(sdk);
|
||||
|
||||
await runCopilotAttempt(
|
||||
makeParams({
|
||||
model: {
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://anthropic.example.test",
|
||||
headers: {
|
||||
"X-Tenant": "tenant-a",
|
||||
"X-Trace": "trace-1",
|
||||
},
|
||||
id: "claude-test",
|
||||
provider: "anthropic-proxy",
|
||||
} as never,
|
||||
resolvedApiKey: "byok-token",
|
||||
authProfileId: "anthropic-proxy:main",
|
||||
} as never),
|
||||
{ pool },
|
||||
);
|
||||
|
||||
const cfg = (sdk.createSession.mock.calls[0] as unknown[] | undefined)?.[0] as {
|
||||
provider?: { headers?: Record<string, string> };
|
||||
};
|
||||
const sendOptions = sdk.sessions[0]?.sendAndWait.mock.calls[0]?.[0] as {
|
||||
requestHeaders?: Record<string, string>;
|
||||
};
|
||||
expect(cfg.provider?.headers).toEqual({
|
||||
"X-Tenant": "tenant-a",
|
||||
"X-Trace": "trace-1",
|
||||
});
|
||||
expect(sendOptions.requestHeaders).toEqual({
|
||||
"X-Tenant": "tenant-a",
|
||||
"X-Trace": "trace-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves prepared BYOK header-auth without synthesizing SDK apiKey auth", async () => {
|
||||
const sdk = makeFakeSdk();
|
||||
const pool = makeFakePool(sdk);
|
||||
const model = attachModelProviderRequestTransport(
|
||||
{
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://proxy.example.test/v1",
|
||||
headers: { "x-api-key": "header-secret" },
|
||||
id: "gpt-test",
|
||||
provider: "custom-header-proxy",
|
||||
},
|
||||
{ auth: { mode: "header", headerName: "x-api-key", value: "header-secret" } },
|
||||
);
|
||||
|
||||
await runCopilotAttempt(
|
||||
makeParams({
|
||||
model: model as never,
|
||||
resolvedApiKey: "header-secret",
|
||||
authProfileId: "custom-header-proxy:main",
|
||||
} as never),
|
||||
{ pool },
|
||||
);
|
||||
|
||||
const cfg = (sdk.createSession.mock.calls[0] as unknown[] | undefined)?.[0] as {
|
||||
provider?: { apiKey?: string; headers?: Record<string, string> };
|
||||
};
|
||||
const sendOptions = sdk.sessions[0]?.sendAndWait.mock.calls[0]?.[0] as {
|
||||
requestHeaders?: Record<string, string>;
|
||||
};
|
||||
expect(cfg.provider).toEqual(
|
||||
expect.objectContaining({
|
||||
headers: { "x-api-key": "header-secret" },
|
||||
}),
|
||||
);
|
||||
expect(cfg.provider).not.toHaveProperty("apiKey");
|
||||
expect(sendOptions.requestHeaders).toEqual({ "x-api-key": "header-secret" });
|
||||
});
|
||||
|
||||
it("rejects BYOK providers with request transport policy overrides before creating a SDK session", async () => {
|
||||
const sdk = makeFakeSdk();
|
||||
const pool = makeFakePool(sdk);
|
||||
const model = attachModelProviderRequestTransport(
|
||||
{
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://proxy.example.test/v1",
|
||||
id: "gpt-test",
|
||||
provider: "custom-header-proxy",
|
||||
},
|
||||
{ proxy: { mode: "env-proxy" } },
|
||||
);
|
||||
|
||||
const result = await runCopilotAttempt(
|
||||
makeParams({
|
||||
model: model as never,
|
||||
resolvedApiKey: "header-secret",
|
||||
authProfileId: "custom-header-proxy:main",
|
||||
} as never),
|
||||
{ pool },
|
||||
);
|
||||
|
||||
expect(getPromptErrorCode(result)).toBe("model_not_supported");
|
||||
expect((result.promptError as Error | undefined)?.message).toContain("request proxy");
|
||||
expect(sdk.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("session-level gitHubToken (independent of client-level)", () => {
|
||||
// The SDK contract (@github/copilot-sdk/dist/types.d.ts:1168-1178)
|
||||
// makes `SessionConfig.gitHubToken` independent of the client-level
|
||||
@@ -2401,6 +2549,37 @@ describe("runCopilotAttempt", () => {
|
||||
expect(resumeCfg.gitHubToken).toBe("contract-token-resume");
|
||||
});
|
||||
|
||||
it("BYOK provider config is forwarded to resumeSession", async () => {
|
||||
const sdk = makeFakeSdk();
|
||||
const pool = makeFakePool(sdk);
|
||||
|
||||
await runCopilotAttempt(
|
||||
makeParams({
|
||||
auth: { gitHubToken: "unrelated-token" } as never,
|
||||
model: {
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://api.example.test/v1",
|
||||
id: "gpt-test",
|
||||
provider: "custom-openai",
|
||||
} as never,
|
||||
resolvedApiKey: "byok-token",
|
||||
authProfileId: "custom-openai:main",
|
||||
initialReplayState: { sdkSessionId: "resume-target" } as never,
|
||||
} as never),
|
||||
{ pool },
|
||||
);
|
||||
|
||||
const resumeCfg = sdk.resumeSession.mock.calls[0]?.[1] as {
|
||||
provider?: { apiKey?: string; baseUrl?: string };
|
||||
};
|
||||
expect(resumeCfg.provider).toEqual(
|
||||
expect.objectContaining({
|
||||
apiKey: "byok-token",
|
||||
baseUrl: expect.stringMatching(/^http:\/\/127\.0\.0\.1:\d+\/[a-f0-9]{24}\/v1$/),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("SessionConfig.gitHubToken is omitted when useLoggedInUser is the resolved mode", async () => {
|
||||
const sdk = makeFakeSdk();
|
||||
const pool = makeFakePool(sdk);
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
import {
|
||||
buildAgentHookContextChannelFields,
|
||||
detectAndLoadAgentHarnessPromptImages,
|
||||
getModelProviderRequestTransport,
|
||||
resolveAgentHarnessBeforePromptBuildResult,
|
||||
resolveAttemptFsWorkspaceOnly,
|
||||
resolveAttemptSpawnWorkspaceDir,
|
||||
@@ -27,7 +28,8 @@ import {
|
||||
clearActiveEmbeddedRun,
|
||||
setActiveEmbeddedRun,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { resolveCopilotAuth } from "./auth-bridge.js";
|
||||
import { createCopilotByokAuth, resolveCopilotAuth } from "./auth-bridge.js";
|
||||
import { createCopilotByokProxy } from "./byok-proxy.js";
|
||||
import {
|
||||
createInfiniteSessionConfig,
|
||||
type CopilotInfiniteSessionOptions,
|
||||
@@ -50,6 +52,7 @@ import {
|
||||
rejectAllPolicy,
|
||||
type CopilotPermissionPolicy,
|
||||
} from "./permission-bridge.js";
|
||||
import { resolveCopilotProvider, type ResolvedCopilotProvider } from "./provider-bridge.js";
|
||||
import {
|
||||
classifyResumeFailure,
|
||||
computeReplayMetadata,
|
||||
@@ -79,6 +82,7 @@ export type CopilotSessionConfig = Pick<
|
||||
| "model"
|
||||
| "onPermissionRequest"
|
||||
| "onUserInputRequest"
|
||||
| "provider"
|
||||
| "reasoningEffort"
|
||||
| "systemMessage"
|
||||
| "tools"
|
||||
@@ -115,7 +119,42 @@ type AttemptParamsLike = AgentHarnessAttemptParams & {
|
||||
// internal expansion. Symmetric to `EmbeddedRunAttemptParams.transcriptPrompt`.
|
||||
transcriptPrompt?: string;
|
||||
};
|
||||
type ModelRef = { api?: string; id: string; provider: string };
|
||||
type ModelRef = {
|
||||
api?: string;
|
||||
id: string;
|
||||
provider: string;
|
||||
baseUrl?: string;
|
||||
azureApiVersion?: string;
|
||||
headers?: Record<string, string | null | undefined>;
|
||||
authHeader?: boolean;
|
||||
requestAuthMode?: string;
|
||||
requestProxy?: unknown;
|
||||
requestTls?: unknown;
|
||||
requestAllowPrivateNetwork?: unknown;
|
||||
contextTokens?: number;
|
||||
contextWindow?: number;
|
||||
maxTokens?: number;
|
||||
};
|
||||
|
||||
type ModelRefInputObject = {
|
||||
api?: unknown;
|
||||
id?: unknown;
|
||||
provider?: unknown;
|
||||
baseUrl?: unknown;
|
||||
azureApiVersion?: unknown;
|
||||
params?: { azureApiVersion?: unknown };
|
||||
headers?: ModelRef["headers"];
|
||||
authHeader?: boolean;
|
||||
request?: {
|
||||
auth?: { mode?: unknown };
|
||||
proxy?: unknown;
|
||||
tls?: unknown;
|
||||
allowPrivateNetwork?: unknown;
|
||||
};
|
||||
contextTokens?: number;
|
||||
contextWindow?: number;
|
||||
maxTokens?: number;
|
||||
};
|
||||
|
||||
export type { AttemptParamsLike as CopilotPoolAcquireInput, ModelRef };
|
||||
export { SUPPORTED_PROVIDERS };
|
||||
@@ -142,6 +181,7 @@ export interface CopilotAttemptDeps {
|
||||
* attempt.
|
||||
*/
|
||||
onSessionEstablished?: (info: {
|
||||
compactionSessionConfig?: CopilotSessionConfig;
|
||||
sdkSessionId: string;
|
||||
pooledClient: PooledClient;
|
||||
sessionConfig: CopilotSessionConfig;
|
||||
@@ -228,6 +268,7 @@ function deferBackgroundCompactionCleanup(params: {
|
||||
bridge: ReturnType<typeof attachEventBridge>;
|
||||
handle: PooledClient;
|
||||
pool: CopilotClientPool;
|
||||
cleanupByokProxy?: () => Promise<void>;
|
||||
cleanupToolBridge?: () => void;
|
||||
finalizeNativeSubagents?: () => void;
|
||||
sdkSessionId?: string;
|
||||
@@ -260,6 +301,7 @@ function deferBackgroundCompactionCleanup(params: {
|
||||
// The attempt has already returned its timeout result.
|
||||
}
|
||||
params.cleanupToolBridge?.();
|
||||
await params.cleanupByokProxy?.();
|
||||
if (outcome !== "completed" && params.sdkSessionId) {
|
||||
try {
|
||||
await params.handle.client.deleteSession(params.sdkSessionId);
|
||||
@@ -384,15 +426,18 @@ export async function runCopilotAttempt(
|
||||
);
|
||||
}
|
||||
|
||||
if (!SUPPORTED_PROVIDERS.has(modelRef.provider)) {
|
||||
try {
|
||||
resolveCopilotProvider({
|
||||
model: modelRef,
|
||||
resolvedApiKey: readString(params.resolvedApiKey),
|
||||
authProfileId: readString(params.authProfileId),
|
||||
});
|
||||
} catch (error) {
|
||||
return finishAttempt(
|
||||
createResult(input, {
|
||||
messagesSnapshot: messages,
|
||||
now,
|
||||
promptError: createPromptError(
|
||||
"model_not_supported",
|
||||
`[copilot-attempt] provider ${modelRef.provider} is not supported at MVP (subscription Copilot models only; BYOK arrives via byok-mapping-skeleton)`,
|
||||
),
|
||||
promptError: createPromptError("model_not_supported", toError(error).message, error),
|
||||
sdkSessionId: undefined,
|
||||
sessionIdUsed: input.sessionId,
|
||||
}),
|
||||
@@ -549,6 +594,22 @@ export async function runCopilotAttempt(
|
||||
})
|
||||
: undefined;
|
||||
const poolAcquire = resolvePoolAcquire(input);
|
||||
let byokProxy: Awaited<ReturnType<typeof createCopilotByokProxy>>;
|
||||
try {
|
||||
byokProxy = await createCopilotByokProxy(poolAcquire.provider);
|
||||
} catch (error) {
|
||||
return finishAttempt(
|
||||
createResult(input, {
|
||||
messagesSnapshot: messages,
|
||||
now,
|
||||
promptError: createPromptError("model_not_supported", toError(error).message, error),
|
||||
sdkSessionId: undefined,
|
||||
sessionIdUsed: input.sessionId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const cleanupByokProxy = byokProxy?.close;
|
||||
const sessionProvider = byokProxy?.provider ?? poolAcquire.provider;
|
||||
|
||||
// Mutable session holder shared with the tool bridge so onYield
|
||||
// (raised inside wrapped-tool execution) can route to the live SDK
|
||||
@@ -562,6 +623,7 @@ export async function runCopilotAttempt(
|
||||
let sdkTools: SdkTool[];
|
||||
try {
|
||||
const toolBridge = await createToolBridge({
|
||||
allowModelTools: poolAcquire.provider.mode === "byok",
|
||||
modelProvider: modelRef.provider,
|
||||
modelId: modelRef.id,
|
||||
agentId: readString(params.agentId) ?? "copilot",
|
||||
@@ -692,6 +754,7 @@ export async function runCopilotAttempt(
|
||||
modelRef.id,
|
||||
sdkTools,
|
||||
poolAcquire.auth,
|
||||
sessionProvider,
|
||||
promptBuild.developerInstructions || undefined,
|
||||
effectiveWorkspaceDir,
|
||||
effectiveCwd,
|
||||
@@ -703,6 +766,25 @@ export async function runCopilotAttempt(
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
const compactionSessionConfig = byokProxy
|
||||
? createSessionConfig(
|
||||
attemptInput,
|
||||
modelRef.id,
|
||||
sdkTools,
|
||||
poolAcquire.auth,
|
||||
poolAcquire.provider,
|
||||
promptBuild.developerInstructions || undefined,
|
||||
effectiveWorkspaceDir,
|
||||
effectiveCwd,
|
||||
userInputBridge.onUserInputRequest,
|
||||
hasNativePromptHook
|
||||
? {
|
||||
onUserPromptSubmitted: ({ additionalContext, prompt }) =>
|
||||
emitLlmInput(prompt, additionalContext),
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
: sessionConfig;
|
||||
const replayDecision = decideReplayAction({
|
||||
sdkSessionId: input.initialReplayState?.sdkSessionId,
|
||||
replayInvalid: input.initialReplayState?.replayInvalid,
|
||||
@@ -749,7 +831,12 @@ export async function runCopilotAttempt(
|
||||
sessionIdUsed = sdkSessionId ?? input.sessionId;
|
||||
if (sdkSessionId && deps.onSessionEstablished) {
|
||||
try {
|
||||
deps.onSessionEstablished({ sdkSessionId, pooledClient: handle, sessionConfig });
|
||||
deps.onSessionEstablished({
|
||||
compactionSessionConfig,
|
||||
sdkSessionId,
|
||||
pooledClient: handle,
|
||||
sessionConfig,
|
||||
});
|
||||
} catch {
|
||||
// never let session-tracking callbacks break attempts
|
||||
}
|
||||
@@ -809,6 +896,7 @@ export async function runCopilotAttempt(
|
||||
const messageOptions = await createMessageOptions(attemptInput, {
|
||||
effectiveCwd,
|
||||
effectiveWorkspaceDir,
|
||||
provider: poolAcquire.provider,
|
||||
sandbox,
|
||||
workspaceOnly: effectiveFsWorkspaceOnly,
|
||||
});
|
||||
@@ -890,6 +978,7 @@ export async function runCopilotAttempt(
|
||||
awaitSessionIdle: !bridge.hasObservedSessionIdle(),
|
||||
bridge,
|
||||
cleanupToolBridge,
|
||||
cleanupByokProxy,
|
||||
finalizeNativeSubagents: () => nativeSubagentTaskMirror?.finalizeActiveRuns(),
|
||||
handle,
|
||||
pool: deps.pool,
|
||||
@@ -922,6 +1011,7 @@ export async function runCopilotAttempt(
|
||||
await bridge?.awaitAgentEventChain();
|
||||
nativeSubagentTaskMirror?.finalizeActiveRuns();
|
||||
cleanupToolBridge?.();
|
||||
await cleanupByokProxy?.();
|
||||
bridge?.detach();
|
||||
params.abortSignal?.removeEventListener("abort", onAbort);
|
||||
|
||||
@@ -1191,6 +1281,7 @@ function createSessionConfig(
|
||||
sdkModelId: string,
|
||||
sdkTools: SdkTool[],
|
||||
resolvedAuth: ReturnType<typeof resolveCopilotAuth>,
|
||||
resolvedProvider: ResolvedCopilotProvider,
|
||||
systemMessageContent: string | undefined,
|
||||
effectiveWorkspaceDir: string | undefined,
|
||||
effectiveCwd: string | undefined,
|
||||
@@ -1225,6 +1316,10 @@ function createSessionConfig(
|
||||
// Registers the SDK ask_user bridge. The bridge itself owns pending
|
||||
// reply routing so generic mid-run steering still fails closed.
|
||||
onUserInputRequest,
|
||||
// The SDK's ResumeSessionConfig declaration omits ProviderConfig, but its
|
||||
// client forwards config.provider on both session.create and session.resume.
|
||||
// Keep one session config so BYOK resume/compaction stays on the same wire.
|
||||
...(resolvedProvider.provider ? { provider: resolvedProvider.provider } : {}),
|
||||
// Preserve the shipped native SDK hook contract. These callbacks expose
|
||||
// Copilot-specific events and decisions that generic lifecycle hooks do
|
||||
// not model.
|
||||
@@ -1314,14 +1409,28 @@ async function createMessageOptions(
|
||||
context: {
|
||||
effectiveCwd: string | undefined;
|
||||
effectiveWorkspaceDir: string | undefined;
|
||||
provider: ResolvedCopilotProvider;
|
||||
sandbox: SandboxContext | null;
|
||||
workspaceOnly: boolean;
|
||||
},
|
||||
): Promise<MessageOptions> {
|
||||
const attachments = createPromptImageAttachments(await resolvePromptImages(params, context));
|
||||
return attachments.length > 0
|
||||
? { prompt: params.prompt, attachments }
|
||||
: { prompt: params.prompt };
|
||||
const requestHeaders = resolveProviderRequestHeaders(context.provider);
|
||||
return {
|
||||
prompt: params.prompt,
|
||||
...(attachments.length > 0 ? { attachments } : {}),
|
||||
// The SDK declares session-level provider headers, but its Anthropic
|
||||
// runtime path consumes per-turn requestHeaders. Mirror them here so BYOK
|
||||
// tenant/proxy headers survive every supported adapter.
|
||||
...(requestHeaders ? { requestHeaders } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveProviderRequestHeaders(
|
||||
provider: ResolvedCopilotProvider,
|
||||
): Record<string, string> | undefined {
|
||||
const headers = provider.provider?.headers;
|
||||
return headers && Object.keys(headers).length > 0 ? { ...headers } : undefined;
|
||||
}
|
||||
|
||||
function createPromptImageAttachments(
|
||||
@@ -1488,18 +1597,35 @@ function readResolvedAttemptPath(value: unknown): string | undefined {
|
||||
}
|
||||
|
||||
export function resolveModelRef(params: AttemptParamsLike): ModelRef {
|
||||
const rawModel = params.model;
|
||||
const rawModel = (params as { runtimeModel?: unknown }).runtimeModel ?? params.model;
|
||||
if (rawModel && typeof rawModel === "object") {
|
||||
const model = rawModel as ModelRefInputObject;
|
||||
const requestTransport = getModelProviderRequestTransport(rawModel);
|
||||
const rawRequest = model.request;
|
||||
return {
|
||||
api: readString(rawModel.api),
|
||||
api: readString(model.api),
|
||||
id:
|
||||
readString(rawModel.id) ??
|
||||
readString(model.id) ??
|
||||
readString((params as { modelId?: unknown }).modelId) ??
|
||||
"unknown-model",
|
||||
provider:
|
||||
readString(rawModel.provider) ??
|
||||
readString(model.provider) ??
|
||||
readString((params as { provider?: unknown }).provider) ??
|
||||
"unknown-provider",
|
||||
baseUrl: readString(model.baseUrl),
|
||||
azureApiVersion: readString(
|
||||
model.azureApiVersion ?? model.params?.azureApiVersion,
|
||||
),
|
||||
headers: model.headers,
|
||||
authHeader: model.authHeader,
|
||||
requestAuthMode: readString(requestTransport?.auth?.mode ?? rawRequest?.auth?.mode),
|
||||
requestProxy: requestTransport?.proxy ?? rawRequest?.proxy,
|
||||
requestTls: requestTransport?.tls ?? rawRequest?.tls,
|
||||
requestAllowPrivateNetwork:
|
||||
requestTransport?.allowPrivateNetwork ?? rawRequest?.allowPrivateNetwork,
|
||||
contextTokens: model.contextTokens,
|
||||
contextWindow: model.contextWindow,
|
||||
maxTokens: model.maxTokens,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -1529,40 +1655,59 @@ export function resolvePoolAcquire(params: AttemptParamsLike): {
|
||||
* setting both.
|
||||
*/
|
||||
auth: ReturnType<typeof resolveCopilotAuth>;
|
||||
provider: ResolvedCopilotProvider;
|
||||
} {
|
||||
const resolved = resolveCopilotAuth({
|
||||
agentId: readString(params.agentId),
|
||||
agentDir: readString(params.agentDir),
|
||||
workspaceDir: readString(params.workspaceDir),
|
||||
copilotHome: readString(params.copilotHome),
|
||||
auth: params.auth,
|
||||
// Contract-resolved auth (EmbeddedRunAttemptParams): the production
|
||||
// main path for agents with a configured `github-copilot` auth
|
||||
// profile. Falling through to env / useLoggedInUser when absent
|
||||
// keeps the direct-CLI / dogfood paths working unchanged.
|
||||
const model = resolveModelRef(params);
|
||||
const provider = resolveCopilotProvider({
|
||||
model,
|
||||
resolvedApiKey: readString(params.resolvedApiKey),
|
||||
authProfileId: readString(params.authProfileId),
|
||||
profileVersion: readString(params.profileVersion),
|
||||
});
|
||||
|
||||
const auth =
|
||||
provider.mode === "byok"
|
||||
? createCopilotByokAuth({
|
||||
agentId: readString(params.agentId),
|
||||
agentDir: readString(params.agentDir),
|
||||
workspaceDir: readString(params.workspaceDir),
|
||||
copilotHome: readString(params.copilotHome),
|
||||
authProfileId: provider.authProfileId,
|
||||
authProfileVersion: provider.authProfileVersion,
|
||||
})
|
||||
: resolveCopilotAuth({
|
||||
agentId: readString(params.agentId),
|
||||
agentDir: readString(params.agentDir),
|
||||
workspaceDir: readString(params.workspaceDir),
|
||||
copilotHome: readString(params.copilotHome),
|
||||
auth: params.auth,
|
||||
// Contract-resolved auth (EmbeddedRunAttemptParams): the production
|
||||
// main path for agents with a configured `github-copilot` auth
|
||||
// profile. Falling through to env / useLoggedInUser when absent
|
||||
// keeps the direct-CLI / dogfood paths working unchanged.
|
||||
resolvedApiKey: readString(params.resolvedApiKey),
|
||||
authProfileId: readString(params.authProfileId),
|
||||
profileVersion: readString(params.profileVersion),
|
||||
});
|
||||
return {
|
||||
key: {
|
||||
agentId: resolved.agentId,
|
||||
authMode: resolved.authMode,
|
||||
...(resolved.authMode === "gitHubToken"
|
||||
agentId: auth.agentId,
|
||||
authMode: auth.authMode,
|
||||
...(auth.authMode === "gitHubToken" || auth.authMode === "byok"
|
||||
? {
|
||||
authProfileId: resolved.authProfileId,
|
||||
authProfileVersion: resolved.authProfileVersion,
|
||||
authProfileId: auth.authProfileId,
|
||||
authProfileVersion: auth.authProfileVersion,
|
||||
}
|
||||
: {}),
|
||||
copilotHome: resolved.copilotHome,
|
||||
copilotHome: auth.copilotHome,
|
||||
},
|
||||
options: {
|
||||
copilotHome: resolved.copilotHome,
|
||||
gitHubToken: resolved.authMode === "gitHubToken" ? resolved.gitHubToken : undefined,
|
||||
useLoggedInUser: resolved.authMode === "useLoggedInUser",
|
||||
copilotHome: auth.copilotHome,
|
||||
...(auth.authMode === "gitHubToken" && auth.gitHubToken
|
||||
? { gitHubToken: auth.gitHubToken }
|
||||
: {}),
|
||||
useLoggedInUser: auth.authMode === "useLoggedInUser",
|
||||
},
|
||||
auth: resolved,
|
||||
auth,
|
||||
provider,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -54,12 +54,12 @@ export const COPILOT_DEFAULT_AGENT_ID = "copilot";
|
||||
|
||||
/** Resolved auth shape that the runtime / pool consumes. */
|
||||
export interface ResolvedCopilotAuth {
|
||||
authMode: "useLoggedInUser" | "gitHubToken";
|
||||
authMode: "useLoggedInUser" | "gitHubToken" | "byok";
|
||||
/** Present only when authMode is "gitHubToken". */
|
||||
gitHubToken?: string;
|
||||
/** Present only when authMode is "gitHubToken". */
|
||||
/** Present for token and BYOK auth modes. */
|
||||
authProfileId?: string;
|
||||
/** Present only when authMode is "gitHubToken". */
|
||||
/** Present for token and BYOK auth modes. */
|
||||
authProfileVersion?: string;
|
||||
/** Absolute, normalized path. */
|
||||
copilotHome: string;
|
||||
@@ -67,6 +67,33 @@ export interface ResolvedCopilotAuth {
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
export function createCopilotByokAuth(input: {
|
||||
agentId?: string;
|
||||
agentDir?: string;
|
||||
workspaceDir?: string;
|
||||
copilotHome?: string;
|
||||
authProfileId?: string;
|
||||
authProfileVersion?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
homeDir?: () => string;
|
||||
}): ResolvedCopilotAuth {
|
||||
const base = resolveCopilotAuth({
|
||||
agentId: input.agentId,
|
||||
agentDir: input.agentDir,
|
||||
workspaceDir: input.workspaceDir,
|
||||
copilotHome: input.copilotHome,
|
||||
env: input.env,
|
||||
homeDir: input.homeDir,
|
||||
auth: { useLoggedInUser: true },
|
||||
});
|
||||
return {
|
||||
...base,
|
||||
authMode: "byok",
|
||||
authProfileId: input.authProfileId?.trim() || "byok:resolved",
|
||||
authProfileVersion: input.authProfileVersion?.trim() || "byok:unfingerprinted",
|
||||
};
|
||||
}
|
||||
|
||||
export interface ResolveCopilotAuthInput {
|
||||
agentId?: string;
|
||||
agentDir?: string;
|
||||
|
||||
167
extensions/copilot/src/byok-proxy.test.ts
Normal file
167
extensions/copilot/src/byok-proxy.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
// Copilot BYOK proxy tests verify SDK-local transport is guarded outbound fetch.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createCopilotByokProxy } from "./byok-proxy.js";
|
||||
import { resolveCopilotProvider } from "./provider-bridge.js";
|
||||
|
||||
const ssrfRuntimeMock = vi.hoisted(() => ({
|
||||
fetchWithSsrFGuard: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("openclaw/plugin-sdk/ssrf-runtime")>()),
|
||||
fetchWithSsrFGuard: ssrfRuntimeMock.fetchWithSsrFGuard,
|
||||
}));
|
||||
|
||||
describe("createCopilotByokProxy", () => {
|
||||
afterEach(() => {
|
||||
ssrfRuntimeMock.fetchWithSsrFGuard.mockReset();
|
||||
});
|
||||
|
||||
it("presents a loopback SDK endpoint and forwards through guarded fetch", async () => {
|
||||
const release = vi.fn(async () => undefined);
|
||||
ssrfRuntimeMock.fetchWithSsrFGuard.mockResolvedValue({
|
||||
response: new Response("ok", {
|
||||
status: 201,
|
||||
headers: {
|
||||
"content-encoding": "gzip",
|
||||
"content-length": "999",
|
||||
"x-upstream": "yes",
|
||||
},
|
||||
}),
|
||||
release,
|
||||
});
|
||||
const resolvedProvider = resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "custom-proxy",
|
||||
api: "openai-responses",
|
||||
id: "proxy-model",
|
||||
baseUrl: "https://proxy.example/v1?routing=blue",
|
||||
},
|
||||
resolvedApiKey: "secret-key",
|
||||
});
|
||||
|
||||
const proxy = await createCopilotByokProxy(resolvedProvider);
|
||||
expect(proxy?.provider.provider?.baseUrl).toMatch(
|
||||
/^http:\/\/127\.0\.0\.1:\d+\/[a-f0-9]{24}\/v1$/,
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${proxy?.provider.provider?.baseUrl}/responses?trace=request`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: "Bearer secret-key",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ model: "proxy-model" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.headers.get("content-encoding")).toBeNull();
|
||||
expect(response.headers.get("content-length")).toBeNull();
|
||||
expect(response.headers.get("x-upstream")).toBe("yes");
|
||||
expect(await response.text()).toBe("ok");
|
||||
expect(ssrfRuntimeMock.fetchWithSsrFGuard).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
auditContext: "copilot-byok-provider",
|
||||
requireHttps: true,
|
||||
url: "https://proxy.example/v1/responses?routing=blue&trace=request",
|
||||
init: expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({
|
||||
"accept-encoding": "identity",
|
||||
authorization: "Bearer secret-key",
|
||||
"content-type": "application/json",
|
||||
}),
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
await proxy?.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("aborts in-flight upstream fetches when the proxy closes", async () => {
|
||||
let upstreamSignal: AbortSignal | undefined;
|
||||
ssrfRuntimeMock.fetchWithSsrFGuard.mockImplementation(async ({ init }: any) => {
|
||||
upstreamSignal = init.signal;
|
||||
await new Promise((_, reject) => {
|
||||
upstreamSignal?.addEventListener("abort", () => reject(new Error("upstream aborted")), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
throw new Error("unreachable");
|
||||
});
|
||||
const resolvedProvider = resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "custom-proxy",
|
||||
api: "openai-responses",
|
||||
id: "proxy-model",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
},
|
||||
});
|
||||
const proxy = await createCopilotByokProxy(resolvedProvider);
|
||||
|
||||
const responsePromise = fetch(`${proxy?.provider.provider?.baseUrl}/responses`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ model: "proxy-model" }),
|
||||
}).catch((error: unknown) => error);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(upstreamSignal).toBeDefined();
|
||||
});
|
||||
|
||||
await proxy?.close();
|
||||
|
||||
expect(upstreamSignal?.aborted).toBe(true);
|
||||
await responsePromise;
|
||||
});
|
||||
|
||||
it("accepts Azure SDK paths that are rebuilt from the proxy origin", async () => {
|
||||
ssrfRuntimeMock.fetchWithSsrFGuard.mockResolvedValue({
|
||||
response: new Response("azure-ok", { status: 200 }),
|
||||
release: vi.fn(async () => undefined),
|
||||
});
|
||||
const resolvedProvider = resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "custom-azure",
|
||||
api: "azure-openai-responses",
|
||||
id: "deployment-gpt",
|
||||
baseUrl: "https://example.openai.azure.com/openai/v1",
|
||||
},
|
||||
resolvedApiKey: "azure-key",
|
||||
});
|
||||
|
||||
const proxy = await createCopilotByokProxy(resolvedProvider);
|
||||
expect(proxy?.provider.provider?.baseUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${proxy?.provider.provider?.baseUrl}/openai/v1/responses?trace=request`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "api-key": "azure-key" },
|
||||
body: JSON.stringify({ model: "deployment-gpt" }),
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe("azure-ok");
|
||||
expect(ssrfRuntimeMock.fetchWithSsrFGuard).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
requireHttps: true,
|
||||
url: "https://example.openai.azure.com/openai/v1/responses?trace=request",
|
||||
init: expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"accept-encoding": "identity",
|
||||
"api-key": "azure-key",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await proxy?.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
269
extensions/copilot/src/byok-proxy.ts
Normal file
269
extensions/copilot/src/byok-proxy.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
// Copilot BYOK transport proxy keeps OpenClaw in charge of outbound network policy.
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { Readable } from "node:stream";
|
||||
import { finished } from "node:stream/promises";
|
||||
import type { ReadableStream as NodeReadableStream } from "node:stream/web";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import type { ResolvedCopilotProvider } from "./provider-bridge.js";
|
||||
|
||||
const LOOPBACK_HOST = "127.0.0.1";
|
||||
|
||||
export type CopilotByokProxyHandle = {
|
||||
close: () => Promise<void>;
|
||||
provider: ResolvedCopilotProvider;
|
||||
};
|
||||
|
||||
type HeaderValue = string | number | string[] | undefined;
|
||||
|
||||
export async function createCopilotByokProxy(
|
||||
resolvedProvider: ResolvedCopilotProvider,
|
||||
): Promise<CopilotByokProxyHandle | undefined> {
|
||||
if (resolvedProvider.mode !== "byok") {
|
||||
return undefined;
|
||||
}
|
||||
const providerConfig = resolvedProvider.provider;
|
||||
if (!providerConfig?.baseUrl) {
|
||||
throw new Error("[copilot-attempt] BYOK requires a provider baseUrl");
|
||||
}
|
||||
|
||||
const targetBaseUrl = new URL(providerConfig.baseUrl);
|
||||
const nonce = randomBytes(12).toString("hex");
|
||||
const targetPathPrefix = trimTrailingSlash(targetBaseUrl.pathname);
|
||||
const proxyPathPrefix = `/${nonce}${targetPathPrefix}`;
|
||||
const acceptsAzureSdkPaths = providerConfig.type === "azure";
|
||||
const activeFetches = new Set<AbortController>();
|
||||
const server = createServer((req, res) => {
|
||||
void handleProxyRequest(req, res, {
|
||||
acceptsAzureSdkPaths,
|
||||
activeFetches,
|
||||
proxyPathPrefix,
|
||||
targetBaseUrl,
|
||||
targetPathPrefix,
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, LOOPBACK_HOST, () => {
|
||||
server.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close();
|
||||
throw new Error("[copilot-attempt] failed to start BYOK network proxy");
|
||||
}
|
||||
|
||||
const proxyBaseUrl = `http://${LOOPBACK_HOST}:${address.port}${proxyPathPrefix}`;
|
||||
const sdkBaseUrl = acceptsAzureSdkPaths
|
||||
? `http://${LOOPBACK_HOST}:${address.port}`
|
||||
: proxyBaseUrl;
|
||||
return {
|
||||
provider: {
|
||||
...resolvedProvider,
|
||||
provider: {
|
||||
...providerConfig,
|
||||
baseUrl: sdkBaseUrl,
|
||||
},
|
||||
},
|
||||
close: async () => {
|
||||
for (const controller of activeFetches) {
|
||||
controller.abort();
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function handleProxyRequest(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
params: {
|
||||
acceptsAzureSdkPaths: boolean;
|
||||
activeFetches: Set<AbortController>;
|
||||
proxyPathPrefix: string;
|
||||
targetBaseUrl: URL;
|
||||
targetPathPrefix: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
let guarded: Awaited<ReturnType<typeof fetchWithSsrFGuard>> | undefined;
|
||||
const upstreamAbort = new AbortController();
|
||||
params.activeFetches.add(upstreamAbort);
|
||||
const abortUpstream = () => upstreamAbort.abort();
|
||||
req.on("aborted", abortUpstream);
|
||||
res.on("close", () => {
|
||||
if (!res.writableEnded) {
|
||||
abortUpstream();
|
||||
}
|
||||
});
|
||||
try {
|
||||
const url = resolveTargetUrl(req, params);
|
||||
if (!url) {
|
||||
res.writeHead(404);
|
||||
res.end("Not found");
|
||||
return;
|
||||
}
|
||||
const body = req.method === "GET" || req.method === "HEAD" ? undefined : await readBody(req);
|
||||
guarded = await fetchWithSsrFGuard({
|
||||
url: url.toString(),
|
||||
init: {
|
||||
method: req.method,
|
||||
headers: normalizeProxyRequestHeaders(req.headers),
|
||||
signal: upstreamAbort.signal,
|
||||
...(body ? { body: toFetchBody(body) } : {}),
|
||||
},
|
||||
auditContext: "copilot-byok-provider",
|
||||
requireHttps: true,
|
||||
});
|
||||
res.writeHead(
|
||||
guarded.response.status,
|
||||
guarded.response.statusText,
|
||||
normalizeProxyResponseHeaders(guarded.response.headers),
|
||||
);
|
||||
if (!guarded.response.body) {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
await finished(
|
||||
Readable.fromWeb(
|
||||
guarded.response.body as unknown as NodeReadableStream<Uint8Array>,
|
||||
).pipe(res),
|
||||
);
|
||||
} catch (error) {
|
||||
if (res.destroyed || res.writableEnded) {
|
||||
return;
|
||||
}
|
||||
if (res.headersSent) {
|
||||
res.destroy(error instanceof Error ? error : undefined);
|
||||
return;
|
||||
}
|
||||
res.writeHead(502);
|
||||
res.end(error instanceof Error ? error.message : "BYOK provider proxy failed");
|
||||
} finally {
|
||||
req.off("aborted", abortUpstream);
|
||||
params.activeFetches.delete(upstreamAbort);
|
||||
await guarded?.release().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTargetUrl(
|
||||
req: IncomingMessage,
|
||||
params: {
|
||||
acceptsAzureSdkPaths: boolean;
|
||||
proxyPathPrefix: string;
|
||||
targetBaseUrl: URL;
|
||||
targetPathPrefix: string;
|
||||
},
|
||||
): URL | undefined {
|
||||
const incomingUrl = new URL(req.url ?? "/", `http://${LOOPBACK_HOST}`);
|
||||
if (
|
||||
incomingUrl.pathname !== params.proxyPathPrefix &&
|
||||
!incomingUrl.pathname.startsWith(`${params.proxyPathPrefix}/`)
|
||||
) {
|
||||
return params.acceptsAzureSdkPaths && isAzureSdkProxyPath(incomingUrl.pathname)
|
||||
? resolveDirectTargetUrl(incomingUrl, params.targetBaseUrl)
|
||||
: undefined;
|
||||
}
|
||||
const suffix = incomingUrl.pathname.slice(params.proxyPathPrefix.length);
|
||||
const targetUrl = new URL(params.targetBaseUrl);
|
||||
targetUrl.pathname = `${params.targetPathPrefix}${suffix}` || "/";
|
||||
for (const [key, value] of incomingUrl.searchParams) {
|
||||
targetUrl.searchParams.append(key, value);
|
||||
}
|
||||
return targetUrl;
|
||||
}
|
||||
|
||||
function resolveDirectTargetUrl(incomingUrl: URL, targetBaseUrl: URL): URL {
|
||||
const targetUrl = new URL(targetBaseUrl);
|
||||
targetUrl.pathname = incomingUrl.pathname;
|
||||
for (const [key, value] of incomingUrl.searchParams) {
|
||||
targetUrl.searchParams.append(key, value);
|
||||
}
|
||||
return targetUrl;
|
||||
}
|
||||
|
||||
function isAzureSdkProxyPath(pathname: string): boolean {
|
||||
return pathname === "/openai" || pathname.startsWith("/openai/");
|
||||
}
|
||||
|
||||
async function readBody(req: IncomingMessage): Promise<Buffer | undefined> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
return chunks.length > 0 ? Buffer.concat(chunks) : undefined;
|
||||
}
|
||||
|
||||
function toFetchBody(body: Buffer): Uint8Array<ArrayBuffer> {
|
||||
const copy = new Uint8Array(body.byteLength);
|
||||
copy.set(body);
|
||||
return copy;
|
||||
}
|
||||
|
||||
function normalizeProxyRequestHeaders(headers: IncomingMessage["headers"]): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (isHopByHopHeader(key) || key.toLowerCase() === "accept-encoding") {
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeHeaderValue(value);
|
||||
if (normalized !== undefined) {
|
||||
out[key] = normalized;
|
||||
}
|
||||
}
|
||||
out["accept-encoding"] = "identity";
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeProxyResponseHeaders(headers: Headers): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
headers.forEach((value, key) => {
|
||||
if (!isHopByHopHeader(key) && !isContentEncodingHeader(key)) {
|
||||
out[key] = value;
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeHeaderValue(value: HeaderValue): string | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return Array.isArray(value) ? value.join(", ") : String(value);
|
||||
}
|
||||
|
||||
function isHopByHopHeader(key: string): boolean {
|
||||
switch (key.toLowerCase()) {
|
||||
case "connection":
|
||||
case "host":
|
||||
case "keep-alive":
|
||||
case "proxy-authenticate":
|
||||
case "proxy-authorization":
|
||||
case "te":
|
||||
case "trailer":
|
||||
case "transfer-encoding":
|
||||
case "upgrade":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isContentEncodingHeader(key: string): boolean {
|
||||
switch (key.toLowerCase()) {
|
||||
case "content-encoding":
|
||||
case "content-length":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function trimTrailingSlash(pathname: string): string {
|
||||
const trimmed = pathname.replace(/\/+$/, "");
|
||||
return trimmed === "" ? "" : trimmed;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ const REGISTERED_EVENT_TYPES = [
|
||||
"tool.execution_complete",
|
||||
"session.plan_changed",
|
||||
"exit_plan_mode.requested",
|
||||
"exit_plan_mode.completed",
|
||||
"subagent.started",
|
||||
"subagent.completed",
|
||||
"subagent.failed",
|
||||
@@ -149,6 +150,50 @@ describe("attachEventBridge", () => {
|
||||
expect(bridge.snapshot().assistantTexts).toEqual(["hello"]);
|
||||
});
|
||||
|
||||
it("ignores child assistant and usage events but keeps child tool side effects", async () => {
|
||||
const session = createFakeSession();
|
||||
const onAssistantDelta = vi.fn();
|
||||
const bridge = attachEventBridge(session, {
|
||||
getSdkSessionId: () => "sdk-session-id",
|
||||
isAborted: () => false,
|
||||
onAssistantDelta,
|
||||
});
|
||||
|
||||
session.emit("assistant.message_delta", {
|
||||
...makeEvent("assistant.message_delta", { deltaContent: "child", messageId: "child-msg" }),
|
||||
agentId: "child-1",
|
||||
} as SessionEvent);
|
||||
session.emit(
|
||||
"assistant.message_delta",
|
||||
makeEvent("assistant.message_delta", { deltaContent: "root", messageId: "root-msg" }),
|
||||
);
|
||||
session.emit("tool.execution_start", {
|
||||
...makeEvent("tool.execution_start", { toolCallId: "child-call", toolName: "write" }),
|
||||
agentId: "child-1",
|
||||
} as SessionEvent);
|
||||
session.emit("tool.execution_complete", {
|
||||
...makeEvent("tool.execution_complete", {
|
||||
result: { content: "child write" },
|
||||
success: true,
|
||||
toolCallId: "child-call",
|
||||
}),
|
||||
agentId: "child-1",
|
||||
} as SessionEvent);
|
||||
session.emit("assistant.usage", {
|
||||
...makeEvent("assistant.usage", { inputTokens: 99, outputTokens: 99 }),
|
||||
agentId: "child-1",
|
||||
} as SessionEvent);
|
||||
|
||||
expect(bridge.snapshot().assistantTexts).toEqual(["root"]);
|
||||
expect(bridge.snapshot().startedCount).toBe(0);
|
||||
expect(bridge.snapshot().toolMetas).toEqual([
|
||||
{ toolName: "write" },
|
||||
{ meta: "child write", toolName: "write" },
|
||||
]);
|
||||
await bridge.awaitDeltaChain();
|
||||
expect(onAssistantDelta).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("interleaved messageIds produce two ordered assistantTexts entries", () => {
|
||||
const session = createFakeSession();
|
||||
const bridge = attachEventBridge(session, {
|
||||
@@ -483,10 +528,18 @@ describe("attachEventBridge", () => {
|
||||
summary: "Plan ready",
|
||||
}),
|
||||
);
|
||||
session.emit(
|
||||
"exit_plan_mode.completed",
|
||||
makeEvent("exit_plan_mode.completed", {
|
||||
approved: true,
|
||||
requestId: "request-1",
|
||||
selectedAction: "approve",
|
||||
}),
|
||||
);
|
||||
|
||||
await bridge.awaitAgentEventChain();
|
||||
|
||||
expect(onAgentEvent).toHaveBeenCalledTimes(2);
|
||||
expect(onAgentEvent).toHaveBeenCalledTimes(3);
|
||||
expect(onAgentEvent).toHaveBeenNthCalledWith(1, {
|
||||
stream: "plan",
|
||||
data: {
|
||||
@@ -509,6 +562,17 @@ describe("attachEventBridge", () => {
|
||||
recommendedAction: "approve",
|
||||
},
|
||||
});
|
||||
expect(onAgentEvent).toHaveBeenNthCalledWith(3, {
|
||||
stream: "plan",
|
||||
data: {
|
||||
phase: "update",
|
||||
title: "Plan decision",
|
||||
source: "copilot-sdk",
|
||||
requestId: "request-1",
|
||||
approved: true,
|
||||
selectedAction: "approve",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards native Copilot subagent lifecycle events to the adapter", () => {
|
||||
|
||||
@@ -128,6 +128,9 @@ export function attachEventBridge(
|
||||
const unsubscribeFns: Array<() => void> = [];
|
||||
|
||||
registerListener(session, unsubscribeFns, "assistant.message_delta", (event) => {
|
||||
if (!isRootSessionEvent(event)) {
|
||||
return;
|
||||
}
|
||||
const messageId = readString(event.data.messageId) ?? "assistant-message";
|
||||
const delta = event.data.deltaContent;
|
||||
if (!delta) {
|
||||
@@ -162,6 +165,9 @@ export function attachEventBridge(
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "assistant.reasoning_delta", (event) => {
|
||||
if (!isRootSessionEvent(event)) {
|
||||
return;
|
||||
}
|
||||
const reasoningId = readString(event.data.reasoningId) ?? "assistant-reasoning";
|
||||
const delta = event.data.deltaContent;
|
||||
if (!delta) {
|
||||
@@ -175,6 +181,9 @@ export function attachEventBridge(
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "assistant.message", (event) => {
|
||||
if (!isRootSessionEvent(event)) {
|
||||
return;
|
||||
}
|
||||
lastAssistantEvent = event;
|
||||
const entry = ensureMessageAccumulator(messagesById, messageOrder, event.data.messageId);
|
||||
if (typeof event.data.content === "string" && event.data.content.length >= entry.text.length) {
|
||||
@@ -183,17 +192,24 @@ export function attachEventBridge(
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "assistant.usage", (event) => {
|
||||
if (!isRootSessionEvent(event)) {
|
||||
return;
|
||||
}
|
||||
usage = normalizeCopilotUsage(event.data);
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "tool.execution_start", (event) => {
|
||||
startedCount += 1;
|
||||
if (isRootSessionEvent(event)) {
|
||||
startedCount += 1;
|
||||
}
|
||||
toolNamesByCallId.set(event.data.toolCallId, event.data.toolName);
|
||||
toolMetas.push({ toolName: event.data.toolName });
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "tool.execution_complete", (event) => {
|
||||
completedCount += 1;
|
||||
if (isRootSessionEvent(event)) {
|
||||
completedCount += 1;
|
||||
}
|
||||
const toolName = toolNamesByCallId.get(event.data.toolCallId);
|
||||
const meta = event.data.success
|
||||
? (event.data.result?.detailedContent ?? event.data.result?.content)
|
||||
@@ -236,6 +252,25 @@ export function attachEventBridge(
|
||||
});
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "exit_plan_mode.completed", (event) => {
|
||||
enqueueAgentEvent({
|
||||
stream: "plan",
|
||||
data: {
|
||||
phase: "update",
|
||||
title: "Plan decision",
|
||||
source: "copilot-sdk",
|
||||
requestId: event.data.requestId,
|
||||
...(event.data.approved !== undefined ? { approved: event.data.approved } : {}),
|
||||
...(event.data.autoApproveEdits !== undefined
|
||||
? { autoApproveEdits: event.data.autoApproveEdits }
|
||||
: {}),
|
||||
...(event.data.feedback ? { feedback: event.data.feedback } : {}),
|
||||
...(event.data.selectedAction ? { selectedAction: event.data.selectedAction } : {}),
|
||||
...(event.agentId ? { agentId: event.agentId } : {}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "subagent.started", (event) => {
|
||||
forwardNativeSubagentEvent(event);
|
||||
});
|
||||
@@ -531,10 +566,14 @@ function isAssistantMessageEvent(
|
||||
return event?.type === "assistant.message";
|
||||
}
|
||||
|
||||
function isRootSessionEvent(event: { agentId?: string }): boolean {
|
||||
return event.agentId === undefined;
|
||||
}
|
||||
|
||||
function isRootCompactionEvent(event: { agentId?: string }): boolean {
|
||||
// SDK session events include subagent compaction; only root compaction
|
||||
// affects the pooled root session's cleanup and reuse lifecycle.
|
||||
return event.agentId === undefined;
|
||||
return isRootSessionEvent(event);
|
||||
}
|
||||
|
||||
function joinReasoning(order: string[], reasoningById: Map<string, string>): string {
|
||||
|
||||
376
extensions/copilot/src/provider-bridge.test.ts
Normal file
376
extensions/copilot/src/provider-bridge.test.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
// Copilot tests cover BYOK provider mapping behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
COPILOT_BYOK_PROVIDER_ERROR,
|
||||
COPILOT_BYOK_ENDPOINT_POLICY_ERROR,
|
||||
COPILOT_BYOK_TRANSPORT_POLICY_ERROR,
|
||||
resolveCopilotProvider,
|
||||
supportsCopilotByokProviderShape,
|
||||
} from "./provider-bridge.js";
|
||||
|
||||
describe("resolveCopilotProvider", () => {
|
||||
it("keeps the subscription provider on the native Copilot auth path", () => {
|
||||
expect(
|
||||
resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "github-copilot",
|
||||
api: "github-copilot",
|
||||
id: "gpt-5",
|
||||
baseUrl: "https://ignored.example",
|
||||
},
|
||||
resolvedApiKey: "ignored",
|
||||
}),
|
||||
).toEqual({ mode: "github-copilot" });
|
||||
});
|
||||
|
||||
it("maps OpenAI Responses BYOK with a bearer token and stable limits", () => {
|
||||
const result = resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "local-proxy",
|
||||
api: "openai-responses",
|
||||
id: "proxy-model",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
authHeader: true,
|
||||
contextTokens: 12_000,
|
||||
maxTokens: 512,
|
||||
headers: { "X-Trace": "test" },
|
||||
},
|
||||
resolvedApiKey: "secret-key",
|
||||
authProfileId: "local-proxy:main",
|
||||
});
|
||||
|
||||
expect(result.mode).toBe("byok");
|
||||
expect(result.authProfileId).toBe("local-proxy:main");
|
||||
expect(result.authProfileVersion).toMatch(/^sha256:/);
|
||||
expect(result.provider).toEqual({
|
||||
type: "openai",
|
||||
wireApi: "responses",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
modelId: "proxy-model",
|
||||
wireModel: "proxy-model",
|
||||
bearerToken: "secret-key",
|
||||
headers: { "X-Trace": "test" },
|
||||
maxPromptTokens: 12_000,
|
||||
maxOutputTokens: 512,
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults custom BYOK providers without an api to OpenAI Responses", () => {
|
||||
const result = resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "custom-proxy",
|
||||
id: "proxy-model",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
},
|
||||
resolvedApiKey: "secret-key",
|
||||
});
|
||||
|
||||
expect(result.provider).toMatchObject({
|
||||
type: "openai",
|
||||
wireApi: "responses",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
});
|
||||
expect(supportsCopilotByokProviderShape({ baseUrl: "https://proxy.example/v1" })).toBe(true);
|
||||
});
|
||||
|
||||
it("changes the BYOK compatibility fingerprint when token limits change", () => {
|
||||
const base = {
|
||||
provider: "custom-proxy",
|
||||
api: "openai-responses",
|
||||
id: "proxy-model",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
};
|
||||
|
||||
const small = resolveCopilotProvider({
|
||||
model: { ...base, contextTokens: 8_000, maxTokens: 512 },
|
||||
resolvedApiKey: "secret-key",
|
||||
});
|
||||
const large = resolveCopilotProvider({
|
||||
model: { ...base, contextTokens: 16_000, maxTokens: 1024 },
|
||||
resolvedApiKey: "secret-key",
|
||||
});
|
||||
|
||||
expect(small.authProfileVersion).not.toBe(large.authProfileVersion);
|
||||
});
|
||||
|
||||
it("maps Anthropic and Ollama-compatible APIs", () => {
|
||||
expect(
|
||||
resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "anthropic-proxy",
|
||||
api: "anthropic-messages",
|
||||
id: "claude",
|
||||
baseUrl: "https://anthropic.example",
|
||||
},
|
||||
}).provider,
|
||||
).toMatchObject({ type: "anthropic", baseUrl: "https://anthropic.example" });
|
||||
|
||||
expect(
|
||||
resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "ollama-compatible",
|
||||
api: "ollama",
|
||||
id: "qwen",
|
||||
baseUrl: "https://ollama-compatible.example/v1",
|
||||
},
|
||||
}).provider,
|
||||
).toMatchObject({ type: "openai", wireApi: "completions" });
|
||||
});
|
||||
|
||||
it("normalizes Azure OpenAI Responses config for the Copilot SDK provider contract", () => {
|
||||
const result = resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "custom-azure",
|
||||
api: "azure-openai-responses",
|
||||
id: "deployment-gpt",
|
||||
baseUrl: "https://example.openai.azure.com/openai/v1",
|
||||
azureApiVersion: "2025-01-01-preview",
|
||||
},
|
||||
resolvedApiKey: "azure-key",
|
||||
});
|
||||
|
||||
expect(result.provider).toEqual({
|
||||
type: "azure",
|
||||
wireApi: "responses",
|
||||
baseUrl: "https://example.openai.azure.com",
|
||||
modelId: "deployment-gpt",
|
||||
wireModel: "deployment-gpt",
|
||||
apiKey: "azure-key",
|
||||
azure: { apiVersion: "2025-01-01-preview" },
|
||||
});
|
||||
expect(
|
||||
resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "custom-azure",
|
||||
api: "azure-openai-responses",
|
||||
id: "deployment-gpt",
|
||||
baseUrl: "https://example.cognitiveservices.azure.com/openai/v1",
|
||||
},
|
||||
}).provider,
|
||||
).toMatchObject({
|
||||
type: "azure",
|
||||
baseUrl: "https://example.cognitiveservices.azure.com",
|
||||
});
|
||||
expect(
|
||||
resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "custom-azure",
|
||||
api: "azure-openai-responses",
|
||||
id: "deployment",
|
||||
baseUrl: "https://example.cognitiveservices.azure.com/openai/v1",
|
||||
},
|
||||
}).provider,
|
||||
).not.toHaveProperty("azure");
|
||||
expect(
|
||||
resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "custom-azure",
|
||||
api: "azure-openai-responses",
|
||||
id: "deployment-gpt",
|
||||
baseUrl: "https://project.services.ai.azure.com/api/projects/demo/openai/v1",
|
||||
},
|
||||
resolvedApiKey: "azure-key",
|
||||
}).provider,
|
||||
).toEqual({
|
||||
type: "openai",
|
||||
wireApi: "responses",
|
||||
baseUrl: "https://project.services.ai.azure.com/api/projects/demo/openai/v1",
|
||||
modelId: "deployment-gpt",
|
||||
wireModel: "deployment-gpt",
|
||||
apiKey: "azure-key",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not forward local auth markers or null no-auth headers", () => {
|
||||
const result = resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "local-proxy",
|
||||
api: "openai-completions",
|
||||
id: "local-model",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
authHeader: true,
|
||||
headers: {
|
||||
Authorization: null,
|
||||
"X-Local": "true",
|
||||
},
|
||||
},
|
||||
resolvedApiKey: "custom-local",
|
||||
});
|
||||
|
||||
expect(result.provider).toEqual({
|
||||
type: "openai",
|
||||
wireApi: "completions",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
modelId: "local-model",
|
||||
wireModel: "local-model",
|
||||
headers: { "X-Local": "true" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not synthesize SDK apiKey auth when request auth already prepared headers", () => {
|
||||
const result = resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "custom-header-proxy",
|
||||
api: "openai-responses",
|
||||
id: "proxy-model",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
headers: { "x-api-key": "header-secret" },
|
||||
requestAuthMode: "header",
|
||||
},
|
||||
resolvedApiKey: "header-secret",
|
||||
});
|
||||
|
||||
expect(result.provider).toEqual({
|
||||
type: "openai",
|
||||
wireApi: "responses",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
modelId: "proxy-model",
|
||||
wireModel: "proxy-model",
|
||||
headers: { "x-api-key": "header-secret" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects request transport policy the SDK provider config cannot enforce", () => {
|
||||
for (const model of [
|
||||
{ requestProxy: { mode: "env-proxy" } },
|
||||
{ requestTls: { ca: "ca-pem" } },
|
||||
{ requestAllowPrivateNetwork: false },
|
||||
]) {
|
||||
expect(() =>
|
||||
resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "custom-proxy",
|
||||
api: "openai-responses",
|
||||
id: "proxy-model",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
...model,
|
||||
},
|
||||
}),
|
||||
).toThrow(COPILOT_BYOK_TRANSPORT_POLICY_ERROR);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects BYOK endpoints blocked by OpenClaw SSRF policy", () => {
|
||||
for (const baseUrl of [
|
||||
"file://public.example/v1",
|
||||
"ftp://public.example/v1",
|
||||
"http://proxy.example/v1",
|
||||
"https://user:pass@proxy.example/v1",
|
||||
"https://proxy.example/v1?api_key=secret",
|
||||
"https://proxy.example/v1?x-api-key=secret",
|
||||
"https://proxy.example/v1?x-auth-token=secret",
|
||||
"https://proxy.example/v1?password=secret",
|
||||
"https://proxy.example/v1?client%5Fse%E2%80%8Bcret=secret",
|
||||
"http://169.254.169.254/v1",
|
||||
"http://metadata.google.internal/v1",
|
||||
"http://localhost:11434/v1",
|
||||
]) {
|
||||
expect(() =>
|
||||
resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "custom-proxy",
|
||||
api: "openai-responses",
|
||||
id: "proxy-model",
|
||||
baseUrl,
|
||||
},
|
||||
}),
|
||||
).toThrow(COPILOT_BYOK_ENDPOINT_POLICY_ERROR);
|
||||
}
|
||||
});
|
||||
|
||||
it("advertises support only for representable BYOK provider shapes", () => {
|
||||
expect(
|
||||
supportsCopilotByokProviderShape({
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
supportsCopilotByokProviderShape({
|
||||
api: "azure-openai-responses",
|
||||
baseUrl: "https://example.openai.azure.com/openai/v1",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
supportsCopilotByokProviderShape({
|
||||
api: "azure-openai-responses",
|
||||
baseUrl: "https://project.services.ai.azure.com/api/projects/demo/openai/v1",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
supportsCopilotByokProviderShape({
|
||||
api: "azure-openai-responses",
|
||||
baseUrl: "https://project.services.ai.azure.com/api/projects/demo",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
supportsCopilotByokProviderShape({
|
||||
api: "google-generative-ai",
|
||||
baseUrl: "https://google.example",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
supportsCopilotByokProviderShape({
|
||||
api: "openai-responses",
|
||||
baseUrl: "file://public.example/v1",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
supportsCopilotByokProviderShape({
|
||||
api: "openai-responses",
|
||||
baseUrl: "http://proxy.example/v1",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
supportsCopilotByokProviderShape({
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://user:pass@proxy.example/v1",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
supportsCopilotByokProviderShape({
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://proxy.example/v1?api_key=secret",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
supportsCopilotByokProviderShape({
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://proxy.example/v1?x-api-key=secret",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(supportsCopilotByokProviderShape({ api: "openai-responses" })).toBe(false);
|
||||
expect(
|
||||
supportsCopilotByokProviderShape({
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
requestProxy: { mode: "env-proxy" },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects provider APIs the SDK adapter cannot represent", () => {
|
||||
expect(() =>
|
||||
resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "google",
|
||||
api: "google-generative-ai",
|
||||
id: "gemini",
|
||||
baseUrl: "https://google.example",
|
||||
},
|
||||
}),
|
||||
).toThrow(COPILOT_BYOK_PROVIDER_ERROR);
|
||||
});
|
||||
|
||||
it("requires an endpoint for non-subscription providers", () => {
|
||||
expect(() =>
|
||||
resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "custom",
|
||||
api: "openai-completions",
|
||||
id: "model",
|
||||
},
|
||||
}),
|
||||
).toThrow(COPILOT_BYOK_PROVIDER_ERROR);
|
||||
});
|
||||
});
|
||||
339
extensions/copilot/src/provider-bridge.ts
Normal file
339
extensions/copilot/src/provider-bridge.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
// Copilot plugin module implements BYOK provider mapping.
|
||||
import type { ProviderConfig } from "@github/copilot-sdk";
|
||||
import { isNonSecretApiKeyMarker } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { tokenFingerprint } from "./auth-bridge.js";
|
||||
|
||||
export const COPILOT_BYOK_PROVIDER_ERROR =
|
||||
"[copilot-attempt] BYOK requires an OpenAI-compatible or Anthropic model api and a non-empty baseUrl";
|
||||
export const COPILOT_BYOK_TRANSPORT_POLICY_ERROR =
|
||||
"[copilot-attempt] BYOK does not support OpenClaw provider request proxy, TLS, or private-network policy overrides";
|
||||
export const COPILOT_BYOK_ENDPOINT_POLICY_ERROR =
|
||||
"[copilot-attempt] BYOK endpoint is blocked by OpenClaw SSRF policy";
|
||||
|
||||
const CREDENTIAL_QUERY_PARAM_NAMES = new Set([
|
||||
"accesstoken",
|
||||
"appsecret",
|
||||
"auth",
|
||||
"authtoken",
|
||||
"apikey",
|
||||
"authorization",
|
||||
"clientsecret",
|
||||
"code",
|
||||
"credential",
|
||||
"hooktoken",
|
||||
"idtoken",
|
||||
"jwt",
|
||||
"key",
|
||||
"pass",
|
||||
"passwd",
|
||||
"password",
|
||||
"privatekey",
|
||||
"refreshtoken",
|
||||
"secret",
|
||||
"session",
|
||||
"sig",
|
||||
"signature",
|
||||
"token",
|
||||
"xapikey",
|
||||
"xaccesstoken",
|
||||
"xamzsecuritytoken",
|
||||
"xamzsignature",
|
||||
"xauthtoken",
|
||||
]);
|
||||
const QUERY_PARAM_NAME_SEPARATOR_RE = /[\p{C}\p{Z}\u115F\u1160\u3164\uFFA0+]/gu;
|
||||
|
||||
export type CopilotProviderMode = "github-copilot" | "byok";
|
||||
|
||||
export type CopilotModelProviderInput = {
|
||||
api?: string;
|
||||
id: string;
|
||||
provider: string;
|
||||
baseUrl?: string;
|
||||
azureApiVersion?: string;
|
||||
headers?: Record<string, string | null | undefined>;
|
||||
authHeader?: boolean;
|
||||
requestAuthMode?: string;
|
||||
requestProxy?: unknown;
|
||||
requestTls?: unknown;
|
||||
requestAllowPrivateNetwork?: unknown;
|
||||
contextTokens?: number;
|
||||
contextWindow?: number;
|
||||
maxTokens?: number;
|
||||
};
|
||||
|
||||
export type ResolvedCopilotProvider = {
|
||||
mode: CopilotProviderMode;
|
||||
provider?: ProviderConfig;
|
||||
authProfileId?: string;
|
||||
authProfileVersion?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps OpenClaw's prepared model facts into the Copilot SDK's session-level
|
||||
* provider contract. The SDK owns the wire request; OpenClaw only supplies
|
||||
* the already-resolved endpoint, model, headers, and credential.
|
||||
*/
|
||||
export function resolveCopilotProvider(params: {
|
||||
model: CopilotModelProviderInput;
|
||||
resolvedApiKey?: string;
|
||||
authProfileId?: string;
|
||||
}): ResolvedCopilotProvider {
|
||||
if (params.model.provider.trim().toLowerCase() === "github-copilot") {
|
||||
return { mode: "github-copilot" };
|
||||
}
|
||||
|
||||
const baseUrl = readString(params.model.baseUrl);
|
||||
if (!baseUrl) {
|
||||
throw new Error(COPILOT_BYOK_PROVIDER_ERROR);
|
||||
}
|
||||
assertByokEndpointAllowed(baseUrl);
|
||||
if (hasUnsupportedTransportPolicy(params.model)) {
|
||||
throw new Error(COPILOT_BYOK_TRANSPORT_POLICY_ERROR);
|
||||
}
|
||||
|
||||
const api = readString(params.model.api)?.toLowerCase() ?? "openai-responses";
|
||||
const provider = resolveProviderType(api, baseUrl, params.model.azureApiVersion);
|
||||
const resolvedApiKey = resolveProviderCredential(params.resolvedApiKey);
|
||||
const headers = resolveProviderHeaders(params.model.headers);
|
||||
const requestAuthMode = readString(params.model.requestAuthMode)?.toLowerCase();
|
||||
const usePreparedRequestAuth =
|
||||
requestAuthMode !== undefined && requestAuthMode !== "provider-default";
|
||||
const providerConfig: ProviderConfig = {
|
||||
type: provider.type,
|
||||
...(provider.wireApi ? { wireApi: provider.wireApi } : {}),
|
||||
baseUrl: provider.baseUrl,
|
||||
modelId: params.model.id,
|
||||
wireModel: params.model.id,
|
||||
...(resolvedApiKey && !usePreparedRequestAuth
|
||||
? params.model.authHeader
|
||||
? { bearerToken: resolvedApiKey }
|
||||
: { apiKey: resolvedApiKey }
|
||||
: {}),
|
||||
...(headers ? { headers } : {}),
|
||||
...(provider.azure ? { azure: provider.azure } : {}),
|
||||
...((params.model.contextTokens ?? params.model.contextWindow)
|
||||
? { maxPromptTokens: params.model.contextTokens ?? params.model.contextWindow }
|
||||
: {}),
|
||||
...(params.model.maxTokens ? { maxOutputTokens: params.model.maxTokens } : {}),
|
||||
};
|
||||
const authProfileId = params.authProfileId?.trim() || `byok:${params.model.provider}`;
|
||||
const authProfileVersion = tokenFingerprint(
|
||||
stableSerialize({
|
||||
api,
|
||||
baseUrl: provider.baseUrl,
|
||||
azureApiVersion: provider.azure?.apiVersion,
|
||||
headers,
|
||||
authHeader: params.model.authHeader,
|
||||
requestAuthMode: params.model.requestAuthMode,
|
||||
apiKey: resolvedApiKey,
|
||||
modelId: params.model.id,
|
||||
maxPromptTokens: params.model.contextTokens ?? params.model.contextWindow,
|
||||
maxOutputTokens: params.model.maxTokens,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
mode: "byok",
|
||||
provider: providerConfig,
|
||||
authProfileId,
|
||||
authProfileVersion,
|
||||
};
|
||||
}
|
||||
|
||||
export function isCopilotByokUnsupportedProviderError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
(error.message === COPILOT_BYOK_PROVIDER_ERROR ||
|
||||
error.message === COPILOT_BYOK_TRANSPORT_POLICY_ERROR ||
|
||||
error.message === COPILOT_BYOK_ENDPOINT_POLICY_ERROR)
|
||||
);
|
||||
}
|
||||
|
||||
export function supportsCopilotByokProviderShape(
|
||||
model: Pick<
|
||||
CopilotModelProviderInput,
|
||||
"api" | "baseUrl" | "requestProxy" | "requestTls" | "requestAllowPrivateNetwork"
|
||||
>,
|
||||
): boolean {
|
||||
if (!readString(model.baseUrl) || hasUnsupportedTransportPolicy(model)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
resolveProviderType(
|
||||
readString(model.api)?.toLowerCase() ?? "openai-responses",
|
||||
readString(model.baseUrl)!,
|
||||
undefined,
|
||||
);
|
||||
assertByokEndpointHostAllowed(readString(model.baseUrl)!);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function hasUnsupportedTransportPolicy(
|
||||
model: Pick<
|
||||
CopilotModelProviderInput,
|
||||
"requestProxy" | "requestTls" | "requestAllowPrivateNetwork"
|
||||
>,
|
||||
): boolean {
|
||||
return (
|
||||
model.requestProxy !== undefined ||
|
||||
model.requestTls !== undefined ||
|
||||
model.requestAllowPrivateNetwork !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
function assertByokEndpointHostAllowed(baseUrl: string): void {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(baseUrl);
|
||||
} catch {
|
||||
throw new Error(COPILOT_BYOK_PROVIDER_ERROR);
|
||||
}
|
||||
if (url.protocol !== "https:") {
|
||||
throw new Error(COPILOT_BYOK_ENDPOINT_POLICY_ERROR);
|
||||
}
|
||||
if (url.username || url.password) {
|
||||
throw new Error(COPILOT_BYOK_ENDPOINT_POLICY_ERROR);
|
||||
}
|
||||
for (const key of url.searchParams.keys()) {
|
||||
if (CREDENTIAL_QUERY_PARAM_NAMES.has(normalizeCredentialQueryParamName(key))) {
|
||||
throw new Error(COPILOT_BYOK_ENDPOINT_POLICY_ERROR);
|
||||
}
|
||||
}
|
||||
const hostname = url.hostname.toLowerCase().replace(/\.+$/, "");
|
||||
if (isBlockedHostnameOrIp(hostname)) {
|
||||
throw new Error(COPILOT_BYOK_ENDPOINT_POLICY_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCredentialQueryParamName(name: string): string {
|
||||
const stripped = name.replace(QUERY_PARAM_NAME_SEPARATOR_RE, "");
|
||||
try {
|
||||
return decodeURIComponent(stripped)
|
||||
.replace(QUERY_PARAM_NAME_SEPARATOR_RE, "")
|
||||
.toLowerCase()
|
||||
.replace(/[-_]/g, "");
|
||||
} catch {
|
||||
return stripped.toLowerCase().replace(/[-_]/g, "");
|
||||
}
|
||||
}
|
||||
|
||||
function assertByokEndpointAllowed(baseUrl: string): void {
|
||||
assertByokEndpointHostAllowed(baseUrl);
|
||||
}
|
||||
|
||||
function resolveProviderType(
|
||||
api: string | undefined,
|
||||
baseUrl: string,
|
||||
azureApiVersion: string | undefined,
|
||||
): {
|
||||
type: NonNullable<ProviderConfig["type"]>;
|
||||
wireApi?: NonNullable<ProviderConfig["wireApi"]>;
|
||||
baseUrl: string;
|
||||
azure?: NonNullable<ProviderConfig["azure"]>;
|
||||
} {
|
||||
switch (api) {
|
||||
case "anthropic-messages":
|
||||
return { type: "anthropic", baseUrl };
|
||||
case "azure-openai-responses":
|
||||
return resolveAzureProviderType(baseUrl, azureApiVersion);
|
||||
case "openai-responses":
|
||||
return { type: "openai", wireApi: "responses", baseUrl };
|
||||
case "openai-completions":
|
||||
case "ollama":
|
||||
return { type: "openai", wireApi: "completions", baseUrl };
|
||||
default:
|
||||
throw new Error(COPILOT_BYOK_PROVIDER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAzureProviderType(
|
||||
baseUrl: string,
|
||||
apiVersion: string | undefined,
|
||||
): {
|
||||
type: NonNullable<ProviderConfig["type"]>;
|
||||
wireApi: NonNullable<ProviderConfig["wireApi"]>;
|
||||
baseUrl: string;
|
||||
azure?: NonNullable<ProviderConfig["azure"]>;
|
||||
} {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(baseUrl);
|
||||
} catch {
|
||||
throw new Error(COPILOT_BYOK_PROVIDER_ERROR);
|
||||
}
|
||||
if (isOpenAICompatibleAzureResponsesBaseUrl(url)) {
|
||||
return { type: "openai", wireApi: "responses", baseUrl };
|
||||
}
|
||||
if (!isTraditionalAzureOpenAIHost(url.hostname)) {
|
||||
throw new Error(COPILOT_BYOK_PROVIDER_ERROR);
|
||||
}
|
||||
url.pathname = "";
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
const resolvedApiVersion = readString(apiVersion);
|
||||
return {
|
||||
type: "azure",
|
||||
wireApi: "responses",
|
||||
baseUrl: url.toString().replace(/\/+$/, ""),
|
||||
...(resolvedApiVersion ? { azure: { apiVersion: resolvedApiVersion } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function isTraditionalAzureOpenAIHost(hostname: string): boolean {
|
||||
return (
|
||||
hostname.endsWith(".openai.azure.com") || hostname.endsWith(".cognitiveservices.azure.com")
|
||||
);
|
||||
}
|
||||
|
||||
function isOpenAICompatibleAzureResponsesBaseUrl(url: URL): boolean {
|
||||
if (isTraditionalAzureOpenAIHost(url.hostname)) {
|
||||
return false;
|
||||
}
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
const isFoundryHost =
|
||||
hostname.endsWith(".services.ai.azure.com") ||
|
||||
hostname.endsWith(".api.cognitive.microsoft.com");
|
||||
if (!isFoundryHost) {
|
||||
return false;
|
||||
}
|
||||
const normalizedPath = url.pathname.replace(/\/+$/, "");
|
||||
return normalizedPath === "/openai/v1" || normalizedPath.endsWith("/openai/v1");
|
||||
}
|
||||
|
||||
function stableSerialize(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map(stableSerialize).join(",")}]`;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return `{${Object.entries(value as Record<string, unknown>)
|
||||
.toSorted(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, entry]) => `${JSON.stringify(key)}:${stableSerialize(entry)}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value) ?? "null";
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function resolveProviderCredential(value: string | undefined): string | undefined {
|
||||
const credential = readString(value);
|
||||
return credential && !isNonSecretApiKeyMarker(credential) ? credential : undefined;
|
||||
}
|
||||
|
||||
function resolveProviderHeaders(
|
||||
headers: Record<string, string | null | undefined> | undefined,
|
||||
): Record<string, string> | undefined {
|
||||
if (!headers) {
|
||||
return undefined;
|
||||
}
|
||||
const resolved = Object.fromEntries(
|
||||
Object.entries(headers).filter(([, value]) => typeof value === "string"),
|
||||
) as Record<string, string>;
|
||||
return Object.keys(resolved).length > 0 ? resolved : undefined;
|
||||
}
|
||||
@@ -14,7 +14,7 @@ const POOL_DISPOSED_MESSAGE = "[copilot-pool] pool disposed";
|
||||
export interface PoolKey {
|
||||
readonly agentId: string;
|
||||
readonly copilotHome: string;
|
||||
readonly authMode: "useLoggedInUser" | "gitHubToken";
|
||||
readonly authMode: "useLoggedInUser" | "gitHubToken" | "byok";
|
||||
readonly authProfileId?: string;
|
||||
readonly authProfileVersion?: string;
|
||||
}
|
||||
|
||||
@@ -107,6 +107,29 @@ describe("createCopilotToolBridge", () => {
|
||||
expect(createOpenClawCodingTools).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("allows vetted BYOK providers to expose model tools", async () => {
|
||||
const sourceTools = [makeTool()];
|
||||
const createOpenClawCodingTools = vi.fn(async () => sourceTools);
|
||||
|
||||
const result = await createCopilotToolBridge({
|
||||
agentId: "agent-1",
|
||||
allowModelTools: true,
|
||||
createOpenClawCodingTools,
|
||||
modelId: "gpt-test",
|
||||
modelProvider: "custom-openai",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
expect(createOpenClawCodingTools).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
modelId: "gpt-test",
|
||||
modelProvider: "custom-openai",
|
||||
}),
|
||||
);
|
||||
expect(result.sourceTools).toEqual(sourceTools);
|
||||
expect(result.sdkTools.map((tool) => tool.name)).toEqual(["tool-a"]);
|
||||
});
|
||||
|
||||
it("forwards supported fields to injected createOpenClawCodingTools", async () => {
|
||||
const controller = new AbortController();
|
||||
const createOpenClawCodingTools = vi.fn(async () => [makeTool()]);
|
||||
|
||||
@@ -69,6 +69,7 @@ export type CopilotToolCompletion = {
|
||||
};
|
||||
|
||||
export interface CopilotToolBridgeInput {
|
||||
allowModelTools?: boolean;
|
||||
modelProvider: string;
|
||||
modelId: string;
|
||||
agentId: string;
|
||||
@@ -151,7 +152,7 @@ export function supportsModelTools(modelProvider: string): boolean {
|
||||
export async function createCopilotToolBridge(
|
||||
input: CopilotToolBridgeInput,
|
||||
): Promise<CopilotToolBridge> {
|
||||
if (!supportsModelTools(input.modelProvider)) {
|
||||
if (!input.allowModelTools && !supportsModelTools(input.modelProvider)) {
|
||||
return { sdkTools: [], sourceTools: [] };
|
||||
}
|
||||
|
||||
|
||||
@@ -36,21 +36,49 @@ type DuckDuckGoResult = {
|
||||
};
|
||||
|
||||
function decodeHtmlEntities(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'")
|
||||
.replace(///g, "/")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/–/g, "-")
|
||||
.replace(/—/g, "--")
|
||||
.replace(/…/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 === "<") {
|
||||
return "<";
|
||||
}
|
||||
if (normalized === ">") {
|
||||
return ">";
|
||||
}
|
||||
if (normalized === """) {
|
||||
return '"';
|
||||
}
|
||||
if (normalized === "'" || normalized === "'" || normalized === "'") {
|
||||
return "'";
|
||||
}
|
||||
if (normalized === "/") {
|
||||
return "/";
|
||||
}
|
||||
if (normalized === " ") {
|
||||
return " ";
|
||||
}
|
||||
if (normalized === "–") {
|
||||
return "-";
|
||||
}
|
||||
if (normalized === "—") {
|
||||
return "--";
|
||||
}
|
||||
if (normalized === "…") {
|
||||
return "...";
|
||||
}
|
||||
if (normalized === "&") {
|
||||
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 {
|
||||
|
||||
@@ -186,6 +186,17 @@ describe("duckduckgo web search provider", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not double-decode escaped entities (decodes & last)", () => {
|
||||
// A result whose text literally shows "<" arrives double-encoded as
|
||||
// "&lt;". Decoding & first would re-decode it into "<", corrupting
|
||||
// the snippet; & must be decoded last.
|
||||
expect(ddgClientTesting.decodeHtmlEntities("How to escape &lt; in HTML")).toBe(
|
||||
"How to escape < in HTML",
|
||||
);
|
||||
expect(ddgClientTesting.decodeHtmlEntities("a&#39;b")).toBe("a'b");
|
||||
expect(ddgClientTesting.decodeHtmlEntities("a&amp;b")).toBe("a&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">
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -37,6 +37,15 @@ describe("stripHtmlFromTeamsMessage", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not double-decode escaped entities (decodes & last)", () => {
|
||||
// Graph encodes literally-typed entity text by escaping its '&' to '&'.
|
||||
// Decoding '&' first would re-decode the now-bare '<'/'>' into
|
||||
// angle brackets, corrupting the user's literal text.
|
||||
expect(stripHtmlFromTeamsMessage("The token is &lt;APIKEY&gt;")).toBe(
|
||||
"The token is <APIKEY>",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes multiple whitespace to single space", () => {
|
||||
expect(stripHtmlFromTeamsMessage("hello world")).toBe("hello world");
|
||||
});
|
||||
|
||||
@@ -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. & must be decoded LAST to prevent
|
||||
// double-decoding (e.g. &lt; → < not <), matching decodeHtmlEntities
|
||||
// in inbound.ts.
|
||||
text = text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /g, " ");
|
||||
.replace(/ /g, " ")
|
||||
.replace(/&/g, "&");
|
||||
// Normalize whitespace.
|
||||
return text.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
27
extensions/slack/src/truncate.test.ts
Normal file
27
extensions/slack/src/truncate.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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)}…`;
|
||||
}
|
||||
|
||||
@@ -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>"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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>";
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)." },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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(
|
||||
(
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -73,6 +73,7 @@ function cleanedLockForPath(lockPath: string): SessionLockInspection {
|
||||
ageMs: 1_000,
|
||||
stale: true,
|
||||
staleReasons: ["dead-pid"],
|
||||
removable: true,
|
||||
removed: true,
|
||||
};
|
||||
}
|
||||
|
||||
126
src/agents/media-generation-task-status-shared.test.ts
Normal file
126
src/agents/media-generation-task-status-shared.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user