Commit Graph

2 Commits

Author SHA1 Message Date
Tom Boucher
3ab0007164 enh(#2875): materialization primitives — durable user-artifact staging and descriptor-authoritative agents (#3600)
* fix(#2875): stage user artifacts durably across install wipes (#1874-F19)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Adds the Changed changeset for the surface-path convergence.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* chore(#2875): backfill changeset pr number

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

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

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

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

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

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

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

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 17:25:53 -04:00
Tom Boucher
a4a02a7a01 enhance(#2874): return the executed plan and route install IO through a seam (#3568)
* test(#2874): add failing-first gate for the executed-plan return

Four rows from the matrix's red-first order. E3 pins the one early return,
for the opencode family, where a void-shaped hole would otherwise survive
unnoticed. E13 sweeps every runtime in the registry - enumerated from the
registry rather than hardcoded, so a runtime added later cannot slip past.
F2 proves absence of real filesystem contact rather than merely that the
happy path ran, which is the difference between a complete seam and a
partial one.

G1 and G3 are the additive guard and must be green before and after. G3
deliberately leaves the two existing adapter test doubles untouched: if
this change required editing them it would not be additive, and the
acceptance criterion would be unmet.

No production code. All 19 runtimes install without throwing today, so
E3 and E13 fail on the undefined comparison alone.

Refs #2874

* feat(#2874): return the executed plan and route install IO through a seam

installRuntimeArtifacts returned void, so its correctness was observable
only by re-reading disk. It now returns what it executed - per kind, per
scope - including on the combinedFamilyInstall path, which was the one
early return where a void-shaped hole would have survived unnoticed.

Failure still throws rather than becoming an ok:false return, so control
flow is unchanged for both existing callers. A best-effort cleanup that
fails is still swallowed, but is now visible in the returned value rather
than silently absent.

The fs seam is ambient rather than threaded. Explicit deps through
install-profiles and the 3000-line conversion module was impractical; the
tradeoff, the synchronous-only re-entrancy assumption, the restore
guarantee and the partial-adapter fallback trap are all documented at the
seam. findInstallSourceRoot and its sibling stay unrouted by design -
they locate the package's own source, not the install destination.

readCmdNames keeps a second implementation because the standalone CLI
that owns the original cannot require the compiled adapter without a
build-order dependency on its own output. A parity test fails if the two
ever disagree.

Refs #2874

* chore(#2874): gitignore the new build artifact

install-fs-adapter.cjs is tsc output from src/install-fs-adapter.cts, not
a tracked source file. It was added to eslint's ignore list but not to
.gitignore, so it landed as a tracked file - the third time this step of
the new-.cts ripple has been missed on this epic.

Refs #2874

* fix(#2874): close two seam leaks and correct a false comment

A correctness review found the seam still leaked in two places, both
subtler than the three already closed.

readGsdCommandNames was routed when it should not have been: it reads the
package's own commands directory, which a destination-fake is never
seeded with, so under a fake adapter it returned an empty or wrong roster
instead of failing loudly. It now reads real fs, matching the precedent
already documented for findInstallSourceRoot.

cleanupStagedSkills ran raw rmSync from a process exit handler, which is
real filesystem work deferred past the point where withInstallFs has
restored - the one thing the synchronous-only contract exists to
exclude. Staging now captures the adapter that created each directory and
cleanup replays it, so a real install cleans up exactly as before and a
fake-staged path never reaches the real filesystem.

Also corrected a comment claiming the migration reads were an unrouted,
untested residual gap. They are routed and exercised; a comment
understating the seam is as corrosive as one overstating it in a module
whose trust rests on being honestly documented.

Refs #2874

* test(#2874): migrate the exemplar group and cover the matrix

AC3's exemplar migration lands in place: the qwen install group now
asserts skills and agents destinations from the returned plan in one
deepStrictEqual instead of probing the filesystem for each.

Nine facts the old probes established were enumerated first. Two moved to
the value assertion; seven were retained deliberately - per-file SKILL.md
existence, the VERSION file written outside this function, the manifest
content, and the post-uninstall absence checks all sit outside the plan's
per-kind contract. A migration that quietly asserts less looks like a win
and is a regression, so the enumeration is the guard rather than the
line count.

Also implements the rest of the matrix: the executed-plan shape, adapter
failure modes, the security-boundary rows including a fake that cannot
certify an install the real filesystem would refuse, cleanup visibility,
and two seeded property tests. Only the two external CI gates are left
unticked, because self-certifying them would be a claim rather than a
check.

Refs #2874

* fix(#2874): restore streaming hashes and derive F2 from the boundary rule

The checkpoint found three things reasoning had missed.

sha256File had been converted from raw-fd streaming to a single
readFileSync on the assumption that GSD artifacts are never large. A test
named for exactly that contract already existed and went red. Streaming is
restored, now routed through the adapter, which gains openSync, readSync
and closeSync. The contract was the specification; the assumption was not.

Three existing tests inject faults by monkeypatching real fs. They broke
because mkInstallTempDir stopped calling real mkdtempSync, not because of
any binding subtlety - the real adapter was already late-bound. It now
calls the real function when no fake is injected, so a monkeypatch applied
after import is still seen and the additive contract holds.

F2 poisoned real fs by method, so a deliberately unrouted package-source
read failed a correct design. It now poisons by path: destination IO is
forbidden, package-source IO is allowed and positively asserted. The claim
was always zero real destination IO, and the test now derives from that
rule instead of coincidentally matching it.

Refs #2874

* docs(#2874): add the contributor how-to for plan-based test migration

The phase gate caught a real gap. The docs plan was Reference plus
Explanation only, and every CI check would have passed, because the
docs-required lint only verifies that some file under docs/ moved.

But this phase exists to demonstrate a pattern for follow-on work, and
that work is other contributors migrating probing test groups. The
sequence has two live traps - a partial fake silently falls back to real
fs, and the seam is ambient and synchronous-only - plus one discipline
nobody infers: enumerate the facts before converting, or you assert less
and call it a win.

The page carries the qwen migration's arithmetic, nine facts enumerated
and only two converted, because a reader seeing only the diff would
reasonably conclude the pattern is to replace probes wholesale.

No locale mirrors: none of the four carries any contributor-only how-to,
so a single translated file would manufacture parity rather than provide
it.

Refs #2874

* chore(#2874): backfill changeset pr number

* test(#2874): normalize both sides of the G1 tree comparison

G1 failed on Windows only, deterministically on both shards. The defect
was in the test helper, not production.

_computePathPrefix posix-normalizes the resolved config dir
unconditionally, so on Windows the path embedded in every emitted
SKILL.md body is forward-slash form. hashDirTree stripped against the raw
backslash path from mkdtempSync, so the substring never matched and each
install's unique temp suffix stayed baked into every file - all fifteen
skill bodies hashed differently for two runs that had written identical
bytes.

Both sides are now normalized unconditionally rather than gated on
path.sep, matching the rule this repo already records: backslash paths
arrive on Linux too.

Production code is untouched and was verified correct. Normalizing this
away on the production side would have hidden a real portability bug if
one had existed.

Refs #2874

---------

Co-authored-by: sim <sim@local>
2026-08-16 02:48:24 -04:00