mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-29 04:55:12 +02:00
fix(install): gate OpenCode hook activation
Signed-off-by: Samarjeet Singh Tomar <samar_tomar@hotmail.com>
This commit is contained in:
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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-');
|
||||
|
||||
Reference in New Issue
Block a user