diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index 611d747cf1e3..14b838bdaaed 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -3347,7 +3347,7 @@ src/infra/state-migrations.meeting-transcripts.ts 2 src/infra/state-migrations.node-host.ts 2 src/infra/state-migrations.runtime-state.ts 7 src/infra/state-migrations.session-store.ts 12 -src/infra/state-migrations.shared-auth-store.ts 2 +src/infra/state-migrations.shared-auth-store.ts 1 src/infra/state-migrations.source-snapshot.ts 1 src/infra/state-migrations.state-dir.ts 1 src/infra/state-migrations.storage.ts 21 diff --git a/src/agents/auth-profiles.sqlite-store.test.ts b/src/agents/auth-profiles.sqlite-store.test.ts index 96a74e6c3169..05a2086e2f3c 100644 --- a/src/agents/auth-profiles.sqlite-store.test.ts +++ b/src/agents/auth-profiles.sqlite-store.test.ts @@ -11,6 +11,10 @@ import { DatabaseSync } from "node:sqlite"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as kyselySync from "../infra/kysely-sync.js"; import * as nodeSqlite from "../infra/node-sqlite.js"; +import { + detectSharedAuthStoreMigration, + migrateSharedAuthStore, +} from "../infra/state-migrations.shared-auth-store.js"; import { writeConfigMachineState } from "../state/config-machine-state.js"; import { closeOpenClawAgentDatabasesForTest, @@ -30,13 +34,19 @@ import { inspectPersistedAuthProfileStateRaw, inspectPersistedAuthProfileStoreRaw, resolveAuthProfileDatabasePath, + writePersistedAuthProfileStateRaw, + writePersistedAuthProfileStoreRaw, } from "./auth-profiles/sqlite.js"; import { ensureAuthProfileStore, getRuntimeAuthProfileStoreSnapshotRevision, saveAuthProfileStore, } from "./auth-profiles/store.js"; -import type { AuthProfileStore, OAuthCredential } from "./auth-profiles/types.js"; +import type { ApiKeyCredential, AuthProfileStore, OAuthCredential } from "./auth-profiles/types.js"; +import { + persistAuthProfileBatch, + upsertAuthProfileWithLockOrThrow, +} from "./auth-profiles/upsert-with-lock.js"; type RuntimeOnlyOverlay = { profileId: string; @@ -59,20 +69,23 @@ vi.mock("../plugins/provider-runtime.js", () => ({ resolveExternalAuthProfilesWithPlugins: () => [], })); +function apiKeyCredential(key: string): ApiKeyCredential { + return { type: "api_key", provider: "openai", key }; +} + function apiKeyStore(key: string): AuthProfileStore { return { version: 1, profiles: { - "openai:default": { - type: "api_key", - provider: "openai", - key, - }, + "openai:default": apiKeyCredential(key), }, }; } -async function withAgentDirEnv(prefix: string, run: (agentDir: string) => void | Promise) { +async function withAgentDirEnv( + prefix: string, + run: (agentDir: string, stateDir: string) => void | Promise, +) { const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); const agentDir = path.join(root, "agents", "main", "agent"); try { @@ -82,7 +95,7 @@ async function withAgentDirEnv(prefix: string, run: (agentDir: string) => void | OPENCLAW_STATE_DIR: root, OPENCLAW_AGENT_DIR: agentDir, }, - async () => await run(agentDir), + async () => await run(agentDir, root), ); } finally { clearRuntimeAuthProfileStoreSnapshots(); @@ -127,11 +140,22 @@ describe("auth profile sqlite store", () => { }); }); - it("persists the relocated shared store through the shared-state adapter", async () => { - await withAgentDirEnv("openclaw-auth-shared-state-", () => { - writeConfigMachineState("auth.sharedStore", { location: "state-db" }); - saveAuthProfileStore({ - ...apiKeyStore("sk-shared"), + it.each([ + { label: "pre-recorded ownership", recordOwnership: true }, + { label: "fresh ownership", recordOwnership: false }, + ])("persists the shared store through the shared-state adapter with $label", async (testCase) => { + await withAgentDirEnv("openclaw-auth-shared-state-", async (agentDir) => { + if (testCase.recordOwnership) { + writeConfigMachineState("auth.sharedStore", { location: "state-db" }); + } + await persistAuthProfileBatch({ + agentDir, + profiles: [ + { + profileId: "openai:default", + credential: apiKeyCredential("sk-shared"), + }, + ], order: { openai: ["openai:default"] }, }); @@ -150,7 +174,218 @@ describe("auth profile sqlite store", () => { .prepare("SELECT store_key FROM auth_profile_state WHERE store_key = 'shared'") .get(), ).toEqual({ store_key: "shared" }); + expect( + database + .prepare( + "SELECT value_json FROM config_machine_state WHERE state_key = 'auth.sharedStore'", + ) + .get(), + ).toEqual({ value_json: JSON.stringify({ location: "state-db" }) }); database.close(); + expect(fs.existsSync(resolveAuthProfileDatabasePath(agentDir))).toBe(false); + }); + }); + + it.each([ + { + label: "credential row", + seed: (agentDir: string) => + writePersistedAuthProfileStoreRaw(apiKeyStore("sk-legacy"), agentDir), + }, + { + label: "runtime-state row", + seed: (agentDir: string) => + writePersistedAuthProfileStateRaw( + { version: 1, order: { openai: ["openai:legacy"] } }, + agentDir, + ), + }, + ])("keeps legacy ownership when the main agent has a $label", async (testCase) => { + await withAgentDirEnv("openclaw-auth-shared-legacy-", async (agentDir) => { + testCase.seed(agentDir); + + await upsertAuthProfileWithLockOrThrow({ + agentDir, + profileId: "openai:default", + credential: apiKeyCredential("sk-updated"), + }); + + const sharedDatabase = new DatabaseSync(resolveOpenClawStateSqlitePath()); + expect( + sharedDatabase + .prepare( + "SELECT value_json FROM config_machine_state WHERE state_key = 'auth.sharedStore'", + ) + .get(), + ).toBeUndefined(); + expect( + sharedDatabase + .prepare("SELECT store_key FROM auth_profile_stores WHERE store_key = 'shared'") + .get(), + ).toBeUndefined(); + sharedDatabase.close(); + expect(inspectPersistedAuthProfileStoreRaw(agentDir).status).toBe("readable"); + }); + }); + + it("memoizes legacy inspection and follows Doctor's ownership flip", async () => { + await withAgentDirEnv("openclaw-auth-shared-memo-", async (agentDir, stateDir) => { + const sourcePath = resolveAuthProfileDatabasePath(agentDir); + writePersistedAuthProfileStoreRaw(apiKeyStore("sk-legacy"), agentDir); + const realLstat = fs.lstatSync; + let sourceInspections = 0; + const lstatSpy = vi.spyOn(fs, "lstatSync").mockImplementation((pathname, options) => { + if (path.resolve(String(pathname)) === path.resolve(sourcePath)) { + sourceInspections += 1; + } + return realLstat(pathname, options as never); + }); + + try { + for (const key of ["sk-first", "sk-second"]) { + await upsertAuthProfileWithLockOrThrow({ + agentDir, + profileId: "openai:default", + credential: apiKeyCredential(key), + }); + } + expect(sourceInspections).toBe(1); + + const detected = detectSharedAuthStoreMigration({ + stateDir, + doctorOnlyStateMigrations: true, + }); + await migrateSharedAuthStore({ detected, stateDir }); + const inspectionsAfterDoctor = sourceInspections; + + await upsertAuthProfileWithLockOrThrow({ + agentDir, + profileId: "openai:default", + credential: apiKeyCredential("sk-after-doctor"), + }); + + expect(sourceInspections).toBe(inspectionsAfterDoctor); + expect(ensureAuthProfileStore(undefined, { syncExternalCli: false })).toMatchObject({ + profiles: { "openai:default": { key: "sk-after-doctor" } }, + }); + expect(inspectPersistedAuthProfileStoreRaw(agentDir).status).toBe("missing"); + } finally { + lstatSpy.mockRestore(); + } + }); + }); + + it("keeps legacy ownership while shared-auth cleanup is pending", async () => { + await withAgentDirEnv("openclaw-auth-shared-pending-", async (agentDir) => { + const sourcePath = resolveAuthProfileDatabasePath(agentDir); + writeConfigMachineState("test.seed", true); + const sharedDatabase = new DatabaseSync(resolveOpenClawStateSqlitePath()); + sharedDatabase + .prepare( + `INSERT INTO migration_runs (id, started_at, finished_at, status, report_json) + VALUES ('shared-auth-pending', 1, NULL, 'copied', '{}')`, + ) + .run(); + sharedDatabase + .prepare( + `INSERT INTO migration_sources + (source_key, migration_kind, source_path, target_table, source_sha256, + source_size_bytes, source_record_count, last_run_id, status, imported_at, + removed_source, report_json) + VALUES ('shared-auth-pending:store', 'shared-auth-store-state-db', ?, + 'auth_profile_stores', NULL, NULL, NULL, 'shared-auth-pending', + 'copied', 1, 0, '{}')`, + ) + .run(sourcePath); + sharedDatabase.close(); + + await upsertAuthProfileWithLockOrThrow({ + agentDir, + profileId: "openai:default", + credential: apiKeyCredential("sk-after-crash"), + }); + + const after = new DatabaseSync(resolveOpenClawStateSqlitePath()); + expect( + after + .prepare( + "SELECT value_json FROM config_machine_state WHERE state_key = 'auth.sharedStore'", + ) + .get(), + ).toBeUndefined(); + expect( + after.prepare("SELECT store_key FROM auth_profile_stores WHERE store_key = 'shared'").get(), + ).toBeUndefined(); + after.close(); + expect(inspectPersistedAuthProfileStoreRaw(agentDir).status).toBe("readable"); + }); + }); + + it("keeps legacy ownership when the main-agent source is unreadable", async () => { + await withAgentDirEnv("openclaw-auth-shared-unreadable-", async (agentDir) => { + const sourcePath = resolveAuthProfileDatabasePath(agentDir); + const realLstat = fs.lstatSync; + const lstatSpy = vi.spyOn(fs, "lstatSync").mockImplementation((pathname, options) => { + if (path.resolve(String(pathname)) === path.resolve(sourcePath)) { + throw Object.assign(new Error("permission denied"), { code: "EACCES" }); + } + return realLstat(pathname, options as never); + }); + + try { + await upsertAuthProfileWithLockOrThrow({ + agentDir, + profileId: "openai:default", + credential: apiKeyCredential("sk-unreadable"), + }); + } finally { + lstatSpy.mockRestore(); + } + + const sharedDatabase = new DatabaseSync(resolveOpenClawStateSqlitePath()); + expect( + sharedDatabase + .prepare( + "SELECT value_json FROM config_machine_state WHERE state_key = 'auth.sharedStore'", + ) + .get(), + ).toBeUndefined(); + sharedDatabase.close(); + expect(inspectPersistedAuthProfileStoreRaw(agentDir).status).toBe("readable"); + }); + }); + + it("keeps legacy ownership when a retired-file probe fails", async () => { + await withAgentDirEnv("openclaw-auth-shared-file-probe-error-", async (agentDir) => { + const authPath = path.join(agentDir, "auth-profiles.json"); + const realExistsSync = fs.existsSync.bind(fs); + let authPathProbes = 0; + const existsSpy = vi.spyOn(fs, "existsSync").mockImplementation((pathname) => { + if (path.resolve(String(pathname)) === path.resolve(authPath)) { + authPathProbes += 1; + throw Object.assign(new Error("permission denied"), { code: "EACCES" }); + } + return realExistsSync(pathname); + }); + + try { + writePersistedAuthProfileStoreRaw(apiKeyStore("sk-first")); + writePersistedAuthProfileStoreRaw(apiKeyStore("sk-second")); + expect(authPathProbes).toBe(1); + } finally { + existsSpy.mockRestore(); + } + + const sharedDatabase = new DatabaseSync(resolveOpenClawStateSqlitePath()); + expect( + sharedDatabase + .prepare( + "SELECT value_json FROM config_machine_state WHERE state_key = 'auth.sharedStore'", + ) + .get(), + ).toBeUndefined(); + sharedDatabase.close(); + expect(inspectPersistedAuthProfileStoreRaw(agentDir).status).toBe("readable"); }); }); diff --git a/src/agents/auth-profiles/legacy-source-diagnostic.ts b/src/agents/auth-profiles/legacy-source-diagnostic.ts index 3e316a8ef63d..57f6fd1c8154 100644 --- a/src/agents/auth-profiles/legacy-source-diagnostic.ts +++ b/src/agents/auth-profiles/legacy-source-diagnostic.ts @@ -1,98 +1,33 @@ -import fs from "node:fs"; -import path from "node:path"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; -import { resolveOAuthDir } from "../../config/paths.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { shortenHomePath } from "../../utils.js"; +import { + listLegacyAuthProfileSources, + type LegacyAuthProfileSource, + type LegacyAuthProfileSourceKind, +} from "./legacy-source-files.js"; import { resolveSharedAuthStorePath } from "./path-resolve.js"; import { resolveSharedMainAuthAgentDir } from "./shared-main-dir.js"; import { inspectPersistedAuthProfileStoreRaw, resolveAuthProfileDatabasePath } from "./sqlite.js"; +export { + listLegacyAuthProfileArchives, + listLegacyAuthProfileSources, + resolveLegacyOAuthPath, +} from "./legacy-source-files.js"; + const AUTH_PROFILE_MIGRATION_REQUIRED_CODE = "AUTH_PROFILE_MIGRATION_REQUIRED" as const; const AUTH_PROFILE_MIGRATION_COMMAND = "openclaw doctor --fix" as const; const log = createSubsystemLogger("auth-profiles/persistence"); -type LegacyAuthProfileSourceKind = "auth-profiles" | "auth-state" | "legacy-auth" | "legacy-oauth"; - -type LegacyAuthProfileSource = { - kind: LegacyAuthProfileSourceKind; - path: string; -}; - function isCredentialSource(source: LegacyAuthProfileSource): boolean { return source.kind !== "auth-state"; } -export function resolveLegacyOAuthPath(env: NodeJS.ProcessEnv = process.env): string { - return path.join(resolveOAuthDir(env), "oauth.json"); -} - function resolveAuthProfileOwnerPath(agentDir?: string): string { return agentDir ? resolveAuthProfileDatabasePath(agentDir) : resolveSharedAuthStorePath(); } -function resolveLegacySourceAgentDir( - agentDir: string | undefined, - env: NodeJS.ProcessEnv = process.env, -): string { - return agentDir - ? path.dirname(resolveAuthProfileOwnerPath(agentDir)) - : resolveSharedMainAuthAgentDir(env); -} - -/** Detects retired auth files by name only; runtime code must never read their contents. */ -export function listLegacyAuthProfileSources(params: { - agentDir?: string; - env?: NodeJS.ProcessEnv; -}): LegacyAuthProfileSource[] { - const agentDir = resolveLegacySourceAgentDir(params.agentDir, params.env); - const candidates: LegacyAuthProfileSource[] = [ - { kind: "auth-profiles", path: path.join(agentDir, "auth-profiles.json") }, - { kind: "auth-state", path: path.join(agentDir, "auth-state.json") }, - { kind: "legacy-auth", path: path.join(agentDir, "auth.json") }, - ]; - const sharedMainDir = resolveSharedMainAuthAgentDir(params.env); - if (path.resolve(agentDir) === path.resolve(sharedMainDir)) { - candidates.push({ kind: "legacy-oauth", path: resolveLegacyOAuthPath(params.env) }); - } - return candidates.filter((candidate) => fs.existsSync(candidate.path)); -} - -export function listLegacyAuthProfileArchives(params: { - agentDirs: readonly string[]; - env?: NodeJS.ProcessEnv; -}): LegacyAuthProfileSource[] { - const candidates = new Map(); - for (const agentDir of params.agentDirs) { - candidates.set(path.join(agentDir, "auth-profiles.json"), "auth-profiles"); - candidates.set(path.join(agentDir, "auth-state.json"), "auth-state"); - candidates.set(path.join(agentDir, "auth.json"), "legacy-auth"); - } - candidates.set(resolveLegacyOAuthPath(params.env), "legacy-oauth"); - const archives: LegacyAuthProfileSource[] = []; - for (const [sourcePath, kind] of candidates) { - const directory = path.dirname(sourcePath); - const baseName = path.basename(sourcePath); - const migratedPrefix = `${baseName}.migrated-`; - const priorImportPrefix = `${baseName}.sqlite-import.`; - let entries: string[]; - try { - entries = fs.readdirSync(directory); - } catch { - continue; - } - for (const entry of entries) { - if ( - entry.startsWith(migratedPrefix) || - (entry.startsWith(priorImportPrefix) && entry.endsWith(".bak")) - ) { - archives.push({ kind, path: path.join(directory, entry) }); - } - } - } - return archives; -} - export function hasLegacyAuthProfileCredentialSource(agentDir?: string): boolean { return listLegacyAuthProfileSources({ agentDir }).some(isCredentialSource); } diff --git a/src/agents/auth-profiles/legacy-source-files.ts b/src/agents/auth-profiles/legacy-source-files.ts new file mode 100644 index 000000000000..5c3cef22a291 --- /dev/null +++ b/src/agents/auth-profiles/legacy-source-files.ts @@ -0,0 +1,80 @@ +import fs from "node:fs"; +import path from "node:path"; +import { resolveOAuthDir } from "../../config/paths.js"; +import { resolveUserPath } from "../../utils.js"; +import { resolveSharedMainAuthAgentDir } from "./shared-main-dir.js"; + +export type LegacyAuthProfileSourceKind = + | "auth-profiles" + | "auth-state" + | "legacy-auth" + | "legacy-oauth"; + +export type LegacyAuthProfileSource = { + kind: LegacyAuthProfileSourceKind; + path: string; +}; + +export function resolveLegacyOAuthPath(env: NodeJS.ProcessEnv = process.env): string { + return path.join(resolveOAuthDir(env), "oauth.json"); +} + +function resolveLegacySourceAgentDir( + agentDir: string | undefined, + env: NodeJS.ProcessEnv = process.env, +): string { + return agentDir ? resolveUserPath(agentDir) : resolveSharedMainAuthAgentDir(env); +} + +/** Detects retired auth files by name only; runtime code must never read their contents. */ +export function listLegacyAuthProfileSources(params: { + agentDir?: string; + env?: NodeJS.ProcessEnv; +}): LegacyAuthProfileSource[] { + const agentDir = resolveLegacySourceAgentDir(params.agentDir, params.env); + const candidates: LegacyAuthProfileSource[] = [ + { kind: "auth-profiles", path: path.join(agentDir, "auth-profiles.json") }, + { kind: "auth-state", path: path.join(agentDir, "auth-state.json") }, + { kind: "legacy-auth", path: path.join(agentDir, "auth.json") }, + ]; + const sharedMainDir = resolveSharedMainAuthAgentDir(params.env); + if (path.resolve(agentDir) === path.resolve(sharedMainDir)) { + candidates.push({ kind: "legacy-oauth", path: resolveLegacyOAuthPath(params.env) }); + } + return candidates.filter((candidate) => fs.existsSync(candidate.path)); +} + +export function listLegacyAuthProfileArchives(params: { + agentDirs: readonly string[]; + env?: NodeJS.ProcessEnv; +}): LegacyAuthProfileSource[] { + const candidates = new Map(); + for (const agentDir of params.agentDirs) { + candidates.set(path.join(agentDir, "auth-profiles.json"), "auth-profiles"); + candidates.set(path.join(agentDir, "auth-state.json"), "auth-state"); + candidates.set(path.join(agentDir, "auth.json"), "legacy-auth"); + } + candidates.set(resolveLegacyOAuthPath(params.env), "legacy-oauth"); + const archives: LegacyAuthProfileSource[] = []; + for (const [sourcePath, kind] of candidates) { + const directory = path.dirname(sourcePath); + const baseName = path.basename(sourcePath); + const migratedPrefix = `${baseName}.migrated-`; + const priorImportPrefix = `${baseName}.sqlite-import.`; + let entries: string[]; + try { + entries = fs.readdirSync(directory); + } catch { + continue; + } + for (const entry of entries) { + if ( + entry.startsWith(migratedPrefix) || + (entry.startsWith(priorImportPrefix) && entry.endsWith(".bak")) + ) { + archives.push({ kind, path: path.join(directory, entry) }); + } + } + } + return archives; +} diff --git a/src/agents/auth-profiles/profiles.test.ts b/src/agents/auth-profiles/profiles.test.ts index 5feb7968e423..6d40c7b1921a 100644 --- a/src/agents/auth-profiles/profiles.test.ts +++ b/src/agents/auth-profiles/profiles.test.ts @@ -962,7 +962,8 @@ describe("promoteAuthProfileInOrder", () => { it("normalizes copied secrets when using the locked upsert path", async () => { await withAuthProfileTestState( "openclaw-auth-profile-upsert-", - async ({ agentDir }) => { + async ({ agentDirFor }) => { + const agentDir = agentDirFor("work"); fs.mkdirSync(agentDir, { recursive: true }); await upsertAuthProfileWithLock({ diff --git a/src/agents/auth-profiles/profiles.ts b/src/agents/auth-profiles/profiles.ts index 92e39e0192f4..dfaf62d2033d 100644 --- a/src/agents/auth-profiles/profiles.ts +++ b/src/agents/auth-profiles/profiles.ts @@ -198,6 +198,7 @@ export function upsertAuthProfile(params: { store.profiles[params.profileId] = credential; saveAuthProfileStore(store, params.agentDir, { filterExternalAuthProfiles: false, + sharedStoreWrite: true, syncExternalCli: false, }); } diff --git a/src/agents/auth-profiles/shared-store-bootstrap.ts b/src/agents/auth-profiles/shared-store-bootstrap.ts new file mode 100644 index 000000000000..f621940803ee --- /dev/null +++ b/src/agents/auth-profiles/shared-store-bootstrap.ts @@ -0,0 +1,190 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { DatabaseSync } from "node:sqlite"; +import { hasErrnoCode } from "../../infra/errno.js"; +import { executeSqliteQueryTakeFirstSync, getNodeSqliteKysely } from "../../infra/kysely-sync.js"; +import { openNodeSqliteDatabase } from "../../infra/node-sqlite.js"; +import { writeConfigMachineState } from "../../state/config-machine-state.js"; +import type { DB as OpenClawAgentKyselyDatabase } from "../../state/openclaw-agent-db.generated.js"; +import { withExistingOpenClawStateDatabaseReadOnly } from "../../state/openclaw-state-db-readonly.js"; +import { tableExists } from "../../state/openclaw-state-db-schema-helpers.js"; +import type { DB as OpenClawStateKyselyDatabase } from "../../state/openclaw-state-db.generated.js"; +import { resolveUserPath } from "../../utils.js"; +import { listLegacyAuthProfileSources } from "./legacy-source-files.js"; +import { + noteCommittedSharedAuthStoreOwnership, + resolveSharedAuthStoreOwnership, + SHARED_AUTH_STORE_STATE_KEY, + type SharedAuthStoreOwnership, +} from "./path-resolve.js"; +import { resolveSharedMainAuthAgentDir } from "./shared-main-dir.js"; + +const PRIMARY_ROW_KEY = "primary"; +const SHARED_AUTH_STORE_MIGRATION_KIND = "shared-auth-store-state-db"; + +// Ownership objects are process-stable per state root. Doctor replaces the cached object +// after relocation, so legacy inspection is memoized only for that ownership generation. +const inspectedLegacySharedAuthOwnerships = new WeakSet(); + +type SourceAuthDatabase = Pick< + OpenClawAgentKyselyDatabase, + "auth_profile_store" | "auth_profile_state" +>; +type SharedAuthMigrationDatabase = Pick; + +export type SharedAuthLegacyStoreRow = { store_json: string; updated_at: number }; +export type SharedAuthLegacyStateRow = { state_json: string; updated_at: number }; +export type SharedAuthLegacyRows = { + store: SharedAuthLegacyStoreRow | null; + state: SharedAuthLegacyStateRow | null; +}; + +export class SharedAuthStoreSourceInspectionError extends Error { + readonly code = "SHARED_AUTH_STORE_SOURCE_UNREADABLE" as const; + readonly action = "openclaw doctor --fix" as const; + readonly sourcePath: string; + + constructor(sourcePath: string, operation: string, cause: unknown) { + const detail = cause instanceof Error ? cause.message : String(cause); + super(`Cannot ${operation} legacy shared auth database ${sourcePath}: ${detail}`, { cause }); + this.name = "SharedAuthStoreSourceInspectionError"; + this.sourcePath = sourcePath; + } +} + +export function inspectSharedAuthLegacySourceFile( + sourcePath: string, +): { status: "missing" } | { status: "present"; size: number } { + let entry: fs.Stats; + try { + entry = fs.lstatSync(sourcePath); + } catch (error) { + if (hasErrnoCode(error, "ENOENT")) { + return { status: "missing" }; + } + throw new SharedAuthStoreSourceInspectionError(sourcePath, "inspect", error); + } + let target = entry; + if (entry.isSymbolicLink()) { + try { + target = fs.statSync(sourcePath); + } catch (error) { + throw new SharedAuthStoreSourceInspectionError(sourcePath, "resolve", error); + } + } + if (!target.isFile()) { + throw new SharedAuthStoreSourceInspectionError( + sourcePath, + "open", + new Error("path is not a regular file"), + ); + } + return { status: "present", size: target.size }; +} + +export function readSharedAuthLegacyRowsFromDatabase(database: DatabaseSync): SharedAuthLegacyRows { + const db = getNodeSqliteKysely(database); + const store = tableExists(database, "auth_profile_store") + ? (executeSqliteQueryTakeFirstSync( + database, + db + .selectFrom("auth_profile_store") + .select(["store_json", "updated_at"]) + .where("store_key", "=", PRIMARY_ROW_KEY), + ) ?? null) + : null; + const state = tableExists(database, "auth_profile_state") + ? (executeSqliteQueryTakeFirstSync( + database, + db + .selectFrom("auth_profile_state") + .select(["state_json", "updated_at"]) + .where("state_key", "=", PRIMARY_ROW_KEY), + ) ?? null) + : null; + return { store, state }; +} + +export function inspectSharedAuthLegacyRowsReadOnly(sourcePath: string): SharedAuthLegacyRows { + if (inspectSharedAuthLegacySourceFile(sourcePath).status === "missing") { + return { store: null, state: null }; + } + let database: DatabaseSync; + try { + database = openNodeSqliteDatabase(sourcePath, { readOnly: true }); + } catch (error) { + throw new SharedAuthStoreSourceInspectionError(sourcePath, "open", error); + } + try { + return readSharedAuthLegacyRowsFromDatabase(database); + } catch (error) { + throw new SharedAuthStoreSourceInspectionError(sourcePath, "read", error); + } finally { + database.close(); + } +} + +export function hasPendingSharedAuthCleanup(env: NodeJS.ProcessEnv, sourcePath: string): boolean { + return ( + withExistingOpenClawStateDatabaseReadOnly( + ({ db: database }) => { + const db = getNodeSqliteKysely(database); + const row = executeSqliteQueryTakeFirstSync( + database, + db + .selectFrom("migration_sources") + .select("source_key") + .where("migration_kind", "=", SHARED_AUTH_STORE_MIGRATION_KIND) + .where("source_path", "=", sourcePath) + .where("removed_source", "=", 0) + .limit(1), + ); + return Boolean(row); + }, + { env }, + ) ?? false + ); +} + +function initializeFreshSharedAuthStore(env: NodeJS.ProcessEnv): void { + const ownership = resolveSharedAuthStoreOwnership(env); + if (ownership.location === "state-db" || inspectedLegacySharedAuthOwnerships.has(ownership)) { + return; + } + const sourcePath = path.join(resolveSharedMainAuthAgentDir(env), "openclaw-agent.sqlite"); + try { + if (listLegacyAuthProfileSources({ env }).length > 0) { + inspectedLegacySharedAuthOwnerships.add(ownership); + return; + } + const rows = inspectSharedAuthLegacyRowsReadOnly(sourcePath); + if (rows.store || rows.state || hasPendingSharedAuthCleanup(env, sourcePath)) { + inspectedLegacySharedAuthOwnerships.add(ownership); + return; + } + } catch { + // Doctor owns unreadable or partially migrated legacy state; never infer past it. + inspectedLegacySharedAuthOwnerships.add(ownership); + return; + } + writeConfigMachineState(SHARED_AUTH_STORE_STATE_KEY, { location: "state-db" }, { env }); + noteCommittedSharedAuthStoreOwnership({ location: "state-db" }, env); +} + +export function prepareFreshSharedAuthStoreWrite(params: { + agentDir: string | undefined; + allowExplicitMain: boolean; + env: NodeJS.ProcessEnv; +}): boolean { + // A main-agent credential is shared; explicit main writes must follow the shared target. + // On legacy roots both routes already resolve to the same file, so redirecting is a no-op. + const isSharedWrite = + params.agentDir === undefined || + (params.allowExplicitMain && + path.resolve(resolveUserPath(params.agentDir, params.env)) === + path.resolve(resolveSharedMainAuthAgentDir(params.env))); + if (isSharedWrite) { + initializeFreshSharedAuthStore(params.env); + } + return isSharedWrite; +} diff --git a/src/agents/auth-profiles/sqlite.ts b/src/agents/auth-profiles/sqlite.ts index 2973e3414339..709e3720a077 100644 --- a/src/agents/auth-profiles/sqlite.ts +++ b/src/agents/auth-profiles/sqlite.ts @@ -38,6 +38,7 @@ import { import { resolveUserPath } from "../../utils.js"; import { resolveRegisteredAgentIdForDir } from "../agent-dir-registry.js"; import { resolveSharedAuthStoreOwnership, resolveSharedAuthStorePath } from "./path-resolve.js"; +import { prepareFreshSharedAuthStoreWrite } from "./shared-store-bootstrap.js"; type AgentAuthProfileDatabase = Pick< OpenClawAgentKyselyDatabase, @@ -166,11 +167,13 @@ function resolveAuthProfileDatabaseKind( agentDir: string | undefined, database?: Pick, ): AuthProfileDatabaseTarget["kind"] { - return agentDir !== undefined - ? "agent" - : database && !("agentId" in database) - ? "shared-state" - : resolveAuthProfileDatabaseOptions(agentDir).kind; + if (database && "agentId" in database) { + return "agent"; + } + if (database && "path" in database) { + return "shared-state"; + } + return resolveAuthProfileDatabaseOptions(agentDir).kind; } function inspectAuthProfileTable( @@ -658,12 +661,24 @@ export function writePersistedAuthProfileStateRaw( export function runAuthProfileWriteTransaction( agentDir: string | undefined, operation: (database: AuthProfileDatabase) => T, - options: { env?: NodeJS.ProcessEnv; stateDir?: string } = {}, + options: { + env?: NodeJS.ProcessEnv; + sharedStoreWrite?: boolean; + stateDir?: string; + } = {}, ): T { const env = options.env ?? (options.stateDir ? { ...process.env, OPENCLAW_STATE_DIR: options.stateDir } : process.env); - const databaseTarget = resolveAuthProfileDatabaseOptions(agentDir, env); + const sharedStoreWrite = prepareFreshSharedAuthStoreWrite({ + agentDir, + allowExplicitMain: options.sharedStoreWrite === true, + env, + }); + const databaseTarget = resolveAuthProfileDatabaseOptions( + sharedStoreWrite ? undefined : agentDir, + env, + ); if (databaseTarget.kind === "agent") { return runOpenClawAgentWriteTransaction(operation, databaseTarget); } diff --git a/src/agents/auth-profiles/store.ts b/src/agents/auth-profiles/store.ts index 6399fc8105cc..7e5b78ba46e0 100644 --- a/src/agents/auth-profiles/store.ts +++ b/src/agents/auth-profiles/store.ts @@ -91,6 +91,7 @@ type SaveAuthProfileStoreOptions = { preserveOrderProfileIds?: Iterable; preserveStateProfileIds?: Iterable; pruneOrderProfileIds?: Iterable; + sharedStoreWrite?: boolean; syncExternalCli?: boolean; }; @@ -864,6 +865,7 @@ function mergeRuntimeExternalProfileState(params: { /** Apply an auth store update inside the SQLite write lock. */ export async function updateAuthProfileStoreWithLock(params: { agentDir?: string; + sharedStoreWrite?: boolean; stateDir?: string; saveOptions?: SaveAuthProfileStoreOptions; updater: (store: AuthProfileStore) => boolean; @@ -891,7 +893,7 @@ export async function updateAuthProfileStoreWithLock(params: { } return loadedStore; }, - { stateDir: params.stateDir }, + { sharedStoreWrite: params.sharedStoreWrite, stateDir: params.stateDir }, ); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -1315,25 +1317,30 @@ function saveAuthProfileStoreInTransaction( database: AuthProfileDatabase, publishFromSuppliedStore = false, ): () => void { - const savedAuthPath = agentDir ? resolveAgentAuthPath(agentDir) : database.path; - const mainAuthPath = agentDir ? resolveSharedAuthPath() : database.path; + // Shared-state rows are global: never scope their persistence or runtime snapshots to an + // agent, or shared credentials are published and cached as agent-local state. + const persistenceAgentDir = "agentId" in database ? agentDir : undefined; + const savedAuthPath = persistenceAgentDir + ? resolveAgentAuthPath(persistenceAgentDir) + : database.path; + const mainAuthPath = persistenceAgentDir ? resolveSharedAuthPath() : database.path; const savesMainStore = savedAuthPath === mainAuthPath; - const loadedPersistedStores = loadPersistedAuthProfileStores(agentDir, database); + const loadedPersistedStores = loadPersistedAuthProfileStores(persistenceAgentDir, database); const persistedStores: PersistedAuthProfileStores = { ...loadedPersistedStores, localStore: loadedPersistedStores.localStore ?? { version: AUTH_STORE_VERSION, profiles: {}, - ...loadPersistedAuthProfileState(agentDir, database), + ...loadPersistedAuthProfileState(persistenceAgentDir, database), }, }; const localStore = buildLocalAuthProfileStoreForSave({ store, - agentDir, + agentDir: persistenceAgentDir, options, persistedStores, }); - const existingRaw = readPersistedAuthProfileStoreRaw(agentDir, database); + const existingRaw = readPersistedAuthProfileStoreRaw(persistenceAgentDir, database); const payload = preserveLegacyOAuthRefsOnSave({ payload: buildPersistedAuthProfileSecretsStore(localStore), existingRaw, @@ -1352,20 +1359,25 @@ function saveAuthProfileStoreInTransaction( const credentialsChanged = !isDeepStrictEqual(existingRaw, payload); const statePayload = buildPersistedAuthProfileState(localStore); const stateChanged = !isDeepStrictEqual( - readPersistedAuthProfileStateRaw(agentDir, database), + readPersistedAuthProfileStateRaw(persistenceAgentDir, database), statePayload, ); const suppliedRuntimeStore = publishFromSuppliedStore ? markRuntimePersistedProfiles( - buildRuntimeAuthProfileStoreForSave({ store, agentDir, options, persistedStores }), + buildRuntimeAuthProfileStoreForSave({ + store, + agentDir: persistenceAgentDir, + options, + persistedStores, + }), localStore, ) : undefined; if (credentialsChanged) { - writePersistedAuthProfileStoreRaw(payload, agentDir, database); + writePersistedAuthProfileStoreRaw(payload, persistenceAgentDir, database); } if (stateChanged) { - writePersistedAuthProfileStateRaw(statePayload, agentDir, database); + writePersistedAuthProfileStateRaw(statePayload, persistenceAgentDir, database); } const publishRuntimeSnapshots = () => { // Main-store publication invalidates derived stores. Capture the latest @@ -1376,7 +1388,7 @@ function saveAuthProfileStoreInTransaction( ) : []; if (credentialsChanged || stateChanged) { - noteRuntimeAuthProfileStorePersistedMutation(agentDir, { + noteRuntimeAuthProfileStorePersistedMutation(persistenceAgentDir, { credentialsChanged, profileSetChanged, stateChanged, @@ -1384,7 +1396,7 @@ function saveAuthProfileStoreInTransaction( }); } if (suppliedRuntimeStore) { - const existing = getRuntimeAuthProfileStoreSnapshot(agentDir); + const existing = getRuntimeAuthProfileStoreSnapshot(persistenceAgentDir); if (existing) { const materialized = preserveResolvedSecretBackedCredentials({ next: suppliedRuntimeStore, @@ -1392,7 +1404,7 @@ function saveAuthProfileStoreInTransaction( }); setRuntimeAuthProfileStoreSnapshot( mergeRuntimeExternalProfileReferences({ next: materialized, existing }), - agentDir, + persistenceAgentDir, ); } if (savesMainStore && (credentialsChanged || stateChanged)) { @@ -1411,7 +1423,7 @@ function saveAuthProfileStoreInTransaction( } return; } - refreshRuntimeAuthProfileStoreSnapshot(agentDir); + refreshRuntimeAuthProfileStoreSnapshot(persistenceAgentDir); for (const derived of derivedSnapshots) { const refreshed = loadAuthProfileStoreWithoutExternalProfiles(derived.agentDir); const materialized = preserveResolvedSecretBackedCredentials({ @@ -1454,14 +1466,18 @@ export function saveAuthProfileStore( return; } let publishRuntimeSnapshots: (() => void) | undefined; - runAuthProfileWriteTransaction(effectiveAgentDir, (transactionDatabase) => { - publishRuntimeSnapshots = saveAuthProfileStoreInTransaction( - store, - effectiveAgentDir, - options, - transactionDatabase, - ); - }); + runAuthProfileWriteTransaction( + effectiveAgentDir, + (transactionDatabase) => { + publishRuntimeSnapshots = saveAuthProfileStoreInTransaction( + store, + effectiveAgentDir, + options, + transactionDatabase, + ); + }, + { sharedStoreWrite: options?.sharedStoreWrite }, + ); publishRuntimeSnapshotsAfterCommit(publishRuntimeSnapshots); } diff --git a/src/agents/auth-profiles/upsert-with-lock.sqlite.test.ts b/src/agents/auth-profiles/upsert-with-lock.sqlite.test.ts index a4c37d98282e..782aead0e97d 100644 --- a/src/agents/auth-profiles/upsert-with-lock.sqlite.test.ts +++ b/src/agents/auth-profiles/upsert-with-lock.sqlite.test.ts @@ -33,10 +33,7 @@ async function withAgentDir(run: (agentDir: string) => Promise): Promise await run(agentDir), - ); + await withEnvAsync({ OPENCLAW_STATE_DIR: root }, async () => await run(agentDir)); } finally { closeOpenClawAgentDatabasesForTest(); closeOpenClawStateDatabaseForTest(); diff --git a/src/agents/auth-profiles/upsert-with-lock.ts b/src/agents/auth-profiles/upsert-with-lock.ts index dcf74a7538a9..0e762c1de318 100644 --- a/src/agents/auth-profiles/upsert-with-lock.ts +++ b/src/agents/auth-profiles/upsert-with-lock.ts @@ -84,7 +84,7 @@ export async function persistAuthProfileBatch( ); } }, - { stateDir: params.stateDir }, + { sharedStoreWrite: true, stateDir: params.stateDir }, ); let rolledBack = false; @@ -150,7 +150,7 @@ export async function persistAuthProfileBatch( writePersistedAuthProfileStateRaw(null, params.agentDir, database); } }, - { stateDir: params.stateDir }, + { sharedStoreWrite: true, stateDir: params.stateDir }, ); rolledBack = true; }, @@ -167,6 +167,7 @@ export async function upsertAuthProfileWithLock(params: { const credential = normalizeAuthProfileCredential(params.credential); return await updateAuthProfileStoreWithLock({ agentDir: params.agentDir, + sharedStoreWrite: true, stateDir: params.stateDir, saveOptions: { filterExternalAuthProfiles: false, diff --git a/src/commands/doctor-auth-flat-profiles.test.ts b/src/commands/doctor-auth-flat-profiles.test.ts index 1fdcab4493c3..f83b1066caac 100644 --- a/src/commands/doctor-auth-flat-profiles.test.ts +++ b/src/commands/doctor-auth-flat-profiles.test.ts @@ -185,6 +185,80 @@ afterEach(async () => { }); describe("maybeMigrateAuthProfileJsonStoresToSqlite", () => { + it("keeps JSON-era ownership through shared writes until Doctor imports the credential", async () => { + const state = await makeTestState(); + const authPath = await writeLegacyAuthProfilesJson(state, { + version: 1, + profiles: { + "openai:json-era": { + type: "api_key", + provider: "openai", + key: "sk-json-era", + }, + }, + }); + const legacyDatabasePath = path.join(state.agentDir(), "openclaw-agent.sqlite"); + expect(fs.existsSync(legacyDatabasePath)).toBe(false); + + const realExistsSync = fs.existsSync.bind(fs); + let legacyJsonProbes = 0; + const existsSpy = vi.spyOn(fs, "existsSync").mockImplementation((pathname) => { + if (path.resolve(String(pathname)) === path.resolve(authPath)) { + legacyJsonProbes += 1; + } + return realExistsSync(pathname); + }); + try { + for (const key of ["sk-first-write", "sk-second-write"]) { + writePersistedAuthProfileStoreRaw({ + version: 1, + profiles: { + "anthropic:written": { + type: "api_key", + provider: "anthropic", + key, + }, + }, + }); + } + expect(legacyJsonProbes).toBe(1); + } finally { + existsSpy.mockRestore(); + } + + const beforeDoctor = openOpenClawStateDatabase({ env: state.env }); + expect( + beforeDoctor.db + .prepare("SELECT value_json FROM config_machine_state WHERE state_key = ?") + .get("auth.sharedStore"), + ).toBeUndefined(); + expect(fs.existsSync(legacyDatabasePath)).toBe(true); + + const result = await maybeMigrateAuthProfileJsonStoresToSqlite({ + cfg: {}, + prompter: makePrompter(true), + env: state.env, + now: () => 123, + }); + + expect(result.warnings).toStrictEqual([]); + expect(result.changes).toEqual([expect.stringContaining("Migrated auth profile JSON")]); + expect(loadPersistedAuthProfileStore(state.agentDir())?.profiles).toMatchObject({ + "openai:json-era": { + type: "api_key", + provider: "openai", + key: "sk-json-era", + }, + "anthropic:written": { + type: "api_key", + provider: "anthropic", + key: "sk-second-write", + }, + }); + expect(fs.existsSync(authPath)).toBe(false); + expectMigratedArchive(authPath); + }); + it("migrates the inherited auth owner after it leaves the explicit roster", async () => { const state = await makeTestState(); const authPath = await writeLegacyAuthProfilesJson( diff --git a/src/commands/doctor-auth-flat-profiles.ts b/src/commands/doctor-auth-flat-profiles.ts index 790a681a5eef..2a2af3c91406 100644 --- a/src/commands/doctor-auth-flat-profiles.ts +++ b/src/commands/doctor-auth-flat-profiles.ts @@ -989,6 +989,13 @@ export async function maybeMigrateAuthProfileJsonStoresToSqlite(params: { const sharedStateTarget = candidate.agentDir === undefined && resolveSharedAuthStoreOwnership(env).location === "state-db"; + // A shared candidate on a legacy root names the main agent dir explicitly: it resolves to the + // same database, but an undefined agent dir would enter the shared-write bootstrap and could + // record state-db ownership midway through this import. Doctor stays the only owner of that flip. + const transactionAgentDir = + sharedStateTarget || candidate.agentDir !== undefined + ? candidate.agentDir + : resolveSharedMainAuthAgentDir(env); let sourceReceipts = candidateSourcePaths.filter(fs.existsSync).map((pathname) => prepareAuthProfileSourceReceipt({ pathname, @@ -1160,7 +1167,7 @@ export async function maybeMigrateAuthProfileJsonStoresToSqlite(params: { try { assertAuthProfileMigrationSourcesUnchanged(candidate, sourceReceipts); verifiedStore = runAuthProfileWriteTransaction( - candidate.agentDir, + transactionAgentDir, (database) => { const authoritative = loadAuthProfileMigrationTargetStore( candidate.agentDir, diff --git a/src/commands/doctor-model-catalog-credentials.test.ts b/src/commands/doctor-model-catalog-credentials.test.ts index 0e5fff854cca..417166e51790 100644 --- a/src/commands/doctor-model-catalog-credentials.test.ts +++ b/src/commands/doctor-model-catalog-credentials.test.ts @@ -108,7 +108,22 @@ describe("doctor model catalog credential migration", () => { expect(first.migrated).toBe(3); expect(first.warnings).toEqual([]); expect(cfg.models?.providers?.configured?.apiKey).toBe("configured-secret"); - expect(loadPersistedAuthProfileStore(agentDir)?.profiles).toMatchObject({ + // A fresh root records state-db shared ownership, so the migrated credentials persist in the + // shared store rather than the agent file. Read through the owner for this state root instead of + // pinning the storage layout. + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + process.env.OPENCLAW_STATE_DIR = state.stateDir; + let migratedProfiles: Record; + try { + migratedProfiles = loadPersistedAuthProfileStore(undefined)?.profiles ?? {}; + } finally { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + } + expect(migratedProfiles).toMatchObject({ "configured:default": { type: "api_key", provider: "configured", diff --git a/src/commands/onboard-auth.test.ts b/src/commands/onboard-auth.test.ts index c79230055a89..618fbbce57ad 100644 --- a/src/commands/onboard-auth.test.ts +++ b/src/commands/onboard-auth.test.ts @@ -8,6 +8,7 @@ import { readAuthProfilesForAgent, setupAuthTestEnv, } from "../../test/helpers/auth-wizard.js"; +import { ensureAuthProfileStore } from "../agents/auth-profiles/store.js"; import type { OAuthCredentials } from "../llm/utils/oauth/types.js"; import { applyAuthProfileConfig, @@ -62,6 +63,13 @@ function expectFields(value: unknown, expected: Record, label = return record; } +function readEffectiveAuthProfiles(agentDir: string) { + return ensureAuthProfileStore(agentDir, { + readOnly: true, + syncExternalCli: false, + }); +} + describe("writeOAuthCredentials", () => { const lifecycle = createAuthTestLifecycle([ "OPENCLAW_STATE_DIR", @@ -125,19 +133,23 @@ describe("writeOAuthCredentials", () => { }); for (const dir of [mainAgentDir, kidAgentDir]) { - const persistedStore = await readAuthProfilesForAgent<{ - profiles?: Record; - }>(dir); - expectFields(persistedStore.profiles?.["openai:default"], { + const effectiveStore = readEffectiveAuthProfiles(dir); + expectFields(effectiveStore.profiles?.["openai:default"], { refresh: "refresh-sync", access: "access-sync", type: "oauth", }); } - const inheritedSiblingStore = await readAuthProfilesForAgent<{ + const inheritedSiblingStore = readEffectiveAuthProfiles(workerAgentDir); + expectFields(inheritedSiblingStore.profiles?.["openai:default"], { + refresh: "refresh-sync", + access: "access-sync", + type: "oauth", + }); + const persistedSiblingStore = await readAuthProfilesForAgent<{ profiles?: Record; }>(workerAgentDir); - expect(inheritedSiblingStore.profiles).toEqual({}); + expect(persistedSiblingStore.profiles).toEqual({}); }); it("writes OAuth credentials only to target dir by default", async () => { @@ -160,9 +172,7 @@ describe("writeOAuthCredentials", () => { await writeOAuthCredentials("openai", creds, kidAgentDir); - const kidParsed = await readAuthProfilesForAgent<{ - profiles?: Record; - }>(kidAgentDir); + const kidParsed = readEffectiveAuthProfiles(kidAgentDir); expectFields(kidParsed.profiles?.["openai:default"], { access: "access-kid", type: "oauth", @@ -239,16 +249,20 @@ describe("upsertApiKeyProfile secret refs", () => { agentDir: string, profileId: string, ): Promise { - const parsed = await readAuthProfilesForAgent<{ - profiles?: Record; - }>(agentDir); - return parsed.profiles?.[profileId]; + const parsed = readEffectiveAuthProfiles(agentDir); + const profile = parsed.profiles[profileId]; + if (!profile || profile.type !== "api_key") { + return undefined; + } + return { + ...(profile.key !== undefined ? { key: profile.key } : {}), + ...(profile.keyRef !== undefined ? { keyRef: profile.keyRef } : {}), + ...(profile.metadata !== undefined ? { metadata: profile.metadata } : {}), + }; } async function readProfileIds(agentDir: string): Promise { - const parsed = await readAuthProfilesForAgent<{ - profiles?: Record; - }>(agentDir); + const parsed = readEffectiveAuthProfiles(agentDir); return Object.keys(parsed.profiles ?? {}).toSorted(); } diff --git a/src/infra/state-migrations.shared-auth-store.ts b/src/infra/state-migrations.shared-auth-store.ts index 5983de40dc2f..67f9d29b78e7 100644 --- a/src/infra/state-migrations.shared-auth-store.ts +++ b/src/infra/state-migrations.shared-auth-store.ts @@ -9,6 +9,16 @@ import { SHARED_AUTH_STORE_STATE_KEY, } from "../agents/auth-profiles/path-resolve.js"; import { resolveSharedMainAuthAgentDir } from "../agents/auth-profiles/shared-main-dir.js"; +import { + hasPendingSharedAuthCleanup, + inspectSharedAuthLegacyRowsReadOnly, + inspectSharedAuthLegacySourceFile, + readSharedAuthLegacyRowsFromDatabase, + SharedAuthStoreSourceInspectionError, + type SharedAuthLegacyRows as AuthRows, + type SharedAuthLegacyStateRow as StateRow, + type SharedAuthLegacyStoreRow as StoreRow, +} from "../agents/auth-profiles/shared-store-bootstrap.js"; import { closeAuthProfileReadPool, resolveAuthProfileDatabaseOwnerId, @@ -18,8 +28,6 @@ import { closeOpenClawAgentDatabaseByPath, runOpenClawAgentWriteTransaction, } from "../state/openclaw-agent-db.js"; -import { withExistingOpenClawStateDatabaseReadOnly } from "../state/openclaw-state-db-readonly.js"; -import { tableExists as sqliteTableExists } from "../state/openclaw-state-db-schema-helpers.js"; import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js"; import { runOpenClawStateWriteTransaction } from "../state/openclaw-state-db.js"; import { @@ -27,7 +35,6 @@ import { executeSqliteQueryTakeFirstSync, getNodeSqliteKysely, } from "./kysely-sync.js"; -import { openNodeSqliteDatabase } from "./node-sqlite.js"; import { withLegacyMigrationStateLock } from "./state-migrations.lock.js"; import type { SharedAuthStoreMigrationDetection } from "./state-migrations.shared-auth-store.types.js"; import type { MigrationMessages } from "./state-migrations.types.js"; @@ -50,24 +57,8 @@ type SharedAuthMigrationDatabase = Pick< | "migration_sources" >; -type StoreRow = { store_json: string; updated_at: number }; -type StateRow = { state_json: string; updated_at: number }; -type AuthRows = { store: StoreRow | null; state: StateRow | null }; type MigrationStage = "copied" | "ownership-flipped" | "completed"; -class SharedAuthStoreSourceInspectionError extends Error { - readonly code = "SHARED_AUTH_STORE_SOURCE_UNREADABLE" as const; - readonly action = "openclaw doctor --fix" as const; - readonly sourcePath: string; - - constructor(sourcePath: string, operation: string, cause: unknown) { - const detail = cause instanceof Error ? cause.message : String(cause); - super(`Cannot ${operation} legacy shared auth database ${sourcePath}: ${detail}`, { cause }); - this.name = "SharedAuthStoreSourceInspectionError"; - this.sourcePath = sourcePath; - } -} - function sourceMigrationKey(sourcePath: string, sourceTable: string): string { return `shared-auth-store:${createHash("sha256") .update(path.resolve(sourcePath)) @@ -76,90 +67,17 @@ function sourceMigrationKey(sourcePath: string, sourceTable: string): string { .digest("hex")}`; } -function inspectSourceFile( - sourcePath: string, -): { status: "missing" } | { status: "present"; size: number } { - let entry: fs.Stats; - try { - entry = fs.lstatSync(sourcePath); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return { status: "missing" }; - } - throw new SharedAuthStoreSourceInspectionError(sourcePath, "inspect", error); - } - let target = entry; - if (entry.isSymbolicLink()) { - try { - target = fs.statSync(sourcePath); - } catch (error) { - throw new SharedAuthStoreSourceInspectionError(sourcePath, "resolve", error); - } - } - if (!target.isFile()) { - throw new SharedAuthStoreSourceInspectionError( - sourcePath, - "open", - new Error("path is not a regular file"), - ); - } - return { status: "present", size: target.size }; -} - -function readSourceRowsFromDatabase(database: DatabaseSync): AuthRows { - const db = getNodeSqliteKysely(database); - const store = sqliteTableExists(database, "auth_profile_store") - ? (executeSqliteQueryTakeFirstSync( - database, - db - .selectFrom("auth_profile_store") - .select(["store_json", "updated_at"]) - .where("store_key", "=", SOURCE_STORE_KEY), - ) ?? null) - : null; - const state = sqliteTableExists(database, "auth_profile_state") - ? (executeSqliteQueryTakeFirstSync( - database, - db - .selectFrom("auth_profile_state") - .select(["state_json", "updated_at"]) - .where("state_key", "=", SOURCE_STORE_KEY), - ) ?? null) - : null; - return { store, state }; -} - -function inspectSourceRowsReadOnly(sourcePath: string): AuthRows { - const source = inspectSourceFile(sourcePath); - if (source.status === "missing") { - return { store: null, state: null }; - } - let database: DatabaseSync; - try { - database = openNodeSqliteDatabase(sourcePath, { readOnly: true }); - } catch (error) { - throw new SharedAuthStoreSourceInspectionError(sourcePath, "open", error); - } - try { - return readSourceRowsFromDatabase(database); - } catch (error) { - throw new SharedAuthStoreSourceInspectionError(sourcePath, "read", error); - } finally { - database.close(); - } -} - function readSourceSnapshot(params: { env: NodeJS.ProcessEnv; sourcePath: string }): { rows: AuthRows; size: number | null; } { - const source = inspectSourceFile(params.sourcePath); + const source = inspectSharedAuthLegacySourceFile(params.sourcePath); if (source.status === "missing") { return { rows: { store: null, state: null }, size: null }; } try { const rows = runOpenClawAgentWriteTransaction( - ({ db }) => readSourceRowsFromDatabase(db), + ({ db }) => readSharedAuthLegacyRowsFromDatabase(db), { agentId: resolveAuthProfileDatabaseOwnerId(path.dirname(params.sourcePath)), path: params.sourcePath, @@ -467,14 +385,14 @@ function flipOwnership(params: { } function cleanupSourceRows(params: { env: NodeJS.ProcessEnv; sourcePath: string }): boolean { - if (inspectSourceFile(params.sourcePath).status === "missing") { + if (inspectSharedAuthLegacySourceFile(params.sourcePath).status === "missing") { return false; } try { const removed = runOpenClawAgentWriteTransaction( ({ db: database }) => { const db = getNodeSqliteKysely(database); - const before = readSourceRowsFromDatabase(database); + const before = readSharedAuthLegacyRowsFromDatabase(database); executeSqliteQuerySync( database, db.deleteFrom("auth_profile_store").where("store_key", "=", SOURCE_STORE_KEY), @@ -483,7 +401,7 @@ function cleanupSourceRows(params: { env: NodeJS.ProcessEnv; sourcePath: string database, db.deleteFrom("auth_profile_state").where("state_key", "=", SOURCE_STORE_KEY), ); - const after = readSourceRowsFromDatabase(database); + const after = readSharedAuthLegacyRowsFromDatabase(database); if (after.store || after.state) { throw new Error("legacy shared auth rows remain after cleanup"); } @@ -521,28 +439,6 @@ function finalizeMigration(params: { ); } -function hasPendingCleanup(env: NodeJS.ProcessEnv, sourcePath: string): boolean { - return ( - withExistingOpenClawStateDatabaseReadOnly( - ({ db: database }) => { - const db = getNodeSqliteKysely(database); - const row = executeSqliteQueryTakeFirstSync( - database, - db - .selectFrom("migration_sources") - .select("source_key") - .where("migration_kind", "=", MIGRATION_KIND) - .where("source_path", "=", sourcePath) - .where("removed_source", "=", 0) - .limit(1), - ); - return Boolean(row); - }, - { env }, - ) ?? false - ); -} - /** Detect relocation or unfinished cleanup only in the explicit Doctor repair path. */ export function detectSharedAuthStoreMigration(params: { stateDir: string; @@ -554,14 +450,14 @@ export function detectSharedAuthStoreMigration(params: { return { sourcePath, hasLegacy: false }; } const ownership = resolveSharedAuthStoreOwnership(env); - const sourceRows = inspectSourceRowsReadOnly(sourcePath); + const sourceRows = inspectSharedAuthLegacyRowsReadOnly(sourcePath); return { sourcePath, hasLegacy: ownership.location === "legacy-main" || sourceRows.store !== null || sourceRows.state !== null || - hasPendingCleanup(env, sourcePath), + hasPendingSharedAuthCleanup(env, sourcePath), }; }