moving
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user