mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-07 06:21:32 +08:00
Compare commits
123 Commits
fix/codeql
...
codex/work
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36a7c93389 | ||
|
|
7b27d08e56 | ||
|
|
8de7aefe0a | ||
|
|
d6c90b5af1 | ||
|
|
2535331e94 | ||
|
|
acace04c35 | ||
|
|
0b3d876e74 | ||
|
|
d565c2cc34 | ||
|
|
5f3bb53788 | ||
|
|
a954e2fb46 | ||
|
|
b2b835fb18 | ||
|
|
62703d8430 | ||
|
|
4d7d14cfa7 | ||
|
|
bac3d26fe7 | ||
|
|
2a0a498b0d | ||
|
|
3b81bf4c7c | ||
|
|
40c9da1d57 | ||
|
|
48aa076d12 | ||
|
|
310b5e4f6a | ||
|
|
418056f7a0 | ||
|
|
af954a81d1 | ||
|
|
605cb60586 | ||
|
|
a861da41b5 | ||
|
|
199bb1fe05 | ||
|
|
d3e12cee7e | ||
|
|
d3b70f9823 | ||
|
|
f7f88e52e4 | ||
|
|
675eb38ad0 | ||
|
|
a90daa5759 | ||
|
|
e477125608 | ||
|
|
7995d43625 | ||
|
|
68cf9e52a2 | ||
|
|
e53a8bd865 | ||
|
|
290371399f | ||
|
|
e2099301c5 | ||
|
|
f810cc4d58 | ||
|
|
efb37f8949 | ||
|
|
c93b2540ec | ||
|
|
ab726235bd | ||
|
|
824b5e4d91 | ||
|
|
bb70b41340 | ||
|
|
a9fab78f64 | ||
|
|
e4f04d92a3 | ||
|
|
24ef516879 | ||
|
|
2e3ef1b9e1 | ||
|
|
4ac8b08265 | ||
|
|
e00f9c7a9d | ||
|
|
e19e94ef07 | ||
|
|
cfba24fa3c | ||
|
|
8ebb3ff0d4 | ||
|
|
4451e8479a | ||
|
|
271fc360e7 | ||
|
|
82355d1d9f | ||
|
|
769a09842d | ||
|
|
82fe6f50ef | ||
|
|
a45ebf3281 | ||
|
|
be7a415eb0 | ||
|
|
f377db1015 | ||
|
|
0b6c39be18 | ||
|
|
3ea1bf4232 | ||
|
|
54e4e16844 | ||
|
|
00951dc9f9 | ||
|
|
82b529a6d9 | ||
|
|
5775fe272a | ||
|
|
8e79080bef | ||
|
|
7d4f1a6777 | ||
|
|
89706d323c | ||
|
|
e4c4f955b3 | ||
|
|
74c198f2e8 | ||
|
|
0ee5baf6c5 | ||
|
|
1ffc02e930 | ||
|
|
1ce2596195 | ||
|
|
857b9cd326 | ||
|
|
9d5ab4a54c | ||
|
|
299694d721 | ||
|
|
2713089220 | ||
|
|
b945248650 | ||
|
|
b1a3ad49a4 | ||
|
|
c66f16ac55 | ||
|
|
92859357bb | ||
|
|
dd9d2ebd01 | ||
|
|
5817a76236 | ||
|
|
a0d9598425 | ||
|
|
daaebb8558 | ||
|
|
f57ce21d73 | ||
|
|
b71c91022b | ||
|
|
d07c921ae3 | ||
|
|
132d3c76a0 | ||
|
|
35dcd06764 | ||
|
|
7ae670e501 | ||
|
|
878f2122e5 | ||
|
|
807c6648f9 | ||
|
|
178c36532d | ||
|
|
26f7198eda | ||
|
|
bec52e5f7e | ||
|
|
5aad79571e | ||
|
|
671579663b | ||
|
|
7b0e950e09 | ||
|
|
8205de84a9 | ||
|
|
c65f356ddc | ||
|
|
7e18c07e41 | ||
|
|
3fe8b24c4e | ||
|
|
c95507978f | ||
|
|
59d07f0ab4 | ||
|
|
5c1d6feb33 | ||
|
|
e8fd148437 | ||
|
|
2a283e87a7 | ||
|
|
15b2827fc1 | ||
|
|
65645ec54f | ||
|
|
e8ae3901b6 | ||
|
|
8e444ac5a6 | ||
|
|
6b45ba88a1 | ||
|
|
353950894a | ||
|
|
9da4d5f5df | ||
|
|
c6af0437c9 | ||
|
|
a2f2e5738e | ||
|
|
35fb3f7e1c | ||
|
|
a189394590 | ||
|
|
685f9903ec | ||
|
|
24431e5114 | ||
|
|
ee856ab31f | ||
|
|
acd86a06cd | ||
|
|
77e6e4cf87 |
@@ -52,7 +52,11 @@ 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) {
|
||||
@@ -73,9 +77,7 @@ 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) {
|
||||
@@ -169,9 +171,13 @@ 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;
|
||||
@@ -189,9 +195,7 @@ 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 } } }`,
|
||||
);
|
||||
@@ -261,7 +265,10 @@ 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}`,
|
||||
|
||||
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
@@ -677,9 +677,10 @@ 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"
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
|
||||
git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA"
|
||||
git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA" || return 1
|
||||
test -f "$workdir/.github/actions/setup-node-env/action.yml" || return 1
|
||||
echo "checkout attempt ${attempt}/2 succeeded"
|
||||
}
|
||||
|
||||
|
||||
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
@@ -1,12 +1,6 @@
|
||||
name: CodeQL
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- "**/*.md"
|
||||
- "**/*.mdx"
|
||||
- "LICENSE"
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 6 * * *"
|
||||
|
||||
36
AGENTS.md
36
AGENTS.md
@@ -1,12 +1,12 @@
|
||||
# Repository Guidelines
|
||||
|
||||
- Repo: https://github.com/openclaw/openclaw
|
||||
- In chat replies, file references must be repo-root relative only (example: `src/telegram/index.ts:80`); never absolute paths or `~/...`.
|
||||
- In chat replies, file references must be repo-root relative only (example: `extensions/telegram/src/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`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
|
||||
- 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`).
|
||||
- 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/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)
|
||||
- Core channel code: `src/channels`, `src/routing`, `src/web`
|
||||
- Bundled plugin channels: `extensions/<channel>/` (for example Discord, Telegram, Slack, 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-artifacts.ts`
|
||||
- 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`
|
||||
- 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,6 +87,8 @@
|
||||
- `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.
|
||||
@@ -117,8 +119,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 repo’s 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: `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.
|
||||
- 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.
|
||||
- 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`.
|
||||
@@ -128,8 +130,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` (oxfmt --check)
|
||||
- Format fix: `pnpm format:fix` (oxfmt --write)
|
||||
- Format check: `pnpm format:check` (oxfmt --check)
|
||||
- Format fix: `pnpm format` or `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.
|
||||
@@ -137,8 +139,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 `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 hook’s repo-wide `pnpm format` and `pnpm check`; use that only when you are deliberately covering the touched surface some other way.
|
||||
- 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 hook’s repo-wide `pnpm check`; targeted formatting/linting still runs, so 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`.
|
||||
@@ -215,6 +217,8 @@
|
||||
- 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.
|
||||
@@ -250,8 +254,8 @@
|
||||
|
||||
## Security & Configuration Tips
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -268,13 +272,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); don’t hand-roll spinners/bars.
|
||||
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
|
||||
- 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.**
|
||||
- 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.**
|
||||
- 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`; don’t 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/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).
|
||||
- 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.
|
||||
- **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.
|
||||
|
||||
19
CHANGELOG.md
19
CHANGELOG.md
@@ -4,9 +4,27 @@ 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.
|
||||
|
||||
## 2026.4.15
|
||||
|
||||
@@ -113,6 +131,7 @@ 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
|
||||
|
||||
|
||||
@@ -146,6 +146,7 @@ final class MacNodeModeCoordinator {
|
||||
OpenClawCanvasA2UICommand.push.rawValue,
|
||||
OpenClawCanvasA2UICommand.pushJSONL.rawValue,
|
||||
OpenClawCanvasA2UICommand.reset.rawValue,
|
||||
MacNodeScreenCommand.snapshot.rawValue,
|
||||
MacNodeScreenCommand.record.rawValue,
|
||||
OpenClawSystemCommand.notify.rawValue,
|
||||
OpenClawSystemCommand.which.rawValue,
|
||||
|
||||
@@ -63,6 +63,8 @@ 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:
|
||||
@@ -352,6 +354,34 @@ 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()
|
||||
|
||||
@@ -4,6 +4,13 @@ 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?,
|
||||
@@ -21,9 +28,24 @@ 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?,
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
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?
|
||||
|
||||
109
apps/macos/Sources/OpenClaw/ScreenSnapshotService.swift
Normal file
109
apps/macos/Sources/OpenClaw/ScreenSnapshotService.swift
Normal file
@@ -0,0 +1,109 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,19 @@ 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?,
|
||||
@@ -127,6 +140,94 @@ 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)
|
||||
|
||||
@@ -444,34 +444,18 @@ private struct ChatComposerTextView: NSViewRepresentable {
|
||||
func makeCoordinator() -> Coordinator { Coordinator(self) }
|
||||
|
||||
func makeNSView(context: Context) -> NSScrollView {
|
||||
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
|
||||
let textView = ChatComposerTextViewFactory.makeConfiguredTextView()
|
||||
guard let composerTextView = textView as? ChatComposerNSTextView else {
|
||||
preconditionFailure("ChatComposerTextViewFactory must return ChatComposerNSTextView")
|
||||
}
|
||||
composerTextView.delegate = context.coordinator
|
||||
|
||||
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)
|
||||
composerTextView.string = self.text
|
||||
composerTextView.onSend = { [weak composerTextView] in
|
||||
composerTextView?.window?.makeFirstResponder(nil)
|
||||
self.onSend()
|
||||
}
|
||||
textView.onPasteImageAttachment = self.onPasteImageAttachment
|
||||
composerTextView.onPasteImageAttachment = self.onPasteImageAttachment
|
||||
|
||||
let scroll = NSScrollView()
|
||||
scroll.drawsBackground = false
|
||||
@@ -522,6 +506,34 @@ 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)?
|
||||
|
||||
@@ -1,9 +1,34 @@
|
||||
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?
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
#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
|
||||
@@ -1,2 +1,2 @@
|
||||
9683f324fae8f455f2b64d7e152a77009941e4c7558521bca2510d8bcf573af9 plugin-sdk-api-baseline.json
|
||||
097bf226e4e857e9296d0851852a2963c6263d176c4c470452d9a8efd36988e5 plugin-sdk-api-baseline.jsonl
|
||||
e3df4c13b4dcdc07809775c56eed15c3ab924db191a08fb5a7b48d6f73001966 plugin-sdk-api-baseline.json
|
||||
2bb30ad45d5b382e92fd6b8a240a47f7679c59f9b524e54420879fadc28264b8 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -120,7 +120,7 @@ can write back through the mounted workspace.
|
||||
Seed assets live in `qa/`:
|
||||
|
||||
- `qa/scenarios/index.md`
|
||||
- `qa/scenarios/*.md`
|
||||
- `qa/scenarios/<theme>/*.md`
|
||||
|
||||
These are intentionally in git so the QA plan is visible to both humans and the
|
||||
agent.
|
||||
@@ -129,6 +129,7 @@ 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
|
||||
@@ -139,6 +140,10 @@ 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
|
||||
|
||||
@@ -89,7 +89,21 @@ Gateway → Client:
|
||||
```
|
||||
|
||||
`server`, `features`, `snapshot`, and `policy` are all required by the schema
|
||||
(`src/gateway/protocol/schema/frames.ts`). `auth` and `canvasHostUrl` are optional.
|
||||
(`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"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When a device token is issued, `hello-ok` also includes:
|
||||
|
||||
|
||||
@@ -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 `qa/scenarios/`.
|
||||
5. Author or adapt markdown scenarios under the themed `qa/scenarios/` directories.
|
||||
6. Use the generic scenario helpers for new scenarios.
|
||||
7. Keep existing compatibility aliases working unless the repo is doing an intentional migration.
|
||||
|
||||
|
||||
@@ -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.record`
|
||||
- Screen: `screen.snapshot`, `screen.record`
|
||||
- System: `system.run`, `system.notify`
|
||||
|
||||
The node reports a `permissions` map so agents can decide what’s allowed.
|
||||
|
||||
@@ -185,7 +185,9 @@ Keep inbound mention handling split in two layers:
|
||||
- plugin-owned evidence gathering
|
||||
- shared policy evaluation
|
||||
|
||||
Use `openclaw/plugin-sdk/channel-inbound` for the shared layer.
|
||||
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.
|
||||
|
||||
Good fit for plugin-local logic:
|
||||
|
||||
@@ -255,6 +257,11 @@ 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 })`.
|
||||
|
||||
@@ -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 host embedding 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-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 |
|
||||
|
||||
@@ -88,6 +88,7 @@ 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 |
|
||||
@@ -95,6 +96,7 @@ 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 |
|
||||
@@ -108,7 +110,10 @@ 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` | Inbound debounce, mention matching, mention-policy helpers, and envelope 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-send-result` | Reply result types |
|
||||
| `plugin-sdk/channel-actions` | `createMessageToolButtonsSchema`, `createMessageToolCardSchema` |
|
||||
| `plugin-sdk/channel-targets` | Target parsing/matching helpers |
|
||||
@@ -166,6 +171,7 @@ 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 |
|
||||
@@ -187,6 +193,7 @@ 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 |
|
||||
@@ -211,6 +218,7 @@ 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 |
|
||||
@@ -226,6 +234,12 @@ 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 |
|
||||
@@ -264,7 +278,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 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-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 |
|
||||
|
||||
@@ -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/*.md`.
|
||||
scenario under `qa/scenarios/<theme>/*.md`.
|
||||
|
||||
Implemented:
|
||||
|
||||
@@ -26,7 +26,7 @@ Implemented:
|
||||
- canonical QA pack metadata
|
||||
- operator identity
|
||||
- kickoff mission
|
||||
- `qa/scenarios/*.md`
|
||||
- `qa/scenarios/<theme>/*.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/*.md` as the authored source of
|
||||
truth.
|
||||
Use `qa/scenarios/index.md` plus `qa/scenarios/<theme>/*.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/*.md`
|
||||
- split scenarios into `qa/scenarios/<theme>/*.md`
|
||||
- added parser for named markdown YAML pack content
|
||||
- validated with zod
|
||||
- switched consumers to the parsed pack
|
||||
|
||||
@@ -82,7 +82,10 @@ 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 {
|
||||
|
||||
@@ -60,6 +60,10 @@ 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
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ describe("active-memory plugin", () => {
|
||||
runEmbeddedPiAgent.mockResolvedValue({
|
||||
payloads: [{ text: "- lemon pepper wings\n- blue cheese" }],
|
||||
});
|
||||
await plugin.register(api as unknown as OpenClawPluginApi);
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -425,7 +425,7 @@ describe("active-memory plugin", () => {
|
||||
agents: ["main"],
|
||||
allowedChatTypes: ["direct", "group"],
|
||||
};
|
||||
await plugin.register(api as unknown as OpenClawPluginApi);
|
||||
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",
|
||||
},
|
||||
};
|
||||
await plugin.register(api as unknown as OpenClawPluginApi);
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
{
|
||||
@@ -602,7 +602,7 @@ describe("active-memory plugin", () => {
|
||||
agents: ["main"],
|
||||
queryMode: "message",
|
||||
};
|
||||
await plugin.register(api as unknown as OpenClawPluginApi);
|
||||
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",
|
||||
};
|
||||
await plugin.register(api as unknown as OpenClawPluginApi);
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
{
|
||||
@@ -675,7 +675,7 @@ describe("active-memory plugin", () => {
|
||||
agents: ["main"],
|
||||
thinking: "medium",
|
||||
};
|
||||
await plugin.register(api as unknown as OpenClawPluginApi);
|
||||
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.",
|
||||
};
|
||||
await plugin.register(api as unknown as OpenClawPluginApi);
|
||||
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.",
|
||||
};
|
||||
await plugin.register(api as unknown as OpenClawPluginApi);
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
{
|
||||
@@ -802,7 +802,7 @@ describe("active-memory plugin", () => {
|
||||
api.pluginConfig = {
|
||||
agents: ["main"],
|
||||
};
|
||||
await plugin.register(api as unknown as OpenClawPluginApi);
|
||||
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",
|
||||
};
|
||||
await plugin.register(api as unknown as OpenClawPluginApi);
|
||||
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",
|
||||
};
|
||||
await plugin.register(api as unknown as OpenClawPluginApi);
|
||||
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",
|
||||
};
|
||||
await plugin.register(api as unknown as OpenClawPluginApi);
|
||||
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,
|
||||
};
|
||||
await plugin.register(api as unknown as OpenClawPluginApi);
|
||||
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,
|
||||
};
|
||||
await plugin.register(api as unknown as OpenClawPluginApi);
|
||||
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,
|
||||
};
|
||||
await plugin.register(api as unknown as OpenClawPluginApi);
|
||||
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,
|
||||
};
|
||||
await plugin.register(api as unknown as OpenClawPluginApi);
|
||||
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,
|
||||
};
|
||||
await plugin.register(api as unknown as OpenClawPluginApi);
|
||||
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",
|
||||
};
|
||||
await plugin.register(api as unknown as OpenClawPluginApi);
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
{
|
||||
@@ -1451,7 +1451,7 @@ describe("active-memory plugin", () => {
|
||||
agents: ["main"],
|
||||
queryMode: "full",
|
||||
};
|
||||
await plugin.register(api as unknown as OpenClawPluginApi);
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
{
|
||||
@@ -1482,7 +1482,7 @@ describe("active-memory plugin", () => {
|
||||
agents: ["main"],
|
||||
queryMode: "recent",
|
||||
};
|
||||
await plugin.register(api as unknown as OpenClawPluginApi);
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
{
|
||||
@@ -1536,7 +1536,7 @@ describe("active-memory plugin", () => {
|
||||
agents: ["main"],
|
||||
queryMode: "recent",
|
||||
};
|
||||
await plugin.register(api as unknown as OpenClawPluginApi);
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
{
|
||||
@@ -1578,7 +1578,7 @@ describe("active-memory plugin", () => {
|
||||
agents: ["main"],
|
||||
queryMode: "recent",
|
||||
};
|
||||
await plugin.register(api as unknown as OpenClawPluginApi);
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
{
|
||||
@@ -1611,7 +1611,7 @@ describe("active-memory plugin", () => {
|
||||
agents: ["main"],
|
||||
queryMode: "recent",
|
||||
};
|
||||
await plugin.register(api as unknown as OpenClawPluginApi);
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
{
|
||||
@@ -1619,8 +1619,7 @@ 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",
|
||||
@@ -1674,7 +1673,7 @@ describe("active-memory plugin", () => {
|
||||
agents: ["main"],
|
||||
maxSummaryChars: 40,
|
||||
};
|
||||
await plugin.register(api as unknown as OpenClawPluginApi);
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
runEmbeddedPiAgent.mockResolvedValueOnce({
|
||||
payloads: [
|
||||
{
|
||||
@@ -1708,7 +1707,7 @@ describe("active-memory plugin", () => {
|
||||
agents: ["main"],
|
||||
maxSummaryChars: 90,
|
||||
};
|
||||
await plugin.register(api as unknown as OpenClawPluginApi);
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should i order? prompt-count-check", messages: [] },
|
||||
@@ -1758,7 +1757,7 @@ describe("active-memory plugin", () => {
|
||||
transcriptDir: "active-memory-subagents",
|
||||
logging: true,
|
||||
};
|
||||
await plugin.register(api as unknown as OpenClawPluginApi);
|
||||
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);
|
||||
@@ -1802,7 +1801,7 @@ describe("active-memory plugin", () => {
|
||||
transcriptDir: "C:/temp/escape",
|
||||
logging: true,
|
||||
};
|
||||
await plugin.register(api as unknown as OpenClawPluginApi);
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
const mkdirSpy = vi.spyOn(fs, "mkdir").mockResolvedValue(undefined);
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
@@ -1839,7 +1838,7 @@ describe("active-memory plugin", () => {
|
||||
transcriptDir: "active-memory-subagents",
|
||||
logging: true,
|
||||
};
|
||||
await plugin.register(api as unknown as OpenClawPluginApi);
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
const mkdirSpy = vi.spyOn(fs, "mkdir").mockResolvedValue(undefined);
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
@@ -1906,7 +1905,7 @@ describe("active-memory plugin", () => {
|
||||
agents: ["main"],
|
||||
logging: true,
|
||||
};
|
||||
await plugin.register(api as unknown as OpenClawPluginApi);
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
|
||||
for (let index = 0; index <= 1000; index += 1) {
|
||||
await hooks.before_prompt_build(
|
||||
|
||||
@@ -1527,7 +1527,9 @@ 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;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
|
||||
import { sanitizeAndNormalizeEmbedding } from "./embedding-vectors.js";
|
||||
import { debugEmbeddingsLog } from "./embeddings-debug.js";
|
||||
import type { EmbeddingProvider, EmbeddingProviderOptions } from "./embeddings.types.js";
|
||||
import {
|
||||
debugEmbeddingsLog,
|
||||
sanitizeAndNormalizeEmbedding,
|
||||
type MemoryEmbeddingProvider,
|
||||
type MemoryEmbeddingProviderCreateOptions,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types & constants
|
||||
@@ -254,8 +257,8 @@ function parseCohereBatch(family: Family, raw: string): number[][] {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function createBedrockEmbeddingProvider(
|
||||
options: EmbeddingProviderOptions,
|
||||
): Promise<{ provider: EmbeddingProvider; client: BedrockEmbeddingClient }> {
|
||||
options: MemoryEmbeddingProviderCreateOptions,
|
||||
): Promise<{ provider: MemoryEmbeddingProvider; client: BedrockEmbeddingClient }> {
|
||||
const client = resolveBedrockEmbeddingClient(options);
|
||||
const { BedrockRuntimeClient, InvokeModelCommand } = await loadSdk();
|
||||
const sdk = new BedrockRuntimeClient({ region: client.region });
|
||||
@@ -333,7 +336,7 @@ export async function createBedrockEmbeddingProvider(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function resolveBedrockEmbeddingClient(
|
||||
options: EmbeddingProviderOptions,
|
||||
options: MemoryEmbeddingProviderCreateOptions,
|
||||
): BedrockEmbeddingClient {
|
||||
const model = normalizeBedrockEmbeddingModel(options.model);
|
||||
const spec = resolveSpec(model);
|
||||
37
extensions/amazon-bedrock/memory-embedding-adapter.ts
Normal file
37
extensions/amazon-bedrock/memory-embedding-adapter.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -2,6 +2,9 @@
|
||||
"id": "amazon-bedrock",
|
||||
"enabledByDefault": true,
|
||||
"providers": ["amazon-bedrock"],
|
||||
"contracts": {
|
||||
"memoryEmbeddingProviders": ["bedrock"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
"description": "OpenClaw Amazon Bedrock provider plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-bedrock": "3.1028.0"
|
||||
"@aws-sdk/client-bedrock": "3.1028.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "3.1028.0",
|
||||
"@aws-sdk/credential-provider-node": "3.972.30"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
resolveBedrockConfigApiKey,
|
||||
resolveImplicitBedrockProvider,
|
||||
} from "./api.js";
|
||||
import { bedrockMemoryEmbeddingProviderAdapter } from "./memory-embedding-adapter.js";
|
||||
|
||||
type GuardrailConfig = {
|
||||
guardrailIdentifier: string;
|
||||
@@ -78,6 +79,8 @@ 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);
|
||||
|
||||
|
||||
@@ -2,3 +2,7 @@ export {
|
||||
collectRuntimeConfigAssignments,
|
||||
secretTargetRegistryEntries,
|
||||
} from "./src/secret-contract.js";
|
||||
export {
|
||||
__testing as blueBubblesConversationBindingTesting,
|
||||
createBlueBubblesConversationBindingManager,
|
||||
} from "./src/conversation-bindings.js";
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/** 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;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveTargetIdAfterNavigate } from "./agent.snapshot.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveTargetIdAfterNavigate } from "./agent.snapshot-target.js";
|
||||
|
||||
type Tab = { targetId: string; url: string };
|
||||
|
||||
@@ -8,10 +8,6 @@ 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",
|
||||
@@ -37,6 +33,7 @@ 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" },
|
||||
@@ -47,12 +44,12 @@ describe("resolveTargetIdAfterNavigate", () => {
|
||||
});
|
||||
|
||||
it("retries and resolves targetId when first listTabs has no URL match", async () => {
|
||||
vi.useFakeTimers();
|
||||
let calls = 0;
|
||||
|
||||
const result$ = resolveTargetIdAfterNavigate({
|
||||
const result = await resolveTargetIdAfterNavigate({
|
||||
oldTargetId: "old-123",
|
||||
navigatedUrl: "https://delayed.com",
|
||||
retryDelayMs: 0,
|
||||
listTabs: async () => {
|
||||
calls++;
|
||||
if (calls === 1) {
|
||||
@@ -62,50 +59,33 @@ 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 () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const result$ = resolveTargetIdAfterNavigate({
|
||||
const result = await 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 () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const result$ = resolveTargetIdAfterNavigate({
|
||||
const result = await 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 () => {
|
||||
@@ -120,22 +100,16 @@ describe("resolveTargetIdAfterNavigate", () => {
|
||||
});
|
||||
|
||||
it("keeps the old target when multiple replacement candidates still match after retry", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const result$ = 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" },
|
||||
]),
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(800);
|
||||
const result = await result$;
|
||||
|
||||
expect(result).toBe("old-123");
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
withPlaywrightRouteContext,
|
||||
withRouteTabContext,
|
||||
} from "./agent.shared.js";
|
||||
import { resolveTargetIdAfterNavigate } from "./agent.snapshot-target.js";
|
||||
import {
|
||||
resolveSnapshotPlan,
|
||||
shouldUsePlaywrightForAriaSnapshot,
|
||||
@@ -172,48 +173,6 @@ 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,
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
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,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,577 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,217 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -348,7 +348,6 @@ async function loadBrowserServerModule(): Promise<BrowserServerModule> {
|
||||
if (browserServerModule) {
|
||||
return browserServerModule;
|
||||
}
|
||||
vi.resetModules();
|
||||
browserServerModule = await import("../server.js");
|
||||
return browserServerModule;
|
||||
}
|
||||
@@ -484,7 +483,6 @@ export async function startBrowserControlServerFromConfig() {
|
||||
|
||||
export async function stopBrowserControlServer(): Promise<void> {
|
||||
const server = browserServerModule;
|
||||
browserServerModule = null;
|
||||
if (!server) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -199,6 +199,39 @@ 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");
|
||||
|
||||
@@ -74,6 +74,13 @@ 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 {
|
||||
@@ -212,6 +219,9 @@ export class CodexAppServerClient {
|
||||
}
|
||||
|
||||
private writeMessage(message: RpcRequest | RpcResponse): void {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
this.child.stdin.write(`${JSON.stringify(message)}\n`);
|
||||
}
|
||||
|
||||
@@ -300,7 +310,9 @@ export class CodexAppServerClient {
|
||||
return;
|
||||
}
|
||||
this.closed = true;
|
||||
this.lines.close();
|
||||
this.rejectPendingRequests(error);
|
||||
closeCodexAppServerTransport(this.child);
|
||||
}
|
||||
|
||||
private rejectPendingRequests(error: Error): void {
|
||||
|
||||
@@ -4,6 +4,7 @@ export type CodexAppServerTransport = {
|
||||
end?: () => unknown;
|
||||
destroy?: () => unknown;
|
||||
unref?: () => unknown;
|
||||
on?: (event: "error", listener: (error: Error) => void) => unknown;
|
||||
};
|
||||
stdout: NodeJS.ReadableStream & {
|
||||
destroy?: () => unknown;
|
||||
|
||||
@@ -42,7 +42,7 @@ describeLive("comfy live", () => {
|
||||
beforeAll(async () => {
|
||||
cfg = withPluginsEnabled(loadConfig());
|
||||
agentDir = resolveOpenClawAgentDir();
|
||||
await plugin.register(
|
||||
plugin.register(
|
||||
createTestPluginApi({
|
||||
config: cfg as never,
|
||||
registerImageGenerationProvider(provider) {
|
||||
|
||||
@@ -92,7 +92,7 @@ function registerPairCommand(params?: {
|
||||
pluginConfig?: Record<string, unknown>;
|
||||
}): OpenClawPluginCommandDefinition {
|
||||
let command: OpenClawPluginCommandDefinition | undefined;
|
||||
void registerDevicePair.register(
|
||||
registerDevicePair.register(
|
||||
createApi({
|
||||
...params,
|
||||
registerCommand: (nextCommand) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { createThreadBindingManager } from "./src/monitor/thread-bindings.manager.js";
|
||||
export { __testing as discordThreadBindingTesting } from "./src/monitor/thread-bindings.manager.js";
|
||||
export {
|
||||
listDiscordDirectoryGroupsFromConfig,
|
||||
listDiscordDirectoryPeersFromConfig,
|
||||
|
||||
@@ -4,7 +4,6 @@ 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,
|
||||
@@ -19,10 +18,6 @@ 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,
|
||||
}));
|
||||
@@ -73,15 +68,6 @@ 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(() => {
|
||||
@@ -89,7 +75,6 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
|
||||
resolveConfiguredSecretInputStringMock.mockReset();
|
||||
resolveFirstGithubTokenMock.mockReset();
|
||||
resolveCopilotApiTokenMock.mockReset();
|
||||
createGitHubCopilotEmbeddingProviderMock.mockReset();
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
});
|
||||
|
||||
@@ -113,12 +98,8 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
|
||||
const result = await githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions());
|
||||
|
||||
expect(result.provider?.model).toBe("text-embedding-3-small");
|
||||
expect(createGitHubCopilotEmbeddingProviderMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseUrl: TEST_BASE_URL,
|
||||
githubToken: "gh_test_token_123",
|
||||
model: "text-embedding-3-small",
|
||||
}),
|
||||
expect(resolveCopilotApiTokenMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ githubToken: "gh_test_token_123" }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -217,14 +198,12 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
|
||||
} as never);
|
||||
|
||||
expect(resolveFirstGithubTokenMock).toHaveBeenCalled();
|
||||
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",
|
||||
});
|
||||
expect(resolveCopilotApiTokenMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
env: process.env,
|
||||
githubToken: "gh_remote_token",
|
||||
}),
|
||||
);
|
||||
|
||||
const discoveryCall = fetchWithSsrFGuardMock.mock.calls[0]?.[0] as {
|
||||
init: { headers: Record<string, string> };
|
||||
|
||||
@@ -4,7 +4,10 @@ import {
|
||||
resolveCopilotApiToken,
|
||||
} from "openclaw/plugin-sdk/github-copilot-token";
|
||||
import {
|
||||
createGitHubCopilotEmbeddingProvider,
|
||||
buildRemoteBaseUrlPolicy,
|
||||
sanitizeAndNormalizeEmbedding,
|
||||
withRemoteHttpResponse,
|
||||
type MemoryEmbeddingProvider,
|
||||
type MemoryEmbeddingProviderAdapter,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
|
||||
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
@@ -44,6 +47,15 @@ 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;
|
||||
@@ -147,9 +159,126 @@ 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),
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import crypto from "node:crypto";
|
||||
import {
|
||||
buildEmbeddingBatchGroupOptions,
|
||||
runEmbeddingBatchGroups,
|
||||
type EmbeddingBatchExecutionParams,
|
||||
} from "./batch-runner.js";
|
||||
import { buildBatchHeaders, normalizeBatchBaseUrl } from "./batch-utils.js";
|
||||
import { sanitizeAndNormalizeEmbedding } from "./embedding-vectors.js";
|
||||
import { debugEmbeddingsLog } from "./embeddings-debug.js";
|
||||
import type { GeminiEmbeddingClient, GeminiTextEmbeddingRequest } from "./embeddings-gemini.js";
|
||||
import { hashText } from "./internal.js";
|
||||
import { withRemoteHttpResponse } from "./remote-http.js";
|
||||
buildBatchHeaders,
|
||||
debugEmbeddingsLog,
|
||||
normalizeBatchBaseUrl,
|
||||
sanitizeAndNormalizeEmbedding,
|
||||
withRemoteHttpResponse,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
|
||||
import type { GeminiEmbeddingClient, GeminiTextEmbeddingRequest } from "./embedding-provider.js";
|
||||
|
||||
export type GeminiBatchRequest = {
|
||||
custom_id: string;
|
||||
@@ -40,6 +41,10 @@ export type GeminiBatchOutputLine = {
|
||||
};
|
||||
|
||||
const GEMINI_BATCH_MAX_REQUESTS = 50000;
|
||||
function hashText(text: string): string {
|
||||
return crypto.createHash("sha256").update(text).digest("hex");
|
||||
}
|
||||
|
||||
function getGeminiUploadUrl(baseUrl: string): string {
|
||||
if (baseUrl.includes("/v1beta")) {
|
||||
return baseUrl.replace(/\/v1beta\/?$/, "/upload/v1beta");
|
||||
@@ -1,84 +1,40 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as authModule from "../../agents/model-auth.js";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildGeminiEmbeddingRequest,
|
||||
buildGeminiTextEmbeddingRequest,
|
||||
createGeminiEmbeddingProvider,
|
||||
DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
GEMINI_EMBEDDING_2_MODELS,
|
||||
isGeminiEmbedding2Model,
|
||||
normalizeGeminiModel,
|
||||
resolveGeminiOutputDimensionality,
|
||||
} from "./embeddings-gemini-request.js";
|
||||
import {
|
||||
createGeminiBatchFetchMock,
|
||||
createJsonResponseFetchMock,
|
||||
installFetchMock,
|
||||
mockResolvedProviderKey,
|
||||
parseFetchBody,
|
||||
readFirstFetchRequest,
|
||||
type JsonFetchMock,
|
||||
} from "./embeddings-provider.test-support.js";
|
||||
|
||||
const { resolveApiKeyForProviderMock } = vi.hoisted(() => ({
|
||||
resolveApiKeyForProviderMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/model-auth.js", () => {
|
||||
return {
|
||||
resolveApiKeyForProvider: resolveApiKeyForProviderMock,
|
||||
requireApiKey: (auth: { apiKey?: string; mode?: string }, provider: string) => {
|
||||
if (auth.apiKey) {
|
||||
return auth.apiKey;
|
||||
}
|
||||
throw new Error(`No API key resolved for provider "${provider}" (auth mode: ${auth.mode}).`);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../agents/api-key-rotation.js", () => ({
|
||||
collectProviderApiKeysForExecution: (params: { primaryApiKey?: string }) =>
|
||||
params.primaryApiKey ? [params.primaryApiKey] : [],
|
||||
executeWithApiKeyRotation: async <T>(params: {
|
||||
apiKeys: string[];
|
||||
execute: (apiKey: string) => Promise<T>;
|
||||
}) => {
|
||||
const apiKey = params.apiKeys[0];
|
||||
if (!apiKey) {
|
||||
throw new Error('No API keys configured for provider "google".');
|
||||
}
|
||||
return await params.execute(apiKey);
|
||||
},
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.doUnmock("undici");
|
||||
});
|
||||
} from "./embedding-provider.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.doUnmock("undici");
|
||||
vi.resetAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
type GeminiProviderOptions = Parameters<
|
||||
typeof import("./embeddings-gemini.js").createGeminiEmbeddingProvider
|
||||
>[0];
|
||||
|
||||
async function createProviderWithFetch(
|
||||
fetchMock: JsonFetchMock,
|
||||
options: Partial<GeminiProviderOptions> & { model: string },
|
||||
) {
|
||||
installFetchMock(fetchMock as unknown as typeof globalThis.fetch);
|
||||
mockResolvedProviderKey(authModule.resolveApiKeyForProvider);
|
||||
const { createGeminiEmbeddingProvider } = await import("./embeddings-gemini.js");
|
||||
const { provider } = await createGeminiEmbeddingProvider({
|
||||
config: {} as never,
|
||||
provider: "gemini",
|
||||
fallback: "none",
|
||||
...options,
|
||||
function installFetchMock(
|
||||
handler: (input: RequestInfo | URL, init?: RequestInit) => unknown,
|
||||
): ReturnType<typeof vi.fn> {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
return new Response(JSON.stringify(handler(input, init)), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
return provider;
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
function fetchJsonBody(fetchMock: ReturnType<typeof vi.fn>, index: number): unknown {
|
||||
const init = fetchMock.mock.calls[index]?.[1] as RequestInit | undefined;
|
||||
const body = init?.body;
|
||||
if (typeof body !== "string") {
|
||||
throw new Error("Expected JSON string request body.");
|
||||
}
|
||||
return JSON.parse(body) as unknown;
|
||||
}
|
||||
|
||||
describe("Gemini embedding request helpers", () => {
|
||||
@@ -149,24 +105,9 @@ describe("Gemini embedding request helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("gemini embedding provider", () => {
|
||||
describe("Gemini embedding provider", () => {
|
||||
it("handles legacy and v2 request/response behavior", async () => {
|
||||
const legacyFetch = createGeminiBatchFetchMock(2);
|
||||
const legacyProvider = await createProviderWithFetch(legacyFetch, {
|
||||
model: "gemini-embedding-001",
|
||||
});
|
||||
|
||||
await legacyProvider.embedQuery("test query");
|
||||
await legacyProvider.embedBatch(["text1", "text2"]);
|
||||
|
||||
expect(parseFetchBody(legacyFetch, 0)).toMatchObject({
|
||||
taskType: "RETRIEVAL_QUERY",
|
||||
content: { parts: [{ text: "test query" }] },
|
||||
});
|
||||
expect(parseFetchBody(legacyFetch, 0)).not.toHaveProperty("outputDimensionality");
|
||||
expect(parseFetchBody(legacyFetch, 1)).not.toHaveProperty("outputDimensionality");
|
||||
|
||||
const v2Fetch = createJsonResponseFetchMock((input) => {
|
||||
const fetchMock = installFetchMock((input) => {
|
||||
const url = input instanceof URL ? input.href : typeof input === "string" ? input : input.url;
|
||||
return url.endsWith(":batchEmbedContents")
|
||||
? {
|
||||
@@ -176,16 +117,22 @@ describe("gemini embedding provider", () => {
|
||||
}
|
||||
: { embedding: { values: [3, 4, Number.NaN] } };
|
||||
});
|
||||
const v2Provider = await createProviderWithFetch(v2Fetch, {
|
||||
|
||||
const { provider } = await createGeminiEmbeddingProvider({
|
||||
config: {} as never,
|
||||
provider: "gemini",
|
||||
remote: { apiKey: "test-key" },
|
||||
model: "gemini-embedding-2-preview",
|
||||
outputDimensionality: 768,
|
||||
taskType: "SEMANTIC_SIMILARITY",
|
||||
fallback: "none",
|
||||
});
|
||||
await expect(v2Provider.embedQuery(" ")).resolves.toEqual([]);
|
||||
await expect(v2Provider.embedBatch([])).resolves.toEqual([]);
|
||||
await expect(v2Provider.embedQuery("test query")).resolves.toEqual([0.6, 0.8, 0]);
|
||||
|
||||
const structuredBatch = await v2Provider.embedBatchInputs?.([
|
||||
await expect(provider.embedQuery(" ")).resolves.toEqual([]);
|
||||
await expect(provider.embedBatch([])).resolves.toEqual([]);
|
||||
await expect(provider.embedQuery("test query")).resolves.toEqual([0.6, 0.8, 0]);
|
||||
|
||||
const structuredBatch = await provider.embedBatchInputs?.([
|
||||
{
|
||||
text: "Image file: diagram.png",
|
||||
parts: [
|
||||
@@ -206,38 +153,39 @@ describe("gemini embedding provider", () => {
|
||||
[0, 0, 1],
|
||||
]);
|
||||
|
||||
const { url } = readFirstFetchRequest(v2Fetch);
|
||||
expect(url).toBe(
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-2-preview:embedContent",
|
||||
);
|
||||
expect(parseFetchBody(v2Fetch, 0)).toMatchObject({
|
||||
expect(fetchJsonBody(fetchMock, 0)).toMatchObject({
|
||||
outputDimensionality: 768,
|
||||
taskType: "SEMANTIC_SIMILARITY",
|
||||
content: { parts: [{ text: "test query" }] },
|
||||
});
|
||||
expect(parseFetchBody(v2Fetch, 1).requests).toEqual([
|
||||
{
|
||||
model: "models/gemini-embedding-2-preview",
|
||||
content: {
|
||||
parts: [
|
||||
{ text: "Image file: diagram.png" },
|
||||
{ inlineData: { mimeType: "image/png", data: "img" } },
|
||||
],
|
||||
expect(fetchJsonBody(fetchMock, 1)).toMatchObject({
|
||||
requests: [
|
||||
{
|
||||
model: "models/gemini-embedding-2-preview",
|
||||
content: {
|
||||
parts: [
|
||||
{ text: "Image file: diagram.png" },
|
||||
{ inlineData: { mimeType: "image/png", data: "img" } },
|
||||
],
|
||||
},
|
||||
taskType: "SEMANTIC_SIMILARITY",
|
||||
outputDimensionality: 768,
|
||||
},
|
||||
taskType: "SEMANTIC_SIMILARITY",
|
||||
outputDimensionality: 768,
|
||||
},
|
||||
{
|
||||
model: "models/gemini-embedding-2-preview",
|
||||
content: {
|
||||
parts: [
|
||||
{ text: "Audio file: note.wav" },
|
||||
{ inlineData: { mimeType: "audio/wav", data: "aud" } },
|
||||
],
|
||||
{
|
||||
model: "models/gemini-embedding-2-preview",
|
||||
content: {
|
||||
parts: [
|
||||
{ text: "Audio file: note.wav" },
|
||||
{ inlineData: { mimeType: "audio/wav", data: "aud" } },
|
||||
],
|
||||
},
|
||||
taskType: "SEMANTIC_SIMILARITY",
|
||||
outputDimensionality: 768,
|
||||
},
|
||||
taskType: "SEMANTIC_SIMILARITY",
|
||||
outputDimensionality: 768,
|
||||
},
|
||||
]);
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,44 +1,22 @@
|
||||
import { parseGeminiAuth } from "openclaw/plugin-sdk/image-generation-core";
|
||||
import {
|
||||
buildRemoteBaseUrlPolicy,
|
||||
debugEmbeddingsLog,
|
||||
sanitizeAndNormalizeEmbedding,
|
||||
withRemoteHttpResponse,
|
||||
type EmbeddingInput,
|
||||
type MemoryEmbeddingProvider,
|
||||
type MemoryEmbeddingProviderCreateOptions,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
|
||||
import { resolveMemorySecretInputString } from "openclaw/plugin-sdk/memory-core-host-secret";
|
||||
import {
|
||||
collectProviderApiKeysForExecution,
|
||||
executeWithApiKeyRotation,
|
||||
} from "../../agents/api-key-rotation.js";
|
||||
import { requireApiKey, resolveApiKeyForProvider } from "../../agents/model-auth.js";
|
||||
import { parseGeminiAuth } from "../../infra/gemini-auth.js";
|
||||
import {
|
||||
DEFAULT_GOOGLE_API_BASE_URL,
|
||||
normalizeGoogleApiBaseUrl,
|
||||
} from "../../infra/google-api-base-url.js";
|
||||
import type { SsrFPolicy } from "../../infra/net/ssrf.js";
|
||||
import { normalizeOptionalString } from "../../shared/string-coerce.js";
|
||||
import type { EmbeddingInput } from "./embedding-inputs.js";
|
||||
import { sanitizeAndNormalizeEmbedding } from "./embedding-vectors.js";
|
||||
import { debugEmbeddingsLog } from "./embeddings-debug.js";
|
||||
import {
|
||||
buildGeminiEmbeddingRequest,
|
||||
buildGeminiTextEmbeddingRequest,
|
||||
isGeminiEmbedding2Model,
|
||||
normalizeGeminiModel,
|
||||
resolveGeminiOutputDimensionality,
|
||||
} from "./embeddings-gemini-request.js";
|
||||
import type { EmbeddingProvider, EmbeddingProviderOptions } from "./embeddings.types.js";
|
||||
import { buildRemoteBaseUrlPolicy, withRemoteHttpResponse } from "./remote-http.js";
|
||||
import { resolveMemorySecretInputString } from "./secret-input.js";
|
||||
|
||||
export {
|
||||
buildGeminiEmbeddingRequest,
|
||||
buildGeminiTextEmbeddingRequest,
|
||||
DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
GEMINI_EMBEDDING_2_MODELS,
|
||||
isGeminiEmbedding2Model,
|
||||
normalizeGeminiModel,
|
||||
resolveGeminiOutputDimensionality,
|
||||
type GeminiEmbeddingRequest,
|
||||
type GeminiInlinePart,
|
||||
type GeminiPart,
|
||||
type GeminiTaskType,
|
||||
type GeminiTextEmbeddingRequest,
|
||||
type GeminiTextPart,
|
||||
} from "./embeddings-gemini-request.js";
|
||||
requireApiKey,
|
||||
resolveApiKeyForProvider,
|
||||
} from "openclaw/plugin-sdk/provider-auth-runtime";
|
||||
import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
export type GeminiEmbeddingClient = {
|
||||
baseUrl: string;
|
||||
@@ -50,9 +28,111 @@ export type GeminiEmbeddingClient = {
|
||||
outputDimensionality?: number;
|
||||
};
|
||||
|
||||
export const DEFAULT_GEMINI_EMBEDDING_MODEL = "gemini-embedding-001";
|
||||
const DEFAULT_GOOGLE_API_BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
|
||||
const GEMINI_MAX_INPUT_TOKENS: Record<string, number> = {
|
||||
"text-embedding-004": 2048,
|
||||
"gemini-embedding-001": 2048,
|
||||
"gemini-embedding-2-preview": 8192,
|
||||
};
|
||||
|
||||
export type GeminiTaskType = NonNullable<MemoryEmbeddingProviderCreateOptions["taskType"]>;
|
||||
|
||||
// --- gemini-embedding-2-preview support ---
|
||||
|
||||
export const GEMINI_EMBEDDING_2_MODELS = new Set([
|
||||
"gemini-embedding-2-preview",
|
||||
// Add the GA model name here once released.
|
||||
]);
|
||||
|
||||
const GEMINI_EMBEDDING_2_DEFAULT_DIMENSIONS = 3072;
|
||||
const GEMINI_EMBEDDING_2_VALID_DIMENSIONS = [768, 1536, 3072] as const;
|
||||
|
||||
export type GeminiTextPart = { text: string };
|
||||
export type GeminiInlinePart = {
|
||||
inlineData: { mimeType: string; data: string };
|
||||
};
|
||||
export type GeminiPart = GeminiTextPart | GeminiInlinePart;
|
||||
export type GeminiEmbeddingRequest = {
|
||||
content: { parts: GeminiPart[] };
|
||||
taskType: GeminiTaskType;
|
||||
outputDimensionality?: number;
|
||||
model?: string;
|
||||
};
|
||||
export type GeminiTextEmbeddingRequest = GeminiEmbeddingRequest;
|
||||
|
||||
/** Builds the text-only Gemini embedding request shape used across direct and batch APIs. */
|
||||
export function buildGeminiTextEmbeddingRequest(params: {
|
||||
text: string;
|
||||
taskType: GeminiTaskType;
|
||||
outputDimensionality?: number;
|
||||
modelPath?: string;
|
||||
}): GeminiTextEmbeddingRequest {
|
||||
return buildGeminiEmbeddingRequest({
|
||||
input: { text: params.text },
|
||||
taskType: params.taskType,
|
||||
outputDimensionality: params.outputDimensionality,
|
||||
modelPath: params.modelPath,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildGeminiEmbeddingRequest(params: {
|
||||
input: EmbeddingInput;
|
||||
taskType: GeminiTaskType;
|
||||
outputDimensionality?: number;
|
||||
modelPath?: string;
|
||||
}): GeminiEmbeddingRequest {
|
||||
const request: GeminiEmbeddingRequest = {
|
||||
content: {
|
||||
parts: params.input.parts?.map((part) =>
|
||||
part.type === "text"
|
||||
? ({ text: part.text } satisfies GeminiTextPart)
|
||||
: ({
|
||||
inlineData: { mimeType: part.mimeType, data: part.data },
|
||||
} satisfies GeminiInlinePart),
|
||||
) ?? [{ text: params.input.text }],
|
||||
},
|
||||
taskType: params.taskType,
|
||||
};
|
||||
if (params.modelPath) {
|
||||
request.model = params.modelPath;
|
||||
}
|
||||
if (params.outputDimensionality != null) {
|
||||
request.outputDimensionality = params.outputDimensionality;
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given model name is a gemini-embedding-2 variant that
|
||||
* supports `outputDimensionality` and extended task types.
|
||||
*/
|
||||
export function isGeminiEmbedding2Model(model: string): boolean {
|
||||
return GEMINI_EMBEDDING_2_MODELS.has(model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and return the `outputDimensionality` for gemini-embedding-2 models.
|
||||
* Returns `undefined` for older models (they don't support the param).
|
||||
*/
|
||||
export function resolveGeminiOutputDimensionality(
|
||||
model: string,
|
||||
requested?: number,
|
||||
): number | undefined {
|
||||
if (!isGeminiEmbedding2Model(model)) {
|
||||
return undefined;
|
||||
}
|
||||
if (requested == null) {
|
||||
return GEMINI_EMBEDDING_2_DEFAULT_DIMENSIONS;
|
||||
}
|
||||
const valid: readonly number[] = GEMINI_EMBEDDING_2_VALID_DIMENSIONS;
|
||||
if (!valid.includes(requested)) {
|
||||
throw new Error(
|
||||
`Invalid outputDimensionality ${requested} for ${model}. Valid values: ${valid.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return requested;
|
||||
}
|
||||
function resolveRemoteApiKey(remoteApiKey: unknown): string | undefined {
|
||||
const trimmed = resolveMemorySecretInputString({
|
||||
value: remoteApiKey,
|
||||
@@ -67,6 +147,21 @@ function resolveRemoteApiKey(remoteApiKey: unknown): string | undefined {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function normalizeGeminiModel(model: string): string {
|
||||
const trimmed = model.trim();
|
||||
if (!trimmed) {
|
||||
return DEFAULT_GEMINI_EMBEDDING_MODEL;
|
||||
}
|
||||
const withoutPrefix = trimmed.replace(/^models\//, "");
|
||||
if (withoutPrefix.startsWith("gemini/")) {
|
||||
return withoutPrefix.slice("gemini/".length);
|
||||
}
|
||||
if (withoutPrefix.startsWith("google/")) {
|
||||
return withoutPrefix.slice("google/".length);
|
||||
}
|
||||
return withoutPrefix;
|
||||
}
|
||||
|
||||
async function fetchGeminiEmbeddingPayload(params: {
|
||||
client: GeminiEmbeddingClient;
|
||||
endpoint: string;
|
||||
@@ -120,9 +215,30 @@ function buildGeminiModelPath(model: string): string {
|
||||
return model.startsWith("models/") ? model : `models/${model}`;
|
||||
}
|
||||
|
||||
function normalizeGoogleApiBaseUrl(baseUrl: string): string {
|
||||
const trimmed = baseUrl.trim().replace(/\/+$/, "");
|
||||
if (!trimmed) {
|
||||
return DEFAULT_GOOGLE_API_BASE_URL;
|
||||
}
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
url.hash = "";
|
||||
url.search = "";
|
||||
if (
|
||||
url.origin.toLowerCase() === "https://generativelanguage.googleapis.com" &&
|
||||
url.pathname.replace(/\/+$/, "") === ""
|
||||
) {
|
||||
url.pathname = "/v1beta";
|
||||
}
|
||||
return url.toString().replace(/\/+$/, "");
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createGeminiEmbeddingProvider(
|
||||
options: EmbeddingProviderOptions,
|
||||
): Promise<{ provider: EmbeddingProvider; client: GeminiEmbeddingClient }> {
|
||||
options: MemoryEmbeddingProviderCreateOptions,
|
||||
): Promise<{ provider: MemoryEmbeddingProvider; client: GeminiEmbeddingClient }> {
|
||||
const client = await resolveGeminiEmbeddingClient(options);
|
||||
const baseUrl = client.baseUrl.replace(/\/$/, "");
|
||||
const embedUrl = `${baseUrl}/${client.modelPath}:embedContent`;
|
||||
@@ -190,7 +306,7 @@ export async function createGeminiEmbeddingProvider(
|
||||
}
|
||||
|
||||
export async function resolveGeminiEmbeddingClient(
|
||||
options: EmbeddingProviderOptions,
|
||||
options: MemoryEmbeddingProviderCreateOptions,
|
||||
): Promise<GeminiEmbeddingClient> {
|
||||
const remote = options.remote;
|
||||
const remoteApiKey = resolveRemoteApiKey(remote?.apiKey);
|
||||
121
extensions/google/generation-provider-metadata.ts
Normal file
121
extensions/google/generation-provider-metadata.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,16 +1,23 @@
|
||||
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 { buildGoogleMusicGenerationProvider } from "./music-generation-provider.js";
|
||||
import {
|
||||
createGoogleMusicGenerationProviderMetadata,
|
||||
createGoogleVideoGenerationProviderMetadata,
|
||||
} from "./generation-provider-metadata.js";
|
||||
import { geminiMemoryEmbeddingProviderAdapter } from "./memory-embedding-adapter.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<
|
||||
@@ -37,6 +44,24 @@ 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 (
|
||||
@@ -103,6 +128,22 @@ 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",
|
||||
@@ -111,11 +152,12 @@ export default definePluginEntry({
|
||||
api.registerCliBackend(buildGoogleGeminiCliBackend());
|
||||
registerGoogleGeminiCliProvider(api);
|
||||
registerGoogleProvider(api);
|
||||
api.registerMemoryEmbeddingProvider(geminiMemoryEmbeddingProviderAdapter);
|
||||
api.registerImageGenerationProvider(createLazyGoogleImageGenerationProvider());
|
||||
api.registerMediaUnderstandingProvider(createLazyGoogleMediaUnderstandingProvider());
|
||||
api.registerMusicGenerationProvider(buildGoogleMusicGenerationProvider());
|
||||
api.registerMusicGenerationProvider(createLazyGoogleMusicGenerationProvider());
|
||||
api.registerSpeechProvider(buildGoogleSpeechProvider());
|
||||
api.registerVideoGenerationProvider(buildGoogleVideoGenerationProvider());
|
||||
api.registerVideoGenerationProvider(createLazyGoogleVideoGenerationProvider());
|
||||
api.registerWebSearchProvider(createGeminiWebSearchProvider());
|
||||
},
|
||||
});
|
||||
|
||||
79
extensions/google/memory-embedding-adapter.ts
Normal file
79
extensions/google/memory-embedding-adapter.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
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);
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -5,15 +5,17 @@ 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;
|
||||
@@ -99,39 +101,7 @@ function extractTracks(params: { payload: GoogleGenerateMusicResponse; model: st
|
||||
|
||||
export function buildGoogleMusicGenerationProvider(): MusicGenerationProvider {
|
||||
return {
|
||||
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"],
|
||||
},
|
||||
},
|
||||
},
|
||||
...createGoogleMusicGenerationProviderMetadata(),
|
||||
async generateMusic(req) {
|
||||
if ((req.inputImages?.length ?? 0) > GOOGLE_MAX_INPUT_IMAGES) {
|
||||
throw new Error(
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
},
|
||||
"contracts": {
|
||||
"mediaUnderstandingProviders": ["google"],
|
||||
"memoryEmbeddingProviders": ["gemini"],
|
||||
"imageGenerationProviders": ["google"],
|
||||
"musicGenerationProviders": ["google"],
|
||||
"speechProviders": ["google"],
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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,
|
||||
@@ -16,15 +15,17 @@ 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);
|
||||
@@ -151,61 +152,7 @@ async function downloadGeneratedVideo(params: {
|
||||
|
||||
export function buildGoogleVideoGenerationProvider(): VideoGenerationProvider {
|
||||
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: ({ 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,
|
||||
},
|
||||
},
|
||||
...createGoogleVideoGenerationProviderMetadata(),
|
||||
async generateVideo(req) {
|
||||
if ((req.inputImages?.length ?? 0) > 1) {
|
||||
throw new Error("Google video generation supports at most one input image.");
|
||||
|
||||
@@ -8,7 +8,6 @@ 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";
|
||||
|
||||
@@ -28,6 +27,14 @@ 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,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import path from "node:path";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const listEnabledGoogleChatAccounts = vi.hoisted(() => vi.fn());
|
||||
@@ -9,7 +10,6 @@ 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,15 +32,6 @@ 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", () => {
|
||||
@@ -161,11 +152,9 @@ describe("googlechat message actions", () => {
|
||||
config: { mediaMaxMb: 5 },
|
||||
});
|
||||
resolveGoogleChatOutboundSpace.mockResolvedValue("spaces/BBB");
|
||||
loadOutboundMediaFromUrl.mockResolvedValue({
|
||||
buffer: Buffer.from("local-bytes"),
|
||||
fileName: "local.txt",
|
||||
contentType: "text/plain",
|
||||
});
|
||||
const localRoot = "/tmp/googlechat-action-test";
|
||||
const localPath = path.join(localRoot, "local.md");
|
||||
const readFile = vi.fn(async () => Buffer.from("local-bytes"));
|
||||
getGoogleChatRuntime.mockReturnValue({
|
||||
channel: {
|
||||
media: {
|
||||
@@ -187,23 +176,22 @@ describe("googlechat message actions", () => {
|
||||
action: "upload-file",
|
||||
params: {
|
||||
to: "spaces/BBB",
|
||||
path: "/tmp/local.txt",
|
||||
path: localPath,
|
||||
message: "notes",
|
||||
filename: "renamed.txt",
|
||||
},
|
||||
cfg: {},
|
||||
accountId: "default",
|
||||
mediaLocalRoots: ["/tmp"],
|
||||
mediaLocalRoots: [localRoot],
|
||||
mediaReadFile: readFile,
|
||||
} as never);
|
||||
|
||||
expect(loadOutboundMediaFromUrl).toHaveBeenCalledWith(
|
||||
"/tmp/local.txt",
|
||||
expect.objectContaining({ mediaLocalRoots: ["/tmp"] }),
|
||||
);
|
||||
expect(readFile).toHaveBeenCalledWith(localPath);
|
||||
expect(uploadGoogleChatAttachment).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
space: "spaces/BBB",
|
||||
filename: "renamed.txt",
|
||||
buffer: Buffer.from("local-bytes"),
|
||||
}),
|
||||
);
|
||||
expect(sendGoogleChatMessage).toHaveBeenCalledWith(
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import type {
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelMessageActionName,
|
||||
OpenClawConfig,
|
||||
} from "../runtime-api.js";
|
||||
import {
|
||||
createActionGate,
|
||||
extractToolSend,
|
||||
jsonResult,
|
||||
loadOutboundMediaFromUrl,
|
||||
readNumberParam,
|
||||
readReactionParams,
|
||||
readStringParam,
|
||||
} from "../runtime-api.js";
|
||||
} 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";
|
||||
import { listEnabledGoogleChatAccounts, resolveGoogleChatAccount } from "./accounts.js";
|
||||
import {
|
||||
createGoogleChatReaction,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import crypto from "node:crypto";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { fetchWithSsrFGuard } from "../runtime-api.js";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import { getGoogleChatAccessToken } from "./auth.js";
|
||||
import type { GoogleChatReaction } from "./types.js";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { GoogleAuth, OAuth2Client } from "google-auth-library";
|
||||
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,6 +15,10 @@ 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}`;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/status-helpers";
|
||||
import {
|
||||
createAccountStatusSink,
|
||||
runPassiveAccountLifecycle,
|
||||
type OpenClawConfig,
|
||||
type ResolvedGoogleChatAccount,
|
||||
} from "./channel.deps.runtime.js";
|
||||
} 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";
|
||||
import type { GoogleChatRuntimeEnv } from "./monitor-types.js";
|
||||
|
||||
const loadGoogleChatChannelRuntime = createLazyRuntimeNamedExport(
|
||||
|
||||
@@ -18,6 +18,7 @@ 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 {
|
||||
@@ -28,42 +29,7 @@ function normalizeUserId(raw?: string | null): string {
|
||||
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;
|
||||
});
|
||||
}
|
||||
export { isSenderAllowed } from "./sender-allow.js";
|
||||
|
||||
type GoogleChatGroupEntry = {
|
||||
requireMention?: boolean;
|
||||
|
||||
55
extensions/googlechat/src/monitor-routing.ts
Normal file
55
extensions/googlechat/src/monitor-routing.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
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);
|
||||
}
|
||||
@@ -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 { getGoogleChatRuntime } from "./runtime.js";
|
||||
import type { getGoogleChatRuntime } from "./runtime.js";
|
||||
|
||||
export type GoogleChatRuntimeEnv = {
|
||||
log?: (message: string) => void;
|
||||
|
||||
@@ -8,8 +8,11 @@ const resolveWebhookTargetWithAuthOrReject = vi.hoisted(() => vi.fn());
|
||||
const withResolvedWebhookRequestPipeline = vi.hoisted(() => vi.fn());
|
||||
const verifyGoogleChatRequest = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../runtime-api.js", () => ({
|
||||
vi.mock("openclaw/plugin-sdk/webhook-request-guards", () => ({
|
||||
readJsonWebhookBodyOrReject,
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/webhook-targets", () => ({
|
||||
resolveWebhookTargetWithAuthOrReject,
|
||||
withResolvedWebhookRequestPipeline,
|
||||
}));
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { WebhookInFlightLimiter } from "openclaw/plugin-sdk/webhook-request-guards";
|
||||
import { readJsonWebhookBodyOrReject } from "openclaw/plugin-sdk/webhook-request-guards";
|
||||
import {
|
||||
readJsonWebhookBodyOrReject,
|
||||
resolveWebhookTargetWithAuthOrReject,
|
||||
withResolvedWebhookRequestPipeline,
|
||||
type WebhookInFlightLimiter,
|
||||
} from "../runtime-api.js";
|
||||
} from "openclaw/plugin-sdk/webhook-targets";
|
||||
import { verifyGoogleChatRequest } from "./auth.js";
|
||||
import type { WebhookTarget } from "./monitor-types.js";
|
||||
import type {
|
||||
@@ -15,6 +14,10 @@ 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"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import {
|
||||
deliverTextOrMediaReply,
|
||||
resolveSendableOutboundReplyParts,
|
||||
@@ -7,8 +6,6 @@ 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";
|
||||
@@ -21,27 +18,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 };
|
||||
|
||||
const webhookTargets = new Map<string, WebhookTarget[]>();
|
||||
const webhookInFlightLimiter = createWebhookInFlightLimiter();
|
||||
const googleChatWebhookRequestHandler = createGoogleChatWebhookRequestHandler({
|
||||
webhookTargets,
|
||||
webhookInFlightLimiter,
|
||||
processEvent: async (event, target) => {
|
||||
await processGoogleChatEvent(event, target);
|
||||
},
|
||||
});
|
||||
setGoogleChatWebhookEventProcessor(processGoogleChatEvent);
|
||||
|
||||
function logVerbose(core: GoogleChatCoreRuntime, runtime: GoogleChatRuntimeEnv, message: string) {
|
||||
if (core.logging.shouldLogVerbose()) {
|
||||
@@ -49,29 +46,6 @@ 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") {
|
||||
@@ -87,13 +61,6 @@ 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") {
|
||||
|
||||
@@ -7,7 +7,10 @@ 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.js";
|
||||
import {
|
||||
handleGoogleChatWebhookRequest,
|
||||
registerGoogleChatWebhookTarget,
|
||||
} from "./monitor-routing.js";
|
||||
|
||||
vi.mock("./auth.js", () => ({
|
||||
verifyGoogleChatRequest: vi.fn(),
|
||||
|
||||
48
extensions/googlechat/src/sender-allow.ts
Normal file
48
extensions/googlechat/src/sender-allow.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -11,10 +11,6 @@ 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;
|
||||
@@ -23,6 +19,24 @@ 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>)",
|
||||
|
||||
@@ -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,
|
||||
} from "./channel.deps.runtime.js";
|
||||
type ResolvedGoogleChatAccount,
|
||||
} from "./accounts.js";
|
||||
import { startGoogleChatGatewayAccount } from "./gateway.js";
|
||||
import { googlechatSetupAdapter } from "./setup-core.js";
|
||||
import { googlechatSetupWizard } from "./setup-surface.js";
|
||||
@@ -27,13 +27,16 @@ const hoisted = vi.hoisted(() => ({
|
||||
startGoogleChatMonitor: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./monitor.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./monitor.js")>("./monitor.js");
|
||||
return {
|
||||
...actual,
|
||||
vi.mock("./channel.runtime.js", () => ({
|
||||
googleChatChannelRuntime: {
|
||||
resolveGoogleChatWebhookPath: ({
|
||||
account,
|
||||
}: {
|
||||
account: { config: { webhookPath?: string } };
|
||||
}) => account.config.webhookPath ?? "/googlechat",
|
||||
startGoogleChatMonitor: hoisted.startGoogleChatMonitor,
|
||||
};
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
const googlechatSetupPlugin = {
|
||||
id: "googlechat",
|
||||
@@ -65,6 +68,16 @@ 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();
|
||||
@@ -356,7 +369,7 @@ describe("googlechat setup", () => {
|
||||
account: buildAccount(),
|
||||
});
|
||||
await expectPendingUntilAbort({
|
||||
waitForStarted: waitForStartedMocks(hoisted.startGoogleChatMonitor),
|
||||
waitForStarted: waitForGoogleChatMonitorStarted,
|
||||
isSettled,
|
||||
abort,
|
||||
task,
|
||||
|
||||
@@ -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 "./monitor.js";
|
||||
import { isSenderAllowed } from "./sender-allow.js";
|
||||
import {
|
||||
isGoogleChatSpaceTarget,
|
||||
isGoogleChatUserTarget,
|
||||
@@ -18,10 +18,8 @@ const mocks = vi.hoisted(() => ({
|
||||
getGoogleChatAccessToken: vi.fn().mockResolvedValue("token"),
|
||||
}));
|
||||
|
||||
vi.mock("../runtime-api.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../runtime-api.js")>("../runtime-api.js");
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => {
|
||||
return {
|
||||
...actual,
|
||||
fetchWithSsrFGuard: mocks.fetchWithSsrFGuard,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
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) {
|
||||
|
||||
@@ -7,3 +7,7 @@ export {
|
||||
resolveIMessageAttachmentRoots,
|
||||
resolveIMessageRemoteAttachmentRoots,
|
||||
} from "./src/media-contract.js";
|
||||
export {
|
||||
__testing as imessageConversationBindingTesting,
|
||||
createIMessageConversationBindingManager,
|
||||
} from "./src/conversation-bindings.js";
|
||||
|
||||
@@ -8,6 +8,7 @@ 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,
|
||||
@@ -52,6 +53,7 @@ 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",
|
||||
|
||||
35
extensions/lmstudio/memory-embedding-adapter.ts
Normal file
35
extensions/lmstudio/memory-embedding-adapter.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
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"]),
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -21,6 +21,9 @@
|
||||
"groupHint": "Self-hosted open-weight models"
|
||||
}
|
||||
],
|
||||
"contracts": {
|
||||
"memoryEmbeddingProviders": ["lmstudio"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import type { SsrFPolicy } from "../../infra/net/ssrf.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/logging-core";
|
||||
import {
|
||||
buildRemoteBaseUrlPolicy,
|
||||
createRemoteEmbeddingProvider,
|
||||
normalizeEmbeddingModelWithPrefixes,
|
||||
type MemoryEmbeddingProvider,
|
||||
type MemoryEmbeddingProviderCreateOptions,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
|
||||
import { resolveMemorySecretInputString } from "openclaw/plugin-sdk/memory-core-host-secret";
|
||||
import { formatErrorMessage, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { LMSTUDIO_DEFAULT_EMBEDDING_MODEL, LMSTUDIO_PROVIDER_ID } from "./defaults.js";
|
||||
import { ensureLmstudioModelLoaded } from "./models.fetch.js";
|
||||
import { resolveLmstudioInferenceBase } from "./models.js";
|
||||
import {
|
||||
buildLmstudioAuthHeaders,
|
||||
ensureLmstudioModelLoaded,
|
||||
LMSTUDIO_DEFAULT_EMBEDDING_MODEL,
|
||||
LMSTUDIO_PROVIDER_ID,
|
||||
resolveLmstudioInferenceBase,
|
||||
resolveLmstudioProviderHeaders,
|
||||
resolveLmstudioRuntimeApiKey,
|
||||
} from "../../plugin-sdk/lmstudio-runtime.js";
|
||||
import { normalizeEmbeddingModelWithPrefixes } from "./embeddings-model-normalize.js";
|
||||
import { createRemoteEmbeddingProvider } from "./embeddings-remote-provider.js";
|
||||
import type { EmbeddingProvider, EmbeddingProviderOptions } from "./embeddings.types.js";
|
||||
import { buildRemoteBaseUrlPolicy } from "./remote-http.js";
|
||||
import { resolveMemorySecretInputString } from "./secret-input.js";
|
||||
} from "./runtime.js";
|
||||
|
||||
const log = createSubsystemLogger("memory/embeddings");
|
||||
|
||||
@@ -47,7 +48,7 @@ function hasAuthorizationHeader(headers: Record<string, string> | undefined): bo
|
||||
|
||||
/** Resolves API key (real or synthetic placeholder) from runtime/provider auth config. */
|
||||
async function resolveLmstudioApiKey(
|
||||
options: EmbeddingProviderOptions,
|
||||
options: MemoryEmbeddingProviderCreateOptions,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
return await resolveLmstudioRuntimeApiKey({
|
||||
@@ -65,8 +66,8 @@ async function resolveLmstudioApiKey(
|
||||
|
||||
/** Creates the LM Studio embedding provider client and preloads the target model before return. */
|
||||
export async function createLmstudioEmbeddingProvider(
|
||||
options: EmbeddingProviderOptions,
|
||||
): Promise<{ provider: EmbeddingProvider; client: LmstudioEmbeddingClient }> {
|
||||
options: MemoryEmbeddingProviderCreateOptions,
|
||||
): Promise<{ provider: MemoryEmbeddingProvider; client: LmstudioEmbeddingClient }> {
|
||||
const providerConfig = options.config.models?.providers?.lmstudio;
|
||||
const providerBaseUrl = providerConfig?.baseUrl?.trim();
|
||||
const isFallbackActivation = options.fallback === "lmstudio" && options.provider !== "lmstudio";
|
||||
@@ -1,10 +1,7 @@
|
||||
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(() =>
|
||||
|
||||
@@ -241,10 +241,7 @@ 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"
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createTestPluginApi } from "../../test/helpers/plugins/plugin-api.js";
|
||||
import { registerMatrixCliMetadata } from "./cli-metadata.js";
|
||||
import entry from "./index.js";
|
||||
import entry, { registerMatrixFullRuntime } 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();
|
||||
@@ -56,4 +67,30 @@ 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));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,61 @@
|
||||
import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract";
|
||||
import {
|
||||
defineBundledChannelEntry,
|
||||
type OpenClawPluginApi,
|
||||
} 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",
|
||||
@@ -20,32 +74,5 @@ export default defineBundledChannelEntry({
|
||||
exportName: "setMatrixRuntime",
|
||||
},
|
||||
registerCliMetadata: registerMatrixCliMetadata,
|
||||
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);
|
||||
});
|
||||
},
|
||||
registerFull: registerMatrixFullRuntime,
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
resolveMatrixAccountStringValues,
|
||||
type MatrixResolvedStringField,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { extractToolSend } from "openclaw/plugin-sdk/tool-send";
|
||||
import { requiresExplicitMatrixDefaultAccount } from "./account-selection.js";
|
||||
import { resolveDefaultMatrixAccountId, resolveMatrixAccount } from "./matrix/accounts.js";
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalStringifiedId,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { getMatrixApprovalAuthApprovers, matrixApprovalAuth } from "./approval-auth.js";
|
||||
import { normalizeMatrixApproverId } from "./approval-ids.js";
|
||||
import {
|
||||
|
||||
45
extensions/matrix/src/approval-reaction-auth.ts
Normal file
45
extensions/matrix/src/approval-reaction-auth.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { resolveApprovalApprovers } from "openclaw/plugin-sdk/approval-auth-runtime";
|
||||
import { normalizeMatrixApproverId } from "./approval-ids.js";
|
||||
import { resolveMatrixAccount } from "./matrix/accounts.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
type MatrixApprovalReactionKind = "exec" | "plugin";
|
||||
|
||||
function normalizeMatrixExecApproverId(value: string | number): string | undefined {
|
||||
const normalized = normalizeMatrixApproverId(value);
|
||||
return normalized === "*" ? undefined : normalized;
|
||||
}
|
||||
|
||||
function getMatrixApprovalReactionApprovers(params: {
|
||||
cfg: CoreConfig;
|
||||
accountId?: string | null;
|
||||
approvalKind: MatrixApprovalReactionKind;
|
||||
}): string[] {
|
||||
const account = resolveMatrixAccount(params).config;
|
||||
if (params.approvalKind === "plugin") {
|
||||
return resolveApprovalApprovers({
|
||||
allowFrom: account.dm?.allowFrom,
|
||||
normalizeApprover: normalizeMatrixApproverId,
|
||||
});
|
||||
}
|
||||
return resolveApprovalApprovers({
|
||||
explicit: account.execApprovals?.approvers,
|
||||
allowFrom: account.dm?.allowFrom,
|
||||
normalizeApprover: normalizeMatrixExecApproverId,
|
||||
});
|
||||
}
|
||||
|
||||
export function isMatrixApprovalReactionAuthorizedSender(params: {
|
||||
cfg: CoreConfig;
|
||||
accountId?: string | null;
|
||||
senderId?: string | null;
|
||||
approvalKind: MatrixApprovalReactionKind;
|
||||
}): boolean {
|
||||
const normalizedSenderId = params.senderId
|
||||
? normalizeMatrixApproverId(params.senderId)
|
||||
: undefined;
|
||||
if (!normalizedSenderId) {
|
||||
return false;
|
||||
}
|
||||
return getMatrixApprovalReactionApprovers(params).includes(normalizedSenderId);
|
||||
}
|
||||
@@ -24,11 +24,11 @@ import {
|
||||
createComputedAccountStatusAdapter,
|
||||
createDefaultChannelRuntimeState,
|
||||
} from "openclaw/plugin-sdk/status-helpers";
|
||||
import { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
|
||||
import { matrixMessageActions } from "./actions.js";
|
||||
import { matrixApprovalCapability } from "./approval-native.js";
|
||||
import { createMatrixPairingText, createMatrixProbeAccount } from "./channel-account-paths.js";
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
import { matrixSetupAdapter } from "./setup-core.js";
|
||||
import { matrixSetupWizard } from "./setup-surface.js";
|
||||
import { runMatrixStartupMaintenance } from "./startup-maintenance.js";
|
||||
import { resolveMatrixInboundConversation } from "./thread-binding-api.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
// Mutex for serializing account startup (workaround for concurrent dynamic import race condition)
|
||||
let matrixStartupLock: Promise<void> = Promise.resolve();
|
||||
@@ -267,25 +268,6 @@ function resolveMatrixCommandConversation(params: {
|
||||
return parentConversationId ? { conversationId: parentConversationId } : null;
|
||||
}
|
||||
|
||||
function resolveMatrixInboundConversation(params: {
|
||||
to?: string;
|
||||
conversationId?: string;
|
||||
threadId?: string | number;
|
||||
}) {
|
||||
const rawTarget = params.to?.trim() || params.conversationId?.trim() || "";
|
||||
const target = rawTarget ? resolveMatrixTargetIdentity(rawTarget) : null;
|
||||
const parentConversationId = target?.kind === "room" ? target.id : undefined;
|
||||
const threadId =
|
||||
params.threadId != null ? normalizeOptionalString(String(params.threadId)) : undefined;
|
||||
if (threadId) {
|
||||
return {
|
||||
conversationId: threadId,
|
||||
...(parentConversationId ? { parentConversationId } : {}),
|
||||
};
|
||||
}
|
||||
return parentConversationId ? { conversationId: parentConversationId } : null;
|
||||
}
|
||||
|
||||
function resolveMatrixDeliveryTarget(params: {
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Command } from "commander";
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import { formatZonedTimestamp } from "openclaw/plugin-sdk/matrix-runtime-shared";
|
||||
import type { ChannelSetupInput } from "openclaw/plugin-sdk/setup";
|
||||
import { resolveMatrixAccount, resolveMatrixAccountConfig } from "./matrix/accounts.js";
|
||||
import { withResolvedActionClient, withStartedActionClient } from "./matrix/actions/client.js";
|
||||
import { listMatrixOwnDevices, pruneMatrixStaleGatewayDevices } from "./matrix/actions/devices.js";
|
||||
import { updateMatrixOwnProfile } from "./matrix/actions/profile.js";
|
||||
import {
|
||||
@@ -16,14 +18,9 @@ import { resolveMatrixAuthContext } from "./matrix/client.js";
|
||||
import { setMatrixSdkConsoleLogging, setMatrixSdkLogMode } from "./matrix/client/logging.js";
|
||||
import { resolveMatrixConfigPath, updateMatrixAccountConfig } from "./matrix/config-update.js";
|
||||
import { isOpenClawManagedMatrixDevice } from "./matrix/device-health.js";
|
||||
import {
|
||||
inspectMatrixDirectRooms,
|
||||
repairMatrixDirectRooms,
|
||||
type MatrixDirectRoomCandidate,
|
||||
} from "./matrix/direct-management.js";
|
||||
import type { MatrixDirectRoomCandidate } from "./matrix/direct-management.js";
|
||||
import { formatMatrixErrorMessage } from "./matrix/errors.js";
|
||||
import { applyMatrixProfileUpdate, type MatrixProfileUpdateResult } from "./profile-update.js";
|
||||
import { formatZonedTimestamp, normalizeAccountId, type ChannelSetupInput } from "./runtime-api.js";
|
||||
import { getMatrixRuntime } from "./runtime.js";
|
||||
import { matrixSetupAdapter } from "./setup-core.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
@@ -334,6 +331,10 @@ async function inspectMatrixDirectRoom(params: {
|
||||
accountId: string;
|
||||
userId: string;
|
||||
}): Promise<MatrixCliDirectRoomInspection> {
|
||||
const [{ withResolvedActionClient }, { inspectMatrixDirectRooms }] = await Promise.all([
|
||||
import("./matrix/actions/client.js"),
|
||||
import("./matrix/direct-management.js"),
|
||||
]);
|
||||
return await withResolvedActionClient(
|
||||
{ accountId: params.accountId },
|
||||
async (client) => {
|
||||
@@ -361,6 +362,10 @@ async function repairMatrixDirectRoom(params: {
|
||||
}): Promise<MatrixCliDirectRoomRepair> {
|
||||
const cfg = getMatrixRuntime().config.loadConfig() as CoreConfig;
|
||||
const account = resolveMatrixAccount({ cfg, accountId: params.accountId });
|
||||
const [{ withStartedActionClient }, { repairMatrixDirectRooms }] = await Promise.all([
|
||||
import("./matrix/actions/client.js"),
|
||||
import("./matrix/direct-management.js"),
|
||||
]);
|
||||
return await withStartedActionClient({ accountId: params.accountId }, async (client) => {
|
||||
const repaired = await repairMatrixDirectRooms({
|
||||
client,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolveMatrixAuth } from "./matrix/client.js";
|
||||
import { MatrixAuthedHttpClient } from "./matrix/sdk/http-client.js";
|
||||
import { isMatrixQualifiedUserId, normalizeMatrixMessagingTarget } from "./matrix/target-ids.js";
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { getMatrixApprovalAuthApprovers } from "./approval-auth.js";
|
||||
import { normalizeMatrixApproverId } from "./approval-ids.js";
|
||||
import { listMatrixAccountIds, resolveMatrixAccount } from "./matrix/accounts.js";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
resolveConfiguredMatrixAccountIds,
|
||||
resolveMatrixDefaultOrOnlyAccountId,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { fetchMatrixPollMessageSummary, resolveMatrixPollRootEventId } from "../poll-summary.js";
|
||||
import { isPollEventType } from "../poll-types.js";
|
||||
import { editMessageMatrix, sendMessageMatrix } from "../send.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import { formatMatrixEncryptionUnavailableError } from "../encryption-guidance.js";
|
||||
|
||||
@@ -780,6 +780,7 @@ describe("resolveMatrixAuth", () => {
|
||||
setMatrixAuthClientDepsForTest({
|
||||
MatrixClient: MockMatrixClient as unknown as typeof import("./sdk.js").MatrixClient,
|
||||
ensureMatrixSdkLoggingConfigured: ensureMatrixSdkLoggingConfiguredMock,
|
||||
retryMinDelayMs: 0,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { formatErrorMessage, type PinnedDispatcherPolicy } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import { coerceSecretRef } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { retryAsync } from "openclaw/plugin-sdk/retry-runtime";
|
||||
import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
|
||||
import {
|
||||
coerceSecretRef,
|
||||
normalizeResolvedSecretInputString,
|
||||
} from "openclaw/plugin-sdk/secret-input";
|
||||
import type { PinnedDispatcherPolicy } from "openclaw/plugin-sdk/ssrf-dispatcher";
|
||||
import {
|
||||
requiresExplicitMatrixDefaultAccount,
|
||||
resolveMatrixDefaultOrOnlyAccountId,
|
||||
@@ -33,6 +36,7 @@ import type { MatrixAuth, MatrixResolvedConfig } from "./types.js";
|
||||
type MatrixAuthClientDeps = {
|
||||
MatrixClient: typeof import("../sdk.js").MatrixClient;
|
||||
ensureMatrixSdkLoggingConfigured: typeof import("./logging.js").ensureMatrixSdkLoggingConfigured;
|
||||
retryMinDelayMs?: number;
|
||||
};
|
||||
|
||||
type MatrixCredentialsReadDeps = {
|
||||
@@ -55,6 +59,7 @@ const MATRIX_AUTH_REQUEST_RETRY_RE =
|
||||
export function setMatrixAuthClientDepsForTest(deps?: {
|
||||
MatrixClient: typeof import("../sdk.js").MatrixClient;
|
||||
ensureMatrixSdkLoggingConfigured: typeof import("./logging.js").ensureMatrixSdkLoggingConfigured;
|
||||
retryMinDelayMs?: number;
|
||||
}): void {
|
||||
matrixAuthClientDepsForTest = deps;
|
||||
}
|
||||
@@ -114,7 +119,7 @@ function credentialsMatchBackfillAuthLineage(params: {
|
||||
async function retryMatrixAuthRequest<T>(label: string, run: () => Promise<T>): Promise<T> {
|
||||
return await retryAsync(run, {
|
||||
attempts: 3,
|
||||
minDelayMs: 250,
|
||||
minDelayMs: matrixAuthClientDepsForTest?.retryMinDelayMs ?? 250,
|
||||
maxDelayMs: 1_500,
|
||||
jitter: 0.1,
|
||||
label,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import type { PinnedDispatcherPolicy } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { PinnedDispatcherPolicy } from "openclaw/plugin-sdk/ssrf-dispatcher";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { SsrFPolicy } from "../../runtime-api.js";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import { resolveValidatedMatrixHomeserverUrl } from "./config.js";
|
||||
|
||||
@@ -1,6 +1,95 @@
|
||||
export {
|
||||
hasReadyMatrixEnvAuth,
|
||||
resolveGlobalMatrixEnvConfig,
|
||||
resolveMatrixEnvAuthReadiness,
|
||||
resolveScopedMatrixEnvConfig,
|
||||
} from "./config.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import { getMatrixScopedEnvVarNames } from "../../env-vars.js";
|
||||
|
||||
type MatrixEnvConfig = {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken?: string;
|
||||
password?: string;
|
||||
deviceId?: string;
|
||||
deviceName?: string;
|
||||
};
|
||||
|
||||
function cleanEnv(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
export function resolveGlobalMatrixEnvConfig(env: NodeJS.ProcessEnv): MatrixEnvConfig {
|
||||
return {
|
||||
homeserver: cleanEnv(env.MATRIX_HOMESERVER),
|
||||
userId: cleanEnv(env.MATRIX_USER_ID),
|
||||
accessToken: cleanEnv(env.MATRIX_ACCESS_TOKEN) || undefined,
|
||||
password: cleanEnv(env.MATRIX_PASSWORD) || undefined,
|
||||
deviceId: cleanEnv(env.MATRIX_DEVICE_ID) || undefined,
|
||||
deviceName: cleanEnv(env.MATRIX_DEVICE_NAME) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasReadyMatrixEnvAuth(config: {
|
||||
homeserver?: string;
|
||||
userId?: string;
|
||||
accessToken?: string;
|
||||
password?: string;
|
||||
}): boolean {
|
||||
const homeserver = cleanEnv(config.homeserver);
|
||||
const userId = cleanEnv(config.userId);
|
||||
const accessToken = cleanEnv(config.accessToken);
|
||||
const password = cleanEnv(config.password);
|
||||
return Boolean(homeserver && (accessToken || (userId && password)));
|
||||
}
|
||||
|
||||
export function resolveScopedMatrixEnvConfig(
|
||||
accountId: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): MatrixEnvConfig {
|
||||
const keys = getMatrixScopedEnvVarNames(accountId);
|
||||
return {
|
||||
homeserver: cleanEnv(env[keys.homeserver]),
|
||||
userId: cleanEnv(env[keys.userId]),
|
||||
accessToken: cleanEnv(env[keys.accessToken]) || undefined,
|
||||
password: cleanEnv(env[keys.password]) || undefined,
|
||||
deviceId: cleanEnv(env[keys.deviceId]) || undefined,
|
||||
deviceName: cleanEnv(env[keys.deviceName]) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveMatrixEnvAuthReadiness(
|
||||
accountId: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): {
|
||||
ready: boolean;
|
||||
homeserver?: string;
|
||||
userId?: string;
|
||||
sourceHint: string;
|
||||
missingMessage: string;
|
||||
} {
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
const scoped = resolveScopedMatrixEnvConfig(normalizedAccountId, env);
|
||||
const scopedReady = hasReadyMatrixEnvAuth(scoped);
|
||||
if (normalizedAccountId !== DEFAULT_ACCOUNT_ID) {
|
||||
const keys = getMatrixScopedEnvVarNames(normalizedAccountId);
|
||||
return {
|
||||
ready: scopedReady,
|
||||
homeserver: scoped.homeserver || undefined,
|
||||
userId: scoped.userId || undefined,
|
||||
sourceHint: `${keys.homeserver} (+ auth vars)`,
|
||||
missingMessage: `Set per-account env vars for "${normalizedAccountId}" (for example ${keys.homeserver} + ${keys.accessToken} or ${keys.userId} + ${keys.password}).`,
|
||||
};
|
||||
}
|
||||
|
||||
const defaultScoped = resolveScopedMatrixEnvConfig(DEFAULT_ACCOUNT_ID, env);
|
||||
const global = resolveGlobalMatrixEnvConfig(env);
|
||||
const defaultScopedReady = hasReadyMatrixEnvAuth(defaultScoped);
|
||||
const globalReady = hasReadyMatrixEnvAuth(global);
|
||||
const defaultKeys = getMatrixScopedEnvVarNames(DEFAULT_ACCOUNT_ID);
|
||||
return {
|
||||
ready: defaultScopedReady || globalReady,
|
||||
homeserver: defaultScoped.homeserver || global.homeserver || undefined,
|
||||
userId: defaultScoped.userId || global.userId || undefined,
|
||||
sourceHint: "MATRIX_* or MATRIX_DEFAULT_*",
|
||||
missingMessage:
|
||||
`Set Matrix env vars for the default account ` +
|
||||
`(for example MATRIX_HOMESERVER + MATRIX_ACCESS_TOKEN, MATRIX_USER_ID + MATRIX_PASSWORD, ` +
|
||||
`or ${defaultKeys.homeserver} + ${defaultKeys.accessToken}).`,
|
||||
};
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user