Merge branch 'main' into main
This commit is contained in:
commit
1f0c056502
61
.github/workflows/ci.yml
vendored
61
.github/workflows/ci.yml
vendored
@ -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
9
.gitignore
vendored
@ -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/
|
||||
|
||||
@ -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
|
||||
|
||||
32
CHANGELOG.md
32
CHANGELOG.md
@ -4,10 +4,35 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 2026.2.22 (Unreleased)
|
||||
## 2026.2.23 (Unreleased)
|
||||
|
||||
### Changes
|
||||
|
||||
### Breaking
|
||||
|
||||
### Fixes
|
||||
|
||||
- Auto-reply/Inbound metadata: hide direct-chat `message_id`/`message_id_full` and sender metadata only from normalized chat type (not sender-id sentinels), preserving group metadata visibility and preventing sender-id spoofed direct-mode classification. (#24373) thanks @jd316.
|
||||
- Security/Exec: detect obfuscated commands before exec allowlist decisions and require explicit approval for obfuscation patterns. (#8592) Thanks @CornBrother0x and @vincentkoc.
|
||||
- Agents/Compaction: pass `agentDir` into manual `/compact` command runs so compaction auth/profile resolution stays scoped to the active agent. (#24133) thanks @Glucksberg.
|
||||
- Agents/Models: codify `agents.defaults.model` / `agents.defaults.imageModel` config-boundary input as `string | {primary,fallbacks}`, split explicit vs effective model resolution, and fix `models status --agent` source attribution so defaults-inherited agents are labeled as `defaults` while runtime selection still honors defaults fallback. (#24210) thanks @bianbiandashen.
|
||||
- Security/Skills: escape user-controlled prompt, filename, and output-path values in `openai-image-gen` HTML gallery generation to prevent stored XSS in generated `index.html` output. (#12538) Thanks @CornBrother0x.
|
||||
- Security/Skills: harden `skill-creator` packaging by skipping symlink entries and rejecting files whose resolved paths escape the selected skill root. (#24260, #16959) Thanks @CornBrother0x and @vincentkoc.
|
||||
- Security/OTEL: redact sensitive values (API keys, tokens, credential fields) from diagnostics-otel log bodies, log attributes, and error/reason span fields before OTLP export. (#12542) Thanks @brandonwise.
|
||||
- Providers/OpenRouter: remove conflicting top-level `reasoning_effort` when injecting nested `reasoning.effort`, preventing OpenRouter 400 payload-validation failures for reasoning models. (#24120) thanks @tenequm.
|
||||
- Skills/Python: add CI + pre-commit linting (`ruff`) and pytest discovery coverage for Python scripts/tests under `skills/`, including package test execution from repo root. Thanks @vincentkoc.
|
||||
- Sessions/Store: canonicalize inbound mixed-case session keys for metadata and route updates, and migrate legacy case-variant entries to a single lowercase key to prevent duplicate sessions and missing TUI/WebUI history. (#9561) Thanks @hillghost86.
|
||||
- Security/CI: add pre-commit security hook coverage for private-key detection and production dependency auditing, and enforce those checks in CI alongside baseline secret scanning. Thanks @vincentkoc.
|
||||
- Skills/Python: harden skill script packaging and validation edge cases (self-including `.skill` outputs, CRLF frontmatter parsing, strict `--days` validation, and safer image file loading), with expanded Python regression coverage. Thanks @vincentkoc.
|
||||
- Config/Write: apply `unsetPaths` with immutable path-copy updates so config writes never mutate caller-provided objects, and harden `openclaw config get/set/unset` path traversal by rejecting prototype-key segments and inherited-property traversal. (#24134) thanks @frankekn.
|
||||
- Agents/Failover: treat HTTP 502/503/504 errors as failover-eligible transient timeouts so fallback chains can switch providers/models during upstream outages instead of retrying the same failing target. (#20999) Thanks @taw0002 and @vincentkoc.
|
||||
|
||||
## 2026.2.23
|
||||
|
||||
### Changes
|
||||
|
||||
- Control UI/Agents: make the Tools panel data-driven from runtime `tools.catalog`, add per-tool provenance labels (`core` / `plugin:<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.
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
BIN
docs/experiments/.DS_Store
vendored
BIN
docs/experiments/.DS_Store
vendored
Binary file not shown.
@ -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`):
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/bluebubbles",
|
||||
"version": "2026.2.22",
|
||||
"version": "2026.2.23",
|
||||
"description": "OpenClaw BlueBubbles channel plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/diagnostics-otel",
|
||||
"version": "2026.2.22",
|
||||
"version": "2026.2.23",
|
||||
"description": "OpenClaw diagnostics OpenTelemetry exporter",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/discord",
|
||||
"version": "2026.2.22",
|
||||
"version": "2026.2.23",
|
||||
"description": "OpenClaw Discord channel plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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.
|
||||
@ -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;
|
||||
@ -1,9 +0,0 @@
|
||||
{
|
||||
"id": "google-antigravity-auth",
|
||||
"providers": ["google-antigravity"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/imessage",
|
||||
"version": "2026.2.22",
|
||||
"version": "2026.2.23",
|
||||
"private": true,
|
||||
"description": "OpenClaw iMessage channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/irc",
|
||||
"version": "2026.2.22",
|
||||
"version": "2026.2.23",
|
||||
"description": "OpenClaw IRC channel plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/line",
|
||||
"version": "2026.2.22",
|
||||
"version": "2026.2.23",
|
||||
"private": true,
|
||||
"description": "OpenClaw LINE channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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 ?? ""}`;
|
||||
|
||||
56
extensions/lobster/src/test-helpers.ts
Normal file
56
extensions/lobster/src/test-helpers.ts
Normal 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");
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/matrix",
|
||||
"version": "2026.2.22",
|
||||
"version": "2026.2.23",
|
||||
"description": "OpenClaw Matrix channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/mattermost",
|
||||
"version": "2026.2.22",
|
||||
"version": "2026.2.23",
|
||||
"description": "OpenClaw Mattermost channel plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/msteams",
|
||||
"version": "2026.2.22",
|
||||
"version": "2026.2.23",
|
||||
"description": "OpenClaw Microsoft Teams channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@ -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]));
|
||||
|
||||
@ -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 () => {},
|
||||
};
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/signal",
|
||||
"version": "2026.2.22",
|
||||
"version": "2026.2.23",
|
||||
"private": true,
|
||||
"description": "OpenClaw Signal channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/slack",
|
||||
"version": "2026.2.22",
|
||||
"version": "2026.2.23",
|
||||
"private": true,
|
||||
"description": "OpenClaw Slack channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/telegram",
|
||||
"version": "2026.2.22",
|
||||
"version": "2026.2.23",
|
||||
"private": true,
|
||||
"description": "OpenClaw Telegram channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@ -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,
|
||||
|
||||
12
extensions/test-utils/runtime-env.ts
Normal file
12
extensions/test-utils/runtime-env.ts
Normal 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}`);
|
||||
}),
|
||||
};
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/tlon",
|
||||
"version": "2026.2.22",
|
||||
"version": "2026.2.23",
|
||||
"description": "OpenClaw Tlon/Urbit channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@ -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)}]_`;
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/twitch",
|
||||
"version": "2026.2.22",
|
||||
"version": "2026.2.23",
|
||||
"description": "OpenClaw Twitch channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/voice-call",
|
||||
"version": "2026.2.22",
|
||||
"version": "2026.2.23",
|
||||
"description": "OpenClaw voice-call plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/whatsapp",
|
||||
"version": "2026.2.22",
|
||||
"version": "2026.2.23",
|
||||
"private": true,
|
||||
"description": "OpenClaw WhatsApp channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/zalo",
|
||||
"version": "2026.2.22",
|
||||
"version": "2026.2.23",
|
||||
"description": "OpenClaw Zalo channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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
10
pyproject.toml
Normal 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"]
|
||||
@ -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.
|
||||
|
||||
@ -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.")
|
||||
|
||||
|
||||
40
skills/model-usage/scripts/test_model_usage.py
Normal file
40
skills/model-usage/scripts/test_model_usage.py
Normal 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()
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
50
skills/openai-image-gen/scripts/test_gen.py
Normal file
50
skills/openai-image-gen/scripts/test_gen.py
Normal 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 "<script>" 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 """ 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 & dogs <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
|
||||
|
||||
@ -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}")
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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()
|
||||
|
||||
46
skills/skill-creator/scripts/test_quick_validate.py
Normal file
46
skills/skill-creator/scripts/test_quick_validate.py
Normal 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()
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
17
src/acp/translator.test-helpers.ts
Normal file
17
src/acp/translator.test-helpers.ts
Normal 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;
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user