Files
ECC/scripts/lib/multi-harness-setup.js
T
28e53a0bc1 feat(install): add guided multi-harness installer (#2649)
* feat(install): add guided Claude plugin setup

* fix: support Claude command shims on Windows

* feat: support safe Claude plugin scope migration

* fix(install): preserve interactive setup terminal

* fix(install): auto-migrate setup scope changes

* feat(install): add guided multi-harness installer

* fix(install): sync Yarn binary metadata

* fix(install): handle wizard EOF on Node 18

* ci: allow installer matrix tests to finish

* test(install): allow slower PowerShell delegation

* fix(install): harden guided provider reconciliation

* test(install): harden packaged and local compatibility

* chore: prepare guided installer release 2.2.0

* fix(install): report refreshed Codex marketplace state

* fix(install): verify managed content provenance

* test(install): allow empty Yarn smoke fixture

* test(install): invoke Windows package shims safely

* fix(install): close cross-platform release gaps

* fix(install): require trusted GitHub origins

* fix(install): preserve hook profile precedence

* refactor(install): centralize trusted GitHub origins

* ci: retrigger workflow run after merge of main

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 15:39:49 -04:00

445 lines
16 KiB
JavaScript

'use strict';
const fs = require('fs');
const crypto = require('crypto');
const os = require('os');
const path = require('path');
const { assertSafeInstallOperation } = require('./install/apply');
const { assertWithinTrustedRoot, realpathNearestExisting } = require('./path-safety');
const VALID_CLAUDE_SCOPES = new Set(['user', 'project', 'local']);
const VALID_CLAUDE_HOOKS = new Set(['off', 'minimal', 'standard', 'strict']);
const VALID_PROFILES = new Set(['minimal', 'core', 'developer', 'security', 'research', 'full']);
function catalogHelpers() {
return require('./harness-capabilities');
}
function normalizeGuidedInstallRequest(input = {}) {
const { normalizeHarnessSelection } = catalogHelpers();
const harnesses = normalizeHarnessSelection(input.harnesses || []);
if (harnesses.length === 0) {
throw new Error('Choose at least one guided harness: Claude, Codex, or Kimi.');
}
const includesClaude = harnesses.includes('claude');
const includesKimi = harnesses.includes('kimi');
if (!includesClaude && (input.claudeScope !== undefined || input.claudeHooks !== undefined)) {
throw new Error('Claude scope and hook options require Claude to be selected.');
}
if (!includesKimi && input.profile !== undefined) {
throw new Error('The managed install profile requires Kimi to be selected.');
}
const claudeScope = includesClaude ? (input.claudeScope || 'user') : undefined;
const claudeHooks = includesClaude ? (input.claudeHooks || 'standard') : undefined;
const profile = includesKimi ? (input.profile || 'core') : undefined;
if (claudeScope && !VALID_CLAUDE_SCOPES.has(claudeScope)) {
throw new Error(`Invalid Claude scope: ${claudeScope}`);
}
if (claudeHooks && !VALID_CLAUDE_HOOKS.has(claudeHooks)) {
throw new Error(`Invalid Claude hooks preference: ${claudeHooks}`);
}
if (profile && !VALID_PROFILES.has(profile)) {
throw new Error(`Invalid Kimi install profile: ${profile}`);
}
return {
harnesses,
...(claudeHooks ? { claudeHooks } : {}),
...(claudeScope ? { claudeScope } : {}),
dryRun: Boolean(input.dryRun),
json: Boolean(input.json),
...(profile ? { profile } : {}),
yes: Boolean(input.yes),
};
}
function canonicalPath(filePath) {
return realpathNearestExisting(filePath);
}
function pathsMatch(left, right) {
return canonicalPath(left) === canonicalPath(right);
}
function fingerprintFile(filePath) {
if (!fs.existsSync(filePath)) return { exists: false, sha256: null };
return {
exists: true,
sha256: crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'),
};
}
function operationIdentityMatches(stateOperation, plannedOperation) {
return [
'kind',
'moduleId',
'sourceRelativePath',
'strategy',
'scaffoldOnly',
].every(field => stateOperation[field] === plannedOperation[field]);
}
function assertInstallStateUnchanged(plan, expectedFingerprint) {
const currentFingerprint = fingerprintFile(plan.installStatePath);
if (
currentFingerprint.exists !== expectedFingerprint.exists
|| currentFingerprint.sha256 !== expectedFingerprint.sha256
) {
throw new Error(
`Refusing to overwrite an unowned or changed install-state at ${plan.installStatePath}. `
+ 'Re-run the guided preview and review the existing state before retrying.'
);
}
}
function assertPriorInstallStateMatchesPlan(state, plan) {
const target = state.target || {};
const adapter = plan.adapter || {};
if (
target.id !== adapter.id
|| target.target !== adapter.target
|| target.kind !== adapter.kind
) {
throw new Error(
`Refusing to trust managed install-state at ${plan.installStatePath}: `
+ 'target identity does not match the current Kimi install plan.'
);
}
if (!pathsMatch(target.root, plan.targetRoot)) {
throw new Error(
`Refusing to trust managed install-state at ${plan.installStatePath}: `
+ 'recorded root does not match the current install root.'
);
}
if (!pathsMatch(target.installStatePath, plan.installStatePath)) {
throw new Error(
`Refusing to trust managed install-state at ${plan.installStatePath}: `
+ 'recorded install-state path does not match the current install-state path.'
);
}
}
function readOwnedDestinations(plan, dependencies) {
if (!plan.installStatePath) {
return { destinations: new Set(), stateFingerprint: { exists: false, sha256: null } };
}
try {
assertSafeInstallOperation(plan, { destinationPath: plan.installStatePath });
} catch (error) {
throw new Error(`Refusing to trust managed install-state path: ${error.message}`);
}
if (!fs.existsSync(plan.installStatePath)) {
return { destinations: new Set(), stateFingerprint: { exists: false, sha256: null } };
}
const readState = dependencies.readInstallState || require('./install-state').readInstallState;
const initialFingerprint = fingerprintFile(plan.installStatePath);
const state = readState(plan.installStatePath);
const validatedFingerprint = fingerprintFile(plan.installStatePath);
if (
initialFingerprint.exists !== validatedFingerprint.exists
|| initialFingerprint.sha256 !== validatedFingerprint.sha256
) {
throw new Error(
`Refusing to trust install-state that changed during validation: ${plan.installStatePath}.`
);
}
assertPriorInstallStateMatchesPlan(state, plan);
const plannedByDestination = new Map(plan.operations.map(operation => [
canonicalPath(operation.destinationPath),
operation,
]));
const destinations = new Set();
for (const operation of state.operations || []) {
if (operation.ownership !== 'managed') {
throw new Error(
`Refusing to trust non-managed ownership from install-state at ${plan.installStatePath}.`
);
}
const destinationPath = operation.destinationPath;
assertWithinTrustedRoot(destinationPath, plan.targetRoot, 'trust install-state ownership');
const canonicalDestination = canonicalPath(destinationPath);
const plannedOperation = plannedByDestination.get(canonicalDestination);
if (!plannedOperation) continue;
if (!operationIdentityMatches(operation, plannedOperation)) {
throw new Error(
`Refusing unverified ownership from install-state at ${plan.installStatePath}: `
+ `operation identity does not match the current plan for ${destinationPath}.`
);
}
const currentFingerprint = fingerprintFile(destinationPath);
if (
!currentFingerprint.exists
|| !/^[a-f0-9]{64}$/i.test(operation.contentSha256 || '')
|| currentFingerprint.sha256 !== operation.contentSha256.toLowerCase()
) {
throw new Error(
`Refusing unverified ownership from install-state at ${plan.installStatePath}: `
+ `content digest does not match ${destinationPath}.`
);
}
destinations.add(canonicalDestination);
}
return { destinations, stateFingerprint: validatedFingerprint };
}
function assertMergeDestination(destinationPath) {
if (!fs.existsSync(destinationPath)) return null;
let current;
try {
current = JSON.parse(fs.readFileSync(destinationPath, 'utf8'));
} catch (error) {
throw new Error(`Cannot merge ECC configuration into invalid JSON at ${destinationPath}: ${error.message}`);
}
if (!current || typeof current !== 'object' || Array.isArray(current)) {
throw new Error(`Cannot merge ECC configuration at ${destinationPath}: expected a JSON object.`);
}
return current;
}
function isPlainObject(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function findJsonConflicts(current, patch, prefix = '') {
if (!isPlainObject(patch)) return [];
return Object.entries(patch).flatMap(([key, patchValue]) => {
if (!Object.prototype.hasOwnProperty.call(current, key)) return [];
const currentValue = current[key];
const field = prefix ? `${prefix}.${key}` : key;
if (isPlainObject(currentValue) && isPlainObject(patchValue)) {
return findJsonConflicts(currentValue, patchValue, field);
}
return JSON.stringify(currentValue) === JSON.stringify(patchValue) ? [] : [field];
});
}
function classifyManagedOperation(operation, ownedDestinations) {
const destinationPath = operation.destinationPath;
if (!fs.existsSync(destinationPath)) return 'create';
const canonicalDestination = canonicalPath(destinationPath);
if (operation.kind === 'merge-json') {
const current = assertMergeDestination(destinationPath);
if (ownedDestinations.has(canonicalDestination)) return 'managed-json-update';
const conflicts = findJsonConflicts(current, operation.mergePayload);
if (conflicts.length > 0) {
throw new Error(
`Refusing to overwrite unowned JSON fields at ${destinationPath}: ${conflicts.join(', ')}`
);
}
return 'json-merge';
}
if (ownedDestinations.has(canonicalDestination)) return 'managed-update';
if (
operation.kind === 'copy-file'
&& typeof operation.sourcePath === 'string'
&& fs.existsSync(operation.sourcePath)
&& fs.statSync(destinationPath).isFile()
&& fs.readFileSync(operation.sourcePath).equals(fs.readFileSync(destinationPath))
) {
return 'identical';
}
throw new Error(`Refusing to replace unowned existing file: ${destinationPath}`);
}
function writableRequirement(destinationPath) {
if (fs.existsSync(destinationPath)) {
const mode = fs.statSync(destinationPath).isDirectory()
? fs.constants.W_OK | fs.constants.X_OK
: fs.constants.W_OK;
return { candidatePath: destinationPath, mode };
}
let candidatePath = path.dirname(destinationPath);
while (!fs.existsSync(candidatePath)) {
const parentPath = path.dirname(candidatePath);
if (parentPath === candidatePath) break;
candidatePath = parentPath;
}
return {
candidatePath,
mode: fs.constants.W_OK | fs.constants.X_OK,
};
}
function assertManagedDestinationsWritable(plan, dependencies) {
const accessSync = dependencies.accessSync || fs.accessSync;
const destinationPaths = [
...plan.operations.map(operation => operation.destinationPath),
...(plan.installStatePath ? [plan.installStatePath] : []),
];
const requirements = new Map();
for (const destinationPath of destinationPaths) {
const requirement = writableRequirement(destinationPath);
const existingMode = requirements.get(requirement.candidatePath) || 0;
requirements.set(requirement.candidatePath, existingMode | requirement.mode);
}
for (const [candidatePath, mode] of requirements) {
try {
accessSync(candidatePath, mode);
} catch (_error) {
const label = plan.target === 'kimi' ? 'Kimi' : 'Managed install';
throw new Error(
`${label} destination is not writable by the current user: ${candidatePath}. `
+ 'Fix the project ownership or permissions, then retry.'
);
}
}
}
function preflightManagedPlan(plan, dependencies = {}) {
if (!plan || !Array.isArray(plan.operations)) {
throw new Error('A managed install plan with operations is required.');
}
const ownership = readOwnedDestinations(plan, dependencies);
const operations = plan.operations.map(operation => {
assertSafeInstallOperation(plan, operation);
return {
destinationPath: operation.destinationPath,
kind: operation.kind,
classification: classifyManagedOperation(operation, ownership.destinations),
};
});
assertManagedDestinationsWritable(plan, dependencies);
return {
plan,
operations,
ownershipSnapshot: {
destinations: [...ownership.destinations],
stateFingerprint: ownership.stateFingerprint,
},
};
}
function applyPreflightedManagedPlan(entry) {
const preview = entry.preview && entry.preview.ownershipSnapshot
? entry.preview
: preflightManagedPlan(entry.preview.plan);
const ownedDestinations = new Set(preview.ownershipSnapshot.destinations);
const expectedStateFingerprint = preview.ownershipSnapshot.stateFingerprint;
let operationIndex = 0;
const assertStateUnchanged = () => (
assertInstallStateUnchanged(preview.plan, expectedStateFingerprint)
);
return require('./install-executor').applyInstallPlan(preview.plan, {
beforeOperationWrite({ operation }) {
assertStateUnchanged();
const expected = preview.operations[operationIndex];
const currentClassification = classifyManagedOperation(operation, ownedDestinations);
const destination = canonicalPath(operation.destinationPath);
if (
!expected
|| expected.kind !== operation.kind
|| canonicalPath(expected.destinationPath) !== destination
|| expected.classification !== currentClassification
) {
throw new Error(
`Refusing to write ${operation.destinationPath}: destination changed after Kimi preflight.`
);
}
ownedDestinations.add(destination);
operationIndex += 1;
},
beforeInstallStateWrite: assertStateUnchanged,
});
}
function defaultDependencies(options = {}) {
return {
previewClaude: request => require('../setup').reconcileClaudePlugin(
{ dryRun: true, hooks: request.claudeHooks, scope: request.claudeScope }
),
previewCodex: () => require('./codex-plugin-setup').reconcileCodexPlugin({ dryRun: true }),
createManagedPlan: request => require('./install/runtime').createInstallPlanFromRequest(
require('./install/request').normalizeInstallRequest({
profileId: request.profile,
target: 'kimi',
}),
{
homeDir: options.homeDir || process.env.HOME || os.homedir(),
projectRoot: options.projectRoot || process.cwd(),
sourceRoot: options.sourceRoot,
}
),
preflightManaged: preflightManagedPlan,
applyClaude: request => require('../setup').reconcileClaudePlugin(
{ dryRun: false, hooks: request.claudeHooks, scope: request.claudeScope }
),
applyCodex: () => require('./codex-plugin-setup').reconcileCodexPlugin({ dryRun: false }),
applyManaged: applyPreflightedManagedPlan,
};
}
async function createMultiHarnessPlan(request, injected = {}, options = {}) {
const dependencies = { ...defaultDependencies(options), ...injected };
let entries = [];
for (const id of request.harnesses) {
if (id === 'claude') {
entries = [...entries, { id, channel: 'native-plugin', preview: await dependencies.previewClaude(request) }];
} else if (id === 'codex') {
entries = [...entries, { id, channel: 'native-plugin', preview: await dependencies.previewCodex(request) }];
} else if (id === 'kimi') {
const managedPlan = await dependencies.createManagedPlan(request);
entries = [...entries, {
id,
channel: 'managed-project',
preview: await dependencies.preflightManaged(managedPlan),
}];
} else {
throw new Error(`Unsupported guided harness: ${id}`);
}
}
return { harnesses: entries, request };
}
async function applyMultiHarnessPlan(plan, injected = {}, options = {}) {
const dependencies = { ...defaultDependencies(options), ...injected };
if (plan.request.dryRun) {
return { status: 'preview', completed: [], retryHarnesses: [...plan.request.harnesses] };
}
let completed = [];
for (let index = 0; index < plan.harnesses.length; index += 1) {
const entry = plan.harnesses[index];
try {
let result;
if (entry.id === 'claude') result = await dependencies.applyClaude(plan.request, entry);
else if (entry.id === 'codex') result = await dependencies.applyCodex(plan.request, entry);
else if (entry.preview && entry.preview.plan) {
const latestPreview = dependencies.preflightManaged(entry.preview.plan);
result = await dependencies.applyManaged(
{ ...entry, preview: latestPreview },
plan.request
);
} else {
result = await dependencies.applyManaged(entry, plan.request);
}
completed = [...completed, { id: entry.id, result }];
} catch (error) {
return {
status: completed.length > 0 ? 'partial' : 'failed',
completed,
failure: { id: entry.id, message: error.message },
retryHarnesses: plan.harnesses.slice(index).map(item => item.id),
};
}
}
return { status: 'complete', completed, retryHarnesses: [] };
}
module.exports = {
VALID_CLAUDE_HOOKS,
VALID_CLAUDE_SCOPES,
VALID_PROFILES,
applyMultiHarnessPlan,
createMultiHarnessPlan,
normalizeGuidedInstallRequest,
preflightManagedPlan,
findJsonConflicts,
};