diff --git a/docs/cli/node.md b/docs/cli/node.md index 920f006c5dec..22e81e268e7b 100644 --- a/docs/cli/node.md +++ b/docs/cli/node.md @@ -208,7 +208,7 @@ when set): | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | `state/openclaw.sqlite` (`node_host_config`) | Client instance ID, display name, and Gateway connection metadata. The client sends this ID as `instanceId`. | | `state/openclaw.sqlite` (`device_identities`, `primary`) | Signed Ed25519 keypair and derived device ID. For signed connections, this device ID is the routed node ID and pairing identity. | -| `identity/device-auth.json` | Paired device tokens, keyed by cryptographic device ID and role. | +| `state/openclaw.sqlite` (`device_auth_tokens`) | Paired device tokens, keyed by cryptographic device ID and role. | `--node-id` changes only the client instance ID in shared SQLite state. It does not change the cryptographic device ID or clear pairing auth. Migrating a retired @@ -234,15 +234,14 @@ The two request IDs are distinct. An applicable trusted-CIDR policy can auto-approve the first-time device-pairing step; command-surface approval remains a separate check. -Older OpenClaw releases stored node-host state in `node.json` and the signed -identity in `identity/device.json`. Stop the node host and run +Older OpenClaw releases stored node-host state in `node.json`, the signed +identity in `identity/device.json`, and paired auth in +`identity/device-auth.json`. Stop the node host and run `openclaw doctor --fix` once; Doctor claims each retired source, validates it, imports and verifies the canonical SQLite row, then removes the old file. Normal node commands fail closed with this repair instruction while either retired file -or an interrupted Doctor claim remains. Keep `state/openclaw.sqlite` and -`identity/device-auth.json` private; they contain the device keypair and auth -tokens. Device auth remains a separate store and is not rewritten by the -identity migration. +or an interrupted Doctor claim remains. Keep `state/openclaw.sqlite` private; +it contains the device keypair and auth tokens. ## Exec approvals diff --git a/docs/nodes/index.md b/docs/nodes/index.md index e6b5263b9212..9118b5741044 100644 --- a/docs/nodes/index.md +++ b/docs/nodes/index.md @@ -232,11 +232,11 @@ operators can ignore skills from every paired node with ### Headless identity state -The headless node keeps three separate state records: +The headless node keeps three separate state records in shared SQLite: - `~/.openclaw/state/openclaw.sqlite` (`node_host_config`): the client instance ID, display name, and Gateway connection metadata. - `~/.openclaw/state/openclaw.sqlite` (`device_identities`, key `primary`): the signed device keypair and derived cryptographic device ID. -- `~/.openclaw/identity/device-auth.json`: paired device auth tokens keyed by cryptographic device ID and role. +- `~/.openclaw/state/openclaw.sqlite` (`device_auth_tokens`): paired device auth tokens keyed by cryptographic device ID and role. For a signed node, the Gateway uses the cryptographic device ID for pairing and node routing. The client instance ID is only connection metadata. Changing @@ -244,10 +244,10 @@ node routing. The client instance ID is only connection metadata. Changing [Identity and pairing state](/cli/node#identity-and-pairing-state) for the supported revoke-and-re-pair flow and upgrade notes. -A retired `identity/device.json` file or interrupted Doctor claim blocks normal -identity use. Stop the node host and run `openclaw doctor --fix`; Doctor imports -the validated keypair into SQLite before removing the old file. The identity -migration leaves `identity/device-auth.json` untouched. +Retired `identity/device.json` and `identity/device-auth.json` files are +Doctor-owned migration inputs. Stop the node host and run +`openclaw doctor --fix`; Doctor imports and verifies their rows in SQLite before +removing the old files. ### Allowlist the commands diff --git a/docs/refactor/database-first.md b/docs/refactor/database-first.md index 5bf0991e2121..0c6babd1ed86 100644 --- a/docs/refactor/database-first.md +++ b/docs/refactor/database-first.md @@ -2238,7 +2238,7 @@ Add a repo check that fails new runtime writes to legacy state paths: gateway startup, transient pending/bootstrap rows are dropped) - `nodes/pending.json` / `nodes/paired.json` (retired 2026.7: folded into paired device records at gateway startup) - `identity/device.json` -- `identity/device-auth.json` +- `identity/device-auth.json` (retired; Doctor-only import into `device_auth_tokens`) - `push/web-push-subscriptions.json` (retired; Doctor-only import into `web_push_subscriptions`) - `push/vapid-keys.json` (retired; Doctor-only import into `web_push_vapid_keys`) - `push/apns-registrations.json` (retired; Doctor-only import into `apns_registrations`) diff --git a/src/commands/doctor-device-pairing.test.ts b/src/commands/doctor-device-pairing.test.ts index bbc34df2be3c..75071b46d646 100644 --- a/src/commands/doctor-device-pairing.test.ts +++ b/src/commands/doctor-device-pairing.test.ts @@ -1,7 +1,6 @@ // Doctor device pairing tests cover device-pairing checks, repair prompts, and diagnostics. import fs from "node:fs/promises"; import path from "node:path"; -import { expectDefined } from "@openclaw/normalization-core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { storeDeviceAuthToken } from "../infra/device-auth-store.js"; import { @@ -199,24 +198,15 @@ describe("noteDevicePairingHealth", () => { }); it("warns when the local cached device token predates the gateway rotation", async () => { - await withApprovedOperatorPairing(async ({ stateDir, identity }) => { + await withApprovedOperatorPairing(async ({ identity }) => { + const now = vi.spyOn(Date, "now").mockReturnValue(1); storeDeviceAuthToken({ deviceId: identity.deviceId, role: "operator", token: "stale-local-token", scopes: ["operator.read"], }); - const deviceAuthPath = path.join(stateDir, "identity", "device-auth.json"); - const store = JSON.parse(await fs.readFile(deviceAuthPath, "utf8")) as { - version: 1; - deviceId: string; - tokens: Record< - string, - { token: string; role: string; scopes: string[]; updatedAtMs: number } - >; - }; - expectDefined(store.tokens.operator, "store.tokens.operator test invariant").updatedAtMs = 1; - await fs.writeFile(deviceAuthPath, `${JSON.stringify(store, null, 2)}\n`, "utf8"); + now.mockRestore(); const rotated = await rotateDeviceToken({ deviceId: identity.deviceId, diff --git a/src/commands/doctor-device-pairing.ts b/src/commands/doctor-device-pairing.ts index 6630c6aabdea..94c7b07c3f94 100644 --- a/src/commands/doctor-device-pairing.ts +++ b/src/commands/doctor-device-pairing.ts @@ -1,14 +1,13 @@ /** Doctor diagnostics for pending, paired, and locally cached device auth state. */ -import path from "node:path"; import { normalizeUniqueSingleOrTrimmedStringList } from "@openclaw/normalization-core/string-normalization"; import { note } from "../../packages/terminal-core/src/note.js"; import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js"; import { formatCliCommand } from "../cli/command-format.js"; import { quoteCliArg } from "../cli/quote-cli-arg.js"; -import { resolveStateDir } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { HealthFinding } from "../flows/health-checks.js"; import { callGateway } from "../gateway/call.js"; +import { loadDeviceAuthTokens } from "../infra/device-auth-store.js"; import { loadDeviceIdentityIfPresent } from "../infra/device-identity.js"; import { listApprovedPairedDeviceRoles, @@ -18,8 +17,6 @@ import { type DevicePairingPendingRequest, type PairedDevice, } from "../infra/device-pairing.js"; -import { tryReadJsonSync } from "../infra/json-files.js"; -import type { DeviceAuthStore } from "../shared/device-auth.js"; import { normalizeDeviceAuthScopes } from "../shared/device-auth.js"; import { roleScopesAllow } from "../shared/operator-scope-compat.js"; @@ -106,26 +103,6 @@ type LocalDeviceAuthIssue = { fixHint: string; }; -function hasNumberVersion(value: object): value is { version: number } { - return "version" in value && typeof value.version === "number"; -} - -function isDeviceAuthStoreTokenEntry(value: unknown): value is DeviceAuthStore["tokens"][string] { - return ( - typeof value === "object" && - value !== null && - "token" in value && - typeof value.token === "string" && - "role" in value && - typeof value.role === "string" && - "scopes" in value && - Array.isArray(value.scopes) && - value.scopes.every((scope) => typeof scope === "string") && - "updatedAtMs" in value && - typeof value.updatedAtMs === "number" - ); -} - function normalizeGatewayPairedDevice(device: GatewayListedPairedDevice): DoctorPairedDevice { return { ...device, @@ -399,10 +376,6 @@ function formatPairedRecordIssue(issue: PairedRecordIssue): string { return `- ${issue.message}`; } -function readJsonFile(filePath: string): unknown { - return tryReadJsonSync(filePath); -} - function readLocalIdentity(env: NodeJS.ProcessEnv = process.env): { deviceId: string } | null { try { return loadDeviceIdentityIfPresent({ env }); @@ -411,43 +384,20 @@ function readLocalIdentity(env: NodeJS.ProcessEnv = process.env): { deviceId: st } } -function readLocalDeviceAuthStore(env: NodeJS.ProcessEnv = process.env): DeviceAuthStore | null { - const filePath = path.join(resolveStateDir(env), "identity", "device-auth.json"); - const store = readJsonFile(filePath); - if ( - !store || - typeof store !== "object" || - !hasNumberVersion(store) || - store.version !== 1 || - !("deviceId" in store) || - typeof store.deviceId !== "string" || - !store.deviceId.trim() || - !("tokens" in store) || - typeof store.tokens !== "object" || - store.tokens === null - ) { - return null; +function readLocalDeviceAuthTokens(deviceId: string, env: NodeJS.ProcessEnv = process.env) { + try { + return loadDeviceAuthTokens({ deviceId, env }); + } catch { + return []; } - const tokens: DeviceAuthStore["tokens"] = {}; - for (const [role, entry] of Object.entries(store.tokens)) { - if (!isDeviceAuthStoreTokenEntry(entry)) { - return null; - } - tokens[role] = entry; - } - return { - version: 1, - deviceId: store.deviceId, - tokens, - }; } function collectLocalDeviceAuthIssues(snapshot: DoctorPairingSnapshot): LocalDeviceAuthIssue[] { const identity = readLocalIdentity(); - const store = readLocalDeviceAuthStore(); - if (!identity || !store || store.deviceId !== identity.deviceId) { + if (!identity) { return []; } + const localTokens = readLocalDeviceAuthTokens(identity.deviceId); const paired = snapshot.paired.find((device) => device.deviceId === identity.deviceId); if (!paired) { return []; @@ -459,7 +409,7 @@ function collectLocalDeviceAuthIssues(snapshot: DoctorPairingSnapshot): LocalDev }); const issues: LocalDeviceAuthIssue[] = []; const approvedRoles = new Set(listApprovedPairedDeviceRoles(paired)); - for (const entry of Object.values(store.tokens)) { + for (const entry of localTokens) { const role = entry.role.trim(); if (!role) { continue; diff --git a/src/commands/doctor.e2e-harness.ts b/src/commands/doctor.e2e-harness.ts index aeb7c7a4edb2..3844d7dd592b 100644 --- a/src/commands/doctor.e2e-harness.ts +++ b/src/commands/doctor.e2e-harness.ts @@ -208,6 +208,11 @@ function createLegacyStateMigrationDetectionResult(params?: { targetScope: undefined, stateDir: "/tmp/state", oauthDir: "/tmp/oauth", + deviceAuth: { + sourcePath: "/tmp/state/identity/device-auth.json", + sourcePresent: false, + hasLegacy: false, + }, deviceIdentity: { sourcePath: "/tmp/state/identity/device.json", claimPath: "/tmp/state/identity/device.json.doctor-importing", diff --git a/src/infra/device-auth-store.test.ts b/src/infra/device-auth-store.test.ts index 9ccda7ecb61f..08d2ebadede4 100644 --- a/src/infra/device-auth-store.test.ts +++ b/src/infra/device-auth-store.test.ts @@ -1,13 +1,19 @@ -// Covers persistent device auth token storage and clearing. -import fs from "node:fs/promises"; +// Covers SQLite-backed device auth token storage and clearing. +import fs from "node:fs"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + closeOpenClawStateDatabaseForTest, + openOpenClawStateDatabase, +} from "../state/openclaw-state-db.js"; import { withTempDir } from "../test-utils/temp-dir.js"; import { clearDeviceAuthToken, loadDeviceAuthToken, + loadDeviceAuthTokens, storeDeviceAuthToken, } from "./device-auth-store.js"; +import { executeSqliteQuerySync, getNodeSqliteKysely } from "./kysely-sync.js"; function createEnv(stateDir: string): NodeJS.ProcessEnv { return { @@ -16,21 +22,23 @@ function createEnv(stateDir: string): NodeJS.ProcessEnv { }; } -function deviceAuthFile(stateDir: string): string { - return path.join(stateDir, "identity", "device-auth.json"); -} +afterEach(() => { + closeOpenClawStateDatabaseForTest(); + vi.restoreAllMocks(); +}); describe("infra/device-auth-store", () => { - it("stores and loads device auth tokens under the configured state dir", async () => { + it("stores and loads normalized device auth tokens in SQLite", async () => { await withTempDir("openclaw-device-auth-", async (stateDir) => { vi.spyOn(Date, "now").mockReturnValue(1234); + const env = createEnv(stateDir); const entry = storeDeviceAuthToken({ deviceId: "device-1", role: " operator ", token: "secret", scopes: [" operator.write ", "operator.read", "operator.read"], - env: createEnv(stateDir), + env, }); expect(entry).toEqual({ @@ -39,125 +47,106 @@ describe("infra/device-auth-store", () => { scopes: ["operator.read", "operator.write"], updatedAtMs: 1234, }); - expect( - loadDeviceAuthToken({ - deviceId: "device-1", - role: "operator", - env: createEnv(stateDir), - }), - ).toEqual(entry); - - const raw = await fs.readFile(deviceAuthFile(stateDir), "utf8"); - expect(raw.endsWith("\n")).toBe(true); - expect(JSON.parse(raw)).toEqual({ - version: 1, - deviceId: "device-1", - tokens: { - operator: entry, - }, - }); + expect(loadDeviceAuthToken({ deviceId: "device-1", role: "operator", env })).toEqual(entry); + expect(loadDeviceAuthTokens({ deviceId: "device-1", env })).toEqual([entry]); + expect(fs.existsSync(path.join(stateDir, "identity", "device-auth.json"))).toBe(false); }); }); - it("returns null for missing, invalid, or mismatched stores", async () => { + it("isolates device ids and overwrites only the normalized role", async () => { await withTempDir("openclaw-device-auth-", async (stateDir) => { const env = createEnv(stateDir); + vi.spyOn(Date, "now").mockReturnValueOnce(1).mockReturnValueOnce(2).mockReturnValueOnce(3); - expect(loadDeviceAuthToken({ deviceId: "device-1", role: "operator", env })).toBeNull(); - - await fs.mkdir(path.dirname(deviceAuthFile(stateDir)), { recursive: true }); - await fs.writeFile(deviceAuthFile(stateDir), '{"version":2,"deviceId":"device-1"}\n', "utf8"); - expect(loadDeviceAuthToken({ deviceId: "device-1", role: "operator", env })).toBeNull(); - - await fs.writeFile( - deviceAuthFile(stateDir), - '{"version":1,"deviceId":"device-2","tokens":{"operator":{"token":"x","role":"operator","scopes":[],"updatedAtMs":1}}}\n', - "utf8", - ); - expect(loadDeviceAuthToken({ deviceId: "device-1", role: "operator", env })).toBeNull(); - }); - }); - - it("normalizes raw persisted token metadata while reading from disk", async () => { - await withTempDir("openclaw-device-auth-", async (stateDir) => { - const env = createEnv(stateDir); - await fs.mkdir(path.dirname(deviceAuthFile(stateDir)), { recursive: true }); - await fs.writeFile( - deviceAuthFile(stateDir), - JSON.stringify({ - version: 1, - deviceId: "device-1", - tokens: { - " operator ": { - token: "operator-token", - role: { nested: "bad" }, - scopes: ["operator.write", "operator.read", 42], - updatedAtMs: "bad-time", - }, - }, - }) + "\n", - "utf8", - ); - - expect(loadDeviceAuthToken({ deviceId: "device-1", role: "operator", env })).toEqual({ - token: "operator-token", - role: "operator", - scopes: ["operator.read", "operator.write"], - updatedAtMs: 0, - }); - }); - }); - - it("loads valid roles when another persisted token entry is malformed", async () => { - await withTempDir("openclaw-device-auth-", async (stateDir) => { - const env = createEnv(stateDir); - await fs.mkdir(path.dirname(deviceAuthFile(stateDir)), { recursive: true }); - await fs.writeFile( - deviceAuthFile(stateDir), - JSON.stringify({ - version: 1, - deviceId: "device-1", - tokens: { - operator: { token: "operator-token", role: "operator", scopes: [], updatedAtMs: 1 }, - broken: { role: "broken", scopes: [], updatedAtMs: 1 }, - }, - }), - "utf8", - ); - - expect(loadDeviceAuthToken({ deviceId: "device-1", role: "operator", env })?.token).toBe( - "operator-token", - ); - expect(loadDeviceAuthToken({ deviceId: "device-1", role: "broken", env })).toBeNull(); - }); - }); - - it("clears only the requested role and leaves unrelated tokens intact", async () => { - await withTempDir("openclaw-device-auth-", async (stateDir) => { - const env = createEnv(stateDir); - - storeDeviceAuthToken({ - deviceId: "device-1", - role: "operator", - token: "operator-token", - env, - }); - storeDeviceAuthToken({ - deviceId: "device-1", - role: "node", - token: "node-token", - env, - }); - - clearDeviceAuthToken({ + storeDeviceAuthToken({ deviceId: "device-1", role: "node", token: "node", env }); + storeDeviceAuthToken({ deviceId: "device-2", role: "operator", token: "other", env }); + const replacement = storeDeviceAuthToken({ deviceId: "device-1", role: " operator ", + token: "replacement", + scopes: ["operator.admin"], env, }); + expect(loadDeviceAuthTokens({ deviceId: "device-1", env })).toEqual([ + { token: "node", role: "node", scopes: [], updatedAtMs: 1 }, + replacement, + ]); + expect(loadDeviceAuthToken({ deviceId: "device-2", role: "operator", env })?.token).toBe( + "other", + ); + }); + }); + + it("fails closed for malformed canonical scope metadata", async () => { + await withTempDir("openclaw-device-auth-", async (stateDir) => { + const env = createEnv(stateDir); + const { db } = openOpenClawStateDatabase({ env }); + executeSqliteQuerySync( + db, + getNodeSqliteKysely<{ + device_auth_tokens: { + device_id: string; + role: string; + token: string; + scopes_json: string; + updated_at_ms: number; + }; + }>(db) + .insertInto("device_auth_tokens") + .values({ + device_id: "device-1", + role: "operator", + token: "secret", + scopes_json: "not-json", + updated_at_ms: 1, + }), + ); + expect(loadDeviceAuthToken({ deviceId: "device-1", role: "operator", env })).toBeNull(); - expect(loadDeviceAuthToken({ deviceId: "device-1", role: "node", env })?.token).toBe( - "node-token", + expect(loadDeviceAuthTokens({ deviceId: "device-1", env })).toEqual([]); + }); + }); + + it("fails closed with repair guidance while retired JSON remains", async () => { + await withTempDir("openclaw-device-auth-", async (stateDir) => { + const env = createEnv(stateDir); + const legacyPath = path.join(stateDir, "identity", "device-auth.json"); + fs.mkdirSync(path.dirname(legacyPath), { recursive: true }); + fs.writeFileSync(legacyPath, '{"version":1}'); + openOpenClawStateDatabase({ env }) + .db.prepare( + "INSERT INTO device_auth_tokens (device_id, role, token, scopes_json, updated_at_ms) VALUES (?, ?, ?, ?, ?)", + ) + .run("device-1", "operator", "sqlite-token", "[]", 1); + + expect(() => loadDeviceAuthToken({ deviceId: "device-1", role: "operator", env })).toThrow( + "openclaw doctor --fix", + ); + expect(() => + storeDeviceAuthToken({ + deviceId: "device-1", + role: "operator", + token: "replacement", + env, + }), + ).toThrow("openclaw doctor --fix"); + }); + }); + + it("clears only the requested role and device", async () => { + await withTempDir("openclaw-device-auth-", async (stateDir) => { + const env = createEnv(stateDir); + storeDeviceAuthToken({ deviceId: "device-1", role: "operator", token: "operator", env }); + storeDeviceAuthToken({ deviceId: "device-1", role: "node", token: "node", env }); + storeDeviceAuthToken({ deviceId: "device-2", role: "operator", token: "other", env }); + + clearDeviceAuthToken({ deviceId: "device-1", role: " operator ", env }); + + expect(loadDeviceAuthToken({ deviceId: "device-1", role: "operator", env })).toBeNull(); + expect(loadDeviceAuthToken({ deviceId: "device-1", role: "node", env })?.token).toBe("node"); + expect(loadDeviceAuthToken({ deviceId: "device-2", role: "operator", env })?.token).toBe( + "other", ); }); }); diff --git a/src/infra/device-auth-store.ts b/src/infra/device-auth-store.ts index 53796b6f75e0..35dcca5bb75f 100644 --- a/src/infra/device-auth-store.ts +++ b/src/infra/device-auth-store.ts @@ -3,87 +3,108 @@ import fs from "node:fs"; import path from "node:path"; import { resolveStateDir } from "../config/paths.js"; import { - clearDeviceAuthTokenFromStore, - coerceDeviceAuthStore, type DeviceAuthEntry, - type DeviceAuthStore, - loadDeviceAuthTokenFromStore, - storeDeviceAuthTokenInStore, -} from "../shared/device-auth-store.js"; -import { privateFileStoreSync } from "./private-file-store.js"; + normalizeDeviceAuthRole, + normalizeDeviceAuthScopes, +} from "../shared/device-auth.js"; +import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js"; +import { + openOpenClawStateDatabase, + runOpenClawStateWriteTransaction, +} from "../state/openclaw-state-db.js"; +import { + executeSqliteQuerySync, + executeSqliteQueryTakeFirstSync, + getNodeSqliteKysely, +} from "./kysely-sync.js"; -const DEVICE_AUTH_FILE = "device-auth.json"; +type DeviceAuthDatabase = Pick; +// The Gateway lock makes state-directory contents process-stable. Cache both +// outcomes to keep reconnects free of freshness polling; Doctor invalidates +// the entry after its exclusive legacy import removes the retired file. +const legacyPresenceCache = new Map(); -type StoreCacheEntry = { store: DeviceAuthStore | null; mtimeMs: number; size: number }; -const storeReadCache = new Map(); - -function storeCacheHit( - cached: StoreCacheEntry | undefined, - stat: { mtimeMs: number; size: number }, -): boolean { - return cached !== undefined && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size; +function assertNoLegacyDeviceAuth(env: NodeJS.ProcessEnv | undefined): void { + const stateDir = resolveStateDir(env); + let hasLegacy = legacyPresenceCache.get(stateDir); + if (hasLegacy === undefined) { + hasLegacy = fs.existsSync(path.join(stateDir, "identity", "device-auth.json")); + legacyPresenceCache.set(stateDir, hasLegacy); + } + if (hasLegacy) { + throw new Error( + "Legacy device auth requires migration; stop the Gateway and run `openclaw doctor --fix`.", + ); + } } -function resolveDeviceAuthPath(env: NodeJS.ProcessEnv = process.env): string { - return path.join(resolveStateDir(env), "identity", DEVICE_AUTH_FILE); +/** Forget one process-local legacy-state probe after Doctor removes the source. */ +export function resetLegacyDeviceAuthPresenceCache(env: NodeJS.ProcessEnv): void { + legacyPresenceCache.delete(resolveStateDir(env)); } -function readStore(filePath: string): DeviceAuthStore | null { +function fromRow(row: { + token: string; + role: string; + scopes_json: string; + updated_at_ms: number; +}): DeviceAuthEntry | null { try { - let stat: fs.Stats | null = null; - try { - stat = fs.statSync(filePath); - } catch { - const cached = storeReadCache.get(filePath); - if (cached?.mtimeMs === -1 && cached.size === -1) { - return cached.store; - } - storeReadCache.set(filePath, { store: null, mtimeMs: -1, size: -1 }); + const scopes = JSON.parse(row.scopes_json) as unknown; + if (!Array.isArray(scopes)) { return null; } - const cached = storeReadCache.get(filePath); - if (cached !== undefined && storeCacheHit(cached, stat)) { - // Device auth is read during gateway reconnects; cache by file metadata to avoid rereads. - return cached.store; - } - const parsed = privateFileStoreSync(path.dirname(filePath)).readJsonIfExists( - path.basename(filePath), - ); - const store = coerceDeviceAuthStore(parsed); - storeReadCache.set(filePath, { store, mtimeMs: stat.mtimeMs, size: stat.size }); - return store; + return { + token: row.token, + role: row.role, + scopes: normalizeDeviceAuthScopes(scopes), + updatedAtMs: row.updated_at_ms, + }; } catch { return null; } } -function writeStore(filePath: string, store: DeviceAuthStore): void { - privateFileStoreSync(path.dirname(filePath)).writeJson(path.basename(filePath), store, { - trailingNewline: true, - }); - try { - const stat = fs.statSync(filePath); - storeReadCache.set(filePath, { store, mtimeMs: stat.mtimeMs, size: stat.size }); - } catch { - storeReadCache.delete(filePath); - } -} - -/** Load a cached device-auth token from the configured OpenClaw state directory. */ +/** Load one cached device-auth token from the shared SQLite state store. */ export function loadDeviceAuthToken(params: { deviceId: string; role: string; env?: NodeJS.ProcessEnv; }): DeviceAuthEntry | null { - const filePath = resolveDeviceAuthPath(params.env); - return loadDeviceAuthTokenFromStore({ - adapter: { readStore: () => readStore(filePath), writeStore: (_store) => {} }, - deviceId: params.deviceId, - role: params.role, + assertNoLegacyDeviceAuth(params.env); + const { db } = openOpenClawStateDatabase({ env: params.env }); + const row = executeSqliteQueryTakeFirstSync( + db, + getNodeSqliteKysely(db) + .selectFrom("device_auth_tokens") + .select(["token", "role", "scopes_json", "updated_at_ms"]) + .where("device_id", "=", params.deviceId) + .where("role", "=", normalizeDeviceAuthRole(params.role)), + ); + return row ? fromRow(row) : null; +} + +/** List cached role tokens for one device from the shared SQLite state store. */ +export function loadDeviceAuthTokens(params: { + deviceId: string; + env?: NodeJS.ProcessEnv; +}): DeviceAuthEntry[] { + assertNoLegacyDeviceAuth(params.env); + const { db } = openOpenClawStateDatabase({ env: params.env }); + return executeSqliteQuerySync( + db, + getNodeSqliteKysely(db) + .selectFrom("device_auth_tokens") + .select(["token", "role", "scopes_json", "updated_at_ms"]) + .where("device_id", "=", params.deviceId) + .orderBy("role"), + ).rows.flatMap((row) => { + const entry = fromRow(row); + return entry ? [entry] : []; }); } -/** Persist or replace one device-auth role token in the private state directory. */ +/** Persist or replace one device-auth role token in the shared SQLite state store. */ export function storeDeviceAuthToken(params: { deviceId: string; role: string; @@ -91,32 +112,57 @@ export function storeDeviceAuthToken(params: { scopes?: string[]; env?: NodeJS.ProcessEnv; }): DeviceAuthEntry { - const filePath = resolveDeviceAuthPath(params.env); - return storeDeviceAuthTokenInStore({ - adapter: { - readStore: () => readStore(filePath), - writeStore: (store) => writeStore(filePath, store), - }, - deviceId: params.deviceId, - role: params.role, + assertNoLegacyDeviceAuth(params.env); + const entry: DeviceAuthEntry = { token: params.token, - scopes: params.scopes, - }); + role: normalizeDeviceAuthRole(params.role), + scopes: normalizeDeviceAuthScopes(params.scopes), + updatedAtMs: Date.now(), + }; + runOpenClawStateWriteTransaction( + ({ db }) => { + executeSqliteQuerySync( + db, + getNodeSqliteKysely(db) + .insertInto("device_auth_tokens") + .values({ + device_id: params.deviceId, + role: entry.role, + token: entry.token, + scopes_json: JSON.stringify(entry.scopes), + updated_at_ms: entry.updatedAtMs, + }) + .onConflict((conflict) => + conflict.columns(["device_id", "role"]).doUpdateSet({ + token: entry.token, + scopes_json: JSON.stringify(entry.scopes), + updated_at_ms: entry.updatedAtMs, + }), + ), + ); + }, + { env: params.env }, + ); + return entry; } -/** Remove one role token for the current gateway device from the private state directory. */ +/** Remove one role token for the current gateway device from shared SQLite state. */ export function clearDeviceAuthToken(params: { deviceId: string; role: string; env?: NodeJS.ProcessEnv; }): void { - const filePath = resolveDeviceAuthPath(params.env); - clearDeviceAuthTokenFromStore({ - adapter: { - readStore: () => readStore(filePath), - writeStore: (store) => writeStore(filePath, store), + assertNoLegacyDeviceAuth(params.env); + runOpenClawStateWriteTransaction( + ({ db }) => { + executeSqliteQuerySync( + db, + getNodeSqliteKysely(db) + .deleteFrom("device_auth_tokens") + .where("device_id", "=", params.deviceId) + .where("role", "=", normalizeDeviceAuthRole(params.role)), + ); }, - deviceId: params.deviceId, - role: params.role, - }); + { env: params.env }, + ); } diff --git a/src/infra/state-migrations.device-auth.test.ts b/src/infra/state-migrations.device-auth.test.ts new file mode 100644 index 000000000000..48f3d04b0ec4 --- /dev/null +++ b/src/infra/state-migrations.device-auth.test.ts @@ -0,0 +1,128 @@ +// Covers Doctor-only import of the retired device-auth JSON store. +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; +import { loadDeviceAuthToken, storeDeviceAuthToken } from "./device-auth-store.js"; +import { detectLegacyDeviceAuth, migrateLegacyDeviceAuth } from "./state-migrations.device-auth.js"; + +describe("legacy device-auth Doctor migration", () => { + const tempDirs = useAutoCleanupTempDirTracker((cleanup) => { + afterEach(() => { + closeOpenClawStateDatabaseForTest(); + cleanup(); + }); + }); + + function useStateDir() { + const stateDir = tempDirs.make("openclaw-device-auth-migration-"); + const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; + const sourcePath = path.join(stateDir, "identity", "device-auth.json"); + return { stateDir, env, sourcePath }; + } + + async function writeLegacy( + sourcePath: string, + overrides: Record = {}, + ): Promise { + await fsp.mkdir(path.dirname(sourcePath), { recursive: true }); + await fsp.writeFile( + sourcePath, + JSON.stringify({ + version: 1, + deviceId: "device-1", + tokens: { + operator: { + token: "legacy-token", + role: "operator", + scopes: ["operator.write"], + updatedAtMs: 10, + }, + }, + ...overrides, + }), + ); + } + + async function migrate(stateDir: string, env: NodeJS.ProcessEnv) { + return migrateLegacyDeviceAuth({ + detected: detectLegacyDeviceAuth({ stateDir, doctorOnlyStateMigrations: true }), + stateDir, + env, + }); + } + + it("detects only with Doctor authority and imports verified rows before deleting JSON", async () => { + const { stateDir, env, sourcePath } = useStateDir(); + await writeLegacy(sourcePath); + + expect(detectLegacyDeviceAuth({ stateDir })).toMatchObject({ + sourcePresent: true, + hasLegacy: false, + }); + expect(detectLegacyDeviceAuth({ stateDir, doctorOnlyStateMigrations: true }).hasLegacy).toBe( + true, + ); + + const result = await migrate(stateDir, env); + + expect(result.warnings).toEqual([]); + expect(result.changes).toEqual(["Migrated 1 device-auth token to SQLite."]); + expect(loadDeviceAuthToken({ deviceId: "device-1", role: "operator", env })).toEqual({ + token: "legacy-token", + role: "operator", + scopes: ["operator.read", "operator.write"], + updatedAtMs: 10, + }); + expect(fs.existsSync(sourcePath)).toBe(false); + }); + + it("preserves canonical SQLite rows instead of replaying stale JSON", async () => { + const { stateDir, env, sourcePath } = useStateDir(); + storeDeviceAuthToken({ + deviceId: "device-1", + role: "operator", + token: "canonical-token", + env, + }); + await writeLegacy(sourcePath); + + const result = await migrate(stateDir, env); + + expect(result.warnings).toEqual([]); + expect(result.notices).toContain("Preserved 1 canonical SQLite device-auth token."); + expect(loadDeviceAuthToken({ deviceId: "device-1", role: "operator", env })?.token).toBe( + "canonical-token", + ); + expect(fs.existsSync(sourcePath)).toBe(false); + }); + + it("keeps the last legacy token when role aliases normalize to the same key", async () => { + const { stateDir, env, sourcePath } = useStateDir(); + await writeLegacy(sourcePath, { + tokens: { + " operator ": { token: "stale", scopes: [], updatedAtMs: 1 }, + operator: { token: "current", scopes: ["operator.read"], updatedAtMs: 2 }, + }, + }); + + await migrate(stateDir, env); + + expect(loadDeviceAuthToken({ deviceId: "device-1", role: "operator", env })?.token).toBe( + "current", + ); + }); + + it("keeps invalid legacy state for operator repair", async () => { + const invalid = useStateDir(); + await fsp.mkdir(path.dirname(invalid.sourcePath), { recursive: true }); + await fsp.writeFile(invalid.sourcePath, '{"version":2}'); + + const invalidResult = await migrate(invalid.stateDir, invalid.env); + + expect(invalidResult.warnings.join("\n")).toContain("invalid or unsupported"); + expect(fs.existsSync(invalid.sourcePath)).toBe(true); + }); +}); diff --git a/src/infra/state-migrations.device-auth.ts b/src/infra/state-migrations.device-auth.ts new file mode 100644 index 000000000000..598b53a4da60 --- /dev/null +++ b/src/infra/state-migrations.device-auth.ts @@ -0,0 +1,219 @@ +// Doctor-only import for the retired device-auth JSON store. +import fs from "node:fs"; +import path from "node:path"; +import { root } from "@openclaw/fs-safe"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { normalizeDeviceAuthRole, normalizeDeviceAuthScopes } from "../shared/device-auth.js"; +import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js"; +import { runOpenClawStateWriteTransaction } from "../state/openclaw-state-db.js"; +import { resetLegacyDeviceAuthPresenceCache } from "./device-auth-store.js"; +import { formatErrorMessage } from "./errors.js"; +import { acquireGatewayLock, GatewayLockError } from "./gateway-lock.js"; +import { + executeSqliteQuerySync, + executeSqliteQueryTakeFirstSync, + getNodeSqliteKysely, +} from "./kysely-sync.js"; +import type { MigrationMessages } from "./state-migrations.types.js"; + +const LEGACY_PATH = "identity/device-auth.json"; +type DeviceAuthMigrationDatabase = Pick; +type LegacyDeviceAuthDetection = { + sourcePath: string; + sourcePresent: boolean; + hasLegacy: boolean; +}; + +/** Detect the retired device-auth store only when an explicit Doctor flow opts in. */ +export function detectLegacyDeviceAuth(params: { + stateDir: string; + doctorOnlyStateMigrations?: boolean; +}): LegacyDeviceAuthDetection { + const sourcePath = path.join(params.stateDir, LEGACY_PATH); + const sourcePresent = fs.existsSync(sourcePath); + return { + sourcePath, + sourcePresent, + hasLegacy: params.doctorOnlyStateMigrations === true && sourcePresent, + }; +} + +function parseStore(value: unknown) { + if ( + !isRecord(value) || + value.version !== 1 || + typeof value.deviceId !== "string" || + !value.deviceId.trim() || + !isRecord(value.tokens) + ) { + throw new Error("legacy device-auth store is invalid or unsupported"); + } + const entries = Object.entries(value.tokens).flatMap(([rawRole, tokenValue]) => { + const role = normalizeDeviceAuthRole(rawRole); + if (!role || !isRecord(tokenValue) || typeof tokenValue.token !== "string") { + return []; + } + return [ + { + token: tokenValue.token, + role, + scopes: normalizeDeviceAuthScopes( + Array.isArray(tokenValue.scopes) ? tokenValue.scopes : undefined, + ), + updatedAtMs: + typeof tokenValue.updatedAtMs === "number" && Number.isSafeInteger(tokenValue.updatedAtMs) + ? tokenValue.updatedAtMs + : 0, + }, + ]; + }); + // JSON object order is the legacy contract; later aliases of the same + // normalized role replace earlier entries before SQLite conflict handling. + return { + deviceId: value.deviceId, + entries: [...new Map(entries.map((entry) => [entry.role, entry])).values()], + }; +} + +function rowIsCanonical(row: { scopes_json: string; updated_at_ms: number }): boolean { + try { + return Array.isArray(JSON.parse(row.scopes_json)) && Number.isSafeInteger(row.updated_at_ms); + } catch { + return false; + } +} + +async function importLegacyStore(params: { + stateDir: string; + env: NodeJS.ProcessEnv; +}): Promise { + const stateRoot = await root(params.stateDir, { + hardlinks: "reject", + maxBytes: 256 * 1024, + symlinks: "reject", + }); + const source = await stateRoot.read(LEGACY_PATH, { + hardlinks: "reject", + maxBytes: 256 * 1024, + symlinks: "reject", + }); + const store = parseStore(JSON.parse(source.buffer.toString("utf8"))); + const counts = runOpenClawStateWriteTransaction( + ({ db }) => { + const stateDb = getNodeSqliteKysely(db); + let imported = 0; + let preserved = 0; + for (const entry of store.entries) { + const query = stateDb + .selectFrom("device_auth_tokens") + .select(["scopes_json", "updated_at_ms"]) + .where("device_id", "=", store.deviceId) + .where("role", "=", entry.role); + const existing = executeSqliteQueryTakeFirstSync(db, query); + if (existing && rowIsCanonical(existing)) { + preserved += 1; + continue; + } + executeSqliteQuerySync( + db, + stateDb + .insertInto("device_auth_tokens") + .values({ + device_id: store.deviceId, + role: entry.role, + token: entry.token, + scopes_json: JSON.stringify(entry.scopes), + updated_at_ms: entry.updatedAtMs, + }) + .onConflict((conflict) => + conflict.columns(["device_id", "role"]).doUpdateSet({ + token: entry.token, + scopes_json: JSON.stringify(entry.scopes), + updated_at_ms: entry.updatedAtMs, + }), + ), + ); + if (!executeSqliteQueryTakeFirstSync(db, query)) { + throw new Error("SQLite verification failed for a device-auth token"); + } + imported += 1; + } + return { imported, preserved }; + }, + { env: params.env }, + ); + await stateRoot.remove(LEGACY_PATH); + resetLegacyDeviceAuthPresenceCache(params.env); + return { + changes: [ + `Migrated ${counts.imported} device-auth token${counts.imported === 1 ? "" : "s"} to SQLite.`, + ], + warnings: [], + notices: [ + ...(counts.preserved > 0 + ? [ + `Preserved ${counts.preserved} canonical SQLite device-auth token${counts.preserved === 1 ? "" : "s"}.`, + ] + : []), + "Removed retired device-auth JSON after verified SQLite import.", + ], + }; +} + +/** Import retired device-auth JSON while excluding Gateways that can rewrite it. */ +export async function migrateLegacyDeviceAuth(params: { + detected: LegacyDeviceAuthDetection; + stateDir: string; + env?: NodeJS.ProcessEnv; +}): Promise { + if (!params.detected.hasLegacy) { + return { changes: [], warnings: [] }; + } + const env = { ...(params.env ?? process.env), OPENCLAW_STATE_DIR: params.stateDir }; + let lock: Awaited>; + try { + lock = await acquireGatewayLock({ + allowInTests: true, + env, + pollIntervalMs: 25, + role: "sqlite-maintenance", + timeoutMs: 250, + }); + } catch (error) { + const detail = + error instanceof GatewayLockError + ? "the Gateway or another SQLite maintenance command owns this state directory" + : String(error); + return { + changes: [], + warnings: [ + `Failed migrating legacy device auth: ${detail}. Stop the Gateway and run \`openclaw doctor --fix\` again.`, + ], + }; + } + if (!lock) { + return { + changes: [], + warnings: ["Failed migrating legacy device auth: exclusive state ownership unavailable."], + }; + } + let result: MigrationMessages = { changes: [], warnings: [] }; + let releaseError: unknown; + try { + result = await importLegacyStore({ ...params, env }); + } catch (error) { + result.warnings.push(`Failed migrating legacy device auth: ${String(error)}`); + } finally { + try { + await lock.release(); + } catch (error) { + releaseError = error; + } + } + if (releaseError) { + result.warnings.push( + `Device-auth migration lock release failed: ${formatErrorMessage(releaseError)}`, + ); + } + return result; +} diff --git a/src/infra/state-migrations.doctor.ts b/src/infra/state-migrations.doctor.ts index 621966e1a0ab..def3f0ba260b 100644 --- a/src/infra/state-migrations.doctor.ts +++ b/src/infra/state-migrations.doctor.ts @@ -52,6 +52,7 @@ import { detectLegacyDebugProxyCaptureSidecar, migrateLegacyDebugProxyCaptureSidecar, } from "./state-migrations.debug-proxy.js"; +import { detectLegacyDeviceAuth, migrateLegacyDeviceAuth } from "./state-migrations.device-auth.js"; import { detectLegacyDeviceIdentity, migrateLegacyDeviceIdentity, @@ -447,6 +448,10 @@ export async function detectLegacyStateMigrations(params: { stateDir, doctorOnlyStateMigrations: params.doctorOnlyStateMigrations, }); + const deviceAuth = detectLegacyDeviceAuth({ + stateDir, + doctorOnlyStateMigrations: params.doctorOnlyStateMigrations, + }); const deviceIdentity = detectLegacyDeviceIdentity({ stateDir, env, @@ -640,6 +645,9 @@ export async function detectLegacyStateMigrations(params: { if (apns.hasLegacy) { preview.push("- APNs registrations: legacy JSON → shared SQLite state"); } + if (deviceAuth.hasLegacy) { + preview.push("- Device auth tokens: legacy JSON → shared SQLite state"); + } if (deviceIdentity.hasLegacy) { preview.push("- Primary device identity: legacy JSON → shared SQLite state"); } @@ -756,6 +764,7 @@ export async function detectLegacyStateMigrations(params: { acpReplayLedger, managedOutgoingImages, apns, + deviceAuth, deviceIdentity, mcpOauth, restartSentinel, @@ -1021,6 +1030,11 @@ export async function runLegacyStateMigrations(params: { env, stateDir: detected.stateDir, }); + const deviceAuth = await migrateLegacyDeviceAuth({ + detected: detected.deviceAuth, + env, + stateDir: detected.stateDir, + }); const deviceIdentity = await migrateLegacyDeviceIdentity({ detected: detected.deviceIdentity, env, @@ -1096,6 +1110,7 @@ export async function runLegacyStateMigrations(params: { acpReplayLedger, managedOutgoingImages, apns, + deviceAuth, deviceIdentity, mcpOauth, restartSentinel, @@ -1124,6 +1139,7 @@ export async function runLegacyStateMigrations(params: { ...acpReplayLedger.changes, ...managedOutgoingImages.changes, ...apns.changes, + ...deviceAuth.changes, ...deviceIdentity.changes, ...mcpOauth.changes, ...restartSentinel.changes, @@ -1159,6 +1175,7 @@ export async function runLegacyStateMigrations(params: { ...acpReplayLedger.warnings, ...managedOutgoingImages.warnings, ...apns.warnings, + ...deviceAuth.warnings, ...deviceIdentity.warnings, ...mcpOauth.warnings, ...restartSentinel.warnings, @@ -1297,6 +1314,11 @@ export async function autoMigrateLegacyState(params: { homedir: params.homedir, doctorOnlyStateMigrations: params.doctorOnlyStateMigrations, }); + const deviceAuth = await migrateLegacyDeviceAuth({ + detected: detected.deviceAuth, + env, + stateDir: detected.stateDir, + }); const deviceIdentity = await migrateLegacyDeviceIdentity({ detected: detected.deviceIdentity, env, @@ -1374,6 +1396,7 @@ export async function autoMigrateLegacyState(params: { ...configHealth.changes, ...pluginBindingApprovals.changes, ...currentConversationBindings.changes, + ...deviceAuth.changes, ...deviceIdentity.changes, ...restartSentinel.changes, ...channelPairing.changes, @@ -1397,6 +1420,7 @@ export async function autoMigrateLegacyState(params: { ...configHealth.warnings, ...pluginBindingApprovals.warnings, ...currentConversationBindings.warnings, + ...deviceAuth.warnings, ...deviceIdentity.warnings, ...restartSentinel.warnings, ...channelPairing.warnings, @@ -1408,6 +1432,7 @@ export async function autoMigrateLegacyState(params: { detected, pluginInstallIndex, updateCheck, + deviceAuth, deviceIdentity, restartSentinel, pluginPlans, @@ -1431,6 +1456,7 @@ export async function autoMigrateLegacyState(params: { configHealth.changes.length > 0 || pluginBindingApprovals.changes.length > 0 || currentConversationBindings.changes.length > 0 || + deviceAuth.changes.length > 0 || deviceIdentity.changes.length > 0 || restartSentinel.changes.length > 0 || channelPairing.changes.length > 0 || @@ -1458,6 +1484,7 @@ export async function autoMigrateLegacyState(params: { !detected.configHealth.hasLegacy && !detected.pluginBindingApprovals.hasLegacy && !detected.currentConversationBindings.hasLegacy && + !detected.deviceAuth.hasLegacy && !detected.restartSentinel?.hasLegacy && !detected.workspace.hasLegacy && !detected.channelPairing.hasLegacy @@ -1468,6 +1495,7 @@ export async function autoMigrateLegacyState(params: { ...configMachineState.changes, ...orphanKeys.changes, ...acpSessionMetadata.changes, + ...deviceAuth.changes, ...deviceIdentity.changes, ]; const warnings = [ @@ -1477,11 +1505,13 @@ export async function autoMigrateLegacyState(params: { ...detected.warnings, ...orphanKeys.warnings, ...acpSessionMetadata.warnings, + ...deviceAuth.warnings, ...deviceIdentity.warnings, ]; const notices = [ ...(stateDirResult.notices ?? []), ...detected.notices, + ...(deviceAuth.notices ?? []), ...(deviceIdentity.notices ?? []), ]; logMigrationResults(changes, warnings, notices); @@ -1492,6 +1522,7 @@ export async function autoMigrateLegacyState(params: { configMachineState.changes.length > 0 || orphanKeys.changes.length > 0 || acpSessionMetadata.changes.length > 0 || + deviceAuth.changes.length > 0 || deviceIdentity.changes.length > 0, skipped: false, changes, @@ -1583,6 +1614,7 @@ export async function autoMigrateLegacyState(params: { ...configHealth.changes, ...pluginBindingApprovals.changes, ...currentConversationBindings.changes, + ...deviceAuth.changes, ...deviceIdentity.changes, ...restartSentinel.changes, ...channelPairing.changes, @@ -1610,6 +1642,7 @@ export async function autoMigrateLegacyState(params: { ...configHealth.warnings, ...pluginBindingApprovals.warnings, ...currentConversationBindings.warnings, + ...deviceAuth.warnings, ...deviceIdentity.warnings, ...restartSentinel.warnings, ...channelPairing.warnings, @@ -1625,6 +1658,7 @@ export async function autoMigrateLegacyState(params: { detected, pluginInstallIndex, updateCheck, + deviceAuth, deviceIdentity, restartSentinel, pluginPlans, diff --git a/src/infra/state-migrations.types.ts b/src/infra/state-migrations.types.ts index ff3703294ac3..047bcd0b92cf 100644 --- a/src/infra/state-migrations.types.ts +++ b/src/infra/state-migrations.types.ts @@ -119,6 +119,11 @@ export type LegacyStateDetection = { sourcePath: string; hasLegacy: boolean; }; + deviceAuth: { + sourcePath: string; + sourcePresent: boolean; + hasLegacy: boolean; + }; deviceIdentity: LegacyDeviceIdentityDetection; mcpOauth: LegacyMcpOAuthDetection; restartSentinel?: LegacyRestartSentinelDetection; diff --git a/src/shared/device-auth-store.test.ts b/src/shared/device-auth-store.test.ts deleted file mode 100644 index 1be09a3fe387..000000000000 --- a/src/shared/device-auth-store.test.ts +++ /dev/null @@ -1,391 +0,0 @@ -// Device auth store tests cover persisted paired-device auth state. -import { describe, expect, it, vi } from "vitest"; -import { - clearDeviceAuthTokenFromStore, - coerceDeviceAuthStore, - loadDeviceAuthTokenFromStore, - storeDeviceAuthTokenInStore, -} from "./device-auth-store.js"; - -type TestDeviceAuthStoreAdapter = Parameters[0]["adapter"]; - -function createAdapter(initialStore: ReturnType = null) { - let store = initialStore; - const writes: unknown[] = []; - const adapter: TestDeviceAuthStoreAdapter = { - readStore: () => store, - writeStore: (next) => { - store = next; - writes.push(next); - }, - }; - return { adapter, writes, readStore: () => store }; -} - -describe("device-auth-store", () => { - it("loads only matching device ids and normalized roles", () => { - const { adapter } = createAdapter({ - version: 1, - deviceId: "device-1", - tokens: { - operator: { - token: "secret", - role: "operator", - scopes: ["operator.read"], - updatedAtMs: 1, - }, - }, - }); - - expect( - loadDeviceAuthTokenFromStore({ - adapter, - deviceId: "device-1", - role: " operator ", - }), - ).toEqual({ - token: "secret", - role: "operator", - scopes: ["operator.read"], - updatedAtMs: 1, - }); - expect( - loadDeviceAuthTokenFromStore({ - adapter, - deviceId: "device-2", - role: "operator", - }), - ).toBeNull(); - }); - - it("returns null for missing stores and malformed token entries", () => { - expect( - loadDeviceAuthTokenFromStore({ - adapter: createAdapter().adapter, - deviceId: "device-1", - role: "operator", - }), - ).toBeNull(); - - const { adapter } = createAdapter({ - version: 1, - deviceId: "device-1", - tokens: { - operator: { - token: 123 as unknown as string, - role: "operator", - scopes: [], - updatedAtMs: 1, - }, - }, - }); - expect( - loadDeviceAuthTokenFromStore({ - adapter, - deviceId: "device-1", - role: "operator", - }), - ).toBeNull(); - }); - - it("normalizes malformed persisted token metadata before returning entries", () => { - const { adapter } = createAdapter({ - version: 1, - deviceId: "device-1", - tokens: { - operator: { - token: "secret", - role: { nested: "bad" }, - scopes: ["operator.write", 42, "", "operator.read"], - updatedAtMs: "bad-time", - }, - }, - } as never); - - expect( - loadDeviceAuthTokenFromStore({ - adapter, - deviceId: "device-1", - role: "operator", - }), - ).toEqual({ - token: "secret", - role: "operator", - scopes: ["operator.read", "operator.write"], - updatedAtMs: 0, - }); - }); - - it("coerces raw persisted stores into canonical token maps", () => { - expect( - coerceDeviceAuthStore({ - version: 1, - deviceId: "device-1", - tokens: { - " operator ": { - token: "operator-token", - role: { nested: "bad" }, - scopes: ["operator.write", "operator.read", 42], - updatedAtMs: "bad-time", - }, - broken: { - token: 123, - role: "broken", - scopes: [], - updatedAtMs: 1, - }, - }, - }), - ).toEqual({ - version: 1, - deviceId: "device-1", - tokens: { - operator: { - token: "operator-token", - role: "operator", - scopes: ["operator.read", "operator.write"], - updatedAtMs: 0, - }, - }, - }); - - expect(coerceDeviceAuthStore({ version: 2, deviceId: "device-1", tokens: {} })).toBeNull(); - expect(coerceDeviceAuthStore({ version: 1, deviceId: "device-1", tokens: [] })).toBeNull(); - }); - - it("stores normalized roles and deduped sorted scopes while preserving same-device tokens", () => { - vi.spyOn(Date, "now").mockReturnValue(1234); - const { adapter, writes, readStore } = createAdapter({ - version: 1, - deviceId: "device-1", - tokens: { - node: { - token: "node-token", - role: "node", - scopes: ["node.invoke"], - updatedAtMs: 10, - }, - }, - }); - - const entry = storeDeviceAuthTokenInStore({ - adapter, - deviceId: "device-1", - role: " operator ", - token: "operator-token", - scopes: [" operator.write ", "operator.read", "operator.read", ""], - }); - - expect(entry).toEqual({ - token: "operator-token", - role: "operator", - scopes: ["operator.read", "operator.write"], - updatedAtMs: 1234, - }); - expect(writes).toHaveLength(1); - expect(readStore()).toEqual({ - version: 1, - deviceId: "device-1", - tokens: { - node: { - token: "node-token", - role: "node", - scopes: ["node.invoke"], - updatedAtMs: 10, - }, - operator: entry, - }, - }); - }); - - it("canonicalizes same-device persisted tokens while storing new entries", () => { - vi.spyOn(Date, "now").mockReturnValue(5678); - const { adapter, readStore } = createAdapter({ - version: 1, - deviceId: "device-1", - tokens: { - node: { - token: "node-token", - role: { nested: "bad" }, - scopes: ["node.invoke", 123], - updatedAtMs: "bad-time", - }, - broken: { - token: 123, - role: "broken", - scopes: [], - updatedAtMs: 1, - }, - }, - } as never); - - const entry = storeDeviceAuthTokenInStore({ - adapter, - deviceId: "device-1", - role: "operator", - token: "operator-token", - }); - - expect(readStore()).toEqual({ - version: 1, - deviceId: "device-1", - tokens: { - node: { - token: "node-token", - role: "node", - scopes: ["node.invoke"], - updatedAtMs: 0, - }, - operator: entry, - }, - }); - }); - - it("replaces stale stores from other devices instead of merging them", () => { - vi.spyOn(Date, "now").mockReturnValue(3456); - const { adapter, readStore } = createAdapter({ - version: 1, - deviceId: "device-2", - tokens: { - operator: { - token: "old-token", - role: "operator", - scopes: [], - updatedAtMs: 1, - }, - }, - }); - - storeDeviceAuthTokenInStore({ - adapter, - deviceId: "device-1", - role: "node", - token: "node-token", - }); - - expect(readStore()).toEqual({ - version: 1, - deviceId: "device-1", - tokens: { - node: { - token: "node-token", - role: "node", - scopes: [], - updatedAtMs: 3456, - }, - }, - }); - }); - - it("overwrites existing entries for the same normalized role", () => { - vi.spyOn(Date, "now").mockReturnValue(2222); - const { adapter, readStore } = createAdapter({ - version: 1, - deviceId: "device-1", - tokens: { - operator: { - token: "old-token", - role: "operator", - scopes: ["operator.read"], - updatedAtMs: 10, - }, - }, - }); - - const entry = storeDeviceAuthTokenInStore({ - adapter, - deviceId: "device-1", - role: " operator ", - token: "new-token", - scopes: ["operator.write"], - }); - - expect(entry).toEqual({ - token: "new-token", - role: "operator", - scopes: ["operator.read", "operator.write"], - updatedAtMs: 2222, - }); - expect(readStore()).toEqual({ - version: 1, - deviceId: "device-1", - tokens: { - operator: entry, - }, - }); - }); - - it("avoids writes when clearing missing roles or mismatched devices", () => { - const missingRole = createAdapter({ - version: 1, - deviceId: "device-1", - tokens: {}, - }); - clearDeviceAuthTokenFromStore({ - adapter: missingRole.adapter, - deviceId: "device-1", - role: "operator", - }); - expect(missingRole.writes).toHaveLength(0); - - const otherDevice = createAdapter({ - version: 1, - deviceId: "device-2", - tokens: { - operator: { - token: "secret", - role: "operator", - scopes: [], - updatedAtMs: 1, - }, - }, - }); - clearDeviceAuthTokenFromStore({ - adapter: otherDevice.adapter, - deviceId: "device-1", - role: "operator", - }); - expect(otherDevice.writes).toHaveLength(0); - }); - - it("removes normalized roles when clearing stored tokens", () => { - const { adapter, writes, readStore } = createAdapter({ - version: 1, - deviceId: "device-1", - tokens: { - operator: { - token: "secret", - role: "operator", - scopes: ["operator.read"], - updatedAtMs: 1, - }, - node: { - token: "node-token", - role: "node", - scopes: [], - updatedAtMs: 2, - }, - }, - }); - - clearDeviceAuthTokenFromStore({ - adapter, - deviceId: "device-1", - role: " operator ", - }); - - expect(writes).toHaveLength(1); - expect(readStore()).toEqual({ - version: 1, - deviceId: "device-1", - tokens: { - node: { - token: "node-token", - role: "node", - scopes: [], - updatedAtMs: 2, - }, - }, - }); - }); -}); diff --git a/src/shared/device-auth-store.ts b/src/shared/device-auth-store.ts deleted file mode 100644 index 8c7deecf502f..000000000000 --- a/src/shared/device-auth-store.ts +++ /dev/null @@ -1,131 +0,0 @@ -// Device auth store helpers persist and normalize paired device auth records. -import { isRecord } from "@openclaw/normalization-core/record-coerce"; -import { - type DeviceAuthEntry, - type DeviceAuthStore, - normalizeDeviceAuthRole, - normalizeDeviceAuthScopes, -} from "./device-auth.js"; -export type { DeviceAuthEntry, DeviceAuthStore } from "./device-auth.js"; - -/** Storage seam used by shared device-auth helpers and filesystem-backed infra wrappers. */ -type DeviceAuthStoreAdapter = { - readStore: () => DeviceAuthStore | null; - writeStore: (store: DeviceAuthStore) => void; -}; - -function coerceDeviceAuthEntry(role: string, value: unknown): DeviceAuthEntry | null { - if (!isRecord(value) || typeof value.token !== "string") { - return null; - } - const updatedAtMs = - typeof value.updatedAtMs === "number" && Number.isFinite(value.updatedAtMs) - ? value.updatedAtMs - : 0; - return { - token: value.token, - role, - scopes: normalizeDeviceAuthScopes(Array.isArray(value.scopes) ? value.scopes : undefined), - updatedAtMs, - }; -} - -function copyCanonicalDeviceAuthTokens( - tokens: Record, -): Record { - const out: Record = {}; - for (const [rawRole, value] of Object.entries(tokens)) { - const role = normalizeDeviceAuthRole(rawRole); - if (!role) { - continue; - } - const entry = coerceDeviceAuthEntry(role, value); - if (entry) { - out[role] = entry; - } - } - return out; -} - -/** Coerces raw persisted device-auth JSON into the current canonical store shape. */ -export function coerceDeviceAuthStore(value: unknown): DeviceAuthStore | null { - if (!isRecord(value) || value.version !== 1 || typeof value.deviceId !== "string") { - return null; - } - if (!isRecord(value.tokens)) { - return null; - } - return { - version: 1, - deviceId: value.deviceId, - tokens: copyCanonicalDeviceAuthTokens(value.tokens), - }; -} - -/** Load one normalized role token, ignoring stores bound to a different gateway device id. */ -export function loadDeviceAuthTokenFromStore(params: { - adapter: DeviceAuthStoreAdapter; - deviceId: string; - role: string; -}): DeviceAuthEntry | null { - const store = params.adapter.readStore(); - if (!store || store.deviceId !== params.deviceId) { - return null; - } - const role = normalizeDeviceAuthRole(params.role); - return coerceDeviceAuthEntry(role, store.tokens[role]); -} - -/** Store one role token while preserving canonical tokens for the same gateway device id. */ -export function storeDeviceAuthTokenInStore(params: { - adapter: DeviceAuthStoreAdapter; - deviceId: string; - role: string; - token: string; - scopes?: string[]; -}): DeviceAuthEntry { - const role = normalizeDeviceAuthRole(params.role); - const existing = params.adapter.readStore(); - const next: DeviceAuthStore = { - version: 1, - deviceId: params.deviceId, - tokens: - // Device-auth stores are scoped to one gateway device id; never merge stale - // tokens copied from another gateway identity. - existing && existing.deviceId === params.deviceId && existing.tokens - ? copyCanonicalDeviceAuthTokens(existing.tokens) - : {}, - }; - const entry: DeviceAuthEntry = { - token: params.token, - role, - scopes: normalizeDeviceAuthScopes(params.scopes), - updatedAtMs: Date.now(), - }; - next.tokens[role] = entry; - params.adapter.writeStore(next); - return entry; -} - -/** Clear one normalized role token without rewriting missing or wrong-device stores. */ -export function clearDeviceAuthTokenFromStore(params: { - adapter: DeviceAuthStoreAdapter; - deviceId: string; - role: string; -}): void { - const store = params.adapter.readStore(); - if (!store || store.deviceId !== params.deviceId) { - return; - } - const role = normalizeDeviceAuthRole(params.role); - if (!store.tokens[role]) { - return; - } - const next: DeviceAuthStore = { - version: 1, - deviceId: store.deviceId, - tokens: copyCanonicalDeviceAuthTokens(store.tokens), - }; - delete next.tokens[role]; - params.adapter.writeStore(next); -} diff --git a/src/shared/device-auth.ts b/src/shared/device-auth.ts index 4d2f92ed353a..7080c942c954 100644 --- a/src/shared/device-auth.ts +++ b/src/shared/device-auth.ts @@ -6,7 +6,7 @@ export type DeviceAuthEntry = { updatedAtMs: number; }; -/** Versioned on-disk device-auth cache for a gateway device identity. */ +/** Versioned browser-local device-auth cache for a gateway device identity. */ export type DeviceAuthStore = { version: 1; deviceId: string; diff --git a/ui/src/lib/nodes/device-token.test.ts b/ui/src/lib/nodes/device-token.test.ts index 868d4ca64fb9..5837ff3c5850 100644 --- a/ui/src/lib/nodes/device-token.test.ts +++ b/ui/src/lib/nodes/device-token.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createStorageMock } from "../../test-helpers/storage.ts"; import { + clearDeviceAuthToken, loadDeviceAuthToken, revokeDeviceToken, rotateDeviceToken, @@ -56,6 +57,16 @@ const tokenParams = { role: "operator", }; +function storedTokenKey(): string { + const key = Array.from({ length: localStorage.length }, (_, index) => + localStorage.key(index), + ).find((candidate) => candidate?.startsWith("openclaw.device.auth.v1:")); + if (!key) { + throw new Error("missing device-auth test storage key"); + } + return key; +} + beforeEach(() => { vi.stubGlobal("localStorage", createStorageMock()); }); @@ -112,4 +123,46 @@ describe("device token request lifecycle", () => { expect(loadDeviceAuthToken(tokenParams)?.token).toBe("current-token"); }); + + it("normalizes malformed persisted scopes without breaking token loading", () => { + storeDeviceAuthToken({ ...tokenParams, token: "current-token", scopes: [] }); + const key = storedTokenKey(); + const store = JSON.parse(localStorage.getItem(key) ?? "null"); + store.tokens.operator.scopes = "not-an-array"; + localStorage.setItem(key, JSON.stringify(store)); + + expect(loadDeviceAuthToken(tokenParams)).toMatchObject({ + token: "current-token", + scopes: [], + }); + }); + + it("canonicalizes persisted role aliases before storing another token", () => { + storeDeviceAuthToken({ ...tokenParams, token: "operator-token", scopes: [] }); + const key = storedTokenKey(); + const store = JSON.parse(localStorage.getItem(key) ?? "null"); + store.tokens = { " operator ": store.tokens.operator }; + localStorage.setItem(key, JSON.stringify(store)); + + storeDeviceAuthToken({ + ...tokenParams, + role: "node", + token: "node-token", + scopes: ["node.invoke"], + }); + + expect(loadDeviceAuthToken(tokenParams)?.token).toBe("operator-token"); + }); + + it("removes persisted role aliases when clearing a token", () => { + storeDeviceAuthToken({ ...tokenParams, token: "operator-token", scopes: [] }); + const key = storedTokenKey(); + const store = JSON.parse(localStorage.getItem(key) ?? "null"); + store.tokens[" operator "] = store.tokens.operator; + localStorage.setItem(key, JSON.stringify(store)); + + clearDeviceAuthToken(tokenParams); + + expect(loadDeviceAuthToken(tokenParams)).toBeNull(); + }); }); diff --git a/ui/src/lib/nodes/index.ts b/ui/src/lib/nodes/index.ts index 8661148ca7dd..0ae6bb4ebc52 100644 --- a/ui/src/lib/nodes/index.ts +++ b/ui/src/lib/nodes/index.ts @@ -1,12 +1,11 @@ // Shared Nodes operations used by the Control UI page and Gateway event hooks. import { getPublicKeyAsync, signAsync, utils } from "@noble/ed25519"; import { - clearDeviceAuthTokenFromStore, type DeviceAuthEntry, - loadDeviceAuthTokenFromStore, - storeDeviceAuthTokenInStore, -} from "../../../../src/shared/device-auth-store.js"; -import type { DeviceAuthStore } from "../../../../src/shared/device-auth.js"; + type DeviceAuthStore, + normalizeDeviceAuthRole, + normalizeDeviceAuthScopes, +} from "../../../../src/shared/device-auth.js"; import { normalizeGatewayCredentialScope } from "../../app/gateway-scope.ts"; import { getSafeLocalStorage } from "../../local-storage.ts"; import { cloneConfigObject, removePathValue, setPathValue } from "../config-form-utils.ts"; @@ -726,19 +725,34 @@ function writeStore(gatewayUrl: string, store: DeviceAuthStore) { } } +function canonicalDeviceAuthTokens(tokens: DeviceAuthStore["tokens"]) { + const canonical: DeviceAuthStore["tokens"] = {}; + for (const [rawRole, entry] of Object.entries(tokens)) { + const role = normalizeDeviceAuthRole(rawRole); + if (!role || !entry || typeof entry.token !== "string") { + continue; + } + canonical[role] = { + token: entry.token, + role, + scopes: normalizeDeviceAuthScopes(Array.isArray(entry.scopes) ? entry.scopes : undefined), + updatedAtMs: Number.isFinite(entry.updatedAtMs) ? entry.updatedAtMs : 0, + }; + } + return canonical; +} + export function loadDeviceAuthToken(params: { deviceId: string; gatewayUrl: string; role: string; }): DeviceAuthEntry | null { - return loadDeviceAuthTokenFromStore({ - adapter: { - readStore: () => readStore(params.gatewayUrl), - writeStore: (store) => writeStore(params.gatewayUrl, store), - }, - deviceId: params.deviceId, - role: params.role, - }); + const store = readStore(params.gatewayUrl); + if (!store || store.deviceId !== params.deviceId) { + return null; + } + const role = normalizeDeviceAuthRole(params.role); + return canonicalDeviceAuthTokens(store.tokens)[role] ?? null; } export function storeDeviceAuthToken(params: { @@ -748,16 +762,23 @@ export function storeDeviceAuthToken(params: { token: string; scopes?: string[]; }): DeviceAuthEntry { - return storeDeviceAuthTokenInStore({ - adapter: { - readStore: () => readStore(params.gatewayUrl), - writeStore: (store) => writeStore(params.gatewayUrl, store), - }, - deviceId: params.deviceId, - role: params.role, + const existing = readStore(params.gatewayUrl); + const role = normalizeDeviceAuthRole(params.role); + const entry: DeviceAuthEntry = { token: params.token, - scopes: params.scopes, + role, + scopes: normalizeDeviceAuthScopes(params.scopes), + updatedAtMs: Date.now(), + }; + writeStore(params.gatewayUrl, { + version: 1, + deviceId: params.deviceId, + tokens: { + ...(existing?.deviceId === params.deviceId ? canonicalDeviceAuthTokens(existing.tokens) : {}), + [role]: entry, + }, }); + return entry; } export function clearDeviceAuthToken(params: { @@ -765,14 +786,17 @@ export function clearDeviceAuthToken(params: { gatewayUrl: string; role: string; }) { - clearDeviceAuthTokenFromStore({ - adapter: { - readStore: () => readStore(params.gatewayUrl), - writeStore: (store) => writeStore(params.gatewayUrl, store), - }, - deviceId: params.deviceId, - role: params.role, - }); + const store = readStore(params.gatewayUrl); + if (!store || store.deviceId !== params.deviceId) { + return; + } + const role = normalizeDeviceAuthRole(params.role); + if (!store.tokens[role]) { + return; + } + const tokens = canonicalDeviceAuthTokens(store.tokens); + delete tokens[role]; + writeStore(params.gatewayUrl, { ...store, tokens }); } function base64UrlEncode(bytes: Uint8Array): string {