diff --git a/src/agents/auth-profiles.sqlite-store.test.ts b/src/agents/auth-profiles.sqlite-store.test.ts index 73390ff971cb..146817c313c8 100644 --- a/src/agents/auth-profiles.sqlite-store.test.ts +++ b/src/agents/auth-profiles.sqlite-store.test.ts @@ -20,6 +20,7 @@ import { withEnvAsync } from "../test-utils/env.js"; import { resolveAgentDir } from "./agent-scope.js"; import { loadPersistedAuthProfileStore } from "./auth-profiles/persisted.js"; import { + inspectPersistedAuthProfileStateRaw, inspectPersistedAuthProfileStoreRaw, resolveAuthProfileDatabasePath, } from "./auth-profiles/sqlite.js"; @@ -179,6 +180,31 @@ describe("auth profile sqlite store", () => { }); }); + it("classifies each missing auth table through an existing database handle", async () => { + await withAgentDirEnv("openclaw-auth-sqlite-partial-schema-", (agentDir) => { + const database = new DatabaseSync(resolveAuthProfileDatabasePath(agentDir)); + database.exec(` + CREATE TABLE auth_profile_store ( + store_key TEXT NOT NULL PRIMARY KEY, + store_json TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + `); + try { + expect(inspectPersistedAuthProfileStoreRaw(agentDir, { db: database })).toEqual({ + status: "missing", + reason: "row", + }); + expect(inspectPersistedAuthProfileStateRaw(agentDir, { db: database })).toEqual({ + status: "missing", + reason: "table", + }); + } finally { + database.close(); + } + }); + }); + it("rejects a newer agent database that has no current auth table", async () => { await withAgentDirEnv("openclaw-auth-sqlite-newer-schema-", (agentDir) => { const database = new DatabaseSync(resolveAuthProfileDatabasePath(agentDir)); diff --git a/src/agents/auth-profiles/sqlite.ts b/src/agents/auth-profiles/sqlite.ts index 4d66d121d7e6..dcfcaecf1d24 100644 --- a/src/agents/auth-profiles/sqlite.ts +++ b/src/agents/auth-profiles/sqlite.ts @@ -101,10 +101,30 @@ function getAuthProfileKysely(db: DatabaseSync) { return getNodeSqliteKysely(db); } +function inspectAuthProfileTable( + db: DatabaseSync, + target: "store" | "state", +): PersistedAuthProfileStoreInspection | null { + const tableName = target === "store" ? "auth_profile_store" : "auth_profile_state"; + const schemaObject = db + .prepare("SELECT type FROM sqlite_master WHERE name = ?") + .get(tableName) as { type?: unknown } | undefined; + if (!schemaObject) { + // Agent databases shipped before SQLite auth storage do not have these + // additive tables until their next writable bootstrap. + return { status: "missing", reason: "table" }; + } + return schemaObject.type === "table" ? null : { status: "unreadable" }; +} + function inspectAuthProfileJsonCell( db: DatabaseSync, target: "store" | "state", ): PersistedAuthProfileStoreInspection { + const tableInspection = inspectAuthProfileTable(db, target); + if (tableInspection) { + return tableInspection; + } const kysely = getAuthProfileKysely(db); let raw: string; if (target === "store") { @@ -153,18 +173,6 @@ function inspectAuthProfileJsonCellReadOnly( if (readSqliteUserVersion(db) > OPENCLAW_AGENT_SCHEMA_VERSION) { return { status: "unreadable" }; } - const tableName = target === "store" ? "auth_profile_store" : "auth_profile_state"; - const schemaObject = db - .prepare("SELECT type FROM sqlite_master WHERE name = ?") - .get(tableName) as { type?: unknown } | undefined; - if (!schemaObject) { - // Agent databases shipped before SQLite auth storage do not have these - // additive tables until their next writable bootstrap. - return { status: "missing", reason: "table" }; - } - if (schemaObject.type !== "table") { - return { status: "unreadable" }; - } return inspectAuthProfileJsonCell(db, target); } catch { return { status: "unreadable" }; @@ -184,7 +192,7 @@ function readAuthProfileJsonCellReadOnly(pathname: string, target: "store" | "st /** Distinguishes an absent auth row from a present store that could not be read. */ export function inspectPersistedAuthProfileStoreRaw( agentDir?: string, - database?: OpenClawAgentDatabase, + database?: Pick, ): PersistedAuthProfileStoreInspection { if (database) { return inspectAuthProfileJsonCell(database.db, "store"); @@ -199,7 +207,7 @@ export function inspectPersistedAuthProfileStoreRaw( /** Distinguishes an absent auth-state row from state that could not be read. */ export function inspectPersistedAuthProfileStateRaw( agentDir?: string, - database?: OpenClawAgentDatabase, + database?: Pick, ): PersistedAuthProfileStoreInspection { if (database) { return inspectAuthProfileJsonCell(database.db, "state"); diff --git a/test/helpers/stage-live-auth-profiles.test.ts b/test/helpers/stage-live-auth-profiles.test.ts new file mode 100644 index 000000000000..1cf7dba1528e --- /dev/null +++ b/test/helpers/stage-live-auth-profiles.test.ts @@ -0,0 +1,123 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { afterEach, describe, expect, it } from "vitest"; +import { + inspectPersistedAuthProfileStateRaw, + inspectPersistedAuthProfileStoreRaw, + resolveAuthProfileDatabasePath, + runAuthProfileWriteTransaction, + writePersistedAuthProfileStateRaw, + writePersistedAuthProfileStoreRaw, +} from "../../src/agents/auth-profiles/sqlite.js"; +import { closeOpenClawAgentDatabasesForTest } from "../../src/state/openclaw-agent-db.js"; +import { closeOpenClawStateDatabaseForTest } from "../../src/state/openclaw-state-db.js"; +import { stageLiveAuthProfiles } from "./stage-live-auth-profiles.js"; + +const tempDirs = new Set(); + +function createStateDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.add(dir); + return dir; +} + +function createAuthSource(stateDir: string): string { + const agentDir = path.join(stateDir, "agents", "main", "agent"); + runAuthProfileWriteTransaction( + agentDir, + (database) => { + writePersistedAuthProfileStoreRaw( + { + version: 1, + profiles: { + "openai:test": { + type: "api_key", + provider: "openai", + keyRef: { source: "env", provider: "default", id: "OPENCLAW_LIVE_OPENAI_KEY" }, + }, + }, + }, + agentDir, + database, + ); + writePersistedAuthProfileStateRaw( + { version: 1, order: { openai: ["openai:test"] } }, + agentDir, + database, + ); + }, + { stateDir }, + ); + closeOpenClawAgentDatabasesForTest(); + closeOpenClawStateDatabaseForTest(); + return agentDir; +} + +afterEach(() => { + closeOpenClawAgentDatabasesForTest(); + closeOpenClawStateDatabaseForTest(); + for (const dir of tempDirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } + tempDirs.clear(); +}); + +describe("stage-live-auth-profiles", () => { + it.each(["auth_profile_store", "auth_profile_state"] as const)( + "fails closed when %s is the only missing auth table", + (missingTable) => { + const sourceStateDir = createStateDir("openclaw-live-auth-partial-source-"); + const targetStateDir = createStateDir("openclaw-live-auth-partial-target-"); + const sourceAgentDir = createAuthSource(sourceStateDir); + const database = new DatabaseSync(resolveAuthProfileDatabasePath(sourceAgentDir)); + database.exec(`DROP TABLE ${missingTable};`); + database.close(); + + expect(() => stageLiveAuthProfiles(sourceStateDir, targetStateDir)).toThrow( + "canonical auth schema is incomplete", + ); + expect( + fs.existsSync( + resolveAuthProfileDatabasePath(path.join(targetStateDir, "agents", "main", "agent")), + ), + ).toBe(false); + }, + ); + + it("fails closed when both auth tables are absent", () => { + const sourceStateDir = createStateDir("openclaw-live-auth-legacy-source-"); + const targetStateDir = createStateDir("openclaw-live-auth-legacy-target-"); + const sourceAgentDir = createAuthSource(sourceStateDir); + const database = new DatabaseSync(resolveAuthProfileDatabasePath(sourceAgentDir)); + database.exec("DROP TABLE auth_profile_store; DROP TABLE auth_profile_state;"); + database.close(); + + expect(() => stageLiveAuthProfiles(sourceStateDir, targetStateDir)).toThrow( + "canonical auth schema is incomplete", + ); + expect( + fs.existsSync( + resolveAuthProfileDatabasePath(path.join(targetStateDir, "agents", "main", "agent")), + ), + ).toBe(false); + }); + + it("stages a readable store when the state row is absent", () => { + const sourceStateDir = createStateDir("openclaw-live-auth-row-source-"); + const targetStateDir = createStateDir("openclaw-live-auth-row-target-"); + const sourceAgentDir = createAuthSource(sourceStateDir); + const database = new DatabaseSync(resolveAuthProfileDatabasePath(sourceAgentDir)); + database.exec("DELETE FROM auth_profile_state;"); + database.close(); + + expect(() => stageLiveAuthProfiles(sourceStateDir, targetStateDir)).not.toThrow(); + const targetAgentDir = path.join(targetStateDir, "agents", "main", "agent"); + expect(inspectPersistedAuthProfileStoreRaw(targetAgentDir).status).toBe("readable"); + expect(inspectPersistedAuthProfileStateRaw(targetAgentDir)).toEqual({ + status: "missing", + reason: "row", + }); + }); +}); diff --git a/test/helpers/stage-live-auth-profiles.ts b/test/helpers/stage-live-auth-profiles.ts new file mode 100644 index 000000000000..d765eff648e2 --- /dev/null +++ b/test/helpers/stage-live-auth-profiles.ts @@ -0,0 +1,100 @@ +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { + inspectPersistedAuthProfileStateRaw, + inspectPersistedAuthProfileStoreRaw, + resolveAuthProfileDatabaseOwnerId, + resolveAuthProfileDatabasePath, + runAuthProfileWriteTransaction, + writePersistedAuthProfileStateRaw, + writePersistedAuthProfileStoreRaw, +} from "../../src/agents/auth-profiles/sqlite.js"; +import { withOpenClawAgentDatabaseReadOnly } from "../../src/state/openclaw-agent-db-readonly.js"; + +export function stageLiveAuthProfiles(realStateDir: string, tempStateDir: string): void { + const agentsDir = path.join(realStateDir, "agents"); + if (!fs.existsSync(agentsDir)) { + return; + } + + for (const entry of fs.readdirSync(agentsDir, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + const sourceAgentDir = path.join(agentsDir, entry.name, "agent"); + const sourceDatabasePath = resolveAuthProfileDatabasePath(sourceAgentDir); + const sourceSnapshot = withOpenClawAgentDatabaseReadOnly( + (database) => { + database.db.exec("BEGIN"); + try { + const snapshot = { + store: inspectPersistedAuthProfileStoreRaw(sourceAgentDir, database), + state: inspectPersistedAuthProfileStateRaw(sourceAgentDir, database), + }; + database.db.exec("COMMIT"); + return snapshot; + } catch (error) { + if (database.db.isTransaction) { + database.db.exec("ROLLBACK"); + } + throw error; + } + }, + { + agentId: resolveAuthProfileDatabaseOwnerId(sourceAgentDir), + path: sourceDatabasePath, + }, + ); + if (!sourceSnapshot.found) { + if (sourceSnapshot.reason === "schema-missing") { + throw new Error( + `Could not safely stage SQLite auth profiles for live agent "${entry.name}".`, + ); + } + continue; + } + const sourceStore = sourceSnapshot.value.store; + const sourceState = sourceSnapshot.value.state; + if (sourceStore.status === "unreadable" || sourceState.status === "unreadable") { + throw new Error( + `Could not safely stage SQLite auth profiles for live agent "${entry.name}".`, + ); + } + const storeTableMissing = sourceStore.status === "missing" && sourceStore.reason === "table"; + const stateTableMissing = sourceState.status === "missing" && sourceState.reason === "table"; + if (storeTableMissing || stateTableMissing) { + throw new Error( + `Could not safely stage SQLite auth profiles for live agent "${entry.name}": canonical auth schema is incomplete.`, + ); + } + if (sourceStore.status !== "readable" && sourceState.status !== "readable") { + continue; + } + + const targetAgentDir = path.join(tempStateDir, "agents", entry.name, "agent"); + fs.mkdirSync(targetAgentDir, { recursive: true }); + // Copy only canonical auth rows; cloning the agent database would expose + // unrelated sessions to the isolated live-test home. + runAuthProfileWriteTransaction( + targetAgentDir, + (database) => { + if (sourceStore.status === "readable") { + writePersistedAuthProfileStoreRaw(sourceStore.raw, targetAgentDir, database); + } + if (sourceState.status === "readable") { + writePersistedAuthProfileStateRaw(sourceState.raw, targetAgentDir, database); + } + }, + { stateDir: tempStateDir }, + ); + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + const [realStateDir, tempStateDir] = process.argv.slice(2); + if (!realStateDir || !tempStateDir) { + throw new Error("Expected source and target state directories."); + } + stageLiveAuthProfiles(realStateDir, tempStateDir); +} diff --git a/test/test-env.test.ts b/test/test-env.test.ts index 09bfeb610ebf..072422dd23a2 100644 --- a/test/test-env.test.ts +++ b/test/test-env.test.ts @@ -3,6 +3,17 @@ import fs from "node:fs"; import path from "node:path"; import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { + inspectPersistedAuthProfileStateRaw, + inspectPersistedAuthProfileStoreRaw, + resolveAuthProfileDatabasePath, + runAuthProfileWriteTransaction, + writePersistedAuthProfileStateRaw, + writePersistedAuthProfileStoreRaw, +} from "../src/agents/auth-profiles/sqlite.js"; +import { closeOpenClawAgentDatabaseByPath } from "../src/state/openclaw-agent-db.js"; +import { closeOpenClawStateDatabaseByPath } from "../src/state/openclaw-state-db.js"; +import { resolveOpenClawStateSqlitePath } from "../src/state/openclaw-state-db.paths.js"; import { deleteTestEnvValue, setTestEnvValue } from "../src/test-utils/env.js"; import { cleanupTempDirs, makeTempDir } from "./helpers/temp-dir.js"; import { installTestEnv } from "./test-env.js"; @@ -121,10 +132,43 @@ describe("installTestEnv", () => { path.join(openClawHome, ".openclaw", "external-plugins", "glueclaw", "openclaw.plugin.json"), '{"id":"glueclaw"}\n', ); - writeFile( - path.join(openClawHome, ".openclaw", "agents", "main", "agent", "auth-profiles.json"), - JSON.stringify({ version: 1, profiles: { default: { provider: "openai" } } }, null, 2), + const realStateDir = path.join(openClawHome, ".openclaw"); + const realAgentDir = path.join(realStateDir, "agents", "main", "agent"); + const liveAuthStore = { + version: 1, + profiles: { + "openai:api-key": { + type: "api_key", + provider: "openai", + keyRef: { + source: "env", + provider: "default", + id: "OPENCLAW_LIVE_OPENAI_KEY", + }, + }, + }, + }; + const liveAuthState = { + version: 1, + order: { openai: ["openai:api-key"] }, + }; + runAuthProfileWriteTransaction( + realAgentDir, + (database) => { + writePersistedAuthProfileStoreRaw(liveAuthStore, realAgentDir, database); + writePersistedAuthProfileStateRaw(liveAuthState, realAgentDir, database); + }, + { stateDir: realStateDir }, ); + cleanupFns.push(() => { + closeOpenClawAgentDatabaseByPath(resolveAuthProfileDatabasePath(realAgentDir)); + closeOpenClawStateDatabaseByPath( + resolveOpenClawStateSqlitePath({ + ...process.env, + OPENCLAW_STATE_DIR: realStateDir, + }), + ); + }); writeFile(path.join(realHome, ".claude", ".credentials.json"), '{"accessToken":"token"}\n'); writeFile(path.join(realHome, ".claude", "projects", "old-session.jsonl"), "session\n"); fs.mkdirSync(path.join(realHome, ".claude", "settings.local.json"), { recursive: true }); @@ -232,11 +276,16 @@ describe("installTestEnv", () => { ), ), ).toBe(true); - expect( - fs.existsSync( - path.join(testEnv.tempHome, ".openclaw", "agents", "main", "agent", "auth-profiles.json"), - ), - ).toBe(true); + const stagedAgentDir = path.join(testEnv.tempHome, ".openclaw", "agents", "main", "agent"); + expect(inspectPersistedAuthProfileStoreRaw(stagedAgentDir)).toEqual({ + status: "readable", + raw: liveAuthStore, + }); + expect(inspectPersistedAuthProfileStateRaw(stagedAgentDir)).toEqual({ + status: "readable", + raw: liveAuthState, + }); + expect(fs.existsSync(path.join(stagedAgentDir, "auth-profiles.json"))).toBe(false); expect(fs.existsSync(path.join(testEnv.tempHome, ".claude", ".credentials.json"))).toBe(true); expect(fs.existsSync(path.join(testEnv.tempHome, ".claude", "projects"))).toBe(false); expect(fs.existsSync(path.join(testEnv.tempHome, ".claude", "settings.local.json"))).toBe( diff --git a/test/test-env.ts b/test/test-env.ts index e8e97570ca04..bb872392e017 100644 --- a/test/test-env.ts +++ b/test/test-env.ts @@ -399,14 +399,21 @@ function copyLiveAuthProfiles(realStateDir: string, tempStateDir: string): void if (!fs.existsSync(agentsDir)) { return; } - for (const entry of fs.readdirSync(agentsDir, { withFileTypes: true })) { - if (!entry.isDirectory()) { - continue; - } - const sourcePath = path.join(agentsDir, entry.name, "agent", "auth-profiles.json"); - const targetPath = path.join(tempStateDir, "agents", entry.name, "agent", "auth-profiles.json"); - copyFileIfExists(sourcePath, targetPath); - } + const liveAuthStageScript = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "helpers", + "stage-live-auth-profiles.ts", + ); + // Live workers need canonical SQLite auth without loading the database stack + // into every hermetic Vitest worker. + execFileSync( + process.execPath, + ["--import", "tsx", liveAuthStageScript, realStateDir, tempStateDir], + { + env: { ...process.env, NODE_OPTIONS: undefined }, + stdio: "pipe", + }, + ); } function stageLiveTestState(params: {