mirror of https://github.com/openclaw/openclaw.git
fix(gateway): defer bootstrap token revocation
This commit is contained in:
parent
df115822b9
commit
a42f000b53
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 } =
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue