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] 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`;