From c6d6e7f398860d6fed10dc2ab6de9cab6fc54006 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 21:22:43 +0000 Subject: [PATCH] fix: plain skill-start fence that a static resolver can follow (GST-882) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .../preamble/generate-preamble-bash.ts | 9 ++- scripts/resolvers/runtime-root.ts | 38 +++++++++- test/skill-start-plain.test.ts | 70 +++++++++++++++++++ 3 files changed, 109 insertions(+), 8 deletions(-) create mode 100644 test/skill-start-plain.test.ts diff --git a/scripts/resolvers/preamble/generate-preamble-bash.ts b/scripts/resolvers/preamble/generate-preamble-bash.ts index f31997570..87f6f8935 100644 --- a/scripts/resolvers/preamble/generate-preamble-bash.ts +++ b/scripts/resolvers/preamble/generate-preamble-bash.ts @@ -1,6 +1,6 @@ import type { TemplateContext } from '../types'; import { getHostConfig } from '../../../hosts/index'; -import { runtimeRootPrelude } from '../runtime-root'; +import { skillStartLines } from '../runtime-root'; /** * Preamble bootstrap (token-reduction Phase 1). @@ -25,11 +25,10 @@ import { runtimeRootPrelude } from '../runtime-root'; */ export function generatePreambleBash(ctx: TemplateContext): string { const hostConfig = getHostConfig(ctx.host); - const runtimeRoot = hostConfig.usesEnvVars ? `${runtimeRootPrelude(ctx)}\n` : ''; + const start = hostConfig.usesEnvVars ? skillStartLines(ctx) : null; + const runtimeRoot = start ? `${start.lookup}\n` : ''; const brainHealthFlag = ctx.host === 'gbrain' || ctx.host === 'hermes' ? ' --brain-health' : ''; - const startCommand = hostConfig.usesEnvVars - ? `"${ctx.paths.binDir}/gstack-skill-start"` - : `${ctx.paths.binDir}/gstack-skill-start`; + const startCommand = start ? start.command : `${ctx.paths.binDir}/gstack-skill-start`; const entry = ['plan-design-review', 'plan-eng-review'].includes(ctx.skillName) ? `## Preamble (after scope gate) diff --git a/scripts/resolvers/runtime-root.ts b/scripts/resolvers/runtime-root.ts index 31df6b168..9617e606b 100644 --- a/scripts/resolvers/runtime-root.ts +++ b/scripts/resolvers/runtime-root.ts @@ -37,13 +37,45 @@ const uses = (name: string) => new RegExp(`\\$\\{?${name}\\b`); */ function resolveRoot(ctx: TemplateContext): string { const host = getHostConfig(ctx.host); - const fix = `Fix: ./setup --host ${host.name} from your gstack checkout; ./setup --status shows it.`; + const fix = installFix(ctx); const exported = '[ -d "${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||'; if (ctx.installRoot) { return `${exported}{ GSTACK_ROOT="${ctx.installRoot.replace(/\/+$/, '')}";[ -d "$GSTACK_ROOT/bin" ]||{ echo "gstack: no install found (tried $GSTACK_ROOT). ${fix}">&2;exit 1;};}`; } - const global = host.name === 'codex' ? '${CODEX_HOME:-~/.codex}/skills/gstack' : `~/${host.globalRoot}`; - return `${exported}{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/${host.localSkillRoot};[ -d "$_r/bin" ]||_r=${global};[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). ${fix}">&2;exit 1;};GSTACK_ROOT=$_r;}`; + return `${exported}{ _r=$(git rev-parse --show-toplevel 2>/dev/null)/${host.localSkillRoot};[ -d "$_r/bin" ]||_r=${globalRoot(ctx)};[ -d "$_r/bin" ]||{ echo "gstack: no install found (tried $_r). ${fix}">&2;exit 1;};GSTACK_ROOT=$_r;}`; +} + +const installFix = (ctx: TemplateContext) => `Fix: ./setup --host ${getHostConfig(ctx.host).name} from your gstack checkout; ./setup --status shows it.`; +const globalRoot = (ctx: TemplateContext) => { + const host = getHostConfig(ctx.host); + return host.name === 'codex' ? '${CODEX_HOME:-~/.codex}/skills/gstack' : `~/${host.globalRoot}`; +}; + +/** + * The skill-start fence on env-var hosts (GST-882). Hermes's Tirith blocks a + * start command it cannot resolve statically, a grouped fallback or a + * variable-selected path alike, and no allowlist entry clears that finding. An + * install-root render therefore names the literal start path; a checked-in + * render keeps resolveRoot's lookup order as plain lines. Either way a missing + * install prints the fix, and the call then fails. + */ +export function skillStartLines(ctx: TemplateContext): { lookup: string; command: string } { + const fix = installFix(ctx); + if (ctx.installRoot) { + const root = ctx.installRoot.replace(/\/+$/, ''); + return { + lookup: `[ -x "${root}/bin/gstack-skill-start" ]||echo "gstack: no install found (tried ${root}). ${fix}">&2`, + command: `"${root}/bin/gstack-skill-start"`, + }; + } + return { + lookup: [ + `[ -d "\${GSTACK_ROOT:-/-}/bin" ]&&[ -d "$GSTACK_ROOT/lib" ]||GSTACK_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)/${getHostConfig(ctx.host).localSkillRoot}`, + `[ -d "$GSTACK_ROOT/bin" ]||GSTACK_ROOT=${globalRoot(ctx)}`, + `[ -d "$GSTACK_ROOT/bin" ]||echo "gstack: no install found (tried $GSTACK_ROOT). ${fix}">&2`, + ].join('\n'), + command: '"$GSTACK_ROOT/bin/gstack-skill-start"', + }; } const DERIVED: Record = { BIN: 'bin', BROWSE: 'browse/dist', DESIGN: 'design/dist', MAKE_PDF: 'make-pdf/dist' }; diff --git a/test/skill-start-plain.test.ts b/test/skill-start-plain.test.ts new file mode 100644 index 000000000..07f1b56a6 --- /dev/null +++ b/test/skill-start-plain.test.ts @@ -0,0 +1,70 @@ +/** + * GST-882: the skill-start fence must be plain shell a static resolver can + * follow. Hermes's Tirith 0.4.2 blocks a grouped fallback + * (`[ -d … ]||{ …; exit 1;}`) and any variable-selected command with HIGH + * "Nested executable body could not be resolved", which no allowlist entry can + * clear, so every gstack skill start was denied. An install-root render names + * the literal start path; every render keeps a loud failure when gstack is + * missing. + */ +import { describe, expect, test } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { ALL_HOST_CONFIGS } from '../hosts'; +import { generatePreambleBash } from '../scripts/resolvers/preamble/generate-preamble-bash'; +import { insertRuntimePreludes } from '../scripts/resolvers/runtime-root'; +import { HOST_PATHS, type TemplateContext } from '../scripts/resolvers/types'; + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gst882-')); +const ctx = (host: string, installRoot: string | null) => + ({ host, paths: HOST_PATHS[host], skillName: 'ship', tmplPath: '', model: 'claude', installRoot }) as TemplateContext; +const startFence = (c: TemplateContext) => insertRuntimePreludes(generatePreambleBash(c), c).match(/```bash\n([\s\S]*?)\n```/)![1]; +const GROUPED = /(?:^|[;&|]\s*)\{\s|exit 1;\s*\}/m; +const sh = (body: string, env: Record) => + spawnSync('bash', ['-c', body], { cwd: tmp, encoding: 'utf8', timeout: 10_000, env: { PATH: '/usr/bin:/bin', ...env } }); +function stubStart(root: string) { + fs.mkdirSync(path.join(root, 'bin'), { recursive: true }); + fs.mkdirSync(path.join(root, 'lib'), { recursive: true }); + fs.writeFileSync(path.join(root, 'bin', 'gstack-skill-start'), '#!/bin/sh\necho "STARTED $0 $*"\n', { mode: 0o755 }); +} + +describe('skill-start fence is plain shell on every host (GST-882)', () => { + for (const host of ALL_HOST_CONFIGS) { + for (const installRoot of [null, path.join(tmp, `${host.name}-install`)]) { + const label = `${host.name} ${installRoot ? 'install-root' : 'checked-in'} render`; + test(`${label}: no grouped fallback`, () => { + expect(startFence(ctx(host.name, installRoot))).not.toMatch(GROUPED); + }); + if (!host.usesEnvVars) continue; + test(`${label}: a missing install fails loudly`, () => { + const r = sh(startFence(ctx(host.name, installRoot)), { HOME: path.join(tmp, 'empty-home') }); + expect(r.status).not.toBe(0); + expect(r.stderr).toContain('gstack: no install found'); + expect(r.stderr).toContain(`./setup --host ${host.name}`); + }); + } + if (!host.usesEnvVars) continue; + test(`${host.name} install-root render: the start command is a literal path that runs`, () => { + const root = path.join(tmp, `${host.name}-literal`); + const fence = startFence(ctx(host.name, root)); + const command = fence.split('\n').at(-1)!; + expect(command).toStartWith(`"${root}/bin/gstack-skill-start" --skill "ship"`); + expect(fence).not.toContain('$'); + stubStart(root); + const r = sh(fence, { HOME: path.join(tmp, 'empty-home') }); + expect(r.status, r.stderr).toBe(0); + expect(r.stdout).toContain(`STARTED ${root}/bin/gstack-skill-start --skill ship`); + }); + test(`${host.name} checked-in render: finds the global install`, () => { + const home = path.join(tmp, `${host.name}-home`); + const global = path.join(home, host.name === 'codex' ? '.codex/skills/gstack' : host.globalRoot); + stubStart(global); + const r = sh(startFence(ctx(host.name, null)), { HOME: home }); + expect(r.status, r.stderr).toBe(0); + expect(r.stdout).toContain('STARTED '); + expect(r.stdout).toContain('--skill ship'); + }); + } +});