diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b891029a..4b27a46ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [1.92.23.0] - 2026-11-01 + +- **OpenClaw and Hermes: opt-in start hook.** `gstack-harness register openclaw|hermes --start-hook` (or `gstack-config set harness_start_hook on`, or `GSTACK_HARNESS_START_HOOK=1`) adds a `gstack-start` plugin that puts a one-line note on every user turn telling the agent to run a gstack skill's start command as written. Agents in these harnesses read gstack's skills but ran that command on 1 of 29 chances. gstack turns the plugin on through each harness's own CLI, checks that it is active, and exits 4 when it is not. It is off by default, the choice is saved across upgrades, a plugin you disable in the harness stays off, and `--no-start-hook`, unregister and uninstall take it out. See docs/hosts.md#start-hook. + ## [1.92.22.0] - 2026-11-01 **gstack installs into Gemini CLI, and installs made before v1.91.231.0 finally drop the test and eval packages they never needed.** `./setup --host gemini` puts every gstack skill in `~/.gemini/skills`, where Gemini CLI 0.66.0 lists them (#1247). Separately, `./setup` used to treat any checkout that had TypeScript installed as a contributor's, so every install made before v1.91.231.0 (which ran a full `bun install`) kept about 510 MB of dev packages in `node_modules` forever (#3447). diff --git a/VERSION b/VERSION index 893844ff7..ee815512e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.22.0 +1.92.23.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index cee2c4bf1..aacc58cd9 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.22.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.23.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-config b/bin/gstack-config index 8992806e2..fce1672af 100755 --- a/bin/gstack-config +++ b/bin/gstack-config @@ -208,6 +208,14 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne # # the same model; every reviewer keeps full. # Docs: docs/model-policy.md # +# harness_start_hook: # on | off. on: ./setup and gstack-harness add the +# # gstack-start plugin to OpenClaw and Hermes registrations +# # (a one-line note on every turn to run the start +# # command of a skill; edits openclaw.json or config.yaml via the +# # harness CLI). off: takes it out. Absent = each +# # registration keeps its saved choice (default off). +# # GSTACK_HARNESS_START_HOOK=1|0 overrides it. Docs: docs/hosts.md +# # ─── Advanced ──────────────────────────────────────────────────────── # codex_reviews: enabled # Outside review (Codex or Claude Code by harness): sends # # the review prompt and repository content to that model. @@ -368,6 +376,7 @@ lookup_default() { agent_model) echo "" ;; # empty = host (agents keep their own model); gstack-models resolve --role agent codex_effort) echo "" ;; # empty = each gate's own effort; /codex --effort, then GSTACK_CODEX_EFFORT, win (gstack-codex-probe run-with-timeout --effort) review_effort_tiers) echo "on" ;; # on | off — gstack-review-plan effort tiers (docs/model-policy.md#reasoning-effort) + harness_start_hook) echo "" ;; # on | off — empty = each harness registration keeps its saved choice (docs/hosts.md#start-hook) ship_greptile_early) echo "true" ;; # true | false — /ship's early Greptile PR pr_wait_gating_bots) echo "greptile,macroscope" ;; # review bots gstack-pr-wait --until ready waits for pr_wait_advisory_bots) echo "coderabbit" ;; # review bots gstack-pr-wait reports but never waits for @@ -833,6 +842,10 @@ $_gstack_legacy_root" echo "Error: review_effort_tiers '$VALUE' not recognized. Valid values: on, off. Existing value left unchanged." >&2 exit 1 fi + if [ "$KEY" = "harness_start_hook" ] && [ "$VALUE" != "on" ] && [ "$VALUE" != "off" ]; then + echo "Error: harness_start_hook '$VALUE' not recognized. Valid values: on, off. Existing value left unchanged. fix: gstack-config unset harness_start_hook keeps each registration's saved choice (docs/hosts.md#start-hook)" >&2 + exit 1 + fi if [ "$KEY" = "codex_effort" ]; then case "$VALUE" in minimal|low|medium|high|xhigh|max|ultra) ;; @@ -1100,7 +1113,7 @@ $_gstack_legacy_root" ship_measure_judge_outputs ship_measure_ask_per_trial_usd ship_measure_budget_usd \ ship_measure_max_rounds ship_measure_sweep_cases ship_measure_sweep_budget_usd ship_rerun_backend test_backend \ plan_review_tier implementation_tier model_frontier_claude model_frontier_openai \ - model_smart_claude model_smart_openai codex_effort review_effort_tiers agent_model; do + model_smart_claude model_smart_openai codex_effort review_effort_tiers agent_model harness_start_hook; do gstack_config_select "$KEY" VALUE="$_gstack_cfg_value" SOURCE="default" @@ -1125,7 +1138,7 @@ $_gstack_legacy_root" ship_measure_judge_outputs ship_measure_ask_per_trial_usd ship_measure_budget_usd \ ship_measure_max_rounds ship_measure_sweep_cases ship_measure_sweep_budget_usd ship_rerun_backend test_backend \ plan_review_tier implementation_tier model_frontier_claude model_frontier_openai \ - model_smart_claude model_smart_openai codex_effort review_effort_tiers agent_model; do + model_smart_claude model_smart_openai codex_effort review_effort_tiers agent_model harness_start_hook; do printf ' %-24s %s\n' "$KEY:" "$(lookup_default "$KEY")" done ;; diff --git a/bin/gstack-harness b/bin/gstack-harness index 5bd39f320..a80024b9f 100755 --- a/bin/gstack-harness +++ b/bin/gstack-harness @@ -8,10 +8,16 @@ * * Usage: * gstack-harness detect [--json] - * gstack-harness register [--skills router|hybrid|all] [--skills-dir ] [--context ] [--print] [--yes] [--no-registry] + * gstack-harness register [--skills router|hybrid|all] [--skills-dir ] [--context ] [--start-hook|--no-start-hook] [--print] [--yes] [--no-registry] * --context writes gstack's marked routing block into that file only * (opt-in: setup, upgrades and refresh never add one, and a block you * delete stays deleted); it prints the file and its scope before writing + * --start-hook (OpenClaw, Hermes; opt-in) adds the gstack-start plugin, which + * puts a one-line note on every user turn to run a gstack skill's start + * command, and turns it on through the harness's own commands (this edits + * openclaw.json or Hermes's config.yaml); --no-start-hook takes it out. The + * choice is saved; GSTACK_HARNESS_START_HOOK=1|0 or gstack-config + * harness_start_hook on|off make it too. Exit 4: skills registered, hook not active * gstack-harness unregister [--remote] * gstack-harness status [--json] [--no-check] * gstack-harness refresh @@ -46,11 +52,18 @@ function receiptLines(r: RegisterResult): string[] { 'gstack-harness: ' + t.displayName + ': ' + r.skills + ' gstack skills ' + how + ' at ' + r.dest + ' (' + r.status + (r.written ? ', ' + r.written + ' files written' : '') + ')', ...(r.router ? [' router: ' + r.router + ' (register with --skills all to list every skill natively)'] : []), ...r.kept.map(k => ' kept your edit: ' + k), + ...(r.hook ? [' start hook: ' + r.hook.state + (r.hook.state === 'active' ? ' (new sessions get the note; a running gateway picks it up when it reloads)' : '') + (r.hook.detail ? ' - ' + r.hook.detail : '') + (r.hook.fix ? '; ' + r.hook.fix : '')] : []), ' ledger: ' + r.ledger, ' remove: ' + self + ' unregister ' + r.id, ]; } const receipt = (r: RegisterResult) => console.log(receiptLines(r).join('\n')); +/** A start hook this run turned on, or was asked for, that did not end up active: exit 4 after the receipt. */ +function hookFailed(r: RegisterResult): number { + if (!r.hook?.required || r.hook.state === 'active') return 0; + console.error(new HarnessError('HARNESS_HOOK_INACTIVE', TARGETS[r.target].displayName + ' start hook is ' + r.hook.state + (r.hook.detail ? ': ' + r.hook.detail : '') + (r.hook.fix ? '. Turn it on: ' + r.hook.fix : '')).report()); + return 4; +} function usage(code: number): never { const text = fs.readFileSync(process.argv[1], 'utf8').split('\n').slice(1).filter(l => l.startsWith(' *')).map(l => l.slice(3)).join('\n'); @@ -73,6 +86,7 @@ async function main(): Promise { const mode = flag('--skills') as SkillsMode | undefined; const skillsDir = flag('--skills-dir'); const context = flag('--context'); const print = bool('--print'); const yes = bool('--yes'); const noRegistry = bool('--no-registry'); + const startHook = bool('--start-hook') ? true : bool('--no-start-hook') ? false : undefined; const t = getTarget(argv.shift() ?? ''); if (print) { console.log(printPayload(checkout)); return 0; } if (t.name === 'qm') { @@ -88,10 +102,10 @@ async function main(): Promise { return 3; } if (context) (json ? console.error : console.log)('gstack-harness: context block: ' + path.resolve(context) + ' (' + contextScope(t, context, env, home) + ' scope)'); - const r = register({ ...base, target: t, mode, skillsDir, context, registry: !noRegistry }); + const r = register({ ...base, target: t, mode, skillsDir, context, registry: !noRegistry, startHook }); if (json) console.log(JSON.stringify({ ...r, receipt: receiptLines(r).map(l => l.trim()).join('; ') })); else receipt(r); - return 0; + return hookFailed(r); } case 'unregister': { const remote = bool('--remote'); @@ -113,23 +127,25 @@ async function main(): Promise { const extra = (['muse', 'grok'] as TargetName[]).filter(n => TARGETS[n].present(env, home)).map(n => ({ target: n, ...(check ? harnessSees(TARGETS[n], '', env, ['review', 'gstack-review'], true) : { visible: 'unknown' as const }) })); if (json) { console.log(JSON.stringify({ registrations: rows, detected: extra })); return 0; } if (!rows.length && !extra.length) console.log('gstack-harness: no harness registrations on this machine'); - for (const r of rows) console.log(r.target + '\t' + r.mode + '\t' + r.dest + '\t' + r.files + ' files\t' + (r.stale ? 'stale (' + r.version + '; refresh: ' + self + ' refresh)' : r.version) + '\tvisible to harness: ' + r.visible + (r.checked ? ' (' + r.checked + ')' : '') + (r.missing?.length ? ' missing: ' + r.missing.slice(0, 5).join(', ') : '') + '\t' + r.id); + for (const r of rows) console.log(r.target + '\t' + r.mode + '\t' + r.dest + '\t' + r.files + ' files\t' + (r.stale ? 'stale (' + r.version + '; refresh: ' + self + ' refresh)' : r.version) + '\tvisible to harness: ' + r.visible + (r.checked ? ' (' + r.checked + ')' : '') + (r.missing?.length ? ' missing: ' + r.missing.slice(0, 5).join(', ') : '') + '\t' + r.id + + (r.hook ? '\n start hook: ' + r.hook.state + (r.hook.detail ? ' - ' + r.hook.detail : '') + (r.hook.state !== 'active' && r.hook.fix ? '; ' + r.hook.fix : '') : '')); for (const x of extra) console.log(x.target + '\tdetect\t~/.claude/skills\t' + (x.visible === 'yes' ? 'discovered (Claude-rendered, unverified)' : 'unverified') + (x.checked ? ' (' + x.checked + ')' : '')); return 0; } case 'refresh': { - for (const r of refresh(base)) if (r.status !== 'unchanged') receipt(r); - return 0; + let code = 0; + for (const r of refresh(base)) { if (r.status !== 'unchanged' || r.hook?.required || r.hook?.retried || r.hook?.detail) receipt(r); code ||= hookFailed(r); } + return code; } case 'auto': { const d = detect(env, home); let failed = 0; const done = new Set(); for (const n of d.inside.filter(n => TARGETS[n].mechanism === 'skill-folder')) { - try { const r = register({ ...base, target: TARGETS[n], registry: true }); done.add(r.id); receipt(r); } + try { const r = register({ ...base, target: TARGETS[n], registry: true }); done.add(r.id); receipt(r); hookFailed(r); } catch (e) { if (e instanceof HarnessError) { console.error(e.report()); failed = 1; } else throw e; } } - try { for (const r of refresh(base, done)) if (r.status !== 'unchanged') receipt(r); } + try { for (const r of refresh(base, done)) { if (r.status !== 'unchanged' || r.hook?.required || r.hook?.retried || r.hook?.detail) receipt(r); hookFailed(r); } } catch (e) { if (e instanceof HarnessError) { console.error(e.report()); failed = 1; } else throw e; } for (const n of d.present.filter(n => !d.inside.includes(n) && TARGETS[n].mechanism === 'skill-folder' && !listLedgers(stateRoot).some(l => l.ledger.meta.target === n))) { console.log('gstack-harness: ' + TARGETS[n].displayName + ' is installed here; to give it gstack skills: ' + self + ' register ' + n); diff --git a/bin/gstack-harness-ledger.sh b/bin/gstack-harness-ledger.sh index 210e85326..6d24acb4b 100644 --- a/bin/gstack-harness-ledger.sh +++ b/bin/gstack-harness-ledger.sh @@ -20,12 +20,25 @@ # (ledgers without the row: when only whitespace is left). Folders gstack # created are removed only when empty. A QM pack is never touched here: detaching it from # the org is `gstack-harness unregister qm --remote`. +# +# A registration with the start hook on (meta start_hook on) owns a second root, the +# gstack-start plugin folder in the harness profile (meta hook_root). Before its files go, +# gstack_harness_hook_release turns the plugin off through the harness's own CLI +# (lib/harness-start-hook.ts), the same step `register --no-start-hook` runs. _gstack_harness_sha() { if command -v sha256sum >/dev/null 2>&1; then sha256sum ${1:+"$1"} | cut -d' ' -f1 else shasum -a 256 ${1:+"$1"} | cut -d' ' -f1; fi } +# True when $1 is below $2 and nothing between them is a symlink ($2 itself may be one). +_gstack_harness_below() { + local p="$1" top="$2" + [ -n "$top" ] || return 1 + case "$p" in "$top"/*) ;; *) return 1 ;; esac + while [ "$p" != "$top" ]; do [ -L "$p" ] && return 1; p="$(dirname "$p")"; done +} + # True when $1 is below $2 and no path from $2 down to $1 is a symlink. _gstack_harness_owned() { local p="$1" dest="$2" @@ -57,16 +70,53 @@ _gstack_harness_cut_block() { rm -f "$tmp" } +# Turns a registration's start hook off in its harness, from the ledger: OpenClaw drops +# plugins.entries.gstack-start; Hermes removes the plugin and its config entries when its +# folder holds exactly the files gstack wrote, unedited, and is reached without a symlink +# below the profile; otherwise it only disables it so an edit or a kept copy survives. The harness runs with PATH, HOME and the profile only. Without the harness on +# PATH it prints the command to run instead. +gstack_harness_hook_release() { + local ledger="$1" kind a b target="" on="" profile="" root="" edited=0 to="" sub cmd n=0 safe=1 total + [ -f "$ledger" ] || return 0 + while IFS=$'\t' read -r kind a b; do + case "$kind" in + meta) case "$a" in target) target="$b" ;; start_hook) on="$b" ;; hook_profile) profile="$b" ;; hook_root) root="$b" ;; esac ;; + file) if [ -n "$root" ]; then case "$b" in "$root"/*) if [ -f "$b" ]; then n=$((n + 1)); [ "$(_gstack_harness_sha "$b")" != "$a" ] && edited=1; fi ;; esac; fi ;; + esac + done < "$ledger" + { [ "$on" = on ] && [ -n "$profile" ] && [ -n "$root" ]; } || return 0 + if [ -d "$root" ]; then + _gstack_harness_below "$root" "$profile" || { safe=0; edited=1; } + total="$(find "$root" -type f ! -path "$root/__pycache__/*" 2>/dev/null | wc -l | tr -d ' ')" + [ "${total:-0}" -gt "$n" ] && edited=1 + fi + command -v timeout >/dev/null 2>&1 && to="timeout 60" + case "$target" in + openclaw) cmd="config unset plugins.entries.gstack-start" + if command -v openclaw >/dev/null 2>&1 && env -i PATH="$PATH" HOME="$HOME" OPENCLAW_STATE_DIR="$profile" $to openclaw $cmd /dev/null 2>&1; then : + else echo " start hook: turn it off in OpenClaw yourself: OPENCLAW_STATE_DIR=\"$profile\" openclaw $cmd"; fi ;; + hermes) sub=remove; [ "$edited" = 1 ] && sub=disable + if command -v hermes >/dev/null 2>&1 && env -i PATH="$PATH" HOME="$HOME" HERMES_HOME="$profile" $to hermes plugins "$sub" gstack-start /dev/null 2>&1; then : + else echo " start hook: turn it off in Hermes yourself: HERMES_HOME=\"$profile\" hermes plugins disable gstack-start"; fi ;; + esac + if [ "$safe" = 1 ] && [ -d "$root/__pycache__" ] && _gstack_harness_owned "$root/__pycache__" "$root"; then + rm -f "$root"/__pycache__/*.pyc + rmdir "$root/__pycache__" 2>/dev/null + fi + return 0 +} + gstack_harness_unregister_ledger() { - local ledger="$1" stamp kind a b target="" dest="" pack="" pad="" padpath="" + local ledger="$1" stamp kind a b target="" dest="" pack="" pad="" padpath="" hroot="" hprof="" : "${GSTACK_STATE_ROOT:?gstack-harness-ledger: GSTACK_STATE_ROOT is not set}" [ -f "$ledger" ] || return 0 stamp="$(date -u +%Y-%m-%dT%H-%M-%SZ)" + gstack_harness_hook_release "$ledger" while IFS=$'\t' read -r kind a b; do case "$kind" in - meta) case "$a" in target) target="$b" ;; dest) dest="$b" ;; qm_pack) pack="$b" ;; esac ;; + meta) case "$a" in target) target="$b" ;; dest) dest="$b" ;; qm_pack) pack="$b" ;; hook_root) hroot="$b" ;; hook_profile) hprof="$b" ;; esac ;; file) - { [ -f "$b" ] && _gstack_harness_owned "$b" "$dest"; } || continue + { [ -f "$b" ] && { _gstack_harness_owned "$b" "$dest" || { _gstack_harness_owned "$b" "$hroot" && _gstack_harness_below "$hroot" "$hprof"; }; }; } || continue if [ "$(_gstack_harness_sha "$b")" = "$a" ]; then rm -f "$b" else mv "$b" "$b.edited-$stamp" && echo " kept your edit: $b.edited-$stamp"; fi ;; blockpad) pad="$a"; padpath="$b" ;; @@ -75,7 +125,10 @@ gstack_harness_unregister_ledger() { done < "$ledger" while IFS=$'\t' read -r kind a b; do [ "$kind" = dir ] && [ -d "$b" ] && [ ! -L "$b" ] || continue - case "$b" in "$dest"/*) _gstack_harness_owned "$b" "$dest" || continue ;; esac + case "$b" in + "$dest"/*) _gstack_harness_owned "$b" "$dest" || continue ;; + *) if [ -n "$hprof" ]; then case "$b" in "$hprof"/*) _gstack_harness_below "$b" "$hprof" || continue ;; esac; fi ;; + esac rmdir "$b" 2>/dev/null done < "$ledger" case "$target" in diff --git a/docs/OPENCLAW.md b/docs/OPENCLAW.md index e73bab35a..b3fe8af55 100644 --- a/docs/OPENCLAW.md +++ b/docs/OPENCLAW.md @@ -195,6 +195,11 @@ registered as a short entry that tells the agent to read the skill's full `SKILL.md` in `gstack/skills/`, a plain copy of every skill that OpenClaw reads in pages (`.gstack-skills/` under `--skills all`). Install, verify and remove: [hosts.md](hosts.md#agent-harnesses). +Agents in OpenClaw read gstack's skills but rarely run their start command, so +there is an opt-in [start hook](hosts.md#start-hook): `gstack-harness register +openclaw --start-hook` adds a `gstack-start` plugin that puts a one-line note on +every user turn, and grants it the conversation access OpenClaw requires for +`before_prompt_build` through `openclaw config set`. The ACP artifacts in `openclaw/` are generated by `bun run gen:skill-docs --host openclaw`. diff --git a/docs/hosts.md b/docs/hosts.md index 30eacb14c..3482674db 100644 --- a/docs/hosts.md +++ b/docs/hosts.md @@ -24,6 +24,8 @@ neither does `./setup --local`. **What it writes.** Copies in the harness's own skills folder, never edits to `openclaw.json`, Hermes's `config.yaml` or any context file you did not name. +The one exception is the [start hook](#start-hook), which you turn on yourself: +it adds a plugin and enables it through the harness's own commands. A context block is opt-in: `gstack-harness register openclaw|hermes|generic --context ` writes the marked block into that file only, after printing the file and its scope (`profile` inside the harness's own home, else @@ -67,6 +69,30 @@ characters, the part of a description Hermes's skills index shows. | Grok Bot, any chat-only skill library | nothing on disk | `gstack-harness register generic --print`, then paste the router and block into the app | ask it for a gstack skill | delete the pasted text | | any other harness | the router in an Agent Skills folder you name, and/or a 600-byte routing block between `` markers in a context file you name | `gstack-harness register generic --skills-dir ` and/or `--context `; `gstack-harness manifest generic` prints every skill as JSON | the harness's own skill list | `gstack-harness unregister generic` | + +### Start hook (OpenClaw and Hermes, opt-in) + +Every gstack skill starts with a command in its Preamble (`gstack-skill-start`) that sets up the session: whether it runs unattended, questions, consent, upgrade checks. In OpenClaw and Hermes, agents read gstack's skills but almost never run that command (1 start in 29 chances, HARN-18). The start hook is a small `gstack-start` plugin that puts one note of about 210 characters on every user turn, telling the agent to run that command first, exactly as written, with no pipe. + +It is off unless you turn it on, because turning it on changes the harness's own settings: + +| | OpenClaw | Hermes | +|---|---|---| +| Plugin folder | `${OPENCLAW_STATE_DIR:-~/.openclaw}/extensions/gstack-start` (index.js, openclaw.plugin.json, package.json, note.txt) | `${HERMES_HOME:-~/.hermes}/plugins/gstack-start` (plugin.yaml, __init__.py, note.txt) | +| Hook | `before_prompt_build`, returning the note as `prependContext` | `pre_llm_call`, returning the note as `context` | +| Settings it changes, through the harness's own CLI | `openclaw config set plugins.entries.gstack-start.hooks.allowConversationAccess true` and `...enabled true` in `openclaw.json`. OpenClaw runs `before_prompt_build` from a plugin it did not bundle only with that grant, which also lets the plugin's hooks see conversation content (the plugin reads only its own note.txt) | `hermes plugins enable gstack-start` adds it to `plugins.enabled` in `config.yaml` (and creates `config.yaml` when there is none) | +| Check | `openclaw plugins inspect gstack-start --runtime --json` lists `before_prompt_build` under `typedHooks` | `hermes plugins list` shows `gstack-start` enabled | + +**Turn it on.** `gstack-harness register openclaw --start-hook` (or `hermes`), or `gstack-config set harness_start_hook on` followed by `./setup`, or `GSTACK_HARNESS_START_HOOK=1 ./setup --host openclaw`. The first of these that is set decides: the `--start-hook`/`--no-start-hook` flag, then `GSTACK_HARNESS_START_HOOK` (`1`/`on` or `0`/`off`), then `harness_start_hook`, then the choice saved in the registration's ledger. Whatever decides, the choice is saved, so refresh and upgrades keep it. The hook goes only into the harness profile's own plugin folder; a registration with a custom `--skills-dir`, or OpenClaw run with `OPENCLAW_CONFIG_PATH` pointing at another config, gets no hook (`HARNESS_HOOK_UNSUPPORTED` when you ask for one). A hook you already turned on stays on when a later run happens to have `OPENCLAW_CONFIG_PATH` set. + +**Check it.** The receipt and `gstack-harness status` show `start hook: active` (configured for new sessions; a running OpenClaw gateway or Hermes gateway picks it up when it reloads or restarts), `written` (files in place but not active, with the reason and the command that fixes it), `disabled` (you turned it off in the harness), `unchecked` (a run that left a hook already on alone, or `status --no-check`; `gstack-harness status` checks it) or `off`. When the run that turns the hook on (or `--start-hook`) leaves it inactive, `gstack-harness register` and `./setup --host` exit 4 after registering the skills (`HARNESS_HOOK_INACTIVE`). Later refreshes and upgrades try once more each time, without failing, until the hook is active or you disable the plugin in the harness. + +**Turn it off.** `gstack-harness register openclaw --no-start-hook` (or `hermes`), or `gstack-config set harness_start_hook off` followed by `./setup`. Either turns the plugin off through the harness's CLI and removes its folder. You can also switch it off in the harness (`openclaw config set plugins.entries.gstack-start.enabled false`, `hermes plugins disable gstack-start`); gstack then leaves it off and never turns it back on unless you pass `--start-hook` again. `gstack-harness unregister` and `gstack-uninstall` remove it with the rest (Hermes only disables it when its folder holds an edited file or a kept `.edited-*` copy, so your edit survives), and print the harness command to run when the harness CLI is not on `PATH`. + +**What it costs.** The note is about 50 tokens and lands on every user turn, and each copy stays in the session's history, so a long session pays more (mostly cache reads). HARN-18's panel measures the added cost per trial. OpenClaw also logs that it cannot verify where the plugin came from, because gstack writes it instead of installing it from npm or ClawHub. + +**One owner.** A `gstack-start` folder this registration did not write stops the hook (`HARNESS_HOOK_OWNED_ELSEWHERE`): turn it off from the gstack install that wrote it, or move it aside. + OpenClaw's ACP route below is unchanged: native registration gives OpenClaw's own agent gstack's skills; ACP spawns Claude Code with the full install. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 654641b76..c5cdb50ef 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -6019,6 +6019,26 @@ The router's description (at most 300 bytes) and the context block (at most 600 There is no ledger for that harness or id under `$GSTACK_STATE_ROOT/harness`. `gstack-harness status` lists every registration on this machine with its id. + +### `gstack-harness: has no start hook` / `the start hook goes into the profile , and this registration uses another skills folder` (HARNESS_HOOK_UNSUPPORTED) + +The [start hook](hosts.md#start-hook) exists for OpenClaw and Hermes only, and it goes into the harness profile's own plugin folder, the one the harness scans. A registration with `--skills-dir` somewhere else has no profile gstack can trust, and with `OPENCLAW_CONFIG_PATH` set OpenClaw reads a config outside the profile, where gstack's settings change would not land, so `--start-hook` is refused and nothing is written. Register without `--skills-dir` (set `OPENCLAW_STATE_DIR` or `HERMES_HOME` to pick the profile), or drop `--start-hook`. When the choice comes from `GSTACK_HARNESS_START_HOOK` or `harness_start_hook` instead of the flag, such a registration just gets no hook, and its receipt says so. + + +### `gstack-harness: GSTACK_HARNESS_START_HOOK="" is not on or off` (HARNESS_HOOK_CHOICE_INVALID) + +The start hook switch takes `on` or `off` (`GSTACK_HARNESS_START_HOOK` also takes `1` or `0`). Nothing was registered. Fix the value, or unset it so each registration keeps its saved choice. + + +### `gstack-harness: a gstack-start plugin already exists at and this registration did not write it` (HARNESS_HOOK_OWNED_ELSEWHERE) + +Each harness profile has one start hook, owned by the gstack registration that wrote it, so that removing one install can never take another install's hook away. This folder is not in this registration's ledger: another gstack checkout or state root wrote it, or someone made it by hand. Nothing was written. Run `gstack-harness register --no-start-hook` from the gstack install that wrote it (its `gstack-harness status` shows the hook), or move the folder aside, then register again. + + +### `gstack-harness: start hook is written: . Turn it on: ` (HARNESS_HOOK_INACTIVE, exit 4) + +The skills are registered and the start hook's files are in place, but the hook is not active in the harness. gstack turns the hook on through the harness's own CLI and then reads it back, so this means the CLI was not on `PATH`, one of its commands failed or timed out (the message quotes its last output), or the harness's settings block the plugin: OpenClaw's `plugins.allow` set without `gstack-start`, `gstack-start` in `plugins.deny`, or `hooks.allowPromptInjection: false`. gstack never edits your allow or deny lists. Run the printed command, then `gstack-harness status`; `--start-hook` again retries the whole step. `./setup --host` exits 4 for the same reason. Refreshes and upgrades retry on their own without failing, and stop once you disable the plugin in the harness. + ### `gstack-harness: no QM admin credential in the environment` / `... needs --yes` (HARNESS_QM_CREDENTIALS) diff --git a/docs/uninstall.md b/docs/uninstall.md index 8428c44d1..c0d044329 100644 --- a/docs/uninstall.md +++ b/docs/uninstall.md @@ -8,7 +8,7 @@ If gstack is installed on your machine: ~/.claude/skills/gstack/bin/gstack-uninstall ``` -This handles skills, symlinks, harness registrations (the files `gstack-harness` wrote into OpenClaw, Hermes, a Dot or another harness, from their ledgers), global state (`~/.gstack/`), project-local state, browse daemons, and temp files. Use `--keep-state` to preserve config and analytics. Use `--force` to skip confirmation. +This handles skills, symlinks, harness registrations (the files `gstack-harness` wrote into OpenClaw, Hermes, a Dot or another harness, from their ledgers, plus the opt-in [start hook](hosts.md#start-hook), which it turns off through the harness's own CLI), global state (`~/.gstack/`), project-local state, browse daemons, and temp files. Use `--keep-state` to preserve config and analytics. Use `--force` to skip confirmation. ## Option 2: Manual removal (no local repo) @@ -72,6 +72,13 @@ hook, the AskUserQuestion PreToolUse/PostToolUse hooks, and the Stop hooks (session timeline, plus verify-gate if you opted in). Left in place, they error on every matching event once the install directory is gone. +If you turned on the OpenClaw or Hermes [start hook](hosts.md#start-hook), +manual removal also leaves its `gstack-start` plugin turned on. Turn it off +with `openclaw config unset plugins.entries.gstack-start` or `hermes plugins +disable gstack-start`, then delete +`${OPENCLAW_STATE_DIR:-~/.openclaw}/extensions/gstack-start` or +`${HERMES_HOME:-~/.hermes}/plugins/gstack-start`. + ## 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. diff --git a/lib/harness-register.ts b/lib/harness-register.ts index 8ac054d77..9b00b665f 100644 --- a/lib/harness-register.ts +++ b/lib/harness-register.ts @@ -22,6 +22,7 @@ import { spawnSync } from 'node:child_process'; import { bunIsolationArgs } from './bun-isolation'; import { runBashSync } from './script-invocation'; import { frontmatterList, routerLine } from './skill-triggers'; +import { activateHook, assertHookRoot, hookChoice, hookFiles, hookProfile, hookRoot, inspectHook, isHookTarget, profileSkills, type HookResult } from './harness-start-hook'; export type SkillsMode = 'router' | 'hybrid' | 'all'; export type TargetName = 'openclaw' | 'hermes' | 'dots' | 'generic' | 'qm' | 'muse' | 'grok'; @@ -134,6 +135,10 @@ export const HARNESS_CODES = { HARNESS_RENDER_FAILED: { anchor: 'harness-render-failed', fix: 'run bun install in the gstack checkout, then register again; the render output above names the failing skill' }, HARNESS_BUDGET_EXCEEDED: { anchor: 'harness-budget-exceeded', fix: 'this is a gstack bug: report it with the byte count printed above' }, HARNESS_NOT_REGISTERED: { anchor: 'harness-not-registered', fix: 'run gstack-harness status to see the registrations on this machine' }, + HARNESS_HOOK_UNSUPPORTED: { anchor: 'harness-hook-unsupported', fix: 'the start hook is for OpenClaw and Hermes registered in their own profile: drop --skills-dir (set OPENCLAW_STATE_DIR or HERMES_HOME to pick the profile), or drop --start-hook' }, + HARNESS_HOOK_CHOICE_INVALID: { anchor: 'harness-hook-choice-invalid', fix: 'set it to on or off (GSTACK_HARNESS_START_HOOK also takes 1 or 0), or unset it' }, + HARNESS_HOOK_OWNED_ELSEWHERE: { anchor: 'harness-hook-owned-elsewhere', fix: 'if another gstack install wrote it, run that install\'s gstack-harness register --no-start-hook; otherwise move the folder aside, then register again' }, + HARNESS_HOOK_INACTIVE: { anchor: 'harness-hook-inactive', fix: 'run the command printed above, then gstack-harness status; the skills themselves are registered' }, HARNESS_QM_CREDENTIALS: { anchor: 'harness-qm-credentials', fix: 'set GSTACK_QM_URL and either GSTACK_QM_ADMIN_TOKEN, or GSTACK_QM_SIGNING_SECRET with GSTACK_QM_ADMIN_ACTOR, then rerun with --yes' }, HARNESS_QM_API: { anchor: 'harness-qm-api', fix: 'check that the QM core URL is reachable and the credential is an org admin; gstack-harness status shows whether a pack was left half-registered' }, HARNESS_QM_PACK_TOO_LARGE: { anchor: 'harness-qm-pack-too-large', fix: 'QM registration is not available yet; give a QM agent gstack with gstack-harness register generic --print pasted into its instructions' }, @@ -342,10 +347,14 @@ export interface RegisterOptions { version: string; /** Publish an installs.tsv row for host targets (setup's own arm publishes it instead). */ registry?: boolean; + /** --start-hook (true) or --no-start-hook (false); otherwise GSTACK_HARNESS_START_HOOK, gstack-config harness_start_hook, then the ledger decide (lib/harness-start-hook.ts). */ + startHook?: boolean; } export interface RegisterResult { id: string; target: TargetName; dest: string; mode: SkillsMode; status: 'installed' | 'updated' | 'unchanged'; written: number; skills: number; router: string | null; ledger: string; kept: string[]; + /** The start hook, for OpenClaw and Hermes; required: this run turned it on or was asked to, so anything but active is a failure. */ + hook?: HookResult & { required?: boolean; retried?: boolean }; } const PLAIN = /^[^\t\n\r]+$/; @@ -424,9 +433,10 @@ function keepEdit(file: string, stamp: string): string { return to; } -/** Refuses a path outside dest, or one reached through a symlink below dest. */ -function assertOwnedPath(dest: string, p: string): void { - if (!p.startsWith(dest + path.sep)) throw new HarnessError('HARNESS_UNSAFE_DESTINATION', p + ' is outside ' + dest); +/** Refuses a path outside every owned root (the destination, and the start hook's folder), or one reached through a symlink below its root. */ +function assertOwnedPath(roots: string[], p: string): void { + const dest = roots.find(r => r && p.startsWith(r + path.sep)); + if (!dest) throw new HarnessError('HARNESS_UNSAFE_DESTINATION', p + ' is outside ' + roots.filter(Boolean).join(' and ')); for (let d = p; d !== dest; d = path.dirname(d)) { let st: fs.Stats; try { st = fs.lstatSync(d); } catch { continue; } @@ -515,6 +525,56 @@ function shellRegistry(opts: RegisterOptions, fn: string, args: string[]): boole return sourcedBash(opts.checkout, 'gstack-install-registry.sh', fn, args, opts.stateRoot, opts.env).status === 0; } +interface HookPlan { on: boolean; prevOn: boolean; explicit: boolean; retry: boolean; profile: string; root: string; files: Map; note?: string } + +/** The start hook for this run: whether it is on, where it goes, and its files (null for targets without a hook). */ +function hookPlan(opts: RegisterOptions, dest: string, prev: Ledger | null): HookPlan | null { + const t = opts.target; + if (!isHookTarget(t.name)) { + if (opts.startHook) throw new HarnessError('HARNESS_HOOK_UNSUPPORTED', t.displayName + ' has no start hook'); + return null; + } + const choice = hookChoice(opts.startHook, opts.env, opts.stateRoot, prev?.meta.start_hook); + if ('invalid' in choice) throw new HarnessError('HARNESS_HOOK_CHOICE_INVALID', choice.invalid + ' is not on or off'); + const prevOn = prev?.meta.start_hook === 'on' && !!prev.meta.hook_root && !!prev.meta.hook_profile; + const profile = prevOn ? prev!.meta.hook_profile : hookProfile(t.name, opts.env, opts.home); + const root = hookRoot(t.name, profile); + const plan = { on: choice.on, prevOn, explicit: choice.source === 'flag', retry: prevOn && prev!.meta.hook_active === 'no', profile, root, files: new Map() }; + const saved = prev?.meta.hook_root && prev.meta.hook_profile ? { profile: prev.meta.hook_profile, root: prev.meta.hook_root } : null; + const name = t.name; + const off = (p: HookPlan) => { if (saved) assertHookRoot(name, saved.profile, saved.root, saved.root, (code, msg) => { throw new HarnessError(code, msg); }); return p; }; + if (!choice.on) return off(plan); + const why = dest !== profileSkills(t.name, profile) ? 'this registration uses another skills folder (' + dest + ')' + : t.name === 'openclaw' && opts.env.OPENCLAW_CONFIG_PATH ? 'OPENCLAW_CONFIG_PATH points OpenClaw at a config outside that profile' : ''; + if (why && (plan.explicit || !prevOn)) { + if (plan.explicit) throw new HarnessError('HARNESS_HOOK_UNSUPPORTED', 'the start hook goes into the ' + t.displayName + ' profile ' + profile + ', and ' + why); + return off({ ...plan, on: false, note: 'off: ' + why + ' (' + choice.source + ' asked for the hook)' }); + } + try { + assertHookRoot(t.name, profile, root, prevOn ? prev!.meta.hook_root : undefined, (code, msg) => { throw new HarnessError(code, msg); }); + } catch (e) { + if (plan.explicit || prevOn || !(e instanceof HarnessError)) throw e; + return off({ ...plan, on: false, note: 'off: ' + e.message + ' (' + choice.source + ' asked for the hook; ' + HARNESS_CODES[e.code].fix + ')' }); + } + return { ...plan, files: hookFiles(t.name, root) }; +} + +/** Turns the hook on after its files are written (when this run turns it on, was asked to, or the last try did not end active), and reads it back. */ +function finishHook(opts: RegisterOptions, hook: HookPlan | null, released: string): RegisterResult['hook'] { + if (!hook) return undefined; + if (!hook.on) return { state: 'off', ...(hook.note ? { detail: hook.note } : {}), ...(released ? { fix: released } : {}) }; + const t = opts.target.name as 'openclaw' | 'hermes'; + const required = !hook.prevOn || hook.explicit; + if (!required && !hook.retry) return { state: 'unchecked', profile: hook.profile, root: hook.root, fix: 'gstack-harness status shows whether it is active' }; + if (!required) { + const now = inspectHook(t, hook.profile, opts.env, opts.home); + if (now.state === 'active' || now.state === 'disabled') return { ...now, profile: hook.profile, root: hook.root, retried: true }; + } + const failed = activateHook(t, hook.profile, opts.env, opts.home); + const r = inspectHook(t, hook.profile, opts.env, opts.home); + return { ...r, ...(failed && r.state !== 'active' && !r.detail?.startsWith(failed) ? { detail: failed + (r.detail ? '; ' + r.detail : '') } : {}), profile: hook.profile, root: hook.root, required, retried: !required }; +} + export function register(opts: RegisterOptions): RegisterResult { const t = opts.target; if (t.mechanism === 'verify-only' || t.mechanism === 'skill-pack-api') throw new HarnessError('HARNESS_UNKNOWN_TARGET', t.name + ' is not registered through files (use status for ' + t.name + ', or lib/harness-qm.ts for qm)'); @@ -530,12 +590,13 @@ export function register(opts: RegisterOptions): RegisterResult { if (explicit) { assertSafeDest(explicit, opts); assertContextFile(t, explicit); } const lfile = ledgerPath(opts.stateRoot, id); const prev = readLedger(lfile); + const hook = hookPlan(opts, dest, prev); const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-harness-')); try { const { files, router, visible, direct, skills } = planPayload(opts, dest, mode, scratch); const prevFiles = new Map((prev?.files ?? []).map(f => [f.path, f.sha])); const topOf = (p: string) => path.join(dest, path.relative(dest, p).split(path.sep)[0]); - const owned = new Set([...prevFiles.keys()].map(topOf)); + const owned = new Set([...prevFiles.keys()].filter(p => p.startsWith(dest + path.sep)).map(topOf)); for (const top of new Set([...files.keys()].map(topOf))) { let st: fs.Stats; try { st = fs.lstatSync(top); } catch { continue; } @@ -549,7 +610,8 @@ export function register(opts: RegisterOptions): RegisterResult { const other = frontmatterField(fs.readFileSync(md, 'utf8'), 'name'); if (visible.includes(other)) throw new HarnessError('HARNESS_UNOWNED_ENTRY', 'a skill named ' + other + ' already exists at ' + path.join(dest, e) + ' and gstack did not write it'); } - for (const p of new Set([...files.keys(), ...prevFiles.keys()])) assertOwnedPath(dest, p); + for (const [p, b] of hook?.files ?? []) files.set(p, b); + for (const p of new Set([...files.keys(), ...prevFiles.keys()])) assertOwnedPath([dest, prev?.meta.hook_root ?? '', hook?.on ? hook.root : ''], p); const want = [...files].map(([p, b]) => ({ path: p, sha: sha256(b) })); const context = opts.context ? path.resolve(opts.context) : prev?.meta.context ?? ''; const block = contextBlock(router, direct); @@ -573,18 +635,21 @@ export function register(opts: RegisterOptions): RegisterResult { const same = prev && prev.meta.mode === mode && prev.meta.names === visible.join(',') && prev.files.length === want.length && (prev.meta.context ?? '') === context && prev.blocks.length === blocks.length && blocks.every(b => prev.blocks.some(p => p.path === b.path && p.sha === b.sha && p.pad === b.pad)) - && want.every(w => prevFiles.get(w.path) === w.sha && onDiskSha(w.path) === w.sha); + && want.every(w => prevFiles.get(w.path) === w.sha && onDiskSha(w.path) === w.sha) + && (!hook || (prev.meta.start_hook ?? 'off') === (hook.on ? 'on' : 'off')); const stamp = new Date().toISOString().replace(/[:.]/g, '-'); const kept: string[] = []; let written = 0; + const released = hook?.prevOn && !hook.on ? sourcedBash(opts.checkout, 'gstack-harness-ledger.sh', 'gstack_harness_hook_release', [lfile], opts.stateRoot, opts.env).stdout.trim() : ''; if (!same) { const dirs = new Set(prev?.dirs ?? []); for (const p of files.keys()) for (let d = path.dirname(p); !fs.existsSync(d) && path.dirname(d) !== d; d = path.dirname(d)) dirs.add(d); - const record = (recorded: typeof want) => writeLedger(lfile, { - meta: { id, target: t.name, dest, mode, checkout: opts.checkout, version: opts.version, names: visible.join(','), ...(router ? { router } : {}), ...(context ? { context } : {}) }, + const record = (recorded: typeof want, final: boolean) => writeLedger(lfile, { + meta: { id, target: t.name, dest, mode, checkout: opts.checkout, version: opts.version, names: visible.join(','), ...(router ? { router } : {}), ...(context ? { context } : {}), + ...(hook ? { start_hook: hook.on ? 'on' : 'off' } : {}), ...(hook?.on ? { hook_profile: hook.profile, hook_root: hook.root, ...(!hook.prevOn || hook.explicit ? { hook_active: 'no' } : prev?.meta.hook_active ? { hook_active: prev.meta.hook_active } : {}) } : !final && prev?.meta.hook_root ? { hook_profile: prev.meta.hook_profile, hook_root: prev.meta.hook_root } : {}) }, files: recorded, dirs: [...dirs].sort((a, b) => b.length - a.length), blocks, }); - record([...want, ...(prev?.files ?? []).filter(f => !files.has(f.path))]); + record([...want, ...(prev?.files ?? []).filter(f => !files.has(f.path))], false); for (const [p, sha] of prevFiles) { if (files.has(p)) continue; const disk = onDiskSha(p); @@ -603,7 +668,7 @@ export function register(opts: RegisterOptions): RegisterResult { fs.renameSync(p + '.gstack-new', p); written++; } - record(want); + record(want, true); } for (const [file, text] of blockWrites) { if (text === (fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null)) continue; @@ -611,7 +676,12 @@ export function register(opts: RegisterOptions): RegisterResult { fs.writeFileSync(file, text); } if (opts.registry && t.render === 'host') shellRegistry(opts, 'gstack_install_registry_upsert', [t.name, 'global', '-', dest, opts.checkout, opts.checkout, opts.version, '-', '-']); - return { id, target: t.name, dest, mode, status: !prev ? 'installed' : same ? 'unchanged' : 'updated', written, skills, router, ledger: lfile, kept }; + if (same && hook?.on && hook.explicit && prev.meta.hook_active !== 'no') writeLedger(lfile, { ...prev, meta: { ...prev.meta, hook_active: 'no' } }); + const hookResult = finishHook(opts, hook, released); + const active = hookResult?.required || hookResult?.retried ? ({ active: 'yes', disabled: 'disabled' } as Record)[hookResult.state] ?? 'no' : ''; + const saved = active ? readLedger(lfile) : null; + if (saved && saved.meta.hook_active !== active) writeLedger(lfile, { ...saved, meta: { ...saved.meta, hook_active: active } }); + return { id, target: t.name, dest, mode, status: !prev ? 'installed' : same ? 'unchanged' : 'updated', written, skills, router, ledger: lfile, kept, ...(hookResult ? { hook: hookResult } : {}) }; } finally { fs.rmSync(scratch, { recursive: true, force: true }); } @@ -647,7 +717,7 @@ export function refresh(base: Omit, skip: Set })); } -export interface StatusRow { id: string; target: string; dest: string; mode: string; files: number; version: string; stale: boolean; visible: 'yes' | 'no' | 'unknown'; checked?: string; missing?: string[] } +export interface StatusRow { id: string; target: string; dest: string; mode: string; files: number; version: string; stale: boolean; visible: 'yes' | 'no' | 'unknown'; checked?: string; missing?: string[]; hook?: HookResult } /** Runs the harness's own skill list: 'yes' when every name shows (or, with any, at least one). */ export function harnessSees(t: Target, dest: string, env: Env, names: string[], any = false): { visible: 'yes' | 'no' | 'unknown'; checked?: string; missing?: string[] } { @@ -664,6 +734,8 @@ export function status(stateRoot: string, version: string, env: Env, check = tru return listLedgers(stateRoot).map(({ ledger: l }) => { const t = (TARGETS as Record)[l.meta.target]; const seen = check && t ? harnessSees(t, l.meta.dest, env, (l.meta.names ?? '').split(',').filter(Boolean)) : { visible: 'unknown' as const }; - return { id: l.meta.id, target: l.meta.target, dest: l.meta.dest, mode: l.meta.mode, files: l.files.length, version: l.meta.version, stale: l.meta.version !== version, ...seen }; + const on = l.meta.start_hook === 'on' && isHookTarget(l.meta.target) && !!l.meta.hook_profile; + const hook = on ? (check ? inspectHook(l.meta.target as 'openclaw' | 'hermes', l.meta.hook_profile, env, os.homedir()) : { state: 'unchecked' as const }) : undefined; + return { id: l.meta.id, target: l.meta.target, dest: l.meta.dest, mode: l.meta.mode, files: l.files.length, version: l.meta.version, stale: l.meta.version !== version, ...seen, ...(hook ? { hook } : {}) }; }); } diff --git a/lib/harness-start-hook.ts b/lib/harness-start-hook.ts new file mode 100644 index 000000000..7594d6865 --- /dev/null +++ b/lib/harness-start-hook.ts @@ -0,0 +1,183 @@ +/** + * The gstack-start hook (GST-718): an opt-in plugin that puts one short note on every user turn in OpenClaw + * (before_prompt_build) and Hermes (pre_llm_call), telling the agent to run a gstack skill's start command as + * written. Skill listings were followed on 1 of 29 chances (HARN-18); a per-turn note 97-100% (HARN-15). + * + * Off unless the user asks: `--start-hook`, GSTACK_HARNESS_START_HOOK or `gstack-config set harness_start_hook on`. + * Turning it on writes a fixed plugin into the harness profile (never under a custom --skills-dir) and changes the + * harness's settings only through its own commands: OpenClaw needs plugins.entries.gstack-start.hooks. + * allowConversationAccess before a non-bundled plugin's before_prompt_build runs (2026.10.6 hook-policy-decisions:6); + * Hermes loads a user plugin only once `hermes plugins enable` lists it. Activation runs only when the choice goes + * from off to on, so a plugin the user disabled in the harness is never turned back on. Turning it off and + * unregistering run gstack_harness_hook_release in bin/gstack-harness-ledger.sh, the same code gstack-uninstall runs. + */ +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { readConfigKey } from './state-root'; + +export const HOOK_ID = 'gstack-start'; +export const START_NOTE_MAX = 300; +/** Names the helper every rendered preamble runs (test/harness-start-hook.test.ts checks both). */ +export const START_NOTE = 'gstack: if you use a gstack skill this turn, first run the gstack-skill-start command in its Preamble section exactly as written, with no pipe, head, || true or ; echo. Read its output, then follow the skill.'; + +export type HookTarget = 'openclaw' | 'hermes'; +export type HookState = 'active' | 'written' | 'disabled' | 'unchecked' | 'off'; +export interface HookResult { state: HookState; fix?: string; detail?: string; profile?: string; root?: string } +type Env = Record; + +const MANAGED = "Managed by gstack-harness: it rewrites this file. Turn the hook off: gstack-harness register %s --no-start-hook"; +const SPECS = { + openclaw: { + profileVar: 'OPENCLAW_STATE_DIR', home: '.openclaw', sub: 'extensions', skills: ['skills'], + activate: [['config', 'set', 'plugins.entries.' + HOOK_ID + '.hooks.allowConversationAccess', 'true'], ['config', 'set', 'plugins.entries.' + HOOK_ID + '.enabled', 'true']], + files: (): Record => ({ + 'index.js': [ + '// ' + MANAGED.replace('%s', 'openclaw'), + "import { readFileSync } from 'node:fs';", + "const NOTE = new URL('./note.txt', import.meta.url);", + 'export default {', + " id: '" + HOOK_ID + "',", + " name: 'gstack start hook',", + ' description: "Puts gstack\'s start-command note on each user turn (managed by gstack-harness).",', + ' register(api) {', + " api.on('before_prompt_build', () => {", + " let note = '';", + " try { note = readFileSync(NOTE, 'utf8').trim(); } catch {}", + ' return note ? { prependContext: note } : undefined;', + ' });', + ' },', + '};', + '', + ].join('\n'), + 'openclaw.plugin.json': JSON.stringify({ id: HOOK_ID, name: 'gstack start hook', description: "Puts gstack's start-command note on each user turn (managed by gstack-harness).", configSchema: { type: 'object', additionalProperties: false } }, null, 2) + '\n', + 'package.json': JSON.stringify({ name: HOOK_ID, version: '1.0.0', private: true, type: 'module', openclaw: { extensions: ['./index.js'] } }, null, 2) + '\n', + }), + }, + hermes: { + profileVar: 'HERMES_HOME', home: '.hermes', sub: 'plugins', skills: ['skills', 'gstack'], + activate: [['plugins', 'enable', HOOK_ID]], + files: (): Record => ({ + 'plugin.yaml': ['# ' + MANAGED.replace('%s', 'hermes'), 'name: ' + HOOK_ID, 'version: 1.0.0', "description: Puts gstack's start-command note on each user turn (managed by gstack-harness).", ''].join('\n'), + '__init__.py': [ + '# ' + MANAGED.replace('%s', 'hermes'), + 'from pathlib import Path', + '', + '_NOTE = Path(__file__).with_name("note.txt")', + '', + '', + 'def _context(**_kwargs):', + ' try:', + ' note = _NOTE.read_text(encoding="utf-8").strip()', + ' except OSError:', + ' return None', + ' return {"context": note} if note else None', + '', + '', + 'def register(ctx):', + ' ctx.register_hook("pre_llm_call", _context)', + '', + ].join('\n'), + }), + }, +} as const; + +export const isHookTarget = (t: string): t is HookTarget => t === 'openclaw' || t === 'hermes'; +export function hookProfile(t: HookTarget, env: Env, home: string): string { + const v = env[SPECS[t].profileVar]; + return v && path.isAbsolute(v) ? path.resolve(v) : v || path.join(home, SPECS[t].home); +} +export const hookRoot = (t: HookTarget, profile: string) => path.join(profile, SPECS[t].sub, HOOK_ID); +/** The skills folder a registration must use for the hook to go into that profile. */ +export const profileSkills = (t: HookTarget, profile: string) => path.join(profile, ...SPECS[t].skills); +const activateCommands = (t: HookTarget, profile: string) => SPECS[t].activate.map(a => SPECS[t].profileVar + '=' + JSON.stringify(profile) + ' ' + t + ' ' + a.join(' ')).join(' && '); + +/** The plugin's files (absolute path -> bytes): fixed code plus note.txt, which the code reads at each turn. */ +export function hookFiles(t: HookTarget, root: string): Map { + const out = new Map(); + for (const [name, text] of Object.entries({ ...SPECS[t].files(), 'note.txt': START_NOTE + '\n' })) out.set(path.join(root, name), Buffer.from(text)); + return out; +} + +/** The user's choice and where it came from: --start-hook/--no-start-hook, then the env var, then gstack-config, then the ledger. */ +export function hookChoice(flag: boolean | undefined, env: Env, stateRoot: string, recorded: string | undefined): { on: boolean; source: 'flag' | 'env' | 'config' | 'ledger' } | { invalid: string } { + if (flag !== undefined) return { on: flag, source: 'flag' }; + const parse = (v: string | null | undefined) => v == null || v === '' ? null : /^(1|on|true|yes)$/i.test(v) ? true : /^(0|off|false|no)$/i.test(v) ? false : undefined; + const fromEnv = parse(env.GSTACK_HARNESS_START_HOOK); + if (fromEnv === undefined) return { invalid: 'GSTACK_HARNESS_START_HOOK=' + JSON.stringify(env.GSTACK_HARNESS_START_HOOK) }; + if (fromEnv !== null) return { on: fromEnv, source: 'env' }; + const stored = readConfigKey('harness_start_hook', { ...env, GSTACK_STATE_ROOT: stateRoot } as NodeJS.ProcessEnv); + const fromConfig = parse(stored); + if (fromConfig === undefined) return { invalid: 'gstack-config harness_start_hook=' + JSON.stringify(stored) }; + if (fromConfig !== null) return { on: fromConfig, source: 'config' }; + return { on: recorded === 'on', source: 'ledger' }; +} + +/** Runs the harness's own CLI for one profile: argv only, stdin closed, a timeout, and no environment beyond PATH, HOME and the profile. */ +export function runHarness(t: HookTarget, profile: string, args: readonly string[], env: Env, home: string, timeout = 120_000) { + const r = spawnSync(t, args, { env: { PATH: env.PATH ?? '', HOME: home, [SPECS[t].profileVar]: profile }, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout }); + const out = ((r.stdout ?? '') + (r.stderr ?? '')).trim(); + return { ok: r.status === 0, stdout: r.stdout ?? '', why: r.error ? String((r.error as NodeJS.ErrnoException).code ?? r.error.message) : r.status === 0 ? '' : 'exit ' + r.status + (out ? ': ' + out.split('\n').slice(-2).join(' | ').slice(0, 300) : '') }; +} +const onPath = (bin: string, env: Env) => (env.PATH ?? '').split(path.delimiter).some(d => d && fs.existsSync(path.join(d, bin))); + +/** Turns the plugin on in the harness. Returns the first failure, or ''. */ +export function activateHook(t: HookTarget, profile: string, env: Env, home: string): string { + if (!onPath(t, env)) return t + ' is not on PATH'; + for (const a of SPECS[t].activate) { + const r = runHarness(t, profile, a, env, home, 60_000); + if (!r.ok) return t + ' ' + a.join(' ') + ' failed (' + r.why + ')'; + } + return ''; +} + +function parseJson(s: string): any { + for (const m of s.matchAll(/^[ \t]*[[{]/gm)) { try { return JSON.parse(s.slice(m.index)); } catch {} } + return null; +} + +/** Reads the hook back from the harness: active (configured for new sessions), disabled by the user, or written (with the fix). */ +const READ_TIMEOUT = 15_000; + +export function inspectHook(t: HookTarget, profile: string, env: Env, home: string): HookResult { + const fix = activateCommands(t, profile); + if (!onPath(t, env)) return { state: 'written', detail: t + ' is not on PATH, so gstack could not turn the plugin on or check it', fix }; + if (t === 'hermes') { + const r = runHarness(t, profile, ['plugins', 'list', '--json'], env, home, READ_TIMEOUT); + const row = (parseJson(r.stdout) ?? []).find?.((p: any) => p?.name === HOOK_ID); + if (row?.status === 'enabled') return { state: 'active' }; + if (row?.status === 'disabled') return { state: 'disabled', detail: 'turned off in Hermes (plugins.disabled); gstack leaves it off', fix: activateCommands(t, profile) }; + return { state: 'written', detail: row ? 'Hermes lists it as ' + row.status : 'hermes plugins list does not show ' + HOOK_ID + (r.why ? ' (' + r.why + ')' : ''), fix }; + } + const cfg = parseJson(runHarness(t, profile, ['config', 'get', 'plugins', '--json'], env, home, READ_TIMEOUT).stdout) ?? {}; + const entry = cfg.entries?.[HOOK_ID] ?? {}; + if (entry.enabled === false) return { state: 'disabled', detail: 'turned off in openclaw.json (plugins.entries.' + HOOK_ID + '.enabled false); gstack leaves it off', fix }; + if (Array.isArray(cfg.deny) && cfg.deny.includes(HOOK_ID)) return { state: 'written', detail: 'plugins.deny lists ' + HOOK_ID, fix: 'remove ' + HOOK_ID + ' from plugins.deny in openclaw.json' }; + if (Array.isArray(cfg.allow) && cfg.allow.length && !cfg.allow.includes(HOOK_ID)) return { state: 'written', detail: 'plugins.allow is set and does not list ' + HOOK_ID, fix: 'OPENCLAW_STATE_DIR=' + JSON.stringify(profile) + " openclaw config set plugins.allow '" + JSON.stringify([...cfg.allow, HOOK_ID]) + "'" }; + if (entry.hooks?.allowPromptInjection === false) return { state: 'written', detail: 'plugins.entries.' + HOOK_ID + '.hooks.allowPromptInjection is false', fix }; + const r = runHarness(t, profile, ['plugins', 'inspect', HOOK_ID, '--runtime', '--json'], env, home, READ_TIMEOUT); + const d = parseJson(r.stdout); + if (d?.plugin?.status === 'loaded' && d.plugin.enabled && (d.typedHooks ?? []).some((h: any) => h?.name === 'before_prompt_build')) return { state: 'active' }; + const why = d?.plugin ? 'status ' + d.plugin.status + (d.plugin.enabled ? '' : ', not enabled') + ((d.typedHooks ?? []).length ? '' : ', before_prompt_build not registered') : 'openclaw plugins inspect failed' + (r.why ? ' (' + r.why + ')' : ''); + return { state: 'written', detail: why, fix }; +} + +/** True when a plugin folder holds nothing but the .edited- copies gstack kept when it turned the hook off. */ +function onlyKeptEdits(root: string): boolean { + let entries: fs.Dirent[]; + try { entries = fs.readdirSync(root, { recursive: true, withFileTypes: true }); } catch { return false; } + const files = entries.filter(e => !e.isDirectory()); + return files.length > 0 && files.every(e => e.isFile() && /\.edited-[0-9T-]+Z$/.test(e.name)); +} + +/** Checks the plugin folder before anything is written: under the profile, no symlink from the profile down, and not someone else's. */ +export function assertHookRoot(t: HookTarget, profile: string, root: string, recordedRoot: string | undefined, fail: (code: 'HARNESS_UNSAFE_DESTINATION' | 'HARNESS_HOOK_OWNED_ELSEWHERE', msg: string) => never): void { + if (!path.isAbsolute(profile) || /[\t\n\r]/.test(profile)) fail('HARNESS_UNSAFE_DESTINATION', 'the ' + t + ' profile ' + profile + ' is not a plain absolute path'); + const top = path.resolve(profile); + for (let d = root; d !== top && d !== path.dirname(d); d = path.dirname(d)) { + let st: fs.Stats; + try { st = fs.lstatSync(d); } catch { continue; } + if (st.isSymbolicLink()) fail('HARNESS_UNSAFE_DESTINATION', d + ' is a symlink'); + } + if (fs.existsSync(root) && recordedRoot !== root && !onlyKeptEdits(root)) fail('HARNESS_HOOK_OWNED_ELSEWHERE', 'a ' + HOOK_ID + ' plugin already exists at ' + root + ' and this registration did not write it'); +} diff --git a/package.json b/package.json index 4ccb22127..aee1fd8cb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.92.22", + "version": "1.92.23", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/setup b/setup index 2ae82558e..f4d247792 100755 --- a/setup +++ b/setup @@ -519,7 +519,8 @@ $_RF_TARGETS EOF grep -q ' failed ' "$_RF_SUMMARY" && _RF_FAILED=1 if [ "$HARNESS_AUTO" -eq 1 ] && [ -d "$GSTACK_STATE_ROOT/harness" ]; then - bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$SOURCE_GSTACK_DIR/bin/gstack-harness" refresh || _RF_FAILED=1 + # Exit 4: every registration refreshed; a start hook this run turned on is not active (printed above). + bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$SOURCE_GSTACK_DIR/bin/gstack-harness" refresh || [ "$?" -eq 4 ] || _RF_FAILED=1 fi echo "" if [ "$_RF_FAILED" -eq 1 ]; then @@ -2442,11 +2443,16 @@ mkdir -p "$HOME/.gstack/projects" # OpenClaw and Hermes: gstack-harness copies this checkout's render for the # harness into the harness's own skills folder, records every file in a ledger # (bin/gstack-uninstall removes exactly those) and never edits harness config -# (lib/harness-register.ts). Nothing else in this run applies to them. +# unless the user opted into the start hook (GSTACK_HARNESS_START_HOOK or +# gstack-config harness_start_hook; lib/harness-start-hook.ts). Exit 4 from +# register means the skills are registered and the start hook is not active. +# Nothing else in this run applies to them. _gstack_harness() { bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$SOURCE_GSTACK_DIR/bin/gstack-harness" "$@"; } if [ -n "$INSTALL_HARNESS" ]; then _setup_arm_begin "$INSTALL_HARNESS" global "$INSTALL_HARNESS" - if ! _HARNESS_JSON="$(_gstack_harness register "$INSTALL_HARNESS" --json --no-registry)"; then + _HARNESS_RC=0 + _HARNESS_JSON="$(_gstack_harness register "$INSTALL_HARNESS" --json --no-registry)" || _HARNESS_RC=$? + if [ "$_HARNESS_RC" -ne 0 ] && [ "$_HARNESS_RC" -ne 4 ]; then _SETUP_REFUSED="gstack-harness could not register $INSTALL_HARNESS (reason above); nothing was changed for it" exit 1 fi @@ -2454,6 +2460,7 @@ if [ -n "$INSTALL_HARNESS" ]; then _setup_arm_publish "$INSTALL_HARNESS" global - "$_HARNESS_DEST" "$SOURCE_GSTACK_DIR" - - log "$(printf '%s\n' "$_HARNESS_JSON" | sed -n 's/.*"receipt":"\([^"]*\)".*/\1/p')" _setup_print_summary + [ "$_HARNESS_RC" -eq 4 ] && exit 4 printf '%s' "$_SETUP_ROWS" | _setup_ready_line exit 0 fi diff --git a/test/fixtures/module-size-ratchet.json b/test/fixtures/module-size-ratchet.json index a97ff0ccd..ad2cf1081 100644 --- a/test/fixtures/module-size-ratchet.json +++ b/test/fixtures/module-size-ratchet.json @@ -6,6 +6,7 @@ { "file": "scripts/modules-cli.ts", "movedFrom": "(new: bin/gstack-modules CLI, GSTA-140)" }, { "file": "lib/harness-register.ts", "movedFrom": "(new: gstack-harness registrar, GSTA-135)" }, { "file": "lib/harness-qm.ts", "movedFrom": "(new: gstack-harness QM skill-pack target, GSTA-135)" }, + { "file": "lib/harness-start-hook.ts", "movedFrom": "(new: gstack-harness start hook for OpenClaw and Hermes, GST-718)" }, { "file": "lib/design-lint/render.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" }, { "file": "lib/design-lint/render-probe.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint-render.mjs)" }, { "file": "lib/design-lint/render-probe-space.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint-render.mjs)" }, diff --git a/test/harness-start-hook.test.ts b/test/harness-start-hook.test.ts new file mode 100644 index 000000000..a226c2c6c --- /dev/null +++ b/test/harness-start-hook.test.ts @@ -0,0 +1,514 @@ +/** + * The gstack-start hook (GST-718): opt-in OpenClaw and Hermes plugins that put gstack's start note on every user + * turn. Harness CLIs are fake /bin/sh scripts on PATH that record their argv and environment and keep the + * plugin state in the profile, so these cases run no harness and spend nothing. + */ +import { afterAll, afterEach, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { TARGETS, readLedger, register, status, unregister, type RegisterOptions } from '../lib/harness-register'; +import { HOOK_ID, START_NOTE, START_NOTE_MAX, hookChoice, hookFiles, inspectHook } from '../lib/harness-start-hook'; +import { cleanupFixtures, cleanupSeed, makeFixture, makeSource, registryRows, runSetup } from './helpers/install-fixture'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const VERSION = fs.readFileSync(path.join(ROOT, 'VERSION'), 'utf8').trim(); +const temps: string[] = []; +const tmp = () => { const d = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-hook-test-'))); temps.push(d); return d; }; +afterEach(() => { cleanupFixtures(); for (const d of temps.splice(0)) fs.rmSync(d, { recursive: true, force: true }); }); +afterAll(cleanupSeed); + +const FAKE_OPENCLAW = String.raw`#!/bin/sh +d="$OPENCLAW_STATE_DIR"; c="$d/fake-config" +printf '%s|secret=%s|home=%s|cfg=%s\n' "$*" "$ANTHROPIC_API_KEY" "$HOME" "$OPENCLAW_CONFIG_PATH" >> "$d/fake-calls" +[ -f "$d/fake-fail" ] && { echo "config write refused" >&2; exit 3; } +[ -f "$d/fake-kill" ] && { rm -f "$d/fake-kill"; kill -9 "$PPID"; sleep 5; } +[ -f "$d/fake-noisy" ] && echo "[plugins] gstack-start: cannot verify where this plugin came from" +[ -f "$d/fake-hang" ] && [ "$1" = plugins ] && exec sleep 40 +touch "$c" +acc=false; grep -q '^plugins.entries.gstack-start.hooks.allowConversationAccess=true$' "$c" && acc=true +en=$(grep '^plugins.entries.gstack-start.enabled=' "$c" | tail -1 | cut -d= -f2); enj=null; [ -n "$en" ] && enj=$en +case "$1 $2" in + "config set") echo "$3=$4" >> "$c" ;; + "config unset") grep -v "^$3" "$c" > "$c.t"; mv "$c.t" "$c" ;; + "config get") api=""; grep -q '^plugins.entries.gstack-start.hooks.allowPromptInjection=false$' "$c" && api=',"allowPromptInjection":false' + printf '{"allow":%s,"deny":%s,"entries":{"gstack-start":{"enabled":%s,"hooks":{"allowConversationAccess":%s%s}}}}\n' "$(cat "$d/fake-allow" 2>/dev/null || echo null)" "$(cat "$d/fake-deny" 2>/dev/null || echo null)" "$enj" "$acc" "$api" ;; + "plugins inspect") + hooks=""; [ -d "$d/extensions/gstack-start" ] && [ "$acc" = true ] && [ "$en" != false ] && hooks='{"name":"before_prompt_build"}' + printf '{"plugin":{"status":"loaded","enabled":%s},"typedHooks":[%s]}\n' "$([ "$en" = false ] && echo false || echo true)" "$hooks" ;; +esac +`; +const FAKE_HERMES = String.raw`#!/bin/sh +d="$HERMES_HOME" +printf '%s|secret=%s|home=%s\n' "$*" "$ANTHROPIC_API_KEY" "$HOME" >> "$d/fake-calls" +case "$1 $2" in + "plugins enable") echo enabled > "$d/fake-state" ;; + "plugins disable") echo disabled > "$d/fake-state" ;; + "plugins remove") rm -rf "$d/plugins/$3" "$d/fake-state" ;; + "plugins list") if [ -d "$d/plugins/gstack-start" ]; then printf '[{"name":"gstack-start","status":"%s"}]\n' "$(cat "$d/fake-state" 2>/dev/null || echo 'not enabled')"; else echo '[]'; fi ;; +esac +`; + +/** A temp home, state root and profile, with fake openclaw and hermes CLIs first on PATH. */ +function rig(target: 'openclaw' | 'hermes', extra: { noCli?: boolean; env?: Record } = {}) { + const d = tmp(); + const bin = path.join(d, 'bin'); + fs.mkdirSync(bin); + fs.writeFileSync(path.join(bin, 'openclaw'), FAKE_OPENCLAW, { mode: 0o755 }); + fs.writeFileSync(path.join(bin, 'hermes'), FAKE_HERMES, { mode: 0o755 }); + const profile = path.join(d, target === 'openclaw' ? 'oc' : 'he'); + fs.mkdirSync(profile, { recursive: true }); + const env = { PATH: (extra.noCli ? '' : bin + path.delimiter) + '/usr/bin:/bin', ANTHROPIC_API_KEY: 'sekret', [target === 'openclaw' ? 'OPENCLAW_STATE_DIR' : 'HERMES_HOME']: profile, ...extra.env }; + const o = (more: Partial = {}): RegisterOptions => ({ target: TARGETS[target], checkout: ROOT, stateRoot: path.join(d, 'state'), env, home: path.join(d, 'home'), version: VERSION, ...more }); + const root = path.join(profile, target === 'openclaw' ? 'extensions' : 'plugins', HOOK_ID); + const calls = () => fs.existsSync(path.join(profile, 'fake-calls')) ? fs.readFileSync(path.join(profile, 'fake-calls'), 'utf8').trim().split('\n') : []; + return { d, bin, profile, env, o, root, calls }; +} + +describe('start hook: the note and the plugin files', () => { + test('the note fits its budget and names the helper every rendered preamble runs', () => { + expect(START_NOTE.length).toBeLessThanOrEqual(START_NOTE_MAX); + expect(START_NOTE).toContain('gstack-skill-start'); + const review = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf8'); + expect(review).toContain('## Preamble'); + expect(review).toMatch(/bin\/gstack-skill-start --skill "review"/); + }); + + test('plugin code is fixed text: no install path in it, and it reads note.txt from its own folder', () => { + for (const t of ['openclaw', 'hermes'] as const) { + const a = hookFiles(t, '/one/place/' + HOOK_ID); const b = hookFiles(t, '/an\'other "place"/' + HOOK_ID); + expect([...a.values()].map(String)).toEqual([...b.values()].map(String)); + for (const [p, bytes] of a) if (!p.endsWith('note.txt')) expect(String(bytes)).not.toContain('/one/place'); + expect(String(a.get('/one/place/' + HOOK_ID + '/note.txt'))).toBe(START_NOTE + '\n'); + } + }); + + test('the OpenClaw plugin returns the note as prependContext, and nothing when note.txt is gone', async () => { + const d = tmp(); + for (const [p, b] of hookFiles('openclaw', d)) fs.writeFileSync(p, b); + const load = async () => { + let handler: (() => unknown) | undefined; + const mod = await import(path.join(d, 'index.js') + '?' + Math.random()); + mod.default.register({ on: (name: string, fn: () => unknown) => { expect(name).toBe('before_prompt_build'); handler = fn; } }); + return handler!(); + }; + expect(await load()).toEqual({ prependContext: START_NOTE }); + fs.rmSync(path.join(d, 'note.txt')); + expect(await load()).toBeUndefined(); + }); + + test('the Hermes plugin registers pre_llm_call and returns the note as context, and nothing when note.txt is gone', () => { + const d = tmp(); + const pkg = path.join(d, 'gstack_start'); + fs.mkdirSync(pkg); + for (const [p, b] of hookFiles('hermes', pkg)) fs.writeFileSync(p, b); + const probe = 'import sys, json\nsys.path.insert(0, sys.argv[1])\nimport gstack_start as m\nseen = []\nclass Ctx:\n def register_hook(self, name, fn): seen.append((name, fn))\nm.register(Ctx())\nprint(json.dumps([seen[0][0], seen[0][1](session_id="s", user_message="hi")]))\n'; + const run = () => spawnSync('python3', ['-B', '-c', probe, d], { encoding: 'utf8', timeout: 30_000 }); + let r = run(); + expect(r.status, r.stderr).toBe(0); + expect(JSON.parse(r.stdout)).toEqual(['pre_llm_call', { context: START_NOTE }]); + fs.rmSync(path.join(pkg, 'note.txt')); + r = run(); + expect(JSON.parse(r.stdout)).toEqual(['pre_llm_call', null]); + }); +}); + +describe('start hook: who decides', () => { + test('flag, then GSTACK_HARNESS_START_HOOK, then gstack-config harness_start_hook, then the ledger', () => { + const d = tmp(); + const state = path.join(d, 'state'); + fs.mkdirSync(state); + expect(hookChoice(undefined, {}, state, undefined)).toEqual({ on: false, source: 'ledger' }); + expect(hookChoice(undefined, {}, state, 'on')).toEqual({ on: true, source: 'ledger' }); + fs.writeFileSync(path.join(state, 'config.yaml'), 'harness_start_hook: off\n'); + expect(hookChoice(undefined, {}, state, 'on')).toEqual({ on: false, source: 'config' }); + expect(hookChoice(undefined, { GSTACK_HARNESS_START_HOOK: '1' }, state, undefined)).toEqual({ on: true, source: 'env' }); + expect(hookChoice(false, { GSTACK_HARNESS_START_HOOK: 'on' }, state, 'on')).toEqual({ on: false, source: 'flag' }); + expect(hookChoice(undefined, { GSTACK_HARNESS_START_HOOK: 'maybe' }, state, undefined)).toEqual({ invalid: 'GSTACK_HARNESS_START_HOOK="maybe"' }); + fs.writeFileSync(path.join(state, 'config.yaml'), 'harness_start_hook: sometimes\n'); + expect(hookChoice(undefined, {}, state, undefined)).toEqual({ invalid: 'gstack-config harness_start_hook="sometimes"' }); + }); + + test('gstack-config takes harness_start_hook on or off and refuses anything else', () => { + const d = tmp(); + const cfg = (...args: string[]) => spawnSync(path.join(ROOT, 'bin', 'gstack-config'), args, { env: { PATH: process.env.PATH, HOME: path.join(d, 'home'), GSTACK_STATE_ROOT: path.join(d, 'state') }, encoding: 'utf8', timeout: 30_000 }); + expect(cfg('set', 'harness_start_hook', 'on').status).toBe(0); + expect(cfg('get', 'harness_start_hook').stdout.trim()).toBe('on'); + const bad = cfg('set', 'harness_start_hook', 'yes'); + expect(bad.status).toBe(1); + expect(bad.stderr).toContain("harness_start_hook 'yes' not recognized"); + expect(cfg('get', 'harness_start_hook').stdout.trim()).toBe('on'); + }); + + test('an invalid choice and a hook for a target or folder without one stop before anything is written', () => { + const r = rig('openclaw', { env: { GSTACK_HARNESS_START_HOOK: 'maybe' } }); + expect(() => register(r.o())).toThrow(expect.objectContaining({ code: 'HARNESS_HOOK_CHOICE_INVALID' })); + const s = rig('openclaw'); + expect(() => register(s.o({ skillsDir: path.join(s.d, 'custom', 'skills'), startHook: true }))).toThrow(expect.objectContaining({ code: 'HARNESS_HOOK_UNSUPPORTED' })); + expect(() => register({ ...s.o({ startHook: true }), target: TARGETS.dots, skillsDir: path.join(s.d, 'dots') })).toThrow(expect.objectContaining({ code: 'HARNESS_HOOK_UNSUPPORTED' })); + expect(() => register({ ...s.o({ startHook: true }), env: { ...s.env, OPENCLAW_CONFIG_PATH: '/elsewhere/openclaw.json' } })).toThrow(expect.objectContaining({ code: 'HARNESS_HOOK_UNSUPPORTED' })); + for (const x of [r, s]) { expect(fs.existsSync(path.join(x.profile, 'skills'))).toBe(false); expect(x.calls()).toEqual([]); } + expect(fs.existsSync(path.join(s.d, 'custom'))).toBe(false); + }); + + test('a plugin folder gstack did not write, or a symlink on the way to it, stops an explicit --start-hook with nothing written, and turns an env or config opt-in into hook off', () => { + const r = rig('openclaw'); + fs.mkdirSync(r.root, { recursive: true }); + fs.writeFileSync(path.join(r.root, 'index.js'), 'mine'); + expect(() => register(r.o({ startHook: true }))).toThrow(expect.objectContaining({ code: 'HARNESS_HOOK_OWNED_ELSEWHERE' })); + expect(fs.readFileSync(path.join(r.root, 'index.js'), 'utf8')).toBe('mine'); + const s = rig('hermes'); + fs.mkdirSync(path.join(s.d, 'elsewhere')); + fs.symlinkSync(path.join(s.d, 'elsewhere'), path.join(s.profile, 'plugins')); + expect(() => register(s.o({ startHook: true }))).toThrow(expect.objectContaining({ code: 'HARNESS_UNSAFE_DESTINATION' })); + expect(fs.readdirSync(path.join(s.d, 'elsewhere'))).toEqual([]); + const viaEnv = register({ ...s.o(), env: { ...s.env, GSTACK_HARNESS_START_HOOK: '1' } }); + expect(viaEnv.hook?.state).toBe('off'); + expect(viaEnv.hook?.detail).toContain('is a symlink (env asked for the hook'); + expect(fs.existsSync(path.join(s.profile, 'skills', 'gstack'))).toBe(true); + expect(fs.readdirSync(path.join(s.d, 'elsewhere'))).toEqual([]); + expect(r.calls().concat(s.calls())).toEqual([]); + }); + + test('a file, an empty folder or a folder with any non-kept file at the plugin path is still owned elsewhere', () => { + for (const make of [(root: string) => fs.writeFileSync(root, 'x'), (root: string) => fs.mkdirSync(path.join(root, 'sub'), { recursive: true }), + (root: string) => { fs.mkdirSync(root, { recursive: true }); fs.writeFileSync(path.join(root, 'note.txt.edited-2026-11-01T06-31-18-911Z'), 'kept'); fs.writeFileSync(path.join(root, 'plugin.yaml'), 'mine'); }]) { + const r = rig('hermes'); + fs.mkdirSync(path.dirname(r.root), { recursive: true }); + make(r.root); + expect(() => register(r.o({ startHook: true }))).toThrow(expect.objectContaining({ code: 'HARNESS_HOOK_OWNED_ELSEWHERE' })); + expect(register({ ...r.o(), env: { ...r.env, GSTACK_HARNESS_START_HOOK: '1' } }).hook?.state).toBe('off'); + } + }, 300_000); + + test('a profile path with a trailing slash under a symlinked parent still takes the hook and is saved without the slash', () => { + const r = rig('hermes'); + const link = path.join(tmp(), 'ln'); + fs.symlinkSync(r.d, link); + const res = register({ ...r.o({ startHook: true }), env: { ...r.env, HERMES_HOME: path.join(link, 'he') + '/' } }); + expect(res.hook?.state).toBe('active'); + expect(readLedger(res.ledger)!.meta.hook_profile).toBe(path.join(link, 'he')); + expect(fs.existsSync(path.join(r.root, 'plugin.yaml'))).toBe(true); + }, 120_000); +}); + +describe('start hook: OpenClaw lifecycle (fake CLI)', () => { + test('default registration adds no plugin and runs no harness command', () => { + const r = rig('openclaw'); + const res = register(r.o()); + expect(res.hook).toEqual({ state: 'off' }); + expect(fs.existsSync(path.join(r.profile, 'extensions'))).toBe(false); + expect(r.calls()).toEqual([]); + expect(readLedger(res.ledger)!.files.every(f => f.path.startsWith(path.join(r.profile, 'skills') + path.sep))).toBe(true); + }, 120_000); + + test('on, refreshed, disabled by the user, then off: activation runs once, a user disable sticks, opt-out cleans up', () => { + const r = rig('openclaw'); + const on = register(r.o({ startHook: true })); + expect(on.hook).toMatchObject({ state: 'active', required: true, profile: r.profile, root: r.root }); + expect(fs.readdirSync(r.root).sort()).toEqual(['index.js', 'note.txt', 'openclaw.plugin.json', 'package.json']); + const meta = readLedger(on.ledger)!.meta; + expect([meta.start_hook, meta.hook_profile, meta.hook_root]).toEqual(['on', r.profile, r.root]); + const argv = r.calls().map(l => l.split('|')[0]); + expect(argv).toEqual([ + 'config set plugins.entries.gstack-start.hooks.allowConversationAccess true', + 'config set plugins.entries.gstack-start.enabled true', + 'config get plugins --json', + 'plugins inspect gstack-start --runtime --json', + ]); + for (const l of r.calls()) expect(l).toContain('|secret=|home=' + path.join(r.d, 'home') + '|cfg='); + + const again = register(r.o()); + expect(again.status).toBe('unchanged'); + expect(again.hook?.state).toBe('unchecked'); + expect(r.calls()).toHaveLength(4); + + fs.appendFileSync(path.join(r.profile, 'fake-config'), 'plugins.entries.gstack-start.enabled=false\n'); + register(r.o()); + expect(r.calls().filter(l => l.startsWith('config set'))).toHaveLength(2); + expect(status(r.o().stateRoot, VERSION, r.env).find(s => s.target === 'openclaw')?.hook?.state).toBe('disabled'); + + const off = register(r.o({ startHook: false })); + expect(off.hook?.state).toBe('off'); + expect(r.calls().some(l => l.startsWith('config unset plugins.entries.gstack-start|'))).toBe(true); + expect(fs.existsSync(path.join(r.profile, 'extensions'))).toBe(false); + const after = readLedger(off.ledger)!.meta; + expect(after.start_hook).toBe('off'); + expect(after.hook_root).toBeUndefined(); + }, 300_000); + + test('a failed or blocked activation reports written with the fix; the CLI exits 4 after registering the skills', () => { + const r = rig('openclaw'); + fs.writeFileSync(path.join(r.profile, 'fake-allow'), '["anthropic"]'); + const res = register(r.o({ startHook: true })); + expect(res.hook).toMatchObject({ state: 'written', required: true, detail: 'plugins.allow is set and does not list gstack-start' }); + expect(res.hook?.fix).toContain('openclaw config set plugins.allow \'["anthropic","gstack-start"]\''); + + const s = rig('openclaw'); + fs.writeFileSync(path.join(s.profile, 'fake-fail'), ''); + const cli = spawnSync(process.execPath, ['--no-env-file', '--config=/dev/null', path.join(ROOT, 'bin', 'gstack-harness'), 'register', 'openclaw', '--start-hook'], + { env: { ...s.env, PATH: s.env.PATH + path.delimiter + path.dirname(process.execPath), HOME: path.join(s.d, 'home'), GSTACK_STATE_ROOT: path.join(s.d, 'state') }, encoding: 'utf8', timeout: 240_000 }); + expect(cli.status, cli.stderr).toBe(4); + expect(cli.stdout).toContain('start hook: written'); + expect(cli.stderr).toContain('config write refused'); + expect(cli.stderr).toContain('(HARNESS_HOOK_INACTIVE; docs/troubleshooting.md#harness-hook-inactive)'); + expect(fs.existsSync(path.join(s.profile, 'skills', 'gstack', 'SKILL.md'))).toBe(true); + }, 300_000); + + test('a run killed during activation leaves the hook marked not active, so the next plain register activates it', () => { + const r = rig('openclaw'); + fs.writeFileSync(path.join(r.profile, 'fake-kill'), ''); + const cli = spawnSync(process.execPath, ['--no-env-file', '--config=/dev/null', path.join(ROOT, 'bin', 'gstack-harness'), 'register', 'openclaw'], + { env: { ...r.env, GSTACK_HARNESS_START_HOOK: '1', PATH: r.env.PATH + path.delimiter + path.dirname(process.execPath), HOME: path.join(r.d, 'home'), GSTACK_STATE_ROOT: path.join(r.d, 'state') }, encoding: 'utf8', timeout: 240_000 }); + expect(cli.signal, cli.stderr).toBe('SIGKILL'); + const dir = path.join(r.d, 'state', 'harness'); + const lfile = fs.readdirSync(dir).find(f => f.endsWith('.ledger')); + expect(readLedger(path.join(dir, lfile!))!.meta).toMatchObject({ start_hook: 'on', hook_active: 'no' }); + const next = register(r.o()); + expect(next.hook).toMatchObject({ state: 'active', retried: true }); + expect(readLedger(next.ledger)!.meta.hook_active).toBe('yes'); + }, 300_000); + + test('off with an edited plugin file keeps the edit, and the same registration can turn the hook back on', () => { + const r = rig('openclaw'); + expect(register(r.o({ startHook: true })).hook?.state).toBe('active'); + fs.writeFileSync(path.join(r.root, 'note.txt'), 'my note\n'); + const off = register(r.o({ startHook: false })); + expect(off.kept).toHaveLength(1); + expect(off.kept[0]).toMatch(/note\.txt\.edited-/); + const on = register(r.o({ startHook: true })); + expect(on.hook?.state).toBe('active'); + expect(fs.readFileSync(path.join(r.root, 'note.txt'), 'utf8')).toBe(START_NOTE + '\n'); + expect(fs.readFileSync(off.kept[0], 'utf8')).toBe('my note\n'); + }, 300_000); + + test('an explicit --start-hook on an unchanged registration killed during activation is retried by the next run', () => { + const r = rig('openclaw'); + expect(register(r.o({ startHook: true })).hook?.state).toBe('active'); + fs.writeFileSync(path.join(r.profile, 'fake-kill'), ''); + const cli = spawnSync(process.execPath, ['--no-env-file', '--config=/dev/null', path.join(ROOT, 'bin', 'gstack-harness'), 'register', 'openclaw', '--start-hook'], + { env: { ...r.env, PATH: r.env.PATH + path.delimiter + path.dirname(process.execPath), HOME: path.join(r.d, 'home'), GSTACK_STATE_ROOT: path.join(r.d, 'state') }, encoding: 'utf8', timeout: 240_000 }); + expect(cli.signal, cli.stderr).toBe('SIGKILL'); + const dir = path.join(r.d, 'state', 'harness'); + expect(readLedger(path.join(dir, fs.readdirSync(dir).find(f => f.endsWith('.ledger'))!))!.meta.hook_active).toBe('no'); + expect(register(r.o()).hook).toMatchObject({ state: 'active', retried: true }); + }, 300_000); + + test('a log line in brackets before the harness JSON still reads back as active', () => { + const r = rig('openclaw'); + fs.writeFileSync(path.join(r.profile, 'fake-noisy'), ''); + expect(register(r.o({ startHook: true })).hook?.state).toBe('active'); + expect(inspectHook('openclaw', r.profile, r.env, path.join(r.d, 'home')).state).toBe('active'); + }, 120_000); + + test('a hung harness read-back gives up after a short timeout', () => { + const r = rig('openclaw'); + expect(register(r.o({ startHook: true })).hook?.state).toBe('active'); + fs.writeFileSync(path.join(r.profile, 'fake-hang'), ''); + const t0 = Date.now(); + expect(inspectHook('openclaw', r.profile, r.env, path.join(r.d, 'home')).state).toBe('written'); + expect(Date.now() - t0).toBeLessThan(35_000); + }, 120_000); + + test('without the harness on PATH the hook is written, not active, and names the commands to run', () => { + const r = rig('openclaw', { noCli: true }); + const res = register(r.o({ startHook: true })); + expect(res.hook).toMatchObject({ state: 'written', detail: 'openclaw is not on PATH, so gstack could not turn the plugin on or check it' }); + expect(res.hook?.fix).toBe('OPENCLAW_STATE_DIR=' + JSON.stringify(r.profile) + ' openclaw config set plugins.entries.gstack-start.hooks.allowConversationAccess true && OPENCLAW_STATE_DIR=' + JSON.stringify(r.profile) + ' openclaw config set plugins.entries.gstack-start.enabled true'); + }, 120_000); +}); + +describe('start hook: read back and retry (fake CLI)', () => { + test('OpenClaw deny lists and allowPromptInjection false read back as written; a Hermes disable reads back as disabled', () => { + const r = rig('openclaw'); + fs.mkdirSync(r.root, { recursive: true }); + const cfg = (line: string) => fs.appendFileSync(path.join(r.profile, 'fake-config'), line + '\n'); + cfg('plugins.entries.gstack-start.hooks.allowConversationAccess=true'); + expect(inspectHook('openclaw', r.profile, r.env, r.d).state).toBe('active'); + fs.writeFileSync(path.join(r.profile, 'fake-deny'), '["gstack-start"]'); + expect(inspectHook('openclaw', r.profile, r.env, r.d)).toMatchObject({ state: 'written', detail: 'plugins.deny lists gstack-start' }); + fs.rmSync(path.join(r.profile, 'fake-deny')); + cfg('plugins.entries.gstack-start.hooks.allowPromptInjection=false'); + expect(inspectHook('openclaw', r.profile, r.env, r.d)).toMatchObject({ state: 'written', detail: 'plugins.entries.gstack-start.hooks.allowPromptInjection is false' }); + const h = rig('hermes'); + fs.mkdirSync(h.root, { recursive: true }); + fs.writeFileSync(path.join(h.profile, 'fake-state'), 'disabled'); + expect(inspectHook('hermes', h.profile, h.env, h.d)).toMatchObject({ state: 'disabled' }); + }); + + test('after a failed opt-in, refresh retries without failing, and stops for good once the user disables the plugin', () => { + const r = rig('openclaw'); + fs.writeFileSync(path.join(r.profile, 'fake-allow'), '["anthropic"]'); + expect(register(r.o({ startHook: true })).hook).toMatchObject({ state: 'written', required: true }); + expect(readLedger(register(r.o()).ledger)!.meta.hook_active).toBe('no'); + const retry = register(r.o()); + expect(retry.hook).toMatchObject({ state: 'written', required: false, retried: true }); + fs.rmSync(path.join(r.profile, 'fake-allow')); + const fixed = register(r.o()); + expect(fixed.hook).toMatchObject({ state: 'active', retried: true }); + expect(readLedger(fixed.ledger)!.meta.hook_active).toBe('yes'); + const sets = r.calls().filter(l => l.startsWith('config set')).length; + expect(register(r.o()).hook?.state).toBe('unchecked'); + expect(r.calls().filter(l => l.startsWith('config set')).length).toBe(sets); + + const s = rig('openclaw'); + fs.writeFileSync(path.join(s.profile, 'fake-fail'), ''); + expect(register(s.o({ startHook: true })).hook?.state).toBe('written'); + fs.rmSync(path.join(s.profile, 'fake-fail')); + fs.appendFileSync(path.join(s.profile, 'fake-config'), 'plugins.entries.gstack-start.enabled=false\n'); + const before = s.calls().filter(l => l.startsWith('config set')).length; + const left = register(s.o()); + expect(left.hook).toMatchObject({ state: 'disabled', retried: true }); + expect(readLedger(left.ledger)!.meta.hook_active).toBe('disabled'); + register(s.o()); + expect(s.calls().filter(l => l.startsWith('config set')).length).toBe(before); + expect(fs.readFileSync(path.join(s.profile, 'fake-config'), 'utf8')).toContain('enabled=false'); + }, 300_000); +}); + +describe('start hook: Hermes lifecycle and the saved choice (fake CLI)', () => { + test('a one-run env opt-in is saved; config off then refresh turns it off; unregister removes the plugin through Hermes', () => { + const r = rig('hermes', { env: { GSTACK_HARNESS_START_HOOK: '1' } }); + const on = register(r.o()); + expect(on.hook?.state).toBe('active'); + expect(r.calls().map(l => l.split('|')[0])).toEqual(['plugins enable gstack-start', 'plugins list --json']); + + const env = { ...r.env }; delete (env as Record).GSTACK_HARNESS_START_HOOK; + const kept = register({ ...r.o(), env }); + expect(kept.hook?.state).toBe('unchecked'); + expect(fs.existsSync(path.join(r.root, '__init__.py'))).toBe(true); + + fs.writeFileSync(path.join(r.o().stateRoot, 'config.yaml'), 'harness_start_hook: off\n'); + const off = register({ ...r.o(), env }); + expect(off.hook?.state).toBe('off'); + expect(r.calls().at(-1)!.split('|')[0]).toBe('plugins remove gstack-start'); + expect(fs.existsSync(path.join(r.profile, 'plugins'))).toBe(false); + + fs.writeFileSync(path.join(r.o().stateRoot, 'config.yaml'), 'harness_start_hook: on\n'); + const back = register({ ...r.o(), env }); + expect(back.hook?.state).toBe('active'); + fs.mkdirSync(path.join(r.root, '__pycache__')); + fs.writeFileSync(path.join(r.root, '__pycache__', '__init__.cpython-312.pyc'), 'x'); + unregister(r.o().stateRoot, ROOT, 'hermes', env); + expect(r.calls().at(-1)!.split('|')[0]).toBe('plugins remove gstack-start'); + expect(fs.existsSync(path.join(r.profile, 'plugins'))).toBe(false); + }, 300_000); + + test('bash-only uninstall: an edited plugin file is kept and Hermes only disables it; without Hermes on PATH it prints the command', () => { + const r = rig('hermes'); + register(r.o({ startHook: true })); + fs.appendFileSync(path.join(r.root, 'note.txt'), 'my edit\n'); + const ledgerSh = path.join(ROOT, 'bin', 'gstack-harness-ledger.sh'); + const run = (PATH: string) => spawnSync('bash', ['-c', '. "$0" && gstack_harness_unregister_all', ledgerSh], { env: { PATH, HOME: path.join(r.d, 'home'), GSTACK_STATE_ROOT: path.join(r.d, 'state') }, encoding: 'utf8', timeout: 60_000 }); + const res = run(r.bin + path.delimiter + '/usr/bin:/bin'); + expect(res.status, res.stderr).toBe(0); + expect(r.calls().at(-1)!.split('|')[0]).toBe('plugins disable gstack-start'); + expect(fs.readdirSync(r.root).filter(f => f.startsWith('note.txt.edited-'))).toHaveLength(1); + expect(fs.existsSync(path.join(r.root, 'plugin.yaml'))).toBe(false); + + const s = rig('hermes'); + register(s.o({ startHook: true })); + const t = spawnSync('bash', ['-c', '. "$0" && gstack_harness_unregister_all', ledgerSh], { env: { PATH: '/usr/bin:/bin', HOME: path.join(s.d, 'home'), GSTACK_STATE_ROOT: path.join(s.d, 'state') }, encoding: 'utf8', timeout: 60_000 }); + expect(t.status).toBe(0); + expect(t.stdout).toContain('start hook: turn it off in Hermes yourself: HERMES_HOME="' + s.profile + '" hermes plugins disable gstack-start'); + expect(fs.existsSync(path.join(s.profile, 'plugins'))).toBe(false); + }, 300_000); +}); + +describe('start hook: teardown keeps what gstack did not write (fake CLI)', () => { + test('a kept copy of an edited Hermes plugin file survives unregister: Hermes only disables the plugin', () => { + const r = rig('hermes'); + expect(register(r.o({ startHook: true })).hook?.state).toBe('active'); + fs.writeFileSync(path.join(r.root, 'note.txt'), 'my note\n'); + const again = register(r.o()); + expect(again.kept).toHaveLength(1); + unregister(r.o().stateRoot, ROOT, 'hermes', r.env); + expect(r.calls().at(-1)!.split('|')[0]).toBe('plugins disable gstack-start'); + expect(fs.readFileSync(again.kept[0], 'utf8')).toBe('my note\n'); + }, 300_000); + + test('a plugins folder swapped for a symlink after registration: off refuses, unregister deletes nothing through it', () => { + const r = rig('hermes'); + expect(register(r.o({ startHook: true })).hook?.state).toBe('active'); + const elsewhere = path.join(r.d, 'elsewhere'); + fs.renameSync(path.join(r.profile, 'plugins'), elsewhere); + fs.symlinkSync(elsewhere, path.join(r.profile, 'plugins')); + fs.writeFileSync(path.join(elsewhere, HOOK_ID, 'important.txt'), 'keep'); + expect(() => register(r.o({ startHook: false }))).toThrow(expect.objectContaining({ code: 'HARNESS_UNSAFE_DESTINATION' })); + unregister(r.o().stateRoot, ROOT, 'hermes', r.env); + expect(r.calls().some(l => l.startsWith('plugins remove'))).toBe(false); + expect(fs.readdirSync(path.join(elsewhere, HOOK_ID)).sort()).toEqual(['__init__.py', 'important.txt', 'note.txt', 'plugin.yaml']); + }, 300_000); + + test.skipIf(process.platform === 'win32' || process.getuid?.() === 0)('an opt-out that fails to delete the plugin files can be run again', () => { + const r = rig('openclaw'); + const on = register(r.o({ startHook: true })); + expect(on.hook?.state).toBe('active'); + fs.chmodSync(r.root, 0o555); + try { + expect(() => register(r.o({ startHook: false }))).toThrow(/EACCES/); + expect(readLedger(on.ledger)!.meta.hook_root).toBe(r.root); + } finally { fs.chmodSync(r.root, 0o755); } + const ext = path.join(r.profile, 'extensions'), moved = path.join(r.d, 'moved'); + fs.renameSync(ext, moved); + fs.symlinkSync(moved, ext); + expect(() => register(r.o({ startHook: false }))).toThrow(expect.objectContaining({ code: 'HARNESS_UNSAFE_DESTINATION' })); + expect(() => register({ ...r.o(), env: { ...r.env, GSTACK_HARNESS_START_HOOK: '1' } })).toThrow(expect.objectContaining({ code: 'HARNESS_UNSAFE_DESTINATION' })); + expect(fs.existsSync(path.join(moved, HOOK_ID, 'index.js'))).toBe(true); + fs.unlinkSync(ext); + fs.renameSync(moved, ext); + const off = register(r.o({ startHook: false })); + expect(off.hook?.state).toBe('off'); + expect(fs.existsSync(path.join(r.profile, 'extensions'))).toBe(false); + expect(readLedger(off.ledger)!.meta.hook_root).toBeUndefined(); + }, 300_000); + + test('unregister leaves an empty plugin folder alone when the folder above it was swapped for a symlink', () => { + const r = rig('openclaw'); + expect(register(r.o({ startHook: true })).hook?.state).toBe('active'); + const ext = path.join(r.profile, 'extensions'), elsewhere = path.join(r.d, 'elsewhere'); + fs.renameSync(ext, path.join(r.d, 'old')); + fs.mkdirSync(path.join(elsewhere, HOOK_ID), { recursive: true }); + fs.symlinkSync(elsewhere, ext); + unregister(r.o().stateRoot, ROOT, 'openclaw', r.env); + expect(fs.existsSync(path.join(elsewhere, HOOK_ID))).toBe(true); + }, 300_000); + + test('a saved opt-in survives one run from a shell that sets OPENCLAW_CONFIG_PATH', () => { + const r = rig('openclaw'); + expect(register(r.o({ startHook: true })).hook?.state).toBe('active'); + const res = register({ ...r.o(), env: { ...r.env, OPENCLAW_CONFIG_PATH: '/elsewhere/openclaw.json' } }); + expect(res.hook?.state).not.toBe('off'); + expect(r.calls().some(l => l.startsWith('config unset'))).toBe(false); + expect(fs.existsSync(path.join(r.root, 'index.js'))).toBe(true); + expect(readLedger(res.ledger)!.meta.start_hook).toBe('on'); + }, 300_000); +}); + +describe.skipIf(process.platform === 'win32')('start hook through ./setup --host', () => { + test('exits 4 with the skills registered when the hook does not turn on, and the next run retries it', () => { + const f = makeFixture(); + const src = makeSource(f, path.join(f.dir, 'gstack')); + const bin = path.join(f.dir, 'fakebin'); + fs.mkdirSync(bin); + fs.writeFileSync(path.join(bin, 'openclaw'), FAKE_OPENCLAW, { mode: 0o755 }); + const profile = path.join(f.home, '.openclaw'); + fs.mkdirSync(profile, { recursive: true }); + fs.writeFileSync(path.join(profile, 'fake-fail'), ''); + const PATH = bin + path.delimiter + f.env.PATH; + const first = runSetup(f, path.join(src, 'setup'), ['--host', 'openclaw'], { env: { PATH, GSTACK_HARNESS_START_HOOK: '1' } }); + expect(first.status, first.stdout + first.stderr).toBe(4); + expect(first.stdout).toContain('start hook: written'); + expect(first.stderr).toContain('(HARNESS_HOOK_INACTIVE; docs/troubleshooting.md#harness-hook-inactive)'); + expect(fs.existsSync(path.join(profile, 'skills', 'gstack', 'SKILL.md'))).toBe(true); + expect(registryRows(f).map(row => row[0])).toEqual(['openclaw']); + const refreshed = runSetup(f, path.join(src, 'setup'), ['--refresh-registered'], { env: { PATH } }); + expect(refreshed.status, refreshed.stdout + refreshed.stderr).toBe(0); + expect(refreshed.stdout + refreshed.stderr).toContain('start hook: written'); + fs.rmSync(path.join(profile, 'fake-fail')); + const second = runSetup(f, path.join(src, 'setup'), ['--host', 'openclaw'], { env: { PATH } }); + expect(second.status, second.stdout + second.stderr).toBe(0); + expect(second.stdout).toContain('start hook: active'); + }, 180_000); +});