* fix(#3712): confine in-process installs to a sandboxed HOME
A runtime kind may declare a global `home` override resolved from os.homedir()
rather than from the caller's configDir — codex's skills kind (`home: ".agents"`,
ADR-1239 / #2088) is the only live case. Sandboxing configDir/targetDir does not
contain it, and assertDestWithinConfigHome cannot see the class: that gate
confines a destSubpath to whatever root it is handed, and here the root IS the
escaped home. So an in-process caller that forgot to sandbox HOME wrote to, and
pruned gsd-* entries from, the developer's REAL ~/.agents/skills.
tests/agent-descriptor-parity.install.test.cjs's K1 loop did exactly that: it
iterates every agents-kind runtime (codex included) with a sandboxed targetDir
and an un-sandboxed HOME. Reproduced against a canary home on next @ adb46cdd8 —
71 gsd-* skill dirs deleted, a foreign `cloudflare` skill surviving, suite still
exit 0. It is silent because the runtime's own config home is untouched, so the
manifest keeps reporting a healthy install.
FIVE writers resolve a kind `home` and then destroy under it. Three are reachable
today — installRuntimeArtifacts, uninstallRuntimeArtifacts (install-engine.cts)
and applySurface (surface.cts). Two are descriptor-dependent and guarded against a
future descriptor change rather than a present escape: installOpencodeFamilySkills
(behind the combined-family early return) and installAgentsKindStandalone. Those
two are scoped to the single kind each destroys — passing the whole layout made
codex's unrelated skills override trip a writer that never touches it.
- src/test-home-guard.cts: refuse when a run under a test runner cannot be shown
to have sandboxed HOME. NODE_TEST_CONTEXT (set by `node --test`) gates it, so
installs outside a Node test context are untouched; GSD_TEST_MODE is unusable,
as several candidate files including the offender never set it. Homes are
compared by FILESYSTEM IDENTITY (st_dev + st_ino), not by pathname:
path.resolve() resolves neither symlinks nor case, and realpath returns a
canonical pathname that two routes to one directory can still disagree on (bind
mounts). Verified on macOS/APFS — HOME=/users/<name> made the strings differ
while naming the same directory, and the lexical form ALLOWED a write into the
physical real home. FAILS CLOSED: a pair is "different" only when both identify,
or one is definitively absent (ENOENT/ENOTDIR) while the other identifies; every
other errno is "cannot tell" and refuses. Only when neither home identifies is a
marker consulted, and it carries the sandbox PATH and must equal the home in
effect — a boolean checked first let an ambient or stale value disarm the guard.
- helpers: promote sandboxHome() out of its two byte-identical private copies,
which is also what makes them record the sandbox; the three withFakeHome()
helpers record it too. The marker NAME is duplicated as a bare string rather
than required from the compiled guard, keeping helpers.cjs's documented
no-built-lib-at-import-time contract; a test pins the two together.
- agent-descriptor-parity: sandbox HOME across the K1 loop.
- helpers-process-isolation: #3156's canary asserts on <home>/.gsd only, and its
`--cursor --local` spawn cannot reach `.agents` at all, so an assertion added
there would pass with all confinement removed. Add a discriminating row — a
`--codex --global` spawn against a seeded ambient home — which also asserts the
runtime still declares the override. Its check is a sampled inventory (dir names
+ each SKILL.md), not a tree compare.
- install-write-confinement: predicate rows through the deps seam, covering the
symlinked HOME, ambient and stale markers, and each sameDirectory branch
(both-identify, one-absent, neither-identifiable), plus wiring rows that drive
the REAL entrypoints so deleting a guard call site is red.
Verified: guard fires end-to-end against a real un-sandboxed HOME (exit 1, zero
deletions); the case-variant fail-open reproduced on APFS before the fix and
refuses after; K1 file 29/29 green with skills intact; mutation-tested — each of
the three reachable call sites, lexical-only comparison, and treating an unknown
errno as "absent" each take exactly one row red, with every mutation echoed back;
the process-isolation row negative-controlled by reverting installerEnv to its
pre-#3156 leak (16/0 -> 13/3); a full npm test leaves ~/.agents/skills at 71.
Stated residual: the two descriptor-dependent writers have no wiring test, because
no runtime declares a `home` override on those kinds and neither can be exercised
without inventing a descriptor.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore(#3712): add changeset
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(#3712): let a sandbox nested inside the real home through the guard
All six Windows shards of #3725 failed on legitimately sandboxed
destinations. On Windows os.tmpdir() is %LOCALAPPDATA%\Temp — inside the
user's home — so every sandbox a test creates is a descendant of the real
home, and "does this land inside the real home?" answers yes for the safe
case and the dangerous one alike. POSIX conceals this: /tmp and
/var/folders both sit outside $HOME.
Add the missing conjunct: a destination inside the real home is allowed
only when it also sits beneath a HOME that was sandboxed away from the
passwd home. Both halves are required — dropping the first re-admits a
plain un-sandboxed install, and dropping the second decays into the
"is HOME sandboxed?" check the module rejects, which a layout resolved
before the sandbox walks straight through. Each is mutation-proven by a
row that goes red without it.
Also covers the two fail-closed branches of the new exemption, which
survived mutation to `true` with the suite green, and avoids `<user>` in
a docblock — the prompt-injection scanner reads it as a delimiter tag.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(#3712): close three writer/rollback gaps found reviewing the whole PR
Cross-AI review of the full PR (not just the round's delta) surfaced
three ways the guard could still be defeated:
- The nested-sandbox exemption trusted the SPELLING of a destination.
With HOME sandboxed to a directory inside the real home — legitimate on
Windows — an aliased `.agents` (symlink, junction, subordinate bind
mount) beneath it redirected an allowed path into the real home. Decide
containment on the path the write RESOLVES to: walk up to the nearest
existing ancestor, canonicalize, re-append the tail.
- `migrateLegacyDevPreferencesToSkill` is a SIXTH writer that resolves a
skills-kind `home` override. It creates rather than prunes, which is
why it was missed, and `_runLegacyInstallMigrations` runs it before
`installRuntimeArtifacts`' own assertion. Guarded, scoped to that kind.
- Worst of the three: `bin/install.js` snapshots the resolved skills root
before installing, and its outer catch rolls back by deleting and
recreating every snapshotted `gsd-*` directory there. The guard's own
throw landed in that catch, so refusing an un-sandboxed codex install
provoked exactly the mutation the guard exists to prevent. Refusals are
now marked and rethrown without rollback — nothing was written, so
there is no partial install to undo. Every other error still rolls back.
Also carries the sandbox marker into `installSpawnEnv`, so spawned
installers are not refused on passwd-less CI images, and corrects three
claims that no longer hold: "every writer" (six, and named), the
unconditional "fails CLOSED" (the passwd-less marker branch is a
deliberate weakening, and TOCTOU is out of scope), and the assertion that
Windows os.tmpdir() is always %LOCALAPPDATA%\Temp (Node honors TEMP/TMP).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(#3712): name the guard's two limits instead of overclaiming
Round-2 review found the prose had drifted ahead of the code. Corrected,
with no behavior change:
- The module still said FIVE writers; there are six, and the sixth is
now named along with why it was missed (it creates rather than prunes)
and why it carries its own assertion (it runs before the main one).
- The canonicalization docblock listed subordinate bind mounts among the
aliases it closes. It does not close them: a bind mount is not a link,
so realpath keeps the mount-point spelling. `sameDirectory` already
recorded that limit; the new helper now inherits it explicitly rather
than contradicting it. Closing it needs mount-table introspection.
- "FAILS CLOSED" was unqualified while the passwd-less marker branch is
a deliberate weakening — with no passwd entry, nothing can contradict a
marker naming the real home.
- "Refuses BEFORE any write" was too broad: legacy install migrations run
ahead of the layout-driven ones, which is exactly why the two
rollbackInstallerMigrations() calls still execute before the rethrow.
Only the codex skills-root rollback is skipped, and that is the only
_codexPreConfigRollback() call site — applySurface is never called from
bin/install.js and uninstall cannot reach it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(#3712): sandbox HOME in the opencode-family home-override parity rows
The last two Windows failures, and the same platform asymmetry in a
different disguise. This row drives a skills-kind `home` override on
purpose — precisely what the guard polices — but relied on the override
temp dir happening to sit outside the real home. It does on POSIX
(/tmp, /var/folders); on Windows os.tmpdir() is under %USERPROFILE%, so
the guard correctly refused and only Windows went red.
Declare the sandbox instead of depending on the platform: HOME becomes
the override itself, which is the home the call writes under. This is the
fix the guard's own message prescribes, applied to the test rather than
to the guard.
Both failing Windows shards fail on exactly these two rows and nothing
else; every other shard is green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(#3712): make sameDirectory answer NO when it cannot tell
Review Major 1. sameDirectory()'s only caller is the passwd-less marker
branch, which reads a `true` as permission to PROCEED:
if (marker && sameDirectory(marker, osMod.homedir())) return;
The fallthrough returned `true` whenever neither side identified — two
absent paths, or two stats failing EACCES/EPERM/EIO on a locked-down
host — on the reasoning that "cannot tell" should make the caller refuse.
That reasoning was inverted with respect to this caller: it turned the
passwd-less escape hatch into an unconditional bypass for any marker
value at all, on precisely the hosts the fallback exists to serve. Only
two things now answer yes: one resolved pathname, or two readable
identities that match. Restoring the old fallthrough takes the new row
red.
Also from review:
- Major 2 asked whether st_dev/st_ino discriminate directories on
Windows, where Node derives them from BY_HANDLE_FILE_INFORMATION. The
whole guard rests on that primitive, so assert it rather than argue it:
a row comparing two distinct temp directories, and one directory
reached by two spellings. It runs on every platform in the matrix, so
Windows answers the question itself.
- Minor 1: the refusal now names the real home it compared against, not
just the destination it refused. That is the one fact needed to tell a
true positive from a false one, and its absence is what made the
Windows case a CI-log dig rather than a glance.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(#3712): refuse a HOME that merely spells the real home more widely
Review round: one Blocker, four Minors, a Nit.
N2 (the one with teeth) — a destination's ancestor chain is linear, so
"inside the real home AND inside the effective HOME" admits two
arrangements, not one. The intended `effectiveHome ⊂ realHome` is the
Windows temp shape; `realHome ⊂ effectiveHome` — HOME at /Users, /home,
C:\Users — is not a sandbox at all, it is the real home reached by a
wider spelling, and it was exempting a stale destination pointing
straight at ~/.agents. Third conjunct added; the docblock no longer
claims two conditions suffice. Removing the conjunct reds the new row
and nothing else.
N4 — the migration guard resolved its OWN layout, and without
capabilityRegistry, so a registry-dependent descriptor could make it
vouch for a path the migration does not write: a guard reporting safe
while the unsafe write proceeds. It now guards the destination already
resolved by _resolveDevPreferencesSkillTarget, keyed on
`installRoot !== targetDir` — which is exactly the condition under which
a `home` override was declared, read off that same result.
N1 — CONTEXT.md gains the Test Home Guard Module glossary entry that
contributor-standards.md requires of a new Module. Not CI-enforced, so
green CI was never evidence it was met.
N3 — the docs/INVENTORY.md row was misfiled between install-fs-adapter
and install-model-override-resolver; the table is alphabetical and the
manifest already had it right. Moved, and its text now names six writers
and the third conjunct.
N5 — applySurface's signature docblock was two parameters stale; this PR
added the second of them.
N6 — the duplicated rollbackInstallerMigrations() adjacent to the new
rethrow: two identical consecutive calls, not two phases.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(#3712): guard the sixth writer, and close two false-ALLOW paths
Review round 3 (NEW-1, NEW-2) plus three defects Codex found in the
whole-PR pass, each reproduced before it was fixed.
NEW-1 — migrateLegacyDevPreferencesToSkill called the guard with no
`deps`, so it bound real os/process.env and could not be wiring-tested
the way the other three reachable writers were. It now takes the same
optional `deps: { os?, env? }` tail parameter. The wiring block gains
the missing fourth row, and a fifth pinning the ALLOW half; the test
file's header docblock said "FIVE writers ... the three reachable
today", contradicting the six/four statement this PR already put in
src/test-home-guard.cts, CONTEXT.md, docs/INVENTORY.md and the
changeset. Both directions of the guard's condition now fail a row
when broken — previously neither did.
NEW-2 — derivesFromSandboxedHome's docblock claimed "THREE conditions
are required, and no two of them suffice". False for {2,3}: isInside is
reflexive, so whenever conjunct 1 fires conjunct 2 already returns
false on its own. Reworded as a fast path, which is what it is.
Codex 1 (false ALLOW) — on a host with no readable passwd entry the
marker branch returned as soon as the marker matched the effective
HOME. That attests a caller sandboxed HOME and says nothing about where
an already-resolved destination points, so a layout captured before
sandboxHome() — still naming the real ~/.agents — was waved straight
through: the same stale-layout shape the primary branch refuses by
design. The marker must now identify AND contain every destination.
Codex 2 (false ALLOW) — `installRoot !== targetDir` was the stand-in
for "the skills kind declared a home override". The two are not
equivalent: the inequality is false when the override resolves onto
targetDir itself, which is exactly a configDir of $HOME/.agents. The
guard was skipped and SKILL.md written into the real home under a test
runner. _resolveDevPreferencesSkillTarget now reports hasHomeOverride
off the same resolution instead of inferring it from two paths.
Codex 3 (prose) — the shared refusal message claimed every guarded
writer prunes; the migrate writer only creates. The changeset headline
claimed in-process installer calls can no longer reach the real home,
which is wider than the guard: writeNonClaudeDefaults still writes
~/.gsd/defaults.json through os.homedir(). INVENTORY's and CONTEXT's
fail-closed sentences omitted sameDirectory's pathname-equality
shortcut. All four narrowed to what the code does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(#3712): let the sandbox marker follow an overridden HOME
Found by Codex in the whole-PR pass. installSpawnEnv spreads
`overrides` last so an explicit HOME wins — deliberate, and its
docblock tells callers needing per-spawn isolation to pass their own
{ HOME, USERPROFILE }. But the #3712 marker was set before that spread,
so such a caller got HOME=<theirs> and marker=<helper default>. On a
host with no readable passwd entry the guard compares the two and
refuses a legitimately sandboxed spawn — tests/install.test.cjs:7143
and install-shared.cjs's own runInstaller both take that path.
The marker is now derived from the final HOME unless the caller
supplied one explicitly. The contract test asserted HOME after an
override but not the marker, which is why it stayed green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(#3712): name the shipped guard condition, not the deleted one
Review round 4 of #3725. Two artifacts this PR adds still described
`target.installRoot !== targetDir` in the PRESENT tense as the live guard
condition on `migrateLegacyDevPreferencesToSkill`. The shipped condition is
`runtime && target.hasHomeOverride` (src/install-engine.cts:510).
This is not ordinary doc drift. The named condition is the exact false-ALLOW
the previous round closed: a `home` override resolving onto `targetDir` — a
configDir of `$HOME/.agents`, which is where codex's override points — makes
the inequality FALSE while the override is declared, so the guard was skipped.
A maintainer reading CONTEXT.md:290 as authoritative would believe the guard
still skips that case.
- tests/install-write-confinement.test.cjs — the ALLOW-half row's comment.
Its "teeth" rationale is unchanged and still correct as written.
- CONTEXT.md:290 — the Test Home Guard Module glossary entry, a documented
PR gate. Now states the condition and names the inequality only as what it
is NOT, with the reason.
The three surviving mentions of the inequality are all past-tense or negated
(src/install-engine.cts:448, :508 and the sibling test comment at :3698) and
are correct as they stand.
Verified: `npm run lint:ci` exit 0; full `npm test` 31327 tests / 31312 pass /
0 fail / 14 skipped, run with TMPDIR unset.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(#3712): canonicalization fails closed, matching identify's errno split
Codex full-PR review of #3725, run against the round-4 head.
`resolveThroughLinks` caught EVERY realpathSync error and fell back to
`path.resolve(dest)` — the lexical spelling. That inverts the function's own
purpose. An aliased `<sandbox>/.agents` that cannot be canonicalized keeps its
sandbox spelling, satisfies the nested-sandbox exemption at :227, and the write
is ALLOWED into the real home — the exact escape this walk exists to close. The
module documents that it fails CLOSED with ONE named exception (the marker
branch); this was a second, unnamed one.
Split by errno, and deliberately by the SAME split `identify` already draws
rather than a second policy in one module — both answer "does this path exist
as named?", so they must not disagree:
ENOENT / ENOTDIR -> walk up. The ordinary case: a fresh install resolves a
destination nothing has created yet, so realpath fails on the leaf and on
every not-yet-created ancestor. Refusing here rejects every install.
anything else (EACCES, EPERM, ELOOP, EIO) -> refuse. The component exists but
cannot be resolved, so the guard cannot tell where the write lands.
Three rows in the predicate block, beside the other aliasing rows:
- a symlink CYCLE in the destination path (ELOOP) -> REFUSE
- a destination that does not exist yet (ENOENT) -> ALLOW
- a component behind a regular file (ENOTDIR) -> ALLOW
Teeth checked against the artifact the test loads, not the source: reverting
the condition to the swallow-everything shape in the compiled
test-home-guard.cjs turns row 1 — and only row 1 — red. The ENOTDIR row caught
a stale build during development, which is the point of asserting on the
compiled file.
CONTEXT.md and the resolveThroughLinks docblock both record the new behaviour,
so this does not repeat the prose-vs-code drift the round-4 finding was about.
The changeset's existing scope sentence now bounds "six writers" to the
`installRuntimeArtifacts` call tree and names `cmdGenerateDevPreferences` —
which resolves the same codex `home` override through `getGlobalSkillsBase` and
writes SKILL.md beneath it unguarded. It has no in-process caller today (its
only direct require-and-call is a spawnSync with HOME sandboxed), so it is
latent rather than live, and whether it belongs in this PR is raised with the
maintainer rather than decided here.
Verified: `npm run lint:ci` exit 0; full `npm test` 31330 tests / 31315 pass /
0 fail / 14 skipped, TMPDIR unset.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1320 lines
61 KiB
JavaScript
1320 lines
61 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Executed-plan return + fs adapter seam — failing-first tests.
|
|
*
|
|
* #2874 (epic #2866 Phase 5), governed by ADR-58
|
|
* (docs/adr/58-runtime-install-policy-module.md).
|
|
*
|
|
* Design: .gsd/phase/feat-2874-executed-plan-return/40-design.md
|
|
* Test matrix: .gsd/phase/feat-2874-executed-plan-return/50-test-matrix.md
|
|
*
|
|
* This file implements the Red-first order's rows 1-3 from 50-test-matrix.md:
|
|
* - E3 (section E, "Executed-plan return shape"): the opencode-family
|
|
* early return must ALSO return an executed plan, not `undefined`.
|
|
* - E13 (section E): every runtime in the capability registry must return
|
|
* something other than `undefined` — the completeness sweep proving the
|
|
* contract has no per-runtime holes.
|
|
* - F2 (section F, "Fs adapter seam"): a full install driven by an
|
|
* injected fake adapter must touch zero real filesystem paths.
|
|
*
|
|
* All three are RED against the current tree: `installRuntimeArtifacts`
|
|
* (src/install-engine.cts:750) still returns `void` and accepts no `deps`/
|
|
* adapter parameter to route IO through. No production code is touched here
|
|
* — this package is tests only.
|
|
*/
|
|
|
|
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 crypto = require('node:crypto');
|
|
|
|
const fc = require('fast-check');
|
|
|
|
const { createTempDir, cleanup } = require('./helpers.cjs');
|
|
|
|
const { installRuntimeArtifacts, hasExistingSymlinkBetween } = require('../gsd-core/bin/lib/install-engine.cjs');
|
|
const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
|
|
const { loadSkillsManifest, resolveProfile } = require('../gsd-core/bin/lib/install-profiles.cjs');
|
|
const runtimeArtifactLayout = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs');
|
|
const runtimeArtifactInstallPlan = require('../gsd-core/bin/lib/runtime-artifact-install-plan.cjs');
|
|
const { withInstallFs } = require('../gsd-core/bin/lib/install-fs-adapter.cjs');
|
|
const commandRoster = require('../gsd-core/bin/lib/command-roster.cjs');
|
|
const slashCommandTransformer = require('../scripts/fix-slash-commands.cjs');
|
|
|
|
const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
|
|
const MANIFEST = loadSkillsManifest(REAL_COMMANDS_DIR);
|
|
const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST });
|
|
const RESOLVED_FULL = resolveProfile({ modes: ['full'], manifest: MANIFEST });
|
|
const TEST_ATTRIBUTION = () => 'Co-Authored-By: Test <t@example.com>';
|
|
|
|
/**
|
|
* Sandbox HOME/USERPROFILE for the duration of a test. Some runtimes (e.g.
|
|
* codex) resolve a kind's `home` via os.homedir(); without this, an in-process
|
|
* install would write into the developer's real home directory.
|
|
*
|
|
* #3712: promoted to tests/helpers.cjs, from the byte-identical copy that used
|
|
* to live here. It now also sets the sandbox marker src/test-home-guard.cts
|
|
* needs to stay permissive on hosts with no readable passwd entry.
|
|
*/
|
|
const { sandboxHome } = require('./helpers.cjs');
|
|
|
|
// ─── E3 — the opencode-family early return (matrix row E3) ──────────────────
|
|
|
|
describe('installRuntimeArtifacts — E3: opencode-family early return', () => {
|
|
test('family install still returns a plan', (t) => {
|
|
const configDir = createTempDir('gsd-e3-opencode-');
|
|
t.after(() => cleanup(configDir));
|
|
sandboxHome(t, configDir);
|
|
|
|
const result = installRuntimeArtifacts('opencode', configDir, 'global', RESOLVED_CORE);
|
|
|
|
assert.notStrictEqual(
|
|
result,
|
|
undefined,
|
|
'E3: the combinedFamilyInstall early return (install-engine.cts:774) must return an ' +
|
|
'executed plan, not undefined — a whole runtime family returning undefined is a hole ' +
|
|
'in the contract, not an exemption (40-design.md behavior table row 2)',
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── E13 — the all-runtimes sweep (matrix row E13) ───────────────────────────
|
|
|
|
describe('installRuntimeArtifacts — E13: no runtime returns undefined', () => {
|
|
const RUNTIMES = Object.keys(registry.runtimes);
|
|
|
|
test('registry enumerates at least one runtime to sweep', () => {
|
|
assert.ok(RUNTIMES.length > 0, 'capability-registry.cjs runtimes must be non-empty');
|
|
});
|
|
|
|
for (const runtime of RUNTIMES) {
|
|
test(`${runtime}: installRuntimeArtifacts does not return undefined`, (t) => {
|
|
const configDir = createTempDir(`gsd-e13-${runtime}-`);
|
|
t.after(() => cleanup(configDir));
|
|
sandboxHome(t, configDir);
|
|
|
|
const result = installRuntimeArtifacts(runtime, configDir, 'global', RESOLVED_CORE);
|
|
|
|
assert.notStrictEqual(
|
|
result,
|
|
undefined,
|
|
`E13: ${runtime} returned undefined — every runtime in the registry must return an ` +
|
|
'executed plan (40-design.md: "Legitimate undefined returns: none after this phase. ' +
|
|
'If any path can still return undefined, that path is a defect, not an exemption.")',
|
|
);
|
|
});
|
|
}
|
|
});
|
|
|
|
// ─── F2 — zero real filesystem contact (matrix row F2) ───────────────────────
|
|
|
|
// The full write+read surface installRuntimeArtifacts's call tree is known to
|
|
// reach once every gap named in the #2874 follow-up round is closed: the
|
|
// direct mkdirSync/existsSync/rmSync calls, _copyStaged's readdirSync/cpSync/
|
|
// copyFileSync/mkdirSync, _removeGsdEntries's directory scan+delete,
|
|
// _snapshotDir/_restoreDir's read/write of preserved skill dirs, the
|
|
// symlink-escape guard's lstatSync/realpathSync probes, commonjs-marker.cts's
|
|
// lstatSync/writeFileSync/unlinkSync, and installer-migrations.cts's
|
|
// existsSync/readFileSync/openSync/readSync/closeSync (readInstallManifest,
|
|
// classifyArtifact, sha256File — sha256File streams via openSync/readSync/
|
|
// closeSync, restored after a brief round-trip through readFileSync broke
|
|
// tests/installer-migrations.test.cjs's large-file-streaming contract; the
|
|
// fake below implements all three against its store so a fake-adapter
|
|
// install still never touches real fs for hashing).
|
|
//
|
|
// mkdtempSync stays poisoned as a genuine tripwire, not a reachable case:
|
|
// mkInstallTempDir (install-fs-adapter.cts) only ever calls real
|
|
// `fs.mkdtempSync` when `current === REAL_ADAPTER` (no adapter injected at
|
|
// all) — a fake-adapter call always makes `current` a distinct merged
|
|
// object, so it takes the synthesize-name-and-mkdirSync branch instead and
|
|
// never reaches this poison. If this ever fires, `current`'s identity check
|
|
// broke, not a documented gap.
|
|
//
|
|
// One exception this poison list does NOT cover: readGsdCommandNames
|
|
// (command-roster.cts) reads the PACKAGE'S OWN commands/gsd/ source tree via
|
|
// real fs.readdirSync — deliberately unrouted (see install-fs-adapter.cts's
|
|
// module doc, "DELIBERATELY NOT ROUTED"). `poisonRealFsAgainstDestination`
|
|
// below allows real calls scoped to that known package-source root and
|
|
// poisons everything else, rather than poisoning every real fs call
|
|
// wholesale regardless of path.
|
|
const REAL_FS_WRITE_SURFACE = [
|
|
'mkdirSync', 'existsSync', 'rmSync', 'readdirSync',
|
|
'cpSync', 'copyFileSync', 'readFileSync', 'writeFileSync', 'lstatSync',
|
|
'realpathSync', 'unlinkSync', 'rmdirSync',
|
|
'mkdtempSync', 'openSync', 'readSync', 'closeSync',
|
|
];
|
|
|
|
// Package-source roots a correct install is expected to read for real, even
|
|
// while a fake DESTINATION adapter is injected (40-design.md "Known limits":
|
|
// this seam makes destination IO fake-able; package-source IO stays real by
|
|
// design). Mirrors findInstallSourceRoot's/findAgentsSourceRoot's/
|
|
// readGsdCommandNames's own targets (commands/gsd/, agents/), all resolved
|
|
// the same way REAL_COMMANDS_DIR is above.
|
|
const PACKAGE_SOURCE_ROOTS = [REAL_COMMANDS_DIR, path.join(__dirname, '..', 'agents')];
|
|
|
|
function isPackageSourcePath(resolvedPath) {
|
|
return PACKAGE_SOURCE_ROOTS.some(
|
|
(root) => resolvedPath === root || resolvedPath.startsWith(root + path.sep),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* F2's real-fs poisoning, derived from the rule (40-design.md "Known
|
|
* limits"/install-fs-adapter.cts's module doc) rather than aligned with it by
|
|
* coincidence: a real fs call against the install DESTINATION is a failure —
|
|
* the seam exists precisely so a fake adapter can intercept those — but a
|
|
* real call against the package's OWN source tree (commands/gsd/, agents/)
|
|
* is expected and allowed, because that read is deliberately unrouted
|
|
* (readGsdCommandNames et al.). Poisoning every real fs method wholesale,
|
|
* regardless of path, makes a correct install fail for the wrong reason.
|
|
*
|
|
* Returns a Map<method, count> of package-source hits, so a caller can
|
|
* assert POSITIVELY that the expected package-source read actually happened
|
|
* — proving the boundary was exercised, not merely tolerated.
|
|
*/
|
|
function poisonRealFsAgainstDestination(t, label) {
|
|
const packageSourceHits = new Map();
|
|
for (const method of REAL_FS_WRITE_SURFACE) {
|
|
const original = fs[method].bind(fs);
|
|
t.mock.method(fs, method, (...args) => {
|
|
const target = args[0];
|
|
const resolved = (typeof target === 'string' || target instanceof URL || Buffer.isBuffer(target))
|
|
? path.resolve(String(target))
|
|
: null;
|
|
if (resolved !== null && isPackageSourcePath(resolved)) {
|
|
packageSourceHits.set(method, (packageSourceHits.get(method) ?? 0) + 1);
|
|
return original(...args);
|
|
}
|
|
throw new Error(
|
|
`F2${label}: real fs.${method}() was reached against a non-package-source path ` +
|
|
`(${resolved ?? String(target)}) during an install driven by an injected fake adapter`,
|
|
);
|
|
});
|
|
}
|
|
return packageSourceHits;
|
|
}
|
|
|
|
/**
|
|
* A genuinely functional in-memory filesystem, not a set of no-op stubs —
|
|
* required to drive the branches F2 now exercises (opencode-family legacy-dir
|
|
* migration, a nativePlugin runtime, a retiredArtifacts runtime) far enough
|
|
* to reach commonjs-marker.cts and installer-migrations.cts's routed
|
|
* classifyArtifact/readInstallManifest, not just the happy path's first
|
|
* existsSync check. Every method operates against one flat `Map<absPath,
|
|
* entry>` store; `readdirSync` derives listings by prefix-scanning the same
|
|
* store (an entry that readdirSync reports a directory contains is, by
|
|
* construction, also existsSync-true at that exact path — same invariant a
|
|
* real filesystem holds).
|
|
*
|
|
* @param seed - Array<[absPath, {type:'file'|'dir', content?:string|Buffer}]>
|
|
* pre-populated entries.
|
|
*/
|
|
function createFakeInstallFs(seed = []) {
|
|
const store = new Map();
|
|
for (const [p, entry] of seed) store.set(path.normalize(String(p)), entry);
|
|
const fdTable = new Map();
|
|
let nextFd = 1;
|
|
|
|
const norm = (p) => path.normalize(String(p));
|
|
const childPrefix = (dir) => {
|
|
const n = norm(dir);
|
|
return n.endsWith(path.sep) ? n : n + path.sep;
|
|
};
|
|
const enoent = (p) => {
|
|
const err = new Error(`ENOENT: no such file or directory, '${p}'`);
|
|
err.code = 'ENOENT';
|
|
return err;
|
|
};
|
|
|
|
const fakeFs = {
|
|
existsSync: (p) => store.has(norm(p)),
|
|
lstatSync: (p) => {
|
|
const e = store.get(norm(p));
|
|
if (!e) throw enoent(p);
|
|
return {
|
|
isFile: () => e.type === 'file',
|
|
isDirectory: () => e.type === 'dir',
|
|
isSymbolicLink: () => e.type === 'symlink',
|
|
};
|
|
},
|
|
mkdirSync: (p) => { store.set(norm(p), { type: 'dir' }); return undefined; },
|
|
rmSync: (p) => {
|
|
const n = norm(p);
|
|
store.delete(n);
|
|
const prefix = childPrefix(n);
|
|
for (const k of [...store.keys()]) if (k.startsWith(prefix)) store.delete(k);
|
|
},
|
|
unlinkSync: (p) => {
|
|
const n = norm(p);
|
|
if (!store.has(n)) throw enoent(p);
|
|
store.delete(n);
|
|
},
|
|
rmdirSync: (p) => { store.delete(norm(p)); },
|
|
readdirSync: (p, opts) => {
|
|
const prefix = childPrefix(p);
|
|
const names = new Set();
|
|
for (const k of store.keys()) {
|
|
if (!k.startsWith(prefix)) continue;
|
|
const rest = k.slice(prefix.length);
|
|
const sepIdx = rest.indexOf(path.sep);
|
|
const name = sepIdx === -1 ? rest : rest.slice(0, sepIdx);
|
|
if (name) names.add(name);
|
|
}
|
|
const arr = [...names];
|
|
if (opts && opts.withFileTypes) {
|
|
return arr.map((name) => {
|
|
const full = norm(path.join(String(p), name));
|
|
const e = store.get(full);
|
|
return {
|
|
name,
|
|
isFile: () => (e ? e.type === 'file' : false),
|
|
isDirectory: () => (e ? e.type === 'dir' : true),
|
|
};
|
|
});
|
|
}
|
|
return arr;
|
|
},
|
|
readFileSync: (p, encoding) => {
|
|
const e = store.get(norm(p));
|
|
if (!e || e.type !== 'file') throw enoent(p);
|
|
const buf = Buffer.isBuffer(e.content) ? e.content : Buffer.from(e.content ?? '', 'utf8');
|
|
return encoding ? buf.toString(encoding) : buf;
|
|
},
|
|
// sha256File (installer-migrations.cts) streams via openSync/readSync/
|
|
// closeSync instead of readFileSync (large-file hashing must not buffer
|
|
// the whole file — tests/installer-migrations.test.cjs pins this). fdTable
|
|
// maps a synthetic fd to {buf, pos} so this fake never needs a real fd.
|
|
openSync: (p) => {
|
|
const e = store.get(norm(p));
|
|
if (!e || e.type !== 'file') throw enoent(p);
|
|
const buf = Buffer.isBuffer(e.content) ? e.content : Buffer.from(e.content ?? '', 'utf8');
|
|
const fd = nextFd++;
|
|
fdTable.set(fd, { buf, pos: 0 });
|
|
return fd;
|
|
},
|
|
readSync: (fd, buffer, offset, length, position) => {
|
|
const entry = fdTable.get(fd);
|
|
if (!entry) {
|
|
const err = new Error(`EBADF: bad file descriptor, read (fake fd ${fd})`);
|
|
err.code = 'EBADF';
|
|
throw err;
|
|
}
|
|
const readAt = position === null || position === undefined ? entry.pos : position;
|
|
const bytesToRead = Math.max(0, Math.min(length, entry.buf.length - readAt));
|
|
entry.buf.copy(buffer, offset, readAt, readAt + bytesToRead);
|
|
if (position === null || position === undefined) entry.pos += bytesToRead;
|
|
return bytesToRead;
|
|
},
|
|
closeSync: (fd) => { fdTable.delete(fd); },
|
|
writeFileSync: (p, data, opts) => {
|
|
// Emulate `{ flag: 'wx' }` (exclusive create): REAL_ADAPTER.writeFileSync
|
|
// (install-fs-adapter.cts:138) passes `opts` straight through to real
|
|
// `fs.writeFileSync`, which throws EEXIST for `wx` against an existing
|
|
// path. A fake that silently overwrote here would certify something
|
|
// the real implementation refuses — see commonjs-marker.cts's
|
|
// `ensureCommonJsMarker`, which relies on `wx` to close the
|
|
// classify-then-write gap.
|
|
const flag = typeof opts === 'object' && opts !== null ? opts.flag : undefined;
|
|
const n = norm(p);
|
|
if (flag === 'wx' && store.has(n)) {
|
|
const err = new Error(`EEXIST: file already exists, open '${p}'`);
|
|
err.code = 'EEXIST';
|
|
throw err;
|
|
}
|
|
store.set(n, { type: 'file', content: data });
|
|
},
|
|
copyFileSync: (src, dest) => {
|
|
const e = store.get(norm(src));
|
|
store.set(norm(dest), { type: 'file', content: e ? e.content : Buffer.alloc(0) });
|
|
},
|
|
cpSync: (src, dest) => {
|
|
const sn = norm(src);
|
|
const dn = norm(dest);
|
|
const e = store.get(sn);
|
|
if (e) store.set(dn, { ...e });
|
|
const prefix = childPrefix(sn);
|
|
for (const [k, v] of [...store.entries()]) {
|
|
if (k.startsWith(prefix)) store.set(dn + k.slice(sn.length), { ...v });
|
|
}
|
|
},
|
|
realpathSync: (p) => norm(p),
|
|
};
|
|
fakeFs._store = store;
|
|
return fakeFs;
|
|
}
|
|
|
|
// ─── createFakeInstallFs — wx exclusive-create emulation ────────────────────
|
|
//
|
|
// REAL_ADAPTER.writeFileSync (install-fs-adapter.cts:138) passes `opts`
|
|
// through untouched to real fs.writeFileSync, so `{ flag: 'wx' }` throws
|
|
// EEXIST against an existing target (commonjs-marker.cts's
|
|
// ensureCommonJsMarker relies on exactly this to close the
|
|
// classify-then-write TOCTOU gap). A fake that ignored `opts` would silently
|
|
// overwrite where the real adapter refuses — this covers the emulation
|
|
// itself rather than assuming it.
|
|
describe('createFakeInstallFs — wx exclusive-create emulation', () => {
|
|
test('refuses an exclusive create against an existing path (EEXIST)', () => {
|
|
const target = path.join(os.tmpdir(), 'gsd-fake-wx-existing.txt');
|
|
const fakeFs = createFakeInstallFs([[target, { type: 'file', content: 'original' }]]);
|
|
|
|
assert.throws(
|
|
() => fakeFs.writeFileSync(target, 'clobber', { flag: 'wx' }),
|
|
(err) => err.code === 'EEXIST',
|
|
'wx write against an existing fake-store path must throw EEXIST, matching real fs.writeFileSync',
|
|
);
|
|
assert.strictEqual(
|
|
fakeFs.readFileSync(target, 'utf8'),
|
|
'original',
|
|
'a refused wx write must leave the existing content untouched',
|
|
);
|
|
});
|
|
|
|
test('allows an exclusive create against an absent path', () => {
|
|
const target = path.join(os.tmpdir(), 'gsd-fake-wx-absent.txt');
|
|
const fakeFs = createFakeInstallFs();
|
|
|
|
fakeFs.writeFileSync(target, 'created', { flag: 'wx' });
|
|
|
|
assert.strictEqual(fakeFs.readFileSync(target, 'utf8'), 'created');
|
|
});
|
|
});
|
|
|
|
/** sha256 hex digest matching installer-migrations.cts's sha256File — used to
|
|
* seed a manifest entry that classifies a fake file as 'managed-pristine'. */
|
|
function sha256Hex(content) {
|
|
return crypto.createHash('sha256').update(content).digest('hex');
|
|
}
|
|
|
|
describe('installRuntimeArtifacts — F2: fake-adapter install touches no real filesystem', () => {
|
|
test('fake-adapter install touches no real filesystem (claude, skills-only)', (t) => {
|
|
// Every real fs method this call tree could reach is poisoned BY PATH
|
|
// (see poisonRealFsAgainstDestination) for the duration of this test via
|
|
// node:test's mock tracker (auto-restored when the test ends — no
|
|
// try/finally in the test body, per CONTRIBUTING.md's "Never use
|
|
// try/finally inside test bodies").
|
|
const packageSourceHits = poisonRealFsAgainstDestination(t, '');
|
|
|
|
const fakeFs = createFakeInstallFs();
|
|
|
|
// configDir deliberately never created for real — F2 asserts nothing
|
|
// real ever gets written under it.
|
|
const configDir = path.join(os.tmpdir(), `gsd-f2-must-not-exist-${crypto.randomUUID()}`);
|
|
|
|
const result = installRuntimeArtifacts(
|
|
'claude', configDir, 'global', RESOLVED_CORE, undefined, undefined,
|
|
{ fs: fakeFs },
|
|
);
|
|
|
|
assert.notStrictEqual(
|
|
result,
|
|
undefined,
|
|
'F2: a fake-adapter install must still return an executed plan (matrix row F1/E1 shape)',
|
|
);
|
|
// No post-hoc fs.existsSync(configDir) check follows: fs.existsSync is
|
|
// one of the poisoned (non-package-source) methods above for the
|
|
// duration of this test, so the proof of "zero real DESTINATION fs
|
|
// contact" IS that installRuntimeArtifacts returned at all without
|
|
// tripping one of the throws — not a probe that would itself have to
|
|
// touch the poisoned surface.
|
|
assert.ok(
|
|
(packageSourceHits.get('readdirSync') ?? 0) > 0,
|
|
'F2: readGsdCommandNames must have read the real, unrouted commands/gsd/ package-source ' +
|
|
'tree at least once — proving the poison boundary was exercised, not merely tolerated',
|
|
);
|
|
});
|
|
|
|
test('fake-adapter install touches no real filesystem (opencode-family legacy command/ dir migration)', (t) => {
|
|
poisonRealFsAgainstDestination(t, ' (opencode legacy migration)');
|
|
|
|
const configDir = path.join(os.tmpdir(), `gsd-f2-opencode-legacy-${crypto.randomUUID()}`);
|
|
const legacyDir = path.join(configDir, 'command');
|
|
const legacyFile = path.join(legacyDir, 'gsd-old-cmd.md');
|
|
const content = '# stale legacy command\n';
|
|
const manifestPath = path.join(configDir, 'gsd-file-manifest.json');
|
|
const manifestJson = JSON.stringify({ files: { 'command/gsd-old-cmd.md': sha256Hex(content) } });
|
|
|
|
const fakeFs = createFakeInstallFs([
|
|
[configDir, { type: 'dir' }],
|
|
[legacyDir, { type: 'dir' }],
|
|
[legacyFile, { type: 'file', content }],
|
|
[manifestPath, { type: 'file', content: manifestJson }],
|
|
]);
|
|
|
|
const result = installRuntimeArtifacts(
|
|
'opencode', configDir, 'global', RESOLVED_CORE, undefined, undefined,
|
|
{ fs: fakeFs },
|
|
);
|
|
|
|
assert.notStrictEqual(result, undefined, 'F2 (opencode legacy migration): must still return a plan');
|
|
// The manifest hash matches the seeded content exactly, so
|
|
// _migrateLegacyOpencodeCommandDir's classifyArtifact call must have
|
|
// classified it 'managed-pristine' and unlinked it (real
|
|
// installerMigrations.readInstallManifest/classifyArtifact/sha256File —
|
|
// all routed through installFs() — computed this via the fake, not real
|
|
// fs, or the poisoned methods above would have thrown first).
|
|
assert.strictEqual(
|
|
fakeFs._store.has(path.normalize(legacyFile)),
|
|
false,
|
|
'F2 (opencode legacy migration): the managed-pristine legacy file must have been removed via the fake store',
|
|
);
|
|
});
|
|
|
|
test('fake-adapter install touches no real filesystem (nativePlugin runtime: pi)', (t) => {
|
|
poisonRealFsAgainstDestination(t, ' (nativePlugin)');
|
|
|
|
// Resolve the SAME pluginSrc path _installNativePluginIfDeclared
|
|
// (install-engine.cts) computes for pi's declared nativePlugin, using the
|
|
// real (unrouted, package-own-source) findInstallSourceRoot — this read
|
|
// happens BEFORE the poison mocks above are installed... no: it must
|
|
// happen before `t.mock.method` calls would matter for IT, but
|
|
// findInstallSourceRoot's own walk uses `fs.statSync`, which is NOT on
|
|
// the poisoned list (see install-fs-adapter.cts's module doc — it is
|
|
// deliberately unrouted, real-fs-only, package-source introspection), so
|
|
// resolving this here is safe even after poisoning existsSync et al.
|
|
const commandsGsdDir = runtimeArtifactLayout.findInstallSourceRoot();
|
|
const repoRoot = path.dirname(path.dirname(commandsGsdDir));
|
|
const nativePlugin = registry.runtimes.pi.runtime.hostBehaviors.nativePlugin;
|
|
assert.ok(nativePlugin && nativePlugin.source, 'pi must declare hostBehaviors.nativePlugin.source (registry drifted)');
|
|
const pluginSrc = path.join(repoRoot, nativePlugin.source);
|
|
|
|
const configDir = path.join(os.tmpdir(), `gsd-f2-pi-nativeplugin-${crypto.randomUUID()}`);
|
|
const fakeFs = createFakeInstallFs([
|
|
[pluginSrc, { type: 'file', content: '// fake plugin adapter\n' }],
|
|
]);
|
|
|
|
const result = installRuntimeArtifacts(
|
|
'pi', configDir, 'global', RESOLVED_CORE, undefined, undefined,
|
|
{ fs: fakeFs },
|
|
);
|
|
|
|
assert.notStrictEqual(result, undefined, 'F2 (nativePlugin): must still return a plan');
|
|
assert.strictEqual(result.postSteps.nativePlugin, true, 'F2 (nativePlugin): postSteps.nativePlugin must be true for pi');
|
|
const destPath = path.join(configDir, nativePlugin.dir, nativePlugin.file);
|
|
assert.strictEqual(
|
|
fakeFs._store.has(path.normalize(destPath)),
|
|
true,
|
|
'F2 (nativePlugin): the plugin file must have been copied via the fake store (copyFileSync routed)',
|
|
);
|
|
const markerPath = path.join(configDir, nativePlugin.dir, 'package.json');
|
|
assert.strictEqual(
|
|
fakeFs._store.has(path.normalize(markerPath)),
|
|
true,
|
|
'F2 (nativePlugin): ensureCommonJsMarker (commonjs-marker.cts) must have written the CommonJS marker via the fake store',
|
|
);
|
|
});
|
|
|
|
test('fake-adapter install touches no real filesystem (retiredArtifacts runtime: cursor)', (t) => {
|
|
const packageSourceHits = poisonRealFsAgainstDestination(t, ' (retiredArtifacts)');
|
|
|
|
const retired = registry.runtimes.cursor.runtime.hostBehaviors.retiredArtifacts;
|
|
assert.ok(Array.isArray(retired) && retired.length > 0, 'cursor must declare hostBehaviors.retiredArtifacts (registry drifted)');
|
|
const { destSubpath, prefix, suffix } = retired[0];
|
|
|
|
const configDir = path.join(os.tmpdir(), `gsd-f2-cursor-retired-${crypto.randomUUID()}`);
|
|
const destDir = path.resolve(configDir, destSubpath);
|
|
const staleName = `${prefix}retired-probe${suffix}`;
|
|
const staleFile = path.join(destDir, staleName);
|
|
const content = '# stale retired artifact\n';
|
|
const relPath = `${destSubpath.replace(/\\/g, '/')}/${staleName}`;
|
|
const manifestPath = path.join(configDir, 'gsd-file-manifest.json');
|
|
const manifestJson = JSON.stringify({ files: { [relPath]: sha256Hex(content) } });
|
|
|
|
const fakeFs = createFakeInstallFs([
|
|
[configDir, { type: 'dir' }],
|
|
[destDir, { type: 'dir' }],
|
|
[staleFile, { type: 'file', content }],
|
|
[manifestPath, { type: 'file', content: manifestJson }],
|
|
]);
|
|
|
|
const result = installRuntimeArtifacts(
|
|
'cursor', configDir, 'global', RESOLVED_CORE, undefined, undefined,
|
|
{ fs: fakeFs },
|
|
);
|
|
|
|
assert.notStrictEqual(result, undefined, 'F2 (retiredArtifacts): must still return a plan');
|
|
// manifest hash matches the seeded content exactly -> classifyArtifact
|
|
// must classify 'managed-pristine' -> pruneRetiredRuntimeArtifacts
|
|
// (retired-artifact-cleanup.cts, routed) unlinks it via the fake store.
|
|
assert.strictEqual(
|
|
fakeFs._store.has(path.normalize(staleFile)),
|
|
false,
|
|
'F2 (retiredArtifacts): the managed-pristine retired artifact must have been removed via the fake store',
|
|
);
|
|
assert.ok(
|
|
(packageSourceHits.get('readdirSync') ?? 0) > 0,
|
|
'F2 (retiredArtifacts): readGsdCommandNames must have read the real, unrouted commands/gsd/ ' +
|
|
'package-source tree at least once — proving the poison boundary was exercised, not merely tolerated',
|
|
);
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// #2874 follow-up round — 50-test-matrix.md rows E1/E2/E4-E12, F4-F6, G2,
|
|
// H1-H5, I1-I5, K3, L1-L2. Extends the F2/E3/E13 coverage above rather than a
|
|
// new file (install's file-count prefix is grandfathered at 8, must not grow).
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
// ─── E. Executed-plan return shape (E1, E2, E4-E12) ──────────────────────────
|
|
|
|
describe('installRuntimeArtifacts — E1: claude global, normal install', () => {
|
|
test('returns an executed plan for a normal install', (t) => {
|
|
const configDir = createTempDir('gsd-e1-claude-global-');
|
|
t.after(() => cleanup(configDir));
|
|
sandboxHome(t, configDir);
|
|
|
|
const result = installRuntimeArtifacts('claude', configDir, 'global', RESOLVED_CORE);
|
|
|
|
assert.ok(Array.isArray(result.kinds) && result.kinds.length > 0, 'E1: plan must name at least one kind');
|
|
for (const k of result.kinds) {
|
|
assert.strictEqual(typeof k.kind, 'string', 'E1: every kind entry must name its kind');
|
|
assert.strictEqual(typeof k.sourceDir, 'string', 'E1: every kind entry must name its sourceDir');
|
|
assert.strictEqual(typeof k.destDir, 'string', 'E1: every kind entry must name its destDir');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('installRuntimeArtifacts — E2: claude local', () => {
|
|
test('executed plan records the scope', (t) => {
|
|
const configDir = createTempDir('gsd-e2-claude-local-');
|
|
t.after(() => cleanup(configDir));
|
|
sandboxHome(t, configDir);
|
|
|
|
const result = installRuntimeArtifacts('claude', configDir, 'local', RESOLVED_CORE);
|
|
|
|
assert.strictEqual(result.scope, 'local', 'E2: local scope must be reflected verbatim on the returned plan');
|
|
});
|
|
});
|
|
|
|
describe('installRuntimeArtifacts — E4: kilo (second family member)', () => {
|
|
test('kilo family install still returns a plan', (t) => {
|
|
const configDir = createTempDir('gsd-e4-kilo-');
|
|
t.after(() => cleanup(configDir));
|
|
sandboxHome(t, configDir);
|
|
|
|
const result = installRuntimeArtifacts('kilo', configDir, 'global', RESOLVED_CORE);
|
|
|
|
assert.notStrictEqual(
|
|
result, undefined,
|
|
'E4: kilo, the SECOND combined-family runtime, must ALSO return a plan — E3 is not a one-runtime special case',
|
|
);
|
|
// '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']);
|
|
});
|
|
});
|
|
|
|
describe('installRuntimeArtifacts — E5/E7: empty layout + nativePlugin post-step (pi)', () => {
|
|
test('empty layout returns an empty plan', (t) => {
|
|
const configDir = createTempDir('gsd-e5-pi-');
|
|
t.after(() => cleanup(configDir));
|
|
sandboxHome(t, configDir);
|
|
|
|
const result = installRuntimeArtifacts('pi', configDir, 'global', RESOLVED_CORE);
|
|
|
|
assert.ok(Array.isArray(result.kinds), 'E5: kinds must be an array even when layout.kinds is empty');
|
|
assert.strictEqual(result.kinds.length, 0, 'E5: pi declares an empty artifactLayout — kinds must be [], never undefined');
|
|
});
|
|
|
|
test('native plugin post-step is recorded', (t) => {
|
|
const configDir = createTempDir('gsd-e7-pi-');
|
|
t.after(() => cleanup(configDir));
|
|
sandboxHome(t, configDir);
|
|
|
|
const result = installRuntimeArtifacts('pi', configDir, 'global', RESOLVED_CORE);
|
|
|
|
assert.strictEqual(
|
|
result.postSteps.nativePlugin, true,
|
|
'E7: pi declares hostBehaviors.nativePlugin — postSteps.nativePlugin must record it as a post-step',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('installRuntimeArtifacts — E6: hermes post-step is recorded', () => {
|
|
test('hermes post-step is recorded', (t) => {
|
|
const configDir = createTempDir('gsd-e6-hermes-');
|
|
t.after(() => cleanup(configDir));
|
|
sandboxHome(t, configDir);
|
|
|
|
const result = installRuntimeArtifacts('hermes', configDir, 'global', RESOLVED_CORE);
|
|
|
|
assert.strictEqual(
|
|
result.postSteps.hermesBareStemCleanup, true,
|
|
'E6: hermes must record _removeHermesBareStemDirs having run as a post-step',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('installRuntimeArtifacts — E8: preserved user skill dirs are recorded', () => {
|
|
test('preserved user skill dirs are recorded', (t) => {
|
|
const configDir = createTempDir('gsd-e8-claude-');
|
|
t.after(() => cleanup(configDir));
|
|
sandboxHome(t, configDir);
|
|
const preservedSkillDir = path.join(configDir, 'skills', 'gsd-dev-preferences');
|
|
fs.mkdirSync(preservedSkillDir, { recursive: true });
|
|
fs.writeFileSync(path.join(preservedSkillDir, 'SKILL.md'), '# my custom prefs\n');
|
|
|
|
const result = installRuntimeArtifacts('claude', configDir, 'global', RESOLVED_CORE);
|
|
|
|
const skillsKind = result.kinds.find((k) => k.kind === 'skills');
|
|
assert.ok(skillsKind, 'E8 precondition: claude global must write a skills kind');
|
|
assert.deepStrictEqual(
|
|
skillsKind.preserved, ['gsd-dev-preferences'],
|
|
'E8: the plan must record gsd-dev-preferences as preserved',
|
|
);
|
|
assert.strictEqual(
|
|
fs.readFileSync(path.join(preservedSkillDir, 'SKILL.md'), 'utf8'),
|
|
'# my custom prefs\n',
|
|
'E8: the preserved content must actually have been restored after the prune+copy, not just recorded on the plan',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('installRuntimeArtifacts — E9: non-skills kind records its writes', () => {
|
|
test('non-skills kind records its writes', (t) => {
|
|
const configDir = createTempDir('gsd-e9-claude-local-');
|
|
t.after(() => cleanup(configDir));
|
|
sandboxHome(t, configDir);
|
|
|
|
const result = installRuntimeArtifacts('claude', configDir, 'local', RESOLVED_CORE);
|
|
|
|
const commandsKind = result.kinds.find((k) => k.kind === 'commands');
|
|
assert.ok(commandsKind, 'E9 precondition: claude local must write a commands kind');
|
|
assert.strictEqual(commandsKind.destDir, path.join(configDir, 'commands'));
|
|
assert.ok(
|
|
fs.existsSync(commandsKind.destDir) && fs.readdirSync(commandsKind.destDir).length > 0,
|
|
'E9: the destDir the plan records must actually contain the copied files',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('installRuntimeArtifacts — E10: plan item naming a kind absent from layout.kinds', () => {
|
|
test('unknown kind still throws', (t) => {
|
|
const configDir = createTempDir('gsd-e10-claude-');
|
|
t.after(() => cleanup(configDir));
|
|
sandboxHome(t, configDir);
|
|
|
|
const original = runtimeArtifactInstallPlan.createRuntimeArtifactInstallPlan;
|
|
t.after(() => { runtimeArtifactInstallPlan.createRuntimeArtifactInstallPlan = original; });
|
|
// Module-ref monkeypatch (same pattern as
|
|
// tests/runtime-artifact-layout-surface.test.cjs) — install-engine.cts
|
|
// reads this via the module reference, not a destructured local, so
|
|
// reassigning the export is observed at call time.
|
|
runtimeArtifactInstallPlan.createRuntimeArtifactInstallPlan = (args) => {
|
|
const real = original(args);
|
|
if (!real.ok) return real;
|
|
return {
|
|
ok: true,
|
|
plan: {
|
|
items: [...real.plan.items, { kind: 'not-a-real-kind', sourceDir: configDir, destDir: configDir }],
|
|
cleanupDirs: real.plan.cleanupDirs,
|
|
},
|
|
};
|
|
};
|
|
|
|
assert.throws(
|
|
() => installRuntimeArtifacts('claude', configDir, 'global', RESOLVED_CORE),
|
|
/unknown artifact kind/i,
|
|
'E10: a plan item naming a kind absent from layout.kinds must still throw "unknown artifact kind"',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('installRuntimeArtifacts — E11: plan is not shared across calls', () => {
|
|
test('plan is not shared across calls', (t) => {
|
|
const configDir = createTempDir('gsd-e11-claude-');
|
|
t.after(() => cleanup(configDir));
|
|
sandboxHome(t, configDir);
|
|
|
|
const first = installRuntimeArtifacts('claude', configDir, 'global', RESOLVED_CORE);
|
|
first.kinds.push({ kind: 'mutated-by-caller', sourceDir: 'x', destDir: 'y', preserved: [] });
|
|
first.postSteps.mutatedFlag = true;
|
|
|
|
const second = installRuntimeArtifacts('claude', configDir, 'global', RESOLVED_CORE);
|
|
|
|
assert.notStrictEqual(second, first, 'E11: each call must return a fresh object, not the same reference');
|
|
assert.notStrictEqual(second.kinds, first.kinds, 'E11: kinds array must not be shared across calls');
|
|
assert.ok(
|
|
!second.kinds.some((k) => k.kind === 'mutated-by-caller'),
|
|
'E11: mutating the first result must not leak into the second call\'s plan',
|
|
);
|
|
assert.strictEqual(
|
|
second.postSteps.mutatedFlag, undefined,
|
|
'E11: mutating the first result\'s postSteps must not leak into the second call',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('installRuntimeArtifacts — E12: executed plan key set is locked', () => {
|
|
test('executed plan key set is locked', (t) => {
|
|
const configDir = createTempDir('gsd-e12-claude-');
|
|
t.after(() => cleanup(configDir));
|
|
sandboxHome(t, configDir);
|
|
|
|
const result = installRuntimeArtifacts('claude', configDir, 'global', RESOLVED_CORE);
|
|
|
|
assert.deepStrictEqual(
|
|
Object.keys(result).sort(),
|
|
['cleanup', 'kinds', 'postSteps', 'runtime', 'scope'],
|
|
'E12: the executed-plan top-level key set is a locked contract — an added/renamed/removed key ' +
|
|
'here is a breaking change to AC1/AC4 and must be a deliberate, reviewed decision, not an ' +
|
|
'incidental refactor',
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── 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-');
|
|
t.after(() => cleanup(configDir));
|
|
sandboxHome(t, configDir);
|
|
|
|
let capturedCleanupDir;
|
|
const fakeFs = createRealDelegatingFs({
|
|
writeFileSync: (p, data, opts) => {
|
|
if (String(p).includes('gsd-cmd-rewrites-') && capturedCleanupDir === undefined) {
|
|
capturedCleanupDir = path.dirname(p);
|
|
}
|
|
fs.writeFileSync(p, data, opts);
|
|
},
|
|
copyFileSync: (src, dest) => {
|
|
const err = new Error(`EACCES: permission denied, copyfile '${src}' -> '${dest}'`);
|
|
err.code = 'EACCES';
|
|
throw err;
|
|
},
|
|
});
|
|
|
|
assert.throws(
|
|
() => installRuntimeArtifacts('augment', configDir, 'global', RESOLVED_FULL, TEST_ATTRIBUTION, undefined, { fs: fakeFs }),
|
|
(err) => err.code === 'EACCES',
|
|
'F4: an EACCES from the injected adapter mid-copy must propagate to the caller unchanged, exactly as a real EACCES would today',
|
|
);
|
|
assert.ok(capturedCleanupDir, 'F4 test precondition: the commands kind rewrite must have run before the copy failure');
|
|
assert.strictEqual(
|
|
fs.existsSync(capturedCleanupDir), false,
|
|
'F4: cleanup must still run (the finally block) even though the copy step threw',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('installRuntimeArtifacts — F5: fake existsSync drives the same branch', () => {
|
|
test('fake existsSync drives the same branch', () => {
|
|
const configDir = path.join(os.tmpdir(), `gsd-f5-must-not-exist-${crypto.randomUUID()}`);
|
|
const skillsDest = path.join(configDir, 'skills');
|
|
// Seed ONLY the skills destDir as a pre-existing (empty) directory in the
|
|
// fake store — configDir is never created for real, so existsSync(dest)
|
|
// reports true purely because the FAKE says so, driving the exact same
|
|
// `kind.kind === 'skills' && installFs().existsSync(dest)` pre-existing-
|
|
// dest branch a real pre-existing dir would take.
|
|
const fakeFs = createFakeInstallFs([[skillsDest, { type: 'dir' }]]);
|
|
|
|
const result = installRuntimeArtifacts('claude', configDir, 'global', RESOLVED_CORE, undefined, undefined, { fs: fakeFs });
|
|
|
|
assert.notStrictEqual(result, undefined, 'F5: must still return a plan');
|
|
const skillsKind = result.kinds.find((k) => k.kind === 'skills');
|
|
assert.ok(skillsKind, 'F5 precondition: claude global writes a skills kind');
|
|
assert.strictEqual(skillsKind.destDir, skillsDest);
|
|
assert.deepStrictEqual(
|
|
skillsKind.preserved, [],
|
|
'F5: the branch ran off the fake\'s existsSync=true, found an empty pre-existing dir, and preserved ' +
|
|
'nothing — the same outcome the real existsSync-true branch produces for an empty pre-existing dir',
|
|
);
|
|
});
|
|
});
|
|
|
|
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()}`);
|
|
|
|
// 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: () => undefined,
|
|
};
|
|
|
|
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',
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── G. Additive contract — G2 ────────────────────────────────────────────────
|
|
|
|
describe('installRuntimeArtifacts — G2: bin/install.js production call site unchanged', () => {
|
|
test('installer call site unchanged', (t) => {
|
|
const binInstall = require('../bin/install.js');
|
|
const tmpDir = createTempDir('gsd-g2-');
|
|
const previousCwd = process.cwd();
|
|
process.chdir(tmpDir);
|
|
t.after(() => { process.chdir(previousCwd); cleanup(tmpDir); });
|
|
|
|
const result = binInstall.install(false, 'claude');
|
|
|
|
assert.strictEqual(
|
|
result.runtime, 'claude',
|
|
'G2: bin/install.js\'s production call site (6 positional args, no deps) must be unaffected by the new optional deps param',
|
|
);
|
|
// install(false, ...) is a LOCAL install — claude's local layout writes
|
|
// commands+agents, not skills (skills is global-only for claude).
|
|
assert.ok(
|
|
fs.existsSync(path.join(tmpDir, '.claude', 'commands')),
|
|
'G2: the production install must still write commands/ end-to-end',
|
|
);
|
|
binInstall.uninstall(false, 'claude');
|
|
});
|
|
});
|
|
|
|
// ─── H. Security boundaries must NOT move behind the adapter ─────────────────
|
|
|
|
describe('installRuntimeArtifacts — H1: symlink escape still refuses', () => {
|
|
test('symlink escape still refuses', (t) => {
|
|
const configDir = createTempDir('gsd-h1-');
|
|
const outsideDir = createTempDir('gsd-h1-outside-');
|
|
t.after(() => { cleanup(configDir); cleanup(outsideDir); });
|
|
sandboxHome(t, configDir);
|
|
// Pre-create the skills destDir AS a symlink pointing outside configDir —
|
|
// the guard must refuse before mkdirSync ever follows it.
|
|
fs.symlinkSync(outsideDir, path.join(configDir, 'skills'), 'dir');
|
|
|
|
assert.throws(
|
|
() => installRuntimeArtifacts('claude', configDir, 'global', RESOLVED_CORE),
|
|
/GSD_ALLOW_SYMLINKED_DEST/,
|
|
'H1: a destDir that is itself a symlink pointing outside the install root must be refused',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('installRuntimeArtifacts — H2: opt-in still follows', () => {
|
|
test('opt-in still follows', (t) => {
|
|
const configDir = createTempDir('gsd-h2-');
|
|
const outsideDir = createTempDir('gsd-h2-outside-');
|
|
t.after(() => { cleanup(configDir); cleanup(outsideDir); });
|
|
sandboxHome(t, configDir);
|
|
fs.symlinkSync(outsideDir, path.join(configDir, 'skills'), 'dir');
|
|
|
|
const savedOptIn = process.env.GSD_ALLOW_SYMLINKED_DEST;
|
|
process.env.GSD_ALLOW_SYMLINKED_DEST = '1';
|
|
t.after(() => {
|
|
if (savedOptIn === undefined) delete process.env.GSD_ALLOW_SYMLINKED_DEST;
|
|
else process.env.GSD_ALLOW_SYMLINKED_DEST = savedOptIn;
|
|
});
|
|
|
|
const result = installRuntimeArtifacts('claude', configDir, 'global', RESOLVED_CORE);
|
|
|
|
assert.notStrictEqual(result, undefined, 'H2: opt-in must still succeed and return a plan');
|
|
const skillsKind = result.kinds.find((k) => k.kind === 'skills');
|
|
assert.ok(skillsKind, 'H2 precondition: claude global writes a skills kind');
|
|
assert.ok(
|
|
fs.readdirSync(outsideDir).length > 0,
|
|
'H2: with the opt-in set, writes must actually follow the symlink into outsideDir',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('installRuntimeArtifacts — H3: fake adapter cannot bypass the symlink guard', () => {
|
|
test('fake adapter cannot bypass the symlink guard', () => {
|
|
// hasExistingSymlinkBetween's path-traversal refusal (install-engine.cts,
|
|
// part (a) of the guard: "resolvedFullPath !== resolvedRoot &&
|
|
// !resolvedFullPath.startsWith(resolvedRoot + path.sep)") is PURE PATH
|
|
// MATH — path.resolve/startsWith on strings, no fs call at all. Pin that
|
|
// invariant directly: even a fake adapter that lies "nothing exists,
|
|
// nothing is a symlink" everywhere cannot make this refusal pass for an
|
|
// escaping path, because this branch never asks the adapter anything.
|
|
const root = path.join(os.tmpdir(), 'gsd-h3-fake-root');
|
|
const escapingPath = path.join(root, '..', '..', 'etc', 'passwd');
|
|
const lyingFs = {
|
|
existsSync: () => false,
|
|
lstatSync: () => {
|
|
throw new Error('H3: lstatSync must never be reached — the path-traversal refusal is pure path math');
|
|
},
|
|
realpathSync: (p) => p,
|
|
};
|
|
|
|
const refused = withInstallFs(lyingFs, () => hasExistingSymlinkBetween(root, escapingPath));
|
|
|
|
assert.strictEqual(
|
|
refused, true,
|
|
'H3: a fake adapter reporting "nothing exists, nothing is a symlink" must not be able to certify ' +
|
|
'an install the real filesystem would refuse — the path-traversal decision does not consult the ' +
|
|
'adapter at all',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('installRuntimeArtifacts — H4: dest confinement still enforced', () => {
|
|
test('dest confinement still enforced', () => {
|
|
assert.throws(
|
|
() => runtimeArtifactInstallPlan.assertDestWithinConfigHome('/fake/config/home', '../../etc'),
|
|
/escapes configHome|strict subpath/i,
|
|
'H4: assertDestWithinConfigHome must still throw for a destSubpath escaping configHome',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('installRuntimeArtifacts — H5: nul byte in dest is rejected', () => {
|
|
test('nul byte in dest is rejected', () => {
|
|
assert.throws(
|
|
() => runtimeArtifactInstallPlan.assertDestWithinConfigHome('/fake/config/home', 'skills\0evil'),
|
|
/NUL/,
|
|
'H5: assertDestWithinConfigHome must still throw for a destSubpath containing a NUL byte',
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── I. Cleanup visibility ─────────────────────────────────────────────────
|
|
|
|
describe('installRuntimeArtifacts — I1: successful cleanup is recorded', () => {
|
|
test('successful cleanup is recorded', (t) => {
|
|
const configDir = createTempDir('gsd-i1-augment-');
|
|
t.after(() => cleanup(configDir));
|
|
sandboxHome(t, configDir);
|
|
|
|
const result = installRuntimeArtifacts('augment', configDir, 'global', RESOLVED_FULL, TEST_ATTRIBUTION);
|
|
|
|
assert.ok(result.cleanup.length > 0, 'I1: augment install must produce at least one cleanupDirs entry to prove this row');
|
|
for (const entry of result.cleanup) {
|
|
assert.strictEqual(typeof entry.dir, 'string');
|
|
assert.strictEqual(entry.ok, true, `I1: successful cleanup entries must record ok:true (dir=${entry.dir})`);
|
|
assert.strictEqual(fs.existsSync(entry.dir), false, 'I1: a successfully cleaned dir must no longer exist on disk');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('installRuntimeArtifacts — I2: failed cleanup is visible, not silent', () => {
|
|
test('failed cleanup is visible, not silent', (t) => {
|
|
const configDir = createTempDir('gsd-i2-augment-');
|
|
t.after(() => cleanup(configDir));
|
|
sandboxHome(t, configDir);
|
|
|
|
const fakeFs = createRealDelegatingFs({
|
|
rmSync: (p, opts) => {
|
|
if (String(p).includes('gsd-cmd-rewrites-')) {
|
|
throw new Error('I2: simulated cleanup failure');
|
|
}
|
|
// 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 });
|
|
|
|
assert.notStrictEqual(result, undefined, 'I2: install must still succeed (never fail) even when cleanup throws');
|
|
assert.ok(result.cleanup.length > 0, 'I2: augment must have at least one cleanupDirs entry to fail');
|
|
assert.ok(
|
|
result.cleanup.every((c) => c.ok === false),
|
|
'I2: a cleanup rmSync throw must be reported as ok:false on the returned plan, never silently dropped',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('installRuntimeArtifacts — I3: no cleanup dirs is an empty array', () => {
|
|
test('no cleanup dirs is an empty array', (t) => {
|
|
const configDir = createTempDir('gsd-i3-claude-');
|
|
t.after(() => cleanup(configDir));
|
|
sandboxHome(t, configDir);
|
|
|
|
const result = installRuntimeArtifacts('claude', configDir, 'global', RESOLVED_CORE);
|
|
|
|
assert.ok(Array.isArray(result.cleanup), 'I3: cleanup must be an array even when empty');
|
|
assert.strictEqual(
|
|
result.cleanup.length, 0,
|
|
'I3: a claude/core install with no rewritten temp dirs must report an EMPTY cleanup array, not undefined/absent',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('installRuntimeArtifacts — I4: stage failure before any item cleans up and throws', () => {
|
|
test('stage failure cleans up and throws', (t) => {
|
|
const configDir = createTempDir('gsd-i4-');
|
|
t.after(() => cleanup(configDir));
|
|
sandboxHome(t, configDir);
|
|
|
|
assert.throws(
|
|
() => installRuntimeArtifacts('claude', configDir, 'global', { skills: 123, agents: 123 }),
|
|
(err) => err instanceof Error,
|
|
'I4: a malformed resolvedProfile that fails the FIRST kind\'s stage() (before any cleanupDirs exist) ' +
|
|
'must still surface as a thrown Error, with the (empty) cleanupDirs still swept by the finally block',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('installRuntimeArtifacts — I5: rewrite failure mid-plan cleans up and throws', () => {
|
|
test('rewrite failure cleans up and throws', (t) => {
|
|
const configDir = createTempDir('gsd-i5-augment-');
|
|
t.after(() => cleanup(configDir));
|
|
sandboxHome(t, configDir);
|
|
|
|
let capturedCleanupDir;
|
|
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');
|
|
}
|
|
fs.mkdirSync(p, opts);
|
|
return undefined;
|
|
},
|
|
writeFileSync: (p, data, opts) => {
|
|
if (String(p).includes('gsd-cmd-rewrites-') && capturedCleanupDir === undefined) {
|
|
capturedCleanupDir = path.dirname(p);
|
|
}
|
|
fs.writeFileSync(p, data, opts);
|
|
},
|
|
});
|
|
|
|
assert.throws(
|
|
() => installRuntimeArtifacts('augment', configDir, 'global', RESOLVED_FULL, TEST_ATTRIBUTION, undefined, { fs: fakeFs }),
|
|
/I5: simulated skills-stage failure/,
|
|
'I5: a failure in a LATER kind\'s stage step must still propagate as a thrown error',
|
|
);
|
|
assert.ok(capturedCleanupDir, 'I5 test precondition: the commands kind\'s rewrite dir must have been observed before the skills-stage failure');
|
|
assert.strictEqual(
|
|
fs.existsSync(capturedCleanupDir), false,
|
|
'I5: the EARLIER (successfully rewritten) commands cleanupDir must still be removed by the finally ' +
|
|
'block even though a LATER kind\'s stage step failed',
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── K. Byte-identical writes — K3 ─────────────────────────────────────────
|
|
|
|
function walkFilesRecursively(root) {
|
|
const out = new Map();
|
|
const walk = (relPath, absPath) => {
|
|
for (const entry of fs.readdirSync(absPath, { withFileTypes: true })) {
|
|
const childRel = relPath ? path.join(relPath, entry.name) : entry.name;
|
|
const childAbs = path.join(absPath, entry.name);
|
|
if (entry.isDirectory()) walk(childRel, childAbs);
|
|
else if (entry.isFile()) out.set(childRel, fs.readFileSync(childAbs));
|
|
}
|
|
};
|
|
if (fs.existsSync(root)) walk('', root);
|
|
return out;
|
|
}
|
|
|
|
describe('installRuntimeArtifacts — K3: real install before/after, full recursive diff', () => {
|
|
test('writes are byte-identical', (t) => {
|
|
for (const runtime of ['claude', 'qwen']) {
|
|
const dirA = createTempDir(`gsd-k3-${runtime}-a-`);
|
|
const dirB = createTempDir(`gsd-k3-${runtime}-b-`);
|
|
t.after(() => { cleanup(dirA); cleanup(dirB); });
|
|
|
|
sandboxHome(t, dirA);
|
|
installRuntimeArtifacts(runtime, dirA, 'global', RESOLVED_FULL);
|
|
sandboxHome(t, dirB);
|
|
installRuntimeArtifacts(runtime, dirB, 'global', RESOLVED_FULL);
|
|
|
|
const filesA = walkFilesRecursively(dirA);
|
|
const filesB = walkFilesRecursively(dirB);
|
|
assert.deepStrictEqual(
|
|
[...filesA.keys()].sort(), [...filesB.keys()].sort(),
|
|
`K3 (${runtime}): the file sets written by two independent installs must match`,
|
|
);
|
|
for (const [relPath, contentA] of filesA) {
|
|
assert.ok(
|
|
contentA.equals(filesB.get(relPath)),
|
|
`K3 (${runtime}): ${relPath} content drifted between two independent installs`,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── L. Property tests ────────────────────────────────────────────────────
|
|
|
|
describe('installRuntimeArtifacts — L1: plan kinds mirror layout kinds (property)', () => {
|
|
test('plan kinds mirror layout kinds', (t) => {
|
|
const runtimes = Object.keys(registry.runtimes);
|
|
const RUNTIME_ARB = fc.constantFrom(...runtimes);
|
|
const SCOPE_ARB = fc.constantFrom('global', 'local');
|
|
const observedKindSets = new Set();
|
|
const createdDirs = [];
|
|
const savedHome = process.env.HOME;
|
|
const savedUserProfile = process.env.USERPROFILE;
|
|
t.after(() => {
|
|
if (savedHome === undefined) delete process.env.HOME; else process.env.HOME = savedHome;
|
|
if (savedUserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = savedUserProfile;
|
|
for (const d of createdDirs) cleanup(d);
|
|
});
|
|
|
|
// Seeded, bounded numRuns, replay data on failure (verbose:true prints
|
|
// the failing/shrunk (runtime, scope) pair fast-check found).
|
|
fc.assert(
|
|
fc.property(RUNTIME_ARB, SCOPE_ARB, (runtime, scope) => {
|
|
const configDir = createTempDir(`gsd-l1-${runtime}-`);
|
|
createdDirs.push(configDir);
|
|
process.env.HOME = configDir;
|
|
process.env.USERPROFILE = configDir;
|
|
|
|
const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, configDir, scope);
|
|
const expectedKinds = [...new Set(layout.kinds.map((k) => k.kind))].sort();
|
|
const plan = installRuntimeArtifacts(runtime, configDir, scope, RESOLVED_CORE);
|
|
const actualKinds = [...new Set(plan.kinds.map((k) => k.kind))].sort();
|
|
observedKindSets.add(JSON.stringify(actualKinds));
|
|
|
|
assert.deepStrictEqual(
|
|
actualKinds, expectedKinds,
|
|
`L1 (${runtime}/${scope}): plan.kinds must be a bijection with layout.kinds — ` +
|
|
`plan=${JSON.stringify(actualKinds)} vs layout=${JSON.stringify(expectedKinds)}`,
|
|
);
|
|
}),
|
|
{ numRuns: 30, seed: 2874, verbose: true },
|
|
);
|
|
|
|
// Non-vacuity: the registry has runtimes with empty, single-kind, and
|
|
// multi-kind layouts (verified across the whole registry — see this
|
|
// row's PR notes) — a generator that only ever produced ONE kind-set
|
|
// would be exercising nothing.
|
|
assert.ok(
|
|
observedKindSets.size > 1,
|
|
`L1 non-vacuity: the generator must exercise more than one distinct kind-set — observed only ` +
|
|
`${observedKindSets.size} (${[...observedKindSets].join(', ')})`,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('installRuntimeArtifacts — L2: plan is deterministic (property)', () => {
|
|
function normalizePlanForIdempotence(plan) {
|
|
// mkInstallTempDir names every rewrite/staging temp dir with a random hex
|
|
// suffix (install-fs-adapter.cts) — expected to differ between two
|
|
// independent calls even when everything else about the plan is
|
|
// identical. Normalize those away; everything else must match exactly.
|
|
const stripTemp = (p) => (typeof p === 'string' && p.startsWith(os.tmpdir()) ? '<TEMP>' : p);
|
|
return {
|
|
runtime: plan.runtime,
|
|
scope: plan.scope,
|
|
kinds: plan.kinds.map((k) => ({
|
|
kind: k.kind, sourceDir: stripTemp(k.sourceDir), destDir: k.destDir,
|
|
preserved: k.preserved, written: k.written,
|
|
})),
|
|
cleanup: plan.cleanup.map((c) => ({ dir: stripTemp(c.dir), ok: c.ok })),
|
|
postSteps: plan.postSteps,
|
|
};
|
|
}
|
|
|
|
test('plan is deterministic', () => {
|
|
const runtimes = Object.keys(registry.runtimes);
|
|
const RUNTIME_ARB = fc.constantFrom(...runtimes);
|
|
const SCOPE_ARB = fc.constantFrom('global', 'local');
|
|
let hits = 0;
|
|
|
|
fc.assert(
|
|
fc.property(RUNTIME_ARB, SCOPE_ARB, (runtime, scope) => {
|
|
// configDir is never created for real — both calls run against fresh,
|
|
// independent fake adapters, so no real fs cleanup is needed here.
|
|
const configDir = path.join(os.tmpdir(), `gsd-l2-${runtime}-${crypto.randomUUID()}`);
|
|
const planA = installRuntimeArtifacts(runtime, configDir, scope, RESOLVED_CORE, undefined, undefined, { fs: createFakeInstallFs() });
|
|
const planB = installRuntimeArtifacts(runtime, configDir, scope, RESOLVED_CORE, undefined, undefined, { fs: createFakeInstallFs() });
|
|
hits++;
|
|
|
|
assert.deepStrictEqual(
|
|
normalizePlanForIdempotence(planA),
|
|
normalizePlanForIdempotence(planB),
|
|
`L2 (${runtime}/${scope}): two installs against fresh fake adapters with the same inputs must ` +
|
|
'yield structurally identical plans (temp-dir names normalized — see normalizePlanForIdempotence)',
|
|
);
|
|
}),
|
|
{ numRuns: 30, seed: 2874, verbose: true },
|
|
);
|
|
|
|
assert.strictEqual(hits, 30, 'L2 non-vacuity: every generated (runtime, scope) pair must actually have exercised a comparison');
|
|
});
|
|
});
|
|
|
|
// ─── readGsdCommandNames — single-source parity ──────────────────────────────
|
|
//
|
|
// command-roster.cts's readGsdCommandNames reimplements
|
|
// scripts/fix-slash-commands.cjs's readCmdNames' directory-scan rule against
|
|
// the injectable install-fs seam instead of delegating to it — see
|
|
// command-roster.cts's module comment for why (readCmdNames is deliberately
|
|
// a zero-dependency standalone CLI/library with no build-order dependency on
|
|
// gsd-core/bin/lib, so it cannot itself require the compiled
|
|
// install-fs-adapter.cjs). Two implementations of one filtering rule is this
|
|
// repo's recorded Generative Fix Divergence class; this test is the
|
|
// enforcement the coordinator required in exchange for keeping the
|
|
// reimplementation: it fails the moment the two disagree about which stems
|
|
// commands/gsd/ contains.
|
|
|
|
describe('readGsdCommandNames — single-source parity (command-roster.cts vs scripts/fix-slash-commands.cjs)', () => {
|
|
test('both implementations report the identical stem set for commands/gsd/', () => {
|
|
const fromCommandRoster = [...commandRoster.readGsdCommandNames()].sort();
|
|
const fromSlashCommandTransformer = [...slashCommandTransformer.readCmdNames()].sort();
|
|
assert.deepStrictEqual(
|
|
fromCommandRoster,
|
|
fromSlashCommandTransformer,
|
|
'command-roster.cts readGsdCommandNames() and scripts/fix-slash-commands.cjs readCmdNames() ' +
|
|
'diverged — these are two implementations of the SAME directory-scan rule (Generative Fix ' +
|
|
'Divergence); fix the one that is wrong, do not just silence this test',
|
|
);
|
|
assert.ok(fromCommandRoster.length > 0, 'sanity: commands/gsd/ must contain at least one command');
|
|
});
|
|
});
|