Files
gstack/lib/harness-start-hook.ts
Garry Tanandcapy-ai[bot] b15492a148 v1.92.23.0 feat(harness): opt-in start hook for OpenClaw and Hermes, so agents run a skill's start command (GST-718) (#3786)
* feat(harness): opt-in start hook for OpenClaw and Hermes (GST-718)

gstack-harness register openclaw|hermes --start-hook (or GSTACK_HARNESS_START_HOOK,
or gstack-config harness_start_hook) adds a gstack-start plugin that puts a
one-line note on every user turn to run a gstack skill's start command as
written. Activation goes through each harness's own CLI and is read back;
a run that turns it on and ends inactive exits 4. Off by default.

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

* 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>

* fix(harness): an env or config start-hook opt-in with an unsafe plugin folder turns the hook off instead of failing refresh (GST-718)

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

* fix(harness): normalize the hook profile, retry an interrupted activation, re-enable after kept edits

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

* fix(harness): a non-folder or empty plugin path is never taken as kept edits

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

* fix(harness): teardown keeps kept copies and refuses symlinked plugin paths; a failed opt-out can rerun; a saved hook survives OPENCLAW_CONFIG_PATH

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

* fix(harness): any saved plugin root is checked for symlinks before an opt-out deletes through it

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

* fix(harness): tolerant JSON read-back, short read-back timeout, retry an interrupted explicit activation, symlink checks on every hook opt-out and folder cleanup

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

* docs(harness): start hook unchecked state and uninstall notes

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>
2026-11-01 09:52:31 +00:00

184 lines
12 KiB
TypeScript

/**
* 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<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 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<string, string> => ({
'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<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'),
'__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<string, Buffer> {
const out = new Map<string, Buffer>();
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-<stamp> 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');
}