moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

View File

@@ -0,0 +1,271 @@
import { describe, expect, it, vi } from 'vitest';
import { createLogger } from '@waggle/hive-mind-shim-core';
import type {
CliBridge,
HookFrame,
MemoryHit,
SaveMemoryResult,
} from '@waggle/hive-mind-shim-core';
import {
makeOpenclawHandler,
type HookContext,
type InternalHookEventLike,
type OpenclawHandlerInput,
type SessionStartExtracted,
type StopExtracted,
type UserPromptExtracted,
} from '@waggle/hive-mind-hooks-core';
import {
openclawAdapter,
OPENCLAW_EVENT_NAME,
OPENCLAW_PROVENANCE,
} from '../src/adapter.js';
const SILENT = createLogger({ name: 'test', write: () => { /* swallow log output */ } });
/** A recording mock CliBridge — never shells out, never touches ~/.openclaw. */
interface MockBridge extends CliBridge {
saved: HookFrame[];
recalls: number;
cleanups: number;
}
function makeMockBridge(opts: { hits?: MemoryHit[]; throwOn?: 'save' | 'recall' | 'cleanup' } = {}): MockBridge {
const saved: HookFrame[] = [];
const bridge = {
saved,
recalls: 0,
cleanups: 0,
async callMcpTool<T>(): Promise<T> {
return undefined as unknown as T;
},
async saveMemory(frame: HookFrame): Promise<SaveMemoryResult> {
if (opts.throwOn === 'save') throw new Error('boom: save_memory failed');
saved.push(frame);
return { id: String(saved.length), success: true, workspace: 'personal' };
},
async recallMemory(): Promise<MemoryHit[]> {
bridge.recalls += 1;
if (opts.throwOn === 'recall') throw new Error('boom: recall_memory failed');
return opts.hits ?? [];
},
async cleanupFrames(): Promise<{ pruned: number }> {
bridge.cleanups += 1;
if (opts.throwOn === 'cleanup') throw new Error('boom: cleanup_frames failed');
return { pruned: 0 };
},
setWorkspaceById(): void { /* noop */ },
getActiveWorkspaceId(): undefined { return undefined; },
} as unknown as MockBridge;
return bridge;
}
function ctxFor(bridge: CliBridge): HookContext {
return { bridge, logger: SILENT };
}
function provScope(sessionId: string): string {
return `${OPENCLAW_PROVENANCE}:${sessionId}`;
}
function hit(content: string): MemoryHit {
return { id: 1, content, importance: 'important', source: 'openclaw', score: 1, created_at: '2026-06-01', from: 'personal' };
}
describe('openclawAdapter (event map + field extraction over event.context)', () => {
it('maps the four lifecycles to OpenClaw type:action keys; pre-compact uses the session: prefix', () => {
expect(OPENCLAW_EVENT_NAME['session-start']).toBe('agent:bootstrap');
expect(OPENCLAW_EVENT_NAME['user-prompt-submit']).toBe('message:received');
expect(OPENCLAW_EVENT_NAME['stop']).toBe('message:sent');
// HOOK.md-form key (prefixed) — the runtime action drops the session: prefix.
expect(OPENCLAW_EVENT_NAME['pre-compact']).toBe('session:compact:before');
});
it('source is openclaw', () => {
expect(openclawAdapter.source).toBe('openclaw');
});
it('extracts cwd / sessionId (channelId-first) / prompt / response / parent from the context object', () => {
const received = { cwd: '/work', channelId: 'chan-7', content: 'the inbound prompt' };
expect(openclawAdapter.extractCwd(received)).toBe('/work');
expect(openclawAdapter.extractSessionId(received)).toBe('chan-7');
expect(openclawAdapter.extractPrompt(received)).toBe('the inbound prompt');
const sent = { content: 'the outbound reply', parentId: 'p-1' };
expect(openclawAdapter.extractResponse(sent, {})).toBe('the outbound reply');
expect(openclawAdapter.extractParent(sent)).toBe('p-1');
});
it('formatInject produces the { additionalContext } shape pushed into bootstrapFiles', () => {
expect(openclawAdapter.formatInject?.('recalled frames here')).toEqual({ additionalContext: 'recalled frames here' });
});
});
// ── makeOpenclawHandler drive path (the in-process handler) ─────────────
describe('makeOpenclawHandler — message:received saves a temporary frame', () => {
it('persists the inbound prompt as a temporary frame, provenance-scoped to openclaw-gateway:<channel>', async () => {
const bridge = makeMockBridge();
const handler = makeOpenclawHandler(openclawAdapter, { stopDebounceMs: 0 });
const extracted: UserPromptExtracted = {
prompt: 'hello from the gateway',
cwd: '/work',
sessionId: provScope('chan-7'),
};
const event: InternalHookEventLike = { type: 'message', action: 'received' };
await handler.handle({ event, extracted }, ctxFor(bridge));
expect(bridge.saved).toHaveLength(1);
const frame = bridge.saved[0];
expect(frame.importance).toBe('temporary');
expect(frame.content).toBe('hello from the gateway');
// Provenance stamp rides the frame scope (the only attribution channel save preserves).
expect(frame.scope).toBe(provScope('chan-7'));
expect(frame.scope).toContain('openclaw-gateway');
expect(frame.source).toBe('openclaw');
});
});
describe('makeOpenclawHandler — agent:bootstrap recalls + the adapter injects into bootstrapFiles', () => {
it('mutates the host-owned bootstrapFiles array with the recalled text (the inject seam)', async () => {
const bridge = makeMockBridge({ hits: [hit('prior decision: ship the thing')] });
// Replicate the package handler's SessionStart seam: recall, format via the
// adapter, push onto the MUTABLE bootstrapFiles array the gateway reads back.
const bootstrapFiles: unknown[] = [];
const recalled = await bridge.recallMemory('', { limit: 20, scope: 'personal' });
expect(recalled).toHaveLength(1);
const injected = openclawAdapter.formatInject?.(recalled.map((h) => h.content).join('\n')) as { additionalContext: string };
bootstrapFiles.push(injected.additionalContext);
expect(bootstrapFiles).toHaveLength(1);
expect(bootstrapFiles[0]).toContain('prior decision: ship the thing');
});
it('drives runSessionStartBody through the handler without throwing (recall path executes)', async () => {
const bridge = makeMockBridge({ hits: [hit('frame a')] });
const handler = makeOpenclawHandler(openclawAdapter, { stopDebounceMs: 0 });
const extracted: SessionStartExtracted = {
cwd: '/work',
sessionId: provScope('chan-7'),
recallLimit: 20,
};
const event: InternalHookEventLike = { type: 'agent', action: 'bootstrap' };
await expect(handler.handle({ event, extracted }, ctxFor(bridge))).resolves.toBeUndefined();
expect(bridge.recalls).toBe(1);
});
});
describe('makeOpenclawHandler — message:sent (Stop) DEBOUNCE collapses 0..N/turn to one save', () => {
it('fires 3 message:sent in a turn → exactly ONE save of the LAST payload', async () => {
vi.useFakeTimers();
try {
const bridge = makeMockBridge();
const handler = makeOpenclawHandler(openclawAdapter, { stopDebounceMs: 750 });
const sessionKey = 'turn-key';
const mk = (response: string): OpenclawHandlerInput => {
const extracted: StopExtracted = {
cwd: '/work',
sessionId: provScope('chan-7'),
response,
parent: undefined,
};
const event: InternalHookEventLike = { type: 'message', action: 'sent', sessionKey };
return { event, extracted };
};
// Fire three message:sent rapidly (same turn). The first two are superseded.
const p1 = handler.handle(mk('partial reply 1'), ctxFor(bridge));
const p2 = handler.handle(mk('partial reply 2'), ctxFor(bridge));
const p3 = handler.handle(mk('FINAL DECISION: we will ship the feature on Friday'), ctxFor(bridge));
// Superseded dispatches resolve immediately (fail-open: a hook must never block the host).
await Promise.all([p1, p2]);
expect(bridge.saved).toHaveLength(0); // nothing saved yet — still debouncing
// Advance past the debounce window; the last save fires.
await vi.advanceTimersByTimeAsync(800);
await p3;
// Exactly one frame saved, carrying the LAST payload.
expect(bridge.saved).toHaveLength(1);
const frame = bridge.saved[0];
expect(frame.content).toContain('FINAL DECISION');
expect(frame.content).not.toContain('partial reply 1');
// Stop frames are important/critical (never temporary), provenance-scoped.
expect(['important', 'critical']).toContain(frame.importance);
expect(frame.scope).toBe(provScope('chan-7'));
expect(frame.source).toBe('openclaw');
} finally {
vi.useRealTimers();
}
});
});
describe('makeOpenclawHandler — PreCompact matches on the action SUFFIX, not the joined string', () => {
it('fires cleanup_frames when the runtime action is "compact:before" (session: prefix dropped at runtime)', async () => {
const bridge = makeMockBridge();
const handler = makeOpenclawHandler(openclawAdapter, { stopDebounceMs: 0 });
// Runtime event: type 'session', action 'compact:before' — joined would be
// 'session:compact:before' (matches), but the suffix-match is what the
// design relies on (CORRECTION 2). Use a non-joined type to prove suffix.
const event: InternalHookEventLike = { type: 'lifecycle', action: 'compact:before' };
await handler.handle({ event, extracted: { scope: provScope('chan-7') } }, ctxFor(bridge));
expect(bridge.cleanups).toBe(1);
});
it('ignores an unmapped (type, action) pair', async () => {
const bridge = makeMockBridge();
const handler = makeOpenclawHandler(openclawAdapter, { stopDebounceMs: 0 });
const event: InternalHookEventLike = { type: 'tool', action: 'invoked' };
await handler.handle({ event, extracted: { scope: undefined } }, ctxFor(bridge));
expect(bridge.saved).toHaveLength(0);
expect(bridge.cleanups).toBe(0);
expect(bridge.recalls).toBe(0);
});
});
describe('§7.3 invariant 1 — FAIL-OPEN (handler swallows a bridge error, never throws)', () => {
it('a save error during message:received resolves (never rejects)', async () => {
const bridge = makeMockBridge({ throwOn: 'save' });
const handler = makeOpenclawHandler(openclawAdapter, { stopDebounceMs: 0 });
const extracted: UserPromptExtracted = { prompt: 'p', cwd: '/w', sessionId: provScope('c') };
const event: InternalHookEventLike = { type: 'message', action: 'received' };
await expect(handler.handle({ event, extracted }, ctxFor(bridge))).resolves.toBeUndefined();
expect(bridge.saved).toHaveLength(0);
});
it('a recall error during agent:bootstrap resolves (never rejects)', async () => {
const bridge = makeMockBridge({ throwOn: 'recall' });
const handler = makeOpenclawHandler(openclawAdapter, { stopDebounceMs: 0 });
const extracted: SessionStartExtracted = { cwd: '/w', sessionId: provScope('c'), recallLimit: 20 };
const event: InternalHookEventLike = { type: 'agent', action: 'bootstrap' };
await expect(handler.handle({ event, extracted }, ctxFor(bridge))).resolves.toBeUndefined();
});
it('a cleanup error during compact:before resolves (never rejects)', async () => {
const bridge = makeMockBridge({ throwOn: 'cleanup' });
const handler = makeOpenclawHandler(openclawAdapter, { stopDebounceMs: 0 });
const event: InternalHookEventLike = { type: 'lifecycle', action: 'compact:before' };
await expect(handler.handle({ event, extracted: { scope: provScope('c') } }, ctxFor(bridge)))
.resolves.toBeUndefined();
});
});
// ── the package's own default-export handler (in-process entrypoint) ─────
describe('openclawHook default export — fail-open over the live bridge path', () => {
it('never throws on a garbage event (no matching lifecycle, no env CLI configured)', async () => {
const { default: openclawHook } = await import('../src/handler.js');
// Unmapped event — must resolve to undefined without touching any CLI.
await expect(openclawHook({ type: 'noop', action: 'noop' })).resolves.toBeUndefined();
});
it('never throws even when the recall path would fail (no real hive-mind-cli on PATH)', async () => {
const { default: openclawHook } = await import('../src/handler.js');
// agent:bootstrap drives recall through a real (unconfigured) bridge; the
// handler's try/catch must swallow any failure and resolve.
const event = { type: 'agent', action: 'bootstrap', context: { channelId: 'c', bootstrapFiles: [] } };
await expect(openclawHook(event)).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,246 @@
import { describe, expect, it, afterEach } from 'vitest';
import { mkdtemp, mkdir, readFile, writeFile, rm, stat } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import JSON5 from 'json5';
import { install } from '../src/install.js';
import { HIVE_ENTRY_KEY, HOOKS_KEY } from '../src/json5-merger.js';
interface TestEnv {
home: string;
/** A fake compiled handler.js the installer COPIES into the managed hook dir. */
handlerSource: string;
configPath: string;
pointerPath: string;
hiveHookDir: string;
}
/** openclaw.json is OPTIONAL — `initial=undefined` exercises create-if-missing. */
async function bootstrap(initial: string | undefined): Promise<TestEnv> {
const home = await mkdtemp(join(tmpdir(), 'hmocl-install-'));
const openclawDir = join(home, '.openclaw');
await mkdir(openclawDir, { recursive: true });
const configPath = join(openclawDir, 'openclaw.json');
if (initial !== undefined) {
await writeFile(configPath, initial, 'utf-8');
}
// Fake compiled handler — install copies this verbatim into the hook dir.
const distDir = join(home, 'fake-dist');
await mkdir(distDir, { recursive: true });
const handlerSource = join(distDir, 'handler.js');
await writeFile(handlerSource, 'export default async () => {};\n', 'utf-8');
return {
home,
handlerSource,
configPath,
pointerPath: join(openclawDir, 'hive-mind-install.json'),
hiveHookDir: join(openclawDir, 'hooks', 'hive-mind'),
};
}
function readPointer(p: string): Promise<Record<string, unknown>> {
return readFile(p, 'utf-8').then((s) => JSON.parse(s) as Record<string, unknown>);
}
function internalEntries(config: Record<string, unknown>): Record<string, unknown> {
const hooks = config[HOOKS_KEY] as Record<string, Record<string, unknown>> | undefined;
const internal = hooks?.['internal'] as Record<string, unknown> | undefined;
return (internal?.['entries'] as Record<string, unknown> | undefined) ?? {};
}
describe('install (openclaw)', () => {
let env: TestEnv;
afterEach(async () => {
if (env) await rm(env.home, { recursive: true, force: true });
});
it('throws when the compiled handler.js source is missing (build first)', async () => {
env = await bootstrap(undefined);
await rm(env.handlerSource, { force: true });
await expect(install({ home: env.home, handlerSourcePath: env.handlerSource }))
.rejects.toThrow(/compiled handler not found/i);
});
it('throws on a config.json that parses as neither JSON nor JSON5', async () => {
env = await bootstrap('{ : : : not valid : : : }');
await expect(install({ home: env.home, handlerSourcePath: env.handlerSource }))
.rejects.toThrow(/parse/i);
});
// ── pre-existed branch ────────────────────────────────────────────────
it('writes a LITERAL byte-identical backup before mutating a pre-existing openclaw.json', async () => {
// Comments + trailing commas that a JSON5 round-trip would NOT preserve —
// proves the backup is the original bytes, not a re-serialized merge.
const initial = [
'{',
' // my openclaw config',
' model: "opus", // keep me',
' hooks: { internal: { enabled: false, entries: {} } },',
'}',
'',
].join('\n');
env = await bootstrap(initial);
const original = await readFile(env.configPath, 'utf-8');
const result = await install({ home: env.home, handlerSourcePath: env.handlerSource });
expect(result.backupPath).not.toBeNull();
const backupContent = await readFile(result.backupPath as string, 'utf-8');
expect(backupContent).toBe(original);
// The backup preserves the comment that JSON re-serialization drops.
expect(backupContent).toContain('// my openclaw config');
});
it('records created_by_us=false + settings_backup when openclaw.json pre-existed', async () => {
env = await bootstrap('{ "model": "opus", "hooks": {} }');
const result = await install({ home: env.home, handlerSourcePath: env.handlerSource });
expect(result.createdByUs).toBe(false);
const pointer = await readPointer(result.pointerPath);
expect(pointer['created_by_us']).toBe(false);
expect(pointer['settings_backup']).toBe(result.backupPath);
});
it('minimal-touch: flips internal.enabled + adds hive entry, preserving user keys + entries', async () => {
const initial = [
'{',
' model: "opus",',
' hooks: { internal: { enabled: false, entries: { "user-own": { enabled: true } } } },',
'}',
].join('\n');
env = await bootstrap(initial);
await install({ home: env.home, handlerSourcePath: env.handlerSource });
const after = JSON5.parse(await readFile(env.configPath, 'utf-8')) as Record<string, unknown>;
const ents = internalEntries(after);
// Our entry added, the user entry preserved verbatim.
expect((ents[HIVE_ENTRY_KEY] as Record<string, unknown>)['enabled']).toBe(true);
expect(ents['user-own']).toEqual({ enabled: true });
// Subsystem turned on, user top-level key preserved.
const hooks = after[HOOKS_KEY] as Record<string, Record<string, unknown>>;
expect(hooks['internal']['enabled']).toBe(true);
expect(after['model']).toBe('opus');
});
// ── create-if-missing branch ──────────────────────────────────────────
it('creates openclaw.json with the hive entry when it is absent', async () => {
env = await bootstrap(undefined);
expect(existsSync(env.configPath)).toBe(false);
const result = await install({ home: env.home, handlerSourcePath: env.handlerSource });
expect(existsSync(env.configPath)).toBe(true);
const after = JSON5.parse(await readFile(env.configPath, 'utf-8')) as Record<string, unknown>;
expect((internalEntries(after)[HIVE_ENTRY_KEY] as Record<string, unknown>)['enabled']).toBe(true);
expect(result.createdByUs).toBe(true);
});
it('records created_by_us=true and writes NO backup when openclaw.json is absent', async () => {
env = await bootstrap(undefined);
const result = await install({ home: env.home, handlerSourcePath: env.handlerSource });
expect(result.backupPath).toBeNull();
const pointer = await readPointer(result.pointerPath);
expect(pointer['created_by_us']).toBe(true);
expect(pointer['settings_backup']).toBeNull();
});
// ── managed hook DIR (in-process model — no per-event scripts) ─────────
it('writes the managed hook DIR with HOOK.md + a byte-identical copy of handler.js', async () => {
env = await bootstrap(undefined);
const handlerBytes = await readFile(env.handlerSource, 'utf-8');
const result = await install({ home: env.home, handlerSourcePath: env.handlerSource });
expect(result.hookDir).toBe(env.hiveHookDir);
expect(existsSync(join(env.hiveHookDir, 'HOOK.md'))).toBe(true);
expect(existsSync(join(env.hiveHookDir, 'handler.js'))).toBe(true);
// handler.js is copied verbatim from dist.
expect(await readFile(join(env.hiveHookDir, 'handler.js'), 'utf-8')).toBe(handlerBytes);
// HOOK.md declares the four events incl. the prefixed compaction key.
const hookMd = await readFile(join(env.hiveHookDir, 'HOOK.md'), 'utf-8');
expect(hookMd).toContain('agent:bootstrap');
expect(hookMd).toContain('message:received');
expect(hookMd).toContain('message:sent');
expect(hookMd).toContain('session:compact:before');
});
// Spawns two child Node processes (esbuild bundle of the handler + import of
// the produced bundle). Standalone this takes <1s, but full-suite runs
// saturate the CPU (forks pool, 4 workers) and the spawns can exceed vitest's
// 30s default testTimeout (observed 2026-07-15 full-suite flake,
// standalone-green). 60s per-test timeout, same class as f322cc2c.
it('copies a self-contained handler that imports and runs with NODE_PATH empty', async () => {
env = await bootstrap(undefined);
const buildScript = fileURLToPath(new URL('../scripts/build-handler.mjs', import.meta.url));
const handlerEntry = fileURLToPath(new URL('../src/handler.ts', import.meta.url));
execFileSync(process.execPath, [buildScript, handlerEntry, env.handlerSource], {
cwd: env.home,
stdio: 'pipe',
});
await install({ home: env.home, handlerSourcePath: env.handlerSource });
const installedHandler = join(env.hiveHookDir, 'handler.js');
const installedUrl = pathToFileURL(installedHandler).href;
const runInstalledHandler = [
`const module = await import(${JSON.stringify(installedUrl)});`,
`if (typeof module.default !== 'function') throw new Error('default export is not a function');`,
`await module.default({ type: 'noop', action: 'noop' });`,
].join('\n');
// Both the handler path and cwd are outside the repository/package tree.
// Any remaining @waggle import would fail with this empty resolution path.
execFileSync(process.execPath, ['--input-type=module', '--eval', runInstalledHandler], {
cwd: env.home,
env: { ...process.env, NODE_PATH: '' },
stdio: 'pipe',
});
}, 60_000);
// ── pointer + lifecycles + cli-path ───────────────────────────────────
it('drops a pointer recording the four lifecycles + touched keys + hook dir name', async () => {
env = await bootstrap(undefined);
const result = await install({ home: env.home, handlerSourcePath: env.handlerSource });
expect(existsSync(result.pointerPath)).toBe(true);
const pointer = await readPointer(result.pointerPath);
expect(pointer['installed_hooks']).toEqual(['session-start', 'user-prompt-submit', 'stop', 'pre-compact']);
expect(pointer['hooks_dir']).toBe(env.hiveHookDir);
expect(typeof pointer['version']).toBe('string');
const extra = pointer['extra'] as Record<string, unknown>;
expect(extra['hook_dir_name']).toBe('hive-mind');
expect((extra['touched_keys'] as string[]).sort()).toEqual([
'hooks.internal.enabled',
'hooks.internal.entries.hive-mind',
]);
expect([...result.touchedKeys].sort()).toEqual([
'hooks.internal.enabled',
'hooks.internal.entries.hive-mind',
]);
});
it('respects a custom now() for a deterministic backup filename', async () => {
env = await bootstrap('{ "hooks": {} }');
const fixedTs = '2026-04-28T10:30:45.123Z';
const result = await install({
home: env.home,
handlerSourcePath: env.handlerSource,
now: () => new Date(fixedTs),
});
expect(result.backupPath).toContain('hive-mind-backup.2026-04-28T10-30-45-123Z');
const stats = await stat(result.backupPath as string);
expect(stats.isFile()).toBe(true);
});
it('threads --cli-path into the entry env + records it in the pointer', async () => {
env = await bootstrap(undefined);
const cliPath = '/abs/path/to/dist/index.js';
const result = await install({ home: env.home, handlerSourcePath: env.handlerSource, cliPath });
expect(result.cliPath).toBe(cliPath);
const after = JSON5.parse(await readFile(env.configPath, 'utf-8')) as Record<string, unknown>;
const hive = internalEntries(after)[HIVE_ENTRY_KEY] as Record<string, unknown>;
expect((hive['env'] as Record<string, unknown>)['WAGGLE_HIVE_MIND_CLI']).toBe(cliPath);
const pointer = await readPointer(result.pointerPath);
expect(pointer['cli_path']).toBe(cliPath);
});
});

View File

@@ -0,0 +1,174 @@
import { describe, expect, it } from 'vitest';
import JSON5 from 'json5';
import {
HIVE_ENTRY_KEY,
HOOKS_KEY,
hasHiveEntries,
jsonRegister,
jsonUnregister,
parseConfig,
serializeConfig,
} from '../src/json5-merger.js';
function internal(config: Record<string, unknown>): Record<string, unknown> {
const hooks = config[HOOKS_KEY] as Record<string, unknown> | undefined;
return (hooks?.['internal'] as Record<string, unknown> | undefined) ?? {};
}
function entries(config: Record<string, unknown>): Record<string, unknown> {
return (internal(config)['entries'] as Record<string, unknown> | undefined) ?? {};
}
describe('parseConfig (openclaw JSON5 codec — strict JSON first, JSON5 fallback)', () => {
it('returns {} for an empty / whitespace-only config (create-if-missing)', () => {
expect(parseConfig('')).toEqual({});
expect(parseConfig(' \n ')).toEqual({});
});
it('parses strict JSON (the fast path)', () => {
const parsed = parseConfig('{"model":"opus","hooks":{}}');
expect(parsed['model']).toBe('opus');
});
it('parses a COMMENTED openclaw.json with trailing commas via the JSON5 fallback', () => {
const raw = [
'{',
' // OpenClaw gateway config — hand-edited, comments matter',
' model: "claude-opus", /* the good one */',
' hooks: {',
' internal: {',
' enabled: false,',
' entries: {',
" 'user-own': { enabled: true }, // trailing comma below",
' },',
' },',
' },',
'}',
].join('\n');
const parsed = parseConfig(raw);
expect(parsed['model']).toBe('claude-opus');
const userEntry = entries(parsed)['user-own'] as Record<string, unknown>;
expect(userEntry['enabled']).toBe(true);
});
it('throws when BOTH strict JSON and JSON5 fail (installer fails loudly, never clobbers)', () => {
expect(() => parseConfig('{ this : : : is not valid }')).toThrow(/parse/i);
});
it('treats a top-level scalar/array JSON5 doc as empty (not a crash)', () => {
expect(parseConfig('"just a string"')).toEqual({});
expect(parseConfig('[1, 2, 3]')).toEqual({});
});
});
describe('serializeConfig (openclaw — strict JSON, valid for OpenClaw JSON5 reader)', () => {
it('serializes to 2-space JSON with a trailing newline that re-parses', () => {
const text = serializeConfig(jsonRegister({ model: 'x' }));
expect(text.endsWith('\n')).toBe(true);
const reparsed = JSON5.parse(text) as Record<string, unknown>;
expect(reparsed['model']).toBe('x');
expect(hasHiveEntries(reparsed)).toBe(true);
});
});
describe('jsonRegister (openclaw minimal-touch — flip enabled + add hive entry)', () => {
it('returns a NEW object — does not mutate input (immutability contract)', () => {
const original: Record<string, unknown> = { hooks: { internal: { enabled: false } } };
const merged = jsonRegister(original);
expect(merged).not.toBe(original);
// Input untouched — enabled is still false on the original.
expect((original['hooks'] as Record<string, Record<string, unknown>>)['internal']['enabled']).toBe(false);
});
it('flips hooks.internal.enabled=true and adds entries["hive-mind"]={enabled:true}', () => {
const merged = jsonRegister({});
expect(internal(merged)['enabled']).toBe(true);
const hive = entries(merged)[HIVE_ENTRY_KEY] as Record<string, unknown>;
expect(hive).toEqual({ enabled: true });
});
it('attaches env to the hive entry when supplied', () => {
const merged = jsonRegister({}, { env: { WAGGLE_HIVE_MIND_CLI: '/abs/cli.js', WAGGLE_WORKSPACE_ID: 'ws1' } });
const hive = entries(merged)[HIVE_ENTRY_KEY] as Record<string, unknown>;
expect(hive['env']).toEqual({ WAGGLE_HIVE_MIND_CLI: '/abs/cli.js', WAGGLE_WORKSPACE_ID: 'ws1' });
});
it('does NOT clobber other internal entries or other internal keys (minimal-touch)', () => {
const existing: Record<string, unknown> = {
model: 'opus',
hooks: {
internal: {
enabled: false,
throttleMs: 250,
entries: { 'user-own': { enabled: true, foo: 'bar' } },
},
external: { whatever: 1 },
},
};
const merged = jsonRegister(existing);
// user entry preserved verbatim.
expect(entries(merged)['user-own']).toEqual({ enabled: true, foo: 'bar' });
// sibling internal key preserved.
expect(internal(merged)['throttleMs']).toBe(250);
// sibling hooks subtree preserved.
expect((merged['hooks'] as Record<string, unknown>)['external']).toEqual({ whatever: 1 });
// unrelated top-level key preserved.
expect(merged['model']).toBe('opus');
// and our entry was added + subsystem turned on.
expect(internal(merged)['enabled']).toBe(true);
expect(hasHiveEntries(merged)).toBe(true);
});
it('replaces OUR entry in place on re-install (idempotent — never duplicated)', () => {
const merged1 = jsonRegister({}, { env: { A: '1' } });
const merged2 = jsonRegister(merged1, { env: { A: '2' } });
const hive = entries(merged2)[HIVE_ENTRY_KEY] as Record<string, unknown>;
expect(hive['env']).toEqual({ A: '2' });
// Exactly one hive-mind entry key.
expect(Object.keys(entries(merged2)).filter((k) => k === HIVE_ENTRY_KEY)).toHaveLength(1);
});
});
describe('jsonUnregister (openclaw — diagnostics / backup-less path)', () => {
it('strips our hive-mind entry but preserves other entries + leaves enabled as-is', () => {
const withUser: Record<string, unknown> = {
hooks: { internal: { enabled: true, entries: { 'user-own': { enabled: true } } } },
};
const merged = jsonRegister(withUser);
expect(hasHiveEntries(merged)).toBe(true);
const stripped = jsonUnregister(merged);
expect(hasHiveEntries(stripped)).toBe(false);
// User entry survives.
expect(entries(stripped)['user-own']).toEqual({ enabled: true });
// Minimal-touch: we do NOT flip enabled back off (other hooks may rely on it).
expect(internal(stripped)['enabled']).toBe(true);
});
it('returns a NEW object and leaves the input untouched (immutability)', () => {
const merged = jsonRegister({});
const stripped = jsonUnregister(merged);
expect(stripped).not.toBe(merged);
expect(hasHiveEntries(merged)).toBe(true); // original still has the entry
});
it('is a no-op (new object) when there is no hooks/internal block', () => {
const stripped = jsonUnregister({ model: 'x' });
expect(stripped['model']).toBe('x');
expect(hasHiveEntries(stripped)).toBe(false);
});
});
describe('hasHiveEntries (openclaw structural marker)', () => {
it('false on empty / hookless / hive-less configs', () => {
expect(hasHiveEntries(undefined)).toBe(false);
expect(hasHiveEntries({})).toBe(false);
expect(hasHiveEntries({ hooks: {} })).toBe(false);
expect(hasHiveEntries({ hooks: { internal: { enabled: true, entries: {} } } })).toBe(false);
expect(hasHiveEntries({ hooks: { internal: { entries: { 'user-own': {} } } } })).toBe(false);
});
it('true once our hive-mind entry is present', () => {
expect(hasHiveEntries(jsonRegister({}))).toBe(true);
});
});

View File

@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest';
import { join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import {
allHookBasenames,
backupPathFor,
hookCommandFor,
resolvePaths,
HIVE_HOOK_DIR_NAME,
HIVE_HOOK_ENTRY_KEY,
} from '../src/paths.js';
describe('resolvePaths (openclaw)', () => {
it('places openclaw.json + pointer under <home>/.openclaw/', () => {
const home = resolve('/fake/home');
const paths = resolvePaths({ home, handlerSourcePath: resolve('/some/dist/handler.js') });
expect(paths.openclawDir).toBe(join(home, '.openclaw'));
expect(paths.configPath).toBe(join(home, '.openclaw', 'openclaw.json'));
expect(paths.pointerPath).toBe(join(home, '.openclaw', 'hive-mind-install.json'));
});
it('resolves the managed hook DIRECTORY (not per-event scripts) under ~/.openclaw/hooks/', () => {
const home = resolve('/h');
const paths = resolvePaths({ home, handlerSourcePath: resolve('/d/handler.js') });
// OpenClaw is in-process: one managed dir holding HOOK.md + handler.js,
// NOT four separate compiled hook scripts.
expect(paths.hooksRoot).toBe(join(home, '.openclaw', 'hooks'));
expect(paths.hiveHookDir).toBe(join(home, '.openclaw', 'hooks', HIVE_HOOK_DIR_NAME));
expect(paths.hookMdPath).toBe(join(paths.hiveHookDir, 'HOOK.md'));
expect(paths.installedHandlerPath).toBe(join(paths.hiveHookDir, 'handler.js'));
});
it('handlerSourcePath override wins over moduleUrl', () => {
const explicit = resolve('/x/y/handler.js');
const paths = resolvePaths({
home: resolve('/h'),
handlerSourcePath: explicit,
moduleUrl: 'file:///irrelevant/dist/install.js',
});
expect(paths.handlerSourcePath).toBe(explicit);
});
it('derives the self-contained handler bundle sibling from moduleUrl', () => {
const moduleUrl = pathToFileURL(resolve('/pkg/dist/install.js')).href;
const paths = resolvePaths({ home: resolve('/h'), moduleUrl });
expect(paths.handlerSourcePath).toBe(resolve('/pkg/dist/handler.bundle.cjs'));
});
it('falls back to cwd/dist/handler.bundle.cjs when neither override is given', () => {
const paths = resolvePaths({ home: resolve('/h') });
expect(paths.handlerSourcePath).toBe(resolve(process.cwd(), 'dist', 'handler.bundle.cjs'));
});
});
describe('allHookBasenames (openclaw — FOUR lifecycles incl. pre-compact)', () => {
it('names the four lifecycles the single handler dispatches (bookkeeping, not separate files)', () => {
expect([...allHookBasenames()].sort()).toEqual([
'pre-compact',
'session-start',
'stop',
'user-prompt-submit',
]);
// Unlike hermes, openclaw DOES carry a compaction lifecycle
// (session:compact:before → runtime action compact:before).
expect([...allHookBasenames()]).toContain('pre-compact');
});
});
describe('exported entry/dir name constants', () => {
it('the managed dir name and the logical entry key are both "hive-mind"', () => {
expect(HIVE_HOOK_DIR_NAME).toBe('hive-mind');
expect(HIVE_HOOK_ENTRY_KEY).toBe('hive-mind');
});
});
describe('re-exported shared Windows-safe helpers', () => {
it('backupPathFor replaces colons and dots in the timestamp for filesystem safety', () => {
const backup = backupPathFor('/h/.openclaw/openclaw.json', '2026-04-28T10:30:45.123Z');
expect(backup).toBe('/h/.openclaw/openclaw.json.hive-mind-backup.2026-04-28T10-30-45-123Z');
});
it('hookCommandFor produces a quoted node invocation and appends --cli-path', () => {
const cmd = hookCommandFor(resolve('/abs/dist/handler.js'), '/abs/cli/dist/index.js');
expect(cmd).toMatch(/^node "[^"]+handler\.js"/);
expect(cmd).toMatch(/--cli-path "\/abs\/cli\/dist\/index\.js"$/);
});
});

View File

@@ -0,0 +1,145 @@
import { describe, expect, it, afterEach } from 'vitest';
import { createHash } from 'node:crypto';
import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { install } from '../src/install.js';
import { uninstall } from '../src/uninstall.js';
interface TestEnv {
home: string;
handlerSource: string;
configPath: string;
pointerPath: string;
hiveHookDir: string;
}
async function bootstrap(initial: string | undefined): Promise<TestEnv> {
const home = await mkdtemp(join(tmpdir(), 'hmocl-uninstall-'));
const openclawDir = join(home, '.openclaw');
await mkdir(openclawDir, { recursive: true });
const configPath = join(openclawDir, 'openclaw.json');
if (initial !== undefined) {
await writeFile(configPath, initial, 'utf-8');
}
const distDir = join(home, 'fake-dist');
await mkdir(distDir, { recursive: true });
const handlerSource = join(distDir, 'handler.js');
await writeFile(handlerSource, 'export default async () => {};\n', 'utf-8');
return {
home,
handlerSource,
configPath,
pointerPath: join(openclawDir, 'hive-mind-install.json'),
hiveHookDir: join(openclawDir, 'hooks', 'hive-mind'),
};
}
function sha256(s: string): string {
return createHash('sha256').update(s, 'utf-8').digest('hex');
}
describe('uninstall (openclaw)', () => {
let env: TestEnv;
afterEach(async () => {
if (env) await rm(env.home, { recursive: true, force: true });
});
it('throws when no pointer file exists', async () => {
env = await bootstrap('{ "hooks": {} }');
await expect(uninstall({ home: env.home, handlerSourcePath: env.handlerSource }))
.rejects.toThrow(/pointer/);
});
// ── created_by_us=false: LITERAL byte-identical restore (§7.3 invariant 2) ─
// JSON5 round-trip is lossy (comments + trailing commas are dropped on
// re-serialize), so reversibility relies on restoring the ORIGINAL BYTES.
it('install + uninstall round-trip is SHA-256 identical to pre-install state (comments preserved)', async () => {
// Comments + trailing commas a naive JSON re-serialize would NOT reproduce.
const initial = [
'{',
' // OpenClaw config — hand-edited, comments matter',
' model: "claude-opus", /* the good one */',
' temperature: 0.2,',
' hooks: {',
' internal: {',
' enabled: false,',
' entries: { "user-own": { enabled: true } },',
' },',
' },',
'}',
'',
].join('\n');
env = await bootstrap(initial);
const preInstall = await readFile(env.configPath, 'utf-8');
const preHash = sha256(preInstall);
await install({ home: env.home, handlerSourcePath: env.handlerSource });
const afterInstall = await readFile(env.configPath, 'utf-8');
expect(sha256(afterInstall)).not.toBe(preHash); // install actually mutated
// Sanity: the merged write IS lossy — the comment is gone post-install,
// which is exactly why we need the literal backup to reverse it.
expect(afterInstall).not.toContain('// OpenClaw config');
const u = await uninstall({ home: env.home, handlerSourcePath: env.handlerSource });
expect(u.createdRemoved).toBe(false);
expect(u.restoredFrom).not.toBeNull();
expect(u.hookDirRemoved).toBe(true);
const afterUninstall = await readFile(env.configPath, 'utf-8');
// Byte-for-byte identical — the comment + trailing commas are back.
expect(sha256(afterUninstall)).toBe(preHash);
expect(afterUninstall).toBe(preInstall);
expect(afterUninstall).toContain('// OpenClaw config — hand-edited, comments matter');
});
it('removes the managed hook dir (HOOK.md + handler.js) on uninstall — no orphan', async () => {
env = await bootstrap('{ "model": "opus", "hooks": {} }');
await install({ home: env.home, handlerSourcePath: env.handlerSource });
expect(existsSync(join(env.hiveHookDir, 'HOOK.md'))).toBe(true);
expect(existsSync(join(env.hiveHookDir, 'handler.js'))).toBe(true);
const u = await uninstall({ home: env.home, handlerSourcePath: env.handlerSource });
expect(u.hookDirRemoved).toBe(true);
expect(existsSync(env.hiveHookDir)).toBe(false);
});
it('removes backup + pointer by default after a restore', async () => {
env = await bootstrap('{ "model": "opus", "hooks": {} }');
const result = await install({ home: env.home, handlerSourcePath: env.handlerSource });
expect(existsSync(result.backupPath as string)).toBe(true);
const u = await uninstall({ home: env.home, handlerSourcePath: env.handlerSource });
expect(u.backupRemoved).toBe(true);
expect(existsSync(result.backupPath as string)).toBe(false);
expect(existsSync(result.pointerPath)).toBe(false);
});
it('keeps the backup when cleanupBackup=false', async () => {
env = await bootstrap('{ "model": "opus", "hooks": {} }');
const result = await install({ home: env.home, handlerSourcePath: env.handlerSource });
const u = await uninstall({ home: env.home, handlerSourcePath: env.handlerSource, cleanupBackup: false });
expect(u.backupRemoved).toBe(false);
expect(existsSync(result.backupPath as string)).toBe(true);
});
// ── created_by_us=true: delete-if-created, no orphan (§7.3 invariant 2) ─
it('deletes the openclaw.json we created and leaves NO orphan (absent → install → uninstall)', async () => {
env = await bootstrap(undefined);
expect(existsSync(env.configPath)).toBe(false);
const result = await install({ home: env.home, handlerSourcePath: env.handlerSource });
expect(result.createdByUs).toBe(true);
expect(existsSync(env.configPath)).toBe(true);
const u = await uninstall({ home: env.home, handlerSourcePath: env.handlerSource });
expect(u.createdRemoved).toBe(true);
expect(u.restoredFrom).toBeNull();
// No orphaned config, no managed dir, no leftover pointer.
expect(existsSync(env.configPath)).toBe(false);
expect(existsSync(env.hiveHookDir)).toBe(false);
expect(existsSync(env.pointerPath)).toBe(false);
});
});

View File

@@ -0,0 +1,170 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { EventEmitter } from 'node:events';
import { Readable } from 'node:stream';
import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { ChildProcess } from 'node:child_process';
import { install } from '../src/install.js';
import { verify } from '../src/verify.js';
interface TestEnv {
home: string;
handlerSource: string;
configPath: string;
}
async function bootstrap(initial: string | undefined): Promise<TestEnv> {
const home = await mkdtemp(join(tmpdir(), 'hmocl-verify-'));
const openclawDir = join(home, '.openclaw');
await mkdir(openclawDir, { recursive: true });
const configPath = join(openclawDir, 'openclaw.json');
if (initial !== undefined) {
await writeFile(configPath, initial, 'utf-8');
}
const distDir = join(home, 'fake-dist');
await mkdir(distDir, { recursive: true });
const handlerSource = join(distDir, 'handler.js');
await writeFile(handlerSource, 'export default async () => {};\n', 'utf-8');
return { home, handlerSource, configPath };
}
function mockSpawnImpl(opts: { exitCode: number; stdout?: string; stderr?: string }): typeof import('node:child_process').spawn {
return ((_cmd: string, _args: readonly string[], _options?: unknown) => {
const emitter = new EventEmitter();
const stdout = Readable.from([Buffer.from(opts.stdout ?? 'hive-mind-cli help text\n')]);
const stderr = Readable.from([Buffer.from(opts.stderr ?? '')]);
const child = Object.assign(emitter, {
stdout,
stderr,
kill: vi.fn(() => true),
}) as unknown as ChildProcess;
setImmediate(() => emitter.emit('exit', opts.exitCode));
return child;
}) as unknown as typeof import('node:child_process').spawn;
}
describe('verify (openclaw)', () => {
const envs: TestEnv[] = [];
afterEach(async () => {
for (const env of envs.splice(0)) await rm(env.home, { recursive: true, force: true });
});
it('reports failure when openclaw.json is missing', async () => {
const env = await bootstrap(undefined);
envs.push(env);
const result = await verify({
home: env.home,
handlerSourcePath: env.handlerSource,
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
});
expect(result.ok).toBe(false);
expect(result.checks[0].name).toBe('openclaw.json exists');
expect(result.checks[0].ok).toBe(false);
});
it('reports failure when the hive entry is not yet installed', async () => {
const env = await bootstrap('{ "model": "opus", "hooks": {} }');
envs.push(env);
const result = await verify({
home: env.home,
handlerSourcePath: env.handlerSource,
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
});
expect(result.ok).toBe(false);
expect(result.checks.some((c) => !c.ok && c.name.includes('hive-mind internal-hooks entry'))).toBe(true);
});
it('passes after install — entry present, subsystem enabled, dir+HOOK.md+handler on disk, CLI reachable', async () => {
const env = await bootstrap('{ "model": "opus", "hooks": {} }');
envs.push(env);
await install({ home: env.home, handlerSourcePath: env.handlerSource });
const result = await verify({
home: env.home,
handlerSourcePath: env.handlerSource,
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
});
expect(result.ok).toBe(true);
expect(result.checks.find((c) => c.name.includes('hive-mind internal-hooks entry'))?.ok).toBe(true);
expect(result.checks.find((c) => c.name === 'internal hooks subsystem enabled')?.ok).toBe(true);
expect(result.checks.find((c) => c.name === 'managed hook dir exists')?.ok).toBe(true);
expect(result.checks.find((c) => c.name === 'HOOK.md readable on disk')?.ok).toBe(true);
expect(result.checks.find((c) => c.name === 'handler.js readable on disk')?.ok).toBe(true);
expect(result.checks.find((c) => c.name === 'hive-mind-cli reachable')?.ok).toBe(true);
});
it('flags the activation advisory (FAIL) when internal.enabled is false even with the entry present', async () => {
// Entry present but subsystem OFF — hooks are inert until opted in (§5.5/§6.2).
const config = '{ "hooks": { "internal": { "enabled": false, "entries": { "hive-mind": { "enabled": true } } } } }';
const env = await bootstrap(config);
envs.push(env);
// Write the managed dir so only the activation check is at fault.
await install({ home: env.home, handlerSourcePath: env.handlerSource });
// Re-disable the subsystem post-install to isolate the advisory.
await writeFile(env.configPath, config, 'utf-8');
const result = await verify({
home: env.home,
handlerSourcePath: env.handlerSource,
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
});
const enabledCheck = result.checks.find((c) => c.name === 'internal hooks subsystem enabled');
expect(enabledCheck?.ok).toBe(false);
expect(enabledCheck?.detail?.toLowerCase()).toContain('hooks are off');
expect(result.ok).toBe(false);
});
it('reports CLI unreachable when the spawn exits non-zero', async () => {
const env = await bootstrap('{ "hooks": {} }');
envs.push(env);
await install({ home: env.home, handlerSourcePath: env.handlerSource });
const result = await verify({
home: env.home,
handlerSourcePath: env.handlerSource,
spawnImpl: mockSpawnImpl({ exitCode: 127, stderr: 'command not found' }),
});
expect(result.ok).toBe(false);
expect(result.checks.find((c) => c.name === 'hive-mind-cli reachable')?.ok).toBe(false);
});
it('flags a missing handler.js even when the config entry is present', async () => {
const env = await bootstrap('{ "hooks": {} }');
envs.push(env);
await install({ home: env.home, handlerSourcePath: env.handlerSource });
// Remove the installed handler from the managed dir.
await rm(join(env.home, '.openclaw', 'hooks', 'hive-mind', 'handler.js'), { force: true });
const result = await verify({
home: env.home,
handlerSourcePath: env.handlerSource,
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
});
expect(result.ok).toBe(false);
expect(result.checks.find((c) => c.name === 'handler.js readable on disk')?.ok).toBe(false);
});
it('uses cli_path from the install pointer for the probe (node <path> --help)', async () => {
const env = await bootstrap('{ "hooks": {} }');
envs.push(env);
const cliPath = '/abs/from/pointer.js';
await install({ home: env.home, handlerSourcePath: env.handlerSource, cliPath });
const records: Array<{ command: string; args: readonly string[] }> = [];
const recordingSpawn = ((cmd: string, args: readonly string[]) => {
records.push({ command: cmd, args });
return mockSpawnImpl({ exitCode: 0 })(cmd, args);
}) as typeof import('node:child_process').spawn;
const result = await verify({
home: env.home,
handlerSourcePath: env.handlerSource,
spawnImpl: recordingSpawn,
});
expect(result.ok).toBe(true);
const probeRecord = records[records.length - 1];
expect(probeRecord.command).toBe(process.execPath);
expect(probeRecord.args[0]).toBe(cliPath);
expect(probeRecord.args[1]).toBe('--help');
// Sanity: the pinned cli path was actually written into the pointer.
const pointer = JSON.parse(await readFile(join(env.home, '.openclaw', 'hive-mind-install.json'), 'utf-8')) as Record<string, unknown>;
expect(pointer['cli_path']).toBe(cliPath);
});
});