Files
msd-core/tests/emitted-drift-acks/3024-sync-skills-runtime-launcher-preamble.json
Tom Boucher 3f349e551d fix(#3024): route sync-skills through the shipped gsd-tools instead of an unshipped install.js (#3195)
* fix(#3024): sync-skills workflow uses gsd-tools query skills-root instead of unshipped install.js

The sync-skills workflow Step 2 shelled out to gsd-core/bin/install.js --skills-root,
but install.js is not shipped in installed trees (only in the npm tarball root bin/).
Every /gsd-update --sync invocation failed with MODULE_NOT_FOUND.

Fix: added 'gsd-tools query skills-root <runtime>' subcommand (gsd-tools IS shipped)
that calls the same getGlobalSkillsBase function install.js used. Updated the
workflow to call gsd_run query skills-root instead of the dead install.js path.

Also documented the #3025 verbatim-cp limitation in Step 5 with a workaround.

* test(#3024): failing-first guards for the three defects in the adopted fix

The cherry-picked commit came from an aborted run that never executed its own
tests. Its raw-path assertion fails as written, which is the clearest evidence
the work never reached verification.

Covers:
- --raw must emit a bare path, not JSON (output() takes a third rawValue arg
  that routeSkillsRoot omits, so the raw branch never fires)
- an unknown, empty, whitespace, traversing, or metacharacter-bearing runtime
  must be rejected, not silently resolved to claude's skills root
- sync-skills.md must contain zero references to the unshipped install.js,
  including the guard's remediation text — the issue's second reported defect
- parity across every runtime in the registry, not three hardcoded ones, so the
  two entry points cannot drift

Also converts the adopted tests off a hand-rolled spawnSync onto the bounded
process seam, per CONTRIBUTING.

Fails before the fix. Verified via the remote runner.

* fix(#3024): make the skills-root query actually work and reach non-Claude runtimes

The cherry-picked commit never ran its own tests. Six defects, all fixed here.

--raw was ignored: output() is output(result, raw, rawValue) and the third
argument was omitted, so the raw branch never fired and the workflow captured a
JSON blob as SRC_SKILLS_ROOT. Every downstream cp -r then resolved against a
nonexistent path — the command would have shipped still broken.

An unknown runtime silently resolved to claude's skills root, because
getGlobalSkillsBase falls back rather than returning null, leaving the existing
=== null guard dead. The runtime id is now validated at the CLI boundary against
the shipped registry, so a typo'd --from/--to fails instead of reading from or
writing into the wrong runtime's tree.

getGlobalSkillsBase('vscode') threw a raw TypeError. vscode is non-installable
by descriptor, so it has no skills root — null is the answer, not a crash. The
resolver now short-circuits configHome.kind 'none', which also fixes the same
latent crash in install.js --skills-root vscode. Every caller already gates on
=== null.

sync-skills.md used gsd_run WITHOUT the canonical launcher preamble, so gsd_run
was undefined on non-Claude runtimes — the fix would have been dead in exactly
the place the original bug bit. Preamble propagated via sync-runtime-launcher.

Also registers skills-root in TOP_LEVEL_USAGE (the help/dispatch parity guard
caught it), removes the last two install.js references including the guard's
remediation text (the issue's second reported defect), and updates the stale
assertion that still described the removed contract.

Verified on the remote runner.

* fix(#3024): align the documented runtime list with the registry and gate both entry points

Isolated review returned BLOCK on two findings.

The workflow's Supported-runtimes list and its --to all expansion named grok and
gemini, neither of which is a registered runtime. Once this branch added
validation, --to all — a documented first-class feature — aborted. The list was
hand-copied prose shadowing the registry, so correcting it alone would drift
again; a parity assertion now fails in BOTH directions if the doc and the
registry disagree. vscode is excluded by name: it is installSurface 'none', so
syncing skills to it is meaningless and would abort.

bin/install.js --skills-root reached getGlobalSkillsBase with no own-property
gate, so --skills-root __proto__ silently resolved to claude's skills root. This
branch had just hardened the OTHER entry point to the same function; leaving one
of two parallel surfaces open is the same divergence class as the first finding.
Both now call one shared isRegisteredRuntimeId() rather than a copied check, and
the parity test covers the hostile ids so the two can never disagree again.

Also guards the workflow's root resolution: neither command substitution checked
its exit status and only the source had an existence guard, so a failed
destination resolution left DEST_ROOT empty and turned rm -rf "$DEST_ROOT/$SKILL"
into an absolute path at filesystem root. Both resolutions are now checked, and
Step 5 requires both roots to be non-empty and absolute before any destructive
command.

Verified on the remote runner.

* test(#3024): anchor the runtime-list parity extractor to the list span

The extractor captured (.+) to end of line, so it swallowed the em-dash prose
that explains the vscode exclusion — and that sentence contains backticked
`runtimes` and `null`, which is where the three phantom ids came from. The
documented list was correct; the test was reading its own explanation back as
data. Anchored to the id-list span.

Both directions still fail as intended: proven by injecting a bogus id and by
removing a registered one.

* test(#3024): anchor the --to all extractor and fail loudly on empty captures

The workflow has three TO_RUNTIMES= assignments and the regex matched the first
one — an empty array initializer at line 28 — so the extractor captured nothing
and the assertion diffed [] against 18 ids as if that were data.

That is the same failure twice, so the fix is the general one: every extractor
in this test now asserts it captured a plausible list before comparing, naming
which extractor found nothing and what it was looking for. An extractor that
silently yields [] is a confident wrong answer, and a parity guard that reports
it as a data mismatch teaches the reader to loosen the assertion.

Verified against the real workflow and against doctored copies with each target
construct removed, plus both teeth directions.

* fix(#3024): merge duplicate process-seam import after rebase

The rebase applied cleanly but left runNode declared twice: next had gained its
own import of the seam while this branch added one carrying OUTCOME. A clean
rebase is not a correct one — the file no longer parsed. Merged into a single
import providing both.

* fix(#3024): bind DEST_ROOT per destination instead of a dangling map

Step 2 stored each destination's root into DEST_SKILLS_ROOTS, which nothing ever
read, while Steps 3 and 5 used a scalar DEST_ROOT that nothing ever assigned. The
array was also never declare -A'd, so on bash 3.2 — macOS system bash, which this
repo supports — every destination collapsed onto index 0.

The absolute-path guard added earlier was the only thing standing between that and
rm -rf "/$SKILL"; it turned a silent disaster into a hard stop, but the feature
still could not complete. Each destination now binds its own DEST_ROOT where it is
used, and the unread map is gone rather than replaced.

Step 2 keeps eager validation, so a bad runtime id in a multi-destination --to
aborts before any destination is written rather than after some already have been.

Verified on bash 3.2 with a two-destination run binding distinct roots, and with a
bad id aborting before any destructive call.

* fix(#3024): restore grok support broken by the registry gate

The registry gate added earlier rejected grok, and that was my error. I confirmed
grok was absent from the capability registry and concluded the hardcoded branch
was dead — without checking what it resolved to. It resolves to ~/.agents/skills,
a real grok-specific path, exactly as the pre-fix workflow documented ('grok uses
the ~/.agents layout'), and there is a support discussion doc for it. So a
working, documented runtime silently lost --skills-root and sync-skills support
as a side effect of prototype-pollution hardening — and the parity test I added
locked that in as correct.

gemini is the one that really was dead: it fell through to CLAUDE's skills root,
so rejecting it is right and it stays rejected, as do bogus ids, __proto__,
empty, whitespace and traversal.

The validator's real question is 'does this id have a genuine runtime-specific
resolution', not 'is it in the registry map'. Registry membership was a proxy
that happened to miss grok. Legacy non-registry runtimes with dedicated
resolution branches are now a named, documented set; enumerating every hardcoded
branch in getGlobalConfigDir against the registry confirms grok is the only one.

The new tests assert grok resolves UNDER .agents and specifically not to claude's
root. Allow-listing an id proves nothing about whether it resolves correctly —
that assertion is what would have caught my mistake.

Also uses the shared PROBE_TIMEOUT_MS instead of a duplicate literal, and guards
Step 3's DEST_ROOT re-resolution, which contradicted the file's own stated
guarantee.

Verified on the remote runner.

* test(#3024): guard against LEGACY_NON_REGISTRY_RUNTIME_IDS drifting

The named legacy set is a second hand-maintained proxy for the same predicate
the registry check got wrong — 'does this id resolve runtime-specifically'.
Nothing stopped a third hardcoded branch being added to getGlobalConfigDir
without updating the Set, reproducing the exact class of bug that broke grok.

Production stays explicit and greppable; the test derives the truth instead. It
resolves a sentinel id to learn the generic fallback, classifies every candidate
against it, and fails in both directions — an id resolving runtime-specifically
that is in neither the registry nor the Set, or a Set entry that no longer earns
its exemption. The failure message names the remedy.

Confirms grok resolves runtime-specifically and gemini does not, which is the
distinction the original registry check could not see.

Also reverts the shared-timeout swap: SKILLS_ROOT_PROBE_TIMEOUT_MS is
pre-existing on next and arrived by rebase, so changing it here was scope creep
into another issue's territory.

Verified on the remote runner.

* chore(#3024): backfill changeset PR number

---------

Co-authored-by: sim <sim@local>
2026-08-07 18:53:25 -04:00

9 lines
4.9 KiB
JSON

{
"version": 1,
"paths": {
"sync-skills.md": {
"reason": "#3024: Step 2 now resolves skills roots via `gsd_run query skills-root <runtime>` instead of shelling out to the unshipped `install.js --skills-root`. Because the workflow now calls `gsd_run`, it must carry the canonical runtime-launcher preamble (`node scripts/sync-runtime-launcher.cjs`, per runtime-launcher-parity.test.cjs) so `gsd_run` resolves on every non-Claude runtime, not just Claude Code — without it the fix would be dead exactly where the original #3024 bug bit. The single-line preamble (~4.4KB, one bash line with a resolver arm per supported runtime home) accounts for essentially all of the growth (6,125 -> 10,928 bytes); the remainder is the reworded Step 2 lead-in and error-guidance text that no longer references the unshipped install.js entry point (defect C). Follow-up hardening (still #3024): neither `gsd_run query skills-root` call in Step 2 checked its exit status or for an empty result, so an unregistered/invalid runtime id silently produced an empty `$DEST_ROOT`, which Step 5 then fed into `rm -rf \"$DEST_ROOT/$SKILL\"` (an absolute-root deletion). Step 2's bash now checks exit status + non-empty for both the source and every destination resolution and aborts with a named error; a matching prose guard covers destination-resolution failure alongside the pre-existing source-not-found guard; Step 5 adds a one-line non-empty/absolute check on both `$SRC_SKILLS_ROOT` and `$DEST_ROOT` before any `rm -rf`/`cp -r`. (Separately noted but NOT fixed here, as it requires restructuring Steps 3-5 rather than a Step 2/Step 5 guard: `DEST_SKILLS_ROOTS` is populated as a keyed map but is never read back into `$DEST_ROOT` anywhere in the file, and on bash 3.2 — the macOS system `/bin/bash` — assigning to it without `declare -A` silently collapses every destination's resolved root into index `[0]`; `declare -A` itself is bash4+-only and errors on 3.2, so this is not a one-line fix.) This accounts for the additional growth (10,928 -> 12,166 bytes). Follow-up (review BLOCKER on this fix): the \"Supported runtime names\" prose list and the `--to all` TO_RUNTIMES expansion both hand-copied a runtime-id list that had drifted from the capability registry — `grok` and `gemini` were listed but are not registered runtime ids, and this branch's own-property validation gate in `routeSkillsRoot` now correctly rejects them, so `--to all` (and any explicit `--to grok`/`--to gemini`) aborted. Both lists are corrected to the registry's 19 runtime ids minus `vscode` (deliberately excluded and named in prose: `installSurface: 'none'`, `getGlobalSkillsBase('vscode')` returns `null`, so a sync to/from it cannot succeed) — 18 ids. A parity test (`tests/install.test.cjs`) now fails if the documented list and the registry disagree in either direction. This accounts for the growth (12,166 -> 12,510 bytes). Follow-up (this fix, the previously-deferred `DEST_SKILLS_ROOTS` defect noted above): that associative-array assignment is dropped entirely — it was never read back anywhere in the file and, on bash 3.2, silently collapsed every destination's resolved root into index `[0]`. Step 2's TO_RUNTIMES loop is kept for its eager per-destination validation (a bad runtime id anywhere in a multi-destination `--to` still aborts before any destination is touched) but no longer stores the resolved value. Steps 3 and 5 each now bind `DEST_ROOT` themselves at the top of their own bash block via `DEST_ROOT=$(gsd_run query skills-root \"$DEST_RUNTIME\" --raw)`, so every use of `$DEST_ROOT` is unambiguously scoped to the destination currently being processed in that construct, with no shared array and no bash4+ syntax. This accounts for the final growth (12,510 -> 13,480 bytes). Follow-up (delta-review BLOCKER: the previous fix's `grok`/`gemini` removal was itself wrong for `grok`): `grok` has a live, dedicated `~/.agents`-layout resolution branch in `getGlobalConfigDir` (`src/runtime-homes.cts`'s `LEGACY_NON_REGISTRY_RUNTIME_IDS`) predating the capability registry, and was documented pre-fix — removing it silently broke working, documented `--skills-root`/`sync-skills` support. `grok` is restored to both the \"Supported runtime names\" prose list and the `--to all` TO_RUNTIMES expansion, with a clause explaining why it is included despite being absent from the registry; `gemini` stays excluded (it has no dedicated branch and falls through to claude's skills root — the wrong-runtime bug this PR exists to fix). Separately, Step 3 (`ls`-based diff computation) re-resolved `DEST_ROOT` with no exit-status/empty guard, contradicting the file's own stated guarantee (\"never proceed to Step 3 or Step 5 with an empty or unresolved root\") even though Step 5 already carried that guard; Step 3 now carries the identical exit-status + non-empty check immediately after its `DEST_ROOT` resolution. This accounts for the final growth (13,480 -> 13,842 bytes)."
}
}
}