Skip to content

Commit ec56dd3

Browse files
committed
fix(pairing): preserve corrupt pairing stores
1 parent 5469740 commit ec56dd3

10 files changed

Lines changed: 154 additions & 9 deletions

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ Docs: https://docs.openclaw.ai
7575
- Diagnostics/OTEL: treat normal early model stream cleanup as a completed
7676
model call instead of exporting a misleading `StreamAbandoned` error span.
7777
Thanks @vincentkoc.
78+
- Gateway/pairing: stop corrupt or unreadable device/node pairing stores from
79+
being treated as empty state, preserving `paired.json` for repair instead of
80+
overwriting approved pairings. Fixes #71873. Thanks @iret77.
7881
- ACP: wait for the configured runtime backend to become healthy before startup
7982
identity reconciliation, avoiding transient acpx warnings during Gateway boot.
8083
Fixes #40566.

src/commands/doctor-device-pairing.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,34 @@ describe("noteDevicePairingHealth", () => {
104104
});
105105
});
106106

107+
it("warns when local pairing state is corrupt instead of treating it as empty", async () => {
108+
await withTempDir("openclaw-doctor-device-pairing-", async (stateDir) => {
109+
await withEnvAsync(
110+
{
111+
OPENCLAW_STATE_DIR: stateDir,
112+
OPENCLAW_TEST_FAST: "1",
113+
},
114+
async () => {
115+
const pairedPath = path.join(stateDir, "devices", "paired.json");
116+
await fs.mkdir(path.dirname(pairedPath), { recursive: true });
117+
await fs.writeFile(pairedPath, "{not-json}", "utf8");
118+
119+
await noteDevicePairingHealth({
120+
cfg: { gateway: { mode: "local" } },
121+
healthOk: false,
122+
});
123+
124+
expect(noteMock).toHaveBeenCalledTimes(1);
125+
const message = String(noteMock.mock.calls[0]?.[0] ?? "");
126+
expect(noteMock.mock.calls[0]?.[1]).toBe("Device pairing");
127+
expect(message).toContain("paired.json");
128+
expect(message).toContain("refused to treat it as empty");
129+
expect(await fs.readFile(pairedPath, "utf8")).toBe("{not-json}");
130+
},
131+
);
132+
});
133+
});
134+
107135
it("warns when the local cached device token predates the gateway rotation", async () => {
108136
await withApprovedOperatorPairing(async ({ stateDir, identity }) => {
109137
storeDeviceAuthToken({

src/commands/doctor-device-pairing.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
type DevicePairingPendingRequest,
1313
type PairedDevice,
1414
} from "../infra/device-pairing.js";
15+
import { JsonFileReadError } from "../infra/json-files.js";
1516
import type { DeviceAuthStore } from "../shared/device-auth.js";
1617
import { normalizeDeviceAuthScopes } from "../shared/device-auth.js";
1718
import { roleScopesAllow } from "../shared/operator-scope-compat.js";
@@ -510,11 +511,25 @@ function collectLocalDeviceAuthIssues(snapshot: DoctorPairingSnapshot): string[]
510511
return lines;
511512
}
512513

514+
function formatPairingStoreReadIssue(error: JsonFileReadError): string {
515+
const problem = error.reason === "parse" ? "contains invalid JSON" : "could not be read";
516+
return `- Device pairing store ${error.filePath} ${problem}. OpenClaw refused to treat it as empty to avoid overwriting approved pairings. Fix the JSON or file permissions, or move it aside and re-pair devices.`;
517+
}
518+
513519
export async function noteDevicePairingHealth(params: {
514520
cfg: OpenClawConfig;
515521
healthOk: boolean;
516522
}): Promise<void> {
517-
const snapshot = await loadDoctorPairingSnapshot(params);
523+
let snapshot: DoctorPairingSnapshot | null;
524+
try {
525+
snapshot = await loadDoctorPairingSnapshot(params);
526+
} catch (error) {
527+
if (error instanceof JsonFileReadError) {
528+
note(formatPairingStoreReadIssue(error), "Device pairing");
529+
return;
530+
}
531+
throw error;
532+
}
518533
if (!snapshot) {
519534
return;
520535
}

src/infra/device-pairing.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1053,6 +1053,26 @@ describe("device pairing tokens", () => {
10531053
await expect(getPairedDevice("device-1", baseDir)).resolves.toBeNull();
10541054
});
10551055

1056+
test("refuses to overwrite corrupt paired device state", async () => {
1057+
const baseDir = await makeDevicePairingDir();
1058+
const request = await requestDevicePairing(
1059+
{
1060+
deviceId: "device-1",
1061+
publicKey: "public-key-1",
1062+
role: "node",
1063+
scopes: [],
1064+
},
1065+
baseDir,
1066+
);
1067+
const { pairedPath } = resolvePairingPaths(baseDir, "devices");
1068+
await writeFile(pairedPath, "{not-json}", "utf8");
1069+
1070+
await expect(
1071+
approveDevicePairing(request.request.requestId, { callerScopes: [] }, baseDir),
1072+
).rejects.toThrow(/paired\.json/);
1073+
await expect(readFile(pairedPath, "utf8")).resolves.toBe("{not-json}");
1074+
});
1075+
10561076
test("clears paired device state by device id", async () => {
10571077
const baseDir = await makeDevicePairingDir();
10581078
await setupPairedOperatorDevice(baseDir, ["operator.read"]);

src/infra/device-pairing.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
import {
1313
createAsyncLock,
1414
pruneExpiredPending,
15-
readJsonFile,
15+
readDurableJsonFile,
1616
reconcilePendingPairingRequests,
1717
resolvePairingPaths,
1818
writeJsonAtomic,
@@ -143,8 +143,8 @@ export function formatDevicePairingForbiddenMessage(result: DevicePairingForbidd
143143
async function loadState(baseDir?: string): Promise<DevicePairingStateFile> {
144144
const { pendingPath, pairedPath } = resolvePairingPaths(baseDir, "devices");
145145
const [pending, paired] = await Promise.all([
146-
readJsonFile<Record<string, DevicePairingPendingRequest>>(pendingPath),
147-
readJsonFile<Record<string, PairedDevice>>(pairedPath),
146+
readDurableJsonFile<Record<string, DevicePairingPendingRequest>>(pendingPath),
147+
readDurableJsonFile<Record<string, PairedDevice>>(pairedPath),
148148
]);
149149
const state: DevicePairingStateFile = {
150150
pendingById: pending ?? {},

src/infra/json-files.test.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,14 @@ import path from "node:path";
33
import { setTimeout as sleep } from "node:timers/promises";
44
import { afterEach, describe, expect, it, vi } from "vitest";
55
import { withTempDir } from "../test-helpers/temp-dir.js";
6-
import { createAsyncLock, readJsonFile, writeJsonAtomic, writeTextAtomic } from "./json-files.js";
6+
import {
7+
JsonFileReadError,
8+
createAsyncLock,
9+
readDurableJsonFile,
10+
readJsonFile,
11+
writeJsonAtomic,
12+
writeTextAtomic,
13+
} from "./json-files.js";
714

815
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
916

@@ -45,6 +52,23 @@ describe("json file helpers", () => {
4552
});
4653
});
4754

55+
it("reads durable json strictly while allowing missing files", async () => {
56+
await withTempDir({ prefix: "openclaw-json-files-" }, async (base) => {
57+
const validPath = path.join(base, "valid.json");
58+
const invalidPath = path.join(base, "invalid.json");
59+
const missingPath = path.join(base, "missing.json");
60+
await fs.writeFile(validPath, '{"ok":true}', "utf8");
61+
await fs.writeFile(invalidPath, "{not-json}", "utf8");
62+
63+
await expect(readDurableJsonFile(validPath)).resolves.toEqual({ ok: true });
64+
await expect(readDurableJsonFile(missingPath)).resolves.toBeNull();
65+
await expect(readDurableJsonFile(invalidPath)).rejects.toMatchObject({
66+
filePath: invalidPath,
67+
reason: "parse",
68+
} satisfies Partial<JsonFileReadError>);
69+
});
70+
});
71+
4872
it("writes json atomically with pretty formatting and optional trailing newline", async () => {
4973
await withTempDir({ prefix: "openclaw-json-files-" }, async (base) => {
5074
const filePath = path.join(base, "nested", "config.json");

src/infra/json-files.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ function getErrorCode(err: unknown): string | undefined {
77
return err instanceof Error ? (err as NodeJS.ErrnoException).code : undefined;
88
}
99

10+
export class JsonFileReadError extends Error {
11+
readonly filePath: string;
12+
readonly reason: "read" | "parse";
13+
14+
constructor(filePath: string, reason: "read" | "parse", cause: unknown) {
15+
super(`Failed to ${reason} JSON file: ${filePath}`, { cause });
16+
this.name = "JsonFileReadError";
17+
this.filePath = filePath;
18+
this.reason = reason;
19+
}
20+
}
21+
1022
async function replaceFileWithWindowsFallback(tempPath: string, filePath: string, mode: number) {
1123
try {
1224
await fs.rename(tempPath, filePath);
@@ -43,6 +55,23 @@ export async function readJsonFile<T>(filePath: string): Promise<T | null> {
4355
}
4456
}
4557

58+
export async function readDurableJsonFile<T>(filePath: string): Promise<T | null> {
59+
let raw: string;
60+
try {
61+
raw = await fs.readFile(filePath, "utf8");
62+
} catch (err) {
63+
if (getErrorCode(err) === "ENOENT") {
64+
return null;
65+
}
66+
throw new JsonFileReadError(filePath, "read", err);
67+
}
68+
try {
69+
return JSON.parse(raw) as T;
70+
} catch (err) {
71+
throw new JsonFileReadError(filePath, "parse", err);
72+
}
73+
}
74+
4675
export function readJsonFileSync(filePath: string): unknown {
4776
try {
4877
const raw = readFileSync(filePath, "utf8");

src/infra/node-pairing.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import fs from "node:fs/promises";
12
import { afterAll, beforeAll, describe, expect, test } from "vitest";
23
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
34
import {
@@ -7,6 +8,7 @@ import {
78
requestNodePairing,
89
verifyNodeToken,
910
} from "./node-pairing.js";
11+
import { resolvePairingPaths } from "./pairing-files.js";
1012

1113
async function setupPairedNode(baseDir: string): Promise<string> {
1214
const request = await requestNodePairing(
@@ -202,4 +204,23 @@ describe("node pairing tokens", () => {
202204
});
203205
});
204206
});
207+
208+
test("refuses to overwrite corrupt paired node state when requesting pairing", async () => {
209+
await withNodePairingDir(async (baseDir) => {
210+
const { dir, pairedPath } = resolvePairingPaths(baseDir, "nodes");
211+
await fs.mkdir(dir, { recursive: true });
212+
await fs.writeFile(pairedPath, "{not-json}", "utf8");
213+
214+
await expect(
215+
requestNodePairing(
216+
{
217+
nodeId: "node-1",
218+
platform: "darwin",
219+
},
220+
baseDir,
221+
),
222+
).rejects.toThrow(/paired\.json/);
223+
await expect(fs.readFile(pairedPath, "utf8")).resolves.toBe("{not-json}");
224+
});
225+
});
205226
});

src/infra/node-pairing.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { type NodeApprovalScope, resolveNodePairApprovalScopes } from "./node-pa
55
import {
66
createAsyncLock,
77
pruneExpiredPending,
8-
readJsonFile,
8+
readDurableJsonFile,
99
reconcilePendingPairingRequests,
1010
resolvePairingPaths,
1111
writeJsonAtomic,
@@ -134,8 +134,8 @@ type ApproveNodePairingResult = ApprovedNodePairingResult | ForbiddenNodePairing
134134
async function loadState(baseDir?: string): Promise<NodePairingStateFile> {
135135
const { pendingPath, pairedPath } = resolvePairingPaths(baseDir, "nodes");
136136
const [pending, paired] = await Promise.all([
137-
readJsonFile<Record<string, NodePairingPendingRequest>>(pendingPath),
138-
readJsonFile<Record<string, NodePairingPairedNode>>(pairedPath),
137+
readDurableJsonFile<Record<string, NodePairingPendingRequest>>(pendingPath),
138+
readDurableJsonFile<Record<string, NodePairingPairedNode>>(pairedPath),
139139
]);
140140
const state: NodePairingStateFile = {
141141
pendingById: pending ?? {},

src/infra/pairing-files.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import path from "node:path";
22
import { resolveStateDir } from "../config/paths.js";
33

4-
export { createAsyncLock, readJsonFile, writeJsonAtomic } from "./json-files.js";
4+
export {
5+
createAsyncLock,
6+
readDurableJsonFile,
7+
readJsonFile,
8+
writeJsonAtomic,
9+
} from "./json-files.js";
510

611
export function resolvePairingPaths(baseDir: string | undefined, subdir: string) {
712
const root = baseDir ?? resolveStateDir();

0 commit comments

Comments
 (0)