test: catch lost plugin edits and stale packaged release guidance (#1832)

Adds two checks for upgrade problems people have hit.

1. The installed daemon-restart scenario now seeds an older plugin and an edited binary. When the backup is denied, the restart must skip the managed refresh and leave every target unchanged, with a specific warning. Once permissions are restored, it must keep the exact recovery bytes and move to the shipped version. A missing or corrupt proof fails the report.
2. The package-content test reads the same tarball's what's-new heading and intro against its package.json, with independent stale-heading and stale-intro controls.

Test-only: 5 files, +83. No product, scenario-grammar, scheduler or dependency change. One existing installed case gains one extra daemon start and file hashing.
This commit is contained in:
Mike Schwarz authored and GitHub committed 2026-10-23 04:12:00 -07:00
1 parent dae1dd99fc
commit 9848c7b681
5 files changed
+83

No files matched your search

@@ -0,0 +1,46 @@
import assert from 'node:assert/strict';
import { chmodSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { dirname, join, relative } from 'node:path';
// Exercise startup's real refresh in the existing restart case's disposable HOME.
export async function preserveRefresh(daemon, home, rigBin) {
const plugin = join(home, 'plugins/openrig-core');
const recovery = join(home, 'backups/managed-refresh');
const shipped = join(dirname(rigBin), '../daemon/assets/plugins/openrig-core');
const edited = Buffer.from([0xff, 0, ...Buffer.from('unique owner edit\r\n')]);
const sha = bytes => createHash('sha256').update(bytes).digest('hex');
const files = dir => readdirSync(dir, { recursive: true, withFileTypes: true })
.filter(entry => entry.isFile()).map(entry => join(entry.parentPath, entry.name));
const snapshot = () => Object.fromEntries(files(plugin).sort()
.map(file => [relative(plugin, file), sha(readFileSync(file))]));
await daemon.sigterm();
writeFileSync(join(plugin, 'README.md'), edited);
for (const harness of ['.claude-plugin', '.codex-plugin']) {
const path = join(plugin, harness, 'plugin.json');
const manifest = JSON.parse(readFileSync(path, 'utf8'));
writeFileSync(path, JSON.stringify({ ...manifest, version: '0.0.1' }));
}
const before = snapshot();
mkdirSync(recovery, { recursive: true });
chmodSync(recovery, 0o500); // Non-root fixture: fail backup before any target write.
try {
await daemon.restart();
assert.deepEqual(snapshot(), before, 'failed backup changed plugin bytes/version');
assert.ok(readFileSync(join(home, 'daemon.log'), 'utf8').split('\n').some(line =>
line.includes(`preservation failed for ${plugin}:`) && line.includes('refresh skipped')),
'missing skipped-refresh warning for this plugin');
} finally {
chmodSync(recovery, 0o700);
}
await daemon.restart();
const backup = files(recovery).find(file => readFileSync(file).equals(edited));
assert.ok(backup, 'refresh lost the exact binary edit');
assert.deepEqual(readFileSync(join(plugin, 'README.md')), readFileSync(join(shipped, 'README.md')));
const version = dir => JSON.parse(readFileSync(join(dir, '.claude-plugin/plugin.json'), 'utf8')).version;
assert.equal(version(plugin), version(shipped), 'managed version did not advance');
assert.notEqual(version(plugin), '0.0.1');
return { failedBackupUnchanged: true, editedSha256: sha(edited), backedUpSha256: sha(readFileSync(backup)),
targetFilesChecked: Object.keys(before).length, version: version(plugin), shippedVersion: version(shipped) };
}
+9
View File
@@ -36,6 +36,15 @@ export function verifyPassingRun(exitCode, report, caseName) {
assert.deepEqual(report.records[0], report.result);
assert.equal(exitCode, 0);
assert.equal(report.result.verdict, 'PASS');
if (caseName === 'daemon-restart') {
const proof = report.preservation;
assert.equal(proof?.failedBackupUnchanged, true);
assert.match(proof.editedSha256, /^[0-9a-f]{64}$/);
assert.equal(proof.backedUpSha256, proof.editedSha256);
assert.ok(proof.targetFilesChecked > 0);
assert.equal(proof.version, proof.shippedVersion);
assert.notEqual(proof.version, '0.0.1');
}
if (selected.kind === 'hook') {
verifyHookOutput(report.hook);
for (const text of selected.includes) assert.ok(report.hook.additionalContext.includes(text), text);
+5
View File
@@ -8,6 +8,7 @@ import { networkInterfaces } from 'node:os';
import { join } from 'node:path';
import { defaultHostDaemon, runScenarioFile } from '../../daemon/test/helpers/scenario-pipeline.ts';
import { CASES, PASSING_CASES, RESULT_PREFIX } from './result.mjs';
import { preserveRefresh } from './preserve-refresh.mjs';
assert.equal(process.platform, 'linux', 'container execution only');
assert.notEqual(process.getuid(), 0, 'run as the unprivileged testbed user');
@@ -77,6 +78,10 @@ sys.exit(r.returncode)
},
daemon: async (scaffold, options) => {
const daemon = await defaultHostDaemon(scaffold, options);
if (caseName === 'daemon-restart') return {
...daemon,
restart: async () => { report.preservation = await preserveRefresh(daemon, scaffold.openrigHome, rigBin); },
};
if (mode === 'healthy' || selected.kind === 'hook') return daemon;
return {
...daemon,
+15
View File
@@ -30,6 +30,19 @@ const DAEMON_SURFACES = [
"daemon/dist/gateway-slack-surface.js",
];
function assertReleaseGuidance(version, note) {
const [heading, intro] = note.trim().split(/\n\s*\n/, 2);
assert.equal(heading, `# What changed in OpenRig ${version}`, 'packaged whats-new heading is stale');
assert.ok(intro.includes(`just upgraded to ${version}.`), 'packaged whats-new introduction is stale');
}
test("whats-new names its package version, and rejects stale heading or introduction", () => {
const note = '# What changed in OpenRig 0.7.0\n\nThis note is for an agent on an install that was just upgraded to 0.7.0.';
assertReleaseGuidance('0.7.0', note);
assert.throws(() => assertReleaseGuidance('0.7.0', note.replace('OpenRig 0.7.0', 'OpenRig 0.6.12')), /heading is stale/);
assert.throws(() => assertReleaseGuidance('0.7.0', note.replace('upgraded to 0.7.0', 'upgraded to 0.6.12')), /introduction is stale/);
});
test("pre-check: @openrig/cli, ships the `rig` bin, and does not depend on the unpublished @openrig/daemon", () => {
const pkg = JSON.parse(readFileSync(join(CLI_DIR, "package.json"), "utf8"));
assert.equal(pkg.name, "@openrig/cli", "must pack @openrig/cli, not the root openrig");
@@ -61,6 +74,8 @@ test("package proof: the tarball ships the daemon surfaces and no packaged JS im
`the shipped daemon must include ${surface} (a value-import resolution target)`);
}
const packed = JSON.parse(execFileSync("tar", ["-xzOf", tgzPath, "package/package.json"], { encoding: "utf8" }));
const note = execFileSync("tar", ["-xzOf", tgzPath, "package/daemon/context-packs/reference/whats-new.md"], { encoding: "utf8" });
assertReleaseGuidance(packed.version, note);
assert.equal(packed.dependencies?.["@openrig/daemon"], undefined, "the packed manifest must not depend on @openrig/daemon");
assert.equal(packed.bundledDependencies, undefined, "the packed manifest must not bundle dependencies");
+8
View File
@@ -81,6 +81,14 @@ test('a passing-only case admits only a clean PASS of its own scenario', () => {
const text = (PASSING_CASES[caseName].includes ?? []).join('\n');
const hook = { rawOutput: JSON.stringify({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: text } }), additionalContext: text };
const pass = { ...(PASSING_CASES[caseName].kind === 'hook' ? { hook } : {}), mode: 'healthy', caseName, scenarioSha256: 'a'.repeat(64), result, records: [result], fault: null };
if (caseName === 'daemon-restart') {
pass.preservation = { failedBackupUnchanged: true, editedSha256: 'b'.repeat(64), backedUpSha256: 'b'.repeat(64),
targetFilesChecked: 2, version: '0.1.17', shippedVersion: '0.1.17' };
for (const changed of [undefined, { ...pass.preservation, failedBackupUnchanged: false },
{ ...pass.preservation, backedUpSha256: 'c'.repeat(64) }, { ...pass.preservation, version: '0.0.1' }]) {
assert.throws(() => verifyPassingRun(0, { ...pass, preservation: changed }, caseName));
}
}
verifyPassingRun(0, pass, caseName);
const fail = { ...result, verdict: 'FAIL', failedStep: 2 };
for (const [exitCode, bad] of [