Files
openclaw/test/scripts/release-plugin-marketplace-lifecycle.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

235 lines
7.2 KiB
TypeScript

import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { afterEach, describe, expect, it } from "vitest";
import { cleanupTempDirs, makeTempDir } from "../helpers/temp-dir.js";
const HELPER = path.resolve("scripts/e2e/lib/release-plugin-marketplace/lifecycle-assertions.mjs");
const tempDirs = new Set<string>();
afterEach(() => {
cleanupTempDirs(tempDirs);
});
function writeIndex(
stateDir: string,
pluginId: string,
record: Record<string, unknown>,
packageVersion: string,
) {
const databasePath = path.join(stateDir, "state", "openclaw.sqlite");
fs.mkdirSync(path.dirname(databasePath), { recursive: true });
const db = new DatabaseSync(databasePath);
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 valueJson = JSON.stringify({
revision: now,
index: {
version: 1,
hostContractVersion: "test",
compatRegistryVersion: "test",
migrationVersion: 1,
policyHash: "test",
generatedAtMs: now,
refreshReason: "source-changed",
installRecords: { [pluginId]: record },
plugins: [{ pluginId, packageVersion }],
diagnostics: [],
},
});
db.prepare(
`
INSERT INTO config_machine_state (state_key, value_json, updated_at_ms)
VALUES ('plugins.installedIndex', ?, ?)
ON CONFLICT(state_key) DO UPDATE SET
value_json = excluded.value_json,
updated_at_ms = excluded.updated_at_ms
`,
).run(valueJson, now);
} finally {
db.close();
}
}
function runHelper(home: string, args: string[]) {
const stateDir = path.join(home, ".openclaw");
return spawnSync(process.execPath, [HELPER, ...args], {
encoding: "utf8",
env: {
...process.env,
HOME: home,
OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"),
OPENCLAW_STATE_DIR: stateDir,
},
});
}
function writeMarketplaceState(home: string, version: string) {
const pluginId = "release-marketplace-plugin";
const stateDir = path.join(home, ".openclaw");
const installPath = path.join(stateDir, "extensions", pluginId);
fs.mkdirSync(installPath, { recursive: true });
fs.writeFileSync(
path.join(installPath, "package.json"),
`${JSON.stringify({ name: `@openclaw/${pluginId}`, version })}\n`,
"utf8",
);
fs.writeFileSync(
path.join(stateDir, "openclaw.json"),
`${JSON.stringify({
plugins: { entries: { [pluginId]: { enabled: true } } },
})}\n`,
"utf8",
);
writeIndex(
stateDir,
pluginId,
{
source: "marketplace",
installPath,
version,
marketplaceName: "Release Fixture Marketplace",
marketplaceSource: "release-fixtures",
marketplacePlugin: pluginId,
},
version,
);
return { installPath, pluginId };
}
function clearMarketplaceIndex(home: string) {
const databasePath = path.join(home, ".openclaw", "state", "openclaw.sqlite");
const db = new DatabaseSync(databasePath);
try {
db.prepare(
`
UPDATE config_machine_state
SET value_json = json_set(
value_json,
'$.index.installRecords', json('{}'),
'$.index.plugins', json('[]')
),
updated_at_ms = ?
WHERE state_key = 'plugins.installedIndex'
`,
).run(Date.now());
} finally {
db.close();
}
}
describe("release plugin marketplace lifecycle assertions", () => {
it("checks canonical marketplace metadata and stable managed install paths", () => {
const home = makeTempDir(tempDirs, "openclaw-marketplace-lifecycle-");
const { installPath, pluginId } = writeMarketplaceState(home, "0.0.1");
const installPathFile = path.join(home, "install-path.txt");
const initial = runHelper(home, [
"assert-marketplace-state",
pluginId,
"0.0.1",
"release-fixtures",
pluginId,
installPathFile,
]);
expect(initial.stderr).toBe("");
expect(initial.status).toBe(0);
expect(fs.readFileSync(installPathFile, "utf8").trim()).toBe(installPath);
writeMarketplaceState(home, "0.0.2");
const updated = runHelper(home, [
"assert-marketplace-state",
pluginId,
"0.0.2",
"release-fixtures",
pluginId,
installPathFile,
]);
expect(updated.stderr).toBe("");
expect(updated.status).toBe(0);
});
it("rejects marketplace state with the wrong persisted version", () => {
const home = makeTempDir(tempDirs, "openclaw-marketplace-lifecycle-");
const { pluginId } = writeMarketplaceState(home, "0.0.1");
const result = runHelper(home, [
"assert-marketplace-state",
pluginId,
"0.0.2",
"release-fixtures",
pluginId,
path.join(home, "install-path.txt"),
]);
expect(result.status).toBe(1);
expect(result.stderr).toContain("expected install record version 0.0.2, got 0.0.1");
});
it("seeds uninstall state and verifies complete cleanup with sentinels preserved", () => {
const home = makeTempDir(tempDirs, "openclaw-marketplace-lifecycle-");
const { installPath, pluginId } = writeMarketplaceState(home, "0.0.2");
const sentinelPluginId = "release-marketplace-other";
const sentinelPath = path.join(home, "marketplace", sentinelPluginId);
const installPathFile = path.join(home, "install-path.txt");
fs.mkdirSync(sentinelPath, { recursive: true });
const seeded = runHelper(home, [
"seed-marketplace-uninstall-state",
pluginId,
sentinelPluginId,
sentinelPath,
installPathFile,
]);
expect(seeded.stderr).toBe("");
expect(seeded.status).toBe(0);
const configPath = path.join(home, ".openclaw", "openclaw.json");
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
expect(config.plugins.allow).toEqual([pluginId, sentinelPluginId]);
expect(config.plugins.deny).toEqual([pluginId, sentinelPluginId]);
expect(config.plugins.load.paths).toEqual([installPath, sentinelPath]);
delete config.plugins.entries[pluginId];
config.plugins.allow = [sentinelPluginId];
config.plugins.deny = [sentinelPluginId];
config.plugins.load.paths = [sentinelPath];
fs.writeFileSync(configPath, `${JSON.stringify(config)}\n`, "utf8");
fs.rmSync(installPath, { recursive: true });
clearMarketplaceIndex(home);
const verified = runHelper(home, [
"assert-marketplace-uninstalled",
pluginId,
sentinelPluginId,
sentinelPath,
installPathFile,
]);
expect(verified.stderr).toBe("");
expect(verified.status).toBe(0);
});
it("checks the exact dry-run and update outcome text", () => {
const home = makeTempDir(tempDirs, "openclaw-marketplace-lifecycle-");
const logPath = path.join(home, "update.log");
fs.writeFileSync(logPath, "Would update release-marketplace-plugin: 0.0.1 -> 0.0.2.\n", "utf8");
const result = runHelper(home, [
"assert-update-log",
logPath,
"Would update release-marketplace-plugin: 0.0.1 -> 0.0.2.",
]);
expect(result.stderr).toBe("");
expect(result.status).toBe(0);
});
});