From ef00f3ad88c5ae3bacaef794bc3a4f32fa5a33ea Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 06:12:07 +0000 Subject: [PATCH] fix(harness): retry an inactive start hook without failing refresh, and never re-enable a user disable (GST-718) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-harness | 6 ++-- docs/hosts.md | 4 +-- docs/troubleshooting.md | 4 +-- lib/harness-register.ts | 22 +++++++----- setup | 3 +- test/harness-start-hook.test.ts | 59 +++++++++++++++++++++++++++++++-- 6 files changed, 79 insertions(+), 19 deletions(-) diff --git a/bin/gstack-harness b/bin/gstack-harness index ecf0a101d..c8f4699bd 100755 --- a/bin/gstack-harness +++ b/bin/gstack-harness @@ -134,7 +134,7 @@ async function main(): Promise { } case 'refresh': { let code = 0; - for (const r of refresh(base)) { if (r.status !== 'unchanged') receipt(r); code ||= hookFailed(r); } + for (const r of refresh(base)) { if (r.status !== 'unchanged' || r.hook?.required || r.hook?.retried) receipt(r); code ||= hookFailed(r); } return code; } case 'auto': { @@ -142,10 +142,10 @@ async function main(): Promise { 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); if (hookFailed(r)) failed = 1; } + 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); if (hookFailed(r)) failed = 1; } } + try { for (const r of refresh(base, done)) { if (r.status !== 'unchanged' || r.hook?.required || r.hook?.retried) 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/docs/hosts.md b/docs/hosts.md index 5fe5c21ce..f63cc6a23 100644 --- a/docs/hosts.md +++ b/docs/hosts.md @@ -83,9 +83,9 @@ It is off unless you turn it on, because turning it on changes the harness's own | 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` gets no hook (`HARNESS_HOOK_UNSUPPORTED` when you ask for one). +**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). -**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) or `off`. When a run turns the hook on and it does not end up active, `gstack-harness register` and `./setup --host` exit 4 after registering the skills (`HARNESS_HOOK_INACTIVE`). +**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) 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, and print the harness command to run when the harness CLI is not on `PATH`. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 61d01cf19..62c8453ed 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -5901,7 +5901,7 @@ There is no ledger for that harness or id under `$GSTACK_STATE_ROOT/harness`. `g ### `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, 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` 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) @@ -5916,7 +5916,7 @@ Each harness profile has one start hook, owned by the gstack registration that w ### `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. +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/lib/harness-register.ts b/lib/harness-register.ts index a1870b308..67976bed7 100644 --- a/lib/harness-register.ts +++ b/lib/harness-register.ts @@ -354,7 +354,7 @@ 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 }; + hook?: HookResult & { required?: boolean; retried?: boolean }; } const PLAIN = /^[^\t\n\r]+$/; @@ -541,9 +541,11 @@ function hookPlan(opts: RegisterOptions, dest: string, prev: Ledger | null): Hoo 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() }; if (!choice.on) return plan; - if (dest !== profileSkills(t.name, profile)) { - if (plan.explicit) throw new HarnessError('HARNESS_HOOK_UNSUPPORTED', 'the start hook goes into the ' + t.displayName + ' profile ' + profile + ', and this registration uses another skills folder (' + dest + ')'); - return { ...plan, on: false, note: 'off: this registration uses a custom skills folder (' + choice.source + ' asked for the hook)' }; + 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) { + if (plan.explicit) throw new HarnessError('HARNESS_HOOK_UNSUPPORTED', 'the start hook goes into the ' + t.displayName + ' profile ' + profile + ', and ' + why); + return { ...plan, on: false, note: 'off: ' + why + ' (' + choice.source + ' asked for the hook)' }; } assertHookRoot(t.name, profile, root, prevOn ? prev!.meta.hook_root : undefined, (code, msg) => { throw new HarnessError(code, msg); }); return { ...plan, files: hookFiles(t.name, root) }; @@ -554,11 +556,15 @@ function finishHook(opts: RegisterOptions, hook: HookPlan | null, released: stri 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 || hook.retry; - if (!required) return { state: 'unchecked', profile: hook.profile, root: hook.root, fix: 'gstack-harness status shows whether it is active' }; + 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 }; + 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 { @@ -663,7 +669,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, '-', '-']); const hookResult = finishHook(opts, hook, released); - const active = hookResult?.required ? (hookResult.state === 'active' ? 'yes' : 'no') : ''; + 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 } : {}) }; diff --git a/setup b/setup index 3b679f79f..6aca2409f 100755 --- a/setup +++ b/setup @@ -518,7 +518,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 diff --git a/test/harness-start-hook.test.ts b/test/harness-start-hook.test.ts index 150642542..9bf500a64 100644 --- a/test/harness-start-hook.test.ts +++ b/test/harness-start-hook.test.ts @@ -9,7 +9,7 @@ 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 } from '../lib/harness-start-hook'; +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, '..'); @@ -29,7 +29,8 @@ en=$(grep '^plugins.entries.gstack-start.enabled=' "$c" | tail -1 | cut -d= -f2) case "$1 $2" in "config set") echo "$3=$4" >> "$c" ;; "config unset") grep -v "^$3" "$c" > "$c.t"; mv "$c.t" "$c" ;; - "config get") printf '{"allow":%s,"entries":{"gstack-start":{"enabled":%s,"hooks":{"allowConversationAccess":%s}}}}\n' "$(cat "$d/fake-allow" 2>/dev/null || echo null)" "$enj" "$acc" ;; + "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" ;; @@ -55,7 +56,7 @@ function rig(target: 'openclaw' | 'hermes', extra: { noCli?: boolean; env?: Reco 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', OPENCLAW_CONFIG_PATH: '/elsewhere/openclaw.json', [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 => ({ 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') : []; @@ -143,6 +144,7 @@ describe('start hook: who decides', () => { 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); }); @@ -233,6 +235,54 @@ describe('start hook: OpenClaw lifecycle (fake CLI)', () => { }, 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' } }); @@ -299,6 +349,9 @@ describe.skipIf(process.platform === 'win32')('start hook through ./setup --host 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);