fix(#381): make gsd_run launcher reachable in fresh-shell-per-block runtimes (#1084)

* fix(#381): make gsd_run launcher reachable in fresh-shell-per-block runtimes

On runtimes that execute each fenced bash block in a separate shell process
(e.g. Claude Code — documented behavior: each Bash command is a separate
process; inline shell functions and exported vars do not persist between
calls), the once-per-file gsd_run() function was undefined in every block
after the preamble block, and the call was swallowed by
`2>/dev/null || echo "{}"` into silent empty state.

Fix (budget-neutral session-level resolution):
- Ship gsd-core/bin/gsd_run, a POSIX sh wrapper that symlink-resolves its own
  location and execs the co-located gsd-tools.cjs. Exposed on PATH via the npm
  `bin` field (global installs) and shipped to local installs via the recursive
  gsd-core/ copy.
- The per-file launcher preamble now appends `export PATH='<bindir>':"$PATH"`
  to the file named by $CLAUDE_ENV_FILE (Claude Code's documented
  env-persistence mechanism) so later fresh-shell blocks resolve gsd_run from
  PATH. Guarded as a strict no-op when CLAUDE_ENV_FILE is unset; the inline
  gsd_run() definition remains the fallback for all other runtimes. The
  single-quoted dir neutralizes shell metacharacters at source time.
- Propagated via scripts/sync-runtime-launcher.cjs to all launcher-using files.
- XL workflow byte budget 93000 -> 93200 (the ~130B clause pushes plan-phase.md
  to 93135; legitimate content growth, ratchet-up per #717).

Regression tests (I)/(J) in runtime-launcher-parity.test.cjs cover wrapper
delegation and end-to-end PATH persistence (sourcing the env file with a
space-bearing install path).

Known limitation: an install path containing a literal single-quote yields a
malformed env-file line and falls back to the status quo (no regression);
rare on sanitized home directories.

Closes #381

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

* docs(#381): add changeset for gsd_run fresh-shell reachability fix

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

* test(#381): scope test (J) bare-PATH execution to POSIX (Windows Git Bash exec bit)

Windows Git Bash (msys2) does not honor Node's chmod exec bit for
PATH-executing extension-less scripts, so the bare `gsd_run` command lookup
failed there even though the env-file PATH persistence was correct. The
env-file content assertions (the fix's actual cross-platform logic) still run
on every platform; only the final source-and-execute sub-step is gated to
non-win32. Global installs on Windows are covered by npm's generated bin shim.

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

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-11 21:42:52 -04:00
committed by GitHub
parent 2023f47c64
commit 7c07fce70f
98 changed files with 238 additions and 99 deletions

View File

@@ -546,6 +546,117 @@ describe('runtime-launcher-parity (#373)', () => {
});
});
// ─── Issue #381: standalone gsd_run executable + CLAUDE_ENV_FILE persistence ──
describe('runtime-launcher-parity — standalone executable (#381)', () => {
const BIN_DIR = path.join(__dirname, '..', 'gsd-core', 'bin');
const GSD_RUN_SRC = path.join(BIN_DIR, 'gsd_run');
// ─── (I) gsd_run executable delegates to gsd-tools.cjs beside it ──────────
test('(I) gsd_run executable delegates to gsd-tools.cjs beside it', () => {
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-381-I-'));
try {
const binDir = path.join(base, 'gsd-core', 'bin');
fs.mkdirSync(binDir, { recursive: true });
// Copy the real gsd_run executable into the temp bin dir
fs.copyFileSync(GSD_RUN_SRC, path.join(binDir, 'gsd_run'));
fs.chmodSync(path.join(binDir, 'gsd_run'), 0o755);
// Write a stub gsd-tools.cjs that echoes its args
fs.writeFileSync(
path.join(binDir, 'gsd-tools.cjs'),
`console.log('GSD_TOOLS_STUB:' + process.argv.slice(2).join(' '))`,
);
const stdout = execFileSync('sh', [path.join(binDir, 'gsd_run'), 'query', 'x'], {
encoding: 'utf8',
});
assert.ok(
stdout.includes('GSD_TOOLS_STUB:query x'),
`Expected stdout to contain "GSD_TOOLS_STUB:query x", got: ${stdout.trim()}`,
);
} finally {
cleanup(base);
}
});
// ─── (J) preamble persists bin dir to CLAUDE_ENV_FILE ─────────────────────
test('(J) preamble persists bin dir to CLAUDE_ENV_FILE so a fresh shell resolves gsd_run', () => {
// Use a RUNTIME_DIR whose path contains a SPACE to prove single-quote safety.
const baseParent = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-381-J-'));
const base = path.join(baseParent, 'has space');
try {
const binDir = path.join(base, 'gsd-core', 'bin');
fs.mkdirSync(binDir, { recursive: true });
// gsd_run stub that prints GSD_RUN_STUB:<args>
const gsdRunStub = path.join(binDir, 'gsd_run');
fs.writeFileSync(gsdRunStub, '#!/bin/sh\necho "GSD_RUN_STUB:$*"\n');
fs.chmodSync(gsdRunStub, 0o755);
// gsd-tools.cjs stub (must exist for preamble first arm to win)
fs.writeFileSync(path.join(binDir, 'gsd-tools.cjs'), '// stub');
const envFile = path.join(baseParent, 'envfile');
const snippet = fs.readFileSync(SNIPPET_FILE, 'utf8');
// Script: just source the preamble with RUNTIME_DIR + CLAUDE_ENV_FILE set
const preambleScript = path.join(baseParent, 'run-preamble.sh');
fs.writeFileSync(preambleScript, snippet);
execFileSync('bash', [preambleScript], {
encoding: 'utf8',
env: {
RUNTIME_DIR: base,
CLAUDE_ENV_FILE: envFile,
PATH: process.env.PATH,
},
});
// Assert envfile exists and the persisted line is single-quoted
assert.ok(fs.existsSync(envFile), `Expected CLAUDE_ENV_FILE (${envFile}) to be created after preamble runs`);
const envFileContent = fs.readFileSync(envFile, 'utf8');
// The persisted line must single-quote the directory (neutralising $, spaces, etc.)
// and keep "$PATH" expanding at source time.
// Expected form: export PATH='<dir>':"$PATH"
assert.ok(
envFileContent.includes("export PATH='"),
`Expected envfile to contain single-quoted export PATH line, got: ${envFileContent.trim()}`,
);
assert.ok(
envFileContent.includes('has space/gsd-core/bin'),
`Expected envfile to contain the spaced bin dir, got: ${envFileContent.trim()}`,
);
assert.ok(
envFileContent.includes(':"$PATH"'),
`Expected envfile to contain :"$PATH" (double-quoted, expands at source time), got: ${envFileContent.trim()}`,
);
// Windows Git Bash (msys2) does not honor Node's chmod exec bit for PATH-executing
// extension-less scripts; the env-file persistence above is the cross-platform proof.
// Global installs on Windows are covered by npm's generated bin shim.
if (process.platform !== 'win32') {
// Simulate a LATER fresh block that SOURCES the env file to get gsd_run on PATH.
// The later shell does NOT have the bin dir on PATH beforehand — it only gets it
// by sourcing the env file. We use a minimal PATH (no temp bin dir pre-injected).
const stdout = execFileSync('bash', ['-c', '. "$CLAUDE_ENV_FILE"; gsd_run hello'], {
encoding: 'utf8',
env: {
CLAUDE_ENV_FILE: envFile,
PATH: process.env.PATH,
},
});
assert.ok(
stdout.includes('GSD_RUN_STUB:hello'),
`Expected stdout to contain "GSD_RUN_STUB:hello" after sourcing env file, got: ${stdout.trim()}`,
);
}
} finally {
cleanup(baseParent);
}
});
});
// ─── Agent parity — runtime-launcher-parity — agents (#1041) ─────────────────
describe('runtime-launcher-parity — agents (#1041)', () => {
// ─── (B-agents) Exactly ONE canonical preamble per using agent file ────────

View File

@@ -81,9 +81,10 @@ const GRACE = 3000;
// current high-water mark within GRACE (#597 tighten-only ratchet).
// XL high-water mark is execute-phase.md — note that under LINES it was
// plan-phase; bytes genuinely re-rank the tier, which is the point of #717.
// actualMax=92525 (execute-phase, #913 inline-fallback scope clarification);
// slack=475 ≤ GRACE. plan-phase.md=90748 (#922 attempt-based Agent gate), new-project.md=58110.
const XL_BUDGET = 93000;
// actualMax=93130 (plan-phase, #381 CLAUDE_ENV_FILE persist clause in per-file launcher preamble);
// slack=70 ≤ GRACE. execute-phase.md=92880, new-project.md=61685.
// 93200: +200B headroom for the #381 CLAUDE_ENV_FILE persist clause added to every per-file launcher preamble (legit content growth, ratchet-up per #717).
const XL_BUDGET = 93200;
// LARGE high-water mark is docs-update.md. actualMax=54410 (#891 launcher shim expansion);
// slack=1590 ≤ GRACE. quick.md=45710, autonomous.md=38030.
const LARGE_BUDGET = 56000;
@@ -95,9 +96,9 @@ const DEFAULT_BUDGET = 40000;
// Grandfathered at current sizes — see PR #2551 for the progressive-disclosure
// pattern that future shrinks should follow. Byte counts noted for reference.
const XL_WORKFLOWS = new Set([
'execute-phase', // 92525 bytes (tier high-water mark; grew in #913 inline-fallback scope clarification)
'plan-phase', // 90748 bytes (grew in #922 attempt-based Agent gate)
'new-project', // 55850 bytes
'execute-phase', // 92880 bytes (grew in #381 CLAUDE_ENV_FILE persist clause)
'plan-phase', // 93130 bytes (tier high-water mark; grew in #381 CLAUDE_ENV_FILE persist clause)
'new-project', // 61685 bytes
]);
// Multi-step planners and bigger feature workflows. Grandfathered.