mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
fix(macos): native chat settings changes can overwrite a newer session or permission state (#162043)
* fix(macos): stop native chat settings writes from overwriting newer session state The macOS chat transport rebuilt the shared settings patch field by field and forwarded only model/thinking/fast/verbose to sessions.patch. The compare-and-swap guards (expectedSessionId, expectedPermissionMode, expectedToolOverrides) and the permissionMode/toolOverrides replacements were dropped, and the session-settings-contract / session-settings-cas-v1 capabilities were never negotiated. A stale Mac settings write could overwrite a reset session or newer permission/tool state, and a permission/tool change became an empty patch that looked successful. The patch-to-params mapping and the fail-closed capability gate now have one shared owner: OpenClawChatGatewayRequests.patchSessionSettings(patch:) plus OpenClawChatSessionSettingsPatch.requiresSessionSettingsContract/CAS. iOS moves onto it with unchanged route capture, probing, and dispatch. macOS drops its sessionSettingsRequest wrapper, probes both capabilities on the same ServerLease it dispatches on, and lets the Gateway's session-changed rejection reach the shared ChatViewModel settings lane unchanged, matching iOS. Regression tests ran red against the old adapter first (guards missing on the wire, stale writes applied, unsupported Gateways still dispatched). * fix(apple): keep the released field-based settings patch builder public The field-based OpenClawChatGatewayRequests.patchSessionSettings overload shipped as public API in the v2026.9.7 OpenClawChatUI library product. Restore its visibility so external Swift callers keep compiling; the patch-based builder continues to delegate to it. Verified with an external package that imports OpenClawChatUI without @testable.
This commit is contained in:
parent
f59e979213
commit
a98fb670d3
7 changed files with 301 additions and 101 deletions
|
|
@ -375,11 +375,7 @@ struct IOSGatewayChatTransport: OpenClawChatGatewayTransport {
|
|||
patch: OpenClawChatSessionSettingsPatch,
|
||||
ifCurrentRoute expectedRoute: GatewayNodeSessionRoute?) async throws -> OpenClawChatModelPatchResult?
|
||||
{
|
||||
let requiresSettingsContract = patch.expectedSessionID != nil ||
|
||||
patch.permissionMode != nil || patch.toolOverrides != nil
|
||||
let requiresSettingsCAS = patch.expectedPermissionMode != nil ||
|
||||
patch.expectedToolOverrides != nil || patch.permissionMode != nil || patch.toolOverrides != nil
|
||||
let fallbackRoute: GatewayNodeSessionRoute? = if requiresSettingsContract, expectedRoute == nil {
|
||||
let fallbackRoute: GatewayNodeSessionRoute? = if patch.requiresSessionSettingsContract, expectedRoute == nil {
|
||||
await self.currentSessionMutationRoute()
|
||||
} else {
|
||||
nil
|
||||
|
|
@ -390,25 +386,11 @@ struct IOSGatewayChatTransport: OpenClawChatGatewayTransport {
|
|||
} else {
|
||||
(settingsContract: false, settingsCAS: false)
|
||||
}
|
||||
guard !requiresSettingsContract || settingsSupport.settingsContract else {
|
||||
throw OpenClawChatTransportSendError.notDispatched
|
||||
}
|
||||
guard !requiresSettingsCAS || settingsSupport.settingsCAS else {
|
||||
throw OpenClawChatTransportSendError.notDispatched
|
||||
}
|
||||
let target = self.sessionTarget(for: sessionKey, overrideAgentID: agentID)
|
||||
let request = OpenClawChatGatewayRequests.patchSessionSettings(
|
||||
let request = try OpenClawChatGatewayRequests.patchSessionSettings(
|
||||
sessionKey: target.sessionKey,
|
||||
agentID: target.agentID,
|
||||
expectedSessionID: patch.expectedSessionID,
|
||||
expectedPermissionMode: patch.expectedPermissionMode,
|
||||
expectedToolOverrides: patch.expectedToolOverrides,
|
||||
model: patch.model,
|
||||
thinkingLevel: patch.thinkingLevel,
|
||||
fastMode: patch.fastMode,
|
||||
verboseLevel: patch.verboseLevel,
|
||||
permissionMode: patch.permissionMode,
|
||||
toolOverrides: patch.toolOverrides,
|
||||
patch: patch,
|
||||
supportsSessionSettingsContract: settingsSupport.settingsContract,
|
||||
supportsSessionSettingsCAS: settingsSupport.settingsCAS)
|
||||
let response = if let settingsRoute {
|
||||
|
|
|
|||
|
|
@ -476,34 +476,41 @@ struct MacGatewayChatTransport: OpenClawChatGatewayTransport {
|
|||
patch: OpenClawChatSessionSettingsPatch,
|
||||
serverLease: GatewayConnection.ServerLease?) async throws -> OpenClawChatModelPatchResult?
|
||||
{
|
||||
var settingsLease = serverLease
|
||||
if settingsLease == nil, patch.requiresSessionSettingsContract || patch.requiresSessionSettingsCAS {
|
||||
guard let capturedLease = await self.connection.captureServerLease() else {
|
||||
throw OpenClawChatTransportSendError.notDispatched
|
||||
}
|
||||
settingsLease = capturedLease
|
||||
}
|
||||
let supportsSettingsContract = if let settingsLease {
|
||||
await self.connection.supportsServerCapability(
|
||||
.sessionSettingsContract, ifCurrentServerLease: settingsLease) == true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
let supportsSettingsCAS = if let settingsLease {
|
||||
await self.connection.supportsServerCapability(
|
||||
.sessionSettingsCAS, ifCurrentServerLease: settingsLease) == true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
let target = self.sessionTarget(for: sessionKey, overrideAgentID: agentID)
|
||||
let request = Self.sessionSettingsRequest(
|
||||
let request = try OpenClawChatGatewayRequests.patchSessionSettings(
|
||||
sessionKey: target.sessionKey,
|
||||
agentID: target.agentID,
|
||||
patch: patch)
|
||||
let data: Data = if let serverLease {
|
||||
patch: patch,
|
||||
supportsSessionSettingsContract: supportsSettingsContract,
|
||||
supportsSessionSettingsCAS: supportsSettingsCAS)
|
||||
let data: Data = if let settingsLease {
|
||||
try await self.connection.request(
|
||||
request, ifCurrentServerLease: serverLease)
|
||||
request, ifCurrentServerLease: settingsLease)
|
||||
} else {
|
||||
try await self.connection.request(request)
|
||||
}
|
||||
return try JSONDecoder().decode(OpenClawChatModelPatchResult.self, from: data)
|
||||
}
|
||||
|
||||
static func sessionSettingsRequest(
|
||||
sessionKey: String,
|
||||
agentID: String?,
|
||||
patch: OpenClawChatSessionSettingsPatch) -> OpenClawChatGatewayRequest
|
||||
{
|
||||
OpenClawChatGatewayRequests.patchSessionSettings(
|
||||
sessionKey: sessionKey,
|
||||
agentID: agentID,
|
||||
model: patch.model,
|
||||
thinkingLevel: patch.thinkingLevel,
|
||||
fastMode: patch.fastMode,
|
||||
verboseLevel: patch.verboseLevel)
|
||||
}
|
||||
|
||||
func acquireSessionSettingsRouteLease() async -> OpenClawChatSessionSettingsRouteLease? {
|
||||
guard await self.currentOutboxGatewayMatchesConnection() else { return nil }
|
||||
guard let serverLease = await connection.captureServerLease() else { return nil }
|
||||
|
|
|
|||
|
|
@ -118,6 +118,18 @@ enum GatewayWebSocketTestSupport {
|
|||
return Data(json.utf8)
|
||||
}
|
||||
|
||||
static func errorResponseData(
|
||||
id: String,
|
||||
code: String,
|
||||
message: String,
|
||||
details: [String: Any]) throws -> Data
|
||||
{
|
||||
try JSONSerialization.data(withJSONObject: [
|
||||
"type": "res", "id": id, "ok": false,
|
||||
"error": ["code": code, "message": message, "details": details],
|
||||
])
|
||||
}
|
||||
|
||||
static func connectAuthFailureData(
|
||||
id: String,
|
||||
detailCode: String,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,45 @@ struct MacGatewayChatTransportMappingTests {
|
|||
}
|
||||
}
|
||||
|
||||
private actor SessionSettingsState {
|
||||
private var entry: [String: Any] = [
|
||||
"sessionId": "current-session", "permissionMode": "guarded",
|
||||
"model": "current-model", "toolOverrides": NSNull(),
|
||||
]
|
||||
|
||||
func snapshot() throws -> Data {
|
||||
try JSONSerialization.data(withJSONObject: self.entry, options: .sortedKeys)
|
||||
}
|
||||
|
||||
func respond(to data: Data) throws -> Data {
|
||||
let frame = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
let id = try #require(frame["id"] as? String)
|
||||
let params = try #require(frame["params"] as? [String: Any])
|
||||
let key = try #require(params["key"] as? String)
|
||||
for (expected, field) in [
|
||||
("expectedSessionId", "sessionId"),
|
||||
("expectedPermissionMode", "permissionMode"),
|
||||
("expectedToolOverrides", "toolOverrides"),
|
||||
] {
|
||||
if let value = params[expected],
|
||||
!NSDictionary(dictionary: ["value": value]).isEqual(to: ["value": self.entry[field] ?? NSNull()])
|
||||
{
|
||||
return try GatewayWebSocketTestSupport.errorResponseData(
|
||||
id: id, code: "INVALID_REQUEST",
|
||||
message: "Session \(key) changed before patch. Retry.",
|
||||
details: ["reason": "session-changed"])
|
||||
}
|
||||
}
|
||||
for field in ["model", "permissionMode", "toolOverrides"] {
|
||||
if let value = params[field] { self.entry[field] = value }
|
||||
}
|
||||
return try JSONSerialization.data(withJSONObject: [
|
||||
"type": "res", "id": id, "ok": true,
|
||||
"payload": ["key": key, "entry": self.entry],
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: [false, true, nil] as [Bool?])
|
||||
func `progress requests negotiate owner scope on the connected server`(supportsOwner: Bool?) async throws {
|
||||
let recorder = RequestRecorder()
|
||||
|
|
@ -99,6 +138,7 @@ struct MacGatewayChatTransportMappingTests {
|
|||
connectInitially: Bool = true,
|
||||
mainSessionKey: String? = nil,
|
||||
capabilities: [String] = ["session-unread-ack-contract"],
|
||||
settingsState: SessionSettingsState? = nil,
|
||||
_ run: @MainActor (MacGatewayChatTransport, RequestRecorder) async throws -> Void) async throws
|
||||
{
|
||||
let recorder = RequestRecorder()
|
||||
|
|
@ -115,6 +155,10 @@ struct MacGatewayChatTransportMappingTests {
|
|||
if method != "health" {
|
||||
await recorder.append(data)
|
||||
}
|
||||
if method == "sessions.patch", let settingsState {
|
||||
try await socket.emitReceiveSuccess(.data(settingsState.respond(to: data)))
|
||||
return
|
||||
}
|
||||
let frame = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
let params = frame["params"] as? [String: Any]
|
||||
let payload = switch method {
|
||||
|
|
@ -409,23 +453,123 @@ struct MacGatewayChatTransportMappingTests {
|
|||
await connection.shutdown()
|
||||
}
|
||||
|
||||
@Test func `session settings request preserves verbosity patch`() {
|
||||
let request = MacGatewayChatTransport.sessionSettingsRequest(
|
||||
sessionKey: "global",
|
||||
agentID: "reviewer",
|
||||
patch: OpenClawChatSessionSettingsPatch(
|
||||
model: .some("openai/gpt-5.6-luna"),
|
||||
thinkingLevel: .some(nil),
|
||||
fastMode: .some(.on),
|
||||
verboseLevel: .some("full")))
|
||||
@Test(arguments: [false, true])
|
||||
func `guarded settings writes carry CAS and replacements`(leased: Bool) async throws {
|
||||
try await self.withSessionTransport(
|
||||
capabilities: ["session-settings-contract", "session-settings-cas-v1"],
|
||||
settingsState: SessionSettingsState())
|
||||
{ transport, recorder in
|
||||
let patch = OpenClawChatSessionSettingsPatch(
|
||||
expectedSessionID: "current-session",
|
||||
expectedPermissionMode: .some(.guarded),
|
||||
expectedToolOverrides: .some(nil),
|
||||
permissionMode: .some(.workspace),
|
||||
toolOverrides: .some(.init(webSearch: false)))
|
||||
let result: OpenClawChatModelPatchResult?
|
||||
if leased {
|
||||
let lease = try #require(await transport.acquireSessionSettingsRouteLease())
|
||||
result = try await lease.patchSessionSettings(
|
||||
sessionKey: "global",
|
||||
agentID: "reviewer",
|
||||
patch: patch)
|
||||
} else {
|
||||
result = try await transport.patchSessionSettings(
|
||||
sessionKey: "global",
|
||||
agentID: "reviewer",
|
||||
patch: patch)
|
||||
}
|
||||
#expect(result?.permissionMode == .workspace)
|
||||
#expect(result?.toolOverrides?.webSearch == false)
|
||||
let frames = await recorder.snapshot()
|
||||
try #require(frames.count == 1)
|
||||
let frame = try #require(JSONSerialization.jsonObject(with: frames[0]) as? [String: Any])
|
||||
#expect(frame["method"] as? String == "sessions.patch")
|
||||
let params = try #require(frame["params"] as? [String: Any])
|
||||
#expect(NSDictionary(dictionary: params).isEqual(to: [
|
||||
"key": "global", "agentId": "reviewer", "expectedSessionId": "current-session",
|
||||
"expectedPermissionMode": "guarded", "expectedToolOverrides": NSNull(),
|
||||
"permissionMode": "workspace", "toolOverrides": ["webSearch": false],
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
#expect(request.method == "sessions.patch")
|
||||
#expect(request.params["key"]?.value as? String == "global")
|
||||
#expect(request.params["agentId"]?.value as? String == "reviewer")
|
||||
#expect(request.params["model"]?.value as? String == "openai/gpt-5.6-luna")
|
||||
#expect(request.params["thinkingLevel"]?.value is NSNull)
|
||||
#expect(request.params["fastMode"]?.value as? Bool == true)
|
||||
#expect(request.params["verboseLevel"]?.value as? String == "full")
|
||||
@Test(arguments: [false, true], [
|
||||
OpenClawChatSessionSettingsPatch(expectedSessionID: "stale-session", model: .some("new-model")),
|
||||
OpenClawChatSessionSettingsPatch(expectedPermissionMode: .some(.workspace), permissionMode: .some(.full)),
|
||||
OpenClawChatSessionSettingsPatch(
|
||||
expectedToolOverrides: .some(.init(webSearch: true)), toolOverrides: .some(.init(webSearch: false))),
|
||||
])
|
||||
func `stale settings writes propagate Gateway rejection without changing state`(
|
||||
leased: Bool, patch: OpenClawChatSessionSettingsPatch) async throws
|
||||
{
|
||||
let state = SessionSettingsState()
|
||||
let before = try await state.snapshot()
|
||||
try await self.withSessionTransport(
|
||||
capabilities: ["session-settings-contract", "session-settings-cas-v1"],
|
||||
settingsState: state)
|
||||
{ transport, recorder in
|
||||
do {
|
||||
if leased {
|
||||
let lease = try #require(await transport.acquireSessionSettingsRouteLease())
|
||||
_ = try await lease.patchSessionSettings(sessionKey: "global", agentID: nil, patch: patch)
|
||||
} else {
|
||||
_ = try await transport.patchSessionSettings(sessionKey: "global", agentID: nil, patch: patch)
|
||||
}
|
||||
Issue.record("Stale settings patch succeeded")
|
||||
} catch let error as GatewayResponseError {
|
||||
#expect(error.code == "INVALID_REQUEST")
|
||||
#expect(error.detailsReason == "session-changed")
|
||||
#expect(error.message == "Session global changed before patch. Retry.")
|
||||
#expect(error.localizedDescription ==
|
||||
"sessions.patch: [INVALID_REQUEST] Session global changed before patch. Retry.")
|
||||
}
|
||||
#expect(try await state.snapshot() == before)
|
||||
#expect(await recorder.snapshot().count == 1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: [false, true], [
|
||||
[String](), ["session-settings-contract"], ["session-settings-cas-v1"],
|
||||
])
|
||||
func `unsupported settings guards fail before dispatch`(leased: Bool, capabilities: [String]) async throws {
|
||||
try await self.withSessionTransport(capabilities: capabilities) { transport, recorder in
|
||||
let patch = OpenClawChatSessionSettingsPatch(
|
||||
expectedSessionID: "current-session", expectedPermissionMode: .some(nil),
|
||||
permissionMode: .some(.workspace))
|
||||
await #expect(throws: OpenClawChatTransportSendError.notDispatched) {
|
||||
if leased {
|
||||
let lease = try #require(await transport.acquireSessionSettingsRouteLease())
|
||||
_ = try await lease.patchSessionSettings(sessionKey: "global", agentID: nil, patch: patch)
|
||||
} else {
|
||||
_ = try await transport.patchSessionSettings(sessionKey: "global", agentID: nil, patch: patch)
|
||||
}
|
||||
}
|
||||
#expect(await recorder.snapshot().isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: [false, true])
|
||||
func `unguarded settings writes preserve model thinking fast mode and verbosity`(leased: Bool) async throws {
|
||||
try await self.withSessionTransport(capabilities: []) { transport, recorder in
|
||||
let patch = OpenClawChatSessionSettingsPatch(
|
||||
model: .some("openai/gpt-5.6-luna"), thinkingLevel: .some(nil),
|
||||
fastMode: .some(.on), verboseLevel: .some("full"))
|
||||
if leased {
|
||||
let lease = try #require(await transport.acquireSessionSettingsRouteLease())
|
||||
_ = try await lease.patchSessionSettings(sessionKey: "global", agentID: "reviewer", patch: patch)
|
||||
} else {
|
||||
_ = try await transport.patchSessionSettings(sessionKey: "global", agentID: "reviewer", patch: patch)
|
||||
}
|
||||
let frames = await recorder.snapshot()
|
||||
try #require(frames.count == 1)
|
||||
let frame = try #require(JSONSerialization.jsonObject(with: frames[0]) as? [String: Any])
|
||||
#expect(frame["method"] as? String == "sessions.patch")
|
||||
let params = try #require(frame["params"] as? [String: Any])
|
||||
#expect(NSDictionary(dictionary: params).isEqual(to: [
|
||||
"key": "global", "agentId": "reviewer", "model": "openai/gpt-5.6-luna",
|
||||
"thinkingLevel": NSNull(), "fastMode": true, "verboseLevel": "full",
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `scoped settings mutations keep the fixed owner for bare keys`() async throws {
|
||||
|
|
|
|||
|
|
@ -325,6 +325,32 @@ public enum OpenClawChatGatewayRequests {
|
|||
verboseLevel: verboseLevel)
|
||||
}
|
||||
|
||||
public static func patchSessionSettings(
|
||||
sessionKey: String,
|
||||
agentID: String?,
|
||||
patch: OpenClawChatSessionSettingsPatch,
|
||||
supportsSessionSettingsContract: Bool,
|
||||
supportsSessionSettingsCAS: Bool) throws -> OpenClawChatGatewayRequest
|
||||
{
|
||||
guard !patch.requiresSessionSettingsContract || supportsSessionSettingsContract,
|
||||
!patch.requiresSessionSettingsCAS || supportsSessionSettingsCAS
|
||||
else { throw OpenClawChatTransportSendError.notDispatched }
|
||||
return self.patchSessionSettings(
|
||||
sessionKey: sessionKey,
|
||||
agentID: agentID,
|
||||
expectedSessionID: patch.expectedSessionID,
|
||||
expectedPermissionMode: patch.expectedPermissionMode,
|
||||
expectedToolOverrides: patch.expectedToolOverrides,
|
||||
model: patch.model,
|
||||
thinkingLevel: patch.thinkingLevel,
|
||||
fastMode: patch.fastMode,
|
||||
verboseLevel: patch.verboseLevel,
|
||||
permissionMode: patch.permissionMode,
|
||||
toolOverrides: patch.toolOverrides,
|
||||
supportsSessionSettingsContract: supportsSessionSettingsContract,
|
||||
supportsSessionSettingsCAS: supportsSessionSettingsCAS)
|
||||
}
|
||||
|
||||
public static func patchSessionSettings(
|
||||
sessionKey: String,
|
||||
agentID: String?,
|
||||
|
|
|
|||
|
|
@ -296,6 +296,15 @@ public struct OpenClawChatSessionSettingsPatch: Sendable, Equatable {
|
|||
public let permissionMode: OpenClawChatPermissionMode??
|
||||
public let toolOverrides: OpenClawChatSessionToolOverrides??
|
||||
|
||||
public var requiresSessionSettingsContract: Bool {
|
||||
self.expectedSessionID != nil || self.permissionMode != nil || self.toolOverrides != nil
|
||||
}
|
||||
|
||||
public var requiresSessionSettingsCAS: Bool {
|
||||
self.expectedPermissionMode != nil || self.expectedToolOverrides != nil ||
|
||||
self.permissionMode != nil || self.toolOverrides != nil
|
||||
}
|
||||
|
||||
public init(
|
||||
expectedSessionID: String? = nil,
|
||||
expectedPermissionMode: OpenClawChatPermissionMode?? = nil,
|
||||
|
|
|
|||
|
|
@ -311,67 +311,87 @@ struct ChatGatewayRequestTests {
|
|||
#expect(request.params["fastMode"]?.value as? String == "ultrafast")
|
||||
}
|
||||
|
||||
@Test func `settings patch request preserves permission and sparse tool overrides`() throws {
|
||||
@Test func `settings patch builder maps every field including sparse overrides and resets`() throws {
|
||||
let overrides = OpenClawChatSessionToolOverrides(
|
||||
webSearch: false,
|
||||
skills: ["release": false],
|
||||
mcpServers: ["github": true],
|
||||
mcpToolsDeny: ["github": ["create_issue", "delete_issue"]])
|
||||
let request = OpenClawChatGatewayRequests.patchSessionSettings(
|
||||
let request = try OpenClawChatGatewayRequests.patchSessionSettings(
|
||||
sessionKey: "global",
|
||||
agentID: "reviewer",
|
||||
expectedSessionID: "sess-global",
|
||||
expectedPermissionMode: .some(.guarded),
|
||||
expectedToolOverrides: .some(OpenClawChatSessionToolOverrides(webSearch: false)),
|
||||
permissionMode: .some(.workspace),
|
||||
toolOverrides: .some(overrides),
|
||||
patch: .init(
|
||||
expectedSessionID: "sess-global",
|
||||
expectedPermissionMode: .some(.guarded),
|
||||
expectedToolOverrides: .some(.init(webSearch: false)),
|
||||
model: .some("example/model"),
|
||||
thinkingLevel: .some("high"),
|
||||
fastMode: .some(.automatic),
|
||||
verboseLevel: .some("full"),
|
||||
permissionMode: .some(.workspace),
|
||||
toolOverrides: .some(overrides)),
|
||||
supportsSessionSettingsContract: true,
|
||||
supportsSessionSettingsCAS: true)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.sortedKeys]
|
||||
let expected: [String: Any] = [
|
||||
"key": "global", "agentId": "reviewer", "expectedSessionId": "sess-global",
|
||||
"expectedPermissionMode": "guarded", "expectedToolOverrides": ["webSearch": false],
|
||||
"model": "example/model", "thinkingLevel": "high", "fastMode": "auto", "verboseLevel": "full",
|
||||
"permissionMode": "workspace",
|
||||
"toolOverrides": [
|
||||
"webSearch": false, "skills": ["release": false], "mcpServers": ["github": true],
|
||||
"mcpToolsDeny": ["github": ["create_issue", "delete_issue"]],
|
||||
],
|
||||
]
|
||||
#expect(request.method == "sessions.patch")
|
||||
#expect(request.timeoutMs == 15000)
|
||||
#expect(try encoder.encode(request.params) == JSONSerialization.data(
|
||||
withJSONObject: expected, options: .sortedKeys))
|
||||
|
||||
#expect(request.params["expectedSessionId"]?.value as? String == "sess-global")
|
||||
#expect(request.params["expectedPermissionMode"]?.value as? String == "guarded")
|
||||
#expect(request.params["permissionMode"]?.value as? String == "workspace")
|
||||
let encoded = try JSONEncoder().encode(request.params["toolOverrides"])
|
||||
let value = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any])
|
||||
let expectedEncoded = try JSONEncoder().encode(request.params["expectedToolOverrides"])
|
||||
let expectedValue = try #require(
|
||||
JSONSerialization.jsonObject(with: expectedEncoded) as? [String: Any])
|
||||
#expect(expectedValue["webSearch"] as? Bool == false)
|
||||
#expect(value["webSearch"] as? Bool == false)
|
||||
#expect((value["skills"] as? [String: Bool])?["release"] == false)
|
||||
#expect((value["mcpServers"] as? [String: Bool])?["github"] == true)
|
||||
#expect((value["mcpToolsDeny"] as? [String: [String]])?["github"] == [
|
||||
"create_issue",
|
||||
"delete_issue",
|
||||
])
|
||||
|
||||
let reset = OpenClawChatGatewayRequests.patchSessionSettings(
|
||||
let reset = try OpenClawChatGatewayRequests.patchSessionSettings(
|
||||
sessionKey: "global",
|
||||
agentID: "reviewer",
|
||||
expectedPermissionMode: .some(.workspace),
|
||||
expectedToolOverrides: .some(nil),
|
||||
permissionMode: .some(nil),
|
||||
toolOverrides: .some(nil),
|
||||
patch: .init(
|
||||
expectedPermissionMode: .some(nil), expectedToolOverrides: .some(nil),
|
||||
model: .some(nil), thinkingLevel: .some(nil), fastMode: .some(nil), verboseLevel: .some(nil),
|
||||
permissionMode: .some(nil), toolOverrides: .some(nil)),
|
||||
supportsSessionSettingsContract: true,
|
||||
supportsSessionSettingsCAS: true)
|
||||
#expect(reset.params["permissionMode"]?.value is NSNull)
|
||||
#expect(reset.params["expectedPermissionMode"]?.value as? String == "workspace")
|
||||
#expect(reset.params["expectedToolOverrides"]?.value is NSNull)
|
||||
#expect(reset.params["toolOverrides"]?.value is NSNull)
|
||||
let cleared: [String: Any] = [
|
||||
"key": "global", "agentId": "reviewer", "expectedPermissionMode": NSNull(),
|
||||
"expectedToolOverrides": NSNull(), "model": NSNull(), "thinkingLevel": NSNull(),
|
||||
"fastMode": NSNull(), "verboseLevel": NSNull(), "permissionMode": NSNull(), "toolOverrides": NSNull(),
|
||||
]
|
||||
#expect(try encoder.encode(reset.params) == JSONSerialization.data(
|
||||
withJSONObject: cleared, options: .sortedKeys))
|
||||
}
|
||||
|
||||
let releasedGateway = OpenClawChatGatewayRequests.patchSessionSettings(
|
||||
sessionKey: "global",
|
||||
agentID: "reviewer",
|
||||
expectedSessionID: "sess-global",
|
||||
expectedPermissionMode: .some(nil),
|
||||
expectedToolOverrides: .some(nil),
|
||||
permissionMode: .some(.workspace),
|
||||
toolOverrides: .some(overrides))
|
||||
#expect(releasedGateway.params["expectedSessionId"] == nil)
|
||||
#expect(releasedGateway.params["expectedPermissionMode"] == nil)
|
||||
#expect(releasedGateway.params["expectedToolOverrides"] == nil)
|
||||
#expect(releasedGateway.params["permissionMode"] == nil)
|
||||
#expect(releasedGateway.params["toolOverrides"] == nil)
|
||||
@Test(arguments: [false, true], [false, true])
|
||||
func `settings patch builder gates contract and CAS independently`(contract: Bool, cas: Bool) throws {
|
||||
let cases: [(patch: OpenClawChatSessionSettingsPatch, contract: Bool, cas: Bool)] = [
|
||||
(.init(model: .some("example/model")), false, false),
|
||||
(.init(expectedSessionID: "sess-global", model: .some("example/model")), true, false),
|
||||
(.init(expectedPermissionMode: .some(nil)), false, true),
|
||||
(.init(expectedToolOverrides: .some(nil)), false, true),
|
||||
(.init(permissionMode: .some(nil)), true, true),
|
||||
(.init(toolOverrides: .some(nil)), true, true),
|
||||
]
|
||||
for item in cases {
|
||||
if (item.contract && !contract) || (item.cas && !cas) {
|
||||
#expect(throws: OpenClawChatTransportSendError.notDispatched) {
|
||||
try OpenClawChatGatewayRequests.patchSessionSettings(
|
||||
sessionKey: "global", agentID: nil, patch: item.patch,
|
||||
supportsSessionSettingsContract: contract, supportsSessionSettingsCAS: cas)
|
||||
}
|
||||
} else {
|
||||
let request = try OpenClawChatGatewayRequests.patchSessionSettings(
|
||||
sessionKey: "global", agentID: nil, patch: item.patch,
|
||||
supportsSessionSettingsContract: contract, supportsSessionSettingsCAS: cas)
|
||||
#expect(request.params["key"]?.value as? String == "global")
|
||||
#expect(request.params.count == (item.patch.expectedSessionID == nil ? 2 : 3))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `composer catalog requests preserve their owner scope`() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue