feat(gateway): add least-privilege voice-node pairing (#115712)

* feat(gateway): add least-privilege voice-node pairing

* chore: leave voice-node note to release

* fix(gateway): keep bootstrap helpers private

* fix(ci): remove unused Code Mode type export

* fix(ci): remove stale model matrix import

* fix(ci): align QA evidence timeout guard
This commit is contained in:
Peter Steinberger
2026-07-29 04:09:37 -04:00
committed by GitHub
parent 4a108155c5
commit e1a00726a3
20 changed files with 357 additions and 73 deletions
+5 -1
View File
@@ -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 <requestId>
- `--token <token>`: override the gateway token the bootstrap flow authenticates against
- `--password <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
+5 -1
View File
@@ -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
+9 -3
View File
@@ -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.<id>.apiKey`
@@ -520,7 +526,7 @@ methods. Treat this as feature discovery, not a full enumeration of
<Accordion title="Talk and TTS">
- `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.
+32
View File
@@ -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: {
+11 -1
View File
@@ -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 <token>", "Override gateway token for setup payload")
.option("--password <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,
+21 -3
View File
@@ -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<GatewayWsClient>([
@@ -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",
+34 -18
View File
@@ -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",
});
}
});
+8
View File
@@ -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 };
}
+18 -18
View File
@@ -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" },
+3
View File
@@ -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,
];
+5 -1
View File
@@ -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<string, string[]> = {
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));
}
@@ -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");
@@ -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[];
@@ -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 {
@@ -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") &&
+22 -1
View File
@@ -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: {
@@ -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(
+24 -3
View File
@@ -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<string>(MOBILE_FULL_ACCESS_OPERATOR_SCOPES);
const VOICE_NODE_OPERATOR_SCOPE_SET = new Set<string>(["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] : []),
+19
View File
@@ -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({
+4
View File
@@ -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<string>): 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);
}