diff --git a/packages/test-system/ci/preserve-refresh.mjs b/packages/test-system/ci/preserve-refresh.mjs new file mode 100644 index 000000000..d74eeaae2 --- /dev/null +++ b/packages/test-system/ci/preserve-refresh.mjs @@ -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) }; +} diff --git a/packages/test-system/ci/result.mjs b/packages/test-system/ci/result.mjs index 0e9e54623..6d13b327f 100644 --- a/packages/test-system/ci/result.mjs +++ b/packages/test-system/ci/result.mjs @@ -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); diff --git a/packages/test-system/ci/run.mjs b/packages/test-system/ci/run.mjs index 070686b4e..6abf2afee 100644 --- a/packages/test-system/ci/run.mjs +++ b/packages/test-system/ci/run.mjs @@ -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, diff --git a/scripts/build-testbed-cli-pack.test.mjs b/scripts/build-testbed-cli-pack.test.mjs index 5c3e5ddd1..4bcea03df 100644 --- a/scripts/build-testbed-cli-pack.test.mjs +++ b/scripts/build-testbed-cli-pack.test.mjs @@ -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"); diff --git a/scripts/pr-scenarios.test.mjs b/scripts/pr-scenarios.test.mjs index 1c71324e4..db4728a99 100644 --- a/scripts/pr-scenarios.test.mjs +++ b/scripts/pr-scenarios.test.mjs @@ -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 [