From 9dbc1435a6cac576d5fd71f4e4bff11a5d9d43ba Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 20 Jan 2026 09:23:56 +0000 Subject: [PATCH] fix: enforce ws3 roles + node allowlist --- .../ClawdbotProtocol/GatewayModels.swift | 2269 +++++++++++++++++ docs/gateway/configuration.md | 23 + docs/gateway/protocol.md | 6 + ...board-non-interactive.gateway-auth.test.ts | 24 + ...ard-non-interactive.lan-auto-token.test.ts | 24 + src/config/schema.ts | 6 + src/config/types.gateway.ts | 8 + src/config/zod-schema.ts | 7 + src/gateway/client.ts | 11 +- src/gateway/gateway.wizard.e2e.test.ts | 24 + src/gateway/node-command-policy.ts | 110 + src/gateway/node-registry.ts | 1 + src/gateway/protocol/index.ts | 7 + .../protocol/schema/agents-models-skills.ts | 9 + src/gateway/protocol/schema/frames.ts | 18 +- .../protocol/schema/protocol-schemas.ts | 4 + src/gateway/protocol/schema/types.ts | 4 + src/gateway/server-methods-list.ts | 1 + src/gateway/server-methods.ts | 72 +- src/gateway/server-methods/nodes.ts | 45 +- src/gateway/server-methods/skills.ts | 62 + src/gateway/server.auth.test.ts | 6 + src/gateway/server.nodes.allowlist.test.ts | 147 ++ src/gateway/server.roles.test.ts | 61 + .../server/ws-connection/message-handler.ts | 119 +- src/gateway/test-helpers.server.ts | 50 +- src/infra/device-pairing.ts | 18 +- 27 files changed, 3096 insertions(+), 40 deletions(-) create mode 100644 apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift create mode 100644 src/gateway/node-command-policy.ts create mode 100644 src/gateway/server.nodes.allowlist.test.ts create mode 100644 src/gateway/server.roles.test.ts diff --git a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift new file mode 100644 index 000000000..ea9b5a776 --- /dev/null +++ b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift @@ -0,0 +1,2269 @@ +// Generated by scripts/protocol-gen-swift.ts — do not edit by hand +import Foundation + +public let GATEWAY_PROTOCOL_VERSION = 3 + +public enum ErrorCode: String, Codable, Sendable { + case notLinked = "NOT_LINKED" + case notPaired = "NOT_PAIRED" + case agentTimeout = "AGENT_TIMEOUT" + case invalidRequest = "INVALID_REQUEST" + case unavailable = "UNAVAILABLE" +} + +public struct ConnectParams: Codable, Sendable { + public let minprotocol: Int + public let maxprotocol: Int + public let client: [String: AnyCodable] + public let caps: [String]? + public let commands: [String]? + public let permissions: [String: AnyCodable]? + public let role: String? + public let scopes: [String]? + public let device: [String: AnyCodable] + public let auth: [String: AnyCodable]? + public let locale: String? + public let useragent: String? + + public init( + minprotocol: Int, + maxprotocol: Int, + client: [String: AnyCodable], + caps: [String]?, + commands: [String]?, + permissions: [String: AnyCodable]?, + role: String?, + scopes: [String]?, + device: [String: AnyCodable], + auth: [String: AnyCodable]?, + locale: String?, + useragent: String? + ) { + self.minprotocol = minprotocol + self.maxprotocol = maxprotocol + self.client = client + self.caps = caps + self.commands = commands + self.permissions = permissions + self.role = role + self.scopes = scopes + self.device = device + self.auth = auth + self.locale = locale + self.useragent = useragent + } + private enum CodingKeys: String, CodingKey { + case minprotocol = "minProtocol" + case maxprotocol = "maxProtocol" + case client + case caps + case commands + case permissions + case role + case scopes + case device + case auth + case locale + case useragent = "userAgent" + } +} + +public struct HelloOk: Codable, Sendable { + public let type: String + public let _protocol: Int + public let server: [String: AnyCodable] + public let features: [String: AnyCodable] + public let snapshot: Snapshot + public let canvashosturl: String? + public let policy: [String: AnyCodable] + + public init( + type: String, + _protocol: Int, + server: [String: AnyCodable], + features: [String: AnyCodable], + snapshot: Snapshot, + canvashosturl: String?, + policy: [String: AnyCodable] + ) { + self.type = type + self._protocol = _protocol + self.server = server + self.features = features + self.snapshot = snapshot + self.canvashosturl = canvashosturl + self.policy = policy + } + private enum CodingKeys: String, CodingKey { + case type + case _protocol = "protocol" + case server + case features + case snapshot + case canvashosturl = "canvasHostUrl" + case policy + } +} + +public struct RequestFrame: Codable, Sendable { + public let type: String + public let id: String + public let method: String + public let params: AnyCodable? + + public init( + type: String, + id: String, + method: String, + params: AnyCodable? + ) { + self.type = type + self.id = id + self.method = method + self.params = params + } + private enum CodingKeys: String, CodingKey { + case type + case id + case method + case params + } +} + +public struct ResponseFrame: Codable, Sendable { + public let type: String + public let id: String + public let ok: Bool + public let payload: AnyCodable? + public let error: [String: AnyCodable]? + + public init( + type: String, + id: String, + ok: Bool, + payload: AnyCodable?, + error: [String: AnyCodable]? + ) { + self.type = type + self.id = id + self.ok = ok + self.payload = payload + self.error = error + } + private enum CodingKeys: String, CodingKey { + case type + case id + case ok + case payload + case error + } +} + +public struct EventFrame: Codable, Sendable { + public let type: String + public let event: String + public let payload: AnyCodable? + public let seq: Int? + public let stateversion: [String: AnyCodable]? + + public init( + type: String, + event: String, + payload: AnyCodable?, + seq: Int?, + stateversion: [String: AnyCodable]? + ) { + self.type = type + self.event = event + self.payload = payload + self.seq = seq + self.stateversion = stateversion + } + private enum CodingKeys: String, CodingKey { + case type + case event + case payload + case seq + case stateversion = "stateVersion" + } +} + +public struct PresenceEntry: Codable, Sendable { + public let host: String? + public let ip: String? + public let version: String? + public let platform: String? + public let devicefamily: String? + public let modelidentifier: String? + public let mode: String? + public let lastinputseconds: Int? + public let reason: String? + public let tags: [String]? + public let text: String? + public let ts: Int + public let instanceid: String? + + public init( + host: String?, + ip: String?, + version: String?, + platform: String?, + devicefamily: String?, + modelidentifier: String?, + mode: String?, + lastinputseconds: Int?, + reason: String?, + tags: [String]?, + text: String?, + ts: Int, + instanceid: String? + ) { + self.host = host + self.ip = ip + self.version = version + self.platform = platform + self.devicefamily = devicefamily + self.modelidentifier = modelidentifier + self.mode = mode + self.lastinputseconds = lastinputseconds + self.reason = reason + self.tags = tags + self.text = text + self.ts = ts + self.instanceid = instanceid + } + private enum CodingKeys: String, CodingKey { + case host + case ip + case version + case platform + case devicefamily = "deviceFamily" + case modelidentifier = "modelIdentifier" + case mode + case lastinputseconds = "lastInputSeconds" + case reason + case tags + case text + case ts + case instanceid = "instanceId" + } +} + +public struct StateVersion: Codable, Sendable { + public let presence: Int + public let health: Int + + public init( + presence: Int, + health: Int + ) { + self.presence = presence + self.health = health + } + private enum CodingKeys: String, CodingKey { + case presence + case health + } +} + +public struct Snapshot: Codable, Sendable { + public let presence: [PresenceEntry] + public let health: AnyCodable + public let stateversion: StateVersion + public let uptimems: Int + public let configpath: String? + public let statedir: String? + public let sessiondefaults: [String: AnyCodable]? + + public init( + presence: [PresenceEntry], + health: AnyCodable, + stateversion: StateVersion, + uptimems: Int, + configpath: String?, + statedir: String?, + sessiondefaults: [String: AnyCodable]? + ) { + self.presence = presence + self.health = health + self.stateversion = stateversion + self.uptimems = uptimems + self.configpath = configpath + self.statedir = statedir + self.sessiondefaults = sessiondefaults + } + private enum CodingKeys: String, CodingKey { + case presence + case health + case stateversion = "stateVersion" + case uptimems = "uptimeMs" + case configpath = "configPath" + case statedir = "stateDir" + case sessiondefaults = "sessionDefaults" + } +} + +public struct ErrorShape: Codable, Sendable { + public let code: String + public let message: String + public let details: AnyCodable? + public let retryable: Bool? + public let retryafterms: Int? + + public init( + code: String, + message: String, + details: AnyCodable?, + retryable: Bool?, + retryafterms: Int? + ) { + self.code = code + self.message = message + self.details = details + self.retryable = retryable + self.retryafterms = retryafterms + } + private enum CodingKeys: String, CodingKey { + case code + case message + case details + case retryable + case retryafterms = "retryAfterMs" + } +} + +public struct AgentEvent: Codable, Sendable { + public let runid: String + public let seq: Int + public let stream: String + public let ts: Int + public let data: [String: AnyCodable] + + public init( + runid: String, + seq: Int, + stream: String, + ts: Int, + data: [String: AnyCodable] + ) { + self.runid = runid + self.seq = seq + self.stream = stream + self.ts = ts + self.data = data + } + private enum CodingKeys: String, CodingKey { + case runid = "runId" + case seq + case stream + case ts + case data + } +} + +public struct SendParams: Codable, Sendable { + public let to: String + public let message: String + public let mediaurl: String? + public let gifplayback: Bool? + public let channel: String? + public let accountid: String? + public let sessionkey: String? + public let idempotencykey: String + + public init( + to: String, + message: String, + mediaurl: String?, + gifplayback: Bool?, + channel: String?, + accountid: String?, + sessionkey: String?, + idempotencykey: String + ) { + self.to = to + self.message = message + self.mediaurl = mediaurl + self.gifplayback = gifplayback + self.channel = channel + self.accountid = accountid + self.sessionkey = sessionkey + self.idempotencykey = idempotencykey + } + private enum CodingKeys: String, CodingKey { + case to + case message + case mediaurl = "mediaUrl" + case gifplayback = "gifPlayback" + case channel + case accountid = "accountId" + case sessionkey = "sessionKey" + case idempotencykey = "idempotencyKey" + } +} + +public struct PollParams: Codable, Sendable { + public let to: String + public let question: String + public let options: [String] + public let maxselections: Int? + public let durationhours: Int? + public let channel: String? + public let accountid: String? + public let idempotencykey: String + + public init( + to: String, + question: String, + options: [String], + maxselections: Int?, + durationhours: Int?, + channel: String?, + accountid: String?, + idempotencykey: String + ) { + self.to = to + self.question = question + self.options = options + self.maxselections = maxselections + self.durationhours = durationhours + self.channel = channel + self.accountid = accountid + self.idempotencykey = idempotencykey + } + private enum CodingKeys: String, CodingKey { + case to + case question + case options + case maxselections = "maxSelections" + case durationhours = "durationHours" + case channel + case accountid = "accountId" + case idempotencykey = "idempotencyKey" + } +} + +public struct AgentParams: Codable, Sendable { + public let message: String + public let agentid: String? + public let to: String? + public let replyto: String? + public let sessionid: String? + public let sessionkey: String? + public let thinking: String? + public let deliver: Bool? + public let attachments: [AnyCodable]? + public let channel: String? + public let replychannel: String? + public let accountid: String? + public let replyaccountid: String? + public let timeout: Int? + public let lane: String? + public let extrasystemprompt: String? + public let idempotencykey: String + public let label: String? + public let spawnedby: String? + + public init( + message: String, + agentid: String?, + to: String?, + replyto: String?, + sessionid: String?, + sessionkey: String?, + thinking: String?, + deliver: Bool?, + attachments: [AnyCodable]?, + channel: String?, + replychannel: String?, + accountid: String?, + replyaccountid: String?, + timeout: Int?, + lane: String?, + extrasystemprompt: String?, + idempotencykey: String, + label: String?, + spawnedby: String? + ) { + self.message = message + self.agentid = agentid + self.to = to + self.replyto = replyto + self.sessionid = sessionid + self.sessionkey = sessionkey + self.thinking = thinking + self.deliver = deliver + self.attachments = attachments + self.channel = channel + self.replychannel = replychannel + self.accountid = accountid + self.replyaccountid = replyaccountid + self.timeout = timeout + self.lane = lane + self.extrasystemprompt = extrasystemprompt + self.idempotencykey = idempotencykey + self.label = label + self.spawnedby = spawnedby + } + private enum CodingKeys: String, CodingKey { + case message + case agentid = "agentId" + case to + case replyto = "replyTo" + case sessionid = "sessionId" + case sessionkey = "sessionKey" + case thinking + case deliver + case attachments + case channel + case replychannel = "replyChannel" + case accountid = "accountId" + case replyaccountid = "replyAccountId" + case timeout + case lane + case extrasystemprompt = "extraSystemPrompt" + case idempotencykey = "idempotencyKey" + case label + case spawnedby = "spawnedBy" + } +} + +public struct AgentWaitParams: Codable, Sendable { + public let runid: String + public let timeoutms: Int? + + public init( + runid: String, + timeoutms: Int? + ) { + self.runid = runid + self.timeoutms = timeoutms + } + private enum CodingKeys: String, CodingKey { + case runid = "runId" + case timeoutms = "timeoutMs" + } +} + +public struct WakeParams: Codable, Sendable { + public let mode: AnyCodable + public let text: String + + public init( + mode: AnyCodable, + text: String + ) { + self.mode = mode + self.text = text + } + private enum CodingKeys: String, CodingKey { + case mode + case text + } +} + +public struct NodePairRequestParams: Codable, Sendable { + public let nodeid: String + public let displayname: String? + public let platform: String? + public let version: String? + public let coreversion: String? + public let uiversion: String? + public let devicefamily: String? + public let modelidentifier: String? + public let caps: [String]? + public let commands: [String]? + public let remoteip: String? + public let silent: Bool? + + public init( + nodeid: String, + displayname: String?, + platform: String?, + version: String?, + coreversion: String?, + uiversion: String?, + devicefamily: String?, + modelidentifier: String?, + caps: [String]?, + commands: [String]?, + remoteip: String?, + silent: Bool? + ) { + self.nodeid = nodeid + self.displayname = displayname + self.platform = platform + self.version = version + self.coreversion = coreversion + self.uiversion = uiversion + self.devicefamily = devicefamily + self.modelidentifier = modelidentifier + self.caps = caps + self.commands = commands + self.remoteip = remoteip + self.silent = silent + } + private enum CodingKeys: String, CodingKey { + case nodeid = "nodeId" + case displayname = "displayName" + case platform + case version + case coreversion = "coreVersion" + case uiversion = "uiVersion" + case devicefamily = "deviceFamily" + case modelidentifier = "modelIdentifier" + case caps + case commands + case remoteip = "remoteIp" + case silent + } +} + +public struct NodePairListParams: Codable, Sendable { +} + +public struct NodePairApproveParams: Codable, Sendable { + public let requestid: String + + public init( + requestid: String + ) { + self.requestid = requestid + } + private enum CodingKeys: String, CodingKey { + case requestid = "requestId" + } +} + +public struct NodePairRejectParams: Codable, Sendable { + public let requestid: String + + public init( + requestid: String + ) { + self.requestid = requestid + } + private enum CodingKeys: String, CodingKey { + case requestid = "requestId" + } +} + +public struct NodePairVerifyParams: Codable, Sendable { + public let nodeid: String + public let token: String + + public init( + nodeid: String, + token: String + ) { + self.nodeid = nodeid + self.token = token + } + private enum CodingKeys: String, CodingKey { + case nodeid = "nodeId" + case token + } +} + +public struct NodeRenameParams: Codable, Sendable { + public let nodeid: String + public let displayname: String + + public init( + nodeid: String, + displayname: String + ) { + self.nodeid = nodeid + self.displayname = displayname + } + private enum CodingKeys: String, CodingKey { + case nodeid = "nodeId" + case displayname = "displayName" + } +} + +public struct NodeListParams: Codable, Sendable { +} + +public struct NodeDescribeParams: Codable, Sendable { + public let nodeid: String + + public init( + nodeid: String + ) { + self.nodeid = nodeid + } + private enum CodingKeys: String, CodingKey { + case nodeid = "nodeId" + } +} + +public struct NodeInvokeParams: Codable, Sendable { + public let nodeid: String + public let command: String + public let params: AnyCodable? + public let timeoutms: Int? + public let idempotencykey: String + + public init( + nodeid: String, + command: String, + params: AnyCodable?, + timeoutms: Int?, + idempotencykey: String + ) { + self.nodeid = nodeid + self.command = command + self.params = params + self.timeoutms = timeoutms + self.idempotencykey = idempotencykey + } + private enum CodingKeys: String, CodingKey { + case nodeid = "nodeId" + case command + case params + case timeoutms = "timeoutMs" + case idempotencykey = "idempotencyKey" + } +} + +public struct NodeInvokeResultParams: Codable, Sendable { + public let id: String + public let nodeid: String + public let ok: Bool + public let payload: AnyCodable? + public let payloadjson: String? + public let error: [String: AnyCodable]? + + public init( + id: String, + nodeid: String, + ok: Bool, + payload: AnyCodable?, + payloadjson: String?, + error: [String: AnyCodable]? + ) { + self.id = id + self.nodeid = nodeid + self.ok = ok + self.payload = payload + self.payloadjson = payloadjson + self.error = error + } + private enum CodingKeys: String, CodingKey { + case id + case nodeid = "nodeId" + case ok + case payload + case payloadjson = "payloadJSON" + case error + } +} + +public struct NodeEventParams: Codable, Sendable { + public let event: String + public let payload: AnyCodable? + public let payloadjson: String? + + public init( + event: String, + payload: AnyCodable?, + payloadjson: String? + ) { + self.event = event + self.payload = payload + self.payloadjson = payloadjson + } + private enum CodingKeys: String, CodingKey { + case event + case payload + case payloadjson = "payloadJSON" + } +} + +public struct NodeInvokeRequestEvent: Codable, Sendable { + public let id: String + public let nodeid: String + public let command: String + public let paramsjson: String? + public let timeoutms: Int? + public let idempotencykey: String? + + public init( + id: String, + nodeid: String, + command: String, + paramsjson: String?, + timeoutms: Int?, + idempotencykey: String? + ) { + self.id = id + self.nodeid = nodeid + self.command = command + self.paramsjson = paramsjson + self.timeoutms = timeoutms + self.idempotencykey = idempotencykey + } + private enum CodingKeys: String, CodingKey { + case id + case nodeid = "nodeId" + case command + case paramsjson = "paramsJSON" + case timeoutms = "timeoutMs" + case idempotencykey = "idempotencyKey" + } +} + +public struct SessionsListParams: Codable, Sendable { + public let limit: Int? + public let activeminutes: Int? + public let includeglobal: Bool? + public let includeunknown: Bool? + public let label: String? + public let spawnedby: String? + public let agentid: String? + + public init( + limit: Int?, + activeminutes: Int?, + includeglobal: Bool?, + includeunknown: Bool?, + label: String?, + spawnedby: String?, + agentid: String? + ) { + self.limit = limit + self.activeminutes = activeminutes + self.includeglobal = includeglobal + self.includeunknown = includeunknown + self.label = label + self.spawnedby = spawnedby + self.agentid = agentid + } + private enum CodingKeys: String, CodingKey { + case limit + case activeminutes = "activeMinutes" + case includeglobal = "includeGlobal" + case includeunknown = "includeUnknown" + case label + case spawnedby = "spawnedBy" + case agentid = "agentId" + } +} + +public struct SessionsResolveParams: Codable, Sendable { + public let key: String? + public let label: String? + public let agentid: String? + public let spawnedby: String? + public let includeglobal: Bool? + public let includeunknown: Bool? + + public init( + key: String?, + label: String?, + agentid: String?, + spawnedby: String?, + includeglobal: Bool?, + includeunknown: Bool? + ) { + self.key = key + self.label = label + self.agentid = agentid + self.spawnedby = spawnedby + self.includeglobal = includeglobal + self.includeunknown = includeunknown + } + private enum CodingKeys: String, CodingKey { + case key + case label + case agentid = "agentId" + case spawnedby = "spawnedBy" + case includeglobal = "includeGlobal" + case includeunknown = "includeUnknown" + } +} + +public struct SessionsPatchParams: Codable, Sendable { + public let key: String + public let label: AnyCodable? + public let thinkinglevel: AnyCodable? + public let verboselevel: AnyCodable? + public let reasoninglevel: AnyCodable? + public let responseusage: AnyCodable? + public let elevatedlevel: AnyCodable? + public let exechost: AnyCodable? + public let execsecurity: AnyCodable? + public let execask: AnyCodable? + public let execnode: AnyCodable? + public let model: AnyCodable? + public let spawnedby: AnyCodable? + public let sendpolicy: AnyCodable? + public let groupactivation: AnyCodable? + + public init( + key: String, + label: AnyCodable?, + thinkinglevel: AnyCodable?, + verboselevel: AnyCodable?, + reasoninglevel: AnyCodable?, + responseusage: AnyCodable?, + elevatedlevel: AnyCodable?, + exechost: AnyCodable?, + execsecurity: AnyCodable?, + execask: AnyCodable?, + execnode: AnyCodable?, + model: AnyCodable?, + spawnedby: AnyCodable?, + sendpolicy: AnyCodable?, + groupactivation: AnyCodable? + ) { + self.key = key + self.label = label + self.thinkinglevel = thinkinglevel + self.verboselevel = verboselevel + self.reasoninglevel = reasoninglevel + self.responseusage = responseusage + self.elevatedlevel = elevatedlevel + self.exechost = exechost + self.execsecurity = execsecurity + self.execask = execask + self.execnode = execnode + self.model = model + self.spawnedby = spawnedby + self.sendpolicy = sendpolicy + self.groupactivation = groupactivation + } + private enum CodingKeys: String, CodingKey { + case key + case label + case thinkinglevel = "thinkingLevel" + case verboselevel = "verboseLevel" + case reasoninglevel = "reasoningLevel" + case responseusage = "responseUsage" + case elevatedlevel = "elevatedLevel" + case exechost = "execHost" + case execsecurity = "execSecurity" + case execask = "execAsk" + case execnode = "execNode" + case model + case spawnedby = "spawnedBy" + case sendpolicy = "sendPolicy" + case groupactivation = "groupActivation" + } +} + +public struct SessionsResetParams: Codable, Sendable { + public let key: String + + public init( + key: String + ) { + self.key = key + } + private enum CodingKeys: String, CodingKey { + case key + } +} + +public struct SessionsDeleteParams: Codable, Sendable { + public let key: String + public let deletetranscript: Bool? + + public init( + key: String, + deletetranscript: Bool? + ) { + self.key = key + self.deletetranscript = deletetranscript + } + private enum CodingKeys: String, CodingKey { + case key + case deletetranscript = "deleteTranscript" + } +} + +public struct SessionsCompactParams: Codable, Sendable { + public let key: String + public let maxlines: Int? + + public init( + key: String, + maxlines: Int? + ) { + self.key = key + self.maxlines = maxlines + } + private enum CodingKeys: String, CodingKey { + case key + case maxlines = "maxLines" + } +} + +public struct ConfigGetParams: Codable, Sendable { +} + +public struct ConfigSetParams: Codable, Sendable { + public let raw: String + public let basehash: String? + + public init( + raw: String, + basehash: String? + ) { + self.raw = raw + self.basehash = basehash + } + private enum CodingKeys: String, CodingKey { + case raw + case basehash = "baseHash" + } +} + +public struct ConfigApplyParams: Codable, Sendable { + public let raw: String + public let basehash: String? + public let sessionkey: String? + public let note: String? + public let restartdelayms: Int? + + public init( + raw: String, + basehash: String?, + sessionkey: String?, + note: String?, + restartdelayms: Int? + ) { + self.raw = raw + self.basehash = basehash + self.sessionkey = sessionkey + self.note = note + self.restartdelayms = restartdelayms + } + private enum CodingKeys: String, CodingKey { + case raw + case basehash = "baseHash" + case sessionkey = "sessionKey" + case note + case restartdelayms = "restartDelayMs" + } +} + +public struct ConfigPatchParams: Codable, Sendable { + public let raw: String + public let basehash: String? + + public init( + raw: String, + basehash: String? + ) { + self.raw = raw + self.basehash = basehash + } + private enum CodingKeys: String, CodingKey { + case raw + case basehash = "baseHash" + } +} + +public struct ConfigSchemaParams: Codable, Sendable { +} + +public struct ConfigSchemaResponse: Codable, Sendable { + public let schema: AnyCodable + public let uihints: [String: AnyCodable] + public let version: String + public let generatedat: String + + public init( + schema: AnyCodable, + uihints: [String: AnyCodable], + version: String, + generatedat: String + ) { + self.schema = schema + self.uihints = uihints + self.version = version + self.generatedat = generatedat + } + private enum CodingKeys: String, CodingKey { + case schema + case uihints = "uiHints" + case version + case generatedat = "generatedAt" + } +} + +public struct WizardStartParams: Codable, Sendable { + public let mode: AnyCodable? + public let workspace: String? + + public init( + mode: AnyCodable?, + workspace: String? + ) { + self.mode = mode + self.workspace = workspace + } + private enum CodingKeys: String, CodingKey { + case mode + case workspace + } +} + +public struct WizardNextParams: Codable, Sendable { + public let sessionid: String + public let answer: [String: AnyCodable]? + + public init( + sessionid: String, + answer: [String: AnyCodable]? + ) { + self.sessionid = sessionid + self.answer = answer + } + private enum CodingKeys: String, CodingKey { + case sessionid = "sessionId" + case answer + } +} + +public struct WizardCancelParams: Codable, Sendable { + public let sessionid: String + + public init( + sessionid: String + ) { + self.sessionid = sessionid + } + private enum CodingKeys: String, CodingKey { + case sessionid = "sessionId" + } +} + +public struct WizardStatusParams: Codable, Sendable { + public let sessionid: String + + public init( + sessionid: String + ) { + self.sessionid = sessionid + } + private enum CodingKeys: String, CodingKey { + case sessionid = "sessionId" + } +} + +public struct WizardStep: Codable, Sendable { + public let id: String + public let type: AnyCodable + public let title: String? + public let message: String? + public let options: [[String: AnyCodable]]? + public let initialvalue: AnyCodable? + public let placeholder: String? + public let sensitive: Bool? + public let executor: AnyCodable? + + public init( + id: String, + type: AnyCodable, + title: String?, + message: String?, + options: [[String: AnyCodable]]?, + initialvalue: AnyCodable?, + placeholder: String?, + sensitive: Bool?, + executor: AnyCodable? + ) { + self.id = id + self.type = type + self.title = title + self.message = message + self.options = options + self.initialvalue = initialvalue + self.placeholder = placeholder + self.sensitive = sensitive + self.executor = executor + } + private enum CodingKeys: String, CodingKey { + case id + case type + case title + case message + case options + case initialvalue = "initialValue" + case placeholder + case sensitive + case executor + } +} + +public struct WizardNextResult: Codable, Sendable { + public let done: Bool + public let step: [String: AnyCodable]? + public let status: AnyCodable? + public let error: String? + + public init( + done: Bool, + step: [String: AnyCodable]?, + status: AnyCodable?, + error: String? + ) { + self.done = done + self.step = step + self.status = status + self.error = error + } + private enum CodingKeys: String, CodingKey { + case done + case step + case status + case error + } +} + +public struct WizardStartResult: Codable, Sendable { + public let sessionid: String + public let done: Bool + public let step: [String: AnyCodable]? + public let status: AnyCodable? + public let error: String? + + public init( + sessionid: String, + done: Bool, + step: [String: AnyCodable]?, + status: AnyCodable?, + error: String? + ) { + self.sessionid = sessionid + self.done = done + self.step = step + self.status = status + self.error = error + } + private enum CodingKeys: String, CodingKey { + case sessionid = "sessionId" + case done + case step + case status + case error + } +} + +public struct WizardStatusResult: Codable, Sendable { + public let status: AnyCodable + public let error: String? + + public init( + status: AnyCodable, + error: String? + ) { + self.status = status + self.error = error + } + private enum CodingKeys: String, CodingKey { + case status + case error + } +} + +public struct TalkModeParams: Codable, Sendable { + public let enabled: Bool + public let phase: String? + + public init( + enabled: Bool, + phase: String? + ) { + self.enabled = enabled + self.phase = phase + } + private enum CodingKeys: String, CodingKey { + case enabled + case phase + } +} + +public struct ChannelsStatusParams: Codable, Sendable { + public let probe: Bool? + public let timeoutms: Int? + + public init( + probe: Bool?, + timeoutms: Int? + ) { + self.probe = probe + self.timeoutms = timeoutms + } + private enum CodingKeys: String, CodingKey { + case probe + case timeoutms = "timeoutMs" + } +} + +public struct ChannelsStatusResult: Codable, Sendable { + public let ts: Int + public let channelorder: [String] + public let channellabels: [String: AnyCodable] + public let channels: [String: AnyCodable] + public let channelaccounts: [String: AnyCodable] + public let channeldefaultaccountid: [String: AnyCodable] + + public init( + ts: Int, + channelorder: [String], + channellabels: [String: AnyCodable], + channels: [String: AnyCodable], + channelaccounts: [String: AnyCodable], + channeldefaultaccountid: [String: AnyCodable] + ) { + self.ts = ts + self.channelorder = channelorder + self.channellabels = channellabels + self.channels = channels + self.channelaccounts = channelaccounts + self.channeldefaultaccountid = channeldefaultaccountid + } + private enum CodingKeys: String, CodingKey { + case ts + case channelorder = "channelOrder" + case channellabels = "channelLabels" + case channels + case channelaccounts = "channelAccounts" + case channeldefaultaccountid = "channelDefaultAccountId" + } +} + +public struct ChannelsLogoutParams: Codable, Sendable { + public let channel: String + public let accountid: String? + + public init( + channel: String, + accountid: String? + ) { + self.channel = channel + self.accountid = accountid + } + private enum CodingKeys: String, CodingKey { + case channel + case accountid = "accountId" + } +} + +public struct WebLoginStartParams: Codable, Sendable { + public let force: Bool? + public let timeoutms: Int? + public let verbose: Bool? + public let accountid: String? + + public init( + force: Bool?, + timeoutms: Int?, + verbose: Bool?, + accountid: String? + ) { + self.force = force + self.timeoutms = timeoutms + self.verbose = verbose + self.accountid = accountid + } + private enum CodingKeys: String, CodingKey { + case force + case timeoutms = "timeoutMs" + case verbose + case accountid = "accountId" + } +} + +public struct WebLoginWaitParams: Codable, Sendable { + public let timeoutms: Int? + public let accountid: String? + + public init( + timeoutms: Int?, + accountid: String? + ) { + self.timeoutms = timeoutms + self.accountid = accountid + } + private enum CodingKeys: String, CodingKey { + case timeoutms = "timeoutMs" + case accountid = "accountId" + } +} + +public struct AgentSummary: Codable, Sendable { + public let id: String + public let name: String? + + public init( + id: String, + name: String? + ) { + self.id = id + self.name = name + } + private enum CodingKeys: String, CodingKey { + case id + case name + } +} + +public struct AgentsListParams: Codable, Sendable { +} + +public struct AgentsListResult: Codable, Sendable { + public let defaultid: String + public let mainkey: String + public let scope: AnyCodable + public let agents: [AgentSummary] + + public init( + defaultid: String, + mainkey: String, + scope: AnyCodable, + agents: [AgentSummary] + ) { + self.defaultid = defaultid + self.mainkey = mainkey + self.scope = scope + self.agents = agents + } + private enum CodingKeys: String, CodingKey { + case defaultid = "defaultId" + case mainkey = "mainKey" + case scope + case agents + } +} + +public struct ModelChoice: Codable, Sendable { + public let id: String + public let name: String + public let provider: String + public let contextwindow: Int? + public let reasoning: Bool? + + public init( + id: String, + name: String, + provider: String, + contextwindow: Int?, + reasoning: Bool? + ) { + self.id = id + self.name = name + self.provider = provider + self.contextwindow = contextwindow + self.reasoning = reasoning + } + private enum CodingKeys: String, CodingKey { + case id + case name + case provider + case contextwindow = "contextWindow" + case reasoning + } +} + +public struct ModelsListParams: Codable, Sendable { +} + +public struct ModelsListResult: Codable, Sendable { + public let models: [ModelChoice] + + public init( + models: [ModelChoice] + ) { + self.models = models + } + private enum CodingKeys: String, CodingKey { + case models + } +} + +public struct SkillsStatusParams: Codable, Sendable { +} + +public struct SkillsBinsParams: Codable, Sendable { +} + +public struct SkillsBinsResult: Codable, Sendable { + public let bins: [String] + + public init( + bins: [String] + ) { + self.bins = bins + } + private enum CodingKeys: String, CodingKey { + case bins + } +} + +public struct SkillsInstallParams: Codable, Sendable { + public let name: String + public let installid: String + public let timeoutms: Int? + + public init( + name: String, + installid: String, + timeoutms: Int? + ) { + self.name = name + self.installid = installid + self.timeoutms = timeoutms + } + private enum CodingKeys: String, CodingKey { + case name + case installid = "installId" + case timeoutms = "timeoutMs" + } +} + +public struct SkillsUpdateParams: Codable, Sendable { + public let skillkey: String + public let enabled: Bool? + public let apikey: String? + public let env: [String: AnyCodable]? + + public init( + skillkey: String, + enabled: Bool?, + apikey: String?, + env: [String: AnyCodable]? + ) { + self.skillkey = skillkey + self.enabled = enabled + self.apikey = apikey + self.env = env + } + private enum CodingKeys: String, CodingKey { + case skillkey = "skillKey" + case enabled + case apikey = "apiKey" + case env + } +} + +public struct CronJob: Codable, Sendable { + public let id: String + public let agentid: String? + public let name: String + public let description: String? + public let enabled: Bool + public let deleteafterrun: Bool? + public let createdatms: Int + public let updatedatms: Int + public let schedule: AnyCodable + public let sessiontarget: AnyCodable + public let wakemode: AnyCodable + public let payload: AnyCodable + public let isolation: [String: AnyCodable]? + public let state: [String: AnyCodable] + + public init( + id: String, + agentid: String?, + name: String, + description: String?, + enabled: Bool, + deleteafterrun: Bool?, + createdatms: Int, + updatedatms: Int, + schedule: AnyCodable, + sessiontarget: AnyCodable, + wakemode: AnyCodable, + payload: AnyCodable, + isolation: [String: AnyCodable]?, + state: [String: AnyCodable] + ) { + self.id = id + self.agentid = agentid + self.name = name + self.description = description + self.enabled = enabled + self.deleteafterrun = deleteafterrun + self.createdatms = createdatms + self.updatedatms = updatedatms + self.schedule = schedule + self.sessiontarget = sessiontarget + self.wakemode = wakemode + self.payload = payload + self.isolation = isolation + self.state = state + } + private enum CodingKeys: String, CodingKey { + case id + case agentid = "agentId" + case name + case description + case enabled + case deleteafterrun = "deleteAfterRun" + case createdatms = "createdAtMs" + case updatedatms = "updatedAtMs" + case schedule + case sessiontarget = "sessionTarget" + case wakemode = "wakeMode" + case payload + case isolation + case state + } +} + +public struct CronListParams: Codable, Sendable { + public let includedisabled: Bool? + + public init( + includedisabled: Bool? + ) { + self.includedisabled = includedisabled + } + private enum CodingKeys: String, CodingKey { + case includedisabled = "includeDisabled" + } +} + +public struct CronStatusParams: Codable, Sendable { +} + +public struct CronAddParams: Codable, Sendable { + public let name: String + public let agentid: AnyCodable? + public let description: String? + public let enabled: Bool? + public let deleteafterrun: Bool? + public let schedule: AnyCodable + public let sessiontarget: AnyCodable + public let wakemode: AnyCodable + public let payload: AnyCodable + public let isolation: [String: AnyCodable]? + + public init( + name: String, + agentid: AnyCodable?, + description: String?, + enabled: Bool?, + deleteafterrun: Bool?, + schedule: AnyCodable, + sessiontarget: AnyCodable, + wakemode: AnyCodable, + payload: AnyCodable, + isolation: [String: AnyCodable]? + ) { + self.name = name + self.agentid = agentid + self.description = description + self.enabled = enabled + self.deleteafterrun = deleteafterrun + self.schedule = schedule + self.sessiontarget = sessiontarget + self.wakemode = wakemode + self.payload = payload + self.isolation = isolation + } + private enum CodingKeys: String, CodingKey { + case name + case agentid = "agentId" + case description + case enabled + case deleteafterrun = "deleteAfterRun" + case schedule + case sessiontarget = "sessionTarget" + case wakemode = "wakeMode" + case payload + case isolation + } +} + +public struct CronRunLogEntry: Codable, Sendable { + public let ts: Int + public let jobid: String + public let action: String + public let status: AnyCodable? + public let error: String? + public let summary: String? + public let runatms: Int? + public let durationms: Int? + public let nextrunatms: Int? + + public init( + ts: Int, + jobid: String, + action: String, + status: AnyCodable?, + error: String?, + summary: String?, + runatms: Int?, + durationms: Int?, + nextrunatms: Int? + ) { + self.ts = ts + self.jobid = jobid + self.action = action + self.status = status + self.error = error + self.summary = summary + self.runatms = runatms + self.durationms = durationms + self.nextrunatms = nextrunatms + } + private enum CodingKeys: String, CodingKey { + case ts + case jobid = "jobId" + case action + case status + case error + case summary + case runatms = "runAtMs" + case durationms = "durationMs" + case nextrunatms = "nextRunAtMs" + } +} + +public struct LogsTailParams: Codable, Sendable { + public let cursor: Int? + public let limit: Int? + public let maxbytes: Int? + + public init( + cursor: Int?, + limit: Int?, + maxbytes: Int? + ) { + self.cursor = cursor + self.limit = limit + self.maxbytes = maxbytes + } + private enum CodingKeys: String, CodingKey { + case cursor + case limit + case maxbytes = "maxBytes" + } +} + +public struct LogsTailResult: Codable, Sendable { + public let file: String + public let cursor: Int + public let size: Int + public let lines: [String] + public let truncated: Bool? + public let reset: Bool? + + public init( + file: String, + cursor: Int, + size: Int, + lines: [String], + truncated: Bool?, + reset: Bool? + ) { + self.file = file + self.cursor = cursor + self.size = size + self.lines = lines + self.truncated = truncated + self.reset = reset + } + private enum CodingKeys: String, CodingKey { + case file + case cursor + case size + case lines + case truncated + case reset + } +} + +public struct ExecApprovalsGetParams: Codable, Sendable { +} + +public struct ExecApprovalsSetParams: Codable, Sendable { + public let file: [String: AnyCodable] + public let basehash: String? + + public init( + file: [String: AnyCodable], + basehash: String? + ) { + self.file = file + self.basehash = basehash + } + private enum CodingKeys: String, CodingKey { + case file + case basehash = "baseHash" + } +} + +public struct ExecApprovalsNodeGetParams: Codable, Sendable { + public let nodeid: String + + public init( + nodeid: String + ) { + self.nodeid = nodeid + } + private enum CodingKeys: String, CodingKey { + case nodeid = "nodeId" + } +} + +public struct ExecApprovalsNodeSetParams: Codable, Sendable { + public let nodeid: String + public let file: [String: AnyCodable] + public let basehash: String? + + public init( + nodeid: String, + file: [String: AnyCodable], + basehash: String? + ) { + self.nodeid = nodeid + self.file = file + self.basehash = basehash + } + private enum CodingKeys: String, CodingKey { + case nodeid = "nodeId" + case file + case basehash = "baseHash" + } +} + +public struct ExecApprovalsSnapshot: Codable, Sendable { + public let path: String + public let exists: Bool + public let hash: String + public let file: [String: AnyCodable] + + public init( + path: String, + exists: Bool, + hash: String, + file: [String: AnyCodable] + ) { + self.path = path + self.exists = exists + self.hash = hash + self.file = file + } + private enum CodingKeys: String, CodingKey { + case path + case exists + case hash + case file + } +} + +public struct ExecApprovalRequestParams: Codable, Sendable { + public let command: String + public let cwd: String? + public let host: String? + public let security: String? + public let ask: String? + public let agentid: String? + public let resolvedpath: String? + public let sessionkey: String? + public let timeoutms: Int? + + public init( + command: String, + cwd: String?, + host: String?, + security: String?, + ask: String?, + agentid: String?, + resolvedpath: String?, + sessionkey: String?, + timeoutms: Int? + ) { + self.command = command + self.cwd = cwd + self.host = host + self.security = security + self.ask = ask + self.agentid = agentid + self.resolvedpath = resolvedpath + self.sessionkey = sessionkey + self.timeoutms = timeoutms + } + private enum CodingKeys: String, CodingKey { + case command + case cwd + case host + case security + case ask + case agentid = "agentId" + case resolvedpath = "resolvedPath" + case sessionkey = "sessionKey" + case timeoutms = "timeoutMs" + } +} + +public struct ExecApprovalResolveParams: Codable, Sendable { + public let id: String + public let decision: String + + public init( + id: String, + decision: String + ) { + self.id = id + self.decision = decision + } + private enum CodingKeys: String, CodingKey { + case id + case decision + } +} + +public struct DevicePairListParams: Codable, Sendable { +} + +public struct DevicePairApproveParams: Codable, Sendable { + public let requestid: String + + public init( + requestid: String + ) { + self.requestid = requestid + } + private enum CodingKeys: String, CodingKey { + case requestid = "requestId" + } +} + +public struct DevicePairRejectParams: Codable, Sendable { + public let requestid: String + + public init( + requestid: String + ) { + self.requestid = requestid + } + private enum CodingKeys: String, CodingKey { + case requestid = "requestId" + } +} + +public struct DevicePairRequestedEvent: Codable, Sendable { + public let requestid: String + public let deviceid: String + public let publickey: String + public let displayname: String? + public let platform: String? + public let clientid: String? + public let clientmode: String? + public let role: String? + public let roles: [String]? + public let scopes: [String]? + public let remoteip: String? + public let silent: Bool? + public let isrepair: Bool? + public let ts: Int + + public init( + requestid: String, + deviceid: String, + publickey: String, + displayname: String?, + platform: String?, + clientid: String?, + clientmode: String?, + role: String?, + roles: [String]?, + scopes: [String]?, + remoteip: String?, + silent: Bool?, + isrepair: Bool?, + ts: Int + ) { + self.requestid = requestid + self.deviceid = deviceid + self.publickey = publickey + self.displayname = displayname + self.platform = platform + self.clientid = clientid + self.clientmode = clientmode + self.role = role + self.roles = roles + self.scopes = scopes + self.remoteip = remoteip + self.silent = silent + self.isrepair = isrepair + self.ts = ts + } + private enum CodingKeys: String, CodingKey { + case requestid = "requestId" + case deviceid = "deviceId" + case publickey = "publicKey" + case displayname = "displayName" + case platform + case clientid = "clientId" + case clientmode = "clientMode" + case role + case roles + case scopes + case remoteip = "remoteIp" + case silent + case isrepair = "isRepair" + case ts + } +} + +public struct DevicePairResolvedEvent: Codable, Sendable { + public let requestid: String + public let deviceid: String + public let decision: String + public let ts: Int + + public init( + requestid: String, + deviceid: String, + decision: String, + ts: Int + ) { + self.requestid = requestid + self.deviceid = deviceid + self.decision = decision + self.ts = ts + } + private enum CodingKeys: String, CodingKey { + case requestid = "requestId" + case deviceid = "deviceId" + case decision + case ts + } +} + +public struct ChatHistoryParams: Codable, Sendable { + public let sessionkey: String + public let limit: Int? + + public init( + sessionkey: String, + limit: Int? + ) { + self.sessionkey = sessionkey + self.limit = limit + } + private enum CodingKeys: String, CodingKey { + case sessionkey = "sessionKey" + case limit + } +} + +public struct ChatSendParams: Codable, Sendable { + public let sessionkey: String + public let message: String + public let thinking: String? + public let deliver: Bool? + public let attachments: [AnyCodable]? + public let timeoutms: Int? + public let idempotencykey: String + + public init( + sessionkey: String, + message: String, + thinking: String?, + deliver: Bool?, + attachments: [AnyCodable]?, + timeoutms: Int?, + idempotencykey: String + ) { + self.sessionkey = sessionkey + self.message = message + self.thinking = thinking + self.deliver = deliver + self.attachments = attachments + self.timeoutms = timeoutms + self.idempotencykey = idempotencykey + } + private enum CodingKeys: String, CodingKey { + case sessionkey = "sessionKey" + case message + case thinking + case deliver + case attachments + case timeoutms = "timeoutMs" + case idempotencykey = "idempotencyKey" + } +} + +public struct ChatAbortParams: Codable, Sendable { + public let sessionkey: String + public let runid: String? + + public init( + sessionkey: String, + runid: String? + ) { + self.sessionkey = sessionkey + self.runid = runid + } + private enum CodingKeys: String, CodingKey { + case sessionkey = "sessionKey" + case runid = "runId" + } +} + +public struct ChatInjectParams: Codable, Sendable { + public let sessionkey: String + public let message: String + public let label: String? + + public init( + sessionkey: String, + message: String, + label: String? + ) { + self.sessionkey = sessionkey + self.message = message + self.label = label + } + private enum CodingKeys: String, CodingKey { + case sessionkey = "sessionKey" + case message + case label + } +} + +public struct ChatEvent: Codable, Sendable { + public let runid: String + public let sessionkey: String + public let seq: Int + public let state: AnyCodable + public let message: AnyCodable? + public let errormessage: String? + public let usage: AnyCodable? + public let stopreason: String? + + public init( + runid: String, + sessionkey: String, + seq: Int, + state: AnyCodable, + message: AnyCodable?, + errormessage: String?, + usage: AnyCodable?, + stopreason: String? + ) { + self.runid = runid + self.sessionkey = sessionkey + self.seq = seq + self.state = state + self.message = message + self.errormessage = errormessage + self.usage = usage + self.stopreason = stopreason + } + private enum CodingKeys: String, CodingKey { + case runid = "runId" + case sessionkey = "sessionKey" + case seq + case state + case message + case errormessage = "errorMessage" + case usage + case stopreason = "stopReason" + } +} + +public struct UpdateRunParams: Codable, Sendable { + public let sessionkey: String? + public let note: String? + public let restartdelayms: Int? + public let timeoutms: Int? + + public init( + sessionkey: String?, + note: String?, + restartdelayms: Int?, + timeoutms: Int? + ) { + self.sessionkey = sessionkey + self.note = note + self.restartdelayms = restartdelayms + self.timeoutms = timeoutms + } + private enum CodingKeys: String, CodingKey { + case sessionkey = "sessionKey" + case note + case restartdelayms = "restartDelayMs" + case timeoutms = "timeoutMs" + } +} + +public struct TickEvent: Codable, Sendable { + public let ts: Int + + public init( + ts: Int + ) { + self.ts = ts + } + private enum CodingKeys: String, CodingKey { + case ts + } +} + +public struct ShutdownEvent: Codable, Sendable { + public let reason: String + public let restartexpectedms: Int? + + public init( + reason: String, + restartexpectedms: Int? + ) { + self.reason = reason + self.restartexpectedms = restartexpectedms + } + private enum CodingKeys: String, CodingKey { + case reason + case restartexpectedms = "restartExpectedMs" + } +} + +public enum GatewayFrame: Codable, Sendable { + case req(RequestFrame) + case res(ResponseFrame) + case event(EventFrame) + case unknown(type: String, raw: [String: AnyCodable]) + + private enum CodingKeys: String, CodingKey { + case type + } + + public init(from decoder: Decoder) throws { + let typeContainer = try decoder.container(keyedBy: CodingKeys.self) + let type = try typeContainer.decode(String.self, forKey: .type) + switch type { + case "req": + self = .req(try RequestFrame(from: decoder)) + case "res": + self = .res(try ResponseFrame(from: decoder)) + case "event": + self = .event(try EventFrame(from: decoder)) + default: + let container = try decoder.singleValueContainer() + let raw = try container.decode([String: AnyCodable].self) + self = .unknown(type: type, raw: raw) + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .req(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 .unknown(_, let raw): + var container = encoder.singleValueContainer() + try container.encode(raw) + } + } + +} diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index e89af4b54..d54c03dc8 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -2713,6 +2713,29 @@ macOS app behavior: } ``` +### `gateway.nodes` (Node command allowlist) + +The Gateway enforces a per-platform command allowlist for `node.invoke`. Nodes must both +**declare** a command and have it **allowed** by the Gateway to run it. + +Use this section to extend or deny commands: + +```json5 +{ + gateway: { + nodes: { + allowCommands: ["custom.vendor.command"], // extra commands beyond defaults + denyCommands: ["sms.send"] // block a command even if declared + } + } +} +``` + +Notes: +- `allowCommands` extends the built-in per-platform defaults. +- `denyCommands` always wins (even if the node claims the command). +- `node.invoke` rejects commands that are not declared by the node. + ### `gateway.reload` (Config hot reload) The Gateway watches `~/.clawdbot/clawdbot.json` (or `CLAWDBOT_CONFIG_PATH`) and applies changes automatically. diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 505be4162..5b27b0879 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -123,6 +123,11 @@ Nodes declare capability claims at connect time: The Gateway treats these as **claims** and enforces server-side allowlists. +### Node helper methods + +- Nodes may call `skills.bins` to fetch the current list of skill executables + for auto-allow checks. + ## Versioning - `PROTOCOL_VERSION` lives in `src/gateway/protocol/schema.ts`. @@ -144,6 +149,7 @@ The Gateway treats these as **claims** and enforces server-side allowlists. - Gateways issue tokens per device + role. - Pairing approvals are required for new device IDs unless local auto-approval is enabled. +- All WS clients must include `device` identity during `connect` (operator + node). ## TLS + pinning diff --git a/src/commands/onboard-non-interactive.gateway-auth.test.ts b/src/commands/onboard-non-interactive.gateway-auth.test.ts index 38291879c..4180b29ad 100644 --- a/src/commands/onboard-non-interactive.gateway-auth.test.ts +++ b/src/commands/onboard-non-interactive.gateway-auth.test.ts @@ -6,6 +6,12 @@ import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { WebSocket } from "ws"; +import { + loadOrCreateDeviceIdentity, + publicKeyRawBase64UrlFromPem, + signDevicePayload, +} from "../infra/device-identity.js"; +import { buildDeviceAuthPayload } from "../gateway/device-auth.js"; import { PROTOCOL_VERSION } from "../gateway/protocol/index.js"; import { rawDataToString } from "../infra/ws.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; @@ -58,6 +64,23 @@ async function onceMessage( async function connectReq(params: { url: string; token?: string }) { const ws = new WebSocket(params.url); await new Promise((resolve) => ws.once("open", resolve)); + const identity = loadOrCreateDeviceIdentity(); + const signedAtMs = Date.now(); + const payload = buildDeviceAuthPayload({ + deviceId: identity.deviceId, + clientId: GATEWAY_CLIENT_NAMES.TEST, + clientMode: GATEWAY_CLIENT_MODES.TEST, + role: "operator", + scopes: [], + signedAtMs, + token: params.token ?? null, + }); + const device = { + id: identity.deviceId, + publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), + signature: signDevicePayload(identity.privateKeyPem, payload), + signedAt: signedAtMs, + }; ws.send( JSON.stringify({ type: "req", @@ -75,6 +98,7 @@ async function connectReq(params: { url: string; token?: string }) { }, caps: [], auth: params.token ? { token: params.token } : undefined, + device, }, }), ); diff --git a/src/commands/onboard-non-interactive.lan-auto-token.test.ts b/src/commands/onboard-non-interactive.lan-auto-token.test.ts index 88e8487e3..904664f12 100644 --- a/src/commands/onboard-non-interactive.lan-auto-token.test.ts +++ b/src/commands/onboard-non-interactive.lan-auto-token.test.ts @@ -6,6 +6,12 @@ import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { WebSocket } from "ws"; +import { + loadOrCreateDeviceIdentity, + publicKeyRawBase64UrlFromPem, + signDevicePayload, +} from "../infra/device-identity.js"; +import { buildDeviceAuthPayload } from "../gateway/device-auth.js"; import { PROTOCOL_VERSION } from "../gateway/protocol/index.js"; import { getFreePort as getFreeTestPort } from "../gateway/test-helpers.js"; import { rawDataToString } from "../infra/ws.js"; @@ -64,6 +70,23 @@ async function onceMessage( async function connectReq(params: { url: string; token?: string }) { const ws = new WebSocket(params.url); await new Promise((resolve) => ws.once("open", resolve)); + const identity = loadOrCreateDeviceIdentity(); + const signedAtMs = Date.now(); + const payload = buildDeviceAuthPayload({ + deviceId: identity.deviceId, + clientId: GATEWAY_CLIENT_NAMES.TEST, + clientMode: GATEWAY_CLIENT_MODES.TEST, + role: "operator", + scopes: [], + signedAtMs, + token: params.token ?? null, + }); + const device = { + id: identity.deviceId, + publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), + signature: signDevicePayload(identity.privateKeyPem, payload), + signedAt: signedAtMs, + }; ws.send( JSON.stringify({ type: "req", @@ -81,6 +104,7 @@ async function connectReq(params: { url: string; token?: string }) { }, caps: [], auth: params.token ? { token: params.token } : undefined, + device, }, }), ); diff --git a/src/config/schema.ts b/src/config/schema.ts index 855d93be1..68f19eb8a 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -169,6 +169,8 @@ const FIELD_LABELS: Record = { "gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint", "gateway.reload.mode": "Config Reload Mode", "gateway.reload.debounceMs": "Config Reload Debounce (ms)", + "gateway.nodes.allowCommands": "Gateway Node Allowlist (Extra Commands)", + "gateway.nodes.denyCommands": "Gateway Node Denylist", "skills.load.watch": "Watch Skills", "skills.load.watchDebounceMs": "Skills Watch Debounce (ms)", "agents.defaults.workspace": "Workspace", @@ -318,6 +320,10 @@ const FIELD_HELP: Record = { "Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).", "gateway.reload.mode": 'Hot reload strategy for config changes ("hybrid" recommended).', "gateway.reload.debounceMs": "Debounce window (ms) before applying config changes.", + "gateway.nodes.allowCommands": + "Extra node.invoke commands to allow beyond the gateway defaults (array of command strings).", + "gateway.nodes.denyCommands": + "Commands to block even if present in node claims or default allowlist.", "tools.exec.applyPatch.enabled": "Experimental. Enables apply_patch for OpenAI models when allowed by tool policy.", "tools.exec.applyPatch.allowModels": diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index 77e6c88a2..caa5ab22b 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -170,6 +170,13 @@ export type GatewayHttpConfig = { endpoints?: GatewayHttpEndpointsConfig; }; +export type GatewayNodesConfig = { + /** Additional node.invoke commands to allow on the gateway. */ + allowCommands?: string[]; + /** Commands to deny even if they appear in the defaults or node claims. */ + denyCommands?: string[]; +}; + export type GatewayConfig = { /** Single multiplexed port for Gateway WS + HTTP (default: 18789). */ port?: number; @@ -196,4 +203,5 @@ export type GatewayConfig = { reload?: GatewayReloadConfig; tls?: GatewayTlsConfig; http?: GatewayHttpConfig; + nodes?: GatewayNodesConfig; }; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 58612d459..49416b3b1 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -341,6 +341,13 @@ export const ClawdbotSchema = z }) .strict() .optional(), + nodes: z + .object({ + allowCommands: z.array(z.string()).optional(), + denyCommands: z.array(z.string()).optional(), + }) + .strict() + .optional(), }) .strict() .optional(), diff --git a/src/gateway/client.ts b/src/gateway/client.ts index 2c52526b2..67232fd63 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -3,7 +3,11 @@ import { WebSocket, type ClientOptions, type CertMeta } from "ws"; import { rawDataToString } from "../infra/ws.js"; import { logDebug, logError } from "../logger.js"; import type { DeviceIdentity } from "../infra/device-identity.js"; -import { publicKeyRawBase64UrlFromPem, signDevicePayload } from "../infra/device-identity.js"; +import { + loadOrCreateDeviceIdentity, + publicKeyRawBase64UrlFromPem, + signDevicePayload, +} from "../infra/device-identity.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES, @@ -78,7 +82,10 @@ export class GatewayClient { private tickTimer: NodeJS.Timeout | null = null; constructor(opts: GatewayClientOptions) { - this.opts = opts; + this.opts = { + ...opts, + deviceIdentity: opts.deviceIdentity ?? loadOrCreateDeviceIdentity(), + }; } start() { diff --git a/src/gateway/gateway.wizard.e2e.test.ts b/src/gateway/gateway.wizard.e2e.test.ts index 385ffcc57..5c23ff002 100644 --- a/src/gateway/gateway.wizard.e2e.test.ts +++ b/src/gateway/gateway.wizard.e2e.test.ts @@ -6,9 +6,15 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { WebSocket } from "ws"; +import { + loadOrCreateDeviceIdentity, + publicKeyRawBase64UrlFromPem, + signDevicePayload, +} from "../infra/device-identity.js"; import { rawDataToString } from "../infra/ws.js"; import { getDeterministicFreePortBlock } from "../test-utils/ports.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; +import { buildDeviceAuthPayload } from "./device-auth.js"; import { PROTOCOL_VERSION } from "./protocol/index.js"; async function getFreeGatewayPort(): Promise { @@ -43,6 +49,23 @@ async function onceMessage( async function connectReq(params: { url: string; token?: string }) { const ws = new WebSocket(params.url); await new Promise((resolve) => ws.once("open", resolve)); + const identity = loadOrCreateDeviceIdentity(); + const signedAtMs = Date.now(); + const payload = buildDeviceAuthPayload({ + deviceId: identity.deviceId, + clientId: GATEWAY_CLIENT_NAMES.TEST, + clientMode: GATEWAY_CLIENT_MODES.TEST, + role: "operator", + scopes: [], + signedAtMs, + token: params.token ?? null, + }); + const device = { + id: identity.deviceId, + publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), + signature: signDevicePayload(identity.privateKeyPem, payload), + signedAt: signedAtMs, + }; ws.send( JSON.stringify({ type: "req", @@ -60,6 +83,7 @@ async function connectReq(params: { url: string; token?: string }) { }, caps: [], auth: params.token ? { token: params.token } : undefined, + device, }, }), ); diff --git a/src/gateway/node-command-policy.ts b/src/gateway/node-command-policy.ts new file mode 100644 index 000000000..117a5eef9 --- /dev/null +++ b/src/gateway/node-command-policy.ts @@ -0,0 +1,110 @@ +import type { ClawdbotConfig } from "../config/config.js"; +import type { NodeSession } from "./node-registry.js"; + +const CANVAS_COMMANDS = [ + "canvas.present", + "canvas.hide", + "canvas.navigate", + "canvas.eval", + "canvas.snapshot", + "canvas.a2ui.push", + "canvas.a2ui.pushJSONL", + "canvas.a2ui.reset", +]; + +const CAMERA_COMMANDS = ["camera.list", "camera.snap", "camera.clip"]; + +const SCREEN_COMMANDS = ["screen.record"]; + +const LOCATION_COMMANDS = ["location.get"]; + +const SMS_COMMANDS = ["sms.send"]; + +const SYSTEM_COMMANDS = [ + "system.run", + "system.which", + "system.notify", + "system.execApprovals.get", + "system.execApprovals.set", +]; + +const PLATFORM_DEFAULTS: Record = { + ios: [...CANVAS_COMMANDS, ...CAMERA_COMMANDS, ...SCREEN_COMMANDS, ...LOCATION_COMMANDS], + android: [ + ...CANVAS_COMMANDS, + ...CAMERA_COMMANDS, + ...SCREEN_COMMANDS, + ...LOCATION_COMMANDS, + ...SMS_COMMANDS, + ], + macos: [ + ...CANVAS_COMMANDS, + ...CAMERA_COMMANDS, + ...SCREEN_COMMANDS, + ...LOCATION_COMMANDS, + ...SYSTEM_COMMANDS, + ], + linux: [...SYSTEM_COMMANDS], + windows: [...SYSTEM_COMMANDS], + unknown: [ + ...CANVAS_COMMANDS, + ...CAMERA_COMMANDS, + ...SCREEN_COMMANDS, + ...LOCATION_COMMANDS, + ...SMS_COMMANDS, + ...SYSTEM_COMMANDS, + ], +}; + +function normalizePlatformId(platform?: string, deviceFamily?: string): string { + const raw = (platform ?? "").trim().toLowerCase(); + if (raw.startsWith("ios")) return "ios"; + if (raw.startsWith("android")) return "android"; + if (raw.startsWith("mac")) return "macos"; + if (raw.startsWith("darwin")) return "macos"; + if (raw.startsWith("win")) return "windows"; + if (raw.startsWith("linux")) return "linux"; + const family = (deviceFamily ?? "").trim().toLowerCase(); + if (family.includes("iphone") || family.includes("ipad") || family.includes("ios")) return "ios"; + if (family.includes("android")) return "android"; + if (family.includes("mac")) return "macos"; + if (family.includes("windows")) return "windows"; + if (family.includes("linux")) return "linux"; + return "unknown"; +} + +export function resolveNodeCommandAllowlist( + cfg: ClawdbotConfig, + node?: Pick, +): Set { + const platformId = normalizePlatformId(node?.platform, node?.deviceFamily); + const base = PLATFORM_DEFAULTS[platformId] ?? PLATFORM_DEFAULTS.unknown; + const extra = cfg.gateway?.nodes?.allowCommands ?? []; + const deny = new Set(cfg.gateway?.nodes?.denyCommands ?? []); + const allow = new Set([...base, ...extra].map((cmd) => cmd.trim()).filter(Boolean)); + for (const blocked of deny) { + const trimmed = blocked.trim(); + if (trimmed) allow.delete(trimmed); + } + return allow; +} + +export function isNodeCommandAllowed(params: { + command: string; + declaredCommands?: string[]; + allowlist: Set; +}): { ok: true } | { ok: false; reason: string } { + const command = params.command.trim(); + if (!command) return { ok: false, reason: "command required" }; + if (!params.allowlist.has(command)) { + return { ok: false, reason: "command not allowlisted" }; + } + if (Array.isArray(params.declaredCommands) && params.declaredCommands.length > 0) { + if (!params.declaredCommands.includes(command)) { + return { ok: false, reason: "command not declared by node" }; + } + } else { + return { ok: false, reason: "node did not declare commands" }; + } + return { ok: true }; +} diff --git a/src/gateway/node-registry.ts b/src/gateway/node-registry.ts index 1b020bda8..f04eab008 100644 --- a/src/gateway/node-registry.ts +++ b/src/gateway/node-registry.ts @@ -155,6 +155,7 @@ export class NodeRegistry { }): boolean { const pending = this.pendingInvokes.get(params.id); if (!pending) return false; + if (pending.nodeId !== params.nodeId) return false; clearTimeout(pending.timer); this.pendingInvokes.delete(params.id); pending.resolve({ diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index caa958dc4..02d91a85c 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -138,6 +138,10 @@ import { SessionsResolveParamsSchema, type ShutdownEvent, ShutdownEventSchema, + type SkillsBinsParams, + SkillsBinsParamsSchema, + type SkillsBinsResult, + SkillsBinsResultSchema, type SkillsInstallParams, SkillsInstallParamsSchema, type SkillsStatusParams, @@ -247,6 +251,7 @@ export const validateChannelsLogoutParams = ajv.compile( ); export const validateModelsListParams = ajv.compile(ModelsListParamsSchema); export const validateSkillsStatusParams = ajv.compile(SkillsStatusParamsSchema); +export const validateSkillsBinsParams = ajv.compile(SkillsBinsParamsSchema); export const validateSkillsInstallParams = ajv.compile(SkillsInstallParamsSchema); export const validateSkillsUpdateParams = ajv.compile(SkillsUpdateParamsSchema); @@ -424,6 +429,8 @@ export type { AgentsListParams, AgentsListResult, SkillsStatusParams, + SkillsBinsParams, + SkillsBinsResult, SkillsInstallParams, SkillsUpdateParams, NodePairRejectParams, diff --git a/src/gateway/protocol/schema/agents-models-skills.ts b/src/gateway/protocol/schema/agents-models-skills.ts index 28a71aa1e..edf655ac5 100644 --- a/src/gateway/protocol/schema/agents-models-skills.ts +++ b/src/gateway/protocol/schema/agents-models-skills.ts @@ -44,6 +44,15 @@ export const ModelsListResultSchema = Type.Object( export const SkillsStatusParamsSchema = Type.Object({}, { additionalProperties: false }); +export const SkillsBinsParamsSchema = Type.Object({}, { additionalProperties: false }); + +export const SkillsBinsResultSchema = Type.Object( + { + bins: Type.Array(NonEmptyString), + }, + { additionalProperties: false }, +); + export const SkillsInstallParamsSchema = Type.Object( { name: NonEmptyString, diff --git a/src/gateway/protocol/schema/frames.ts b/src/gateway/protocol/schema/frames.ts index 5ad1d113b..166e137a7 100644 --- a/src/gateway/protocol/schema/frames.ts +++ b/src/gateway/protocol/schema/frames.ts @@ -39,16 +39,14 @@ export const ConnectParamsSchema = Type.Object( permissions: Type.Optional(Type.Record(NonEmptyString, Type.Boolean())), role: Type.Optional(NonEmptyString), scopes: Type.Optional(Type.Array(NonEmptyString)), - device: Type.Optional( - Type.Object( - { - id: NonEmptyString, - publicKey: NonEmptyString, - signature: NonEmptyString, - signedAt: Type.Integer({ minimum: 0 }), - }, - { additionalProperties: false }, - ), + device: Type.Object( + { + id: NonEmptyString, + publicKey: NonEmptyString, + signature: NonEmptyString, + signedAt: Type.Integer({ minimum: 0 }), + }, + { additionalProperties: false }, ), auth: Type.Optional( Type.Object( diff --git a/src/gateway/protocol/schema/protocol-schemas.ts b/src/gateway/protocol/schema/protocol-schemas.ts index cb995913b..841d517bb 100644 --- a/src/gateway/protocol/schema/protocol-schemas.ts +++ b/src/gateway/protocol/schema/protocol-schemas.ts @@ -15,6 +15,8 @@ import { ModelChoiceSchema, ModelsListParamsSchema, ModelsListResultSchema, + SkillsBinsParamsSchema, + SkillsBinsResultSchema, SkillsInstallParamsSchema, SkillsStatusParamsSchema, SkillsUpdateParamsSchema, @@ -179,6 +181,8 @@ export const ProtocolSchemas: Record = { ModelsListParams: ModelsListParamsSchema, ModelsListResult: ModelsListResultSchema, SkillsStatusParams: SkillsStatusParamsSchema, + SkillsBinsParams: SkillsBinsParamsSchema, + SkillsBinsResult: SkillsBinsResultSchema, SkillsInstallParams: SkillsInstallParamsSchema, SkillsUpdateParams: SkillsUpdateParamsSchema, CronJob: CronJobSchema, diff --git a/src/gateway/protocol/schema/types.ts b/src/gateway/protocol/schema/types.ts index 67dffe836..ccb6dd1d9 100644 --- a/src/gateway/protocol/schema/types.ts +++ b/src/gateway/protocol/schema/types.ts @@ -13,6 +13,8 @@ import type { ModelChoiceSchema, ModelsListParamsSchema, ModelsListResultSchema, + SkillsBinsParamsSchema, + SkillsBinsResultSchema, SkillsInstallParamsSchema, SkillsStatusParamsSchema, SkillsUpdateParamsSchema, @@ -168,6 +170,8 @@ export type ModelChoice = Static; export type ModelsListParams = Static; export type ModelsListResult = Static; export type SkillsStatusParams = Static; +export type SkillsBinsParams = Static; +export type SkillsBinsResult = Static; export type SkillsInstallParams = Static; export type SkillsUpdateParams = Static; export type CronJob = Static; diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index e97b245ca..7f0c90907 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -27,6 +27,7 @@ const BASE_METHODS = [ "models.list", "agents.list", "skills.status", + "skills.bins", "skills.install", "skills.update", "update.run", diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 3a6178017..7c801ced9 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -25,11 +25,13 @@ import { webHandlers } from "./server-methods/web.js"; import { wizardHandlers } from "./server-methods/wizard.js"; const ADMIN_SCOPE = "operator.admin"; +const READ_SCOPE = "operator.read"; +const WRITE_SCOPE = "operator.write"; const APPROVALS_SCOPE = "operator.approvals"; const PAIRING_SCOPE = "operator.pairing"; const APPROVAL_METHODS = new Set(["exec.approval.request", "exec.approval.resolve"]); -const NODE_ROLE_METHODS = new Set(["node.invoke.result", "node.event"]); +const NODE_ROLE_METHODS = new Set(["node.invoke.result", "node.event", "skills.bins"]); const PAIRING_METHODS = new Set([ "node.pair.request", "node.pair.list", @@ -39,15 +41,51 @@ const PAIRING_METHODS = new Set([ "device.pair.list", "device.pair.approve", "device.pair.reject", + "node.rename", ]); const ADMIN_METHOD_PREFIXES = ["exec.approvals."]; +const READ_METHODS = new Set([ + "health", + "logs.tail", + "channels.status", + "status", + "usage.status", + "usage.cost", + "models.list", + "agents.list", + "skills.status", + "voicewake.get", + "sessions.list", + "cron.list", + "cron.status", + "cron.runs", + "system-presence", + "last-heartbeat", + "node.list", + "node.describe", + "chat.history", +]); +const WRITE_METHODS = new Set([ + "send", + "agent", + "agent.wait", + "wake", + "talk.mode", + "voicewake.set", + "node.invoke", + "chat.send", + "chat.abort", +]); function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["client"]) { if (!client?.connect) return null; const role = client.connect.role ?? "operator"; const scopes = client.connect.scopes ?? []; + if (NODE_ROLE_METHODS.has(method)) { + if (role === "node") return null; + return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`); + } if (role === "node") { - if (NODE_ROLE_METHODS.has(method)) return null; return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`); } if (role !== "operator") { @@ -60,10 +98,38 @@ function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["c if (PAIRING_METHODS.has(method) && !scopes.includes(PAIRING_SCOPE)) { return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.pairing"); } + if (READ_METHODS.has(method) && !(scopes.includes(READ_SCOPE) || scopes.includes(WRITE_SCOPE))) { + return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.read"); + } + if (WRITE_METHODS.has(method) && !scopes.includes(WRITE_SCOPE)) { + return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.write"); + } + if (APPROVAL_METHODS.has(method)) return null; + if (PAIRING_METHODS.has(method)) return null; + if (READ_METHODS.has(method)) return null; + if (WRITE_METHODS.has(method)) return null; if (ADMIN_METHOD_PREFIXES.some((prefix) => method.startsWith(prefix))) { return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.admin"); } - return null; + if ( + method.startsWith("config.") || + method.startsWith("wizard.") || + method.startsWith("update.") || + method === "channels.logout" || + method === "skills.install" || + method === "skills.update" || + method === "cron.add" || + method === "cron.update" || + method === "cron.remove" || + method === "cron.run" || + method === "sessions.patch" || + method === "sessions.reset" || + method === "sessions.delete" || + method === "sessions.compact" + ) { + return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.admin"); + } + return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.admin"); } export const coreGatewayHandlers: GatewayRequestHandlers = { diff --git a/src/gateway/server-methods/nodes.ts b/src/gateway/server-methods/nodes.ts index b70006f3d..b5a26039d 100644 --- a/src/gateway/server-methods/nodes.ts +++ b/src/gateway/server-methods/nodes.ts @@ -28,6 +28,11 @@ import { safeParseJson, uniqueSortedStrings, } from "./nodes.helpers.js"; +import { loadConfig } from "../../config/config.js"; +import { + isNodeCommandAllowed, + resolveNodeCommandAllowlist, +} from "../node-command-policy.js"; import type { GatewayRequestHandlers } from "./types.js"; function isNodeEntry(entry: { role?: string; roles?: string[] }) { @@ -353,6 +358,34 @@ export const nodeHandlers: GatewayRequestHandlers = { } await respondUnavailableOnThrow(respond, async () => { + const nodeSession = context.nodeRegistry.get(nodeId); + if (!nodeSession) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, "node not connected", { + details: { code: "NOT_CONNECTED" }, + }), + ); + return; + } + const cfg = loadConfig(); + const allowlist = resolveNodeCommandAllowlist(cfg, nodeSession); + const allowed = isNodeCommandAllowed({ + command, + declaredCommands: nodeSession.commands, + allowlist, + }); + if (!allowed.ok) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "node command not allowed", { + details: { reason: allowed.reason, command }, + }), + ); + return; + } const res = await context.nodeRegistry.invoke({ nodeId, command, @@ -384,7 +417,7 @@ export const nodeHandlers: GatewayRequestHandlers = { ); }); }, - "node.invoke.result": async ({ params, respond, context }) => { + "node.invoke.result": async ({ params, respond, context, client }) => { if (!validateNodeInvokeResultParams(params)) { respondInvalidParams({ respond, @@ -401,6 +434,11 @@ export const nodeHandlers: GatewayRequestHandlers = { payloadJSON?: string | null; error?: { code?: string; message?: string } | null; }; + const callerNodeId = client?.connect?.device?.id ?? client?.connect?.client?.id; + if (callerNodeId && callerNodeId !== p.nodeId) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId mismatch")); + return; + } const ok = context.nodeRegistry.handleInvokeResult({ id: p.id, nodeId: p.nodeId, @@ -415,7 +453,7 @@ export const nodeHandlers: GatewayRequestHandlers = { } respond(true, { ok: true }, undefined); }, - "node.event": async ({ params, respond, context }) => { + "node.event": async ({ params, respond, context, client }) => { if (!validateNodeEventParams(params)) { respondInvalidParams({ respond, @@ -433,6 +471,7 @@ export const nodeHandlers: GatewayRequestHandlers = { : null; await respondUnavailableOnThrow(respond, async () => { const { handleNodeEvent } = await import("../server-node-events.js"); + const nodeId = client?.connect?.device?.id ?? client?.connect?.client?.id ?? "node"; const nodeContext = { deps: context.deps, broadcast: context.broadcast, @@ -453,7 +492,7 @@ export const nodeHandlers: GatewayRequestHandlers = { loadGatewayModelCatalog: context.loadGatewayModelCatalog, logGateway: { warn: context.logGateway.warn }, }; - await handleNodeEvent(nodeContext, "node", { + await handleNodeEvent(nodeContext, nodeId, { event: p.event, payloadJSON, }); diff --git a/src/gateway/server-methods/skills.ts b/src/gateway/server-methods/skills.ts index d6767353e..955e05d0d 100644 --- a/src/gateway/server-methods/skills.ts +++ b/src/gateway/server-methods/skills.ts @@ -1,6 +1,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { installSkill } from "../../agents/skills-install.js"; import { buildWorkspaceSkillStatus } from "../../agents/skills-status.js"; +import { loadWorkspaceSkillEntries, type SkillEntry } from "../../agents/skills.js"; import type { ClawdbotConfig } from "../../config/config.js"; import { loadConfig, writeConfigFile } from "../../config/config.js"; import { getRemoteSkillEligibility } from "../../infra/skills-remote.js"; @@ -8,12 +9,52 @@ import { ErrorCodes, errorShape, formatValidationErrors, + validateSkillsBinsParams, validateSkillsInstallParams, validateSkillsStatusParams, validateSkillsUpdateParams, } from "../protocol/index.js"; import type { GatewayRequestHandlers } from "./types.js"; +function listWorkspaceDirs(cfg: ClawdbotConfig): string[] { + const dirs = new Set(); + const list = cfg.agents?.list; + if (Array.isArray(list)) { + for (const entry of list) { + if (entry && typeof entry === "object" && typeof entry.id === "string") { + dirs.add(resolveAgentWorkspaceDir(cfg, entry.id)); + } + } + } + dirs.add(resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg))); + return [...dirs]; +} + +function collectSkillBins(entries: SkillEntry[]): string[] { + const bins = new Set(); + for (const entry of entries) { + const required = entry.clawdbot?.requires?.bins ?? []; + const anyBins = entry.clawdbot?.requires?.anyBins ?? []; + const install = entry.clawdbot?.install ?? []; + for (const bin of required) { + const trimmed = bin.trim(); + if (trimmed) bins.add(trimmed); + } + for (const bin of anyBins) { + const trimmed = bin.trim(); + if (trimmed) bins.add(trimmed); + } + for (const spec of install) { + const specBins = spec?.bins ?? []; + for (const bin of specBins) { + const trimmed = String(bin).trim(); + if (trimmed) bins.add(trimmed); + } + } + } + return [...bins].sort(); +} + export const skillsHandlers: GatewayRequestHandlers = { "skills.status": ({ params, respond }) => { if (!validateSkillsStatusParams(params)) { @@ -35,6 +76,27 @@ export const skillsHandlers: GatewayRequestHandlers = { }); respond(true, report, undefined); }, + "skills.bins": ({ params, respond }) => { + if (!validateSkillsBinsParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid skills.bins params: ${formatValidationErrors(validateSkillsBinsParams.errors)}`, + ), + ); + return; + } + const cfg = loadConfig(); + const workspaceDirs = listWorkspaceDirs(cfg); + const bins = new Set(); + for (const workspaceDir of workspaceDirs) { + const entries = loadWorkspaceSkillEntries(workspaceDir, { config: cfg }); + for (const bin of collectSkillBins(entries)) bins.add(bin); + } + respond(true, { bins: [...bins].sort() }, undefined); + }, "skills.install": async ({ params, respond }) => { if (!validateSkillsInstallParams(params)) { respond( diff --git a/src/gateway/server.auth.test.ts b/src/gateway/server.auth.test.ts index 232ecd09b..2ff302ec0 100644 --- a/src/gateway/server.auth.test.ts +++ b/src/gateway/server.auth.test.ts @@ -150,6 +150,12 @@ describe("gateway server auth/connect", () => { platform: "web", mode: "webchat", }, + device: { + id: 123, + publicKey: "bad", + signature: "bad", + signedAt: "bad", + }, }, }), ); diff --git a/src/gateway/server.nodes.allowlist.test.ts b/src/gateway/server.nodes.allowlist.test.ts new file mode 100644 index 000000000..d2c800010 --- /dev/null +++ b/src/gateway/server.nodes.allowlist.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, test } from "vitest"; +import { WebSocket } from "ws"; + +import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; +import { + connectOk, + installGatewayTestHooks, + onceMessage, + rpcReq, + startServerWithClient, +} from "./test-helpers.js"; + +installGatewayTestHooks(); + +describe("gateway node command allowlist", () => { + test("rejects commands outside platform allowlist", async () => { + const { server, ws, port } = await startServerWithClient(); + await connectOk(ws); + + const nodeWs = new WebSocket(`ws://127.0.0.1:${port}`); + await new Promise((resolve) => nodeWs.once("open", resolve)); + await connectOk(nodeWs, { + role: "node", + client: { + id: GATEWAY_CLIENT_NAMES.NODE_HOST, + version: "1.0.0", + platform: "ios", + mode: GATEWAY_CLIENT_MODES.NODE, + }, + commands: ["system.run"], + }); + + const listRes = await rpcReq<{ nodes?: Array<{ nodeId: string }> }>(ws, "node.list", {}); + const nodeId = listRes.payload?.nodes?.[0]?.nodeId ?? ""; + expect(nodeId).toBeTruthy(); + + const res = await rpcReq(ws, "node.invoke", { + nodeId, + command: "system.run", + params: { command: "echo hi" }, + idempotencyKey: "allowlist-1", + }); + expect(res.ok).toBe(false); + expect(res.error?.message).toContain("node command not allowed"); + + nodeWs.close(); + ws.close(); + await server.close(); + }); + + test("rejects commands not declared by node", async () => { + const { server, ws, port } = await startServerWithClient(); + await connectOk(ws); + + const nodeWs = new WebSocket(`ws://127.0.0.1:${port}`); + await new Promise((resolve) => nodeWs.once("open", resolve)); + await connectOk(nodeWs, { + role: "node", + client: { + id: "node-empty", + version: "1.0.0", + platform: "ios", + mode: GATEWAY_CLIENT_MODES.NODE, + }, + commands: [], + }); + + const listRes = await rpcReq<{ nodes?: Array<{ nodeId: string }> }>(ws, "node.list", {}); + const nodeId = listRes.payload?.nodes?.find((entry) => entry.nodeId)?.nodeId ?? ""; + expect(nodeId).toBeTruthy(); + + const res = await rpcReq(ws, "node.invoke", { + nodeId, + command: "canvas.snapshot", + params: {}, + idempotencyKey: "allowlist-2", + }); + expect(res.ok).toBe(false); + expect(res.error?.message).toContain("node command not allowed"); + + nodeWs.close(); + ws.close(); + await server.close(); + }); + + test("allows declared command within allowlist", async () => { + const { server, ws, port } = await startServerWithClient(); + await connectOk(ws); + + const nodeWs = new WebSocket(`ws://127.0.0.1:${port}`); + await new Promise((resolve) => nodeWs.once("open", resolve)); + await connectOk(nodeWs, { + role: "node", + client: { + id: "node-allowed", + version: "1.0.0", + platform: "ios", + mode: GATEWAY_CLIENT_MODES.NODE, + }, + commands: ["canvas.snapshot"], + }); + + const listRes = await rpcReq<{ nodes?: Array<{ nodeId: string }> }>(ws, "node.list", {}); + const nodeId = listRes.payload?.nodes?.[0]?.nodeId ?? ""; + expect(nodeId).toBeTruthy(); + + const invokeReqP = onceMessage<{ type: "event"; event: string; payload?: unknown }>( + nodeWs, + (o) => o.type === "event" && o.event === "node.invoke.request", + ); + + const invokeResP = rpcReq(ws, "node.invoke", { + nodeId, + command: "canvas.snapshot", + params: { format: "png" }, + idempotencyKey: "allowlist-3", + }); + + const invokeReq = await invokeReqP; + const payload = invokeReq.payload as { id?: string; nodeId?: string }; + const requestId = payload?.id ?? ""; + const nodeIdFromReq = payload?.nodeId ?? "node-allowed"; + + nodeWs.send( + JSON.stringify({ + type: "req", + id: "node-result", + method: "node.invoke.result", + params: { + id: requestId, + nodeId: nodeIdFromReq, + ok: true, + payloadJSON: JSON.stringify({ ok: true }), + }, + }), + ); + + await onceMessage(nodeWs, (o) => o.type === "res" && o.id === "node-result"); + + const invokeRes = await invokeResP; + expect(invokeRes.ok).toBe(true); + + nodeWs.close(); + ws.close(); + await server.close(); + }); +}); diff --git a/src/gateway/server.roles.test.ts b/src/gateway/server.roles.test.ts new file mode 100644 index 000000000..ba6b17638 --- /dev/null +++ b/src/gateway/server.roles.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "vitest"; +import { WebSocket } from "ws"; + +import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; +import { + connectOk, + installGatewayTestHooks, + rpcReq, + startServerWithClient, +} from "./test-helpers.js"; + +installGatewayTestHooks(); + +describe("gateway role enforcement", () => { + test("operator cannot send node events or invoke results", async () => { + const { server, ws } = await startServerWithClient(); + await connectOk(ws); + + const eventRes = await rpcReq(ws, "node.event", { event: "test", payload: { ok: true } }); + expect(eventRes.ok).toBe(false); + expect(eventRes.error?.message ?? "").toContain("unauthorized role"); + + const invokeRes = await rpcReq(ws, "node.invoke.result", { + id: "invoke-1", + nodeId: "node-1", + ok: true, + }); + expect(invokeRes.ok).toBe(false); + expect(invokeRes.error?.message ?? "").toContain("unauthorized role"); + + ws.close(); + await server.close(); + }); + + test("node can fetch skills bins but not control plane methods", async () => { + const { server, port } = await startServerWithClient(); + const nodeWs = new WebSocket(`ws://127.0.0.1:${port}`); + await new Promise((resolve) => nodeWs.once("open", resolve)); + await connectOk(nodeWs, { + role: "node", + client: { + id: GATEWAY_CLIENT_NAMES.NODE_HOST, + version: "1.0.0", + platform: "ios", + mode: GATEWAY_CLIENT_MODES.NODE, + }, + commands: [], + }); + + const binsRes = await rpcReq<{ bins?: unknown[] }>(nodeWs, "skills.bins", {}); + expect(binsRes.ok).toBe(true); + expect(Array.isArray(binsRes.payload?.bins)).toBe(true); + + const statusRes = await rpcReq(nodeWs, "status", {}); + expect(statusRes.ok).toBe(false); + expect(statusRes.error?.message ?? "").toContain("unauthorized role"); + + nodeWs.close(); + await server.close(); + }); +}); diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 89910054c..5a4aa1f51 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -24,6 +24,7 @@ import { authorizeGatewayConnect } from "../../auth.js"; import { loadConfig } from "../../../config/config.js"; import { buildDeviceAuthPayload } from "../../device-auth.js"; import { isLoopbackAddress } from "../../net.js"; +import { resolveNodeCommandAllowlist } from "../../node-command-policy.js"; import { type ConnectParams, ErrorCodes, @@ -253,17 +254,55 @@ export function attachGatewayWsMessageHandler(params: { } const authMethod = authResult.method ?? "none"; - const role = connectParams.role ?? "operator"; - const scopes = Array.isArray(connectParams.scopes) - ? connectParams.scopes - : role === "operator" - ? ["operator.admin"] - : []; + const roleRaw = connectParams.role ?? "operator"; + const role = roleRaw === "operator" || roleRaw === "node" ? roleRaw : null; + if (!role) { + setHandshakeState("failed"); + setCloseCause("invalid-role", { + role: roleRaw, + client: connectParams.client.id, + clientDisplayName: connectParams.client.displayName, + mode: connectParams.client.mode, + version: connectParams.client.version, + }); + send({ + type: "res", + id: frame.id, + ok: false, + error: errorShape(ErrorCodes.INVALID_REQUEST, "invalid role"), + }); + close(1008, "invalid role"); + return; + } + const requestedScopes = Array.isArray(connectParams.scopes) ? connectParams.scopes : []; + const scopes = + requestedScopes.length > 0 + ? requestedScopes + : role === "operator" + ? ["operator.admin"] + : []; connectParams.role = role; connectParams.scopes = scopes; const device = connectParams.device; let devicePublicKey: string | null = null; + if (!device) { + setHandshakeState("failed"); + setCloseCause("device-required", { + client: connectParams.client.id, + clientDisplayName: connectParams.client.displayName, + mode: connectParams.client.mode, + version: connectParams.client.version, + }); + send({ + type: "res", + id: frame.id, + ok: false, + error: errorShape(ErrorCodes.NOT_PAIRED, "device identity required"), + }); + close(1008, "device identity required"); + return; + } if (device) { const derivedId = deriveDeviceIdFromPublicKey(device.publicKey); if (!derivedId || derivedId !== device.id) { @@ -307,7 +346,7 @@ export function attachGatewayWsMessageHandler(params: { clientId: connectParams.client.id, clientMode: connectParams.client.mode, role, - scopes, + scopes: requestedScopes, signedAtMs: signedAt, token: connectParams.auth?.token ?? null, }); @@ -347,9 +386,7 @@ export function attachGatewayWsMessageHandler(params: { } if (device && devicePublicKey) { - const paired = await getPairedDevice(device.id); - const isPaired = paired?.publicKey === devicePublicKey; - if (!isPaired) { + const requirePairing = async (reason: string, paired?: { deviceId: string }) => { const pairing = await requestDevicePairing({ deviceId: device.id, publicKey: devicePublicKey, @@ -360,7 +397,7 @@ export function attachGatewayWsMessageHandler(params: { role, scopes, remoteIp: remoteAddr, - silent: isLoopbackAddress(remoteAddr) && authMethod !== "none", + silent: isLoopbackAddress(remoteAddr), }); const context = buildRequestContext(); if (pairing.request.silent === true) { @@ -385,6 +422,7 @@ export function attachGatewayWsMessageHandler(params: { setCloseCause("pairing-required", { deviceId: device.id, requestId: pairing.request.requestId, + reason, }); send({ type: "res", @@ -395,9 +433,47 @@ export function attachGatewayWsMessageHandler(params: { }), }); close(1008, "pairing required"); - return; + return false; } + return true; + }; + + const paired = await getPairedDevice(device.id); + const isPaired = paired?.publicKey === devicePublicKey; + if (!isPaired) { + const ok = await requirePairing("not-paired"); + if (!ok) return; } else { + const allowedRoles = new Set( + Array.isArray(paired.roles) + ? paired.roles + : paired.role + ? [paired.role] + : [], + ); + if (allowedRoles.size === 0) { + const ok = await requirePairing("role-upgrade", paired); + if (!ok) return; + } else if (!allowedRoles.has(role)) { + const ok = await requirePairing("role-upgrade", paired); + if (!ok) return; + } + + const pairedScopes = Array.isArray(paired.scopes) ? paired.scopes : []; + if (scopes.length > 0) { + if (pairedScopes.length === 0) { + const ok = await requirePairing("scope-upgrade", paired); + if (!ok) return; + } else { + const allowedScopes = new Set(pairedScopes); + const missingScope = scopes.find((scope) => !allowedScopes.has(scope)); + if (missingScope) { + const ok = await requirePairing("scope-upgrade", paired); + if (!ok) return; + } + } + } + await updatePairedDeviceMetadata(device.id, { displayName: connectParams.client.displayName, platform: connectParams.client.platform, @@ -410,10 +486,25 @@ export function attachGatewayWsMessageHandler(params: { } } + if (role === "node") { + const cfg = loadConfig(); + const allowlist = resolveNodeCommandAllowlist(cfg, { + platform: connectParams.client.platform, + deviceFamily: connectParams.client.deviceFamily, + }); + const declared = Array.isArray(connectParams.commands) ? connectParams.commands : []; + const filtered = declared + .map((cmd) => cmd.trim()) + .filter((cmd) => cmd.length > 0 && allowlist.has(cmd)); + connectParams.commands = filtered; + } + const shouldTrackPresence = !isGatewayCliClient(connectParams.client); const clientId = connectParams.client.id; const instanceId = connectParams.client.instanceId; - const presenceKey = shouldTrackPresence ? (instanceId ?? connId) : undefined; + const presenceKey = shouldTrackPresence + ? (connectParams.device?.id ?? instanceId ?? connId) + : undefined; logWs("in", "connect", { connId, @@ -441,7 +532,7 @@ export function attachGatewayWsMessageHandler(params: { deviceFamily: connectParams.client.deviceFamily, modelIdentifier: connectParams.client.modelIdentifier, mode: connectParams.client.mode, - instanceId, + instanceId: connectParams.device?.id ?? instanceId, reason: "connect", }); incrementPresenceVersion(); diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index fc749674f..ba9ec7c3d 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -8,6 +8,11 @@ import { WebSocket } from "ws"; import { resolveMainSessionKeyFromConfig, type SessionEntry } from "../config/sessions.js"; import { resetAgentRunContextForTest } from "../infra/agent-events.js"; +import { + loadOrCreateDeviceIdentity, + publicKeyRawBase64UrlFromPem, + signDevicePayload, +} from "../infra/device-identity.js"; import { drainSystemEvents, peekSystemEvents } from "../infra/system-events.js"; import { rawDataToString } from "../infra/ws.js"; import { resetLogger, setLoggerOverride } from "../logging.js"; @@ -16,6 +21,7 @@ import { getDeterministicFreePortBlock } from "../test-utils/ports.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { PROTOCOL_VERSION } from "./protocol/index.js"; +import { buildDeviceAuthPayload } from "./device-auth.js"; import type { GatewayServerOptions } from "./server.js"; import { agentCommand, @@ -268,10 +274,44 @@ export async function connectReq( caps?: string[]; commands?: string[]; permissions?: Record; + device?: { + id: string; + publicKey: string; + signature: string; + signedAt: number; + }; }, ): Promise { const { randomUUID } = await import("node:crypto"); const id = randomUUID(); + const client = opts?.client ?? { + id: GATEWAY_CLIENT_NAMES.TEST, + version: "1.0.0", + platform: "test", + mode: GATEWAY_CLIENT_MODES.TEST, + }; + const role = opts?.role ?? "operator"; + const requestedScopes = Array.isArray(opts?.scopes) ? opts?.scopes : []; + const device = (() => { + if (opts?.device) return opts.device; + const identity = loadOrCreateDeviceIdentity(); + const signedAtMs = Date.now(); + const payload = buildDeviceAuthPayload({ + deviceId: identity.deviceId, + clientId: client.id, + clientMode: client.mode, + role, + scopes: requestedScopes, + signedAtMs, + token: opts?.token ?? null, + }); + return { + id: identity.deviceId, + publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), + signature: signDevicePayload(identity.privateKeyPem, payload), + signedAt: signedAtMs, + }; + })(); ws.send( JSON.stringify({ type: "req", @@ -280,16 +320,11 @@ export async function connectReq( params: { minProtocol: opts?.minProtocol ?? PROTOCOL_VERSION, maxProtocol: opts?.maxProtocol ?? PROTOCOL_VERSION, - client: opts?.client ?? { - id: GATEWAY_CLIENT_NAMES.TEST, - version: "1.0.0", - platform: "test", - mode: GATEWAY_CLIENT_MODES.TEST, - }, + client, caps: opts?.caps ?? [], commands: opts?.commands ?? [], permissions: opts?.permissions ?? undefined, - role: opts?.role, + role, scopes: opts?.scopes, auth: opts?.token || opts?.password @@ -298,6 +333,7 @@ export async function connectReq( password: opts?.password, } : undefined, + device, }, }), ); diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index 8c1a9d166..7c15a985e 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -154,6 +154,19 @@ function mergeRoles(...items: Array): string[] | return [...roles]; } +function mergeScopes(...items: Array): string[] | undefined { + const scopes = new Set(); + for (const item of items) { + if (!item) continue; + for (const scope of item) { + const trimmed = scope.trim(); + if (trimmed) scopes.add(trimmed); + } + } + if (scopes.size === 0) return undefined; + return [...scopes]; +} + export async function listDevicePairing(baseDir?: string): Promise { const state = await loadState(baseDir); const pending = Object.values(state.pendingById).sort((a, b) => b.ts - a.ts); @@ -223,6 +236,7 @@ export async function approveDevicePairing( const now = Date.now(); const existing = state.pairedByDeviceId[pending.deviceId]; const roles = mergeRoles(existing?.roles, existing?.role, pending.roles, pending.role); + const scopes = mergeScopes(existing?.scopes, pending.scopes); const device: PairedDevice = { deviceId: pending.deviceId, publicKey: pending.publicKey, @@ -232,7 +246,7 @@ export async function approveDevicePairing( clientMode: pending.clientMode, role: pending.role, roles, - scopes: pending.scopes, + scopes, remoteIp: pending.remoteIp, createdAtMs: existing?.createdAtMs ?? now, approvedAtMs: now, @@ -268,6 +282,7 @@ export async function updatePairedDeviceMetadata( const existing = state.pairedByDeviceId[normalizeDeviceId(deviceId)]; if (!existing) return; const roles = mergeRoles(existing.roles, existing.role, patch.role); + const scopes = mergeScopes(existing.scopes, patch.scopes); state.pairedByDeviceId[deviceId] = { ...existing, ...patch, @@ -276,6 +291,7 @@ export async function updatePairedDeviceMetadata( approvedAtMs: existing.approvedAtMs, role: patch.role ?? existing.role, roles, + scopes, }; await persistState(state, baseDir); });