mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 11:20:43 +00:00
Merge branch 'main' into meow/markdown-preview-polish
This commit is contained in:
90
.github/workflows/ci.yml
vendored
90
.github/workflows/ci.yml
vendored
@@ -40,6 +40,7 @@ jobs:
|
||||
has_changed_extensions: ${{ steps.manifest.outputs.has_changed_extensions }}
|
||||
changed_extensions_matrix: ${{ steps.manifest.outputs.changed_extensions_matrix }}
|
||||
run_build_artifacts: ${{ steps.manifest.outputs.run_build_artifacts }}
|
||||
run_checks_fast_core: ${{ steps.manifest.outputs.run_checks_fast_core }}
|
||||
run_checks_fast: ${{ steps.manifest.outputs.run_checks_fast }}
|
||||
checks_fast_core_matrix: ${{ steps.manifest.outputs.checks_fast_core_matrix }}
|
||||
channel_contracts_matrix: ${{ steps.manifest.outputs.channel_contracts_matrix }}
|
||||
@@ -130,6 +131,9 @@ jobs:
|
||||
OPENCLAW_CI_RUN_MACOS: ${{ steps.changed_scope.outputs.run_macos || 'false' }}
|
||||
OPENCLAW_CI_RUN_ANDROID: ${{ steps.changed_scope.outputs.run_android || 'false' }}
|
||||
OPENCLAW_CI_RUN_WINDOWS: ${{ steps.changed_scope.outputs.run_windows || 'false' }}
|
||||
OPENCLAW_CI_RUN_NODE_FAST_ONLY: ${{ steps.changed_scope.outputs.run_node_fast_only || 'false' }}
|
||||
OPENCLAW_CI_RUN_NODE_FAST_PLUGIN_CONTRACTS: ${{ steps.changed_scope.outputs.run_node_fast_plugin_contracts || 'false' }}
|
||||
OPENCLAW_CI_RUN_NODE_FAST_CI_ROUTING: ${{ steps.changed_scope.outputs.run_node_fast_ci_routing || 'false' }}
|
||||
OPENCLAW_CI_RUN_SKILLS_PYTHON: ${{ steps.changed_scope.outputs.run_skills_python || 'false' }}
|
||||
OPENCLAW_CI_RUN_CONTROL_UI_I18N: ${{ steps.changed_scope.outputs.run_control_ui_i18n || 'false' }}
|
||||
OPENCLAW_CI_HAS_CHANGED_EXTENSIONS: ${{ steps.changed_extensions.outputs.has_changed_extensions || 'false' }}
|
||||
@@ -173,12 +177,23 @@ jobs:
|
||||
const docsOnly = parseBoolean(process.env.OPENCLAW_CI_DOCS_ONLY);
|
||||
const docsChanged = parseBoolean(process.env.OPENCLAW_CI_DOCS_CHANGED);
|
||||
const runNode = parseBoolean(process.env.OPENCLAW_CI_RUN_NODE) && !docsOnly;
|
||||
const runNodeFastOnly =
|
||||
runNode && parseBoolean(process.env.OPENCLAW_CI_RUN_NODE_FAST_ONLY);
|
||||
const runNodeFull = runNode && !runNodeFastOnly;
|
||||
const runNodeFastPluginContracts =
|
||||
runNode && parseBoolean(process.env.OPENCLAW_CI_RUN_NODE_FAST_PLUGIN_CONTRACTS);
|
||||
const runNodeFastCiRouting =
|
||||
runNode && parseBoolean(process.env.OPENCLAW_CI_RUN_NODE_FAST_CI_ROUTING);
|
||||
const runChecksFastCore = runNodeFull || runNodeFastPluginContracts || runNodeFastCiRouting;
|
||||
const runMacos =
|
||||
parseBoolean(process.env.OPENCLAW_CI_RUN_MACOS) && !docsOnly && isCanonicalRepository;
|
||||
const runAndroid =
|
||||
parseBoolean(process.env.OPENCLAW_CI_RUN_ANDROID) && !docsOnly && isCanonicalRepository;
|
||||
const runWindows =
|
||||
parseBoolean(process.env.OPENCLAW_CI_RUN_WINDOWS) && !docsOnly && isCanonicalRepository;
|
||||
parseBoolean(process.env.OPENCLAW_CI_RUN_WINDOWS) &&
|
||||
!docsOnly &&
|
||||
!runNodeFastOnly &&
|
||||
isCanonicalRepository;
|
||||
const runSkillsPython = parseBoolean(process.env.OPENCLAW_CI_RUN_SKILLS_PYTHON) && !docsOnly;
|
||||
const runControlUiI18n =
|
||||
parseBoolean(process.env.OPENCLAW_CI_RUN_CONTROL_UI_I18N) && !docsOnly;
|
||||
@@ -191,7 +206,7 @@ jobs:
|
||||
? DEFAULT_EXTENSION_TEST_SHARD_COUNT
|
||||
: Math.max(DEFAULT_EXTENSION_TEST_SHARD_COUNT, 36);
|
||||
const extensionShardMatrix = createMatrix(
|
||||
runNode
|
||||
runNodeFull
|
||||
? createExtensionTestShards({
|
||||
shardCount: extensionTestShardCount,
|
||||
}).map((shard) => ({
|
||||
@@ -207,7 +222,33 @@ jobs:
|
||||
}))
|
||||
: [],
|
||||
);
|
||||
const nodeTestShards = runNode
|
||||
const checksFastCoreTasks = [];
|
||||
if (runNodeFull) {
|
||||
checksFastCoreTasks.push(
|
||||
{ check_name: "checks-fast-bundled", runtime: "node", task: "bundled" },
|
||||
{
|
||||
check_name: "checks-fast-contracts-plugins",
|
||||
runtime: "node",
|
||||
task: "contracts-plugins",
|
||||
},
|
||||
);
|
||||
} else {
|
||||
if (runNodeFastPluginContracts) {
|
||||
checksFastCoreTasks.push({
|
||||
check_name: "checks-fast-contracts-plugins",
|
||||
runtime: "node",
|
||||
task: runNodeFastCiRouting ? "contracts-plugins-ci-routing" : "contracts-plugins",
|
||||
});
|
||||
} else if (runNodeFastCiRouting) {
|
||||
checksFastCoreTasks.push({
|
||||
check_name: "checks-fast-ci-routing",
|
||||
runtime: "node",
|
||||
task: "ci-routing",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const nodeTestShards = runNodeFull
|
||||
? createNodeTestShards().map((shard) => ({
|
||||
check_name: shard.checkName,
|
||||
runtime: "node",
|
||||
@@ -232,25 +273,17 @@ jobs:
|
||||
run_windows: runWindows,
|
||||
has_changed_extensions: hasChangedExtensions,
|
||||
changed_extensions_matrix: changedExtensionsMatrix,
|
||||
run_build_artifacts: runNode,
|
||||
run_checks_fast: runNode,
|
||||
checks_fast_core_matrix: createMatrix(
|
||||
runNode
|
||||
? [
|
||||
{ check_name: "checks-fast-bundled", runtime: "node", task: "bundled" },
|
||||
{
|
||||
check_name: "checks-fast-contracts-plugins",
|
||||
runtime: "node",
|
||||
task: "contracts-plugins",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
run_build_artifacts: runNodeFull,
|
||||
run_checks_fast_core: runChecksFastCore,
|
||||
run_checks_fast: runNodeFull,
|
||||
checks_fast_core_matrix: createMatrix(checksFastCoreTasks),
|
||||
channel_contracts_matrix: createMatrix(
|
||||
runNodeFull ? createChannelContractTestShards() : [],
|
||||
),
|
||||
channel_contracts_matrix: createMatrix(runNode ? createChannelContractTestShards() : []),
|
||||
checks_node_extensions_matrix: extensionShardMatrix,
|
||||
run_checks: runNode,
|
||||
run_checks: runNodeFull,
|
||||
checks_matrix: createMatrix(
|
||||
runNode
|
||||
runNodeFull
|
||||
? [
|
||||
{ check_name: "checks-node-channels", runtime: "node", task: "channels" },
|
||||
]
|
||||
@@ -269,9 +302,9 @@ jobs:
|
||||
}))
|
||||
: [],
|
||||
),
|
||||
run_check: runNode,
|
||||
run_check_additional: runNode,
|
||||
run_build_smoke: runNode,
|
||||
run_check: runNodeFull,
|
||||
run_check_additional: runNodeFull,
|
||||
run_build_smoke: runNodeFull,
|
||||
run_check_docs: docsChanged,
|
||||
run_control_ui_i18n: runControlUiI18n,
|
||||
run_skills_python_job: runSkillsPython,
|
||||
@@ -662,7 +695,7 @@ jobs:
|
||||
contents: read
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_checks_fast == 'true'
|
||||
if: needs.preflight.outputs.run_checks_fast_core == 'true'
|
||||
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
@@ -739,6 +772,13 @@ jobs:
|
||||
contracts-plugins)
|
||||
pnpm test:contracts:plugins
|
||||
;;
|
||||
contracts-plugins-ci-routing)
|
||||
pnpm test:contracts:plugins
|
||||
pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/test-projects.test.ts
|
||||
;;
|
||||
ci-routing)
|
||||
pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/test-projects.test.ts
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported checks-fast task: $TASK" >&2
|
||||
exit 1
|
||||
@@ -1044,7 +1084,7 @@ jobs:
|
||||
contents: read
|
||||
name: checks-node-compat-node22
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_node == 'true' && github.event_name == 'push'
|
||||
if: needs.preflight.outputs.run_build_artifacts == 'true' && github.event_name == 'push'
|
||||
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
@@ -1587,7 +1627,7 @@ jobs:
|
||||
packages/plugin-sdk/dist
|
||||
extensions/*/dist/.boundary-tsc.tsbuildinfo
|
||||
extensions/*/dist/.boundary-tsc.stamp
|
||||
key: ${{ runner.os }}-extension-package-boundary-v1-${{ hashFiles('tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'packages/plugin-sdk/tsconfig.json', 'scripts/check-extension-package-tsc-boundary.mjs', 'scripts/prepare-extension-package-boundary-artifacts.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entrypoints.json', 'scripts/lib/plugin-sdk-entries.mjs', 'src/plugin-sdk/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'src/types/**', 'extensions/**', 'extensions/tsconfig.package-boundary*.json', 'package.json', 'pnpm-lock.yaml') }}
|
||||
key: ${{ runner.os }}-extension-package-boundary-v1-${{ hashFiles('tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'packages/plugin-sdk/tsconfig.json', 'scripts/check-extension-package-tsc-boundary.mjs', 'scripts/prepare-extension-package-boundary-artifacts.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entrypoints.json', 'scripts/lib/plugin-sdk-entries.mjs', 'src/plugin-sdk/**', 'src/auto-reply/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'src/types/**', 'extensions/**', 'extensions/tsconfig.package-boundary*.json', 'package.json', 'pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-extension-package-boundary-v1-
|
||||
|
||||
|
||||
3
.github/workflows/install-smoke.yml
vendored
3
.github/workflows/install-smoke.yml
vendored
@@ -349,4 +349,5 @@ jobs:
|
||||
- name: Run fast bundled plugin Docker E2E
|
||||
env:
|
||||
OPENCLAW_BUNDLED_CHANNEL_DEPS_E2E_IMAGE: openclaw-bundled-channel-fast:local
|
||||
run: timeout 120s pnpm test:docker:bundled-channel-deps:fast
|
||||
OPENCLAW_BUNDLED_CHANNEL_DOCKER_RUN_TIMEOUT: 90s
|
||||
run: timeout 240s pnpm test:docker:bundled-channel-deps:fast
|
||||
|
||||
@@ -432,24 +432,35 @@ jobs:
|
||||
OPENCLAW_DISCORD_SMOKE_CHANNEL_ID: ${{ secrets.OPENCLAW_DISCORD_SMOKE_CHANNEL_ID }}
|
||||
OPENCLAW_RELEASE_CHECK_OS: ${{ matrix.os_id }}
|
||||
OPENCLAW_RELEASE_CHECK_RUNNER: ${{ matrix.runner }}
|
||||
CANDIDATE_TGZ: ${{ runner.temp }}/openclaw-cross-os-release-checks/candidate/${{ needs.prepare.outputs.candidate_file_name }}
|
||||
CANDIDATE_VERSION: ${{ needs.prepare.outputs.candidate_version }}
|
||||
SOURCE_SHA: ${{ needs.prepare.outputs.source_sha }}
|
||||
BASELINE_SPEC: ${{ needs.prepare.outputs.baseline_spec }}
|
||||
PREVIOUS_VERSION: ${{ inputs.previous_version }}
|
||||
BASELINE_TGZ: ${{ runner.temp }}/openclaw-cross-os-release-checks/baseline/${{ needs.prepare.outputs.baseline_file_name }}
|
||||
PROVIDER: ${{ inputs.provider }}
|
||||
MODE: ${{ matrix.lane }}
|
||||
SUITE: ${{ matrix.suite }}
|
||||
REF: ${{ inputs.ref }}
|
||||
OUTPUT_DIR: ${{ runner.temp }}/openclaw-cross-os-release-checks/${{ matrix.artifact_name }}-${{ matrix.suite }}
|
||||
run: |
|
||||
DISCORD_ARGS=()
|
||||
if [[ -n "${OPENCLAW_DISCORD_SMOKE_BOT_TOKEN}" ]] && [[ -n "${OPENCLAW_DISCORD_SMOKE_GUILD_ID}" ]] && [[ -n "${OPENCLAW_DISCORD_SMOKE_CHANNEL_ID}" ]]; then
|
||||
DISCORD_ARGS+=(--run-discord-roundtrip true)
|
||||
fi
|
||||
pnpm dlx "tsx@${TSX_VERSION}" workflow/scripts/openclaw-cross-os-release-checks.ts \
|
||||
--candidate-tgz "$RUNNER_TEMP/openclaw-cross-os-release-checks/candidate/${{ needs.prepare.outputs.candidate_file_name }}" \
|
||||
--candidate-version "${{ needs.prepare.outputs.candidate_version }}" \
|
||||
--source-sha "${{ needs.prepare.outputs.source_sha }}" \
|
||||
--baseline-spec "${{ needs.prepare.outputs.baseline_spec }}" \
|
||||
--previous-version "${{ inputs.previous_version }}" \
|
||||
--baseline-tgz "$RUNNER_TEMP/openclaw-cross-os-release-checks/baseline/${{ needs.prepare.outputs.baseline_file_name }}" \
|
||||
--provider "${{ inputs.provider }}" \
|
||||
--mode "${{ matrix.lane }}" \
|
||||
--suite "${{ matrix.suite }}" \
|
||||
--ref "${{ inputs.ref }}" \
|
||||
--candidate-tgz "${CANDIDATE_TGZ}" \
|
||||
--candidate-version "${CANDIDATE_VERSION}" \
|
||||
--source-sha "${SOURCE_SHA}" \
|
||||
--baseline-spec "${BASELINE_SPEC}" \
|
||||
--previous-version "${PREVIOUS_VERSION}" \
|
||||
--baseline-tgz "${BASELINE_TGZ}" \
|
||||
--provider "${PROVIDER}" \
|
||||
--mode "${MODE}" \
|
||||
--suite "${SUITE}" \
|
||||
--ref "${REF}" \
|
||||
"${DISCORD_ARGS[@]}" \
|
||||
--output-dir "$RUNNER_TEMP/openclaw-cross-os-release-checks/${{ matrix.artifact_name }}-${{ matrix.suite }}"
|
||||
--output-dir "${OUTPUT_DIR}"
|
||||
|
||||
- name: Summarize release checks
|
||||
if: always()
|
||||
|
||||
53
CHANGELOG.md
53
CHANGELOG.md
@@ -2,6 +2,22 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changes
|
||||
|
||||
- Diagnostics/OTEL: emit bounded exec-process diagnostics and export them as `openclaw.exec` spans without exposing command text, working directories, or container identifiers. (#70424) Thanks @jlapenna.
|
||||
- Diagnostics/OTEL: support `OPENCLAW_OTEL_PRELOADED=1` so the plugin can reuse an already-registered OpenTelemetry SDK while keeping OpenClaw diagnostic listeners wired. (#70424) Thanks @jlapenna.
|
||||
- Control UI: refine the agent Tool Access panel with compact live-tool chips, collapsible tool groups, direct per-tool toggles, and clearer runtime/source provenance. (#71405) Thanks @BunsDev.
|
||||
- Memory-core/hybrid search: expose raw `vectorScore` and `textScore` alongside the combined `score` on hybrid memory search results, so callers can inspect vector-versus-text retrieval contribution before temporal decay or MMR reordering. Fixes #68166. (#68286) Thanks @ajfonthemove.
|
||||
|
||||
### Fixes
|
||||
|
||||
- MCP: retire one-shot embedded bundled MCP runtimes at run end, skip bundle-MCP startup when a runtime tool allowlist cannot reach bundle-MCP tools, and add `mcp.sessionIdleTtlMs` idle eviction for leaked session runtimes. Fixes #71106, #71110, #70389, and #70808.
|
||||
- Gateway/restart continuation: durably hand restart continuations to a session-delivery queue before deleting the restart sentinel, recover queued continuation work after crashy restarts, and fall back to a session-only wake when no channel route survives reboot. (#70780) Thanks @fuller-stack-dev.
|
||||
- Agents/tool-result pruning: harden the tool-result character estimator and context-pruning loops against malformed `{ type: "text" }` blocks created by void or undefined tool handler results, serializing non-string text payloads for size accounting so they cannot bypass trimming as zero-sized. Fixes #34979. (#51267) Thanks @cgdusek.
|
||||
- Daemon/service-env: add Nix Home Manager profile bin directories to generated gateway service PATHs on macOS and Linux, honoring `NIX_PROFILES` right-to-left precedence and falling back to `~/.nix-profile/bin` when unset. Fixes #44402. (#59935) Thanks @jerome-benoit.
|
||||
|
||||
## 2026.4.24 (Unreleased)
|
||||
|
||||
### Breaking
|
||||
@@ -10,8 +26,12 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- Gateway/nodes: add disabled-by-default `gateway.nodes.pairing.autoApproveCidrs` for first-time node pairing from explicit trusted CIDRs, while keeping operator/browser pairing and all upgrade flows manual. Fixes #60800. Thanks @sahilsatralkar.
|
||||
- Browser: add viewport coordinate clicks for managed and existing-session automation, plus `openclaw browser click-coords` for CLI use. (#54452) Thanks @dluttz.
|
||||
- Browser: add `browser.actionTimeoutMs` and use a 60s default action budget so healthy long browser waits do not fail at the client transport boundary. (#62589) Thanks @andyylin.
|
||||
- Browser/config: support per-profile `browser.profiles.<name>.headless` overrides for locally launched browser profiles, so one profile can run headless without forcing all browser profiles headless. Thanks @nakamotoliu.
|
||||
- Plugins/PDF: move local PDF extraction into a bundled `document-extract` plugin so core no longer owns `pdfjs-dist` or PDF image-rendering dependencies. Thanks @vincentkoc.
|
||||
- Dependencies/memory: stop installing `node-llama-cpp` by default; local embeddings now load it only when operators install the optional runtime package. Thanks @vincentkoc.
|
||||
- Matrix: require full cross-signing identity trust for self-device verification and add `openclaw matrix verify self` so operators can establish that trust from the CLI. (#70401) Thanks @gumadeiras.
|
||||
- WebChat/sessions: keep runtime-only prompt context out of visible transcript history and scrub legacy wrappers from session history surfaces. Thanks @91wan.
|
||||
- Gradium: add a bundled text-to-speech provider with voice-note and telephony output support. (#64958) Thanks @LaurentMazare.
|
||||
@@ -57,25 +77,55 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugins/Google Meet: add a bundled participant plugin with personal Google auth, explicit meeting URL joins, Chrome and Twilio transports, and realtime voice support. (#70765) Thanks @steipete.
|
||||
- Plugins/Google Meet: default Chrome realtime sessions to OpenAI plus SoX `rec`/`play` audio bridge commands, so the usual setup only needs the plugin enabled and `OPENAI_API_KEY`. Thanks @steipete.
|
||||
- Plugins/Google Meet: add a `chrome-node` transport so a paired macOS node, such as a Parallels VM, can own Chrome, BlackHole, and SoX while the Gateway machine keeps the agent and model key. Thanks @steipete.
|
||||
- Plugins/Google Meet: add `googlemeet artifacts` and `googlemeet attendance` commands plus matching tool/gateway actions for conference records, recordings, transcripts and transcript entries, smart notes, and participant sessions. Thanks @steipete.
|
||||
- Plugins/Google Meet: add markdown and file output for `googlemeet artifacts` and `googlemeet attendance` reports. Thanks @steipete.
|
||||
- Plugins/Google Meet: add `googlemeet latest` plus matching tool/gateway actions to find the newest conference record for a meeting. Thanks @steipete.
|
||||
- Plugins/Google Meet: make meeting-based artifact and attendance lookups use the latest conference record by default, with `--all-conference-records` for full history. Thanks @steipete.
|
||||
- Plugins/Google Meet: add `googlemeet doctor --oauth` so operators can verify OAuth token refresh, Meet space reads, and side-effecting space creation without printing secrets. Thanks @steipete.
|
||||
- Plugins/Voice Call: expose the shared `openclaw_agent_consult` realtime tool so live phone calls can ask the full OpenClaw agent for deeper/tool-backed answers. Thanks @steipete.
|
||||
- Plugins/Voice Call: add `voicecall setup` and a dry-run-by-default `voicecall smoke` command so Twilio/provider readiness can be checked before placing a live test call. Thanks @steipete.
|
||||
- Plugins/Google Meet: add `googlemeet doctor` and a `recover_current_tab`/`recover-tab` flow so agents can inspect an already-open Meet tab and report the blocker without opening another window. Thanks @steipete.
|
||||
- Plugins/Bonjour: move LAN Gateway discovery advertising into a default-enabled bundled plugin with its own `@homebridge/ciao` dependency, so users can disable Bonjour without cutting wide-area discovery. Thanks @vincentkoc.
|
||||
- Providers/Google: add a Gemini Live realtime voice provider for backend Voice Call and Google Meet audio bridges, with bidirectional audio and function-call support. Thanks @steipete.
|
||||
- Providers/Google: let Gemini TTS prepend configured `audioProfile` and `speakerName` prompt text for reusable speech style control. Thanks @tdack.
|
||||
- Plugins/Google Meet: let realtime Meet sessions consult the full OpenClaw agent for deeper answers while staying in the live voice loop. Thanks @steipete.
|
||||
- Gateway/VoiceClaw: add a realtime brain WebSocket endpoint backed by Gemini Live, with owner-auth gating and async OpenClaw tool handoff. (#70938) Thanks @yagudaev.
|
||||
- Providers/DeepSeek: add DeepSeek V4 Flash and V4 Pro to the bundled catalog and make V4 Flash the onboarding default. Thanks @lsdsjy.
|
||||
- CLI/Gateway: make `gateway status` start faster by skipping plugin loading on the read-only status path. (#71364) Thanks @andyylin.
|
||||
- Plugins/compatibility: add a central plugin compatibility registry and docs for SDK/config/setup/runtime deprecation records, including dated migration metadata for legacy harness naming and other plugin-facing aliases. Thanks @vincentkoc.
|
||||
- Agents/bootstrap: add `agents.defaults.contextInjection: "never"` to disable workspace bootstrap file injection for agents that fully own their prompt lifecycle. (#65006) Thanks @xDarkicex.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Feishu: back off streaming-card creation after HTTP 400 startup failures, so unsupported card setups fall back without delaying every message. Fixes #56981. Thanks @JinnanDuan.
|
||||
- Feishu/topic groups: key native Feishu/Lark topic-group sessions by `thread_id` so starter messages and replies with different `root_id` formats stay in the same `group_topic` conversation. Fixes #71438. Thanks @1335848090.
|
||||
- Feishu: suppress duplicate final card delivery when idle closes a streaming card before the final payload arrives. (#68491) Thanks @MoerAI.
|
||||
- Signal: preserve sender attachment filenames and resolve missing MIME types from those filenames, so Linux `signal-cli` voice notes without `contentType` still enter audio transcription. Fixes #48614. Thanks @mindfury.
|
||||
- Telegram/agents: suppress the phantom "Agent couldn't generate a response" fallback after a reply was already committed through the messaging tool. (#70623) Thanks @chinar-amrutkar.
|
||||
- Models/CLI: show provider runtime `contextTokens` beside native `contextWindow` in `openclaw models list`, and align `openai-codex/gpt-5.5` with Codex's 272K runtime cap plus 400K native window. Fixes #71403.
|
||||
- Dashboard/security: avoid writing tokenized Control UI URLs or SSH hints to runtime logs, keeping gateway bearer fragments out of console-captured logs readable through `logs.tail`. (#70029) Thanks @Ziy1-Tan.
|
||||
- Providers/OpenRouter: treat DeepSeek refs as cache-TTL eligible without injecting Anthropic cache-control markers, aligning context pruning with OpenRouter-managed prompt caching. (#51983) Thanks @QuinnH496.
|
||||
- Control UI/browser: defer temp-dir access-mode constants until Node-only temp-dir resolution runs, preventing browser bundles from crashing when `node:fs` constants are stubbed. (#48930) Thanks @Valentinws.
|
||||
- Discord/cron: deliver text-only isolated cron and heartbeat announce output from the canonical final assistant text once, avoiding duplicate Discord posts when streamed block payloads and the final answer contain the same content. Fixes #71406. Thanks @alexgross21.
|
||||
- macOS Gateway: wait for launchd to reload the exited Gateway LaunchAgent before bootstrapping repair fallback, preventing config-triggered restarts from leaving the service not loaded. Fixes #45178. Thanks @vincentkoc.
|
||||
- macOS Gateway: tolerate launchctl bootstrap's already-loaded exit during restart fallback and use non-killing kickstart after bootstrap, avoiding a second race that can unload the LaunchAgent. Fixes #41934. Thanks @zerone0x.
|
||||
- macOS Gateway: rewrite stale LaunchAgent plists before restart fallback bootstrap, matching install repair behavior when `gateway restart` has to re-register launchd. Thanks @maybegeeker.
|
||||
- TTS/hooks: preserve audio-only TTS transcripts for `message_sending` and `message_sent` hooks without rendering the transcript as a media caption. Thanks @zqchris.
|
||||
- WhatsApp/TTS: preserve `audioAsVoice` through shared media payload sends and the WhatsApp outbound adapter, so `[[audio_as_voice]]` reply payloads keep their voice-note intent when routed through `sendPayload`. Fixes #66053. Thanks @masatohoshino.
|
||||
- Control UI/WebChat: hide heartbeat prompts, `HEARTBEAT_OK` acknowledgments, and internal-only runtime context turns from visible chat history while leaving the underlying transcript intact. Fixes #71381. Thanks @gerald1950ggg-ai.
|
||||
- Control UI/chat: keep optimistic user and assistant tail messages visible when a final history refresh briefly returns an older snapshot, preventing message cards from flash-disappearing until the next refresh. Fixes #71371. Thanks @WolvenRA.
|
||||
- Talk/TTS: resolve configured extension speech providers from the active runtime registry before provider-list discovery, so Talk mode no longer rejects valid plugin speech providers as unsupported.
|
||||
- Sessions/subagents: stop stale ended runs and old store-only child reverse links from reappearing in `childSessions`, while keeping live descendants and recently-ended children visible. Fixes #57920.
|
||||
- Subagents: recover child sessions after recoverable wait transport failures without exposing an extra wait state, and keep terminal lifecycle timer ordering deterministic. (#71423) Thanks @ZiPengWei.
|
||||
- Subagents: stop stale unended runs from counting as active or pending forever, while preserving restart-aborted recovery for recoverable child sessions. Fixes #71252. Thanks @hclsys.
|
||||
- Gateway/tools: allow `POST /tools/invoke` to reach plugin-backed catalog tools such as `browser` when no core implementation exists, while still preferring built-in tools for real core names. Thanks @chat2way.
|
||||
- Browser/security: require `operator.admin` for the `browser.request` gateway method, matching the host/browser-node control authority exposed by that route. Thanks @RichardCao.
|
||||
- Browser/profiles: allow local managed profiles to override `browser.executablePath`, so different profiles can launch different Chromium-based browsers. Thanks @nobrainer-tech.
|
||||
- Agents/replay: repair displaced or missing tool results before strict provider replay, use Codex-compatible `aborted` outputs for OpenAI Responses history, and drop partial aborted/error transport turns before retries.
|
||||
- Browser/startup: deduplicate concurrent lazy-start calls per profile so simultaneous browser tool requests no longer race into duplicate Chrome launches and `PortInUseError`. (#61772) Thanks @sukhdeepjohar.
|
||||
- Browser/profiles: recover from stale Chromium `Singleton*` profile locks after crashes or host moves by clearing dead/foreign locks and retrying launch once. Thanks @seanc-dev.
|
||||
- Browser/existing-session: keep Chrome MCP status probes transport-only and ephemeral, and retry stale cached Playwright attaches once so idle profile checks no longer poison the next real attach. (#57245) Thanks @josephbergvinson.
|
||||
- Cron/exec: suppress automatic background exec completion wakes only for silent cron jobs with `delivery.mode="none"` while keeping webhook and announce runs observable. (#71391) Thanks @goldmar.
|
||||
- Reply media: allow sandboxed replies to deliver OpenClaw-managed `media/outbound` and `media/tool-*` attachments without treating them as sandbox escapes, while keeping alias-escape checks on the managed media root. Fixes #71138. Thanks @mayor686, @truffle-dev, and @neeravmakwana.
|
||||
- CLI/agent: keep `openclaw agent --json` stdout reserved for the JSON response by routing gateway, plugin, and embedded-fallback diagnostics to stderr before execution starts. Fixes #71319.
|
||||
- Agents/Gemini: retry reasoning-only, empty, and planning-only Gemini turns instead of letting sessions silently stall. Fixes #71074. (#71362) Thanks @neeravmakwana.
|
||||
@@ -120,6 +170,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway/sessions: copy the oversized `sessions.json` to a rotation backup before the atomic rewrite instead of renaming the live store away, so a crash during rotation keeps the existing session-to-transcript mapping authoritative. Fixes #68229. Thanks @jjjojoj.
|
||||
- Providers/OpenAI-compatible: strip OpenAI-only Completions `store` from proxy payloads and allow `extra_body`/`extraBody` passthrough params for provider-specific request fields. Fixes #61826 and #69717.
|
||||
- Discord/subagents: preserve thread-bound completion delivery by keeping the requester-agent announce path primary and falling back to direct thread sends only when the announce produces no visible output. (#71064) Thanks @DolencLuka.
|
||||
- Discord/proxy: serialize proxied multipart attachment uploads with undici `FormData`, so Discord media sends work through configured REST proxies. (#71383) Thanks @TC500.
|
||||
- Browser/tool: give Chrome MCP existing-session manage calls a longer default timeout, pass explicit tool timeouts through tab management, and recover stale selected-page MCP sessions instead of forcing a manual reset. Thanks @steipete.
|
||||
- Browser/sandbox: clean up idle tracked tabs opened by primary-agent browser sessions, while preserving active tab reuse and lifecycle cleanup for subagents, cron, and ACP sessions. Fixes #71165. Thanks @dwbutler.
|
||||
- Plugins/Voice Call: reuse the webhook runtime across in-process plugin contexts, avoiding `EADDRINUSE` when agent tools or CLI commands run while the Gateway already owns the voice webhook port. Fixes #58115. Thanks @sfbrian.
|
||||
@@ -134,6 +185,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugins/Google Meet: tell agents to recover already-open Meet tabs after browser timeouts, and make the dev CLI release its build lock if compiler spawning fails. Thanks @steipete.
|
||||
- Plugins/Google Meet: return structured manual-action details when browser-based meeting creation needs login or permissions, so agents can guide the operator without opening duplicate Meet tabs. Thanks @steipete.
|
||||
- Plugins/CLI: provide Gateway-backed node inspection to plugin commands, so `googlemeet recover-tab` can inspect paired browser nodes from the terminal. Thanks @steipete.
|
||||
- Cron/isolated sessions: clear stale runtime, lifecycle, auth, model, exec, heartbeat, usage, privilege, routing, and delivery artifacts when creating a fresh isolated run, and persist per-run session rows as snapshots so old base-session state no longer leaks into new cron executions. Thanks @vincentkoc.
|
||||
- Gateway/sessions: recover main-agent turns interrupted by a gateway restart from stale transcript-lock evidence, avoiding stuck `status: "running"` sessions without broad post-boot transcript scans. Fixes #70555. Thanks @bitloi.
|
||||
- Codex approvals: sanitize MCP elicitation approval titles, descriptions, and display parameters before forwarding them to OpenClaw approval prompts. (#71343) Thanks @Lucenx9.
|
||||
- Codex approvals: keep command approval responses within Codex app-server `availableDecisions`, including deny/cancel fallbacks for prompts that do not offer `decline`. (#71338) Thanks @Lucenx9.
|
||||
@@ -246,6 +298,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Memory search: apply session visibility and agent-to-agent policy to session transcript hits, and keep `corpus=sessions` ranking scoped to session collections before result limiting. (#70761) Thanks @nefainl.
|
||||
- Agents/sessions: stop session write-lock timeouts from entering model failover, so local lock contention surfaces directly instead of cascading across providers. (#68700) Thanks @MonkeyLeeT.
|
||||
- Auto-reply: run inbound reply delivery through `message_sending` hooks so plugins can transform or cancel generated replies before they are sent. (#70118) Thanks @jzakirov.
|
||||
- CI/release-checks: pass workflow inputs and matrix values through step environment variables instead of embedding them directly into `run:` shell commands, reducing template-injection surface in the cross-OS release-check workflow. (#66884) Thanks @alexlomt.
|
||||
|
||||
## 2026.4.23
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
d885c14dea2c361123a97a0f6c854f6dbae8592f39daa211173ef7f1fe7d554a config-baseline.json
|
||||
c991bb527d8efffb5c9a2c5e502113260a2873923d469289c82f7029257fddaf config-baseline.core.json
|
||||
f1fd4557473391980caf6d6b32f78e4de25f8504b29dfe083f7f9e325d0b204c config-baseline.json
|
||||
68e0784ca0f9279d49b40ce4493e1cb2c416e1fb70a137a853a10a8c078c97ca config-baseline.core.json
|
||||
d72032762ab46b99480b57deb81130a0ab5b1401189cfbaf4f7fef4a063a7f6c config-baseline.channel.json
|
||||
0d5ba81f0030bd39b7ae285096276cc18b150836c2252fd2217329fc6154e80e config-baseline.plugin.json
|
||||
0504c4f38d4c753fffeb465c93540d829df6b0fcef921eb0e2226ac16bdbbe07 config-baseline.plugin.json
|
||||
|
||||
@@ -86,6 +86,8 @@ This fires ~5–6 times per month instead of 0–1 times per month. OpenClaw use
|
||||
|
||||
**Main session** jobs enqueue a system event and optionally wake the heartbeat (`--wake now` or `--wake next-heartbeat`). **Isolated** jobs run a dedicated agent turn with a fresh session. **Custom sessions** (`session:xxx`) persist context across runs, enabling workflows like daily standups that build on previous summaries.
|
||||
|
||||
For isolated jobs, “fresh session” means a new transcript/session id for each run. OpenClaw may carry safe preferences such as thinking/fast/verbose settings, labels, and explicit user-selected model/auth overrides, but it does not inherit ambient conversation context from an older cron row: channel/group routing, send or queue policy, elevation, origin, or ACP runtime binding. Use `current` or `session:<id>` when a recurring job should deliberately build on the same conversation context.
|
||||
|
||||
For isolated jobs, runtime teardown now includes best-effort browser cleanup for that cron session. Cleanup failures are ignored so the actual cron result still wins.
|
||||
|
||||
Isolated cron runs also dispose any bundled MCP runtime instances created for the job through the shared runtime-cleanup path. This matches how main-session and custom-session MCP clients are torn down, so isolated cron jobs do not leak stdio child processes or long-lived MCP connections across runs.
|
||||
@@ -94,6 +96,11 @@ When isolated cron runs orchestrate subagents, delivery also prefers the final
|
||||
descendant output over stale parent interim text. If descendants are still
|
||||
running, OpenClaw suppresses that partial parent update instead of announcing it.
|
||||
|
||||
For text-only Discord announce targets, OpenClaw sends the canonical final
|
||||
assistant text once instead of replaying both streamed/intermediate text payloads
|
||||
and the final answer. Media and structured Discord payloads are still delivered
|
||||
as separate payloads so attachments and components are not dropped.
|
||||
|
||||
### Payload options for isolated jobs
|
||||
|
||||
- `--message`: prompt text (required for isolated)
|
||||
@@ -111,7 +118,7 @@ Model-selection precedence for isolated jobs is:
|
||||
|
||||
1. Gmail hook model override (when the run came from Gmail and that override is allowed)
|
||||
2. Per-job payload `model`
|
||||
3. Stored cron session model override
|
||||
3. User-selected stored cron session model override
|
||||
4. Agent/default model selection
|
||||
|
||||
Fast mode follows the resolved live selection too. If the selected model config
|
||||
@@ -119,10 +126,11 @@ has `params.fastMode`, isolated cron uses that by default. A stored session
|
||||
`fastMode` override still wins over config in either direction.
|
||||
|
||||
If an isolated run hits a live model-switch handoff, cron retries with the
|
||||
switched provider/model and persists that live selection before retrying. When
|
||||
the switch also carries a new auth profile, cron persists that auth profile
|
||||
override too. Retries are bounded: after the initial attempt plus 2 switch
|
||||
retries, cron aborts instead of looping forever.
|
||||
switched provider/model and persists that live selection for the active run
|
||||
before retrying. When the switch also carries a new auth profile, cron persists
|
||||
that auth profile override for the active run too. Retries are bounded: after
|
||||
the initial attempt plus 2 switch retries, cron aborts instead of looping
|
||||
forever.
|
||||
|
||||
## Delivery and output
|
||||
|
||||
|
||||
@@ -267,6 +267,9 @@ Now create some channels on your Discord server and start chatting. Your agent c
|
||||
- Guild channels are isolated session keys (`agent:<agentId>:discord:channel:<channelId>`).
|
||||
- Group DMs are ignored by default (`channels.discord.dm.groupEnabled=false`).
|
||||
- Native slash commands run in isolated command sessions (`agent:<agentId>:discord:slash:<userId>`), while still carrying `CommandTargetSessionKey` to the routed conversation session.
|
||||
- Text-only cron/heartbeat announce delivery to Discord uses the final
|
||||
assistant-visible answer once. Media and structured component payloads remain
|
||||
multi-message when the agent emits multiple deliverable payloads.
|
||||
|
||||
## Forum channels
|
||||
|
||||
|
||||
@@ -430,6 +430,12 @@ Full configuration: [Gateway configuration](/gateway/configuration)
|
||||
- ✅ Thread replies
|
||||
- ✅ Media replies stay thread-aware when replying to a thread message
|
||||
|
||||
For `groupSessionScope: "group_topic"` and `"group_topic_sender"`, native
|
||||
Feishu/Lark topic groups use the event `thread_id` (`omt_*`) as the canonical
|
||||
topic session key. Normal group replies that OpenClaw turns into threads keep
|
||||
using the reply root message ID (`om_*`) so the first turn and follow-up turn
|
||||
stay in the same session.
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
@@ -104,6 +104,28 @@ existing approval as-is and creates a fresh pending upgrade request. Use
|
||||
`openclaw devices list` to compare the currently approved access with the newly
|
||||
requested access before you approve.
|
||||
|
||||
### Optional trusted-CIDR node auto-approve
|
||||
|
||||
Device pairing remains manual by default. For tightly controlled node networks,
|
||||
you can opt in to first-time node auto-approval with explicit CIDRs or exact IPs:
|
||||
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
nodes: {
|
||||
pairing: {
|
||||
autoApproveCidrs: ["192.168.1.0/24"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
This only applies to fresh `role: node` pairing requests with no requested
|
||||
scopes. Operator, browser, Control UI, and WebChat clients still require manual
|
||||
approval. Role, scope, metadata, and public-key changes still require manual
|
||||
approval.
|
||||
|
||||
### Node pairing state storage
|
||||
|
||||
Stored under `~/.openclaw/devices/`:
|
||||
|
||||
@@ -208,6 +208,7 @@ Groups:
|
||||
- Outbound text is chunked to `channels.signal.textChunkLimit` (default 4000).
|
||||
- Optional newline chunking: set `channels.signal.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking.
|
||||
- Attachments supported (base64 fetched from `signal-cli`).
|
||||
- Voice-note attachments use the `signal-cli` filename as a MIME fallback when `contentType` is missing, so audio transcription can still classify AAC voice memos.
|
||||
- Default media cap: `channels.signal.mediaMaxMb` (default 8).
|
||||
- Use `channels.signal.ignoreAttachments` to skip downloading media.
|
||||
- Group history context uses `channels.signal.historyLimit` (or `channels.signal.accounts.*.historyLimit`), falling back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
|
||||
|
||||
@@ -361,6 +361,7 @@ When the linked self number is also present in `allowFrom`, WhatsApp self-chat s
|
||||
|
||||
<Accordion title="Outbound media behavior">
|
||||
- supports image, video, audio (PTT voice-note), and document payloads
|
||||
- reply payloads preserve `audioAsVoice`; WhatsApp sends audio media as Baileys PTT voice notes
|
||||
- `audio/ogg` is rewritten to `audio/ogg; codecs=opus` for voice-note compatibility
|
||||
- animated GIF playback is supported via `gifPlayback: true` on video sends
|
||||
- captions are applied to the first media item when sending multi-media reply payloads
|
||||
|
||||
@@ -79,7 +79,7 @@ gh workflow run duplicate-after-merge.yml \
|
||||
| `android` | Android unit tests for both flavors plus one debug APK build | Android-relevant changes |
|
||||
| `test-performance-agent` | Daily Codex slow-test optimization after trusted activity | Main CI success or manual dispatch |
|
||||
|
||||
## Fail-Fast Order
|
||||
## Fail-fast order
|
||||
|
||||
Jobs are ordered so cheap checks fail before expensive ones run:
|
||||
|
||||
@@ -90,8 +90,9 @@ Jobs are ordered so cheap checks fail before expensive ones run:
|
||||
|
||||
Scope logic lives in `scripts/ci-changed-scope.mjs` and is covered by unit tests in `src/scripts/ci-changed-scope.test.ts`.
|
||||
CI workflow edits validate the Node CI graph plus workflow linting, but do not force Windows, Android, or macOS native builds by themselves; those platform lanes stay scoped to platform source changes.
|
||||
CI routing-only edits, selected cheap core-test fixture edits, and narrow plugin contract helper/test-routing edits use a fast Node-only manifest path: preflight, security, and a single `checks-fast-core` task. That path avoids build artifacts, Node 22 compatibility, channel contracts, full core shards, bundled-plugin shards, and additional guard matrices when the changed files are limited to the routing or helper surfaces that the fast task exercises directly.
|
||||
Windows Node checks are scoped to Windows-specific process/path wrappers, npm/pnpm/UI runner helpers, package manager config, and the CI workflow surfaces that execute that lane; unrelated source, plugin, install-smoke, and test-only changes stay on the Linux Node lanes so they do not reserve a 16-vCPU Windows worker for coverage that is already exercised by the normal test shards.
|
||||
The separate `install-smoke` workflow reuses the same scope script through its own `preflight` job. It splits smoke coverage into `run_fast_install_smoke` and `run_full_install_smoke`. Pull requests run the fast path for Docker/package surfaces, bundled plugin package/manifest changes, and core plugin/channel/gateway/Plugin SDK surfaces that the Docker smoke jobs exercise. Source-only bundled plugin changes, test-only edits, and docs-only edits do not reserve Docker workers. The fast path builds the root Dockerfile image once, checks the CLI, runs the agents delete shared-workspace CLI smoke, runs the container gateway-network e2e, verifies a bundled extension build arg, and runs the bounded bundled-plugin Docker profile under a 120-second command timeout. The full path keeps QR package install and installer Docker/update coverage for nightly scheduled runs, manual dispatches, workflow-call release checks, and pull requests that truly touch installer/package/Docker surfaces. `main` pushes, including merge commits, do not force the full path; when changed-scope logic would request full coverage on a push, the workflow keeps the fast Docker smoke and leaves the full install smoke to nightly or release validation. The slow Bun global install image-provider smoke is separately gated by `run_bun_global_install_smoke`; it runs on the nightly schedule and from the release checks workflow, and manual `install-smoke` dispatches can opt into it, but pull requests and `main` pushes do not run it. QR and installer Docker tests keep their own install-focused Dockerfiles. Local `test:docker:all` prebuilds one shared live-test image and one shared `scripts/e2e/Dockerfile` built-app image, then runs the live/E2E smoke lanes with a weighted scheduler and `OPENCLAW_SKIP_DOCKER_BUILD=1`; tune the default main-pool slot count of 10 with `OPENCLAW_DOCKER_ALL_PARALLELISM` and the provider-sensitive tail-pool slot count of 10 with `OPENCLAW_DOCKER_ALL_TAIL_PARALLELISM`. Heavy lane caps default to `OPENCLAW_DOCKER_ALL_LIVE_LIMIT=6`, `OPENCLAW_DOCKER_ALL_NPM_LIMIT=8`, and `OPENCLAW_DOCKER_ALL_SERVICE_LIMIT=7` so npm install and multi-service lanes do not overcommit Docker while lighter lanes still fill available slots. Lane starts are staggered by 2 seconds by default to avoid local Docker daemon create storms; override with `OPENCLAW_DOCKER_ALL_START_STAGGER_MS=0` or another millisecond value. The local aggregate preflights Docker, removes stale OpenClaw E2E containers, emits active-lane status, persists lane timings for longest-first ordering, and supports `OPENCLAW_DOCKER_ALL_DRY_RUN=1` for scheduler inspection. It stops scheduling new pooled lanes after the first failure by default, and each lane has a 120-minute fallback timeout overrideable with `OPENCLAW_DOCKER_ALL_LANE_TIMEOUT_MS`; selected live/tail lanes use tighter per-lane caps. The reusable live/E2E workflow mirrors the shared-image pattern by building and pushing one SHA-tagged GHCR Docker E2E image before the Docker matrix, then running the matrix with `OPENCLAW_SKIP_DOCKER_BUILD=1`. The scheduled live/E2E workflow runs the full release-path Docker suite daily. The bundled update matrix is split by update target so repeated npm update and doctor repair passes can shard with other bundled checks.
|
||||
The separate `install-smoke` workflow reuses the same scope script through its own `preflight` job. It splits smoke coverage into `run_fast_install_smoke` and `run_full_install_smoke`. Pull requests run the fast path for Docker/package surfaces, bundled plugin package/manifest changes, and core plugin/channel/gateway/Plugin SDK surfaces that the Docker smoke jobs exercise. Source-only bundled plugin changes, test-only edits, and docs-only edits do not reserve Docker workers. The fast path builds the root Dockerfile image once, checks the CLI, runs the agents delete shared-workspace CLI smoke, runs the container gateway-network e2e, verifies a bundled extension build arg, and runs the bounded bundled-plugin Docker profile under a 240-second aggregate command timeout with each scenario's Docker run capped separately. The full path keeps QR package install and installer Docker/update coverage for nightly scheduled runs, manual dispatches, workflow-call release checks, and pull requests that truly touch installer/package/Docker surfaces. `main` pushes, including merge commits, do not force the full path; when changed-scope logic would request full coverage on a push, the workflow keeps the fast Docker smoke and leaves the full install smoke to nightly or release validation. The slow Bun global install image-provider smoke is separately gated by `run_bun_global_install_smoke`; it runs on the nightly schedule and from the release checks workflow, and manual `install-smoke` dispatches can opt into it, but pull requests and `main` pushes do not run it. QR and installer Docker tests keep their own install-focused Dockerfiles. Local `test:docker:all` prebuilds one shared live-test image and one shared `scripts/e2e/Dockerfile` built-app image, then runs the live/E2E smoke lanes with a weighted scheduler and `OPENCLAW_SKIP_DOCKER_BUILD=1`; tune the default main-pool slot count of 10 with `OPENCLAW_DOCKER_ALL_PARALLELISM` and the provider-sensitive tail-pool slot count of 10 with `OPENCLAW_DOCKER_ALL_TAIL_PARALLELISM`. Heavy lane caps default to `OPENCLAW_DOCKER_ALL_LIVE_LIMIT=6`, `OPENCLAW_DOCKER_ALL_NPM_LIMIT=8`, and `OPENCLAW_DOCKER_ALL_SERVICE_LIMIT=7` so npm install and multi-service lanes do not overcommit Docker while lighter lanes still fill available slots. Lane starts are staggered by 2 seconds by default to avoid local Docker daemon create storms; override with `OPENCLAW_DOCKER_ALL_START_STAGGER_MS=0` or another millisecond value. The local aggregate preflights Docker, removes stale OpenClaw E2E containers, emits active-lane status, persists lane timings for longest-first ordering, and supports `OPENCLAW_DOCKER_ALL_DRY_RUN=1` for scheduler inspection. It stops scheduling new pooled lanes after the first failure by default, and each lane has a 120-minute fallback timeout overrideable with `OPENCLAW_DOCKER_ALL_LANE_TIMEOUT_MS`; selected live/tail lanes use tighter per-lane caps. The reusable live/E2E workflow mirrors the shared-image pattern by building and pushing one SHA-tagged GHCR Docker E2E image before the Docker matrix, then running the matrix with `OPENCLAW_SKIP_DOCKER_BUILD=1`. The scheduled live/E2E workflow runs the full release-path Docker suite daily. The bundled update matrix is split by update target so repeated npm update and doctor repair passes can shard with other bundled checks.
|
||||
|
||||
Local changed-lane logic lives in `scripts/changed-lanes.mjs` and is executed by `scripts/check-changed.mjs`. That local gate is stricter about architecture boundaries than the broad CI platform scope: core production changes run core prod typecheck plus core tests, core test-only changes run only core test typecheck/tests, extension production changes run extension prod typecheck plus extension tests, and extension test-only changes run only extension test typecheck/tests. Public Plugin SDK or plugin-contract changes expand to extension validation because extensions depend on those core contracts. Release metadata-only version bumps run targeted version/config/root-dependency checks. Unknown root/config changes fail safe to all lanes.
|
||||
|
||||
|
||||
@@ -164,6 +164,7 @@ Navigate/click/type (ref-based UI automation):
|
||||
```bash
|
||||
openclaw browser navigate https://example.com
|
||||
openclaw browser click <ref>
|
||||
openclaw browser click-coords 120 340
|
||||
openclaw browser type <ref> "hello"
|
||||
openclaw browser press Enter
|
||||
openclaw browser hover <ref>
|
||||
@@ -241,6 +242,8 @@ This path is host-only. For Docker, headless servers, Browserless, or other remo
|
||||
Current existing-session limits:
|
||||
|
||||
- snapshot-driven actions use refs, not CSS selectors
|
||||
- `browser.actionTimeoutMs` defaults supported `act` requests to 60000 ms when
|
||||
callers omit `timeoutMs`; per-call `timeoutMs` still wins.
|
||||
- `click` is left-click only
|
||||
- `type` does not support `slowly=true`
|
||||
- `press` does not support `delayMs`
|
||||
|
||||
@@ -186,7 +186,7 @@ openclaw config set secrets.providers.vaultfile \
|
||||
--strict-json
|
||||
```
|
||||
|
||||
## Provider Builder Flags
|
||||
## Provider builder flags
|
||||
|
||||
Provider builder targets must use `secrets.providers.<alias>` as the path.
|
||||
|
||||
@@ -279,7 +279,7 @@ Dry-run behavior:
|
||||
- `skippedExecRefs`: number of exec refs skipped because `--allow-exec` was not set
|
||||
- `errors`: structured schema/resolvability failures when `ok=false`
|
||||
|
||||
### JSON Output Shape
|
||||
### JSON output shape
|
||||
|
||||
```json5
|
||||
{
|
||||
|
||||
@@ -33,6 +33,11 @@ Note: `--session` supports `main`, `isolated`, `current`, and `session:<id>`.
|
||||
Use `current` to bind to the active session at creation time, or `session:<id>` for
|
||||
an explicit persistent session key.
|
||||
|
||||
Note: `--session isolated` creates a fresh transcript/session id for each run.
|
||||
Safe preferences and explicit user-selected model/auth overrides can carry, but
|
||||
ambient conversation context does not: channel/group routing, send/queue policy,
|
||||
elevation, origin, and ACP runtime binding are reset for the new isolated run.
|
||||
|
||||
Note: for one-shot CLI jobs, offset-less `--at` datetimes are treated as UTC unless you also pass
|
||||
`--tz <iana>`, which interprets that local wall-clock time in the given timezone.
|
||||
|
||||
@@ -59,17 +64,17 @@ model override with no explicit per-job fallback list no longer appends the
|
||||
agent primary as a hidden extra retry target.
|
||||
|
||||
Note: isolated cron model precedence is Gmail-hook override first, then per-job
|
||||
`--model`, then any stored cron-session model override, then the normal
|
||||
agent/default selection.
|
||||
`--model`, then any user-selected stored cron-session model override, then the
|
||||
normal agent/default selection.
|
||||
|
||||
Note: isolated cron fast mode follows the resolved live model selection. Model
|
||||
config `params.fastMode` applies by default, but a stored session `fastMode`
|
||||
override still wins over config.
|
||||
|
||||
Note: if an isolated run throws `LiveSessionModelSwitchError`, cron persists the
|
||||
switched provider/model (and switched auth profile override when present) before
|
||||
retrying. The outer retry loop is bounded to 2 switch retries after the initial
|
||||
attempt, then aborts instead of looping forever.
|
||||
switched provider/model (and switched auth profile override when present) for
|
||||
the active run before retrying. The outer retry loop is bounded to 2 switch
|
||||
retries after the initial attempt, then aborts instead of looping forever.
|
||||
|
||||
Note: failure notifications use `delivery.failureDestination` first, then
|
||||
global `cron.failureDestination`, and finally fall back to the job's primary
|
||||
|
||||
@@ -66,6 +66,12 @@ request. Review the `Requested` vs `Approved` columns in `openclaw devices list`
|
||||
or use `openclaw devices approve --latest` to preview the exact upgrade before
|
||||
approving it.
|
||||
|
||||
If the Gateway is explicitly configured with
|
||||
`gateway.nodes.pairing.autoApproveCidrs`, first-time `role: node` requests from
|
||||
matching client IPs can be approved before they appear in this list. That policy
|
||||
is disabled by default and never applies to operator/browser clients or upgrade
|
||||
requests.
|
||||
|
||||
```
|
||||
openclaw devices approve
|
||||
openclaw devices approve <requestId>
|
||||
@@ -127,6 +133,8 @@ Pass `--token` or `--password` explicitly. Missing explicit credentials is an er
|
||||
|
||||
- Token rotation returns a new token (sensitive). Treat it like a secret.
|
||||
- These commands require `operator.pairing` (or `operator.admin`) scope.
|
||||
- `gateway.nodes.pairing.autoApproveCidrs` is an opt-in Gateway policy for
|
||||
fresh node device pairing only; it does not change CLI approval authority.
|
||||
- Token rotation stays inside the approved pairing role set and approved scope
|
||||
baseline for that device. A stray cached token entry does not grant a new
|
||||
rotate target.
|
||||
|
||||
@@ -376,6 +376,9 @@ Important behavior:
|
||||
- embedded Pi exposes configured MCP tools in normal `coding` and `messaging`
|
||||
tool profiles; `minimal` still hides them, and `tools.deny: ["bundle-mcp"]`
|
||||
disables them explicitly
|
||||
- session-scoped bundled MCP runtimes are reaped after `mcp.sessionIdleTtlMs`
|
||||
milliseconds of idle time (default 10 minutes; set `0` to disable) and
|
||||
one-shot embedded runs clean them up at run end
|
||||
|
||||
## Saved MCP server definitions
|
||||
|
||||
|
||||
@@ -50,6 +50,10 @@ Notes:
|
||||
- `models list --all` includes bundled provider-owned static catalog rows even
|
||||
when you have not authenticated with that provider yet. Those rows still show
|
||||
as unavailable until matching auth is configured.
|
||||
- `models list` keeps native model metadata and runtime caps distinct. In table
|
||||
output, `Ctx` shows `contextTokens/contextWindow` when an effective runtime
|
||||
cap differs from the native context window; JSON rows include `contextTokens`
|
||||
when a provider exposes that cap.
|
||||
- `models list --provider <id>` filters by provider id, such as `moonshot` or
|
||||
`openai-codex`. It does not accept display labels from interactive provider
|
||||
pickers, such as `Moonshot AI`.
|
||||
|
||||
@@ -123,6 +123,25 @@ openclaw devices list
|
||||
openclaw devices approve <requestId>
|
||||
```
|
||||
|
||||
On tightly controlled node networks, the Gateway operator can explicitly opt in
|
||||
to auto-approving first-time node pairing from trusted CIDRs:
|
||||
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
nodes: {
|
||||
pairing: {
|
||||
autoApproveCidrs: ["192.168.1.0/24"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
This is disabled by default. It only applies to fresh `role: node` pairing with
|
||||
no requested scopes. Operator/browser clients, Control UI, WebChat, and role,
|
||||
scope, metadata, or public-key upgrades still require manual approval.
|
||||
|
||||
If the node retries pairing with changed auth details (role/scopes/public key),
|
||||
the previous pending request is superseded and a new `requestId` is created.
|
||||
Run `openclaw devices list` again before approval.
|
||||
|
||||
@@ -42,6 +42,9 @@ filter to nodes that connected within a duration (e.g. `24h`, `7d`).
|
||||
Approval note:
|
||||
|
||||
- `openclaw nodes pending` only needs pairing scope.
|
||||
- `gateway.nodes.pairing.autoApproveCidrs` can skip the pending step only for
|
||||
explicitly trusted, first-time `role: node` device pairing. It is off by
|
||||
default and does not approve upgrades.
|
||||
- `openclaw nodes approve <requestId>` inherits extra scope requirements from the
|
||||
pending request:
|
||||
- commandless request: pairing only
|
||||
|
||||
@@ -14,7 +14,7 @@ the finished turn to OpenClaw.
|
||||
Runtimes are easy to confuse with providers because both show up near model
|
||||
configuration. They are different layers:
|
||||
|
||||
| Layer | Examples | What It Means |
|
||||
| Layer | Examples | What it means |
|
||||
| ------------- | ------------------------------------- | ------------------------------------------------------------------- |
|
||||
| Provider | `openai`, `anthropic`, `openai-codex` | How OpenClaw authenticates, discovers models, and names model refs. |
|
||||
| Model | `gpt-5.5`, `claude-opus-4-6` | The model selected for the agent turn. |
|
||||
@@ -97,7 +97,7 @@ routed back to PI just because defaults used `fallback: "pi"`.
|
||||
When a runtime is not PI, it should document what OpenClaw surfaces it supports.
|
||||
Use this shape for runtime docs:
|
||||
|
||||
| Question | Why It Matters |
|
||||
| Question | Why it matters |
|
||||
| -------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| Who owns the model loop? | Determines where retries, tool continuation, and final answer decisions happen. |
|
||||
| Who owns canonical thread history? | Determines whether OpenClaw can edit history or only mirror it. |
|
||||
|
||||
@@ -38,8 +38,9 @@ To set a provider explicitly:
|
||||
|
||||
Without an embedding provider, only keyword search is available.
|
||||
|
||||
To force the built-in local embedding provider, point `local.modelPath` at a
|
||||
GGUF file:
|
||||
To force the built-in local embedding provider, install the optional
|
||||
`node-llama-cpp` runtime package next to OpenClaw, then point `local.modelPath`
|
||||
at a GGUF file:
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -66,7 +67,7 @@ GGUF file:
|
||||
| Voyage | `voyage` | Yes | |
|
||||
| Mistral | `mistral` | Yes | |
|
||||
| Ollama | `ollama` | No | Local, set explicitly |
|
||||
| Local | `local` | Yes (first) | GGUF model, ~0.6 GB download |
|
||||
| Local | `local` | Yes (first) | Optional `node-llama-cpp` runtime |
|
||||
|
||||
Auto-detection picks the first provider whose API key can be resolved, in the
|
||||
order shown. Set `memorySearch.provider` to override.
|
||||
|
||||
@@ -15,7 +15,8 @@ binary, and can index content beyond your workspace memory files.
|
||||
- **Reranking and query expansion** for better recall.
|
||||
- **Index extra directories** -- project docs, team notes, anything on disk.
|
||||
- **Index session transcripts** -- recall earlier conversations.
|
||||
- **Fully local** -- runs via Bun + node-llama-cpp, auto-downloads GGUF models.
|
||||
- **Fully local** -- runs with the optional node-llama-cpp runtime package and
|
||||
auto-downloads GGUF models.
|
||||
- **Automatic fallback** -- if QMD is unavailable, OpenClaw falls back to the
|
||||
builtin engine seamlessly.
|
||||
|
||||
|
||||
@@ -29,8 +29,8 @@ explicitly:
|
||||
}
|
||||
```
|
||||
|
||||
For local embeddings with no API key, use `provider: "local"` (requires
|
||||
node-llama-cpp).
|
||||
For local embeddings with no API key, install the optional `node-llama-cpp`
|
||||
runtime package next to OpenClaw and use `provider: "local"`.
|
||||
|
||||
## Supported providers
|
||||
|
||||
|
||||
@@ -6,8 +6,7 @@ read_when:
|
||||
title: "Model providers"
|
||||
---
|
||||
|
||||
This page covers **LLM/model providers** (not chat channels like WhatsApp/Telegram).
|
||||
For model selection rules, see [/concepts/models](/concepts/models).
|
||||
Reference for **LLM/model providers** (not chat channels like WhatsApp/Telegram). For model selection rules, see [Models](/concepts/models).
|
||||
|
||||
## Quick rules
|
||||
|
||||
@@ -31,11 +30,9 @@ For model selection rules, see [/concepts/models](/concepts/models).
|
||||
`google-gemini-cli`, or `codex-cli` when you want a local CLI backend.
|
||||
Legacy `claude-cli/*`, `google-gemini-cli/*`, and `codex-cli/*` refs migrate
|
||||
back to canonical provider refs with the runtime recorded separately.
|
||||
- GPT-5.5 is currently available through subscription/OAuth routes:
|
||||
`openai-codex/gpt-5.5` in PI or `openai/gpt-5.5` with the Codex app-server
|
||||
harness. The direct API-key route for `openai/gpt-5.5` is supported once
|
||||
OpenAI enables GPT-5.5 on the public API; until then use API-enabled models
|
||||
such as `openai/gpt-5.4` for `OPENAI_API_KEY` setups.
|
||||
- GPT-5.5 is available through `openai-codex/gpt-5.5` in PI, the native
|
||||
Codex app-server harness, and the public OpenAI API when the bundled PI
|
||||
catalog exposes `openai/gpt-5.5` for your install.
|
||||
|
||||
## Plugin-owned provider behavior
|
||||
|
||||
@@ -74,10 +71,10 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no**
|
||||
- Provider: `openai`
|
||||
- Auth: `OPENAI_API_KEY`
|
||||
- Optional rotation: `OPENAI_API_KEYS`, `OPENAI_API_KEY_1`, `OPENAI_API_KEY_2`, plus `OPENCLAW_LIVE_OPENAI_KEY` (single override)
|
||||
- Example models: `openai/gpt-5.4`, `openai/gpt-5.4-mini`
|
||||
- GPT-5.5 direct API support is future-ready here once OpenAI exposes GPT-5.5 on the API
|
||||
- Verify direct API availability with `openclaw models list --provider openai`
|
||||
before using `openai/gpt-5.5` without the Codex app-server runtime
|
||||
- Example models: `openai/gpt-5.5`, `openai/gpt-5.4`, `openai/gpt-5.4-mini`
|
||||
- GPT-5.5 direct API support depends on the bundled PI catalog version for
|
||||
your install; verify with `openclaw models list --provider openai` before
|
||||
using `openai/gpt-5.5` without the Codex app-server runtime.
|
||||
- CLI: `openclaw onboard --auth-choice openai-api-key`
|
||||
- Default transport is `auto` (WebSocket-first, SSE fallback)
|
||||
- Override per model via `agents.defaults.models["openai/<model>"].params.transport` (`"sse"`, `"websocket"`, or `"auto"`)
|
||||
@@ -134,9 +131,9 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no**
|
||||
`User-Agent`) are only attached on native Codex traffic to
|
||||
`chatgpt.com/backend-api`, not generic OpenAI-compatible proxies
|
||||
- Shares the same `/fast` toggle and `params.fastMode` config as direct `openai/*`; OpenClaw maps that to `service_tier=priority`
|
||||
- `openai-codex/gpt-5.5` keeps native `contextWindow = 1000000` and a default runtime `contextTokens = 272000`; override the runtime cap with `models.providers.openai-codex.models[].contextTokens`
|
||||
- `openai-codex/gpt-5.5` uses the Codex catalog native `contextWindow = 400000` and default runtime `contextTokens = 272000`; override the runtime cap with `models.providers.openai-codex.models[].contextTokens`
|
||||
- Policy note: OpenAI Codex OAuth is explicitly supported for external tools/workflows like OpenClaw.
|
||||
- Current GPT-5.5 access uses this OAuth/subscription route until OpenAI enables GPT-5.5 on the public API.
|
||||
- Use `openai-codex/gpt-5.5` when you want the Codex OAuth/subscription route; use `openai/gpt-5.5` when your API-key setup and local catalog expose the public API route.
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -160,7 +157,7 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no**
|
||||
|
||||
- [Qwen Cloud](/providers/qwen): Qwen Cloud provider surface plus Alibaba DashScope and Coding Plan endpoint mapping
|
||||
- [MiniMax](/providers/minimax): MiniMax Coding Plan OAuth or API key access
|
||||
- [GLM Models](/providers/glm): Z.AI Coding Plan or general API endpoints
|
||||
- [GLM models](/providers/glm): Z.AI Coding Plan or general API endpoints
|
||||
|
||||
### OpenCode
|
||||
|
||||
@@ -270,7 +267,7 @@ See [/providers/kilocode](/providers/kilocode) for setup details.
|
||||
|
||||
Quirks worth knowing:
|
||||
|
||||
- **OpenRouter** applies its app-attribution headers and Anthropic `cache_control` markers only on verified `openrouter.ai` routes. As a proxy-style OpenAI-compatible path, it skips native-OpenAI-only shaping (`serviceTier`, Responses `store`, prompt-cache hints, OpenAI reasoning-compat). Gemini-backed refs keep proxy-Gemini thought-signature sanitation only.
|
||||
- **OpenRouter** applies its app-attribution headers and Anthropic `cache_control` markers only on verified `openrouter.ai` routes. DeepSeek, Moonshot, and ZAI refs are cache-TTL eligible for OpenRouter-managed prompt caching but do not receive Anthropic cache markers. As a proxy-style OpenAI-compatible path, it skips native-OpenAI-only shaping (`serviceTier`, Responses `store`, prompt-cache hints, OpenAI reasoning-compat). Gemini-backed refs keep proxy-Gemini thought-signature sanitation only.
|
||||
- **Kilo Gateway** Gemini-backed refs follow the same proxy-Gemini sanitation path; `kilocode/kilo/auto` and other proxy-reasoning-unsupported refs skip proxy reasoning injection.
|
||||
- **MiniMax** API-key onboarding writes explicit text-only M2.7 chat model definitions; image understanding stays on the plugin-owned `MiniMax-VL-01` media provider.
|
||||
- **xAI** uses the xAI Responses path. `/fast` or `params.fastMode: true` rewrites `grok-3`, `grok-3-mini`, `grok-4`, and `grok-4-0709` to their `*-fast` variants. `tool_stream` defaults on; disable via `agents.defaults.models["xai/<model>"].params.tool_stream=false`.
|
||||
@@ -646,11 +643,11 @@ openclaw models set opencode/claude-opus-4-6
|
||||
openclaw models list
|
||||
```
|
||||
|
||||
See also: [/gateway/configuration](/gateway/configuration) for full configuration examples.
|
||||
See also: [Configuration](/gateway/configuration) for full configuration examples.
|
||||
|
||||
## Related
|
||||
|
||||
- [Models](/concepts/models) — model configuration and aliases
|
||||
- [Model Failover](/concepts/model-failover) — fallback chains and retry behavior
|
||||
- [Configuration Reference](/gateway/config-agents#agent-defaults) — model config keys
|
||||
- [Model failover](/concepts/model-failover) — fallback chains and retry behavior
|
||||
- [Configuration reference](/gateway/config-agents#agent-defaults) — model config keys
|
||||
- [Providers](/providers) — per-provider setup guides
|
||||
|
||||
@@ -171,8 +171,8 @@ How to see what profile IDs exist:
|
||||
|
||||
Related docs:
|
||||
|
||||
- [/concepts/model-failover](/concepts/model-failover) (rotation + cooldown rules)
|
||||
- [/tools/slash-commands](/tools/slash-commands) (command surface)
|
||||
- [Model failover](/concepts/model-failover) (rotation + cooldown rules)
|
||||
- [Slash commands](/tools/slash-commands) (command surface)
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -7,8 +7,6 @@ read_when:
|
||||
title: "Streaming and chunking"
|
||||
---
|
||||
|
||||
# Streaming + chunking
|
||||
|
||||
OpenClaw has two separate streaming layers:
|
||||
|
||||
- **Block streaming (channels):** emit completed **blocks** as the assistant writes. These are normal channel messages (not token deltas).
|
||||
|
||||
@@ -1165,6 +1165,7 @@
|
||||
"plugins/hooks",
|
||||
"plugins/sdk-channel-plugins",
|
||||
"plugins/sdk-provider-plugins",
|
||||
"plugins/compatibility",
|
||||
"plugins/sdk-migration"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -349,6 +349,12 @@ When bundle MCP is enabled, OpenClaw:
|
||||
If no MCP servers are enabled, OpenClaw still injects a strict config when a
|
||||
backend opts into bundle MCP so background runs stay isolated.
|
||||
|
||||
Session-scoped bundled MCP runtimes are cached for reuse within a session, then
|
||||
reaped after `mcp.sessionIdleTtlMs` milliseconds of idle time (default 10
|
||||
minutes; set `0` to disable). One-shot embedded runs such as auth probes,
|
||||
slug generation, and active-memory recall request cleanup at run end so stdio
|
||||
children and Streamable HTTP/SSE streams do not outlive the run.
|
||||
|
||||
## Limitations
|
||||
|
||||
- **No direct OpenClaw tool calls.** OpenClaw does not inject tool calls into
|
||||
|
||||
@@ -72,6 +72,7 @@ Disables automatic creation of workspace bootstrap files (`AGENTS.md`, `SOUL.md`
|
||||
Controls when workspace bootstrap files are injected into the system prompt. Default: `"always"`.
|
||||
|
||||
- `"continuation-skip"`: safe continuation turns (after a completed assistant response) skip workspace bootstrap re-injection, reducing prompt size. Heartbeat runs and post-compaction retries still rebuild context.
|
||||
- `"never"`: disable workspace bootstrap and context-file injection on every turn. Use this only for agents that fully own their prompt lifecycle (custom context engines, native runtimes that build their own context, or specialized bootstrap-free workflows). Heartbeat and compaction-recovery turns also skip injection.
|
||||
|
||||
```json5
|
||||
{
|
||||
|
||||
@@ -415,7 +415,7 @@ OpenClaw uses the built-in model catalog. Add custom providers via `models.provi
|
||||
- `request.allowPrivateNetwork`: when `true`, allow HTTPS to `baseUrl` when DNS resolves to private, CGNAT, or similar ranges, via the provider HTTP fetch guard (operator opt-in for trusted self-hosted OpenAI-compatible endpoints). WebSocket uses the same `request` for headers/TLS but not that fetch SSRF gate. Default `false`.
|
||||
- `models.providers.*.models`: explicit provider model catalog entries.
|
||||
- `models.providers.*.models.*.contextWindow`: native model context window metadata.
|
||||
- `models.providers.*.models.*.contextTokens`: optional runtime context cap. Use this when you want a smaller effective context budget than the model's native `contextWindow`.
|
||||
- `models.providers.*.models.*.contextTokens`: optional runtime context cap. Use this when you want a smaller effective context budget than the model's native `contextWindow`; `openclaw models list` shows both values when they differ.
|
||||
- `models.providers.*.models.*.compat.supportsDeveloperRole`: optional compatibility hint. For `api: "openai-completions"` with a non-empty non-native `baseUrl` (host not `api.openai.com`), OpenClaw forces this to `false` at runtime. Empty/omitted `baseUrl` keeps default OpenAI behavior.
|
||||
- `models.providers.*.models.*.compat.requiresStringContent`: optional compatibility hint for string-only OpenAI-compatible chat endpoints. When `true`, OpenClaw flattens pure text `messages[].content` arrays into plain strings before sending the request.
|
||||
- `plugins.entries.amazon-bedrock.config.discovery`: Bedrock auto-discovery settings root.
|
||||
|
||||
@@ -501,6 +501,28 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
|
||||
}
|
||||
```
|
||||
|
||||
### Trusted node network auto-approval
|
||||
|
||||
Keep device pairing manual unless you control the network path. For a dedicated
|
||||
lab or tailnet subnet, you can opt in to first-time node device auto-approval
|
||||
with exact CIDRs or IPs:
|
||||
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
nodes: {
|
||||
pairing: {
|
||||
autoApproveCidrs: ["192.168.1.0/24", "fd00:1234:5678::/64"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
This remains off when unset. It only applies to fresh `role: node` pairing with
|
||||
no requested scopes. Operator/browser clients and role, scope, metadata, or
|
||||
public-key upgrades still require manual approval.
|
||||
|
||||
### Secure DM mode (shared inbox / multi-user DMs)
|
||||
|
||||
If more than one person can DM your bot (multiple entries in `allowFrom`, pairing approvals for multiple people, or `dmPolicy: "open"`), enable **secure DM mode** so DMs from different senders don’t share one context by default:
|
||||
|
||||
@@ -8,7 +8,7 @@ read_when:
|
||||
|
||||
Core config reference for `~/.openclaw/openclaw.json`. For a task-oriented overview, see [Configuration](/gateway/configuration).
|
||||
|
||||
This page covers the main OpenClaw config surfaces and links out when a subsystem has its own deeper reference. It does **not** try to inline every channel/plugin-owned command catalog or every deep memory/QMD knob on one page.
|
||||
Covers the main OpenClaw config surfaces and links out when a subsystem has its own deeper reference. Channel- and plugin-owned command catalogs and deep memory/QMD knobs live on their own pages rather than on this one.
|
||||
|
||||
Code truth:
|
||||
|
||||
@@ -19,7 +19,7 @@ Code truth:
|
||||
Dedicated deep references:
|
||||
|
||||
- [Memory configuration reference](/reference/memory-config) for `agents.defaults.memorySearch.*`, `memory.qmd.*`, `memory.citations`, and dreaming config under `plugins.entries.memory-core.config.dreaming`
|
||||
- [Slash Commands](/tools/slash-commands) for the current built-in + bundled command catalog
|
||||
- [Slash commands](/tools/slash-commands) for the current built-in + bundled command catalog
|
||||
- owning channel/plugin pages for channel-specific command surfaces
|
||||
|
||||
Config format is **JSON5** (comments + trailing commas allowed). All fields are optional — OpenClaw uses safe defaults when omitted.
|
||||
@@ -51,6 +51,44 @@ Tool policy, experimental toggles, provider-backed tool config, and custom
|
||||
provider / base-URL setup moved to a dedicated page — see
|
||||
[Configuration — tools and custom providers](/gateway/config-tools).
|
||||
|
||||
## MCP
|
||||
|
||||
OpenClaw-managed MCP server definitions live under `mcp.servers` and are
|
||||
consumed by embedded Pi and other runtime adapters. The `openclaw mcp list`,
|
||||
`show`, `set`, and `unset` commands manage this block without connecting to the
|
||||
target server during config edits.
|
||||
|
||||
```json5
|
||||
{
|
||||
mcp: {
|
||||
// Optional. Default: 600000 ms (10 minutes). Set 0 to disable idle eviction.
|
||||
sessionIdleTtlMs: 600000,
|
||||
servers: {
|
||||
docs: {
|
||||
command: "npx",
|
||||
args: ["-y", "@modelcontextprotocol/server-fetch"],
|
||||
},
|
||||
remote: {
|
||||
url: "https://example.com/mcp",
|
||||
transport: "streamable-http", // streamable-http | sse
|
||||
headers: {
|
||||
Authorization: "Bearer ${MCP_REMOTE_TOKEN}",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- `mcp.servers`: named stdio or remote MCP server definitions for runtimes that
|
||||
expose configured MCP tools.
|
||||
- `mcp.sessionIdleTtlMs`: idle TTL for session-scoped bundled MCP runtimes.
|
||||
One-shot embedded runs request run-end cleanup; this TTL is the backstop for
|
||||
long-lived sessions and future callers.
|
||||
|
||||
See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and
|
||||
[CLI backends](/gateway/cli-backends#bundle-mcp-overlays) for runtime behavior.
|
||||
|
||||
## Skills
|
||||
|
||||
```json5
|
||||
@@ -297,6 +335,14 @@ See [Plugins](/tools/plugin).
|
||||
trustedProxies: ["10.0.0.1"],
|
||||
// Optional. Default false.
|
||||
allowRealIpFallback: false,
|
||||
nodes: {
|
||||
pairing: {
|
||||
// Optional. Default unset/disabled.
|
||||
autoApproveCidrs: ["192.168.1.0/24", "fd00:1234:5678::/64"],
|
||||
},
|
||||
allowCommands: ["canvas.navigate"],
|
||||
denyCommands: ["system.run"],
|
||||
},
|
||||
tools: {
|
||||
// Additional /tools/invoke HTTP denies
|
||||
deny: ["browser"],
|
||||
@@ -359,6 +405,8 @@ See [Plugins](/tools/plugin).
|
||||
- If `gateway.auth.token` / `gateway.auth.password` is explicitly configured via SecretRef and unresolved, resolution fails closed (no remote fallback masking).
|
||||
- `trustedProxies`: reverse proxy IPs that terminate TLS or inject forwarded-client headers. Only list proxies you control. Loopback entries are still valid for same-host proxy/local-detection setups (for example Tailscale Serve or a local reverse proxy), but they do **not** make loopback requests eligible for `gateway.auth.mode: "trusted-proxy"`.
|
||||
- `allowRealIpFallback`: when `true`, the gateway accepts `X-Real-IP` if `X-Forwarded-For` is missing. Default `false` for fail-closed behavior.
|
||||
- `gateway.nodes.pairing.autoApproveCidrs`: optional CIDR/IP allowlist for auto-approving first-time node device pairing with no requested scopes. It is disabled when unset. This does not auto-approve operator/browser/Control UI/WebChat pairing, and it does not auto-approve role, scope, metadata, or public-key upgrades.
|
||||
- `gateway.nodes.allowCommands` / `gateway.nodes.denyCommands`: global allow/deny shaping for declared node commands after pairing and allowlist evaluation.
|
||||
- `gateway.tools.deny`: extra tool names blocked for HTTP `POST /tools/invoke` (extends default deny list).
|
||||
- `gateway.tools.allow`: remove tool names from the default HTTP deny list.
|
||||
|
||||
@@ -848,6 +896,7 @@ Notes:
|
||||
- `otel.sampleRate`: trace sampling rate `0`–`1`.
|
||||
- `otel.flushIntervalMs`: periodic telemetry flush interval in ms.
|
||||
- `otel.captureContent`: opt-in raw content capture for OTEL span attributes. Defaults to off. Boolean `true` captures non-system message/tool content; the object form lets you enable `inputMessages`, `outputMessages`, `toolInputs`, `toolOutputs`, and `systemPrompt` explicitly.
|
||||
- `OPENCLAW_OTEL_PRELOADED=1`: environment toggle for hosts that already registered a global OpenTelemetry SDK. OpenClaw then skips plugin-owned SDK startup/shutdown while keeping diagnostic listeners active.
|
||||
- `cacheTrace.enabled`: log cache trace snapshots for embedded runs (default: `false`).
|
||||
- `cacheTrace.filePath`: output path for cache trace JSONL (default: `$OPENCLAW_STATE_DIR/logs/cache-trace.jsonl`).
|
||||
- `cacheTrace.includeMessages` / `includePrompt` / `includeSystem`: control what is included in cache trace output (all default: `true`).
|
||||
|
||||
@@ -265,6 +265,9 @@ Use `accountId` to target a specific account on multi-account channels like Tele
|
||||
send chat output to, and it is disabled by `typingMode: "never"`.
|
||||
- Heartbeat-only replies do **not** keep the session alive; the last `updatedAt`
|
||||
is restored so idle expiry behaves normally.
|
||||
- Control UI and WebChat history hide heartbeat prompts and OK-only
|
||||
acknowledgments. The underlying session transcript can still contain those
|
||||
turns for audit/replay.
|
||||
- Detached [background tasks](/automation/tasks) can enqueue a system event and wake heartbeat when the main session should notice something quickly. That wake does not make the heartbeat run a background task.
|
||||
|
||||
## Visibility controls
|
||||
|
||||
@@ -115,6 +115,34 @@ The macOS app can optionally attempt a **silent approval** when:
|
||||
|
||||
If silent approval fails, it falls back to the normal “Approve/Reject” prompt.
|
||||
|
||||
## Trusted-CIDR device auto-approval
|
||||
|
||||
WS device pairing for `role: node` remains manual by default. For private
|
||||
node networks where the Gateway already trusts the network path, operators can
|
||||
opt in with explicit CIDRs or exact IPs:
|
||||
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
nodes: {
|
||||
pairing: {
|
||||
autoApproveCidrs: ["192.168.1.0/24"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Security boundary:
|
||||
|
||||
- Disabled when `gateway.nodes.pairing.autoApproveCidrs` is unset.
|
||||
- No blanket LAN or private-network auto-approve mode exists.
|
||||
- Only fresh `role: node` device pairing with no requested scopes is eligible.
|
||||
- Operator, browser, Control UI, and WebChat clients stay manual.
|
||||
- Role, scope, metadata, and public-key upgrades stay manual.
|
||||
- Same-host loopback trusted-proxy header paths are not eligible because that
|
||||
path can be spoofed by local callers.
|
||||
|
||||
## Metadata-upgrade auto-approval
|
||||
|
||||
When an already paired device reconnects with only non-sensitive metadata
|
||||
|
||||
@@ -111,6 +111,7 @@ Use this as the quick model when triaging risk:
|
||||
| `canvas.eval` / browser evaluate | Intentional operator capability when enabled | "Any JS eval primitive is automatically a vuln in this trust model" |
|
||||
| Local TUI `!` shell | Explicit operator-triggered local execution | "Local shell convenience command is remote injection" |
|
||||
| Node pairing and node commands | Operator-level remote execution on paired devices | "Remote device control should be treated as untrusted user access by default" |
|
||||
| `gateway.nodes.pairing.autoApproveCidrs` | Opt-in trusted-network node enrollment policy | "A disabled-by-default allowlist is an automatic pairing vulnerability" |
|
||||
|
||||
## Not vulnerabilities by design
|
||||
|
||||
@@ -133,6 +134,12 @@ a real boundary bypass is demonstrated:
|
||||
approval layer for `system.run`, when the real execution boundary is still
|
||||
the gateway's global node command policy plus the node's own exec
|
||||
approvals.
|
||||
- Reports that treat configured `gateway.nodes.pairing.autoApproveCidrs` as a
|
||||
vulnerability by itself. This setting is disabled by default, requires
|
||||
explicit CIDR/IP entries, only applies to first-time `role: node` pairing with
|
||||
no requested scopes, and does not auto-approve operator/browser/Control UI,
|
||||
WebChat, role upgrades, scope upgrades, metadata changes, public-key changes,
|
||||
or same-host loopback trusted-proxy header paths.
|
||||
- "Missing per-user authorization" findings that treat `sessionKey` as an
|
||||
auth token.
|
||||
|
||||
@@ -353,6 +360,12 @@ gateway:
|
||||
|
||||
When `trustedProxies` is configured, the Gateway uses `X-Forwarded-For` to determine the client IP. `X-Real-IP` is ignored by default unless `gateway.allowRealIpFallback: true` is explicitly set.
|
||||
|
||||
Trusted proxy headers do not make node device pairing automatically trusted.
|
||||
`gateway.nodes.pairing.autoApproveCidrs` is a separate, disabled-by-default
|
||||
operator policy. Even when enabled, loopback-source trusted-proxy header paths
|
||||
are excluded from node auto-approval because local callers can forge those
|
||||
headers.
|
||||
|
||||
Good reverse proxy behavior (overwrite incoming forwarding headers):
|
||||
|
||||
```nginx
|
||||
|
||||
@@ -55,9 +55,9 @@ Fix options:
|
||||
|
||||
Related:
|
||||
|
||||
- [/providers/anthropic](/providers/anthropic)
|
||||
- [/reference/token-use](/reference/token-use)
|
||||
- [/help/faq-first-run#why-am-i-seeing-http-429-ratelimiterror-from-anthropic](/help/faq-first-run#why-am-i-seeing-http-429-ratelimiterror-from-anthropic)
|
||||
- [Anthropic](/providers/anthropic)
|
||||
- [Token use and costs](/reference/token-use)
|
||||
- [Why am I seeing HTTP 429 from Anthropic?](/help/faq-first-run#why-am-i-seeing-http-429-ratelimiterror-from-anthropic)
|
||||
|
||||
## Local OpenAI-compatible backend passes direct probes but agent runs fail
|
||||
|
||||
@@ -110,9 +110,9 @@ Fix options:
|
||||
|
||||
Related:
|
||||
|
||||
- [/gateway/local-models](/gateway/local-models)
|
||||
- [/gateway/configuration](/gateway/configuration)
|
||||
- [/gateway/configuration-reference#openai-compatible-endpoints](/gateway/configuration-reference#openai-compatible-endpoints)
|
||||
- [Local models](/gateway/local-models)
|
||||
- [Configuration](/gateway/configuration)
|
||||
- [OpenAI-compatible endpoints](/gateway/configuration-reference#openai-compatible-endpoints)
|
||||
|
||||
## No replies
|
||||
|
||||
@@ -140,9 +140,9 @@ Common signatures:
|
||||
|
||||
Related:
|
||||
|
||||
- [/channels/troubleshooting](/channels/troubleshooting)
|
||||
- [/channels/pairing](/channels/pairing)
|
||||
- [/channels/groups](/channels/groups)
|
||||
- [Channel troubleshooting](/channels/troubleshooting)
|
||||
- [Pairing](/channels/pairing)
|
||||
- [Groups](/channels/groups)
|
||||
|
||||
## Dashboard control ui connectivity
|
||||
|
||||
@@ -223,11 +223,11 @@ If `openclaw devices rotate` / `revoke` / `remove` is denied unexpectedly:
|
||||
|
||||
Related:
|
||||
|
||||
- [/web/control-ui](/web/control-ui)
|
||||
- [/gateway/configuration](/gateway/configuration) (gateway auth modes)
|
||||
- [/gateway/trusted-proxy-auth](/gateway/trusted-proxy-auth)
|
||||
- [/gateway/remote](/gateway/remote)
|
||||
- [/cli/devices](/cli/devices)
|
||||
- [Control UI](/web/control-ui)
|
||||
- [Configuration](/gateway/configuration) (gateway auth modes)
|
||||
- [Trusted proxy auth](/gateway/trusted-proxy-auth)
|
||||
- [Remote access](/gateway/remote)
|
||||
- [Devices](/cli/devices)
|
||||
|
||||
## Gateway service not running
|
||||
|
||||
@@ -258,9 +258,9 @@ Common signatures:
|
||||
|
||||
Related:
|
||||
|
||||
- [/gateway/background-process](/gateway/background-process)
|
||||
- [/gateway/configuration](/gateway/configuration)
|
||||
- [/gateway/doctor](/gateway/doctor)
|
||||
- [Background exec and process tool](/gateway/background-process)
|
||||
- [Configuration](/gateway/configuration)
|
||||
- [Doctor](/gateway/doctor)
|
||||
|
||||
## Gateway restored last-known-good config
|
||||
|
||||
@@ -318,10 +318,10 @@ Fix options:
|
||||
|
||||
Related:
|
||||
|
||||
- [/gateway/configuration#strict-validation](/gateway/configuration#strict-validation)
|
||||
- [/gateway/configuration#config-hot-reload](/gateway/configuration#config-hot-reload)
|
||||
- [/cli/config](/cli/config)
|
||||
- [/gateway/doctor](/gateway/doctor)
|
||||
- [Configuration: strict validation](/gateway/configuration#strict-validation)
|
||||
- [Configuration: hot reload](/gateway/configuration#config-hot-reload)
|
||||
- [Config](/cli/config)
|
||||
- [Doctor](/gateway/doctor)
|
||||
|
||||
## Gateway probe warnings
|
||||
|
||||
@@ -348,9 +348,9 @@ Common signatures:
|
||||
|
||||
Related:
|
||||
|
||||
- [/cli/gateway](/cli/gateway)
|
||||
- [/gateway#multiple-gateways-same-host](/gateway#multiple-gateways-same-host)
|
||||
- [/gateway/remote](/gateway/remote)
|
||||
- [Gateway](/cli/gateway)
|
||||
- [Multiple gateways on the same host](/gateway#multiple-gateways-same-host)
|
||||
- [Remote access](/gateway/remote)
|
||||
|
||||
## Channel connected messages not flowing
|
||||
|
||||
@@ -378,10 +378,10 @@ Common signatures:
|
||||
|
||||
Related:
|
||||
|
||||
- [/channels/troubleshooting](/channels/troubleshooting)
|
||||
- [/channels/whatsapp](/channels/whatsapp)
|
||||
- [/channels/telegram](/channels/telegram)
|
||||
- [/channels/discord](/channels/discord)
|
||||
- [Channel troubleshooting](/channels/troubleshooting)
|
||||
- [WhatsApp](/channels/whatsapp)
|
||||
- [Telegram](/channels/telegram)
|
||||
- [Discord](/channels/discord)
|
||||
|
||||
## Cron and heartbeat delivery
|
||||
|
||||
@@ -413,9 +413,9 @@ Common signatures:
|
||||
|
||||
Related:
|
||||
|
||||
- [/automation/cron-jobs#troubleshooting](/automation/cron-jobs#troubleshooting)
|
||||
- [/automation/cron-jobs](/automation/cron-jobs)
|
||||
- [/gateway/heartbeat](/gateway/heartbeat)
|
||||
- [Scheduled tasks: troubleshooting](/automation/cron-jobs#troubleshooting)
|
||||
- [Scheduled tasks](/automation/cron-jobs)
|
||||
- [Heartbeat](/gateway/heartbeat)
|
||||
|
||||
## Node paired tool fails
|
||||
|
||||
@@ -444,9 +444,9 @@ Common signatures:
|
||||
|
||||
Related:
|
||||
|
||||
- [/nodes/troubleshooting](/nodes/troubleshooting)
|
||||
- [/nodes/index](/nodes/index)
|
||||
- [/tools/exec-approvals](/tools/exec-approvals)
|
||||
- [Node troubleshooting](/nodes/troubleshooting)
|
||||
- [Nodes](/nodes/index)
|
||||
- [Exec approvals](/tools/exec-approvals)
|
||||
|
||||
## Browser tool fails
|
||||
|
||||
@@ -492,8 +492,8 @@ Common signatures:
|
||||
|
||||
Related:
|
||||
|
||||
- [/tools/browser-linux-troubleshooting](/tools/browser-linux-troubleshooting)
|
||||
- [/tools/browser](/tools/browser)
|
||||
- [Browser troubleshooting](/tools/browser-linux-troubleshooting)
|
||||
- [Browser (OpenClaw-managed)](/tools/browser)
|
||||
|
||||
## If you upgraded and something suddenly broke
|
||||
|
||||
@@ -566,9 +566,9 @@ openclaw gateway restart
|
||||
|
||||
Related:
|
||||
|
||||
- [/gateway/pairing](/gateway/pairing)
|
||||
- [/gateway/authentication](/gateway/authentication)
|
||||
- [/gateway/background-process](/gateway/background-process)
|
||||
- [Gateway-owned pairing](/gateway/pairing)
|
||||
- [Authentication](/gateway/authentication)
|
||||
- [Background exec and process tool](/gateway/background-process)
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -7,22 +7,20 @@ read_when:
|
||||
title: "Nix"
|
||||
---
|
||||
|
||||
# Nix Installation
|
||||
|
||||
Install OpenClaw declaratively with **[nix-openclaw](https://github.com/openclaw/nix-openclaw)** -- a batteries-included Home Manager module.
|
||||
Install OpenClaw declaratively with **[nix-openclaw](https://github.com/openclaw/nix-openclaw)** — a batteries-included Home Manager module.
|
||||
|
||||
<Info>
|
||||
The [nix-openclaw](https://github.com/openclaw/nix-openclaw) repo is the source of truth for Nix installation. This page is a quick overview.
|
||||
</Info>
|
||||
|
||||
## What You Get
|
||||
## What you get
|
||||
|
||||
- Gateway + macOS app + tools (whisper, spotify, cameras) -- all pinned
|
||||
- Launchd service that survives reboots
|
||||
- Plugin system with declarative config
|
||||
- Instant rollback: `home-manager switch --rollback`
|
||||
|
||||
## Quick Start
|
||||
## Quick start
|
||||
|
||||
<Steps>
|
||||
<Step title="Install Determinate Nix">
|
||||
@@ -50,7 +48,7 @@ The [nix-openclaw](https://github.com/openclaw/nix-openclaw) repo is the source
|
||||
|
||||
See the [nix-openclaw README](https://github.com/openclaw/nix-openclaw) for full module options and examples.
|
||||
|
||||
## Nix Mode Runtime Behavior
|
||||
## Nix-mode runtime behavior
|
||||
|
||||
When `OPENCLAW_NIX_MODE=1` is set (automatic with nix-openclaw), OpenClaw enters a deterministic mode that disables auto-install flows.
|
||||
|
||||
@@ -82,6 +80,18 @@ OpenClaw reads JSON5 config from `OPENCLAW_CONFIG_PATH` and stores mutable data
|
||||
| `OPENCLAW_STATE_DIR` | `~/.openclaw` |
|
||||
| `OPENCLAW_CONFIG_PATH` | `$OPENCLAW_STATE_DIR/openclaw.json` |
|
||||
|
||||
### Service PATH discovery
|
||||
|
||||
The launchd/systemd gateway service auto-discovers Nix-profile binaries so
|
||||
plugins and tools that shell out to `nix`-installed executables work without
|
||||
manual PATH setup:
|
||||
|
||||
- When `NIX_PROFILES` is set, every entry is added to the service PATH in
|
||||
right-to-left precedence (matches Nix shell precedence — rightmost wins).
|
||||
- When `NIX_PROFILES` is unset, `~/.nix-profile/bin` is added as a fallback.
|
||||
|
||||
This applies to both macOS launchd and Linux systemd service environments.
|
||||
|
||||
## Related
|
||||
|
||||
- [nix-openclaw](https://github.com/openclaw/nix-openclaw) -- full setup guide
|
||||
|
||||
@@ -216,6 +216,12 @@ Queue + session:
|
||||
- `run.attempt`: run retry/attempt metadata.
|
||||
- `diagnostic.heartbeat`: aggregate counters (webhooks/queue/session).
|
||||
|
||||
Exec:
|
||||
|
||||
- `exec.process.completed`: terminal exec process outcome, duration, target, mode,
|
||||
exit code, and failure kind. Command text and working directories are not
|
||||
included.
|
||||
|
||||
### Enable diagnostics (no exporter)
|
||||
|
||||
Use this if you want diagnostics events available to plugins or custom sinks:
|
||||
@@ -307,6 +313,10 @@ Notes:
|
||||
- Set `headers` when your collector requires auth.
|
||||
- Environment variables supported: `OTEL_EXPORTER_OTLP_ENDPOINT`,
|
||||
`OTEL_SERVICE_NAME`, `OTEL_EXPORTER_OTLP_PROTOCOL`.
|
||||
- Set `OPENCLAW_OTEL_PRELOADED=1` when another preload or host process already
|
||||
registered the global OpenTelemetry SDK. In that mode the plugin does not start
|
||||
or shut down its own SDK, but it still wires OpenClaw diagnostic listeners and
|
||||
honors `diagnostics.otel.traces`, `metrics`, and `logs`.
|
||||
|
||||
### Exported metrics (names + types)
|
||||
|
||||
@@ -348,6 +358,11 @@ Queues + sessions:
|
||||
- `openclaw.session.stuck_age_ms` (histogram, attrs: `openclaw.state`)
|
||||
- `openclaw.run.attempt` (counter, attrs: `openclaw.attempt`)
|
||||
|
||||
Exec:
|
||||
|
||||
- `openclaw.exec.duration_ms` (histogram, attrs: `openclaw.exec.target`,
|
||||
`openclaw.exec.mode`, `openclaw.outcome`, `openclaw.failureKind`)
|
||||
|
||||
### Exported spans (names + key attributes)
|
||||
|
||||
- `openclaw.model.usage`
|
||||
@@ -363,6 +378,10 @@ Queues + sessions:
|
||||
- `openclaw.tool.execution`
|
||||
- `gen_ai.tool.name`, `openclaw.toolName`, `openclaw.errorCategory`,
|
||||
`openclaw.tool.params.*`
|
||||
- `openclaw.exec`
|
||||
- `openclaw.exec.target`, `openclaw.exec.mode`, `openclaw.outcome`,
|
||||
`openclaw.failureKind`, `openclaw.exec.command_length`,
|
||||
`openclaw.exec.exit_code`, `openclaw.exec.timed_out`
|
||||
- `openclaw.webhook.processed`
|
||||
- `openclaw.channel`, `openclaw.webhook`, `openclaw.chatId`
|
||||
- `openclaw.webhook.error`
|
||||
@@ -389,6 +408,8 @@ classes you opted into.
|
||||
`OTEL_EXPORTER_OTLP_ENDPOINT`.
|
||||
- If the endpoint already contains `/v1/traces` or `/v1/metrics`, it is used as-is.
|
||||
- If the endpoint already contains `/v1/logs`, it is used as-is for logs.
|
||||
- `OPENCLAW_OTEL_PRELOADED=1` reuses an externally registered OpenTelemetry SDK
|
||||
for traces/metrics instead of starting a plugin-owned NodeSDK.
|
||||
- `diagnostics.otel.logs` enables OTLP log export for the main logger output.
|
||||
|
||||
### Log export behavior
|
||||
|
||||
@@ -117,6 +117,25 @@ openclaw devices reject <requestId>
|
||||
|
||||
Pairing details: [Pairing](/channels/pairing).
|
||||
|
||||
Optional: if the Android node always connects from a tightly controlled subnet,
|
||||
you can opt in to first-time node auto-approval with explicit CIDRs or exact IPs:
|
||||
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
nodes: {
|
||||
pairing: {
|
||||
autoApproveCidrs: ["192.168.1.0/24"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
This is disabled by default. It applies only to fresh `role: node` pairing with
|
||||
no requested scopes. Operator/browser pairing and any role, scope, metadata, or
|
||||
public-key change still require manual approval.
|
||||
|
||||
### 5) Verify the node is connected
|
||||
|
||||
- Via nodes status:
|
||||
|
||||
@@ -44,6 +44,25 @@ If the app retries pairing with changed auth details (role/scopes/public key),
|
||||
the previous pending request is superseded and a new `requestId` is created.
|
||||
Run `openclaw devices list` again before approval.
|
||||
|
||||
Optional: if the iOS node always connects from a tightly controlled subnet, you
|
||||
can opt in to first-time node auto-approval with explicit CIDRs or exact IPs:
|
||||
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
nodes: {
|
||||
pairing: {
|
||||
autoApproveCidrs: ["192.168.1.0/24"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
This is disabled by default. It applies only to fresh `role: node` pairing with
|
||||
no requested scopes. Operator/browser pairing and any role, scope, metadata, or
|
||||
public-key change still require manual approval.
|
||||
|
||||
4. Verify connection:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -612,7 +612,7 @@ Supported in Codex runtime v1:
|
||||
|
||||
Not supported in Codex runtime v1:
|
||||
|
||||
| Surface | V1 Boundary | Future Path |
|
||||
| Surface | V1 boundary | Future path |
|
||||
| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| Native tool argument mutation | Codex native pre-tool hooks can block, but OpenClaw does not rewrite Codex-native tool arguments. | Requires Codex hook/schema support for replacement tool input. |
|
||||
| Editable Codex-native transcript history | Codex owns canonical native thread history. OpenClaw owns a mirror and can project future context, but should not mutate unsupported internals. | Add explicit Codex app-server APIs if native thread surgery is needed. |
|
||||
@@ -693,11 +693,11 @@ turn for that agent must be a Codex-supported OpenAI model.
|
||||
|
||||
## Related
|
||||
|
||||
- [Agent Harness Plugins](/plugins/sdk-agent-harness)
|
||||
- [Agent harness plugins](/plugins/sdk-agent-harness)
|
||||
- [Agent runtimes](/concepts/agent-runtimes)
|
||||
- [Model Providers](/concepts/model-providers)
|
||||
- [Model providers](/concepts/model-providers)
|
||||
- [OpenAI provider](/providers/openai)
|
||||
- [Status](/cli/status)
|
||||
- [Plugin hooks](/plugins/hooks)
|
||||
- [Configuration Reference](/gateway/configuration-reference)
|
||||
- [Configuration reference](/gateway/configuration-reference)
|
||||
- [Testing](/help/testing-live#live-codex-app-server-harness-smoke)
|
||||
|
||||
98
docs/plugins/compatibility.md
Normal file
98
docs/plugins/compatibility.md
Normal file
@@ -0,0 +1,98 @@
|
||||
---
|
||||
summary: "Plugin compatibility contracts, deprecation metadata, and migration expectations"
|
||||
title: "Plugin compatibility"
|
||||
read_when:
|
||||
- You maintain an OpenClaw plugin
|
||||
- You see a plugin compatibility warning
|
||||
- You are planning a plugin SDK or manifest migration
|
||||
---
|
||||
|
||||
OpenClaw keeps older plugin contracts wired through named compatibility
|
||||
adapters before removing them. This protects existing bundled and external
|
||||
plugins while the SDK, manifest, setup, config, and agent runtime contracts
|
||||
evolve.
|
||||
|
||||
## Compatibility registry
|
||||
|
||||
Plugin compatibility contracts are tracked in the core registry at
|
||||
`src/plugins/compat/registry.ts`.
|
||||
|
||||
Each record has:
|
||||
|
||||
- a stable compatibility code
|
||||
- status: `active`, `deprecated`, `removal-pending`, or `removed`
|
||||
- owner: SDK, config, setup, channel, provider, plugin execution, agent runtime,
|
||||
or core
|
||||
- introduction and deprecation dates when applicable
|
||||
- replacement guidance
|
||||
- docs, diagnostics, and tests that cover the old and new behavior
|
||||
|
||||
The registry is the source for maintainer planning and future plugin inspector
|
||||
checks. If a plugin-facing behavior changes, add or update the compatibility
|
||||
record in the same change that adds the adapter.
|
||||
|
||||
## Plugin inspector package
|
||||
|
||||
The plugin inspector should live outside the core OpenClaw repo as a separate
|
||||
package/repository backed by the versioned compatibility and manifest
|
||||
contracts.
|
||||
|
||||
The day-one CLI should be:
|
||||
|
||||
```sh
|
||||
openclaw-plugin-inspector ./my-plugin
|
||||
```
|
||||
|
||||
It should emit:
|
||||
|
||||
- manifest/schema validation
|
||||
- the contract compatibility version being checked
|
||||
- install/source metadata checks
|
||||
- cold-path import checks
|
||||
- deprecation and compatibility warnings
|
||||
|
||||
Use `--json` for stable machine-readable output in CI annotations. OpenClaw
|
||||
core should expose contracts and fixtures the inspector can consume, but should
|
||||
not publish the inspector binary from the main `openclaw` package.
|
||||
|
||||
## Deprecation policy
|
||||
|
||||
OpenClaw should not remove a documented plugin contract in the same release
|
||||
that introduces its replacement.
|
||||
|
||||
The migration sequence is:
|
||||
|
||||
1. Add the new contract.
|
||||
2. Keep the old behavior wired through a named compatibility adapter.
|
||||
3. Emit diagnostics or warnings when plugin authors can act.
|
||||
4. Document the replacement and timeline.
|
||||
5. Test both old and new paths.
|
||||
6. Wait through the announced migration window.
|
||||
7. Remove only with explicit breaking-release approval.
|
||||
|
||||
Deprecated records must include a warning start date, replacement, docs link,
|
||||
and target removal date when known.
|
||||
|
||||
## Current compatibility areas
|
||||
|
||||
Current compatibility records include:
|
||||
|
||||
- legacy broad SDK imports such as `openclaw/plugin-sdk/compat`
|
||||
- legacy hook-only plugin shapes and `before_agent_start`
|
||||
- bundled plugin allowlist and enablement behavior
|
||||
- legacy provider/channel env-var manifest metadata
|
||||
- activation hints that are being replaced by manifest contribution ownership
|
||||
- `embeddedHarness` and `agent-harness` naming aliases while public naming moves
|
||||
toward `agentRuntime`
|
||||
- generated bundled channel config metadata fallback while registry-first
|
||||
`channelConfigs` metadata lands
|
||||
|
||||
New plugin code should prefer the replacement listed in the registry and in the
|
||||
specific migration guide. Existing plugins can keep using a compatibility path
|
||||
until the docs, diagnostics, and release notes announce a removal window.
|
||||
|
||||
## Release notes
|
||||
|
||||
Release notes should include upcoming plugin deprecations with target dates and
|
||||
links to migration docs. That warning needs to happen before a compatibility
|
||||
path moves to `removal-pending` or `removed`.
|
||||
@@ -158,7 +158,7 @@ the microphone/speaker path used by OpenClaw. For clean duplex audio, use
|
||||
separate virtual devices or a Loopback-style graph; a single BlackHole device is
|
||||
enough for a first smoke test but can echo.
|
||||
|
||||
### Local Gateway + Parallels Chrome
|
||||
### Local gateway + Parallels Chrome
|
||||
|
||||
You do **not** need a full OpenClaw Gateway or model API key inside a macOS VM
|
||||
just to make the VM own Chrome. Run the Gateway and agent locally, then run a
|
||||
@@ -437,8 +437,51 @@ OAuth is optional for creating a Meet link because `googlemeet create` can fall
|
||||
back to browser automation. Configure OAuth when you want official API create,
|
||||
space resolution, or Meet Media API preflight checks.
|
||||
|
||||
Google Meet API access uses a personal OAuth client first. Configure
|
||||
`oauth.clientId` and optionally `oauth.clientSecret`, then run:
|
||||
Google Meet API access uses user OAuth: create a Google Cloud OAuth client,
|
||||
request the required scopes, authorize a Google account, then store the
|
||||
resulting refresh token in the Google Meet plugin config or provide the
|
||||
`OPENCLAW_GOOGLE_MEET_*` environment variables.
|
||||
|
||||
OAuth does not replace the Chrome join path. Chrome and Chrome-node transports
|
||||
still join through a signed-in Chrome profile, BlackHole/SoX, and a connected
|
||||
node when you use browser participation. OAuth is only for the official Google
|
||||
Meet API path: create meeting spaces, resolve spaces, and run Meet Media API
|
||||
preflight checks.
|
||||
|
||||
### Create Google credentials
|
||||
|
||||
In Google Cloud Console:
|
||||
|
||||
1. Create or select a Google Cloud project.
|
||||
2. Enable **Google Meet REST API** for that project.
|
||||
3. Configure the OAuth consent screen.
|
||||
- **Internal** is simplest for a Google Workspace organization.
|
||||
- **External** works for personal/test setups; while the app is in Testing,
|
||||
add each Google account that will authorize the app as a test user.
|
||||
4. Add the scopes OpenClaw requests:
|
||||
- `https://www.googleapis.com/auth/meetings.space.created`
|
||||
- `https://www.googleapis.com/auth/meetings.space.readonly`
|
||||
- `https://www.googleapis.com/auth/meetings.conference.media.readonly`
|
||||
5. Create an OAuth client ID.
|
||||
- Application type: **Web application**.
|
||||
- Authorized redirect URI:
|
||||
|
||||
```text
|
||||
http://localhost:8085/oauth2callback
|
||||
```
|
||||
|
||||
6. Copy the client ID and client secret.
|
||||
|
||||
`meetings.space.created` is required by Google Meet `spaces.create`.
|
||||
`meetings.space.readonly` lets OpenClaw resolve Meet URLs/codes to spaces.
|
||||
`meetings.conference.media.readonly` is for Meet Media API preflight and media
|
||||
work; Google may require Developer Preview enrollment for actual Media API use.
|
||||
If you only need browser-based Chrome joins, skip OAuth entirely.
|
||||
|
||||
### Mint the refresh token
|
||||
|
||||
Configure `oauth.clientId` and optionally `oauth.clientSecret`, or pass them as
|
||||
environment variables, then run:
|
||||
|
||||
```bash
|
||||
openclaw googlemeet auth login --json
|
||||
@@ -448,11 +491,116 @@ The command prints an `oauth` config block with a refresh token. It uses PKCE,
|
||||
localhost callback on `http://localhost:8085/oauth2callback`, and a manual
|
||||
copy/paste flow with `--manual`.
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
OPENCLAW_GOOGLE_MEET_CLIENT_ID="your-client-id" \
|
||||
OPENCLAW_GOOGLE_MEET_CLIENT_SECRET="your-client-secret" \
|
||||
openclaw googlemeet auth login --json
|
||||
```
|
||||
|
||||
Use manual mode when the browser cannot reach the local callback:
|
||||
|
||||
```bash
|
||||
OPENCLAW_GOOGLE_MEET_CLIENT_ID="your-client-id" \
|
||||
OPENCLAW_GOOGLE_MEET_CLIENT_SECRET="your-client-secret" \
|
||||
openclaw googlemeet auth login --json --manual
|
||||
```
|
||||
|
||||
The JSON output includes:
|
||||
|
||||
```json
|
||||
{
|
||||
"oauth": {
|
||||
"clientId": "your-client-id",
|
||||
"clientSecret": "your-client-secret",
|
||||
"refreshToken": "refresh-token",
|
||||
"accessToken": "access-token",
|
||||
"expiresAt": 1770000000000
|
||||
},
|
||||
"scope": "..."
|
||||
}
|
||||
```
|
||||
|
||||
Store the `oauth` object under the Google Meet plugin config:
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
"google-meet": {
|
||||
enabled: true,
|
||||
config: {
|
||||
oauth: {
|
||||
clientId: "your-client-id",
|
||||
clientSecret: "your-client-secret",
|
||||
refreshToken: "refresh-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Prefer environment variables when you do not want the refresh token in config.
|
||||
If both config and environment values are present, the plugin resolves config
|
||||
first and then environment fallback.
|
||||
|
||||
The OAuth consent includes Meet space creation, Meet space read access, and Meet
|
||||
conference media read access. If you authenticated before meeting creation
|
||||
support existed, rerun `openclaw googlemeet auth login --json` so the refresh
|
||||
token has the `meetings.space.created` scope.
|
||||
|
||||
### Verify OAuth with doctor
|
||||
|
||||
Run the OAuth doctor when you want a fast, non-secret health check:
|
||||
|
||||
```bash
|
||||
openclaw googlemeet doctor --oauth --json
|
||||
```
|
||||
|
||||
This does not load the Chrome runtime or require a connected Chrome node. It
|
||||
checks that OAuth config exists and that the refresh token can mint an access
|
||||
token. The JSON report includes only status fields such as `ok`, `configured`,
|
||||
`tokenSource`, `expiresAt`, and check messages; it does not print the access
|
||||
token, refresh token, or client secret.
|
||||
|
||||
Common results:
|
||||
|
||||
| Check | Meaning |
|
||||
| -------------------- | --------------------------------------------------------------------------------------- |
|
||||
| `oauth-config` | `oauth.clientId` plus `oauth.refreshToken`, or a cached access token, is present. |
|
||||
| `oauth-token` | The cached access token is still valid, or the refresh token minted a new access token. |
|
||||
| `meet-spaces-get` | Optional `--meeting` check resolved an existing Meet space. |
|
||||
| `meet-spaces-create` | Optional `--create-space` check created a new Meet space. |
|
||||
|
||||
To prove Google Meet API enablement and `spaces.create` scope as well, run the
|
||||
side-effecting create check:
|
||||
|
||||
```bash
|
||||
openclaw googlemeet doctor --oauth --create-space --json
|
||||
openclaw googlemeet create --no-join --json
|
||||
```
|
||||
|
||||
`--create-space` creates a throwaway Meet URL. Use it when you need to confirm
|
||||
that the Google Cloud project has the Meet API enabled and that the authorized
|
||||
account has the `meetings.space.created` scope.
|
||||
|
||||
To prove read access for an existing meeting space:
|
||||
|
||||
```bash
|
||||
openclaw googlemeet doctor --oauth --meeting https://meet.google.com/abc-defg-hij --json
|
||||
openclaw googlemeet resolve-space --meeting https://meet.google.com/abc-defg-hij
|
||||
```
|
||||
|
||||
`doctor --oauth --meeting` and `resolve-space` prove read access to an existing
|
||||
space that the authorized Google account can access. A `403` from these checks
|
||||
usually means the Google Meet REST API is disabled, the consented refresh token
|
||||
is missing the required scope, or the Google account cannot access that Meet
|
||||
space. A refresh-token error means rerun `openclaw googlemeet auth login
|
||||
--json` and store the new `oauth` block.
|
||||
|
||||
No OAuth credentials are needed for the browser fallback. In that mode, Google
|
||||
auth comes from the signed-in Chrome profile on the selected node, not from
|
||||
OpenClaw config.
|
||||
@@ -480,6 +628,42 @@ Run preflight before media work:
|
||||
openclaw googlemeet preflight --meeting https://meet.google.com/abc-defg-hij
|
||||
```
|
||||
|
||||
List meeting artifacts and attendance after Meet has created conference records:
|
||||
|
||||
```bash
|
||||
openclaw googlemeet artifacts --meeting https://meet.google.com/abc-defg-hij
|
||||
openclaw googlemeet attendance --meeting https://meet.google.com/abc-defg-hij
|
||||
```
|
||||
|
||||
With `--meeting`, `artifacts` and `attendance` use the latest conference record
|
||||
by default. Pass `--all-conference-records` when you want every retained record
|
||||
for that meeting.
|
||||
|
||||
If you already know the conference record id, address it directly:
|
||||
|
||||
```bash
|
||||
openclaw googlemeet latest --meeting https://meet.google.com/abc-defg-hij
|
||||
openclaw googlemeet artifacts --conference-record conferenceRecords/abc123 --json
|
||||
openclaw googlemeet attendance --conference-record conferenceRecords/abc123 --json
|
||||
```
|
||||
|
||||
Write a readable report:
|
||||
|
||||
```bash
|
||||
openclaw googlemeet artifacts --conference-record conferenceRecords/abc123 \
|
||||
--format markdown --output meet-artifacts.md
|
||||
openclaw googlemeet attendance --conference-record conferenceRecords/abc123 \
|
||||
--format markdown --output meet-attendance.md
|
||||
```
|
||||
|
||||
`artifacts` returns conference record metadata plus participant, recording,
|
||||
transcript, structured transcript-entry, and smart-note resource metadata when
|
||||
Google exposes it for the meeting. Use `--no-transcript-entries` to skip
|
||||
entry lookup for large meetings. `attendance` expands participants into
|
||||
participant-session rows with join/leave timestamps. These commands use the Meet
|
||||
REST API only; Google Docs/Drive document body download is intentionally out of
|
||||
scope because that requires separate Google Docs/Drive access.
|
||||
|
||||
Create a fresh Meet space:
|
||||
|
||||
```bash
|
||||
@@ -946,7 +1130,10 @@ Also verify:
|
||||
`googlemeet doctor [session-id]` prints the session, node, in-call state,
|
||||
manual action reason, realtime provider connection, `realtimeReady`, audio
|
||||
input/output activity, last audio timestamps, byte counters, and browser URL.
|
||||
Use `googlemeet status [session-id]` when you need the raw JSON.
|
||||
Use `googlemeet status [session-id]` when you need the raw JSON. Use
|
||||
`googlemeet doctor --oauth` when you need to verify Google Meet OAuth refresh
|
||||
without exposing tokens; add `--meeting` or `--create-space` when you need a
|
||||
Google Meet API proof as well.
|
||||
|
||||
If an agent timed out and you can see a Meet tab already open, inspect that tab
|
||||
without opening another one:
|
||||
|
||||
@@ -190,6 +190,11 @@ Use message hooks for channel-level routing and delivery policy:
|
||||
- `message_sending`: rewrite `content` or return `{ cancel: true }`.
|
||||
- `message_sent`: observe final success or failure.
|
||||
|
||||
For audio-only TTS replies, `content` may contain the hidden spoken transcript
|
||||
even when the channel payload has no visible text/caption. Rewriting that
|
||||
`content` updates the hook-visible transcript only; it is not rendered as a
|
||||
media caption.
|
||||
|
||||
Message hook contexts expose stable correlation fields when available:
|
||||
`ctx.sessionKey`, `ctx.runId`, `ctx.messageId`, `ctx.senderId`, `ctx.trace`,
|
||||
`ctx.traceId`, `ctx.spanId`, `ctx.parentSpanId`, and `ctx.callDepth`. Prefer
|
||||
|
||||
@@ -462,6 +462,6 @@ Beyond `api.runtime`, the API object also provides:
|
||||
|
||||
## Related
|
||||
|
||||
- [SDK Overview](/plugins/sdk-overview) -- subpath reference
|
||||
- [SDK Entry Points](/plugins/sdk-entrypoints) -- `definePluginEntry` options
|
||||
- [Plugin Internals](/plugins/architecture) -- capability model and registry
|
||||
- [SDK overview](/plugins/sdk-overview) — subpath reference
|
||||
- [SDK entry points](/plugins/sdk-entrypoints) — `definePluginEntry` options
|
||||
- [Plugin internals](/plugins/architecture) — capability model and registry
|
||||
|
||||
@@ -566,6 +566,6 @@ startup installs; keep using the explicit plugin installer.
|
||||
|
||||
## Related
|
||||
|
||||
- [SDK Entry Points](/plugins/sdk-entrypoints) -- `definePluginEntry` and `defineChannelPluginEntry`
|
||||
- [Plugin Manifest](/plugins/manifest) -- full manifest schema reference
|
||||
- [Building Plugins](/plugins/building-plugins) -- step-by-step getting started guide
|
||||
- [SDK entry points](/plugins/sdk-entrypoints) — `definePluginEntry` and `defineChannelPluginEntry`
|
||||
- [Plugin manifest](/plugins/manifest) — full manifest schema reference
|
||||
- [Building plugins](/plugins/building-plugins) — step-by-step getting started guide
|
||||
|
||||
@@ -6,8 +6,6 @@ read_when:
|
||||
title: "Voice call plugin"
|
||||
---
|
||||
|
||||
# Voice Call (plugin)
|
||||
|
||||
Voice calls for OpenClaw via a plugin. Supports outbound notifications and
|
||||
multi-turn conversations with inbound policies.
|
||||
|
||||
|
||||
@@ -267,6 +267,7 @@ To use Google as the default TTS provider:
|
||||
google: {
|
||||
model: "gemini-3.1-flash-tts-preview",
|
||||
voiceName: "Kore",
|
||||
audioProfile: "Speak professionally with a calm tone.",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -274,9 +275,14 @@ To use Google as the default TTS provider:
|
||||
}
|
||||
```
|
||||
|
||||
Gemini API TTS accepts expressive square-bracket audio tags in the text, such as
|
||||
`[whispers]` or `[laughs]`. To keep tags out of the visible chat reply while
|
||||
sending them to TTS, put them inside a `[[tts:text]]...[[/tts:text]]` block:
|
||||
Gemini API TTS uses natural-language prompting for style control. Set
|
||||
`audioProfile` to prepend a reusable style prompt before the spoken text. Set
|
||||
`speakerName` when your prompt text refers to a named speaker.
|
||||
|
||||
Gemini API TTS also accepts expressive square-bracket audio tags in the text,
|
||||
such as `[whispers]` or `[laughs]`. To keep tags out of the visible chat reply
|
||||
while sending them to TTS, put them inside a `[[tts:text]]...[[/tts:text]]`
|
||||
block:
|
||||
|
||||
```text
|
||||
Here is the clean reply text.
|
||||
|
||||
@@ -16,7 +16,7 @@ cache values still take precedence over transcript fallback values.
|
||||
|
||||
Why this matters: lower token cost, faster responses, and more predictable performance for long-running sessions. Without caching, repeated prompts pay the full prompt cost on every turn even when most input did not change.
|
||||
|
||||
This page covers all cache-related knobs that affect prompt reuse and token cost.
|
||||
The sections below cover every cache-related knob that affects prompt reuse and token cost.
|
||||
|
||||
Provider references:
|
||||
|
||||
@@ -123,7 +123,7 @@ Per-agent heartbeat is supported at `agents.list[].heartbeat`.
|
||||
- Anthropic Claude model refs (`amazon-bedrock/*anthropic.claude*`) support explicit `cacheRetention` pass-through.
|
||||
- Non-Anthropic Bedrock models are forced to `cacheRetention: "none"` at runtime.
|
||||
|
||||
### OpenRouter Anthropic models
|
||||
### OpenRouter models
|
||||
|
||||
For `openrouter/anthropic/*` model refs, OpenClaw injects Anthropic
|
||||
`cache_control` on system/developer prompt blocks to improve prompt-cache
|
||||
@@ -131,6 +131,16 @@ reuse only when the request is still targeting a verified OpenRouter route
|
||||
(`openrouter` on its default endpoint, or any provider/base URL that resolves
|
||||
to `openrouter.ai`).
|
||||
|
||||
For `openrouter/deepseek/*`, `openrouter/moonshot*/*`, and `openrouter/zai/*`
|
||||
model refs, `contextPruning.mode: "cache-ttl"` is allowed because OpenRouter
|
||||
handles provider-side prompt caching automatically. OpenClaw does not inject
|
||||
Anthropic `cache_control` markers into those requests.
|
||||
|
||||
DeepSeek cache construction is best-effort and can take a few seconds. An
|
||||
immediate follow-up may still show `cached_tokens: 0`; verify with a repeated
|
||||
same-prefix request after a short delay and use `usage.prompt_tokens_details.cached_tokens`
|
||||
as the cache-hit signal.
|
||||
|
||||
If you repoint the model at an arbitrary OpenAI-compatible proxy URL, OpenClaw
|
||||
stops injecting those OpenRouter-specific Anthropic cache markers.
|
||||
|
||||
@@ -338,9 +348,9 @@ Defaults:
|
||||
Related docs:
|
||||
|
||||
- [Anthropic](/providers/anthropic)
|
||||
- [Token Use and Costs](/reference/token-use)
|
||||
- [Session Pruning](/concepts/session-pruning)
|
||||
- [Gateway Configuration Reference](/gateway/configuration-reference)
|
||||
- [Token use and costs](/reference/token-use)
|
||||
- [Session pruning](/concepts/session-pruning)
|
||||
- [Gateway configuration reference](/gateway/configuration-reference)
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ Rules:
|
||||
- The web UI strips the shortcode from visible text and renders the embed inline.
|
||||
- `MEDIA:` is not an embed alias and should not be used for rich embed rendering.
|
||||
|
||||
## Stored Rendering Shape
|
||||
## Stored rendering shape
|
||||
|
||||
The normalized/stored assistant content block is a structured `canvas` item:
|
||||
|
||||
|
||||
@@ -7,9 +7,7 @@ read_when:
|
||||
title: "Session management deep dive"
|
||||
---
|
||||
|
||||
# Session Management & Compaction (Deep Dive)
|
||||
|
||||
This document explains how OpenClaw manages sessions end-to-end:
|
||||
This page explains how OpenClaw manages sessions end-to-end:
|
||||
|
||||
- **Session routing** (how inbound messages map to a `sessionKey`)
|
||||
- **Session store** (`sessions.json`) and what it tracks
|
||||
@@ -21,12 +19,12 @@ This document explains how OpenClaw manages sessions end-to-end:
|
||||
|
||||
If you want a higher-level overview first, start with:
|
||||
|
||||
- [/concepts/session](/concepts/session)
|
||||
- [/concepts/compaction](/concepts/compaction)
|
||||
- [/concepts/memory](/concepts/memory)
|
||||
- [/concepts/memory-search](/concepts/memory-search)
|
||||
- [/concepts/session-pruning](/concepts/session-pruning)
|
||||
- [/reference/transcript-hygiene](/reference/transcript-hygiene)
|
||||
- [Session management](/concepts/session)
|
||||
- [Compaction](/concepts/compaction)
|
||||
- [Memory overview](/concepts/memory)
|
||||
- [Memory search](/concepts/memory-search)
|
||||
- [Session pruning](/concepts/session-pruning)
|
||||
- [Transcript hygiene](/reference/transcript-hygiene)
|
||||
|
||||
---
|
||||
|
||||
@@ -103,6 +101,14 @@ Isolated cron runs also create session entries/transcripts, and they have dedica
|
||||
- `cron.sessionRetention` (default `24h`) prunes old isolated cron run sessions from the session store (`false` disables).
|
||||
- `cron.runLog.maxBytes` + `cron.runLog.keepLines` prune `~/.openclaw/cron/runs/<jobId>.jsonl` files (defaults: `2_000_000` bytes and `2000` lines).
|
||||
|
||||
When cron force-creates a new isolated run session, it sanitizes the previous
|
||||
`cron:<jobId>` session entry before writing the new row. It carries safe
|
||||
preferences such as thinking/fast/verbose settings, labels, and explicit
|
||||
user-selected model/auth overrides. It drops ambient conversation context such
|
||||
as channel/group routing, send or queue policy, elevation, origin, and ACP
|
||||
runtime binding so a fresh isolated run cannot inherit stale delivery or
|
||||
runtime authority from an older run.
|
||||
|
||||
---
|
||||
|
||||
## Session keys (`sessionKey`)
|
||||
|
||||
@@ -27,7 +27,7 @@ Scope includes:
|
||||
|
||||
If you need transcript storage details, see:
|
||||
|
||||
- [/reference/session-management-compaction](/reference/session-management-compaction)
|
||||
- [Session management deep dive](/reference/session-management-compaction)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ title: "ACP agents — setup"
|
||||
---
|
||||
|
||||
For the overview, operator runbook, and concepts, see [ACP agents](/tools/acp-agents).
|
||||
This page covers acpx harness config, plugin setup for the MCP bridges, and
|
||||
permission configuration.
|
||||
|
||||
The sections below cover acpx harness config, plugin setup for the MCP bridges, and permission configuration.
|
||||
|
||||
## acpx harness support (current)
|
||||
|
||||
|
||||
@@ -165,6 +165,7 @@ openclaw browser responsebody "**/api" --max-chars 5000
|
||||
openclaw browser navigate https://example.com
|
||||
openclaw browser resize 1280 720
|
||||
openclaw browser click 12 --double # or e12 for role refs
|
||||
openclaw browser click-coords 120 340 # viewport coordinates
|
||||
openclaw browser type 23 "hello" --submit
|
||||
openclaw browser press Enter
|
||||
openclaw browser hover 44
|
||||
@@ -212,7 +213,7 @@ openclaw browser set device "iPhone 14"
|
||||
Notes:
|
||||
|
||||
- `upload` and `dialog` are **arming** calls; run them before the click/press that triggers the chooser/dialog.
|
||||
- `click`/`type`/etc require a `ref` from `snapshot` (numeric `12` or role ref `e12`). CSS selectors are intentionally not supported for actions.
|
||||
- `click`/`type`/etc require a `ref` from `snapshot` (numeric `12` or role ref `e12`). CSS selectors are intentionally not supported for actions. Use `click-coords` when the visible viewport position is the only reliable target.
|
||||
- Download, trace, and upload paths are constrained to OpenClaw temp roots: `/tmp/openclaw{,/downloads,/uploads}` (fallback: `${os.tmpdir()}/openclaw/...`).
|
||||
- `upload` can also set file inputs directly via `--input-ref` or `--element`.
|
||||
|
||||
|
||||
@@ -25,6 +25,16 @@ chromium-browser is already the newest version (2:1snap1-0ubuntu2).
|
||||
|
||||
This is NOT a real browser - it's just a wrapper.
|
||||
|
||||
Other common Linux launch failures:
|
||||
|
||||
- `The profile appears to be in use by another Chromium process` means Chrome
|
||||
found stale `Singleton*` lock files in the managed profile directory. OpenClaw
|
||||
removes those locks and retries once when the lock points at a dead or
|
||||
different-host process.
|
||||
- `Missing X server or $DISPLAY` means OpenClaw is trying to launch a visible
|
||||
browser on a host without a desktop session. Use `browser.headless: true`,
|
||||
start `Xvfb`, or run OpenClaw in a real desktop session.
|
||||
|
||||
### Solution 1: Install Google Chrome (Recommended)
|
||||
|
||||
Install the official Google Chrome `.deb` package, which is not sandboxed by snap:
|
||||
|
||||
@@ -129,6 +129,7 @@ Browser settings live in `~/.openclaw/openclaw.json`.
|
||||
// cdpUrl: "http://127.0.0.1:18792", // legacy single-profile override
|
||||
remoteCdpTimeoutMs: 1500, // remote CDP HTTP timeout (ms)
|
||||
remoteCdpHandshakeTimeoutMs: 3000, // remote CDP WebSocket handshake timeout (ms)
|
||||
actionTimeoutMs: 60000, // default browser act timeout (ms)
|
||||
tabCleanup: {
|
||||
enabled: true, // default: true
|
||||
idleMinutes: 120, // set 0 to disable idle cleanup
|
||||
@@ -173,6 +174,7 @@ Browser settings live in `~/.openclaw/openclaw.json`.
|
||||
- Control service binds to loopback on a port derived from `gateway.port` (default `18791` = gateway + 2). Overriding `gateway.port` or `OPENCLAW_GATEWAY_PORT` shifts the derived ports in the same family.
|
||||
- Local `openclaw` profiles auto-assign `cdpPort`/`cdpUrl`; set those only for remote CDP. `cdpUrl` defaults to the managed local CDP port when unset.
|
||||
- `remoteCdpTimeoutMs` applies to remote (non-loopback) CDP HTTP reachability checks; `remoteCdpHandshakeTimeoutMs` applies to remote CDP WebSocket handshakes.
|
||||
- `actionTimeoutMs` is the default budget for browser `act` requests when the caller does not pass `timeoutMs`. The client transport adds a small slack window so long waits can finish instead of timing out at the HTTP boundary.
|
||||
- `tabCleanup` is best-effort cleanup for tabs opened by primary-agent browser sessions. Subagent, cron, and ACP lifecycle cleanup still closes their explicit tracked tabs at session end; primary sessions keep active tabs reusable, then close idle or excess tracked tabs in the background.
|
||||
|
||||
</Accordion>
|
||||
@@ -427,7 +429,7 @@ Defaults:
|
||||
|
||||
All control endpoints accept `?profile=<name>`; the CLI uses `--browser-profile`.
|
||||
|
||||
## Existing-session via Chrome DevTools MCP
|
||||
## Existing session via Chrome DevTools MCP
|
||||
|
||||
OpenClaw can also attach to a running Chromium-based browser profile through the
|
||||
official Chrome DevTools MCP server. This reuses the tabs and login state
|
||||
@@ -529,7 +531,7 @@ Notes:
|
||||
Compared to the managed `openclaw` profile, existing-session drivers are more constrained:
|
||||
|
||||
- **Screenshots** — page captures and `--ref` element captures work; CSS `--element` selectors do not. `--full-page` cannot combine with `--ref` or `--element`. Playwright is not required for page or ref-based element screenshots.
|
||||
- **Actions** — `click`, `type`, `hover`, `scrollIntoView`, `drag`, and `select` require snapshot refs (no CSS selectors). `click` is left-button only. `type` does not support `slowly=true`; use `fill` or `press`. `press` does not support `delayMs`. `type`, `hover`, `scrollIntoView`, `drag`, `select`, `fill`, and `evaluate` do not support per-call timeouts. `select` accepts a single value.
|
||||
- **Actions** — `click`, `type`, `hover`, `scrollIntoView`, `drag`, and `select` require snapshot refs (no CSS selectors). `click-coords` clicks visible viewport coordinates and does not require a snapshot ref. `click` is left-button only. `type` does not support `slowly=true`; use `fill` or `press`. `press` does not support `delayMs`. `type`, `hover`, `scrollIntoView`, `drag`, `select`, `fill`, and `evaluate` do not support per-call timeouts. `select` accepts a single value.
|
||||
- **Wait / upload / dialog** — `wait --url` supports exact, substring, and glob patterns; `wait --load networkidle` is not supported. Upload hooks require `ref` or `inputRef`, one file at a time, no CSS `element`. Dialog hooks do not support timeout overrides.
|
||||
- **Managed-only features** — batch actions, PDF export, download interception, and `responsebody` still require the managed browser path.
|
||||
|
||||
|
||||
@@ -423,13 +423,13 @@ observe results through `after_tool_call`, and participate in Codex
|
||||
arguments yet. The exact Codex runtime support boundary lives in the
|
||||
[Codex harness v1 support contract](/plugins/codex-harness#v1-support-contract).
|
||||
|
||||
For full typed hook behavior, see [SDK Overview](/plugins/sdk-overview#hook-decision-semantics).
|
||||
For full typed hook behavior, see [SDK overview](/plugins/sdk-overview#hook-decision-semantics).
|
||||
|
||||
## Related
|
||||
|
||||
- [Building Plugins](/plugins/building-plugins) — create your own plugin
|
||||
- [Plugin Bundles](/plugins/bundles) — Codex/Claude/Cursor bundle compatibility
|
||||
- [Plugin Manifest](/plugins/manifest) — manifest schema
|
||||
- [Registering Tools](/plugins/building-plugins#registering-agent-tools) — add agent tools in a plugin
|
||||
- [Plugin Internals](/plugins/architecture) — capability model and load pipeline
|
||||
- [Community Plugins](/plugins/community) — third-party listings
|
||||
- [Building plugins](/plugins/building-plugins) — create your own plugin
|
||||
- [Plugin bundles](/plugins/bundles) — Codex/Claude/Cursor bundle compatibility
|
||||
- [Plugin manifest](/plugins/manifest) — manifest schema
|
||||
- [Registering tools](/plugins/building-plugins#registering-agent-tools) — add agent tools in a plugin
|
||||
- [Plugin internals](/plugins/architecture) — capability model and load pipeline
|
||||
- [Community plugins](/plugins/community) — third-party listings
|
||||
|
||||
@@ -170,7 +170,7 @@ Auto-archive:
|
||||
- Auto-archive applies equally to depth-1 and depth-2 sessions.
|
||||
- Browser cleanup is separate from archive cleanup: tracked browser tabs/processes are best-effort closed when the run finishes, even if the transcript/session record is kept.
|
||||
|
||||
## Nested Sub-Agents
|
||||
## Nested sub-agents
|
||||
|
||||
By default, sub-agents cannot spawn their own sub-agents (`maxSpawnDepth: 1`). You can enable one level of nesting by setting `maxSpawnDepth: 2`, which allows the **orchestrator pattern**: main → orchestrator sub-agent → worker sub-sub-agents.
|
||||
|
||||
|
||||
@@ -379,6 +379,8 @@ Then run:
|
||||
- `providers.minimax.pitch`: integer pitch shift `-12..12` (default 0). Fractional values are truncated before calling MiniMax T2A because the API rejects non-integer pitch values.
|
||||
- `providers.google.model`: Gemini TTS model (default `gemini-3.1-flash-tts-preview`).
|
||||
- `providers.google.voiceName`: Gemini prebuilt voice name (default `Kore`; `voice` is also accepted).
|
||||
- `providers.google.audioProfile`: natural-language style prompt prepended before the spoken text.
|
||||
- `providers.google.speakerName`: optional speaker label prepended before the spoken text when your TTS prompt uses a named speaker.
|
||||
- `providers.google.baseUrl`: override the Gemini API base URL. Only `https://generativelanguage.googleapis.com` is accepted.
|
||||
- If `messages.tts.providers.google.apiKey` is omitted, TTS can reuse `models.providers.google.apiKey` before env fallback.
|
||||
- `providers.gradium.baseUrl`: override Gradium API base URL (default `https://api.gradium.ai`).
|
||||
|
||||
@@ -154,6 +154,10 @@ Cron jobs panel notes:
|
||||
- `chat.history` responses are size-bounded for UI safety. When transcript entries are too large, Gateway may truncate long text fields, omit heavy metadata blocks, and replace oversized messages with a placeholder (`[chat.history omitted: message too large]`).
|
||||
- Assistant/generated images are persisted as managed media references and served back through authenticated Gateway media URLs, so reloads do not depend on raw base64 image payloads staying in the chat history response.
|
||||
- `chat.history` also strips display-only inline directive tags from visible assistant text (for example `[[reply_to_*]]` and `[[audio_as_voice]]`), plain-text tool-call XML payloads (including `<tool_call>...</tool_call>`, `<function_call>...</function_call>`, `<tool_calls>...</tool_calls>`, `<function_calls>...</function_calls>`, and truncated tool-call blocks), and leaked ASCII/full-width model control tokens, and omits assistant entries whose whole visible text is only the exact silent token `NO_REPLY` / `no_reply`.
|
||||
- During an active send and the final history refresh, the chat view keeps local
|
||||
optimistic user/assistant messages visible if `chat.history` briefly returns
|
||||
an older snapshot; the canonical transcript replaces those local messages once
|
||||
the Gateway history catches up.
|
||||
- `chat.inject` appends an assistant note to the session transcript and broadcasts a `chat` event for UI-only updates (no agent run, no channel delivery).
|
||||
- The chat header model and thinking pickers patch the active session immediately through `sessions.patch`; they are persistent session overrides, not one-turn-only send options.
|
||||
- When fresh Gateway session usage reports show high context pressure, the chat
|
||||
@@ -314,7 +318,7 @@ Trusted-proxy note:
|
||||
device identity
|
||||
- this does **not** extend to node-role Control UI sessions
|
||||
- same-host loopback reverse proxies still do not satisfy trusted-proxy auth; see
|
||||
[Trusted Proxy Auth](/gateway/trusted-proxy-auth)
|
||||
[Trusted proxy auth](/gateway/trusted-proxy-auth)
|
||||
|
||||
See [Tailscale](/gateway/tailscale) for HTTPS setup guidance.
|
||||
|
||||
|
||||
@@ -566,6 +566,7 @@ describe("active-memory plugin", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
cleanupBundleMcpOnRunEnd: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -653,6 +654,9 @@ describe("active-memory plugin", () => {
|
||||
"You receive conversation context, including the user's latest message.",
|
||||
);
|
||||
expect(runParams?.prompt).toContain("Use only memory_search and memory_get.");
|
||||
expect(runParams?.prompt).toContain(
|
||||
"When searching for preference or habit recall, use a permissive memory_search threshold before deciding that no useful memory exists.",
|
||||
);
|
||||
expect(runParams?.prompt).toContain(
|
||||
"If the user is directly asking about favorites, preferences, habits, routines, or personal facts, treat that as a strong recall signal.",
|
||||
);
|
||||
|
||||
@@ -787,6 +787,7 @@ function buildRecallPrompt(params: {
|
||||
"Your job is to search memory and return only the most relevant memory context for that model.",
|
||||
"You receive conversation context, including the user's latest message.",
|
||||
"Use only memory_search and memory_get.",
|
||||
"When searching for preference or habit recall, use a permissive memory_search threshold before deciding that no useful memory exists.",
|
||||
"Do not answer the user directly.",
|
||||
`Prompt style: ${params.config.promptStyle}.`,
|
||||
...buildPromptStyleLines(params.config.promptStyle),
|
||||
@@ -1684,6 +1685,7 @@ async function runRecallSubagent(params: {
|
||||
thinkLevel: params.config.thinking,
|
||||
reasoningLevel: "off",
|
||||
silentExpected: true,
|
||||
cleanupBundleMcpOnRunEnd: true,
|
||||
abortSignal: params.abortSignal,
|
||||
});
|
||||
if (params.abortSignal?.aborted) {
|
||||
|
||||
@@ -1,31 +1,30 @@
|
||||
import type { Model } from "@mariozechner/pi-ai";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createAssistantMessageEventStream, type Model } from "@mariozechner/pi-ai";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { AnthropicVertexStreamDeps } from "./stream-runtime.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const streamAnthropicMock = vi.fn(() => Symbol("anthropic-vertex-stream"));
|
||||
function createStreamDeps(): {
|
||||
deps: AnthropicVertexStreamDeps;
|
||||
streamAnthropicMock: ReturnType<typeof vi.fn>;
|
||||
anthropicVertexCtorMock: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const streamAnthropicMock = vi.fn(
|
||||
(..._args: Parameters<AnthropicVertexStreamDeps["streamAnthropic"]>) =>
|
||||
createAssistantMessageEventStream(),
|
||||
);
|
||||
const anthropicVertexCtorMock = vi.fn();
|
||||
const MockAnthropicVertex = function MockAnthropicVertex(options: unknown) {
|
||||
anthropicVertexCtorMock(options);
|
||||
} as unknown as AnthropicVertexStreamDeps["AnthropicVertex"];
|
||||
|
||||
return {
|
||||
deps: {
|
||||
AnthropicVertex: MockAnthropicVertex,
|
||||
streamAnthropic: streamAnthropicMock,
|
||||
},
|
||||
streamAnthropicMock,
|
||||
anthropicVertexCtorMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@mariozechner/pi-ai", async () => {
|
||||
const original =
|
||||
await vi.importActual<typeof import("@mariozechner/pi-ai")>("@mariozechner/pi-ai");
|
||||
return {
|
||||
...original,
|
||||
streamAnthropic: hoisted.streamAnthropicMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@anthropic-ai/vertex-sdk", () => ({
|
||||
AnthropicVertex: vi.fn(function MockAnthropicVertex(options: unknown) {
|
||||
hoisted.anthropicVertexCtorMock(options);
|
||||
return { options };
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
let createAnthropicVertexStreamFn: typeof import("./api.js").createAnthropicVertexStreamFn;
|
||||
let createAnthropicVertexStreamFnForModel: typeof import("./api.js").createAnthropicVertexStreamFnForModel;
|
||||
@@ -45,33 +44,34 @@ describe("Anthropic Vertex API stream factories", () => {
|
||||
await import("./api.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
hoisted.streamAnthropicMock.mockClear();
|
||||
hoisted.anthropicVertexCtorMock.mockClear();
|
||||
});
|
||||
|
||||
it("reuses the runtime stream factory across direct stream calls", async () => {
|
||||
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5");
|
||||
const { deps, streamAnthropicMock, anthropicVertexCtorMock } = createStreamDeps();
|
||||
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
|
||||
const model = makeModel();
|
||||
|
||||
await streamFn(model, { messages: [] }, {});
|
||||
await streamFn(model, { messages: [] }, {});
|
||||
|
||||
expect(hoisted.anthropicVertexCtorMock).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.streamAnthropicMock).toHaveBeenCalledTimes(2);
|
||||
expect(anthropicVertexCtorMock).toHaveBeenCalledTimes(1);
|
||||
expect(streamAnthropicMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("reuses the runtime stream factory across model-derived stream calls", async () => {
|
||||
const streamFn = createAnthropicVertexStreamFnForModel(makeModel(), {
|
||||
ANTHROPIC_VERTEX_PROJECT_ID: "vertex-project",
|
||||
GOOGLE_CLOUD_LOCATION: "us-east5",
|
||||
} as NodeJS.ProcessEnv);
|
||||
const { deps, streamAnthropicMock, anthropicVertexCtorMock } = createStreamDeps();
|
||||
const streamFn = createAnthropicVertexStreamFnForModel(
|
||||
makeModel(),
|
||||
{
|
||||
ANTHROPIC_VERTEX_PROJECT_ID: "vertex-project",
|
||||
GOOGLE_CLOUD_LOCATION: "us-east5",
|
||||
} as NodeJS.ProcessEnv,
|
||||
deps,
|
||||
);
|
||||
const model = makeModel();
|
||||
|
||||
await streamFn(model, { messages: [] }, {});
|
||||
await streamFn(model, { messages: [] }, {});
|
||||
|
||||
expect(hoisted.anthropicVertexCtorMock).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.streamAnthropicMock).toHaveBeenCalledTimes(2);
|
||||
expect(anthropicVertexCtorMock).toHaveBeenCalledTimes(1);
|
||||
expect(streamAnthropicMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { StreamFn } from "@mariozechner/pi-agent-core";
|
||||
import type { AnthropicVertexStreamDeps } from "./stream-runtime.js";
|
||||
|
||||
export {
|
||||
ANTHROPIC_VERTEX_DEFAULT_MODEL_ID,
|
||||
@@ -47,9 +48,10 @@ export function createAnthropicVertexStreamFn(
|
||||
projectId: string | undefined,
|
||||
region: string,
|
||||
baseURL?: string,
|
||||
deps?: AnthropicVertexStreamDeps,
|
||||
): StreamFn {
|
||||
const streamFnPromise = import("./stream-runtime.js").then((runtime) =>
|
||||
runtime.createAnthropicVertexStreamFn(projectId, region, baseURL),
|
||||
runtime.createAnthropicVertexStreamFn(projectId, region, baseURL, deps),
|
||||
);
|
||||
return async (model, context, options) => {
|
||||
const streamFn = await streamFnPromise;
|
||||
@@ -60,9 +62,10 @@ export function createAnthropicVertexStreamFn(
|
||||
export function createAnthropicVertexStreamFnForModel(
|
||||
model: { baseUrl?: string },
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
deps?: AnthropicVertexStreamDeps,
|
||||
): StreamFn {
|
||||
const streamFnPromise = import("./stream-runtime.js").then((runtime) =>
|
||||
runtime.createAnthropicVertexStreamFnForModel(model, env),
|
||||
runtime.createAnthropicVertexStreamFnForModel(model, env, deps),
|
||||
);
|
||||
return async (...args) => {
|
||||
const streamFn = await streamFnPromise;
|
||||
|
||||
@@ -1,36 +1,32 @@
|
||||
import type { Model } from "@mariozechner/pi-ai";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createAssistantMessageEventStream, type Model } from "@mariozechner/pi-ai";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { AnthropicVertexStreamDeps } from "./stream-runtime.js";
|
||||
|
||||
const SYSTEM_PROMPT_CACHE_BOUNDARY = "\n<!-- OPENCLAW_CACHE_BOUNDARY -->\n";
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const streamAnthropicMock = vi.fn<(model: unknown, context: unknown, options: unknown) => symbol>(
|
||||
() => Symbol("anthropic-vertex-stream"),
|
||||
function createStreamDeps(): {
|
||||
deps: AnthropicVertexStreamDeps;
|
||||
streamAnthropicMock: ReturnType<typeof vi.fn>;
|
||||
anthropicVertexCtorMock: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const streamAnthropicMock = vi.fn(
|
||||
(..._args: Parameters<AnthropicVertexStreamDeps["streamAnthropic"]>) =>
|
||||
createAssistantMessageEventStream(),
|
||||
);
|
||||
const anthropicVertexCtorMock = vi.fn();
|
||||
const MockAnthropicVertex = function MockAnthropicVertex(options: unknown) {
|
||||
anthropicVertexCtorMock(options);
|
||||
} as unknown as AnthropicVertexStreamDeps["AnthropicVertex"];
|
||||
|
||||
return {
|
||||
deps: {
|
||||
AnthropicVertex: MockAnthropicVertex,
|
||||
streamAnthropic: streamAnthropicMock,
|
||||
},
|
||||
streamAnthropicMock,
|
||||
anthropicVertexCtorMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@mariozechner/pi-ai", async () => {
|
||||
const original =
|
||||
await vi.importActual<typeof import("@mariozechner/pi-ai")>("@mariozechner/pi-ai");
|
||||
return {
|
||||
...original,
|
||||
streamAnthropic: (model: unknown, context: unknown, options: unknown) =>
|
||||
hoisted.streamAnthropicMock(model, context, options),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@anthropic-ai/vertex-sdk", () => ({
|
||||
AnthropicVertex: vi.fn(function MockAnthropicVertex(options: unknown) {
|
||||
hoisted.anthropicVertexCtorMock(options);
|
||||
return { options };
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
let createAnthropicVertexStreamFn: typeof import("./stream-runtime.js").createAnthropicVertexStreamFn;
|
||||
let createAnthropicVertexStreamFnForModel: typeof import("./stream-runtime.js").createAnthropicVertexStreamFnForModel;
|
||||
@@ -48,8 +44,12 @@ const CACHE_BOUNDARY_PROMPT = `Stable prefix${SYSTEM_PROMPT_CACHE_BOUNDARY}Dynam
|
||||
|
||||
type PayloadHook = (payload: unknown, payloadModel: unknown) => Promise<unknown>;
|
||||
|
||||
function captureCacheBoundaryPayloadHook(onPayload: PayloadHook) {
|
||||
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5");
|
||||
function captureCacheBoundaryPayloadHook(
|
||||
onPayload: PayloadHook,
|
||||
deps: AnthropicVertexStreamDeps,
|
||||
streamAnthropicMock: ReturnType<typeof vi.fn>,
|
||||
) {
|
||||
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
|
||||
const model = makeModel({ id: "claude-sonnet-4-6", maxTokens: 64000 });
|
||||
|
||||
void streamFn(
|
||||
@@ -64,7 +64,7 @@ function captureCacheBoundaryPayloadHook(onPayload: PayloadHook) {
|
||||
} as never,
|
||||
);
|
||||
|
||||
const transportOptions = hoisted.streamAnthropicMock.mock.calls[0]?.[2] as {
|
||||
const transportOptions = streamAnthropicMock.mock.calls[0]?.[2] as {
|
||||
onPayload?: PayloadHook;
|
||||
};
|
||||
|
||||
@@ -105,31 +105,29 @@ describe("createAnthropicVertexStreamFn", () => {
|
||||
await import("./stream-runtime.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
hoisted.streamAnthropicMock.mockClear();
|
||||
hoisted.anthropicVertexCtorMock.mockClear();
|
||||
});
|
||||
|
||||
it("omits projectId when ADC credentials are used without an explicit project", () => {
|
||||
const streamFn = createAnthropicVertexStreamFn(undefined, "global");
|
||||
const { deps, anthropicVertexCtorMock } = createStreamDeps();
|
||||
const streamFn = createAnthropicVertexStreamFn(undefined, "global", undefined, deps);
|
||||
|
||||
void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 128000 }), { messages: [] }, {});
|
||||
|
||||
expect(hoisted.anthropicVertexCtorMock).toHaveBeenCalledWith({
|
||||
expect(anthropicVertexCtorMock).toHaveBeenCalledWith({
|
||||
region: "global",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes an explicit baseURL through to the Vertex client", () => {
|
||||
const { deps, anthropicVertexCtorMock } = createStreamDeps();
|
||||
const streamFn = createAnthropicVertexStreamFn(
|
||||
"vertex-project",
|
||||
"us-east5",
|
||||
"https://proxy.example.test/vertex/v1",
|
||||
deps,
|
||||
);
|
||||
|
||||
void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 128000 }), { messages: [] }, {});
|
||||
|
||||
expect(hoisted.anthropicVertexCtorMock).toHaveBeenCalledWith({
|
||||
expect(anthropicVertexCtorMock).toHaveBeenCalledWith({
|
||||
projectId: "vertex-project",
|
||||
region: "us-east5",
|
||||
baseURL: "https://proxy.example.test/vertex/v1",
|
||||
@@ -137,12 +135,13 @@ describe("createAnthropicVertexStreamFn", () => {
|
||||
});
|
||||
|
||||
it("defaults maxTokens to the model limit instead of the old 32000 cap", () => {
|
||||
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5");
|
||||
const { deps, streamAnthropicMock } = createStreamDeps();
|
||||
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
|
||||
const model = makeModel({ id: "claude-opus-4-6", maxTokens: 128000 });
|
||||
|
||||
void streamFn(model, { messages: [] }, {});
|
||||
|
||||
expect(hoisted.streamAnthropicMock).toHaveBeenCalledWith(
|
||||
expect(streamAnthropicMock).toHaveBeenCalledWith(
|
||||
model,
|
||||
{ messages: [] },
|
||||
expect.objectContaining({
|
||||
@@ -152,12 +151,13 @@ describe("createAnthropicVertexStreamFn", () => {
|
||||
});
|
||||
|
||||
it("clamps explicit maxTokens to the selected model limit", () => {
|
||||
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5");
|
||||
const { deps, streamAnthropicMock } = createStreamDeps();
|
||||
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
|
||||
const model = makeModel({ id: "claude-sonnet-4-6", maxTokens: 128000 });
|
||||
|
||||
void streamFn(model, { messages: [] }, { maxTokens: 999999 });
|
||||
|
||||
expect(hoisted.streamAnthropicMock).toHaveBeenCalledWith(
|
||||
expect(streamAnthropicMock).toHaveBeenCalledWith(
|
||||
model,
|
||||
{ messages: [] },
|
||||
expect.objectContaining({
|
||||
@@ -167,12 +167,13 @@ describe("createAnthropicVertexStreamFn", () => {
|
||||
});
|
||||
|
||||
it("maps xhigh reasoning to max effort for adaptive Opus models", () => {
|
||||
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5");
|
||||
const { deps, streamAnthropicMock } = createStreamDeps();
|
||||
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
|
||||
const model = makeModel({ id: "claude-opus-4-6", maxTokens: 64000 });
|
||||
|
||||
void streamFn(model, { messages: [] }, { reasoning: "xhigh" });
|
||||
|
||||
expect(hoisted.streamAnthropicMock).toHaveBeenCalledWith(
|
||||
expect(streamAnthropicMock).toHaveBeenCalledWith(
|
||||
model,
|
||||
{ messages: [] },
|
||||
expect.objectContaining({
|
||||
@@ -183,12 +184,13 @@ describe("createAnthropicVertexStreamFn", () => {
|
||||
});
|
||||
|
||||
it("maps xhigh reasoning to xhigh effort for Opus 4.7", () => {
|
||||
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5");
|
||||
const { deps, streamAnthropicMock } = createStreamDeps();
|
||||
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
|
||||
const model = makeModel({ id: "claude-opus-4-7", maxTokens: 64000 });
|
||||
|
||||
void streamFn(model, { messages: [] }, { reasoning: "xhigh" });
|
||||
|
||||
expect(hoisted.streamAnthropicMock).toHaveBeenCalledWith(
|
||||
expect(streamAnthropicMock).toHaveBeenCalledWith(
|
||||
model,
|
||||
{ messages: [] },
|
||||
expect.objectContaining({
|
||||
@@ -199,8 +201,13 @@ describe("createAnthropicVertexStreamFn", () => {
|
||||
});
|
||||
|
||||
it("applies Anthropic cache-boundary shaping before forwarding payload hooks", async () => {
|
||||
const { deps, streamAnthropicMock } = createStreamDeps();
|
||||
const onPayload = vi.fn(async (payload: unknown) => payload);
|
||||
const { model, onPayload: transportPayloadHook } = captureCacheBoundaryPayloadHook(onPayload);
|
||||
const { model, onPayload: transportPayloadHook } = captureCacheBoundaryPayloadHook(
|
||||
onPayload,
|
||||
deps,
|
||||
streamAnthropicMock,
|
||||
);
|
||||
const payload = {
|
||||
system: [
|
||||
{
|
||||
@@ -220,6 +227,7 @@ describe("createAnthropicVertexStreamFn", () => {
|
||||
});
|
||||
|
||||
it("reapplies Anthropic cache-boundary shaping when payload hooks return a fresh payload", async () => {
|
||||
const { deps, streamAnthropicMock } = createStreamDeps();
|
||||
const onPayload = vi.fn(async () => ({
|
||||
system: [
|
||||
{
|
||||
@@ -229,7 +237,11 @@ describe("createAnthropicVertexStreamFn", () => {
|
||||
],
|
||||
messages: [{ role: "user", content: "Hello again" }],
|
||||
}));
|
||||
const { model, onPayload: transportPayloadHook } = captureCacheBoundaryPayloadHook(onPayload);
|
||||
const { model, onPayload: transportPayloadHook } = captureCacheBoundaryPayloadHook(
|
||||
onPayload,
|
||||
deps,
|
||||
streamAnthropicMock,
|
||||
);
|
||||
|
||||
const nextPayload = await transportPayloadHook?.(
|
||||
{
|
||||
@@ -248,12 +260,13 @@ describe("createAnthropicVertexStreamFn", () => {
|
||||
});
|
||||
|
||||
it("omits maxTokens when neither the model nor request provide a finite limit", () => {
|
||||
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5");
|
||||
const { deps, streamAnthropicMock } = createStreamDeps();
|
||||
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
|
||||
const model = makeModel({ id: "claude-sonnet-4-6" });
|
||||
|
||||
void streamFn(model, { messages: [] }, { maxTokens: Number.NaN });
|
||||
|
||||
expect(hoisted.streamAnthropicMock).toHaveBeenCalledWith(
|
||||
expect(streamAnthropicMock).toHaveBeenCalledWith(
|
||||
model,
|
||||
{ messages: [] },
|
||||
expect.not.objectContaining({
|
||||
@@ -264,19 +277,17 @@ describe("createAnthropicVertexStreamFn", () => {
|
||||
});
|
||||
|
||||
describe("createAnthropicVertexStreamFnForModel", () => {
|
||||
beforeEach(() => {
|
||||
hoisted.anthropicVertexCtorMock.mockClear();
|
||||
});
|
||||
|
||||
it("derives project and region from the model and env", () => {
|
||||
const { deps, anthropicVertexCtorMock } = createStreamDeps();
|
||||
const streamFn = createAnthropicVertexStreamFnForModel(
|
||||
{ baseUrl: "https://europe-west4-aiplatform.googleapis.com" },
|
||||
{ GOOGLE_CLOUD_PROJECT_ID: "vertex-project" } as NodeJS.ProcessEnv,
|
||||
deps,
|
||||
);
|
||||
|
||||
void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 64000 }), { messages: [] }, {});
|
||||
|
||||
expect(hoisted.anthropicVertexCtorMock).toHaveBeenCalledWith({
|
||||
expect(anthropicVertexCtorMock).toHaveBeenCalledWith({
|
||||
projectId: "vertex-project",
|
||||
region: "europe-west4",
|
||||
baseURL: "https://europe-west4-aiplatform.googleapis.com/v1",
|
||||
@@ -284,14 +295,16 @@ describe("createAnthropicVertexStreamFnForModel", () => {
|
||||
});
|
||||
|
||||
it("preserves explicit custom provider base URLs", () => {
|
||||
const { deps, anthropicVertexCtorMock } = createStreamDeps();
|
||||
const streamFn = createAnthropicVertexStreamFnForModel(
|
||||
{ baseUrl: "https://proxy.example.test/custom-root/v1" },
|
||||
{ GOOGLE_CLOUD_PROJECT_ID: "vertex-project" } as NodeJS.ProcessEnv,
|
||||
deps,
|
||||
);
|
||||
|
||||
void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 64000 }), { messages: [] }, {});
|
||||
|
||||
expect(hoisted.anthropicVertexCtorMock).toHaveBeenCalledWith({
|
||||
expect(anthropicVertexCtorMock).toHaveBeenCalledWith({
|
||||
projectId: "vertex-project",
|
||||
region: "global",
|
||||
baseURL: "https://proxy.example.test/custom-root/v1",
|
||||
@@ -299,14 +312,16 @@ describe("createAnthropicVertexStreamFnForModel", () => {
|
||||
});
|
||||
|
||||
it("adds /v1 for path-prefixed custom provider base URLs", () => {
|
||||
const { deps, anthropicVertexCtorMock } = createStreamDeps();
|
||||
const streamFn = createAnthropicVertexStreamFnForModel(
|
||||
{ baseUrl: "https://proxy.example.test/custom-root" },
|
||||
{ GOOGLE_CLOUD_PROJECT_ID: "vertex-project" } as NodeJS.ProcessEnv,
|
||||
deps,
|
||||
);
|
||||
|
||||
void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 64000 }), { messages: [] }, {});
|
||||
|
||||
expect(hoisted.anthropicVertexCtorMock).toHaveBeenCalledWith({
|
||||
expect(anthropicVertexCtorMock).toHaveBeenCalledWith({
|
||||
projectId: "vertex-project",
|
||||
region: "global",
|
||||
baseURL: "https://proxy.example.test/custom-root/v1",
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { AnthropicVertex } from "@anthropic-ai/vertex-sdk";
|
||||
import { AnthropicVertex as AnthropicVertexSdk } from "@anthropic-ai/vertex-sdk";
|
||||
import type { StreamFn } from "@mariozechner/pi-agent-core";
|
||||
import { streamAnthropic, type AnthropicOptions, type Model } from "@mariozechner/pi-ai";
|
||||
import {
|
||||
streamAnthropic as streamAnthropicDefault,
|
||||
type AnthropicOptions,
|
||||
type Model,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import {
|
||||
applyAnthropicPayloadPolicyToParams,
|
||||
resolveAnthropicPayloadPolicy,
|
||||
@@ -9,6 +13,17 @@ import { resolveAnthropicVertexClientRegion, resolveAnthropicVertexProjectId } f
|
||||
|
||||
type AnthropicVertexEffort = NonNullable<AnthropicOptions["effort"]>;
|
||||
type AnthropicVertexAdaptiveEffort = AnthropicVertexEffort | "xhigh";
|
||||
type AnthropicVertexClientOptions = ConstructorParameters<typeof AnthropicVertexSdk>[0];
|
||||
|
||||
export type AnthropicVertexStreamDeps = {
|
||||
AnthropicVertex: new (options: AnthropicVertexClientOptions) => unknown;
|
||||
streamAnthropic: typeof streamAnthropicDefault;
|
||||
};
|
||||
|
||||
const defaultAnthropicVertexStreamDeps: AnthropicVertexStreamDeps = {
|
||||
AnthropicVertex: AnthropicVertexSdk as AnthropicVertexStreamDeps["AnthropicVertex"],
|
||||
streamAnthropic: streamAnthropicDefault,
|
||||
};
|
||||
|
||||
function isClaudeOpus47Model(modelId: string): boolean {
|
||||
return modelId.includes("opus-4-7") || modelId.includes("opus-4.7");
|
||||
@@ -104,8 +119,9 @@ export function createAnthropicVertexStreamFn(
|
||||
projectId: string | undefined,
|
||||
region: string,
|
||||
baseURL?: string,
|
||||
deps: AnthropicVertexStreamDeps = defaultAnthropicVertexStreamDeps,
|
||||
): StreamFn {
|
||||
const client = new AnthropicVertex({
|
||||
const client = new deps.AnthropicVertex({
|
||||
region,
|
||||
...(baseURL ? { baseURL } : {}),
|
||||
...(projectId ? { projectId } : {}),
|
||||
@@ -122,7 +138,7 @@ export function createAnthropicVertexStreamFn(
|
||||
requestedMaxTokens: options?.maxTokens,
|
||||
});
|
||||
const opts: AnthropicOptions = {
|
||||
client: client as unknown as AnthropicOptions["client"],
|
||||
client: client as AnthropicOptions["client"],
|
||||
temperature: options?.temperature,
|
||||
...(maxTokens !== undefined ? { maxTokens } : {}),
|
||||
signal: options?.signal,
|
||||
@@ -157,7 +173,7 @@ export function createAnthropicVertexStreamFn(
|
||||
opts.thinkingEnabled = false;
|
||||
}
|
||||
|
||||
return streamAnthropic(transportModel, context, opts);
|
||||
return deps.streamAnthropic(transportModel, context, opts);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -187,6 +203,7 @@ function resolveAnthropicVertexSdkBaseUrl(baseUrl?: string): string | undefined
|
||||
export function createAnthropicVertexStreamFnForModel(
|
||||
model: { baseUrl?: string },
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
deps?: AnthropicVertexStreamDeps,
|
||||
): StreamFn {
|
||||
return createAnthropicVertexStreamFn(
|
||||
resolveAnthropicVertexProjectId(env),
|
||||
@@ -195,5 +212,6 @@ export function createAnthropicVertexStreamFnForModel(
|
||||
env,
|
||||
}),
|
||||
resolveAnthropicVertexSdkBaseUrl(model.baseUrl),
|
||||
deps,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
resolveProfile,
|
||||
wrapExternalContent,
|
||||
} from "./browser-tool.runtime.js";
|
||||
import { DEFAULT_BROWSER_ACTION_TIMEOUT_MS } from "./browser/constants.js";
|
||||
|
||||
const browserToolActionDeps = {
|
||||
browserAct,
|
||||
@@ -25,6 +26,94 @@ const browserToolActionDeps = {
|
||||
loadConfig,
|
||||
};
|
||||
|
||||
const BROWSER_ACT_REQUEST_TIMEOUT_SLACK_MS = 5_000;
|
||||
|
||||
type BrowserActRequest = Parameters<typeof browserAct>[1];
|
||||
type BrowserActRequestWithTimeout = BrowserActRequest & { timeoutMs?: number };
|
||||
|
||||
function normalizePositiveTimeoutMs(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0
|
||||
? Math.floor(value)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function supportsBrowserActTimeout(request: BrowserActRequest): boolean {
|
||||
switch (request.kind) {
|
||||
case "click":
|
||||
case "type":
|
||||
case "hover":
|
||||
case "scrollIntoView":
|
||||
case "drag":
|
||||
case "select":
|
||||
case "fill":
|
||||
case "evaluate":
|
||||
case "wait":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function existingSessionRejectsActTimeout(request: BrowserActRequest): boolean {
|
||||
switch (request.kind) {
|
||||
case "type":
|
||||
case "hover":
|
||||
case "scrollIntoView":
|
||||
case "drag":
|
||||
case "select":
|
||||
case "fill":
|
||||
case "evaluate":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function usesExistingSessionProfile(profileName: string | undefined): boolean {
|
||||
const cfg = browserToolActionDeps.loadConfig();
|
||||
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
||||
const profile = resolveProfile(resolved, profileName ?? resolved.defaultProfile);
|
||||
return profile ? getBrowserProfileCapabilities(profile).usesChromeMcp : false;
|
||||
}
|
||||
|
||||
function withConfiguredActTimeout(
|
||||
request: BrowserActRequest,
|
||||
profileName: string | undefined,
|
||||
): BrowserActRequest {
|
||||
const typedRequest = request as BrowserActRequestWithTimeout;
|
||||
if (normalizePositiveTimeoutMs(typedRequest.timeoutMs) !== undefined) {
|
||||
return request;
|
||||
}
|
||||
if (!supportsBrowserActTimeout(request)) {
|
||||
return request;
|
||||
}
|
||||
if (existingSessionRejectsActTimeout(request) && usesExistingSessionProfile(profileName)) {
|
||||
return request;
|
||||
}
|
||||
|
||||
const cfg = browserToolActionDeps.loadConfig();
|
||||
const configuredTimeout =
|
||||
normalizePositiveTimeoutMs(cfg.browser?.actionTimeoutMs) ?? DEFAULT_BROWSER_ACTION_TIMEOUT_MS;
|
||||
return { ...typedRequest, timeoutMs: configuredTimeout } as BrowserActRequest;
|
||||
}
|
||||
|
||||
function resolveActProxyTimeoutMs(request: BrowserActRequest): number | undefined {
|
||||
const candidateTimeouts: number[] = [];
|
||||
const explicitTimeout = normalizePositiveTimeoutMs(
|
||||
(request as BrowserActRequestWithTimeout).timeoutMs,
|
||||
);
|
||||
if (explicitTimeout !== undefined) {
|
||||
candidateTimeouts.push(explicitTimeout + BROWSER_ACT_REQUEST_TIMEOUT_SLACK_MS);
|
||||
}
|
||||
if (request.kind === "wait") {
|
||||
const waitDuration = normalizePositiveTimeoutMs(request.timeMs);
|
||||
if (waitDuration !== undefined) {
|
||||
candidateTimeouts.push(waitDuration + BROWSER_ACT_REQUEST_TIMEOUT_SLACK_MS);
|
||||
}
|
||||
}
|
||||
return candidateTimeouts.length ? Math.max(...candidateTimeouts) : undefined;
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
setDepsForTest(
|
||||
overrides: Partial<{
|
||||
@@ -408,32 +497,34 @@ export async function executeConsoleAction(params: {
|
||||
}
|
||||
|
||||
export async function executeActAction(params: {
|
||||
request: Parameters<typeof browserAct>[1];
|
||||
request: BrowserActRequest;
|
||||
baseUrl?: string;
|
||||
profile?: string;
|
||||
proxyRequest: BrowserProxyRequest | null;
|
||||
onTabActivity?: (targetId: string | undefined) => void;
|
||||
}): Promise<AgentToolResult<unknown>> {
|
||||
const { request, baseUrl, profile, proxyRequest } = params;
|
||||
const effectiveRequest = withConfiguredActTimeout(request, profile);
|
||||
try {
|
||||
const result = proxyRequest
|
||||
? await proxyRequest({
|
||||
method: "POST",
|
||||
path: "/act",
|
||||
profile,
|
||||
body: request,
|
||||
body: effectiveRequest,
|
||||
timeoutMs: resolveActProxyTimeoutMs(effectiveRequest),
|
||||
})
|
||||
: await browserToolActionDeps.browserAct(baseUrl, request, {
|
||||
: await browserToolActionDeps.browserAct(baseUrl, effectiveRequest, {
|
||||
profile,
|
||||
});
|
||||
params.onTabActivity?.(
|
||||
readStringValue((result as { targetId?: unknown }).targetId) ??
|
||||
readStringValue(request.targetId),
|
||||
readStringValue(effectiveRequest.targetId),
|
||||
);
|
||||
return jsonResult(result);
|
||||
} catch (err) {
|
||||
if (isChromeStaleTargetError(profile, err)) {
|
||||
const retryRequest = stripTargetIdFromActRequest(request);
|
||||
const retryRequest = stripTargetIdFromActRequest(effectiveRequest);
|
||||
const tabs = proxyRequest
|
||||
? ((
|
||||
(await proxyRequest({
|
||||
@@ -445,7 +536,7 @@ export async function executeActAction(params: {
|
||||
: await browserToolActionDeps.browserTabs(baseUrl, { profile }).catch(() => []);
|
||||
// Some user-browser targetIds can go stale between snapshots and actions.
|
||||
// Only retry safe read-only actions, and only when exactly one tab remains attached.
|
||||
if (retryRequest && canRetryChromeActWithoutTargetId(request) && tabs.length === 1) {
|
||||
if (retryRequest && canRetryChromeActWithoutTargetId(effectiveRequest) && tabs.length === 1) {
|
||||
try {
|
||||
const retryResult = proxyRequest
|
||||
? await proxyRequest({
|
||||
@@ -453,6 +544,7 @@ export async function executeActAction(params: {
|
||||
path: "/act",
|
||||
profile,
|
||||
body: retryRequest,
|
||||
timeoutMs: resolveActProxyTimeoutMs(retryRequest),
|
||||
})
|
||||
: await browserToolActionDeps.browserAct(baseUrl, retryRequest, {
|
||||
profile,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Type } from "typebox";
|
||||
|
||||
const BROWSER_ACT_KINDS = [
|
||||
"click",
|
||||
"clickCoords",
|
||||
"type",
|
||||
"press",
|
||||
"hover",
|
||||
@@ -55,6 +56,8 @@ const BrowserActSchema = Type.Object({
|
||||
doubleClick: Type.Optional(Type.Boolean()),
|
||||
button: Type.Optional(Type.String()),
|
||||
modifiers: Type.Optional(Type.Array(Type.String())),
|
||||
x: Type.Optional(Type.Number()),
|
||||
y: Type.Optional(Type.Number()),
|
||||
// type
|
||||
text: Type.Optional(Type.String()),
|
||||
submit: Type.Optional(Type.Boolean()),
|
||||
@@ -122,6 +125,8 @@ export const BrowserToolSchema = Type.Object({
|
||||
doubleClick: Type.Optional(Type.Boolean()),
|
||||
button: Type.Optional(Type.String()),
|
||||
modifiers: Type.Optional(Type.Array(Type.String())),
|
||||
x: Type.Optional(Type.Number()),
|
||||
y: Type.Optional(Type.Number()),
|
||||
text: Type.Optional(Type.String()),
|
||||
submit: Type.Optional(Type.Boolean()),
|
||||
slowly: Type.Optional(Type.Boolean()),
|
||||
|
||||
@@ -69,6 +69,7 @@ const browserConfigMocks = vi.hoisted(() => ({
|
||||
controlPort: 18791,
|
||||
profiles: {},
|
||||
defaultProfile: "openclaw",
|
||||
actionTimeoutMs: 60_000,
|
||||
})),
|
||||
resolveProfile: vi.fn((resolved: Record<string, unknown>, name: string) => {
|
||||
const profile = (resolved.profiles as Record<string, Record<string, unknown>> | undefined)?.[
|
||||
@@ -249,6 +250,7 @@ function resetBrowserToolMocks() {
|
||||
controlPort: 18791,
|
||||
profiles: {},
|
||||
defaultProfile: "openclaw",
|
||||
actionTimeoutMs: 60_000,
|
||||
});
|
||||
nodesUtilsMocks.listNodes.mockResolvedValue([]);
|
||||
browserToolTesting.setDepsForTest({
|
||||
@@ -292,6 +294,7 @@ function setResolvedBrowserProfiles(
|
||||
controlPort: 18791,
|
||||
profiles,
|
||||
defaultProfile,
|
||||
actionTimeoutMs: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1078,6 +1081,87 @@ describe("browser tool act compatibility", () => {
|
||||
expect.objectContaining({ profile: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it("applies configured browser action timeout when act timeout is omitted", async () => {
|
||||
configMocks.loadConfig.mockReturnValue({ browser: { actionTimeoutMs: 45_000 } });
|
||||
|
||||
const tool = createBrowserTool();
|
||||
await tool.execute?.("call-1", {
|
||||
action: "act",
|
||||
request: {
|
||||
kind: "wait",
|
||||
timeMs: 20_000,
|
||||
},
|
||||
});
|
||||
|
||||
expect(browserActionsMocks.browserAct).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
{
|
||||
kind: "wait",
|
||||
timeMs: 20_000,
|
||||
timeoutMs: 45_000,
|
||||
},
|
||||
expect.objectContaining({ profile: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not inject unsupported action timeout for existing-session type actions", async () => {
|
||||
setResolvedBrowserProfiles({
|
||||
user: { driver: "existing-session", attachOnly: true, color: "#00AA00" },
|
||||
});
|
||||
configMocks.loadConfig.mockReturnValue({ browser: { actionTimeoutMs: 45_000 } });
|
||||
|
||||
const tool = createBrowserTool();
|
||||
await tool.execute?.("call-1", {
|
||||
action: "act",
|
||||
profile: "user",
|
||||
target: "host",
|
||||
request: {
|
||||
kind: "type",
|
||||
ref: "f1e3",
|
||||
text: "Test Title",
|
||||
},
|
||||
});
|
||||
|
||||
expect(browserActionsMocks.browserAct).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
{
|
||||
kind: "type",
|
||||
ref: "f1e3",
|
||||
text: "Test Title",
|
||||
},
|
||||
expect.objectContaining({ profile: "user" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes configured act timeout through node proxy with transport slack", async () => {
|
||||
mockSingleBrowserProxyNode();
|
||||
configMocks.loadConfig.mockReturnValue({
|
||||
browser: {
|
||||
actionTimeoutMs: 45_000,
|
||||
},
|
||||
gateway: { nodes: { browser: { node: "node-1" } } },
|
||||
});
|
||||
|
||||
const tool = createBrowserTool();
|
||||
await tool.execute?.("call-1", {
|
||||
action: "act",
|
||||
target: "node",
|
||||
request: { kind: "wait", timeMs: 20_000 },
|
||||
});
|
||||
|
||||
expect(gatewayMocks.callGatewayTool).toHaveBeenCalledWith(
|
||||
"node.invoke",
|
||||
{ timeoutMs: 55_000 },
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
path: "/act",
|
||||
body: { kind: "wait", timeMs: 20_000, timeoutMs: 45_000 },
|
||||
timeoutMs: 45_000 + 5_000,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("browser tool snapshot labels", () => {
|
||||
|
||||
@@ -147,6 +147,8 @@ const LEGACY_BROWSER_ACT_REQUEST_KEYS = [
|
||||
"doubleClick",
|
||||
"button",
|
||||
"modifiers",
|
||||
"x",
|
||||
"y",
|
||||
"text",
|
||||
"submit",
|
||||
"slowly",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clickChromeMcpElement,
|
||||
buildChromeMcpArgs,
|
||||
ensureChromeMcpAvailable,
|
||||
evaluateChromeMcpScript,
|
||||
listChromeMcpTabs,
|
||||
navigateChromeMcpPage,
|
||||
@@ -97,6 +98,7 @@ function createFakeSession(): ChromeMcpSession {
|
||||
describe("chrome MCP page parsing", () => {
|
||||
beforeEach(async () => {
|
||||
await resetChromeMcpSessionsForTest();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -186,6 +188,72 @@ describe("chrome MCP page parsing", () => {
|
||||
expect(result).toBe(123);
|
||||
});
|
||||
|
||||
it("does not cache an ephemeral availability probe before the next real attach", async () => {
|
||||
let factoryCalls = 0;
|
||||
const closeMocks: Array<ReturnType<typeof vi.fn>> = [];
|
||||
const factory: ChromeMcpSessionFactory = async () => {
|
||||
factoryCalls += 1;
|
||||
const session = createFakeSession();
|
||||
const closeMock = vi.fn().mockResolvedValue(undefined);
|
||||
session.client.close = closeMock as typeof session.client.close;
|
||||
closeMocks.push(closeMock);
|
||||
return session;
|
||||
};
|
||||
setChromeMcpSessionFactoryForTest(factory);
|
||||
|
||||
await ensureChromeMcpAvailable("chrome-live", undefined, { ephemeral: true });
|
||||
|
||||
expect(factoryCalls).toBe(1);
|
||||
expect(closeMocks[0]).toHaveBeenCalledTimes(1);
|
||||
|
||||
const tabs = await listChromeMcpTabs("chrome-live");
|
||||
|
||||
expect(factoryCalls).toBe(2);
|
||||
expect(closeMocks[1]).not.toHaveBeenCalled();
|
||||
expect(tabs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not poison the next real attach after an ephemeral no-page probe", async () => {
|
||||
let factoryCalls = 0;
|
||||
const closeMocks: Array<ReturnType<typeof vi.fn>> = [];
|
||||
const factory: ChromeMcpSessionFactory = async () => {
|
||||
factoryCalls += 1;
|
||||
const session = createFakeSession();
|
||||
const closeMock = vi.fn().mockResolvedValue(undefined);
|
||||
session.client.close = closeMock as typeof session.client.close;
|
||||
closeMocks.push(closeMock);
|
||||
if (factoryCalls === 1) {
|
||||
const callTool = vi.fn(async ({ name }: ToolCall) => {
|
||||
if (name === "list_pages") {
|
||||
return {
|
||||
content: [{ type: "text", text: "No page selected" }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected tool ${name}`);
|
||||
});
|
||||
session.client.callTool = callTool as typeof session.client.callTool;
|
||||
}
|
||||
return session;
|
||||
};
|
||||
setChromeMcpSessionFactoryForTest(factory);
|
||||
|
||||
await expect(
|
||||
listChromeMcpTabs("chrome-live", undefined, {
|
||||
ephemeral: true,
|
||||
}),
|
||||
).rejects.toThrow(/No page selected/);
|
||||
|
||||
expect(factoryCalls).toBe(1);
|
||||
expect(closeMocks[0]).toHaveBeenCalledTimes(1);
|
||||
|
||||
const tabs = await listChromeMcpTabs("chrome-live");
|
||||
|
||||
expect(factoryCalls).toBe(2);
|
||||
expect(closeMocks[1]).not.toHaveBeenCalled();
|
||||
expect(tabs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("surfaces MCP tool errors instead of JSON parse noise", async () => {
|
||||
const factory: ChromeMcpSessionFactory = async () => {
|
||||
const session = createFakeSession();
|
||||
@@ -407,7 +475,6 @@ describe("chrome MCP page parsing", () => {
|
||||
expect(factoryCalls).toBe(2);
|
||||
expect(tabs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("reconnects and retries list_pages once when Chrome MCP reports a stale selected page", async () => {
|
||||
let factoryCalls = 0;
|
||||
const factory: ChromeMcpSessionFactory = async () => {
|
||||
@@ -546,4 +613,34 @@ describe("chrome MCP page parsing", () => {
|
||||
expect(factoryCalls).toBe(2);
|
||||
expect(tabs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("honors timeoutMs for ephemeral availability probes", async () => {
|
||||
vi.useFakeTimers();
|
||||
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 promise = ensureChromeMcpAvailable("chrome-live", undefined, {
|
||||
ephemeral: true,
|
||||
timeoutMs: 50,
|
||||
});
|
||||
const expectation = expect(promise).rejects.toThrow(/timed out after 50ms/i);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
|
||||
await expectation;
|
||||
expect(closeMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,18 @@ type ChromeMcpSession = {
|
||||
ready: Promise<void>;
|
||||
};
|
||||
|
||||
type ChromeMcpCallOptions = {
|
||||
ephemeral?: boolean;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
type ChromeMcpSessionLease = {
|
||||
session: ChromeMcpSession;
|
||||
cacheKey: string;
|
||||
temporary: boolean;
|
||||
};
|
||||
|
||||
type ChromeMcpSessionFactory = (
|
||||
profileName: string,
|
||||
userDataDir?: string,
|
||||
@@ -332,7 +344,42 @@ async function createRealSession(
|
||||
};
|
||||
}
|
||||
|
||||
async function getSession(profileName: string, userDataDir?: string): Promise<ChromeMcpSession> {
|
||||
async function waitForChromeMcpReady(
|
||||
session: ChromeMcpSession,
|
||||
profileName: string,
|
||||
timeoutMs?: number,
|
||||
): Promise<void> {
|
||||
if (!timeoutMs || timeoutMs <= 0) {
|
||||
await session.ready;
|
||||
return;
|
||||
}
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
await Promise.race([
|
||||
session.ready,
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(
|
||||
new BrowserProfileUnavailableError(
|
||||
`Chrome MCP existing-session attach for profile "${profileName}" timed out after ${timeoutMs}ms.`,
|
||||
),
|
||||
);
|
||||
}, timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getSession(
|
||||
profileName: string,
|
||||
userDataDir?: string,
|
||||
timeoutMs?: number,
|
||||
): Promise<ChromeMcpSession> {
|
||||
const cacheKey = buildChromeMcpSessionCacheKey(profileName, userDataDir);
|
||||
await closeChromeMcpSessionsForProfile(profileName, cacheKey);
|
||||
|
||||
@@ -364,7 +411,7 @@ async function getSession(profileName: string, userDataDir?: string): Promise<Ch
|
||||
}
|
||||
}
|
||||
try {
|
||||
await session.ready;
|
||||
await waitForChromeMcpReady(session, profileName, timeoutMs);
|
||||
return session;
|
||||
} catch (err) {
|
||||
const current = sessions.get(cacheKey);
|
||||
@@ -375,23 +422,110 @@ async function getSession(profileName: string, userDataDir?: string): Promise<Ch
|
||||
}
|
||||
}
|
||||
|
||||
async function getExistingSession(
|
||||
cacheKey: string,
|
||||
profileName: string,
|
||||
timeoutMs?: number,
|
||||
): Promise<ChromeMcpSession | null> {
|
||||
let session = sessions.get(cacheKey);
|
||||
if (session && session.transport.pid === null) {
|
||||
sessions.delete(cacheKey);
|
||||
session = undefined;
|
||||
}
|
||||
if (session) {
|
||||
try {
|
||||
await waitForChromeMcpReady(session, profileName, timeoutMs);
|
||||
return session;
|
||||
} catch (err) {
|
||||
const current = sessions.get(cacheKey);
|
||||
if (current?.transport === session.transport) {
|
||||
sessions.delete(cacheKey);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const pending = pendingSessions.get(cacheKey);
|
||||
if (!pending) {
|
||||
return null;
|
||||
}
|
||||
|
||||
session = await pending;
|
||||
try {
|
||||
await waitForChromeMcpReady(session, profileName, timeoutMs);
|
||||
return session;
|
||||
} catch (err) {
|
||||
const current = sessions.get(cacheKey);
|
||||
if (current?.transport === session.transport) {
|
||||
sessions.delete(cacheKey);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function createEphemeralSession(
|
||||
profileName: string,
|
||||
userDataDir?: string,
|
||||
timeoutMs?: number,
|
||||
): Promise<ChromeMcpSession> {
|
||||
const session = await (sessionFactory ?? createRealSession)(profileName, userDataDir);
|
||||
try {
|
||||
await waitForChromeMcpReady(session, profileName, timeoutMs);
|
||||
return session;
|
||||
} catch (err) {
|
||||
await session.client.close().catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function leaseSession(
|
||||
profileName: string,
|
||||
userDataDir?: string,
|
||||
options: ChromeMcpCallOptions = {},
|
||||
): Promise<ChromeMcpSessionLease> {
|
||||
const cacheKey = buildChromeMcpSessionCacheKey(profileName, userDataDir);
|
||||
if (!options.ephemeral) {
|
||||
return {
|
||||
session: await getSession(profileName, userDataDir, options.timeoutMs),
|
||||
cacheKey,
|
||||
temporary: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Status probes should avoid seeding the shared attach session cache, but they can safely
|
||||
// reuse a real cached session if one already exists.
|
||||
const existingSession = await getExistingSession(cacheKey, profileName, options.timeoutMs);
|
||||
if (existingSession) {
|
||||
return {
|
||||
session: existingSession,
|
||||
cacheKey,
|
||||
temporary: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
session: await createEphemeralSession(profileName, userDataDir, options.timeoutMs),
|
||||
cacheKey,
|
||||
temporary: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function callTool(
|
||||
profileName: string,
|
||||
userDataDir: string | undefined,
|
||||
name: string,
|
||||
args: Record<string, unknown> = {},
|
||||
opts?: { timeoutMs?: number; signal?: AbortSignal },
|
||||
options: ChromeMcpCallOptions = {},
|
||||
): Promise<ChromeMcpToolResult> {
|
||||
const cacheKey = buildChromeMcpSessionCacheKey(profileName, userDataDir);
|
||||
const timeoutMs = opts?.timeoutMs;
|
||||
const signal = opts?.signal;
|
||||
const timeoutMs = options.timeoutMs;
|
||||
const signal = options.signal;
|
||||
if (signal?.aborted) {
|
||||
throw signal.reason ?? new Error("aborted");
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const session = await getSession(profileName, userDataDir);
|
||||
const rawCall = session.client.callTool({
|
||||
const lease = await leaseSession(profileName, userDataDir, options);
|
||||
const rawCall = lease.session.client.callTool({
|
||||
name,
|
||||
arguments: args,
|
||||
}) as Promise<ChromeMcpToolResult>;
|
||||
@@ -430,10 +564,12 @@ async function callTool(
|
||||
void rawCall.catch(() => {});
|
||||
// Transport/connection error, timeout, or abort: tear down session so it reconnects.
|
||||
// Transport-identity check prevents clobbering a replacement session created concurrently.
|
||||
const cur = sessions.get(cacheKey);
|
||||
if (cur?.transport === session.transport) {
|
||||
sessions.delete(cacheKey);
|
||||
await session.client.close().catch(() => {});
|
||||
if (!lease.temporary) {
|
||||
const cur = sessions.get(lease.cacheKey);
|
||||
if (cur?.transport === lease.session.transport) {
|
||||
sessions.delete(lease.cacheKey);
|
||||
await lease.session.client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
@@ -443,6 +579,9 @@ async function callTool(
|
||||
if (signal && abortListener) {
|
||||
signal.removeEventListener("abort", abortListener);
|
||||
}
|
||||
if (lease.temporary) {
|
||||
await lease.session.client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
// Tool-level errors (element not found, script error, etc.) don't indicate a
|
||||
// broken connection. A stale selected-page error does poison the Chrome MCP
|
||||
@@ -450,10 +589,12 @@ async function callTool(
|
||||
if (result.isError) {
|
||||
const message = extractToolErrorMessage(result, name);
|
||||
if (shouldReconnectForToolError(name, message)) {
|
||||
const cur = sessions.get(cacheKey);
|
||||
if (cur?.transport === session.transport) {
|
||||
sessions.delete(cacheKey);
|
||||
await session.client.close().catch(() => {});
|
||||
if (!lease.temporary) {
|
||||
const cur = sessions.get(lease.cacheKey);
|
||||
if (cur?.transport === lease.session.transport) {
|
||||
sessions.delete(lease.cacheKey);
|
||||
await lease.session.client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
if (attempt === 0) {
|
||||
continue;
|
||||
@@ -492,8 +633,12 @@ async function findPageById(
|
||||
export async function ensureChromeMcpAvailable(
|
||||
profileName: string,
|
||||
userDataDir?: string,
|
||||
options: ChromeMcpCallOptions = {},
|
||||
): Promise<void> {
|
||||
await getSession(profileName, userDataDir);
|
||||
const lease = await leaseSession(profileName, userDataDir, options);
|
||||
if (lease.temporary) {
|
||||
await lease.session.client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export function getChromeMcpPid(profileName: string): number | null {
|
||||
@@ -519,16 +664,18 @@ export async function stopAllChromeMcpSessions(): Promise<void> {
|
||||
export async function listChromeMcpPages(
|
||||
profileName: string,
|
||||
userDataDir?: string,
|
||||
options: ChromeMcpCallOptions = {},
|
||||
): Promise<ChromeMcpStructuredPage[]> {
|
||||
const result = await callTool(profileName, userDataDir, "list_pages");
|
||||
const result = await callTool(profileName, userDataDir, "list_pages", {}, options);
|
||||
return extractStructuredPages(result);
|
||||
}
|
||||
|
||||
export async function listChromeMcpTabs(
|
||||
profileName: string,
|
||||
userDataDir?: string,
|
||||
options: ChromeMcpCallOptions = {},
|
||||
): Promise<BrowserTab[]> {
|
||||
return toBrowserTabs(await listChromeMcpPages(profileName, userDataDir));
|
||||
return toBrowserTabs(await listChromeMcpPages(profileName, userDataDir, options));
|
||||
}
|
||||
|
||||
export async function openChromeMcpTab(
|
||||
@@ -677,6 +824,65 @@ export async function clickChromeMcpElement(params: {
|
||||
);
|
||||
}
|
||||
|
||||
export async function clickChromeMcpCoords(params: {
|
||||
profileName: string;
|
||||
userDataDir?: string;
|
||||
targetId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
doubleClick?: boolean;
|
||||
button?: "left" | "right" | "middle";
|
||||
delayMs?: number;
|
||||
}): Promise<void> {
|
||||
const button = params.button ?? "left";
|
||||
const buttonCode = button === "middle" ? 1 : button === "right" ? 2 : 0;
|
||||
const pressedButtons = button === "middle" ? 4 : button === "right" ? 2 : 1;
|
||||
const x = JSON.stringify(params.x);
|
||||
const y = JSON.stringify(params.y);
|
||||
const delayMs = JSON.stringify(Math.max(0, Math.floor(params.delayMs ?? 0)));
|
||||
const doubleClick = params.doubleClick ? "true" : "false";
|
||||
await evaluateChromeMcpScript({
|
||||
profileName: params.profileName,
|
||||
userDataDir: params.userDataDir,
|
||||
targetId: params.targetId,
|
||||
fn: `async () => {
|
||||
const x = ${x};
|
||||
const y = ${y};
|
||||
const delayMs = ${delayMs};
|
||||
const doubleClick = ${doubleClick};
|
||||
const target = document.elementFromPoint(x, y) ?? document.body ?? document.documentElement ?? document;
|
||||
const base = {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: window,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
screenX: window.screenX + x,
|
||||
screenY: window.screenY + y,
|
||||
button: ${buttonCode},
|
||||
};
|
||||
const pressedButtons = ${pressedButtons};
|
||||
const dispatch = (type, buttons, detail) => {
|
||||
target.dispatchEvent(new MouseEvent(type, { ...base, buttons, detail }));
|
||||
};
|
||||
dispatch("mousemove", 0, 0);
|
||||
dispatch("mousedown", pressedButtons, 1);
|
||||
if (delayMs > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
dispatch("mouseup", 0, 1);
|
||||
dispatch("click", 0, 1);
|
||||
if (doubleClick) {
|
||||
dispatch("mousedown", pressedButtons, 2);
|
||||
dispatch("mouseup", 0, 2);
|
||||
dispatch("click", 0, 2);
|
||||
dispatch("dblclick", 0, 2);
|
||||
}
|
||||
return true;
|
||||
}`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function fillChromeMcpElement(params: {
|
||||
profileName: string;
|
||||
userDataDir?: string;
|
||||
|
||||
@@ -433,6 +433,66 @@ describe("chrome.ts internal", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("clears stale singleton locks and retries once after profile-in-use launch failure", async () => {
|
||||
let cdpReachable = false;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
if (!cdpReachable) {
|
||||
throw new Error("ECONNREFUSED");
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" }),
|
||||
} as unknown as Response;
|
||||
}),
|
||||
);
|
||||
vi.spyOn(fs, "existsSync").mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
if (s === "/tmp/profile-chrome" || s.endsWith("Local State") || s.endsWith("Preferences")) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
let spawnCalls = 0;
|
||||
const firstProc = makeFakeProc();
|
||||
const secondProc = makeFakeProc();
|
||||
spawnMock.mockImplementation(() => {
|
||||
spawnCalls += 1;
|
||||
if (spawnCalls === 1) {
|
||||
setTimeout(() => {
|
||||
firstProc.stderr.emit(
|
||||
"data",
|
||||
Buffer.from("The profile appears to be in use by another Chromium process"),
|
||||
);
|
||||
}, 0);
|
||||
return firstProc;
|
||||
}
|
||||
cdpReachable = true;
|
||||
return secondProc;
|
||||
});
|
||||
|
||||
const profile = { ...makeProfile(18888), executablePath: "/tmp/profile-chrome" };
|
||||
const userDataDir = resolveOpenClawUserDataDir(profile.name);
|
||||
await fsp.mkdir(userDataDir, { recursive: true });
|
||||
await fsp.writeFile(path.join(userDataDir, "SingletonCookie"), "cookie");
|
||||
await fsp.writeFile(path.join(userDataDir, "SingletonSocket"), "socket");
|
||||
await fsp.symlink("remote-host-535", path.join(userDataDir, "SingletonLock"));
|
||||
|
||||
try {
|
||||
const running = await launchOpenClawChrome(makeResolved(), profile);
|
||||
expect(running.proc).toBe(secondProc);
|
||||
expect(firstProc.kill).toHaveBeenCalledWith("SIGKILL");
|
||||
expect(spawnCalls).toBe(2);
|
||||
expect(fs.existsSync(path.join(userDataDir, "SingletonLock"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(userDataDir, "SingletonSocket"))).toBe(false);
|
||||
running.proc.kill?.("SIGTERM");
|
||||
} finally {
|
||||
await fsp.rm(userDataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("throws with stderr hint + sandbox hint when CDP never becomes reachable", async () => {
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, "platform", { value: "linux" });
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
resolveGoogleChromeExecutableForPlatform,
|
||||
} from "./chrome.executables.js";
|
||||
import {
|
||||
clearStaleChromeSingletonLocks,
|
||||
decorateOpenClawProfile,
|
||||
diagnoseChromeCdp,
|
||||
ensureProfileCleanExit,
|
||||
@@ -212,6 +213,55 @@ describe("browser chrome profile decoration", () => {
|
||||
const profile = prefs.profile as Record<string, unknown>;
|
||||
expect(profile.name).toBe(DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME);
|
||||
});
|
||||
|
||||
it("clears stale singleton artifacts when the lock points at another host", async () => {
|
||||
const userDataDir = await createUserDataDir();
|
||||
await fsp.writeFile(path.join(userDataDir, "SingletonCookie"), "cookie");
|
||||
await fsp.writeFile(path.join(userDataDir, "SingletonSocket"), "socket");
|
||||
await fsp.symlink("remote-host-535", path.join(userDataDir, "SingletonLock"));
|
||||
|
||||
expect(clearStaleChromeSingletonLocks(userDataDir, "local-host")).toBe(true);
|
||||
expect(fs.existsSync(path.join(userDataDir, "SingletonLock"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(userDataDir, "SingletonSocket"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(userDataDir, "SingletonCookie"))).toBe(false);
|
||||
});
|
||||
|
||||
it("clears stale singleton artifacts when the lock PID is dead on the current host", async () => {
|
||||
const userDataDir = await createUserDataDir();
|
||||
const deadPid = 2147483646;
|
||||
await fsp.symlink(`${os.hostname()}-${deadPid}`, path.join(userDataDir, "SingletonLock"));
|
||||
|
||||
expect(clearStaleChromeSingletonLocks(userDataDir, os.hostname())).toBe(true);
|
||||
expect(fs.existsSync(path.join(userDataDir, "SingletonLock"))).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps singleton artifacts when the lock points at a current-host live process", async () => {
|
||||
const userDataDir = await createUserDataDir();
|
||||
await fsp.symlink(`${os.hostname()}-${process.pid}`, path.join(userDataDir, "SingletonLock"));
|
||||
|
||||
expect(clearStaleChromeSingletonLocks(userDataDir, os.hostname())).toBe(false);
|
||||
expect(fs.lstatSync(path.join(userDataDir, "SingletonLock")).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps singleton artifacts when the lock PID exists but cannot be signaled", async () => {
|
||||
const userDataDir = await createUserDataDir();
|
||||
await fsp.symlink(`${os.hostname()}-12345`, path.join(userDataDir, "SingletonLock"));
|
||||
const err = new Error("operation not permitted") as NodeJS.ErrnoException;
|
||||
err.code = "EPERM";
|
||||
const killSpy = vi.spyOn(process, "kill").mockImplementation(((pid, signal) => {
|
||||
if (pid === 12345 && signal === 0) {
|
||||
throw err;
|
||||
}
|
||||
return true;
|
||||
}) as typeof process.kill);
|
||||
|
||||
try {
|
||||
expect(clearStaleChromeSingletonLocks(userDataDir, os.hostname())).toBe(false);
|
||||
expect(fs.lstatSync(path.join(userDataDir, "SingletonLock")).isSymbolicLink()).toBe(true);
|
||||
} finally {
|
||||
killSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("browser chrome helpers", () => {
|
||||
@@ -631,6 +681,7 @@ describe("browser chrome launch args", () => {
|
||||
evaluateEnabled: false,
|
||||
remoteCdpTimeoutMs: 1500,
|
||||
remoteCdpHandshakeTimeoutMs: 3000,
|
||||
actionTimeoutMs: 60_000,
|
||||
extraArgs: [],
|
||||
color: "#FF4500",
|
||||
headless: false,
|
||||
|
||||
@@ -53,6 +53,13 @@ import {
|
||||
} from "./constants.js";
|
||||
|
||||
const log = createSubsystemLogger("browser").child("chrome");
|
||||
const CHROME_SINGLETON_LOCK_PATHS = [
|
||||
"SingletonLock",
|
||||
"SingletonSocket",
|
||||
"SingletonCookie",
|
||||
] as const;
|
||||
const CHROME_SINGLETON_IN_USE_PATTERN = /profile appears to be in use by another chromium process/i;
|
||||
const CHROME_MISSING_DISPLAY_PATTERN = /missing x server|\$DISPLAY/i;
|
||||
|
||||
export type { BrowserExecutable } from "./chrome.executables.js";
|
||||
export {
|
||||
@@ -81,6 +88,109 @@ function exists(filePath: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function processExists(pid: number): boolean {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "EPERM") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearChromeSingletonArtifacts(userDataDir: string) {
|
||||
for (const basename of CHROME_SINGLETON_LOCK_PATHS) {
|
||||
try {
|
||||
fs.rmSync(path.join(userDataDir, basename), { force: true });
|
||||
} catch {
|
||||
// ignore best-effort cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function clearStaleChromeSingletonLocks(
|
||||
userDataDir: string,
|
||||
hostname = os.hostname(),
|
||||
): boolean {
|
||||
const lockPath = path.join(userDataDir, "SingletonLock");
|
||||
let target: string;
|
||||
try {
|
||||
target = fs.readlinkSync(lockPath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const match = /^(?<lockHost>.+)-(?<pid>\d+)$/.exec(target);
|
||||
if (!match?.groups) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lockHost = normalizeOptionalString(match.groups.lockHost) ?? "";
|
||||
const pid = Number.parseInt(match.groups.pid ?? "", 10);
|
||||
if (lockHost === hostname && processExists(pid)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
clearChromeSingletonArtifacts(userDataDir);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function waitForChromeProcessExit(proc: ChildProcess, timeoutMs: number): Promise<void> {
|
||||
if (proc.exitCode != null || proc.signalCode != null || proc.killed) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
proc.off("exit", onExit);
|
||||
proc.off("close", onExit);
|
||||
resolve();
|
||||
}, timeoutMs);
|
||||
const onExit = () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
proc.once("exit", onExit);
|
||||
proc.once("close", onExit);
|
||||
});
|
||||
}
|
||||
|
||||
async function terminateChromeForRetry(proc: ChildProcess, userDataDir: string) {
|
||||
try {
|
||||
proc.kill("SIGKILL");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
await waitForChromeProcessExit(proc, CHROME_BOOTSTRAP_EXIT_TIMEOUT_MS);
|
||||
clearStaleChromeSingletonLocks(userDataDir);
|
||||
}
|
||||
|
||||
function chromeLaunchHints(params: {
|
||||
stderrOutput: string;
|
||||
resolved: ResolvedBrowserConfig;
|
||||
profile: ResolvedBrowserProfile;
|
||||
}): string {
|
||||
const hints: string[] = [];
|
||||
if (process.platform === "linux" && !params.resolved.noSandbox) {
|
||||
hints.push("If running in a container or as root, try setting browser.noSandbox: true.");
|
||||
}
|
||||
if (CHROME_MISSING_DISPLAY_PATTERN.test(params.stderrOutput) && !params.profile.headless) {
|
||||
hints.push(
|
||||
"No DISPLAY/X server was detected. Enable browser.headless: true, start Xvfb, or run the Gateway in a desktop session.",
|
||||
);
|
||||
}
|
||||
if (CHROME_SINGLETON_IN_USE_PATTERN.test(params.stderrOutput)) {
|
||||
hints.push(
|
||||
`The Chromium profile "${params.profile.name}" is locked. Stop the existing browser or remove stale Singleton* lock files under ~/.openclaw/browser/${params.profile.name}/user-data.`,
|
||||
);
|
||||
}
|
||||
return hints.length > 0 ? `\nHint: ${hints.join("\nHint: ")}` : "";
|
||||
}
|
||||
|
||||
export type RunningChrome = {
|
||||
pid: number;
|
||||
exe: BrowserExecutable;
|
||||
@@ -363,66 +473,80 @@ export async function launchOpenClawChrome(
|
||||
log.warn(`openclaw browser clean-exit prefs failed: ${String(err)}`);
|
||||
}
|
||||
|
||||
const proc = spawnOnce();
|
||||
const launchOnceAndWait = async (allowSingletonRecovery: boolean): Promise<RunningChrome> => {
|
||||
const proc = spawnOnce();
|
||||
|
||||
// Collect stderr for diagnostics in case Chrome fails to start.
|
||||
// The listener is removed on success to avoid unbounded memory growth
|
||||
// from a long-lived Chrome process that emits periodic warnings.
|
||||
const stderrChunks: Buffer[] = [];
|
||||
const onStderr = (chunk: Buffer) => {
|
||||
stderrChunks.push(chunk);
|
||||
};
|
||||
proc.stderr?.on("data", onStderr);
|
||||
// Collect stderr for diagnostics in case Chrome fails to start.
|
||||
// The listener is removed on success to avoid unbounded memory growth
|
||||
// from a long-lived Chrome process that emits periodic warnings.
|
||||
const stderrChunks: Buffer[] = [];
|
||||
const onStderr = (chunk: Buffer) => {
|
||||
stderrChunks.push(chunk);
|
||||
};
|
||||
proc.stderr?.on("data", onStderr);
|
||||
|
||||
// Wait for CDP to come up.
|
||||
const readyDeadline = Date.now() + CHROME_LAUNCH_READY_WINDOW_MS;
|
||||
while (Date.now() < readyDeadline) {
|
||||
if (await isChromeReachable(profile.cdpUrl)) {
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, CHROME_LAUNCH_READY_POLL_MS));
|
||||
}
|
||||
|
||||
if (!(await isChromeReachable(profile.cdpUrl))) {
|
||||
const diagnosticText = await diagnoseChromeCdp(profile.cdpUrl)
|
||||
.then(formatChromeCdpDiagnostic)
|
||||
.catch((err) => `CDP diagnostic failed: ${safeChromeCdpErrorMessage(err)}.`);
|
||||
const stderrOutput =
|
||||
normalizeOptionalString(Buffer.concat(stderrChunks).toString("utf8")) ?? "";
|
||||
const stderrHint = stderrOutput
|
||||
? `\nChrome stderr:\n${stderrOutput.slice(0, CHROME_STDERR_HINT_MAX_CHARS)}`
|
||||
: "";
|
||||
const sandboxHint =
|
||||
process.platform === "linux" && !resolved.noSandbox
|
||||
? "\nHint: If running in a container or as root, try setting browser.noSandbox: true in config."
|
||||
: "";
|
||||
try {
|
||||
proc.kill("SIGKILL");
|
||||
} catch {
|
||||
// ignore
|
||||
const readyDeadline = Date.now() + CHROME_LAUNCH_READY_WINDOW_MS;
|
||||
while (Date.now() < readyDeadline) {
|
||||
if (await isChromeReachable(profile.cdpUrl)) {
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, CHROME_LAUNCH_READY_POLL_MS));
|
||||
}
|
||||
|
||||
if (!(await isChromeReachable(profile.cdpUrl))) {
|
||||
const diagnosticText = await diagnoseChromeCdp(profile.cdpUrl)
|
||||
.then(formatChromeCdpDiagnostic)
|
||||
.catch((err) => `CDP diagnostic failed: ${safeChromeCdpErrorMessage(err)}.`);
|
||||
const stderrOutput =
|
||||
normalizeOptionalString(Buffer.concat(stderrChunks).toString("utf8")) ?? "";
|
||||
if (
|
||||
allowSingletonRecovery &&
|
||||
CHROME_SINGLETON_IN_USE_PATTERN.test(stderrOutput) &&
|
||||
clearStaleChromeSingletonLocks(userDataDir)
|
||||
) {
|
||||
log.warn(
|
||||
`Removed stale Chromium Singleton* locks for profile "${profile.name}" and retrying launch.`,
|
||||
);
|
||||
await terminateChromeForRetry(proc, userDataDir);
|
||||
return await launchOnceAndWait(false);
|
||||
}
|
||||
const stderrHint = stderrOutput
|
||||
? `\nChrome stderr:\n${stderrOutput.slice(0, CHROME_STDERR_HINT_MAX_CHARS)}`
|
||||
: "";
|
||||
const launchHints = chromeLaunchHints({ stderrOutput, resolved, profile });
|
||||
try {
|
||||
proc.kill("SIGKILL");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to start Chrome CDP on port ${profile.cdpPort} for profile "${profile.name}". ${diagnosticText}${launchHints}${stderrHint}`,
|
||||
);
|
||||
}
|
||||
|
||||
const pid = proc.pid ?? -1;
|
||||
log.info(
|
||||
`🦞 openclaw browser started (${exe.kind}) profile "${profile.name}" on 127.0.0.1:${profile.cdpPort} (pid ${pid})`,
|
||||
);
|
||||
|
||||
return {
|
||||
pid,
|
||||
exe,
|
||||
userDataDir,
|
||||
cdpPort: profile.cdpPort,
|
||||
startedAt,
|
||||
proc,
|
||||
};
|
||||
} finally {
|
||||
// Chrome started successfully or launch failed — detach the stderr listener
|
||||
// and release the buffer.
|
||||
proc.stderr?.off("data", onStderr);
|
||||
stderrChunks.length = 0;
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to start Chrome CDP on port ${profile.cdpPort} for profile "${profile.name}". ${diagnosticText}${sandboxHint}${stderrHint}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Chrome started successfully — detach the stderr listener and release the buffer.
|
||||
proc.stderr?.off("data", onStderr);
|
||||
stderrChunks.length = 0;
|
||||
|
||||
const pid = proc.pid ?? -1;
|
||||
log.info(
|
||||
`🦞 openclaw browser started (${exe.kind}) profile "${profile.name}" on 127.0.0.1:${profile.cdpPort} (pid ${pid})`,
|
||||
);
|
||||
|
||||
return {
|
||||
pid,
|
||||
exe,
|
||||
userDataDir,
|
||||
cdpPort: profile.cdpPort,
|
||||
startedAt,
|
||||
proc,
|
||||
};
|
||||
|
||||
return await launchOnceAndWait(true);
|
||||
}
|
||||
|
||||
export async function stopOpenClawChrome(
|
||||
|
||||
@@ -6,7 +6,10 @@ import type {
|
||||
import { buildProfileQuery, withBaseUrl } from "./client-actions-url.js";
|
||||
import type { BrowserActRequest, BrowserFormField } from "./client-actions.types.js";
|
||||
import { fetchBrowserJson } from "./client-fetch.js";
|
||||
import { DEFAULT_BROWSER_SCREENSHOT_TIMEOUT_MS } from "./constants.js";
|
||||
import {
|
||||
DEFAULT_BROWSER_ACTION_TIMEOUT_MS,
|
||||
DEFAULT_BROWSER_SCREENSHOT_TIMEOUT_MS,
|
||||
} from "./constants.js";
|
||||
|
||||
export type { BrowserActRequest, BrowserFormField } from "./client-actions.types.js";
|
||||
|
||||
@@ -26,6 +29,29 @@ export type BrowserDownloadPayload = {
|
||||
|
||||
type BrowserDownloadResult = { ok: true; targetId: string; download: BrowserDownloadPayload };
|
||||
|
||||
const BROWSER_ACT_REQUEST_TIMEOUT_SLACK_MS = 5_000;
|
||||
|
||||
function normalizePositiveTimeoutMs(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0
|
||||
? Math.floor(value)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function resolveBrowserActRequestTimeoutMs(req: BrowserActRequest): number {
|
||||
const explicitTimeout = normalizePositiveTimeoutMs((req as { timeoutMs?: unknown }).timeoutMs);
|
||||
const candidateTimeouts =
|
||||
explicitTimeout === undefined
|
||||
? [DEFAULT_BROWSER_ACTION_TIMEOUT_MS]
|
||||
: [explicitTimeout + BROWSER_ACT_REQUEST_TIMEOUT_SLACK_MS];
|
||||
if (req.kind === "wait") {
|
||||
const waitDuration = normalizePositiveTimeoutMs(req.timeMs);
|
||||
if (waitDuration !== undefined) {
|
||||
candidateTimeouts.push(waitDuration + BROWSER_ACT_REQUEST_TIMEOUT_SLACK_MS);
|
||||
}
|
||||
}
|
||||
return Math.max(...candidateTimeouts);
|
||||
}
|
||||
|
||||
async function postDownloadRequest(
|
||||
baseUrl: string | undefined,
|
||||
route: "/wait/download" | "/download",
|
||||
@@ -167,7 +193,7 @@ export async function browserAct(
|
||||
timeoutMs:
|
||||
typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs)
|
||||
? Math.max(1, Math.floor(opts.timeoutMs))
|
||||
: 20000,
|
||||
: resolveBrowserActRequestTimeoutMs(req),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,16 @@ export type BrowserActRequest =
|
||||
delayMs?: number;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
| {
|
||||
kind: "clickCoords";
|
||||
x: number;
|
||||
y: number;
|
||||
targetId?: string;
|
||||
doubleClick?: boolean;
|
||||
button?: string;
|
||||
delayMs?: number;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
| {
|
||||
kind: "type";
|
||||
ref?: string;
|
||||
|
||||
@@ -334,4 +334,30 @@ describe("browser client", () => {
|
||||
timeoutMs: 20_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("gives browser act requests enough client timeout for long waits", async () => {
|
||||
const calls: Array<{ url: string; init?: RequestInit & { timeoutMs?: number } }> = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string, init?: RequestInit & { timeoutMs?: number }) => {
|
||||
calls.push({ url, init });
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ ok: true, targetId: "t1" }),
|
||||
} as unknown as Response;
|
||||
}),
|
||||
);
|
||||
|
||||
await browserAct("http://127.0.0.1:18791", { kind: "click", ref: "1" });
|
||||
await browserAct("http://127.0.0.1:18791", {
|
||||
kind: "wait",
|
||||
timeMs: 70_000,
|
||||
});
|
||||
await browserAct("http://127.0.0.1:18791", {
|
||||
kind: "wait",
|
||||
timeoutMs: 45_000,
|
||||
});
|
||||
|
||||
expect(calls.map((call) => call.init?.timeoutMs)).toEqual([60_000, 75_000, 50_000]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,6 +60,7 @@ describe("browser config", () => {
|
||||
expect(resolveProfile(resolved, "chrome-relay")).toBe(null);
|
||||
expect(resolved.remoteCdpTimeoutMs).toBe(1500);
|
||||
expect(resolved.remoteCdpHandshakeTimeoutMs).toBe(3000);
|
||||
expect(resolved.actionTimeoutMs).toBe(60_000);
|
||||
expect(resolved.tabCleanup).toEqual({
|
||||
enabled: true,
|
||||
idleMinutes: 120,
|
||||
@@ -119,9 +120,11 @@ describe("browser config", () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
remoteCdpTimeoutMs: 2200,
|
||||
remoteCdpHandshakeTimeoutMs: 5000,
|
||||
actionTimeoutMs: 45_000,
|
||||
});
|
||||
expect(resolved.remoteCdpTimeoutMs).toBe(2200);
|
||||
expect(resolved.remoteCdpHandshakeTimeoutMs).toBe(5000);
|
||||
expect(resolved.actionTimeoutMs).toBe(45_000);
|
||||
});
|
||||
|
||||
it("supports custom browser tab cleanup policy", () => {
|
||||
|
||||
@@ -20,6 +20,7 @@ import { resolveUserPath } from "../utils.js";
|
||||
import { parseBrowserHttpUrl, redactCdpUrl, isLoopbackHost } from "./cdp.helpers.js";
|
||||
import {
|
||||
DEFAULT_AI_SNAPSHOT_MAX_CHARS,
|
||||
DEFAULT_BROWSER_ACTION_TIMEOUT_MS,
|
||||
DEFAULT_BROWSER_DEFAULT_PROFILE_NAME,
|
||||
DEFAULT_BROWSER_EVALUATE_ENABLED,
|
||||
DEFAULT_BROWSER_TAB_CLEANUP_IDLE_MINUTES,
|
||||
@@ -66,6 +67,7 @@ export type ResolvedBrowserConfig = {
|
||||
cdpIsLoopback: boolean;
|
||||
remoteCdpTimeoutMs: number;
|
||||
remoteCdpHandshakeTimeoutMs: number;
|
||||
actionTimeoutMs: number;
|
||||
color: string;
|
||||
executablePath?: string;
|
||||
headless: boolean;
|
||||
@@ -263,6 +265,10 @@ export function resolveBrowserConfig(
|
||||
cfg?.remoteCdpHandshakeTimeoutMs,
|
||||
Math.max(2000, remoteCdpTimeoutMs * 2),
|
||||
);
|
||||
const actionTimeoutMs = normalizeTimeoutMs(
|
||||
cfg?.actionTimeoutMs,
|
||||
DEFAULT_BROWSER_ACTION_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
const derivedCdpRange = deriveDefaultBrowserCdpPortRange(controlPort);
|
||||
const cdpRangeSpan = derivedCdpRange.end - derivedCdpRange.start;
|
||||
@@ -343,6 +349,7 @@ export function resolveBrowserConfig(
|
||||
cdpIsLoopback: isLoopbackHost(cdpInfo.parsed.hostname),
|
||||
remoteCdpTimeoutMs,
|
||||
remoteCdpHandshakeTimeoutMs,
|
||||
actionTimeoutMs,
|
||||
color: defaultColor,
|
||||
executablePath,
|
||||
headless,
|
||||
|
||||
@@ -3,6 +3,7 @@ export const DEFAULT_BROWSER_EVALUATE_ENABLED = true;
|
||||
export const DEFAULT_OPENCLAW_BROWSER_COLOR = "#FF4500";
|
||||
export const DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME = "openclaw";
|
||||
export const DEFAULT_BROWSER_DEFAULT_PROFILE_NAME = "openclaw";
|
||||
export const DEFAULT_BROWSER_ACTION_TIMEOUT_MS = 60_000;
|
||||
export const DEFAULT_BROWSER_SCREENSHOT_TIMEOUT_MS = 20_000;
|
||||
export const DEFAULT_BROWSER_TAB_CLEANUP_IDLE_MINUTES = 120;
|
||||
export const DEFAULT_BROWSER_TAB_CLEANUP_MAX_TABS_PER_SESSION = 8;
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { chromium } from "playwright-core";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import * as chromeModule from "./chrome.js";
|
||||
import { closePlaywrightBrowserConnection, listPagesViaPlaywright } from "./pw-session.js";
|
||||
import {
|
||||
closePlaywrightBrowserConnection,
|
||||
getPageForTargetId,
|
||||
listPagesViaPlaywright,
|
||||
} from "./pw-session.js";
|
||||
|
||||
const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP");
|
||||
const getChromeWebSocketUrlSpy = vi.spyOn(chromeModule, "getChromeWebSocketUrl");
|
||||
@@ -42,6 +46,24 @@ function makeBrowser(targetId: string, url: string): BrowserMockBundle {
|
||||
return { browser, browserClose };
|
||||
}
|
||||
|
||||
function makeEmptyBrowser(): BrowserMockBundle {
|
||||
const browserClose = vi.fn(async () => {});
|
||||
const context = {
|
||||
pages: () => [],
|
||||
on: vi.fn(),
|
||||
newCDPSession: vi.fn(),
|
||||
} as unknown as import("playwright-core").BrowserContext;
|
||||
|
||||
const browser = {
|
||||
contexts: () => [context],
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
close: browserClose,
|
||||
} as unknown as import("playwright-core").Browser;
|
||||
|
||||
return { browser, browserClose };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
connectOverCdpSpy.mockReset();
|
||||
getChromeWebSocketUrlSpy.mockReset();
|
||||
@@ -116,4 +138,37 @@ describe("pw-session connection scoping", () => {
|
||||
expect(browserA.browserClose).toHaveBeenCalledTimes(1);
|
||||
expect(browserB.browserClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("evicts only the stale cdpUrl when getPageForTargetId retries a cached connection", async () => {
|
||||
const staleA = makeEmptyBrowser();
|
||||
const refreshedA = makeBrowser("A", "https://a.example/recovered");
|
||||
const browserB = makeBrowser("B", "https://b.example");
|
||||
let callsForA = 0;
|
||||
|
||||
connectOverCdpSpy.mockImplementation((async (...args: unknown[]) => {
|
||||
const endpointText = String(args[0]);
|
||||
if (endpointText === "http://127.0.0.1:9222") {
|
||||
callsForA += 1;
|
||||
return callsForA === 1 ? staleA.browser : refreshedA.browser;
|
||||
}
|
||||
if (endpointText === "http://127.0.0.1:9333") {
|
||||
return browserB.browser;
|
||||
}
|
||||
throw new Error(`unexpected endpoint: ${endpointText}`);
|
||||
}) as never);
|
||||
getChromeWebSocketUrlSpy.mockResolvedValue(null);
|
||||
|
||||
await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9222" });
|
||||
await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9333" });
|
||||
|
||||
const recoveredA = await getPageForTargetId({ cdpUrl: "http://127.0.0.1:9222" });
|
||||
const stillCachedB = await getPageForTargetId({ cdpUrl: "http://127.0.0.1:9333" });
|
||||
|
||||
expect(recoveredA.url()).toBe("https://a.example/recovered");
|
||||
expect(stillCachedB.url()).toBe("https://b.example");
|
||||
expect(staleA.browserClose).toHaveBeenCalledTimes(1);
|
||||
expect(refreshedA.browserClose).not.toHaveBeenCalled();
|
||||
expect(browserB.browserClose).not.toHaveBeenCalled();
|
||||
expect(connectOverCdpSpy).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,69 @@
|
||||
import { chromium } from "playwright-core";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import * as chromeModule from "./chrome.js";
|
||||
import { closePlaywrightBrowserConnection, getPageForTargetId } from "./pw-session.js";
|
||||
import {
|
||||
closePlaywrightBrowserConnection,
|
||||
getPageForTargetId,
|
||||
listPagesViaPlaywright,
|
||||
} from "./pw-session.js";
|
||||
|
||||
const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP");
|
||||
const getChromeWebSocketUrlSpy = vi.spyOn(chromeModule, "getChromeWebSocketUrl");
|
||||
|
||||
type MockPageSpec = {
|
||||
targetId?: string;
|
||||
url?: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
type BrowserMockBundle = {
|
||||
browser: import("playwright-core").Browser;
|
||||
browserClose: ReturnType<typeof vi.fn>;
|
||||
pages: import("playwright-core").Page[];
|
||||
};
|
||||
|
||||
function makeBrowser(pages: MockPageSpec[]): BrowserMockBundle {
|
||||
let context: import("playwright-core").BrowserContext;
|
||||
const browserClose = vi.fn(async () => {});
|
||||
const targetIdByPage = new Map<import("playwright-core").Page, string | undefined>();
|
||||
|
||||
const pageObjects = pages.map((spec, index) => {
|
||||
const page = {
|
||||
on: vi.fn(),
|
||||
context: () => context,
|
||||
title: vi.fn(async () => spec.title ?? spec.targetId ?? `page-${index + 1}`),
|
||||
url: vi.fn(() => spec.url ?? `https://page-${index + 1}.example`),
|
||||
} as unknown as import("playwright-core").Page;
|
||||
targetIdByPage.set(page, spec.targetId);
|
||||
return page;
|
||||
});
|
||||
|
||||
context = {
|
||||
pages: () => pageObjects,
|
||||
on: vi.fn(),
|
||||
newCDPSession: vi.fn(async (page: import("playwright-core").Page) => ({
|
||||
send: vi.fn(async (method: string) =>
|
||||
method === "Target.getTargetInfo"
|
||||
? { targetInfo: { targetId: targetIdByPage.get(page) } }
|
||||
: {},
|
||||
),
|
||||
detach: vi.fn(async () => {}),
|
||||
})),
|
||||
} as unknown as import("playwright-core").BrowserContext;
|
||||
|
||||
const browser = {
|
||||
contexts: () => [context],
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
close: browserClose,
|
||||
} as unknown as import("playwright-core").Browser;
|
||||
|
||||
return { browser, browserClose, pages: pageObjects };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
connectOverCdpSpy.mockClear();
|
||||
getChromeWebSocketUrlSpy.mockClear();
|
||||
connectOverCdpSpy.mockReset();
|
||||
getChromeWebSocketUrlSpy.mockReset();
|
||||
await closePlaywrightBrowserConnection().catch(() => {});
|
||||
});
|
||||
|
||||
@@ -119,4 +174,73 @@ describe("pw-session getPageForTargetId", () => {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("evicts a stale cached page-less browser once and succeeds on a fresh reconnect", async () => {
|
||||
const stale = makeBrowser([]);
|
||||
const fresh = makeBrowser([{ targetId: "TARGET_OK", url: "https://fresh.example" }]);
|
||||
|
||||
connectOverCdpSpy.mockResolvedValueOnce(stale.browser).mockResolvedValueOnce(fresh.browser);
|
||||
getChromeWebSocketUrlSpy.mockResolvedValue(null);
|
||||
|
||||
await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9222" });
|
||||
|
||||
const resolved = await getPageForTargetId({ cdpUrl: "http://127.0.0.1:9222" });
|
||||
|
||||
expect(resolved).toBe(fresh.pages[0]);
|
||||
expect(connectOverCdpSpy).toHaveBeenCalledTimes(2);
|
||||
expect(stale.browserClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("evicts a stale cached tab-selection miss once and succeeds on a fresh reconnect", async () => {
|
||||
const stale = makeBrowser([
|
||||
{ targetId: "TARGET_A", url: "https://alpha.example" },
|
||||
{ targetId: "TARGET_C", url: "https://charlie.example" },
|
||||
]);
|
||||
const fresh = makeBrowser([
|
||||
{ targetId: "TARGET_A", url: "https://alpha.example" },
|
||||
{ targetId: "TARGET_B", url: "https://beta.example" },
|
||||
]);
|
||||
|
||||
connectOverCdpSpy.mockResolvedValueOnce(stale.browser).mockResolvedValueOnce(fresh.browser);
|
||||
getChromeWebSocketUrlSpy.mockResolvedValue(null);
|
||||
|
||||
await getPageForTargetId({ cdpUrl: "http://127.0.0.1:9333" });
|
||||
|
||||
const resolved = await getPageForTargetId({
|
||||
cdpUrl: "http://127.0.0.1:9333",
|
||||
targetId: "TARGET_B",
|
||||
});
|
||||
|
||||
expect(resolved).toBe(fresh.pages[1]);
|
||||
expect(connectOverCdpSpy).toHaveBeenCalledTimes(2);
|
||||
expect(stale.browserClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fails after a single reconnect when the refreshed browser is still page-less", async () => {
|
||||
const stale = makeBrowser([]);
|
||||
const stillBroken = makeBrowser([]);
|
||||
|
||||
connectOverCdpSpy
|
||||
.mockResolvedValueOnce(stale.browser)
|
||||
.mockResolvedValueOnce(stillBroken.browser);
|
||||
getChromeWebSocketUrlSpy.mockResolvedValue(null);
|
||||
|
||||
await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9444" });
|
||||
|
||||
await expect(getPageForTargetId({ cdpUrl: "http://127.0.0.1:9444" })).rejects.toThrow(
|
||||
"No pages available in the connected browser.",
|
||||
);
|
||||
expect(connectOverCdpSpy).toHaveBeenCalledTimes(2);
|
||||
expect(stale.browserClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not add an extra top-level retry for non-recoverable connect failures", async () => {
|
||||
connectOverCdpSpy.mockRejectedValue(new Error("connectOverCDP exploded"));
|
||||
getChromeWebSocketUrlSpy.mockResolvedValue(null);
|
||||
|
||||
await expect(getPageForTargetId({ cdpUrl: "http://127.0.0.1:9555" })).rejects.toThrow(
|
||||
"connectOverCDP exploded",
|
||||
);
|
||||
expect(connectOverCdpSpy).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,6 +123,27 @@ function normalizeCdpUrl(raw: string) {
|
||||
return raw.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function hasCachedPlaywrightBrowserConnection(cdpUrl: string): boolean {
|
||||
return cachedByCdpUrl.has(normalizeCdpUrl(cdpUrl));
|
||||
}
|
||||
|
||||
function isRecoverableStalePageSelectionError(err: unknown, reusedCachedBrowser: boolean): boolean {
|
||||
if (!reusedCachedBrowser) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
err instanceof Error &&
|
||||
err.message.includes("No pages available in the connected browser.")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (err instanceof BrowserTabNotFoundError) {
|
||||
return true;
|
||||
}
|
||||
const message = err instanceof Error ? err.message : formatErrorMessage(err);
|
||||
return message.toLowerCase().includes("tab not found");
|
||||
}
|
||||
|
||||
function findNetworkRequestById(state: PageState, id: string): BrowserNetworkRequest | undefined {
|
||||
for (let i = state.requests.length - 1; i >= 0; i -= 1) {
|
||||
const candidate = state.requests[i];
|
||||
@@ -625,7 +646,7 @@ async function resolvePageByTargetIdOrThrow(opts: {
|
||||
return page;
|
||||
}
|
||||
|
||||
export async function getPageForTargetId(opts: {
|
||||
async function getPageForTargetIdOnce(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
@@ -671,6 +692,23 @@ export async function getPageForTargetId(opts: {
|
||||
throw new BrowserTabNotFoundError();
|
||||
}
|
||||
|
||||
export async function getPageForTargetId(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
}): Promise<Page> {
|
||||
const reusedCachedBrowser = hasCachedPlaywrightBrowserConnection(opts.cdpUrl);
|
||||
try {
|
||||
return await getPageForTargetIdOnce(opts);
|
||||
} catch (err) {
|
||||
if (!isRecoverableStalePageSelectionError(err, reusedCachedBrowser)) {
|
||||
throw err;
|
||||
}
|
||||
await closePlaywrightBrowserConnection({ cdpUrl: opts.cdpUrl });
|
||||
return await getPageForTargetIdOnce(opts);
|
||||
}
|
||||
}
|
||||
|
||||
function isTopLevelNavigationRequest(page: Page, request: Request): boolean {
|
||||
let sameMainFrame = false;
|
||||
try {
|
||||
|
||||
@@ -592,6 +592,35 @@ export async function clickViaPlaywright(opts: {
|
||||
}
|
||||
}
|
||||
|
||||
export async function clickCoordsViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
x: number;
|
||||
y: number;
|
||||
doubleClick?: boolean;
|
||||
button?: "left" | "right" | "middle";
|
||||
delayMs?: number;
|
||||
timeoutMs?: number;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
}): Promise<void> {
|
||||
const page = await getRestoredPageForTarget(opts);
|
||||
const previousUrl = page.url();
|
||||
await assertInteractionNavigationCompletedSafely({
|
||||
action: async () => {
|
||||
await page.mouse.click(opts.x, opts.y, {
|
||||
button: opts.button,
|
||||
clickCount: opts.doubleClick ? 2 : 1,
|
||||
delay: resolveBoundedDelayMs(opts.delayMs, "clickCoords delayMs", ACT_MAX_CLICK_DELAY_MS),
|
||||
});
|
||||
},
|
||||
cdpUrl: opts.cdpUrl,
|
||||
page,
|
||||
previousUrl,
|
||||
ssrfPolicy: opts.ssrfPolicy,
|
||||
targetId: opts.targetId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function hoverViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
@@ -1244,6 +1273,19 @@ async function executeSingleAction(
|
||||
ssrfPolicy,
|
||||
});
|
||||
break;
|
||||
case "clickCoords":
|
||||
await clickCoordsViaPlaywright({
|
||||
cdpUrl,
|
||||
targetId: effectiveTargetId,
|
||||
x: action.x,
|
||||
y: action.y,
|
||||
doubleClick: action.doubleClick,
|
||||
button: action.button as "left" | "right" | "middle" | undefined,
|
||||
delayMs: action.delayMs,
|
||||
timeoutMs: action.timeoutMs,
|
||||
ssrfPolicy,
|
||||
});
|
||||
break;
|
||||
case "type":
|
||||
await typeViaPlaywright({
|
||||
cdpUrl,
|
||||
|
||||
@@ -114,6 +114,36 @@ export function normalizeActRequest(
|
||||
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
||||
};
|
||||
}
|
||||
case "clickCoords": {
|
||||
const x = toNumber(body.x);
|
||||
const y = toNumber(body.y);
|
||||
if (x === undefined || y === undefined || x < 0 || y < 0) {
|
||||
throw new Error("clickCoords requires non-negative x and y");
|
||||
}
|
||||
const buttonRaw = toStringOrEmpty(body.button);
|
||||
const button = buttonRaw ? parseClickButton(buttonRaw) : undefined;
|
||||
if (buttonRaw && !button) {
|
||||
throw new Error("clickCoords button must be left|right|middle");
|
||||
}
|
||||
const doubleClick = toBoolean(body.doubleClick);
|
||||
const delayMs = normalizeActBoundedNonNegativeMs(
|
||||
toNumber(body.delayMs),
|
||||
"clickCoords delayMs",
|
||||
ACT_MAX_CLICK_DELAY_MS,
|
||||
);
|
||||
const timeoutMs = toNumber(body.timeoutMs);
|
||||
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||
return {
|
||||
kind,
|
||||
x,
|
||||
y,
|
||||
...(targetId ? { targetId } : {}),
|
||||
...(doubleClick !== undefined ? { doubleClick } : {}),
|
||||
...(button ? { button } : {}),
|
||||
...(delayMs !== undefined ? { delayMs } : {}),
|
||||
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
||||
};
|
||||
}
|
||||
case "type": {
|
||||
const ref = toStringOrEmpty(body.ref) || undefined;
|
||||
const selector = toStringOrEmpty(body.selector) || undefined;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const ACT_KINDS = [
|
||||
"batch",
|
||||
"click",
|
||||
"clickCoords",
|
||||
"close",
|
||||
"drag",
|
||||
"evaluate",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import {
|
||||
clickChromeMcpElement,
|
||||
clickChromeMcpCoords,
|
||||
closeChromeMcpTab,
|
||||
dragChromeMcpElement,
|
||||
evaluateChromeMcpScript,
|
||||
@@ -279,6 +280,8 @@ function getExistingSessionUnsupportedMessage(action: BrowserActRequest): string
|
||||
return EXISTING_SESSION_LIMITS.act.clickButtonOrModifiers;
|
||||
}
|
||||
return null;
|
||||
case "clickCoords":
|
||||
return null;
|
||||
case "type":
|
||||
if (action.selector) {
|
||||
return EXISTING_SESSION_LIMITS.act.typeSelector;
|
||||
@@ -425,6 +428,22 @@ export function registerBrowserAgentActRoutes(
|
||||
guard: existingSessionNavigationGuard,
|
||||
});
|
||||
return res.json({ ok: true, targetId: tab.targetId, url: tab.url });
|
||||
case "clickCoords":
|
||||
await runExistingSessionActionWithNavigationGuard({
|
||||
execute: () =>
|
||||
clickChromeMcpCoords({
|
||||
profileName,
|
||||
userDataDir: profileCtx.profile.userDataDir,
|
||||
targetId: tab.targetId,
|
||||
x: action.x,
|
||||
y: action.y,
|
||||
doubleClick: action.doubleClick ?? false,
|
||||
button: action.button as "left" | "right" | "middle" | undefined,
|
||||
delayMs: action.delayMs,
|
||||
}),
|
||||
guard: existingSessionNavigationGuard,
|
||||
});
|
||||
return res.json({ ok: true, targetId: tab.targetId, url: tab.url });
|
||||
case "type":
|
||||
await runExistingSessionActionWithNavigationGuard({
|
||||
execute: async () => {
|
||||
@@ -610,6 +629,7 @@ export function registerBrowserAgentActRoutes(
|
||||
result: result.result,
|
||||
});
|
||||
case "click":
|
||||
case "clickCoords":
|
||||
case "resize":
|
||||
return res.json({ ok: true, targetId: tab.targetId, url: tab.url });
|
||||
default:
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createBrowserRouteApp, createBrowserRouteResponse } from "./test-helper
|
||||
const routeState = existingSessionRouteState;
|
||||
|
||||
const chromeMcpMocks = vi.hoisted(() => ({
|
||||
clickChromeMcpCoords: vi.fn(async () => {}),
|
||||
clickChromeMcpElement: vi.fn(async () => {}),
|
||||
evaluateChromeMcpScript: vi.fn(
|
||||
async (_params: { profileName: string; targetId: string; fn: string }) => true,
|
||||
@@ -30,6 +31,7 @@ const navigationGuardMocks = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("../chrome-mcp.js", () => ({
|
||||
clickChromeMcpCoords: chromeMcpMocks.clickChromeMcpCoords,
|
||||
clickChromeMcpElement: chromeMcpMocks.clickChromeMcpElement,
|
||||
closeChromeMcpTab: vi.fn(async () => {}),
|
||||
dragChromeMcpElement: vi.fn(async () => {}),
|
||||
@@ -108,6 +110,7 @@ describe("existing-session browser routes", () => {
|
||||
beforeEach(() => {
|
||||
routeState.profileCtx.ensureTabAvailable.mockClear();
|
||||
routeState.profileCtx.listTabs.mockClear();
|
||||
chromeMcpMocks.clickChromeMcpCoords.mockClear();
|
||||
chromeMcpMocks.clickChromeMcpElement.mockClear();
|
||||
chromeMcpMocks.evaluateChromeMcpScript.mockReset();
|
||||
chromeMcpMocks.fillChromeMcpElement.mockClear();
|
||||
@@ -313,4 +316,31 @@ describe("existing-session browser routes", () => {
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
});
|
||||
|
||||
it("supports coordinate clicks for existing-session profiles", async () => {
|
||||
const handler = getActPostHandler();
|
||||
const response = createBrowserRouteResponse();
|
||||
|
||||
await handler?.(
|
||||
{
|
||||
params: {},
|
||||
query: {},
|
||||
body: { kind: "clickCoords", x: 25, y: "32", doubleClick: true, delayMs: 5 },
|
||||
},
|
||||
response.res,
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toMatchObject({ ok: true, targetId: "7", url: "https://example.com" });
|
||||
expect(chromeMcpMocks.clickChromeMcpCoords).toHaveBeenCalledWith({
|
||||
profileName: "chrome-live",
|
||||
userDataDir: undefined,
|
||||
targetId: "7",
|
||||
x: 25,
|
||||
y: 32,
|
||||
doubleClick: true,
|
||||
button: undefined,
|
||||
delayMs: 5,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,11 @@ vi.mock("../chrome-mcp.js", () => ({
|
||||
const { BrowserProfileUnavailableError } = await import("../errors.js");
|
||||
const { registerBrowserBasicRoutes } = await import("./basic.js");
|
||||
|
||||
function createExistingSessionProfileState(params?: { isHttpReachable?: () => Promise<boolean> }) {
|
||||
function createExistingSessionProfileState(params?: {
|
||||
isHttpReachable?: () => Promise<boolean>;
|
||||
isTransportAvailable?: () => Promise<boolean>;
|
||||
isReachable?: () => Promise<boolean>;
|
||||
}) {
|
||||
return {
|
||||
resolved: {
|
||||
enabled: true,
|
||||
@@ -31,7 +35,8 @@ function createExistingSessionProfileState(params?: { isHttpReachable?: () => Pr
|
||||
attachOnly: true,
|
||||
},
|
||||
isHttpReachable: params?.isHttpReachable ?? (async () => true),
|
||||
isReachable: async () => true,
|
||||
isTransportAvailable: params?.isTransportAvailable ?? (async () => true),
|
||||
isReachable: params?.isReachable ?? (async () => true),
|
||||
}) as never,
|
||||
};
|
||||
}
|
||||
@@ -58,7 +63,7 @@ describe("basic browser routes", () => {
|
||||
it("maps existing-session status failures to JSON browser errors", async () => {
|
||||
const response = await callBasicRouteWithState({
|
||||
state: createExistingSessionProfileState({
|
||||
isHttpReachable: async () => {
|
||||
isTransportAvailable: async () => {
|
||||
throw new BrowserProfileUnavailableError("attach failed");
|
||||
},
|
||||
}),
|
||||
@@ -86,4 +91,43 @@ describe("basic browser routes", () => {
|
||||
pid: 4321,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats attach-only profiles as running when transport is available even if page reachability is false", async () => {
|
||||
const response = await callBasicRouteWithState({
|
||||
state: createExistingSessionProfileState({
|
||||
isTransportAvailable: async () => true,
|
||||
isReachable: async () => false,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
profile: "chrome-live",
|
||||
driver: "existing-session",
|
||||
transport: "chrome-mcp",
|
||||
running: true,
|
||||
cdpReady: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("probes Chrome MCP transport only once for status", async () => {
|
||||
const isHttpReachable = vi.fn(async () => true);
|
||||
const isTransportAvailable = vi.fn(async () => true);
|
||||
|
||||
const response = await callBasicRouteWithState({
|
||||
state: createExistingSessionProfileState({
|
||||
isHttpReachable,
|
||||
isTransportAvailable,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(isTransportAvailable).toHaveBeenCalledTimes(1);
|
||||
expect(isHttpReachable).not.toHaveBeenCalled();
|
||||
expect(response.body).toMatchObject({
|
||||
cdpHttp: true,
|
||||
cdpReady: true,
|
||||
running: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,13 +61,15 @@ async function buildBrowserStatus(req: BrowserRequest, ctx: BrowserRouteContext)
|
||||
throw new BrowserError(profileCtx.error, profileCtx.status);
|
||||
}
|
||||
|
||||
const [cdpHttp, cdpReady] = await Promise.all([
|
||||
profileCtx.isHttpReachable(300),
|
||||
profileCtx.isReachable(600),
|
||||
]);
|
||||
const capabilities = getBrowserProfileCapabilities(profileCtx.profile);
|
||||
const [cdpHttp, cdpReady] = capabilities.usesChromeMcp
|
||||
? await (async () => {
|
||||
const ready = await profileCtx.isTransportAvailable(600);
|
||||
return [ready, ready] as const;
|
||||
})()
|
||||
: await Promise.all([profileCtx.isHttpReachable(300), profileCtx.isTransportAvailable(600)]);
|
||||
|
||||
const profileState = current.profiles.get(profileCtx.profile.name);
|
||||
const capabilities = getBrowserProfileCapabilities(profileCtx.profile);
|
||||
let detectedBrowser: string | null = null;
|
||||
let detectedExecutablePath: string | null = null;
|
||||
let detectError: string | null = null;
|
||||
|
||||
@@ -46,6 +46,7 @@ type AvailabilityDeps = {
|
||||
|
||||
type AvailabilityOps = {
|
||||
isHttpReachable: (timeoutMs?: number) => Promise<boolean>;
|
||||
isTransportAvailable: (timeoutMs?: number) => Promise<boolean>;
|
||||
isReachable: (timeoutMs?: number) => Promise<boolean>;
|
||||
ensureBrowserAvailable: () => Promise<void>;
|
||||
stopRunningBrowser: () => Promise<{ stopped: boolean }>;
|
||||
@@ -87,9 +88,21 @@ export function createProfileAvailability({
|
||||
);
|
||||
};
|
||||
|
||||
const isTransportAvailable = async (timeoutMs?: number) => {
|
||||
if (capabilities.usesChromeMcp) {
|
||||
const { ensureChromeMcpAvailable } = await getChromeMcpModule();
|
||||
await ensureChromeMcpAvailable(profile.name, profile.userDataDir, {
|
||||
ephemeral: true,
|
||||
timeoutMs,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return await isReachable(timeoutMs);
|
||||
};
|
||||
|
||||
const isHttpReachable = async (timeoutMs?: number) => {
|
||||
if (capabilities.usesChromeMcp) {
|
||||
return await isReachable(timeoutMs);
|
||||
return await isTransportAvailable(timeoutMs);
|
||||
}
|
||||
const { httpTimeoutMs } = resolveTimeouts(timeoutMs);
|
||||
return await isChromeReachable(profile.cdpUrl, httpTimeoutMs, getCdpReachabilityPolicy());
|
||||
@@ -200,7 +213,9 @@ export function createProfileAvailability({
|
||||
throw new BrowserProfileUnavailableError(formatChromeMcpAttachFailure(lastError));
|
||||
};
|
||||
|
||||
const ensureBrowserAvailable = async (): Promise<void> => {
|
||||
let inflightEnsureBrowserAvailable: Promise<void> | null = null;
|
||||
|
||||
const ensureBrowserAvailableOnce = async (): Promise<void> => {
|
||||
await reconcileProfileRuntime();
|
||||
if (capabilities.usesChromeMcp) {
|
||||
if (profile.userDataDir && !fs.existsSync(profile.userDataDir)) {
|
||||
@@ -305,6 +320,16 @@ export function createProfileAvailability({
|
||||
}
|
||||
};
|
||||
|
||||
const ensureBrowserAvailable = async (): Promise<void> => {
|
||||
if (inflightEnsureBrowserAvailable) {
|
||||
return inflightEnsureBrowserAvailable;
|
||||
}
|
||||
inflightEnsureBrowserAvailable = ensureBrowserAvailableOnce().finally(() => {
|
||||
inflightEnsureBrowserAvailable = null;
|
||||
});
|
||||
return inflightEnsureBrowserAvailable;
|
||||
};
|
||||
|
||||
const stopRunningBrowser = async (): Promise<{ stopped: boolean }> => {
|
||||
await reconcileProfileRuntime();
|
||||
if (capabilities.usesChromeMcp) {
|
||||
@@ -329,6 +354,7 @@ export function createProfileAvailability({
|
||||
|
||||
return {
|
||||
isHttpReachable,
|
||||
isTransportAvailable,
|
||||
isReachable,
|
||||
ensureBrowserAvailable,
|
||||
stopRunningBrowser,
|
||||
|
||||
@@ -88,6 +88,42 @@ describe("browser server-context ensureBrowserAvailable", () => {
|
||||
expect(stopOpenClawChrome).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("deduplicates concurrent lazy-start calls to prevent PortInUseError", async () => {
|
||||
const { launchOpenClawChrome, stopOpenClawChrome, isChromeCdpReady, profile } =
|
||||
setupEnsureBrowserAvailableHarness();
|
||||
isChromeCdpReady.mockResolvedValue(true);
|
||||
mockLaunchedChrome(launchOpenClawChrome, 456);
|
||||
|
||||
const first = profile.ensureBrowserAvailable();
|
||||
const second = profile.ensureBrowserAvailable();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([undefined, undefined]);
|
||||
|
||||
expect(launchOpenClawChrome).toHaveBeenCalledTimes(1);
|
||||
expect(stopOpenClawChrome).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears the concurrent lazy-start guard after launch failure", async () => {
|
||||
const { launchOpenClawChrome, stopOpenClawChrome, isChromeCdpReady, profile } =
|
||||
setupEnsureBrowserAvailableHarness();
|
||||
isChromeCdpReady.mockResolvedValue(true);
|
||||
launchOpenClawChrome.mockRejectedValueOnce(
|
||||
new Error("PortInUseError: listen EADDRINUSE 127.0.0.1:18800"),
|
||||
);
|
||||
|
||||
const first = profile.ensureBrowserAvailable();
|
||||
const second = profile.ensureBrowserAvailable();
|
||||
await expect(Promise.all([first, second])).rejects.toThrow("PortInUseError");
|
||||
|
||||
mockLaunchedChrome(launchOpenClawChrome, 789);
|
||||
const retry = profile.ensureBrowserAvailable();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
await expect(retry).resolves.toBeUndefined();
|
||||
|
||||
expect(launchOpenClawChrome).toHaveBeenCalledTimes(2);
|
||||
expect(stopOpenClawChrome).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reuses a pre-existing loopback browser after an initial short probe miss", async () => {
|
||||
const { launchOpenClawChrome, stopOpenClawChrome, isChromeCdpReady, profile, state } =
|
||||
setupEnsureBrowserAvailableHarness();
|
||||
|
||||
@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import "../../test-support/browser-security-runtime.mock.js";
|
||||
import type { BrowserServerState } from "./server-context.js";
|
||||
|
||||
vi.mock("./chrome-mcp.js", () => ({
|
||||
const chromeMcpMock = vi.hoisted(() => ({
|
||||
closeChromeMcpSession: vi.fn(async () => true),
|
||||
ensureChromeMcpAvailable: vi.fn(async () => {}),
|
||||
focusChromeMcpTab: vi.fn(async () => {}),
|
||||
@@ -13,15 +13,21 @@ vi.mock("./chrome-mcp.js", () => ({
|
||||
openChromeMcpTab: vi.fn(async () => ({
|
||||
targetId: "8",
|
||||
title: "",
|
||||
url: "https://openclaw.ai",
|
||||
url: "about:blank",
|
||||
type: "page",
|
||||
})),
|
||||
closeChromeMcpTab: vi.fn(async () => {}),
|
||||
getChromeMcpPid: vi.fn(() => 4321),
|
||||
}));
|
||||
|
||||
vi.mock("./chrome-mcp.js", () => chromeMcpMock);
|
||||
|
||||
vi.mock("./chrome-mcp.runtime.js", () => ({
|
||||
getChromeMcpModule: vi.fn(async () => chromeMcpMock),
|
||||
}));
|
||||
|
||||
const { createBrowserRouteContext } = await import("./server-context.js");
|
||||
const chromeMcp = await import("./chrome-mcp.js");
|
||||
const chromeMcp = chromeMcpMock;
|
||||
|
||||
function makeState(): BrowserServerState {
|
||||
return {
|
||||
@@ -38,6 +44,7 @@ function makeState(): BrowserServerState {
|
||||
cdpIsLoopback: true,
|
||||
remoteCdpTimeoutMs: 1500,
|
||||
remoteCdpHandshakeTimeoutMs: 3000,
|
||||
actionTimeoutMs: 60_000,
|
||||
color: "#FF4500",
|
||||
headless: false,
|
||||
noSandbox: false,
|
||||
@@ -85,6 +92,72 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("browser server-context existing-session profile", () => {
|
||||
it("reports attach-only profiles as running when the MCP session is available but no page is selected", async () => {
|
||||
fs.mkdirSync("/tmp/brave-profile", { recursive: true });
|
||||
const state = makeState();
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
|
||||
vi.mocked(chromeMcp.ensureChromeMcpAvailable).mockResolvedValueOnce();
|
||||
vi.mocked(chromeMcp.listChromeMcpTabs).mockRejectedValueOnce(new Error("No page selected"));
|
||||
|
||||
const profiles = await ctx.listProfiles();
|
||||
expect(profiles).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "chrome-live",
|
||||
transport: "chrome-mcp",
|
||||
running: true,
|
||||
tabCount: 0,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(chromeMcp.ensureChromeMcpAvailable).toHaveBeenCalledWith(
|
||||
"chrome-live",
|
||||
"/tmp/brave-profile",
|
||||
{ ephemeral: true, timeoutMs: 300 },
|
||||
);
|
||||
expect(chromeMcp.listChromeMcpTabs).toHaveBeenCalledWith("chrome-live", "/tmp/brave-profile", {
|
||||
ephemeral: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the next real attach on the normal sticky session path after an idle status probe", async () => {
|
||||
fs.mkdirSync("/tmp/brave-profile", { recursive: true });
|
||||
const state = makeState();
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
const live = ctx.forProfile("chrome-live");
|
||||
|
||||
vi.mocked(chromeMcp.listChromeMcpTabs).mockRejectedValueOnce(new Error("No page selected"));
|
||||
|
||||
await expect(ctx.listProfiles()).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
name: "chrome-live",
|
||||
running: true,
|
||||
tabCount: 0,
|
||||
}),
|
||||
]);
|
||||
|
||||
vi.mocked(chromeMcp.listChromeMcpTabs).mockClear();
|
||||
|
||||
await live.ensureBrowserAvailable();
|
||||
const tabs = await live.listTabs();
|
||||
|
||||
expect(tabs.map((tab) => tab.targetId)).toEqual(["7"]);
|
||||
expect(chromeMcp.ensureChromeMcpAvailable).toHaveBeenLastCalledWith(
|
||||
"chrome-live",
|
||||
"/tmp/brave-profile",
|
||||
);
|
||||
expect(chromeMcp.listChromeMcpTabs).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"chrome-live",
|
||||
"/tmp/brave-profile",
|
||||
);
|
||||
expect(chromeMcp.listChromeMcpTabs).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"chrome-live",
|
||||
"/tmp/brave-profile",
|
||||
);
|
||||
});
|
||||
|
||||
it("routes tab operations through the Chrome MCP backend", async () => {
|
||||
fs.mkdirSync("/tmp/brave-profile", { recursive: true });
|
||||
const state = makeState();
|
||||
@@ -100,22 +173,22 @@ describe("browser server-context existing-session profile", () => {
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ targetId: "7", title: "", url: "https://example.com", type: "page" },
|
||||
{ targetId: "8", title: "", url: "https://openclaw.ai", type: "page" },
|
||||
{ targetId: "8", title: "", url: "about:blank", type: "page" },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ targetId: "7", title: "", url: "https://example.com", type: "page" },
|
||||
{ targetId: "8", title: "", url: "https://openclaw.ai", type: "page" },
|
||||
{ targetId: "8", title: "", url: "about:blank", type: "page" },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ targetId: "7", title: "", url: "https://example.com", type: "page" },
|
||||
{ targetId: "8", title: "", url: "https://openclaw.ai", type: "page" },
|
||||
{ targetId: "8", title: "", url: "about:blank", type: "page" },
|
||||
]);
|
||||
|
||||
await live.ensureBrowserAvailable();
|
||||
const tabs = await live.listTabs();
|
||||
expect(tabs.map((tab) => tab.targetId)).toEqual(["7"]);
|
||||
|
||||
const opened = await live.openTab("https://openclaw.ai");
|
||||
const opened = await live.openTab("about:blank");
|
||||
expect(opened.targetId).toBe("8");
|
||||
|
||||
const selected = await live.ensureTabAvailable();
|
||||
@@ -131,7 +204,7 @@ describe("browser server-context existing-session profile", () => {
|
||||
expect(chromeMcp.listChromeMcpTabs).toHaveBeenCalledWith("chrome-live", "/tmp/brave-profile");
|
||||
expect(chromeMcp.openChromeMcpTab).toHaveBeenCalledWith(
|
||||
"chrome-live",
|
||||
"https://openclaw.ai",
|
||||
"about:blank",
|
||||
"/tmp/brave-profile",
|
||||
);
|
||||
expect(chromeMcp.focusChromeMcpTab).toHaveBeenCalledWith(
|
||||
|
||||
@@ -24,6 +24,7 @@ export function makeState(
|
||||
cdpIsLoopback: profile !== "remote",
|
||||
remoteCdpTimeoutMs: 1500,
|
||||
remoteCdpHandshakeTimeoutMs: 3000,
|
||||
actionTimeoutMs: 60_000,
|
||||
evaluateEnabled: false,
|
||||
extraArgs: [],
|
||||
color: "#FF4500",
|
||||
|
||||
@@ -37,6 +37,7 @@ export function makeBrowserServerState(params?: {
|
||||
evaluateEnabled: false,
|
||||
remoteCdpTimeoutMs: 1500,
|
||||
remoteCdpHandshakeTimeoutMs: 3000,
|
||||
actionTimeoutMs: 60_000,
|
||||
extraArgs: [],
|
||||
color: profile.color,
|
||||
headless: true,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
resolveCdpReachabilityPolicy,
|
||||
} from "./cdp-reachability-policy.js";
|
||||
import { usesFastLoopbackCdpProbeClass } from "./cdp-timeouts.js";
|
||||
import { listChromeMcpTabs } from "./chrome-mcp.js";
|
||||
import { isChromeReachable, resolveOpenClawUserDataDir } from "./chrome.js";
|
||||
import type { ResolvedBrowserProfile } from "./config.js";
|
||||
import { resolveProfile } from "./config.js";
|
||||
@@ -79,14 +80,19 @@ function createProfileContext(
|
||||
getProfileState,
|
||||
});
|
||||
|
||||
const { ensureBrowserAvailable, isHttpReachable, isReachable, stopRunningBrowser } =
|
||||
createProfileAvailability({
|
||||
opts,
|
||||
profile,
|
||||
state,
|
||||
getProfileState,
|
||||
setProfileRunning,
|
||||
});
|
||||
const {
|
||||
ensureBrowserAvailable,
|
||||
isHttpReachable,
|
||||
isTransportAvailable,
|
||||
isReachable,
|
||||
stopRunningBrowser,
|
||||
} = createProfileAvailability({
|
||||
opts,
|
||||
profile,
|
||||
state,
|
||||
getProfileState,
|
||||
setProfileRunning,
|
||||
});
|
||||
|
||||
const { ensureTabAvailable, focusTab, closeTab } = createProfileSelectionOps({
|
||||
profile,
|
||||
@@ -110,6 +116,7 @@ function createProfileContext(
|
||||
ensureBrowserAvailable,
|
||||
ensureTabAvailable,
|
||||
isHttpReachable,
|
||||
isTransportAvailable,
|
||||
isReachable,
|
||||
listTabs,
|
||||
openTab,
|
||||
@@ -173,9 +180,11 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
|
||||
|
||||
if (capabilities.usesChromeMcp) {
|
||||
try {
|
||||
running = await profileCtx.isReachable(300);
|
||||
running = await profileCtx.isTransportAvailable(300);
|
||||
if (running) {
|
||||
const tabs = await profileCtx.listTabs();
|
||||
const tabs = await listChromeMcpTabs(profile.name, profile.userDataDir, {
|
||||
ephemeral: true,
|
||||
}).catch(() => [] as BrowserTab[]);
|
||||
tabCount = tabs.filter((t) => t.type === "page").length;
|
||||
}
|
||||
} catch {
|
||||
@@ -251,6 +260,7 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
|
||||
ensureBrowserAvailable: () => getDefaultContext().ensureBrowserAvailable(),
|
||||
ensureTabAvailable: (targetId) => getDefaultContext().ensureTabAvailable(targetId),
|
||||
isHttpReachable: (timeoutMs) => getDefaultContext().isHttpReachable(timeoutMs),
|
||||
isTransportAvailable: (timeoutMs) => getDefaultContext().isTransportAvailable(timeoutMs),
|
||||
isReachable: (timeoutMs) => getDefaultContext().isReachable(timeoutMs),
|
||||
listTabs: () => getDefaultContext().listTabs(),
|
||||
openTab: (url, opts) => getDefaultContext().openTab(url, opts),
|
||||
|
||||
@@ -36,6 +36,7 @@ type BrowserProfileActions = {
|
||||
ensureBrowserAvailable: () => Promise<void>;
|
||||
ensureTabAvailable: (targetId?: string) => Promise<BrowserTab>;
|
||||
isHttpReachable: (timeoutMs?: number) => Promise<boolean>;
|
||||
isTransportAvailable: (timeoutMs?: number) => Promise<boolean>;
|
||||
isReachable: (timeoutMs?: number) => Promise<boolean>;
|
||||
listTabs: () => Promise<BrowserTab[]>;
|
||||
openTab: (url: string, opts?: { label?: string }) => Promise<BrowserTab>;
|
||||
|
||||
@@ -82,6 +82,23 @@ describe("browser control server", () => {
|
||||
slowTimeoutMs,
|
||||
);
|
||||
|
||||
it(
|
||||
"returns ACT_INVALID_REQUEST for malformed coordinate clicks",
|
||||
async () => {
|
||||
const base = await startServerAndBase();
|
||||
const response = await postActAndReadError(base, {
|
||||
kind: "clickCoords",
|
||||
x: -1,
|
||||
y: 20,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.code).toBe("ACT_INVALID_REQUEST");
|
||||
expect(response.body.error).toContain("clickCoords requires non-negative x and y");
|
||||
},
|
||||
slowTimeoutMs,
|
||||
);
|
||||
|
||||
it(
|
||||
"returns ACT_EXISTING_SESSION_UNSUPPORTED for unsupported existing-session actions",
|
||||
async () => {
|
||||
@@ -297,6 +314,31 @@ describe("browser control server", () => {
|
||||
const [clickSelectorArgs] = pwMocks.clickViaPlaywright.mock.calls[1] ?? [];
|
||||
expect((clickSelectorArgs as { doubleClick?: boolean }).doubleClick).toBeUndefined();
|
||||
|
||||
const clickCoords = await postJson<{ ok: boolean; url?: string }>(`${base}/act`, {
|
||||
kind: "clickCoords",
|
||||
x: "42.5",
|
||||
y: 64,
|
||||
doubleClick: "true",
|
||||
button: "left",
|
||||
delayMs: "10",
|
||||
});
|
||||
expect(clickCoords.ok).toBe(true);
|
||||
expect(clickCoords.url).toBe("https://example.com");
|
||||
expect(pwMocks.clickCoordsViaPlaywright).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cdpUrl: state.cdpBaseUrl,
|
||||
targetId: "abcd1234",
|
||||
x: 42.5,
|
||||
y: 64,
|
||||
doubleClick: true,
|
||||
button: "left",
|
||||
delayMs: 10,
|
||||
ssrfPolicy: {
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const type = await postJson<{ ok: boolean }>(`${base}/act`, {
|
||||
kind: "type",
|
||||
ref: "1",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user