openclaw/scripts/e2e/lib/plugin-index-sqlite.mjs
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

179 lines
5.2 KiB
JavaScript

// SQLite readers for plugin install indexes produced during E2E scenarios.
import fs from "node:fs";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { readPositiveIntEnv } from "./env-limits.mjs";
import { readTextFileBounded } from "./text-file-utils.mjs";
const STATE_KEY = "plugins.installedIndex";
const ERROR_DETAIL_TAIL_BYTES = 16 * 1024;
const JSON_ARTIFACT_MAX_BYTES = readPositiveIntEnv(
"OPENCLAW_PLUGIN_INDEX_JSON_MAX_BYTES",
1024 * 1024,
);
function stateDir() {
return process.env.OPENCLAW_STATE_DIR || path.join(process.env.HOME, ".openclaw");
}
function configPath() {
return process.env.OPENCLAW_CONFIG_PATH || path.join(stateDir(), "openclaw.json");
}
function readJsonMaybe(file) {
let text;
try {
text = readTextFileBounded(file, "plugin index JSON artifact", JSON_ARTIFACT_MAX_BYTES, {
tailBytes: ERROR_DETAIL_TAIL_BYTES,
});
} catch (error) {
if (error?.code === "ETOOBIG") {
throw error;
}
return {};
}
try {
return JSON.parse(text);
} catch {
return {};
}
}
function textTooLargeError(message) {
return Object.assign(new Error(message), { code: "ETOOBIG" });
}
function parseIndexJsonText(text, label) {
const bytes = Buffer.byteLength(text, "utf8");
if (bytes > JSON_ARTIFACT_MAX_BYTES) {
throw textTooLargeError(`${label} exceeded ${JSON_ARTIFACT_MAX_BYTES} bytes (${bytes} bytes)`);
}
return JSON.parse(text);
}
function assertIndexJsonByteLength(bytesRaw, label) {
const bytes = Number(bytesRaw);
if (!Number.isFinite(bytes) || bytes < 0) {
throw new Error(`${label} byte length was invalid: ${String(bytesRaw)}`);
}
if (bytes > JSON_ARTIFACT_MAX_BYTES) {
throw textTooLargeError(`${label} exceeded ${JSON_ARTIFACT_MAX_BYTES} bytes (${bytes} bytes)`);
}
}
function sqlitePath(root = stateDir()) {
return path.join(root, "state", "openclaw.sqlite");
}
function legacyIndexPath(root = stateDir()) {
return path.join(root, "plugins", "installs.json");
}
function readSqlitePluginIndex(root = stateDir()) {
const dbPath = sqlitePath(root);
if (!fs.existsSync(dbPath)) {
return {};
}
let db;
try {
db = new DatabaseSync(dbPath, { readOnly: true });
const lengths = db
.prepare(
`
SELECT octet_length(value_json) AS value_json_bytes
FROM config_machine_state
WHERE state_key = ?
`,
)
.get(STATE_KEY);
if (!lengths) {
return {};
}
assertIndexJsonByteLength(lengths.value_json_bytes, "plugin index value_json");
const row = db
.prepare("SELECT value_json FROM config_machine_state WHERE state_key = ?")
.get(STATE_KEY);
if (!row) {
return {};
}
const value = parseIndexJsonText(row.value_json, "plugin index value_json");
return value?.index && typeof value.index === "object" ? value.index : {};
} catch (error) {
if (error?.code === "ETOOBIG") {
throw error;
}
return {};
} finally {
db?.close();
}
}
export function readPluginInstallIndex(options = {}) {
const root = options.stateDir ?? stateDir();
const config = readJsonMaybe(options.configPath ?? configPath());
const sqliteIndex = readSqlitePluginIndex(root);
if (sqliteIndex.installRecords) {
return sqliteIndex;
}
const legacyIndex = readJsonMaybe(legacyIndexPath(root));
const installRecords =
legacyIndex.installRecords ??
legacyIndex.records ??
options.fallbackRecords ??
config.plugins?.installs ??
{};
return {
...legacyIndex,
installRecords,
};
}
export function readPluginInstallRecords(options = {}) {
return readPluginInstallIndex(options).installRecords ?? {};
}
export function writePluginInstallIndexForE2E(index, options = {}) {
const root = options.stateDir ?? stateDir();
const dbPath = sqlitePath(root);
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
const db = new DatabaseSync(dbPath);
try {
db.exec(`
CREATE TABLE IF NOT EXISTS config_machine_state (
state_key TEXT NOT NULL PRIMARY KEY,
value_json TEXT NOT NULL,
updated_at_ms INTEGER NOT NULL
);
`);
const now = Date.now();
const persisted = {
revision: now,
index: {
version: index.version ?? 1,
warning:
index.warning ??
"DO NOT EDIT. This row is generated by OpenClaw plugin registry commands.",
hostContractVersion: index.hostContractVersion ?? "docker-e2e",
compatRegistryVersion: index.compatRegistryVersion ?? "docker-e2e",
migrationVersion: index.migrationVersion ?? 1,
policyHash: index.policyHash ?? "docker-e2e",
generatedAtMs: index.generatedAtMs ?? now,
...(index.refreshReason ? { refreshReason: index.refreshReason } : {}),
installRecords: index.installRecords ?? {},
plugins: index.plugins ?? [],
diagnostics: index.diagnostics ?? [],
},
};
db.prepare(
`
INSERT INTO config_machine_state (state_key, value_json, updated_at_ms)
VALUES (?, ?, ?)
ON CONFLICT(state_key) DO UPDATE SET
value_json = excluded.value_json,
updated_at_ms = excluded.updated_at_ms
`,
).run(STATE_KEY, JSON.stringify(persisted), now);
} finally {
db.close();
}
}