diff --git a/scripts/lib/install/apply.js b/scripts/lib/install/apply.js index eac9bbb25..cde010a04 100644 --- a/scripts/lib/install/apply.js +++ b/scripts/lib/install/apply.js @@ -447,9 +447,11 @@ function openCodeActivationCandidates(plan, previousOperations) { return candidates; } -function activationIsInactive(kind, operation, content) { +function activationIsInactive(kind, operation, content, strict = false) { if (kind === 'plugin') { - return isInertOpenCodePluginStub(content); + // The final gate accepts only the V2-valid stub: the pre-fix one is inert but OpenCode rejects it. + return strict ? content.toString('utf8') === getDisabledOpenCodePluginContent() + : isInertOpenCodePluginStub(content); } const text = content.toString('utf8'); const relativePath = operation.sourceRelativePath || operation.destinationPath; @@ -505,7 +507,7 @@ function assertOpenCodeHookDeactivationReady(plan, options = {}) { const digest = content === null ? null : crypto.createHash('sha256').update(content).digest('hex'); snapshot.set(key, digest); if (content === null) continue; - const inactive = activationIsInactive(kind, operation, content); + const inactive = activationIsInactive(kind, operation, content, options.requireInactive); if (options.requireInactive) { if (!inactive) { throw new Error(`OpenCode hook activation remains active at ${operation.destinationPath}`); diff --git a/tests/lib/opencode-consent-legacy-lock.test.js b/tests/lib/opencode-consent-legacy-lock.test.js index 8d92fec2a..48228307b 100644 --- a/tests/lib/opencode-consent-legacy-lock.test.js +++ b/tests/lib/opencode-consent-legacy-lock.test.js @@ -347,6 +347,25 @@ function runTests() { assert.strictEqual(fs.readFileSync(path.join(value.canonicalRoot, 'plugins/ecc-hooks.ts'), 'utf8'), getDisabledOpenCodePluginContent()); }); + test('a retained pre-fix declined factory stub never reports a successful repair', value => { + const state = addLegacyActivations(value, 'declined'); + const oldStub = 'export default async () => ({});\n'; + const stubPath = path.join(value.legacyRoot, 'plugins/ecc-hooks.ts'); + fs.writeFileSync(stubPath, oldStub); + state.operations.find(operation => operation.destinationPath === stubPath).contentSha256 = sha256(oldStub); + writeInstallState(value.legacyStatePath, state); + const originalRename = fileSystem.renameSync; + fileSystem.renameSync = (from, ...args) => { + if (from === stubPath) throw Object.assign(new Error('Synthetic removal denial'), { code: 'EACCES' }); + return originalRename(from, ...args); + }; + try { + const result = repair(value, buildInertPayload); + assert.strictEqual(result.results[0].status, 'error', JSON.stringify(result)); + assert.strictEqual(fs.readFileSync(stubPath, 'utf8'), oldStub); + } finally { fileSystem.renameSync = originalRename; } + assertBothLocksReleased(value); + }); for (const defect of ['modified', 'missing-digest', 'source-mismatch', 'source-missing', 'symlink', 'unrecorded-alias', 'merge-json']) { test(`legacy ${defect} activation refuses before build and preserves ownership`, value => { const state = addLegacyActivations(value);