v1.94.24.0 fix(harness): start hook leaves a fresh harness config alone, keeps the profile's own OPENCLAW_CONFIG_PATH, reads status off PATH (GST-778) (#3794)

* fix(harness): start hook waits for the harness config, keeps the profile's own OPENCLAW_CONFIG_PATH, reads status from config off PATH (GST-778)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(changelog): recovery note covers every release before this fix

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(hosts): turning the hook off in a profile with no harness config runs no harness command

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-03 15:41:57 +00:00
1 parent b3e9e5d32c
commit 1ccab0d8bc
10 files changed
+246 -27

No files matched your search

+8
View File
@@ -1,5 +1,13 @@
# Changelog # Changelog
## [1.94.24.0] - 2026-11-03
### Fixed
- **The OpenClaw and Hermes start hook no longer breaks a fresh harness.** When the harness has no config yet, gstack runs none of its commands: `openclaw config set` and `hermes plugins enable` would have created a config holding only gstack's plugin setting, with no model, provider or terminal settings. The hook reports `written` with the setup to run first (`openclaw onboard`, `hermes setup`), and the next `./setup` or `gstack-harness refresh` turns it on, changing only the plugin entry. Turning the hook off or unregistering in such a profile also leaves the config alone. If v1.92.23.0 or a later release before this fix already created a config holding only the plugin entry, move it aside, run the harness's own setup, then `gstack-harness refresh`.
- **`OPENCLAW_CONFIG_PATH` naming the profile's own `openclaw.json` keeps the start hook on.** gstack compares the path after resolving symlinks, so only a config outside the profile turns the hook off. When `GSTACK_HARNESS_START_HOOK` or `harness_start_hook` asked for the hook and it ends up off, `gstack-harness register` and `./setup --host` now exit 4 with the reason and the fix instead of exiting 0.
- **`gstack-harness status` reads the hook from the harness's config when the harness CLI is not on `PATH`**, as inside Hermes's or OpenClaw's own terminal tool. Hermes's `config.yaml` settles it (`active`, `disabled` or `written`); otherwise status says `unknown` instead of a wrong `written`.
## [1.94.23.0] - 2026-11-03 ## [1.94.23.0] - 2026-11-03
**Two `gstack-pregate` preflight false positives fixed (GST-1211).** Both stopped /ship on real work in gbrain-evals. **Two `gstack-pregate` preflight false positives fixed (GST-1211).** Both stopped /ship on real work in gbrain-evals.
+1 -1
View File
@@ -1 +1 @@
1.94.23.0 1.94.24.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.94.23.0 — regenerate/re-copy after upgrading gstack # gstack digest v1.94.24.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows, for agent hosts without a full skill install. The full skills add workflows,
+6 -2
View File
@@ -74,9 +74,10 @@ _gstack_harness_cut_block() {
# plugins.entries.gstack-start; Hermes removes the plugin and its config entries when its # 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 # 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 # 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. # PATH it prints the command to run instead. A profile with no harness config (openclaw.json, config.yaml) never had
# the plugin turned on, and the harness CLI would create a config holding only that change, so it runs no harness command.
gstack_harness_hook_release() { gstack_harness_hook_release() {
local ledger="$1" kind a b target="" on="" profile="" root="" edited=0 to="" sub cmd n=0 safe=1 total local ledger="$1" kind a b target="" on="" profile="" root="" edited=0 to="" sub cmd cfg n=0 safe=1 total
[ -f "$ledger" ] || return 0 [ -f "$ledger" ] || return 0
while IFS=$'\t' read -r kind a b; do while IFS=$'\t' read -r kind a b; do
case "$kind" in case "$kind" in
@@ -91,6 +92,8 @@ gstack_harness_hook_release() {
[ "${total:-0}" -gt "$n" ] && edited=1 [ "${total:-0}" -gt "$n" ] && edited=1
fi fi
command -v timeout >/dev/null 2>&1 && to="timeout 60" command -v timeout >/dev/null 2>&1 && to="timeout 60"
case "$target" in openclaw) cfg=openclaw.json ;; hermes) cfg=config.yaml ;; *) cfg="" ;; esac
if [ -z "$cfg" ] || [ -f "$profile/$cfg" ]; then
case "$target" in case "$target" in
openclaw) cmd="config unset plugins.entries.gstack-start" 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 >/dev/null 2>&1; then : if command -v openclaw >/dev/null 2>&1 && env -i PATH="$PATH" HOME="$HOME" OPENCLAW_STATE_DIR="$profile" $to openclaw $cmd </dev/null >/dev/null 2>&1; then :
@@ -99,6 +102,7 @@ gstack_harness_hook_release() {
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 >/dev/null 2>&1; then : 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 >/dev/null 2>&1; then :
else echo " start hook: turn it off in Hermes yourself: HERMES_HOME=\"$profile\" hermes plugins disable gstack-start"; fi ;; else echo " start hook: turn it off in Hermes yourself: HERMES_HOME=\"$profile\" hermes plugins disable gstack-start"; fi ;;
esac esac
fi
if [ "$safe" = 1 ] && [ -d "$root/__pycache__" ] && _gstack_harness_owned "$root/__pycache__" "$root"; then if [ "$safe" = 1 ] && [ -d "$root/__pycache__" ] && _gstack_harness_owned "$root/__pycache__" "$root"; then
rm -f "$root"/__pycache__/*.pyc rm -f "$root"/__pycache__/*.pyc
rmdir "$root/__pycache__" 2>/dev/null rmdir "$root/__pycache__" 2>/dev/null
+6 -4
View File
@@ -80,14 +80,16 @@ It is off unless you turn it on, because turning it on changes the harness's own
|---|---|---| |---|---|---|
| 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) | | 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` | | 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) | | 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` |
| Check | `openclaw plugins inspect gstack-start --runtime --json` lists `before_prompt_build` under `typedHooks` | `hermes plugins list` shows `gstack-start` enabled | | 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. **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` with `--start-hook`; with `GSTACK_HARNESS_START_HOOK` or `harness_start_hook`, `register` and `./setup --host` say why and exit 4). `OPENCLAW_CONFIG_PATH` naming the profile's own `openclaw.json`, by any path that resolves to it, is fine. 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. **Run the harness's own setup first.** gstack turns the hook on only in a harness that already has its config (`openclaw.json` from `openclaw onboard`, `config.yaml` from `hermes setup`). Without one, the harness CLI would create a config holding nothing but gstack's plugin setting, with no model, provider or terminal settings, so gstack runs no harness command: the plugin's files are written, the hook reports `written` with the setup to run, and `register` and `./setup --host` exit 4. Once the harness is set up, the next `./setup` or `gstack-harness refresh` turns the hook on, changing only the `gstack-start` plugin entry.
**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`. **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), `unknown` (the harness CLI is not on `PATH`, as inside OpenClaw's or Hermes's own terminal tool, and the config file cannot settle it; gstack reads `plugins.enabled` and `plugins.disabled` from Hermes's `config.yaml` and the plugin entry from `openclaw.json` instead), `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`. In a profile with no harness config yet (no `openclaw.json` or `config.yaml`), the hook was never turned on, so turning it off or unregistering runs no harness command and leaves the config alone.
**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. **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.
+4 -2
View File
@@ -6472,7 +6472,7 @@ There is no ledger for that harness or id under `$GSTACK_STATE_ROOT/harness`. `g
<a id="harness-hook-unsupported"></a> <a id="harness-hook-unsupported"></a>
### `gstack-harness: <harness> has no start hook` / `the start hook goes into the <harness> profile <dir>, and this registration uses another skills folder` (HARNESS_HOOK_UNSUPPORTED) ### `gstack-harness: <harness> has no start hook` / `the start hook goes into the <harness> profile <dir>, 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. 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` naming another config OpenClaw reads settings outside the profile, where gstack's settings change would not land, so `--start-hook` is refused and nothing is written. `OPENCLAW_CONFIG_PATH` naming the profile's own `openclaw.json` (compared after resolving symlinks) is not refused. Register without `--skills-dir` (set `OPENCLAW_STATE_DIR` or `HERMES_HOME` to pick the profile), unset `OPENCLAW_CONFIG_PATH` or point it at the profile's own `openclaw.json`, or drop `--start-hook`. When the choice comes from `GSTACK_HARNESS_START_HOOK` or `harness_start_hook` instead of the flag, the registration gets no hook, and `gstack-harness register` and `./setup --host` exit 4 with the reason (`HARNESS_HOOK_INACTIVE`, `start hook is off`); refreshes only print it.
<a id="harness-hook-choice-invalid"></a> <a id="harness-hook-choice-invalid"></a>
### `gstack-harness: GSTACK_HARNESS_START_HOOK="<value>" is not on or off` (HARNESS_HOOK_CHOICE_INVALID) ### `gstack-harness: GSTACK_HARNESS_START_HOOK="<value>" is not on or off` (HARNESS_HOOK_CHOICE_INVALID)
@@ -6487,7 +6487,9 @@ Each harness profile has one start hook, owned by the gstack registration that w
<a id="harness-hook-inactive"></a> <a id="harness-hook-inactive"></a>
### `gstack-harness: <harness> start hook is written: <why>. Turn it on: <command>` (HARNESS_HOOK_INACTIVE, exit 4) ### `gstack-harness: <harness> start hook is written: <why>. Turn it on: <command>` (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. 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 harness has no config yet (`no OpenClaw config at <path> yet` or `no Hermes config at <path> yet`: run `openclaw onboard` or `hermes setup` first, then `gstack-harness refresh`; gstack runs no harness command until then, because the CLI would create a config holding only gstack's plugin setting), 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.
The same code with `start hook is off: <why>` means you asked for the hook through `GSTACK_HARNESS_START_HOOK` or `harness_start_hook` but this registration cannot take it (a custom `--skills-dir`, `OPENCLAW_CONFIG_PATH` naming another config, or an unsafe plugin folder); the message names the fix.
<a id="harness-qm-credentials"></a> <a id="harness-qm-credentials"></a>
### `gstack-harness: no QM admin credential in the environment` / `... needs --yes` (HARNESS_QM_CREDENTIALS) ### `gstack-harness: no QM admin credential in the environment` / `... needs --yes` (HARNESS_QM_CREDENTIALS)
+11 -9
View File
@@ -22,7 +22,7 @@ import { spawnSync } from 'node:child_process';
import { bunIsolationArgs } from './bun-isolation'; import { bunIsolationArgs } from './bun-isolation';
import { runBashSync } from './script-invocation'; import { runBashSync } from './script-invocation';
import { frontmatterList, routerLine } from './skill-triggers'; import { frontmatterList, routerLine } from './skill-triggers';
import { activateHook, assertHookRoot, hookChoice, hookFiles, hookProfile, hookRoot, inspectHook, isHookTarget, profileSkills, type HookResult } from './harness-start-hook'; import { activateHook, assertHookRoot, foreignOpenclawConfig, hookChoice, hookConfig, hookFiles, hookProfile, hookRoot, inspectHook, isHookTarget, profileSkills, type HookResult } from './harness-start-hook';
export type SkillsMode = 'router' | 'hybrid' | 'all'; export type SkillsMode = 'router' | 'hybrid' | 'all';
export type TargetName = 'openclaw' | 'hermes' | 'dots' | 'generic' | 'qm' | 'muse' | 'grok'; export type TargetName = 'openclaw' | 'hermes' | 'dots' | 'generic' | 'qm' | 'muse' | 'grok';
@@ -135,7 +135,7 @@ 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_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_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_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_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), unset OPENCLAW_CONFIG_PATH or point it at the profile\'s own openclaw.json, 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_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 <harness> --no-start-hook; otherwise move the folder aside, then register again' }, HARNESS_HOOK_OWNED_ELSEWHERE: { anchor: 'harness-hook-owned-elsewhere', fix: 'if another gstack install wrote it, run that install\'s gstack-harness register <harness> --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_HOOK_INACTIVE: { anchor: 'harness-hook-inactive', fix: 'run the command printed above, then gstack-harness status; the skills themselves are registered' },
@@ -525,7 +525,7 @@ 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; 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<string, Buffer>; note?: string } interface HookPlan { on: boolean; prevOn: boolean; explicit: boolean; retry: boolean; profile: string; root: string; files: Map<string, Buffer>; note?: string; fix?: string; requested?: boolean }
/** The start hook for this run: whether it is on, where it goes, and its files (null for targets without a hook). */ /** 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 { function hookPlan(opts: RegisterOptions, dest: string, prev: Ledger | null): HookPlan | null {
@@ -544,17 +544,19 @@ function hookPlan(opts: RegisterOptions, dest: string, prev: Ledger | null): Hoo
const name = t.name; 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; }; 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); if (!choice.on) return off(plan);
const why = dest !== profileSkills(t.name, profile) ? 'this registration uses another skills folder (' + dest + ')' const requested = !opts.inherit;
: t.name === 'openclaw' && opts.env.OPENCLAW_CONFIG_PATH ? 'OPENCLAW_CONFIG_PATH points OpenClaw at a config outside that profile' : ''; const foreign = t.name === 'openclaw' ? foreignOpenclawConfig(opts.env, profile) : '';
const [why, fix] = dest !== profileSkills(t.name, profile) ? ['this registration uses another skills folder (' + dest + ')', 'register without --skills-dir to get the hook, or pass --no-start-hook to keep this registration without it']
: foreign ? ['OPENCLAW_CONFIG_PATH points OpenClaw at ' + foreign + ', outside that profile', 'unset OPENCLAW_CONFIG_PATH or set it to ' + hookConfig('openclaw', profile) + ', then register again'] : ['', ''];
if (why && (plan.explicit || !prevOn)) { 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); 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)' }); return off({ ...plan, on: false, requested, fix, note: 'off: ' + why + ' (' + choice.source + ' asked for the hook)' });
} }
try { try {
assertHookRoot(t.name, profile, root, prevOn ? prev!.meta.hook_root : undefined, (code, msg) => { throw new HarnessError(code, msg); }); assertHookRoot(t.name, profile, root, prevOn ? prev!.meta.hook_root : undefined, (code, msg) => { throw new HarnessError(code, msg); });
} catch (e) { } catch (e) {
if (plan.explicit || prevOn || !(e instanceof HarnessError)) throw 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 off({ ...plan, on: false, requested, fix: HARNESS_CODES[e.code].fix, note: 'off: ' + e.message + ' (' + choice.source + ' asked for the hook)' });
} }
return { ...plan, files: hookFiles(t.name, root) }; return { ...plan, files: hookFiles(t.name, root) };
} }
@@ -562,7 +564,7 @@ function hookPlan(opts: RegisterOptions, dest: string, prev: Ledger | null): Hoo
/** 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. */ /** 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'] { function finishHook(opts: RegisterOptions, hook: HookPlan | null, released: string): RegisterResult['hook'] {
if (!hook) return undefined; if (!hook) return undefined;
if (!hook.on) return { state: 'off', ...(hook.note ? { detail: hook.note } : {}), ...(released ? { fix: released } : {}) }; if (!hook.on) return { state: 'off', ...(hook.note ? { detail: hook.note } : {}), ...(released || hook.fix ? { fix: released || hook.fix } : {}), ...(hook.requested ? { required: true } : {}) };
const t = opts.target.name as 'openclaw' | 'hermes'; const t = opts.target.name as 'openclaw' | 'hermes';
const required = !hook.prevOn || hook.explicit; 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 && !hook.retry) return { state: 'unchecked', profile: hook.profile, root: hook.root, fix: 'gstack-harness status shows whether it is active' };
@@ -678,7 +680,7 @@ export function register(opts: RegisterOptions): RegisterResult {
if (opts.registry && t.render === 'host') shellRegistry(opts, 'gstack_install_registry_upsert', [t.name, 'global', '-', dest, opts.checkout, opts.checkout, opts.version, '-', '-']); if (opts.registry && t.render === 'host') shellRegistry(opts, 'gstack_install_registry_upsert', [t.name, 'global', '-', dest, opts.checkout, opts.checkout, opts.version, '-', '-']);
if (same && hook?.on && hook.explicit && prev.meta.hook_active !== 'no') writeLedger(lfile, { ...prev, meta: { ...prev.meta, hook_active: 'no' } }); 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 hookResult = finishHook(opts, hook, released);
const active = hookResult?.required || hookResult?.retried ? ({ active: 'yes', disabled: 'disabled' } as Record<string, string>)[hookResult.state] ?? 'no' : ''; const active = hook?.on && (hookResult?.required || hookResult?.retried) ? ({ active: 'yes', disabled: 'disabled' } as Record<string, string>)[hookResult!.state] ?? 'no' : '';
const saved = active ? readLedger(lfile) : null; const saved = active ? readLedger(lfile) : null;
if (saved && saved.meta.hook_active !== active) writeLedger(lfile, { ...saved, meta: { ...saved.meta, hook_active: active } }); 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 } : {}) }; return { id, target: t.name, dest, mode, status: !prev ? 'installed' : same ? 'unchanged' : 'updated', written, skills, router, ledger: lfile, kept, ...(hookResult ? { hook: hookResult } : {}) };
+54 -5
View File
@@ -22,14 +22,14 @@ export const START_NOTE_MAX = 300;
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 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 HookTarget = 'openclaw' | 'hermes';
export type HookState = 'active' | 'written' | 'disabled' | 'unchecked' | 'off'; export type HookState = 'active' | 'written' | 'disabled' | 'unknown' | 'unchecked' | 'off';
export interface HookResult { state: HookState; fix?: string; detail?: string; profile?: string; root?: string } export interface HookResult { state: HookState; fix?: string; detail?: string; profile?: string; root?: string }
type Env = Record<string, string | undefined>; type Env = Record<string, string | undefined>;
const MANAGED = "Managed by gstack-harness: it rewrites this file. Turn the hook off: gstack-harness register %s --no-start-hook"; const MANAGED = "Managed by gstack-harness: it rewrites this file. Turn the hook off: gstack-harness register %s --no-start-hook";
const SPECS = { const SPECS = {
openclaw: { openclaw: {
profileVar: 'OPENCLAW_STATE_DIR', home: '.openclaw', sub: 'extensions', skills: ['skills'], profileVar: 'OPENCLAW_STATE_DIR', home: '.openclaw', sub: 'extensions', skills: ['skills'], name: 'OpenClaw', config: 'openclaw.json', setup: 'openclaw onboard',
activate: [['config', 'set', 'plugins.entries.' + HOOK_ID + '.hooks.allowConversationAccess', 'true'], ['config', 'set', 'plugins.entries.' + HOOK_ID + '.enabled', 'true']], activate: [['config', 'set', 'plugins.entries.' + HOOK_ID + '.hooks.allowConversationAccess', 'true'], ['config', 'set', 'plugins.entries.' + HOOK_ID + '.enabled', 'true']],
files: (): Record<string, string> => ({ files: (): Record<string, string> => ({
'index.js': [ 'index.js': [
@@ -55,7 +55,7 @@ const SPECS = {
}), }),
}, },
hermes: { hermes: {
profileVar: 'HERMES_HOME', home: '.hermes', sub: 'plugins', skills: ['skills', 'gstack'], profileVar: 'HERMES_HOME', home: '.hermes', sub: 'plugins', skills: ['skills', 'gstack'], name: 'Hermes', config: 'config.yaml', setup: 'hermes setup',
activate: [['plugins', 'enable', HOOK_ID]], activate: [['plugins', 'enable', HOOK_ID]],
files: (): Record<string, string> => ({ files: (): Record<string, string> => ({
'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'), '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'),
@@ -91,6 +91,51 @@ export const hookRoot = (t: HookTarget, profile: string) => path.join(profile, S
/** The skills folder a registration must use for the hook to go into that profile. */ /** 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); 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(' && '); const activateCommands = (t: HookTarget, profile: string) => SPECS[t].activate.map(a => SPECS[t].profileVar + '=' + JSON.stringify(profile) + ' ' + t + ' ' + a.join(' ')).join(' && ');
/** The harness's own config file in a profile: the file activation edits through the harness CLI. */
export const hookConfig = (t: HookTarget, profile: string) => path.join(profile, SPECS[t].config);
function canonical(p: string): string {
try { return fs.realpathSync(p); } catch {}
const parent = path.dirname(p);
return parent === p ? p : path.join(canonical(parent), path.basename(p));
}
/** The config OPENCLAW_CONFIG_PATH names when it is not the profile's own openclaw.json (compared after realpath), else ''. */
export function foreignOpenclawConfig(env: Env, profile: string): string {
const v = env.OPENCLAW_CONFIG_PATH;
if (!v) return '';
const named = path.resolve(v);
return canonical(named) === canonical(hookConfig('openclaw', profile)) ? '' : named;
}
/** A profile with no harness config yet: turning the plugin on through the harness CLI would create a config holding only that setting, so gstack waits for the harness's own setup. */
function noConfig(t: HookTarget, profile: string): HookResult | null {
const cfg = hookConfig(t, profile);
if (fs.existsSync(cfg)) return null;
const s = SPECS[t];
return { state: 'written', detail: 'no ' + s.name + ' config at ' + cfg + ' yet, and turning the plugin on would create one holding only that setting', fix: 'run ' + s.name + "'s own setup first (" + s.profileVar + '=' + JSON.stringify(profile) + ' ' + s.setup + '), then gstack-harness refresh' };
}
/** Reads the hook from the harness's config file when its CLI is not on PATH (a harness's own terminal tool often lacks it). */
function inspectConfig(t: HookTarget, profile: string, fix: string): HookResult {
const why = t + ' is not on PATH';
const written: HookResult = { state: 'written', detail: why + ', so gstack could not turn the plugin on or check it', fix };
const cfg = hookConfig(t, profile);
const missing = noConfig(t, profile);
if (missing) return { ...missing, detail: why + ' and ' + missing.detail };
let plugins: any;
try { plugins = ((t === 'hermes' ? Bun.YAML.parse(fs.readFileSync(cfg, 'utf8')) : JSON.parse(fs.readFileSync(cfg, 'utf8'))) as any)?.plugins ?? {}; }
catch { return { state: 'unknown', detail: why + ' and ' + cfg + ' could not be parsed, so gstack could not check the plugin', fix: 'gstack-harness status from a shell where ' + t + ' is on PATH' }; }
if (t === 'hermes') {
const listed = (k: string) => Array.isArray(plugins[k]) && plugins[k].includes(HOOK_ID);
if (listed('disabled')) return { state: 'disabled', detail: 'turned off in Hermes (plugins.disabled in ' + cfg + '); gstack leaves it off', fix };
if (listed('enabled') && fs.existsSync(path.join(hookRoot(t, profile), 'plugin.yaml'))) return { state: 'active', detail: 'read from plugins.enabled in ' + cfg + ' (' + why + ')' };
return written;
}
const entry = plugins.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 (entry.enabled === true && entry.hooks?.allowConversationAccess === true) return { state: 'unknown', detail: cfg + ' turns it on, but ' + why + ', so gstack could not check that OpenClaw loads it', fix: 'gstack-harness status from a shell where openclaw is on PATH' };
return written;
}
/** The plugin's files (absolute path -> bytes): fixed code plus note.txt, which the code reads at each turn. */ /** 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<string, Buffer> { export function hookFiles(t: HookTarget, root: string): Map<string, Buffer> {
@@ -124,6 +169,8 @@ const onPath = (bin: string, env: Env) => (env.PATH ?? '').split(path.delimiter)
/** Turns the plugin on in the harness. Returns the first failure, or ''. */ /** Turns the plugin on in the harness. Returns the first failure, or ''. */
export function activateHook(t: HookTarget, profile: string, env: Env, home: string): string { export function activateHook(t: HookTarget, profile: string, env: Env, home: string): string {
if (!onPath(t, env)) return t + ' is not on PATH'; if (!onPath(t, env)) return t + ' is not on PATH';
const missing = noConfig(t, profile);
if (missing) return missing.detail!;
for (const a of SPECS[t].activate) { for (const a of SPECS[t].activate) {
const r = runHarness(t, profile, a, env, home, 60_000); const r = runHarness(t, profile, a, env, home, 60_000);
if (!r.ok) return t + ' ' + a.join(' ') + ' failed (' + r.why + ')'; if (!r.ok) return t + ' ' + a.join(' ') + ' failed (' + r.why + ')';
@@ -136,12 +183,14 @@ function parseJson(s: string): any {
return null; return null;
} }
/** Reads the hook back from the harness: active (configured for new sessions), disabled by the user, or written (with the fix). */ /** Reads the hook back from the harness: active (configured for new sessions), disabled by the user, written (with the fix), or unknown (no CLI and a config that cannot settle it). Runs no harness command while the profile has no harness config. */
const READ_TIMEOUT = 15_000; const READ_TIMEOUT = 15_000;
export function inspectHook(t: HookTarget, profile: string, env: Env, home: string): HookResult { export function inspectHook(t: HookTarget, profile: string, env: Env, home: string): HookResult {
const fix = activateCommands(t, profile); 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 (!onPath(t, env)) return inspectConfig(t, profile, fix);
const missing = noConfig(t, profile);
if (missing) return missing;
if (t === 'hermes') { if (t === 'hermes') {
const r = runHarness(t, profile, ['plugins', 'list', '--json'], env, home, READ_TIMEOUT); const r = runHarness(t, profile, ['plugins', 'list', '--json'], env, home, READ_TIMEOUT);
const row = (parseJson(r.stdout) ?? []).find?.((p: any) => p?.name === HOOK_ID); const row = (parseJson(r.stdout) ?? []).find?.((p: any) => p?.name === HOOK_ID);
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "gstack", "name": "gstack",
"version": "1.94.23", "version": "1.94.24",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
+154 -2
View File
@@ -38,6 +38,13 @@ case "$1 $2" in
hooks=""; [ -d "$d/extensions/gstack-start" ] && [ "$acc" = true ] && [ "$en" != false ] && hooks='{"name":"before_prompt_build"}' 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" ;; printf '{"plugin":{"status":"loaded","enabled":%s},"typedHooks":[%s]}\n' "$([ "$en" = false ] && echo false || echo true)" "$hooks" ;;
esac esac
case "$1 $2" in "config set"|"config unset")
j="$d/openclaw.json"; b="$d/fake-base.json"
[ -f "$b" ] || { if [ -f "$j" ]; then cp "$j" "$b"; else printf '{"meta":{"lastTouchedVersion":"fake"}}' > "$b"; fi; }
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
if grep -q '^plugins.entries.gstack-start' "$c"; then printf '%s,"plugins":{"entries":{"gstack-start":{"enabled":%s,"hooks":{"allowConversationAccess":%s}}}}}' "$(sed 's/}$//' "$b")" "$enj" "$acc" > "$j"; else cp "$b" "$j"; fi ;;
esac
`; `;
const FAKE_HERMES = String.raw`#!/bin/sh const FAKE_HERMES = String.raw`#!/bin/sh
d="$HERMES_HOME" d="$HERMES_HOME"
@@ -48,10 +55,19 @@ case "$1 $2" in
"plugins remove") rm -rf "$d/plugins/$3" "$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 ;; "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 esac
case "$1 $2" in "plugins enable"|"plugins disable"|"plugins remove")
y="$d/config.yaml"; b="$d/fake-base.yaml"
[ -f "$b" ] || { if [ -f "$y" ]; then cp "$y" "$b"; else : > "$b"; fi; }
s=$(cat "$d/fake-state" 2>/dev/null)
{ cat "$b"; [ -z "$s" ] || printf 'plugins:\n %s:\n - gstack-start\n' "$s"; } > "$y.t"; mv "$y.t" "$y" ;;
esac
`; `;
/** A configured harness: what each harness's own setup leaves (OpenClaw onboarding, hermes setup) before gstack runs. */
const HEALTHY = { openclaw: '{"agents":{"defaults":{"model":{"primary":"anthropic/claude-opus-5-5"}}},"gateway":{"mode":"local"}}', hermes: 'model:\n default: claude-opus-5-5\n provider: anthropic\nterminal:\n backend: local\n' };
const CONFIG = { openclaw: 'openclaw.json', hermes: 'config.yaml' };
/** A temp home, state root and profile, with fake openclaw and hermes CLIs first on PATH. */ /** 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<string, string> } = {}) { function rig(target: 'openclaw' | 'hermes', extra: { noCli?: boolean; fresh?: boolean; env?: Record<string, string> } = {}) {
const d = tmp(); const d = tmp();
const bin = path.join(d, 'bin'); const bin = path.join(d, 'bin');
fs.mkdirSync(bin); fs.mkdirSync(bin);
@@ -59,11 +75,13 @@ function rig(target: 'openclaw' | 'hermes', extra: { noCli?: boolean; env?: Reco
fs.writeFileSync(path.join(bin, 'hermes'), FAKE_HERMES, { mode: 0o755 }); fs.writeFileSync(path.join(bin, 'hermes'), FAKE_HERMES, { mode: 0o755 });
const profile = path.join(d, target === 'openclaw' ? 'oc' : 'he'); const profile = path.join(d, target === 'openclaw' ? 'oc' : 'he');
fs.mkdirSync(profile, { recursive: true }); fs.mkdirSync(profile, { recursive: true });
const config = path.join(profile, CONFIG[target]);
if (!extra.fresh) fs.writeFileSync(config, HEALTHY[target]);
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 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> = {}): RegisterOptions => ({ target: TARGETS[target], checkout: ROOT, stateRoot: path.join(d, 'state'), env, home: path.join(d, 'home'), version: VERSION, ...more }); const o = (more: Partial<RegisterOptions> = {}): 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 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') : []; 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 }; return { d, bin, profile, config, env, o, root, calls };
} }
describe('start hook: the note and the plugin files', () => { describe('start hook: the note and the plugin files', () => {
@@ -486,7 +504,140 @@ describe('start hook: teardown keeps what gstack did not write (fake CLI)', () =
}, 300_000); }, 300_000);
}); });
describe('start hook: HARN-18 smoke defects (GST-778)', () => {
const offPath = (r: { env: Record<string, string> }) => ({ ...r.env, PATH: '/usr/bin:/bin' });
const cli = (r: { bin: string; env: Record<string, string> }, t: 'openclaw' | 'hermes', ...args: string[]) => spawnSync(path.join(r.bin, t), args, { env: r.env, encoding: 'utf8', timeout: 30_000 });
test('OPENCLAW_CONFIG_PATH naming the profile own openclaw.json, even through a symlink, keeps the hook', () => {
const r = rig('openclaw');
const link = path.join(tmp(), 'ln');
fs.symlinkSync(r.profile, link);
const viaEnv = register({ ...r.o(), env: { ...r.env, GSTACK_HARNESS_START_HOOK: '1', OPENCLAW_CONFIG_PATH: path.join(link, 'openclaw.json') } });
expect(viaEnv.hook).toMatchObject({ state: 'active', required: true });
const s = rig('openclaw');
expect(register({ ...s.o({ startHook: true }), env: { ...s.env, OPENCLAW_CONFIG_PATH: s.config } }).hook?.state).toBe('active');
}, 120_000);
test('a foreign OPENCLAW_CONFIG_PATH still turns an env opt-in off, and says so as a failure with the fix', () => {
const r = rig('openclaw');
const res = register({ ...r.o(), env: { ...r.env, GSTACK_HARNESS_START_HOOK: '1', OPENCLAW_CONFIG_PATH: path.join(r.d, 'elsewhere', 'openclaw.json') } });
expect(res.hook).toMatchObject({ state: 'off', required: true });
expect(res.hook?.detail).toContain('OPENCLAW_CONFIG_PATH points OpenClaw at ' + path.join(r.d, 'elsewhere', 'openclaw.json'));
expect(res.hook?.fix).toContain('unset OPENCLAW_CONFIG_PATH or set it to ' + r.config);
expect(r.calls()).toEqual([]);
expect(fs.existsSync(path.join(r.profile, 'extensions'))).toBe(false);
expect(readLedger(res.ledger)!.meta.start_hook).toBe('off');
expect(fs.readFileSync(r.config, 'utf8')).toBe(HEALTHY.openclaw);
const custom = register({ ...r.o({ skillsDir: path.join(r.d, 'custom', 'skills') }), env: { ...r.env, GSTACK_HARNESS_START_HOOK: '1' } });
expect(custom.hook).toMatchObject({ state: 'off', required: true });
expect(custom.hook?.fix).toContain('--no-start-hook');
expect(register({ ...r.o({ skillsDir: path.join(r.d, 'custom', 'skills'), startHook: false }), env: { ...r.env, GSTACK_HARNESS_START_HOOK: '1' } }).hook).toEqual({ state: 'off' });
}, 120_000);
test('a fresh harness with no config: turning the hook on creates no config, runs no harness command, and fails with the setup to run first', () => {
for (const t of ['openclaw', 'hermes'] as const) {
const r = rig(t, { fresh: true });
const res = register(r.o({ startHook: true }));
expect(res.hook).toMatchObject({ state: 'written', required: true });
expect(res.hook?.detail).toContain('no ' + (t === 'openclaw' ? 'OpenClaw' : 'Hermes') + ' config at ' + r.config);
expect(res.hook?.fix).toContain(t === 'openclaw' ? 'openclaw onboard' : 'hermes setup');
expect(fs.existsSync(r.config)).toBe(false);
expect(r.calls()).toEqual([]);
expect(fs.existsSync(path.join(r.profile, 'skills'))).toBe(true);
expect(readLedger(res.ledger)!.meta.hook_active).toBe('no');
const bare = rig(t, { fresh: true, noCli: true });
const noCli = register(bare.o({ startHook: true })).hook;
expect(noCli).toMatchObject({ state: 'written', required: true });
expect(noCli?.detail).toContain(t + ' is not on PATH');
expect(noCli?.fix).toContain(t === 'openclaw' ? 'openclaw onboard' : 'hermes setup');
expect(noCli?.fix).not.toContain(t === 'openclaw' ? 'config set' : 'plugins enable');
}
}, 300_000);
test('once the harness setup has written its config, the next refresh turns the hook on and changes only the plugin entry', () => {
const o = rig('openclaw', { fresh: true });
register(o.o({ startHook: true }));
fs.writeFileSync(o.config, HEALTHY.openclaw);
expect(register(o.o()).hook).toMatchObject({ state: 'active', retried: true });
const json = JSON.parse(fs.readFileSync(o.config, 'utf8'));
expect(json.plugins).toEqual({ entries: { [HOOK_ID]: { enabled: true, hooks: { allowConversationAccess: true } } } });
delete json.plugins;
expect(json).toEqual(JSON.parse(HEALTHY.openclaw));
expect(o.calls().filter(l => l.startsWith('config set')).map(l => l.split('|')[0])).toEqual(['config set plugins.entries.gstack-start.hooks.allowConversationAccess true', 'config set plugins.entries.gstack-start.enabled true']);
const h = rig('hermes', { fresh: true });
register(h.o({ startHook: true }));
fs.writeFileSync(h.config, HEALTHY.hermes);
expect(register(h.o()).hook).toMatchObject({ state: 'active', retried: true });
expect(fs.readFileSync(h.config, 'utf8')).toBe(HEALTHY.hermes + 'plugins:\n enabled:\n - gstack-start\n');
}, 300_000);
test('turning off or unregistering a hook that never turned on, in a harness with no config, creates no config', () => {
for (const t of ['openclaw', 'hermes'] as const) {
const r = rig(t, { fresh: true });
register(r.o({ startHook: true }));
if (t === 'openclaw') expect(register(r.o({ startHook: false })).hook?.state).toBe('off');
else unregister(r.o().stateRoot, ROOT, 'hermes', r.env);
expect(fs.existsSync(r.config)).toBe(false);
expect(r.calls()).toEqual([]);
expect(fs.existsSync(r.root)).toBe(false);
}
}, 300_000);
test('Hermes status without hermes on PATH (its own terminal tool) reads the hook from config.yaml', () => {
const r = rig('hermes');
expect(register(r.o({ startHook: true })).hook?.state).toBe('active');
const row = () => status(r.o().stateRoot, VERSION, offPath(r)).find(s => s.target === 'hermes')!.hook!;
expect(row()).toMatchObject({ state: 'active' });
expect(row().detail).toContain('hermes is not on PATH');
cli(r, 'hermes', 'plugins', 'disable', HOOK_ID);
expect(row().state).toBe('disabled');
fs.writeFileSync(r.config, HEALTHY.hermes);
expect(row().state).toBe('written');
fs.writeFileSync(r.config, 'plugins: [unclosed\n');
expect(row()).toMatchObject({ state: 'unknown' });
expect(row().detail).toContain('hermes is not on PATH');
}, 120_000);
test('OpenClaw status without openclaw on PATH is unknown when openclaw.json turns the hook on, disabled when the user turned it off', () => {
const r = rig('openclaw');
expect(register(r.o({ startHook: true })).hook?.state).toBe('active');
const row = () => status(r.o().stateRoot, VERSION, offPath(r)).find(s => s.target === 'openclaw')!.hook!;
expect(row()).toMatchObject({ state: 'unknown' });
expect(row().detail).toContain('openclaw is not on PATH');
cli(r, 'openclaw', 'config', 'set', 'plugins.entries.gstack-start.enabled', 'false');
expect(row().state).toBe('disabled');
expect(status(r.o().stateRoot, VERSION, r.env).find(s => s.target === 'openclaw')!.hook!.state).toBe('disabled');
}, 120_000);
});
describe.skipIf(process.platform === 'win32')('start hook through ./setup --host', () => { describe.skipIf(process.platform === 'win32')('start hook through ./setup --host', () => {
test('OPENCLAW_CONFIG_PATH naming the profile own config keeps an env opt-in on; a foreign one exits 4 with the hook off', () => {
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, 'openclaw.json'), HEALTHY.openclaw);
const PATH = bin + path.delimiter + f.env.PATH;
const own = runSetup(f, path.join(src, 'setup'), ['--host', 'openclaw'], { env: { PATH, GSTACK_HARNESS_START_HOOK: '1', OPENCLAW_CONFIG_PATH: path.join(profile, 'openclaw.json') } });
expect(own.status, own.stdout + own.stderr).toBe(0);
expect(own.stdout).toContain('start hook: active');
const g = makeFixture();
const gsrc = makeSource(g, path.join(g.dir, 'gstack'));
fs.mkdirSync(path.join(g.home, '.openclaw'), { recursive: true });
fs.writeFileSync(path.join(g.home, '.openclaw', 'openclaw.json'), HEALTHY.openclaw);
const foreign = runSetup(g, path.join(gsrc, 'setup'), ['--host', 'openclaw'], { env: { PATH: bin + path.delimiter + g.env.PATH, GSTACK_HARNESS_START_HOOK: '1', OPENCLAW_CONFIG_PATH: path.join(g.dir, 'other', 'openclaw.json') } });
expect(foreign.status, foreign.stdout + foreign.stderr).toBe(4);
expect(foreign.stdout).toContain('start hook: off');
expect(foreign.stderr).toContain('(HARNESS_HOOK_INACTIVE; docs/troubleshooting.md#harness-hook-inactive)');
expect(foreign.stderr).toContain('OPENCLAW_CONFIG_PATH');
expect(fs.existsSync(path.join(g.home, '.openclaw', 'skills', 'gstack', 'SKILL.md'))).toBe(true);
}, 300_000);
test('exits 4 with the skills registered when the hook does not turn on, and the next run retries it', () => { test('exits 4 with the skills registered when the hook does not turn on, and the next run retries it', () => {
const f = makeFixture(); const f = makeFixture();
const src = makeSource(f, path.join(f.dir, 'gstack')); const src = makeSource(f, path.join(f.dir, 'gstack'));
@@ -495,6 +646,7 @@ describe.skipIf(process.platform === 'win32')('start hook through ./setup --host
fs.writeFileSync(path.join(bin, 'openclaw'), FAKE_OPENCLAW, { mode: 0o755 }); fs.writeFileSync(path.join(bin, 'openclaw'), FAKE_OPENCLAW, { mode: 0o755 });
const profile = path.join(f.home, '.openclaw'); const profile = path.join(f.home, '.openclaw');
fs.mkdirSync(profile, { recursive: true }); fs.mkdirSync(profile, { recursive: true });
fs.writeFileSync(path.join(profile, 'openclaw.json'), HEALTHY.openclaw);
fs.writeFileSync(path.join(profile, 'fake-fail'), ''); fs.writeFileSync(path.join(profile, 'fake-fail'), '');
const PATH = bin + path.delimiter + f.env.PATH; 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' } }); const first = runSetup(f, path.join(src, 'setup'), ['--host', 'openclaw'], { env: { PATH, GSTACK_HARNESS_START_HOOK: '1' } });