diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index abb5b50a5ce..8d518a7b831 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -317,7 +317,9 @@ jobs: - name: Check docs run: pnpm check:docs - secrets: + skills-python: + needs: [docs-scope, changed-scope] + if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true') runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout @@ -330,10 +332,39 @@ jobs: with: python-version: "3.12" - - name: Install detect-secrets + - name: Install Python tooling run: | python -m pip install --upgrade pip - python -m pip install detect-secrets==1.5.0 + python -m pip install pytest ruff + + - name: Lint Python skill scripts + run: python -m ruff check skills + + - name: Test skill Python scripts + run: python -m pytest -q skills + + secrets: + runs-on: blacksmith-16vcpu-ubuntu-2404 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: false + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + install-bun: "false" + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install pre-commit + run: | + python -m pip install --upgrade pip + python -m pip install pre-commit detect-secrets==1.5.0 - name: Detect secrets run: | @@ -342,6 +373,30 @@ jobs: exit 1 fi + - name: Detect committed private keys + run: pre-commit run --all-files detect-private-key + + - name: Audit changed GitHub workflows with zizmor + run: | + set -euo pipefail + + if [ "${{ github.event_name }}" = "push" ]; then + BASE="${{ github.event.before }}" + else + BASE="${{ github.event.pull_request.base.sha }}" + fi + + mapfile -t workflow_files < <(git diff --name-only "$BASE" HEAD -- '.github/workflows/*.yml' '.github/workflows/*.yaml') + if [ "${#workflow_files[@]}" -eq 0 ]; then + echo "No workflow changes detected; skipping zizmor." + exit 0 + fi + + pre-commit run zizmor --files "${workflow_files[@]}" + + - name: Audit production dependencies + run: pre-commit run --all-files pnpm-audit-prod + checks-windows: needs: [docs-scope, changed-scope, build-artifacts, check] if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true') diff --git a/.gitignore b/.gitignore index 69d89b2c4cd..cb28d086e6a 100644 --- a/.gitignore +++ b/.gitignore @@ -104,3 +104,12 @@ apps/ios/LocalSigning.xcconfig # Generated protocol schema (produced via pnpm protocol:gen) dist/protocol.schema.json .ant-colony/ + +# Eclipse +**/.project +**/.classpath +**/.settings/ +**/.gradle/ + +# Synthing +**/.stfolder/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e946d18c112..30b6363a34d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,6 +18,8 @@ repos: - id: check-added-large-files args: [--maxkb=500] - id: check-merge-conflict + - id: detect-private-key + exclude: '(^|/)(\.secrets\.baseline$|\.detect-secrets\.cfg$|\.pre-commit-config\.yaml$|apps/ios/fastlane/Fastfile$|.*\.test\.ts$)' # Secret detection (same as CI) - repo: https://github.com/Yelp/detect-secrets @@ -45,7 +47,6 @@ repos: - '=== "string"' - --exclude-lines - 'typeof remote\?\.password === "string"' - # Shell script linting - repo: https://github.com/koalaman/shellcheck-precommit rev: v0.11.0 @@ -69,9 +70,34 @@ repos: args: [--persona=regular, --min-severity=medium, --min-confidence=medium] exclude: "^(vendor/|Swabble/)" + # Python checks for skills scripts + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.1 + hooks: + - id: ruff + files: "^skills/.*\\.py$" + args: [--config, pyproject.toml] + + - repo: local + hooks: + - id: skills-python-tests + name: skills python tests + entry: pytest -q skills + language: python + additional_dependencies: [pytest>=8, <9] + pass_filenames: false + files: "^skills/.*\\.py$" + # Project checks (same commands as CI) - repo: local hooks: + # pnpm audit --prod --audit-level=high + - id: pnpm-audit-prod + name: pnpm-audit-prod + entry: pnpm audit --prod --audit-level=high + language: system + pass_filenames: false + # oxlint --type-aware src test - id: oxlint name: oxlint diff --git a/CHANGELOG.md b/CHANGELOG.md index 90f2f695be6..7fcc92a6cb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,35 @@ Docs: https://docs.openclaw.ai ## Unreleased -## 2026.2.22 (Unreleased) +## 2026.2.23 (Unreleased) ### Changes +### Breaking + +### Fixes + +- Auto-reply/Inbound metadata: hide direct-chat `message_id`/`message_id_full` and sender metadata only from normalized chat type (not sender-id sentinels), preserving group metadata visibility and preventing sender-id spoofed direct-mode classification. (#24373) thanks @jd316. +- Security/Exec: detect obfuscated commands before exec allowlist decisions and require explicit approval for obfuscation patterns. (#8592) Thanks @CornBrother0x and @vincentkoc. +- Agents/Compaction: pass `agentDir` into manual `/compact` command runs so compaction auth/profile resolution stays scoped to the active agent. (#24133) thanks @Glucksberg. +- Agents/Models: codify `agents.defaults.model` / `agents.defaults.imageModel` config-boundary input as `string | {primary,fallbacks}`, split explicit vs effective model resolution, and fix `models status --agent` source attribution so defaults-inherited agents are labeled as `defaults` while runtime selection still honors defaults fallback. (#24210) thanks @bianbiandashen. +- Security/Skills: escape user-controlled prompt, filename, and output-path values in `openai-image-gen` HTML gallery generation to prevent stored XSS in generated `index.html` output. (#12538) Thanks @CornBrother0x. +- Security/Skills: harden `skill-creator` packaging by skipping symlink entries and rejecting files whose resolved paths escape the selected skill root. (#24260, #16959) Thanks @CornBrother0x and @vincentkoc. +- Security/OTEL: redact sensitive values (API keys, tokens, credential fields) from diagnostics-otel log bodies, log attributes, and error/reason span fields before OTLP export. (#12542) Thanks @brandonwise. +- Providers/OpenRouter: remove conflicting top-level `reasoning_effort` when injecting nested `reasoning.effort`, preventing OpenRouter 400 payload-validation failures for reasoning models. (#24120) thanks @tenequm. +- Skills/Python: add CI + pre-commit linting (`ruff`) and pytest discovery coverage for Python scripts/tests under `skills/`, including package test execution from repo root. Thanks @vincentkoc. +- Sessions/Store: canonicalize inbound mixed-case session keys for metadata and route updates, and migrate legacy case-variant entries to a single lowercase key to prevent duplicate sessions and missing TUI/WebUI history. (#9561) Thanks @hillghost86. +- Security/CI: add pre-commit security hook coverage for private-key detection and production dependency auditing, and enforce those checks in CI alongside baseline secret scanning. Thanks @vincentkoc. +- Skills/Python: harden skill script packaging and validation edge cases (self-including `.skill` outputs, CRLF frontmatter parsing, strict `--days` validation, and safer image file loading), with expanded Python regression coverage. Thanks @vincentkoc. +- Config/Write: apply `unsetPaths` with immutable path-copy updates so config writes never mutate caller-provided objects, and harden `openclaw config get/set/unset` path traversal by rejecting prototype-key segments and inherited-property traversal. (#24134) thanks @frankekn. +- Agents/Failover: treat HTTP 502/503/504 errors as failover-eligible transient timeouts so fallback chains can switch providers/models during upstream outages instead of retrying the same failing target. (#20999) Thanks @taw0002 and @vincentkoc. + +## 2026.2.23 + +### Changes + +- Control UI/Agents: make the Tools panel data-driven from runtime `tools.catalog`, add per-tool provenance labels (`core` / `plugin:` + optional marker), and keep a static fallback list when the runtime catalog is unavailable. +- Control UI/Cron: add full web cron edit parity (including clone and richer validation/help text), plus all-jobs run history with pagination/search/sort/multi-filter controls and improved cron page layout for cleaner scheduling and failure triage workflows. - Provider/Mistral: add support for the Mistral provider, including memory embeddings and voice support. (#23845) Thanks @vincentkoc. - Update/Core: add an optional built-in auto-updater for package installs (`update.auto.*`), default-off, with stable rollout delay+jitter and beta hourly cadence. - CLI/Update: add `openclaw update --dry-run` to preview channel/tag/target/restart actions without mutating config, installing, syncing plugins, or restarting. @@ -27,6 +52,7 @@ Docs: https://docs.openclaw.ai ### Breaking +- **BREAKING:** removed Google Antigravity provider support and the bundled `google-antigravity-auth` plugin. Existing `google-antigravity/*` model/profile configs no longer work; migrate to `google-gemini-cli` or other supported providers. - **BREAKING:** tool-failure replies now hide raw error details by default. OpenClaw still sends a failure summary, but detailed error suffixes (for example provider/runtime messages and local path fragments) now require `/verbose on` or `/verbose full`. - **BREAKING:** CLI local onboarding now sets `session.dmScope` to `per-channel-peer` by default for new/implicit DM scope configuration. If you depend on shared DM continuity across senders, explicitly set `session.dmScope` to `main`. (#23468) Thanks @bmendonca3. - **BREAKING:** unify channel preview-streaming config to `channels..streaming` with enum values `off | partial | block | progress`, and move Slack native stream toggle to `channels.slack.nativeStreaming`. Legacy keys (`streamMode`, Slack boolean `streaming`) are still read and migrated by `openclaw doctor --fix`, but canonical saved config/docs now use the unified names. @@ -79,6 +105,8 @@ Docs: https://docs.openclaw.ai - Cron/Run log: clean up settled per-path run-log write queue entries so long-running cron uptime does not retain stale promise bookkeeping in memory. - Cron/Run log: harden `cron.runs` run-log path resolution by rejecting path-separator `id`/`jobId` inputs and enforcing reads within the per-cron `runs/` directory. - Cron/Announce: when announce delivery target resolution fails (for example multiple configured channels with no explicit target), skip injecting fallback `Cron (error): ...` into the main session so runs fail cleanly without accidental last-route sends. (#24074) +- Cron/Telegram: validate cron `delivery.to` with shared Telegram target parsing and resolve legacy `@username`/`t.me` targets to numeric IDs at send-time for deterministic delivery target writeback. (#21930) Thanks @kesor. +- Telegram/Targets: normalize unprefixed topic-qualified targets through the shared parse/normalize path so valid `@channel:topic:` and `:topic:` routes are recognized again. (#24166) Thanks @obviyus. - Cron/Isolation: force fresh session IDs for isolated cron runs so `sessionTarget="isolated"` executions never reuse prior run context. (#23470) Thanks @echoVic. - Plugins/Install: strip `workspace:*` devDependency entries from copied plugin manifests before `npm install --omit=dev`, preventing `EUNSUPPORTEDPROTOCOL` install failures for npm-published channel plugins (including Feishu and MS Teams). - Feishu/Plugins: restore bundled Feishu SDK availability for global installs and strip `openclaw: workspace:*` from plugin `devDependencies` during plugin-version sync so npm-installed Feishu plugins do not fail dependency install. (#23611, #23645, #23603) @@ -95,6 +123,7 @@ Docs: https://docs.openclaw.ai - Channels/Group policy: fail closed when `groupPolicy: "allowlist"` is set without explicit `groups`, honor account-level `groupPolicy` overrides, and enforce `groupPolicy: "disabled"` as a hard group block. (#22215) Thanks @etereo. - Telegram/Discord extensions: propagate trusted `mediaLocalRoots` through extension outbound `sendMedia` options so extension direct-send media paths honor agent-scoped local-media allowlists. (#20029, #21903, #23227) - Agents/Exec: honor explicit agent context when resolving `tools.exec` defaults for runs with opaque/non-agent session keys, so per-agent `host/security/ask` policies are applied consistently. (#11832) +- CLI/Sessions: resolve implicit session-store path templates with the configured default agent ID so named-agent setups do not silently read/write stale `agent:main` session/auth stores. (#22685) Thanks @sene1337. - Doctor/Security: add an explicit warning that `approvals.exec.enabled=false` disables forwarding only, while enforcement remains driven by host-local `exec-approvals.json` policy. (#15047) - Sandbox/Docker: default sandbox container user to the workspace owner `uid:gid` when `agents.*.sandbox.docker.user` is unset, fixing non-root gateway file-tool permissions under capability-dropped containers. (#20979) - Plugins/Media sandbox: propagate trusted `mediaLocalRoots` through plugin action dispatch (including Discord/Telegram action adapters) so plugin send paths enforce the same agent-scoped local-media sandbox roots as core outbound sends. (#20258, #22718) @@ -196,6 +225,7 @@ Docs: https://docs.openclaw.ai - Agents/Subagents: honor `tools.subagents.tools.alsoAllow` and explicit subagent `allow` entries when resolving built-in subagent deny defaults, so explicitly granted tools (for example `sessions_send`) are no longer blocked unless re-denied in `tools.subagents.tools.deny`. (#23359) Thanks @goren-beehero. - Agents/Subagents: make announce call timeouts configurable via `agents.defaults.subagents.announceTimeoutMs` and restore a 60s default to prevent false timeout failures on slower announce paths. (#22719) Thanks @Valadon. - Agents/Diagnostics: include resolved lifecycle error text in `embedded run agent end` warnings so UI/TUI “Connection error” runs expose actionable provider failure reasons in gateway logs. (#23054) Thanks @Raize. +- Agents/Auth profiles: resolve `agentCommand` session scope before choosing `agentDir`/workspace so resumed runs no longer read auth from `agents/main/agent` when the resolved session belongs to a different/default agent (for example `agent:exec:*` sessions). (#24016) Thanks @abersonFAC. - Agents/Auth profiles: skip auth-profile cooldown writes for timeout failures in embedded runner rotation so model/network timeouts do not poison same-provider fallback model selection while still allowing in-turn account rotation. (#22622) Thanks @vageeshkumar. - Plugins/Hooks: run legacy `before_agent_start` once per agent turn and reuse that result across model-resolve and prompt-build compatibility paths, preventing duplicate hook side effects (for example duplicate external API calls). (#23289) Thanks @ksato8710. - Models/Config: default missing Anthropic provider/model `api` fields to `anthropic-messages` during config validation so custom relay model entries are preserved instead of being dropped by runtime model registry validation. (#23332) Thanks @bigbigmonkey123. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2beaeeba290..10d4f290704 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,7 +51,7 @@ Welcome to the lobster tank! 🦞 1. **Bugs & small fixes** → Open a PR! 2. **New features / architecture** → Start a [GitHub Discussion](https://github.com/openclaw/openclaw/discussions) or ask in Discord first -3. **Questions** → Discord #setup-help +3. **Questions** → Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828) ## Before You PR diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index b91b1e21537..52e1014e7ba 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -21,8 +21,8 @@ android { applicationId = "ai.openclaw.android" minSdk = 31 targetSdk = 36 - versionCode = 202602210 - versionName = "2026.2.21" + versionCode = 202602230 + versionName = "2026.2.23" ndk { // Support all major ABIs — native libs are tiny (~47 KB per ABI) abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64") diff --git a/apps/ios/ShareExtension/Info.plist b/apps/ios/ShareExtension/Info.plist index 0656afbf2d7..aedea62a5e1 100644 --- a/apps/ios/ShareExtension/Info.plist +++ b/apps/ios/ShareExtension/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 2026.2.21 + 2026.2.23 CFBundleVersion - 20260220 + 20260223 NSExtension NSExtensionAttributes diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist index c3b469e7092..c34fccb5052 100644 --- a/apps/ios/Sources/Info.plist +++ b/apps/ios/Sources/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.2.21 + 2026.2.23 CFBundleURLTypes @@ -32,7 +32,7 @@ CFBundleVersion - 20260220 + 20260223 NSAppTransportSecurity NSAllowsArbitraryLoadsInWebContent diff --git a/apps/ios/Tests/Info.plist b/apps/ios/Tests/Info.plist index 7fc8d827044..a3420e27321 100644 --- a/apps/ios/Tests/Info.plist +++ b/apps/ios/Tests/Info.plist @@ -17,8 +17,8 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 2026.2.21 + 2026.2.23 CFBundleVersion - 20260220 + 20260223 diff --git a/apps/ios/WatchApp/Info.plist b/apps/ios/WatchApp/Info.plist index cc5dbf6cdda..4e309b031a6 100644 --- a/apps/ios/WatchApp/Info.plist +++ b/apps/ios/WatchApp/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.2.21 + 2026.2.23 CFBundleVersion - 20260220 + 20260223 WKCompanionAppBundleIdentifier $(OPENCLAW_APP_BUNDLE_ID) WKWatchKitApp diff --git a/apps/ios/WatchExtension/Info.plist b/apps/ios/WatchExtension/Info.plist index 2d6b7baa7b8..1b5f28dfc43 100644 --- a/apps/ios/WatchExtension/Info.plist +++ b/apps/ios/WatchExtension/Info.plist @@ -15,9 +15,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 2026.2.21 + 2026.2.23 CFBundleVersion - 20260220 + 20260223 NSExtension NSExtensionAttributes diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 613322f3e8e..1028876e510 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -92,8 +92,8 @@ targets: - CFBundleURLName: ai.openclaw.ios CFBundleURLSchemes: - openclaw - CFBundleShortVersionString: "2026.2.21" - CFBundleVersion: "20260220" + CFBundleShortVersionString: "2026.2.23" + CFBundleVersion: "20260223" UILaunchScreen: {} UIApplicationSceneManifest: UIApplicationSupportsMultipleScenes: false @@ -146,8 +146,8 @@ targets: path: ShareExtension/Info.plist properties: CFBundleDisplayName: OpenClaw Share - CFBundleShortVersionString: "2026.2.21" - CFBundleVersion: "20260220" + CFBundleShortVersionString: "2026.2.23" + CFBundleVersion: "20260223" NSExtension: NSExtensionPointIdentifier: com.apple.share-services NSExtensionPrincipalClass: "$(PRODUCT_MODULE_NAME).ShareViewController" @@ -176,8 +176,8 @@ targets: path: WatchApp/Info.plist properties: CFBundleDisplayName: OpenClaw - CFBundleShortVersionString: "2026.2.21" - CFBundleVersion: "20260220" + CFBundleShortVersionString: "2026.2.23" + CFBundleVersion: "20260223" WKCompanionAppBundleIdentifier: "$(OPENCLAW_APP_BUNDLE_ID)" WKWatchKitApp: true @@ -200,8 +200,8 @@ targets: path: WatchExtension/Info.plist properties: CFBundleDisplayName: OpenClaw - CFBundleShortVersionString: "2026.2.21" - CFBundleVersion: "20260220" + CFBundleShortVersionString: "2026.2.23" + CFBundleVersion: "20260223" NSExtension: NSExtensionAttributes: WKAppBundleIdentifier: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)" @@ -228,5 +228,5 @@ targets: path: Tests/Info.plist properties: CFBundleDisplayName: OpenClawTests - CFBundleShortVersionString: "2026.2.21" - CFBundleVersion: "20260220" + CFBundleShortVersionString: "2026.2.23" + CFBundleVersion: "20260223" diff --git a/apps/macos/Sources/OpenClaw/Resources/Info.plist b/apps/macos/Sources/OpenClaw/Resources/Info.plist index e7ca1ad5487..3a425368d09 100644 --- a/apps/macos/Sources/OpenClaw/Resources/Info.plist +++ b/apps/macos/Sources/OpenClaw/Resources/Info.plist @@ -15,9 +15,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.2.21 + 2026.2.23 CFBundleVersion - 202602210 + 202602230 CFBundleIconFile OpenClaw CFBundleURLTypes diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index 2909418d0c3..af7b1ccafdc 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -527,6 +527,7 @@ public struct AgentParams: Codable, Sendable { public let groupchannel: String? public let groupspace: String? public let timeout: Int? + public let besteffortdeliver: Bool? public let lane: String? public let extrasystemprompt: String? public let inputprovenance: [String: AnyCodable]? @@ -553,6 +554,7 @@ public struct AgentParams: Codable, Sendable { groupchannel: String?, groupspace: String?, timeout: Int?, + besteffortdeliver: Bool?, lane: String?, extrasystemprompt: String?, inputprovenance: [String: AnyCodable]?, @@ -578,6 +580,7 @@ public struct AgentParams: Codable, Sendable { self.groupchannel = groupchannel self.groupspace = groupspace self.timeout = timeout + self.besteffortdeliver = besteffortdeliver self.lane = lane self.extrasystemprompt = extrasystemprompt self.inputprovenance = inputprovenance @@ -605,6 +608,7 @@ public struct AgentParams: Codable, Sendable { case groupchannel = "groupChannel" case groupspace = "groupSpace" case timeout + case besteffortdeliver = "bestEffortDeliver" case lane case extrasystemprompt = "extraSystemPrompt" case inputprovenance = "inputProvenance" @@ -2170,6 +2174,132 @@ public struct SkillsStatusParams: Codable, Sendable { } } +public struct ToolsCatalogParams: Codable, Sendable { + public let agentid: String? + public let includeplugins: Bool? + + public init( + agentid: String?, + includeplugins: Bool?) + { + self.agentid = agentid + self.includeplugins = includeplugins + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case includeplugins = "includePlugins" + } +} + +public struct ToolCatalogProfile: Codable, Sendable { + public let id: AnyCodable + public let label: String + + public init( + id: AnyCodable, + label: String) + { + self.id = id + self.label = label + } + + private enum CodingKeys: String, CodingKey { + case id + case label + } +} + +public struct ToolCatalogEntry: Codable, Sendable { + public let id: String + public let label: String + public let description: String + public let source: AnyCodable + public let pluginid: String? + public let optional: Bool? + public let defaultprofiles: [AnyCodable] + + public init( + id: String, + label: String, + description: String, + source: AnyCodable, + pluginid: String?, + optional: Bool?, + defaultprofiles: [AnyCodable]) + { + self.id = id + self.label = label + self.description = description + self.source = source + self.pluginid = pluginid + self.optional = optional + self.defaultprofiles = defaultprofiles + } + + private enum CodingKeys: String, CodingKey { + case id + case label + case description + case source + case pluginid = "pluginId" + case optional + case defaultprofiles = "defaultProfiles" + } +} + +public struct ToolCatalogGroup: Codable, Sendable { + public let id: String + public let label: String + public let source: AnyCodable + public let pluginid: String? + public let tools: [ToolCatalogEntry] + + public init( + id: String, + label: String, + source: AnyCodable, + pluginid: String?, + tools: [ToolCatalogEntry]) + { + self.id = id + self.label = label + self.source = source + self.pluginid = pluginid + self.tools = tools + } + + private enum CodingKeys: String, CodingKey { + case id + case label + case source + case pluginid = "pluginId" + case tools + } +} + +public struct ToolsCatalogResult: Codable, Sendable { + public let agentid: String + public let profiles: [ToolCatalogProfile] + public let groups: [ToolCatalogGroup] + + public init( + agentid: String, + profiles: [ToolCatalogProfile], + groups: [ToolCatalogGroup]) + { + self.agentid = agentid + self.profiles = profiles + self.groups = groups + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case profiles + case groups + } +} + public struct SkillsBinsParams: Codable, Sendable {} public struct SkillsBinsResult: Codable, Sendable { @@ -2306,15 +2436,39 @@ public struct CronJob: Codable, Sendable { public struct CronListParams: Codable, Sendable { public let includedisabled: Bool? + public let limit: Int? + public let offset: Int? + public let query: String? + public let enabled: AnyCodable? + public let sortby: AnyCodable? + public let sortdir: AnyCodable? public init( - includedisabled: Bool?) + includedisabled: Bool?, + limit: Int?, + offset: Int?, + query: String?, + enabled: AnyCodable?, + sortby: AnyCodable?, + sortdir: AnyCodable?) { self.includedisabled = includedisabled + self.limit = limit + self.offset = offset + self.query = query + self.enabled = enabled + self.sortby = sortby + self.sortdir = sortdir } private enum CodingKeys: String, CodingKey { case includedisabled = "includeDisabled" + case limit + case offset + case query + case enabled + case sortby = "sortBy" + case sortdir = "sortDir" } } @@ -2374,6 +2528,60 @@ public struct CronAddParams: Codable, Sendable { } } +public struct CronRunsParams: Codable, Sendable { + public let scope: AnyCodable? + public let id: String? + public let jobid: String? + public let limit: Int? + public let offset: Int? + public let statuses: [AnyCodable]? + public let status: AnyCodable? + public let deliverystatuses: [AnyCodable]? + public let deliverystatus: AnyCodable? + public let query: String? + public let sortdir: AnyCodable? + + public init( + scope: AnyCodable?, + id: String?, + jobid: String?, + limit: Int?, + offset: Int?, + statuses: [AnyCodable]?, + status: AnyCodable?, + deliverystatuses: [AnyCodable]?, + deliverystatus: AnyCodable?, + query: String?, + sortdir: AnyCodable?) + { + self.scope = scope + self.id = id + self.jobid = jobid + self.limit = limit + self.offset = offset + self.statuses = statuses + self.status = status + self.deliverystatuses = deliverystatuses + self.deliverystatus = deliverystatus + self.query = query + self.sortdir = sortdir + } + + private enum CodingKeys: String, CodingKey { + case scope + case id + case jobid = "jobId" + case limit + case offset + case statuses + case status + case deliverystatuses = "deliveryStatuses" + case deliverystatus = "deliveryStatus" + case query + case sortdir = "sortDir" + } +} + public struct CronRunLogEntry: Codable, Sendable { public let ts: Int public let jobid: String @@ -2389,6 +2597,10 @@ public struct CronRunLogEntry: Codable, Sendable { public let runatms: Int? public let durationms: Int? public let nextrunatms: Int? + public let model: String? + public let provider: String? + public let usage: [String: AnyCodable]? + public let jobname: String? public init( ts: Int, @@ -2404,7 +2616,11 @@ public struct CronRunLogEntry: Codable, Sendable { sessionkey: String?, runatms: Int?, durationms: Int?, - nextrunatms: Int?) + nextrunatms: Int?, + model: String?, + provider: String?, + usage: [String: AnyCodable]?, + jobname: String?) { self.ts = ts self.jobid = jobid @@ -2420,6 +2636,10 @@ public struct CronRunLogEntry: Codable, Sendable { self.runatms = runatms self.durationms = durationms self.nextrunatms = nextrunatms + self.model = model + self.provider = provider + self.usage = usage + self.jobname = jobname } private enum CodingKeys: String, CodingKey { @@ -2437,6 +2657,10 @@ public struct CronRunLogEntry: Codable, Sendable { case runatms = "runAtMs" case durationms = "durationMs" case nextrunatms = "nextRunAtMs" + case model + case provider + case usage + case jobname = "jobName" } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 2909418d0c3..af7b1ccafdc 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -527,6 +527,7 @@ public struct AgentParams: Codable, Sendable { public let groupchannel: String? public let groupspace: String? public let timeout: Int? + public let besteffortdeliver: Bool? public let lane: String? public let extrasystemprompt: String? public let inputprovenance: [String: AnyCodable]? @@ -553,6 +554,7 @@ public struct AgentParams: Codable, Sendable { groupchannel: String?, groupspace: String?, timeout: Int?, + besteffortdeliver: Bool?, lane: String?, extrasystemprompt: String?, inputprovenance: [String: AnyCodable]?, @@ -578,6 +580,7 @@ public struct AgentParams: Codable, Sendable { self.groupchannel = groupchannel self.groupspace = groupspace self.timeout = timeout + self.besteffortdeliver = besteffortdeliver self.lane = lane self.extrasystemprompt = extrasystemprompt self.inputprovenance = inputprovenance @@ -605,6 +608,7 @@ public struct AgentParams: Codable, Sendable { case groupchannel = "groupChannel" case groupspace = "groupSpace" case timeout + case besteffortdeliver = "bestEffortDeliver" case lane case extrasystemprompt = "extraSystemPrompt" case inputprovenance = "inputProvenance" @@ -2170,6 +2174,132 @@ public struct SkillsStatusParams: Codable, Sendable { } } +public struct ToolsCatalogParams: Codable, Sendable { + public let agentid: String? + public let includeplugins: Bool? + + public init( + agentid: String?, + includeplugins: Bool?) + { + self.agentid = agentid + self.includeplugins = includeplugins + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case includeplugins = "includePlugins" + } +} + +public struct ToolCatalogProfile: Codable, Sendable { + public let id: AnyCodable + public let label: String + + public init( + id: AnyCodable, + label: String) + { + self.id = id + self.label = label + } + + private enum CodingKeys: String, CodingKey { + case id + case label + } +} + +public struct ToolCatalogEntry: Codable, Sendable { + public let id: String + public let label: String + public let description: String + public let source: AnyCodable + public let pluginid: String? + public let optional: Bool? + public let defaultprofiles: [AnyCodable] + + public init( + id: String, + label: String, + description: String, + source: AnyCodable, + pluginid: String?, + optional: Bool?, + defaultprofiles: [AnyCodable]) + { + self.id = id + self.label = label + self.description = description + self.source = source + self.pluginid = pluginid + self.optional = optional + self.defaultprofiles = defaultprofiles + } + + private enum CodingKeys: String, CodingKey { + case id + case label + case description + case source + case pluginid = "pluginId" + case optional + case defaultprofiles = "defaultProfiles" + } +} + +public struct ToolCatalogGroup: Codable, Sendable { + public let id: String + public let label: String + public let source: AnyCodable + public let pluginid: String? + public let tools: [ToolCatalogEntry] + + public init( + id: String, + label: String, + source: AnyCodable, + pluginid: String?, + tools: [ToolCatalogEntry]) + { + self.id = id + self.label = label + self.source = source + self.pluginid = pluginid + self.tools = tools + } + + private enum CodingKeys: String, CodingKey { + case id + case label + case source + case pluginid = "pluginId" + case tools + } +} + +public struct ToolsCatalogResult: Codable, Sendable { + public let agentid: String + public let profiles: [ToolCatalogProfile] + public let groups: [ToolCatalogGroup] + + public init( + agentid: String, + profiles: [ToolCatalogProfile], + groups: [ToolCatalogGroup]) + { + self.agentid = agentid + self.profiles = profiles + self.groups = groups + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case profiles + case groups + } +} + public struct SkillsBinsParams: Codable, Sendable {} public struct SkillsBinsResult: Codable, Sendable { @@ -2306,15 +2436,39 @@ public struct CronJob: Codable, Sendable { public struct CronListParams: Codable, Sendable { public let includedisabled: Bool? + public let limit: Int? + public let offset: Int? + public let query: String? + public let enabled: AnyCodable? + public let sortby: AnyCodable? + public let sortdir: AnyCodable? public init( - includedisabled: Bool?) + includedisabled: Bool?, + limit: Int?, + offset: Int?, + query: String?, + enabled: AnyCodable?, + sortby: AnyCodable?, + sortdir: AnyCodable?) { self.includedisabled = includedisabled + self.limit = limit + self.offset = offset + self.query = query + self.enabled = enabled + self.sortby = sortby + self.sortdir = sortdir } private enum CodingKeys: String, CodingKey { case includedisabled = "includeDisabled" + case limit + case offset + case query + case enabled + case sortby = "sortBy" + case sortdir = "sortDir" } } @@ -2374,6 +2528,60 @@ public struct CronAddParams: Codable, Sendable { } } +public struct CronRunsParams: Codable, Sendable { + public let scope: AnyCodable? + public let id: String? + public let jobid: String? + public let limit: Int? + public let offset: Int? + public let statuses: [AnyCodable]? + public let status: AnyCodable? + public let deliverystatuses: [AnyCodable]? + public let deliverystatus: AnyCodable? + public let query: String? + public let sortdir: AnyCodable? + + public init( + scope: AnyCodable?, + id: String?, + jobid: String?, + limit: Int?, + offset: Int?, + statuses: [AnyCodable]?, + status: AnyCodable?, + deliverystatuses: [AnyCodable]?, + deliverystatus: AnyCodable?, + query: String?, + sortdir: AnyCodable?) + { + self.scope = scope + self.id = id + self.jobid = jobid + self.limit = limit + self.offset = offset + self.statuses = statuses + self.status = status + self.deliverystatuses = deliverystatuses + self.deliverystatus = deliverystatus + self.query = query + self.sortdir = sortdir + } + + private enum CodingKeys: String, CodingKey { + case scope + case id + case jobid = "jobId" + case limit + case offset + case statuses + case status + case deliverystatuses = "deliveryStatuses" + case deliverystatus = "deliveryStatus" + case query + case sortdir = "sortDir" + } +} + public struct CronRunLogEntry: Codable, Sendable { public let ts: Int public let jobid: String @@ -2389,6 +2597,10 @@ public struct CronRunLogEntry: Codable, Sendable { public let runatms: Int? public let durationms: Int? public let nextrunatms: Int? + public let model: String? + public let provider: String? + public let usage: [String: AnyCodable]? + public let jobname: String? public init( ts: Int, @@ -2404,7 +2616,11 @@ public struct CronRunLogEntry: Codable, Sendable { sessionkey: String?, runatms: Int?, durationms: Int?, - nextrunatms: Int?) + nextrunatms: Int?, + model: String?, + provider: String?, + usage: [String: AnyCodable]?, + jobname: String?) { self.ts = ts self.jobid = jobid @@ -2420,6 +2636,10 @@ public struct CronRunLogEntry: Codable, Sendable { self.runatms = runatms self.durationms = durationms self.nextrunatms = nextrunatms + self.model = model + self.provider = provider + self.usage = usage + self.jobname = jobname } private enum CodingKeys: String, CodingKey { @@ -2437,6 +2657,10 @@ public struct CronRunLogEntry: Codable, Sendable { case runatms = "runAtMs" case durationms = "durationMs" case nextrunatms = "nextRunAtMs" + case model + case provider + case usage + case jobname = "jobName" } } diff --git a/docs/experiments/.DS_Store b/docs/experiments/.DS_Store deleted file mode 100644 index b13221a744b..00000000000 Binary files a/docs/experiments/.DS_Store and /dev/null differ diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 50f40998ca1..209427ca277 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -718,9 +718,15 @@ Time format in system prompt. Default: `auto` (OS preference). } ``` +- `model`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`). + - String form sets only the primary model. + - Object form sets primary plus ordered failover models. +- `imageModel`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`). + - Used by the `image` tool path as its vision-model config. + - Also used as fallback routing when the selected/default model cannot accept image input. - `model.primary`: format `provider/model` (e.g. `anthropic/claude-opus-4-6`). If you omit the provider, OpenClaw assumes `anthropic` (deprecated). - `models`: the configured model catalog and allowlist for `/model`. Each entry can include `alias` (shortcut) and `params` (provider-specific: `temperature`, `maxTokens`). -- `imageModel`: only used if the primary model lacks image input. +- Config writers that mutate these fields (for example `/models set`, `/models set-image`, and fallback add/remove commands) save canonical object form and preserve existing fallback lists when possible. - `maxConcurrent`: max parallel agent runs across sessions (each session still serialized). Default: 1. **Built-in alias shorthands** (only apply when the model is in `agents.defaults.models`): diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 8bcedbe0631..85a69aca679 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -170,6 +170,14 @@ The Gateway treats these as **claims** and enforces server-side allowlists. - Nodes may call `skills.bins` to fetch the current list of skill executables for auto-allow checks. +### Operator helper methods + +- Operators may call `tools.catalog` (`operator.read`) to fetch the runtime tool catalog for an + agent. The response includes grouped tools and provenance metadata: + - `source`: `core` or `plugin` + - `pluginId`: plugin owner when `source="plugin"` + - `optional`: whether a plugin tool is optional + ## Exec approvals - When an exec request needs approval, the gateway broadcasts `exec.approval.requested`. diff --git a/docs/platforms/mac/release.md b/docs/platforms/mac/release.md index 7d3a8d0190b..029ab3eed93 100644 --- a/docs/platforms/mac/release.md +++ b/docs/platforms/mac/release.md @@ -34,17 +34,17 @@ Notes: # From repo root; set release IDs so Sparkle feed is enabled. # APP_BUILD must be numeric + monotonic for Sparkle compare. BUNDLE_ID=bot.molt.mac \ -APP_VERSION=2026.2.21 \ +APP_VERSION=2026.2.23 \ APP_BUILD="$(git rev-list --count HEAD)" \ BUILD_CONFIG=release \ SIGN_IDENTITY="Developer ID Application: ()" \ scripts/package-mac-app.sh # Zip for distribution (includes resource forks for Sparkle delta support) -ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.2.21.zip +ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.2.23.zip # Optional: also build a styled DMG for humans (drag to /Applications) -scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.21.dmg +scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.23.dmg # Recommended: build + notarize/staple zip + DMG # First, create a keychain profile once: @@ -52,14 +52,14 @@ scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.21.dmg # --apple-id "" --team-id "" --password "" NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \ BUNDLE_ID=bot.molt.mac \ -APP_VERSION=2026.2.21 \ +APP_VERSION=2026.2.23 \ APP_BUILD="$(git rev-list --count HEAD)" \ BUILD_CONFIG=release \ SIGN_IDENTITY="Developer ID Application: ()" \ scripts/package-mac-dist.sh # Optional: ship dSYM alongside the release -ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.2.21.dSYM.zip +ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.2.23.dSYM.zip ``` ## Appcast entry @@ -67,7 +67,7 @@ ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenCl Use the release note generator so Sparkle renders formatted HTML notes: ```bash -SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.2.21.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml +SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.2.23.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml ``` Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry. @@ -75,7 +75,7 @@ Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when ## Publish & verify -- Upload `OpenClaw-2026.2.21.zip` (and `OpenClaw-2026.2.21.dSYM.zip`) to the GitHub release for tag `v2026.2.21`. +- Upload `OpenClaw-2026.2.23.zip` (and `OpenClaw-2026.2.23.dSYM.zip`) to the GitHub release for tag `v2026.2.23`. - Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`. - Sanity checks: - `curl -I https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml` returns 200. diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 9ff05572ca0..ebaad5aef90 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -67,7 +67,7 @@ you revoke it with `openclaw devices revoke --device --role `. See - Channels: WhatsApp/Telegram/Discord/Slack + plugin channels (Mattermost, etc.) status + QR login + per-channel config (`channels.status`, `web.login.*`, `config.patch`) - Instances: presence list + refresh (`system-presence`) - Sessions: list + per-session thinking/verbose overrides (`sessions.list`, `sessions.patch`) -- Cron jobs: list/add/run/enable/disable + run history (`cron.*`) +- Cron jobs: list/add/edit/run/enable/disable + run history (`cron.*`) - Skills: status, enable/disable, install, API key updates (`skills.*`) - Nodes: list + caps (`node.list`) - Exec approvals: edit gateway or node allowlists + ask policy for `exec host=gateway/node` (`exec.approvals.*`) @@ -85,6 +85,9 @@ Cron jobs panel notes: - Channel/target fields appear when announce is selected. - Webhook mode uses `delivery.mode = "webhook"` with `delivery.to` set to a valid HTTP(S) webhook URL. - For main-session jobs, webhook and none delivery modes are available. +- Advanced edit controls include delete-after-run, clear agent override, cron exact/stagger options, + agent model/thinking overrides, and best-effort delivery toggles. +- Form validation is inline with field-level errors; invalid values disable the save button until fixed. - Set `cron.webhookToken` to send a dedicated bearer token, if omitted the webhook is sent without an auth header. - Deprecated fallback: stored legacy jobs with `notify: true` can still use `cron.webhook` until migrated. diff --git a/docs/web/webchat.md b/docs/web/webchat.md index 9853e372159..307a69a8dcf 100644 --- a/docs/web/webchat.md +++ b/docs/web/webchat.md @@ -31,6 +31,14 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket. - History is always fetched from the gateway (no local file watching). - If the gateway is unreachable, WebChat is read-only. +## Control UI agents tools panel + +- The Control UI `/agents` Tools panel fetches a runtime catalog via `tools.catalog` and labels each + tool as `core` or `plugin:` (plus `optional` for optional plugin tools). +- If `tools.catalog` is unavailable, the panel falls back to a built-in static list. +- The panel edits profile and override config, but effective runtime access still follows policy + precedence (`allow`/`deny`, per-agent and provider/channel overrides). + ## Remote use - Remote mode tunnels the gateway WebSocket over SSH/Tailscale. diff --git a/extensions/bluebubbles/package.json b/extensions/bluebubbles/package.json index da6b3ad9afb..102b7171711 100644 --- a/extensions/bluebubbles/package.json +++ b/extensions/bluebubbles/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/bluebubbles", - "version": "2026.2.22", + "version": "2026.2.23", "description": "OpenClaw BlueBubbles channel plugin", "type": "module", "devDependencies": { diff --git a/extensions/bluebubbles/src/actions.test.ts b/extensions/bluebubbles/src/actions.test.ts index aabc5adf8fe..5db42331207 100644 --- a/extensions/bluebubbles/src/actions.test.ts +++ b/extensions/bluebubbles/src/actions.test.ts @@ -47,6 +47,22 @@ describe("bluebubblesMessageActions", () => { const handleAction = bluebubblesMessageActions.handleAction!; const callHandleAction = (ctx: Omit[0], "channel">) => handleAction({ channel: "bluebubbles", ...ctx }); + const blueBubblesConfig = (): OpenClawConfig => ({ + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }); + const runReactAction = async (params: Record) => { + return await callHandleAction({ + action: "react", + params, + cfg: blueBubblesConfig(), + accountId: null, + }); + }; beforeEach(() => { vi.clearAllMocks(); @@ -285,23 +301,10 @@ describe("bluebubblesMessageActions", () => { it("sends reaction successfully with chatGuid", async () => { const { sendBlueBubblesReaction } = await import("./reactions.js"); - const cfg: OpenClawConfig = { - channels: { - bluebubbles: { - serverUrl: "http://localhost:1234", - password: "test-password", - }, - }, - }; - const result = await callHandleAction({ - action: "react", - params: { - emoji: "❤️", - messageId: "msg-123", - chatGuid: "iMessage;-;+15551234567", - }, - cfg, - accountId: null, + const result = await runReactAction({ + emoji: "❤️", + messageId: "msg-123", + chatGuid: "iMessage;-;+15551234567", }); expect(sendBlueBubblesReaction).toHaveBeenCalledWith( @@ -320,24 +323,11 @@ describe("bluebubblesMessageActions", () => { it("sends reaction removal successfully", async () => { const { sendBlueBubblesReaction } = await import("./reactions.js"); - const cfg: OpenClawConfig = { - channels: { - bluebubbles: { - serverUrl: "http://localhost:1234", - password: "test-password", - }, - }, - }; - const result = await callHandleAction({ - action: "react", - params: { - emoji: "❤️", - messageId: "msg-123", - chatGuid: "iMessage;-;+15551234567", - remove: true, - }, - cfg, - accountId: null, + const result = await runReactAction({ + emoji: "❤️", + messageId: "msg-123", + chatGuid: "iMessage;-;+15551234567", + remove: true, }); expect(sendBlueBubblesReaction).toHaveBeenCalledWith( diff --git a/extensions/bluebubbles/src/attachments.test.ts b/extensions/bluebubbles/src/attachments.test.ts index 17060229930..7ebab0485df 100644 --- a/extensions/bluebubbles/src/attachments.test.ts +++ b/extensions/bluebubbles/src/attachments.test.ts @@ -64,6 +64,24 @@ describe("downloadBlueBubblesAttachment", () => { setBlueBubblesRuntime(runtimeStub); }); + async function expectAttachmentTooLarge(params: { bufferBytes: number; maxBytes?: number }) { + const largeBuffer = new Uint8Array(params.bufferBytes); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers(), + arrayBuffer: () => Promise.resolve(largeBuffer.buffer), + }); + + const attachment: BlueBubblesAttachment = { guid: "att-large" }; + await expect( + downloadBlueBubblesAttachment(attachment, { + serverUrl: "http://localhost:1234", + password: "test", + ...(params.maxBytes === undefined ? {} : { maxBytes: params.maxBytes }), + }), + ).rejects.toThrow("too large"); + } + it("throws when guid is missing", async () => { const attachment: BlueBubblesAttachment = {}; await expect( @@ -175,38 +193,14 @@ describe("downloadBlueBubblesAttachment", () => { }); it("throws when attachment exceeds max bytes", async () => { - const largeBuffer = new Uint8Array(10 * 1024 * 1024); - mockFetch.mockResolvedValueOnce({ - ok: true, - headers: new Headers(), - arrayBuffer: () => Promise.resolve(largeBuffer.buffer), + await expectAttachmentTooLarge({ + bufferBytes: 10 * 1024 * 1024, + maxBytes: 5 * 1024 * 1024, }); - - const attachment: BlueBubblesAttachment = { guid: "att-large" }; - await expect( - downloadBlueBubblesAttachment(attachment, { - serverUrl: "http://localhost:1234", - password: "test", - maxBytes: 5 * 1024 * 1024, - }), - ).rejects.toThrow("too large"); }); it("uses default max bytes when not specified", async () => { - const largeBuffer = new Uint8Array(9 * 1024 * 1024); - mockFetch.mockResolvedValueOnce({ - ok: true, - headers: new Headers(), - arrayBuffer: () => Promise.resolve(largeBuffer.buffer), - }); - - const attachment: BlueBubblesAttachment = { guid: "att-large" }; - await expect( - downloadBlueBubblesAttachment(attachment, { - serverUrl: "http://localhost:1234", - password: "test", - }), - ).rejects.toThrow("too large"); + await expectAttachmentTooLarge({ bufferBytes: 9 * 1024 * 1024 }); }); it("uses attachment mimeType as fallback when response has no content-type", async () => { diff --git a/extensions/bluebubbles/src/chat.test.ts b/extensions/bluebubbles/src/chat.test.ts index d22ded63613..cc37829bc9d 100644 --- a/extensions/bluebubbles/src/chat.test.ts +++ b/extensions/bluebubbles/src/chat.test.ts @@ -22,6 +22,44 @@ installBlueBubblesFetchTestHooks({ }); describe("chat", () => { + function mockOkTextResponse() { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + } + + async function expectCalledUrlIncludesPassword(params: { + password: string; + invoke: () => Promise; + }) { + mockOkTextResponse(); + await params.invoke(); + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain(`password=${params.password}`); + } + + async function expectCalledUrlUsesConfigCredentials(params: { + serverHost: string; + password: string; + invoke: (cfg: { + channels: { bluebubbles: { serverUrl: string; password: string } }; + }) => Promise; + }) { + mockOkTextResponse(); + await params.invoke({ + channels: { + bluebubbles: { + serverUrl: `http://${params.serverHost}`, + password: params.password, + }, + }, + }); + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain(params.serverHost); + expect(calledUrl).toContain(`password=${params.password}`); + } + describe("markBlueBubblesChatRead", () => { it("does nothing when chatGuid is empty or whitespace", async () => { for (const chatGuid of ["", " "]) { @@ -73,18 +111,14 @@ describe("chat", () => { }); it("includes password in URL query", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await markBlueBubblesChatRead("chat-123", { - serverUrl: "http://localhost:1234", + await expectCalledUrlIncludesPassword({ password: "my-secret", + invoke: () => + markBlueBubblesChatRead("chat-123", { + serverUrl: "http://localhost:1234", + password: "my-secret", + }), }); - - const calledUrl = mockFetch.mock.calls[0][0] as string; - expect(calledUrl).toContain("password=my-secret"); }); it("throws on non-ok response", async () => { @@ -119,25 +153,14 @@ describe("chat", () => { }); it("resolves credentials from config", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), + await expectCalledUrlUsesConfigCredentials({ + serverHost: "config-server:9999", + password: "config-pass", + invoke: (cfg) => + markBlueBubblesChatRead("chat-123", { + cfg, + }), }); - - await markBlueBubblesChatRead("chat-123", { - cfg: { - channels: { - bluebubbles: { - serverUrl: "http://config-server:9999", - password: "config-pass", - }, - }, - }, - }); - - const calledUrl = mockFetch.mock.calls[0][0] as string; - expect(calledUrl).toContain("config-server:9999"); - expect(calledUrl).toContain("password=config-pass"); }); }); @@ -536,18 +559,14 @@ describe("chat", () => { }); it("includes password in URL query", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", { - serverUrl: "http://localhost:1234", + await expectCalledUrlIncludesPassword({ password: "my-secret", + invoke: () => + setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", { + serverUrl: "http://localhost:1234", + password: "my-secret", + }), }); - - const calledUrl = mockFetch.mock.calls[0][0] as string; - expect(calledUrl).toContain("password=my-secret"); }); it("throws on non-ok response", async () => { @@ -582,25 +601,14 @@ describe("chat", () => { }); it("resolves credentials from config", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), + await expectCalledUrlUsesConfigCredentials({ + serverHost: "config-server:9999", + password: "config-pass", + invoke: (cfg) => + setGroupIconBlueBubbles("chat-123", new Uint8Array([1]), "icon.png", { + cfg, + }), }); - - await setGroupIconBlueBubbles("chat-123", new Uint8Array([1]), "icon.png", { - cfg: { - channels: { - bluebubbles: { - serverUrl: "http://config-server:9999", - password: "config-pass", - }, - }, - }, - }); - - const calledUrl = mockFetch.mock.calls[0][0] as string; - expect(calledUrl).toContain("config-server:9999"); - expect(calledUrl).toContain("password=config-pass"); }); it("includes filename in multipart body", async () => { diff --git a/extensions/bluebubbles/src/reactions.test.ts b/extensions/bluebubbles/src/reactions.test.ts index 0ea99f911f6..419ccc81e45 100644 --- a/extensions/bluebubbles/src/reactions.test.ts +++ b/extensions/bluebubbles/src/reactions.test.ts @@ -19,6 +19,27 @@ describe("reactions", () => { }); describe("sendBlueBubblesReaction", () => { + async function expectRemovedReaction(emoji: string) { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await sendBlueBubblesReaction({ + chatGuid: "chat-123", + messageGuid: "msg-123", + emoji, + remove: true, + opts: { + serverUrl: "http://localhost:1234", + password: "test", + }, + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.reaction).toBe("-love"); + } + it("throws when chatGuid is empty", async () => { await expect( sendBlueBubblesReaction({ @@ -208,45 +229,11 @@ describe("reactions", () => { }); it("sends reaction removal with dash prefix", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await sendBlueBubblesReaction({ - chatGuid: "chat-123", - messageGuid: "msg-123", - emoji: "love", - remove: true, - opts: { - serverUrl: "http://localhost:1234", - password: "test", - }, - }); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.reaction).toBe("-love"); + await expectRemovedReaction("love"); }); it("strips leading dash from emoji when remove flag is set", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await sendBlueBubblesReaction({ - chatGuid: "chat-123", - messageGuid: "msg-123", - emoji: "-love", - remove: true, - opts: { - serverUrl: "http://localhost:1234", - password: "test", - }, - }); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.reaction).toBe("-love"); + await expectRemovedReaction("-love"); }); it("uses custom partIndex when provided", async () => { diff --git a/extensions/bluebubbles/src/send.test.ts b/extensions/bluebubbles/src/send.test.ts index 9872372641e..6b2e5fe051f 100644 --- a/extensions/bluebubbles/src/send.test.ts +++ b/extensions/bluebubbles/src/send.test.ts @@ -44,6 +44,23 @@ function mockSendResponse(body: unknown) { }); } +function mockNewChatSendResponse(guid: string) { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ data: [] }), + }) + .mockResolvedValueOnce({ + ok: true, + text: () => + Promise.resolve( + JSON.stringify({ + data: { guid }, + }), + ), + }); +} + describe("send", () => { describe("resolveChatGuidForTarget", () => { const resolveHandleTargetGuid = async (data: Array>) => { @@ -453,20 +470,7 @@ describe("send", () => { }); it("strips markdown when creating a new chat", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ data: [] }), - }) - .mockResolvedValueOnce({ - ok: true, - text: () => - Promise.resolve( - JSON.stringify({ - data: { guid: "new-msg-stripped" }, - }), - ), - }); + mockNewChatSendResponse("new-msg-stripped"); const result = await sendMessageBlueBubbles("+15550009999", "**Welcome** to the _chat_!", { serverUrl: "http://localhost:1234", @@ -483,20 +487,7 @@ describe("send", () => { }); it("creates a new chat when handle target is missing", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ data: [] }), - }) - .mockResolvedValueOnce({ - ok: true, - text: () => - Promise.resolve( - JSON.stringify({ - data: { guid: "new-msg-guid" }, - }), - ), - }); + mockNewChatSendResponse("new-msg-guid"); const result = await sendMessageBlueBubbles("+15550009999", "Hello new chat", { serverUrl: "http://localhost:1234", diff --git a/extensions/copilot-proxy/package.json b/extensions/copilot-proxy/package.json index 155e611f6a8..3a3310f7d99 100644 --- a/extensions/copilot-proxy/package.json +++ b/extensions/copilot-proxy/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/copilot-proxy", - "version": "2026.2.22", + "version": "2026.2.23", "private": true, "description": "OpenClaw Copilot Proxy provider plugin", "type": "module", diff --git a/extensions/diagnostics-otel/package.json b/extensions/diagnostics-otel/package.json index 7e382e3c67a..994e9edb58a 100644 --- a/extensions/diagnostics-otel/package.json +++ b/extensions/diagnostics-otel/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/diagnostics-otel", - "version": "2026.2.22", + "version": "2026.2.23", "description": "OpenClaw diagnostics OpenTelemetry exporter", "type": "module", "dependencies": { diff --git a/extensions/diagnostics-otel/src/service.test.ts b/extensions/diagnostics-otel/src/service.test.ts index 6a7620c54bf..73a3bad0b4c 100644 --- a/extensions/diagnostics-otel/src/service.test.ts +++ b/extensions/diagnostics-otel/src/service.test.ts @@ -293,4 +293,127 @@ describe("diagnostics-otel service", () => { expect(options?.url).toBe("https://collector.example.com/v1/Traces"); await service.stop?.(ctx); }); + + test("redacts sensitive data from log messages before export", async () => { + const registeredTransports: Array<(logObj: Record) => void> = []; + const stopTransport = vi.fn(); + registerLogTransportMock.mockImplementation((transport) => { + registeredTransports.push(transport); + return stopTransport; + }); + + const service = createDiagnosticsOtelService(); + const ctx: OpenClawPluginServiceContext = { + config: { + diagnostics: { + enabled: true, + otel: { + enabled: true, + endpoint: "http://otel-collector:4318", + protocol: "http/protobuf", + logs: true, + }, + }, + }, + logger: createLogger(), + stateDir: "/tmp/openclaw-diagnostics-otel-test", + }; + await service.start(ctx); + expect(registeredTransports).toHaveLength(1); + registeredTransports[0]?.({ + 0: "Using API key sk-1234567890abcdef1234567890abcdef", + _meta: { logLevelName: "INFO", date: new Date() }, + }); + + expect(logEmit).toHaveBeenCalled(); + const emitCall = logEmit.mock.calls[0]?.[0]; + expect(emitCall?.body).not.toContain("sk-1234567890abcdef1234567890abcdef"); + expect(emitCall?.body).toContain("sk-123"); + expect(emitCall?.body).toContain("…"); + await service.stop?.(ctx); + }); + + test("redacts sensitive data from log attributes before export", async () => { + const registeredTransports: Array<(logObj: Record) => void> = []; + const stopTransport = vi.fn(); + registerLogTransportMock.mockImplementation((transport) => { + registeredTransports.push(transport); + return stopTransport; + }); + + const service = createDiagnosticsOtelService(); + const ctx: OpenClawPluginServiceContext = { + config: { + diagnostics: { + enabled: true, + otel: { + enabled: true, + endpoint: "http://otel-collector:4318", + protocol: "http/protobuf", + logs: true, + }, + }, + }, + logger: createLogger(), + stateDir: "/tmp/openclaw-diagnostics-otel-test", + }; + await service.start(ctx); + expect(registeredTransports).toHaveLength(1); + registeredTransports[0]?.({ + 0: '{"token":"ghp_abcdefghijklmnopqrstuvwxyz123456"}', + 1: "auth configured", + _meta: { logLevelName: "DEBUG", date: new Date() }, + }); + + expect(logEmit).toHaveBeenCalled(); + const emitCall = logEmit.mock.calls[0]?.[0]; + const tokenAttr = emitCall?.attributes?.["openclaw.token"]; + expect(tokenAttr).not.toBe("ghp_abcdefghijklmnopqrstuvwxyz123456"); + if (typeof tokenAttr === "string") { + expect(tokenAttr).toContain("…"); + } + await service.stop?.(ctx); + }); + + test("redacts sensitive reason in session.state metric attributes", async () => { + const service = createDiagnosticsOtelService(); + const ctx: OpenClawPluginServiceContext = { + config: { + diagnostics: { + enabled: true, + otel: { + enabled: true, + endpoint: "http://otel-collector:4318", + protocol: "http/protobuf", + metrics: true, + traces: false, + logs: false, + }, + }, + }, + logger: createLogger(), + stateDir: "/tmp/openclaw-diagnostics-otel-test", + }; + await service.start(ctx); + + emitDiagnosticEvent({ + type: "session.state", + state: "waiting", + reason: "token=ghp_abcdefghijklmnopqrstuvwxyz123456", + }); + + const sessionCounter = telemetryState.counters.get("openclaw.session.state"); + expect(sessionCounter?.add).toHaveBeenCalledWith( + 1, + expect.objectContaining({ + "openclaw.reason": expect.stringContaining("…"), + }), + ); + const attrs = sessionCounter?.add.mock.calls[0]?.[1] as Record | undefined; + expect(typeof attrs?.["openclaw.reason"]).toBe("string"); + expect(String(attrs?.["openclaw.reason"])).not.toContain( + "ghp_abcdefghijklmnopqrstuvwxyz123456", + ); + await service.stop?.(ctx); + }); }); diff --git a/extensions/diagnostics-otel/src/service.ts b/extensions/diagnostics-otel/src/service.ts index 78975eb36e2..a36341c8421 100644 --- a/extensions/diagnostics-otel/src/service.ts +++ b/extensions/diagnostics-otel/src/service.ts @@ -10,7 +10,7 @@ import { NodeSDK } from "@opentelemetry/sdk-node"; import { ParentBasedSampler, TraceIdRatioBasedSampler } from "@opentelemetry/sdk-trace-base"; import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions"; import type { DiagnosticEventPayload, OpenClawPluginService } from "openclaw/plugin-sdk"; -import { onDiagnosticEvent, registerLogTransport } from "openclaw/plugin-sdk"; +import { onDiagnosticEvent, redactSensitiveText, registerLogTransport } from "openclaw/plugin-sdk"; const DEFAULT_SERVICE_NAME = "openclaw"; @@ -54,6 +54,14 @@ function formatError(err: unknown): string { } } +function redactOtelAttributes(attributes: Record) { + const redactedAttributes: Record = {}; + for (const [key, value] of Object.entries(attributes)) { + redactedAttributes[key] = typeof value === "string" ? redactSensitiveText(value) : value; + } + return redactedAttributes; +} + export function createDiagnosticsOtelService(): OpenClawPluginService { let sdk: NodeSDK | null = null; let logProvider: LoggerProvider | null = null; @@ -336,11 +344,12 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { attributes["openclaw.code.location"] = meta.path.filePathWithLine; } + // OTLP can leave the host boundary, so redact string fields before export. otelLogger.emit({ - body: message, + body: redactSensitiveText(message), severityText: logLevelName, severityNumber, - attributes, + attributes: redactOtelAttributes(attributes), timestamp: meta?.date ?? new Date(), }); } catch (err) { @@ -469,9 +478,10 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { if (!tracesEnabled) { return; } + const redactedError = redactSensitiveText(evt.error); const spanAttrs: Record = { ...attrs, - "openclaw.error": evt.error, + "openclaw.error": redactedError, }; if (evt.chatId !== undefined) { spanAttrs["openclaw.chatId"] = String(evt.chatId); @@ -479,7 +489,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { const span = tracer.startSpan("openclaw.webhook.error", { attributes: spanAttrs, }); - span.setStatus({ code: SpanStatusCode.ERROR, message: evt.error }); + span.setStatus({ code: SpanStatusCode.ERROR, message: redactedError }); span.end(); }; @@ -524,11 +534,11 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { spanAttrs["openclaw.messageId"] = String(evt.messageId); } if (evt.reason) { - spanAttrs["openclaw.reason"] = evt.reason; + spanAttrs["openclaw.reason"] = redactSensitiveText(evt.reason); } const span = spanWithDuration("openclaw.message.processed", spanAttrs, evt.durationMs); - if (evt.outcome === "error") { - span.setStatus({ code: SpanStatusCode.ERROR, message: evt.error }); + if (evt.outcome === "error" && evt.error) { + span.setStatus({ code: SpanStatusCode.ERROR, message: redactSensitiveText(evt.error) }); } span.end(); }; @@ -557,7 +567,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { ) => { const attrs: Record = { "openclaw.state": evt.state }; if (evt.reason) { - attrs["openclaw.reason"] = evt.reason; + attrs["openclaw.reason"] = redactSensitiveText(evt.reason); } sessionStateCounter.add(1, attrs); }; diff --git a/extensions/discord/package.json b/extensions/discord/package.json index 98ca5edb26e..dac541368eb 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/discord", - "version": "2026.2.22", + "version": "2026.2.23", "description": "OpenClaw Discord channel plugin", "type": "module", "devDependencies": { diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json index 1debb8f4ee0..2eb73728056 100644 --- a/extensions/feishu/package.json +++ b/extensions/feishu/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/feishu", - "version": "2026.2.22", + "version": "2026.2.23", "description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)", "type": "module", "dependencies": { diff --git a/extensions/google-antigravity-auth/README.md b/extensions/google-antigravity-auth/README.md deleted file mode 100644 index 4e1dee975ea..00000000000 --- a/extensions/google-antigravity-auth/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# Google Antigravity Auth (OpenClaw plugin) - -OAuth provider plugin for **Google Antigravity** (Cloud Code Assist). - -## Enable - -Bundled plugins are disabled by default. Enable this one: - -```bash -openclaw plugins enable google-antigravity-auth -``` - -Restart the Gateway after enabling. - -## Authenticate - -```bash -openclaw models auth login --provider google-antigravity --set-default -``` - -## Notes - -- Antigravity uses Google Cloud project quotas. -- If requests fail, ensure Gemini for Google Cloud is enabled. diff --git a/extensions/google-antigravity-auth/index.ts b/extensions/google-antigravity-auth/index.ts deleted file mode 100644 index 055cb15e00b..00000000000 --- a/extensions/google-antigravity-auth/index.ts +++ /dev/null @@ -1,424 +0,0 @@ -import { createHash, randomBytes } from "node:crypto"; -import { createServer } from "node:http"; -import { - buildOauthProviderAuthResult, - emptyPluginConfigSchema, - isWSL2Sync, - type OpenClawPluginApi, - type ProviderAuthContext, -} from "openclaw/plugin-sdk"; - -// OAuth constants - decoded from pi-ai's base64 encoded values to stay in sync -const decode = (s: string) => Buffer.from(s, "base64").toString(); -const CLIENT_ID = decode( - "MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==", -); -const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY="); -const REDIRECT_URI = "http://localhost:51121/oauth-callback"; -const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; -const TOKEN_URL = "https://oauth2.googleapis.com/token"; -const DEFAULT_PROJECT_ID = "rising-fact-p41fc"; -const DEFAULT_MODEL = "google-antigravity/claude-opus-4-6-thinking"; - -const SCOPES = [ - "https://www.googleapis.com/auth/cloud-platform", - "https://www.googleapis.com/auth/userinfo.email", - "https://www.googleapis.com/auth/userinfo.profile", - "https://www.googleapis.com/auth/cclog", - "https://www.googleapis.com/auth/experimentsandconfigs", -]; - -const CODE_ASSIST_ENDPOINTS = [ - "https://cloudcode-pa.googleapis.com", - "https://daily-cloudcode-pa.sandbox.googleapis.com", -]; - -const RESPONSE_PAGE = ` - - - - OpenClaw Antigravity OAuth - - -
-

