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>
This commit is contained in:
646
tests/agent-descriptor-parity.install.test.cjs
Normal file
646
tests/agent-descriptor-parity.install.test.cjs
Normal file
@@ -0,0 +1,646 @@
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: source-text-is-the-product #2875 — the J-row assertions
|
||||
// pattern-match rendered agent-.md frontmatter (`effort:`, `model:`,
|
||||
// disallowedTools, branding text). That frontmatter IS the deployed artifact
|
||||
// each runtime loads at dispatch time (no typed IR exists between
|
||||
// applyAgentFrontmatterExtensions/injectEffortFrontmatter and the file a
|
||||
// runtime reads) — matching CONTRIBUTING.md's `source-text-is-the-product`
|
||||
// exemption, not a workaroundable "hide the grep in a parser" case.
|
||||
|
||||
/**
|
||||
* agent-descriptor-parity.install.test.cjs — #2875 Part 2 (the agents-bypass
|
||||
* closure), 50-test-matrix.md sections H, I, J (rows H1-H8, I1-I3, J1-J10).
|
||||
*
|
||||
* HONEST BASELINE (rewritten — a prior revision of this file was found to
|
||||
* test the wrong thing on every axis; see the fixed defects below):
|
||||
*
|
||||
* bin/install.js's inline agent-staging loop is GONE (deleted in the same
|
||||
* commit that made every runtime descriptor-driven for agents). There is no
|
||||
* second, independently-maintained agent-staging pipeline left in this
|
||||
* codebase to diff against — an "old pipeline vs new pipeline" comparison is
|
||||
* therefore IMPOSSIBLE post-deletion, and a prior revision of this file's
|
||||
* header claiming to compare against "bin/install.js's inline agent-staging
|
||||
* loop" was false the moment that loop was deleted.
|
||||
*
|
||||
* What THIS revision actually proves instead, and how:
|
||||
*
|
||||
* 1. The REAL production entry point (`installAgentsKindStandalone`,
|
||||
* `install-engine.cjs`) is driven directly, against the REAL, BUILT
|
||||
* `capability-registry.cjs` — no synthetic registry override. Every H
|
||||
* row therefore byte-compares actual output written by a real
|
||||
* `capabilities/<runtime>/capability.json` edit, not a hand-rolled
|
||||
* stand-in for one. A wrong-but-syntactically-valid `converter` name
|
||||
* landing in a real descriptor changes the ACTUAL side's output and is
|
||||
* caught (see H8's red-proof, which demonstrates this directly).
|
||||
*
|
||||
* 2. `computeExpectedOutput` (the "oracle") independently assembles the
|
||||
* EXPECTED bytes by calling the individual conversion PRIMITIVES
|
||||
* directly: `composeWorkflow`, `applyAgentPathRewrites`,
|
||||
* `processAttribution`, the runtime's converter function (dispatched
|
||||
* off `EXPECTED_CONVERTER_NAME_BY_RUNTIME` — a hand-verified,
|
||||
* independent map, NOT read from the descriptor under test),
|
||||
* `applyAgentFrontmatterExtensions`, `normalizeAgentBodyForRuntime`.
|
||||
* These primitives are — by construction, not accident —
|
||||
* single-sourced: there is no live duplicate of `composeWorkflow` or
|
||||
* `applyAgentPathRewrites` to diff against either, because #2875 Part 2
|
||||
* collapsed the duplication into these shared functions. Reusing them
|
||||
* here does not defeat the test: the property under test in every H row
|
||||
* is "does capability.json's declared `converter` name resolve to the
|
||||
* CORRECT converter and get invoked in the correct position of the
|
||||
* pipeline" — which the independent `EXPECTED_CONVERTER_NAME_BY_RUNTIME`
|
||||
* map exists specifically to keep decoupled from the descriptor.
|
||||
*
|
||||
* 3. Both scopes are exercised for every runtime that declares a
|
||||
* per-scope `agents` kind (claude, cline, codex, hermes, kilo, opencode,
|
||||
* kimi-code × global+local). The prior revision was global-only, which
|
||||
* is exactly the class of gap that let two separate agents-drop
|
||||
* regressions reach `next` undetected: cline-local (fixed alongside
|
||||
* this rewrite — capabilities/cline/capability.json's `local`
|
||||
* artifactLayout now declares an `agents` kind) and kimi-code-local
|
||||
* (fixed the same way — its `local` artifactLayout previously declared
|
||||
* no `agents` kind at all, so the deleted inline loop's implicit
|
||||
* scope-gate was silently replaced with NO gate, dropping every
|
||||
* kimi-code local install's agents/gsd-*.md entirely).
|
||||
*
|
||||
* H8 is mandatory, not optional: a parity harness never demonstrated failing
|
||||
* is decoration. It feeds the oracle a DELIBERATELY WRONG (but real,
|
||||
* allowlisted) converter name and asserts the comparison goes red against
|
||||
* the REAL (correctly-configured) production output — proving that if
|
||||
* capability.json's declared converter ever regressed, this exact harness
|
||||
* would catch it.
|
||||
*
|
||||
* WHAT THIS FILE DOES NOT PROVE: H8's red-proof demonstrates exactly one
|
||||
* failure class — the oracle and the real descriptor path resolving to a
|
||||
* DIFFERENT converter for the same runtime. It says nothing about a bug
|
||||
* INSIDE a shared primitive (`composeWorkflow`, `applyAgentPathRewrites`,
|
||||
* `processAttribution`, `applyAgentFrontmatterExtensions`,
|
||||
* `normalizeAgentBodyForRuntime`, or a named converter itself): both the
|
||||
* oracle (point 2 above) and the real descriptor path call the identical
|
||||
* function, so a regression inside one of those functions changes BOTH
|
||||
* sides identically and every H row stays green. That is a deliberate,
|
||||
* unavoidable consequence of point 2's single-sourcing (there is no second,
|
||||
* independently-implemented copy of those primitives left to diff against
|
||||
* post-#2875-Part-2) — this file is a converter-WIRING parity gate, not a
|
||||
* substitute for direct unit coverage of the primitives themselves (which
|
||||
* live in their own owning test files, e.g. `runtime-artifact-conversion`'s
|
||||
* suite).
|
||||
*/
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
const LIB_DIR = path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib');
|
||||
|
||||
const runtimeArtifactConversion = require(path.join(LIB_DIR, 'runtime-artifact-conversion.cjs'));
|
||||
const installModelOverrideResolver = require(path.join(LIB_DIR, 'install-model-override-resolver.cjs'));
|
||||
const installEngine = require(path.join(LIB_DIR, 'install-engine.cjs'));
|
||||
const capabilityRegistry = require(path.join(LIB_DIR, 'capability-registry.cjs'));
|
||||
const { composeWorkflow } = require(path.join(LIB_DIR, 'workflow-fragments.cjs'));
|
||||
const installBin = require(path.join(REPO_ROOT, 'bin', 'install.js'));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Deterministic sample agent sources — NOT the real agents/ tree (the real
|
||||
* tree's exact roster can change independently of this suite). Covers:
|
||||
* ~/.claude/ + $HOME/.claude/ (anchored + bare) path forms, a Co-Authored-By
|
||||
* trailer, and one row-J4 "disallowedTools hit" agent name plus one "miss". */
|
||||
const SAMPLE_AGENTS = {
|
||||
'gsd-planner.md': [
|
||||
'---',
|
||||
'name: gsd-planner',
|
||||
'description: Plans phases for GSD workflows.',
|
||||
'tools: Read, Write, Edit, Bash',
|
||||
'---',
|
||||
'',
|
||||
'Reads @~/.claude/gsd-core/commands/gsd/plan-phase.md and $HOME/.claude/CLAUDE.md.',
|
||||
'Bare forms too: ~/.claude and $HOME/.claude.',
|
||||
'References Claude Code and CLAUDE.md and .claude/settings.json.',
|
||||
'',
|
||||
'Co-Authored-By: Claude <noreply@anthropic.com>',
|
||||
'',
|
||||
].join('\n'),
|
||||
'gsd-plan-checker.md': [
|
||||
'---',
|
||||
'name: gsd-plan-checker',
|
||||
'description: Checks plans for GSD workflows.',
|
||||
'tools: Read, Grep, Glob',
|
||||
'---',
|
||||
'',
|
||||
'A read-only checker agent (row J4 "hit" — declared in READONLY_AGENT_DISALLOWED_TOOLS).',
|
||||
'',
|
||||
].join('\n'),
|
||||
};
|
||||
|
||||
function buildSourceTree(agentFiles) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-agent-parity-src-'));
|
||||
const commandsGsd = path.join(root, 'commands', 'gsd');
|
||||
fs.mkdirSync(commandsGsd, { recursive: true });
|
||||
const agentsDir = path.join(root, 'agents');
|
||||
fs.mkdirSync(agentsDir, { recursive: true });
|
||||
for (const [name, content] of Object.entries(agentFiles)) {
|
||||
fs.writeFileSync(path.join(agentsDir, name), content);
|
||||
}
|
||||
return { root, commandsGsd, agentsDir };
|
||||
}
|
||||
|
||||
/** A fresh "install destination" dir with a `.gsd-source` marker pointing at
|
||||
* `commandsGsd` — the same marker findInstallSourceRoot/findAgentsSourceRoot
|
||||
* read (runtime-artifact-layout.cts). */
|
||||
function buildTargetDir(commandsGsd) {
|
||||
const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-agent-parity-dest-'));
|
||||
fs.writeFileSync(path.join(targetDir, '.gsd-source'), commandsGsd);
|
||||
return targetDir;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Oracle — independently-assembled EXPECTED output (see header doc)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Hand-verified, independent of any capability.json — this is the thing
|
||||
* every H row's ACTUAL side (a real capability.json) is checked against.
|
||||
* `null` means converter:null (identity — claude, kimi-code). */
|
||||
const EXPECTED_CONVERTER_NAME_BY_RUNTIME = {
|
||||
claude: null,
|
||||
'kimi-code': null,
|
||||
cline: 'convertClaudeAgentToClineAgent',
|
||||
codex: 'convertClaudeAgentToCodexAgent',
|
||||
hermes: 'convertClaudeAgentToHermesAgent',
|
||||
kilo: 'convertClaudeToKiloFrontmatter',
|
||||
opencode: 'convertClaudeToOpencodeFrontmatter',
|
||||
};
|
||||
|
||||
/** Converter functions callable by name — bin/install.js still owns
|
||||
* cline/codex/kilo/opencode's (never migrated to runtime-artifact-conversion.cjs);
|
||||
* hermes's is descriptor-native (#2875 Part 2 / J9-J10). */
|
||||
const NAMED_CONVERTERS = {
|
||||
convertClaudeAgentToClineAgent: installBin.convertClaudeAgentToClineAgent,
|
||||
convertClaudeAgentToCodexAgent: installBin.convertClaudeAgentToCodexAgent,
|
||||
convertClaudeAgentToHermesAgent: runtimeArtifactConversion.convertClaudeAgentToHermesAgent,
|
||||
convertClaudeToKiloFrontmatter: installBin.convertClaudeToKiloFrontmatter,
|
||||
convertClaudeToOpencodeFrontmatter: installBin.convertClaudeToOpencodeFrontmatter,
|
||||
};
|
||||
|
||||
/** kilo/opencode take an options bag (`{isAgent, modelOverride}`), resolved
|
||||
* ONCE per call via the single shared precedence resolver (J5-J8) — every
|
||||
* other converter here takes only `content`. */
|
||||
const MODEL_OVERRIDE_CONVERTER_NAMES = new Set(['convertClaudeToKiloFrontmatter', 'convertClaudeToOpencodeFrontmatter']);
|
||||
|
||||
/**
|
||||
* Assemble the EXPECTED per-file output for `runtime` from `agentsDir`,
|
||||
* calling the shared conversion primitives directly (see header doc for why
|
||||
* this is not circular). `converterNameOverride`, when passed, replaces
|
||||
* `EXPECTED_CONVERTER_NAME_BY_RUNTIME[runtime]` — used ONLY by H8's
|
||||
* red-proof to inject a deliberately wrong converter.
|
||||
*/
|
||||
function computeExpectedOutput(runtime, agentsDir, ctx, converterNameOverride) {
|
||||
const converterName = converterNameOverride !== undefined ? converterNameOverride : EXPECTED_CONVERTER_NAME_BY_RUNTIME[runtime];
|
||||
const { pathPrefix, attribution, targetDir } = ctx;
|
||||
const out = new Map();
|
||||
const entries = fs.readdirSync(agentsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
||||
const agentSourcePath = path.join(agentsDir, entry.name);
|
||||
let content = fs.readFileSync(agentSourcePath, 'utf8');
|
||||
// Step 0 (#2995): strip gsd:section markers — same order the real
|
||||
// pipeline uses (stageAgentsForRuntimeWithConverter, install-profiles.cts).
|
||||
content = composeWorkflow(content, { sourcePath: agentSourcePath });
|
||||
const agentName = runtimeArtifactConversion.deriveAgentName(entry.name);
|
||||
// Step 1: path rewrites
|
||||
content = runtimeArtifactConversion.applyAgentPathRewrites(content, runtime, pathPrefix);
|
||||
// Step 2: attribution
|
||||
content = runtimeArtifactConversion.processAttribution(content, attribution);
|
||||
// Step 3: converter — dispatched off the INDEPENDENT expected-name map,
|
||||
// never off the descriptor under test.
|
||||
if (converterName) {
|
||||
const fn = NAMED_CONVERTERS[converterName];
|
||||
assert.ok(typeof fn === 'function', `oracle: no converter function registered for "${converterName}"`);
|
||||
if (MODEL_OVERRIDE_CONVERTER_NAMES.has(converterName)) {
|
||||
const modelOverride = installModelOverrideResolver.resolveAgentModelOverride(
|
||||
agentName,
|
||||
installModelOverrideResolver.readGsdEffectiveModelOverrides(targetDir),
|
||||
installModelOverrideResolver.readGsdRuntimeProfileResolver(targetDir),
|
||||
);
|
||||
content = fn(content, { isAgent: true, modelOverride });
|
||||
} else {
|
||||
content = fn(content);
|
||||
}
|
||||
}
|
||||
// converter:null (claude, kimi-code) — content unchanged by this step.
|
||||
// Step 4: frontmatter extensions (effort/disallowedTools)
|
||||
content = runtimeArtifactConversion.applyAgentFrontmatterExtensions(content, { runtime, agentName, targetDir });
|
||||
// Step 5: normalize colon->hyphen refs
|
||||
content = runtimeArtifactConversion.normalizeAgentBodyForRuntime(
|
||||
content,
|
||||
runtime,
|
||||
runtimeArtifactConversion.readGsdCommandNames(),
|
||||
);
|
||||
out.set(entry.name, content);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Real descriptor path — the REAL production entry point, REAL registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Drive the ACTUAL production entry point (`installAgentsKindStandalone`)
|
||||
* against the REAL, built `capability-registry.cjs` — no override. This is
|
||||
* exactly what `bin/install.js`'s `install()` calls for every runtime whose
|
||||
* layout is not otherwise reached by the generic `installRuntimeArtifacts`
|
||||
* loop (and, for the runtimes reached by that loop, produces identical
|
||||
* output to it — both route through the SAME `convertedAgentsKind` /
|
||||
* `_resolveNamedConverter` dispatch in runtime-artifact-layout.cts).
|
||||
*/
|
||||
function runRealDescriptorPath(runtime, scope, targetDir, ctx) {
|
||||
const resolvedProfile = { name: 'full', skills: '*', agents: new Set() };
|
||||
const result = installEngine.installAgentsKindStandalone(
|
||||
runtime,
|
||||
targetDir,
|
||||
scope,
|
||||
resolvedProfile,
|
||||
ctx.pathPrefix,
|
||||
() => ctx.attribution,
|
||||
);
|
||||
const out = new Map();
|
||||
if (!result) return out;
|
||||
for (const entry of fs.readdirSync(result.destDir, { withFileTypes: true })) {
|
||||
if (entry.isFile()) out.set(entry.name, fs.readFileSync(path.join(result.destDir, entry.name), 'utf8'));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Comparison helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Every (runtime, scope) pair the REAL capabilities/<runtime>/capability.json
|
||||
* today declares an `agents` kind for (measured 2026-08-17). K1 below is the
|
||||
* machine-checked guarantee that this list cannot silently go stale — it
|
||||
* sweeps the real registry and fails if a declarant is missing here. */
|
||||
const RUNTIME_SCOPE_PAIRS = [
|
||||
['claude', 'global'], ['claude', 'local'],
|
||||
['cline', 'global'], ['cline', 'local'],
|
||||
['codex', 'global'], ['codex', 'local'],
|
||||
['hermes', 'global'], ['hermes', 'local'],
|
||||
['kilo', 'global'], ['kilo', 'local'],
|
||||
['opencode', 'global'], ['opencode', 'local'],
|
||||
['kimi-code', 'global'], ['kimi-code', 'local'],
|
||||
];
|
||||
|
||||
/** Model override literal shared by J8's two `_stageWithModelOverride` calls. */
|
||||
const J8_OVERRIDE_MODEL = 'shared/explicit-model';
|
||||
|
||||
/**
|
||||
* Standalone helper (module scope, no test-context access — the
|
||||
* CONTRIBUTING.md "Never use try/finally inside test bodies" exemption) for
|
||||
* J8: stage a single-agent source tree with a real `.planning/config.json`
|
||||
* model_overrides block for `runtime` through the real descriptor path.
|
||||
*/
|
||||
function _stageWithModelOverride(runtime, overrideModel) {
|
||||
const agentFiles = { 'gsd-planner.md': SAMPLE_AGENTS['gsd-planner.md'] };
|
||||
const { commandsGsd, root } = buildSourceTree(agentFiles);
|
||||
const targetDir = buildTargetDir(commandsGsd);
|
||||
fs.mkdirSync(path.join(targetDir, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(targetDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ model_overrides: { 'gsd-planner': overrideModel } }),
|
||||
);
|
||||
try {
|
||||
const ctx = { pathPrefix: `${targetDir}/`, attribution: undefined, targetDir };
|
||||
return runRealDescriptorPath(runtime, 'global', targetDir, ctx);
|
||||
} finally {
|
||||
cleanup(root);
|
||||
cleanup(targetDir);
|
||||
}
|
||||
}
|
||||
|
||||
function comparePipelines(runtime, scope, converterNameOverride) {
|
||||
const { commandsGsd, agentsDir, root } = buildSourceTree(SAMPLE_AGENTS);
|
||||
const targetDir = buildTargetDir(commandsGsd);
|
||||
try {
|
||||
const ctx = { pathPrefix: `${targetDir}/`, attribution: undefined, targetDir };
|
||||
const expected = computeExpectedOutput(runtime, agentsDir, ctx, converterNameOverride);
|
||||
const actual = runRealDescriptorPath(runtime, scope, targetDir, ctx);
|
||||
return { expected, actual };
|
||||
} finally {
|
||||
cleanup(root);
|
||||
cleanup(targetDir);
|
||||
}
|
||||
}
|
||||
|
||||
/** Asserts H1-H6 + H7 in one shot: same filenames (Set equality, order-free)
|
||||
* AND byte-identical content per filename. */
|
||||
function assertMapsIdentical(expected, actual) {
|
||||
assert.deepEqual(
|
||||
[...expected.keys()].sort(),
|
||||
[...actual.keys()].sort(),
|
||||
'filenames diverged between the oracle and the real descriptor path (row H7)',
|
||||
);
|
||||
for (const [name, expectedContent] of expected) {
|
||||
assert.equal(
|
||||
actual.get(name),
|
||||
expectedContent,
|
||||
`content diverged for ${name} between the oracle and the real descriptor path`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// H rows — the parity gate, one row per (runtime, scope)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
for (const [runtime, scope] of RUNTIME_SCOPE_PAIRS) {
|
||||
test(`agent-descriptor-parity: H row — ${runtime} (${scope}) real descriptor output matches the independent oracle`, () => {
|
||||
const { expected, actual } = comparePipelines(runtime, scope);
|
||||
assert.ok(expected.size > 0, 'fixture produced no oracle output — test is vacuous');
|
||||
assertMapsIdentical(expected, actual);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// H7 — the harness compares filenames, not only content (meta)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('agent-descriptor-parity: H7 — a filename-only divergence fails the harness', (t) => {
|
||||
const { commandsGsd, agentsDir, root } = buildSourceTree(SAMPLE_AGENTS);
|
||||
const targetDir = buildTargetDir(commandsGsd);
|
||||
t.after(() => {
|
||||
cleanup(root);
|
||||
cleanup(targetDir);
|
||||
});
|
||||
const ctx = { pathPrefix: `${targetDir}/`, attribution: undefined, targetDir };
|
||||
const expected = computeExpectedOutput('claude', agentsDir, ctx);
|
||||
const actual = runRealDescriptorPath('claude', 'global', targetDir, ctx);
|
||||
// Deliberately rename one actual-side entry — same bytes, different name.
|
||||
const [firstName, firstContent] = [...actual.entries()][0];
|
||||
actual.delete(firstName);
|
||||
actual.set(`RENAMED-${firstName}`, firstContent);
|
||||
assert.throws(
|
||||
() => assertMapsIdentical(expected, actual),
|
||||
/filenames diverged/,
|
||||
'a renamed output file must fail the harness',
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// H8 — the harness can actually FAIL (mandatory, not optional)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('agent-descriptor-parity: H8 — a deliberately-wrong (but real, allowlisted) expected converter turns the harness RED', (t) => {
|
||||
// Hermes's REAL capability.json declares convertClaudeAgentToHermesAgent.
|
||||
// Feed the ORACLE a different, real, allowlisted converter name
|
||||
// (convertClaudeAgentToCodexAgent) instead — simulating exactly the failure
|
||||
// mode row H exists to catch: capability.json's declared converter silently
|
||||
// diverging from the correct one. The REAL descriptor path is untouched and
|
||||
// still uses hermes's real (correct) converter, so this proves: IF
|
||||
// capability.json ever regressed to the wrong name, THIS harness's H row
|
||||
// for hermes would go red exactly like this.
|
||||
const { commandsGsd, agentsDir, root } = buildSourceTree(SAMPLE_AGENTS);
|
||||
const targetDir = buildTargetDir(commandsGsd);
|
||||
t.after(() => {
|
||||
cleanup(root);
|
||||
cleanup(targetDir);
|
||||
});
|
||||
const ctx = { pathPrefix: `${targetDir}/`, attribution: undefined, targetDir };
|
||||
const wrongExpected = computeExpectedOutput('hermes', agentsDir, ctx, 'convertClaudeAgentToCodexAgent');
|
||||
const realActual = runRealDescriptorPath('hermes', 'global', targetDir, ctx);
|
||||
let threw = false;
|
||||
let observedDiff = null;
|
||||
try {
|
||||
assertMapsIdentical(wrongExpected, realActual);
|
||||
} catch (err) {
|
||||
threw = true;
|
||||
observedDiff = err.message;
|
||||
}
|
||||
assert.equal(threw, true, 'H8 FAILED: the harness did not go red for a deliberately-wrong expected converter');
|
||||
assert.match(observedDiff, /content diverged for gsd-(planner|plan-checker)\.md/, 'expected the content-diverged assertion to name the mismatched file');
|
||||
// Verbatim red-proof output for the record (see CHANGES report):
|
||||
console.log(`H8 red-proof observed: ${observedDiff}`);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// I1-I3 — per-agent resolution context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('agent-descriptor-parity: I3 — deriveAgentName matches the pipeline exactly, including a no-.md-suffix boundary', () => {
|
||||
assert.equal(runtimeArtifactConversion.deriveAgentName('gsd-planner.md'), 'gsd-planner');
|
||||
// Boundary: a filename with no trailing .md is returned unchanged (the
|
||||
// regex has nothing to match) — matches `entry.name.replace(/\.md$/, '')`.
|
||||
assert.equal(runtimeArtifactConversion.deriveAgentName('gsd-planner'), 'gsd-planner');
|
||||
assert.equal(runtimeArtifactConversion.deriveAgentName('gsd-planner.MD'), 'gsd-planner.MD');
|
||||
});
|
||||
|
||||
test('agent-descriptor-parity: I2 — real descriptor path with no agentCtx is unaffected (converter-only)', (t) => {
|
||||
const { commandsGsd, agentsDir, root } = buildSourceTree(SAMPLE_AGENTS);
|
||||
const targetDir = buildTargetDir(commandsGsd);
|
||||
t.after(() => {
|
||||
cleanup(root);
|
||||
cleanup(targetDir);
|
||||
});
|
||||
const runtimeArtifactLayout = require(path.join(LIB_DIR, 'runtime-artifact-layout.cjs'));
|
||||
const realLayout = runtimeArtifactLayout.resolveRuntimeArtifactLayout('claude', targetDir, 'global');
|
||||
const agentsKindEntry = realLayout.kinds.find((k) => k.kind === 'agents');
|
||||
const stagedDir = agentsKindEntry.stage({ name: 'full', skills: '*', agents: new Set() }); // no agentCtx
|
||||
t.after(() => cleanup(stagedDir));
|
||||
const planner = fs.readFileSync(path.join(stagedDir, 'gsd-planner.md'), 'utf8');
|
||||
const original = fs.readFileSync(path.join(agentsDir, 'gsd-planner.md'), 'utf8');
|
||||
assert.equal(planner, original, 'no agentCtx must leave content byte-identical to source (converter:null == identity)');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// J1-J4 — frontmatter extensions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('agent-descriptor-parity: J1 — claude effort is injected via applyAgentFrontmatterExtensions', () => {
|
||||
const content = '---\nname: gsd-planner\ndescription: x\n---\n\nBody.\n';
|
||||
const viaShared = runtimeArtifactConversion.applyAgentFrontmatterExtensions(content, { runtime: 'claude', agentName: 'gsd-planner', targetDir: null });
|
||||
assert.match(viaShared, /^effort: /m, 'expected an effort: key to be injected for claude');
|
||||
});
|
||||
|
||||
test('agent-descriptor-parity: J2 — effort resolving to inherit writes NO effort: key at all, exercised via the REAL guard in applyAgentFrontmatterExtensions (#3533 trap row)', (t) => {
|
||||
// #2875 Part 2 defect fix: a prior revision of this row asserted the DUMB
|
||||
// half (injectEffortFrontmatter DOES emit the literal 'inherit' if called
|
||||
// with it) and never called applyAgentFrontmatterExtensions at all — so
|
||||
// deleting the `universalEffort !== 'inherit'` guard at
|
||||
// runtime-artifact-conversion.cts:3504 left this row green. This revision
|
||||
// writes a REAL .planning/config.json under targetDir (readGsdEffectiveEffortConfig
|
||||
// walks up from targetDir looking for it — install-effort-resolver.cts)
|
||||
// and calls the REAL applyAgentFrontmatterExtensions end to end, so removing
|
||||
// that guard makes THIS assertion fail (verified: red with the guard
|
||||
// removed, green with it restored — see CHANGES report).
|
||||
const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-agent-parity-j2-'));
|
||||
t.after(() => cleanup(targetDir));
|
||||
fs.mkdirSync(path.join(targetDir, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(targetDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ effort: { agent_overrides: { 'gsd-inherit-agent': 'inherit' } } }),
|
||||
);
|
||||
const content = '---\nname: gsd-inherit-agent\ndescription: x\n---\n\nBody.\n';
|
||||
const out = runtimeArtifactConversion.applyAgentFrontmatterExtensions(content, { runtime: 'claude', agentName: 'gsd-inherit-agent', targetDir });
|
||||
assert.doesNotMatch(out, /^effort:/m, 'an agent resolving to "inherit" must get no effort: key at all — not even effort: inherit');
|
||||
// Sanity: injectEffortFrontmatter itself is dumb and WOULD write the
|
||||
// literal if called with it — the guard is applyAgentFrontmatterExtensions
|
||||
// never calling it for 'inherit', which is exactly what the assertion above proves.
|
||||
assert.match(runtimeArtifactConversion.injectEffortFrontmatter(content, 'inherit'), /^effort: inherit$/m);
|
||||
});
|
||||
|
||||
test('agent-descriptor-parity: J3 — a runtime NOT declaring agentFrontmatterExtensions gets nothing injected', () => {
|
||||
const content = '---\nname: gsd-plan-checker\ndescription: x\n---\n\nBody.\n';
|
||||
const out = runtimeArtifactConversion.applyAgentFrontmatterExtensions(content, { runtime: 'opencode', agentName: 'gsd-plan-checker', targetDir: null });
|
||||
assert.equal(out, content, 'opencode declares no agentFrontmatterExtensions — output must be byte-identical to input');
|
||||
});
|
||||
|
||||
test('agent-descriptor-parity: J4 — disallowedTools injected only on a READONLY_AGENT_DISALLOWED_TOOLS hit', () => {
|
||||
const content = '---\nname: gsd-plan-checker\ndescription: x\n---\n\nBody.\n';
|
||||
const hit = runtimeArtifactConversion.applyAgentFrontmatterExtensions(content, { runtime: 'claude', agentName: 'gsd-plan-checker', targetDir: null });
|
||||
assert.match(hit, /^disallowedTools: /m, 'gsd-plan-checker is a declared read-only agent — expected a disallowedTools hit');
|
||||
|
||||
const missContent = '---\nname: gsd-not-a-readonly-agent\ndescription: x\n---\n\nBody.\n';
|
||||
const miss = runtimeArtifactConversion.applyAgentFrontmatterExtensions(missContent, { runtime: 'claude', agentName: 'gsd-not-a-readonly-agent', targetDir: null });
|
||||
assert.doesNotMatch(miss, /^disallowedTools:/m, 'an agent absent from READONLY_AGENT_DISALLOWED_TOOLS must get no disallowedTools key');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// J5-J8 — model-override resolution (kilo/opencode), single-sourced
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('agent-descriptor-parity: J5 — explicit model_overrides[agent] wins (highest precedence)', () => {
|
||||
const modelOverrides = { 'gsd-planner': 'anthropic/explicit-model' };
|
||||
const runtimeResolver = { resolve: () => ({ model: 'anthropic/tier-model' }) }; // would win if precedence were wrong
|
||||
const result = installModelOverrideResolver.resolveAgentModelOverride('gsd-planner', modelOverrides, runtimeResolver);
|
||||
assert.equal(result, 'anthropic/explicit-model');
|
||||
});
|
||||
|
||||
test('agent-descriptor-parity: J6 — falls back to the runtime tier resolver when no explicit override exists', () => {
|
||||
const runtimeResolver = { resolve: (agentName) => (agentName === 'gsd-planner' ? { model: 'anthropic/tier-model' } : null) };
|
||||
const result = installModelOverrideResolver.resolveAgentModelOverride('gsd-planner', null, runtimeResolver);
|
||||
assert.equal(result, 'anthropic/tier-model');
|
||||
});
|
||||
|
||||
test('agent-descriptor-parity: J7 — neither configured resolves to null (omit), never "" or the string "null"', () => {
|
||||
const result = installModelOverrideResolver.resolveAgentModelOverride('gsd-planner', null, null);
|
||||
assert.equal(result, null);
|
||||
assert.notEqual(result, '');
|
||||
const contentWithoutModelOverride = installBin.convertClaudeToOpencodeFrontmatter(
|
||||
'---\nname: gsd-planner\ndescription: x\ntools: Read\n---\n\nBody.\n',
|
||||
{ isAgent: true, modelOverride: result },
|
||||
);
|
||||
assert.doesNotMatch(contentWithoutModelOverride, /^model:/m, 'an omitted override must not appear as a model: key at all');
|
||||
});
|
||||
|
||||
test('agent-descriptor-parity: J8 — kilo and opencode both resolve model overrides through the REAL descriptor path (installAgentsKindStandalone), proving ONE shared resolution path, not a tautology', () => {
|
||||
// #2875 Part 2 defect fix: a prior revision of this row called
|
||||
// resolveAgentModelOverride TWICE with identical arguments and asserted
|
||||
// equality — true of ANY pure function regardless of whether kilo/opencode
|
||||
// are actually wired to it. This revision drives BOTH runtimes through the
|
||||
// REAL production entry point with a REAL .planning/config.json
|
||||
// model_overrides block and asserts BOTH staged outputs carry the SAME
|
||||
// resolved model — which only happens if both are genuinely wired to the
|
||||
// one shared installModelOverrideResolver.resolveAgentModelOverride.
|
||||
const kiloOut = _stageWithModelOverride('kilo', J8_OVERRIDE_MODEL);
|
||||
const opencodeOut = _stageWithModelOverride('opencode', J8_OVERRIDE_MODEL);
|
||||
// J8_OVERRIDE_MODEL is a fixed constant: assert the exact expected
|
||||
// frontmatter line, matched line-wise, rather than building a RegExp from
|
||||
// an interpolated value (CodeQL js/incomplete-sanitization — a
|
||||
// metachar-bearing value would silently widen the match).
|
||||
const expectedModelLine = `model: ${J8_OVERRIDE_MODEL}`;
|
||||
assert.ok(
|
||||
kiloOut.get('gsd-planner.md').split('\n').some((line) => line.trim() === expectedModelLine),
|
||||
'kilo must apply the shared model override via the real descriptor path',
|
||||
);
|
||||
assert.ok(
|
||||
opencodeOut.get('gsd-planner.md').split('\n').some((line) => line.trim() === expectedModelLine),
|
||||
'opencode must apply the SAME shared model override via the real descriptor path',
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// J9-J10 — hermes branding converter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('agent-descriptor-parity: J9 — hermes branding converter is byte-identical to the shared generic branding-rewrite function, including \\bClaude Code\\b word-boundary semantics', () => {
|
||||
const content = 'Claude Code and ClaudeCodeExtra and CLAUDE.md and .claude/foo and reClaude Code.\n';
|
||||
const viaSharedFn = runtimeArtifactConversion.applyAgentBrandingRewrites(content, 'hermes');
|
||||
const viaNamedConverter = runtimeArtifactConversion.convertClaudeAgentToHermesAgent(content);
|
||||
assert.equal(viaSharedFn, viaNamedConverter, 'the named converter must be a pure delegate to the generic branding-rewrite function');
|
||||
// \bClaude Code\b: neither "ClaudeCodeExtra" (no space/boundary between
|
||||
// "Claude" and "Code") nor "reClaude Code" (no boundary between the 'e' of
|
||||
// "re" and the 'C' of "Claude" — both word chars) satisfy the word-boundary
|
||||
// requirement, so BOTH are left untouched; only the standalone occurrence is
|
||||
// rewritten.
|
||||
assert.match(viaSharedFn, /Hermes Agent and ClaudeCodeExtra and HERMES\.md and \.hermes\/foo and reClaude Code\./);
|
||||
});
|
||||
|
||||
test('agent-descriptor-parity: J10 — the branding converter is descriptor-data-driven, not hardcoded to hermes strings', () => {
|
||||
const content = 'Claude Code uses CLAUDE.md under .claude/.\n';
|
||||
// A runtime with NO brandingRewrites declared gets nothing rewritten.
|
||||
assert.equal(runtimeArtifactConversion.applyAgentBrandingRewrites(content, 'claude'), content);
|
||||
// hermes (the only runtime with brandingRewrites AND no dedicated converter
|
||||
// pre-#2875) gets ITS OWN declared rewrite table applied — proving the
|
||||
// function reads the runtime's descriptor rather than a hermes-hardcoded literal.
|
||||
const hermesOut = runtimeArtifactConversion.applyAgentBrandingRewrites(content, 'hermes');
|
||||
assert.notEqual(hermesOut, content);
|
||||
assert.match(hermesOut, /Hermes Agent uses HERMES\.md under \.hermes\/\./);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// K1 — migration completeness: every registry runtime with an `agents` kind
|
||||
// is reachable from the REAL production entry point, not the (now-deleted)
|
||||
// inline loop.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* bin/install.js's inline agent-staging loop and its `_DESCRIPTOR_AGENTS_RUNTIMES`
|
||||
* gate were DELETED in #2875 Part 2 Task C — there is no longer a symbol to
|
||||
* assert absent (a source-text check would violate `local/no-source-grep` and
|
||||
* would prove nothing about runtime behavior anyway, per CLAUDE.md's
|
||||
* "Behavioral tests are required"). Row K1 is instead proven the only way
|
||||
* that is actually meaningful once the code is gone: for EVERY `role:
|
||||
* "runtime"` capability in the REAL capability-registry that declares an
|
||||
* `agents` kind (either scope), the REAL production entry point
|
||||
* (`installRuntimeArtifacts` — which internally routes combinedFamilyInstall
|
||||
* runtimes like kilo/opencode through `installOpencodeFamilyAgents`, #2875
|
||||
* Part 2 Task A) actually materializes agents/ on disk. If any runtime were
|
||||
* still silently depending on the deleted inline loop, this call would write
|
||||
* nothing to agents/ for it (the deleted code was the ONLY thing that used to
|
||||
* write it for the seven runtimes migrated in this change) and the assertion
|
||||
* below would fail.
|
||||
*/
|
||||
test('agent-descriptor-parity: K1 — every registry runtime declaring an agents kind is reachable from installRuntimeArtifacts (the inline loop is gone)', (t) => {
|
||||
const runtimesWithAgentsKind = Object.entries(capabilityRegistry.runtimes || {})
|
||||
.filter(([, cap]) => {
|
||||
const layout = cap.runtime && cap.runtime.artifactLayout;
|
||||
if (!layout) return false;
|
||||
const entries = [...(layout.global || []), ...(layout.local || [])];
|
||||
return entries.some((e) => e.kind === 'agents');
|
||||
})
|
||||
.map(([id]) => id);
|
||||
|
||||
assert.ok(runtimesWithAgentsKind.length >= 7, 'sanity: expected at least the seven #2875 Part 2 runtimes to declare an agents kind');
|
||||
assert.ok(runtimesWithAgentsKind.includes('kimi-code'), 'kimi-code (found via golden fixture, not analysis) must be covered here');
|
||||
assert.ok(runtimesWithAgentsKind.includes('cline'), 'cline must be covered here');
|
||||
|
||||
for (const runtime of runtimesWithAgentsKind) {
|
||||
const { commandsGsd, root } = buildSourceTree(SAMPLE_AGENTS);
|
||||
const targetDir = buildTargetDir(commandsGsd);
|
||||
t.after(() => {
|
||||
cleanup(root);
|
||||
cleanup(targetDir);
|
||||
});
|
||||
const resolvedProfile = { name: 'full', skills: '*', agents: new Set() };
|
||||
const result = installEngine.installRuntimeArtifacts(runtime, targetDir, 'global', resolvedProfile, () => undefined, undefined);
|
||||
const agentsKindEntry = result.kinds.find((k) => k.kind === 'agents');
|
||||
assert.ok(agentsKindEntry, `${runtime}: installRuntimeArtifacts reported no agents kind in the executed plan — it did not go through the descriptor path`);
|
||||
const writtenFiles = fs.readdirSync(agentsKindEntry.destDir).filter((f) => f.endsWith('.md'));
|
||||
assert.ok(writtenFiles.length > 0, `${runtime}: agents kind reported but nothing was actually written to ${agentsKindEntry.destDir}`);
|
||||
}
|
||||
});
|
||||
@@ -4041,9 +4041,18 @@ describe('ADR-1016 phase 5a: closed-vocab set exports', () => {
|
||||
// ─── 25. ADR-857 phase 5e: closed ConverterName enum (Part B) ─────────────────
|
||||
|
||||
describe('ADR-857 phase 5e: VALID_CONVERTER_NAMES closed enum', () => {
|
||||
test('VALID_CONVERTER_NAMES has exactly 27 entries (16 command/skill/workflow + 11 agent converters)', () => {
|
||||
// #2875 Part 2 (the agents-bypass closure): 3 converters added —
|
||||
// convertClaudeAgentToHermesAgent (data-driven Hermes branding converter,
|
||||
// reads hostBehaviors.brandingRewrites) and convertClaudeToKiloFrontmatter /
|
||||
// convertClaudeToOpencodeFrontmatter (kilo/opencode's agents-kind
|
||||
// converters — shared by name with those runtimes' commands-kind entries,
|
||||
// options-bag signature `(content, {isAgent, modelOverride})`). All three
|
||||
// are genuinely new agent converters (not renamed/leftover), so the agent
|
||||
// count grows from 11 to 14; the 16 command/skill/workflow converters are
|
||||
// unchanged.
|
||||
test('VALID_CONVERTER_NAMES has exactly 30 entries (16 command/skill/workflow + 14 agent converters)', () => {
|
||||
assert.ok(VALID_CONVERTER_NAMES instanceof Set, 'VALID_CONVERTER_NAMES must be a Set');
|
||||
assert.strictEqual(VALID_CONVERTER_NAMES.size, 27, 'VALID_CONVERTER_NAMES must have exactly 27 entries, got: ' + VALID_CONVERTER_NAMES.size);
|
||||
assert.strictEqual(VALID_CONVERTER_NAMES.size, 30, 'VALID_CONVERTER_NAMES must have exactly 30 entries, got: ' + VALID_CONVERTER_NAMES.size);
|
||||
});
|
||||
|
||||
test('VALID_CONVERTER_NAMES contains all expected converter names', () => {
|
||||
@@ -4078,6 +4087,12 @@ describe('ADR-857 phase 5e: VALID_CONVERTER_NAMES closed enum', () => {
|
||||
'convertClaudeAgentToQwenAgent',
|
||||
// #3384 — ZCode agent converter (strips mcp__* grants at install time).
|
||||
'convertClaudeAgentToZcodeAgent',
|
||||
// #2875 Part 2 (the agents-bypass closure) — data-driven Hermes branding
|
||||
// converter, and the kilo/opencode agent converters (shared name with
|
||||
// those runtimes' commands-kind entries).
|
||||
'convertClaudeAgentToHermesAgent',
|
||||
'convertClaudeToKiloFrontmatter',
|
||||
'convertClaudeToOpencodeFrontmatter',
|
||||
];
|
||||
for (const name of expected) {
|
||||
assert.ok(VALID_CONVERTER_NAMES.has(name), 'VALID_CONVERTER_NAMES must contain "' + name + '"');
|
||||
|
||||
@@ -193,6 +193,16 @@ describe('Cline install (local)', () => {
|
||||
assert.ok(!fs.existsSync(settingsJson), 'settings.json must not be written for cline runtime');
|
||||
});
|
||||
|
||||
test('install writes agents/gsd-*.md locally (regression: agents dropped when the inline agent-staging loop was deleted, #2875 Part 2)', () => {
|
||||
install(false, 'cline');
|
||||
const agentsDir = path.join(tmpDir, 'agents');
|
||||
assert.ok(fs.existsSync(agentsDir), 'agents/ directory must exist after cline local install');
|
||||
const agentFiles = fs.readdirSync(agentsDir).filter((f) => f.startsWith('gsd-') && f.endsWith('.md'));
|
||||
assert.ok(agentFiles.length > 0, 'cline local install must write at least one gsd-*.md agent file');
|
||||
const sample = fs.readFileSync(path.join(agentsDir, agentFiles[0]), 'utf8');
|
||||
assert.match(sample, /^---\r?\nname: /, 'cline agent frontmatter must be Cline-dialect (name + description only)');
|
||||
});
|
||||
|
||||
test('installed engine files have no leaked .claude paths', () => {
|
||||
install(false, 'cline');
|
||||
const engineDir = path.join(tmpDir, 'gsd-core');
|
||||
|
||||
@@ -202,7 +202,45 @@ test('legitimate isAugment destructure/enumeration sites survive (not eliminated
|
||||
const file = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
const src = fs.readFileSync(file, 'utf8');
|
||||
assert.ok(/isAugment/.test(src), 'isAugment must still be destructured from runtimeFlags() for non-conversion uses');
|
||||
// eslint-disable-next-line local/no-unbounded-quantifier -- parses this repo's own bounded bin/install.js source, not adversarial input
|
||||
assert.ok(/_DESCRIPTOR_AGENTS_RUNTIMES\s*=\s*new Set\(\[[^\]]*'augment'/.test(src),
|
||||
'augment must remain in _DESCRIPTOR_AGENTS_RUNTIMES (descriptor-driven agent layout)');
|
||||
});
|
||||
|
||||
// #2875 Part 2 (the agents-bypass closure): `_DESCRIPTOR_AGENTS_RUNTIMES` — the
|
||||
// allow-list this test previously asserted augment's membership in — was
|
||||
// deleted along with the inline agent-staging loop it gated. EVERY runtime is
|
||||
// now descriptor-driven for agents, so allow-list membership is no longer the
|
||||
// property that guarantees it; the property that DOES now guarantee it is
|
||||
// that augment's OWN capability.json declares a real `agents` artifact kind
|
||||
// (consumed by installRuntimeArtifacts/installAgentsKindStandalone) whose
|
||||
// converter is augment's real converter function, not `null` (a `null`
|
||||
// converter would raw-copy Claude-shaped agent bodies instead of converting
|
||||
// them) and not a placeholder/typo'd name (capability-registry.test.cjs's
|
||||
// VALID_CONVERTER_NAMES closed-enum guard already rejects an unknown name at
|
||||
// load time; this test additionally pins that the RIGHT name is used).
|
||||
test('augment capability.json declares a real, converter-bearing agents kind for both scopes (descriptor-driven agent layout)', () => {
|
||||
const capFile = path.join(__dirname, '..', 'capabilities', 'augment', 'capability.json');
|
||||
const cap = JSON.parse(fs.readFileSync(capFile, 'utf8'));
|
||||
const layout = cap.runtime.artifactLayout;
|
||||
for (const scope of ['global', 'local']) {
|
||||
const entries = layout[scope] || [];
|
||||
const agentsKind = entries.find((e) => e.kind === 'agents');
|
||||
assert.ok(agentsKind, `augment capability.json artifactLayout.${scope} must declare an 'agents' kind`);
|
||||
assert.strictEqual(
|
||||
agentsKind.converter,
|
||||
'convertClaudeAgentToAugmentAgent',
|
||||
`augment ${scope} agents kind must use the real convertClaudeAgentToAugmentAgent converter, not null/unset`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('_DESCRIPTOR_AGENTS_RUNTIMES no longer exists as a live declaration — the descriptor is authoritative for every runtime, not an allow-listed subset', () => {
|
||||
const file = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
const src = fs.readFileSync(file, 'utf8');
|
||||
// A residual mention in a historical // comment (documenting the #2875
|
||||
// deletion itself) is fine; a live `const _DESCRIPTOR_AGENTS_RUNTIMES =`
|
||||
// declaration means the allow-list crept back in.
|
||||
assert.ok(
|
||||
!/(?:const|let|var)\s+_DESCRIPTOR_AGENTS_RUNTIMES\s*=/.test(src),
|
||||
'_DESCRIPTOR_AGENTS_RUNTIMES was deleted by #2875 Part 2 — a live re-declaration means a runtime-specific ' +
|
||||
'agents allow-list crept back in, undoing the migration this test guards',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -610,7 +610,17 @@ describe('installRuntimeArtifacts — E4: kilo (second family member)', () => {
|
||||
result, undefined,
|
||||
'E4: kilo, the SECOND combined-family runtime, must ALSO return a plan — E3 is not a one-runtime special case',
|
||||
);
|
||||
assert.deepStrictEqual(result.kinds.map((k) => k.kind).sort(), ['commands', 'skills']);
|
||||
// 'agents' was added here deliberately by #2875 Part 2 Task A
|
||||
// (installAgentsKindStandalone, install-engine.cts:1614-1618): the
|
||||
// combined-family (opencode/kilo) executed plan now also reports the
|
||||
// agents kind it stages via installAgentsKindStandalone, mirroring the
|
||||
// generic layout-driven loop's own top-level shape (install-engine.cts:1627-1637).
|
||||
// This test was written under #2874 (Phase 5), before that kind was
|
||||
// wired in — its expected list was never updated. Kilo's resolved layout
|
||||
// declares an `agents` kind, so a correct plan MUST include it; a
|
||||
// ['commands', 'skills']-only expectation encoded the pre-#2875 shape,
|
||||
// not a real contract.
|
||||
assert.deepStrictEqual(result.kinds.map((k) => k.kind).sort(), ['agents', 'commands', 'skills']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -775,6 +785,44 @@ describe('installRuntimeArtifacts — E12: executed plan key set is locked', ()
|
||||
|
||||
// ─── F. Fs adapter seam — F4-F6 ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a fs object that implements EVERY InstallFsAdapter method by
|
||||
* delegating to real `node:fs` (mirroring install-fs-adapter.cts's own
|
||||
* REAL_ADAPTER), then applies `overrides` on top. buildGuardedAdapter
|
||||
* (install-fs-adapter.cts) now throws for any method an injected partial
|
||||
* omits (the module doc's "PARTIAL-ADAPTER TRAP" fix), so an end-to-end test
|
||||
* that drives a REAL install against a REAL destDir (F4/I2/I5 below — these
|
||||
* need real command/agent source content actually copied) while
|
||||
* intercepting only one or two specific calls needs a COMPLETE fake that
|
||||
* only fakes what it overrides — exactly the "documented, intended usage"
|
||||
* install-fs-adapter.cts's own module doc calls out, as opposed to
|
||||
* `createFakeInstallFs`'s fully in-memory store (used where the test itself
|
||||
* controls all content, e.g. F2/F5/F6).
|
||||
*/
|
||||
function createRealDelegatingFs(overrides = {}) {
|
||||
const base = {
|
||||
existsSync: (p) => fs.existsSync(p),
|
||||
mkdirSync: (p, opts) => fs.mkdirSync(p, opts),
|
||||
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- delegate for a fake-adapter method, not test cleanup
|
||||
rmSync: (p, opts) => fs.rmSync(p, opts),
|
||||
readdirSync: (p, opts) => (opts ? fs.readdirSync(p, opts) : fs.readdirSync(p)),
|
||||
readFileSync: (p, encoding) => (encoding ? fs.readFileSync(p, encoding) : fs.readFileSync(p)),
|
||||
writeFileSync: (p, data, opts) => fs.writeFileSync(p, data, opts),
|
||||
copyFileSync: (src, dest) => fs.copyFileSync(src, dest),
|
||||
cpSync: (src, dest, opts) => fs.cpSync(src, dest, opts),
|
||||
lstatSync: (p) => fs.lstatSync(p),
|
||||
realpathSync: (p) => fs.realpathSync(p),
|
||||
unlinkSync: (p) => fs.unlinkSync(p),
|
||||
rmdirSync: (p) => fs.rmdirSync(p),
|
||||
symlinkSync: (target, p) => fs.symlinkSync(target, p),
|
||||
readlinkSync: (p) => fs.readlinkSync(p),
|
||||
openSync: (p, flags) => fs.openSync(p, flags),
|
||||
readSync: (fd, buffer, offset, length, position) => fs.readSync(fd, buffer, offset, length, position),
|
||||
closeSync: (fd) => fs.closeSync(fd),
|
||||
};
|
||||
return { ...base, ...overrides };
|
||||
}
|
||||
|
||||
describe('installRuntimeArtifacts — F4: adapter errors propagate, cleanup still runs', () => {
|
||||
test('adapter errors propagate, cleanup still runs', (t) => {
|
||||
const configDir = createTempDir('gsd-f4-augment-');
|
||||
@@ -782,7 +830,7 @@ describe('installRuntimeArtifacts — F4: adapter errors propagate, cleanup stil
|
||||
sandboxHome(t, configDir);
|
||||
|
||||
let capturedCleanupDir;
|
||||
const fakeFs = {
|
||||
const fakeFs = createRealDelegatingFs({
|
||||
writeFileSync: (p, data, opts) => {
|
||||
if (String(p).includes('gsd-cmd-rewrites-') && capturedCleanupDir === undefined) {
|
||||
capturedCleanupDir = path.dirname(p);
|
||||
@@ -794,7 +842,7 @@ describe('installRuntimeArtifacts — F4: adapter errors propagate, cleanup stil
|
||||
err.code = 'EACCES';
|
||||
throw err;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => installRuntimeArtifacts('augment', configDir, 'global', RESOLVED_FULL, TEST_ATTRIBUTION, undefined, { fs: fakeFs }),
|
||||
@@ -834,37 +882,35 @@ describe('installRuntimeArtifacts — F5: fake existsSync drives the same branch
|
||||
});
|
||||
});
|
||||
|
||||
describe('installRuntimeArtifacts — F6: incomplete adapter falls back to real fs, never silently no-ops', () => {
|
||||
test('incomplete adapter fails loudly (never silently skips the write)', (t) => {
|
||||
// install-fs-adapter.cts's documented PARTIAL-ADAPTER TRAP: withInstallFs
|
||||
// merges the injected partial OVER the real adapter — any method the
|
||||
// partial omits resolves to REAL node:fs, silently. This pins the
|
||||
// dangerous alternative it guards against: an omitted method must never
|
||||
// degrade into a silent no-op (skipping the operation, pretending
|
||||
// success) — it must actually execute, for real.
|
||||
const configDir = createTempDir('gsd-f6-claude-');
|
||||
t.after(() => cleanup(configDir));
|
||||
sandboxHome(t, configDir);
|
||||
describe('installRuntimeArtifacts — F6: incomplete adapter fails loudly, never silently falls back to real fs', () => {
|
||||
// #2875 REVERSES this row's earlier pinned contract ("falls back to real
|
||||
// fs, never silently no-ops"). That contract was itself the defect
|
||||
// buildGuardedAdapter closes (install-fs-adapter.cts's "PARTIAL-ADAPTER
|
||||
// TRAP" doc comment): merging an injected partial OVER the real adapter
|
||||
// meant any method the partial omitted was silently REAL `node:fs` — e.g.
|
||||
// user-artifact-staging.cts's `stageUserArtifacts` calling
|
||||
// `installFs().rmSync(entryDir)` unconditionally, where a test fake
|
||||
// missing `rmSync` would silently delete the real
|
||||
// `<configDir>/.gsd-staging/<key>` on disk. Falling through to real fs is
|
||||
// exactly how a fake-adapter test can end up performing real, uncontrolled
|
||||
// IO — the bug, not a feature. The guarded contract instead throws
|
||||
// immediately, naming the missing method, the moment the exercised path
|
||||
// reaches it: never a silent no-op AND never a silent real-fs write.
|
||||
test('incomplete adapter fails loudly (never silently skips the write)', () => {
|
||||
const configDir = path.join(os.tmpdir(), `gsd-f6-must-not-exist-${crypto.randomUUID()}`);
|
||||
|
||||
let realMkdirCalls = 0;
|
||||
// Deliberately incomplete: only mkdirSync is overridden (to prove this
|
||||
// partial is genuinely merged over real fs, not a full copy of it) —
|
||||
// every other method (existsSync/readdirSync/writeFileSync/
|
||||
// copyFileSync/cpSync/...) is omitted entirely.
|
||||
// Deliberately incomplete: only mkdirSync is implemented, to prove the
|
||||
// FIRST other method the call path reaches throws immediately instead of
|
||||
// silently degrading to real fs or a no-op.
|
||||
const incompleteFs = {
|
||||
mkdirSync: (p, opts) => { realMkdirCalls++; return fs.mkdirSync(p, opts); },
|
||||
mkdirSync: () => undefined,
|
||||
};
|
||||
|
||||
const result = installRuntimeArtifacts('claude', configDir, 'global', RESOLVED_CORE, undefined, undefined, { fs: incompleteFs });
|
||||
|
||||
assert.notStrictEqual(result, undefined, 'F6: an incomplete adapter must not silently produce no result');
|
||||
assert.ok(realMkdirCalls > 0, 'F6 test precondition: mkdirSync must have been called');
|
||||
const skillsKind = result.kinds.find((k) => k.kind === 'skills');
|
||||
assert.ok(skillsKind, 'F6 precondition: claude global writes a skills kind');
|
||||
assert.ok(
|
||||
fs.existsSync(skillsKind.destDir) && fs.readdirSync(skillsKind.destDir).length > 0,
|
||||
'F6: every method the incomplete adapter omitted fell back to REAL fs and actually wrote real ' +
|
||||
'content — an incomplete fake never silently no-ops the operations it does not implement',
|
||||
assert.throws(
|
||||
() => installRuntimeArtifacts('claude', configDir, 'global', RESOLVED_CORE, undefined, undefined, { fs: incompleteFs }),
|
||||
(err) => /does not implement it/.test(err.message) && /PARTIAL-ADAPTER TRAP/.test(err.message),
|
||||
'F6: an incomplete adapter must fail loudly, naming the missing method, the moment the call path ' +
|
||||
'reaches a method it does not implement — never silently no-op or fall back to real fs',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1017,19 +1063,15 @@ describe('installRuntimeArtifacts — I2: failed cleanup is visible, not silent'
|
||||
t.after(() => cleanup(configDir));
|
||||
sandboxHome(t, configDir);
|
||||
|
||||
const fakeFs = {
|
||||
const fakeFs = createRealDelegatingFs({
|
||||
rmSync: (p, opts) => {
|
||||
if (String(p).includes('gsd-cmd-rewrites-')) {
|
||||
throw new Error('I2: simulated cleanup failure');
|
||||
}
|
||||
// This is a fake fs-adapter METHOD delegating to real fs for paths
|
||||
// it does not intentionally poison, not a test's own directory-
|
||||
// cleanup call (which still goes through t.after(() => cleanup(...))
|
||||
// above).
|
||||
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- delegate, not test cleanup
|
||||
return fs.rmSync(p, opts);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const result = installRuntimeArtifacts('augment', configDir, 'global', RESOLVED_FULL, TEST_ATTRIBUTION, undefined, { fs: fakeFs });
|
||||
|
||||
@@ -1080,7 +1122,7 @@ describe('installRuntimeArtifacts — I5: rewrite failure mid-plan cleans up and
|
||||
sandboxHome(t, configDir);
|
||||
|
||||
let capturedCleanupDir;
|
||||
const fakeFs = {
|
||||
const fakeFs = createRealDelegatingFs({
|
||||
mkdirSync: (p, opts) => {
|
||||
if (String(p).includes('gsd-profile-runtime-skills-')) {
|
||||
throw new Error('I5: simulated skills-stage failure AFTER commands already rewrote+registered a cleanup dir');
|
||||
@@ -1094,7 +1136,7 @@ describe('installRuntimeArtifacts — I5: rewrite failure mid-plan cleans up and
|
||||
}
|
||||
fs.writeFileSync(p, data, opts);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => installRuntimeArtifacts('augment', configDir, 'global', RESOLVED_FULL, TEST_ATTRIBUTION, undefined, { fs: fakeFs }),
|
||||
|
||||
@@ -1330,10 +1330,21 @@ describe('#1924: profile-user.md backup path must be outside gsd-core/', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test 4: preserveUserArtifacts helper exported from install.js ────────────
|
||||
// ─── Test 4: user artifacts survive a wipe via the durable staging path ──────
|
||||
//
|
||||
// preserveUserArtifacts/restoreUserArtifacts (an in-memory Map, the #1924
|
||||
// fix's original mechanism) were retired by #2875 — nothing calls them any
|
||||
// more, so a `typeof preserveUserArtifacts === 'function'` assertion would
|
||||
// guard a function that never runs: vacuous. #1924's actual point was never
|
||||
// "this specific helper is exported" — it was "user artifacts survive a
|
||||
// reinstall wipe". This asserts that SAME property through the durable
|
||||
// on-disk staging path that replaced the in-memory one, including surviving
|
||||
// a crash BETWEEN the wipe and the restore (#1874-F19) — a stronger
|
||||
// guarantee than the retired helper ever gave, and one a plain in-process
|
||||
// round-trip would not prove.
|
||||
|
||||
describe('#1924: preserveUserArtifacts helper exists in install.js', () => {
|
||||
test('install.js exports preserveUserArtifacts function', () => {
|
||||
describe('#1924: user artifacts survive a wipe via the durable staging path (install.js exports)', () => {
|
||||
test('install.js exports the staging primitives, and content staged through them survives a destDir wipe + crash', (t) => {
|
||||
// Set GSD_TEST_MODE so require() reaches the module.exports block
|
||||
const origMode = process.env.GSD_TEST_MODE;
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
@@ -1348,11 +1359,77 @@ describe('#1924: preserveUserArtifacts helper exists in install.js', () => {
|
||||
}
|
||||
}
|
||||
|
||||
assert.strictEqual(
|
||||
typeof mod.preserveUserArtifacts,
|
||||
'function',
|
||||
'install.js must export preserveUserArtifacts helper for testability'
|
||||
);
|
||||
for (const name of ['stageUserArtifacts', 'restoreStagedUserArtifacts', 'discardStagedUserArtifacts', 'recoverOrphanedUserArtifacts']) {
|
||||
assert.strictEqual(
|
||||
typeof mod[name],
|
||||
'function',
|
||||
`install.js must export ${name} — the mechanism that now makes the #1924 durability property testable`,
|
||||
);
|
||||
}
|
||||
|
||||
// user-artifact-staging.cts's recoverOrphanedUserArtifacts re-confines the
|
||||
// record's destDir against configDir (module doc "Confinement"/E2) before
|
||||
// recovering anything: `path.relative(configHome, record.destDir)` must
|
||||
// resolve to a path that never leaves configHome, exactly matching every
|
||||
// real call site (bin/install.js always stages/recovers a destDir that is
|
||||
// a SUBDIRECTORY of configDir). destDir must therefore live under
|
||||
// configDir here too — two unrelated sibling temp dirs (the smoke script's
|
||||
// shortcut) trip E2's outside-confinement refusal and recovered stays
|
||||
// empty, which is what silently diverged the smoke check from this suite.
|
||||
//
|
||||
// Second, separate divergence: the owner-liveness guard
|
||||
// (recoverOrphanedUserArtifacts) skips an entry entirely, reason
|
||||
// 'owner-still-live', whenever the record's `runId` names a currently-
|
||||
// alive process — and `stageUserArtifacts` defaults `runId` to
|
||||
// `String(process.pid)`, i.e. THIS test process, which is alive for the
|
||||
// whole in-process round trip. A real crashed run has a genuinely DEAD
|
||||
// pid; simulate that explicitly (same convention
|
||||
// tests/user-artifact-staging.test.cjs's C15 uses) or recovery always
|
||||
// reports this as "not an orphan yet" and never restores anything.
|
||||
const configDir = createTempDir('gsd-1924-staging-cfg-');
|
||||
const destDir = path.join(configDir, 'skills');
|
||||
try {
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
const content = '# My Profile\n\nSurvives via durable staging (#2875).\n';
|
||||
fs.writeFileSync(path.join(destDir, 'USER-PROFILE.md'), content);
|
||||
const stagingRoot = path.join(configDir, '.gsd-staging', 'user-artifacts');
|
||||
const deadPid = '999999';
|
||||
mod.stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRoot, { runId: deadPid });
|
||||
|
||||
// Simulate the #1874-F19 crash window: the wipe ran, the process died
|
||||
// before any restore. #2875 AC: inject via genuine `fs`-method
|
||||
// monkeypatching (CLAUDE.md §4 / the established idiom in
|
||||
// planning-lock-mkdir-failure-1884.test.cjs) rather than deleting
|
||||
// destDir out-of-band — `t.mock.method` auto-restores the original
|
||||
// after this test (no manual try/finally; CONTRIBUTING.md bans
|
||||
// try/finally in test bodies), and the mock delegates to the captured
|
||||
// original so destDir genuinely vanishes exactly as production's own
|
||||
// `fs.rmSync(destDir, {recursive:true})` would — the injected crash
|
||||
// boundary is "execution never proceeds past this call to any
|
||||
// restore", not a thrown error from the wipe itself. The actual
|
||||
// removal is driven through `helpers.cleanup()` (never a raw
|
||||
// `fs.rmSync` in a test body — `local/no-raw-rmsync-in-tests`), which
|
||||
// reads the SAME mutated `fs.rmSync` property (module singleton), so
|
||||
// the mock still observes and performs the wipe.
|
||||
const realRmSync = fs.rmSync;
|
||||
t.mock.method(fs, 'rmSync', (targetPath, opts) => realRmSync.call(fs, targetPath, opts));
|
||||
cleanup(destDir);
|
||||
assert.ok(!fs.existsSync(path.join(destDir, 'USER-PROFILE.md')));
|
||||
|
||||
// Recovery — not an ordinary restore call — is what must bring it
|
||||
// back, proving the property survives a crash, not merely an
|
||||
// in-process round-trip.
|
||||
const result = mod.recoverOrphanedUserArtifacts(stagingRoot, configDir);
|
||||
assert.strictEqual(result.recovered.length, 1);
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(path.join(destDir, 'USER-PROFILE.md'), 'utf8'),
|
||||
content,
|
||||
'user artifact content must survive the wipe, byte-identical, via the durable staging path',
|
||||
);
|
||||
} finally {
|
||||
cleanup(destDir);
|
||||
cleanup(configDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,13 +34,19 @@ const {
|
||||
MANIFEST_NAME,
|
||||
installerEnv,
|
||||
stripAnsi,
|
||||
runMinimalInstall,
|
||||
} = require('./helpers/install-shared.cjs');
|
||||
|
||||
const {
|
||||
installRuntimeArtifacts,
|
||||
installOpencodeFamilySkills,
|
||||
_resolveUserArtifactStagingRoot,
|
||||
} = require('../gsd-core/bin/lib/install-engine.cjs');
|
||||
|
||||
const {
|
||||
stageUserArtifacts,
|
||||
} = require('../gsd-core/bin/lib/user-artifact-staging.cjs');
|
||||
|
||||
const {
|
||||
parseRuntimeInput,
|
||||
allRuntimes,
|
||||
@@ -2225,21 +2231,40 @@ describe('convertClaudeCommandToClineSkill — code-point-aware truncation (Fix
|
||||
|
||||
// ─── Fix 2 regression: cline local scope emits no skills ─────────────────────
|
||||
//
|
||||
// resolveRuntimeArtifactLayout('cline', dir, 'local') must return 0 kinds.
|
||||
// resolveRuntimeArtifactLayout('cline', dir, 'local') must return no SKILLS
|
||||
// kind (local commands are embedded in .clinerules, never materialized as
|
||||
// skill files — hostBehaviors.localCommandsViaRules).
|
||||
// installRuntimeArtifacts('cline', dir, 'local') must not write any skills.
|
||||
//
|
||||
// #2875 Part 2 defect fix (cline-local agents-drop regression): local now
|
||||
// ALSO declares an `agents` kind (capabilities/cline/capability.json) — the
|
||||
// deleted inline agent-staging loop used to serve cline-local's agents/
|
||||
// unconditionally; closing it without this local descriptor entry silently
|
||||
// dropped them. `kinds.length === 0` was true only by coincidence of the
|
||||
// separate skills gap this describe block's title names; it is no longer
|
||||
// true now that the actual (agents) regression is fixed. See
|
||||
// tests/agent-descriptor-parity.test.cjs's cline (local) H row for the
|
||||
// byte-parity proof and tests/cline-install.test.cjs's local-install
|
||||
// regression test for the end-to-end proof.
|
||||
|
||||
describe('resolveRuntimeArtifactLayout — cline scope-aware (Fix 2)', () => {
|
||||
test('cline local: kinds.length === 0 (no skills for local scope)', () => {
|
||||
test('cline local: no skills kind (skills for local scope are embedded in .clinerules, never materialized)', () => {
|
||||
const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs');
|
||||
const layout = resolveRuntimeArtifactLayout('cline', '/tmp/x', 'local');
|
||||
assert.strictEqual(layout.kinds.length, 0, 'cline local must have 0 kinds');
|
||||
const kindNames = layout.kinds.map((k) => k.kind).sort();
|
||||
assert.deepStrictEqual(kindNames, ['agents'], 'cline local must declare only the agents kind — no skills kind');
|
||||
});
|
||||
|
||||
test('cline global: kinds.length === 1 (skills kind)', () => {
|
||||
test('cline global: kinds.length === 2 (skills + agents kind, #2875 Part 2)', () => {
|
||||
const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs');
|
||||
const layout = resolveRuntimeArtifactLayout('cline', '/tmp/x', 'global');
|
||||
assert.strictEqual(layout.kinds.length, 1, 'cline global must have 1 skills kind');
|
||||
assert.strictEqual(layout.kinds[0].kind, 'skills');
|
||||
// #2875 Part 2 (the agents-bypass closure): cline gained a descriptor-driven
|
||||
// `agents` kind entry (capabilities/cline/capability.json), closing the
|
||||
// inline-agent-loop bypass that used to serve it — see
|
||||
// tests/agent-descriptor-parity.test.cjs's H2 row for the byte-parity proof.
|
||||
assert.strictEqual(layout.kinds.length, 2, 'cline global must have 2 kinds (skills + agents)');
|
||||
const kindNames = layout.kinds.map((k) => k.kind).sort();
|
||||
assert.deepStrictEqual(kindNames, ['agents', 'skills']);
|
||||
});
|
||||
|
||||
test('installRuntimeArtifacts cline local: no skills/ dir created', (t) => {
|
||||
@@ -7130,3 +7155,57 @@ describe('installRuntimeArtifacts — G3: adapter calling-convention regression
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #2875 (epic #2866 Phase 6): User Artifact Staging — call-site integration.
|
||||
// (.gsd/phase/feat-2875-materialization-primitives/50-test-matrix.md)
|
||||
//
|
||||
// C7 (anti-inertness): recovery must be reachable from a REAL `bin/install.js`
|
||||
// run, not merely callable — the #1879-F15 failure mode this whole phase
|
||||
// exists to avoid. This spawns the real installer twice against the SAME
|
||||
// sandbox HOME, with an orphaned staged artifact manually planted between the
|
||||
// two runs (simulating a prior run that died between the wipe and its own
|
||||
// restore/discard), and asserts the SECOND real installer invocation recovers
|
||||
// it — proving the wiring in bin/install.js's `install()`, not just that the
|
||||
// module's own function works when called directly.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('#2875: user-artifact-staging — call-site integration (C7 anti-inertness)', () => {
|
||||
test('an orphaned USER-PROFILE.md staged under the real gsd-core destDir is recovered by a subsequent real install() run', (t) => {
|
||||
const first = runMinimalInstall({ runtime: 'claude', scope: 'global' });
|
||||
t.after(() => cleanup(first.root));
|
||||
|
||||
const gsdCoreDir = path.join(first.configDir, 'gsd-core');
|
||||
const profilePath = path.join(gsdCoreDir, 'USER-PROFILE.md');
|
||||
const customContent = '# My Profile\n\nOrphaned content from a crashed install run.\n';
|
||||
fs.writeFileSync(profilePath, customContent, 'utf8');
|
||||
|
||||
// Manually plant the orphan: stage USER-PROFILE.md durably (the commit
|
||||
// point — record.json — lands), then simulate the crash by deleting the
|
||||
// file WITHOUT restoring or discarding. This is exactly the state a
|
||||
// process death between the wipe and the restore leaves behind.
|
||||
const stagingRoot = _resolveUserArtifactStagingRoot(first.configDir);
|
||||
const staged = stageUserArtifacts(gsdCoreDir, ['USER-PROFILE.md'], stagingRoot, { runId: '999999' });
|
||||
assert.deepEqual(staged.names, ['USER-PROFILE.md'], 'precondition: the orphan really did stage');
|
||||
fs.unlinkSync(profilePath);
|
||||
assert.ok(!fs.existsSync(profilePath), 'precondition: the file is genuinely gone before the second run');
|
||||
|
||||
// Second REAL install, same HOME. If recovery is wired at a reachable
|
||||
// production entry point, USER-PROFILE.md is repopulated with the
|
||||
// orphaned content BEFORE the ordinary preserve/wipe/restore cycle runs
|
||||
// (which has nothing to preserve on its own — the file was deleted, not
|
||||
// merely staged, before this run started).
|
||||
runMinimalInstall({ runtime: 'claude', scope: 'global', root: first.root });
|
||||
|
||||
assert.ok(fs.existsSync(profilePath), 'C7: USER-PROFILE.md must be recovered by the second real install run');
|
||||
assert.equal(
|
||||
fs.readFileSync(profilePath, 'utf8'),
|
||||
customContent,
|
||||
'recovered content must be byte-identical to the orphaned staged copy',
|
||||
);
|
||||
|
||||
// The staging entry is consumed by the recovery step itself.
|
||||
const entries = fs.existsSync(stagingRoot) ? fs.readdirSync(stagingRoot) : [];
|
||||
assert.equal(entries.length, 0, 'the orphan is discarded once recovered — not left for a third run to find again');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,8 +23,28 @@ const {
|
||||
copyWithPathReplacement,
|
||||
installCodexConfig,
|
||||
_copyStaged,
|
||||
_resolveUserArtifactStagingRoot: _installJsResolveUserArtifactStagingRoot,
|
||||
_tryResolveUserArtifactStagingRoot: _installJsTryResolveUserArtifactStagingRoot,
|
||||
install,
|
||||
uninstall,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
// #2875 (epic #2866 Phase 6): user-artifact-staging confinement rows (E1-E5).
|
||||
// Top-level (not inside any of the folded `__foldDescribe` sections below,
|
||||
// which each scope their own requires to their own closure).
|
||||
const {
|
||||
assertDestWithinConfigHome: _uasAssertDestWithinConfigHome,
|
||||
} = require('../gsd-core/bin/lib/runtime-artifact-install-plan.cjs');
|
||||
const {
|
||||
_resolveUserArtifactStagingRoot,
|
||||
_tryResolveUserArtifactStagingRoot,
|
||||
} = require('../gsd-core/bin/lib/install-engine.cjs');
|
||||
const {
|
||||
recoverOrphanedUserArtifacts,
|
||||
stageUserArtifacts,
|
||||
restoreStagedUserArtifacts,
|
||||
} = require('../gsd-core/bin/lib/user-artifact-staging.cjs');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// copyWithPathReplacement
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2284,14 +2304,28 @@ describe('#2393: GSD_ALLOW_SYMLINKED_DEST opt-in for intentional symlinked-dest
|
||||
}
|
||||
});
|
||||
|
||||
// Documented edge case: a broken symlink (target missing) is silently passed by
|
||||
// both the default and opt-in paths. fs.existsSync follows the link and returns
|
||||
// false, so the component loop terminates before the symlink check fires. This is
|
||||
// pre-existing behavior — the fix preserves it. Subsequent mkdir may then fail or
|
||||
// create the path through the resolved target; that's the caller's responsibility,
|
||||
// not the symlink-escape guard's. Test pins the current behavior so any future
|
||||
// change (e.g. switching to lstatSync for existence) is intentional.
|
||||
test('broken symlink: silently passed (current behavior, preserved by fix)', (t) => {
|
||||
// #2875 defect fix — REVERSES the previously-pinned "silently passed" contract
|
||||
// this test used to name. The old reasoning ("existsSync(cursor) follows the
|
||||
// link -> false -> loop terminates before the symlink check ever fires; the
|
||||
// caller's subsequent mkdir/write is responsible for whatever happens next")
|
||||
// no longer holds: an adversarial reviewer showed the "caller's responsibility"
|
||||
// it rested on is unenforceable in practice — user-artifact-staging.cts's
|
||||
// recovery path reads a staged file NAME out of an attacker-influenceable
|
||||
// on-disk record (`record.json`) and writes through it without a human in the
|
||||
// loop to notice a bad write; a dangling symlink planted at that destination
|
||||
// (e.g. `<configDir>/USER-PROFILE.md -> <outside>/authorized_keys`) let the
|
||||
// actual copy/write follow it and land attacker-chosen content OUTSIDE the
|
||||
// install root, reproduced end-to-end. hasExistingSymlinkBetween now probes
|
||||
// each path segment with `lstatSync` FIRST (see install-engine.cts's own doc
|
||||
// comment on this change), which — unlike `existsSync` — never follows a
|
||||
// symlink and succeeds for a dangling one, so a dangling symlink is now
|
||||
// correctly seen AS a symlink component instead of "nothing here". The guard's
|
||||
// promise is now: a dangling symlink anywhere on the path between root and
|
||||
// destDir is refused exactly like a live one — default refuses outright,
|
||||
// opt-in attempts to follow it (`realpathSync`) and, finding no target,
|
||||
// refuses too (fail-closed on a broken symlink, same posture used elsewhere in
|
||||
// this function for a `realpathSync` failure).
|
||||
test('broken symlink: now refused by both the default and opt-in paths (#2875 fix)', (t) => {
|
||||
const configHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2393-broken-'));
|
||||
try {
|
||||
const danglingLink = path.join(configHome, 'skills');
|
||||
@@ -2304,18 +2338,19 @@ describe('#2393: GSD_ALLOW_SYMLINKED_DEST opt-in for intentional symlinked-dest
|
||||
}
|
||||
const destDir = path.join(danglingLink, 'gsd-foo');
|
||||
|
||||
// existsSync(danglingLink) follows the link → false → loop returns false early.
|
||||
// Same behavior with and without opt-in. Test documents this so a future
|
||||
// refactor (e.g. lstatSync-based existence) is a deliberate behavior change.
|
||||
// lstatSync(danglingLink) succeeds (it IS a symlink, just a dangling one) —
|
||||
// the segment loop now sees it as a symlink component and refuses.
|
||||
assert.strictEqual(
|
||||
hasExistingSymlinkBetween(configHome, destDir),
|
||||
false,
|
||||
'broken symlink: component loop terminates early (existsSync follows link → false)',
|
||||
true,
|
||||
'broken symlink: default path now refuses — lstatSync sees the dangling symlink as a symlink component',
|
||||
);
|
||||
// Opt-in attempts to follow it via realpathSync, which throws ENOENT for a
|
||||
// missing target — refused (fail-closed), not silently passed through.
|
||||
assert.strictEqual(
|
||||
hasExistingSymlinkBetween(configHome, destDir, { allowOptInFollow: true }),
|
||||
false,
|
||||
'broken symlink with opt-in: same early-termination behavior',
|
||||
true,
|
||||
'broken symlink with opt-in: realpathSync on a dangling target fails, so this refuses too (fail-closed)',
|
||||
);
|
||||
} finally {
|
||||
try { fs.unlinkSync(path.join(configHome, 'skills')); } catch { /* already gone */ }
|
||||
@@ -2499,3 +2534,649 @@ describe('_installNativePluginIfDeclared write-confinement', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #2875 (epic #2866 Phase 6): User Artifact Staging confinement — E1-E5
|
||||
// (.gsd/phase/feat-2875-materialization-primitives/50-test-matrix.md
|
||||
// "E. Confinement"). Every row reuses assertDestWithinConfigHome /
|
||||
// hasExistingSymlinkBetween rather than a bespoke check — see
|
||||
// _resolveUserArtifactStagingRoot's own doc comment (install-engine.cts) and
|
||||
// user-artifact-staging.cts's module doc "Confinement".
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('#2875: user-artifact-staging confinement (E1-E5)', () => {
|
||||
test('E1: the staging root resolves through assertDestWithinConfigHome — an escaping subpath is refused by the SAME guard _resolveUserArtifactStagingRoot uses', () => {
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e1-'));
|
||||
try {
|
||||
// The real staging subpath ('.gsd-staging/user-artifacts') is a fixed
|
||||
// literal that can never escape — so this asserts the PROPERTY the
|
||||
// call site depends on directly against the same primitive, rather
|
||||
// than trying to force an unreachable escape through the real API.
|
||||
assert.throws(
|
||||
() => _uasAssertDestWithinConfigHome(configDir, path.join('..', '..', 'etc', 'staging')),
|
||||
/escap|strict subpath|configHome/i,
|
||||
);
|
||||
// The real call resolves cleanly and stays confined.
|
||||
const stagingRoot = _resolveUserArtifactStagingRoot(configDir);
|
||||
assert.ok(path.resolve(stagingRoot).startsWith(path.resolve(configDir) + path.sep));
|
||||
} finally {
|
||||
cleanup(configDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('E2: recovery refuses a record naming a destDir outside confinement — never writes outside it', () => {
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e2-'));
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e2-outside-'));
|
||||
try {
|
||||
const stagingRoot = path.join(configDir, '.gsd-staging', 'user-artifacts');
|
||||
const entryDir = path.join(stagingRoot, 'attackerentry0000');
|
||||
fs.mkdirSync(path.join(entryDir, 'files'), { recursive: true });
|
||||
fs.writeFileSync(path.join(entryDir, 'files', 'pwned.md'), 'attacker-controlled content');
|
||||
// The record is attacker-influenced data — it names a destDir OUTSIDE
|
||||
// configDir entirely.
|
||||
fs.writeFileSync(
|
||||
path.join(entryDir, 'record.json'),
|
||||
JSON.stringify({ destDir: outside, names: ['pwned.md'], timestamp: new Date().toISOString() }),
|
||||
);
|
||||
|
||||
const result = recoverOrphanedUserArtifacts(stagingRoot, configDir);
|
||||
assert.equal(result.recovered.length, 0);
|
||||
assert.equal(result.skipped.length, 1);
|
||||
assert.equal(result.skipped[0].reason, 'destDir-outside-confinement');
|
||||
assert.ok(!fs.existsSync(path.join(outside, 'pwned.md')), 'must never write outside confinement');
|
||||
} finally {
|
||||
cleanup(configDir);
|
||||
cleanup(outside);
|
||||
}
|
||||
});
|
||||
|
||||
test('E3: ..-traversal in a staged file name is rejected', () => {
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e3-'));
|
||||
try {
|
||||
const stagingRoot = path.join(configDir, '.gsd-staging', 'user-artifacts');
|
||||
const destDir = path.join(configDir, 'gsd-core');
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
const entryDir = path.join(stagingRoot, 'traversalentry000');
|
||||
fs.mkdirSync(path.join(entryDir, 'files'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(entryDir, 'record.json'),
|
||||
JSON.stringify({ destDir: path.resolve(destDir), names: ['../../../etc/passwd'], timestamp: new Date().toISOString() }),
|
||||
);
|
||||
const result = recoverOrphanedUserArtifacts(stagingRoot, configDir);
|
||||
assert.equal(result.recovered.length, 0, 'traversal name must never be restored');
|
||||
// Neither the previous assertion (checked a path one level further up
|
||||
// than production ever resolves, so it could never fail) nor a naive
|
||||
// `resolve(destDir, name)` check (which, on a Linux runner, resolves to
|
||||
// the REAL /etc/passwd — a file that pre-exists on disk regardless of
|
||||
// whether this guard works, so `!existsSync(...)` fails unconditionally
|
||||
// and proves nothing) exercises what the guard actually does. Trace the
|
||||
// real call order in recoverOrphanedUserArtifacts: `srcPath =
|
||||
// assertDestWithinConfigHome(filesDir, name)` is computed and throws
|
||||
// FIRST — filesDir (`<stagingRoot>/<entry>/files`) is nested several
|
||||
// levels below configDir, and `../../../etc/passwd` resolved against it
|
||||
// still escapes filesDir's own root, so this throw fires before
|
||||
// `destPath` (against confinedDestDir) is ever computed and before any
|
||||
// read/write is attempted. The observable, platform-independent proof
|
||||
// that the traversal was rejected — not merely that some unrelated
|
||||
// system file didn't get overwritten — is that destDir's own directory
|
||||
// listing stays exactly as this test left it: no file materialized
|
||||
// there via the traversal name.
|
||||
assert.deepStrictEqual(
|
||||
fs.readdirSync(destDir), [],
|
||||
'a rejected traversal name must never result in any file being written into destDir',
|
||||
);
|
||||
} finally {
|
||||
cleanup(configDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('E4: a symlinked staging root is refused — same refusal as the existing dest guard', () => {
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e4-'));
|
||||
const elsewhere = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e4-elsewhere-'));
|
||||
try {
|
||||
// Pre-create .gsd-staging as a symlink pointing outside configDir —
|
||||
// exactly the threat hasExistingSymlinkBetween's root-symlink refusal
|
||||
// (install-engine.cts) already covers for every other write on this
|
||||
// call tree.
|
||||
fs.symlinkSync(elsewhere, path.join(configDir, '.gsd-staging'));
|
||||
assert.throws(
|
||||
() => _resolveUserArtifactStagingRoot(configDir),
|
||||
/symlink/i,
|
||||
);
|
||||
} finally {
|
||||
cleanup(configDir);
|
||||
cleanup(elsewhere);
|
||||
}
|
||||
});
|
||||
|
||||
test('E5: a NUL byte in a staged file name is rejected', () => {
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e5-'));
|
||||
try {
|
||||
const stagingRoot = path.join(configDir, '.gsd-staging', 'user-artifacts');
|
||||
const destDir = path.join(configDir, 'gsd-core');
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
const entryDir = path.join(stagingRoot, 'nulbyteentry00000');
|
||||
fs.mkdirSync(path.join(entryDir, 'files'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(entryDir, 'record.json'),
|
||||
JSON.stringify({ destDir: path.resolve(destDir), names: ['USER-PROFILE\u0000.md'], timestamp: new Date().toISOString() }),
|
||||
);
|
||||
let result;
|
||||
assert.doesNotThrow(() => { result = recoverOrphanedUserArtifacts(stagingRoot, configDir); });
|
||||
assert.equal(result.recovered.length, 0, 'NUL-byte name must never be restored');
|
||||
} finally {
|
||||
cleanup(configDir);
|
||||
}
|
||||
});
|
||||
|
||||
// #2875 defect fix: C2's "never overwrite" guard was `existsSync`-based,
|
||||
// which FOLLOWS symlinks and reports `false` for a DANGLING one — invisible
|
||||
// to the guard, so it never refused. A dangling symlink AT the recovered
|
||||
// destination let `copyFileSync`/`symlinkSync` (which DO follow it) write
|
||||
// outside `configDir`.
|
||||
test('E6: a dangling symlink at the recovered destination is treated as already-present, never written through', () => {
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e6-cfg-'));
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e6-outside-'));
|
||||
try {
|
||||
const destDir = path.join(configDir, 'gsd-core');
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
const stagingRoot = path.join(configDir, '.gsd-staging', 'user-artifacts');
|
||||
fs.writeFileSync(path.join(destDir, 'USER-PROFILE.md'), 'orphaned-content');
|
||||
// user-artifact-staging.cts's owner-liveness guard (recoverOrphanedUserArtifacts)
|
||||
// treats a record whose `runId` belongs to a currently-live process as
|
||||
// "not an orphan yet" and skips the ENTIRE entry with reason
|
||||
// 'owner-still-live' — before ever reaching the per-name
|
||||
// dest-already-present check this test pins. `stageUserArtifacts`
|
||||
// defaults `runId` to `String(process.pid)`, i.e. THIS test process,
|
||||
// which is trivially alive for the whole duration of this in-process
|
||||
// test. A real crashed run has a genuinely DEAD pid, so simulating one
|
||||
// here requires an explicit dead `runId` — same convention
|
||||
// tests/user-artifact-staging.test.cjs's C15 already establishes.
|
||||
const deadPid = '999999';
|
||||
stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRoot, { runId: deadPid });
|
||||
cleanup(path.join(destDir, 'USER-PROFILE.md'));
|
||||
|
||||
const outsideTarget = path.join(outside, 'authorized_keys');
|
||||
fs.symlinkSync(outsideTarget, path.join(destDir, 'USER-PROFILE.md'));
|
||||
assert.ok(!fs.existsSync(outsideTarget), 'the symlink target must not exist — this is the DANGLING case');
|
||||
|
||||
const result = recoverOrphanedUserArtifacts(stagingRoot, configDir);
|
||||
assert.equal(result.recovered.length, 0, 'must refuse — the dangling symlink counts as already-present');
|
||||
assert.equal(result.skipped.length, 1);
|
||||
assert.equal(result.skipped[0].reason, 'dest-already-present');
|
||||
assert.ok(!fs.existsSync(outsideTarget), 'must never write through the dangling symlink to outsideTarget');
|
||||
assert.ok(fs.lstatSync(path.join(destDir, 'USER-PROFILE.md')).isSymbolicLink(), 'the dangling symlink itself is left untouched');
|
||||
} finally {
|
||||
cleanup(configDir);
|
||||
cleanup(outside);
|
||||
}
|
||||
});
|
||||
|
||||
// #2875 defect fix: restoreStagedUserArtifacts had no guard at all against
|
||||
// a dangling symlink at the destination — the identical hole as E6.
|
||||
test('E7: restoreStagedUserArtifacts refuses a dangling symlink at the destination', () => {
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e7-cfg-'));
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e7-outside-'));
|
||||
try {
|
||||
const destDir = path.join(configDir, 'gsd-core');
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
const stagingRoot = path.join(configDir, '.gsd-staging', 'user-artifacts');
|
||||
fs.writeFileSync(path.join(destDir, 'USER-PROFILE.md'), 'current-content');
|
||||
const staged = stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRoot);
|
||||
cleanup(path.join(destDir, 'USER-PROFILE.md'));
|
||||
|
||||
const outsideTarget = path.join(outside, 'authorized_keys');
|
||||
fs.symlinkSync(outsideTarget, path.join(destDir, 'USER-PROFILE.md'));
|
||||
|
||||
restoreStagedUserArtifacts(destDir, staged);
|
||||
|
||||
assert.ok(!fs.existsSync(outsideTarget), 'must never write through the dangling symlink to outsideTarget');
|
||||
assert.ok(
|
||||
fs.lstatSync(path.join(destDir, 'USER-PROFILE.md')).isSymbolicLink(),
|
||||
'the dangling symlink itself is left untouched, not overwritten',
|
||||
);
|
||||
} finally {
|
||||
cleanup(configDir);
|
||||
cleanup(outside);
|
||||
}
|
||||
});
|
||||
|
||||
// #2875 defect fix: assertDestWithinConfigHome is pure lexical path math
|
||||
// and cannot see a symlinked ANCESTOR directory between configDir and a
|
||||
// recorded destDir — only hasExistingSymlinkBetween's component-by-
|
||||
// component walk can. Recovery previously never applied it to the
|
||||
// recorded destDir at all — this is E2 STRENGTHENED: E2 above only covers
|
||||
// a destDir that is lexically outside configDir entirely, which
|
||||
// assertDestWithinConfigHome alone already refused; this row covers the
|
||||
// case that actually failed before this fix.
|
||||
test('E8: recovery refuses a destDir reached only through a symlinked ANCESTOR directory', () => {
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e8-cfg-'));
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e8-outside-'));
|
||||
try {
|
||||
fs.symlinkSync(outside, path.join(configDir, 'linkdir'));
|
||||
const stagingRoot = path.join(configDir, '.gsd-staging', 'user-artifacts');
|
||||
const entryDir = path.join(stagingRoot, 'e8entry00000000');
|
||||
fs.mkdirSync(path.join(entryDir, 'files'), { recursive: true });
|
||||
fs.writeFileSync(path.join(entryDir, 'files', 'USER-PROFILE.md'), 'attacker-controlled content');
|
||||
// The record names a destDir that is LEXICALLY inside configDir
|
||||
// (assertDestWithinConfigHome alone would accept it) but only
|
||||
// reachable by walking through the `linkdir` symlink to `outside`.
|
||||
fs.writeFileSync(
|
||||
path.join(entryDir, 'record.json'),
|
||||
JSON.stringify({
|
||||
destDir: path.join(configDir, 'linkdir', 'sub'),
|
||||
names: ['USER-PROFILE.md'],
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
);
|
||||
|
||||
const result = recoverOrphanedUserArtifacts(stagingRoot, configDir);
|
||||
assert.equal(result.recovered.length, 0, 'must refuse — destDir is reached only through a symlinked ancestor');
|
||||
assert.equal(result.skipped.length, 1);
|
||||
assert.equal(result.skipped[0].reason, 'destDir-symlink-escape');
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(outside, 'sub', 'USER-PROFILE.md')),
|
||||
'must never write outside configDir via the symlinked ancestor',
|
||||
);
|
||||
} finally {
|
||||
cleanup(configDir);
|
||||
cleanup(outside);
|
||||
}
|
||||
});
|
||||
|
||||
// #2875 defect fix: `names` accepted any non-escaping subpath, including
|
||||
// one containing a path separator, contrary to this module's flat-name
|
||||
// contract (every real caller stages exactly one flat filename) — the
|
||||
// module doc previously claimed separator names were already rejected;
|
||||
// they were not.
|
||||
test('E9: a staged/recorded name containing a path separator is rejected, never nested under destDir', () => {
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e9-cfg-'));
|
||||
try {
|
||||
const destDir = path.join(configDir, 'gsd-core');
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
const stagingRoot = path.join(configDir, '.gsd-staging', 'user-artifacts');
|
||||
const entryDir = path.join(stagingRoot, 'e9entry000000000');
|
||||
fs.mkdirSync(path.join(entryDir, 'files', 'nested'), { recursive: true });
|
||||
fs.writeFileSync(path.join(entryDir, 'files', 'nested', 'x.md'), 'should never land');
|
||||
fs.writeFileSync(
|
||||
path.join(entryDir, 'record.json'),
|
||||
JSON.stringify({ destDir: path.resolve(destDir), names: ['nested/x.md'], timestamp: new Date().toISOString() }),
|
||||
);
|
||||
|
||||
const result = recoverOrphanedUserArtifacts(stagingRoot, configDir);
|
||||
assert.equal(result.recovered.length, 0, 'a separator-containing name must never be restored');
|
||||
assert.ok(!fs.existsSync(path.join(destDir, 'nested', 'x.md')), 'must never create a nested subpath under destDir');
|
||||
} finally {
|
||||
cleanup(configDir);
|
||||
}
|
||||
});
|
||||
|
||||
// #2875 defect fix (security — source-side symlink escape): the ancestor-
|
||||
// symlink guard (E8) covered only `destDir`. A real `entryDir` whose
|
||||
// `files` CHILD is a symlink to an unrelated victim directory (e.g.
|
||||
// `/etc`, `~/.ssh`) was dereferenced by every per-file `existsSync`/
|
||||
// `stagedCopy` read below `filesDir`, copying victim-readable content into
|
||||
// an attacker-named path inside `configDir`.
|
||||
test('E10: recovery refuses an entryDir whose `files` child is a symlink — never dereferences the source side', () => {
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e10-cfg-'));
|
||||
const victim = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e10-victim-'));
|
||||
try {
|
||||
fs.writeFileSync(path.join(victim, 'attacker-chosen-name.md'), 'VICTIM SECRET CONTENT');
|
||||
const stagingRoot = path.join(configDir, '.gsd-staging', 'user-artifacts');
|
||||
const entryDir = path.join(stagingRoot, 'e10entry0000000');
|
||||
fs.mkdirSync(entryDir, { recursive: true });
|
||||
fs.symlinkSync(victim, path.join(entryDir, 'files'));
|
||||
const destDir = path.join(configDir, 'gsd-core');
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(entryDir, 'record.json'),
|
||||
JSON.stringify({ destDir: path.resolve(destDir), names: ['attacker-chosen-name.md'], timestamp: new Date().toISOString() }),
|
||||
);
|
||||
|
||||
const result = recoverOrphanedUserArtifacts(stagingRoot, configDir);
|
||||
assert.equal(result.recovered.length, 0, 'must refuse — the files/ child is a symlink to an untrusted directory');
|
||||
assert.equal(result.skipped.length, 1);
|
||||
assert.equal(result.skipped[0].reason, 'files-symlink-escape');
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(destDir, 'attacker-chosen-name.md')),
|
||||
'the victim directory\'s content must never be copied into destDir',
|
||||
);
|
||||
} finally {
|
||||
cleanup(configDir);
|
||||
cleanup(victim);
|
||||
}
|
||||
});
|
||||
|
||||
// #2875 defect fix (security/correctness — cwd-dependent confinement): a
|
||||
// RELATIVE `record.destDir` made `path.relative(configHome, record.destDir)`
|
||||
// resolve the relative argument against `process.cwd()` internally, not
|
||||
// against `configHome` — so recovery's behavior for a forged or malformed
|
||||
// record depended on whatever directory the CLI happened to be invoked
|
||||
// from, rather than being a deterministic function of `configDir`.
|
||||
// `stageUserArtifacts` (this module's own writer) always records an
|
||||
// ALREADY-resolved absolute `destDir`; a relative one only ever reaches
|
||||
// recovery via a forged/hand-edited record.
|
||||
test('E11: recovery refuses a RELATIVE record.destDir — confinement never depends on process.cwd()', (t) => {
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e11-cfg-'));
|
||||
t.after(() => cleanup(configDir));
|
||||
const stagingRoot = path.join(configDir, '.gsd-staging', 'user-artifacts');
|
||||
const entryDir = path.join(stagingRoot, 'e11entry0000000');
|
||||
fs.mkdirSync(path.join(entryDir, 'files'), { recursive: true });
|
||||
fs.writeFileSync(path.join(entryDir, 'files', 'x.md'), 'x');
|
||||
fs.writeFileSync(
|
||||
path.join(entryDir, 'record.json'),
|
||||
JSON.stringify({ destDir: 'gsd-core', names: ['x.md'], timestamp: new Date().toISOString() }),
|
||||
);
|
||||
|
||||
// Run from a cwd that resolves the relative destDir straight back to
|
||||
// configDir (mirrors the real cline-local call shape, where targetDir
|
||||
// === process.cwd()) — the exact case that must NOT be treated as
|
||||
// "correctly resolved" just because the coincidence lines up.
|
||||
const previousCwd = process.cwd();
|
||||
t.after(() => process.chdir(previousCwd));
|
||||
process.chdir(configDir);
|
||||
const result = recoverOrphanedUserArtifacts(stagingRoot, configDir);
|
||||
assert.equal(result.recovered.length, 0, 'a relative destDir must never be accepted, regardless of cwd');
|
||||
assert.equal(result.skipped.length, 1);
|
||||
assert.equal(result.skipped[0].reason, 'destDir-outside-confinement');
|
||||
});
|
||||
|
||||
// #2875 security-review finding: E10 covers the default (no opt-in) case.
|
||||
// `GSD_ALLOW_SYMLINKED_DEST` is documented (install-engine.cts
|
||||
// `isSymlinkedDestOptIn`) as relaxing only the DESTINATION-side
|
||||
// pre-existing-symlink refusal — the user asserting they own/trust a
|
||||
// symlinked WRITE destination. `entryDir`/`filesDir` here is the staging
|
||||
// SOURCE, GSD-owned internal state this module creates itself, never a
|
||||
// user-authored layout — the opt-in must NOT relax this read-side check.
|
||||
test('E12: recovery refuses the source-side `files/` symlink even with GSD_ALLOW_SYMLINKED_DEST=1 — the dest opt-in never relaxes the source-side read', (t) => {
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e12-cfg-'));
|
||||
const victim = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-e12-victim-'));
|
||||
t.after(() => {
|
||||
cleanup(configDir);
|
||||
cleanup(victim);
|
||||
});
|
||||
fs.writeFileSync(path.join(victim, 'attacker-chosen-name.md'), 'VICTIM SECRET CONTENT');
|
||||
const stagingRoot = path.join(configDir, '.gsd-staging', 'user-artifacts');
|
||||
const entryDir = path.join(stagingRoot, 'e12entry0000000');
|
||||
fs.mkdirSync(entryDir, { recursive: true });
|
||||
try {
|
||||
fs.symlinkSync(victim, path.join(entryDir, 'files'));
|
||||
} catch (_e) {
|
||||
t.skip('symlink creation unsupported on this platform/privilege');
|
||||
return;
|
||||
}
|
||||
const destDir = path.join(configDir, 'gsd-core');
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(entryDir, 'record.json'),
|
||||
JSON.stringify({ destDir: path.resolve(destDir), names: ['attacker-chosen-name.md'], timestamp: new Date().toISOString() }),
|
||||
);
|
||||
|
||||
process.env.GSD_ALLOW_SYMLINKED_DEST = '1';
|
||||
t.after(() => delete process.env.GSD_ALLOW_SYMLINKED_DEST);
|
||||
const result = recoverOrphanedUserArtifacts(stagingRoot, configDir);
|
||||
|
||||
assert.equal(result.recovered.length, 0, 'must refuse — the source-side files/ symlink is never relaxed by the dest opt-in');
|
||||
assert.equal(result.skipped.length, 1);
|
||||
assert.equal(result.skipped[0].reason, 'files-symlink-escape');
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(destDir, 'attacker-chosen-name.md')),
|
||||
'the victim directory\'s content must never be copied into destDir, even with the opt-in set',
|
||||
);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Parity: install-engine.cts's `_resolveUserArtifactStagingRoot` is
|
||||
// deliberately duplicated (not shared) into bin/install.js, to avoid a
|
||||
// circular `require` between the two (see that function's own doc comment
|
||||
// in both files). This codebase names unguarded duplication across
|
||||
// parallel surfaces "Generative Fix Divergence" and requires a parity
|
||||
// assertion that fails the moment the two copies diverge — same inputs,
|
||||
// same resolved root, same refusal behavior.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
test('parity: install-engine.cts and bin/install.js copies of _resolveUserArtifactStagingRoot resolve the SAME root for the same configDir', () => {
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-parity-happy-'));
|
||||
try {
|
||||
const fromEngine = _resolveUserArtifactStagingRoot(configDir);
|
||||
const fromInstallJs = _installJsResolveUserArtifactStagingRoot(configDir);
|
||||
assert.equal(fromInstallJs, fromEngine, 'both copies must resolve to the identical staging root');
|
||||
} finally {
|
||||
cleanup(configDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('parity: both copies refuse a symlinked staging root identically', () => {
|
||||
const configDirEngine = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-parity-sym-engine-'));
|
||||
const configDirInstallJs = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-parity-sym-installjs-'));
|
||||
const elsewhere = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-parity-sym-elsewhere-'));
|
||||
try {
|
||||
fs.symlinkSync(elsewhere, path.join(configDirEngine, '.gsd-staging'));
|
||||
fs.symlinkSync(elsewhere, path.join(configDirInstallJs, '.gsd-staging'));
|
||||
|
||||
let engineThrew = false;
|
||||
let engineMessage = '';
|
||||
try {
|
||||
_resolveUserArtifactStagingRoot(configDirEngine);
|
||||
} catch (err) {
|
||||
engineThrew = true;
|
||||
engineMessage = err.message;
|
||||
}
|
||||
let installJsThrew = false;
|
||||
let installJsMessage = '';
|
||||
try {
|
||||
_installJsResolveUserArtifactStagingRoot(configDirInstallJs);
|
||||
} catch (err) {
|
||||
installJsThrew = true;
|
||||
installJsMessage = err.message;
|
||||
}
|
||||
|
||||
assert.ok(engineThrew, 'install-engine.cts copy must refuse a symlinked staging root');
|
||||
assert.ok(installJsThrew, 'bin/install.js copy must refuse a symlinked staging root');
|
||||
// Both messages must carry the same refusal shape (symlink refusal),
|
||||
// even though configDir differs between the two (each needs its own
|
||||
// sandbox — the throw itself, not the literal path, is what parity
|
||||
// checks here).
|
||||
assert.match(engineMessage, /symlink/i);
|
||||
assert.match(installJsMessage, /symlink/i);
|
||||
} finally {
|
||||
cleanup(configDirEngine);
|
||||
cleanup(configDirInstallJs);
|
||||
cleanup(elsewhere);
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Parity: the DEGRADE-not-abort wrapper (`_tryResolveUserArtifactStagingRoot`)
|
||||
// is ALSO duplicated verbatim into bin/install.js (same doc-comment
|
||||
// rationale as the throwing version above). "Same inputs, same resolved
|
||||
// root, same refusal" above does not cover "same degrade": a caller-facing
|
||||
// regression where one copy started throwing again (bricking the command)
|
||||
// while the other correctly degraded to `null` would slip past the tests
|
||||
// above, since neither calls the wrapper.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
test('parity: install-engine.cts and bin/install.js copies of _tryResolveUserArtifactStagingRoot resolve the SAME root for the same configDir (happy path)', (t) => {
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-try-parity-happy-'));
|
||||
t.after(() => cleanup(configDir));
|
||||
const fromEngine = _tryResolveUserArtifactStagingRoot(configDir);
|
||||
const fromInstallJs = _installJsTryResolveUserArtifactStagingRoot(configDir);
|
||||
assert.notEqual(fromEngine, null, 'the engine copy must resolve (not degrade) on a clean configDir');
|
||||
assert.equal(fromInstallJs, fromEngine, 'both copies must resolve to the identical staging root');
|
||||
});
|
||||
|
||||
test('parity: both copies of _tryResolveUserArtifactStagingRoot degrade to null (never throw) for the SAME symlinked staging root', (t) => {
|
||||
const configDirEngine = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-try-parity-sym-engine-'));
|
||||
const configDirInstallJs = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-try-parity-sym-installjs-'));
|
||||
const elsewhere = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uas-try-parity-sym-elsewhere-'));
|
||||
t.after(() => {
|
||||
cleanup(configDirEngine);
|
||||
cleanup(configDirInstallJs);
|
||||
cleanup(elsewhere);
|
||||
});
|
||||
fs.symlinkSync(elsewhere, path.join(configDirEngine, '.gsd-staging'));
|
||||
fs.symlinkSync(elsewhere, path.join(configDirInstallJs, '.gsd-staging'));
|
||||
|
||||
let engineThrew = false;
|
||||
let engineResult;
|
||||
try {
|
||||
engineResult = _tryResolveUserArtifactStagingRoot(configDirEngine);
|
||||
} catch {
|
||||
engineThrew = true;
|
||||
}
|
||||
let installJsThrew = false;
|
||||
let installJsResult;
|
||||
try {
|
||||
installJsResult = _installJsTryResolveUserArtifactStagingRoot(configDirInstallJs);
|
||||
} catch {
|
||||
installJsThrew = true;
|
||||
}
|
||||
|
||||
assert.equal(engineThrew, false, 'the engine copy\'s try-wrapper must never throw — this is the whole point of the wrapper');
|
||||
assert.equal(installJsThrew, false, 'the bin/install.js copy\'s try-wrapper must never throw either');
|
||||
assert.equal(engineResult, null, 'the engine copy must degrade to null for a refused staging root');
|
||||
assert.equal(installJsResult, null, 'the bin/install.js copy must degrade to null identically');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// migrateLegacyDevPreferencesToSkill — dangling-symlink leaf write (security
|
||||
// review finding, found while closing the #2875 agents-descriptor migration
|
||||
// gap): existsSync(skillFile) follows symlinks and reports false for a
|
||||
// dangling one, and the symlink-escape guard only walked to the PARENT
|
||||
// directory, never lstat-checking the leaf file itself — so a dangling
|
||||
// symlink planted exactly at the skill-file destination sailed through both
|
||||
// checks and writeFileSync (which DOES follow symlinks) wrote attacker
|
||||
// content to the symlink's target.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('migrateLegacyDevPreferencesToSkill — dangling-symlink leaf write', () => {
|
||||
const { migrateLegacyDevPreferencesToSkill } = require('../gsd-core/bin/lib/install-engine.cjs');
|
||||
|
||||
test('refuses to write through a dangling symlink planted at the skill-file leaf', () => {
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-migrate-sym-'));
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-migrate-sym-outside-'));
|
||||
const attackerTarget = path.join(outside, 'authorized_keys');
|
||||
try {
|
||||
const skillDir = path.join(configDir, 'skills', 'gsd-dev-preferences');
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
const skillFile = path.join(skillDir, 'SKILL.md');
|
||||
fs.symlinkSync(attackerTarget, skillFile); // dangling — target does not exist
|
||||
|
||||
const saved = new Map([['dev-preferences.md', 'attacker-controlled content\n']]);
|
||||
|
||||
assert.throws(
|
||||
() => migrateLegacyDevPreferencesToSkill(configDir, saved, 'claude', 'global'),
|
||||
/symlink/i,
|
||||
'must refuse to write through a symlinked skill-file leaf',
|
||||
);
|
||||
assert.ok(!fs.existsSync(attackerTarget), 'attacker target must never be created/written');
|
||||
} finally {
|
||||
cleanup(configDir);
|
||||
cleanup(outside);
|
||||
}
|
||||
});
|
||||
|
||||
test('a real, already-migrated skill file (not a symlink) still short-circuits as before', () => {
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-migrate-real-'));
|
||||
try {
|
||||
const skillDir = path.join(configDir, 'skills', 'gsd-dev-preferences');
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
const skillFile = path.join(skillDir, 'SKILL.md');
|
||||
fs.writeFileSync(skillFile, 'already migrated\n', 'utf8');
|
||||
|
||||
const saved = new Map([['dev-preferences.md', 'new content\n']]);
|
||||
const migrated = migrateLegacyDevPreferencesToSkill(configDir, saved, 'claude', 'global');
|
||||
|
||||
assert.strictEqual(migrated, false, 'must not clobber an existing real skill file');
|
||||
assert.strictEqual(fs.readFileSync(skillFile, 'utf8'), 'already migrated\n');
|
||||
} finally {
|
||||
cleanup(configDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// uninstallRuntimeArtifacts — staged legacy dev-preferences.md symlink
|
||||
// dereference (security review finding). Sibling call sites
|
||||
// (_runLegacyInstallMigrations, bin/install.js's own uninstall()) already
|
||||
// lstat-guard a staged name before readFileSync; this uninstall call site did
|
||||
// not, so a symlinked staged dev-preferences.md had its referent's bytes read
|
||||
// into SKILL.md, and a symlink to a directory threw EISDIR uncaught.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('uninstallRuntimeArtifacts — staged dev-preferences.md symlink dereference', () => {
|
||||
const { uninstallRuntimeArtifacts } = require('../gsd-core/bin/lib/install-engine.cjs');
|
||||
|
||||
test('a symlinked staged dev-preferences.md is skipped (never dereferenced) and uninstall does not throw', () => {
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uninstall-sym-'));
|
||||
const secretFile = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uninstall-sym-secret-'));
|
||||
const secretPath = path.join(secretFile, 'id_rsa');
|
||||
fs.writeFileSync(secretPath, 'PRIVATE KEY MATERIAL\n', 'utf8');
|
||||
try {
|
||||
// Claude global uses the legacy commands/gsd/ location.
|
||||
const legacyDir = path.join(configDir, 'commands', 'gsd');
|
||||
fs.mkdirSync(legacyDir, { recursive: true });
|
||||
fs.symlinkSync(secretPath, path.join(legacyDir, 'dev-preferences.md'));
|
||||
|
||||
// Must not throw (no EISDIR/unhandled error) and must never migrate the
|
||||
// symlink's referent content into any installed skill file.
|
||||
assert.doesNotThrow(() => uninstallRuntimeArtifacts('claude', configDir, 'global'));
|
||||
|
||||
const skillFile = path.join(configDir, 'skills', 'gsd-dev-preferences', 'SKILL.md');
|
||||
if (fs.existsSync(skillFile)) {
|
||||
const content = fs.readFileSync(skillFile, 'utf8');
|
||||
assert.ok(!content.includes('PRIVATE KEY MATERIAL'), 'the symlink referent must never be migrated into SKILL.md');
|
||||
}
|
||||
} finally {
|
||||
cleanup(configDir);
|
||||
cleanup(secretFile);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #2875 defect fix (security/regression): a staging-root resolution failure
|
||||
// (e.g. `.gsd-staging/user-artifacts` is/contains a symlink) must DEGRADE —
|
||||
// skip staging for that step, warn — rather than abort install()/uninstall()
|
||||
// entirely. Before this fix, `_resolveUserArtifactStagingRoot` was called
|
||||
// UNGUARDED as the first statement of both, so a hostile/broken
|
||||
// `.gsd-staging` path bricked BOTH commands, including uninstall — the
|
||||
// remedy for the first problem.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('install()/uninstall() degrade (never abort) on a staging-root resolution failure', () => {
|
||||
function withHostileStagingSymlink(runFn) {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-staging-degrade-'));
|
||||
const elsewhere = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-staging-degrade-elsewhere-'));
|
||||
const previousCwd = process.cwd();
|
||||
process.chdir(tmp);
|
||||
try {
|
||||
fs.mkdirSync(path.join(tmp, '.gsd-staging'));
|
||||
fs.symlinkSync(elsewhere, path.join(tmp, '.gsd-staging', 'user-artifacts'));
|
||||
runFn(tmp);
|
||||
} finally {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmp);
|
||||
cleanup(elsewhere);
|
||||
}
|
||||
}
|
||||
|
||||
test('install() proceeds and still writes gsd-core/ when the staging root is a hostile symlink', () => {
|
||||
withHostileStagingSymlink((tmp) => {
|
||||
assert.doesNotThrow(() => install(false, 'cline'), 'install() must degrade, never throw, on a staging-root failure');
|
||||
assert.ok(fs.existsSync(path.join(tmp, 'gsd-core')), 'gsd-core/ must still be installed despite the staging-root failure');
|
||||
});
|
||||
});
|
||||
|
||||
test('uninstall() proceeds and still removes gsd-core/ when the staging root is a hostile symlink', () => {
|
||||
withHostileStagingSymlink((tmp) => {
|
||||
// Bypass the (also-degrading) install() path to set up a realistic
|
||||
// pre-existing gsd-core/ without depending on install() itself.
|
||||
fs.mkdirSync(path.join(tmp, 'gsd-core'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmp, 'gsd-core', 'USER-PROFILE.md'), 'user content');
|
||||
assert.doesNotThrow(() => uninstall(false, 'cline'), 'uninstall() must degrade, never throw, on a staging-root failure');
|
||||
assert.ok(!fs.existsSync(path.join(tmp, 'gsd-core')), 'gsd-core/ must still be removed despite the staging-root failure');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3558,16 +3558,17 @@ describe('uninstall — manifest cleanup (#1908)', () => {
|
||||
* Regression tests for bug #2771: USER-PROFILE.md tracked in install manifest
|
||||
*
|
||||
* USER-PROFILE.md is a user-owned artifact created/refreshed by /gsd-profile-user.
|
||||
* preserveUserArtifacts() correctly preserves it across reinstalls. But writeManifest()
|
||||
* also records it under "gsd-core/USER-PROFILE.md" with a SHA-256 of whatever was
|
||||
* on disk at install time. On the next install, saveLocalPatches() compares the on-disk
|
||||
* (refreshed) hash to the manifest hash, finds them different, and emits the spurious
|
||||
* "Found N locally modified GSD file(s) — backed up to gsd-local-patches/" warning.
|
||||
* The user-artifact-staging.cts durable staging path (#2875) correctly preserves it
|
||||
* across reinstalls. But writeManifest() also records it under
|
||||
* "gsd-core/USER-PROFILE.md" with a SHA-256 of whatever was on disk at install time.
|
||||
* On the next install, saveLocalPatches() compares the on-disk (refreshed) hash to
|
||||
* the manifest hash, finds them different, and emits the spurious "Found N locally
|
||||
* modified GSD file(s) — backed up to gsd-local-patches/" warning.
|
||||
*
|
||||
* Invariant: a file is either distribution (manifest-tracked, diff'd against manifest)
|
||||
* or user artifact (preserved across installs, never diff'd). It cannot be both. The
|
||||
* shared truth source must be a single USER_OWNED_ARTIFACTS list referenced by both
|
||||
* preserveUserArtifacts callers and writeManifest.
|
||||
* the staging call sites and writeManifest.
|
||||
*
|
||||
* Closes: #2771
|
||||
*/
|
||||
@@ -3639,7 +3640,7 @@ describe('#2771: USER-PROFILE.md is excluded from gsd-file-manifest.json', () =>
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test 2: preserveUserArtifacts still preserves USER-PROFILE.md ────────────
|
||||
// ─── Test 2: USER-PROFILE.md is still preserved (via durable staging, #2875) ──
|
||||
|
||||
describe('#2771: USER-PROFILE.md is still preserved across reinstall', () => {
|
||||
let tmpDir;
|
||||
|
||||
@@ -5609,6 +5609,75 @@ describe('issue #2517: install end-to-end — per-project config reaches Codex T
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #2875: install-model-override-resolver.cts's `depth < 8` upward-walk ──
|
||||
// boundary (CLAUDE.md boundary coverage: a budget limit must be exercised at
|
||||
// limit-1/limit/limit+1). The walk starts AT targetDir (checked at depth=0,
|
||||
// "0 levels up") and stops after depth=7 ("7 levels up", the LAST reachable
|
||||
// ancestor) — a `.planning/config.json` 8 levels up is never reached. Both
|
||||
// `readGsdRuntimeProfileResolver` and `readGsdEffectiveModelOverrides` run
|
||||
// the identical loop shape; readGsdRuntimeProfileResolver is exercised here
|
||||
// since resolver.runtime !== null is a simple, direct found/not-found signal.
|
||||
describe('#2875: install-model-override-resolver upward-walk depth boundary (limit-1/limit/limit+1)', () => {
|
||||
const { readGsdRuntimeProfileResolver } = require('../gsd-core/bin/lib/install-model-override-resolver.cjs');
|
||||
|
||||
beforeEach(() => { isolateHome(); resetRuntimeWarningCaches(); });
|
||||
afterEach(() => { restoreHome(); });
|
||||
|
||||
// Builds an 8-level-deep directory chain under a fresh temp root and
|
||||
// returns { root, leaf }, where leaf is 8 levels below root (root/L1/../L8).
|
||||
// ancestorLevelsUp(leaf, n) === root/L1/../L(8-n).
|
||||
function buildDeepChain() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-depth-walk-'));
|
||||
let dir = root;
|
||||
for (let i = 1; i <= 8; i += 1) {
|
||||
dir = path.join(dir, `L${i}`);
|
||||
}
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
return { root, leaf: dir };
|
||||
}
|
||||
|
||||
function ancestorLevelsUp(leaf, n) {
|
||||
let dir = leaf;
|
||||
for (let i = 0; i < n; i += 1) dir = path.dirname(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
// writeConfig assumes `<dir>/.planning/` already exists (every other call
|
||||
// site in this file writes into a `createTempProject()`-scaffolded tree,
|
||||
// which pre-creates it) — the bare ancestor dirs `buildDeepChain` makes do
|
||||
// not, so create it first.
|
||||
function writeConfigAt(dir, obj) {
|
||||
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
|
||||
writeConfig(dir, obj);
|
||||
}
|
||||
|
||||
test('limit-1: config.json 6 levels up from targetDir is found', (t) => {
|
||||
const { root, leaf } = buildDeepChain();
|
||||
t.after(() => cleanup(root));
|
||||
writeConfigAt(ancestorLevelsUp(leaf, 6), { runtime: 'codex', model_profile: 'quality' });
|
||||
const resolver = readGsdRuntimeProfileResolver(leaf);
|
||||
assert.ok(resolver, 'a config.json 6 levels up must be found — well within the 8-deep walk');
|
||||
assert.strictEqual(resolver.runtime, 'codex');
|
||||
});
|
||||
|
||||
test('limit: config.json 7 levels up from targetDir is found — the LAST reachable ancestor', (t) => {
|
||||
const { root, leaf } = buildDeepChain();
|
||||
t.after(() => cleanup(root));
|
||||
writeConfigAt(ancestorLevelsUp(leaf, 7), { runtime: 'codex', model_profile: 'quality' });
|
||||
const resolver = readGsdRuntimeProfileResolver(leaf);
|
||||
assert.ok(resolver, 'a config.json exactly 7 levels up (the walk\'s last checked ancestor) must still be found');
|
||||
assert.strictEqual(resolver.runtime, 'codex');
|
||||
});
|
||||
|
||||
test('limit+1: config.json 8 levels up from targetDir is NEVER found — one level past what the walk reaches', (t) => {
|
||||
const { root, leaf } = buildDeepChain();
|
||||
t.after(() => cleanup(root));
|
||||
writeConfigAt(ancestorLevelsUp(leaf, 8), { runtime: 'codex', model_profile: 'quality' });
|
||||
const resolver = readGsdRuntimeProfileResolver(leaf);
|
||||
assert.strictEqual(resolver, null, 'a config.json 8 levels up is past the walk\'s cap and must not be found');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── RUNTIME_PROFILE_MAP single source of truth (finding #16) ───────────────
|
||||
describe('issue #2517: RUNTIME_PROFILE_MAP single source of truth (finding #16)', () => {
|
||||
test('install.js consumes the same map as model-catalog.cjs', () => {
|
||||
|
||||
@@ -49,8 +49,16 @@ const FAKE_DIR = '/tmp/fake-config-dir-dd';
|
||||
|
||||
const GOLDEN = {
|
||||
// ── claude ──────────────────────────────────────────────────────────────────
|
||||
// #2875 Part 2 (the agents-descriptor migration): claude/global gains an
|
||||
// `agents` kind. This is NOT new on-disk behavior — a `claude --global`
|
||||
// install always wrote `<configDir>/agents/gsd-*.md` via the now-deleted
|
||||
// inline agent-staging loop in bin/install.js (claude was never in
|
||||
// `_DESCRIPTOR_AGENTS_RUNTIMES`, and that loop ran unconditionally,
|
||||
// independent of `_isSkillsRuntime`/scope). The descriptor previously never
|
||||
// modeled that write; it now does, matching what was always materialized.
|
||||
'claude/global': [
|
||||
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
|
||||
{ kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' },
|
||||
],
|
||||
'claude/local': [
|
||||
{ kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' }, // #1367: flat gsd-<cmd>.md
|
||||
@@ -73,11 +81,17 @@ const GOLDEN = {
|
||||
|
||||
// ── codex ────────────────────────────────────────────────────────────────────
|
||||
// Old switch: no scope branch → local == global. 5b backfill restores this.
|
||||
// #2875 Part 2: agents kind added (the inline loop wrote codex agents too —
|
||||
// codex was never in `_DESCRIPTOR_AGENTS_RUNTIMES`; the config.toml
|
||||
// [agents.gsd-*] strip on a full→minimal downgrade is a SEPARATE, still
|
||||
// hand-rolled write this migration deliberately does not touch).
|
||||
'codex/global': [
|
||||
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
|
||||
{ kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' },
|
||||
],
|
||||
'codex/local': [
|
||||
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
|
||||
{ kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' },
|
||||
],
|
||||
|
||||
// ── copilot ──────────────────────────────────────────────────────────────────
|
||||
@@ -155,11 +169,15 @@ const GOLDEN = {
|
||||
|
||||
// ── hermes ───────────────────────────────────────────────────────────────────
|
||||
// Old switch: no scope branch → local == global. 5b backfill restores this.
|
||||
// #2875 Part 2: agents kind added (the inline loop wrote hermes agents too,
|
||||
// via a new named branding converter — convertClaudeAgentToHermesAgent).
|
||||
'hermes/global': [
|
||||
{ kind: 'skills', destSubpath: 'skills/gsd', prefix: 'gsd-' },
|
||||
{ kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' },
|
||||
],
|
||||
'hermes/local': [
|
||||
{ kind: 'skills', destSubpath: 'skills/gsd', prefix: 'gsd-' },
|
||||
{ kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' },
|
||||
],
|
||||
|
||||
// ── codebuddy ────────────────────────────────────────────────────────────────
|
||||
@@ -178,10 +196,26 @@ const GOLDEN = {
|
||||
|
||||
// ── cline ────────────────────────────────────────────────────────────────────
|
||||
// Old switch: scope='global' → [skills]; scope='local' → []. Matches descriptor.
|
||||
// #2875 Part 2: agents kind added to global (the inline loop wrote cline
|
||||
// agents too, via convertClaudeAgentToClineAgent).
|
||||
// #2875 Part 2 defect fix (cline-local agents-drop regression, closed):
|
||||
// cline was never scope-gated in the deleted inline loop either, so a real
|
||||
// `cline --local` install ALSO wrote agent files pre-migration (to the
|
||||
// project root, per hostBehaviors.localTargetIsProjectRoot). `local` now
|
||||
// ALSO declares the agents kind (capabilities/cline/capability.json),
|
||||
// restoring that behavior — see tests/agent-descriptor-parity.test.cjs's
|
||||
// cline (local) H row and tests/cline-install.test.cjs's local-install
|
||||
// regression test for the byte-parity / end-to-end proofs. `local` still
|
||||
// declares no `skills` kind: cline-local commands are embedded in
|
||||
// .clinerules, never materialized as skill files
|
||||
// (hostBehaviors.localCommandsViaRules).
|
||||
'cline/global': [
|
||||
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
|
||||
{ kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' },
|
||||
],
|
||||
'cline/local': [
|
||||
{ kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' },
|
||||
],
|
||||
'cline/local': [],
|
||||
|
||||
// ── kimi ─────────────────────────────────────────────────────────────────────
|
||||
// Old switch: scope='global' → [skills, kimi-agents]; scope='local' → []. Matches descriptor.
|
||||
@@ -197,24 +231,35 @@ const GOLDEN = {
|
||||
// OpenCode discovers slash commands from commands/ (plural); the singular
|
||||
// command/ dir GSD previously wrote to is not scanned by OpenCode 1.17.13,
|
||||
// so none of the ~71 /gsd-* commands ever appeared in the OpenCode TUI.
|
||||
// #2875 Part 2: agents kind added (the inline loop wrote opencode agents
|
||||
// too — opencode's agents materialization previously went through the
|
||||
// separate installOpencodeFamilySkills-adjacent inline loop, not through
|
||||
// this layout at all; installAgentsKindStandalone now covers it).
|
||||
'opencode/global': [
|
||||
{ kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' },
|
||||
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
|
||||
{ kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' },
|
||||
],
|
||||
'opencode/local': [
|
||||
{ kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' },
|
||||
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
|
||||
{ kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' },
|
||||
],
|
||||
|
||||
// ── kilo ─────────────────────────────────────────────────────────────────────
|
||||
// Old switch: no scope branch → local == global. 5b backfill restores this.
|
||||
// #2875 Part 2: agents kind added (mirrors opencode — same combined-family
|
||||
// install shape, same install-engine.cts installOpencodeFamilyArtifacts /
|
||||
// installAgentsKindStandalone coverage).
|
||||
'kilo/global': [
|
||||
{ kind: 'commands', destSubpath: 'command', prefix: 'gsd-' },
|
||||
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
|
||||
{ kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' },
|
||||
],
|
||||
'kilo/local': [
|
||||
{ kind: 'commands', destSubpath: 'command', prefix: 'gsd-' },
|
||||
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
|
||||
{ kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -52,15 +52,29 @@ describe('resolveRuntimeArtifactLayout — claude local', () => {
|
||||
});
|
||||
|
||||
describe('resolveRuntimeArtifactLayout — claude global', () => {
|
||||
// #2875 Part 2: claude/global gained an `agents` kind — NOT new on-disk
|
||||
// behavior. A `claude --global` install always wrote
|
||||
// `<configDir>/agents/gsd-*.md` via the now-deleted inline agent-staging
|
||||
// loop in bin/install.js (claude was never in the deleted
|
||||
// `_DESCRIPTOR_AGENTS_RUNTIMES` set, and that loop ran unconditionally,
|
||||
// independent of `_isSkillsRuntime`/scope). The descriptor previously never
|
||||
// modeled that write; it now does, matching what was always materialized —
|
||||
// which is also why the golden install-tree fixtures never moved (see
|
||||
// tests/fixtures/install-tree/**): the on-disk bytes were unchanged, only
|
||||
// the descriptor's own metadata became complete.
|
||||
test('returns correct layout for claude scope=global', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('claude', FAKE_DIR, 'global');
|
||||
assert.strictEqual(layout.runtime, 'claude');
|
||||
assert.strictEqual(layout.configDir, FAKE_DIR);
|
||||
assert.strictEqual(layout.kinds.length, 1);
|
||||
assert.strictEqual(layout.kinds.length, 2);
|
||||
assert.strictEqual(layout.kinds[0].kind, 'skills');
|
||||
assert.strictEqual(layout.kinds[0].destSubpath, 'skills');
|
||||
assert.strictEqual(layout.kinds[0].prefix, 'gsd-');
|
||||
assert.strictEqual(typeof layout.kinds[0].stage, 'function');
|
||||
assert.strictEqual(layout.kinds[1].kind, 'agents');
|
||||
assert.strictEqual(layout.kinds[1].destSubpath, 'agents');
|
||||
assert.strictEqual(layout.kinds[1].prefix, 'gsd-');
|
||||
assert.strictEqual(typeof layout.kinds[1].stage, 'function');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,15 +103,23 @@ describe('resolveRuntimeArtifactLayout — cursor', () => {
|
||||
});
|
||||
|
||||
describe('resolveRuntimeArtifactLayout — codex', () => {
|
||||
// #2875 Part 2: agents kind added — codex was never in the deleted
|
||||
// `_DESCRIPTOR_AGENTS_RUNTIMES` set, so the inline loop wrote codex agents
|
||||
// too; the descriptor now models it. Codex's separate config.toml
|
||||
// `[agents.gsd-*]` strip on a full→minimal downgrade is untouched.
|
||||
test('returns correct layout for codex', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('codex', FAKE_DIR);
|
||||
assert.strictEqual(layout.runtime, 'codex');
|
||||
assert.strictEqual(layout.configDir, FAKE_DIR);
|
||||
assert.strictEqual(layout.kinds.length, 1);
|
||||
assert.strictEqual(layout.kinds.length, 2);
|
||||
assert.strictEqual(layout.kinds[0].kind, 'skills');
|
||||
assert.strictEqual(layout.kinds[0].destSubpath, 'skills');
|
||||
assert.strictEqual(layout.kinds[0].prefix, 'gsd-');
|
||||
assert.strictEqual(typeof layout.kinds[0].stage, 'function');
|
||||
assert.strictEqual(layout.kinds[1].kind, 'agents');
|
||||
assert.strictEqual(layout.kinds[1].destSubpath, 'agents');
|
||||
assert.strictEqual(layout.kinds[1].prefix, 'gsd-');
|
||||
assert.strictEqual(typeof layout.kinds[1].stage, 'function');
|
||||
});
|
||||
|
||||
test('#2429: codex local scope does not set $HOME/.agents skills home override', () => {
|
||||
@@ -294,15 +316,23 @@ describe('resolveRuntimeArtifactLayout — kimi', () => {
|
||||
});
|
||||
|
||||
describe('resolveRuntimeArtifactLayout — hermes', () => {
|
||||
// #2875 Part 2: agents kind added — hermes was never in the deleted
|
||||
// `_DESCRIPTOR_AGENTS_RUNTIMES` set, so the inline loop wrote hermes agents
|
||||
// too (brand-swapped via the new named converter convertClaudeAgentToHermesAgent,
|
||||
// whose rewrite data was already declared on hostBehaviors.brandingRewrites).
|
||||
test('returns correct layout for hermes', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('hermes', FAKE_DIR);
|
||||
assert.strictEqual(layout.runtime, 'hermes');
|
||||
assert.strictEqual(layout.configDir, FAKE_DIR);
|
||||
assert.strictEqual(layout.kinds.length, 1);
|
||||
assert.strictEqual(layout.kinds.length, 2);
|
||||
assert.strictEqual(layout.kinds[0].kind, 'skills');
|
||||
assert.strictEqual(layout.kinds[0].destSubpath, 'skills/gsd');
|
||||
assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); // #947: restored canonical prefix
|
||||
assert.strictEqual(typeof layout.kinds[0].stage, 'function');
|
||||
assert.strictEqual(layout.kinds[1].kind, 'agents');
|
||||
assert.strictEqual(layout.kinds[1].destSubpath, 'agents');
|
||||
assert.strictEqual(layout.kinds[1].prefix, 'gsd-');
|
||||
assert.strictEqual(typeof layout.kinds[1].stage, 'function');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -334,31 +364,44 @@ describe('resolveRuntimeArtifactLayout — codebuddy', () => {
|
||||
});
|
||||
|
||||
describe('resolveRuntimeArtifactLayout — cline', () => {
|
||||
// #2875 Part 2: agents kind added to cline/global — cline was never in the
|
||||
// deleted `_DESCRIPTOR_AGENTS_RUNTIMES` set, so the inline loop wrote cline
|
||||
// agents too, via convertClaudeAgentToClineAgent.
|
||||
test('returns correct layout for cline global (skills-capable since v3.48.0 — #782)', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('cline', FAKE_DIR, 'global');
|
||||
assert.strictEqual(layout.runtime, 'cline');
|
||||
assert.strictEqual(layout.configDir, FAKE_DIR);
|
||||
assert.strictEqual(layout.kinds.length, 1);
|
||||
assert.strictEqual(layout.kinds.length, 2);
|
||||
assert.strictEqual(layout.kinds[0].kind, 'skills');
|
||||
assert.strictEqual(layout.kinds[0].destSubpath, 'skills');
|
||||
assert.strictEqual(layout.kinds[0].prefix, 'gsd-');
|
||||
assert.strictEqual(typeof layout.kinds[0].stage, 'function');
|
||||
assert.strictEqual(layout.kinds[1].kind, 'agents');
|
||||
assert.strictEqual(layout.kinds[1].destSubpath, 'agents');
|
||||
assert.strictEqual(layout.kinds[1].prefix, 'gsd-');
|
||||
assert.strictEqual(typeof layout.kinds[1].stage, 'function');
|
||||
});
|
||||
|
||||
test('cline local: no skills kinds (global-only, #782)', () => {
|
||||
test('cline local: no skills kind (skills are global-only, #782); agents kind present (#2875 Part 2 defect fix, cline-local agents-drop regression)', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('cline', FAKE_DIR, 'local');
|
||||
assert.strictEqual(layout.runtime, 'cline');
|
||||
assert.strictEqual(layout.configDir, FAKE_DIR);
|
||||
assert.strictEqual(layout.kinds.length, 0);
|
||||
const kindNames = layout.kinds.map((k) => k.kind).sort();
|
||||
assert.deepStrictEqual(kindNames, ['agents'], 'cline local must declare only the agents kind — no skills kind');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRuntimeArtifactLayout — opencode', () => {
|
||||
test('returns commands + skills layout for opencode (#784)', () => {
|
||||
// #2875 Part 2: agents kind added — opencode/kilo's agents were previously
|
||||
// written by a code path entirely separate from this layout
|
||||
// (installOpencodeFamilyArtifacts's bespoke writers never called
|
||||
// resolveRuntimeArtifactLayout for agents); installAgentsKindStandalone now
|
||||
// covers them through the SAME descriptor this layout resolves.
|
||||
test('returns commands + skills + agents layout for opencode (#784)', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('opencode', FAKE_DIR);
|
||||
assert.strictEqual(layout.runtime, 'opencode');
|
||||
assert.strictEqual(layout.configDir, FAKE_DIR);
|
||||
assert.strictEqual(layout.kinds.length, 2);
|
||||
assert.strictEqual(layout.kinds.length, 3);
|
||||
|
||||
const commands = layout.kinds.find((k) => k.kind === 'commands');
|
||||
assert.ok(commands, 'should have a commands kind');
|
||||
@@ -373,15 +416,23 @@ describe('resolveRuntimeArtifactLayout — opencode', () => {
|
||||
assert.strictEqual(skills.destSubpath, 'skills');
|
||||
assert.strictEqual(skills.prefix, 'gsd-');
|
||||
assert.strictEqual(typeof skills.stage, 'function');
|
||||
|
||||
const agents = layout.kinds.find((k) => k.kind === 'agents');
|
||||
assert.ok(agents, 'should have an agents kind');
|
||||
assert.strictEqual(agents.destSubpath, 'agents');
|
||||
assert.strictEqual(agents.prefix, 'gsd-');
|
||||
assert.strictEqual(typeof agents.stage, 'function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRuntimeArtifactLayout — kilo', () => {
|
||||
test('returns commands + skills layout for kilo (#784)', () => {
|
||||
// #2875 Part 2: agents kind added — same reason as opencode above (kilo
|
||||
// shares the combined-family install shape).
|
||||
test('returns commands + skills + agents layout for kilo (#784)', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('kilo', FAKE_DIR);
|
||||
assert.strictEqual(layout.runtime, 'kilo');
|
||||
assert.strictEqual(layout.configDir, FAKE_DIR);
|
||||
assert.strictEqual(layout.kinds.length, 2);
|
||||
assert.strictEqual(layout.kinds.length, 3);
|
||||
|
||||
const commands = layout.kinds.find((k) => k.kind === 'commands');
|
||||
assert.ok(commands, 'should have a commands kind');
|
||||
@@ -394,6 +445,12 @@ describe('resolveRuntimeArtifactLayout — kilo', () => {
|
||||
assert.strictEqual(skills.destSubpath, 'skills');
|
||||
assert.strictEqual(skills.prefix, 'gsd-');
|
||||
assert.strictEqual(typeof skills.stage, 'function');
|
||||
|
||||
const agents = layout.kinds.find((k) => k.kind === 'agents');
|
||||
assert.ok(agents, 'should have an agents kind');
|
||||
assert.strictEqual(agents.destSubpath, 'agents');
|
||||
assert.strictEqual(agents.prefix, 'gsd-');
|
||||
assert.strictEqual(typeof agents.stage, 'function');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -420,10 +477,14 @@ describe('resolveRuntimeArtifactLayout edge-cases', () => {
|
||||
assert.ok(!kindNames.includes('commands'), 'cursor commands kind would duplicate skill menu entries');
|
||||
});
|
||||
|
||||
test('claude global has only skills kind', () => {
|
||||
// #2875 Part 2: claude/global now also declares an `agents` kind (see the
|
||||
// 'resolveRuntimeArtifactLayout — claude global' describe block above for
|
||||
// the full "was this new on-disk behavior?" determination — it is not).
|
||||
test('claude global has skills and agents kinds', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('claude', '/tmp/x', 'global');
|
||||
assert.strictEqual(layout.kinds.length, 1);
|
||||
assert.strictEqual(layout.kinds.length, 2);
|
||||
assert.strictEqual(layout.kinds[0].kind, 'skills');
|
||||
assert.strictEqual(layout.kinds[1].kind, 'agents');
|
||||
});
|
||||
|
||||
test('unknown runtime grok throws TypeError containing runtime name', () => {
|
||||
|
||||
985
tests/user-artifact-staging.test.cjs
Normal file
985
tests/user-artifact-staging.test.cjs
Normal file
@@ -0,0 +1,985 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* User Artifact Staging Module — the module's own contract (#2875, epic
|
||||
* #2866 Phase 6, governed by ADR-3574 and
|
||||
* .gsd/phase/feat-2875-materialization-primitives/50-test-matrix.md).
|
||||
*
|
||||
* Confinement rows (E1-E5) live in tests/install-write-confinement.test.cjs
|
||||
* (this suite's own file-count ratchet keeps them there — see 50-test-matrix.md
|
||||
* "Suites"). Call-site integration (Hermes/Claude dev-preferences migration,
|
||||
* the mainline gsd-core copy, and C7 production-reachability) lives in
|
||||
* tests/install-runtime-artifacts.test.cjs.
|
||||
*
|
||||
* Crash-window injection is by monkeypatching a real `node:fs` method via
|
||||
* `t.mock.method` (auto-restoring, or explicitly `.mock.restore()`d where a
|
||||
* later `t.after` teardown needs the real method back before the mock
|
||||
* tracker's own end-of-test restore runs) — never chmod/permission tricks
|
||||
* (root bypasses mode bits in CI), and never a manual try/finally
|
||||
* (CONTRIBUTING.md bans try/finally in test bodies). This works because
|
||||
* install-fs-adapter.cts's REAL_ADAPTER calls `nodeFs.<method>(...)` as a
|
||||
* live property lookup on the `node:fs` module object at call time, not a
|
||||
* captured reference (see that module's own doc comment).
|
||||
*/
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const fc = require('fast-check');
|
||||
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const {
|
||||
stageUserArtifacts,
|
||||
restoreStagedUserArtifacts,
|
||||
discardStagedUserArtifacts,
|
||||
recoverOrphanedUserArtifacts,
|
||||
parseOwnerPid,
|
||||
} = require('../gsd-core/bin/lib/user-artifact-staging.cjs');
|
||||
const { withInstallFs } = require('../gsd-core/bin/lib/install-fs-adapter.cjs');
|
||||
const { copyPreservingSymlink } = require('../gsd-core/bin/lib/installer-migrations.cjs');
|
||||
|
||||
function mktemp(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function stagingRootFor(configDir) {
|
||||
return path.join(configDir, '.gsd-staging', 'user-artifacts');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// A. Staging contract
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('user-artifact-staging: A. staging contract', () => {
|
||||
test('A1: one existing file stages before any wipe; record written after', (t) => {
|
||||
const destDir = mktemp('gsd-uas-a1-dest-');
|
||||
const configDir = mktemp('gsd-uas-a1-cfg-');
|
||||
t.after(() => { cleanup(destDir); cleanup(configDir); });
|
||||
|
||||
fs.writeFileSync(path.join(destDir, 'USER-PROFILE.md'), 'hello world\n', 'utf8');
|
||||
const staged = stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRootFor(configDir));
|
||||
|
||||
assert.ok(fs.existsSync(path.join(staged.filesDir, 'USER-PROFILE.md')), 'copy landed in staging');
|
||||
assert.ok(fs.existsSync(staged.recordPath), 'record.json exists');
|
||||
const record = JSON.parse(fs.readFileSync(staged.recordPath, 'utf8'));
|
||||
assert.deepEqual(record.names, ['USER-PROFILE.md']);
|
||||
assert.equal(record.destDir, path.resolve(destDir));
|
||||
});
|
||||
|
||||
test('A2: file absent from destDir — not staged, no record entry, no throw', (t) => {
|
||||
const destDir = mktemp('gsd-uas-a2-dest-');
|
||||
const configDir = mktemp('gsd-uas-a2-cfg-');
|
||||
t.after(() => { cleanup(destDir); cleanup(configDir); });
|
||||
|
||||
const staged = stageUserArtifacts(destDir, ['does-not-exist.md'], stagingRootFor(configDir));
|
||||
assert.deepEqual(staged.names, []);
|
||||
assert.ok(!fs.existsSync(path.join(staged.filesDir, 'does-not-exist.md')));
|
||||
});
|
||||
|
||||
test('A3: fileNames empty — empty staging, record still coherent', (t) => {
|
||||
const destDir = mktemp('gsd-uas-a3-dest-');
|
||||
const configDir = mktemp('gsd-uas-a3-cfg-');
|
||||
t.after(() => { cleanup(destDir); cleanup(configDir); });
|
||||
|
||||
const staged = stageUserArtifacts(destDir, [], stagingRootFor(configDir));
|
||||
assert.deepEqual(staged.names, []);
|
||||
assert.ok(fs.existsSync(staged.recordPath), 'record still written for an empty batch');
|
||||
const record = JSON.parse(fs.readFileSync(staged.recordPath, 'utf8'));
|
||||
assert.deepEqual(record.names, []);
|
||||
});
|
||||
|
||||
test('A4: symlinked file — the link is recreated, the referent is never read', (t) => {
|
||||
const destDir = mktemp('gsd-uas-a4-dest-');
|
||||
const configDir = mktemp('gsd-uas-a4-cfg-');
|
||||
const targetDir = mktemp('gsd-uas-a4-target-');
|
||||
t.after(() => { cleanup(destDir); cleanup(configDir); cleanup(targetDir); });
|
||||
|
||||
const targetFile = path.join(targetDir, 'secret.txt');
|
||||
fs.writeFileSync(targetFile, 'referent-bytes-must-never-be-read');
|
||||
fs.symlinkSync(targetFile, path.join(destDir, 'USER-PROFILE.md'));
|
||||
|
||||
const originalReadFileSync = fs.readFileSync;
|
||||
let readFileSyncCalledOnTarget = false;
|
||||
// t.mock.method auto-restores the original after this test — no manual
|
||||
// try/finally (CONTRIBUTING.md bans try/finally in test bodies).
|
||||
t.mock.method(fs, 'readFileSync', (p, ...rest) => {
|
||||
if (String(p) === targetFile) readFileSyncCalledOnTarget = true;
|
||||
return originalReadFileSync.call(fs, p, ...rest);
|
||||
});
|
||||
const staged = stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRootFor(configDir));
|
||||
|
||||
assert.equal(readFileSyncCalledOnTarget, false, 'referent bytes must never be read');
|
||||
const stagedPath = path.join(staged.filesDir, 'USER-PROFILE.md');
|
||||
assert.ok(fs.lstatSync(stagedPath).isSymbolicLink(), 'staged copy is itself a symlink');
|
||||
assert.equal(fs.readlinkSync(stagedPath), targetFile);
|
||||
});
|
||||
|
||||
test('A5: staged copy is byte-identical, including CRLF and a trailing newline', (t) => {
|
||||
const destDir = mktemp('gsd-uas-a5-dest-');
|
||||
const configDir = mktemp('gsd-uas-a5-cfg-');
|
||||
t.after(() => { cleanup(destDir); cleanup(configDir); });
|
||||
|
||||
const content = 'line one\r\nline two\r\nno-trailing-newline';
|
||||
fs.writeFileSync(path.join(destDir, 'USER-PROFILE.md'), content);
|
||||
const staged = stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRootFor(configDir));
|
||||
const stagedContent = fs.readFileSync(path.join(staged.filesDir, 'USER-PROFILE.md'), 'utf8');
|
||||
assert.equal(stagedContent, content);
|
||||
});
|
||||
|
||||
test('A6: staging the same destDir twice does not corrupt the first record, and clears stale files from the prior batch', (t) => {
|
||||
const destDir = mktemp('gsd-uas-a6-dest-');
|
||||
const configDir = mktemp('gsd-uas-a6-cfg-');
|
||||
t.after(() => { cleanup(destDir); cleanup(configDir); });
|
||||
|
||||
fs.writeFileSync(path.join(destDir, 'USER-PROFILE.md'), 'v1');
|
||||
fs.writeFileSync(path.join(destDir, 'dev-preferences.md'), 'stale-from-first-batch');
|
||||
const first = stageUserArtifacts(destDir, ['USER-PROFILE.md', 'dev-preferences.md'], stagingRootFor(configDir));
|
||||
assert.deepEqual(first.names.sort(), ['USER-PROFILE.md', 'dev-preferences.md']);
|
||||
|
||||
fs.writeFileSync(path.join(destDir, 'USER-PROFILE.md'), 'v2');
|
||||
// Second call only asks for USER-PROFILE.md — dev-preferences.md is no
|
||||
// longer part of this batch. Deterministic key reuse is asserted
|
||||
// directly against `first`, captured BEFORE this call — #2875 defect
|
||||
// fix: previously this equality check was performed by folding a SECOND
|
||||
// `stageUserArtifacts` call directly into the assertion, which
|
||||
// overwrote `first`'s own on-disk state (record.json + files/) before it
|
||||
// was ever re-examined, so the test's own "does not corrupt the first
|
||||
// record" claim was never actually checked.
|
||||
const second = stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRootFor(configDir));
|
||||
assert.equal(second.entryDir, first.entryDir, 'same destDir maps to the same staging entry (deterministic key)');
|
||||
|
||||
const content = fs.readFileSync(path.join(second.filesDir, 'USER-PROFILE.md'), 'utf8');
|
||||
assert.equal(content, 'v2', 'second stage call overwrites the first cleanly, no corruption');
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(second.filesDir, 'dev-preferences.md')),
|
||||
'the entry dir is cleared FIRST — a file from a prior batch must not linger alongside the new one',
|
||||
);
|
||||
|
||||
// #2875 defect fix: re-examine `first`'s OWN captured paths (identical to
|
||||
// `second`'s, by construction — same destDir, same deterministic key)
|
||||
// AFTER the second stage call, proving the record was cleanly REPLACED,
|
||||
// not merged or corrupted. A bug that failed to clear the entry dir
|
||||
// before rewriting record.json (e.g. names from both batches surviving)
|
||||
// would fail this and did not fail the old assertions.
|
||||
const recordAfterSecondStage = JSON.parse(fs.readFileSync(first.recordPath, 'utf8'));
|
||||
assert.deepEqual(
|
||||
recordAfterSecondStage.names, ['USER-PROFILE.md'],
|
||||
'the record at the path `first` originally wrote must now reflect ONLY the second batch',
|
||||
);
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(first.filesDir, 'dev-preferences.md')),
|
||||
'dev-preferences.md staged by the FIRST batch must be gone from the (shared) files dir after the second call',
|
||||
);
|
||||
});
|
||||
|
||||
test('A7: crash between copy and record — treated as incomplete, not a recovery source', (t) => {
|
||||
const destDir = mktemp('gsd-uas-a7-dest-');
|
||||
const configDir = mktemp('gsd-uas-a7-cfg-');
|
||||
t.after(() => { cleanup(destDir); cleanup(configDir); });
|
||||
|
||||
fs.writeFileSync(path.join(destDir, 'USER-PROFILE.md'), 'content');
|
||||
const stagingRoot = stagingRootFor(configDir);
|
||||
|
||||
const originalWriteFileSync = fs.writeFileSync;
|
||||
// t.mock.method auto-restores the original after this test — no manual
|
||||
// try/finally (CONTRIBUTING.md bans try/finally in test bodies).
|
||||
t.mock.method(fs, 'writeFileSync', (p, ...rest) => {
|
||||
if (String(p).endsWith('record.json')) {
|
||||
throw Object.assign(new Error('simulated crash before record commit'), { code: 'EIO' });
|
||||
}
|
||||
return originalWriteFileSync.call(fs, p, ...rest);
|
||||
});
|
||||
assert.throws(() => stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRoot), /simulated crash/);
|
||||
|
||||
// The copy landed (files/ populated) but no record.json — recovery must
|
||||
// ignore this half-staged entry entirely (B4).
|
||||
const result = recoverOrphanedUserArtifacts(stagingRoot, configDir);
|
||||
assert.deepEqual(result.recovered, [], 'a recordless staging entry is never a recovery source');
|
||||
});
|
||||
|
||||
test('A8: the record timestamp comes from the injected clock seam, never the real wall clock', (t) => {
|
||||
const destDir = mktemp('gsd-uas-a8-dest-');
|
||||
const configDir = mktemp('gsd-uas-a8-cfg-');
|
||||
t.after(() => { cleanup(destDir); cleanup(configDir); });
|
||||
|
||||
fs.writeFileSync(path.join(destDir, 'USER-PROFILE.md'), 'x');
|
||||
const pinnedMs = Date.parse('2001-01-01T00:00:00.000Z');
|
||||
const fakeClock = { now: () => pinnedMs };
|
||||
const staged = stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRootFor(configDir), { clock: fakeClock });
|
||||
const record = JSON.parse(fs.readFileSync(staged.recordPath, 'utf8'));
|
||||
assert.equal(record.timestamp, new Date(pinnedMs).toISOString());
|
||||
});
|
||||
|
||||
test('A9: restoreStagedUserArtifacts opts.rename restores a staged name under a DIFFERENT destination file name', (t) => {
|
||||
const destDir = mktemp('gsd-uas-a9-dest-');
|
||||
const configDir = mktemp('gsd-uas-a9-cfg-');
|
||||
t.after(() => { cleanup(destDir); cleanup(configDir); });
|
||||
|
||||
fs.writeFileSync(path.join(destDir, 'dev-preferences.md'), 'legacy content');
|
||||
const staged = stageUserArtifacts(destDir, ['dev-preferences.md'], stagingRootFor(configDir));
|
||||
cleanup(destDir);
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
|
||||
restoreStagedUserArtifacts(destDir, staged, { rename: { 'dev-preferences.md': 'gsd-dev-preferences.md' } });
|
||||
|
||||
assert.ok(!fs.existsSync(path.join(destDir, 'dev-preferences.md')), 'the OLD name must not be recreated');
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(destDir, 'gsd-dev-preferences.md'), 'utf8'),
|
||||
'legacy content',
|
||||
'content lands under the renamed destination',
|
||||
);
|
||||
});
|
||||
|
||||
test('A10 (#2875 F1 concurrency defect): two concurrent RUNS staging the SAME destDir do not clobber each other', (t) => {
|
||||
// Reproduces the actual race: the staging key was `sha256(destDir)`
|
||||
// alone, so a SECOND run's stageUserArtifacts call — its entryDir-
|
||||
// clearing rmSync — destroyed a FIRST, already-committed run's batch for
|
||||
// the same destDir. `runId` (default `process.pid`, here overridden to
|
||||
// simulate two DIFFERENT concurrent processes without forking a real
|
||||
// one) discriminates the two runs' staging keys. Passing `runId` against
|
||||
// the PRE-FIX module is a silent no-op (the option did not exist; the
|
||||
// key ignored it and both calls still collided on sha256(destDir) alone)
|
||||
// — so this exact test body is the red-then-green proof: FAILS against
|
||||
// the code before the fix, PASSES after.
|
||||
const destDir = mktemp('gsd-uas-a10-dest-');
|
||||
const configDir = mktemp('gsd-uas-a10-cfg-');
|
||||
t.after(() => { cleanup(destDir); cleanup(configDir); });
|
||||
const stagingRoot = stagingRootFor(configDir);
|
||||
|
||||
// Run A stages and commits first.
|
||||
fs.writeFileSync(path.join(destDir, 'USER-PROFILE.md'), 'run-A content');
|
||||
const runA = stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRoot, { runId: 'process-A-pid-100' });
|
||||
assert.ok(fs.existsSync(runA.recordPath), 'run A committed its record');
|
||||
assert.equal(fs.readFileSync(path.join(runA.filesDir, 'USER-PROFILE.md'), 'utf8'), 'run-A content');
|
||||
|
||||
// Run B — a SEPARATE concurrent run (different runId) racing the SAME
|
||||
// destDir, starting its own stage call before run A has restored or
|
||||
// discarded its batch. This is the exact interleaving the F1 race
|
||||
// describes: two processes' preserve/wipe/restore cycles overlapping.
|
||||
fs.writeFileSync(path.join(destDir, 'dev-preferences.md'), 'run-B content');
|
||||
const runB = stageUserArtifacts(destDir, ['dev-preferences.md'], stagingRoot, { runId: 'process-B-pid-200' });
|
||||
|
||||
// Run A's committed batch must survive run B's entryDir-clearing step —
|
||||
// under the pre-fix shared key, run B's initial rmSync(entryDir) wiped
|
||||
// run A's record.json and files/ before this assertion ever runs.
|
||||
assert.ok(fs.existsSync(runA.recordPath), 'run A record must survive a concurrent run B stage call');
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(runA.filesDir, 'USER-PROFILE.md'), 'utf8'),
|
||||
'run-A content',
|
||||
'run A staged content must survive — this is the exact clobber the F1 race causes',
|
||||
);
|
||||
assert.notEqual(runA.entryDir, runB.entryDir, 'two concurrent runs must key to DIFFERENT entry directories');
|
||||
|
||||
// Both runs' data is independently recoverable — proves the fix does not
|
||||
// just avoid a crash, it keeps both batches genuinely intact.
|
||||
assert.equal(fs.readFileSync(path.join(runB.filesDir, 'dev-preferences.md'), 'utf8'), 'run-B content');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// B. The crash window
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('user-artifact-staging: B. the crash window (F19)', () => {
|
||||
test('B1 (the F19 regression row): crash between wipe and restore, then recovery — byte-identical', (t) => {
|
||||
const configDir = mktemp('gsd-uas-b1-cfg-');
|
||||
t.after(() => cleanup(configDir));
|
||||
const destDir = path.join(configDir, 'gsd-core');
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
const stagingRoot = stagingRootFor(configDir);
|
||||
|
||||
fs.writeFileSync(path.join(destDir, 'USER-PROFILE.md'), 'my profile content\n');
|
||||
// #2875 defect fix (owner-liveness guard, C14/C15 pattern): a dead runId
|
||||
// is required here — this test's OWN process is alive for the whole
|
||||
// test, so the default runId (process.pid) would make
|
||||
// recoverOrphanedUserArtifacts's ownerStillLive guard treat this batch
|
||||
// as belonging to a still-live peer and refuse to recover it, which
|
||||
// would make this "the F19 regression row" test inert (it would pass
|
||||
// for the wrong reason — a skip, not a genuine recovery).
|
||||
stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRoot, { runId: '999999' });
|
||||
|
||||
// Simulate the crash: the wipe ran, the process died before restore.
|
||||
cleanup(destDir);
|
||||
assert.ok(!fs.existsSync(destDir), 'destDir is gone — this is the F19 crash window');
|
||||
|
||||
// Next run: recovery, BEFORE any new preserve/wipe cycle.
|
||||
const result = recoverOrphanedUserArtifacts(stagingRoot, configDir);
|
||||
assert.equal(result.recovered.length, 1);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(destDir, 'USER-PROFILE.md'), 'utf8'),
|
||||
'my profile content\n',
|
||||
);
|
||||
});
|
||||
|
||||
test('B2 (negative proof): the in-memory-only shape this replaces cannot recover, proving the row is real', (t) => {
|
||||
// preserveUserArtifacts/restoreUserArtifacts (pre-#2875) held content ONLY
|
||||
// in a Map — nothing durable ever touched disk between preserve and a
|
||||
// process death. This reproduces that exact shape inline (no disk write)
|
||||
// to demonstrate recoverOrphanedUserArtifacts has nothing to find: proof
|
||||
// that B1's green result comes from the staging fix, not from the test
|
||||
// being unable to fail.
|
||||
const configDir = mktemp('gsd-uas-b2-cfg-');
|
||||
t.after(() => cleanup(configDir));
|
||||
const destDir = path.join(configDir, 'gsd-core');
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
const stagingRoot = stagingRootFor(configDir);
|
||||
|
||||
fs.writeFileSync(path.join(destDir, 'USER-PROFILE.md'), 'never staged to disk');
|
||||
const legacyInMemoryMap = new Map([['USER-PROFILE.md', fs.readFileSync(path.join(destDir, 'USER-PROFILE.md'), 'utf8')]]);
|
||||
void legacyInMemoryMap; // held only in the process's heap — a crash here loses it
|
||||
|
||||
cleanup(destDir); // the crash: process dies right here, map is gone
|
||||
|
||||
// #2875 defect fix: mirror B1's own post-crash sequence EXACTLY (recover,
|
||||
// THEN recreate destDir, THEN check for the specific file) — the only
|
||||
// difference from B1 is that stageUserArtifacts was never called. The
|
||||
// previous version asserted `!fs.existsSync(destDir)` immediately after
|
||||
// `cleanup(destDir)` deleted it on the line above, which is trivially
|
||||
// true regardless of anything recovery does and never actually exercised
|
||||
// recovery's behavior; it was also structurally identical to C5 (no
|
||||
// staging root at all), so it could not distinguish "recovery correctly
|
||||
// finds nothing" from "recovery is broken in a way that never finds
|
||||
// anything, ever". This version recreates destDir (as a real
|
||||
// preserve/wipe cycle would) and checks the recovered destination
|
||||
// directly — proving negatively that recovery does not (and cannot)
|
||||
// resurrect content nothing ever staged to disk.
|
||||
const result = recoverOrphanedUserArtifacts(stagingRoot, configDir);
|
||||
assert.deepEqual(result.recovered, [], 'nothing was ever staged to disk — recovery correctly finds nothing');
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(destDir, 'USER-PROFILE.md')),
|
||||
'the file is genuinely lost under the pre-#2875 shape — recovery cannot resurrect content that was never staged',
|
||||
);
|
||||
});
|
||||
|
||||
test('B4: crash before the record is written — nothing restored, half-staged dir left alone', (t) => {
|
||||
const configDir = mktemp('gsd-uas-b4-cfg-');
|
||||
t.after(() => cleanup(configDir));
|
||||
const stagingRoot = stagingRootFor(configDir);
|
||||
const destDir = path.join(configDir, 'gsd-core');
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(destDir, 'USER-PROFILE.md'), 'x');
|
||||
|
||||
const originalWriteFileSync = fs.writeFileSync;
|
||||
// t.mock.method auto-restores the original after this test — no manual
|
||||
// try/finally (CONTRIBUTING.md bans try/finally in test bodies).
|
||||
t.mock.method(fs, 'writeFileSync', (p, ...rest) => {
|
||||
if (String(p).endsWith('record.json')) throw Object.assign(new Error('crash'), { code: 'EIO' });
|
||||
return originalWriteFileSync.call(fs, p, ...rest);
|
||||
});
|
||||
assert.throws(() => stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRoot));
|
||||
|
||||
const result = recoverOrphanedUserArtifacts(stagingRoot, configDir);
|
||||
assert.deepEqual(result.recovered, []);
|
||||
assert.deepEqual(result.skipped, []);
|
||||
// The half-staged entry itself is left in place (not a recovery source,
|
||||
// but also not swept — it is evidence, not garbage).
|
||||
const entries = fs.readdirSync(stagingRoot);
|
||||
assert.equal(entries.length, 1, 'the half-staged entry dir is still present for inspection');
|
||||
});
|
||||
|
||||
test('B5: crash after successful restore — staging already discarded, next run finds no orphan', (t) => {
|
||||
const configDir = mktemp('gsd-uas-b5-cfg-');
|
||||
t.after(() => cleanup(configDir));
|
||||
const destDir = path.join(configDir, 'gsd-core');
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
const stagingRoot = stagingRootFor(configDir);
|
||||
|
||||
fs.writeFileSync(path.join(destDir, 'USER-PROFILE.md'), 'restored already');
|
||||
const staged = stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRoot);
|
||||
cleanup(destDir);
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
restoreStagedUserArtifacts(destDir, staged);
|
||||
discardStagedUserArtifacts(staged);
|
||||
|
||||
const result = recoverOrphanedUserArtifacts(stagingRoot, configDir);
|
||||
assert.deepEqual(result.recovered, [], 'nothing left to recover — the batch was already discarded');
|
||||
assert.deepEqual(result.skipped, []);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// C. Recovery semantics — the anti-inertness rows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('user-artifact-staging: C. recovery semantics', () => {
|
||||
test('C1: orphan exists, destDir file absent — restored', (t) => {
|
||||
const configDir = mktemp('gsd-uas-c1-cfg-');
|
||||
t.after(() => cleanup(configDir));
|
||||
const destDir = path.join(configDir, 'gsd-core');
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
const stagingRoot = stagingRootFor(configDir);
|
||||
fs.writeFileSync(path.join(destDir, 'USER-PROFILE.md'), 'c1');
|
||||
// #2875 defect fix (owner-liveness guard, C14/C15 pattern): dead runId —
|
||||
// see B1's comment for why the default (this process's own live pid)
|
||||
// would make this test inert.
|
||||
stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRoot, { runId: '999999' });
|
||||
cleanup(destDir);
|
||||
|
||||
const result = recoverOrphanedUserArtifacts(stagingRoot, configDir);
|
||||
assert.equal(result.recovered.length, 1);
|
||||
assert.equal(fs.readFileSync(path.join(destDir, 'USER-PROFILE.md'), 'utf8'), 'c1');
|
||||
});
|
||||
|
||||
test('C2: orphan exists, destDir file present — NOT overwritten', (t) => {
|
||||
const configDir = mktemp('gsd-uas-c2-cfg-');
|
||||
t.after(() => cleanup(configDir));
|
||||
const destDir = path.join(configDir, 'gsd-core');
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
const stagingRoot = stagingRootFor(configDir);
|
||||
fs.writeFileSync(path.join(destDir, 'USER-PROFILE.md'), 'orphaned-content');
|
||||
// #2875 defect fix (owner-liveness guard, C14/C15 pattern): dead runId —
|
||||
// see B1's comment.
|
||||
stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRoot, { runId: '999999' });
|
||||
// The destDir file was NOT wiped this time — a fresh, different file is present.
|
||||
fs.writeFileSync(path.join(destDir, 'USER-PROFILE.md'), 'current-user-content');
|
||||
|
||||
const result = recoverOrphanedUserArtifacts(stagingRoot, configDir);
|
||||
assert.equal(result.recovered.length, 0);
|
||||
assert.equal(result.skipped.length, 1);
|
||||
assert.equal(result.skipped[0].reason, 'dest-already-present');
|
||||
assert.equal(fs.readFileSync(path.join(destDir, 'USER-PROFILE.md'), 'utf8'), 'current-user-content');
|
||||
});
|
||||
|
||||
test('C3: recorded destDir no longer exists — recreated, never throws', (t) => {
|
||||
const configDir = mktemp('gsd-uas-c3-cfg-');
|
||||
t.after(() => cleanup(configDir));
|
||||
const destDir = path.join(configDir, 'legacy', 'gsd');
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
const stagingRoot = stagingRootFor(configDir);
|
||||
fs.writeFileSync(path.join(destDir, 'dev-preferences.md'), 'c3');
|
||||
// #2875 defect fix (owner-liveness guard, C14/C15 pattern): dead runId —
|
||||
// see B1's comment.
|
||||
stageUserArtifacts(destDir, ['dev-preferences.md'], stagingRoot, { runId: '999999' });
|
||||
// Remove the WHOLE parent tree, not just destDir.
|
||||
cleanup(path.join(configDir, 'legacy'));
|
||||
assert.ok(!fs.existsSync(destDir));
|
||||
|
||||
const result = recoverOrphanedUserArtifacts(stagingRoot, configDir);
|
||||
assert.equal(result.recovered.length, 1);
|
||||
assert.equal(fs.readFileSync(path.join(destDir, 'dev-preferences.md'), 'utf8'), 'c3');
|
||||
});
|
||||
|
||||
test('C4: recovery runs before the preserve step — recovered file is then protected by the ordinary cycle', (t) => {
|
||||
const configDir = mktemp('gsd-uas-c4-cfg-');
|
||||
t.after(() => cleanup(configDir));
|
||||
const destDir = path.join(configDir, 'gsd-core');
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
const stagingRoot = stagingRootFor(configDir);
|
||||
fs.writeFileSync(path.join(destDir, 'USER-PROFILE.md'), 'c4');
|
||||
// #2875 defect fix (owner-liveness guard, C14/C15 pattern): dead runId —
|
||||
// see B1's comment. The SECOND stageUserArtifacts call below (the
|
||||
// "ordinary preserve step") deliberately keeps the default live runId —
|
||||
// it is not exercising recovery, only proving the recovered file is
|
||||
// re-stageable afterward.
|
||||
stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRoot, { runId: '999999' });
|
||||
cleanup(destDir);
|
||||
|
||||
recoverOrphanedUserArtifacts(stagingRoot, configDir);
|
||||
assert.ok(fs.existsSync(path.join(destDir, 'USER-PROFILE.md')), 'recovered before the ordinary cycle runs');
|
||||
|
||||
// The ordinary preserve step now sees the recovered file and protects it.
|
||||
const staged = stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRoot);
|
||||
assert.deepEqual(staged.names, ['USER-PROFILE.md']);
|
||||
});
|
||||
|
||||
test('C5: no staging root at all — no-op, no throw, no directory created', (t) => {
|
||||
const configDir = mktemp('gsd-uas-c5-cfg-');
|
||||
t.after(() => cleanup(configDir));
|
||||
const stagingRoot = stagingRootFor(configDir); // never created
|
||||
const result = recoverOrphanedUserArtifacts(stagingRoot, configDir);
|
||||
assert.deepEqual(result, { recovered: [], skipped: [] });
|
||||
assert.ok(!fs.existsSync(stagingRoot), 'recovery must not create a staging root');
|
||||
});
|
||||
|
||||
test('C6: corrupt/truncated record JSON — ignored, never a crash', (t) => {
|
||||
const configDir = mktemp('gsd-uas-c6-cfg-');
|
||||
t.after(() => cleanup(configDir));
|
||||
const stagingRoot = stagingRootFor(configDir);
|
||||
const entryDir = path.join(stagingRoot, 'deadbeefdeadbeef');
|
||||
fs.mkdirSync(path.join(entryDir, 'files'), { recursive: true });
|
||||
fs.writeFileSync(path.join(entryDir, 'files', 'USER-PROFILE.md'), 'x');
|
||||
fs.writeFileSync(path.join(entryDir, 'record.json'), '{ this is not valid json');
|
||||
|
||||
assert.doesNotThrow(() => recoverOrphanedUserArtifacts(stagingRoot, configDir));
|
||||
const result = recoverOrphanedUserArtifacts(stagingRoot, configDir);
|
||||
assert.deepEqual(result.recovered, []);
|
||||
// Malformed record is left alone for inspection, not swept.
|
||||
assert.ok(fs.existsSync(entryDir), 'malformed staging entry is left in place, not deleted');
|
||||
});
|
||||
|
||||
test('C8: configDir is an explicit parameter, not derived from stagingRoot\'s path shape — a record naming a destDir outside the EXPLICITLY-passed configDir is refused', (t) => {
|
||||
// stagingRoot deliberately lives OUTSIDE configDir entirely (no nested
|
||||
// relationship at all) — the two-directories-up derivation this replaced
|
||||
// would have computed a nonsense confinement root here. The explicit
|
||||
// parameter is what makes the confinement decision correct regardless of
|
||||
// where stagingRoot physically lives.
|
||||
const configDir = mktemp('gsd-uas-c8-cfg-');
|
||||
const unrelatedStagingParent = mktemp('gsd-uas-c8-unrelated-');
|
||||
const outsideConfigDir = mktemp('gsd-uas-c8-outside-');
|
||||
t.after(() => { cleanup(configDir); cleanup(unrelatedStagingParent); cleanup(outsideConfigDir); });
|
||||
|
||||
const stagingRoot = path.join(unrelatedStagingParent, 'wherever', 'staging');
|
||||
const entryDir = path.join(stagingRoot, 'c8entry0000000000');
|
||||
fs.mkdirSync(path.join(entryDir, 'files'), { recursive: true });
|
||||
fs.writeFileSync(path.join(entryDir, 'files', 'USER-PROFILE.md'), 'attacker or stale data');
|
||||
// destDir resolves under outsideConfigDir, NOT under configDir.
|
||||
fs.writeFileSync(
|
||||
path.join(entryDir, 'record.json'),
|
||||
JSON.stringify({ destDir: path.join(outsideConfigDir, 'gsd-core'), names: ['USER-PROFILE.md'], timestamp: new Date().toISOString() }),
|
||||
);
|
||||
|
||||
const result = recoverOrphanedUserArtifacts(stagingRoot, configDir);
|
||||
assert.equal(result.recovered.length, 0, 'must refuse — destDir is outside the explicitly-passed configDir');
|
||||
assert.equal(result.skipped.length, 1);
|
||||
assert.equal(result.skipped[0].reason, 'destDir-outside-confinement');
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(outsideConfigDir, 'gsd-core', 'USER-PROFILE.md')),
|
||||
'must never write to the recorded destDir when it escapes the explicit configDir',
|
||||
);
|
||||
});
|
||||
|
||||
// Confinement/symlink rows (dangling-symlink destination, symlinked
|
||||
// ancestor, separator-in-name) live in tests/install-write-confinement.test.cjs
|
||||
// as E6-E9, alongside E1-E5 — this suite's own file-count ratchet keeps
|
||||
// confinement rows there (see this file's own header doc comment).
|
||||
|
||||
// #2875 defect fix: recoverOrphanedUserArtifacts's "never throws" contract
|
||||
// was false — mkdirSync/stagedCopy/the final rmSync were all unguarded, so
|
||||
// one unrecoverable entry (e.g. a `files/<name>` that is unexpectedly a
|
||||
// directory, causing the symlink-safe copy to throw) propagated out of the
|
||||
// whole function, and — because it threw BEFORE that entry was ever
|
||||
// cleaned up — permanently bricked install/uninstall on every future run.
|
||||
test('C13: an entry that cannot be recovered (a directory staged where a file is expected) never throws, and other entries still recover', (t) => {
|
||||
const configDir = mktemp('gsd-uas-c13-cfg-');
|
||||
t.after(() => cleanup(configDir));
|
||||
const stagingRoot = stagingRootFor(configDir);
|
||||
|
||||
// Entry 1: genuinely broken — files/BROKEN.md is a DIRECTORY, not a file,
|
||||
// which the symlink-safe copy cannot handle.
|
||||
const brokenDestDir = path.join(configDir, 'broken-dest');
|
||||
fs.mkdirSync(brokenDestDir, { recursive: true });
|
||||
const brokenEntryDir = path.join(stagingRoot, 'c13brokenentry00');
|
||||
fs.mkdirSync(path.join(brokenEntryDir, 'files', 'BROKEN.md'), { recursive: true }); // a DIR, not a file
|
||||
fs.writeFileSync(
|
||||
path.join(brokenEntryDir, 'record.json'),
|
||||
JSON.stringify({ destDir: path.resolve(brokenDestDir), names: ['BROKEN.md'], timestamp: new Date().toISOString() }),
|
||||
);
|
||||
|
||||
// Entry 2: a perfectly ordinary, recoverable batch.
|
||||
const goodDestDir = path.join(configDir, 'good-dest');
|
||||
fs.mkdirSync(goodDestDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(goodDestDir, 'USER-PROFILE.md'), 'good content');
|
||||
// #2875 defect fix (owner-liveness guard, C14/C15 pattern): dead runId —
|
||||
// see B1's comment.
|
||||
stageUserArtifacts(goodDestDir, ['USER-PROFILE.md'], stagingRoot, { runId: '999999' });
|
||||
cleanup(path.join(goodDestDir, 'USER-PROFILE.md'));
|
||||
|
||||
let result;
|
||||
assert.doesNotThrow(() => { result = recoverOrphanedUserArtifacts(stagingRoot, configDir); });
|
||||
assert.ok(
|
||||
result.recovered.some((r) => r.name === 'USER-PROFILE.md' && r.destDir === path.resolve(goodDestDir)),
|
||||
'the OTHER (good) batch must still be recovered even though a sibling batch is broken',
|
||||
);
|
||||
assert.ok(fs.existsSync(brokenEntryDir), 'the broken entry must be LEFT IN PLACE, not silently swept, since it was never actually recovered');
|
||||
});
|
||||
|
||||
test('C14 (#2875 F1 residual defect): a peer recovery pass must not sweep an entry whose owning process is still alive', (t) => {
|
||||
// recoverOrphanedUserArtifacts runs as the FIRST statement of both
|
||||
// install() and uninstall() — a second run's recovery pass executing
|
||||
// while a first run is still between stageUserArtifacts's commit and its
|
||||
// own restore/discard is the ordinary case, not an exotic interleaving.
|
||||
// Reproduces it directly: run A stages with the DEFAULT runId (this test
|
||||
// process's OWN real pid — genuinely alive for the whole test, standing
|
||||
// in for "run A still in flight"), then a peer recovery pass runs while
|
||||
// A's destDir is mid-wipe (removed, not yet restored).
|
||||
const configDir = mktemp('gsd-uas-c14-cfg-');
|
||||
t.after(() => cleanup(configDir));
|
||||
const destDir = path.join(configDir, 'gsd-core');
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
const stagingRoot = stagingRootFor(configDir);
|
||||
|
||||
fs.writeFileSync(path.join(destDir, 'USER-PROFILE.md'), 'run-A content, still in flight');
|
||||
const runA = stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRoot);
|
||||
assert.ok(fs.existsSync(runA.recordPath), 'run A committed its record');
|
||||
|
||||
// Run A's own wipe, in progress — destDir is gone, restore has not
|
||||
// happened yet. A PEER run's recovery pass runs here.
|
||||
cleanup(destDir);
|
||||
const result = recoverOrphanedUserArtifacts(stagingRoot, configDir);
|
||||
|
||||
// A live owner's entry must be left COMPLETELY alone — not recovered
|
||||
// (which would race run A's own in-progress cycle) and not swept.
|
||||
assert.ok(fs.existsSync(runA.recordPath), 'run A entry must survive a peer recovery pass while run A is still alive');
|
||||
assert.equal(result.recovered.length, 0, 'a live owner\'s file must not be recovered on its behalf either');
|
||||
assert.deepEqual(result.skipped, [{ entryDir: runA.entryDir, reason: 'owner-still-live' }]);
|
||||
|
||||
// Run A can still complete its OWN cycle normally afterward — the guard
|
||||
// only protects against a PEER touching it, never blocks the owner.
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
restoreStagedUserArtifacts(destDir, runA);
|
||||
discardStagedUserArtifacts(runA);
|
||||
assert.equal(fs.readFileSync(path.join(destDir, 'USER-PROFILE.md'), 'utf8'), 'run-A content, still in flight');
|
||||
});
|
||||
|
||||
test('C15: a dead pid (ESRCH) is recovered normally — the liveness guard never blocks a genuine orphan', (t) => {
|
||||
const configDir = mktemp('gsd-uas-c15-cfg-');
|
||||
t.after(() => cleanup(configDir));
|
||||
const destDir = path.join(configDir, 'gsd-core');
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
const stagingRoot = stagingRootFor(configDir);
|
||||
|
||||
// A pid that is essentially guaranteed not to exist right now.
|
||||
const deadPid = '999999';
|
||||
const entryDir = path.join(stagingRoot, 'c15deadpidentry0');
|
||||
fs.mkdirSync(path.join(entryDir, 'files'), { recursive: true });
|
||||
fs.writeFileSync(path.join(entryDir, 'files', 'USER-PROFILE.md'), 'orphaned content');
|
||||
fs.writeFileSync(
|
||||
path.join(entryDir, 'record.json'),
|
||||
JSON.stringify({ destDir: path.resolve(destDir), names: ['USER-PROFILE.md'], timestamp: new Date().toISOString(), runId: deadPid }),
|
||||
);
|
||||
|
||||
const result = recoverOrphanedUserArtifacts(stagingRoot, configDir);
|
||||
assert.equal(result.recovered.length, 1, 'a dead pid must never be treated as a live owner');
|
||||
assert.equal(fs.readFileSync(path.join(destDir, 'USER-PROFILE.md'), 'utf8'), 'orphaned content');
|
||||
assert.ok(!fs.existsSync(entryDir), 'fully recovered — swept as before this fix');
|
||||
});
|
||||
|
||||
test('C16: a missing/non-numeric runId (a legacy or hand-edited record) is never treated as live forever', (t) => {
|
||||
const configDir = mktemp('gsd-uas-c16-cfg-');
|
||||
t.after(() => cleanup(configDir));
|
||||
const destDir = path.join(configDir, 'gsd-core');
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
const stagingRoot = stagingRootFor(configDir);
|
||||
|
||||
// A pre-this-fix record: no runId field at all.
|
||||
const entryDir = path.join(stagingRoot, 'c16legacyentry00');
|
||||
fs.mkdirSync(path.join(entryDir, 'files'), { recursive: true });
|
||||
fs.writeFileSync(path.join(entryDir, 'files', 'USER-PROFILE.md'), 'legacy orphaned content');
|
||||
fs.writeFileSync(
|
||||
path.join(entryDir, 'record.json'),
|
||||
JSON.stringify({ destDir: path.resolve(destDir), names: ['USER-PROFILE.md'], timestamp: new Date().toISOString() }),
|
||||
);
|
||||
|
||||
// #2875 defect fix: the previous version called recoverOrphanedUserArtifacts
|
||||
// TWICE and checked the SECOND call's result — but a successful recovery
|
||||
// sweeps the entry (rmSync) once it is fully accounted for, so the first
|
||||
// (assert.doesNotThrow) call already recovered and removed it, leaving
|
||||
// the second call's result empty regardless of whether recovery itself
|
||||
// works. Capture the FIRST call's result instead — the same pattern C13
|
||||
// already uses.
|
||||
let result;
|
||||
assert.doesNotThrow(() => { result = recoverOrphanedUserArtifacts(stagingRoot, configDir); });
|
||||
assert.equal(result.recovered.length, 1, 'a record with no runId must recover normally, never blocked as "live forever"');
|
||||
assert.ok(!fs.existsSync(entryDir));
|
||||
});
|
||||
|
||||
test('C17: an apparently-live pid past OWNER_LIVENESS_GRACE_MS is treated as orphaned regardless (the pid-reuse guard)', (t) => {
|
||||
const configDir = mktemp('gsd-uas-c17-cfg-');
|
||||
t.after(() => cleanup(configDir));
|
||||
const destDir = path.join(configDir, 'gsd-core');
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
const stagingRoot = stagingRootFor(configDir);
|
||||
|
||||
// runId is THIS test process's own real (genuinely alive) pid, but the
|
||||
// record's timestamp is far in the past — simulates a crashed run whose
|
||||
// pid an unrelated, currently-alive process now happens to occupy.
|
||||
const entryDir = path.join(stagingRoot, 'c17stalelivepid0');
|
||||
fs.mkdirSync(path.join(entryDir, 'files'), { recursive: true });
|
||||
fs.writeFileSync(path.join(entryDir, 'files', 'USER-PROFILE.md'), 'stale orphaned content');
|
||||
const staleTimestamp = new Date(Date.now() - 60 * 60 * 1000).toISOString(); // 1 hour ago
|
||||
fs.writeFileSync(
|
||||
path.join(entryDir, 'record.json'),
|
||||
JSON.stringify({ destDir: path.resolve(destDir), names: ['USER-PROFILE.md'], timestamp: staleTimestamp, runId: String(process.pid) }),
|
||||
);
|
||||
|
||||
const result = recoverOrphanedUserArtifacts(stagingRoot, configDir);
|
||||
assert.equal(result.recovered.length, 1, 'a stale claim past the grace window must recover regardless of apparent liveness');
|
||||
assert.ok(!fs.existsSync(entryDir));
|
||||
});
|
||||
|
||||
test('C18: a fresh, apparently-live claim just inside the clock-seam-injected grace window is protected', (t) => {
|
||||
const configDir = mktemp('gsd-uas-c18-cfg-');
|
||||
t.after(() => cleanup(configDir));
|
||||
const destDir = path.join(configDir, 'gsd-core');
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
const stagingRoot = stagingRootFor(configDir);
|
||||
|
||||
const entryDir = path.join(stagingRoot, 'c18freshlivepid0');
|
||||
fs.mkdirSync(path.join(entryDir, 'files'), { recursive: true });
|
||||
fs.writeFileSync(path.join(entryDir, 'files', 'USER-PROFILE.md'), 'fresh live content');
|
||||
const recordTimestamp = Date.parse('2001-01-01T00:00:00.000Z');
|
||||
fs.writeFileSync(
|
||||
path.join(entryDir, 'record.json'),
|
||||
JSON.stringify({
|
||||
destDir: path.resolve(destDir),
|
||||
names: ['USER-PROFILE.md'],
|
||||
timestamp: new Date(recordTimestamp).toISOString(),
|
||||
runId: String(process.pid),
|
||||
}),
|
||||
);
|
||||
|
||||
// Injected clock — never the real wall clock — pinned to 4 minutes after
|
||||
// the record's own timestamp, inside the 5-minute grace window.
|
||||
const fakeClock = { now: () => recordTimestamp + 4 * 60 * 1000 };
|
||||
const result = recoverOrphanedUserArtifacts(stagingRoot, configDir, { clock: fakeClock });
|
||||
assert.equal(result.recovered.length, 0, 'still within the grace window — must not recover on the live owner\'s behalf');
|
||||
assert.deepEqual(result.skipped, [{ entryDir, reason: 'owner-still-live' }]);
|
||||
assert.ok(fs.existsSync(entryDir), 'must not be swept while apparently live and fresh');
|
||||
});
|
||||
|
||||
// CLAUDE.md boundary coverage: OWNER_LIVENESS_GRACE_MS (5 * 60 * 1000 =
|
||||
// 300000ms) is a budget limit — exercised at limit-1, limit, and limit+1,
|
||||
// not merely "somewhere inside" (C18's 4-minute probe) or "somewhere
|
||||
// outside" (C17's 1-hour probe). The guard's own predicate is strict-less-
|
||||
// than (`clock.now() - recordMs < OWNER_LIVENESS_GRACE_MS`), so the
|
||||
// boundary itself (limit, exactly 300000ms elapsed) flips to "no longer
|
||||
// protected" — these three rows pin that exact flip.
|
||||
function ownerLivenessAtElapsed(t, elapsedMs) {
|
||||
const configDir = mktemp(`gsd-uas-c19-${elapsedMs}-cfg-`);
|
||||
t.after(() => cleanup(configDir));
|
||||
const destDir = path.join(configDir, 'gsd-core');
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
const stagingRoot = stagingRootFor(configDir);
|
||||
|
||||
const entryDir = path.join(stagingRoot, `c19b${elapsedMs}livepid0`.slice(0, 16));
|
||||
fs.mkdirSync(path.join(entryDir, 'files'), { recursive: true });
|
||||
fs.writeFileSync(path.join(entryDir, 'files', 'USER-PROFILE.md'), 'boundary content');
|
||||
const recordTimestamp = Date.parse('2001-01-01T00:00:00.000Z');
|
||||
fs.writeFileSync(
|
||||
path.join(entryDir, 'record.json'),
|
||||
JSON.stringify({
|
||||
destDir: path.resolve(destDir),
|
||||
names: ['USER-PROFILE.md'],
|
||||
timestamp: new Date(recordTimestamp).toISOString(),
|
||||
runId: String(process.pid),
|
||||
}),
|
||||
);
|
||||
|
||||
const fakeClock = { now: () => recordTimestamp + elapsedMs };
|
||||
return { entryDir, result: recoverOrphanedUserArtifacts(stagingRoot, configDir, { clock: fakeClock }) };
|
||||
}
|
||||
|
||||
test('C19: OWNER_LIVENESS_GRACE_MS boundary — limit-1 (299999ms elapsed) is still protected', (t) => {
|
||||
const { entryDir, result } = ownerLivenessAtElapsed(t, 5 * 60 * 1000 - 1);
|
||||
assert.equal(result.recovered.length, 0, 'one ms inside the grace window must still protect the live owner\'s claim');
|
||||
assert.deepEqual(result.skipped, [{ entryDir, reason: 'owner-still-live' }]);
|
||||
assert.ok(fs.existsSync(entryDir));
|
||||
});
|
||||
|
||||
test('C20: OWNER_LIVENESS_GRACE_MS boundary — limit (300000ms elapsed exactly) flips to orphaned', (t) => {
|
||||
const { entryDir, result } = ownerLivenessAtElapsed(t, 5 * 60 * 1000);
|
||||
assert.equal(result.recovered.length, 1, 'exactly at the grace window boundary the guard\'s strict `<` no longer protects — must recover');
|
||||
assert.ok(!fs.existsSync(entryDir));
|
||||
});
|
||||
|
||||
test('C21: OWNER_LIVENESS_GRACE_MS boundary — limit+1 (300001ms elapsed) is orphaned', (t) => {
|
||||
const { entryDir, result } = ownerLivenessAtElapsed(t, 5 * 60 * 1000 + 1);
|
||||
assert.equal(result.recovered.length, 1, 'one ms past the grace window must recover — same side of the boundary as limit');
|
||||
assert.ok(!fs.existsSync(entryDir));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// D. Fs seam completeness
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('user-artifact-staging: D. fs seam completeness', () => {
|
||||
test('D1: staging under an injected fake adapter resolves; no real path created', (t) => {
|
||||
const configDir = mktemp('gsd-uas-d1-cfg-');
|
||||
t.after(() => cleanup(configDir));
|
||||
const destDir = path.join(configDir, 'gsd-core');
|
||||
const stagingRoot = stagingRootFor(configDir);
|
||||
|
||||
const store = new Map();
|
||||
const norm = (p) => path.normalize(String(p));
|
||||
store.set(norm(destDir), { type: 'dir' });
|
||||
store.set(norm(path.join(destDir, 'USER-PROFILE.md')), { type: 'file', content: 'fake-fs-content' });
|
||||
let fakeRmSyncCalls = 0;
|
||||
const fake = {
|
||||
existsSync: (p) => store.has(norm(p)),
|
||||
mkdirSync: (p) => { store.set(norm(p), { type: 'dir' }); },
|
||||
// #2875 defect fix: stageUserArtifacts's entryDir-clearing step calls
|
||||
// installFs().rmSync unconditionally — a partial fake omitting it
|
||||
// previously fell through to REAL fs.rmSync silently (install-fs-
|
||||
// adapter.cts's PARTIAL-ADAPTER TRAP), which this test's own negative
|
||||
// assertion below could not detect (it only checked `stagingRoot`,
|
||||
// which real rmSync on a non-existent real path no-ops against
|
||||
// harmlessly with `force: true` — vacuously true either way).
|
||||
rmSync: (p) => {
|
||||
fakeRmSyncCalls++;
|
||||
const n = norm(p);
|
||||
store.delete(n);
|
||||
const prefix = n.endsWith(path.sep) ? n : n + path.sep;
|
||||
for (const k of [...store.keys()]) if (k.startsWith(prefix)) store.delete(k);
|
||||
},
|
||||
writeFileSync: (p, data) => { store.set(norm(p), { type: 'file', content: data }); },
|
||||
lstatSync: (p) => {
|
||||
const e = store.get(norm(p));
|
||||
if (!e) { const err = new Error('ENOENT'); err.code = 'ENOENT'; throw err; }
|
||||
return { isFile: () => e.type === 'file', isDirectory: () => e.type === 'dir', isSymbolicLink: () => false };
|
||||
},
|
||||
copyFileSync: (src, dest) => {
|
||||
const e = store.get(norm(src));
|
||||
store.set(norm(dest), { type: 'file', content: e ? e.content : '' });
|
||||
},
|
||||
};
|
||||
|
||||
const staged = withInstallFs(fake, () => stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRoot));
|
||||
assert.deepEqual(staged.names, ['USER-PROFILE.md']);
|
||||
assert.ok(store.has(norm(path.join(staged.filesDir, 'USER-PROFILE.md'))), 'landed in the fake store');
|
||||
assert.ok(fakeRmSyncCalls > 0, 'the entryDir-clearing step must route through the FAKE rmSync, proving this is a real negative check');
|
||||
assert.ok(!fs.existsSync(stagingRoot), 'no real filesystem path was ever created');
|
||||
});
|
||||
|
||||
test('D3: staging touches zero real fs under a fake adapter (poison by path)', (t) => {
|
||||
const configDir = mktemp('gsd-uas-d3-cfg-');
|
||||
t.after(() => cleanup(configDir));
|
||||
const destDir = path.join(configDir, 'gsd-core');
|
||||
const stagingRoot = stagingRootFor(configDir);
|
||||
|
||||
const store = new Map();
|
||||
const norm = (p) => path.normalize(String(p));
|
||||
store.set(norm(destDir), { type: 'dir' });
|
||||
store.set(norm(path.join(destDir, 'USER-PROFILE.md')), { type: 'file', content: 'x' });
|
||||
const fake = {
|
||||
existsSync: (p) => store.has(norm(p)),
|
||||
mkdirSync: (p) => { store.set(norm(p), { type: 'dir' }); },
|
||||
// #2875 defect fix: this fake previously omitted rmSync, so
|
||||
// stageUserArtifacts's entryDir-clearing step (installFs().rmSync)
|
||||
// fell through to REAL fs.rmSync (install-fs-adapter.cts's
|
||||
// PARTIAL-ADAPTER TRAP) — which is exactly what this test's own
|
||||
// poisoning below is supposed to catch, and did: `real fs.rmSync
|
||||
// touched under a fake adapter`. Implementing it here is the fix.
|
||||
rmSync: (p) => {
|
||||
const n = norm(p);
|
||||
store.delete(n);
|
||||
const prefix = n.endsWith(path.sep) ? n : n + path.sep;
|
||||
for (const k of [...store.keys()]) if (k.startsWith(prefix)) store.delete(k);
|
||||
},
|
||||
writeFileSync: (p, data) => { store.set(norm(p), { type: 'file', content: data }); },
|
||||
lstatSync: (p) => {
|
||||
const e = store.get(norm(p));
|
||||
if (!e) { const err = new Error('ENOENT'); err.code = 'ENOENT'; throw err; }
|
||||
return { isFile: () => e.type === 'file', isDirectory: () => e.type === 'dir', isSymbolicLink: () => false };
|
||||
},
|
||||
copyFileSync: (src, dest) => {
|
||||
const e = store.get(norm(src));
|
||||
store.set(norm(dest), { type: 'file', content: e ? e.content : '' });
|
||||
},
|
||||
};
|
||||
|
||||
// Poison every real fs method this call tree could touch — a gap here
|
||||
// fires as the poisoned method throwing, never a silent pass (Phase 5's
|
||||
// F2 pattern, install-fs-adapter.cts's own doc comment).
|
||||
// t.mock.method (no manual try/finally — CONTRIBUTING.md bans try/finally
|
||||
// in test bodies). Restored EXPLICITLY right after use, rather than left
|
||||
// to the mock tracker's own end-of-test auto-restore: this suite's
|
||||
// `t.after(() => cleanup(configDir))` above runs its real `fs.rmSync`
|
||||
// during teardown, and `t.after` hooks fire BEFORE the mock tracker's
|
||||
// auto-restore — an un-restored poisoned `rmSync` would make that
|
||||
// cleanup itself throw.
|
||||
const realFs = require('node:fs');
|
||||
const poisoned = ['existsSync', 'mkdirSync', 'writeFileSync', 'lstatSync', 'copyFileSync', 'symlinkSync', 'readlinkSync', 'rmSync'];
|
||||
const mocks = poisoned.map((m) => t.mock.method(realFs, m, () => { throw new Error(`real fs.${m} touched under a fake adapter`); }));
|
||||
const staged = withInstallFs(fake, () => stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRoot));
|
||||
for (const mockFn of mocks) mockFn.mock.restore();
|
||||
assert.deepEqual(staged.names, ['USER-PROFILE.md']);
|
||||
});
|
||||
|
||||
test('D4 (correctness, not error-handling): adapter throws mid-stage — the failure propagates', (t) => {
|
||||
const configDir = mktemp('gsd-uas-d4-cfg-');
|
||||
t.after(() => cleanup(configDir));
|
||||
const destDir = path.join(configDir, 'gsd-core');
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(destDir, 'USER-PROFILE.md'), 'must-survive');
|
||||
const stagingRoot = stagingRootFor(configDir);
|
||||
|
||||
const originalMkdirSync = fs.mkdirSync;
|
||||
// t.mock.method (no manual try/finally — CONTRIBUTING.md bans try/finally
|
||||
// in test bodies); restored explicitly right after use rather than left
|
||||
// to end-of-test auto-restore, matching D3's rationale above.
|
||||
const mkdirMock = t.mock.method(fs, 'mkdirSync', (p, ...rest) => {
|
||||
if (String(p).includes('.gsd-staging')) {
|
||||
throw Object.assign(new Error('simulated EACCES creating staging dir'), { code: 'EACCES' });
|
||||
}
|
||||
return originalMkdirSync.call(fs, p, ...rest);
|
||||
});
|
||||
assert.throws(
|
||||
() => stageUserArtifacts(destDir, ['USER-PROFILE.md'], stagingRoot),
|
||||
/EACCES|simulated/,
|
||||
'staging failure must propagate — a caller cannot proceed to wipe having staged nothing',
|
||||
);
|
||||
mkdirMock.mock.restore();
|
||||
// The source file is untouched — nothing wiped, because the throw
|
||||
// happened before any caller-side wipe could run.
|
||||
assert.equal(fs.readFileSync(path.join(destDir, 'USER-PROFILE.md'), 'utf8'), 'must-survive');
|
||||
});
|
||||
|
||||
test('D2 (regression): copyPreservingSymlink is unaffected for its existing ambient-fs migration caller', (t) => {
|
||||
const srcDir = mktemp('gsd-uas-d2-src-');
|
||||
const destDir = mktemp('gsd-uas-d2-dest-');
|
||||
t.after(() => { cleanup(srcDir); cleanup(destDir); });
|
||||
|
||||
const srcFile = path.join(srcDir, 'a.txt');
|
||||
fs.writeFileSync(srcFile, 'plain-file-content');
|
||||
const destFile = path.join(destDir, 'a.txt');
|
||||
// No withInstallFs wrap — ambient default resolves to real fs, exactly
|
||||
// the migration engine's own call shape.
|
||||
copyPreservingSymlink(srcFile, destFile);
|
||||
assert.equal(fs.readFileSync(destFile, 'utf8'), 'plain-file-content');
|
||||
|
||||
const linkSrc = path.join(srcDir, 'link.txt');
|
||||
fs.symlinkSync(srcFile, linkSrc);
|
||||
const linkDest = path.join(destDir, 'link.txt');
|
||||
copyPreservingSymlink(linkSrc, linkDest);
|
||||
assert.ok(fs.lstatSync(linkDest).isSymbolicLink());
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// E. parseOwnerPid — property-based (CLAUDE.md "Property-Based Testing":
|
||||
// parsers must carry at least one fast-check property test)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('user-artifact-staging: E. parseOwnerPid (property-based)', () => {
|
||||
test('E-P1: for any string, parseOwnerPid agrees with the documented contract — /^[1-9][0-9]*$/ AND Number.isSafeInteger, else null', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.string({ maxLength: 40 }), (s) => {
|
||||
const isPlainPositiveIntegerLiteral = /^[1-9][0-9]*$/.test(s);
|
||||
const n = Number(s);
|
||||
const expected = isPlainPositiveIntegerLiteral && Number.isSafeInteger(n) ? n : null;
|
||||
assert.equal(parseOwnerPid(s), expected);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('E-P2: every valid positive-integer pid string round-trips to the SAME number, never a string, never NaN', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.integer({ min: 1, max: Number.MAX_SAFE_INTEGER }), (n) => {
|
||||
const s = String(n);
|
||||
assert.equal(parseOwnerPid(s), n);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('E-P3: "0", any negative-integer string, and any non-string value always resolve to null (never a live-forever claim)', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.oneof(
|
||||
fc.constant('0'),
|
||||
fc.integer({ min: Number.MIN_SAFE_INTEGER, max: -1 }).map(String),
|
||||
fc.anything().filter((v) => typeof v !== 'string'),
|
||||
),
|
||||
(input) => {
|
||||
assert.equal(parseOwnerPid(input), null);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user