feat(gateway)!: switch handshake to req:connect (protocol v2)
This commit is contained in:
@@ -151,7 +151,7 @@ actor GatewayChannelActor {
|
|||||||
self.task = self.session.makeWebSocketTask(url: self.url)
|
self.task = self.session.makeWebSocketTask(url: self.url)
|
||||||
self.task?.resume()
|
self.task?.resume()
|
||||||
do {
|
do {
|
||||||
try await self.sendHello()
|
try await self.sendConnect()
|
||||||
} catch {
|
} catch {
|
||||||
let wrapped = self.wrap(error, context: "connect to gateway @ \(self.url.absoluteString)")
|
let wrapped = self.wrap(error, context: "connect to gateway @ \(self.url.absoluteString)")
|
||||||
self.connected = false
|
self.connected = false
|
||||||
@@ -176,40 +176,50 @@ actor GatewayChannelActor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sendHello() async throws {
|
private func sendConnect() async throws {
|
||||||
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
|
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
|
||||||
let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
|
let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
|
||||||
let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier
|
let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier
|
||||||
let clientName = InstanceIdentity.displayName
|
let clientName = InstanceIdentity.displayName
|
||||||
|
|
||||||
let hello = Hello(
|
let reqId = UUID().uuidString
|
||||||
type: "hello",
|
let client: [String: ProtoAnyCodable] = [
|
||||||
minprotocol: GATEWAY_PROTOCOL_VERSION,
|
"name": ProtoAnyCodable(clientName),
|
||||||
maxprotocol: GATEWAY_PROTOCOL_VERSION,
|
"version": ProtoAnyCodable(
|
||||||
client: [
|
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"),
|
||||||
"name": ClawdisProtocol.AnyCodable(clientName),
|
"platform": ProtoAnyCodable(platform),
|
||||||
"version": ClawdisProtocol.AnyCodable(
|
"mode": ProtoAnyCodable("app"),
|
||||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"),
|
"instanceId": ProtoAnyCodable(InstanceIdentity.instanceId),
|
||||||
"platform": ClawdisProtocol.AnyCodable(platform),
|
]
|
||||||
"mode": ClawdisProtocol.AnyCodable("app"),
|
var params: [String: ProtoAnyCodable] = [
|
||||||
"instanceId": ClawdisProtocol.AnyCodable(InstanceIdentity.instanceId),
|
"minProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
|
||||||
],
|
"maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
|
||||||
caps: [],
|
"client": ProtoAnyCodable(client),
|
||||||
auth: self.token.map { ["token": ClawdisProtocol.AnyCodable($0)] },
|
"caps": ProtoAnyCodable([] as [String]),
|
||||||
locale: primaryLocale,
|
"locale": ProtoAnyCodable(primaryLocale),
|
||||||
useragent: ProcessInfo.processInfo.operatingSystemVersionString)
|
"userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
|
||||||
let data = try JSONEncoder().encode(hello)
|
]
|
||||||
|
if let token = self.token {
|
||||||
|
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)])
|
||||||
|
}
|
||||||
|
|
||||||
|
let frame = RequestFrame(
|
||||||
|
type: "req",
|
||||||
|
id: reqId,
|
||||||
|
method: "connect",
|
||||||
|
params: ProtoAnyCodable(params))
|
||||||
|
let data = try self.encoder.encode(frame)
|
||||||
try await self.task?.send(.data(data))
|
try await self.task?.send(.data(data))
|
||||||
guard let msg = try await task?.receive() else {
|
guard let msg = try await task?.receive() else {
|
||||||
throw NSError(
|
throw NSError(
|
||||||
domain: "Gateway",
|
domain: "Gateway",
|
||||||
code: 1,
|
code: 1,
|
||||||
userInfo: [NSLocalizedDescriptionKey: "hello failed (no response)"])
|
userInfo: [NSLocalizedDescriptionKey: "connect failed (no response)"])
|
||||||
}
|
}
|
||||||
try await self.handleHelloResponse(msg)
|
try await self.handleConnectResponse(msg, reqId: reqId)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleHelloResponse(_ msg: URLSessionWebSocketTask.Message) async throws {
|
private func handleConnectResponse(_ msg: URLSessionWebSocketTask.Message, reqId: String) async throws {
|
||||||
let data: Data? = switch msg {
|
let data: Data? = switch msg {
|
||||||
case let .data(d): d
|
case let .data(d): d
|
||||||
case let .string(s): s.data(using: .utf8)
|
case let .string(s): s.data(using: .utf8)
|
||||||
@@ -219,37 +229,46 @@ actor GatewayChannelActor {
|
|||||||
throw NSError(
|
throw NSError(
|
||||||
domain: "Gateway",
|
domain: "Gateway",
|
||||||
code: 1,
|
code: 1,
|
||||||
userInfo: [NSLocalizedDescriptionKey: "hello failed (empty response)"])
|
userInfo: [NSLocalizedDescriptionKey: "connect failed (empty response)"])
|
||||||
}
|
}
|
||||||
let decoder = JSONDecoder()
|
let decoder = JSONDecoder()
|
||||||
if let ok = try? decoder.decode(HelloOk.self, from: data) {
|
guard let frame = try? decoder.decode(GatewayFrame.self, from: data) else {
|
||||||
if let tick = ok.policy["tickIntervalMs"]?.value as? Double {
|
|
||||||
self.tickIntervalMs = tick
|
|
||||||
} else if let tick = ok.policy["tickIntervalMs"]?.value as? Int {
|
|
||||||
self.tickIntervalMs = Double(tick)
|
|
||||||
}
|
|
||||||
self.lastTick = Date()
|
|
||||||
self.tickTask?.cancel()
|
|
||||||
self.tickTask = Task { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
await self.watchTicks()
|
|
||||||
}
|
|
||||||
await self.pushHandler?(.snapshot(ok))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if let err = try? decoder.decode(HelloError.self, from: data) {
|
|
||||||
let reason = err.reason
|
|
||||||
// Log and throw a detailed error so UI can surface token/hello issues.
|
|
||||||
self.logger.error("gateway hello-error: \(reason, privacy: .public)")
|
|
||||||
throw NSError(
|
throw NSError(
|
||||||
domain: "Gateway",
|
domain: "Gateway",
|
||||||
code: 1008,
|
code: 1,
|
||||||
userInfo: [NSLocalizedDescriptionKey: "hello-error: \(reason)"])
|
userInfo: [NSLocalizedDescriptionKey: "connect failed (invalid response)"])
|
||||||
}
|
}
|
||||||
throw NSError(
|
guard case let .res(res) = frame, res.id == reqId else {
|
||||||
domain: "Gateway",
|
throw NSError(
|
||||||
code: 1,
|
domain: "Gateway",
|
||||||
userInfo: [NSLocalizedDescriptionKey: "hello failed (unexpected response)"])
|
code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "connect failed (unexpected response)"])
|
||||||
|
}
|
||||||
|
if res.ok == false {
|
||||||
|
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
|
||||||
|
throw NSError(domain: "Gateway", code: 1008, userInfo: [NSLocalizedDescriptionKey: msg])
|
||||||
|
}
|
||||||
|
guard let payload = res.payload else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "Gateway",
|
||||||
|
code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "connect failed (missing payload)"])
|
||||||
|
}
|
||||||
|
let payloadData = try self.encoder.encode(payload)
|
||||||
|
let ok = try decoder.decode(HelloOk.self, from: payloadData)
|
||||||
|
if let tick = ok.policy["tickIntervalMs"]?.value as? Double {
|
||||||
|
self.tickIntervalMs = tick
|
||||||
|
} else if let tick = ok.policy["tickIntervalMs"]?.value as? Int {
|
||||||
|
self.tickIntervalMs = Double(tick)
|
||||||
|
}
|
||||||
|
self.lastTick = Date()
|
||||||
|
self.tickTask?.cancel()
|
||||||
|
self.tickTask = Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
await self.watchTicks()
|
||||||
|
}
|
||||||
|
await self.pushHandler?(.snapshot(ok))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
private func listen() {
|
private func listen() {
|
||||||
@@ -301,9 +320,6 @@ actor GatewayChannelActor {
|
|||||||
}
|
}
|
||||||
if evt.event == "tick" { self.lastTick = Date() }
|
if evt.event == "tick" { self.lastTick = Date() }
|
||||||
await self.pushHandler?(.event(evt))
|
await self.pushHandler?(.event(evt))
|
||||||
case let .helloOk(ok):
|
|
||||||
self.lastTick = Date()
|
|
||||||
await self.pushHandler?(.snapshot(ok))
|
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,11 +51,11 @@ class GatewaySocket {
|
|||||||
this.ws = ws;
|
this.ws = ws;
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
logStatus(`ws: open -> sending hello (${this.url})`);
|
const id = randomId();
|
||||||
const hello = {
|
logStatus(`ws: open -> sending connect (${this.url})`);
|
||||||
type: "hello",
|
const params = {
|
||||||
minProtocol: 1,
|
minProtocol: 2,
|
||||||
maxProtocol: 1,
|
maxProtocol: 2,
|
||||||
client: {
|
client: {
|
||||||
name: "webchat-ui",
|
name: "webchat-ui",
|
||||||
version: "dev",
|
version: "dev",
|
||||||
@@ -63,8 +63,10 @@ class GatewaySocket {
|
|||||||
mode: "webchat",
|
mode: "webchat",
|
||||||
instanceId: randomId(),
|
instanceId: randomId(),
|
||||||
},
|
},
|
||||||
|
caps: [],
|
||||||
};
|
};
|
||||||
ws.send(JSON.stringify(hello));
|
ws.send(JSON.stringify({ type: "req", id, method: "connect", params }));
|
||||||
|
this.pending.set(id, { resolve, reject, _handshake: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onerror = (err) => {
|
ws.onerror = (err) => {
|
||||||
@@ -91,14 +93,6 @@ class GatewaySocket {
|
|||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (msg.type === "hello-ok") {
|
|
||||||
logStatus(
|
|
||||||
`ws: hello-ok presence=${msg?.snapshot?.presence?.length ?? 0} healthOk=${msg?.snapshot?.health?.ok ?? "n/a"}`,
|
|
||||||
);
|
|
||||||
this.handlers.set("snapshot", msg.snapshot);
|
|
||||||
resolve(msg);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (msg.type === "event") {
|
if (msg.type === "event") {
|
||||||
const cb = this.handlers.get(msg.event);
|
const cb = this.handlers.get(msg.event);
|
||||||
if (cb) cb(msg.payload, msg);
|
if (cb) cb(msg.payload, msg);
|
||||||
@@ -108,8 +102,20 @@ class GatewaySocket {
|
|||||||
const pending = this.pending.get(msg.id);
|
const pending = this.pending.get(msg.id);
|
||||||
if (!pending) return;
|
if (!pending) return;
|
||||||
this.pending.delete(msg.id);
|
this.pending.delete(msg.id);
|
||||||
if (msg.ok) pending.resolve(msg.payload);
|
if (msg.ok) {
|
||||||
else pending.reject(new Error(msg.error?.message || "gateway error"));
|
if (pending._handshake) {
|
||||||
|
const helloOk = msg.payload;
|
||||||
|
logStatus(
|
||||||
|
`ws: hello-ok presence=${helloOk?.snapshot?.presence?.length ?? 0} healthOk=${helloOk?.snapshot?.health?.ok ?? "n/a"}`,
|
||||||
|
);
|
||||||
|
this.handlers.set("snapshot", helloOk.snapshot);
|
||||||
|
pending.resolve(helloOk);
|
||||||
|
} else {
|
||||||
|
pending.resolve(msg.payload);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pending.reject(new Error(msg.error?.message || "gateway error"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -196394,20 +196394,31 @@ var GatewaySocket = class {
|
|||||||
const ws = new WebSocket(this.url);
|
const ws = new WebSocket(this.url);
|
||||||
this.ws = ws;
|
this.ws = ws;
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
logStatus(`ws: open -> sending hello (${this.url})`);
|
const id = randomId();
|
||||||
const hello = {
|
logStatus(`ws: open -> sending connect (${this.url})`);
|
||||||
type: "hello",
|
const params = {
|
||||||
minProtocol: 1,
|
minProtocol: 2,
|
||||||
maxProtocol: 1,
|
maxProtocol: 2,
|
||||||
client: {
|
client: {
|
||||||
name: "webchat-ui",
|
name: "webchat-ui",
|
||||||
version: "dev",
|
version: "dev",
|
||||||
platform: "browser",
|
platform: "browser",
|
||||||
mode: "webchat",
|
mode: "webchat",
|
||||||
instanceId: randomId()
|
instanceId: randomId()
|
||||||
}
|
},
|
||||||
|
caps: []
|
||||||
};
|
};
|
||||||
ws.send(JSON.stringify(hello));
|
ws.send(JSON.stringify({
|
||||||
|
type: "req",
|
||||||
|
id,
|
||||||
|
method: "connect",
|
||||||
|
params
|
||||||
|
}));
|
||||||
|
this.pending.set(id, {
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
_handshake: true
|
||||||
|
});
|
||||||
};
|
};
|
||||||
ws.onerror = (err) => {
|
ws.onerror = (err) => {
|
||||||
logStatus(`ws: error ${formatError(err)}`);
|
logStatus(`ws: error ${formatError(err)}`);
|
||||||
@@ -196428,12 +196439,6 @@ var GatewaySocket = class {
|
|||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (msg.type === "hello-ok") {
|
|
||||||
logStatus(`ws: hello-ok presence=${msg?.snapshot?.presence?.length ?? 0} healthOk=${msg?.snapshot?.health?.ok ?? "n/a"}`);
|
|
||||||
this.handlers.set("snapshot", msg.snapshot);
|
|
||||||
resolve(msg);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (msg.type === "event") {
|
if (msg.type === "event") {
|
||||||
const cb = this.handlers.get(msg.event);
|
const cb = this.handlers.get(msg.event);
|
||||||
if (cb) cb(msg.payload, msg);
|
if (cb) cb(msg.payload, msg);
|
||||||
@@ -196443,8 +196448,18 @@ var GatewaySocket = class {
|
|||||||
const pending = this.pending.get(msg.id);
|
const pending = this.pending.get(msg.id);
|
||||||
if (!pending) return;
|
if (!pending) return;
|
||||||
this.pending.delete(msg.id);
|
this.pending.delete(msg.id);
|
||||||
if (msg.ok) pending.resolve(msg.payload);
|
if (msg.ok) {
|
||||||
else pending.reject(new Error(msg.error?.message || "gateway error"));
|
if (pending._handshake) {
|
||||||
|
const helloOk = msg.payload;
|
||||||
|
logStatus(`ws: hello-ok presence=${helloOk?.snapshot?.presence?.length ?? 0} healthOk=${helloOk?.snapshot?.health?.ok ?? "n/a"}`);
|
||||||
|
this.handlers.set("snapshot", helloOk.snapshot);
|
||||||
|
pending.resolve(helloOk);
|
||||||
|
} else {
|
||||||
|
pending.resolve(msg.payload);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pending.reject(new Error(msg.error?.message || "gateway error"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Generated by scripts/protocol-gen-swift.ts — do not edit by hand
|
// Generated by scripts/protocol-gen-swift.ts — do not edit by hand
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public let GATEWAY_PROTOCOL_VERSION = 1
|
public let GATEWAY_PROTOCOL_VERSION = 2
|
||||||
|
|
||||||
public enum ErrorCode: String, Codable {
|
public enum ErrorCode: String, Codable {
|
||||||
case notLinked = "NOT_LINKED"
|
case notLinked = "NOT_LINKED"
|
||||||
@@ -10,8 +10,7 @@ public enum ErrorCode: String, Codable {
|
|||||||
case unavailable = "UNAVAILABLE"
|
case unavailable = "UNAVAILABLE"
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct Hello: Codable {
|
public struct ConnectParams: Codable {
|
||||||
public let type: String
|
|
||||||
public let minprotocol: Int
|
public let minprotocol: Int
|
||||||
public let maxprotocol: Int
|
public let maxprotocol: Int
|
||||||
public let client: [String: AnyCodable]
|
public let client: [String: AnyCodable]
|
||||||
@@ -21,7 +20,6 @@ public struct Hello: Codable {
|
|||||||
public let useragent: String?
|
public let useragent: String?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
type: String,
|
|
||||||
minprotocol: Int,
|
minprotocol: Int,
|
||||||
maxprotocol: Int,
|
maxprotocol: Int,
|
||||||
client: [String: AnyCodable],
|
client: [String: AnyCodable],
|
||||||
@@ -30,7 +28,6 @@ public struct Hello: Codable {
|
|||||||
locale: String?,
|
locale: String?,
|
||||||
useragent: String?
|
useragent: String?
|
||||||
) {
|
) {
|
||||||
self.type = type
|
|
||||||
self.minprotocol = minprotocol
|
self.minprotocol = minprotocol
|
||||||
self.maxprotocol = maxprotocol
|
self.maxprotocol = maxprotocol
|
||||||
self.client = client
|
self.client = client
|
||||||
@@ -40,7 +37,6 @@ public struct Hello: Codable {
|
|||||||
self.useragent = useragent
|
self.useragent = useragent
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case type
|
|
||||||
case minprotocol = "minProtocol"
|
case minprotocol = "minProtocol"
|
||||||
case maxprotocol = "maxProtocol"
|
case maxprotocol = "maxProtocol"
|
||||||
case client
|
case client
|
||||||
@@ -84,31 +80,6 @@ public struct HelloOk: Codable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct HelloError: Codable {
|
|
||||||
public let type: String
|
|
||||||
public let reason: String
|
|
||||||
public let expectedprotocol: Int?
|
|
||||||
public let minclient: String?
|
|
||||||
|
|
||||||
public init(
|
|
||||||
type: String,
|
|
||||||
reason: String,
|
|
||||||
expectedprotocol: Int?,
|
|
||||||
minclient: String?
|
|
||||||
) {
|
|
||||||
self.type = type
|
|
||||||
self.reason = reason
|
|
||||||
self.expectedprotocol = expectedprotocol
|
|
||||||
self.minclient = minclient
|
|
||||||
}
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case type
|
|
||||||
case reason
|
|
||||||
case expectedprotocol = "expectedProtocol"
|
|
||||||
case minclient = "minClient"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct RequestFrame: Codable {
|
public struct RequestFrame: Codable {
|
||||||
public let type: String
|
public let type: String
|
||||||
public let id: String
|
public let id: String
|
||||||
@@ -537,9 +508,6 @@ public struct ShutdownEvent: Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public enum GatewayFrame: Codable {
|
public enum GatewayFrame: Codable {
|
||||||
case hello(Hello)
|
|
||||||
case helloOk(HelloOk)
|
|
||||||
case helloError(HelloError)
|
|
||||||
case req(RequestFrame)
|
case req(RequestFrame)
|
||||||
case res(ResponseFrame)
|
case res(ResponseFrame)
|
||||||
case event(EventFrame)
|
case event(EventFrame)
|
||||||
@@ -553,12 +521,6 @@ public enum GatewayFrame: Codable {
|
|||||||
let typeContainer = try decoder.container(keyedBy: CodingKeys.self)
|
let typeContainer = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
let type = try typeContainer.decode(String.self, forKey: .type)
|
let type = try typeContainer.decode(String.self, forKey: .type)
|
||||||
switch type {
|
switch type {
|
||||||
case "hello":
|
|
||||||
self = .hello(try Hello(from: decoder))
|
|
||||||
case "hello-ok":
|
|
||||||
self = .helloOk(try HelloOk(from: decoder))
|
|
||||||
case "hello-error":
|
|
||||||
self = .helloError(try HelloError(from: decoder))
|
|
||||||
case "req":
|
case "req":
|
||||||
self = .req(try RequestFrame(from: decoder))
|
self = .req(try RequestFrame(from: decoder))
|
||||||
case "res":
|
case "res":
|
||||||
@@ -574,9 +536,6 @@ public enum GatewayFrame: Codable {
|
|||||||
|
|
||||||
public func encode(to encoder: Encoder) throws {
|
public func encode(to encoder: Encoder) throws {
|
||||||
switch self {
|
switch self {
|
||||||
case .hello(let v): try v.encode(to: encoder)
|
|
||||||
case .helloOk(let v): try v.encode(to: encoder)
|
|
||||||
case .helloError(let v): try v.encode(to: encoder)
|
|
||||||
case .req(let v): try v.encode(to: encoder)
|
case .req(let v): try v.encode(to: encoder)
|
||||||
case .res(let v): try v.encode(to: encoder)
|
case .res(let v): try v.encode(to: encoder)
|
||||||
case .event(let v): try v.encode(to: encoder)
|
case .event(let v): try v.encode(to: encoder)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Testing
|
|||||||
|
|
||||||
@Suite struct GatewayConnectionTests {
|
@Suite struct GatewayConnectionTests {
|
||||||
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
|
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
|
||||||
|
private let connectRequestID = OSAllocatedUnfairLock<String?>(initialState: nil)
|
||||||
private let pendingReceiveHandler =
|
private let pendingReceiveHandler =
|
||||||
OSAllocatedUnfairLock<(@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?>(initialState: nil)
|
OSAllocatedUnfairLock<(@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?>(initialState: nil)
|
||||||
private let cancelCount = OSAllocatedUnfairLock(initialState: 0)
|
private let cancelCount = OSAllocatedUnfairLock(initialState: 0)
|
||||||
@@ -40,8 +41,18 @@ import Testing
|
|||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
// First send is the hello frame. Subsequent sends are request frames.
|
// First send is the connect handshake request. Subsequent sends are request frames.
|
||||||
if currentSendCount == 0 { return }
|
if currentSendCount == 0 {
|
||||||
|
guard case let .data(data) = message else { return }
|
||||||
|
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
(obj["type"] as? String) == "req",
|
||||||
|
(obj["method"] as? String) == "connect",
|
||||||
|
let id = obj["id"] as? String
|
||||||
|
{
|
||||||
|
self.connectRequestID.withLock { $0 = id }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
guard case let .data(data) = message else { return }
|
guard case let .data(data) = message else { return }
|
||||||
guard
|
guard
|
||||||
@@ -61,7 +72,8 @@ import Testing
|
|||||||
if self.helloDelayMs > 0 {
|
if self.helloDelayMs > 0 {
|
||||||
try await Task.sleep(nanoseconds: UInt64(self.helloDelayMs) * 1_000_000)
|
try await Task.sleep(nanoseconds: UInt64(self.helloDelayMs) * 1_000_000)
|
||||||
}
|
}
|
||||||
return .data(Self.helloOkData())
|
let id = self.connectRequestID.withLock { $0 } ?? "connect"
|
||||||
|
return .data(Self.connectOkData(id: id))
|
||||||
}
|
}
|
||||||
|
|
||||||
func receive(
|
func receive(
|
||||||
@@ -75,20 +87,25 @@ import Testing
|
|||||||
handler?(Result<URLSessionWebSocketTask.Message, Error>.success(.data(data)))
|
handler?(Result<URLSessionWebSocketTask.Message, Error>.success(.data(data)))
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func helloOkData() -> Data {
|
private static func connectOkData(id: String) -> Data {
|
||||||
let json = """
|
let json = """
|
||||||
{
|
{
|
||||||
"type": "hello-ok",
|
"type": "res",
|
||||||
"protocol": 1,
|
"id": "\(id)",
|
||||||
"server": { "version": "test", "connId": "test" },
|
"ok": true,
|
||||||
"features": { "methods": [], "events": [] },
|
"payload": {
|
||||||
"snapshot": {
|
"type": "hello-ok",
|
||||||
"presence": [ { "ts": 1 } ],
|
"protocol": 2,
|
||||||
"health": {},
|
"server": { "version": "test", "connId": "test" },
|
||||||
"stateVersion": { "presence": 0, "health": 0 },
|
"features": { "methods": [], "events": [] },
|
||||||
"uptimeMs": 0
|
"snapshot": {
|
||||||
},
|
"presence": [ { "ts": 1 } ],
|
||||||
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
|
"health": {},
|
||||||
|
"stateVersion": { "presence": 0, "health": 0 },
|
||||||
|
"uptimeMs": 0
|
||||||
|
},
|
||||||
|
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
return Data(json.utf8)
|
return Data(json.utf8)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import Testing
|
|||||||
|
|
||||||
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
|
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
|
||||||
private let response: FakeResponse
|
private let response: FakeResponse
|
||||||
|
private let connectRequestID = OSAllocatedUnfairLock<String?>(initialState: nil)
|
||||||
private let pendingReceiveHandler =
|
private let pendingReceiveHandler =
|
||||||
OSAllocatedUnfairLock<(@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?>(
|
OSAllocatedUnfairLock<(@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?>(
|
||||||
initialState: nil)
|
initialState: nil)
|
||||||
@@ -36,13 +37,26 @@ import Testing
|
|||||||
}
|
}
|
||||||
|
|
||||||
func send(_ message: URLSessionWebSocketTask.Message) async throws {
|
func send(_ message: URLSessionWebSocketTask.Message) async throws {
|
||||||
_ = message
|
let data: Data? = switch message {
|
||||||
|
case let .data(d): d
|
||||||
|
case let .string(s): s.data(using: .utf8)
|
||||||
|
@unknown default: nil
|
||||||
|
}
|
||||||
|
guard let data else { return }
|
||||||
|
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
obj["type"] as? String == "req",
|
||||||
|
obj["method"] as? String == "connect",
|
||||||
|
let id = obj["id"] as? String
|
||||||
|
{
|
||||||
|
self.connectRequestID.withLock { $0 = id }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func receive() async throws -> URLSessionWebSocketTask.Message {
|
func receive() async throws -> URLSessionWebSocketTask.Message {
|
||||||
let (delayMs, msg): (Int, URLSessionWebSocketTask.Message) = switch self.response {
|
let (delayMs, msg): (Int, URLSessionWebSocketTask.Message) = switch self.response {
|
||||||
case let .helloOk(delayMs):
|
case let .helloOk(delayMs):
|
||||||
(delayMs, .data(Self.helloOkData()))
|
let id = self.connectRequestID.withLock { $0 } ?? "connect"
|
||||||
|
(delayMs, .data(Self.connectOkData(id: id)))
|
||||||
case let .invalid(delayMs):
|
case let .invalid(delayMs):
|
||||||
(delayMs, .string("not json"))
|
(delayMs, .string("not json"))
|
||||||
}
|
}
|
||||||
@@ -58,20 +72,25 @@ import Testing
|
|||||||
self.pendingReceiveHandler.withLock { $0 = completionHandler }
|
self.pendingReceiveHandler.withLock { $0 = completionHandler }
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func helloOkData() -> Data {
|
private static func connectOkData(id: String) -> Data {
|
||||||
let json = """
|
let json = """
|
||||||
{
|
{
|
||||||
"type": "hello-ok",
|
"type": "res",
|
||||||
"protocol": 1,
|
"id": "\(id)",
|
||||||
"server": { "version": "test", "connId": "test" },
|
"ok": true,
|
||||||
"features": { "methods": [], "events": [] },
|
"payload": {
|
||||||
"snapshot": {
|
"type": "hello-ok",
|
||||||
"presence": [ { "ts": 1 } ],
|
"protocol": 2,
|
||||||
"health": {},
|
"server": { "version": "test", "connId": "test" },
|
||||||
"stateVersion": { "presence": 0, "health": 0 },
|
"features": { "methods": [], "events": [] },
|
||||||
"uptimeMs": 0
|
"snapshot": {
|
||||||
},
|
"presence": [ { "ts": 1 } ],
|
||||||
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
|
"health": {},
|
||||||
|
"stateVersion": { "presence": 0, "health": 0 },
|
||||||
|
"uptimeMs": 0
|
||||||
|
},
|
||||||
|
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
return Data(json.utf8)
|
return Data(json.utf8)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Testing
|
|||||||
@Suite struct GatewayChannelRequestTests {
|
@Suite struct GatewayChannelRequestTests {
|
||||||
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
|
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
|
||||||
private let requestSendDelayMs: Int
|
private let requestSendDelayMs: Int
|
||||||
|
private let connectRequestID = OSAllocatedUnfairLock<String?>(initialState: nil)
|
||||||
private let pendingReceiveHandler =
|
private let pendingReceiveHandler =
|
||||||
OSAllocatedUnfairLock<(@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?>(initialState: nil)
|
OSAllocatedUnfairLock<(@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?>(initialState: nil)
|
||||||
private let sendCount = OSAllocatedUnfairLock(initialState: 0)
|
private let sendCount = OSAllocatedUnfairLock(initialState: 0)
|
||||||
@@ -37,7 +38,22 @@ import Testing
|
|||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
// First send is the hello frame. Second send is the request frame.
|
// First send is the connect handshake. Second send is the request frame.
|
||||||
|
if currentSendCount == 0 {
|
||||||
|
let data: Data? = switch message {
|
||||||
|
case let .data(d): d
|
||||||
|
case let .string(s): s.data(using: .utf8)
|
||||||
|
@unknown default: nil
|
||||||
|
}
|
||||||
|
guard let data else { return }
|
||||||
|
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
obj["type"] as? String == "req",
|
||||||
|
obj["method"] as? String == "connect",
|
||||||
|
let id = obj["id"] as? String
|
||||||
|
{
|
||||||
|
self.connectRequestID.withLock { $0 = id }
|
||||||
|
}
|
||||||
|
}
|
||||||
if currentSendCount == 1 {
|
if currentSendCount == 1 {
|
||||||
try await Task.sleep(nanoseconds: UInt64(self.requestSendDelayMs) * 1_000_000)
|
try await Task.sleep(nanoseconds: UInt64(self.requestSendDelayMs) * 1_000_000)
|
||||||
throw URLError(.cannotConnectToHost)
|
throw URLError(.cannotConnectToHost)
|
||||||
@@ -45,7 +61,8 @@ import Testing
|
|||||||
}
|
}
|
||||||
|
|
||||||
func receive() async throws -> URLSessionWebSocketTask.Message {
|
func receive() async throws -> URLSessionWebSocketTask.Message {
|
||||||
.data(Self.helloOkData())
|
let id = self.connectRequestID.withLock { $0 } ?? "connect"
|
||||||
|
return .data(Self.connectOkData(id: id))
|
||||||
}
|
}
|
||||||
|
|
||||||
func receive(
|
func receive(
|
||||||
@@ -54,20 +71,25 @@ import Testing
|
|||||||
self.pendingReceiveHandler.withLock { $0 = completionHandler }
|
self.pendingReceiveHandler.withLock { $0 = completionHandler }
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func helloOkData() -> Data {
|
private static func connectOkData(id: String) -> Data {
|
||||||
let json = """
|
let json = """
|
||||||
{
|
{
|
||||||
"type": "hello-ok",
|
"type": "res",
|
||||||
"protocol": 1,
|
"id": "\(id)",
|
||||||
"server": { "version": "test", "connId": "test" },
|
"ok": true,
|
||||||
"features": { "methods": [], "events": [] },
|
"payload": {
|
||||||
"snapshot": {
|
"type": "hello-ok",
|
||||||
"presence": [ { "ts": 1 } ],
|
"protocol": 2,
|
||||||
"health": {},
|
"server": { "version": "test", "connId": "test" },
|
||||||
"stateVersion": { "presence": 0, "health": 0 },
|
"features": { "methods": [], "events": [] },
|
||||||
"uptimeMs": 0
|
"snapshot": {
|
||||||
},
|
"presence": [ { "ts": 1 } ],
|
||||||
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
|
"health": {},
|
||||||
|
"stateVersion": { "presence": 0, "health": 0 },
|
||||||
|
"uptimeMs": 0
|
||||||
|
},
|
||||||
|
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
return Data(json.utf8)
|
return Data(json.utf8)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Testing
|
|||||||
|
|
||||||
@Suite struct GatewayChannelShutdownTests {
|
@Suite struct GatewayChannelShutdownTests {
|
||||||
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
|
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
|
||||||
|
private let connectRequestID = OSAllocatedUnfairLock<String?>(initialState: nil)
|
||||||
private let pendingReceiveHandler =
|
private let pendingReceiveHandler =
|
||||||
OSAllocatedUnfairLock<(@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?>(initialState: nil)
|
OSAllocatedUnfairLock<(@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?>(initialState: nil)
|
||||||
private let cancelCount = OSAllocatedUnfairLock(initialState: 0)
|
private let cancelCount = OSAllocatedUnfairLock(initialState: 0)
|
||||||
@@ -29,11 +30,24 @@ import Testing
|
|||||||
}
|
}
|
||||||
|
|
||||||
func send(_ message: URLSessionWebSocketTask.Message) async throws {
|
func send(_ message: URLSessionWebSocketTask.Message) async throws {
|
||||||
_ = message
|
let data: Data? = switch message {
|
||||||
|
case let .data(d): d
|
||||||
|
case let .string(s): s.data(using: .utf8)
|
||||||
|
@unknown default: nil
|
||||||
|
}
|
||||||
|
guard let data else { return }
|
||||||
|
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
obj["type"] as? String == "req",
|
||||||
|
obj["method"] as? String == "connect",
|
||||||
|
let id = obj["id"] as? String
|
||||||
|
{
|
||||||
|
self.connectRequestID.withLock { $0 = id }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func receive() async throws -> URLSessionWebSocketTask.Message {
|
func receive() async throws -> URLSessionWebSocketTask.Message {
|
||||||
.data(Self.helloOkData())
|
let id = self.connectRequestID.withLock { $0 } ?? "connect"
|
||||||
|
return .data(Self.connectOkData(id: id))
|
||||||
}
|
}
|
||||||
|
|
||||||
func receive(
|
func receive(
|
||||||
@@ -47,20 +61,25 @@ import Testing
|
|||||||
handler?(Result<URLSessionWebSocketTask.Message, Error>.failure(URLError(.networkConnectionLost)))
|
handler?(Result<URLSessionWebSocketTask.Message, Error>.failure(URLError(.networkConnectionLost)))
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func helloOkData() -> Data {
|
private static func connectOkData(id: String) -> Data {
|
||||||
let json = """
|
let json = """
|
||||||
{
|
{
|
||||||
"type": "hello-ok",
|
"type": "res",
|
||||||
"protocol": 1,
|
"id": "\(id)",
|
||||||
"server": { "version": "test", "connId": "test" },
|
"ok": true,
|
||||||
"features": { "methods": [], "events": [] },
|
"payload": {
|
||||||
"snapshot": {
|
"type": "hello-ok",
|
||||||
"presence": [ { "ts": 1 } ],
|
"protocol": 2,
|
||||||
"health": {},
|
"server": { "version": "test", "connId": "test" },
|
||||||
"stateVersion": { "presence": 0, "health": 0 },
|
"features": { "methods": [], "events": [] },
|
||||||
"uptimeMs": 0
|
"snapshot": {
|
||||||
},
|
"presence": [ { "ts": 1 } ],
|
||||||
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
|
"health": {},
|
||||||
|
"stateVersion": { "presence": 0, "health": 0 },
|
||||||
|
"uptimeMs": 0
|
||||||
|
},
|
||||||
|
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
return Data(json.utf8)
|
return Data(json.utf8)
|
||||||
@@ -106,4 +125,3 @@ import Testing
|
|||||||
#expect(session.snapshotMakeCount() == 1)
|
#expect(session.snapshotMakeCount() == 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
334
dist/protocol.schema.json
vendored
334
dist/protocol.schema.json
vendored
@@ -4,15 +4,6 @@
|
|||||||
"title": "Clawdis Gateway Protocol",
|
"title": "Clawdis Gateway Protocol",
|
||||||
"description": "Handshake, request/response, and event frames for the Gateway WebSocket.",
|
"description": "Handshake, request/response, and event frames for the Gateway WebSocket.",
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
{
|
|
||||||
"$ref": "#/definitions/Hello"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"$ref": "#/definitions/HelloOk"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"$ref": "#/definitions/HelloError"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"$ref": "#/definitions/RequestFrame"
|
"$ref": "#/definitions/RequestFrame"
|
||||||
},
|
},
|
||||||
@@ -26,23 +17,16 @@
|
|||||||
"discriminator": {
|
"discriminator": {
|
||||||
"propertyName": "type",
|
"propertyName": "type",
|
||||||
"mapping": {
|
"mapping": {
|
||||||
"hello": "#/definitions/Hello",
|
|
||||||
"hello-ok": "#/definitions/HelloOk",
|
|
||||||
"hello-error": "#/definitions/HelloError",
|
|
||||||
"req": "#/definitions/RequestFrame",
|
"req": "#/definitions/RequestFrame",
|
||||||
"res": "#/definitions/ResponseFrame",
|
"res": "#/definitions/ResponseFrame",
|
||||||
"event": "#/definitions/EventFrame"
|
"event": "#/definitions/EventFrame"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"definitions": {
|
"definitions": {
|
||||||
"Hello": {
|
"ConnectParams": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"type": {
|
|
||||||
"const": "hello",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"minProtocol": {
|
"minProtocol": {
|
||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
@@ -108,7 +92,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"type",
|
|
||||||
"minProtocol",
|
"minProtocol",
|
||||||
"maxProtocol",
|
"maxProtocol",
|
||||||
"client"
|
"client"
|
||||||
@@ -298,32 +281,6 @@
|
|||||||
"policy"
|
"policy"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"HelloError": {
|
|
||||||
"additionalProperties": false,
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"type": {
|
|
||||||
"const": "hello-error",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"reason": {
|
|
||||||
"minLength": 1,
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"expectedProtocol": {
|
|
||||||
"minimum": 1,
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"minClient": {
|
|
||||||
"minLength": 1,
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"type",
|
|
||||||
"reason"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"RequestFrame": {
|
"RequestFrame": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -441,295 +398,6 @@
|
|||||||
"GatewayFrame": {
|
"GatewayFrame": {
|
||||||
"discriminator": "type",
|
"discriminator": "type",
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
|
||||||
"additionalProperties": false,
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"type": {
|
|
||||||
"const": "hello",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"minProtocol": {
|
|
||||||
"minimum": 1,
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"maxProtocol": {
|
|
||||||
"minimum": 1,
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"client": {
|
|
||||||
"additionalProperties": false,
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"name": {
|
|
||||||
"minLength": 1,
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"version": {
|
|
||||||
"minLength": 1,
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"platform": {
|
|
||||||
"minLength": 1,
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"mode": {
|
|
||||||
"minLength": 1,
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"instanceId": {
|
|
||||||
"minLength": 1,
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"name",
|
|
||||||
"version",
|
|
||||||
"platform",
|
|
||||||
"mode"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"caps": {
|
|
||||||
"default": [],
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"minLength": 1,
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"auth": {
|
|
||||||
"additionalProperties": false,
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"token": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"locale": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"userAgent": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"type",
|
|
||||||
"minProtocol",
|
|
||||||
"maxProtocol",
|
|
||||||
"client"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"additionalProperties": false,
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"type": {
|
|
||||||
"const": "hello-ok",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"protocol": {
|
|
||||||
"minimum": 1,
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"server": {
|
|
||||||
"additionalProperties": false,
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"version": {
|
|
||||||
"minLength": 1,
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"commit": {
|
|
||||||
"minLength": 1,
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"host": {
|
|
||||||
"minLength": 1,
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"connId": {
|
|
||||||
"minLength": 1,
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"version",
|
|
||||||
"connId"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"features": {
|
|
||||||
"additionalProperties": false,
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"methods": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"minLength": 1,
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"events": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"minLength": 1,
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"methods",
|
|
||||||
"events"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"snapshot": {
|
|
||||||
"additionalProperties": false,
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"presence": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"additionalProperties": false,
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"host": {
|
|
||||||
"minLength": 1,
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"ip": {
|
|
||||||
"minLength": 1,
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"version": {
|
|
||||||
"minLength": 1,
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"mode": {
|
|
||||||
"minLength": 1,
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"lastInputSeconds": {
|
|
||||||
"minimum": 0,
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"reason": {
|
|
||||||
"minLength": 1,
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"tags": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"minLength": 1,
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"text": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"ts": {
|
|
||||||
"minimum": 0,
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"instanceId": {
|
|
||||||
"minLength": 1,
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"ts"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"health": {},
|
|
||||||
"stateVersion": {
|
|
||||||
"additionalProperties": false,
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"presence": {
|
|
||||||
"minimum": 0,
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"health": {
|
|
||||||
"minimum": 0,
|
|
||||||
"type": "integer"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"presence",
|
|
||||||
"health"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"uptimeMs": {
|
|
||||||
"minimum": 0,
|
|
||||||
"type": "integer"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"presence",
|
|
||||||
"health",
|
|
||||||
"stateVersion",
|
|
||||||
"uptimeMs"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"policy": {
|
|
||||||
"additionalProperties": false,
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"maxPayload": {
|
|
||||||
"minimum": 1,
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"maxBufferedBytes": {
|
|
||||||
"minimum": 1,
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"tickIntervalMs": {
|
|
||||||
"minimum": 1,
|
|
||||||
"type": "integer"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"maxPayload",
|
|
||||||
"maxBufferedBytes",
|
|
||||||
"tickIntervalMs"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"type",
|
|
||||||
"protocol",
|
|
||||||
"server",
|
|
||||||
"features",
|
|
||||||
"snapshot",
|
|
||||||
"policy"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"additionalProperties": false,
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"type": {
|
|
||||||
"const": "hello-error",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"reason": {
|
|
||||||
"minLength": 1,
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"expectedProtocol": {
|
|
||||||
"minimum": 1,
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"minClient": {
|
|
||||||
"minLength": 1,
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"type",
|
|
||||||
"reason"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Last updated: 2025-12-09
|
|||||||
- **Gateway (daemon)**
|
- **Gateway (daemon)**
|
||||||
- Maintains Baileys/Telegram connections.
|
- Maintains Baileys/Telegram connections.
|
||||||
- Exposes a typed WS API (req/resp + server push events).
|
- Exposes a typed WS API (req/resp + server push events).
|
||||||
- Validates every inbound frame against JSON Schema; rejects anything before a mandatory `hello`.
|
- Validates every inbound frame against JSON Schema; rejects anything before a mandatory `connect`.
|
||||||
- **Clients (mac app / CLI / web admin)**
|
- **Clients (mac app / CLI / web admin)**
|
||||||
- One WS connection per client.
|
- One WS connection per client.
|
||||||
- Send requests (`health`, `status`, `send`, `agent`, `system-presence`, toggles) and subscribe to events (`tick`, `agent`, `presence`, `shutdown`).
|
- Send requests (`health`, `status`, `send`, `agent`, `system-presence`, toggles) and subscribe to events (`tick`, `agent`, `presence`, `shutdown`).
|
||||||
@@ -31,9 +31,9 @@ Last updated: 2025-12-09
|
|||||||
```
|
```
|
||||||
Client Gateway
|
Client Gateway
|
||||||
| |
|
| |
|
||||||
|------- hello ----------->|
|
|---- req:connect -------->|
|
||||||
|<------ hello-ok ---------| (or hello-error + close)
|
|<------ res (ok) ---------| (or res error + close)
|
||||||
| (hello-ok carries snapshot: presence + health)
|
| (payload=hello-ok carries snapshot: presence + health)
|
||||||
| |
|
| |
|
||||||
|<------ event:presence ---| (deltas)
|
|<------ event:presence ---| (deltas)
|
||||||
|<------ event:tick -------| (keepalive/no-op)
|
|<------ event:tick -------| (keepalive/no-op)
|
||||||
@@ -46,13 +46,12 @@ Client Gateway
|
|||||||
```
|
```
|
||||||
## Wire protocol (summary)
|
## Wire protocol (summary)
|
||||||
- Transport: WebSocket, text frames with JSON payloads.
|
- Transport: WebSocket, text frames with JSON payloads.
|
||||||
- First frame must be `hello {type:"hello", minProtocol, maxProtocol, client:{name,version,platform,mode,instanceId}, caps, auth?, locale?, userAgent? }`.
|
- First frame must be `req {type:"req", id, method:"connect", params:{minProtocol, maxProtocol, client:{name,version,platform,mode,instanceId}, caps, auth?, locale?, userAgent? } }`.
|
||||||
- Server replies `hello-ok {type:"hello-ok", protocol:<chosen>, server:{version,commit,host,connId}, features:{methods,events}, snapshot:{presence:[...], health:{...}, stateVersion:{presence,health}, uptimeMs}, policy:{maxPayload,maxBufferedBytes,tickIntervalMs} }`
|
- Server replies `res {type:"res", id, ok:true, payload: hello-ok }` or `ok:false` then closes.
|
||||||
or `hello-error {type:"hello-error", reason, expectedProtocol, minClient }` then closes.
|
|
||||||
- After handshake:
|
- After handshake:
|
||||||
- Requests: `{type:"req", id, method, params}` → `{type:"res", id, ok, payload|error}`
|
- Requests: `{type:"req", id, method, params}` → `{type:"res", id, ok, payload|error}`
|
||||||
- Events: `{type:"event", event:"agent"|"presence"|"tick"|"shutdown", payload, seq?, stateVersion?}`
|
- Events: `{type:"event", event:"agent"|"presence"|"tick"|"shutdown", payload, seq?, stateVersion?}`
|
||||||
- If `CLAWDIS_GATEWAY_TOKEN` (or `--token`) is set, `hello.auth.token` must match; otherwise the socket closes with policy violation.
|
- If `CLAWDIS_GATEWAY_TOKEN` (or `--token`) is set, `connect.params.auth.token` must match; otherwise the socket closes with policy violation.
|
||||||
- Presence payload is structured, not free text: `{host, ip, version, mode, lastInputSeconds?, ts, reason?, tags?[], instanceId? }`.
|
- Presence payload is structured, not free text: `{host, ip, version, mode, lastInputSeconds?, ts, reason?, tags?[], instanceId? }`.
|
||||||
- Agent runs are acked `{runId,status:"accepted"}` then complete with a final res `{runId,status,summary}`; streamed output arrives as `event:"agent"`.
|
- Agent runs are acked `{runId,status:"accepted"}` then complete with a final res `{runId,status,summary}`; streamed output arrives as `event:"agent"`.
|
||||||
- Protocol versions are bumped on breaking changes; clients must match `minClient`; Gateway chooses within client’s min/max.
|
- Protocol versions are bumped on breaking changes; clients must match `minClient`; Gateway chooses within client’s min/max.
|
||||||
@@ -69,13 +68,14 @@ Client Gateway
|
|||||||
|
|
||||||
## Invariants
|
## Invariants
|
||||||
- Exactly one Gateway controls a single Baileys session per host. No fallbacks to ad-hoc direct Baileys sends.
|
- Exactly one Gateway controls a single Baileys session per host. No fallbacks to ad-hoc direct Baileys sends.
|
||||||
- Handshake is mandatory; any non-JSON or non-hello first frame is a hard close.
|
- Handshake is mandatory; any non-JSON or non-connect first frame is a hard close.
|
||||||
- All methods and events are versioned; new fields are additive; breaking changes increment `protocol`.
|
- All methods and events are versioned; new fields are additive; breaking changes increment `protocol`.
|
||||||
- No event replay: on seq gaps, clients must refresh (`health` + `system-presence`) and continue; presence is bounded via TTL/max entries.
|
- No event replay: on seq gaps, clients must refresh (`health` + `system-presence`) and continue; presence is bounded via TTL/max entries.
|
||||||
|
|
||||||
## Remote access
|
## Remote access
|
||||||
- Preferred: Tailscale or VPN; alternate: SSH tunnel `ssh -N -L 18789:127.0.0.1:18789 user@host`.
|
- Preferred: Tailscale or VPN; alternate: SSH tunnel `ssh -N -L 18789:127.0.0.1:18789 user@host`.
|
||||||
- Same protocol over the tunnel; same handshake. If a shared token is configured, clients must send it in `hello.auth.token` even over the tunnel.
|
- Same protocol over the tunnel; same handshake. If a shared token is configured, clients must send it in `connect.params.auth.token` even over the tunnel.
|
||||||
|
- Same protocol over the tunnel; same handshake. If a shared token is configured, clients must send it in `connect.params.auth.token` even over the tunnel.
|
||||||
|
|
||||||
## Operations snapshot
|
## Operations snapshot
|
||||||
- Start: `clawdis gateway` (foreground, logs to stdout).
|
- Start: `clawdis gateway` (foreground, logs to stdout).
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ pnpm clawdis gateway --force
|
|||||||
- Pass `--verbose` to mirror debug logging (handshakes, req/res, events) from the log file into stdio when troubleshooting.
|
- Pass `--verbose` to mirror debug logging (handshakes, req/res, events) from the log file into stdio when troubleshooting.
|
||||||
- `--force` uses `lsof` to find listeners on the chosen port, sends SIGTERM, logs what it killed, then starts the gateway (fails fast if `lsof` is missing).
|
- `--force` uses `lsof` to find listeners on the chosen port, sends SIGTERM, logs what it killed, then starts the gateway (fails fast if `lsof` is missing).
|
||||||
- If you run under a supervisor (launchd/systemd/mac app child-process mode), a stop/restart typically sends **SIGTERM**; older builds may surface this as `pnpm` `ELIFECYCLE` exit code **143** (SIGTERM), which is a normal shutdown, not a crash.
|
- If you run under a supervisor (launchd/systemd/mac app child-process mode), a stop/restart typically sends **SIGTERM**; older builds may surface this as `pnpm` `ELIFECYCLE` exit code **143** (SIGTERM), which is a normal shutdown, not a crash.
|
||||||
- Optional shared secret: pass `--token <value>` or set `CLAWDIS_GATEWAY_TOKEN` to require clients to send `hello.auth.token`.
|
- Optional shared secret: pass `--token <value>` or set `CLAWDIS_GATEWAY_TOKEN` to require clients to send `connect.params.auth.token`.
|
||||||
|
|
||||||
## Remote access
|
## Remote access
|
||||||
- Tailscale/VPN preferred; otherwise SSH tunnel:
|
- Tailscale/VPN preferred; otherwise SSH tunnel:
|
||||||
@@ -33,11 +33,11 @@ pnpm clawdis gateway --force
|
|||||||
ssh -N -L 18789:127.0.0.1:18789 user@host
|
ssh -N -L 18789:127.0.0.1:18789 user@host
|
||||||
```
|
```
|
||||||
- Clients then connect to `ws://127.0.0.1:18789` through the tunnel.
|
- Clients then connect to `ws://127.0.0.1:18789` through the tunnel.
|
||||||
- If a token is configured, clients must include it in `hello.auth.token` even over the tunnel.
|
- If a token is configured, clients must include it in `connect.params.auth.token` even over the tunnel.
|
||||||
|
|
||||||
## Protocol (operator view)
|
## Protocol (operator view)
|
||||||
- Mandatory first frame from client: `hello {type:"hello", minProtocol, maxProtocol, client:{name,version,platform,mode,instanceId}, caps, auth?, locale?, userAgent? }`.
|
- Mandatory first frame from client: `req {type:"req", id, method:"connect", params:{minProtocol,maxProtocol,client:{name,version,platform,mode,instanceId}, caps, auth?, locale?, userAgent? } }`.
|
||||||
- Gateway replies `hello-ok {type:"hello-ok", protocol:<chosen>, server:{version,commit,host,connId}, features:{methods,events}, snapshot:{presence[], health, stateVersion, uptimeMs}, policy:{maxPayload,maxBufferedBytes,tickIntervalMs} }` or `hello-error`.
|
- Gateway replies `res {type:"res", id, ok:true, payload:hello-ok }` (or `ok:false` with an error, then closes).
|
||||||
- After handshake:
|
- After handshake:
|
||||||
- Requests: `{type:"req", id, method, params}` → `{type:"res", id, ok, payload|error}`
|
- Requests: `{type:"req", id, method, params}` → `{type:"res", id, ok, payload|error}`
|
||||||
- Events: `{type:"event", event, payload, seq?, stateVersion?}`
|
- Events: `{type:"event", event, payload, seq?, stateVersion?}`
|
||||||
@@ -63,13 +63,13 @@ See also: `docs/presence.md` for how presence is produced/deduped and why `insta
|
|||||||
## WebChat integration
|
## WebChat integration
|
||||||
- WebChat serves static assets locally (default port 18788, configurable).
|
- WebChat serves static assets locally (default port 18788, configurable).
|
||||||
- The WebChat backend keeps a single WS connection to the Gateway for control/data; all sends and agent runs flow through that connection.
|
- The WebChat backend keeps a single WS connection to the Gateway for control/data; all sends and agent runs flow through that connection.
|
||||||
- Remote use goes through the same SSH/Tailscale tunnel; if a gateway token is configured, WebChat must include it during hello.
|
- Remote use goes through the same SSH/Tailscale tunnel; if a gateway token is configured, WebChat must include it during connect.
|
||||||
- macOS app also connects via this WS (one socket); it hydrates presence from the initial snapshot and listens for `presence` events to update the UI.
|
- macOS app also connects via this WS (one socket); it hydrates presence from the initial snapshot and listens for `presence` events to update the UI.
|
||||||
|
|
||||||
## Typing and validation
|
## Typing and validation
|
||||||
- Server validates every inbound frame with AJV against JSON Schema emitted from the protocol definitions.
|
- Server validates every inbound frame with AJV against JSON Schema emitted from the protocol definitions.
|
||||||
- Clients (TS/Swift) consume generated types (TS directly; Swift via quicktype from the JSON Schema).
|
- Clients (TS/Swift) consume generated types (TS directly; Swift via the repo’s generator).
|
||||||
- Types live in `src/gateway/protocol/*.ts`; regenerate schemas/models with `pnpm protocol:gen` (writes `dist/protocol.schema.json` and `apps/macos/Sources/ClawdisProtocol/Protocol.swift`).
|
- Types live in `src/gateway/protocol/*.ts`; regenerate schemas/models with `pnpm protocol:gen` (writes `dist/protocol.schema.json`) and `pnpm protocol:gen:swift` (writes `apps/macos/Sources/ClawdisProtocol/GatewayModels.swift`).
|
||||||
|
|
||||||
## Connection snapshot
|
## Connection snapshot
|
||||||
- `hello-ok` includes a `snapshot` with `presence`, `health`, `stateVersion`, and `uptimeMs` plus `policy {maxPayload,maxBufferedBytes,tickIntervalMs}` so clients can render immediately without extra requests.
|
- `hello-ok` includes a `snapshot` with `presence`, `health`, `stateVersion`, and `uptimeMs` plus `policy {maxPayload,maxBufferedBytes,tickIntervalMs}` so clients can render immediately without extra requests.
|
||||||
@@ -119,14 +119,14 @@ WantedBy=multi-user.target
|
|||||||
Enable with `systemctl enable --now clawdis-gateway.service`.
|
Enable with `systemctl enable --now clawdis-gateway.service`.
|
||||||
|
|
||||||
## Operational checks
|
## Operational checks
|
||||||
- Liveness: open WS and send `hello` → expect `hello-ok` (with snapshot).
|
- Liveness: open WS and send `req:connect` → expect `res` with `payload.type="hello-ok"` (with snapshot).
|
||||||
- Readiness: call `health` → expect `ok: true` and `web.linked=true`.
|
- Readiness: call `health` → expect `ok: true` and `web.linked=true`.
|
||||||
- Debug: subscribe to `tick` and `presence` events; ensure `status` shows linked/auth age; presence entries show Gateway host and connected clients.
|
- Debug: subscribe to `tick` and `presence` events; ensure `status` shows linked/auth age; presence entries show Gateway host and connected clients.
|
||||||
|
|
||||||
## Safety guarantees
|
## Safety guarantees
|
||||||
- Only one Gateway per host; all sends/agent calls must go through it.
|
- Only one Gateway per host; all sends/agent calls must go through it.
|
||||||
- No fallback to direct Baileys connections; if the Gateway is down, sends fail fast.
|
- No fallback to direct Baileys connections; if the Gateway is down, sends fail fast.
|
||||||
- Non-hello first frames or malformed JSON are rejected and the socket is closed.
|
- Non-connect first frames or malformed JSON are rejected and the socket is closed.
|
||||||
- Graceful shutdown: emit `shutdown` event before closing; clients must handle close + reconnect.
|
- Graceful shutdown: emit `shutdown` event before closing; clients must handle close + reconnect.
|
||||||
|
|
||||||
## CLI helpers
|
## CLI helpers
|
||||||
@@ -138,4 +138,4 @@ Enable with `systemctl enable --now clawdis-gateway.service`.
|
|||||||
|
|
||||||
## Migration guidance
|
## Migration guidance
|
||||||
- Retire uses of `clawdis gateway` and the legacy TCP control port.
|
- Retire uses of `clawdis gateway` and the legacy TCP control port.
|
||||||
- Update clients to speak the WS protocol with mandatory hello and structured presence.
|
- Update clients to speak the WS protocol with mandatory connect and structured presence.
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ Unify mac Canvas + iOS Canvas under a single conceptual surface:
|
|||||||
Add to `src/gateway/protocol/schema.ts` (and regenerate Swift models):
|
Add to `src/gateway/protocol/schema.ts` (and regenerate Swift models):
|
||||||
|
|
||||||
**Identity**
|
**Identity**
|
||||||
- Node identity comes from `hello.client.instanceId` (stable), and `hello.client.mode = "node"` (or `"ios-node"`).
|
- Node identity comes from `connect.params.client.instanceId` (stable), and `connect.params.client.mode = "node"` (or `"ios-node"`).
|
||||||
|
|
||||||
**Methods**
|
**Methods**
|
||||||
- `node.list` → list paired/connected nodes + capabilities
|
- `node.list` → list paired/connected nodes + capabilities
|
||||||
@@ -134,7 +134,7 @@ When iOS is backgrounded:
|
|||||||
## Code sharing (macOS + iOS)
|
## Code sharing (macOS + iOS)
|
||||||
Create/expand SwiftPM targets so both apps share:
|
Create/expand SwiftPM targets so both apps share:
|
||||||
- `ClawdisProtocol` (generated models; platform-neutral)
|
- `ClawdisProtocol` (generated models; platform-neutral)
|
||||||
- `ClawdisGatewayClient` (shared WS framing + hello/req/res + seq-gap handling)
|
- `ClawdisGatewayClient` (shared WS framing + connect/req/res + seq-gap handling)
|
||||||
- `ClawdisNodeKit` (node.invoke command types + error codes)
|
- `ClawdisNodeKit` (node.invoke command types + error codes)
|
||||||
|
|
||||||
macOS continues to own:
|
macOS continues to own:
|
||||||
@@ -191,6 +191,6 @@ open ClawdisNode.xcodeproj
|
|||||||
- Keep existing implementation, but expose it through the unified protocol path so the agent uses one API.
|
- Keep existing implementation, but expose it through the unified protocol path so the agent uses one API.
|
||||||
|
|
||||||
## Open questions
|
## Open questions
|
||||||
- Should `hello.client.mode` be `"node"` with `platform="ios ..."` or a distinct mode `"ios-node"`? (Presence filtering currently excludes `"cli"` only.)
|
- Should `connect.params.client.mode` be `"node"` with `platform="ios ..."` or a distinct mode `"ios-node"`? (Presence filtering currently excludes `"cli"` only.)
|
||||||
- Do we want a “permissions” model per node (voice only vs voice+screen) at pairing time?
|
- Do we want a “permissions” model per node (voice only vs voice+screen) at pairing time?
|
||||||
- Should “website mode” allow arbitrary https, or enforce an allowlist to reduce risk?
|
- Should “website mode” allow arbitrary https, or enforce an allowlist to reduce risk?
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ summary: "How Clawdis presence entries are produced, merged, and displayed"
|
|||||||
read_when:
|
read_when:
|
||||||
- Debugging the Instances tab
|
- Debugging the Instances tab
|
||||||
- Investigating duplicate or stale instance rows
|
- Investigating duplicate or stale instance rows
|
||||||
- Changing gateway WS hello or system-event beacons
|
- Changing gateway WS connect or system-event beacons
|
||||||
---
|
---
|
||||||
# Presence
|
# Presence
|
||||||
|
|
||||||
@@ -36,13 +36,13 @@ The Gateway seeds a “self” entry at startup so UIs always show at least the
|
|||||||
|
|
||||||
Implementation: `src/infra/system-presence.ts` (`initSelfPresence()`).
|
Implementation: `src/infra/system-presence.ts` (`initSelfPresence()`).
|
||||||
|
|
||||||
### 2) WebSocket hello (connection-derived presence)
|
### 2) WebSocket connect (connection-derived presence)
|
||||||
|
|
||||||
Every WS client must begin with a `hello` frame. On successful handshake, the Gateway upserts a presence entry for that connection.
|
Every WS client must begin with a `connect` request. On successful handshake, the Gateway upserts a presence entry for that connection.
|
||||||
|
|
||||||
This is meant to answer: “Which clients are currently connected?”
|
This is meant to answer: “Which clients are currently connected?”
|
||||||
|
|
||||||
Implementation: `src/gateway/server.ts` (WS `hello` handling uses `hello.client.instanceId` when provided; otherwise falls back to `connId`).
|
Implementation: `src/gateway/server.ts` (connect handling uses `connect.params.client.instanceId` when provided; otherwise falls back to `connId`).
|
||||||
|
|
||||||
#### Why one-off CLI commands do not show up
|
#### Why one-off CLI commands do not show up
|
||||||
|
|
||||||
@@ -113,6 +113,6 @@ The store refreshes periodically and also applies `presence` WS events.
|
|||||||
|
|
||||||
- To see the raw list, call `system-presence` against the gateway.
|
- To see the raw list, call `system-presence` against the gateway.
|
||||||
- If you see duplicates:
|
- If you see duplicates:
|
||||||
- confirm clients send a stable `instanceId` in `hello`
|
- confirm clients send a stable `instanceId` in the handshake (`connect.params.client.instanceId`)
|
||||||
- confirm beaconing uses the same `instanceId`
|
- confirm beaconing uses the same `instanceId`
|
||||||
- check whether the connection-derived entry is missing `instanceId` (then it will be keyed by `connId` and duplicates are expected on reconnect)
|
- check whether the connection-derived entry is missing `instanceId` (then it will be keyed by `connId` and duplicates are expected on reconnect)
|
||||||
|
|||||||
@@ -16,17 +16,16 @@ Goal: replace legacy gateway/stdin/TCP control with a single WebSocket Gateway,
|
|||||||
- **Protocol folder**: create `protocol/` for schemas and build artifacts. ✅ `src/gateway/protocol`.
|
- **Protocol folder**: create `protocol/` for schemas and build artifacts. ✅ `src/gateway/protocol`.
|
||||||
- **Schema tooling**:
|
- **Schema tooling**:
|
||||||
- Prefer **TypeBox** (or ArkType) as source-of-truth types. ✅ TypeBox in `schema.ts`.
|
- Prefer **TypeBox** (or ArkType) as source-of-truth types. ✅ TypeBox in `schema.ts`.
|
||||||
- `pnpm protocol:gen`:
|
- `pnpm protocol:gen`: emits JSON Schema (`dist/protocol.schema.json`). ✅
|
||||||
1) emits JSON Schema (`dist/protocol.schema.json`),
|
- `pnpm protocol:gen:swift`: generates Swift `Codable` models (`apps/macos/Sources/ClawdisProtocol/GatewayModels.swift`). ✅
|
||||||
2) runs quicktype → Swift `Codable` models (`apps/macos/Sources/ClawdisProtocol/Protocol.swift`). ✅
|
|
||||||
- AJV compile step for server validators. ✅
|
- AJV compile step for server validators. ✅
|
||||||
- **CI**: add a job that fails if schema or generated Swift is stale. ✅ `pnpm protocol:check` (runs gen + git diff).
|
- **CI**: add a job that fails if schema or generated Swift is stale. ✅ `pnpm protocol:check` (runs gen + git diff).
|
||||||
|
|
||||||
## Phase 1 — Protocol specification
|
## Phase 1 — Protocol specification
|
||||||
- Frames (WS text JSON, all with explicit `type`):
|
- Frames (WS text JSON, all with explicit `type`):
|
||||||
- `hello {type:"hello", minProtocol, maxProtocol, client:{name,version,platform,mode,instanceId}, caps, auth:{token?}, locale?, userAgent?}`
|
- `req {type:"req", id, method:"connect", params:{minProtocol,maxProtocol,client:{name,version,platform,mode,instanceId}, caps, auth:{token?}, locale?, userAgent?}}`
|
||||||
|
- `res {type:"res", id, ok:true, payload: hello-ok }` (or `ok:false` then close)
|
||||||
- `hello-ok {type:"hello-ok", protocol:<chosen>, server:{version,commit,host,connId}, features:{methods,events}, snapshot:{presence[], health, stateVersion:{presence,health}, uptimeMs}, policy:{maxPayload, maxBufferedBytes, tickIntervalMs}}`
|
- `hello-ok {type:"hello-ok", protocol:<chosen>, server:{version,commit,host,connId}, features:{methods,events}, snapshot:{presence[], health, stateVersion:{presence,health}, uptimeMs}, policy:{maxPayload, maxBufferedBytes, tickIntervalMs}}`
|
||||||
- `hello-error {type:"hello-error", reason, expectedProtocol, minClient}`
|
|
||||||
- `req {type:"req", id, method, params?}`
|
- `req {type:"req", id, method, params?}`
|
||||||
- `res {type:"res", id, ok, payload?, error?}` where `error` = `{code,message,details?,retryable?,retryAfterMs?}`
|
- `res {type:"res", id, ok, payload?, error?}` where `error` = `{code,message,details?,retryable?,retryAfterMs?}`
|
||||||
- `event {type:"event", event, payload, seq?, stateVersion?}` (presence/tick/shutdown/agent)
|
- `event {type:"event", event, payload, seq?, stateVersion?}` (presence/tick/shutdown/agent)
|
||||||
@@ -40,8 +39,8 @@ Goal: replace legacy gateway/stdin/TCP control with a single WebSocket Gateway,
|
|||||||
- Error codes: `NOT_LINKED`, `AGENT_TIMEOUT`, `INVALID_REQUEST`, `UNAVAILABLE`.
|
- Error codes: `NOT_LINKED`, `AGENT_TIMEOUT`, `INVALID_REQUEST`, `UNAVAILABLE`.
|
||||||
- Error shape: `{code, message, details?, retryable?, retryAfterMs?}`
|
- Error shape: `{code, message, details?, retryable?, retryAfterMs?}`
|
||||||
- Rules:
|
- Rules:
|
||||||
- First frame must be `type:"hello"`; otherwise close. Add handshake timeout (e.g., 3s) for silent clients.
|
- First frame must be `req` with `method:"connect"`; otherwise close. Add handshake timeout (e.g., 3s) for silent clients.
|
||||||
- Negotiate protocol: server picks within `[minProtocol,maxProtocol]`; if none, send `hello-error`.
|
- Negotiate protocol: server picks within `[minProtocol,maxProtocol]`; if none, reply `res ok:false` and close.
|
||||||
- Protocol version bump on breaking changes; `hello-ok` must include `minClient` when needed.
|
- Protocol version bump on breaking changes; `hello-ok` must include `minClient` when needed.
|
||||||
- `stateVersion` increments for presence/health to drop stale deltas.
|
- `stateVersion` increments for presence/health to drop stale deltas.
|
||||||
- Stable IDs: client sends `instanceId`; server issues per-connection `connId` in `hello-ok`; presence entries may include `instanceId` to dedupe reconnects.
|
- Stable IDs: client sends `instanceId`; server issues per-connection `connId` in `hello-ok`; presence entries may include `instanceId` to dedupe reconnects.
|
||||||
@@ -49,14 +48,14 @@ Goal: replace legacy gateway/stdin/TCP control with a single WebSocket Gateway,
|
|||||||
- Presence is primarily connection-derived; client may add hints (e.g., lastInputSeconds); entries expire via TTL to keep the map bounded (e.g., 5m TTL, max 200 entries).
|
- Presence is primarily connection-derived; client may add hints (e.g., lastInputSeconds); entries expire via TTL to keep the map bounded (e.g., 5m TTL, max 200 entries).
|
||||||
- Idempotency keys: required for `send` and `agent` to safely retry after disconnects.
|
- Idempotency keys: required for `send` and `agent` to safely retry after disconnects.
|
||||||
- Size limits: bound first-frame size by `maxPayload`; reject early if exceeded.
|
- Size limits: bound first-frame size by `maxPayload`; reject early if exceeded.
|
||||||
- Close on any non-JSON or wrong `type` before hello.
|
- Close on any non-JSON or wrong `type` before connect.
|
||||||
- Per-op idempotency keys: client SHOULD supply an explicit key per `send`/`agent`; if omitted, server may derive a scoped key from `instanceId+connId`, but explicit keys are safer across reconnects.
|
- Per-op idempotency keys: client SHOULD supply an explicit key per `send`/`agent`; if omitted, server may derive a scoped key from `instanceId+connId`, but explicit keys are safer across reconnects.
|
||||||
- Locale/userAgent are informational; server may log them for analytics but must not rely on them for access control.
|
- Locale/userAgent are informational; server may log them for analytics but must not rely on them for access control.
|
||||||
|
|
||||||
## Phase 2 — Gateway WebSocket server
|
## Phase 2 — Gateway WebSocket server
|
||||||
- New module `src/gateway/server.ts`:
|
- New module `src/gateway/server.ts`:
|
||||||
- Bind 127.0.0.1:18789 (configurable).
|
- Bind 127.0.0.1:18789 (configurable).
|
||||||
- On connect: validate `hello`, send `hello-ok` with snapshot, start event pump.
|
- On connect: validate `connect` params, send snapshot payload, start event pump.
|
||||||
- Per-connection queues with backpressure (bounded; drop oldest non-critical).
|
- Per-connection queues with backpressure (bounded; drop oldest non-critical).
|
||||||
- WS-level caps: set `maxPayload` to cap frame size before JSON parse.
|
- WS-level caps: set `maxPayload` to cap frame size before JSON parse.
|
||||||
- Emit `tick` every N seconds when idle (or WS ping/pong if adequate).
|
- Emit `tick` every N seconds when idle (or WS ping/pong if adequate).
|
||||||
@@ -73,7 +72,7 @@ Goal: replace legacy gateway/stdin/TCP control with a single WebSocket Gateway,
|
|||||||
- Handshake edge cases:
|
- Handshake edge cases:
|
||||||
- Close on handshake timeout.
|
- Close on handshake timeout.
|
||||||
- Close on over-limit first frame (maxPayload).
|
- Close on over-limit first frame (maxPayload).
|
||||||
- Close immediately on non-JSON or wrong `type` before hello.
|
- Close immediately on non-JSON or wrong `type` before connect.
|
||||||
- Default guardrails: `maxPayload` ~512 KB, handshake timeout ~3 s, outbound buffered amount cap ~1.5 MB (tune as you implement).
|
- Default guardrails: `maxPayload` ~512 KB, handshake timeout ~3 s, outbound buffered amount cap ~1.5 MB (tune as you implement).
|
||||||
- Dedupe cache: bound TTL (~5m) and max size (~1000 entries); evict oldest first (LRU) to prevent memory growth.
|
- Dedupe cache: bound TTL (~5m) and max size (~1000 entries); evict oldest first (LRU) to prevent memory growth.
|
||||||
|
|
||||||
@@ -101,7 +100,7 @@ Goal: replace legacy gateway/stdin/TCP control with a single WebSocket Gateway,
|
|||||||
- Replace stdio/SSH RPC with WS client (tunneled via SSH/Tailscale for remote). ✅ AgentRPC/ControlChannel now use Gateway WS.
|
- Replace stdio/SSH RPC with WS client (tunneled via SSH/Tailscale for remote). ✅ AgentRPC/ControlChannel now use Gateway WS.
|
||||||
- Implement handshake, snapshot hydration, subscriptions to `presence`, `tick`, `agent`, `shutdown`. ✅ snapshot + presence events broadcast to InstancesStore; agent events still to wire to UI if desired.
|
- Implement handshake, snapshot hydration, subscriptions to `presence`, `tick`, `agent`, `shutdown`. ✅ snapshot + presence events broadcast to InstancesStore; agent events still to wire to UI if desired.
|
||||||
- Remove immediate `health/system-presence` fetch on connect. ✅ presence hydrated from snapshot; periodic refresh kept as fallback.
|
- Remove immediate `health/system-presence` fetch on connect. ✅ presence hydrated from snapshot; periodic refresh kept as fallback.
|
||||||
- Handle `hello-error` and retry with backoff if version/token mismatched. ✅ macOS GatewayChannel reconnects with exponential backoff.
|
- Handle connect failures (`res ok:false`) and retry with backoff if version/token mismatched. ✅ macOS GatewayChannel reconnects with exponential backoff.
|
||||||
- **CLI**:
|
- **CLI**:
|
||||||
- Add lightweight WS client helper for `status/health/send/agent` when Gateway is up. ✅ `gateway` subcommands use the Gateway over WS.
|
- Add lightweight WS client helper for `status/health/send/agent` when Gateway is up. ✅ `gateway` subcommands use the Gateway over WS.
|
||||||
- Consider a “local only” flag to avoid accidental remote connects. (optional; not needed with tunnel-first model.)
|
- Consider a “local only” flag to avoid accidental remote connects. (optional; not needed with tunnel-first model.)
|
||||||
@@ -134,7 +133,7 @@ Goal: replace legacy gateway/stdin/TCP control with a single WebSocket Gateway,
|
|||||||
|
|
||||||
## Edge cases and ordering
|
## Edge cases and ordering
|
||||||
- Event ordering: all events carry `seq`; clients detect gaps and should re-fetch snapshot (or targeted refresh) on gap.
|
- Event ordering: all events carry `seq`; clients detect gaps and should re-fetch snapshot (or targeted refresh) on gap.
|
||||||
- Partial handshakes: if client connects and never sends hello, server closes after handshake timeout.
|
- Partial handshakes: if client connects and never sends `req:connect`, server closes after handshake timeout.
|
||||||
- Garbage/oversize first frame: bounded by `maxPayload`; server closes immediately on parse failure.
|
- Garbage/oversize first frame: bounded by `maxPayload`; server closes immediately on parse failure.
|
||||||
- Duplicate delivery on reconnect: clients must send idempotency keys; Gateway dedupe cache prevents double-send/agent execution.
|
- Duplicate delivery on reconnect: clients must send idempotency keys; Gateway dedupe cache prevents double-send/agent execution.
|
||||||
- Snapshot sufficiency: `hello-ok.snapshot` must contain enough to render UI after reconnect without event replay.
|
- Snapshot sufficiency: `hello-ok.snapshot` must contain enough to render UI after reconnect without event replay.
|
||||||
@@ -144,7 +143,7 @@ Goal: replace legacy gateway/stdin/TCP control with a single WebSocket Gateway,
|
|||||||
|
|
||||||
## Phase 9 — Testing & validation
|
## Phase 9 — Testing & validation
|
||||||
- Unit: frame validation, handshake failure, auth/token, stateVersion on presence events, agent stream fanout, send dedupe. ✅
|
- Unit: frame validation, handshake failure, auth/token, stateVersion on presence events, agent stream fanout, send dedupe. ✅
|
||||||
- Integration: connect → snapshot → req/res → streaming agent → shutdown. ✅ Covered in gateway WS tests (hello/health/status/presence, agent ack+final, shutdown broadcast).
|
- Integration: connect → snapshot → req/res → streaming agent → shutdown. ✅ Covered in gateway WS tests (connect/health/status/presence, agent ack+final, shutdown broadcast).
|
||||||
- Load: multiple concurrent WS clients; backpressure behavior under burst. ✅ Basic fanout test with 3 clients receiving presence broadcast; heavier soak still recommended.
|
- Load: multiple concurrent WS clients; backpressure behavior under burst. ✅ Basic fanout test with 3 clients receiving presence broadcast; heavier soak still recommended.
|
||||||
- Mac app smoke: presence/health render from snapshot; reconnect on tick loss. (Manual: open Instances tab, verify snapshot after connect, induce seq gap by toggling wifi, ensure UI refreshes.)
|
- Mac app smoke: presence/health render from snapshot; reconnect on tick loss. (Manual: open Instances tab, verify snapshot after connect, induce seq gap by toggling wifi, ensure UI refreshes.)
|
||||||
- WebChat smoke: snapshot seed + event updates; tunnel scenario. ✅ Offline snapshot harness in `src/webchat/server.test.ts` (mock gateway) now passes; live tunnel still recommended for manual.
|
- WebChat smoke: snapshot seed + event updates; tunnel scenario. ✅ Offline snapshot harness in `src/webchat/server.test.ts` (mock gateway) now passes; live tunnel still recommended for manual.
|
||||||
@@ -161,7 +160,7 @@ Goal: replace legacy gateway/stdin/TCP control with a single WebSocket Gateway,
|
|||||||
- Quick checklist
|
- Quick checklist
|
||||||
- [x] Protocol types & schemas (TS + JSON Schema + Swift via quicktype)
|
- [x] Protocol types & schemas (TS + JSON Schema + Swift via quicktype)
|
||||||
- [x] AJV validators wired
|
- [x] AJV validators wired
|
||||||
- [x] WS server with hello → snapshot → events
|
- [x] WS server with connect → snapshot → events
|
||||||
- [x] Tick + shutdown events
|
- [x] Tick + shutdown events
|
||||||
- [x] stateVersion + presence deltas
|
- [x] stateVersion + presence deltas
|
||||||
- [x] Gateway CLI command
|
- [x] Gateway CLI command
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ Context: web chat currently lives in a WKWebView that loads the pi-web bundle. S
|
|||||||
|
|
||||||
## Client work (pi-web bundle)
|
## Client work (pi-web bundle)
|
||||||
- Replace `NativeTransport` with a Gateway WS client:
|
- Replace `NativeTransport` with a Gateway WS client:
|
||||||
- `hello` → `chat.history` for initial state.
|
- `connect` → `chat.history` for initial state.
|
||||||
- Listen to `chat/presence/tick/health`; update UI from events only.
|
- Listen to `chat/presence/tick/health`; update UI from events only.
|
||||||
- Send via `chat.send`; mark pending until `chat state:final|error`.
|
- Send via `chat.send`; mark pending until `chat state:final|error`.
|
||||||
- Enforce health gate + 30s timeout.
|
- Enforce health gate + 30s timeout.
|
||||||
|
|||||||
@@ -7,22 +7,22 @@ read_when:
|
|||||||
|
|
||||||
Last updated: 2025-12-09
|
Last updated: 2025-12-09
|
||||||
|
|
||||||
We use TypeBox schemas in `src/gateway/protocol/schema.ts` as the single source of truth for the Gateway control plane (hello/req/res/event frames and payloads). All derived artifacts should be generated from these schemas, not edited by hand.
|
We use TypeBox schemas in `src/gateway/protocol/schema.ts` as the single source of truth for the Gateway control plane (connect/req/res/event frames and payloads). All derived artifacts should be generated from these schemas, not edited by hand.
|
||||||
|
|
||||||
## Current pipeline
|
## Current pipeline
|
||||||
|
|
||||||
- **TypeBox → JSON Schema**: `pnpm protocol:gen` writes `dist/protocol.schema.json` (draft-07) and runs AJV in the server tests.
|
- **TypeBox → JSON Schema**: `pnpm protocol:gen` writes `dist/protocol.schema.json` (draft-07) and runs AJV in the server tests.
|
||||||
- **TypeBox → Swift (quicktype)**: `pnpm protocol:gen` currently also generates `apps/macos/Sources/ClawdisProtocol/Protocol.swift` via quicktype. This produces a single struct with many optionals and is not ideal for strong typing.
|
- **TypeBox → Swift**: `pnpm protocol:gen:swift` generates `apps/macos/Sources/ClawdisProtocol/GatewayModels.swift`.
|
||||||
|
|
||||||
## Problem
|
## Problem
|
||||||
|
|
||||||
- Quicktype flattens `oneOf`/`discriminator` into an all-optional struct, so Swift loses exhaustiveness and safety for `GatewayFrame`.
|
- We want strong typing in Swift, including a sealed `GatewayFrame` enum with a discriminator and a forward-compatible `unknown` case.
|
||||||
|
|
||||||
## Preferred plan (next step)
|
## Preferred plan (next step)
|
||||||
|
|
||||||
- Add a small, custom Swift generator driven directly by the TypeBox schemas:
|
- Add a small, custom Swift generator driven directly by the TypeBox schemas:
|
||||||
- Emit a sealed `enum GatewayFrame: Codable { case hello(Hello), helloOk(HelloOk), helloError(...), req(RequestFrame), res(ResponseFrame), event(EventFrame) }`.
|
- Emit a sealed `enum GatewayFrame: Codable { case req(RequestFrame), res(ResponseFrame), event(EventFrame) }`.
|
||||||
- Emit strongly typed payload structs/enums (`Hello`, `HelloOk`, `HelloError`, `RequestFrame`, `ResponseFrame`, `EventFrame`, `PresenceEntry`, `Snapshot`, `StateVersion`, `ErrorShape`, `AgentEvent`, `TickEvent`, `ShutdownEvent`, `SendParams`, `AgentParams`, `ErrorCode`, `PROTOCOL_VERSION`).
|
- Emit strongly typed payload structs/enums (`ConnectParams`, `HelloOk`, `RequestFrame`, `ResponseFrame`, `EventFrame`, `PresenceEntry`, `Snapshot`, `StateVersion`, `ErrorShape`, `AgentEvent`, `TickEvent`, `ShutdownEvent`, `SendParams`, `AgentParams`, `ErrorCode`, `PROTOCOL_VERSION`).
|
||||||
- Custom `init(from:)` / `encode(to:)` enforces the `type` discriminator and can include an `unknown` case for forward compatibility.
|
- Custom `init(from:)` / `encode(to:)` enforces the `type` discriminator and can include an `unknown` case for forward compatibility.
|
||||||
- Wire a new script (e.g., `pnpm protocol:gen:swift`) into `protocol:check` so CI fails if the generated Swift is stale.
|
- Wire a new script (e.g., `pnpm protocol:gen:swift`) into `protocol:check` so CI fails if the generated Swift is stale.
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Updated: 2025-12-09
|
|||||||
- Data plane is entirely on the Gateway WS (`ws://127.0.0.1:<gatewayPort>`): methods `chat.history`, `chat.send`; events `chat`, `presence`, `tick`, `health`.
|
- Data plane is entirely on the Gateway WS (`ws://127.0.0.1:<gatewayPort>`): methods `chat.history`, `chat.send`; events `chat`, `presence`, `tick`, `health`.
|
||||||
|
|
||||||
## How it connects
|
## How it connects
|
||||||
- Browser/WebView performs Gateway WS `hello`, then calls `chat.history` for bootstrap and `chat.send` for sends; listens to `chat/presence/tick/health` events.
|
- Browser/WebView performs Gateway WS `connect`, then calls `chat.history` for bootstrap and `chat.send` for sends; listens to `chat/presence/tick/health` events.
|
||||||
- No session file watching. History comes from the Gateway via `chat.history`.
|
- No session file watching. History comes from the Gateway via `chat.history`.
|
||||||
- If Gateway WS is unavailable, the UI surfaces the error and blocks send.
|
- If Gateway WS is unavailable, the UI surfaces the error and blocks send.
|
||||||
|
|
||||||
|
|||||||
@@ -146,11 +146,8 @@ function emitStruct(name: string, schema: JsonSchema): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function emitGatewayFrame(): string {
|
function emitGatewayFrame(): string {
|
||||||
const cases = ["hello", "hello-ok", "hello-error", "req", "res", "event"];
|
const cases = ["req", "res", "event"];
|
||||||
const associated: Record<string, string> = {
|
const associated: Record<string, string> = {
|
||||||
hello: "Hello",
|
|
||||||
"hello-ok": "HelloOk",
|
|
||||||
"hello-error": "HelloError",
|
|
||||||
req: "RequestFrame",
|
req: "RequestFrame",
|
||||||
res: "ResponseFrame",
|
res: "ResponseFrame",
|
||||||
event: "EventFrame",
|
event: "EventFrame",
|
||||||
@@ -165,12 +162,6 @@ function emitGatewayFrame(): string {
|
|||||||
let typeContainer = try decoder.container(keyedBy: CodingKeys.self)
|
let typeContainer = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
let type = try typeContainer.decode(String.self, forKey: .type)
|
let type = try typeContainer.decode(String.self, forKey: .type)
|
||||||
switch type {
|
switch type {
|
||||||
case "hello":
|
|
||||||
self = .hello(try Hello(from: decoder))
|
|
||||||
case "hello-ok":
|
|
||||||
self = .helloOk(try HelloOk(from: decoder))
|
|
||||||
case "hello-error":
|
|
||||||
self = .helloError(try HelloError(from: decoder))
|
|
||||||
case "req":
|
case "req":
|
||||||
self = .req(try RequestFrame(from: decoder))
|
self = .req(try RequestFrame(from: decoder))
|
||||||
case "res":
|
case "res":
|
||||||
@@ -186,9 +177,6 @@ function emitGatewayFrame(): string {
|
|||||||
|
|
||||||
public func encode(to encoder: Encoder) throws {
|
public func encode(to encoder: Encoder) throws {
|
||||||
switch self {
|
switch self {
|
||||||
case .hello(let v): try v.encode(to: encoder)
|
|
||||||
case .helloOk(let v): try v.encode(to: encoder)
|
|
||||||
case .helloError(let v): try v.encode(to: encoder)
|
|
||||||
case .req(let v): try v.encode(to: encoder)
|
case .req(let v): try v.encode(to: encoder)
|
||||||
case .res(let v): try v.encode(to: encoder)
|
case .res(let v): try v.encode(to: encoder)
|
||||||
case .event(let v): try v.encode(to: encoder)
|
case .event(let v): try v.encode(to: encoder)
|
||||||
|
|||||||
@@ -18,9 +18,6 @@ async function writeJsonSchema() {
|
|||||||
title: "Clawdis Gateway Protocol",
|
title: "Clawdis Gateway Protocol",
|
||||||
description: "Handshake, request/response, and event frames for the Gateway WebSocket.",
|
description: "Handshake, request/response, and event frames for the Gateway WebSocket.",
|
||||||
oneOf: [
|
oneOf: [
|
||||||
{ $ref: "#/definitions/Hello" },
|
|
||||||
{ $ref: "#/definitions/HelloOk" },
|
|
||||||
{ $ref: "#/definitions/HelloError" },
|
|
||||||
{ $ref: "#/definitions/RequestFrame" },
|
{ $ref: "#/definitions/RequestFrame" },
|
||||||
{ $ref: "#/definitions/ResponseFrame" },
|
{ $ref: "#/definitions/ResponseFrame" },
|
||||||
{ $ref: "#/definitions/EventFrame" },
|
{ $ref: "#/definitions/EventFrame" },
|
||||||
@@ -28,9 +25,6 @@ async function writeJsonSchema() {
|
|||||||
discriminator: {
|
discriminator: {
|
||||||
propertyName: "type",
|
propertyName: "type",
|
||||||
mapping: {
|
mapping: {
|
||||||
hello: "#/definitions/Hello",
|
|
||||||
"hello-ok": "#/definitions/HelloOk",
|
|
||||||
"hello-error": "#/definitions/HelloError",
|
|
||||||
req: "#/definitions/RequestFrame",
|
req: "#/definitions/RequestFrame",
|
||||||
res: "#/definitions/ResponseFrame",
|
res: "#/definitions/ResponseFrame",
|
||||||
event: "#/definitions/EventFrame",
|
event: "#/definitions/EventFrame",
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ Examples:
|
|||||||
)
|
)
|
||||||
.option(
|
.option(
|
||||||
"--token <token>",
|
"--token <token>",
|
||||||
"Shared token required in hello.auth.token (default: CLAWDIS_GATEWAY_TOKEN env if set)",
|
"Shared token required in connect.params.auth.token (default: CLAWDIS_GATEWAY_TOKEN env if set)",
|
||||||
)
|
)
|
||||||
.option(
|
.option(
|
||||||
"--force",
|
"--force",
|
||||||
|
|||||||
@@ -29,11 +29,13 @@ describe("GatewayClient", () => {
|
|||||||
wss = new WebSocketServer({ port, host: "127.0.0.1" });
|
wss = new WebSocketServer({ port, host: "127.0.0.1" });
|
||||||
|
|
||||||
wss.on("connection", (socket) => {
|
wss.on("connection", (socket) => {
|
||||||
socket.once("message", () => {
|
socket.once("message", (data) => {
|
||||||
|
const first = JSON.parse(String(data)) as { id?: string };
|
||||||
|
const id = first.id ?? "connect";
|
||||||
// Respond with tiny tick interval to trigger watchdog quickly.
|
// Respond with tiny tick interval to trigger watchdog quickly.
|
||||||
const helloOk = {
|
const helloOk = {
|
||||||
type: "hello-ok",
|
type: "hello-ok",
|
||||||
protocol: 1,
|
protocol: 2,
|
||||||
server: { version: "dev", connId: "c1" },
|
server: { version: "dev", connId: "c1" },
|
||||||
features: { methods: [], events: [] },
|
features: { methods: [], events: [] },
|
||||||
snapshot: {
|
snapshot: {
|
||||||
@@ -48,7 +50,9 @@ describe("GatewayClient", () => {
|
|||||||
tickIntervalMs: 5,
|
tickIntervalMs: 5,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
socket.send(JSON.stringify(helloOk));
|
socket.send(
|
||||||
|
JSON.stringify({ type: "res", id, ok: true, payload: helloOk }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { randomUUID } from "node:crypto";
|
|||||||
import { WebSocket } from "ws";
|
import { WebSocket } from "ws";
|
||||||
import { logDebug, logError } from "../logger.js";
|
import { logDebug, logError } from "../logger.js";
|
||||||
import {
|
import {
|
||||||
|
type ConnectParams,
|
||||||
type EventFrame,
|
type EventFrame,
|
||||||
type Hello,
|
|
||||||
type HelloOk,
|
type HelloOk,
|
||||||
PROTOCOL_VERSION,
|
PROTOCOL_VERSION,
|
||||||
type RequestFrame,
|
type RequestFrame,
|
||||||
@@ -53,7 +53,7 @@ export class GatewayClient {
|
|||||||
const url = this.opts.url ?? "ws://127.0.0.1:18789";
|
const url = this.opts.url ?? "ws://127.0.0.1:18789";
|
||||||
this.ws = new WebSocket(url, { maxPayload: 512 * 1024 });
|
this.ws = new WebSocket(url, { maxPayload: 512 * 1024 });
|
||||||
|
|
||||||
this.ws.on("open", () => this.sendHello());
|
this.ws.on("open", () => this.sendConnect());
|
||||||
this.ws.on("message", (data) => this.handleMessage(data.toString()));
|
this.ws.on("message", (data) => this.handleMessage(data.toString()));
|
||||||
this.ws.on("close", (code, reason) => {
|
this.ws.on("close", (code, reason) => {
|
||||||
this.ws = null;
|
this.ws = null;
|
||||||
@@ -79,9 +79,8 @@ export class GatewayClient {
|
|||||||
this.flushPendingErrors(new Error("gateway client stopped"));
|
this.flushPendingErrors(new Error("gateway client stopped"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private sendHello() {
|
private sendConnect() {
|
||||||
const hello: Hello = {
|
const params: ConnectParams = {
|
||||||
type: "hello",
|
|
||||||
minProtocol: this.opts.minProtocol ?? PROTOCOL_VERSION,
|
minProtocol: this.opts.minProtocol ?? PROTOCOL_VERSION,
|
||||||
maxProtocol: this.opts.maxProtocol ?? PROTOCOL_VERSION,
|
maxProtocol: this.opts.maxProtocol ?? PROTOCOL_VERSION,
|
||||||
client: {
|
client: {
|
||||||
@@ -94,28 +93,27 @@ export class GatewayClient {
|
|||||||
caps: [],
|
caps: [],
|
||||||
auth: this.opts.token ? { token: this.opts.token } : undefined,
|
auth: this.opts.token ? { token: this.opts.token } : undefined,
|
||||||
};
|
};
|
||||||
this.ws?.send(JSON.stringify(hello));
|
|
||||||
|
void this.request<HelloOk>("connect", params)
|
||||||
|
.then((helloOk) => {
|
||||||
|
this.backoffMs = 1000;
|
||||||
|
this.tickIntervalMs =
|
||||||
|
typeof helloOk.policy?.tickIntervalMs === "number"
|
||||||
|
? helloOk.policy.tickIntervalMs
|
||||||
|
: 30_000;
|
||||||
|
this.lastTick = Date.now();
|
||||||
|
this.startTickWatch();
|
||||||
|
this.opts.onHelloOk?.(helloOk);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
logError(`gateway connect failed: ${String(err)}`);
|
||||||
|
this.ws?.close(1008, "connect failed");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleMessage(raw: string) {
|
private handleMessage(raw: string) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
if (parsed?.type === "hello-ok") {
|
|
||||||
this.backoffMs = 1000;
|
|
||||||
this.tickIntervalMs =
|
|
||||||
typeof parsed.policy?.tickIntervalMs === "number"
|
|
||||||
? parsed.policy.tickIntervalMs
|
|
||||||
: 30_000;
|
|
||||||
this.lastTick = Date.now();
|
|
||||||
this.startTickWatch();
|
|
||||||
this.opts.onHelloOk?.(parsed as HelloOk);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (parsed?.type === "hello-error") {
|
|
||||||
logError(`gateway hello-error: ${parsed.reason}`);
|
|
||||||
this.ws?.close(1008, "hello-error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (parsed?.type === "event") {
|
if (parsed?.type === "event") {
|
||||||
const evt = parsed as EventFrame;
|
const evt = parsed as EventFrame;
|
||||||
const seq = typeof evt.seq === "number" ? evt.seq : null;
|
const seq = typeof evt.seq === "number" ? evt.seq : null;
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
ChatEventSchema,
|
ChatEventSchema,
|
||||||
ChatHistoryParamsSchema,
|
ChatHistoryParamsSchema,
|
||||||
ChatSendParamsSchema,
|
ChatSendParamsSchema,
|
||||||
|
type ConnectParams,
|
||||||
|
ConnectParamsSchema,
|
||||||
ErrorCodes,
|
ErrorCodes,
|
||||||
type ErrorShape,
|
type ErrorShape,
|
||||||
ErrorShapeSchema,
|
ErrorShapeSchema,
|
||||||
@@ -15,12 +17,8 @@ import {
|
|||||||
errorShape,
|
errorShape,
|
||||||
type GatewayFrame,
|
type GatewayFrame,
|
||||||
GatewayFrameSchema,
|
GatewayFrameSchema,
|
||||||
type Hello,
|
|
||||||
type HelloError,
|
|
||||||
HelloErrorSchema,
|
|
||||||
type HelloOk,
|
type HelloOk,
|
||||||
HelloOkSchema,
|
HelloOkSchema,
|
||||||
HelloSchema,
|
|
||||||
PROTOCOL_VERSION,
|
PROTOCOL_VERSION,
|
||||||
type PresenceEntry,
|
type PresenceEntry,
|
||||||
PresenceEntrySchema,
|
PresenceEntrySchema,
|
||||||
@@ -50,7 +48,8 @@ const ajv = new (
|
|||||||
removeAdditional: false,
|
removeAdditional: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const validateHello = ajv.compile<Hello>(HelloSchema);
|
export const validateConnectParams =
|
||||||
|
ajv.compile<ConnectParams>(ConnectParamsSchema);
|
||||||
export const validateRequestFrame =
|
export const validateRequestFrame =
|
||||||
ajv.compile<RequestFrame>(RequestFrameSchema);
|
ajv.compile<RequestFrame>(RequestFrameSchema);
|
||||||
export const validateSendParams = ajv.compile(SendParamsSchema);
|
export const validateSendParams = ajv.compile(SendParamsSchema);
|
||||||
@@ -67,9 +66,8 @@ export function formatValidationErrors(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
HelloSchema,
|
ConnectParamsSchema,
|
||||||
HelloOkSchema,
|
HelloOkSchema,
|
||||||
HelloErrorSchema,
|
|
||||||
RequestFrameSchema,
|
RequestFrameSchema,
|
||||||
ResponseFrameSchema,
|
ResponseFrameSchema,
|
||||||
EventFrameSchema,
|
EventFrameSchema,
|
||||||
@@ -94,9 +92,8 @@ export {
|
|||||||
|
|
||||||
export type {
|
export type {
|
||||||
GatewayFrame,
|
GatewayFrame,
|
||||||
Hello,
|
ConnectParams,
|
||||||
HelloOk,
|
HelloOk,
|
||||||
HelloError,
|
|
||||||
RequestFrame,
|
RequestFrame,
|
||||||
ResponseFrame,
|
ResponseFrame,
|
||||||
EventFrame,
|
EventFrame,
|
||||||
|
|||||||
@@ -53,9 +53,8 @@ export const ShutdownEventSchema = Type.Object(
|
|||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
export const HelloSchema = Type.Object(
|
export const ConnectParamsSchema = Type.Object(
|
||||||
{
|
{
|
||||||
type: Type.Literal("hello"),
|
|
||||||
minProtocol: Type.Integer({ minimum: 1 }),
|
minProtocol: Type.Integer({ minimum: 1 }),
|
||||||
maxProtocol: Type.Integer({ minimum: 1 }),
|
maxProtocol: Type.Integer({ minimum: 1 }),
|
||||||
client: Type.Object(
|
client: Type.Object(
|
||||||
@@ -116,16 +115,6 @@ export const HelloOkSchema = Type.Object(
|
|||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
export const HelloErrorSchema = Type.Object(
|
|
||||||
{
|
|
||||||
type: Type.Literal("hello-error"),
|
|
||||||
reason: NonEmptyString,
|
|
||||||
expectedProtocol: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
||||||
minClient: Type.Optional(NonEmptyString),
|
|
||||||
},
|
|
||||||
{ additionalProperties: false },
|
|
||||||
);
|
|
||||||
|
|
||||||
export const ErrorShapeSchema = Type.Object(
|
export const ErrorShapeSchema = Type.Object(
|
||||||
{
|
{
|
||||||
code: NonEmptyString,
|
code: NonEmptyString,
|
||||||
@@ -173,14 +162,7 @@ export const EventFrameSchema = Type.Object(
|
|||||||
// downstream codegen (quicktype) produce tighter types instead of all-optional
|
// downstream codegen (quicktype) produce tighter types instead of all-optional
|
||||||
// blobs.
|
// blobs.
|
||||||
export const GatewayFrameSchema = Type.Union(
|
export const GatewayFrameSchema = Type.Union(
|
||||||
[
|
[RequestFrameSchema, ResponseFrameSchema, EventFrameSchema],
|
||||||
HelloSchema,
|
|
||||||
HelloOkSchema,
|
|
||||||
HelloErrorSchema,
|
|
||||||
RequestFrameSchema,
|
|
||||||
ResponseFrameSchema,
|
|
||||||
EventFrameSchema,
|
|
||||||
],
|
|
||||||
{ discriminator: "type" },
|
{ discriminator: "type" },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -261,9 +243,8 @@ export const ChatEventSchema = Type.Object(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const ProtocolSchemas: Record<string, TSchema> = {
|
export const ProtocolSchemas: Record<string, TSchema> = {
|
||||||
Hello: HelloSchema,
|
ConnectParams: ConnectParamsSchema,
|
||||||
HelloOk: HelloOkSchema,
|
HelloOk: HelloOkSchema,
|
||||||
HelloError: HelloErrorSchema,
|
|
||||||
RequestFrame: RequestFrameSchema,
|
RequestFrame: RequestFrameSchema,
|
||||||
ResponseFrame: ResponseFrameSchema,
|
ResponseFrame: ResponseFrameSchema,
|
||||||
EventFrame: EventFrameSchema,
|
EventFrame: EventFrameSchema,
|
||||||
@@ -282,11 +263,10 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
|||||||
ShutdownEvent: ShutdownEventSchema,
|
ShutdownEvent: ShutdownEventSchema,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PROTOCOL_VERSION = 1 as const;
|
export const PROTOCOL_VERSION = 2 as const;
|
||||||
|
|
||||||
export type Hello = Static<typeof HelloSchema>;
|
export type ConnectParams = Static<typeof ConnectParamsSchema>;
|
||||||
export type HelloOk = Static<typeof HelloOkSchema>;
|
export type HelloOk = Static<typeof HelloOkSchema>;
|
||||||
export type HelloError = Static<typeof HelloErrorSchema>;
|
|
||||||
export type RequestFrame = Static<typeof RequestFrameSchema>;
|
export type RequestFrame = Static<typeof RequestFrameSchema>;
|
||||||
export type ResponseFrame = Static<typeof ResponseFrameSchema>;
|
export type ResponseFrame = Static<typeof ResponseFrameSchema>;
|
||||||
export type EventFrame = Static<typeof EventFrameSchema>;
|
export type EventFrame = Static<typeof EventFrameSchema>;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { WebSocket } from "ws";
|
|||||||
import { agentCommand } from "../commands/agent.js";
|
import { agentCommand } from "../commands/agent.js";
|
||||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||||
import { GatewayLockError } from "../infra/gateway-lock.js";
|
import { GatewayLockError } from "../infra/gateway-lock.js";
|
||||||
|
import { PROTOCOL_VERSION } from "./protocol/index.js";
|
||||||
import { startGatewayServer } from "./server.js";
|
import { startGatewayServer } from "./server.js";
|
||||||
|
|
||||||
let testSessionStorePath: string | undefined;
|
let testSessionStorePath: string | undefined;
|
||||||
@@ -109,6 +110,67 @@ async function startServerWithClient(token?: string) {
|
|||||||
return { server, ws, port, prevToken: prev };
|
return { server, ws, port, prevToken: prev };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ConnectResponse = {
|
||||||
|
type: "res";
|
||||||
|
id: string;
|
||||||
|
ok: boolean;
|
||||||
|
payload?: unknown;
|
||||||
|
error?: { message?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
async function connectReq(
|
||||||
|
ws: WebSocket,
|
||||||
|
opts?: {
|
||||||
|
token?: string;
|
||||||
|
minProtocol?: number;
|
||||||
|
maxProtocol?: number;
|
||||||
|
client?: {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
platform: string;
|
||||||
|
mode: string;
|
||||||
|
instanceId?: string;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
): Promise<ConnectResponse> {
|
||||||
|
const id = randomUUID();
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "req",
|
||||||
|
id,
|
||||||
|
method: "connect",
|
||||||
|
params: {
|
||||||
|
minProtocol: opts?.minProtocol ?? PROTOCOL_VERSION,
|
||||||
|
maxProtocol: opts?.maxProtocol ?? PROTOCOL_VERSION,
|
||||||
|
client: opts?.client ?? {
|
||||||
|
name: "test",
|
||||||
|
version: "1.0.0",
|
||||||
|
platform: "test",
|
||||||
|
mode: "test",
|
||||||
|
},
|
||||||
|
caps: [],
|
||||||
|
auth: opts?.token ? { token: opts.token } : undefined,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return await onceMessage<ConnectResponse>(
|
||||||
|
ws,
|
||||||
|
(o) => o.type === "res" && o.id === id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectOk(
|
||||||
|
ws: WebSocket,
|
||||||
|
opts?: Parameters<typeof connectReq>[1],
|
||||||
|
) {
|
||||||
|
const res = await connectReq(ws, opts);
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect((res.payload as { type?: unknown } | undefined)?.type).toBe(
|
||||||
|
"hello-ok",
|
||||||
|
);
|
||||||
|
return res.payload as { type: "hello-ok" };
|
||||||
|
}
|
||||||
|
|
||||||
describe("gateway server", () => {
|
describe("gateway server", () => {
|
||||||
test("agent falls back to allowFrom when lastTo is stale", async () => {
|
test("agent falls back to allowFrom when lastTo is stale", async () => {
|
||||||
testAllowFrom = ["+436769770569"];
|
testAllowFrom = ["+436769770569"];
|
||||||
@@ -132,16 +194,7 @@ describe("gateway server", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { server, ws } = await startServerWithClient();
|
const { server, ws } = await startServerWithClient();
|
||||||
ws.send(
|
await connectOk(ws);
|
||||||
JSON.stringify({
|
|
||||||
type: "hello",
|
|
||||||
minProtocol: 1,
|
|
||||||
maxProtocol: 1,
|
|
||||||
client: { name: "test", version: "1", platform: "test", mode: "test" },
|
|
||||||
caps: [],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await onceMessage(ws, (o) => o.type === "hello-ok");
|
|
||||||
|
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -196,16 +249,7 @@ describe("gateway server", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { server, ws } = await startServerWithClient();
|
const { server, ws } = await startServerWithClient();
|
||||||
ws.send(
|
await connectOk(ws);
|
||||||
JSON.stringify({
|
|
||||||
type: "hello",
|
|
||||||
minProtocol: 1,
|
|
||||||
maxProtocol: 1,
|
|
||||||
client: { name: "test", version: "1", platform: "test", mode: "test" },
|
|
||||||
caps: [],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await onceMessage(ws, (o) => o.type === "hello-ok");
|
|
||||||
|
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -260,16 +304,7 @@ describe("gateway server", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { server, ws } = await startServerWithClient();
|
const { server, ws } = await startServerWithClient();
|
||||||
ws.send(
|
await connectOk(ws);
|
||||||
JSON.stringify({
|
|
||||||
type: "hello",
|
|
||||||
minProtocol: 1,
|
|
||||||
maxProtocol: 1,
|
|
||||||
client: { name: "test", version: "1", platform: "test", mode: "test" },
|
|
||||||
caps: [],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await onceMessage(ws, (o) => o.type === "hello-ok");
|
|
||||||
|
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -322,16 +357,7 @@ describe("gateway server", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { server, ws } = await startServerWithClient();
|
const { server, ws } = await startServerWithClient();
|
||||||
ws.send(
|
await connectOk(ws);
|
||||||
JSON.stringify({
|
|
||||||
type: "hello",
|
|
||||||
minProtocol: 1,
|
|
||||||
maxProtocol: 1,
|
|
||||||
client: { name: "test", version: "1", platform: "test", mode: "test" },
|
|
||||||
caps: [],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await onceMessage(ws, (o) => o.type === "hello-ok");
|
|
||||||
|
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -364,18 +390,12 @@ describe("gateway server", () => {
|
|||||||
|
|
||||||
test("rejects protocol mismatch", async () => {
|
test("rejects protocol mismatch", async () => {
|
||||||
const { server, ws } = await startServerWithClient();
|
const { server, ws } = await startServerWithClient();
|
||||||
ws.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: "hello",
|
|
||||||
minProtocol: 2,
|
|
||||||
maxProtocol: 3,
|
|
||||||
client: { name: "test", version: "1", platform: "test", mode: "test" },
|
|
||||||
caps: [],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
try {
|
try {
|
||||||
const res = await onceMessage(ws, () => true, 2000);
|
const res = await connectReq(ws, {
|
||||||
expect(res.type).toBe("hello-error");
|
minProtocol: PROTOCOL_VERSION + 1,
|
||||||
|
maxProtocol: PROTOCOL_VERSION + 2,
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
} catch {
|
} catch {
|
||||||
// If the server closed before we saw the frame, that's acceptable for mismatch.
|
// If the server closed before we saw the frame, that's acceptable for mismatch.
|
||||||
}
|
}
|
||||||
@@ -385,19 +405,9 @@ describe("gateway server", () => {
|
|||||||
|
|
||||||
test("rejects invalid token", async () => {
|
test("rejects invalid token", async () => {
|
||||||
const { server, ws, prevToken } = await startServerWithClient("secret");
|
const { server, ws, prevToken } = await startServerWithClient("secret");
|
||||||
ws.send(
|
const res = await connectReq(ws, { token: "wrong" });
|
||||||
JSON.stringify({
|
expect(res.ok).toBe(false);
|
||||||
type: "hello",
|
expect(res.error?.message ?? "").toContain("unauthorized");
|
||||||
minProtocol: 1,
|
|
||||||
maxProtocol: 1,
|
|
||||||
client: { name: "test", version: "1", platform: "test", mode: "test" },
|
|
||||||
caps: [],
|
|
||||||
auth: { token: "wrong" },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const res = await onceMessage(ws, () => true);
|
|
||||||
expect(res.type).toBe("hello-error");
|
|
||||||
expect(res.reason).toContain("unauthorized");
|
|
||||||
ws.close();
|
ws.close();
|
||||||
await server.close();
|
await server.close();
|
||||||
process.env.CLAWDIS_GATEWAY_TOKEN = prevToken;
|
process.env.CLAWDIS_GATEWAY_TOKEN = prevToken;
|
||||||
@@ -420,16 +430,17 @@ describe("gateway server", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
test(
|
test("connect (req) handshake returns hello-ok payload", async () => {
|
||||||
"hello + health + presence + status succeed",
|
const { server, ws } = await startServerWithClient();
|
||||||
{ timeout: 8000 },
|
const id = randomUUID();
|
||||||
async () => {
|
ws.send(
|
||||||
const { server, ws } = await startServerWithClient();
|
JSON.stringify({
|
||||||
ws.send(
|
type: "req",
|
||||||
JSON.stringify({
|
id,
|
||||||
type: "hello",
|
method: "connect",
|
||||||
minProtocol: 1,
|
params: {
|
||||||
maxProtocol: 1,
|
minProtocol: PROTOCOL_VERSION,
|
||||||
|
maxProtocol: PROTOCOL_VERSION,
|
||||||
client: {
|
client: {
|
||||||
name: "test",
|
name: "test",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -437,9 +448,40 @@ describe("gateway server", () => {
|
|||||||
mode: "test",
|
mode: "test",
|
||||||
},
|
},
|
||||||
caps: [],
|
caps: [],
|
||||||
}),
|
},
|
||||||
);
|
}),
|
||||||
await onceMessage(ws, (o) => o.type === "hello-ok");
|
);
|
||||||
|
|
||||||
|
const res = await onceMessage<{ ok: boolean; payload?: unknown }>(
|
||||||
|
ws,
|
||||||
|
(o) => o.type === "res" && o.id === id,
|
||||||
|
);
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect((res.payload as { type?: unknown } | undefined)?.type).toBe(
|
||||||
|
"hello-ok",
|
||||||
|
);
|
||||||
|
ws.close();
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects non-connect first request", async () => {
|
||||||
|
const { server, ws } = await startServerWithClient();
|
||||||
|
ws.send(JSON.stringify({ type: "req", id: "h1", method: "health" }));
|
||||||
|
const res = await onceMessage<{ ok: boolean; error?: unknown }>(
|
||||||
|
ws,
|
||||||
|
(o) => o.type === "res" && o.id === "h1",
|
||||||
|
);
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
await new Promise<void>((resolve) => ws.once("close", () => resolve()));
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
"connect + health + presence + status succeed",
|
||||||
|
{ timeout: 8000 },
|
||||||
|
async () => {
|
||||||
|
const { server, ws } = await startServerWithClient();
|
||||||
|
await connectOk(ws);
|
||||||
|
|
||||||
const healthP = onceMessage(
|
const healthP = onceMessage(
|
||||||
ws,
|
ws,
|
||||||
@@ -478,21 +520,7 @@ describe("gateway server", () => {
|
|||||||
{ timeout: 8000 },
|
{ timeout: 8000 },
|
||||||
async () => {
|
async () => {
|
||||||
const { server, ws } = await startServerWithClient();
|
const { server, ws } = await startServerWithClient();
|
||||||
ws.send(
|
await connectOk(ws);
|
||||||
JSON.stringify({
|
|
||||||
type: "hello",
|
|
||||||
minProtocol: 1,
|
|
||||||
maxProtocol: 1,
|
|
||||||
client: {
|
|
||||||
name: "test",
|
|
||||||
version: "1.0.0",
|
|
||||||
platform: "test",
|
|
||||||
mode: "test",
|
|
||||||
},
|
|
||||||
caps: [],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await onceMessage(ws, (o) => o.type === "hello-ok");
|
|
||||||
|
|
||||||
const presenceEventP = onceMessage(
|
const presenceEventP = onceMessage(
|
||||||
ws,
|
ws,
|
||||||
@@ -519,21 +547,7 @@ describe("gateway server", () => {
|
|||||||
|
|
||||||
test("agent events stream with seq", { timeout: 8000 }, async () => {
|
test("agent events stream with seq", { timeout: 8000 }, async () => {
|
||||||
const { server, ws } = await startServerWithClient();
|
const { server, ws } = await startServerWithClient();
|
||||||
ws.send(
|
await connectOk(ws);
|
||||||
JSON.stringify({
|
|
||||||
type: "hello",
|
|
||||||
minProtocol: 1,
|
|
||||||
maxProtocol: 1,
|
|
||||||
client: {
|
|
||||||
name: "test",
|
|
||||||
version: "1.0.0",
|
|
||||||
platform: "test",
|
|
||||||
mode: "test",
|
|
||||||
},
|
|
||||||
caps: [],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await onceMessage(ws, (o) => o.type === "hello-ok");
|
|
||||||
|
|
||||||
// Emit a fake agent event directly through the shared emitter.
|
// Emit a fake agent event directly through the shared emitter.
|
||||||
const evtPromise = onceMessage(
|
const evtPromise = onceMessage(
|
||||||
@@ -555,21 +569,7 @@ describe("gateway server", () => {
|
|||||||
{ timeout: 8000 },
|
{ timeout: 8000 },
|
||||||
async () => {
|
async () => {
|
||||||
const { server, ws } = await startServerWithClient();
|
const { server, ws } = await startServerWithClient();
|
||||||
ws.send(
|
await connectOk(ws);
|
||||||
JSON.stringify({
|
|
||||||
type: "hello",
|
|
||||||
minProtocol: 1,
|
|
||||||
maxProtocol: 1,
|
|
||||||
client: {
|
|
||||||
name: "test",
|
|
||||||
version: "1.0.0",
|
|
||||||
platform: "test",
|
|
||||||
mode: "test",
|
|
||||||
},
|
|
||||||
caps: [],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await onceMessage(ws, (o) => o.type === "hello-ok");
|
|
||||||
|
|
||||||
const ackP = onceMessage(
|
const ackP = onceMessage(
|
||||||
ws,
|
ws,
|
||||||
@@ -610,21 +610,7 @@ describe("gateway server", () => {
|
|||||||
{ timeout: 8000 },
|
{ timeout: 8000 },
|
||||||
async () => {
|
async () => {
|
||||||
const { server, ws } = await startServerWithClient();
|
const { server, ws } = await startServerWithClient();
|
||||||
ws.send(
|
await connectOk(ws);
|
||||||
JSON.stringify({
|
|
||||||
type: "hello",
|
|
||||||
minProtocol: 1,
|
|
||||||
maxProtocol: 1,
|
|
||||||
client: {
|
|
||||||
name: "test",
|
|
||||||
version: "1.0.0",
|
|
||||||
platform: "test",
|
|
||||||
mode: "test",
|
|
||||||
},
|
|
||||||
caps: [],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await onceMessage(ws, (o) => o.type === "hello-ok");
|
|
||||||
|
|
||||||
const firstFinalP = onceMessage(
|
const firstFinalP = onceMessage(
|
||||||
ws,
|
ws,
|
||||||
@@ -665,21 +651,7 @@ describe("gateway server", () => {
|
|||||||
|
|
||||||
test("shutdown event is broadcast on close", { timeout: 8000 }, async () => {
|
test("shutdown event is broadcast on close", { timeout: 8000 }, async () => {
|
||||||
const { server, ws } = await startServerWithClient();
|
const { server, ws } = await startServerWithClient();
|
||||||
ws.send(
|
await connectOk(ws);
|
||||||
JSON.stringify({
|
|
||||||
type: "hello",
|
|
||||||
minProtocol: 1,
|
|
||||||
maxProtocol: 1,
|
|
||||||
client: {
|
|
||||||
name: "test",
|
|
||||||
version: "1.0.0",
|
|
||||||
platform: "test",
|
|
||||||
mode: "test",
|
|
||||||
},
|
|
||||||
caps: [],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await onceMessage(ws, (o) => o.type === "hello-ok");
|
|
||||||
|
|
||||||
const shutdownP = onceMessage(
|
const shutdownP = onceMessage(
|
||||||
ws,
|
ws,
|
||||||
@@ -700,21 +672,7 @@ describe("gateway server", () => {
|
|||||||
const mkClient = async () => {
|
const mkClient = async () => {
|
||||||
const c = new WebSocket(`ws://127.0.0.1:${port}`);
|
const c = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||||
await new Promise<void>((resolve) => c.once("open", resolve));
|
await new Promise<void>((resolve) => c.once("open", resolve));
|
||||||
c.send(
|
await connectOk(c);
|
||||||
JSON.stringify({
|
|
||||||
type: "hello",
|
|
||||||
minProtocol: 1,
|
|
||||||
maxProtocol: 1,
|
|
||||||
client: {
|
|
||||||
name: "test",
|
|
||||||
version: "1.0.0",
|
|
||||||
platform: "test",
|
|
||||||
mode: "test",
|
|
||||||
},
|
|
||||||
caps: [],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await onceMessage(c, (o) => o.type === "hello-ok");
|
|
||||||
return c;
|
return c;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -742,21 +700,7 @@ describe("gateway server", () => {
|
|||||||
|
|
||||||
test("send dedupes by idempotencyKey", { timeout: 8000 }, async () => {
|
test("send dedupes by idempotencyKey", { timeout: 8000 }, async () => {
|
||||||
const { server, ws } = await startServerWithClient();
|
const { server, ws } = await startServerWithClient();
|
||||||
ws.send(
|
await connectOk(ws);
|
||||||
JSON.stringify({
|
|
||||||
type: "hello",
|
|
||||||
minProtocol: 1,
|
|
||||||
maxProtocol: 1,
|
|
||||||
client: {
|
|
||||||
name: "test",
|
|
||||||
version: "1.0.0",
|
|
||||||
platform: "test",
|
|
||||||
mode: "test",
|
|
||||||
},
|
|
||||||
caps: [],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await onceMessage(ws, (o) => o.type === "hello-ok");
|
|
||||||
|
|
||||||
const idem = "same-key";
|
const idem = "same-key";
|
||||||
const res1P = onceMessage(ws, (o) => o.type === "res" && o.id === "a1");
|
const res1P = onceMessage(ws, (o) => o.type === "res" && o.id === "a1");
|
||||||
@@ -789,21 +733,7 @@ describe("gateway server", () => {
|
|||||||
const dial = async () => {
|
const dial = async () => {
|
||||||
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||||
await new Promise<void>((resolve) => ws.once("open", resolve));
|
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||||
ws.send(
|
await connectOk(ws);
|
||||||
JSON.stringify({
|
|
||||||
type: "hello",
|
|
||||||
minProtocol: 1,
|
|
||||||
maxProtocol: 1,
|
|
||||||
client: {
|
|
||||||
name: "test",
|
|
||||||
version: "1.0.0",
|
|
||||||
platform: "test",
|
|
||||||
mode: "test",
|
|
||||||
},
|
|
||||||
caps: [],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await onceMessage(ws, (o) => o.type === "hello-ok");
|
|
||||||
return ws;
|
return ws;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -849,16 +779,7 @@ describe("gateway server", () => {
|
|||||||
|
|
||||||
test("chat.send accepts image attachment", { timeout: 12000 }, async () => {
|
test("chat.send accepts image attachment", { timeout: 12000 }, async () => {
|
||||||
const { server, ws } = await startServerWithClient();
|
const { server, ws } = await startServerWithClient();
|
||||||
ws.send(
|
await connectOk(ws);
|
||||||
JSON.stringify({
|
|
||||||
type: "hello",
|
|
||||||
minProtocol: 1,
|
|
||||||
maxProtocol: 1,
|
|
||||||
client: { name: "test", version: "1", platform: "test", mode: "test" },
|
|
||||||
caps: [],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await onceMessage(ws, (o) => o.type === "hello-ok");
|
|
||||||
|
|
||||||
const reqId = "chat-img";
|
const reqId = "chat-img";
|
||||||
ws.send(
|
ws.send(
|
||||||
@@ -916,16 +837,7 @@ describe("gateway server", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { server, ws } = await startServerWithClient();
|
const { server, ws } = await startServerWithClient();
|
||||||
ws.send(
|
await connectOk(ws);
|
||||||
JSON.stringify({
|
|
||||||
type: "hello",
|
|
||||||
minProtocol: 1,
|
|
||||||
maxProtocol: 1,
|
|
||||||
client: { name: "test", version: "1", platform: "test", mode: "test" },
|
|
||||||
caps: [],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await onceMessage(ws, (o) => o.type === "hello-ok");
|
|
||||||
|
|
||||||
const reqId = "chat-route";
|
const reqId = "chat-route";
|
||||||
ws.send(
|
ws.send(
|
||||||
@@ -961,22 +873,15 @@ describe("gateway server", () => {
|
|||||||
|
|
||||||
test("presence includes client fingerprint", async () => {
|
test("presence includes client fingerprint", async () => {
|
||||||
const { server, ws } = await startServerWithClient();
|
const { server, ws } = await startServerWithClient();
|
||||||
ws.send(
|
await connectOk(ws, {
|
||||||
JSON.stringify({
|
client: {
|
||||||
type: "hello",
|
name: "fingerprint",
|
||||||
minProtocol: 1,
|
version: "9.9.9",
|
||||||
maxProtocol: 1,
|
platform: "test",
|
||||||
client: {
|
mode: "ui",
|
||||||
name: "fingerprint",
|
instanceId: "abc",
|
||||||
version: "9.9.9",
|
},
|
||||||
platform: "test",
|
});
|
||||||
mode: "ui",
|
|
||||||
instanceId: "abc",
|
|
||||||
},
|
|
||||||
caps: [],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await onceMessage(ws, (o) => o.type === "hello-ok");
|
|
||||||
|
|
||||||
const presenceP = onceMessage(
|
const presenceP = onceMessage(
|
||||||
ws,
|
ws,
|
||||||
@@ -1005,22 +910,15 @@ describe("gateway server", () => {
|
|||||||
test("cli connections are not tracked as instances", async () => {
|
test("cli connections are not tracked as instances", async () => {
|
||||||
const { server, ws } = await startServerWithClient();
|
const { server, ws } = await startServerWithClient();
|
||||||
const cliId = `cli-${randomUUID()}`;
|
const cliId = `cli-${randomUUID()}`;
|
||||||
ws.send(
|
await connectOk(ws, {
|
||||||
JSON.stringify({
|
client: {
|
||||||
type: "hello",
|
name: "cli",
|
||||||
minProtocol: 1,
|
version: "dev",
|
||||||
maxProtocol: 1,
|
platform: "test",
|
||||||
client: {
|
mode: "cli",
|
||||||
name: "cli",
|
instanceId: cliId,
|
||||||
version: "dev",
|
},
|
||||||
platform: "test",
|
});
|
||||||
mode: "cli",
|
|
||||||
instanceId: cliId,
|
|
||||||
},
|
|
||||||
caps: [],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await onceMessage(ws, (o) => o.type === "hello-ok");
|
|
||||||
|
|
||||||
const presenceP = onceMessage(
|
const presenceP = onceMessage(
|
||||||
ws,
|
ws,
|
||||||
|
|||||||
@@ -39,25 +39,25 @@ import { sendMessageWhatsApp } from "../web/outbound.js";
|
|||||||
import { ensureWebChatServerFromConfig } from "../webchat/server.js";
|
import { ensureWebChatServerFromConfig } from "../webchat/server.js";
|
||||||
import { buildMessageWithAttachments } from "./chat-attachments.js";
|
import { buildMessageWithAttachments } from "./chat-attachments.js";
|
||||||
import {
|
import {
|
||||||
|
type ConnectParams,
|
||||||
ErrorCodes,
|
ErrorCodes,
|
||||||
type ErrorShape,
|
type ErrorShape,
|
||||||
errorShape,
|
errorShape,
|
||||||
formatValidationErrors,
|
formatValidationErrors,
|
||||||
type Hello,
|
|
||||||
PROTOCOL_VERSION,
|
PROTOCOL_VERSION,
|
||||||
type RequestFrame,
|
type RequestFrame,
|
||||||
type Snapshot,
|
type Snapshot,
|
||||||
validateAgentParams,
|
validateAgentParams,
|
||||||
validateChatHistoryParams,
|
validateChatHistoryParams,
|
||||||
validateChatSendParams,
|
validateChatSendParams,
|
||||||
validateHello,
|
validateConnectParams,
|
||||||
validateRequestFrame,
|
validateRequestFrame,
|
||||||
validateSendParams,
|
validateSendParams,
|
||||||
} from "./protocol/index.js";
|
} from "./protocol/index.js";
|
||||||
|
|
||||||
type Client = {
|
type Client = {
|
||||||
socket: WebSocket;
|
socket: WebSocket;
|
||||||
hello: Hello;
|
connect: ConnectParams;
|
||||||
connId: string;
|
connId: string;
|
||||||
presenceKey?: string;
|
presenceKey?: string;
|
||||||
};
|
};
|
||||||
@@ -502,13 +502,10 @@ export async function startGatewayServer(
|
|||||||
const remoteAddr = (
|
const remoteAddr = (
|
||||||
socket as WebSocket & { _socket?: { remoteAddress?: string } }
|
socket as WebSocket & { _socket?: { remoteAddress?: string } }
|
||||||
)._socket?.remoteAddress;
|
)._socket?.remoteAddress;
|
||||||
logWs("in", "connect", { connId, remoteAddr });
|
logWs("in", "open", { connId, remoteAddr });
|
||||||
const describeHello = (hello: Hello | null | undefined) =>
|
const isWebchatConnect = (params: ConnectParams | null | undefined) =>
|
||||||
hello
|
params?.client?.mode === "webchat" ||
|
||||||
? `${hello.client.name ?? "unknown"} ${hello.client.mode ?? "?"} v${hello.client.version ?? "?"}`
|
params?.client?.name === "webchat-ui";
|
||||||
: "unknown";
|
|
||||||
const isWebchatHello = (hello: Hello | null | undefined) =>
|
|
||||||
hello?.client?.mode === "webchat" || hello?.client?.name === "webchat-ui";
|
|
||||||
|
|
||||||
const send = (obj: unknown) => {
|
const send = (obj: unknown) => {
|
||||||
try {
|
try {
|
||||||
@@ -539,10 +536,10 @@ export async function startGatewayServer(
|
|||||||
socket.once("close", (code, reason) => {
|
socket.once("close", (code, reason) => {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
logWarn(
|
logWarn(
|
||||||
`gateway/ws closed before hello conn=${connId} remote=${remoteAddr ?? "?"} code=${code ?? "n/a"} reason=${reason?.toString() || "n/a"}`,
|
`gateway/ws closed before connect conn=${connId} remote=${remoteAddr ?? "?"} code=${code ?? "n/a"} reason=${reason?.toString() || "n/a"}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (client && isWebchatHello(client.hello)) {
|
if (client && isWebchatConnect(client.connect)) {
|
||||||
logInfo(
|
logInfo(
|
||||||
`webchat disconnected code=${code} reason=${reason?.toString() || "n/a"} conn=${connId}`,
|
`webchat disconnected code=${code} reason=${reason?.toString() || "n/a"} conn=${connId}`,
|
||||||
);
|
);
|
||||||
@@ -585,91 +582,115 @@ export async function startGatewayServer(
|
|||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(text);
|
const parsed = JSON.parse(text);
|
||||||
if (!client) {
|
if (!client) {
|
||||||
// Expect hello
|
// Handshake must be a normal request:
|
||||||
if (!validateHello(parsed)) {
|
// { type:"req", method:"connect", params: ConnectParams }.
|
||||||
logWarn(
|
if (
|
||||||
`gateway/ws invalid hello conn=${connId} remote=${remoteAddr ?? "?"}`,
|
!validateRequestFrame(parsed) ||
|
||||||
);
|
(parsed as RequestFrame).method !== "connect" ||
|
||||||
send({
|
!validateConnectParams((parsed as RequestFrame).params)
|
||||||
type: "hello-error",
|
) {
|
||||||
reason: `invalid hello: ${formatValidationErrors(validateHello.errors)}`,
|
if (validateRequestFrame(parsed)) {
|
||||||
});
|
const req = parsed as RequestFrame;
|
||||||
socket.close(1008, "invalid hello");
|
send({
|
||||||
|
type: "res",
|
||||||
|
id: req.id,
|
||||||
|
ok: false,
|
||||||
|
error: errorShape(
|
||||||
|
ErrorCodes.INVALID_REQUEST,
|
||||||
|
req.method === "connect"
|
||||||
|
? `invalid connect params: ${formatValidationErrors(validateConnectParams.errors)}`
|
||||||
|
: "invalid handshake: first request must be connect",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logWarn(
|
||||||
|
`gateway/ws invalid handshake conn=${connId} remote=${remoteAddr ?? "?"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
socket.close(1008, "invalid handshake");
|
||||||
close();
|
close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const hello = parsed as Hello;
|
|
||||||
|
const req = parsed as RequestFrame;
|
||||||
|
const connectParams = req.params as ConnectParams;
|
||||||
|
|
||||||
// protocol negotiation
|
// protocol negotiation
|
||||||
const { minProtocol, maxProtocol } = hello;
|
const { minProtocol, maxProtocol } = connectParams;
|
||||||
if (
|
if (
|
||||||
maxProtocol < PROTOCOL_VERSION ||
|
maxProtocol < PROTOCOL_VERSION ||
|
||||||
minProtocol > PROTOCOL_VERSION
|
minProtocol > PROTOCOL_VERSION
|
||||||
) {
|
) {
|
||||||
logWarn(
|
logWarn(
|
||||||
`gateway/ws protocol mismatch conn=${connId} remote=${remoteAddr ?? "?"} client=${describeHello(hello)}`,
|
`gateway/ws protocol mismatch conn=${connId} remote=${remoteAddr ?? "?"} client=${connectParams.client.name} ${connectParams.client.mode} v${connectParams.client.version}`,
|
||||||
);
|
);
|
||||||
logWs("out", "hello-error", {
|
|
||||||
connId,
|
|
||||||
reason: "protocol mismatch",
|
|
||||||
minProtocol,
|
|
||||||
maxProtocol,
|
|
||||||
expected: PROTOCOL_VERSION,
|
|
||||||
});
|
|
||||||
send({
|
send({
|
||||||
type: "hello-error",
|
type: "res",
|
||||||
reason: "protocol mismatch",
|
id: req.id,
|
||||||
expectedProtocol: PROTOCOL_VERSION,
|
ok: false,
|
||||||
|
error: errorShape(
|
||||||
|
ErrorCodes.INVALID_REQUEST,
|
||||||
|
"protocol mismatch",
|
||||||
|
{
|
||||||
|
details: { expectedProtocol: PROTOCOL_VERSION },
|
||||||
|
},
|
||||||
|
),
|
||||||
});
|
});
|
||||||
socket.close(1002, "protocol mismatch");
|
socket.close(1002, "protocol mismatch");
|
||||||
close();
|
close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// token auth if required
|
// token auth if required
|
||||||
const token = getGatewayToken();
|
const token = getGatewayToken();
|
||||||
if (token && hello.auth?.token !== token) {
|
if (token && connectParams.auth?.token !== token) {
|
||||||
logWarn(
|
logWarn(
|
||||||
`gateway/ws unauthorized conn=${connId} remote=${remoteAddr ?? "?"} client=${describeHello(hello)}`,
|
`gateway/ws unauthorized conn=${connId} remote=${remoteAddr ?? "?"} client=${connectParams.client.name} ${connectParams.client.mode} v${connectParams.client.version}`,
|
||||||
);
|
);
|
||||||
logWs("out", "hello-error", { connId, reason: "unauthorized" });
|
|
||||||
send({
|
send({
|
||||||
type: "hello-error",
|
type: "res",
|
||||||
reason: "unauthorized",
|
id: req.id,
|
||||||
|
ok: false,
|
||||||
|
error: errorShape(ErrorCodes.INVALID_REQUEST, "unauthorized"),
|
||||||
});
|
});
|
||||||
socket.close(1008, "unauthorized");
|
socket.close(1008, "unauthorized");
|
||||||
close();
|
close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldTrackPresence = hello.client.mode !== "cli";
|
const shouldTrackPresence = connectParams.client.mode !== "cli";
|
||||||
// synthesize presence entry for this connection (client fingerprint)
|
|
||||||
const presenceKey = shouldTrackPresence
|
const presenceKey = shouldTrackPresence
|
||||||
? hello.client.instanceId || connId
|
? connectParams.client.instanceId || connId
|
||||||
: undefined;
|
: undefined;
|
||||||
logWs("in", "hello", {
|
|
||||||
|
logWs("in", "connect", {
|
||||||
connId,
|
connId,
|
||||||
client: hello.client.name,
|
client: connectParams.client.name,
|
||||||
version: hello.client.version,
|
version: connectParams.client.version,
|
||||||
mode: hello.client.mode,
|
mode: connectParams.client.mode,
|
||||||
instanceId: hello.client.instanceId,
|
instanceId: connectParams.client.instanceId,
|
||||||
platform: hello.client.platform,
|
platform: connectParams.client.platform,
|
||||||
token: hello.auth?.token ? "set" : "none",
|
token: connectParams.auth?.token ? "set" : "none",
|
||||||
});
|
});
|
||||||
if (isWebchatHello(hello)) {
|
|
||||||
|
if (isWebchatConnect(connectParams)) {
|
||||||
logInfo(
|
logInfo(
|
||||||
`webchat connected conn=${connId} remote=${remoteAddr ?? "?"} client=${describeHello(hello)}`,
|
`webchat connected conn=${connId} remote=${remoteAddr ?? "?"} client=${connectParams.client.name} ${connectParams.client.mode} v${connectParams.client.version}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (presenceKey) {
|
if (presenceKey) {
|
||||||
upsertPresence(presenceKey, {
|
upsertPresence(presenceKey, {
|
||||||
host: hello.client.name || os.hostname(),
|
host: connectParams.client.name || os.hostname(),
|
||||||
ip: isLoopbackAddress(remoteAddr) ? undefined : remoteAddr,
|
ip: isLoopbackAddress(remoteAddr) ? undefined : remoteAddr,
|
||||||
version: hello.client.version,
|
version: connectParams.client.version,
|
||||||
mode: hello.client.mode,
|
mode: connectParams.client.mode,
|
||||||
instanceId: hello.client.instanceId,
|
instanceId: connectParams.client.instanceId,
|
||||||
reason: "connect",
|
reason: "connect",
|
||||||
});
|
});
|
||||||
presenceVersion += 1;
|
presenceVersion += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = buildSnapshot();
|
const snapshot = buildSnapshot();
|
||||||
if (healthCache) {
|
if (healthCache) {
|
||||||
snapshot.health = healthCache;
|
snapshot.health = healthCache;
|
||||||
@@ -695,10 +716,10 @@ export async function startGatewayServer(
|
|||||||
tickIntervalMs: TICK_INTERVAL_MS,
|
tickIntervalMs: TICK_INTERVAL_MS,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
clearTimeout(handshakeTimer);
|
clearTimeout(handshakeTimer);
|
||||||
// Add the client only after the hello response is ready so no tick/presence
|
client = { socket, connect: connectParams, connId, presenceKey };
|
||||||
// events reach it before the handshake completes.
|
|
||||||
client = { socket, hello, connId, presenceKey };
|
|
||||||
logWs("out", "hello-ok", {
|
logWs("out", "hello-ok", {
|
||||||
connId,
|
connId,
|
||||||
methods: METHODS.length,
|
methods: METHODS.length,
|
||||||
@@ -706,11 +727,12 @@ export async function startGatewayServer(
|
|||||||
presence: snapshot.presence.length,
|
presence: snapshot.presence.length,
|
||||||
stateVersion: snapshot.stateVersion.presence,
|
stateVersion: snapshot.stateVersion.presence,
|
||||||
});
|
});
|
||||||
send(helloOk);
|
|
||||||
|
send({ type: "res", id: req.id, ok: true, payload: helloOk });
|
||||||
|
|
||||||
clients.add(client);
|
clients.add(client);
|
||||||
// Kick a health refresh in the background to keep cache warm.
|
|
||||||
void refreshHealthSnapshot({ probe: true }).catch((err) =>
|
void refreshHealthSnapshot({ probe: true }).catch((err) =>
|
||||||
logError(`post-hello health refresh failed: ${formatError(err)}`),
|
logError(`post-connect health refresh failed: ${formatError(err)}`),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -751,6 +773,17 @@ export async function startGatewayServer(
|
|||||||
};
|
};
|
||||||
|
|
||||||
switch (req.method) {
|
switch (req.method) {
|
||||||
|
case "connect": {
|
||||||
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(
|
||||||
|
ErrorCodes.INVALID_REQUEST,
|
||||||
|
"connect is only valid as the first request",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "health": {
|
case "health": {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const cached = healthCache;
|
const cached = healthCache;
|
||||||
|
|||||||
Reference in New Issue
Block a user