From 73f61801db8a7bc29601d69086bfd9d34ac8a02a Mon Sep 17 00:00:00 2001 From: Ali Arslan <149683093+aarslan007@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:32:41 +0500 Subject: [PATCH] fix(opencode): make the declined-hooks plugin stub satisfy the V2 loader schema The inert stub written when hook consent is not granted was `export default async () => ({})`. OpenCode v2 validates every plugin module before running it and requires the default export to be a definition object with an `id` and an `effect` or `setup` function, so a declined or runtime-less home install printed the same startup error as the V1 hook plugin: PluginModule.LoadError: Plugin must export a default definition with an id and an effect or setup function. (cause: SchemaError(Expected object at ["default"])) Write `export default { id: "ecc-hooks", setup() {} }` instead. It keeps the inert behaviour (no imports, no module initialization of the replaced plugin) and satisfies the loader. Tests that pinned the generated bytes now compare against getDisabledOpenCodePluginContent() rather than a duplicated literal, and tests/opencode-plugin-loader-contract.test.js asserts the loader contract on the generated payload. That test fails on the previous stub ("default export must be an object, got function"). Refs #3537. The same assertion should cover .opencode/plugins/*.ts once a V2 entrypoint lands (#3536, #3224); those plugins are scanned independently in home installs, so both the barrel and the implementation are loaded. --- scripts/lib/install/hook-consent.js | 7 +- tests/lib/install-executor.test.js | 4 +- tests/lib/install-lifecycle.test.js | 7 +- .../lib/opencode-consent-legacy-lock.test.js | 4 +- .../lib/opencode-hook-consent-safety.test.js | 14 ++- tests/opencode-plugin-loader-contract.test.js | 100 ++++++++++++++++++ 6 files changed, 123 insertions(+), 13 deletions(-) create mode 100644 tests/opencode-plugin-loader-contract.test.js diff --git a/scripts/lib/install/hook-consent.js b/scripts/lib/install/hook-consent.js index 25a54386a..13fcc6180 100644 --- a/scripts/lib/install/hook-consent.js +++ b/scripts/lib/install/hook-consent.js @@ -183,7 +183,12 @@ function isOpenCodePluginEntrypoint(operation = {}) { function getDisabledOpenCodePluginContent() { // OpenCode discovers plugins independently of opencode.json registration. // Do not import the original module: even module initialization has effects. - return 'export default async () => ({});\n'; + // The stub must still satisfy the V2 loader schema: the default export has to + // be a definition object with an id and an effect or setup function. A factory + // (`export default async () => ({})`) is rejected at startup with + // PluginModule.LoadError / SchemaError(Expected object at ["default"]), which + // is the same failure reported for the V1 hook plugin in issue #3537. + return 'export default { id: "ecc-hooks", setup() {} };\n'; } function isOpenCodeHookActivationOperation(operation = {}) { diff --git a/tests/lib/install-executor.test.js b/tests/lib/install-executor.test.js index f2faf477a..218ccc552 100644 --- a/tests/lib/install-executor.test.js +++ b/tests/lib/install-executor.test.js @@ -22,7 +22,7 @@ const { const { applyInstallPlan: applyInstallPlanDirect } = require('../../scripts/lib/install/apply'); const { normalizeInstallRequest } = require('../../scripts/lib/install/request'); const { createInstallPlanFromRequest } = require('../../scripts/lib/install/runtime'); -const { withHookConsent } = require('../../scripts/lib/install/hook-consent'); +const { getDisabledOpenCodePluginContent, withHookConsent } = require('../../scripts/lib/install/hook-consent'); const { repairInstalledStates, uninstallInstalledStates } = require('../../scripts/lib/install-lifecycle'); const REPO_ROOT = path.resolve(__dirname, '..', '..'); @@ -53,7 +53,7 @@ function operationFor(plan, suffix) { )); } -const INERT_OPENCODE_PLUGIN = 'export default async () => ({});\n'; +const INERT_OPENCODE_PLUGIN = getDisabledOpenCodePluginContent(); const OPENCODE_ENTRYPOINTS = ['ecc-hooks.ts', 'index.ts']; function snapshotFiles(root) { diff --git a/tests/lib/install-lifecycle.test.js b/tests/lib/install-lifecycle.test.js index 0b375046b..d801d65d7 100644 --- a/tests/lib/install-lifecycle.test.js +++ b/tests/lib/install-lifecycle.test.js @@ -29,6 +29,7 @@ const { materializeManagedHooks, } = require('../../scripts/lib/install/claude-settings'); const { readHooksConfig } = require('../../scripts/lib/hooks-config'); +const { getDisabledOpenCodePluginContent } = require('../../scripts/lib/install/hook-consent'); const REPO_ROOT = path.join(__dirname, '..', '..'); const CURRENT_PACKAGE_VERSION = JSON.parse( @@ -2048,7 +2049,7 @@ function runTests() { applyInstallPlan(plan); const pluginPaths = ['ecc-hooks.ts', 'index.ts'].map(name => path.join(plan.targetRoot, 'plugins', name)); for (const pluginPath of pluginPaths) { - assert.strictEqual(fs.readFileSync(pluginPath, 'utf8'), 'export default async () => ({});\n'); + assert.strictEqual(fs.readFileSync(pluginPath, 'utf8'), getDisabledOpenCodePluginContent()); fs.unlinkSync(pluginPath); } const before = buildDoctorReport({ repoRoot: REPO_ROOT, homeDir, projectRoot: homeDir, targets: ['opencode'] }); @@ -2067,7 +2068,7 @@ function runTests() { assert.strictEqual(buildCalls, 1, 'Only the injected inert builder is used'); assert.strictEqual(repaired.results[0].status, 'repaired'); for (const pluginPath of pluginPaths) { - assert.strictEqual(fs.readFileSync(pluginPath, 'utf8'), 'export default async () => ({});\n'); + assert.strictEqual(fs.readFileSync(pluginPath, 'utf8'), getDisabledOpenCodePluginContent()); } const state = readInstallState(plan.installStatePath); assert.strictEqual(state.request.hookConsent, 'declined'); @@ -2101,7 +2102,7 @@ function runTests() { }); assert.strictEqual(result.results[0].status, 'repaired'); for (const name of ['ecc-hooks.ts', 'index.ts']) { - assert.strictEqual(fs.readFileSync(path.join(recorded.targetRoot, 'plugins', name), 'utf8'), 'export default async () => ({});\n'); + assert.strictEqual(fs.readFileSync(path.join(recorded.targetRoot, 'plugins', name), 'utf8'), getDisabledOpenCodePluginContent()); } assert.ok(!JSON.parse(fs.readFileSync(path.join(recorded.targetRoot, 'opencode.json'), 'utf8')).plugin.includes('./plugins')); const state = readInstallState(recorded.installStatePath); diff --git a/tests/lib/opencode-consent-legacy-lock.test.js b/tests/lib/opencode-consent-legacy-lock.test.js index dabfb47a1..0704dc0bd 100644 --- a/tests/lib/opencode-consent-legacy-lock.test.js +++ b/tests/lib/opencode-consent-legacy-lock.test.js @@ -11,7 +11,7 @@ const { assertFilePostimage } = require('./helpers/assert-file-postimage'); const fileSystem = { ...fs }; const load = createFileSystemLoader(fileSystem); const { applyInstallPlan } = load(require.resolve('../../scripts/lib/install/apply')); -const { withHookConsent } = require('../../scripts/lib/install/hook-consent'); +const { getDisabledOpenCodePluginContent, withHookConsent } = require('../../scripts/lib/install/hook-consent'); const { repairInstalledStates, buildDoctorReport } = load(require.resolve('../../scripts/lib/install-lifecycle')); const { createInstallState, readInstallState, writeInstallState } = require('../../scripts/lib/install-state'); const { withOpenCodeInstallLocks } = load(require.resolve('../../scripts/lib/install/opencode-install-lock')); @@ -325,7 +325,7 @@ function runTests() { assert.ok(!state.resolution.selectedModules.includes('hooks-runtime')); assert.notStrictEqual(state.request.hookConsent, 'enabled'); assert.strictEqual(fs.readFileSync(path.join(value.canonicalRoot, 'plugins/ecc-hooks.ts'), 'utf8'), - 'export default async () => ({});\n'); + getDisabledOpenCodePluginContent()); assert.deepStrictEqual(JSON.parse(fs.readFileSync(path.join(value.canonicalRoot, 'opencode.json'))).plugin, []); for (const relative of ['opencode.json', 'plugins/ecc-hooks.ts', 'ecc-install-state.json']) { assert.strictEqual(fs.existsSync(path.join(value.legacyRoot, relative)), false); diff --git a/tests/lib/opencode-hook-consent-safety.test.js b/tests/lib/opencode-hook-consent-safety.test.js index 2e2908b3b..8e01d4f76 100644 --- a/tests/lib/opencode-hook-consent-safety.test.js +++ b/tests/lib/opencode-hook-consent-safety.test.js @@ -9,7 +9,11 @@ const { createFileSystemLoader } = require('./helpers/load-with-file-system'); const fileSystem = { ...fs }; const load = createFileSystemLoader(fileSystem); const { applyInstallPlan } = load(require.resolve('../../scripts/lib/install/apply')); -const { parseJsoncConfig, withHookConsent } = require('../../scripts/lib/install/hook-consent'); +const { + getDisabledOpenCodePluginContent, + parseJsoncConfig, + withHookConsent, +} = require('../../scripts/lib/install/hook-consent'); const { createInstallState, readInstallState, writeInstallState } = require('../../scripts/lib/install-state'); const { buildDoctorReport, repairInstalledStates } = load(require.resolve('../../scripts/lib/install-lifecycle')); @@ -326,7 +330,7 @@ function runTests() { assert.strictEqual(result.status, 'repaired', result.error); assert.strictEqual(result.stateRefreshed, true); assert.strictEqual(fs.readFileSync(path.join(value.targetRoot, 'plugins/ecc-hooks.ts'), 'utf8'), - 'export default async () => ({});\n'); + getDisabledOpenCodePluginContent()); assert.deepStrictEqual(JSON.parse(fs.readFileSync(path.join(value.targetRoot, 'opencode.json'))), { plugin: [], userSetting: true }); assert.strictEqual(fs.existsSync(`${value.installStatePath}.ecc.lock`), false); @@ -348,7 +352,7 @@ function runTests() { assert.ok(!operations.some(operation => operation.destinationPath === destination), 'Do not adopt a user config'); } assert.strictEqual(fs.readFileSync(path.join(value.targetRoot, 'plugins/ecc-hooks.ts'), 'utf8'), - 'export default async () => ({});\n'); + getDisabledOpenCodePluginContent()); assert.strictEqual(fs.existsSync(`${value.installStatePath}.ecc.lock`), false); })); } @@ -446,7 +450,7 @@ function runTests() { // must never adopt the preserved user config after this refusal. const checkpoint = readInstallState(value.installStatePath); assert.deepStrictEqual(checkpoint.operations.map(operation => operation.destinationPath), [plugin]); - assert.strictEqual(fs.readFileSync(plugin, 'utf8'), 'export default async () => ({});\n'); + assert.strictEqual(fs.readFileSync(plugin, 'utf8'), getDisabledOpenCodePluginContent()); assert.strictEqual(checkpoint.operations[0].contentSha256, sha256(fs.readFileSync(plugin))); assert.strictEqual(fs.existsSync(`${value.installStatePath}.ecc.lock`), false); })); @@ -629,7 +633,7 @@ function runTests() { operations: [...value.basePlan.operations, operation] }, consent); assert.strictEqual(applyInstallPlan(plan).applied, true); assert.strictEqual(fs.readFileSync(destinationPath, 'utf8'), consent === 'enabled' - ? content : 'export default async () => ({});\n'); + ? content : getDisabledOpenCodePluginContent()); })); } test('nested package metadata fails before writing and never receives a JavaScript tombstone', () => fixture(value => { diff --git a/tests/opencode-plugin-loader-contract.test.js b/tests/opencode-plugin-loader-contract.test.js new file mode 100644 index 000000000..7b31c6d55 --- /dev/null +++ b/tests/opencode-plugin-loader-contract.test.js @@ -0,0 +1,100 @@ +/** + * Tests for the OpenCode V2 plugin loader contract. + * + * OpenCode v2 validates every plugin module before running it: the default export + * must be an object with an `id` and an `effect` or `setup` function + * (packages/core/src/plugin/module.ts). A factory default export, which is what a + * V1 plugin used, fails at startup with + * + * PluginModule.LoadError: Plugin must export a default definition with an id and + * an effect or setup function. (cause: SchemaError(Expected object at ["default"])) + * + * so a declined-hooks install still printed the same error as the V1 plugin did + * (issue #3537). These tests pin the contract on the payload ECC generates itself. + * + * Run with: node tests/opencode-plugin-loader-contract.test.js + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { pathToFileURL } = require('url'); + +const { getDisabledOpenCodePluginContent } = require('../scripts/lib/install/hook-consent'); + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (err) { + console.log(` ✗ ${name}`); + console.log(` Error: ${err.message}`); + return false; + } +} + +/** Loads generated plugin source the way the OpenCode loader does: import it. */ +async function loadPluginModule(source) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-plugin-contract-')); + const file = path.join(dir, 'plugin.mjs'); + fs.writeFileSync(file, source); + return import(pathToFileURL(file).href); +} + +/** Mirrors the loader schema: { default: { id, effect } | { id, setup } }. */ +function assertV2PluginDefinition(namespace, label) { + const definition = namespace.default; + assert.ok( + definition && typeof definition === 'object', + `${label}: default export must be an object, got ${typeof definition}` + ); + assert.strictEqual(typeof definition.id, 'string', `${label}: default export needs a string id`); + assert.ok( + typeof definition.effect === 'function' || typeof definition.setup === 'function', + `${label}: default export needs an effect or setup function` + ); +} + +async function main() { + let passed = 0; + let failed = 0; + + const check = async (name, fn) => { + try { + await fn(); + console.log(` ✓ ${name}`); + passed += 1; + } catch (err) { + console.log(` ✗ ${name}`); + console.log(` Error: ${err.message}`); + failed += 1; + } + }; + + const stub = getDisabledOpenCodePluginContent(); + const stubModule = await loadPluginModule(stub); + + await check('declined-hooks stub satisfies the V2 loader schema', () => { + assertV2PluginDefinition(stubModule, 'disabled OpenCode plugin stub'); + }); + + await check('the stub check still rejects a V1 factory default export', async () => { + const v1 = await loadPluginModule('export default async () => ({});\n'); + assert.throws(() => assertV2PluginDefinition(v1, 'V1 factory'), /default export must be an object/); + }); + + await check('the stub does not import the module it replaces', () => { + assert.ok(!/^\s*import\s/m.test(stub), 'stub must stay free of imports'); + }); + + // The same check applies to every shipped plugin entrypoint once a V2 entrypoint + // lands (.opencode/plugins/*.ts is scanned independently in home installs). + // See issue #3537 and the API ports in #3536 and #3224. + + console.log(`\n ${passed} passed, ${failed} failed\n`); + process.exit(failed === 0 ? 0 : 1); +} + +main(); \ No newline at end of file