Files
msd-core/src/surface.cts
Tom Boucher 3ab0007164 enh(#2875): materialization primitives — durable user-artifact staging and descriptor-authoritative agents (#3600)
* fix(#2875): stage user artifacts durably across install wipes (#1874-F19)

preserveUserArtifacts held user files only in an in-memory Map across the
wipe, so any process death between preserve and restore lost them outright.

Seven call sites, not the four the issue records. Three of them never called
the helper at all - they open-coded the same read/wipe/write - so searching
for callers under-counted by construction; the extra sites were found by
sweeping for the pattern instead.

The worst is the mainline install path, where the crash window spans the
entire gsd-core tree copy rather than a single rmSync.

Adds src/user-artifact-staging.cts: durable on-disk staging with a record
written after the copies land as the commit point, plus recovery of orphaned
batches on the next run - without recovery the staged bytes survive but the
user's file is still gone, which would pass its own test while delivering
nothing.

Routes copyPreservingSymlink through installFs() so staging cannot bypass the
install fs seam, and reunites its symlink-safety docblock with the function it
documents.

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

* docs(#2875): amend ADR-3574 with four claims disproved by implementation

Implementing Phase 6 disproved four statements the ADR rests on. The central
decision - no single materializer - is unaffected and stands.

Corrected: decision 3 was already satisfied, so nothing was extracted; the
agents-bypass runtime set omitted claude, kilo and opencode, and closing it
needed three new pieces of descriptor contract rather than proceeding on its
own terms; three of the four blockers the layout comment names were already
stale; and F19 is seven call sites, not four.

Records the generalizable lesson: the defect is the pattern of holding user
data in memory across a wipe, not the helper, so searching for callers of the
helper under-counts by construction.

Also resolves the ADR's open question on USER_OWNED_ARTIFACTS membership, and
notes that copyPreservingSymlink needed routing through the install fs seam
before it could be reused.

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

* fix(#2875): close dangling-symlink blind spot and harden staging recovery

An adversarial review found the F19 staging work shipped red and unsafe.

Root cause, shared by two arbitrary-write findings: hasExistingSymlinkBetween
missed dangling symlinks in both its root check and its per-segment walk,
because it probed with existsSync, which is false for a link whose target does
not exist. Fixing only the new module would have reused a guard that was
itself blind. This guard protects the whole install tree.

Recovery no longer throws: it degrades per entry and per file, so one bad
batch cannot block the others. Previously an unrecoverable entry propagated
out of the first statement of install and uninstall, before the cleanup that
would have removed it - wedging the installer permanently.

Partial fs adapters now throw on any omitted method instead of silently
reaching the real filesystem, closing the trap that let a test poison list
pass while real IO happened.

Staged names must be flat, recovery refuses a dangling destination symlink,
and a batch whose recovery genuinely failed is no longer swept - it was
discarding the only durable copy of the file it had just failed to restore.

Replaces three tests that could not fail, including the one labelled negative
proof.

Known limitation, documented not closed: concurrent installs sharing a staging
key can still lose a batch. A real fix needs a cross-process lock.

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

* enh(#2875): make the descriptor authoritative for the agents kind

Deletes the inline agent-staging loop in bin/install.js and the
_DESCRIPTOR_AGENTS_RUNTIMES set, so every runtime materializes agents from
its capability descriptor instead of an inline hostBehaviors dispatch.

Closing it needed three pieces of contract the descriptor pipeline never had,
all reducible to one missing input - per-agent resolution context: a
frontmatter-extensions step for claude's effort and disallowedTools, per-agent
model-override resolution for kilo and opencode, and a named branding
converter for hermes, whose rewrite data was already declared.

Seven runtimes were on the loop, not the six the design recorded - kimi-code
was found by a golden fixture, not by analysis. claude-local and kimi-code
both silently lost their agents mid-change; the fixtures caught both and the
cause was fixed rather than the fixtures regenerated.

A parity harness gates the migration: both pipelines over identical inputs,
byte-identical output including filenames, per runtime. It is demonstrated
red before being trusted. Surface and install paths converge for all seven,
which also fixes surface previously writing no agents for these runtimes.

Codex's config.toml strip stays put - it mutates host config, which no
descriptor kind models.

Also routes install-model-override-resolver and install-effort-resolver
through the install fs seam. Both leaked real filesystem IO from the install
call tree; the stricter adapter is what exposed them.

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

* docs(#2875): record the agents-descriptor migration and correct the ADR count

The _DESCRIPTOR_AGENTS_RUNTIMES allow-list no longer exists, so the host
integration guide told readers to join a set that is gone. Replaces that with
what is now true - declare an agents entry and it installs, on the surface
path as well as install - and points anyone needing a per-agent transform at
the three extension points rather than at a new inline branch.

Corrects the ADR amendment: seven runtimes were on the inline loop, not six.
kimi-code was found by a golden fixture going red, not by reading. That is the
third short count this phase, all from enumerating by symbol or set membership
when the thing that matters is a behavior.

Adds the Changed changeset for the surface-path convergence.

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

* docs(#2875): amend ADR-2866 - claude global always wrote agents on disk

The claude row's global=[skills] described what capability.json declared, not
what the installer wrote. bin/install.js's inline agent-staging loop was never
scope-gated and never consulted the descriptor, so a claude --global install
has always written agents/gsd-*.md.

Phase 6 closes the gap by deleting that loop and declaring agents on claude's
descriptor at global scope. On-disk bytes are unchanged - the golden fixtures
did not move, which is the evidence that the descriptor, not the installer,
was incomplete.

#2218 is unaffected: agents are not trigger-bearing, so the wider row does not
introduce a new shadowing case.

Records the warning that an incomplete descriptor is invisible while a second
code path silently does its work, and only surfaces when the two are forced
into agreement.

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

* fix(#2875): close review findings across staging, agents and the parity harness

Two independent reviews of this branch found defects the local gates missed.

Security: a dangling symlink at a migration destination allowed writing
outside configDir - the same class this change claimed to close, missed at the
terminal write of the flow being added. The staging-root resolver threw as the
first statement of install and uninstall, so a hostile symlink bricked both,
and symlinked-configDir users lost uninstall as well as install; it now
degrades instead of aborting. Recovery gained a source-side symlink check and
now refuses a relative destDir, which resolved against cwd. Converter dispatch
gained a runtime allowlist - lint-time validation stopped mattering once this
branch promoted that dispatch from the surface path to real installs.

Correctness: claude --local --minimal exited 1 because the minimal profile
legitimately yields zero agents and the new path treated that as a failure.
cline --local silently lost its agents - its descriptor declared none while
the deleted loop wrote them unconditionally. The agents prune was widened to
any gsd-* entry and destroyed user files it never owned.

The parity harness, on which the migration's safety argument rested, drove a
synthetic registry and never byte-compared the shipped descriptors; two of its
trap rows could not fail. It now drives the real registry across 13
runtime-scope rows including kimi-code and cline-local, and its red-proof is
demonstrated by corrupting a live capability.json. Three goldens that had
encoded the cline regression as expected behavior were corrected.

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

* fix(#2875): close findings from both mandated review engines

/security-review found the staging source-side walk honouring
GSD_ALLOW_SYMLINKED_DEST, an opt-in documented as relaxing only the write
destination. A symlinked files/ component dereferenced because
copyPreservingSymlink lstats the leaf only, so an intermediate link is
followed. The source walk no longer honours the opt-in; the destination check
still does.

/code-review spec axis found this branch had reintroduced its own bug:
migrateLegacyDevPreferencesToSkill's new symlink refusal threw unguarded after
the legacy dir was wiped and before the staged batch was restored, so a
planted symlink bricked uninstall permanently and orphaned the batch. Refusal
kept, abort removed.

kimi-code local silently lost its agents, the same class as the cline bug, and
the parity harness recorded that exclusion as intentional - the third test in
this branch to pin a regression as correct.

--minimal now creates an empty agents/ dir that never existed. Behaviour
restored rather than softening the changeset, so its byte-identical claim
stays true.

Standards axis: try/finally removed from twelve test bodies, fast-check
properties added for parseOwnerPid, boundary coverage at the grace window and
the ancestor-probe depth, a parity assertion for the staging-root helper
duplicated across two files, and the 8-deep config walk deduplicated.

Records 60-review.json with every finding and disposition from five passes,
including the smells left unfixed and why.

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

* fix(#2875): prune stale agents unconditionally in minimal mode

The previous round stopped an empty agents/ directory being created when the
resolved profile yields no agents. That was implemented by skipping the agents
kind entirely, which also skipped its stale-agent prune - so a full to minimal
downgrade left stale gsd-* agents behind.

The deleted inline loop pruned unconditionally and only skipped writing. Those
are three separate conditions, not one: prune always, write only when there is
something to write, create the directory only when writing.

Both call sites now run _removeGsdEntries before the empty-staged early exit.
The symlink-escape guard moved with it, since the prune also touches dest.
Codex .toml agents and the config.toml stanzas are cleaned again, and
user-owned agents are still preserved.

The agents/ directory is left in place after a prune empties it, matching
every sibling kind - none of them remove the destination directory itself.

Golden fixtures confirmed byte-identical: the prune is a no-op on a fresh
install, so fixture generation is unaffected.

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

* docs(#2875): document interrupted-install recovery for user-owned files

The durable-staging fix is invisible to the user it protects. Someone whose
install died mid-flight has no way to know USER-PROFILE.md was staged before
the delete, that the next run restores it, or that recovery happens at the
start of that run rather than in the background.

Written as the task the user has - finish the interrupted command - rather
than as a description of the mechanism, and states what it will not do:
overwrite a file already present, or touch staging belonging to another
install still running.

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

* chore(#2875): backfill changeset pr number

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

* test(#2875): assert the J8 model override without building a regex

CodeQL flagged incomplete string escaping: the assertion interpolated the
override value into a RegExp while escaping only forward slashes, which is
meaningless in a constructor, leaving real metacharacters unescaped.

The failure direction was the dangerous one - a metacharacter would have made
the match more permissive, so the row would pass when it should fail. That
matters here because J8 exists precisely because an earlier revision was a
tautology; the rewrite reintroduced a different way for the same assertion to
stop discriminating.

Replaced with a line-wise exact match, so no regex is constructed at all.
Swept the other test files this branch adds; no sibling instances.

lint:ci passed on the original - lint-no-adhoc-regex-escape matches a full
metachar-escape copy, so a single slash replace slipped under it.

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

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 17:25:53 -04:00

725 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Runtime surface module — ADR-0011 Phase 2 (Option B).
*
* Manages the runtime enable/disable surface state (the `.gsd-surface.json` marker in
* each runtime's config dir root (e.g., ~/.claude)) independently of the install-time profile marker
* (`.gsd-profile`). Runtime config locations are resolved by callers.
*
* Effective skill set = base profile ∪ explicitAdds − disabledClusters − explicitRemoves,
* then transitively closed via the manifest.
*
* Exports:
* readSurface(runtimeConfigDir)
* writeSurface(runtimeConfigDir, surfaceState)
* resolveSurface(runtimeConfigDir, manifest, clusterMap?, registry?)
* applySurface(runtimeConfigDir, layout, manifest, clusterMap?, registry?)
* listSurface(runtimeConfigDir, manifest, clusterMap?, registry?)
* pruneSkillDirs(skillsDir, retainedNames, prefix, manifest)
*
* The optional `registry` param (ADR-857 phase 4c) accepts the capability-registry
* object. When present, capability clusters are merged into the effective cluster
* map and the registry is threaded into resolveProfile so capability-contributed
* skills participate in the base set and disable-ability. Absent or undefined
* leaves behaviour identical to the pre-registry path (no-op for current registry
* where UI=full and the full profile returns '*' regardless).
*
* ADR-457 build-at-publish: the hand-written bin/lib/surface.cjs collapsed
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
* from the prior hand-written .cjs; only types are added.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { platformWriteSync, posixNormalize } from './shell-command-projection.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import installProfiles = require('./install-profiles.cjs');
const {
readActiveProfile,
resolveProfile,
loadSkillsManifest,
// #2322 HIGH-3: shared marker name — single source of truth with the writer
// (install-profiles.cts stageSkillsForRuntimeAsSkills) so the prune reader
// below can never drift from what the stage-time writer actually wrote.
CAPABILITY_SKILL_MARKER,
} = installProfiles;
import { CLUSTERS } from './clusters.cjs';
import type { ClusterMap } from './clusters.cjs';
// #2870: `isGlobalScope` centralizes the `scope === 'global'` boolean
// projection `applySurface` needs at `_computePathPrefix`'s `isGlobal:
// boolean` boundary (see its doc comment in install-scope.cts for why the
// projection is centralized rather than eliminated).
import { isGlobalScope } from './install-scope.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import runtimeArtifactLayout = require('./runtime-artifact-layout.cjs');
const { findInstallSourceRoot } = runtimeArtifactLayout;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import runtimeArtifactConversion = require('./runtime-artifact-conversion.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import runtimeArtifactInstallPlan = require('./runtime-artifact-install-plan.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import retiredArtifactCleanup = require('./retired-artifact-cleanup.cjs');
const { assertDestWithinConfigHome } = runtimeArtifactInstallPlan;
const SURFACE_FILE_NAME = '.gsd-surface.json';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface AgentCtx {
runtime: string;
pathPrefix: string;
attribution: string | null | undefined;
/** #2875 Part 2 (row I1): install root, mirrors
* runtime-artifact-install-plan.cts's identically-named field — see its
* doc comment. */
targetDir?: string | null;
}
interface ArtifactKind {
kind: string;
destSubpath: string;
prefix: string;
// #2911: optional install-root override (e.g. Codex skills -> $HOME/.agents),
// set by resolveRuntimeArtifactLayout's dispatchKindEntry. Must be honored as
// a FALLBACK by every destination-computation call site — see applySurface.
home?: string;
stage: (resolvedProfile: { name: string; skills: Set<string> | '*'; agents: Set<string> }, agentCtx?: AgentCtx) => string;
}
interface Layout {
runtime: string;
configDir: string;
scope?: 'local' | 'global';
kinds: ArtifactKind[];
}
interface ApplySurfaceOptions {
resolveAttribution?: (runtime: string) => string | null | undefined;
homedir?: () => string;
platform?: string;
}
// ---------------------------------------------------------------------------
// State IO
// ---------------------------------------------------------------------------
/**
* @typedef {Object} SurfaceState
* @property {string} baseProfile
* @property {string[]} disabledClusters
* @property {string[]} explicitAdds
* @property {string[]} explicitRemoves
*/
interface SurfaceState {
baseProfile: string;
disabledClusters: string[];
explicitAdds: string[];
explicitRemoves: string[];
}
/**
* Read the surface state from a runtime config directory.
*
* @param runtimeConfigDir
* @returns null if file missing or corrupt
*/
function readSurface(runtimeConfigDir: string): SurfaceState | null {
const filePath = path.join(runtimeConfigDir, SURFACE_FILE_NAME);
try {
const raw = fs.readFileSync(filePath, 'utf8');
const parsed: unknown = JSON.parse(raw);
// Structural validation — must have these fields with expected types
if (typeof parsed !== 'object' || parsed === null) return null;
const p = parsed as Record<string, unknown>;
if (typeof p['baseProfile'] !== 'string') return null;
if (!Array.isArray(p['disabledClusters'])) return null;
if (!Array.isArray(p['explicitAdds'])) return null;
if (!Array.isArray(p['explicitRemoves'])) return null;
return {
baseProfile: p['baseProfile'],
disabledClusters: p['disabledClusters'] as string[],
explicitAdds: p['explicitAdds'] as string[],
explicitRemoves: p['explicitRemoves'] as string[],
};
} catch {
return null;
}
}
/**
* Write the surface state atomically via the platform seam (mkdir + tmp+rename).
*/
function writeSurface(runtimeConfigDir: string, surfaceState: SurfaceState): void {
platformWriteSync(path.join(runtimeConfigDir, SURFACE_FILE_NAME), JSON.stringify(surfaceState, null, 2) + '\n');
}
// ---------------------------------------------------------------------------
// Resolution
// ---------------------------------------------------------------------------
/**
* Expand cluster names to skill stems using the provided clusterMap.
*/
function clustersToSkills(clusterNames: string[], clusterMap: ClusterMap | Record<string, string[]>): Set<string> {
const result = new Set<string>();
for (const name of clusterNames) {
const members = (clusterMap as Record<string, ReadonlyArray<string> | undefined>)[name];
// FIX 5: guard against non-iterable members — malformed registry must never throw
if (!Array.isArray(members)) continue;
for (const s of (members as string[])) result.add(s);
}
return result;
}
/**
* Normalize manifest inputs to the skill-dependency Map shape expected by
* resolveProfile/computeClosure.
*
* Supports:
* - canonical Map<string, string[]>
* - legacy plain object map: { [stem]: string[] }
* - parsed gsd-file-manifest.json object ({ files: { ... } }) by rebuilding
* the dependency manifest from the install source tree
*/
function normalizeSkillManifest(runtimeConfigDir: string, manifest: Map<string, string[]> | object | null | undefined): Map<string, string[]> {
if (manifest instanceof Map) {
return manifest;
}
if (!manifest || typeof manifest !== 'object') {
return new Map();
}
// Legacy/ad-hoc object map: stem -> requires[]
const arrayEntries = Object.entries(manifest as Record<string, unknown>).filter(([, value]) => Array.isArray(value)) as [string, string[]][];
if (arrayEntries.length > 0) {
return new Map(arrayEntries.map(([stem, deps]) => [stem, deps]));
}
// Parsed gsd-file-manifest.json shape: rebuild the dependency map from source.
const manifestObj = manifest as Record<string, unknown>;
if (manifestObj['files'] && typeof manifestObj['files'] === 'object') {
const srcCommandsDir = findInstallSourceRoot(runtimeConfigDir);
return loadSkillsManifest(srcCommandsDir);
}
return new Map();
}
/**
* Resolve the effective surface to a typed profile-like object.
* Shape: { name, skills: Set<string>|'*', agents: Set<string> }
*
* Resolution order:
* 1. Start with base profile resolved via resolveProfile()
* 2. Remove skills in disabled clusters
* 3. Add explicitAdds (and their transitive closure)
* 4. Remove explicitRemoves (only the stem itself, no cascade)
*
* ADR-857 phase 4c: optional registry param. When present:
* - capability clusters are merged into the effective cluster map so
* capability-owned skill groups are disable-able.
* - registry is threaded into resolveProfile so capability skills
* participate in the base skill set and their requires: chains expand.
*/
function resolveSurface(runtimeConfigDir: string, manifest: Map<string, string[]> | object, clusterMap?: ClusterMap | Record<string, string[]>, registry?: { capabilityClusters?: Record<string, string[]>; profileMembership?: Record<string, { tier: string; profiles: string[] }> }): { name: string; skills: Set<string>; agents: Set<string> } {
// Merge capability clusters into the cluster map when registry is provided.
// The ADR-857 phase 4a HARD gate guarantees that when a capId matches a CLUSTERS
// key, the values are EQUAL — so the spread is idempotent for matching names.
// Defense-in-depth: if a capId collides with a hand-authored CLUSTERS key AND
// the values DIFFER (future drift bypassing the gate), prefer the hand-authored
// value so disable behavior is never silently changed by a stale registry entry.
// Also guard: skip entries whose value is not a string[] (malformed registry).
let cm: ClusterMap | Record<string, string[]> = clusterMap || CLUSTERS;
if (registry && registry.capabilityClusters && typeof registry.capabilityClusters === 'object') {
const baseCm = cm;
const capClusters = registry.capabilityClusters;
const merged: Record<string, string[]> = { ...(baseCm as Record<string, string[]>) };
for (const capId of Object.keys(capClusters)) {
const val = capClusters[capId];
// FIX 5: skip malformed (non-array) entries — never throw on bad registry
if (!Array.isArray(val)) continue;
// FIX 4: if the capId matches an existing cluster key, only override when
// the values are identical (guaranteed by 4a gate). If they differ, the
// hand-authored value wins — prefer known-correct disable behavior over
// a potentially stale registry entry.
if (Object.prototype.hasOwnProperty.call(baseCm, capId)) {
const existing = (baseCm as Record<string, readonly string[] | string[]>)[capId];
if (!Array.isArray(existing)) { merged[capId] = val; continue; }
// Values differ → hand-authored wins (skip the override)
if (existing.length !== val.length || existing.some((v, i) => v !== val[i])) continue;
}
// Prototype-pollution guard (parity with _capabilitySkillsForMode in install-profiles.cts)
if (capId === '__proto__' || capId === 'constructor' || capId === 'prototype') continue;
merged[capId] = val;
}
cm = merged;
}
const skillManifest = normalizeSkillManifest(runtimeConfigDir, manifest);
const surface = readSurface(runtimeConfigDir);
// Determine base profile name: from surface state or from .gsd-profile marker
const baseProfileName = (surface && surface.baseProfile)
? surface.baseProfile
: (readActiveProfile(runtimeConfigDir) || 'full');
// Resolve base profile — thread registry so capability skills are included.
const baseResolved = resolveProfile({
modes: baseProfileName.split(',').map((s: string) => s.trim()),
manifest: skillManifest,
registry,
});
// If full, we need to enumerate all skills from the manifest
let skills: Set<string>;
if (baseResolved.skills === '*') {
// Materialize all skill stems from manifest
skills = new Set<string>();
for (const [key] of skillManifest) {
if (!key.startsWith('_calls_agents_')) skills.add(key);
}
// Issue #2045 (DEFECT 1): third-party capability skills live at
// ~/.gsd/capabilities/<id>/skills/<stem>/SKILL.md — NOT in the runtime skills
// dir → never in skillManifest → never in the Set → surfaced:false. The
// overlay-aware registry's `capabilityClusters` already covers accepted
// overlay caps (composed by loadRegistry({includeInstalled})), so union its
// values into the surfaced Set. This is IDEMPOTENT for first-party skills
// (their stems are already on disk → already in the Set) and ADDITIVE for
// third-party skills (the fix). The 'full' profile means everything, and
// every cap's profileMembership profiles-array includes 'full' (it is the
// suffix top), so no per-tier gate is needed here — this invariant is owned
// by gen-capability-registry.cjs deriveProfileMembership (PROFILE_RANK suffix)
// + deriveCapabilityClusters (same non-empty-skills scoping); revisit both if
// either derivation changes. Prototype-pollution guard mirrors the cluster-
// merge block above (lines 217-240). NOTE: third-party cap agents are NOT
// unioned here (skillManifest has no `_calls_agents_` companion for them) —
// v1 scopes to skills-only caps per issue #2045; agents are a follow-up.
if (registry && registry.capabilityClusters && typeof registry.capabilityClusters === 'object') {
const BANNED = ['__proto__', 'constructor', 'prototype'];
for (const capId of Object.keys(registry.capabilityClusters)) {
if (BANNED.includes(capId)) continue;
const stems = (registry.capabilityClusters as Record<string, unknown>)[capId];
if (!Array.isArray(stems)) continue;
for (const s of stems) {
if (typeof s === 'string' && s.length > 0) skills.add(s);
}
}
}
} else {
skills = new Set(baseResolved.skills);
}
if (surface) {
// Step 2: remove disabled cluster members
const disabledSkills = clustersToSkills(surface.disabledClusters, cm);
for (const s of disabledSkills) skills.delete(s);
// Step 3: add explicitAdds with transitive closure
if (surface.explicitAdds.length > 0) {
const addSet = new Set(surface.explicitAdds);
// Compute closure of adds
const queue = [...addSet];
const visited = new Set(addSet);
while (queue.length > 0) {
const stem = queue.pop()!;
const deps = skillManifest.get(stem) || [];
for (const dep of deps) {
if (!visited.has(dep)) {
visited.add(dep);
queue.push(dep);
}
}
}
for (const s of visited) skills.add(s);
}
// Step 4: remove explicitRemoves (stem only, no cascade)
for (const s of surface.explicitRemoves) {
skills.delete(s);
}
}
// Derive agents from skills
const agents = new Set<string>();
for (const skillStem of skills) {
const agentRefs = skillManifest.get(`_calls_agents_${skillStem}`) || [];
for (const agentStem of agentRefs) agents.add(agentStem);
}
const name = surface ? `surface:${surface.baseProfile}` : `profile:${baseProfileName}`;
return { name, skills, agents };
}
// ---------------------------------------------------------------------------
// Apply
// ---------------------------------------------------------------------------
/**
* Re-stage the active surface using the resolved layout.
* Iterates layout.kinds and syncs each artifact kind to its destination.
*/
function applySurface(runtimeConfigDir: string, layout: Layout, manifest: Map<string, string[]> | object, clusterMap?: ClusterMap | Record<string, string[]>, registry?: { capabilityClusters?: Record<string, string[]>; profileMembership?: Record<string, { tier: string; profiles: string[] }> }, opts?: ApplySurfaceOptions): { name: string; skills: Set<string>; agents: Set<string> } {
if (path.resolve(runtimeConfigDir) !== path.resolve(layout.configDir)) {
throw new TypeError('applySurface runtimeConfigDir must match layout.configDir');
}
const skillManifest = normalizeSkillManifest(layout.configDir, manifest);
const resolved = resolveSurface(layout.configDir, skillManifest, clusterMap, registry);
// Profile toggles must converge retired surfaces too. Once a kind disappears
// from artifactLayout there is no normal sync pass left to prune it (#2644).
retiredArtifactCleanup.pruneRetiredRuntimeArtifacts(layout.runtime, layout.configDir);
// #1575: agents kind now mirrors createRuntimeArtifactInstallPlan — build
// agentCtx (pathPrefix + attribution) and pass it to kind.stage() so
// stageAgentsForRuntimeWithConverter applies the full inline-loop pipeline
// (pathRewrites -> attribution -> converter -> normalize). Without this,
// surface-path agents lack path-prefix rewrites and Co-Authored-By trailers,
// diverging from a fresh install.
const _homedirFn: () => string = opts?.homedir ?? (() => os.homedir());
const _resolvedTarget = posixNormalize(path.resolve(layout.configDir));
const _homeDir = posixNormalize(_homedirFn());
// #2870: same judgment as createRuntimeArtifactInstallPlan's identical
// line — `layout.scope` is already the resolved scope value on the
// `Layout` this function received. `layout.scope` is optional, so the
// pre-existing `?? 'global'` default is kept ahead of the call: it must
// run BEFORE `isGlobalScope`, because `isGlobalScope(undefined)` throws
// (unlike the old inline `undefined === 'global'`, which silently
// evaluated to `false`) — the default is what makes an undefined scope
// resolve to `'global'` here, exactly as it did before. `isGlobalScope`
// then projects the defaulted value to the boolean `_computePathPrefix`'s
// existing `isGlobal: boolean` API requires.
const _isGlobal = isGlobalScope(layout.scope ?? 'global');
const _isOpencode = layout.runtime === 'opencode';
const _isWindowsHost = (opts?.platform ?? process.platform) === 'win32';
const _pathPrefix = runtimeArtifactConversion._computePathPrefix({ isGlobal: _isGlobal, isOpencode: _isOpencode, isWindowsHost: _isWindowsHost, resolvedTarget: _resolvedTarget, homeDir: _homeDir });
const _attribution = opts?.resolveAttribution ? opts.resolveAttribution(layout.runtime) : undefined;
// #2875 Part 2 (row I1): layout.configDir is this call's install root.
const agentCtx: AgentCtx = { runtime: layout.runtime, pathPrefix: _pathPrefix, attribution: _attribution, targetDir: layout.configDir };
const tempDirsToClean: string[] = [];
// #1575: When the surface has no state modifications AND the base profile is
// 'full', pass the '*' sentinel for agents staging so ALL agents are staged —
// matching the install path which uses { skills: '*' }. Without this, agents
// not referenced by any skill's _calls_agents_ manifest entry would be silently
// dropped from the surface path. For tiered profiles (core/standard) or when
// surface mods exist, pass the resolved set so only the filtered subset stages.
const _surfaceState = readSurface(layout.configDir);
const _baseProfileName = (_surfaceState && _surfaceState.baseProfile)
? _surfaceState.baseProfile
: (readActiveProfile(layout.configDir) || 'full');
const _hasSurfaceMods = !!_surfaceState && (
_surfaceState.disabledClusters.length > 0 ||
_surfaceState.explicitAdds.length > 0 ||
_surfaceState.explicitRemoves.length > 0
);
const _isUnmodifiedFull = _baseProfileName === 'full' && !_hasSurfaceMods;
try {
for (const kind of layout.kinds) {
let staged: string;
if (kind.kind === 'agents') {
const agentProfile = _isUnmodifiedFull ? { ...resolved, skills: '*' as const } : resolved;
staged = kind.stage(agentProfile, agentCtx);
} else {
staged = kind.stage(resolved);
}
if (kind.kind === 'skills') {
runtimeArtifactConversion.rewriteStagedSkillBodies(staged, {
runtime: layout.runtime,
configDir: layout.configDir,
scope: layout.scope ?? 'global',
});
} else if (kind.kind === 'commands') {
const rewritten: string | undefined = runtimeArtifactConversion.rewriteStagedCommandBodies(staged, {
runtime: layout.runtime,
configDir: layout.configDir,
scope: layout.scope ?? 'global',
}) as string | undefined;
if (rewritten && rewritten !== staged) {
staged = rewritten;
tempDirsToClean.push(rewritten);
}
}
// #2911: honor kind.home as a FALLBACK-preferred override (e.g. Codex
// skills -> $HOME/.agents), never a blanket replacement — kinds without
// a `home` must keep resolving against layout.configDir. This must stay
// in lockstep with _copyStaged's root selection in src/install-engine.cts;
// the parity test in tests/runtime-artifact-layout-surface.test.cjs
// enforces that the two writers never diverge again.
const dest = assertDestWithinConfigHome(kind.home ?? layout.configDir, kind.destSubpath);
_syncGsdDir(staged, dest, kind, skillManifest, layout.runtime);
}
} finally {
for (const dir of tempDirsToClean) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
}
}
return resolved;
}
/**
* Prune GSD-managed skill directories from a skills directory.
*
* Removes every directory in `skillsDir` that is GSD-owned but NOT listed
* in `retainedNames`. User-owned dirs (not matching the GSD ownership criteria)
* are always preserved.
*
* Ownership criteria:
* - Non-empty prefix (e.g. 'gsd-'): dir name starts with that prefix AND
* EITHER appears in the manifest (first-party membership) OR carries the
* persisted `CAPABILITY_SKILL_MARKER` file (#2322 HIGH-3: a third-party
* capability skill, self-certifying and independent of current registry
* state — so an uninstalled/unsurfaced capability's stale skill is still
* prunable even though it no longer appears in any registry view). Dirs
* that match the prefix but satisfy NEITHER are treated as user-owned and
* preserved — this prevents data loss for user-created gsd-* directories.
* A warning is written to stderr when such a dir is encountered.
* - Empty prefix (Hermes): dir name appears as a canonical skill stem in the
* manifest. User dirs not in the manifest are preserved. (Hermes does not
* yet stage third-party capability skills, so the marker check does not
* apply on this path.)
* - Empty prefix without manifest, or manifest not a Map: conservative; no
* dirs are removed.
*
* This is the single point of truth for skill-dir pruning. Both _syncGsdDir
* (surface apply) and callers that need stand-alone pruning use this function.
*
* @param skillsDir directory that contains the gsd-STEM sub-dirs
* @param retainedNames set of directory names to keep (e.g. 'gsd-help')
* @param prefix GSD dir prefix, e.g. 'gsd-' (or '' for Hermes)
* @param manifest optional; required for Hermes empty-prefix case
* and for manifest-membership gate in prefixed case.
* Must be a Map; any other type is treated as missing.
*/
function pruneSkillDirs(skillsDir: string, retainedNames: Set<string>, prefix: string, manifest?: Map<string, string[]>): void {
if (!fs.existsSync(skillsDir)) return;
// Finding 2: guard against callers passing a truthy non-Map as manifest.
// A non-Map manifest would throw on .keys(); treat it as absent and be conservative.
const safeManifest = (manifest instanceof Map) ? manifest : null;
// Build the canonical stem set from the manifest (used for both prefixed and Hermes paths).
// Deletion requires manifest membership — without a valid manifest, be conservative.
const canonicalStems = safeManifest
? new Set([...safeManifest.keys()].filter(k => !k.startsWith('_calls_agents_')))
: null;
for (const entry of fs.readdirSync(skillsDir)) {
const entryPath = path.join(skillsDir, entry);
if (!fs.statSync(entryPath).isDirectory()) continue;
let isGsdOwned: boolean;
if (prefix !== '') {
if (!entry.startsWith(prefix)) {
// Does not match prefix at all — user-owned, preserve.
continue;
}
// #2322: an entry in THIS apply's retained set is unambiguously wanted —
// check that BEFORE the first-party-manifest-membership gate below. The
// manifest only ever knows gsd-core's own bundled stems; a materialized
// third-party capability skill (retained via the resolved profile's
// registry union, #2045/#2322) has no manifest entry at all, so without
// this early check it fell into the "unknown, preserve with warning"
// branch on EVERY apply — misreporting a live, GSD-managed capability
// skill as "user-owned or unknown" noise. This does not change any
// deletion outcome (a retained entry was always preserved — see the
// `retainedNames.has(entry)` check further below); it only short-
// circuits the ambiguous-ownership warning for entries we already know,
// this apply, are wanted.
if (retainedNames.has(entry)) continue;
// Finding 1 fix: prefix match is necessary but NOT sufficient.
// The dir must also be in the manifest to be considered GSD-owned.
// A user-created gsd-* dir that isn't in the manifest is preserved with a warning.
const stem = entry.slice(prefix.length);
if (canonicalStems && canonicalStems.has(stem)) {
isGsdOwned = true;
} else if (fs.existsSync(path.join(entryPath, CAPABILITY_SKILL_MARKER))) {
// #2322 HIGH-3: not a first-party stem, but self-certified as a
// GSD-managed THIRD-PARTY capability skill via the persisted marker
// (written by install-profiles.cts stageSkillsForRuntimeAsSkills at
// stage time). Without this, an orphaned capability skill — its
// owning capability uninstalled/unsurfaced and no longer appearing in
// ANY registry view — had no manifest entry at all and fell into the
// "unknown, preserve with warning" branch below FOREVER: uninstalling
// a malicious capability never actually removed its already-staged
// instructions from the agent's context. The marker makes ownership
// self-certifying at prune time, independent of current registry
// state (or even of whether a manifest was supplied at all).
isGsdOwned = true;
} else if (!canonicalStems) {
// No manifest available and no capability marker: cannot confirm
// ownership — preserve conservatively (silent).
continue;
} else {
process.stderr.write(
`[gsd] Warning: ${entry} matches GSD prefix '${prefix}' but is not in the manifest — preserving (user-owned or unknown)\n`
);
continue;
}
} else if (canonicalStems) {
// Hermes: GSD-owned iff the directory name appears in the canonical manifest.
isGsdOwned = canonicalStems.has(entry);
} else {
// No manifest available: be conservative, don't remove anything.
continue;
}
if (!isGsdOwned) continue; // Hermes path only: preserve user-owned dirs not in manifest
if (retainedNames.has(entry)) continue; // GSD-owned and in retain set
try {
fs.rmSync(entryPath, { recursive: true, force: true });
} catch (err) {
process.stderr.write(`surface: failed to prune ${entryPath}: ${(err as Error).message}\n`);
}
}
}
/**
* Sync destination directory from staged source.
*
* For 'commands' kind: iterate *.md files in destDir, remove if not in staged set.
* For 'agents' kind: same, but only remove files starting with 'gsd-' prefix.
* For 'skills' kind: iterate directories in destDir matching kind.prefix; add missing
* by copying recursively; remove dirs not in staged set. Preserves dirs not matching
* the prefix (user-owned skills). Pruning is delegated to pruneSkillDirs().
*
* For Hermes (empty prefix): uses manifest membership to discriminate GSD-owned vs
* user-owned dirs. GSD-owned = stem in manifest; removal targets = in manifest AND
* not in staged set. User-owned (not in manifest) are always preserved.
*/
function _syncGsdDir(stagedDir: string, destDir: string, kind: ArtifactKind | string, manifest?: Map<string, string[]>, runtime?: string): void {
if (!fs.existsSync(stagedDir)) return;
fs.mkdirSync(destDir, { recursive: true });
// Normalize: allow legacy string context for backward-compat with internal callers
const kindName = (typeof kind === 'string') ? kind : kind.kind;
const kindPrefix = (typeof kind === 'object' && kind !== null) ? kind.prefix : 'gsd-';
// #1575 / #2103: agent files are renamed .md -> <agentFileExtension> at copy
// time when the runtime's descriptor declares hostBehaviors.agentFileExtension
// (e.g. copilot's '.agent.md'), mirroring install-engine.cts's staged-copy
// loop (`_copyStaged`) — ONE descriptor read shared by both surfaces instead
// of a duplicated hardcoded `runtime === 'copilot'` literal. Other runtimes
// (no agentFileExtension declared) keep the staged filename verbatim.
const _agentExt = runtime ? runtimeArtifactConversion.agentFileExtensionFor(runtime) : undefined;
const isRenamedAgents = !!_agentExt && kindName === 'agents';
if (kindName === 'skills') {
// Skills kind: work with directories, not files.
// Each staged entry is a directory named ${prefix}${stem}.
const stagedDirs = new Set<string>(
fs.readdirSync(stagedDir).filter(entry => {
return fs.statSync(path.join(stagedDir, entry)).isDirectory();
})
);
// Copy missing dirs from staged to dest (always overwrite to ensure content is current)
for (const dirName of stagedDirs) {
const destSubDir = path.join(destDir, dirName);
fs.cpSync(path.join(stagedDir, dirName), destSubDir, { recursive: true });
}
// Prune GSD-owned dirs that are no longer in the staged set.
// pruneSkillDirs() is the single point of truth for this logic.
pruneSkillDirs(destDir, stagedDirs, kindPrefix, manifest);
} else {
// commands / agents kind: mirror installRuntimeArtifacts (_copyStaged /
// _removeGsdEntries in bin/install.js) so surface produces the SAME files as a
// fresh install (#816). Flat command dirs (opencode/cursor/augment/kilo) take
// the gsd- prefix on copy; namespaced command dirs (commands/gsd) and agents
// keep their staged names. Copying staged names verbatim previously diverged
// from install and orphaned the installed gsd-*.md files, and the unscoped
// prune deleted user-owned command files.
//
// Single source of truth: runtimeArtifactLayout.isNamespacedByDir (#2871
// Phase 2 review finding — this rule previously drifted independently
// across install-engine.cts / surface.cts / runtime-artifact-layout.cts).
const kindDestSubpath = (typeof kind === 'object' && kind !== null && kind.destSubpath) ? kind.destSubpath : '';
const namespacedByDir = runtimeArtifactLayout.isNamespacedByDir(kindName, kindDestSubpath, kindPrefix);
const stagedFiles = fs.readdirSync(stagedDir).filter(f => f.endsWith('.md'));
const stagedDestNames = new Set<string>();
for (const file of stagedFiles) {
const destName = isRenamedAgents
? file.replace(/\.md$/, _agentExt)
: (kindName === 'agents' || namespacedByDir)
? file
: `${kindPrefix}${file.slice(0, -3)}.md`;
fs.copyFileSync(path.join(stagedDir, file), path.join(destDir, destName));
stagedDestNames.add(destName);
}
// Prune stale GSD-owned files not in the staged set, preserving user-owned files
// (mirrors install's prefix-scoped _removeGsdEntries):
// - agents: only gsd-* are GSD-owned (copilot: gsd-*.agent.md)
// - flat command dirs: only `${kindPrefix}`-prefixed are GSD-owned
// - namespaced command dirs: the whole dir is GSD-owned
const shouldPruneAgents = !(kindName === 'agents' && (!manifest || manifest.size === 0));
if (shouldPruneAgents) {
for (const file of fs.readdirSync(destDir).filter(f => f.endsWith('.md'))) {
if (kindName === 'agents' && !file.startsWith('gsd-')) continue;
if (kindName === 'commands' && !namespacedByDir && kindPrefix && !file.startsWith(kindPrefix)) continue;
if (!stagedDestNames.has(file)) {
try { fs.unlinkSync(path.join(destDir, file)); } catch { /* ignore */ }
}
}
}
}
}
// ---------------------------------------------------------------------------
// List
// ---------------------------------------------------------------------------
/**
* List the currently enabled and disabled skills with token cost.
*
* Token cost = sum of description lengths ÷ 4 (mirrors audit script).
* Descriptions are read from the install source (findInstallSourceRoot).
*/
function listSurface(runtimeConfigDir: string, manifest: Map<string, string[]> | object, clusterMap?: ClusterMap | Record<string, string[]>, registry?: { capabilityClusters?: Record<string, string[]>; profileMembership?: Record<string, { tier: string; profiles: string[] }> }): { enabled: string[]; disabled: string[]; tokenCost: number } {
const skillManifest = normalizeSkillManifest(runtimeConfigDir, manifest);
const resolved = resolveSurface(runtimeConfigDir, skillManifest, clusterMap, registry);
// All known stems from manifest (exclude _calls_agents_ meta keys)
const allStems: string[] = [];
for (const [key] of skillManifest) {
if (!key.startsWith('_calls_agents_')) allStems.push(key);
}
const enabledSet = resolved.skills instanceof Set ? resolved.skills : new Set(allStems);
const enabled = allStems.filter(s => enabledSet.has(s)).sort();
const disabled = allStems.filter(s => !enabledSet.has(s)).sort();
// Compute token cost by reading descriptions from the install source
const srcCommandsDir = findInstallSourceRoot(runtimeConfigDir);
let tokenCost = 0;
for (const stem of enabled) {
const filePath = path.join(srcCommandsDir, `${stem}.md`);
try {
const content = fs.readFileSync(filePath, 'utf8');
const descMatch = content.match(/^description:\s*(.+)$/m);
if (descMatch) {
tokenCost += Math.ceil(descMatch[1].trim().length / 4);
}
} catch { /* ignore */ }
}
return { enabled, disabled, tokenCost };
}
// ---------------------------------------------------------------------------
// Exports
// ---------------------------------------------------------------------------
export = {
readSurface,
writeSurface,
resolveSurface,
applySurface,
listSurface,
// Exported for testing and for callers that need stand-alone pruning
pruneSkillDirs,
_syncGsdDir,
};