Merge branch 'main' into main

This commit is contained in:
DDD-HHY 2026-02-23 18:53:32 +08:00 committed by GitHub
commit 1f0c056502
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
350 changed files with 11961 additions and 7354 deletions

View File

@ -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')

9
.gitignore vendored
View File

@ -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/

View File

@ -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

View File

@ -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:<id>` + 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.<channel>.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:<id>` and `<chatId>:topic:<id>` 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.

View File

@ -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

View File

@ -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")

View File

@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.21</string>
<string>2026.2.23</string>
<key>CFBundleVersion</key>
<string>20260220</string>
<string>20260223</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>

View File

@ -19,7 +19,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.21</string>
<string>2026.2.23</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
@ -32,7 +32,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>20260220</string>
<string>20260223</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoadsInWebContent</key>

View File

@ -17,8 +17,8 @@
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.21</string>
<string>2026.2.23</string>
<key>CFBundleVersion</key>
<string>20260220</string>
<string>20260223</string>
</dict>
</plist>

View File

@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.21</string>
<string>2026.2.23</string>
<key>CFBundleVersion</key>
<string>20260220</string>
<string>20260223</string>
<key>WKCompanionAppBundleIdentifier</key>
<string>$(OPENCLAW_APP_BUNDLE_ID)</string>
<key>WKWatchKitApp</key>

View File

@ -15,9 +15,9 @@
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.21</string>
<string>2026.2.23</string>
<key>CFBundleVersion</key>
<string>20260220</string>
<string>20260223</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>

View File

@ -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"

View File

@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.21</string>
<string>2026.2.23</string>
<key>CFBundleVersion</key>
<string>202602210</string>
<string>202602230</string>
<key>CFBundleIconFile</key>
<string>OpenClaw</string>
<key>CFBundleURLTypes</key>

View File

@ -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"
}
}

View File

@ -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"
}
}

Binary file not shown.

View File

@ -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`):

View File

@ -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`.

View File

@ -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: <Developer Name> (<TEAMID>)" \
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 "<apple-id>" --team-id "<team-id>" --password "<app-specific-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: <Developer Name> (<TEAMID>)" \
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.

View File

