mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-29 04:55:12 +02:00
Merge reviewed contributor PR #3008 into backlog batch
Source-PR: https://github.com/affaan-m/ECC/pull/3008
Source-Head: e9911d6668
Local integration checkpoint; aggregate review and hosted acceptance pending.
This commit is contained in:
+374
-296
@@ -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 };
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
@@ -393,6 +393,7 @@ function cleanupLegacyOpencodeInstall(plan) {
|
||||
module.exports = {
|
||||
cleanupLegacyOpencodeInstall,
|
||||
getLegacyOpencodeLocation,
|
||||
getLegacyLocationForPlan,
|
||||
inspectLegacyOpencodeState,
|
||||
removeVerifiedLegacyFile,
|
||||
};
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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-');
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user