Authentication complete

-

You can return to the terminal.

-
- -`; - -function generatePkce(): { verifier: string; challenge: string } { - const verifier = randomBytes(32).toString("hex"); - const challenge = createHash("sha256").update(verifier).digest("base64url"); - return { verifier, challenge }; -} - -function shouldUseManualOAuthFlow(isRemote: boolean): boolean { - return isRemote || isWSL2Sync(); -} - -function buildAuthUrl(params: { challenge: string; state: string }): string { - const url = new URL(AUTH_URL); - url.searchParams.set("client_id", CLIENT_ID); - url.searchParams.set("response_type", "code"); - url.searchParams.set("redirect_uri", REDIRECT_URI); - url.searchParams.set("scope", SCOPES.join(" ")); - url.searchParams.set("code_challenge", params.challenge); - url.searchParams.set("code_challenge_method", "S256"); - url.searchParams.set("state", params.state); - url.searchParams.set("access_type", "offline"); - url.searchParams.set("prompt", "consent"); - return url.toString(); -} - -function parseCallbackInput(input: string): { code: string; state: string } | { error: string } { - const trimmed = input.trim(); - if (!trimmed) { - return { error: "No input provided" }; - } - - try { - const url = new URL(trimmed); - const code = url.searchParams.get("code"); - const state = url.searchParams.get("state"); - if (!code) { - return { error: "Missing 'code' parameter in URL" }; - } - if (!state) { - return { error: "Missing 'state' parameter in URL" }; - } - return { code, state }; - } catch { - return { error: "Paste the full redirect URL (not just the code)." }; - } -} - -async function startCallbackServer(params: { timeoutMs: number }) { - const redirect = new URL(REDIRECT_URI); - const port = redirect.port ? Number(redirect.port) : 51121; - - let settled = false; - let resolveCallback: (url: URL) => void; - let rejectCallback: (err: Error) => void; - - const callbackPromise = new Promise((resolve, reject) => { - resolveCallback = (url) => { - if (settled) { - return; - } - settled = true; - resolve(url); - }; - rejectCallback = (err) => { - if (settled) { - return; - } - settled = true; - reject(err); - }; - }); - - const timeout = setTimeout(() => { - rejectCallback(new Error("Timed out waiting for OAuth callback")); - }, params.timeoutMs); - timeout.unref?.(); - - const server = createServer((request, response) => { - if (!request.url) { - response.writeHead(400, { "Content-Type": "text/plain" }); - response.end("Missing URL"); - return; - } - - const url = new URL(request.url, `${redirect.protocol}//${redirect.host}`); - if (url.pathname !== redirect.pathname) { - response.writeHead(404, { "Content-Type": "text/plain" }); - response.end("Not found"); - return; - } - - response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); - response.end(RESPONSE_PAGE); - resolveCallback(url); - - setImmediate(() => { - server.close(); - }); - }); - - await new Promise((resolve, reject) => { - const onError = (err: Error) => { - server.off("error", onError); - reject(err); - }; - server.once("error", onError); - server.listen(port, "127.0.0.1", () => { - server.off("error", onError); - resolve(); - }); - }); - - return { - waitForCallback: () => callbackPromise, - close: () => - new Promise((resolve) => { - server.close(() => resolve()); - }), - }; -} - -async function exchangeCode(params: { - code: string; - verifier: string; -}): Promise<{ access: string; refresh: string; expires: number }> { - const response = await fetch(TOKEN_URL, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - client_id: CLIENT_ID, - client_secret: CLIENT_SECRET, - code: params.code, - grant_type: "authorization_code", - redirect_uri: REDIRECT_URI, - code_verifier: params.verifier, - }), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`Token exchange failed: ${text}`); - } - - const data = (await response.json()) as { - access_token?: string; - refresh_token?: string; - expires_in?: number; - }; - - const access = data.access_token?.trim(); - const refresh = data.refresh_token?.trim(); - const expiresIn = data.expires_in ?? 0; - - if (!access) { - throw new Error("Token exchange returned no access_token"); - } - if (!refresh) { - throw new Error("Token exchange returned no refresh_token"); - } - - const expires = Date.now() + expiresIn * 1000 - 5 * 60 * 1000; - return { access, refresh, expires }; -} - -async function fetchUserEmail(accessToken: string): Promise { - try { - const response = await fetch("https://www.googleapis.com/oauth2/v1/userinfo?alt=json", { - headers: { Authorization: `Bearer ${accessToken}` }, - }); - if (!response.ok) { - return undefined; - } - const data = (await response.json()) as { email?: string }; - return data.email; - } catch { - return undefined; - } -} - -async function fetchProjectId(accessToken: string): Promise { - const headers = { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - "User-Agent": "google-api-nodejs-client/9.15.1", - "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", - "Client-Metadata": JSON.stringify({ - ideType: "IDE_UNSPECIFIED", - platform: "PLATFORM_UNSPECIFIED", - pluginType: "GEMINI", - }), - }; - - for (const endpoint of CODE_ASSIST_ENDPOINTS) { - try { - const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, { - method: "POST", - headers, - body: JSON.stringify({ - metadata: { - ideType: "IDE_UNSPECIFIED", - platform: "PLATFORM_UNSPECIFIED", - pluginType: "GEMINI", - }, - }), - }); - - if (!response.ok) { - continue; - } - const data = (await response.json()) as { - cloudaicompanionProject?: string | { id?: string }; - }; - - if (typeof data.cloudaicompanionProject === "string") { - return data.cloudaicompanionProject; - } - if ( - data.cloudaicompanionProject && - typeof data.cloudaicompanionProject === "object" && - data.cloudaicompanionProject.id - ) { - return data.cloudaicompanionProject.id; - } - } catch { - // ignore - } - } - - return DEFAULT_PROJECT_ID; -} - -async function loginAntigravity(params: { - isRemote: boolean; - openUrl: (url: string) => Promise; - prompt: (message: string) => Promise; - note: (message: string, title?: string) => Promise; - log: (message: string) => void; - progress: { update: (msg: string) => void; stop: (msg?: string) => void }; -}): Promise<{ - access: string; - refresh: string; - expires: number; - email?: string; - projectId: string; -}> { - const { verifier, challenge } = generatePkce(); - const state = randomBytes(16).toString("hex"); - const authUrl = buildAuthUrl({ challenge, state }); - - let callbackServer: Awaited> | null = null; - const needsManual = shouldUseManualOAuthFlow(params.isRemote); - if (!needsManual) { - try { - callbackServer = await startCallbackServer({ timeoutMs: 5 * 60 * 1000 }); - } catch { - callbackServer = null; - } - } - - if (!callbackServer) { - await params.note( - [ - "Open the URL in your local browser.", - "After signing in, copy the full redirect URL and paste it back here.", - "", - `Auth URL: ${authUrl}`, - `Redirect URI: ${REDIRECT_URI}`, - ].join("\n"), - "Google Antigravity OAuth", - ); - // Output raw URL below the box for easy copying (fixes #1772) - params.log(""); - params.log("Copy this URL:"); - params.log(authUrl); - params.log(""); - } - - if (!needsManual) { - params.progress.update("Opening Google sign-in…"); - try { - await params.openUrl(authUrl); - } catch { - // ignore - } - } - - let code = ""; - let returnedState = ""; - - if (callbackServer) { - params.progress.update("Waiting for OAuth callback…"); - const callback = await callbackServer.waitForCallback(); - code = callback.searchParams.get("code") ?? ""; - returnedState = callback.searchParams.get("state") ?? ""; - await callbackServer.close(); - } else { - params.progress.update("Waiting for redirect URL…"); - const input = await params.prompt("Paste the redirect URL: "); - const parsed = parseCallbackInput(input); - if ("error" in parsed) { - throw new Error(parsed.error); - } - code = parsed.code; - returnedState = parsed.state; - } - - if (!code) { - throw new Error("Missing OAuth code"); - } - if (returnedState !== state) { - throw new Error("OAuth state mismatch. Please try again."); - } - - params.progress.update("Exchanging code for tokens…"); - const tokens = await exchangeCode({ code, verifier }); - const email = await fetchUserEmail(tokens.access); - const projectId = await fetchProjectId(tokens.access); - - params.progress.stop("Antigravity OAuth complete"); - return { ...tokens, email, projectId }; -} - -const antigravityPlugin = { - id: "google-antigravity-auth", - name: "Google Antigravity Auth", - description: "OAuth flow for Google Antigravity (Cloud Code Assist)", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { - api.registerProvider({ - id: "google-antigravity", - label: "Google Antigravity", - docsPath: "/providers/models", - aliases: ["antigravity"], - auth: [ - { - id: "oauth", - label: "Google OAuth", - hint: "PKCE + localhost callback", - kind: "oauth", - run: async (ctx: ProviderAuthContext) => { - const spin = ctx.prompter.progress("Starting Antigravity OAuth…"); - try { - const result = await loginAntigravity({ - isRemote: ctx.isRemote, - openUrl: ctx.openUrl, - prompt: async (message) => String(await ctx.prompter.text({ message })), - note: ctx.prompter.note, - log: (message) => ctx.runtime.log(message), - progress: spin, - }); - - return buildOauthProviderAuthResult({ - providerId: "google-antigravity", - defaultModel: DEFAULT_MODEL, - access: result.access, - refresh: result.refresh, - expires: result.expires, - email: result.email, - credentialExtra: { projectId: result.projectId }, - notes: [ - "Antigravity uses Google Cloud project quotas.", - "Enable Gemini for Google Cloud on your project if requests fail.", - ], - }); - } catch (err) { - spin.stop("Antigravity OAuth failed"); - throw err; - } - }, - }, - ], - }); - }, -}; - -export default antigravityPlugin; diff --git a/extensions/google-antigravity-auth/openclaw.plugin.json b/extensions/google-antigravity-auth/openclaw.plugin.json deleted file mode 100644 index 2ef207f0486..00000000000 --- a/extensions/google-antigravity-auth/openclaw.plugin.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "id": "google-antigravity-auth", - "providers": ["google-antigravity"], - "configSchema": { - "type": "object", - "additionalProperties": false, - "properties": {} - } -} diff --git a/extensions/google-antigravity-auth/package.json b/extensions/google-antigravity-auth/package.json deleted file mode 100644 index e730f4dcbe4..00000000000 --- a/extensions/google-antigravity-auth/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "@openclaw/google-antigravity-auth", - "version": "2026.2.22", - "private": true, - "description": "OpenClaw Google Antigravity OAuth provider plugin", - "type": "module", - "devDependencies": { - "openclaw": "workspace:*" - }, - "openclaw": { - "extensions": [ - "./index.ts" - ] - } -} diff --git a/extensions/google-gemini-cli-auth/package.json b/extensions/google-gemini-cli-auth/package.json index c9675901266..62fcd6d318e 100644 --- a/extensions/google-gemini-cli-auth/package.json +++ b/extensions/google-gemini-cli-auth/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/google-gemini-cli-auth", - "version": "2026.2.22", + "version": "2026.2.23", "private": true, "description": "OpenClaw Gemini CLI OAuth provider plugin", "type": "module", diff --git a/extensions/googlechat/package.json b/extensions/googlechat/package.json index bd166510c7a..95d444f3078 100644 --- a/extensions/googlechat/package.json +++ b/extensions/googlechat/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/googlechat", - "version": "2026.2.22", + "version": "2026.2.23", "private": true, "description": "OpenClaw Google Chat channel plugin", "type": "module", diff --git a/extensions/imessage/package.json b/extensions/imessage/package.json index 926e012ddd1..9956c7bb7f4 100644 --- a/extensions/imessage/package.json +++ b/extensions/imessage/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/imessage", - "version": "2026.2.22", + "version": "2026.2.23", "private": true, "description": "OpenClaw iMessage channel plugin", "type": "module", diff --git a/extensions/irc/package.json b/extensions/irc/package.json index 39e2d8485f8..876fcb9adb7 100644 --- a/extensions/irc/package.json +++ b/extensions/irc/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/irc", - "version": "2026.2.22", + "version": "2026.2.23", "description": "OpenClaw IRC channel plugin", "type": "module", "devDependencies": { diff --git a/extensions/line/package.json b/extensions/line/package.json index 69907bd5ef7..ef86adea732 100644 --- a/extensions/line/package.json +++ b/extensions/line/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/line", - "version": "2026.2.22", + "version": "2026.2.23", "private": true, "description": "OpenClaw LINE channel plugin", "type": "module", diff --git a/extensions/line/src/channel.logout.test.ts b/extensions/line/src/channel.logout.test.ts index c2864ec70c0..b11bdc99870 100644 --- a/extensions/line/src/channel.logout.test.ts +++ b/extensions/line/src/channel.logout.test.ts @@ -1,10 +1,6 @@ -import type { - OpenClawConfig, - PluginRuntime, - ResolvedLineAccount, - RuntimeEnv, -} from "openclaw/plugin-sdk"; +import type { OpenClawConfig, PluginRuntime, ResolvedLineAccount } from "openclaw/plugin-sdk"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; import { linePlugin } from "./channel.js"; import { setLineRuntime } from "./runtime.js"; @@ -47,16 +43,6 @@ function createRuntime(): { runtime: PluginRuntime; mocks: LineRuntimeMocks } { return { runtime, mocks: { writeConfigFile, resolveLineAccount } }; } -function createRuntimeEnv(): RuntimeEnv { - return { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - }; -} - function resolveAccount( resolveLineAccount: LineRuntimeMocks["resolveLineAccount"], cfg: OpenClawConfig, diff --git a/extensions/line/src/channel.startup.test.ts b/extensions/line/src/channel.startup.test.ts index abd1aedf17c..e5b0ce333f5 100644 --- a/extensions/line/src/channel.startup.test.ts +++ b/extensions/line/src/channel.startup.test.ts @@ -4,9 +4,9 @@ import type { OpenClawConfig, PluginRuntime, ResolvedLineAccount, - RuntimeEnv, } from "openclaw/plugin-sdk"; import { describe, expect, it, vi } from "vitest"; +import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; import { linePlugin } from "./channel.js"; import { setLineRuntime } from "./runtime.js"; @@ -33,20 +33,10 @@ function createRuntime() { return { runtime, probeLineBot, monitorLineProvider }; } -function createRuntimeEnv(): RuntimeEnv { - return { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - }; -} - function createStartAccountCtx(params: { token: string; secret: string; - runtime: RuntimeEnv; + runtime: ReturnType; }): ChannelGatewayContext { const snapshot: ChannelAccountSnapshot = { accountId: "default", diff --git a/extensions/llm-task/package.json b/extensions/llm-task/package.json index 7e9e24eade1..44dc1b46fe1 100644 --- a/extensions/llm-task/package.json +++ b/extensions/llm-task/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/llm-task", - "version": "2026.2.22", + "version": "2026.2.23", "private": true, "description": "OpenClaw JSON-only LLM task plugin", "type": "module", diff --git a/extensions/llm-task/src/llm-task-tool.ts b/extensions/llm-task/src/llm-task-tool.ts index f40d0351fec..87588d7adbd 100644 --- a/extensions/llm-task/src/llm-task-tool.ts +++ b/extensions/llm-task/src/llm-task-tool.ts @@ -96,7 +96,11 @@ export function createLlmTaskTool(api: OpenClawPluginApi) { const pluginCfg = (api.pluginConfig ?? {}) as PluginCfg; - const primary = api.config?.agents?.defaults?.model?.primary; + const defaultsModel = api.config?.agents?.defaults?.model; + const primary = + typeof defaultsModel === "string" + ? defaultsModel.trim() + : (defaultsModel?.primary?.trim() ?? undefined); const primaryProvider = typeof primary === "string" ? primary.split("/")[0] : undefined; const primaryModel = typeof primary === "string" ? primary.split("/").slice(1).join("/") : undefined; diff --git a/extensions/lobster/package.json b/extensions/lobster/package.json index e6c7665735e..4fdbe8bd887 100644 --- a/extensions/lobster/package.json +++ b/extensions/lobster/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/lobster", - "version": "2026.2.22", + "version": "2026.2.23", "description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)", "type": "module", "openclaw": { diff --git a/extensions/lobster/src/lobster-tool.test.ts b/extensions/lobster/src/lobster-tool.test.ts index 294e625ce2b..78de735f8ef 100644 --- a/extensions/lobster/src/lobster-tool.test.ts +++ b/extensions/lobster/src/lobster-tool.test.ts @@ -5,6 +5,12 @@ import path from "node:path"; import { PassThrough } from "node:stream"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawPluginApi, OpenClawPluginToolContext } from "../../../src/plugins/types.js"; +import { + createWindowsCmdShimFixture, + restorePlatformPathEnv, + setProcessPlatform, + snapshotPlatformPathEnv, +} from "./test-helpers.js"; const spawnState = vi.hoisted(() => ({ queue: [] as Array<{ stdout: string; stderr?: string; exitCode?: number }>, @@ -57,20 +63,9 @@ function fakeCtx(overrides: Partial = {}): OpenClawPl }; } -function setProcessPlatform(platform: NodeJS.Platform) { - Object.defineProperty(process, "platform", { - value: platform, - configurable: true, - }); -} - describe("lobster plugin tool", () => { let tempDir = ""; - const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); - const originalPath = process.env.PATH; - const originalPathAlt = process.env.Path; - const originalPathExt = process.env.PATHEXT; - const originalPathExtAlt = process.env.Pathext; + const originalProcessState = snapshotPlatformPathEnv(); beforeAll(async () => { ({ createLobsterTool } = await import("./lobster-tool.js")); @@ -79,29 +74,7 @@ describe("lobster plugin tool", () => { }); afterEach(() => { - if (originalPlatform) { - Object.defineProperty(process, "platform", originalPlatform); - } - if (originalPath === undefined) { - delete process.env.PATH; - } else { - process.env.PATH = originalPath; - } - if (originalPathAlt === undefined) { - delete process.env.Path; - } else { - process.env.Path = originalPathAlt; - } - if (originalPathExt === undefined) { - delete process.env.PATHEXT; - } else { - process.env.PATHEXT = originalPathExt; - } - if (originalPathExtAlt === undefined) { - delete process.env.Pathext; - } else { - process.env.Pathext = originalPathExtAlt; - } + restorePlatformPathEnv(originalProcessState); }); afterAll(async () => { @@ -156,17 +129,6 @@ describe("lobster plugin tool", () => { }); }; - const createWindowsShimFixture = async (params: { - shimPath: string; - scriptPath: string; - scriptToken: string; - }) => { - await fs.mkdir(path.dirname(params.scriptPath), { recursive: true }); - await fs.mkdir(path.dirname(params.shimPath), { recursive: true }); - await fs.writeFile(params.scriptPath, "module.exports = {};\n", "utf8"); - await fs.writeFile(params.shimPath, `@echo off\r\n"${params.scriptToken}" %*\r\n`, "utf8"); - }; - it("runs lobster and returns parsed envelope in details", async () => { spawnState.queue.push({ stdout: JSON.stringify({ @@ -281,10 +243,10 @@ describe("lobster plugin tool", () => { setProcessPlatform("win32"); const shimScriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs"); const shimPath = path.join(tempDir, "shim-bin", "lobster.cmd"); - await createWindowsShimFixture({ + await createWindowsCmdShimFixture({ shimPath, scriptPath: shimScriptPath, - scriptToken: "%dp0%\\..\\shim-dist\\lobster-cli.cjs", + shimLine: `"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`, }); process.env.PATHEXT = ".CMD;.EXE"; process.env.PATH = `${path.dirname(shimPath)};${process.env.PATH ?? ""}`; diff --git a/extensions/lobster/src/test-helpers.ts b/extensions/lobster/src/test-helpers.ts new file mode 100644 index 00000000000..30f2dc81d1b --- /dev/null +++ b/extensions/lobster/src/test-helpers.ts @@ -0,0 +1,56 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +type PathEnvKey = "PATH" | "Path" | "PATHEXT" | "Pathext"; + +const PATH_ENV_KEYS = ["PATH", "Path", "PATHEXT", "Pathext"] as const; + +export type PlatformPathEnvSnapshot = { + platformDescriptor: PropertyDescriptor | undefined; + env: Record; +}; + +export function setProcessPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { + value: platform, + configurable: true, + }); +} + +export function snapshotPlatformPathEnv(): PlatformPathEnvSnapshot { + return { + platformDescriptor: Object.getOwnPropertyDescriptor(process, "platform"), + env: { + PATH: process.env.PATH, + Path: process.env.Path, + PATHEXT: process.env.PATHEXT, + Pathext: process.env.Pathext, + }, + }; +} + +export function restorePlatformPathEnv(snapshot: PlatformPathEnvSnapshot): void { + if (snapshot.platformDescriptor) { + Object.defineProperty(process, "platform", snapshot.platformDescriptor); + } + + for (const key of PATH_ENV_KEYS) { + const value = snapshot.env[key]; + if (value === undefined) { + delete process.env[key]; + continue; + } + process.env[key] = value; + } +} + +export async function createWindowsCmdShimFixture(params: { + shimPath: string; + scriptPath: string; + shimLine: string; +}): Promise { + await fs.mkdir(path.dirname(params.scriptPath), { recursive: true }); + await fs.mkdir(path.dirname(params.shimPath), { recursive: true }); + await fs.writeFile(params.scriptPath, "module.exports = {};\n", "utf8"); + await fs.writeFile(params.shimPath, `@echo off\r\n${params.shimLine}\r\n`, "utf8"); +} diff --git a/extensions/lobster/src/windows-spawn.test.ts b/extensions/lobster/src/windows-spawn.test.ts index 75f49f34b05..e3d791e36e4 100644 --- a/extensions/lobster/src/windows-spawn.test.ts +++ b/extensions/lobster/src/windows-spawn.test.ts @@ -2,22 +2,17 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + createWindowsCmdShimFixture, + restorePlatformPathEnv, + setProcessPlatform, + snapshotPlatformPathEnv, +} from "./test-helpers.js"; import { resolveWindowsLobsterSpawn } from "./windows-spawn.js"; -function setProcessPlatform(platform: NodeJS.Platform) { - Object.defineProperty(process, "platform", { - value: platform, - configurable: true, - }); -} - describe("resolveWindowsLobsterSpawn", () => { let tempDir = ""; - const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); - const originalPath = process.env.PATH; - const originalPathAlt = process.env.Path; - const originalPathExt = process.env.PATHEXT; - const originalPathExtAlt = process.env.Pathext; + const originalProcessState = snapshotPlatformPathEnv(); beforeEach(async () => { tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-win-spawn-")); @@ -25,29 +20,7 @@ describe("resolveWindowsLobsterSpawn", () => { }); afterEach(async () => { - if (originalPlatform) { - Object.defineProperty(process, "platform", originalPlatform); - } - if (originalPath === undefined) { - delete process.env.PATH; - } else { - process.env.PATH = originalPath; - } - if (originalPathAlt === undefined) { - delete process.env.Path; - } else { - process.env.Path = originalPathAlt; - } - if (originalPathExt === undefined) { - delete process.env.PATHEXT; - } else { - process.env.PATHEXT = originalPathExt; - } - if (originalPathExtAlt === undefined) { - delete process.env.Pathext; - } else { - process.env.Pathext = originalPathExtAlt; - } + restorePlatformPathEnv(originalProcessState); if (tempDir) { await fs.rm(tempDir, { recursive: true, force: true }); tempDir = ""; @@ -57,14 +30,11 @@ describe("resolveWindowsLobsterSpawn", () => { it("unwraps cmd shim with %dp0% token", async () => { const scriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs"); const shimPath = path.join(tempDir, "shim", "lobster.cmd"); - await fs.mkdir(path.dirname(scriptPath), { recursive: true }); - await fs.mkdir(path.dirname(shimPath), { recursive: true }); - await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8"); - await fs.writeFile( + await createWindowsCmdShimFixture({ shimPath, - `@echo off\r\n"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*\r\n`, - "utf8", - ); + scriptPath, + shimLine: `"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`, + }); const target = resolveWindowsLobsterSpawn(shimPath, ["run", "noop"], process.env); expect(target.command).toBe(process.execPath); @@ -75,14 +45,11 @@ describe("resolveWindowsLobsterSpawn", () => { it("unwraps cmd shim with %~dp0% token", async () => { const scriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs"); const shimPath = path.join(tempDir, "shim", "lobster.cmd"); - await fs.mkdir(path.dirname(scriptPath), { recursive: true }); - await fs.mkdir(path.dirname(shimPath), { recursive: true }); - await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8"); - await fs.writeFile( + await createWindowsCmdShimFixture({ shimPath, - `@echo off\r\n"%~dp0%\\..\\shim-dist\\lobster-cli.cjs" %*\r\n`, - "utf8", - ); + scriptPath, + shimLine: `"%~dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`, + }); const target = resolveWindowsLobsterSpawn(shimPath, ["run", "noop"], process.env); expect(target.command).toBe(process.execPath); diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index 7ffcb8e6cd9..f7ea9f2327c 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/matrix", - "version": "2026.2.22", + "version": "2026.2.23", "description": "OpenClaw Matrix channel plugin", "type": "module", "dependencies": { diff --git a/extensions/mattermost/package.json b/extensions/mattermost/package.json index be6206d71f9..e1036ea2e39 100644 --- a/extensions/mattermost/package.json +++ b/extensions/mattermost/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/mattermost", - "version": "2026.2.22", + "version": "2026.2.23", "description": "OpenClaw Mattermost channel plugin", "type": "module", "devDependencies": { diff --git a/extensions/memory-core/package.json b/extensions/memory-core/package.json index b577c8cfc90..aa9102dfccd 100644 --- a/extensions/memory-core/package.json +++ b/extensions/memory-core/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/memory-core", - "version": "2026.2.22", + "version": "2026.2.23", "private": true, "description": "OpenClaw core memory search plugin", "type": "module", diff --git a/extensions/memory-lancedb/package.json b/extensions/memory-lancedb/package.json index dfd9b2b8030..12610d18e9e 100644 --- a/extensions/memory-lancedb/package.json +++ b/extensions/memory-lancedb/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/memory-lancedb", - "version": "2026.2.22", + "version": "2026.2.23", "private": true, "description": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture", "type": "module", diff --git a/extensions/minimax-portal-auth/package.json b/extensions/minimax-portal-auth/package.json index 3913b304c6b..f61b1e01967 100644 --- a/extensions/minimax-portal-auth/package.json +++ b/extensions/minimax-portal-auth/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/minimax-portal-auth", - "version": "2026.2.22", + "version": "2026.2.23", "private": true, "description": "OpenClaw MiniMax Portal OAuth provider plugin", "type": "module", diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json index 3f44afa994d..1dd46e1d788 100644 --- a/extensions/msteams/package.json +++ b/extensions/msteams/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/msteams", - "version": "2026.2.22", + "version": "2026.2.23", "description": "OpenClaw Microsoft Teams channel plugin", "type": "module", "dependencies": { diff --git a/extensions/msteams/src/attachments.test.ts b/extensions/msteams/src/attachments.test.ts index 66ea8b9babd..f33541cb8d3 100644 --- a/extensions/msteams/src/attachments.test.ts +++ b/extensions/msteams/src/attachments.test.ts @@ -48,6 +48,53 @@ const runtimeStub = { }, } as unknown as PluginRuntime; +type AttachmentsModule = typeof import("./attachments.js"); +type DownloadAttachmentsParams = Parameters[0]; +type DownloadGraphMediaParams = Parameters[0]; + +const DEFAULT_MESSAGE_URL = "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123"; +const DEFAULT_MAX_BYTES = 1024 * 1024; +const DEFAULT_ALLOW_HOSTS = ["x"]; + +const createOkFetchMock = (contentType: string, payload = "png") => + vi.fn(async () => { + return new Response(Buffer.from(payload), { + status: 200, + headers: { "content-type": contentType }, + }); + }); + +const buildDownloadParams = ( + attachments: DownloadAttachmentsParams["attachments"], + overrides: Partial< + Omit + > & + Pick = {}, +): DownloadAttachmentsParams => { + return { + attachments, + maxBytes: DEFAULT_MAX_BYTES, + allowHosts: DEFAULT_ALLOW_HOSTS, + resolveFn: publicResolveFn, + ...overrides, + }; +}; + +const buildDownloadGraphParams = ( + fetchFn: typeof fetch, + overrides: Partial< + Omit + > = {}, +): DownloadGraphMediaParams => { + return { + messageUrl: DEFAULT_MESSAGE_URL, + tokenProvider: { getAccessToken: vi.fn(async () => "token") }, + maxBytes: DEFAULT_MAX_BYTES, + fetchFn, + ...overrides, + }; +}; + describe("msteams attachments", () => { const load = async () => { return await import("./attachments.js"); @@ -133,20 +180,12 @@ describe("msteams attachments", () => { describe("downloadMSTeamsAttachments", () => { it("downloads and stores image contentUrl attachments", async () => { const { downloadMSTeamsAttachments } = await load(); - const fetchMock = vi.fn(async () => { - return new Response(Buffer.from("png"), { - status: 200, - headers: { "content-type": "image/png" }, - }); - }); - - const media = await downloadMSTeamsAttachments({ - attachments: [{ contentType: "image/png", contentUrl: "https://x/img" }], - maxBytes: 1024 * 1024, - allowHosts: ["x"], - fetchFn: fetchMock as unknown as typeof fetch, - resolveFn: publicResolveFn, - }); + const fetchMock = createOkFetchMock("image/png"); + const media = await downloadMSTeamsAttachments( + buildDownloadParams([{ contentType: "image/png", contentUrl: "https://x/img" }], { + fetchFn: fetchMock as unknown as typeof fetch, + }), + ); expect(fetchMock).toHaveBeenCalled(); expect(saveMediaBufferMock).toHaveBeenCalled(); @@ -156,25 +195,18 @@ describe("msteams attachments", () => { it("supports Teams file.download.info downloadUrl attachments", async () => { const { downloadMSTeamsAttachments } = await load(); - const fetchMock = vi.fn(async () => { - return new Response(Buffer.from("png"), { - status: 200, - headers: { "content-type": "image/png" }, - }); - }); - - const media = await downloadMSTeamsAttachments({ - attachments: [ - { - contentType: "application/vnd.microsoft.teams.file.download.info", - content: { downloadUrl: "https://x/dl", fileType: "png" }, - }, - ], - maxBytes: 1024 * 1024, - allowHosts: ["x"], - fetchFn: fetchMock as unknown as typeof fetch, - resolveFn: publicResolveFn, - }); + const fetchMock = createOkFetchMock("image/png"); + const media = await downloadMSTeamsAttachments( + buildDownloadParams( + [ + { + contentType: "application/vnd.microsoft.teams.file.download.info", + content: { downloadUrl: "https://x/dl", fileType: "png" }, + }, + ], + { fetchFn: fetchMock as unknown as typeof fetch }, + ), + ); expect(fetchMock).toHaveBeenCalled(); expect(media).toHaveLength(1); @@ -182,25 +214,18 @@ describe("msteams attachments", () => { it("downloads non-image file attachments (PDF)", async () => { const { downloadMSTeamsAttachments } = await load(); - const fetchMock = vi.fn(async () => { - return new Response(Buffer.from("pdf"), { - status: 200, - headers: { "content-type": "application/pdf" }, - }); - }); + const fetchMock = createOkFetchMock("application/pdf", "pdf"); detectMimeMock.mockResolvedValueOnce("application/pdf"); saveMediaBufferMock.mockResolvedValueOnce({ path: "/tmp/saved.pdf", contentType: "application/pdf", }); - const media = await downloadMSTeamsAttachments({ - attachments: [{ contentType: "application/pdf", contentUrl: "https://x/doc.pdf" }], - maxBytes: 1024 * 1024, - allowHosts: ["x"], - fetchFn: fetchMock as unknown as typeof fetch, - resolveFn: publicResolveFn, - }); + const media = await downloadMSTeamsAttachments( + buildDownloadParams([{ contentType: "application/pdf", contentUrl: "https://x/doc.pdf" }], { + fetchFn: fetchMock as unknown as typeof fetch, + }), + ); expect(fetchMock).toHaveBeenCalled(); expect(media).toHaveLength(1); @@ -210,25 +235,18 @@ describe("msteams attachments", () => { it("downloads inline image URLs from html attachments", async () => { const { downloadMSTeamsAttachments } = await load(); - const fetchMock = vi.fn(async () => { - return new Response(Buffer.from("png"), { - status: 200, - headers: { "content-type": "image/png" }, - }); - }); - - const media = await downloadMSTeamsAttachments({ - attachments: [ - { - contentType: "text/html", - content: '', - }, - ], - maxBytes: 1024 * 1024, - allowHosts: ["x"], - fetchFn: fetchMock as unknown as typeof fetch, - resolveFn: publicResolveFn, - }); + const fetchMock = createOkFetchMock("image/png"); + const media = await downloadMSTeamsAttachments( + buildDownloadParams( + [ + { + contentType: "text/html", + content: '', + }, + ], + { fetchFn: fetchMock as unknown as typeof fetch }, + ), + ); expect(media).toHaveLength(1); expect(fetchMock).toHaveBeenCalled(); @@ -237,16 +255,14 @@ describe("msteams attachments", () => { it("stores inline data:image base64 payloads", async () => { const { downloadMSTeamsAttachments } = await load(); const base64 = Buffer.from("png").toString("base64"); - const media = await downloadMSTeamsAttachments({ - attachments: [ + const media = await downloadMSTeamsAttachments( + buildDownloadParams([ { contentType: "text/html", content: ``, }, - ], - maxBytes: 1024 * 1024, - allowHosts: ["x"], - }); + ]), + ); expect(media).toHaveLength(1); expect(saveMediaBufferMock).toHaveBeenCalled(); @@ -266,15 +282,13 @@ describe("msteams attachments", () => { }); }); - const media = await downloadMSTeamsAttachments({ - attachments: [{ contentType: "image/png", contentUrl: "https://x/img" }], - maxBytes: 1024 * 1024, - tokenProvider: { getAccessToken: vi.fn(async () => "token") }, - allowHosts: ["x"], - authAllowHosts: ["x"], - fetchFn: fetchMock as unknown as typeof fetch, - resolveFn: publicResolveFn, - }); + const media = await downloadMSTeamsAttachments( + buildDownloadParams([{ contentType: "image/png", contentUrl: "https://x/img" }], { + tokenProvider: { getAccessToken: vi.fn(async () => "token") }, + authAllowHosts: ["x"], + fetchFn: fetchMock as unknown as typeof fetch, + }), + ); expect(fetchMock).toHaveBeenCalled(); expect(media).toHaveLength(1); @@ -295,17 +309,17 @@ describe("msteams attachments", () => { }); }); - const media = await downloadMSTeamsAttachments({ - attachments: [ - { contentType: "image/png", contentUrl: "https://attacker.azureedge.net/img" }, - ], - maxBytes: 1024 * 1024, - tokenProvider, - allowHosts: ["azureedge.net"], - authAllowHosts: ["graph.microsoft.com"], - fetchFn: fetchMock as unknown as typeof fetch, - resolveFn: publicResolveFn, - }); + const media = await downloadMSTeamsAttachments( + buildDownloadParams( + [{ contentType: "image/png", contentUrl: "https://attacker.azureedge.net/img" }], + { + tokenProvider, + allowHosts: ["azureedge.net"], + authAllowHosts: ["graph.microsoft.com"], + fetchFn: fetchMock as unknown as typeof fetch, + }, + ), + ); expect(media).toHaveLength(0); expect(fetchMock).toHaveBeenCalled(); @@ -315,12 +329,13 @@ describe("msteams attachments", () => { it("skips urls outside the allowlist", async () => { const { downloadMSTeamsAttachments } = await load(); const fetchMock = vi.fn(); - const media = await downloadMSTeamsAttachments({ - attachments: [{ contentType: "image/png", contentUrl: "https://evil.test/img" }], - maxBytes: 1024 * 1024, - allowHosts: ["graph.microsoft.com"], - fetchFn: fetchMock as unknown as typeof fetch, - }); + const media = await downloadMSTeamsAttachments( + buildDownloadParams([{ contentType: "image/png", contentUrl: "https://evil.test/img" }], { + allowHosts: ["graph.microsoft.com"], + resolveFn: undefined, + fetchFn: fetchMock as unknown as typeof fetch, + }), + ); expect(media).toHaveLength(0); expect(fetchMock).not.toHaveBeenCalled(); @@ -388,12 +403,9 @@ describe("msteams attachments", () => { return new Response("not found", { status: 404 }); }); - const media = await downloadMSTeamsGraphMedia({ - messageUrl: "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123", - tokenProvider: { getAccessToken: vi.fn(async () => "token") }, - maxBytes: 1024 * 1024, - fetchFn: fetchMock as unknown as typeof fetch, - }); + const media = await downloadMSTeamsGraphMedia( + buildDownloadGraphParams(fetchMock as unknown as typeof fetch), + ); expect(media.media).toHaveLength(1); expect(fetchMock).toHaveBeenCalled(); @@ -458,12 +470,9 @@ describe("msteams attachments", () => { return new Response("not found", { status: 404 }); }); - const media = await downloadMSTeamsGraphMedia({ - messageUrl: "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123", - tokenProvider: { getAccessToken: vi.fn(async () => "token") }, - maxBytes: 1024 * 1024, - fetchFn: fetchMock as unknown as typeof fetch, - }); + const media = await downloadMSTeamsGraphMedia( + buildDownloadGraphParams(fetchMock as unknown as typeof fetch), + ); expect(media.media).toHaveLength(2); }); @@ -534,13 +543,11 @@ describe("msteams attachments", () => { return new Response("not found", { status: 404 }); }); - const media = await downloadMSTeamsGraphMedia({ - messageUrl: "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123", - tokenProvider: { getAccessToken: vi.fn(async () => "token") }, - maxBytes: 1024 * 1024, - allowHosts: ["graph.microsoft.com", "contoso.sharepoint.com"], - fetchFn: fetchMock as unknown as typeof fetch, - }); + const media = await downloadMSTeamsGraphMedia( + buildDownloadGraphParams(fetchMock as unknown as typeof fetch, { + allowHosts: ["graph.microsoft.com", "contoso.sharepoint.com"], + }), + ); expect(media.media).toHaveLength(0); const calledUrls = fetchMock.mock.calls.map((call) => String(call[0])); diff --git a/extensions/msteams/src/messenger.test.ts b/extensions/msteams/src/messenger.test.ts index cbd562ae3ad..ba176019994 100644 --- a/extensions/msteams/src/messenger.test.ts +++ b/extensions/msteams/src/messenger.test.ts @@ -49,6 +49,28 @@ const runtimeStub = { }, } as unknown as PluginRuntime; +const createNoopAdapter = (): MSTeamsAdapter => ({ + continueConversation: async () => {}, + process: async () => {}, +}); + +const createRecordedSendActivity = ( + sink: string[], + failFirstWithStatusCode?: number, +): ((activity: unknown) => Promise<{ id: string }>) => { + let attempts = 0; + return async (activity: unknown) => { + const { text } = activity as { text?: string }; + const content = text ?? ""; + sink.push(content); + attempts += 1; + if (failFirstWithStatusCode !== undefined && attempts === 1) { + throw Object.assign(new Error("send failed"), { statusCode: failFirstWithStatusCode }); + } + return { id: `id:${content}` }; + }; +}; + describe("msteams messenger", () => { beforeEach(() => { setMSTeamsRuntime(runtimeStub); @@ -117,17 +139,9 @@ describe("msteams messenger", () => { it("sends thread messages via the provided context", async () => { const sent: string[] = []; const ctx = { - sendActivity: async (activity: unknown) => { - const { text } = activity as { text?: string }; - sent.push(text ?? ""); - return { id: `id:${text ?? ""}` }; - }, - }; - - const adapter: MSTeamsAdapter = { - continueConversation: async () => {}, - process: async () => {}, + sendActivity: createRecordedSendActivity(sent), }; + const adapter = createNoopAdapter(); const ids = await sendMSTeamsMessages({ replyStyle: "thread", @@ -149,11 +163,7 @@ describe("msteams messenger", () => { continueConversation: async (_appId, reference, logic) => { seen.reference = reference; await logic({ - sendActivity: async (activity: unknown) => { - const { text } = activity as { text?: string }; - seen.texts.push(text ?? ""); - return { id: `id:${text ?? ""}` }; - }, + sendActivity: createRecordedSendActivity(seen.texts), }); }, process: async () => {}, @@ -192,10 +202,7 @@ describe("msteams messenger", () => { }, }; - const adapter: MSTeamsAdapter = { - continueConversation: async () => {}, - process: async () => {}, - }; + const adapter = createNoopAdapter(); const ids = await sendMSTeamsMessages({ replyStyle: "thread", @@ -242,20 +249,9 @@ describe("msteams messenger", () => { const retryEvents: Array<{ nextAttempt: number; delayMs: number }> = []; const ctx = { - sendActivity: async (activity: unknown) => { - const { text } = activity as { text?: string }; - attempts.push(text ?? ""); - if (attempts.length === 1) { - throw Object.assign(new Error("throttled"), { statusCode: 429 }); - } - return { id: `id:${text ?? ""}` }; - }, - }; - - const adapter: MSTeamsAdapter = { - continueConversation: async () => {}, - process: async () => {}, + sendActivity: createRecordedSendActivity(attempts, 429), }; + const adapter = createNoopAdapter(); const ids = await sendMSTeamsMessages({ replyStyle: "thread", @@ -280,10 +276,7 @@ describe("msteams messenger", () => { }, }; - const adapter: MSTeamsAdapter = { - continueConversation: async () => {}, - process: async () => {}, - }; + const adapter = createNoopAdapter(); await expect( sendMSTeamsMessages({ @@ -303,18 +296,7 @@ describe("msteams messenger", () => { const adapter: MSTeamsAdapter = { continueConversation: async (_appId, _reference, logic) => { - await logic({ - sendActivity: async (activity: unknown) => { - const { text } = activity as { text?: string }; - attempts.push(text ?? ""); - if (attempts.length === 1) { - throw Object.assign(new Error("server error"), { - statusCode: 503, - }); - } - return { id: `id:${text ?? ""}` }; - }, - }); + await logic({ sendActivity: createRecordedSendActivity(attempts, 503) }); }, process: async () => {}, }; diff --git a/extensions/nextcloud-talk/package.json b/extensions/nextcloud-talk/package.json index 80a1f5fbd2f..772cffe238c 100644 --- a/extensions/nextcloud-talk/package.json +++ b/extensions/nextcloud-talk/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/nextcloud-talk", - "version": "2026.2.22", + "version": "2026.2.23", "description": "OpenClaw Nextcloud Talk channel plugin", "type": "module", "devDependencies": { diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json index 27ce113e3fa..c8583c392a3 100644 --- a/extensions/nostr/package.json +++ b/extensions/nostr/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/nostr", - "version": "2026.2.22", + "version": "2026.2.23", "description": "OpenClaw Nostr channel plugin for NIP-04 encrypted DMs", "type": "module", "dependencies": { diff --git a/extensions/nostr/src/nostr-profile-http.test.ts b/extensions/nostr/src/nostr-profile-http.test.ts index d0c1c30ac8b..5e2d3c838d5 100644 --- a/extensions/nostr/src/nostr-profile-http.test.ts +++ b/extensions/nostr/src/nostr-profile-http.test.ts @@ -204,6 +204,23 @@ describe("nostr-profile-http", () => { }); describe("PUT /api/channels/nostr/:accountId/profile", () => { + async function expectPrivatePictureRejected(pictureUrl: string) { + const ctx = createMockContext(); + const handler = createNostrProfileHttpHandler(ctx); + const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", { + name: "hacker", + picture: pictureUrl, + }); + const res = createMockResponse(); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(400); + const data = JSON.parse(res._getData()); + expect(data.ok).toBe(false); + expect(data.error).toContain("private"); + } + it("validates profile and publishes", async () => { const ctx = createMockContext(); const handler = createNostrProfileHttpHandler(ctx); @@ -263,37 +280,11 @@ describe("nostr-profile-http", () => { }); it("rejects private IP in picture URL (SSRF protection)", async () => { - const ctx = createMockContext(); - const handler = createNostrProfileHttpHandler(ctx); - const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", { - name: "hacker", - picture: "https://127.0.0.1/evil.jpg", - }); - const res = createMockResponse(); - - await handler(req, res); - - expect(res._getStatusCode()).toBe(400); - const data = JSON.parse(res._getData()); - expect(data.ok).toBe(false); - expect(data.error).toContain("private"); + await expectPrivatePictureRejected("https://127.0.0.1/evil.jpg"); }); it("rejects ISATAP-embedded private IPv4 in picture URL", async () => { - const ctx = createMockContext(); - const handler = createNostrProfileHttpHandler(ctx); - const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", { - name: "hacker", - picture: "https://[2001:db8:1234::5efe:127.0.0.1]/evil.jpg", - }); - const res = createMockResponse(); - - await handler(req, res); - - expect(res._getStatusCode()).toBe(400); - const data = JSON.parse(res._getData()); - expect(data.ok).toBe(false); - expect(data.error).toContain("private"); + await expectPrivatePictureRejected("https://[2001:db8:1234::5efe:127.0.0.1]/evil.jpg"); }); it("rejects non-https URLs", async () => { diff --git a/extensions/open-prose/package.json b/extensions/open-prose/package.json index 76bc26da176..038a5d7f3a7 100644 --- a/extensions/open-prose/package.json +++ b/extensions/open-prose/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/open-prose", - "version": "2026.2.22", + "version": "2026.2.23", "private": true, "description": "OpenProse VM skill pack plugin (slash command + telemetry).", "type": "module", diff --git a/extensions/signal/package.json b/extensions/signal/package.json index bca4c655cd1..bec90b98233 100644 --- a/extensions/signal/package.json +++ b/extensions/signal/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/signal", - "version": "2026.2.22", + "version": "2026.2.23", "private": true, "description": "OpenClaw Signal channel plugin", "type": "module", diff --git a/extensions/slack/package.json b/extensions/slack/package.json index 8c936b45e36..8541fffd014 100644 --- a/extensions/slack/package.json +++ b/extensions/slack/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/slack", - "version": "2026.2.22", + "version": "2026.2.23", "private": true, "description": "OpenClaw Slack channel plugin", "type": "module", diff --git a/extensions/synology-chat/package.json b/extensions/synology-chat/package.json index 10080758806..230bcc80b3d 100644 --- a/extensions/synology-chat/package.json +++ b/extensions/synology-chat/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/synology-chat", - "version": "2026.2.22", + "version": "2026.2.23", "description": "Synology Chat channel plugin for OpenClaw", "type": "module", "dependencies": { diff --git a/extensions/synology-chat/src/client.test.ts b/extensions/synology-chat/src/client.test.ts index 9aa14f3f5f3..edb48306948 100644 --- a/extensions/synology-chat/src/client.test.ts +++ b/extensions/synology-chat/src/client.test.ts @@ -23,14 +23,14 @@ async function settleTimers(promise: Promise): Promise { return promise; } -function mockSuccessResponse() { +function mockResponse(statusCode: number, body: string) { const httpsRequest = vi.mocked(https.request); httpsRequest.mockImplementation((_url: any, _opts: any, callback: any) => { const res = new EventEmitter() as any; - res.statusCode = 200; + res.statusCode = statusCode; process.nextTick(() => { callback(res); - res.emit("data", Buffer.from('{"success":true}')); + res.emit("data", Buffer.from(body)); res.emit("end"); }); const req = new EventEmitter() as any; @@ -41,22 +41,12 @@ function mockSuccessResponse() { }); } +function mockSuccessResponse() { + mockResponse(200, '{"success":true}'); +} + function mockFailureResponse(statusCode = 500) { - const httpsRequest = vi.mocked(https.request); - httpsRequest.mockImplementation((_url: any, _opts: any, callback: any) => { - const res = new EventEmitter() as any; - res.statusCode = statusCode; - process.nextTick(() => { - callback(res); - res.emit("data", Buffer.from("error")); - res.emit("end"); - }); - const req = new EventEmitter() as any; - req.write = vi.fn(); - req.end = vi.fn(); - req.destroy = vi.fn(); - return req; - }); + mockResponse(statusCode, "error"); } describe("sendMessage", () => { diff --git a/extensions/synology-chat/src/webhook-handler.test.ts b/extensions/synology-chat/src/webhook-handler.test.ts index 9248cc427e6..7e20c100610 100644 --- a/extensions/synology-chat/src/webhook-handler.test.ts +++ b/extensions/synology-chat/src/webhook-handler.test.ts @@ -80,6 +80,24 @@ describe("createWebhookHandler", () => { }; }); + async function expectForbiddenByPolicy(params: { + account: Partial; + bodyContains: string; + }) { + const handler = createWebhookHandler({ + account: makeAccount(params.account), + deliver: vi.fn(), + log, + }); + + const req = makeReq("POST", validBody); + const res = makeRes(); + await handler(req, res); + + expect(res._status).toBe(403); + expect(res._body).toContain(params.bodyContains); + } + it("rejects non-POST methods with 405", async () => { const handler = createWebhookHandler({ account: makeAccount(), @@ -129,36 +147,20 @@ describe("createWebhookHandler", () => { }); it("returns 403 for unauthorized user with allowlist policy", async () => { - const handler = createWebhookHandler({ - account: makeAccount({ + await expectForbiddenByPolicy({ + account: { dmPolicy: "allowlist", allowedUserIds: ["456"], - }), - deliver: vi.fn(), - log, + }, + bodyContains: "not authorized", }); - - const req = makeReq("POST", validBody); - const res = makeRes(); - await handler(req, res); - - expect(res._status).toBe(403); - expect(res._body).toContain("not authorized"); }); it("returns 403 when DMs are disabled", async () => { - const handler = createWebhookHandler({ - account: makeAccount({ dmPolicy: "disabled" }), - deliver: vi.fn(), - log, + await expectForbiddenByPolicy({ + account: { dmPolicy: "disabled" }, + bodyContains: "disabled", }); - - const req = makeReq("POST", validBody); - const res = makeRes(); - await handler(req, res); - - expect(res._status).toBe(403); - expect(res._body).toContain("disabled"); }); it("returns 429 when rate limited", async () => { diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json index a89802860c7..e6d9d0aff85 100644 --- a/extensions/telegram/package.json +++ b/extensions/telegram/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/telegram", - "version": "2026.2.22", + "version": "2026.2.23", "private": true, "description": "OpenClaw Telegram channel plugin", "type": "module", diff --git a/extensions/telegram/src/channel.test.ts b/extensions/telegram/src/channel.test.ts index ffe4ce58fb7..0fd75ae7664 100644 --- a/extensions/telegram/src/channel.test.ts +++ b/extensions/telegram/src/channel.test.ts @@ -4,9 +4,9 @@ import type { OpenClawConfig, PluginRuntime, ResolvedTelegramAccount, - RuntimeEnv, } from "openclaw/plugin-sdk"; import { describe, expect, it, vi } from "vitest"; +import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; import { telegramPlugin } from "./channel.js"; import { setTelegramRuntime } from "./runtime.js"; @@ -25,20 +25,10 @@ function createCfg(): OpenClawConfig { } as OpenClawConfig; } -function createRuntimeEnv(): RuntimeEnv { - return { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - }; -} - function createStartAccountCtx(params: { cfg: OpenClawConfig; accountId: string; - runtime: RuntimeEnv; + runtime: ReturnType; }): ChannelGatewayContext { const account = telegramPlugin.config.resolveAccount( params.cfg, diff --git a/extensions/test-utils/runtime-env.ts b/extensions/test-utils/runtime-env.ts new file mode 100644 index 00000000000..747ad5f5f3a --- /dev/null +++ b/extensions/test-utils/runtime-env.ts @@ -0,0 +1,12 @@ +import type { RuntimeEnv } from "openclaw/plugin-sdk"; +import { vi } from "vitest"; + +export function createRuntimeEnv(): RuntimeEnv { + return { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number): never => { + throw new Error(`exit ${code}`); + }), + }; +} diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json index c58a60564a4..ae5079b29ad 100644 --- a/extensions/tlon/package.json +++ b/extensions/tlon/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/tlon", - "version": "2026.2.22", + "version": "2026.2.23", "description": "OpenClaw Tlon/Urbit channel plugin", "type": "module", "dependencies": { diff --git a/extensions/tlon/src/monitor/index.ts b/extensions/tlon/src/monitor/index.ts index e9d9750537b..bbcfa3fedc7 100644 --- a/extensions/tlon/src/monitor/index.ts +++ b/extensions/tlon/src/monitor/index.ts @@ -422,11 +422,12 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise { channel: "testchannel", }; + function runAccessCheck(params: { + account?: Partial; + message?: Partial; + }) { + return checkTwitchAccessControl({ + message: { + ...mockMessage, + ...params.message, + }, + account: { + ...mockAccount, + ...params.account, + }, + botUsername: "testbot", + }); + } + + function expectSingleRoleAllowed(params: { + role: NonNullable[number]; + message: Partial; + }) { + const result = runAccessCheck({ + account: { allowedRoles: [params.role] }, + message: { + message: "@testbot hello", + ...params.message, + }, + }); + expect(result.allowed).toBe(true); + return result; + } + describe("when no restrictions are configured", () => { it("allows messages that mention the bot (default requireMention)", () => { const message: TwitchChatMessage = { @@ -243,22 +275,10 @@ describe("checkTwitchAccessControl", () => { describe("allowedRoles", () => { it("allows users with matching role", () => { - const account: TwitchAccountConfig = { - ...mockAccount, - allowedRoles: ["moderator"], - }; - const message: TwitchChatMessage = { - ...mockMessage, - message: "@testbot hello", - isMod: true, - }; - - const result = checkTwitchAccessControl({ - message, - account, - botUsername: "testbot", + const result = expectSingleRoleAllowed({ + role: "moderator", + message: { isMod: true }, }); - expect(result.allowed).toBe(true); expect(result.matchSource).toBe("role"); }); @@ -323,79 +343,31 @@ describe("checkTwitchAccessControl", () => { }); it("handles moderator role", () => { - const account: TwitchAccountConfig = { - ...mockAccount, - allowedRoles: ["moderator"], - }; - const message: TwitchChatMessage = { - ...mockMessage, - message: "@testbot hello", - isMod: true, - }; - - const result = checkTwitchAccessControl({ - message, - account, - botUsername: "testbot", + expectSingleRoleAllowed({ + role: "moderator", + message: { isMod: true }, }); - expect(result.allowed).toBe(true); }); it("handles subscriber role", () => { - const account: TwitchAccountConfig = { - ...mockAccount, - allowedRoles: ["subscriber"], - }; - const message: TwitchChatMessage = { - ...mockMessage, - message: "@testbot hello", - isSub: true, - }; - - const result = checkTwitchAccessControl({ - message, - account, - botUsername: "testbot", + expectSingleRoleAllowed({ + role: "subscriber", + message: { isSub: true }, }); - expect(result.allowed).toBe(true); }); it("handles owner role", () => { - const account: TwitchAccountConfig = { - ...mockAccount, - allowedRoles: ["owner"], - }; - const message: TwitchChatMessage = { - ...mockMessage, - message: "@testbot hello", - isOwner: true, - }; - - const result = checkTwitchAccessControl({ - message, - account, - botUsername: "testbot", + expectSingleRoleAllowed({ + role: "owner", + message: { isOwner: true }, }); - expect(result.allowed).toBe(true); }); it("handles vip role", () => { - const account: TwitchAccountConfig = { - ...mockAccount, - allowedRoles: ["vip"], - }; - const message: TwitchChatMessage = { - ...mockMessage, - message: "@testbot hello", - isVip: true, - }; - - const result = checkTwitchAccessControl({ - message, - account, - botUsername: "testbot", + expectSingleRoleAllowed({ + role: "vip", + message: { isVip: true }, }); - expect(result.allowed).toBe(true); }); }); @@ -421,21 +393,15 @@ describe("checkTwitchAccessControl", () => { }); it("checks allowlist before allowedRoles", () => { - const account: TwitchAccountConfig = { - ...mockAccount, - allowFrom: ["123456"], - allowedRoles: ["owner"], - }; - const message: TwitchChatMessage = { - ...mockMessage, - message: "@testbot hello", - isOwner: false, - }; - - const result = checkTwitchAccessControl({ - message, - account, - botUsername: "testbot", + const result = runAccessCheck({ + account: { + allowFrom: ["123456"], + allowedRoles: ["owner"], + }, + message: { + message: "@testbot hello", + isOwner: false, + }, }); expect(result.allowed).toBe(true); expect(result.matchSource).toBe("allowlist"); diff --git a/extensions/voice-call/package.json b/extensions/voice-call/package.json index 7d8607ea367..cb636350415 100644 --- a/extensions/voice-call/package.json +++ b/extensions/voice-call/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/voice-call", - "version": "2026.2.22", + "version": "2026.2.23", "description": "OpenClaw voice-call plugin", "type": "module", "dependencies": { diff --git a/extensions/voice-call/src/manager/events.test.ts b/extensions/voice-call/src/manager/events.test.ts index f1d5b5d6f03..f37f8624267 100644 --- a/extensions/voice-call/src/manager/events.test.ts +++ b/extensions/voice-call/src/manager/events.test.ts @@ -71,19 +71,26 @@ function createInboundInitiatedEvent(params: { }; } +function createRejectingInboundContext(): { + ctx: CallManagerContext; + hangupCalls: HangupCallInput[]; +} { + const hangupCalls: HangupCallInput[] = []; + const provider = createProvider({ + hangupCall: async (input: HangupCallInput): Promise => { + hangupCalls.push(input); + }, + }); + const ctx = createContext({ + config: createInboundDisabledConfig(), + provider, + }); + return { ctx, hangupCalls }; +} + describe("processEvent (functional)", () => { it("calls provider hangup when rejecting inbound call", () => { - const hangupCalls: HangupCallInput[] = []; - const provider = createProvider({ - hangupCall: async (input: HangupCallInput): Promise => { - hangupCalls.push(input); - }, - }); - - const ctx = createContext({ - config: createInboundDisabledConfig(), - provider, - }); + const { ctx, hangupCalls } = createRejectingInboundContext(); const event = createInboundInitiatedEvent({ id: "evt-1", providerCallId: "prov-1", @@ -118,16 +125,7 @@ describe("processEvent (functional)", () => { }); it("calls hangup only once for duplicate events for same rejected call", () => { - const hangupCalls: HangupCallInput[] = []; - const provider = createProvider({ - hangupCall: async (input: HangupCallInput): Promise => { - hangupCalls.push(input); - }, - }); - const ctx = createContext({ - config: createInboundDisabledConfig(), - provider, - }); + const { ctx, hangupCalls } = createRejectingInboundContext(); const event1 = createInboundInitiatedEvent({ id: "evt-init", providerCallId: "prov-dup", diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index 819c3c2ab30..60dd015f6ad 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/whatsapp", - "version": "2026.2.22", + "version": "2026.2.23", "private": true, "description": "OpenClaw WhatsApp channel plugin", "type": "module", diff --git a/extensions/zalo/package.json b/extensions/zalo/package.json index f0edd3e3a76..b7172deaaee 100644 --- a/extensions/zalo/package.json +++ b/extensions/zalo/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/zalo", - "version": "2026.2.22", + "version": "2026.2.23", "description": "OpenClaw Zalo channel plugin", "type": "module", "dependencies": { diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json index c779e291159..7a76c1553f2 100644 --- a/extensions/zalouser/package.json +++ b/extensions/zalouser/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/zalouser", - "version": "2026.2.22", + "version": "2026.2.23", "description": "OpenClaw Zalo Personal Account plugin via zca-cli", "type": "module", "dependencies": { diff --git a/package.json b/package.json index 69f10411241..be8ec9577e1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openclaw", - "version": "2026.2.22-2", + "version": "2026.2.23", "description": "Multi-channel AI gateway with extensible messaging integrations", "keywords": [], "homepage": "https://github.com/openclaw/openclaw#readme", diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000000..b69da03be53 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,10 @@ +[tool.ruff] +target-version = "py310" +line-length = 100 + +[tool.ruff.lint] +select = ["E9", "F63", "F7", "F82", "I"] + +[tool.pytest.ini_options] +testpaths = ["skills"] +python_files = ["test_*.py"] diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index bed23a431fd..5abfdf0fa11 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -43,7 +43,6 @@ const unitIsolatedFilesRaw = [ "src/agents/subagent-announce.format.test.ts", "src/infra/archive.test.ts", "src/cli/daemon-cli.coverage.test.ts", - "test/media-understanding.auto.test.ts", // Model normalization test imports config/model discovery stack; keep off unit-fast critical path. "src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts", // Auth profile rotation suite is retry-heavy and high-variance under vmForks contention. diff --git a/skills/model-usage/scripts/model_usage.py b/skills/model-usage/scripts/model_usage.py index 0b71f96ea0f..ea28fa0d983 100644 --- a/skills/model-usage/scripts/model_usage.py +++ b/skills/model-usage/scripts/model_usage.py @@ -17,6 +17,16 @@ from datetime import date, datetime, timedelta from typing import Any, Dict, Iterable, List, Optional, Tuple +def positive_int(value: str) -> int: + try: + parsed = int(value) + except ValueError as exc: + raise argparse.ArgumentTypeError("must be an integer") from exc + if parsed < 1: + raise argparse.ArgumentTypeError("must be >= 1") + return parsed + + def eprint(msg: str) -> None: print(msg, file=sys.stderr) @@ -239,7 +249,7 @@ def main() -> int: parser.add_argument("--mode", choices=["current", "all"], default="current") parser.add_argument("--model", help="Explicit model name to report instead of auto-current.") parser.add_argument("--input", help="Path to codexbar cost JSON (or '-' for stdin).") - parser.add_argument("--days", type=int, help="Limit to last N days (based on daily rows).") + parser.add_argument("--days", type=positive_int, help="Limit to last N days (based on daily rows).") parser.add_argument("--format", choices=["text", "json"], default="text") parser.add_argument("--pretty", action="store_true", help="Pretty-print JSON output.") diff --git a/skills/model-usage/scripts/test_model_usage.py b/skills/model-usage/scripts/test_model_usage.py new file mode 100644 index 00000000000..4d5273401de --- /dev/null +++ b/skills/model-usage/scripts/test_model_usage.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +""" +Tests for model_usage helpers. +""" + +import argparse +from datetime import date, timedelta +from unittest import TestCase, main + +from model_usage import filter_by_days, positive_int + + +class TestModelUsage(TestCase): + def test_positive_int_accepts_valid_numbers(self): + self.assertEqual(positive_int("1"), 1) + self.assertEqual(positive_int("7"), 7) + + def test_positive_int_rejects_zero_and_negative(self): + with self.assertRaises(argparse.ArgumentTypeError): + positive_int("0") + with self.assertRaises(argparse.ArgumentTypeError): + positive_int("-3") + + def test_filter_by_days_keeps_recent_entries(self): + today = date.today() + entries = [ + {"date": (today - timedelta(days=5)).strftime("%Y-%m-%d"), "modelBreakdowns": []}, + {"date": (today - timedelta(days=1)).strftime("%Y-%m-%d"), "modelBreakdowns": []}, + {"date": today.strftime("%Y-%m-%d"), "modelBreakdowns": []}, + ] + + filtered = filter_by_days(entries, 2) + + self.assertEqual(len(filtered), 2) + self.assertEqual(filtered[0]["date"], (today - timedelta(days=1)).strftime("%Y-%m-%d")) + self.assertEqual(filtered[1]["date"], today.strftime("%Y-%m-%d")) + + +if __name__ == "__main__": + main() diff --git a/skills/nano-banana-pro/scripts/generate_image.py b/skills/nano-banana-pro/scripts/generate_image.py index 3365c20077f..8d60882c456 100755 --- a/skills/nano-banana-pro/scripts/generate_image.py +++ b/skills/nano-banana-pro/scripts/generate_image.py @@ -95,12 +95,13 @@ def main(): max_input_dim = 0 for img_path in args.input_images: try: - img = PILImage.open(img_path) - input_images.append(img) + with PILImage.open(img_path) as img: + copied = img.copy() + width, height = copied.size + input_images.append(copied) print(f"Loaded input image: {img_path}") # Track largest dimension for auto-resolution - width, height = img.size max_input_dim = max(max_input_dim, width, height) except Exception as e: print(f"Error loading input image '{img_path}': {e}", file=sys.stderr) diff --git a/skills/openai-image-gen/scripts/gen.py b/skills/openai-image-gen/scripts/gen.py index 7bd59e36126..4043f1a8ed7 100644 --- a/skills/openai-image-gen/scripts/gen.py +++ b/skills/openai-image-gen/scripts/gen.py @@ -9,6 +9,7 @@ import re import sys import urllib.error import urllib.request +from html import escape as html_escape from pathlib import Path @@ -131,8 +132,8 @@ def write_gallery(out_dir: Path, items: list[dict]) -> None: [ f"""
- -
{it["prompt"]}
+ +
{html_escape(it["prompt"])}
""".strip() for it in items @@ -152,7 +153,7 @@ def write_gallery(out_dir: Path, items: list[dict]) -> None: code {{ color: #9cd1ff; }}

openai-image-gen

-

Output: {out_dir.as_posix()}

+

Output: {html_escape(out_dir.as_posix())}

{thumbs}
diff --git a/skills/openai-image-gen/scripts/test_gen.py b/skills/openai-image-gen/scripts/test_gen.py new file mode 100644 index 00000000000..3f0a38d978f --- /dev/null +++ b/skills/openai-image-gen/scripts/test_gen.py @@ -0,0 +1,50 @@ +"""Tests for write_gallery HTML escaping (fixes #12538 - stored XSS).""" + +import tempfile +from pathlib import Path + +from gen import write_gallery + + +def test_write_gallery_escapes_prompt_xss(): + with tempfile.TemporaryDirectory() as tmpdir: + out = Path(tmpdir) + items = [{"prompt": '', "file": "001-test.png"}] + write_gallery(out, items) + html = (out / "index.html").read_text() + assert "