diff --git a/src/config/sessions/store-read.test.ts b/src/config/sessions/store-read.test.ts new file mode 100644 index 00000000000..5c5acae5908 --- /dev/null +++ b/src/config/sessions/store-read.test.ts @@ -0,0 +1,24 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { withTempDir } from "../../test-utils/temp-dir.js"; +import { readSessionStoreReadOnly } from "./store-read.js"; + +describe("readSessionStoreReadOnly", () => { + it("returns an empty store for malformed or non-object JSON", async () => { + await withTempDir("openclaw-session-store-", async (dir) => { + const storePath = path.join(dir, "sessions.json"); + + await fs.writeFile(storePath, '["not-an-object"]\n', "utf8"); + expect(readSessionStoreReadOnly(storePath)).toEqual({}); + + await fs.writeFile(storePath, '{"session-1":{"sessionId":"s1","updatedAt":1}}\n', "utf8"); + expect(readSessionStoreReadOnly(storePath)).toMatchObject({ + "session-1": { + sessionId: "s1", + updatedAt: 1, + }, + }); + }); + }); +}); diff --git a/src/config/sessions/store-read.ts b/src/config/sessions/store-read.ts index 51c199f59e1..7444b3b6b2f 100644 --- a/src/config/sessions/store-read.ts +++ b/src/config/sessions/store-read.ts @@ -1,9 +1,11 @@ import fs from "node:fs"; +import { z } from "zod"; +import { safeParseJsonWithSchema } from "../../utils/zod-parse.js"; import type { SessionEntry } from "./types.js"; -function isSessionStoreRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} +const SessionStoreSchema = z.record(z.string(), z.unknown()) as z.ZodType< + Record +>; export function readSessionStoreReadOnly( storePath: string, @@ -13,8 +15,7 @@ export function readSessionStoreReadOnly( if (!raw.trim()) { return {}; } - const parsed = JSON.parse(raw); - return isSessionStoreRecord(parsed) ? parsed : {}; + return safeParseJsonWithSchema(SessionStoreSchema, raw) ?? {}; } catch { return {}; } diff --git a/src/infra/device-auth-store.ts b/src/infra/device-auth-store.ts index 1cf20295281..e9b6dc6e1e6 100644 --- a/src/infra/device-auth-store.ts +++ b/src/infra/device-auth-store.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import { z } from "zod"; import { resolveStateDir } from "../config/paths.js"; import { clearDeviceAuthTokenFromStore, @@ -8,8 +9,14 @@ import { storeDeviceAuthTokenInStore, } from "../shared/device-auth-store.js"; import type { DeviceAuthStore } from "../shared/device-auth.js"; +import { safeParseJsonWithSchema } from "../utils/zod-parse.js"; const DEVICE_AUTH_FILE = "device-auth.json"; +const DeviceAuthStoreSchema = z.object({ + version: z.literal(1), + deviceId: z.string(), + tokens: z.record(z.string(), z.unknown()), +}) as z.ZodType; function resolveDeviceAuthPath(env: NodeJS.ProcessEnv = process.env): string { return path.join(resolveStateDir(env), "identity", DEVICE_AUTH_FILE); @@ -21,14 +28,7 @@ function readStore(filePath: string): DeviceAuthStore | null { return null; } const raw = fs.readFileSync(filePath, "utf8"); - const parsed = JSON.parse(raw) as DeviceAuthStore; - if (parsed?.version !== 1 || typeof parsed.deviceId !== "string") { - return null; - } - if (!parsed.tokens || typeof parsed.tokens !== "object") { - return null; - } - return parsed; + return safeParseJsonWithSchema(DeviceAuthStoreSchema, raw); } catch { return null; } diff --git a/src/infra/gateway-lock.ts b/src/infra/gateway-lock.ts index b216f206916..8b4f270ca0e 100644 --- a/src/infra/gateway-lock.ts +++ b/src/infra/gateway-lock.ts @@ -3,8 +3,10 @@ import fsSync from "node:fs"; import fs from "node:fs/promises"; import net from "node:net"; import path from "node:path"; +import { z } from "zod"; import { resolveConfigPath, resolveGatewayLockDir, resolveStateDir } from "../config/paths.js"; import { isPidAlive } from "../shared/pid-alive.js"; +import { safeParseJsonWithSchema } from "../utils/zod-parse.js"; import { isGatewayArgv, parseProcCmdline } from "./gateway-process-argv.js"; const DEFAULT_TIMEOUT_MS = 5000; @@ -19,6 +21,13 @@ type LockPayload = { startTime?: number; }; +const LockPayloadSchema = z.object({ + pid: z.number(), + createdAt: z.string(), + configPath: z.string(), + startTime: z.number().optional(), +}) as z.ZodType; + export type GatewayLockHandle = { lockPath: string; configPath: string; @@ -142,23 +151,7 @@ async function resolveGatewayOwnerStatus( async function readLockPayload(lockPath: string): Promise { try { const raw = await fs.readFile(lockPath, "utf8"); - const parsed = JSON.parse(raw) as Partial; - if (typeof parsed.pid !== "number") { - return null; - } - if (typeof parsed.createdAt !== "string") { - return null; - } - if (typeof parsed.configPath !== "string") { - return null; - } - const startTime = typeof parsed.startTime === "number" ? parsed.startTime : undefined; - return { - pid: parsed.pid, - createdAt: parsed.createdAt, - configPath: parsed.configPath, - startTime, - }; + return safeParseJsonWithSchema(LockPayloadSchema, raw); } catch { return null; } diff --git a/src/plugin-sdk/extension-shared.ts b/src/plugin-sdk/extension-shared.ts index a0c5a12faa1..af754c0385b 100644 --- a/src/plugin-sdk/extension-shared.ts +++ b/src/plugin-sdk/extension-shared.ts @@ -1,6 +1,7 @@ import type { z } from "zod"; import { runPassiveAccountLifecycle } from "./channel-lifecycle.js"; import { createLoggerBackedRuntime } from "./runtime.js"; +export { safeParseJsonWithSchema, safeParseWithSchema } from "../utils/zod-parse.js"; type PassiveChannelStatusSnapshot = { configured?: boolean; diff --git a/src/shared/tailscale-status.ts b/src/shared/tailscale-status.ts index 2756e6efdf1..af9609cab3a 100644 --- a/src/shared/tailscale-status.ts +++ b/src/shared/tailscale-status.ts @@ -1,3 +1,6 @@ +import { z } from "zod"; +import { safeParseJsonWithSchema } from "../utils/zod-parse.js"; + export type TailscaleStatusCommandResult = { code: number | null; stdout: string; @@ -13,30 +16,31 @@ const TAILSCALE_STATUS_COMMAND_CANDIDATES = [ "/Applications/Tailscale.app/Contents/MacOS/Tailscale", ]; -function parsePossiblyNoisyJsonObject(raw: string): Record { +const TailscaleStatusSchema = z.object({ + Self: z + .object({ + DNSName: z.string().optional(), + TailscaleIPs: z.array(z.string()).optional(), + }) + .optional(), +}); + +function parsePossiblyNoisyStatus(raw: string): z.infer | null { const start = raw.indexOf("{"); const end = raw.lastIndexOf("}"); if (start === -1 || end <= start) { - return {}; - } - try { - return JSON.parse(raw.slice(start, end + 1)) as Record; - } catch { - return {}; + return null; } + return safeParseJsonWithSchema(TailscaleStatusSchema, raw.slice(start, end + 1)); } function extractTailnetHostFromStatusJson(raw: string): string | null { - const parsed = parsePossiblyNoisyJsonObject(raw); - const self = - typeof parsed.Self === "object" && parsed.Self !== null - ? (parsed.Self as Record) - : undefined; - const dns = typeof self?.DNSName === "string" ? self.DNSName : undefined; + const parsed = parsePossiblyNoisyStatus(raw); + const dns = parsed?.Self?.DNSName; if (dns && dns.length > 0) { return dns.replace(/\.$/, ""); } - const ips = Array.isArray(self?.TailscaleIPs) ? (self.TailscaleIPs as string[]) : []; + const ips = parsed?.Self?.TailscaleIPs ?? []; return ips.length > 0 ? (ips[0] ?? null) : null; } diff --git a/src/utils/zod-parse.ts b/src/utils/zod-parse.ts new file mode 100644 index 00000000000..21c1711130a --- /dev/null +++ b/src/utils/zod-parse.ts @@ -0,0 +1,14 @@ +import type { ZodType } from "zod"; + +export function safeParseWithSchema(schema: ZodType, value: unknown): T | null { + const parsed = schema.safeParse(value); + return parsed.success ? parsed.data : null; +} + +export function safeParseJsonWithSchema(schema: ZodType, raw: string): T | null { + try { + return safeParseWithSchema(schema, JSON.parse(raw)); + } catch { + return null; + } +}