mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
refactor(infra): split device pairing lifecycle modules (#124734)
This commit is contained in:
committed by
GitHub
parent
57ebd20566
commit
eb77ce5690
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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-",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
+61
-1080
File diff suppressed because it is too large
Load Diff
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user