fix(gateway): widen native protocol compatibility

This commit is contained in:
Peter Steinberger
2026-05-11 01:37:33 +01:00
parent 966afa85fa
commit 3f815fad12
19 changed files with 152 additions and 17 deletions

View File

@@ -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.

View File

@@ -1,3 +1,4 @@
package ai.openclaw.app.gateway
const val GATEWAY_PROTOCOL_VERSION = 4
const val GATEWAY_MIN_PROTOCOL_VERSION = 3

View File

@@ -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)))

View File

@@ -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<Unit>()
val connectParams = CompletableDeferred<JsonObject>()
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 {

View File

@@ -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]()),

View File

@@ -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(

View File

@@ -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)",

View File

@@ -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),

View File

@@ -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"

View File

@@ -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

View File

@@ -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`) |

View File

@@ -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}"`)

View File

@@ -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<T>(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 {

View File

@@ -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({

View File

@@ -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,

View File

@@ -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,

View File

@@ -490,4 +490,8 @@ export const ProtocolSchemas = {
ShutdownEvent: ShutdownEventSchema,
} satisfies Record<string, TSchema>;
export { MIN_PROBE_PROTOCOL_VERSION, PROTOCOL_VERSION } from "../version.js";
export {
MIN_CLIENT_PROTOCOL_VERSION,
MIN_PROBE_PROTOCOL_VERSION,
PROTOCOL_VERSION,
} from "../version.js";

View File

@@ -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;

View File

@@ -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;