mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:10:44 +00:00
fix(pairing): recover malformed pairing state files
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
||||
import { PAIRING_SETUP_BOOTSTRAP_PROFILE } from "../shared/device-bootstrap-profile.js";
|
||||
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
|
||||
@@ -161,6 +161,42 @@ describe("device pairing tokens", () => {
|
||||
expect(second.request.requestId).toBe(first.request.requestId);
|
||||
});
|
||||
|
||||
test("recovers when pairing state files were written as arrays", async () => {
|
||||
const baseDir = await makeDevicePairingDir();
|
||||
const paths = resolvePairingPaths(baseDir, "devices");
|
||||
await mkdir(paths.dir, { recursive: true });
|
||||
await writeFile(paths.pendingPath, "[]", "utf8");
|
||||
await writeFile(paths.pairedPath, "[]", "utf8");
|
||||
|
||||
const pending = await requestDevicePairing(
|
||||
{
|
||||
deviceId: "device-array-state",
|
||||
publicKey: "public-key-array-state",
|
||||
role: "operator",
|
||||
scopes: ["operator.read"],
|
||||
},
|
||||
baseDir,
|
||||
);
|
||||
const approved = await approveDevicePairing(
|
||||
pending.request.requestId,
|
||||
{ callerScopes: ["operator.read"] },
|
||||
baseDir,
|
||||
);
|
||||
|
||||
expect(approved).toEqual(
|
||||
expect.objectContaining({
|
||||
status: "approved",
|
||||
device: expect.objectContaining({ deviceId: "device-array-state" }),
|
||||
}),
|
||||
);
|
||||
expect(Array.isArray(JSON.parse(await readFile(paths.pendingPath, "utf8")))).toBe(false);
|
||||
expect(JSON.parse(await readFile(paths.pairedPath, "utf8"))).toEqual(
|
||||
expect.objectContaining({
|
||||
"device-array-state": expect.objectContaining({ deviceId: "device-array-state" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("re-requesting with identical params preserves the original ts to prevent queue-jumping", async () => {
|
||||
// Regression: refreshPendingDevicePairingRequest must not bump ts to Date.now().
|
||||
// An attacker who reconnects with the same key/role/scopes could otherwise
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
pruneExpiredPending,
|
||||
readDurableJsonFile,
|
||||
reconcilePendingPairingRequests,
|
||||
coercePairingStateRecord,
|
||||
resolvePairingPaths,
|
||||
writeJsonAtomic,
|
||||
} from "./pairing-files.js";
|
||||
@@ -152,12 +153,12 @@ export function formatDevicePairingForbiddenMessage(result: DevicePairingForbidd
|
||||
async function loadState(baseDir?: string): Promise<DevicePairingStateFile> {
|
||||
const { pendingPath, pairedPath } = resolvePairingPaths(baseDir, "devices");
|
||||
const [pending, paired] = await Promise.all([
|
||||
readDurableJsonFile<Record<string, DevicePairingPendingRequest>>(pendingPath),
|
||||
readDurableJsonFile<Record<string, PairedDevice>>(pairedPath),
|
||||
readDurableJsonFile<unknown>(pendingPath),
|
||||
readDurableJsonFile<unknown>(pairedPath),
|
||||
]);
|
||||
const state: DevicePairingStateFile = {
|
||||
pendingById: pending ?? {},
|
||||
pairedByDeviceId: paired ?? {},
|
||||
pendingById: coercePairingStateRecord<DevicePairingPendingRequest>(pending),
|
||||
pairedByDeviceId: coercePairingStateRecord<PairedDevice>(paired),
|
||||
};
|
||||
pruneExpiredPending(state.pendingById, Date.now(), PENDING_TTL_MS);
|
||||
return state;
|
||||
|
||||
@@ -131,6 +131,41 @@ describe("node pairing tokens", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("recovers when pairing state files were written as arrays", async () => {
|
||||
await withNodePairingDir(async (baseDir) => {
|
||||
const paths = resolvePairingPaths(baseDir, "nodes");
|
||||
await fs.mkdir(paths.dir, { recursive: true });
|
||||
await fs.writeFile(paths.pendingPath, "[]", "utf8");
|
||||
await fs.writeFile(paths.pairedPath, "[]", "utf8");
|
||||
|
||||
const pending = await requestNodePairing(
|
||||
{
|
||||
nodeId: "node-array-state",
|
||||
platform: "darwin",
|
||||
commands: ["system.run"],
|
||||
},
|
||||
baseDir,
|
||||
);
|
||||
const approved = await approveNodePairing(
|
||||
pending.request.requestId,
|
||||
{ callerScopes: ["operator.pairing", "operator.admin"] },
|
||||
baseDir,
|
||||
);
|
||||
|
||||
expect(approved).toEqual(
|
||||
expect.objectContaining({
|
||||
node: expect.objectContaining({ nodeId: "node-array-state" }),
|
||||
}),
|
||||
);
|
||||
expect(Array.isArray(JSON.parse(await fs.readFile(paths.pendingPath, "utf8")))).toBe(false);
|
||||
expect(JSON.parse(await fs.readFile(paths.pairedPath, "utf8"))).toEqual(
|
||||
expect.objectContaining({
|
||||
"node-array-state": expect.objectContaining({ nodeId: "node-array-state" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("generates base64url node tokens and rejects mismatches", async () => {
|
||||
await withNodePairingDir(async (baseDir) => {
|
||||
const token = await setupPairedNode(baseDir);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
pruneExpiredPending,
|
||||
readDurableJsonFile,
|
||||
reconcilePendingPairingRequests,
|
||||
coercePairingStateRecord,
|
||||
resolvePairingPaths,
|
||||
writeJsonAtomic,
|
||||
} from "./pairing-files.js";
|
||||
@@ -136,12 +137,12 @@ type ApproveNodePairingResult = ApprovedNodePairingResult | ForbiddenNodePairing
|
||||
async function loadState(baseDir?: string): Promise<NodePairingStateFile> {
|
||||
const { pendingPath, pairedPath } = resolvePairingPaths(baseDir, "nodes");
|
||||
const [pending, paired] = await Promise.all([
|
||||
readDurableJsonFile<Record<string, NodePairingPendingRequest>>(pendingPath),
|
||||
readDurableJsonFile<Record<string, NodePairingPairedNode>>(pairedPath),
|
||||
readDurableJsonFile<unknown>(pendingPath),
|
||||
readDurableJsonFile<unknown>(pairedPath),
|
||||
]);
|
||||
const state: NodePairingStateFile = {
|
||||
pendingById: pending ?? {},
|
||||
pairedByNodeId: paired ?? {},
|
||||
pendingById: coercePairingStateRecord<NodePairingPendingRequest>(pending),
|
||||
pairedByNodeId: coercePairingStateRecord<NodePairingPairedNode>(paired),
|
||||
};
|
||||
pruneExpiredPending(state.pendingById, Date.now(), PENDING_TTL_MS);
|
||||
return state;
|
||||
|
||||
@@ -18,6 +18,13 @@ export function resolvePairingPaths(baseDir: string | undefined, subdir: string)
|
||||
};
|
||||
}
|
||||
|
||||
export function coercePairingStateRecord<T>(value: unknown): Record<string, T> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
return value as Record<string, T>;
|
||||
}
|
||||
|
||||
export function pruneExpiredPending<T extends { ts: number }>(
|
||||
pendingById: Record<string, T>,
|
||||
nowMs: number,
|
||||
|
||||
Reference in New Issue
Block a user