mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
1c70136472
commit
df3ff35277
+6
-7
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -119,6 +119,11 @@ export type LegacyStateDetection = {
|
||||
sourcePath: string;
|
||||
hasLegacy: boolean;
|
||||
};
|
||||
deviceAuth: {
|
||||
sourcePath: string;
|
||||
sourcePresent: boolean;
|
||||
hasLegacy: boolean;
|
||||
};
|
||||
deviceIdentity: LegacyDeviceIdentityDetection;
|
||||
mcpOauth: LegacyMcpOAuthDetection;
|
||||
restartSentinel?: LegacyRestartSentinelDetection;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user