mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-05 16:41:51 +00:00
921 lines
35 KiB
TypeScript
921 lines
35 KiB
TypeScript
// 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();
|
|
});
|
|
});
|