From f8c0c2c182fe91839e2e083a34bababc287ba942 Mon Sep 17 00:00:00 2001 From: Samarjeet Singh Tomar Date: Mon, 7 Sep 2026 02:26:37 -0500 Subject: [PATCH 1/5] fix(install): gate OpenCode hook activation Signed-off-by: Samarjeet Singh Tomar --- scripts/lib/install-lifecycle.js | 8 ++- scripts/lib/install/apply.js | 9 ++- scripts/lib/install/hook-consent.js | 93 ++++++++++++++++++++++++- scripts/lib/install/plan.js | 5 +- tests/lib/hook-consent.test.js | 35 ++++++++++ tests/lib/install-executor.test.js | 102 ++++++++++++++++++++++++++++ tests/lib/install-lifecycle.test.js | 49 +++++++++++++ 7 files changed, 295 insertions(+), 6 deletions(-) diff --git a/scripts/lib/install-lifecycle.js b/scripts/lib/install-lifecycle.js index c10b1cfe3..1bc36fe6e 100644 --- a/scripts/lib/install-lifecycle.js +++ b/scripts/lib/install-lifecycle.js @@ -8,7 +8,10 @@ const { loadInstallManifests } = require('./install-manifests'); const { readInstallState, validateInstallState } = require('./install-state'); const { assertWithinTrustedRoot } = require('./path-safety'); const { createInstallPlanFromRequest } = require('./install/runtime'); -const { getRecordedHookConsent } = require('./install/hook-consent'); +const { + disableOpenCodeHookPluginRegistration, + getRecordedHookConsent, +} = require('./install/hook-consent'); const { prepareClaudeSkillMigration, } = require('./install/claude-skill-migration'); @@ -217,6 +220,9 @@ function transformCopyFileContent(operation, content) { if (operation.contentTransform === 'antigravity-agent-frontmatter') { return adaptAntigravityAgent(content, operation.sourceRelativePath); } + if (operation.contentTransform === 'opencode-disable-ecc-hooks') { + return disableOpenCodeHookPluginRegistration(content, operation.sourceRelativePath); + } throw new Error(`Unknown install content transform: ${operation.contentTransform}`); } diff --git a/scripts/lib/install/apply.js b/scripts/lib/install/apply.js index e755586a8..d5870feaf 100644 --- a/scripts/lib/install/apply.js +++ b/scripts/lib/install/apply.js @@ -9,7 +9,11 @@ const { withCommitAttributionDisabled, } = require('../claude-commit-attribution'); const { writeInstallState } = require('../install-state'); -const { assertHookConsentReady, planMaterializesHookRuntime } = require('./hook-consent'); +const { + assertHookConsentReady, + disableOpenCodeHookPluginRegistration, + planMaterializesHookRuntime, +} = require('./hook-consent'); const { filterMcpConfig, parseDisabledMcpServers } = require('../mcp-config'); const { assertWithinTrustedRoot } = require('../path-safety'); const { @@ -33,6 +37,9 @@ function transformInstallContent(operation, content) { if (operation.contentTransform === 'antigravity-agent-frontmatter') { return adaptAntigravityAgent(content, operation.sourceRelativePath); } + if (operation.contentTransform === 'opencode-disable-ecc-hooks') { + return disableOpenCodeHookPluginRegistration(content, operation.sourceRelativePath); + } throw new Error(`Unknown install content transform: ${operation.contentTransform}`); } diff --git a/scripts/lib/install/hook-consent.js b/scripts/lib/install/hook-consent.js index f12bd833c..ea382af1d 100644 --- a/scripts/lib/install/hook-consent.js +++ b/scripts/lib/install/hook-consent.js @@ -38,16 +38,52 @@ const HOOK_CAPABILITY_GROUPS = Object.freeze([ const HOOK_CONSENT_DECISIONS = Object.freeze(['enabled', 'declined']); const HOOK_RUNTIME_MODULE_ID = 'hooks-runtime'; +const OPENCODE_DISABLE_ECC_HOOKS_TRANSFORM = 'opencode-disable-ecc-hooks'; function normalizeOperationPath(value) { return String(value || '').replace(/\\/g, '/').toLowerCase(); } +function disableOpenCodeHookPluginRegistration(content, sourceRelativePath) { + let config; + try { + config = JSON.parse(content); + } catch (error) { + throw new Error(`Failed to parse ${sourceRelativePath}: ${error.message}`); + } + if (!config || typeof config !== 'object' || Array.isArray(config)) { + throw new Error(`Invalid ${sourceRelativePath}: expected a JSON object`); + } + if (config.plugin !== undefined && !Array.isArray(config.plugin)) { + throw new Error(`Invalid ${sourceRelativePath}: plugin must be an array`); + } + + if (!Array.isArray(config.plugin)) { + return `${JSON.stringify(config, null, 2)}\n`; + } + + return `${JSON.stringify({ + ...config, + plugin: config.plugin.filter(plugin => plugin !== './plugins'), + }, null, 2)}\n`; +} + +function isOpenCodeHookActivationOperation(operation = {}) { + return normalizeOperationPath(operation.sourceRelativePath) === '.opencode/opencode.json'; +} + function isHookRuntimeOperation(operation = {}) { if (operation.moduleId === HOOK_RUNTIME_MODULE_ID) { return true; } + if (isOpenCodeHookActivationOperation(operation)) { + return !( + operation.kind === 'copy-file' + && operation.contentTransform === OPENCODE_DISABLE_ECC_HOOKS_TRANSFORM + ); + } + const source = normalizeOperationPath(operation.sourceRelativePath); const destination = normalizeOperationPath(operation.destinationPath); return ( @@ -90,6 +126,50 @@ function withoutHookRuntimeId(values) { return (Array.isArray(values) ? values : []).filter(value => value !== HOOK_RUNTIME_MODULE_ID); } +function withoutOpenCodeHookActivation(operation) { + if ( + !isOpenCodeHookActivationOperation(operation) + || operation.kind !== 'copy-file' + ) { + return operation; + } + return { + ...operation, + contentTransform: OPENCODE_DISABLE_ECC_HOOKS_TRANSFORM, + }; +} + +function transformOpenCodeHookActivationOperations(operations) { + return (Array.isArray(operations) ? operations : []).map(withoutOpenCodeHookActivation); +} + +function planSelectsHookRuntime(plan = {}) { + return ( + Array.isArray(plan.selectedModuleIds) + && plan.selectedModuleIds.includes(HOOK_RUNTIME_MODULE_ID) + ) || ( + Array.isArray(plan.operations) + && plan.operations.some(operation => operation.moduleId === HOOK_RUNTIME_MODULE_ID) + ); +} + +function disableUnselectedOpenCodeHooks(plan) { + if (plan.target !== 'opencode' || planSelectsHookRuntime(plan)) { + return plan; + } + + return { + ...plan, + operations: transformOpenCodeHookActivationOperations(plan.operations), + statePreview: plan.statePreview + ? { + ...plan.statePreview, + operations: transformOpenCodeHookActivationOperations(plan.statePreview.operations), + } + : plan.statePreview, + }; +} + function setStatePreviewHookConsent(statePreview, hookConsent) { if (!statePreview || !statePreview.request) { return statePreview; @@ -131,11 +211,17 @@ function stripHookRuntimeFromPlan(plan) { const hadHookRuntimeModule = Array.isArray(plan.selectedModuleIds) && plan.selectedModuleIds.includes('hooks-runtime'); const operations = (Array.isArray(plan.operations) ? plan.operations : []) + .map(operation => ( + plan.target === 'opencode' ? withoutOpenCodeHookActivation(operation) : operation + )) .filter(operation => !isHookRuntimeOperation(operation)); const statePreview = plan.statePreview ? { ...plan.statePreview, operations: (Array.isArray(plan.statePreview.operations) ? plan.statePreview.operations : []) + .map(operation => ( + plan.target === 'opencode' ? withoutOpenCodeHookActivation(operation) : operation + )) .filter(operation => !isHookRuntimeOperation(operation)), resolution: plan.statePreview.resolution ? { @@ -164,10 +250,11 @@ function withHookConsent(plan, hookConsent = null) { if (hookConsent === 'declined') { return { ...stripHookRuntimeFromPlan(plan), hookConsent }; } + const effectivePlan = disableUnselectedOpenCodeHooks(plan); return { - ...plan, + ...effectivePlan, hookConsent, - statePreview: setStatePreviewHookConsent(plan.statePreview, hookConsent), + statePreview: setStatePreviewHookConsent(effectivePlan.statePreview, hookConsent), }; } @@ -190,6 +277,8 @@ function assertHookConsentReady(plan = {}) { module.exports = { HOOK_CAPABILITY_GROUPS, assertHookConsentReady, + disableUnselectedOpenCodeHooks, + disableOpenCodeHookPluginRegistration, formatHookCapabilityDisclosure, getRecordedHookConsent, isHookRuntimeOperation, diff --git a/scripts/lib/install/plan.js b/scripts/lib/install/plan.js index d98ef8f0b..1d7738c77 100644 --- a/scripts/lib/install/plan.js +++ b/scripts/lib/install/plan.js @@ -7,6 +7,7 @@ const { execFileSync } = require('child_process'); const { resolveInstallPlan } = require('../install-manifests'); const { getInstallTargetAdapter } = require('../install-targets/registry'); const { resolveInvocationEnvironment } = require('../invocation-environment'); +const { disableUnselectedOpenCodeHooks } = require('./hook-consent'); const EXCLUDED_GENERATED_SOURCE_SUFFIXES = ['/ecc-install-state.json', '/ecc/install-state.json']; const IGNORED_DIRECTORY_NAMES = new Set([ @@ -285,7 +286,7 @@ function createManifestInstallPlan(options = {}) { source }); - return { + return disableUnselectedOpenCodeHooks({ mode: options.mode || 'manifest', sourceRoot, target, @@ -311,7 +312,7 @@ function createManifestInstallPlan(options = {}) { excludedModuleIds: plan.excludedModuleIds, operations, statePreview - }; + }); } module.exports = { diff --git a/tests/lib/hook-consent.test.js b/tests/lib/hook-consent.test.js index 716a91b3a..f3f0e6d9a 100644 --- a/tests/lib/hook-consent.test.js +++ b/tests/lib/hook-consent.test.js @@ -7,6 +7,7 @@ const assert = require('assert'); const { HOOK_CAPABILITY_GROUPS, assertHookConsentReady, + disableOpenCodeHookPluginRegistration, formatHookCapabilityDisclosure, isHookRuntimeOperation, planMaterializesHookRuntime, @@ -80,6 +81,23 @@ function runTests() { }), false ); + assert.strictEqual(isHookRuntimeOperation({ + kind: 'copy-file', + moduleId: 'platform-configs', + sourceRelativePath: '.opencode/opencode.json', + }), true); + assert.strictEqual(isHookRuntimeOperation({ + kind: 'copy-file', + moduleId: 'platform-configs', + sourceRelativePath: '.opencode/opencode.json', + contentTransform: 'opencode-disable-ecc-hooks', + }), false); + assert.strictEqual(isHookRuntimeOperation({ + kind: 'merge-json', + moduleId: 'platform-configs', + sourceRelativePath: '.opencode/opencode.json', + contentTransform: 'opencode-disable-ecc-hooks', + }), true); assert.strictEqual(isHookRuntimeOperation({ sourceRelativePath: 'rules/common.md' }), false); assert.strictEqual( isHookRuntimeOperation({ sourceRelativePath: 'skills/webhooks-guide.md' }), @@ -96,6 +114,23 @@ function runTests() { assert.strictEqual(planMaterializesHookRuntime({}), false); })) passed++; else failed++; + if (test('removes only ECC hook activation from OpenCode config', () => { + const transformed = disableOpenCodeHookPluginRegistration(JSON.stringify({ + plugin: ['./plugins', 'example-plugin'], + instructions: ['AGENTS.md'], + }), '.opencode/opencode.json'); + assert.deepStrictEqual(JSON.parse(transformed), { + plugin: ['example-plugin'], + instructions: ['AGENTS.md'], + }); + assert.deepStrictEqual(JSON.parse(disableOpenCodeHookPluginRegistration( + JSON.stringify({ instructions: ['AGENTS.md'] }), + '.opencode/opencode.json' + )), { + instructions: ['AGENTS.md'], + }); + })) passed++; else failed++; + if (test('formats one numbered disclosure line per capability group', () => { const disclosure = formatHookCapabilityDisclosure(); const lines = disclosure.split('\n'); diff --git a/tests/lib/install-executor.test.js b/tests/lib/install-executor.test.js index 4a65ce5ef..e9f43d5b3 100644 --- a/tests/lib/install-executor.test.js +++ b/tests/lib/install-executor.test.js @@ -19,6 +19,8 @@ const { listAvailableLanguages, } = require('../../scripts/lib/install-executor'); const { applyInstallPlan: applyInstallPlanDirect } = require('../../scripts/lib/install/apply'); +const { normalizeInstallRequest } = require('../../scripts/lib/install/request'); +const { createInstallPlanFromRequest } = require('../../scripts/lib/install/runtime'); const REPO_ROOT = path.resolve(__dirname, '..', '..'); @@ -640,6 +642,106 @@ function runTests() { } })) passed++; else failed++; + if (test('OpenCode profile keeps plugin source dormant until hook opt-in is consented', () => { + const homeDir = createTempDir('install-executor-opencode-boundary-'); + try { + const planOptions = { + sourceRoot: REPO_ROOT, + homeDir, + projectRoot: homeDir, + exemptValidationCodes: ['opencode-plugin-not-built'], + }; + const rawDefaultPlan = createManifestInstallPlan({ + ...planOptions, + target: 'opencode', + profileId: 'opencode', + }); + assert.strictEqual( + rawDefaultPlan.operations.find(operation => ( + operation.sourceRelativePath.split(path.sep).join('/') === '.opencode/opencode.json' + )).contentTransform, + 'opencode-disable-ecc-hooks' + ); + const defaultPlan = createInstallPlanFromRequest( + normalizeInstallRequest({ target: 'opencode', profileId: 'opencode' }), + planOptions + ); + const defaultConfig = defaultPlan.operations.find(operation => ( + operation.sourceRelativePath.split(path.sep).join('/') === '.opencode/opencode.json' + )); + const defaultStateConfig = defaultPlan.statePreview.operations.find(operation => ( + operation.sourceRelativePath.split(path.sep).join('/') === '.opencode/opencode.json' + )); + + assert.strictEqual(defaultConfig.contentTransform, 'opencode-disable-ecc-hooks'); + assert.strictEqual(defaultStateConfig.contentTransform, 'opencode-disable-ecc-hooks'); + assert.ok(defaultPlan.operations.some(operation => ( + operation.sourceRelativePath.split(path.sep).join('/') === '.opencode/plugins/ecc-hooks.ts' + )), 'Default plan should still copy dormant plugin source'); + + applyInstallPlanDirect(defaultPlan, { writeInstallState() {} }); + const installedConfig = JSON.parse(fs.readFileSync( + path.join(homeDir, '.config', 'opencode', 'opencode.json'), + 'utf8' + )); + assert.ok(!installedConfig.plugin.includes('./plugins')); + + const enabledWithoutRuntime = createInstallPlanFromRequest( + normalizeInstallRequest({ + target: 'opencode', + profileId: 'opencode', + enableHooks: true, + }), + planOptions + ); + assert.strictEqual( + enabledWithoutRuntime.operations.find(operation => ( + operation.sourceRelativePath.split(path.sep).join('/') === '.opencode/opencode.json' + )).contentTransform, + 'opencode-disable-ecc-hooks' + ); + + const declinedPlan = createInstallPlanFromRequest( + normalizeInstallRequest({ target: 'opencode', profileId: 'core', noHooks: true }), + planOptions + ); + assert.strictEqual( + declinedPlan.operations.find(operation => ( + operation.sourceRelativePath.split(path.sep).join('/') === '.opencode/opencode.json' + )).contentTransform, + 'opencode-disable-ecc-hooks' + ); + assert.ok(!declinedPlan.operations.some(operation => operation.moduleId === 'hooks-runtime')); + + const pendingPlan = createInstallPlanFromRequest( + normalizeInstallRequest({ + target: 'opencode', + moduleIds: ['platform-configs', 'hooks-runtime'], + }), + planOptions + ); + assert.throws( + () => applyInstallPlanDirect(pendingPlan, { writeInstallState() {} }), + /automatic hook runtime/ + ); + + const enabledPlan = createInstallPlanFromRequest( + normalizeInstallRequest({ + target: 'opencode', + moduleIds: ['platform-configs', 'hooks-runtime'], + enableHooks: true, + }), + planOptions + ); + const enabledConfig = enabledPlan.operations.find(operation => ( + operation.sourceRelativePath.split(path.sep).join('/') === '.opencode/opencode.json' + )); + assert.strictEqual(enabledConfig.contentTransform, undefined); + } finally { + cleanup(homeDir); + } + })) passed++; else failed++; + if (test('Claude hooks install refuses a symlinked hooks destination', () => { if (process.platform === 'win32') return; diff --git a/tests/lib/install-lifecycle.test.js b/tests/lib/install-lifecycle.test.js index 51d39f9e1..4f362d158 100644 --- a/tests/lib/install-lifecycle.test.js +++ b/tests/lib/install-lifecycle.test.js @@ -1869,6 +1869,55 @@ function runTests() { } })) passed++; else failed++; + if (test('doctor dispatches the OpenCode hook-disable content transform consistently', () => { + const projectRoot = createTempDir('install-lifecycle-opencode-transform-'); + + try { + const targetRoot = path.join(projectRoot, '.cursor'); + const installStatePath = path.join(targetRoot, 'ecc-install-state.json'); + const destinationPath = path.join(targetRoot, 'opencode.json'); + const sourceConfig = JSON.parse(fs.readFileSync( + path.join(REPO_ROOT, '.opencode', 'opencode.json'), + 'utf8' + )); + const expectedContent = `${JSON.stringify({ + ...sourceConfig, + plugin: sourceConfig.plugin.filter(plugin => plugin !== './plugins'), + }, null, 2)}\n`; + fs.mkdirSync(targetRoot, { recursive: true }); + fs.writeFileSync(destinationPath, expectedContent, 'utf8'); + writeState(installStatePath, createCursorStateOptions(projectRoot, { + targetRoot, + installStatePath, + operations: [{ + kind: 'copy-file', + moduleId: 'platform-configs', + sourceRelativePath: '.opencode/opencode.json', + destinationPath, + strategy: 'preserve-relative-path', + ownership: 'managed', + scaffoldOnly: false, + contentTransform: 'opencode-disable-ecc-hooks', + }], + })); + + const report = buildDoctorReport({ + repoRoot: REPO_ROOT, + homeDir: projectRoot, + projectRoot, + targets: ['cursor'], + }); + + assert.strictEqual(report.results.length, 1); + assert.ok(!report.results[0].issues.some(issue => ( + issue.code === 'drifted-managed-files' + || issue.code === 'unverified-managed-operations' + ))); + } finally { + cleanup(projectRoot); + } + })) passed++; else failed++; + if (test('doctor infers enabled hooks from older manifest install-state records', () => { const homeDir = createTempDir('install-lifecycle-home-'); const projectRoot = createTempDir('install-lifecycle-project-'); From 9f6c16fe02131c4d6061ef96e980e4e98ef9b781 Mon Sep 17 00:00:00 2001 From: affaan-m <124439313+affaan-m@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:04:21 -0400 Subject: [PATCH 2/5] fix(tests): make guarded parent replacement portable to Windows --- tests/lib/guarded-write.test.js | 74 +++++++++++++++++++++++++++++---- 1 file changed, 67 insertions(+), 7 deletions(-) diff --git a/tests/lib/guarded-write.test.js b/tests/lib/guarded-write.test.js index e3b163264..44e537e96 100644 --- a/tests/lib/guarded-write.test.js +++ b/tests/lib/guarded-write.test.js @@ -113,21 +113,81 @@ test('a directory destination is refused without writes', ({ file, options }) => assert.throws(() => writeFileNoFollow(file, 'off', options)); assert.ok(fs.statSync(file).isDirectory()); }); -test('a parent replacement at open is refused', ({ root, options }) => { +test('a parent replacement before native open is refused even when the file identity is unchanged', ({ root, options }) => { const parent = path.join(root, 'plugins'); fs.mkdirSync(parent); const file = path.join(parent, 'entry.js'); fs.writeFileSync(file, 'old activation bytes'); - replaceMethod('openSync', original => (...args) => { - const fd = original(...args); + const fileIdentity = fs.statSync(file, { bigint: true }); + const originalOpen = fs.openSync; + const originalClose = fs.closeSync; + const originalTruncate = fs.ftruncateSync; + let descriptor; + let swaps = 0; + let closes = 0; + let truncates = 0; + fs.openSync = (...args) => { if (args[0] === file && typeof args[1] === 'number') { + // The writer has pinned the parent, but has not opened the file yet. + // Moving an open file's parent is not portable to Windows. Keep the + // same file inode and bytes so only the parent identity rejects this. fs.renameSync(parent, `${parent}.old`); fs.mkdirSync(parent); - fs.writeFileSync(file, 'replacement'); + fs.renameSync(path.join(`${parent}.old`, 'entry.js'), file); + swaps++; + descriptor = originalOpen(...args); + return descriptor; } - return fd; - }, () => assert.throws(() => writeFileNoFollow(file, 'off', existing(options)), /changed/)); - assert.strictEqual(fs.readFileSync(file, 'utf8'), 'replacement'); + return originalOpen(...args); + }; + fs.closeSync = fd => { if (fd === descriptor) closes++; return originalClose(fd); }; + fs.ftruncateSync = (...args) => { truncates++; return originalTruncate(...args); }; + try { + assert.throws(() => writeFileNoFollow(file, 'off', existing(options)), error => { + assert.match(error.message, /changed after preflight/); + assert.strictEqual(error.cause, undefined, 'the parent guard, not an open error, refuses'); + return true; + }); + } finally { + fs.openSync = originalOpen; + fs.closeSync = originalClose; + fs.ftruncateSync = originalTruncate; + } + assert.strictEqual(swaps, 1); + assert.strictEqual(typeof descriptor, 'number'); + assert.strictEqual(closes, 1); + assert.strictEqual(truncates, 0); + const replacementIdentity = fs.statSync(file, { bigint: true }); + assert.strictEqual(replacementIdentity.dev, fileIdentity.dev); + assert.strictEqual(replacementIdentity.ino, fileIdentity.ino); + assert.strictEqual(fs.readFileSync(file, 'utf8'), 'old activation bytes'); + assert.ok(fs.statSync(`${parent}.old`).isDirectory()); +}); +test('a denied native open preserves its cause without closing an unallocated descriptor', ({ file, options }) => { + fs.writeFileSync(file, 'old activation bytes'); + const denied = Object.assign(new Error('fixture open denied'), { code: 'EPERM' }); + const originalClose = fs.closeSync; + const originalTruncate = fs.ftruncateSync; + let closes = 0; + let truncates = 0; + fs.closeSync = fd => { closes++; return originalClose(fd); }; + fs.ftruncateSync = (...args) => { truncates++; return originalTruncate(...args); }; + try { + replaceMethod('openSync', original => (...args) => { + if (args[0] === file && typeof args[1] === 'number') throw denied; + return original(...args); + }, () => assert.throws(() => writeFileNoFollow(file, 'off', existing(options)), error => { + assert.strictEqual(error.cause, denied); + assert.strictEqual(error.code, 'EPERM'); + return true; + })); + } finally { + fs.closeSync = originalClose; + fs.ftruncateSync = originalTruncate; + } + assert.strictEqual(closes, 0); + assert.strictEqual(truncates, 0); + assert.strictEqual(fs.readFileSync(file, 'utf8'), 'old activation bytes'); }); test('destination validator is mandatory and must return the same path', ({ file, options }) => { assert.throws(() => writeFileNoFollow(file, 'off', {}), /validateDestination/); From 6dc706fe85cfd28f8b91e63ba18c06c5cf73ce95 Mon Sep 17 00:00:00 2001 From: affaan-m <124439313+affaan-m@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:23:11 -0400 Subject: [PATCH 3/5] fix(install): distinguish user plugins from ECC aliases Attribute unrecorded OpenCode aliases only to exact trusted ECC source or build bytes while keeping planned and recorded destinations strict. Pass the trusted source root through repair plans and preserve unrelated user plugin files. --- scripts/lib/install-lifecycle.js | 2 + scripts/lib/install/apply.js | 46 +++++++-- tests/lib/install-lifecycle.test.js | 2 +- .../lib/opencode-hook-consent-safety.test.js | 94 +++++++++++++++++++ 4 files changed, 137 insertions(+), 7 deletions(-) diff --git a/scripts/lib/install-lifecycle.js b/scripts/lib/install-lifecycle.js index 8b8ce2ca7..36122e7ea 100644 --- a/scripts/lib/install-lifecycle.js +++ b/scripts/lib/install-lifecycle.js @@ -1799,6 +1799,7 @@ function createRepairPlanFromRecord(record, context, options = {}) { const statePreview = buildRecordedStatePreview(state, context, operations); const recordedPlan = { + sourceRoot: context.repoRoot, mode: state.request.legacyMode ? 'legacy' : 'recorded', target: record.adapter.target, adapter: record.adapter, @@ -1950,6 +1951,7 @@ function preflightOpenCodeHookDeactivation(record, context, options = {}) { if (record.legacyLayout === 'opencode') { const state = record.state; const legacyPlan = withHookConsent({ + sourceRoot: context.repoRoot, target: 'opencode', adapter: record.adapter, targetRoot: record.targetRoot, diff --git a/scripts/lib/install/apply.js b/scripts/lib/install/apply.js index 0f8b27f51..39c7823ad 100644 --- a/scripts/lib/install/apply.js +++ b/scripts/lib/install/apply.js @@ -321,6 +321,35 @@ function getOpenCodeActivationKind(plan, operation) { return isOpenCodeHookActivationOperation(operation) ? 'config' : null; } +function readOpenCodeAliasForAttribution(plan, destinationPath) { + try { + const operation = { destinationPath }; + assertSafeInstallOperation(plan, operation); + if (!fs.lstatSync(destinationPath).isFile()) return null; + return readInstalledFileNoFollow(plan, operation); + } catch { + // Optional, unrecorded aliases have no ECC ownership until their bytes + // prove it. Never follow an unsafe path or relax recorded/planned guards. + return null; + } +} + +function knownOpenCodePluginDigests(plan) { + const digests = new Set(); + if (typeof plan.sourceRoot !== 'string' || !path.isAbsolute(plan.sourceRoot)) return digests; + const sourcePlan = { ...plan, targetRoot: plan.sourceRoot }; + for (const directory of ['.opencode/plugins', '.opencode/dist/plugins']) { + for (const name of ['ecc-hooks', 'index']) { + for (const extension of ['ts', 'js', 'mjs', 'cjs']) { + const content = readOpenCodeAliasForAttribution(sourcePlan, + path.join(plan.sourceRoot, directory, `${name}.${extension}`)); + if (content !== null) digests.add(crypto.createHash('sha256').update(content).digest('hex')); + } + } + } + return digests; +} + function openCodeActivationCandidates(plan, previousOperations) { const candidates = new Map(); for (const operation of [...previousOperations, ...plan.operations]) { @@ -328,17 +357,22 @@ function openCodeActivationCandidates(plan, previousOperations) { candidates.set(comparablePath(operation.destinationPath), operation); } } - // Old installs can leave aliases that are absent from the new source tree. - // Inspect only ECC's known entrypoint names, never unrelated user plugins. + // Old installs can leave unrecorded aliases, but names such as index.js + // are also used by unrelated plugins. Attribute only exact ECC artifacts. + const knownDigests = knownOpenCodePluginDigests(plan); for (const name of ['ecc-hooks', 'index']) { for (const extension of ['ts', 'js', 'mjs', 'cjs']) { const destinationPath = path.join(plan.targetRoot, 'plugins', `${name}.${extension}`); const key = comparablePath(destinationPath); if (!candidates.has(key)) { - candidates.set(key, { - sourceRelativePath: `.opencode/plugins/${name}.${extension}`, - destinationPath, - }); + const content = readOpenCodeAliasForAttribution(plan, destinationPath); + const digest = content === null ? null : crypto.createHash('sha256').update(content).digest('hex'); + if (knownDigests.has(digest)) { + candidates.set(key, { + sourceRelativePath: `.opencode/plugins/${name}.${extension}`, + destinationPath, + }); + } } } } diff --git a/tests/lib/install-lifecycle.test.js b/tests/lib/install-lifecycle.test.js index c1862581c..6a892797f 100644 --- a/tests/lib/install-lifecycle.test.js +++ b/tests/lib/install-lifecycle.test.js @@ -2239,7 +2239,7 @@ function runTests() { const pluginPath = path.join(recorded.targetRoot, 'plugins', 'index.ts'); const canonicalPluginPath = fs.realpathSync(pluginPath); const aliasPath = path.join(recorded.targetRoot, 'plugins', 'index.js'); - const aliasContent = 'globalThis.lateActiveAlias = true;\n'; + const aliasContent = fs.readFileSync(path.join(REPO_ROOT, '.opencode', 'plugins', 'index.ts'), 'utf8'); const stateBefore = fs.readFileSync(recorded.installStatePath); let inserted = false; fs.openSync = function trackPluginWriteDescriptor(candidate, ...args) { diff --git a/tests/lib/opencode-hook-consent-safety.test.js b/tests/lib/opencode-hook-consent-safety.test.js index 09b5a9fc7..4f6b05b23 100644 --- a/tests/lib/opencode-hook-consent-safety.test.js +++ b/tests/lib/opencode-hook-consent-safety.test.js @@ -220,6 +220,100 @@ function runTests() { { plugin: [], userSetting: true }); assert.strictEqual(fs.existsSync(`${value.installStatePath}.ecc.lock`), false); })); + for (const consent of [null, 'declined']) { + test(`fresh ${consent || 'default'} apply preserves unrelated unrecorded plugin aliases`, () => fixture(value => { + fs.unlinkSync(value.installStatePath); + for (const operation of value.basePlan.operations) fs.unlinkSync(operation.destinationPath); + const aliases = ['index.ts', 'index.js', 'index.mjs', 'index.cjs', 'ecc-hooks.js']; + const content = 'export default async () => ({ "user.plugin": () => {} });\n'; + for (const alias of aliases) fs.writeFileSync(path.join(value.targetRoot, 'plugins', alias), content); + const result = applyInstallPlan(withHookConsent(value.basePlan, consent)); + assert.strictEqual(result.applied, true); + const state = readInstallState(value.installStatePath); + for (const alias of aliases) { + const destination = path.join(value.targetRoot, 'plugins', alias); + assert.strictEqual(fs.readFileSync(destination, 'utf8'), content); + assert.ok(!state.operations.some(operation => operation.destinationPath === destination), 'Do not adopt a user plugin'); + } + })); + } + for (const mode of ['doctor', 'repair']) { + test(`${mode} preserves an unrelated plugin without reporting ECC activation`, () => fixture(value => { + applyInstallPlan(value.declinePlan); + const destination = path.join(value.targetRoot, 'plugins', 'index.js'); + const content = 'export default async () => ({ "user.plugin": () => {} });\n'; + fs.writeFileSync(destination, content); + const before = fs.readFileSync(value.installStatePath); + if (mode === 'doctor') { + const result = buildDoctorReport({ repoRoot: value.sourceRoot, homeDir: value.homeDir, + projectRoot: value.homeDir, targets: ['opencode'] }).results[0]; + assert.ok(!result.issues.some(issue => issue.code === 'opencode-hook-consent-violation'), JSON.stringify(result.issues)); + } else { + const result = repair(value).results[0]; + assert.notStrictEqual(result.status, 'error', result.error); + } + assert.strictEqual(fs.readFileSync(destination, 'utf8'), content); + const { lastValidatedAt: _beforeValidation, ...priorState } = JSON.parse(before); + const { lastValidatedAt: _afterValidation, ...afterState } = readInstallState(value.installStatePath); + assert.deepStrictEqual(afterState, priorState, 'Only the legitimate validation timestamp may change'); + assert.ok(!afterState.operations.some(operation => operation.destinationPath === destination), 'Do not adopt a user plugin'); + })); + } + for (const artifact of ['source', 'build']) { + test(`unrecorded ${artifact}-identical ECC alias still fails closed`, () => fixture(value => { + applyInstallPlan(value.declinePlan); + const source = artifact === 'source' + ? path.join(value.sourceRoot, '.opencode', 'plugins', 'ecc-hooks.ts') + : path.join(value.sourceRoot, '.opencode', 'dist', 'plugins', 'index.js'); + if (artifact === 'build') fs.writeFileSync(source, 'module.exports = { eccHook: true };\n'); + const content = fs.readFileSync(source); + const alias = path.join(value.targetRoot, 'plugins', 'index.js'); + fs.writeFileSync(alias, content); + const before = fs.readFileSync(value.installStatePath); + assert.throws(() => applyInstallPlan(value.declinePlan), /OpenCode hook deactivation/); + const doctor = buildDoctorReport({ repoRoot: value.sourceRoot, homeDir: value.homeDir, + projectRoot: value.homeDir, targets: ['opencode'] }).results[0]; + assert.ok(doctor.issues.some(issue => issue.code === 'opencode-hook-consent-violation')); + assert.strictEqual(repair(value).results[0].status, 'error'); + assert.deepStrictEqual(fs.readFileSync(alias), content); + assert.deepStrictEqual(fs.readFileSync(value.installStatePath), before); + })); + } + test('an unrecorded collision at a planned ECC plugin destination still fails closed', () => fixture(value => { + fs.unlinkSync(value.installStatePath); + fs.unlinkSync(path.join(value.targetRoot, 'opencode.json')); + const destination = path.join(value.targetRoot, 'plugins', 'ecc-hooks.ts'); + const content = '// user-owned file at an ECC destination\n'; + fs.writeFileSync(destination, content); + assert.throws(() => applyInstallPlan(value.declinePlan), /user-owned|unverifiable/i); + assert.strictEqual(fs.readFileSync(destination, 'utf8'), content); + assert.strictEqual(fs.existsSync(value.installStatePath), false); + })); + for (const planned of [false, true]) { + test(`${planned ? 'planned ECC' : 'unrecorded user'} plugin read failures respect the attribution boundary`, () => fixture(value => { + const destination = path.join(value.targetRoot, 'plugins', planned ? 'ecc-hooks.ts' : 'index.js'); + const content = planned ? fs.readFileSync(destination) : Buffer.from('// unreadable user plugin\n'); + if (!planned) fs.writeFileSync(destination, content); + const originalOpen = fs.openSync; + let refusedReads = 0; + fs.openSync = function (candidate, ...args) { + if (typeof candidate === 'string' && path.resolve(candidate) === destination) { + refusedReads++; + throw Object.assign(new Error('Synthetic plugin read permission denied'), { code: 'EACCES' }); + } + return originalOpen.call(fs, candidate, ...args); + }; + try { + if (planned) assert.throws(() => applyInstallPlan(value.declinePlan), /permission denied/); + else assert.strictEqual(applyInstallPlan(value.declinePlan).applied, true); + assert.ok(refusedReads > 0, 'The permission boundary must be exercised'); + } finally { + fs.openSync = originalOpen; + } + assert.deepStrictEqual(fs.readFileSync(destination), content); + if (!planned) assert.ok(!readInstallState(value.installStatePath).operations.some(operation => operation.destinationPath === destination)); + })); + } test('repair preserves the primary failure and replacement lock when release also fails', () => fixture(value => { const destination = path.join(value.targetRoot, 'opencode.json'); const lock = `${value.installStatePath}.ecc.lock`; From 429384f80c2709dd50d43403f4bed35b0e95317c Mon Sep 17 00:00:00 2001 From: affaan-m <124439313+affaan-m@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:12:34 -0400 Subject: [PATCH 4/5] fix(opencode): refuse known legacy hooks without consent --- scripts/lib/install/apply.js | 67 ++++++++++- .../lib/opencode-hook-consent-safety.test.js | 107 ++++++++++++++++++ 2 files changed, 172 insertions(+), 2 deletions(-) diff --git a/scripts/lib/install/apply.js b/scripts/lib/install/apply.js index 39c7823ad..206a21ac3 100644 --- a/scripts/lib/install/apply.js +++ b/scripts/lib/install/apply.js @@ -334,9 +334,72 @@ function readOpenCodeAliasForAttribution(plan, destinationPath) { } } +// Finite, exact public ECC entrypoint history reachable from d3b8a3e908904e242ed2dbe66af62cca71131419. +// Refusal evidence only: matching bytes never grant ownership or permission to +// adopt, rewrite or delete an unrecorded file. Unknown modified/compiled variants +// are not covered. No history lookup or plugin execution occurs at runtime. +const LEGACY_OPENCODE_PLUGIN_DIGESTS = Object.freeze([ + // a0600a00fbe3a193a44584ad55800ce82cec62af:.opencode/plugins/index.ts (blob 3a98f0ba6510d436cc9cf3e2161f8f69771d968a) + '7dd2d255da5d4344eb38ca93cf1765e425b0c01943f6e45428614662ebee0d4b', + // a0600a00fbe3a193a44584ad55800ce82cec62af:.opencode/plugins/ecc-hooks.ts (blob bf06c03f8ff6bdd835c5266921758a6db85152bf) + '5db9b59434af0d5971538f0176779733b8146d7a71fbd9055ae0183c26754068', + // 91ba9b4cf6c47c8130829004f8bb64762a76ccbb:.opencode/plugins/ecc-hooks.ts (blob 4aabde61203d4473e04d5a10803b0560b8c596e4) + 'c683b9321d8b5fbc6889b1740f4583c4f94c84554ee97e2072f61de45c661bda', + // 1a8beb71c5282ddfe77c72ab0290961a820e3d89:.opencode/plugins/ecc-hooks.ts (blob 69b59727e552991a79c196aab8ec128173329d9a) + '1e890ce162325c9d7c579b0716383b6c297179edb78084c4b59cc8bf766ceb71', + // e65f12bf7ea474a6f5ac96991a251673f474b445:.opencode/plugins/index.ts (blob c1e17a1595403080490fcec6820c1485bb6afba9) + '965c5fac76ce0c3ceb3836814f5eb9ede8c9db50373a508c734f949cb321a21a', + // e65f12bf7ea474a6f5ac96991a251673f474b445:.opencode/plugins/ecc-hooks.ts (blob 54881ad868c276ff0d50cc83d8ae938464ad02da) + '5c043b84693a654fffe4b407e87411b28c9af8b92f5ef49a03caab7b27f03102', + // 2cdc218c45a81ce46035832b13bf68d91137301e:.opencode/plugins/ecc-hooks.ts (blob d496e61a538131ff6f33b2e6d941544e3d94c5d8) + '73692e599d271bbb9b7aac59f97e193518af2b5db3a3505af0376c8d8657220a', + // 5929d246946eeb5d147612ba06d60c575c5a4e21:.opencode/plugins/ecc-hooks.ts (blob 472f80f5ae9500fa9a0a7885b6ce4dc4b409d3d6) + 'd7a410380ed2e0bcb613110b2810221d03a8944e50766bc7a4db2eb1b44446b6', + // ca185ef5f7667078a1e70a763bd3a9c71c48acf0:.opencode/plugins/ecc-hooks.ts (blob 22b1132f0964bd4ba5c1a4ad1bafa605de99eb6a) + '0345093b34e537d350c5b5aa0296511f558aa767e5104fb5ef05069013f3b5b6', + // 28e53a0bc10e286f68b53bb1e3b3f049021e57b9:.opencode/plugins/ecc-hooks.ts (blob 47265c0ebd031168d8e3a18f30036864338cd22c) + 'e20ecd53714b1fd55baeff796c6f4538ebd4bae71ca1e791b2b8e29575061846', + // 591ab5cbd3f2f65860ea91c226e410b1502c8e2e:.opencode/plugins/ecc-hooks.ts (blob 49124c255003eb5517178a8ecad7dd453303df33) + 'b9c22c76ae2464c9410963579ee5ff49003e4d79e32b69c228539ae7b15f5104', + // 6f452d48d258b39f4f6e1171b7ca18c6f7f61ad5:.opencode/plugins/ecc-hooks.ts (blob 6336081e97c4345f02adfdc0b9ad9e27dccdec04) + 'c7122565cf97b896cc3da9009bf06daca7513b3e9ba7448c26b8872591dbf3f7', + // 3a08b0c7a85bda69ee9922a103e077a04d538150:.opencode/plugins/ecc-hooks.ts (blob bad6a4cecf2270a7d8a919daeb6541c94a810c48) + 'e438603c13206365068b400063df0af98bd587842b80f09b97fe57f7ddef56e0', + // 29edd57708bee26f16363c16a28fec7f6b09f53f:.opencode/plugins/ecc-hooks.ts (blob 05792ce9ae86a785b746bdb843572e4b5bc93130) + '6a9063b2f67334a78d269d53f95679c33d6d126260f8e5fc7cc941fea9b903e8', + // 8141f6904f14fa8a83131e1cb5b6507d687e25bb:.opencode/plugins/ecc-hooks.ts (blob 606bcb7c59aa5e459d2093ffb9cf9208d1184c30) + 'a198b640fd1faf1c75e96909eabf4ae24899de127b2447490eed813766013836', + // 6d613f67dd24189a8bb7fb1a2f5e535957f46a58:.opencode/plugins/index.ts (blob ca58596901d816147ac4eff525f1a885d36bd094) + 'e89aaa309b7a0578bb69af4a2425744fcf14c2556cd34a1036bbcba448a0b517', + // 6d613f67dd24189a8bb7fb1a2f5e535957f46a58:.opencode/plugins/ecc-hooks.ts (blob 31cfa8ac31ac3cbc5c51b4b275017ef18b8f9033) + 'd66a43e43ef9669589de593e8f94e750ee11d3c75cb5fe79e81636ef738c3e45', + // affbd334858368518c5baf5f84f74034dea1ea6f:.opencode/plugins/ecc-hooks.ts (blob ff8628b5fd47181cbee54367dc4e32e5392cde1a) + '4874a12639fd58da59a54fe5b2461c0ddd2eeca6770111615caa402ff0e95693', + // 0a87323eda77ee412fa3a3bf028a577536966505:.opencode/plugins/ecc-hooks.ts (blob fa96b805685e3c3e6f86535debca5ab8a5bea1ef) + '4e2330f340e074208cd323c1a833667032ce8db4febc4eaeda354bc49cb58bc4', + // a0a1eda8fc4828e58dc8aabcec4e25f9ef038a0a:.opencode/plugins/ecc-hooks.ts (blob 51bde010b4d426676b52ffc9567b1da80f4ca510) + 'ca9abadee5d072121677168752fb4f3b16ce9fc7eb55a3c9c7f517377e0bf69b', + // 05acc275307a09eea89080619a35d7dbd20b128b:.opencode/plugins/ecc-hooks.ts (blob 9e4ab3fcd50f6610cfdfa5a7d0d71c2374f65745) + '96998990d6aac0b9535ab6ab60a0be1c284ffcca7e4ba04f1cfa0e67409a8146', + // a2b3cc1600e9cab58147ef01c03f9889b5a8cc86:.opencode/plugins/ecc-hooks.ts (blob 58a209283f70efe1dbfef5d78b4d72764c67f027) + '0697bfed6e6ad887443a32810e83adb5316d2c9c0490b98f9fcb6ea52bea84e3', + // 0c7deb26a344db095c04a213eba5634d4ccce030:.opencode/plugins/ecc-hooks.ts (blob 9193bb412920a1f1d5af98fda0a66d1e3295f46f) + 'd666a94e9d0ccbcfdeffd59b624938cd44706571eec3771974c57fdbe28577c1', + // 48b883d7412914b04c8b185d9a82685b105d1734:.opencode/plugins/ecc-hooks.ts (blob 3053314750a61dbcdb06a9cca39492304457f582) + '16fe21ca801a613a0ae2fc1f8dd5c8474138dc31c75ea35cd8695884397f1f15', + // d70bab85e33af7a03b78c70dba7a7ce3b01d1b17:.opencode/plugins/ecc-hooks.ts (blob 1f158d7999f5f100e386587a94a90a08d512e278) + '0354270a5fc26809d0795ecc7eef1dee91de4905d58f26d5828d43af24767b96', + // 0e9f613fd196f6d4157765b17d39c2c42ebbf564:.opencode/plugins/ecc-hooks.ts (blob 50d23bfde3607832446fda26b25a3ed3e527e5d1) + '513190b6c935dac472efd11818b20d7f2479ec1f7be06ef7f4d241c2493ad9e3', + // 6d440c036df2c1b2fec957627d1202c3708e0627:.opencode/plugins/index.ts (blob d19a91f1a686d6ed060d08eddeb5aa05a4be6b75) + 'e42c733adb177f84cea813663aa34c7868dbaa98c96950d0ef91cd211b8aa169', + // 6d440c036df2c1b2fec957627d1202c3708e0627:.opencode/plugins/ecc-hooks.ts (blob b64ffae7ce10cab9e5ed9b04cec23d62db9036e7) + '503ea491cbeadff5bf59b936a75bff65caaf1d71e47a953a9b9b790be780efef', +]); + function knownOpenCodePluginDigests(plan) { - const digests = new Set(); - if (typeof plan.sourceRoot !== 'string' || !path.isAbsolute(plan.sourceRoot)) return digests; + if (typeof plan.sourceRoot !== 'string' || !path.isAbsolute(plan.sourceRoot)) return new Set(); + const digests = new Set(LEGACY_OPENCODE_PLUGIN_DIGESTS); const sourcePlan = { ...plan, targetRoot: plan.sourceRoot }; for (const directory of ['.opencode/plugins', '.opencode/dist/plugins']) { for (const name of ['ecc-hooks', 'index']) { diff --git a/tests/lib/opencode-hook-consent-safety.test.js b/tests/lib/opencode-hook-consent-safety.test.js index 4f6b05b23..46bbb725a 100644 --- a/tests/lib/opencode-hook-consent-safety.test.js +++ b/tests/lib/opencode-hook-consent-safety.test.js @@ -13,6 +13,47 @@ const { buildDoctorReport, repairInstalledStates } = require('../../scripts/lib/ const REPO_ROOT = path.resolve(__dirname, '../..'); const sha256 = value => crypto.createHash('sha256').update(value).digest('hex'); +// Authentic public ECC source fixtures, kept as inert bytes (never imported). +// Copying these bytes to an alias does not claim they were published build output. +const legacyPluginFixtures = [ + { + name: "2.2.1 barrel", + // https://github.com/affaan-m/ECC/blob/ca185ef5f7667078a1e70a763bd3a9c71c48acf0/.opencode/plugins/index.ts + sha256: '965c5fac76ce0c3ceb3836814f5eb9ede8c9db50373a508c734f949cb321a21a', + content: `/** + * ECC Plugins for OpenCode + * + * This module exports all ECC plugins for OpenCode integration. + * Plugins provide hook-based automation that mirrors Claude Code's hook system + * while taking advantage of OpenCode's more sophisticated 20+ event types. + */ + +export { ECCHooksPlugin, default } from "./ecc-hooks.js" + +// Re-export for named imports +export * from "./ecc-hooks.js" +`, + }, + { + name: "early barrel", + // https://github.com/affaan-m/ECC/blob/6d440c036df2c1b2fec957627d1202c3708e0627/.opencode/plugins/index.ts + sha256: 'e42c733adb177f84cea813663aa34c7868dbaa98c96950d0ef91cd211b8aa169', + content: `/** + * Everything Claude Code (ECC) Plugins for OpenCode + * + * This module exports all ECC plugins for OpenCode integration. + * Plugins provide hook-based automation that mirrors Claude Code's hook system + * while taking advantage of OpenCode's more sophisticated 20+ event types. + */ + +export { ECCHooksPlugin, default } from "./ecc-hooks" + +// Re-export for named imports +export * from "./ecc-hooks" +`, + }, +]; + function fixture(callback, enabled = false) { const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-opencode-write-'))); const homeDir = path.join(root, 'home'); @@ -259,6 +300,72 @@ function runTests() { assert.ok(!afterState.operations.some(operation => operation.destinationPath === destination), 'Do not adopt a user plugin'); })); } + for (const legacy of legacyPluginFixtures) { + for (const consent of [null, 'declined']) { + test(`fresh ${consent || 'default'} apply refuses the unrecorded historical ${legacy.name}`, () => fixture(value => { + assert.strictEqual(sha256(legacy.content), legacy.sha256, 'Preserve exact public-source fixture bytes'); + assert.notStrictEqual(sha256(fs.readFileSync(path.join(value.sourceRoot, '.opencode/plugins/ecc-hooks.ts'))), legacy.sha256); + assert.notStrictEqual(sha256(fs.readFileSync(path.join(REPO_ROOT, '.opencode/plugins/index.ts'))), legacy.sha256); + fs.unlinkSync(value.installStatePath); + for (const operation of value.basePlan.operations) fs.unlinkSync(operation.destinationPath); + const alias = path.join(value.targetRoot, 'plugins', 'index.js'); + assert.ok(!value.basePlan.operations.some(operation => operation.destinationPath === alias)); + fs.writeFileSync(alias, legacy.content); + assert.throws(() => applyInstallPlan(withHookConsent(value.basePlan, consent)), /Refusing OpenCode hook deactivation/); + assert.strictEqual(fs.readFileSync(alias, 'utf8'), legacy.content); + assert.strictEqual(fs.existsSync(value.installStatePath), false, 'Do not adopt an unrecorded historical alias'); + for (const operation of value.basePlan.operations) assert.strictEqual(fs.existsSync(operation.destinationPath), false); + assert.strictEqual(fs.existsSync(`${value.installStatePath}.ecc.lock`), false); + })); + } + for (const mode of ['doctor', 'repair']) { + test(`${mode} refuses the unrecorded historical ${legacy.name} without changing ownership`, () => fixture(value => { + applyInstallPlan(value.declinePlan); + const alias = path.join(value.targetRoot, 'plugins', 'index.js'); + fs.writeFileSync(alias, legacy.content); + const before = fs.readFileSync(value.installStatePath); + const operationsBefore = value.basePlan.operations.map(operation => fs.readFileSync(operation.destinationPath)); + assert.ok(!readInstallState(value.installStatePath).operations.some(operation => operation.destinationPath === alias)); + if (mode === 'doctor') { + const result = buildDoctorReport({ repoRoot: value.sourceRoot, homeDir: value.homeDir, + projectRoot: value.homeDir, targets: ['opencode'] }).results[0]; + const issue = result.issues.find(entry => entry.code === 'opencode-hook-consent-violation'); + assert.ok(issue, JSON.stringify(result.issues)); + assert.match(issue.message, /OpenCode hook activation remains active/); + } else { + const result = repair(value).results[0]; + assert.strictEqual(result.status, 'error'); + assert.match(result.error, /Refusing OpenCode hook deactivation/); + assert.notStrictEqual(result.stateRefreshed, true); + } + assert.strictEqual(fs.readFileSync(alias, 'utf8'), legacy.content); + assert.deepStrictEqual(fs.readFileSync(value.installStatePath), before); + assert.deepStrictEqual(value.basePlan.operations.map(operation => fs.readFileSync(operation.destinationPath)), operationsBefore); + assert.strictEqual(fs.existsSync(`${value.installStatePath}.ecc.lock`), false); + })); + } + } + for (const mode of ['apply', 'repair']) { + test(`${mode} refuses a historical alias inserted after preflight before state refresh`, () => fixture(value => { + const destination = path.join(value.targetRoot, 'plugins', 'ecc-hooks.ts'); + const alias = path.join(value.targetRoot, 'plugins', 'index.js'); + const content = legacyPluginFixtures[0].content; + withWritableOpenMutation(destination, () => fs.writeFileSync(alias, content), () => { + if (mode === 'apply') assert.throws(() => applyInstallPlan(value.declinePlan), /OpenCode hook activation remains active/); + else { + const result = repair(value).results[0]; + assert.strictEqual(result.status, 'error'); + assert.match(result.error, /OpenCode hook activation remains active/); + assert.notStrictEqual(result.stateRefreshed, true); + } + assert.strictEqual(fs.readFileSync(alias, 'utf8'), content); + const state = readInstallState(value.installStatePath); + assert.ok(!state.operations.some(operation => operation.destinationPath === alias)); + assert.strictEqual(state.request.hookConsent, value.state.request.hookConsent); + assert.strictEqual(fs.existsSync(`${value.installStatePath}.ecc.lock`), false); + }); + })); + } for (const artifact of ['source', 'build']) { test(`unrecorded ${artifact}-identical ECC alias still fails closed`, () => fixture(value => { applyInstallPlan(value.declinePlan); From e9911d66687363c9912cb502eb12b01bcea020fa Mon Sep 17 00:00:00 2001 From: affaan-m <124439313+affaan-m@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:41:08 -0400 Subject: [PATCH 5/5] fix(opencode): preserve primary failures through complete lock cleanup --- scripts/lib/install/opencode-install-lock.js | 33 +++- tests/lib/opencode-install-lock.test.js | 187 +++++++++++++++++++ 2 files changed, 212 insertions(+), 8 deletions(-) diff --git a/scripts/lib/install/opencode-install-lock.js b/scripts/lib/install/opencode-install-lock.js index 8b80e0b5c..4b690d3d3 100644 --- a/scripts/lib/install/opencode-install-lock.js +++ b/scripts/lib/install/opencode-install-lock.js @@ -56,18 +56,31 @@ function assertOwnedLock(owned) { } } +function annotateCleanupFailure(primary, property, value) { + try { + // Own data properties avoid invoking caller getters/setters. Frozen values, + // primitives and rejecting proxy traps simply retain no extra diagnostic. + Object.defineProperty(primary, property, { value, configurable: true, enumerable: true, writable: true }); + } catch { + // Diagnostics must never replace the exact primary thrown value. + } +} + function releaseOwned(ownedLocks) { - let primaryError; + const failures = []; for (const owned of [...ownedLocks].reverse()) { try { assertOwnedLock(owned); owned.release(); } catch (error) { - if (!primaryError) primaryError = error; - else (primaryError.releaseErrors ||= []).push(error); + failures.push(error); } } - if (primaryError) throw primaryError; + // Finish every safe release before touching any caller-owned error object. + if (failures.length > 0) { + if (failures.length > 1) annotateCleanupFailure(failures[0], 'releaseErrors', failures.slice(1)); + throw failures[0]; + } } function acquireOpenCodeInstallLocks(roots, existingLease) { @@ -97,7 +110,7 @@ function acquireOpenCodeInstallLocks(roots, existingLease) { } } catch (error) { try { releaseOwned(ownedLocks); } - catch (releaseError) { error.releaseError = releaseError; } + catch (releaseError) { annotateCleanupFailure(error, 'releaseError', releaseError); } throw error; } const lease = Object.freeze({}); @@ -115,13 +128,17 @@ function acquireOpenCodeInstallLocks(roots, existingLease) { function withOpenCodeInstallLocks(roots, callback, existingLease) { if (typeof callback !== 'function') throw new TypeError('OpenCode install lock callback must be a function.'); const holder = acquireOpenCodeInstallLocks(roots, existingLease); + let didThrow = false; let primaryError; let result; try { result = callback(holder.lease); } - catch (error) { primaryError = error; } + catch (error) { didThrow = true; primaryError = error; } try { holder.release(); } - catch (error) { if (primaryError) primaryError.releaseError = error; else primaryError = error; } - if (primaryError) throw primaryError; + catch (error) { + if (didThrow) annotateCleanupFailure(primaryError, 'releaseError', error); + else { didThrow = true; primaryError = error; } + } + if (didThrow) throw primaryError; return result; } diff --git a/tests/lib/opencode-install-lock.test.js b/tests/lib/opencode-install-lock.test.js index 44e01569f..defa39598 100644 --- a/tests/lib/opencode-install-lock.test.js +++ b/tests/lib/opencode-install-lock.test.js @@ -233,5 +233,192 @@ test('engine ownership metadata is immutable and derived from the acquired descr } finally { release(); } assert.strictEqual(fs.existsSync(`${settingsPath}.ecc.lock`), false); }); + +function captureThrown(callback) { + try { return { didThrow: false, result: callback() }; } + catch (value) { return { didThrow: true, value }; } +} + +const falsyThrownValues = [undefined, null, false, 0, '', NaN]; +for (const [index, primary] of falsyThrownValues.entries()) { + for (const cleanupFails of [false, true]) { + test(`falsy callback value ${index} survives ${cleanupFails ? 'failed' : 'healthy'} cleanup`, root => { + let lease; + const caught = captureThrown(() => withOpenCodeInstallLocks([root], active => { + lease = active; + if (cleanupFails) { + fs.renameSync(lockPath(root), `${lockPath(root)}.owned`); + fs.writeFileSync(lockPath(root), 'replacement'); + } + throw primary; + })); + assert.strictEqual(caught.didThrow, true, 'A thrown falsy value must not become success'); + assert.ok(Object.is(caught.value, primary), 'Preserve the exact thrown value, including NaN'); + assert.throws(() => acquireOpenCodeInstallLocks([root], lease), /inactive/); + if (cleanupFails) { + assert.strictEqual(fs.readFileSync(lockPath(root), 'utf8'), 'replacement'); + assert.ok(fs.existsSync(`${lockPath(root)}.owned`)); + } else assert.strictEqual(fs.existsSync(lockPath(root)), false); + }); + } +} + +for (const kind of ['frozen', 'nonextensible', 'nonwritable', 'accessor', 'proxy', 'primitive']) { + test(`${kind} callback primary survives cleanup failure without invoking accessors`, root => { + const a = path.join(root, 'a'); + const b = path.join(root, 'b'); + const marker = new Error('preexisting release diagnostic'); + let accesses = 0; + let definitions = 0; + let primary = new Error('primary'); + if (kind === 'frozen') Object.freeze(primary); + if (kind === 'nonextensible') Object.preventExtensions(primary); + if (kind === 'nonwritable') Object.defineProperty(primary, 'releaseError', { value: marker }); + if (kind === 'accessor') Object.defineProperty(primary, 'releaseError', { + configurable: true, + get() { accesses++; throw new Error('getter must not run'); }, + set() { accesses++; throw new Error('setter must not run'); } + }); + if (kind === 'proxy') primary = new Proxy(primary, { defineProperty() { + definitions++; + assert.strictEqual(fs.existsSync(lockPath(a)), false, 'All safe cleanup precedes annotation'); + throw new Error('annotation rejected'); + } }); + if (kind === 'primitive') primary = 'literal primary'; + let lease; + const caught = captureThrown(() => withOpenCodeInstallLocks([a, b], active => { + lease = active; + fs.renameSync(lockPath(b), `${lockPath(b)}.owned`); + fs.writeFileSync(lockPath(b), 'replacement'); + throw primary; + })); + assert.strictEqual(caught.didThrow, true); + assert.ok(Object.is(caught.value, primary)); + assert.strictEqual(accesses, 0); + if (kind === 'proxy') assert.strictEqual(definitions, 1); + if (kind === 'nonwritable') assert.strictEqual(primary.releaseError, marker); + if (kind === 'accessor') { + const descriptor = Object.getOwnPropertyDescriptor(primary, 'releaseError'); + assert.ok('value' in descriptor, 'Diagnostic should be an own data property'); + assert.match(descriptor.value.message, /changed/); + } + assert.strictEqual(fs.existsSync(lockPath(a)), false); + assert.strictEqual(fs.readFileSync(lockPath(b), 'utf8'), 'replacement'); + assert.ok(fs.existsSync(`${lockPath(b)}.owned`)); + assert.throws(() => acquireOpenCodeInstallLocks([a], lease), /inactive/); + }); +} + +for (const kind of ['mutable', 'frozen', 'frozen array', 'non-array', 'readonly', 'accessor', 'proxy']) { + test(`three-root cleanup preserves ${kind} first failure and finishes reverse cleanup`, root => { + const roots = ['a', 'b', 'c'].map(name => path.join(root, name)); + const [a, b, c] = roots; + const originalRename = fs.renameSync; + const secondary = new Error('second cleanup failure'); + const priorDiagnostic = new Error('prior diagnostic'); + const originalArray = Object.freeze([priorDiagnostic]); + let accesses = 0; + let primary = new Error('first cleanup failure'); + if (kind === 'frozen') Object.freeze(primary); + if (kind === 'frozen array') primary.releaseErrors = originalArray; + if (kind === 'non-array') primary.releaseErrors = { push() { accesses++; throw new Error('caller push must not run'); } }; + if (kind === 'readonly') Object.defineProperty(primary, 'releaseErrors', { value: originalArray }); + if (kind === 'accessor') Object.defineProperty(primary, 'releaseErrors', { + get() { accesses++; throw new Error('caller getter must not run'); }, + set() { accesses++; throw new Error('caller setter must not run'); } + }); + if (kind === 'proxy') primary = new Proxy(primary, { defineProperty() { + assert.strictEqual(fs.existsSync(lockPath(a)), false, 'Finish cleanup before a diagnostic trap'); + throw new Error('diagnostic trap'); + } }); + const holder = acquireOpenCodeInstallLocks(roots); + const attempts = []; + fs.renameSync = (from, to) => { + if (roots.some(candidate => lockPath(candidate) === from)) attempts.push(from); + if (from === lockPath(c)) throw primary; + if (from === lockPath(b)) throw secondary; + return originalRename(from, to); + }; + let caught; + try { caught = captureThrown(() => holder.release()); } + finally { fs.renameSync = originalRename; } + assert.strictEqual(caught.didThrow, true); + assert.strictEqual(caught.value, primary); + assert.deepStrictEqual(attempts, [lockPath(c), lockPath(b), lockPath(a)]); + assert.strictEqual(fs.existsSync(lockPath(a)), false); + assert.ok(fs.existsSync(lockPath(b)) && fs.existsSync(lockPath(c)), 'Failed releases must not be claimed removed'); + assert.strictEqual(accesses, 0); + assert.deepStrictEqual(originalArray, [priorDiagnostic], 'Never mutate a caller-owned diagnostics array'); + if (['mutable', 'frozen array', 'non-array'].includes(kind)) { + assert.deepStrictEqual(primary.releaseErrors, [secondary]); + assert.notStrictEqual(primary.releaseErrors, originalArray); + } + if (kind === 'readonly') assert.strictEqual(primary.releaseErrors, originalArray); + assert.throws(() => acquireOpenCodeInstallLocks([a], holder.lease), /inactive/); + }); +} + +for (const [index, primary] of falsyThrownValues.entries()) { + test(`falsy first cleanup value ${index} is thrown after all remaining roots are attempted`, root => { + const roots = ['a', 'b', 'c'].map(name => path.join(root, name)); + const [a, b, c] = roots; + const originalRename = fs.renameSync; + const secondary = new Error('second cleanup failure'); + const attempts = []; + let lease; + let caught; + fs.renameSync = (from, to) => { + if (roots.some(candidate => lockPath(candidate) === from)) attempts.push(from); + if (from === lockPath(c)) throw primary; + if (from === lockPath(b)) throw secondary; + return originalRename(from, to); + }; + try { caught = captureThrown(() => withOpenCodeInstallLocks(roots, active => { lease = active; return 'success'; })); } + finally { fs.renameSync = originalRename; } + assert.strictEqual(caught.didThrow, true); + assert.ok(Object.is(caught.value, primary)); + assert.deepStrictEqual(attempts, [lockPath(c), lockPath(b), lockPath(a)]); + assert.strictEqual(fs.existsSync(lockPath(a)), false); + assert.ok(fs.existsSync(lockPath(b)) && fs.existsSync(lockPath(c))); + assert.throws(() => acquireOpenCodeInstallLocks([a], lease), /inactive/); + }); +} + +test('frozen acquisition failure survives rollback while replaced ownership is preserved', root => { + const roots = ['a', 'b', 'c'].map(name => path.join(root, name)); + const [a, b, c] = roots; + const primary = Object.freeze(new Error('one-shot acquisition validation failure')); + const originalLink = fs.linkSync; + const originalStat = fs.lstatSync; + const originalRename = fs.renameSync; + let published = false; + let faults = 0; + let callbacks = 0; + const checked = []; + fs.linkSync = (from, to) => { originalLink(from, to); if (to === lockPath(c)) published = true; }; + fs.lstatSync = (...args) => { + if (published && args[0] === c && faults === 0) { + faults++; + originalRename(lockPath(b), `${lockPath(b)}.owned`); + fs.writeFileSync(lockPath(b), 'replacement during rollback'); + throw primary; + } + if (faults > 0 && roots.includes(args[0]) && args[1]?.bigint) checked.push(args[0]); + return originalStat(...args); + }; + let caught; + try { caught = captureThrown(() => withOpenCodeInstallLocks(roots, () => { callbacks++; })); } + finally { fs.linkSync = originalLink; fs.lstatSync = originalStat; } + assert.strictEqual(caught.didThrow, true); + assert.strictEqual(caught.value, primary); + assert.strictEqual(faults, 1); + assert.strictEqual(callbacks, 0); + assert.deepStrictEqual(checked, [c, b, a]); + assert.strictEqual(fs.existsSync(lockPath(a)), false); + assert.strictEqual(fs.existsSync(lockPath(c)), false); + assert.strictEqual(fs.readFileSync(lockPath(b), 'utf8'), 'replacement during rollback'); + assert.ok(fs.existsSync(`${lockPath(b)}.owned`)); +}); + console.log(`Results: Passed: ${passed}, Failed: ${failed}`); process.exitCode = failed ? 1 : 0;