diff --git a/hosts/codex.ts b/hosts/codex.ts index e447b8153..0f2594626 100644 --- a/hosts/codex.ts +++ b/hosts/codex.ts @@ -20,8 +20,9 @@ const codex = defineHost({ // generateMetadata emits agents/openai.yaml (the format is hardcoded in // gen-skill-docs.ts). Codex also gets a repo-local sidecar at - // .agents/skills/gstack (symlinked runtime assets: bin, browse, review, qa, - // ETHOS.md) — that behavior lives in setup's create_agents_sidecar, not here. + // .agents/skills/gstack (symlinked runtime assets: bin, lib, browse, review, + // qa, design/dist, make-pdf/dist, ETHOS.md) — that behavior lives in setup's + // create_agents_sidecar, not here. generation: { generateMetadata: true, skipSkills: ['codex'], @@ -38,6 +39,15 @@ const codex = defineHost({ { from: 'CLAUDE.md', to: 'AGENTS.md' }, ], + // Mirrors create_codex_runtime_root in setup. design/dist and make-pdf/dist + // back $GSTACK_DESIGN and $GSTACK_MAKE_PDF (#2891). + runtimeRoot: { + globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'design/dist', 'make-pdf/dist', 'gstack-upgrade', 'ETHOS.md'], + globalFiles: { + 'review': ['checklist.md', 'design-checklist.md', 'greptile-triage.md', 'TODOS-format.md'], + }, + }, + // Outside-review resolvers route to Claude Code; Review Army has its own restriction. suppressedResolvers: ['REVIEW_ARMY', ...GBRAIN_RESOLVERS], diff --git a/hosts/define-host.ts b/hosts/define-host.ts index d2b9927e1..a90010e37 100644 --- a/hosts/define-host.ts +++ b/hosts/define-host.ts @@ -126,7 +126,7 @@ export function defineHost(overrides: HostOverrides): toolRewrites, suppressedResolvers = [...GBRAIN_RESOLVERS], runtimeRoot = { - globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'], + globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'design/dist', 'make-pdf/dist', 'gstack-upgrade', 'ETHOS.md'], globalFiles: { 'review': ['checklist.md', 'TODOS-format.md'], }, diff --git a/hosts/opencode.ts b/hosts/opencode.ts index 30da3f7ea..038267a0f 100644 --- a/hosts/opencode.ts +++ b/hosts/opencode.ts @@ -14,7 +14,7 @@ const opencode = defineHost({ // OpenCode links a wider runtime asset set than the shared default // (design binary, review specialists, qa templates/references, DX hall of fame). runtimeRoot: { - globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'design/dist', 'gstack-upgrade', 'ETHOS.md', 'review/specialists', 'qa/templates', 'qa/references', 'plan-devex-review/dx-hall-of-fame.md'], + globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'design/dist', 'make-pdf/dist', 'gstack-upgrade', 'ETHOS.md', 'review/specialists', 'qa/templates', 'qa/references', 'plan-devex-review/dx-hall-of-fame.md'], globalFiles: { 'review': ['checklist.md', 'design-checklist.md', 'greptile-triage.md', 'TODOS-format.md'], }, diff --git a/setup b/setup index cbb1ed14c..87c9573e9 100755 --- a/setup +++ b/setup @@ -496,6 +496,21 @@ _link_or_copy() { fi } +# _link_runtime_dists SOURCE ROOT — the compiled design and make-pdf binaries +# behind $GSTACK_DESIGN and $GSTACK_MAKE_PDF on every env-var host (#2891). +# dist/ only, never the whole directory: make-pdf/ ships a Claude SKILL.md a +# host scanning its skills root would discover as a duplicate skill. +_link_runtime_dists() { + local d + for d in design/dist make-pdf/dist; do + [ -d "$1/$d" ] || continue + if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$2/$d" ] || [ ! -e "$2/$d" ]; then + mkdir -p "$2/${d%/dist}" + _link_or_copy "$1/$d" "$2/$d" + fi + done +} + # ─── Ownership gate for skill entries (#2119) ───────────────────────────────── # setup and gstack-relink must never delete or link over a skill they do not # own. This is the single rule both use (relink carries the same logic — keep @@ -2322,6 +2337,8 @@ create_agents_sidecar() { fi done + _link_runtime_dists "$SOURCE_GSTACK_DIR" "$agents_gstack" + # supabase/config.sh — required by gstack-telemetry-sync to resolve GSTACK_SUPABASE_URL # (file-level on purpose: migrations/ and functions/ are dev-only) if [ -f "$SOURCE_GSTACK_DIR/supabase/config.sh" ]; then @@ -2398,6 +2415,7 @@ create_codex_runtime_root() { if [ -d "$gstack_dir/browse/bin" ]; then _link_or_copy "$gstack_dir/browse/bin" "$codex_gstack/browse/bin" fi + _link_runtime_dists "$gstack_dir" "$codex_gstack" if [ -f "$agents_dir/gstack-upgrade/SKILL.md" ]; then _link_or_copy "$agents_dir/gstack-upgrade/SKILL.md" "$codex_gstack/gstack-upgrade/SKILL.md" fi @@ -2453,6 +2471,7 @@ create_factory_runtime_root() { if [ -d "$gstack_dir/browse/bin" ]; then _link_or_copy "$gstack_dir/browse/bin" "$factory_gstack/browse/bin" fi + _link_runtime_dists "$gstack_dir" "$factory_gstack" if [ -f "$factory_dir/gstack-upgrade/SKILL.md" ]; then _link_or_copy "$factory_dir/gstack-upgrade/SKILL.md" "$factory_gstack/gstack-upgrade/SKILL.md" fi @@ -2489,7 +2508,7 @@ create_opencode_runtime_root() { rm -rf "$opencode_gstack" fi - mkdir -p "$opencode_gstack" "$opencode_gstack/browse" "$opencode_gstack/design" "$opencode_gstack/gstack-upgrade" "$opencode_gstack/review" "$opencode_gstack/qa" "$opencode_gstack/plan-devex-review" + mkdir -p "$opencode_gstack" "$opencode_gstack/browse" "$opencode_gstack/gstack-upgrade" "$opencode_gstack/review" "$opencode_gstack/qa" "$opencode_gstack/plan-devex-review" if [ -f "$opencode_dir/gstack/SKILL.md" ]; then _link_or_copy "$opencode_dir/gstack/SKILL.md" "$opencode_gstack/SKILL.md" @@ -2506,9 +2525,7 @@ create_opencode_runtime_root() { if [ -d "$gstack_dir/browse/bin" ]; then _link_or_copy "$gstack_dir/browse/bin" "$opencode_gstack/browse/bin" fi - if [ -d "$gstack_dir/design/dist" ]; then - _link_or_copy "$gstack_dir/design/dist" "$opencode_gstack/design/dist" - fi + _link_runtime_dists "$gstack_dir" "$opencode_gstack" if [ -f "$opencode_dir/gstack-upgrade/SKILL.md" ]; then _link_or_copy "$opencode_dir/gstack-upgrade/SKILL.md" "$opencode_gstack/gstack-upgrade/SKILL.md" fi @@ -2671,6 +2688,7 @@ create_cursor_runtime_root() { if [ -d "$gstack_dir/browse/bin" ]; then _link_or_copy "$gstack_dir/browse/bin" "$cursor_gstack/browse/bin" fi + _link_runtime_dists "$gstack_dir" "$cursor_gstack" if [ -f "$cursor_dir/gstack-upgrade/SKILL.md" ]; then _link_or_copy "$cursor_dir/gstack-upgrade/SKILL.md" "$cursor_gstack/gstack-upgrade/SKILL.md" fi @@ -2728,6 +2746,7 @@ create_cursor_sidecar() { _link_or_copy "$repo_root/browse/bin" "$cursor_gstack/browse/bin" fi fi + _link_runtime_dists "$repo_root" "$cursor_gstack" if [ -f "$cursor_dir/gstack-upgrade/SKILL.md" ]; then if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$cursor_gstack/gstack-upgrade/SKILL.md" ] || [ ! -e "$cursor_gstack/gstack-upgrade/SKILL.md" ]; then _link_or_copy "$cursor_dir/gstack-upgrade/SKILL.md" "$cursor_gstack/gstack-upgrade/SKILL.md" @@ -2838,6 +2857,7 @@ create_copilot_runtime_root() { _link_or_copy "$gstack_dir/$asset" "$copilot_gstack/$asset" fi done + _link_runtime_dists "$gstack_dir" "$copilot_gstack" if [ -f "$copilot_dir/gstack-upgrade/SKILL.md" ]; then _link_or_copy "$copilot_dir/gstack-upgrade/SKILL.md" "$copilot_gstack/gstack-upgrade/SKILL.md" fi @@ -3122,6 +3142,7 @@ if [ "$INSTALL_KIRO" -eq 1 ]; then _link_or_copy "$SOURCE_GSTACK_DIR/lib" "$KIRO_GSTACK/lib" _link_or_copy "$SOURCE_GSTACK_DIR/browse/dist" "$KIRO_GSTACK/browse/dist" _link_or_copy "$SOURCE_GSTACK_DIR/browse/bin" "$KIRO_GSTACK/browse/bin" + _link_runtime_dists "$SOURCE_GSTACK_DIR" "$KIRO_GSTACK" if [ -f "$SOURCE_GSTACK_DIR/ETHOS.md" ]; then _link_or_copy "$SOURCE_GSTACK_DIR/ETHOS.md" "$KIRO_GSTACK/ETHOS.md" fi diff --git a/test/setup-codex-design-pdf-runtime.test.ts b/test/setup-codex-design-pdf-runtime.test.ts new file mode 100644 index 000000000..86a1a4416 --- /dev/null +++ b/test/setup-codex-design-pdf-runtime.test.ts @@ -0,0 +1,158 @@ +import { describe, test, expect } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; +import { HOST_PATHS, type TemplateContext } from '../scripts/resolvers/types'; +import { generateDesignSetup } from '../scripts/resolvers/design'; +import { generateMakePdfSetup } from '../scripts/resolvers/make-pdf'; + +// #2891 (C2): Codex design and make-pdf skills resolve their binaries from the +// repo-local .agents sidecar first, then from $GSTACK_DESIGN / $GSTACK_MAKE_PDF +// under the global ~/.codex/skills/gstack root. setup never linked either +// dist/ into those roots, so both preflights printed *_NOT_AVAILABLE on a +// fresh install even though the binaries were built. +// +// Each root is built with the real setup shell functions, then the bash block +// the generator emits for Codex runs in a fresh process (the way Codex runs +// every shell call). + +const ROOT = path.resolve(import.meta.dir, '..'); +const SETUP_SRC = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8'); + +function extractFunction(name: string): string { + const start = SETUP_SRC.indexOf(`\n${name}() {`); + const end = SETUP_SRC.indexOf('\n}\n', start); + if (start < 0 || end < 0) throw new Error(`Could not locate ${name}() in setup`); + return SETUP_SRC.slice(start + 1, end + 2); +} + +function codexCtx(skillName: string): TemplateContext { + return { skillName, tmplPath: `${skillName}/SKILL.md.tmpl`, host: 'codex', paths: HOST_PATHS.codex }; +} + +function firstBashBlock(rendered: string): string { + const m = rendered.match(/```bash\n([\s\S]*?)```/); + if (!m) throw new Error('no bash block in rendered section'); + return m[1]; +} + +const DESIGN_PREFLIGHT = firstBashBlock(generateDesignSetup(codexCtx('design-shotgun'))); +const MAKE_PDF_PREFLIGHT = firstBashBlock(generateMakePdfSetup(codexCtx('make-pdf'))); + +// A minimal gstack source tree: both built binaries, plus make-pdf's own +// SKILL.md and design sources, which must NOT leak into a Codex-scanned root. +function makeSource(sandbox: string): string { + const src = path.join(sandbox, 'gstack'); + for (const [rel, body] of [ + ['design/dist/design', '#!/bin/sh\necho design\n'], + ['make-pdf/dist/pdf', '#!/bin/sh\necho pdf\n'], + ] as const) { + fs.mkdirSync(path.dirname(path.join(src, rel)), { recursive: true }); + fs.writeFileSync(path.join(src, rel), body, { mode: 0o755 }); + } + fs.mkdirSync(path.join(src, 'design', 'src'), { recursive: true }); + fs.writeFileSync(path.join(src, 'make-pdf', 'SKILL.md'), '---\nname: make-pdf\n---\n'); + return src; +} + +function run(cmd: string, opts: { cwd: string; env?: Record }) { + const env: Record = { PATH: process.env.PATH ?? '/usr/bin:/bin', ...opts.env }; + return spawnSync('bash', ['-c', cmd], { cwd: opts.cwd, encoding: 'utf-8', timeout: 30000, env }); +} + +interface Built { script: string; rootDir: string; preflight: { cwd: string; env: Record } } + +const BUILDERS: Record Built> = { + 'codex global root': (sandbox, src) => { + const rootDir = path.join(sandbox, 'home', '.codex', 'skills', 'gstack'); + const outside = path.join(sandbox, 'not-a-repo'); + fs.mkdirSync(outside, { recursive: true }); + return { + script: `create_codex_runtime_root "${src}" "${rootDir}"`, + rootDir, + preflight: { + cwd: outside, + env: { + HOME: path.join(sandbox, 'home'), + GSTACK_DESIGN: path.join(rootDir, 'design', 'dist'), + GSTACK_MAKE_PDF: path.join(rootDir, 'make-pdf', 'dist'), + }, + }, + }; + }, + 'agents sidecar': (sandbox, src) => { + const repo = path.join(sandbox, 'repo'); + fs.mkdirSync(repo, { recursive: true }); + spawnSync('git', ['init', '-q'], { cwd: repo, timeout: 30000 }); + return { + script: `SOURCE_GSTACK_DIR="${src}"\ncreate_agents_sidecar "${repo}"`, + rootDir: path.join(repo, '.agents', 'skills', 'gstack'), + preflight: { + cwd: repo, + env: { + HOME: path.join(sandbox, 'home'), + GSTACK_DESIGN: path.join(sandbox, 'missing'), + GSTACK_MAKE_PDF: path.join(sandbox, 'missing'), + }, + }, + }; + }, +}; + +const FUNCTIONS = ['_link_or_copy', '_link_runtime_dists', '_sidecar_root_user_owned', '_gstack_generated_header', 'create_agents_sidecar', 'create_codex_runtime_root'] + .filter(name => SETUP_SRC.includes(`\n${name}() {`)) + .map(extractFunction) + .join('\n'); + +describe.skipIf(process.platform === 'win32')('setup: Codex roots expose design and make-pdf binaries (#2891)', () => { + for (const [name, build] of Object.entries(BUILDERS)) { + for (const isWindows of ['0', '1'] as const) { + const mode = isWindows === '1' ? 'Windows copy install' : 'symlink install'; + test(`${name} (${mode}): design and make-pdf preflights report READY`, () => { + const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-codex-dist-')); + try { + const src = makeSource(sandbox); + const built = build(sandbox, src); + const b = run(`IS_WINDOWS=${isWindows}\n${FUNCTIONS}\n${built.script}`, { cwd: sandbox }); + expect(b.stderr).toBe(''); + expect(b.status).toBe(0); + + expect(fs.lstatSync(path.join(built.rootDir, 'design', 'dist')).isSymbolicLink()).toBe(isWindows === '0'); + expect(fs.existsSync(path.join(built.rootDir, 'make-pdf', 'SKILL.md'))).toBe(false); + expect(fs.existsSync(path.join(built.rootDir, 'design', 'src'))).toBe(false); + + const d = run(DESIGN_PREFLIGHT, built.preflight); + expect(d.stdout).toContain('DESIGN_READY:'); + expect(d.stdout).not.toContain('DESIGN_NOT_AVAILABLE'); + const p = run(MAKE_PDF_PREFLIGHT, built.preflight); + expect(p.stdout).toContain('MAKE_PDF_READY:'); + expect(p.stdout).not.toContain('MAKE_PDF_NOT_AVAILABLE'); + } finally { + fs.rmSync(sandbox, { recursive: true, force: true }); + } + }); + } + } + + // Negative control: a root without design/dist and make-pdf/dist fails both + // preflights, so the READY cells are not vacuous. + test('a Codex root without design/dist and make-pdf/dist reports NOT_AVAILABLE', () => { + const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-codex-dist-')); + try { + const rootDir = path.join(sandbox, 'home', '.codex', 'skills', 'gstack'); + fs.mkdirSync(path.join(rootDir, 'browse'), { recursive: true }); + const outside = path.join(sandbox, 'not-a-repo'); + fs.mkdirSync(outside, { recursive: true }); + const env = { + HOME: path.join(sandbox, 'home'), + GSTACK_DESIGN: path.join(rootDir, 'design', 'dist'), + GSTACK_MAKE_PDF: path.join(rootDir, 'make-pdf', 'dist'), + }; + expect(run(DESIGN_PREFLIGHT, { cwd: outside, env }).stdout).toContain('DESIGN_NOT_AVAILABLE'); + expect(run(MAKE_PDF_PREFLIGHT, { cwd: outside, env }).stdout).toContain('MAKE_PDF_NOT_AVAILABLE'); + } finally { + fs.rmSync(sandbox, { recursive: true, force: true }); + } + }); +}); diff --git a/test/setup-kiro-native.test.ts b/test/setup-kiro-native.test.ts index aac6d8e8a..229a37529 100644 --- a/test/setup-kiro-native.test.ts +++ b/test/setup-kiro-native.test.ts @@ -17,7 +17,7 @@ const fn = (name: string) => { const blockStart = setup.indexOf('# 6. Install for Kiro CLI'); const block = setup.slice(blockStart, setup.indexOf('# 6b.', blockStart)); const helpers = [ - '_link_or_copy', '_sidecar_root_user_owned', '_claude_entry_is_ours', + '_link_or_copy', '_link_runtime_dists', '_sidecar_root_user_owned', '_claude_entry_is_ours', '_claude_entry_owned_strongly', '_gstack_link_target_abs', '_gstack_target_is_ours', '_gstack_generated_header', '_backup_skill_md', '_prune_stale_generated', '_skill_source_exists', '_owned_for_windows_refresh', '_cleanup_weak_dir', diff --git a/test/setup-windows-rerun-refresh.test.ts b/test/setup-windows-rerun-refresh.test.ts index b68ad23a1..f9e18401d 100644 --- a/test/setup-windows-rerun-refresh.test.ts +++ b/test/setup-windows-rerun-refresh.test.ts @@ -159,6 +159,7 @@ function runInstaller( `IS_WINDOWS=${isWindows}`, extraVars, extractFn('_link_or_copy'), + extractFn('_link_runtime_dists'), // Ownership gates (#2142) — dependencies of every installer under test. extractFn('_owned_for_windows_refresh'), extractFn('_sidecar_root_user_owned'),