diff --git a/scripts/lib/install/antigravity-agent.js b/scripts/lib/install/antigravity-agent.js index e5e4a1883..7e4f7a962 100644 --- a/scripts/lib/install/antigravity-agent.js +++ b/scripts/lib/install/antigravity-agent.js @@ -1,7 +1,5 @@ 'use strict'; -const yaml = require('js-yaml'); - const TOOL_NAMES = Object.freeze({ Read: 'view_file', Write: 'write_to_file', @@ -25,7 +23,10 @@ function splitFrontmatter(source, label) { throw new Error(`Cannot adapt Antigravity agent ${label}: missing YAML frontmatter`); } - const frontmatter = yaml.load(match[1]); + // Keep YAML loading behind the transform boundary. Public help commands load + // the installer graph without executing a transform, including in hermetic + // packed-artifact checks where runtime dependencies are intentionally absent. + const frontmatter = require('js-yaml').load(match[1]); if (!frontmatter || typeof frontmatter !== 'object' || Array.isArray(frontmatter)) { throw new Error(`Cannot adapt Antigravity agent ${label}: frontmatter must be an object`); } @@ -56,7 +57,7 @@ function adaptAntigravityAgent(source, label = '') { ? MODEL_NAMES[frontmatter.model] : frontmatter.model; } - const serialized = yaml + const serialized = require('js-yaml') .dump(adapted, { lineWidth: -1, noRefs: true }) .trimEnd(); return `---\n${serialized}\n---\n${body}`; diff --git a/scripts/lib/install/apply.js b/scripts/lib/install/apply.js index 24627e6ed..682341856 100644 --- a/scripts/lib/install/apply.js +++ b/scripts/lib/install/apply.js @@ -68,15 +68,27 @@ function readJsonObject(filePath, label) { return parsed; } -function stateWithContentDigests(state) { +function stateWithContentDigests(state, plan) { return { ...state, operations: (state.operations || []).map(operation => { - if ( - !operation.destinationPath - || !fs.existsSync(operation.destinationPath) - || !fs.statSync(operation.destinationPath).isFile() - ) { + if (!operation.destinationPath) { + return { ...operation }; + } + if (plan) { + assertSafeInstallOperation(plan, operation); + assertSafeClaudeSkillOperation(plan, operation); + } + let destinationStat; + try { + destinationStat = fs.lstatSync(operation.destinationPath); + } catch (error) { + if (error.code === 'ENOENT') { + return { ...operation }; + } + throw error; + } + if (!destinationStat.isFile() || destinationStat.isSymbolicLink()) { return { ...operation }; } return { @@ -310,98 +322,116 @@ function applyInstallPlan(plan, dependencies = {}) { persistInstallState(plan.installStatePath, migration.bridgeState); } - for (const operation of appliedPlan.operations) { - assertSafeInstallOperation(appliedPlan, operation); - assertSafeClaudeSkillOperation(appliedPlan, operation); - fs.mkdirSync(path.dirname(operation.destinationPath), { recursive: true }); - // Recheck directories that were absent during the first validation. This - // narrows the symlink-swap window around mkdirSync, but path checks cannot - // eliminate a later TOCTOU race before the file write. - assertSafeInstallOperation(appliedPlan, operation); - assertSafeClaudeSkillOperation(appliedPlan, operation); - if (typeof beforeOperationWrite === 'function') { - beforeOperationWrite({ plan: appliedPlan, operation }); - } - - if (operation.kind === 'merge-json') { - const payload = cloneJsonValue(operation.mergePayload); - if (payload === undefined) { - throw new Error(`Missing merge payload for ${operation.destinationPath}`); + let finalState; + try { + for (const operation of appliedPlan.operations) { + assertSafeInstallOperation(appliedPlan, operation); + assertSafeClaudeSkillOperation(appliedPlan, operation); + fs.mkdirSync(path.dirname(operation.destinationPath), { recursive: true }); + // Recheck directories that were absent during the first validation. This + // narrows the symlink-swap window around mkdirSync, but path checks cannot + // eliminate a later TOCTOU race before the file write. + assertSafeInstallOperation(appliedPlan, operation); + assertSafeClaudeSkillOperation(appliedPlan, operation); + if (typeof beforeOperationWrite === 'function') { + beforeOperationWrite({ plan: appliedPlan, operation }); } - const filteredPayload = ( - isMcpConfigPath(operation.destinationPath) && disabledServers.length > 0 - ) - ? filterMcpConfig(payload, disabledServers).config - : payload; + if (operation.kind === 'merge-json') { + const payload = cloneJsonValue(operation.mergePayload); + if (payload === undefined) { + throw new Error(`Missing merge payload for ${operation.destinationPath}`); + } - const currentValue = fs.existsSync(operation.destinationPath) - ? readJsonObject(operation.destinationPath, 'existing JSON config') - : {}; - const mergedValue = deepMergeJson(currentValue, filteredPayload); - fs.writeFileSync(operation.destinationPath, formatJson(mergedValue), 'utf8'); - continue; - } + const filteredPayload = ( + isMcpConfigPath(operation.destinationPath) && disabledServers.length > 0 + ) + ? filterMcpConfig(payload, disabledServers).config + : payload; - if (operation.kind === 'copy-file' && isMcpConfigPath(operation.destinationPath) && disabledServers.length > 0) { - const sourceConfig = readJsonObject(operation.sourcePath, 'MCP config'); - const filteredConfig = filterMcpConfig(sourceConfig, disabledServers).config; - fs.writeFileSync(operation.destinationPath, formatJson(filteredConfig), 'utf8'); - continue; - } + const currentValue = fs.existsSync(operation.destinationPath) + ? readJsonObject(operation.destinationPath, 'existing JSON config') + : {}; + const mergedValue = deepMergeJson(currentValue, filteredPayload); + fs.writeFileSync(operation.destinationPath, formatJson(mergedValue), 'utf8'); + continue; + } - // Declared transforms are part of the install contract and always apply. - // Markdown link rewriting is additive when the plan has a usable index. - const needsLinkRewrite = Boolean( - linkIndex - && operation.sourceRelativePath - && isMarkdownPath(operation.destinationPath) - ); - if (operation.kind === 'copy-file' && (operation.contentTransform || needsLinkRewrite)) { - const transformed = transformInstallContent( - operation, - fs.readFileSync(operation.sourcePath, 'utf8') + if (operation.kind === 'copy-file' && isMcpConfigPath(operation.destinationPath) && disabledServers.length > 0) { + const sourceConfig = readJsonObject(operation.sourcePath, 'MCP config'); + const filteredConfig = filterMcpConfig(sourceConfig, disabledServers).config; + fs.writeFileSync(operation.destinationPath, formatJson(filteredConfig), 'utf8'); + continue; + } + + // Declared transforms are part of the install contract and always apply. + // Markdown link rewriting is additive when the plan has a usable index. + const needsLinkRewrite = Boolean( + linkIndex + && operation.sourceRelativePath + && isMarkdownPath(operation.destinationPath) ); - const installedContent = needsLinkRewrite - ? rewriteRelativeLinks(transformed, { - sourceRel: operation.sourceRelativePath, - index: linkIndex, - }) - : transformed; - fs.writeFileSync(operation.destinationPath, installedContent, 'utf8'); - continue; + if (operation.kind === 'copy-file' && (operation.contentTransform || needsLinkRewrite)) { + const transformed = transformInstallContent( + operation, + fs.readFileSync(operation.sourcePath, 'utf8') + ); + const installedContent = needsLinkRewrite + ? rewriteRelativeLinks(transformed, { + sourceRel: operation.sourceRelativePath, + index: linkIndex, + }) + : transformed; + fs.writeFileSync(operation.destinationPath, installedContent, 'utf8'); + continue; + } + + fs.copyFileSync(operation.sourcePath, operation.destinationPath); } - fs.copyFileSync(operation.sourcePath, operation.destinationPath); - } - - if (resolvedClaudeHooksPlan) { - assertSafeInstallOperation(appliedPlan, resolvedClaudeHooksPlan.hooksOperation); - fs.mkdirSync(path.dirname(resolvedClaudeHooksPlan.hooksDestinationPath), { recursive: true }); - assertSafeInstallOperation(appliedPlan, resolvedClaudeHooksPlan.hooksOperation); - if (typeof beforeOperationWrite === 'function') { - beforeOperationWrite({ plan: appliedPlan, operation: resolvedClaudeHooksPlan.hooksOperation }); + if (resolvedClaudeHooksPlan) { + assertSafeInstallOperation(appliedPlan, resolvedClaudeHooksPlan.hooksOperation); + fs.mkdirSync(path.dirname(resolvedClaudeHooksPlan.hooksDestinationPath), { recursive: true }); + assertSafeInstallOperation(appliedPlan, resolvedClaudeHooksPlan.hooksOperation); + if (typeof beforeOperationWrite === 'function') { + beforeOperationWrite({ plan: appliedPlan, operation: resolvedClaudeHooksPlan.hooksOperation }); + } + fs.writeFileSync( + resolvedClaudeHooksPlan.hooksDestinationPath, + JSON.stringify(resolvedClaudeHooksPlan.resolvedHooksConfig, null, 2) + '\n', + 'utf8' + ); } - fs.writeFileSync( - resolvedClaudeHooksPlan.hooksDestinationPath, - JSON.stringify(resolvedClaudeHooksPlan.resolvedHooksConfig, null, 2) + '\n', - 'utf8' - ); - } - if (hasLegacyMigration) { - removeLegacyClaudeSkillFiles(migration, plan.targetRoot); - } + if (hasLegacyMigration) { + removeLegacyClaudeSkillFiles(migration, plan.targetRoot); + } - if (shouldSetClaudeCommitAttributionPreference(appliedPlan)) { - writeClaudeCommitAttributionPreference(path.join(plan.targetRoot, 'settings.json')); - } + if (shouldSetClaudeCommitAttributionPreference(appliedPlan)) { + writeClaudeCommitAttributionPreference(path.join(plan.targetRoot, 'settings.json')); + } - const finalState = stateWithContentDigests(migration.finalState); - if (typeof beforeInstallStateWrite === 'function') { - beforeInstallStateWrite({ plan: appliedPlan, state: finalState }); + finalState = stateWithContentDigests(migration.finalState, appliedPlan); + if (typeof beforeInstallStateWrite === 'function') { + beforeInstallStateWrite({ plan: appliedPlan, state: finalState }); + } + persistInstallState(plan.installStatePath, finalState); + } catch (error) { + if (migration.requiresBridgeState) { + try { + // The bridge was committed before any writes. Refresh it with hashes of + // files that now exist so uninstall can remove only bytes this attempt + // actually installed while preserving user changes. + persistInstallState( + plan.installStatePath, + stateWithContentDigests(migration.bridgeState, appliedPlan) + ); + } catch (checkpointError) { + error.message += ` Install-state checkpoint also failed: ${checkpointError.message}`; + } + } + throw error; } - persistInstallState(plan.installStatePath, finalState); let antigravityMigrationWarnings = []; try { const antigravityMigration = cleanupLegacyAntigravityInstall(appliedPlan); diff --git a/tests/lib/install-lifecycle.test.js b/tests/lib/install-lifecycle.test.js index 04ad9a451..132221401 100644 --- a/tests/lib/install-lifecycle.test.js +++ b/tests/lib/install-lifecycle.test.js @@ -2101,14 +2101,18 @@ function runTests() { canonicalDestinationPath = fs.realpathSync(destinationPath); writeCursorState(projectRoot, { operations: [ - managedOperation('copy-file', destinationPath, { strategy: 'copy-file' }), + managedOperation('copy-file', destinationPath, { + strategy: 'copy-file', + contentSha256: '0'.repeat(64), + }), ], }); fs.openSync = function openSyncWithLateParentSwap(filePath, flags, mode) { - const isDestinationWrite = path.resolve(filePath) === canonicalDestinationPath + const writeFlags = fs.constants.O_WRONLY | fs.constants.O_RDWR; + const isDestinationWrite = path.resolve(String(filePath)) === canonicalDestinationPath && typeof flags === 'number' - && (flags & fs.constants.O_WRONLY) === fs.constants.O_WRONLY; + && (flags & writeFlags) !== 0; if (!insertedSymlink && isDestinationWrite) { fs.renameSync(destinationParent, backupParent); fs.symlinkSync(