Merge branch 'main' into meow/diagnostic-liveness-stability

This commit is contained in:
Val Alexander
2026-05-11 06:51:45 -05:00
committed by GitHub
1803 changed files with 46721 additions and 18204 deletions
+77 -13
View File
@@ -127,6 +127,7 @@ Read the JSON summary. Useful fields:
- `provider`: should be `blacksmith-testbox`
- `leaseId`: `tbx_...`
- `syncDelegated`: should be `true`
- `commandPhases`: populated when the command prints `CRABBOX_PHASE:<name>`
- `commandMs` / `totalMs`
- `exitCode`
@@ -138,6 +139,62 @@ unclear:
blacksmith testbox list
```
## Observability Flags
Use these on debugging runs before inventing ad hoc logging:
- `--preflight`: prints run context, workspace mode, SSH target, remote user/cwd,
sudo/apt, Node, pnpm, Docker, and bubblewrap. On `blacksmith-testbox`, this
prints a delegated-unsupported note because the workflow owns setup.
- `CRABBOX_ENV_ALLOW=NAME,...`: forwards only listed local env vars for direct
providers and prints `set len=N secret=true` style summaries. On
`blacksmith-testbox`, env forwarding is unsupported; put secrets in the
Testbox workflow instead.
- `--env-from-profile <file>` plus `--allow-env NAME`: loads simple
`export NAME=value` / `NAME=value` lines from a local profile without
executing it, then forwards only allowlisted names. `--allow-env` is
repeatable and comma-separated. Profile values override ambient allowlisted
env values for that run.
- `--script <file>` / `--script-stdin`: upload a local script into
`.crabbox/scripts/` and execute it on the remote box. Shebang scripts execute
directly; scripts without a shebang run through `bash`. Arguments after `--`
become script args.
- `--fresh-pr owner/repo#123|URL|number`: skip dirty local sync and create a
fresh remote checkout of the GitHub PR. Bare numbers use the current repo's
GitHub origin. Add `--apply-local-patch` only when the current local
`git diff --binary HEAD` should be applied on top of that PR checkout.
- `--capture-stdout <path>` / `--capture-stderr <path>`: write remote streams to
local files and keep binary/noisy output out of retained logs. Parent
directories must already exist. These are direct-provider only.
- `--capture-on-fail`: on non-zero direct-provider exits, downloads
`.crabbox/captures/*.tar.gz` with `test-results`, `playwright-report`,
`coverage`, JUnit XML, and nearby logs. Treat as secret-bearing until reviewed.
- `--timing-json`: final machine-readable timing. Add
`echo CRABBOX_PHASE:install`, `CRABBOX_PHASE:test`, etc. in long shell
commands; direct providers and Blacksmith Testbox both report them as
`commandPhases`.
Live-provider debug template for direct AWS/Hetzner leases:
```sh
mkdir -p .crabbox/logs
pnpm crabbox:run -- --provider aws \
--preflight \
--env-from-profile ~/.profile \
--allow-env OPENAI_API_KEY,OPENAI_BASE_URL \
--timing-json \
--capture-stdout .crabbox/logs/live-provider.stdout.log \
--capture-stderr .crabbox/logs/live-provider.stderr.log \
--capture-on-fail \
--shell -- \
"echo CRABBOX_PHASE:install; pnpm install --frozen-lockfile; echo CRABBOX_PHASE:test; pnpm test:live"
```
Do not pass `--capture-*`, `--download`, `--checksum`, `--force-sync-large`, or
`--sync-only` to delegated providers. Also do not pass `--script*` or
`--fresh-pr` there. Crabbox rejects these because the provider owns sync or
command transport.
## Efficient Bug E2E Verification
Use the smallest Crabbox lane that proves the reported user path, not just the
@@ -179,6 +236,11 @@ Efficient flow:
Keep it efficient:
- Reuse existing E2E scripts and helper assertions before writing ad hoc shell.
- Use `--script <file>` or `--script-stdin` for multi-line E2E commands instead
of quote-heavy `--shell` strings on direct SSH providers.
- Use `--fresh-pr <pr>` when validating an upstream PR in isolation from the
local dirty tree. Add `--apply-local-patch` only when testing a local fixup on
top of that PR.
- Use one-shot Crabbox for a single proof; use a reusable Testbox only when
several commands must share built images, installed packages, or live state.
- Prefer `OPENCLAW_CURRENT_PACKAGE_TGZ` with Docker/package lanes when testing a
@@ -285,7 +347,9 @@ Common Crabbox-only failures:
- Slug/claim confusion: use the raw `tbx_...` id, or run one-shot without
`--id`.
- Sync/timing bug: add `--debug --timing-json`; capture the final JSON and the
printed Actions URL.
printed Actions URL. Large sync warnings now include top source directories
by file count and a hint to update `.crabboxignore` / `sync.exclude`; inspect
those before reaching for `--force-sync-large`.
- Cleanup uncertainty: run `blacksmith testbox list` and stop only boxes you
created.
- Testbox queued/capacity pressure: do not convert a broad changed gate or full
@@ -294,18 +358,19 @@ Common Crabbox-only failures:
report the capacity blocker.
If Crabbox cannot dispatch, sync, attach, or stop but Blacksmith itself works,
use direct Blacksmith from the repo root:
first try the same command through the repo wrapper with `--debug` and
`--timing-json`:
```sh
blacksmith testbox warmup ci-check-testbox.yml --ref main --idle-timeout 90
blacksmith testbox run --id <tbx_id> "env CI=1 NODE_OPTIONS=--max-old-space-size=4096 OPENCLAW_TEST_PROJECTS_PARALLEL=6 OPENCLAW_VITEST_MAX_WORKERS=1 OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS=900000 pnpm test:changed"
blacksmith testbox stop --id <tbx_id>
pnpm crabbox:run -- --provider blacksmith-testbox --debug --timing-json -- \
CI=1 NODE_OPTIONS=--max-old-space-size=4096 OPENCLAW_TEST_PROJECTS_PARALLEL=6 OPENCLAW_VITEST_MAX_WORKERS=1 OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS=900000 pnpm test:changed
```
Direct full suite:
Full suite:
```sh
blacksmith testbox run --id <tbx_id> "env CI=1 NODE_OPTIONS=--max-old-space-size=4096 OPENCLAW_TEST_PROJECTS_PARALLEL=6 OPENCLAW_VITEST_MAX_WORKERS=1 OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS=900000 pnpm test"
pnpm crabbox:run -- --provider blacksmith-testbox --debug --timing-json -- \
CI=1 NODE_OPTIONS=--max-old-space-size=4096 OPENCLAW_TEST_PROJECTS_PARALLEL=6 OPENCLAW_VITEST_MAX_WORKERS=1 OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS=900000 pnpm test
```
Auth fallback, only when `blacksmith` says auth is missing:
@@ -340,16 +405,15 @@ The hydration workflow owns checkout, Node/pnpm setup, dependency install,
secrets, ready marker, and keepalive. Crabbox owns dispatch, sync, SSH command
execution, timing, logs/results, and cleanup.
Minimal direct Blacksmith fallback, from repo root:
Minimal Blacksmith-backed Crabbox run, from repo root:
```sh
blacksmith testbox warmup ci-check-testbox.yml --ref main --idle-timeout 90
blacksmith testbox run --id <tbx_id> "env CI=1 NODE_OPTIONS=--max-old-space-size=4096 OPENCLAW_TEST_PROJECTS_PARALLEL=6 OPENCLAW_VITEST_MAX_WORKERS=1 pnpm test:changed"
blacksmith testbox stop --id <tbx_id>
pnpm crabbox:run -- --provider blacksmith-testbox --timing-json -- \
CI=1 NODE_OPTIONS=--max-old-space-size=4096 OPENCLAW_TEST_PROJECTS_PARALLEL=6 OPENCLAW_VITEST_MAX_WORKERS=1 pnpm test:changed
```
Use direct Blacksmith only when Crabbox is the broken layer and Blacksmith
itself still works. Prefer direct `blacksmith testbox list` for cleanup
Use direct Blacksmith only when Crabbox is the broken layer and you are
isolating a Crabbox bug. Prefer direct `blacksmith testbox list` for cleanup
diagnostics, not as a reusable work queue.
Important Blacksmith footguns:
+113
View File
@@ -0,0 +1,113 @@
---
name: openclaw-debugging
description: Debug OpenClaw model, provider, tool-surface, code-mode, streaming, and live/Crabbox behavior by choosing the right logs, probes, and proof path before changing code.
---
# OpenClaw Debugging
Use this skill when OpenClaw behavior differs between local tests, live models,
providers, code mode, Tool Search, Crabbox, or CI, and the next move should be a
debug signal rather than a guess.
## Read First
- `docs/logging.md` for log files, `openclaw logs`, and targeted debug flags.
- `docs/reference/test.md` for local test commands.
- `docs/reference/code-mode.md` for code-mode exec/wait and tool catalog rules.
- Use `$openclaw-testing` for choosing test lanes.
- Use `$crabbox` for broad, Docker, package, Linux, live-key, or CI-parity proof.
## Default Loop
1. State the suspected boundary: config, tool construction, provider payload,
fetch, stream/SSE, transcript replay, worker/runtime, package/dist, or CI.
2. Add or enable the narrowest signal that proves that boundary.
3. Reproduce with the same provider/model/config. Do not randomly switch models
unless the model itself is the variable being tested.
4. Compare configured state with actual run activation.
5. Patch the root cause.
6. Rerun the exact failing probe, then broaden only if the contract requires it.
## Model Transport Logs
Use targeted env flags instead of global debug when the model request shape or
stream timing matters:
```bash
OPENCLAW_DEBUG_MODEL_TRANSPORT=1 openclaw gateway
OPENCLAW_DEBUG_MODEL_PAYLOAD=tools OPENCLAW_DEBUG_SSE=events openclaw gateway
OPENCLAW_DEBUG_MODEL_PAYLOAD=full-redacted OPENCLAW_DEBUG_SSE=peek openclaw gateway
```
Useful flags:
- `OPENCLAW_DEBUG_MODEL_TRANSPORT=1`: request start, fetch response, SDK
headers, first SSE event, stream done, and transport errors at `info`.
- `OPENCLAW_DEBUG_MODEL_PAYLOAD=summary`: bounded payload summary.
- `OPENCLAW_DEBUG_MODEL_PAYLOAD=tools`: all model-facing tool names.
- `OPENCLAW_DEBUG_MODEL_PAYLOAD=full-redacted`: capped, redacted JSON payload.
Use only while debugging; prompts/message text may still appear.
- `OPENCLAW_DEBUG_SSE=events`: first-event and stream-completion timing.
- `OPENCLAW_DEBUG_SSE=peek`: first five redacted SSE events.
- `OPENCLAW_DEBUG_CODE_MODE=1`: code-mode tool-surface diagnostics.
Watch logs with:
```bash
openclaw logs --follow
```
## Common Boundaries
- **Config vs activation:** config can be enabled while the run disables tools,
is raw, has an empty allowlist, or lacks model tool support. Check the actual
visible tools before enforcing provider payload invariants.
- **Tool surface:** inspect final model-visible tool names, not only the tool
registry or config. Code mode means exactly `exec` and `wait` only after it
actually activates.
- **Provider payload:** log fields, model id, service tier, reasoning, input
size, metadata keys, prompt-cache key presence, and tool names before SDK
call.
- **Fetch vs SSE:** fetch response proves HTTP headers arrived; first SSE event
proves provider body progress. A gap here is a stream/body/provider issue, not
tool execution.
- **Worker/dist:** run `pnpm build` when touching workers, dynamic imports,
package exports, lazy runtime boundaries, or published paths.
- **Live keys:** check local `~/.profile` for key presence/length before saying
live proof is blocked. Never print secrets.
## Code Pointers
- Model payload + Responses stream:
`src/agents/openai-transport-stream.ts`
- Guarded fetch/timing:
`src/agents/provider-transport-fetch.ts`
- OpenAI/Codex provider wrappers:
`src/agents/pi-embedded-runner/openai-stream-wrappers.ts`
- Tool construction, Tool Search, code-mode activation:
`src/agents/pi-embedded-runner/run/attempt.ts`
- Code-mode runtime and worker:
`src/agents/code-mode.ts`
`src/agents/code-mode.worker.ts`
- Tool Search catalog:
`src/agents/tool-search.ts`
## Proof Choice
- Single helper/payload bug: local targeted Vitest.
- Docs/logging-only: `pnpm check:docs` and `git diff --check`.
- Worker/dist/lazy import/package surface: targeted tests plus `pnpm build`.
- Live provider/model behavior: same provider/model with debug flags and a real
key if available.
- Docker/package/Linux/CI-parity: `$crabbox`.
- CI failure: exact SHA, relevant job only, logs only after failure/completion.
## Output Habit
Report:
- boundary tested
- exact command/env shape, redacted
- observed signal, such as tool names or first SSE event timing
- fix location
- narrow proof and any remaining risk
@@ -0,0 +1,4 @@
interface:
display_name: "OpenClaw Debugging"
short_description: "Debug model, tool, stream, and live behavior"
default_prompt: "Use $openclaw-debugging to identify the right OpenClaw debug boundary, turn on targeted logs, and choose the narrowest local or Crabbox proof."
@@ -92,11 +92,11 @@ barrels, package-boundary tests, or extension suites.
- runtime capture should be quiet and config-tolerant.
- command output should include wall time, exit code, and peak RSS when
available.
4. For broad or package-heavy plugin proof, use Blacksmith Testbox by default on
maintainer machines. Warm once and reuse the same box:
- `blacksmith testbox warmup ci-check-testbox.yml --ref main --idle-timeout 90`
- `blacksmith testbox run --id <ID> "OPENCLAW_TESTBOX=1 pnpm test:extensions:batch <ids>"`
- stop the box when done.
4. For broad or package-heavy plugin proof, use Crabbox-backed Blacksmith
Testbox by default on maintainer machines:
- `pnpm crabbox:run -- --provider blacksmith-testbox --timing-json -- OPENCLAW_TESTBOX=1 pnpm test:extensions:batch <ids>`
- add `--keep`/`--id <id-or-slug>` only when several commands must share one
warmed box; stop it with `pnpm crabbox:stop -- <id-or-slug>`.
5. If plugin performance is package-artifact sensitive, switch to
`openclaw-pre-release-plugin-testing` and Package Acceptance rather than
trusting source-only timing.
+5 -8
View File
@@ -36,14 +36,11 @@ Prove the touched surface first. Do not reflexively run the whole suite.
- Prefer GitHub Actions for release/Docker proof when the workflow already has the prepared image and secrets.
- Use `scripts/committer "<msg>" <paths...>` when committing; stage only your files.
- If deps are missing, run `pnpm install`, retry once, then report the first actionable error.
- For Blacksmith Testbox proof, reuse only an id warmed and claimed in this
operator session. `blacksmith testbox list` is diagnostics only; a listed id
can have a local key and still carry stale rsync state from another lane.
After warmup, run `pnpm testbox:claim --id <id>`, then prefer
`pnpm testbox:run --id <id> -- "<command>"` for OpenClaw gates so stale
org-visible ids fail fast before syncing. Claims older than 12 hours are
stale unless `OPENCLAW_TESTBOX_CLAIM_TTL_MINUTES` is explicitly set for long
work.
- For Blacksmith Testbox proof, use Crabbox first. `pnpm crabbox:run -- --provider
blacksmith-testbox --timing-json -- <command...>` warms, claims, syncs, runs,
reports, and cleans up one-shot boxes. Reuse only an id/slug created in this
operator session; `blacksmith testbox list` is diagnostics only, not a shared
work queue.
## Local Test Shortcuts
@@ -0,0 +1,93 @@
# Mantis Telegram Desktop Proof Agent
You are Mantis running native Telegram Desktop visual proof for an OpenClaw PR.
Goal: inspect the pull request, decide the best Telegram-visible behavior to
prove, run before/after native Telegram Desktop sessions, iterate until the GIFs
are visually good, and leave a Mantis evidence manifest for the workflow to
publish.
Hard limits:
- Do not post GitHub comments or reviews. The workflow publishes the manifest.
- Do not commit, push, label, merge, or edit PR metadata.
- Do not print secrets, credential payloads, Telegram profile data, TDLib data,
or raw session archives.
- Do not use fixed `/status` proof unless it genuinely proves the PR.
- Do not finish with tiny, cropped-wrong, off-bottom, or sidebar-heavy GIFs.
- Do not invent a generic proof. The proof must match the PR behavior.
Inputs are provided as environment variables:
- `MANTIS_PR_NUMBER`
- `BASELINE_REF`
- `BASELINE_SHA`
- `CANDIDATE_REF`
- `CANDIDATE_SHA`
- `MANTIS_OUTPUT_DIR`
- `MANTIS_INSTRUCTIONS`
- `CRABBOX_PROVIDER`
- `OPENCLAW_TELEGRAM_USER_PROOF_CMD`
- optional `CRABBOX_LEASE_ID`
Required workflow:
1. Read `.agents/skills/telegram-crabbox-e2e-proof/SKILL.md`.
2. Inspect the PR with `gh pr view "$MANTIS_PR_NUMBER"` and
`gh pr diff "$MANTIS_PR_NUMBER"` when `MANTIS_PR_NUMBER` is set. If the run
came from workflow dispatch without a PR number, inspect
`BASELINE_SHA..CANDIDATE_SHA`.
3. Decide what Telegram message, mock model response, command, callback, button,
media, or sequence best proves the PR. Use `MANTIS_INSTRUCTIONS` as extra
maintainer guidance, not as a replacement for reading the PR.
4. Create detached worktrees under
`.artifacts/qa-e2e/mantis/telegram-desktop-proof-worktrees/baseline` and
`.artifacts/qa-e2e/mantis/telegram-desktop-proof-worktrees/candidate`, then
install and build each worktree with the repo's normal `pnpm` commands.
5. In each worktree, run the real-user Telegram Crabbox proof flow from the
skill with `$OPENCLAW_TELEGRAM_USER_PROOF_CMD`; do not run
`pnpm qa:telegram-user:crabbox` directly. The proof command comes from the
trusted workflow checkout while the current directory controls which
baseline or candidate OpenClaw build is tested. Use
`$OPENCLAW_TELEGRAM_USER_DRIVER_SCRIPT`, the workflow-provided `crabbox`
binary, and the workflow-provided local `ffmpeg`/`ffprobe`; do not generate,
install, or patch replacement proof tooling during the run. Use the same
proof idea for baseline and candidate. You may iterate and rerun if the
visual result is not convincing.
6. Open Telegram Desktop directly to the newest relevant message with the
runner `view` command before finishing each recording. Keep the chat scrolled
to the bottom so new proof messages appear in-frame.
7. Finish each session with `--preview-crop telegram-window`.
8. Build `${MANTIS_OUTPUT_DIR}/mantis-evidence.json` with:
```bash
node scripts/mantis/build-telegram-desktop-proof-evidence.mjs \
--output-dir "$MANTIS_OUTPUT_DIR" \
--baseline-repo-root <baseline-worktree> \
--baseline-output-dir <baseline-session-output-dir> \
--baseline-ref "$BASELINE_REF" \
--baseline-sha "$BASELINE_SHA" \
--candidate-repo-root <candidate-worktree> \
--candidate-output-dir <candidate-session-output-dir> \
--candidate-ref "$CANDIDATE_REF" \
--candidate-sha "$CANDIDATE_SHA" \
--scenario-label telegram-desktop-proof
```
Visual acceptance:
- The GIFs show native Telegram Desktop, not transcript HTML.
- Telegram is in single-chat proof view with no left chat list or right info
pane.
- The proof behavior is visible without reading logs.
- Main and PR GIFs are comparable side by side.
- The final relevant message or button is visible near the bottom.
- If one run fails because the PR genuinely changes behavior, still finish the
session and produce the manifest if useful visual artifacts exist.
Expected final state:
- `${MANTIS_OUTPUT_DIR}/mantis-evidence.json` exists.
- The manifest contains paired `motionPreview` artifacts labeled `Main` and
`This PR`.
- The worktree can be dirty only under `.artifacts/`.
+28 -1
View File
@@ -183,6 +183,7 @@ jobs:
ITEM_NUMBER: ${{ github.event.issue.number }}
COMMENT_ID: ${{ github.event.comment.id }}
COMMENT_BODY: ${{ github.event.comment.body }}
AUTHOR_ASSOCIATION: ${{ github.event.comment.author_association }}
SOURCE_ACTION: ${{ github.event.action }}
run: |
set -euo pipefail
@@ -213,13 +214,39 @@ jobs:
else
echo "::notice::Skipping ClawSweeper comment acknowledgement because no target token is configured."
fi
status_comment_id=""
if [ -n "$TARGET_TOKEN" ]; then
case "$AUTHOR_ASSOCIATION" in
OWNER|MEMBER|COLLABORATOR)
status_body="$(printf '%s\n' \
"<!-- clawsweeper-command-ack:$COMMENT_ID -->" \
"🦞👀" \
"ClawSweeper picked this up." \
"" \
"Command router queued. I will update this comment with the next step.")"
status_payload="$(jq -nc --arg body "$status_body" '{body:$body}')"
status_err="$(mktemp)"
if status_response="$(GH_TOKEN="$TARGET_TOKEN" gh api \
"repos/$TARGET_REPO/issues/$ITEM_NUMBER/comments" \
--method POST \
--input - <<< "$status_payload" 2>"$status_err")"; then
status_comment_id="$(jq -r '.id // empty' <<< "$status_response")"
else
cat "$status_err" >&2
echo "::warning::Could not create ClawSweeper queued status comment; dispatching command router without one."
fi
rm -f "$status_err"
;;
esac
fi
payload="$(jq -nc \
--arg target_repo "$TARGET_REPO" \
--argjson item_number "$ITEM_NUMBER" \
--argjson comment_id "$COMMENT_ID" \
--arg status_comment_id "$status_comment_id" \
--arg source_event "issue_comment" \
--arg source_action "$SOURCE_ACTION" \
'{event_type:"clawsweeper_comment",client_payload:{target_repo:$target_repo,item_number:$item_number,comment_id:$comment_id,source_event:$source_event,source_action:$source_action}}')"
'{event_type:"clawsweeper_comment",client_payload:({target_repo:$target_repo,item_number:$item_number,comment_id:$comment_id,source_event:$source_event,source_action:$source_action,max_comments:"1"} + (if $status_comment_id != "" then {status_comment_id:($status_comment_id|tonumber)} else {} end))}')"
if GH_TOKEN="$DISPATCH_TOKEN" gh api repos/openclaw/clawsweeper/dispatches \
--method POST \
--input - <<< "$payload"; then
+18
View File
@@ -13,6 +13,7 @@ on:
- discord-thread-reply-filepath-attachment
- slack-desktop-smoke
- telegram-live
- telegram-desktop-proof
baseline_ref:
description: Optional baseline ref for before/after scenarios
required: false
@@ -103,6 +104,23 @@ jobs:
fi
gh "${args[@]}"
;;
telegram-desktop-proof)
baseline_ref="$BASELINE_REF"
if [[ -z "$baseline_ref" || "$baseline_ref" == "0bf06e953fdda290799fc9fb9244a8f67fdae593" ]]; then
baseline_ref="main"
fi
args=(
workflow run mantis-telegram-desktop-proof.yml
--repo "$GITHUB_REPOSITORY"
--ref main
-f "baseline_ref=${baseline_ref}"
-f "candidate_ref=${CANDIDATE_REF}"
)
if [[ -n "${PR_NUMBER:-}" ]]; then
args+=(-f "pr_number=${PR_NUMBER}")
fi
gh "${args[@]}"
;;
*)
echo "Unsupported Mantis scenario: ${SCENARIO_ID}" >&2
exit 1
@@ -0,0 +1,503 @@
name: Mantis Telegram Desktop Proof
on:
issue_comment:
types: [created]
workflow_dispatch:
inputs:
baseline_ref:
description: Ref, tag, or SHA to capture as the before GIF
required: true
default: main
type: string
candidate_ref:
description: Ref, tag, or SHA to capture as the after GIF
required: true
default: main
type: string
pr_number:
description: Optional PR number to receive the QA evidence comment
required: false
type: string
instructions:
description: Optional freeform proof instructions for the agent
required: false
type: string
crabbox_provider:
description: Crabbox provider for the native Telegram Desktop capture
required: false
default: aws
type: choice
options:
- aws
- hetzner
crabbox_lease_id:
description: Optional existing Crabbox desktop lease id or slug to reuse
required: false
type: string
permissions:
contents: write
issues: write
pull-requests: write
concurrency:
group: mantis-telegram-desktop-proof-${{ github.event.issue.number || inputs.pr_number || inputs.candidate_ref || github.run_id }}-${{ github.run_attempt }}
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24.x"
PNPM_VERSION: "11.0.8"
OPENCLAW_BUILD_PRIVATE_QA: "1"
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
CRABBOX_REF: main
MANTIS_OUTPUT_DIR: .artifacts/qa-e2e/mantis/telegram-desktop-proof
jobs:
authorize_actor:
name: Authorize workflow actor
if: >-
${{
github.event_name == 'workflow_dispatch' ||
(
github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
(
contains(github.event.comment.body, '@openclaw-mantis') ||
contains(github.event.comment.body, '/openclaw-mantis')
)
)
}}
runs-on: ubuntu-24.04
steps:
- name: Require maintainer-level repository access
uses: actions/github-script@v8
with:
script: |
const allowed = new Set(["admin", "maintain", "write"]);
const { owner, repo } = context.repo;
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
owner,
repo,
username: context.actor,
});
const permission = data.permission;
core.info(`Actor ${context.actor} permission: ${permission}`);
if (!allowed.has(permission)) {
core.setFailed(
`Workflow requires write/maintain/admin access. Actor "${context.actor}" has "${permission}".`,
);
}
resolve_request:
name: Resolve Mantis request
needs: authorize_actor
runs-on: ubuntu-24.04
outputs:
baseline_ref: ${{ steps.resolve.outputs.baseline_ref }}
candidate_ref: ${{ steps.resolve.outputs.candidate_ref }}
crabbox_provider: ${{ steps.resolve.outputs.crabbox_provider }}
instructions: ${{ steps.resolve.outputs.instructions }}
lease_id: ${{ steps.resolve.outputs.lease_id }}
pr_number: ${{ steps.resolve.outputs.pr_number }}
request_source: ${{ steps.resolve.outputs.request_source }}
should_run: ${{ steps.resolve.outputs.should_run }}
steps:
- name: Resolve refs and target PR
id: resolve
uses: actions/github-script@v8
with:
script: |
const eventName = context.eventName;
function setOutput(name, value) {
core.setOutput(name, value ?? "");
core.info(`${name}=${value ?? ""}`);
}
if (eventName === "workflow_dispatch") {
const inputs = context.payload.inputs ?? {};
setOutput("should_run", "true");
setOutput("baseline_ref", inputs.baseline_ref || "main");
setOutput("candidate_ref", inputs.candidate_ref || "main");
setOutput("pr_number", inputs.pr_number || "");
setOutput("instructions", inputs.instructions || "");
setOutput("crabbox_provider", inputs.crabbox_provider || "aws");
setOutput("lease_id", inputs.crabbox_lease_id || "");
setOutput("request_source", "workflow_dispatch");
return;
}
if (eventName !== "issue_comment") {
core.setFailed(`Unsupported event: ${eventName}`);
return;
}
const issue = context.payload.issue;
const body = context.payload.comment?.body ?? "";
if (!issue?.pull_request) {
core.setFailed("Mantis issue_comment trigger requires a pull request comment.");
return;
}
const normalized = body.toLowerCase();
const requested =
(normalized.includes("@openclaw-mantis") || normalized.includes("/openclaw-mantis")) &&
normalized.includes("telegram") &&
(normalized.includes("desktop") || normalized.includes("native")) &&
normalized.includes("proof");
if (!requested) {
core.notice("Comment mentioned Mantis but did not request Telegram desktop proof.");
setOutput("should_run", "false");
setOutput("baseline_ref", "");
setOutput("candidate_ref", "");
setOutput("pr_number", "");
setOutput("instructions", "");
setOutput("crabbox_provider", "");
setOutput("lease_id", "");
setOutput("request_source", "unsupported_issue_comment");
return;
}
const { owner, repo } = context.repo;
const { data: pr } = await github.rest.pulls.get({
owner,
repo,
pull_number: issue.number,
});
let mergedBaseline = "";
let mergedCandidate = "";
if (pr.merged) {
const { data: commits } = await github.rest.pulls.listCommits({
owner,
repo,
pull_number: issue.number,
per_page: 100,
});
mergedCandidate = pr.merge_commit_sha || commits.at(-1)?.sha || "";
mergedBaseline = mergedCandidate && commits.length > 0 ? `${mergedCandidate}~${commits.length}` : "";
}
const baselineMatch = body.match(/(?:baseline|base)[\s:=]+([^\s`]+)/i);
const candidateMatch = body.match(/(?:candidate|head)[\s:=]+([^\s`]+)/i);
const providerMatch = body.match(/(?:provider|crabbox_provider)[\s:=]+([^\s`]+)/i);
const leaseMatch = body.match(/(?:lease|lease_id|crabbox_lease_id)[\s:=]+([^\s`]+)/i);
const provider = providerMatch?.[1] || "aws";
if (!["aws", "hetzner"].includes(provider)) {
core.setFailed(`Unsupported Crabbox provider for Mantis Telegram desktop proof: ${provider}`);
return;
}
const rawCandidate = candidateMatch?.[1];
const candidate =
rawCandidate && !["head", "pr", "pr-head"].includes(rawCandidate.toLowerCase())
? rawCandidate
: mergedCandidate || pr.head.sha;
setOutput("should_run", "true");
setOutput("baseline_ref", baselineMatch?.[1] || mergedBaseline || "main");
setOutput("candidate_ref", candidate);
setOutput("pr_number", String(issue.number));
setOutput("instructions", body);
setOutput("crabbox_provider", provider);
setOutput("lease_id", leaseMatch?.[1] || "");
setOutput("request_source", "issue_comment");
await github.rest.reactions.createForIssueComment({
owner,
repo,
comment_id: context.payload.comment.id,
content: "eyes",
}).catch((error) => core.warning(`Could not add eyes reaction: ${error.message}`));
validate_refs:
name: Validate selected refs
needs: resolve_request
if: ${{ needs.resolve_request.outputs.should_run == 'true' }}
runs-on: ubuntu-24.04
outputs:
baseline_revision: ${{ steps.validate.outputs.baseline_revision }}
candidate_revision: ${{ steps.validate.outputs.candidate_revision }}
steps:
- name: Checkout harness ref
uses: actions/checkout@v6
with:
persist-credentials: false
fetch-depth: 0
- name: Validate refs are trusted
id: validate
env:
BASELINE_REF: ${{ needs.resolve_request.outputs.baseline_ref }}
CANDIDATE_REF: ${{ needs.resolve_request.outputs.candidate_ref }}
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ needs.resolve_request.outputs.pr_number }}
shell: bash
run: |
set -euo pipefail
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
if [[ -n "${PR_NUMBER:-}" ]]; then
git fetch --no-tags origin "+refs/pull/${PR_NUMBER}/head:refs/remotes/origin/pr/${PR_NUMBER}" || true
fi
validate_ref() {
local label="$1"
local input_ref="$2"
local revision=""
local reason=""
if ! revision="$(git rev-parse --verify "${input_ref}^{commit}" 2>/dev/null)"; then
echo "${label} ref '${input_ref}' is not available in the workflow checkout." >&2
exit 1
fi
if git merge-base --is-ancestor "$revision" refs/remotes/origin/main; then
reason="main-ancestor"
elif git tag --points-at "$revision" | grep -Eq '^v'; then
reason="release-tag"
else
local pr_head_count
pr_head_count="$(
gh api \
-H "Accept: application/vnd.github+json" \
"repos/${GITHUB_REPOSITORY}/commits/${revision}/pulls" \
--jq '[.[] | select(.state == "open" and .head.repo.full_name == "'"${GITHUB_REPOSITORY}"'" and .head.sha == "'"${revision}"'")] | length'
)"
if [[ "$pr_head_count" != "0" ]]; then
reason="open-pr-head"
fi
fi
if [[ -z "$reason" ]]; then
echo "${label} ref '${input_ref}' resolved to ${revision}, which is not trusted for this secret-bearing Mantis run." >&2
exit 1
fi
printf '%s\n' "$revision"
}
baseline_revision="$(validate_ref baseline "$BASELINE_REF")"
candidate_revision="$(validate_ref candidate "$CANDIDATE_REF")"
echo "baseline_revision=${baseline_revision}" >> "$GITHUB_OUTPUT"
echo "candidate_revision=${candidate_revision}" >> "$GITHUB_OUTPUT"
{
echo "baseline: \`${BASELINE_REF}\`"
echo "baseline SHA: \`${baseline_revision}\`"
echo "candidate: \`${CANDIDATE_REF}\`"
echo "candidate SHA: \`${candidate_revision}\`"
} >> "$GITHUB_STEP_SUMMARY"
run_telegram_desktop_proof:
name: Run agentic native Telegram proof
needs: [resolve_request, validate_refs]
if: ${{ needs.resolve_request.outputs.should_run == 'true' }}
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 360
environment: qa-live-shared
outputs:
comparison_status: ${{ steps.inspect.outputs.comparison_status }}
output_dir: ${{ steps.inspect.outputs.output_dir }}
steps:
- name: Checkout harness ref
uses: actions/checkout@v6
with:
persist-credentials: false
fetch-depth: 0
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "true"
- name: Setup Go for Crabbox CLI
uses: actions/setup-go@v6
with:
go-version: "1.26.x"
cache: false
- name: Install Crabbox CLI
shell: bash
run: |
set -euo pipefail
install_dir="${RUNNER_TEMP}/crabbox"
mkdir -p "$install_dir/src"
git init "$install_dir/src"
git -C "$install_dir/src" remote add origin https://github.com/openclaw/crabbox.git
git -C "$install_dir/src" fetch --depth 1 origin "$CRABBOX_REF"
git -C "$install_dir/src" checkout --detach FETCH_HEAD
go build -C "$install_dir/src" -o "$install_dir/crabbox" ./cmd/crabbox
sudo install -m 0755 "$install_dir/crabbox" /usr/local/bin/crabbox
crabbox --version
crabbox media preview --help >/dev/null
- name: Install local proof tools
shell: bash
run: |
set -euo pipefail
test -f scripts/e2e/telegram-user-driver.py
cat >"${RUNNER_TEMP}/openclaw-telegram-user-crabbox-proof" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
exec node --import tsx "${GITHUB_WORKSPACE}/scripts/e2e/telegram-user-crabbox-proof.ts" "$@"
EOF
chmod 0755 "${RUNNER_TEMP}/openclaw-telegram-user-crabbox-proof"
sudo install -m 0755 "${RUNNER_TEMP}/openclaw-telegram-user-crabbox-proof" /usr/local/bin/openclaw-telegram-user-crabbox-proof
/usr/local/bin/openclaw-telegram-user-crabbox-proof --help >/dev/null
media_tools="${RUNNER_TEMP}/mantis-media-tools"
install -d "$media_tools"
curl --fail --location --retry 3 --retry-delay 2 \
--connect-timeout 15 --max-time 180 \
https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz \
--output "$media_tools/ffmpeg.tar.xz"
tar -xJf "$media_tools/ffmpeg.tar.xz" -C "$media_tools"
bin_dir="$(find "$media_tools" -type d -path '*/bin' | head -n 1)"
sudo install -m 0755 "$bin_dir/ffmpeg" /usr/local/bin/ffmpeg
sudo install -m 0755 "$bin_dir/ffprobe" /usr/local/bin/ffprobe
ffmpeg -version >/dev/null
ffprobe -version >/dev/null
- name: Ensure agent key exists
env:
OPENAI_API_KEY: ${{ secrets.OPENCLAW_MANTIS_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
run: |
set -euo pipefail
if [ -z "${OPENAI_API_KEY:-}" ]; then
echo "Missing OPENCLAW_MANTIS_AGENT_OPENAI_API_KEY or OPENAI_API_KEY secret." >&2
exit 1
fi
- name: Prepare Codex user
shell: bash
run: |
set -euo pipefail
sudo useradd --create-home --shell /bin/bash codex
{
printf '%s\n' 'Defaults env_keep += "CODEX_HOME CODEX_INTERNAL_ORIGINATOR_OVERRIDE"'
printf '%s\n' 'Defaults env_keep += "BASELINE_REF BASELINE_SHA CANDIDATE_REF CANDIDATE_SHA"'
printf '%s\n' 'Defaults env_keep += "CRABBOX_ACCESS_CLIENT_ID CRABBOX_ACCESS_CLIENT_SECRET CRABBOX_COORDINATOR CRABBOX_COORDINATOR_TOKEN CRABBOX_LEASE_ID CRABBOX_PROVIDER"'
printf '%s\n' 'Defaults env_keep += "GH_TOKEN MANTIS_INSTRUCTIONS MANTIS_OUTPUT_DIR MANTIS_PR_NUMBER"'
printf '%s\n' 'Defaults env_keep += "OPENCLAW_BUILD_PRIVATE_QA OPENCLAW_ENABLE_PRIVATE_QA_CLI OPENCLAW_QA_CONVEX_SECRET_CI OPENCLAW_QA_CONVEX_SITE_URL OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN"'
printf '%s\n' 'Defaults env_keep += "OPENCLAW_TELEGRAM_USER_CRABBOX_BIN OPENCLAW_TELEGRAM_USER_CRABBOX_PROVIDER OPENCLAW_TELEGRAM_USER_DRIVER_SCRIPT OPENCLAW_TELEGRAM_USER_PROOF_CMD"'
} | sudo tee /etc/sudoers.d/mantis-codex-env >/dev/null
sudo chmod 0440 /etc/sudoers.d/mantis-codex-env
codex_home="/tmp/mantis-codex-home-${GITHUB_RUN_ID}"
sudo install -d -m 0770 -o codex -g codex "$codex_home"
sudo setfacl -m u:runner:rwx,u:codex:rwx "$codex_home"
sudo setfacl -d -m u:runner:rwx,u:codex:rwx "$codex_home"
workspace_parent="$(dirname "$GITHUB_WORKSPACE")"
while [ "$workspace_parent" != "/" ]; do
sudo setfacl -m u:codex:--x "$workspace_parent"
[ "$workspace_parent" = "/home/runner" ] && break
workspace_parent="$(dirname "$workspace_parent")"
done
sudo chown -R codex:codex "$GITHUB_WORKSPACE"
- name: Run Codex Mantis Telegram agent
uses: openai/codex-action@5c3f4ccdb2b8790f73d6b21751ac00e602aa0c02
env:
BASELINE_REF: ${{ needs.resolve_request.outputs.baseline_ref }}
BASELINE_SHA: ${{ needs.validate_refs.outputs.baseline_revision }}
CANDIDATE_REF: ${{ needs.resolve_request.outputs.candidate_ref }}
CANDIDATE_SHA: ${{ needs.validate_refs.outputs.candidate_revision }}
CRABBOX_ACCESS_CLIENT_ID: ${{ secrets.CRABBOX_ACCESS_CLIENT_ID }}
CRABBOX_ACCESS_CLIENT_SECRET: ${{ secrets.CRABBOX_ACCESS_CLIENT_SECRET }}
CRABBOX_COORDINATOR: ${{ secrets.CRABBOX_COORDINATOR || secrets.OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR }}
CRABBOX_COORDINATOR_TOKEN: ${{ secrets.CRABBOX_COORDINATOR_TOKEN || secrets.OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN }}
CRABBOX_LEASE_ID: ${{ needs.resolve_request.outputs.lease_id }}
CRABBOX_PROVIDER: ${{ needs.resolve_request.outputs.crabbox_provider }}
GH_TOKEN: ${{ github.token }}
MANTIS_INSTRUCTIONS: ${{ needs.resolve_request.outputs.instructions }}
MANTIS_OUTPUT_DIR: ${{ env.MANTIS_OUTPUT_DIR }}
MANTIS_PR_NUMBER: ${{ needs.resolve_request.outputs.pr_number }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR: ${{ secrets.OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR }}
OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN: ${{ secrets.OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN }}
OPENCLAW_TELEGRAM_USER_CRABBOX_BIN: /usr/local/bin/crabbox
OPENCLAW_TELEGRAM_USER_CRABBOX_PROVIDER: ${{ needs.resolve_request.outputs.crabbox_provider }}
OPENCLAW_TELEGRAM_USER_DRIVER_SCRIPT: ${{ github.workspace }}/scripts/e2e/telegram-user-driver.py
OPENCLAW_TELEGRAM_USER_PROOF_CMD: /usr/local/bin/openclaw-telegram-user-crabbox-proof
with:
openai-api-key: ${{ secrets.OPENCLAW_MANTIS_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
prompt-file: .github/codex/prompts/mantis-telegram-desktop-proof.md
model: ${{ vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}
effort: high
sandbox: danger-full-access
codex-home: /tmp/mantis-codex-home-${{ github.run_id }}
safety-strategy: unprivileged-user
codex-user: codex
- name: Inspect Mantis evidence manifest
id: inspect
if: ${{ always() }}
shell: bash
run: |
set -euo pipefail
output_dir="$MANTIS_OUTPUT_DIR"
echo "output_dir=${output_dir}" >> "$GITHUB_OUTPUT"
manifest="$output_dir/mantis-evidence.json"
if [[ ! -f "$manifest" ]]; then
echo "Mantis agent did not produce ${manifest}." >&2
exit 1
fi
comparison_status="$(jq -r 'if .comparison.pass then "pass" else "fail" end' "$manifest")"
echo "comparison_status=${comparison_status}" >> "$GITHUB_OUTPUT"
- name: Upload Mantis Telegram desktop artifacts
id: upload_artifact
if: ${{ always() && steps.inspect.outputs.output_dir != '' }}
uses: actions/upload-artifact@v4
with:
name: mantis-telegram-desktop-proof-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.inspect.outputs.output_dir }}
retention-days: 14
if-no-files-found: warn
- name: Create Mantis GitHub App token
id: mantis_app_token
if: ${{ always() && needs.resolve_request.outputs.pr_number != '' }}
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.MANTIS_GITHUB_APP_ID }}
private-key: ${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: ${{ github.event.repository.name }}
permission-contents: write
permission-issues: write
permission-pull-requests: write
- name: Comment PR with inline QA evidence
if: ${{ always() && needs.resolve_request.outputs.pr_number != '' && steps.inspect.outputs.output_dir != '' }}
env:
ARTIFACT_URL: ${{ steps.upload_artifact.outputs.artifact-url }}
GH_TOKEN: ${{ steps.mantis_app_token.outputs.token }}
REQUEST_SOURCE: ${{ needs.resolve_request.outputs.request_source }}
TARGET_PR: ${{ needs.resolve_request.outputs.pr_number }}
shell: bash
run: |
set -euo pipefail
root="${{ steps.inspect.outputs.output_dir }}"
if [[ ! -f "$root/mantis-evidence.json" ]]; then
echo "No Mantis evidence manifest found; skipping PR evidence comment."
exit 0
fi
artifact_url_args=()
if [[ -n "${ARTIFACT_URL:-}" ]]; then
artifact_url_args=(--artifact-url "$ARTIFACT_URL")
fi
node scripts/mantis/publish-pr-evidence.mjs \
--manifest "$root/mantis-evidence.json" \
--target-pr "$TARGET_PR" \
--artifact-root "mantis/telegram-desktop/pr-${TARGET_PR}/run-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \
--marker "<!-- mantis-telegram-desktop-proof -->" \
"${artifact_url_args[@]}" \
--run-url "https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
--request-source "$REQUEST_SOURCE"
- name: Fail when Mantis Telegram desktop proof failed
if: ${{ always() && steps.inspect.outputs.output_dir != '' && steps.inspect.outputs.comparison_status != 'pass' }}
env:
COMPARISON_STATUS: ${{ steps.inspect.outputs.comparison_status }}
run: |
echo "Mantis Telegram desktop proof failed: comparison=${COMPARISON_STATUS:-unset}." >&2
exit 1
+1 -1
View File
@@ -44,7 +44,7 @@ concurrency:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24.x"
PNPM_VERSION: "10.33.0"
PNPM_VERSION: "11.0.8"
OPENCLAW_BUILD_PRIVATE_QA: "1"
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
CRABBOX_REF: main
@@ -455,7 +455,7 @@ jobs:
needs: validate_selected_ref
if: inputs.include_live_suites && !inputs.live_models_only && (inputs.live_suite_filter == '' || inputs.live_suite_filter == 'live-cache')
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 40
timeout-minutes: 20
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
@@ -490,12 +490,12 @@ jobs:
- name: Verify live prompt cache floors
run: |
set -euo pipefail
for attempt in 1 2 3; do
echo "live-cache attempt ${attempt}/3"
if timeout --foreground --kill-after=30s 12m pnpm test:live:cache; then
for attempt in 1 2; do
echo "live-cache attempt ${attempt}/2"
if timeout --foreground --kill-after=30s 8m pnpm test:live:cache; then
exit 0
fi
if [[ "$attempt" == "3" ]]; then
if [[ "$attempt" == "2" ]]; then
exit 1
fi
sleep $((attempt * 15))
+182
View File
@@ -346,6 +346,185 @@ jobs:
OPENCLAW_EXTENSION_BATCH: ${{ matrix.extensions_csv }}
run: pnpm test:extensions:batch -- "$OPENCLAW_EXTENSION_BATCH"
plugin-prerelease-inspector:
permissions:
contents: read
name: plugin-prerelease-inspector
needs: [preflight]
if: needs.preflight.outputs.run_plugin_prerelease_suite == 'true'
continue-on-error: true
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.preflight.outputs.checkout_revision }}
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Run plugin inspector advisory sweep
env:
OPENCLAW_PLUGIN_INSPECTOR_VERSION: "0.3.10"
OPENCLAW_PLUGIN_INSPECTOR_ROOT: .artifacts/plugin-inspector
shell: bash
run: |
set -euo pipefail
mkdir -p "$OPENCLAW_PLUGIN_INSPECTOR_ROOT"
set +e
node --input-type=module <<'EOF'
import { existsSync } from "node:fs";
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
const artifactRoot = process.env.OPENCLAW_PLUGIN_INSPECTOR_ROOT;
if (!artifactRoot) {
throw new Error("OPENCLAW_PLUGIN_INSPECTOR_ROOT is required");
}
const readJson = async (filePath) => JSON.parse(await readFile(filePath, "utf8"));
const inferSeams = (pluginManifest, packageJson) => {
const contracts = Object.keys(pluginManifest?.contracts ?? {});
if (contracts.includes("tools")) {
return ["dynamic-tool"];
}
const openclawPackage = packageJson?.openclaw ?? {};
if (openclawPackage.extensions || openclawPackage.runtimeExtensions) {
return ["plugin-runtime"];
}
return ["plugin-metadata"];
};
const extensionRoot = path.resolve("extensions");
const fixtures = [];
for (const entry of await readdir(extensionRoot, { withFileTypes: true })) {
if (!entry.isDirectory()) {
continue;
}
const relativePath = `extensions/${entry.name}`;
const packagePath = path.join(extensionRoot, entry.name, "package.json");
const manifestPath = path.join(extensionRoot, entry.name, "openclaw.plugin.json");
if (!existsSync(packagePath) || !existsSync(manifestPath)) {
continue;
}
const packageJson = await readJson(packagePath);
const pluginManifest = await readJson(manifestPath);
fixtures.push({
id: entry.name,
name: pluginManifest.name ?? packageJson.name ?? entry.name,
path: relativePath,
priority: "high",
repo: "local",
seams: inferSeams(pluginManifest, packageJson),
why: "bundled OpenClaw plugin prerelease advisory fixture",
});
}
fixtures.sort((left, right) => left.id.localeCompare(right.id));
if (fixtures.length === 0) {
throw new Error("No bundled plugin fixtures found under extensions/");
}
await mkdir(artifactRoot, { recursive: true });
const config = `${JSON.stringify(
{
version: 1,
submoduleRoot: ".",
openclaw: {
defaultCheckoutPath: ".",
},
fixtures,
},
null,
2,
)}\n`;
await writeFile("plugin-inspector.config.json", config, "utf8");
await writeFile(path.join(artifactRoot, "plugin-inspector.config.json"), config, "utf8");
EOF
config_status=$?
set -e
echo "$config_status" > "$OPENCLAW_PLUGIN_INSPECTOR_ROOT/config-exit-code.txt"
if [ "$config_status" -eq 0 ]; then
set +e
npm exec --yes "@openclaw/plugin-inspector@${OPENCLAW_PLUGIN_INSPECTOR_VERSION}" -- ci \
--config plugin-inspector.config.json \
--openclaw "$PWD" \
--out "$OPENCLAW_PLUGIN_INSPECTOR_ROOT/reports" \
--json \
> "$OPENCLAW_PLUGIN_INSPECTOR_ROOT/plugin-inspector-stdout.json" \
2> "$OPENCLAW_PLUGIN_INSPECTOR_ROOT/plugin-inspector-stderr.log"
inspector_status=$?
set -e
else
inspector_status=127
echo "Skipped plugin-inspector because config generation failed." \
> "$OPENCLAW_PLUGIN_INSPECTOR_ROOT/plugin-inspector-stderr.log"
fi
echo "$inspector_status" > "$OPENCLAW_PLUGIN_INSPECTOR_ROOT/exit-code.txt"
node --input-type=module <<'EOF'
import { existsSync } from "node:fs";
import { appendFile, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
const artifactRoot = process.env.OPENCLAW_PLUGIN_INSPECTOR_ROOT;
const summaryPath = path.join(artifactRoot, "reports/plugin-inspector-ci-summary.json");
const markdownPath = path.join(artifactRoot, "reports/plugin-inspector-ci-summary.md");
const configExitCode = (await readFile(path.join(artifactRoot, "config-exit-code.txt"), "utf8")).trim();
const exitCode = (await readFile(path.join(artifactRoot, "exit-code.txt"), "utf8")).trim();
const lines = [
"## Plugin Inspector Advisory",
"",
`Inspector: @openclaw/plugin-inspector@${process.env.OPENCLAW_PLUGIN_INSPECTOR_VERSION}`,
`Config exit code: ${configExitCode}`,
`Exit code: ${exitCode}`,
];
if (existsSync(summaryPath)) {
const summary = JSON.parse(await readFile(summaryPath, "utf8"));
lines.push(
`Status: ${String(summary.status ?? "unknown").toUpperCase()}`,
"",
"| Metric | Count |",
"| --- | ---: |",
`| Hard breakages | ${summary.summary?.breakages ?? 0} |`,
`| Issues | ${summary.summary?.issues ?? 0} |`,
`| P0 issues | ${summary.summary?.p0Issues ?? 0} |`,
`| P1 issues | ${summary.summary?.p1Issues ?? 0} |`,
`| Compat gaps | ${summary.summary?.compatGaps ?? 0} |`,
`| Inspector gaps | ${summary.summary?.inspectorGaps ?? 0} |`,
"",
"This job is informational; Plugin Prerelease blocking status is unchanged.",
);
await writeFile(path.join(artifactRoot, "advisory-summary.md"), `${lines.join("\n")}\n`, "utf8");
if (existsSync(markdownPath)) {
lines.push("", "### Full inspector summary", "");
lines.push(await readFile(markdownPath, "utf8"));
}
} else {
lines.push("", "No plugin-inspector CI summary was produced.", "");
lines.push("This job is informational; inspect the uploaded stdout/stderr artifacts.");
await writeFile(path.join(artifactRoot, "advisory-summary.md"), `${lines.join("\n")}\n`, "utf8");
}
await appendFile(process.env.GITHUB_STEP_SUMMARY, `${lines.join("\n")}\n`, "utf8");
EOF
- name: Upload plugin inspector advisory artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: plugin-inspector-advisory
path: .artifacts/plugin-inspector/**
if-no-files-found: warn
plugin-prerelease-docker-suite:
name: plugin-prerelease-docker-suite
needs: [preflight]
@@ -375,6 +554,7 @@ jobs:
- plugin-prerelease-static-shard
- plugin-prerelease-node-shard
- plugin-prerelease-extension-shard
- plugin-prerelease-inspector
- plugin-prerelease-docker-suite
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_plugin_prerelease_suite == 'true' }}
runs-on: ubuntu-24.04
@@ -389,6 +569,7 @@ jobs:
STATIC_RESULT: ${{ needs.plugin-prerelease-static-shard.result }}
NODE_RESULT: ${{ needs.plugin-prerelease-node-shard.result }}
EXTENSIONS_RESULT: ${{ needs.plugin-prerelease-extension-shard.result }}
INSPECTOR_RESULT: ${{ needs.plugin-prerelease-inspector.result }}
DOCKER_RESULT: ${{ needs.plugin-prerelease-docker-suite.result }}
shell: bash
run: |
@@ -411,4 +592,5 @@ jobs:
check_required "plugin-prerelease-node" "$RUN_NODE" "$NODE_RESULT"
check_required "plugin-prerelease-extensions" "$RUN_EXTENSIONS" "$EXTENSIONS_RESULT"
check_required "plugin-prerelease-docker" "$RUN_DOCKER" "$DOCKER_RESULT"
echo "plugin-prerelease-inspector advisory result: ${INSPECTOR_RESULT}"
exit "$failed"
+2
View File
@@ -118,6 +118,8 @@ USER.md
!.agents/skills/gitcrawl/
!.agents/skills/gitcrawl/**
!.agents/skills/openclaw-docs/**
!.agents/skills/openclaw-debugging/
!.agents/skills/openclaw-debugging/**
!.agents/skills/openclaw-ghsa-maintainer/
!.agents/skills/openclaw-ghsa-maintainer/**
!.agents/skills/openclaw-parallels-smoke/
+16
View File
@@ -1,5 +1,20 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"arrowParens": "always",
"bracketSameLine": false,
"bracketSpacing": true,
"embeddedLanguageFormatting": "auto",
"endOfLine": "lf",
"htmlWhitespaceSensitivity": "css",
"insertFinalNewline": true,
"jsxSingleQuote": false,
"objectWrap": "preserve",
"printWidth": 100,
"proseWrap": "preserve",
"quoteProps": "as-needed",
"semi": true,
"singleAttributePerLine": false,
"singleQuote": false,
"sortImports": {
"newlinesBetween": false,
},
@@ -7,6 +22,7 @@
"sortScripts": true,
},
"tabWidth": 2,
"trailingComma": "all",
"useTabs": false,
"ignorePatterns": [
"apps/",
+65 -2
View File
@@ -36,8 +36,12 @@
"eslint/no-new-wrappers": "error",
"eslint/no-else-return": "error",
"eslint/no-case-declarations": "error",
"eslint/default-case-last": "error",
"eslint/default-param-last": "error",
"eslint/prefer-exponentiation-operator": "error",
"eslint/prefer-numeric-literals": "error",
"eslint/prefer-rest-params": "error",
"eslint/prefer-spread": "error",
"eslint/radix": "error",
"eslint/unicode-bom": "error",
"eslint/yoda": "error",
@@ -49,7 +53,12 @@
"oxc/no-accumulating-spread": "error",
"oxc/no-async-endpoint-handlers": "error",
"oxc/no-map-spread": "error",
"promise/no-callback-in-promise": "error",
"promise/no-multiple-resolved": "error",
"promise/no-promise-in-callback": "error",
"promise/no-return-in-finally": "error",
"promise/no-new-statics": "error",
"promise/valid-params": "error",
"typescript/adjacent-overload-signatures": "error",
"typescript/ban-tslint-comment": "error",
"typescript/consistent-return": "error",
@@ -66,24 +75,35 @@
"typescript/no-unnecessary-type-parameters": "error",
"typescript/no-unsafe-type-assertion": "off",
"typescript/no-useless-default-assignment": "error",
"typescript/no-useless-empty-export": "error",
"typescript/no-wrapper-object-types": "error",
"typescript/switch-exhaustiveness-check": [
"error",
{ "considerDefaultExhaustiveForUnions": true }
],
"typescript/prefer-as-const": "error",
"typescript/prefer-namespace-keyword": "error",
"typescript/prefer-return-this-type": "error",
"typescript/prefer-find": "error",
"typescript/prefer-function-type": "error",
"typescript/prefer-includes": "error",
"typescript/prefer-reduce-type-parameter": "error",
"typescript/prefer-ts-expect-error": "error",
"typescript/require-array-sort-compare": "error",
"typescript/restrict-template-expressions": "error",
"typescript/triple-slash-reference": "error",
"unicorn/consistent-date-clone": "error",
"unicorn/consistent-empty-array-spread": "error",
"unicorn/consistent-function-scoping": "off",
"unicorn/no-console-spaces": "error",
"unicorn/no-empty-file": "error",
"unicorn/no-invalid-fetch-options": "error",
"unicorn/no-invalid-remove-event-listener": "error",
"unicorn/no-length-as-slice-end": "error",
"unicorn/no-instanceof-array": "error",
"unicorn/no-negation-in-equality-check": "error",
"unicorn/no-new-buffer": "error",
"unicorn/no-thenable": "error",
"unicorn/no-typeof-undefined": "error",
"unicorn/no-unnecessary-array-flat-depth": "error",
"unicorn/no-unnecessary-array-splice-count": "error",
@@ -102,16 +122,59 @@
"unicorn/prefer-prototype-methods": "error",
"unicorn/prefer-regexp-test": "error",
"unicorn/prefer-set-size": "error",
"unicorn/prefer-string-starts-ends-with": "error",
"unicorn/prefer-string-slice": "error",
"unicorn/require-array-join-separator": "error",
"unicorn/require-number-to-fixed-digits-argument": "error",
"unicorn/require-post-message-target-origin": "error",
"unicorn/throw-new-error": "error",
"vitest/no-import-node-test": "error",
"vitest/consistent-vitest-vi": "error",
"vitest/consistent-each-for": "error",
"vitest/expect-expect": "error",
"vitest/hoisted-apis-on-top": "error",
"vitest/no-alias-methods": "error",
"vitest/no-commented-out-tests": "error",
"vitest/no-conditional-expect": "error",
"vitest/no-conditional-in-test": "error",
"vitest/no-conditional-tests": "error",
"vitest/no-disabled-tests": "error",
"vitest/no-duplicate-hooks": "error",
"vitest/no-focused-tests": "error",
"vitest/no-identical-title": "error",
"vitest/no-import-node-test": "error",
"vitest/no-standalone-expect": "error",
"vitest/no-test-return-statement": "error",
"vitest/prefer-called-once": "error",
"vitest/prefer-called-times": "error",
"vitest/prefer-expect-type-of": "error"
"vitest/prefer-called-with": "error",
"vitest/prefer-comparison-matcher": "error",
"vitest/prefer-each": "error",
"vitest/prefer-equality-matcher": "error",
"vitest/prefer-expect-resolves": "error",
"vitest/prefer-expect-type-of": "error",
"vitest/prefer-hooks-in-order": "error",
"vitest/prefer-hooks-on-top": "error",
"vitest/prefer-mock-promise-shorthand": "error",
"vitest/prefer-mock-return-shorthand": "error",
"vitest/prefer-spy-on": "error",
"vitest/prefer-strict-boolean-matchers": "error",
"vitest/prefer-strict-equal": "error",
"vitest/prefer-to-be": "error",
"vitest/prefer-to-be-falsy": "error",
"vitest/prefer-to-be-object": "error",
"vitest/prefer-to-be-truthy": "error",
"vitest/prefer-to-contain": "error",
"vitest/prefer-to-have-length": "error",
"vitest/require-awaited-expect-poll": "error",
"vitest/require-hook": "error",
"vitest/require-local-test-context-for-concurrent-snapshots": "error",
"vitest/require-mock-type-parameters": "error",
"vitest/require-to-throw-message": "error",
"vitest/valid-describe-callback": "error",
"vitest/valid-expect": "error",
"vitest/valid-expect-in-promise": "error",
"vitest/valid-title": "error",
"vitest/warn-todo": "error"
},
"ignorePatterns": [
"dist/",
+3 -1
View File
@@ -73,6 +73,8 @@ Skills own workflows; root owns hard policy and routing.
- PR review answer: bug/behavior, URL(s), affected surface, best-fix judgment, evidence from code/tests/CI/current or shipped behavior.
- Issue/PR final answer: last line is the full GitHub URL.
- Changelog: PR landings/fixes need one unless pure test/internal. Do not mention missing changelog as a review finding; Codex handles it during fix/landing.
- PR verification: before merge, post exact local commands, CI/Testbox run IDs, before/after proof when used, and known proof gaps.
- Issue fixed on `main` with proof: comment proof + commit/PR, then close.
- After landing or requested close/sweep: search duplicates; comment proof + canonical commit/PR/release before closing.
- `ship` that fixes an issue: after push, comment proof + commit link, then close the issue.
- GH comments with backticks, `$`, or shell snippets: use heredoc/body file, not inline double-quoted `--body`.
@@ -132,7 +134,7 @@ Skills own workflows; root owns hard policy and routing.
- Never commit real phone numbers, videos, credentials, live config.
- Secrets: channel/provider creds in `~/.openclaw/credentials/`; model auth profiles in `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`.
- Env keys: check `~/.profile`; redact output.
- Dependency patches/overrides/vendor changes need explicit approval. `pnpm.patchedDependencies` exact versions only.
- Dependency patches/overrides/vendor changes need explicit approval. `pnpm-workspace.yaml` patched dependencies use exact versions only.
- Carbon pins owner-only: do not change `@buape/carbon` unless Shadow (`@thewilloftheshadow`, verified by `gh`) asks.
- Releases/publish/version bumps need explicit approval. Use `$openclaw-release-maintainer`.
- GHSA/advisories: `$openclaw-ghsa-maintainer` / `$security-triage`. Secret scanning: `$openclaw-secret-scanning-maintainer`.
+80
View File
@@ -6,7 +6,20 @@ Docs: https://docs.openclaw.ai
### Changes
- CI: add a non-blocking `plugin-inspector-advisory` artifact to Plugin Prerelease so release runs capture bundled plugin compatibility triage without changing the blocking gate.
- Providers/fal: route GPT Image 2 and Nano Banana 2 reference-image edit requests to `/edit` with `image_urls` array, enforce NB2 edit geometry using `aspect_ratio` and `resolution` params, lift Fal edit mode input-image caps to 10 for GPT Image 2 and 14 for Nano Banana 2, and allow aspect-ratio hints in edit mode. (#77295) Thanks @leoge007.
- Build: enable additional low-churn oxlint rules for promise, TypeScript, and runtime footgun checks.
- Build: enable stricter Vitest lint rules for focused, disabled, conditional, hook, matcher, and expectation hazards.
- Build: pin explicit oxfmt defaults in the shared formatter config to keep formatting behavior stable across upgrades.
- TypeScript: enable stricter compiler checks for implicit returns, side-effect imports, overrides, and unused production code.
- Logging: add targeted model transport, payload, SSE, and code-mode diagnostics with redacted URL handling.
- Agents: allow `session.agentToAgent.maxPingPongTurns` up to 20 while keeping the default at 5 for longer agent-to-agent reply chains. Fixes #52382. (#52400) Thanks @thirumaleshp.
- Agents: add per-agent `tools.message.crossContext` overrides so sandboxed/public agents can restrict message sends to the current conversation without changing the global bot policy.
- Agents: add per-agent `tools.message.actions.allow` overrides so sandboxed/public agents can expose and enforce send-only message tools.
- Agents: omit the sandbox workspace marker from compact command progress previews while keeping internal sandbox diagnostics unchanged.
- Build: upgrade workspace package management to pnpm 11 and keep Docker, install, update, and release workflows on the pnpm 11 config surface. (#79414) Thanks @altaywtf.
- Build: align Telegram QA workflows and git source installs with the pnpm 11 workspace build allowlist surface. (#80588) Thanks @altaywtf.
- Models: add provider-level `localService` startup for on-demand local model servers before OpenAI-compatible requests, including one-shot model probes.
- Agents: trim default system prompt guidance and send-only message tool schemas to reduce prompt tokens while preserving GPT-5 personality guidance.
- Context: add `/context map` to send a treemap image of the current session context contributors. (#79867)
@@ -28,28 +41,79 @@ Docs: https://docs.openclaw.ai
- Gateway/skills: add an opt-in private skill archive upload install path gated by `skills.install.allowUploadedArchives`, so trusted Gateway clients can stage and install zip-backed skills only when operators explicitly enable the code-install surface. (#74430) Thanks @samzong.
- Codex app-server: enable Codex native code-mode-only for harness threads so deferred OpenClaw dynamic tools run through Codex's own searchable code execution surface instead of a PI-style wrapper.
- Dependencies: refresh workspace pins and patch targets, including ACPX `@agentclientprotocol/claude-agent-acp` `0.33.1`, Codex ACP `0.14.0`, Baileys `7.0.0-rc10`, Google GenAI `2.0.1`, OpenAI `6.37.0`, AWS SDK `3.1045.0`, Kysely `0.29.0`, Tlon skill `0.3.6`, Aimock `1.19.5`, and tsdown `0.22.0`.
- Dependencies: move embedded Pi packages to the `@earendil-works` namespace, refresh Twitch Twurple packages, and move `@openclaw/fs-safe` from the GitHub release pin to the published npm package.
- Build: route Testbox changed-check delegation through Crabbox and remove the OpenClaw-specific Blacksmith Testbox helper scripts.
- Agents/compaction: preserve scoped background exec/process session references across embedded compaction and after-turn runtime contexts without exposing sessions from unrelated scopes. Fixes #79284. (#79307) Thanks @TurboTheTurtle.
- Agents/process: tell agents to inspect background sessions with `process log` before sending interactive input and to use `waitingForInput`/`stdinWritable` hints from `log`/`poll`.
- CLI/onboarding: improve setup, onboarding, configure, and channel command wayfinding so terminal flows explain the next useful command instead of relying on terse setup labels.
- Agents/Codex: remove the configurable Codex dynamic-tools profile so Codex app-server always owns workspace, edit, patch, exec, process, and plan tools while OpenClaw integration tools remain available.
- macOS app: update the Peekaboo bridge dependency to Peekaboo 3.0.0.
- Dependencies: refresh workspace pins and move the WhatsApp plugin from `@whiskeysockets/baileys` to `baileys` while keeping the `7.0.0-rc10` runtime.
- Plugin SDK: add bundled-plugin session actions, `sendSessionAttachment`, and Cron-backed `scheduleSessionTurn`/tag cleanup under the grouped session namespace. Replaces #75578/#75581/#75588 and part of #73384/#74483. Thanks @100yenadmin.
- Plugin SDK/media-understanding: add `extractStructuredWithModel(...)` plus the optional provider-side `extractStructured(...)` seam so trusted plugins can run bounded image-first structured extraction with optional supplemental text context through provider-owned runtimes such as Codex.
### Fixes
- Browser: report Chrome MCP existing-session page readiness in browser status without letting status probes exceed the client timeout. Fixes #80268. (#80280) Thanks @ai-hpc.
- Memory: reject symlinked directory components in configured extra memory paths before reading Markdown files. (#80331) Thanks @samzong.
- Sessions/transcripts: replace whole-file `readFile` scans with shared streaming helpers (`streamSessionTranscriptLines` and `streamSessionTranscriptLinesReverse`) for idempotency lookup, latest/tail assistant text reads, delivery-mirror dedupe, and compaction fork loading, so long-running sessions no longer materialize the full transcript in memory. Forward scans use `readline` over a bounded `createReadStream`; reverse scans read bounded chunks from the file end and decode complete JSONL lines newest-first without a fixed tail cap. Synthetic 200 MiB transcript: peak RSS delta drops from +252 MiB to +27 MiB while preserving malformed-line tolerance and idempotency-key return semantics. Fixes #54296. Thanks @jack-stormentswe.
- WhatsApp: apply hot-reloaded `dmPolicy` and `allowFrom` settings to the active Web listener before processing new inbound DMs. Fixes #80538. Thanks @Ampaskopi129.
- Plugins: let `openclaw doctor --fix` repair managed plugin installs whose package entrypoints fail package-directory boundary validation after local state moves. Fixes #80592. Thanks @wei-wei-zhao.
- Voice-call: resume voice-originated exec approval follow-ups as internal non-delivery turns instead of rejecting them as `unknown channel: voice`. Fixes #80540. Thanks @patrickmch.
- Control UI: preserve the composer draft when Stop is tapped during an active chat run, preventing accidental prompt loss on mobile. Fixes #80586. Thanks @KCALLC.
- Infra/retry: keep jittered retry delays at or above server-supplied Retry-After lower bounds when the hint can be honored. Fixes #68541. (#68543) Thanks @Feelw00.
- Docs: clarify that `/model provider/model` is an exact session route, while duplicate bare model ids only use configured fallback order on non-session override paths. Refs #80562. Thanks @gaodaabao.
- Redact persisted secret-shaped payloads [AI]. (#79006) Thanks @pgondhi987.
- Agents: label `.openclaw/sandboxes` exec workdirs as sandbox runs in compact tool summaries instead of showing the full path.
- OpenAI Codex: surface browser OAuth and device-code login failures instead of treating failed logins as empty successful auth results. Refs #80363.
- CLI agents: carry runtime-only current-turn sender/reply context into CLI model prompts while keeping prompt-build hook input and transcript text clean.
- Control UI: keep workspace file presence checks from treating `fs-safe` stat helper failures as missing files, restoring Agents file status for existing Windows workspace files. Fixes #79953. Thanks @lovelefeng-glitch.
- Microsoft Foundry: report an explicit error when the Azure subscription prompt returns an id that is not present in the enabled subscription list, instead of continuing from an unsafe subscription assertion. (#62742) Thanks @oliviareid-svg.
- fix(matrix): gate name-based allowlist resolution [AI]. (#79007) Thanks @pgondhi987.
- Slack: include the bot's own root/parent message in new thread sessions so in-thread replies reach the agent with the parent text the user is responding to, instead of only `reply_to_id` metadata. Fixes #79338. Thanks @sxxtony.
- Docker: keep image builds on the source pnpm workspace policy so pnpm 11 can prune production dependencies without a Docker-only workspace rewrite.
- Agents/compaction: restore info-level gateway logs for embedded compaction start, completion, and incomplete outcomes. (#71961) Thanks @rubencu.
- Telegram: build reply-aware inbound turns through the shared channel context path so agents see the current reply target inline with the current message.
- Telegram: recover legacy message cache files that mixed JSON-array and line-delimited entries so restarted gateways preserve reply-window context. (#80567)
- Telegram: update the reply-context cache when messages are edited, so streamed bot replies appear in later agent context with their final text instead of the first draft.
- Skills/Windows: normalize compacted skill prompt locations to forward slashes after home-prefix compaction so Windows skill paths remain readable by model file tools. (#52200) Thanks @chienchandler.
- Control UI/Windows: update `@openclaw/fs-safe` so agent workspace file presence checks fall back correctly on Windows, preventing existing AGENTS.md, SOUL.md, TOOLS.md, IDENTITY.md, USER.md, HEARTBEAT.md, and MEMORY.md files from showing as missing. Fixes #79953. Thanks @lovelefeng-glitch.
- Memory: skip managed dreaming cron reconciliation warnings for ordinary cron and heartbeat hook contexts that cannot manage Gateway cron. (#77027) Thanks @rubencu.
- Cron: treat Codex app-server turn acceptance, CLI process spawn, and tool starts as execution milestones, preventing isolated runs from tripping the early startup watchdog after work has begun.
- Codex app-server: treat current-turn `<turn_aborted>` raw markers as terminal so interrupted native-tool turns release Discord agent sessions instead of waiting for the outer timeout.
- Yuanbao: bump `openclaw-plugin-yuanbao` to 2.13.1 to support `sourceReplyDeliveryMode: "automatic"` for group chat. (#79814) Thanks @loongfay.
- Memory: keep `memory_search` result `corpus` labels aligned with the hit source, so session transcript hits surface as `sessions` and memory-file hits stay `memory`. Fixes #72885. (#71898, #72886) Thanks @rubencu.
- Codex app-server: default native plugin app tool approvals to automatic so non-destructive read tools run when destructive actions are disabled.
- Google/Gemini: normalize retired nested Gemini 3 Pro Preview ids while converting manifest catalog rows into emitted provider config, so `google/gemini-3.1-pro-preview` is used for testing instead of `google/gemini-3-pro-preview`.
- Google/Gemini: normalize retired nested Gemini 3 Pro Preview ids in configured proxy/provider-auth model catalogs, so regenerated config keeps testing `google/gemini-3.1-pro-preview` instead of `google/gemini-3-pro-preview`.
- Google/Gemini: normalize retired nested Gemini 3 Pro Preview ids while onboarding provider catalog presets, so setup-emitted proxy configs test `google/gemini-3.1-pro-preview` instead of `google/gemini-3-pro-preview`.
- Google/Gemini: normalize retired Gemini 3 Pro Preview ids in provider catalog rows during generic config writes, so unrelated config changes keep testing `google/gemini-3.1-pro-preview`.
- Models: keep configured fallback chains ahead of configured primary models for override selections with duplicate model ids, preventing fallback jumps to the wrong provider. Fixes #80562.
- Native apps: advertise the Gateway protocol compatibility range so chat and node sessions can connect to v3 gateways after additive v4 client updates.
- Gateway/agents: keep stale `sessions_send` ACP manager and `web_fetch` runtime chunks importable after package updates, preventing live gateways from breaking before restart. Fixes #78804. Thanks @Gomesy72.
- Gateway/install: preserve service environment value-source metadata in `openclaw gateway install`, so systemd reinstall paths keep env-file-backed secrets out of inline unit metadata. Refs #77406, #77427. Thanks @stainlu and @brokemac79.
- Auto-reply/reset: include inbound sender context in bare `/new` and `/reset` model prompts while keeping startup instructions out of transcript prompts, so agents see sender identity on the first reset turn. Fixes #77360. Thanks @srb11e.
- Gateway: avoid synchronous restart-sentinel state probes during post-attach startup, preventing slow Windows or redirected state directories from blocking channel turns. Fixes #79264. Thanks @liyi58.
- Agents/auth: update successful model auth profile status with one locked store write, reducing post-model reply latency from duplicate `auth-profiles.json` saves. Thanks @mcaxtr.
- Agents/image: honor explicit `image` tool model overrides even when `agents.defaults.imageModel` is unset, restoring one-off vision calls for configured multimodal providers. Fixes #79341. Thanks @haumanto.
- Doctor/update: leave live systemd gateway units unchanged during noninteractive update-mode service repair, so update-time doctor does not silently overwrite operator-owned unit directives. Refs #80462.
- Update: accept optional leading `v` prefixes when verifying exact npm package install targets, so `openclaw update --tag v2026...` does not roll back after installing the matching bare package version. Refs #74069; #80480. Thanks @Kaspre.
- Doctor: treat missing plugin ids in `plugins.deny` as stale config warnings instead of fatal validation errors, and remove them during stale plugin cleanup so update repair does not restore last-known-good config for deny-only stale plugin refs. Refs #77802. Thanks @Kaspre.
- Codex app-server: preserve prompt-local current-turn context through context-engine prompt projection, so replied-to Telegram messages stay visible to the Codex model input.
- Telegram: pass agent-scoped media roots through gateway message actions so workspace-local media from the active agent is not rejected as cross-agent access. Thanks @frankekn.
- CLI/gateway: keep `gateway status --deep` plugin-aware so configured plugin manifest warnings, including missing channel config metadata, stay visible during install and update smoke checks.
- Doctor/status: clarify gateway token source conflict warnings and suppress them inside the managed Gateway service credential context.
- Feishu: accept Schema 2 card callbacks whose operator identity is nested under `operator.user_id`, so card buttons dispatch instead of being dropped as malformed. Fixes #71670. (#71787) Thanks @rubencu.
- Feishu: fall back to a top-level group send when normal group quoted replies target a withdrawn or missing message, preventing replies from disappearing silently while preserving native topic safety. Fixes #79349. Thanks @arlen8411.
- Doctor: stop flagging the live compatibility agent directory as orphaned when the configured default agent is not `main`. Fixes #74313. (#74438) Thanks @carlos4s.
- Auth/Claude CLI: persist fresher managed external CLI OAuth credentials back to `auth-profiles.json`, preventing stale `anthropic:claude-cli` profiles from repeatedly bootstrapping and flooding debug logs. Fixes #80129. Thanks @Caulderein.
- Context: render `/context map` only from actual run context and persist Codex app-server run reports without counting deferred tool-search schemas as prompt-loaded tool schemas.
- Codex app-server: report Codex-native tool execution to diagnostics so long-running native `bash`, web, file, and MCP tools no longer look like stale embedded runs to the watchdog. (#80217)
- Codex app-server: refresh Codex account rate limits after subscription usage-limit failures so Discord and other channel replies can show the next reset time instead of saying Codex returned none. Thanks @pashpashpash.
- Agents/auth: let Codex-backed OpenAI agent turns use `auth.order.openai` entries for Codex-compatible OAuth and API-key profiles while keeping existing `openai-codex` profile ordering valid.
- Tasks: route group and channel task completions through the requester session so the parent agent can send the visible summary instead of stopping at a generic task-status line. Fixes #77251. (#77365) Thanks @funmerlin.
- Telegram: preserve blank lines between manually indented bullet blocks and following numbered sections in rendered replies. Fixes #76998. Thanks @evgyur.
- Agents/sandbox: allow read-only sandbox sessions to read the `/agent` workspace mount while keeping write/edit/apply_patch workspace-only guarded, restoring `read /agent/...` for `workspaceAccess: "ro"`. Fixes #39497. Thanks @stainlu and @teosborne.
- Slack: pass configured agent identity through draft preview sends so partial streaming replies keep custom username/avatar on the initial Slack message. Fixes #38235. (#38237) Thanks @lacymorrow.
- Slack: support `allowBots: "mentions"` for bot-authored messages that mention the receiving bot, matching the documented Discord-style mode without accepting every bot message. Fixes #43587. (#43588) Thanks @raw34.
- Slack: refresh private file URLs with `files.info` when inbound DM file events omit or stale attachment URLs, preventing file attachments from being dropped before media hydration. Fixes #50129. (#50200) Thanks @smartchainark.
@@ -92,6 +156,7 @@ Docs: https://docs.openclaw.ai
- Discord/voice: keep default agent-proxy realtime sessions from auto-speaking filler before the forced OpenClaw consult answer, finish Discord playback on realtime response completion, and queue later exact-speech answers until playback idles to avoid mid-sentence replacement.
- Gateway: return deterministic `400 invalid_request_error` responses for malformed encoded session-kill HTTP paths instead of letting route-shaped requests fall through to later Gateway handlers. (#72439) Thanks @rubencu.
- Control UI: serve root PWA and favicon assets from `/__openclaw__/` SPA routes so tab icons, install metadata, and the service worker do not 404 after internal navigation. Fixes #80072. Thanks @CodeNovice2017.
- Exec/safe bins: compare trusted safe-bin dirs with path-specific case folding on case-insensitive filesystems so Windows and default macOS paths match without weakening case-sensitive mounts. (#42131) Thanks @hkochar.
- OpenAI/realtime voice: honor disabled input-audio interruption locally so server VAD speech-start events do not clear Discord playback after operators set `interruptResponseOnInputAudio: false`.
- Telegram: keep no-response DM turns quiet instead of rewriting them into visible silent-reply chatter. Fixes #78188. (#78228) Thanks @Beandon13.
- Telegram: handle managed select button callbacks before the raw callback fallback while preserving delimiter-containing option values such as `env|prod`. (#79816) Thanks @moeedahmed.
@@ -102,6 +167,7 @@ Docs: https://docs.openclaw.ai
- Models/Anthropic: add `anthropic/claude-haiku-4-5` to Anthropic API-key agent allowlist defaults when an Anthropic default model is configured, so cron model overrides can select the current Haiku alias. Fixes #78000.
- Agents/compaction: initialize built-in context engines before CLI transcript compaction resolves the default engine, preventing clean-process `legacy` engine registration failures during CLI session persistence. Fixes #79446. Thanks @TurboTheTurtle.
- Agents/Anthropic-compatible: strip replayed thinking blocks for custom Anthropic-compatible models that explicitly declare `supportsReasoningEffort: false`, preventing Kimi-compatible providers from resending unsupported `thinking` content. Fixes #47452.
- Kimi: keep Anthropic-compatible thinking streams valid by supplying required thinking budgets and enough output room for hidden reasoning plus final text. (#80481) Thanks @InTheCloudDan.
- Browser: wait longer for existing-session Chrome MCP status and non-deep doctor probes so slow first attaches do not falsely report offline while keeping raw CDP status probes short. (#77473) Thanks @rubencu.
- Gateway/logging: install console capture before foreground Gateway fast-path parsing and suppress known libsignal session dumps even in verbose mode, preventing raw terminal logs from printing WhatsApp session key material. (#76306) Thanks @rubencu.
- Exec approvals: keep `exec.approval.list` on the lightweight policy-summary path so listing pending approvals no longer loads the rich tree-sitter command explainer. (#76943) Thanks @rubencu.
@@ -136,8 +202,10 @@ Docs: https://docs.openclaw.ai
- Google/Gemini: normalize retired Gemini 3 Pro Preview ids inside provider auth config patches so setup-emitted provider catalogs test `google/gemini-3.1-pro-preview`.
- GitHub Copilot: mint short-lived Copilot API tokens with the same `vscode-chat` integration identity used by runtime requests, and refresh legacy cached tokens missing that identity so image-capable Copilot models no longer inherit the `copilot-language-server` scope. Fixes #79946, #80074. Thanks @TurboTheTurtle.
- Plugins/doctor: drop stale managed npm install records when `openclaw doctor --fix` removes npm packages that shadow bundled plugins, so the rebuilt registry no longer resurrects the removed package metadata.
- Doctor: warn when a per-agent model config omits the `fallbacks` key and `agents.defaults.model.fallbacks` is non-empty. Covers both string-form (`"model": "..."`) and partial-object form (`"model": { "primary": "..." }`) — both silently clobber the defaults chain at runtime. Use `"fallbacks": []` to explicitly opt out of fallbacks, or add `"fallbacks": [...]` to inherit or override. Fixes #79369. Thanks @Kaspre.
- Discord/voice: reuse or suppress late realtime consult tool calls without stealing newer speaker context or speaking forced fallback answers twice.
- Discord/voice: skip likely incomplete realtime forced-consult transcript fragments and non-actionable closings so stale partial speech does not queue delayed answers over the next turn.
- Discord/voice: keep realtime forced consults from clearing active exact-speech playback, so back-to-back voice answers queue instead of cutting each other off.
- Discord/voice: synthesize realtime playback timestamps from emitted Discord PCM so OpenAI realtime barge-in truncation no longer sees `audioEndMs=0` and skips legitimate interruptions.
- Plugin SDK: keep activated linked plugin runtime facades loadable when bundled plugin fallback is disabled. Thanks @shakkernerd.
- Feishu: auto-thread `message(action="send")` replies inside the topic when the active session is group_topic or group_topic_sender, and propagate `replyInThread` through text, card, and media outbound adapters so topic-scoped sessions no longer post at the group root. Fixes #74903. (#77151) Thanks @ai-hpc.
@@ -171,6 +239,8 @@ Docs: https://docs.openclaw.ai
- iMessage: emit a WARN log when an action is blocked because the imsg private API bridge is not attached, so operators see the silent-drop in `~/.openclaw/logs/openclaw.log` instead of having to read per-session trajectory JSONL `tool.result` payloads. Common after a gateway restart un-injects the dylib from Messages.app. (#80035) Thanks @omarshahine.
- Codex: cross-fill missing `thread.id` and `thread.sessionId` before schema validation so live Codex app-server responses that omit `sessionId` no longer fail `thread/start` or `thread/resume`. Fixes #80124. (#80137) Thanks @kagura-agent.
- Agents/Pi: wait for embedded abort cleanup to settle before releasing the session write lock, preventing follow-up turns from racing previous prompt teardown. (#80239) Thanks @samzong.
- WhatsApp: downgrade OpenClaw watchdog-triggered Web reconnects from runtime errors to recovery warnings and clear the recovered reconnect status after the next healthy connection. (#77026) Thanks @rubencu.
- ACPX/Windows: hide the MCP proxy target child process window on Windows so ACP-backed agents do not flash or fail because of terminal window handling. Fixes #60672. (#60678) Thanks @KChow-ctrl.
## 2026.5.9
@@ -178,6 +248,7 @@ Docs: https://docs.openclaw.ai
- Skills: add `skills.load.allowSymlinkTargets` so intentional symlinked skill folders can resolve into trusted sibling repos without disabling root containment.
- Agents/tools: add core Tool Search so agents can search and call large OpenClaw, MCP, and client tool catalogs through one compact PI bridge.
- Doctor: warn when a per-agent model config omits the `fallbacks` key and `agents.defaults.model.fallbacks` is non-empty. Covers both string-form (`"model": "..."`) and partial-object form (`"model": { "primary": "..." }`) — both silently clobber the defaults chain at runtime. Use `"fallbacks": []` to explicitly opt out of fallbacks, or add `"fallbacks": [...]` to inherit or override. Fixes #79369.
- Chat commands: add `/think default` and `/fast default` to clear session overrides and inherit configured/provider defaults. (#79385) Thanks @VACInc.
- Dependencies: refresh workspace dependency pins and lockfile, including `@openai/codex` `0.130.0`, `acpx` `0.7.0`, AWS SDK `3.1044.0`, OpenTelemetry `0.217.0`, `typebox` `1.1.38`, `vite` `8.0.11`, `oxfmt` `0.48.0`, and `oxlint` `1.63.0`, and update the Codex harness model snapshot for the new bundled app-server catalog.
- Plugins/install: add guarded plugin install overrides so onboarding and repair tests can route specific plugins to registry specs or local `npm pack` artifacts via environment variables.
@@ -237,6 +308,7 @@ Docs: https://docs.openclaw.ai
- Models/config: allow `compat.thinkingFormat` values `qwen` and `qwen-chat-template` for configured OpenAI-compatible Qwen models, preserving them through catalog normalization and mapping `/think` levels to `enable_thinking` or `chat_template_kwargs.enable_thinking`. Fixes #79677. (#79777) Thanks @indulgeback.
- Codex app-server: default implicit local stdio app-server permissions to guardian when Codex system requirements disallow the YOLO approval, reviewer, or sandbox value, including hostname-scoped remote sandbox entries, avoiding turn-start failures on managed hosts that permit only reviewed approval or narrower sandboxes.
- Plugins/install: run managed npm-root install, uninstall, prune, and repair commands from the managed root without a redundant `--prefix .`, avoiding npm 10.9.3 Arborist crashes on native Windows WhatsApp plugin installs. Fixes #78514. (#78902) Thanks @melihselamett-stack.
- Config/schema/Windows: detect direct execution of the base config schema generator with `pathToFileURL` so Windows paths with backslashes still run the `--check` and `--write` command body. (#52989) Thanks @easyteacher.
- Discord/voice: stream ElevenLabs TTS directly into Discord playback and send ElevenLabs latency optimization as the documented query parameter so spoken replies can start sooner.
- Discord/voice: keep TTS playback running when another user starts speaking, ignore new capture during playback to avoid feedback loops, and downgrade expected receive-stream aborts to verbose diagnostics.
- iMessage: expose native private-API message actions through `imsg rpc` for reactions, edits, unsends, replies, rich sends, attachments, and group management when `imsg status --json` reports the required bridge capabilities.
@@ -257,6 +329,7 @@ Docs: https://docs.openclaw.ai
- Process tool: show input-wait hints from `log` and `poll` for idle interactive background sessions so operators can inspect stuck CLIs and resume them with existing input actions. Fixes #33957. Thanks @bitloi and @vincentkoc.
- Shell env/Windows: hide the login-shell environment probe child window so gateway startup and shell-env refreshes do not flash a console on Windows. Fixes #78159. (#78266) Thanks @BradGroux.
- MS Teams: surface blocked Bot Framework egress by logging JWKS fetch network failures and adding a Bot Connector send hint for transport-level reply failures. Fixes #77674. (#78081) Thanks @Beandon13.
- Windows/restart: skip duplicate scheduled-task `/Run` calls when the gateway task is already running, using a locale-stable PowerShell task-state probe before retrying. Fixes #52044. (#52487) Thanks @andyk-ms.
- Media/host-read: allow buffer-verified ZIP archives in the host-local media validator so agents can send ZIP attachments via the message tool. Fixes #78057. (#78292) Thanks @Linux2010.
- Gateway/sessions: fast-path already-qualified model refs while building session-list rows so `openclaw sessions` and Control UI session lists avoid heavyweight model resolution on large stores. (#77902) Thanks @ragesaq.
- Contributor PRs: remind external contributors to redact private information like IP addresses, API keys, phone numbers, and non-public endpoints from real behavior proof. Thanks @pashpashpash.
@@ -322,6 +395,7 @@ Docs: https://docs.openclaw.ai
- QA/Mantis: accept Blacksmith Testbox `tbx_...` lease ids from desktop smoke warmup, so provider overrides do not fail before inspect/run. Thanks @vincentkoc.
- Plugins/SDK: add bounded `before_agent_finalize` retry instructions so workflow plugins can request one more model pass. Thanks @100yenadmin.
- Plugin SDK: add plugin-owned `SessionEntry` slot projection and scoped trusted-policy session extension reads. (#75609; replaces part of #73384/#74483) Thanks @100yenadmin.
- Plugin SDK/Gateway: add scoped `plugins.sessionAction` dispatch and plugin-attributed `emitAgentEvent` support so plugins can expose typed session actions and workflow events to trusted clients. (#75578; replaces part of #73384/#74483) Thanks @100yenadmin.
- Plugins/SDK: expose host-derived tool target paths to `before_tool_call` and trusted policy hooks so workflow plugins can reason about known file targets without reparsing tool envelopes. (#75605) Thanks @100yenadmin.
- Control UI/WebChat: show a persistent compact context usage indicator from fresh session token data before the high-pressure warning state, while keeping the existing compaction prompt threshold. Fixes #46398; refs #45048, #50071, and #73744. Thanks @walterwkchoy, @AxelrodAI, @Brissux, @vincentkoc, and @BunsDev.
- Contributor PRs: require external pull requests to include after-fix real behavior proof from a real OpenClaw setup, with terminal screenshots, console output, redacted runtime logs, linked artifacts, and copied live output treated as valid evidence while unit tests, mocks, lint, typechecks, snapshots, and CI remain supplemental only.
@@ -382,6 +456,7 @@ Docs: https://docs.openclaw.ai
- Codex app-server: keep native hook relays alive for long-running turns so shell and file approvals stay reachable until the configured run window finishes. (#77533) Thanks @rubencu.
- Gateway/macOS: clear ignored SIGUSR1 restart state, skip redundant package-update restarts when the refreshed LaunchAgent already serves the expected version, and give launchd a 10s throttle plus 20s shutdown window so update restarts do not leave old gateways alive or fight supervisor recovery. Fixes #79577; refs #78699 and #60885. Thanks @BunsDev.
- Status/Codex: route Codex-harness `openai/*` usage through the OpenAI Codex quota provider and scope CLI status usage to the default agent auth store so `/status` and `openclaw status --usage` show Codex quota windows again. Fixes #79312. Thanks @keshavbotagent.
- Matrix: keep joined strict DM rooms discoverable when stale `m.direct` mappings already point at an older strict room, and let `dm.sessionScope: "per-room"` promote safe unmapped strict rooms through the existing unnamed/unaliased room gate. Fixes #79514. Thanks @stainlu.
- Gateway/agent: pass the session-key agent id into inline image attachment validation so the first image in a fresh per-agent session uses the agent's vision-capable model override instead of the text-only system default. Fixes #79407. Thanks @pandadev66.
- Gateway/maintenance: prune dedupe overflow against a stable excess count and keep active agent retries from starting duplicate runs after cache eviction. (#73841) Thanks @thesomewhatyou.
- Control UI/subagents: suppress internal `subagent_announce` handoff prompts from requester transcripts and hide legacy inter-session wrapper rows so completed subagent results no longer surface runtime context in WebChat history. (#79618) Thanks @joshavant.
@@ -396,6 +471,7 @@ Docs: https://docs.openclaw.ai
- QA-lab/parity: bump the live mock-openai parity baseline from `claude-opus-4-6`/`claude-sonnet-4-6` to `claude-opus-4-7`/`claude-sonnet-4-7` and the candidate alt from `gpt-5.4-alt` to `gpt-5.5-alt` in `openclaw-release-checks.yml` and `qa-live-transports-convex.yml`, matching the active Opus 4.7 / GPT-5.5 defaults already used elsewhere on main. Carries forward the surface-bump portion of #74290. Thanks @100yenadmin.
- QA-lab/scenarios: raise the `approval-turn-tool-followthrough` per-turn fallback timeouts from 20s/30s to 60s so cold mock-gateway parity runs do not flake on the approval-turn chain. Carries forward the timeout-bump portion of #74290. Thanks @100yenadmin.
- Gateway/restart continuation: treat routed post-reboot agent turns as trusted internal continuations while preserving the original Telegram topic route, and retry briefly when the previous run is still shutting down, so owner-only tools remain available for chained restart workflows after reboot.
- MS Teams: normalize pre-thread-qualified route session keys before deriving channel-thread lanes so cached route reuse cannot create malformed mixed `:thread:OLD:thread:NEW` sessions. Fixes #66771. (#78850) Thanks @harrisali0101.
- Agents/compaction: keep the recent tail after manual `/compact` when Pi returns an empty or no-op compaction summary, preventing blank checkpoints from replacing the live context.
- Native commands: handle slash commands before workspace and agent-reply bootstrap so Telegram `/status` and other command-only native replies do not wait behind full agent turn setup.
- Telegram/groups: include the recent local chat window and nearby reply-target window as generic inbound context so stale reply ancestry does not overshadow the live group conversation.
@@ -484,6 +560,7 @@ Docs: https://docs.openclaw.ai
- OpenRouter: keep the default `openrouter/auto` model ref canonical while preventing TUI and Control UI catalog pickers from displaying or submitting `openrouter/openrouter/auto`. Fixes #62655.
- Status/Claude CLI: show `oauth (claude-cli)` for working Claude CLI OAuth runtime sessions instead of `unknown` when no local auth profile exists. Fixes #78632. Thanks @gorkem2020.
- Memory search: preserve keyword-only hybrid FTS matches when vector scoring is unavailable or below the configured minimum score, so exact lexical hits are not dropped by weighted min-score filtering.
- Heartbeat/async exec: remap cron-run session keys to agent-main (or `"global"` under `session.scope=global`) at the bash exec, ACP, gateway node-event, and CLI watchdog enqueue sites, and treat cron-run descendants as ephemeral for retention pruning, so async exec completion events land in the same queue the heartbeat drains instead of being stranded under the ephemeral cron-run key. Refs #52305. Thanks @Kaspre.
- Exec approvals/node: let trusted backend node invokes complete no-device Control UI approvals after the original request connection changes, while keeping node, command, cwd, env, and allow-once replay bindings enforced. Fixes #78569. Thanks @naturedogdog.
- Agents/subagents: keep background completion delivery on the requester-agent handoff/queue-retry path instead of raw-sending child results directly, and strip child-result wrapper or OpenClaw runtime-context scaffolding from queued outbound retries. Fixes #78531. Thanks @EthanSK.
- Sandbox: recreate cached browser bridges when JavaScript-evaluation permission changes, keep failed prune removals tracked for retry, and make cross-device directory moves copy-then-commit without partially emptying the source on failure.
@@ -773,6 +850,8 @@ Docs: https://docs.openclaw.ai
### Fixes
- Browser/chrome-mcp: read Chrome DevTools MCP screenshot output from the extension-suffixed path, fixing ENOENT on screenshot capture. Fixes #77222. (#74685) Thanks @barbarhan.
- macOS/launchd: set generated Gateway LaunchAgent plists to `ProcessType=Interactive` so the gateway keeps timely execution during idle periods. Fixes #58061; refs #62294 and closed duplicate #66992. (#62308) Thanks @bryanpearson and @zssggle-rgb.
- Plugins/install: honor the beta update channel for onboarding and doctor-managed plugin installs by requesting floating npm and ClawHub specs with `@beta` while keeping persistent install records on the catalog default. Thanks @vincentkoc.
- WhatsApp/onboarding: canonicalize setup and pairing allowlist entries to WhatsApp's digit-only phone ids while still accepting E.164, JID, and `whatsapp:` inputs, so personal-phone allowlists match WhatsApp Web sender ids after setup. Thanks @vincentkoc.
@@ -1071,6 +1150,7 @@ Docs: https://docs.openclaw.ai
- Memory/LanceDB: declare `apache-arrow` in the bundled memory plugin package so LanceDB installs include its runtime peer. Fixes #76910. Thanks @afiqfiles-max.
- CLI/devices: retry explicit device-pair approval with `operator.admin` after a pairing-scope ownership denial, so existing admin-capable paired-device tokens can recover new Control UI/browser pairing after upgrades instead of requiring manual JSON edits. Fixes #76956. Thanks @neo19482.
- CLI/devices: stop local pairing fallback when the active Gateway names a pending request that is absent from the local pairing store, so profile or state-dir mismatches no longer make `openclaw devices list/approve` inspect the wrong store while a real device stays blocked. Thanks @vincentkoc.
- Control UI/webchat: fix streaming assistant responses causing the chat viewport to scroll upward by guarding `handleChatScroll` against scroll events triggered by the auto-scroll logic itself; introduces a `chatIsProgrammaticScroll` flag that suppresses near-bottom state updates during programmatic `scrollTo` calls so streaming output stays pinned to the bottom. Thanks @nickmopen.
- Google Meet: use the local call-control microphone button instead of disabled remote participant mute buttons, and block realtime speech when the OpenClaw Meet microphone remains muted.
- Google Meet: refresh realtime browser state during status and retry delayed speech after Meet finishes joining, so a just-opened in-call tab no longer leaves speech stuck behind stale `not-in-call` health.
- Plugins/install: recover the install ledger from the managed npm root when `plugins/installs.json` is empty or partial, so reinstalling Discord and Codex no longer makes the other installed plugin disappear.
+33 -30
View File
@@ -5,9 +5,8 @@
#
# Multi-stage build produces a minimal runtime image without build tools,
# source code, or Bun. Works with Docker, Buildx, and Podman.
# The ext-deps stage extracts only the package.json files we need from the
# bundled plugin workspace tree, so the main build layer is not invalidated by
# unrelated plugin source changes.
# The dependency manifest stages extract only package.json files, so the main
# build layer is not invalidated by unrelated source changes.
#
# Build stages use full bookworm; the runtime image is always bookworm-slim.
ARG OPENCLAW_EXTENSIONS=""
@@ -26,16 +25,24 @@ ARG OPENCLAW_BUN_IMAGE="oven/bun:1.3.13@sha256:87416c977a612a204eb54ab9f3927023c
# node:24-bookworm-slim (or podman) and replace the digests below with the
# current multi-arch manifest list entries.
FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS ext-deps
FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS workspace-deps
ARG OPENCLAW_EXTENSIONS
ARG OPENCLAW_BUNDLED_PLUGIN_DIR
# Copy package.json for opted-in extensions so pnpm resolves their deps.
RUN --mount=type=bind,source=${OPENCLAW_BUNDLED_PLUGIN_DIR},target=/tmp/${OPENCLAW_BUNDLED_PLUGIN_DIR},readonly \
mkdir -p /out && \
# Copy package.json files for workspace packages used by the install layer.
RUN --mount=type=bind,source=packages,target=/tmp/packages,readonly \
--mount=type=bind,source=${OPENCLAW_BUNDLED_PLUGIN_DIR},target=/tmp/${OPENCLAW_BUNDLED_PLUGIN_DIR},readonly \
mkdir -p /out/packages "/out/${OPENCLAW_BUNDLED_PLUGIN_DIR}" && \
for manifest in /tmp/packages/*/package.json; do \
[ -f "$manifest" ] || continue; \
pkg_dir="${manifest%/package.json}"; \
pkg_name="${pkg_dir##*/}"; \
mkdir -p "/out/packages/$pkg_name" && \
cp "$manifest" "/out/packages/$pkg_name/package.json"; \
done && \
for ext in $(printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' '); do \
if [ -f "/tmp/${OPENCLAW_BUNDLED_PLUGIN_DIR}/$ext/package.json" ]; then \
mkdir -p "/out/$ext" && \
cp "/tmp/${OPENCLAW_BUNDLED_PLUGIN_DIR}/$ext/package.json" "/out/$ext/package.json"; \
mkdir -p "/out/${OPENCLAW_BUNDLED_PLUGIN_DIR}/$ext" && \
cp "/tmp/${OPENCLAW_BUNDLED_PLUGIN_DIR}/$ext/package.json" "/out/${OPENCLAW_BUNDLED_PLUGIN_DIR}/$ext/package.json"; \
fi; \
done
@@ -58,12 +65,16 @@ COPY patches ./patches
COPY scripts/postinstall-bundled-plugins.mjs scripts/preinstall-package-manager-warning.mjs scripts/npm-runner.mjs scripts/windows-cmd-helpers.mjs ./scripts/
COPY scripts/lib/package-dist-imports.mjs ./scripts/lib/package-dist-imports.mjs
COPY --from=ext-deps /out/ ./${OPENCLAW_BUNDLED_PLUGIN_DIR}/
COPY --from=workspace-deps /out/packages/ ./packages/
COPY --from=workspace-deps /out/${OPENCLAW_BUNDLED_PLUGIN_DIR}/ ./${OPENCLAW_BUNDLED_PLUGIN_DIR}/
# Reduce OOM risk on low-memory hosts during dependency installation.
# Docker builds on small VMs may otherwise fail with "Killed" (exit 137).
RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \
NODE_OPTIONS=--max-old-space-size=2048 pnpm install --frozen-lockfile
NODE_OPTIONS=--max-old-space-size=2048 pnpm install --frozen-lockfile \
--config.supportedArchitectures.os=linux \
--config.supportedArchitectures.cpu="$(node -p 'process.arch')" \
--config.supportedArchitectures.libc=glibc
# pnpm v10+ may append peer-resolution hashes to virtual-store folder names; do not hardcode `.pnpm/...`
# paths. Matrix's native downloader can hit transient release CDN errors while
@@ -95,37 +106,29 @@ RUN for dir in /app/${OPENCLAW_BUNDLED_PLUGIN_DIR} /app/.agent /app/.agents; do
# A2UI bundle may fail under QEMU cross-compilation (e.g. building amd64
# on Apple Silicon). CI builds natively per-arch so this is a no-op there.
# Stub it so local cross-arch builds still succeed.
RUN pnpm canvas:a2ui:bundle || \
RUN pnpm_config_verify_deps_before_run=false pnpm canvas:a2ui:bundle || \
(echo "A2UI bundle: creating stub (non-fatal)" && \
mkdir -p extensions/canvas/src/host/a2ui && \
echo "/* A2UI bundle unavailable in this build */" > extensions/canvas/src/host/a2ui/a2ui.bundle.js && \
echo "stub" > extensions/canvas/src/host/a2ui/.bundle.hash && \
rm -rf vendor/a2ui apps/shared/OpenClawKit/Tools/CanvasA2UI)
RUN NODE_OPTIONS=--max-old-space-size=8192 pnpm build:docker
RUN NODE_OPTIONS=--max-old-space-size=8192 pnpm_config_verify_deps_before_run=false pnpm build:docker
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
ENV OPENCLAW_PREFER_PNPM=1
RUN pnpm ui:build
RUN pnpm qa:lab:build
RUN pnpm_config_verify_deps_before_run=false pnpm ui:build
RUN pnpm_config_verify_deps_before_run=false pnpm qa:lab:build
# Prune dev dependencies and strip build-only metadata before copying
# runtime assets into the final image.
FROM build AS runtime-assets
ARG OPENCLAW_EXTENSIONS
ARG OPENCLAW_BUNDLED_PLUGIN_DIR
# Keep the install layer frozen, but allow prune to run against the full copied
# workspace tree subset used during `pnpm install`. The build stage only copied
# the root, `ui`, and opted-in plugin manifests into the install layer, so
# prune must not rediscover unrelated workspaces from the later full source
# copy. Restore the source workspace config after prune so runtime pnpm keeps
# patch metadata and package-manager policy.
RUN cp pnpm-workspace.yaml /tmp/pnpm-workspace.source.yaml && \
printf 'packages:\n - .\n - ui\n' > /tmp/pnpm-workspace.runtime.yaml && \
for ext in $(printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' '); do \
printf ' - %s/%s\n' "$OPENCLAW_BUNDLED_PLUGIN_DIR" "$ext" >> /tmp/pnpm-workspace.runtime.yaml; \
done && \
cp /tmp/pnpm-workspace.runtime.yaml pnpm-workspace.yaml && \
CI=true pnpm_config_frozen_lockfile=false pnpm prune --prod && \
cp /tmp/pnpm-workspace.source.yaml pnpm-workspace.yaml && \
RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \
CI=true pnpm prune --prod \
--config.offline=true \
--config.supportedArchitectures.os=linux \
--config.supportedArchitectures.cpu="$(node -p 'process.arch')" \
--config.supportedArchitectures.libc=glibc && \
node scripts/postinstall-bundled-plugins.mjs && \
OPENCLAW_EXTENSIONS="$OPENCLAW_EXTENSIONS" node scripts/prune-docker-plugin-dist.mjs && \
find dist -type f \( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o -name '*.map' \) -delete && \
@@ -163,7 +166,7 @@ RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,shar
--mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates procps hostname curl git lsof openssl python3 tini && \
ca-certificates curl git hostname lsof openssl procps python3 tini && \
update-ca-certificates
RUN chown node:node /app
@@ -1,3 +1,4 @@
package ai.openclaw.app.gateway
const val GATEWAY_PROTOCOL_VERSION = 4
const val GATEWAY_MIN_PROTOCOL_VERSION = 3
@@ -687,7 +687,7 @@ class GatewaySession(
}
return buildJsonObject {
put("minProtocol", JsonPrimitive(GATEWAY_PROTOCOL_VERSION))
put("minProtocol", JsonPrimitive(GATEWAY_MIN_PROTOCOL_VERSION))
put("maxProtocol", JsonPrimitive(GATEWAY_PROTOCOL_VERSION))
put("client", clientObj)
if (options.caps.isNotEmpty()) put("caps", JsonArray(options.caps.map(::JsonPrimitive)))
@@ -79,6 +79,50 @@ private data class InvokeScenarioResult(
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class GatewaySessionInvokeTest {
@Test
fun connect_advertisesCompatibleProtocolRange() =
runBlocking {
val json = testJson()
val connected = CompletableDeferred<Unit>()
val connectParams = CompletableDeferred<JsonObject>()
val lastDisconnect = AtomicReference("")
val server =
startGatewayServer(json) { webSocket, id, method, frame ->
when (method) {
"connect" -> {
if (!connectParams.isCompleted) {
connectParams.complete(frame["params"]!!.jsonObject)
}
webSocket.send(connectResponseFrame(id))
webSocket.close(1000, "done")
}
}
}
val harness =
createNodeHarness(
connected = connected,
lastDisconnect = lastDisconnect,
) { GatewaySession.InvokeResult.ok("""{"handled":true}""") }
try {
connectNodeSession(harness.session, server.port)
awaitConnectedOrThrow(connected, lastDisconnect, server)
val params = withTimeout(TEST_TIMEOUT_MS) { connectParams.await() }
assertEquals(
GATEWAY_MIN_PROTOCOL_VERSION,
params["minProtocol"]?.jsonPrimitive?.content?.toInt(),
)
assertEquals(
GATEWAY_PROTOCOL_VERSION,
params["maxProtocol"]?.jsonPrimitive?.content?.toInt(),
)
} finally {
shutdownHarness(harness, server)
}
}
@Test
fun connect_usesBootstrapTokenWhenSharedAndDeviceTokensAreAbsent() =
runBlocking {
@@ -99,7 +99,7 @@ enum ModelCatalogLoader {
]
for root in roots {
let candidate = root
.appendingPathComponent("node_modules/@mariozechner/pi-ai/dist/models.generated.js")
.appendingPathComponent("node_modules/@earendil-works/pi-ai/dist/models.generated.js")
if FileManager().isReadableFile(atPath: candidate.path) {
return candidate.path
}
@@ -257,7 +257,7 @@ actor GatewayWizardClient {
]
var params: [String: ProtoAnyCodable] = [
"minProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
"minProtocol": ProtoAnyCodable(GATEWAY_MIN_PROTOCOL_VERSION),
"maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
"client": ProtoAnyCodable(client),
"caps": ProtoAnyCodable([String]()),
@@ -1,9 +1,30 @@
import Foundation
import OpenClawKit
import OpenClawProtocol
import Testing
@testable import OpenClaw
struct GatewayChannelConnectTests {
private final class ConnectParamsRecorder: @unchecked Sendable {
private let lock = NSLock()
private var params: [String: Any]?
func record(_ message: URLSessionWebSocketTask.Message) {
guard let params = GatewayWebSocketTestSupport.connectRequestParams(from: message) else {
return
}
self.lock.lock()
self.params = params
self.lock.unlock()
}
func snapshot() -> [String: Any]? {
self.lock.lock()
defer { self.lock.unlock() }
return self.params
}
}
private final class TLSFailureSession: WebSocketSessioning, GatewayTLSFailureProviding, @unchecked Sendable {
private var failure: GatewayTLSValidationFailure?
@@ -87,6 +108,28 @@ struct GatewayChannelConnectTests {
#expect(session.snapshotMakeCount() == 1)
}
@Test func `connect advertises compatible protocol range`() async throws {
let recorder = ConnectParamsRecorder()
let session = GatewayTestWebSocketSession(
taskFactory: {
GatewayTestWebSocketTask(
sendHook: { _, message, sendIndex in
guard sendIndex == 0 else { return }
recorder.record(message)
})
})
let channel = try GatewayChannelActor(
url: #require(URL(string: "ws://example.invalid")),
token: nil,
session: WebSocketSessionBox(session: session))
try await channel.connect()
let params = try #require(recorder.snapshot())
#expect(params["minProtocol"] as? Int == GATEWAY_MIN_PROTOCOL_VERSION)
#expect(params["maxProtocol"] as? Int == GATEWAY_PROTOCOL_VERSION)
}
@Test func `concurrent connect shares failure`() async throws {
let session = self.makeSession(response: .invalid(delayMs: 200))
let channel = try GatewayChannelActor(
@@ -28,6 +28,14 @@ enum GatewayWebSocketTestSupport {
return obj["id"] as? String
}
static func connectRequestParams(from message: URLSessionWebSocketTask.Message) -> [String: Any]? {
guard let obj = self.requestFrameObject(from: message) else { return nil }
guard (obj["type"] as? String) == "req", (obj["method"] as? String) == "connect" else {
return nil
}
return obj["params"] as? [String: Any]
}
static func connectOkData(id: String) -> Data {
let json = """
{
@@ -74,6 +82,7 @@ enum GatewayWebSocketTestSupport {
"id": "\(id)",
"ok": false,
"error": {
"code": "INVALID_REQUEST",
"message": "\(message)",
"details": {
"code": "\(detailCode)",
@@ -130,7 +130,9 @@ private func gatewayErrorDetails(_ error: ErrorShape?) -> [String: ProtoAnyCodab
details.merge(nested) { _, nestedValue in nestedValue }
}
if let error {
details["code"] = ProtoAnyCodable(error.code)
if details["code"] == nil {
details["code"] = ProtoAnyCodable(error.code)
}
details["message"] = ProtoAnyCodable(error.message)
if let retryable = error.retryable {
details["retryable"] = ProtoAnyCodable(retryable)
@@ -423,7 +425,7 @@ public actor GatewayChannelActor {
client["modelIdentifier"] = ProtoAnyCodable(model)
}
var params: [String: ProtoAnyCodable] = [
"minProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
"minProtocol": ProtoAnyCodable(GATEWAY_MIN_PROTOCOL_VERSION),
"maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
"client": ProtoAnyCodable(client),
"caps": ProtoAnyCodable(options.caps),
@@ -3,6 +3,22 @@
import Foundation
public let GATEWAY_PROTOCOL_VERSION = 4
public let GATEWAY_MIN_PROTOCOL_VERSION = 3
private struct GatewayAnyCodingKey: CodingKey, Hashable {
let stringValue: String
let intValue: Int?
init?(stringValue: String) {
self.stringValue = stringValue
self.intValue = nil
}
init?(intValue: Int) {
self.stringValue = String(intValue)
self.intValue = intValue
}
}
public enum ErrorCode: String, Codable, Sendable {
case notLinked = "NOT_LINKED"
@@ -578,6 +594,9 @@ public struct SendParams: Codable, Sendable {
public let agentid: String?
public let replytoid: String?
public let threadid: String?
public let forcedocument: Bool?
public let silent: Bool?
public let parsemode: String?
public let sessionkey: String?
public let idempotencykey: String
@@ -593,6 +612,9 @@ public struct SendParams: Codable, Sendable {
agentid: String?,
replytoid: String?,
threadid: String?,
forcedocument: Bool?,
silent: Bool?,
parsemode: String?,
sessionkey: String?,
idempotencykey: String)
{
@@ -607,6 +629,9 @@ public struct SendParams: Codable, Sendable {
self.agentid = agentid
self.replytoid = replytoid
self.threadid = threadid
self.forcedocument = forcedocument
self.silent = silent
self.parsemode = parsemode
self.sessionkey = sessionkey
self.idempotencykey = idempotencykey
}
@@ -623,6 +648,9 @@ public struct SendParams: Codable, Sendable {
case agentid = "agentId"
case replytoid = "replyToId"
case threadid = "threadId"
case forcedocument = "forceDocument"
case silent
case parsemode = "parseMode"
case sessionkey = "sessionKey"
case idempotencykey = "idempotencyKey"
}
@@ -5706,6 +5734,156 @@ public struct PluginControlUiDescriptor: Codable, Sendable {
}
}
public struct PluginsSessionActionFailureResult: Codable, Sendable {
public let ok: Bool
public let error: String
public let code: String?
public let details: AnyCodable?
public init(
error: String,
code: String?,
details: AnyCodable?
)
{
self.ok = false
self.error = error
self.code = code
self.details = details
}
private enum CodingKeys: String, CodingKey {
case ok
case error
case code
case details
}
public init(from decoder: Decoder) throws {
let rawContainer = try decoder.container(keyedBy: GatewayAnyCodingKey.self)
let unexpectedKeys = rawContainer.allKeys
.map(\.stringValue)
.filter { !Set(["ok", "error", "code", "details"]).contains($0) }
if !unexpectedKeys.isEmpty {
throw DecodingError.dataCorrupted(
.init(
codingPath: rawContainer.codingPath,
debugDescription: "Unexpected keys for PluginsSessionActionFailureResult: \(unexpectedKeys.sorted().joined(separator: ", "))"
)
)
}
let container = try decoder.container(keyedBy: CodingKeys.self)
let decodedOk = try container.decode(Bool.self, forKey: .ok)
guard decodedOk == false else {
throw DecodingError.dataCorruptedError(
forKey: .ok,
in: container,
debugDescription: "Expected ok to equal false"
)
}
self.ok = false
self.error = try container.decode(String.self, forKey: .error)
self.code = try container.decodeIfPresent(String.self, forKey: .code)
self.details = try container.decodeIfPresent(AnyCodable.self, forKey: .details)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(false, forKey: .ok)
try container.encode(error, forKey: .error)
try container.encodeIfPresent(code, forKey: .code)
try container.encodeIfPresent(details, forKey: .details)
}
}
public struct PluginsSessionActionParams: Codable, Sendable {
public let pluginid: String
public let actionid: String
public let sessionkey: String?
public let payload: AnyCodable?
public init(
pluginid: String,
actionid: String,
sessionkey: String?,
payload: AnyCodable?)
{
self.pluginid = pluginid
self.actionid = actionid
self.sessionkey = sessionkey
self.payload = payload
}
private enum CodingKeys: String, CodingKey {
case pluginid = "pluginId"
case actionid = "actionId"
case sessionkey = "sessionKey"
case payload
}
}
public struct PluginsSessionActionSuccessResult: Codable, Sendable {
public let ok: Bool
public let result: AnyCodable?
public let continueagent: Bool?
public let reply: AnyCodable?
public init(
result: AnyCodable?,
continueagent: Bool?,
reply: AnyCodable?
)
{
self.ok = true
self.result = result
self.continueagent = continueagent
self.reply = reply
}
private enum CodingKeys: String, CodingKey {
case ok
case result
case continueagent = "continueAgent"
case reply
}
public init(from decoder: Decoder) throws {
let rawContainer = try decoder.container(keyedBy: GatewayAnyCodingKey.self)
let unexpectedKeys = rawContainer.allKeys
.map(\.stringValue)
.filter { !Set(["ok", "result", "continueAgent", "reply"]).contains($0) }
if !unexpectedKeys.isEmpty {
throw DecodingError.dataCorrupted(
.init(
codingPath: rawContainer.codingPath,
debugDescription: "Unexpected keys for PluginsSessionActionSuccessResult: \(unexpectedKeys.sorted().joined(separator: ", "))"
)
)
}
let container = try decoder.container(keyedBy: CodingKeys.self)
let decodedOk = try container.decode(Bool.self, forKey: .ok)
guard decodedOk == true else {
throw DecodingError.dataCorruptedError(
forKey: .ok,
in: container,
debugDescription: "Expected ok to equal true"
)
}
self.ok = true
self.result = try container.decodeIfPresent(AnyCodable.self, forKey: .result)
self.continueagent = try container.decodeIfPresent(Bool.self, forKey: .continueagent)
self.reply = try container.decodeIfPresent(AnyCodable.self, forKey: .reply)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(true, forKey: .ok)
try container.encodeIfPresent(result, forKey: .result)
try container.encodeIfPresent(continueagent, forKey: .continueagent)
try container.encodeIfPresent(reply, forKey: .reply)
}
}
public struct PluginsUiDescriptorsParams: Codable, Sendable {}
public struct PluginsUiDescriptorsResult: Codable, Sendable {
@@ -6156,6 +6334,37 @@ public struct ShutdownEvent: Codable, Sendable {
}
}
public enum PluginsSessionActionResult: Codable, Sendable {
case success(PluginsSessionActionSuccessResult)
case failure(PluginsSessionActionFailureResult)
private enum CodingKeys: String, CodingKey {
case discriminator = "ok"
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let discriminator = try container.decode(Bool.self, forKey: .discriminator)
switch discriminator {
case true: self = try .success(PluginsSessionActionSuccessResult(from: decoder))
case false: self = try .failure(PluginsSessionActionFailureResult(from: decoder))
default:
throw DecodingError.dataCorruptedError(
forKey: .discriminator,
in: container,
debugDescription: "Unknown PluginsSessionActionResult discriminator value"
)
}
}
public func encode(to encoder: Encoder) throws {
switch self {
case .success(let value): try value.encode(to: encoder)
case .failure(let value): try value.encode(to: encoder)
}
}
}
public enum GatewayFrame: Codable, Sendable {
case req(RequestFrame)
case res(ResponseFrame)
+2 -2
View File
@@ -1,4 +1,4 @@
c9e88800854b697cb3c9721d0087eb2bc7bcf6ae7239cb51d9849c49ef3d48e3 config-baseline.json
67c58457ed2b525975cdb053489f92a5f840c8cf982666393e111fd327dd132e config-baseline.core.json
ebb8fa25af8be3a6c42a8bbf505f119819ee49b3c28a317ae04a244f740be381 config-baseline.json
647f7a12deed46b4a962848a17ed5666d24fc526b777feab62cf331d84ce957d config-baseline.core.json
f90c9d96ccc4c0c703d6c489f86d89fde208cd7f697b396aeee96ff3ee087956 config-baseline.channel.json
18f71e9d4a62fe68fbd5bf18d5833a4e380fc705ad641769e1cf05794286344c config-baseline.plugin.json
@@ -1,2 +1,2 @@
5219fe6237bdf573740840ef3c29631a8465abb73dd60128fb94589384ae2a96 plugin-sdk-api-baseline.json
f63c9723859bb900cf636e5e3fc1349a48563e279ca09e01a7ffafad9efe72f8 plugin-sdk-api-baseline.jsonl
19455aee06dd33e2679cfcd8075b10cce806069667097fd7e717aa641c262e51 plugin-sdk-api-baseline.json
ea6e0b36ab14977bed8dcf64118e58a8e58a76f41860c32055a73bcd04612826 plugin-sdk-api-baseline.jsonl
Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

+9 -5
View File
@@ -110,8 +110,9 @@ To accept every invite, use `autoJoin: "always"`.
DM and room allowlists are best populated with stable IDs:
- DMs (`dm.allowFrom`, `groupAllowFrom`, `groups.<room>.users`): use `@user:server`. Display names only resolve when the homeserver directory returns exactly one match.
- Rooms (`groups`, `autoJoinAllowlist`): use `!room:server` or `#alias:server`. Names are resolved best-effort against joined rooms; unresolved entries are ignored at runtime.
- DMs (`dm.allowFrom`, `groupAllowFrom`, `groups.<room>.users`): use `@user:server`. Display names are ignored by default because they are mutable; set `dangerouslyAllowNameMatching: true` only when you explicitly need compatibility with display-name entries.
- Room allowlist keys (`groups`, legacy `rooms`): use `!room:server` or `#alias:server`. Plain room names are ignored by default; set `dangerouslyAllowNameMatching: true` only when you explicitly need compatibility with joined-room name lookup.
- Invite allowlists (`autoJoinAllowlist`): use `!room:server`, `#alias:server`, or `*`. Plain room names are rejected.
### Account ID normalization
@@ -823,12 +824,14 @@ keys are not a reliable source for Matrix delivery IDs.
Live directory lookup uses the logged-in Matrix account:
- User lookups query the Matrix user directory on that homeserver.
- Room lookups accept explicit room IDs and aliases directly, then fall back to searching joined room names for that account.
- Joined-room name lookup is best-effort. If a room name cannot be resolved to an ID or alias, it is ignored by runtime allowlist resolution.
- Room lookups accept explicit room IDs and aliases directly. Joined-room name lookup is best-effort and only applies to runtime room allowlists when `dangerouslyAllowNameMatching: true` is set.
- If a room name cannot be resolved to an ID or alias, it is ignored by runtime allowlist resolution.
## Configuration reference
Allowlist-style fields (`groupAllowFrom`, `dm.allowFrom`, `groups.<room>.users`) accept full Matrix user IDs (safest). Exact directory matches are resolved at startup and whenever the allowlist changes while the monitor is running; entries that cannot be resolved are ignored at runtime. Room allowlists prefer room IDs or aliases for the same reason.
Allowlist-style user fields (`groupAllowFrom`, `dm.allowFrom`, `groups.<room>.users`) accept full Matrix user IDs (safest). Non-ID user entries are ignored by default. If you set `dangerouslyAllowNameMatching: true`, exact Matrix directory display-name matches are resolved at startup and whenever the allowlist changes while the monitor is running; entries that cannot be resolved are ignored at runtime.
Room allowlist keys (`groups`, legacy `rooms`) should be room IDs or aliases. Plain room-name keys are ignored by default; `dangerouslyAllowNameMatching: true` restores best-effort lookup against joined room names.
### Account and connection
@@ -864,6 +867,7 @@ Allowlist-style fields (`groupAllowFrom`, `dm.allowFrom`, `groups.<room>.users`)
- `dm.threadReplies`: DM-only override for reply threading (`"off"`, `"inbound"`, `"always"`).
- `allowBots`: accept messages from other configured Matrix bot accounts (`true` or `"mentions"`).
- `allowlistOnly`: when `true`, forces all active DM policies (except `"disabled"`) and `"open"` group policies to `"allowlist"`. Does not change `"disabled"` policies.
- `dangerouslyAllowNameMatching`: when `true`, allows Matrix display-name directory lookup for user allowlist entries and joined-room name lookup for room allowlist keys. Prefer full `@user:server` IDs and room IDs or aliases.
- `autoJoin`: `"always"`, `"allowlist"`, or `"off"`. Default: `"off"`. Applies to every Matrix invite, including DM-style invites.
- `autoJoinAllowlist`: rooms/aliases allowed when `autoJoin` is `"allowlist"`. Alias entries are resolved against the homeserver, not against state claimed by the invited room.
- `contextVisibility`: supplemental context visibility (`"all"` default, `"allowlist"`, `"allowlist_quote"`).
+32 -1
View File
@@ -62,7 +62,15 @@ openclaw pairing approve telegram <CODE>
</Step>
<Step title="Add the bot to a group">
Add the bot to your group, then set `channels.telegram.groups` and `groupPolicy` to match your access model.
Add the bot to your group, then get both IDs that group access needs:
- your Telegram user ID, used in `allowFrom` / `groupAllowFrom`
- the Telegram group chat ID, used as the key under `channels.telegram.groups`
For first-time setup, get the group chat ID from `openclaw logs --follow`, a forwarded-ID bot, or Bot API `getUpdates`. After the group is allowed, `/whoami@<bot_username>` can confirm the user and group IDs.
Negative Telegram supergroup IDs that start with `-100` are group chat IDs. Put them under `channels.telegram.groups`, not under `groupAllowFrom`.
</Step>
</Steps>
@@ -169,6 +177,28 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
Practical pattern for one-owner bots: set your user ID in `channels.telegram.allowFrom`, leave `groupAllowFrom` unset, and allow the target groups under `channels.telegram.groups`.
Runtime note: if `channels.telegram` is completely missing, runtime defaults to fail-closed `groupPolicy="allowlist"` unless `channels.defaults.groupPolicy` is explicitly set.
Owner-only group setup:
```json5
{
channels: {
telegram: {
enabled: true,
dmPolicy: "pairing",
allowFrom: ["<YOUR_TELEGRAM_USER_ID>"],
groupPolicy: "allowlist",
groups: {
"<GROUP_CHAT_ID>": {
requireMention: true,
},
},
},
},
}
```
Test it from the group with `@<bot_username> ping`. Plain group messages do not trigger the bot while `requireMention: true`.
Example: allow any member in one specific group:
```json5
@@ -250,6 +280,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
- forward a group message to `@userinfobot` / `@getidsbot`
- or read `chat.id` from `openclaw logs --follow`
- or inspect Bot API `getUpdates`
- after the group is allowed, run `/whoami@<bot_username>` if native commands are enabled
</Tab>
</Tabs>
+19 -13
View File
@@ -388,7 +388,7 @@ The scheduled live/E2E workflow runs the full release-path Docker suite daily.
## Plugin Prerelease
`Plugin Prerelease` is more expensive product/package coverage, so it is a separate workflow dispatched by `Full Release Validation` or by an explicit operator. Normal pull requests, `main` pushes, and standalone manual CI dispatches keep that suite off. It balances bundled plugin tests across eight extension workers; those extension shard jobs run up to two plugin config groups at a time with one Vitest worker per group and a larger Node heap so import-heavy plugin batches do not create extra CI jobs. The release-only Docker prerelease path batches targeted Docker lanes in small groups to avoid reserving dozens of runners for one-to-three-minute jobs.
`Plugin Prerelease` is more expensive product/package coverage, so it is a separate workflow dispatched by `Full Release Validation` or by an explicit operator. Normal pull requests, `main` pushes, and standalone manual CI dispatches keep that suite off. It balances bundled plugin tests across eight extension workers; those extension shard jobs run up to two plugin config groups at a time with one Vitest worker per group and a larger Node heap so import-heavy plugin batches do not create extra CI jobs. The release-only Docker prerelease path batches targeted Docker lanes in small groups to avoid reserving dozens of runners for one-to-three-minute jobs. The workflow also uploads an informational `plugin-inspector-advisory` artifact from `@openclaw/plugin-inspector`; inspector findings are triage input and do not change the blocking Plugin Prerelease gate.
## QA Lab
@@ -493,13 +493,23 @@ Local changed-test routing lives in `scripts/test-projects.test-support.mjs` and
## Testbox validation
Run Testbox from the repo root and prefer a fresh warmed box for broad proof. Before spending a slow gate on a box that was reused, expired, or just reported an unexpectedly large sync, run `pnpm testbox:sanity` inside the box first.
Crabbox is the repo-owned remote-box wrapper for maintainer Linux proof. Use it
from the repo root when a check is too broad for a local edit loop, when CI
parity matters, or when the proof needs secrets, Docker, package lanes,
reusable boxes, or remote logs. The normal OpenClaw backend is
`blacksmith-testbox`; owned AWS/Hetzner capacity is a fallback for Blacksmith
outages, quota issues, or explicit owned-capacity testing.
The sanity check fails fast when required root files such as `pnpm-lock.yaml` disappeared or when `git status --short` shows at least 200 tracked deletions. That usually means the remote sync state is not a trustworthy copy of the PR; stop that box and warm a fresh one instead of debugging the product test failure. For intentional large-deletion PRs, set `OPENCLAW_TESTBOX_ALLOW_MASS_DELETIONS=1` for that sanity run.
Crabbox-backed Blacksmith runs warm, claim, sync, run, report, and clean up
one-shot Testboxes. The built-in sync sanity check fails fast when required
root files such as `pnpm-lock.yaml` disappear or when `git status --short`
shows at least 200 tracked deletions. For intentional large-deletion PRs, set
`OPENCLAW_TESTBOX_ALLOW_MASS_DELETIONS=1` for the remote command.
`pnpm testbox:run` also terminates a local Blacksmith CLI invocation that stays in the sync phase for more than five minutes without post-sync output. Set `OPENCLAW_TESTBOX_SYNC_TIMEOUT_MS=0` to disable that guard, or use a larger millisecond value for unusually large local diffs.
Crabbox is the repo-owned remote-box wrapper for maintainer Linux proof. Use it when a check is too broad for a local edit loop, when CI parity matters, or when the proof needs secrets, Docker, package lanes, reusable boxes, or remote logs. The normal OpenClaw backend is `blacksmith-testbox`; owned AWS/Hetzner capacity is a fallback for Blacksmith outages, quota issues, or explicit owned-capacity testing.
Crabbox also terminates a local Blacksmith CLI invocation that stays in the
sync phase for more than five minutes without post-sync output. Set
`CRABBOX_BLACKSMITH_SYNC_TIMEOUT_MS=0` to disable that guard, or use a larger
millisecond value for unusually large local diffs.
Before a first run, check the wrapper from the repo root:
@@ -569,13 +579,9 @@ pnpm crabbox:run -- --provider blacksmith-testbox --id <tbx_id> --no-sync --timi
pnpm crabbox:stop -- <tbx_id>
```
If Crabbox is the broken layer but Blacksmith itself works, use direct Blacksmith as a narrow fallback:
```bash
blacksmith testbox warmup ci-check-testbox.yml --ref main --idle-timeout 90
blacksmith testbox run --id <tbx_id> "env CI=1 NODE_OPTIONS=--max-old-space-size=4096 OPENCLAW_TEST_PROJECTS_PARALLEL=6 OPENCLAW_VITEST_MAX_WORKERS=1 OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS=900000 pnpm check:changed"
blacksmith testbox stop --id <tbx_id>
```
If Crabbox is the broken layer but Blacksmith itself works, use direct
Blacksmith only for diagnostics such as `list`, `status`, and cleanup. Fix the
Crabbox path before treating a direct Blacksmith run as maintainer proof.
If `blacksmith testbox list --all` and `blacksmith testbox status` work but new
warmups sit `queued` with no IP or Actions run URL after a couple of minutes,
+1 -1
View File
@@ -58,7 +58,7 @@ Notes:
- When WhatsApp is enabled, doctor checks for a degraded Gateway event loop with local `openclaw-tui` clients still running. `doctor --fix` stops only verified local TUI clients so WhatsApp replies are not queued behind stale TUI refresh loops.
- Doctor rewrites legacy `openai-codex/*` model refs to canonical `openai/*` refs across primary models, fallbacks, heartbeat/subagent/compaction overrides, hooks, channel model overrides, and stale session route pins. `--fix` moves Codex intent onto provider/model-scoped `agentRuntime.id: "codex"` entries, preserves session auth-profile pins such as `openai-codex:...`, removes stale whole-agent/session runtime pins, and keeps repaired OpenAI agent refs on Codex auth routing instead of direct OpenAI API-key auth.
- Doctor cleans legacy plugin dependency staging state created by older OpenClaw versions. It also repairs missing downloadable plugins that are referenced by config, such as `plugins.entries`, configured channels, configured provider/search settings, or configured agent runtimes. During package updates, doctor skips package-manager plugin repair until the package swap is complete; rerun `openclaw doctor --fix` afterward if a configured plugin still needs recovery. If the download fails, doctor reports the install error and preserves the configured plugin entry for the next repair attempt.
- Doctor repairs stale plugin config by removing missing plugin ids from `plugins.allow`/`plugins.entries`, plus matching dangling channel config, heartbeat targets, and channel model overrides when plugin discovery is healthy.
- Doctor repairs stale plugin config by removing missing plugin ids from `plugins.allow`/`plugins.deny`/`plugins.entries`, plus matching dangling channel config, heartbeat targets, and channel model overrides when plugin discovery is healthy.
- Doctor quarantines invalid plugin config by disabling the affected `plugins.entries.<id>` entry and removing its invalid `config` payload. Gateway startup already skips only that bad plugin so other plugins and channels can keep running.
- Set `OPENCLAW_SERVICE_REPAIR_POLICY=external` when another supervisor owns the gateway lifecycle. Doctor still reports gateway/service health and applies non-service repairs, but skips service install/start/restart/bootstrap and legacy service cleanup.
- On Linux, doctor ignores inactive extra gateway-like systemd units and does not rewrite command/entrypoint metadata for a running systemd gateway service during repair. Stop the service first or use `openclaw gateway install --force` when you intentionally want to replace the active launcher.
+1
View File
@@ -24,6 +24,7 @@ Auto-compaction is on by default. It runs when the session nears the context lim
You will see:
- `embedded run auto-compaction start` / `complete` in normal Gateway logs.
- `🧹 Auto-compaction complete` in verbose mode.
- `/status` showing `🧹 Compactions: <count>`.
+11
View File
@@ -250,6 +250,17 @@ number is available. This lane is transcript-visual rather than logged-in
Telegram Web proof: the Telegram Bot API gives stable live message evidence, but
Telegram Web login state is not required for normal Mantis automation.
`Mantis Telegram Desktop Proof` is the agentic native Telegram Desktop
before/after wrapper. A maintainer can trigger it from a PR comment with
`@Mantis telegram desktop proof`, from the Actions UI with freeform
instructions, or through the generic `Mantis Scenario` dispatcher. The workflow
hands the PR, baseline ref, candidate ref, and maintainer instructions to Codex.
The agent reads the PR, decides what Telegram-visible behavior proves the
change, runs the real-user Crabbox Telegram Desktop proof lane for baseline and
candidate, iterates until the native GIFs are useful, writes paired
`motionPreview` artifacts into `mantis-evidence.json`, uploads the bundle, and
posts a 2-column PR evidence table when a PR number is available.
For human-in-the-loop Telegram desktop setup, use the scenario builder:
```bash
+29 -6
View File
@@ -123,15 +123,38 @@ OpenClaw **pins the chosen auth profile per session** to keep provider caches wa
Manual selection via `/model …@<profileId>` sets a **user override** for that session and is not auto-rotated until a new session starts.
<Note>
Auto-pinned profiles (selected by the session router) are treated as a **preference**: they are tried first, but OpenClaw may rotate to another profile on rate limits/timeouts. User-pinned profiles stay locked to that profile; if it fails and model fallbacks are configured, OpenClaw moves to the next model instead of switching profiles.
Auto-pinned profiles (selected by the session router) are treated as a **preference**: they are tried first, but OpenClaw may rotate to another profile on rate limits/timeouts. When the original profile becomes available again, new runs can prefer it again without changing the selected model or runtime. User-pinned profiles stay locked to that profile; if it fails and model fallbacks are configured, OpenClaw moves to the next model instead of switching profiles.
</Note>
### Why OAuth can "look lost"
### OpenAI Codex subscription plus API-key backup
If you have both an OAuth profile and an API key profile for the same provider, round-robin can switch between them across messages unless pinned. To force a single profile:
For OpenAI agent models, auth and runtime are separate. `openai/gpt-*` stays on
the Codex harness while auth can rotate between a Codex subscription profile and
an OpenAI API-key backup.
- Pin with `auth.order[provider] = ["provider:profileId"]`, or
- Use a per-session override via `/model …` with a profile override (when supported by your UI/chat surface).
Use `auth.order.openai` for the user-facing order:
```json5
{
auth: {
order: {
openai: ["openai-codex:user@example.com", "openai:api-key-backup"],
},
},
}
```
Existing Codex subscription profiles may still use the legacy
`openai-codex:*` profile id. The ordered API-key backup can be a normal
`openai:*` API-key profile. When the subscription hits a Codex usage limit,
OpenClaw records the exact reset time when Codex provides one, tries the next
ordered auth profile, and keeps the run inside the Codex harness. Once the reset
time passes, the subscription profile is eligible again and the next automatic
selection can return to it.
Use a user-pinned profile only when you want to force one account/key for that
session. User-pinned profiles are intentionally strict and do not silently jump
to another profile.
## Cooldowns
@@ -229,7 +252,7 @@ OpenClaw builds the candidate list from the currently requested `provider/model`
- The requested model is always first.
- Explicit configured fallbacks are deduplicated but not filtered by the model allowlist. They are treated as explicit operator intent.
- If the current run is already on a configured fallback in the same provider family, OpenClaw keeps using the full configured chain.
- If the current run is on a different provider than config and that current model is not already part of the configured fallback chain, OpenClaw does not append unrelated configured fallbacks from another provider.
- When no explicit fallback override is supplied, configured fallbacks are tried before the configured primary even if the requested model uses a different provider.
- When no explicit fallback override is supplied to the fallback runner, the configured primary is appended at the end so the chain can settle back onto the normal default once earlier candidates are exhausted.
- When a caller supplies `fallbacksOverride`, the runner uses exactly the requested model plus that override list. An empty list disables model fallback and prevents the configured primary from being appended as a hidden retry target.
+7 -5
View File
@@ -165,11 +165,13 @@ Example allowlist config:
```json5
{
agent: {
model: { primary: "anthropic/claude-sonnet-4-6" },
models: {
"anthropic/claude-sonnet-4-6": { alias: "Sonnet" },
"anthropic/claude-opus-4-6": { alias: "Opus" },
agents: {
defaults: {
model: { primary: "anthropic/claude-sonnet-4-6" },
models: {
"anthropic/claude-sonnet-4-6": { alias: "Sonnet" },
"anthropic/claude-opus-4-6": { alias: "Opus" },
},
},
},
}
+1 -1
View File
@@ -105,7 +105,7 @@ Claude login on the host, onboarding/configure can reuse it directly.
## OAuth exchange (how login works)
OpenClaw's interactive login flows are implemented in `@mariozechner/pi-ai` and wired into the wizards/commands.
OpenClaw's interactive login flows are implemented in `@earendil-works/pi-ai` and wired into the wizards/commands.
### Anthropic setup-token
+2 -1
View File
@@ -104,7 +104,8 @@ provenance. The receiving agent should treat them as tool-routed data, not as a
direct end-user-authored instruction.
After the target responds, OpenClaw can run a **reply-back loop** where the
agents alternate messages (up to 5 turns). The target agent can reply
agents alternate messages (up to `session.agentToAgent.maxPingPongTurns`, range
0-20, default 5). The target agent can reply
`REPLY_SKIP` to stop early.
## Status and orchestration helpers
+4 -3
View File
@@ -94,7 +94,7 @@ Connect (first message):
"id": "c1",
"method": "connect",
"params": {
"minProtocol": 4,
"minProtocol": 3,
"maxProtocol": 4,
"client": {
"id": "openclaw-macos",
@@ -266,14 +266,15 @@ The Swift generator emits:
- `GatewayFrame` enum with `req`, `res`, `event`, and `unknown` cases
- Strongly typed payload structs/enums
- `ErrorCode` values and `GATEWAY_PROTOCOL_VERSION`
- `ErrorCode` values, `GATEWAY_PROTOCOL_VERSION`, and `GATEWAY_MIN_PROTOCOL_VERSION`
Unknown frame types are preserved as raw payloads for forward compatibility.
## Versioning + compatibility
- `PROTOCOL_VERSION` lives in `src/gateway/protocol/version.ts`.
- Clients send `minProtocol` + `maxProtocol`; the server rejects mismatches.
- Clients send `minProtocol` + `maxProtocol`; the server rejects ranges that
do not include its current protocol.
- The Swift models keep unknown frame types to avoid breaking older clients.
## Schema patterns and conventions
+1 -1
View File
@@ -1219,7 +1219,7 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden
- **`reset`**: primary reset policy. `daily` resets at `atHour` local time; `idle` resets after `idleMinutes`. When both configured, whichever expires first wins. Daily reset freshness uses the session row's `sessionStartedAt`; idle reset freshness uses `lastInteractionAt`. Background/system-event writes such as heartbeat, cron wakeups, exec notifications, and gateway bookkeeping can update `updatedAt`, but they do not keep daily/idle sessions fresh.
- **`resetByType`**: per-type overrides (`direct`, `group`, `thread`). Legacy `dm` accepted as alias for `direct`.
- **`mainKey`**: legacy field. Runtime always uses `"main"` for the main direct-chat bucket.
- **`agentToAgent.maxPingPongTurns`**: maximum reply-back turns between agents during agent-to-agent exchanges (integer, range: `0``5`). `0` disables ping-pong chaining.
- **`agentToAgent.maxPingPongTurns`**: maximum reply-back turns between agents during agent-to-agent exchanges (integer, range: `0`-`20`, default: `5`). `0` disables ping-pong chaining.
- **`sendPolicy`**: match by `channel`, `chatType` (`direct|group|channel`, with legacy `dm` alias), `keyPrefix`, or `rawKeyPrefix`. First deny wins.
- **`maintenance`**: session-store cleanup + retention controls.
- `mode`: `warn` emits warnings only; `enforce` applies cleanup.
+49 -31
View File
@@ -15,7 +15,7 @@ Examples below are aligned with the current config schema. For the exhaustive re
```json5
{
agent: { workspace: "~/.openclaw/workspace" },
agents: { defaults: { workspace: "~/.openclaw/workspace" } },
channels: { whatsapp: { allowFrom: ["+15555550123"] } },
}
```
@@ -26,14 +26,21 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
```json5
{
identity: {
name: "Clawd",
theme: "helpful assistant",
emoji: "🦞",
},
agent: {
workspace: "~/.openclaw/workspace",
model: { primary: "anthropic/claude-sonnet-4-6" },
agents: {
defaults: {
workspace: "~/.openclaw/workspace",
model: { primary: "anthropic/claude-sonnet-4-6" },
},
list: [
{
id: "main",
identity: {
name: "Clawd",
theme: "helpful assistant",
emoji: "🦞",
},
},
],
},
channels: {
whatsapp: {
@@ -83,12 +90,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
},
},
// Identity
identity: {
name: "Samantha",
theme: "helpful sloth",
emoji: "🦥",
},
// Identity is per agent — set it on agents.list[].identity below.
// Logging
logging: {
@@ -307,6 +309,11 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
{
id: "main",
default: true,
identity: {
name: "Samantha",
theme: "helpful sloth",
emoji: "🦥",
},
// inherits defaults.skills -> github, weather
groupChat: {
mentionPatterns: ["@openclaw", "openclaw"],
@@ -513,7 +520,7 @@ example `~/.agents/skills/manager -> ~/Projects/manager/skills`.
```json5
{
agent: { workspace: "~/.openclaw/workspace" },
agents: { defaults: { workspace: "~/.openclaw/workspace" } },
channels: {
whatsapp: { allowFrom: ["+15555550123"] },
telegram: {
@@ -605,11 +612,13 @@ Only enable direct mutable name/email/nick matching with each channel's `dangero
},
},
},
agent: {
workspace: "~/.openclaw/workspace",
model: {
primary: "anthropic/claude-opus-4-6",
fallbacks: ["minimax/MiniMax-M2.7"],
agents: {
defaults: {
workspace: "~/.openclaw/workspace",
model: {
primary: "anthropic/claude-opus-4-6",
fallbacks: ["minimax/MiniMax-M2.7"],
},
},
},
}
@@ -619,13 +628,20 @@ Only enable direct mutable name/email/nick matching with each channel's `dangero
```json5
{
identity: {
name: "WorkBot",
theme: "professional assistant",
},
agent: {
workspace: "~/work-openclaw",
elevated: { enabled: false },
agents: {
defaults: {
workspace: "~/work-openclaw",
elevatedDefault: "off",
},
list: [
{
id: "main",
identity: {
name: "WorkBot",
theme: "professional assistant",
},
},
],
},
channels: {
slack: {
@@ -644,9 +660,11 @@ Only enable direct mutable name/email/nick matching with each channel's `dangero
```json5
{
agent: {
workspace: "~/.openclaw/workspace",
model: { primary: "lmstudio/my-local-model" },
agents: {
defaults: {
workspace: "~/.openclaw/workspace",
model: { primary: "lmstudio/my-local-model" },
},
},
models: {
mode: "merge",
+1 -1
View File
@@ -232,7 +232,7 @@ That stages grounded durable candidates into the short-term dreaming store while
</Accordion>
<Accordion title="2b. OpenCode provider overrides">
If you've added `models.providers.opencode`, `opencode-zen`, or `opencode-go` manually, it overrides the built-in OpenCode catalog from `@mariozechner/pi-ai`. That can force models onto the wrong API or zero out costs. Doctor warns so you can remove the override and restore per-model API routing + costs.
If you've added `models.providers.opencode`, `opencode-zen`, or `opencode-go` manually, it overrides the built-in OpenCode catalog from `@earendil-works/pi-ai`. That can force models onto the wrong API or zero out costs. Doctor warns so you can remove the override and restore per-model API routing + costs.
</Accordion>
<Accordion title="2c. Browser migration and Chrome MCP readiness">
If your browser config still points at the removed Chrome extension path, doctor normalizes it to the current host-local Chrome MCP attach model:
+6 -3
View File
@@ -44,7 +44,7 @@ Client → Gateway:
"id": "…",
"method": "connect",
"params": {
"minProtocol": 4,
"minProtocol": 3,
"maxProtocol": 4,
"client": {
"id": "cli",
@@ -182,7 +182,7 @@ roles still need scopes under their own role prefix.
"id": "…",
"method": "connect",
"params": {
"minProtocol": 4,
"minProtocol": 3,
"maxProtocol": 4,
"client": {
"id": "ios-node",
@@ -631,7 +631,9 @@ terminal summary, and sanitized error text.
## Versioning
- `PROTOCOL_VERSION` lives in `src/gateway/protocol/version.ts`.
- Clients send `minProtocol` + `maxProtocol`; the server rejects mismatches.
- Clients send `minProtocol` + `maxProtocol`; the server rejects ranges that
do not include its current protocol. Native clients use a v3 lower bound so
additive v4 clients can still reach v3 gateways.
- Schemas + models are generated from TypeBox definitions:
- `pnpm protocol:gen`
- `pnpm protocol:gen:swift`
@@ -645,6 +647,7 @@ stable across protocol v4 and are the expected baseline for third-party clients.
| Constant | Default | Source |
| ----------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `PROTOCOL_VERSION` | `4` | `src/gateway/protocol/version.ts` |
| `MIN_CLIENT_PROTOCOL_VERSION` | `3` | `src/gateway/protocol/version.ts` |
| Request timeout (per RPC) | `30_000` ms | `src/gateway/client.ts` (`requestTimeoutMs`) |
| Preauth / connect-challenge timeout | `15_000` ms | `src/gateway/handshake-timeouts.ts` (config/env can raise the paired server/client budget) |
| Initial reconnect backoff | `1_000` ms | `src/gateway/client.ts` (`backoffMs`) |
+7 -3
View File
@@ -112,9 +112,13 @@ Both resolve from process env at activation time. SecretRef details are document
## Logging
| Variable | Purpose |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `OPENCLAW_LOG_LEVEL` | Override log level for both file and console (e.g. `debug`, `trace`). Takes precedence over `logging.level` and `logging.consoleLevel` in config. Invalid values are ignored with a warning. |
| Variable | Purpose |
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `OPENCLAW_LOG_LEVEL` | Override log level for both file and console (e.g. `debug`, `trace`). Takes precedence over `logging.level` and `logging.consoleLevel` in config. Invalid values are ignored with a warning. |
| `OPENCLAW_DEBUG_MODEL_TRANSPORT` | Emit targeted model request/response timing diagnostics at `info` level without enabling global debug logs. |
| `OPENCLAW_DEBUG_MODEL_PAYLOAD` | Model payload diagnostics: `summary`, `tools`, or `full-redacted`. `full-redacted` is capped and redacted but may include prompt/message text. |
| `OPENCLAW_DEBUG_SSE` | Streaming diagnostics: `events` for first/done timing, `peek` to include the first five redacted SSE events. |
| `OPENCLAW_DEBUG_CODE_MODE` | Code-mode model-surface diagnostics, including provider-tool hiding and exec/wait-only enforcement. |
### `OPENCLAW_HOME`
+13
View File
@@ -144,6 +144,19 @@ troubleshooting, see the main [FAQ](/help/faq).
</Accordion>
<Accordion title="If two providers expose the same model id, which one does /model use?">
`/model provider/model` selects that exact provider route for the session.
For example, `qianfan/deepseek-v4-flash` and `deepseek/deepseek-v4-flash` are different model refs even though both contain `deepseek-v4-flash`. OpenClaw should not silently switch from one provider to the other just because the bare model id matches.
A user-selected `/model` ref is also strict for fallback policy. If that selected provider/model is unavailable, the reply fails visibly instead of answering from `agents.defaults.model.fallbacks`. Configured fallback chains still apply to configured defaults, cron job primaries, and auto-selected fallback state.
If a run that started from a non-session override is allowed to use fallback, OpenClaw tries the requested provider/model first, then configured fallbacks, and only then the configured primary. That prevents duplicate bare model ids from jumping directly back to the default provider.
See [Models](/concepts/models) and [Model failover](/concepts/model-failover).
</Accordion>
<Accordion title="Can I use GPT 5.5 for daily tasks and Codex 5.5 for coding?">
Yes. Treat model choice and runtime choice separately:
+17 -2
View File
@@ -340,6 +340,21 @@ telegram-live`) or directly from a pull request comment:
@Mantis telegram scenarios=telegram-status-command,telegram-mentioned-message-reply
```
`Mantis Telegram Desktop Proof` is the agentic native Telegram Desktop
before/after wrapper for PR visual proof. Start it from the Actions UI with
freeform `instructions`, through `Mantis Scenario` (`scenario_id:
telegram-desktop-proof`), or from a PR comment:
```text
@Mantis telegram desktop proof
```
The Mantis agent reads the PR, decides what Telegram-visible behavior proves the
change, runs the real-user Crabbox Telegram Desktop proof lane on baseline and
candidate refs, iterates until the native GIFs are useful, writes a paired
`motionPreview` manifest, and posts the same 2-column GIF table through the
Mantis GitHub App when `pr_number` is set.
- `pnpm openclaw qa mantis telegram-desktop-builder`
- Leases or reuses a Crabbox Linux desktop, installs native Telegram Desktop, configures OpenClaw with a leased Telegram SUT bot token, starts the gateway, and records screenshot/MP4 evidence from the visible VNC desktop.
- Defaults to `--credential-source convex` so workflows only need the Convex broker secret. Use `--credential-source env` with the same `OPENCLAW_QA_TELEGRAM_*` variables as `pnpm openclaw qa telegram`.
@@ -538,8 +553,8 @@ Think of the suites as "increasing realism" (and increasing flakiness/cost):
Native dependency policy:
- Default test installs skip optional native Discord opus builds. Discord voice receive uses the pure-JS `opusscript` decoder, and `@discordjs/opus` stays in `ignoredBuiltDependencies` so local tests and Testbox lanes do not compile the native addon.
- Use a dedicated Discord voice performance or live lane if you intentionally need to compare a native opus build. Do not add `@discordjs/opus` back to the default `onlyBuiltDependencies`; that makes unrelated install/test loops compile native code.
- Default test installs skip optional native Discord opus builds. Discord voice receive uses the pure-JS `opusscript` decoder, and `@discordjs/opus` stays disabled in `allowBuilds` so local tests and Testbox lanes do not compile the native addon.
- Use a dedicated Discord voice performance or live lane if you intentionally need to compare a native opus build. Do not set `@discordjs/opus` to `true` in the default `allowBuilds`; that makes unrelated install/test loops compile native code.
<AccordionGroup>
<Accordion title="Projects, shards, and scoped lanes">
+3 -2
View File
@@ -409,12 +409,13 @@ See [ClawDock](/install/clawdock) for the full helper guide.
1. **Persist `/home/node`**: `export OPENCLAW_HOME_VOLUME="openclaw_home"`
2. **Bake system deps**: `export OPENCLAW_DOCKER_APT_PACKAGES="git curl jq"`
3. **Install Playwright browsers**:
3. **Bake Playwright Chromium**: `export OPENCLAW_INSTALL_BROWSER=1`
4. **Or install Playwright browsers into a persisted volume**:
```bash
docker compose run --rm openclaw-cli \
node /app/node_modules/playwright-core/cli.js install chromium
```
4. **Persist browser downloads**: use `OPENCLAW_HOME_VOLUME` or
5. **Persist browser downloads**: use `OPENCLAW_HOME_VOLUME` or
`OPENCLAW_EXTRA_MOUNTS`. OpenClaw auto-detects the Docker image's
Playwright-managed Chromium on Linux.
+33
View File
@@ -175,6 +175,39 @@ You can override both via the **`OPENCLAW_LOG_LEVEL`** environment variable (e.g
`--verbose` only affects console output and WS log verbosity; it does not change
file log levels.
### Targeted model transport diagnostics
When debugging provider calls, use targeted environment flags instead of raising
all logs to `debug`:
```bash
OPENCLAW_DEBUG_MODEL_TRANSPORT=1 openclaw gateway
OPENCLAW_DEBUG_MODEL_PAYLOAD=tools OPENCLAW_DEBUG_SSE=events openclaw gateway
```
Available flags:
- `OPENCLAW_DEBUG_MODEL_TRANSPORT=1`: emit request start, fetch response, SDK
headers, first streaming event, stream completion, and transport errors at
`info` level.
- `OPENCLAW_DEBUG_MODEL_PAYLOAD=summary`: include a bounded request payload
summary in model request logs.
- `OPENCLAW_DEBUG_MODEL_PAYLOAD=tools`: include all model-facing tool names in
the payload summary.
- `OPENCLAW_DEBUG_MODEL_PAYLOAD=full-redacted`: include a redacted, capped JSON
payload snapshot. Use only while debugging; secrets are redacted but prompts
and message text may still be present.
- `OPENCLAW_DEBUG_SSE=events`: emit first-event and stream-completion timing.
- `OPENCLAW_DEBUG_SSE=peek`: also emit the first five redacted SSE event
payloads, capped per event.
- `OPENCLAW_DEBUG_CODE_MODE=1`: emit code-mode model-surface diagnostics,
including when native provider tools are hidden because code mode owns the
tool surface.
These flags log through normal OpenClaw logging, so `openclaw logs --follow`
and the Control UI Logs tab show them. Without the flags, the same diagnostics
remain available at `debug` level.
### Trace correlation
File logs are JSONL. When a log call carries a valid diagnostic trace context,
+6 -6
View File
@@ -23,10 +23,10 @@ OpenClaw uses the pi SDK to embed an AI coding agent into its messaging gateway
```json
{
"@mariozechner/pi-agent-core": "0.73.0",
"@mariozechner/pi-ai": "0.73.0",
"@mariozechner/pi-coding-agent": "0.73.0",
"@mariozechner/pi-tui": "0.73.0"
"@earendil-works/pi-agent-core": "0.74.0",
"@earendil-works/pi-ai": "0.74.0",
"@earendil-works/pi-coding-agent": "0.74.0",
"@earendil-works/pi-tui": "0.74.0"
}
```
@@ -171,7 +171,7 @@ import {
DefaultResourceLoader,
SessionManager,
SettingsManager,
} from "@mariozechner/pi-coding-agent";
} from "@earendil-works/pi-coding-agent";
const resourceLoader = new DefaultResourceLoader({
cwd: resolvedWorkspace,
@@ -518,7 +518,7 @@ OpenClaw also has a local TUI mode that uses pi-tui components directly:
```typescript
// src/tui/tui.ts
import { ... } from "@mariozechner/pi-tui";
import { ... } from "@earendil-works/pi-tui";
```
This provides the interactive terminal experience similar to pi's native mode.
+29
View File
@@ -499,6 +499,30 @@ const video = await api.runtime.mediaUnderstanding.describeVideoFile({
filePath: "/tmp/inbound-video.mp4",
cfg: api.config,
});
const extraction = await api.runtime.mediaUnderstanding.extractStructuredWithModel({
provider: "codex",
model: "gpt-5.5",
input: [
{
type: "image",
buffer: receiptImageBuffer,
fileName: "receipt.png",
mime: "image/png",
},
{ type: "text", text: "Use the printed fields as the source of truth." },
],
instructions: "Return entities and searchable tags.",
schemaName: "example.evidence",
jsonSchema: {
type: "object",
properties: {
entities: { type: "array", items: { type: "string" } },
tags: { type: "array", items: { type: "string" } },
},
},
cfg: api.config,
});
```
For audio transcription, plugins can use either the media-understanding runtime
@@ -517,6 +541,11 @@ Notes:
- `api.runtime.mediaUnderstanding.*` is the preferred shared surface for
image/audio/video understanding.
- `extractStructuredWithModel(...)` is the plugin-facing seam for bounded
provider-owned image-first extraction. Include at least one image input;
text inputs are supplemental context.
product plugins own their routes and schemas while OpenClaw owns the
provider/runtime boundary.
- Uses core media-understanding audio configuration (`tools.media.audio`) and provider fallback order.
- Returns `{ text: undefined }` when no transcription output is produced (for example skipped/unsupported input).
- `api.runtime.stt.transcribeAudioFile(...)` remains as a compatibility alias.
+40 -18
View File
@@ -17,9 +17,9 @@ selection, OpenClaw dynamic tools, approvals, media delivery, and the visible
transcript mirror.
The normal setup uses canonical OpenAI model refs such as `openai/gpt-5.5`.
Do not configure `openai-codex/gpt-*` model refs. `openai-codex` is the auth
profile provider for Codex OAuth or Codex API-key profiles, not the model
provider prefix for new agent config.
Do not configure `openai-codex/gpt-*` model refs. Put OpenAI agent auth order
under `auth.order.openai`; older `openai-codex:*` profiles and
`auth.order.openai-codex` entries remain supported for existing installs.
OpenClaw starts Codex app-server threads with Codex native code mode and
code-mode-only enabled. That keeps deferred/searchable OpenClaw dynamic tools
@@ -101,21 +101,37 @@ turn resolves the harness from current config.
The quickstart config is the minimum viable Codex harness config. Set Codex
harness options in OpenClaw config, and use the CLI only for Codex auth:
| Need | Set | Where |
| -------------------------------------- | ------------------------------------------------------------------ | ------------------------------ |
| Enable the harness | `plugins.entries.codex.enabled: true` | OpenClaw config |
| Keep an allowlisted plugin install | Include `codex` in `plugins.allow` | OpenClaw config |
| Route OpenAI agent turns through Codex | `agents.defaults.model` or `agents.list[].model` as `openai/gpt-*` | OpenClaw agent config |
| Sign in with Codex OAuth | `openclaw models auth login --provider openai-codex` | CLI auth profile |
| Fail closed when Codex is unavailable | Provider or model `agentRuntime.id: "codex"` | OpenClaw model/provider config |
| Use direct OpenAI API traffic | Provider or model `agentRuntime.id: "pi"` with normal OpenAI auth | OpenClaw model/provider config |
| Tune app-server behavior | `plugins.entries.codex.config.appServer.*` | Codex plugin config |
| Enable native Codex plugin apps | `plugins.entries.codex.config.codexPlugins.*` | Codex plugin config |
| Enable Codex Computer Use | `plugins.entries.codex.config.computerUse.*` | Codex plugin config |
| Need | Set | Where |
| -------------------------------------- | -------------------------------------------------------------------------------- | ---------------------------------- |
| Enable the harness | `plugins.entries.codex.enabled: true` | OpenClaw config |
| Keep an allowlisted plugin install | Include `codex` in `plugins.allow` | OpenClaw config |
| Route OpenAI agent turns through Codex | `agents.defaults.model` or `agents.list[].model` as `openai/gpt-*` | OpenClaw agent config |
| Sign in with Codex OAuth | `openclaw models auth login --provider openai-codex` | CLI auth profile |
| Add API-key backup for Codex runs | `openai:*` API-key profile listed after subscription auth in `auth.order.openai` | CLI auth profile + OpenClaw config |
| Fail closed when Codex is unavailable | Provider or model `agentRuntime.id: "codex"` | OpenClaw model/provider config |
| Use direct OpenAI API traffic | Provider or model `agentRuntime.id: "pi"` with normal OpenAI auth | OpenClaw model/provider config |
| Tune app-server behavior | `plugins.entries.codex.config.appServer.*` | Codex plugin config |
| Enable native Codex plugin apps | `plugins.entries.codex.config.codexPlugins.*` | Codex plugin config |
| Enable Codex Computer Use | `plugins.entries.codex.config.computerUse.*` | Codex plugin config |
Use `openai/gpt-*` model refs for Codex-backed OpenAI agent turns.
`openai-codex` is only the auth-profile provider name for Codex OAuth and
Codex API-key profiles. Do not write new `openai-codex/gpt-*` model refs.
Use `openai/gpt-*` model refs for Codex-backed OpenAI agent turns. Prefer
`auth.order.openai` for subscription-first/API-key-backup ordering. Existing
`openai-codex:*` auth profiles and `auth.order.openai-codex` remain valid, but
do not write new `openai-codex/gpt-*` model refs.
```json5
{
auth: {
order: {
openai: ["openai-codex:user@example.com", "openai:api-key-backup"],
},
},
}
```
In that shape, both profiles still run through Codex for `openai/gpt-*` agent
turns. The API key is only an auth fallback, not a request to switch to PI or
plain OpenAI Responses.
The rest of this page covers common variants users must choose between:
deployment shape, fail-closed routing, guardian approval policy, native Codex
@@ -384,7 +400,8 @@ For upload mechanics and runtime-level diagnostics boundaries, see
Auth is selected in this order:
1. An explicit OpenClaw Codex auth profile for the agent.
1. Ordered OpenAI auth profiles for the agent, preferably under
`auth.order.openai`. Existing `openai-codex:*` profile ids remain valid.
2. The app-server's existing account in that agent's Codex home.
3. For local stdio app-server launches only, `CODEX_API_KEY`, then
`OPENAI_API_KEY`, when no app-server account is present and OpenAI auth is
@@ -399,6 +416,11 @@ login instead of inherited child-process env. WebSocket app-server connections
do not receive Gateway env API-key fallback; use an explicit auth profile or the
remote app-server's own account.
If a subscription profile hits a Codex usage limit, OpenClaw records the reset
time when Codex reports one and tries the next ordered auth profile for the same
Codex run. When the reset time passes, the subscription profile becomes eligible
again without changing the selected `openai/gpt-*` model or Codex runtime.
If a deployment needs additional environment isolation, add those variables to
`appServer.clearEnv`:
+3 -2
View File
@@ -137,8 +137,9 @@ are emitted with `open_world_enabled: true`; OpenClaw does not expose a separate
plugin open-world policy knob and does not maintain per-plugin destructive
tool-name deny lists.
Tool approval mode is prompted by default for plugin apps because OpenClaw does
not have an interactive app-elicitation UI in this same-thread path.
Tool approval mode is automatic by default for plugin apps so non-destructive
read tools can run without a same-thread approval UI. Destructive tools remain
controlled by each app's `destructive_enabled` policy.
## Destructive action policy
+6 -8
View File
@@ -69,16 +69,14 @@ not publish the inspector binary from the main `openclaw` package.
### Maintainer acceptance lane
Use Blacksmith Testbox for the installable-package acceptance lane when validating
the external inspector against OpenClaw plugin packages. Run it from a clean
OpenClaw checkout after the package is built:
Use Crabbox-backed Blacksmith Testbox for the installable-package acceptance
lane when validating the external inspector against OpenClaw plugin packages.
Run it from a clean OpenClaw checkout after the package is built:
```sh
blacksmith testbox warmup ci-check-testbox.yml --ref main --idle-timeout 90
blacksmith testbox run --id <tbx_id> "pnpm install && pnpm build && npm exec --yes @openclaw/plugin-inspector@0.1.0 -- ./extensions/telegram --json"
blacksmith testbox run --id <tbx_id> "npm exec --yes @openclaw/plugin-inspector@0.1.0 -- ./extensions/discord --json"
blacksmith testbox run --id <tbx_id> "npm exec --yes @openclaw/plugin-inspector@0.1.0 -- <clawhub-plugin-dir> --json"
blacksmith testbox stop <tbx_id>
pnpm crabbox:run -- --provider blacksmith-testbox --timing-json --shell -- "pnpm install && pnpm build && npm exec --yes @openclaw/plugin-inspector@0.1.0 -- ./extensions/telegram --json"
pnpm crabbox:run -- --provider blacksmith-testbox --timing-json --shell -- "npm exec --yes @openclaw/plugin-inspector@0.1.0 -- ./extensions/discord --json"
pnpm crabbox:run -- --provider blacksmith-testbox --timing-json --shell -- "npm exec --yes @openclaw/plugin-inspector@0.1.0 -- <clawhub-plugin-dir> --json"
```
Keep this lane opt-in for maintainers because it installs an external npm
+46 -12
View File
@@ -140,18 +140,52 @@ generic contracts; Plan Mode can use them, but so can approval workflows,
workspace policy gates, background monitors, setup wizards, and UI companion
plugins.
| Method | Contract it owns |
| ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |
| `api.registerSessionExtension(...)` | Plugin-owned, JSON-compatible session state projected through Gateway sessions |
| `api.enqueueNextTurnInjection(...)` | Durable exactly-once context injected into the next agent turn for one session |
| `api.registerTrustedToolPolicy(...)` | Bundled/trusted pre-plugin tool policy that can block or rewrite tool params |
| `api.registerToolMetadata(...)` | Tool catalog display metadata without changing the tool implementation |
| `api.registerCommand(...)` | Scoped plugin commands; command results can set `continueAgent: true`; Discord native commands support `descriptionLocalizations` |
| `api.registerControlUiDescriptor(...)` | Control UI contribution descriptors for session, tool, run, or settings surfaces |
| `api.registerRuntimeLifecycle(...)` | Cleanup callbacks for plugin-owned runtime resources on reset/delete/reload paths |
| `api.registerAgentEventSubscription(...)` | Sanitized event subscriptions for workflow state and monitors |
| `api.setRunContext(...)` / `getRunContext(...)` / `clearRunContext(...)` | Per-run plugin scratch state cleared on terminal run lifecycle |
| `api.registerSessionSchedulerJob(...)` | Plugin-owned session scheduler job records with deterministic cleanup |
| Method | Contract it owns |
| ------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |
| `api.session.state.registerSessionExtension(...)` | Plugin-owned, JSON-compatible session state projected through Gateway sessions |
| `api.session.workflow.enqueueNextTurnInjection(...)` | Durable exactly-once context injected into the next agent turn for one session |
| `api.registerTrustedToolPolicy(...)` | Bundled/trusted pre-plugin tool policy that can block or rewrite tool params |
| `api.registerToolMetadata(...)` | Tool catalog display metadata without changing the tool implementation |
| `api.registerCommand(...)` | Scoped plugin commands; command results can set `continueAgent: true`; Discord native commands support `descriptionLocalizations` |
| `api.session.controls.registerControlUiDescriptor(...)` | Control UI contribution descriptors for session, tool, run, or settings surfaces |
| `api.lifecycle.registerRuntimeLifecycle(...)` | Cleanup callbacks for plugin-owned runtime resources on reset/delete/reload paths |
| `api.agent.events.registerAgentEventSubscription(...)` | Sanitized event subscriptions for workflow state and monitors |
| `api.runContext.setRunContext(...)` / `getRunContext(...)` / `clearRunContext(...)` | Per-run plugin scratch state cleared on terminal run lifecycle |
| `api.session.workflow.registerSessionSchedulerJob(...)` | Cleanup metadata for plugin-owned scheduler jobs; does not schedule work or create task records |
| `api.session.workflow.sendSessionAttachment(...)` | Bundled-only host-mediated file attachment delivery to the active direct-outbound session route |
| `api.session.workflow.scheduleSessionTurn(...)` / `unscheduleSessionTurnsByTag(...)` | Bundled-only Cron-backed scheduled session turns plus tag-based cleanup |
| `api.session.controls.registerSessionAction(...)` | Typed session actions clients can dispatch through the Gateway |
Use the grouped namespaces for new plugin code:
- `api.session.state.registerSessionExtension(...)`
- `api.session.workflow.enqueueNextTurnInjection(...)`
- `api.session.workflow.registerSessionSchedulerJob(...)`
- `api.session.workflow.sendSessionAttachment(...)`
- `api.session.workflow.scheduleSessionTurn(...)`
- `api.session.workflow.unscheduleSessionTurnsByTag(...)`
- `api.session.controls.registerSessionAction(...)`
- `api.session.controls.registerControlUiDescriptor(...)`
- `api.agent.events.registerAgentEventSubscription(...)`
- `api.agent.events.emitAgentEvent(...)`
- `api.runContext.setRunContext(...)` / `getRunContext(...)` / `clearRunContext(...)`
- `api.lifecycle.registerRuntimeLifecycle(...)`
The equivalent flat methods remain available as deprecated compatibility
aliases for existing plugins. Do not add new plugin code that calls
`api.registerSessionExtension`, `api.enqueueNextTurnInjection`,
`api.registerControlUiDescriptor`, `api.registerRuntimeLifecycle`,
`api.registerAgentEventSubscription`, `api.emitAgentEvent`,
`api.setRunContext`, `api.getRunContext`, `api.clearRunContext`,
`api.registerSessionSchedulerJob`, `api.registerSessionAction`,
`api.sendSessionAttachment`, `api.scheduleSessionTurn`, or
`api.unscheduleSessionTurnsByTag` directly.
`scheduleSessionTurn(...)` is a session-scoped convenience over the Gateway
Cron scheduler. Cron owns timing and creates the background task record when the
turn runs; the Plugin SDK only constrains the target session, plugin-owned
naming, and cleanup. Use `api.runtime.tasks.managedFlows` inside the scheduled
turn when the work itself needs durable multi-step Task Flow state.
The contracts intentionally split authority:
+33
View File
@@ -217,6 +217,11 @@ Provider and channel execution paths must use the active runtime config snapshot
<Accordion title="api.runtime.tasks.managedFlows">
Bind a Task Flow runtime to an existing OpenClaw session key or trusted tool context, then create and manage Task Flows without passing an owner on every call.
Task Flow tracks durable multi-step workflow state. It is not a scheduler:
use Cron or `api.session.workflow.scheduleSessionTurn(...)` for future
wakeups, then use `managedFlows` from the scheduled turn when that work
needs flow state, child tasks, waits, or cancellation.
```typescript
const taskFlow = api.runtime.tasks.managedFlows.fromToolContext(ctx);
@@ -300,6 +305,34 @@ Provider and channel execution paths must use the active runtime config snapshot
filePath: "/tmp/inbound-file.pdf",
cfg: api.config,
});
// Structured image extraction through a specific provider/model.
// Include at least one image; text inputs are supplemental context.
const evidence = await api.runtime.mediaUnderstanding.extractStructuredWithModel({
provider: "codex",
model: "gpt-5.5",
input: [
{
type: "image",
buffer: receiptImageBuffer,
fileName: "receipt.png",
mime: "image/png",
},
{ type: "text", text: "Prefer the printed total over handwritten notes." },
],
instructions: "Extract vendor, total, and searchable tags.",
schemaName: "receipt.evidence",
jsonSchema: {
type: "object",
properties: {
vendor: { type: "string" },
total: { type: "number" },
tags: { type: "array", items: { type: "string" } },
},
required: ["vendor", "total"],
},
cfg: api.config,
});
```
Returns `{ text: undefined }` when no output is produced (e.g. skipped input).
+1 -1
View File
@@ -306,7 +306,7 @@ focused channel/runtime subpaths, `config-contracts`, `string-coerce-runtime`,
| `plugin-sdk/media-mime` | Narrow MIME normalization, file-extension mapping, MIME detection, and media-kind helpers |
| `plugin-sdk/media-store` | Narrow media store helpers such as `saveMediaBuffer` |
| `plugin-sdk/media-generation-runtime` | Shared media-generation failover helpers, candidate selection, and missing-model messaging |
| `plugin-sdk/media-understanding` | Media understanding provider types plus provider-facing image/audio helper exports |
| `plugin-sdk/media-understanding` | Media understanding provider types plus provider-facing image/audio/structured-extraction helper exports |
| `plugin-sdk/text-chunking` | Text and markdown chunking/render helpers, markdown table conversion, directive-tag stripping, and safe-text utilities |
| `plugin-sdk/text-chunking` | Outbound text chunking helper |
| `plugin-sdk/speech` | Speech provider types plus provider-facing directive, registry, validation, OpenAI-compatible TTS builder, and speech helper exports |
+11 -9
View File
@@ -43,17 +43,19 @@ OpenClaw ships a bundled `fal` provider for hosted image and video generation.
The bundled `fal` image-generation provider defaults to
`fal/fal-ai/flux/dev`.
| Capability | Value |
| -------------- | -------------------------- |
| Max images | 4 per request |
| Edit mode | Enabled, 1 reference image |
| Size overrides | Supported |
| Aspect ratio | Supported |
| Resolution | Supported |
| Output format | `png` or `jpeg` |
| Capability | Value |
| -------------- | ----------------------------------------------------------- |
| Max images | 4 per request |
| Edit mode | Flux: 1 reference image; GPT Image 2: 10; Nano Banana 2: 14 |
| Size overrides | Supported |
| Aspect ratio | Supported for generate and GPT Image 2/Nano Banana 2 edit |
| Resolution | Supported |
| Output format | `png` or `jpeg` |
<Warning>
The fal image edit endpoint does **not** support `aspectRatio` overrides.
Flux image-to-image requests do **not** support `aspectRatio` overrides. GPT
Image 2 and Nano Banana 2 edit requests use fal's `/edit` endpoint and accept
aspect-ratio hints.
</Warning>
Use `outputFormat: "png"` when you want PNG output. fal does not declare an
+2 -2
View File
@@ -226,8 +226,8 @@ The bundled `google` plugin also registers video generation through the shared
- Default video model: `google/veo-3.1-fast-generate-preview`
- Modes: text-to-video, image-to-video, and single-video reference flows
- Supports `aspectRatio`, `resolution`, and `audio`
- Current duration clamp: **4 to 8 seconds**
- Supports `aspectRatio` (`16:9`, `9:16`) and `resolution` (`720P`, `1080P`); audio output is not supported by Veo today
- Supported durations: **4, 6, or 8 seconds** (other values snap to the nearest allowed value)
To use Google as the default video provider:
+72 -39
View File
@@ -17,8 +17,8 @@ default; direct OpenAI API-key auth remains available for non-agent OpenAI
surfaces such as images, embeddings, speech, and realtime.
- **Agent models** - `openai/*` models through the Codex runtime; sign in with
`openai-codex` auth for ChatGPT/Codex subscription use, or configure an
`openai-codex` API-key profile when you intentionally want API-key auth.
Codex auth for ChatGPT/Codex subscription use, or configure a Codex-compatible
OpenAI API-key backup when you intentionally want API-key auth.
- **Non-agent OpenAI APIs** - direct OpenAI Platform access with usage-based
billing through `OPENAI_API_KEY` or OpenAI API-key onboarding.
- **Legacy config** - `openai-codex/*` model refs are repaired by
@@ -32,32 +32,34 @@ changing config.
## Quick choice
| Goal | Use | Notes |
| ---------------------------------------------------- | ------------------------------------------------------- | --------------------------------------------------------------------- |
| ChatGPT/Codex subscription with native Codex runtime | `openai/gpt-5.5` | Default OpenAI agent setup. Sign in with `openai-codex` auth. |
| Direct API-key billing for agent models | `openai/gpt-5.5` plus an `openai-codex` API-key profile | Use `auth.order.openai-codex` to prefer that profile. |
| Direct API-key billing through explicit PI | `openai/gpt-5.5` plus provider/model runtime `pi` | Select a normal `openai` API-key profile. |
| Latest ChatGPT Instant API alias | `openai/chat-latest` | Direct API-key only. Moving alias for experiments, not the default. |
| ChatGPT/Codex subscription auth through explicit PI | `openai/gpt-5.5` plus provider/model runtime `pi` | Select an `openai-codex` auth profile for the compatibility route. |
| Image generation or editing | `openai/gpt-image-2` | Works with either `OPENAI_API_KEY` or OpenAI Codex OAuth. |
| Transparent-background images | `openai/gpt-image-1.5` | Use `outputFormat=png` or `webp` and `openai.background=transparent`. |
| Goal | Use | Notes |
| ---------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------- |
| ChatGPT/Codex subscription with native Codex runtime | `openai/gpt-5.5` | Default OpenAI agent setup. Sign in with Codex auth. |
| Direct API-key billing for agent models | `openai/gpt-5.5` plus a Codex-compatible API-key profile | Use `auth.order.openai` to place the backup after subscription auth. |
| Direct API-key billing through explicit PI | `openai/gpt-5.5` plus provider/model runtime `pi` | Select a normal `openai` API-key profile. |
| Latest ChatGPT Instant API alias | `openai/chat-latest` | Direct API-key only. Moving alias for experiments, not the default. |
| ChatGPT/Codex subscription auth through explicit PI | `openai/gpt-5.5` plus provider/model runtime `pi` | Select an `openai-codex` auth profile for the compatibility route. |
| Image generation or editing | `openai/gpt-image-2` | Works with either `OPENAI_API_KEY` or OpenAI Codex OAuth. |
| Transparent-background images | `openai/gpt-image-1.5` | Use `outputFormat=png` or `webp` and `openai.background=transparent`. |
## Naming map
The names are similar but not interchangeable:
| Name you see | Layer | Meaning |
| --------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------- |
| `openai` | Provider prefix | Canonical OpenAI model route; agent turns use the Codex runtime. |
| `openai-codex` | Auth/profile prefix | OpenAI Codex OAuth/subscription auth profile provider. |
| `codex` plugin | Plugin | Bundled OpenClaw plugin that provides native Codex app-server runtime and `/codex` chat controls. |
| provider/model `agentRuntime.id: codex` | Agent runtime | Force the native Codex app-server harness for matching embedded turns. |
| `/codex ...` | Chat command set | Bind/control Codex app-server threads from a conversation. |
| `runtime: "acp", agentId: "codex"` | ACP session route | Explicit fallback path that runs Codex through ACP/acpx. |
| Name you see | Layer | Meaning |
| --------------------------------------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `openai` | Provider prefix | Canonical OpenAI model route; agent turns use the Codex runtime. |
| `openai-codex` | Legacy auth/profile prefix | Older OpenAI Codex OAuth/subscription profile namespace. Existing profiles and `auth.order.openai-codex` still work. |
| `codex` plugin | Plugin | Bundled OpenClaw plugin that provides native Codex app-server runtime and `/codex` chat controls. |
| provider/model `agentRuntime.id: codex` | Agent runtime | Force the native Codex app-server harness for matching embedded turns. |
| `/codex ...` | Chat command set | Bind/control Codex app-server threads from a conversation. |
| `runtime: "acp", agentId: "codex"` | ACP session route | Explicit fallback path that runs Codex through ACP/acpx. |
This means a config can intentionally contain both `openai/*` model refs and
`openai-codex` auth profiles. `openclaw doctor --fix` rewrites legacy
`openai-codex/*` model refs to the canonical OpenAI model route.
This means a config can intentionally contain `openai/*` model refs while auth
profiles still point at Codex-compatible credentials. Prefer `auth.order.openai`
for new config; existing `openai-codex:*` profiles and `auth.order.openai-codex`
remain supported. `openclaw doctor --fix` rewrites legacy `openai-codex/*` model
refs to the canonical OpenAI model route.
<Note>
GPT-5.5 is available through both direct OpenAI Platform API-key access and
@@ -152,15 +154,16 @@ Choose your preferred auth method and follow the setup steps.
| Model ref | Runtime config | Route | Auth |
| ---------------------- | -------------------------- | --------------------------- | ---------------- |
| `openai/gpt-5.5` | omitted / provider/model `agentRuntime.id: "codex"` | Codex app-server harness | `openai-codex` profile |
| `openai/gpt-5.4-mini` | omitted / provider/model `agentRuntime.id: "codex"` | Codex app-server harness | `openai-codex` profile |
| `openai/gpt-5.5` | omitted / provider/model `agentRuntime.id: "codex"` | Codex app-server harness | Codex-compatible OpenAI profile |
| `openai/gpt-5.4-mini` | omitted / provider/model `agentRuntime.id: "codex"` | Codex app-server harness | Codex-compatible OpenAI profile |
| `openai/gpt-5.5` | provider/model `agentRuntime.id: "pi"` | PI embedded runtime | `openai` profile or selected `openai-codex` profile |
<Note>
`openai/*` agent models use the Codex app-server harness. To use API-key
auth for an agent model, create an `openai-codex` API-key profile and order
it with `auth.order.openai-codex`; `OPENAI_API_KEY` remains the direct
fallback for non-agent OpenAI API surfaces.
auth for an agent model, create a Codex-compatible API-key profile and order
it with `auth.order.openai`; `OPENAI_API_KEY` remains the direct fallback for
non-agent OpenAI API surfaces. Older `auth.order.openai-codex` entries still
work.
</Note>
### Config example
@@ -239,7 +242,7 @@ Choose your preferred auth method and follow the setup steps.
| Model ref | Runtime config | Route | Auth |
|-----------|----------------|-------|------|
| `openai/gpt-5.5` | omitted / provider/model `agentRuntime.id: "codex"` | Native Codex app-server harness | Codex sign-in or selected `openai-codex` profile |
| `openai/gpt-5.5` | omitted / provider/model `agentRuntime.id: "codex"` | Native Codex app-server harness | Codex sign-in or ordered `openai` auth profile |
| `openai/gpt-5.5` | provider/model `agentRuntime.id: "pi"` | PI embedded runtime with internal Codex-auth transport | Selected `openai-codex` profile |
| `openai-codex/gpt-5.5` | repaired by doctor | Legacy route rewritten to `openai/gpt-5.5` | Existing `openai-codex` profile |
@@ -251,10 +254,11 @@ Choose your preferred auth method and follow the setup steps.
</Warning>
<Note>
Keep using the `openai-codex` provider id for auth/profile commands. The
`openai-codex/*` model prefix is legacy config repaired by doctor. For the
common subscription plus native runtime setup, sign in with `openai-codex`
but keep the model ref as `openai/gpt-5.5`.
The `openai-codex/*` model prefix is legacy config repaired by doctor. For
the common subscription plus native runtime setup, sign in with Codex auth
but keep the model ref as `openai/gpt-5.5`. New config should put OpenAI
agent auth order under `auth.order.openai`; older `auth.order.openai-codex`
entries remain valid.
</Note>
### Config example
@@ -270,6 +274,29 @@ Choose your preferred auth method and follow the setup steps.
}
```
With an API-key backup, keep the model on `openai/gpt-5.5` and put the
auth order under `openai`. OpenClaw will try the subscription first, then
the API key, while staying on the Codex harness:
```json5
{
plugins: { entries: { codex: { enabled: true } } },
agents: {
defaults: {
model: { primary: "openai/gpt-5.5" },
},
},
auth: {
order: {
openai: [
"openai-codex:user@example.com",
"openai:api-key-backup",
],
},
},
}
```
<Note>
Onboarding no longer imports OAuth material from `~/.codex`. Sign in with browser OAuth (default) or the device-code flow above — OpenClaw manages the resulting credentials in its own agent auth store.
</Note>
@@ -309,8 +336,9 @@ Choose your preferred auth method and follow the setup steps.
openclaw models status --probe --probe-provider openai-codex
```
`openai-codex` remains the auth/profile provider id. `openai/*` is the
model route for OpenAI agent turns through Codex.
`openai/*` is the model route for OpenAI agent turns through Codex. The
`openai-codex` auth/profile provider id remains accepted for existing
profiles and CLI listing.
### Status indicator
@@ -366,11 +394,12 @@ Choose your preferred auth method and follow the setup steps.
## Native Codex app-server auth
The native Codex app-server harness uses `openai/*` model refs plus omitted
runtime config or provider/model `agentRuntime.id: "codex"`, but its auth is still
account-based. OpenClaw
selects auth in this order:
runtime config or provider/model `agentRuntime.id: "codex"`, but its auth is
still account-based. OpenClaw selects auth in this order:
1. An explicit OpenClaw `openai-codex` auth profile bound to the agent.
1. Ordered OpenAI auth profiles for the agent, preferably under
`auth.order.openai`. Existing `openai-codex:*` profiles and
`auth.order.openai-codex` remain valid for older installs.
2. The app-server's existing account, such as a local Codex CLI ChatGPT sign-in.
3. For local stdio app-server launches only, `CODEX_API_KEY`, then
`OPENAI_API_KEY`, when the app-server reports no account and still requires
@@ -382,7 +411,11 @@ or embeddings. Env API-key fallback is only the local stdio no-account path; it
is not sent to WebSocket app-server connections. When a subscription-style Codex
profile is selected, OpenClaw also keeps `CODEX_API_KEY` and `OPENAI_API_KEY`
out of the spawned stdio app-server child and sends the selected credentials
through the app-server login RPC.
through the app-server login RPC. When that subscription profile is blocked by a
Codex usage limit, OpenClaw can rotate to the next ordered `openai:*` API-key
profile without changing the selected model or dropping out of the Codex
harness. Once the subscription reset time passes, the subscription profile is
eligible again.
## Image generation
+1 -1
View File
@@ -45,7 +45,7 @@ only when Package Acceptance should intentionally prove a different package.
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Target resolution | **Job:** `Resolve target ref`<br />**Child workflow:** none<br />**Proves:** resolves the release branch, tag, or full commit SHA and records selected inputs.<br />**Rerun:** rerun the umbrella if this fails. |
| Vitest and normal CI | **Job:** `Run normal full CI`<br />**Child workflow:** `CI`<br />**Proves:** manual full CI graph against the target ref, including Linux Node lanes, bundled plugin shards, channel contracts, Node 22 compatibility, `check`, `check-additional`, build smoke, docs checks, Python skills, Windows, macOS, Control UI i18n, and Android via the umbrella.<br />**Rerun:** `rerun_group=ci`. |
| Plugin prerelease | **Job:** `Run plugin prerelease validation`<br />**Child workflow:** `Plugin Prerelease`<br />**Proves:** release-only plugin static checks, agentic plugin coverage, full extension batch shards, and plugin prerelease Docker lanes.<br />**Rerun:** `rerun_group=plugin-prerelease`. |
| Plugin prerelease | **Job:** `Run plugin prerelease validation`<br />**Child workflow:** `Plugin Prerelease`<br />**Proves:** release-only plugin static checks, agentic plugin coverage, full extension batch shards, plugin prerelease Docker lanes, and a non-blocking `plugin-inspector-advisory` artifact for compatibility triage.<br />**Rerun:** `rerun_group=plugin-prerelease`. |
| Release checks | **Job:** `Run release/live/Docker/QA validation`<br />**Child workflow:** `OpenClaw Release Checks`<br />**Proves:** install smoke, cross-OS package checks, Package Acceptance, QA Lab parity, live Matrix, and live Telegram. With `run_release_soak=true` or `release_profile=full`, also runs exhaustive live/E2E suites and Docker release-path chunks.<br />**Rerun:** `rerun_group=release-checks` or a narrower release-checks handle. |
| Package artifact | **Job:** `Prepare release package artifact`<br />**Child workflow:** none<br />**Proves:** creates the parent `release-package-under-test` tarball early enough for package-facing checks that do not need to wait for `OpenClaw Release Checks`.<br />**Rerun:** rerun the umbrella or provide `release_package_spec` for published-package reruns. |
| Package Telegram | **Job:** `Run package Telegram E2E`<br />**Child workflow:** `NPM Telegram Beta E2E`<br />**Proves:** parent-artifact-backed Telegram package proof for `rerun_group=all` with `release_profile=full`, or published-package Telegram proof when `release_package_spec` or `npm_telegram_package_spec` is set.<br />**Rerun:** `rerun_group=npm-telegram` with `release_package_spec` or `npm_telegram_package_spec`. |
@@ -200,7 +200,7 @@ The store is safe to edit, but the Gateway is the authority: it may rewrite or r
## Transcript structure (`*.jsonl`)
Transcripts are managed by `@mariozechner/pi-coding-agent`'s `SessionManager`.
Transcripts are managed by `@earendil-works/pi-coding-agent`'s `SessionManager`.
The file is JSONL:
@@ -376,6 +376,7 @@ You can observe compaction and session state via:
- `/status` (in any chat session)
- `openclaw status` (CLI)
- `openclaw sessions` / `sessions --json`
- Gateway logs (`pnpm gateway:watch` or `openclaw logs --follow`): `embedded run auto-compaction start` + `complete`
- Verbose mode: `🧹 Auto-compaction complete` + compaction count
---
+22 -14
View File
@@ -120,13 +120,24 @@ Example:
```json5
{
logging: { level: "info" },
agent: {
model: "anthropic/claude-opus-4-6",
workspace: "~/.openclaw/workspace",
thinkingDefault: "high",
timeoutSeconds: 1800,
// Start with 0; enable later.
heartbeat: { every: "0m" },
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-6" },
workspace: "~/.openclaw/workspace",
thinkingDefault: "high",
timeoutSeconds: 1800,
// Start with 0; enable later.
heartbeat: { every: "0m" },
},
list: [
{
id: "main",
default: true,
groupChat: {
mentionPatterns: ["@openclaw", "openclaw"],
},
},
],
},
channels: {
whatsapp: {
@@ -136,11 +147,6 @@ Example:
},
},
},
routing: {
groupChat: {
mentionPatterns: ["@openclaw", "openclaw"],
},
},
session: {
scope: "per-sender",
resetTriggers: ["/new", "/reset"],
@@ -174,8 +180,10 @@ Set `agents.defaults.heartbeat.every: "0m"` to disable.
```json5
{
agent: {
heartbeat: { every: "30m" },
agents: {
defaults: {
heartbeat: { every: "30m" },
},
},
}
```
+7 -1
View File
@@ -104,7 +104,13 @@ browser binaries as shown below.
#### Docker Playwright install
If your Gateway runs in Docker, avoid `npx playwright` (npm override conflicts).
Use the bundled CLI instead:
For custom images, bake Chromium into the image:
```bash
OPENCLAW_INSTALL_BROWSER=1 ./scripts/docker/setup.sh
```
For an existing image, install through the bundled CLI instead:
```bash
docker compose run --rm openclaw-cli \
+3 -3
View File
@@ -62,13 +62,13 @@ If the agent is sandboxed, the browser tool defaults to the sandbox. To allow ho
}
```
Then target the host browser:
Then open the host browser yourself (CLI invocations always run against the host browser):
```bash
openclaw browser open https://x.com --browser-profile openclaw --target host
openclaw browser open https://x.com --browser-profile openclaw
```
Or disable sandboxing for the agent that posts updates.
The agent's `browser` tool calls can then target the host once `sandbox.browser.allowHostControl: true` is set. Alternatively, disable sandboxing for the agent that posts updates.
## Related
+11 -4
View File
@@ -23,7 +23,7 @@ When you send:
OpenClaw:
1. snapshots the current session context,
2. runs a separate **tool-less** model call,
2. runs a separate ephemeral side query,
3. answers only the side question,
4. leaves the main run alone,
5. does **not** write the BTW question or answer to session history,
@@ -33,17 +33,24 @@ The important mental model is:
- same session context
- separate one-shot side query
- no tool calls
- same native harness transport when the session uses a native harness
- no future context pollution
- no transcript persistence
For Codex harness sessions, BTW stays inside Codex by forking the active
app-server thread as an ephemeral side thread. That keeps Codex OAuth and native
thread behavior intact while still isolating the side answer from the parent
transcript. Like Codex `/side`, the side thread keeps the current Codex
permissions and native tool surface, with guardrails that tell the model not to
treat inherited parent-thread work as active instructions. Non-Codex runtimes
keep the older direct one-shot path.
## What it does not do
`/btw` does **not**:
- create a new durable session,
- continue the unfinished main task,
- run tools or agent tool loops,
- write BTW question/answer data to transcript history,
- appear in `chat.history`,
- survive a reload.
@@ -60,7 +67,7 @@ explicitly telling the model:
- answer only the side question,
- do not resume or complete the unfinished main task,
- do not emit tool calls or pseudo-tool calls.
- do not steer the parent conversation.
That keeps BTW isolated from the main run while still making it aware of what
the session is about.
+11 -9
View File
@@ -90,7 +90,7 @@ backend emits it.
| ---------- | --------------------------------------- | ---------------------------------- | ----------------------------------------------------- |
| ComfyUI | `workflow` | Yes (1 image, workflow-configured) | `COMFY_API_KEY` or `COMFY_CLOUD_API_KEY` for cloud |
| DeepInfra | `black-forest-labs/FLUX-1-schnell` | Yes (1 image) | `DEEPINFRA_API_KEY` |
| fal | `fal-ai/flux/dev` | Yes | `FAL_KEY` |
| fal | `fal-ai/flux/dev` | Yes (model-specific limits) | `FAL_KEY` |
| Google | `gemini-3.1-flash-image-preview` | Yes | `GEMINI_API_KEY` or `GOOGLE_API_KEY` |
| LiteLLM | `gpt-image-2` | Yes (up to 5 input images) | `LITELLM_API_KEY` |
| MiniMax | `image-01` | Yes (subject reference) | `MINIMAX_API_KEY` or MiniMax OAuth (`minimax-portal`) |
@@ -107,13 +107,13 @@ Use `action: "list"` to inspect available providers and models at runtime:
## Provider capabilities
| Capability | ComfyUI | DeepInfra | fal | Google | MiniMax | OpenAI | Vydra | xAI |
| --------------------- | ------------------ | --------- | ----------------- | -------------- | --------------------- | -------------- | ----- | -------------- |
| Generate (max count) | Workflow-defined | 4 | 4 | 4 | 9 | 4 | 1 | 4 |
| Edit / reference | 1 image (workflow) | 1 image | 1 image | Up to 5 images | 1 image (subject ref) | Up to 5 images | - | Up to 5 images |
| Size control | - | ✓ | ✓ | ✓ | - | Up to 4K | - | - |
| Aspect ratio | - | - | ✓ (generate only) | ✓ | ✓ | - | - | ✓ |
| Resolution (1K/2K/4K) | - | - | ✓ | ✓ | - | - | - | 1K, 2K |
| Capability | ComfyUI | DeepInfra | fal | Google | MiniMax | OpenAI | Vydra | xAI |
| --------------------- | ------------------ | --------- | ------------------------- | -------------- | --------------------- | -------------- | ----- | -------------- |
| Generate (max count) | Workflow-defined | 4 | 4 | 4 | 9 | 4 | 1 | 4 |
| Edit / reference | 1 image (workflow) | 1 image | Flux: 1; GPT: 10; NB2: 14 | Up to 5 images | 1 image (subject ref) | Up to 5 images | - | Up to 5 images |
| Size control | - | ✓ | ✓ | ✓ | - | Up to 4K | - | - |
| Aspect ratio | - | - | ✓ | ✓ | ✓ | - | - | ✓ |
| Resolution (1K/2K/4K) | - | - | ✓ | ✓ | - | - | - | 1K, 2K |
## Tool parameters
@@ -241,7 +241,9 @@ reference images. Pass a reference image path or URL:
```
OpenAI, OpenRouter, Google, and xAI support up to 5 reference images via the
`images` parameter. fal, MiniMax, and ComfyUI support 1.
`images` parameter. fal supports 1 reference image for Flux image-to-image, up
to 10 for GPT Image 2 edits, and up to 14 for Nano Banana 2 edits. MiniMax and
ComfyUI support 1.
## Provider deep dives
+4 -4
View File
@@ -89,10 +89,10 @@ can satisfy the MiniMax Search bearer credential.
## Supported parameters
MiniMax Search supports:
- `query`
- `count` (OpenClaw trims the returned result list to the requested count)
| Parameter | Type | Constraints | Description |
| --------- | ------- | ----------- | --------------------------------------------------------------------------- |
| `query` | string | required | Search query string. |
| `count` | integer | 1-10 | Number of results to return. OpenClaw trims the returned list to this size. |
Provider-specific filters are not currently supported.
+9 -3
View File
@@ -50,8 +50,14 @@ Auth is scoped by agent: each agent has its own `agentDir` auth store at `~/.ope
"scope": "agent"
},
"tools": {
"allow": ["read"],
"deny": ["exec", "write", "edit", "apply_patch", "process", "browser"]
"allow": ["read", "message"],
"deny": ["exec", "write", "edit", "apply_patch", "process", "browser"],
"message": {
"crossContext": {
"allowWithinProvider": false,
"allowAcrossProviders": false
}
}
}
}
]
@@ -75,7 +81,7 @@ Auth is scoped by agent: each agent has its own `agentDir` auth store at `~/.ope
**Result:**
- `main` agent: runs on host, full tool access.
- `family` agent: runs in Docker (one container per agent), only `read` tool.
- `family` agent: runs in Docker (one container per agent), only `read` and current-conversation message sends.
</Accordion>
<Accordion title="Example 2: Work agent with shared sandbox">
+3 -1
View File
@@ -463,7 +463,9 @@ Examples:
Unlike normal chat:
- it uses the current session as background context,
- it runs as a separate **tool-less** one-shot call,
- in Codex harness sessions, it runs as an ephemeral Codex side thread with the
current Codex permissions and native tool surface,
- in non-Codex sessions, it keeps the older direct one-shot side-call behavior,
- it does not change future session context,
- it is not written to transcript history,
- it is delivered as a live side result instead of a normal assistant message.
+15 -6
View File
@@ -7,9 +7,14 @@ read_when:
- You are implementing or debugging tool discovery for PI runs
---
Tool Search gives PI agents one compact way to discover and call large tool
catalogs. It is useful when the run has many available tools but the model is
likely to need only a few of them.
Tool Search is an experimental OpenClaw PI-agent feature. It gives PI agents one
compact way to discover and call large tool catalogs. It is useful when the run
has many available tools but the model is likely to need only a few of them.
This page documents OpenClaw PI Tool Search. It is not the Codex-native tool
search or dynamic-tools surface. Codex-native code mode, tool search, deferred
dynamic tools, and nested tool calls are stable Codex harness surfaces and do
not depend on `tools.toolSearch`.
When enabled for PI, the model receives one `tool_search_code` tool by default.
That tool runs a short JavaScript body in an isolated Node subprocess with an
@@ -29,9 +34,10 @@ client-provided tools. The model does not see every full schema up front.
Instead, it searches compact descriptors, describes one selected tool when it
needs the exact schema, and calls that tool through OpenClaw.
Codex harness runs do not receive these OpenClaw Tool Search controls. OpenClaw
passes product capabilities to Codex as dynamic tools, and Codex owns native
code mode, native tool search, deferred dynamic tools, and nested tool calls.
Codex harness runs do not receive these experimental OpenClaw Tool Search
controls. OpenClaw passes product capabilities to Codex as dynamic tools, and
Codex owns the stable native code mode, native tool search, deferred dynamic
tools, and nested tool calls.
## How a turn runs
@@ -64,6 +70,9 @@ shape the model sees. If the current runtime cannot launch the isolated Node
code-mode child process, the default `code` mode falls back to `tools` before
catalog compaction.
Both modes are experimental. Prefer direct tool exposure for small PI tool
catalogs, and prefer the Codex-native stable surfaces for Codex harness runs.
There is no separate source-selection config. When Tool Search is enabled, the
catalog includes eligible OpenClaw, MCP, and client tools after normal policy
filtering.
+14 -9
View File
@@ -76,6 +76,17 @@ function expectWrapperToContainPathSuffix(wrapper: string, pathSuffix: string[])
}
}
async function expectPathMissing(targetPath: string): Promise<void> {
let error: unknown;
try {
await fs.access(targetPath);
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(Error);
expect((error as NodeJS.ErrnoException).code).toBe("ENOENT");
}
afterEach(async () => {
vi.restoreAllMocks();
restoreEnv("CODEX_HOME");
@@ -121,9 +132,7 @@ describe("prepareAcpxCodexAuthConfig", () => {
const wrapper = await fs.readFile(generated.wrapperPath, "utf8");
expect(wrapper).toContain(JSON.stringify(installedBinPath));
expect(wrapper).toContain("defaultArgs = [installedBinPath]");
await expect(
fs.access(path.join(agentDir, "acp-auth", "codex", "auth.json")),
).rejects.toMatchObject({ code: "ENOENT" });
await expectPathMissing(path.join(agentDir, "acp-auth", "codex", "auth.json"));
});
it("keeps generated wrappers usable when chmod is rejected by the state filesystem", async () => {
@@ -373,12 +382,8 @@ describe("prepareAcpxCodexAuthConfig", () => {
const wrapper = await fs.readFile(generated.wrapperPath, "utf8");
expect(wrapper).toContain("CODEX_HOME: codexHome");
expect(wrapper).not.toContain(sourceCodexHome);
await expect(
fs.access(path.join(agentDir, "acp-auth", "codex-source", "auth.json")),
).rejects.toMatchObject({ code: "ENOENT" });
await expect(
fs.access(path.join(agentDir, "acp-auth", "codex", "auth.json")),
).rejects.toMatchObject({ code: "ENOENT" });
await expectPathMissing(path.join(agentDir, "acp-auth", "codex-source", "auth.json"));
await expectPathMissing(path.join(agentDir, "acp-auth", "codex", "auth.json"));
});
it("copies only trusted Codex project declarations into the isolated Codex home", async () => {
@@ -64,6 +64,17 @@ function rewriteLine(line, mcpServers) {
}
}
export function createTargetSpawnOptions(platform = process.platform) {
const options = {
stdio: ["pipe", "pipe", "inherit"],
env: process.env,
};
if (platform === "win32") {
options.windowsHide = true;
}
return options;
}
function isMainModule() {
const mainPath = process.argv[1];
if (!mainPath) {
@@ -75,10 +86,7 @@ function isMainModule() {
function main() {
const { targetCommand, mcpServers } = decodePayload(process.argv.slice(2));
const target = splitCommandLine(targetCommand);
const child = spawn(target.command, target.args, {
stdio: ["pipe", "pipe", "inherit"],
env: process.env,
});
const child = spawn(target.command, target.args, createTargetSpawnOptions());
if (!child.stdin || !child.stdout) {
throw new Error("Failed to create MCP proxy stdio pipes");
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { bundledPluginFile } from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, describe, expect, it } from "vitest";
@@ -28,6 +29,21 @@ afterEach(async () => {
});
describe("mcp-proxy", () => {
it("hides the target MCP process window on Windows only", async () => {
const moduleUrl = pathToFileURL(proxyPath).href;
const { createTargetSpawnOptions } = (await import(moduleUrl)) as {
createTargetSpawnOptions: (platform?: NodeJS.Platform) => Record<string, unknown>;
};
expect(createTargetSpawnOptions("win32")).toEqual({
env: process.env,
stdio: ["pipe", "pipe", "inherit"],
windowsHide: true,
});
expect(createTargetSpawnOptions("darwin")).not.toHaveProperty("windowsHide");
expect(createTargetSpawnOptions("linux")).not.toHaveProperty("windowsHide");
});
it("injects configured MCP servers into ACP session bootstrap requests", async () => {
const echoServerPath = await makeTempScript(
"echo-server.cjs",
+14 -19
View File
@@ -667,20 +667,17 @@ describe("AcpxRuntime fresh reset wrapper", () => {
expect(leaseStore.store.save).toHaveBeenCalledTimes(2);
const leases = Array.from(leaseStore.leases.values());
expect(leases).toHaveLength(1);
expect(leases[0]).toMatchObject({
gatewayInstanceId: "gateway-test",
sessionKey: "agent:codex:acp:binding:test",
rootPid: 777,
state: "open",
wrapperPath: "/tmp/openclaw/acpx/codex-acp-wrapper.mjs",
});
const lease = leases[0];
expect(lease?.gatewayInstanceId).toBe("gateway-test");
expect(lease?.sessionKey).toBe("agent:codex:acp:binding:test");
expect(lease?.rootPid).toBe(777);
expect(lease?.state).toBe("open");
expect(lease?.wrapperPath).toBe("/tmp/openclaw/acpx/codex-acp-wrapper.mjs");
expect(launchCommands[0]).toContain("OPENCLAW_ACPX_LEASE_ID=");
expect(launchCommands[0]).toContain("OPENCLAW_GATEWAY_INSTANCE_ID=gateway-test");
expect(savedRecords[0]?.agentCommand).toBe(CODEX_ACP_WRAPPER_COMMAND);
expect(savedRecords[0]).toMatchObject({
openclawGatewayInstanceId: "gateway-test",
openclawLeaseId: leases[0]?.leaseId,
});
expect(savedRecords[0]?.openclawGatewayInstanceId).toBe("gateway-test");
expect(savedRecords[0]?.openclawLeaseId).toBe(lease?.leaseId);
});
it("keeps reusable persistent ACP launch commands stable across ensures", async () => {
@@ -757,10 +754,9 @@ describe("AcpxRuntime fresh reset wrapper", () => {
openclawWrapperRoot: "/tmp/openclaw/acpx",
});
await expect(wrappedStore.load("agent:codex:acp:binding:test")).resolves.toMatchObject({
openclawGatewayInstanceId: "gateway-test",
openclawLeaseId: "lease-loaded",
});
const loadedRecord = await wrappedStore.load("agent:codex:acp:binding:test");
expect(loadedRecord?.openclawGatewayInstanceId).toBe("gateway-test");
expect(loadedRecord?.openclawLeaseId).toBe("lease-loaded");
});
it("merges the lease for the current ACPX session process when old leases exist", async () => {
@@ -801,10 +797,9 @@ describe("AcpxRuntime fresh reset wrapper", () => {
openclawWrapperRoot: "/tmp/openclaw/acpx",
});
await expect(wrappedStore.load("agent:codex:acp:binding:test")).resolves.toMatchObject({
openclawGatewayInstanceId: "gateway-test",
openclawLeaseId: "lease-current",
});
const loadedRecord = await wrappedStore.load("agent:codex:acp:binding:test");
expect(loadedRecord?.openclawGatewayInstanceId).toBe("gateway-test");
expect(loadedRecord?.openclawLeaseId).toBe("lease-current");
});
it("uses matching leases before legacy pid cleanup on close", async () => {
@@ -1,4 +1,4 @@
import type { Api, Model } from "@mariozechner/pi-ai";
import type { Api, Model } from "@earendil-works/pi-ai";
import { describe, expect, it, vi } from "vitest";
import {
createMantleAnthropicStreamFn,
@@ -30,6 +30,13 @@ function createTestDeps() {
};
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`Expected ${label} to be an object`);
}
return value as Record<string, unknown>;
}
describe("createMantleAnthropicStreamFn", () => {
it("uses authToken bearer auth for Mantle Anthropic requests", () => {
const stream = { kind: "anthropic-stream" };
@@ -46,31 +53,24 @@ describe("createMantleAnthropicStreamFn", () => {
});
expect(result).toBe(stream);
expect(deps.createClient).toHaveBeenCalledWith(
expect.objectContaining({
apiKey: null,
authToken: "bedrock-bearer-token",
baseURL: "https://bedrock-mantle.us-east-1.api.aws/anthropic",
defaultHeaders: expect.objectContaining({
accept: "application/json",
"anthropic-beta": "fine-grained-tool-streaming-2025-05-14",
"X-Test": "model-header",
"X-Caller": "caller-header",
}),
}),
);
expect(deps.stream).toHaveBeenCalledWith(
model,
context,
expect.objectContaining({
client: expect.objectContaining({
options: expect.objectContaining({
authToken: "bedrock-bearer-token",
}),
}),
thinkingEnabled: false,
}),
const clientOptions = requireRecord(deps.createClient.mock.calls[0]?.[0], "client options");
expect(clientOptions.apiKey).toBeNull();
expect(clientOptions.authToken).toBe("bedrock-bearer-token");
expect(clientOptions.baseURL).toBe("https://bedrock-mantle.us-east-1.api.aws/anthropic");
const defaultHeaders = requireRecord(clientOptions.defaultHeaders, "default headers");
expect(defaultHeaders.accept).toBe("application/json");
expect(defaultHeaders["anthropic-beta"]).toBe("fine-grained-tool-streaming-2025-05-14");
expect(defaultHeaders["X-Test"]).toBe("model-header");
expect(defaultHeaders["X-Caller"]).toBe("caller-header");
expect(deps.stream.mock.calls[0]?.[0]).toBe(model);
expect(deps.stream.mock.calls[0]?.[1]).toBe(context);
const streamOptions = requireRecord(deps.stream.mock.calls[0]?.[2], "stream options");
const client = requireRecord(streamOptions.client, "stream client");
expect(requireRecord(client.options, "stream client options").authToken).toBe(
"bedrock-bearer-token",
);
expect(streamOptions.thinkingEnabled).toBe(false);
});
it("omits unsupported Opus 4.7 sampling and reasoning overrides", () => {
@@ -85,14 +85,11 @@ describe("createMantleAnthropicStreamFn", () => {
reasoning: "high",
});
expect(deps.stream).toHaveBeenCalledWith(
model,
context,
expect.objectContaining({
temperature: undefined,
thinkingEnabled: false,
}),
);
expect(deps.stream.mock.calls[0]?.[0]).toBe(model);
expect(deps.stream.mock.calls[0]?.[1]).toBe(context);
const streamOptions = requireRecord(deps.stream.mock.calls[0]?.[2], "stream options");
expect(streamOptions.temperature).toBeUndefined();
expect(streamOptions.thinkingEnabled).toBe(false);
});
it("normalizes Mantle provider URLs to the Anthropic endpoint", () => {
@@ -1,7 +1,7 @@
import Anthropic from "@anthropic-ai/sdk";
import type { StreamFn } from "@mariozechner/pi-agent-core";
import type { Api, Model, SimpleStreamOptions } from "@mariozechner/pi-ai";
import { streamAnthropic } from "@mariozechner/pi-ai/anthropic";
import type { StreamFn } from "@earendil-works/pi-agent-core";
import type { Api, Model, SimpleStreamOptions } from "@earendil-works/pi-ai";
import { streamAnthropic } from "@earendil-works/pi-ai/anthropic";
const MANTLE_ANTHROPIC_BETA = "fine-grained-tool-streaming-2025-05-14";
type AnthropicOptions = ConstructorParameters<typeof Anthropic>[0];
@@ -7,7 +7,7 @@
"dependencies": {
"@anthropic-ai/sdk": "0.95.1",
"@aws/bedrock-token-generator": "^1.1.0",
"@mariozechner/pi-ai": "0.73.1"
"@earendil-works/pi-ai": "0.74.0"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
+1 -1
View File
@@ -8,7 +8,7 @@
"@aws-sdk/client-bedrock": "3.1045.0",
"@aws-sdk/client-bedrock-runtime": "3.1045.0",
"@aws-sdk/credential-provider-node": "3.972.39",
"@mariozechner/pi-ai": "0.73.1",
"@earendil-works/pi-ai": "0.74.0",
"@smithy/shared-ini-file-loader": "4.4.9"
},
"devDependencies": {
@@ -1,5 +1,5 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import { streamSimple } from "@mariozechner/pi-ai";
import type { StreamFn } from "@earendil-works/pi-agent-core";
import { streamSimple } from "@earendil-works/pi-ai";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolvePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
@@ -122,7 +122,7 @@ function createGuardrailWrapStreamFn(
/**
* Mirrors the shipped pi-ai Bedrock `supportsPromptCaching` matcher.
* Keep this in sync with node_modules/@mariozechner/pi-ai/dist/providers/amazon-bedrock.js.
* Keep this in sync with node_modules/@earendil-works/pi-ai/dist/providers/amazon-bedrock.js.
*/
function matchesPiAiPromptCachingModelId(modelId: string): boolean {
const id = modelId.toLowerCase();
+1 -1
View File
@@ -1,4 +1,4 @@
import { createAssistantMessageEventStream, type Model } from "@mariozechner/pi-ai";
import { createAssistantMessageEventStream, type Model } from "@earendil-works/pi-ai";
import { beforeAll, describe, expect, it, vi } from "vitest";
import type { AnthropicVertexStreamDeps } from "./stream-runtime.js";
+1 -1
View File
@@ -1,4 +1,4 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import type { StreamFn } from "@earendil-works/pi-agent-core";
import type { AnthropicVertexStreamDeps } from "./stream-runtime.js";
export {
+11 -12
View File
@@ -69,18 +69,17 @@ describe("anthropic-vertex provider plugin", () => {
}),
} as never);
expect(result).toEqual({
provider: {
api: "anthropic-messages",
apiKey: "gcp-vertex-credentials",
baseUrl: "https://europe-west4-aiplatform.googleapis.com",
headers: { "x-test-header": "1" },
models: [
expect.objectContaining({ id: "claude-opus-4-6" }),
expect.objectContaining({ id: "claude-sonnet-4-6" }),
],
},
});
if (!result || !("provider" in result)) {
throw new Error("expected single provider catalog result");
}
expect(result.provider.api).toBe("anthropic-messages");
expect(result.provider.apiKey).toBe("gcp-vertex-credentials");
expect(result.provider.baseUrl).toBe("https://europe-west4-aiplatform.googleapis.com");
expect(result.provider.headers).toEqual({ "x-test-header": "1" });
expect(result.provider.models.map((model) => model.id)).toEqual([
"claude-opus-4-6",
"claude-sonnet-4-6",
]);
});
it("owns Anthropic-style replay policy", async () => {
+2 -2
View File
@@ -6,8 +6,8 @@
"type": "module",
"dependencies": {
"@anthropic-ai/vertex-sdk": "^0.16.0",
"@mariozechner/pi-agent-core": "0.73.1",
"@mariozechner/pi-ai": "0.73.1"
"@earendil-works/pi-agent-core": "0.74.0",
"@earendil-works/pi-ai": "0.74.0"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
@@ -1,4 +1,4 @@
import { createAssistantMessageEventStream, type Model } from "@mariozechner/pi-ai";
import { createAssistantMessageEventStream, type Model } from "@earendil-works/pi-ai";
import { beforeAll, describe, expect, it, vi } from "vitest";
import type { AnthropicVertexStreamDeps } from "./stream-runtime.js";
@@ -141,13 +141,10 @@ describe("createAnthropicVertexStreamFn", () => {
void streamFn(model, { messages: [] }, {});
expect(streamAnthropicMock).toHaveBeenCalledWith(
model,
{ messages: [] },
expect.objectContaining({
maxTokens: 128000,
}),
);
const transportOptions = streamAnthropicMock.mock.calls[0]?.[2] as
| { maxTokens?: number }
| undefined;
expect(transportOptions?.maxTokens).toBe(128000);
});
it("clamps explicit maxTokens to the selected model limit", () => {
@@ -157,13 +154,10 @@ describe("createAnthropicVertexStreamFn", () => {
void streamFn(model, { messages: [] }, { maxTokens: 999999 });
expect(streamAnthropicMock).toHaveBeenCalledWith(
model,
{ messages: [] },
expect.objectContaining({
maxTokens: 128000,
}),
);
const transportOptions = streamAnthropicMock.mock.calls[0]?.[2] as
| { maxTokens?: number }
| undefined;
expect(transportOptions?.maxTokens).toBe(128000);
});
it("maps xhigh reasoning to max effort for adaptive Opus models", () => {
@@ -173,14 +167,11 @@ describe("createAnthropicVertexStreamFn", () => {
void streamFn(model, { messages: [] }, { reasoning: "xhigh" });
expect(streamAnthropicMock).toHaveBeenCalledWith(
model,
{ messages: [] },
expect.objectContaining({
thinkingEnabled: true,
effort: "max",
}),
);
const transportOptions = streamAnthropicMock.mock.calls[0]?.[2] as
| { effort?: string; thinkingEnabled?: boolean }
| undefined;
expect(transportOptions?.thinkingEnabled).toBe(true);
expect(transportOptions?.effort).toBe("max");
});
it("maps xhigh reasoning to xhigh effort for Opus 4.7", () => {
@@ -190,14 +181,11 @@ describe("createAnthropicVertexStreamFn", () => {
void streamFn(model, { messages: [] }, { reasoning: "xhigh" });
expect(streamAnthropicMock).toHaveBeenCalledWith(
model,
{ messages: [] },
expect.objectContaining({
thinkingEnabled: true,
effort: "xhigh",
}),
);
const transportOptions = streamAnthropicMock.mock.calls[0]?.[2] as
| { effort?: string; thinkingEnabled?: boolean }
| undefined;
expect(transportOptions?.thinkingEnabled).toBe(true);
expect(transportOptions?.effort).toBe("xhigh");
});
it("applies Anthropic cache-boundary shaping before forwarding payload hooks", async () => {
@@ -266,13 +254,12 @@ describe("createAnthropicVertexStreamFn", () => {
void streamFn(model, { messages: [] }, { maxTokens: Number.NaN });
expect(streamAnthropicMock).toHaveBeenCalledWith(
model,
{ messages: [] },
expect.not.objectContaining({
maxTokens: expect.anything(),
}),
);
expect(streamAnthropicMock).toHaveBeenCalledTimes(1);
const [calledModel, payload, transportOptions] = streamAnthropicMock.mock.calls[0] ?? [];
expect(calledModel).toBe(model);
expect(payload).toEqual({ messages: [] });
expect(transportOptions).toBeTypeOf("object");
expect(Object.hasOwn(transportOptions as object, "maxTokens")).toBe(false);
});
});
@@ -1,10 +1,10 @@
import { AnthropicVertex as AnthropicVertexSdk } from "@anthropic-ai/vertex-sdk";
import type { StreamFn } from "@mariozechner/pi-agent-core";
import type { StreamFn } from "@earendil-works/pi-agent-core";
import {
streamAnthropic as streamAnthropicDefault,
type AnthropicOptions,
type Model,
} from "@mariozechner/pi-ai";
} from "@earendil-works/pi-ai";
import {
applyAnthropicPayloadPolicyToParams,
resolveAnthropicPayloadPolicy,
+1 -1
View File
@@ -5,7 +5,7 @@
"description": "OpenClaw Anthropic provider plugin",
"type": "module",
"dependencies": {
"@mariozechner/pi-ai": "0.73.1"
"@earendil-works/pi-ai": "0.74.0"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
@@ -33,34 +33,32 @@ function collectLegacyExtendedLevelIds(levels: readonly { id: string }[] | undef
return ids;
}
function levelIds(levels: readonly { id: string }[] | undefined): string[] {
return (levels ?? []).map((level) => level.id);
}
describe("anthropic provider policy public artifact", () => {
it("normalizes Anthropic provider config", () => {
expect(
normalizeConfig({
provider: "anthropic",
providerConfig: {
baseUrl: "https://api.anthropic.com",
models: [createModel("claude-sonnet-4-6", "Claude Sonnet 4.6")],
},
}),
).toMatchObject({
api: "anthropic-messages",
baseUrl: "https://api.anthropic.com",
const normalized = normalizeConfig({
provider: "anthropic",
providerConfig: {
baseUrl: "https://api.anthropic.com",
models: [createModel("claude-sonnet-4-6", "Claude Sonnet 4.6")],
},
});
expect(normalized.api).toBe("anthropic-messages");
expect(normalized.baseUrl).toBe("https://api.anthropic.com");
});
it("normalizes Claude CLI provider config", () => {
expect(
normalizeConfig({
provider: "claude-cli",
providerConfig: {
baseUrl: "https://api.anthropic.com",
models: [createModel("claude-sonnet-4-6", "Claude Sonnet 4.6")],
},
}),
).toMatchObject({
api: "anthropic-messages",
const normalized = normalizeConfig({
provider: "claude-cli",
providerConfig: {
baseUrl: "https://api.anthropic.com",
models: [createModel("claude-sonnet-4-6", "Claude Sonnet 4.6")],
},
});
expect(normalized.api).toBe("anthropic-messages");
});
it("does not normalize non-Anthropic provider config", () => {
@@ -96,22 +94,20 @@ describe("anthropic provider policy public artifact", () => {
env: {},
});
expect(nextConfig.agents?.defaults?.contextPruning).toMatchObject({
mode: "cache-ttl",
ttl: "1h",
});
expect(nextConfig.agents?.defaults?.contextPruning?.mode).toBe("cache-ttl");
expect(nextConfig.agents?.defaults?.contextPruning?.ttl).toBe("1h");
});
it("exposes Claude Opus 4.7 thinking levels without loading the full provider plugin", () => {
expect(
resolveThinkingProfile({
provider: "anthropic",
modelId: "claude-opus-4-7",
}),
).toMatchObject({
levels: expect.arrayContaining([{ id: "xhigh" }, { id: "adaptive" }, { id: "max" }]),
defaultLevel: "off",
const profile = resolveThinkingProfile({
provider: "anthropic",
modelId: "claude-opus-4-7",
});
const ids = levelIds(profile?.levels);
expect(ids).toContain("xhigh");
expect(ids).toContain("adaptive");
expect(ids).toContain("max");
expect(profile?.defaultLevel).toBe("off");
});
it("keeps adaptive-only Claude profiles aligned with the runtime provider", () => {
@@ -120,13 +116,11 @@ describe("anthropic provider policy public artifact", () => {
modelId: "claude-opus-4-6",
});
expect(profile).toMatchObject({
levels: expect.arrayContaining([{ id: "adaptive" }]),
defaultLevel: "adaptive",
});
if (!profile) {
throw new Error("Expected Anthropic policy profile");
}
expect(levelIds(profile.levels)).toContain("adaptive");
expect(profile.defaultLevel).toBe("adaptive");
expect(collectLegacyExtendedLevelIds(profile.levels)).toStrictEqual([]);
});
+2 -2
View File
@@ -1,4 +1,4 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import type { StreamFn } from "@earendil-works/pi-agent-core";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
__testing,
@@ -110,7 +110,7 @@ describe("anthropic stream wrappers", () => {
it("composes the anthropic provider stream chain from extra params", () => {
const captured = runComposedAnthropicProviderStream("sk-ant-api-123");
expect(captured.headers?.["anthropic-beta"]).toContain(CONTEXT_1M_BETA);
expect(captured.payload).toMatchObject({ service_tier: "auto" });
expect(captured.payload).toEqual({ service_tier: "auto" });
});
});
+2 -2
View File
@@ -1,5 +1,5 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import { streamSimple } from "@mariozechner/pi-ai";
import type { StreamFn } from "@earendil-works/pi-agent-core";
import { streamSimple } from "@earendil-works/pi-ai";
import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry";
import {
applyAnthropicPayloadPolicyToParams,
+58 -69
View File
@@ -20,19 +20,21 @@ describe("arcee provider plugin", () => {
providers: [provider],
choice: "arceeai-api-key",
});
expect(directChoice).toMatchObject({
provider: { id: "arcee" },
method: { id: "arcee-platform" },
});
if (!directChoice) {
throw new Error("expected direct Arcee auth choice");
}
expect(directChoice.provider.id).toBe("arcee");
expect(directChoice.method.id).toBe("arcee-platform");
const orChoice = resolveProviderPluginChoice({
providers: [provider],
choice: "arceeai-openrouter",
});
expect(orChoice).toMatchObject({
provider: { id: "arcee" },
method: { id: "openrouter" },
});
if (!orChoice) {
throw new Error("expected OpenRouter Arcee auth choice");
}
expect(orChoice.provider.id).toBe("arcee");
expect(orChoice.method.id).toBe("openrouter");
});
it("stores the OpenRouter onboarding path under the OpenRouter auth profile", async () => {
@@ -58,14 +60,12 @@ describe("arcee provider plugin", () => {
toApiKeyCredential: () => null,
} as never);
expect(config?.auth?.profiles?.["openrouter:default"]).toMatchObject({
provider: "openrouter",
mode: "api_key",
});
expect(config?.models?.providers?.arcee).toMatchObject({
baseUrl: "https://openrouter.ai/api/v1",
api: "openai-completions",
});
const openRouterProfile = config?.auth?.profiles?.["openrouter:default"];
expect(openRouterProfile?.provider).toBe("openrouter");
expect(openRouterProfile?.mode).toBe("api_key");
const arceeConfig = config?.models?.providers?.arcee;
expect(arceeConfig?.baseUrl).toBe("https://openrouter.ai/api/v1");
expect(arceeConfig?.api).toBe("openai-completions");
expect(config?.models?.providers?.arcee?.models?.map((model) => model.id)).toEqual([
"arcee/trinity-mini",
"arcee/trinity-large-preview",
@@ -94,12 +94,11 @@ describe("arcee provider plugin", () => {
"trinity-large-preview",
"trinity-large-thinking",
]);
expect(
catalogProvider.models?.find((model) => model.id === "trinity-large-thinking")?.compat,
).toMatchObject({
supportsTools: false,
supportsReasoningEffort: false,
});
const thinkingCompat = catalogProvider.models?.find(
(model) => model.id === "trinity-large-thinking",
)?.compat;
expect(thinkingCompat?.supportsTools).toBe(false);
expect(thinkingCompat?.supportsReasoningEffort).toBe(false);
});
it("builds the OpenRouter-backed Arcee AI model catalog", async () => {
@@ -120,31 +119,27 @@ describe("arcee provider plugin", () => {
"arcee/trinity-large-preview",
"arcee/trinity-large-thinking",
]);
expect(
catalogProvider.models?.find((model) => model.id === "arcee/trinity-large-thinking")?.compat,
).toMatchObject({
supportsTools: false,
supportsReasoningEffort: false,
});
const thinkingCompat = catalogProvider.models?.find(
(model) => model.id === "arcee/trinity-large-thinking",
)?.compat;
expect(thinkingCompat?.supportsTools).toBe(false);
expect(thinkingCompat?.supportsReasoningEffort).toBe(false);
});
it("normalizes Arcee OpenRouter models to vendor-prefixed runtime ids", async () => {
const provider = await registerSingleProviderPlugin(arceePlugin);
expect(
provider.normalizeResolvedModel?.({
modelId: "arcee/trinity-large-thinking",
model: {
provider: "arcee",
id: "trinity-large-thinking",
name: "Trinity Large Thinking",
api: "openai-completions",
baseUrl: "https://openrouter.ai/api/v1",
},
} as never),
).toMatchObject({
id: "arcee/trinity-large-thinking",
});
const openRouterModel = provider.normalizeResolvedModel?.({
modelId: "arcee/trinity-large-thinking",
model: {
provider: "arcee",
id: "trinity-large-thinking",
name: "Trinity Large Thinking",
api: "openai-completions",
baseUrl: "https://openrouter.ai/api/v1",
},
} as never);
expect(openRouterModel?.id).toBe("arcee/trinity-large-thinking");
expect(
provider.normalizeResolvedModel?.({
@@ -163,34 +158,28 @@ describe("arcee provider plugin", () => {
it("canonicalizes stale OpenRouter /v1 config and transport metadata", async () => {
const provider = await registerSingleProviderPlugin(arceePlugin);
expect(
provider.normalizeConfig?.({
provider: "arcee",
providerConfig: {
api: "openai-completions",
baseUrl: "https://openrouter.ai/v1/",
models: [],
},
} as never),
).toMatchObject({
baseUrl: "https://openrouter.ai/api/v1",
});
const normalizedConfig = provider.normalizeConfig?.({
provider: "arcee",
providerConfig: {
api: "openai-completions",
baseUrl: "https://openrouter.ai/v1/",
models: [],
},
} as never);
expect(normalizedConfig?.baseUrl).toBe("https://openrouter.ai/api/v1");
expect(
provider.normalizeResolvedModel?.({
modelId: "arcee/trinity-large-thinking",
model: {
provider: "arcee",
id: "trinity-large-thinking",
name: "Trinity Large Thinking",
api: "openai-completions",
baseUrl: "https://openrouter.ai/v1",
},
} as never),
).toMatchObject({
id: "arcee/trinity-large-thinking",
baseUrl: "https://openrouter.ai/api/v1",
});
const normalizedModel = provider.normalizeResolvedModel?.({
modelId: "arcee/trinity-large-thinking",
model: {
provider: "arcee",
id: "trinity-large-thinking",
name: "Trinity Large Thinking",
api: "openai-completions",
baseUrl: "https://openrouter.ai/v1",
},
} as never);
expect(normalizedModel?.id).toBe("arcee/trinity-large-thinking");
expect(normalizedModel?.baseUrl).toBe("https://openrouter.ai/api/v1");
expect(
provider.normalizeTransport?.({
+22 -15
View File
@@ -98,21 +98,28 @@ describe("buildAzureSpeechProvider", () => {
},
});
expect(canonical).toEqual(
expect.objectContaining({
apiKey: "key",
region: "eastus",
baseUrl: "https://eastus.tts.speech.microsoft.com",
voice: "en-US-AriaNeural",
}),
);
expect(alias).toEqual(
expect.objectContaining({
apiKey: "alias-key",
endpoint: "https://westus.tts.speech.microsoft.com/cognitiveservices/v1",
baseUrl: "https://westus.tts.speech.microsoft.com",
}),
);
expect(canonical).toEqual({
apiKey: "key",
region: "eastus",
endpoint: undefined,
baseUrl: "https://eastus.tts.speech.microsoft.com",
voice: "en-US-AriaNeural",
lang: "en-US",
outputFormat: "audio-24khz-48kbitrate-mono-mp3",
voiceNoteOutputFormat: "ogg-24khz-16bit-mono-opus",
timeoutMs: undefined,
});
expect(alias).toEqual({
apiKey: "alias-key",
region: undefined,
endpoint: "https://westus.tts.speech.microsoft.com/cognitiveservices/v1",
baseUrl: "https://westus.tts.speech.microsoft.com",
voice: "en-US-JennyNeural",
lang: "en-US",
outputFormat: "audio-24khz-48kbitrate-mono-mp3",
voiceNoteOutputFormat: "ogg-24khz-16bit-mono-opus",
timeoutMs: undefined,
});
});
it("parses provider-specific TTS directives", () => {
+11 -21
View File
@@ -46,6 +46,10 @@ function warnMessages(): string[] {
return logger.warn.mock.calls.map(([message]) => String(message));
}
function expectWarnContaining(fragment: string) {
expect(warnMessages().some((message) => message.includes(fragment))).toBe(true);
}
function enableAdvertiserUnitMode(hostname = "test-host") {
// Allow advertiser to run in unit tests.
delete process.env.VITEST;
@@ -407,17 +411,13 @@ describe("gateway bonjour advertiser", () => {
expect(exceptionHandler).toBeTypeOf("function");
expect(handler?.(new Error("CIAO PROBING CANCELLED"))).toBe(true);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("suppressing ciao cancellation"),
);
expectWarnContaining("suppressing ciao cancellation");
logger.warn.mockClear();
expect(
handler?.(new Error("Reached illegal state! IPV4 address change from defined to undefined!")),
).toBe(true);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("suppressing ciao interface assertion"),
);
expectWarnContaining("suppressing ciao interface assertion");
logger.warn.mockClear();
expect(
@@ -430,9 +430,7 @@ describe("gateway bonjour advertiser", () => {
),
),
).toBe(true);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("suppressing ciao netmask assertion"),
);
expectWarnContaining("suppressing ciao netmask assertion");
logger.warn.mockClear();
expect(
@@ -442,9 +440,7 @@ describe("gateway bonjour advertiser", () => {
),
),
).toBe(true);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("suppressing ciao self-probe race"),
);
expectWarnContaining("suppressing ciao self-probe race");
await started.stop();
});
@@ -470,9 +466,7 @@ describe("gateway bonjour advertiser", () => {
expect(createService).toHaveBeenCalledTimes(2);
});
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("suppressing ciao cancellation"),
);
expectWarnContaining("suppressing ciao cancellation");
expect(warnMessages().some((message) => message.includes("restarting advertiser"))).toBe(true);
expect(destroy).toHaveBeenCalledTimes(1);
expect(advertise).toHaveBeenCalledTimes(2);
@@ -709,9 +703,7 @@ describe("gateway bonjour advertiser", () => {
await vi.advanceTimersByTimeAsync(25_000);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("service stuck in announcing"),
);
expectWarnContaining("service stuck in announcing");
expect(createService).toHaveBeenCalledTimes(2);
expect(advertise).toHaveBeenCalledTimes(3);
expect(destroy).toHaveBeenCalledTimes(1);
@@ -737,9 +729,7 @@ describe("gateway bonjour advertiser", () => {
await vi.advanceTimersByTimeAsync(55_000);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("disabling advertiser after 1 stuck-state restart"),
);
expectWarnContaining("disabling advertiser after 1 stuck-state restart");
expect(createService).toHaveBeenCalledTimes(2);
expect(advertise).toHaveBeenCalledTimes(2);
expect(destroy).toHaveBeenCalledTimes(2);
@@ -100,10 +100,11 @@ describe("brave web search provider", () => {
const result = await tool.execute({ query: "OpenClaw docs" });
expect(result).toMatchObject({
expect(result).toEqual({
error: "missing_brave_api_key",
message:
"web_search (brave) needs a Brave Search API key. Run `openclaw configure --section web` to store it, or set BRAVE_API_KEY in the Gateway environment. If you do not want to configure a search API key, use web_fetch for a specific URL or the browser tool for interactive pages.",
docs: "https://docs.openclaw.ai/tools/web",
});
});
@@ -368,8 +369,10 @@ describe("brave web search provider", () => {
date_before: "2026-03-01",
});
expect(result).toMatchObject({
expect(result).toEqual({
error: "invalid_date_range",
message: "date_after must be before date_before.",
docs: "https://docs.openclaw.ai/tools/web",
});
});
@@ -517,8 +520,10 @@ describe("brave web search provider", () => {
date_after: "2999-01-01",
});
expect(result).toMatchObject({
expect(result).toEqual({
error: "invalid_date_range",
message: "date_after cannot be in the future for Brave llm-context mode.",
docs: "https://docs.openclaw.ai/tools/web",
});
expect(mockFetch).not.toHaveBeenCalled();
});
@@ -543,8 +548,11 @@ describe("brave web search provider", () => {
date_before: "2025-01-31",
});
expect(result).toMatchObject({
expect(result).toEqual({
error: "unsupported_date_filter",
message:
"Brave llm-context mode requires date_after when date_before is set. Use a bounded date range or freshness.",
docs: "https://docs.openclaw.ai/tools/web",
});
expect(mockFetch).not.toHaveBeenCalled();
});
+24 -23
View File
@@ -82,12 +82,10 @@ function registerBrowserAutoEnableProbe(): BrowserAutoEnableProbe {
describe("browser plugin", () => {
it("exposes static browser metadata on the plugin definition", () => {
expect(browserPluginReload).toEqual({ restartPrefixes: ["browser"] });
expect(browserPluginNodeHostCommands).toEqual([
expect.objectContaining({
command: "browser.proxy",
cap: "browser",
}),
]);
expect(browserPluginNodeHostCommands).toHaveLength(1);
expect(browserPluginNodeHostCommands[0]?.command).toBe("browser.proxy");
expect(browserPluginNodeHostCommands[0]?.cap).toBe("browser");
expect(typeof browserPluginNodeHostCommands[0]?.handle).toBe("function");
expect(browserSecurityAuditCollectors).toHaveLength(1);
});
@@ -135,21 +133,19 @@ describe("browser plugin", () => {
const { api, registerCli } = createApi();
registerBrowserPlugin(api);
expect(registerCli).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({
commands: ["browser"],
descriptors: [
{
name: "browser",
description: "Manage OpenClaw's dedicated browser (Chrome/Chromium)",
hasSubcommands: true,
},
],
}),
);
expect(registerCli).toHaveBeenCalledTimes(1);
const registrar = registerCli.mock.calls[0]?.[0];
expect(typeof registrar).toBe("function");
expect(registerCli.mock.calls[0]?.[1]).toEqual({
commands: ["browser"],
descriptors: [
{
name: "browser",
description: "Manage OpenClaw's dedicated browser (Chrome/Chromium)",
hasSubcommands: true,
},
],
});
await registrar({ program: {} as never });
expect(runtimeApiMocks.registerBrowserCli).toHaveBeenCalledWith({});
});
@@ -158,10 +154,13 @@ describe("browser plugin", () => {
const { api, registerGatewayMethod } = createApi();
registerBrowserPlugin(api);
expect(registerGatewayMethod).toHaveBeenCalledWith("browser.request", expect.any(Function), {
expect(registerGatewayMethod).toHaveBeenCalledTimes(1);
expect(registerGatewayMethod.mock.calls[0]?.[0]).toBe("browser.request");
const handler = registerGatewayMethod.mock.calls[0]?.[1];
expect(typeof handler).toBe("function");
expect(registerGatewayMethod.mock.calls[0]?.[2]).toEqual({
scope: "operator.admin",
});
const handler = registerGatewayMethod.mock.calls[0]?.[1];
await handler({ method: "browser.request" });
expect(runtimeApiMocks.handleBrowserGatewayRequest).toHaveBeenCalledWith({
method: "browser.request",
@@ -181,7 +180,9 @@ describe("browser plugin", () => {
registerBrowserPlugin(api);
const service = registerService.mock.calls[0]?.[0];
expect(service).toMatchObject({ id: "browser-control" });
expect(service?.id).toBe("browser-control");
expect(typeof service?.start).toBe("function");
expect(typeof service?.stop).toBe("function");
expect(runtimeApiMocks.createBrowserPluginService).not.toHaveBeenCalled();
await service.start({ config: {}, stateDir: "/tmp/openclaw", logger: { warn: vi.fn() } });
@@ -1,4 +1,4 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
import {
DEFAULT_AI_SNAPSHOT_MAX_CHARS,
browserAct,
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it } from "vitest";
import { type WebSocket, WebSocketServer } from "ws";
import { rawDataToString } from "../infra/ws.js";
import "../test-support/browser-security.mock.js";
import {
type AriaSnapshotNode,
captureScreenshot,
@@ -107,9 +107,7 @@ describe("CDP screenshot params", () => {
await captureScreenshot({ wsUrl: "ws://localhost:9222/devtools/page/X", format: "png" });
const call = requireSentMessage("Page.captureScreenshot");
expect(call.params).toMatchObject({
format: "png",
});
expect(call.params?.format).toBe("png");
expect(call.params).not.toHaveProperty("fromSurface");
expect(call.params).not.toHaveProperty("captureBeyondViewport");
expect(call.params).not.toHaveProperty("clip");
@@ -152,27 +150,23 @@ describe("CDP screenshot params", () => {
}
// Expand: uses saved DPR, mobile defaults to false
expect(firstSetCall.params).toMatchObject({
width: 1200,
height: 3000,
deviceScaleFactor: 2,
mobile: false,
});
expect(firstSetCall.params?.width).toBe(1200);
expect(firstSetCall.params?.height).toBe(3000);
expect(firstSetCall.params?.deviceScaleFactor).toBe(2);
expect(firstSetCall.params?.mobile).toBe(false);
// Clear is called first in the finally block
requireSentMessage("Emulation.clearDeviceMetricsOverride");
const captureCall = requireSentMessage("Page.captureScreenshot");
expect(captureCall.params).toMatchObject({ captureBeyondViewport: true });
expect(captureCall.params?.captureBeyondViewport).toBe(true);
// Viewport drifted after clear → re-apply saved dimensions
expect(secondSetCall.params).toMatchObject({
width: 800,
height: 600,
deviceScaleFactor: 2,
mobile: false,
screenWidth: 800,
screenHeight: 600,
});
expect(secondSetCall.params?.width).toBe(800);
expect(secondSetCall.params?.height).toBe(600);
expect(secondSetCall.params?.deviceScaleFactor).toBe(2);
expect(secondSetCall.params?.mobile).toBe(false);
expect(secondSetCall.params?.screenWidth).toBe(800);
expect(secondSetCall.params?.screenHeight).toBe(600);
});
it("fullPage on non-emulated tab: clears and does NOT re-apply emulation", async () => {
@@ -1,3 +1,4 @@
import fs from "node:fs/promises";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
clickChromeMcpElement,
@@ -9,6 +10,7 @@ import {
openChromeMcpTab,
resetChromeMcpSessionsForTest,
setChromeMcpSessionFactoryForTest,
takeChromeMcpScreenshot,
} from "./chrome-mcp.js";
type ToolCall = {
@@ -78,6 +80,15 @@ function createFakeSession(): ChromeMcpSession {
],
};
}
if (name === "take_screenshot") {
const filePath = typeof args?.filePath === "string" ? args.filePath : undefined;
const format = args?.format === "jpeg" ? "jpeg" : "png";
if (!filePath) {
throw new Error("missing filePath");
}
await fs.writeFile(`${filePath}.${format}`, Buffer.from(`screenshot:${format}`));
return { content: [{ type: "text", text: `Saved screenshot to ${filePath}.${format}.` }] };
}
throw new Error(`unexpected tool ${name}`);
});
@@ -127,6 +138,19 @@ describe("chrome MCP page parsing", () => {
]);
});
it("reads screenshot files with the extension written by chrome-devtools-mcp", async () => {
const factory: ChromeMcpSessionFactory = async () => createFakeSession();
setChromeMcpSessionFactoryForTest(factory);
await expect(
takeChromeMcpScreenshot({
profileName: "chrome-live",
targetId: "1",
format: "jpeg",
}),
).resolves.toEqual(Buffer.from("screenshot:jpeg"));
});
it("adds --userDataDir when an explicit Chromium profile path is configured", () => {
expect(buildChromeMcpArgs("/tmp/brave-profile")).toEqual([
"-y",
@@ -708,4 +732,32 @@ describe("chrome MCP page parsing", () => {
await expectation;
expect(closeMock).toHaveBeenCalledTimes(1);
});
it("honors abort signals while waiting for ephemeral availability probes", async () => {
const closeMock = vi.fn().mockResolvedValue(undefined);
const factory: ChromeMcpSessionFactory = async () =>
({
client: {
callTool: vi.fn(),
listTools: vi.fn(),
close: closeMock,
connect: vi.fn(),
},
transport: {
pid: 123,
},
ready: new Promise<void>(() => {}),
}) as unknown as ChromeMcpSession;
setChromeMcpSessionFactoryForTest(factory);
const ctrl = new AbortController();
const promise = ensureChromeMcpAvailable("chrome-live", undefined, {
ephemeral: true,
signal: ctrl.signal,
});
ctrl.abort(new Error("status budget exhausted"));
await expect(promise).rejects.toThrow(/status budget exhausted/);
expect(closeMock).toHaveBeenCalledTimes(1);
});
});

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