diff --git a/scripts/lib/install-lifecycle.js b/scripts/lib/install-lifecycle.js index 7da0ae78a..36122e7ea 100644 --- a/scripts/lib/install-lifecycle.js +++ b/scripts/lib/install-lifecycle.js @@ -10,8 +10,15 @@ const { readInstallState, validateInstallState } = require('./install-state'); const { assertWithinTrustedRoot } = require('./path-safety'); const { createInstallPlanFromRequest } = require('./install/runtime'); const { assertNoNewUserOwnedFile, prepareUserOwnedFileGuard } = require('./install/ownership-guard'); +const { writeFileNoFollow: guardedWriteFile } = require('./install/guarded-write'); +const { withOpenCodeInstallLocks } = require('./install/opencode-install-lock'); const { isCodexUserConfig } = require('./install/codex-user-config'); -const { getRecordedHookConsent } = require('./install/hook-consent'); +const { + disableOpenCodeHookPluginRegistration, + getDisabledOpenCodePluginContent, + getRecordedHookConsent, + withHookConsent, +} = require('./install/hook-consent'); const { prepareClaudeSkillMigration, } = require('./install/claude-skill-migration'); @@ -232,6 +239,12 @@ 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); + } + if (operation.contentTransform === 'opencode-disable-plugin-entrypoint') { + return getDisabledOpenCodePluginContent(); + } throw new Error(`Unknown install content transform: ${operation.contentTransform}`); } @@ -450,66 +463,15 @@ function createChangedDestinationError(action) { ); } -function getStableParentStat(filePath, action) { - const parentStat = fs.lstatSync(path.dirname(filePath)); - if (!parentStat.isDirectory() || parentStat.isSymbolicLink()) { - throw createChangedDestinationError(action); - } - return parentStat; -} - -function assertPinnedWriteDestination( - filePath, - fileDescriptor, - expectedParentStat, - trustedRoot, - action -) { - const liveDestination = getManagedDestination(filePath, trustedRoot, action); - if (path.resolve(liveDestination.managedPath) !== path.resolve(filePath)) { - throw createChangedDestinationError(action); - } - - const liveParentStat = getStableParentStat(filePath, action); - if (!hasSameFileIdentity(expectedParentStat, liveParentStat)) { - throw createChangedDestinationError(action); - } - - const descriptorStat = fs.fstatSync(fileDescriptor); - const livePathStat = fs.lstatSync(liveDestination.managedPath); - if ( - !descriptorStat.isFile() - || !livePathStat.isFile() - || livePathStat.isSymbolicLink() - || !hasSameFileIdentity(descriptorStat, livePathStat) - ) { - throw createChangedDestinationError(action); - } -} - -function writeFileNoFollow(filePath, content, mode, trustedRoot, action) { - const expectedParentStat = getStableParentStat(filePath, action); - const flags = fs.constants.O_WRONLY - | fs.constants.O_CREAT - | (fs.constants.O_NOFOLLOW || 0); - const fileDescriptor = fs.openSync(filePath, flags, mode); - - try { - assertPinnedWriteDestination( - filePath, - fileDescriptor, - expectedParentStat, - trustedRoot, - action - ); - fs.ftruncateSync(fileDescriptor, 0); - fs.writeFileSync(fileDescriptor, content); - if (mode !== undefined) { - fs.fchmodSync(fileDescriptor, mode); - } - } finally { - fs.closeSync(fileDescriptor); - } +function writeFileNoFollow(filePath, content, mode, trustedRoot, action, writeOptions = {}) { + return guardedWriteFile(filePath, content, { + ...writeOptions, + mode, + action, + validateDestination(destinationPath) { + return getManagedDestination(destinationPath, trustedRoot, action).managedPath; + }, + }); } function readFileWithMetadataNoFollow(filePath, encoding) { @@ -564,7 +526,7 @@ function assertClaudeSettingsDestination(operation, trustedRoot, target = null) assertClaudeSettingsPath(operation.destinationPath, trustedRoot); } -function writeContainedFile(destinationPath, content, trustedRoot, action, mode) { +function writeContainedFile(destinationPath, content, trustedRoot, action, mode, writeOptions) { const preparedDestination = prepareContainedWriteDestination(destinationPath, trustedRoot, action); const finalDestination = getManagedDestination( preparedDestination, @@ -576,7 +538,8 @@ function writeContainedFile(destinationPath, content, trustedRoot, action, mode) content, mode, trustedRoot, - action + action, + writeOptions ); return finalDestination; } @@ -782,7 +745,8 @@ function executeRepairOperation( trustedRoot, linkIndex = null, target = null, - settingsLockHeld = false + settingsLockHeld = false, + writeOptions = {} ) { // Install-state is attacker-controllable; never write/delete outside the // adapter-derived trusted root, regardless of what the state file claims @@ -800,7 +764,8 @@ function executeRepairOperation( getExpectedCopyFileContent(operation, source.content, linkIndex), trustedRoot, 'repair', - source.mode & 0o777 + source.mode & 0o777, + writeOptions ); } else { copyContainedFile(sourcePath, operation.destinationPath, trustedRoot, 'repair'); @@ -1179,7 +1144,11 @@ function inspectManagedOperation(repoRoot, trustedRoot, operation, linkIndex = n let contentMatches; try { - contentMatches = hasRecordedContentDigest(operation) + // A deactivation transform changes the desired bytes. A historical + // active-file digest proves ownership, not completion of that transition. + const deactivatesOpenCode = operation.contentTransform === 'opencode-disable-ecc-hooks' + || operation.contentTransform === 'opencode-disable-plugin-entrypoint'; + contentMatches = hasRecordedContentDigest(operation) && !deactivatesOpenCode ? fileMatchesRecordedContent(inspectedPath, operation) : operation.contentTransform || isMarkdownPath(operation.destinationPath) ? readFileNoFollow(inspectedPath, 'utf8') === getExpectedCopyFileContent( @@ -1629,6 +1598,14 @@ function analyzeRecord(record, context) { }; } + if (record.adapter.target === 'opencode') { + try { + preflightOpenCodeHookDeactivation(record, context, { requireInactive: true }); + } catch (error) { + issues.push(buildIssue('error', 'opencode-hook-consent-violation', error.message)); + } + } + if (!fs.existsSync(state.target.root)) { issues.push(buildIssue('error', 'missing-target-root', `Target root does not exist: ${state.target.root}`)); } @@ -1821,7 +1798,8 @@ function createRepairPlanFromRecord(record, context, options = {}) { ); const statePreview = buildRecordedStatePreview(state, context, operations); - return { + const recordedPlan = { + sourceRoot: context.repoRoot, mode: state.request.legacyMode ? 'legacy' : 'recorded', target: record.adapter.target, adapter: record.adapter, @@ -1830,9 +1808,13 @@ function createRepairPlanFromRecord(record, context, options = {}) { installStatePath: state.target.installStatePath, warnings: [], languages: Array.isArray(state.request.legacyLanguages) ? [...state.request.legacyLanguages] : [], + selectedModuleIds: [...(state.resolution.selectedModules || [])], operations, statePreview }; + return record.adapter.target === 'opencode' + ? withHookConsent(recordedPlan, getRecordedHookConsent(state)) + : recordedPlan; } const desiredPlan = resolveRecordedManifestPlan(record, context, options); @@ -1882,8 +1864,11 @@ function writeRefreshedInstallState(record, statePreview, writtenPaths = []) { return { ...operation }; } // Refreshing a ledger is not a file write. Keep the last installed digest - // for untouched shared configs so a concurrent user edit is never claimed. - if (isCodexUserConfig(record, operation) + // for untouched shared configs and OpenCode activations so a concurrent + // user edit is never claimed, even by a partial repair checkpoint. + if ((isCodexUserConfig(record, operation) + || (record.adapter.target === 'opencode' + && require('./install/apply').getOpenCodeActivationKind(record, operation))) && !writtenPaths.some(writtenPath => path.relative(writtenPath, operation.destinationPath) === '')) { const previousOperation = (record.state.operations || []).find(previous => ( previous.destinationPath @@ -1923,10 +1908,13 @@ function prepareRepairMigration(plan, record) { installStatePath: record.installStatePath, statePreview: buildAdapterDerivedStatePreview(plan.statePreview, record), }; - const skillMigration = prepareClaudeSkillMigration(trustedPlan); - const migration = record.adapter.id === 'codex-home' - ? prepareUserOwnedFileGuard(trustedPlan, skillMigration) - : skillMigration; + const initialMigration = prepareClaudeSkillMigration(trustedPlan); + const guardedMigration = record.adapter.id === 'codex-home' + ? prepareUserOwnedFileGuard(trustedPlan, initialMigration) + : initialMigration; + const migration = trustedPlan.target === 'opencode' + ? require('./install/apply').prepareHookConsentMigration(trustedPlan, guardedMigration) + : guardedMigration; return { migration, plan: { @@ -1941,6 +1929,49 @@ function prepareRepairMigration(plan, record) { }; } +function assertOpenCodeRepairHookDeactivation(plan, options = {}) { + if (plan.target !== 'opencode') { + return new Map(); + } + // The installer imports lifecycle helpers. Resolve this shared read-only + // guard lazily so both paths enforce the same discovery/ownership boundary. + const { assertOpenCodeHookDeactivationReady } = require('./install/apply'); + return assertOpenCodeHookDeactivationReady(plan, options); +} + +function preflightOpenCodeHookDeactivation(record, context, options = {}) { + if (record.adapter.target !== 'opencode') { + return; + } + const rawPlan = createRepairPlanFromRecord(record, context, { + // Planning must reject unsafe existing activations before a repair build + // writes compiled files. Missing payload is still validated/built later. + exemptValidationCodes: [OPENCODE_PLUGIN_NOT_BUILT_CODE], + }); + if (record.legacyLayout === 'opencode') { + const state = record.state; + const legacyPlan = withHookConsent({ + sourceRoot: context.repoRoot, + target: 'opencode', + adapter: record.adapter, + targetRoot: record.targetRoot, + installRoot: record.targetRoot, + installStatePath: record.installStatePath, + selectedModuleIds: [...(state.resolution.selectedModules || [])], + operations: getManagedOperations(state), + statePreview: buildAdapterDerivedStatePreview(state, record), + }, getRecordedHookConsent(state)); + // 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(rawPlan, options); + return; + } + const { plan } = prepareRepairMigration(rawPlan, record); + assertOpenCodeRepairHookDeactivation(plan, options); +} + function repairInstalledStates(options = {}) { const repoRoot = options.repoRoot || DEFAULT_REPO_ROOT; const manifests = loadInstallManifests({ repoRoot }); @@ -1965,35 +1996,130 @@ function repairInstalledStates(options = {}) { && (!record.legacy || record.legacyLayout === 'opencode') )); - const results = records.map(record => { - if (record.error) { - return { - adapter: record.adapter, - status: 'error', - installStatePath: record.installStatePath, - repairedPaths: [], - plannedRepairs: [], - error: record.error - }; - } - - let releaseSettingsLock = null; - try { - const settingsPathToLock = !options.dryRun - && getManagedOperations(record.state || {}).some( - operation => operation.kind === 'update-claude-settings' - ) - ? getClaudeSettingsPath(record.targetRoot) - : null; - if (settingsPathToLock) { - releaseSettingsLock = acquireSettingsLock(settingsPathToLock); + const results = records.map(initialRecord => { + let record = initialRecord; + const performRepair = (opencodeLease) => { + if (record.error) { + return { + adapter: record.adapter, + status: 'error', + installStatePath: record.installStatePath, + repairedPaths: [], + plannedRepairs: [], + error: record.error + }; } - const needsOpencodeBuild = record.adapter.target === 'opencode' - && hasOpencodeBuildError(getOpencodeBuildValidationIssues(context)); - const opencodeBuildRepairPath = path.join(context.repoRoot, OPENCODE_BUILD_ARTIFACT); - if (record.legacyLayout === 'opencode') { - if (needsOpencodeBuild && !options.dryRun) { + let releaseSettingsLock = null; + try { + preflightOpenCodeHookDeactivation(record, context); + const settingsPathToLock = !options.dryRun + && getManagedOperations(record.state || {}).some( + operation => operation.kind === 'update-claude-settings' + ) + ? getClaudeSettingsPath(record.targetRoot) + : null; + if (settingsPathToLock) { + releaseSettingsLock = acquireSettingsLock(settingsPathToLock); + } + const needsOpencodeBuild = record.adapter.target === 'opencode' + && hasOpencodeBuildError(getOpencodeBuildValidationIssues(context)); + const opencodeBuildRepairPath = path.join(context.repoRoot, OPENCODE_BUILD_ARTIFACT); + + if (record.legacyLayout === 'opencode') { + if (needsOpencodeBuild && !options.dryRun) { + try { + buildOpencodeRunner(context.repoRoot); + } catch (error) { + return { + adapter: record.adapter, + status: 'error', + installStatePath: record.installStatePath, + repairedPaths: [], + plannedRepairs: [], + error: formatBuildErrorMessage(error), + }; + } + } + + const canonicalPlan = createRepairPlanFromRecord(record, context, { + exemptValidationCodes: options.dryRun && needsOpencodeBuild + ? [OPENCODE_PLUGIN_NOT_BUILT_CODE] + : [], + }); + assertOpenCodeRepairHookDeactivation(canonicalPlan); + const plannedRepairs = [...new Set([ + ...(needsOpencodeBuild ? [opencodeBuildRepairPath] : []), + ...canonicalPlan.operations.map(operation => operation.destinationPath), + ...getManagedOperations(record.state).map(operation => operation.destinationPath), + record.installStatePath, + ])]; + + if (options.dryRun) { + return { + adapter: record.adapter, + status: 'planned', + installStatePath: canonicalPlan.installStatePath, + repairedPaths: [], + plannedRepairs, + stateRefreshed: false, + warnings: canonicalPlan.warnings, + error: null, + }; + } + + // Load lazily to avoid a module cycle during install-lifecycle startup. + const { applyInstallPlan } = require('./install/apply'); + const appliedPlan = applyInstallPlan(canonicalPlan, { opencodeLease }); + return { + adapter: record.adapter, + status: 'repaired', + installStatePath: canonicalPlan.installStatePath, + repairedPaths: [ + ...(needsOpencodeBuild ? [opencodeBuildRepairPath] : []), + ...canonicalPlan.operations.map(operation => operation.destinationPath), + ], + plannedRepairs: [], + stateRefreshed: true, + warnings: appliedPlan.warnings, + error: null, + }; + } + + if (needsOpencodeBuild && options.dryRun) { + const rawPlan = createRepairPlanFromRecord(record, context, { + exemptValidationCodes: [OPENCODE_PLUGIN_NOT_BUILT_CODE], + }); + const { plan: desiredPlan } = prepareRepairMigration(rawPlan, record); + const operationHealth = summarizeManagedOperationHealth( + context.repoRoot, + record.targetRoot, + desiredPlan.operations, + record.adapter.target + ); + const unsafeOperationResult = getUnsafeOperationResult( + record, + operationHealth + ); + if (unsafeOperationResult) { + return unsafeOperationResult; + } + const repairOperations = [...operationHealth.missing.map(entry => ({ ...entry.operation })), ...operationHealth.drifted.map(entry => ({ ...entry.operation }))]; + const plannedRepairs = [opencodeBuildRepairPath, ...repairOperations.map(operation => operation.destinationPath)]; + + return { + adapter: record.adapter, + status: 'planned', + installStatePath: record.installStatePath, + repairedPaths: [], + plannedRepairs, + stateRefreshed: false, + warnings: desiredPlan.warnings, + error: null + }; + } + + if (needsOpencodeBuild) { try { buildOpencodeRunner(context.repoRoot); } catch (error) { @@ -2003,65 +2129,24 @@ function repairInstalledStates(options = {}) { installStatePath: record.installStatePath, repairedPaths: [], plannedRepairs: [], - error: formatBuildErrorMessage(error), + error: formatBuildErrorMessage(error) }; } } - const canonicalPlan = createRepairPlanFromRecord(record, context, { - exemptValidationCodes: options.dryRun && needsOpencodeBuild - ? [OPENCODE_PLUGIN_NOT_BUILT_CODE] - : [], - }); - const plannedRepairs = [...new Set([ - ...(needsOpencodeBuild ? [opencodeBuildRepairPath] : []), - ...canonicalPlan.operations.map(operation => operation.destinationPath), - ...getManagedOperations(record.state).map(operation => operation.destinationPath), - record.installStatePath, - ])]; - - if (options.dryRun) { - return { - adapter: record.adapter, - status: 'planned', - installStatePath: canonicalPlan.installStatePath, - repairedPaths: [], - plannedRepairs, - stateRefreshed: false, - warnings: canonicalPlan.warnings, - error: null, - }; - } - - // Load lazily to avoid a module cycle during install-lifecycle startup. - const { applyInstallPlan } = require('./install/apply'); - const appliedPlan = applyInstallPlan(canonicalPlan); - return { - adapter: record.adapter, - status: 'repaired', - installStatePath: canonicalPlan.installStatePath, - repairedPaths: [ - ...(needsOpencodeBuild ? [opencodeBuildRepairPath] : []), - ...canonicalPlan.operations.map(operation => operation.destinationPath), - ], - plannedRepairs: [], - stateRefreshed: true, - warnings: appliedPlan.warnings, - error: null, - }; - } - - if (needsOpencodeBuild && options.dryRun) { - const rawPlan = createRepairPlanFromRecord(record, context, { - exemptValidationCodes: [OPENCODE_PLUGIN_NOT_BUILT_CODE], - }); - const { plan: desiredPlan } = prepareRepairMigration(rawPlan, record); + const rawPlan = createRepairPlanFromRecord(record, context); + const { + migration, + plan: desiredPlan, + } = prepareRepairMigration(rawPlan, record); + const activationSnapshot = assertOpenCodeRepairHookDeactivation(desiredPlan); const operationHealth = summarizeManagedOperationHealth( context.repoRoot, record.targetRoot, desiredPlan.operations, record.adapter.target ); + const unsafeOperationResult = getUnsafeOperationResult( record, operationHealth @@ -2069,175 +2154,168 @@ function repairInstalledStates(options = {}) { if (unsafeOperationResult) { return unsafeOperationResult; } - const repairOperations = [...operationHealth.missing.map(entry => ({ ...entry.operation })), ...operationHealth.drifted.map(entry => ({ ...entry.operation }))]; - const plannedRepairs = [opencodeBuildRepairPath, ...repairOperations.map(operation => operation.destinationPath)]; - return { - adapter: record.adapter, - status: 'planned', - installStatePath: record.installStatePath, - repairedPaths: [], - plannedRepairs, - stateRefreshed: false, - warnings: desiredPlan.warnings, - error: null - }; - } - - if (needsOpencodeBuild) { - try { - buildOpencodeRunner(context.repoRoot); - } catch (error) { + if (operationHealth.missingSource.length > 0) { return { adapter: record.adapter, status: 'error', installStatePath: record.installStatePath, repairedPaths: [], plannedRepairs: [], - error: formatBuildErrorMessage(error) + warnings: desiredPlan.warnings, + error: `Missing source file(s): ${operationHealth.missingSource.map(entry => entry.sourcePath).join(', ')}` }; } - } - const rawPlan = createRepairPlanFromRecord(record, context); - const { - migration, - plan: desiredPlan, - } = prepareRepairMigration(rawPlan, record); - const operationHealth = summarizeManagedOperationHealth( - context.repoRoot, - record.targetRoot, - desiredPlan.operations, - record.adapter.target - ); + const repairOperations = [ + ...operationHealth.missing.map(entry => ({ ...entry.operation })), + ...operationHealth.drifted.map(entry => ({ ...entry.operation })), + ...desiredPlan.operations + .filter(operation => ( + operation.kind === 'update-claude-settings' + && operation.previousManagedHooks + && !isDeepStrictEqual(operation.previousManagedHooks, operation.managedHooks) + )) + .map(operation => ({ ...operation })), + ].filter((operation, index, items) => items.findIndex(candidate => ( + candidate.kind === operation.kind + && candidate.destinationPath === operation.destinationPath + )) === index); + const repairLinkIndex = buildLinkIndexForOperations(desiredPlan.operations, record.targetRoot); + const legacyMigrationPaths = migration.legacyOperationsToRemove.map( + operation => operation.destinationPath + ); + const plannedRepairs = [...new Set([ + ...(needsOpencodeBuild ? [opencodeBuildRepairPath] : []), + ...repairOperations.map(operation => operation.destinationPath), + ...legacyMigrationPaths, + ])]; - const unsafeOperationResult = getUnsafeOperationResult( - record, - operationHealth - ); - if (unsafeOperationResult) { - return unsafeOperationResult; - } + if (options.dryRun) { + return { + adapter: record.adapter, + status: plannedRepairs.length > 0 ? 'planned' : 'ok', + installStatePath: record.installStatePath, + repairedPaths: [], + plannedRepairs, + stateRefreshed: plannedRepairs.length === 0, + warnings: desiredPlan.warnings, + error: null + }; + } - if (operationHealth.missingSource.length > 0) { + const hasLegacyMigration = migration.legacyOperationsToRemove.length > 0; + const repairedPaths = needsOpencodeBuild ? [opencodeBuildRepairPath] : []; + if (desiredPlan.target === 'opencode') { + const { assertOpenCodeActivationUnchanged } = require('./install/apply'); + // Health inspection must not let a changed activation become owned by + // a bridge checkpoint before the per-write check can reject it. + for (const destinationPath of activationSnapshot.keys()) { + assertOpenCodeActivationUnchanged(desiredPlan, { destinationPath }, activationSnapshot); + } + } + if (migration.requiresBridgeState && (repairOperations.length > 0 || hasLegacyMigration)) { + writeRefreshedInstallState(record, migration.bridgeState); + } + + for (const operation of repairOperations) { + if (desiredPlan.target === 'opencode') { + const { assertOpenCodeActivationUnchanged } = require('./install/apply'); + assertOpenCodeActivationUnchanged(desiredPlan, operation, activationSnapshot); + } + if (record.adapter.id === 'codex-home') { + assertNoNewUserOwnedFile(migration, operation, desiredPlan); + } + const repairedPath = executeRepairOperation( + context.repoRoot, + operation, + record.targetRoot, + repairLinkIndex, + record.adapter.target, + Boolean(releaseSettingsLock), + require('./install/apply').getOpenCodeActivationWriteOptions(operation, activationSnapshot) + ); + if (repairedPath) { + repairedPaths.push(repairedPath); + } + } + if (hasLegacyMigration) { + for (const operation of migration.legacyOperationsToRemove) { + const removedPath = removeContainedPath( + operation.destinationPath, + record.targetRoot, + 'migrate managed Claude skill', + { force: true } + ); + if (removedPath) { + repairedPaths.push(removedPath); + } + } + } + const changedInstalledBytes = repairOperations.length > 0 + || needsOpencodeBuild + || hasLegacyMigration; + const statePreviewToWrite = changedInstalledBytes + ? desiredPlan.statePreview + : { + ...desiredPlan.statePreview, + installedAt: record.state.installedAt, + source: { ...record.state.source }, + }; + assertOpenCodeRepairHookDeactivation(desiredPlan, { requireInactive: true }); + writeRefreshedInstallState(record, statePreviewToWrite, repairedPaths); + + return { + adapter: record.adapter, + status: (repairOperations.length > 0 || needsOpencodeBuild || hasLegacyMigration) + ? 'repaired' + : 'ok', + installStatePath: record.installStatePath, + repairedPaths, + plannedRepairs: [], + stateRefreshed: true, + warnings: desiredPlan.warnings, + error: null + }; + } catch (error) { return { adapter: record.adapter, status: 'error', installStatePath: record.installStatePath, repairedPaths: [], plannedRepairs: [], - warnings: desiredPlan.warnings, - error: `Missing source file(s): ${operationHealth.missingSource.map(entry => entry.sourcePath).join(', ')}` + error: error.message }; + } finally { + if (releaseSettingsLock) releaseSettingsLock(); } - - const repairOperations = [ - ...operationHealth.missing.map(entry => ({ ...entry.operation })), - ...operationHealth.drifted.map(entry => ({ ...entry.operation })), - ...desiredPlan.operations - .filter(operation => ( - operation.kind === 'update-claude-settings' - && operation.previousManagedHooks - && !isDeepStrictEqual(operation.previousManagedHooks, operation.managedHooks) - )) - .map(operation => ({ ...operation })), - ].filter((operation, index, items) => items.findIndex(candidate => ( - candidate.kind === operation.kind - && candidate.destinationPath === operation.destinationPath - )) === index); - const repairLinkIndex = buildLinkIndexForOperations(desiredPlan.operations, record.targetRoot); - const legacyMigrationPaths = migration.legacyOperationsToRemove.map( - operation => operation.destinationPath - ); - const plannedRepairs = [...new Set([ - ...(needsOpencodeBuild ? [opencodeBuildRepairPath] : []), - ...repairOperations.map(operation => operation.destinationPath), - ...legacyMigrationPaths, - ])]; - - if (options.dryRun) { - return { - adapter: record.adapter, - status: plannedRepairs.length > 0 ? 'planned' : 'ok', - installStatePath: record.installStatePath, - repairedPaths: [], - plannedRepairs, - stateRefreshed: plannedRepairs.length === 0, - warnings: desiredPlan.warnings, - error: null - }; - } - - const hasLegacyMigration = migration.legacyOperationsToRemove.length > 0; - const repairedPaths = needsOpencodeBuild ? [opencodeBuildRepairPath] : []; - if (migration.requiresBridgeState && (repairOperations.length > 0 || hasLegacyMigration)) { - writeRefreshedInstallState(record, migration.bridgeState); - } - - for (const operation of repairOperations) { - if (record.adapter.id === 'codex-home') { - assertNoNewUserOwnedFile(migration, operation, desiredPlan); + }; + if (record.adapter.target !== 'opencode' || options.dryRun) return performRepair(); + let repairResult; + try { + const adapter = getInstallTargetAdapter('opencode'); + const targetRoot = adapter.resolveRoot({ + homeDir: context.homeDir, projectRoot: context.projectRoot, + repoRoot: context.projectRoot, env: context.env, + }); + const { getOpenCodeInstallRoots } = require('./install/apply'); + const roots = getOpenCodeInstallRoots({ adapter, targetRoot, homeDir: context.homeDir }); + return withOpenCodeInstallLocks(roots, lease => { + // Discovery precedes acquisition. Never repair from that stale state. + record = buildDiscoveryRecord(adapter, context, record.legacyLayout === 'opencode' + ? getLegacyOpencodeLocation(context.homeDir) : null); + if (!record.exists || record.error) { + throw new Error(record.error || 'OpenCode install-state disappeared before repair.'); } - const repairedPath = executeRepairOperation( - context.repoRoot, - operation, - record.targetRoot, - repairLinkIndex, - record.adapter.target, - Boolean(releaseSettingsLock) - ); - if (repairedPath) { - repairedPaths.push(repairedPath); - } - } - if (hasLegacyMigration) { - for (const operation of migration.legacyOperationsToRemove) { - const removedPath = removeContainedPath( - operation.destinationPath, - record.targetRoot, - 'migrate managed Claude skill', - { force: true } - ); - if (removedPath) { - repairedPaths.push(removedPath); - } - } - } - const changedInstalledBytes = repairOperations.length > 0 - || needsOpencodeBuild - || hasLegacyMigration; - const statePreviewToWrite = changedInstalledBytes - ? desiredPlan.statePreview - : { - ...desiredPlan.statePreview, - installedAt: record.state.installedAt, - source: { ...record.state.source }, - }; - writeRefreshedInstallState(record, statePreviewToWrite, repairedPaths); - - return { - adapter: record.adapter, - status: (repairOperations.length > 0 || needsOpencodeBuild || hasLegacyMigration) - ? 'repaired' - : 'ok', - installStatePath: record.installStatePath, - repairedPaths, - plannedRepairs: [], - stateRefreshed: true, - warnings: desiredPlan.warnings, - error: null - }; + repairResult = performRepair(lease); + return repairResult; + }); } catch (error) { - return { - adapter: record.adapter, - status: 'error', - installStatePath: record.installStatePath, - repairedPaths: [], - plannedRepairs: [], - error: error.message - }; - } finally { - if (releaseSettingsLock) releaseSettingsLock(); + if (repairResult?.status === 'error') { + return { ...repairResult, releaseError: error.message }; + } + return { adapter: record.adapter, status: 'error', installStatePath: record.installStatePath, + repairedPaths: repairResult?.repairedPaths || [], plannedRepairs: [], error: error.message }; } }); diff --git a/scripts/lib/install/apply.js b/scripts/lib/install/apply.js index 1776cb6ca..206a21ac3 100644 --- a/scripts/lib/install/apply.js +++ b/scripts/lib/install/apply.js @@ -9,7 +9,16 @@ const { withCommitAttributionDisabled, } = require('../claude-commit-attribution'); const { readInstallState, writeInstallState } = require('../install-state'); -const { assertHookConsentReady, planMaterializesHookRuntime } = require('./hook-consent'); +const { + assertHookConsentReady, + disableOpenCodeHookPluginRegistration, + getDisabledOpenCodePluginContent, + getRecordedHookConsent, + isOpenCodeHookActivationOperation, + isOpenCodePluginEntrypoint, + planMaterializesHookRuntime, + shouldDisableOpenCodeHooks, +} = require('./hook-consent'); const { getClaudeSettingsPath, mergeManagedHooks, @@ -33,7 +42,9 @@ const { prepareUserOwnedFileGuard, preserveUnwrittenFiles, } = require('./ownership-guard'); -const { cleanupLegacyOpencodeInstall } = require('./opencode-legacy-migration'); +const { cleanupLegacyOpencodeInstall, getLegacyLocationForPlan } = require('./opencode-legacy-migration'); +const { writeFileNoFollow } = require('./guarded-write'); +const { withOpenCodeInstallLocks } = require('./opencode-install-lock'); const { completeExcludedPathsReconciliation, prepareExcludedPathsReconciliation, @@ -52,6 +63,12 @@ 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); + } + if (operation.contentTransform === 'opencode-disable-plugin-entrypoint') { + return getDisabledOpenCodePluginContent(); + } throw new Error(`Unknown install content transform: ${operation.contentTransform}`); } @@ -292,6 +309,237 @@ function comparablePath(filePath) { return process.platform === 'win32' ? resolved.toLowerCase() : resolved; } +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; +} + +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; + } +} + +// Finite, exact public ECC entrypoint history reachable from d3b8a3e908904e242ed2dbe66af62cca71131419. +// Refusal evidence only: matching bytes never grant ownership or permission to +// adopt, rewrite or delete an unrecorded file. Unknown modified/compiled variants +// are not covered. No history lookup or plugin execution occurs at runtime. +const LEGACY_OPENCODE_PLUGIN_DIGESTS = Object.freeze([ + // a0600a00fbe3a193a44584ad55800ce82cec62af:.opencode/plugins/index.ts (blob 3a98f0ba6510d436cc9cf3e2161f8f69771d968a) + '7dd2d255da5d4344eb38ca93cf1765e425b0c01943f6e45428614662ebee0d4b', + // a0600a00fbe3a193a44584ad55800ce82cec62af:.opencode/plugins/ecc-hooks.ts (blob bf06c03f8ff6bdd835c5266921758a6db85152bf) + '5db9b59434af0d5971538f0176779733b8146d7a71fbd9055ae0183c26754068', + // 91ba9b4cf6c47c8130829004f8bb64762a76ccbb:.opencode/plugins/ecc-hooks.ts (blob 4aabde61203d4473e04d5a10803b0560b8c596e4) + 'c683b9321d8b5fbc6889b1740f4583c4f94c84554ee97e2072f61de45c661bda', + // 1a8beb71c5282ddfe77c72ab0290961a820e3d89:.opencode/plugins/ecc-hooks.ts (blob 69b59727e552991a79c196aab8ec128173329d9a) + '1e890ce162325c9d7c579b0716383b6c297179edb78084c4b59cc8bf766ceb71', + // e65f12bf7ea474a6f5ac96991a251673f474b445:.opencode/plugins/index.ts (blob c1e17a1595403080490fcec6820c1485bb6afba9) + '965c5fac76ce0c3ceb3836814f5eb9ede8c9db50373a508c734f949cb321a21a', + // e65f12bf7ea474a6f5ac96991a251673f474b445:.opencode/plugins/ecc-hooks.ts (blob 54881ad868c276ff0d50cc83d8ae938464ad02da) + '5c043b84693a654fffe4b407e87411b28c9af8b92f5ef49a03caab7b27f03102', + // 2cdc218c45a81ce46035832b13bf68d91137301e:.opencode/plugins/ecc-hooks.ts (blob d496e61a538131ff6f33b2e6d941544e3d94c5d8) + '73692e599d271bbb9b7aac59f97e193518af2b5db3a3505af0376c8d8657220a', + // 5929d246946eeb5d147612ba06d60c575c5a4e21:.opencode/plugins/ecc-hooks.ts (blob 472f80f5ae9500fa9a0a7885b6ce4dc4b409d3d6) + 'd7a410380ed2e0bcb613110b2810221d03a8944e50766bc7a4db2eb1b44446b6', + // ca185ef5f7667078a1e70a763bd3a9c71c48acf0:.opencode/plugins/ecc-hooks.ts (blob 22b1132f0964bd4ba5c1a4ad1bafa605de99eb6a) + '0345093b34e537d350c5b5aa0296511f558aa767e5104fb5ef05069013f3b5b6', + // 28e53a0bc10e286f68b53bb1e3b3f049021e57b9:.opencode/plugins/ecc-hooks.ts (blob 47265c0ebd031168d8e3a18f30036864338cd22c) + 'e20ecd53714b1fd55baeff796c6f4538ebd4bae71ca1e791b2b8e29575061846', + // 591ab5cbd3f2f65860ea91c226e410b1502c8e2e:.opencode/plugins/ecc-hooks.ts (blob 49124c255003eb5517178a8ecad7dd453303df33) + 'b9c22c76ae2464c9410963579ee5ff49003e4d79e32b69c228539ae7b15f5104', + // 6f452d48d258b39f4f6e1171b7ca18c6f7f61ad5:.opencode/plugins/ecc-hooks.ts (blob 6336081e97c4345f02adfdc0b9ad9e27dccdec04) + 'c7122565cf97b896cc3da9009bf06daca7513b3e9ba7448c26b8872591dbf3f7', + // 3a08b0c7a85bda69ee9922a103e077a04d538150:.opencode/plugins/ecc-hooks.ts (blob bad6a4cecf2270a7d8a919daeb6541c94a810c48) + 'e438603c13206365068b400063df0af98bd587842b80f09b97fe57f7ddef56e0', + // 29edd57708bee26f16363c16a28fec7f6b09f53f:.opencode/plugins/ecc-hooks.ts (blob 05792ce9ae86a785b746bdb843572e4b5bc93130) + '6a9063b2f67334a78d269d53f95679c33d6d126260f8e5fc7cc941fea9b903e8', + // 8141f6904f14fa8a83131e1cb5b6507d687e25bb:.opencode/plugins/ecc-hooks.ts (blob 606bcb7c59aa5e459d2093ffb9cf9208d1184c30) + 'a198b640fd1faf1c75e96909eabf4ae24899de127b2447490eed813766013836', + // 6d613f67dd24189a8bb7fb1a2f5e535957f46a58:.opencode/plugins/index.ts (blob ca58596901d816147ac4eff525f1a885d36bd094) + 'e89aaa309b7a0578bb69af4a2425744fcf14c2556cd34a1036bbcba448a0b517', + // 6d613f67dd24189a8bb7fb1a2f5e535957f46a58:.opencode/plugins/ecc-hooks.ts (blob 31cfa8ac31ac3cbc5c51b4b275017ef18b8f9033) + 'd66a43e43ef9669589de593e8f94e750ee11d3c75cb5fe79e81636ef738c3e45', + // affbd334858368518c5baf5f84f74034dea1ea6f:.opencode/plugins/ecc-hooks.ts (blob ff8628b5fd47181cbee54367dc4e32e5392cde1a) + '4874a12639fd58da59a54fe5b2461c0ddd2eeca6770111615caa402ff0e95693', + // 0a87323eda77ee412fa3a3bf028a577536966505:.opencode/plugins/ecc-hooks.ts (blob fa96b805685e3c3e6f86535debca5ab8a5bea1ef) + '4e2330f340e074208cd323c1a833667032ce8db4febc4eaeda354bc49cb58bc4', + // a0a1eda8fc4828e58dc8aabcec4e25f9ef038a0a:.opencode/plugins/ecc-hooks.ts (blob 51bde010b4d426676b52ffc9567b1da80f4ca510) + 'ca9abadee5d072121677168752fb4f3b16ce9fc7eb55a3c9c7f517377e0bf69b', + // 05acc275307a09eea89080619a35d7dbd20b128b:.opencode/plugins/ecc-hooks.ts (blob 9e4ab3fcd50f6610cfdfa5a7d0d71c2374f65745) + '96998990d6aac0b9535ab6ab60a0be1c284ffcca7e4ba04f1cfa0e67409a8146', + // a2b3cc1600e9cab58147ef01c03f9889b5a8cc86:.opencode/plugins/ecc-hooks.ts (blob 58a209283f70efe1dbfef5d78b4d72764c67f027) + '0697bfed6e6ad887443a32810e83adb5316d2c9c0490b98f9fcb6ea52bea84e3', + // 0c7deb26a344db095c04a213eba5634d4ccce030:.opencode/plugins/ecc-hooks.ts (blob 9193bb412920a1f1d5af98fda0a66d1e3295f46f) + 'd666a94e9d0ccbcfdeffd59b624938cd44706571eec3771974c57fdbe28577c1', + // 48b883d7412914b04c8b185d9a82685b105d1734:.opencode/plugins/ecc-hooks.ts (blob 3053314750a61dbcdb06a9cca39492304457f582) + '16fe21ca801a613a0ae2fc1f8dd5c8474138dc31c75ea35cd8695884397f1f15', + // d70bab85e33af7a03b78c70dba7a7ce3b01d1b17:.opencode/plugins/ecc-hooks.ts (blob 1f158d7999f5f100e386587a94a90a08d512e278) + '0354270a5fc26809d0795ecc7eef1dee91de4905d58f26d5828d43af24767b96', + // 0e9f613fd196f6d4157765b17d39c2c42ebbf564:.opencode/plugins/ecc-hooks.ts (blob 50d23bfde3607832446fda26b25a3ed3e527e5d1) + '513190b6c935dac472efd11818b20d7f2479ec1f7be06ef7f4d241c2493ad9e3', + // 6d440c036df2c1b2fec957627d1202c3708e0627:.opencode/plugins/index.ts (blob d19a91f1a686d6ed060d08eddeb5aa05a4be6b75) + 'e42c733adb177f84cea813663aa34c7868dbaa98c96950d0ef91cd211b8aa169', + // 6d440c036df2c1b2fec957627d1202c3708e0627:.opencode/plugins/ecc-hooks.ts (blob b64ffae7ce10cab9e5ed9b04cec23d62db9036e7) + '503ea491cbeadff5bf59b936a75bff65caaf1d71e47a953a9b9b790be780efef', +]); + +function knownOpenCodePluginDigests(plan) { + if (typeof plan.sourceRoot !== 'string' || !path.isAbsolute(plan.sourceRoot)) return new Set(); + const digests = new Set(LEGACY_OPENCODE_PLUGIN_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]) { + if (getOpenCodeActivationKind(plan, operation) && operation.destinationPath) { + candidates.set(comparablePath(operation.destinationPath), operation); + } + } + // 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)) { + 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, + }); + } + } + } + } + return candidates; +} + +function activationIsInactive(kind, operation, content) { + if (kind === 'plugin') { + return content.toString('utf8') === getDisabledOpenCodePluginContent(); + } + const text = content.toString('utf8'); + // Validate first so malformed JSON retains its source context. + disableOpenCodeHookPluginRegistration(text, operation.sourceRelativePath || operation.destinationPath); + const config = JSON.parse(text); + return !Array.isArray(config.plugin) || !config.plugin.includes('./plugins'); +} + +function assertOpenCodeHookDeactivationReady(plan, options = {}) { + if (!shouldDisableOpenCodeHooks(plan)) { + return new Map(); + } + assertSafeInstallOperation(plan, { destinationPath: plan.installStatePath }); + const previousState = readPreviousInstallState(plan); + if (previousState && ( + previousState.target.id !== plan.adapter.id + || comparablePath(previousState.target.root) !== comparablePath(plan.targetRoot) + || comparablePath(previousState.target.installStatePath) !== comparablePath(plan.installStatePath) + )) { + throw new Error('Refusing OpenCode hook deactivation: install-state target mismatch.'); + } + const previous = new Map(((previousState && previousState.operations) || []) + .filter(operation => operation.ownership === 'managed' && operation.destinationPath) + .map(operation => [comparablePath(operation.destinationPath), operation])); + 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()])) { + const kind = getOpenCodeActivationKind(plan, operation); + const expectedTransform = kind === 'plugin' + ? 'opencode-disable-plugin-entrypoint' : 'opencode-disable-ecc-hooks'; + const replacement = desired.get(key); + // Validate planned activation even when its destination does not yet exist. + // Recorded operations may name an unrelated source or use render-template. + if (replacement && (replacement.kind !== 'copy-file' + || replacement.contentTransform !== expectedTransform)) { + throw new Error(`Refusing OpenCode hook deactivation: unsupported activation operation at ${operation.destinationPath}`); + } + const content = readInstalledFileNoFollow(plan, operation); + if (content === null && fs.existsSync(operation.destinationPath)) { + throw new Error(`Refusing OpenCode hook deactivation: non-file activation at ${operation.destinationPath}`); + } + const digest = content === null ? null : crypto.createHash('sha256').update(content).digest('hex'); + snapshot.set(key, digest); + if (content === null) continue; + const inactive = activationIsInactive(kind, operation, content); + if (options.requireInactive) { + if (!inactive) { + throw new Error(`OpenCode hook activation remains active at ${operation.destinationPath}`); + } + continue; + } + if (kind === 'plugin' && inactive) continue; + const recorded = previous.get(key); + if (!replacement || replacement.kind !== 'copy-file' + || replacement.contentTransform !== expectedTransform + || !recorded || recorded.contentSha256 !== digest) { + throw new Error(`Refusing OpenCode hook deactivation: user-owned, modified, unverifiable or stale activation at ${operation.destinationPath}`); + } + } + return snapshot; +} + +function assertOpenCodeActivationUnchanged(plan, operation, snapshot) { + const key = comparablePath(operation.destinationPath); + if (!snapshot.has(key)) return; + const content = readInstalledFileNoFollow(plan, operation); + const digest = content === null ? null : crypto.createHash('sha256').update(content).digest('hex'); + if (digest !== snapshot.get(key)) { + throw new Error(`Refusing OpenCode hook deactivation: activation changed after preflight at ${operation.destinationPath}`); + } +} + +function getOpenCodeActivationWriteOptions(operation, snapshot) { + const key = comparablePath(operation.destinationPath); + if (!snapshot.has(key)) return {}; + const digest = snapshot.get(key); + return { expectedContent: Object.freeze(digest === null + ? { kind: 'absent' } : { kind: 'sha256', digest }) }; +} + +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; + } + } + return roots; +} + function findPreviousManagedHooks(previousState, plan, operation) { if ( !previousState @@ -347,6 +595,27 @@ function preflightClaudeSettingsOperations(plan) { } function prepareHookConsentMigration(plan, migration) { + if (shouldDisableOpenCodeHooks(plan) && migration.requiresBridgeState) { + const previousState = readPreviousInstallState(plan); + if (previousState) { + const previousConsent = getRecordedHookConsent(previousState); + return { + ...migration, + // A checkpoint is not a completed consent transition. On failure, + // retain the previous decision until every activation is inactive. + bridgeState: { + ...migration.bridgeState, + request: { ...migration.bridgeState.request, hookConsent: previousConsent }, + resolution: { + ...migration.bridgeState.resolution, + selectedModules: previousConsent === 'enabled' + ? [...new Set([...migration.bridgeState.resolution.selectedModules, 'hooks-runtime'])] + : migration.bridgeState.resolution.selectedModules, + }, + }, + }; + } + } if (plan.hookConsent !== 'declined') { return migration; } @@ -400,6 +669,7 @@ function prepareHookConsentMigration(plan, migration) { } function previewInstallPlan(plan) { + assertOpenCodeHookDeactivationReady(plan); const migration = prepareHookConsentMigration( plan, prepareUserOwnedFileGuard(plan, prepareClaudeSkillMigration(plan)) @@ -429,6 +699,14 @@ function previewInstallPlan(plan) { function applyInstallPlan(plan, dependencies = {}) { assertHookConsentReady(plan); + if (plan.adapter?.target === 'opencode') { + assertSafeInstallOperation(plan, { destinationPath: plan.installStatePath }); + return withOpenCodeInstallLocks( + getOpenCodeInstallRoots(plan), + () => applyInstallPlanLocked(plan, dependencies, false), + dependencies.opencodeLease + ); + } const isClaudeManualTarget = plan.adapter && (plan.adapter.target === 'claude' || plan.adapter.target === 'claude-project'); const settingsPathToLock = isClaudeManualTarget @@ -453,6 +731,7 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals if (typeof beforeInstallStateRead === 'function') { beforeInstallStateRead({ plan }); } + const activationSnapshot = assertOpenCodeHookDeactivationReady(plan); const migration = prepareExcludedPathsReconciliation( plan, prepareHookConsentMigration( @@ -499,6 +778,7 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals beforeOperationWrite({ plan: appliedPlan, operation }); } assertNoNewUserOwnedFile(migration, operation, appliedPlan); + assertOpenCodeActivationUnchanged(appliedPlan, operation, activationSnapshot); if ( operation.kind === 'update-claude-settings' @@ -585,7 +865,20 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals index: linkIndex, }) : transformed; - fs.writeFileSync(operation.destinationPath, installedContent, 'utf8'); + const writeOptions = getOpenCodeActivationWriteOptions(operation, activationSnapshot); + if (writeOptions.expectedContent) { + writeFileNoFollow(operation.destinationPath, installedContent, { + ...writeOptions, + action: 'install OpenCode activation', + validateDestination(destinationPath) { + assertSafeInstallOperation(appliedPlan, { destinationPath }); + assertSafeClaudeSkillOperation(appliedPlan, { destinationPath }); + return destinationPath; + }, + }); + } else { + fs.writeFileSync(operation.destinationPath, installedContent, 'utf8'); + } writtenDestinations.add(operation.destinationPath); continue; } @@ -605,6 +898,7 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals ); } + assertOpenCodeHookDeactivationReady(appliedPlan, { requireInactive: true }); finalState = stateWithContentDigests(migration.finalState, appliedPlan); if (typeof beforeInstallStateWrite === 'function') { beforeInstallStateWrite({ plan: appliedPlan, state: finalState }); @@ -705,6 +999,12 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals module.exports = { applyInstallPlan, + assertOpenCodeActivationUnchanged, + assertOpenCodeHookDeactivationReady, + getOpenCodeActivationKind, + getOpenCodeActivationWriteOptions, + getOpenCodeInstallRoots, assertSafeInstallOperation, + prepareHookConsentMigration, previewInstallPlan, }; diff --git a/scripts/lib/install/claude-settings-lock.js b/scripts/lib/install/claude-settings-lock.js index 75aa6a4f0..164e887ec 100644 --- a/scripts/lib/install/claude-settings-lock.js +++ b/scripts/lib/install/claude-settings-lock.js @@ -5,6 +5,7 @@ const fs = require('fs'); const path = require('path'); const INVALID_LOCK_STALE_MS = 5 * 60 * 1000; +const acquiredLockIdentities = new WeakMap(); function sameFileIdentity(left, right) { if (left.ino !== right.ino) { @@ -19,7 +20,7 @@ function sameFileIdentity(left, right) { return left.dev === right.dev; } -function createSettingsLock(lockPath) { +function createSettingsLock(lockPath, label = 'Claude settings') { const tempPath = `${lockPath}.create-${process.pid}-${crypto.randomBytes(8).toString('hex')}`; let descriptor; let ownedStats; @@ -43,18 +44,28 @@ function createSettingsLock(lockPath) { fs.rmSync(tempPath, { force: true }); let released = false; - return () => { + const release = () => { if (released) return; const quarantinePath = `${lockPath}.release-${process.pid}-${crypto.randomBytes(8).toString('hex')}`; fs.renameSync(lockPath, quarantinePath); const quarantinedStats = fs.lstatSync(quarantinePath, { bigint: true }); if (!sameFileIdentity(quarantinedStats, ownedStats)) { if (!fs.existsSync(lockPath)) fs.renameSync(quarantinePath, lockPath); - throw new Error(`Refusing to release a changed Claude settings lock: ${lockPath}`); + throw new Error(`Refusing to release a changed ${label} lock: ${lockPath}`); } released = true; fs.rmSync(quarantinePath, { force: true }); }; + // Retain the identity observed through the creation descriptor. A pathname + // sampled after publication may already refer to a replacement lock. + acquiredLockIdentities.set(release, Object.freeze({ dev: ownedStats.dev, ino: ownedStats.ino })); + return release; +} + +function getSettingsLockIdentity(release) { + const identity = acquiredLockIdentities.get(release); + if (!identity) throw new Error('No acquired settings lock identity for this release function.'); + return identity; } function inspectSettingsLock(lockPath) { @@ -91,7 +102,7 @@ function processIsAlive(pid) { } } -function recoverSettingsLock(lockPath) { +function recoverSettingsLock(lockPath, label = 'Claude settings') { const recoveryPath = `${lockPath}.recover`; try { fs.mkdirSync(recoveryPath, { mode: 0o700 }); @@ -106,7 +117,7 @@ function recoverSettingsLock(lockPath) { try { inspected = inspectSettingsLock(lockPath); } catch (error) { - if (error && error.code === 'ENOENT') return createSettingsLock(lockPath); + if (error && error.code === 'ENOENT') return createSettingsLock(lockPath, label); throw error; } const validOwner = Number.isSafeInteger(inspected.metadata && inspected.metadata.pid) @@ -123,27 +134,27 @@ function recoverSettingsLock(lockPath) { return null; } fs.rmSync(quarantinePath, { force: true }); - return createSettingsLock(lockPath); + return createSettingsLock(lockPath, label); } finally { fs.rmSync(recoveryPath, { recursive: true, force: true }); fs.rmSync(quarantinePath, { force: true }); } } -function acquireSettingsLock(settingsPath) { +function acquireSettingsLock(settingsPath, { label = 'Claude settings' } = {}) { const lockPath = `${settingsPath}.ecc.lock`; fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); try { - return createSettingsLock(lockPath); + return createSettingsLock(lockPath, label); } catch (error) { if (!error || error.code !== 'EEXIST') { throw error; } } - const recovered = recoverSettingsLock(lockPath); + const recovered = recoverSettingsLock(lockPath, label); if (recovered) return recovered; throw new Error( - `Another ECC process is updating Claude settings: ${settingsPath}. ` + `Another ECC process is updating ${label}: ${settingsPath}. ` + `If no ECC process is active, inspect and remove ${lockPath}.` ); } @@ -175,6 +186,7 @@ function runWithSettingsLock(settingsPath, callback) { module.exports = { acquireSettingsLock, + getSettingsLockIdentity, runWithSettingsLock, sameFileIdentity, }; diff --git a/scripts/lib/install/guarded-write.js b/scripts/lib/install/guarded-write.js new file mode 100644 index 000000000..ac654f6f9 --- /dev/null +++ b/scripts/lib/install/guarded-write.js @@ -0,0 +1,115 @@ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const { sameFileIdentity } = require('./claude-settings-lock'); + +function snapshotExpectedContent(expectedContent) { + if (expectedContent === undefined) return undefined; + if (expectedContent && expectedContent.kind === 'absent') { + return Object.freeze({ kind: 'absent' }); + } + if (expectedContent && expectedContent.kind === 'sha256' + && typeof expectedContent.digest === 'string' && /^[a-f0-9]{64}$/i.test(expectedContent.digest)) { + return Object.freeze({ kind: 'sha256', digest: expectedContent.digest.toLowerCase() }); + } + throw new TypeError('Invalid expectedContent for guarded write.'); +} + +function changedDestination(action, filePath, expectedContent, cause) { + const message = expectedContent + ? `Refusing OpenCode hook deactivation: activation changed after preflight at ${filePath}` + : `Refusing to ${action}: managed destination changed during the write.`; + const error = new Error(message); + if (cause) { + error.cause = cause; + if (cause.code) error.code = cause.code; + } + return error; +} + +function writeFileNoFollow(filePath, content, { + mode, + action = 'write managed file', + validateDestination, + expectedContent: requestedContent, +} = {}) { + if (typeof validateDestination !== 'function') { + throw new TypeError('writeFileNoFollow requires validateDestination.'); + } + const destination = path.resolve(filePath); + const expectedContent = snapshotExpectedContent(requestedContent); + const bytes = typeof content === 'string' ? Buffer.from(content) : content; + if (!Buffer.isBuffer(bytes)) throw new TypeError('Managed file content must be a string or Buffer.'); + const changed = cause => changedDestination(action, destination, expectedContent, cause); + function validatePath() { + const validated = validateDestination(destination); + if (typeof validated !== 'string' || path.resolve(validated) !== destination) throw changed(); + } + function parentStats() { + const stats = fs.lstatSync(path.dirname(destination), { bigint: true }); + if (!stats.isDirectory() || stats.isSymbolicLink()) throw changed(); + return stats; + } + validatePath(); + const parent = parentStats(); + const flags = (expectedContent ? fs.constants.O_RDWR : fs.constants.O_WRONLY) + | (!expectedContent || expectedContent.kind === 'absent' ? fs.constants.O_CREAT : 0) + | (expectedContent && expectedContent.kind === 'absent' ? fs.constants.O_EXCL : 0) + | (fs.constants.O_NOFOLLOW || 0); + let descriptor; + try { + descriptor = fs.openSync(destination, flags, mode); + } catch (error) { + if (expectedContent) throw changed(error); + throw error; + } + function assertPinned() { + validatePath(); + const descriptorStat = fs.fstatSync(descriptor, { bigint: true }); + const liveStat = fs.lstatSync(destination, { bigint: true }); + if (!sameFileIdentity(parent, parentStats()) || !descriptorStat.isFile() + || !liveStat.isFile() || liveStat.isSymbolicLink() || !sameFileIdentity(descriptorStat, liveStat)) { + throw changed(); + } + return descriptorStat; + } + let primaryError; + try { + const before = assertPinned(); + if (expectedContent && expectedContent.kind === 'absent' && before.size !== 0n) throw changed(); + if (expectedContent && expectedContent.kind === 'sha256') { + const digest = crypto.createHash('sha256').update(fs.readFileSync(descriptor)).digest('hex'); + const after = assertPinned(); + if (!sameFileIdentity(before, after) || before.size !== after.size + || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs + || digest !== expectedContent.digest) throw changed(); + } + // Observed changes are rejected before truncation. This is not a CAS against + // arbitrary editors: callers coordinate participating writers with a lease. + fs.ftruncateSync(descriptor, 0); + for (let offset = 0; offset < bytes.length;) { + const written = fs.writeSync(descriptor, bytes, offset, bytes.length - offset, offset); + if (!Number.isInteger(written) || written <= 0 || written > bytes.length - offset) { + throw new Error(`Refusing to ${action}: file write made no valid progress.`); + } + offset += written; + } + if (mode !== undefined) fs.fchmodSync(descriptor, mode); + } catch (error) { + primaryError = error; + } finally { + try { + fs.closeSync(descriptor); + } catch (error) { + if (primaryError) primaryError.closeError = error; + else primaryError = error; + } + } + // An exclusive creation that later fails is not unlinked: the pathname may + // already belong to another writer. Keep the refusal visible to the caller. + if (primaryError) throw primaryError; +} + +module.exports = { writeFileNoFollow }; diff --git a/scripts/lib/install/hook-consent.js b/scripts/lib/install/hook-consent.js index 883c121d7..e29c01bf5 100644 --- a/scripts/lib/install/hook-consent.js +++ b/scripts/lib/install/hook-consent.js @@ -38,11 +38,54 @@ 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'; +const OPENCODE_DISABLE_PLUGIN_TRANSFORM = 'opencode-disable-plugin-entrypoint'; 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 isOpenCodePluginEntrypoint(operation = {}) { + return /^\.opencode\/(?:dist\/)?plugins\/[^/]+\.(?:[cm]?js|ts)$/.test( + normalizeOperationPath(operation.sourceRelativePath) + ); +} + +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'; +} + +function isOpenCodeHookActivationOperation(operation = {}) { + return normalizeOperationPath(operation.sourceRelativePath) === '.opencode/opencode.json' + || isOpenCodePluginEntrypoint(operation); +} + function isHookRuntimeOperation(operation = {}) { if ( operation.kind === 'update-claude-settings' @@ -51,6 +94,15 @@ function isHookRuntimeOperation(operation = {}) { return true; } + if (isOpenCodeHookActivationOperation(operation)) { + return !( + operation.kind === 'copy-file' + && operation.contentTransform === (isOpenCodePluginEntrypoint(operation) + ? OPENCODE_DISABLE_PLUGIN_TRANSFORM + : OPENCODE_DISABLE_ECC_HOOKS_TRANSFORM) + ); + } + const source = normalizeOperationPath(operation.sourceRelativePath); const destination = normalizeOperationPath(operation.destinationPath); return ( @@ -93,6 +145,57 @@ 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: isOpenCodePluginEntrypoint(operation) + ? OPENCODE_DISABLE_PLUGIN_TRANSFORM + : 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 shouldDisableOpenCodeHooks(plan = {}) { + return plan.target === 'opencode' + && (plan.hookConsent === 'declined' || !planSelectsHookRuntime(plan)); +} + function setStatePreviewHookConsent(statePreview, hookConsent) { if (!statePreview || !statePreview.request) { return statePreview; @@ -123,7 +226,11 @@ function getRecordedHookConsent(state = {}) { return 'enabled'; } - if (planMaterializesHookRuntime(state)) { + // Older OpenCode installs copied activation files without asking for consent. + // Their presence cannot establish that the user opted in to automatic hooks. + if ((Array.isArray(state.operations) ? state.operations : []).some(operation => ( + !isOpenCodeHookActivationOperation(operation) && isHookRuntimeOperation(operation) + ))) { return 'enabled'; } @@ -134,11 +241,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 ? { @@ -167,10 +280,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), }; } @@ -193,10 +307,16 @@ function assertHookConsentReady(plan = {}) { module.exports = { HOOK_CAPABILITY_GROUPS, assertHookConsentReady, + disableUnselectedOpenCodeHooks, + disableOpenCodeHookPluginRegistration, + getDisabledOpenCodePluginContent, formatHookCapabilityDisclosure, getRecordedHookConsent, isHookRuntimeOperation, + isOpenCodeHookActivationOperation, + isOpenCodePluginEntrypoint, planMaterializesHookRuntime, resolveHookConsentFlags, + shouldDisableOpenCodeHooks, withHookConsent, }; diff --git a/scripts/lib/install/opencode-install-lock.js b/scripts/lib/install/opencode-install-lock.js new file mode 100644 index 000000000..4b690d3d3 --- /dev/null +++ b/scripts/lib/install/opencode-install-lock.js @@ -0,0 +1,145 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { realpathNearestExisting } = require('../path-safety'); +const { acquireSettingsLock, getSettingsLockIdentity, sameFileIdentity } = require('./claude-settings-lock'); + +const activeLeases = new WeakMap(); +const STATE_FILENAME = 'ecc-install-state.json'; +const comparablePath = value => process.platform === 'win32' ? value.toLowerCase() : value; + +function inspectEntry(filePath) { + try { return fs.lstatSync(filePath, { bigint: true }); } + catch (error) { if (error.code === 'ENOENT') return null; throw error; } +} + +function canonicalRoots(roots) { + if (!Array.isArray(roots)) throw new TypeError('OpenCode install roots must be an array.'); + const deduplicated = new Map(); + for (const root of roots) { + if (typeof root !== 'string' || !path.isAbsolute(root)) { + throw new TypeError('OpenCode install root must be an absolute trusted path.'); + } + const stats = inspectEntry(root); + if (stats && (stats.isSymbolicLink() || !stats.isDirectory())) { + throw new Error(`Refusing OpenCode install lock through a non-directory or symlink root: ${root}`); + } + const canonical = realpathNearestExisting(root); + deduplicated.set(comparablePath(canonical), canonical); + } + return [...deduplicated.values()].sort((left, right) => { + const a = comparablePath(left); + const b = comparablePath(right); + return a < b ? -1 : a > b ? 1 : 0; + }); +} + +function assertLockLocation(root, expectedRoot) { + const stats = inspectEntry(root); + if (!stats || !stats.isDirectory() || stats.isSymbolicLink() + || comparablePath(fs.realpathSync(root)) !== comparablePath(root) + || (expectedRoot && !sameFileIdentity(stats, expectedRoot))) { + throw new Error(`Refusing changed OpenCode install lock root: ${root}`); + } + const lock = inspectEntry(path.join(root, `${STATE_FILENAME}.ecc.lock`)); + if (lock && (!lock.isFile() || lock.isSymbolicLink())) { + throw new Error(`Refusing non-file or symlink OpenCode install lock: ${root}`); + } + return { root: stats, lock }; +} + +function assertOwnedLock(owned) { + const live = assertLockLocation(owned.root, owned.rootStats); + if (!live.lock || !sameFileIdentity(live.lock, owned.lockStats)) { + throw new Error(`Refusing changed OpenCode install lock: ${owned.root}`); + } +} + +function annotateCleanupFailure(primary, property, value) { + try { + // Own data properties avoid invoking caller getters/setters. Frozen values, + // primitives and rejecting proxy traps simply retain no extra diagnostic. + Object.defineProperty(primary, property, { value, configurable: true, enumerable: true, writable: true }); + } catch { + // Diagnostics must never replace the exact primary thrown value. + } +} + +function releaseOwned(ownedLocks) { + const failures = []; + for (const owned of [...ownedLocks].reverse()) { + try { + assertOwnedLock(owned); + owned.release(); + } catch (error) { + failures.push(error); + } + } + // Finish every safe release before touching any caller-owned error object. + if (failures.length > 0) { + if (failures.length > 1) annotateCleanupFailure(failures[0], 'releaseErrors', failures.slice(1)); + throw failures[0]; + } +} + +function acquireOpenCodeInstallLocks(roots, existingLease) { + const orderedRoots = canonicalRoots(roots); + if (existingLease !== undefined) { + const existing = existingLease && typeof existingLease === 'object' && activeLeases.get(existingLease); + if (!existing) throw new Error('Invalid or inactive OpenCode install lease.'); + const covered = new Set(existing.map(owned => comparablePath(owned.root))); + if (orderedRoots.some(root => !covered.has(comparablePath(root)))) { + throw new Error('OpenCode install lease does not cover every required root.'); + } + existing.forEach(assertOwnedLock); + return { lease: existingLease, release() {} }; + } + const ownedLocks = []; + try { + for (const root of orderedRoots) { + fs.mkdirSync(root, { recursive: true }); + const before = assertLockLocation(root); + const release = acquireSettingsLock(path.join(root, STATE_FILENAME), { label: 'OpenCode installation' }); + // Bind descriptor-derived ownership before any path revalidation. Never + // adopt the identity of a replacement published at the lock pathname. + const owned = { root, rootStats: before.root, release, + lockStats: getSettingsLockIdentity(release) }; + ownedLocks.push(owned); + assertOwnedLock(owned); + } + } catch (error) { + try { releaseOwned(ownedLocks); } + catch (releaseError) { annotateCleanupFailure(error, 'releaseError', releaseError); } + throw error; + } + const lease = Object.freeze({}); + activeLeases.set(lease, ownedLocks); + return { + lease, + release() { + if (!activeLeases.has(lease)) return; + activeLeases.delete(lease); + releaseOwned(ownedLocks); + }, + }; +} + +function withOpenCodeInstallLocks(roots, callback, existingLease) { + if (typeof callback !== 'function') throw new TypeError('OpenCode install lock callback must be a function.'); + const holder = acquireOpenCodeInstallLocks(roots, existingLease); + let didThrow = false; + let primaryError; + let result; + try { result = callback(holder.lease); } + catch (error) { didThrow = true; primaryError = error; } + try { holder.release(); } + catch (error) { + if (didThrow) annotateCleanupFailure(primaryError, 'releaseError', error); + else { didThrow = true; primaryError = error; } + } + if (didThrow) throw primaryError; + return result; +} + +module.exports = { acquireOpenCodeInstallLocks, withOpenCodeInstallLocks }; diff --git a/scripts/lib/install/opencode-legacy-migration.js b/scripts/lib/install/opencode-legacy-migration.js index baf3472f1..485130eb4 100644 --- a/scripts/lib/install/opencode-legacy-migration.js +++ b/scripts/lib/install/opencode-legacy-migration.js @@ -393,6 +393,7 @@ function cleanupLegacyOpencodeInstall(plan) { module.exports = { cleanupLegacyOpencodeInstall, getLegacyOpencodeLocation, + getLegacyLocationForPlan, inspectLegacyOpencodeState, removeVerifiedLegacyFile, }; diff --git a/scripts/lib/install/plan.js b/scripts/lib/install/plan.js index 1400557c7..afcb4f06b 100644 --- a/scripts/lib/install/plan.js +++ b/scripts/lib/install/plan.js @@ -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 { readHooksConfig } = require('../hooks-config'); const { materializeManagedHooks, @@ -315,7 +316,7 @@ function createManifestInstallPlan(options = {}) { source }); - return { + return disableUnselectedOpenCodeHooks({ mode: options.mode || 'manifest', sourceRoot, target, @@ -341,7 +342,7 @@ function createManifestInstallPlan(options = {}) { excludedModuleIds: plan.excludedModuleIds, operations, statePreview - }; + }); } module.exports = { diff --git a/tests/lib/guarded-write.test.js b/tests/lib/guarded-write.test.js new file mode 100644 index 000000000..44e537e96 --- /dev/null +++ b/tests/lib/guarded-write.test.js @@ -0,0 +1,283 @@ +/** Focused descriptor-bound writes; every filesystem fixture is private. */ +'use strict'; +const assert = require('assert'); +const crypto = require('crypto'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { writeFileNoFollow } = require('../../scripts/lib/install/guarded-write'); +const digest = value => crypto.createHash('sha256').update(value).digest('hex'); +let passed = 0; +let failed = 0; +function test(name, callback) { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-guarded-'))); + const file = path.join(root, 'entry.js'); + const options = { action: 'replace activation', validateDestination: value => value }; + try { callback({ root, file, options }); passed++; console.log(` PASS ${name}`); } + catch (error) { failed++; console.error(` FAIL ${name}: ${error.stack}`); } + finally { fs.rmSync(root, { recursive: true, force: true }); } +} +function replaceMethod(name, replacement, callback) { + const original = fs[name]; + fs[name] = replacement(original); + try { callback(); } finally { fs[name] = original; } +} +function existing(options, content = 'old activation bytes') { + return { ...options, expectedContent: Object.freeze({ kind: 'sha256', digest: digest(content) }) }; +} +test('hashing then writing shorter bytes starts at zero and preserves the requested mode', ({ file, options }) => { + fs.writeFileSync(file, 'old activation bytes'); + writeFileNoFollow(file, 'off\n', { ...existing(options), mode: 0o600 }); + assert.deepStrictEqual(fs.readFileSync(file), Buffer.from('off\n')); + assert.strictEqual(fs.statSync(file).size, 4); + if (process.platform !== 'win32') assert.strictEqual(fs.statSync(file).mode & 0o777, 0o600); +}); +test('short writes are completed at explicit positions', ({ file, options }) => { + const positions = []; + replaceMethod('writeSync', original => (fd, buffer, offset, length, position) => { + positions.push(position); + return original(fd, buffer, offset, Math.min(length, 2), position); + }, () => writeFileNoFollow(file, 'abcdef', options)); + assert.deepStrictEqual(positions, [0, 2, 4]); + assert.strictEqual(fs.readFileSync(file, 'utf8'), 'abcdef'); +}); +test('expected absence creates a new exact file', ({ file, options }) => { + writeFileNoFollow(file, Buffer.from('inert'), { ...options, expectedContent: { kind: 'absent' } }); + assert.strictEqual(fs.readFileSync(file, 'utf8'), 'inert'); +}); +test('a competing file before exclusive open is preserved', ({ file, options }) => { + replaceMethod('openSync', original => (...args) => { + if (args[0] === file && typeof args[1] === 'number') fs.writeFileSync(file, 'user bytes'); + return original(...args); + }, () => assert.throws(() => writeFileNoFollow(file, 'off', { + ...options, expectedContent: { kind: 'absent' }, + }), /changed after preflight/)); + assert.strictEqual(fs.readFileSync(file, 'utf8'), 'user bytes'); +}); +test('existing content removed before open is never recreated', ({ file, options }) => { + fs.writeFileSync(file, 'old activation bytes'); + replaceMethod('openSync', original => (...args) => { + if (args[0] === file) fs.unlinkSync(file); + return original(...args); + }, () => assert.throws(() => writeFileNoFollow(file, 'off', existing(options)), /changed after preflight/)); + assert.strictEqual(fs.existsSync(file), false); +}); +test('same-inode edit at the writable-open boundary refuses before truncate and closes once', ({ file, options }) => { + fs.writeFileSync(file, 'old activation bytes'); + let descriptor; + let closes = 0; + let truncates = 0; + const originalOpen = fs.openSync; + const originalClose = fs.closeSync; + const originalTruncate = fs.ftruncateSync; + fs.openSync = (...args) => { + const fd = originalOpen(...args); + if (args[0] === file && typeof args[1] === 'number') { + descriptor = fd; + fs.writeFileSync(file, 'user edited activation'); + } + return fd; + }; + fs.closeSync = fd => { if (fd === descriptor) closes++; return originalClose(fd); }; + fs.ftruncateSync = (...args) => { truncates++; return originalTruncate(...args); }; + try { assert.throws(() => writeFileNoFollow(file, 'off', existing(options)), /changed after preflight/); } + finally { fs.openSync = originalOpen; fs.closeSync = originalClose; fs.ftruncateSync = originalTruncate; } + assert.strictEqual(closes, 1); + assert.strictEqual(truncates, 0); + assert.strictEqual(fs.readFileSync(file, 'utf8'), 'user edited activation'); +}); +test('a descriptor read concurrent edit is observed before truncation', ({ file, options }) => { + fs.writeFileSync(file, 'old activation bytes'); + replaceMethod('readFileSync', original => (...args) => { + const result = original(...args); + if (typeof args[0] === 'number') fs.writeFileSync(file, 'raced during hash'); + return result; + }, () => assert.throws(() => writeFileNoFollow(file, 'off', existing(options)), /changed after preflight/)); + assert.strictEqual(fs.readFileSync(file, 'utf8'), 'raced during hash'); +}); +test('a pathname replacement after opening preserves both original and replacement', ({ file, options }) => { + fs.writeFileSync(file, 'old activation bytes'); + replaceMethod('openSync', original => (...args) => { + const fd = original(...args); + if (args[0] === file && typeof args[1] === 'number') { + fs.renameSync(file, `${file}.old`); + fs.writeFileSync(file, 'replacement'); + } + return fd; + }, () => assert.throws(() => writeFileNoFollow(file, 'off', existing(options)), /changed/)); + assert.strictEqual(fs.readFileSync(file, 'utf8'), 'replacement'); + assert.strictEqual(fs.readFileSync(`${file}.old`, 'utf8'), 'old activation bytes'); +}); +test('a directory destination is refused without writes', ({ file, options }) => { + fs.mkdirSync(file); + assert.throws(() => writeFileNoFollow(file, 'off', options)); + assert.ok(fs.statSync(file).isDirectory()); +}); +test('a parent replacement before native open is refused even when the file identity is unchanged', ({ root, options }) => { + const parent = path.join(root, 'plugins'); + fs.mkdirSync(parent); + const file = path.join(parent, 'entry.js'); + fs.writeFileSync(file, 'old activation bytes'); + const fileIdentity = fs.statSync(file, { bigint: true }); + const originalOpen = fs.openSync; + const originalClose = fs.closeSync; + const originalTruncate = fs.ftruncateSync; + let descriptor; + let swaps = 0; + let closes = 0; + let truncates = 0; + fs.openSync = (...args) => { + if (args[0] === file && typeof args[1] === 'number') { + // The writer has pinned the parent, but has not opened the file yet. + // Moving an open file's parent is not portable to Windows. Keep the + // same file inode and bytes so only the parent identity rejects this. + fs.renameSync(parent, `${parent}.old`); + fs.mkdirSync(parent); + fs.renameSync(path.join(`${parent}.old`, 'entry.js'), file); + swaps++; + descriptor = originalOpen(...args); + return descriptor; + } + return originalOpen(...args); + }; + fs.closeSync = fd => { if (fd === descriptor) closes++; return originalClose(fd); }; + fs.ftruncateSync = (...args) => { truncates++; return originalTruncate(...args); }; + try { + assert.throws(() => writeFileNoFollow(file, 'off', existing(options)), error => { + assert.match(error.message, /changed after preflight/); + assert.strictEqual(error.cause, undefined, 'the parent guard, not an open error, refuses'); + return true; + }); + } finally { + fs.openSync = originalOpen; + fs.closeSync = originalClose; + fs.ftruncateSync = originalTruncate; + } + assert.strictEqual(swaps, 1); + 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'); + assert.ok(fs.statSync(`${parent}.old`).isDirectory()); +}); +test('a denied native open preserves its cause without closing an unallocated descriptor', ({ file, options }) => { + fs.writeFileSync(file, 'old activation bytes'); + const denied = Object.assign(new Error('fixture open denied'), { code: 'EPERM' }); + const originalClose = fs.closeSync; + const originalTruncate = fs.ftruncateSync; + let closes = 0; + let truncates = 0; + fs.closeSync = fd => { closes++; return originalClose(fd); }; + fs.ftruncateSync = (...args) => { truncates++; return originalTruncate(...args); }; + try { + replaceMethod('openSync', original => (...args) => { + if (args[0] === file && typeof args[1] === 'number') throw denied; + return original(...args); + }, () => assert.throws(() => writeFileNoFollow(file, 'off', existing(options)), error => { + assert.strictEqual(error.cause, denied); + assert.strictEqual(error.code, 'EPERM'); + return true; + })); + } finally { + fs.closeSync = originalClose; + fs.ftruncateSync = originalTruncate; + } + assert.strictEqual(closes, 0); + assert.strictEqual(truncates, 0); + assert.strictEqual(fs.readFileSync(file, 'utf8'), 'old activation bytes'); +}); +test('destination validator is mandatory and must return the same path', ({ file, options }) => { + assert.throws(() => writeFileNoFollow(file, 'off', {}), /validateDestination/); + assert.throws(() => writeFileNoFollow(file, 'off', { ...options, validateDestination: () => `${file}.other` }), /changed/); + assert.strictEqual(fs.existsSync(file), false); +}); +test('destination validation runs again after the descriptor hash', ({ file, options }) => { + fs.writeFileSync(file, 'old activation bytes'); + let validations = 0; + assert.throws(() => writeFileNoFollow(file, 'off', { + ...existing(options), validateDestination: value => { + validations++; + if (validations === 3) throw new Error('containment changed'); + return value; + }, + }), /containment changed/); + assert.strictEqual(fs.readFileSync(file, 'utf8'), 'old activation bytes'); +}); +for (const method of ['readFileSync', 'ftruncateSync', 'writeSync', 'fchmodSync']) { + test(`${method} failure closes once and preserves the primary error if close also fails`, ({ file, options }) => { + fs.writeFileSync(file, 'old activation bytes'); + const primary = new Error(`${method} primary`); + const secondary = new Error('close secondary'); + const original = fs[method]; + const close = fs.closeSync; + let closes = 0; + fs[method] = (...args) => { if (typeof args[0] === 'number') throw primary; return original(...args); }; + fs.closeSync = fd => { closes++; close(fd); throw secondary; }; + try { + assert.throws(() => writeFileNoFollow(file, 'off', { ...existing(options), mode: 0o600 }), error => { + assert.strictEqual(error, primary); + assert.strictEqual(error.closeError, secondary); + return true; + }); + } finally { fs[method] = original; fs.closeSync = close; } + assert.strictEqual(closes, 1); + }); +} +test('close failure after a successful write is reported', ({ file, options }) => { + replaceMethod('closeSync', original => fd => { original(fd); throw new Error('close failed'); }, () => { + assert.throws(() => writeFileNoFollow(file, 'off', options), /close failed/); + }); + assert.strictEqual(fs.readFileSync(file, 'utf8'), 'off'); +}); +test('zero-progress writes refuse instead of spinning', ({ file, options }) => { + replaceMethod('writeSync', () => () => 0, () => assert.throws(() => writeFileNoFollow(file, 'off', options), /progress/)); +}); +test('malformed expected-content guards fail before opening a destination', ({ file, options }) => { + for (const expectedContent of [null, true, { kind: 'sha256', digest: 'bad' }, { kind: 'missing' }]) { + assert.throws(() => writeFileNoFollow(file, 'off', { ...options, expectedContent }), /expectedContent/); + } + assert.strictEqual(fs.existsSync(file), false); +}); +test('a new exclusive file changed through the owned descriptor is preserved', ({ file, options }) => { + replaceMethod('openSync', original => (...args) => { + const fd = original(...args); + if (args[0] === file && typeof args[1] === 'number') fs.writeSync(fd, Buffer.from('raced creation'), 0, 14, 0); + return fd; + }, () => assert.throws(() => writeFileNoFollow(file, 'off', { + ...options, expectedContent: { kind: 'absent' }, + }), /changed after preflight/)); + assert.strictEqual(fs.readFileSync(file, 'utf8'), 'raced creation'); +}); +test('the final pathname must remain a regular non-symlink even without O_NOFOLLOW support', ({ file, options }) => { + fs.writeFileSync(file, 'old activation bytes'); + replaceMethod('lstatSync', original => (...args) => { + const stats = original(...args); + if (args[0] === file) { + const simulatedSymlink = Object.create(stats); + simulatedSymlink.isSymbolicLink = () => true; + return simulatedSymlink; + } + return stats; + }, () => assert.throws(() => writeFileNoFollow(file, 'off', existing(options)), /changed/)); + assert.strictEqual(fs.readFileSync(file, 'utf8'), 'old activation bytes'); +}); +test('the immutable preflight digest cannot be changed by a validator callback', ({ file, options }) => { + fs.writeFileSync(file, 'user bytes'); + const expectedContent = { kind: 'sha256', digest: digest('old bytes') }; + assert.throws(() => writeFileNoFollow(file, 'off', { + ...options, expectedContent, validateDestination: value => { + expectedContent.digest = digest('user bytes'); + return value; + }, + }), /changed after preflight/); + assert.strictEqual(fs.readFileSync(file, 'utf8'), 'user bytes'); +}); +test('unexpected input fails without creating a file', ({ file, options }) => { + assert.throws(() => writeFileNoFollow(file, {}, options), /content/); + assert.strictEqual(fs.existsSync(file), false); +}); +console.log(`Results: Passed: ${passed}, Failed: ${failed}`); +process.exitCode = failed ? 1 : 0; diff --git a/tests/lib/hook-consent.test.js b/tests/lib/hook-consent.test.js index 2f44361e2..691dbceaa 100644 --- a/tests/lib/hook-consent.test.js +++ b/tests/lib/hook-consent.test.js @@ -7,7 +7,9 @@ const assert = require('assert'); const { HOOK_CAPABILITY_GROUPS, assertHookConsentReady, + disableOpenCodeHookPluginRegistration, formatHookCapabilityDisclosure, + getRecordedHookConsent, isHookRuntimeOperation, planMaterializesHookRuntime, resolveHookConsentFlags, @@ -98,8 +100,25 @@ function runTests() { sourceRelativePath: '.opencode/plugins/ecc-hooks.ts', destinationPath: '/root/.config/opencode/plugins/ecc-hooks.ts', }), - false + true ); + 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' }), @@ -107,6 +126,41 @@ function runTests() { ); })) passed++; else failed++; + if (test('OpenCode auto-discovered entrypoints require selected runtime and consent', () => { + const entrypoints = ['.opencode/plugins/ecc-hooks.ts', '.opencode/plugins/index.ts']; + const operations = entrypoints.map(sourceRelativePath => ({ + kind: 'copy-file', moduleId: 'platform-configs', sourceRelativePath, + })); + const base = { + target: 'opencode', operations, selectedModuleIds: ['platform-configs'], + statePreview: { operations, request: {}, resolution: { selectedModules: ['platform-configs'] } }, + }; + for (const decision of [null, 'enabled', 'declined']) { + const plan = withHookConsent(base, decision); + for (const operation of [...plan.operations, ...plan.statePreview.operations]) { + assert.strictEqual(operation.contentTransform, 'opencode-disable-plugin-entrypoint'); + assert.strictEqual(isHookRuntimeOperation(operation), false); + } + assert.doesNotThrow(() => assertHookConsentReady(plan)); + } + const selected = { ...base, selectedModuleIds: ['platform-configs', 'hooks-runtime'] }; + const pending = withHookConsent(selected, null); + assert.throws(() => assertHookConsentReady(pending), /automatic hook runtime/); + const enabled = withHookConsent(selected, 'enabled'); + assert.ok(enabled.operations.every(operation => operation.contentTransform === undefined)); + assert.doesNotThrow(() => assertHookConsentReady(enabled)); + const declined = withHookConsent(selected, 'declined'); + assert.strictEqual(declined.operations.length, 2); + assert.ok(declined.operations.every(operation => ( + operation.contentTransform === 'opencode-disable-plugin-entrypoint' + ))); + assert.ok(operations.every(operation => operation.contentTransform === undefined), 'Input plan stays unchanged'); + assert.strictEqual(isHookRuntimeOperation({ + kind: 'copy-file', moduleId: 'platform-configs', + sourceRelativePath: '.opencode/plugins/helpers/readme.md', + }), false); + })) passed++; else failed++; + if (test('detects hook materialization from plan operations only', () => { assert.strictEqual(planMaterializesHookRuntime(buildHookPlan()), true); assert.strictEqual(planMaterializesHookRuntime({ @@ -116,6 +170,38 @@ 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('historical OpenCode activation bytes alone do not imply hook consent', () => { + const state = { + request: {}, resolution: { selectedModules: ['platform-configs'] }, + operations: [ + { kind: 'copy-file', moduleId: 'platform-configs', sourceRelativePath: '.opencode/opencode.json' }, + { kind: 'copy-file', moduleId: 'platform-configs', sourceRelativePath: '.opencode/plugins/ecc-hooks.ts' }, + ], + }; + assert.strictEqual(getRecordedHookConsent(state), null); + assert.strictEqual(getRecordedHookConsent({ ...state, request: { hookConsent: 'declined' } }), 'declined'); + assert.strictEqual(getRecordedHookConsent({ ...state, request: { hookConsent: 'enabled' } }), 'enabled'); + assert.strictEqual(getRecordedHookConsent({ ...state, resolution: { selectedModules: ['hooks-runtime'] } }), 'enabled'); + assert.strictEqual(getRecordedHookConsent({ operations: [{ kind: 'update-claude-settings' }] }), 'enabled'); + })) 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-executor.test.js b/tests/lib/install-executor.test.js index 6f64b540c..d39c14628 100644 --- a/tests/lib/install-executor.test.js +++ b/tests/lib/install-executor.test.js @@ -20,6 +20,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 { withHookConsent } = require('../../scripts/lib/install/hook-consent'); const REPO_ROOT = path.resolve(__dirname, '..', '..'); @@ -50,6 +52,40 @@ function operationFor(plan, suffix) { )); } +const INERT_OPENCODE_PLUGIN = 'export default async () => ({});\n'; +const OPENCODE_ENTRYPOINTS = ['ecc-hooks.ts', 'index.ts']; + +function snapshotFiles(root) { + const entries = []; + function visit(directory, prefix = '') { + if (!fs.existsSync(directory)) return; + for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const relativePath = path.join(prefix, entry.name); + const fullPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + entries.push([relativePath, 'directory']); + visit(fullPath, relativePath); + } else { + entries.push([relativePath, fs.readFileSync(fullPath).toString('base64')]); + } + } + } + visit(root); + return entries; +} + +function createOpenCodePlan(homeDir, hookConsent = null, enabledRuntime = false) { + return createInstallPlanFromRequest(normalizeInstallRequest({ + target: 'opencode', + moduleIds: enabledRuntime ? ['platform-configs', 'hooks-runtime'] : ['platform-configs'], + enableHooks: hookConsent === 'enabled', + noHooks: hookConsent === 'declined', + }), { + sourceRoot: REPO_ROOT, homeDir, projectRoot: homeDir, + exemptValidationCodes: ['opencode-plugin-not-built'], + }); +} + function writeLegacySourceFixture(root) { writeJson(root, 'package.json', { version: '9.8.7' }); writeFile(root, path.join('rules', 'common', 'coding-style.md'), '# Common\n'); @@ -843,6 +879,220 @@ 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'); + + for (const name of OPENCODE_ENTRYPOINTS) { + for (const operations of [defaultPlan.operations, defaultPlan.statePreview.operations]) { + const operation = operations.find(candidate => ( + candidate.sourceRelativePath.split(path.sep).join('/') === `.opencode/plugins/${name}` + )); + assert.ok(operation, `Plan includes ${name}`); + assert.strictEqual(operation.contentTransform, 'opencode-disable-plugin-entrypoint'); + } + } + applyInstallPlanDirect(defaultPlan); + for (const name of OPENCODE_ENTRYPOINTS) { + assert.strictEqual(fs.readFileSync(path.join(homeDir, '.config', 'opencode', 'plugins', name), 'utf8'), INERT_OPENCODE_PLUGIN); + } + 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); + applyInstallPlanDirect(enabledPlan); + for (const name of OPENCODE_ENTRYPOINTS) { + assert.strictEqual( + fs.readFileSync(path.join(homeDir, '.config', 'opencode', 'plugins', name), 'utf8'), + fs.readFileSync(path.join(REPO_ROOT, '.opencode', 'plugins', name), 'utf8') + ); + } + applyInstallPlanDirect(declinedPlan); + const declinedState = JSON.parse(fs.readFileSync(declinedPlan.installStatePath, 'utf8')); + assert.strictEqual(declinedState.request.hookConsent, 'declined'); + assert.ok(!declinedState.resolution.selectedModules.includes('hooks-runtime')); + assert.ok(!JSON.parse(fs.readFileSync(path.join(declinedPlan.targetRoot, 'opencode.json'), 'utf8')).plugin.includes('./plugins')); + for (const name of OPENCODE_ENTRYPOINTS) { + const destinationPath = path.join(homeDir, '.config', 'opencode', 'plugins', name); + assert.strictEqual(fs.readFileSync(destinationPath, 'utf8'), INERT_OPENCODE_PLUGIN); + const operation = declinedState.operations.find(candidate => candidate.destinationPath === destinationPath); + assert.strictEqual(operation.contentTransform, 'opencode-disable-plugin-entrypoint'); + assert.strictEqual(operation.contentSha256, crypto.createHash('sha256').update(INERT_OPENCODE_PLUGIN).digest('hex')); + } + } finally { + cleanup(homeDir); + } + })) passed++; else failed++; + + if (test('OpenCode disabled install refuses user-owned activation files before any writes', () => { + for (const relativePath of ['opencode.json', path.join('plugins', 'ecc-hooks.ts'), path.join('plugins', 'index.ts')]) { + const homeDir = createTempDir('install-opencode-user-owned-'); + try { + const plan = createOpenCodePlan(homeDir, 'declined'); + writeFile(plan.targetRoot, relativePath, relativePath === 'opencode.json' + ? '{"plugin":["./plugins"],"theme":"user-owned"}\n' + : 'globalThis.userOwnedPlugin = true; export default async () => ({});\n'); + const before = snapshotFiles(homeDir); + assert.throws(() => applyInstallPlanDirect(plan), /OpenCode hook|OpenCode.*activation|OpenCode.*plugin/i); + assert.deepStrictEqual(snapshotFiles(homeDir), before, `No writes when ${relativePath} is user-owned`); + } finally { + cleanup(homeDir); + } + } + })) passed++; else failed++; + + if (test('OpenCode disabled install refuses modified or unverifiable managed activation files before writes', () => { + for (const scenario of ['modified-plugin', 'modified-config', 'missing-digest', 'stale-entrypoint']) { + const homeDir = createTempDir('install-opencode-managed-guard-'); + try { + const enabledPlan = createOpenCodePlan(homeDir, 'enabled', true); + applyInstallPlanDirect(enabledPlan); + const state = JSON.parse(fs.readFileSync(enabledPlan.installStatePath, 'utf8')); + const pluginPath = path.join(enabledPlan.targetRoot, 'plugins', 'ecc-hooks.ts'); + if (scenario === 'modified-plugin') { + fs.appendFileSync(pluginPath, '// user modification\n'); + } else if (scenario === 'modified-config') { + fs.appendFileSync(path.join(enabledPlan.targetRoot, 'opencode.json'), ' \n'); + } else if (scenario === 'missing-digest') { + delete state.operations.find(operation => operation.destinationPath === pluginPath).contentSha256; + writeJson(homeDir, path.relative(homeDir, enabledPlan.installStatePath), state); + } else { + const stalePath = writeFile(enabledPlan.targetRoot, path.join('plugins', 'legacy-hooks.js'), 'globalThis.legacyHook = true;\n'); + state.operations.push({ + kind: 'copy-file', moduleId: 'platform-configs', ownership: 'managed', scaffoldOnly: false, + sourceRelativePath: '.opencode/plugins/legacy-hooks.js', destinationPath: stalePath, + strategy: 'preserve-relative-path', + contentSha256: crypto.createHash('sha256').update(fs.readFileSync(stalePath)).digest('hex'), + }); + writeJson(homeDir, path.relative(homeDir, enabledPlan.installStatePath), state); + } + const declinedPlan = createOpenCodePlan(homeDir, 'declined'); + const before = snapshotFiles(homeDir); + assert.throws(() => applyInstallPlanDirect(declinedPlan), /OpenCode hook|OpenCode.*activation|OpenCode.*plugin/i); + assert.deepStrictEqual(snapshotFiles(homeDir), before, `No writes for ${scenario}`); + } finally { + cleanup(homeDir); + } + } + })) passed++; else failed++; + + if (test('OpenCode decline preserves activation changed at the install write boundary', () => { + const homeDir = createTempDir('install-opencode-write-race-'); + try { + const enabledPlan = createOpenCodePlan(homeDir, 'enabled', true); + applyInstallPlanDirect(enabledPlan); + const pluginPath = path.join(enabledPlan.targetRoot, 'plugins', 'ecc-hooks.ts'); + const changedContent = 'globalThis.userChangedHook = true;\n'; + const stateBefore = fs.readFileSync(enabledPlan.installStatePath); + let changed = false; + const declinedPlan = createOpenCodePlan(homeDir, 'declined'); + assert.throws(() => applyInstallPlanDirect(declinedPlan, { + beforeOperationWrite({ operation }) { + if (operation.destinationPath === pluginPath) { + changed = true; + fs.writeFileSync(pluginPath, changedContent); + } + }, + }), /OpenCode hook.*changed after preflight/i); + assert.strictEqual(changed, true); + assert.strictEqual(fs.readFileSync(pluginPath, 'utf8'), changedContent); + const previousState = JSON.parse(stateBefore.toString('utf8')); + const checkpointState = JSON.parse(fs.readFileSync(enabledPlan.installStatePath, 'utf8')); + const priorOperation = previousState.operations.find(operation => operation.destinationPath === pluginPath); + const checkpointOperation = checkpointState.operations.find(operation => operation.destinationPath === pluginPath); + assert.strictEqual(checkpointOperation.contentSha256, priorOperation.contentSha256, 'Failure checkpoint must not adopt raced activation bytes'); + assert.strictEqual(checkpointState.request.hookConsent, 'enabled', 'Failed decline must retain the prior enabled decision'); + } finally { + cleanup(homeDir); + } + })) passed++; else failed++; + if (test('Claude hooks install refuses a symlinked hooks destination', () => { if (process.platform === 'win32') return; diff --git a/tests/lib/install-lifecycle.test.js b/tests/lib/install-lifecycle.test.js index 1eed5071b..6a892797f 100644 --- a/tests/lib/install-lifecycle.test.js +++ b/tests/lib/install-lifecycle.test.js @@ -206,6 +206,27 @@ function writeOpencodeState(homeDir, overrides = {}) { }; } +function writeRecordedOpenCodeActivation(homeDir) { + 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 destinationPath = path.join(targetRoot, relativePath); + fs.mkdirSync(path.dirname(destinationPath), { recursive: true }); + fs.writeFileSync(destinationPath, content); + return { + kind: 'copy-file', moduleId: 'platform-configs', sourceRelativePath, destinationPath, + ownership: 'managed', scaffoldOnly: false, strategy: 'preserve-relative-path', + contentSha256: crypto.createHash('sha256').update(content).digest('hex'), + }; + }); + return writeOpencodeState(homeDir, { + request: { modules: ['platform-configs'], hookConsent: null }, + resolution: { selectedModules: ['platform-configs'] }, + operations, + }); +} + function withTemporarilyMovedPath(filePath, callback) { if (!fs.existsSync(filePath)) { try { @@ -1935,6 +1956,342 @@ 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('OpenCode doctor and repair keep auto-discovered plugin entrypoints inert after declined consent', () => { + const homeDir = createTempDir('install-lifecycle-opencode-inert-'); + try { + withTemporarilyMovedPath(path.join(REPO_ROOT, '.opencode', 'dist'), () => { + const plan = createInstallPlanFromRequest({ + mode: 'manifest', target: 'opencode', profileId: null, + moduleIds: ['platform-configs'], includeComponentIds: [], excludeComponentIds: [], + legacyLanguages: [], hookConsent: 'declined', + }, { + sourceRoot: REPO_ROOT, homeDir, projectRoot: homeDir, + exemptValidationCodes: ['opencode-plugin-not-built'], + }); + 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'); + fs.unlinkSync(pluginPath); + } + const before = buildDoctorReport({ repoRoot: REPO_ROOT, homeDir, projectRoot: homeDir, targets: ['opencode'] }); + assert.ok(before.results[0].issues.some(issue => issue.code === 'missing-managed-files')); + let buildCalls = 0; + const repaired = repairInstalledStates({ + repoRoot: REPO_ROOT, homeDir, projectRoot: homeDir, targets: ['opencode'], + buildOpencodePayload(repoRoot) { + buildCalls += 1; + const distDir = path.join(repoRoot, '.opencode', 'dist'); + fs.mkdirSync(path.join(distDir, 'plugins'), { recursive: true }); + fs.mkdirSync(path.join(distDir, 'tools'), { recursive: true }); + fs.writeFileSync(path.join(distDir, 'index.js'), 'module.exports = {};\n'); + }, + }); + 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'); + } + const state = readInstallState(plan.installStatePath); + assert.strictEqual(state.request.hookConsent, 'declined'); + for (const pluginPath of pluginPaths) { + const operation = state.operations.find(candidate => candidate.destinationPath === pluginPath); + assert.strictEqual(operation.contentTransform, 'opencode-disable-plugin-entrypoint'); + } + const after = buildDoctorReport({ repoRoot: REPO_ROOT, homeDir, projectRoot: homeDir, targets: ['opencode'] }); + assert.ok(!after.results[0].issues.some(issue => ( + ['missing-managed-files', 'drifted-managed-files', 'unverified-managed-operations'].includes(issue.code) + ))); + }); + } finally { + cleanup(homeDir); + } + })) passed++; else failed++; + + if (test('OpenCode repair disables older auto-discovered activation without inferring consent', () => { + const homeDir = createTempDir('install-lifecycle-opencode-old-activation-'); + try { + withTemporarilyMovedPath(path.join(REPO_ROOT, '.opencode', 'dist'), () => { + const recorded = writeRecordedOpenCodeActivation(homeDir); + const result = repairInstalledStates({ + repoRoot: REPO_ROOT, homeDir, projectRoot: homeDir, targets: ['opencode'], + buildOpencodePayload(repoRoot) { + const distDir = path.join(repoRoot, '.opencode', 'dist'); + fs.mkdirSync(path.join(distDir, 'plugins'), { recursive: true }); + fs.mkdirSync(path.join(distDir, 'tools'), { recursive: true }); + fs.writeFileSync(path.join(distDir, 'index.js'), 'module.exports = {};\n'); + }, + }); + 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.ok(!JSON.parse(fs.readFileSync(path.join(recorded.targetRoot, 'opencode.json'), 'utf8')).plugin.includes('./plugins')); + const state = readInstallState(recorded.installStatePath); + assert.notStrictEqual(state.request.hookConsent, 'enabled'); + assert.ok(!state.resolution.selectedModules.includes('hooks-runtime')); + for (const operation of state.operations.filter(operation => ( + ['.opencode/plugins/ecc-hooks.ts', '.opencode/plugins/index.ts'].includes(operation.sourceRelativePath) + ))) { + assert.strictEqual(operation.contentTransform, 'opencode-disable-plugin-entrypoint'); + } + }); + } finally { + cleanup(homeDir); + } + })) passed++; else failed++; + + if (test('OpenCode repair refuses modified activation before building or restoring missing files', () => { + const homeDir = createTempDir('install-lifecycle-opencode-modified-activation-'); + try { + withTemporarilyMovedPath(path.join(REPO_ROOT, '.opencode', 'dist'), () => { + const recorded = writeRecordedOpenCodeActivation(homeDir); + const pluginPath = path.join(recorded.targetRoot, 'plugins', 'ecc-hooks.ts'); + const missingPath = path.join(recorded.targetRoot, 'plugins', 'index.ts'); + const configPath = path.join(recorded.targetRoot, 'opencode.json'); + fs.appendFileSync(pluginPath, '// user custom hook\n'); + fs.unlinkSync(missingPath); + const before = [pluginPath, configPath, recorded.installStatePath].map(filePath => fs.readFileSync(filePath)); + let buildCalls = 0; + const result = repairInstalledStates({ + repoRoot: REPO_ROOT, homeDir, projectRoot: homeDir, targets: ['opencode'], + buildOpencodePayload() { + buildCalls += 1; + throw new Error('Guard must run before the builder'); + }, + }); + assert.strictEqual(result.results[0].status, 'error'); + assert.match(result.results[0].error, /OpenCode hook|OpenCode.*activation|OpenCode.*plugin/i); + assert.strictEqual(buildCalls, 0); + assert.strictEqual(fs.existsSync(missingPath), false); + assert.deepStrictEqual([pluginPath, configPath, recorded.installStatePath].map(filePath => fs.readFileSync(filePath)), before); + assert.strictEqual(fs.existsSync(path.join(REPO_ROOT, '.opencode', 'dist')), false); + }); + } finally { + cleanup(homeDir); + } + })) passed++; else failed++; + + if (test('OpenCode repair rejects recorded non-plugin sources at missing activation destinations before building', () => { + for (const kind of ['render-template', 'copy-file']) { + const homeDir = createTempDir('install-lifecycle-opencode-disguised-'); + try { + withTemporarilyMovedPath(path.join(REPO_ROOT, '.opencode', 'dist'), () => { + const targetRoot = path.join(homeDir, '.config', 'opencode'); + const destinationPath = path.join(targetRoot, 'plugins', 'index.ts'); + const recorded = writeOpencodeState(homeDir, { + request: { modules: ['platform-configs'], hookConsent: 'declined', legacyMode: true }, + resolution: { selectedModules: ['platform-configs'] }, + operations: [managedOperation(kind, destinationPath, { + moduleId: 'platform-configs', + sourceRelativePath: '.claude-plugin/plugin.json.template', + ...(kind === 'render-template' ? { renderedContent: 'globalThis.unconsentedHook = true;\n' } : {}), + })], + }); + const stateBefore = fs.readFileSync(recorded.installStatePath); + let buildCalls = 0; + const result = repairInstalledStates({ + repoRoot: REPO_ROOT, homeDir, projectRoot: homeDir, targets: ['opencode'], + buildOpencodePayload() { buildCalls += 1; throw new Error('Unsafe activation must fail before building'); }, + }); + assert.strictEqual(result.results[0].status, 'error', kind); + assert.match(result.results[0].error, /OpenCode hook.*unsupported activation operation/i); + assert.strictEqual(buildCalls, 0); + assert.strictEqual(fs.existsSync(destinationPath), false); + assert.strictEqual(fs.existsSync(path.dirname(destinationPath)), false); + assert.strictEqual(fs.existsSync(path.join(REPO_ROOT, '.opencode', 'dist')), false); + assert.strictEqual(crypto.createHash('sha256').update(fs.readFileSync(recorded.installStatePath)).digest('hex'), crypto.createHash('sha256').update(stateBefore).digest('hex'), 'Failed repair must not refresh install-state'); + }); + } finally { + cleanup(homeDir); + } + } + })) passed++; else failed++; + + if (test('OpenCode repair preserves activation bytes changed between health inspection and write', () => { + const homeDir = createTempDir('install-lifecycle-opencode-health-race-'); + 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); + 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) { + 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) { + descriptors.delete(descriptor); + return originalCloseSync.call(fs, descriptor); + }; + fs.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. + if (!changed && descriptors.has(candidate) && args.length > 0) { + changed = true; + fs.writeFileSync(pluginPath, changedContent); + } + return content; + }; + let result; + try { + result = repairInstalledStates({ + repoRoot: REPO_ROOT, homeDir, projectRoot: homeDir, targets: ['opencode'], + buildOpencodePayload(repoRoot) { + const distDir = path.join(repoRoot, '.opencode', 'dist'); + fs.mkdirSync(path.join(distDir, 'plugins'), { recursive: true }); + fs.mkdirSync(path.join(distDir, 'tools'), { recursive: true }); + fs.writeFileSync(path.join(distDir, 'index.js'), 'module.exports = {};\n'); + }, + }); + } finally { + fs.openSync = originalOpenSync; + fs.readFileSync = originalReadFileSync; + fs.closeSync = originalCloseSync; + } + assert.strictEqual(changed, true, 'The health-read boundary was exercised'); + assert.strictEqual(result.results[0].status, 'error'); + assert.match(result.results[0].error, /OpenCode hook.*changed after preflight/i); + assert.strictEqual(fs.readFileSync(pluginPath, 'utf8'), changedContent); + const previousState = JSON.parse(stateBefore.toString('utf8')); + const checkpointState = readInstallState(recorded.installStatePath); + const priorOperation = previousState.operations.find(operation => operation.destinationPath === pluginPath); + const checkpointOperation = checkpointState.operations.find(operation => operation.destinationPath === pluginPath); + assert.strictEqual(checkpointOperation.contentSha256, priorOperation.contentSha256, 'Failure checkpoint must not adopt raced activation bytes'); + assert.strictEqual(checkpointState.request.hookConsent, previousState.request.hookConsent); + assert.notStrictEqual(result.results[0].stateRefreshed, true); + }); + } finally { + fs.openSync = originalOpenSync; + fs.readFileSync = originalReadFileSync; + fs.closeSync = originalCloseSync; + cleanup(homeDir); + } + })) 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 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); + 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 stateBefore = fs.readFileSync(recorded.installStatePath); + let inserted = false; + fs.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) { + descriptors.delete(descriptor); + return originalCloseSync.call(fs, descriptor); + }; + fs.writeSync = function insertAliasAfterPluginWrite(candidate, ...args) { + const result = originalWriteSync.call(fs, candidate, ...args); + if (!inserted && descriptors.has(candidate)) { + inserted = true; + originalWriteFileSync.call(fs, aliasPath, aliasContent); + } + return result; + }; + let result; + try { + result = repairInstalledStates({ + repoRoot: REPO_ROOT, homeDir, projectRoot: homeDir, targets: ['opencode'], + buildOpencodePayload(repoRoot) { + const distDir = path.join(repoRoot, '.opencode', 'dist'); + fs.mkdirSync(path.join(distDir, 'plugins'), { recursive: true }); + fs.mkdirSync(path.join(distDir, 'tools'), { recursive: true }); + fs.writeFileSync(path.join(distDir, 'index.js'), 'module.exports = {};\n'); + }, + }); + } finally { + fs.openSync = originalOpenSync; + fs.writeSync = originalWriteSync; + fs.closeSync = originalCloseSync; + } + assert.strictEqual(inserted, true, 'The plugin-write boundary was exercised'); + assert.strictEqual(result.results[0].status, 'error'); + assert.match(result.results[0].error, /OpenCode hook activation remains active/i); + assert.strictEqual(fs.readFileSync(aliasPath, 'utf8'), aliasContent); + const checkpointState = readInstallState(recorded.installStatePath); + assert.ok(!checkpointState.operations.some(operation => operation.destinationPath === aliasPath), 'Failed repair must not adopt the raced alias'); + assert.strictEqual(checkpointState.request.hookConsent, JSON.parse(stateBefore.toString('utf8')).request.hookConsent); + assert.notStrictEqual(result.results[0].stateRefreshed, true); + }); + } finally { + fs.openSync = originalOpenSync; + fs.writeSync = originalWriteSync; + fs.closeSync = originalCloseSync; + cleanup(homeDir); + } + })) 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-'); diff --git a/tests/lib/opencode-consent-legacy-lock.test.js b/tests/lib/opencode-consent-legacy-lock.test.js new file mode 100644 index 000000000..259e86b9f --- /dev/null +++ b/tests/lib/opencode-consent-legacy-lock.test.js @@ -0,0 +1,196 @@ +/** Synthetic legacy migration: both install roots stay locked across build/apply. */ +'use strict'; + +const assert = require('assert'); +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 { createInstallState, readInstallState, writeInstallState } = require('../../scripts/lib/install-state'); +const { withOpenCodeInstallLocks } = require('../../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'; +const sha256 = value => crypto.createHash('sha256').update(value).digest('hex'); +const lockPath = root => path.join(root, 'ecc-install-state.json.ecc.lock'); + +function writeJson(filePath, value) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +function privateFixture(callback) { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-legacy-consent-lock-'))); + const sourceRoot = path.join(root, 'source'); + const homeDir = path.join(root, 'home'); + const legacyRoot = path.join(homeDir, '.opencode'); + const canonicalRoot = path.join(homeDir, '.config', 'opencode'); + const sourcePath = path.join(sourceRoot, SOURCE_RELATIVE_PATH); + const legacyFile = path.join(legacyRoot, SOURCE_RELATIVE_PATH); + const legacyStatePath = path.join(legacyRoot, 'ecc-install-state.json'); + const canonicalStatePath = path.join(canonicalRoot, 'ecc-install-state.json'); + const adapter = { id: 'opencode-home', target: 'opencode', kind: 'home' }; + try { + writeJson(path.join(sourceRoot, 'package.json'), { name: 'synthetic-ecc-lock-fixture', version: '2.2.2' }); + writeJson(path.join(sourceRoot, 'manifests', 'install-modules.json'), { + version: 1, + modules: [{ id: 'workflow-quality', kind: 'skills', description: 'Inert test skill.', + paths: [SOURCE_RELATIVE_PATH], targets: ['opencode'], dependencies: [], + defaultInstall: false, cost: 'light', stability: 'stable' }], + }); + writeJson(path.join(sourceRoot, 'manifests', 'install-profiles.json'), { version: 1, profiles: {} }); + writeJson(path.join(sourceRoot, 'manifests', 'install-components.json'), { version: 1, components: [] }); + for (const filePath of [sourcePath, legacyFile]) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, SKILL_CONTENT); + } + const operation = { kind: 'copy-file', moduleId: 'workflow-quality', + sourceRelativePath: SOURCE_RELATIVE_PATH, destinationPath: legacyFile, + strategy: 'preserve-relative-path', ownership: 'managed', scaffoldOnly: false, + contentSha256: sha256(SKILL_CONTENT) }; + const stateOptions = { + adapter, + request: { profile: null, modules: ['workflow-quality'], includeComponents: [], + excludeComponents: [], legacyLanguages: [], legacyMode: false, hookConsent: null }, + resolution: { selectedModules: ['workflow-quality'], skippedModules: [] }, + source: { repoVersion: '2.2.2', repoCommit: 'synthetic-legacy-lock-fixture', manifestVersion: 1 }, + }; + writeInstallState(legacyStatePath, createInstallState({ ...stateOptions, + targetRoot: legacyRoot, installStatePath: legacyStatePath, operations: [operation] })); + const canonicalOperation = { ...operation, sourcePath, + destinationPath: path.join(canonicalRoot, SOURCE_RELATIVE_PATH) }; + const canonicalPlan = { + target: 'opencode', adapter, sourceRoot, homeDir, targetRoot: canonicalRoot, + installRoot: canonicalRoot, installStatePath: canonicalStatePath, + selectedModuleIds: ['workflow-quality'], operations: [canonicalOperation], warnings: [], + statePreview: createInstallState({ ...stateOptions, targetRoot: canonicalRoot, + installStatePath: canonicalStatePath, operations: [canonicalOperation] }), + }; + const sourceFiles = ['package.json', 'manifests/install-modules.json', + 'manifests/install-profiles.json', 'manifests/install-components.json', SOURCE_RELATIVE_PATH]; + const sourceSnapshot = new Map(sourceFiles.map(relative => [relative, + fs.readFileSync(path.join(sourceRoot, relative))])); + callback({ root, homeDir, sourceRoot, sourcePath, sourceSnapshot, legacyRoot, canonicalRoot, + legacyFile, legacyStatePath, canonicalStatePath, canonicalPlan }); + } finally { + // The generated payload and all install paths are inside this fixture. + // No repository assets, compiler output, real user home or providers are used. + fs.rmSync(root, { recursive: true, force: true }); + } +} + +function repair(value, buildOpencodePayload) { + return repairInstalledStates({ repoRoot: value.sourceRoot, homeDir: value.homeDir, + projectRoot: value.homeDir, targets: ['opencode'], env: {}, buildOpencodePayload }); +} + +function assertSourceUnchanged(value) { + for (const [relative, bytes] of value.sourceSnapshot) { + assert.deepStrictEqual(fs.readFileSync(path.join(value.sourceRoot, relative)), bytes, relative); + } +} + +function assertHeldAndIndependentWritersRefused(value) { + const lockBytes = [value.canonicalRoot, value.legacyRoot].map(targetRoot => { + const bytes = fs.readFileSync(lockPath(targetRoot)); + assert.strictEqual(JSON.parse(bytes).pid, process.pid); + return bytes; + }); + const legacyBytes = fs.readFileSync(value.legacyStatePath); + assert.throws(() => applyInstallPlan(value.canonicalPlan, { + beforeInstallStateRead() { assert.fail('Independent apply reached state read while migration held both locks'); }, + }), /Another ECC process.*OpenCode/); + let nestedBuilds = 0; + const nested = repair(value, () => { + nestedBuilds++; + assert.fail('Independent repair reached build while migration held both locks'); + }); + assert.strictEqual(nested.results.length, 1, JSON.stringify(nested)); + assert.strictEqual(nested.results[0].status, 'error'); + assert.match(nested.results[0].error, /Another ECC process.*OpenCode/); + assert.strictEqual(nestedBuilds, 0); + assert.strictEqual(fs.existsSync(value.canonicalStatePath), false); + assert.deepStrictEqual(fs.readFileSync(value.legacyStatePath), legacyBytes); + [value.canonicalRoot, value.legacyRoot].forEach((targetRoot, index) => { + assert.deepStrictEqual(fs.readFileSync(lockPath(targetRoot)), lockBytes[index]); + }); +} + +function assertBothLocksReleased(value) { + for (const targetRoot of [value.canonicalRoot, value.legacyRoot]) { + assert.strictEqual(fs.existsSync(lockPath(targetRoot)), false); + } + withOpenCodeInstallLocks([value.canonicalRoot, value.legacyRoot], () => { + assert.ok(fs.existsSync(lockPath(value.canonicalRoot))); + assert.ok(fs.existsSync(lockPath(value.legacyRoot))); + }); + for (const targetRoot of [value.canonicalRoot, value.legacyRoot]) { + assert.strictEqual(fs.existsSync(lockPath(targetRoot)), false); + } +} + +function runTests() { + let passed = 0; + let failed = 0; + function test(name, callback) { + try { privateFixture(callback); passed++; console.log(` PASS ${name}`); } + catch (error) { failed++; console.error(` FAIL ${name}: ${error.stack}`); } + } + test('legacy repair holds both roots before build and releases them after a throwing builder', value => { + const beforeState = fs.readFileSync(value.legacyStatePath); + let builds = 0; + const result = repair(value, sourceRoot => { + builds++; + assert.strictEqual(sourceRoot, value.sourceRoot); + assertHeldAndIndependentWritersRefused(value); + throw new Error('Synthetic builder failure before any source mutation'); + }); + assert.strictEqual(builds, 1); + assert.strictEqual(result.results.length, 1, JSON.stringify(result)); + assert.strictEqual(result.results[0].status, 'error'); + assert.match(result.results[0].error, /Synthetic builder failure before any source mutation/); + assert.strictEqual(result.results[0].stateRefreshed, undefined); + assert.deepStrictEqual(fs.readFileSync(value.legacyStatePath), beforeState); + assert.strictEqual(fs.readFileSync(value.legacyFile, 'utf8'), SKILL_CONTENT); + assert.strictEqual(fs.existsSync(value.canonicalStatePath), false); + assert.strictEqual(fs.existsSync(path.join(value.sourceRoot, '.opencode', 'dist')), false); + assertSourceUnchanged(value); + assertBothLocksReleased(value); + }); + test('legacy repair reuses its opaque lease for canonical apply after an inert synthetic build', value => { + let builds = 0; + const result = repair(value, sourceRoot => { + builds++; + assert.strictEqual(sourceRoot, value.sourceRoot); + assertHeldAndIndependentWritersRefused(value); + 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'); + }); + assert.strictEqual(builds, 1); + assert.strictEqual(result.summary.errorCount, 0, JSON.stringify(result)); + assert.strictEqual(result.results.length, 1, JSON.stringify(result)); + assert.strictEqual(result.results[0].status, 'repaired'); + assert.strictEqual(result.results[0].stateRefreshed, true); + assert.strictEqual(result.results[0].installStatePath, value.canonicalStatePath); + const state = readInstallState(value.canonicalStatePath); + assert.strictEqual(state.target.root, value.canonicalRoot); + assert.deepStrictEqual(state.resolution.selectedModules, ['workflow-quality']); + assert.notStrictEqual(state.request.hookConsent, 'enabled'); + assert.strictEqual(state.operations.length, 1); + assert.strictEqual(state.operations[0].contentSha256, sha256(SKILL_CONTENT)); + assert.strictEqual(fs.readFileSync(path.join(value.canonicalRoot, SOURCE_RELATIVE_PATH), 'utf8'), SKILL_CONTENT); + assert.strictEqual(fs.existsSync(value.legacyStatePath), false); + assert.strictEqual(fs.existsSync(value.legacyFile), false); + assertSourceUnchanged(value); + assertBothLocksReleased(value); + }); + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + return { passed, failed }; +} + +if (require.main === module) process.exitCode = runTests().failed ? 1 : 0; +module.exports = { runTests }; diff --git a/tests/lib/opencode-hook-consent-safety.test.js b/tests/lib/opencode-hook-consent-safety.test.js new file mode 100644 index 000000000..46bbb725a --- /dev/null +++ b/tests/lib/opencode-hook-consent-safety.test.js @@ -0,0 +1,445 @@ +'use strict'; + +const assert = require('assert'); +const crypto = require('crypto'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { applyInstallPlan } = require('../../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 REPO_ROOT = path.resolve(__dirname, '../..'); +const sha256 = value => crypto.createHash('sha256').update(value).digest('hex'); + +// Authentic public ECC source fixtures, kept as inert bytes (never imported). +// Copying these bytes to an alias does not claim they were published build output. +const legacyPluginFixtures = [ + { + name: "2.2.1 barrel", + // https://github.com/affaan-m/ECC/blob/ca185ef5f7667078a1e70a763bd3a9c71c48acf0/.opencode/plugins/index.ts + sha256: '965c5fac76ce0c3ceb3836814f5eb9ede8c9db50373a508c734f949cb321a21a', + content: `/** + * ECC Plugins for OpenCode + * + * This module exports all ECC plugins for OpenCode integration. + * Plugins provide hook-based automation that mirrors Claude Code's hook system + * while taking advantage of OpenCode's more sophisticated 20+ event types. + */ + +export { ECCHooksPlugin, default } from "./ecc-hooks.js" + +// Re-export for named imports +export * from "./ecc-hooks.js" +`, + }, + { + name: "early barrel", + // https://github.com/affaan-m/ECC/blob/6d440c036df2c1b2fec957627d1202c3708e0627/.opencode/plugins/index.ts + sha256: 'e42c733adb177f84cea813663aa34c7868dbaa98c96950d0ef91cd211b8aa169', + content: `/** + * Everything Claude Code (ECC) Plugins for OpenCode + * + * This module exports all ECC plugins for OpenCode integration. + * Plugins provide hook-based automation that mirrors Claude Code's hook system + * while taking advantage of OpenCode's more sophisticated 20+ event types. + */ + +export { ECCHooksPlugin, default } from "./ecc-hooks" + +// Re-export for named imports +export * from "./ecc-hooks" +`, + }, +]; + +function fixture(callback, enabled = false) { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-opencode-write-'))); + const homeDir = path.join(root, 'home'); + const sourceRoot = path.join(root, 'source'); + const targetRoot = path.join(homeDir, '.config', 'opencode'); + const adapter = { id: 'opencode-home', target: 'opencode', kind: 'home' }; + try { + fs.mkdirSync(path.join(sourceRoot, 'manifests'), { recursive: true }); + for (const name of ['install-modules.json', 'install-components.json', 'install-profiles.json']) { + fs.copyFileSync(path.join(REPO_ROOT, 'manifests', name), path.join(sourceRoot, 'manifests', name)); + } + fs.copyFileSync(path.join(REPO_ROOT, 'package.json'), path.join(sourceRoot, 'package.json')); + const operations = ['opencode.json', 'plugins/ecc-hooks.ts'].map(relativePath => { + const sourceRelativePath = `.opencode/${relativePath}`; + const sourcePath = path.join(sourceRoot, sourceRelativePath); + const destinationPath = path.join(targetRoot, relativePath); + const content = relativePath === 'opencode.json' + ? '{"plugin":["./plugins"],"userSetting":true}\n' + : 'export default async () => ({ "session.created": () => {} });\n'; + fs.mkdirSync(path.dirname(sourcePath), { recursive: true }); + fs.mkdirSync(path.dirname(destinationPath), { recursive: true }); + fs.writeFileSync(sourcePath, content); + fs.writeFileSync(destinationPath, content); + return { kind: 'copy-file', moduleId: 'platform-configs', sourceRelativePath, sourcePath, + destinationPath, ownership: 'managed', scaffoldOnly: false, + strategy: 'preserve-relative-path', contentSha256: sha256(content) }; + }); + 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'); + const installStatePath = path.join(targetRoot, 'ecc-install-state.json'); + const state = createInstallState({ + adapter, targetRoot, installStatePath, + request: { modules: ['platform-configs'], legacyMode: true, + hookConsent: enabled ? 'enabled' : null }, + resolution: { selectedModules: enabled ? ['platform-configs', 'hooks-runtime'] : ['platform-configs'], + skippedModules: [] }, + operations, source: { repoVersion: '2.2.2', manifestVersion: 1 }, + }); + writeInstallState(installStatePath, state); + 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') }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +function repair(value, extra = {}) { + return repairInstalledStates({ repoRoot: value.sourceRoot, homeDir: value.homeDir, + projectRoot: value.homeDir, targets: ['opencode'], + buildOpencodePayload() { throw new Error('Unexpected compiler execution'); }, ...extra }); +} + +function withWritableOpenMutation(filePath, mutate, callback) { + const originalOpen = fs.openSync; + const originalClose = fs.closeSync; + let injected = false; + const descriptors = new Set(); + fs.openSync = function (candidate, flags, ...rest) { + const writable = typeof flags === 'number' + ? Boolean(flags & (fs.constants.O_WRONLY | fs.constants.O_RDWR)) + : /[wa+]/.test(flags); + const matches = typeof candidate === 'string' && path.resolve(candidate) === path.resolve(filePath); + if (matches && writable && !injected) { + injected = true; + mutate(); + } + const fd = originalOpen.call(fs, candidate, flags, ...rest); + if (matches && writable) descriptors.add(fd); + return fd; + }; + fs.closeSync = function (fd) { + descriptors.delete(fd); + return originalClose.call(fs, fd); + }; + try { + 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; + } +} + +function assertPriorOwnership(value, filePath) { + const after = readInstallState(value.installStatePath); + const prior = value.state.operations.find(operation => operation.destinationPath === filePath); + const current = after.operations.find(operation => operation.destinationPath === filePath); + assert.strictEqual(current.contentSha256, prior.contentSha256, 'Do not adopt raced bytes'); + assert.strictEqual(after.request.hookConsent, value.state.request.hookConsent); +} + +function runTests() { + let passed = 0; + let failed = 0; + const test = (name, callback) => { + try { callback(); passed++; console.log(` PASS ${name}`); } + catch (error) { failed++; console.error(` FAIL ${name}: ${error.stack}`); } + }; + 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 => { + const destination = path.join(value.targetRoot, relativePath); + const content = relativePath === 'opencode.json' + ? '{"plugin":["./plugins"],"userEdit":"keep"}\n' : '// user edit: preserve this\n'; + withWritableOpenMutation(destination, () => fs.writeFileSync(destination, content), () => { + if (mode === 'apply') { + assert.throws(() => applyInstallPlan(value.declinePlan), /changed after preflight/i); + } else { + const result = repair(value).results[0]; + assert.strictEqual(result.status, 'error'); + assert.match(result.error, /changed after preflight/i); + assert.notStrictEqual(result.stateRefreshed, true); + } + assert.strictEqual(fs.readFileSync(destination, 'utf8'), content); + assertPriorOwnership(value, destination); + }); + }, mode === 'apply')); + } + } + for (const mode of ['apply', 'repair']) { + test(`${mode} preserves a file created after expected absence`, () => fixture(value => { + const destination = path.join(value.targetRoot, 'plugins/ecc-hooks.ts'); + fs.unlinkSync(destination); + const content = '// newly created user file\n'; + withWritableOpenMutation(destination, () => fs.writeFileSync(destination, content), () => { + if (mode === 'apply') assert.throws(() => applyInstallPlan(value.declinePlan), /EEXIST|changed after preflight/i); + else assert.strictEqual(repair(value).results[0].status, 'error'); + assert.strictEqual(fs.readFileSync(destination, 'utf8'), content); + assertPriorOwnership(value, destination); + }); + }, mode === 'apply')); + test(`${mode} does not recreate an expected existing file removed at open`, () => fixture(value => { + const destination = path.join(value.targetRoot, 'opencode.json'); + withWritableOpenMutation(destination, () => fs.unlinkSync(destination), () => { + if (mode === 'apply') assert.throws(() => applyInstallPlan(value.declinePlan), /ENOENT|changed after preflight/i); + else assert.strictEqual(repair(value).results[0].status, 'error'); + assert.strictEqual(fs.existsSync(destination), false); + assertPriorOwnership(value, destination); + }); + }, mode === 'apply')); + } + for (const mode of ['apply', 'doctor', 'repair']) { + test(`${mode} reports malformed activation config with source context`, () => fixture(value => { + const destination = path.join(value.targetRoot, 'opencode.json'); + fs.writeFileSync(destination, '{ malformed'); + const before = fs.readFileSync(value.installStatePath); + if (mode === 'apply') assert.throws(() => applyInstallPlan(value.declinePlan), /Failed to parse .*opencode\.json/); + else if (mode === 'repair') assert.match(repair(value).results[0].error, /Failed to parse .*opencode\.json/); + else { + const result = buildDoctorReport({ repoRoot: value.sourceRoot, homeDir: value.homeDir, + projectRoot: value.homeDir, targets: ['opencode'] }).results[0]; + assert.match(JSON.stringify(result.issues), /Failed to parse .*opencode\.json/); + } + assert.strictEqual(fs.readFileSync(destination, 'utf8'), '{ malformed'); + assert.deepStrictEqual(fs.readFileSync(value.installStatePath), before); + })); + } + test('apply rejects another enabled writer and repair while holding its target lease', () => fixture(value => { + let checked = false; + applyInstallPlan(value.declinePlan, { + beforeOperationWrite() { + if (checked) return; + checked = true; + assert.throws(() => applyInstallPlan(withHookConsent(value.basePlan, 'enabled')), /Another ECC process|OpenCode.*lock/i); + assert.strictEqual(repair(value).results[0].status, 'error'); + }, + }); + assert.ok(checked); + assert.strictEqual(fs.existsSync(`${value.installStatePath}.ecc.lock`), false); + assert.strictEqual(applyInstallPlan(value.declinePlan).applied, true); + })); + test('repair preserves ownership for a destination-classified activation with a recorded transform', () => fixture(value => { + const operation = value.state.operations.find(entry => entry.sourceRelativePath.endsWith('ecc-hooks.ts')); + const oldDestination = operation.destinationPath; + operation.sourceRelativePath = '.opencode/tools/fixture.js'; + operation.contentTransform = 'opencode-disable-plugin-entrypoint'; + operation.destinationPath = path.join(value.targetRoot, 'plugins', 'custom.js'); + const source = path.join(value.sourceRoot, operation.sourceRelativePath); + fs.mkdirSync(path.dirname(source), { recursive: true }); + fs.copyFileSync(oldDestination, source); + fs.unlinkSync(oldDestination); + writeInstallState(value.installStatePath, value.state); + const content = '// preserve unowned new custom plugin\n'; + withWritableOpenMutation(operation.destinationPath, () => fs.writeFileSync(operation.destinationPath, content), () => { + const result = repair(value).results[0]; + assert.strictEqual(result.status, 'error'); + assert.match(result.error, /changed after preflight/i); + assert.strictEqual(fs.readFileSync(operation.destinationPath, 'utf8'), content); + assertPriorOwnership(value, operation.destinationPath); + }); + })); + test('repair completes historical deactivation with exact inert bytes and releases its lock', () => fixture(value => { + const result = repair(value).results[0]; + 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'); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(path.join(value.targetRoot, 'opencode.json'))), + { 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 legacy of legacyPluginFixtures) { + for (const consent of [null, 'declined']) { + test(`fresh ${consent || 'default'} apply refuses the unrecorded historical ${legacy.name}`, () => fixture(value => { + assert.strictEqual(sha256(legacy.content), legacy.sha256, 'Preserve exact public-source fixture bytes'); + assert.notStrictEqual(sha256(fs.readFileSync(path.join(value.sourceRoot, '.opencode/plugins/ecc-hooks.ts'))), legacy.sha256); + assert.notStrictEqual(sha256(fs.readFileSync(path.join(REPO_ROOT, '.opencode/plugins/index.ts'))), legacy.sha256); + fs.unlinkSync(value.installStatePath); + for (const operation of value.basePlan.operations) fs.unlinkSync(operation.destinationPath); + const alias = path.join(value.targetRoot, 'plugins', 'index.js'); + assert.ok(!value.basePlan.operations.some(operation => operation.destinationPath === alias)); + fs.writeFileSync(alias, legacy.content); + assert.throws(() => applyInstallPlan(withHookConsent(value.basePlan, consent)), /Refusing OpenCode hook deactivation/); + assert.strictEqual(fs.readFileSync(alias, 'utf8'), legacy.content); + assert.strictEqual(fs.existsSync(value.installStatePath), false, 'Do not adopt an unrecorded historical alias'); + for (const operation of value.basePlan.operations) assert.strictEqual(fs.existsSync(operation.destinationPath), false); + assert.strictEqual(fs.existsSync(`${value.installStatePath}.ecc.lock`), false); + })); + } + for (const mode of ['doctor', 'repair']) { + test(`${mode} refuses the unrecorded historical ${legacy.name} without changing ownership`, () => fixture(value => { + applyInstallPlan(value.declinePlan); + const alias = path.join(value.targetRoot, 'plugins', 'index.js'); + fs.writeFileSync(alias, legacy.content); + const before = fs.readFileSync(value.installStatePath); + const operationsBefore = value.basePlan.operations.map(operation => fs.readFileSync(operation.destinationPath)); + assert.ok(!readInstallState(value.installStatePath).operations.some(operation => operation.destinationPath === alias)); + if (mode === 'doctor') { + const result = buildDoctorReport({ repoRoot: value.sourceRoot, homeDir: value.homeDir, + projectRoot: value.homeDir, targets: ['opencode'] }).results[0]; + const issue = result.issues.find(entry => entry.code === 'opencode-hook-consent-violation'); + assert.ok(issue, JSON.stringify(result.issues)); + assert.match(issue.message, /OpenCode hook activation remains active/); + } else { + const result = repair(value).results[0]; + assert.strictEqual(result.status, 'error'); + assert.match(result.error, /Refusing OpenCode hook deactivation/); + assert.notStrictEqual(result.stateRefreshed, true); + } + assert.strictEqual(fs.readFileSync(alias, 'utf8'), legacy.content); + assert.deepStrictEqual(fs.readFileSync(value.installStatePath), before); + assert.deepStrictEqual(value.basePlan.operations.map(operation => fs.readFileSync(operation.destinationPath)), operationsBefore); + assert.strictEqual(fs.existsSync(`${value.installStatePath}.ecc.lock`), false); + })); + } + } + for (const mode of ['apply', 'repair']) { + test(`${mode} refuses a historical alias inserted after preflight before state refresh`, () => fixture(value => { + const destination = path.join(value.targetRoot, 'plugins', 'ecc-hooks.ts'); + const alias = path.join(value.targetRoot, 'plugins', 'index.js'); + const content = legacyPluginFixtures[0].content; + withWritableOpenMutation(destination, () => fs.writeFileSync(alias, content), () => { + if (mode === 'apply') assert.throws(() => applyInstallPlan(value.declinePlan), /OpenCode hook activation remains active/); + else { + const result = repair(value).results[0]; + assert.strictEqual(result.status, 'error'); + assert.match(result.error, /OpenCode hook activation remains active/); + assert.notStrictEqual(result.stateRefreshed, true); + } + assert.strictEqual(fs.readFileSync(alias, 'utf8'), content); + const state = readInstallState(value.installStatePath); + assert.ok(!state.operations.some(operation => operation.destinationPath === alias)); + assert.strictEqual(state.request.hookConsent, value.state.request.hookConsent); + assert.strictEqual(fs.existsSync(`${value.installStatePath}.ecc.lock`), false); + }); + })); + } + 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`; + withWritableOpenMutation(destination, () => { + fs.writeFileSync(destination, '{"userEdit":true}'); + fs.renameSync(lock, `${lock}.owned`); + fs.writeFileSync(lock, 'replacement lock'); + }, () => { + const result = repair(value).results[0]; + assert.strictEqual(result.status, 'error'); + assert.match(result.error, /changed after preflight/i); + assert.match(result.releaseError, /changed OpenCode install lock/); + assert.strictEqual(fs.readFileSync(lock, 'utf8'), 'replacement lock'); + assertPriorOwnership(value, destination); + }); + })); + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + return { passed, failed }; +} + +if (require.main === module) process.exitCode = runTests().failed ? 1 : 0; +module.exports = { runTests }; diff --git a/tests/lib/opencode-install-lock.test.js b/tests/lib/opencode-install-lock.test.js new file mode 100644 index 000000000..defa39598 --- /dev/null +++ b/tests/lib/opencode-install-lock.test.js @@ -0,0 +1,424 @@ +/** Cooperative OpenCode installation lock scopes, without child processes. */ +'use strict'; +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { acquireOpenCodeInstallLocks, withOpenCodeInstallLocks } = require('../../scripts/lib/install/opencode-install-lock'); +const { acquireSettingsLock, getSettingsLockIdentity, sameFileIdentity } = require('../../scripts/lib/install/claude-settings-lock'); +let passed = 0; +let failed = 0; +const lockPath = root => path.join(root, 'ecc-install-state.json.ecc.lock'); +function test(name, callback) { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-opencode-lock-'))); + try { callback(root); passed++; console.log(` PASS ${name}`); } + catch (error) { failed++; console.error(` FAIL ${name}: ${error.stack}`); } + finally { fs.rmSync(root, { recursive: true, force: true }); } +} +test('same target refuses an independent writer and releases on success', root => { + assert.strictEqual(withOpenCodeInstallLocks([root], lease => { + assert.ok(Object.isFrozen(lease)); + assert.ok(fs.existsSync(lockPath(root))); + assert.throws(() => acquireOpenCodeInstallLocks([root]), /Another ECC process.*OpenCode/); + return 'done'; + }), 'done'); + assert.strictEqual(fs.existsSync(lockPath(root)), false); +}); +test('different roots can be held independently', root => { + const other = path.join(root, 'other'); + withOpenCodeInstallLocks([root], () => withOpenCodeInstallLocks([other], () => { + assert.ok(fs.existsSync(lockPath(root)) && fs.existsSync(lockPath(other))); + })); + assert.strictEqual(fs.existsSync(lockPath(other)), false); +}); +test('roots are deduplicated, sorted and released in reverse order', root => { + const a = path.join(root, 'a'); + const b = path.join(root, 'b'); + const linked = []; + const renamed = []; + const originalLink = fs.linkSync; + const originalRename = fs.renameSync; + fs.linkSync = (from, to) => { linked.push(to); return originalLink(from, to); }; + fs.renameSync = (from, to) => { renamed.push(from); return originalRename(from, to); }; + try { withOpenCodeInstallLocks([b, a, b], () => {}); } + finally { fs.linkSync = originalLink; fs.renameSync = originalRename; } + assert.deepStrictEqual(linked, [lockPath(a), lockPath(b)]); + assert.deepStrictEqual(renamed, [lockPath(b), lockPath(a)]); +}); +test('nested use requires a real active lease with full coverage', root => { + const holder = acquireOpenCodeInstallLocks([root]); + const nested = acquireOpenCodeInstallLocks([root], holder.lease); + assert.strictEqual(nested.lease, holder.lease); + nested.release(); + assert.ok(fs.existsSync(lockPath(root))); + assert.throws(() => acquireOpenCodeInstallLocks([path.join(root, 'missing')], holder.lease), /cover/); + assert.throws(() => acquireOpenCodeInstallLocks([root], true), /lease/); + assert.throws(() => acquireOpenCodeInstallLocks([root], {}), /lease/); + holder.release(); + holder.release(); + assert.throws(() => acquireOpenCodeInstallLocks([root], holder.lease), /lease/); +}); +test('callback error retains identity and releases', root => { + const primary = new Error('callback failure'); + assert.throws(() => withOpenCodeInstallLocks([root], () => { throw primary; }), error => error === primary); + assert.strictEqual(fs.existsSync(lockPath(root)), false); +}); +test('second lock failure releases the first without disturbing the other owner', root => { + const a = path.join(root, 'a'); + const b = path.join(root, 'b'); + const other = acquireOpenCodeInstallLocks([b]); + const bytes = fs.readFileSync(lockPath(b)); + try { assert.throws(() => acquireOpenCodeInstallLocks([b, a]), /Another ECC process/); } + finally { assert.deepStrictEqual(fs.readFileSync(lockPath(b)), bytes); other.release(); } + assert.strictEqual(fs.existsSync(lockPath(a)), false); +}); +test('a replaced lock invalidates nested reuse and is preserved on release', root => { + const holder = acquireOpenCodeInstallLocks([root]); + fs.renameSync(lockPath(root), `${lockPath(root)}.owned`); + fs.writeFileSync(lockPath(root), 'replacement'); + assert.throws(() => acquireOpenCodeInstallLocks([root], holder.lease), /changed/); + assert.throws(() => holder.release(), /changed/); + assert.strictEqual(fs.readFileSync(lockPath(root), 'utf8'), 'replacement'); +}); +test('release failure is attached without hiding the callback failure', root => { + const primary = new Error('primary'); + assert.throws(() => withOpenCodeInstallLocks([root], () => { + fs.renameSync(lockPath(root), `${lockPath(root)}.owned`); + fs.writeFileSync(lockPath(root), 'replacement'); + throw primary; + }), error => error === primary && /changed/.test(error.releaseError.message)); + assert.strictEqual(fs.readFileSync(lockPath(root), 'utf8'), 'replacement'); +}); +test('invalid root and non-file lock paths refuse before callback', root => { + for (const roots of [null, ['relative'], [true], ['']]) { + assert.throws(() => acquireOpenCodeInstallLocks(roots), /root/); + } + fs.mkdirSync(lockPath(root)); + assert.throws(() => withOpenCodeInstallLocks([root], () => assert.fail('must refuse')), /lock/); +}); +test('lock engine still recovers a stale malformed lock', root => { + fs.writeFileSync(lockPath(root), 'stale'); + const old = new Date(Date.now() - 600000); + fs.utimesSync(lockPath(root), old, old); + withOpenCodeInstallLocks([root], () => assert.match(fs.readFileSync(lockPath(root), 'utf8'), /"pid"/)); + assert.strictEqual(fs.existsSync(lockPath(root)), false); +}); +test('a root or lock reported as a symlink refuses without callback or outside access', root => { + for (const target of [root, lockPath(root)]) { + if (target !== root) fs.writeFileSync(target, 'unrelated target'); + const original = fs.lstatSync; + fs.lstatSync = (...args) => { + const stats = original(...args); + if (args[0] === target) { + const symlink = Object.create(stats); + symlink.isSymbolicLink = () => true; + return symlink; + } + return stats; + }; + try { + assert.throws(() => withOpenCodeInstallLocks([root], () => assert.fail('must refuse')), /symlink/); + } finally { fs.lstatSync = original; } + if (target !== root) assert.strictEqual(fs.readFileSync(target, 'utf8'), 'unrelated target'); + } +}); +test('permission errors remain permission errors', root => { + const original = fs.lstatSync; + const denied = Object.assign(new Error('permission denied'), { code: 'EACCES' }); + fs.lstatSync = (...args) => { if (args[0] === lockPath(root)) throw denied; return original(...args); }; + try { assert.throws(() => acquireOpenCodeInstallLocks([root]), error => error === denied); } + finally { fs.lstatSync = original; } + assert.strictEqual(fs.existsSync(lockPath(root)), false); +}); +test('releasing all roots continues after one replaced lock and rejects later lease reuse', root => { + const a = path.join(root, 'a'); + const b = path.join(root, 'b'); + const holder = acquireOpenCodeInstallLocks([a, b]); + fs.renameSync(lockPath(b), `${lockPath(b)}.owned`); + fs.writeFileSync(lockPath(b), 'replacement'); + assert.throws(() => holder.release(), /changed/); + assert.strictEqual(fs.existsSync(lockPath(a)), false); + assert.strictEqual(fs.readFileSync(lockPath(b), 'utf8'), 'replacement'); + assert.throws(() => acquireOpenCodeInstallLocks([a], holder.lease), /lease/); +}); +test('a root replaced during a held scope is preserved while other roots release and the lease expires', root => { + const a = path.join(root, 'a'); + const b = path.join(root, 'b'); + const movedRoot = path.join(root, 'b-original'); + const replacementState = path.join(b, 'ecc-install-state.json'); + let heldLease; + let originalLockBytes; + assert.throws(() => withOpenCodeInstallLocks([a, b], lease => { + heldLease = lease; + originalLockBytes = fs.readFileSync(lockPath(b)); + fs.renameSync(b, movedRoot); + fs.mkdirSync(b); + fs.writeFileSync(lockPath(b), 'replacement root lock'); + fs.writeFileSync(replacementState, 'replacement root state'); + return 'release must refuse instead of returning this result'; + }), /Refusing changed OpenCode install lock root/); + assert.strictEqual(fs.existsSync(lockPath(a)), false); + assert.strictEqual(fs.readFileSync(lockPath(b), 'utf8'), 'replacement root lock'); + assert.strictEqual(fs.readFileSync(replacementState, 'utf8'), 'replacement root state'); + assert.deepStrictEqual(fs.readdirSync(b).sort(), ['ecc-install-state.json', 'ecc-install-state.json.ecc.lock']); + assert.deepStrictEqual(fs.readFileSync(lockPath(movedRoot)), originalLockBytes); + assert.throws(() => acquireOpenCodeInstallLocks([a, b], heldLease), /Invalid or inactive OpenCode install lease/); + withOpenCodeInstallLocks([a], () => assert.ok(fs.existsSync(lockPath(a)))); + assert.strictEqual(fs.existsSync(lockPath(a)), false); +}); +test('an absent callback is rejected before a lock is created', root => { + assert.throws(() => withOpenCodeInstallLocks([root], null), /callback/); + assert.strictEqual(fs.existsSync(lockPath(root)), false); +}); +test('a replacement immediately after lock publication cannot enter the protected callback', root => { + const target = lockPath(root); + const originalLink = fs.linkSync; + let callbacks = 0; + let replacements = 0; + fs.linkSync = (from, to) => { + originalLink(from, to); + if (to === target) { + replacements++; + fs.renameSync(target, `${target}.owned`); + fs.writeFileSync(target, 'replacement at acquisition boundary'); + } + }; + try { + assert.throws(() => withOpenCodeInstallLocks([root], () => { callbacks++; }), /changed .*lock/); + } finally { fs.linkSync = originalLink; } + assert.strictEqual(replacements, 1); + assert.strictEqual(callbacks, 0, 'A pathname replacement must never become the acquired identity'); + assert.strictEqual(fs.readFileSync(target, 'utf8'), 'replacement at acquisition boundary'); + assert.strictEqual(JSON.parse(fs.readFileSync(`${target}.owned`, 'utf8')).pid, process.pid); +}); +test('a transient post-acquisition validation failure releases authentic locks and preserves the primary error', root => { + const a = path.join(root, 'a'); + const b = path.join(root, 'b'); + const primary = Object.assign(new Error('one-shot validation error'), { code: 'EIO' }); + const originalLink = fs.linkSync; + const originalStat = fs.lstatSync; + let published = false; + let faults = 0; + let callbacks = 0; + let caught; + fs.linkSync = (from, to) => { + originalLink(from, to); + if (to === lockPath(b)) published = true; + }; + fs.lstatSync = (...args) => { + if (published && args[0] === b && faults === 0) { faults++; throw primary; } + return originalStat(...args); + }; + try { + try { withOpenCodeInstallLocks([a, b], () => { callbacks++; }); } + catch (error) { caught = error; } + } finally { fs.linkSync = originalLink; fs.lstatSync = originalStat; } + assert.strictEqual(faults, 1); + assert.strictEqual(callbacks, 0); + assert.strictEqual(caught, primary); + assert.strictEqual(caught.releaseError, undefined, 'Cleanup must not dereference a missing identity'); + assert.strictEqual(fs.existsSync(lockPath(a)), false); + assert.strictEqual(fs.existsSync(lockPath(b)), false); +}); +test('engine ownership metadata is immutable and derived from the acquired descriptor', root => { + const settingsPath = path.join(root, 'settings.json'); + const release = acquireSettingsLock(settingsPath); + try { + const identity = getSettingsLockIdentity(release); + assert.ok(Object.isFrozen(identity)); + assert.deepStrictEqual(Object.keys(identity).sort(), ['dev', 'ino']); + assert.ok(sameFileIdentity(identity, fs.lstatSync(`${settingsPath}.ecc.lock`, { bigint: true }))); + assert.throws(() => { identity.ino = 0n; }, TypeError); + assert.throws(() => getSettingsLockIdentity(() => {}), /identity/); + } finally { release(); } + assert.strictEqual(fs.existsSync(`${settingsPath}.ecc.lock`), false); +}); + +function captureThrown(callback) { + try { return { didThrow: false, result: callback() }; } + catch (value) { return { didThrow: true, value }; } +} + +const falsyThrownValues = [undefined, null, false, 0, '', NaN]; +for (const [index, primary] of falsyThrownValues.entries()) { + for (const cleanupFails of [false, true]) { + test(`falsy callback value ${index} survives ${cleanupFails ? 'failed' : 'healthy'} cleanup`, root => { + let lease; + const caught = captureThrown(() => withOpenCodeInstallLocks([root], active => { + lease = active; + if (cleanupFails) { + fs.renameSync(lockPath(root), `${lockPath(root)}.owned`); + fs.writeFileSync(lockPath(root), 'replacement'); + } + throw primary; + })); + assert.strictEqual(caught.didThrow, true, 'A thrown falsy value must not become success'); + assert.ok(Object.is(caught.value, primary), 'Preserve the exact thrown value, including NaN'); + assert.throws(() => acquireOpenCodeInstallLocks([root], lease), /inactive/); + if (cleanupFails) { + assert.strictEqual(fs.readFileSync(lockPath(root), 'utf8'), 'replacement'); + assert.ok(fs.existsSync(`${lockPath(root)}.owned`)); + } else assert.strictEqual(fs.existsSync(lockPath(root)), false); + }); + } +} + +for (const kind of ['frozen', 'nonextensible', 'nonwritable', 'accessor', 'proxy', 'primitive']) { + test(`${kind} callback primary survives cleanup failure without invoking accessors`, root => { + const a = path.join(root, 'a'); + const b = path.join(root, 'b'); + const marker = new Error('preexisting release diagnostic'); + let accesses = 0; + let definitions = 0; + let primary = new Error('primary'); + if (kind === 'frozen') Object.freeze(primary); + if (kind === 'nonextensible') Object.preventExtensions(primary); + if (kind === 'nonwritable') Object.defineProperty(primary, 'releaseError', { value: marker }); + if (kind === 'accessor') Object.defineProperty(primary, 'releaseError', { + configurable: true, + get() { accesses++; throw new Error('getter must not run'); }, + set() { accesses++; throw new Error('setter must not run'); } + }); + if (kind === 'proxy') primary = new Proxy(primary, { defineProperty() { + definitions++; + assert.strictEqual(fs.existsSync(lockPath(a)), false, 'All safe cleanup precedes annotation'); + throw new Error('annotation rejected'); + } }); + if (kind === 'primitive') primary = 'literal primary'; + let lease; + const caught = captureThrown(() => withOpenCodeInstallLocks([a, b], active => { + lease = active; + fs.renameSync(lockPath(b), `${lockPath(b)}.owned`); + fs.writeFileSync(lockPath(b), 'replacement'); + throw primary; + })); + assert.strictEqual(caught.didThrow, true); + assert.ok(Object.is(caught.value, primary)); + assert.strictEqual(accesses, 0); + if (kind === 'proxy') assert.strictEqual(definitions, 1); + if (kind === 'nonwritable') assert.strictEqual(primary.releaseError, marker); + if (kind === 'accessor') { + const descriptor = Object.getOwnPropertyDescriptor(primary, 'releaseError'); + assert.ok('value' in descriptor, 'Diagnostic should be an own data property'); + assert.match(descriptor.value.message, /changed/); + } + assert.strictEqual(fs.existsSync(lockPath(a)), false); + assert.strictEqual(fs.readFileSync(lockPath(b), 'utf8'), 'replacement'); + assert.ok(fs.existsSync(`${lockPath(b)}.owned`)); + assert.throws(() => acquireOpenCodeInstallLocks([a], lease), /inactive/); + }); +} + +for (const kind of ['mutable', 'frozen', 'frozen array', 'non-array', 'readonly', 'accessor', 'proxy']) { + test(`three-root cleanup preserves ${kind} first failure and finishes reverse cleanup`, root => { + const roots = ['a', 'b', 'c'].map(name => path.join(root, name)); + const [a, b, c] = roots; + const originalRename = fs.renameSync; + const secondary = new Error('second cleanup failure'); + const priorDiagnostic = new Error('prior diagnostic'); + const originalArray = Object.freeze([priorDiagnostic]); + let accesses = 0; + let primary = new Error('first cleanup failure'); + if (kind === 'frozen') Object.freeze(primary); + if (kind === 'frozen array') primary.releaseErrors = originalArray; + if (kind === 'non-array') primary.releaseErrors = { push() { accesses++; throw new Error('caller push must not run'); } }; + if (kind === 'readonly') Object.defineProperty(primary, 'releaseErrors', { value: originalArray }); + if (kind === 'accessor') Object.defineProperty(primary, 'releaseErrors', { + get() { accesses++; throw new Error('caller getter must not run'); }, + set() { accesses++; throw new Error('caller setter must not run'); } + }); + if (kind === 'proxy') primary = new Proxy(primary, { defineProperty() { + assert.strictEqual(fs.existsSync(lockPath(a)), false, 'Finish cleanup before a diagnostic trap'); + throw new Error('diagnostic trap'); + } }); + const holder = acquireOpenCodeInstallLocks(roots); + const attempts = []; + fs.renameSync = (from, to) => { + if (roots.some(candidate => lockPath(candidate) === from)) attempts.push(from); + if (from === lockPath(c)) throw primary; + if (from === lockPath(b)) throw secondary; + return originalRename(from, to); + }; + let caught; + try { caught = captureThrown(() => holder.release()); } + finally { fs.renameSync = originalRename; } + assert.strictEqual(caught.didThrow, true); + assert.strictEqual(caught.value, primary); + assert.deepStrictEqual(attempts, [lockPath(c), lockPath(b), lockPath(a)]); + assert.strictEqual(fs.existsSync(lockPath(a)), false); + assert.ok(fs.existsSync(lockPath(b)) && fs.existsSync(lockPath(c)), 'Failed releases must not be claimed removed'); + assert.strictEqual(accesses, 0); + assert.deepStrictEqual(originalArray, [priorDiagnostic], 'Never mutate a caller-owned diagnostics array'); + if (['mutable', 'frozen array', 'non-array'].includes(kind)) { + assert.deepStrictEqual(primary.releaseErrors, [secondary]); + assert.notStrictEqual(primary.releaseErrors, originalArray); + } + if (kind === 'readonly') assert.strictEqual(primary.releaseErrors, originalArray); + assert.throws(() => acquireOpenCodeInstallLocks([a], holder.lease), /inactive/); + }); +} + +for (const [index, primary] of falsyThrownValues.entries()) { + test(`falsy first cleanup value ${index} is thrown after all remaining roots are attempted`, root => { + const roots = ['a', 'b', 'c'].map(name => path.join(root, name)); + const [a, b, c] = roots; + const originalRename = fs.renameSync; + const secondary = new Error('second cleanup failure'); + const attempts = []; + let lease; + let caught; + fs.renameSync = (from, to) => { + if (roots.some(candidate => lockPath(candidate) === from)) attempts.push(from); + if (from === lockPath(c)) throw primary; + if (from === lockPath(b)) throw secondary; + return originalRename(from, to); + }; + try { caught = captureThrown(() => withOpenCodeInstallLocks(roots, active => { lease = active; return 'success'; })); } + finally { fs.renameSync = originalRename; } + assert.strictEqual(caught.didThrow, true); + assert.ok(Object.is(caught.value, primary)); + assert.deepStrictEqual(attempts, [lockPath(c), lockPath(b), lockPath(a)]); + assert.strictEqual(fs.existsSync(lockPath(a)), false); + assert.ok(fs.existsSync(lockPath(b)) && fs.existsSync(lockPath(c))); + assert.throws(() => acquireOpenCodeInstallLocks([a], lease), /inactive/); + }); +} + +test('frozen acquisition failure survives rollback while replaced ownership is preserved', root => { + const roots = ['a', 'b', 'c'].map(name => path.join(root, name)); + const [a, b, c] = roots; + const primary = Object.freeze(new Error('one-shot acquisition validation failure')); + const originalLink = fs.linkSync; + const originalStat = fs.lstatSync; + const originalRename = fs.renameSync; + let published = false; + let faults = 0; + let callbacks = 0; + const checked = []; + fs.linkSync = (from, to) => { originalLink(from, to); if (to === lockPath(c)) published = true; }; + fs.lstatSync = (...args) => { + if (published && args[0] === c && faults === 0) { + faults++; + originalRename(lockPath(b), `${lockPath(b)}.owned`); + fs.writeFileSync(lockPath(b), 'replacement during rollback'); + throw primary; + } + if (faults > 0 && roots.includes(args[0]) && args[1]?.bigint) checked.push(args[0]); + return originalStat(...args); + }; + let caught; + try { caught = captureThrown(() => withOpenCodeInstallLocks(roots, () => { callbacks++; })); } + finally { fs.linkSync = originalLink; fs.lstatSync = originalStat; } + assert.strictEqual(caught.didThrow, true); + assert.strictEqual(caught.value, primary); + assert.strictEqual(faults, 1); + assert.strictEqual(callbacks, 0); + assert.deepStrictEqual(checked, [c, b, a]); + assert.strictEqual(fs.existsSync(lockPath(a)), false); + assert.strictEqual(fs.existsSync(lockPath(c)), false); + assert.strictEqual(fs.readFileSync(lockPath(b), 'utf8'), 'replacement during rollback'); + assert.ok(fs.existsSync(`${lockPath(b)}.owned`)); +}); + +console.log(`Results: Passed: ${passed}, Failed: ${failed}`); +process.exitCode = failed ? 1 : 0;