215 lines
7.5 KiB
TypeScript
215 lines
7.5 KiB
TypeScript
|
|
import { resolveWorkerTools } from '../src/modules/netaclaw/subagent/worker_tools.js';
|
||
|
|
import * as fs from 'fs';
|
||
|
|
import * as os from 'os';
|
||
|
|
import * as path from 'path';
|
||
|
|
|
||
|
|
describe('resolveWorkerTools', () => {
|
||
|
|
const cleanupTasks: Array<() => void> = [];
|
||
|
|
|
||
|
|
afterEach(() => {
|
||
|
|
cleanupTasks.splice(0).forEach(cleanup => cleanup());
|
||
|
|
});
|
||
|
|
|
||
|
|
const createTempDir = () => {
|
||
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'neta-worker-policy-'));
|
||
|
|
cleanupTasks.push(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||
|
|
return dir;
|
||
|
|
};
|
||
|
|
|
||
|
|
it('resolves only worker-local evidence tools', () => {
|
||
|
|
const result = resolveWorkerTools([
|
||
|
|
{ name: 'bash', visibility: 'tool', capability: 'text', kind: 'builtin', executionMode: 'parallel', supportedInWorker: true, requiresShell: true },
|
||
|
|
{ name: 'read_file', visibility: 'tool', capability: 'text', kind: 'builtin', executionMode: 'parallel', supportedInWorker: true },
|
||
|
|
{ name: 'list_dir', visibility: 'tool', capability: 'text', kind: 'builtin', executionMode: 'parallel', supportedInWorker: true },
|
||
|
|
{ name: 'find_files', visibility: 'tool', capability: 'text', kind: 'builtin', executionMode: 'parallel', supportedInWorker: true },
|
||
|
|
{ name: 'grep', visibility: 'tool', capability: 'text', kind: 'builtin', executionMode: 'parallel', supportedInWorker: true },
|
||
|
|
{ name: 'write_file', visibility: 'tool', capability: 'text', kind: 'builtin', executionMode: 'sequential', supportedInWorker: false, requiresWrite: true },
|
||
|
|
{ name: 'delegate_task', visibility: 'tool', capability: 'text', kind: 'delegation', executionMode: 'sequential', supportedInWorker: false },
|
||
|
|
{ name: 'bash', visibility: 'tool', capability: 'text', kind: 'builtin', executionMode: 'parallel', supportedInWorker: true, requiresShell: true },
|
||
|
|
]);
|
||
|
|
|
||
|
|
expect(result.tools.map(tool => tool.name)).toEqual([
|
||
|
|
'bash',
|
||
|
|
'read_file',
|
||
|
|
'list_dir',
|
||
|
|
'find_files',
|
||
|
|
'grep',
|
||
|
|
]);
|
||
|
|
expect(result.unsupportedToolNames).toEqual([
|
||
|
|
'write_file',
|
||
|
|
'delegate_task',
|
||
|
|
]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('blocks file tools when no workspace root is configured', async () => {
|
||
|
|
const result = resolveWorkerTools([
|
||
|
|
{ name: 'read_file', visibility: 'tool', capability: 'text', kind: 'builtin', executionMode: 'parallel', supportedInWorker: true },
|
||
|
|
]);
|
||
|
|
const readFile = result.tools[0];
|
||
|
|
|
||
|
|
await expect(readFile.execute('tool-1', { path: __filename })).rejects.toThrow(
|
||
|
|
'Subagent subprocess policy requires at least one workspace root'
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('blocks file tools outside configured workspace roots', async () => {
|
||
|
|
const root = createTempDir();
|
||
|
|
const outside = path.join(os.tmpdir(), `neta-outside-${Date.now()}.txt`);
|
||
|
|
fs.writeFileSync(outside, 'outside', 'utf8');
|
||
|
|
cleanupTasks.push(() => fs.rmSync(outside, { force: true }));
|
||
|
|
const result = resolveWorkerTools([{
|
||
|
|
name: 'read_file',
|
||
|
|
visibility: 'tool',
|
||
|
|
capability: 'text',
|
||
|
|
kind: 'builtin',
|
||
|
|
executionMode: 'parallel',
|
||
|
|
supportedInWorker: true,
|
||
|
|
}], {
|
||
|
|
workspaceRoots: [root],
|
||
|
|
});
|
||
|
|
const readFile = result.tools[0];
|
||
|
|
|
||
|
|
await expect(readFile.execute('tool-1', { path: outside })).rejects.toThrow(
|
||
|
|
'Subagent subprocess policy blocks path outside workspace roots'
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('allows file tools inside configured workspace roots', async () => {
|
||
|
|
const root = createTempDir();
|
||
|
|
const filePath = path.join(root, 'note.txt');
|
||
|
|
fs.writeFileSync(filePath, 'inside-root', 'utf8');
|
||
|
|
const result = resolveWorkerTools([{
|
||
|
|
name: 'read_file',
|
||
|
|
visibility: 'tool',
|
||
|
|
capability: 'text',
|
||
|
|
kind: 'builtin',
|
||
|
|
executionMode: 'parallel',
|
||
|
|
supportedInWorker: true,
|
||
|
|
}], {
|
||
|
|
workspaceRoots: [root],
|
||
|
|
});
|
||
|
|
const readFile = result.tools[0];
|
||
|
|
|
||
|
|
await expect(readFile.execute('tool-1', { path: filePath })).resolves.toEqual({
|
||
|
|
type: 'text',
|
||
|
|
text: 'inside-root',
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('passes read_file offset and limit through worker policy wrapper', async () => {
|
||
|
|
const root = createTempDir();
|
||
|
|
const filePath = path.join(root, 'note.txt');
|
||
|
|
fs.writeFileSync(filePath, 'line 1\nline 2\nline 3', 'utf8');
|
||
|
|
const result = resolveWorkerTools([{
|
||
|
|
name: 'read_file',
|
||
|
|
visibility: 'tool',
|
||
|
|
capability: 'text',
|
||
|
|
kind: 'builtin',
|
||
|
|
executionMode: 'parallel',
|
||
|
|
supportedInWorker: true,
|
||
|
|
}], {
|
||
|
|
workspaceRoots: [root],
|
||
|
|
});
|
||
|
|
const readFile = result.tools[0];
|
||
|
|
|
||
|
|
await expect(readFile.execute('tool-1', {
|
||
|
|
path: filePath,
|
||
|
|
offset: 2,
|
||
|
|
limit: 1,
|
||
|
|
})).resolves.toMatchObject({
|
||
|
|
type: 'text',
|
||
|
|
text: expect.stringContaining('line 2'),
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('blocks shell execution unless explicitly allowed', async () => {
|
||
|
|
const root = createTempDir();
|
||
|
|
const result = resolveWorkerTools([{
|
||
|
|
name: 'bash',
|
||
|
|
visibility: 'tool',
|
||
|
|
capability: 'text',
|
||
|
|
kind: 'builtin',
|
||
|
|
executionMode: 'parallel',
|
||
|
|
supportedInWorker: true,
|
||
|
|
requiresShell: true,
|
||
|
|
}], {
|
||
|
|
workspaceRoots: [root],
|
||
|
|
});
|
||
|
|
const bash = result.tools[0];
|
||
|
|
|
||
|
|
await expect(bash.execute('tool-1', {
|
||
|
|
command: 'echo blocked',
|
||
|
|
cwd: root,
|
||
|
|
})).rejects.toThrow('Subagent subprocess policy blocks shell execution');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('allows shell execution only with allowShell and workspace cwd', async () => {
|
||
|
|
const root = createTempDir();
|
||
|
|
const result = resolveWorkerTools([{
|
||
|
|
name: 'bash',
|
||
|
|
visibility: 'tool',
|
||
|
|
capability: 'text',
|
||
|
|
kind: 'builtin',
|
||
|
|
executionMode: 'parallel',
|
||
|
|
supportedInWorker: true,
|
||
|
|
requiresShell: true,
|
||
|
|
}], {
|
||
|
|
workspaceRoots: [root],
|
||
|
|
allowShell: true,
|
||
|
|
});
|
||
|
|
const bash = result.tools[0];
|
||
|
|
|
||
|
|
await expect(bash.execute('tool-1', {
|
||
|
|
command: 'node -e "process.stdout.write(\'inside-root\')"',
|
||
|
|
cwd: root,
|
||
|
|
timeout: 5000,
|
||
|
|
})).resolves.toEqual({
|
||
|
|
type: 'text',
|
||
|
|
text: 'inside-root',
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('builds worker-local tools through injected default operations', async () => {
|
||
|
|
const root = createTempDir();
|
||
|
|
const readFile = jest.fn().mockResolvedValue(Buffer.from('worker-injected', 'utf8'));
|
||
|
|
|
||
|
|
let resolveWorkerToolsWithMock: typeof resolveWorkerTools;
|
||
|
|
jest.isolateModules(() => {
|
||
|
|
jest.doMock('../src/modules/netaclaw/tools/operations/index.js', () => ({
|
||
|
|
getDefaultOperations: () => ({
|
||
|
|
file: {
|
||
|
|
readFile,
|
||
|
|
writeFile: jest.fn(),
|
||
|
|
appendFile: jest.fn(),
|
||
|
|
access: jest.fn().mockResolvedValue(undefined),
|
||
|
|
isDirectory: jest.fn().mockResolvedValue(false),
|
||
|
|
readDir: jest.fn().mockResolvedValue([]),
|
||
|
|
mkdir: jest.fn().mockResolvedValue(undefined),
|
||
|
|
realpath: jest.fn().mockImplementation(async (p: string) => p),
|
||
|
|
},
|
||
|
|
process: {
|
||
|
|
exec: jest.fn(),
|
||
|
|
},
|
||
|
|
search: {
|
||
|
|
ripgrep: jest.fn(),
|
||
|
|
fd: jest.fn(),
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
}));
|
||
|
|
({ resolveWorkerTools: resolveWorkerToolsWithMock } = require('../src/modules/netaclaw/subagent/worker_tools.js'));
|
||
|
|
});
|
||
|
|
|
||
|
|
const result = resolveWorkerToolsWithMock!([
|
||
|
|
{ name: 'read_file', visibility: 'tool', capability: 'text', kind: 'builtin', executionMode: 'parallel', supportedInWorker: true },
|
||
|
|
], {
|
||
|
|
workspaceRoots: [root],
|
||
|
|
});
|
||
|
|
|
||
|
|
const readFileTool = result.tools[0];
|
||
|
|
const toolResult = await readFileTool.execute('tool-1', { path: path.join(root, 'demo.txt') });
|
||
|
|
|
||
|
|
expect(readFile).toHaveBeenCalledWith(path.join(root, 'demo.txt'));
|
||
|
|
expect(toolResult).toEqual({ type: 'text', text: 'worker-injected' });
|
||
|
|
});
|
||
|
|
});
|