Files
msd-core/src/runtime-artifact-install-plan.cts
Michel Moreira 2f0e99f9e0 fix(#4377): opt in to project-relative includes for local installs (#4425)
* enhance(#4377): opt-in project-relative includes for local installs

A local install wrote the includes that point at GSD's own files as absolute
paths — whatever the installer resolved at install time. For one checkout
that is invisible. Across git worktrees it is not: each worktree gets its own
.claude/ copy, but all of them point back at the checkout that ran the
installer, so a worktree runs its own gsd-tools.cjs while reading workflow
prose from a different checkout. Update that one checkout and every other
worktree is running new instructions against an old engine, with nothing to
stage the update with.

--relative-includes (or GSD_RELATIVE_INCLUDES=1) makes a local install emit
`@.claude/gsd-core/...`. Opt-in, and staying opt-in: absolute works for a
single checkout, which is most people, and flipping the default would change
every existing local install to solve a problem those users do not have.

The prefix is the runtime's own localConfigDir descriptor value, never a
literal — the same value resolveScope joins onto the cwd to produce the
install target, and the same one the rewrite engine already uses for its
./.claude/ -> ./<dir>/ substitutions. Copilot and Antigravity have shipped
this shape for local installs since they were added, with hardcoded .github/
and .agents/; this is that behavior, derived rather than written down.

Six seams compute a path prefix and all six had to be threaded, which is why
the opt-in travels through the environment the way --portable-hooks already
does: one variable they all read cannot fall out of sync the way six
signatures can.

The launcher shim deliberately keeps its ABSOLUTE fallbacks. It probes
gsd-tools through ${CLAUDE_CONFIG_DIR:-$HOME/.claude} and one such default
per runtime; those are shell word expansions, not includes, and a relative
value there resolves against the shell's cwd rather than the project.
Trading an include that points at the wrong checkout for a path that points
at nothing is not a fix. All three rewrite paths mask ${VAR:-default} spans
before substituting and restore them after, and the mask only runs when the
prefix is relative, so an absolute install is byte-for-byte unchanged.

Every unexpressible case falls back to absolute: no opt-in, a global install,
a missing dir name, the configHome.kind === 'none' sentinel, an absolute
descriptor value, or one climbing out of the project with '..'.

* chore(#4377): add changeset for project-relative local includes

* fix(#4377): compare against POSIX-normalized roots in the install e2e arms

The emitted prefix is POSIX-normalized by design — it is substituted into
markdown @-references, which use forward slashes universally, so a backslash
would leak into shipped content (#1615). The e2e arms compared against the
raw temp root, which on Windows is `D:\a\...` and appears in no emitted file.

That reddened the control arm on the windows shard, and it was worse than a
red: the negative arm ("nothing references the checkout") was passing
VACUOUSLY there, because a string that cannot occur is trivially absent. Both
now go through the same normalization, so the Windows lane asserts what the
Linux lane does.

* fix(#4377): tolerate a resolved temp root, and make the e2e diff self-diagnosing

Two changes, one confirmed and one to stop guessing.

Confirmed: the emitted content carries the RESOLVED root, not the spelling
mkdtemp handed back. Reproduced on Linux with a symlinked install root —
236 emitted files carry the realpath, zero carry the link path. macOS has
this structurally, since /var is a symlink to /private/var. Comparisons now
go through both spellings, or the negative arms pass vacuously: "nothing
references the checkout" is trivially true when the string being searched
for cannot occur.

Not confirmed: the macOS shard reported ~every workflow file differing in
the "differ ONLY" arm while the five arms around it passed, and the
assertion printed a list of filenames — which says a difference exists
somewhere across 236 files and leaves the reader to guess which bytes. I
cannot reproduce that platform locally, and guessing turns one CI round-trip
into four. The assertion now reports the first divergence as text: the file,
the byte offset, and a bounded window of both sides.

* fix(#4377): strip the longest root spelling first in the install e2e diff

The macOS failure was my test corrupting its own comparison, not a product
defect. /var/folders/…/X is a SUBSTRING of /private/var/folders/…/X, so
stripping the unresolved spelling first matched inside the resolved one and
left the /private prefix glued to what followed:

  @/private/var/…/X/.claude/gsd-core/…  ->  @/private.claude/gsd-core/…

a string present in neither install, which is why all 236 files "differed".
Sorting the spellings longest-first consumes the whole occurrence, and the
short form then has nothing left to match. Proven in isolation on the exact
macOS shapes: short-first yields @/private.claude/…, longest-first yields
@.claude/….

The self-diagnosing assertion added in the previous commit is what found
this — it named the file, the byte offset, and printed both sides, so the
corrupted string was visible rather than inferred from a list of 236
filenames. Keeping it.

* fix(#4377): address review findings

* test(#4377): scan nested shell defaults without regex backtracking

* fix(#4377): close relative include review gaps

* fix(#4377): preserve root-target runtime includes

* fix(#4377): guard project-root relative includes

* test(#4377): normalize Cline fallback roots

* fix(#4377): persist relative include style

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-15 03:40:04 -04:00

281 lines
11 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');
const { tryWithinRootLexical } = _require('./security.cjs') as typeof import('./security.cjs');
// #2870: InstallScope is owned by install-scope.cts, not re-declared here.
// `isGlobalScope` centralizes the `scope === 'global'` boolean projection
// this module needs at `_computePathPrefix`'s `isGlobal: boolean` boundary
// (see the module-level doc comment on `isGlobalScope` for why the
// projection is centralized rather than eliminated).
import { isGlobalScope, type InstallScope } from './install-scope.cjs';
type ArtifactKindName = 'commands' | 'agents' | 'skills' | 'kimi-agents';
interface ResolvedProfile {
name?: string;
skills?: Set<string> | '*';
agents?: Set<string>;
}
interface AgentCtx {
runtime: string;
pathPrefix: string;
attribution: string | null | undefined;
/** #2875 Part 2 (row I1): install root, threaded through so the
* descriptor pipeline's frontmatter-extensions step and model-override
* resolution can read config exactly as the inline agent loop's own
* `targetDir` variable did. */
targetDir?: string | null;
/** Project/config discovery root, distinct from global artifact destinations. */
projectDir?: string | null;
}
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;
/** #4377: the runtime's `localConfigDir`, used only when the project-relative
* include style is opted in on a local install. Optional — an omitted value
* falls back to the absolute prefix, which is the pre-#4377 behavior. */
localDirName?: string;
/** #4377: explicit opt-in override. Defaults to `GSD_RELATIVE_INCLUDES === '1'`
* inside `_computePathPrefix`; present here so tests can drive both arms
* without mutating the environment. */
projectRelative?: boolean;
}
interface RuntimeArtifactConversionExports {
rewriteStagedSkillBodies: (stagedDir: string, opts: RewriteOpts) => string | void;
rewriteStagedCommandBodies: (stagedDir: string, opts: RewriteOpts) => string | void;
_computePathPrefix: (opts: ComputePathPrefixOpts) => string;
_localIncludeDirName: (runtime: string) => string | undefined;
}
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;
projectDir?: string | null;
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);
// `resolved === root` is a DELIBERATE ADDITIONAL rejection, separate from
// the containment decision: `tryWithinRootLexical` treats target === root
// as CONTAINED, but a destSubpath of "" (or one that resolves to configDir
// itself) must never be accepted here — this is the strict-subpath
// requirement Phase B of ADR-1239 imposes on third-party descriptors, and
// it prevents a descriptor from writing at configHome itself. Kept as its
// own check per ADR-4650 decision 6 (a wrapper may add its own conditions
// on top of the canonical predicate, never invert it).
const contained = tryWithinRootLexical(destSubpath, configDir);
if (contained === null || contained === root) {
throw new Error(
`destSubpath "${destSubpath}" must be a strict subpath of configHome "${configDir}" — not configHome itself or outside it (escapes configHome)`,
);
}
return contained;
}
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,
projectDir,
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 the staging context once per plan. Agent kinds apply
// the CORRECT pre-converter cross-cutting (path rewrites → attribution →
// converter → normalize). This
// mirrors the exact per-file order in the former inline agent loop.
// 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());
// #2870: `scope` above is already the module-owned `InstallScope` value
// (`layout.scope ?? 'global'`, defaulted before this point, so it is never
// `undefined` here) — `isGlobalScope` projects it to the boolean
// `_computePathPrefix`'s existing `isGlobal: boolean` API requires.
const isGlobal = isGlobalScope(scope);
const isOpencode = layout.runtime === 'opencode';
const isWindowsHost = (platform ?? process.platform) === 'win32';
// #4377: descriptor-derived local dir name, so an opted-in local install
// emits a project-relative prefix instead of this checkout's absolute path.
const pathPrefix = conversionExports._computePathPrefix({ isGlobal, isOpencode, isWindowsHost, resolvedTarget, homeDir, localDirName: conversionExports._localIncludeDirName(layout.runtime) });
const attribution = resolveAttribution ? resolveAttribution(layout.runtime) : undefined;
// #2875 Part 2 (row I1): layout.configDir IS the install root the inline
// agent loop called `targetDir` — same value, same resolution.
const agentCtx: AgentCtx = {
runtime: layout.runtime,
pathPrefix,
attribution,
targetDir: layout.configDir,
projectDir: projectDir ?? layout.configDir,
};
for (const kind of layout.kinds) {
let stagedDir: string;
try {
// Agent kinds use the context for their pre-converter cross-cutting
// sequence; other kinds ignore it.
stagedDir = kind.stage(resolvedProfile, agentCtx);
} 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);
}
// Agent kinds: 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 };