fix(gateway): defer bootstrap token revocation

This commit is contained in:
Ayaan Zaidi 2026-04-03 15:43:26 +05:30 committed by Peter Steinberger
parent df115822b9
commit a42f000b53
5 changed files with 204 additions and 15 deletions

View File

@ -62,6 +62,7 @@ Docs: https://docs.openclaw.ai
- Mobile pairing/device approval: mint both node and operator device tokens when one approval grants merged roles, so mixed mobile bootstrap pairings stop reconnecting as operator-only and showing the node offline. (#60208) Thanks @obviyus.
- Agents/tool policy: stop `tools.profile` warnings from flagging runtime-gated baseline core tools as unknown when the coding profile is missing tools like `code_execution`, `x_search`, `image`, or `image_generate`, while still warning on explicit extra allowlist entries. Thanks @vincentkoc.
- Sessions/resolution: collapse alias-duplicate session-id matches before scoring, keep distinct structural ties ambiguous, and prefer current-store reuse when resolving equal cross-store duplicates so follow-up turns stop dropping or duplicating sessions on timestamp ties.
- Mobile pairing/bootstrap: keep setup bootstrap tokens alive through the initial node auto-pair so the same QR bootstrap token can finish operator approval, then revoke it after the full issued profile connects successfully. (#60221) Thanks @obviyus.
## 2026.4.2

View File

@ -710,7 +710,7 @@ export function registerControlUiAndPairingSuite(): void {
restoreGatewayToken(prevToken);
});
test("auto-approves fresh node bootstrap pairing from qr setup code", async () => {
test("auto-approves fresh node-only bootstrap pairing and revokes the token after connect", async () => {
const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js");
const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js");
const { server, ws, port, prevToken } = await startServerWithClient("secret");
@ -728,7 +728,12 @@ export function registerControlUiAndPairingSuite(): void {
};
try {
const issued = await issueDeviceBootstrapToken();
const issued = await issueDeviceBootstrapToken({
profile: {
roles: ["node"],
scopes: [],
},
});
const wsBootstrap = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
const initial = await connectReq(wsBootstrap, {
skipDefaultAuth: true,
@ -800,6 +805,108 @@ export function registerControlUiAndPairingSuite(): void {
}
});
test("keeps setup bootstrap tokens valid until operator approval completes", async () => {
const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js");
const { approveDevicePairing, getPairedDevice, listDevicePairing } =
await import("../infra/device-pairing.js");
const { server, ws, port, prevToken } = await startServerWithClient("secret");
ws.close();
const { identityPath, identity, client } = await createOperatorIdentityFixture(
"openclaw-bootstrap-setup-profile-",
);
const nodeClient = {
...client,
id: "openclaw-android",
mode: "node",
};
const operatorClient = {
...client,
id: "openclaw-android",
mode: "ui",
};
const operatorScopes = ["operator.read", "operator.write", "operator.talk.secrets"];
try {
const issued = await issueDeviceBootstrapToken();
const wsNode = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
const nodeConnect = await connectReq(wsNode, {
skipDefaultAuth: true,
bootstrapToken: issued.token,
role: "node",
scopes: [],
client: nodeClient,
deviceIdentityPath: identityPath,
});
expect(nodeConnect.ok).toBe(true);
wsNode.close();
const pairedAfterNode = await getPairedDevice(identity.deviceId);
expect(pairedAfterNode?.roles).toEqual(expect.arrayContaining(["node"]));
const wsOperatorPending = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
const operatorPending = await connectReq(wsOperatorPending, {
skipDefaultAuth: true,
bootstrapToken: issued.token,
role: "operator",
scopes: operatorScopes,
client: operatorClient,
deviceIdentityPath: identityPath,
});
expect(operatorPending.ok).toBe(false);
expect((operatorPending.error?.details as { code?: string } | undefined)?.code).toBe(
ConnectErrorDetailCodes.PAIRING_REQUIRED,
);
wsOperatorPending.close();
const pending = (await listDevicePairing()).pending.filter(
(entry) => entry.deviceId === identity.deviceId,
);
expect(pending).toHaveLength(1);
const pendingRequest = pending[0];
if (!pendingRequest) {
throw new Error("expected pending pairing request");
}
await approveDevicePairing(pendingRequest.requestId, {
callerScopes: pendingRequest.scopes ?? ["operator.admin"],
});
const wsOperatorApproved = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
const operatorApproved = await connectReq(wsOperatorApproved, {
skipDefaultAuth: true,
bootstrapToken: issued.token,
role: "operator",
scopes: operatorScopes,
client: operatorClient,
deviceIdentityPath: identityPath,
});
expect(operatorApproved.ok).toBe(true);
wsOperatorApproved.close();
const pairedAfterOperator = await getPairedDevice(identity.deviceId);
expect(pairedAfterOperator?.roles).toEqual(expect.arrayContaining(["node", "operator"]));
const wsReplay = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
const replay = await connectReq(wsReplay, {
skipDefaultAuth: true,
bootstrapToken: issued.token,
role: "operator",
scopes: operatorScopes,
client: operatorClient,
deviceIdentityPath: identityPath,
});
expect(replay.ok).toBe(false);
expect((replay.error?.details as { code?: string } | undefined)?.code).toBe(
ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID,
);
wsReplay.close();
} finally {
await server.close();
restoreGatewayToken(prevToken);
}
});
test("requires approval for bootstrap-auth role upgrades on already-paired devices", async () => {
const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js");
const { approveDevicePairing, getPairedDevice, listDevicePairing, requestDevicePairing } =

View File

@ -3,6 +3,7 @@ import os from "node:os";
import type { WebSocket } from "ws";
import { loadConfig } from "../../../config/config.js";
import {
getDeviceBootstrapTokenProfile,
revokeDeviceBootstrapToken,
verifyDeviceBootstrapToken,
} from "../../../infra/device-bootstrap.js";
@ -31,6 +32,7 @@ import { upsertPresence } from "../../../infra/system-presence.js";
import { loadVoiceWakeConfig } from "../../../infra/voicewake.js";
import { rawDataToString } from "../../../infra/ws.js";
import type { createSubsystemLogger } from "../../../logging/subsystem.js";
import type { DeviceBootstrapProfile } from "../../../shared/device-bootstrap-profile.js";
import { roleScopesAllow } from "../../../shared/operator-scope-compat.js";
import {
isBrowserOperatorUiClient,
@ -144,6 +146,44 @@ function resolvePinnedClientMetadata(params: {
};
}
function resolveBootstrapProfileScopes(role: string, scopes: readonly string[]): string[] {
if (role === "operator") {
return scopes.filter((scope) => scope.startsWith("operator."));
}
return scopes.filter((scope) => !scope.startsWith("operator."));
}
function pairedDeviceSatisfiesBootstrapProfile(
pairedDevice: Awaited<ReturnType<typeof getPairedDevice>>,
bootstrapProfile: DeviceBootstrapProfile,
): boolean {
if (!pairedDevice) {
return false;
}
const approvedScopes = Array.isArray(pairedDevice.approvedScopes)
? pairedDevice.approvedScopes
: Array.isArray(pairedDevice.scopes)
? pairedDevice.scopes
: [];
for (const bootstrapRole of bootstrapProfile.roles) {
if (!hasEffectivePairedDeviceRole(pairedDevice, bootstrapRole)) {
return false;
}
const requestedScopes = resolveBootstrapProfileScopes(bootstrapRole, bootstrapProfile.scopes);
if (
requestedScopes.length > 0 &&
!roleScopesAllow({
role: bootstrapRole,
requestedScopes,
allowedScopes: approvedScopes,
})
) {
return false;
}
}
return true;
}
export function attachGatewayWsMessageHandler(params: {
socket: WebSocket;
upgradeReq: IncomingMessage;
@ -696,6 +736,10 @@ export function attachGatewayWsMessageHandler(params: {
rejectUnauthorized(authResult);
return;
}
const bootstrapProfile =
authMethod === "bootstrap-token" && bootstrapTokenCandidate
? await getDeviceBootstrapTokenProfile({ token: bootstrapTokenCandidate })
: null;
const trustedProxyAuthOk = isTrustedProxyControlUiOperatorAuth({
isControlUi,
@ -791,9 +835,9 @@ export function attachGatewayWsMessageHandler(params: {
isWebchat,
reason,
});
// QR bootstrap onboarding is node-only and single-use. When a fresh device presents
// a valid bootstrap token for the baseline node profile, complete pairing in the same
// handshake so iOS does not get stuck retrying with an already-consumed bootstrap token.
// Bootstrap setup can silently pair the first fresh node connect. Keep the token alive
// until the issued profile is fully redeemed so follow-up operator connects can still
// present the same bootstrap token while approval is pending.
const allowSilentBootstrapPairing =
authMethod === "bootstrap-token" &&
reason === "not-paired" &&
@ -832,16 +876,6 @@ export function attachGatewayWsMessageHandler(params: {
callerScopes: scopes,
});
if (approved?.status === "approved") {
if (allowSilentBootstrapPairing && bootstrapTokenCandidate) {
const revoked = await revokeDeviceBootstrapToken({
token: bootstrapTokenCandidate,
});
if (!revoked.removed) {
logGateway.warn(
`bootstrap token revoke skipped after silent auto-approval device=${approved.device.deviceId}`,
);
}
}
logGateway.info(
`device pairing auto-approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`,
);
@ -991,6 +1025,22 @@ export function attachGatewayWsMessageHandler(params: {
const deviceToken = device
? await ensureDeviceToken({ deviceId: device.id, role, scopes })
: null;
if (
authMethod === "bootstrap-token" &&
bootstrapProfile &&
bootstrapTokenCandidate &&
device &&
pairedDeviceSatisfiesBootstrapProfile(await getPairedDevice(device.id), bootstrapProfile)
) {
const revoked = await revokeDeviceBootstrapToken({
token: bootstrapTokenCandidate,
});
if (!revoked.removed) {
logGateway.warn(
`bootstrap token revoke skipped after profile redemption device=${device.id}`,
);
}
}
if (role === "node") {
const reconciliation = await reconcileNodePairingOnConnect({

View File

@ -5,6 +5,7 @@ import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
import {
clearDeviceBootstrapTokens,
DEVICE_BOOTSTRAP_TOKEN_TTL_MS,
getDeviceBootstrapTokenProfile,
issueDeviceBootstrapToken,
revokeDeviceBootstrapToken,
verifyDeviceBootstrapToken,
@ -93,6 +94,19 @@ describe("device bootstrap tokens", () => {
});
});
it("loads the issued bootstrap profile for a valid token", async () => {
const baseDir = await createTempDir();
const issued = await issueDeviceBootstrapToken({ baseDir });
await expect(getDeviceBootstrapTokenProfile({ baseDir, token: issued.token })).resolves.toEqual(
{
roles: ["node", "operator"],
scopes: ["operator.read", "operator.talk.secrets", "operator.write"],
},
);
await expect(getDeviceBootstrapTokenProfile({ baseDir, token: "invalid" })).resolves.toBeNull();
});
it("clears outstanding bootstrap tokens on demand", async () => {
const baseDir = await createTempDir();
const first = await issueDeviceBootstrapToken({ baseDir });

View File

@ -169,6 +169,23 @@ export async function revokeDeviceBootstrapToken(params: {
});
}
export async function getDeviceBootstrapTokenProfile(params: {
token: string;
baseDir?: string;
}): Promise<DeviceBootstrapProfile | null> {
return await withLock(async () => {
const providedToken = params.token.trim();
if (!providedToken) {
return null;
}
const state = await loadState(params.baseDir);
const found = Object.values(state).find((candidate) =>
verifyPairingToken(providedToken, candidate.token),
);
return found ? resolvePersistedBootstrapProfile(found) : null;
});
}
export async function verifyDeviceBootstrapToken(params: {
token: string;
deviceId: string;