Merge branch 'main' into docs/node-exec-cwd-troubleshooting

This commit is contained in:
Andrew D'Amelio 2026-02-24 09:15:51 -05:00 committed by GitHub
commit e9d9226e69
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 420 additions and 203 deletions

View File

@ -2,73 +2,19 @@
Docs: https://docs.openclaw.ai
## Unreleased
### Breaking
- **BREAKING:** non-loopback Control UI now requires explicit `gateway.controlUi.allowedOrigins` (full origins). Startup fails closed when missing unless `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` is set to use Host-header origin fallback mode.
- **BREAKING:** channel `allowFrom` matching is now ID-only by default across channels that previously allowed mutable name/tag/email principal matching. If you relied on direct mutable-name matching, migrate allowlists to stable IDs (recommended) or explicitly opt back in with `channels.<channel>.dangerouslyAllowNameMatching=true` (break-glass compatibility mode). (#24907)
## 2026.2.24 (Unreleased)
### Changes
- Subagents/Sessions: add `agents.defaults.subagents.runTimeoutSeconds` so `sessions_spawn` can inherit a configurable default timeout when the tool call omits `runTimeoutSeconds` (unset remains `0`, meaning no timeout). (#24594) Thanks @mitchmcalister.
- Config/Kilo Gateway: Kilo provider flow now surfaces an updated list of models. (#24921) thanks @gumadeiras.
- Auto-reply/Abort shortcuts: expand standalone stop phrases (`stop openclaw`, `stop action`, `stop run`, `stop agent`, `please stop`, and related variants), accept trailing punctuation (for example `STOP OPENCLAW!!!`), and add multilingual stop keywords (including ES/FR/ZH/HI/AR/JP/DE/PT/RU forms) so emergency stop messages are caught more reliably. Thanks @steipete and @vincentkoc.
- Auto-reply/Abort shortcuts: expand standalone stop phrases (`stop openclaw`, `stop action`, `stop run`, `stop agent`, `please stop`, and related variants), accept trailing punctuation (for example `STOP OPENCLAW!!!`), and add multilingual stop keywords (including ES/FR/ZH/HI/AR/JP/DE/PT/RU forms) so emergency stop messages are caught more reliably. (#25103) Thanks @steipete and @vincentkoc.
- Security/Audit: add `security.trust_model.multi_user_heuristic` to flag likely shared-user ingress and clarify the personal-assistant trust model, with hardening guidance for intentional multi-user setups (`sandbox.mode="all"`, workspace-scoped FS, reduced tool surface, no personal/private identities on shared runtimes).
### Fixes
- Agents/Tool dispatch: await block-reply flush before tool execution starts so buffered block replies preserve message ordering around tool calls. (#25427) Thanks @SidQin-cyber.
- macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai.
- Control UI/Chat images: harden image-open clicks against reverse tabnabbing by using opener isolation (`noopener,noreferrer` plus `window.opener = null`). (#18685) Thanks @Mariana-Codebase.
- Security/iOS deep links: require local confirmation (or trusted key) before forwarding `openclaw://agent` requests from iOS to gateway `agent.request`, and strip unkeyed delivery-routing fields to reduce exfiltration risk. This ships in the next npm release. Thanks @GCXWLP for reporting.
- Security/Export session HTML: escape raw HTML markdown tokens in the exported session viewer, harden tree/header metadata rendering against HTML injection, and sanitize image data-URL MIME types in export output to prevent stored XSS when opening exported HTML files. This ships in the next npm release. Thanks @allsmog for reporting.
- Security/Session export: harden exported HTML image rendering against data-URL attribute injection by validating image MIME/base64 fields, rejecting malformed base64 input in media ingestion paths, and dropping invalid tool-image payloads.
- Security/Image tool: enforce `tools.fs.workspaceOnly` for sandboxed `image` path resolution so mounted out-of-workspace paths are blocked before media bytes are loaded/sent to vision providers. This ships in the next npm release. Thanks @tdjackey for reporting.
- Security/Sandbox: enforce `tools.exec.applyPatch.workspaceOnly` and `tools.fs.workspaceOnly` for `apply_patch` in sandbox-mounted paths so writes/deletes cannot escape the workspace boundary via mounts like `/agent` unless explicitly opted out (`tools.exec.applyPatch.workspaceOnly=false`). This ships in the next npm release. Thanks @tdjackey for reporting.
- Security/Commands: enforce sender-only matching for `commands.allowFrom` by blocking conversation-shaped `From` identities (`channel:`, `group:`, `thread:`, `@g.us`) while preserving direct-message fallback when sender fields are missing. Ships in the next npm release. Thanks @jiseoung.
- Security/Config writes: block reserved prototype keys in account-id normalization and route account config resolution through own-key lookups, hardening `/allowlist` and account-scoped config paths against prototype-chain pollution.
- Security/Channels: unify dangerous name-matching policy checks (`dangerouslyAllowNameMatching`) across core and extension channels, share mutable-allowlist detectors between `openclaw doctor` and `openclaw security audit`, and scan all configured accounts (not only the default account) in channel security audit findings.
- Security/Exec approvals: bind `host=node` approvals to explicit `nodeId`, reject cross-node replay of approved `system.run` requests, and include the target node in approval prompts. This ships in the next npm release. Thanks @tdjackey for reporting.
- Security/Exec approvals: restore two-phase approval registration + wait-decision handling for gateway/node exec paths, requiring approval IDs to be registered before returning `approval-pending` and honoring server-assigned approval IDs during wait resolution to prevent orphaned `/approve` flows and immediate-return races (`ask:on-miss`). This ships in the next npm release. Thanks @vitalyis for reporting.
- Security/Exec approvals: enforce canonical wrapper execution plans across allowlist analysis and runtime execution (node host + gateway host), fail closed on semantic `env` wrapper usage, and reject unknown short safe-bin flags to prevent `env -S/--split-string` interpretation-mismatch bypasses. This ships in the next npm release. Thanks @tdjackey for reporting.
- Security/Exec approvals: recognize `busybox`/`toybox` shell applets in wrapper analysis and allow-always persistence, persist inner executables instead of multiplexer wrapper binaries, and fail closed when multiplexer unwrapping is unsafe to prevent allow-always bypasses. This ships in the next npm release. Thanks @jiseoung for reporting.
- Security/Exec approvals: for non-default setups that enable `autoAllowSkills`, require pathless invocations plus trusted resolved-path matches so `./<skill-bin>`/absolute-path basename collisions cannot satisfy skill auto-allow checks under allowlist mode. This ships in the next npm release. Thanks @tdjackey for reporting.
- Security/Exec: harden `safeBins` long-option validation by rejecting unknown/ambiguous GNU long-option abbreviations and denying sort filesystem-dependent flags (`--random-source`, `--temporary-directory`, `-T`), closing safe-bin denylist bypasses. This ships in the next npm release. Thanks @tdjackey and @jiseoung for reporting.
- Security/Shell env fallback: remove trusted-prefix shell-path fallback and only trust login shells explicitly registered in `/etc/shells`, defaulting to `/bin/sh` when `SHELL` is not registered. This ships in the next npm release. Thanks @tdjackey for reporting.
- Security/Voice Call: harden Twilio webhook replay handling by preserving provider event IDs through normalization, adding bounded replay dedupe, and enforcing per-call turn-token matching for call-state transitions. This ships in the next npm release. Thanks @jiseoung for reporting.
- Telegram/Media SSRF: keep RFC2544 benchmark range (`198.18.0.0/15`) blocked by default, add an explicit SSRF-policy opt-in for Telegram media downloads, and keep other channels/URL fetch paths blocked. (#24982) Thanks @stakeswky.
- WhatsApp/Auto-reply: send only final payloads to WhatsApp, suppress tool/block payload leakage (reasoning/thinking), and force block streaming off for WhatsApp dispatch so final-only delivery cannot cause silent turns. (#24962) Thanks @SidQin-cyber.
- Channels/Reasoning: suppress reasoning/thinking payload segments in the shared channel dispatch path so non-Telegram channels (including WhatsApp and Web) no longer emit internal reasoning blocks as user-visible replies. (#24991) Thanks @stakeswky.
- Discord/Reasoning: suppress reasoning/thinking-only payload blocks from Discord delivery output. (#24969)
- WhatsApp/DM routing: only update main-session last-route state when DM traffic is bound to the main session, preserving isolated `dmScope` routing. (#24949) Thanks @kevinWangSheng.
- WhatsApp/Access control: honor `selfChatMode` in inbound access-control checks. (#24738)
- WhatsApp/Logging: redact outbound recipient identifiers in WhatsApp outbound + heartbeat logs and remove message/poll preview text from those log lines. (#24980) Thanks @coygeek.
- Discord/Threading: recover missing thread parent IDs by refetching thread metadata before resolving parent channel context. (#24897) Thanks @z-x-yang.
- Web UI/i18n: load and hydrate saved locale translations during startup so non-English sessions apply immediately without manual toggling. (#24795) Thanks @chilu18.
- Gateway/Browser control: load `src/browser/server.js` during browser-control startup so the control listener starts reliably when browser control is enabled. (#23974) Thanks @ieaves.
- Browser/Chrome relay: harden debugger detach handling during full-page navigation with bounded auto-reattach retries and better cancellation behavior for user/devtools detaches. (#19766) Thanks @nishantkabra77.
- Browser/Chrome extension options: validate relay `/json/version` payload shape and content type (not just HTTP status) to detect wrong-port gateway checks, and clarify relay port derivation for custom gateway ports (`gateway + 3`). (#22252) Thanks @krizpoon.
- Status/Pairing recovery: show explicit pairing-approval command hints (including requestId when safe) when gateway probe failures report pairing-required closures. (#24771) Thanks @markmusson.
- Onboarding/Custom providers: raise verification probe token budgets for OpenAI and Anthropic compatibility checks to avoid false negatives on strict provider defaults. (#24743) Thanks @Glucksberg.
- Auth/OAuth: classify missing OAuth scopes as auth failures for clearer remediation and retry behavior. (#24761)
- Providers/OpenRouter: when thinking is explicitly off, avoid injecting `reasoning.effort` so reasoning-required models can use provider defaults instead of failing request validation. (#24863) Thanks @DevSecTim.
- Sessions/Reasoning: persist `reasoningLevel: "off"` explicitly instead of deleting it so session overrides survive patch/update flows. (#24406, #24559)
- Cron/Isolated sessions: use full prompt mode for isolated cron runs so skills/extensions are available during cron execution. (#24944)
- Synology Chat/Webhooks: deregister stale webhook routes before re-registering on channel restart to prevent duplicate route handling. (#24971)
- Plugins/Config: use plugin manifest `id` (instead of npm package name) for config entry keys so plugin settings stay bound correctly. (#24796)
- Plugins/Config schema: support legacy plugin schemas without `toJSONSchema()` by falling back to permissive object schema generation. (#24933) Thanks @pandego.
- Gateway/Prompt builder: safely extract text from mixed content arrays when assembling prompts to avoid malformed prompt payloads. (#24946)
- Gateway/Slug generation: respect agent-level model config in slug generation flows. (#24776)
- Agents/Workspace paths: strip null bytes and guard undefined `.trim()` calls for workspace-path handling to avoid `ENOTDIR`/`TypeError` crashes. (#24876, #24875)
- Agents/Tool warnings: suppress `sessions_send` relay errors from chat-facing warning payloads to avoid leaking transient inter-session transport failures. (#24740) Thanks @Glucksberg.
- Sessions/Model overrides: keep stored sub-agent model overrides when `agents.defaults.models` is empty (allow-any mode) instead of resetting to defaults. (#21088) Thanks @Slats24.
- Subagents/Registry: prune orphaned restored runs (missing child session/sessionId) before retry/announce resume to prevent zombie entries and stale completion retries, and clarify status output to report bootstrap-file presence semantics. (#24244) Thanks @HeMuling.
- Subagents/Announce queue: add exponential backoff when queue-drain delivery fails to reduce retry storms. (#24783)
- Doctor/UX: suppress the redundant "Run doctor --fix" hint when already in fix mode with no changes. (#24666)
- CLI/Doctor: correct stale recovery hints to use valid commands (`openclaw gateway status --deep` and `openclaw configure --section model`). (#24485) Thanks @chilu18.
- Doctor/Nix: skip false-positive permission warnings for Nix store symlinks in state-integrity checks. (#24901)
- Update/Systemd: back up an existing systemd unit before overwriting it during update flows. (#24350, #24937)
- Install/Global detection: resolve symlinks when detecting pnpm/bun global install paths. (#24744)
- Infra/Windows TOCTOU: handle Windows `dev=0` edge cases in same-file identity checks. (#24939)
- Exec/Bash tools: clamp poll sleep duration to non-negative values in process polling loops. (#24889)
## 2026.2.23

View File

@ -22,7 +22,7 @@ android {
minSdk = 31
targetSdk = 36
versionCode = 202602230
versionName = "2026.2.23"
versionName = "2026.2.24"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")

View File

@ -0,0 +1,71 @@
import Foundation
import UIKit
import Darwin
/// Shared device and platform info for Settings, gateway node payloads, and device status.
enum DeviceInfoHelper {
/// e.g. "iOS 18.0.0" or "iPadOS 18.0.0" by interface idiom. Use for gateway/device payloads.
static func platformString() -> String {
let v = ProcessInfo.processInfo.operatingSystemVersion
let name = switch UIDevice.current.userInterfaceIdiom {
case .pad:
"iPadOS"
case .phone:
"iOS"
default:
"iOS"
}
return "\(name) \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
}
/// Always "iOS X.Y.Z" for UI display (e.g. Settings), matching legacy behavior on iPad.
static func platformStringForDisplay() -> String {
let v = ProcessInfo.processInfo.operatingSystemVersion
return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
}
/// Device family for display: "iPad", "iPhone", or "iOS".
static func deviceFamily() -> String {
switch UIDevice.current.userInterfaceIdiom {
case .pad:
"iPad"
case .phone:
"iPhone"
default:
"iOS"
}
}
/// Machine model identifier from uname (e.g. "iPhone17,1").
static func modelIdentifier() -> String {
var systemInfo = utsname()
uname(&systemInfo)
let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in
String(bytes: ptr.prefix { $0 != 0 }, encoding: .utf8)
}
let trimmed = machine?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? "unknown" : trimmed
}
/// App marketing version only, e.g. "2026.2.0" or "dev".
static func appVersion() -> String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"
}
/// App build string, e.g. "123" or "".
static func appBuild() -> String {
let raw = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? ""
return raw.trimmingCharacters(in: .whitespacesAndNewlines)
}
/// Display string for Settings: "1.2.3" or "1.2.3 (456)" when build differs.
static func openClawVersionString() -> String {
let version = appVersion()
let build = appBuild()
if build.isEmpty || build == version {
return version
}
return "\(version) (\(build))"
}
}

View File

@ -26,12 +26,12 @@ final class DeviceStatusService: DeviceStatusServicing {
func info() -> OpenClawDeviceInfoPayload {
let device = UIDevice.current
let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"
let appBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "0"
let appVersion = DeviceInfoHelper.appVersion()
let appBuild = DeviceStatusService.fallbackAppBuild(DeviceInfoHelper.appBuild())
let locale = Locale.preferredLanguages.first ?? Locale.current.identifier
return OpenClawDeviceInfoPayload(
deviceName: device.name,
modelIdentifier: Self.modelIdentifier(),
modelIdentifier: DeviceInfoHelper.modelIdentifier(),
systemName: device.systemName,
systemVersion: device.systemVersion,
appVersion: appVersion,
@ -75,13 +75,8 @@ final class DeviceStatusService: DeviceStatusServicing {
return OpenClawStorageStatusPayload(totalBytes: total, freeBytes: free, usedBytes: used)
}
private static func modelIdentifier() -> String {
var systemInfo = utsname()
uname(&systemInfo)
let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in
String(bytes: ptr.prefix { $0 != 0 }, encoding: .utf8)
}
let trimmed = machine?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? "unknown" : trimmed
/// Fallback for payloads that require a non-empty build (e.g. "0").
private static func fallbackAppBuild(_ build: String) -> String {
build.isEmpty ? "0" : build
}
}

View File

@ -921,44 +921,6 @@ final class GatewayConnectionController {
private static func motionAvailable() -> Bool {
CMMotionActivityManager.isActivityAvailable() || CMPedometer.isStepCountingAvailable()
}
private func platformString() -> String {
let v = ProcessInfo.processInfo.operatingSystemVersion
let name = switch UIDevice.current.userInterfaceIdiom {
case .pad:
"iPadOS"
case .phone:
"iOS"
default:
"iOS"
}
return "\(name) \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
}
private func deviceFamily() -> String {
switch UIDevice.current.userInterfaceIdiom {
case .pad:
"iPad"
case .phone:
"iPhone"
default:
"iOS"
}
}
private func modelIdentifier() -> String {
var systemInfo = utsname()
uname(&systemInfo)
let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in
String(bytes: ptr.prefix { $0 != 0 }, encoding: .utf8)
}
let trimmed = machine?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? "unknown" : trimmed
}
private func appVersion() -> String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"
}
}
#if DEBUG
@ -980,19 +942,19 @@ extension GatewayConnectionController {
}
func _test_platformString() -> String {
self.platformString()
DeviceInfoHelper.platformString()
}
func _test_deviceFamily() -> String {
self.deviceFamily()
DeviceInfoHelper.deviceFamily()
}
func _test_modelIdentifier() -> String {
self.modelIdentifier()
DeviceInfoHelper.modelIdentifier()
}
func _test_appVersion() -> String {
self.appVersion()
DeviceInfoHelper.appVersion()
}
func _test_setGateways(_ gateways: [GatewayDiscoveryModel.DiscoveredGateway]) {

View File

@ -19,7 +19,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.23</string>
<string>2026.2.24</string>
<key>CFBundleURLTypes</key>
<array>
<dict>

View File

@ -374,9 +374,9 @@ struct SettingsTab: View {
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
LabeledContent("Device", value: self.deviceFamily())
LabeledContent("Platform", value: self.platformString())
LabeledContent("OpenClaw", value: self.openClawVersionString())
LabeledContent("Device", value: DeviceInfoHelper.deviceFamily())
LabeledContent("Platform", value: DeviceInfoHelper.platformStringForDisplay())
LabeledContent("OpenClaw", value: DeviceInfoHelper.openClawVersionString())
}
}
}
@ -584,32 +584,6 @@ struct SettingsTab: View {
return trimmed.isEmpty ? "Not connected" : trimmed
}
private func platformString() -> String {
let v = ProcessInfo.processInfo.operatingSystemVersion
return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
}
private func deviceFamily() -> String {
switch UIDevice.current.userInterfaceIdiom {
case .pad:
"iPad"
case .phone:
"iPhone"
default:
"iOS"
}
}
private func openClawVersionString() -> String {
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"
let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? ""
let trimmedBuild = build.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedBuild.isEmpty || trimmedBuild == version {
return version
}
return "\(version) (\(trimmedBuild))"
}
private func featureToggle(
_ title: String,
isOn: Binding<Bool>,

View File

@ -4,6 +4,9 @@ Sources/Gateway/GatewayDiscoveryModel.swift
Sources/Gateway/GatewaySettingsStore.swift
Sources/Gateway/KeychainStore.swift
Sources/Camera/CameraController.swift
Sources/Device/DeviceInfoHelper.swift
Sources/Device/DeviceStatusService.swift
Sources/Device/NetworkStatusService.swift
Sources/Chat/ChatSheet.swift
Sources/Chat/IOSGatewayChatTransport.swift
Sources/OpenClawApp.swift

View File

@ -17,7 +17,7 @@
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.23</string>
<string>2026.2.24</string>
<key>CFBundleVersion</key>
<string>20260223</string>
</dict>

View File

@ -446,6 +446,8 @@ extension MenuSessionsInjector {
private func buildUsageOverflowMenu(rows: [UsageRow], width: CGFloat) -> NSMenu {
let menu = NSMenu()
// Keep submenu delegate nil: reusing the status-menu delegate here causes
// recursive reinjection whenever this submenu is opened.
for row in rows {
let item = NSMenuItem()
item.tag = self.tag
@ -493,7 +495,6 @@ extension MenuSessionsInjector {
guard !summary.daily.isEmpty else { return nil }
let menu = NSMenu()
menu.delegate = self
let chartView = CostUsageHistoryMenuView(summary: summary, width: width)
let hosting = NSHostingView(rootView: AnyView(chartView))
@ -1226,6 +1227,12 @@ extension MenuSessionsInjector {
self.usageCacheUpdatedAt = Date()
}
func setTestingCostUsageSummary(_ summary: GatewayCostUsageSummary?, errorText: String? = nil) {
self.cachedCostSummary = summary
self.cachedCostErrorText = errorText
self.costCacheUpdatedAt = Date()
}
func injectForTesting(into menu: NSMenu) {
self.inject(into: menu)
}

View File

@ -15,7 +15,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.23</string>
<string>2026.2.24</string>
<key>CFBundleVersion</key>
<string>202602230</string>
<key>CFBundleIconFile</key>

View File

@ -93,4 +93,45 @@ struct MenuSessionsInjectorTests {
#expect(menu.items.contains { $0.tag == 9_415_557 })
#expect(menu.items.contains { $0.tag == 9_415_557 && $0.isSeparatorItem })
}
@Test func costUsageSubmenuDoesNotUseInjectorDelegate() {
let injector = MenuSessionsInjector()
injector.setTestingControlChannelConnected(true)
let summary = GatewayCostUsageSummary(
updatedAt: Date().timeIntervalSince1970 * 1000,
days: 1,
daily: [
GatewayCostUsageDay(
date: "2026-02-24",
input: 10,
output: 20,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 30,
totalCost: 0.12,
missingCostEntries: 0),
],
totals: GatewayCostUsageTotals(
input: 10,
output: 20,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 30,
totalCost: 0.12,
missingCostEntries: 0))
injector.setTestingCostUsageSummary(summary, errorText: nil)
let menu = NSMenu()
menu.addItem(NSMenuItem(title: "Header", action: nil, keyEquivalent: ""))
menu.addItem(.separator())
menu.addItem(NSMenuItem(title: "Send Heartbeats", action: nil, keyEquivalent: ""))
injector.injectForTesting(into: menu)
let usageCostItem = menu.items.first { $0.title == "Usage cost (30 days)" }
#expect(usageCostItem != nil)
#expect(usageCostItem?.submenu != nil)
#expect(usageCostItem?.submenu?.delegate == nil)
}
}

View File

@ -25,6 +25,8 @@ openclaw security audit --json
The audit warns when multiple DM senders share the main session and recommends **secure DM mode**: `session.dmScope="per-channel-peer"` (or `per-account-channel-peer` for multi-account channels) for shared inboxes.
This is for cooperative/shared inbox hardening. A single Gateway shared by mutually untrusted/adversarial operators is not a recommended setup; split trust boundaries with separate gateways (or separate OS users/hosts).
It also emits `security.trust_model.multi_user_heuristic` when config suggests likely shared-user ingress (for example configured group targets or wildcard sender rules), and reminds you that OpenClaw is a personal-assistant trust model by default.
For intentional shared-user setups, the audit guidance is to sandbox all sessions, keep filesystem access workspace-scoped, and keep personal/private identities or credentials off that runtime.
It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled.
For webhook ingress, it warns when `hooks.defaultSessionKey` is unset, when request `sessionKey` overrides are enabled, and when overrides are enabled without `hooks.allowedSessionKeyPrefixes`.
It also warns when sandbox Docker settings are configured while sandbox mode is off, when `gateway.nodes.denyCommands` uses ineffective pattern-like/unknown entries, when `gateway.nodes.allowCommands` explicitly enables dangerous node commands, when global `tools.profile="minimal"` is overridden by agent tool profiles, when open groups expose runtime/filesystem tools without sandbox/workspace guards, and when installed extension plugin tools may be reachable under permissive tool policy.

View File

@ -7,6 +7,22 @@ title: "Security"
# Security 🔒
> [!WARNING]
> **Personal assistant trust model:** this guidance assumes one trusted operator boundary per gateway (single-user/personal assistant model).
> OpenClaw is **not** a hostile multi-tenant security boundary for multiple adversarial users sharing one agent/gateway.
> If you need mixed-trust or adversarial-user operation, split trust boundaries (separate gateway + credentials, ideally separate OS users/hosts).
## Scope first: personal assistant security model
OpenClaw security guidance assumes a **personal assistant** deployment: one trusted operator boundary, potentially many agents.
- Supported security posture: one user/trust boundary per gateway (prefer one OS user/host/VPS per boundary).
- Not a supported security boundary: one shared gateway/agent used by mutually untrusted or adversarial users.
- If adversarial-user isolation is required, split by trust boundary (separate gateway + credentials, and ideally separate OS users/hosts).
- If multiple untrusted users can message one tool-enabled agent, treat them as sharing the same delegated tool authority for that agent.
This page explains hardening **within that model**. It does not claim hostile multi-tenant isolation on one shared gateway.
## Quick check: `openclaw security audit`
See also: [Formal Verification (Security Models)](/security/formal-verification/)

View File

@ -34,17 +34,17 @@ Notes:
# From repo root; set release IDs so Sparkle feed is enabled.
# APP_BUILD must be numeric + monotonic for Sparkle compare.
BUNDLE_ID=bot.molt.mac \
APP_VERSION=2026.2.23 \
APP_VERSION=2026.2.24 \
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.23.zip
ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.2.24.zip
# Optional: also build a styled DMG for humans (drag to /Applications)
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.23.dmg
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.24.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.23.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.23 \
APP_VERSION=2026.2.24 \
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.23.dSYM.zip
ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.2.24.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.23.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.24.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.23.zip` (and `OpenClaw-2026.2.23.dSYM.zip`) to the GitHub release for tag `v2026.2.23`.
- Upload `OpenClaw-2026.2.24.zip` (and `OpenClaw-2026.2.24.dSYM.zip`) to the GitHub release for tag `v2026.2.24`.
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`.
- Sanity checks:
- `curl -I https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml` returns 200.

View File

@ -1,6 +1,6 @@
{
"name": "openclaw",
"version": "2026.2.23",
"version": "2026.2.24",
"description": "Multi-channel AI gateway with extensible messaging integrations",
"keywords": [],
"homepage": "https://github.com/openclaw/openclaw#readme",

View File

@ -88,6 +88,37 @@ describe("handleToolExecutionStart read path checks", () => {
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0]?.[0] ?? "")).toContain("read tool called without path");
});
it("awaits onBlockReplyFlush before continuing tool start processing", async () => {
const { ctx, onBlockReplyFlush } = createTestContext();
let releaseFlush: (() => void) | undefined;
onBlockReplyFlush.mockImplementation(
() =>
new Promise<void>((resolve) => {
releaseFlush = resolve;
}),
);
const evt: ToolExecutionStartEvent = {
type: "tool_execution_start",
toolName: "exec",
toolCallId: "tool-await-flush",
args: { command: "echo hi" },
};
const pending = handleToolExecutionStart(ctx, evt);
// Let the async function reach the awaited flush Promise.
await Promise.resolve();
// If flush isn't awaited, tool metadata would already be recorded here.
expect(ctx.state.toolMetaById.has("tool-await-flush")).toBe(false);
expect(releaseFlush).toBeTypeOf("function");
releaseFlush?.();
await pending;
expect(ctx.state.toolMetaById.has("tool-await-flush")).toBe(true);
});
});
describe("handleToolExecutionEnd cron.add commitment tracking", () => {

View File

@ -174,7 +174,7 @@ export async function handleToolExecutionStart(
// Flush pending block replies to preserve message boundaries before tool execution.
ctx.flushBlockReplyBuffer();
if (ctx.params.onBlockReplyFlush) {
void ctx.params.onBlockReplyFlush();
await ctx.params.onBlockReplyFlush();
}
const rawToolName = String(evt.toolName);

View File

@ -338,6 +338,137 @@ function listGroupPolicyOpen(cfg: OpenClawConfig): string[] {
return out;
}
function hasConfiguredGroupTargets(section: Record<string, unknown>): boolean {
const groupKeys = ["groups", "guilds", "channels", "rooms"];
return groupKeys.some((key) => {
const value = section[key];
return Boolean(value && typeof value === "object" && Object.keys(value).length > 0);
});
}
function listPotentialMultiUserSignals(cfg: OpenClawConfig): string[] {
const out = new Set<string>();
const channels = cfg.channels as Record<string, unknown> | undefined;
if (!channels || typeof channels !== "object") {
return [];
}
const inspectSection = (section: Record<string, unknown>, basePath: string) => {
const groupPolicy = typeof section.groupPolicy === "string" ? section.groupPolicy : null;
if (groupPolicy === "open") {
out.add(`${basePath}.groupPolicy="open"`);
} else if (groupPolicy === "allowlist" && hasConfiguredGroupTargets(section)) {
out.add(`${basePath}.groupPolicy="allowlist" with configured group targets`);
}
const dmPolicy = typeof section.dmPolicy === "string" ? section.dmPolicy : null;
if (dmPolicy === "open") {
out.add(`${basePath}.dmPolicy="open"`);
}
const allowFrom = Array.isArray(section.allowFrom) ? section.allowFrom : [];
if (allowFrom.some((entry) => String(entry).trim() === "*")) {
out.add(`${basePath}.allowFrom includes "*"`);
}
const groupAllowFrom = Array.isArray(section.groupAllowFrom) ? section.groupAllowFrom : [];
if (groupAllowFrom.some((entry) => String(entry).trim() === "*")) {
out.add(`${basePath}.groupAllowFrom includes "*"`);
}
const dm = section.dm;
if (dm && typeof dm === "object") {
const dmSection = dm as Record<string, unknown>;
const dmLegacyPolicy = typeof dmSection.policy === "string" ? dmSection.policy : null;
if (dmLegacyPolicy === "open") {
out.add(`${basePath}.dm.policy="open"`);
}
const dmAllowFrom = Array.isArray(dmSection.allowFrom) ? dmSection.allowFrom : [];
if (dmAllowFrom.some((entry) => String(entry).trim() === "*")) {
out.add(`${basePath}.dm.allowFrom includes "*"`);
}
}
};
for (const [channelId, value] of Object.entries(channels)) {
if (!value || typeof value !== "object") {
continue;
}
const section = value as Record<string, unknown>;
inspectSection(section, `channels.${channelId}`);
const accounts = section.accounts;
if (!accounts || typeof accounts !== "object") {
continue;
}
for (const [accountId, accountValue] of Object.entries(accounts)) {
if (!accountValue || typeof accountValue !== "object") {
continue;
}
inspectSection(
accountValue as Record<string, unknown>,
`channels.${channelId}.accounts.${accountId}`,
);
}
}
return Array.from(out);
}
function collectRiskyToolExposureContexts(cfg: OpenClawConfig): {
riskyContexts: string[];
hasRuntimeRisk: boolean;
} {
const contexts: Array<{
label: string;
agentId?: string;
tools?: AgentToolsConfig;
}> = [{ label: "agents.defaults" }];
for (const agent of cfg.agents?.list ?? []) {
if (!agent || typeof agent !== "object" || typeof agent.id !== "string") {
continue;
}
contexts.push({
label: `agents.list.${agent.id}`,
agentId: agent.id,
tools: agent.tools,
});
}
const riskyContexts: string[] = [];
let hasRuntimeRisk = false;
for (const context of contexts) {
const sandboxMode = resolveSandboxConfigForAgent(cfg, context.agentId).mode;
const policies = resolveToolPolicies({
cfg,
agentTools: context.tools,
sandboxMode,
agentId: context.agentId ?? null,
});
const runtimeTools = ["exec", "process"].filter((tool) =>
isToolAllowedByPolicies(tool, policies),
);
const fsTools = ["read", "write", "edit", "apply_patch"].filter((tool) =>
isToolAllowedByPolicies(tool, policies),
);
const fsWorkspaceOnly = context.tools?.fs?.workspaceOnly ?? cfg.tools?.fs?.workspaceOnly;
const runtimeUnguarded = runtimeTools.length > 0 && sandboxMode !== "all";
const fsUnguarded = fsTools.length > 0 && sandboxMode !== "all" && fsWorkspaceOnly !== true;
if (!runtimeUnguarded && !fsUnguarded) {
continue;
}
if (runtimeUnguarded) {
hasRuntimeRisk = true;
}
riskyContexts.push(
`${context.label} (sandbox=${sandboxMode}; runtime=[${runtimeTools.join(", ") || "off"}]; fs=[${fsTools.join(", ") || "off"}]; fs.workspaceOnly=${
fsWorkspaceOnly === true ? "true" : "false"
})`,
);
}
return { riskyContexts, hasRuntimeRisk };
}
// --------------------------------------------------------------------------
// Exported collectors
// --------------------------------------------------------------------------
@ -358,7 +489,9 @@ export function collectAttackSurfaceSummaryFindings(cfg: OpenClawConfig): Securi
`\n` +
`hooks.internal: ${internalHooksEnabled ? "enabled" : "disabled"}` +
`\n` +
`browser control: ${browserEnabled ? "enabled" : "disabled"}`;
`browser control: ${browserEnabled ? "enabled" : "disabled"}` +
`\n` +
"trust model: personal assistant (one trusted operator boundary), not hostile multi-tenant on one shared gateway";
return [
{
@ -1096,53 +1229,7 @@ export function collectExposureMatrixFindings(cfg: OpenClawConfig): SecurityAudi
});
}
const contexts: Array<{
label: string;
agentId?: string;
tools?: AgentToolsConfig;
}> = [{ label: "agents.defaults" }];
for (const agent of cfg.agents?.list ?? []) {
if (!agent || typeof agent !== "object" || typeof agent.id !== "string") {
continue;
}
contexts.push({
label: `agents.list.${agent.id}`,
agentId: agent.id,
tools: agent.tools,
});
}
const riskyContexts: string[] = [];
let hasRuntimeRisk = false;
for (const context of contexts) {
const sandboxMode = resolveSandboxConfigForAgent(cfg, context.agentId).mode;
const policies = resolveToolPolicies({
cfg,
agentTools: context.tools,
sandboxMode,
agentId: context.agentId ?? null,
});
const runtimeTools = ["exec", "process"].filter((tool) =>
isToolAllowedByPolicies(tool, policies),
);
const fsTools = ["read", "write", "edit", "apply_patch"].filter((tool) =>
isToolAllowedByPolicies(tool, policies),
);
const fsWorkspaceOnly = context.tools?.fs?.workspaceOnly ?? cfg.tools?.fs?.workspaceOnly;
const runtimeUnguarded = runtimeTools.length > 0 && sandboxMode !== "all";
const fsUnguarded = fsTools.length > 0 && sandboxMode !== "all" && fsWorkspaceOnly !== true;
if (!runtimeUnguarded && !fsUnguarded) {
continue;
}
if (runtimeUnguarded) {
hasRuntimeRisk = true;
}
riskyContexts.push(
`${context.label} (sandbox=${sandboxMode}; runtime=[${runtimeTools.join(", ") || "off"}]; fs=[${fsTools.join(", ") || "off"}]; fs.workspaceOnly=${
fsWorkspaceOnly === true ? "true" : "false"
})`,
);
}
const { riskyContexts, hasRuntimeRisk } = collectRiskyToolExposureContexts(cfg);
if (riskyContexts.length > 0) {
findings.push({
@ -1160,3 +1247,35 @@ export function collectExposureMatrixFindings(cfg: OpenClawConfig): SecurityAudi
return findings;
}
export function collectLikelyMultiUserSetupFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
const findings: SecurityAuditFinding[] = [];
const signals = listPotentialMultiUserSignals(cfg);
if (signals.length === 0) {
return findings;
}
const { riskyContexts, hasRuntimeRisk } = collectRiskyToolExposureContexts(cfg);
const impactLine = hasRuntimeRisk
? "Runtime/process tools are exposed without full sandboxing in at least one context."
: "No unguarded runtime/process tools were detected by this heuristic.";
const riskyContextsDetail =
riskyContexts.length > 0
? `Potential high-impact tool exposure contexts:\n${riskyContexts.map((line) => `- ${line}`).join("\n")}`
: "No unguarded runtime/filesystem contexts detected.";
findings.push({
checkId: "security.trust_model.multi_user_heuristic",
severity: "warn",
title: "Potential multi-user setup detected (personal-assistant model warning)",
detail:
"Heuristic signals indicate this gateway may be reachable by multiple users:\n" +
signals.map((signal) => `- ${signal}`).join("\n") +
`\n${impactLine}\n${riskyContextsDetail}\n` +
"OpenClaw's default security model is personal-assistant (one trusted operator boundary), not hostile multi-tenant isolation on one shared gateway.",
remediation:
'If users may be mutually untrusted, split trust boundaries (separate gateways + credentials, ideally separate OS users/hosts). If you intentionally run shared-user access, set agents.defaults.sandbox.mode="all", keep tools.fs.workspaceOnly=true, deny runtime/fs/web tools unless required, and keep personal/private identities + credentials off that runtime.',
});
return findings;
}

View File

@ -14,6 +14,7 @@ export {
collectGatewayHttpNoAuthFindings,
collectGatewayHttpSessionKeyOverrideFindings,
collectHooksHardeningFindings,
collectLikelyMultiUserSetupFindings,
collectMinimalProfileOverrideFindings,
collectModelHygieneFindings,
collectNodeDangerousAllowCommandFindings,

View File

@ -178,12 +178,14 @@ describe("security audit", () => {
};
const res = await audit(cfg);
const summary = res.findings.find((f) => f.checkId === "summary.attack_surface");
expect(res.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({ checkId: "summary.attack_surface", severity: "info" }),
]),
);
expect(summary?.detail).toContain("trust model: personal assistant");
});
it("flags non-loopback bind without auth as critical", async () => {
@ -2696,6 +2698,51 @@ description: test skill
).toBe(false);
});
it("warns when config heuristics suggest a likely multi-user setup", async () => {
const cfg: OpenClawConfig = {
channels: {
discord: {
groupPolicy: "allowlist",
guilds: {
"1234567890": {
channels: {
"7777777777": { allow: true },
},
},
},
},
},
tools: { elevated: { enabled: false } },
};
const res = await audit(cfg);
const finding = res.findings.find(
(f) => f.checkId === "security.trust_model.multi_user_heuristic",
);
expect(finding?.severity).toBe("warn");
expect(finding?.detail).toContain(
'channels.discord.groupPolicy="allowlist" with configured group targets',
);
expect(finding?.detail).toContain("personal-assistant");
expect(finding?.remediation).toContain('agents.defaults.sandbox.mode="all"');
});
it("does not warn for multi-user heuristic when no shared-user signals are configured", async () => {
const cfg: OpenClawConfig = {
channels: {
discord: {
groupPolicy: "allowlist",
},
},
tools: { elevated: { enabled: false } },
};
const res = await audit(cfg);
expectNoFinding(res, "security.trust_model.multi_user_heuristic");
});
describe("maybeProbeGateway auth selection", () => {
const makeProbeCapture = () => {
let capturedAuth: { token?: string; password?: string } | undefined;

View File

@ -24,6 +24,7 @@ import {
collectHooksHardeningFindings,
collectIncludeFilePermFindings,
collectInstalledSkillsCodeSafetyFindings,
collectLikelyMultiUserSetupFindings,
collectSandboxBrowserHashLabelFindings,
collectMinimalProfileOverrideFindings,
collectModelHygieneFindings,
@ -866,6 +867,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<Secu
findings.push(...collectModelHygieneFindings(cfg));
findings.push(...collectSmallModelRiskFindings({ cfg, env }));
findings.push(...collectExposureMatrixFindings(cfg));
findings.push(...collectLikelyMultiUserSetupFindings(cfg));
const configSnapshot =
opts.includeFilesystem !== false