From a48776a1b9721fecdacabec2cc7e8eb199e26e60 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 14 Jul 2026 12:57:31 -0700 Subject: [PATCH 01/14] docs(readme): separate optional state purge from manual uninstall --- README.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 4bb177c3a7..cbe2eb276c 100644 --- a/README.md +++ b/README.md @@ -331,6 +331,8 @@ This handles skills, symlinks, global state (`~/.gstack/`), project-local state, If you don't have the repo cloned (e.g. you installed via a Claude Code paste and later deleted the clone): +Mirrors `gstack-uninstall --keep-state` (preserves `~/.gstack/` data): + ```bash # 1. Stop browse daemons pkill -f "gstack.*browse" 2>/dev/null || true @@ -352,23 +354,28 @@ done # 3. Remove gstack rm -rf ~/.claude/skills/gstack -# 4. Remove global state -rm -rf ~/.gstack - -# 5. Remove integrations (skip any you never installed) +# 4. Remove integrations (skip any you never installed) rm -rf ~/.codex/skills/gstack* 2>/dev/null rm -rf ~/.factory/skills/gstack* 2>/dev/null rm -rf ~/.kiro/skills/gstack* 2>/dev/null rm -rf ~/.openclaw/skills/gstack* 2>/dev/null -# 6. Remove temp files +# 5. Remove temp files rm -f /tmp/gstack-* 2>/dev/null -# 7. Per-project cleanup (run from each project root) +# 6. Per-project cleanup (run from each project root) rm -rf .gstack .gstack-worktrees .claude/skills/gstack 2>/dev/null rm -rf .agents/skills/gstack* .factory/skills/gstack* 2>/dev/null ``` +#### Also purge gstack data (optional) + +`~/.gstack/` holds config, analytics, sessions, project history, and the installation-id. Run this only if you want a clean slate (equivalent to `gstack-uninstall` without `--keep-state`): + +```bash +rm -rf ~/.gstack +``` + ### Clean up CLAUDE.md The uninstall script does not edit CLAUDE.md. In each project where gstack was added, remove the `## gstack` and `## Skill routing` sections. From f1e61f432f46ff329c1b58fd24912ed05f29e917 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 14 Jul 2026 12:57:31 -0700 Subject: [PATCH 02/14] fix(setup): make help side-effect free --- setup | 34 ++++++++++++++++++++++ test/setup-help.test.ts | 64 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 test/setup-help.test.ts diff --git a/setup b/setup index 275236cd36..3dddb1f81b 100755 --- a/setup +++ b/setup @@ -3,6 +3,40 @@ set -e umask 077 # Restrict new files to owner-only (0o600 files, 0o700 dirs) +usage() { + cat <<'EOF' +gstack setup — install gstack skills + build browse binary + +Usage: ./setup [options] + +Options: + --host Install for a specific host (claude, codex, kiro, factory, + opencode, openclaw, hermes, gbrain, auto). Default: claude. + --prefix Install skills with the gstack- prefix (e.g. /gstack-review). + --no-prefix Install skills with short names (e.g. /review). Default. + --team Switch to team mode (per-repo gstack with auto-update). + --no-team Force solo install even if a team-mode repo is detected. + -q, --quiet Suppress progress output. + -h, --help Show this help and exit. + +Examples: + ./setup # solo install for Claude Code + ./setup --host codex # install for OpenAI Codex CLI + ./setup --team # team mode for a shared repo + ./setup --no-prefix # use short slash-command names + +Docs: https://github.com/garrytan/gstack +EOF +} + +# Short-circuit on -h/--help before any environment checks so users can +# discover flags even without bun installed. +for _arg in "$@"; do + case "$_arg" in + -h|--help) usage; exit 0 ;; + esac +done + if ! command -v bun >/dev/null 2>&1; then echo "Error: bun is required but not installed." >&2 echo "Install with checksum verification:" >&2 diff --git a/test/setup-help.test.ts b/test/setup-help.test.ts new file mode 100644 index 0000000000..f1cbcc6e33 --- /dev/null +++ b/test/setup-help.test.ts @@ -0,0 +1,64 @@ +import { describe, test, expect } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as path from 'path'; +import * as fs from 'fs'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const SETUP_SCRIPT = path.join(ROOT, 'setup'); + +describe('setup: --help flag (#1133)', () => { + test('setup script defines a usage() function', () => { + const content = fs.readFileSync(SETUP_SCRIPT, 'utf-8'); + expect(content).toMatch(/^usage\(\)\s*\{/m); + }); + + test('setup script short-circuits on -h/--help before env checks', () => { + const content = fs.readFileSync(SETUP_SCRIPT, 'utf-8'); + const helpIdx = content.search(/-h\|--help\)\s*usage;\s*exit 0/); + const bunCheckIdx = content.indexOf('command -v bun'); + expect(helpIdx).toBeGreaterThan(-1); + expect(bunCheckIdx).toBeGreaterThan(-1); + // --help must be handled before the bun availability check so the flag + // works on machines that haven't installed bun yet. + expect(helpIdx).toBeLessThan(bunCheckIdx); + }); + + test('usage text documents every supported flag', () => { + const content = fs.readFileSync(SETUP_SCRIPT, 'utf-8'); + const usageMatch = content.match(/usage\(\)\s*\{[\s\S]*?\n\}/); + expect(usageMatch).toBeTruthy(); + const usage = usageMatch![0]; + for (const flag of [ + '--host', + '--prefix', + '--no-prefix', + '--team', + '--no-team', + '--quiet', + '--help', + ]) { + expect(usage).toContain(flag); + } + }); + + test('./setup --help exits 0, prints usage, and does not run installer', () => { + const res = spawnSync('bash', [SETUP_SCRIPT, '--help'], { + encoding: 'utf-8', + timeout: 5000, + }); + expect(res.status).toBe(0); + expect(res.stdout).toContain('Usage:'); + expect(res.stdout).toContain('gstack setup'); + // Hard guarantee it short-circuited — none of the install-side output appears. + expect(res.stdout).not.toMatch(/Installing|bun install|Building|gen:skill-docs/); + }); + + test('./setup -h is equivalent to ./setup --help', () => { + const res = spawnSync('bash', [SETUP_SCRIPT, '-h'], { + encoding: 'utf-8', + timeout: 5000, + }); + expect(res.status).toBe(0); + expect(res.stdout).toContain('Usage:'); + }); +}); From dcb92b04894de6f9260f2a15a6a68f89353ee502 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 14 Jul 2026 12:57:31 -0700 Subject: [PATCH 03/14] fix(paths): shell-quote resolved roots --- bin/gstack-paths | 18 ++++++++++++------ test/gstack-paths.test.ts | 7 ++++++- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/bin/gstack-paths b/bin/gstack-paths index 1a7e073065..a1c8cdcc0f 100755 --- a/bin/gstack-paths +++ b/bin/gstack-paths @@ -13,11 +13,17 @@ # PLAN_ROOT: GSTACK_PLAN_DIR -> CLAUDE_PLANS_DIR -> $HOME/.claude/plans -> .claude/plans # TMP_ROOT: TMPDIR -> TMP -> .gstack/tmp (and mkdir -p, best-effort) # -# Security: output values are not sanitized — callers may receive paths with -# shell-special characters if env vars contain them. Skills should always quote -# expansions ("$GSTACK_STATE_ROOT", not $GSTACK_STATE_ROOT). +# Output values are shell-quoted so eval "$(gstack-paths)" is safe for paths +# containing spaces, $, ;, backticks, and other metacharacters. Single quotes +# are used for portability (POSIX sh / bash / zsh / dash). set -u +# Wrap a path in single quotes, escaping any embedded single quotes. +# Produces output safe for: eval "$(gstack-paths)" +_shell_quote() { + printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")" +} + # State root: where gstack writes projects/, sessions/, analytics/. if [ -n "${GSTACK_HOME:-}" ]; then _state_root="$GSTACK_HOME" @@ -60,6 +66,6 @@ fi # will discover that on their own write attempt. Don't fail the eval here. mkdir -p "$_tmp_root" 2>/dev/null || true -echo "GSTACK_STATE_ROOT=$_state_root" -echo "PLAN_ROOT=$_plan_root" -echo "TMP_ROOT=$_tmp_root" +printf 'GSTACK_STATE_ROOT=%s\n' "$(_shell_quote "$_state_root")" +printf 'PLAN_ROOT=%s\n' "$(_shell_quote "$_plan_root")" +printf 'TMP_ROOT=%s\n' "$(_shell_quote "$_tmp_root")" diff --git a/test/gstack-paths.test.ts b/test/gstack-paths.test.ts index 42c13c3acd..662fe37350 100644 --- a/test/gstack-paths.test.ts +++ b/test/gstack-paths.test.ts @@ -26,7 +26,12 @@ function run(env: Record): Record { const out: Record = {}; for (const line of result.stdout.split('\n')) { const eq = line.indexOf('='); - if (eq > 0) out[line.slice(0, eq)] = line.slice(eq + 1); + if (eq > 0) { + // gstack-paths single-quotes values so eval is space-safe; unwrap for assertions + let v = line.slice(eq + 1); + if (v.startsWith("'") && v.endsWith("'")) v = v.slice(1, -1).replaceAll("'\\''", "'"); + out[line.slice(0, eq)] = v; + } } return out; } From 8878863c9d2e9481726984612497bb64e9f59e30 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 14 Jul 2026 12:57:31 -0700 Subject: [PATCH 04/14] fix(benchmark): validate timeout flag --- bin/gstack-model-benchmark | 16 +++++++++++++++- test/benchmark-cli.test.ts | 15 +++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/bin/gstack-model-benchmark b/bin/gstack-model-benchmark index c5f5cb5b65..a198fddc96 100755 --- a/bin/gstack-model-benchmark +++ b/bin/gstack-model-benchmark @@ -88,6 +88,20 @@ function parseProviders(s: string | undefined): Array<'claude' | 'gpt' | 'gemini return seen.size ? Array.from(seen) : ['claude']; } +function parsePositiveIntegerFlag(name: string, def: string): number { + const raw = arg(name, def); + if (!raw || !/^\+?[1-9]\d*$/.test(raw)) { + console.error(`${name} requires a positive integer`); + process.exit(1); + } + const parsed = Number(raw); + if (!Number.isSafeInteger(parsed)) { + console.error(`${name} requires a positive integer`); + process.exit(1); + } + return parsed; +} + function resolvePrompt(positional: string | undefined): string { const inline = arg('--prompt'); if (inline) return inline; @@ -107,7 +121,7 @@ async function main(): Promise { const prompt = resolvePrompt(positional); const providers = parseProviders(arg('--models')); const workdir = arg('--workdir', process.cwd())!; - const timeoutMs = parseInt(arg('--timeout-ms', '300000')!, 10); + const timeoutMs = parsePositiveIntegerFlag('--timeout-ms', '300000'); const output = (arg('--output', 'table') as OutputFormat); const skipUnavailable = flag('--skip-unavailable'); const doJudge = flag('--judge'); diff --git a/test/benchmark-cli.test.ts b/test/benchmark-cli.test.ts index 8edea3b245..45392f7107 100644 --- a/test/benchmark-cli.test.ts +++ b/test/benchmark-cli.test.ts @@ -73,6 +73,21 @@ describe('gstack-model-benchmark --dry-run', () => { expect(r.stdout).toContain('workdir: /tmp'); }); + test('--timeout-ms accepts plus-prefixed positive integers', () => { + const r = run(['--prompt', 'hi', '--timeout-ms', '+2500', '--dry-run']); + expect(r.status).toBe(0); + expect(r.stdout).toContain('timeout_ms: 2500'); + }); + + test('--timeout-ms rejects malformed values', () => { + for (const value of ['1abc', 'nope', '0', '-1', '1.5', '']) { + const r = run(['--prompt', 'hi', '--timeout-ms', value, '--dry-run']); + expect(r.status).toBe(1); + expect(r.stderr).toContain('--timeout-ms requires a positive integer'); + expect(r.stdout).toBe(''); + } + }); + test('--judge flag reported in dry-run output', () => { const r = run(['--prompt', 'hi', '--judge', '--dry-run']); expect(r.status).toBe(0); From e55bea589d7b91cef9763b7c07cab8a29f15186d Mon Sep 17 00:00:00 2001 From: t Date: Tue, 14 Jul 2026 12:57:31 -0700 Subject: [PATCH 05/14] fix(ios-qa): add side-effect-free daemon help --- bin/gstack-ios-qa-daemon | 33 +++++++++++++++++++++++++++++++++ test/ios-qa-daemon-cli.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 test/ios-qa-daemon-cli.test.ts diff --git a/bin/gstack-ios-qa-daemon b/bin/gstack-ios-qa-daemon index b0ca2c6afd..a328fca6af 100755 --- a/bin/gstack-ios-qa-daemon +++ b/bin/gstack-ios-qa-daemon @@ -22,6 +22,39 @@ set -euo pipefail +print_usage() { + cat <<'EOF' +Usage: + gstack-ios-qa-daemon [--tailnet] + gstack-ios-qa-daemon --help + +Mac-side broker for /ios-qa and /ios-design-review. By default it binds a +loopback listener for a USB-connected iPhone running the in-app StateServer. +Pass --tailnet to also open the Tailscale listener after the local tailscaled +identity probe succeeds. + +Options: + --tailnet Enable the authenticated tailnet listener in addition to loopback. + -h, --help Print this help and exit without starting the daemon. + +Environment: + GSTACK_IOS_DAEMON_PORT Loopback listener port. Default: 9099. + GSTACK_IOS_TARGET_UDID Target iOS device UDID. Optional. + GSTACK_IOS_TARGET_BUNDLE_ID Bundle ID hosting StateServer. Default: com.gstack.iosqa.fixture. + +Readiness: + The daemon prints "READY: port= pid=" once the listener is bound. + Probe local health with: curl -sf http://127.0.0.1:${GSTACK_IOS_DAEMON_PORT:-9099}/healthz +EOF +} + +case "${1:-}" in + -h|--help) + print_usage + exit 0 + ;; +esac + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" GSTACK_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" ENTRY="$GSTACK_DIR/ios-qa/daemon/src/index.ts" diff --git a/test/ios-qa-daemon-cli.test.ts b/test/ios-qa-daemon-cli.test.ts new file mode 100644 index 0000000000..288972328f --- /dev/null +++ b/test/ios-qa-daemon-cli.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from 'bun:test'; +import { spawnSync } from 'child_process'; +import { join } from 'path'; + +const ROOT = join(import.meta.dir, '..'); +const DAEMON_BIN = join(ROOT, 'bin/gstack-ios-qa-daemon'); + +describe('gstack-ios-qa-daemon CLI', () => { + test('--help prints usage and exits without starting the daemon', () => { + const result = spawnSync('bash', [DAEMON_BIN, '--help'], { + cwd: ROOT, + encoding: 'utf-8', + timeout: 2_000, + }); + + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + expect(result.stdout).toContain('Usage:'); + expect(result.stdout).toContain('gstack-ios-qa-daemon'); + expect(result.stdout).toContain('--tailnet'); + expect(result.stdout).toContain('GSTACK_IOS_DAEMON_PORT'); + expect(result.stdout).toContain('/healthz'); + expect(result.stderr).toBe(''); + }); +}); From 034326ab3234bbcef0607c8d2da5ed586d3f12ec Mon Sep 17 00:00:00 2001 From: t Date: Tue, 14 Jul 2026 13:39:38 -0700 Subject: [PATCH 06/14] fix(settings): fail closed on malformed custom config --- bin/gstack-settings-hook | 28 ++++++++++++++++++++++------ test/team-mode.test.ts | 16 ++++++++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/bin/gstack-settings-hook b/bin/gstack-settings-hook index 6d663b23f7..77b1bc70c5 100755 --- a/bin/gstack-settings-hook +++ b/bin/gstack-settings-hook @@ -26,7 +26,7 @@ set -euo pipefail ACTION="${1:-}" -SETTINGS_FILE="${GSTACK_SETTINGS_FILE:-$HOME/.claude/settings.json}" +SETTINGS_FILE="${GSTACK_SETTINGS_FILE:-${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json}" if [ -z "$ACTION" ]; then cat <&2 @@ -71,7 +71,13 @@ case "$ACTION" in const settingsPath = process.env.GSTACK_SETTINGS_PATH; const hookCmd = process.env.GSTACK_HOOK_CMD; let settings = {}; - try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); } catch {} + if (fs.existsSync(settingsPath)) { + try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); } + catch (err) { + console.error(`Refusing to overwrite malformed settings: ${settingsPath}: ${err.message}`); + process.exit(2); + } + } if (!settings.hooks) settings.hooks = {}; if (!settings.hooks.SessionStart) settings.hooks.SessionStart = []; const exists = settings.hooks.SessionStart.some(entry => @@ -85,7 +91,7 @@ case "$ACTION" in const tmp = settingsPath + ".tmp"; fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n"); fs.renameSync(tmp, settingsPath); - ' 2>/dev/null + ' ;; remove) @@ -100,7 +106,11 @@ case "$ACTION" in const fs = require("fs"); const settingsPath = process.env.GSTACK_SETTINGS_PATH; let settings = {}; - try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); } catch { process.exit(0); } + try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); } + catch (err) { + console.error(`Refusing to rewrite malformed settings: ${settingsPath}: ${err.message}`); + process.exit(2); + } if (settings.hooks && settings.hooks.SessionStart) { settings.hooks.SessionStart = settings.hooks.SessionStart.filter(entry => !(entry.hooks && entry.hooks.some(h => h.command && h.command.includes("gstack-session-update"))) @@ -111,7 +121,7 @@ case "$ACTION" in const tmp = settingsPath + ".tmp"; fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n"); fs.renameSync(tmp, settingsPath); - ' 2>/dev/null + ' ;; add-event|diff-event) @@ -162,7 +172,13 @@ case "$ACTION" in const diffOnly = process.env.GSTACK_DIFF_ONLY === "1"; let settings = {}; - try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); } catch {} + if (fs.existsSync(settingsPath)) { + try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); } + catch (err) { + console.error(`Refusing to overwrite malformed settings: ${settingsPath}: ${err.message}`); + process.exit(2); + } + } const before = JSON.stringify(settings, null, 2); diff --git a/test/team-mode.test.ts b/test/team-mode.test.ts index ce8c1d6107..8583db7077 100644 --- a/test/team-mode.test.ts +++ b/test/team-mode.test.ts @@ -62,6 +62,22 @@ describe('gstack-settings-hook', () => { expect(settings.hooks.SessionStart).toHaveLength(1); }); + test('CLAUDE_CONFIG_DIR is honored and malformed settings are preserved', () => { + const configDir = path.join(tmpDir, 'custom-claude'); + fs.mkdirSync(configDir, { recursive: true }); + const customSettings = path.join(configDir, 'settings.json'); + const malformed = '{ user-owned malformed settings\n'; + fs.writeFileSync(customSettings, malformed); + + const result = run(`${SETTINGS_HOOK} add /path/to/gstack-session-update`, { + env: { CLAUDE_CONFIG_DIR: configDir, GSTACK_SETTINGS_FILE: '' }, + }); + + expect(result.exitCode).toBe(2); + expect(fs.readFileSync(customSettings, 'utf-8')).toBe(malformed); + expect(result.stderr).toContain('Refusing to overwrite malformed settings'); + }); + test('add deduplicates (running twice does not double-add)', () => { run(`${SETTINGS_HOOK} add /path/to/gstack-session-update`, { env: { GSTACK_SETTINGS_FILE: settingsFile }, From ad695fbd04d28262527529a96fe708a600aba5a8 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 14 Jul 2026 13:39:38 -0700 Subject: [PATCH 07/14] fix(uninstall): preserve unowned skill directories --- bin/gstack-uninstall | 48 +++++++++++++++++++++++++++++++++--------- test/uninstall.test.ts | 23 ++++++++++++++++++++ 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/bin/gstack-uninstall b/bin/gstack-uninstall index 17d7d30bcd..dab65a8eda 100755 --- a/bin/gstack-uninstall +++ b/bin/gstack-uninstall @@ -91,6 +91,24 @@ fi REMOVED=() +# Only remove host-side entries that are demonstrably linked into this gstack +# checkout. A basename such as "gstack-personal" is not ownership proof. +is_owned_skill_link() { + local item="$1" + [ -L "$item" ] || return 1 + local target resolved + target="$(readlink "$item" 2>/dev/null || true)" + [ -n "$target" ] || return 1 + case "$target" in + /*) resolved="$target" ;; + *) resolved="$(cd "$(dirname "$item")" 2>/dev/null && cd "$(dirname "$target")" 2>/dev/null && pwd -P)/$(basename "$target")" ;; + esac + case "$resolved" in + "$GSTACK_DIR"|"$GSTACK_DIR"/*) return 0 ;; + *) return 1 ;; + esac +} + # ─── Stop running browse daemons ───────────────────────────── # Browse servers write PID to {project}/.gstack/browse.json. # Stop any we can find before removing state directories. @@ -166,8 +184,10 @@ CODEX_SKILLS="$HOME/.codex/skills" if [ -d "$CODEX_SKILLS" ]; then for _ITEM in "$CODEX_SKILLS"/gstack*; do [ -e "$_ITEM" ] || [ -L "$_ITEM" ] || continue - rm -rf "$_ITEM" - REMOVED+=("codex/$(basename "$_ITEM")") + if is_owned_skill_link "$_ITEM"; then + rm -f "$_ITEM" + REMOVED+=("codex/$(basename "$_ITEM")") + fi done fi @@ -176,8 +196,10 @@ FACTORY_SKILLS="$HOME/.factory/skills" if [ -d "$FACTORY_SKILLS" ]; then for _ITEM in "$FACTORY_SKILLS"/gstack*; do [ -e "$_ITEM" ] || [ -L "$_ITEM" ] || continue - rm -rf "$_ITEM" - REMOVED+=("factory/$(basename "$_ITEM")") + if is_owned_skill_link "$_ITEM"; then + rm -f "$_ITEM" + REMOVED+=("factory/$(basename "$_ITEM")") + fi done fi @@ -186,8 +208,10 @@ KIRO_SKILLS="$HOME/.kiro/skills" if [ -d "$KIRO_SKILLS" ]; then for _ITEM in "$KIRO_SKILLS"/gstack*; do [ -e "$_ITEM" ] || [ -L "$_ITEM" ] || continue - rm -rf "$_ITEM" - REMOVED+=("kiro/$(basename "$_ITEM")") + if is_owned_skill_link "$_ITEM"; then + rm -f "$_ITEM" + REMOVED+=("kiro/$(basename "$_ITEM")") + fi done fi @@ -195,8 +219,10 @@ fi if [ -n "$_GIT_ROOT" ] && [ -d "$_GIT_ROOT/.agents/skills" ]; then for _ITEM in "$_GIT_ROOT/.agents/skills"/gstack*; do [ -e "$_ITEM" ] || [ -L "$_ITEM" ] || continue - rm -rf "$_ITEM" - REMOVED+=("agents/$(basename "$_ITEM")") + if is_owned_skill_link "$_ITEM"; then + rm -f "$_ITEM" + REMOVED+=("agents/$(basename "$_ITEM")") + fi done rmdir "$_GIT_ROOT/.agents/skills" 2>/dev/null || true @@ -207,8 +233,10 @@ fi if [ -n "$_GIT_ROOT" ] && [ -d "$_GIT_ROOT/.factory/skills" ]; then for _ITEM in "$_GIT_ROOT/.factory/skills"/gstack*; do [ -e "$_ITEM" ] || [ -L "$_ITEM" ] || continue - rm -rf "$_ITEM" - REMOVED+=("factory/$(basename "$_ITEM")") + if is_owned_skill_link "$_ITEM"; then + rm -f "$_ITEM" + REMOVED+=("factory/$(basename "$_ITEM")") + fi done rmdir "$_GIT_ROOT/.factory/skills" 2>/dev/null || true diff --git a/test/uninstall.test.ts b/test/uninstall.test.ts index a7208e8770..56abcf1319 100644 --- a/test/uninstall.test.ts +++ b/test/uninstall.test.ts @@ -161,5 +161,28 @@ describe('gstack-uninstall', () => { // Non-gstack should survive expect(fs.existsSync(path.join(mockHome, '.claude', 'skills', 'other-tool'))).toBe(true); }); + + test('preserves foreign real directories while removing owned host symlinks', () => { + const codexSkills = path.join(mockHome, '.codex', 'skills'); + const gstackDir = path.join(mockHome, '.claude', 'skills', 'gstack'); + fs.mkdirSync(path.join(codexSkills, 'gstack-personal'), { recursive: true }); + fs.writeFileSync(path.join(codexSkills, 'gstack-personal', 'SENTINEL'), 'user-owned'); + fs.symlinkSync(path.join(gstackDir, 'review'), path.join(codexSkills, 'gstack-review')); + + const result = spawnSync('bash', [UNINSTALL, '--force', '--keep-state'], { + stdio: 'pipe', + env: { + ...process.env, + HOME: mockHome, + GSTACK_DIR: gstackDir, + GSTACK_STATE_DIR: path.join(mockHome, '.gstack'), + }, + cwd: mockGitRoot, + }); + + expect(result.status).toBe(0); + expect(fs.readFileSync(path.join(codexSkills, 'gstack-personal', 'SENTINEL'), 'utf-8')).toBe('user-owned'); + expect(fs.existsSync(path.join(codexSkills, 'gstack-review'))).toBe(false); + }); }); }); From f036446f09aab4cba8fc8f17fe269e4562226274 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 14 Jul 2026 13:44:25 -0700 Subject: [PATCH 08/14] fix(artifacts): preserve configured push protocol --- bin/gstack-artifacts-init | 60 ++++++++++++++++++++++++++---- test/gstack-artifacts-init.test.ts | 39 ++++++++++++++++++- 2 files changed, 89 insertions(+), 10 deletions(-) diff --git a/bin/gstack-artifacts-init b/bin/gstack-artifacts-init index b8bfe830cf..8017e01449 100755 --- a/bin/gstack-artifacts-init +++ b/bin/gstack-artifacts-init @@ -8,6 +8,7 @@ # # Usage: # gstack-artifacts-init [--remote ] [--host github|gitlab|manual] +# [--push-protocol auto|https|ssh] # [--url-form-supported true|false] # # Interactive by default. Pass --remote to skip the host prompt. @@ -42,17 +43,25 @@ REMOTE_FILE="$HOME/.gstack-artifacts-remote.txt" REMOTE_URL="" HOST_PREF="" +PUSH_PROTOCOL="auto" +REMOTE_SOURCE="provider" URL_FORM_SUPPORTED="false" while [ $# -gt 0 ]; do case "$1" in - --remote) REMOTE_URL="$2"; shift 2 ;; + --remote) REMOTE_URL="$2"; REMOTE_SOURCE="explicit"; shift 2 ;; --host) HOST_PREF="$2"; shift 2 ;; + --push-protocol) PUSH_PROTOCOL="$2"; shift 2 ;; --url-form-supported) URL_FORM_SUPPORTED="$2"; shift 2 ;; --help|-h) sed -n '2,32p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; *) echo "Unknown flag: $1" >&2; exit 1 ;; esac done +case "$PUSH_PROTOCOL" in + auto|https|ssh) ;; + *) echo "Invalid --push-protocol: $PUSH_PROTOCOL (expected auto|https|ssh)" >&2; exit 1 ;; +esac + # ---- preconditions ---- mkdir -p "$GSTACK_HOME" @@ -89,6 +98,7 @@ if command -v glab >/dev/null 2>&1 && glab auth status >/dev/null 2>&1; then gla # ---- choose remote URL ---- if [ -z "$REMOTE_URL" ] && [ -n "$EXISTING_REMOTE" ]; then REMOTE_URL="$EXISTING_REMOTE" + REMOTE_SOURCE="existing" echo "Using existing remote: $REMOTE_URL" fi @@ -164,6 +174,7 @@ if [ -z "$REMOTE_URL" ]; then echo "No URL provided. Aborting." >&2 exit 1 fi + REMOTE_SOURCE="manual" ;; *) echo "Unknown --host: $HOST_PREF (expected github|gitlab|manual)" >&2; exit 1 ;; esac @@ -179,20 +190,53 @@ if [ -z "$CANONICAL_HTTPS" ]; then CANONICAL_HTTPS="$REMOTE_URL" fi -# Use SSH for git push (more reliable for repeated pushes than HTTPS+token). -# Fall back to the canonical input if derivation fails. -PUSH_URL=$("$URL_BIN" --to ssh "$CANONICAL_HTTPS" 2>/dev/null || echo "$CANONICAL_HTTPS") +# Preserve an explicit/existing/manual URL unless the user requests a protocol. +# Provider-created remotes honor the provider CLI's configured git protocol. +RESOLVED_PUSH_PROTOCOL="$PUSH_PROTOCOL" +if [ "$RESOLVED_PUSH_PROTOCOL" = "auto" ]; then + case "$REMOTE_SOURCE" in + explicit|existing|manual) + # Preserve the byte-for-byte URL, including a trailing .git and any + # self-hosted path details. Git credential and insteadOf rules can be + # scoped to that exact URL. + RESOLVED_PUSH_PROTOCOL="preserve" + ;; + provider) + CONFIGURED_PROTOCOL="" + case "$HOST_PREF" in + github) CONFIGURED_PROTOCOL=$(gh config get git_protocol 2>/dev/null || true) ;; + gitlab) CONFIGURED_PROTOCOL=$(glab config get git_protocol 2>/dev/null || true) ;; + esac + case "$CONFIGURED_PROTOCOL" in + ssh|https) RESOLVED_PUSH_PROTOCOL="$CONFIGURED_PROTOCOL" ;; + *) RESOLVED_PUSH_PROTOCOL="https" ;; + esac + ;; + esac +fi + +if [ "$RESOLVED_PUSH_PROTOCOL" = "preserve" ]; then + PUSH_URL="$REMOTE_URL" + case "$REMOTE_URL" in + git@*|ssh://*) PROTOCOL_LABEL="ssh" ;; + http://*|https://*) PROTOCOL_LABEL="https" ;; + *) PROTOCOL_LABEL="the remote's configured protocol" ;; + esac +else + PUSH_URL=$("$URL_BIN" --to "$RESOLVED_PUSH_PROTOCOL" "$CANONICAL_HTTPS" 2>/dev/null || echo "$CANONICAL_HTTPS") + PROTOCOL_LABEL="$RESOLVED_PUSH_PROTOCOL" +fi # ---- verify push URL is reachable ---- echo "Verifying remote connectivity: $PUSH_URL" if ! git ls-remote "$PUSH_URL" >/dev/null 2>&1; then cat >&2 < { + test('provider-created remotes default to HTTPS when no CLI protocol is configured', () => { makeFakeGh({ webUrl: 'https://github.com/testuser/gstack-artifacts-testuser' }); const r = run(['--host', 'github']); expect(r.status).toBe(0); const remote = spawnSync('git', ['-C', tmpHome, 'remote', 'get-url', 'origin'], { encoding: 'utf-8' }); - expect(remote.stdout.trim()).toBe('git@github.com:testuser/gstack-artifacts-testuser.git'); + expect(remote.stdout.trim()).toBe('https://github.com/testuser/gstack-artifacts-testuser'); + }); + + test('explicit HTTPS survives a real ls-remote and push', () => { + const realHome = fs.mkdtempSync(path.join(os.tmpdir(), 'artifacts-real-home-')); + const realState = path.join(realHome, '.gstack'); + const realRemote = fs.mkdtempSync(path.join(os.tmpdir(), 'artifacts-real-bare-')); + const url = 'https://example.invalid/probe/artifacts.git'; + spawnSync('git', ['init', '--bare', '-q', '-b', 'main', realRemote]); + spawnSync('git', ['config', '--global', 'url.' + realRemote + '.insteadOf', url], { + env: { ...process.env, HOME: realHome }, + }); + spawnSync('git', ['config', '--global', 'user.email', 'probe@example.invalid'], { + env: { ...process.env, HOME: realHome }, + }); + spawnSync('git', ['config', '--global', 'user.name', 'Probe'], { + env: { ...process.env, HOME: realHome }, + }); + + const r = spawnSync(INIT_BIN, ['--remote', url, '--host', 'manual'], { + cwd: ROOT, + encoding: 'utf-8', + env: { + ...process.env, + HOME: realHome, + GSTACK_HOME: realState, + USER: 'probe', + PATH: '/usr/bin:/bin:/opt/homebrew/bin', + }, + }); + + expect(r.status).toBe(0); + expect(spawnSync('git', ['--git-dir', realRemote, 'rev-parse', 'refs/heads/main']).status).toBe(0); + expect(spawnSync('git', ['-C', realState, 'config', '--get', 'remote.origin.url'], { encoding: 'utf-8' }).stdout.trim()).toBe(url); + fs.rmSync(realHome, { recursive: true, force: true }); + fs.rmSync(realRemote, { recursive: true, force: true }); }); }); From 7260b8e2edaedd18544cdd816ae601c4f03f9c6f Mon Sep 17 00:00:00 2001 From: t Date: Tue, 14 Jul 2026 13:50:25 -0700 Subject: [PATCH 09/14] fix(memory): fail closed on transcript repo trust --- bin/gstack-memory-ingest.ts | 62 +++++++++++++++++++++++++++++++ test/gstack-memory-ingest.test.ts | 53 +++++++++++++++++++++++++- 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/bin/gstack-memory-ingest.ts b/bin/gstack-memory-ingest.ts index 653d4069ae..0ac5a0ca5b 100644 --- a/bin/gstack-memory-ingest.ts +++ b/bin/gstack-memory-ingest.ts @@ -527,6 +527,19 @@ function* walkAllSources(ctx: WalkContext): Generator<{ path: string; type: Memo yield* walkGstackArtifacts(ctx); } +function transcriptIngestMode(): "enabled" | "off" { + try { + const raw = readFileSync(join(GSTACK_HOME, "config.yaml"), "utf-8"); + const match = raw.match(/^transcript_ingest_mode:\s*([^#\s]+)\s*(?:#.*)?$/m); + const value = match?.[1]?.toLowerCase(); + return value === "off" ? "off" : "enabled"; + } catch { + // Preserve compatibility for pre-setting installs. Repository trust below + // still fails closed for attributed transcripts. + return "enabled"; + } +} + // ── Renderers ────────────────────────────────────────────────────────────── interface ParsedSession { @@ -683,6 +696,29 @@ function resolveGitRemote(cwd: string): string { } } +let cachedCurrentRepoRemote: string | undefined; +function currentRepoRemote(): string { + if (cachedCurrentRepoRemote !== undefined) return cachedCurrentRepoRemote; + cachedCurrentRepoRemote = resolveGitRemote(process.cwd()); + return cachedCurrentRepoRemote; +} + +let cachedRepoPolicy: Record | undefined; +function repoTrustTier(remote: string): string { + if (!remote) return "unset"; + try { + cachedRepoPolicy ??= JSON.parse( + readFileSync(join(GSTACK_HOME, "gbrain-repo-policy.json"), "utf-8"), + ) as Record; + const tier = cachedRepoPolicy[canonicalizeRemote(remote)]; + return typeof tier === "string" ? tier : "unset"; + } catch { + // Missing, unreadable, or corrupt policy is not authorization. + cachedRepoPolicy = {}; + return "unset"; + } +} + function repoSlug(remote: string): string { if (!remote) return "_unattributed"; // github.com/foo/bar → foo-bar @@ -1171,6 +1207,32 @@ function preparePages( skippedUnattributed++; continue; } + if (args.mode !== "probe") { + if (transcriptIngestMode() === "off") { + skippedUnattributed++; + continue; + } + const pageRemote = canonicalizeRemote(page.git_remote || ""); + const explicitlyUnattributed = + (!page.git_remote || page.git_remote === "_unattributed") && args.includeUnattributed; + if (!explicitlyUnattributed) { + const currentRemote = currentRepoRemote(); + if ( + !currentRemote || + pageRemote !== currentRemote || + repoTrustTier(pageRemote) !== "read-write" + ) { + skippedUnattributed++; + if (!args.quiet) { + console.error( + `[trust-policy] skipped transcript from ${pageRemote || "_unattributed"}; ` + + "only the current repo with an explicit read-write policy may ingest transcripts.", + ); + } + continue; + } + } + } if (page.partial) partialPages++; } else { page = buildArtifactPage(path, type); diff --git a/test/gstack-memory-ingest.test.ts b/test/gstack-memory-ingest.test.ts index fef9070c44..0556f3a5c3 100644 --- a/test/gstack-memory-ingest.test.ts +++ b/test/gstack-memory-ingest.test.ts @@ -29,10 +29,12 @@ function makeTestHome(): string { } function runScript(args: string[], env: Record = {}): { stdout: string; stderr: string; exitCode: number } { + const { GSTACK_TEST_CWD, ...childEnv } = env; const result = spawnSync("bun", [SCRIPT, ...args], { encoding: "utf-8", timeout: 30000, - env: { ...process.env, ...env }, + cwd: GSTACK_TEST_CWD, + env: { ...process.env, ...childEnv }, }); return { stdout: result.stdout || "", @@ -251,6 +253,55 @@ describe("gstack-memory-ingest security: untrusted cwd cannot trigger shell subs }); }); +describe("gstack-memory-ingest transcript trust policy", () => { + function trustFixture(tier?: "read-write" | "read-only" | "deny") { + const home = makeTestHome(); + const gstackHome = join(home, ".gstack"); + const repo = mkdtempSync(join(tmpdir(), "gstack-mi-trust-repo-")); + mkdirSync(gstackHome, { recursive: true }); + spawnSync("git", ["init", "-q"], { cwd: repo }); + spawnSync("git", ["remote", "add", "origin", "https://github.com/example/trusted.git"], { cwd: repo }); + writeFileSync(join(gstackHome, "config.yaml"), "transcript_ingest_mode: incremental\n"); + if (tier) { + writeFileSync(join(gstackHome, "gbrain-repo-policy.json"), JSON.stringify({ + _schema_version: 2, + "github.com/example/trusted": tier, + })); + } + const session = + `{"type":"user","message":{"role":"user","content":"hello"},"timestamp":"2026-07-14T00:00:00Z","cwd":${JSON.stringify(repo)}}\n`; + writeClaudeCodeSession(home, "trust-repo", "trust123", session); + return { home, gstackHome, repo }; + } + + it("fails closed when policy is missing, read-only, or deny", () => { + for (const tier of [undefined, "read-only", "deny"] as const) { + const f = trustFixture(tier); + const r = runScript(["--bulk", "--sources", "transcript", "--no-write"], { + HOME: f.home, + GSTACK_HOME: f.gstackHome, + }); + expect(r.exitCode).toBe(0); + expect(r.stdout).toMatch(/written:\s+0/); + rmSync(f.home, { recursive: true, force: true }); + rmSync(f.repo, { recursive: true, force: true }); + } + }); + + it("allows the current repo only with explicit read-write policy", () => { + const f = trustFixture("read-write"); + const r = runScript(["--bulk", "--sources", "transcript", "--no-write"], { + HOME: f.home, + GSTACK_HOME: f.gstackHome, + GSTACK_TEST_CWD: f.repo, + }); + expect(r.exitCode).toBe(0); + expect(r.stdout).toMatch(/written:\s+1/); + rmSync(f.home, { recursive: true, force: true }); + rmSync(f.repo, { recursive: true, force: true }); + }); +}); + // ── Transcript parser via re-import of the source module ─────────────────── describe("internal: parseTranscriptJsonl + buildTranscriptPage shape", () => { From 93eab0cba7d08130f7d9c004771c3a97fdc46994 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 14 Jul 2026 13:55:12 -0700 Subject: [PATCH 10/14] fix(setup): bound Playwright process trees --- setup | 75 +++++++++++++++++++++++--- test/setup-playwright-deadline.test.ts | 35 ++++++++++++ 2 files changed, 102 insertions(+), 8 deletions(-) create mode 100644 test/setup-playwright-deadline.test.ts diff --git a/setup b/setup index 3dddb1f81b..dd398c442b 100755 --- a/setup +++ b/setup @@ -64,6 +64,60 @@ case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*|Windows_NT) IS_WINDOWS=1 ;; esac +# Run a command with a portable deadline and terminate its descendants. Stock +# macOS has no timeout(1), so setup cannot rely on coreutils being present. +process_identity() { + ps -p "$1" -o lstart= 2>/dev/null | sed 's/^[[:space:]]*//' +} + +terminate_process_tree() { + local parent="$1" child + if [ "$IS_WINDOWS" -eq 1 ] && command -v taskkill.exe >/dev/null 2>&1; then + taskkill.exe //PID "$parent" //T //F >/dev/null 2>&1 || true + return + fi + if command -v pgrep >/dev/null 2>&1; then + for child in $(pgrep -P "$parent" 2>/dev/null || true); do + terminate_process_tree "$child" + done + fi + kill -TERM "$parent" 2>/dev/null || true + sleep 0.1 + kill -KILL "$parent" 2>/dev/null || true +} + +run_with_deadline() { + local seconds="$1" marker pid watcher status identity current + shift + marker="${TMPDIR:-/tmp}/gstack-setup-timeout.$$.$RANDOM" + rm -f "$marker" + "$@" & + pid=$! + identity="" + for _identity_try in 1 2 3 4 5; do + identity="$(process_identity "$pid")" + [ -n "$identity" ] && break + sleep 0.01 + done + ( + sleep "$seconds" + current="$(process_identity "$pid")" + if [ -n "$identity" ] && [ "$current" = "$identity" ] && kill -0 "$pid" 2>/dev/null; then + : > "$marker" + terminate_process_tree "$pid" + fi + ) & + watcher=$! + if wait "$pid"; then status=0; else status=$?; fi + kill "$watcher" 2>/dev/null || true + wait "$watcher" 2>/dev/null || true + if [ -f "$marker" ]; then + rm -f "$marker" + return 124 + fi + return "$status" +} + # ─── Symlink-or-copy helper ─────────────────────────────────── # On macOS/Linux: create a symlink (existing behavior). # On Windows without Developer Mode (MSYS2/Git Bash): plain ln -snf silently @@ -284,17 +338,18 @@ if [ "$INSTALL_CODEX" -eq 1 ]; then fi ensure_playwright_browser() { - if [ "$IS_WINDOWS" -eq 1 ]; then + local probe_timeout="${GSTACK_PLAYWRIGHT_PROBE_TIMEOUT_SECONDS:-15}" + if [ "$IS_WINDOWS" -eq 1 ] || [ "$(uname -s)" = "Darwin" ]; then # On Windows, Bun can't launch Chromium due to broken pipe handling # (oven-sh/bun#4253). Use Node.js to verify Chromium works instead. ( cd "$SOURCE_GSTACK_DIR" - node -e "const { chromium } = require('playwright'); (async () => { const b = await chromium.launch(); await b.close(); })()" 2>/dev/null + run_with_deadline "$probe_timeout" node -e "const { chromium } = require('playwright'); (async () => { const b = await chromium.launch(); await b.close(); })()" 2>/dev/null ) else ( cd "$SOURCE_GSTACK_DIR" - bun --eval 'import { chromium } from "playwright"; const browser = await chromium.launch(); await browser.close();' + run_with_deadline "$probe_timeout" bun --eval 'import { chromium } from "playwright"; const browser = await chromium.launch(); await browser.close();' ) >/dev/null 2>&1 fi } @@ -512,12 +567,15 @@ fi # 2. Ensure Playwright's Chromium is available if ! ensure_playwright_browser; then echo "Installing Playwright Chromium..." - ( + _playwright_install_timeout="${GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT_SECONDS:-300}" + if ! ( cd "$SOURCE_GSTACK_DIR" - bunx playwright install chromium - ) + run_with_deadline "$_playwright_install_timeout" bunx playwright install chromium + ); then + echo " warning: Playwright Chromium installation failed or timed out after ${_playwright_install_timeout}s." >&2 + fi - if [ "$IS_WINDOWS" -eq 1 ]; then + if [ "$IS_WINDOWS" -eq 1 ] && ensure_playwright_browser; then # On Windows, Node.js launches Chromium (not Bun — see oven-sh/bun#4253). # Ensure playwright is importable by Node from the gstack directory. if ! command -v node >/dev/null 2>&1; then @@ -539,6 +597,7 @@ if ! ensure_playwright_browser; then fi if ! ensure_playwright_browser; then + echo " warning: Playwright Chromium is unavailable." >&2 if [ "$IS_WINDOWS" -eq 1 ]; then echo "gstack setup failed: Playwright Chromium could not be launched via Node.js" >&2 echo " This is a known issue with Bun on Windows (oven-sh/bun#4253)." >&2 @@ -546,7 +605,7 @@ if ! ensure_playwright_browser; then else echo "gstack setup failed: Playwright Chromium could not be launched" >&2 fi - exit 1 + echo " Browser-backed skills are unavailable. Re-run ./setup to retry; other skills remain installed." >&2 fi # 2b. Ensure a color-emoji font is installed so make-pdf emoji render (Linux). diff --git a/test/setup-playwright-deadline.test.ts b/test/setup-playwright-deadline.test.ts new file mode 100644 index 0000000000..e16925b35c --- /dev/null +++ b/test/setup-playwright-deadline.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'fs'; +import { join } from 'path'; + +const SETUP = readFileSync(join(import.meta.dir, '..', 'setup'), 'utf8'); + +describe('setup Playwright deadline', () => { + test('bounds launch and install on every platform', () => { + expect(SETUP).toContain('run_with_deadline "$probe_timeout" node -e'); + expect(SETUP).toContain('run_with_deadline "$probe_timeout" bun --eval'); + expect(SETUP).toContain('run_with_deadline "$_playwright_install_timeout" bunx playwright install chromium'); + }); + + test('marks timeout before terminating the full process tree', () => { + const marker = SETUP.indexOf(': > "$marker"'); + const terminate = SETUP.indexOf('terminate_process_tree "$pid"', marker); + expect(marker).toBeGreaterThan(-1); + expect(terminate).toBeGreaterThan(marker); + expect(SETUP).toContain('terminate_process_tree "$child"'); + }); + + test('guards against PID reuse with process identity', () => { + expect(SETUP).toContain('identity="$(process_identity "$pid")"'); + expect(SETUP).toContain('[ "$current" = "$identity" ]'); + }); + + test('continues registration after browser failure', () => { + const warning = SETUP.indexOf('Browser-backed skills are unavailable.'); + const registration = SETUP.indexOf('link_claude_skill_dirs "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"'); + expect(warning).toBeGreaterThan(-1); + expect(registration).toBeGreaterThan(warning); + expect(SETUP.slice(warning, registration)).not.toMatch(/^\s*exit 1\s*$/m); + }); +}); + From 6ba91c53f47ff2a94ffc84698ab86359a5cd8dc0 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 14 Jul 2026 14:08:46 -0700 Subject: [PATCH 11/14] feat(setup): install owned Hermes skills safely --- bin/gstack-uninstall | 18 ++++++++++++++- setup | 37 ++++++++++++++++++++++++------- test/setup-hermes-install.test.ts | 21 ++++++++++++++++++ 3 files changed, 67 insertions(+), 9 deletions(-) create mode 100644 test/setup-hermes-install.test.ts diff --git a/bin/gstack-uninstall b/bin/gstack-uninstall index dab65a8eda..9a799f598e 100755 --- a/bin/gstack-uninstall +++ b/bin/gstack-uninstall @@ -95,7 +95,11 @@ REMOVED=() # checkout. A basename such as "gstack-personal" is not ownership proof. is_owned_skill_link() { local item="$1" - [ -L "$item" ] || return 1 + if [ ! -L "$item" ]; then + [ -f "$item/.gstack-install-owner" ] || return 1 + [ "$(cat "$item/.gstack-install-owner" 2>/dev/null || true)" = "$GSTACK_DIR" ] + return + fi local target resolved target="$(readlink "$item" 2>/dev/null || true)" [ -n "$target" ] || return 1 @@ -215,6 +219,18 @@ if [ -d "$KIRO_SKILLS" ]; then done fi +# ─── Remove Hermes skills ──────────────────────────────────── +HERMES_SKILLS="$HOME/.hermes/skills" +if [ -d "$HERMES_SKILLS" ]; then + for _ITEM in "$HERMES_SKILLS"/gstack*; do + [ -e "$_ITEM" ] || [ -L "$_ITEM" ] || continue + if is_owned_skill_link "$_ITEM"; then + rm -rf "$_ITEM" + REMOVED+=("hermes/$(basename "$_ITEM")") + fi + done +fi + # ─── Remove per-project .agents/ sidecar ───────────────────── if [ -n "$_GIT_ROOT" ] && [ -d "$_GIT_ROOT/.agents/skills" ]; then for _ITEM in "$_GIT_ROOT/.agents/skills"/gstack*; do diff --git a/setup b/setup index dd398c442b..4d8bbae600 100755 --- a/setup +++ b/setup @@ -203,14 +203,35 @@ case "$HOST" in echo "" exit 0 ;; hermes) - echo "" - echo "Hermes integration uses the same model as OpenClaw — Hermes spawns" - echo "Claude Code sessions, and gstack provides methodology artifacts." - echo "" - echo "To integrate gstack with Hermes:" - echo " 1. Tell your Hermes agent: 'install gstack for hermes'" - echo " 2. Or generate artifacts: bun run gen:skill-docs --host hermes" - echo "" + HERMES_SKILLS="$HOME/.hermes/skills" + mkdir -p "$HERMES_SKILLS" + ( + cd "$SOURCE_GSTACK_DIR" + bun install --frozen-lockfile 2>/dev/null || bun install + bun run gen:skill-docs --host hermes + ) + for _hermes_src in "$SOURCE_GSTACK_DIR"/.hermes/skills/gstack*; do + [ -d "$_hermes_src" ] || continue + _hermes_dst="$HERMES_SKILLS/$(basename "$_hermes_src")" + if [ -e "$_hermes_dst" ] || [ -L "$_hermes_dst" ]; then + _hermes_owned=0 + if [ -L "$_hermes_dst" ] && [ "$(cd "$(dirname "$_hermes_dst")" && cd "$(dirname "$(readlink "$_hermes_dst")")" 2>/dev/null && pwd -P)/$(basename "$(readlink "$_hermes_dst")")" = "$_hermes_src" ]; then + _hermes_owned=1 + elif [ -f "$_hermes_dst/.gstack-install-owner" ] && [ "$(cat "$_hermes_dst/.gstack-install-owner")" = "$SOURCE_GSTACK_DIR" ]; then + _hermes_owned=1 + fi + if [ "$_hermes_owned" -ne 1 ]; then + echo "warning: preserving unowned Hermes skill at $_hermes_dst" >&2 + continue + fi + rm -rf "$_hermes_dst" + fi + _link_or_copy "$_hermes_src" "$_hermes_dst" + if [ ! -L "$_hermes_dst" ]; then + printf '%s\n' "$SOURCE_GSTACK_DIR" > "$_hermes_dst/.gstack-install-owner" + fi + done + echo "Hermes skills installed. Verify with: hermes skills list --source local" exit 0 ;; gbrain) echo "" diff --git a/test/setup-hermes-install.test.ts b/test/setup-hermes-install.test.ts new file mode 100644 index 0000000000..ab022547a7 --- /dev/null +++ b/test/setup-hermes-install.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'fs'; +import { join } from 'path'; + +const ROOT = join(import.meta.dir, '..'); +const SETUP = readFileSync(join(ROOT, 'setup'), 'utf8'); +const UNINSTALL = readFileSync(join(ROOT, 'bin', 'gstack-uninstall'), 'utf8'); + +describe('Hermes install ownership', () => { + test('generates Hermes skills and preserves unowned collisions', () => { + expect(SETUP).toContain('bun run gen:skill-docs --host hermes'); + expect(SETUP).toContain('warning: preserving unowned Hermes skill'); + expect(SETUP).toContain('.gstack-install-owner'); + }); + + test('uninstall removes only demonstrably owned Hermes entries', () => { + expect(UNINSTALL).toContain('HERMES_SKILLS="$HOME/.hermes/skills"'); + expect(UNINSTALL).toContain('if is_owned_skill_link "$_ITEM"; then'); + expect(UNINSTALL).toContain('.gstack-install-owner'); + }); +}); From bbaf70df517ef18fa054d2a4c606173c662c45f3 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 14 Jul 2026 14:11:02 -0700 Subject: [PATCH 12/14] fix(ios-qa): exclude private touch APIs from Release --- ios-qa/templates/DebugBridgeTouch.m.template | 6 +++--- ios-qa/templates/Package.swift.template | 7 ++++++- test/fixtures/ios-qa/FixtureApp/Package.swift | 3 +++ .../Sources/DebugBridgeTouch/DebugBridgeTouch.m | 6 +++--- test/skill-e2e-ios-swift-build.test.ts | 14 ++++++++++++++ 5 files changed, 29 insertions(+), 7 deletions(-) diff --git a/ios-qa/templates/DebugBridgeTouch.m.template b/ios-qa/templates/DebugBridgeTouch.m.template index 7f7b7d1a3d..67e7d05bd0 100644 --- a/ios-qa/templates/DebugBridgeTouch.m.template +++ b/ios-qa/templates/DebugBridgeTouch.m.template @@ -20,7 +20,7 @@ #import "DebugBridgeTouch.h" #import -#if TARGET_OS_IOS +#if TARGET_OS_IOS && defined(DEBUG) #import #import @@ -286,7 +286,7 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) { @end -#else // !TARGET_OS_IOS +#else // !(TARGET_OS_IOS && DEBUG) // macOS / Catalyst / other non-iOS host build: no-op stub so the module // resolves cleanly without UIKit or IOKit. The Swift cross-platform tests @@ -298,4 +298,4 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) { } @end -#endif // TARGET_OS_IOS +#endif // TARGET_OS_IOS && defined(DEBUG) diff --git a/ios-qa/templates/Package.swift.template b/ios-qa/templates/Package.swift.template index 88d6bd3194..4272fbe124 100644 --- a/ios-qa/templates/Package.swift.template +++ b/ios-qa/templates/Package.swift.template @@ -1,3 +1,4 @@ +// swift-tools-version:5.9 // AUTO-GENERATED from gstack/ios-qa/templates/Package.swift.template // // Drop-in SPM package definition for the DebugBridge stack. Three targets: @@ -18,7 +19,6 @@ // CI invariant: `swift build -c release` + `nm -j build/Release/ // | grep -q DebugBridge && exit 1`. -// swift-tools-version:5.9 import PackageDescription let package = Package( @@ -43,6 +43,11 @@ let package = Package( dependencies: [], path: "Sources/DebugBridgeTouch", publicHeadersPath: "include", + cSettings: [ + // Swift settings do not propagate to Objective-C. Keep the + // private touch implementation out of Release translation units. + .define("DEBUG", to: "1", .when(configuration: .debug)), + ], linkerSettings: [ // IOKit is loaded dynamically via dlopen at runtime (it's a // private framework on iOS and can't be linked statically). diff --git a/test/fixtures/ios-qa/FixtureApp/Package.swift b/test/fixtures/ios-qa/FixtureApp/Package.swift index 40477f0fee..8e876d55ca 100644 --- a/test/fixtures/ios-qa/FixtureApp/Package.swift +++ b/test/fixtures/ios-qa/FixtureApp/Package.swift @@ -32,6 +32,9 @@ let package = Package( dependencies: [], path: "Sources/DebugBridgeTouch", publicHeadersPath: "include", + cSettings: [ + .define("DEBUG", to: "1", .when(configuration: .debug)), + ], linkerSettings: [ .linkedFramework("UIKit", .when(platforms: [.iOS])), ] diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/DebugBridgeTouch.m b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/DebugBridgeTouch.m index 7f7b7d1a3d..67e7d05bd0 100644 --- a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/DebugBridgeTouch.m +++ b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeTouch/DebugBridgeTouch.m @@ -20,7 +20,7 @@ #import "DebugBridgeTouch.h" #import -#if TARGET_OS_IOS +#if TARGET_OS_IOS && defined(DEBUG) #import #import @@ -286,7 +286,7 @@ + (BOOL)sendTapAtPoint:(CGPoint)point inWindow:(UIWindow *)window { @end -#else // !TARGET_OS_IOS +#else // !(TARGET_OS_IOS && DEBUG) // macOS / Catalyst / other non-iOS host build: no-op stub so the module // resolves cleanly without UIKit or IOKit. The Swift cross-platform tests @@ -298,4 +298,4 @@ + (BOOL)sendTapAtPoint:(CGPoint)point inWindow:(UIWindow *)window { } @end -#endif // TARGET_OS_IOS +#endif // TARGET_OS_IOS && defined(DEBUG) diff --git a/test/skill-e2e-ios-swift-build.test.ts b/test/skill-e2e-ios-swift-build.test.ts index 8a8c3b92b9..462b5dbaa0 100644 --- a/test/skill-e2e-ios-swift-build.test.ts +++ b/test/skill-e2e-ios-swift-build.test.ts @@ -61,6 +61,20 @@ describe('template ↔ fixture parity', () => { }); }); +describe('iOS Release private API guard', () => { + test('Objective-C touch implementation requires both iOS and DEBUG', () => { + const m = readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.m.template'), 'utf-8'); + expect(m).toContain('#if TARGET_OS_IOS && defined(DEBUG)'); + expect(m).not.toMatch(/#if\s+TARGET_OS_IOS\s*$/m); + }); + + test('Objective-C target receives DEBUG only in debug configuration', () => { + const pkg = readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'), 'utf-8'); + expect(pkg.split('\n')[0]).toMatch(/^\/\/ swift-tools-version:/); + expect(pkg).toMatch(/cSettings:\s*\[[\s\S]*?\.define\("DEBUG", to: "1", \.when\(configuration: \.debug\)\)/); + }); +}); + function hasSwift(): boolean { const r = spawnSync('swift', ['--version'], { stdio: 'pipe' }); return r.status === 0; From 13d3860a7c4ca830008e47017df6954b898abb26 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 14 Jul 2026 14:16:13 -0700 Subject: [PATCH 13/14] style(test): remove trailing blank line --- test/setup-playwright-deadline.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/setup-playwright-deadline.test.ts b/test/setup-playwright-deadline.test.ts index e16925b35c..c862756cf0 100644 --- a/test/setup-playwright-deadline.test.ts +++ b/test/setup-playwright-deadline.test.ts @@ -32,4 +32,3 @@ describe('setup Playwright deadline', () => { expect(SETUP.slice(warning, registration)).not.toMatch(/^\s*exit 1\s*$/m); }); }); - From 87b0600fb3461f2c450af192bb339128dc1b4c69 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 14 Jul 2026 14:24:32 -0700 Subject: [PATCH 14/14] fix(setup): avoid implicit Conductor AUQ hooks --- setup | 23 +++++-------------- ...tup-plan-tune-hooks-noninteractive.test.ts | 5 ++++ 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/setup b/setup index 4d8bbae600..8c84b5c80f 100755 --- a/setup +++ b/setup @@ -1494,16 +1494,10 @@ if [ "$NO_TEAM_MODE" -ne 1 ] \ *) PT_DECISION="prompt" ;; esac - # Conductor host reliability: the PreToolUse preference hook also carries the - # Conductor-prose enforcement (deny the flaky mcp__conductor__AskUserQuestion, - # redirect to a prose decision brief). A Conductor workspace setup otherwise - # falls through to "prompt" → the non-interactive skip below, leaving Conductor - # users without that backstop. Treat Conductor as an implicit opt-in — but - # only on the silent fall-through, never overriding an explicit --no-plan-tune-hooks. - if [ "$PT_DECISION" = "prompt" ] && { [ -n "${CONDUCTOR_WORKSPACE_PATH:-}" ] || [ -n "${CONDUCTOR_PORT:-}" ]; }; then - PT_DECISION="yes" - _PT_CONDUCTOR_AUTO=1 - fi + # Conductor must not implicitly opt in. Its Agent SDK uses PreToolUse hooks in + # the AskUserQuestion permission round-trip, where even a deferred preference + # hook can make the result disappear. Explicit flags, environment, and saved + # config still opt in normally; the default non-interactive path skips hooks. _install_plan_tune_hooks() { "$SETTINGS_HOOK" add-event \ @@ -1539,15 +1533,10 @@ if [ "$NO_TEAM_MODE" -ne 1 ] \ log "" log "Plan-tune hooks already installed. Run \`$SETTINGS_HOOK list-sources\` to inspect." elif [ "$PT_DECISION" = "yes" ]; then - # Explicit opt-in (flag / env / config) or Conductor implicit opt-in. Non-interactive. + # Explicit opt-in (flag / env / config). Non-interactive. _install_plan_tune_hooks log "" - if [ "${_PT_CONDUCTOR_AUTO:-0}" -eq 1 ]; then - log "AskUserQuestion reliability hooks installed (Conductor detected): decisions" - log "render as a prose brief instead of the flaky AskUserQuestion tool. Inspect with /plan-tune." - else - log "Plan-tune hooks installed. Run /plan-tune anytime to inspect." - fi + log "Plan-tune hooks installed. Run /plan-tune anytime to inspect." touch "$PLAN_TUNE_INSTALL_MARKER" elif [ "$PT_DECISION" = "no" ]; then # Explicit opt-out (flag / env / config). Non-interactive. diff --git a/test/setup-plan-tune-hooks-noninteractive.test.ts b/test/setup-plan-tune-hooks-noninteractive.test.ts index 9a0f03dede..7910b91a0a 100644 --- a/test/setup-plan-tune-hooks-noninteractive.test.ts +++ b/test/setup-plan-tune-hooks-noninteractive.test.ts @@ -60,6 +60,11 @@ describe('setup: plan-tune hooks are non-interactive-safe', () => { expect(setupSrc).toMatch(/tr '\[:upper:\]' '\[:lower:\]'/); expect(setupSrc).toMatch(/PT_DECISION=\$\(printf .* tr/); }); + + test('Conductor does not implicitly opt in to AskUserQuestion hooks', () => { + expect(setupSrc).not.toContain('_PT_CONDUCTOR_AUTO'); + expect(setupSrc).not.toMatch(/CONDUCTOR_(?:WORKSPACE_PATH|PORT)[\s\S]{0,300}PT_DECISION="yes"/); + }); }); describe('dev-setup: never silently mutates global settings.json', () => {