797 lines
29 KiB
TypeScript
797 lines
29 KiB
TypeScript
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,
|
|
} from '../src/external-tool-runner.js';
|
|
import { loadThirdPartyManifests } from '../src/tool-manifest-loader.js';
|
|
|
|
class FakeStream extends EventEmitter {
|
|
override on(event: 'data', cb: (chunk: Buffer | string) => void): this {
|
|
return super.on(event, cb);
|
|
}
|
|
}
|
|
|
|
class FakeStdin {
|
|
value = '';
|
|
ended = false;
|
|
write(value: string) { this.value += value; }
|
|
end() { this.ended = true; }
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
function manifest(id: string): ToolManifest {
|
|
const found = BUILTIN_TOOL_MANIFESTS.find((item) => item.id === id);
|
|
if (!found) throw new Error(`manifest not found: ${id}`);
|
|
return found;
|
|
}
|
|
|
|
function baseRequest(id: string) {
|
|
return {
|
|
manifest: manifest(id),
|
|
binary: `/bin/${id}`,
|
|
workspaceId: 'workspace-1',
|
|
workspacePath: '/workspace',
|
|
runId: 'run-1',
|
|
roomId: 'room-1',
|
|
prompt: 'Review this workspace; do not leak $SECRETS',
|
|
access: 'read-only' as const,
|
|
};
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
describe('runExternalTool', () => {
|
|
it('runs Claude Code headlessly with literal stdin and structured result parsing', async () => {
|
|
const child = new FakeChild();
|
|
let captured: { args: string[]; env: NodeJS.ProcessEnv } | undefined;
|
|
const events: string[] = [];
|
|
const promise = runExternalTool({
|
|
...baseRequest('claude-code'),
|
|
onEvent: (event) => events.push(event.type),
|
|
}, {
|
|
resolveWorkspacePath: () => '/workspace',
|
|
baseEnv: { PATH: '/bin', SUPER_SECRET: 'must-not-pass', ANTHROPIC_API_KEY: '12345678-secret' },
|
|
spawnProcess: (_binary, args, options) => {
|
|
captured = { args, env: options.env };
|
|
queueMicrotask(() => {
|
|
child.stdout.emit('data', '{"type":"system","session_id":"claude-session"}\n');
|
|
child.stdout.emit('data', '{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Read"}]}}\n');
|
|
child.stdout.emit('data', '{"type":"result","result":"Claude finished","is_error":false}\n');
|
|
child.emit('exit', 0);
|
|
});
|
|
return child;
|
|
},
|
|
});
|
|
|
|
const result = await promise;
|
|
expect(captured?.args).toEqual([
|
|
'-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[] = [];
|
|
const promise = runExternalTool({
|
|
...baseRequest('codex'),
|
|
access: 'workspace-write',
|
|
}, {
|
|
resolveWorkspacePath: () => 'C:\\workspace',
|
|
spawnProcess: (_binary, value) => {
|
|
args = value;
|
|
queueMicrotask(() => {
|
|
child.stdout.emit('data', '{"type":"thread.started","thread_id":"codex-session"}\n');
|
|
child.stdout.emit('data', '{"type":"item.completed","item":{"type":"agent_message","text":"Codex finished"}}\n');
|
|
child.emit('exit', 0);
|
|
});
|
|
return child;
|
|
},
|
|
});
|
|
const result = await promise;
|
|
expect(args).toEqual([
|
|
'--ask-for-approval', 'never', '--sandbox', 'workspace-write', 'exec',
|
|
'--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[] = [];
|
|
const prompt = baseRequest('hermes').prompt;
|
|
const promise = runExternalTool({ ...baseRequest('hermes'), access: 'native' }, {
|
|
resolveWorkspacePath: () => '/workspace',
|
|
spawnProcess: (_binary, value) => {
|
|
args = value;
|
|
queueMicrotask(() => {
|
|
child.stdout.emit('data', 'Hermes finished\nSession ID: hermes-session\n');
|
|
child.emit('exit', 0);
|
|
});
|
|
return child;
|
|
},
|
|
});
|
|
const result = await promise;
|
|
expect(args).toEqual([
|
|
'chat', '-q', prompt, '-Q', '--source', 'tool', '--ignore-rules',
|
|
'--max-turns', '12', '--checkpoints',
|
|
]);
|
|
expect(args).not.toContain('--yolo');
|
|
expect(args).not.toContain('--oneshot');
|
|
expect(child.stdin.value).toBe('');
|
|
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 }> = [];
|
|
const promise = runExternalTool({
|
|
...baseRequest('hermes'),
|
|
access: 'native',
|
|
onEvent: (event) => events.push({ type: event.type, text: event.text }),
|
|
}, {
|
|
platform: 'win32',
|
|
resolveWorkspacePath: () => 'C:\\workspace',
|
|
spawnProcess: () => {
|
|
queueMicrotask(() => {
|
|
child.stdout.emit('data', '\u001b[2;3mInspecting the workspace first.\u001b[0m\r\n');
|
|
child.stdout.emit('data', '\u001b[2;3mChecking the relevant tests.\u001b[0m\r\n');
|
|
child.stdout.emit('data', 'Hermes completed the requested review.\r\n');
|
|
child.stderr.emit('data', 'Warning: terminal capability fallback\r\n\rsession_');
|
|
child.stderr.emit('data', 'id: 20260711_hermes123\r\n');
|
|
child.emit('exit', 0);
|
|
});
|
|
return child;
|
|
},
|
|
});
|
|
|
|
const result = await promise;
|
|
expect(result).toMatchObject({
|
|
status: 'completed',
|
|
summary: 'Hermes completed the requested review.',
|
|
sessionId: '20260711_hermes123',
|
|
});
|
|
expect(result.summary).not.toContain('\u001b');
|
|
expect(result.stderrTail).toContain('terminal capability fallback');
|
|
expect(events).toContainEqual({ type: 'progress', text: 'Inspecting the workspace first.' });
|
|
expect(events).toContainEqual({ type: 'progress', text: 'Checking the relevant tests.' });
|
|
expect(events.at(-1)).toEqual({ type: 'completed', text: result.summary });
|
|
});
|
|
|
|
it('binds OpenClaw to a managed workspace agent and deletes its prompt file', async () => {
|
|
const child = new FakeChild();
|
|
let args: string[] = [];
|
|
let promptFileContent = '';
|
|
let cleaned = false;
|
|
const promise = runExternalTool({
|
|
...baseRequest('openclaw'),
|
|
access: 'native',
|
|
managedAgentId: 'waggle-workspace-1',
|
|
}, {
|
|
resolveWorkspacePath: () => '/workspace',
|
|
createPromptFile: (prompt) => {
|
|
promptFileContent = prompt;
|
|
return { path: '/tmp/prompt.txt', cleanup: () => { cleaned = true; } };
|
|
},
|
|
spawnProcess: (_binary, value) => {
|
|
args = value;
|
|
queueMicrotask(() => {
|
|
child.stdout.emit('data', JSON.stringify({
|
|
status: 'ok', sessionId: 'openclaw-session', result: { payloads: [{ text: 'OpenClaw finished' }] },
|
|
}));
|
|
child.emit('exit', 0);
|
|
});
|
|
return child;
|
|
},
|
|
});
|
|
const result = await promise;
|
|
expect(args).toEqual([
|
|
'agent', '--agent', 'waggle-workspace-1', '--session-key',
|
|
'agent:waggle-workspace-1:waggle:run-1', '--message-file', '/tmp/prompt.txt',
|
|
'--json', '--timeout', '600',
|
|
]);
|
|
expect(promptFileContent).toBe(baseRequest('openclaw').prompt);
|
|
expect(cleaned).toBe(true);
|
|
expect(result).toMatchObject({ status: 'completed', summary: 'OpenClaw finished', sessionId: 'openclaw-session' });
|
|
});
|
|
|
|
it('rejects GUI-only tools instead of pretending that launch equals task execution', async () => {
|
|
await expect(runExternalTool(baseRequest('cursor'), {
|
|
resolveWorkspacePath: () => '/workspace',
|
|
spawnProcess: () => { throw new Error('must not spawn'); },
|
|
})).rejects.toThrow(/TOOL_NOT_HEADLESS/);
|
|
});
|
|
|
|
it('redacts the narrow collaboration credential from structured output', async () => {
|
|
const child = new FakeChild();
|
|
const token = 'run-token-that-must-never-be-persisted-12345';
|
|
const promise = runExternalTool({
|
|
...baseRequest('claude-code'),
|
|
dance: {
|
|
url: 'http://127.0.0.1:3333', token,
|
|
nodePath: '/runtime/node', cliEntry: '/runtime/hive-mind-cli.js',
|
|
},
|
|
dataDir: '/waggle-data',
|
|
}, {
|
|
resolveWorkspacePath: () => '/workspace',
|
|
spawnProcess: () => {
|
|
queueMicrotask(() => {
|
|
child.stdout.emit('data', `{"type":"result","result":"accidental ${token}","is_error":false}\n`);
|
|
child.emit('exit', 0);
|
|
});
|
|
return child;
|
|
},
|
|
});
|
|
|
|
const result = await promise;
|
|
expect(result.summary).toContain('[REDACTED]');
|
|
expect(result.summary).not.toContain(token);
|
|
expect(result.stdoutTail).not.toContain(token);
|
|
});
|
|
|
|
it('cancels one process tree exactly once', async () => {
|
|
const child = new FakeChild();
|
|
const controller = new AbortController();
|
|
let kills = 0;
|
|
const promise = runExternalTool({ ...baseRequest('claude-code'), signal: controller.signal }, {
|
|
resolveWorkspacePath: () => '/workspace',
|
|
spawnProcess: () => {
|
|
queueMicrotask(() => {
|
|
controller.abort();
|
|
setTimeout(() => child.emit('exit', null), 0);
|
|
});
|
|
return child;
|
|
},
|
|
killTree: async () => { kills++; },
|
|
});
|
|
const result = await promise;
|
|
expect(kills).toBe(1);
|
|
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);
|
|
const child = new FakeChild();
|
|
const events: ExternalRunEvent[] = [];
|
|
const promise = runExternalTool({
|
|
...baseRequest('claude-code'),
|
|
timeoutMs: 120_000,
|
|
stallAfterMs: 30_000,
|
|
onEvent: (event) => events.push(event),
|
|
}, {
|
|
resolveWorkspacePath: () => '/workspace',
|
|
spawnProcess: () => child,
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(30_000);
|
|
expect(events.filter((event) => event.stalled === true)).toHaveLength(1);
|
|
expect(events).toContainEqual(expect.objectContaining({
|
|
type: 'progress',
|
|
text: '[stalled] no output for 30s',
|
|
stalled: true,
|
|
}));
|
|
|
|
await vi.advanceTimersByTimeAsync(30_000);
|
|
expect(events.filter((event) => event.stalled === true)).toHaveLength(1);
|
|
|
|
child.stdout.emit('data', '{"type":"system"}\n');
|
|
expect(events).toContainEqual(expect.objectContaining({
|
|
type: 'progress',
|
|
text: '[recovered] output resumed',
|
|
stalled: false,
|
|
}));
|
|
|
|
await vi.advanceTimersByTimeAsync(30_000);
|
|
expect(events.filter((event) => event.stalled === true)).toHaveLength(2);
|
|
|
|
child.emit('exit', 0);
|
|
await promise;
|
|
const eventCountAtExit = events.length;
|
|
await vi.advanceTimersByTimeAsync(300_000);
|
|
expect(events).toHaveLength(eventCountAtExit);
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
});
|
|
|
|
it('does not emit a stall after cancellation while process exit is pending', async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(0);
|
|
const child = new FakeChild();
|
|
const controller = new AbortController();
|
|
const events: ExternalRunEvent[] = [];
|
|
const promise = runExternalTool({
|
|
...baseRequest('claude-code'),
|
|
timeoutMs: 120_000,
|
|
stallAfterMs: 30_000,
|
|
signal: controller.signal,
|
|
onEvent: (event) => events.push(event),
|
|
}, {
|
|
resolveWorkspacePath: () => '/workspace',
|
|
spawnProcess: () => child,
|
|
killTree: async () => undefined,
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(10_000);
|
|
controller.abort();
|
|
await vi.advanceTimersByTimeAsync(60_000);
|
|
expect(events.some((event) => event.stalled === true)).toBe(false);
|
|
|
|
child.emit('exit', null);
|
|
await expect(promise).resolves.toMatchObject({ status: 'cancelled' });
|
|
});
|
|
|
|
it.each([
|
|
['uses the default', undefined, 120_000],
|
|
['respects an override', 45_000, 45_000],
|
|
['clamps short overrides', 1_000, 30_000],
|
|
])('%s stall delay', async (_label, configuredStallAfterMs, expectedStallAfterMs) => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(0);
|
|
const child = new FakeChild();
|
|
const events: ExternalRunEvent[] = [];
|
|
const promise = runExternalTool({
|
|
...baseRequest('claude-code'),
|
|
timeoutMs: 300_000,
|
|
...(configuredStallAfterMs === undefined ? {} : { stallAfterMs: configuredStallAfterMs }),
|
|
onEvent: (event) => events.push(event),
|
|
}, {
|
|
resolveWorkspacePath: () => '/workspace',
|
|
spawnProcess: () => child,
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(expectedStallAfterMs - 1);
|
|
expect(events.some((event) => event.stalled === true)).toBe(false);
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(events.filter((event) => event.stalled === true)).toHaveLength(1);
|
|
|
|
child.emit('exit', 0);
|
|
await promise;
|
|
});
|
|
|
|
it('disables the stall watchdog when its threshold is not below the run timeout', async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(0);
|
|
const child = new FakeChild();
|
|
const events: ExternalRunEvent[] = [];
|
|
const promise = runExternalTool({
|
|
...baseRequest('claude-code'),
|
|
timeoutMs: 30_000,
|
|
stallAfterMs: 30_000,
|
|
onEvent: (event) => events.push(event),
|
|
}, {
|
|
resolveWorkspacePath: () => '/workspace',
|
|
spawnProcess: () => child,
|
|
killTree: async () => undefined,
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(30_000);
|
|
expect(events.some((event) => event.stalled !== undefined)).toBe(false);
|
|
child.emit('exit', null);
|
|
await expect(promise).resolves.toMatchObject({ status: 'timed_out' });
|
|
});
|
|
});
|
|
|
|
describe('external adapter safety', () => {
|
|
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: '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: {
|
|
url: 'http://127.0.0.1:3333', token: 'run-token-123456789012345678901234',
|
|
nodePath: '/runtime/node', cliEntry: '/runtime/hive-mind-cli.js',
|
|
},
|
|
dataDir: '/waggle-data',
|
|
},
|
|
'/workspace',
|
|
'win32',
|
|
);
|
|
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', () => {
|
|
const base = {
|
|
id: 'safe-cli', displayName: 'Safe', launchable: true, hookCapable: false,
|
|
hookPointer: '.safe/hm.json', detect: { kind: 'path', binaryName: 'safe' },
|
|
};
|
|
const deps = (value: unknown) => ({
|
|
dir: '/fake', readDir: () => ['adapter.json'], readFile: () => JSON.stringify(value),
|
|
});
|
|
const safe = loadThirdPartyManifests(deps({
|
|
...base,
|
|
task: {
|
|
argvTemplate: ['run', '--workspace', '{workspacePath}', '{prompt}'],
|
|
accessArgs: { 'read-only': [] }, promptTransport: 'arg', outputDialect: 'jsonl',
|
|
workspaceBinding: 'flag', permissionModes: ['read-only'], resumable: false,
|
|
},
|
|
}));
|
|
expect(safe[0]?.capabilities?.headlessTask).toBe(true);
|
|
|
|
expect(loadThirdPartyManifests(deps({
|
|
...base,
|
|
task: {
|
|
argvTemplate: ['run', '{executeJavascript}'], accessArgs: { native: [] },
|
|
promptTransport: 'stdin', outputDialect: 'jsonl', workspaceBinding: 'cwd',
|
|
permissionModes: ['native'], resumable: false,
|
|
},
|
|
}))).toEqual([]);
|
|
expect(loadThirdPartyManifests(deps({ ...base, parserModule: './evil.js' }))).toEqual([]);
|
|
});
|
|
});
|