Replace every open-coded separator translation across the installer/hooks
source with named, tested seams in shell-command-projection.cts (the platform
seam), removing all hardcoded `/`+`\` from path handling:
- toPosixPath(p) — this machine's native path → POSIX (running-OS relative;
for local filesystem paths).
- toNativePath(p) — POSIX → native (collapses the win32 `/\//g,'\\'` ternary).
- posixNormalize(p)— unconditional `\`→`/`, OS-independent; for emitting paths
to a POSIX/bash TARGET (which may differ from the running
OS) and for parsing mixed-separator input.
core-utils.toPosixPath now delegates to the seam, so its 20+ existing consumers
resolve to one implementation; no duplicate helper.
- ~47 sites across runtime-hooks-surface, runtime-artifact-conversion,
runtime-artifact-install-plan, drift, init, worktree-safety,
installer-migrations, installer-migration-authoring, install-engine, surface,
verify, runtime-artifact-layout, schema-detect, check-command-router.
- Closes the latent POSIX-literal-backslash corruption class (the regex form
corrupts a POSIX path containing a literal backslash; split(path.sep) does not).
- New unit + fast-check property tests for all three helpers.
Closes #2246
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
241 lines
8.4 KiB
TypeScript
241 lines
8.4 KiB
TypeScript
'use strict';
|
|
|
|
/**
|
|
* Runtime Artifact Install Plan Module.
|
|
*
|
|
* Turns a pre-resolved runtime artifact layout into staged copy inputs. The
|
|
* installer adapter still owns pruning, copying, migrations, output, and final
|
|
* cleanup execution.
|
|
*/
|
|
|
|
// In .cts (CommonJS output) files, `require` is available as a global.
|
|
const _require: NodeRequire = require;
|
|
const path = _require('node:path') as typeof import('node:path');
|
|
|
|
type ArtifactKindName = 'commands' | 'agents' | 'skills' | 'kimi-agents';
|
|
type InstallScope = 'local' | 'global';
|
|
|
|
interface ResolvedProfile {
|
|
name?: string;
|
|
skills?: Set<string> | '*';
|
|
agents?: Set<string>;
|
|
}
|
|
|
|
interface AgentCtx {
|
|
runtime: string;
|
|
pathPrefix: string;
|
|
attribution: string | null | undefined;
|
|
}
|
|
|
|
interface ArtifactKind {
|
|
kind: ArtifactKindName;
|
|
destSubpath: string;
|
|
prefix?: string;
|
|
stage: (resolvedProfile: ResolvedProfile, agentCtx?: AgentCtx) => string;
|
|
/** Resolved absolute alternate install root for this kind, if the descriptor
|
|
* specifies one (e.g. codex skills → $HOME/.agents). Undefined means the
|
|
* kind installs under the runtime's normal configDir. */
|
|
home?: string;
|
|
}
|
|
|
|
interface Layout {
|
|
runtime: string;
|
|
configDir: string;
|
|
scope?: InstallScope;
|
|
kinds: ArtifactKind[];
|
|
}
|
|
|
|
interface RewriteOpts {
|
|
runtime: string;
|
|
configDir: string;
|
|
scope: InstallScope;
|
|
homedir?: () => string;
|
|
platform?: NodeJS.Platform;
|
|
resolveAttribution?: (runtime: string) => string | null | undefined;
|
|
}
|
|
|
|
interface Dependencies {
|
|
rewriteStagedSkillBodies?: (stagedDir: string, opts: RewriteOpts) => string | void;
|
|
rewriteStagedCommandBodies?: (stagedDir: string, opts: RewriteOpts) => string | void;
|
|
}
|
|
|
|
interface ComputePathPrefixOpts {
|
|
isGlobal: boolean;
|
|
isOpencode: boolean;
|
|
isWindowsHost: boolean;
|
|
resolvedTarget: string;
|
|
homeDir: string;
|
|
}
|
|
|
|
interface RuntimeArtifactConversionExports {
|
|
rewriteStagedSkillBodies: (stagedDir: string, opts: RewriteOpts) => string | void;
|
|
rewriteStagedCommandBodies: (stagedDir: string, opts: RewriteOpts) => string | void;
|
|
_computePathPrefix: (opts: ComputePathPrefixOpts) => string;
|
|
}
|
|
|
|
interface PlanItem {
|
|
kind: ArtifactKindName;
|
|
sourceDir: string;
|
|
destDir: string;
|
|
}
|
|
|
|
interface InstallPlan {
|
|
items: PlanItem[];
|
|
cleanupDirs: string[];
|
|
}
|
|
|
|
interface UninstallPlanItem {
|
|
kind: ArtifactKindName;
|
|
destDir: string;
|
|
}
|
|
|
|
interface UninstallPlan {
|
|
items: UninstallPlanItem[];
|
|
}
|
|
|
|
type InstallPlanResult =
|
|
| { ok: true; plan: InstallPlan }
|
|
| { ok: false; kind: 'stage_failed' | 'rewrite_failed'; message: string; cleanupDirs: string[]; failedKind?: ArtifactKindName };
|
|
|
|
interface CreateRuntimeArtifactInstallPlanArgs {
|
|
layout: Layout;
|
|
resolvedProfile: ResolvedProfile;
|
|
homedir?: () => string;
|
|
platform?: NodeJS.Platform;
|
|
resolveAttribution?: (runtime: string) => string | null | undefined;
|
|
deps?: Dependencies;
|
|
}
|
|
|
|
/**
|
|
* Asserts that `destSubpath` resolves to a path inside `configDir`.
|
|
*
|
|
* Rejects any path that escapes the configDir root (e.g. "../../etc") and any
|
|
* path containing a NUL byte. This is a security gate for Phase B of
|
|
* ADR-1239: third-party descriptors must never be able to write outside the
|
|
* designated config home directory.
|
|
*
|
|
* @param configDir - The root config directory (e.g. ~/.claude).
|
|
* @param destSubpath - The relative path declared by the runtime descriptor.
|
|
* @returns The resolved absolute path under configDir.
|
|
* @throws {Error} if destSubpath escapes configDir or contains a NUL byte.
|
|
*/
|
|
function assertDestWithinConfigHome(configDir: string, destSubpath: string): string {
|
|
if (destSubpath.includes('\0')) {
|
|
throw new Error(
|
|
`destSubpath "${destSubpath}" contains a NUL byte and is not valid`,
|
|
);
|
|
}
|
|
const root = path.resolve(configDir);
|
|
const resolved = path.resolve(configDir, destSubpath);
|
|
if (resolved === root || !resolved.startsWith(root + path.sep)) {
|
|
throw new Error(
|
|
`destSubpath "${destSubpath}" must be a strict subpath of configHome "${configDir}" — not configHome itself or outside it (escapes configHome)`,
|
|
);
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
function errorMessage(err: unknown): string {
|
|
if (err instanceof Error) return err.message;
|
|
return String(err);
|
|
}
|
|
|
|
function addCleanupDir(cleanupDirs: string[], stagedDir: string, rewrittenDir: string | void): string {
|
|
const sourceDir = rewrittenDir ?? stagedDir;
|
|
if (sourceDir !== stagedDir) cleanupDirs.push(sourceDir);
|
|
return sourceDir;
|
|
}
|
|
|
|
function createRuntimeArtifactInstallPlan(args: CreateRuntimeArtifactInstallPlanArgs): InstallPlanResult {
|
|
const {
|
|
layout,
|
|
resolvedProfile,
|
|
homedir,
|
|
platform,
|
|
resolveAttribution,
|
|
deps = {},
|
|
} = args;
|
|
const conversionExports = _require('./runtime-artifact-conversion.cjs') as RuntimeArtifactConversionExports;
|
|
const rewriteStagedSkillBodies = deps.rewriteStagedSkillBodies ?? conversionExports.rewriteStagedSkillBodies;
|
|
const rewriteStagedCommandBodies = deps.rewriteStagedCommandBodies ?? conversionExports.rewriteStagedCommandBodies;
|
|
const cleanupDirs: string[] = [];
|
|
const items: PlanItem[] = [];
|
|
const scope = layout.scope ?? 'global';
|
|
const rewriteOpts: RewriteOpts = {
|
|
runtime: layout.runtime,
|
|
configDir: layout.configDir,
|
|
scope,
|
|
homedir,
|
|
platform,
|
|
resolveAttribution,
|
|
};
|
|
|
|
// ADR-1235 §1: build agentCtx once per plan so agents kind entries can apply
|
|
// the CORRECT pre-converter cross-cutting (path rewrites → attribution → converter
|
|
// → normalize). This mirrors the exact per-file order in the inline agent loop
|
|
// in bin/install.js (lines 9330-9415). agentCtx is passed as the second arg
|
|
// to kind.stage() for agents kind entries with a converter (convertedAgentsKind).
|
|
// NO _stampNonClaudeRuntimeDefaults — agents are NOT stamped in the inline loop.
|
|
const os = _require('node:os') as typeof import('node:os');
|
|
const { posixNormalize } = _require('./shell-command-projection.cjs') as { posixNormalize: (p: string) => string };
|
|
const homedirFn: () => string = homedir ?? (() => os.homedir());
|
|
const resolvedTarget = posixNormalize(path.resolve(layout.configDir));
|
|
const homeDir = posixNormalize(homedirFn());
|
|
const isGlobal = scope === 'global';
|
|
const isOpencode = layout.runtime === 'opencode';
|
|
const isWindowsHost = (platform ?? process.platform) === 'win32';
|
|
const pathPrefix = conversionExports._computePathPrefix({ isGlobal, isOpencode, isWindowsHost, resolvedTarget, homeDir });
|
|
const attribution = resolveAttribution ? resolveAttribution(layout.runtime) : undefined;
|
|
const agentCtx: AgentCtx = { runtime: layout.runtime, pathPrefix, attribution };
|
|
|
|
for (const kind of layout.kinds) {
|
|
let stagedDir: string;
|
|
try {
|
|
if (kind.kind === 'agents') {
|
|
// ADR-1235 §1: pass agentCtx so stageAgentsForRuntimeWithConverter applies
|
|
// the full inline-loop order: pathRewrites → attribution → converter → normalize.
|
|
// The cross-cutting is now PRE-converter (inside staging), not POST.
|
|
stagedDir = kind.stage(resolvedProfile, agentCtx);
|
|
} else {
|
|
stagedDir = kind.stage(resolvedProfile);
|
|
}
|
|
} catch (err) {
|
|
return { ok: false, kind: 'stage_failed', message: errorMessage(err), cleanupDirs, failedKind: kind.kind };
|
|
}
|
|
|
|
let sourceDir = stagedDir;
|
|
try {
|
|
if (kind.kind === 'commands') {
|
|
const rewrittenDir = rewriteStagedCommandBodies(stagedDir, rewriteOpts);
|
|
sourceDir = addCleanupDir(cleanupDirs, stagedDir, rewrittenDir);
|
|
} else if (kind.kind === 'skills' || kind.kind === 'kimi-agents') {
|
|
const rewrittenDir = rewriteStagedSkillBodies(stagedDir, rewriteOpts);
|
|
sourceDir = addCleanupDir(cleanupDirs, stagedDir, rewrittenDir);
|
|
}
|
|
// agents kind: cross-cutting already applied INSIDE kind.stage() via agentCtx.
|
|
// No POST-step needed. sourceDir stays as stagedDir.
|
|
} catch (err) {
|
|
return { ok: false, kind: 'rewrite_failed', message: errorMessage(err), cleanupDirs, failedKind: kind.kind };
|
|
}
|
|
|
|
items.push({
|
|
kind: kind.kind,
|
|
sourceDir,
|
|
destDir: assertDestWithinConfigHome(kind.home ?? layout.configDir, kind.destSubpath),
|
|
});
|
|
}
|
|
|
|
return { ok: true, plan: { items, cleanupDirs } };
|
|
}
|
|
|
|
function createRuntimeArtifactUninstallPlan(layout: Layout): UninstallPlan {
|
|
return {
|
|
items: layout.kinds.map((kind) => ({
|
|
kind: kind.kind,
|
|
destDir: assertDestWithinConfigHome(kind.home ?? layout.configDir, kind.destSubpath),
|
|
})),
|
|
};
|
|
}
|
|
|
|
export = { assertDestWithinConfigHome, createRuntimeArtifactInstallPlan, createRuntimeArtifactUninstallPlan };
|