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.
This commit is contained in:
affaan-m
2026-09-27 21:23:11 -04:00
parent 9f6c16fe02
commit 6dc706fe85
4 changed files with 137 additions and 7 deletions
+2
View File
@@ -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,
+40 -6
View File
@@ -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,
});
}
}
}
}
+1 -1
View File
@@ -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) {
@@ -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`;