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 ### 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. - 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. - 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. - 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 return null
} }

View File

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

View File

@@ -29,14 +29,14 @@ import java.util.UUID
@Config(sdk = [34]) @Config(sdk = [34])
class GatewayBootstrapAuthTest { class GatewayBootstrapAuthTest {
@Test @Test
fun connectsOperatorSessionWhenOnlyBootstrapAuthExists() { fun doesNotConnectOperatorSessionWhenOnlyBootstrapAuthExists() {
assertTrue( assertFalse(
shouldConnectOperatorSession( shouldConnectOperatorSession(
NodeRuntime.GatewayConnectAuth(token = "", bootstrapToken = "bootstrap-1", password = ""), NodeRuntime.GatewayConnectAuth(token = "", bootstrapToken = "bootstrap-1", password = ""),
storedOperatorToken = "", storedOperatorToken = "",
), ),
) )
assertTrue( assertFalse(
shouldConnectOperatorSession( shouldConnectOperatorSession(
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null), NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null),
storedOperatorToken = null, storedOperatorToken = null,
@@ -84,17 +84,14 @@ class GatewayBootstrapAuthTest {
} }
@Test @Test
fun resolveOperatorSessionConnectAuthUsesBootstrapWhenNoStoredOperatorTokenExists() { fun resolveOperatorSessionConnectAuthIgnoresBootstrapWhenNoStoredOperatorTokenExists() {
val resolved = val resolved =
resolveOperatorSessionConnectAuth( resolveOperatorSessionConnectAuth(
auth = NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null), auth = NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null),
storedOperatorToken = null, storedOperatorToken = null,
) )
assertEquals( assertNull(resolved)
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null),
resolved,
)
} }
@Test @Test
@@ -174,7 +171,7 @@ class GatewayBootstrapAuthTest {
assertEquals("fp-1", prefs.loadGatewayTlsFingerprint(endpoint.stableId)) assertEquals("fp-1", prefs.loadGatewayTlsFingerprint(endpoint.stableId))
assertEquals("setup-bootstrap-token", desiredBootstrapToken(runtime, "nodeSession")) assertEquals("setup-bootstrap-token", desiredBootstrapToken(runtime, "nodeSession"))
assertEquals("setup-bootstrap-token", desiredBootstrapToken(runtime, "operatorSession")) assertNull(desiredBootstrapToken(runtime, "operatorSession"))
} }
@Test @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: That bootstrap token carries the built-in pairing bootstrap profile:
- primary handed-off `node` token stays `scopes: []` - the built-in setup profile allows only the `node` role
- any handed-off `operator` token stays bounded to the bootstrap allowlist: - after approval, the handed-off `node` token stays `scopes: []`
`operator.approvals`, `operator.read`, `operator.talk.secrets`, `operator.write` - the built-in setup-code flow does not hand off an `operator` token
- bootstrap scope checks are role-prefixed, not one flat scope pool: - operator access requires a separate approved operator pairing or token flow
operator scope entries only satisfy operator requests, and non-operator roles
must still request scopes under their own role prefix
- later token rotation/revocation remains bounded by both the device's approved - later token rotation/revocation remains bounded by both the device's approved
role contract and the caller session's operator scopes 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` when there is only one pending request
- `/pair approve latest` for most recent - `/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. 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. - `--token` and `--password` are mutually exclusive.
- The setup code itself now carries an opaque short-lived `bootstrapToken`, not the shared gateway token/password. - 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: []`. - Built-in setup-code bootstrap is node-only. After approval, the primary node token 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`. - 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.
- Bootstrap scope checks are role-prefixed. That operator allowlist only satisfies operator requests; non-operator roles still need scopes under their own role prefix.
- 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. - 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 - With `--remote`, OpenClaw requires either `gateway.remote.url` or
`gateway.tailscale.mode=serve|funnel`. `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 Built-in QR/setup-code bootstrap is node-only. After the owner approves the
bounded role entries in `deviceTokens`: pending node request, `hello-ok.auth` includes the primary node token:
```json ```json
{ {
"auth": { "auth": {
"deviceToken": "…", "deviceToken": "…",
"role": "node", "role": "node",
"scopes": [], "scopes": []
"deviceTokens": [
{
"deviceToken": "…",
"role": "operator",
"scopes": ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"]
}
]
} }
} }
``` ```
For the built-in node/operator bootstrap flow, the primary node token stays The built-in setup-code flow does not include additional `deviceTokens` entries
`scopes: []` and any handed-off operator token stays bounded to the bootstrap or hand off an operator token. Client authors should treat the optional
operator allowlist (`operator.approvals`, `operator.read`, `hello-ok.auth.deviceTokens` field as legacy/custom bootstrap extension data:
`operator.talk.secrets`, `operator.write`). Bootstrap scope checks stay persist it only when present on a trusted transport, and do not require it for
role-prefixed: operator entries only satisfy operator requests, and non-operator built-in pairing.
roles still need scopes under their own role prefix.
### Node example ### Node example
@@ -694,9 +686,17 @@ rather than the pre-handshake defaults.
`AUTH_TOKEN_MISMATCH` retry is gated to **trusted endpoints only** `AUTH_TOKEN_MISMATCH` retry is gated to **trusted endpoints only**
loopback, or `wss://` with a pinned `tlsFingerprint`. Public `wss://` loopback, or `wss://` with a pinned `tlsFingerprint`. Public `wss://`
without pinning does not qualify. without pinning does not qualify.
- Additional `hello-ok.auth.deviceTokens` entries are bootstrap handoff tokens. - Built-in setup-code bootstrap returns only the primary node
Persist them only when the connect used bootstrap auth on a trusted transport `hello-ok.auth.deviceToken`; clients must not expect an additional operator
such as `wss://` or loopback/local pairing. 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 - If a client supplies an **explicit** `deviceToken` or explicit `scopes`, that
caller-requested scope set remains authoritative; cached scopes are only caller-requested scope set remains authoritative; cached scopes are only
reused when the client is reusing the stored per-device token. 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`). - 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. - 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. - 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: 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 () => { it("clears stale stored device tokens and does not reconnect on AUTH_DEVICE_TOKEN_MISMATCH", async () => {
loadDeviceAuthTokenMock.mockReturnValue({ loadDeviceAuthTokenMock.mockReturnValue({
token: "stored-device-token", token: "stored-device-token",

View File

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

View File

@@ -74,12 +74,18 @@ describe("pairing connect details", () => {
buildPairingConnectErrorDetails({ buildPairingConnectErrorDetails({
reason: ConnectPairingRequiredReasons.NOT_PAIRED, reason: ConnectPairingRequiredReasons.NOT_PAIRED,
requestId: "req-123", requestId: "req-123",
recommendedNextStep: "wait_then_retry",
retryable: true,
pauseReconnect: false,
}), }),
).toEqual({ ).toEqual({
code: "PAIRING_REQUIRED", code: "PAIRING_REQUIRED",
reason: "not-paired", reason: "not-paired",
requestId: "req-123", requestId: "req-123",
remediationHint: "Approve this device from the pending pairing requests.", 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; reason?: ConnectPairingRequiredReason;
requestId?: string; requestId?: string;
remediationHint?: string; remediationHint?: string;
recommendedNextStep?: ConnectRecoveryNextStep;
retryable?: boolean;
pauseReconnect?: boolean;
deviceId?: string; deviceId?: string;
requestedRole?: string; requestedRole?: string;
requestedScopes?: string[]; requestedScopes?: string[];
@@ -245,6 +248,9 @@ function createPairingConnectErrorDetails(params: {
reason?: ConnectPairingRequiredReason; reason?: ConnectPairingRequiredReason;
requestId?: string; requestId?: string;
remediationHint?: string; remediationHint?: string;
recommendedNextStep?: ConnectRecoveryNextStep;
retryable?: boolean;
pauseReconnect?: boolean;
deviceId?: string; deviceId?: string;
requestedRole?: string; requestedRole?: string;
requestedScopes?: string[]; requestedScopes?: string[];
@@ -256,6 +262,9 @@ function createPairingConnectErrorDetails(params: {
...(params.reason ? { reason: params.reason } : {}), ...(params.reason ? { reason: params.reason } : {}),
...(params.requestId ? { requestId: params.requestId } : {}), ...(params.requestId ? { requestId: params.requestId } : {}),
...(params.remediationHint ? { remediationHint: params.remediationHint } : {}), ...(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.deviceId ? { deviceId: params.deviceId } : {}),
...(params.requestedRole ? { requestedRole: params.requestedRole } : {}), ...(params.requestedRole ? { requestedRole: params.requestedRole } : {}),
...(params.requestedScopes ? { requestedScopes: params.requestedScopes } : {}), ...(params.requestedScopes ? { requestedScopes: params.requestedScopes } : {}),
@@ -300,6 +309,9 @@ export function buildPairingConnectErrorDetails(params: {
reason: ConnectPairingRequiredReason | undefined; reason: ConnectPairingRequiredReason | undefined;
requestId?: string; requestId?: string;
remediationHint?: string; remediationHint?: string;
recommendedNextStep?: ConnectRecoveryNextStep;
retryable?: boolean;
pauseReconnect?: boolean;
deviceId?: string; deviceId?: string;
requestedRole?: string; requestedRole?: string;
requestedScopes?: string[]; requestedScopes?: string[];
@@ -319,6 +331,9 @@ export function buildPairingConnectErrorDetails(params: {
reason: params.reason, reason: params.reason,
requestId, requestId,
remediationHint, remediationHint,
recommendedNextStep: params.recommendedNextStep,
retryable: params.retryable,
pauseReconnect: params.pauseReconnect,
deviceId, deviceId,
requestedRole, requestedRole,
requestedScopes, requestedScopes,
@@ -349,6 +364,9 @@ export function readPairingConnectErrorDetails(
reason?: unknown; reason?: unknown;
requestId?: unknown; requestId?: unknown;
remediationHint?: unknown; remediationHint?: unknown;
recommendedNextStep?: unknown;
retryable?: unknown;
pauseReconnect?: unknown;
deviceId?: unknown; deviceId?: unknown;
requestedRole?: unknown; requestedRole?: unknown;
requestedScopes?: unknown; requestedScopes?: unknown;
@@ -359,6 +377,12 @@ export function readPairingConnectErrorDetails(
const requestId = normalizePairingConnectRequestId(raw.requestId); const requestId = normalizePairingConnectRequestId(raw.requestId);
const remediationHint = const remediationHint =
normalizeOptionalString(raw.remediationHint) ?? buildPairingConnectRemediationHint(reason); 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 deviceId = normalizeOptionalString(raw.deviceId);
const requestedRole = normalizeOptionalString(raw.requestedRole); const requestedRole = normalizeOptionalString(raw.requestedRole);
const requestedScopes = normalizeStringArray(raw.requestedScopes); const requestedScopes = normalizeStringArray(raw.requestedScopes);
@@ -368,6 +392,9 @@ export function readPairingConnectErrorDetails(
reason, reason,
requestId, requestId,
remediationHint, remediationHint,
recommendedNextStep,
retryable: typeof raw.retryable === "boolean" ? raw.retryable : undefined,
pauseReconnect: typeof raw.pauseReconnect === "boolean" ? raw.pauseReconnect : undefined,
deviceId, deviceId,
requestedRole, requestedRole,
requestedScopes, 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)", () => { it("allows reconnect for AUTH_TOKEN_MISMATCH (device-token fallback flow)", () => {
// Browser client can queue a single trusted-device retry after shared token mismatch. // Browser client can queue a single trusted-device retry after shared token mismatch.
// Blocking reconnect on mismatch here would skip that bounded recovery attempt. // 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 { export function registerControlUiAndPairingSuite(): void {
const trustedProxyControlUiCases: Array<{ const trustedProxyControlUiCases: Array<{
name: string; 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 } = const { issueDeviceBootstrapToken, verifyDeviceBootstrapToken } =
await import("../infra/device-bootstrap.js"); await import("../infra/device-bootstrap.js");
const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js"); const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js");
const { getPairedDevice, listDevicePairing, verifyDeviceToken } = const { approveDevicePairing, getPairedDevice, listDevicePairing, verifyDeviceToken } =
await import("../infra/device-pairing.js"); await import("../infra/device-pairing.js");
const { server, port, prevToken } = await startControlUiServer("secret"); const { server, port, prevToken } = await startControlUiServer("secret");
@@ -1066,8 +1058,47 @@ export function registerControlUiAndPairingSuite(): void {
client, client,
deviceIdentityPath: identityPath, deviceIdentityPath: identityPath,
}); });
expect(initial.ok).toBe(true); expect(initial.ok).toBe(false);
const initialPayload = initial.payload as 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; type?: string;
auth?: { auth?: {
@@ -1082,61 +1113,32 @@ export function registerControlUiAndPairingSuite(): void {
}; };
} }
| undefined; | undefined;
expect(initialPayload?.type).toBe("hello-ok"); expect(approvedPayload?.type).toBe("hello-ok");
const issuedDeviceToken = initialPayload?.auth?.deviceToken; const issuedDeviceToken = approvedPayload?.auth?.deviceToken;
const issuedOperatorToken = initialPayload?.auth?.deviceTokens?.find( if (!issuedDeviceToken) {
(entry) => entry.role === "operator", throw new Error("expected issued device token");
)?.deviceToken;
if (!issuedDeviceToken || !issuedOperatorToken) {
throw new Error("expected issued device and operator tokens");
} }
expect(initialPayload?.auth?.role).toBe("node"); expect(approvedPayload?.auth?.role).toBe("node");
expect(initialPayload?.auth?.scopes ?? []).toEqual([]); expect(approvedPayload?.auth?.scopes ?? []).toEqual([]);
expect(initialPayload?.auth?.deviceTokens?.some((entry) => entry.role === "node")).toBe( expect(approvedPayload?.auth?.deviceTokens ?? []).toEqual([]);
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"]);
const afterBootstrap = await listDevicePairing(); const afterBootstrap = await listDevicePairing();
expect( expect(
afterBootstrap.pending.filter((entry) => entry.deviceId === identity.deviceId), afterBootstrap.pending.filter((entry) => entry.deviceId === identity.deviceId),
).toEqual([]); ).toEqual([]);
const paired = await getPairedDevice(identity.deviceId); const paired = await getPairedDevice(identity.deviceId);
expectArrayIncludes(paired?.roles, ["node", "operator"]); expect(paired?.roles).toEqual(["node"]);
expectArrayIncludes(paired?.approvedScopes, [ expect(paired?.approvedScopes).toEqual([]);
"operator.approvals",
"operator.read",
"operator.talk.secrets",
"operator.write",
]);
expect(paired?.tokens?.node?.token).toBe(issuedDeviceToken); expect(paired?.tokens?.node?.token).toBe(issuedDeviceToken);
expect(paired?.tokens?.operator?.token).toBe(issuedOperatorToken); expect(paired?.tokens?.operator).toBeUndefined();
if (!issuedDeviceToken || !issuedOperatorToken) {
throw new Error("expected hello-ok auth.deviceTokens for bootstrap onboarding");
}
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
if (wsBootstrap.readyState === WebSocket.CLOSED) { if (wsApproved.readyState === WebSocket.CLOSED) {
resolve(); resolve();
return; return;
} }
wsBootstrap.once("close", () => resolve()); wsApproved.once("close", () => resolve());
wsBootstrap.close(); wsApproved.close();
}); });
const wsReplay = await openWs(port, REMOTE_BOOTSTRAP_HEADERS); const wsReplay = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
@@ -1187,7 +1189,7 @@ export function registerControlUiAndPairingSuite(): void {
await expect( await expect(
verifyDeviceToken({ verifyDeviceToken({
deviceId: identity.deviceId, deviceId: identity.deviceId,
token: issuedOperatorToken, token: issuedDeviceToken,
role: "operator", role: "operator",
scopes: [ scopes: [
"operator.approvals", "operator.approvals",
@@ -1196,7 +1198,76 @@ export function registerControlUiAndPairingSuite(): void {
"operator.write", "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 { } finally {
await server.close(); await server.close();
restoreGatewayToken(prevToken); restoreGatewayToken(prevToken);
@@ -1205,6 +1276,7 @@ export function registerControlUiAndPairingSuite(): void {
test("does not consume bootstrap token when node reconcile fails before hello-ok", async () => { test("does not consume bootstrap token when node reconcile fails before hello-ok", async () => {
const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); 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 reconcileModule = await import("./node-connect-reconcile.js");
const reconcileSpy = vi const reconcileSpy = vi
.spyOn(reconcileModule, "reconcileNodePairingOnConnect") .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); const wsFail = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
await expect( await expect(
connectReq(wsFail, { connectReq(wsFail, {

View File

@@ -3,10 +3,10 @@ import os from "node:os";
import type { RawData, WebSocket } from "ws"; import type { RawData, WebSocket } from "ws";
import { getRuntimeConfig } from "../../../config/io.js"; import { getRuntimeConfig } from "../../../config/io.js";
import { import {
getBoundDeviceBootstrapProfile,
getDeviceBootstrapTokenProfile, getDeviceBootstrapTokenProfile,
redeemDeviceBootstrapTokenProfile, redeemDeviceBootstrapTokenProfile,
revokeDeviceBootstrapToken, revokeDeviceBootstrapToken,
restoreDeviceBootstrapToken,
verifyDeviceBootstrapToken, verifyDeviceBootstrapToken,
} from "../../../infra/device-bootstrap.js"; } from "../../../infra/device-bootstrap.js";
import { import {
@@ -14,7 +14,6 @@ import {
normalizeDevicePublicKeyBase64Url, normalizeDevicePublicKeyBase64Url,
} from "../../../infra/device-identity.js"; } from "../../../infra/device-identity.js";
import { import {
approveBootstrapDevicePairing,
approveDevicePairing, approveDevicePairing,
ensureDeviceToken, ensureDeviceToken,
getPairedDevice, getPairedDevice,
@@ -42,10 +41,6 @@ import { loadVoiceWakeConfig } from "../../../infra/voicewake.js";
import { rawDataToString } from "../../../infra/ws.js"; import { rawDataToString } from "../../../infra/ws.js";
import { logRejectedLargePayload } from "../../../logging/diagnostic-payload.js"; import { logRejectedLargePayload } from "../../../logging/diagnostic-payload.js";
import type { createSubsystemLogger } from "../../../logging/subsystem.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 { roleScopesAllow } from "../../../shared/operator-scope-compat.js";
import { import {
isBrowserOperatorUiClient, isBrowserOperatorUiClient,
@@ -900,9 +895,6 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
authMethod === "bootstrap-token" && bootstrapTokenCandidate authMethod === "bootstrap-token" && bootstrapTokenCandidate
? await getDeviceBootstrapTokenProfile({ token: bootstrapTokenCandidate }) ? await getDeviceBootstrapTokenProfile({ token: bootstrapTokenCandidate })
: null; : null;
let boundBootstrapProfile: DeviceBootstrapProfile | null = null;
let handoffBootstrapProfile: DeviceBootstrapProfile | null = null;
const trustedProxyAuthOk = isTrustedProxyControlUiOperatorAuth({ const trustedProxyAuthOk = isTrustedProxyControlUiOperatorAuth({
isControlUi, isControlUi,
role, role,
@@ -997,21 +989,6 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
allowedScopes: pairedScopes, 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 = !( const allowSilentExistingNonOperatorPairing = !(
existingPairedDevice && role !== "operator" existingPairedDevice && role !== "operator"
); );
@@ -1039,30 +1016,14 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
autoApproveCidrs: configSnapshot.gateway?.nodes?.pairing?.autoApproveCidrs, 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({ const pairing = await requestDevicePairing({
deviceId: device.id, deviceId: device.id,
publicKey: devicePublicKey, publicKey: devicePublicKey,
...clientPairingMetadata, ...clientPairingMetadata,
...(bootstrapPairingRoles ? { roles: bootstrapPairingRoles } : {}),
silent: silent:
reason === "scope-upgrade" reason === "scope-upgrade"
? false ? false
: allowSilentLocalPairing || : allowSilentLocalPairing || allowSilentTrustedCidrsNodePairing,
allowSilentBootstrapPairing ||
allowSilentTrustedCidrsNodePairing,
}); });
const context = buildRequestContext(); const context = buildRequestContext();
let approved: Awaited<ReturnType<typeof approveDevicePairing>> | undefined; let approved: Awaited<ReturnType<typeof approveDevicePairing>> | undefined;
@@ -1083,18 +1044,10 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
return replacementPending?.requestId; return replacementPending?.requestId;
}; };
if (pairing.request.silent === true) { if (pairing.request.silent === true) {
approved = bootstrapProfileForSilentApproval approved = await approveDevicePairing(pairing.request.requestId, {
? await approveBootstrapDevicePairing(
pairing.request.requestId,
bootstrapProfileForSilentApproval,
)
: await approveDevicePairing(pairing.request.requestId, {
callerScopes: scopes, callerScopes: scopes,
}); });
if (approved?.status === "approved") { if (approved?.status === "approved") {
if (bootstrapProfileForSilentApproval) {
handoffBootstrapProfile = bootstrapProfileForSilentApproval;
}
logGateway.info( logGateway.info(
`device pairing auto-approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`, `device pairing auto-approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`,
); );
@@ -1143,9 +1096,22 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
? existingPairedDevice.scopes ? existingPairedDevice.scopes
: [] : []
: []; : [];
const retryAfterBootstrapPairingApproval =
authMethod === "bootstrap-token" &&
reason === "not-paired" &&
role === "node" &&
scopes.length === 0 &&
!existingPairedDevice;
const pairingErrorDetails = buildPairingConnectErrorDetails({ const pairingErrorDetails = buildPairingConnectErrorDetails({
reason, reason,
requestId: recoveryRequestId, requestId: recoveryRequestId,
...(retryAfterBootstrapPairingApproval
? {
recommendedNextStep: "wait_then_retry",
retryable: true,
pauseReconnect: false,
}
: {}),
deviceId: device.id, deviceId: device.id,
requestedRole: role, requestedRole: role,
requestedScopes: scopes, requestedScopes: scopes,
@@ -1290,50 +1256,6 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
shouldIssueDeviceToken && device && hasServerApprovedDeviceTokenBaseline shouldIssueDeviceToken && device && hasServerApprovedDeviceTokenBaseline
? await ensureDeviceToken({ deviceId: device.id, role, scopes }) ? await ensureDeviceToken({ deviceId: device.id, role, scopes })
: null; : 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") { if (role === "node") {
const reconciliation = await reconcileNodePairingOnConnect({ const reconciliation = await reconcileNodePairingOnConnect({
cfg: getRuntimeConfig(), cfg: getRuntimeConfig(),
@@ -1561,9 +1483,6 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
? { ? {
deviceToken: deviceToken.token, deviceToken: deviceToken.token,
issuedAtMs: deviceToken.rotatedAtMs ?? deviceToken.createdAtMs, issuedAtMs: deviceToken.rotatedAtMs ?? deviceToken.createdAtMs,
...(bootstrapDeviceTokens.length > 1
? { deviceTokens: bootstrapDeviceTokens.slice(1) }
: {}),
} }
: {}), : {}),
}, },
@@ -1574,25 +1493,12 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
}, },
}; };
try { let revokedBootstrapTokenRecord:
await sendFrame({ type: "res", id: frame.id, ok: true, payload: helloOk }); | Awaited<ReturnType<typeof revokeDeviceBootstrapToken>>["record"]
} catch (err) { | undefined;
setCloseCause("hello-send-failed", { error: formatForLog(err) });
close();
return;
}
if (authMethod === "bootstrap-token" && bootstrapTokenCandidate && device) { if (authMethod === "bootstrap-token" && bootstrapTokenCandidate && device) {
try { try {
if (handoffBootstrapProfile) { if (issuedBootstrapProfile) {
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) {
const redemption = await redeemDeviceBootstrapTokenProfile({ const redemption = await redeemDeviceBootstrapTokenProfile({
token: bootstrapTokenCandidate, token: bootstrapTokenCandidate,
role, role,
@@ -1606,6 +1512,8 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
logGateway.warn( logGateway.warn(
`bootstrap token revoke skipped after profile redemption device=${device.id}`, `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", { logWs("out", "hello-ok", {
connId, connId,
methods: gatewayMethods.length, 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]?.ts).toBe(Date.now());
expect(parsed[issued.token]?.issuedAtMs).toBe(Date.now()); expect(parsed[issued.token]?.issuedAtMs).toBe(Date.now());
expect(parsed[issued.token]?.profile).toEqual({ expect(parsed[issued.token]?.profile).toEqual({
roles: ["node", "operator"], roles: ["node"],
scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"], scopes: [],
}); });
}); });
@@ -151,8 +151,8 @@ describe("device bootstrap tokens", () => {
await expect(getDeviceBootstrapTokenProfile({ baseDir, token: issued.token })).resolves.toEqual( await expect(getDeviceBootstrapTokenProfile({ baseDir, token: issued.token })).resolves.toEqual(
{ {
roles: ["node", "operator"], roles: ["node"],
scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"], scopes: [],
}, },
); );
await expect(getDeviceBootstrapTokenProfile({ baseDir, token: "invalid" })).resolves.toBeNull(); await expect(getDeviceBootstrapTokenProfile({ baseDir, token: "invalid" })).resolves.toBeNull();
@@ -172,7 +172,7 @@ describe("device bootstrap tokens", () => {
}), }),
).resolves.toEqual({ ).resolves.toEqual({
recorded: true, recorded: true,
fullyRedeemed: false, fullyRedeemed: true,
}); });
await expect( await expect(
@@ -180,18 +180,7 @@ describe("device bootstrap tokens", () => {
role: "operator", role: "operator",
scopes: ["operator.approvals", "operator.read", "operator.write", "operator.talk.secrets"], scopes: ["operator.approvals", "operator.read", "operator.write", "operator.talk.secrets"],
}), }),
).resolves.toEqual({ ok: true }); ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
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,
});
}); });
it("clears outstanding bootstrap tokens on demand", async () => { it("clears outstanding bootstrap tokens on demand", async () => {
@@ -316,9 +305,15 @@ describe("device bootstrap tokens", () => {
expect(raw).toContain(issued.token); 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 baseDir = await createTempDir();
const issued = await issueDeviceBootstrapToken({ baseDir }); const issued = await issueDeviceBootstrapToken({
baseDir,
profile: {
roles: ["operator"],
scopes: ["operator.read"],
},
});
await expect( await expect(
verifyBootstrapToken(baseDir, issued.token, { verifyBootstrapToken(baseDir, issued.token, {
@@ -537,8 +532,8 @@ describe("device bootstrap tokens", () => {
baseDir, baseDir,
}), }),
).resolves.toEqual({ ).resolves.toEqual({
roles: ["node", "operator"], roles: ["node"],
scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"], 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: { export async function restoreDeviceBootstrapToken(params: {
record: DeviceBootstrapTokenRecord; record: DeviceBootstrapTokenRecord;
baseDir?: string; baseDir?: string;

View File

@@ -14,6 +14,7 @@ import {
listDevicePairing, listDevicePairing,
removePairedDevice, removePairedDevice,
requestDevicePairing, requestDevicePairing,
rejectDevicePairing,
revokeDeviceToken, revokeDeviceToken,
rotateDeviceToken, rotateDeviceToken,
updatePairedDeviceMetadata, updatePairedDeviceMetadata,
@@ -640,6 +641,48 @@ describe("device pairing tokens", () => {
expect(paired?.tokens?.operator?.scopes).toEqual(["operator.read"]); 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 () => { test("fails closed for operator approvals when caller scopes are omitted", async () => {
const baseDir = await makeDevicePairingDir(); const baseDir = await makeDevicePairingDir();
const request = await requestDevicePairing( const request = await requestDevicePairing(
@@ -989,14 +1032,14 @@ describe("device pairing tokens", () => {
expect(paired?.tokens?.node?.scopes).toStrictEqual([]); 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 baseDir = await makeDevicePairingDir();
const request = await requestDevicePairing( const request = await requestDevicePairing(
{ {
deviceId: "bootstrap-device-1", deviceId: "bootstrap-device-1",
publicKey: "bootstrap-public-key-1", publicKey: "bootstrap-public-key-1",
role: "node", role: "node",
roles: ["node", "operator"], roles: ["node"],
scopes: [], scopes: [],
silent: true, silent: true,
}, },
@@ -1011,28 +1054,20 @@ describe("device pairing tokens", () => {
expectRecordFields(approved, "approved result", { status: "approved" }); expectRecordFields(approved, "approved result", { status: "approved" });
const paired = await getPairedDevice("bootstrap-device-1", baseDir); const paired = await getPairedDevice("bootstrap-device-1", baseDir);
expectArrayIncludesAll(paired?.roles, ["node", "operator"], "paired roles"); expect(paired?.roles).toEqual(["node"]);
expectArrayIncludesAll( expect(paired?.approvedScopes).toStrictEqual([]);
paired?.approvedScopes,
PAIRING_SETUP_BOOTSTRAP_PROFILE.scopes,
"paired approved scopes",
);
expect(paired?.tokens?.node?.scopes).toStrictEqual([]); expect(paired?.tokens?.node?.scopes).toStrictEqual([]);
expectArrayIncludesAll( expect(paired?.tokens?.operator).toBeUndefined();
paired?.tokens?.operator?.scopes,
PAIRING_SETUP_BOOTSTRAP_PROFILE.scopes,
"operator token scopes",
);
}); });
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 baseDir = await makeDevicePairingDir();
const request = await requestDevicePairing( const request = await requestDevicePairing(
{ {
deviceId: "bootstrap-device-operator-default", deviceId: "bootstrap-device-operator-default",
publicKey: "bootstrap-public-key-operator-default", publicKey: "bootstrap-public-key-operator-default",
role: "node", role: "node",
roles: ["node", "operator"], roles: ["node"],
scopes: [], scopes: [],
silent: true, silent: true,
}, },
@@ -1047,34 +1082,56 @@ describe("device pairing tokens", () => {
expectRecordFields(approved, "approved result", { status: "approved" }); expectRecordFields(approved, "approved result", { status: "approved" });
const paired = await getPairedDevice("bootstrap-device-operator-default", baseDir); 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( await expect(
verifyDeviceToken({ verifyDeviceToken({
deviceId: "bootstrap-device-operator-default", deviceId: "bootstrap-device-operator-default",
token: operatorToken, token: nodeToken,
role: "operator", role: "operator",
scopes: ["operator.approvals", "operator.read", "operator.write"], scopes: ["operator.approvals", "operator.read", "operator.write"],
baseDir, 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 }); ).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 () => { test("bootstrap pairing keeps operator token scopes operator-only", async () => {
@@ -1085,7 +1142,7 @@ describe("device pairing tokens", () => {
publicKey: "bootstrap-public-key-operator-scope", publicKey: "bootstrap-public-key-operator-scope",
role: "node", role: "node",
roles: ["node", "operator"], roles: ["node", "operator"],
scopes: [], scopes: ["node.exec", "operator.read", "operator.write"],
silent: true, silent: true,
}, },
baseDir, baseDir,
@@ -1114,7 +1171,13 @@ describe("device pairing tokens", () => {
publicKey: "bootstrap-public-key-bounded-baseline", publicKey: "bootstrap-public-key-bounded-baseline",
role: "node", role: "node",
roles: ["node", "operator"], roles: ["node", "operator"],
scopes: [], scopes: [
"node.exec",
"operator.approvals",
"operator.read",
"operator.talk.secrets",
"operator.write",
],
silent: true, silent: true,
}, },
baseDir, baseDir,
@@ -1164,23 +1227,23 @@ describe("device pairing tokens", () => {
test("bootstrap pairing sanitizes merged legacy baseline scopes", async () => { test("bootstrap pairing sanitizes merged legacy baseline scopes", async () => {
const baseDir = await makeDevicePairingDir(); const baseDir = await makeDevicePairingDir();
const bootstrapProfile = {
roles: ["node", "operator"],
scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"],
};
const first = await requestDevicePairing( const first = await requestDevicePairing(
{ {
deviceId: "bootstrap-device-legacy-baseline", deviceId: "bootstrap-device-legacy-baseline",
publicKey: "bootstrap-public-key-legacy-baseline", publicKey: "bootstrap-public-key-legacy-baseline",
role: "node", role: "node",
roles: ["node", "operator"], roles: ["node", "operator"],
scopes: [], scopes: bootstrapProfile.scopes,
silent: true, silent: true,
}, },
baseDir, baseDir,
); );
await approveBootstrapDevicePairing( await approveBootstrapDevicePairing(first.request.requestId, bootstrapProfile, baseDir);
first.request.requestId,
PAIRING_SETUP_BOOTSTRAP_PROFILE,
baseDir,
);
await mutatePairedDevice(baseDir, "bootstrap-device-legacy-baseline", (device) => { await mutatePairedDevice(baseDir, "bootstrap-device-legacy-baseline", (device) => {
device.approvedScopes = ["operator.admin"]; device.approvedScopes = ["operator.admin"];
device.scopes = ["operator.admin"]; device.scopes = ["operator.admin"];
@@ -1192,20 +1255,20 @@ describe("device pairing tokens", () => {
publicKey: "bootstrap-public-key-legacy-baseline-rotated", publicKey: "bootstrap-public-key-legacy-baseline-rotated",
role: "node", role: "node",
roles: ["node", "operator"], roles: ["node", "operator"],
scopes: [], scopes: bootstrapProfile.scopes,
silent: true, silent: true,
}, },
baseDir, baseDir,
); );
const approved = await approveBootstrapDevicePairing( const approved = await approveBootstrapDevicePairing(
repair.request.requestId, repair.request.requestId,
PAIRING_SETUP_BOOTSTRAP_PROFILE, bootstrapProfile,
baseDir, baseDir,
); );
expectRecordFields(approved, "approved result", { status: "approved" }); expectRecordFields(approved, "approved result", { status: "approved" });
const paired = await getPairedDevice("bootstrap-device-legacy-baseline", baseDir); 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( await expect(
ensureDeviceToken({ ensureDeviceToken({
deviceId: "bootstrap-device-legacy-baseline", deviceId: "bootstrap-device-legacy-baseline",

View File

@@ -10,6 +10,7 @@ import {
resolveScopeOutsideRequestedRoles, resolveScopeOutsideRequestedRoles,
roleScopesAllow, roleScopesAllow,
} from "../shared/operator-scope-compat.js"; } from "../shared/operator-scope-compat.js";
import { revokeDeviceBootstrapTokensForDevice } from "./device-bootstrap.js";
import { import {
createAsyncLock, createAsyncLock,
pruneExpiredPending, pruneExpiredPending,
@@ -19,7 +20,6 @@ import {
resolvePairingPaths, resolvePairingPaths,
writeJson, writeJson,
} from "./pairing-files.js"; } from "./pairing-files.js";
import { rejectPendingPairingRequest } from "./pairing-pending.js";
import { generatePairingToken, verifyPairingToken } from "./pairing-token.js"; import { generatePairingToken, verifyPairingToken } from "./pairing-token.js";
export type DevicePairingPendingRequest = { export type DevicePairingPendingRequest = {
@@ -430,6 +430,27 @@ function resolveRoleScopedDeviceTokenScopes(role: string, scopes: string[] | und
return normalized.filter((scope) => !scope.startsWith(OPERATOR_SCOPE_PREFIX)); 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: { function resolveApprovedTokenScopes(params: {
role: string; role: string;
pending: DevicePairingPendingRequest; pending: DevicePairingPendingRequest;
@@ -692,9 +713,6 @@ export async function approveBootstrapDevicePairing(
bootstrapProfile: DeviceBootstrapProfile, bootstrapProfile: DeviceBootstrapProfile,
baseDir?: string, baseDir?: string,
): Promise<ApproveDevicePairingResult> { ): 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 approvedRoles = mergeRoles(bootstrapProfile.roles) ?? [];
const approvedScopes = resolveBootstrapProfileScopesForRoles( const approvedScopes = resolveBootstrapProfileScopesForRoles(
approvedRoles, approvedRoles,
@@ -725,28 +743,26 @@ export async function approveBootstrapDevicePairing(
const now = Date.now(); const now = Date.now();
const existing = state.pairedByDeviceId[pending.deviceId]; const existing = state.pairedByDeviceId[pending.deviceId];
const roles = mergeRoles( const grantedRoles = requestedRoles;
existing?.roles, const grantedScopes = resolveBootstrapProfileScopesForRoles(grantedRoles, pending.scopes ?? []);
existing?.role, const grantedRoleSet = new Set(grantedRoles);
pending.roles, const preservedExistingScopes = (mergeRoles(existing?.roles, existing?.role) ?? []).flatMap(
pending.role, (existingRole) =>
approvedRoles, grantedRoleSet.has(existingRole)
); ? []
const nextApprovedScopes = mergeScopes( : preserveRoleScopedApprovalScopes(
existingRole,
existing?.approvedScopes ?? existing?.scopes, existing?.approvedScopes ?? existing?.scopes,
pending.scopes, ),
approvedScopes,
);
const sanitizedApprovedScopes = resolveBootstrapProfileScopesForRoles(
approvedRoles,
nextApprovedScopes ?? [],
); );
const roles = mergeRoles(existing?.roles, existing?.role, pending.roles, pending.role);
const nextApprovedScopes = mergeScopes(preservedExistingScopes, grantedScopes);
const tokens = existing?.tokens ? { ...existing.tokens } : {}; const tokens = existing?.tokens ? { ...existing.tokens } : {};
for (const roleForToken of approvedRoles) { for (const roleForToken of grantedRoles) {
const existingToken = tokens[roleForToken]; const existingToken = tokens[roleForToken];
const tokenScopes = const tokenScopes =
roleForToken === OPERATOR_ROLE roleForToken === OPERATOR_ROLE
? resolveBootstrapProfileScopesForRole(roleForToken, approvedScopes) ? resolveBootstrapProfileScopesForRole(roleForToken, grantedScopes)
: []; : [];
tokens[roleForToken] = buildDeviceAuthToken({ tokens[roleForToken] = buildDeviceAuthToken({
role: roleForToken, role: roleForToken,
@@ -767,8 +783,8 @@ export async function approveBootstrapDevicePairing(
clientMode: pending.clientMode, clientMode: pending.clientMode,
role: pending.role, role: pending.role,
roles, roles,
scopes: sanitizedApprovedScopes, scopes: nextApprovedScopes,
approvedScopes: sanitizedApprovedScopes, approvedScopes: nextApprovedScopes,
remoteIp: pending.remoteIp, remoteIp: pending.remoteIp,
tokens, tokens,
createdAtMs: existing?.createdAtMs ?? now, createdAtMs: existing?.createdAtMs ?? now,
@@ -786,17 +802,19 @@ export async function rejectDevicePairing(
baseDir?: string, baseDir?: string,
): Promise<{ requestId: string; deviceId: string } | null> { ): Promise<{ requestId: string; deviceId: string } | null> {
return await withLock(async () => { return await withLock(async () => {
return await rejectPendingPairingRequest< const state = await loadState(baseDir);
DevicePairingPendingRequest, const pending = state.pendingById[requestId];
DevicePairingStateFile, if (!pending) {
"deviceId" return null;
>({ }
requestId, delete state.pendingById[requestId];
idKey: "deviceId", await persistState(state, baseDir, "pending");
loadState: () => loadState(baseDir), await revokeDeviceBootstrapTokensForDevice({
persistState: (state) => persistState(state, baseDir, "pending"), deviceId: pending.deviceId,
getId: (pending: DevicePairingPendingRequest) => pending.deviceId, publicKey: pending.publicKey,
baseDir,
}); });
return { requestId, deviceId: pending.deviceId };
}); });
} }

View File

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

View File

@@ -1,6 +1,7 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { import {
BOOTSTRAP_HANDOFF_OPERATOR_SCOPES, BOOTSTRAP_HANDOFF_OPERATOR_SCOPES,
PAIRING_SETUP_BOOTSTRAP_PROFILE,
normalizeDeviceBootstrapHandoffProfile, normalizeDeviceBootstrapHandoffProfile,
resolveBootstrapProfileScopesForRole, resolveBootstrapProfileScopesForRole,
resolveBootstrapProfileScopesForRoles, 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([ expect([...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES]).toEqual([
"operator.approvals", "operator.approvals",
"operator.read", "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); const BOOTSTRAP_HANDOFF_OPERATOR_SCOPE_SET = new Set<string>(BOOTSTRAP_HANDOFF_OPERATOR_SCOPES);
export const PAIRING_SETUP_BOOTSTRAP_PROFILE: DeviceBootstrapProfile = { export const PAIRING_SETUP_BOOTSTRAP_PROFILE: DeviceBootstrapProfile = {
roles: ["node", "operator"], roles: ["node"],
scopes: [...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES], scopes: [],
}; };
export function resolveBootstrapProfileScopesForRole( export function resolveBootstrapProfileScopesForRole(

View File

@@ -903,6 +903,44 @@ describe("GatewayBrowserClient", () => {
vi.useRealTimers(); 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 () => { it("clears stale stored device tokens and does not reconnect on AUTH_DEVICE_TOKEN_MISMATCH", async () => {
useNodeFakeTimers(); useNodeFakeTimers();

View File

@@ -10,6 +10,7 @@ import {
formatConnectErrorMessage, formatConnectErrorMessage,
readConnectErrorRecoveryAdvice, readConnectErrorRecoveryAdvice,
readConnectErrorDetailCode, readConnectErrorDetailCode,
readPairingConnectErrorDetails,
} from "../../../src/gateway/protocol/connect-error-details.js"; } from "../../../src/gateway/protocol/connect-error-details.js";
import { import {
isRetryableGatewayStartupUnavailableError, isRetryableGatewayStartupUnavailableError,
@@ -71,6 +72,14 @@ export function resolveGatewayErrorDetailCode(
return readConnectErrorDetailCode(error?.details); 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. * 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; return false;
} }
const code = resolveGatewayErrorDetailCode(error); const code = resolveGatewayErrorDetailCode(error);
if (
code === ConnectErrorDetailCodes.PAIRING_REQUIRED &&
shouldContinueReconnectForPairingRequired(error.details)
) {
return false;
}
return ( return (
code === ConnectErrorDetailCodes.AUTH_TOKEN_MISSING || code === ConnectErrorDetailCodes.AUTH_TOKEN_MISSING ||
code === ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID || code === ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID ||