refactor(state): move device auth tokens to SQLite (#112663)

* refactor(state): store device auth tokens in sqlite

* fix(state): keep device auth migration types acyclic

* fix(state): keep migration detection type private
This commit is contained in:
Peter Steinberger
2026-07-22 06:13:03 -07:00
committed by GitHub
parent 1c70136472
commit df3ff35277
17 changed files with 750 additions and 830 deletions
+6 -7
View File
@@ -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
+6 -6
View File
@@ -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
+1 -1
View File
@@ -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`)
+3 -13
View File
@@ -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,
+9 -59
View File
@@ -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;
+5
View File
@@ -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",
+104 -115
View File
@@ -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",
);
});
});
+123 -77
View File
@@ -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<OpenClawStateKyselyDatabase, "device_auth_tokens">;
// 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<string, boolean>();
type StoreCacheEntry = { store: DeviceAuthStore | null; mtimeMs: number; size: number };
const storeReadCache = new Map<string, StoreCacheEntry>();
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<DeviceAuthDatabase>(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<DeviceAuthDatabase>(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<DeviceAuthDatabase>(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<DeviceAuthDatabase>(db)
.deleteFrom("device_auth_tokens")
.where("device_id", "=", params.deviceId)
.where("role", "=", normalizeDeviceAuthRole(params.role)),
);
},
deviceId: params.deviceId,
role: params.role,
});
{ env: params.env },
);
}
@@ -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<string, unknown> = {},
): Promise<void> {
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);
});
});
+219
View File
@@ -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<OpenClawStateKyselyDatabase, "device_auth_tokens">;
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<MigrationMessages> {
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<DeviceAuthMigrationDatabase>(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<MigrationMessages> {
if (!params.detected.hasLegacy) {
return { changes: [], warnings: [] };
}
const env = { ...(params.env ?? process.env), OPENCLAW_STATE_DIR: params.stateDir };
let lock: Awaited<ReturnType<typeof acquireGatewayLock>>;
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;
}
+34
View File
@@ -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,
+5
View File
@@ -119,6 +119,11 @@ export type LegacyStateDetection = {
sourcePath: string;
hasLegacy: boolean;
};
deviceAuth: {
sourcePath: string;
sourcePresent: boolean;
hasLegacy: boolean;
};
deviceIdentity: LegacyDeviceIdentityDetection;
mcpOauth: LegacyMcpOAuthDetection;
restartSentinel?: LegacyRestartSentinelDetection;
-391
View File
@@ -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<typeof loadDeviceAuthTokenFromStore>[0]["adapter"];
function createAdapter(initialStore: ReturnType<TestDeviceAuthStoreAdapter["readStore"]> = 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,
},
},
});
});
});
-131
View File
@@ -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<string, unknown>,
): Record<string, DeviceAuthEntry> {
const out: Record<string, DeviceAuthEntry> = {};
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);
}
+1 -1
View File
@@ -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;
+53
View File
@@ -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();
});
});
+53 -29
View File
@@ -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 {