Require approval for setup-code device pairing [AI] (#81292)

* fix: require approval for setup-code bootstrap pairing

* addressing review-skill

* addressing codex review

* addressing codex review

* addressing codex review

* addressing codex review

* addressing codex review

* addressing ci

* addressing ci

* docs: add changelog entry for PR merge
This commit is contained in:
Pavan Kumar Gondhi
2026-05-13 18:48:44 +05:30
committed by GitHub
parent 05bef5db20
commit b17e77a22b
26 changed files with 758 additions and 341 deletions

View File

@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Require approval for setup-code device pairing [AI]. (#81292) Thanks @pgondhi987.
- Docker: pin setup-time container paths so stale host `.env` OpenClaw paths cannot leak into Linux containers. Fixes #80381. (#81105) Thanks @brokemac79.
- Channels/WeCom: refresh the official onboarding install to `@wecom/wecom-openclaw-plugin@2026.5.7` and update existing managed npm installs instead of failing on the package directory. Fixes #79884. (#80390) Thanks @brokemac79.
- Control UI/WebChat: keep short assistant replies clear of in-bubble copy/open action buttons by applying the existing reserved action spacing in the grouped chat renderer. Fixes #79509. (#81244) Thanks @JARVIS-Glasses.

View File

@@ -1612,15 +1612,6 @@ internal fun resolveOperatorSessionConnectAuth(
)
}
val explicitBootstrapToken = auth.bootstrapToken?.trim()?.takeIf { it.isNotEmpty() }
if (explicitBootstrapToken != null) {
return NodeRuntime.GatewayConnectAuth(
token = null,
bootstrapToken = explicitBootstrapToken,
password = null,
)
}
return null
}

View File

