moving
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
# @waggle/hive-mind-hooks-openclaw
|
||||
|
||||
> **Roadmap-only in the current Waggle Windows Solo release.** This package is
|
||||
> retained for development and future qualification. The production manifest
|
||||
> does not expose OpenClaw launch, hook installation, Fleet/task dispatch, or
|
||||
> direct agent runs. The implementation notes below do not override that gate.
|
||||
|
||||
Silent-capture shim that wires **OpenClaw** (`openclaw/openclaw`) gateway
|
||||
lifecycle hooks into [hive-mind](https://github.com/marolinik/hive-mind)
|
||||
frames. Every OpenClaw conversation deterministically captures
|
||||
|
||||
@@ -58,10 +58,9 @@ function printHelp(): void {
|
||||
' --help, -h Show this help.',
|
||||
' --handler-source <PATH> Override compiled handler.js source (testing).',
|
||||
' --cli-path <PATH> Absolute path to the hive-mind-cli binary or its',
|
||||
' compiled JS entry. Required on Windows (npm bin',
|
||||
' is a .cmd shim) and recommended for production',
|
||||
' installs. Threaded into the hook entry env as',
|
||||
' WAGGLE_HIVE_MIND_CLI.',
|
||||
' compiled JS entry. Required for managed install',
|
||||
' and verify. Verify treats it as the authoritative',
|
||||
' packaged CLI expectation.',
|
||||
'',
|
||||
'Repo: https://github.com/marolinik/waggle-os',
|
||||
'',
|
||||
@@ -159,7 +158,11 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
if (command === 'verify') {
|
||||
const result = await verify(baseOpts);
|
||||
const result = await verify({
|
||||
...baseOpts,
|
||||
...(cliPath !== undefined ? { cliPath } : {}),
|
||||
requireManagedRuntime: true,
|
||||
});
|
||||
printVerifySummary(result);
|
||||
if (!result.ok) process.exit(1);
|
||||
return;
|
||||
|
||||
@@ -33,13 +33,15 @@
|
||||
* this package's tree (or have the deps bundled). Documented in the README.
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import {
|
||||
createCliBridge,
|
||||
createLogger,
|
||||
type CliBridgeOptions,
|
||||
type MemoryHit,
|
||||
type SpawnFn,
|
||||
} from '@waggle/hive-mind-shim-core';
|
||||
import {
|
||||
buildHookBridgeOptions,
|
||||
makeOpenclawHandler,
|
||||
type HookContext,
|
||||
type InternalHookEventLike,
|
||||
@@ -134,19 +136,64 @@ function extractFor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a CliBridge, honoring an install-pinned cli path from env. */
|
||||
function buildBridge(): ReturnType<typeof createCliBridge> {
|
||||
export interface OpenclawHookRuntimeOptions {
|
||||
/** Install-pinned CLI path embedded into the managed handler loader. */
|
||||
readonly cliPath?: string;
|
||||
/** Install-pinned Node executable used for JavaScript CLI entries. */
|
||||
readonly nodePath?: string;
|
||||
}
|
||||
|
||||
/** Build a CliBridge, preferring the loader-pinned path over ambient env. */
|
||||
function buildBridge(opts: OpenclawHookRuntimeOptions = {}): ReturnType<typeof createCliBridge> {
|
||||
const logger = createLogger({ name: 'openclaw-hooks/handler' });
|
||||
const cliPath = process.env.WAGGLE_HIVE_MIND_CLI;
|
||||
const opts: CliBridgeOptions = { logger };
|
||||
if (typeof cliPath === 'string' && cliPath.length > 0) opts.cli_path = cliPath;
|
||||
return createCliBridge(opts);
|
||||
const envCliPath = process.env.WAGGLE_HIVE_MIND_CLI;
|
||||
const cliPath = typeof opts.cliPath === 'string' && opts.cliPath.length > 0
|
||||
? opts.cliPath
|
||||
: envCliPath;
|
||||
const nodePath = typeof opts.nodePath === 'string' && opts.nodePath.length > 0
|
||||
? opts.nodePath
|
||||
: undefined;
|
||||
const isJavaScriptCli = typeof cliPath === 'string'
|
||||
&& /\.(?:c|m)?js$/i.test(cliPath);
|
||||
const pinnedNodeSpawn: SpawnFn | undefined = isJavaScriptCli && nodePath
|
||||
? (_command, args, spawnOptions) => (
|
||||
spawnOptions === undefined
|
||||
? spawn(nodePath, args)
|
||||
: spawn(nodePath, args, spawnOptions)
|
||||
)
|
||||
: undefined;
|
||||
// OpenClaw shares its gateway event loop with hooks, so every bridge call —
|
||||
// including pre-compact cleanup — is intentionally best-effort and bounded.
|
||||
// A stalled CLI must release the gateway instead of delaying compaction.
|
||||
return createCliBridge({
|
||||
...buildHookBridgeOptions(
|
||||
logger,
|
||||
typeof cliPath === 'string' && cliPath.length > 0 ? cliPath : undefined,
|
||||
),
|
||||
...(pinnedNodeSpawn ? { spawnImpl: pinnedNodeSpawn } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
const handler = makeOpenclawHandler(openclawAdapter, {
|
||||
stopDebounceMs: DEFAULT_STOP_DEBOUNCE_MS,
|
||||
});
|
||||
|
||||
const pendingPromptSnapshots = new Map<string, Promise<MemoryHit[]>>();
|
||||
const PENDING_PROMPT_SNAPSHOT_TTL_MS = 5 * 60_000;
|
||||
|
||||
function promptSnapshotKey(event: OpenclawRuntimeEvent, fallback: string): string {
|
||||
const key = event.sessionKey;
|
||||
return typeof key === 'string' && key.length > 0 ? key : fallback;
|
||||
}
|
||||
|
||||
function rememberPromptSnapshot(key: string, snapshot: Promise<MemoryHit[]>): void {
|
||||
pendingPromptSnapshots.set(key, snapshot);
|
||||
const expiry = setTimeout(() => {
|
||||
if (pendingPromptSnapshots.get(key) === snapshot) pendingPromptSnapshots.delete(key);
|
||||
}, PENDING_PROMPT_SNAPSHOT_TTL_MS);
|
||||
expiry.unref?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* The OpenClaw default export. Receives the runtime `InternalHookEvent`, maps
|
||||
* `event.context` → the extracted payload, and drives the shared bodies.
|
||||
@@ -158,23 +205,68 @@ const handler = makeOpenclawHandler(openclawAdapter, {
|
||||
*
|
||||
* NEVER throws — always returns a resolved promise (fail-open).
|
||||
*/
|
||||
export default async function openclawHook(event: OpenclawRuntimeEvent): Promise<void> {
|
||||
export default async function openclawHook(
|
||||
event: OpenclawRuntimeEvent,
|
||||
runtimeOptions: OpenclawHookRuntimeOptions = {},
|
||||
): Promise<void> {
|
||||
try {
|
||||
const lifecycle = lifecycleFor(event);
|
||||
if (lifecycle === undefined) return;
|
||||
const ctx = asContext(event);
|
||||
const extracted = extractFor(lifecycle, ctx);
|
||||
|
||||
if (lifecycle === 'user-prompt-submit') {
|
||||
const prompt = extracted as UserPromptExtracted;
|
||||
const key = promptSnapshotKey(event, prompt.sessionId);
|
||||
const previous = pendingPromptSnapshots.get(key);
|
||||
const snapshot = (previous ?? Promise.resolve<MemoryHit[]>([]))
|
||||
.catch(() => [])
|
||||
.then(async () => {
|
||||
const bridge = buildBridge(runtimeOptions);
|
||||
let hits: MemoryHit[] = [];
|
||||
try {
|
||||
hits = await bridge.recallMemory('', {
|
||||
limit: DEFAULT_RECALL_LIMIT,
|
||||
scope: 'personal',
|
||||
});
|
||||
} catch {
|
||||
// Fail open: prompt capture still runs if historical recall fails.
|
||||
}
|
||||
const hookCtx: HookContext = {
|
||||
bridge,
|
||||
logger: createLogger({ name: 'openclaw-hooks/handler' }),
|
||||
};
|
||||
await handler.handle({ event, extracted: prompt }, hookCtx);
|
||||
return hits;
|
||||
})
|
||||
.catch(() => []);
|
||||
rememberPromptSnapshot(key, snapshot);
|
||||
await snapshot;
|
||||
return;
|
||||
}
|
||||
|
||||
// SessionStart: drive recall ourselves so we can mutate bootstrapFiles
|
||||
// with the injected text (the shared body's stdout return is unused
|
||||
// in-process).
|
||||
if (lifecycle === 'session-start') {
|
||||
await injectBootstrap(ctx, extracted as SessionStartExtracted);
|
||||
const sessionStart = extracted as SessionStartExtracted;
|
||||
const key = promptSnapshotKey(event, sessionStart.sessionId ?? provenanceScope(ctx));
|
||||
const snapshot = pendingPromptSnapshots.get(key);
|
||||
if (snapshot) {
|
||||
const hits = await snapshot;
|
||||
if (pendingPromptSnapshots.get(key) === snapshot) pendingPromptSnapshots.delete(key);
|
||||
await injectBootstrap(ctx, sessionStart, runtimeOptions, hits);
|
||||
} else {
|
||||
await injectBootstrap(ctx, sessionStart, runtimeOptions);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const input: OpenclawHandlerInput = { event, extracted };
|
||||
const hookCtx: HookContext = { bridge: buildBridge(), logger: createLogger({ name: 'openclaw-hooks/handler' }) };
|
||||
const hookCtx: HookContext = {
|
||||
bridge: buildBridge(runtimeOptions),
|
||||
logger: createLogger({ name: 'openclaw-hooks/handler' }),
|
||||
};
|
||||
await handler.handle(input, hookCtx);
|
||||
} catch {
|
||||
// FAIL-OPEN: swallow — the gateway flow must never be affected.
|
||||
@@ -190,11 +282,13 @@ export default async function openclawHook(event: OpenclawRuntimeEvent): Promise
|
||||
async function injectBootstrap(
|
||||
ctx: OpenclawRuntimeContext,
|
||||
extracted: SessionStartExtracted,
|
||||
runtimeOptions: OpenclawHookRuntimeOptions = {},
|
||||
snapshot?: readonly MemoryHit[],
|
||||
): Promise<void> {
|
||||
const logger = createLogger({ name: 'openclaw-hooks/handler' });
|
||||
try {
|
||||
const bridge = buildBridge();
|
||||
const hits: MemoryHit[] = await bridge.recallMemory('', {
|
||||
const hits: readonly MemoryHit[] = snapshot
|
||||
?? await buildBridge(runtimeOptions).recallMemory('', {
|
||||
limit: extracted.recallLimit,
|
||||
scope: 'personal',
|
||||
});
|
||||
@@ -203,7 +297,7 @@ async function injectBootstrap(
|
||||
const arr = ctx.bootstrapFiles;
|
||||
if (Array.isArray(arr)) {
|
||||
// Mutate the host-owned array in place — this IS the injection seam.
|
||||
(arr as unknown[]).push(text);
|
||||
(arr as unknown[]).push(createRecallBootstrapFile(text));
|
||||
} else {
|
||||
// The host did not provide a mutable array; nothing to inject into.
|
||||
logger.debug('agent:bootstrap had no bootstrapFiles array — skipping inject');
|
||||
@@ -213,6 +307,21 @@ async function injectBootstrap(
|
||||
}
|
||||
}
|
||||
|
||||
export interface OpenclawBootstrapFile {
|
||||
path: string;
|
||||
name: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** Build the virtual context file shape accepted by OpenClaw's sanitizer. */
|
||||
export function createRecallBootstrapFile(content: string): OpenclawBootstrapFile {
|
||||
return {
|
||||
path: 'HIVE_MIND_RECALL.md',
|
||||
name: 'HIVE_MIND_RECALL.md',
|
||||
content,
|
||||
};
|
||||
}
|
||||
|
||||
const PER_HIT_BUDGET = 240;
|
||||
|
||||
function formatHits(hits: readonly MemoryHit[]): string {
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
* lossy — comments / trailing commas are dropped — so reversibility relies
|
||||
* on the literal backup, not a re-serialized diff.)
|
||||
* 3. Write the managed hook DIRECTORY
|
||||
* `~/.openclaw/hooks/hive-mind/{HOOK.md, handler.js}` — HOOK.md declares
|
||||
* our events, handler.js is the compiled in-process default export COPIED
|
||||
* from this package's dist/.
|
||||
* `~/.openclaw/hooks/hive-mind/{HOOK.md, handler.js, handler.cjs,
|
||||
* package.json}`. The discoverable handler.js loads the self-contained
|
||||
* CommonJS bundle from handler.cjs under a hook-local module boundary.
|
||||
* 4. Minimal-touch edit of openclaw.json: flip `hooks.internal.enabled=true`
|
||||
* + add `hooks.internal.entries["hive-mind"]={enabled:true, env?}` via
|
||||
* `jsonRegister`. Existing config preserved verbatim.
|
||||
@@ -29,7 +29,8 @@
|
||||
|
||||
import { readFile, writeFile, mkdir, copyFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { createLogger, type Logger } from '@waggle/hive-mind-shim-core';
|
||||
import {
|
||||
backupByteIdentical,
|
||||
@@ -62,6 +63,8 @@ export interface InstallResult {
|
||||
createdByUs: boolean;
|
||||
/** The cli_path embedded in the entry env (undefined = default lookup at runtime). */
|
||||
cliPath?: string;
|
||||
/** The packaged Node runtime embedded into the managed loader. */
|
||||
nodePath?: string;
|
||||
}
|
||||
|
||||
export interface InstallOptions extends ResolvePathsOptions {
|
||||
@@ -76,18 +79,61 @@ export interface InstallOptions extends ResolvePathsOptions {
|
||||
* in-process handler's CliBridge uses it.
|
||||
*/
|
||||
cliPath?: string;
|
||||
/** Packaged Node executable used for JavaScript CLI entries. */
|
||||
nodePath?: string;
|
||||
/** Extra env to attach to the hook entry (e.g. WAGGLE_WORKSPACE_ID). */
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface OpenclawRuntimeBinding {
|
||||
version: 1;
|
||||
node_path: string;
|
||||
node_sha256: string;
|
||||
cli_path: string;
|
||||
cli_sha256: string;
|
||||
}
|
||||
|
||||
const POINTER_VERSION = '0.1.0';
|
||||
const TOUCHED_KEYS = ['hooks.internal.enabled', `hooks.internal.entries.${HIVE_HOOK_ENTRY_KEY}`] as const;
|
||||
const LIFECYCLE_NAMES = ['session-start', 'user-prompt-submit', 'stop', 'pre-compact'] as const;
|
||||
export const OPENCLAW_HANDLER_BUNDLE = 'handler.cjs';
|
||||
|
||||
const OPENCLAW_HANDLER_ENTRY = 'handler.js';
|
||||
export function renderOpenclawHandlerEntrySource(cliPath?: string, nodePath?: string): string {
|
||||
if (cliPath === undefined) {
|
||||
return `'use strict';\nmodule.exports = require('./${OPENCLAW_HANDLER_BUNDLE}');\n`;
|
||||
}
|
||||
const runtimeOptions = nodePath === undefined
|
||||
? `{ cliPath: ${JSON.stringify(cliPath)} }`
|
||||
: JSON.stringify({ cliPath, nodePath });
|
||||
return [
|
||||
`'use strict';`,
|
||||
`const handler = require('./${OPENCLAW_HANDLER_BUNDLE}');`,
|
||||
`module.exports = (event) => handler(event, ${runtimeOptions});`,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
export const OPENCLAW_HANDLER_ENTRY_SOURCE = renderOpenclawHandlerEntrySource();
|
||||
export const OPENCLAW_HANDLER_PACKAGE_JSON = `${JSON.stringify({ private: true, type: 'commonjs' }, null, 2)}\n`;
|
||||
|
||||
async function ensureDir(p: string): Promise<void> {
|
||||
if (!existsSync(p)) await mkdir(p, { recursive: true });
|
||||
}
|
||||
|
||||
function isJavaScriptPath(filePath: string): boolean {
|
||||
return /\.(?:c|m)?js$/i.test(filePath);
|
||||
}
|
||||
|
||||
async function sha256Required(filePath: string, label: string): Promise<string> {
|
||||
try {
|
||||
return createHash('sha256').update(await readFile(filePath)).digest('hex');
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`${label} is not readable at ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function install(opts: InstallOptions = {}): Promise<InstallResult> {
|
||||
const log = opts.logger ?? createLogger({ name: 'openclaw-hooks/install' });
|
||||
const paths = resolvePaths({
|
||||
@@ -114,24 +160,50 @@ export async function install(opts: InstallOptions = {}): Promise<InstallResult>
|
||||
const { backupPath } = await backupByteIdentical(paths.configPath, now().toISOString());
|
||||
if (backupPath) log.info('openclaw.json backed up', { backupPath });
|
||||
|
||||
// Write the managed hook dir: HOOK.md + the compiled handler.js (copied from
|
||||
// this package's dist/). We COPY rather than reference so the gateway loads a
|
||||
// stable file even if this package is removed (the handler still requires the
|
||||
// @waggle/* deps at runtime — the OQ-5 live-install caveat, see README).
|
||||
// Copy the self-contained bundle so the gateway remains independent of this
|
||||
// package after installation.
|
||||
await ensureDir(paths.hiveHookDir);
|
||||
await writeFile(paths.hookMdPath, renderHookMd('handler.js'), 'utf-8');
|
||||
const cliPath = normalizeCliPath(opts.cliPath);
|
||||
const nodePathCandidate = normalizeCliPath(
|
||||
opts.nodePath ?? process.env.WAGGLE_HOOK_NODE_PATH,
|
||||
);
|
||||
let runtimeBinding: OpenclawRuntimeBinding | undefined;
|
||||
if (cliPath !== undefined && isJavaScriptPath(cliPath) && nodePathCandidate !== undefined) {
|
||||
const [nodeSha256, cliSha256] = await Promise.all([
|
||||
sha256Required(nodePathCandidate, 'packaged Node runtime'),
|
||||
sha256Required(cliPath, 'pinned hive-mind CLI'),
|
||||
]);
|
||||
runtimeBinding = {
|
||||
version: 1,
|
||||
node_path: nodePathCandidate,
|
||||
node_sha256: nodeSha256,
|
||||
cli_path: cliPath,
|
||||
cli_sha256: cliSha256,
|
||||
};
|
||||
}
|
||||
const nodePath = runtimeBinding?.node_path;
|
||||
await writeFile(paths.hookMdPath, renderHookMd(OPENCLAW_HANDLER_ENTRY), 'utf-8');
|
||||
if (!existsSync(paths.handlerSourcePath)) {
|
||||
throw new Error(
|
||||
`compiled handler not found at ${paths.handlerSourcePath}. ` +
|
||||
`Build the package (tsc --build) before installing.`,
|
||||
);
|
||||
}
|
||||
await copyFile(paths.handlerSourcePath, paths.installedHandlerPath);
|
||||
// OpenClaw discovers handler.js by filename. Keep that entry deterministic
|
||||
// CommonJS while retaining the self-contained bundle's .cjs identity; the
|
||||
// hook-local package.json overrides any ancestor `type: module` boundary.
|
||||
await copyFile(paths.handlerSourcePath, join(paths.hiveHookDir, OPENCLAW_HANDLER_BUNDLE));
|
||||
await writeFile(
|
||||
paths.installedHandlerPath,
|
||||
renderOpenclawHandlerEntrySource(cliPath, nodePath),
|
||||
'utf-8',
|
||||
);
|
||||
await writeFile(join(paths.hiveHookDir, 'package.json'), OPENCLAW_HANDLER_PACKAGE_JSON, 'utf-8');
|
||||
|
||||
// Minimal-touch config edit.
|
||||
const cliPath = normalizeCliPath(opts.cliPath);
|
||||
const env: Record<string, string> = { ...(opts.env ?? {}) };
|
||||
if (cliPath !== undefined) env['WAGGLE_HIVE_MIND_CLI'] = cliPath;
|
||||
if (nodePath !== undefined) env['WAGGLE_HOOK_NODE_PATH'] = nodePath;
|
||||
const merged = jsonRegister(existingConfig, Object.keys(env).length > 0 ? { env } : {});
|
||||
await writeFile(paths.configPath, serializeConfig(merged), 'utf-8');
|
||||
|
||||
@@ -148,11 +220,17 @@ export async function install(opts: InstallOptions = {}): Promise<InstallResult>
|
||||
extra: {
|
||||
hook_dir_name: HIVE_HOOK_DIR_NAME,
|
||||
touched_keys: TOUCHED_KEYS,
|
||||
runtime_binding: runtimeBinding ?? null,
|
||||
},
|
||||
};
|
||||
await writePointer(paths.pointerPath, pointer);
|
||||
|
||||
log.info('install complete', { createdByUs, hookDir: paths.hiveHookDir, cliPath: cliPath ?? '(PATH lookup)' });
|
||||
log.info('install complete', {
|
||||
createdByUs,
|
||||
hookDir: paths.hiveHookDir,
|
||||
cliPath: cliPath ?? '(PATH lookup)',
|
||||
nodePath: nodePath ?? '(host runtime)',
|
||||
});
|
||||
|
||||
const result: InstallResult = {
|
||||
paths,
|
||||
@@ -164,5 +242,6 @@ export async function install(opts: InstallOptions = {}): Promise<InstallResult>
|
||||
createdByUs,
|
||||
};
|
||||
if (cliPath !== undefined) result.cliPath = cliPath;
|
||||
if (nodePath !== undefined) result.nodePath = nodePath;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -5,18 +5,26 @@
|
||||
* answers a `--help` probe. Plus the openclaw-specific activation advisory
|
||||
* (spec §5.5 / §6.2): hooks are OFF until `hooks.internal.enabled=true`.
|
||||
*
|
||||
* Probe priority for `cli_path`:
|
||||
* 1. Explicit `opts.cliPath` (caller override)
|
||||
* 2. `cli_path` recorded in `~/.openclaw/hive-mind-install.json`
|
||||
* 3. Bare `'hive-mind-cli'` on PATH
|
||||
* Probe selection follows the managed runtime: the trusted install pin, or
|
||||
* bare `'hive-mind-cli'` on PATH when the loader is unpinned.
|
||||
*/
|
||||
|
||||
import { readFile, access } from 'node:fs/promises';
|
||||
import { constants, existsSync } from 'node:fs';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { createLogger, type Logger } from '@waggle/hive-mind-shim-core';
|
||||
import { resolvePaths, type ResolvePathsOptions } from './paths.js';
|
||||
import { normalizeCliPath } from '@waggle/hive-mind-hooks-core';
|
||||
import { HIVE_HOOK_ENTRY_KEY, resolvePaths, type ResolvePathsOptions } from './paths.js';
|
||||
import { parseConfig, hasHiveEntries, HOOKS_KEY } from './json5-merger.js';
|
||||
import {
|
||||
OPENCLAW_HANDLER_BUNDLE,
|
||||
OPENCLAW_HANDLER_PACKAGE_JSON,
|
||||
renderOpenclawHandlerEntrySource,
|
||||
type OpenclawRuntimeBinding,
|
||||
} from './install.js';
|
||||
|
||||
export interface VerifyCheck {
|
||||
name: string;
|
||||
@@ -31,10 +39,16 @@ export interface VerifyResult {
|
||||
|
||||
export interface VerifyOptions extends ResolvePathsOptions {
|
||||
logger?: Logger;
|
||||
/** Override hive-mind-cli executable name. Default 'hive-mind-cli'. */
|
||||
/** Authoritative packaged CLI path expected by the launcher/verifier. */
|
||||
cliPath?: string;
|
||||
/** Expected packaged Node path; production receives this from the launcher runtime. */
|
||||
nodePath?: string;
|
||||
/** Fail closed unless a complete packaged Node/CLI binding is present. */
|
||||
requireManagedRuntime?: boolean;
|
||||
/** Test hook for spawn. */
|
||||
spawnImpl?: typeof spawn;
|
||||
/** Test hook for the CLI probe timeout. */
|
||||
cliProbeTimeoutMs?: number;
|
||||
}
|
||||
|
||||
async function fileReadable(p: string): Promise<boolean> {
|
||||
@@ -53,43 +67,222 @@ function internalEnabled(config: Record<string, unknown>): boolean {
|
||||
return (internal as Record<string, unknown>)['enabled'] === true;
|
||||
}
|
||||
|
||||
function probeCliVersion(
|
||||
cliPath: string,
|
||||
spawnImpl: typeof spawn,
|
||||
function configuredCliPath(config: Record<string, unknown>): string | undefined {
|
||||
const hooks = config[HOOKS_KEY];
|
||||
if (!hooks || typeof hooks !== 'object') return undefined;
|
||||
const internal = (hooks as Record<string, unknown>)['internal'];
|
||||
if (!internal || typeof internal !== 'object') return undefined;
|
||||
const entries = (internal as Record<string, unknown>)['entries'];
|
||||
if (!entries || typeof entries !== 'object') return undefined;
|
||||
const hiveEntry = (entries as Record<string, unknown>)[HIVE_HOOK_ENTRY_KEY];
|
||||
if (!hiveEntry || typeof hiveEntry !== 'object') return undefined;
|
||||
const env = (hiveEntry as Record<string, unknown>)['env'];
|
||||
if (!env || typeof env !== 'object') return undefined;
|
||||
const cliPath = (env as Record<string, unknown>)['WAGGLE_HIVE_MIND_CLI'];
|
||||
return typeof cliPath === 'string' && cliPath.length > 0 ? cliPath : undefined;
|
||||
}
|
||||
|
||||
function configuredNodePath(config: Record<string, unknown>): string | undefined {
|
||||
const hooks = config[HOOKS_KEY];
|
||||
if (!hooks || typeof hooks !== 'object') return undefined;
|
||||
const internal = (hooks as Record<string, unknown>)['internal'];
|
||||
if (!internal || typeof internal !== 'object') return undefined;
|
||||
const entries = (internal as Record<string, unknown>)['entries'];
|
||||
if (!entries || typeof entries !== 'object') return undefined;
|
||||
const hiveEntry = (entries as Record<string, unknown>)[HIVE_HOOK_ENTRY_KEY];
|
||||
if (!hiveEntry || typeof hiveEntry !== 'object') return undefined;
|
||||
const env = (hiveEntry as Record<string, unknown>)['env'];
|
||||
if (!env || typeof env !== 'object') return undefined;
|
||||
const nodePath = (env as Record<string, unknown>)['WAGGLE_HOOK_NODE_PATH'];
|
||||
return typeof nodePath === 'string' && nodePath.length > 0 ? nodePath : undefined;
|
||||
}
|
||||
|
||||
const TERMINATION_GRACE_MS = 250;
|
||||
|
||||
function waitForProbe(
|
||||
child: ChildProcess,
|
||||
timeoutMs: number,
|
||||
timeoutMessage: string,
|
||||
successMessage: string,
|
||||
outputLimit: number,
|
||||
): Promise<{ ok: boolean; output: string }> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const command = isJsPath(cliPath) ? process.execPath : cliPath;
|
||||
const args = isJsPath(cliPath) ? [cliPath, '--help'] : ['--help'];
|
||||
const child = spawnImpl(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let timedOut = false;
|
||||
const timers: {
|
||||
timeout?: ReturnType<typeof setTimeout>;
|
||||
force?: ReturnType<typeof setTimeout>;
|
||||
detach?: ReturnType<typeof setTimeout>;
|
||||
} = {};
|
||||
const stdout: Buffer[] = [];
|
||||
const stderr: Buffer[] = [];
|
||||
const timer = setTimeout(() => {
|
||||
|
||||
const clearTimers = (): void => {
|
||||
if (timers.timeout !== undefined) clearTimeout(timers.timeout);
|
||||
if (timers.force !== undefined) clearTimeout(timers.force);
|
||||
if (timers.detach !== undefined) clearTimeout(timers.detach);
|
||||
};
|
||||
const finish = (result: { ok: boolean; output: string }): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try { child.kill('SIGTERM'); } catch { /* gone */ }
|
||||
resolve({ ok: false, output: 'timed out probing hive-mind-cli' });
|
||||
}, timeoutMs);
|
||||
child.stdout?.on('data', (c: Buffer) => stdout.push(c));
|
||||
child.stderr?.on('data', (c: Buffer) => stderr.push(c));
|
||||
child.on('error', (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve({ ok: false, output: err instanceof Error ? err.message : String(err) });
|
||||
clearTimers();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
child.stdout?.on('data', (chunk: Buffer) => stdout.push(chunk));
|
||||
child.stderr?.on('data', (chunk: Buffer) => stderr.push(chunk));
|
||||
child.on('error', (error) => {
|
||||
finish({
|
||||
ok: false,
|
||||
output: timedOut ? timeoutMessage : error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
child.on('exit', (code) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
const out = Buffer.concat(stdout).toString('utf-8').slice(0, 200);
|
||||
const err = Buffer.concat(stderr).toString('utf-8').slice(0, 200);
|
||||
resolve({ ok: code === 0, output: code === 0 ? out : err });
|
||||
if (timedOut) {
|
||||
finish({ ok: false, output: timeoutMessage });
|
||||
return;
|
||||
}
|
||||
const output = Buffer.concat(code === 0 ? stdout : stderr)
|
||||
.toString('utf-8')
|
||||
.trim()
|
||||
.slice(0, outputLimit);
|
||||
finish({
|
||||
ok: code === 0,
|
||||
output: output || (code === 0 ? successMessage : `process exited ${String(code)}`),
|
||||
});
|
||||
});
|
||||
|
||||
timers.timeout = setTimeout(() => {
|
||||
if (settled) return;
|
||||
timedOut = true;
|
||||
try { child.kill('SIGTERM'); } catch { /* already gone */ }
|
||||
timers.force = setTimeout(() => {
|
||||
if (settled) return;
|
||||
try { child.kill('SIGKILL'); } catch { /* already gone */ }
|
||||
timers.detach = setTimeout(() => {
|
||||
if (settled) return;
|
||||
child.stdout?.destroy();
|
||||
child.stderr?.destroy();
|
||||
if (typeof child.unref === 'function') child.unref();
|
||||
finish({ ok: false, output: `${timeoutMessage}; process did not exit after SIGKILL` });
|
||||
}, TERMINATION_GRACE_MS);
|
||||
}, TERMINATION_GRACE_MS);
|
||||
}, timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
function probeCliVersion(
|
||||
cliPath: string,
|
||||
nodePath: string | undefined,
|
||||
spawnImpl: typeof spawn,
|
||||
timeoutMs: number,
|
||||
): Promise<{ ok: boolean; output: string }> {
|
||||
const command = isJsPath(cliPath) ? nodePath ?? process.execPath : cliPath;
|
||||
const args = isJsPath(cliPath) ? [cliPath, '--help'] : ['--help'];
|
||||
const child = spawnImpl(command, args, {
|
||||
env: runtimeProbeEnv(),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
return waitForProbe(
|
||||
child,
|
||||
timeoutMs,
|
||||
'timed out probing hive-mind-cli',
|
||||
'hive-mind-cli exited successfully',
|
||||
200,
|
||||
);
|
||||
}
|
||||
|
||||
async function sha256File(filePath: string): Promise<string | undefined> {
|
||||
try {
|
||||
return createHash('sha256').update(await readFile(filePath)).digest('hex');
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function fileHasExactText(filePath: string, expected: string): Promise<boolean> {
|
||||
try {
|
||||
return await readFile(filePath, 'utf-8') === expected;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runtimeBindingFromPointer(pointer: Record<string, unknown> | undefined): {
|
||||
declared: boolean;
|
||||
binding?: OpenclawRuntimeBinding;
|
||||
} {
|
||||
const extra = pointer?.['extra'];
|
||||
if (!extra || typeof extra !== 'object') return { declared: false };
|
||||
const candidate = (extra as Record<string, unknown>)['runtime_binding'];
|
||||
if (candidate === undefined || candidate === null) return { declared: false };
|
||||
if (!candidate || typeof candidate !== 'object') return { declared: true };
|
||||
const value = candidate as Record<string, unknown>;
|
||||
const sha256 = /^[a-f0-9]{64}$/;
|
||||
if (
|
||||
value['version'] !== 1
|
||||
|| typeof value['node_path'] !== 'string'
|
||||
|| value['node_path'].length === 0
|
||||
|| typeof value['node_sha256'] !== 'string'
|
||||
|| !sha256.test(value['node_sha256'])
|
||||
|| typeof value['cli_path'] !== 'string'
|
||||
|| value['cli_path'].length === 0
|
||||
|| typeof value['cli_sha256'] !== 'string'
|
||||
|| !sha256.test(value['cli_sha256'])
|
||||
) {
|
||||
return { declared: true };
|
||||
}
|
||||
return {
|
||||
declared: true,
|
||||
binding: {
|
||||
version: 1,
|
||||
node_path: value['node_path'],
|
||||
node_sha256: value['node_sha256'],
|
||||
cli_path: value['cli_path'],
|
||||
cli_sha256: value['cli_sha256'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeProbeEnv(): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = { NODE_PATH: '', NO_COLOR: '1' };
|
||||
for (const key of [
|
||||
'PATH', 'Path', 'SystemRoot', 'SYSTEMROOT', 'WINDIR', 'ComSpec',
|
||||
'PATHEXT', 'TEMP', 'TMP',
|
||||
]) {
|
||||
const value = process.env[key];
|
||||
if (value !== undefined) env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function probeInstalledHandler(
|
||||
handlerPath: string,
|
||||
nodePath: string | undefined,
|
||||
timeoutMs: number,
|
||||
): Promise<{ ok: boolean; output: string }> {
|
||||
const handlerUrl = pathToFileURL(handlerPath).href;
|
||||
const script = [
|
||||
`const loaded = await import(${JSON.stringify(handlerUrl)});`,
|
||||
`if (typeof loaded.default !== 'function') throw new Error('installed handler default export is not a function');`,
|
||||
`await loaded.default({ type: 'noop', action: 'noop' });`,
|
||||
].join('\n');
|
||||
const child = spawn(nodePath ?? process.execPath, ['--input-type=module', '--eval', script], {
|
||||
cwd: dirname(handlerPath),
|
||||
env: runtimeProbeEnv(),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
return waitForProbe(
|
||||
child,
|
||||
timeoutMs,
|
||||
'timed out runtime-loading installed handler',
|
||||
'default export loaded and handled a noop event',
|
||||
500,
|
||||
);
|
||||
}
|
||||
|
||||
export async function verify(opts: VerifyOptions = {}): Promise<VerifyResult> {
|
||||
const log = opts.logger ?? createLogger({ name: 'openclaw-hooks/verify' });
|
||||
const paths = resolvePaths({
|
||||
@@ -152,25 +345,175 @@ export async function verify(opts: VerifyOptions = {}): Promise<VerifyResult> {
|
||||
ok: await fileReadable(paths.installedHandlerPath),
|
||||
detail: paths.installedHandlerPath,
|
||||
});
|
||||
|
||||
// 5. hive-mind-cli responds to --help (prefer install-pinned --cli-path).
|
||||
const installedBundlePath = join(paths.hiveHookDir, OPENCLAW_HANDLER_BUNDLE);
|
||||
const installedPackagePath = join(paths.hiveHookDir, 'package.json');
|
||||
checks.push({
|
||||
name: 'handler.cjs readable on disk',
|
||||
ok: await fileReadable(installedBundlePath),
|
||||
detail: installedBundlePath,
|
||||
});
|
||||
const [installedBundleHash, trustedBundleHash] = await Promise.all([
|
||||
sha256File(installedBundlePath),
|
||||
sha256File(paths.handlerSourcePath),
|
||||
]);
|
||||
const bundleTrusted = installedBundleHash !== undefined
|
||||
&& trustedBundleHash !== undefined
|
||||
&& installedBundleHash === trustedBundleHash;
|
||||
checks.push({
|
||||
name: 'handler.cjs matches trusted bundle',
|
||||
ok: bundleTrusted,
|
||||
detail: bundleTrusted
|
||||
? `sha256 ${installedBundleHash}`
|
||||
: `installed=${installedBundleHash ?? 'unreadable'} trusted=${trustedBundleHash ?? 'unreadable'}`,
|
||||
});
|
||||
let pointerObj: Record<string, unknown> | undefined;
|
||||
let cliPathFromPointer: string | undefined;
|
||||
if (existsSync(paths.pointerPath)) {
|
||||
try {
|
||||
const pointerObj = JSON.parse(await readFile(paths.pointerPath, 'utf-8')) as Record<string, unknown>;
|
||||
pointerObj = JSON.parse(await readFile(paths.pointerPath, 'utf-8')) as Record<string, unknown>;
|
||||
const pointerCliPath = pointerObj['cli_path'];
|
||||
if (typeof pointerCliPath === 'string' && pointerCliPath.length > 0) {
|
||||
cliPathFromPointer = pointerCliPath;
|
||||
}
|
||||
} catch { /* pointer unreadable — fall through */ }
|
||||
} catch { /* pointer unreadable — fail the trust comparison below */ }
|
||||
}
|
||||
const cliPath = opts.cliPath ?? cliPathFromPointer ?? 'hive-mind-cli';
|
||||
const cliPathFromConfig = configuredCliPath(parsed);
|
||||
const nodePathFromConfig = configuredNodePath(parsed);
|
||||
const runtimeBindingRead = runtimeBindingFromPointer(pointerObj);
|
||||
const runtimeBinding = runtimeBindingRead.binding;
|
||||
const expectedCliPath = normalizeCliPath(opts.cliPath);
|
||||
const launcherNodePath = opts.nodePath
|
||||
?? process.env.WAGGLE_HOOK_NODE_PATH
|
||||
?? process.execPath;
|
||||
const runtimeBindingRequired = opts.requireManagedRuntime === true
|
||||
|| expectedCliPath !== undefined
|
||||
|| runtimeBindingRead.declared
|
||||
|| nodePathFromConfig !== undefined
|
||||
|| opts.nodePath !== undefined
|
||||
|| process.env.WAGGLE_HOOK_NODE_PATH !== undefined;
|
||||
const runtimePathsAgree = !runtimeBindingRequired || (
|
||||
runtimeBinding !== undefined
|
||||
&& runtimeBinding.cli_path === cliPathFromPointer
|
||||
&& runtimeBinding.cli_path === cliPathFromConfig
|
||||
&& runtimeBinding.node_path === nodePathFromConfig
|
||||
&& runtimeBinding.node_path === launcherNodePath
|
||||
);
|
||||
checks.push({
|
||||
name: 'packaged Node matches verifier expectation',
|
||||
ok: runtimePathsAgree,
|
||||
detail: runtimeBindingRequired
|
||||
? `binding=${runtimeBinding?.node_path ?? '(invalid)'} config=${nodePathFromConfig ?? '(none)'} verifier=${launcherNodePath}`
|
||||
: 'legacy install without a packaged runtime binding',
|
||||
});
|
||||
const cliExpectationMatches = expectedCliPath === undefined
|
||||
? opts.requireManagedRuntime !== true
|
||||
: runtimeBinding !== undefined && runtimeBinding.cli_path === expectedCliPath;
|
||||
checks.push({
|
||||
name: 'packaged CLI matches verifier expectation',
|
||||
ok: cliExpectationMatches,
|
||||
detail: expectedCliPath === undefined
|
||||
? opts.requireManagedRuntime === true
|
||||
? 'managed verification requires --cli-path'
|
||||
: 'no authoritative packaged CLI expectation'
|
||||
: `binding=${runtimeBinding?.cli_path ?? '(invalid)'} verifier=${expectedCliPath}`,
|
||||
});
|
||||
const [currentNodeHash, currentCliHash] = runtimeBinding === undefined
|
||||
? [undefined, undefined]
|
||||
: await Promise.all([
|
||||
sha256File(runtimeBinding.node_path),
|
||||
sha256File(runtimeBinding.cli_path),
|
||||
]);
|
||||
const nodeHashTrusted = !runtimeBindingRequired || (
|
||||
runtimeBinding !== undefined
|
||||
&& currentNodeHash !== undefined
|
||||
&& currentNodeHash === runtimeBinding.node_sha256
|
||||
);
|
||||
const cliHashTrusted = !runtimeBindingRequired || (
|
||||
runtimeBinding !== undefined
|
||||
&& currentCliHash !== undefined
|
||||
&& currentCliHash === runtimeBinding.cli_sha256
|
||||
);
|
||||
checks.push({
|
||||
name: 'packaged Node matches install receipt',
|
||||
ok: nodeHashTrusted,
|
||||
detail: runtimeBindingRequired
|
||||
? `installed=${runtimeBinding?.node_sha256 ?? '(invalid)'} current=${currentNodeHash ?? '(unreadable)'}`
|
||||
: 'legacy install without a packaged runtime binding',
|
||||
});
|
||||
checks.push({
|
||||
name: 'packaged CLI matches install receipt',
|
||||
ok: cliHashTrusted,
|
||||
detail: runtimeBindingRequired
|
||||
? `installed=${runtimeBinding?.cli_sha256 ?? '(invalid)'} current=${currentCliHash ?? '(unreadable)'}`
|
||||
: 'legacy install without a packaged runtime binding',
|
||||
});
|
||||
// These digests are point-in-time drift receipts, not authentication against
|
||||
// the local account that owns both the current-user app and OpenClaw config.
|
||||
const runtimeBindingTrusted = runtimePathsAgree
|
||||
&& cliExpectationMatches
|
||||
&& nodeHashTrusted
|
||||
&& cliHashTrusted;
|
||||
const pointerCliPathTrusted = cliPathFromPointer === cliPathFromConfig
|
||||
&& (!runtimeBindingRequired || runtimeBinding?.cli_path === cliPathFromPointer);
|
||||
checks.push({
|
||||
name: 'install pointer cli_path matches managed config',
|
||||
ok: pointerCliPathTrusted,
|
||||
detail: pointerCliPathTrusted
|
||||
? cliPathFromPointer ?? 'no pinned CLI path'
|
||||
: `pointer=${cliPathFromPointer ?? '(none)'} config=${cliPathFromConfig ?? '(none)'}`,
|
||||
});
|
||||
const trustedPointerCliPath = pointerCliPathTrusted && runtimeBindingTrusted
|
||||
? cliPathFromPointer
|
||||
: undefined;
|
||||
const trustedNodePath = runtimeBindingRequired && runtimeBindingTrusted
|
||||
? runtimeBinding?.node_path
|
||||
: undefined;
|
||||
|
||||
const entryTrusted = await fileHasExactText(
|
||||
paths.installedHandlerPath,
|
||||
renderOpenclawHandlerEntrySource(trustedPointerCliPath, trustedNodePath),
|
||||
);
|
||||
checks.push({
|
||||
name: 'handler.js matches managed loader',
|
||||
ok: entryTrusted,
|
||||
});
|
||||
const packageTrusted = await fileHasExactText(
|
||||
installedPackagePath,
|
||||
OPENCLAW_HANDLER_PACKAGE_JSON,
|
||||
);
|
||||
checks.push({
|
||||
name: 'hook package locks CommonJS mode',
|
||||
ok: packageTrusted,
|
||||
detail: installedPackagePath,
|
||||
});
|
||||
const artifactsTrusted = bundleTrusted
|
||||
&& entryTrusted
|
||||
&& packageTrusted
|
||||
&& runtimeBindingTrusted;
|
||||
const handlerProbe = artifactsTrusted
|
||||
? await probeInstalledHandler(paths.installedHandlerPath, trustedNodePath, 4000)
|
||||
: { ok: false, output: 'skipped: installed handler artifacts do not match trusted bytes' };
|
||||
checks.push({
|
||||
name: 'installed handler runtime-loads',
|
||||
ok: handlerProbe.ok,
|
||||
detail: handlerProbe.output,
|
||||
});
|
||||
|
||||
// 5. hive-mind-cli responds to --help (prefer the trusted install pin).
|
||||
const cliPath = trustedPointerCliPath ?? 'hive-mind-cli';
|
||||
const spawnImpl = opts.spawnImpl ?? spawn;
|
||||
const probe = await probeCliVersion(cliPath, spawnImpl, 4000);
|
||||
const probe = runtimeBindingRequired && !runtimeBindingTrusted
|
||||
? { ok: false, output: 'skipped: packaged Node/CLI runtime binding is not trusted' }
|
||||
: await probeCliVersion(
|
||||
cliPath,
|
||||
trustedNodePath,
|
||||
spawnImpl,
|
||||
opts.cliProbeTimeoutMs ?? 4000,
|
||||
);
|
||||
checks.push({
|
||||
name: 'hive-mind-cli reachable',
|
||||
ok: probe.ok,
|
||||
detail: cliPathFromPointer ? `${probe.output} (pinned: ${cliPath})` : probe.output,
|
||||
detail: trustedPointerCliPath ? `${probe.output} (pinned: ${cliPath})` : probe.output,
|
||||
});
|
||||
|
||||
const ok = checks.every((c) => c.ok);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { copyFile, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createLogger } from '@waggle/hive-mind-shim-core';
|
||||
import type {
|
||||
CliBridge,
|
||||
@@ -268,4 +271,95 @@ describe('openclawHook default export — fail-open over the live bridge path',
|
||||
const event = { type: 'agent', action: 'bootstrap', context: { channelId: 'c', bootstrapFiles: [] } };
|
||||
await expect(openclawHook(event)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('recalls history through the loader-pinned CLI and Node runtime', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'hmocl-handler-runtime-'));
|
||||
const cliPath = join(root, 'fake-hive-mind-cli.mjs');
|
||||
const nodePath = join(root, 'waggle-node.exe');
|
||||
const markerPath = join(root, 'cli-calls.txt');
|
||||
const runtimePathReceipt = join(root, 'node-paths.txt');
|
||||
const previousCliPath = process.env['WAGGLE_HIVE_MIND_CLI'];
|
||||
await copyFile(process.execPath, nodePath);
|
||||
|
||||
await writeFile(
|
||||
cliPath,
|
||||
[
|
||||
`import { appendFileSync } from 'node:fs';`,
|
||||
`appendFileSync(${JSON.stringify(markerPath)}, process.argv.slice(2).join(' ') + '\\n');`,
|
||||
`appendFileSync(${JSON.stringify(runtimePathReceipt)}, process.execPath + '\\n');`,
|
||||
`const tool = process.argv[3];`,
|
||||
`const payload = tool === 'recall_memory' ? [{ id: 7, content: 'historical decision only', importance: 'important', source: 'openclaw', score: 1, created_at: '2026-07-26T10:00:00.000Z' }] : { id: 'runtime-pinned-1', workspace: 'personal' };`,
|
||||
`process.stdout.write(JSON.stringify({`,
|
||||
` ok: true,`,
|
||||
` tool,`,
|
||||
` content: [{`,
|
||||
` type: 'text',`,
|
||||
` text: JSON.stringify(payload),`,
|
||||
` }],`,
|
||||
`}));`,
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
delete process.env['WAGGLE_HIVE_MIND_CLI'];
|
||||
try {
|
||||
const { default: openclawHook } = await import('../src/handler.js');
|
||||
await openclawHook(
|
||||
{
|
||||
type: 'message',
|
||||
action: 'received',
|
||||
sessionKey: 'agent:main:runtime-channel',
|
||||
context: {
|
||||
channelId: 'runtime-channel',
|
||||
content: 'persist through the install-pinned CLI',
|
||||
cwd: root,
|
||||
},
|
||||
},
|
||||
{ cliPath, nodePath },
|
||||
);
|
||||
|
||||
const callsAfterMessage = (await readFile(markerPath, 'utf-8')).trim().split(/\r?\n/);
|
||||
expect(callsAfterMessage).toHaveLength(2);
|
||||
expect(callsAfterMessage[0]).toMatch(/^hook-call recall_memory /);
|
||||
expect(callsAfterMessage[1]).toMatch(/^hook-call save_memory /);
|
||||
const runtimePaths = (await readFile(runtimePathReceipt, 'utf-8'))
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.map((value) => value.toLowerCase());
|
||||
expect(runtimePaths).toEqual([
|
||||
nodePath.toLowerCase(),
|
||||
nodePath.toLowerCase(),
|
||||
]);
|
||||
expect(runtimePaths).not.toContain(process.execPath.toLowerCase());
|
||||
|
||||
const bootstrapFiles: unknown[] = [];
|
||||
await openclawHook(
|
||||
{
|
||||
type: 'agent',
|
||||
action: 'bootstrap',
|
||||
sessionKey: 'agent:main:runtime-channel',
|
||||
context: {
|
||||
channelId: 'runtime-channel',
|
||||
bootstrapFiles,
|
||||
},
|
||||
},
|
||||
{ cliPath, nodePath },
|
||||
);
|
||||
|
||||
const callsAfterBootstrap = (await readFile(markerPath, 'utf-8')).trim().split(/\r?\n/);
|
||||
expect(callsAfterBootstrap).toHaveLength(2);
|
||||
expect(bootstrapFiles).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'HIVE_MIND_RECALL.md',
|
||||
content: expect.stringContaining('historical decision only'),
|
||||
}),
|
||||
]);
|
||||
expect(JSON.stringify(bootstrapFiles)).not.toContain('persist through the install-pinned CLI');
|
||||
} finally {
|
||||
if (previousCliPath === undefined) delete process.env['WAGGLE_HIVE_MIND_CLI'];
|
||||
else process.env['WAGGLE_HIVE_MIND_CLI'] = previousCliPath;
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ 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. */
|
||||
/** A fake compiled CommonJS handler bundle the installer COPIES into the managed hook dir. */
|
||||
handlerSource: string;
|
||||
configPath: string;
|
||||
pointerPath: string;
|
||||
@@ -30,8 +30,8 @@ async function bootstrap(initial: string | undefined): Promise<TestEnv> {
|
||||
// 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');
|
||||
const handlerSource = join(distDir, 'handler.bundle.cjs');
|
||||
await writeFile(handlerSource, 'module.exports = async () => {};\n', 'utf-8');
|
||||
return {
|
||||
home,
|
||||
handlerSource,
|
||||
@@ -146,15 +146,20 @@ describe('install (openclaw)', () => {
|
||||
|
||||
// ── 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 () => {
|
||||
it('writes a host-discoverable loader plus a byte-identical .cjs bundle', 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);
|
||||
expect(existsSync(join(env.hiveHookDir, 'handler.cjs'))).toBe(true);
|
||||
expect(await readFile(join(env.hiveHookDir, 'handler.cjs'), 'utf-8')).toBe(handlerBytes);
|
||||
expect(await readFile(join(env.hiveHookDir, 'handler.js'), 'utf-8')).toContain("require('./handler.cjs')");
|
||||
expect(JSON.parse(await readFile(join(env.hiveHookDir, 'package.json'), 'utf-8'))).toMatchObject({
|
||||
type: 'commonjs',
|
||||
private: true,
|
||||
});
|
||||
// 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');
|
||||
@@ -168,8 +173,9 @@ describe('install (openclaw)', () => {
|
||||
// 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 () => {
|
||||
it('loads below a type:module ancestor and runs with NODE_PATH empty', async () => {
|
||||
env = await bootstrap(undefined);
|
||||
await writeFile(join(env.home, 'package.json'), '{"type":"module"}\n', 'utf-8');
|
||||
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], {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
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 { copyFile, mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import type { ChildProcess, SpawnOptions } from 'node:child_process';
|
||||
import { install } from '../src/install.js';
|
||||
import { createRecallBootstrapFile } from '../src/handler.js';
|
||||
import { verify } from '../src/verify.js';
|
||||
|
||||
interface TestEnv {
|
||||
@@ -24,8 +27,8 @@ async function bootstrap(initial: string | undefined): Promise<TestEnv> {
|
||||
}
|
||||
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');
|
||||
const handlerSource = join(distDir, 'handler.bundle.cjs');
|
||||
await writeFile(handlerSource, 'module.exports = async () => {};\n', 'utf-8');
|
||||
return { home, handlerSource, configPath };
|
||||
}
|
||||
|
||||
@@ -78,6 +81,7 @@ describe('verify (openclaw)', () => {
|
||||
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 writeFile(join(env.home, 'package.json'), '{"type":"module"}\n', 'utf-8');
|
||||
await install({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
const result = await verify({
|
||||
home: env.home,
|
||||
@@ -90,9 +94,80 @@ describe('verify (openclaw)', () => {
|
||||
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 === 'handler.cjs readable on disk')?.ok).toBe(true);
|
||||
expect(result.checks.find((c) => c.name === 'handler.cjs matches trusted bundle')?.ok).toBe(true);
|
||||
expect(result.checks.find((c) => c.name === 'handler.js matches managed loader')?.ok).toBe(true);
|
||||
expect(result.checks.find((c) => c.name === 'hook package locks CommonJS mode')?.ok).toBe(true);
|
||||
expect(result.checks.find((c) => c.name === 'installed handler runtime-loads')?.ok).toBe(true);
|
||||
expect(result.checks.find((c) => c.name === 'hive-mind-cli reachable')?.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a tampered .cjs bundle without executing its side effect', async () => {
|
||||
const env = await bootstrap('{ "hooks": {} }');
|
||||
envs.push(env);
|
||||
await install({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
const sideEffectPath = join(env.home, 'tampered-handler-executed.txt');
|
||||
await writeFile(
|
||||
join(env.home, '.openclaw', 'hooks', 'hive-mind', 'handler.cjs'),
|
||||
[
|
||||
"const { writeFileSync } = require('node:fs');",
|
||||
`writeFileSync(${JSON.stringify(sideEffectPath)}, 'executed');`,
|
||||
'module.exports = async () => {};',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
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.cjs matches trusted bundle')?.ok).toBe(false);
|
||||
const runtimeCheck = result.checks.find((c) => c.name === 'installed handler runtime-loads');
|
||||
expect(runtimeCheck?.ok).toBe(false);
|
||||
expect(runtimeCheck?.detail).toContain('skipped');
|
||||
expect(existsSync(sideEffectPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('runtime-loads trusted code without forwarding provider API secrets', async () => {
|
||||
const env = await bootstrap('{ "hooks": {} }');
|
||||
envs.push(env);
|
||||
const receiptPath = join(env.home, 'runtime-env.json');
|
||||
await writeFile(env.handlerSource, [
|
||||
"const { writeFileSync } = require('node:fs');",
|
||||
'module.exports = async () => {',
|
||||
` writeFileSync(${JSON.stringify(receiptPath)}, JSON.stringify({`,
|
||||
' openrouter: process.env.OPENROUTER_API_KEY ?? null,',
|
||||
' anthropic: process.env.ANTHROPIC_API_KEY ?? null,',
|
||||
' }));',
|
||||
'};',
|
||||
'',
|
||||
].join('\n'), 'utf-8');
|
||||
await install({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
const previousOpenRouter = process.env['OPENROUTER_API_KEY'];
|
||||
const previousAnthropic = process.env['ANTHROPIC_API_KEY'];
|
||||
process.env['OPENROUTER_API_KEY'] = 'must-not-reach-runtime-probe';
|
||||
process.env['ANTHROPIC_API_KEY'] = 'must-not-reach-runtime-probe';
|
||||
try {
|
||||
const result = await verify({
|
||||
home: env.home,
|
||||
handlerSourcePath: env.handlerSource,
|
||||
spawnImpl: mockSpawnImpl({ exitCode: 0 }),
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
expect(JSON.parse(await readFile(receiptPath, 'utf-8'))).toEqual({
|
||||
openrouter: null,
|
||||
anthropic: null,
|
||||
});
|
||||
} finally {
|
||||
if (previousOpenRouter === undefined) delete process.env['OPENROUTER_API_KEY'];
|
||||
else process.env['OPENROUTER_API_KEY'] = previousOpenRouter;
|
||||
if (previousAnthropic === undefined) delete process.env['ANTHROPIC_API_KEY'];
|
||||
else process.env['ANTHROPIC_API_KEY'] = previousAnthropic;
|
||||
}
|
||||
});
|
||||
|
||||
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 } } } } }';
|
||||
@@ -147,24 +222,407 @@ describe('verify (openclaw)', () => {
|
||||
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 });
|
||||
const records: Array<{
|
||||
command: string;
|
||||
args: readonly string[];
|
||||
options?: { env?: NodeJS.ProcessEnv; windowsHide?: boolean };
|
||||
}> = [];
|
||||
const recordingSpawn = ((cmd: string, args: readonly string[], options?: SpawnOptions) => {
|
||||
records.push({
|
||||
command: cmd,
|
||||
args,
|
||||
options,
|
||||
});
|
||||
return mockSpawnImpl({ exitCode: 0 })(cmd, args);
|
||||
}) as typeof import('node:child_process').spawn;
|
||||
}) as unknown as typeof import('node:child_process').spawn;
|
||||
|
||||
const previousOpenRouter = process.env['OPENROUTER_API_KEY'];
|
||||
const previousAnthropic = process.env['ANTHROPIC_API_KEY'];
|
||||
process.env['OPENROUTER_API_KEY'] = 'must-not-reach-cli-probe';
|
||||
process.env['ANTHROPIC_API_KEY'] = 'must-not-reach-cli-probe';
|
||||
try {
|
||||
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');
|
||||
expect(probeRecord.options?.env?.['OPENROUTER_API_KEY']).toBeUndefined();
|
||||
expect(probeRecord.options?.env?.['ANTHROPIC_API_KEY']).toBeUndefined();
|
||||
expect(probeRecord.options?.windowsHide).toBe(true);
|
||||
// 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);
|
||||
} finally {
|
||||
if (previousOpenRouter === undefined) delete process.env['OPENROUTER_API_KEY'];
|
||||
else process.env['OPENROUTER_API_KEY'] = previousOpenRouter;
|
||||
if (previousAnthropic === undefined) delete process.env['ANTHROPIC_API_KEY'];
|
||||
else process.env['ANTHROPIC_API_KEY'] = previousAnthropic;
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the install-pinned Node runtime for a JavaScript CLI probe', async () => {
|
||||
const env = await bootstrap(undefined);
|
||||
envs.push(env);
|
||||
const cliPath = join(env.home, 'hive-mind-cli.js');
|
||||
const nodePath = join(env.home, 'waggle-node.exe');
|
||||
await writeFile(cliPath, 'console.log("hive-mind-cli");\n', 'utf-8');
|
||||
await copyFile(process.execPath, nodePath);
|
||||
await install({
|
||||
home: env.home,
|
||||
handlerSourcePath: env.handlerSource,
|
||||
cliPath,
|
||||
nodePath,
|
||||
});
|
||||
const pointer = JSON.parse(
|
||||
await readFile(join(env.home, '.openclaw', 'hive-mind-install.json'), 'utf-8'),
|
||||
) as Record<string, unknown>;
|
||||
const binding = (pointer['extra'] as Record<string, unknown>)['runtime_binding'] as Record<string, unknown>;
|
||||
expect(binding).toMatchObject({
|
||||
version: 1,
|
||||
node_path: nodePath,
|
||||
cli_path: cliPath,
|
||||
node_sha256: createHash('sha256').update(await readFile(nodePath)).digest('hex'),
|
||||
cli_sha256: createHash('sha256').update(await readFile(cliPath)).digest('hex'),
|
||||
});
|
||||
const config = JSON.parse(await readFile(env.configPath, 'utf-8')) as {
|
||||
hooks: { internal: { entries: { 'hive-mind': { env: Record<string, string> } } } };
|
||||
};
|
||||
expect(config.hooks.internal.entries['hive-mind'].env['WAGGLE_HOOK_NODE_PATH']).toBe(nodePath);
|
||||
|
||||
const probes: Array<{ command: string; args: readonly string[] }> = [];
|
||||
const recordingSpawn = ((command: string, args: readonly string[]) => {
|
||||
probes.push({ command, args });
|
||||
return mockSpawnImpl({ exitCode: 0 })(command, args);
|
||||
}) as unknown as typeof import('node:child_process').spawn;
|
||||
|
||||
const result = await verify({
|
||||
home: env.home,
|
||||
handlerSourcePath: env.handlerSource,
|
||||
nodePath,
|
||||
spawnImpl: recordingSpawn,
|
||||
});
|
||||
|
||||
expect(result.checks.find((c) => c.name === 'packaged Node matches verifier expectation')?.ok).toBe(true);
|
||||
expect(result.checks.find((c) => c.name === 'packaged Node matches install receipt')?.ok).toBe(true);
|
||||
expect(probes.at(-1)).toEqual({
|
||||
command: nodePath,
|
||||
args: [cliPath, '--help'],
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['node', 'cli'] as const)(
|
||||
'rejects changed pinned %s bytes without spawning the managed runtime',
|
||||
async (artifact) => {
|
||||
const env = await bootstrap(undefined);
|
||||
envs.push(env);
|
||||
const cliPath = join(env.home, 'hive-mind-cli.js');
|
||||
const nodePath = join(env.home, 'waggle-node.exe');
|
||||
await writeFile(cliPath, 'console.log("hive-mind-cli");\n', 'utf-8');
|
||||
await copyFile(process.execPath, nodePath);
|
||||
await install({
|
||||
home: env.home,
|
||||
handlerSourcePath: env.handlerSource,
|
||||
cliPath,
|
||||
nodePath,
|
||||
});
|
||||
await writeFile(
|
||||
artifact === 'node' ? nodePath : cliPath,
|
||||
`tampered-${artifact}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const probes: Array<{ command: string; args: readonly string[] }> = [];
|
||||
const recordingSpawn = ((command: string, args: readonly string[]) => {
|
||||
probes.push({ command, args });
|
||||
return mockSpawnImpl({ exitCode: 0 })(command, args);
|
||||
}) as unknown as typeof import('node:child_process').spawn;
|
||||
const result = await verify({
|
||||
home: env.home,
|
||||
handlerSourcePath: env.handlerSource,
|
||||
nodePath,
|
||||
spawnImpl: recordingSpawn,
|
||||
});
|
||||
|
||||
const digestCheck = artifact === 'node'
|
||||
? 'packaged Node matches install receipt'
|
||||
: 'packaged CLI matches install receipt';
|
||||
expect(result.checks.find((check) => check.name === digestCheck)?.ok).toBe(false);
|
||||
expect(result.checks.find((check) => check.name === 'installed handler runtime-loads')?.detail)
|
||||
.toContain('skipped');
|
||||
expect(result.checks.find((check) => check.name === 'hive-mind-cli reachable')?.detail)
|
||||
.toContain('skipped');
|
||||
expect(probes).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects a verifier CLI expectation that differs from the loader pin', async () => {
|
||||
const env = await bootstrap('{ "hooks": {} }');
|
||||
envs.push(env);
|
||||
const pinnedCliPath = '/abs/broken-pinned-cli.js';
|
||||
const overrideCliPath = '/abs/working-override-cli.js';
|
||||
await install({
|
||||
home: env.home,
|
||||
handlerSourcePath: env.handlerSource,
|
||||
cliPath: pinnedCliPath,
|
||||
});
|
||||
|
||||
const invokedPaths: string[] = [];
|
||||
const recordingSpawn = ((
|
||||
cmd: string,
|
||||
args: readonly string[],
|
||||
) => {
|
||||
const invokedPath = cmd === process.execPath ? args[0] : cmd;
|
||||
if (invokedPath !== undefined) invokedPaths.push(invokedPath);
|
||||
return mockSpawnImpl({
|
||||
exitCode: invokedPath === pinnedCliPath ? 127 : 0,
|
||||
stderr: invokedPath === pinnedCliPath ? 'not found' : '',
|
||||
})(cmd, args);
|
||||
}) as unknown as typeof import('node:child_process').spawn;
|
||||
|
||||
const result = await verify({
|
||||
home: env.home,
|
||||
handlerSourcePath: env.handlerSource,
|
||||
cliPath: overrideCliPath,
|
||||
spawnImpl: recordingSpawn,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.checks.find((c) => c.name === 'hive-mind-cli reachable')?.ok).toBe(false);
|
||||
expect(result.checks.find((c) => c.name === 'packaged CLI matches verifier expectation')?.ok)
|
||||
.toBe(false);
|
||||
expect(invokedPaths).not.toContain(pinnedCliPath);
|
||||
expect(invokedPaths).not.toContain(overrideCliPath);
|
||||
});
|
||||
|
||||
it('rejects managed verification when the loader has no runtime pin', async () => {
|
||||
const env = await bootstrap('{ "hooks": {} }');
|
||||
envs.push(env);
|
||||
const overrideCliPath = '/abs/working-override-cli.js';
|
||||
await install({
|
||||
home: env.home,
|
||||
handlerSourcePath: env.handlerSource,
|
||||
});
|
||||
|
||||
const invokedPaths: string[] = [];
|
||||
const recordingSpawn = ((
|
||||
cmd: string,
|
||||
args: readonly string[],
|
||||
) => {
|
||||
const invokedPath = cmd === process.execPath ? args[0] : cmd;
|
||||
if (invokedPath !== undefined) invokedPaths.push(invokedPath);
|
||||
return mockSpawnImpl({
|
||||
exitCode: invokedPath === 'hive-mind-cli' ? 127 : 0,
|
||||
stderr: invokedPath === 'hive-mind-cli' ? 'not found' : '',
|
||||
})(cmd, args);
|
||||
}) as unknown as typeof import('node:child_process').spawn;
|
||||
|
||||
const result = await verify({
|
||||
home: env.home,
|
||||
handlerSourcePath: env.handlerSource,
|
||||
cliPath: overrideCliPath,
|
||||
spawnImpl: recordingSpawn,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.checks.find((c) => c.name === 'hive-mind-cli reachable')?.ok).toBe(false);
|
||||
expect(result.checks.find((c) => c.name === 'packaged CLI matches verifier expectation')?.ok)
|
||||
.toBe(false);
|
||||
expect(invokedPaths).not.toContain('hive-mind-cli');
|
||||
expect(invokedPaths).not.toContain(overrideCliPath);
|
||||
});
|
||||
|
||||
it('rejects coordinated CLI substitution against the verifier expectation', async () => {
|
||||
const env = await bootstrap('{ "hooks": {} }');
|
||||
envs.push(env);
|
||||
const trustedCliPath = join(env.home, 'trusted-hive-mind-cli.js');
|
||||
const attackerCliPath = join(env.home, 'substituted-hive-mind-cli.js');
|
||||
const nodePath = join(env.home, 'waggle-node.exe');
|
||||
await writeFile(trustedCliPath, 'console.log("trusted");\n', 'utf-8');
|
||||
await writeFile(attackerCliPath, 'console.log("substituted");\n', 'utf-8');
|
||||
await copyFile(process.execPath, nodePath);
|
||||
await install({
|
||||
home: env.home,
|
||||
handlerSourcePath: env.handlerSource,
|
||||
cliPath: trustedCliPath,
|
||||
nodePath,
|
||||
});
|
||||
|
||||
const pointerPath = join(env.home, '.openclaw', 'hive-mind-install.json');
|
||||
const pointer = JSON.parse(await readFile(pointerPath, 'utf-8')) as {
|
||||
cli_path: string;
|
||||
extra: { runtime_binding: Record<string, unknown> };
|
||||
};
|
||||
pointer.cli_path = attackerCliPath;
|
||||
pointer.extra.runtime_binding['cli_path'] = attackerCliPath;
|
||||
pointer.extra.runtime_binding['cli_sha256'] = createHash('sha256')
|
||||
.update(await readFile(attackerCliPath))
|
||||
.digest('hex');
|
||||
await writeFile(pointerPath, `${JSON.stringify(pointer, null, 2)}\n`, 'utf-8');
|
||||
|
||||
const config = JSON.parse(await readFile(env.configPath, 'utf-8')) as {
|
||||
hooks: { internal: { entries: { 'hive-mind': { env: Record<string, string> } } } };
|
||||
};
|
||||
config.hooks.internal.entries['hive-mind'].env['WAGGLE_HIVE_MIND_CLI'] = attackerCliPath;
|
||||
await writeFile(env.configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf-8');
|
||||
const handlerPath = join(env.home, '.openclaw', 'hooks', 'hive-mind', 'handler.js');
|
||||
await writeFile(
|
||||
handlerPath,
|
||||
[
|
||||
`'use strict';`,
|
||||
`const handler = require('./handler.cjs');`,
|
||||
`module.exports = (event) => handler(event, ${JSON.stringify({
|
||||
cliPath: attackerCliPath,
|
||||
nodePath,
|
||||
})});`,
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const probes: string[] = [];
|
||||
const recordingSpawn = ((command: string) => {
|
||||
probes.push(command);
|
||||
return mockSpawnImpl({ exitCode: 0 })(command, []);
|
||||
}) as unknown as typeof import('node:child_process').spawn;
|
||||
const result = await verify({
|
||||
home: env.home,
|
||||
handlerSourcePath: env.handlerSource,
|
||||
cliPath: trustedCliPath,
|
||||
nodePath,
|
||||
spawnImpl: recordingSpawn,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.checks.find((c) => c.name === 'packaged CLI matches verifier expectation')?.ok)
|
||||
.toBe(false);
|
||||
expect(probes).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects a stripped managed binding instead of downgrading to legacy verification', async () => {
|
||||
const env = await bootstrap('{ "hooks": {} }');
|
||||
envs.push(env);
|
||||
const cliPath = join(env.home, 'hive-mind-cli.js');
|
||||
const nodePath = join(env.home, 'waggle-node.exe');
|
||||
await writeFile(cliPath, 'console.log("trusted");\n', 'utf-8');
|
||||
await copyFile(process.execPath, nodePath);
|
||||
await install({
|
||||
home: env.home,
|
||||
handlerSourcePath: env.handlerSource,
|
||||
cliPath,
|
||||
nodePath,
|
||||
});
|
||||
|
||||
const pointerPath = join(env.home, '.openclaw', 'hive-mind-install.json');
|
||||
const pointer = JSON.parse(await readFile(pointerPath, 'utf-8')) as Record<string, unknown>;
|
||||
delete pointer['cli_path'];
|
||||
const extra = pointer['extra'] as Record<string, unknown>;
|
||||
delete extra['runtime_binding'];
|
||||
await writeFile(pointerPath, `${JSON.stringify(pointer, null, 2)}\n`, 'utf-8');
|
||||
const config = JSON.parse(await readFile(env.configPath, 'utf-8')) as {
|
||||
hooks: { internal: { entries: { 'hive-mind': { env: Record<string, string> } } } };
|
||||
};
|
||||
delete config.hooks.internal.entries['hive-mind'].env['WAGGLE_HIVE_MIND_CLI'];
|
||||
delete config.hooks.internal.entries['hive-mind'].env['WAGGLE_HOOK_NODE_PATH'];
|
||||
await writeFile(env.configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf-8');
|
||||
const handlerPath = join(env.home, '.openclaw', 'hooks', 'hive-mind', 'handler.js');
|
||||
await writeFile(
|
||||
handlerPath,
|
||||
"'use strict';\nmodule.exports = require('./handler.cjs');\n",
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const probes: string[] = [];
|
||||
const recordingSpawn = ((command: string) => {
|
||||
probes.push(command);
|
||||
return mockSpawnImpl({ exitCode: 0 })(command, []);
|
||||
}) as unknown as typeof import('node:child_process').spawn;
|
||||
const result = await verify({
|
||||
home: env.home,
|
||||
handlerSourcePath: env.handlerSource,
|
||||
cliPath,
|
||||
requireManagedRuntime: true,
|
||||
spawnImpl: recordingSpawn,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.checks.find((c) => c.name === 'packaged CLI matches verifier expectation')?.ok)
|
||||
.toBe(false);
|
||||
expect(probes).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects a tampered pointer cli_path without spawning it', async () => {
|
||||
const env = await bootstrap('{ "hooks": {} }');
|
||||
envs.push(env);
|
||||
const trustedCliPath = '/abs/trusted-cli.js';
|
||||
await install({ home: env.home, handlerSourcePath: env.handlerSource, cliPath: trustedCliPath });
|
||||
|
||||
const pointerPath = join(env.home, '.openclaw', 'hive-mind-install.json');
|
||||
const pointer = JSON.parse(await readFile(pointerPath, 'utf-8')) as Record<string, unknown>;
|
||||
const tamperedCliPath = join(env.home, 'tampered-cli.js');
|
||||
pointer['cli_path'] = tamperedCliPath;
|
||||
await writeFile(pointerPath, `${JSON.stringify(pointer, null, 2)}\n`, 'utf-8');
|
||||
|
||||
const spawned: Array<{ command: string; args: readonly string[] }> = [];
|
||||
const recordingSpawn = ((command: string, args: readonly string[], _options?: SpawnOptions) => {
|
||||
spawned.push({ command, args });
|
||||
return mockSpawnImpl({ exitCode: 0 })(command, args);
|
||||
}) as unknown 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);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.checks.find((c) => c.name === 'install pointer cli_path matches managed config')?.ok).toBe(false);
|
||||
expect(spawned.some(({ command, args }) => command === tamperedCliPath || args.includes(tamperedCliPath))).toBe(false);
|
||||
});
|
||||
|
||||
it('escalates a timed-out CLI probe to SIGKILL before returning', async () => {
|
||||
const env = await bootstrap('{ "hooks": {} }');
|
||||
envs.push(env);
|
||||
await install({ home: env.home, handlerSourcePath: env.handlerSource });
|
||||
|
||||
const signals: NodeJS.Signals[] = [];
|
||||
const hangingSpawn = (() => {
|
||||
const emitter = new EventEmitter();
|
||||
const child = Object.assign(emitter, {
|
||||
stdout: Readable.from([]),
|
||||
stderr: Readable.from([]),
|
||||
kill: vi.fn((signal: NodeJS.Signals) => {
|
||||
signals.push(signal);
|
||||
if (signal === 'SIGKILL') setImmediate(() => emitter.emit('exit', null, 'SIGKILL'));
|
||||
return true;
|
||||
}),
|
||||
}) as unknown as ChildProcess;
|
||||
return child;
|
||||
}) as unknown as typeof import('node:child_process').spawn;
|
||||
|
||||
const result = await verify({
|
||||
home: env.home,
|
||||
handlerSourcePath: env.handlerSource,
|
||||
spawnImpl: hangingSpawn,
|
||||
cliProbeTimeoutMs: 10,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(signals).toEqual(['SIGTERM', 'SIGKILL']);
|
||||
expect(result.checks.find((c) => c.name === 'hive-mind-cli reachable')?.detail).toContain('timed out');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenClaw 2026.6.11 bootstrap file contract', () => {
|
||||
it('preserves recalled text in a path/name/content object accepted by the host sanitizer', () => {
|
||||
const recalled = 'hive-mind: recalled exact text';
|
||||
expect(createRecallBootstrapFile(recalled)).toEqual({
|
||||
path: 'HIVE_MIND_RECALL.md',
|
||||
name: 'HIVE_MIND_RECALL.md',
|
||||
content: recalled,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user