refactor(infra): split device pairing lifecycle modules (#124734)

This commit is contained in:
Peter Steinberger
2026-08-16 11:40:55 -07:00
committed by GitHub
parent 57ebd20566
commit eb77ce5690
53 changed files with 1307 additions and 1200 deletions
-1
View File
@@ -706,7 +706,6 @@ src/infra/backup-create.ts
src/infra/clawhub-install-trust.ts
src/infra/command-explainer/extract.ts
src/infra/device-pairing.test.ts
src/infra/device-pairing.ts
src/infra/diagnostic-events.ts
src/infra/exec-approval-forwarder.ts
src/infra/exec-approvals-allow-always.test.ts
+3 -1
View File
@@ -24,8 +24,10 @@ import { isLoopbackHost } from "../gateway/net.js";
import {
approveDevicePairing,
formatDevicePairingForbiddenMessage,
} from "../infra/device-pairing-approval.js";
import { summarizeDeviceTokens } from "../infra/device-pairing-tokens.js";
import {
listDevicePairing,
summarizeDeviceTokens,
type PairedDevice as InfraPairedDevice,
} from "../infra/device-pairing.js";
import { formatTimeAgo } from "../infra/format-time/format-relative.ts";
+6
View File
@@ -47,7 +47,13 @@ vi.mock("./progress.js", () => ({
vi.mock("../infra/device-pairing.js", () => ({
listDevicePairing: mocks.listDevicePairing,
}));
vi.mock("../infra/device-pairing-approval.js", () => ({
approveDevicePairing: mocks.approveDevicePairing,
}));
vi.mock("../infra/device-pairing-tokens.js", () => ({
summarizeDeviceTokens: mocks.summarizeDeviceTokens,
}));
+3 -6
View File
@@ -8,12 +8,9 @@ import {
loadOrCreateDeviceIdentity,
publicKeyRawBase64UrlFromPem,
} from "../infra/device-identity.js";
import {
approveDevicePairing,
requestDevicePairing,
revokeDeviceToken,
rotateDeviceToken,
} from "../infra/device-pairing.js";
import { approveDevicePairing } from "../infra/device-pairing-approval.js";
import { revokeDeviceToken, rotateDeviceToken } from "../infra/device-pairing-tokens.js";
import { requestDevicePairing } from "../infra/device-pairing.js";
import { withEnvAsync } from "../test-utils/env.js";
import { withTempDir } from "../test-utils/temp-dir.js";
+4 -2
View File
@@ -10,10 +10,12 @@ import { callGateway } from "../gateway/call.js";
import { loadDeviceAuthTokens } from "../infra/device-auth-store.js";
import { loadDeviceIdentityIfPresent } from "../infra/device-identity.js";
import {
listApprovedPairedDeviceRoles,
listDevicePairingReadOnly,
summarizeDeviceTokens,
type DeviceAuthTokenSummary,
} from "../infra/device-pairing-tokens.js";
import {
listApprovedPairedDeviceRoles,
listDevicePairingReadOnly,
type DevicePairingPendingRequest,
type PairedDevice,
} from "../infra/device-pairing.js";
+3 -5
View File
@@ -12,11 +12,9 @@ import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { normalizeAssistantIdentity } from "../../ui/src/lib/assistant-identity.ts";
import { resolveStateDir } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
approveDevicePairing,
ensureDeviceToken,
requestDevicePairing,
} from "../infra/device-pairing.js";
import { approveDevicePairing } from "../infra/device-pairing-approval.js";
import { ensureDeviceToken } from "../infra/device-pairing-tokens.js";
import { requestDevicePairing } from "../infra/device-pairing.js";
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
+2 -1
View File
@@ -15,7 +15,8 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import { matchRootFileOpenFailure, openRootFileSync } from "../infra/boundary-file-read.js";
import { readFileDescriptorBounded } from "../infra/boundary-file-read.js";
import { resolveDevInstallGitBranch } from "../infra/dev-install-branch.js";
import { listDevicePairing, verifyDeviceToken } from "../infra/device-pairing.js";
import { verifyDeviceToken } from "../infra/device-pairing-tokens.js";
import { listDevicePairing } from "../infra/device-pairing.js";
import { readFileWindowFully } from "../infra/file-read.js";
import { openLocalFileSafely, FsSafeError } from "../infra/fs-safe.js";
import { safeFileURLToPath } from "../infra/local-file-access.js";
+3 -6
View File
@@ -9,12 +9,9 @@ import {
publicKeyRawBase64UrlFromPem,
type DeviceIdentity,
} from "../infra/device-identity.js";
import {
approveDevicePairing,
getPairedDevice,
requestDevicePairing,
rotateDeviceToken,
} from "../infra/device-pairing.js";
import { approveDevicePairing } from "../infra/device-pairing-approval.js";
import { rotateDeviceToken } from "../infra/device-pairing-tokens.js";
import { getPairedDevice, requestDevicePairing } from "../infra/device-pairing.js";
import { trackConnectChallengeNonce } from "./test-helpers.js";
export function resolveDeviceIdentityPath(name: string): string {
+1 -1
View File
@@ -1,12 +1,12 @@
// Covers gateway-side cleanup when silent pairing supersedes stale sibling records.
import { afterAll, afterEach, beforeAll, describe, expect, test } from "vitest";
import { approveDevicePairing } from "../infra/device-pairing-approval.js";
import {
approveNodePairing,
listNodePairing,
requestNodePairing,
} from "../infra/device-pairing-node.js";
import {
approveDevicePairing,
listDevicePairing,
removePairedDeviceRole,
requestDevicePairing,
@@ -15,11 +15,8 @@ import {
publicKeyRawBase64UrlFromPem,
type DeviceIdentity,
} from "../infra/device-identity.js";
import {
approveDevicePairing,
getPairedDevice,
requestDevicePairing,
} from "../infra/device-pairing.js";
import { approveDevicePairing } from "../infra/device-pairing-approval.js";
import { getPairedDevice, requestDevicePairing } from "../infra/device-pairing.js";
import { isTruthyEnvValue } from "../infra/env.js";
import { getFreePortBlockWithPermissionFallback } from "../test-utils/ports.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
@@ -1,5 +1,6 @@
// Covers paired-node reapproval reuse and changed-surface write limits.
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { approveDevicePairing } from "../infra/device-pairing-approval.js";
import {
approveNodePairing,
beginNodePairingConnect,
@@ -7,7 +8,7 @@ import {
releaseNodePairingCleanupClaim,
requestNodePairing,
} from "../infra/device-pairing-node.js";
import { approveDevicePairing, requestDevicePairing } from "../infra/device-pairing.js";
import { requestDevicePairing } from "../infra/device-pairing.js";
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
import { createNodeReapprovalCoordinator } from "./node-reapproval-coordinator.js";
+2 -1
View File
@@ -13,7 +13,8 @@ const { probeGateway } = await import("./probe.js");
const { storeDeviceAuthToken } = await import("../infra/device-auth-store.js");
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem } =
await import("../infra/device-identity.js");
const { approveDevicePairing, requestDevicePairing } = await import("../infra/device-pairing.js");
const { approveDevicePairing } = await import("../infra/device-pairing-approval.js");
const { requestDevicePairing } = await import("../infra/device-pairing.js");
await import("./server.js");
let gatewayHarness: Awaited<ReturnType<typeof createGatewaySuiteHarness>>;
+17 -2
View File
@@ -51,15 +51,30 @@ vi.mock("../../infra/device-pairing.js", async () => {
);
return {
...actual,
approveDevicePairing: approveDevicePairingMock,
getPairedDevice: getPairedDeviceMock,
getPendingDevicePairing: getPendingDevicePairingMock,
listDevicePairing: listDevicePairingMock,
removePairedDevice: removePairedDeviceMock,
rejectDevicePairing: rejectDevicePairingMock,
updatePairedDeviceMetadata: updatePairedDeviceMetadataMock,
};
});
vi.mock("../../infra/device-pairing-approval.js", async () => {
const actual = await vi.importActual<typeof import("../../infra/device-pairing-approval.js")>(
"../../infra/device-pairing-approval.js",
);
return { ...actual, approveDevicePairing: approveDevicePairingMock };
});
vi.mock("../../infra/device-pairing-tokens.js", async () => {
const actual = await vi.importActual<typeof import("../../infra/device-pairing-tokens.js")>(
"../../infra/device-pairing-tokens.js",
);
return {
...actual,
revokeDeviceToken: revokeDeviceTokenMock,
rotateDeviceToken: rotateDeviceTokenMock,
updatePairedDeviceMetadata: updatePairedDeviceMetadataMock,
};
});
+9 -5
View File
@@ -13,17 +13,21 @@ import {
import {
approveDevicePairing,
formatDevicePairingForbiddenMessage,
} from "../../infra/device-pairing-approval.js";
import {
type RevokeDeviceTokenDenyReason,
type RotateDeviceTokenDenyReason,
revokeDeviceToken,
rotateDeviceToken,
summarizeDeviceTokens,
} from "../../infra/device-pairing-tokens.js";
import {
getPairedDevice,
getPendingDevicePairing,
listDevicePairing,
removePairedDevice,
type DeviceAuthToken,
type RevokeDeviceTokenDenyReason,
type RotateDeviceTokenDenyReason,
rejectDevicePairing,
revokeDeviceToken,
rotateDeviceToken,
summarizeDeviceTokens,
updatePairedDeviceMetadata,
} from "../../infra/device-pairing.js";
import type { DiagnosticSecurityEventInput } from "../../infra/diagnostic-events.js";
+2 -3
View File
@@ -4,6 +4,7 @@ import {
GATEWAY_CLIENT_IDS,
GATEWAY_CLIENT_MODES,
} from "../../../packages/gateway-protocol/src/client-info.js";
import { approveDevicePairing } from "../../infra/device-pairing-approval.js";
import {
captureNodePairingGeneration,
captureNodePairingState,
@@ -11,12 +12,10 @@ import {
resolveCurrentPairedDeviceNodeBinding,
} from "../../infra/device-pairing-node-state.js";
import { approveNodePairing, requestNodePairing } from "../../infra/device-pairing-node.js";
import { revokeDeviceToken, rotateDeviceToken } from "../../infra/device-pairing-tokens.js";
import {
approveDevicePairing,
listDevicePairing,
requestDevicePairing,
revokeDeviceToken,
rotateDeviceToken,
withPairedDeviceRecords,
} from "../../infra/device-pairing.js";
import {
@@ -240,8 +240,9 @@ describe("gateway auth compatibility baseline", () => {
);
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem } =
await import("../infra/device-identity.js");
const { approveDevicePairing, requestDevicePairing, rotateDeviceToken } =
await import("../infra/device-pairing.js");
const { approveDevicePairing } = await import("../infra/device-pairing-approval.js");
const { rotateDeviceToken } = await import("../infra/device-pairing-tokens.js");
const { requestDevicePairing } = await import("../infra/device-pairing.js");
const identity = loadOrCreateDeviceIdentity({ path: identityPath });
const pending = await requestDevicePairing({
@@ -438,8 +439,8 @@ describe("gateway auth compatibility baseline", () => {
try {
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem } =
await import("../infra/device-identity.js");
const { approveDevicePairing, requestDevicePairing } =
await import("../infra/device-pairing.js");
const { approveDevicePairing } = await import("../infra/device-pairing-approval.js");
const { requestDevicePairing } = await import("../infra/device-pairing.js");
const nonce = await readConnectChallengeNonce(ws);
const identityPath = path.join(
os.tmpdir(),
@@ -21,8 +21,8 @@ export function registerControlUiBootstrapLifecycleSuite(): void {
const { issueDevicePairSetupBootstrapToken, verifyDeviceBootstrapToken } =
await import("../infra/device-bootstrap.js");
const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js");
const { approveBootstrapDevicePairing, requestDevicePairing } =
await import("../infra/device-pairing.js");
const { approveBootstrapDevicePairing } = await import("../infra/device-pairing-approval.js");
const { requestDevicePairing } = await import("../infra/device-pairing.js");
const { FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE } =
await import("../shared/device-bootstrap-profile.js");
const { server, port, prevToken } = await startControlUiServer("secret");
@@ -222,7 +222,8 @@ export function registerControlUiBootstrapLifecycleSuite(): 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 { approveDevicePairing } = await import("../infra/device-pairing-approval.js");
const { listDevicePairing } = await import("../infra/device-pairing.js");
const reconcileModule = await import("./node-connect-reconcile.js");
const reconcileSpy = vi
.spyOn(reconcileModule, "reconcileNodePairingOnConnect")
@@ -301,7 +302,8 @@ export function registerControlUiBootstrapLifecycleSuite(): void {
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 } =
const { approveDevicePairing } = await import("../infra/device-pairing-approval.js");
const { getPairedDevice, listDevicePairing, requestDevicePairing } =
await import("../infra/device-pairing.js");
const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js");
const { server, port, prevToken } = await startControlUiServer("secret");
@@ -174,7 +174,7 @@ export function registerControlUiDeviceTokenSuite(): void {
});
test("rejects revoked device token", async () => {
const { revokeDeviceToken } = await import("../infra/device-pairing.js");
const { revokeDeviceToken } = await import("../infra/device-pairing-tokens.js");
const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret");
const { identity, deviceToken, deviceIdentityPath } =
await ensurePairedDeviceTokenForCurrentIdentity(ws);
@@ -122,7 +122,8 @@ export const seedApprovedOperatorReadPairing = async (params: {
scopes?: string[];
}): Promise<{ identityPath: string; identity: { deviceId: string } }> => {
const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js");
const { approveDevicePairing, requestDevicePairing } = await import("../infra/device-pairing.js");
const { approveDevicePairing } = await import("../infra/device-pairing-approval.js");
const { requestDevicePairing } = await import("../infra/device-pairing.js");
const { identityPath, identity } = await createOperatorIdentityFixture(params.identityPrefix);
const scopes = params.scopes ?? ["operator.read"];
const devicePublicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem);
@@ -179,8 +179,8 @@ export function registerControlUiMobileBootstrapSuite(): void {
const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js");
const { FULL_ACCESS_PAIRING_SETUP_BOOTSTRAP_PROFILE } =
await import("../shared/device-bootstrap-profile.js");
const { getPairedDevice, listDevicePairing, verifyDeviceToken } =
await import("../infra/device-pairing.js");
const { verifyDeviceToken } = await import("../infra/device-pairing-tokens.js");
const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js");
const { server, port, prevToken } = await startControlUiServer("secret");
const { identityPath, identity } = await createOperatorIdentityFixture(
@@ -433,7 +433,8 @@ export function registerControlUiMobileBootstrapSuite(): void {
]);
expect(operatorHandoff?.scopes).not.toContain("operator.admin");
const { getPairedDevice, verifyDeviceToken } = await import("../infra/device-pairing.js");
const { verifyDeviceToken } = await import("../infra/device-pairing-tokens.js");
const { getPairedDevice } = await import("../infra/device-pairing.js");
const paired = await getPairedDevice(identity.deviceId);
expect(paired?.approvedScopes).not.toContain("operator.admin");
expect(paired?.tokens?.operator?.scopes).not.toContain("operator.admin");
@@ -17,8 +17,8 @@ import {
export function registerControlUiOwnerBootstrapSuite(): void {
test("silently approves host-authorized control ui owner bootstrap tokens", async () => {
const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js");
const { getPairedDevice, listDevicePairing, verifyDeviceToken } =
await import("../infra/device-pairing.js");
const { verifyDeviceToken } = await import("../infra/device-pairing-tokens.js");
const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js");
const { CONTROL_UI_OWNER_BOOTSTRAP_OPERATOR_SCOPES, CONTROL_UI_OWNER_BOOTSTRAP_PROFILE } =
await import("../shared/device-bootstrap-profile.js");
const { resolveSharedGatewaySessionGeneration } =
@@ -374,7 +374,8 @@ export function registerControlUiPairingSuite(): void {
test("allows operator shared auth with legacy paired metadata", async () => {
const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js");
const { approveDevicePairing, getPairedDevice, listDevicePairing, requestDevicePairing } =
const { approveDevicePairing } = await import("../infra/device-pairing-approval.js");
const { getPairedDevice, listDevicePairing, requestDevicePairing } =
await import("../infra/device-pairing.js");
const { identityPath, identity } = await createOperatorIdentityFixture(
"openclaw-device-legacy-meta-",
+2 -1
View File
@@ -219,7 +219,8 @@ function resolveGatewayTokenOrEnv(): string {
}
async function approvePendingPairingIfNeeded() {
const { approveDevicePairing, listDevicePairing } = await import("../infra/device-pairing.js");
const { approveDevicePairing } = await import("../infra/device-pairing-approval.js");
const { listDevicePairing } = await import("../infra/device-pairing.js");
const list = await listDevicePairing();
const pending = list.pending.at(0);
if (!pending?.requestId) {
@@ -2,11 +2,8 @@
* Tests device-pair approval superseding behavior in the gateway server.
*/
import { describe, expect, test } from "vitest";
import {
approveDevicePairing,
getPairedDevice,
requestDevicePairing,
} from "../infra/device-pairing.js";
import { approveDevicePairing } from "../infra/device-pairing-approval.js";
import { getPairedDevice, requestDevicePairing } from "../infra/device-pairing.js";
import { installGatewayTestHooks } from "./test-helpers.js";
installGatewayTestHooks({ scope: "suite" });
@@ -2,11 +2,8 @@
// rights, approved node reconnects, and invoke continuity after token changes.
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { WebSocket } from "ws";
import {
approveDevicePairing,
getPairedDevice,
requestDevicePairing,
} from "../infra/device-pairing.js";
import { approveDevicePairing } from "../infra/device-pairing-approval.js";
import { getPairedDevice, requestDevicePairing } from "../infra/device-pairing.js";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
@@ -287,7 +287,8 @@ describe("node.invoke approval bypass", () => {
});
const approveAllPendingPairings = async () => {
const { approveDevicePairing, listDevicePairing } = await import("../infra/device-pairing.js");
const { approveDevicePairing } = await import("../infra/device-pairing-approval.js");
const { listDevicePairing } = await import("../infra/device-pairing.js");
const { approveNodePairing, listNodePairing } = await import("../infra/device-pairing-node.js");
const deviceList = await listDevicePairing();
for (const pending of deviceList.pending) {
@@ -10,12 +10,13 @@ import {
loadOrCreateDeviceIdentity,
publicKeyRawBase64UrlFromPem,
} from "../infra/device-identity.js";
import { approveDevicePairing } from "../infra/device-pairing-approval.js";
import {
approveNodePairing,
listNodePairing,
requestNodePairing,
} from "../infra/device-pairing-node.js";
import { approveDevicePairing, requestDevicePairing } from "../infra/device-pairing.js";
import { requestDevicePairing } from "../infra/device-pairing.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import {
connectReq,
@@ -1,5 +1,6 @@
import { afterAll, beforeAll, describe } from "vitest";
import { approveDevicePairing, requestDevicePairing } from "../infra/device-pairing.js";
import { approveDevicePairing } from "../infra/device-pairing-approval.js";
import { requestDevicePairing } from "../infra/device-pairing.js";
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
import { startServerWithClient } from "./test-helpers.js";
@@ -7,8 +7,9 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws";
import type { DeviceIdentity } from "../infra/device-identity.js";
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
import { approveDevicePairing } from "../infra/device-pairing-approval.js";
import { approveNodePairing, requestNodePairing } from "../infra/device-pairing-node.js";
import { approveDevicePairing, listDevicePairing } from "../infra/device-pairing.js";
import { listDevicePairing } from "../infra/device-pairing.js";
import { readRestartSentinel } from "../infra/restart-sentinel.js";
import { SUPERVISOR_HINT_ENV_VARS } from "../infra/supervisor-markers.js";
import { getActiveRuntimePluginRegistry } from "../plugins/active-runtime-registry.js";
+2 -1
View File
@@ -131,7 +131,8 @@ async function emitLifecycleAssistantReply(params: {
beforeAll(async () => {
envSnapshot = captureEnv(["OPENCLAW_GATEWAY_PORT", "OPENCLAW_GATEWAY_TOKEN"]);
gatewayPort = await getGatewayTestPort();
const { approveDevicePairing, requestDevicePairing } = await import("../infra/device-pairing.js");
const { approveDevicePairing } = await import("../infra/device-pairing-approval.js");
const { requestDevicePairing } = await import("../infra/device-pairing.js");
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem } =
await import("../infra/device-identity.js");
const identity = loadOrCreateDeviceIdentity();
@@ -53,8 +53,10 @@ async function openDeviceTokenWsWithDetails(
const identityPath = path.join(os.tmpdir(), `openclaw-shared-auth-${process.pid}-${port}.sqlite`);
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem } =
await import("../infra/device-identity.js");
const { approveDevicePairing, ensureDeviceToken, requestDevicePairing, rotateDeviceToken } =
await import("../infra/device-pairing.js");
const { approveDevicePairing } = await import("../infra/device-pairing-approval.js");
const { ensureDeviceToken, rotateDeviceToken } =
await import("../infra/device-pairing-tokens.js");
const { requestDevicePairing } = await import("../infra/device-pairing.js");
const client = params.browserClient
? {
id: "openclaw-control-ui",
@@ -209,7 +211,8 @@ async function expectIssuerTaggedDeviceToken(params: {
token: string;
issuerGeneration: string;
}) {
const { getPairedDevice, verifyDeviceToken } = await import("../infra/device-pairing.js");
const { verifyDeviceToken } = await import("../infra/device-pairing-tokens.js");
const { getPairedDevice } = await import("../infra/device-pairing.js");
const paired = await getPairedDevice(params.deviceId);
expect(paired?.tokens?.operator?.issuer).toEqual({
kind: "shared-gateway-auth",
@@ -6,12 +6,10 @@ import {
loadOrCreateDeviceIdentity,
publicKeyRawBase64UrlFromPem,
} from "../infra/device-identity.js";
import * as devicePairingApprovalModule from "../infra/device-pairing-approval.js";
import { approveDevicePairing } from "../infra/device-pairing-approval.js";
import * as devicePairingModule from "../infra/device-pairing.js";
import {
approveDevicePairing,
getPairedDevice,
requestDevicePairing,
} from "../infra/device-pairing.js";
import { getPairedDevice, requestDevicePairing } from "../infra/device-pairing.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { callGateway } from "./call.js";
import {
@@ -305,7 +303,7 @@ describe("gateway silent scope-upgrade reconnect", () => {
const loaded = loadDeviceIdentity("silent-reconnect-race");
let ws: WebSocket | undefined;
const approveOriginal = devicePairingModule.approveDevicePairing;
const approveOriginal = devicePairingApprovalModule.approveDevicePairing;
let simulatedRace = false;
const forwardApprove = async (requestId: string, optionsOrBaseDir?: unknown) => {
if (optionsOrBaseDir && typeof optionsOrBaseDir === "object") {
@@ -317,7 +315,7 @@ describe("gateway silent scope-upgrade reconnect", () => {
return await approveOriginal(requestId);
};
const approveSpy = vi
.spyOn(devicePairingModule, "approveDevicePairing")
.spyOn(devicePairingApprovalModule, "approveDevicePairing")
.mockImplementation(async (requestId: string, optionsOrBaseDir?: unknown) => {
if (simulatedRace) {
return await forwardApprove(requestId, optionsOrBaseDir);
@@ -353,7 +351,7 @@ describe("gateway silent scope-upgrade reconnect", () => {
let ws: WebSocket | undefined;
const approveSpy = vi
.spyOn(devicePairingModule, "approveDevicePairing")
.spyOn(devicePairingApprovalModule, "approveDevicePairing")
.mockImplementation(async (requestId: string) => {
await devicePairingModule.rejectDevicePairing(requestId);
return null;
@@ -400,7 +398,7 @@ describe("gateway silent scope-upgrade reconnect", () => {
let replacementRequestId = "";
const approveSpy = vi
.spyOn(devicePairingModule, "approveDevicePairing")
.spyOn(devicePairingApprovalModule, "approveDevicePairing")
.mockImplementation(async (_requestId: string) => {
const replacement = await devicePairingModule.requestDevicePairing({
deviceId: loaded.identity.deviceId,
@@ -8,7 +8,7 @@ import {
getDeviceBootstrapTokenProfile,
verifyDeviceBootstrapToken,
} from "../../../infra/device-bootstrap.js";
import { verifyDeviceToken } from "../../../infra/device-pairing.js";
import { verifyDeviceToken } from "../../../infra/device-pairing-tokens.js";
import type { DeviceBootstrapProfile } from "../../../shared/device-bootstrap-profile.js";
import type { GatewayAuthResult } from "../../auth.js";
import { formatForLog } from "../../ws-log.js";
@@ -6,7 +6,8 @@ import {
} from "../../../../packages/gateway-protocol/src/client-info.js";
import { replaceConfigFile } from "../../../config/config.js";
import type { GatewayAuthConfig } from "../../../config/types.gateway.js";
import { ensureDeviceToken, getPairedDevice } from "../../../infra/device-pairing.js";
import { ensureDeviceToken } from "../../../infra/device-pairing-tokens.js";
import { getPairedDevice } from "../../../infra/device-pairing.js";
import {
loadDeviceIdentity,
openTrackedWs,
@@ -14,6 +14,8 @@ import { getBoundDeviceBootstrapProfile } from "../../../infra/device-bootstrap.
import {
approveBootstrapDevicePairing,
approveDevicePairing,
} from "../../../infra/device-pairing-approval.js";
import {
getPairedDevice,
hasEffectivePairedDeviceRole,
listApprovedPairedDeviceRoles,
@@ -1,5 +1,5 @@
// Gateway WebSocket device authorization issues the session and bootstrap handoff tokens.
import { ensureDeviceToken } from "../../../infra/device-pairing.js";
import { ensureDeviceToken } from "../../../infra/device-pairing-tokens.js";
import { resolveBootstrapProfileScopesForRole } from "../../../shared/device-bootstrap-profile.js";
import type {
AuthenticatedGatewayConnect,
@@ -1,6 +1,7 @@
import type { ConnectPairingRequiredReason } from "../../../../packages/gateway-protocol/src/connect-error-details.js";
// Gateway WebSocket node pairing can finish a fresh capability-free request over SSH.
import { approveDevicePairing, getPairedDevice } from "../../../infra/device-pairing.js";
import { approveDevicePairing } from "../../../infra/device-pairing-approval.js";
import { getPairedDevice } from "../../../infra/device-pairing.js";
import {
planNodePairingSshVerify,
startNodePairingSshVerify,
@@ -4,11 +4,8 @@ import {
loadOrCreateDeviceIdentity,
publicKeyRawBase64UrlFromPem,
} from "../infra/device-identity.js";
import {
approveDevicePairing,
getPairedDevice,
requestDevicePairing,
} from "../infra/device-pairing.js";
import { approveDevicePairing } from "../infra/device-pairing-approval.js";
import { getPairedDevice, requestDevicePairing } from "../infra/device-pairing.js";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { withStateDirEnv } from "../test-helpers/state-dir-env.js";
import { READ_SCOPE } from "./operator-scopes.js";
+2 -5
View File
@@ -7,11 +7,8 @@ import {
loadOrCreateDeviceIdentity,
publicKeyRawBase64UrlFromPem,
} from "../infra/device-identity.js";
import {
approveDevicePairing,
getPairedDevice,
requestDevicePairing,
} from "../infra/device-pairing.js";
import { approveDevicePairing } from "../infra/device-pairing-approval.js";
import { getPairedDevice, requestDevicePairing } from "../infra/device-pairing.js";
import { roleScopesAllow } from "../shared/operator-scope-compat.js";
import { ADMIN_SCOPE } from "./operator-scopes.js";
+2 -5
View File
@@ -25,11 +25,8 @@ import {
publicKeyRawBase64UrlFromPem,
signDevicePayload,
} from "../infra/device-identity.js";
import {
approveDevicePairing,
getPairedDevice,
requestDevicePairing,
} from "../infra/device-pairing.js";
import { approveDevicePairing } from "../infra/device-pairing-approval.js";
import { getPairedDevice, requestDevicePairing } from "../infra/device-pairing.js";
import { resetGatewaySuspendCoordinatorForLifecycleRestart } from "../infra/gateway-suspend-coordinator.js";
import {
resetGatewayRestartStateForInProcessRestart,
+2 -2
View File
@@ -23,14 +23,14 @@ import {
publicKeyRawBase64UrlFromPem,
signDevicePayload,
} from "../infra/device-identity.js";
import { approveDevicePairing } from "../infra/device-pairing-approval.js";
import { listNodePairing } from "../infra/device-pairing-node.js";
import { loadDevicePairSetupCompletionRecord } from "../infra/device-pairing-store.js";
import { revokeDeviceToken } from "../infra/device-pairing-tokens.js";
import {
approveDevicePairing,
getPairedDevice,
requestDevicePairing,
resolveNodePairingState,
revokeDeviceToken,
} from "../infra/device-pairing.js";
import { NODE_PAIRING_SETUP_BOOTSTRAP_PROFILE } from "../shared/device-bootstrap-profile.js";
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
+2 -3
View File
@@ -24,6 +24,7 @@ import {
deriveDeviceIdFromPublicKey,
normalizeDevicePublicKeyBase64Url,
} from "../infra/device-identity.js";
import { approveBootstrapDevicePairing } from "../infra/device-pairing-approval.js";
import { captureAuthenticatedNodePairingState } from "../infra/device-pairing-node-state.js";
import {
approveNodePairing,
@@ -35,13 +36,11 @@ import {
recordPairedNodeDisconnection,
type RequestNodePairingResult,
} from "../infra/device-pairing-node.js";
import { ensureDeviceToken, verifyDeviceToken } from "../infra/device-pairing-tokens.js";
import {
approveBootstrapDevicePairing,
ensureDeviceToken,
getPairedDevice,
requestDevicePairing,
resolveNodePairingState,
verifyDeviceToken,
} from "../infra/device-pairing.js";
import { pruneMapToMaxSize } from "../infra/map-size.js";
import { isNodePairingSetupBootstrapProfile } from "../shared/device-bootstrap-profile.js";
+503
View File
@@ -0,0 +1,503 @@
// Owner and bootstrap approval flows for pending device pairing requests.
import { normalizeDeviceAuthScopes } from "../shared/device-auth.js";
import {
resolveDeviceProfileRoleScopes,
resolveDeviceProfileScopes,
type DeviceBootstrapProfile,
} from "../shared/device-bootstrap-profile.js";
import {
resolveMissingRequestedScope,
resolveScopeOutsideRequestedRoles,
} from "../shared/operator-scope-compat.js";
import {
loadDevicePairingState,
mergeDevicePairingRoles,
mergeDevicePairingScopes,
preserveDeviceRoleScopes,
resolveRequestedDeviceRoles,
sameDevicePairingStringSet,
withDevicePairingLock,
} from "./device-pairing-state.js";
import { persistDevicePairingStoreState as persistState } from "./device-pairing-store.js";
import { createDeviceAuthToken, resolveRoleTokenScopes } from "./device-pairing-tokens.js";
import { clearNodePairingGenerationBins, resolveNodePairingGeneration } from "./device-pairing.js";
import type {
DeviceAuthToken,
DevicePairingPendingRequest,
PairedDevice,
PairedDeviceApprovalKind,
} from "./device-pairing.types.js";
import { generatePairingToken } from "./pairing-token.js";
const OPERATOR_ROLE = "operator";
const OPERATOR_SCOPE_PREFIX = "operator.";
/** Paired-device access metadata refreshed when an existing device reconnects. */
type DevicePairingAccessMetadata = Pick<
PairedDevice,
"displayName" | "remoteIp" | "lastSeenAtMs" | "lastSeenReason"
>;
/** Authorization failure categories for owner approval and bootstrap approval flows. */
type DevicePairingForbiddenReason =
| "caller-scopes-required"
| "caller-missing-scope"
| "scope-outside-requested-roles"
| "bootstrap-role-not-allowed"
| "bootstrap-scope-not-allowed";
/** Structured forbidden result with the missing/disallowed role or scope when known. */
type DevicePairingForbiddenResult = {
status: "forbidden";
reason: DevicePairingForbiddenReason;
scope?: string;
role?: string;
};
/** Pairing approval outcome: approved, forbidden with reason, or request not found. */
type ApproveDevicePairingResult =
| {
status: "approved";
requestId: string;
device: PairedDevice;
/** Existing connected node transports must be retired before success is returned. */
nodePairingGenerationChanged?: true;
}
| DevicePairingForbiddenResult
| null;
/** Format a device-pairing authorization failure for CLI/API callers. */
export function formatDevicePairingForbiddenMessage(result: DevicePairingForbiddenResult): string {
switch (result.reason) {
case "caller-scopes-required":
return `missing scope: ${result.scope ?? "callerScopes-required"}`;
case "caller-missing-scope":
return `missing scope: ${result.scope ?? "unknown"}`;
case "scope-outside-requested-roles":
return `invalid scope for requested roles: ${result.scope ?? "unknown"}`;
case "bootstrap-role-not-allowed":
return `bootstrap profile does not allow role: ${result.role ?? "unknown"}`;
case "bootstrap-scope-not-allowed":
return `bootstrap profile does not allow scope: ${result.scope ?? "unknown"}`;
}
throw new Error("Unsupported device pairing forbidden reason");
}
// Interactive approvals must stay sticky: a later silent repair/re-approve of the
// same device id cannot downgrade an owner/bootstrap record into prune-eligible
// state. Pre-provenance records (approvedVia undefined) may have been approved by
// an owner, so a non-interactive re-approve must keep them protected (undefined).
function mergeApprovalKind(
existing: PairedDevice | undefined,
incoming: PairedDeviceApprovalKind,
): PairedDeviceApprovalKind | undefined {
if (incoming === "owner" || !existing) {
return incoming;
}
if (existing.approvedVia === undefined) {
return incoming === "bootstrap" ? "bootstrap" : undefined;
}
if (existing.approvedVia === "owner" || existing.approvedVia === "bootstrap") {
return existing.approvedVia;
}
return incoming;
}
function buildApprovedPairedDevice(params: {
pending: DevicePairingPendingRequest;
existing: PairedDevice | undefined;
roles: string[] | undefined;
approvedScopes: string[] | undefined;
tokens: Record<string, DeviceAuthToken>;
now: number;
approvedVia: PairedDeviceApprovalKind;
accessMetadata?: DevicePairingAccessMetadata;
}): PairedDevice {
return {
deviceId: params.pending.deviceId,
publicKey: params.pending.publicKey,
displayName: params.accessMetadata?.displayName ?? params.pending.displayName,
platform: params.pending.platform,
deviceFamily: params.pending.deviceFamily,
clientId: params.pending.clientId,
clientMode: params.pending.clientMode,
browserOrigin: params.pending.browserOrigin,
role: params.pending.role,
roles: params.roles,
scopes: params.approvedScopes,
approvedScopes: params.approvedScopes,
remoteIp: params.accessMetadata?.remoteIp ?? params.pending.remoteIp,
tokens: params.tokens,
approvedVia: mergeApprovalKind(params.existing, params.approvedVia),
// Node capability approvals ride on the device record; device repair or
// role re-approval must not silently revoke an approved node surface.
...(params.existing?.nodeSurface ? { nodeSurface: params.existing.nodeSurface } : {}),
...(params.existing?.pendingNodeSurface
? { pendingNodeSurface: params.existing.pendingNodeSurface }
: {}),
// Operator-assigned label is owner-side state; device repair or role
// re-approval must not silently drop it.
...(params.existing?.operatorLabel ? { operatorLabel: params.existing.operatorLabel } : {}),
createdAtMs: params.existing?.createdAtMs ?? params.now,
approvedAtMs: params.now,
lastSeenAtMs: params.accessMetadata?.lastSeenAtMs ?? params.existing?.lastSeenAtMs,
lastSeenReason: params.accessMetadata?.lastSeenReason ?? params.existing?.lastSeenReason,
};
}
function resolveApprovedTokenScopes(params: {
role: string;
pending: DevicePairingPendingRequest;
existingToken?: DeviceAuthToken;
approvedScopes?: string[];
existing?: PairedDevice;
}): string[] {
const pendingScopes = resolveRoleTokenScopes(params.role, params.pending.scopes);
if (pendingScopes.length > 0) {
const approvedBaseline = resolveRoleTokenScopes(
params.role,
params.existing?.approvedScopes ?? params.existing?.scopes,
);
const requestedScopeDelta =
params.existingToken && approvedBaseline.length > 0
? pendingScopes.filter((scope) => !approvedBaseline.includes(scope))
: pendingScopes;
if (requestedScopeDelta.length === 0 && params.existingToken) {
return resolveRoleTokenScopes(params.role, params.existingToken.scopes);
}
return resolveRoleTokenScopes(
params.role,
mergeDevicePairingScopes(params.existingToken?.scopes, requestedScopeDelta),
);
}
return resolveRoleTokenScopes(
params.role,
params.existingToken?.scopes ??
params.approvedScopes ??
params.existing?.approvedScopes ??
params.existing?.scopes,
);
}
/** Approve a pending request with optional caller-scope checks for operator grants. */
export async function approveDevicePairing(
requestId: string,
baseDir?: string,
): Promise<ApproveDevicePairingResult>;
export async function approveDevicePairing(
requestId: string,
options: {
callerScopes?: readonly string[];
accessMetadata?: DevicePairingAccessMetadata;
approvedVia?: Extract<
PairedDeviceApprovalKind,
"owner" | "silent" | "trusted-cidr" | "trusted-proxy" | "ssh-verified"
>;
/**
* Replace the pending scopes only for a brand-new operator device, or under
* trusted-proxy approval for a known operator device re-requesting with its
* already-paired public key. The live role set is rechecked under the pairing
* lock so a merged request cannot inherit non-operator access through browser
* auto-approval.
*/
autoApproveNewDeviceScopes?: readonly string[];
},
baseDir?: string,
): Promise<ApproveDevicePairingResult>;
export async function approveDevicePairing(
requestId: string,
optionsOrBaseDir?:
| {
callerScopes?: readonly string[];
accessMetadata?: DevicePairingAccessMetadata;
approvedVia?: Extract<
PairedDeviceApprovalKind,
"owner" | "silent" | "trusted-cidr" | "trusted-proxy" | "ssh-verified"
>;
autoApproveNewDeviceScopes?: readonly string[];
}
| string,
maybeBaseDir?: string,
): Promise<ApproveDevicePairingResult> {
const options =
typeof optionsOrBaseDir === "string" || optionsOrBaseDir === undefined
? undefined
: optionsOrBaseDir;
const baseDir = typeof optionsOrBaseDir === "string" ? optionsOrBaseDir : maybeBaseDir;
return await approveDevicePairingWithOptions(requestId, options, baseDir);
}
async function approveDevicePairingWithOptions(
requestId: string,
options:
| {
callerScopes?: readonly string[];
accessMetadata?: DevicePairingAccessMetadata;
approvedVia?: Extract<
PairedDeviceApprovalKind,
"owner" | "silent" | "trusted-cidr" | "trusted-proxy" | "ssh-verified"
>;
autoApproveNewDeviceScopes?: readonly string[];
}
| undefined,
baseDir?: string,
): Promise<ApproveDevicePairingResult> {
return await withDevicePairingLock(async () => {
const state = await loadDevicePairingState(baseDir);
const pendingRecord = state.pendingById[requestId];
if (!pendingRecord) {
return null;
}
const autoApproveScopes = options?.autoApproveNewDeviceScopes;
const requestedRoles = resolveRequestedDeviceRoles(pendingRecord);
const knownDevice = state.pairedByDeviceId[pendingRecord.deviceId];
// Trusted-proxy connects carry an SSO-authenticated user, and the connect
// handshake has already proven possession of the pending public key. A
// matching key on the paired record is therefore the same physical device
// re-requesting (typically a scope upgrade) and may auto-approve; a key
// mismatch is a real repair — possibly a deviceId squat — and stays a
// manual owner decision.
const trustedProxySameKeyDevice =
options?.approvedVia === "trusted-proxy" &&
knownDevice !== undefined &&
knownDevice.publicKey === pendingRecord.publicKey;
if (
autoApproveScopes &&
(((pendingRecord.isRepair || knownDevice) && !trustedProxySameKeyDevice) ||
!sameDevicePairingStringSet(requestedRoles, [OPERATOR_ROLE]))
) {
return null;
}
const pending = autoApproveScopes
? { ...pendingRecord, scopes: [...autoApproveScopes] }
: pendingRecord;
const requestedScopes = normalizeDeviceAuthScopes(pending.scopes);
const roleMismatchScope = resolveScopeOutsideRequestedRoles({
requestedRoles,
requestedScopes,
});
if (roleMismatchScope) {
return {
status: "forbidden",
reason: "scope-outside-requested-roles",
scope: roleMismatchScope,
};
}
const now = Date.now();
const existing = state.pairedByDeviceId[pending.deviceId];
const roles = mergeDevicePairingRoles(
existing?.roles,
existing?.role,
pending.roles,
pending.role,
);
const approvedScopes = mergeDevicePairingScopes(
existing?.approvedScopes ?? existing?.scopes,
pending.scopes,
);
const previousNodeGeneration = resolveNodePairingGeneration(existing ?? null);
const tokens = existing?.tokens ? { ...existing.tokens } : {};
const nextTokenScopesByRole = new Map<string, string[]>();
for (const roleForToken of requestedRoles) {
const existingToken = tokens[roleForToken];
const nextScopes = resolveApprovedTokenScopes({
role: roleForToken,
pending,
existingToken,
approvedScopes,
existing,
});
nextTokenScopesByRole.set(roleForToken, nextScopes);
if (roleForToken === OPERATOR_ROLE && nextScopes.length > 0) {
const callerRequiredScopes =
mergeDevicePairingScopes(
resolveRoleTokenScopes(roleForToken, pending.scopes),
nextScopes,
) ?? nextScopes;
if (!options?.callerScopes) {
return {
status: "forbidden",
reason: "caller-scopes-required",
scope: callerRequiredScopes[0],
};
}
const missingScope = resolveMissingRequestedScope({
role: OPERATOR_ROLE,
requestedScopes: callerRequiredScopes,
allowedScopes: options.callerScopes,
});
if (missingScope) {
return { status: "forbidden", reason: "caller-missing-scope", scope: missingScope };
}
}
}
for (const [roleForToken, nextScopes] of nextTokenScopesByRole) {
const existingToken = tokens[roleForToken];
const tokenNow = Date.now();
tokens[roleForToken] = {
token: generatePairingToken(),
role: roleForToken,
scopes: nextScopes,
createdAtMs: existingToken?.createdAtMs ?? tokenNow,
rotatedAtMs: existingToken ? tokenNow : undefined,
revokedAtMs: undefined,
lastUsedAtMs: existingToken?.lastUsedAtMs,
};
}
const device = buildApprovedPairedDevice({
pending,
existing,
roles,
approvedScopes,
tokens,
now,
approvedVia: options?.approvedVia ?? "owner",
accessMetadata: options?.accessMetadata,
});
const nextNodeGeneration = resolveNodePairingGeneration(device);
const nodePairingGenerationChanged = Boolean(
previousNodeGeneration && previousNodeGeneration.key !== nextNodeGeneration?.key,
);
clearNodePairingGenerationBins(device, previousNodeGeneration);
const installationIdentityChanged = Boolean(
existing && existing.publicKey !== device.publicKey,
);
delete state.pendingById[requestId];
state.pairedByDeviceId[device.deviceId] = device;
persistState(
state,
baseDir,
"both",
installationIdentityChanged ? { clearApnsNodeIds: [device.deviceId] } : undefined,
);
return {
status: "approved",
requestId,
device,
...(nodePairingGenerationChanged ? { nodePairingGenerationChanged: true as const } : {}),
};
});
}
/** Approve a pending request through a bounded bootstrap profile handoff. */
export async function approveBootstrapDevicePairing(
requestId: string,
bootstrapProfile: DeviceBootstrapProfile,
baseDir?: string,
): Promise<ApproveDevicePairingResult>;
export async function approveBootstrapDevicePairing(
requestId: string,
bootstrapProfile: DeviceBootstrapProfile,
options: { accessMetadata?: DevicePairingAccessMetadata },
baseDir?: string,
): Promise<ApproveDevicePairingResult>;
export async function approveBootstrapDevicePairing(
requestId: string,
bootstrapProfile: DeviceBootstrapProfile,
optionsOrBaseDir?: { accessMetadata?: DevicePairingAccessMetadata } | string,
maybeBaseDir?: string,
): Promise<ApproveDevicePairingResult> {
const options =
typeof optionsOrBaseDir === "string" || optionsOrBaseDir === undefined
? undefined
: optionsOrBaseDir;
const baseDir = typeof optionsOrBaseDir === "string" ? optionsOrBaseDir : maybeBaseDir;
const approvedRoles = mergeDevicePairingRoles(bootstrapProfile.roles) ?? [];
const approvedScopes = resolveDeviceProfileScopes(bootstrapProfile, approvedRoles);
return await withDevicePairingLock(async () => {
const state = await loadDevicePairingState(baseDir);
const pending = state.pendingById[requestId];
if (!pending) {
return null;
}
const requestedRoles = resolveRequestedDeviceRoles(pending);
const missingRole = requestedRoles.find((role) => !approvedRoles.includes(role));
if (missingRole) {
return { status: "forbidden", reason: "bootstrap-role-not-allowed", role: missingRole };
}
const requestedOperatorScopes = normalizeDeviceAuthScopes(pending.scopes).filter((scope) =>
scope.startsWith(OPERATOR_SCOPE_PREFIX),
);
const missingScope = resolveMissingRequestedScope({
role: OPERATOR_ROLE,
requestedScopes: requestedOperatorScopes,
allowedScopes: approvedScopes,
});
if (missingScope) {
return { status: "forbidden", reason: "bootstrap-scope-not-allowed", scope: missingScope };
}
const now = Date.now();
const existing = state.pairedByDeviceId[pending.deviceId];
const grantedRoles = requestedRoles;
const grantedScopes = resolveDeviceProfileScopes(
bootstrapProfile,
grantedRoles,
pending.scopes ?? [],
);
const grantedRoleSet = new Set(grantedRoles);
const preservedExistingScopes = (
mergeDevicePairingRoles(existing?.roles, existing?.role) ?? []
).flatMap((existingRole) =>
grantedRoleSet.has(existingRole)
? []
: preserveDeviceRoleScopes(existingRole, existing?.approvedScopes ?? existing?.scopes),
);
const roles = mergeDevicePairingRoles(
existing?.roles,
existing?.role,
pending.roles,
pending.role,
);
const nextApprovedScopes = mergeDevicePairingScopes(preservedExistingScopes, grantedScopes);
const previousNodeGeneration = resolveNodePairingGeneration(existing ?? null);
const tokens = existing?.tokens ? { ...existing.tokens } : {};
for (const roleForToken of grantedRoles) {
const existingToken = tokens[roleForToken];
const tokenScopes =
roleForToken === OPERATOR_ROLE
? resolveDeviceProfileRoleScopes(bootstrapProfile, roleForToken, grantedScopes)
: [];
tokens[roleForToken] = createDeviceAuthToken({
role: roleForToken,
scopes: tokenScopes,
existing: existingToken,
now,
...(existingToken ? { rotatedAtMs: now } : {}),
});
}
const device = buildApprovedPairedDevice({
pending,
existing,
roles,
approvedScopes: nextApprovedScopes,
tokens,
now,
approvedVia: "bootstrap",
accessMetadata: options?.accessMetadata,
});
const nextNodeGeneration = resolveNodePairingGeneration(device);
const nodePairingGenerationChanged = Boolean(
previousNodeGeneration && previousNodeGeneration.key !== nextNodeGeneration?.key,
);
clearNodePairingGenerationBins(device, previousNodeGeneration);
const installationIdentityChanged = Boolean(
existing && existing.publicKey !== device.publicKey,
);
delete state.pendingById[requestId];
state.pairedByDeviceId[device.deviceId] = device;
persistState(
state,
baseDir,
"both",
installationIdentityChanged ? { clearApnsNodeIds: [device.deviceId] } : undefined,
);
return {
status: "approved",
requestId,
device,
...(nodePairingGenerationChanged ? { nodePairingGenerationChanged: true as const } : {}),
};
});
}
+1 -1
View File
@@ -2,8 +2,8 @@
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { normalizeDeviceAuthScopes } from "../shared/device-auth.js";
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
import { approveDevicePairing } from "./device-pairing-approval.js";
import {
approveDevicePairing,
getPairedDevice,
listDevicePairing,
requestDevicePairing,
+1 -1
View File
@@ -4,9 +4,9 @@ import path from "node:path";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
import { approveDevicePairing } from "./device-pairing-approval.js";
import { migrateLegacyDevicePairingStore } from "./device-pairing-migration.js";
import {
approveDevicePairing,
getPairedDevice,
listDevicePairing,
requestDevicePairing,
+1 -1
View File
@@ -3,6 +3,7 @@ import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { createDeferred } from "../../test/helpers/promise.js";
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
import { approveDevicePairing } from "./device-pairing-approval.js";
import {
approveNodePairing,
beginNodePairingConnect,
@@ -17,7 +18,6 @@ import {
updatePairedNodeBins,
} from "./device-pairing-node.js";
import {
approveDevicePairing,
getPairedDevice,
requestDevicePairing,
resolveNodePairingGeneration,
+1 -2
View File
@@ -2,9 +2,8 @@
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
import { approveBootstrapDevicePairing, approveDevicePairing } from "./device-pairing-approval.js";
import {
approveBootstrapDevicePairing,
approveDevicePairing,
getPairedDevice,
listDevicePairing,
pruneSupersededSilentPairedDevices,
+180
View File
@@ -0,0 +1,180 @@
// Shared snapshot, lock, and normalization owner for device pairing domain modules.
import { expectDefined } from "@openclaw/normalization-core";
import { normalizeUniqueSingleOrTrimmedStringList } from "@openclaw/normalization-core/string-normalization";
import { loadDevicePairingStoreStateReadOnly } from "./device-pairing-store-readonly.js";
import {
loadDevicePairingStoreState,
type DevicePairingStoreState,
} from "./device-pairing-store.js";
import type { DeviceAuthToken, PairedDevice } from "./device-pairing.types.js";
import { createAsyncLock, pruneExpiredPending } from "./pairing-files.js";
const PAIRING_PENDING_TTL_MS = 5 * 60 * 1000;
const withLock = createAsyncLock();
function pruneExpiredPairingState(state: DevicePairingStoreState): void {
const now = Date.now();
pruneExpiredPending(state.pendingById, now, PAIRING_PENDING_TTL_MS);
// Pending node-surface requests share the pairing TTL; requests refresh
// their ts on reconnect so an actively retrying node keeps one alive.
for (const device of Object.values(state.pairedByDeviceId)) {
if (device.pendingNodeSurface && now - device.pendingNodeSurface.ts > PAIRING_PENDING_TTL_MS) {
delete device.pendingNodeSurface;
}
}
}
/** Run one pairing mutation under the process-wide device pairing lock. */
export async function withDevicePairingLock<T>(operate: () => Promise<T>): Promise<T> {
return await withLock(operate);
}
/** Load one mutable pairing snapshot with expired pending state removed. */
export async function loadDevicePairingState(baseDir?: string): Promise<DevicePairingStoreState> {
const state = loadDevicePairingStoreState(baseDir);
pruneExpiredPairingState(state);
return state;
}
/** Load one read-only pairing snapshot with expired pending state removed. */
export async function loadDevicePairingStateReadOnly(
baseDir?: string,
): Promise<DevicePairingStoreState> {
const state = loadDevicePairingStoreStateReadOnly(baseDir);
pruneExpiredPairingState(state);
return state;
}
/** Return whether one pending pairing timestamp is beyond the shared TTL. */
export function isPairingRequestExpired(timestampMs: number, nowMs = Date.now()): boolean {
return nowMs - timestampMs > PAIRING_PENDING_TTL_MS;
}
/** Resolve the expiry timestamp for one pending pairing request. */
export function resolvePairingRequestExpiry(timestampMs: number): number {
return timestampMs + PAIRING_PENDING_TTL_MS;
}
/** Normalize a device id at pairing state boundaries. */
export function normalizeDevicePairingId(deviceId: string) {
return deviceId.trim();
}
/** Normalize one requested or approved pairing role. */
export function normalizeDevicePairingRole(role: string | undefined): string | null {
const trimmed = role?.trim();
return trimmed ? trimmed : null;
}
/** Merge pairing roles while preserving first-seen order. */
export function mergeDevicePairingRoles(
...items: Array<string | string[] | undefined>
): string[] | undefined {
const roles = new Set<string>();
for (const item of items) {
for (const role of normalizeUniqueSingleOrTrimmedStringList(item)) {
roles.add(role);
}
}
if (roles.size === 0) {
return undefined;
}
return [...roles];
}
/** Merge pairing scopes while preserving first-seen order and explicit emptiness. */
export function mergeDevicePairingScopes(
...items: Array<string[] | undefined>
): string[] | undefined {
const scopes = new Set<string>();
let sawExplicitScopeList = false;
for (const item of items) {
if (!Array.isArray(item)) {
continue;
}
sawExplicitScopeList = true;
for (const scope of normalizeUniqueSingleOrTrimmedStringList(item)) {
scopes.add(scope);
}
}
if (scopes.size === 0) {
return sawExplicitScopeList ? [] : undefined;
}
return [...scopes];
}
/** Preserve only approval scopes owned by one pairing role. */
export function preserveDeviceRoleScopes(role: string, scopes: string[] | undefined): string[] {
return normalizeUniqueSingleOrTrimmedStringList(scopes).filter((scope) =>
role === "operator" ? scope.startsWith("operator.") : !scope.startsWith("operator."),
);
}
/** Compare pairing role or scope lists as unordered sets. */
export function sameDevicePairingStringSet(
left: readonly string[],
right: readonly string[],
): boolean {
if (left.length !== right.length) {
return false;
}
const rightSet = new Set(right);
for (const value of left) {
if (!rightSet.has(value)) {
return false;
}
}
return true;
}
/** Resolve the normalized role set requested by a pairing record. */
export function resolveRequestedDeviceRoles(input: { role?: string; roles?: string[] }): string[] {
return mergeDevicePairingRoles(input.roles, input.role) ?? [];
}
/** Clone a paired device's role-token map before mutation. */
export function cloneDevicePairingTokens(device: PairedDevice): Record<string, DeviceAuthToken> {
return device.tokens ? { ...device.tokens } : {};
}
/** Refresh one compatible pending request or replace a superseded request set atomically. */
export function reconcilePendingPairingRequests<
TPending extends { requestId: string },
TIncoming,
>(params: {
pendingById: Record<string, TPending>;
existing: readonly TPending[];
incoming: TIncoming;
canRefreshSingle: (existing: TPending, incoming: TIncoming) => boolean;
refreshSingle: (existing: TPending, incoming: TIncoming) => TPending;
buildReplacement: (params: { existing: readonly TPending[]; incoming: TIncoming }) => TPending;
persist: () => void;
}): { status: "pending"; request: TPending; created: boolean } {
if (
params.existing.length === 1 &&
params.canRefreshSingle(
expectDefined(params.existing[0], "existing entry at 0"),
params.incoming,
)
) {
const refreshed = params.refreshSingle(
expectDefined(params.existing[0], "existing entry at 0"),
params.incoming,
);
params.pendingById[refreshed.requestId] = refreshed;
params.persist();
return { status: "pending", request: refreshed, created: false };
}
for (const existing of params.existing) {
delete params.pendingById[existing.requestId];
}
const request = params.buildReplacement({
existing: params.existing,
incoming: params.incoming,
});
params.pendingById[request.requestId] = request;
params.persist();
return { status: "pending", request, created: true };
}
+421
View File
@@ -0,0 +1,421 @@
// Device token issuance, verification, rotation, and revocation for paired devices.
import { normalizeDeviceAuthScopes } from "../shared/device-auth.js";
import { resolveMissingRequestedScope, roleScopesAllow } from "../shared/operator-scope-compat.js";
import {
cloneDevicePairingTokens,
loadDevicePairingState,
normalizeDevicePairingId,
normalizeDevicePairingRole,
withDevicePairingLock,
} from "./device-pairing-state.js";
import {
persistDevicePairingStoreState as persistState,
type DevicePairingStoreState,
} from "./device-pairing-store.js";
import {
clearNodePairingGenerationBins,
listApprovedPairedDeviceRoles,
resolveNodePairingGeneration,
} from "./device-pairing.js";
import type { DeviceAuthToken, PairedDevice } from "./device-pairing.types.js";
import { generatePairingToken, verifyPairingToken } from "./pairing-token.js";
const OPERATOR_SCOPE_PREFIX = "operator.";
const SHARED_GATEWAY_AUTH_ISSUER_KIND = "shared-gateway-auth";
const BROWSER_DEVICE_CLIENT_IDS = new Set(["openclaw-control-ui", "webchat-ui"]);
const BROWSER_DEVICE_CLIENT_MODE = "webchat";
/** Redacted token metadata safe for list/status responses. */
export type DeviceAuthTokenSummary = {
role: string;
scopes: string[];
createdAtMs: number;
rotatedAtMs?: number;
revokedAtMs?: number;
lastUsedAtMs?: number;
};
/** Deny reasons returned when rotating an existing paired-device token. */
export type RotateDeviceTokenDenyReason =
| "unknown-device-or-role"
| "missing-approved-scope-baseline"
| "scope-outside-approved-baseline"
| "caller-missing-scope";
/** Token rotation result with the replacement token entry on success. */
type RotateDeviceTokenResult =
| { ok: true; entry: DeviceAuthToken }
| { ok: false; reason: RotateDeviceTokenDenyReason; scope?: string };
export type RevokeDeviceTokenDenyReason = "unknown-device-or-role" | "caller-missing-scope";
/** Token revocation result with the revoked entry on success. */
type RevokeDeviceTokenResult =
| { ok: true; entry: DeviceAuthToken }
| { ok: false; reason: RevokeDeviceTokenDenyReason; scope?: string };
function getPairedDeviceFromState(
state: DevicePairingStoreState,
deviceId: string,
): PairedDevice | null {
return state.pairedByDeviceId[normalizeDevicePairingId(deviceId)] ?? null;
}
function isBrowserRelatedPairedDevice(device: Pick<PairedDevice, "clientId" | "clientMode">) {
const clientMode = device.clientMode?.trim().toLowerCase();
if (clientMode === BROWSER_DEVICE_CLIENT_MODE) {
return true;
}
const clientId = device.clientId?.trim().toLowerCase();
return clientId ? BROWSER_DEVICE_CLIENT_IDS.has(clientId) : false;
}
function deviceTokenIssuerMatches(
entry: DeviceAuthToken,
issuer: DeviceAuthToken["issuer"] | undefined,
): boolean {
if (!issuer) {
return !entry.issuer;
}
return entry.issuer?.kind === issuer.kind && entry.issuer.generation === issuer.generation;
}
/** Build one freshly generated role token while preserving requested lifecycle fields. */
export function createDeviceAuthToken(params: {
role: string;
scopes: string[];
issuer?: DeviceAuthToken["issuer"];
existing?: DeviceAuthToken;
preserveExistingIssuer?: boolean;
now: number;
rotatedAtMs?: number;
}): DeviceAuthToken {
return {
token: generatePairingToken(),
role: params.role,
scopes: params.scopes,
issuer: params.issuer ?? (params.preserveExistingIssuer ? params.existing?.issuer : undefined),
createdAtMs: params.existing?.createdAtMs ?? params.now,
rotatedAtMs: params.rotatedAtMs,
revokedAtMs: undefined,
lastUsedAtMs: params.existing?.lastUsedAtMs,
};
}
/** Select scopes owned by one device-token role. */
export function resolveRoleTokenScopes(role: string, scopes: string[] | undefined): string[] {
const normalized = normalizeDeviceAuthScopes(scopes);
if (role === "operator") {
return normalized.filter((scope) => scope.startsWith(OPERATOR_SCOPE_PREFIX));
}
return normalized.filter((scope) => !scope.startsWith(OPERATOR_SCOPE_PREFIX));
}
function resolveApprovedDeviceScopeBaseline(device: PairedDevice): string[] | null {
const baseline = device.approvedScopes ?? device.scopes;
if (!Array.isArray(baseline)) {
return null;
}
return normalizeDeviceAuthScopes(baseline);
}
function scopesWithinApprovedDeviceBaseline(params: {
role: string;
scopes: readonly string[];
approvedScopes: readonly string[] | null;
}): boolean {
if (!params.approvedScopes) {
return false;
}
return roleScopesAllow({
role: params.role,
requestedScopes: params.scopes,
allowedScopes: params.approvedScopes,
});
}
/** Summarize token metadata without exposing bearer token strings. */
export function summarizeDeviceTokens(
tokens: Record<string, DeviceAuthToken> | undefined,
): DeviceAuthTokenSummary[] | undefined {
if (!tokens) {
return undefined;
}
const summaries = Object.values(tokens)
.map((token) => ({
role: token.role,
scopes: token.scopes,
createdAtMs: token.createdAtMs,
rotatedAtMs: token.rotatedAtMs,
revokedAtMs: token.revokedAtMs,
lastUsedAtMs: token.lastUsedAtMs,
}))
.toSorted((a, b) => a.role.localeCompare(b.role));
return summaries.length > 0 ? summaries : undefined;
}
/** Verify a device role token, scope it to the approval baseline, and mark last use. */
export async function verifyDeviceToken(params: {
deviceId: string;
token: string;
role: string;
scopes: string[];
requiredSharedGatewaySessionGeneration?: string;
baseDir?: string;
}): Promise<{ ok: boolean; reason?: string; issuer?: DeviceAuthToken["issuer"] }> {
return await withDevicePairingLock(async () => {
const state = await loadDevicePairingState(params.baseDir);
const device = getPairedDeviceFromState(state, params.deviceId);
if (!device) {
return { ok: false, reason: "device-not-paired" };
}
const role = normalizeDevicePairingRole(params.role);
if (!role) {
return { ok: false, reason: "role-missing" };
}
const entry = device.tokens?.[role];
if (!entry) {
return { ok: false, reason: "token-missing" };
}
if (entry.revokedAtMs) {
return { ok: false, reason: "token-revoked" };
}
if (!verifyPairingToken(params.token, entry.token)) {
return { ok: false, reason: "token-mismatch" };
}
if (
entry.issuer?.kind === SHARED_GATEWAY_AUTH_ISSUER_KIND &&
entry.issuer.generation !== params.requiredSharedGatewaySessionGeneration
) {
return { ok: false, reason: "issuer-generation-stale" };
}
if (
!entry.issuer &&
params.requiredSharedGatewaySessionGeneration !== undefined &&
isBrowserRelatedPairedDevice(device)
) {
return { ok: false, reason: "legacy-browser-token" };
}
const approvedScopes = resolveApprovedDeviceScopeBaseline(device);
if (
!scopesWithinApprovedDeviceBaseline({
role,
scopes: entry.scopes,
approvedScopes,
})
) {
return { ok: false, reason: "scope-mismatch" };
}
const requestedScopes = normalizeDeviceAuthScopes(params.scopes);
if (!roleScopesAllow({ role, requestedScopes, allowedScopes: entry.scopes })) {
return { ok: false, reason: "scope-mismatch" };
}
const now = Date.now();
entry.lastUsedAtMs = now;
device.tokens ??= {};
device.tokens[role] = entry;
device.lastSeenAtMs = now;
device.lastSeenReason = "device-token-auth";
state.pairedByDeviceId[device.deviceId] = device;
persistState(state, params.baseDir, "paired");
return entry.issuer ? { ok: true, issuer: entry.issuer } : { ok: true };
});
}
/** Return a reusable token for a role or issue one within the approved scope baseline. */
export async function ensureDeviceToken(params: {
deviceId: string;
role: string;
scopes: string[];
issuer?: DeviceAuthToken["issuer"];
baseDir?: string;
}): Promise<DeviceAuthToken | null> {
return await withDevicePairingLock(async () => {
const state = await loadDevicePairingState(params.baseDir);
const requestedScopes = normalizeDeviceAuthScopes(params.scopes);
const context = resolveDeviceTokenUpdateContext({
state,
deviceId: params.deviceId,
role: params.role,
});
if (!context) {
return null;
}
const { device, role, tokens, existing } = context;
const previousNodeGeneration = resolveNodePairingGeneration(device);
const approvedScopes = resolveApprovedDeviceScopeBaseline(device);
if (
!scopesWithinApprovedDeviceBaseline({
role,
scopes: requestedScopes,
approvedScopes,
})
) {
return null;
}
if (existing && !existing.revokedAtMs) {
const existingWithinApproved = scopesWithinApprovedDeviceBaseline({
role,
scopes: existing.scopes,
approvedScopes,
});
const issuerAllowsReuse = deviceTokenIssuerMatches(existing, params.issuer);
if (
existingWithinApproved &&
issuerAllowsReuse &&
roleScopesAllow({ role, requestedScopes, allowedScopes: existing.scopes })
) {
return existing;
}
}
const now = Date.now();
const next = createDeviceAuthToken({
role,
scopes: requestedScopes,
issuer: params.issuer,
existing,
now,
rotatedAtMs: existing ? now : undefined,
});
tokens[role] = next;
device.tokens = tokens;
clearNodePairingGenerationBins(device, previousNodeGeneration);
state.pairedByDeviceId[device.deviceId] = device;
persistState(state, params.baseDir, "paired");
return next;
});
}
function resolveDeviceTokenUpdateContext(params: {
state: DevicePairingStoreState;
deviceId: string;
role: string;
}): {
device: PairedDevice;
role: string;
tokens: Record<string, DeviceAuthToken>;
existing: DeviceAuthToken | undefined;
} | null {
const device = getPairedDeviceFromState(params.state, params.deviceId);
if (!device) {
return null;
}
const role = normalizeDevicePairingRole(params.role);
if (!role) {
return null;
}
// Token issuance and rotation must stay inside the role set that pairing
// approval recorded for this device.
if (!listApprovedPairedDeviceRoles(device).includes(role)) {
return null;
}
const tokens = cloneDevicePairingTokens(device);
const existing = tokens[role];
return { device, role, tokens, existing };
}
/** Rotate a role token inside the device's approved scope baseline. */
export async function rotateDeviceToken(params: {
deviceId: string;
role: string;
scopes?: string[];
callerScopes?: readonly string[];
baseDir?: string;
}): Promise<RotateDeviceTokenResult> {
return await withDevicePairingLock(async () => {
const state = await loadDevicePairingState(params.baseDir);
const context = resolveDeviceTokenUpdateContext({
state,
deviceId: params.deviceId,
role: params.role,
});
if (!context) {
return { ok: false, reason: "unknown-device-or-role" };
}
const { device, role, tokens, existing } = context;
const previousNodeGeneration = resolveNodePairingGeneration(device);
const requestedScopes = normalizeDeviceAuthScopes(
params.scopes ?? existing?.scopes ?? device.scopes,
);
const approvedScopes = resolveApprovedDeviceScopeBaseline(device);
if (!approvedScopes) {
return { ok: false, reason: "missing-approved-scope-baseline" };
}
if (
!scopesWithinApprovedDeviceBaseline({
role,
scopes: requestedScopes,
approvedScopes,
})
) {
return { ok: false, reason: "scope-outside-approved-baseline" };
}
if (params.callerScopes) {
const missingScope = resolveMissingRequestedScope({
role,
requestedScopes,
allowedScopes: params.callerScopes,
});
if (missingScope) {
return { ok: false, reason: "caller-missing-scope", scope: missingScope };
}
}
const now = Date.now();
const next = createDeviceAuthToken({
role,
scopes: requestedScopes,
existing,
preserveExistingIssuer: true,
now,
rotatedAtMs: now,
});
tokens[role] = next;
device.tokens = tokens;
clearNodePairingGenerationBins(device, previousNodeGeneration);
state.pairedByDeviceId[device.deviceId] = device;
persistState(state, params.baseDir, "paired");
return { ok: true, entry: next };
});
}
/** Revoke one active role token after optional caller-scope authorization. */
export async function revokeDeviceToken(params: {
deviceId: string;
role: string;
callerScopes?: readonly string[];
baseDir?: string;
}): Promise<RevokeDeviceTokenResult> {
return await withDevicePairingLock(async () => {
const state = await loadDevicePairingState(params.baseDir);
const context = resolveDeviceTokenUpdateContext({
state,
deviceId: params.deviceId,
role: params.role,
});
if (!context || !context.existing) {
return { ok: false, reason: "unknown-device-or-role" };
}
const { device, role, tokens, existing } = context;
const previousNodeGeneration = resolveNodePairingGeneration(device);
const targetScopes = normalizeDeviceAuthScopes(
Array.isArray(existing.scopes) ? existing.scopes : device.scopes,
);
if (params.callerScopes) {
const missingScope = resolveMissingRequestedScope({
role,
requestedScopes: targetScopes,
allowedScopes: params.callerScopes,
});
if (missingScope) {
return { ok: false, reason: "caller-missing-scope", scope: missingScope };
}
}
const entry = { ...existing, revokedAtMs: Date.now() };
tokens[role] = entry;
device.tokens = tokens;
clearNodePairingGenerationBins(device, previousNodeGeneration);
state.pairedByDeviceId[device.deviceId] = device;
persistState(state, params.baseDir, "paired");
return { ok: true, entry };
});
}
+6 -5
View File
@@ -8,6 +8,7 @@ import {
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
import { issueDeviceBootstrapToken, verifyDeviceBootstrapToken } from "./device-bootstrap.js";
import { approveBootstrapDevicePairing, approveDevicePairing } from "./device-pairing-approval.js";
import {
approveNodePairing,
requestNodePairing,
@@ -19,9 +20,12 @@ import {
persistDevicePairingStoreState,
} from "./device-pairing-store.js";
import {
approveBootstrapDevicePairing,
approveDevicePairing,
ensureDeviceToken,
revokeDeviceToken,
rotateDeviceToken,
verifyDeviceToken,
} from "./device-pairing-tokens.js";
import {
getPairedDevice,
hasEffectivePairedDeviceRole,
listEffectivePairedDeviceRoles,
@@ -30,11 +34,8 @@ import {
requestDevicePairing,
rejectDevicePairing,
resolveNodePairingGeneration,
revokeDeviceToken,
rotateDeviceToken,
updatePairedDeviceMetadata,
updatePairedDevicePresence,
verifyDeviceToken,
withPairedDeviceRecords,
type PairedDevice,
} from "./device-pairing.js";
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -3,13 +3,14 @@ import fs from "node:fs/promises";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
import { approveDevicePairing } from "./device-pairing-approval.js";
import {
approveNodePairing,
listNodePairing,
recordPairedNodeConnection,
requestNodePairing,
} from "./device-pairing-node.js";
import { approveDevicePairing, getPairedDevice, requestDevicePairing } from "./device-pairing.js";
import { getPairedDevice, requestDevicePairing } from "./device-pairing.js";
import { migrateLegacyNodePairingStore } from "./node-pairing-migration.js";
import { resolvePairingPaths } from "./pairing-files.js";
+2 -1
View File
@@ -1,6 +1,7 @@
// Shared bootstrap/pairing helpers for plugins that provision remote devices.
export { approveDevicePairing, listDevicePairing } from "../infra/device-pairing.js";
export { approveDevicePairing } from "../infra/device-pairing-approval.js";
export { listDevicePairing } from "../infra/device-pairing.js";
export {
clearDeviceBootstrapTokens,
issueDeviceBootstrapToken,