mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-08 09:48:20 +01:00
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>
This commit is contained in:
1 parent
74836e41d6
commit
c6d6e7f398
3 files changed
+109
-8
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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<string, string> = { BIN: 'bin', BROWSE: 'browse/dist', DESIGN: 'design/dist', MAKE_PDF: 'make-pdf/dist' };
|
||||
|
||||
@@ -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<string, string>) =>
|
||||
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');
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user