diff --git a/docs/cli/qr.md b/docs/cli/qr.md index 8b0648571d06..b2195d2eb377 100644 --- a/docs/cli/qr.md +++ b/docs/cli/qr.md @@ -16,6 +16,7 @@ openclaw qr --setup-code-only openclaw qr --json openclaw qr --remote openclaw qr --limited +openclaw qr --voice-node openclaw qr --url wss://gateway.example/ws ``` @@ -36,11 +37,12 @@ openclaw devices approve - `--token `: override the gateway token the bootstrap flow authenticates against - `--password `: override the gateway password the bootstrap flow authenticates against - `--limited`: omit administrative Gateway access from the handed-off operator token +- `--voice-node`: issue node credentials plus only `operator.read` and `operator.talk` - `--setup-code-only`: print only the setup code - `--no-ascii`: skip ASCII QR rendering - `--json`: emit JSON (`setupCode`, `gatewayUrl`, optional `gatewayUrls`, `auth`, `access`, optional `accessDowngraded`, `urlSource`) -`--token` and `--password` are mutually exclusive. +`--token` and `--password` are mutually exclusive. `--limited` and `--voice-node` are mutually exclusive. ## Setup code contents @@ -51,6 +53,8 @@ The setup code carries an opaque, short-lived `bootstrapToken`, not the shared g Use `--limited` to keep the same node token while omitting `operator.admin` from the operator handoff. Pairing-mutation scope is never handed off by a setup code. +Use `--voice-node` for an embedded or room voice client. It keeps the node token and hands off a separate operator token limited to `operator.read` and `operator.talk`; it cannot send messages, mutate configuration, or invoke general write-scoped Gateway methods. + Plaintext LAN `ws://` setup remains available, but OpenClaw automatically uses the limited profile because a network observer could capture and race the bearer bootstrap token. Configure `wss://` or Tailscale Serve, then generate a new code diff --git a/docs/gateway/operator-scopes.md b/docs/gateway/operator-scopes.md index 9ee6f4956487..2026fd81c1b6 100644 --- a/docs/gateway/operator-scopes.md +++ b/docs/gateway/operator-scopes.md @@ -37,6 +37,7 @@ require the `node` role. | `operator.pairing` | Device and node pairing management: list, approve, reject, remove, rotate, revoke. | | `operator.approvals` | Exec and plugin approval APIs. | | `operator.questions` | Listing, reading, answering, and resolving interactive questions. | +| `operator.talk` | Creating, steering, and closing Talk sessions without general Gateway write access. `operator.write` also satisfies this scope. | | `operator.talk.secrets` | Reading Talk configuration with secrets included. | Unknown future `operator.*` scopes require an exact match unless the caller @@ -54,6 +55,8 @@ dispatch so authorization failures have one canonical structured response: `operator.admin` for `browser.proxy`, `fs.listDir`, and `terminal.upload`. - `talk.config` needs `operator.read`; `includeSecrets: true` also needs `operator.talk.secrets`. +- `talk.client.*`, `talk.session.*`, `talk.speak`, and `talk.mode` need + `operator.talk` (or the compatible broader `operator.write`). Some handlers then apply stricter checks based on the concrete thing being approved or mutated: @@ -88,7 +91,8 @@ Approving a device request: `operator.admin`, even though `device.pair.approve` itself only needs `operator.pairing`. - A request for `operator.read`, `operator.write`, `operator.approvals`, - `operator.questions`, `operator.pairing`, or `operator.talk.secrets` requires + `operator.questions`, `operator.pairing`, `operator.talk`, or + `operator.talk.secrets` requires the caller to already hold that scope, or `operator.admin`. - A request for `operator.admin` requires `operator.admin`. - A repair request with no explicit scopes can inherit the existing operator diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 2993dce05f60..e99ad41c602e 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -207,8 +207,9 @@ operator token: ``` This operator handoff is bounded on purpose: enough to start the mobile -operator loop and native setup, including `operator.talk.secrets` for Talk -config reads, but no pairing-mutation scopes and no `operator.admin`. Broader +operator loop and native setup, with `operator.write` satisfying Talk sessions +and `operator.talk.secrets` covering Talk config reads, but no pairing-mutation scopes +and no `operator.admin`. Broader pairing/admin access needs a separate approved pairing or token flow. Persist `hello-ok.auth.deviceTokens` only when bootstrap auth ran over a trusted transport (`wss://` or loopback/local pairing). @@ -311,8 +312,13 @@ Operator scopes (`src/gateway/operator-scopes.ts`), the full closed set: - `operator.admin` - `operator.approvals` - `operator.pairing` +- `operator.talk` - `operator.talk.secrets` +`operator.write` continues to satisfy `operator.talk` for compatibility with +existing clients. Voice-device setup can issue the narrower Talk grant without +general Gateway write access. + `talk.config` with `includeSecrets: true` requires `operator.talk.secrets` (or `operator.admin`). When secrets are included, read the active Talk provider credential from `talk.resolved.config.apiKey`; `talk.providers..apiKey` @@ -520,7 +526,7 @@ methods. Treat this as feature discovery, not a full enumeration of - `talk.catalog` returns the read-only Talk provider catalog for speech, streaming transcription, and realtime voice: canonical provider ids, registry aliases, labels, configured state, an optional group-level `ready` result, exposed model/voice ids, canonical modes, transports, brain strategies, and realtime audio/capability flags, without returning provider secrets or mutating global config. Current gateways set `ready` after applying runtime provider selection; treat its absence as unverified on older gateways. - `talk.config` returns the effective Talk config payload; `includeSecrets` requires `operator.talk.secrets` (or `operator.admin`). - - `talk.session.create` creates a gateway-owned Talk session for `realtime/gateway-relay`, `transcription/gateway-relay`, or `stt-tts/managed-room`. For `stt-tts/managed-room`, `operator.write` callers that pass `sessionKey` must also pass `spawnedBy` for scoped session-key visibility; unscoped `sessionKey` creation and `brain: "direct-tools"` require `operator.admin`. + - `talk.session.create` (`operator.talk`) creates a gateway-owned Talk session for `realtime/gateway-relay`, `transcription/gateway-relay`, or `stt-tts/managed-room`. For `stt-tts/managed-room`, non-admin callers that pass `sessionKey` must also pass `spawnedBy` for scoped session-key visibility; unscoped `sessionKey` creation and `brain: "direct-tools"` require `operator.admin`. - `talk.session.join` validates a managed-room session token, emits `session.ready` or `session.replaced` as needed, and returns room/session metadata plus recent Talk events, never the plaintext token or its hash. - `talk.session.appendAudio` appends base64 PCM input audio to gateway-owned realtime relay and transcription sessions. - `talk.session.startTurn`, `talk.session.endTurn`, and `talk.session.cancelTurn` drive managed-room turn lifecycle with stale-turn rejection before state clears. diff --git a/src/cli/qr-cli.test.ts b/src/cli/qr-cli.test.ts index cf7bc60eb52a..92f2091e685f 100644 --- a/src/cli/qr-cli.test.ts +++ b/src/cli/qr-cli.test.ts @@ -5,6 +5,7 @@ import { encodePairingSetupCode } from "../pairing/setup-code.js"; import { FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE, PAIRING_SETUP_BOOTSTRAP_PROFILE, + VOICE_NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE, } from "../shared/device-bootstrap-profile.js"; import { createCliRuntimeCapture, mockRuntimeModule } from "./test-runtime-capture.js"; @@ -244,6 +245,37 @@ describe("registerQrCli", () => { ); }); + it("uses the least-privilege bootstrap profile with --voice-node", async () => { + loadConfig.mockReturnValue({ + gateway: { + bind: "custom", + customBindHost: "127.0.0.1", + auth: { mode: "token", token: "tok" }, + }, + }); + + await runQr(["--setup-code-only", "--voice-node"]); + + expect(issueDeviceBootstrapToken).toHaveBeenCalledWith( + expect.objectContaining({ profile: VOICE_NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE }), + ); + }); + + it("rejects combining --limited with --voice-node", async () => { + loadConfig.mockReturnValue({ + gateway: { + bind: "custom", + customBindHost: "127.0.0.1", + auth: { mode: "token", token: "tok" }, + }, + }); + + await expect(runQr(["--setup-code-only", "--limited", "--voice-node"])).rejects.toThrow("exit"); + expect(runtime.error).toHaveBeenCalledWith( + "Error: Use either --limited or --voice-node, not both.", + ); + }); + it("renders ASCII QR by default", async () => { loadConfig.mockReturnValue({ gateway: { diff --git a/src/cli/qr-cli.ts b/src/cli/qr-cli.ts index 94868114bedd..873278fabb3b 100644 --- a/src/cli/qr-cli.ts +++ b/src/cli/qr-cli.ts @@ -12,6 +12,7 @@ import { resolvePairingSetupFromConfig, encodePairingSetupCode } from "../pairin import { runCommandWithTimeout } from "../process/exec.js"; import { defaultRuntime } from "../runtime.js"; import { PAIRING_SETUP_BOOTSTRAP_PROFILE } from "../shared/device-bootstrap-profile.js"; +import { VOICE_NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE } from "../shared/device-bootstrap-profile.js"; import { resolveCommandSecretRefsViaGateway } from "./command-secret-gateway.js"; import { getQrRemoteCommandSecretTargetIds } from "./command-secret-targets.js"; @@ -25,6 +26,7 @@ type QrCliOptions = { token?: string; password?: string; limited?: boolean; + voiceNode?: boolean; }; const LIMITED_TRANSPORT_WARNING = @@ -114,6 +116,7 @@ export function registerQrCli(program: Command) { .option("--token ", "Override gateway token for setup payload") .option("--password ", "Override gateway password for setup payload") .option("--limited", "Pair with limited operator access (omit operator.admin)", false) + .option("--voice-node", "Pair a voice node with node, read, and Talk access only", false) .option("--setup-code-only", "Print only the setup code", false) .option("--no-ascii", "Skip ASCII QR rendering") .option("--json", "Output JSON", false) @@ -122,6 +125,9 @@ export function registerQrCli(program: Command) { if (opts.token && opts.password) { throw new Error("Use either --token or --password, not both."); } + if (opts.limited && opts.voiceNode) { + throw new Error("Use either --limited or --voice-node, not both."); + } const token = trimToUndefined(opts.token) ?? ""; const password = trimToUndefined(opts.password) ?? ""; @@ -205,7 +211,11 @@ export function registerQrCli(program: Command) { const resolved = await resolvePairingSetupFromConfig(cfg, { publicUrl, preferRemoteUrl: wantsRemote, - ...(opts.limited ? { bootstrapProfile: PAIRING_SETUP_BOOTSTRAP_PROFILE } : {}), + ...(opts.voiceNode + ? { bootstrapProfile: VOICE_NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE } + : opts.limited + ? { bootstrapProfile: PAIRING_SETUP_BOOTSTRAP_PROFILE } + : {}), runCommandWithTimeout: async (argv, runOpts) => await runCommandWithTimeout(argv, { timeoutMs: runOpts.timeoutMs, diff --git a/src/gateway/gateway-misc.test.ts b/src/gateway/gateway-misc.test.ts index 070a05bd5390..322141ed7315 100644 --- a/src/gateway/gateway-misc.test.ts +++ b/src/gateway/gateway-misc.test.ts @@ -340,6 +340,7 @@ function makeScopedBroadcastClients() { const pairingSocket = makeRecordingSocket(); const nodeSocket = makeRecordingSocket(); const readSocket = makeRecordingSocket(); + const talkSocket = makeRecordingSocket(); const writeSocket = makeRecordingSocket(); const adminSocket = makeRecordingSocket(); const clients = new Set([ @@ -349,11 +350,12 @@ function makeScopedBroadcastClients() { scopes: ["operator.read"], } as GatewayWsClient["connect"]), makeOperatorWsClient("c-read", readSocket, ["operator.read"]), + makeOperatorWsClient("c-talk", talkSocket, ["operator.talk"]), makeOperatorWsClient("c-write", writeSocket, ["operator.write"]), makeOperatorWsClient("c-admin", adminSocket, ["operator.admin"]), ]); - return { pairingSocket, nodeSocket, readSocket, writeSocket, adminSocket, clients }; + return { pairingSocket, nodeSocket, readSocket, talkSocket, writeSocket, adminSocket, clients }; } function makeScopedBroadcastContext() { @@ -690,8 +692,15 @@ describe("gateway broadcaster", () => { }); it("defaults unknown events to deny and classifies remaining gateway broadcast events", () => { - const { pairingSocket, nodeSocket, readSocket, writeSocket, adminSocket, broadcast } = - makeScopedBroadcastContext(); + const { + pairingSocket, + nodeSocket, + readSocket, + talkSocket, + writeSocket, + adminSocket, + broadcast, + } = makeScopedBroadcastContext(); broadcast("cron", { jobId: "job-1" }); broadcast("talk.mode", { enabled: true }); @@ -738,6 +747,15 @@ describe("gateway broadcaster", () => { "shutdown", "update.available", ]); + expectSentEvents(talkSocket, [ + "talk.mode", + "heartbeat", + "presence", + "health", + "tick", + "shutdown", + "update.available", + ]); const writeVisibleEvents = [ "cron", "talk.mode", diff --git a/src/gateway/method-scopes.test.ts b/src/gateway/method-scopes.test.ts index e243035f5add..ca417f78d1fc 100644 --- a/src/gateway/method-scopes.test.ts +++ b/src/gateway/method-scopes.test.ts @@ -96,22 +96,22 @@ describe("method scope resolution", () => { ["skills.curator.restore", ["operator.admin"]], ["node.pair.approve", ["operator.pairing"]], ["poll", ["operator.write"]], - ["talk.client.create", ["operator.write"]], - ["talk.client.transcript", ["operator.write"]], - ["talk.client.close", ["operator.write"]], - ["talk.client.toolCall", ["operator.write"]], - ["talk.client.steer", ["operator.write"]], - ["talk.session.create", ["operator.write"]], - ["talk.session.join", ["operator.write"]], - ["talk.session.appendAudio", ["operator.write"]], - ["talk.session.startTurn", ["operator.write"]], - ["talk.session.endTurn", ["operator.write"]], - ["talk.session.cancelTurn", ["operator.write"]], - ["talk.session.cancelOutput", ["operator.write"]], - ["talk.session.acknowledgeMark", ["operator.write"]], - ["talk.session.submitToolResult", ["operator.write"]], - ["talk.session.steer", ["operator.write"]], - ["talk.session.close", ["operator.write"]], + ["talk.client.create", ["operator.talk"]], + ["talk.client.transcript", ["operator.talk"]], + ["talk.client.close", ["operator.talk"]], + ["talk.client.toolCall", ["operator.talk"]], + ["talk.client.steer", ["operator.talk"]], + ["talk.session.create", ["operator.talk"]], + ["talk.session.join", ["operator.talk"]], + ["talk.session.appendAudio", ["operator.talk"]], + ["talk.session.startTurn", ["operator.talk"]], + ["talk.session.endTurn", ["operator.talk"]], + ["talk.session.cancelTurn", ["operator.talk"]], + ["talk.session.cancelOutput", ["operator.talk"]], + ["talk.session.acknowledgeMark", ["operator.talk"]], + ["talk.session.submitToolResult", ["operator.talk"]], + ["talk.session.steer", ["operator.talk"]], + ["talk.session.close", ["operator.talk"]], ["update.status", ["operator.admin"]], ["config.schema", ["operator.admin"]], ["config.patch", ["operator.admin"]], @@ -188,6 +188,19 @@ describe("method scope resolution", () => { ).toEqual({ allowed: true }); }); + it("accepts dedicated Talk access and preserves operator.write compatibility", () => { + expect(authorizeOperatorScopesForMethod("talk.client.create", ["operator.talk"])).toEqual({ + allowed: true, + }); + expect(authorizeOperatorScopesForMethod("talk.client.create", ["operator.write"])).toEqual({ + allowed: true, + }); + expect(authorizeOperatorScopesForMethod("talk.client.create", ["operator.read"])).toEqual({ + allowed: false, + missingScope: "operator.talk", + }); + }); + it("requires admin only when DM pairing approval bootstraps a command owner", () => { expect(resolveLeastPrivilegeOperatorScopesForMethod("channels.pairing.approve", {})).toEqual([ "operator.pairing", @@ -708,7 +721,7 @@ describe("operator scope authorization", () => { }); }); - it("allows operator.write clients to use unified Talk sessions", () => { + it("allows operator.talk and legacy operator.write clients to use unified Talk sessions", () => { for (const method of [ "talk.client.create", "talk.client.transcript", @@ -727,12 +740,15 @@ describe("operator scope authorization", () => { "talk.session.steer", "talk.session.close", ]) { + expect(authorizeOperatorScopesForMethod(method, ["operator.talk"])).toEqual({ + allowed: true, + }); expect(authorizeOperatorScopesForMethod(method, ["operator.write"])).toEqual({ allowed: true, }); expect(authorizeOperatorScopesForMethod(method, ["operator.read"])).toEqual({ allowed: false, - missingScope: "operator.write", + missingScope: "operator.talk", }); } }); diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index fb8369e9229c..e35b30ca37ae 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -22,6 +22,7 @@ import { PAIRING_SCOPE, QUESTIONS_SCOPE, READ_SCOPE, + TALK_SCOPE, TALK_SECRETS_SCOPE, WRITE_SCOPE, isOperatorScope, @@ -34,6 +35,7 @@ export { PAIRING_SCOPE, QUESTIONS_SCOPE, READ_SCOPE, + TALK_SCOPE, WRITE_SCOPE, type OperatorScope, }; @@ -328,6 +330,12 @@ export function authorizeOperatorScopesForRequiredScope( } return { allowed: false, missingScope: READ_SCOPE }; } + if (requiredScope === TALK_SCOPE) { + if (scopes.includes(TALK_SCOPE) || scopes.includes(WRITE_SCOPE)) { + return { allowed: true }; + } + return { allowed: false, missingScope: TALK_SCOPE }; + } if (scopes.includes(requiredScope)) { return { allowed: true }; } diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index da484b5efe65..1eef13d5fde4 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -91,24 +91,24 @@ const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [ { name: "talk.catalog", scope: "operator.read", since: "<=2026.7" }, // Params-aware: reading redacted config needs read; includeSecrets also needs talk secrets. { name: "talk.config", scope: "dynamic", since: "<=2026.7" }, - { name: "talk.client.create", scope: "operator.write", since: "<=2026.7" }, - { name: "talk.client.transcript", scope: "operator.write", since: "<=2026.7" }, - { name: "talk.client.close", scope: "operator.write", since: "<=2026.7" }, - { name: "talk.client.toolCall", scope: "operator.write", since: "<=2026.7" }, - { name: "talk.client.steer", scope: "operator.write", since: "<=2026.7" }, - { name: "talk.session.create", scope: "operator.write", since: "<=2026.7" }, - { name: "talk.session.join", scope: "operator.write", since: "<=2026.7" }, - { name: "talk.session.appendAudio", scope: "operator.write", since: "<=2026.7" }, - { name: "talk.session.startTurn", scope: "operator.write", since: "<=2026.7" }, - { name: "talk.session.endTurn", scope: "operator.write", since: "<=2026.7" }, - { name: "talk.session.cancelTurn", scope: "operator.write", since: "<=2026.7" }, - { name: "talk.session.cancelOutput", scope: "operator.write", since: "<=2026.7" }, - { name: "talk.session.acknowledgeMark", scope: "operator.write", since: "<=2026.7" }, - { name: "talk.session.submitToolResult", scope: "operator.write", since: "<=2026.7" }, - { name: "talk.session.steer", scope: "operator.write", since: "<=2026.7" }, - { name: "talk.session.close", scope: "operator.write", since: "<=2026.7" }, - { name: "talk.speak", scope: "operator.write", since: "<=2026.7" }, - { name: "talk.mode", scope: "operator.write", since: "<=2026.7" }, + { name: "talk.client.create", scope: "operator.talk", since: "<=2026.7" }, + { name: "talk.client.transcript", scope: "operator.talk", since: "<=2026.7" }, + { name: "talk.client.close", scope: "operator.talk", since: "<=2026.7" }, + { name: "talk.client.toolCall", scope: "operator.talk", since: "<=2026.7" }, + { name: "talk.client.steer", scope: "operator.talk", since: "<=2026.7" }, + { name: "talk.session.create", scope: "operator.talk", since: "<=2026.7" }, + { name: "talk.session.join", scope: "operator.talk", since: "<=2026.7" }, + { name: "talk.session.appendAudio", scope: "operator.talk", since: "<=2026.7" }, + { name: "talk.session.startTurn", scope: "operator.talk", since: "<=2026.7" }, + { name: "talk.session.endTurn", scope: "operator.talk", since: "<=2026.7" }, + { name: "talk.session.cancelTurn", scope: "operator.talk", since: "<=2026.7" }, + { name: "talk.session.cancelOutput", scope: "operator.talk", since: "<=2026.7" }, + { name: "talk.session.acknowledgeMark", scope: "operator.talk", since: "<=2026.7" }, + { name: "talk.session.submitToolResult", scope: "operator.talk", since: "<=2026.7" }, + { name: "talk.session.steer", scope: "operator.talk", since: "<=2026.7" }, + { name: "talk.session.close", scope: "operator.talk", since: "<=2026.7" }, + { name: "talk.speak", scope: "operator.talk", since: "<=2026.7" }, + { name: "talk.mode", scope: "operator.talk", since: "<=2026.7" }, { name: "commands.list", scope: "operator.read", since: "<=2026.7" }, { name: "models.list", scope: "operator.read", since: "<=2026.7", startup: true }, { name: "models.authStatus", scope: "operator.read", since: "<=2026.7" }, diff --git a/src/gateway/operator-scopes.ts b/src/gateway/operator-scopes.ts index 4df73a3232d2..6df22edd393f 100644 --- a/src/gateway/operator-scopes.ts +++ b/src/gateway/operator-scopes.ts @@ -6,6 +6,7 @@ export const WRITE_SCOPE = "operator.write" as const; export const APPROVALS_SCOPE = "operator.approvals" as const; export const QUESTIONS_SCOPE = "operator.questions" as const; export const PAIRING_SCOPE = "operator.pairing" as const; +export const TALK_SCOPE = "operator.talk" as const; export const TALK_SECRETS_SCOPE = "operator.talk.secrets" as const; /** Operator privileges advertised by gateway auth and checked by method policy. */ @@ -16,6 +17,7 @@ export type OperatorScope = | typeof APPROVALS_SCOPE | typeof QUESTIONS_SCOPE | typeof PAIRING_SCOPE + | typeof TALK_SCOPE | typeof TALK_SECRETS_SCOPE; const KNOWN_OPERATOR_SCOPE_VALUES: readonly OperatorScope[] = [ @@ -25,6 +27,7 @@ const KNOWN_OPERATOR_SCOPE_VALUES: readonly OperatorScope[] = [ APPROVALS_SCOPE, QUESTIONS_SCOPE, PAIRING_SCOPE, + TALK_SCOPE, TALK_SECRETS_SCOPE, ]; diff --git a/src/gateway/server-broadcast.ts b/src/gateway/server-broadcast.ts index 812eb23145fa..ed9faa879396 100644 --- a/src/gateway/server-broadcast.ts +++ b/src/gateway/server-broadcast.ts @@ -13,6 +13,7 @@ import { PAIRING_SCOPE, QUESTIONS_SCOPE, READ_SCOPE, + TALK_SCOPE, WRITE_SCOPE, } from "./method-scopes.js"; import type { @@ -54,7 +55,7 @@ const EVENT_SCOPE_GUARDS: Record = { shutdown: [], tick: [], "talk.event": [READ_SCOPE], - "talk.mode": [WRITE_SCOPE], + "talk.mode": [TALK_SCOPE], task: [READ_SCOPE], "task.suggestion": [READ_SCOPE], "update.available": [], @@ -179,6 +180,9 @@ function hasEventScope( if (required.includes(READ_SCOPE)) { return scopes.includes(READ_SCOPE) || scopes.includes(WRITE_SCOPE); } + if (required.includes(TALK_SCOPE)) { + return scopes.includes(TALK_SCOPE) || scopes.includes(WRITE_SCOPE); + } return required.some((scope) => scopes.includes(scope)); } diff --git a/src/gateway/server.auth.control-ui.suite.ts b/src/gateway/server.auth.control-ui.suite.ts index b23f49972262..a711d3dc24c9 100644 --- a/src/gateway/server.auth.control-ui.suite.ts +++ b/src/gateway/server.auth.control-ui.suite.ts @@ -924,6 +924,74 @@ export function registerControlUiAndPairingSuite(): void { } }); + test("voice-node setup code returns node token plus Talk-only operator handoff", async () => { + const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); + const { VOICE_NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE } = + await import("../shared/device-bootstrap-profile.js"); + const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); + const { server, port, prevToken } = await startControlUiServer("secret"); + const { identityPath, identity } = await createOperatorIdentityFixture( + "openclaw-bootstrap-voice-node-", + ); + const client = { + id: "node-host", + version: "1.0.0", + platform: "esp32", + mode: "node" as const, + deviceFamily: "ESP32", + }; + + try { + const issued = await issueDeviceBootstrapToken({ + profile: VOICE_NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE, + }); + const wsBootstrap = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); + const initial = await connectReq(wsBootstrap, { + skipDefaultAuth: true, + bootstrapToken: issued.token, + role: "node", + scopes: [], + client, + deviceIdentityPath: identityPath, + }); + if (!initial.ok) { + throw new Error(`voice-node bootstrap failed: ${JSON.stringify(initial.error)}`); + } + expect(initial.ok).toBe(true); + const auth = ( + initial.payload as + | { + auth?: { + role?: string; + scopes?: string[]; + deviceToken?: string; + deviceTokens?: Array<{ + role?: string; + scopes?: string[]; + deviceToken?: string; + }>; + }; + } + | undefined + )?.auth; + expect(auth?.role).toBe("node"); + expect(auth?.scopes).toEqual([]); + expect(auth?.deviceToken).toEqual(expect.any(String)); + expect(auth?.deviceTokens?.find((entry) => entry.role === "operator")).toMatchObject({ + scopes: ["operator.read", "operator.talk"], + deviceToken: expect.any(String), + }); + expect((await listDevicePairing()).pending).toEqual([]); + const paired = await getPairedDevice(identity.deviceId); + expect(paired?.roles).toEqual(["node", "operator"]); + expect(paired?.approvedScopes).toEqual(["operator.read", "operator.talk"]); + wsBootstrap.close(); + } finally { + await server.close(); + restoreGatewayToken(prevToken); + } + }); + test("qr setup code returns node token plus full operator handoff", async () => { const { issueDeviceBootstrapToken, verifyDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); diff --git a/src/gateway/server/ws-connection/connect-device-metadata.ts b/src/gateway/server/ws-connection/connect-device-metadata.ts index c38d11f0e948..18eb5e515d05 100644 --- a/src/gateway/server/ws-connection/connect-device-metadata.ts +++ b/src/gateway/server/ws-connection/connect-device-metadata.ts @@ -6,6 +6,8 @@ import { import { hasEffectivePairedDeviceRole, type PairedDevice } from "../../../infra/device-pairing.js"; import { BOOTSTRAP_HANDOFF_OPERATOR_SCOPES, + isMobilePairingSetupBootstrapProfile, + isVoiceNodePairingSetupBootstrapProfile, resolveBootstrapProfileScopesForRole, type DeviceBootstrapProfile, } from "../../../shared/device-bootstrap-profile.js"; @@ -23,7 +25,7 @@ export function resolvePairedAccessScopes( return normalizeSortedUniqueTrimmedStringList(scopes); } -export function isSetupCodeMobileBootstrapClient(client: { +function isSetupCodeMobileBootstrapClient(client: { id?: string; platform?: string; deviceFamily?: string; @@ -39,6 +41,34 @@ export function isSetupCodeMobileBootstrapClient(client: { return false; } +/** Embedded voice nodes must prove the canonical node-host and ESP32 metadata tuple. */ +function isSetupCodeVoiceNodeBootstrapClient(client: { + id?: string; + platform?: string; + deviceFamily?: string; +}): boolean { + const platform = normalizeDeviceMetadataForAuth(client.platform); + const deviceFamily = normalizeDeviceMetadataForAuth(client.deviceFamily); + return ( + client.id === GATEWAY_CLIENT_IDS.NODE_HOST && + /^esp32(?:\s|$)/u.test(platform) && + deviceFamily === "esp32" + ); +} + +/** Match a closed setup profile to the client metadata class allowed to redeem it silently. */ +export function isSetupCodeHandoffBootstrapClient(params: { + profile: DeviceBootstrapProfile; + client: { id?: string; platform?: string; deviceFamily?: string }; +}): boolean { + return ( + (isMobilePairingSetupBootstrapProfile(params.profile) && + isSetupCodeMobileBootstrapClient(params.client)) || + (isVoiceNodePairingSetupBootstrapProfile(params.profile) && + isSetupCodeVoiceNodeBootstrapClient(params.client)) + ); +} + export function isControlUiOperatorBootstrapProfile(params: { profile: DeviceBootstrapProfile | null; requestedScopes: readonly string[]; diff --git a/src/gateway/server/ws-connection/connect-device-pairing.ts b/src/gateway/server/ws-connection/connect-device-pairing.ts index 9d25d3b3a257..7b128c665d75 100644 --- a/src/gateway/server/ws-connection/connect-device-pairing.ts +++ b/src/gateway/server/ws-connection/connect-device-pairing.ts @@ -23,7 +23,6 @@ import { resolveEffectiveOperatorDeviceIdentity, } from "../../../infra/device-pairing.js"; import { - isMobilePairingSetupBootstrapProfile, resolveBootstrapProfileScopesForRole, resolveBootstrapProfileScopesForRoles, } from "../../../shared/device-bootstrap-profile.js"; @@ -38,7 +37,7 @@ import { resolveTrustedProxyControlUiScopes } from "./connect-admission.js"; import { isControlUiOperatorBootstrapProfile, isMobileNodeBootstrapConnect, - isSetupCodeMobileBootstrapClient, + isSetupCodeHandoffBootstrapClient, pairedDeviceAllowsBootstrapProfile, resolvePairedAccessScopes, } from "./connect-device-metadata.js"; @@ -312,12 +311,14 @@ export async function authorizeGatewayConnectDevice( publicKey: devicePublicKey, }) : null; - const allowSetupCodeMobileBootstrapPairing = + const allowSetupCodeHandoffBootstrapPairing = boundBootstrapProfile !== null && - isMobilePairingSetupBootstrapProfile(boundBootstrapProfile) && isSetupCodeMobileNodeConnect && - isSetupCodeMobileBootstrapClient(connectParams.client); - const setupCodeMobileBootstrapProfile = allowSetupCodeMobileBootstrapPairing + isSetupCodeHandoffBootstrapClient({ + profile: boundBootstrapProfile, + client: connectParams.client, + }); + const setupCodeHandoffBootstrapProfile = allowSetupCodeHandoffBootstrapPairing ? boundBootstrapProfile : null; const allowControlUiOperatorBootstrapPairing = isControlUiOperatorBootstrapProfile({ @@ -332,16 +333,16 @@ export async function authorizeGatewayConnectDevice( // agree before the Gateway can skip owner approval and hand off the // selected operator profile below. Full mobile setup includes admin; // limited setup retains the previous bounded operator scope set. - const bootstrapPairingRoles = setupCodeMobileBootstrapProfile - ? uniqueStrings([role, ...setupCodeMobileBootstrapProfile.roles]) + const bootstrapPairingRoles = setupCodeHandoffBootstrapProfile + ? uniqueStrings([role, ...setupCodeHandoffBootstrapProfile.roles]) : controlUiOperatorBootstrapProfile ? ["operator"] : undefined; - const bootstrapPairingScopes = setupCodeMobileBootstrapProfile + const bootstrapPairingScopes = setupCodeHandoffBootstrapProfile ? resolveBootstrapProfileScopesForRoles( bootstrapPairingRoles ?? [], - setupCodeMobileBootstrapProfile.scopes, - setupCodeMobileBootstrapProfile.purpose, + setupCodeHandoffBootstrapProfile.scopes, + setupCodeHandoffBootstrapProfile.purpose, ) : controlUiOperatorBootstrapProfile ? resolveBootstrapProfileScopesForRole( @@ -351,7 +352,7 @@ export async function authorizeGatewayConnectDevice( ) : undefined; const bootstrapApprovalProfile = - setupCodeMobileBootstrapProfile ?? controlUiOperatorBootstrapProfile; + setupCodeHandoffBootstrapProfile ?? controlUiOperatorBootstrapProfile; const pairingRequestScopes = allowControlUiDeviceAuthMigrationForUnpairedInstall && deviceAuthMigrationScopes.length > 0 && @@ -371,11 +372,11 @@ export async function authorizeGatewayConnectDevice( } : {}), silent: - reason === "scope-upgrade" && !allowSetupCodeMobileBootstrapPairing + reason === "scope-upgrade" && !allowSetupCodeHandoffBootstrapPairing ? false : allowSilentLocalPairing || allowSilentTrustedCidrsNodePairing || - allowSetupCodeMobileBootstrapPairing || + allowSetupCodeHandoffBootstrapPairing || allowControlUiOperatorBootstrapPairing, }); const trustedProxyAutoApproveScopes = @@ -475,7 +476,7 @@ export async function authorizeGatewayConnectDevice( }, { dropIfSlow: true }, ); - if (!(allowSetupCodeMobileBootstrapPairing && boundBootstrapProfile)) { + if (!(allowSetupCodeHandoffBootstrapPairing && boundBootstrapProfile)) { // Best-effort retirement of stale silent siblings; a prune // failure must never fail the fresh device's handshake. try { diff --git a/src/gateway/server/ws-connection/connect-existing-device.ts b/src/gateway/server/ws-connection/connect-existing-device.ts index 612f20632101..d80893313fdf 100644 --- a/src/gateway/server/ws-connection/connect-existing-device.ts +++ b/src/gateway/server/ws-connection/connect-existing-device.ts @@ -5,15 +5,12 @@ import { listEffectivePairedDeviceRoles, updatePairedDeviceMetadata, } from "../../../infra/device-pairing.js"; -import { - isMobilePairingSetupBootstrapProfile, - resolveBootstrapProfileScopesForRole, -} from "../../../shared/device-bootstrap-profile.js"; +import { resolveBootstrapProfileScopesForRole } from "../../../shared/device-bootstrap-profile.js"; import type { DeviceBootstrapProfile } from "../../../shared/device-bootstrap-profile.js"; import { roleScopesAllow } from "../../../shared/operator-scope-compat.js"; import { isMobileNodeBootstrapConnect, - isSetupCodeMobileBootstrapClient, + isSetupCodeHandoffBootstrapClient, pairedDeviceAllowsBootstrapOperator, resolvePairedAccessScopes, resolvePinnedClientMetadata, @@ -148,8 +145,10 @@ export async function authorizeExistingGatewayDevice(params: { retryBootstrapHandoffProfile.purpose, ); if ( - isMobilePairingSetupBootstrapProfile(retryBootstrapHandoffProfile) && - isSetupCodeMobileBootstrapClient(connectParams.client) + isSetupCodeHandoffBootstrapClient({ + profile: retryBootstrapHandoffProfile, + client: connectParams.client, + }) ) { const pairedAllowsHandoff = pairedRoles.includes("operator") && diff --git a/src/pairing/setup-code.test.ts b/src/pairing/setup-code.test.ts index 4f93326f4426..873e1ab12b2f 100644 --- a/src/pairing/setup-code.test.ts +++ b/src/pairing/setup-code.test.ts @@ -1,7 +1,10 @@ // Tests setup code generation and environment-derived defaults. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { SecretInput } from "../config/types.secrets.js"; -import { PAIRING_SETUP_BOOTSTRAP_PROFILE } from "../shared/device-bootstrap-profile.js"; +import { + PAIRING_SETUP_BOOTSTRAP_PROFILE, + VOICE_NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE, +} from "../shared/device-bootstrap-profile.js"; import { captureEnv } from "../test-utils/env.js"; vi.mock("../infra/device-bootstrap.js", () => ({ @@ -301,6 +304,24 @@ describe("pairing setup code", () => { }); }); + it("issues a least-privilege voice-node bootstrap profile", async () => { + await expectResolvedSetupSuccessCase({ + config: createCustomGatewayConfig({ mode: "token", token: "tok_123" }), + options: { + forceSecure: true, + publicUrl: "gateway.example.test:18789/setup", + bootstrapProfile: VOICE_NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE, + }, + expected: { + authLabel: "token", + url: "wss://gateway.example.test:18789", + urlSource: "plugins.entries.device-pair.config.publicUrl", + bootstrapProfile: VOICE_NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE, + access: "limited", + }, + }); + }); + it("rejects invalid gateway.remote.url before falling back to bind-derived setup urls", async () => { await expectResolvedSetupFailureCase({ config: { diff --git a/src/shared/device-bootstrap-profile.test.ts b/src/shared/device-bootstrap-profile.test.ts index b3a3dd24d46c..8a1ec7499a3d 100644 --- a/src/shared/device-bootstrap-profile.test.ts +++ b/src/shared/device-bootstrap-profile.test.ts @@ -5,8 +5,10 @@ import { FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE, NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE, PAIRING_SETUP_BOOTSTRAP_PROFILE, + VOICE_NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE, isMobilePairingSetupBootstrapProfile, isNodePairingSetupBootstrapProfile, + isVoiceNodePairingSetupBootstrapProfile, normalizeDeviceBootstrapHandoffProfile, normalizeDeviceBootstrapProfile, resolveBootstrapProfileScopesForRole, @@ -134,6 +136,20 @@ describe("device bootstrap profile", () => { expect(isMobilePairingSetupBootstrapProfile(NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE)).toBe(false); }); + test("voice-node setup profile carries only node, read, and Talk access", () => { + expect(VOICE_NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE).toEqual({ + roles: ["node", "operator"], + scopes: ["operator.read", "operator.talk"], + purpose: "voice-node", + }); + expect(isMobilePairingSetupBootstrapProfile(VOICE_NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE)).toBe( + false, + ); + expect( + isVoiceNodePairingSetupBootstrapProfile(VOICE_NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE), + ).toBe(true); + }); + test("recognizes only the supported mobile setup profiles", () => { expect(isMobilePairingSetupBootstrapProfile(PAIRING_SETUP_BOOTSTRAP_PROFILE)).toBe(true); expect(isMobilePairingSetupBootstrapProfile(FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE)).toBe( diff --git a/src/shared/device-bootstrap-profile.ts b/src/shared/device-bootstrap-profile.ts index 156fa1dfb7d2..d45e8145496e 100644 --- a/src/shared/device-bootstrap-profile.ts +++ b/src/shared/device-bootstrap-profile.ts @@ -2,7 +2,7 @@ import { normalizeDeviceAuthRole, normalizeDeviceAuthScopes } from "./device-auth.js"; /** Closed purpose codes carried by specialized bootstrap tokens. */ -export type DeviceBootstrapPurpose = "control-ui" | "mobile-full"; +export type DeviceBootstrapPurpose = "control-ui" | "mobile-full" | "voice-node"; /** Normalized roles/scopes carried by a bootstrap token during device handoff. */ export type DeviceBootstrapProfile = { @@ -36,6 +36,7 @@ const MOBILE_FULL_ACCESS_OPERATOR_SCOPES = [ ] as const; const MOBILE_FULL_ACCESS_OPERATOR_SCOPE_SET = new Set(MOBILE_FULL_ACCESS_OPERATOR_SCOPES); +const VOICE_NODE_OPERATOR_SCOPE_SET = new Set(["operator.read", "operator.talk"]); /** Existing least-privilege setup-code/QR profile. */ export const PAIRING_SETUP_BOOTSTRAP_PROFILE: DeviceBootstrapProfile = { @@ -59,6 +60,13 @@ export const NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE: DeviceBootstrapProfile = { scopes: [], }; +/** Room/embedded voice profile: node capabilities plus least-privilege Talk RPCs. */ +export const VOICE_NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE: DeviceBootstrapProfile = { + roles: ["node", "operator"], + scopes: ["operator.read", "operator.talk"], + purpose: "voice-node", +}; + /** Compare normalized bootstrap profiles, including their closed purpose. */ export function deviceBootstrapProfilesEqual( left: DeviceBootstrapProfileInput | undefined, @@ -104,6 +112,13 @@ export function isNodePairingSetupBootstrapProfile( return matchesBootstrapProfile(input, NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE); } +/** Return whether an input exactly matches the embedded voice-node setup profile. */ +export function isVoiceNodePairingSetupBootstrapProfile( + input: DeviceBootstrapProfileInput | undefined, +): boolean { + return matchesBootstrapProfile(input, VOICE_NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE); +} + /** Resolve the subset of requested scopes a bootstrap profile may carry for one role. */ export function resolveBootstrapProfileScopesForRole( role: string, @@ -116,7 +131,9 @@ export function resolveBootstrapProfileScopesForRole( const allowedScopes = purpose === "mobile-full" ? MOBILE_FULL_ACCESS_OPERATOR_SCOPE_SET - : BOOTSTRAP_HANDOFF_OPERATOR_SCOPE_SET; + : purpose === "voice-node" + ? VOICE_NODE_OPERATOR_SCOPE_SET + : BOOTSTRAP_HANDOFF_OPERATOR_SCOPE_SET; return normalizedScopes.filter((scope) => allowedScopes.has(scope)); } return []; @@ -183,7 +200,11 @@ export function normalizeDeviceBootstrapProfile( input: DeviceBootstrapProfileInput | undefined, ): DeviceBootstrapProfile { const purpose = - input?.purpose === "control-ui" || input?.purpose === "mobile-full" ? input.purpose : undefined; + input?.purpose === "control-ui" || + input?.purpose === "mobile-full" || + input?.purpose === "voice-node" + ? input.purpose + : undefined; return { roles: normalizeBootstrapRoles(input?.roles), scopes: normalizeDeviceAuthScopes(input?.scopes ? [...input.scopes] : []), diff --git a/src/shared/operator-scope-compat.test.ts b/src/shared/operator-scope-compat.test.ts index ce873cd9fb81..e0334ffe5eff 100644 --- a/src/shared/operator-scope-compat.test.ts +++ b/src/shared/operator-scope-compat.test.ts @@ -58,6 +58,25 @@ describe("roleScopesAllow", () => { ).toBe(true); }); + it("treats operator.talk as satisfied by talk/write/admin scopes", () => { + for (const allowedScope of ["operator.talk", "operator.write", "operator.admin"]) { + expect( + roleScopesAllow({ + role: "operator", + requestedScopes: ["operator.talk"], + allowedScopes: [allowedScope], + }), + ).toBe(true); + } + expect( + roleScopesAllow({ + role: "operator", + requestedScopes: ["operator.talk"], + allowedScopes: ["operator.read"], + }), + ).toBe(false); + }); + it("treats operator.approvals/operator.pairing as satisfied by operator.admin", () => { expect( roleScopesAllow({ diff --git a/src/shared/operator-scope-compat.ts b/src/shared/operator-scope-compat.ts index 7933942d64be..37af01c4623b 100644 --- a/src/shared/operator-scope-compat.ts +++ b/src/shared/operator-scope-compat.ts @@ -2,6 +2,7 @@ const OPERATOR_ROLE = "operator"; const OPERATOR_ADMIN_SCOPE = "operator.admin"; const OPERATOR_READ_SCOPE = "operator.read"; +const OPERATOR_TALK_SCOPE = "operator.talk"; const OPERATOR_WRITE_SCOPE = "operator.write"; const OPERATOR_SCOPE_PREFIX = "operator."; @@ -29,6 +30,9 @@ function operatorScopeSatisfied(requestedScope: string, granted: Set): b if (requestedScope === OPERATOR_WRITE_SCOPE) { return granted.has(OPERATOR_WRITE_SCOPE); } + if (requestedScope === OPERATOR_TALK_SCOPE) { + return granted.has(OPERATOR_TALK_SCOPE) || granted.has(OPERATOR_WRITE_SCOPE); + } return granted.has(requestedScope); }