mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 07:18:03 +01:00
* 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>
184 lines
12 KiB
TypeScript
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');
|
|
}
|