mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 11:44:46 +00:00
fix(gateway): widen native protocol compatibility
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
package ai.openclaw.app.gateway
|
||||
|
||||
const val GATEWAY_PROTOCOL_VERSION = 4
|
||||
const val GATEWAY_MIN_PROTOCOL_VERSION = 3
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]()),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
@@ -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}"`)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user