@@ -64,6 +64,7 @@ data class GatewayConnectErrorDetails(
val code: String?,
val canRetryWithDeviceToken: Boolean,
val recommendedNextStep: String?,
val pauseReconnect: Boolean? = null,
val reason: String? = null,
)
@@ -736,6 +737,7 @@ class GatewaySession(
code = it["code"].asStringOrNull(),
canRetryWithDeviceToken = it["canRetryWithDeviceToken"].asBooleanOrNull() == true,
recommendedNextStep = it["recommendedNextStep"].asStringOrNull(),
pauseReconnect = it["pauseReconnect"].asBooleanOrNull(),
reason = it["reason"].asStringOrNull(),
)
}
@@ -1040,20 +1042,17 @@ class GatewaySession(
detailCode == "AUTH_TOKEN_MISMATCH"
}
private fun shouldPauseReconnectAfterAuthFailure(error: ErrorShape): Boolean =
when (error.details?.code) {
"AUTH_TOKEN_MISSING",
"AUTH_BOOTSTRAP_TOKEN_INVALID",
"AUTH_PASSWORD_MISSING",
"AUTH_PASSWORD_MISMATCH",
"AUTH_RATE_LIMITED",
"PAIRING_REQUIRED",
"CONTROL_UI_DEVICE_IDENTITY_REQUIRED",
"DEVICE_IDENTITY_REQUIRED",
-> true
"AUTH_TOKEN_MISMATCH" -> deviceTokenRetryBudgetUsed && !pendingDeviceTokenRetry
else -> false
}
private fun shouldPauseReconnectAfterAuthFailure(error: ErrorShape): Boolean {
val target = desired
return shouldPauseGatewayReconnectAfterAuthFailure(
error = error,
hasBootstrapToken = target?.bootstrapToken?.trim()?.isNotEmpty() == true,
role = target?.options?.role,
scopes = target?.options?.scopes ?: emptyList(),
deviceTokenRetryBudgetUsed = deviceTokenRetryBudgetUsed,
pendingDeviceTokenRetry = pendingDeviceTokenRetry,
)
}
private fun shouldClearStoredDeviceTokenAfterRetry(error: ErrorShape): Boolean = error.details?.code == "AUTH_DEVICE_TOKEN_MISMATCH"
@@ -1068,6 +1067,36 @@ class GatewaySession(
}
}
internal fun shouldPauseGatewayReconnectAfterAuthFailure(
error: GatewaySession.ErrorShape,
hasBootstrapToken: Boolean,
role: String?,
scopes: List<String>,
deviceTokenRetryBudgetUsed: Boolean,
pendingDeviceTokenRetry: Boolean,
): Boolean =
when (error.details?.code) {
"AUTH_TOKEN_MISSING",
"AUTH_BOOTSTRAP_TOKEN_INVALID",
"AUTH_PASSWORD_MISSING",
"AUTH_PASSWORD_MISMATCH",
"AUTH_RATE_LIMITED",
"CONTROL_UI_DEVICE_IDENTITY_REQUIRED",
"DEVICE_IDENTITY_REQUIRED",
-> true
"PAIRING_REQUIRED" ->
!(
hasBootstrapToken &&
role?.trim() == "node" &&
scopes.isEmpty() &&
error.details.reason == "not-paired" &&
(error.details.pauseReconnect == false ||
error.details.recommendedNextStep == "wait_then_retry")
)
"AUTH_TOKEN_MISMATCH" -> deviceTokenRetryBudgetUsed && !pendingDeviceTokenRetry
else -> false
}
internal fun buildGatewayWebSocketUrl(
host: String,
port: Int,

View File

@@ -29,14 +29,14 @@ import java.util.UUID
@Config(sdk = [34])
class GatewayBootstrapAuthTest {
@Test
fun connectsOperatorSessionWhenOnlyBootstrapAuthExists() {
assertTrue(
fun doesNotConnectOperatorSessionWhenOnlyBootstrapAuthExists() {
assertFalse(
shouldConnectOperatorSession(
NodeRuntime.GatewayConnectAuth(token = "", bootstrapToken = "bootstrap-1", password = ""),
storedOperatorToken = "",
),
)
assertTrue(
assertFalse(
shouldConnectOperatorSession(
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null),
storedOperatorToken = null,
@@ -84,17 +84,14 @@ class GatewayBootstrapAuthTest {
}
@Test
fun resolveOperatorSessionConnectAuthUsesBootstrapWhenNoStoredOperatorTokenExists() {
fun resolveOperatorSessionConnectAuthIgnoresBootstrapWhenNoStoredOperatorTokenExists() {
val resolved =
resolveOperatorSessionConnectAuth(
auth = NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null),
storedOperatorToken = null,
)
assertEquals(
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null),
resolved,
)
assertNull(resolved)
}
@Test
@@ -174,7 +171,7 @@ class GatewayBootstrapAuthTest {
assertEquals("fp-1", prefs.loadGatewayTlsFingerprint(endpoint.stableId))
assertEquals("setup-bootstrap-token", desiredBootstrapToken(runtime, "nodeSession"))
assertEquals("setup-bootstrap-token", desiredBootstrapToken(runtime, "operatorSession"))
assertNull(desiredBootstrapToken(runtime, "operatorSession"))
}
@Test

View File

@@ -0,0 +1,116 @@
package ai.openclaw.app.gateway
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class GatewaySessionReconnectTest {
@Test
fun bootstrapNodePairingRequiredKeepsReconnectActive() {
val error =
GatewaySession.ErrorShape(
code = "NOT_PAIRED",
message = "pairing required",
details =
GatewayConnectErrorDetails(
code = "PAIRING_REQUIRED",
canRetryWithDeviceToken = false,
recommendedNextStep = "wait_then_retry",
pauseReconnect = false,
reason = "not-paired",
),
)
assertFalse(
shouldPauseGatewayReconnectAfterAuthFailure(
error = error,
hasBootstrapToken = true,
role = "node",
scopes = emptyList(),
deviceTokenRetryBudgetUsed = false,
pendingDeviceTokenRetry = false,
),
)
}
@Test
fun bootstrapNodePairingRequiredWithoutRetryHintPausesReconnect() {
val error =
GatewaySession.ErrorShape(
code = "NOT_PAIRED",
message = "pairing required",
details =
GatewayConnectErrorDetails(
code = "PAIRING_REQUIRED",
canRetryWithDeviceToken = false,
recommendedNextStep = null,
reason = "not-paired",
),
)
assertTrue(
shouldPauseGatewayReconnectAfterAuthFailure(
error = error,
hasBootstrapToken = true,
role = "node",
scopes = emptyList(),
deviceTokenRetryBudgetUsed = false,
pendingDeviceTokenRetry = false,
),
)
}
@Test
fun nonBootstrapPairingRequiredStillPausesReconnect() {
val error =
GatewaySession.ErrorShape(
code = "NOT_PAIRED",
message = "pairing required",
details =
GatewayConnectErrorDetails(
code = "PAIRING_REQUIRED",
canRetryWithDeviceToken = false,
recommendedNextStep = "wait_then_retry",
reason = "not-paired",
),
)
assertTrue(
shouldPauseGatewayReconnectAfterAuthFailure(
error = error,
hasBootstrapToken = false,
role = "node",
scopes = emptyList(),
deviceTokenRetryBudgetUsed = false,
pendingDeviceTokenRetry = false,
),
)
}
@Test
fun bootstrapRoleUpgradeStillPausesReconnect() {
val error =
GatewaySession.ErrorShape(
code = "NOT_PAIRED",
message = "pairing required",
details =
GatewayConnectErrorDetails(
code = "PAIRING_REQUIRED",
canRetryWithDeviceToken = false,
recommendedNextStep = null,
reason = "role-upgrade",
),
)
assertTrue(
shouldPauseGatewayReconnectAfterAuthFailure(
error = error,
hasBootstrapToken = true,
role = "node",
scopes = emptyList(),
deviceTokenRetryBudgetUsed = false,
pendingDeviceTokenRetry = false,
),
)
}
}

View File

@@ -123,12 +123,10 @@ The setup code is a base64-encoded JSON payload that contains:
That bootstrap token carries the built-in pairing bootstrap profile:
- primary handed-off `node` token stays `scopes: []`
- any handed-off `operator` token stays bounded to the bootstrap allowlist:
`operator.approvals`, `operator.read`, `operator.talk.secrets`, `operator.write`
- bootstrap scope checks are role-prefixed, not one flat scope pool:
operator scope entries only satisfy operator requests, and non-operator roles
must still request scopes under their own role prefix
- the built-in setup profile allows only the `node` role
- after approval, the handed-off `node` token stays `scopes: []`
- the built-in setup-code flow does not hand off an `operator` token
- operator access requires a separate approved operator pairing or token flow
- later token rotation/revocation remains bounded by both the device's approved
role contract and the caller session's operator scopes

View File

@@ -457,7 +457,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
- `/pair approve` when there is only one pending request
- `/pair approve latest` for most recent
The setup code carries a short-lived bootstrap token. Built-in bootstrap handoff keeps the primary node token at `scopes: []`; any handed-off operator token stays bounded to `operator.approvals`, `operator.read`, `operator.talk.secrets`, and `operator.write`. Bootstrap scope checks are role-prefixed, so that operator allowlist only satisfies operator requests; non-operator roles still need scopes under their own role prefix.
The setup code carries a short-lived bootstrap token. Built-in setup-code bootstrap is node-only: the first connect creates a pending node request, and after approval the Gateway returns a durable node token with `scopes: []`. It does not return a handed-off operator token; operator access requires a separate approved operator pairing or token flow.
If a device retries with changed auth details (for example role/scopes/public key), the previous pending request is superseded and the new request uses a different `requestId`. Re-run `/pair pending` before approving.

View File

@@ -35,9 +35,8 @@ openclaw qr --url wss://gateway.example/ws
- `--token` and `--password` are mutually exclusive.
- The setup code itself now carries an opaque short-lived `bootstrapToken`, not the shared gateway token/password.
- In the built-in node/operator bootstrap flow, the primary node token still lands with `scopes: []`.
- If bootstrap handoff also issues an operator token, it stays bounded to the bootstrap allowlist: `operator.approvals`, `operator.read`, `operator.talk.secrets`, `operator.write`.
- Bootstrap scope checks are role-prefixed. That operator allowlist only satisfies operator requests; non-operator roles still need scopes under their own role prefix.
- Built-in setup-code bootstrap is node-only. After approval, the primary node token lands with `scopes: []`.
- The built-in setup-code flow does not return a handed-off operator token; operator access requires a separate approved operator pairing or token flow.
- Mobile pairing fails closed for Tailscale/public `ws://` gateway URLs. Private LAN addresses and `.local` Bonjour hosts remain supported over `ws://`, but Tailscale/public mobile routes should use Tailscale Serve/Funnel or a `wss://` gateway URL.
- With `--remote`, OpenClaw requires either `gateway.remote.url` or
`gateway.tailscale.mode=serve|funnel`.

View File

@@ -147,32 +147,24 @@ When a device token is issued, `hello-ok` also includes:
}
```
During trusted bootstrap handoff, `hello-ok.auth` may also include additional
bounded role entries in `deviceTokens`:
Built-in QR/setup-code bootstrap is node-only. After the owner approves the
pending node request, `hello-ok.auth` includes the primary node token:
```json
{
"auth": {
"deviceToken": "…",
"role": "node",
"scopes": [],
"deviceTokens": [
{
"deviceToken": "…",
"role": "operator",
"scopes": ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"]
}
]
"scopes": []
}
}
```
For the built-in node/operator bootstrap flow, the primary node token stays
`scopes: []` and any handed-off operator token stays bounded to the bootstrap
operator allowlist (`operator.approvals`, `operator.read`,
`operator.talk.secrets`, `operator.write`). Bootstrap scope checks stay
role-prefixed: operator entries only satisfy operator requests, and non-operator
roles still need scopes under their own role prefix.
The built-in setup-code flow does not include additional `deviceTokens` entries
or hand off an operator token. Client authors should treat the optional
`hello-ok.auth.deviceTokens` field as legacy/custom bootstrap extension data:
persist it only when present on a trusted transport, and do not require it for
built-in pairing.
### Node example
@@ -694,9 +686,17 @@ rather than the pre-handshake defaults.
`AUTH_TOKEN_MISMATCH` retry is gated to **trusted endpoints only**
loopback, or `wss://` with a pinned `tlsFingerprint`. Public `wss://`
without pinning does not qualify.
- Additional `hello-ok.auth.deviceTokens` entries are bootstrap handoff tokens.
Persist them only when the connect used bootstrap auth on a trusted transport
such as `wss://` or loopback/local pairing.
- Built-in setup-code bootstrap returns only the primary node
`hello-ok.auth.deviceToken`; clients must not expect an additional operator
token in `hello-ok.auth.deviceTokens`.
- While built-in setup-code bootstrap is waiting for approval, `PAIRING_REQUIRED`
details include `recommendedNextStep: "wait_then_retry"`, `retryable: true`,
and `pauseReconnect: false`. Clients should keep reconnecting with the same
bootstrap token until the request is approved or the token becomes invalid.
- If an older or custom trusted bootstrap flow includes optional
`hello-ok.auth.deviceTokens` entries, persist them only when the connect used
bootstrap auth on a trusted transport such as `wss://` or loopback/local
pairing.
- If a client supplies an **explicit** `deviceToken` or explicit `scopes`, that
caller-requested scope set remains authoritative; cached scopes are only
reused when the client is reusing the stored per-device token.

View File

@@ -1459,7 +1459,7 @@ lives on the [Models FAQ](/help/faq-models).
- On `AUTH_TOKEN_MISMATCH`, trusted clients can attempt one bounded retry with a cached device token when the gateway returns retry hints (`canRetryWithDeviceToken=true`, `recommendedNextStep=retry_with_device_token`).
- That cached-token retry now reuses the cached approved scopes stored with the device token. Explicit `deviceToken` / explicit `scopes` callers still keep their requested scope set instead of inheriting cached scopes.
- Outside that retry path, connect auth precedence is explicit shared token/password first, then explicit `deviceToken`, then stored device token, then bootstrap token.
- Bootstrap token scope checks are role-prefixed. The built-in bootstrap operator allowlist only satisfies operator requests; node or other non-operator roles still need scopes under their own role prefix.
- Built-in setup-code bootstrap is node-only. After approval, it returns a node device token with `scopes: []` and does not return a handed-off operator token.
Fix:

View File

@@ -1184,6 +1184,37 @@ describe("GatewayClient connect auth payload", () => {
});
});
it("keeps reconnecting on PAIRING_REQUIRED when retry hints keep reconnect active", async () => {
vi.useFakeTimers();
const onReconnectPaused = vi.fn();
const client = new GatewayClient({
url: "ws://127.0.0.1:18789",
bootstrapToken: "setup-bootstrap-token",
role: "node",
scopes: [],
onReconnectPaused,
});
try {
const { ws: ws1, connect: firstConnect } = startClientAndConnect({ client });
emitConnectFailure(ws1, firstConnect.id, {
code: "PAIRING_REQUIRED",
reason: "not-paired",
recommendedNextStep: "wait_then_retry",
pauseReconnect: false,
});
await vi.advanceTimersByTimeAsync(999);
expect(wsInstances).toHaveLength(1);
await vi.advanceTimersByTimeAsync(1);
expect(wsInstances).toHaveLength(2);
expect(onReconnectPaused).not.toHaveBeenCalled();
} finally {
client.stop();
vi.useRealTimers();
}
});
it("clears stale stored device tokens and does not reconnect on AUTH_DEVICE_TOKEN_MISMATCH", async () => {
loadDeviceAuthTokenMock.mockReturnValue({
token: "stored-device-token",

View File

@@ -38,6 +38,7 @@ import {
formatConnectErrorMessage,
readConnectErrorDetailCode,
readConnectErrorRecoveryAdvice,
readPairingConnectErrorDetails,
type ConnectErrorRecoveryAdvice,
} from "./protocol/connect-error-details.js";
import {
@@ -222,6 +223,7 @@ export class GatewayClient {
private deviceTokenRetryBudgetUsed = false;
private pendingStartupReconnectDelayMs: number | null = null;
private pendingConnectErrorDetailCode: string | null = null;
private pendingConnectErrorDetails: unknown = null;
// Track last tick to detect silent stalls.
private lastTick: number | null = null;
private tickIntervalMs = 30_000;
@@ -337,7 +339,9 @@ export class GatewayClient {
ws.on("close", (code, reason) => {
const reasonText = rawDataToString(reason);
const connectErrorDetailCode = this.pendingConnectErrorDetailCode;
const connectErrorDetails = this.pendingConnectErrorDetails;
this.pendingConnectErrorDetailCode = null;
this.pendingConnectErrorDetails = null;
if (this.ws === ws) {
this.ws = null;
}
@@ -369,7 +373,12 @@ export class GatewayClient {
}
}
this.flushPendingErrors(new Error(`gateway closed (${code}): ${reasonText}`));
if (this.shouldPauseReconnectAfterAuthFailure(connectErrorDetailCode)) {
if (
this.shouldPauseReconnectAfterAuthFailure({
detailCode: connectErrorDetailCode,
details: connectErrorDetails,
})
) {
this.opts.onReconnectPaused?.({
code,
reason: reasonText,
@@ -428,6 +437,7 @@ export class GatewayClient {
this.deviceTokenRetryBudgetUsed = false;
this.pendingStartupReconnectDelayMs = null;
this.pendingConnectErrorDetailCode = null;
this.pendingConnectErrorDetails = null;
this.clearReconnectTimer();
if (this.tickTimer) {
clearInterval(this.tickTimer);
@@ -576,6 +586,7 @@ export class GatewayClient {
this.deviceTokenRetryBudgetUsed = false;
this.pendingStartupReconnectDelayMs = null;
this.pendingConnectErrorDetailCode = null;
this.pendingConnectErrorDetails = null;
const authInfo = helloOk?.auth;
if (authInfo?.deviceToken && this.opts.deviceIdentity) {
storeDeviceAuthToken({
@@ -598,6 +609,8 @@ export class GatewayClient {
.catch((err) => {
this.pendingConnectErrorDetailCode =
err instanceof GatewayClientRequestError ? readConnectErrorDetailCode(err.details) : null;
this.pendingConnectErrorDetails =
err instanceof GatewayClientRequestError ? err.details : null;
const shouldRetryWithDeviceToken = this.shouldRetryWithStoredDeviceToken({
error: err,
explicitGatewayToken: normalizeOptionalString(this.opts.token),
@@ -682,10 +695,22 @@ export class GatewayClient {
};
}
private shouldPauseReconnectAfterAuthFailure(detailCode: string | null): boolean {
private shouldPauseReconnectAfterAuthFailure(params: {
detailCode: string | null;
details?: unknown;
}): boolean {
const { detailCode, details } = params;
if (!detailCode) {
return false;
}
const pairingDetails = readPairingConnectErrorDetails(details);
if (
detailCode === ConnectErrorDetailCodes.PAIRING_REQUIRED &&
(pairingDetails?.pauseReconnect === false ||
pairingDetails?.recommendedNextStep === "wait_then_retry")
) {
return false;
}
if (
detailCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISSING ||
detailCode === ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID ||

View File

@@ -74,12 +74,18 @@ describe("pairing connect details", () => {
buildPairingConnectErrorDetails({
reason: ConnectPairingRequiredReasons.NOT_PAIRED,
requestId: "req-123",
recommendedNextStep: "wait_then_retry",
retryable: true,
pauseReconnect: false,
}),
).toEqual({
code: "PAIRING_REQUIRED",
reason: "not-paired",
requestId: "req-123",
remediationHint: "Approve this device from the pending pairing requests.",
recommendedNextStep: "wait_then_retry",
retryable: true,
pauseReconnect: false,
});
});

View File

@@ -60,6 +60,9 @@ export type PairingConnectErrorDetails = {
reason?: ConnectPairingRequiredReason;
requestId?: string;
remediationHint?: string;
recommendedNextStep?: ConnectRecoveryNextStep;
retryable?: boolean;
pauseReconnect?: boolean;
deviceId?: string;
requestedRole?: string;
requestedScopes?: string[];
@@ -245,6 +248,9 @@ function createPairingConnectErrorDetails(params: {
reason?: ConnectPairingRequiredReason;
requestId?: string;
remediationHint?: string;
recommendedNextStep?: ConnectRecoveryNextStep;
retryable?: boolean;
pauseReconnect?: boolean;
deviceId?: string;
requestedRole?: string;
requestedScopes?: string[];
@@ -256,6 +262,9 @@ function createPairingConnectErrorDetails(params: {
...(params.reason ? { reason: params.reason } : {}),
...(params.requestId ? { requestId: params.requestId } : {}),
...(params.remediationHint ? { remediationHint: params.remediationHint } : {}),
...(params.recommendedNextStep ? { recommendedNextStep: params.recommendedNextStep } : {}),
...(params.retryable !== undefined ? { retryable: params.retryable } : {}),
...(params.pauseReconnect !== undefined ? { pauseReconnect: params.pauseReconnect } : {}),
...(params.deviceId ? { deviceId: params.deviceId } : {}),
...(params.requestedRole ? { requestedRole: params.requestedRole } : {}),
...(params.requestedScopes ? { requestedScopes: params.requestedScopes } : {}),
@@ -300,6 +309,9 @@ export function buildPairingConnectErrorDetails(params: {
reason: ConnectPairingRequiredReason | undefined;
requestId?: string;
remediationHint?: string;
recommendedNextStep?: ConnectRecoveryNextStep;
retryable?: boolean;
pauseReconnect?: boolean;
deviceId?: string;
requestedRole?: string;
requestedScopes?: string[];
@@ -319,6 +331,9 @@ export function buildPairingConnectErrorDetails(params: {
reason: params.reason,
requestId,
remediationHint,
recommendedNextStep: params.recommendedNextStep,
retryable: params.retryable,
pauseReconnect: params.pauseReconnect,
deviceId,
requestedRole,
requestedScopes,
@@ -349,6 +364,9 @@ export function readPairingConnectErrorDetails(
reason?: unknown;
requestId?: unknown;
remediationHint?: unknown;
recommendedNextStep?: unknown;
retryable?: unknown;
pauseReconnect?: unknown;
deviceId?: unknown;
requestedRole?: unknown;
requestedScopes?: unknown;
@@ -359,6 +377,12 @@ export function readPairingConnectErrorDetails(
const requestId = normalizePairingConnectRequestId(raw.requestId);
const remediationHint =
normalizeOptionalString(raw.remediationHint) ?? buildPairingConnectRemediationHint(reason);
const normalizedNextStep = normalizeOptionalString(raw.recommendedNextStep) ?? "";
const recommendedNextStep = CONNECT_RECOVERY_NEXT_STEP_VALUES.has(
normalizedNextStep as ConnectRecoveryNextStep,
)
? (normalizedNextStep as ConnectRecoveryNextStep)
: undefined;
const deviceId = normalizeOptionalString(raw.deviceId);
const requestedRole = normalizeOptionalString(raw.requestedRole);
const requestedScopes = normalizeStringArray(raw.requestedScopes);
@@ -368,6 +392,9 @@ export function readPairingConnectErrorDetails(
reason,
requestId,
remediationHint,
recommendedNextStep,
retryable: typeof raw.retryable === "boolean" ? raw.retryable : undefined,
pauseReconnect: typeof raw.pauseReconnect === "boolean" ? raw.pauseReconnect : undefined,
deviceId,
requestedRole,
requestedScopes,

View File

@@ -63,6 +63,21 @@ describe("isNonRecoverableAuthError", () => {
);
});
it("allows reconnect for PAIRING_REQUIRED when retry hints keep reconnect active", () => {
expect(
isNonRecoverableAuthError({
code: "connect_failed",
message: "auth failed",
details: {
code: ConnectErrorDetailCodes.PAIRING_REQUIRED,
reason: "not-paired",
recommendedNextStep: "wait_then_retry",
pauseReconnect: false,
},
}),
).toBe(false);
});
it("allows reconnect for AUTH_TOKEN_MISMATCH (device-token fallback flow)", () => {
// Browser client can queue a single trusted-device retry after shared token mismatch.
// Blocking reconnect on mismatch here would skip that bounded recovery attempt.

View File

@@ -41,14 +41,6 @@ function expectArrayIncludes(actual: unknown, expectedValues: string[]): void {
}
}
function expectArrayExcludes(actual: unknown, deniedValues: string[]): void {
expect(Array.isArray(actual)).toBe(true);
const values = actual as unknown[];
for (const denied of deniedValues) {
expect(values).not.toContain(denied);
}
}
export function registerControlUiAndPairingSuite(): void {
const trustedProxyControlUiCases: Array<{
name: string;
@@ -1036,11 +1028,11 @@ export function registerControlUiAndPairingSuite(): void {
}
});
test("auto-approves fresh node bootstrap pairing from qr setup code", async () => {
test("requires approval before qr setup code returns a durable node token", async () => {
const { issueDeviceBootstrapToken, verifyDeviceBootstrapToken } =
await import("../infra/device-bootstrap.js");
const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js");
const { getPairedDevice, listDevicePairing, verifyDeviceToken } =
const { approveDevicePairing, getPairedDevice, listDevicePairing, verifyDeviceToken } =
await import("../infra/device-pairing.js");
const { server, port, prevToken } = await startControlUiServer("secret");
@@ -1066,8 +1058,47 @@ export function registerControlUiAndPairingSuite(): void {
client,
deviceIdentityPath: identityPath,
});
expect(initial.ok).toBe(true);
const initialPayload = initial.payload as
expect(initial.ok).toBe(false);
expect(initial.error?.message ?? "").toContain("pairing required");
const initialDetails = initial.error?.details as
| {
code?: string;
pauseReconnect?: boolean;
recommendedNextStep?: string;
retryable?: boolean;
}
| undefined;
expect(initialDetails?.code).toBe(ConnectErrorDetailCodes.PAIRING_REQUIRED);
expect(initialDetails?.recommendedNextStep).toBe("wait_then_retry");
expect(initialDetails?.retryable).toBe(true);
expect(initialDetails?.pauseReconnect).toBe(false);
const pendingAfterInitial = await listDevicePairing();
const pendingForDevice = pendingAfterInitial.pending.filter(
(entry) => entry.deviceId === identity.deviceId,
);
expect(pendingForDevice).toHaveLength(1);
expect(pendingForDevice[0]?.role).toBe("node");
expect(pendingForDevice[0]?.roles).toEqual(["node"]);
expect(await getPairedDevice(identity.deviceId)).toBeNull();
expect(
await approveDevicePairing(pendingForDevice[0]?.requestId ?? "", {
callerScopes: ["operator.pairing"],
}),
).toMatchObject({ status: "approved" });
wsBootstrap.close();
const wsApproved = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
const approvedConnect = await connectReq(wsApproved, {
skipDefaultAuth: true,
bootstrapToken: issued.token,
role: "node",
scopes: [],
client,
deviceIdentityPath: identityPath,
});
expect(approvedConnect.ok).toBe(true);
const approvedPayload = approvedConnect.payload as
| {
type?: string;
auth?: {
@@ -1082,61 +1113,32 @@ export function registerControlUiAndPairingSuite(): void {
};
}
| undefined;
expect(initialPayload?.type).toBe("hello-ok");
const issuedDeviceToken = initialPayload?.auth?.deviceToken;
const issuedOperatorToken = initialPayload?.auth?.deviceTokens?.find(
(entry) => entry.role === "operator",
)?.deviceToken;
if (!issuedDeviceToken || !issuedOperatorToken) {
throw new Error("expected issued device and operator tokens");
expect(approvedPayload?.type).toBe("hello-ok");
const issuedDeviceToken = approvedPayload?.auth?.deviceToken;
if (!issuedDeviceToken) {
throw new Error("expected issued device token");
}
expect(initialPayload?.auth?.role).toBe("node");
expect(initialPayload?.auth?.scopes ?? []).toEqual([]);
expect(initialPayload?.auth?.deviceTokens?.some((entry) => entry.role === "node")).toBe(
false,
);
const operatorBootstrapScopes = initialPayload?.auth?.deviceTokens?.find(
(entry) => entry.role === "operator",
)?.scopes;
expectArrayIncludes(operatorBootstrapScopes, [
"operator.approvals",
"operator.read",
"operator.talk.secrets",
"operator.write",
]);
expectArrayExcludes(operatorBootstrapScopes, [
"node.camera",
"node.display",
"node.exec",
"node.voice",
]);
expectArrayExcludes(operatorBootstrapScopes, ["operator.admin", "operator.pairing"]);
expect(approvedPayload?.auth?.role).toBe("node");
expect(approvedPayload?.auth?.scopes ?? []).toEqual([]);
expect(approvedPayload?.auth?.deviceTokens ?? []).toEqual([]);
const afterBootstrap = await listDevicePairing();
expect(
afterBootstrap.pending.filter((entry) => entry.deviceId === identity.deviceId),
).toEqual([]);
const paired = await getPairedDevice(identity.deviceId);
expectArrayIncludes(paired?.roles, ["node", "operator"]);
expectArrayIncludes(paired?.approvedScopes, [
"operator.approvals",
"operator.read",
"operator.talk.secrets",
"operator.write",
]);
expect(paired?.roles).toEqual(["node"]);
expect(paired?.approvedScopes).toEqual([]);
expect(paired?.tokens?.node?.token).toBe(issuedDeviceToken);
expect(paired?.tokens?.operator?.token).toBe(issuedOperatorToken);
if (!issuedDeviceToken || !issuedOperatorToken) {
throw new Error("expected hello-ok auth.deviceTokens for bootstrap onboarding");
}
expect(paired?.tokens?.operator).toBeUndefined();
await new Promise<void>((resolve) => {
if (wsBootstrap.readyState === WebSocket.CLOSED) {
if (wsApproved.readyState === WebSocket.CLOSED) {
resolve();
return;
}
wsBootstrap.once("close", () => resolve());
wsBootstrap.close();
wsApproved.once("close", () => resolve());
wsApproved.close();
});
const wsReplay = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
@@ -1187,7 +1189,7 @@ export function registerControlUiAndPairingSuite(): void {
await expect(
verifyDeviceToken({
deviceId: identity.deviceId,
token: issuedOperatorToken,
token: issuedDeviceToken,
role: "operator",
scopes: [
"operator.approvals",
@@ -1196,7 +1198,76 @@ export function registerControlUiAndPairingSuite(): void {
"operator.write",
],
}),
).resolves.toEqual({ ok: true });
).resolves.toEqual({ ok: false, reason: "token-missing" });
} finally {
await server.close();
restoreGatewayToken(prevToken);
}
});
test("rejected qr setup code cannot recreate pending node pairing", async () => {
const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js");
const { listDevicePairing, rejectDevicePairing } = await import("../infra/device-pairing.js");
const { server, port, prevToken } = await startControlUiServer("secret");
const { identityPath, identity } = await createOperatorIdentityFixture(
"openclaw-bootstrap-node-reject-",
);
const client = {
id: "openclaw-ios",
version: "2026.3.30",
platform: "iOS 26.3.1",
mode: "node",
deviceFamily: "iPhone",
};
try {
const issued = await issueDeviceBootstrapToken();
const wsInitial = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
const initial = await connectReq(wsInitial, {
skipDefaultAuth: true,
bootstrapToken: issued.token,
role: "node",
scopes: [],
client,
deviceIdentityPath: identityPath,
});
expect(initial.ok).toBe(false);
expect(
initial.error?.details as { code?: string; pauseReconnect?: boolean } | undefined,
).toMatchObject({
code: ConnectErrorDetailCodes.PAIRING_REQUIRED,
pauseReconnect: false,
});
wsInitial.close();
const pending = (await listDevicePairing()).pending.find(
(entry) => entry.deviceId === identity.deviceId,
);
if (!pending) {
throw new Error("expected pending bootstrap pairing request");
}
await expect(rejectDevicePairing(pending.requestId)).resolves.toEqual({
requestId: pending.requestId,
deviceId: identity.deviceId,
});
const wsRetry = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
const retry = await connectReq(wsRetry, {
skipDefaultAuth: true,
bootstrapToken: issued.token,
role: "node",
scopes: [],
client,
deviceIdentityPath: identityPath,
});
expect(retry.ok).toBe(false);
expect((retry.error?.details as { code?: string } | undefined)?.code).toBe(
ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID,
);
wsRetry.close();
expect(
(await listDevicePairing()).pending.filter((entry) => entry.deviceId === identity.deviceId),
).toEqual([]);
} finally {
await server.close();
restoreGatewayToken(prevToken);
@@ -1205,6 +1276,7 @@ export function registerControlUiAndPairingSuite(): void {
test("does not consume bootstrap token when node reconcile fails before hello-ok", async () => {
const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js");
const { approveDevicePairing, listDevicePairing } = await import("../infra/device-pairing.js");
const reconcileModule = await import("./node-connect-reconcile.js");
const reconcileSpy = vi
.spyOn(reconcileModule, "reconcileNodePairingOnConnect")
@@ -1228,6 +1300,25 @@ export function registerControlUiAndPairingSuite(): void {
},
});
const wsInitial = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
const initial = await connectReq(wsInitial, {
skipDefaultAuth: true,
bootstrapToken: issued.token,
role: "node",
scopes: [],
client: nodeClient,
deviceIdentityPath: identityPath,
});
expect(initial.ok).toBe(false);
wsInitial.close();
const pending = (await listDevicePairing()).pending.find(
(entry) => entry.clientId === nodeClient.id,
);
if (!pending) {
throw new Error("expected pending bootstrap pairing request");
}
await approveDevicePairing(pending.requestId, { callerScopes: ["operator.pairing"] });
const wsFail = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
await expect(
connectReq(wsFail, {

View File

@@ -3,10 +3,10 @@ import os from "node:os";
import type { RawData, WebSocket } from "ws";
import { getRuntimeConfig } from "../../../config/io.js";
import {
getBoundDeviceBootstrapProfile,
getDeviceBootstrapTokenProfile,
redeemDeviceBootstrapTokenProfile,
revokeDeviceBootstrapToken,
restoreDeviceBootstrapToken,
verifyDeviceBootstrapToken,
} from "../../../infra/device-bootstrap.js";
import {
@@ -14,7 +14,6 @@ import {
normalizeDevicePublicKeyBase64Url,
} from "../../../infra/device-identity.js";
import {
approveBootstrapDevicePairing,
approveDevicePairing,
ensureDeviceToken,
getPairedDevice,
@@ -42,10 +41,6 @@ import { loadVoiceWakeConfig } from "../../../infra/voicewake.js";
import { rawDataToString } from "../../../infra/ws.js";
import { logRejectedLargePayload } from "../../../logging/diagnostic-payload.js";
import type { createSubsystemLogger } from "../../../logging/subsystem.js";
import {
resolveBootstrapProfileScopesForRole,
type DeviceBootstrapProfile,
} from "../../../shared/device-bootstrap-profile.js";
import { roleScopesAllow } from "../../../shared/operator-scope-compat.js";
import {
isBrowserOperatorUiClient,
@@ -900,9 +895,6 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
authMethod === "bootstrap-token" && bootstrapTokenCandidate
? await getDeviceBootstrapTokenProfile({ token: bootstrapTokenCandidate })
: null;
let boundBootstrapProfile: DeviceBootstrapProfile | null = null;
let handoffBootstrapProfile: DeviceBootstrapProfile | null = null;
const trustedProxyAuthOk = isTrustedProxyControlUiOperatorAuth({
isControlUi,
role,
@@ -997,21 +989,6 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
allowedScopes: pairedScopes,
});
};
if (
boundBootstrapProfile === null &&
authMethod === "bootstrap-token" &&
reason === "not-paired" &&
role === "node" &&
scopes.length === 0 &&
!existingPairedDevice &&
bootstrapTokenCandidate
) {
boundBootstrapProfile = await getBoundDeviceBootstrapProfile({
token: bootstrapTokenCandidate,
deviceId: device.id,
publicKey: devicePublicKey,
});
}
const allowSilentExistingNonOperatorPairing = !(
existingPairedDevice && role !== "operator"
);
@@ -1039,30 +1016,14 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
autoApproveCidrs: configSnapshot.gateway?.nodes?.pairing?.autoApproveCidrs,
},
);
const allowSilentBootstrapPairing =
authMethod === "bootstrap-token" &&
reason === "not-paired" &&
role === "node" &&
scopes.length === 0 &&
!existingPairedDevice &&
boundBootstrapProfile !== null;
const bootstrapProfileForSilentApproval = allowSilentBootstrapPairing
? boundBootstrapProfile
: null;
const bootstrapPairingRoles = bootstrapProfileForSilentApproval
? Array.from(new Set([role, ...bootstrapProfileForSilentApproval.roles]))
: undefined;
const pairing = await requestDevicePairing({
deviceId: device.id,
publicKey: devicePublicKey,
...clientPairingMetadata,
...(bootstrapPairingRoles ? { roles: bootstrapPairingRoles } : {}),
silent:
reason === "scope-upgrade"
? false
: allowSilentLocalPairing ||
allowSilentBootstrapPairing ||
allowSilentTrustedCidrsNodePairing,
: allowSilentLocalPairing || allowSilentTrustedCidrsNodePairing,
});
const context = buildRequestContext();
let approved: Awaited<ReturnType<typeof approveDevicePairing>> | undefined;
@@ -1083,18 +1044,10 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
return replacementPending?.requestId;
};
if (pairing.request.silent === true) {
approved = bootstrapProfileForSilentApproval
? await approveBootstrapDevicePairing(
pairing.request.requestId,
bootstrapProfileForSilentApproval,
)
: await approveDevicePairing(pairing.request.requestId, {
callerScopes: scopes,
});
approved = await approveDevicePairing(pairing.request.requestId, {
callerScopes: scopes,
});
if (approved?.status === "approved") {
if (bootstrapProfileForSilentApproval) {
handoffBootstrapProfile = bootstrapProfileForSilentApproval;
}
logGateway.info(
`device pairing auto-approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`,
);
@@ -1143,9 +1096,22 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
? existingPairedDevice.scopes
: []
: [];
const retryAfterBootstrapPairingApproval =
authMethod === "bootstrap-token" &&
reason === "not-paired" &&
role === "node" &&
scopes.length === 0 &&
!existingPairedDevice;
const pairingErrorDetails = buildPairingConnectErrorDetails({
reason,
requestId: recoveryRequestId,
...(retryAfterBootstrapPairingApproval
? {
recommendedNextStep: "wait_then_retry",
retryable: true,
pauseReconnect: false,
}
: {}),
deviceId: device.id,
requestedRole: role,
requestedScopes: scopes,
@@ -1290,50 +1256,6 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
shouldIssueDeviceToken && device && hasServerApprovedDeviceTokenBaseline
? await ensureDeviceToken({ deviceId: device.id, role, scopes })
: null;
const bootstrapDeviceTokens: Array<{
deviceToken: string;
role: string;
scopes: string[];
issuedAtMs: number;
}> = [];
if (deviceToken) {
bootstrapDeviceTokens.push({
deviceToken: deviceToken.token,
role: deviceToken.role,
scopes: deviceToken.scopes,
issuedAtMs: deviceToken.rotatedAtMs ?? deviceToken.createdAtMs,
});
}
if (device && handoffBootstrapProfile) {
const bootstrapProfileForHello = handoffBootstrapProfile as DeviceBootstrapProfile;
for (const bootstrapRole of bootstrapProfileForHello.roles) {
if (bootstrapDeviceTokens.some((entry) => entry.role === bootstrapRole)) {
continue;
}
const bootstrapRoleScopes =
bootstrapRole === "operator"
? resolveBootstrapProfileScopesForRole(
bootstrapRole,
bootstrapProfileForHello.scopes,
)
: [];
const extraToken = await ensureDeviceToken({
deviceId: device.id,
role: bootstrapRole,
scopes: bootstrapRoleScopes,
});
if (!extraToken) {
continue;
}
bootstrapDeviceTokens.push({
deviceToken: extraToken.token,
role: extraToken.role,
scopes: extraToken.scopes,
issuedAtMs: extraToken.rotatedAtMs ?? extraToken.createdAtMs,
});
}
}
if (role === "node") {
const reconciliation = await reconcileNodePairingOnConnect({
cfg: getRuntimeConfig(),
@@ -1561,9 +1483,6 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
? {
deviceToken: deviceToken.token,
issuedAtMs: deviceToken.rotatedAtMs ?? deviceToken.createdAtMs,
...(bootstrapDeviceTokens.length > 1
? { deviceTokens: bootstrapDeviceTokens.slice(1) }
: {}),
}
: {}),
},
@@ -1574,25 +1493,12 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
},
};
try {
await sendFrame({ type: "res", id: frame.id, ok: true, payload: helloOk });
} catch (err) {
setCloseCause("hello-send-failed", { error: formatForLog(err) });
close();
return;
}
let revokedBootstrapTokenRecord:
| Awaited<ReturnType<typeof revokeDeviceBootstrapToken>>["record"]
| undefined;
if (authMethod === "bootstrap-token" && bootstrapTokenCandidate && device) {
try {
if (handoffBootstrapProfile) {
const revoked = await revokeDeviceBootstrapToken({
token: bootstrapTokenCandidate,
});
if (!revoked.removed) {
logGateway.warn(
`bootstrap token revoke skipped after device-token handoff device=${device.id}`,
);
}
} else if (issuedBootstrapProfile) {
if (issuedBootstrapProfile) {
const redemption = await redeemDeviceBootstrapTokenProfile({
token: bootstrapTokenCandidate,
role,
@@ -1606,6 +1512,8 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
logGateway.warn(
`bootstrap token revoke skipped after profile redemption device=${device.id}`,
);
} else {
revokedBootstrapTokenRecord = revoked.record;
}
}
}
@@ -1615,6 +1523,22 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
);
}
}
try {
await sendFrame({ type: "res", id: frame.id, ok: true, payload: helloOk });
} catch (err) {
if (revokedBootstrapTokenRecord) {
try {
await restoreDeviceBootstrapToken({ record: revokedBootstrapTokenRecord });
} catch (restoreErr) {
logGateway.warn(
`bootstrap token restore after hello-send failure failed device=${device?.id ?? "unknown"}: ${formatForLog(restoreErr)}`,
);
}
}
setCloseCause("hello-send-failed", { error: formatForLog(err) });
close();
return;
}
logWs("out", "hello-ok", {
connId,
methods: gatewayMethods.length,

View File

@@ -71,8 +71,8 @@ describe("device bootstrap tokens", () => {
expect(parsed[issued.token]?.ts).toBe(Date.now());
expect(parsed[issued.token]?.issuedAtMs).toBe(Date.now());
expect(parsed[issued.token]?.profile).toEqual({
roles: ["node", "operator"],
scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"],
roles: ["node"],
scopes: [],
});
});
@@ -151,8 +151,8 @@ describe("device bootstrap tokens", () => {
await expect(getDeviceBootstrapTokenProfile({ baseDir, token: issued.token })).resolves.toEqual(
{
roles: ["node", "operator"],
scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"],
roles: ["node"],
scopes: [],
},
);
await expect(getDeviceBootstrapTokenProfile({ baseDir, token: "invalid" })).resolves.toBeNull();
@@ -172,7 +172,7 @@ describe("device bootstrap tokens", () => {
}),
).resolves.toEqual({
recorded: true,
fullyRedeemed: false,
fullyRedeemed: true,
});
await expect(
@@ -180,18 +180,7 @@ describe("device bootstrap tokens", () => {
role: "operator",
scopes: ["operator.approvals", "operator.read", "operator.write", "operator.talk.secrets"],
}),
).resolves.toEqual({ ok: true });
await expect(
redeemDeviceBootstrapTokenProfile({
baseDir,
token: issued.token,
role: "operator",
scopes: ["operator.approvals", "operator.read", "operator.write", "operator.talk.secrets"],
}),
).resolves.toEqual({
recorded: true,
fullyRedeemed: true,
});
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
});
it("clears outstanding bootstrap tokens on demand", async () => {
@@ -316,9 +305,15 @@ describe("device bootstrap tokens", () => {
expect(raw).toContain(issued.token);
});
it("allows operator scope subsets within the issued bootstrap profile", async () => {
it("allows operator scope subsets within an explicitly issued bootstrap profile", async () => {
const baseDir = await createTempDir();
const issued = await issueDeviceBootstrapToken({ baseDir });
const issued = await issueDeviceBootstrapToken({
baseDir,
profile: {
roles: ["operator"],
scopes: ["operator.read"],
},
});
await expect(
verifyBootstrapToken(baseDir, issued.token, {
@@ -537,8 +532,8 @@ describe("device bootstrap tokens", () => {
baseDir,
}),
).resolves.toEqual({
roles: ["node", "operator"],
scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"],
roles: ["node"],
scopes: [],
});
});

View File

@@ -288,6 +288,36 @@ export async function revokeDeviceBootstrapToken(params: {
});
}
export async function revokeDeviceBootstrapTokensForDevice(params: {
deviceId: string;
publicKey: string;
baseDir?: string;
}): Promise<{ removed: number }> {
return await withLock(async () => {
const deviceId = params.deviceId.trim();
const publicKey = normalizeBootstrapPublicKey(params.publicKey);
if (!deviceId || !publicKey) {
return { removed: 0 };
}
const state = await loadState(params.baseDir);
let removed = 0;
for (const [tokenKey, record] of Object.entries(state)) {
const recordPublicKey =
typeof record.publicKey === "string"
? normalizeBootstrapPublicKey(record.publicKey)
: undefined;
if (record.deviceId?.trim() === deviceId && recordPublicKey === publicKey) {
delete state[tokenKey];
removed += 1;
}
}
if (removed > 0) {
await persistState(state, params.baseDir);
}
return { removed };
});
}
export async function restoreDeviceBootstrapToken(params: {
record: DeviceBootstrapTokenRecord;
baseDir?: string;

View File

@@ -14,6 +14,7 @@ import {
listDevicePairing,
removePairedDevice,
requestDevicePairing,
rejectDevicePairing,
revokeDeviceToken,
rotateDeviceToken,
updatePairedDeviceMetadata,
@@ -640,6 +641,48 @@ describe("device pairing tokens", () => {
expect(paired?.tokens?.operator?.scopes).toEqual(["operator.read"]);
});
test("rejecting a bootstrap-bound pending request revokes the bootstrap token", async () => {
const baseDir = await makeDevicePairingDir();
const issued = await issueDeviceBootstrapToken({ baseDir });
await expect(
verifyDeviceBootstrapToken({
token: issued.token,
deviceId: "bootstrap-reject-device",
publicKey: "bootstrap-reject-public-key",
role: "node",
scopes: [],
baseDir,
}),
).resolves.toEqual({ ok: true });
const pending = await requestDevicePairing(
{
deviceId: "bootstrap-reject-device",
publicKey: "bootstrap-reject-public-key",
role: "node",
roles: ["node"],
scopes: [],
},
baseDir,
);
await expect(rejectDevicePairing(pending.request.requestId, baseDir)).resolves.toEqual({
requestId: pending.request.requestId,
deviceId: "bootstrap-reject-device",
});
await expect(
verifyDeviceBootstrapToken({
token: issued.token,
deviceId: "bootstrap-reject-device",
publicKey: "bootstrap-reject-public-key",
role: "node",
scopes: [],
baseDir,
}),
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
});
test("fails closed for operator approvals when caller scopes are omitted", async () => {
const baseDir = await makeDevicePairingDir();
const request = await requestDevicePairing(
@@ -989,14 +1032,14 @@ describe("device pairing tokens", () => {
expect(paired?.tokens?.node?.scopes).toStrictEqual([]);
});
test("bootstrap pairing seeds node and operator device tokens explicitly", async () => {
test("bootstrap pairing seeds only the requested node token by default", async () => {
const baseDir = await makeDevicePairingDir();
const request = await requestDevicePairing(
{
deviceId: "bootstrap-device-1",
publicKey: "bootstrap-public-key-1",
role: "node",
roles: ["node", "operator"],
roles: ["node"],
scopes: [],
silent: true,
},
@@ -1011,28 +1054,20 @@ describe("device pairing tokens", () => {
expectRecordFields(approved, "approved result", { status: "approved" });
const paired = await getPairedDevice("bootstrap-device-1", baseDir);
expectArrayIncludesAll(paired?.roles, ["node", "operator"], "paired roles");
expectArrayIncludesAll(
paired?.approvedScopes,
PAIRING_SETUP_BOOTSTRAP_PROFILE.scopes,
"paired approved scopes",
);
expect(paired?.roles).toEqual(["node"]);
expect(paired?.approvedScopes).toStrictEqual([]);
expect(paired?.tokens?.node?.scopes).toStrictEqual([]);
expectArrayIncludesAll(
paired?.tokens?.operator?.scopes,
PAIRING_SETUP_BOOTSTRAP_PROFILE.scopes,
"operator token scopes",
);
expect(paired?.tokens?.operator).toBeUndefined();
});
test("bootstrap-issued operator tokens accept handoff scopes and reject admin or pairing", async () => {
test("default bootstrap pairing does not issue operator tokens", async () => {
const baseDir = await makeDevicePairingDir();
const request = await requestDevicePairing(
{
deviceId: "bootstrap-device-operator-default",
publicKey: "bootstrap-public-key-operator-default",
role: "node",
roles: ["node", "operator"],
roles: ["node"],
scopes: [],
silent: true,
},
@@ -1047,34 +1082,56 @@ describe("device pairing tokens", () => {
expectRecordFields(approved, "approved result", { status: "approved" });
const paired = await getPairedDevice("bootstrap-device-operator-default", baseDir);
const operatorToken = requireToken(paired?.tokens?.operator?.token);
const nodeToken = requireToken(paired?.tokens?.node?.token);
await expect(
verifyDeviceToken({
deviceId: "bootstrap-device-operator-default",
token: operatorToken,
token: nodeToken,
role: "operator",
scopes: ["operator.approvals", "operator.read", "operator.write"],
baseDir,
}),
).resolves.toEqual({ ok: false, reason: "token-missing" });
});
test("bootstrap node approval preserves existing operator token scopes", async () => {
const baseDir = await makeDevicePairingDir();
await setupPairedOperatorDevice(baseDir, ["operator.admin"]);
const before = await getPairedDevice("device-1", baseDir);
const operatorToken = requireToken(before?.tokens?.operator?.token);
const request = await requestDevicePairing(
{
deviceId: "device-1",
publicKey: "public-key-1",
role: "node",
roles: ["node"],
scopes: [],
silent: true,
},
baseDir,
);
const approved = await approveBootstrapDevicePairing(
request.request.requestId,
PAIRING_SETUP_BOOTSTRAP_PROFILE,
baseDir,
);
expectRecordFields(approved, "approved result", { status: "approved" });
const paired = await getPairedDevice("device-1", baseDir);
expect(paired?.approvedScopes).toEqual(["operator.admin"]);
expect(paired?.tokens?.operator?.token).toBe(operatorToken);
expect(paired?.tokens?.node?.scopes).toStrictEqual([]);
await expect(
verifyDeviceToken({
deviceId: "device-1",
token: operatorToken,
role: "operator",
scopes: ["operator.read"],
baseDir,
}),
).resolves.toEqual({ ok: true });
await expect(
verifyDeviceToken({
deviceId: "bootstrap-device-operator-default",
token: operatorToken,
role: "operator",
scopes: ["operator.admin"],
baseDir,
}),
).resolves.toEqual({ ok: false, reason: "scope-mismatch" });
await expect(
verifyDeviceToken({
deviceId: "bootstrap-device-operator-default",
token: operatorToken,
role: "operator",
scopes: ["operator.pairing"],
baseDir,
}),
).resolves.toEqual({ ok: false, reason: "scope-mismatch" });
});
test("bootstrap pairing keeps operator token scopes operator-only", async () => {
@@ -1085,7 +1142,7 @@ describe("device pairing tokens", () => {
publicKey: "bootstrap-public-key-operator-scope",
role: "node",
roles: ["node", "operator"],
scopes: [],
scopes: ["node.exec", "operator.read", "operator.write"],
silent: true,
},
baseDir,
@@ -1114,7 +1171,13 @@ describe("device pairing tokens", () => {
publicKey: "bootstrap-public-key-bounded-baseline",
role: "node",
roles: ["node", "operator"],
scopes: [],
scopes: [
"node.exec",
"operator.approvals",
"operator.read",
"operator.talk.secrets",
"operator.write",
],
silent: true,
},
baseDir,
@@ -1164,23 +1227,23 @@ describe("device pairing tokens", () => {
test("bootstrap pairing sanitizes merged legacy baseline scopes", async () => {
const baseDir = await makeDevicePairingDir();
const bootstrapProfile = {
roles: ["node", "operator"],
scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"],
};
const first = await requestDevicePairing(
{
deviceId: "bootstrap-device-legacy-baseline",
publicKey: "bootstrap-public-key-legacy-baseline",
role: "node",
roles: ["node", "operator"],
scopes: [],
scopes: bootstrapProfile.scopes,
silent: true,
},
baseDir,
);
await approveBootstrapDevicePairing(
first.request.requestId,
PAIRING_SETUP_BOOTSTRAP_PROFILE,
baseDir,
);
await approveBootstrapDevicePairing(first.request.requestId, bootstrapProfile, baseDir);
await mutatePairedDevice(baseDir, "bootstrap-device-legacy-baseline", (device) => {
device.approvedScopes = ["operator.admin"];
device.scopes = ["operator.admin"];
@@ -1192,20 +1255,20 @@ describe("device pairing tokens", () => {
publicKey: "bootstrap-public-key-legacy-baseline-rotated",
role: "node",
roles: ["node", "operator"],
scopes: [],
scopes: bootstrapProfile.scopes,
silent: true,
},
baseDir,
);
const approved = await approveBootstrapDevicePairing(
repair.request.requestId,
PAIRING_SETUP_BOOTSTRAP_PROFILE,
bootstrapProfile,
baseDir,
);
expectRecordFields(approved, "approved result", { status: "approved" });
const paired = await getPairedDevice("bootstrap-device-legacy-baseline", baseDir);
expect(paired?.approvedScopes).toEqual(PAIRING_SETUP_BOOTSTRAP_PROFILE.scopes);
expect(paired?.approvedScopes).toEqual(bootstrapProfile.scopes);
await expect(
ensureDeviceToken({
deviceId: "bootstrap-device-legacy-baseline",

View File

@@ -10,6 +10,7 @@ import {
resolveScopeOutsideRequestedRoles,
roleScopesAllow,
} from "../shared/operator-scope-compat.js";
import { revokeDeviceBootstrapTokensForDevice } from "./device-bootstrap.js";
import {
createAsyncLock,
pruneExpiredPending,
@@ -19,7 +20,6 @@ import {
resolvePairingPaths,
writeJson,
} from "./pairing-files.js";
import { rejectPendingPairingRequest } from "./pairing-pending.js";
import { generatePairingToken, verifyPairingToken } from "./pairing-token.js";
export type DevicePairingPendingRequest = {
@@ -430,6 +430,27 @@ function resolveRoleScopedDeviceTokenScopes(role: string, scopes: string[] | und
return normalized.filter((scope) => !scope.startsWith(OPERATOR_SCOPE_PREFIX));
}
function preserveRoleScopedApprovalScopes(role: string, scopes: string[] | undefined): string[] {
if (!Array.isArray(scopes)) {
return [];
}
const out = new Set<string>();
for (const scope of scopes) {
const trimmed = scope.trim();
if (!trimmed) {
continue;
}
const belongsToRole =
role === OPERATOR_ROLE
? trimmed.startsWith(OPERATOR_SCOPE_PREFIX)
: !trimmed.startsWith(OPERATOR_SCOPE_PREFIX);
if (belongsToRole) {
out.add(trimmed);
}
}
return [...out];
}
function resolveApprovedTokenScopes(params: {
role: string;
pending: DevicePairingPendingRequest;
@@ -692,9 +713,6 @@ export async function approveBootstrapDevicePairing(
bootstrapProfile: DeviceBootstrapProfile,
baseDir?: string,
): Promise<ApproveDevicePairingResult> {
// QR bootstrap handoff is an explicit trust path: it can seed the bounded
// node/operator baseline from the verified bootstrap profile without routing
// operator scope approval through the generic interactive approval checker.
const approvedRoles = mergeRoles(bootstrapProfile.roles) ?? [];
const approvedScopes = resolveBootstrapProfileScopesForRoles(
approvedRoles,
@@ -725,28 +743,26 @@ export async function approveBootstrapDevicePairing(
const now = Date.now();
const existing = state.pairedByDeviceId[pending.deviceId];
const roles = mergeRoles(
existing?.roles,
existing?.role,
pending.roles,
pending.role,
approvedRoles,
);
const nextApprovedScopes = mergeScopes(
existing?.approvedScopes ?? existing?.scopes,
pending.scopes,
approvedScopes,
);
const sanitizedApprovedScopes = resolveBootstrapProfileScopesForRoles(
approvedRoles,
nextApprovedScopes ?? [],
const grantedRoles = requestedRoles;
const grantedScopes = resolveBootstrapProfileScopesForRoles(grantedRoles, pending.scopes ?? []);
const grantedRoleSet = new Set(grantedRoles);
const preservedExistingScopes = (mergeRoles(existing?.roles, existing?.role) ?? []).flatMap(
(existingRole) =>
grantedRoleSet.has(existingRole)
? []
: preserveRoleScopedApprovalScopes(
existingRole,
existing?.approvedScopes ?? existing?.scopes,
),
);
const roles = mergeRoles(existing?.roles, existing?.role, pending.roles, pending.role);
const nextApprovedScopes = mergeScopes(preservedExistingScopes, grantedScopes);
const tokens = existing?.tokens ? { ...existing.tokens } : {};
for (const roleForToken of approvedRoles) {
for (const roleForToken of grantedRoles) {
const existingToken = tokens[roleForToken];
const tokenScopes =
roleForToken === OPERATOR_ROLE
? resolveBootstrapProfileScopesForRole(roleForToken, approvedScopes)
? resolveBootstrapProfileScopesForRole(roleForToken, grantedScopes)
: [];
tokens[roleForToken] = buildDeviceAuthToken({
role: roleForToken,
@@ -767,8 +783,8 @@ export async function approveBootstrapDevicePairing(
clientMode: pending.clientMode,
role: pending.role,
roles,
scopes: sanitizedApprovedScopes,
approvedScopes: sanitizedApprovedScopes,
scopes: nextApprovedScopes,
approvedScopes: nextApprovedScopes,
remoteIp: pending.remoteIp,
tokens,
createdAtMs: existing?.createdAtMs ?? now,
@@ -786,17 +802,19 @@ export async function rejectDevicePairing(
baseDir?: string,
): Promise<{ requestId: string; deviceId: string } | null> {
return await withLock(async () => {
return await rejectPendingPairingRequest<
DevicePairingPendingRequest,
DevicePairingStateFile,
"deviceId"
>({
requestId,
idKey: "deviceId",
loadState: () => loadState(baseDir),
persistState: (state) => persistState(state, baseDir, "pending"),
getId: (pending: DevicePairingPendingRequest) => pending.deviceId,
const state = await loadState(baseDir);
const pending = state.pendingById[requestId];
if (!pending) {
return null;
}
delete state.pendingById[requestId];
await persistState(state, baseDir, "pending");
await revokeDeviceBootstrapTokensForDevice({
deviceId: pending.deviceId,
publicKey: pending.publicKey,
baseDir,
});
return { requestId, deviceId: pending.deviceId };
});
}

View File

@@ -91,8 +91,8 @@ describe("pairing setup code", () => {
expect(issueDeviceBootstrapTokenMock).toHaveBeenCalledWith({
baseDir: undefined,
profile: {
roles: ["node", "operator"],
scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"],
roles: ["node"],
scopes: [],
},
});
if (params.url) {

View File

@@ -1,6 +1,7 @@
import { describe, expect, test } from "vitest";
import {
BOOTSTRAP_HANDOFF_OPERATOR_SCOPES,
PAIRING_SETUP_BOOTSTRAP_PROFILE,
normalizeDeviceBootstrapHandoffProfile,
resolveBootstrapProfileScopesForRole,
resolveBootstrapProfileScopesForRoles,
@@ -56,7 +57,14 @@ describe("device bootstrap profile", () => {
});
});
test("bootstrap handoff operator allowlist stays aligned with pairing setup profile", () => {
test("default setup profile is node-only", () => {
expect(PAIRING_SETUP_BOOTSTRAP_PROFILE).toEqual({
roles: ["node"],
scopes: [],
});
});
test("bootstrap handoff operator allowlist stays bounded", () => {
expect([...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES]).toEqual([
"operator.approvals",
"operator.read",

View File

@@ -20,8 +20,8 @@ export const BOOTSTRAP_HANDOFF_OPERATOR_SCOPES = [
const BOOTSTRAP_HANDOFF_OPERATOR_SCOPE_SET = new Set<string>(BOOTSTRAP_HANDOFF_OPERATOR_SCOPES);
export const PAIRING_SETUP_BOOTSTRAP_PROFILE: DeviceBootstrapProfile = {
roles: ["node", "operator"],
scopes: [...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES],
roles: ["node"],
scopes: [],
};
export function resolveBootstrapProfileScopesForRole(

View File

@@ -903,6 +903,44 @@ describe("GatewayBrowserClient", () => {
vi.useRealTimers();
});
it("keeps reconnecting on PAIRING_REQUIRED when retry hints keep reconnect active", async () => {
useNodeFakeTimers();
localStorage.clear();
const client = new GatewayBrowserClient({
url: "ws://127.0.0.1:18789",
token: "setup-token",
});
const { ws: ws1, connectFrame: connect } = await startConnect(client);
ws1.emitMessage({
type: "res",
id: connect.id,
ok: false,
error: {
code: "INVALID_REQUEST",
message: "unauthorized",
details: {
code: "PAIRING_REQUIRED",
reason: "not-paired",
recommendedNextStep: "wait_then_retry",
pauseReconnect: false,
},
},
});
await expectSocketClosed(ws1);
ws1.emitClose(4008, "connect failed");
await vi.advanceTimersByTimeAsync(799);
expect(wsInstances).toHaveLength(1);
await vi.advanceTimersByTimeAsync(1);
expect(wsInstances).toHaveLength(2);
client.stop();
vi.useRealTimers();
});
it("clears stale stored device tokens and does not reconnect on AUTH_DEVICE_TOKEN_MISMATCH", async () => {
useNodeFakeTimers();

View File

@@ -10,6 +10,7 @@ import {
formatConnectErrorMessage,
readConnectErrorRecoveryAdvice,
readConnectErrorDetailCode,
readPairingConnectErrorDetails,
} from "../../../src/gateway/protocol/connect-error-details.js";
import {
isRetryableGatewayStartupUnavailableError,
@@ -71,6 +72,14 @@ export function resolveGatewayErrorDetailCode(
return readConnectErrorDetailCode(error?.details);
}
function shouldContinueReconnectForPairingRequired(details: unknown): boolean {
const pairingDetails = readPairingConnectErrorDetails(details);
return (
pairingDetails?.pauseReconnect === false ||
pairingDetails?.recommendedNextStep === "wait_then_retry"
);
}
/**
* Auth errors that won't resolve without user action — don't auto-reconnect.
*
@@ -84,6 +93,12 @@ export function isNonRecoverableAuthError(error: GatewayErrorInfo | undefined):
return false;
}
const code = resolveGatewayErrorDetailCode(error);
if (
code === ConnectErrorDetailCodes.PAIRING_REQUIRED &&
shouldContinueReconnectForPairingRequired(error.details)
) {
return false;
}
return (
code === ConnectErrorDetailCodes.AUTH_TOKEN_MISSING ||
code === ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID ||