Files
openclaw/test/scripts/plugin-index-sqlite.test.ts
Peter Steinberger 1ea2640f54 refactor(state): consolidate wide rows, plugin index, workspace attestations, and shared auth singletons at schema v13 (#130466)
* refactor(state): make cron and subagent rows JSON-canonical

* refactor(state): make gateway origin device tokens canonical at v13

The lazy ensure predates the table joining the canonical schema; at the
v13 bump the schema owns creation, so the feature-local DDL, WeakSet
dedupe, and lazy-list entry retire. The legacy-file guard the ensure
carried stays at each call site.

* test: drop obsolete lazy-ensure coverage for origin device tokens

The table is canonical at v13; same-version lazy creation no longer
exists to protect. Origin CRUD, isolation, and rotation coverage remains
in the surviving cases.

* refactor(state): fold installed_plugin_index into config_machine_state

The singleton index row becomes one JSON value under
plugins.installedIndex with its rollback-fencing revision inside the
value; reads, CAS restore, and the lease-held write transactions use
direct Kysely on config_machine_state so the state_leases assertion
stays in-transaction. The v13 migration imports the row and drops the
table; the additive workspace_dir entry folds with it. Doctor guidance,
docker staging, and the e2e probes name the machine-state row.

* refactor(state): merge workspace_attestations into workspace_setup_state

One row per workspace now carries both setup milestones and the
attestation clock: nullable setup columns represent attestation-only
workspaces (replaceWorkspaceAttestation can precede any setup write) and
setupExists derives from a non-null version. The bootstrap-hash FK
repoints to the merged table; migration receipts keep the historical
workspace_attestations discriminator string. The v13 migration grows and
rebuilds the table, merges attestation rows (orphans without a path
alias drop — their hashes re-derive at the next bootstrap attestation),
and the consolidation kind is renamed state-consolidation-v13 to cover
the batch.

* test(state): cover the workspace merge and consolidation fallout

The v12-to-v13 regression seeds merged, attestation-only, and orphan
attestation workspaces; the 13-to-12 downgrade fixture recreates
workspace_attestations and installed_plugin_index from the folded data;
the fold-in migration gates the additive workspace_dir column for
pre-additive rows; the workspace merge now triggers on the setup table's
own shape so stable-era databases without an attestations table still
reshape; the consolidation applied-message covers the batch.

* refactor(state): fold shared auth profile singletons into config_machine_state

The shared-state auth_profile_stores/auth_profile_state rows (fixed key
'shared') become authProfiles.store/authProfiles.state machine-state
values; the agent-DB tables of the same names are untouched. Git-backup
redaction moves from table-drop to the authProfiles. secret prefix with
seeded-secret absence proof; migration receipts keep the historical
table-name discriminators; the shared-auth relocation and receipt
verification project the KV cells back to the receipt-era row shapes so
persisted digests stay byte-compatible. mcp_oauth_stores stays a table —
its multi-key fold is a named follow-up.

* test(state): finish shared-auth fold coverage and annotate boundary casts

Auth seeders and assertions across the e2e/scripts/secrets suites target
the authProfiles machine-state cells; the v12-to-v13 regression proves
payload-byte fidelity, non-shared-row drop, and insert-if-absent
precedence; the downgrade fixture recreates and repopulates both v12
tables. Boundary type assertions in the plugin-index store carry SAFETY
invariants per the ratchet.

* chore: shrink assertion-safety baseline for plugin-index store

* refactor(doctor): delete the dead onboarding-recommendations migration

Its input — the unscoped 'primary' onboarding row — existed only between
9a93a52a8a and 473962b7de, a two-day beta window; no shipped stable
can produce it and the runtime table folded away at v12. The audit
backup list keeps recognizing system-agent.jsonl artifacts because beta
installs that ran that import may still carry its backups.

* docs: sync the 13-to-12 downgrade example with the executable fixture

* style: format the synced downgrade example

* style: drop unused import and duplicate union constituent

* fix(state): keep orphan attestations across the v13 workspace merge

The merged workspace_setup_state required a workspace path, but legacy
orphan hashed-key attestations never recorded one. workspace_path is now
nullable (setup rows still enforce it via CHECK), the v13 migration and
the doctor file import keep orphans with a NULL path that heals on the
next live access, and the 13-to-12 downgrade keeps attestation-owned
hashes. Doctor test seeds move to the folded KV row.

* perf(state): retire unused cron indexes

* fix(state): preserve v13 migration recovery

* fix(state): preserve v12 lazy-table upgrade

* docs(state): document v13 auth relocation

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-08-27 15:26:14 +08:00

134 lines
4.4 KiB
TypeScript

// Plugin Index SQLite tests cover shared E2E install-index readers.
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { pathToFileURL } from "node:url";
import { describe, expect, it } from "vitest";
import { withEnvAsync } from "../../src/test-utils/env.js";
const MODULE_URL = pathToFileURL(path.resolve("scripts/e2e/lib/plugin-index-sqlite.mjs")).href;
let importCounter = 0;
async function loadPluginIndex(env: Record<string, string> = {}) {
return await withEnvAsync(env, async () => {
return await import(`${MODULE_URL}?case=${importCounter++}`);
});
}
function writeLegacyIndex(root: string, text: string) {
const file = path.join(root, "plugins", "installs.json");
mkdirSync(path.dirname(file), { recursive: true });
writeFileSync(file, text, "utf8");
}
function configPath(root: string) {
return path.join(root, "openclaw.json");
}
function writeSqliteIndex(root: string, installRecordsJson: string) {
const dbPath = path.join(root, "state", "openclaw.sqlite");
mkdirSync(path.dirname(dbPath), { recursive: true });
const db = new DatabaseSync(dbPath);
try {
db.exec(`
CREATE TABLE config_machine_state (
state_key TEXT NOT NULL PRIMARY KEY,
value_json TEXT NOT NULL,
updated_at_ms INTEGER NOT NULL
);
`);
db.prepare(
"INSERT INTO config_machine_state (state_key, value_json, updated_at_ms) VALUES (?, ?, ?)",
).run(
"plugins.installedIndex",
JSON.stringify({
revision: Date.now(),
index: {
version: 1,
hostContractVersion: "1",
compatRegistryVersion: "1",
migrationVersion: 1,
policyHash: "hash",
generatedAtMs: Date.now(),
installRecords: JSON.parse(installRecordsJson) as unknown,
plugins: [],
diagnostics: [],
},
}),
Date.now(),
);
} finally {
db.close();
}
}
describe("plugin index SQLite E2E helpers", () => {
it("reads legacy install records when SQLite index state is absent", async () => {
const root = mkdtempSync(path.join(tmpdir(), "openclaw-plugin-index-"));
try {
writeLegacyIndex(
root,
JSON.stringify({ records: { demo: { installPath: "/tmp/demo", source: "npm" } } }),
);
const { readPluginInstallRecords } = await loadPluginIndex();
expect(readPluginInstallRecords({ stateDir: root, configPath: configPath(root) })).toEqual({
demo: { installPath: "/tmp/demo", source: "npm" },
});
} finally {
rmSync(root, { force: true, recursive: true });
}
});
it("keeps malformed legacy install JSON as an empty fallback", async () => {
const root = mkdtempSync(path.join(tmpdir(), "openclaw-plugin-index-"));
try {
writeLegacyIndex(root, "{not-json");
const { readPluginInstallRecords } = await loadPluginIndex();
expect(readPluginInstallRecords({ stateDir: root, configPath: configPath(root) })).toEqual(
{},
);
} finally {
rmSync(root, { force: true, recursive: true });
}
});
it("rejects oversized legacy install JSON before parsing it", async () => {
const root = mkdtempSync(path.join(tmpdir(), "openclaw-plugin-index-"));
try {
writeLegacyIndex(root, JSON.stringify({ records: {}, filler: "x".repeat(128) }));
const { readPluginInstallRecords } = await loadPluginIndex({
OPENCLAW_PLUGIN_INDEX_JSON_MAX_BYTES: "64",
});
expect(() =>
readPluginInstallRecords({ stateDir: root, configPath: configPath(root) }),
).toThrow("plugin index JSON artifact exceeded 64 bytes");
} finally {
rmSync(root, { force: true, recursive: true });
}
});
it("rejects oversized SQLite install index JSON before parsing it", async () => {
const root = mkdtempSync(path.join(tmpdir(), "openclaw-plugin-index-"));
try {
writeSqliteIndex(root, JSON.stringify({ filler: "x".repeat(128) }));
const { readPluginInstallIndex } = await loadPluginIndex({
OPENCLAW_PLUGIN_INDEX_JSON_MAX_BYTES: "64",
});
expect(() =>
readPluginInstallIndex({ stateDir: root, configPath: configPath(root) }),
).toThrow("plugin index value_json exceeded 64 bytes");
} finally {
rmSync(root, { force: true, recursive: true });
}
});
});