@ -67,7 +67,7 @@ you revoke it with `openclaw devices revoke --device <id> --role <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.

View File

@ -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:<id>` (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.

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/bluebubbles",
"version": "2026.2.22",
"version": "2026.2.23",
"description": "OpenClaw BlueBubbles channel plugin",
"type": "module",
"devDependencies": {

View File

@ -47,6 +47,22 @@ describe("bluebubblesMessageActions", () => {
const handleAction = bluebubblesMessageActions.handleAction!;
const callHandleAction = (ctx: Omit<Parameters<typeof handleAction>[0], "channel">) =>
handleAction({ channel: "bluebubbles", ...ctx });
const blueBubblesConfig = (): OpenClawConfig => ({
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
});
const runReactAction = async (params: Record<string, unknown>) => {
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(

View File

@ -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 () => {

View File

@ -22,6 +22,44 @@ installBlueBubblesFetchTestHooks({
});
describe("chat", () => {
function mockOkTextResponse() {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
}
async function expectCalledUrlIncludesPassword(params: {
password: string;
invoke: () => Promise<void>;
}) {
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<void>;
}) {
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 () => {

View File

@ -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 () => {

View File

@ -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<Record<string, unknown>>) => {
@ -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",

View File

@ -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",

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/diagnostics-otel",
"version": "2026.2.22",
"version": "2026.2.23",
"description": "OpenClaw diagnostics OpenTelemetry exporter",
"type": "module",
"dependencies": {

View File

@ -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<string, unknown>) => 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<string, unknown>) => 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<string, unknown> | undefined;
expect(typeof attrs?.["openclaw.reason"]).toBe("string");
expect(String(attrs?.["openclaw.reason"])).not.toContain(
"ghp_abcdefghijklmnopqrstuvwxyz123456",
);
await service.stop?.(ctx);
});
});

View File

@ -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<string, string | number | boolean>) {
const redactedAttributes: Record<string, string | number | boolean> = {};
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<string, string | number> = {
...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<string, string> = { "openclaw.state": evt.state };
if (evt.reason) {
attrs["openclaw.reason"] = evt.reason;
attrs["openclaw.reason"] = redactSensitiveText(evt.reason);
}
sessionStateCounter.add(1, attrs);
};

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/discord",
"version": "2026.2.22",
"version": "2026.2.23",
"description": "OpenClaw Discord channel plugin",
"type": "module",
"devDependencies": {

View File

@ -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": {

View File

@ -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.

View File

@ -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 = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>OpenClaw Antigravity OAuth</title>
</head>
<body>
<main>
<h1>Authentication complete</h1>
<p>You can return to the terminal.</p>
</main>
</body>
</html>`;
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<URL>((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<void>((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<void>((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<string | undefined> {
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<string> {
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<void>;
prompt: (message: string) => Promise<string>;
note: (message: string, title?: string) => Promise<void>;
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<ReturnType<typeof startCallbackServer>> | 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;

View File

@ -1,9 +0,0 @@
{
"id": "google-antigravity-auth",
"providers": ["google-antigravity"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@ -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"
]
}
}

View File

@ -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",

View File

@ -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",

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/imessage",
"version": "2026.2.22",
"version": "2026.2.23",
"private": true,
"description": "OpenClaw iMessage channel plugin",
"type": "module",

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/irc",
"version": "2026.2.22",
"version": "2026.2.23",
"description": "OpenClaw IRC channel plugin",
"type": "module",
"devDependencies": {

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/line",
"version": "2026.2.22",
"version": "2026.2.23",
"private": true,
"description": "OpenClaw LINE channel plugin",
"type": "module",

View File

@ -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,

View File

@ -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<typeof createRuntimeEnv>;
}): ChannelGatewayContext<ResolvedLineAccount> {
const snapshot: ChannelAccountSnapshot = {
accountId: "default",

View File

@ -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",

View File

@ -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;

View File

@ -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": {

View File

@ -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<OpenClawPluginToolContext> = {}): 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 ?? ""}`;

View File

@ -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<PathEnvKey, string | undefined>;
};
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<void> {
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");
}

View File

@ -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);

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/matrix",
"version": "2026.2.22",
"version": "2026.2.23",
"description": "OpenClaw Matrix channel plugin",
"type": "module",
"dependencies": {

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/mattermost",
"version": "2026.2.22",
"version": "2026.2.23",
"description": "OpenClaw Mattermost channel plugin",
"type": "module",
"devDependencies": {

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/msteams",
"version": "2026.2.22",
"version": "2026.2.23",
"description": "OpenClaw Microsoft Teams channel plugin",
"type": "module",
"dependencies": {

View File

@ -48,6 +48,53 @@ const runtimeStub = {
},
} as unknown as PluginRuntime;
type AttachmentsModule = typeof import("./attachments.js");
type DownloadAttachmentsParams = Parameters<AttachmentsModule["downloadMSTeamsAttachments"]>[0];
type DownloadGraphMediaParams = Parameters<AttachmentsModule["downloadMSTeamsGraphMedia"]>[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<DownloadAttachmentsParams, "attachments" | "maxBytes" | "allowHosts" | "resolveFn">
> &
Pick<DownloadAttachmentsParams, "allowHosts" | "resolveFn"> = {},
): DownloadAttachmentsParams => {
return {
attachments,
maxBytes: DEFAULT_MAX_BYTES,
allowHosts: DEFAULT_ALLOW_HOSTS,
resolveFn: publicResolveFn,
...overrides,
};
};
const buildDownloadGraphParams = (
fetchFn: typeof fetch,
overrides: Partial<
Omit<DownloadGraphMediaParams, "messageUrl" | "tokenProvider" | "maxBytes">
> = {},
): 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: '<img src="https://x/inline.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: "text/html",
content: '<img src="https://x/inline.png" />',
},
],
{ 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: `<img src="data:image/png;base64,${base64}" />`,
},
],
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]));

View File

@ -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 () => {},
};

View File

@ -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": {

View File

@ -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": {

View File

