// Generated plugin catalogs reuse the existing per-agent SQLite cache. import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { DatabaseSync } from "node:sqlite"; import { afterEach, describe, expect, it } from "vitest"; import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js"; import { decodePluginModelCatalogRelativePathPluginId, encodePluginModelCatalogRelativePath, loadPersistedPluginModelCatalogs, loadPersistedPluginModelCatalogsReadOnly, migrateLegacyPluginModelCatalogs, PLUGIN_MODEL_CATALOG_GENERATED_BY, replacePersistedPluginModelCatalogs, } from "./plugin-model-catalog.js"; function listPersistedPluginModelCatalogs(agentDir: string) { return loadPersistedPluginModelCatalogs(agentDir).catalogs; } const tempDirs: string[] = []; function createAgentDir(): string { const agentDir = mkdtempSync(join(tmpdir(), "openclaw-plugin-model-catalog-")); tempDirs.push(agentDir); return agentDir; } function catalogContents(provider: string, apiKey?: string): string { return JSON.stringify({ generatedBy: PLUGIN_MODEL_CATALOG_GENERATED_BY, providers: { [provider]: { baseUrl: `https://${provider}.example/v1`, api: "openai-completions", ...(apiKey ? { apiKey } : {}), models: [{ id: `${provider}-model`, name: `${provider} model` }], }, }, }); } function readCatalogCacheRow( agentDir: string, pluginId: string, ): { value_json: string; updated_at: number; } { const database = new DatabaseSync(join(agentDir, "openclaw-agent.sqlite"), { readOnly: true, }); try { const row = database .prepare("SELECT value_json, updated_at FROM cache_entries WHERE scope = ? AND key = ?") .get("plugin-model-catalog-v1", pluginId) as | { value_json: string; updated_at: number } | undefined; if (!row) { throw new Error(`Missing generated catalog cache row for ${pluginId}`); } return row; } finally { database.close(); } } afterEach(() => { closeOpenClawAgentDatabasesForTest(); for (const agentDir of tempDirs.splice(0)) { rmSync(agentDir, { recursive: true, force: true }); } }); describe("SQLite-backed plugin model catalogs", () => { it("reads only named catalog rows without running migration or repair", () => { const agentDir = createAgentDir(); const zai = catalogContents("zai"); const anthropic = catalogContents("anthropic"); replacePersistedPluginModelCatalogs({ agentDir, pluginCatalogWrites: { [encodePluginModelCatalogRelativePath("zai")]: zai, [encodePluginModelCatalogRelativePath("anthropic")]: anthropic, }, }); const legacyPath = join(agentDir, encodePluginModelCatalogRelativePath("legacy")); mkdirSync(join(agentDir, "plugins", "legacy"), { recursive: true }); writeFileSync(legacyPath, catalogContents("legacy"), "utf8"); expect(loadPersistedPluginModelCatalogsReadOnly(agentDir, ["zai"])).toEqual([ { pluginId: "zai", contents: zai }, ]); expect(loadPersistedPluginModelCatalogsReadOnly(agentDir)).toEqual([ { pluginId: "anthropic", contents: anthropic }, { pluginId: "zai", contents: zai }, ]); expect(existsSync(legacyPath)).toBe(true); }); it("removes generated model rows whose API semantics cannot be derived", () => { const agentDir = createAgentDir(); const relativePath = encodePluginModelCatalogRelativePath("nvidia"); replacePersistedPluginModelCatalogs({ agentDir, pluginCatalogWrites: { [relativePath]: JSON.stringify({ generatedBy: PLUGIN_MODEL_CATALOG_GENERATED_BY, providers: { nvidia: { baseUrl: "https://integrate.api.nvidia.com/v1", apiKey: "NVIDIA_API_KEY", models: [ { id: "meta-llama/llama-3.3-70b-instruct" }, { id: "model-owned-api", api: "openai-completions" }, ], }, }, }), }, }); const persisted = listPersistedPluginModelCatalogs(agentDir); expect(persisted).toHaveLength(1); const catalog = JSON.parse(persisted[0]?.contents ?? "{}") as { providers?: { nvidia?: { api?: string; apiKey?: string; models?: Array<{ id?: string; api?: string }> }; }; }; expect(catalog.providers?.nvidia).toMatchObject({ apiKey: "NVIDIA_API_KEY", models: [{ id: "model-owned-api", api: "openai-completions" }], }); expect(catalog.providers?.nvidia).not.toHaveProperty("api"); }); it("repairs malformed persisted catalogs once without touching valid siblings", () => { const agentDir = createAgentDir(); const validNvidia = catalogContents("nvidia", "NVIDIA_API_KEY"); const validZai = catalogContents("zai", "ZAI_API_KEY"); replacePersistedPluginModelCatalogs({ agentDir, pluginCatalogWrites: { [encodePluginModelCatalogRelativePath("nvidia")]: validNvidia, [encodePluginModelCatalogRelativePath("zai")]: validZai, }, }); const malformedNvidia = JSON.stringify({ generatedBy: PLUGIN_MODEL_CATALOG_GENERATED_BY, providers: { nvidia: { baseUrl: "https://integrate.api.nvidia.com/v1", apiKey: "NVIDIA_API_KEY", models: [{ id: "meta-llama/llama-3.3-70b-instruct" }], }, }, }); const database = new DatabaseSync(join(agentDir, "openclaw-agent.sqlite")); try { database .prepare( "UPDATE cache_entries SET value_json = ?, updated_at = 42 WHERE scope = ? AND key = ?", ) .run(malformedNvidia, "plugin-model-catalog-v1", "nvidia"); } finally { database.close(); } const firstLoad = listPersistedPluginModelCatalogs(agentDir); const repairedNvidia = JSON.parse( firstLoad.find((catalog) => catalog.pluginId === "nvidia")?.contents ?? "{}", ) as { providers?: { nvidia?: { api?: string; apiKey?: string; models?: unknown[] } }; }; expect(repairedNvidia.providers?.nvidia).toMatchObject({ apiKey: "NVIDIA_API_KEY", models: [], }); expect(repairedNvidia.providers?.nvidia).not.toHaveProperty("api"); expect(firstLoad.find((catalog) => catalog.pluginId === "zai")?.contents).toBe(validZai); const repairedRow = readCatalogCacheRow(agentDir, "nvidia"); expect(repairedRow.updated_at).not.toBe(42); const secondLoad = listPersistedPluginModelCatalogs(agentDir); expect(secondLoad).toEqual(firstLoad); expect(readCatalogCacheRow(agentDir, "nvidia")).toEqual(repairedRow); }); it("re-reads a concurrent refresh that wins the repair compare-and-set", () => { const agentDir = createAgentDir(); const relativePath = encodePluginModelCatalogRelativePath("nvidia"); const refreshed = catalogContents("nvidia", "concurrently-refreshed-provider-test-key"); replacePersistedPluginModelCatalogs({ agentDir, pluginCatalogWrites: { [relativePath]: refreshed }, }); const malformed = JSON.stringify({ generatedBy: PLUGIN_MODEL_CATALOG_GENERATED_BY, providers: { nvidia: { baseUrl: "https://integrate.api.nvidia.com/v1", apiKey: "NVIDIA_API_KEY", models: [{ id: "meta-llama/llama-3.3-70b-instruct" }], }, }, }); const database = new DatabaseSync(join(agentDir, "openclaw-agent.sqlite")); try { database .prepare( "UPDATE cache_entries SET value_json = ?, updated_at = 42 WHERE scope = ? AND key = ?", ) .run(malformed, "plugin-model-catalog-v1", "nvidia"); database.exec("CREATE TABLE catalog_repair_refresh (value_json TEXT NOT NULL)"); database.prepare("INSERT INTO catalog_repair_refresh (value_json) VALUES (?)").run(refreshed); database.exec(` CREATE TRIGGER refresh_catalog_before_repair BEFORE UPDATE OF value_json ON cache_entries WHEN OLD.scope = 'plugin-model-catalog-v1' AND OLD.key = 'nvidia' AND NEW.value_json != (SELECT value_json FROM catalog_repair_refresh) BEGIN UPDATE cache_entries SET value_json = (SELECT value_json FROM catalog_repair_refresh), updated_at = 99 WHERE scope = OLD.scope AND key = OLD.key; SELECT RAISE(IGNORE); END `); } finally { database.close(); } expect(listPersistedPluginModelCatalogs(agentDir)).toEqual([ { pluginId: "nvidia", contents: refreshed }, ]); expect(readCatalogCacheRow(agentDir, "nvidia")).toEqual({ value_json: refreshed, updated_at: 99, }); }); it("does not create an agent database for a read-only cache miss", () => { const agentDir = createAgentDir(); expect(listPersistedPluginModelCatalogs(agentDir)).toEqual([]); expect(existsSync(join(agentDir, "openclaw-agent.sqlite"))).toBe(false); expect(existsSync(join(agentDir, "plugins"))).toBe(false); }); it("does not create agent state when an empty catalog is already current", () => { const agentDir = createAgentDir(); expect(replacePersistedPluginModelCatalogs({ agentDir, pluginCatalogWrites: {} })).toBe(false); expect(existsSync(join(agentDir, "openclaw-agent.sqlite"))).toBe(false); expect(existsSync(join(agentDir, "plugins"))).toBe(false); }); it("automatically migrates a released catalog before the first SQLite read", () => { const agentDir = createAgentDir(); const contents = catalogContents("zai", "released-provider-test-key"); const sourcePath = join(agentDir, encodePluginModelCatalogRelativePath("zai")); mkdirSync(join(agentDir, "plugins", "zai"), { recursive: true }); writeFileSync(sourcePath, contents, "utf8"); expect(listPersistedPluginModelCatalogs(agentDir)).toEqual([{ pluginId: "zai", contents }]); expect(existsSync(join(agentDir, "openclaw-agent.sqlite"))).toBe(true); expect(existsSync(sourcePath)).toBe(false); }); it("retires migrated credential recovery data after verifying the canonical catalog", () => { const agentDir = createAgentDir(); const contents = catalogContents("zai", "retired-released-provider-test-key"); const sourcePath = join(agentDir, encodePluginModelCatalogRelativePath("zai")); mkdirSync(join(agentDir, "plugins", "zai"), { recursive: true }); writeFileSync(sourcePath, contents, "utf8"); expect(listPersistedPluginModelCatalogs(agentDir)).toEqual([{ pluginId: "zai", contents }]); const database = new DatabaseSync(join(agentDir, "openclaw-agent.sqlite"), { readOnly: true, }); try { expect( database .prepare("SELECT value_json FROM cache_entries WHERE scope = ?") .all("plugin-model-catalog-migration-v1"), ).toEqual([]); } finally { database.close(); } }); it("retires an orphaned recovery credential after an interrupted migration", () => { const agentDir = createAgentDir(); const contents = catalogContents("zai", "interrupted-released-provider-test-key"); replacePersistedPluginModelCatalogs({ agentDir, pluginCatalogWrites: { [encodePluginModelCatalogRelativePath("zai")]: contents, }, }); const database = new DatabaseSync(join(agentDir, "openclaw-agent.sqlite")); try { database .prepare( "INSERT INTO cache_entries (scope, key, value_json, blob, expires_at, updated_at) VALUES (?, ?, ?, NULL, NULL, ?)", ) .run("plugin-model-catalog-migration-v1", "zai", contents, Date.now()); } finally { database.close(); } expect(listPersistedPluginModelCatalogs(agentDir)).toEqual([{ pluginId: "zai", contents }]); const verified = new DatabaseSync(join(agentDir, "openclaw-agent.sqlite"), { readOnly: true, }); try { expect( verified .prepare("SELECT value_json FROM cache_entries WHERE scope = ?") .all("plugin-model-catalog-migration-v1"), ).toEqual([]); } finally { verified.close(); } }); it("protects migration recovery credentials when a plugin directory cannot be inspected", () => { if (process.getuid?.() === 0) { return; } const agentDir = createAgentDir(); const contents = catalogContents("zai", "protected-released-provider-test-key"); const pluginDir = join(agentDir, "plugins", "zai"); replacePersistedPluginModelCatalogs({ agentDir, pluginCatalogWrites: { [encodePluginModelCatalogRelativePath("zai")]: contents, }, }); const database = new DatabaseSync(join(agentDir, "openclaw-agent.sqlite")); try { database .prepare( "INSERT INTO cache_entries (scope, key, value_json, blob, expires_at, updated_at) VALUES (?, ?, ?, NULL, NULL, ?)", ) .run("plugin-model-catalog-migration-v1", "zai", contents, Date.now()); } finally { database.close(); } mkdirSync(pluginDir, { recursive: true }); writeFileSync(join(pluginDir, "catalog.json"), contents, "utf8"); chmodSync(pluginDir, 0o000); try { expect(migrateLegacyPluginModelCatalogs({ agentDir })).toEqual({ detected: 0, migrated: 0, warnings: [expect.stringContaining("Could not inspect legacy provider catalogs")], }); const verified = new DatabaseSync(join(agentDir, "openclaw-agent.sqlite"), { readOnly: true, }); try { expect( verified .prepare("SELECT value_json FROM cache_entries WHERE scope = ? AND key = ?") .all("plugin-model-catalog-migration-v1", "zai"), ).toEqual([{ value_json: contents }]); } finally { verified.close(); } } finally { chmodSync(pluginDir, 0o700); } expect(listPersistedPluginModelCatalogs(agentDir)).toEqual([{ pluginId: "zai", contents }]); }); it("detects a released catalog that appears after an earlier empty scan", () => { const agentDir = createAgentDir(); expect(listPersistedPluginModelCatalogs(agentDir)).toEqual([]); const contents = catalogContents("zai", "late-released-provider-test-key"); const sourcePath = join(agentDir, encodePluginModelCatalogRelativePath("zai")); mkdirSync(join(agentDir, "plugins", "zai"), { recursive: true }); writeFileSync(sourcePath, contents, "utf8"); expect(listPersistedPluginModelCatalogs(agentDir)).toEqual([{ pluginId: "zai", contents }]); expect(existsSync(sourcePath)).toBe(false); }); it("migrates a later provider catalog after an earlier migration completed", () => { const agentDir = createAgentDir(); const zai = catalogContents("zai", "released-zai-provider-test-key"); const zaiPath = join(agentDir, encodePluginModelCatalogRelativePath("zai")); mkdirSync(join(agentDir, "plugins", "zai"), { recursive: true }); writeFileSync(zaiPath, zai, "utf8"); expect(listPersistedPluginModelCatalogs(agentDir)).toEqual([ { pluginId: "zai", contents: zai }, ]); const anthropic = catalogContents("anthropic", "late-anthropic-provider-test-key"); const anthropicPath = join(agentDir, encodePluginModelCatalogRelativePath("anthropic")); mkdirSync(join(agentDir, "plugins", "anthropic"), { recursive: true }); writeFileSync(anthropicPath, anthropic, "utf8"); expect(listPersistedPluginModelCatalogs(agentDir)).toEqual([ { pluginId: "anthropic", contents: anthropic }, { pluginId: "zai", contents: zai }, ]); expect(existsSync(zaiPath)).toBe(false); expect(existsSync(anthropicPath)).toBe(false); }); it("migrates a changed later sidecar for an already migrated provider", () => { const agentDir = createAgentDir(); const pluginDir = join(agentDir, "plugins", "zai"); const sourcePath = join(agentDir, encodePluginModelCatalogRelativePath("zai")); const original = catalogContents("zai", "first-released-provider-test-key"); const later = catalogContents("zai", "later-released-provider-test-key"); mkdirSync(pluginDir, { recursive: true }); writeFileSync(sourcePath, original, "utf8"); expect(listPersistedPluginModelCatalogs(agentDir)).toEqual([ { pluginId: "zai", contents: original }, ]); writeFileSync(sourcePath, later, "utf8"); expect(listPersistedPluginModelCatalogs(agentDir)).toEqual([ { pluginId: "zai", contents: later }, ]); expect(existsSync(sourcePath)).toBe(false); }); it("preserves released credentials and unrelated regenerated SQLite catalogs", () => { const agentDir = createAgentDir(); const regenerated = catalogContents("zai", "regenerated-provider-test-key"); const released = catalogContents("zai", "released-provider-test-key"); const unrelated = catalogContents("anthropic", "unrelated-provider-test-key"); replacePersistedPluginModelCatalogs({ agentDir, pluginCatalogWrites: { [encodePluginModelCatalogRelativePath("zai")]: regenerated, [encodePluginModelCatalogRelativePath("anthropic")]: unrelated, }, }); const sourcePath = join(agentDir, encodePluginModelCatalogRelativePath("zai")); mkdirSync(join(agentDir, "plugins", "zai"), { recursive: true }); writeFileSync(sourcePath, released, "utf8"); expect( migrateLegacyPluginModelCatalogs({ agentDir, expectedContents: new Map([["zai", released]]), }), ).toEqual({ detected: 1, migrated: 1, warnings: [] }); expect(listPersistedPluginModelCatalogs(agentDir)).toEqual([ { pluginId: "anthropic", contents: unrelated }, { pluginId: "zai", contents: released }, ]); expect(existsSync(sourcePath)).toBe(false); }); it("recovers a released sidecar that appears after a catalog replacement was planned", () => { const agentDir = createAgentDir(); const released = catalogContents("zai", "released-provider-test-key"); const preplanned = catalogContents("zai", "preplanned-provider-test-key"); const sourcePath = join(agentDir, encodePluginModelCatalogRelativePath("zai")); mkdirSync(join(agentDir, "plugins", "zai"), { recursive: true }); writeFileSync(sourcePath, released, "utf8"); replacePersistedPluginModelCatalogs({ agentDir, pluginCatalogWrites: { [encodePluginModelCatalogRelativePath("zai")]: preplanned, }, }); expect(existsSync(sourcePath)).toBe(true); expect(listPersistedPluginModelCatalogs(agentDir)).toEqual([ { pluginId: "zai", contents: released }, ]); expect(existsSync(sourcePath)).toBe(false); }); it("treats an already-completed legacy migration as safe to repeat", () => { const agentDir = createAgentDir(); const contents = catalogContents("zai", "released-provider-test-key"); const sourcePath = join(agentDir, encodePluginModelCatalogRelativePath("zai")); mkdirSync(join(agentDir, "plugins", "zai"), { recursive: true }); writeFileSync(sourcePath, contents, "utf8"); expect(migrateLegacyPluginModelCatalogs({ agentDir })).toEqual({ detected: 1, migrated: 1, warnings: [], }); expect(migrateLegacyPluginModelCatalogs({ agentDir })).toEqual({ detected: 0, migrated: 0, warnings: [], }); expect(listPersistedPluginModelCatalogs(agentDir)).toEqual([{ pluginId: "zai", contents }]); }); it("accepts a sidecar already migrated and removed by another process", () => { const agentDir = createAgentDir(); const contents = catalogContents("zai", "concurrently-migrated-provider-test-key"); replacePersistedPluginModelCatalogs({ agentDir, pluginCatalogWrites: { [encodePluginModelCatalogRelativePath("zai")]: contents, }, }); expect( migrateLegacyPluginModelCatalogs({ agentDir, expectedContents: new Map([["zai", contents]]), }), ).toEqual({ detected: 0, migrated: 0, warnings: [] }); expect(listPersistedPluginModelCatalogs(agentDir)).toEqual([{ pluginId: "zai", contents }]); }); it("keeps committed catalogs available when a released sidecar cannot be removed", () => { if (process.getuid?.() === 0) { return; } const agentDir = createAgentDir(); const contents = catalogContents("zai", "released-provider-test-key"); const pluginDir = join(agentDir, "plugins", "zai"); const sourcePath = join(agentDir, encodePluginModelCatalogRelativePath("zai")); mkdirSync(pluginDir, { recursive: true }); writeFileSync(sourcePath, contents, "utf8"); chmodSync(pluginDir, 0o500); try { expect(migrateLegacyPluginModelCatalogs({ agentDir })).toEqual({ detected: 1, migrated: 0, warnings: [expect.stringContaining("Could not remove migrated legacy provider catalog")], }); expect(listPersistedPluginModelCatalogs(agentDir)).toEqual([{ pluginId: "zai", contents }]); expect(existsSync(sourcePath)).toBe(true); const refreshed = catalogContents("zai", "refreshed-provider-test-key"); replacePersistedPluginModelCatalogs({ agentDir, pluginCatalogWrites: { [encodePluginModelCatalogRelativePath("zai")]: refreshed, }, }); expect(listPersistedPluginModelCatalogs(agentDir)).toEqual([ { pluginId: "zai", contents: refreshed }, ]); expect(existsSync(sourcePath)).toBe(true); } finally { chmodSync(pluginDir, 0o700); } }); it("never deletes retained migrated credentials after a newer catalog replaces them", () => { if (process.getuid?.() === 0) { return; } const agentDir = createAgentDir(); const original = catalogContents("zai", "released-provider-test-key"); const refreshed = catalogContents("zai", "refreshed-provider-test-key"); const pluginDir = join(agentDir, "plugins", "zai"); const sourcePath = join(agentDir, encodePluginModelCatalogRelativePath("zai")); mkdirSync(pluginDir, { recursive: true }); writeFileSync(sourcePath, original, "utf8"); chmodSync(pluginDir, 0o500); try { expect(migrateLegacyPluginModelCatalogs({ agentDir })).toEqual({ detected: 1, migrated: 0, warnings: [expect.stringContaining("Could not remove migrated legacy provider catalog")], }); replacePersistedPluginModelCatalogs({ agentDir, pluginCatalogWrites: { [encodePluginModelCatalogRelativePath("zai")]: refreshed, }, }); } finally { chmodSync(pluginDir, 0o700); } expect(migrateLegacyPluginModelCatalogs({ agentDir })).toEqual({ detected: 1, migrated: 0, warnings: [expect.stringContaining("Left superseded legacy provider catalog in place")], }); expect(existsSync(sourcePath)).toBe(true); expect(listPersistedPluginModelCatalogs(agentDir)).toEqual([ { pluginId: "zai", contents: refreshed }, ]); expect(existsSync(sourcePath)).toBe(true); }); it("atomically preserves a provider catalog refreshed immediately before migration claims it", () => { const agentDir = createAgentDir(); const original = catalogContents("zai", "released-provider-test-key"); const refreshed = catalogContents("zai", "concurrently-refreshed-provider-test-key"); const sourcePath = join(agentDir, encodePluginModelCatalogRelativePath("zai")); mkdirSync(join(agentDir, "plugins", "zai"), { recursive: true }); writeFileSync(sourcePath, original, "utf8"); expect( migrateLegacyPluginModelCatalogs({ agentDir, beforeLegacyCatalogClaim: (pathname) => { writeFileSync(pathname, refreshed, "utf8"); }, }), ).toEqual({ detected: 1, migrated: 0, warnings: [expect.stringContaining("Left changed legacy provider catalog in place")], }); expect(readFileSync(sourcePath, "utf8")).toBe(refreshed); expect(listPersistedPluginModelCatalogs(agentDir)).toEqual([ { pluginId: "zai", contents: refreshed }, ]); expect(existsSync(sourcePath)).toBe(false); }); it("never publishes a stale scan after another process removes the legacy source", () => { const agentDir = createAgentDir(); const original = catalogContents("zai", "stale-released-provider-test-key"); const refreshed = catalogContents("zai", "current-regenerated-provider-test-key"); const sourcePath = join(agentDir, encodePluginModelCatalogRelativePath("zai")); mkdirSync(join(agentDir, "plugins", "zai"), { recursive: true }); writeFileSync(sourcePath, original, "utf8"); expect( migrateLegacyPluginModelCatalogs({ agentDir, beforeLegacyCatalogClaim: (pathname) => { replacePersistedPluginModelCatalogs({ agentDir, pluginCatalogWrites: { [encodePluginModelCatalogRelativePath("zai")]: refreshed, }, }); unlinkSync(pathname); }, }), ).toEqual({ detected: 1, migrated: 0, warnings: [ expect.stringContaining( "Legacy provider catalog was claimed before its migration was committed", ), ], }); expect(listPersistedPluginModelCatalogs(agentDir)).toEqual([ { pluginId: "zai", contents: refreshed }, ]); }); it("blocks preparation until another process commits its claimed provider catalog", () => { const agentDir = createAgentDir(); const contents = catalogContents("zai", "in-flight-released-provider-test-key"); const sourcePath = join(agentDir, encodePluginModelCatalogRelativePath("zai")); const claimPath = `${sourcePath}.doctor-importing-concurrent-process`; mkdirSync(join(agentDir, "plugins", "zai"), { recursive: true }); writeFileSync(sourcePath, contents, "utf8"); expect( migrateLegacyPluginModelCatalogs({ agentDir, beforeLegacyCatalogClaim: (pathname) => { renameSync(pathname, claimPath); }, }), ).toEqual({ detected: 1, migrated: 0, warnings: [ expect.stringContaining( "Legacy provider catalog was claimed before its migration was committed", ), ], }); expect(existsSync(claimPath)).toBe(true); expect(existsSync(join(agentDir, "openclaw-agent.sqlite"))).toBe(false); expect(listPersistedPluginModelCatalogs(agentDir)).toEqual([{ pluginId: "zai", contents }]); expect(existsSync(claimPath)).toBe(false); }); it("never migrates a provider while one retained claim cannot be read", () => { if (process.getuid?.() === 0) { return; } const agentDir = createAgentDir(); const pluginDir = join(agentDir, "plugins", "zai"); const claimPath = join(pluginDir, "catalog.json.doctor-importing-previous-process"); const sourcePath = join(pluginDir, "catalog.json"); const retained = catalogContents("zai", "unreadable-retained-provider-test-key"); const refreshed = catalogContents("zai", "current-canonical-provider-test-key"); mkdirSync(pluginDir, { recursive: true }); writeFileSync(claimPath, retained, "utf8"); writeFileSync(sourcePath, refreshed, "utf8"); chmodSync(claimPath, 0o000); try { expect(migrateLegacyPluginModelCatalogs({ agentDir })).toEqual({ detected: 0, migrated: 0, warnings: [expect.stringContaining("Could not read legacy provider catalog")], }); expect(existsSync(claimPath)).toBe(true); expect(existsSync(sourcePath)).toBe(true); expect(existsSync(join(agentDir, "openclaw-agent.sqlite"))).toBe(false); } finally { chmodSync(claimPath, 0o600); } expect(listPersistedPluginModelCatalogs(agentDir)).toEqual([ { pluginId: "zai", contents: refreshed }, ]); }); it("recovers a retained atomic migration claim after an interrupted upgrade", () => { const agentDir = createAgentDir(); const contents = catalogContents("zai", "interrupted-released-provider-test-key"); const pluginDir = join(agentDir, "plugins", "zai"); const claimPath = join(pluginDir, "catalog.json.doctor-importing-previous-process"); mkdirSync(pluginDir, { recursive: true }); writeFileSync(claimPath, contents, "utf8"); expect(listPersistedPluginModelCatalogs(agentDir)).toEqual([{ pluginId: "zai", contents }]); expect(existsSync(claimPath)).toBe(false); }); it("recovers a retained claim before applying a newer canonical provider catalog", () => { const agentDir = createAgentDir(); const retained = catalogContents("zai", "interrupted-released-provider-test-key"); const refreshed = catalogContents("zai", "concurrently-refreshed-provider-test-key"); const pluginDir = join(agentDir, "plugins", "zai"); const sourcePath = join(pluginDir, "catalog.json"); const claimPath = join(pluginDir, "catalog.json.doctor-importing-previous-process"); mkdirSync(pluginDir, { recursive: true }); writeFileSync(claimPath, retained, "utf8"); writeFileSync(sourcePath, refreshed, "utf8"); expect(migrateLegacyPluginModelCatalogs({ agentDir })).toEqual({ detected: 2, migrated: 2, warnings: [], }); expect(listPersistedPluginModelCatalogs(agentDir)).toEqual([ { pluginId: "zai", contents: refreshed }, ]); expect(existsSync(claimPath)).toBe(false); expect(existsSync(sourcePath)).toBe(false); }); it("preserves conflicting retained claims without guessing which credential is newer", () => { const agentDir = createAgentDir(); const pluginDir = join(agentDir, "plugins", "zai"); const olderPath = join(pluginDir, "catalog.json.doctor-importing-zzz"); const newerPath = join(pluginDir, "catalog.json.doctor-importing-aaa"); mkdirSync(pluginDir, { recursive: true }); writeFileSync(olderPath, catalogContents("zai", "older-provider-test-key"), "utf8"); writeFileSync(newerPath, catalogContents("zai", "newer-provider-test-key"), "utf8"); expect(migrateLegacyPluginModelCatalogs({ agentDir })).toEqual({ detected: 0, migrated: 0, warnings: [expect.stringContaining("Conflicting retained legacy provider catalogs")], }); expect(existsSync(olderPath)).toBe(true); expect(existsSync(newerPath)).toBe(true); expect(existsSync(join(agentDir, "openclaw-agent.sqlite"))).toBe(false); }); it("does not let an unreadable sidecar hide other committed provider catalogs", () => { if (process.getuid?.() === 0) { return; } const agentDir = createAgentDir(); const anthropic = catalogContents("anthropic", "available-anthropic-provider-test-key"); replacePersistedPluginModelCatalogs({ agentDir, pluginCatalogWrites: { [encodePluginModelCatalogRelativePath("anthropic")]: anthropic, }, }); const pluginDir = join(agentDir, "plugins", "zai"); const sourcePath = join(agentDir, encodePluginModelCatalogRelativePath("zai")); mkdirSync(pluginDir, { recursive: true }); writeFileSync(sourcePath, catalogContents("zai", "unreadable-provider-test-key"), "utf8"); chmodSync(sourcePath, 0o000); try { expect(migrateLegacyPluginModelCatalogs({ agentDir })).toEqual({ detected: 0, migrated: 0, warnings: [expect.stringContaining("Could not read legacy provider catalog")], }); expect(loadPersistedPluginModelCatalogs(agentDir)).toEqual({ catalogs: [{ pluginId: "anthropic", contents: anthropic }], warnings: [expect.stringContaining("Could not read legacy provider catalog")], }); expect(listPersistedPluginModelCatalogs(agentDir)).toEqual([ { pluginId: "anthropic", contents: anthropic }, ]); } finally { chmodSync(sourcePath, 0o600); } }); it("never migrates or deletes an unmarked user-authored catalog", () => { const agentDir = createAgentDir(); const sourcePath = join(agentDir, encodePluginModelCatalogRelativePath("zai")); const contents = JSON.stringify({ providers: { zai: { apiKey: "user-authored-test-key" } } }); mkdirSync(join(agentDir, "plugins", "zai"), { recursive: true }); writeFileSync(sourcePath, contents, "utf8"); expect(listPersistedPluginModelCatalogs(agentDir)).toEqual([]); expect(existsSync(join(agentDir, "openclaw-agent.sqlite"))).toBe(false); expect(existsSync(sourcePath)).toBe(true); }); it("persists provider-owned catalogs in deterministic SQLite order", () => { const agentDir = createAgentDir(); const zai = catalogContents("zai"); const anthropic = catalogContents("anthropic"); expect( replacePersistedPluginModelCatalogs({ agentDir, pluginCatalogWrites: { [encodePluginModelCatalogRelativePath("zai")]: zai, [encodePluginModelCatalogRelativePath("anthropic")]: anthropic, }, }), ).toBe(true); expect(listPersistedPluginModelCatalogs(agentDir)).toEqual([ { pluginId: "anthropic", contents: anthropic }, { pluginId: "zai", contents: zai }, ]); expect(existsSync(join(agentDir, "openclaw-agent.sqlite"))).toBe(true); expect(existsSync(join(agentDir, "plugins"))).toBe(false); }); it("recognizes unchanged payloads and atomically removes stale provider rows", () => { const agentDir = createAgentDir(); const zai = catalogContents("zai"); const anthropic = catalogContents("anthropic"); const both = { [encodePluginModelCatalogRelativePath("zai")]: zai, [encodePluginModelCatalogRelativePath("anthropic")]: anthropic, }; expect(replacePersistedPluginModelCatalogs({ agentDir, pluginCatalogWrites: both })).toBe(true); expect(replacePersistedPluginModelCatalogs({ agentDir, pluginCatalogWrites: both })).toBe( false, ); expect( replacePersistedPluginModelCatalogs({ agentDir, pluginCatalogWrites: { [encodePluginModelCatalogRelativePath("zai")]: zai, }, }), ).toBe(true); expect(listPersistedPluginModelCatalogs(agentDir)).toEqual([ { pluginId: "zai", contents: zai }, ]); }); it("rejects invalid planning keys without deleting the committed catalog", () => { const agentDir = createAgentDir(); const zai = catalogContents("zai"); replacePersistedPluginModelCatalogs({ agentDir, pluginCatalogWrites: { [encodePluginModelCatalogRelativePath("zai")]: zai, }, }); expect(() => replacePersistedPluginModelCatalogs({ agentDir, pluginCatalogWrites: { "../catalog.json": catalogContents("anthropic") }, }), ).toThrow("Invalid generated plugin model catalog key: ../catalog.json"); expect(listPersistedPluginModelCatalogs(agentDir)).toEqual([ { pluginId: "zai", contents: zai }, ]); }); it("round-trips encoded plugin ownership without filesystem discovery", () => { const pluginId = "provider/with spaces"; const key = encodePluginModelCatalogRelativePath(pluginId); expect(key).toBe("plugins/provider%2Fwith%20spaces/catalog.json"); expect(decodePluginModelCatalogRelativePathPluginId(key)).toBe(pluginId); expect(decodePluginModelCatalogRelativePathPluginId("../catalog.json")).toBeUndefined(); }); });