This commit is contained in:
Oleg Maslov
2026-09-02 10:14:22 +02:00
parent 0c3e2ead3b
commit b20b138fe4
771 changed files with 161561 additions and 9027 deletions

View File

@@ -1,8 +1,12 @@
import { EventEmitter } from 'node:events';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { BUILTIN_TOOL_MANIFESTS, type ToolManifest } from '@waggle/shared';
import {
buildExternalToolEnv,
resolveWindowsTaskkillPath,
runExternalTool,
type ExternalRunEvent,
type ExternalProcessHandle,
@@ -24,9 +28,16 @@ class FakeStdin {
class FakeChild extends EventEmitter implements ExternalProcessHandle {
pid = 4321;
exitCode: number | null = null;
signalCode: NodeJS.Signals | null = null;
killSignals: Array<NodeJS.Signals | number | undefined> = [];
stdout = new FakeStream();
stderr = new FakeStream();
stdin = new FakeStdin();
kill(signal?: NodeJS.Signals | number) {
this.killSignals.push(signal);
return true;
}
override once(event: 'error' | 'exit', cb: (...args: never[]) => void): this {
return super.once(event, cb);
}
@@ -80,18 +91,101 @@ describe('runExternalTool', () => {
const result = await promise;
expect(captured?.args).toEqual([
'-p', '--safe-mode', '--disable-slash-commands', '--no-session-persistence',
'--max-budget-usd', '0.25', '--input-format', 'text', '--output-format',
'-p', '--safe-mode', '--disable-slash-commands',
'--max-budget-usd', '1.00', '--input-format', 'text', '--output-format',
'stream-json', '--verbose', '--permission-mode', 'plan',
]);
expect(captured?.args).not.toContain('--no-session-persistence');
expect(child.stdin.value).toBe(baseRequest('claude-code').prompt);
expect(result).toMatchObject({ status: 'completed', summary: 'Claude finished', sessionId: 'claude-session' });
expect(events).toContain('tool');
expect(events.at(-1)).toBe('completed');
expect(captured?.env.SUPER_SECRET).toBeUndefined();
expect(captured?.env.ANTHROPIC_API_KEY).toBeUndefined();
expect(captured?.env.WAGGLE_RUN_ID).toBe('run-1');
});
it('retains Claude assistant text while surfacing a zero-exit budget failure', async () => {
const child = new FakeChild();
const events: ExternalRunEvent[] = [];
const promise = runExternalTool({
...baseRequest('claude-code'),
onEvent: (event) => events.push(event),
}, {
resolveWorkspacePath: () => '/workspace',
spawnProcess: () => {
queueMicrotask(() => {
child.stdout.emit('data', '{"type":"system","session_id":"claude-budget-session"}\n');
child.stdout.emit('data', '{"type":"assistant","message":{"content":[{"type":"text","text":"OK"}]}}\n');
child.stdout.emit('data', '{"type":"result","subtype":"error_max_budget_usd","is_error":false}\n');
child.emit('exit', 0);
});
return child;
},
});
const result = await promise;
expect(result).toMatchObject({
status: 'failed',
summary: 'OK',
error: 'error_max_budget_usd',
sessionId: 'claude-budget-session',
});
expect(result.summary).not.toContain('"type":"system"');
expect(events.at(-1)).toMatchObject({ type: 'failed', text: 'error_max_budget_usd' });
});
it('fails an empty Claude structured result without exposing protocol JSON', async () => {
const child = new FakeChild();
const promise = runExternalTool(baseRequest('claude-code'), {
resolveWorkspacePath: () => '/workspace',
spawnProcess: () => {
queueMicrotask(() => {
child.stdout.emit('data', '{"type":"system","session_id":"empty-session"}\n');
child.stdout.emit('data', '{"type":"result","subtype":"success","is_error":false}\n');
child.emit('exit', 0);
});
return child;
},
});
const result = await promise;
expect(result).toMatchObject({
status: 'failed',
summary: 'Claude Code completed without a final response',
error: 'Claude Code completed without a final response',
sessionId: 'empty-session',
});
expect(result.summary).not.toContain('"type":"system"');
expect(result.stdoutTail).toContain('"type":"system"');
});
it('resumes the persisted Claude session without weakening safe mode', async () => {
const child = new FakeChild();
let args: string[] = [];
const promise = runExternalTool({
...baseRequest('claude-code'),
sessionId: 'claude-session',
}, {
resolveWorkspacePath: () => '/workspace',
spawnProcess: (_binary, value) => {
args = value;
queueMicrotask(() => {
child.stdout.emit('data', '{"type":"result","result":"Resumed","is_error":false}\n');
child.emit('exit', 0);
});
return child;
},
});
await expect(promise).resolves.toMatchObject({ status: 'completed', summary: 'Resumed' });
expect(args).toEqual([
'-p', '--safe-mode', '--disable-slash-commands', '--resume', 'claude-session',
'--max-budget-usd', '1.00', '--input-format', 'text', '--output-format',
'stream-json', '--verbose', '--permission-mode', 'plan',
]);
});
it('runs Codex through exec with an explicit workspace sandbox', async () => {
const child = new FakeChild();
let args: string[] = [];
@@ -113,13 +207,42 @@ describe('runExternalTool', () => {
const result = await promise;
expect(args).toEqual([
'--ask-for-approval', 'never', '--sandbox', 'workspace-write', 'exec',
'--ignore-user-config', '--ignore-rules', '--ephemeral', '--skip-git-repo-check',
'--ignore-user-config', '--ignore-rules', '--skip-git-repo-check',
'--json', '--color', 'never', '-C', 'C:\\workspace', '-',
]);
expect(args).not.toContain('--ephemeral');
expect(child.stdin.value).toBe(baseRequest('codex').prompt);
expect(result).toMatchObject({ status: 'completed', summary: 'Codex finished', sessionId: 'codex-session' });
});
it('resumes Codex with exec-level flags before the resume subcommand', async () => {
const child = new FakeChild();
let args: string[] = [];
const promise = runExternalTool({
...baseRequest('codex'),
sessionId: '00000000-0000-0000-0000-000000000000',
}, {
resolveWorkspacePath: () => 'C:\\workspace',
spawnProcess: (_binary, value) => {
args = value;
queueMicrotask(() => {
child.stdout.emit('data', '{"type":"item.completed","item":{"type":"agent_message","text":"Codex resumed"}}\n');
child.emit('exit', 0);
});
return child;
},
});
await expect(promise).resolves.toMatchObject({ status: 'completed', summary: 'Codex resumed' });
expect(args).toEqual([
'--ask-for-approval', 'never', '--sandbox', 'read-only', 'exec',
'--ignore-user-config', '--ignore-rules', '--skip-git-repo-check',
'--color', 'never', '-C', 'C:\\workspace', 'resume', '--json',
'00000000-0000-0000-0000-000000000000', '-',
]);
expect(args).not.toContain('--ephemeral');
});
it('uses Hermes quiet query mode without unsafe yolo/oneshot flags', async () => {
const child = new FakeChild();
let args: string[] = [];
@@ -146,6 +269,61 @@ describe('runExternalTool', () => {
expect(result).toMatchObject({ status: 'completed', summary: 'Hermes finished', sessionId: 'hermes-session' });
});
it.runIf(process.platform === 'win32')('fails closed before a Hermes batch shim can reparse its prompt', async () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-hermes-unsafe-batch-'));
const batch = path.join(directory, 'hermes.cmd');
const marker = path.join(directory, 'injected.txt');
fs.writeFileSync(batch, '@echo off\r\necho Hermes finished\r\n');
try {
const result = await runExternalTool({
...baseRequest('hermes'),
binary: batch,
workspacePath: directory,
prompt: `safe" & echo injected>${marker} & rem`,
access: 'native',
}, { platform: 'win32' });
expect(result).toMatchObject({
status: 'failed',
summary: expect.stringContaining('UNSAFE_WINDOWS_BATCH_SHIM'),
});
expect(fs.existsSync(marker)).toBe(false);
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
});
it('keeps resumed Hermes sessions in the newly assigned workspace', async () => {
const child = new FakeChild();
let args: string[] = [];
let cwd = '';
const prompt = baseRequest('hermes').prompt;
const promise = runExternalTool({
...baseRequest('hermes'),
access: 'native',
sessionId: '20260801_resume',
}, {
resolveWorkspacePath: () => 'C:\\assigned-workspace',
spawnProcess: (_binary, value, options) => {
args = value;
cwd = options.cwd;
queueMicrotask(() => {
child.stdout.emit('data', 'Hermes resumed\n');
child.emit('exit', 0);
});
return child;
},
});
await expect(promise).resolves.toMatchObject({ status: 'completed', summary: 'Hermes resumed' });
expect(cwd).toBe('C:\\assigned-workspace');
expect(args).toEqual([
'chat', '--resume', '20260801_resume', '--no-restore-cwd', '-q', prompt,
'-Q', '--source', 'tool', '--ignore-rules', '--max-turns', '12', '--checkpoints',
]);
});
it('keeps Hermes reasoning as progress and parses its stderr session trailer', async () => {
const child = new FakeChild();
const events: Array<{ type: string; text?: string }> = [];
@@ -273,6 +451,130 @@ describe('runExternalTool', () => {
expect(result.status).toBe('cancelled');
});
it('does not kill a process that exited before a queued abort is handled', async () => {
const child = new FakeChild();
const controller = new AbortController();
let treeKills = 0;
const promise = runExternalTool({
...baseRequest('hermes'),
access: 'native',
signal: controller.signal,
}, {
resolveWorkspacePath: () => '/workspace',
spawnProcess: () => {
queueMicrotask(() => {
child.exitCode = 0;
controller.abort();
});
return child;
},
killTree: async () => { treeKills += 1; },
});
await expect(promise).resolves.toMatchObject({ status: 'completed', exitCode: 0 });
expect(treeKills).toBe(0);
expect(child.killSignals).toEqual([]);
});
it('recovers a spawn/listener abort race when tree cleanup rejects', async () => {
vi.useFakeTimers();
const child = new FakeChild();
const controller = new AbortController();
const cleanup = vi.fn();
const events: ExternalRunEvent[] = [];
const promise = runExternalTool({
...baseRequest('openclaw'),
access: 'native',
managedAgentId: 'waggle-workspace-1',
signal: controller.signal,
onEvent: (event) => events.push(event),
}, {
resolveWorkspacePath: () => '/workspace',
createPromptFile: () => ({ path: '/tmp/prompt.txt', cleanup }),
spawnProcess: () => {
controller.abort();
return child;
},
killTree: async () => { throw new Error('tree cleanup failed'); },
});
await vi.advanceTimersByTimeAsync(0);
expect(child.killSignals).toEqual(['SIGKILL']);
let settled = false;
void promise.then(() => { settled = true; });
await vi.advanceTimersByTimeAsync(1_999);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await expect(promise).resolves.toMatchObject({
status: 'cancelled',
stderrTail: expect.stringContaining('tree cleanup failed'),
});
expect(cleanup).toHaveBeenCalledTimes(1);
expect(events.filter((event) => event.type === 'cancelled')).toHaveLength(1);
expect(vi.getTimerCount()).toBe(0);
});
it('bounds a hung tree cleanup and a child that never exits', async () => {
vi.useFakeTimers();
const child = new FakeChild();
const controller = new AbortController();
let treeKills = 0;
const promise = runExternalTool({
...baseRequest('claude-code'),
timeoutMs: 120_000,
signal: controller.signal,
}, {
resolveWorkspacePath: () => '/workspace',
spawnProcess: () => child,
killTree: () => {
treeKills += 1;
return new Promise<void>(() => {});
},
});
controller.abort();
let settled = false;
void promise.then(() => { settled = true; });
await vi.advanceTimersByTimeAsync(4_999);
expect(settled).toBe(false);
expect(child.killSignals).toEqual([]);
await vi.advanceTimersByTimeAsync(1);
expect(treeKills).toBe(1);
expect(child.killSignals).toEqual(['SIGKILL']);
await vi.advanceTimersByTimeAsync(1_999);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await expect(promise).resolves.toMatchObject({ status: 'cancelled' });
expect(vi.getTimerCount()).toBe(0);
});
it('preserves a timeout that wins before a later abort', async () => {
vi.useFakeTimers();
const child = new FakeChild();
const controller = new AbortController();
let treeKills = 0;
const promise = runExternalTool({
...baseRequest('codex'),
timeoutMs: 1_000,
signal: controller.signal,
}, {
resolveWorkspacePath: () => '/workspace',
spawnProcess: () => child,
killTree: async () => { treeKills += 1; },
});
await vi.advanceTimersByTimeAsync(1_000);
controller.abort();
await vi.advanceTimersByTimeAsync(2_000);
await expect(promise).resolves.toMatchObject({ status: 'timed_out' });
expect(treeKills).toBe(1);
expect(child.killSignals).toEqual([]);
expect(vi.getTimerCount()).toBe(0);
});
it('emits one stall per quiet episode, recovers on output, and clears its watchdog on exit', async () => {
vi.useFakeTimers();
vi.setSystemTime(0);
@@ -396,9 +698,30 @@ describe('runExternalTool', () => {
});
describe('external adapter safety', () => {
it('passes only an explicit environment allowlist plus run identity', () => {
it('resolves taskkill from an absolute Windows system directory', () => {
expect(resolveWindowsTaskkillPath({ SystemRoot: 'C:\\Windows' }))
.toBe('C:\\Windows\\System32\\taskkill.exe');
expect(resolveWindowsTaskkillPath({ WINDIR: 'D:\\WinNT' }))
.toBe('D:\\WinNT\\System32\\taskkill.exe');
expect(resolveWindowsTaskkillPath({ SystemRoot: 'relative\\windows' }))
.toBe('C:\\Windows\\System32\\taskkill.exe');
});
it('passes OS context and explicit run identity but no ambient secrets', () => {
const env = buildExternalToolEnv(
{ PATH: '/bin', OPENAI_API_KEY: 'allowed-provider-key', DATABASE_URL: 'must-not-pass' },
{
PATH: 'C:\\Windows\\System32', USERPROFILE: 'C:\\Users\\tester',
APPDATA: 'C:\\Users\\tester\\AppData\\Roaming',
LOCALAPPDATA: 'C:\\Redirected\\Local', HERMES_HOME: 'D:\\Hermes Data',
TERM: 'xterm-256color',
ANTHROPIC_API_KEY: 'anthropic-secret', OPENAI_API_KEY: 'openai-secret',
OPENROUTER_API_KEY: 'openrouter-secret', GOOGLE_API_KEY: 'google-secret',
GEMINI_API_KEY: 'gemini-secret', XAI_API_KEY: 'xai-secret',
STRIPE_SECRET_KEY: 'stripe-secret', AWS_SECRET_ACCESS_KEY: 'aws-secret',
DATABASE_URL: 'database-secret', SSH_AUTH_SOCK: 'credential-socket',
GIT_ASKPASS: 'credential-helper', HTTPS_PROXY: 'https://user:secret@proxy.invalid',
NODE_OPTIONS: '--require C:\\malicious.js', WAGGLE_RUN_TOKEN: 'stale-token',
},
{
runId: 'run', roomId: 'room', workspaceId: 'workspace',
dance: {
@@ -408,16 +731,38 @@ describe('external adapter safety', () => {
dataDir: '/waggle-data',
},
'/workspace',
'win32',
);
expect(env.PATH).toBe('/bin');
expect(env.OPENAI_API_KEY).toBe('allowed-provider-key');
expect(env.DATABASE_URL).toBeUndefined();
expect(env).toMatchObject({
PATH: 'C:\\Windows\\System32',
USERPROFILE: 'C:\\Users\\tester',
APPDATA: 'C:\\Users\\tester\\AppData\\Roaming',
LOCALAPPDATA: 'C:\\Redirected\\Local',
HERMES_HOME: 'D:\\Hermes Data',
TERM: 'xterm-256color',
});
for (const name of [
'ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'OPENROUTER_API_KEY',
'GOOGLE_API_KEY', 'GEMINI_API_KEY', 'XAI_API_KEY',
'STRIPE_SECRET_KEY', 'AWS_SECRET_ACCESS_KEY', 'DATABASE_URL',
'SSH_AUTH_SOCK', 'GIT_ASKPASS', 'HTTPS_PROXY', 'NODE_OPTIONS',
]) {
expect(env[name], name).toBeUndefined();
}
expect(env.WAGGLE_DANCE_TEAM_ID).toBe('room::room');
expect(env.WAGGLE_DANCE_URL).toBe('http://127.0.0.1:3333');
expect(env.WAGGLE_RUN_TOKEN).toBe('run-token-123456789012345678901234');
expect(env.WAGGLE_CLI_NODE_PATH).toBe('/runtime/node');
expect(env.WAGGLE_CLI_ENTRY).toBe('/runtime/hive-mind-cli.js');
expect(env.HIVE_MIND_DATA_DIR).toBe('/waggle-data');
const withoutDance = buildExternalToolEnv(
{ WAGGLE_RUN_TOKEN: 'stale-ambient-token' },
{ runId: 'run', roomId: 'room', workspaceId: 'workspace' },
'/workspace',
'win32',
);
expect(withoutDance.WAGGLE_RUN_TOKEN).toBeUndefined();
});
it('loads only data-only generic task specs with known placeholders', () => {