mirror of
https://github.com/openclaw/openclaw.git
synced 2026-09-09 01:29:06 +00:00
* 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 between9a93a52a8aand473962b7de, 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>
179 lines
5.2 KiB
JavaScript
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();
|
|
}
|
|
}
|