* feat(#3146): resolve gsd_run so workflows cannot reach a foreign gsd-tools The predecessor package get-shit-done-cc publishes a colliding gsd-tools bin whose phases.clear DELETES where this package's ARCHIVES, and both print success-shaped output against a gitignored .planning/ -- which is how #3129 cost a user 43 phase directories with no error and nothing recoverable from git. The launcher's PATH branch now resolves gsd_run, published only by this package and self-locating via its own symlink chain to the sibling shim, instead of the colliding gsd-tools. A foreign handler becomes unreachable from PATH, and when no gsd_run is reachable the resolver fails closed rather than falling back -- that fallback was the vulnerability. This is smaller than the branch it replaces, which matters: the preamble is inlined into 113 shipped files and agents/gsd-verifier.md sits 2 bytes under a red-line size cap. unset -f gsd_run leads the preamble so a re-source is idempotent. Without it, command -v finds the shell function, returns a bare name, and the resolver falls through to an exit 1 that kills a sourced caller's shell. Adds gsd-tools runtime-identity, a manual diagnostic reporting this runtime's package coordinates over the baked package-identity (#498) and readHostVersion, with a strict total classifier: only a JSON object with an exact packageName verifies, since JSON.parse admits 0/"str"/[]/null/true. An inlined identity assertion was built and reviewed first, then withdrawn -- it breaks five frozen size ceilings and no assertion fits in 2 bytes. Closes #3146 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3146): stop sync:launcher relocating a deliberate preamble placement Pre-existing defect, surfaced by this PR because sync is a no-op unless the snippet content actually changes. transformFile inserts the preamble into the first block that CALLS gsd_run, but gsd-core/workflows/explore.md deliberately places it in a bootstrap-only block that DEFINES gsd_run without calling it -- its own comment explains why: declining the research offer must not leave Step 5's commit call unbootstrapped. Stripping empties that block of calls, so the preamble migrated forward and broke the define-before-use invariant tests/explore-command.test.cjs pins. Reproduced on a pristine origin/next checkout with the base snippet and base file, so this was not introduced here. The insertion target now honours a block that already carried the preamble, falling back to the first calling block for files that have none yet. Adds a behavioral regression test over a two-block fixture. Also updates three runtime-launcher-parity tests that pinned the removed PATH fallback to gsd-tools. Their intent is preserved -- the PATH stub is renamed gsd_run so it is reachable by the new resolver, and the RUNTIME_DIR-wins test still asserts the stub is never invoked. Fixture shebangs move to an absolute /bin/sh, because the fixture PATH is deliberately restricted and #!/usr/bin/env sh could not resolve. Refs #3146 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#3146): backfill changeset PR number Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#3146): document the FEATURES.md section-numbering practice The monotonically increasing section number in docs/FEATURES.md is the most frequent merge-conflict source in this repo, and it has TWO conflict cells, not one: the ### N. heading and the hand-maintained table of contents. Two PRs adding differently numbered features still collide on the TOC, so renumbering alone does not make a branch safe. This branch alone was renumbered 165 -> 166 -> 167 -> 168 across successive rebases. Adds a CONTRIBUTING section stating the practice: allocate the number last, never pre-emptively renumber, take max+1 after a rebase and update the TOC in the same commit, and never renumber someone else's section. Fork contributors are told explicitly they may leave the number to a maintainer at merge rather than chasing the counter. Agents are told to lease the allocation and to include the file in their published touched set. Records the durable fix as planned rather than pretending it exists: FEATURES.md should be generated from per-feature fragments the way CHANGELOG.md is generated from .changeset/, and the way tests/emitted-drift-acks/ works (#2914). Also renumbers this branch's own section to 168, leaving 167 to the PR already in flight. Refs #3146 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -167,6 +167,49 @@ function delegatesToResolverReference(content) {
|
||||
}
|
||||
|
||||
describe('runtime-launcher-parity (#373)', () => {
|
||||
// ─── (#3146) Deliberate bootstrap-only preamble placement is preserved ────
|
||||
test('sync preserves a deliberate bootstrap-only preamble placement (#3146)', () => {
|
||||
// gsd-core/workflows/explore.md deliberately places the preamble in a
|
||||
// Step 1 block that DEFINES gsd_run but never CALLS it — its own comment
|
||||
// explains why: "Placed in Step 1 rather than Step 3 so declining the
|
||||
// research offer cannot leave Step 5's commit call unbootstrapped."
|
||||
// transformFile strips all blocks before re-inserting the preamble, so a
|
||||
// naive "first block that CALLS gsd_run" target would silently relocate
|
||||
// the preamble into the second block, breaking define-before-use. This
|
||||
// fixture reproduces that shape with two blocks: the first containing
|
||||
// ONLY the preamble (no gsd_run call), the second containing a gsd_run
|
||||
// call.
|
||||
const preamble = expectedPreamble();
|
||||
const preambleText = preamble.join('\n');
|
||||
|
||||
const fixture =
|
||||
'# Fixture\n\n' +
|
||||
'```bash\n' +
|
||||
preambleText + '\n' +
|
||||
'```\n\n' +
|
||||
'```bash\n' +
|
||||
'gsd_run query something\n' +
|
||||
'```\n';
|
||||
|
||||
const { transformFile } = require('../scripts/sync-runtime-launcher.cjs');
|
||||
const result = transformFile(fixture, preamble);
|
||||
|
||||
// transformFile returns null when no changes are needed (already correct
|
||||
// and idempotent); otherwise the transformed content.
|
||||
const finalContent = result === null ? fixture : result;
|
||||
|
||||
const markerIdx = finalContent.indexOf('_GSD_SHIM_NAME=');
|
||||
const useIdx = finalContent.indexOf('gsd_run query something');
|
||||
|
||||
assert.ok(markerIdx >= 0, 'expected the preamble marker to be present in the transformed fixture');
|
||||
assert.ok(useIdx >= 0, 'expected the gsd_run call to be present in the transformed fixture');
|
||||
assert.ok(
|
||||
markerIdx < useIdx,
|
||||
`expected preamble (index ${markerIdx}) to remain in the FIRST block, before the gsd_run call ` +
|
||||
`(index ${useIdx}) — the sync script must not relocate a deliberately-placed bootstrap-only preamble`,
|
||||
);
|
||||
});
|
||||
|
||||
// ─── (A) No retired GSD_SDK token ────────────────────────────────────────
|
||||
test('(A) no GSD_SDK token in any workflow .md file', () => {
|
||||
const files = collectWorkflowFiles();
|
||||
@@ -365,17 +408,22 @@ describe('runtime-launcher-parity (#373)', () => {
|
||||
});
|
||||
|
||||
// ─── (E) PATH fallback behavioral (#3668) ────────────────────────────────
|
||||
test('(E) PATH fallback: uses installed gsd-tools when no local gsd-tools.cjs present', () => {
|
||||
test('(E) PATH fallback: uses installed gsd_run when no local gsd-tools.cjs present', () => {
|
||||
// Create a temp dir with NO local gsd-core/bin/gsd-tools.cjs.
|
||||
// Place an executable gsd-tools stub on a dedicated PATH dir.
|
||||
// Place an executable gsd_run stub on a dedicated PATH dir.
|
||||
// RUNTIME_DIR points somewhere that has no gsd-tools.cjs.
|
||||
//
|
||||
// #3146: the PATH-fallback target changed from `gsd-tools` to `gsd_run`.
|
||||
// The predecessor package `get-shit-done-cc` publishes a colliding
|
||||
// `gsd-tools` bin, so the launcher resolves `gsd_run`, which only this
|
||||
// package publishes.
|
||||
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd 373 pathfb '));
|
||||
try {
|
||||
const pathBinDir = path.join(base, 'bin');
|
||||
fs.mkdirSync(pathBinDir, { recursive: true });
|
||||
|
||||
// Stub installed gsd-tools binary that prints a marker
|
||||
const stubPath = path.join(pathBinDir, 'gsd-tools');
|
||||
// Stub installed gsd_run binary that prints a marker
|
||||
const stubPath = path.join(pathBinDir, 'gsd_run');
|
||||
fs.writeFileSync(stubPath, '#!/bin/sh\necho "installed:$*"\n');
|
||||
fs.chmodSync(stubPath, 0o755);
|
||||
|
||||
@@ -397,14 +445,14 @@ describe('runtime-launcher-parity (#373)', () => {
|
||||
// The PATH fallback must have resolved GSD_TOOLS to the stub binary.
|
||||
// Normalize backslashes → forward slashes so the assertion works on Windows
|
||||
// (git-bash emits POSIX paths while Node's os.tmpdir() returns the Windows form).
|
||||
// Assert by suffix (/bin/gsd-tools, no .cjs extension) rather than absolute prefix
|
||||
// Assert by suffix (/bin/gsd_run, no .cjs extension) rather than absolute prefix
|
||||
// because the prefix differs between Windows and POSIX.
|
||||
// Use .+ (not \S*) to tolerate paths that contain spaces.
|
||||
const normStdout = stdout.replace(/\\/g, '/');
|
||||
assert.match(
|
||||
normStdout,
|
||||
/GSD_TOOLS=.+\/bin\/gsd-tools(?:\s|$)/m,
|
||||
`Expected GSD_TOOLS to resolve to the installed PATH stub (suffix /bin/gsd-tools), got: ${stdout.trim()}`,
|
||||
/GSD_TOOLS=.+\/bin\/gsd_run(?:\s|$)/m,
|
||||
`Expected GSD_TOOLS to resolve to the installed PATH stub (suffix /bin/gsd_run), got: ${stdout.trim()}`,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
normStdout,
|
||||
@@ -1564,10 +1612,15 @@ function writeExecutable(file, content) {
|
||||
}
|
||||
|
||||
describe('bug-3668: workflow SDK resolver supports installed user projects', { skip: process.platform === 'win32' }, () => {
|
||||
test('falls back to installed gsd-tools when project-local runtime copy is absent', () => {
|
||||
test('falls back to installed gsd_run when project-local runtime copy is absent', () => {
|
||||
// Bug #3668: when a user project has no local gsd-core/bin/gsd-tools.cjs,
|
||||
// the elif branch must resolve to the gsd-tools binary on PATH.
|
||||
// the elif branch must resolve to the gsd_run binary on PATH.
|
||||
// RUNTIME_DIR points to a dir that has no gsd-tools.cjs.
|
||||
//
|
||||
// #3146: the PATH-fallback target changed from `gsd-tools` to `gsd_run`.
|
||||
// The predecessor package `get-shit-done-cc` publishes a colliding
|
||||
// `gsd-tools` bin, so the launcher resolves `gsd_run`, which only this
|
||||
// package publishes.
|
||||
const tmp = makeTempDir();
|
||||
const project = path.join(tmp, 'user-project');
|
||||
const runtimeNoLocal = path.join(tmp, 'runtime-no-local');
|
||||
@@ -1575,9 +1628,9 @@ describe('bug-3668: workflow SDK resolver supports installed user projects', { s
|
||||
fs.mkdirSync(project, { recursive: true });
|
||||
fs.mkdirSync(runtimeNoLocal, { recursive: true });
|
||||
|
||||
// Place gsd-tools stub on PATH (installed binary)
|
||||
// Place gsd_run stub on PATH (installed binary)
|
||||
writeExecutable(
|
||||
path.join(pathBin, 'gsd-tools'),
|
||||
path.join(pathBin, 'gsd_run'),
|
||||
'#!/bin/sh\nprintf "installed:%s %s\\n" "$1" "$2"\n',
|
||||
);
|
||||
|
||||
@@ -1585,18 +1638,23 @@ describe('bug-3668: workflow SDK resolver supports installed user projects', { s
|
||||
const output = runResolver({ cwd: project, runtimeDir: runtimeNoLocal, pathDir: pathBin });
|
||||
|
||||
// GSD_TOOLS must have been reassigned to the PATH binary (not the missing .cjs)
|
||||
assert.match(output, /GSD_TOOLS=.+gsd-tools(?:\s|$)/m);
|
||||
assert.match(output, /GSD_TOOLS=.+gsd_run(?:\s|$)/m);
|
||||
// The PATH stub must have been invoked
|
||||
assert.match(output, /installed:query state\.json/);
|
||||
});
|
||||
|
||||
test('preserves RUNTIME_DIR local gsd-tools.cjs preference over PATH fallback', () => {
|
||||
// #3146: the PATH-fallback target changed from `gsd-tools` to `gsd_run`
|
||||
// (the predecessor package `get-shit-done-cc` publishes a colliding
|
||||
// `gsd-tools` bin, so the launcher resolves `gsd_run` instead), but the
|
||||
// point of this test is unchanged: a RUNTIME_DIR-local gsd-tools.cjs must
|
||||
// win over the PATH stub, and the PATH stub must never be invoked.
|
||||
const tmp = makeTempDir();
|
||||
const project = path.join(tmp, 'user-project');
|
||||
const runtime = path.join(tmp, 'runtime');
|
||||
const pathBin = path.join(tmp, 'bin');
|
||||
fs.mkdirSync(project, { recursive: true });
|
||||
writeExecutable(path.join(pathBin, 'gsd-tools'), '#!/bin/sh\nprintf "path-installed:%s %s\\n" "$1" "$2"\n');
|
||||
writeExecutable(path.join(pathBin, 'gsd_run'), '#!/bin/sh\nprintf "path-installed:%s %s\\n" "$1" "$2"\n');
|
||||
writeExecutable(
|
||||
path.join(runtime, 'gsd-core', 'bin', 'gsd-tools.cjs'),
|
||||
'#!/usr/bin/env node\nconsole.log(`runtime:${process.argv[2]} ${process.argv[3]}`);\n',
|
||||
|
||||
Reference in New Issue
Block a user