mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 15:47:28 +00:00
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:
committed by
GitHub
parent
05bef5db20
commit
b17e77a22b
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,19 +1042,16 @@ 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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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, {
|
||||
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,
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
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,
|
||||
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 } : {};
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
Reference in New Issue
Block a user