Compare commits

..

4 Commits

918 changed files with 30101 additions and 35226 deletions

View File

@@ -52,11 +52,7 @@ function ghGraphQL(query, options = {}) {
function failOnGraphQLFailure(result, message) {
if (result?.gh_failed) {
const details = (
result.stderr ||
result.stdout ||
`gh exited with status ${result.status}`
).trim();
const details = (result.stderr || result.stdout || `gh exited with status ${result.status}`).trim();
fail(`${message}: ${details}`);
}
if (Array.isArray(result?.errors) && result.errors.length > 0) {
@@ -77,7 +73,9 @@ function formatGraphQLAfterClause(cursor) {
}
function findDiscussionCommentNode(nodes, discussionCommentDbId) {
return nodes.find((node) => String(node.databaseId) === String(discussionCommentDbId)) || null;
return (
nodes.find((node) => String(node.databaseId) === String(discussionCommentDbId)) || null
);
}
function fetchDiscussionReplyPage(commentNodeId, cursor) {
@@ -171,13 +169,9 @@ function fetchDiscussionComment(discussionNumber, discussionCommentDbId) {
while (!reply && hasMoreReplies) {
const replyPage = fetchDiscussionReplyPage(topLevelComment.id, replyCursor);
failOnGraphQLFailure(
replyPage,
`Failed to fetch replies for discussion comment ${topLevelComment.id}`,
);
failOnGraphQLFailure(replyPage, `Failed to fetch replies for discussion comment ${topLevelComment.id}`);
const replies = replyPage?.data?.node?.replies;
if (!replies)
fail(`Failed to paginate replies for discussion comment ${topLevelComment.id}`);
if (!replies) fail(`Failed to paginate replies for discussion comment ${topLevelComment.id}`);
reply = findDiscussionCommentNode(replies.nodes, discussionCommentDbId);
hasMoreReplies = replies.pageInfo.hasNextPage;
@@ -195,7 +189,9 @@ function fetchDiscussionComment(discussionNumber, discussionCommentDbId) {
}
function createDiscussionComment(discussionNodeId, body, replyToNodeId) {
const replyToClause = replyToNodeId ? `, replyToId: "${escapeGraphQLString(replyToNodeId)}"` : "";
const replyToClause = replyToNodeId
? `, replyToId: "${escapeGraphQLString(replyToNodeId)}"`
: "";
const result = ghGraphQL(
`mutation { addDiscussionComment(input: { discussionId: "${escapeGraphQLString(discussionNodeId)}"${replyToClause}, body: "${escapeGraphQLString(body)}" }) { comment { id url } } }`,
);
@@ -265,10 +261,7 @@ function cmdFetchContent(locationJson) {
const discussionNumber = urlMatch[1];
const discussionCommentDbId = urlMatch[2];
const { discussionId, comment } = fetchDiscussionComment(
discussionNumber,
discussionCommentDbId,
);
const { discussionId, comment } = fetchDiscussionComment(discussionNumber, discussionCommentDbId);
if (!comment)
fail(
`Discussion comment #${discussionCommentDbId} not found in discussion #${discussionNumber}`,

View File

@@ -677,10 +677,9 @@ jobs:
-c protocol.version=2 \
-c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target"
git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA" || return 1
test -f "$workdir/.github/actions/setup-node-env/action.yml" || return 1
git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA"
echo "checkout attempt ${attempt}/2 succeeded"
}

View File

@@ -1,6 +1,12 @@
name: CodeQL
on:
pull_request:
branches: [main]
paths-ignore:
- "**/*.md"
- "**/*.mdx"
- "LICENSE"
workflow_dispatch:
schedule:
- cron: "0 6 * * *"

View File

@@ -1,12 +1,12 @@
# Repository Guidelines
- Repo: https://github.com/openclaw/openclaw
- In chat replies, file references must be repo-root relative only (example: `extensions/telegram/src/index.ts:80`); never absolute paths or `~/...`.
- In chat replies, file references must be repo-root relative only (example: `src/telegram/index.ts:80`); never absolute paths or `~/...`.
- Do not edit files covered by security-focused `CODEOWNERS` rules unless a listed owner explicitly asked for the change or is already reviewing it with you. Treat those paths as restricted surfaces, not drive-by cleanup.
## Project Structure & Module Organization
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, infra in `src/infra`, media pipeline in `src/media`, web provider helpers in `src/web` and `src/plugins/web-*provider*.ts`).
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
- Tests: colocated `*.test.ts`.
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
- Nomenclature: use "plugin" / "plugins" in docs, UI, changelogs, and contributor guidance. The bundled workspace plugin tree remains the internal package layout to avoid repo-wide churn from a rename.
@@ -17,8 +17,8 @@
- Installers served from `https://openclaw.ai/*`: live in the sibling repo `../openclaw.ai` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
- Messaging channels: always consider **all** built-in + extension channels when refactoring shared logic (routing, allowlists, pairing, command gating, onboarding, docs).
- Core channel docs: `docs/channels/`
- Core channel code: `src/channels`, `src/routing`, `src/web`
- Bundled plugin channels: `extensions/<channel>/` (for example Discord, Telegram, Slack, Matrix, Zalo, ZaloUser, Voice Call)
- Core channel code: `src/telegram`, `src/discord`, `src/slack`, `src/signal`, `src/imessage`, `src/web` (WhatsApp web), `src/channels`, `src/routing`
- Bundled plugin channels: the workspace plugin tree (for example Matrix, Zalo, ZaloUser, Voice Call)
- When adding channels/plugins/apps/docs, update `.github/labeler.yml` and create matching GitHub labels (use existing channel/plugin label colors).
## Architecture Boundaries
@@ -73,7 +73,7 @@
- `hooks.internal.entries` is the canonical public hook config model. `hooks.internal.handlers` is compatibility-only input and must not be re-exposed in public schema/help/baseline surfaces.
- Bundled plugin contract boundary:
- Public docs: `docs/plugins/architecture.md`, `docs/plugins/manifest.md`, `docs/plugins/sdk-overview.md`
- Definition files: `src/plugins/contracts/registry.ts`, `src/plugins/types.ts`, `src/plugins/public-surface-loader.ts`, `src/plugins/public-surface-runtime.ts`, `src/plugins/provider-public-artifacts.ts`, `src/plugins/web-provider-public-artifacts.ts`
- Definition files: `src/plugins/contracts/registry.ts`, `src/plugins/types.ts`, `src/plugins/public-artifacts.ts`
- Rule: keep manifest metadata, runtime registration, public SDK exports, and contract tests aligned. Do not create a hidden path around the declared plugin interfaces.
- Extension test boundary:
- Keep extension-owned onboarding/config/provider coverage under the owning bundled plugin package when feasible.
@@ -87,8 +87,6 @@
- `src/plugin-sdk/AGENTS.md` expands public SDK contract rules.
- `src/plugins/AGENTS.md` expands plugin loading, registry, and manifest rules.
- `src/gateway/protocol/AGENTS.md` expands typed Gateway protocol rules.
- `src/gateway/AGENTS.md` expands Gateway server hot-path and plugin artifact rules.
- `src/agents/AGENTS.md` expands agent test/import performance rules.
- `test/helpers/AGENTS.md` and `test/helpers/channels/AGENTS.md` expand shared test helper boundary rules.
- Plugin architecture direction:
- Keep a manifest-first control plane: discovery, validation, enablement, setup hints, and activation planning should stay metadata-driven by default.
@@ -119,8 +117,8 @@
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
- Install deps: `pnpm install`
- If deps are missing (for example `node_modules` missing, `vitest not found`, or `command not found`), run the repos package-manager install command (prefer lockfile/README-defined PM), then rerun the exact requested command once. Apply this to test/build/lint/typecheck/dev commands; if retry still fails, report the command and first actionable error.
- Pre-commit hooks are installed by the package `prepare` script (`git config core.hooksPath git-hooks`). The hook formats/lints staged source files and runs `pnpm check` unless the staged change is docs-only or `FAST_COMMIT=1` is set.
- `FAST_COMMIT=1` skips the repo-wide `pnpm check` inside the pre-commit hook only. The hook still runs targeted formatting/linting for staged files and restages formatter changes. Use it when you intentionally want a faster commit path and are running equivalent targeted verification manually. It does not change CI and does not change what `pnpm check` itself does.
- Pre-commit hooks: `prek install`. The hook runs the repo verification flow, including `pnpm check`.
- `FAST_COMMIT=1` skips the repo-wide `pnpm format` and `pnpm check` inside the pre-commit hook only. Use it when you intentionally want a faster commit path and are running equivalent targeted verification manually. It does not change CI and does not change what `pnpm check` itself does.
- Also supported: `bun install` (keep `pnpm-lock.yaml` + Bun patching in sync when touching deps/patches).
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
- Run CLI in dev: `pnpm openclaw ...` (bun) or `pnpm dev`.
@@ -130,8 +128,8 @@
- TypeScript checks: `pnpm tsgo`
- Lint/format: `pnpm check`
- Local agent/dev shells default to host-aware `OPENCLAW_LOCAL_CHECK=1` behavior for `pnpm tsgo` and `pnpm lint`; set `OPENCLAW_LOCAL_CHECK_MODE=throttled` to force the lower-memory profile, `OPENCLAW_LOCAL_CHECK_MODE=full` to keep lock-only behavior, or `OPENCLAW_LOCAL_CHECK=0` in CI/shared runs.
- Format check: `pnpm format:check` (oxfmt --check)
- Format fix: `pnpm format` or `pnpm format:fix` (oxfmt --write)
- Format check: `pnpm format` (oxfmt --check)
- Format fix: `pnpm format:fix` (oxfmt --write)
- Terminology:
- "gate" means a verification command or command set that must be green for the decision you are making.
- A local dev gate is the fast default loop, usually `pnpm check` plus any scoped test you actually need.
@@ -139,8 +137,8 @@
- A CI gate is whatever the relevant workflow enforces for that lane (for example `check`, `check-additional`, `build-smoke`, or release validation).
- Local dev gate: prefer `pnpm check` for the normal edit loop. It keeps the repo-architecture policy guards out of the default local loop.
- CI architecture gate: `check-additional` enforces architecture and boundary policy guards that are intentionally kept out of the default local loop.
- Formatting gate: the pre-commit hook runs targeted formatting on staged source files before `pnpm check`. If you want a repo-wide formatting-only preflight locally, run `pnpm format:check` explicitly.
- If you need a fast commit loop, `FAST_COMMIT=1 git commit ...` skips the hooks repo-wide `pnpm check`; targeted formatting/linting still runs, so use that only when you are deliberately covering the touched surface some other way.
- Formatting gate: the pre-commit hook runs `pnpm format` before `pnpm check`. If you want a formatting-only preflight locally, run `pnpm format` explicitly.
- If you need a fast commit loop, `FAST_COMMIT=1 git commit ...` skips the hooks repo-wide `pnpm format` and `pnpm check`; use that only when you are deliberately covering the touched surface some other way.
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
- Generated baseline drift detection uses SHA-256 hash files under `docs/.generated/` (`.sha256` files tracked in git; full JSON baselines are gitignored, generated locally for inspection).
- Config schema drift uses `pnpm config:docs:gen` / `pnpm config:docs:check`.
@@ -217,8 +215,6 @@
- Test performance guardrail: when production code already accepts `deps`, callbacks, or runtime injection, use that seam in tests before adding module-level mocks.
- Test performance guardrail: prefer narrow public SDK subpaths such as `models-provider-runtime`, `skill-commands-runtime`, and `reply-dispatch-runtime` over older broad helper barrels when both expose the needed helper.
- Test performance guardrail: treat import-dominated test time as a boundary bug. Refactor the import surface before adding more cases to the slow file.
- Test performance guardrail: when replacing a slow integration test with helper-level coverage, extract the exact production composition into a named helper and test that helper. Do not trade coverage shape for speed without preserving the behavior proof somewhere cheaper.
- Test performance guardrail: for plugin-owned static descriptors used by core tests or cold paths, prefer lightweight public artifacts with full-runtime fallback over loading broad bundled plugin barrels.
- Agents MUST NOT modify baseline, inventory, ignore, snapshot, or expected-failure files to silence failing checks without explicit approval in this chat.
- For targeted/local debugging, use the native root-project entrypoint: `pnpm test <path-or-filter> [vitest args...]` (for example `pnpm test src/commands/onboard-search.test.ts -t "shows registered plugin providers"`); do not default to raw `pnpm vitest run ...` because it bypasses the repo's default config/profile/pool routing.
- Do not set test workers above 16; tried already.
@@ -254,8 +250,8 @@
## Security & Configuration Tips
- Channel/provider state lives under `~/.openclaw/credentials/`; rerun `openclaw channels login` if logged out. Model auth profiles live under `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`; legacy OAuth import still reads `~/.openclaw/credentials/oauth.json`.
- Pi sessions live under `~/.openclaw/agents/<agentId>/sessions/` by default; `session.store` can override the session store path.
- Web provider stores creds at `~/.openclaw/credentials/`; rerun `openclaw login` if logged out.
- Pi sessions live under `~/.openclaw/sessions/` by default; the base directory is not configurable.
- Environment variables: see `~/.profile`.
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
- Release flow: use the private [maintainer release docs](https://github.com/openclaw/maintainers/blob/main/release/README.md) for the actual runbook, `docs/reference/RELEASING.md` for the public release policy, and `$openclaw-release-maintainer` for the maintainership workflow.
@@ -272,13 +268,13 @@
- Signal: "update fly" => `fly ssh console -a flawd-bot -C "bash -lc 'cd /data/clawd/openclaw && git pull --rebase origin main'"` then `fly machines restart e825232f34d058 -a flawd-bot`.
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); dont hand-roll spinners/bars.
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
- Gateway may run as an app-managed launchd job. Restart the gateway via the app or `openclaw gateway restart`; inspect with `openclaw gateway status --deep` or, for the default profile, `launchctl print gui/$UID/ai.openclaw.gateway`. Use `scripts/restart-mac.sh` when you need to rebuild/relaunch the local macOS app itself. The app LaunchAgent uses `ai.openclaw.mac`. **When debugging on macOS, start/stop the gateway via the app or gateway CLI, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the OpenClaw Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep openclaw` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
- macOS logs: use `./scripts/clawlog.sh` to query unified logs for the OpenClaw subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
- If shared guardrails are available locally, review them; otherwise follow this repo's guidance.
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; dont introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
- Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync.
- Version locations: `package.json` (CLI), `apps/android/app/build.gradle.kts` (versionName/versionCode), `apps/ios/version.json` (source for generated iOS config and Fastlane metadata), `apps/macos/Sources/OpenClaw/Resources/Info.plist` (CFBundleShortVersionString/CFBundleVersion), and `docs/install/updating.md` (pinned npm version).
- "Bump version everywhere" means all version locations above, then run `pnpm ios:version:sync` for iOS generated outputs. Only touch appcast metadata when cutting a new macOS Sparkle release.
- Version locations: `package.json` (CLI), `apps/android/app/build.gradle.kts` (versionName/versionCode), `apps/ios/Sources/Info.plist` + `apps/ios/Tests/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `apps/macos/Sources/OpenClaw/Resources/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `docs/install/updating.md` (pinned npm version), and Peekaboo Xcode projects/Info.plists (MARKETING_VERSION/CURRENT_PROJECT_VERSION).
- "Bump version everywhere" means all version locations above **except** `appcast.xml` (only touch appcast when cutting a new macOS Sparkle release).
- **Restart apps:** “restart iOS/Android apps” means rebuild (recompile/install) and relaunch, not just kill/launch.
- **Device checks:** before testing, verify connected real devices (iOS/Android) before reaching for simulators/emulators.
- Mobile pairing: `ws://` (cleartext) is allowed for private LAN addresses (RFC 1918, link-local, mDNS `.local`) and loopback. Private LAN hosts typically lack PKI-backed identity, so requiring TLS there adds complexity without meaningful security gain. `wss://` is required for Tailscale and public endpoints.

View File

@@ -4,43 +4,9 @@ Docs: https://docs.openclaw.ai
## Unreleased
### Changes
- macOS/gateway: add `screen.snapshot` support for macOS app nodes, including runtime plumbing, default macOS allowlisting, and docs for monitor preview flows. (#67954) Thanks @BunsDev.
### Fixes
- Agents/bootstrap: resolve bootstrap from workspace truth instead of stale session transcript markers, keep embedded bootstrap instructions on a hidden user-context prelude, suppress normal `/new` and `/reset` greetings while `BOOTSTRAP.md` is still pending, and make the embedded runner read the bootstrap ritual before replying normally.
- Onboarding/non-interactive: preserve existing gateway auth tokens during re-onboard so active local gateway clients are not disconnected by an implicit token rotation. (#67821) Thanks @BKF-Gitty.
- Gateway/hello-ok: always report negotiated auth metadata for successful shared-auth handshakes, including control-ui bypass coverage when no device token is issued. (#67810) Thanks @BunsDev.
- OpenAI Codex/Responses: unify native Responses API capability detection so Codex OAuth requests emit the required `store: false` field on the native Responses path. (#67918) Thanks @obviyus.
- WhatsApp/setup: guard personal-phone and allowlist prompt values so setup fails with clear validation errors instead of crashing on undefined prompt text. (#67895) Thanks @lawrence3699.
- Models/config: preserve an existing `models.json` provider `baseUrl` during merge-mode regeneration so custom endpoints do not get reset on restart. (#67893) Thanks @lawrence3699.
- Plugins/discovery: reuse bundled and global plugin discovery results across workspace cache misses so Windows multi-workspace startup stops redoing the shared synchronous scan. (#67940) Thanks @obviyus.
- Plugins/webhooks: enforce synchronous plugin registration with full rollback of failed plugin side effects, and cache SecretRef-backed webhook auth per route so plugin startup and inbound webhook auth stay deterministic. (#67941) Thanks @obviyus.
- Telegram/ACP bindings: drop persisted DM bindings that still point at missing or failed ACP sessions on restart, while preserving plugin-owned bindings and uncertain store reads. (#67822) Thanks @chinar-amrutkar.
- Telegram/streaming: keep a transient preview on the same Telegram message when auto-compaction retries an in-flight answer, so streamed replies no longer appear duplicated after compaction. (#66939) Thanks @rubencu.
- Memory/sqlite-vec: emit the degraded sqlite-vec warning once per degraded episode instead of repeating it for every file write, while preserving the latch across safe-reindex rollback and resetting it when vector state is genuinely rebuilt. (#67898) Thanks @rubencu.
- Reply/block streaming: preserve post-stream incomplete-turn error payloads after block streaming already emitted content, so users get the warning instead of silence. (#67991) Thanks @obviyus.
- Telegram/streaming: clear the compaction replay guard after visible non-final boundaries so a post-tool assistant reply rotates to a fresh preview instead of editing the pre-compaction message. (#67993) Thanks @obviyus.
- Matrix: fix `sessions_spawn --thread` subagent session spawning — thread binding creation, cleanup on session end, and completion-message delivery target resolution now work end-to-end. (#67643) Thanks @eejohnso-ops and @gumadeiras.
- macOS/webchat: enable Undo and Redo in the composer text input by turning on the native `NSTextView` undo manager. (#34962) Thanks @tylerbittner.
- macOS/remote SSH: require an already-trusted host key on the macOS remote command, gateway probe, port tunnel, and pairing probe paths by switching `StrictHostKeyChecking=accept-new` to `StrictHostKeyChecking=yes` and centralizing the shared SSH option fragments in `CommandResolver`, so first-time macOS remote connections no longer silently accept an unknown host key and must be trusted ahead of time via `~/.ssh/known_hosts`. (#68199)
- CLI/configure: show the channel picker before probing statuses and let remove mode delete configured channel blocks directly from config. (#68007) Thanks @gumadeiras.
- OpenAI Codex/OAuth: keep OpenClaw as the canonical owner for imported Codex CLI OAuth sessions, stop writing refreshed credentials back into `.codex`, and prefer fresher OpenClaw credentials over stale imported CLI state so refresh recovery stays stable. Thanks @vincentkoc.
- OpenAI Codex/OAuth: treat the OpenAI TLS prerequisites probe as advisory instead of a hard blocker, so Codex sign-in can still proceed when the speculative Node/OpenSSL precheck fails but the real OAuth flow still works. Thanks @vincentkoc.
- Models status/OAuth health: align OAuth health reporting with the same effective credential view runtime uses, so expired refreshable sessions stop showing healthy by default and fresher imported Codex CLI credentials surface correctly in `models status`, doctor, and gateway auth status. Thanks @vincentkoc.
- OpenAI Codex/OAuth: keep external CLI OAuth imports runtime-only by overlaying fresher Codex CLI credentials without mutating `auth-profiles.json`, so `.codex` stays a bootstrap/runtime input instead of becoming durable OpenClaw state. Thanks @vincentkoc.
- OpenAI Codex/OAuth: drop legacy CLI-manager routing from the remaining bootstrap path so Codex and MiniMax CLI imports are matched by their canonical OpenClaw profile ids instead of stale `managedBy` metadata. Thanks @vincentkoc.
- OpenAI Codex/OAuth: only bootstrap from external CLI OAuth when the local OpenClaw profile is missing or unusable, so healthy local sessions are no longer overridden by fresher `.codex` tokens. Thanks @vincentkoc.
- OpenAI Codex/OAuth: rename the external CLI bootstrap helper, reuse the same usable-oauth check across runtime fallback paths, and add debug logs plus health coverage so bootstrap decisions stay legible. Thanks @vincentkoc.
- Twitch/setup: load Twitch through the bundled setup-entry discovery path and keep setup/status account detection aligned with runtime config. (#68008) Thanks @gumadeiras.
- Feishu/card actions: resolve card-action chat type from the Feishu chat API when stored context is missing, preferring `chat_mode` over `chat_type`, so DM-originated card actions no longer bypass `dmPolicy` by falling through to the group handling path. (#68201)
- Cron/isolated-agent: preserve `trusted: false` on isolated cron awareness events mirrored into the main session, and forward the optional `trusted` flag through the gateway cron wrapper so explicit trust downgrades survive session-key scoping. (#68210)
- Agents/fallback: recognize bare leading ZenMux `402 ...` quota-refresh errors without misclassifying plain numeric `402 ...` text, and keep the embedded fallback regression coverage stable. (#47579) Thanks @bwjoke.
- Failover/google: only treat `INTERNAL` status payloads as retryable timeouts when they also carry a `500` code, so malformed non-500 payloads do not enter the retry path. (#68238) Thanks @altaywtf and @Openbling.
- Agents/tools: filter bundled MCP/LSP tools through the final owner-only and tool-policy pipeline after merging them into the effective tool list, so existing allowlists, deny rules, sandbox policy, subagent policy, and owner-only restrictions apply to bundled tools the same way they apply to core tools. (#68195)
- Gateway/assistant media: require `operator.read` scope for assistant-media file and metadata requests on identity-bearing HTTP auth paths so callers without a read scope can no longer access assistant media. (#68175) Thanks @eleqtrizit.
## 2026.4.15
@@ -147,7 +113,6 @@ Docs: https://docs.openclaw.ai
- Webchat/security: reject remote-host `file://` URLs in the media embedding path. (#67293) Thanks @pgondhi987.
- Dreaming/memory-core: use the ingestion day, not the source file day, for daily recall dedupe so repeat sweeps of the same daily note can increment `dailyCount` across days instead of stalling at `1`. (#67091) Thanks @Bartok9.
- Node-host/tools.exec: let approval binding distinguish known native binaries from mutable shell payload files, while still fail-closing unknown or racy file probes so absolute-path node-host commands like `/usr/bin/whoami` no longer get rejected as unsafe interpreter/runtime commands. (#66731) Thanks @tmimmanuel.
- Codex/gateway: fix gateway crash when the codex-acp subprocess terminates abruptly; an unhandled EPIPE on the child stdin stream now routes through graceful client shutdown, rejecting pending requests instead of propagating as an uncaught exception that crashes the entire gateway daemon and all connected channels. Fixes #67886. (#67947) thanks @openperf
## 2026.4.14

View File

@@ -3,12 +3,6 @@ import Foundation
enum CommandResolver {
private static let projectRootDefaultsKey = "openclaw.gatewayProjectRootPath"
private static let helperName = "openclaw"
static let strictHostKeyCheckingSSHOptions = [
"-o", "StrictHostKeyChecking=yes",
]
static let updateHostKeysSSHOptions = [
"-o", "UpdateHostKeys=yes",
]
static func gatewayEntrypoint(in root: URL) -> String? {
let distEntry = root.appendingPathComponent("dist/index.js").path
@@ -403,7 +397,9 @@ enum CommandResolver {
"""
let options: [String] = [
"-o", "BatchMode=yes",
] + self.strictHostKeyCheckingSSHOptions + self.updateHostKeysSSHOptions
"-o", "StrictHostKeyChecking=accept-new",
"-o", "UpdateHostKeys=yes",
]
let args = self.sshArguments(
target: parsed,
identity: settings.identity,

View File

@@ -146,7 +146,6 @@ final class MacNodeModeCoordinator {
OpenClawCanvasA2UICommand.push.rawValue,
OpenClawCanvasA2UICommand.pushJSONL.rawValue,
OpenClawCanvasA2UICommand.reset.rawValue,
MacNodeScreenCommand.snapshot.rawValue,
MacNodeScreenCommand.record.rawValue,
OpenClawSystemCommand.notify.rawValue,
OpenClawSystemCommand.which.rawValue,

View File

@@ -63,8 +63,6 @@ actor MacNodeRuntime {
return try await self.handleCameraInvoke(req)
case OpenClawLocationCommand.get.rawValue:
return try await self.handleLocationInvoke(req)
case MacNodeScreenCommand.snapshot.rawValue:
return try await self.handleScreenSnapshotInvoke(req)
case MacNodeScreenCommand.record.rawValue:
return try await self.handleScreenRecordInvoke(req)
case OpenClawSystemCommand.run.rawValue:
@@ -354,34 +352,6 @@ actor MacNodeRuntime {
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
}
private func handleScreenSnapshotInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
let params = (try? Self.decodeParams(MacNodeScreenSnapshotParams.self, from: req.paramsJSON)) ??
MacNodeScreenSnapshotParams()
let services = await self.mainActorServices()
let capturedAtMs = Int64(Date().timeIntervalSince1970 * 1000)
let res = try await services.snapshotScreen(
screenIndex: params.screenIndex,
maxWidth: params.maxWidth,
quality: params.quality,
format: params.format)
struct ScreenSnapshotPayload: Encodable {
var format: String
var base64: String
var width: Int
var height: Int
var screenIndex: Int?
var capturedAtMs: Int64
}
let payload = try Self.encodePayload(ScreenSnapshotPayload(
format: res.format.rawValue,
base64: res.data.base64EncodedString(),
width: res.width,
height: res.height,
screenIndex: params.screenIndex,
capturedAtMs: capturedAtMs))
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
}
private func mainActorServices() async -> any MacNodeRuntimeMainActorServices {
if let cachedMainActorServices { return cachedMainActorServices }
let services = await self.makeMainActorServices()

View File

@@ -4,13 +4,6 @@ import OpenClawKit
@MainActor
protocol MacNodeRuntimeMainActorServices: Sendable {
func snapshotScreen(
screenIndex: Int?,
maxWidth: Int?,
quality: Double?,
format: OpenClawScreenSnapshotFormat?) async throws
-> (data: Data, format: OpenClawScreenSnapshotFormat, width: Int, height: Int)
func recordScreen(
screenIndex: Int?,
durationMs: Int?,
@@ -28,24 +21,9 @@ protocol MacNodeRuntimeMainActorServices: Sendable {
@MainActor
final class LiveMacNodeRuntimeMainActorServices: MacNodeRuntimeMainActorServices, @unchecked Sendable {
private let screenSnapshotter = ScreenSnapshotService()
private let screenRecorder = ScreenRecordService()
private let locationService = MacNodeLocationService()
func snapshotScreen(
screenIndex: Int?,
maxWidth: Int?,
quality: Double?,
format: OpenClawScreenSnapshotFormat?) async throws
-> (data: Data, format: OpenClawScreenSnapshotFormat, width: Int, height: Int)
{
try await self.screenSnapshotter.snapshot(
screenIndex: screenIndex,
maxWidth: maxWidth,
quality: quality,
format: format)
}
func recordScreen(
screenIndex: Int?,
durationMs: Int?,

View File

@@ -1,18 +1,9 @@
import Foundation
import OpenClawKit
enum MacNodeScreenCommand: String, Codable {
case snapshot = "screen.snapshot"
case record = "screen.record"
}
struct MacNodeScreenSnapshotParams: Codable, Equatable {
var screenIndex: Int?
var maxWidth: Int?
var quality: Double?
var format: OpenClawScreenSnapshotFormat?
}
struct MacNodeScreenRecordParams: Codable, Equatable {
var screenIndex: Int?
var durationMs: Int?

View File

@@ -483,7 +483,8 @@ final class NodePairingApprovalPrompter {
"-o", "ConnectTimeout=5",
"-o", "NumberOfPasswordPrompts=0",
"-o", "PreferredAuthentications=publickey",
] + CommandResolver.strictHostKeyCheckingSSHOptions
"-o", "StrictHostKeyChecking=accept-new",
]
guard let target = CommandResolver.makeSSHTarget(user: user, host: host, port: port) else {
return false
}

View File

@@ -200,7 +200,9 @@ enum RemoteGatewayProbe {
let options = [
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=5",
] + CommandResolver.strictHostKeyCheckingSSHOptions + CommandResolver.updateHostKeysSSHOptions
"-o", "StrictHostKeyChecking=accept-new",
"-o", "UpdateHostKeys=yes",
]
let args = CommandResolver.sshArguments(
target: parsed,
identity: identity,

View File

@@ -73,12 +73,14 @@ final class RemotePortTunnel {
let options: [String] = [
"-o", "BatchMode=yes",
"-o", "ExitOnForwardFailure=yes",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "UpdateHostKeys=yes",
"-o", "ServerAliveInterval=15",
"-o", "ServerAliveCountMax=3",
"-o", "TCPKeepAlive=yes",
"-N",
"-L", "\(localPort):127.0.0.1:\(resolvedRemotePort)",
] + CommandResolver.strictHostKeyCheckingSSHOptions + CommandResolver.updateHostKeysSSHOptions
]
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
let args = CommandResolver.sshArguments(
target: parsed,

View File

@@ -1,109 +0,0 @@
import AppKit
import Foundation
import OpenClawKit
@preconcurrency import ScreenCaptureKit
@MainActor
final class ScreenSnapshotService {
enum ScreenSnapshotError: LocalizedError {
case noDisplays
case invalidScreenIndex(Int)
case captureFailed(String)
case encodeFailed(String)
var errorDescription: String? {
switch self {
case .noDisplays:
"No displays available for screen snapshot"
case let .invalidScreenIndex(idx):
"Invalid screen index \(idx)"
case let .captureFailed(message):
message
case let .encodeFailed(message):
message
}
}
}
func snapshot(
screenIndex: Int?,
maxWidth: Int?,
quality: Double?,
format: OpenClawScreenSnapshotFormat?) async throws
-> (data: Data, format: OpenClawScreenSnapshotFormat, width: Int, height: Int)
{
let format = format ?? .jpeg
let normalized = Self.normalize(maxWidth: maxWidth, quality: quality, format: format)
let content = try await SCShareableContent.current
let displays = content.displays.sorted { $0.displayID < $1.displayID }
guard !displays.isEmpty else {
throw ScreenSnapshotError.noDisplays
}
let idx = screenIndex ?? 0
guard idx >= 0, idx < displays.count else {
throw ScreenSnapshotError.invalidScreenIndex(idx)
}
let display = displays[idx]
let filter = SCContentFilter(display: display, excludingWindows: [])
let config = SCStreamConfiguration()
let targetSize = Self.targetSize(
width: display.width,
height: display.height,
maxWidth: normalized.maxWidth)
config.width = targetSize.width
config.height = targetSize.height
config.showsCursor = true
let cgImage: CGImage
do {
cgImage = try await SCScreenshotManager.captureImage(
contentFilter: filter,
configuration: config)
} catch {
throw ScreenSnapshotError.captureFailed(error.localizedDescription)
}
let bitmap = NSBitmapImageRep(cgImage: cgImage)
let data: Data
switch format {
case .png:
guard let encoded = bitmap.representation(using: .png, properties: [:]) else {
throw ScreenSnapshotError.encodeFailed("png encode failed")
}
data = encoded
case .jpeg:
guard let encoded = bitmap.representation(
using: .jpeg,
properties: [.compressionFactor: normalized.quality])
else {
throw ScreenSnapshotError.encodeFailed("jpeg encode failed")
}
data = encoded
}
return (data: data, format: format, width: cgImage.width, height: cgImage.height)
}
private static func normalize(
maxWidth: Int?,
quality: Double?,
format: OpenClawScreenSnapshotFormat)
-> (maxWidth: Int, quality: Double)
{
let resolvedMaxWidth = maxWidth.flatMap { $0 > 0 ? $0 : nil } ?? (format == .png ? 900 : 1600)
let resolvedQuality = min(1.0, max(0.05, quality ?? 0.72))
return (maxWidth: resolvedMaxWidth, quality: resolvedQuality)
}
private static func targetSize(width: Int, height: Int, maxWidth: Int) -> (width: Int, height: Int) {
guard width > 0, height > 0, width > maxWidth else {
return (width: width, height: height)
}
let scale = Double(maxWidth) / Double(width)
let targetHeight = max(1, Int((Double(height) * scale).rounded()))
return (width: maxWidth, height: targetHeight)
}
}

View File

@@ -164,9 +164,6 @@ import Testing
} else {
#expect(Bool(false))
}
#expect(cmd.contains("StrictHostKeyChecking=yes"))
#expect(!cmd.contains("StrictHostKeyChecking=accept-new"))
#expect(cmd.contains("UpdateHostKeys=yes"))
#expect(cmd.contains("-i"))
#expect(cmd.contains("/tmp/id_ed25519"))
if let script = cmd.last {

View File

@@ -78,19 +78,6 @@ struct MacNodeRuntimeTests {
@Test func `handle invoke screen record uses injected services`() async throws {
@MainActor
final class FakeMainActorServices: MacNodeRuntimeMainActorServices, @unchecked Sendable {
func snapshotScreen(
screenIndex: Int?,
maxWidth: Int?,
quality: Double?,
format: OpenClawScreenSnapshotFormat?) async throws
-> (data: Data, format: OpenClawScreenSnapshotFormat, width: Int, height: Int)
{
_ = screenIndex
_ = maxWidth
_ = quality
return (Data("snapshot".utf8), format ?? .jpeg, 640, 360)
}
func recordScreen(
screenIndex: Int?,
durationMs: Int?,
@@ -140,94 +127,6 @@ struct MacNodeRuntimeTests {
#expect(!payload.base64.isEmpty)
}
@Test func `handle invoke screen snapshot uses injected services`() async throws {
@MainActor
final class FakeMainActorServices: MacNodeRuntimeMainActorServices, @unchecked Sendable {
var snapshotCalledAtMs: Int64?
func snapshotScreen(
screenIndex: Int?,
maxWidth: Int?,
quality: Double?,
format: OpenClawScreenSnapshotFormat?) async throws
-> (data: Data, format: OpenClawScreenSnapshotFormat, width: Int, height: Int)
{
self.snapshotCalledAtMs = Int64(Date().timeIntervalSince1970 * 1000)
#expect(screenIndex == 0)
#expect(maxWidth == 800)
#expect(quality == 0.5)
return (Data("ok".utf8), format ?? .jpeg, 800, 450)
}
func recordScreen(
screenIndex: Int?,
durationMs: Int?,
fps: Double?,
includeAudio: Bool?,
outPath: String?) async throws -> (path: String, hasAudio: Bool)
{
let url = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-test-screen-record-\(UUID().uuidString).mp4")
try Data("ok".utf8).write(to: url)
return (path: url.path, hasAudio: false)
}
func locationAuthorizationStatus() -> CLAuthorizationStatus {
.authorizedAlways
}
func locationAccuracyAuthorization() -> CLAccuracyAuthorization {
.fullAccuracy
}
func currentLocation(
desiredAccuracy: OpenClawLocationAccuracy,
maxAgeMs: Int?,
timeoutMs: Int?) async throws -> CLLocation
{
_ = desiredAccuracy
_ = maxAgeMs
_ = timeoutMs
return CLLocation(latitude: 0, longitude: 0)
}
}
let services = await MainActor.run { FakeMainActorServices() }
let runtime = MacNodeRuntime(makeMainActorServices: { services })
let params = MacNodeScreenSnapshotParams(
screenIndex: 0,
maxWidth: 800,
quality: 0.5,
format: .jpeg)
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(
BridgeInvokeRequest(
id: "req-screen-snapshot",
command: MacNodeScreenCommand.snapshot.rawValue,
paramsJSON: json))
#expect(response.ok == true)
let payloadJSON = try #require(response.payloadJSON)
struct Payload: Decodable {
var format: String
var base64: String
var width: Int
var height: Int
var capturedAtMs: Int64
}
let payload = try JSONDecoder().decode(Payload.self, from: Data(payloadJSON.utf8))
#expect(payload.format == "jpeg")
#expect(payload.base64 == Data("ok".utf8).base64EncodedString())
#expect(payload.width == 800)
#expect(payload.height == 450)
#expect(payload.capturedAtMs > 0)
let snapshotCalledAtMs = await MainActor.run { services.snapshotCalledAtMs }
#expect(snapshotCalledAtMs != nil)
#expect(payload.capturedAtMs <= snapshotCalledAtMs!)
}
@Test func `handle invoke browser proxy uses injected request`() async {
let runtime = MacNodeRuntime(browserProxyRequest: { paramsJSON in
#expect(paramsJSON?.contains("/tabs") == true)

View File

@@ -444,18 +444,34 @@ private struct ChatComposerTextView: NSViewRepresentable {
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeNSView(context: Context) -> NSScrollView {
let textView = ChatComposerTextViewFactory.makeConfiguredTextView()
guard let composerTextView = textView as? ChatComposerNSTextView else {
preconditionFailure("ChatComposerTextViewFactory must return ChatComposerNSTextView")
}
composerTextView.delegate = context.coordinator
let textView = ChatComposerNSTextView()
textView.delegate = context.coordinator
textView.drawsBackground = false
textView.isRichText = false
textView.isAutomaticQuoteSubstitutionEnabled = false
textView.isAutomaticTextReplacementEnabled = false
textView.isAutomaticDashSubstitutionEnabled = false
textView.isAutomaticSpellingCorrectionEnabled = false
textView.font = .systemFont(ofSize: 14, weight: .regular)
textView.textContainer?.lineBreakMode = .byWordWrapping
textView.textContainer?.lineFragmentPadding = 0
textView.textContainerInset = NSSize(width: 2, height: 4)
textView.focusRingType = .none
composerTextView.string = self.text
composerTextView.onSend = { [weak composerTextView] in
composerTextView?.window?.makeFirstResponder(nil)
textView.minSize = .zero
textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
textView.isHorizontallyResizable = false
textView.isVerticallyResizable = true
textView.autoresizingMask = [.width]
textView.textContainer?.containerSize = NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude)
textView.textContainer?.widthTracksTextView = true
textView.string = self.text
textView.onSend = { [weak textView] in
textView?.window?.makeFirstResponder(nil)
self.onSend()
}
composerTextView.onPasteImageAttachment = self.onPasteImageAttachment
textView.onPasteImageAttachment = self.onPasteImageAttachment
let scroll = NSScrollView()
scroll.drawsBackground = false
@@ -506,34 +522,6 @@ private struct ChatComposerTextView: NSViewRepresentable {
}
}
enum ChatComposerTextViewFactory {
// Internal for @testable import coverage of composer text view defaults.
@MainActor
static func makeConfiguredTextView() -> NSTextView {
let textView = ChatComposerNSTextView()
textView.drawsBackground = false
textView.isRichText = false
textView.isAutomaticQuoteSubstitutionEnabled = false
textView.isAutomaticTextReplacementEnabled = false
textView.isAutomaticDashSubstitutionEnabled = false
textView.isAutomaticSpellingCorrectionEnabled = false
textView.font = .systemFont(ofSize: 14, weight: .regular)
textView.textContainer?.lineBreakMode = .byWordWrapping
textView.textContainer?.lineFragmentPadding = 0
textView.textContainerInset = NSSize(width: 2, height: 4)
textView.focusRingType = .none
textView.allowsUndo = true
textView.minSize = .zero
textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
textView.isHorizontallyResizable = false
textView.isVerticallyResizable = true
textView.autoresizingMask = [.width]
textView.textContainer?.containerSize = NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude)
textView.textContainer?.widthTracksTextView = true
return textView
}
}
private final class ChatComposerNSTextView: NSTextView {
var onSend: (() -> Void)?
var onPasteImageAttachment: ((_ data: Data, _ fileName: String, _ mimeType: String) -> Void)?

View File

@@ -1,34 +1,9 @@
import Foundation
public enum OpenClawScreenCommand: String, Codable, Sendable {
case snapshot = "screen.snapshot"
case record = "screen.record"
}
public enum OpenClawScreenSnapshotFormat: String, Codable, Sendable {
case jpeg
case png
}
public struct OpenClawScreenSnapshotParams: Codable, Sendable, Equatable {
public var screenIndex: Int?
public var maxWidth: Int?
public var quality: Double?
public var format: OpenClawScreenSnapshotFormat?
public init(
screenIndex: Int? = nil,
maxWidth: Int? = nil,
quality: Double? = nil,
format: OpenClawScreenSnapshotFormat? = nil)
{
self.screenIndex = screenIndex
self.maxWidth = maxWidth
self.quality = quality
self.format = format
}
}
public struct OpenClawScreenRecordParams: Codable, Sendable, Equatable {
public var screenIndex: Int?
public var durationMs: Int?

View File

@@ -1,15 +0,0 @@
#if os(macOS)
import AppKit
import Testing
@testable import OpenClawChatUI
@Suite
@MainActor
struct ChatComposerTextViewTests {
@Test func configuredComposerTextViewEnablesUndo() {
let textView = ChatComposerTextViewFactory.makeConfiguredTextView()
#expect(textView.allowsUndo)
}
}
#endif

View File

@@ -1,2 +1,2 @@
052943a9f1eb82a49452b6715f4c08faeb650d16a36c150a3c726ff392ecad0d plugin-sdk-api-baseline.json
a5077395f009f5064331dc1c38bb2d6d2864299d3c1fbd9e40956c1700fa253c plugin-sdk-api-baseline.jsonl
9683f324fae8f455f2b64d7e152a77009941e4c7558521bca2510d8bcf573af9 plugin-sdk-api-baseline.json
097bf226e4e857e9296d0851852a2963c6263d176c4c470452d9a8efd36988e5 plugin-sdk-api-baseline.jsonl

View File

@@ -120,7 +120,7 @@ can write back through the mounted workspace.
Seed assets live in `qa/`:
- `qa/scenarios/index.md`
- `qa/scenarios/<theme>/*.md`
- `qa/scenarios/*.md`
These are intentionally in git so the QA plan is visible to both humans and the
agent.
@@ -129,7 +129,6 @@ agent.
the source of truth for one test run and should define:
- scenario metadata
- optional category, capability, lane, and risk metadata
- docs and code refs
- optional plugin requirements
- optional gateway config patch
@@ -140,10 +139,6 @@ and cross-cutting. For example, markdown scenarios can combine transport-side
helpers with browser-side helpers that drive the embedded Control UI through the
Gateway `browser.request` seam without adding a special-case runner.
Scenario files should be grouped by product capability rather than source tree
folder. Keep scenario IDs stable when files move; use `docsRefs` and `codeRefs`
for implementation traceability.
The baseline list should stay broad enough to cover:
- DM and channel chat

View File

@@ -89,21 +89,7 @@ Gateway → Client:
```
`server`, `features`, `snapshot`, and `policy` are all required by the schema
(`src/gateway/protocol/schema/frames.ts`). `canvasHostUrl` is optional. `auth`
reports the negotiated role/scopes when available, and includes `deviceToken`
when the gateway issues one.
When no device token is issued, `hello-ok.auth` can still report the negotiated
permissions:
```json
{
"auth": {
"role": "operator",
"scopes": ["operator.read", "operator.write"]
}
}
```
(`src/gateway/protocol/schema/frames.ts`). `auth` and `canvasHostUrl` are optional.
When a device token is issued, `hello-ok` also includes:

View File

@@ -213,7 +213,7 @@ The minimum adoption bar for a new channel is:
4. Mount the runner as `openclaw qa <runner>` instead of registering a competing root command.
Runner plugins should declare `qaRunners` in `openclaw.plugin.json` and export a matching `qaRunnerCliRegistrations` array from `runtime-api.ts`.
Keep `runtime-api.ts` light; lazy CLI and runner execution should stay behind separate entrypoints.
5. Author or adapt markdown scenarios under the themed `qa/scenarios/` directories.
5. Author or adapt markdown scenarios under `qa/scenarios/`.
6. Use the generic scenario helpers for new scenarios.
7. Keep existing compatibility aliases working unless the repo is doing an intentional migration.

View File

@@ -55,7 +55,7 @@ The macOS app presents itself as a node. Common commands:
- Canvas: `canvas.present`, `canvas.navigate`, `canvas.eval`, `canvas.snapshot`, `canvas.a2ui.*`
- Camera: `camera.snap`, `camera.clip`
- Screen: `screen.snapshot`, `screen.record`
- Screen: `screen.record`
- System: `system.run`, `system.notify`
The node reports a `permissions` map so agents can decide whats allowed.

View File

@@ -185,9 +185,7 @@ Keep inbound mention handling split in two layers:
- plugin-owned evidence gathering
- shared policy evaluation
Use `openclaw/plugin-sdk/channel-mention-gating` for mention-policy decisions.
Use `openclaw/plugin-sdk/channel-inbound` only when you need the broader inbound
helper barrel.
Use `openclaw/plugin-sdk/channel-inbound` for the shared layer.
Good fit for plugin-local logic:
@@ -257,11 +255,6 @@ bundled channel plugins that already depend on runtime injection:
- `implicitMentionKindWhen`
- `resolveInboundMentionDecision`
If you only need `implicitMentionKindWhen` and
`resolveInboundMentionDecision`, import from
`openclaw/plugin-sdk/channel-mention-gating` to avoid loading unrelated inbound
runtime helpers.
The older `resolveMentionGating*` helpers remain on
`openclaw/plugin-sdk/channel-inbound` as compatibility exports only. New code
should use `resolveInboundMentionDecision({ facts, policy })`.

View File

@@ -318,7 +318,7 @@ Current bundled provider examples:
| `plugin-sdk/memory-core` | Bundled memory-core helpers | Memory manager/config/file/CLI helper surface |
| `plugin-sdk/memory-core-engine-runtime` | Memory engine runtime facade | Memory index/search runtime facade |
| `plugin-sdk/memory-core-host-engine-foundation` | Memory host foundation engine | Memory host foundation engine exports |
| `plugin-sdk/memory-core-host-engine-embeddings` | Memory host embedding engine | Memory embedding contracts, registry access, local provider, and generic batch/remote helpers; concrete remote providers live in their owning plugins |
| `plugin-sdk/memory-core-host-engine-embeddings` | Memory host embedding engine | Memory host embedding engine exports |
| `plugin-sdk/memory-core-host-engine-qmd` | Memory host QMD engine | Memory host QMD engine exports |
| `plugin-sdk/memory-core-host-engine-storage` | Memory host storage engine | Memory host storage engine exports |
| `plugin-sdk/memory-core-host-multimodal` | Memory host multimodal helpers | Memory host multimodal helpers |

View File

@@ -88,7 +88,6 @@ explicitly promotes one as public.
| `plugin-sdk/channel-config-helpers` | `createHybridChannelConfigAdapter` |
| `plugin-sdk/channel-config-schema` | Channel config schema types |
| `plugin-sdk/telegram-command-config` | Telegram custom-command normalization/validation helpers with bundled-contract fallback |
| `plugin-sdk/command-gating` | Narrow command authorization gate helpers |
| `plugin-sdk/channel-policy` | `resolveChannelGroupRequireMention` |
| `plugin-sdk/channel-lifecycle` | `createAccountStatusSink` |
| `plugin-sdk/inbound-envelope` | Shared inbound route + envelope builder helpers |
@@ -96,7 +95,6 @@ explicitly promotes one as public.
| `plugin-sdk/messaging-targets` | Target parsing/matching helpers |
| `plugin-sdk/outbound-media` | Shared outbound media loading helpers |
| `plugin-sdk/outbound-runtime` | Outbound identity/send delegate helpers |
| `plugin-sdk/poll-runtime` | Narrow poll normalization helpers |
| `plugin-sdk/thread-bindings-runtime` | Thread-binding lifecycle and adapter helpers |
| `plugin-sdk/agent-media-payload` | Legacy agent media payload builder |
| `plugin-sdk/conversation-runtime` | Conversation/thread binding, pairing, and configured-binding helpers |
@@ -110,10 +108,7 @@ explicitly promotes one as public.
| `plugin-sdk/group-access` | Shared group-access decision helpers |
| `plugin-sdk/direct-dm` | Shared direct-DM auth/guard helpers |
| `plugin-sdk/interactive-runtime` | Interactive reply payload normalization/reduction helpers |
| `plugin-sdk/channel-inbound` | Compatibility barrel for inbound debounce, mention matching, mention-policy helpers, and envelope helpers |
| `plugin-sdk/channel-mention-gating` | Narrow mention-policy helpers without the broader inbound runtime surface |
| `plugin-sdk/channel-location` | Channel location context and formatting helpers |
| `plugin-sdk/channel-logging` | Channel logging helpers for inbound drops and typing/ack failures |
| `plugin-sdk/channel-inbound` | Inbound debounce, mention matching, mention-policy helpers, and envelope helpers |
| `plugin-sdk/channel-send-result` | Reply result types |
| `plugin-sdk/channel-actions` | `createMessageToolButtonsSchema`, `createMessageToolCardSchema` |
| `plugin-sdk/channel-targets` | Target parsing/matching helpers |
@@ -171,7 +166,6 @@ explicitly promotes one as public.
| `plugin-sdk/secret-ref-runtime` | Narrow `coerceSecretRef` and SecretRef typing helpers for secret-contract/config parsing |
| `plugin-sdk/security-runtime` | Shared trust, DM gating, external-content, and secret-collection helpers |
| `plugin-sdk/ssrf-policy` | Host allowlist and private-network SSRF policy helpers |
| `plugin-sdk/ssrf-dispatcher` | Narrow pinned-dispatcher helpers without the broad infra runtime surface |
| `plugin-sdk/ssrf-runtime` | Pinned-dispatcher, SSRF-guarded fetch, and SSRF policy helpers |
| `plugin-sdk/secret-input` | Secret input parsing helpers |
| `plugin-sdk/webhook-ingress` | Webhook request/target helpers |
@@ -193,7 +187,6 @@ explicitly promotes one as public.
| `plugin-sdk/gateway-runtime` | Gateway client and channel-status patch helpers |
| `plugin-sdk/config-runtime` | Config load/write helpers |
| `plugin-sdk/telegram-command-config` | Telegram command-name/description normalization and duplicate/conflict checks, even when the bundled Telegram contract surface is unavailable |
| `plugin-sdk/text-autolink-runtime` | File-reference autolink detection without the broad text-runtime barrel |
| `plugin-sdk/approval-runtime` | Exec/plugin approval helpers, approval-capability builders, auth/profile helpers, native routing/runtime helpers |
| `plugin-sdk/reply-runtime` | Shared inbound/reply runtime helpers, chunking, dispatch, heartbeat, reply planner |
| `plugin-sdk/reply-dispatch-runtime` | Narrow reply dispatch/finalize helpers |
@@ -218,7 +211,6 @@ explicitly promotes one as public.
| `plugin-sdk/file-lock` | Re-entrant file-lock helpers |
| `plugin-sdk/persistent-dedupe` | Disk-backed dedupe cache helpers |
| `plugin-sdk/acp-runtime` | ACP runtime/session and reply-dispatch helpers |
| `plugin-sdk/acp-binding-resolve-runtime` | Read-only ACP binding resolution without lifecycle startup imports |
| `plugin-sdk/agent-config-primitives` | Narrow agent runtime config-schema primitives |
| `plugin-sdk/boolean-param` | Loose boolean param reader |
| `plugin-sdk/dangerous-name-runtime` | Dangerous-name matching resolution helpers |
@@ -234,12 +226,6 @@ explicitly promotes one as public.
| `plugin-sdk/diagnostic-runtime` | Diagnostic flag and event helpers |
| `plugin-sdk/error-runtime` | Error graph, formatting, shared error classification helpers, `isApprovalNotFoundError` |
| `plugin-sdk/fetch-runtime` | Wrapped fetch, proxy, and pinned lookup helpers |
| `plugin-sdk/runtime-fetch` | Dispatcher-aware runtime fetch without proxy/guarded-fetch imports |
| `plugin-sdk/response-limit-runtime` | Bounded response-body reader without the broad media runtime surface |
| `plugin-sdk/session-binding-runtime` | Current conversation binding state without configured binding routing or pairing stores |
| `plugin-sdk/session-store-runtime` | Session-store read helpers without broad config writes/maintenance imports |
| `plugin-sdk/context-visibility-runtime` | Context visibility resolution and supplemental context filtering without broad config/security imports |
| `plugin-sdk/string-coerce-runtime` | Narrow primitive record/string coercion and normalization helpers without markdown/logging imports |
| `plugin-sdk/host-runtime` | Hostname and SCP host normalization helpers |
| `plugin-sdk/retry-runtime` | Retry config and retry runner helpers |
| `plugin-sdk/agent-runtime` | Agent dir/identity/workspace helpers |
@@ -278,7 +264,7 @@ explicitly promotes one as public.
| `plugin-sdk/memory-core` | Bundled memory-core helper surface for manager/config/file/CLI helpers |
| `plugin-sdk/memory-core-engine-runtime` | Memory index/search runtime facade |
| `plugin-sdk/memory-core-host-engine-foundation` | Memory host foundation engine exports |
| `plugin-sdk/memory-core-host-engine-embeddings` | Memory host embedding contracts, registry access, local provider, and generic batch/remote helpers |
| `plugin-sdk/memory-core-host-engine-embeddings` | Memory host embedding engine exports |
| `plugin-sdk/memory-core-host-engine-qmd` | Memory host QMD engine exports |
| `plugin-sdk/memory-core-host-engine-storage` | Memory host storage engine exports |
| `plugin-sdk/memory-core-host-multimodal` | Memory host multimodal helpers |

View File

@@ -18,7 +18,7 @@ The desired end state is a generic QA harness that loads powerful scenario defin
## Current State
Primary source of truth now lives in `qa/scenarios/index.md` plus one file per
scenario under `qa/scenarios/<theme>/*.md`.
scenario under `qa/scenarios/*.md`.
Implemented:
@@ -26,7 +26,7 @@ Implemented:
- canonical QA pack metadata
- operator identity
- kickoff mission
- `qa/scenarios/<theme>/*.md`
- `qa/scenarios/*.md`
- one markdown file per scenario
- scenario metadata
- handler bindings
@@ -107,8 +107,8 @@ These categories matter because they drive DSL requirements. A flat list of prom
### Single source of truth
Use `qa/scenarios/index.md` plus `qa/scenarios/<theme>/*.md` as the authored
source of truth.
Use `qa/scenarios/index.md` plus `qa/scenarios/*.md` as the authored source of
truth.
The pack should stay:
@@ -363,7 +363,7 @@ Generated compatibility:
Done.
- added `qa/scenarios/index.md`
- split scenarios into `qa/scenarios/<theme>/*.md`
- split scenarios into `qa/scenarios/*.md`
- added parser for named markdown YAML pack content
- validated with zod
- switched consumers to the parsed pack

View File

@@ -82,10 +82,7 @@ html.dark .nav-tabs-underline {
border-radius: 8px;
background: color-mix(in oklab, rgb(var(--primary)) 4%, transparent);
text-decoration: none;
transition:
transform 0.16s ease,
border-color 0.16s ease,
background 0.16s ease;
transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease;
}
.showcase-actions a:first-child {

View File

@@ -60,10 +60,6 @@ third-party plugins see.
- Do not rely on eager global registry seeding or import-time side effects to
make a plugin “available”. Plugin availability should come from manifest
ownership plus targeted activation.
- When core needs plugin-owned static data on a hot path, expose a lightweight
top-level artifact such as `gateway-auth-api.ts`, `message-tool-api.ts`, or a
similarly narrow `*-api.ts`. Reuse the same local helper from the artifact and
the full plugin so fast paths do not drift from runtime behavior.
## Expanding The Boundary

View File

@@ -119,7 +119,7 @@ describe("active-memory plugin", () => {
runEmbeddedPiAgent.mockResolvedValue({
payloads: [{ text: "- lemon pepper wings\n- blue cheese" }],
});
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
});
afterEach(async () => {
@@ -425,7 +425,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
allowedChatTypes: ["direct", "group"],
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
const result = await hooks.before_prompt_build(
{ prompt: "what wings should we order?", messages: [] },
@@ -513,7 +513,7 @@ describe("active-memory plugin", () => {
searchMode: "inherit",
},
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
@@ -602,7 +602,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
queryMode: "message",
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
@@ -630,7 +630,7 @@ describe("active-memory plugin", () => {
queryMode: "message",
promptStyle: "preference-only",
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
@@ -675,7 +675,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
thinking: "medium",
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
@@ -701,7 +701,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
promptAppend: "Prefer stable long-term preferences over one-off events.",
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
@@ -730,7 +730,7 @@ describe("active-memory plugin", () => {
promptOverride: "Custom memory prompt. Return NONE or one user fact.",
promptAppend: "Extra custom instruction.",
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
@@ -802,7 +802,7 @@ describe("active-memory plugin", () => {
api.pluginConfig = {
agents: ["main"],
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? temp transcript", messages: [] },
@@ -828,7 +828,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
modelFallbackPolicy: "resolved-only",
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order? no fallback", messages: [] },
@@ -851,7 +851,7 @@ describe("active-memory plugin", () => {
modelFallback: "google/gemini-3-flash",
modelFallbackPolicy: "default-remote",
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? custom fallback", messages: [] },
@@ -878,7 +878,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
modelFallbackPolicy: "default-remote",
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order? built-in fallback", messages: [] },
@@ -1027,7 +1027,7 @@ describe("active-memory plugin", () => {
timeoutMs: 250,
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
let lastAbortSignal: AbortSignal | undefined;
runEmbeddedPiAgent.mockImplementation(async (params: { abortSignal?: AbortSignal }) => {
lastAbortSignal = params.abortSignal;
@@ -1073,7 +1073,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? session id cache", messages: [] },
@@ -1107,7 +1107,7 @@ describe("active-memory plugin", () => {
timeoutMs: 250,
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
runEmbeddedPiAgent.mockImplementationOnce(async (params: { timeoutMs?: number }) => {
await new Promise((resolve) => setTimeout(resolve, (params.timeoutMs ?? 0) + 25));
return {
@@ -1145,7 +1145,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? log sanitization", messages: [] },
@@ -1179,7 +1179,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
const hugeSession = `agent:main:${"x".repeat(500)}`;
await hooks.before_prompt_build(
@@ -1423,7 +1423,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
queryMode: "message",
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
@@ -1451,7 +1451,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
queryMode: "full",
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
@@ -1482,7 +1482,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
queryMode: "recent",
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
@@ -1536,7 +1536,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
queryMode: "recent",
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
@@ -1578,7 +1578,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
queryMode: "recent",
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
@@ -1611,7 +1611,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
queryMode: "recent",
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
@@ -1619,7 +1619,8 @@ describe("active-memory plugin", () => {
messages: [
{
role: "user",
content: "Active Memory: I really do want you to remember that I prefer aisle seats.",
content:
"Active Memory: I really do want you to remember that I prefer aisle seats.",
},
{
role: "user",
@@ -1673,7 +1674,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
maxSummaryChars: 40,
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
runEmbeddedPiAgent.mockResolvedValueOnce({
payloads: [
{
@@ -1707,7 +1708,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
maxSummaryChars: 90,
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? prompt-count-check", messages: [] },
@@ -1757,7 +1758,7 @@ describe("active-memory plugin", () => {
transcriptDir: "active-memory-subagents",
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
const mkdirSpy = vi.spyOn(fs, "mkdir").mockResolvedValue(undefined);
const mkdtempSpy = vi.spyOn(fs, "mkdtemp");
const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
@@ -1801,7 +1802,7 @@ describe("active-memory plugin", () => {
transcriptDir: "C:/temp/escape",
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
const mkdirSpy = vi.spyOn(fs, "mkdir").mockResolvedValue(undefined);
await hooks.before_prompt_build(
@@ -1838,7 +1839,7 @@ describe("active-memory plugin", () => {
transcriptDir: "active-memory-subagents",
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
const mkdirSpy = vi.spyOn(fs, "mkdir").mockResolvedValue(undefined);
await hooks.before_prompt_build(
@@ -1905,7 +1906,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
for (let index = 0; index <= 1000; index += 1) {
await hooks.before_prompt_build(

View File

@@ -1527,9 +1527,7 @@ function extractRecentTurns(messages: unknown[]): ActiveRecallRecentTurn[] {
}
const rawText = extractTextContent(typed.content);
const text =
role === "assistant"
? stripRecalledContextNoise(rawText)
: stripInjectedActiveMemoryPrefixOnly(rawText);
role === "assistant" ? stripRecalledContextNoise(rawText) : stripInjectedActiveMemoryPrefixOnly(rawText);
if (!text) {
continue;
}

View File

@@ -1,37 +0,0 @@
import {
isMissingEmbeddingApiKeyError,
type MemoryEmbeddingProviderAdapter,
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import {
createBedrockEmbeddingProvider,
DEFAULT_BEDROCK_EMBEDDING_MODEL,
} from "./embedding-provider.js";
export const bedrockMemoryEmbeddingProviderAdapter: MemoryEmbeddingProviderAdapter = {
id: "bedrock",
defaultModel: DEFAULT_BEDROCK_EMBEDDING_MODEL,
transport: "remote",
authProviderId: "amazon-bedrock",
autoSelectPriority: 60,
allowExplicitWhenConfiguredAuto: true,
shouldContinueAutoSelection: isMissingEmbeddingApiKeyError,
create: async (options) => {
const { provider, client } = await createBedrockEmbeddingProvider({
...options,
provider: "bedrock",
fallback: "none",
});
return {
provider,
runtime: {
id: "bedrock",
cacheKeyData: {
provider: "bedrock",
region: client.region,
model: client.model,
dimensions: client.dimensions,
},
},
};
},
};

View File

@@ -2,9 +2,6 @@
"id": "amazon-bedrock",
"enabledByDefault": true,
"providers": ["amazon-bedrock"],
"contracts": {
"memoryEmbeddingProviders": ["bedrock"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@@ -5,9 +5,7 @@
"description": "OpenClaw Amazon Bedrock provider plugin",
"type": "module",
"dependencies": {
"@aws-sdk/client-bedrock": "3.1028.0",
"@aws-sdk/client-bedrock-runtime": "3.1028.0",
"@aws-sdk/credential-provider-node": "3.972.30"
"@aws-sdk/client-bedrock": "3.1028.0"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"

View File

@@ -14,7 +14,6 @@ import {
resolveBedrockConfigApiKey,
resolveImplicitBedrockProvider,
} from "./api.js";
import { bedrockMemoryEmbeddingProviderAdapter } from "./memory-embedding-adapter.js";
type GuardrailConfig = {
guardrailIdentifier: string;
@@ -79,8 +78,6 @@ export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void {
const pluginConfig = (api.pluginConfig ?? {}) as AmazonBedrockPluginConfig;
const guardrail = pluginConfig.guardrail;
api.registerMemoryEmbeddingProvider(bedrockMemoryEmbeddingProviderAdapter);
const baseWrapStreamFn = ({ modelId, streamFn }: { modelId: string; streamFn?: StreamFn }) =>
isAnthropicBedrockModel(modelId) ? streamFn : createBedrockNoCacheWrapper(streamFn);

View File

@@ -1,5 +1,4 @@
export { CLAUDE_CLI_BACKEND_ID, isClaudeCliProvider } from "./cli-shared.js";
export { buildAnthropicProvider } from "./register.runtime.js";
export {
createAnthropicBetaHeadersWrapper,
createAnthropicFastModeWrapper,

View File

@@ -18,10 +18,7 @@ import {
upsertAuthProfile,
validateAnthropicSetupToken,
} from "openclaw/plugin-sdk/provider-auth";
import {
cloneFirstTemplateModel,
type ProviderPlugin,
} from "openclaw/plugin-sdk/provider-model-shared";
import { cloneFirstTemplateModel } from "openclaw/plugin-sdk/provider-model-shared";
import { fetchClaudeUsage } from "openclaw/plugin-sdk/provider-usage";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import * as claudeCliAuth from "./cli-auth-seam.js";
@@ -398,10 +395,11 @@ async function runAnthropicCliMigrationNonInteractive(ctx: {
};
}
export function buildAnthropicProvider(): ProviderPlugin {
export function registerAnthropicPlugin(api: OpenClawPluginApi): void {
const providerId = "anthropic";
const defaultAnthropicModel = DEFAULT_ANTHROPIC_MODEL;
return {
api.registerCliBackend(buildAnthropicCliBackend());
api.registerProvider({
id: providerId,
label: "Anthropic",
docsPath: "/providers/models",
@@ -507,11 +505,6 @@ export function buildAnthropicProvider(): ProviderPlugin {
store: ctx.store,
profileId: ctx.profileId,
}),
};
}
export function registerAnthropicPlugin(api: OpenClawPluginApi): void {
api.registerCliBackend(buildAnthropicCliBackend());
api.registerProvider(buildAnthropicProvider());
});
api.registerMediaUnderstandingProvider(anthropicMediaUnderstandingProvider);
}

View File

@@ -2,7 +2,3 @@ export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";
export {
__testing as blueBubblesConversationBindingTesting,
createBlueBubblesConversationBindingManager,
} from "./src/conversation-bindings.js";

View File

@@ -1,46 +0,0 @@
/** Resolve the correct targetId after a navigation that may trigger a renderer swap. */
export async function resolveTargetIdAfterNavigate(opts: {
oldTargetId: string;
navigatedUrl: string;
listTabs: () => Promise<Array<{ targetId: string; url: string }>>;
retryDelayMs?: number;
}): Promise<string> {
let currentTargetId = opts.oldTargetId;
try {
const pickReplacement = (
tabs: Array<{ targetId: string; url: string }>,
options?: { allowSingleTabFallback?: boolean },
): { targetId: string; shouldRetry: boolean } => {
if (tabs.some((tab) => tab.targetId === opts.oldTargetId)) {
return { targetId: opts.oldTargetId, shouldRetry: false };
}
const byUrl = tabs.filter((tab) => tab.url === opts.navigatedUrl);
if (byUrl.length === 1) {
return { targetId: byUrl[0]?.targetId ?? opts.oldTargetId, shouldRetry: false };
}
const uniqueReplacement = byUrl.filter((tab) => tab.targetId !== opts.oldTargetId);
if (uniqueReplacement.length === 1) {
return {
targetId: uniqueReplacement[0]?.targetId ?? opts.oldTargetId,
shouldRetry: false,
};
}
if (options?.allowSingleTabFallback && tabs.length === 1) {
return { targetId: tabs[0]?.targetId ?? opts.oldTargetId, shouldRetry: false };
}
return { targetId: opts.oldTargetId, shouldRetry: true };
};
const first = pickReplacement(await opts.listTabs());
currentTargetId = first.targetId;
if (first.shouldRetry) {
await new Promise((r) => setTimeout(r, opts.retryDelayMs ?? 800));
currentTargetId = pickReplacement(await opts.listTabs(), {
allowSingleTabFallback: true,
}).targetId;
}
} catch {
// Best-effort: fall back to pre-navigation targetId.
}
return currentTargetId;
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { resolveTargetIdAfterNavigate } from "./agent.snapshot-target.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolveTargetIdAfterNavigate } from "./agent.snapshot.js";
type Tab = { targetId: string; url: string };
@@ -8,6 +8,10 @@ function staticListTabs(tabs: Tab[]): () => Promise<Tab[]> {
}
describe("resolveTargetIdAfterNavigate", () => {
beforeEach(() => {
vi.useRealTimers();
});
it("returns original targetId when old target still exists (no swap)", async () => {
const result = await resolveTargetIdAfterNavigate({
oldTargetId: "old-123",
@@ -33,7 +37,6 @@ describe("resolveTargetIdAfterNavigate", () => {
const result = await resolveTargetIdAfterNavigate({
oldTargetId: "old-123",
navigatedUrl: "https://example.com",
retryDelayMs: 0,
listTabs: staticListTabs([
{ targetId: "preexisting-000", url: "https://example.com" },
{ targetId: "fresh-777", url: "https://example.com" },
@@ -44,12 +47,12 @@ describe("resolveTargetIdAfterNavigate", () => {
});
it("retries and resolves targetId when first listTabs has no URL match", async () => {
vi.useFakeTimers();
let calls = 0;
const result = await resolveTargetIdAfterNavigate({
const result$ = resolveTargetIdAfterNavigate({
oldTargetId: "old-123",
navigatedUrl: "https://delayed.com",
retryDelayMs: 0,
listTabs: async () => {
calls++;
if (calls === 1) {
@@ -59,33 +62,50 @@ describe("resolveTargetIdAfterNavigate", () => {
},
});
await vi.advanceTimersByTimeAsync(800);
const result = await result$;
expect(result).toBe("delayed-999");
expect(calls).toBe(2);
vi.useRealTimers();
});
it("falls back to original targetId when no match found after retry", async () => {
const result = await resolveTargetIdAfterNavigate({
vi.useFakeTimers();
const result$ = resolveTargetIdAfterNavigate({
oldTargetId: "old-123",
navigatedUrl: "https://no-match.com",
retryDelayMs: 0,
listTabs: staticListTabs([
{ targetId: "unrelated-1", url: "https://unrelated.com" },
{ targetId: "unrelated-2", url: "https://unrelated2.com" },
]),
});
await vi.advanceTimersByTimeAsync(800);
const result = await result$;
expect(result).toBe("old-123");
vi.useRealTimers();
});
it("falls back to single remaining tab when no URL match after retry", async () => {
const result = await resolveTargetIdAfterNavigate({
vi.useFakeTimers();
const result$ = resolveTargetIdAfterNavigate({
oldTargetId: "old-123",
navigatedUrl: "https://single-tab.com",
retryDelayMs: 0,
listTabs: staticListTabs([{ targetId: "only-tab", url: "https://some-other.com" }]),
});
await vi.advanceTimersByTimeAsync(800);
const result = await result$;
expect(result).toBe("only-tab");
vi.useRealTimers();
});
it("falls back to original targetId when listTabs throws", async () => {
@@ -100,16 +120,22 @@ describe("resolveTargetIdAfterNavigate", () => {
});
it("keeps the old target when multiple replacement candidates still match after retry", async () => {
const result = await resolveTargetIdAfterNavigate({
vi.useFakeTimers();
const result$ = resolveTargetIdAfterNavigate({
oldTargetId: "old-123",
navigatedUrl: "https://example.com",
retryDelayMs: 0,
listTabs: staticListTabs([
{ targetId: "preexisting-000", url: "https://example.com" },
{ targetId: "fresh-777", url: "https://example.com" },
]),
});
await vi.advanceTimersByTimeAsync(800);
const result = await result$;
expect(result).toBe("old-123");
vi.useRealTimers();
});
});

View File

@@ -32,7 +32,6 @@ import {
withPlaywrightRouteContext,
withRouteTabContext,
} from "./agent.shared.js";
import { resolveTargetIdAfterNavigate } from "./agent.snapshot-target.js";
import {
resolveSnapshotPlan,
shouldUsePlaywrightForAriaSnapshot,
@@ -173,6 +172,48 @@ async function saveBrowserMediaResponse(params: {
});
}
/** Resolve the correct targetId after a navigation that may trigger a renderer swap. */
export async function resolveTargetIdAfterNavigate(opts: {
oldTargetId: string;
navigatedUrl: string;
listTabs: () => Promise<Array<{ targetId: string; url: string }>>;
}): Promise<string> {
let currentTargetId = opts.oldTargetId;
try {
const pickReplacement = (
tabs: Array<{ targetId: string; url: string }>,
options?: { allowSingleTabFallback?: boolean },
) => {
if (tabs.some((tab) => tab.targetId === opts.oldTargetId)) {
return opts.oldTargetId;
}
const byUrl = tabs.filter((tab) => tab.url === opts.navigatedUrl);
if (byUrl.length === 1) {
return byUrl[0]?.targetId ?? opts.oldTargetId;
}
const uniqueReplacement = byUrl.filter((tab) => tab.targetId !== opts.oldTargetId);
if (uniqueReplacement.length === 1) {
return uniqueReplacement[0]?.targetId ?? opts.oldTargetId;
}
if (options?.allowSingleTabFallback && tabs.length === 1) {
return tabs[0]?.targetId ?? opts.oldTargetId;
}
return opts.oldTargetId;
};
currentTargetId = pickReplacement(await opts.listTabs());
if (currentTargetId === opts.oldTargetId) {
await new Promise((r) => setTimeout(r, 800));
currentTargetId = pickReplacement(await opts.listTabs(), {
allowSingleTabFallback: true,
});
}
} catch {
// Best-effort: fall back to pre-navigation targetId
}
return currentTargetId;
}
export function registerBrowserAgentSnapshotRoutes(
app: BrowserRouteRegistrar,
ctx: BrowserRouteContext,

View File

@@ -0,0 +1,176 @@
import { describe, expect, it } from "vitest";
import {
installAgentContractHooks,
startServerAndBase,
} from "./server.agent-contract.test-harness.js";
import {
setBrowserControlServerEvaluateEnabled,
setBrowserControlServerProfiles,
} from "./server.control-server.test-harness.js";
import { getBrowserTestFetch } from "./test-fetch.js";
type ActErrorResponse = {
error?: string;
code?: string;
};
type ActErrorHttpResponse = {
status: number;
body: ActErrorResponse;
};
async function postActAndReadError(base: string, body?: unknown): Promise<ActErrorHttpResponse> {
const realFetch = getBrowserTestFetch();
const response = await realFetch(`${base}/act`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
return {
status: response.status,
body: (await response.json()) as ActErrorResponse,
};
}
describe("browser control server", () => {
installAgentContractHooks();
const slowTimeoutMs = process.platform === "win32" ? 40_000 : 20_000;
it(
"returns ACT_KIND_REQUIRED when kind is missing",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {});
expect(response.status).toBe(400);
expect(response.body.code).toBe("ACT_KIND_REQUIRED");
expect(response.body.error).toContain("kind is required");
},
slowTimeoutMs,
);
it(
"returns ACT_INVALID_REQUEST for malformed action payloads",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "click",
ref: {},
});
expect(response.status).toBe(400);
expect(response.body.code).toBe("ACT_INVALID_REQUEST");
expect(response.body.error).toContain("click requires ref or selector");
},
slowTimeoutMs,
);
it(
"returns ACT_EXISTING_SESSION_UNSUPPORTED for unsupported existing-session actions",
async () => {
setBrowserControlServerProfiles({
openclaw: {
color: "#FF4500",
driver: "existing-session",
},
});
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "batch",
actions: [{ kind: "press", key: "Enter" }],
});
expect(response.status).toBe(501);
expect(response.body.code).toBe("ACT_EXISTING_SESSION_UNSUPPORTED");
expect(response.body.error).toContain("batch");
},
slowTimeoutMs,
);
it(
"returns ACT_TARGET_ID_MISMATCH for batched action targetId overrides",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "batch",
actions: [{ kind: "click", ref: "5", targetId: "other-tab" }],
});
expect(response.status).toBe(403);
expect(response.body.code).toBe("ACT_TARGET_ID_MISMATCH");
expect(response.body.error).toContain("batched action targetId must match request targetId");
},
slowTimeoutMs,
);
it(
"returns ACT_TARGET_ID_MISMATCH for top-level action targetId overrides",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "click",
ref: "5",
// Intentionally non-string: route-level target selection ignores this,
// while action normalization stringifies it.
targetId: 12345,
});
expect(response.status).toBe(403);
expect(response.body.code).toBe("ACT_TARGET_ID_MISMATCH");
expect(response.body.error).toContain("action targetId must match request targetId");
},
slowTimeoutMs,
);
it(
"returns ACT_SELECTOR_UNSUPPORTED for selector on unsupported action kinds",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "evaluate",
fn: "() => 1",
selector: "#submit",
});
expect(response.status).toBe(400);
expect(response.body.code).toBe("ACT_SELECTOR_UNSUPPORTED");
expect(response.body.error).toContain("'selector' is not supported");
},
slowTimeoutMs,
);
it(
"returns ACT_INVALID_REQUEST for malformed unsupported selector actions before selector gating",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "press",
selector: "#submit",
});
expect(response.status).toBe(400);
expect(response.body.code).toBe("ACT_INVALID_REQUEST");
expect(response.body.error).toContain("press requires key");
},
slowTimeoutMs,
);
it(
"returns ACT_EVALUATE_DISABLED when evaluate is blocked by config",
async () => {
setBrowserControlServerEvaluateEnabled(false);
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "evaluate",
fn: "() => 1",
});
expect(response.status).toBe(403);
expect(response.body.code).toBe("ACT_EVALUATE_DISABLED");
expect(response.body.error).toContain("browser.evaluateEnabled=false");
},
slowTimeoutMs,
);
});

View File

@@ -1,577 +0,0 @@
import fs from "node:fs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "./constants.js";
import {
installAgentContractHooks,
postJson,
startServerAndBase,
} from "./server.agent-contract.test-harness.js";
import {
cleanupBrowserControlServerTestContext,
getBrowserControlServerBaseUrl,
getBrowserControlServerTestState,
getCdpMocks,
getPwMocks,
makeResponse,
resetBrowserControlServerTestContext,
setBrowserControlServerEvaluateEnabled,
setBrowserControlServerProfiles,
setBrowserControlServerReachable,
startBrowserControlServerFromConfig,
} from "./server.control-server.test-harness.js";
import { getBrowserTestFetch } from "./test-fetch.js";
type ActErrorResponse = {
error?: string;
code?: string;
};
type ActErrorHttpResponse = {
status: number;
body: ActErrorResponse;
};
async function postActAndReadError(base: string, body?: unknown): Promise<ActErrorHttpResponse> {
const realFetch = getBrowserTestFetch();
const response = await realFetch(`${base}/act`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
return {
status: response.status,
body: (await response.json()) as ActErrorResponse,
};
}
const state = getBrowserControlServerTestState();
const cdpMocks = getCdpMocks();
const pwMocks = getPwMocks();
describe("browser control server", () => {
installAgentContractHooks();
const slowTimeoutMs = process.platform === "win32" ? 40_000 : 20_000;
it(
"returns ACT_KIND_REQUIRED when kind is missing",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {});
expect(response.status).toBe(400);
expect(response.body.code).toBe("ACT_KIND_REQUIRED");
expect(response.body.error).toContain("kind is required");
},
slowTimeoutMs,
);
it(
"returns ACT_INVALID_REQUEST for malformed action payloads",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "click",
ref: {},
});
expect(response.status).toBe(400);
expect(response.body.code).toBe("ACT_INVALID_REQUEST");
expect(response.body.error).toContain("click requires ref or selector");
},
slowTimeoutMs,
);
it(
"returns ACT_EXISTING_SESSION_UNSUPPORTED for unsupported existing-session actions",
async () => {
setBrowserControlServerProfiles({
openclaw: {
color: "#FF4500",
driver: "existing-session",
},
});
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "batch",
actions: [{ kind: "press", key: "Enter" }],
});
expect(response.status).toBe(501);
expect(response.body.code).toBe("ACT_EXISTING_SESSION_UNSUPPORTED");
expect(response.body.error).toContain("batch");
},
slowTimeoutMs,
);
it(
"returns ACT_TARGET_ID_MISMATCH for batched action targetId overrides",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "batch",
actions: [{ kind: "click", ref: "5", targetId: "other-tab" }],
});
expect(response.status).toBe(403);
expect(response.body.code).toBe("ACT_TARGET_ID_MISMATCH");
expect(response.body.error).toContain("batched action targetId must match request targetId");
},
slowTimeoutMs,
);
it(
"returns ACT_TARGET_ID_MISMATCH for top-level action targetId overrides",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "click",
ref: "5",
// Intentionally non-string: route-level target selection ignores this,
// while action normalization stringifies it.
targetId: 12345,
});
expect(response.status).toBe(403);
expect(response.body.code).toBe("ACT_TARGET_ID_MISMATCH");
expect(response.body.error).toContain("action targetId must match request targetId");
},
slowTimeoutMs,
);
it(
"returns ACT_SELECTOR_UNSUPPORTED for selector on unsupported action kinds",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "evaluate",
fn: "() => 1",
selector: "#submit",
});
expect(response.status).toBe(400);
expect(response.body.code).toBe("ACT_SELECTOR_UNSUPPORTED");
expect(response.body.error).toContain("'selector' is not supported");
},
slowTimeoutMs,
);
it(
"returns ACT_INVALID_REQUEST for malformed unsupported selector actions before selector gating",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "press",
selector: "#submit",
});
expect(response.status).toBe(400);
expect(response.body.code).toBe("ACT_INVALID_REQUEST");
expect(response.body.error).toContain("press requires key");
},
slowTimeoutMs,
);
it(
"returns ACT_EVALUATE_DISABLED when evaluate is blocked by config",
async () => {
setBrowserControlServerEvaluateEnabled(false);
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "evaluate",
fn: "() => 1",
});
expect(response.status).toBe(403);
expect(response.body.code).toBe("ACT_EVALUATE_DISABLED");
expect(response.body.error).toContain("browser.evaluateEnabled=false");
},
slowTimeoutMs,
);
it("agent contract: snapshot endpoints", async () => {
const base = await startServerAndBase();
const realFetch = getBrowserTestFetch();
const snapAria = (await realFetch(`${base}/snapshot?format=aria&limit=1`).then((r) =>
r.json(),
)) as { ok: boolean; format?: string };
expect(snapAria.ok).toBe(true);
expect(snapAria.format).toBe("aria");
expect(cdpMocks.snapshotAria).toHaveBeenCalledWith({
wsUrl: "ws://127.0.0.1/devtools/page/abcd1234",
limit: 1,
});
const snapAi = (await realFetch(`${base}/snapshot?format=ai`).then((r) => r.json())) as {
ok: boolean;
format?: string;
};
expect(snapAi.ok).toBe(true);
expect(snapAi.format).toBe("ai");
expect(pwMocks.snapshotAiViaPlaywright).toHaveBeenCalledWith({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
maxChars: DEFAULT_AI_SNAPSHOT_MAX_CHARS,
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
});
const snapAiZero = (await realFetch(`${base}/snapshot?format=ai&maxChars=0`).then((r) =>
r.json(),
)) as { ok: boolean; format?: string };
expect(snapAiZero.ok).toBe(true);
expect(snapAiZero.format).toBe("ai");
const [lastCall] = pwMocks.snapshotAiViaPlaywright.mock.calls.at(-1) ?? [];
expect(lastCall).toEqual({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
});
});
it("agent contract: navigation + common act commands", async () => {
const base = await startServerAndBase();
const realFetch = getBrowserTestFetch();
const nav = await postJson<{ ok: boolean; targetId?: string }>(`${base}/navigate`, {
url: "https://example.com",
});
expect(nav.ok).toBe(true);
expect(typeof nav.targetId).toBe("string");
expect(pwMocks.navigateViaPlaywright).toHaveBeenCalledWith(
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
url: "https://example.com",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
}),
);
const click = await postJson<{ ok: boolean }>(`${base}/act`, {
kind: "click",
ref: "1",
button: "left",
modifiers: ["Shift"],
});
expect(click.ok).toBe(true);
expect(pwMocks.clickViaPlaywright).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ref: "1",
button: "left",
modifiers: ["Shift"],
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
}),
);
const [clickArgs] = pwMocks.clickViaPlaywright.mock.calls[0] ?? [];
expect((clickArgs as { doubleClick?: boolean }).doubleClick).toBeUndefined();
const clickSelector = await realFetch(`${base}/act`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "click", selector: "button.save" }),
});
expect(clickSelector.status).toBe(200);
expect(((await clickSelector.json()) as { ok?: boolean }).ok).toBe(true);
expect(pwMocks.clickViaPlaywright).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
selector: "button.save",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
}),
);
const [clickSelectorArgs] = pwMocks.clickViaPlaywright.mock.calls[1] ?? [];
expect((clickSelectorArgs as { doubleClick?: boolean }).doubleClick).toBeUndefined();
const type = await postJson<{ ok: boolean }>(`${base}/act`, {
kind: "type",
ref: "1",
text: "",
});
expect(type.ok).toBe(true);
expect(pwMocks.typeViaPlaywright).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ref: "1",
text: "",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
}),
);
const [typeArgs] = pwMocks.typeViaPlaywright.mock.calls[0] ?? [];
expect((typeArgs as { submit?: boolean }).submit).toBeUndefined();
expect((typeArgs as { slowly?: boolean }).slowly).toBeUndefined();
const press = await postJson<{ ok: boolean }>(`${base}/act`, {
kind: "press",
key: "Enter",
});
expect(press.ok).toBe(true);
expect(pwMocks.pressKeyViaPlaywright).toHaveBeenCalledWith(
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
key: "Enter",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
}),
);
const [pressArgs] = pwMocks.pressKeyViaPlaywright.mock.calls[0] ?? [];
expect((pressArgs as { delayMs?: number }).delayMs).toBeUndefined();
const hover = await postJson<{ ok: boolean }>(`${base}/act`, {
kind: "hover",
ref: "2",
});
expect(hover.ok).toBe(true);
expect(pwMocks.hoverViaPlaywright).toHaveBeenCalledWith(
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ref: "2",
}),
);
const [hoverArgs] = pwMocks.hoverViaPlaywright.mock.calls[0] ?? [];
expect((hoverArgs as { timeoutMs?: number }).timeoutMs).toBeUndefined();
const scroll = await postJson<{ ok: boolean }>(`${base}/act`, {
kind: "scrollIntoView",
ref: "2",
});
expect(scroll.ok).toBe(true);
expect(pwMocks.scrollIntoViewViaPlaywright).toHaveBeenCalledWith(
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ref: "2",
}),
);
const [scrollArgs] = pwMocks.scrollIntoViewViaPlaywright.mock.calls[0] ?? [];
expect((scrollArgs as { timeoutMs?: number }).timeoutMs).toBeUndefined();
const drag = await postJson<{ ok: boolean }>(`${base}/act`, {
kind: "drag",
startRef: "3",
endRef: "4",
});
expect(drag.ok).toBe(true);
expect(pwMocks.dragViaPlaywright).toHaveBeenCalledWith(
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
startRef: "3",
endRef: "4",
}),
);
const [dragArgs] = pwMocks.dragViaPlaywright.mock.calls[0] ?? [];
expect((dragArgs as { timeoutMs?: number }).timeoutMs).toBeUndefined();
});
it("POST /tabs/open?profile=unknown returns 404", async () => {
await startBrowserControlServerFromConfig();
const base = getBrowserControlServerBaseUrl();
const realFetch = getBrowserTestFetch();
const result = await realFetch(`${base}/tabs/open?profile=unknown`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: "https://example.com" }),
});
expect(result.status).toBe(404);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("not found");
});
it("POST /tabs/open returns 400 for invalid URLs", async () => {
setBrowserControlServerReachable(true);
await startBrowserControlServerFromConfig();
const base = getBrowserControlServerBaseUrl();
const realFetch = getBrowserTestFetch();
const result = await realFetch(`${base}/tabs/open`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: "not a url" }),
});
expect(result.status).toBe(400);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("Invalid URL:");
});
});
describe("profile CRUD endpoints", () => {
beforeEach(async () => {
await resetBrowserControlServerTestContext();
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
const u = url;
if (u.includes("/json/list")) {
return makeResponse([]);
}
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
}),
);
});
afterEach(async () => {
await cleanupBrowserControlServerTestContext();
});
it("validates profile create/delete endpoints", async () => {
await startBrowserControlServerFromConfig();
const base = getBrowserControlServerBaseUrl();
const realFetch = getBrowserTestFetch();
const createMissingName = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(createMissingName.status).toBe(400);
const createMissingNameBody = (await createMissingName.json()) as { error: string };
expect(createMissingNameBody.error).toContain("name is required");
const createInvalidName = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Invalid Name!" }),
});
expect(createInvalidName.status).toBe(400);
const createInvalidNameBody = (await createInvalidName.json()) as { error: string };
expect(createInvalidNameBody.error).toContain("invalid profile name");
const createDuplicate = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "openclaw" }),
});
expect(createDuplicate.status).toBe(409);
const createDuplicateBody = (await createDuplicate.json()) as { error: string };
expect(createDuplicateBody.error).toContain("already exists");
const createRemote = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "remote", cdpUrl: "http://10.0.0.42:9222" }),
});
expect(createRemote.status).toBe(200);
const createRemoteBody = (await createRemote.json()) as {
profile?: string;
cdpUrl?: string;
isRemote?: boolean;
};
expect(createRemoteBody.profile).toBe("remote");
expect(createRemoteBody.cdpUrl).toBe("http://10.0.0.42:9222");
expect(createRemoteBody.isRemote).toBe(true);
const createBadRemote = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "badremote", cdpUrl: "ftp://bad" }),
});
expect(createBadRemote.status).toBe(400);
const createBadRemoteBody = (await createBadRemote.json()) as { error: string };
expect(createBadRemoteBody.error).toContain("cdpUrl");
const createClawd = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "legacyclawd", driver: "clawd" }),
});
expect(createClawd.status).toBe(200);
const createClawdBody = (await createClawd.json()) as {
profile?: string;
transport?: string;
cdpPort?: number | null;
userDataDir?: string | null;
};
expect(createClawdBody.profile).toBe("legacyclawd");
expect(createClawdBody.transport).toBe("cdp");
expect(createClawdBody.cdpPort).toBeTypeOf("number");
expect(createClawdBody.userDataDir).toBeNull();
const explicitUserDataDir = "/tmp/openclaw-brave-profile";
await fs.promises.mkdir(explicitUserDataDir, { recursive: true });
const createExistingSession = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "brave-live",
driver: "existing-session",
userDataDir: explicitUserDataDir,
}),
});
expect(createExistingSession.status).toBe(200);
const createExistingSessionBody = (await createExistingSession.json()) as {
profile?: string;
transport?: string;
userDataDir?: string | null;
};
expect(createExistingSessionBody.profile).toBe("brave-live");
expect(createExistingSessionBody.transport).toBe("chrome-mcp");
expect(createExistingSessionBody.userDataDir).toBe(explicitUserDataDir);
const createBadExistingSession = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "bad-live",
userDataDir: explicitUserDataDir,
}),
});
expect(createBadExistingSession.status).toBe(400);
const createBadExistingSessionBody = (await createBadExistingSession.json()) as {
error: string;
};
expect(createBadExistingSessionBody.error).toContain("driver=existing-session is required");
const createLegacyDriver = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "legacy", driver: "extension" }),
});
expect(createLegacyDriver.status).toBe(400);
const createLegacyDriverBody = (await createLegacyDriver.json()) as { error: string };
expect(createLegacyDriverBody.error).toContain('unsupported profile driver "extension"');
const deleteMissing = await realFetch(`${base}/profiles/nonexistent`, {
method: "DELETE",
});
expect(deleteMissing.status).toBe(404);
const deleteMissingBody = (await deleteMissing.json()) as { error: string };
expect(deleteMissingBody.error).toContain("not found");
const deleteDefault = await realFetch(`${base}/profiles/openclaw`, {
method: "DELETE",
});
expect(deleteDefault.status).toBe(400);
const deleteDefaultBody = (await deleteDefault.json()) as { error: string };
expect(deleteDefaultBody.error).toContain("cannot delete the default profile");
const deleteInvalid = await realFetch(`${base}/profiles/Invalid-Name!`, {
method: "DELETE",
});
expect(deleteInvalid.status).toBe(400);
const deleteInvalidBody = (await deleteInvalid.json()) as { error: string };
expect(deleteInvalidBody.error).toContain("invalid profile name");
});
});

View File

@@ -0,0 +1,217 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "./constants.js";
import {
installAgentContractHooks,
postJson,
startServerAndBase,
} from "./server.agent-contract.test-harness.js";
import {
getBrowserControlServerTestState,
getCdpMocks,
getPwMocks,
} from "./server.control-server.test-harness.js";
import { getBrowserTestFetch } from "./test-fetch.js";
const state = getBrowserControlServerTestState();
const cdpMocks = getCdpMocks();
const pwMocks = getPwMocks();
describe("browser control server", () => {
installAgentContractHooks();
it("agent contract: snapshot endpoints", async () => {
const base = await startServerAndBase();
const realFetch = getBrowserTestFetch();
const snapAria = (await realFetch(`${base}/snapshot?format=aria&limit=1`).then((r) =>
r.json(),
)) as { ok: boolean; format?: string };
expect(snapAria.ok).toBe(true);
expect(snapAria.format).toBe("aria");
expect(cdpMocks.snapshotAria).toHaveBeenCalledWith({
wsUrl: "ws://127.0.0.1/devtools/page/abcd1234",
limit: 1,
});
const snapAi = (await realFetch(`${base}/snapshot?format=ai`).then((r) => r.json())) as {
ok: boolean;
format?: string;
};
expect(snapAi.ok).toBe(true);
expect(snapAi.format).toBe("ai");
expect(pwMocks.snapshotAiViaPlaywright).toHaveBeenCalledWith({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
maxChars: DEFAULT_AI_SNAPSHOT_MAX_CHARS,
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
});
const snapAiZero = (await realFetch(`${base}/snapshot?format=ai&maxChars=0`).then((r) =>
r.json(),
)) as { ok: boolean; format?: string };
expect(snapAiZero.ok).toBe(true);
expect(snapAiZero.format).toBe("ai");
const [lastCall] = pwMocks.snapshotAiViaPlaywright.mock.calls.at(-1) ?? [];
expect(lastCall).toEqual({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
});
});
it("agent contract: navigation + common act commands", async () => {
const base = await startServerAndBase();
const realFetch = getBrowserTestFetch();
const nav = await postJson<{ ok: boolean; targetId?: string }>(`${base}/navigate`, {
url: "https://example.com",
});
expect(nav.ok).toBe(true);
expect(typeof nav.targetId).toBe("string");
expect(pwMocks.navigateViaPlaywright).toHaveBeenCalledWith(
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
url: "https://example.com",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
}),
);
const click = await postJson<{ ok: boolean }>(`${base}/act`, {
kind: "click",
ref: "1",
button: "left",
modifiers: ["Shift"],
});
expect(click.ok).toBe(true);
expect(pwMocks.clickViaPlaywright).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ref: "1",
button: "left",
modifiers: ["Shift"],
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
}),
);
const [clickArgs] = pwMocks.clickViaPlaywright.mock.calls[0] ?? [];
expect((clickArgs as { doubleClick?: boolean }).doubleClick).toBeUndefined();
const clickSelector = await realFetch(`${base}/act`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "click", selector: "button.save" }),
});
expect(clickSelector.status).toBe(200);
expect(((await clickSelector.json()) as { ok?: boolean }).ok).toBe(true);
expect(pwMocks.clickViaPlaywright).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
selector: "button.save",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
}),
);
const [clickSelectorArgs] = pwMocks.clickViaPlaywright.mock.calls[1] ?? [];
expect((clickSelectorArgs as { doubleClick?: boolean }).doubleClick).toBeUndefined();
const type = await postJson<{ ok: boolean }>(`${base}/act`, {
kind: "type",
ref: "1",
text: "",
});
expect(type.ok).toBe(true);
expect(pwMocks.typeViaPlaywright).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ref: "1",
text: "",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
}),
);
const [typeArgs] = pwMocks.typeViaPlaywright.mock.calls[0] ?? [];
expect((typeArgs as { submit?: boolean }).submit).toBeUndefined();
expect((typeArgs as { slowly?: boolean }).slowly).toBeUndefined();
const press = await postJson<{ ok: boolean }>(`${base}/act`, {
kind: "press",
key: "Enter",
});
expect(press.ok).toBe(true);
expect(pwMocks.pressKeyViaPlaywright).toHaveBeenCalledWith(
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
key: "Enter",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: true,
},
}),
);
const [pressArgs] = pwMocks.pressKeyViaPlaywright.mock.calls[0] ?? [];
expect((pressArgs as { delayMs?: number }).delayMs).toBeUndefined();
const hover = await postJson<{ ok: boolean }>(`${base}/act`, {
kind: "hover",
ref: "2",
});
expect(hover.ok).toBe(true);
expect(pwMocks.hoverViaPlaywright).toHaveBeenCalledWith(
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ref: "2",
}),
);
const [hoverArgs] = pwMocks.hoverViaPlaywright.mock.calls[0] ?? [];
expect((hoverArgs as { timeoutMs?: number }).timeoutMs).toBeUndefined();
const scroll = await postJson<{ ok: boolean }>(`${base}/act`, {
kind: "scrollIntoView",
ref: "2",
});
expect(scroll.ok).toBe(true);
expect(pwMocks.scrollIntoViewViaPlaywright).toHaveBeenCalledWith(
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ref: "2",
}),
);
const [scrollArgs] = pwMocks.scrollIntoViewViaPlaywright.mock.calls[0] ?? [];
expect((scrollArgs as { timeoutMs?: number }).timeoutMs).toBeUndefined();
const drag = await postJson<{ ok: boolean }>(`${base}/act`, {
kind: "drag",
startRef: "3",
endRef: "4",
});
expect(drag.ok).toBe(true);
expect(pwMocks.dragViaPlaywright).toHaveBeenCalledWith(
expect.objectContaining({
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
startRef: "3",
endRef: "4",
}),
);
const [dragArgs] = pwMocks.dragViaPlaywright.mock.calls[0] ?? [];
expect((dragArgs as { timeoutMs?: number }).timeoutMs).toBeUndefined();
});
});

View File

@@ -348,6 +348,7 @@ async function loadBrowserServerModule(): Promise<BrowserServerModule> {
if (browserServerModule) {
return browserServerModule;
}
vi.resetModules();
browserServerModule = await import("../server.js");
return browserServerModule;
}
@@ -483,6 +484,7 @@ export async function startBrowserControlServerFromConfig() {
export async function stopBrowserControlServer(): Promise<void> {
const server = browserServerModule;
browserServerModule = null;
if (!server) {
return;
}

View File

@@ -0,0 +1,207 @@
import fs from "node:fs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
cleanupBrowserControlServerTestContext,
getBrowserControlServerBaseUrl,
installBrowserControlServerHooks,
makeResponse,
resetBrowserControlServerTestContext,
setBrowserControlServerReachable,
startBrowserControlServerFromConfig,
} from "./server.control-server.test-harness.js";
import { getBrowserTestFetch } from "./test-fetch.js";
describe("browser control server", () => {
installBrowserControlServerHooks();
it("POST /tabs/open?profile=unknown returns 404", async () => {
await startBrowserControlServerFromConfig();
const base = getBrowserControlServerBaseUrl();
const realFetch = getBrowserTestFetch();
const result = await realFetch(`${base}/tabs/open?profile=unknown`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: "https://example.com" }),
});
expect(result.status).toBe(404);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("not found");
});
it("POST /tabs/open returns 400 for invalid URLs", async () => {
setBrowserControlServerReachable(true);
await startBrowserControlServerFromConfig();
const base = getBrowserControlServerBaseUrl();
const realFetch = getBrowserTestFetch();
const result = await realFetch(`${base}/tabs/open`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: "not a url" }),
});
expect(result.status).toBe(400);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("Invalid URL:");
});
});
describe("profile CRUD endpoints", () => {
beforeEach(async () => {
await resetBrowserControlServerTestContext();
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
const u = url;
if (u.includes("/json/list")) {
return makeResponse([]);
}
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
}),
);
});
afterEach(async () => {
await cleanupBrowserControlServerTestContext();
});
it("validates profile create/delete endpoints", async () => {
await startBrowserControlServerFromConfig();
const base = getBrowserControlServerBaseUrl();
const realFetch = getBrowserTestFetch();
const createMissingName = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(createMissingName.status).toBe(400);
const createMissingNameBody = (await createMissingName.json()) as { error: string };
expect(createMissingNameBody.error).toContain("name is required");
const createInvalidName = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Invalid Name!" }),
});
expect(createInvalidName.status).toBe(400);
const createInvalidNameBody = (await createInvalidName.json()) as { error: string };
expect(createInvalidNameBody.error).toContain("invalid profile name");
const createDuplicate = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "openclaw" }),
});
expect(createDuplicate.status).toBe(409);
const createDuplicateBody = (await createDuplicate.json()) as { error: string };
expect(createDuplicateBody.error).toContain("already exists");
const createRemote = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "remote", cdpUrl: "http://10.0.0.42:9222" }),
});
expect(createRemote.status).toBe(200);
const createRemoteBody = (await createRemote.json()) as {
profile?: string;
cdpUrl?: string;
isRemote?: boolean;
};
expect(createRemoteBody.profile).toBe("remote");
expect(createRemoteBody.cdpUrl).toBe("http://10.0.0.42:9222");
expect(createRemoteBody.isRemote).toBe(true);
const createBadRemote = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "badremote", cdpUrl: "ftp://bad" }),
});
expect(createBadRemote.status).toBe(400);
const createBadRemoteBody = (await createBadRemote.json()) as { error: string };
expect(createBadRemoteBody.error).toContain("cdpUrl");
const createClawd = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "legacyclawd", driver: "clawd" }),
});
expect(createClawd.status).toBe(200);
const createClawdBody = (await createClawd.json()) as {
profile?: string;
transport?: string;
cdpPort?: number | null;
userDataDir?: string | null;
};
expect(createClawdBody.profile).toBe("legacyclawd");
expect(createClawdBody.transport).toBe("cdp");
expect(createClawdBody.cdpPort).toBeTypeOf("number");
expect(createClawdBody.userDataDir).toBeNull();
const explicitUserDataDir = "/tmp/openclaw-brave-profile";
await fs.promises.mkdir(explicitUserDataDir, { recursive: true });
const createExistingSession = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "brave-live",
driver: "existing-session",
userDataDir: explicitUserDataDir,
}),
});
expect(createExistingSession.status).toBe(200);
const createExistingSessionBody = (await createExistingSession.json()) as {
profile?: string;
transport?: string;
userDataDir?: string | null;
};
expect(createExistingSessionBody.profile).toBe("brave-live");
expect(createExistingSessionBody.transport).toBe("chrome-mcp");
expect(createExistingSessionBody.userDataDir).toBe(explicitUserDataDir);
const createBadExistingSession = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "bad-live",
userDataDir: explicitUserDataDir,
}),
});
expect(createBadExistingSession.status).toBe(400);
const createBadExistingSessionBody = (await createBadExistingSession.json()) as {
error: string;
};
expect(createBadExistingSessionBody.error).toContain("driver=existing-session is required");
const createLegacyDriver = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "legacy", driver: "extension" }),
});
expect(createLegacyDriver.status).toBe(400);
const createLegacyDriverBody = (await createLegacyDriver.json()) as { error: string };
expect(createLegacyDriverBody.error).toContain('unsupported profile driver "extension"');
const deleteMissing = await realFetch(`${base}/profiles/nonexistent`, {
method: "DELETE",
});
expect(deleteMissing.status).toBe(404);
const deleteMissingBody = (await deleteMissing.json()) as { error: string };
expect(deleteMissingBody.error).toContain("not found");
const deleteDefault = await realFetch(`${base}/profiles/openclaw`, {
method: "DELETE",
});
expect(deleteDefault.status).toBe(400);
const deleteDefaultBody = (await deleteDefault.json()) as { error: string };
expect(deleteDefaultBody.error).toContain("cannot delete the default profile");
const deleteInvalid = await realFetch(`${base}/profiles/Invalid-Name!`, {
method: "DELETE",
});
expect(deleteInvalid.status).toBe(400);
const deleteInvalidBody = (await deleteInvalid.json()) as { error: string };
expect(deleteInvalidBody.error).toContain("invalid profile name");
});
});

View File

@@ -199,39 +199,6 @@ describe("CodexAppServerClient", () => {
expect(process.kill).toHaveBeenCalledWith("SIGKILL");
expect(process.unref).toHaveBeenCalledTimes(1);
});
it("handles stdin write errors without crashing the process", async () => {
const harness = createClientHarness();
clients.push(harness.client);
// Start a pending request so we can verify it gets properly rejected.
const pending = harness.client.request("test/method");
// Simulate the child process closing its pipe — a write to the now-dead
// stdin emits an asynchronous EPIPE error on the stream.
harness.process.stdin.destroy(Object.assign(new Error("write EPIPE"), { code: "EPIPE" }));
// The pending request must be rejected with the pipe error rather than
// an unhandled exception tearing down the gateway.
await expect(pending).rejects.toThrow("write EPIPE");
// Subsequent requests are rejected immediately (client is closed).
await expect(harness.client.request("another/method")).rejects.toThrow(
"codex app-server client is closed",
);
});
it("does not write to stdin after the child process exits", async () => {
const harness = createClientHarness();
clients.push(harness.client);
// Simulate the child process exiting.
harness.process.emit("exit", 1, null);
// A notification after exit must not attempt a write.
harness.client.notify("late/event", { data: "ignored" });
expect(harness.writes).toHaveLength(0);
});
it("reads the Codex version from the app-server user agent", () => {
expect(readCodexVersionFromUserAgent("Codex Desktop/0.118.0")).toBe("0.118.0");
expect(readCodexVersionFromUserAgent("openclaw/0.118.0 (macOS; test)")).toBe("0.118.0");

View File

@@ -74,13 +74,6 @@ export class CodexAppServerClient {
),
);
});
// Guard against unhandled EPIPE / write-after-close errors on the stdin
// stream. When the child process terminates abruptly the pipe can break
// before the "exit" event fires, so a pending writeMessage() produces an
// asynchronous error on stdin that would otherwise crash the gateway.
child.stdin.on?.("error", (error) =>
this.closeWithError(error instanceof Error ? error : new Error(String(error))),
);
}
static start(options?: Partial<CodexAppServerStartOptions>): CodexAppServerClient {
@@ -219,9 +212,6 @@ export class CodexAppServerClient {
}
private writeMessage(message: RpcRequest | RpcResponse): void {
if (this.closed) {
return;
}
this.child.stdin.write(`${JSON.stringify(message)}\n`);
}
@@ -310,9 +300,7 @@ export class CodexAppServerClient {
return;
}
this.closed = true;
this.lines.close();
this.rejectPendingRequests(error);
closeCodexAppServerTransport(this.child);
}
private rejectPendingRequests(error: Error): void {

View File

@@ -4,7 +4,6 @@ export type CodexAppServerTransport = {
end?: () => unknown;
destroy?: () => unknown;
unref?: () => unknown;
on?: (event: "error", listener: (error: Error) => void) => unknown;
};
stdout: NodeJS.ReadableStream & {
destroy?: () => unknown;

View File

@@ -42,7 +42,7 @@ describeLive("comfy live", () => {
beforeAll(async () => {
cfg = withPluginsEnabled(loadConfig());
agentDir = resolveOpenClawAgentDir();
plugin.register(
await plugin.register(
createTestPluginApi({
config: cfg as never,
registerImageGenerationProvider(provider) {

View File

@@ -92,7 +92,7 @@ function registerPairCommand(params?: {
pluginConfig?: Record<string, unknown>;
}): OpenClawPluginCommandDefinition {
let command: OpenClawPluginCommandDefinition | undefined;
registerDevicePair.register(
void registerDevicePair.register(
createApi({
...params,
registerCommand: (nextCommand) => {

View File

@@ -1,5 +1,4 @@
export { createThreadBindingManager } from "./src/monitor/thread-bindings.manager.js";
export { __testing as discordThreadBindingTesting } from "./src/monitor/thread-bindings.manager.js";
export {
listDiscordDirectoryGroupsFromConfig,
listDiscordDirectoryPeersFromConfig,

View File

@@ -1,4 +1,4 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const {
GatewayIntents,
@@ -221,9 +221,6 @@ describe("createDiscordGatewayPlugin", () => {
}
beforeEach(() => {
vi.unstubAllEnvs();
vi.stubEnv("OPENCLAW_DEBUG_PROXY_ENABLED", "");
vi.stubEnv("OPENCLAW_DEBUG_PROXY_URL", "");
vi.stubGlobal("fetch", globalFetchMock);
vi.useRealTimers();
baseRegisterClientSpy.mockClear();
@@ -239,11 +236,6 @@ describe("createDiscordGatewayPlugin", () => {
resetLastAgent();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllEnvs();
});
it("uses safe gateway metadata lookup without proxy", async () => {
const runtime = createRuntime();
const plugin = createDiscordGatewayPlugin({

View File

@@ -1,21 +0,0 @@
import { describe, expect, it } from "vitest";
import { createDiscordPluginBase } from "./shared.js";
describe("createDiscordPluginBase", () => {
it("owns Discord native command name overrides", () => {
const plugin = createDiscordPluginBase({ setup: {} as never });
expect(
plugin.commands?.resolveNativeCommandName?.({
commandKey: "tts",
defaultName: "tts",
}),
).toBe("voice");
expect(
plugin.commands?.resolveNativeCommandName?.({
commandKey: "status",
defaultName: "status",
}),
).toBe("status");
});
});

View File

@@ -1,5 +1,4 @@
import { describe, expect, it } from "vitest";
import { createExaWebSearchProvider as createContractExaWebSearchProvider } from "../web-search-contract-api.js";
import { __testing, createExaWebSearchProvider } from "./exa-web-search-provider.js";
describe("exa web search provider", () => {
@@ -16,31 +15,6 @@ describe("exa web search provider", () => {
expect(applied.plugins?.entries?.exa?.enabled).toBe(true);
});
it("keeps the lightweight contract surface aligned with provider metadata", () => {
const provider = createExaWebSearchProvider();
const contractProvider = createContractExaWebSearchProvider();
if (!contractProvider.applySelectionConfig) {
throw new Error("Expected contract applySelectionConfig to be defined");
}
const applied = contractProvider.applySelectionConfig({});
expect(contractProvider).toMatchObject({
id: provider.id,
label: provider.label,
hint: provider.hint,
onboardingScopes: provider.onboardingScopes,
credentialLabel: provider.credentialLabel,
envVars: provider.envVars,
placeholder: provider.placeholder,
signupUrl: provider.signupUrl,
docsUrl: provider.docsUrl,
autoDetectOrder: provider.autoDetectOrder,
credentialPath: provider.credentialPath,
});
expect(contractProvider.createTool({ config: {}, searchConfig: {} })).toBeNull();
expect(applied.plugins?.entries?.exa?.enabled).toBe(true);
});
it("prefers scoped configured api keys over environment fallbacks", () => {
expect(__testing.resolveExaApiKey({ apiKey: "exa-secret" })).toBe("exa-secret");
});

View File

@@ -1,29 +0,0 @@
import {
createWebSearchProviderContractFields,
type WebSearchProviderPlugin,
} from "openclaw/plugin-sdk/provider-web-search-contract";
export function createExaWebSearchProvider(): WebSearchProviderPlugin {
const credentialPath = "plugins.entries.exa.config.webSearch.apiKey";
return {
id: "exa",
label: "Exa Search",
hint: "Neural + keyword search with date filters and content extraction",
onboardingScopes: ["text-inference"],
credentialLabel: "Exa API key",
envVars: ["EXA_API_KEY"],
placeholder: "exa-...",
signupUrl: "https://exa.ai/",
docsUrl: "https://docs.openclaw.ai/tools/web",
autoDetectOrder: 65,
credentialPath,
...createWebSearchProviderContractFields({
credentialPath,
searchCredential: { type: "scoped", scopeId: "exa" },
configuredCredential: { pluginId: "exa" },
selectionPluginId: "exa",
}),
createTool: () => null,
};
}

View File

@@ -25,14 +25,9 @@ vi.mock("./bot.js", () => ({
handleFeishuMessage: vi.fn(),
}));
const createFeishuClientMock = vi.hoisted(() => vi.fn());
const sendCardFeishuMock = vi.hoisted(() => vi.fn());
const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
vi.mock("./client.js", () => ({
createFeishuClient: createFeishuClientMock,
}));
vi.mock("./send.js", () => ({
sendCardFeishu: sendCardFeishuMock,
sendMessageFeishu: sendMessageFeishuMock,
@@ -94,13 +89,6 @@ describe("Feishu Card Action Handler", () => {
beforeEach(() => {
vi.clearAllMocks();
createFeishuClientMock.mockReset().mockReturnValue({
im: {
chat: {
get: vi.fn().mockResolvedValue({ code: 0, data: { chat_type: "group" } }),
},
},
});
vi.mocked(handleFeishuMessage)
.mockReset()
.mockResolvedValue(undefined as never);
@@ -366,142 +354,6 @@ describe("Feishu Card Action Handler", () => {
);
});
it("resolves DM chat type from the Feishu chat API when card context omits it", async () => {
createFeishuClientMock.mockReturnValueOnce({
im: {
chat: {
get: vi.fn().mockResolvedValue({ code: 0, data: { chat_type: "p2p" } }),
},
},
});
const event = createCardActionEvent({
token: "tok9b",
chatId: "oc_dm_chat_123",
actionValue: { text: "/help" },
});
await handleFeishuCardAction({ cfg, event, runtime });
expect(handleFeishuMessage).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
message: expect.objectContaining({
chat_id: "oc_dm_chat_123",
chat_type: "p2p",
}),
}),
}),
);
expect(createFeishuClientMock).toHaveBeenCalledTimes(1);
});
it("uses resolved DM chat type when building approval cards without stored context", async () => {
createFeishuClientMock.mockReturnValueOnce({
im: {
chat: {
get: vi.fn().mockResolvedValue({ code: 0, data: { chat_mode: "p2p" } }),
},
},
});
const event = createCardActionEvent({
token: "tok9c",
chatId: "oc_dm_chat_234",
actionValue: createFeishuCardInteractionEnvelope({
k: "meta",
a: FEISHU_APPROVAL_REQUEST_ACTION,
m: {
command: "/new",
prompt: "Start a fresh session?",
},
c: {
u: "u123",
h: "oc_dm_chat_234",
e: Date.now() + 60_000,
},
}),
});
await handleFeishuCardAction({ cfg, event, runtime, accountId: "main" });
expect(sendCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
card: expect.objectContaining({
body: expect.objectContaining({
elements: expect.arrayContaining([
expect.objectContaining({
tag: "action",
actions: expect.arrayContaining([
expect.objectContaining({
value: expect.objectContaining({
c: expect.objectContaining({
t: "p2p",
}),
}),
}),
]),
}),
]),
}),
}),
}),
);
expect(createFeishuClientMock).toHaveBeenCalledTimes(1);
});
it("falls back to p2p when Feishu chat API returns an error", async () => {
createFeishuClientMock.mockReturnValueOnce({
im: {
chat: {
get: vi.fn().mockResolvedValue({ code: 99, msg: "not found" }),
},
},
});
const event = createCardActionEvent({
token: "tok9d",
chatId: "oc_unknown_chat_456",
actionValue: { text: "/help" },
});
await handleFeishuCardAction({ cfg, event, runtime });
expect(handleFeishuMessage).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
message: expect.objectContaining({
chat_type: "p2p",
}),
}),
}),
);
});
it("falls back to p2p when Feishu chat API throws", async () => {
createFeishuClientMock.mockReturnValueOnce({
im: {
chat: {
get: vi.fn().mockRejectedValue(new Error("network failure")),
},
},
});
const event = createCardActionEvent({
token: "tok9e",
chatId: "oc_broken_chat_789",
actionValue: { text: "/help" },
});
await handleFeishuCardAction({ cfg, event, runtime });
expect(handleFeishuMessage).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
message: expect.objectContaining({
chat_type: "p2p",
}),
}),
}),
);
});
it("drops duplicate structured callback tokens", async () => {
const event = createStructuredQuickActionEvent({
token: "tok10",

View File

@@ -2,7 +2,6 @@ import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js";
import { resolveFeishuRuntimeAccount } from "./accounts.js";
import { handleFeishuMessage, type FeishuMessageEvent } from "./bot.js";
import { decodeFeishuCardAction, buildFeishuCardActionTextFallback } from "./card-interaction.js";
import { createFeishuClient } from "./client.js";
import {
createApprovalCard,
FEISHU_APPROVAL_CANCEL_ACTION,
@@ -105,7 +104,7 @@ function releaseFeishuCardActionToken(params: { token: string; accountId: string
function buildSyntheticMessageEvent(
event: FeishuCardActionEvent,
content: string,
chatType: "p2p" | "group",
chatType?: "p2p" | "group",
): FeishuMessageEvent {
return {
sender: {
@@ -118,7 +117,7 @@ function buildSyntheticMessageEvent(
message: {
message_id: `card-action-${event.token}`,
chat_id: event.context.chat_id || event.operator.open_id,
chat_type: chatType,
chat_type: chatType ?? (event.context.chat_id ? "group" : "p2p"),
message_type: "text",
content: JSON.stringify({ text: content }),
},
@@ -137,124 +136,20 @@ async function dispatchSyntheticCommand(params: {
cfg: ClawdbotConfig;
event: FeishuCardActionEvent;
command: string;
account: ReturnType<typeof resolveFeishuRuntimeAccount>;
botOpenId?: string;
runtime?: RuntimeEnv;
accountId?: string;
chatType?: "p2p" | "group";
}): Promise<void> {
const resolvedChatType = await resolveCardActionChatType({
event: params.event,
account: params.account,
chatType: params.chatType,
log: params.runtime?.log ?? console.log,
});
await handleFeishuMessage({
cfg: params.cfg,
event: buildSyntheticMessageEvent(params.event, params.command, resolvedChatType),
event: buildSyntheticMessageEvent(params.event, params.command, params.chatType),
botOpenId: params.botOpenId,
runtime: params.runtime,
accountId: params.accountId,
});
}
// Feishu's im.chat.get returns two fields:
// chat_mode: conversation type — "p2p" | "group" | "topic"
// chat_type: privacy classification — "private" | "public"
// We check chat_mode first because it directly indicates conversation type.
// "private" maps to "p2p" as the safe-failure direction (restrictive DM
// policy) — a private group chat misclassified as p2p is safer than the
// reverse. "topic" and "public" are treated as group semantics.
function normalizeResolvedCardActionChatType(value: unknown): "p2p" | "group" | undefined {
if (value === "group" || value === "topic" || value === "public") {
return "group";
}
if (value === "p2p" || value === "private") {
return "p2p";
}
return undefined;
}
const resolvedChatTypeCache = new Map<string, { value: "p2p" | "group"; expiresAt: number }>();
const CHAT_TYPE_CACHE_TTL_MS = 30 * 60_000;
const CHAT_TYPE_CACHE_MAX_SIZE = 5_000;
function pruneChatTypeCache(now: number): void {
for (const [key, entry] of resolvedChatTypeCache.entries()) {
if (entry.expiresAt <= now) {
resolvedChatTypeCache.delete(key);
}
}
if (resolvedChatTypeCache.size > CHAT_TYPE_CACHE_MAX_SIZE) {
const excess = resolvedChatTypeCache.size - CHAT_TYPE_CACHE_MAX_SIZE;
const iter = resolvedChatTypeCache.keys();
for (let i = 0; i < excess; i++) {
const key = iter.next().value;
if (key !== undefined) {
resolvedChatTypeCache.delete(key);
}
}
}
}
function sanitizeLogValue(v: string): string {
return v.replace(/[\r\n]/g, " ").slice(0, 500);
}
async function resolveCardActionChatType(params: {
event: FeishuCardActionEvent;
account: ReturnType<typeof resolveFeishuRuntimeAccount>;
chatType?: "p2p" | "group";
log: (message: string) => void;
}): Promise<"p2p" | "group"> {
const explicitChatType = normalizeResolvedCardActionChatType(params.chatType);
if (explicitChatType) {
return explicitChatType;
}
const chatId = params.event.context.chat_id?.trim();
if (!chatId) {
return "p2p";
}
const cacheKey = `${params.account.accountId}:${chatId}`;
const now = Date.now();
pruneChatTypeCache(now);
const cached = resolvedChatTypeCache.get(cacheKey);
if (cached) {
return cached.value;
}
try {
const response = (await createFeishuClient(params.account).im.chat.get({
path: { chat_id: chatId },
})) as { code?: number; msg?: string; data?: { chat_type?: unknown; chat_mode?: unknown } };
if (response.code === 0) {
const resolvedChatType =
normalizeResolvedCardActionChatType(response.data?.chat_mode) ??
normalizeResolvedCardActionChatType(response.data?.chat_type);
if (resolvedChatType) {
resolvedChatTypeCache.set(cacheKey, { value: resolvedChatType, expiresAt: now + CHAT_TYPE_CACHE_TTL_MS });
return resolvedChatType;
}
params.log(
`feishu[${params.account.accountId}]: card action missing chat type for chat; defaulting to p2p`,
);
} else {
params.log(
`feishu[${params.account.accountId}]: failed to resolve chat type: ${sanitizeLogValue(response.msg ?? "unknown error")}; defaulting to p2p`,
);
}
} catch (err) {
const message = err instanceof Error ? err.message : "unknown";
params.log(
`feishu[${params.account.accountId}]: failed to resolve chat type: ${sanitizeLogValue(message)}; defaulting to p2p`,
);
}
return "p2p";
}
async function sendInvalidInteractionNotice(params: {
cfg: ClawdbotConfig;
event: FeishuCardActionEvent;
@@ -351,12 +246,7 @@ export async function handleFeishuCardAction(params: {
prompt,
sessionKey: envelope.c?.s,
expiresAt: Date.now() + FEISHU_APPROVAL_CARD_TTL_MS,
chatType: await resolveCardActionChatType({
event,
account,
chatType: envelope.c?.t,
log,
}),
chatType: envelope.c?.t ?? (event.context.chat_id ? "group" : "p2p"),
confirmLabel: command === "/reset" ? "Reset" : "Confirm",
}),
accountId,
@@ -392,11 +282,10 @@ export async function handleFeishuCardAction(params: {
cfg,
event,
command,
account,
botOpenId: params.botOpenId,
runtime,
accountId,
chatType: envelope.c?.t,
chatType: envelope.c?.t ?? (event.context.chat_id ? "group" : "p2p"),
});
completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
return;
@@ -422,7 +311,6 @@ export async function handleFeishuCardAction(params: {
cfg,
event,
command: content,
account,
botOpenId: params.botOpenId,
runtime,
accountId,

View File

@@ -4,6 +4,7 @@ const resolveFirstGithubTokenMock = vi.hoisted(() => vi.fn());
const resolveCopilotApiTokenMock = vi.hoisted(() => vi.fn());
const resolveConfiguredSecretInputStringMock = vi.hoisted(() => vi.fn());
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
const createGitHubCopilotEmbeddingProviderMock = vi.hoisted(() => vi.fn());
vi.mock("./auth.js", () => ({
resolveFirstGithubToken: resolveFirstGithubTokenMock,
@@ -18,6 +19,10 @@ vi.mock("openclaw/plugin-sdk/github-copilot-token", () => ({
resolveCopilotApiToken: resolveCopilotApiTokenMock,
}));
vi.mock("openclaw/plugin-sdk/memory-core-host-engine-embeddings", () => ({
createGitHubCopilotEmbeddingProvider: createGitHubCopilotEmbeddingProviderMock,
}));
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
}));
@@ -68,6 +73,15 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
source: "test",
baseUrl: TEST_BASE_URL,
});
createGitHubCopilotEmbeddingProviderMock.mockImplementation(async (client) => ({
provider: {
id: "github-copilot",
model: client.model,
embedQuery: async () => [0.1, 0.2, 0.3],
embedBatch: async (texts: string[]) => texts.map(() => [0.1, 0.2, 0.3]),
},
client,
}));
});
afterEach(() => {
@@ -75,6 +89,7 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
resolveConfiguredSecretInputStringMock.mockReset();
resolveFirstGithubTokenMock.mockReset();
resolveCopilotApiTokenMock.mockReset();
createGitHubCopilotEmbeddingProviderMock.mockReset();
fetchWithSsrFGuardMock.mockReset();
});
@@ -98,8 +113,12 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
const result = await githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions());
expect(result.provider?.model).toBe("text-embedding-3-small");
expect(resolveCopilotApiTokenMock).toHaveBeenCalledWith(
expect.objectContaining({ githubToken: "gh_test_token_123" }),
expect(createGitHubCopilotEmbeddingProviderMock).toHaveBeenCalledWith(
expect.objectContaining({
baseUrl: TEST_BASE_URL,
githubToken: "gh_test_token_123",
model: "text-embedding-3-small",
}),
);
});
@@ -198,12 +217,14 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
} as never);
expect(resolveFirstGithubTokenMock).toHaveBeenCalled();
expect(resolveCopilotApiTokenMock).toHaveBeenCalledWith(
expect.objectContaining({
env: process.env,
githubToken: "gh_remote_token",
}),
);
expect(createGitHubCopilotEmbeddingProviderMock).toHaveBeenCalledWith({
baseUrl: "https://proxy.example/v1",
env: process.env,
fetchImpl: fetch,
githubToken: "gh_remote_token",
headers: { "X-Proxy-Token": "proxy" },
model: "text-embedding-3-small",
});
const discoveryCall = fetchWithSsrFGuardMock.mock.calls[0]?.[0] as {
init: { headers: Record<string, string> };

View File

@@ -4,10 +4,7 @@ import {
resolveCopilotApiToken,
} from "openclaw/plugin-sdk/github-copilot-token";
import {
buildRemoteBaseUrlPolicy,
sanitizeAndNormalizeEmbedding,
withRemoteHttpResponse,
type MemoryEmbeddingProvider,
createGitHubCopilotEmbeddingProvider,
type MemoryEmbeddingProviderAdapter,
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
@@ -47,15 +44,6 @@ type CopilotModelEntry = {
supported_endpoints?: unknown;
};
type GitHubCopilotEmbeddingClient = {
githubToken: string;
model: string;
baseUrl?: string;
headers?: Record<string, string>;
env?: NodeJS.ProcessEnv;
fetchImpl?: typeof fetch;
};
function isCopilotSetupError(err: unknown): boolean {
if (!(err instanceof Error)) {
return false;
@@ -159,126 +147,9 @@ function pickBestModel(available: string[], userModel?: string): string {
throw new Error("No embedding models available from GitHub Copilot");
}
function parseGitHubCopilotEmbeddingPayload(payload: unknown, expectedCount: number): number[][] {
if (!payload || typeof payload !== "object") {
throw new Error("GitHub Copilot embeddings response missing data[]");
}
const data = (payload as { data?: unknown }).data;
if (!Array.isArray(data)) {
throw new Error("GitHub Copilot embeddings response missing data[]");
}
const vectors = Array.from<number[] | undefined>({ length: expectedCount });
for (const entry of data) {
if (!entry || typeof entry !== "object") {
throw new Error("GitHub Copilot embeddings response contains an invalid entry");
}
const indexValue = (entry as { index?: unknown }).index;
const embedding = (entry as { embedding?: unknown }).embedding;
const index = typeof indexValue === "number" ? indexValue : Number.NaN;
if (!Number.isInteger(index)) {
throw new Error("GitHub Copilot embeddings response contains an invalid index");
}
if (index < 0 || index >= expectedCount) {
throw new Error("GitHub Copilot embeddings response contains an out-of-range index");
}
if (vectors[index] !== undefined) {
throw new Error("GitHub Copilot embeddings response contains duplicate indexes");
}
if (!Array.isArray(embedding) || !embedding.every((value) => typeof value === "number")) {
throw new Error("GitHub Copilot embeddings response contains an invalid embedding");
}
vectors[index] = sanitizeAndNormalizeEmbedding(embedding);
}
for (let index = 0; index < expectedCount; index += 1) {
if (vectors[index] === undefined) {
throw new Error("GitHub Copilot embeddings response missing vectors for some inputs");
}
}
return vectors as number[][];
}
async function resolveGitHubCopilotEmbeddingSession(client: GitHubCopilotEmbeddingClient): Promise<{
baseUrl: string;
headers: Record<string, string>;
}> {
const token = await resolveCopilotApiToken({
githubToken: client.githubToken,
env: client.env,
fetchImpl: client.fetchImpl,
});
const baseUrl = client.baseUrl?.trim() || token.baseUrl || DEFAULT_COPILOT_API_BASE_URL;
return {
baseUrl,
headers: {
...COPILOT_HEADERS_STATIC,
...client.headers,
Authorization: `Bearer ${token.token}`,
},
};
}
async function createGitHubCopilotEmbeddingProvider(
client: GitHubCopilotEmbeddingClient,
): Promise<{ provider: MemoryEmbeddingProvider; client: GitHubCopilotEmbeddingClient }> {
const initialSession = await resolveGitHubCopilotEmbeddingSession(client);
const embed = async (input: string[]): Promise<number[][]> => {
if (input.length === 0) {
return [];
}
const session = await resolveGitHubCopilotEmbeddingSession(client);
const url = `${session.baseUrl.replace(/\/$/, "")}/embeddings`;
return await withRemoteHttpResponse({
url,
fetchImpl: client.fetchImpl,
ssrfPolicy: buildRemoteBaseUrlPolicy(session.baseUrl),
init: {
method: "POST",
headers: session.headers,
body: JSON.stringify({ model: client.model, input }),
},
onResponse: async (response) => {
if (!response.ok) {
throw new Error(
`GitHub Copilot embeddings HTTP ${response.status}: ${await response.text()}`,
);
}
let payload: unknown;
try {
payload = await response.json();
} catch {
throw new Error("GitHub Copilot embeddings returned invalid JSON");
}
return parseGitHubCopilotEmbeddingPayload(payload, input.length);
},
});
};
return {
provider: {
id: COPILOT_EMBEDDING_PROVIDER_ID,
model: client.model,
embedQuery: async (text) => {
const [vector] = await embed([text]);
return vector ?? [];
},
embedBatch: embed,
},
client: {
...client,
baseUrl: initialSession.baseUrl,
},
};
}
export const githubCopilotMemoryEmbeddingProviderAdapter: MemoryEmbeddingProviderAdapter = {
id: COPILOT_EMBEDDING_PROVIDER_ID,
transport: "remote",
authProviderId: COPILOT_EMBEDDING_PROVIDER_ID,
autoSelectPriority: 15,
allowExplicitWhenConfiguredAuto: true,
shouldContinueAutoSelection: (err: unknown) => isCopilotSetupError(err),

View File

@@ -2,8 +2,11 @@ import {
resolveProviderHttpRequestConfig,
type ProviderRequestTransportOverrides,
} from "openclaw/plugin-sdk/provider-http";
import {
applyAgentDefaultModelPrimary,
type OpenClawConfig,
} from "openclaw/plugin-sdk/provider-onboard";
import { parseGoogleOauthApiKey } from "./oauth-token-shared.js";
export { applyGoogleGeminiModelDefault, GOOGLE_GEMINI_DEFAULT_MODEL } from "./onboard.js";
import {
DEFAULT_GOOGLE_API_BASE_URL,
normalizeGoogleApiBaseUrl,
@@ -21,8 +24,6 @@ export {
shouldNormalizeGoogleGenerativeAiProviderConfig,
shouldNormalizeGoogleProviderConfig,
} from "./provider-policy.js";
export { buildGoogleGeminiCliProvider } from "./gemini-cli-provider.js";
export { buildGoogleProvider } from "./provider-registration.js";
export function parseGeminiAuth(apiKey: string): { headers: Record<string, string> } {
const parsed = apiKey.startsWith("{") ? parseGoogleOauthApiKey(apiKey) : null;
@@ -87,3 +88,27 @@ export function resolveGoogleGenerativeAiHttpRequestConfig(params: {
transport: params.transport,
});
}
export const GOOGLE_GEMINI_DEFAULT_MODEL = "google/gemini-3.1-pro-preview";
export function applyGoogleGeminiModelDefault(cfg: OpenClawConfig): {
next: OpenClawConfig;
changed: boolean;
} {
const current = cfg.agents?.defaults?.model as unknown;
const currentPrimary =
typeof current === "string"
? current.trim() || undefined
: current &&
typeof current === "object" &&
typeof (current as { primary?: unknown }).primary === "string"
? ((current as { primary: string }).primary || "").trim() || undefined
: undefined;
if (currentPrimary === GOOGLE_GEMINI_DEFAULT_MODEL) {
return { next: cfg, changed: false };
}
return {
next: applyAgentDefaultModelPrimary(cfg, GOOGLE_GEMINI_DEFAULT_MODEL),
changed: true,
};
}

View File

@@ -4,7 +4,6 @@ import type {
ProviderFetchUsageSnapshotContext,
} from "openclaw/plugin-sdk/plugin-entry";
import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth-result";
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
import { buildProviderToolCompatFamilyHooks } from "openclaw/plugin-sdk/provider-tools";
import { fetchGeminiUsage } from "openclaw/plugin-sdk/provider-usage";
import { formatGoogleOauthApiKey, parseGoogleUsageToken } from "./oauth-token-shared.js";
@@ -30,8 +29,8 @@ async function fetchGeminiCliUsage(ctx: ProviderFetchUsageSnapshotContext) {
return await fetchGeminiUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn, PROVIDER_ID);
}
export function buildGoogleGeminiCliProvider(): ProviderPlugin {
return {
export function registerGoogleGeminiCliProvider(api: OpenClawPluginApi) {
api.registerProvider({
id: PROVIDER_ID,
label: PROVIDER_LABEL,
docsPath: "/providers/models",
@@ -129,9 +128,5 @@ export function buildGoogleGeminiCliProvider(): ProviderPlugin {
};
},
fetchUsageSnapshot: async (ctx) => await fetchGeminiCliUsage(ctx),
};
}
export function registerGoogleGeminiCliProvider(api: OpenClawPluginApi) {
api.registerProvider(buildGoogleGeminiCliProvider());
});
}

View File

@@ -1,121 +0,0 @@
import type { MusicGenerationProvider } from "openclaw/plugin-sdk/music-generation";
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import type {
VideoGenerationProvider,
VideoGenerationProviderConfiguredContext,
} from "openclaw/plugin-sdk/video-generation";
export const DEFAULT_GOOGLE_MUSIC_MODEL = "lyria-3-clip-preview";
export const GOOGLE_PRO_MUSIC_MODEL = "lyria-3-pro-preview";
export const GOOGLE_MAX_INPUT_IMAGES = 10;
export const DEFAULT_GOOGLE_VIDEO_MODEL = "veo-3.1-fast-generate-preview";
export const GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS = [4, 6, 8] as const;
export const GOOGLE_VIDEO_MIN_DURATION_SECONDS = GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS[0];
export const GOOGLE_VIDEO_MAX_DURATION_SECONDS =
GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS[GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS.length - 1];
function isGoogleProviderConfigured(
ctx: { agentDir?: string } | VideoGenerationProviderConfiguredContext,
): boolean {
return isProviderApiKeyConfigured({
provider: "google",
agentDir: ctx.agentDir,
});
}
export function createGoogleMusicGenerationProviderMetadata(): Omit<
MusicGenerationProvider,
"generateMusic"
> {
return {
id: "google",
label: "Google",
defaultModel: DEFAULT_GOOGLE_MUSIC_MODEL,
models: [DEFAULT_GOOGLE_MUSIC_MODEL, GOOGLE_PRO_MUSIC_MODEL],
isConfigured: isGoogleProviderConfigured,
capabilities: {
generate: {
maxTracks: 1,
supportsLyrics: true,
supportsInstrumental: true,
supportsFormat: true,
supportedFormatsByModel: {
[DEFAULT_GOOGLE_MUSIC_MODEL]: ["mp3"],
[GOOGLE_PRO_MUSIC_MODEL]: ["mp3", "wav"],
},
},
edit: {
enabled: true,
maxTracks: 1,
maxInputImages: GOOGLE_MAX_INPUT_IMAGES,
supportsLyrics: true,
supportsInstrumental: true,
supportsFormat: true,
supportedFormatsByModel: {
[DEFAULT_GOOGLE_MUSIC_MODEL]: ["mp3"],
[GOOGLE_PRO_MUSIC_MODEL]: ["mp3", "wav"],
},
},
},
};
}
export function createGoogleVideoGenerationProviderMetadata(): Omit<
VideoGenerationProvider,
"generateVideo"
> {
return {
id: "google",
label: "Google",
defaultModel: DEFAULT_GOOGLE_VIDEO_MODEL,
models: [
DEFAULT_GOOGLE_VIDEO_MODEL,
"veo-3.1-generate-preview",
"veo-3.1-lite-generate-preview",
"veo-3.0-fast-generate-001",
"veo-3.0-generate-001",
"veo-2.0-generate-001",
],
isConfigured: isGoogleProviderConfigured,
capabilities: {
generate: {
maxVideos: 1,
maxDurationSeconds: GOOGLE_VIDEO_MAX_DURATION_SECONDS,
supportedDurationSeconds: [...GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS],
aspectRatios: ["16:9", "9:16"],
resolutions: ["720P", "1080P"],
supportsAspectRatio: true,
supportsResolution: true,
supportsSize: true,
supportsAudio: true,
},
imageToVideo: {
enabled: true,
maxVideos: 1,
maxInputImages: 1,
maxDurationSeconds: GOOGLE_VIDEO_MAX_DURATION_SECONDS,
supportedDurationSeconds: [...GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS],
aspectRatios: ["16:9", "9:16"],
resolutions: ["720P", "1080P"],
supportsAspectRatio: true,
supportsResolution: true,
supportsSize: true,
supportsAudio: true,
},
videoToVideo: {
enabled: true,
maxVideos: 1,
maxInputVideos: 1,
maxDurationSeconds: GOOGLE_VIDEO_MAX_DURATION_SECONDS,
supportedDurationSeconds: [...GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS],
aspectRatios: ["16:9", "9:16"],
resolutions: ["720P", "1080P"],
supportsAspectRatio: true,
supportsResolution: true,
supportsSize: true,
supportsAudio: true,
},
},
};
}

View File

@@ -1,23 +1,16 @@
import type { ImageGenerationProvider } from "openclaw/plugin-sdk/image-generation";
import type { MediaUnderstandingProvider } from "openclaw/plugin-sdk/media-understanding";
import type { MusicGenerationProvider } from "openclaw/plugin-sdk/music-generation";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import type { VideoGenerationProvider } from "openclaw/plugin-sdk/video-generation";
import { buildGoogleGeminiCliBackend } from "./cli-backend.js";
import { registerGoogleGeminiCliProvider } from "./gemini-cli-provider.js";
import {
createGoogleMusicGenerationProviderMetadata,
createGoogleVideoGenerationProviderMetadata,
} from "./generation-provider-metadata.js";
import { geminiMemoryEmbeddingProviderAdapter } from "./memory-embedding-adapter.js";
import { buildGoogleMusicGenerationProvider } from "./music-generation-provider.js";
import { registerGoogleProvider } from "./provider-registration.js";
import { buildGoogleSpeechProvider } from "./speech-provider.js";
import { createGeminiWebSearchProvider } from "./src/gemini-web-search-provider.js";
import { buildGoogleVideoGenerationProvider } from "./video-generation-provider.js";
let googleImageGenerationProviderPromise: Promise<ImageGenerationProvider> | null = null;
let googleMediaUnderstandingProviderPromise: Promise<MediaUnderstandingProvider> | null = null;
let googleMusicGenerationProviderPromise: Promise<MusicGenerationProvider> | null = null;
let googleVideoGenerationProviderPromise: Promise<VideoGenerationProvider> | null = null;
type GoogleMediaUnderstandingProvider = Required<
Pick<
@@ -44,24 +37,6 @@ async function loadGoogleMediaUnderstandingProvider(): Promise<MediaUnderstandin
return await googleMediaUnderstandingProviderPromise;
}
async function loadGoogleMusicGenerationProvider(): Promise<MusicGenerationProvider> {
if (!googleMusicGenerationProviderPromise) {
googleMusicGenerationProviderPromise = import("./music-generation-provider.js").then((mod) =>
mod.buildGoogleMusicGenerationProvider(),
);
}
return await googleMusicGenerationProviderPromise;
}
async function loadGoogleVideoGenerationProvider(): Promise<VideoGenerationProvider> {
if (!googleVideoGenerationProviderPromise) {
googleVideoGenerationProviderPromise = import("./video-generation-provider.js").then((mod) =>
mod.buildGoogleVideoGenerationProvider(),
);
}
return await googleVideoGenerationProviderPromise;
}
async function loadGoogleRequiredMediaUnderstandingProvider(): Promise<GoogleMediaUnderstandingProvider> {
const provider = await loadGoogleMediaUnderstandingProvider();
if (
@@ -128,22 +103,6 @@ function createLazyGoogleMediaUnderstandingProvider(): MediaUnderstandingProvide
};
}
function createLazyGoogleMusicGenerationProvider(): MusicGenerationProvider {
return {
...createGoogleMusicGenerationProviderMetadata(),
generateMusic: async (...args) =>
await (await loadGoogleMusicGenerationProvider()).generateMusic(...args),
};
}
function createLazyGoogleVideoGenerationProvider(): VideoGenerationProvider {
return {
...createGoogleVideoGenerationProviderMetadata(),
generateVideo: async (...args) =>
await (await loadGoogleVideoGenerationProvider()).generateVideo(...args),
};
}
export default definePluginEntry({
id: "google",
name: "Google Plugin",
@@ -152,12 +111,11 @@ export default definePluginEntry({
api.registerCliBackend(buildGoogleGeminiCliBackend());
registerGoogleGeminiCliProvider(api);
registerGoogleProvider(api);
api.registerMemoryEmbeddingProvider(geminiMemoryEmbeddingProviderAdapter);
api.registerImageGenerationProvider(createLazyGoogleImageGenerationProvider());
api.registerMediaUnderstandingProvider(createLazyGoogleMediaUnderstandingProvider());
api.registerMusicGenerationProvider(createLazyGoogleMusicGenerationProvider());
api.registerMusicGenerationProvider(buildGoogleMusicGenerationProvider());
api.registerSpeechProvider(buildGoogleSpeechProvider());
api.registerVideoGenerationProvider(createLazyGoogleVideoGenerationProvider());
api.registerVideoGenerationProvider(buildGoogleVideoGenerationProvider());
api.registerWebSearchProvider(createGeminiWebSearchProvider());
},
});

View File

@@ -1,79 +0,0 @@
import {
hasNonTextEmbeddingParts,
isMissingEmbeddingApiKeyError,
mapBatchEmbeddingsByIndex,
sanitizeEmbeddingCacheHeaders,
type MemoryEmbeddingProviderAdapter,
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import { runGeminiEmbeddingBatches } from "./embedding-batch.js";
import {
buildGeminiEmbeddingRequest,
createGeminiEmbeddingProvider,
DEFAULT_GEMINI_EMBEDDING_MODEL,
} from "./embedding-provider.js";
function supportsGeminiMultimodalEmbeddings(model: string): boolean {
const normalized = model
.trim()
.replace(/^models\//, "")
.replace(/^(gemini|google)\//, "");
return normalized === "gemini-embedding-2-preview";
}
export const geminiMemoryEmbeddingProviderAdapter: MemoryEmbeddingProviderAdapter = {
id: "gemini",
defaultModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
transport: "remote",
authProviderId: "google",
autoSelectPriority: 30,
allowExplicitWhenConfiguredAuto: true,
supportsMultimodalEmbeddings: ({ model }) => supportsGeminiMultimodalEmbeddings(model),
shouldContinueAutoSelection: isMissingEmbeddingApiKeyError,
create: async (options) => {
const { provider, client } = await createGeminiEmbeddingProvider({
...options,
provider: "gemini",
fallback: "none",
});
return {
provider,
runtime: {
id: "gemini",
cacheKeyData: {
provider: "gemini",
baseUrl: client.baseUrl,
model: client.model,
outputDimensionality: client.outputDimensionality,
headers: sanitizeEmbeddingCacheHeaders(client.headers, [
"authorization",
"x-goog-api-key",
]),
},
batchEmbed: async (batch) => {
if (batch.chunks.some((chunk) => hasNonTextEmbeddingParts(chunk.embeddingInput))) {
return null;
}
const byCustomId = await runGeminiEmbeddingBatches({
gemini: client,
agentId: batch.agentId,
requests: batch.chunks.map((chunk, index) => ({
custom_id: String(index),
request: buildGeminiEmbeddingRequest({
input: chunk.embeddingInput ?? { text: chunk.text },
taskType: "RETRIEVAL_DOCUMENT",
modelPath: client.modelPath,
outputDimensionality: client.outputDimensionality,
}),
})),
wait: batch.wait,
concurrency: batch.concurrency,
pollIntervalMs: batch.pollIntervalMs,
timeoutMs: batch.timeoutMs,
debug: batch.debug,
});
return mapBatchEmbeddingsByIndex(byCustomId, batch.chunks.length);
},
},
};
},
};

View File

@@ -5,17 +5,15 @@ import type {
MusicGenerationProvider,
MusicGenerationRequest,
} from "openclaw/plugin-sdk/music-generation";
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { normalizeGoogleApiBaseUrl } from "./api.js";
import {
createGoogleMusicGenerationProviderMetadata,
DEFAULT_GOOGLE_MUSIC_MODEL,
GOOGLE_MAX_INPUT_IMAGES,
GOOGLE_PRO_MUSIC_MODEL,
} from "./generation-provider-metadata.js";
const DEFAULT_GOOGLE_MUSIC_MODEL = "lyria-3-clip-preview";
const GOOGLE_PRO_MUSIC_MODEL = "lyria-3-pro-preview";
const DEFAULT_TIMEOUT_MS = 180_000;
const GOOGLE_MAX_INPUT_IMAGES = 10;
type GoogleInlineDataPart = {
mimeType?: string;
@@ -101,7 +99,39 @@ function extractTracks(params: { payload: GoogleGenerateMusicResponse; model: st
export function buildGoogleMusicGenerationProvider(): MusicGenerationProvider {
return {
...createGoogleMusicGenerationProviderMetadata(),
id: "google",
label: "Google",
defaultModel: DEFAULT_GOOGLE_MUSIC_MODEL,
models: [DEFAULT_GOOGLE_MUSIC_MODEL, GOOGLE_PRO_MUSIC_MODEL],
isConfigured: ({ agentDir }) =>
isProviderApiKeyConfigured({
provider: "google",
agentDir,
}),
capabilities: {
generate: {
maxTracks: 1,
supportsLyrics: true,
supportsInstrumental: true,
supportsFormat: true,
supportedFormatsByModel: {
[DEFAULT_GOOGLE_MUSIC_MODEL]: ["mp3"],
[GOOGLE_PRO_MUSIC_MODEL]: ["mp3", "wav"],
},
},
edit: {
enabled: true,
maxTracks: 1,
maxInputImages: GOOGLE_MAX_INPUT_IMAGES,
supportsLyrics: true,
supportsInstrumental: true,
supportsFormat: true,
supportedFormatsByModel: {
[DEFAULT_GOOGLE_MUSIC_MODEL]: ["mp3"],
[GOOGLE_PRO_MUSIC_MODEL]: ["mp3", "wav"],
},
},
},
async generateMusic(req) {
if ((req.inputImages?.length ?? 0) > GOOGLE_MAX_INPUT_IMAGES) {
throw new Error(

View File

@@ -1,28 +0,0 @@
import {
applyAgentDefaultModelPrimary,
type OpenClawConfig,
} from "openclaw/plugin-sdk/provider-onboard";
export const GOOGLE_GEMINI_DEFAULT_MODEL = "google/gemini-3.1-pro-preview";
export function applyGoogleGeminiModelDefault(cfg: OpenClawConfig): {
next: OpenClawConfig;
changed: boolean;
} {
const current = cfg.agents?.defaults?.model as unknown;
const currentPrimary =
typeof current === "string"
? current.trim() || undefined
: current &&
typeof current === "object" &&
typeof (current as { primary?: unknown }).primary === "string"
? ((current as { primary: string }).primary || "").trim() || undefined
: undefined;
if (currentPrimary === GOOGLE_GEMINI_DEFAULT_MODEL) {
return { next: cfg, changed: false };
}
return {
next: applyAgentDefaultModelPrimary(cfg, GOOGLE_GEMINI_DEFAULT_MODEL),
changed: true,
};
}

View File

@@ -46,7 +46,6 @@
},
"contracts": {
"mediaUnderstandingProviders": ["google"],
"memoryEmbeddingProviders": ["gemini"],
"imageGenerationProviders": ["google"],
"musicGenerationProviders": ["google"],
"speechProviders": ["google"],

View File

@@ -1,17 +1,17 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
import { normalizeGoogleModelId } from "./model-id.js";
import { GOOGLE_GEMINI_DEFAULT_MODEL, applyGoogleGeminiModelDefault } from "./onboard.js";
import {
GOOGLE_GEMINI_DEFAULT_MODEL,
applyGoogleGeminiModelDefault,
normalizeGoogleProviderConfig,
normalizeGoogleModelId,
resolveGoogleGenerativeAiTransport,
} from "./api.js";
import { GOOGLE_GEMINI_PROVIDER_HOOKS } from "./provider-hooks.js";
import { isModernGoogleModel, resolveGoogleGeminiForwardCompatModel } from "./provider-models.js";
import {
normalizeGoogleProviderConfig,
resolveGoogleGenerativeAiTransport,
} from "./provider-policy.js";
export function buildGoogleProvider(): ProviderPlugin {
return {
export function registerGoogleProvider(api: OpenClawPluginApi) {
api.registerProvider({
id: "google",
label: "Google AI Studio",
docsPath: "/providers/models",
@@ -50,9 +50,5 @@ export function buildGoogleProvider(): ProviderPlugin {
}),
...GOOGLE_GEMINI_PROVIDER_HOOKS,
isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId),
};
}
export function registerGoogleProvider(api: OpenClawPluginApi) {
api.registerProvider(buildGoogleProvider());
});
}

View File

@@ -1,6 +1,7 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import path from "node:path";
import { GoogleGenAI } from "@google/genai";
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
createProviderOperationDeadline,
@@ -15,17 +16,15 @@ import type {
VideoGenerationRequest,
} from "openclaw/plugin-sdk/video-generation";
import { normalizeGoogleApiBaseUrl } from "./api.js";
import {
createGoogleVideoGenerationProviderMetadata,
DEFAULT_GOOGLE_VIDEO_MODEL,
GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS,
GOOGLE_VIDEO_MAX_DURATION_SECONDS,
GOOGLE_VIDEO_MIN_DURATION_SECONDS,
} from "./generation-provider-metadata.js";
const DEFAULT_GOOGLE_VIDEO_MODEL = "veo-3.1-fast-generate-preview";
const DEFAULT_TIMEOUT_MS = 180_000;
const POLL_INTERVAL_MS = 10_000;
const MAX_POLL_ATTEMPTS = 90;
const GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS = [4, 6, 8] as const;
const GOOGLE_VIDEO_MIN_DURATION_SECONDS = GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS[0];
const GOOGLE_VIDEO_MAX_DURATION_SECONDS =
GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS[GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS.length - 1];
function resolveConfiguredGoogleVideoBaseUrl(req: VideoGenerationRequest): string | undefined {
const configured = normalizeOptionalString(req.cfg?.models?.providers?.google?.baseUrl);
@@ -152,7 +151,61 @@ async function downloadGeneratedVideo(params: {
export function buildGoogleVideoGenerationProvider(): VideoGenerationProvider {
return {
...createGoogleVideoGenerationProviderMetadata(),
id: "google",
label: "Google",
defaultModel: DEFAULT_GOOGLE_VIDEO_MODEL,
models: [
DEFAULT_GOOGLE_VIDEO_MODEL,
"veo-3.1-generate-preview",
"veo-3.1-lite-generate-preview",
"veo-3.0-fast-generate-001",
"veo-3.0-generate-001",
"veo-2.0-generate-001",
],
isConfigured: ({ agentDir }) =>
isProviderApiKeyConfigured({
provider: "google",
agentDir,
}),
capabilities: {
generate: {
maxVideos: 1,
maxDurationSeconds: GOOGLE_VIDEO_MAX_DURATION_SECONDS,
supportedDurationSeconds: GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS,
aspectRatios: ["16:9", "9:16"],
resolutions: ["720P", "1080P"],
supportsAspectRatio: true,
supportsResolution: true,
supportsSize: true,
supportsAudio: true,
},
imageToVideo: {
enabled: true,
maxVideos: 1,
maxInputImages: 1,
maxDurationSeconds: GOOGLE_VIDEO_MAX_DURATION_SECONDS,
supportedDurationSeconds: GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS,
aspectRatios: ["16:9", "9:16"],
resolutions: ["720P", "1080P"],
supportsAspectRatio: true,
supportsResolution: true,
supportsSize: true,
supportsAudio: true,
},
videoToVideo: {
enabled: true,
maxVideos: 1,
maxInputVideos: 1,
maxDurationSeconds: GOOGLE_VIDEO_MAX_DURATION_SECONDS,
supportedDurationSeconds: GOOGLE_VIDEO_ALLOWED_DURATION_SECONDS,
aspectRatios: ["16:9", "9:16"],
resolutions: ["720P", "1080P"],
supportsAspectRatio: true,
supportsResolution: true,
supportsSize: true,
supportsAudio: true,
},
},
async generateVideo(req) {
if ((req.inputImages?.length ?? 0) > 1) {
throw new Error("Google video generation supports at most one input image.");

View File

@@ -1 +1,28 @@
export { createGeminiWebSearchProvider } from "./src/gemini-web-search-provider.js";
import {
createWebSearchProviderContractFields,
type WebSearchProviderPlugin,
} from "openclaw/plugin-sdk/provider-web-search-config-contract";
export function createGeminiWebSearchProvider(): WebSearchProviderPlugin {
const credentialPath = "plugins.entries.google.config.webSearch.apiKey";
return {
id: "gemini",
label: "Gemini (Google Search)",
hint: "Requires Google Gemini API key · Google Search grounding",
onboardingScopes: ["text-inference"],
credentialLabel: "Google Gemini API key",
envVars: ["GEMINI_API_KEY"],
placeholder: "AIza...",
signupUrl: "https://aistudio.google.com/apikey",
docsUrl: "https://docs.openclaw.ai/tools/web",
autoDetectOrder: 20,
credentialPath,
...createWebSearchProviderContractFields({
credentialPath,
searchCredential: { type: "scoped", scopeId: "gemini" },
configuredCredential: { pluginId: "google" },
}),
createTool: () => null,
};
}

View File

@@ -8,6 +8,7 @@ import {
} from "openclaw/plugin-sdk/account-resolution";
import { safeParseJsonWithSchema, safeParseWithSchema } from "openclaw/plugin-sdk/extension-shared";
import { isSecretRef } from "openclaw/plugin-sdk/secret-input";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { z } from "zod";
import type { GoogleChatAccountConfig } from "./types.config.js";
@@ -27,14 +28,6 @@ const ENV_SERVICE_ACCOUNT = "GOOGLE_CHAT_SERVICE_ACCOUNT";
const ENV_SERVICE_ACCOUNT_FILE = "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE";
const JsonRecordSchema = z.record(z.string(), z.unknown());
function normalizeOptionalString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
const {
listAccountIds: listGoogleChatAccountIds,
resolveDefaultAccountId: resolveDefaultGoogleChatAccountId,

View File

@@ -1,4 +1,3 @@
import path from "node:path";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const listEnabledGoogleChatAccounts = vi.hoisted(() => vi.fn());
@@ -10,6 +9,7 @@ const sendGoogleChatMessage = vi.hoisted(() => vi.fn());
const uploadGoogleChatAttachment = vi.hoisted(() => vi.fn());
const resolveGoogleChatOutboundSpace = vi.hoisted(() => vi.fn());
const getGoogleChatRuntime = vi.hoisted(() => vi.fn());
const loadOutboundMediaFromUrl = vi.hoisted(() => vi.fn());
vi.mock("./accounts.js", () => ({
listEnabledGoogleChatAccounts,
@@ -32,6 +32,15 @@ vi.mock("./targets.js", () => ({
resolveGoogleChatOutboundSpace,
}));
vi.mock("../runtime-api.js", async () => {
const actual = await vi.importActual<typeof import("../runtime-api.js")>("../runtime-api.js");
return {
...actual,
loadOutboundMediaFromUrl: (...args: Parameters<typeof actual.loadOutboundMediaFromUrl>) =>
(loadOutboundMediaFromUrl as unknown as typeof actual.loadOutboundMediaFromUrl)(...args),
};
});
let googlechatMessageActions: typeof import("./actions.js").googlechatMessageActions;
describe("googlechat message actions", () => {
@@ -152,9 +161,11 @@ describe("googlechat message actions", () => {
config: { mediaMaxMb: 5 },
});
resolveGoogleChatOutboundSpace.mockResolvedValue("spaces/BBB");
const localRoot = "/tmp/googlechat-action-test";
const localPath = path.join(localRoot, "local.md");
const readFile = vi.fn(async () => Buffer.from("local-bytes"));
loadOutboundMediaFromUrl.mockResolvedValue({
buffer: Buffer.from("local-bytes"),
fileName: "local.txt",
contentType: "text/plain",
});
getGoogleChatRuntime.mockReturnValue({
channel: {
media: {
@@ -176,22 +187,23 @@ describe("googlechat message actions", () => {
action: "upload-file",
params: {
to: "spaces/BBB",
path: localPath,
path: "/tmp/local.txt",
message: "notes",
filename: "renamed.txt",
},
cfg: {},
accountId: "default",
mediaLocalRoots: [localRoot],
mediaReadFile: readFile,
mediaLocalRoots: ["/tmp"],
} as never);
expect(readFile).toHaveBeenCalledWith(localPath);
expect(loadOutboundMediaFromUrl).toHaveBeenCalledWith(
"/tmp/local.txt",
expect.objectContaining({ mediaLocalRoots: ["/tmp"] }),
);
expect(uploadGoogleChatAttachment).toHaveBeenCalledWith(
expect.objectContaining({
space: "spaces/BBB",
filename: "renamed.txt",
buffer: Buffer.from("local-bytes"),
}),
);
expect(sendGoogleChatMessage).toHaveBeenCalledWith(

View File

@@ -1,17 +1,17 @@
import {
createActionGate,
jsonResult,
readNumberParam,
readReactionParams,
readStringParam,
} from "openclaw/plugin-sdk/channel-actions";
import type {
ChannelMessageActionAdapter,
ChannelMessageActionName,
} from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media";
import { extractToolSend } from "openclaw/plugin-sdk/tool-send";
OpenClawConfig,
} from "../runtime-api.js";
import {
createActionGate,
extractToolSend,
jsonResult,
loadOutboundMediaFromUrl,
readNumberParam,
readReactionParams,
readStringParam,
} from "../runtime-api.js";
import { listEnabledGoogleChatAccounts, resolveGoogleChatAccount } from "./accounts.js";
import {
createGoogleChatReaction,

View File

@@ -1,6 +1,6 @@
import crypto from "node:crypto";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { fetchWithSsrFGuard } from "../runtime-api.js";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
import { getGoogleChatAccessToken } from "./auth.js";
import type { GoogleChatReaction } from "./types.js";

View File

@@ -1,5 +1,5 @@
import { GoogleAuth, OAuth2Client } from "google-auth-library";
import { fetchWithSsrFGuard } from "../runtime-api.js";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
const CHAT_SCOPE = "https://www.googleapis.com/auth/chat.bot";
@@ -16,10 +16,6 @@ const verifyClient = new OAuth2Client();
let cachedCerts: { fetchedAt: number; certs: Record<string, string> } | null = null;
function normalizeLowercaseStringOrEmpty(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
function buildAuthKey(account: ResolvedGoogleChatAccount): string {
if (account.credentialsFile) {
return `file:${account.credentialsFile}`;
@@ -84,20 +80,13 @@ async function fetchChatCerts(): Promise<Record<string, string>> {
if (cachedCerts && now - cachedCerts.fetchedAt < 10 * 60 * 1000) {
return cachedCerts.certs;
}
const { response, release } = await fetchWithSsrFGuard({
url: CHAT_CERTS_URL,
auditContext: "googlechat.auth.certs",
});
try {
if (!response.ok) {
throw new Error(`Failed to fetch Chat certs (${response.status})`);
}
const certs = (await response.json()) as Record<string, string>;
cachedCerts = { fetchedAt: now, certs };
return certs;
} finally {
await release();
const res = await fetch(CHAT_CERTS_URL);
if (!res.ok) {
throw new Error(`Failed to fetch Chat certs (${res.status})`);
}
const certs = (await res.json()) as Record<string, string>;
cachedCerts = { fetchedAt: now, certs };
return certs;
}
export type GoogleChatAudienceType = "app-url" | "project-number";

View File

@@ -1,11 +1,11 @@
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/status-helpers";
import {
createAccountStatusSink,
runPassiveAccountLifecycle,
} from "openclaw/plugin-sdk/channel-lifecycle";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/status-helpers";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
type OpenClawConfig,
type ResolvedGoogleChatAccount,
} from "./channel.deps.runtime.js";
import type { GoogleChatRuntimeEnv } from "./monitor-types.js";
const loadGoogleChatChannelRuntime = createLazyRuntimeNamedExport(

View File

@@ -18,7 +18,6 @@ import {
import type { ResolvedGoogleChatAccount } from "./accounts.js";
import { sendGoogleChatMessage } from "./api.js";
import type { GoogleChatCoreRuntime } from "./monitor-types.js";
import { isSenderAllowed } from "./sender-allow.js";
import type { GoogleChatAnnotation, GoogleChatMessage, GoogleChatSpace } from "./types.js";
function normalizeUserId(raw?: string | null): string {
@@ -29,7 +28,42 @@ function normalizeUserId(raw?: string | null): string {
return normalizeLowercaseStringOrEmpty(trimmed.replace(/^users\//i, ""));
}
export { isSenderAllowed } from "./sender-allow.js";
function isEmailLike(value: string): boolean {
// Keep this intentionally loose; allowlists are user-provided config.
return value.includes("@");
}
export function isSenderAllowed(
senderId: string,
senderEmail: string | undefined,
allowFrom: string[],
allowNameMatching = false,
) {
if (allowFrom.includes("*")) {
return true;
}
const normalizedSenderId = normalizeUserId(senderId);
const normalizedEmail = normalizeLowercaseStringOrEmpty(senderEmail ?? "");
return allowFrom.some((entry) => {
const normalized = normalizeLowercaseStringOrEmpty(entry);
if (!normalized) {
return false;
}
// Accept `googlechat:<id>` but treat `users/...` as an *ID* only (deprecated `users/<email>`).
const withoutPrefix = normalized.replace(/^(googlechat|google-chat|gchat):/i, "");
if (withoutPrefix.startsWith("users/")) {
return normalizeUserId(withoutPrefix) === normalizedSenderId;
}
// Raw email allowlist entries are a break-glass override.
if (allowNameMatching && normalizedEmail && isEmailLike(withoutPrefix)) {
return withoutPrefix === normalizedEmail;
}
return withoutPrefix.replace(/^users\//i, "") === normalizedSenderId;
});
}
type GoogleChatGroupEntry = {
requireMention?: boolean;

View File

@@ -1,55 +0,0 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { createWebhookInFlightLimiter } from "openclaw/plugin-sdk/webhook-request-guards";
import { registerWebhookTargetWithPluginRoute } from "openclaw/plugin-sdk/webhook-targets";
import type { WebhookTarget } from "./monitor-types.js";
import { createGoogleChatWebhookRequestHandler } from "./monitor-webhook.js";
import type { GoogleChatEvent } from "./types.js";
type ProcessGoogleChatEvent = (event: GoogleChatEvent, target: WebhookTarget) => Promise<void>;
const webhookTargets = new Map<string, WebhookTarget[]>();
const webhookInFlightLimiter = createWebhookInFlightLimiter();
let processGoogleChatEvent: ProcessGoogleChatEvent = async () => {};
export function setGoogleChatWebhookEventProcessor(processEvent: ProcessGoogleChatEvent): void {
processGoogleChatEvent = processEvent;
}
const googleChatWebhookRequestHandler = createGoogleChatWebhookRequestHandler({
webhookTargets,
webhookInFlightLimiter,
processEvent: async (event, target) => {
await processGoogleChatEvent(event, target);
},
});
export function registerGoogleChatWebhookTarget(target: WebhookTarget): () => void {
return registerWebhookTargetWithPluginRoute({
targetsByPath: webhookTargets,
target,
route: {
auth: "plugin",
match: "exact",
pluginId: "googlechat",
source: "googlechat-webhook",
accountId: target.account.accountId,
log: target.runtime.log,
handler: async (req, res) => {
const handled = await handleGoogleChatWebhookRequest(req, res);
if (!handled && !res.headersSent) {
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Not Found");
}
},
},
}).unregister;
}
export async function handleGoogleChatWebhookRequest(
req: IncomingMessage,
res: ServerResponse,
): Promise<boolean> {
return await googleChatWebhookRequestHandler(req, res);
}

View File

@@ -1,7 +1,7 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
import type { GoogleChatAudienceType } from "./auth.js";
import type { getGoogleChatRuntime } from "./runtime.js";
import { getGoogleChatRuntime } from "./runtime.js";
export type GoogleChatRuntimeEnv = {
log?: (message: string) => void;

View File

@@ -8,11 +8,8 @@ const resolveWebhookTargetWithAuthOrReject = vi.hoisted(() => vi.fn());
const withResolvedWebhookRequestPipeline = vi.hoisted(() => vi.fn());
const verifyGoogleChatRequest = vi.hoisted(() => vi.fn());
vi.mock("openclaw/plugin-sdk/webhook-request-guards", () => ({
vi.mock("../runtime-api.js", () => ({
readJsonWebhookBodyOrReject,
}));
vi.mock("openclaw/plugin-sdk/webhook-targets", () => ({
resolveWebhookTargetWithAuthOrReject,
withResolvedWebhookRequestPipeline,
}));

View File

@@ -1,10 +1,11 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { WebhookInFlightLimiter } from "openclaw/plugin-sdk/webhook-request-guards";
import { readJsonWebhookBodyOrReject } from "openclaw/plugin-sdk/webhook-request-guards";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import {
readJsonWebhookBodyOrReject,
resolveWebhookTargetWithAuthOrReject,
withResolvedWebhookRequestPipeline,
} from "openclaw/plugin-sdk/webhook-targets";
type WebhookInFlightLimiter,
} from "../runtime-api.js";
import { verifyGoogleChatRequest } from "./auth.js";
import type { WebhookTarget } from "./monitor-types.js";
import type {
@@ -14,10 +15,6 @@ import type {
GoogleChatUser,
} from "./types.js";
function normalizeLowercaseStringOrEmpty(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
function extractBearerToken(header: unknown): string {
const authHeader = Array.isArray(header)
? typeof header[0] === "string"

View File

@@ -1,3 +1,4 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import {
deliverTextOrMediaReply,
resolveSendableOutboundReplyParts,
@@ -6,6 +7,8 @@ import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runti
import type { OpenClawConfig } from "../runtime-api.js";
import {
createChannelReplyPipeline,
createWebhookInFlightLimiter,
registerWebhookTargetWithPluginRoute,
resolveInboundRouteEnvelopeBuilderWithRuntime,
resolveWebhookPath,
} from "../runtime-api.js";
@@ -18,27 +21,27 @@ import {
} from "./api.js";
import { type GoogleChatAudienceType } from "./auth.js";
import { applyGoogleChatInboundAccessPolicy, isSenderAllowed } from "./monitor-access.js";
import {
handleGoogleChatWebhookRequest,
registerGoogleChatWebhookTarget,
setGoogleChatWebhookEventProcessor,
} from "./monitor-routing.js";
import type {
GoogleChatCoreRuntime,
GoogleChatMonitorOptions,
GoogleChatRuntimeEnv,
WebhookTarget,
} from "./monitor-types.js";
import { createGoogleChatWebhookRequestHandler } from "./monitor-webhook.js";
import { getGoogleChatRuntime } from "./runtime.js";
import type { GoogleChatAttachment, GoogleChatEvent } from "./types.js";
export type { GoogleChatMonitorOptions, GoogleChatRuntimeEnv } from "./monitor-types.js";
export {
handleGoogleChatWebhookRequest,
registerGoogleChatWebhookTarget,
} from "./monitor-routing.js";
export { isSenderAllowed };
setGoogleChatWebhookEventProcessor(processGoogleChatEvent);
const webhookTargets = new Map<string, WebhookTarget[]>();
const webhookInFlightLimiter = createWebhookInFlightLimiter();
const googleChatWebhookRequestHandler = createGoogleChatWebhookRequestHandler({
webhookTargets,
webhookInFlightLimiter,
processEvent: async (event, target) => {
await processGoogleChatEvent(event, target);
},
});
function logVerbose(core: GoogleChatCoreRuntime, runtime: GoogleChatRuntimeEnv, message: string) {
if (core.logging.shouldLogVerbose()) {
@@ -46,6 +49,29 @@ function logVerbose(core: GoogleChatCoreRuntime, runtime: GoogleChatRuntimeEnv,
}
}
export function registerGoogleChatWebhookTarget(target: WebhookTarget): () => void {
return registerWebhookTargetWithPluginRoute({
targetsByPath: webhookTargets,
target,
route: {
auth: "plugin",
match: "exact",
pluginId: "googlechat",
source: "googlechat-webhook",
accountId: target.account.accountId,
log: target.runtime.log,
handler: async (req, res) => {
const handled = await handleGoogleChatWebhookRequest(req, res);
if (!handled && !res.headersSent) {
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Not Found");
}
},
},
}).unregister;
}
function normalizeAudienceType(value?: string | null): GoogleChatAudienceType | undefined {
const normalized = normalizeOptionalLowercaseString(value);
if (normalized === "app-url" || normalized === "app_url" || normalized === "app") {
@@ -61,6 +87,13 @@ function normalizeAudienceType(value?: string | null): GoogleChatAudienceType |
return undefined;
}
export async function handleGoogleChatWebhookRequest(
req: IncomingMessage,
res: ServerResponse,
): Promise<boolean> {
return await googleChatWebhookRequestHandler(req, res);
}
async function processGoogleChatEvent(event: GoogleChatEvent, target: WebhookTarget) {
const eventType = event.type ?? (event as { eventType?: string }).eventType;
if (eventType !== "MESSAGE") {

View File

@@ -7,10 +7,7 @@ import { createMockServerResponse } from "../../../test/helpers/plugins/mock-htt
import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
import { verifyGoogleChatRequest } from "./auth.js";
import {
handleGoogleChatWebhookRequest,
registerGoogleChatWebhookTarget,
} from "./monitor-routing.js";
import { handleGoogleChatWebhookRequest, registerGoogleChatWebhookTarget } from "./monitor.js";
vi.mock("./auth.js", () => ({
verifyGoogleChatRequest: vi.fn(),

View File

@@ -1,48 +0,0 @@
function normalizeLowercaseStringOrEmpty(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
function normalizeUserId(raw?: string | null): string {
const trimmed = typeof raw === "string" ? raw.trim() : "";
if (!trimmed) {
return "";
}
return normalizeLowercaseStringOrEmpty(trimmed.replace(/^users\//i, ""));
}
function isEmailLike(value: string): boolean {
// Keep this intentionally loose; allowlists are user-provided config.
return value.includes("@");
}
export function isSenderAllowed(
senderId: string,
senderEmail: string | undefined,
allowFrom: string[],
allowNameMatching = false,
) {
if (allowFrom.includes("*")) {
return true;
}
const normalizedSenderId = normalizeUserId(senderId);
const normalizedEmail = normalizeLowercaseStringOrEmpty(senderEmail ?? "");
return allowFrom.some((entry) => {
const normalized = normalizeLowercaseStringOrEmpty(entry);
if (!normalized) {
return false;
}
// Accept `googlechat:<id>` but treat `users/...` as an *ID* only (deprecated `users/<email>`).
const withoutPrefix = normalized.replace(/^(googlechat|google-chat|gchat):/i, "");
if (withoutPrefix.startsWith("users/")) {
return normalizeUserId(withoutPrefix) === normalizedSenderId;
}
// Raw email allowlist entries are a break-glass override.
if (allowNameMatching && normalizedEmail && isEmailLike(withoutPrefix)) {
return withoutPrefix === normalizedEmail;
}
return withoutPrefix.replace(/^users\//i, "") === normalizedSenderId;
});
}

View File

@@ -11,6 +11,10 @@ import {
type ChannelSetupDmPolicy,
type ChannelSetupWizard,
} from "openclaw/plugin-sdk/setup";
import {
normalizeOptionalString,
normalizeStringifiedOptionalString,
} from "openclaw/plugin-sdk/text-runtime";
import { resolveDefaultGoogleChatAccountId, resolveGoogleChatAccount } from "./accounts.js";
const channel = "googlechat" as const;
@@ -19,24 +23,6 @@ const ENV_SERVICE_ACCOUNT_FILE = "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE";
const USE_ENV_FLAG = "__googlechatUseEnv";
const AUTH_METHOD_FLAG = "__googlechatAuthMethod";
function normalizeOptionalString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
function normalizeStringifiedOptionalString(value: unknown): string | undefined {
if (typeof value === "string") {
return normalizeOptionalString(value);
}
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
return normalizeOptionalString(String(value));
}
return undefined;
}
const promptAllowFrom = createPromptParsedAllowFromForAccount({
defaultAccountId: resolveDefaultGoogleChatAccountId,
message: "Google Chat allowFrom (users/<id> or raw email; avoid users/<email>)",

View File

@@ -11,14 +11,14 @@ import {
expectLifecyclePatch,
expectPendingUntilAbort,
startAccountAndTrackLifecycle,
waitForStartedMocks,
} from "../../../test/helpers/plugins/start-account-lifecycle.js";
import type { OpenClawConfig } from "../runtime-api.js";
import { resolveGoogleChatAccount, type ResolvedGoogleChatAccount } from "./accounts.js";
import {
listGoogleChatAccountIds,
resolveGoogleChatAccount,
resolveDefaultGoogleChatAccountId,
type ResolvedGoogleChatAccount,
} from "./accounts.js";
} from "./channel.deps.runtime.js";
import { startGoogleChatGatewayAccount } from "./gateway.js";
import { googlechatSetupAdapter } from "./setup-core.js";
import { googlechatSetupWizard } from "./setup-surface.js";
@@ -27,16 +27,13 @@ const hoisted = vi.hoisted(() => ({
startGoogleChatMonitor: vi.fn(),
}));
vi.mock("./channel.runtime.js", () => ({
googleChatChannelRuntime: {
resolveGoogleChatWebhookPath: ({
account,
}: {
account: { config: { webhookPath?: string } };
}) => account.config.webhookPath ?? "/googlechat",
vi.mock("./monitor.js", async () => {
const actual = await vi.importActual<typeof import("./monitor.js")>("./monitor.js");
return {
...actual,
startGoogleChatMonitor: hoisted.startGoogleChatMonitor,
},
}));
};
});
const googlechatSetupPlugin = {
id: "googlechat",
@@ -68,16 +65,6 @@ function buildAccount(): ResolvedGoogleChatAccount {
};
}
async function waitForGoogleChatMonitorStarted() {
for (let attempt = 0; attempt < 10; attempt += 1) {
if (hoisted.startGoogleChatMonitor.mock.calls.length === 1) {
return;
}
await new Promise<void>((resolve) => setImmediate(resolve));
}
expect(hoisted.startGoogleChatMonitor).toHaveBeenCalledOnce();
}
describe("googlechat setup", () => {
afterEach(() => {
vi.clearAllMocks();
@@ -369,7 +356,7 @@ describe("googlechat setup", () => {
account: buildAccount(),
});
await expectPendingUntilAbort({
waitForStarted: waitForGoogleChatMonitorStarted,
waitForStarted: waitForStartedMocks(hoisted.startGoogleChatMonitor),
isSettled,
abort,
task,

View File

@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
import { downloadGoogleChatMedia, sendGoogleChatMessage } from "./api.js";
import { resolveGoogleChatGroupRequireMention } from "./group-policy.js";
import { isSenderAllowed } from "./sender-allow.js";
import { isSenderAllowed } from "./monitor.js";
import {
isGoogleChatSpaceTarget,
isGoogleChatUserTarget,
@@ -14,13 +14,14 @@ const mocks = vi.hoisted(() => ({
response: await fetch(params.url, params.init),
release: async () => {},
})),
verifySignedJwtWithCertsAsync: vi.fn(),
verifyIdToken: vi.fn(),
getGoogleChatAccessToken: vi.fn().mockResolvedValue("token"),
}));
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => {
vi.mock("../runtime-api.js", async () => {
const actual = await vi.importActual<typeof import("../runtime-api.js")>("../runtime-api.js");
return {
...actual,
fetchWithSsrFGuard: mocks.fetchWithSsrFGuard,
};
});
@@ -29,7 +30,6 @@ vi.mock("google-auth-library", () => ({
GoogleAuth: function GoogleAuth() {},
OAuth2Client: class {
verifyIdToken = mocks.verifyIdToken;
verifySignedJwtWithCertsAsync = mocks.verifySignedJwtWithCertsAsync;
},
}));
@@ -295,34 +295,4 @@ describe("verifyGoogleChatRequest", () => {
reason: "unexpected add-on principal: principal-2",
});
});
it("fetches Chat certs through the guarded fetch for project-number tokens", async () => {
const release = vi.fn();
mocks.fetchWithSsrFGuard.mockClear();
mocks.fetchWithSsrFGuard.mockResolvedValueOnce({
response: new Response(JSON.stringify({ "kid-1": "cert-body" }), { status: 200 }),
release,
});
mocks.verifySignedJwtWithCertsAsync.mockReset().mockResolvedValue(undefined);
await expect(
verifyGoogleChatRequest({
bearer: "token",
audienceType: "project-number",
audience: "123456789",
}),
).resolves.toEqual({ ok: true });
expect(mocks.fetchWithSsrFGuard).toHaveBeenCalledWith({
url: "https://www.googleapis.com/service_accounts/v1/metadata/x509/chat@system.gserviceaccount.com",
auditContext: "googlechat.auth.certs",
});
expect(mocks.verifySignedJwtWithCertsAsync).toHaveBeenCalledWith(
"token",
{ "kid-1": "cert-body" },
"123456789",
["chat@system.gserviceaccount.com"],
);
expect(release).toHaveBeenCalledOnce();
});
});

View File

@@ -1,10 +1,7 @@
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
import { findGoogleChatDirectMessage } from "./api.js";
function normalizeLowercaseStringOrEmpty(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
export function normalizeGoogleChatTarget(raw?: string | null): string | undefined {
const trimmed = raw?.trim();
if (!trimmed) {

View File

@@ -1,11 +1,9 @@
export {
resolveIMessageAttachmentRoots as resolveInboundAttachmentRoots,
resolveIMessageRemoteAttachmentRoots as resolveRemoteInboundAttachmentRoots,
} from "./src/media-contract.js";
export {
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
resolveIMessageAttachmentRoots as resolveInboundAttachmentRoots,
resolveIMessageAttachmentRoots,
resolveIMessageRemoteAttachmentRoots as resolveRemoteInboundAttachmentRoots,
resolveIMessageRemoteAttachmentRoots,
} from "./media-contract-api.js";
export {
__testing as imessageConversationBindingTesting,
createIMessageConversationBindingManager,
} from "./src/conversation-bindings.js";
} from "./src/media-contract.js";

View File

@@ -1,7 +0,0 @@
export {
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
resolveIMessageAttachmentRoots,
resolveIMessageAttachmentRoots as resolveInboundAttachmentRoots,
resolveIMessageRemoteAttachmentRoots,
resolveIMessageRemoteAttachmentRoots as resolveRemoteInboundAttachmentRoots,
} from "./src/media-contract.js";

View File

@@ -8,7 +8,6 @@ import {
type ProviderRuntimeModel,
} from "openclaw/plugin-sdk/plugin-entry";
import { CUSTOM_LOCAL_AUTH_MARKER } from "openclaw/plugin-sdk/provider-auth";
import { lmstudioMemoryEmbeddingProviderAdapter } from "./memory-embedding-adapter.js";
import {
LMSTUDIO_DEFAULT_API_KEY_ENV_VAR,
LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER,
@@ -53,7 +52,6 @@ export default definePluginEntry({
name: "LM Studio Provider",
description: "Bundled LM Studio provider plugin",
register(api: OpenClawPluginApi) {
api.registerMemoryEmbeddingProvider(lmstudioMemoryEmbeddingProviderAdapter);
api.registerProvider({
id: PROVIDER_ID,
label: "LM Studio",

View File

@@ -1,35 +0,0 @@
import {
sanitizeEmbeddingCacheHeaders,
type MemoryEmbeddingProviderAdapter,
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import {
createLmstudioEmbeddingProvider,
DEFAULT_LMSTUDIO_EMBEDDING_MODEL,
} from "./src/embedding-provider.js";
export const lmstudioMemoryEmbeddingProviderAdapter: MemoryEmbeddingProviderAdapter = {
id: "lmstudio",
defaultModel: DEFAULT_LMSTUDIO_EMBEDDING_MODEL,
transport: "remote",
authProviderId: "lmstudio",
allowExplicitWhenConfiguredAuto: true,
create: async (options) => {
const { provider, client } = await createLmstudioEmbeddingProvider({
...options,
provider: "lmstudio",
fallback: "none",
});
return {
provider,
runtime: {
id: "lmstudio",
cacheKeyData: {
provider: "lmstudio",
baseUrl: client.baseUrl,
model: client.model,
headers: sanitizeEmbeddingCacheHeaders(client.headers, ["authorization"]),
},
},
};
},
};

View File

@@ -21,9 +21,6 @@
"groupHint": "Self-hosted open-weight models"
}
],
"contracts": {
"memoryEmbeddingProviders": ["lmstudio"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@@ -1,7 +1,10 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import { createAssistantMessageEventStream } from "@mariozechner/pi-ai";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { __resetLmstudioPreloadCooldownForTest, wrapLmstudioInferencePreload } from "./stream.js";
import {
__resetLmstudioPreloadCooldownForTest,
wrapLmstudioInferencePreload,
} from "./stream.js";
const ensureLmstudioModelLoadedMock = vi.hoisted(() => vi.fn());
const resolveLmstudioProviderHeadersMock = vi.hoisted(() =>

View File

@@ -241,7 +241,10 @@ export function wrapLmstudioInferencePreload(ctx: ProviderWrapStreamFnContext):
};
const cause = annotated.cause ?? error;
const failures = annotated.consecutiveFailures ?? 1;
const cooldownSec = Math.max(0, Math.round((annotated.cooldownMs ?? 0) / 1000));
const cooldownSec = Math.max(
0,
Math.round((annotated.cooldownMs ?? 0) / 1000),
);
log.warn(
`LM Studio inference preload failed for "${modelKey}" (${failures} consecutive failure${
failures === 1 ? "" : "s"

View File

@@ -1,29 +1,18 @@
import { describe, expect, it, vi } from "vitest";
import { createTestPluginApi } from "../../test/helpers/plugins/plugin-api.js";
import { registerMatrixCliMetadata } from "./cli-metadata.js";
import entry, { registerMatrixFullRuntime } from "./index.js";
import entry from "./index.js";
const cliMocks = vi.hoisted(() => ({
registerMatrixCli: vi.fn(),
}));
const runtimeMocks = vi.hoisted(() => ({
ensureMatrixCryptoRuntime: vi.fn(async () => {}),
handleVerificationBootstrap: vi.fn(async () => {}),
handleVerificationStatus: vi.fn(async () => {}),
handleVerifyRecoveryKey: vi.fn(async () => {}),
setMatrixRuntime: vi.fn(),
}));
vi.mock("./src/cli.js", () => {
return {
registerMatrixCli: cliMocks.registerMatrixCli,
};
});
vi.mock("./plugin-entry.handlers.runtime.js", () => runtimeMocks);
vi.mock("./runtime-api.js", () => ({ setMatrixRuntime: runtimeMocks.setMatrixRuntime }));
describe("matrix plugin", () => {
it("registers matrix CLI through a descriptor-backed lazy registrar", async () => {
const registerCli = vi.fn();
@@ -67,30 +56,4 @@ describe("matrix plugin", () => {
expect(entry.id).toBe("matrix");
expect(entry.name).toBe("Matrix");
});
it("registers subagent lifecycle hooks during full runtime registration", () => {
const on = vi.fn();
const registerGatewayMethod = vi.fn();
const api = createTestPluginApi({
id: "matrix",
name: "Matrix",
source: "test",
config: {},
runtime: {} as never,
registrationMode: "full",
on,
registerGatewayMethod,
});
registerMatrixFullRuntime(api);
expect(on.mock.calls.map(([hookName]) => hookName)).toEqual([
"subagent_spawning",
"subagent_ended",
"subagent_delivery_target",
]);
for (const [, handler] of on.mock.calls) {
expect(handler).toEqual(expect.any(Function));
}
});
});

View File

@@ -1,61 +1,7 @@
import {
defineBundledChannelEntry,
type OpenClawPluginApi,
} from "openclaw/plugin-sdk/channel-entry-contract";
import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { registerMatrixCliMetadata } from "./cli-metadata.js";
type MatrixSubagentHooksModule = typeof import("./src/matrix/subagent-hooks.js");
let matrixSubagentHooksPromise: Promise<MatrixSubagentHooksModule> | null = null;
function loadMatrixSubagentHooksModule() {
matrixSubagentHooksPromise ??= import("./src/matrix/subagent-hooks.js");
return matrixSubagentHooksPromise;
}
export function registerMatrixFullRuntime(api: OpenClawPluginApi): void {
void import("./plugin-entry.handlers.runtime.js")
.then(({ ensureMatrixCryptoRuntime }) =>
ensureMatrixCryptoRuntime({ log: api.logger.info }).catch((err: unknown) => {
const message = formatErrorMessage(err);
api.logger.warn?.(`matrix: crypto runtime bootstrap failed: ${message}`);
}),
)
.catch((err: unknown) => {
const message = formatErrorMessage(err);
api.logger.warn?.(`matrix: failed loading crypto bootstrap runtime: ${message}`);
});
api.registerGatewayMethod("matrix.verify.recoveryKey", async (ctx) => {
const { handleVerifyRecoveryKey } = await import("./plugin-entry.handlers.runtime.js");
await handleVerifyRecoveryKey(ctx);
});
api.registerGatewayMethod("matrix.verify.bootstrap", async (ctx) => {
const { handleVerificationBootstrap } = await import("./plugin-entry.handlers.runtime.js");
await handleVerificationBootstrap(ctx);
});
api.registerGatewayMethod("matrix.verify.status", async (ctx) => {
const { handleVerificationStatus } = await import("./plugin-entry.handlers.runtime.js");
await handleVerificationStatus(ctx);
});
api.on("subagent_spawning", async (event) => {
const { handleMatrixSubagentSpawning } = await loadMatrixSubagentHooksModule();
return await handleMatrixSubagentSpawning(api, event);
});
api.on("subagent_ended", async (event) => {
const { handleMatrixSubagentEnded } = await loadMatrixSubagentHooksModule();
await handleMatrixSubagentEnded(event);
});
api.on("subagent_delivery_target", async (event) => {
const { handleMatrixSubagentDeliveryTarget } = await loadMatrixSubagentHooksModule();
return handleMatrixSubagentDeliveryTarget(event);
});
}
export default defineBundledChannelEntry({
id: "matrix",
name: "Matrix",
@@ -74,5 +20,32 @@ export default defineBundledChannelEntry({
exportName: "setMatrixRuntime",
},
registerCliMetadata: registerMatrixCliMetadata,
registerFull: registerMatrixFullRuntime,
registerFull(api) {
void import("./plugin-entry.handlers.runtime.js")
.then(({ ensureMatrixCryptoRuntime }) =>
ensureMatrixCryptoRuntime({ log: api.logger.info }).catch((err: unknown) => {
const message = formatErrorMessage(err);
api.logger.warn?.(`matrix: crypto runtime bootstrap failed: ${message}`);
}),
)
.catch((err: unknown) => {
const message = formatErrorMessage(err);
api.logger.warn?.(`matrix: failed loading crypto bootstrap runtime: ${message}`);
});
api.registerGatewayMethod("matrix.verify.recoveryKey", async (ctx) => {
const { handleVerifyRecoveryKey } = await import("./plugin-entry.handlers.runtime.js");
await handleVerifyRecoveryKey(ctx);
});
api.registerGatewayMethod("matrix.verify.bootstrap", async (ctx) => {
const { handleVerificationBootstrap } = await import("./plugin-entry.handlers.runtime.js");
await handleVerificationBootstrap(ctx);
});
api.registerGatewayMethod("matrix.verify.status", async (ctx) => {
const { handleVerificationStatus } = await import("./plugin-entry.handlers.runtime.js");
await handleVerificationStatus(ctx);
});
},
});

View File

@@ -10,8 +10,8 @@ import {
normalizeOptionalAccountId,
} from "openclaw/plugin-sdk/account-id";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import {
resolveMatrixAccountStringValues,
type MatrixResolvedStringField,

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