@ -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 () => {

View File

@ -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",

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/signal",
"version": "2026.2.22",
"version": "2026.2.23",
"private": true,
"description": "OpenClaw Signal channel plugin",
"type": "module",

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/slack",
"version": "2026.2.22",
"version": "2026.2.23",
"private": true,
"description": "OpenClaw Slack channel plugin",
"type": "module",

View File

@ -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": {

View File

@ -23,14 +23,14 @@ async function settleTimers<T>(promise: Promise<T>): Promise<T> {
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", () => {

View File

@ -80,6 +80,24 @@ describe("createWebhookHandler", () => {
};
});
async function expectForbiddenByPolicy(params: {
account: Partial<ResolvedSynologyChatAccount>;
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 () => {

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/telegram",
"version": "2026.2.22",
"version": "2026.2.23",
"private": true,
"description": "OpenClaw Telegram channel plugin",
"type": "module",

View File

@ -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<typeof createRuntimeEnv>;
}): ChannelGatewayContext<ResolvedTelegramAccount> {
const account = telegramPlugin.config.resolveAccount(
params.cfg,

View File

@ -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}`);
}),
};
}

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/tlon",
"version": "2026.2.22",
"version": "2026.2.23",
"description": "OpenClaw Tlon/Urbit channel plugin",
"type": "module",
"dependencies": {

View File

@ -422,11 +422,12 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
model?: string;
};
const extRoute = route as typeof route & { model?: string };
const defaultModel = cfg.agents?.defaults?.model;
const modelInfo =
extPayload.metadata?.model ||
extPayload.model ||
extRoute.model ||
cfg.agents?.defaults?.model?.primary;
(typeof defaultModel === "string" ? defaultModel : defaultModel?.primary);
replyText = `${replyText}\n\n_[Generated by ${formatModelName(modelInfo)}]_`;
}

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/twitch",
"version": "2026.2.22",
"version": "2026.2.23",
"description": "OpenClaw Twitch channel plugin",
"type": "module",
"dependencies": {

View File

@ -17,6 +17,38 @@ describe("checkTwitchAccessControl", () => {
channel: "testchannel",
};
function runAccessCheck(params: {
account?: Partial<TwitchAccountConfig>;
message?: Partial<TwitchChatMessage>;
}) {
return checkTwitchAccessControl({
message: {
...mockMessage,
...params.message,
},
account: {
...mockAccount,
...params.account,
},
botUsername: "testbot",
});
}
function expectSingleRoleAllowed(params: {
role: NonNullable<TwitchAccountConfig["allowedRoles"]>[number];
message: Partial<TwitchChatMessage>;
}) {
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");

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/voice-call",
"version": "2026.2.22",
"version": "2026.2.23",
"description": "OpenClaw voice-call plugin",
"type": "module",
"dependencies": {

View File

@ -71,19 +71,26 @@ function createInboundInitiatedEvent(params: {
};
}
function createRejectingInboundContext(): {
ctx: CallManagerContext;
hangupCalls: HangupCallInput[];
} {
const hangupCalls: HangupCallInput[] = [];
const provider = createProvider({
hangupCall: async (input: HangupCallInput): Promise<void> => {
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<void> => {
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<void> => {
hangupCalls.push(input);
},
});
const ctx = createContext({
config: createInboundDisabledConfig(),
provider,
});
const { ctx, hangupCalls } = createRejectingInboundContext();
const event1 = createInboundInitiatedEvent({
id: "evt-init",
providerCallId: "prov-dup",

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/whatsapp",
"version": "2026.2.22",
"version": "2026.2.23",
"private": true,
"description": "OpenClaw WhatsApp channel plugin",
"type": "module",

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/zalo",
"version": "2026.2.22",
"version": "2026.2.23",
"description": "OpenClaw Zalo channel plugin",
"type": "module",
"dependencies": {

View File

@ -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": {

View File

@ -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",

10
pyproject.toml Normal file
View File

@ -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"]

View File

@ -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.

View File

@ -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.")

View File

@ -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()

View File

@ -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)

View File

@ -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"""
<figure>
<a href="{it["file"]}"><img src="{it["file"]}" loading="lazy" /></a>
<figcaption>{it["prompt"]}</figcaption>
<a href="{html_escape(it["file"], quote=True)}"><img src="{html_escape(it["file"], quote=True)}" loading="lazy" /></a>
<figcaption>{html_escape(it["prompt"])}</figcaption>
</figure>
""".strip()
for it in items
@ -152,7 +153,7 @@ def write_gallery(out_dir: Path, items: list[dict]) -> None:
code {{ color: #9cd1ff; }}
</style>
<h1>openai-image-gen</h1>
<p>Output: <code>{out_dir.as_posix()}</code></p>
<p>Output: <code>{html_escape(out_dir.as_posix())}</code></p>
<div class="grid">
{thumbs}
</div>

View File

@ -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": '<script>alert("xss")</script>', "file": "001-test.png"}]
write_gallery(out, items)
html = (out / "index.html").read_text()
assert "<script>" not in html
assert "&lt;script&gt;" in html
def test_write_gallery_escapes_filename():
with tempfile.TemporaryDirectory() as tmpdir:
out = Path(tmpdir)
items = [{"prompt": "safe prompt", "file": '" onload="alert(1)'}]
write_gallery(out, items)
html = (out / "index.html").read_text()
assert 'onload="alert(1)"' not in html
assert "&quot;" in html
def test_write_gallery_escapes_ampersand():
with tempfile.TemporaryDirectory() as tmpdir:
out = Path(tmpdir)
items = [{"prompt": "cats & dogs <3", "file": "001-test.png"}]
write_gallery(out, items)
html = (out / "index.html").read_text()
assert "cats &amp; dogs &lt;3" in html
def test_write_gallery_normal_output():
with tempfile.TemporaryDirectory() as tmpdir:
out = Path(tmpdir)
items = [
{"prompt": "a lobster astronaut, golden hour", "file": "001-lobster.png"},
{"prompt": "a cozy reading nook", "file": "002-nook.png"},
]
write_gallery(out, items)
html = (out / "index.html").read_text()
assert "a lobster astronaut, golden hour" in html
assert 'src="001-lobster.png"' in html
assert "002-nook.png" in html

View File

@ -17,6 +17,14 @@ from pathlib import Path
from quick_validate import validate_skill
def _is_within(path: Path, root: Path) -> bool:
try:
path.relative_to(root)
return True
except ValueError:
return False
def package_skill(skill_path, output_dir=None):
"""
Package a skill folder into a .skill file.
@ -73,18 +81,25 @@ def package_skill(skill_path, output_dir=None):
for file_path in skill_path.rglob("*"):
# Security: never follow or package symlinks.
if file_path.is_symlink():
print(f"[ERROR] Symlinks are not allowed in skills: {file_path}")
print(" This is a security restriction to prevent including arbitrary files.")
return None
print(f"[WARN] Skipping symlink: {file_path}")
continue
rel_parts = file_path.relative_to(skill_path).parts
if any(part in EXCLUDED_DIRS for part in rel_parts):
continue
if file_path.is_file():
# Calculate the relative path within the zip
arcname = file_path.relative_to(skill_path.parent)
resolved_file = file_path.resolve()
if not _is_within(resolved_file, skill_path):
print(f"[ERROR] File escapes skill root: {file_path}")
return None
# If output lives under skill_path, avoid writing archive into itself.
if resolved_file == skill_filename.resolve():
print(f"[WARN] Skipping output archive: {file_path}")
continue
# Calculate the relative path within the zip.
arcname = Path(skill_name) / file_path.relative_to(skill_path)
zipf.write(file_path, arcname)
print(f" Added: {arcname}")

View File

@ -6,12 +6,23 @@ Quick validation script for skills - minimal version
import re
import sys
from pathlib import Path
from typing import Optional
import yaml
MAX_SKILL_NAME_LENGTH = 64
def _extract_frontmatter(content: str) -> Optional[str]:
lines = content.splitlines()
if not lines or lines[0].strip() != "---":
return None
for i in range(1, len(lines)):
if lines[i].strip() == "---":
return "\n".join(lines[1:i])
return None
def validate_skill(skill_path):
"""Basic validation of a skill"""
skill_path = Path(skill_path)
@ -20,16 +31,14 @@ def validate_skill(skill_path):
if not skill_md.exists():
return False, "SKILL.md not found"
content = skill_md.read_text()
if not content.startswith("---"):
return False, "No YAML frontmatter found"
try:
content = skill_md.read_text(encoding="utf-8")
except OSError as e:
return False, f"Could not read SKILL.md: {e}"
match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
if not match:
frontmatter_text = _extract_frontmatter(content)
if frontmatter_text is None:
return False, "Invalid frontmatter format"
frontmatter_text = match.group(1)
try:
frontmatter = yaml.safe_load(frontmatter_text)
if not isinstance(frontmatter, dict):

View File

@ -9,14 +9,26 @@ import types
import zipfile
from pathlib import Path
from unittest import TestCase, main
from unittest.mock import patch
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
fake_quick_validate = types.ModuleType("quick_validate")
fake_quick_validate.validate_skill = lambda _path: (True, "Skill is valid!")
original_quick_validate = sys.modules.get("quick_validate")
sys.modules["quick_validate"] = fake_quick_validate
import package_skill as package_skill_module
from package_skill import package_skill
if original_quick_validate is not None:
sys.modules["quick_validate"] = original_quick_validate
else:
sys.modules.pop("quick_validate", None)
class TestPackageSkillSecurity(TestCase):
def setUp(self):
@ -50,7 +62,7 @@ class TestPackageSkillSecurity(TestCase):
self.assertIn("normal-skill/SKILL.md", names)
self.assertIn("normal-skill/script.py", names)
def test_rejects_symlink_to_external_file(self):
def test_skips_symlink_to_external_file(self):
skill_dir = self.create_skill("symlink-file-skill")
outside = self.temp_dir / "outside-secret.txt"
outside.write_text("super-secret\n")
@ -64,9 +76,16 @@ class TestPackageSkillSecurity(TestCase):
self.skipTest("symlink unsupported on this platform")
result = package_skill(str(skill_dir), str(out_dir))
self.assertIsNone(result)
self.assertIsNotNone(result)
skill_file = out_dir / "symlink-file-skill.skill"
self.assertTrue(skill_file.exists())
with zipfile.ZipFile(skill_file, "r") as archive:
names = set(archive.namelist())
self.assertIn("symlink-file-skill/SKILL.md", names)
self.assertIn("symlink-file-skill/script.py", names)
self.assertNotIn("symlink-file-skill/loot.txt", names)
def test_rejects_symlink_directory(self):
def test_skips_symlink_directory(self):
skill_dir = self.create_skill("symlink-dir-skill")
outside_dir = self.temp_dir / "outside"
outside_dir.mkdir()
@ -81,6 +100,29 @@ class TestPackageSkillSecurity(TestCase):
self.skipTest("symlink unsupported on this platform")
result = package_skill(str(skill_dir), str(out_dir))
self.assertIsNotNone(result)
skill_file = out_dir / "symlink-dir-skill.skill"
with zipfile.ZipFile(skill_file, "r") as archive:
names = set(archive.namelist())
self.assertIn("symlink-dir-skill/SKILL.md", names)
self.assertIn("symlink-dir-skill/script.py", names)
self.assertNotIn("symlink-dir-skill/docs/secret.txt", names)
def test_rejects_resolved_path_outside_skill_root(self):
skill_dir = self.create_skill("escape-skill")
out_dir = self.temp_dir / "out"
out_dir.mkdir()
original_within = package_skill_module._is_within
def fake_is_within(path_obj: Path, root: Path):
if path_obj.name == "script.py":
return False
return original_within(path_obj, root)
with patch.object(package_skill_module, "_is_within", fake_is_within):
result = package_skill(str(skill_dir), str(out_dir))
self.assertIsNone(result)
def test_allows_nested_regular_files(self):
@ -99,6 +141,20 @@ class TestPackageSkillSecurity(TestCase):
names = set(archive.namelist())
self.assertIn("nested-skill/lib/helpers/util.py", names)
def test_skips_output_archive_when_output_dir_is_skill_dir(self):
skill_dir = self.create_skill("self-output-skill")
result = package_skill(str(skill_dir), str(skill_dir))
self.assertIsNotNone(result)
skill_file = skill_dir / "self-output-skill.skill"
self.assertTrue(skill_file.exists())
with zipfile.ZipFile(skill_file, "r") as archive:
names = set(archive.namelist())
self.assertIn("self-output-skill/SKILL.md", names)
self.assertIn("self-output-skill/script.py", names)
self.assertNotIn("self-output-skill/self-output-skill.skill", names)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""
Regression tests for quick skill validation.
"""
import tempfile
from pathlib import Path
from unittest import TestCase, main
from quick_validate import validate_skill
class TestQuickValidate(TestCase):
def setUp(self):
self.temp_dir = Path(tempfile.mkdtemp(prefix="test_quick_validate_"))
def tearDown(self):
import shutil
if self.temp_dir.exists():
shutil.rmtree(self.temp_dir)
def test_accepts_crlf_frontmatter(self):
skill_dir = self.temp_dir / "crlf-skill"
skill_dir.mkdir(parents=True, exist_ok=True)
content = "---\r\nname: crlf-skill\r\ndescription: ok\r\n---\r\n# Skill\r\n"
(skill_dir / "SKILL.md").write_text(content, encoding="utf-8")
valid, message = validate_skill(skill_dir)
self.assertTrue(valid, message)
def test_rejects_missing_frontmatter_closing_fence(self):
skill_dir = self.temp_dir / "bad-skill"
skill_dir.mkdir(parents=True, exist_ok=True)
content = "---\nname: bad-skill\ndescription: missing end\n# no closing fence\n"
(skill_dir / "SKILL.md").write_text(content, encoding="utf-8")
valid, message = validate_skill(skill_dir)
self.assertFalse(valid)
self.assertEqual(message, "Invalid frontmatter format")
if __name__ == "__main__":
main()

View File

@ -1,16 +1,11 @@
import os from "node:os";
import path from "node:path";
import type { AgentSideConnection, PromptRequest } from "@agentclientprotocol/sdk";
import type { PromptRequest } from "@agentclientprotocol/sdk";
import { describe, expect, it, vi } from "vitest";
import type { GatewayClient } from "../gateway/client.js";
import { createInMemorySessionStore } from "./session.js";
import { AcpGatewayAgent } from "./translator.js";
function createConnection(): AgentSideConnection {
return {
sessionUpdate: vi.fn(async () => {}),
} as unknown as AgentSideConnection;
}
import { createAcpConnection, createAcpGateway } from "./translator.test-helpers.js";
describe("acp prompt cwd prefix", () => {
async function runPromptWithCwd(cwd: string) {
@ -33,14 +28,14 @@ describe("acp prompt cwd prefix", () => {
}
return {};
});
const gateway = {
request: requestSpy,
} as unknown as GatewayClient;
const agent = new AcpGatewayAgent(createConnection(), gateway, {
sessionStore,
prefixCwd: true,
});
const agent = new AcpGatewayAgent(
createAcpConnection(),
createAcpGateway(requestSpy as unknown as GatewayClient["request"]),
{
sessionStore,
prefixCwd: true,
},
);
try {
await expect(

View File

@ -1,5 +1,4 @@
import type {
AgentSideConnection,
LoadSessionRequest,
NewSessionRequest,
PromptRequest,
@ -8,20 +7,7 @@ import { describe, expect, it, vi } from "vitest";
import type { GatewayClient } from "../gateway/client.js";
import { createInMemorySessionStore } from "./session.js";
import { AcpGatewayAgent } from "./translator.js";
function createConnection(): AgentSideConnection {
return {
sessionUpdate: vi.fn(async () => {}),
} as unknown as AgentSideConnection;
}
function createGateway(
request: GatewayClient["request"] = vi.fn(async () => ({ ok: true })) as GatewayClient["request"],
): GatewayClient {
return {
request,
} as unknown as GatewayClient;
}
import { createAcpConnection, createAcpGateway } from "./translator.test-helpers.js";
function createNewSessionRequest(cwd = "/tmp"): NewSessionRequest {
return {
@ -55,7 +41,7 @@ function createPromptRequest(
async function expectOversizedPromptRejected(params: { sessionId: string; text: string }) {
const request = vi.fn(async () => ({ ok: true })) as GatewayClient["request"];
const sessionStore = createInMemorySessionStore();
const agent = new AcpGatewayAgent(createConnection(), createGateway(request), {
const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(request), {
sessionStore,
});
await agent.loadSession(createLoadSessionRequest(params.sessionId));
@ -74,7 +60,7 @@ async function expectOversizedPromptRejected(params: { sessionId: string; text:
describe("acp session creation rate limit", () => {
it("rate limits excessive newSession bursts", async () => {
const sessionStore = createInMemorySessionStore();
const agent = new AcpGatewayAgent(createConnection(), createGateway(), {
const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(), {
sessionStore,
sessionCreateRateLimit: {
maxRequests: 2,
@ -93,7 +79,7 @@ describe("acp session creation rate limit", () => {
it("does not count loadSession refreshes for an existing session ID", async () => {
const sessionStore = createInMemorySessionStore();
const agent = new AcpGatewayAgent(createConnection(), createGateway(), {
const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(), {
sessionStore,
sessionCreateRateLimit: {
maxRequests: 1,

View File

@ -0,0 +1,17 @@
import type { AgentSideConnection } from "@agentclientprotocol/sdk";
import { vi } from "vitest";
import type { GatewayClient } from "../gateway/client.js";
export function createAcpConnection(): AgentSideConnection {
return {
sessionUpdate: vi.fn(async () => {}),
} as unknown as AgentSideConnection;
}
export function createAcpGateway(
request: GatewayClient["request"] = vi.fn(async () => ({ ok: true })) as GatewayClient["request"],
): GatewayClient {
return {
request,
} as unknown as GatewayClient;
}

View File

@ -4,6 +4,8 @@ import type { OpenClawConfig } from "../config/config.js";
import {
resolveAgentConfig,
resolveAgentDir,
resolveAgentEffectiveModelPrimary,
resolveAgentExplicitModelPrimary,
resolveEffectiveModelFallbacks,
resolveAgentModelFallbacksOverride,
resolveAgentModelPrimary,
@ -59,6 +61,43 @@ describe("resolveAgentConfig", () => {
});
});
it("resolves explicit and effective model primary separately", () => {
const cfgWithStringDefault = {
agents: {
defaults: {
model: "anthropic/claude-sonnet-4",
},
list: [{ id: "main" }],
},
} as unknown as OpenClawConfig;
expect(resolveAgentExplicitModelPrimary(cfgWithStringDefault, "main")).toBeUndefined();
expect(resolveAgentEffectiveModelPrimary(cfgWithStringDefault, "main")).toBe(
"anthropic/claude-sonnet-4",
);
const cfgWithObjectDefault: OpenClawConfig = {
agents: {
defaults: {
model: {
primary: "openai/gpt-5.2",
fallbacks: ["anthropic/claude-sonnet-4"],
},
},
list: [{ id: "main" }],
},
};
expect(resolveAgentExplicitModelPrimary(cfgWithObjectDefault, "main")).toBeUndefined();
expect(resolveAgentEffectiveModelPrimary(cfgWithObjectDefault, "main")).toBe("openai/gpt-5.2");
const cfgNoDefaults: OpenClawConfig = {
agents: {
list: [{ id: "main" }],
},
};
expect(resolveAgentExplicitModelPrimary(cfgNoDefaults, "main")).toBeUndefined();
expect(resolveAgentEffectiveModelPrimary(cfgNoDefaults, "main")).toBeUndefined();
});
it("supports per-agent model primary+fallbacks", () => {
const cfg: OpenClawConfig = {
agents: {
@ -81,6 +120,8 @@ describe("resolveAgentConfig", () => {
};
expect(resolveAgentModelPrimary(cfg, "linus")).toBe("anthropic/claude-opus-4");
expect(resolveAgentExplicitModelPrimary(cfg, "linus")).toBe("anthropic/claude-opus-4");
expect(resolveAgentEffectiveModelPrimary(cfg, "linus")).toBe("anthropic/claude-opus-4");
expect(resolveAgentModelFallbacksOverride(cfg, "linus")).toEqual(["openai/gpt-5.2"]);
// If fallbacks isn't present, we don't override the global fallbacks.

View File

@ -1,5 +1,6 @@
import path from "node:path";
import type { OpenClawConfig } from "../config/config.js";
import { resolveAgentModelFallbackValues } from "../config/model-input.js";
import { resolveStateDir } from "../config/paths.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import {
@ -142,16 +143,43 @@ export function resolveAgentSkillsFilter(
return normalizeSkillFilter(resolveAgentConfig(cfg, agentId)?.skills);
}
export function resolveAgentModelPrimary(cfg: OpenClawConfig, agentId: string): string | undefined {
const raw = resolveAgentConfig(cfg, agentId)?.model;
if (!raw) {
function resolveModelPrimary(raw: unknown): string | undefined {
if (typeof raw === "string") {
const trimmed = raw.trim();
return trimmed || undefined;
}
if (!raw || typeof raw !== "object") {
return undefined;
}
if (typeof raw === "string") {
return raw.trim() || undefined;
const primary = (raw as { primary?: unknown }).primary;
if (typeof primary !== "string") {
return undefined;
}
const primary = raw.primary?.trim();
return primary || undefined;
const trimmed = primary.trim();
return trimmed || undefined;
}
export function resolveAgentExplicitModelPrimary(
cfg: OpenClawConfig,
agentId: string,
): string | undefined {
const raw = resolveAgentConfig(cfg, agentId)?.model;
return resolveModelPrimary(raw);
}
export function resolveAgentEffectiveModelPrimary(
cfg: OpenClawConfig,
agentId: string,
): string | undefined {
return (
resolveAgentExplicitModelPrimary(cfg, agentId) ??
resolveModelPrimary(cfg.agents?.defaults?.model)
);
}
// Backward-compatible alias. Prefer explicit/effective helpers at new call sites.
export function resolveAgentModelPrimary(cfg: OpenClawConfig, agentId: string): string | undefined {
return resolveAgentExplicitModelPrimary(cfg, agentId);
}
export function resolveAgentModelFallbacksOverride(
@ -178,10 +206,7 @@ export function resolveEffectiveModelFallbacks(params: {
if (!params.hasSessionModelOverride) {
return agentFallbacksOverride;
}
const defaultFallbacks =
typeof params.cfg.agents?.defaults?.model === "object"
? (params.cfg.agents.defaults.model.fallbacks ?? [])
: [];
const defaultFallbacks = resolveAgentModelFallbackValues(params.cfg.agents?.defaults?.model);
return agentFallbacksOverride ?? defaultFallbacks;
}

View File

@ -55,7 +55,7 @@ function isProfileConfigCompatible(params: {
}
function buildOAuthApiKey(provider: string, credentials: OAuthCredentials): string {
const needsProjectId = provider === "google-gemini-cli" || provider === "google-antigravity";
const needsProjectId = provider === "google-gemini-cli";
return needsProjectId
? JSON.stringify({
token: credentials.access,

View File

@ -42,3 +42,27 @@ export async function requestExecApprovalDecision(
: undefined;
return typeof decisionValue === "string" ? decisionValue : null;
}
export async function requestExecApprovalDecisionForHost(params: {
approvalId: string;
command: string;
workdir: string;
host: "gateway" | "node";
security: ExecSecurity;
ask: ExecAsk;
agentId?: string;
resolvedPath?: string;
sessionKey?: string;
}): Promise<string | null> {
return await requestExecApprovalDecision({
id: params.approvalId,
command: params.command,
cwd: params.workdir,
host: params.host,
security: params.security,
ask: params.ask,
agentId: params.agentId,
resolvedPath: params.resolvedPath,
sessionKey: params.sessionKey,
});
}

View File

@ -14,9 +14,11 @@ import {
resolveAllowAlwaysPatterns,
resolveExecApprovals,
} from "../infra/exec-approvals.js";
import { detectCommandObfuscation } from "../infra/exec-obfuscation-detect.js";
import type { SafeBinProfile } from "../infra/exec-safe-bin-policy.js";
import { logInfo } from "../logger.js";
import { markBackgrounded, tail } from "./bash-process-registry.js";
import { requestExecApprovalDecision } from "./bash-tools.exec-approval-request.js";
import { requestExecApprovalDecisionForHost } from "./bash-tools.exec-approval-request.js";
import {
DEFAULT_APPROVAL_TIMEOUT_MS,
DEFAULT_NOTIFY_TAIL_CHARS,
@ -81,6 +83,24 @@ export async function processGatewayAllowlist(
const analysisOk = allowlistEval.analysisOk;
const allowlistSatisfied =
hostSecurity === "allowlist" && analysisOk ? allowlistEval.allowlistSatisfied : false;
const obfuscation = detectCommandObfuscation(params.command);
if (obfuscation.detected) {
logInfo(`exec: obfuscation detected (gateway): ${obfuscation.reasons.join(", ")}`);
params.warnings.push(`⚠️ Obfuscated command detected: ${obfuscation.reasons.join("; ")}`);
}
const recordMatchedAllowlistUse = (resolvedPath?: string) => {
if (allowlistMatches.length === 0) {
return;
}
const seen = new Set<string>();
for (const match of allowlistMatches) {
if (seen.has(match.pattern)) {
continue;
}
seen.add(match.pattern);
recordAllowlistUse(approvals.file, params.agentId, match, params.command, resolvedPath);
}
};
const hasHeredocSegment = allowlistEval.segments.some((segment) =>
segment.argv.some((token) => token.startsWith("<<")),
);
@ -92,7 +112,9 @@ export async function processGatewayAllowlist(
security: hostSecurity,
analysisOk,
allowlistSatisfied,
}) || requiresHeredocApproval;
}) ||
requiresHeredocApproval ||
obfuscation.detected;
if (requiresHeredocApproval) {
params.warnings.push(
"Warning: heredoc execution requires explicit approval in allowlist mode.",
@ -113,10 +135,10 @@ export async function processGatewayAllowlist(
void (async () => {
let decision: string | null = null;
try {
decision = await requestExecApprovalDecision({
id: approvalId,
decision = await requestExecApprovalDecisionForHost({
approvalId,
command: params.command,
cwd: params.workdir,
workdir: params.workdir,
host: "gateway",
security: hostSecurity,
ask: hostAsk,
@ -141,7 +163,9 @@ export async function processGatewayAllowlist(
if (decision === "deny") {
deniedReason = "user-denied";
} else if (!decision) {
if (askFallback === "full") {
if (obfuscation.detected) {
deniedReason = "approval-timeout (obfuscation-detected)";
} else if (askFallback === "full") {
approvedByAsk = true;
} else if (askFallback === "allowlist") {
if (!analysisOk || !allowlistSatisfied) {
@ -186,22 +210,7 @@ export async function processGatewayAllowlist(
return;
}
if (allowlistMatches.length > 0) {
const seen = new Set<string>();
for (const match of allowlistMatches) {
if (seen.has(match.pattern)) {
continue;
}
seen.add(match.pattern);
recordAllowlistUse(
approvals.file,
params.agentId,
match,
params.command,
resolvedPath ?? undefined,
);
}
}
recordMatchedAllowlistUse(resolvedPath ?? undefined);
let run: Awaited<ReturnType<typeof runExecProcess>> | null = null;
try {
@ -321,22 +330,7 @@ export async function processGatewayAllowlist(
}
}
if (allowlistMatches.length > 0) {
const seen = new Set<string>();
for (const match of allowlistMatches) {
if (seen.has(match.pattern)) {
continue;
}
seen.add(match.pattern);
recordAllowlistUse(
approvals.file,
params.agentId,
match,
params.command,
allowlistEval.segments[0]?.resolution?.resolvedPath,
);
}
}
recordMatchedAllowlistUse(allowlistEval.segments[0]?.resolution?.resolvedPath);
return { execCommandOverride };
}

View File

@ -11,8 +11,10 @@ import {
resolveExecApprovals,
resolveExecApprovalsFromFile,
} from "../infra/exec-approvals.js";
import { detectCommandObfuscation } from "../infra/exec-obfuscation-detect.js";
import { buildNodeShellCommand } from "../infra/node-shell.js";
import { requestExecApprovalDecision } from "./bash-tools.exec-approval-request.js";
import { logInfo } from "../logger.js";
import { requestExecApprovalDecisionForHost } from "./bash-tools.exec-approval-request.js";
import {
DEFAULT_APPROVAL_TIMEOUT_MS,
createApprovalSlug,
@ -133,12 +135,20 @@ export async function executeNodeHostCommand(
// Fall back to requiring approval if node approvals cannot be fetched.
}
}
const requiresAsk = requiresExecApproval({
ask: hostAsk,
security: hostSecurity,
analysisOk,
allowlistSatisfied,
});
const obfuscation = detectCommandObfuscation(params.command);
if (obfuscation.detected) {
logInfo(
`exec: obfuscation detected (node=${nodeQuery ?? "default"}): ${obfuscation.reasons.join(", ")}`,
);
params.warnings.push(`⚠️ Obfuscated command detected: ${obfuscation.reasons.join("; ")}`);
}
const requiresAsk =
requiresExecApproval({
ask: hostAsk,
security: hostSecurity,
analysisOk,
allowlistSatisfied,
}) || obfuscation.detected;
const invokeTimeoutMs = Math.max(
10_000,
(typeof params.timeoutSec === "number" ? params.timeoutSec : params.defaultTimeoutSec) * 1000 +
@ -178,10 +188,10 @@ export async function executeNodeHostCommand(
void (async () => {
let decision: string | null = null;
try {
decision = await requestExecApprovalDecision({
id: approvalId,
decision = await requestExecApprovalDecisionForHost({
approvalId,
command: params.command,
cwd: params.workdir,
workdir: params.workdir,
host: "node",
security: hostSecurity,
ask: hostAsk,
@ -203,7 +213,9 @@ export async function executeNodeHostCommand(
if (decision === "deny") {
deniedReason = "user-denied";
} else if (!decision) {
if (askFallback === "full") {
if (obfuscation.detected) {
deniedReason = "approval-timeout (obfuscation-detected)";
} else if (askFallback === "full") {
approvedByAsk = true;
approvalDecision = "allow-once";
} else if (askFallback === "allowlist") {

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