diff --git a/CHANGELOG.md b/CHANGELOG.md index c9c22fcbaf3..22e60729ded 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Google/Gemini: normalize retired nested Gemini 3 Pro Preview ids while converting manifest catalog rows into emitted provider config, so `google/gemini-3.1-pro-preview` is used for testing instead of `google/gemini-3-pro-preview`. +- Native apps: advertise the Gateway protocol compatibility range so chat and node sessions can connect to v3 gateways after additive v4 client updates. - Gateway: avoid synchronous restart-sentinel state probes during post-attach startup, preventing slow Windows or redirected state directories from blocking channel turns. Fixes #79264. Thanks @liyi58. - Agents/auth: update successful model auth profile status with one locked store write, reducing post-model reply latency from duplicate `auth-profiles.json` saves. Thanks @mcaxtr. - Agents/image: honor explicit `image` tool model overrides even when `agents.defaults.imageModel` is unset, restoring one-off vision calls for configured multimodal providers. Fixes #79341. Thanks @haumanto. diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt index ddf33c60702..4ced3393d8d 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt @@ -1,3 +1,4 @@ package ai.openclaw.app.gateway const val GATEWAY_PROTOCOL_VERSION = 4 +const val GATEWAY_MIN_PROTOCOL_VERSION = 3 diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt index 1cf13a43c3e..a08c820d3e9 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt @@ -687,7 +687,7 @@ class GatewaySession( } return buildJsonObject { - put("minProtocol", JsonPrimitive(GATEWAY_PROTOCOL_VERSION)) + put("minProtocol", JsonPrimitive(GATEWAY_MIN_PROTOCOL_VERSION)) put("maxProtocol", JsonPrimitive(GATEWAY_PROTOCOL_VERSION)) put("client", clientObj) if (options.caps.isNotEmpty()) put("caps", JsonArray(options.caps.map(::JsonPrimitive))) diff --git a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt index 7437adc6e0c..ab4a27f8bd4 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt @@ -79,6 +79,50 @@ private data class InvokeScenarioResult( @RunWith(RobolectricTestRunner::class) @Config(sdk = [34]) class GatewaySessionInvokeTest { + @Test + fun connect_advertisesCompatibleProtocolRange() = + runBlocking { + val json = testJson() + val connected = CompletableDeferred() + val connectParams = CompletableDeferred() + val lastDisconnect = AtomicReference("") + val server = + startGatewayServer(json) { webSocket, id, method, frame -> + when (method) { + "connect" -> { + if (!connectParams.isCompleted) { + connectParams.complete(frame["params"]!!.jsonObject) + } + webSocket.send(connectResponseFrame(id)) + webSocket.close(1000, "done") + } + } + } + + val harness = + createNodeHarness( + connected = connected, + lastDisconnect = lastDisconnect, + ) { GatewaySession.InvokeResult.ok("""{"handled":true}""") } + + try { + connectNodeSession(harness.session, server.port) + awaitConnectedOrThrow(connected, lastDisconnect, server) + + val params = withTimeout(TEST_TIMEOUT_MS) { connectParams.await() } + assertEquals( + GATEWAY_MIN_PROTOCOL_VERSION, + params["minProtocol"]?.jsonPrimitive?.content?.toInt(), + ) + assertEquals( + GATEWAY_PROTOCOL_VERSION, + params["maxProtocol"]?.jsonPrimitive?.content?.toInt(), + ) + } finally { + shutdownHarness(harness, server) + } + } + @Test fun connect_usesBootstrapTokenWhenSharedAndDeviceTokensAreAbsent() = runBlocking { diff --git a/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift b/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift index ec110ead8d9..bb12e570ded 100644 --- a/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift +++ b/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift @@ -257,7 +257,7 @@ actor GatewayWizardClient { ] var params: [String: ProtoAnyCodable] = [ - "minProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION), + "minProtocol": ProtoAnyCodable(GATEWAY_MIN_PROTOCOL_VERSION), "maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION), "client": ProtoAnyCodable(client), "caps": ProtoAnyCodable([String]()), diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift index 57d544e0d11..e51b29f2647 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift @@ -1,9 +1,30 @@ import Foundation import OpenClawKit +import OpenClawProtocol import Testing @testable import OpenClaw struct GatewayChannelConnectTests { + private final class ConnectParamsRecorder: @unchecked Sendable { + private let lock = NSLock() + private var params: [String: Any]? + + func record(_ message: URLSessionWebSocketTask.Message) { + guard let params = GatewayWebSocketTestSupport.connectRequestParams(from: message) else { + return + } + self.lock.lock() + self.params = params + self.lock.unlock() + } + + func snapshot() -> [String: Any]? { + self.lock.lock() + defer { self.lock.unlock() } + return self.params + } + } + private final class TLSFailureSession: WebSocketSessioning, GatewayTLSFailureProviding, @unchecked Sendable { private var failure: GatewayTLSValidationFailure? @@ -87,6 +108,28 @@ struct GatewayChannelConnectTests { #expect(session.snapshotMakeCount() == 1) } + @Test func `connect advertises compatible protocol range`() async throws { + let recorder = ConnectParamsRecorder() + let session = GatewayTestWebSocketSession( + taskFactory: { + GatewayTestWebSocketTask( + sendHook: { _, message, sendIndex in + guard sendIndex == 0 else { return } + recorder.record(message) + }) + }) + let channel = try GatewayChannelActor( + url: #require(URL(string: "ws://example.invalid")), + token: nil, + session: WebSocketSessionBox(session: session)) + + try await channel.connect() + + let params = try #require(recorder.snapshot()) + #expect(params["minProtocol"] as? Int == GATEWAY_MIN_PROTOCOL_VERSION) + #expect(params["maxProtocol"] as? Int == GATEWAY_PROTOCOL_VERSION) + } + @Test func `concurrent connect shares failure`() async throws { let session = self.makeSession(response: .invalid(delayMs: 200)) let channel = try GatewayChannelActor( diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift index 66503dbfe02..576c5a50fca 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift @@ -28,6 +28,14 @@ enum GatewayWebSocketTestSupport { return obj["id"] as? String } + static func connectRequestParams(from message: URLSessionWebSocketTask.Message) -> [String: Any]? { + guard let obj = self.requestFrameObject(from: message) else { return nil } + guard (obj["type"] as? String) == "req", (obj["method"] as? String) == "connect" else { + return nil + } + return obj["params"] as? [String: Any] + } + static func connectOkData(id: String) -> Data { let json = """ { @@ -74,6 +82,7 @@ enum GatewayWebSocketTestSupport { "id": "\(id)", "ok": false, "error": { + "code": "INVALID_REQUEST", "message": "\(message)", "details": { "code": "\(detailCode)", diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift index 419a57e8106..f7d08a1e5a2 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift @@ -130,7 +130,9 @@ private func gatewayErrorDetails(_ error: ErrorShape?) -> [String: ProtoAnyCodab details.merge(nested) { _, nestedValue in nestedValue } } if let error { - details["code"] = ProtoAnyCodable(error.code) + if details["code"] == nil { + details["code"] = ProtoAnyCodable(error.code) + } details["message"] = ProtoAnyCodable(error.message) if let retryable = error.retryable { details["retryable"] = ProtoAnyCodable(retryable) @@ -423,7 +425,7 @@ public actor GatewayChannelActor { client["modelIdentifier"] = ProtoAnyCodable(model) } var params: [String: ProtoAnyCodable] = [ - "minProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION), + "minProtocol": ProtoAnyCodable(GATEWAY_MIN_PROTOCOL_VERSION), "maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION), "client": ProtoAnyCodable(client), "caps": ProtoAnyCodable(options.caps), diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 86f813cc979..82f5f8577f2 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -3,6 +3,7 @@ import Foundation public let GATEWAY_PROTOCOL_VERSION = 4 +public let GATEWAY_MIN_PROTOCOL_VERSION = 3 public enum ErrorCode: String, Codable, Sendable { case notLinked = "NOT_LINKED" diff --git a/docs/concepts/typebox.md b/docs/concepts/typebox.md index df6734bebc5..dea53b4e7db 100644 --- a/docs/concepts/typebox.md +++ b/docs/concepts/typebox.md @@ -94,7 +94,7 @@ Connect (first message): "id": "c1", "method": "connect", "params": { - "minProtocol": 4, + "minProtocol": 3, "maxProtocol": 4, "client": { "id": "openclaw-macos", @@ -266,14 +266,15 @@ The Swift generator emits: - `GatewayFrame` enum with `req`, `res`, `event`, and `unknown` cases - Strongly typed payload structs/enums -- `ErrorCode` values and `GATEWAY_PROTOCOL_VERSION` +- `ErrorCode` values, `GATEWAY_PROTOCOL_VERSION`, and `GATEWAY_MIN_PROTOCOL_VERSION` Unknown frame types are preserved as raw payloads for forward compatibility. ## Versioning + compatibility - `PROTOCOL_VERSION` lives in `src/gateway/protocol/version.ts`. -- Clients send `minProtocol` + `maxProtocol`; the server rejects mismatches. +- Clients send `minProtocol` + `maxProtocol`; the server rejects ranges that + do not include its current protocol. - The Swift models keep unknown frame types to avoid breaking older clients. ## Schema patterns and conventions diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 87cfc618528..24a8411d95a 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -44,7 +44,7 @@ Client → Gateway: "id": "…", "method": "connect", "params": { - "minProtocol": 4, + "minProtocol": 3, "maxProtocol": 4, "client": { "id": "cli", @@ -182,7 +182,7 @@ roles still need scopes under their own role prefix. "id": "…", "method": "connect", "params": { - "minProtocol": 4, + "minProtocol": 3, "maxProtocol": 4, "client": { "id": "ios-node", @@ -631,7 +631,9 @@ terminal summary, and sanitized error text. ## Versioning - `PROTOCOL_VERSION` lives in `src/gateway/protocol/version.ts`. -- Clients send `minProtocol` + `maxProtocol`; the server rejects mismatches. +- Clients send `minProtocol` + `maxProtocol`; the server rejects ranges that + do not include its current protocol. Native clients use a v3 lower bound so + additive v4 clients can still reach v3 gateways. - Schemas + models are generated from TypeBox definitions: - `pnpm protocol:gen` - `pnpm protocol:gen:swift` @@ -645,6 +647,7 @@ stable across protocol v4 and are the expected baseline for third-party clients. | Constant | Default | Source | | ----------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------ | | `PROTOCOL_VERSION` | `4` | `src/gateway/protocol/version.ts` | +| `MIN_CLIENT_PROTOCOL_VERSION` | `3` | `src/gateway/protocol/version.ts` | | Request timeout (per RPC) | `30_000` ms | `src/gateway/client.ts` (`requestTimeoutMs`) | | Preauth / connect-challenge timeout | `15_000` ms | `src/gateway/handshake-timeouts.ts` (config/env can raise the paired server/client budget) | | Initial reconnect backoff | `1_000` ms | `src/gateway/client.ts` (`backoffMs`) | diff --git a/scripts/protocol-gen-swift.ts b/scripts/protocol-gen-swift.ts index 44a30cd7cc5..c35837398a6 100644 --- a/scripts/protocol-gen-swift.ts +++ b/scripts/protocol-gen-swift.ts @@ -1,7 +1,12 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { ErrorCodes, PROTOCOL_VERSION, ProtocolSchemas } from "../src/gateway/protocol/schema.js"; +import { + ErrorCodes, + MIN_CLIENT_PROTOCOL_VERSION, + PROTOCOL_VERSION, + ProtocolSchemas, +} from "../src/gateway/protocol/schema.js"; type JsonSchema = { type?: string | string[]; @@ -26,7 +31,7 @@ const outPaths = [ ), ]; -const header = `// Generated by scripts/protocol-gen-swift.ts — do not edit by hand\n// swiftlint:disable file_length\nimport Foundation\n\npublic let GATEWAY_PROTOCOL_VERSION = ${PROTOCOL_VERSION}\n\npublic enum ErrorCode: String, Codable, Sendable {\n${Object.values( +const header = `// Generated by scripts/protocol-gen-swift.ts — do not edit by hand\n// swiftlint:disable file_length\nimport Foundation\n\npublic let GATEWAY_PROTOCOL_VERSION = ${PROTOCOL_VERSION}\npublic let GATEWAY_MIN_PROTOCOL_VERSION = ${MIN_CLIENT_PROTOCOL_VERSION}\n\npublic enum ErrorCode: String, Codable, Sendable {\n${Object.values( ErrorCodes, ) .map((c) => ` case ${camelCase(c)} = "${c}"`) diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 75dcfb0b3e0..220b67699e6 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -41,7 +41,7 @@ import { resolveLeastPrivilegeOperatorScopesForMethod, type OperatorScope, } from "./method-scopes.js"; -import { PROTOCOL_VERSION } from "./protocol/index.js"; +import { MIN_CLIENT_PROTOCOL_VERSION, PROTOCOL_VERSION } from "./protocol/index.js"; export type { GatewayConnectionDetails }; type CallGatewayBaseOptions = { @@ -654,7 +654,7 @@ async function executeGatewayRequestWithScopes(params: { opts.deviceIdentity === undefined ? resolveDeviceIdentityForGatewayCall({ opts, url, token, password }) : opts.deviceIdentity, - minProtocol: opts.minProtocol ?? PROTOCOL_VERSION, + minProtocol: opts.minProtocol ?? MIN_CLIENT_PROTOCOL_VERSION, maxProtocol: opts.maxProtocol ?? PROTOCOL_VERSION, onHelloOk: async (hello) => { try { diff --git a/src/gateway/client.test.ts b/src/gateway/client.test.ts index 5b181f22b36..8240f9193a8 100644 --- a/src/gateway/client.test.ts +++ b/src/gateway/client.test.ts @@ -2,6 +2,7 @@ import { Buffer } from "node:buffer"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { DeviceIdentity } from "../infra/device-identity.js"; import { captureEnv } from "../test-utils/env.js"; +import { MIN_CLIENT_PROTOCOL_VERSION, PROTOCOL_VERSION } from "./protocol/index.js"; const wsInstances = vi.hoisted((): MockWebSocket[] => []); const clearDeviceAuthTokenMock = vi.hoisted(() => vi.fn()); @@ -719,6 +720,8 @@ describe("GatewayClient connect auth payload", () => { type ParsedConnectRequest = { id?: string; params?: { + minProtocol?: number; + maxProtocol?: number; scopes?: string[]; auth?: { token?: string; @@ -753,6 +756,19 @@ describe("GatewayClient connect auth payload", () => { return parseConnectRequest(ws); } + it("advertises the default protocol compatibility range", () => { + const client = new GatewayClient({ + url: "ws://127.0.0.1:18789", + deviceIdentity: null, + }); + + const { connect } = startClientAndConnect({ client }); + + expect(connect.params?.minProtocol).toBe(MIN_CLIENT_PROTOCOL_VERSION); + expect(connect.params?.maxProtocol).toBe(PROTOCOL_VERSION); + client.stop(); + }); + function emitConnectChallenge(ws: MockWebSocket, nonce = "nonce-1") { ws.emitMessage( JSON.stringify({ diff --git a/src/gateway/client.ts b/src/gateway/client.ts index 8068222a036..9a54dff89ae 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -44,6 +44,7 @@ import { type ConnectParams, type EventFrame, type HelloOk, + MIN_CLIENT_PROTOCOL_VERSION, PROTOCOL_VERSION, type RequestFrame, validateEventFrame, @@ -545,7 +546,7 @@ export class GatewayClient { }; })(); const params: ConnectParams = { - minProtocol: this.opts.minProtocol ?? PROTOCOL_VERSION, + minProtocol: this.opts.minProtocol ?? MIN_CLIENT_PROTOCOL_VERSION, maxProtocol: this.opts.maxProtocol ?? PROTOCOL_VERSION, client: { id: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index f7d2d102ab8..69e4a576bc3 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -264,6 +264,7 @@ import { NodeRenameParamsSchema, type PollParams, PollParamsSchema, + MIN_CLIENT_PROTOCOL_VERSION, MIN_PROBE_PROTOCOL_VERSION, PROTOCOL_VERSION, type PushTestParams, @@ -946,6 +947,7 @@ export { TickEventSchema, ShutdownEventSchema, ProtocolSchemas, + MIN_CLIENT_PROTOCOL_VERSION, MIN_PROBE_PROTOCOL_VERSION, PROTOCOL_VERSION, ErrorCodes, diff --git a/src/gateway/protocol/schema/protocol-schemas.ts b/src/gateway/protocol/schema/protocol-schemas.ts index 43dbef5801c..4d209c82f87 100644 --- a/src/gateway/protocol/schema/protocol-schemas.ts +++ b/src/gateway/protocol/schema/protocol-schemas.ts @@ -490,4 +490,8 @@ export const ProtocolSchemas = { ShutdownEvent: ShutdownEventSchema, } satisfies Record; -export { MIN_PROBE_PROTOCOL_VERSION, PROTOCOL_VERSION } from "../version.js"; +export { + MIN_CLIENT_PROTOCOL_VERSION, + MIN_PROBE_PROTOCOL_VERSION, + PROTOCOL_VERSION, +} from "../version.js"; diff --git a/src/gateway/protocol/version.ts b/src/gateway/protocol/version.ts index 7224f020de2..58e2da81986 100644 --- a/src/gateway/protocol/version.ts +++ b/src/gateway/protocol/version.ts @@ -1,2 +1,3 @@ export const PROTOCOL_VERSION = 4 as const; +export const MIN_CLIENT_PROTOCOL_VERSION = 3 as const; export const MIN_PROBE_PROTOCOL_VERSION = 3 as const; diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index 390ca4d9963..3ac372d0b74 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -17,6 +17,7 @@ import { } from "../gateway/protocol/client-info.js"; import { type HelloOk, + MIN_CLIENT_PROTOCOL_VERSION, PROTOCOL_VERSION, type SessionsListParams, type SessionsPatchResult, @@ -128,7 +129,7 @@ export class GatewayChatClient implements TuiBackend { deviceIdentity: connection.allowInsecureLocalOperatorUi ? null : undefined, caps: [GATEWAY_CLIENT_CAPS.TOOL_EVENTS], instanceId: randomUUID(), - minProtocol: PROTOCOL_VERSION, + minProtocol: MIN_CLIENT_PROTOCOL_VERSION, maxProtocol: PROTOCOL_VERSION, onHelloOk: (hello) => { this.hello = hello;