Skip to content

Commit c59bb4f

Browse files
authored
Merge branch 'main' into feat/webview2-bridge-spa
2 parents cd7619b + 23341e5 commit c59bb4f

1,001 files changed

Lines changed: 19140 additions & 7298 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/openclaw-release-checks.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -599,7 +599,7 @@ jobs:
599599
published_upgrade_survivor_baselines: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'last-stable-4 2026.4.23 2026.5.2 2026.4.15' || '' }}
600600
published_upgrade_survivor_scenarios: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'reported-issues' || '' }}
601601
telegram_mode: mock-openai
602-
telegram_scenarios: telegram-help-command,telegram-commands-command,telegram-tools-compact-command,telegram-whoami-command,telegram-context-command,telegram-current-session-status-tool,telegram-mention-gating
602+
telegram_scenarios: telegram-help-command,telegram-commands-command,telegram-tools-compact-command,telegram-whoami-command,telegram-status-command,telegram-other-bot-command-gating,telegram-context-command,telegram-mentioned-message-reply,telegram-reply-chain-exact-marker,telegram-stream-final-single-message,telegram-long-final-reuses-preview,telegram-mention-gating
603603
secrets:
604604
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
605605
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Large diffs are not rendered by default.

apps/macos/Sources/OpenClaw/AppState.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import SwiftUI
88
@MainActor
99
@Observable
1010
final class AppState {
11+
private static let logger = Logger(subsystem: "ai.openclaw", category: "app-state")
12+
1113
private let isPreview: Bool
1214
private var isInitializing = true
1315
private var isApplyingRemoteTokenConfig = false
@@ -696,7 +698,10 @@ final class AppState {
696698
remoteToken: self.remoteToken,
697699
remoteTokenDirty: self.remoteTokenDirty))
698700
guard synced.changed else { return }
699-
OpenClawConfigFile.saveDict(synced.root)
701+
guard OpenClawConfigFile.saveDict(synced.root) else {
702+
Self.logger.warning("gateway config sync rejected to protect persisted gateway auth/mode")
703+
return
704+
}
700705
}
701706

702707
func triggerVoiceEars(ttl: TimeInterval? = 5) {

apps/macos/Sources/OpenClaw/ConfigStore.swift

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ enum ConfigStore {
88
var saveLocal: (@MainActor @Sendable ([String: Any]) -> Void)?
99
var loadRemote: (@MainActor @Sendable () async -> [String: Any])?
1010
var saveRemote: (@MainActor @Sendable ([String: Any]) async throws -> Void)?
11+
var saveGateway: (@MainActor @Sendable ([String: Any]) async throws -> Void)?
1112
}
1213

1314
private actor OverrideStore {
@@ -66,10 +67,19 @@ enum ConfigStore {
6667
do {
6768
try await self.saveToGateway(root)
6869
} catch {
69-
OpenClawConfigFile.saveDict(
70+
guard self.shouldFallbackToLocalWrite(afterGatewaySaveError: error) else {
71+
self.lastHash = nil
72+
throw error
73+
}
74+
guard OpenClawConfigFile.saveDict(
7075
root,
7176
preserveExistingKeys: true,
7277
allowGatewayAuthMutation: allowGatewayAuthMutation)
78+
else {
79+
throw NSError(domain: "ConfigStore", code: 2, userInfo: [
80+
NSLocalizedDescriptionKey: "Local config write rejected to protect gateway auth/mode.",
81+
])
82+
}
7383
}
7484
}
7585
}
@@ -89,8 +99,30 @@ enum ConfigStore {
8999
}
90100
}
91101

102+
private static func shouldFallbackToLocalWrite(afterGatewaySaveError error: Error) -> Bool {
103+
let nsError = error as NSError
104+
let message = "\(nsError.domain) \(nsError.localizedDescription)".lowercased()
105+
let blockedFragments = [
106+
"invalid_request",
107+
"invalid request",
108+
"invalid config",
109+
"config changed since last load",
110+
"base hash",
111+
"basehash",
112+
"unauthorized",
113+
"token mismatch",
114+
"auth",
115+
]
116+
return !blockedFragments.contains { message.contains($0) }
117+
}
118+
92119
@MainActor
93120
private static func saveToGateway(_ root: [String: Any]) async throws {
121+
let overrides = await self.overrideStore.overrides
122+
if let saveGateway = overrides.saveGateway {
123+
try await saveGateway(root)
124+
return
125+
}
94126
if self.lastHash == nil {
95127
_ = await self.loadFromGateway()
96128
}

apps/macos/Sources/OpenClaw/DebugSettings.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -779,7 +779,10 @@ struct DebugSettings: View {
779779
session["store"] = trimmed.isEmpty ? SessionLoader.defaultStorePath : trimmed
780780
root["session"] = session
781781

782-
OpenClawConfigFile.saveDict(root)
782+
guard OpenClawConfigFile.saveDict(root) else {
783+
self.sessionStoreSaveError = "Config write rejected to protect gateway auth/mode."
784+
return
785+
}
783786
self.sessionStoreSaveError = nil
784787
}
785788

apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift

Lines changed: 71 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,16 @@ enum OpenClawConfigFile {
5252
}
5353
}
5454

55+
@discardableResult
5556
static func saveDict(
5657
_ dict: [String: Any],
5758
preserveExistingKeys: Bool = false,
5859
allowGatewayAuthMutation: Bool = false)
60+
-> Bool
5961
{
6062
self.withFileLock {
6163
// Nix mode disables config writes in production, but tests rely on saving temp configs.
62-
if ProcessInfo.processInfo.isNixMode, !ProcessInfo.processInfo.isRunningTests { return }
64+
if ProcessInfo.processInfo.isNixMode, !ProcessInfo.processInfo.isRunningTests { return false }
6365
let url = self.url()
6466
let previousData = try? Data(contentsOf: url)
6567
let previousRoot = previousData.flatMap { self.parseConfigData($0) }
@@ -81,12 +83,7 @@ enum OpenClawConfigFile {
8183

8284
do {
8385
let data = try JSONSerialization.data(withJSONObject: output, options: [.prettyPrinted, .sortedKeys])
84-
try FileManager().createDirectory(
85-
at: url.deletingLastPathComponent(),
86-
withIntermediateDirectories: true)
87-
try data.write(to: url, options: [.atomic])
8886
let nextBytes = data.count
89-
let nextAttributes = try? FileManager().attributesOfItem(atPath: url.path)
9087
let gatewayModeAfter = self.gatewayMode(output)
9188
var suspicious = self.configWriteSuspiciousReasons(
9289
existsBefore: previousData != nil,
@@ -98,6 +95,44 @@ enum OpenClawConfigFile {
9895
if preservedGatewayAuth {
9996
suspicious.append("gateway-auth-preserved")
10097
}
98+
let blocking = self.configWriteBlockingReasons(suspicious)
99+
if !blocking.isEmpty {
100+
let rejectedPath = self.persistRejectedConfigWrite(data: data, configURL: url)
101+
self.logger.warning("config write rejected (\(blocking.joined(separator: ", "))) at \(url.path)")
102+
self.appendConfigWriteAudit([
103+
"result": "rejected",
104+
"configPath": url.path,
105+
"existsBefore": previousData != nil,
106+
"previousBytes": previousBytes ?? NSNull(),
107+
"nextBytes": nextBytes,
108+
"previousDev": self.fileSystemNumber(previousAttributes?[.systemNumber]) ?? NSNull(),
109+
"nextDev": NSNull(),
110+
"previousIno": self.fileSystemNumber(previousAttributes?[.systemFileNumber]) ?? NSNull(),
111+
"nextIno": NSNull(),
112+
"previousMode": self.posixMode(previousAttributes?[.posixPermissions]) ?? NSNull(),
113+
"nextMode": NSNull(),
114+
"previousNlink": self.fileAttributeInt(previousAttributes?[.referenceCount]) ?? NSNull(),
115+
"nextNlink": NSNull(),
116+
"previousUid": self.fileAttributeInt(previousAttributes?[.ownerAccountID]) ?? NSNull(),
117+
"nextUid": NSNull(),
118+
"previousGid": self.fileAttributeInt(previousAttributes?[.groupOwnerAccountID]) ?? NSNull(),
119+
"nextGid": NSNull(),
120+
"hasMetaBefore": hadMetaBefore,
121+
"hasMetaAfter": self.hasMeta(output),
122+
"gatewayModeBefore": gatewayModeBefore ?? NSNull(),
123+
"gatewayModeAfter": gatewayModeAfter ?? NSNull(),
124+
"preservedGatewayAuth": preservedGatewayAuth,
125+
"suspicious": suspicious,
126+
"blocking": blocking,
127+
"rejectedPath": rejectedPath ?? NSNull(),
128+
])
129+
return false
130+
}
131+
try FileManager().createDirectory(
132+
at: url.deletingLastPathComponent(),
133+
withIntermediateDirectories: true)
134+
try data.write(to: url, options: [.atomic])
135+
let nextAttributes = try? FileManager().attributesOfItem(atPath: url.path)
101136
if !suspicious.isEmpty {
102137
self.logger.warning("config write anomaly (\(suspicious.joined(separator: ", "))) at \(url.path)")
103138
}
@@ -123,9 +158,11 @@ enum OpenClawConfigFile {
123158
"hasMetaAfter": self.hasMeta(output),
124159
"gatewayModeBefore": gatewayModeBefore ?? NSNull(),
125160
"gatewayModeAfter": gatewayModeAfter ?? NSNull(),
161+
"preservedGatewayAuth": preservedGatewayAuth,
126162
"suspicious": suspicious,
127163
])
128164
self.observeConfigRead(data: data, root: output, configURL: url, valid: true)
165+
return true
129166
} catch {
130167
self.logger.error("config save failed: \(error.localizedDescription)")
131168
self.appendConfigWriteAudit([
@@ -138,9 +175,11 @@ enum OpenClawConfigFile {
138175
"hasMetaAfter": self.hasMeta(output),
139176
"gatewayModeBefore": gatewayModeBefore ?? NSNull(),
140177
"gatewayModeAfter": self.gatewayMode(output) ?? NSNull(),
178+
"preservedGatewayAuth": preservedGatewayAuth,
141179
"suspicious": preservedGatewayAuth ? ["gateway-auth-preserved"] : [],
142180
"error": error.localizedDescription,
143181
])
182+
return false
144183
}
145184
}
146185
}
@@ -416,6 +455,12 @@ enum OpenClawConfigFile {
416455
return reasons
417456
}
418457

458+
private static func configWriteBlockingReasons(_ suspicious: [String]) -> [String] {
459+
suspicious.filter { reason in
460+
reason.hasPrefix("size-drop:") || reason == "gateway-mode-removed"
461+
}
462+
}
463+
419464
private static func configAuditLogURL() -> URL {
420465
self.stateDirURL()
421466
.appendingPathComponent("logs", isDirectory: true)
@@ -594,6 +639,26 @@ enum OpenClawConfigFile {
594639
}
595640
}
596641

642+
private static func persistRejectedConfigWrite(data: Data, configURL: URL) -> String? {
643+
let timestamp = ISO8601DateFormatter().string(from: Date())
644+
let url = configURL.deletingLastPathComponent()
645+
.appendingPathComponent("\(configURL.lastPathComponent).rejected.\(self.configTimestampToken(timestamp))")
646+
let fileManager = FileManager()
647+
let privatePermissions: NSNumber = 0o600
648+
if fileManager.fileExists(atPath: url.path) {
649+
try? fileManager.setAttributes([.posixPermissions: privatePermissions], ofItemAtPath: url.path)
650+
return url.path
651+
}
652+
guard fileManager.createFile(
653+
atPath: url.path,
654+
contents: data,
655+
attributes: [.posixPermissions: privatePermissions])
656+
else {
657+
return nil
658+
}
659+
return url.path
660+
}
661+
597662
private static func observeConfigRead(data: Data, root: [String: Any]?, configURL: URL, valid: Bool) {
598663
let observedAt = ISO8601DateFormatter().string(from: Date())
599664
let current = self.configFingerprint(data: data, root: root, configURL: configURL, observedAt: observedAt)

apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,4 +259,37 @@ struct AppStateRemoteConfigTests {
259259
remoteTokenDirty: true))
260260
#expect((cleared["token"] as? String) == nil)
261261
}
262+
263+
@Test
264+
func `synced gateway root preserves gateway auth across mode changes`() {
265+
let initialRoot: [String: Any] = [
266+
"gateway": [
267+
"mode": "remote",
268+
"auth": [
269+
"mode": "token",
270+
"token": "test-token", // pragma: allowlist secret
271+
],
272+
"remote": [
273+
"transport": "direct",
274+
"url": "wss://old-gateway.example",
275+
],
276+
],
277+
]
278+
279+
let localRoot = AppState._testSyncedGatewayRoot(
280+
currentRoot: initialRoot,
281+
draft: .init(
282+
connectionMode: .local,
283+
remoteTransport: .ssh,
284+
remoteTarget: "",
285+
remoteIdentity: "",
286+
remoteUrl: "",
287+
remoteToken: "",
288+
remoteTokenDirty: false))
289+
let localGateway = localRoot["gateway"] as? [String: Any]
290+
let auth = localGateway?["auth"] as? [String: Any]
291+
#expect(localGateway?["mode"] as? String == "local")
292+
#expect(auth?["mode"] as? String == "token")
293+
#expect(auth?["token"] as? String == "test-token") // pragma: allowlist secret
294+
}
262295
}

apps/macos/Tests/OpenClawIPCTests/ConfigStoreTests.swift

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import Foundation
12
import Testing
23
@testable import OpenClaw
34

@@ -65,4 +66,76 @@ struct ConfigStoreTests {
6566
#expect(localHit)
6667
#expect(!remoteHit)
6768
}
69+
70+
@Test func `local save does not fall back to direct write after stale gateway rejection`() async throws {
71+
let stateDir = FileManager().temporaryDirectory
72+
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
73+
let configPath = stateDir.appendingPathComponent("openclaw.json")
74+
defer { try? FileManager().removeItem(at: stateDir) }
75+
76+
try await TestIsolation.withEnvValues([
77+
"OPENCLAW_STATE_DIR": stateDir.path,
78+
"OPENCLAW_CONFIG_PATH": configPath.path,
79+
]) {
80+
OpenClawConfigFile.saveDict([
81+
"gateway": [
82+
"mode": "local",
83+
"auth": [
84+
"mode": "token",
85+
"token": "test-token", // pragma: allowlist secret
86+
],
87+
],
88+
])
89+
let before = try String(contentsOf: configPath, encoding: .utf8)
90+
await ConfigStore._testSetOverrides(.init(
91+
isRemoteMode: { false },
92+
saveGateway: { _ in
93+
throw NSError(domain: "Gateway", code: 0, userInfo: [
94+
NSLocalizedDescriptionKey: "config changed since last load; re-run config.get and retry",
95+
])
96+
}))
97+
98+
var didThrow = false
99+
do {
100+
try await ConfigStore.save(["browser": ["enabled": false]])
101+
} catch {
102+
didThrow = true
103+
}
104+
await ConfigStore._testClearOverrides()
105+
106+
#expect(didThrow)
107+
let after = try String(contentsOf: configPath, encoding: .utf8)
108+
#expect(after == before)
109+
}
110+
}
111+
112+
@Test func `local save can fall back to protected direct write when gateway is unavailable`() async throws {
113+
let stateDir = FileManager().temporaryDirectory
114+
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
115+
let configPath = stateDir.appendingPathComponent("openclaw.json")
116+
defer { try? FileManager().removeItem(at: stateDir) }
117+
118+
try await TestIsolation.withEnvValues([
119+
"OPENCLAW_STATE_DIR": stateDir.path,
120+
"OPENCLAW_CONFIG_PATH": configPath.path,
121+
]) {
122+
await ConfigStore._testSetOverrides(.init(
123+
isRemoteMode: { false },
124+
saveGateway: { _ in
125+
throw NSError(domain: "Gateway", code: 0, userInfo: [
126+
NSLocalizedDescriptionKey: "gateway not configured",
127+
])
128+
}))
129+
try await ConfigStore.save([
130+
"gateway": ["mode": "local"],
131+
"browser": ["enabled": false],
132+
])
133+
await ConfigStore._testClearOverrides()
134+
135+
let data = try Data(contentsOf: configPath)
136+
let root = try JSONSerialization.jsonObject(with: data) as? [String: Any]
137+
#expect(((root?["browser"] as? [String: Any])?["enabled"] as? Bool) == false)
138+
#expect((root?["meta"] as? [String: Any]) != nil)
139+
}
140+
}
68141
}

0 commit comments

Comments
 (0)