mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(test): stage live auth profiles from SQLite (#113651)
* fix(test): stage live auth from SQLite Punchcard-Session: cobalt-cedar-timber-04 * fix(test): snapshot staged auth atomically Punchcard-Session: cobalt-cedar-timber-04 * fix(test): fail closed on partial auth schema Punchcard-Session: calm-cedar-river-aa * fix(test): resolve live auth stage path lazily Punchcard-Session: calm-cedar-river-aa --------- Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
committed by
GitHub
parent
81238d0c0e
commit
dedfc01628
@@ -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));
|
||||
|
||||
@@ -101,10 +101,30 @@ function getAuthProfileKysely(db: DatabaseSync) {
|
||||
return getNodeSqliteKysely<AuthProfileDatabase>(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<OpenClawAgentDatabase, "db">,
|
||||
): 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<OpenClawAgentDatabase, "db">,
|
||||
): PersistedAuthProfileStoreInspection {
|
||||
if (database) {
|
||||
return inspectAuthProfileJsonCell(database.db, "state");
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
+57
-8
@@ -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(
|
||||
|
||||
+15
-8
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user