From ef8c2d8b9c91cb16f91e0e97106f294cc14f9e2e Mon Sep 17 00:00:00 2001 From: affaan-m <124439313+affaan-m@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:54:21 -0400 Subject: [PATCH] fix(opencode): guard legacy cleanup and portable consent fixtures Preserve contributor history and current-main behavior while resolving the exact reviewed follow-up. Source-PR: https://github.com/affaan-m/ECC/pull/3008 Source-Parent: d0dc1fcb08b9d522bd983faa6226bb75c773b663 Review-Manifest-SHA256: 8f15febd33dfa9f9ac6e70cb41f75ffee7aca47dd21782e841c33d4874341d6e --- scripts/lib/install-lifecycle.js | 5 +- scripts/lib/install/apply.js | 69 ++++-- scripts/lib/install/hook-consent.js | 26 ++- .../lib/install/opencode-legacy-migration.js | 41 ++-- tests/lib/guarded-write.test.js | 14 +- tests/lib/helpers/load-with-file-system.js | 44 ++++ tests/lib/hook-consent.test.js | 20 ++ tests/lib/install-lifecycle.test.js | 103 ++++++--- .../lib/opencode-consent-legacy-lock.test.js | 202 +++++++++++++++++- .../lib/opencode-hook-consent-safety.test.js | 89 ++++++-- tests/lib/opencode-legacy-migration.test.js | 8 +- 11 files changed, 529 insertions(+), 92 deletions(-) create mode 100644 tests/lib/helpers/load-with-file-system.js diff --git a/scripts/lib/install-lifecycle.js b/scripts/lib/install-lifecycle.js index 1702684ae..bc9cb05cc 100644 --- a/scripts/lib/install-lifecycle.js +++ b/scripts/lib/install-lifecycle.js @@ -1964,7 +1964,7 @@ function preflightOpenCodeHookDeactivation(record, context, options = {}) { // Migration does not replay operations into the old root. Inspect existing // activations there, without treating historical merge-json records as new // writes; the canonical destination is validated separately below. - assertOpenCodeRepairHookDeactivation({ ...legacyPlan, operations: [] }, { requireInactive: true }); + assertOpenCodeRepairHookDeactivation({ ...legacyPlan, operations: [] }, { allowVerifiedLegacyRemoval: true }); assertOpenCodeRepairHookDeactivation(rawPlan, options); return; } @@ -2298,9 +2298,10 @@ function repairInstalledStates(options = {}) { homeDir: context.homeDir, projectRoot: context.projectRoot, repoRoot: context.projectRoot, env: context.env, }); - const { getOpenCodeInstallRoots } = require('./install/apply'); + const { getOpenCodeInstallRoots, assertOpenCodeLeaseCoverage } = require('./install/apply'); const roots = getOpenCodeInstallRoots({ adapter, targetRoot, homeDir: context.homeDir }); return withOpenCodeInstallLocks(roots, lease => { + assertOpenCodeLeaseCoverage({ adapter, targetRoot, homeDir: context.homeDir }, lease); // Discovery precedes acquisition. Never repair from that stale state. record = buildDiscoveryRecord(adapter, context, record.legacyLayout === 'opencode' ? getLegacyOpencodeLocation(context.homeDir) : null); diff --git a/scripts/lib/install/apply.js b/scripts/lib/install/apply.js index eb90de8c1..f9db01d00 100644 --- a/scripts/lib/install/apply.js +++ b/scripts/lib/install/apply.js @@ -14,8 +14,8 @@ const { disableOpenCodeHookPluginRegistration, getDisabledOpenCodePluginContent, getRecordedHookConsent, - isOpenCodeHookActivationOperation, - isOpenCodePluginEntrypoint, + getOpenCodeActivationPathKind, + getOpenCodeSourceActivationKind, planMaterializesHookRuntime, shouldDisableOpenCodeHooks, } = require('./hook-consent'); @@ -42,7 +42,8 @@ const { prepareUserOwnedFileGuard, preserveUnwrittenFiles, } = require('./ownership-guard'); -const { cleanupLegacyOpencodeInstall, getLegacyLocationForPlan } = require('./opencode-legacy-migration'); +const { cleanupLegacyOpencodeInstall, getLegacyLocationForPlan, inspectLegacyOpencodeState, + verifyManagedLegacyFile } = require('./opencode-legacy-migration'); const { writeFileNoFollow } = require('./guarded-write'); const { withOpenCodeInstallLocks } = require('./opencode-install-lock'); const { @@ -313,12 +314,7 @@ function getOpenCodeActivationKind(plan, operation) { const relative = operation.destinationPath ? path.relative(plan.targetRoot, operation.destinationPath).split(path.sep).join('/').toLowerCase() : ''; - if (/^plugins\/(?:[^/]+\.(?:[cm]?js|ts)|[^/]+\/(?:index\.(?:[cm]?js|ts)|package\.json))$/.test(relative)) { - return 'plugin'; - } - if (relative === 'opencode.json') return 'config'; - if (isOpenCodePluginEntrypoint(operation)) return 'plugin'; - return isOpenCodeHookActivationOperation(operation) ? 'config' : null; + return getOpenCodeActivationPathKind(relative) || getOpenCodeSourceActivationKind(operation); } function readOpenCodeAliasForAttribution(plan, destinationPath) { @@ -473,8 +469,11 @@ function assertOpenCodeHookDeactivationReady(plan, options = {}) { const desired = new Map(plan.operations.filter(operation => getOpenCodeActivationKind(plan, operation)) .map(operation => [comparablePath(operation.destinationPath), operation])); const snapshot = new Map(); - for (const [key, operation] of openCodeActivationCandidates(plan, [...previous.values()])) { + for (const [key, operation] of openCodeActivationCandidates(plan, [...previous.values(), ...(options.legacyOperations || [])])) { const kind = getOpenCodeActivationKind(plan, operation); + if (kind === 'package') { + throw new Error(`Unsupported OpenCode package metadata deactivation: ${operation.destinationPath}`); + } const expectedTransform = kind === 'plugin' ? 'opencode-disable-plugin-entrypoint' : 'opencode-disable-ecc-hooks'; const replacement = desired.get(key); @@ -498,8 +497,14 @@ function assertOpenCodeHookDeactivationReady(plan, options = {}) { } continue; } - if (kind === 'plugin' && inactive) continue; + if (inactive && (kind === 'plugin' || options.allowVerifiedLegacyRemoval)) continue; const recorded = previous.get(key); + if (options.allowVerifiedLegacyRemoval && recorded) { + const verified = verifyManagedLegacyFile(recorded, { + targetRoot: plan.targetRoot, installStatePath: plan.installStatePath, + }, plan.sourceRoot); + if (verified.destinationPath && verified.digest === digest) continue; + } if (!replacement || replacement.kind !== 'copy-file' || replacement.contentTransform !== expectedTransform || !recorded || recorded.contentSha256 !== digest) { @@ -530,17 +535,29 @@ function getOpenCodeActivationWriteOptions(operation, snapshot) { function getOpenCodeInstallRoots(plan) { const roots = [plan.targetRoot]; const legacy = getLegacyLocationForPlan(plan); - if (legacy) { - try { - fs.lstatSync(legacy.targetRoot); - roots.push(legacy.targetRoot); - } catch (error) { - if (error.code !== 'ENOENT') throw error; - } - } + const inspection = inspectLegacyOpencodeState(legacy); + if (inspection.status === 'unreadable') throw new Error(inspection.error); + if (inspection.status === 'valid') roots.push(legacy.targetRoot); return roots; } +function inspectLegacyOpenCodeDeactivation(plan) { + const location = getLegacyLocationForPlan(plan); + if (!location || comparablePath(location.targetRoot) === comparablePath(plan.targetRoot)) return null; + const inspection = inspectLegacyOpencodeState(location); + if (inspection.status === 'unreadable') throw new Error(inspection.error); + if (inspection.status !== 'valid') return null; + const legacyPlan = { ...plan, ...location, operations: [] }; + assertOpenCodeHookDeactivationReady(legacyPlan, { allowVerifiedLegacyRemoval: true }); + return { plan: legacyPlan, operations: inspection.state.operations.filter(operation => operation.ownership === 'managed') }; +} + +function assertOpenCodeLeaseCoverage(plan, lease) { + if (plan.adapter?.target !== 'opencode') return; + // Reuse checks opaque ownership without acquiring extra roots out of order. + withOpenCodeInstallLocks(getOpenCodeInstallRoots(plan), () => {}, lease); +} + function findPreviousManagedHooks(previousState, plan, operation) { if ( !previousState @@ -704,7 +721,7 @@ function applyInstallPlan(plan, dependencies = {}) { assertSafeInstallOperation(plan, { destinationPath: plan.installStatePath }); return withOpenCodeInstallLocks( getOpenCodeInstallRoots(plan), - () => applyInstallPlanLocked(plan, dependencies, false), + lease => applyInstallPlanLocked(plan, { ...dependencies, opencodeLease: lease }, false), dependencies.opencodeLease ); } @@ -732,6 +749,8 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals if (typeof beforeInstallStateRead === 'function') { beforeInstallStateRead({ plan }); } + assertOpenCodeLeaseCoverage(plan, dependencies.opencodeLease); + const legacyActivation = inspectLegacyOpenCodeDeactivation(plan); const activationSnapshot = assertOpenCodeHookDeactivationReady(plan); const migration = prepareExcludedPathsReconciliation( plan, @@ -953,6 +972,9 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals ]; } + assertOpenCodeLeaseCoverage(appliedPlan, dependencies.opencodeLease); + // Recheck removable bytes after canonical writes, before legacy cleanup. + inspectLegacyOpenCodeDeactivation(appliedPlan); let opencodeMigrationWarnings = []; try { const opencodeMigration = cleanupLegacyOpencodeInstall(appliedPlan); @@ -968,6 +990,12 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals ]; } + if (legacyActivation) { + assertOpenCodeHookDeactivationReady(legacyActivation.plan, { + requireInactive: true, legacyOperations: legacyActivation.operations, + }); + } + let excludedPathsRemoved = []; let excludedPathsWarnings = []; try { @@ -1001,6 +1029,7 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals module.exports = { applyInstallPlan, assertOpenCodeActivationUnchanged, + assertOpenCodeLeaseCoverage, assertOpenCodeHookDeactivationReady, getOpenCodeActivationKind, getOpenCodeActivationWriteOptions, diff --git a/scripts/lib/install/hook-consent.js b/scripts/lib/install/hook-consent.js index e29c01bf5..f954c9016 100644 --- a/scripts/lib/install/hook-consent.js +++ b/scripts/lib/install/hook-consent.js @@ -69,10 +69,22 @@ function disableOpenCodeHookPluginRegistration(content, sourceRelativePath) { }, null, 2)}\n`; } +function getOpenCodeActivationPathKind(value) { + const relative = normalizeOperationPath(value); + if (relative === 'opencode.json') return 'config'; + if (/^plugins\/[^/]+\/package\.json$/.test(relative)) return 'package'; + if (/^plugins\/(?:[^/]+\.(?:[cm]?js|ts)|[^/]+\/index\.(?:[cm]?js|ts))$/.test(relative)) return 'plugin'; + return null; +} + +function getOpenCodeSourceActivationKind(operation = {}) { + const source = normalizeOperationPath(operation.sourceRelativePath); + if (!source.startsWith('.opencode/')) return null; + return getOpenCodeActivationPathKind(source.replace(/^\.opencode\/(?:dist\/)?/, '')); +} + function isOpenCodePluginEntrypoint(operation = {}) { - return /^\.opencode\/(?:dist\/)?plugins\/[^/]+\.(?:[cm]?js|ts)$/.test( - normalizeOperationPath(operation.sourceRelativePath) - ); + return getOpenCodeSourceActivationKind(operation) === 'plugin'; } function getDisabledOpenCodePluginContent() { @@ -82,8 +94,7 @@ function getDisabledOpenCodePluginContent() { } function isOpenCodeHookActivationOperation(operation = {}) { - return normalizeOperationPath(operation.sourceRelativePath) === '.opencode/opencode.json' - || isOpenCodePluginEntrypoint(operation); + return getOpenCodeSourceActivationKind(operation) !== null; } function isHookRuntimeOperation(operation = {}) { @@ -146,6 +157,9 @@ function withoutHookRuntimeId(values) { } function withoutOpenCodeHookActivation(operation) { + if (getOpenCodeSourceActivationKind(operation) === 'package') { + throw new Error(`Unsupported OpenCode package metadata deactivation: ${operation.sourceRelativePath}`); + } if ( !isOpenCodeHookActivationOperation(operation) || operation.kind !== 'copy-file' @@ -310,6 +324,8 @@ module.exports = { disableUnselectedOpenCodeHooks, disableOpenCodeHookPluginRegistration, getDisabledOpenCodePluginContent, + getOpenCodeActivationPathKind, + getOpenCodeSourceActivationKind, formatHookCapabilityDisclosure, getRecordedHookConsent, isHookRuntimeOperation, diff --git a/scripts/lib/install/opencode-legacy-migration.js b/scripts/lib/install/opencode-legacy-migration.js index 485130eb4..ea704b6ea 100644 --- a/scripts/lib/install/opencode-legacy-migration.js +++ b/scripts/lib/install/opencode-legacy-migration.js @@ -4,7 +4,7 @@ const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); -const { readInstallState } = require('../install-state'); +const { readInstallState, validateInstallState } = require('../install-state'); const { assertWithinTrustedRoot } = require('../path-safety'); const OPENCODE_TARGET = 'opencode'; @@ -65,10 +65,14 @@ function inspectLegacyOpencodeState(location) { return { status: 'absent', state: null, error: null }; } try { - if (!pathExists(location.installStatePath)) { + if (!pathExists(location.targetRoot)) { return { status: 'absent', state: null, error: null }; } const rootStat = fs.lstatSync(location.targetRoot); + if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) { + return { status: 'invalid', state: null, error: null }; + } + if (!pathExists(location.installStatePath)) return { status: 'absent', state: null, error: null }; const stateStat = fs.lstatSync(location.installStatePath); if ( !rootStat.isDirectory() @@ -78,7 +82,11 @@ function inspectLegacyOpencodeState(location) { ) { return { status: 'invalid', state: null, error: null }; } - const state = readInstallState(location.installStatePath); + const { content } = hashFileNoFollow(location.installStatePath); + let state; + try { state = JSON.parse(content.toString('utf8')); } + catch { return { status: 'invalid', state: null, error: null }; } + if (!validateInstallState(state).valid) return { status: 'invalid', state: null, error: null }; const isOpencode = state.target.target === OPENCODE_TARGET || state.target.id === 'opencode-home'; if ( @@ -98,17 +106,17 @@ function inspectLegacyOpencodeState(location) { } } -function hashFileNoFollow(filePath) { - const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0); - const descriptor = fs.openSync(filePath, flags); +function hashFileNoFollow(filePath, fileSystem = fs) { + const flags = fileSystem.constants.O_RDONLY | (fileSystem.constants.O_NOFOLLOW || 0); + const descriptor = fileSystem.openSync(filePath, flags); try { - const before = fs.fstatSync(descriptor, { bigint: true }); + const before = fileSystem.fstatSync(descriptor, { bigint: true }); if (!before.isFile()) { throw new Error(`Refusing to read a non-file at ${filePath}`); } - const content = fs.readFileSync(descriptor); - const after = fs.fstatSync(descriptor, { bigint: true }); - const finalPathStat = fs.lstatSync(filePath, { bigint: true }); + const content = fileSystem.readFileSync(descriptor); + const after = fileSystem.fstatSync(descriptor, { bigint: true }); + const finalPathStat = fileSystem.lstatSync(filePath, { bigint: true }); const unchanged = before.dev === after.dev && before.ino === after.ino && before.size === after.size @@ -123,11 +131,12 @@ function hashFileNoFollow(filePath) { throw new Error(`Refusing to read a file that changed during validation: ${filePath}`); } return { + content, digest: crypto.createHash('sha256').update(content).digest('hex'), stat: after, }; } finally { - fs.closeSync(descriptor); + fileSystem.closeSync(descriptor); } } @@ -202,7 +211,7 @@ function verifyManagedLegacyFile(operation, location, sourceRoot) { if (source.digest !== destination.digest) { return { retainedPath: destinationPath }; } - return { destinationPath, stat: destination.stat }; + return { destinationPath, digest: destination.digest, stat: destination.stat }; } function pathExistsWith(fileSystem, filePath) { @@ -257,6 +266,13 @@ function removeVerifiedLegacyFile(entry, location, fileSystem = fs) { identityError.code = 'ESTALE'; throw identityError; } + // Recheck bytes after quarantine: an in-place edit retains the same inode. + // Production cleanup entries carry the digest verified against ledger/source. + if (entry.digest && hashFileNoFollow(quarantinePath, fileSystem).digest !== entry.digest) { + const changed = new Error(`Legacy OpenCode file changed during quarantine: ${safePath}`); + changed.code = 'ESTALE'; + throw changed; + } fileSystem.rmSync(quarantinePath); fileSystem.rmdirSync(quarantineDir); return true; @@ -396,4 +412,5 @@ module.exports = { getLegacyLocationForPlan, inspectLegacyOpencodeState, removeVerifiedLegacyFile, + verifyManagedLegacyFile, }; diff --git a/tests/lib/guarded-write.test.js b/tests/lib/guarded-write.test.js index 44e537e96..3f3e35cf1 100644 --- a/tests/lib/guarded-write.test.js +++ b/tests/lib/guarded-write.test.js @@ -157,10 +157,16 @@ test('a parent replacement before native open is refused even when the file iden 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'); + const postconditionFd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0)); + try { + const replacementIdentity = fs.fstatSync(postconditionFd, { bigint: true }); + assert.ok(replacementIdentity.isFile()); + assert.strictEqual(replacementIdentity.dev, fileIdentity.dev); + assert.strictEqual(replacementIdentity.ino, fileIdentity.ino); + assert.strictEqual(fs.readFileSync(postconditionFd, 'utf8'), 'old activation bytes'); + } finally { + fs.closeSync(postconditionFd); + } assert.ok(fs.statSync(`${parent}.old`).isDirectory()); }); test('a denied native open preserves its cause without closing an unallocated descriptor', ({ file, options }) => { diff --git a/tests/lib/helpers/load-with-file-system.js b/tests/lib/helpers/load-with-file-system.js new file mode 100644 index 000000000..b11d2a077 --- /dev/null +++ b/tests/lib/helpers/load-with-file-system.js @@ -0,0 +1,44 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const Module = require('module'); + +// A private CommonJS graph for filesystem fault fixtures. Dependencies share +// this graph (including opaque lock identities), never the process module cache. +function createFileSystemLoader(fileSystem) { + const sourceRoot = path.resolve(__dirname, '../../../scripts'); + const cache = new Map(); + function load(filename) { + const absolute = path.resolve(filename); + const relative = path.relative(sourceRoot, absolute); + if (relative.startsWith('..') || path.isAbsolute(relative) || !absolute.endsWith('.js')) { + throw new Error('Fixture loader requires a JavaScript module under scripts/'); + } + if (cache.has(absolute)) return cache.get(absolute).exports; + const child = new Module(absolute, module); + child.filename = absolute; + child.paths = Module._nodeModulePaths(path.dirname(absolute)); + const nativeRequire = Module.createRequire(absolute); + child.require = request => { + if (request === 'fs' || request === 'node:fs') return fileSystem; + const resolved = nativeRequire.resolve(request); + const dependency = path.relative(sourceRoot, resolved); + if (path.isAbsolute(resolved) && !dependency.startsWith('..') + && !path.isAbsolute(dependency) && resolved.endsWith('.js')) return load(resolved); + return nativeRequire(request); + }; + cache.set(absolute, child); + try { + child._compile(fs.readFileSync(absolute, 'utf8'), absolute); + child.loaded = true; + return child.exports; + } catch (error) { + cache.delete(absolute); + throw error; + } + } + return load; +} + +module.exports = { createFileSystemLoader }; diff --git a/tests/lib/hook-consent.test.js b/tests/lib/hook-consent.test.js index 691dbceaa..d802c5507 100644 --- a/tests/lib/hook-consent.test.js +++ b/tests/lib/hook-consent.test.js @@ -11,6 +11,7 @@ const { formatHookCapabilityDisclosure, getRecordedHookConsent, isHookRuntimeOperation, + isOpenCodePluginEntrypoint, planMaterializesHookRuntime, resolveHookConsentFlags, withHookConsent, @@ -202,6 +203,25 @@ function runTests() { assert.strictEqual(getRecordedHookConsent({ operations: [{ kind: 'update-claude-settings' }] }), 'enabled'); })) passed++; else failed++; + if (test('source classification covers nested JavaScript but refuses package metadata deactivation', () => { + for (const extension of ['ts', 'js', 'mjs', 'cjs']) { + for (const sourceRelativePath of [`.opencode/plugins/custom/index.${extension}`, + `.OPENCODE\\DIST\\PLUGINS\\CUSTOM\\INDEX.${extension.toUpperCase()}`]) { + const operation = { kind: 'copy-file', moduleId: 'platform-configs', sourceRelativePath }; + assert.strictEqual(isOpenCodePluginEntrypoint(operation), true, sourceRelativePath); + const plan = withHookConsent({ target: 'opencode', operations: [operation], selectedModuleIds: [] }); + assert.strictEqual(plan.operations[0].contentTransform, 'opencode-disable-plugin-entrypoint'); + } + } + const operation = { kind: 'copy-file', sourceRelativePath: '.opencode/plugins/custom/package.json' }; + for (const decision of [null, 'declined']) { + assert.throws(() => withHookConsent({ target: 'opencode', operations: [operation], selectedModuleIds: [] }, decision), + /unsupported.*package/i); + } + assert.strictEqual(isOpenCodePluginEntrypoint(operation), false); + assert.strictEqual(isOpenCodePluginEntrypoint({ sourceRelativePath: '.opencode/plugins/lib/utility.js' }), false); + })) 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-lifecycle.test.js b/tests/lib/install-lifecycle.test.js index 6a892797f..64290d842 100644 --- a/tests/lib/install-lifecycle.test.js +++ b/tests/lib/install-lifecycle.test.js @@ -7,6 +7,7 @@ const crypto = require('crypto'); const fs = require('fs'); const os = require('os'); const path = require('path'); +const { createFileSystemLoader } = require('./helpers/load-with-file-system'); const { buildDoctorReport, @@ -206,11 +207,11 @@ function writeOpencodeState(homeDir, overrides = {}) { }; } -function writeRecordedOpenCodeActivation(homeDir) { +function writeRecordedOpenCodeActivation(homeDir, sourceRoot = REPO_ROOT) { const targetRoot = path.join(homeDir, '.config', 'opencode'); const operations = ['opencode.json', 'plugins/ecc-hooks.ts', 'plugins/index.ts'].map(relativePath => { const sourceRelativePath = `.opencode/${relativePath}`; - const content = fs.readFileSync(path.join(REPO_ROOT, sourceRelativePath)); + const content = fs.readFileSync(path.join(sourceRoot, sourceRelativePath)); const destinationPath = path.join(targetRoot, relativePath); fs.mkdirSync(path.dirname(destinationPath), { recursive: true }); fs.writeFileSync(destinationPath, content); @@ -227,6 +228,32 @@ function writeRecordedOpenCodeActivation(homeDir) { }); } +// These race fixtures read public source as inert bytes into a private source +// tree. Fake payload construction must never move/write repository build output. +function withPrivateOpenCodeSource(homeDir, callback) { + const sourceRoot = path.join(homeDir, 'source'); + fs.mkdirSync(path.join(sourceRoot, 'manifests'), { recursive: true }); + const files = { + 'package.json': { name: 'private-opencode-race-fixture', version: CURRENT_PACKAGE_VERSION }, + 'manifests/install-modules.json': { version: CURRENT_MANIFEST_VERSION, modules: [{ + id: 'platform-configs', kind: 'platform', description: 'Private race fixture.', + paths: ['.opencode'], targets: ['opencode'], dependencies: [], + defaultInstall: false, cost: 'light', stability: 'stable', + }] }, + 'manifests/install-profiles.json': { version: 1, profiles: {} }, + 'manifests/install-components.json': { version: 1, components: [] }, + }; + for (const [relative, value] of Object.entries(files)) { + fs.writeFileSync(path.join(sourceRoot, relative), formatJson(value)); + } + for (const relative of ['opencode.json', 'plugins/ecc-hooks.ts', 'plugins/index.ts']) { + const destination = path.join(sourceRoot, '.opencode', relative); + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.copyFileSync(path.join(REPO_ROOT, '.opencode', relative), destination); + } + callback(sourceRoot); +} + function withTemporarilyMovedPath(filePath, callback) { if (!fs.existsSync(filePath)) { try { @@ -2159,28 +2186,33 @@ function runTests() { if (test('OpenCode repair preserves activation bytes changed between health inspection and write', () => { const homeDir = createTempDir('install-lifecycle-opencode-health-race-'); + const fileSystem = { ...fs }; + const cacheEntry = require.cache[require.resolve('../../scripts/lib/install-lifecycle')]; + const isolatedRepair = createFileSystemLoader(fileSystem)( + require.resolve('../../scripts/lib/install-lifecycle') + ).repairInstalledStates; const originalOpenSync = fs.openSync; const originalReadFileSync = fs.readFileSync; const originalCloseSync = fs.closeSync; const descriptors = new Set(); try { - withTemporarilyMovedPath(path.join(REPO_ROOT, '.opencode', 'dist'), () => { - const recorded = writeRecordedOpenCodeActivation(homeDir); + withPrivateOpenCodeSource(homeDir, sourceRoot => { + const recorded = writeRecordedOpenCodeActivation(homeDir, sourceRoot); const pluginPath = path.join(recorded.targetRoot, 'plugins', 'ecc-hooks.ts'); const canonicalPluginPath = fs.realpathSync(pluginPath); const changedContent = 'globalThis.userChangedHook = true;\n'; const stateBefore = fs.readFileSync(recorded.installStatePath); let changed = false; - fs.openSync = function trackActivationDescriptor(candidate, ...args) { + fileSystem.openSync = function trackActivationDescriptor(candidate, ...args) { const descriptor = originalOpenSync.call(fs, candidate, ...args); if (typeof candidate === 'string' && [pluginPath, canonicalPluginPath].includes(path.resolve(candidate))) descriptors.add(descriptor); return descriptor; }; - fs.closeSync = function releaseActivationDescriptor(descriptor) { + fileSystem.closeSync = function releaseActivationDescriptor(descriptor) { descriptors.delete(descriptor); return originalCloseSync.call(fs, descriptor); }; - fs.readFileSync = function mutateAfterHealthRead(candidate, ...args) { + fileSystem.readFileSync = function mutateAfterHealthRead(candidate, ...args) { const content = originalReadFileSync.call(fs, candidate, ...args); // Lifecycle reads pass an encoding argument; the consent snapshot // reads the descriptor as raw bytes with no second argument. @@ -2192,8 +2224,8 @@ function runTests() { }; let result; try { - result = repairInstalledStates({ - repoRoot: REPO_ROOT, homeDir, projectRoot: homeDir, targets: ['opencode'], + result = isolatedRepair({ + repoRoot: sourceRoot, homeDir, projectRoot: homeDir, targets: ['opencode'], buildOpencodePayload(repoRoot) { const distDir = path.join(repoRoot, '.opencode', 'dist'); fs.mkdirSync(path.join(distDir, 'plugins'), { recursive: true }); @@ -2202,9 +2234,9 @@ function runTests() { }, }); } finally { - fs.openSync = originalOpenSync; - fs.readFileSync = originalReadFileSync; - fs.closeSync = originalCloseSync; + fileSystem.openSync = originalOpenSync; + fileSystem.readFileSync = originalReadFileSync; + fileSystem.closeSync = originalCloseSync; } assert.strictEqual(changed, true, 'The health-read boundary was exercised'); assert.strictEqual(result.results[0].status, 'error'); @@ -2219,39 +2251,48 @@ function runTests() { assert.notStrictEqual(result.results[0].stateRefreshed, true); }); } finally { - fs.openSync = originalOpenSync; - fs.readFileSync = originalReadFileSync; - fs.closeSync = originalCloseSync; + fileSystem.openSync = originalOpenSync; + fileSystem.readFileSync = originalReadFileSync; + fileSystem.closeSync = originalCloseSync; cleanup(homeDir); + assert.strictEqual(descriptors.size, 0, 'Every tracked descriptor must close'); + assert.strictEqual(fs.openSync, originalOpenSync, 'Native fs must remain untouched'); + assert.strictEqual(fs.closeSync, originalCloseSync); + assert.strictEqual(require.cache[require.resolve('../../scripts/lib/install-lifecycle')], cacheEntry); } })) passed++; else failed++; if (test('OpenCode repair rejects an active alias introduced during writes before refreshing state', () => { const homeDir = createTempDir('install-lifecycle-opencode-alias-race-'); + const fileSystem = { ...fs }; + const cacheEntry = require.cache[require.resolve('../../scripts/lib/install-lifecycle')]; + const isolatedRepair = createFileSystemLoader(fileSystem)( + require.resolve('../../scripts/lib/install-lifecycle') + ).repairInstalledStates; const originalOpenSync = fs.openSync; const originalWriteFileSync = fs.writeFileSync; const originalWriteSync = fs.writeSync; const originalCloseSync = fs.closeSync; const descriptors = new Set(); try { - withTemporarilyMovedPath(path.join(REPO_ROOT, '.opencode', 'dist'), () => { - const recorded = writeRecordedOpenCodeActivation(homeDir); + withPrivateOpenCodeSource(homeDir, sourceRoot => { + const recorded = writeRecordedOpenCodeActivation(homeDir, sourceRoot); 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 = fs.readFileSync(path.join(REPO_ROOT, '.opencode', 'plugins', 'index.ts'), 'utf8'); + const aliasContent = fs.readFileSync(path.join(sourceRoot, '.opencode', 'plugins', 'index.ts'), 'utf8'); const stateBefore = fs.readFileSync(recorded.installStatePath); let inserted = false; - fs.openSync = function trackPluginWriteDescriptor(candidate, ...args) { + fileSystem.openSync = function trackPluginWriteDescriptor(candidate, ...args) { const descriptor = originalOpenSync.call(fs, candidate, ...args); if (typeof candidate === 'string' && [pluginPath, canonicalPluginPath].includes(path.resolve(candidate))) descriptors.add(descriptor); return descriptor; }; - fs.closeSync = function releasePluginWriteDescriptor(descriptor) { + fileSystem.closeSync = function releasePluginWriteDescriptor(descriptor) { descriptors.delete(descriptor); return originalCloseSync.call(fs, descriptor); }; - fs.writeSync = function insertAliasAfterPluginWrite(candidate, ...args) { + fileSystem.writeSync = function insertAliasAfterPluginWrite(candidate, ...args) { const result = originalWriteSync.call(fs, candidate, ...args); if (!inserted && descriptors.has(candidate)) { inserted = true; @@ -2261,8 +2302,8 @@ function runTests() { }; let result; try { - result = repairInstalledStates({ - repoRoot: REPO_ROOT, homeDir, projectRoot: homeDir, targets: ['opencode'], + result = isolatedRepair({ + repoRoot: sourceRoot, homeDir, projectRoot: homeDir, targets: ['opencode'], buildOpencodePayload(repoRoot) { const distDir = path.join(repoRoot, '.opencode', 'dist'); fs.mkdirSync(path.join(distDir, 'plugins'), { recursive: true }); @@ -2271,9 +2312,9 @@ function runTests() { }, }); } finally { - fs.openSync = originalOpenSync; - fs.writeSync = originalWriteSync; - fs.closeSync = originalCloseSync; + fileSystem.openSync = originalOpenSync; + fileSystem.writeSync = originalWriteSync; + fileSystem.closeSync = originalCloseSync; } assert.strictEqual(inserted, true, 'The plugin-write boundary was exercised'); assert.strictEqual(result.results[0].status, 'error'); @@ -2285,10 +2326,14 @@ function runTests() { assert.notStrictEqual(result.results[0].stateRefreshed, true); }); } finally { - fs.openSync = originalOpenSync; - fs.writeSync = originalWriteSync; - fs.closeSync = originalCloseSync; + fileSystem.openSync = originalOpenSync; + fileSystem.writeSync = originalWriteSync; + fileSystem.closeSync = originalCloseSync; cleanup(homeDir); + assert.strictEqual(descriptors.size, 0, 'Every tracked descriptor must close'); + assert.strictEqual(fs.openSync, originalOpenSync, 'Native fs must remain untouched'); + assert.strictEqual(fs.closeSync, originalCloseSync); + assert.strictEqual(require.cache[require.resolve('../../scripts/lib/install-lifecycle')], cacheEntry); } })) passed++; else failed++; diff --git a/tests/lib/opencode-consent-legacy-lock.test.js b/tests/lib/opencode-consent-legacy-lock.test.js index 259e86b9f..a0baced47 100644 --- a/tests/lib/opencode-consent-legacy-lock.test.js +++ b/tests/lib/opencode-consent-legacy-lock.test.js @@ -6,10 +6,14 @@ const crypto = require('crypto'); const fs = require('fs'); const os = require('os'); const path = require('path'); -const { applyInstallPlan } = require('../../scripts/lib/install/apply'); -const { repairInstalledStates } = require('../../scripts/lib/install-lifecycle'); +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 { 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 } = require('../../scripts/lib/install/opencode-install-lock'); +const { withOpenCodeInstallLocks } = load(require.resolve('../../scripts/lib/install/opencode-install-lock')); const SOURCE_RELATIVE_PATH = path.join('skills', 'skill-comply', 'SKILL.md'); const SKILL_CONTENT = '---\nname: skill-comply\ndescription: Synthetic migration fixture.\n---\n\n# Inert fixture\n'; @@ -131,6 +135,43 @@ function assertBothLocksReleased(value) { } } +function addLegacyActivations(value, consent = null) { + const manifestPath = path.join(value.sourceRoot, 'manifests', 'install-modules.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath)); + manifest.modules.push({ id: 'platform-configs', kind: 'platform', description: 'Inert config fixture.', + paths: ['.opencode'], targets: ['opencode'], dependencies: [], defaultInstall: false, + cost: 'light', stability: 'stable' }); + writeJson(manifestPath, manifest); + const state = readInstallState(value.legacyStatePath); + state.request.modules.push('platform-configs'); + state.request.hookConsent = consent; + state.resolution.selectedModules.push('platform-configs'); + for (const [relative, content] of [ + ['opencode.json', '{"plugin":["./plugins"],"userSetting":true}\n'], + ['plugins/ecc-hooks.ts', 'export default async () => ({ "session.created": () => {} });\n'], + ]) { + const sourceRelativePath = `.opencode/${relative}`; + const sourcePath = path.join(value.sourceRoot, sourceRelativePath); + const destinationPath = path.join(value.legacyRoot, relative); + for (const file of [sourcePath, destinationPath]) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); + } + state.operations.push({ kind: 'copy-file', moduleId: 'platform-configs', sourceRelativePath, + destinationPath, ownership: 'managed', scaffoldOnly: false, strategy: 'preserve-relative-path', + contentSha256: sha256(content) }); + } + writeInstallState(value.legacyStatePath, state); + return state; +} + +function buildInertPayload(sourceRoot) { + const dist = path.join(sourceRoot, '.opencode', 'dist'); + fs.mkdirSync(path.join(dist, 'plugins'), { recursive: true }); + fs.mkdirSync(path.join(dist, 'tools'), { recursive: true }); + fs.writeFileSync(path.join(dist, 'index.js'), 'module.exports = {};\n'); +} + function runTests() { let passed = 0; let failed = 0; @@ -188,6 +229,161 @@ function runTests() { assertSourceUnchanged(value); assertBothLocksReleased(value); }); + for (const unrelated of ['directory', 'file', 'symlink', 'invalid-state', 'malformed-state', 'non-ECC-state']) { + test(`canonical apply ignores an unrelated legacy ${unrelated} without locking it`, value => { + fs.rmSync(value.legacyRoot, { recursive: true, force: true }); + if (unrelated === 'file') fs.writeFileSync(value.legacyRoot, 'user file'); + else if (unrelated === 'symlink') { + fs.mkdirSync(value.canonicalRoot, { recursive: true }); + fs.symlinkSync(value.canonicalRoot, value.legacyRoot, process.platform === 'win32' ? 'junction' : 'dir'); + } else { + fs.mkdirSync(value.legacyRoot, { recursive: true }); + if (unrelated === 'malformed-state') fs.writeFileSync(value.legacyStatePath, '{malformed'); + if (unrelated === 'invalid-state') writeJson(value.legacyStatePath, { unrelated: true }); + if (unrelated === 'non-ECC-state') { + const state = { ...value.canonicalPlan.statePreview, target: { id: 'user-tool', target: 'user-tool', + root: value.legacyRoot, installStatePath: value.legacyStatePath } }; + writeJson(value.legacyStatePath, state); + } + } + const previousLedger = fs.existsSync(value.legacyStatePath) + ? fs.readFileSync(value.legacyStatePath) : null; + const original = fileSystem.openSync; + let legacyLocks = 0; + fileSystem.openSync = (candidate, ...args) => { + if (typeof candidate === 'string' && candidate.startsWith(lockPath(value.legacyRoot))) { legacyLocks++; throw new Error('Unrelated legacy root must not be locked'); } + return original(candidate, ...args); + }; + try { + const plan = withHookConsent({ ...value.canonicalPlan, operations: [], + statePreview: { ...value.canonicalPlan.statePreview, operations: [] } }); + assert.strictEqual(applyInstallPlan(plan).applied, true); + assert.strictEqual(legacyLocks, 0); + if (previousLedger) assert.deepStrictEqual(fs.readFileSync(value.legacyStatePath), previousLedger); + if (unrelated === 'file') assert.strictEqual(fs.readFileSync(value.legacyRoot, 'utf8'), 'user file'); + if (unrelated === 'symlink') assert.ok(fs.lstatSync(value.legacyRoot).isSymbolicLink()); + } finally { fileSystem.openSync = original; } + assert.strictEqual(fs.existsSync(lockPath(value.canonicalRoot)), false); + }); + } + test('unreadable legacy ECC state remains an explicit failure before writes', value => { + const original = fileSystem.openSync; + let attempted = false; + fileSystem.openSync = (candidate, ...args) => { + if (candidate === value.legacyStatePath) { + attempted = true; + throw Object.assign(new Error('Synthetic unreadable legacy state'), { code: 'EACCES' }); + } + return original(candidate, ...args); + }; + try { + assert.throws(() => applyInstallPlan(value.canonicalPlan), /Unable to inspect legacy/); + assert.ok(attempted); + assert.strictEqual(fs.existsSync(value.canonicalStatePath), false); + } finally { fileSystem.openSync = original; } + }); + for (const boundary of ['beforeInstallStateRead', 'beforeInstallStateWrite']) { + test(`new valid legacy state at ${boundary} is never cleaned without its lock`, value => { + const originalState = fs.readFileSync(value.legacyStatePath); + fs.rmSync(value.legacyRoot, { recursive: true, force: true }); + let injected = false; + assert.throws(() => applyInstallPlan(value.canonicalPlan, { + [boundary]() { + injected = true; + fs.mkdirSync(path.dirname(value.legacyFile), { recursive: true }); + fs.writeFileSync(value.legacyFile, SKILL_CONTENT); + fs.writeFileSync(value.legacyStatePath, originalState); + }, + }), /lease does not cover/); + assert.ok(injected); + assert.deepStrictEqual(fs.readFileSync(value.legacyStatePath), originalState); + assert.strictEqual(fs.readFileSync(value.legacyFile, 'utf8'), SKILL_CONTENT); + assert.strictEqual(fs.existsSync(lockPath(value.canonicalRoot)), false); + assert.strictEqual(fs.existsSync(lockPath(value.legacyRoot)), false); + }); + } + for (const consent of [null, 'declined']) { + test(`verified active legacy copies migrate under ${consent || 'default'} consent with no hooks`, value => { + addLegacyActivations(value, consent); + const before = fs.readFileSync(value.legacyStatePath); + const doctor = buildDoctorReport({ repoRoot: value.sourceRoot, homeDir: value.homeDir, + projectRoot: value.homeDir, targets: ['opencode'], env: {} }); + assert.ok(doctor.results[0].issues.some(issue => issue.code === 'legacy-opencode-layout')); + assert.ok(!doctor.results[0].issues.some(issue => issue.code === 'opencode-hook-consent-violation')); + assert.deepStrictEqual(fs.readFileSync(value.legacyStatePath), before); + let builds = 0; + const result = repair(value, sourceRoot => { + builds++; + for (const target of [value.canonicalRoot, value.legacyRoot]) assert.ok(fs.existsSync(lockPath(target))); + buildInertPayload(sourceRoot); + }); + assert.strictEqual(result.results[0].status, 'repaired', JSON.stringify(result)); + assert.strictEqual(builds, 1); + const state = readInstallState(value.canonicalStatePath); + 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'); + 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); + } + assertBothLocksReleased(value); + }); + } + for (const defect of ['modified', 'missing-digest', 'source-mismatch', 'source-missing', 'symlink', 'unrecorded-alias', 'merge-json']) { + test(`legacy ${defect} activation refuses before build and preserves ownership`, value => { + const state = addLegacyActivations(value); + const plugin = path.join(value.legacyRoot, 'plugins/ecc-hooks.ts'); + const source = path.join(value.sourceRoot, '.opencode/plugins/ecc-hooks.ts'); + if (defect === 'modified') fs.writeFileSync(plugin, 'user edit\n'); + if (defect === 'missing-digest') delete state.operations[2].contentSha256; + if (defect === 'source-mismatch') fs.writeFileSync(source, 'different trusted source\n'); + if (defect === 'source-missing') fs.unlinkSync(source); + if (defect === 'symlink') { fs.unlinkSync(plugin); fs.symlinkSync(source, plugin); } + if (defect === 'unrecorded-alias') fs.copyFileSync(source, path.join(value.legacyRoot, 'plugins/index.js')); + if (defect === 'merge-json') state.operations[1].kind = 'merge-json'; + writeInstallState(value.legacyStatePath, state); + const before = fs.readFileSync(value.legacyStatePath); + let builds = 0; + const result = repair(value, () => { builds++; throw new Error('Must refuse before build'); }); + assert.strictEqual(result.results[0].status, 'error', defect); + assert.strictEqual(builds, 0); + assert.deepStrictEqual(fs.readFileSync(value.legacyStatePath), before); + assert.strictEqual(fs.existsSync(value.canonicalStatePath), false); + assertBothLocksReleased(value); + }); + } + for (const failure of ['late-edit', 'remove-error', 'quarantine-edit']) { + test(`legacy ${failure} never reports successful deactivation`, value => { + addLegacyActivations(value); + const plugin = path.join(value.legacyRoot, 'plugins/ecc-hooks.ts'); + const originalRename = fileSystem.renameSync; + const before = fs.readFileSync(value.legacyStatePath); + let failedRemoval = false; + fileSystem.renameSync = (from, ...args) => { + if (failure === 'remove-error' && from === plugin) { + failedRemoval = true; + throw Object.assign(new Error('Synthetic removal denial'), { code: 'EACCES' }); + } + if (failure === 'quarantine-edit' && from === plugin) fs.writeFileSync(plugin, 'user edit at quarantine\n'); + return originalRename(from, ...args); + }; + try { + const result = repair(value, sourceRoot => { + buildInertPayload(sourceRoot); + if (failure === 'late-edit') fs.writeFileSync(plugin, 'user edit after preflight\n'); + }); + assert.strictEqual(result.results[0].status, 'error', JSON.stringify(result)); + assert.notStrictEqual(result.results[0].stateRefreshed, true); + assert.deepStrictEqual(fs.readFileSync(value.legacyStatePath), before); + if (failure === 'late-edit') assert.strictEqual(fs.readFileSync(plugin, 'utf8'), 'user edit after preflight\n'); + else if (failure === 'quarantine-edit') assert.strictEqual(fs.readFileSync(plugin, 'utf8'), 'user edit at quarantine\n'); + else assert.ok(failedRemoval, 'The actual cleanup removal was attempted'); + } finally { fileSystem.renameSync = originalRename; } + assertBothLocksReleased(value); + }); + } console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); return { passed, failed }; } diff --git a/tests/lib/opencode-hook-consent-safety.test.js b/tests/lib/opencode-hook-consent-safety.test.js index 824ab5329..305242015 100644 --- a/tests/lib/opencode-hook-consent-safety.test.js +++ b/tests/lib/opencode-hook-consent-safety.test.js @@ -5,10 +5,13 @@ const crypto = require('crypto'); const fs = require('fs'); const os = require('os'); const path = require('path'); -const { applyInstallPlan } = require('../../scripts/lib/install/apply'); +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 { withHookConsent } = require('../../scripts/lib/install/hook-consent'); const { createInstallState, readInstallState, writeInstallState } = require('../../scripts/lib/install-state'); -const { buildDoctorReport, repairInstalledStates } = require('../../scripts/lib/install-lifecycle'); +const { buildDoctorReport, repairInstalledStates } = load(require.resolve('../../scripts/lib/install-lifecycle')); const REPO_ROOT = path.resolve(__dirname, '../..'); const sha256 = value => crypto.createHash('sha256').update(value).digest('hex'); @@ -98,8 +101,16 @@ function fixture(callback, enabled = false) { const basePlan = { target: 'opencode', adapter, homeDir, sourceRoot, targetRoot, installRoot: targetRoot, installStatePath, operations, warnings: [], selectedModuleIds: state.resolution.selectedModules, statePreview: state }; - callback({ root, homeDir, sourceRoot, targetRoot, installStatePath, state, basePlan, - declinePlan: withHookConsent(basePlan, 'declined') }); + const previousCwd = process.cwd(); + try { + // Hosted Windows checkouts and os.tmpdir() may be on different drives. + // Anchor relative-root callers inside their private fixture on every OS. + process.chdir(root); + callback({ root, homeDir, sourceRoot, targetRoot, installStatePath, state, basePlan, + declinePlan: withHookConsent(basePlan, 'declined') }); + } finally { + process.chdir(previousCwd); + } } finally { fs.rmSync(root, { recursive: true, force: true }); } @@ -112,11 +123,11 @@ function repair(value, extra = {}) { } function withWritableOpenMutation(filePath, mutate, callback) { - const originalOpen = fs.openSync; - const originalClose = fs.closeSync; + const originalOpen = fileSystem.openSync; + const originalClose = fileSystem.closeSync; let injected = false; const descriptors = new Set(); - fs.openSync = function (candidate, flags, ...rest) { + fileSystem.openSync = function (candidate, flags, ...rest) { const writable = typeof flags === 'number' ? Boolean(flags & (fs.constants.O_WRONLY | fs.constants.O_RDWR)) : /[wa+]/.test(flags); @@ -129,7 +140,7 @@ function withWritableOpenMutation(filePath, mutate, callback) { if (matches && writable) descriptors.add(fd); return fd; }; - fs.closeSync = function (fd) { + fileSystem.closeSync = function (fd) { descriptors.delete(fd); return originalClose.call(fs, fd); }; @@ -138,8 +149,8 @@ function withWritableOpenMutation(filePath, mutate, callback) { assert.ok(injected, 'The destination writable-open boundary must be exercised'); assert.strictEqual(descriptors.size, 0, 'Every owned writable descriptor must close'); } finally { - fs.openSync = originalOpen; - fs.closeSync = originalClose; + fileSystem.openSync = originalOpen; + fileSystem.closeSync = originalClose; } } @@ -158,6 +169,27 @@ function runTests() { try { callback(); passed++; console.log(` PASS ${name}`); } catch (error) { failed++; console.error(` FAIL ${name}: ${error.stack}`); } }; + test('relative-root fixture remains relative across Windows checkout and temp drives', () => { + const source = 'C:\\private\\source'; + assert.strictEqual(path.win32.isAbsolute(path.win32.relative('D:\\checkout', source)), true); + const relative = path.win32.relative('C:\\private', source); + assert.strictEqual(relative, 'source'); + assert.strictEqual(path.win32.isAbsolute(relative), false); + assert.strictEqual(path.win32.resolve('C:\\private', relative), source); + }); + test('private filesystem injection never patches the process filesystem or module cache', () => fixture(value => { + const nativeOpen = fs.openSync; + const nativeClose = fs.closeSync; + const cachedApply = require.cache[require.resolve('../../scripts/lib/install/apply')]; + const destination = path.join(value.targetRoot, 'plugins/ecc-hooks.ts'); + withWritableOpenMutation(destination, () => { + assert.strictEqual(fs.openSync, nativeOpen); + assert.strictEqual(fs.closeSync, nativeClose); + }, () => assert.strictEqual(applyInstallPlan(value.declinePlan).applied, true)); + assert.strictEqual(fs.openSync, nativeOpen); + assert.strictEqual(fs.closeSync, nativeClose); + assert.strictEqual(require.cache[require.resolve('../../scripts/lib/install/apply')], cachedApply); + })); for (const relativePath of ['plugins/ecc-hooks.ts', 'opencode.json']) { for (const mode of ['apply', 'repair']) { test(`${mode} preserves a same-inode ${relativePath} edit at writable open`, () => fixture(value => { @@ -366,6 +398,37 @@ function runTests() { }); })); } + for (const consent of [null, 'declined', 'enabled']) { + test(`nested JavaScript entrypoint applies with ${consent || 'default'} consent semantics`, () => fixture(value => { + const sourceRelativePath = '.opencode/plugins/custom/index.js'; + const sourcePath = path.join(value.sourceRoot, sourceRelativePath); + const destinationPath = path.join(value.targetRoot, 'plugins/custom/index.js'); + const content = 'export default async () => ({ event: () => {} });\n'; + fs.mkdirSync(path.dirname(sourcePath), { recursive: true }); + fs.writeFileSync(sourcePath, content); + const operation = { kind: 'copy-file', moduleId: 'platform-configs', sourceRelativePath, + sourcePath, destinationPath, ownership: 'managed', scaffoldOnly: false }; + const selectedModuleIds = consent === 'enabled' ? ['platform-configs', 'hooks-runtime'] : ['platform-configs']; + const plan = withHookConsent({ ...value.basePlan, selectedModuleIds, + 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'); + })); + } + test('nested package metadata fails before writing and never receives a JavaScript tombstone', () => fixture(value => { + const destinationPath = path.join(value.targetRoot, 'plugins/custom/package.json'); + const sourcePath = path.join(value.sourceRoot, '.opencode/plugins/custom/package.json'); + fs.mkdirSync(path.dirname(sourcePath), { recursive: true }); + fs.writeFileSync(sourcePath, '{"main":"index.js"}\n'); + const before = fs.readFileSync(value.installStatePath); + const operation = { kind: 'copy-file', sourceRelativePath: '.opencode/plugins/custom/package.json', + sourcePath, destinationPath, ownership: 'managed' }; + assert.throws(() => applyInstallPlan(withHookConsent({ ...value.basePlan, + operations: [...value.basePlan.operations, operation] }, 'declined')), /unsupported.*package/i); + assert.strictEqual(fs.existsSync(destinationPath), false); + assert.deepStrictEqual(fs.readFileSync(value.installStatePath), before); + })); for (const rootForm of ['relative', 'missing']) { for (const consent of [null, 'declined']) { test(`${rootForm} source root keeps historical refusal for fresh ${consent || 'default'} apply`, () => fixture(value => { @@ -523,9 +586,9 @@ function runTests() { 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; + const originalOpen = fileSystem.openSync; let refusedReads = 0; - fs.openSync = function (candidate, ...args) { + fileSystem.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' }); @@ -537,7 +600,7 @@ function runTests() { else assert.strictEqual(applyInstallPlan(value.declinePlan).applied, true); assert.ok(refusedReads > 0, 'The permission boundary must be exercised'); } finally { - fs.openSync = originalOpen; + fileSystem.openSync = originalOpen; } assert.deepStrictEqual(fs.readFileSync(destination), content); if (!planned) assert.ok(!readInstallState(value.installStatePath).operations.some(operation => operation.destinationPath === destination)); diff --git a/tests/lib/opencode-legacy-migration.test.js b/tests/lib/opencode-legacy-migration.test.js index 9cce6bff0..0a871336b 100644 --- a/tests/lib/opencode-legacy-migration.test.js +++ b/tests/lib/opencode-legacy-migration.test.js @@ -118,7 +118,7 @@ function canonicalPlan(homeDir, env) { console.log('\n=== Testing OpenCode legacy migration ===\n'); -test('legacy inspection distinguishes absent, invalid, and unreadable state', () => { +test('legacy inspection distinguishes absent and invalid state without claiming ownership', () => { const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-legacy-inspect-')); try { const location = getLegacyOpencodeLocation(homeDir); @@ -131,9 +131,9 @@ test('legacy inspection distinguishes absent, invalid, and unreadable state', () fs.rmSync(location.installStatePath, { recursive: true, force: true }); fs.writeFileSync(location.installStatePath, '{not-json', 'utf8'); - const unreadable = inspectLegacyOpencodeState(location); - assert.strictEqual(unreadable.status, 'unreadable'); - assert.ok(unreadable.error.includes(location.installStatePath)); + const malformed = inspectLegacyOpencodeState(location); + assert.strictEqual(malformed.status, 'invalid'); + assert.strictEqual(malformed.error, null); assert.deepStrictEqual(cleanupLegacyOpencodeInstall(null), { detected: false,