This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* app-test.test.ts — pure-function unit tests for TestWorkspaceApp helpers.
|
||||
* These cover buildHarnessUrl, validateInput, and evaluateExpectations without
|
||||
* any network/browser calls.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { buildHarnessUrl, validateInput, evaluateExpectations } from './app-test.js';
|
||||
|
||||
describe('buildHarnessUrl', () => {
|
||||
it('assembles a localhost URL with space, app, token, and default entry', () => {
|
||||
const url = buildHarnessUrl(9876, {
|
||||
space: 'space-abc',
|
||||
app: 'my-app',
|
||||
entry: 'apps/my-app/index.html',
|
||||
token: 'tok123',
|
||||
});
|
||||
expect(url).toBe(
|
||||
'http://localhost:9876/app-harness?space=space-abc&app=my-app&entry=apps%2Fmy-app%2Findex.html&token=tok123',
|
||||
);
|
||||
});
|
||||
|
||||
it('URL-encodes special characters in entry', () => {
|
||||
const url = buildHarnessUrl(3000, {
|
||||
space: 's1',
|
||||
app: 'calc',
|
||||
entry: 'apps/calc/index.html',
|
||||
token: 'abc',
|
||||
});
|
||||
expect(url).toContain('entry=apps%2Fcalc%2Findex.html');
|
||||
});
|
||||
|
||||
it('uses the supplied port', () => {
|
||||
const url = buildHarnessUrl(4242, { space: 's', app: 'a', entry: 'apps/a/index.html', token: 't' });
|
||||
expect(url).toMatch(/^http:\/\/localhost:4242\//);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateInput', () => {
|
||||
it('rejects when app is missing', () => {
|
||||
expect(() => validateInput({ space: 's1' })).toThrow(/app/);
|
||||
});
|
||||
|
||||
it('rejects when space is missing', () => {
|
||||
expect(() => validateInput({ app: 'my-app' })).toThrow(/space/);
|
||||
});
|
||||
|
||||
it('defaults entry to apps/{app}/index.html when omitted', () => {
|
||||
const result = validateInput({ space: 's1', app: 'counter' });
|
||||
expect(result.entry).toBe('apps/counter/index.html');
|
||||
});
|
||||
|
||||
it('preserves an explicitly supplied entry', () => {
|
||||
const result = validateInput({ space: 's1', app: 'counter', entry: 'apps/counter/v2.html' });
|
||||
expect(result.entry).toBe('apps/counter/v2.html');
|
||||
});
|
||||
|
||||
it('passes through steps and seed_files (canonical param name)', () => {
|
||||
const steps = [{ type: 'click', selector: '#btn' }];
|
||||
const seed = [{ path: 'apps/counter/index.html', content: '<html/>' }];
|
||||
const result = validateInput({ space: 's1', app: 'counter', steps, seed_files: seed });
|
||||
expect(result.steps).toStrictEqual(steps);
|
||||
expect(result.seed_files).toStrictEqual(seed);
|
||||
});
|
||||
|
||||
it('accepts legacy "actions" param and maps to steps', () => {
|
||||
const actions = [{ type: 'click', selector: '#btn' }];
|
||||
const result = validateInput({ space: 's1', app: 'counter', actions });
|
||||
expect(result.steps).toStrictEqual(actions);
|
||||
});
|
||||
|
||||
it('parses canonical expect items (kind text and file)', () => {
|
||||
const raw = {
|
||||
space: 's1',
|
||||
app: 'counter',
|
||||
expect: [
|
||||
{ kind: 'text', target: '#status', contains: 'OK' },
|
||||
{ kind: 'file', target: 'output/result.txt', contains: 'done' },
|
||||
],
|
||||
};
|
||||
const result = validateInput(raw);
|
||||
expect(result.expect).toHaveLength(2);
|
||||
expect(result.expect[0]).toEqual({ kind: 'text', target: '#status', contains: 'OK' });
|
||||
expect(result.expect[1]).toEqual({ kind: 'file', target: 'output/result.txt', contains: 'done' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluateExpectations', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
// kind: "text" — success / failure
|
||||
// ---------------------------------------------------------------------------
|
||||
it('kind=text passes when contains is present in pageText', () => {
|
||||
const failures = evaluateExpectations(
|
||||
[{ kind: 'text', target: '#status', contains: '保存OK' }],
|
||||
{ pageText: 'some text 保存OK more text', workspacePath: '/tmp' },
|
||||
);
|
||||
expect(failures).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('kind=text records a failure when contains is ABSENT (no silent no-op)', () => {
|
||||
const failures = evaluateExpectations(
|
||||
[{ kind: 'text', target: '#status', contains: '保存OK' }],
|
||||
{ pageText: 'nothing relevant here', workspacePath: '/tmp' },
|
||||
);
|
||||
expect(failures).toHaveLength(1);
|
||||
expect(failures[0]).toMatch(/保存OK/);
|
||||
expect(failures[0]).toMatch(/not found/);
|
||||
});
|
||||
|
||||
it('kind=text with empty contains always passes (empty string is substring of everything)', () => {
|
||||
const failures = evaluateExpectations(
|
||||
[{ kind: 'text', target: 'body', contains: '' }],
|
||||
{ pageText: 'anything', workspacePath: '/tmp' },
|
||||
);
|
||||
expect(failures).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// kind: "file" — success / failure
|
||||
// ---------------------------------------------------------------------------
|
||||
it('kind=file passes when file exists and contains the needle', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'app-test-'));
|
||||
const filePath = path.join(tmpDir, 'result.txt');
|
||||
fs.writeFileSync(filePath, 'hello world from test');
|
||||
|
||||
const failures = evaluateExpectations(
|
||||
[{ kind: 'file', target: 'result.txt', contains: 'from test' }],
|
||||
{ pageText: '', workspacePath: tmpDir },
|
||||
);
|
||||
expect(failures).toHaveLength(0);
|
||||
|
||||
fs.rmSync(tmpDir, { recursive: true });
|
||||
});
|
||||
|
||||
it('kind=file records failure when file exists but does not contain needle', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'app-test-'));
|
||||
const filePath = path.join(tmpDir, 'result.txt');
|
||||
fs.writeFileSync(filePath, 'hello world');
|
||||
|
||||
const failures = evaluateExpectations(
|
||||
[{ kind: 'file', target: 'result.txt', contains: 'NOT_PRESENT' }],
|
||||
{ pageText: '', workspacePath: tmpDir },
|
||||
);
|
||||
expect(failures).toHaveLength(1);
|
||||
expect(failures[0]).toMatch(/NOT_PRESENT/);
|
||||
|
||||
fs.rmSync(tmpDir, { recursive: true });
|
||||
});
|
||||
|
||||
it('kind=file records failure when file does not exist', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'app-test-'));
|
||||
|
||||
const failures = evaluateExpectations(
|
||||
[{ kind: 'file', target: 'missing.txt', contains: 'anything' }],
|
||||
{ pageText: '', workspacePath: tmpDir },
|
||||
);
|
||||
expect(failures).toHaveLength(1);
|
||||
expect(failures[0]).toMatch(/does not exist/);
|
||||
|
||||
fs.rmSync(tmpDir, { recursive: true });
|
||||
});
|
||||
|
||||
it('kind=file records failure when target is empty string', () => {
|
||||
const failures = evaluateExpectations(
|
||||
[{ kind: 'file', target: '', contains: 'x' }],
|
||||
{ pageText: '', workspacePath: '/tmp' },
|
||||
);
|
||||
expect(failures).toHaveLength(1);
|
||||
expect(failures[0]).toMatch(/"target"/);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unknown kind — must never silently pass
|
||||
// ---------------------------------------------------------------------------
|
||||
it('unknown kind records a failure (never silently passes)', () => {
|
||||
const failures = evaluateExpectations(
|
||||
[{ kind: 'element_visible', target: '#btn', contains: '' } as any],
|
||||
{ pageText: 'anything', workspacePath: '/tmp' },
|
||||
);
|
||||
expect(failures).toHaveLength(1);
|
||||
expect(failures[0]).toMatch(/unknown kind/);
|
||||
expect(failures[0]).toMatch(/element_visible/);
|
||||
});
|
||||
|
||||
it('another legacy kind "text_contains" also records a failure', () => {
|
||||
const failures = evaluateExpectations(
|
||||
[{ kind: 'text_contains', contains: 'hi' } as any],
|
||||
{ pageText: 'hi there', workspacePath: '/tmp' },
|
||||
);
|
||||
expect(failures).toHaveLength(1);
|
||||
expect(failures[0]).toMatch(/unknown kind/);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multiple expectations — independent evaluation
|
||||
// ---------------------------------------------------------------------------
|
||||
it('evaluates all expectations independently and collects all failures', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'app-test-'));
|
||||
fs.writeFileSync(path.join(tmpDir, 'out.txt'), 'correct content');
|
||||
|
||||
const failures = evaluateExpectations(
|
||||
[
|
||||
{ kind: 'text', target: 'body', contains: 'MISSING_TEXT' },
|
||||
{ kind: 'file', target: 'out.txt', contains: 'correct content' },
|
||||
{ kind: 'file', target: 'nonexistent.txt', contains: 'x' },
|
||||
],
|
||||
{ pageText: 'page has something else', workspacePath: tmpDir },
|
||||
);
|
||||
// First and third fail, second passes
|
||||
expect(failures).toHaveLength(2);
|
||||
|
||||
fs.rmSync(tmpDir, { recursive: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,422 @@
|
||||
/**
|
||||
* app-test.ts — TestWorkspaceApp tool.
|
||||
*
|
||||
* Mints a scoped harness token, drives the /app-harness page headlessly via
|
||||
* runPageActions, checks expectations, returns a structured E2E result, and
|
||||
* revokes the token in `finally`.
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { ToolDef } from '../../llm/openai-compat.js';
|
||||
import type { ToolContext, ToolResult } from './core.js';
|
||||
import { resolveAndGuard } from './core.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import { loadConfig } from '../../config.js';
|
||||
import { resolveListenPort } from '../../server/config.js';
|
||||
import { mintAppHarnessToken, revokeAppHarnessToken } from '../../bridge/app-harness-token.js';
|
||||
import type { BrowseWebAction } from './browser.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure helpers (exported for unit testing)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the localhost harness URL with all required query params.
|
||||
*/
|
||||
export function buildHarnessUrl(
|
||||
port: number,
|
||||
opts: { space: string; app: string; entry: string; token: string },
|
||||
): string {
|
||||
const params = new URLSearchParams({
|
||||
space: opts.space,
|
||||
app: opts.app,
|
||||
entry: opts.entry,
|
||||
token: opts.token,
|
||||
});
|
||||
return `http://localhost:${port}/app-harness?${params.toString()}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Canonical expect types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ExpectItem {
|
||||
kind: 'text' | 'file';
|
||||
/** kind=text: CSS selector (informational, page text searched); kind=file: workspace-relative path */
|
||||
target: string;
|
||||
/** substring that must appear in page text or file content */
|
||||
contains: string;
|
||||
}
|
||||
|
||||
interface ValidatedInput {
|
||||
space: string;
|
||||
app: string;
|
||||
entry: string;
|
||||
steps: Record<string, unknown>[];
|
||||
seed_files: Array<{ path: string; content: string }>;
|
||||
expect: ExpectItem[];
|
||||
timeout_ms: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate raw tool input. Throws with a descriptive message on missing required
|
||||
* fields. Fills defaults for optional fields.
|
||||
*/
|
||||
export function validateInput(raw: Record<string, unknown>): ValidatedInput {
|
||||
const app = raw['app'];
|
||||
if (typeof app !== 'string' || !app.trim()) {
|
||||
throw new Error('TestWorkspaceApp: "app" (string) is required');
|
||||
}
|
||||
const space = raw['space'];
|
||||
if (typeof space !== 'string' || !space.trim()) {
|
||||
throw new Error('TestWorkspaceApp: "space" (string) is required');
|
||||
}
|
||||
|
||||
const entry =
|
||||
typeof raw['entry'] === 'string' && raw['entry'].trim()
|
||||
? raw['entry'].trim()
|
||||
: `apps/${app}/index.html`;
|
||||
|
||||
// Accept both "steps" (canonical) and legacy "actions"
|
||||
const stepsRaw = Array.isArray(raw['steps'])
|
||||
? raw['steps']
|
||||
: Array.isArray(raw['actions'])
|
||||
? raw['actions']
|
||||
: [];
|
||||
const steps = stepsRaw as Record<string, unknown>[];
|
||||
|
||||
const seed_files = Array.isArray(raw['seed_files'])
|
||||
? (raw['seed_files'] as Array<{ path: string; content: string }>)
|
||||
: [];
|
||||
|
||||
const expectRaw: ExpectItem[] = [];
|
||||
if (Array.isArray(raw['expect'])) {
|
||||
for (const item of raw['expect'] as Record<string, unknown>[]) {
|
||||
if (item['kind'] === 'text' || item['kind'] === 'file') {
|
||||
expectRaw.push({
|
||||
kind: item['kind'] as 'text' | 'file',
|
||||
target: typeof item['target'] === 'string' ? item['target'] : '',
|
||||
contains: typeof item['contains'] === 'string' ? item['contains'] : '',
|
||||
});
|
||||
}
|
||||
// Unknown kinds are silently dropped from validated list —
|
||||
// evaluateExpectations handles the "unknown kind → failure" path
|
||||
// when called with the raw array pre-validation.
|
||||
}
|
||||
}
|
||||
|
||||
const timeout_ms =
|
||||
typeof raw['timeout_ms'] === 'number' && raw['timeout_ms'] > 0
|
||||
? raw['timeout_ms']
|
||||
: 30_000;
|
||||
|
||||
return { space: space.trim(), app: app.trim(), entry, steps, seed_files, expect: expectRaw, timeout_ms };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Matcher (exported pure function for unit testing)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface EvaluateExpectationsContext {
|
||||
/** Aggregated page text from all getText/text actions */
|
||||
pageText: string;
|
||||
/** Absolute path to the workspace root */
|
||||
workspacePath: string;
|
||||
}
|
||||
|
||||
export interface ExpectationFailure {
|
||||
index: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a list of ExpectItems against captured page text and workspace files.
|
||||
* Returns an array of failure messages (empty = all passed).
|
||||
*
|
||||
* Pure in terms of network/browser — only reads the local filesystem for kind="file".
|
||||
*/
|
||||
export function evaluateExpectations(
|
||||
expectList: Array<{ kind: string; target?: string; contains?: string }>,
|
||||
ctx: EvaluateExpectationsContext,
|
||||
): string[] {
|
||||
const failures: string[] = [];
|
||||
|
||||
for (let i = 0; i < expectList.length; i++) {
|
||||
const exp = expectList[i];
|
||||
|
||||
if (exp.kind === 'text') {
|
||||
// target is treated as a hint/CSS selector (informational), contains is checked against page text
|
||||
const needle = exp.contains ?? '';
|
||||
if (!ctx.pageText.includes(needle)) {
|
||||
failures.push(
|
||||
`text[${i}]: target="${exp.target ?? ''}" — "${needle}" not found in page text`,
|
||||
);
|
||||
}
|
||||
} else if (exp.kind === 'file') {
|
||||
const filePath = exp.target ?? '';
|
||||
if (!filePath) {
|
||||
failures.push(`file[${i}]: "target" (file path) is required`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const abs = resolveAndGuard(ctx.workspacePath, filePath);
|
||||
if (!fs.existsSync(abs)) {
|
||||
failures.push(`file[${i}]: "${filePath}" does not exist`);
|
||||
} else {
|
||||
const content = fs.readFileSync(abs, 'utf-8');
|
||||
const needle = exp.contains ?? '';
|
||||
if (!content.includes(needle)) {
|
||||
failures.push(`file[${i}]: "${filePath}" does not contain "${needle}"`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
failures.push(`file[${i}]: error reading "${filePath}": ${(err as Error).message}`);
|
||||
}
|
||||
} else {
|
||||
// Unknown kind — always record a failure, never silently pass
|
||||
failures.push(`expect[${i}]: unknown kind "${exp.kind}" (must be "text" or "file")`);
|
||||
}
|
||||
}
|
||||
|
||||
return failures;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool definition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const TOOL_DEFS: Record<string, ToolDef> = {
|
||||
TestWorkspaceApp: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'TestWorkspaceApp',
|
||||
description:
|
||||
'ワークスペースアプリをヘッドレスブラウザで実起動して操作・期待値を検証し、結果(ok/failures/screenshots/console_errors)を返す。詳細は ReadToolDoc({ name: "TestWorkspaceApp" }) で取得可能。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
space: {
|
||||
type: 'string',
|
||||
description: 'テスト対象スペースの ID',
|
||||
},
|
||||
app: {
|
||||
type: 'string',
|
||||
description: 'apps/ 配下のアプリフォルダ名(パスではなく名前のみ)',
|
||||
},
|
||||
entry: {
|
||||
type: 'string',
|
||||
description:
|
||||
'アプリのエントリーHTML パス(スペースファイルルートからの相対パス)。省略時は apps/{app}/index.html',
|
||||
},
|
||||
seed_files: {
|
||||
type: 'array',
|
||||
description: 'テスト前にワークスペースに書き込むファイル一覧',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'ワークスペースルートからの相対パス' },
|
||||
content: { type: 'string', description: 'ファイル内容(UTF-8 文字列)' },
|
||||
},
|
||||
required: ['path', 'content'],
|
||||
},
|
||||
},
|
||||
steps: {
|
||||
type: 'array',
|
||||
description: 'ハーネスページ上で実行するブラウザアクション列(BrowseWebAction と同じ形式)',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['goto', 'click', 'fill', 'screenshot', 'getText', 'wait', 'dumpHtml'],
|
||||
},
|
||||
selector: { type: 'string' },
|
||||
ref: { type: 'string' },
|
||||
value: { type: 'string' },
|
||||
url: { type: 'string' },
|
||||
ms: { type: 'number' },
|
||||
},
|
||||
required: ['type'],
|
||||
},
|
||||
},
|
||||
expect: {
|
||||
type: 'array',
|
||||
description: '検証する期待値リスト',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
kind: {
|
||||
type: 'string',
|
||||
enum: ['text', 'file'],
|
||||
description:
|
||||
'text: target(CSSセレクタ)のページテキストが contains を含む / file: target(ワークスペース相対パス)のファイル内容が contains を含む',
|
||||
},
|
||||
target: {
|
||||
type: 'string',
|
||||
description: 'kind=text: CSS セレクタ(ページテキスト全体を検索) / kind=file: ワークスペース相対ファイルパス',
|
||||
},
|
||||
contains: {
|
||||
type: 'string',
|
||||
description: 'ページテキストまたはファイル内容に含まれるべき文字列',
|
||||
},
|
||||
},
|
||||
required: ['kind', 'target', 'contains'],
|
||||
},
|
||||
},
|
||||
timeout_ms: {
|
||||
type: 'number',
|
||||
description: 'ブラウザ操作全体のタイムアウト(ミリ秒、デフォルト 30000)',
|
||||
},
|
||||
},
|
||||
required: ['space', 'app'],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Result shape
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AppTestResult {
|
||||
ok: boolean;
|
||||
screenshots: string[];
|
||||
console_errors: string[];
|
||||
bridge_calls: string[];
|
||||
file_changes: Array<{ path: string; bytes: number }>;
|
||||
failures: string[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// executeTool
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function executeTool(
|
||||
name: string,
|
||||
input: Record<string, unknown>,
|
||||
ctx: ToolContext,
|
||||
): Promise<ToolResult | null> {
|
||||
if (name !== 'TestWorkspaceApp') return null;
|
||||
|
||||
// Validate required context
|
||||
const spaceId = ctx.spaceId;
|
||||
const userId = ctx.userId ?? (ctx as unknown as Record<string, unknown>)['ownerId'] as string | undefined;
|
||||
if (!spaceId || !userId) {
|
||||
return {
|
||||
output: 'TestWorkspaceApp: space-scoped job required (ctx.spaceId and ctx.userId must be set)',
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Validate input
|
||||
let validated: ValidatedInput;
|
||||
try {
|
||||
validated = validateInput(input);
|
||||
} catch (err) {
|
||||
return { output: (err as Error).message, isError: true };
|
||||
}
|
||||
|
||||
const { space, app, entry, steps, seed_files, expect: expectations, timeout_ms } = validated;
|
||||
|
||||
const result: AppTestResult = {
|
||||
ok: false,
|
||||
screenshots: [],
|
||||
console_errors: [],
|
||||
bridge_calls: [], // headless observation not feasible; always []
|
||||
file_changes: [],
|
||||
failures: [],
|
||||
};
|
||||
|
||||
// Write seed files into workspace (guarded by resolveAndGuard)
|
||||
for (const sf of seed_files) {
|
||||
try {
|
||||
const abs = resolveAndGuard(ctx.workspacePath, sf.path);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, sf.content, 'utf-8');
|
||||
result.file_changes.push({ path: sf.path, bytes: Buffer.byteLength(sf.content) });
|
||||
} catch (err) {
|
||||
result.failures.push(`seed_files: failed to write "${sf.path}": ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve server port
|
||||
const port = resolveListenPort(process.env['PORT'], loadConfig().server?.port);
|
||||
|
||||
// Mint short-lived token
|
||||
const token = mintAppHarnessToken({ userId, spaceId });
|
||||
logger.debug(`[TestWorkspaceApp] token minted space=${spaceId} app=${app}`);
|
||||
|
||||
try {
|
||||
// Build harness URL
|
||||
const harnessUrl = buildHarnessUrl(port, { space, app, entry, token });
|
||||
|
||||
// Dynamically import browser module (avoids Playwright load when unused)
|
||||
const { runPageActions } = await import('./browser.js');
|
||||
|
||||
// Build action list:
|
||||
// 1. goto the harness URL
|
||||
// 2. wait for the iframe to appear (ms-based wait as BrowseWebAction has no selector-wait)
|
||||
// 3. user-supplied steps
|
||||
// 4. final screenshot
|
||||
const builtActions: BrowseWebAction[] = [
|
||||
{ type: 'goto', url: harnessUrl },
|
||||
// Wait for harness page to load — use ms wait since BrowseWebAction.wait uses ms not selector
|
||||
{ type: 'wait', ms: 2000 },
|
||||
// getText to capture iframe presence and page state
|
||||
{ type: 'getText' },
|
||||
// User steps
|
||||
...(steps as unknown as BrowseWebAction[]),
|
||||
// Final screenshot
|
||||
{ type: 'screenshot', value: `app-test-${app}-final.png` },
|
||||
];
|
||||
|
||||
const pageResult = await runPageActions(harnessUrl, builtActions, ctx, {
|
||||
isolatedContext: true,
|
||||
extraAllowedHosts: ['localhost', '127.0.0.1'],
|
||||
});
|
||||
|
||||
result.screenshots = pageResult.screenshots;
|
||||
result.console_errors = pageResult.consoleErrors;
|
||||
|
||||
// Evaluate expectations using the extracted pure function
|
||||
const pageText = pageResult.texts.join('\n');
|
||||
const expectFailures = evaluateExpectations(expectations, {
|
||||
pageText,
|
||||
workspacePath: ctx.workspacePath,
|
||||
});
|
||||
result.failures.push(...expectFailures);
|
||||
|
||||
result.ok = result.failures.length === 0;
|
||||
|
||||
const summary: Record<string, unknown> = {
|
||||
ok: result.ok,
|
||||
failures: result.failures,
|
||||
screenshots: result.screenshots,
|
||||
console_errors: result.console_errors,
|
||||
bridge_calls: result.bridge_calls,
|
||||
file_changes: result.file_changes,
|
||||
};
|
||||
|
||||
return {
|
||||
output: JSON.stringify(summary, null, 2),
|
||||
isError: !result.ok,
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message ?? String(err);
|
||||
logger.warn(`[TestWorkspaceApp] error: ${msg}`);
|
||||
return {
|
||||
output: JSON.stringify({
|
||||
ok: false,
|
||||
failures: [`browser error: ${msg}`],
|
||||
screenshots: result.screenshots,
|
||||
console_errors: result.console_errors,
|
||||
bridge_calls: [],
|
||||
file_changes: result.file_changes,
|
||||
}, null, 2),
|
||||
isError: true,
|
||||
};
|
||||
} finally {
|
||||
revokeAppHarnessToken(token);
|
||||
logger.debug(`[TestWorkspaceApp] token revoked`);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { looksLikeBinaryBytes, decodeText } from './binary-detect.js';
|
||||
import { looksLikeBinaryBytes, decodeText, stripLeadingBom } from './binary-detect.js';
|
||||
|
||||
const b = (...bytes: number[]) => Buffer.from(bytes);
|
||||
const txt = (s: string) => Buffer.from(s, 'utf-8');
|
||||
|
||||
describe('stripLeadingBom', () => {
|
||||
it('removes a single leading U+FEFF', () => {
|
||||
expect(stripLeadingBom('hello')).toBe('hello');
|
||||
});
|
||||
it('leaves BOM-free strings untouched', () => {
|
||||
expect(stripLeadingBom('hello')).toBe('hello');
|
||||
expect(stripLeadingBom('')).toBe('');
|
||||
});
|
||||
it('only strips the first BOM, not a later one', () => {
|
||||
expect(stripLeadingBom('ab')).toBe('ab');
|
||||
});
|
||||
it('does not touch a BOM that is not at the start', () => {
|
||||
expect(stripLeadingBom('ab')).toBe('ab');
|
||||
});
|
||||
});
|
||||
|
||||
describe('looksLikeBinaryBytes — magic signatures (binary)', () => {
|
||||
it('detects OLE2 (.xls/.doc)', () => {
|
||||
expect(looksLikeBinaryBytes(b(0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1, 0x00, 0x01)))
|
||||
|
||||
@@ -112,6 +112,20 @@ function utf16beSupported(): boolean {
|
||||
return utf16beSupportedCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip a leading UTF-8 BOM (U+FEFF) from a decoded string.
|
||||
*
|
||||
* `fs.readFileSync(path, 'utf-8')` keeps the BOM as a literal U+FEFF character,
|
||||
* which then shows up as garbled text at the very start of whatever the LLM
|
||||
* reads. We only strip it from the text we hand to the LLM (Read/Grep/Bash
|
||||
* output) — the file on disk is left untouched, and Edit/recordVersion keep
|
||||
* operating on the raw bytes so exact-match edits and conflict hashes stay
|
||||
* faithful. UTF-16 BOMs are out of scope (those files trip binary detection).
|
||||
*/
|
||||
export function stripLeadingBom(s: string): string {
|
||||
return s.charCodeAt(0) === 0xfeff ? s.slice(1) : s;
|
||||
}
|
||||
|
||||
export function looksLikeBinaryBytes(head: Buffer): BinaryVerdict {
|
||||
const magic = matchMagic(head);
|
||||
if (magic) return { binary: true, reason: magic };
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { writeFileSync, mkdtempSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import * as http from 'node:http';
|
||||
import { runPageActions } from './browser.js';
|
||||
|
||||
// CONTAINER=1 disables Chromium sandbox (bwrap absent in this environment).
|
||||
// Tests that require a real browser skip unless CONTAINER=1 is set.
|
||||
const CONTAINER = process.env['CONTAINER'] === '1';
|
||||
|
||||
describe('runPageActions', () => {
|
||||
it('opens a local file and extracts text', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'rpa-'));
|
||||
const file = join(dir, 'p.html');
|
||||
writeFileSync(file, '<html><body><h1 data-testid="t">hello-rpa</h1></body></html>');
|
||||
const ctx = { workspacePath: dir } as any;
|
||||
const r = await runPageActions(`file://${file}`, [
|
||||
{ type: 'getText', selector: '[data-testid="t"]' },
|
||||
], ctx);
|
||||
expect(r.texts.join(' ')).toContain('hello-rpa');
|
||||
expect(r.consoleErrors).toEqual([]);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Loopback SSRF tests — require CONTAINER=1 (Chromium sandbox disabled)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe.skipIf(!CONTAINER)('runPageActions loopback SSRF (requires CONTAINER=1)', () => {
|
||||
/**
|
||||
* Start a minimal HTTP server on 127.0.0.1 serving a page with a known
|
||||
* marker element, then tear it down after the test.
|
||||
*/
|
||||
function startLoopbackServer(): Promise<{ port: number; close: () => Promise<void> }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = http.createServer((_req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end('<html><body><h1 data-testid="t">loopback-ok</h1></body></html>');
|
||||
});
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const addr = server.address() as { port: number };
|
||||
const close = () =>
|
||||
new Promise<void>((res, rej) => server.close((e) => (e ? rej(e) : res())));
|
||||
resolve({ port: addr.port, close });
|
||||
});
|
||||
server.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
it(
|
||||
'isolatedContext + extraAllowedHosts allows loopback navigation and returns page text',
|
||||
async () => {
|
||||
const { port, close } = await startLoopbackServer();
|
||||
const dir = mkdtempSync(join(tmpdir(), 'rpa-loopback-'));
|
||||
const ctx = { workspacePath: dir, toolsConfig: {} } as any;
|
||||
|
||||
try {
|
||||
const r = await runPageActions(
|
||||
`http://localhost:${port}/`,
|
||||
[{ type: 'getText', selector: '[data-testid="t"]' }],
|
||||
ctx,
|
||||
{ isolatedContext: true, extraAllowedHosts: ['localhost', '127.0.0.1'] },
|
||||
);
|
||||
expect(r.texts.join('\n')).toContain('loopback-ok');
|
||||
expect(r.consoleErrors).toEqual([]);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
},
|
||||
60000,
|
||||
);
|
||||
|
||||
it(
|
||||
'without opts (normal BrowseWeb path) localhost is SSRF-blocked',
|
||||
async () => {
|
||||
const { port, close } = await startLoopbackServer();
|
||||
const dir = mkdtempSync(join(tmpdir(), 'rpa-ssrf-'));
|
||||
const ctx = { workspacePath: dir, toolsConfig: {} } as any;
|
||||
|
||||
try {
|
||||
// runPageActions without opts — should throw SSRF blocked
|
||||
await expect(
|
||||
runPageActions(
|
||||
`http://localhost:${port}/`,
|
||||
[{ type: 'getText', selector: '[data-testid="t"]' }],
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow(/SSRF blocked/i);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
},
|
||||
60000,
|
||||
);
|
||||
});
|
||||
+319
-192
@@ -155,7 +155,7 @@ async function ssrfCheck(url: string, allowedHosts: string[], workspacePath: str
|
||||
|
||||
// --- Tool definitions ---
|
||||
|
||||
interface BrowseWebAction {
|
||||
export interface BrowseWebAction {
|
||||
type: 'goto' | 'click' | 'fill' | 'screenshot' | 'getText' | 'wait' | 'dumpHtml';
|
||||
selector?: string;
|
||||
ref?: string;
|
||||
@@ -1381,13 +1381,82 @@ async function executeSimple(
|
||||
}
|
||||
}
|
||||
|
||||
async function executeActions(
|
||||
/** Internal extended result that also carries the per-action log lines for ToolResult wrapping. */
|
||||
interface RunPageActionsInternal {
|
||||
screenshots: string[];
|
||||
texts: string[];
|
||||
consoleErrors: string[];
|
||||
html?: string;
|
||||
_rawResults: string[];
|
||||
}
|
||||
|
||||
/** Options for runPageActions / runPageActionsInternal */
|
||||
export interface RunPageActionsOpts {
|
||||
/**
|
||||
* When true, creates a FRESH ephemeral browser context instead of the
|
||||
* pooled job context. The context is torn down in a finally block.
|
||||
* Use this when the caller needs to navigate to loopback (e.g. a local
|
||||
* test-harness server) without leaking loopback-allow to normal BrowseWeb.
|
||||
*/
|
||||
isolatedContext?: boolean;
|
||||
/**
|
||||
* Additional hostnames that are allowed past the SSRF gate for THIS call
|
||||
* only. Merged with ctx.toolsConfig?.webfetchAllowedHosts.
|
||||
* Has no effect unless the host also appears in this list OR the route
|
||||
* interception is configured with the merged list (which isolatedContext
|
||||
* ensures).
|
||||
*/
|
||||
extraAllowedHosts?: string[];
|
||||
}
|
||||
|
||||
async function runPageActionsInternal(
|
||||
url: string,
|
||||
actions: BrowseWebAction[],
|
||||
ctx: ToolContext,
|
||||
recordTo?: string,
|
||||
): Promise<ToolResult> {
|
||||
opts?: RunPageActionsOpts,
|
||||
): Promise<RunPageActionsInternal> {
|
||||
const actionTimeout = ctx.toolsConfig?.browserActionTimeout ?? 30000;
|
||||
const allowedHosts = ctx.toolsConfig?.webfetchAllowedHosts ?? [];
|
||||
const allowedHosts = [
|
||||
...(ctx.toolsConfig?.webfetchAllowedHosts ?? []),
|
||||
...(opts?.extraAllowedHosts ?? []),
|
||||
];
|
||||
|
||||
// When isolatedContext is requested: create a fresh ephemeral context so
|
||||
// that loopback-allow (extraAllowedHosts) is scoped entirely to this call
|
||||
// and never leaks into the pooled job context used by normal BrowseWeb.
|
||||
let ephemeralContext: import('playwright').BrowserContext | null = null;
|
||||
let page: import('playwright').Page;
|
||||
|
||||
if (opts?.isolatedContext) {
|
||||
const browser = await getHeadlessBrowser();
|
||||
ephemeralContext = await browser.newContext();
|
||||
page = await ephemeralContext.newPage();
|
||||
await setupRouteInterception(page, allowedHosts, ctx.workspacePath);
|
||||
page.setDefaultTimeout(actionTimeout);
|
||||
} else {
|
||||
page = await getJobPage(ctx, allowedHosts, actionTimeout);
|
||||
}
|
||||
|
||||
// Collect browser console errors and uncaught page errors.
|
||||
// Capture handler references so we can remove them after the action loop,
|
||||
// preventing listener accumulation on pooled/long-lived Page objects.
|
||||
const consoleErrors: string[] = [];
|
||||
const consoleHandler = (msg: import('playwright').ConsoleMessage) => {
|
||||
if (msg.type() === 'error') {
|
||||
consoleErrors.push(`[console.error] ${msg.text()}`);
|
||||
}
|
||||
};
|
||||
const pageerrorHandler = (err: Error) => {
|
||||
consoleErrors.push(`[pageerror] ${err.message}`);
|
||||
};
|
||||
page.on('console', consoleHandler);
|
||||
page.on('pageerror', pageerrorHandler);
|
||||
|
||||
try {
|
||||
const screenshots: string[] = [];
|
||||
const texts: string[] = [];
|
||||
let lastHtml: string | undefined;
|
||||
|
||||
// Helper to record a successful action. Errors in recording must never fail the BrowseWeb action.
|
||||
function tryRecord(entry: Omit<RecordedAction, 'ts'>): void {
|
||||
@@ -1401,199 +1470,257 @@ async function executeActions(
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const page = await getJobPage(ctx, allowedHosts, actionTimeout);
|
||||
const results: string[] = [];
|
||||
|
||||
for (const action of actions) {
|
||||
switch (action.type) {
|
||||
case 'goto': {
|
||||
const gotoUrl = action.url;
|
||||
if (!gotoUrl) {
|
||||
results.push('[goto] error: url is required');
|
||||
break;
|
||||
}
|
||||
const normalizedUrl = normalizeFileUrlForWorkspace(gotoUrl, ctx.workspacePath);
|
||||
if ('error' in normalizedUrl) {
|
||||
results.push(`[goto] error: ${normalizedUrl.error}`);
|
||||
break;
|
||||
}
|
||||
const ssrfError = await ssrfCheck(normalizedUrl.url, allowedHosts, ctx.workspacePath);
|
||||
if (ssrfError) {
|
||||
results.push(`[goto] SSRF blocked: ${ssrfError}`);
|
||||
break;
|
||||
}
|
||||
await page.goto(normalizedUrl.url, { waitUntil: 'load', timeout: actionTimeout });
|
||||
results.push(`[goto] navigated to ${gotoUrl}`);
|
||||
tryRecord({ type: 'goto', url: gotoUrl, frameChain: [] });
|
||||
const expiredReason = await checkAuthExpiry(page, ctx);
|
||||
if (expiredReason) {
|
||||
return {
|
||||
output: `AUTH_SESSION_EXPIRED: ${expiredReason}\n${results.join('\n')}`,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'click': {
|
||||
// ref があればそれを優先 (フレームを跨いで解決可能)。selector 直指定は
|
||||
// メインフレームに対する操作とみなす。
|
||||
let frame: Frame = page.mainFrame();
|
||||
let selector = action.selector;
|
||||
if (!selector && action.ref) {
|
||||
const target = resolveRef(page, action.ref);
|
||||
if (!target) {
|
||||
results.push(`[click] ref "${action.ref}" not found in current snapshot. Get a fresh snapshot with getText first.`);
|
||||
break;
|
||||
}
|
||||
frame = target.frame;
|
||||
selector = target.selector;
|
||||
}
|
||||
if (!selector) {
|
||||
results.push('[click] error: selector または ref が必要です');
|
||||
break;
|
||||
}
|
||||
await frame.click(selector, { timeout: actionTimeout });
|
||||
results.push(`[click] clicked ${action.ref ?? selector}`);
|
||||
// Only resolve the DOM-based selector path when recording is active.
|
||||
// buildSelectorPath runs a page.evaluate round-trip; skip it for
|
||||
// non-recording BrowseWeb calls. tryRecord already early-returns when
|
||||
// not recording, but this avoids the evaluate overhead entirely.
|
||||
if (ctx.taskId && recorder.recordTo(ctx.taskId)) {
|
||||
let resolvedSelector = selector;
|
||||
try {
|
||||
const locator = frame.locator(selector).first();
|
||||
resolvedSelector = await buildSelectorPath(locator);
|
||||
} catch (_e) {
|
||||
// element may have detached — fall back to the raw selector
|
||||
}
|
||||
const frameChain = await captureFrameChain(frame);
|
||||
tryRecord({ type: 'click', selector: resolvedSelector, originalRef: action.ref, frameChain });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'fill': {
|
||||
let frame: Frame = page.mainFrame();
|
||||
let selector = action.selector;
|
||||
if (!selector && action.ref) {
|
||||
const target = resolveRef(page, action.ref);
|
||||
if (!target) {
|
||||
results.push(`[fill] ref "${action.ref}" not found in current snapshot.`);
|
||||
break;
|
||||
}
|
||||
frame = target.frame;
|
||||
selector = target.selector;
|
||||
}
|
||||
if (!selector) {
|
||||
results.push('[fill] error: selector または ref が必要です');
|
||||
break;
|
||||
}
|
||||
await frame.fill(selector, action.value ?? '', { timeout: actionTimeout });
|
||||
results.push(`[fill] filled ${action.ref ?? selector}`);
|
||||
// Only resolve the DOM-based selector path when recording is active.
|
||||
if (ctx.taskId && recorder.recordTo(ctx.taskId)) {
|
||||
let resolvedSelector = selector;
|
||||
try {
|
||||
const locator = frame.locator(selector).first();
|
||||
resolvedSelector = await buildSelectorPath(locator);
|
||||
} catch (_e) {
|
||||
// element may have detached — fall back to the raw selector
|
||||
}
|
||||
const frameChain = await captureFrameChain(frame);
|
||||
tryRecord({ type: 'fill', selector: resolvedSelector, originalRef: action.ref, value: action.value, frameChain });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'screenshot': {
|
||||
const filename = action.value ?? 'screenshot.png';
|
||||
let savePath: string;
|
||||
try {
|
||||
savePath = resolveOutputPathWithin(ctx.workspacePath, path.join('output', filename), ['output']);
|
||||
} catch (e) {
|
||||
results.push(`[screenshot] error: ${(e as Error).message}`);
|
||||
break;
|
||||
}
|
||||
const { mkdirSync } = await import('fs');
|
||||
mkdirSync(path.dirname(savePath), { recursive: true });
|
||||
await page.screenshot({ path: savePath, fullPage: true });
|
||||
results.push(`[screenshot] saved to output/${filename}`);
|
||||
tryRecord({ type: 'screenshot', value: filename, frameChain: [] });
|
||||
break;
|
||||
}
|
||||
case 'getText': {
|
||||
if (!action.selector) {
|
||||
// 全ページの ref 注釈付きスナップショットを取得
|
||||
const fullText = await snapshotPage(page);
|
||||
const text = await saveBrowseText(ctx, page.url(), fullText, 'snapshot');
|
||||
results.push(`[getText] ${text}`);
|
||||
tryRecord({ type: 'getText', frameChain: [] });
|
||||
} else {
|
||||
const el = await page.$(action.selector);
|
||||
if (el) {
|
||||
const fullText = await el.innerText();
|
||||
const text = await saveBrowseText(ctx, page.url(), fullText, `selector:${action.selector}`);
|
||||
results.push(`[getText] ${text}`);
|
||||
tryRecord({ type: 'getText', selector: action.selector, frameChain: [] });
|
||||
} else {
|
||||
results.push(`[getText] selector "${action.selector}" not found`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'wait': {
|
||||
const ms = action.ms ?? 1000;
|
||||
const waitMs = Math.min(ms, 30000); // cap at 30s
|
||||
await page.waitForTimeout(waitMs);
|
||||
results.push(`[wait] waited ${waitMs}ms`);
|
||||
tryRecord({ type: 'wait', ms: waitMs, frameChain: [] });
|
||||
break;
|
||||
}
|
||||
case 'dumpHtml': {
|
||||
let frame: Frame = page.mainFrame();
|
||||
let selector = action.selector;
|
||||
if (!selector && action.ref) {
|
||||
const target = resolveRef(page, action.ref);
|
||||
if (!target) {
|
||||
results.push(`[dumpHtml] ref "${action.ref}" not found in current snapshot.`);
|
||||
break;
|
||||
}
|
||||
frame = target.frame;
|
||||
selector = target.selector;
|
||||
}
|
||||
const depth = Math.max(0, Math.min(action.depth ?? 3, 10));
|
||||
const fullHtml = await dumpElementHtml(frame, selector, depth);
|
||||
if (fullHtml === null) {
|
||||
results.push(`[dumpHtml] selector "${selector}" not found`);
|
||||
break;
|
||||
}
|
||||
const previewLimit = Math.max(500, Math.min(action.maxChars ?? BROWSE_TEXT_PREVIEW_CHARS, 50_000));
|
||||
const text = await saveBrowseText(
|
||||
ctx,
|
||||
page.url(),
|
||||
fullHtml,
|
||||
`dumpHtml:${action.ref ?? selector ?? 'body'}`,
|
||||
previewLimit,
|
||||
);
|
||||
results.push(`[dumpHtml ${action.ref ?? selector ?? 'body'}] ${text}`);
|
||||
if (ctx.taskId && recorder.recordTo(ctx.taskId)) {
|
||||
const frameChain = await captureFrameChain(frame);
|
||||
tryRecord({ type: 'dumpHtml', selector: selector ?? undefined, originalRef: action.ref, frameChain });
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
results.push(`[${action.type}] unknown action type`);
|
||||
}
|
||||
// Initial navigation when a url is provided
|
||||
if (url) {
|
||||
const normalizedUrl = normalizeFileUrlForWorkspace(url, ctx.workspacePath);
|
||||
if ('error' in normalizedUrl) {
|
||||
throw new Error(`Invalid URL: ${normalizedUrl.error}`);
|
||||
}
|
||||
const ssrfError = await ssrfCheck(normalizedUrl.url, allowedHosts, ctx.workspacePath);
|
||||
if (ssrfError) {
|
||||
throw new Error(`SSRF blocked: ${ssrfError}`);
|
||||
}
|
||||
await page.goto(normalizedUrl.url, { waitUntil: 'load', timeout: actionTimeout });
|
||||
}
|
||||
|
||||
// アクション中に発生したファイルダウンロードを output/ に取り出してレポート
|
||||
const downloads = await drainDownloads(page);
|
||||
const dlSummary = formatDownloadLines(downloads);
|
||||
if (dlSummary) results.push(dlSummary);
|
||||
// Internal results array mirrors the existing BrowseWeb text output (for ToolResult wrapping)
|
||||
const results: string[] = [];
|
||||
|
||||
return { output: results.join('\n'), isError: false };
|
||||
for (const action of actions) {
|
||||
switch (action.type) {
|
||||
case 'goto': {
|
||||
const gotoUrl = action.url;
|
||||
if (!gotoUrl) {
|
||||
results.push('[goto] error: url is required');
|
||||
break;
|
||||
}
|
||||
const normalizedUrl = normalizeFileUrlForWorkspace(gotoUrl, ctx.workspacePath);
|
||||
if ('error' in normalizedUrl) {
|
||||
results.push(`[goto] error: ${normalizedUrl.error}`);
|
||||
break;
|
||||
}
|
||||
const ssrfError = await ssrfCheck(normalizedUrl.url, allowedHosts, ctx.workspacePath);
|
||||
if (ssrfError) {
|
||||
results.push(`[goto] SSRF blocked: ${ssrfError}`);
|
||||
break;
|
||||
}
|
||||
await page.goto(normalizedUrl.url, { waitUntil: 'load', timeout: actionTimeout });
|
||||
results.push(`[goto] navigated to ${gotoUrl}`);
|
||||
tryRecord({ type: 'goto', url: gotoUrl, frameChain: [] });
|
||||
const expiredReason = await checkAuthExpiry(page, ctx);
|
||||
if (expiredReason) {
|
||||
throw new Error(`AUTH_SESSION_EXPIRED: ${expiredReason}\n${results.join('\n')}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'click': {
|
||||
// ref があればそれを優先 (フレームを跨いで解決可能)。selector 直指定は
|
||||
// メインフレームに対する操作とみなす。
|
||||
let frame: Frame = page.mainFrame();
|
||||
let selector = action.selector;
|
||||
if (!selector && action.ref) {
|
||||
const target = resolveRef(page, action.ref);
|
||||
if (!target) {
|
||||
results.push(`[click] ref "${action.ref}" not found in current snapshot. Get a fresh snapshot with getText first.`);
|
||||
break;
|
||||
}
|
||||
frame = target.frame;
|
||||
selector = target.selector;
|
||||
}
|
||||
if (!selector) {
|
||||
results.push('[click] error: selector または ref が必要です');
|
||||
break;
|
||||
}
|
||||
await frame.click(selector, { timeout: actionTimeout });
|
||||
results.push(`[click] clicked ${action.ref ?? selector}`);
|
||||
// Only resolve the DOM-based selector path when recording is active.
|
||||
// buildSelectorPath runs a page.evaluate round-trip; skip it for
|
||||
// non-recording BrowseWeb calls. tryRecord already early-returns when
|
||||
// not recording, but this avoids the evaluate overhead entirely.
|
||||
if (ctx.taskId && recorder.recordTo(ctx.taskId)) {
|
||||
let resolvedSelector = selector;
|
||||
try {
|
||||
const locator = frame.locator(selector).first();
|
||||
resolvedSelector = await buildSelectorPath(locator);
|
||||
} catch (_e) {
|
||||
// element may have detached — fall back to the raw selector
|
||||
}
|
||||
const frameChain = await captureFrameChain(frame);
|
||||
tryRecord({ type: 'click', selector: resolvedSelector, originalRef: action.ref, frameChain });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'fill': {
|
||||
let frame: Frame = page.mainFrame();
|
||||
let selector = action.selector;
|
||||
if (!selector && action.ref) {
|
||||
const target = resolveRef(page, action.ref);
|
||||
if (!target) {
|
||||
results.push(`[fill] ref "${action.ref}" not found in current snapshot.`);
|
||||
break;
|
||||
}
|
||||
frame = target.frame;
|
||||
selector = target.selector;
|
||||
}
|
||||
if (!selector) {
|
||||
results.push('[fill] error: selector または ref が必要です');
|
||||
break;
|
||||
}
|
||||
await frame.fill(selector, action.value ?? '', { timeout: actionTimeout });
|
||||
results.push(`[fill] filled ${action.ref ?? selector}`);
|
||||
// Only resolve the DOM-based selector path when recording is active.
|
||||
if (ctx.taskId && recorder.recordTo(ctx.taskId)) {
|
||||
let resolvedSelector = selector;
|
||||
try {
|
||||
const locator = frame.locator(selector).first();
|
||||
resolvedSelector = await buildSelectorPath(locator);
|
||||
} catch (_e) {
|
||||
// element may have detached — fall back to the raw selector
|
||||
}
|
||||
const frameChain = await captureFrameChain(frame);
|
||||
tryRecord({ type: 'fill', selector: resolvedSelector, originalRef: action.ref, value: action.value, frameChain });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'screenshot': {
|
||||
const filename = action.value ?? 'screenshot.png';
|
||||
let savePath: string;
|
||||
try {
|
||||
savePath = resolveOutputPathWithin(ctx.workspacePath, path.join('output', filename), ['output']);
|
||||
} catch (e) {
|
||||
results.push(`[screenshot] error: ${(e as Error).message}`);
|
||||
break;
|
||||
}
|
||||
const { mkdirSync } = await import('fs');
|
||||
mkdirSync(path.dirname(savePath), { recursive: true });
|
||||
await page.screenshot({ path: savePath, fullPage: true });
|
||||
results.push(`[screenshot] saved to output/${filename}`);
|
||||
screenshots.push(`output/${filename}`);
|
||||
tryRecord({ type: 'screenshot', value: filename, frameChain: [] });
|
||||
break;
|
||||
}
|
||||
case 'getText': {
|
||||
if (!action.selector) {
|
||||
// 全ページの ref 注釈付きスナップショットを取得
|
||||
const fullText = await snapshotPage(page);
|
||||
const text = await saveBrowseText(ctx, page.url(), fullText, 'snapshot');
|
||||
results.push(`[getText] ${text}`);
|
||||
texts.push(fullText);
|
||||
tryRecord({ type: 'getText', frameChain: [] });
|
||||
} else {
|
||||
const el = await page.$(action.selector);
|
||||
if (el) {
|
||||
const fullText = await el.innerText();
|
||||
const text = await saveBrowseText(ctx, page.url(), fullText, `selector:${action.selector}`);
|
||||
results.push(`[getText] ${text}`);
|
||||
texts.push(fullText);
|
||||
tryRecord({ type: 'getText', selector: action.selector, frameChain: [] });
|
||||
} else {
|
||||
results.push(`[getText] selector "${action.selector}" not found`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'wait': {
|
||||
const ms = action.ms ?? 1000;
|
||||
const waitMs = Math.min(ms, 30000); // cap at 30s
|
||||
await page.waitForTimeout(waitMs);
|
||||
results.push(`[wait] waited ${waitMs}ms`);
|
||||
tryRecord({ type: 'wait', ms: waitMs, frameChain: [] });
|
||||
break;
|
||||
}
|
||||
case 'dumpHtml': {
|
||||
let frame: Frame = page.mainFrame();
|
||||
let selector = action.selector;
|
||||
if (!selector && action.ref) {
|
||||
const target = resolveRef(page, action.ref);
|
||||
if (!target) {
|
||||
results.push(`[dumpHtml] ref "${action.ref}" not found in current snapshot.`);
|
||||
break;
|
||||
}
|
||||
frame = target.frame;
|
||||
selector = target.selector;
|
||||
}
|
||||
const depth = Math.max(0, Math.min(action.depth ?? 3, 10));
|
||||
const fullHtml = await dumpElementHtml(frame, selector, depth);
|
||||
if (fullHtml === null) {
|
||||
results.push(`[dumpHtml] selector "${selector}" not found`);
|
||||
break;
|
||||
}
|
||||
const previewLimit = Math.max(500, Math.min(action.maxChars ?? BROWSE_TEXT_PREVIEW_CHARS, 50_000));
|
||||
const text = await saveBrowseText(
|
||||
ctx,
|
||||
page.url(),
|
||||
fullHtml,
|
||||
`dumpHtml:${action.ref ?? selector ?? 'body'}`,
|
||||
previewLimit,
|
||||
);
|
||||
results.push(`[dumpHtml ${action.ref ?? selector ?? 'body'}] ${text}`);
|
||||
texts.push(fullHtml);
|
||||
lastHtml = fullHtml;
|
||||
if (ctx.taskId && recorder.recordTo(ctx.taskId)) {
|
||||
const frameChain = await captureFrameChain(frame);
|
||||
tryRecord({ type: 'dumpHtml', selector: selector ?? undefined, originalRef: action.ref, frameChain });
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
results.push(`[${(action as BrowseWebAction).type}] unknown action type`);
|
||||
}
|
||||
}
|
||||
|
||||
// アクション中に発生したファイルダウンロードを output/ に取り出してレポート
|
||||
const downloads = await drainDownloads(page);
|
||||
const dlSummary = formatDownloadLines(downloads);
|
||||
if (dlSummary) results.push(dlSummary);
|
||||
|
||||
return { screenshots, texts, consoleErrors, html: lastHtml, _rawResults: results };
|
||||
} finally {
|
||||
// Remove per-call listeners to prevent accumulation on pooled Pages.
|
||||
page.off('console', consoleHandler);
|
||||
page.off('pageerror', pageerrorHandler);
|
||||
// Tear down ephemeral context so loopback-allow never leaks to the pool.
|
||||
if (ephemeralContext) {
|
||||
await ephemeralContext.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Core Playwright action runner. Navigates to `url` (if non-empty) then executes
|
||||
* `actions` in order. Returns structured results for internal reuse by other
|
||||
* server modules (e.g., TestWorkspaceApp).
|
||||
*
|
||||
* `screenshots` — workspace-relative paths of saved screenshots (e.g., "output/foo.png")
|
||||
* `texts` — text/html strings produced by getText / dumpHtml actions
|
||||
* `consoleErrors` — browser console errors and uncaught page errors
|
||||
* `html` — last dumpHtml result (convenience alias of last texts entry from dumpHtml)
|
||||
*/
|
||||
export async function runPageActions(
|
||||
url: string,
|
||||
actions: BrowseWebAction[],
|
||||
ctx: ToolContext,
|
||||
opts?: RunPageActionsOpts,
|
||||
): Promise<{ screenshots: string[]; texts: string[]; consoleErrors: string[]; html?: string }> {
|
||||
const { _rawResults: _unused, ...pub } = await runPageActionsInternal(url, actions, ctx, undefined, opts);
|
||||
return pub;
|
||||
}
|
||||
|
||||
async function executeActions(
|
||||
actions: BrowseWebAction[],
|
||||
ctx: ToolContext,
|
||||
recordTo?: string,
|
||||
): Promise<ToolResult> {
|
||||
try {
|
||||
const raw = await runPageActionsInternal('', actions, ctx, recordTo);
|
||||
return { output: raw._rawResults.join('\n'), isError: false };
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message ?? String(e);
|
||||
// AUTH_SESSION_EXPIRED errors include the partial results in the message
|
||||
if (msg.startsWith('AUTH_SESSION_EXPIRED:')) {
|
||||
logger.warn(`[BrowseWeb] auth expired during actions`);
|
||||
return { output: msg, isError: true };
|
||||
}
|
||||
logger.warn(`[BrowseWeb] action error: ${msg}`);
|
||||
return { output: `BrowseWeb error: ${msg}`, isError: true };
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ const ADD_CALENDAR_EVENT_DEF: ToolDef = {
|
||||
title: { type: 'string', description: '予定のタイトル(必須)' },
|
||||
date: { type: 'string', description: '開始日 YYYY-MM-DD(ローカル日付、必須)' },
|
||||
end_date: { type: 'string', description: '終了日 YYYY-MM-DD。複数日にまたがる予定のとき指定。省略すると単日' },
|
||||
time: { type: 'string', description: 'HH:MM。省略すると終日扱い' },
|
||||
time: { type: 'string', description: '開始 HH:MM。省略すると終日扱い' },
|
||||
end_time: { type: 'string', description: '終了 HH:MM。time(開始)があるときのみ有効。単日は開始以降のみ' },
|
||||
description: { type: 'string', description: '予定の補足説明(任意)' },
|
||||
},
|
||||
required: ['title', 'date'],
|
||||
@@ -84,6 +85,7 @@ async function executeAddCalendarEvent(
|
||||
const date = input['date'];
|
||||
const endDateInput = input['end_date'];
|
||||
const time = input['time'];
|
||||
const endTimeInput = input['end_time'];
|
||||
const description = input['description'];
|
||||
|
||||
if (typeof title !== 'string' || !title.trim()) {
|
||||
@@ -105,11 +107,26 @@ async function executeAddCalendarEvent(
|
||||
}
|
||||
endDate = endDateInput;
|
||||
}
|
||||
const startTime = typeof time === 'string' && time !== '' ? time : null;
|
||||
if (time !== undefined && time !== null && time !== '') {
|
||||
if (typeof time !== 'string' || !TIME_PATTERN.test(time)) {
|
||||
return { output: 'time は HH:MM 形式で指定してください', isError: true };
|
||||
}
|
||||
}
|
||||
let endTime: string | null = null;
|
||||
if (endTimeInput !== undefined && endTimeInput !== null && endTimeInput !== '') {
|
||||
if (typeof endTimeInput !== 'string' || !TIME_PATTERN.test(endTimeInput)) {
|
||||
return { output: 'end_time は HH:MM 形式で指定してください', isError: true };
|
||||
}
|
||||
if (startTime == null) {
|
||||
return { output: 'end_time は time(開始時刻)とあわせて指定してください', isError: true };
|
||||
}
|
||||
const multiDay = endDate != null && endDate > date;
|
||||
if (!multiDay && endTimeInput < startTime) {
|
||||
return { output: 'end_time は開始時刻以降にしてください(単日の予定)', isError: true };
|
||||
}
|
||||
endTime = endTimeInput;
|
||||
}
|
||||
if (description !== undefined && description !== null) {
|
||||
if (typeof description !== 'string') {
|
||||
return { output: 'description は文字列で指定してください', isError: true };
|
||||
@@ -125,14 +142,16 @@ async function executeAddCalendarEvent(
|
||||
ownerId: ctx.ownerId ?? null,
|
||||
date,
|
||||
endDate,
|
||||
time: typeof time === 'string' && time !== '' ? time : null,
|
||||
time: startTime,
|
||||
endTime,
|
||||
title: title.trim(),
|
||||
description: typeof description === 'string' ? description : null,
|
||||
createdBy: 'agent',
|
||||
sourceTaskId: Number.isFinite(sourceTaskId) ? sourceTaskId : null,
|
||||
});
|
||||
const timeLabel = ev.time ? (ev.endTime ? `${ev.time}–${ev.endTime}` : ev.time) : '(終日)';
|
||||
return {
|
||||
output: `予定を追加しました: ${ev.date}${ev.time ? ` ${ev.time}` : '(終日)'} 「${ev.title}」(id=${ev.id})`,
|
||||
output: `予定を追加しました: ${ev.date}${ev.endDate ? `–${ev.endDate}` : ''} ${timeLabel} 「${ev.title}」(id=${ev.id})`,
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
@@ -166,8 +185,9 @@ async function executeListCalendarEvents(
|
||||
if (events.length === 0) {
|
||||
return { output: '指定範囲に予定はありません', isError: false };
|
||||
}
|
||||
const lines = events.map(
|
||||
(e) => `- ${e.date}${e.time ? ` ${e.time}` : '(終日)'} 「${e.title}」(id=${e.id}, ${e.createdBy})`,
|
||||
);
|
||||
const lines = events.map((e) => {
|
||||
const timeLabel = e.time ? (e.endTime ? `${e.time}–${e.endTime}` : e.time) : '(終日)';
|
||||
return `- ${e.date}${e.endDate ? `–${e.endDate}` : ''} ${timeLabel} 「${e.title}」(id=${e.id}, ${e.createdBy})`;
|
||||
});
|
||||
return { output: lines.join('\n'), isError: false };
|
||||
}
|
||||
|
||||
@@ -50,6 +50,35 @@ describe('core tools', () => {
|
||||
expect(result?.output).toContain('ディレクトリ');
|
||||
});
|
||||
|
||||
it('strips a leading UTF-8 BOM from Read output but leaves the file on disk untouched', async () => {
|
||||
// #011: Windows メモ帳などが付ける UTF-8 BOM (EF BB BF) が U+FEFF として
|
||||
// 先頭に残ると LLM 側で文字化けする。Read の返却テキストからは剥がす。
|
||||
workspacePath = makeWorkspace();
|
||||
fs.mkdirSync(path.join(workspacePath, 'input'), { recursive: true });
|
||||
const abs = path.join(workspacePath, 'input', 'bom.txt');
|
||||
fs.writeFileSync(abs, '# 見出し\nhello', 'utf-8');
|
||||
|
||||
const result = await executeCoreTools('Read', { file_path: 'input/bom.txt' }, makeContext(workspacePath));
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(result?.output.charCodeAt(0)).not.toBe(0xfeff);
|
||||
expect(result?.output.startsWith('# 見出し')).toBe(true);
|
||||
// ディスク上のファイルは無改変(BOM は残っている)。
|
||||
expect(fs.readFileSync(abs)[0]).toBe(0xef);
|
||||
});
|
||||
|
||||
it('strips the BOM on byte-range Read from the start, but not mid-file', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
fs.mkdirSync(path.join(workspacePath, 'input'), { recursive: true });
|
||||
fs.writeFileSync(path.join(workspacePath, 'input', 'bom2.txt'), 'ABCDEF', 'utf-8');
|
||||
|
||||
// BOM は 3 バイト (EF BB BF)。先頭から 6 バイト読めば BOM + "ABC"。
|
||||
const fromStart = await executeCoreTools('Read', { file_path: 'input/bom2.txt', byte_offset: 0, byte_length: 6 }, makeContext(workspacePath));
|
||||
expect(fromStart?.output.charCodeAt(0)).not.toBe(0xfeff);
|
||||
expect(fromStart?.output.startsWith('ABC')).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks Read on PDF files and suggests ReadPdf', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
fs.mkdirSync(path.join(workspacePath, 'input'), { recursive: true });
|
||||
@@ -847,3 +876,92 @@ describe('Read delegates to shared binary detector', () => {
|
||||
expect(looksLikeBinaryBytes(Buffer.from('export const x = 1;\n', 'utf-8')).binary).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readonly/ enforcement (assertWritable)', () => {
|
||||
let workspacePath = '';
|
||||
|
||||
afterEach(() => {
|
||||
if (workspacePath) {
|
||||
fs.rmSync(workspacePath, { recursive: true, force: true });
|
||||
workspacePath = '';
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects Write under readonly/ but leaves the file untouched', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
fs.mkdirSync(path.join(workspacePath, 'readonly'), { recursive: true });
|
||||
fs.writeFileSync(path.join(workspacePath, 'readonly', 'keep.txt'), 'original', 'utf-8');
|
||||
|
||||
const result = await executeCoreTools(
|
||||
'Write',
|
||||
{ file_path: 'readonly/keep.txt', content: 'agent overwrite' },
|
||||
makeContext(workspacePath),
|
||||
);
|
||||
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('readonly/');
|
||||
// The user's file on disk is unchanged.
|
||||
expect(fs.readFileSync(path.join(workspacePath, 'readonly', 'keep.txt'), 'utf-8')).toBe('original');
|
||||
});
|
||||
|
||||
it('rejects creating a new file under readonly/', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const result = await executeCoreTools(
|
||||
'Write',
|
||||
{ file_path: 'readonly/new.txt', content: 'x' },
|
||||
makeContext(workspacePath),
|
||||
);
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(fs.existsSync(path.join(workspacePath, 'readonly', 'new.txt'))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects Edit under readonly/', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
fs.mkdirSync(path.join(workspacePath, 'readonly'), { recursive: true });
|
||||
fs.writeFileSync(path.join(workspacePath, 'readonly', 'doc.md'), 'hello world', 'utf-8');
|
||||
|
||||
const result = await executeCoreTools(
|
||||
'Edit',
|
||||
{ file_path: 'readonly/doc.md', old_string: 'hello', new_string: 'goodbye' },
|
||||
makeContext(workspacePath),
|
||||
);
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(fs.readFileSync(path.join(workspacePath, 'readonly', 'doc.md'), 'utf-8')).toBe('hello world');
|
||||
});
|
||||
|
||||
it('allows Read of files under readonly/ (read is fine, only writes are blocked)', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
fs.mkdirSync(path.join(workspacePath, 'readonly'), { recursive: true });
|
||||
fs.writeFileSync(path.join(workspacePath, 'readonly', 'note.txt'), 'visible content', 'utf-8');
|
||||
|
||||
const result = await executeCoreTools(
|
||||
'Read',
|
||||
{ file_path: 'readonly/note.txt' },
|
||||
makeContext(workspacePath),
|
||||
);
|
||||
expect(result?.isError).toBeFalsy();
|
||||
expect(result?.output).toContain('visible content');
|
||||
});
|
||||
|
||||
it('still allows Write to output/ (non-readonly dirs unaffected)', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const result = await executeCoreTools(
|
||||
'Write',
|
||||
{ file_path: 'output/result.txt', content: 'done' },
|
||||
makeContext(workspacePath),
|
||||
);
|
||||
expect(result?.isError).toBeFalsy();
|
||||
expect(fs.readFileSync(path.join(workspacePath, 'output', 'result.txt'), 'utf-8')).toBe('done');
|
||||
});
|
||||
|
||||
it('does not block a top-level file whose name merely starts with "readonly"', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const result = await executeCoreTools(
|
||||
'Write',
|
||||
{ file_path: 'readonly-notes.txt', content: 'ok' },
|
||||
makeContext(workspacePath),
|
||||
);
|
||||
expect(result?.isError).toBeFalsy();
|
||||
expect(fs.existsSync(path.join(workspacePath, 'readonly-notes.txt'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import { logger } from '../../logger.js';
|
||||
import type { SearchFilterConfig } from '../../config.js';
|
||||
import type { ContextManager } from '../context-manager.js';
|
||||
import { executeSandboxedBash, isBwrapAvailable, buildSandboxEnv, type SandboxedBashResult } from './sandbox.js';
|
||||
import { looksLikeBinaryBytes, SNIFF_HEAD_BYTES } from './binary-detect.js';
|
||||
import { looksLikeBinaryBytes, SNIFF_HEAD_BYTES, stripLeadingBom } from './binary-detect.js';
|
||||
import { recordVersion, resolveWriteTarget } from './conflict-guard.js';
|
||||
|
||||
export interface ToolsConfig {
|
||||
@@ -115,6 +115,17 @@ export interface ToolContext {
|
||||
mcpQuotaState?: { files: number; bytes: number };
|
||||
runIsolatedLlm?: (messages: Message[]) => Promise<string>;
|
||||
spawnSubTask?: (params: { title: string; instruction: string; piece?: string }) => Promise<{ jobId: string; subtaskIndex: number; workspacePath: string }>;
|
||||
/**
|
||||
* 相 B: 直列・文脈節約型サブエージェント実行。delegate ツールから呼ぶ。
|
||||
* 親ループ内で同期的にサブエージェントを完了まで走らせ最終結果のみ返す。
|
||||
* agent-loop が深さ上限未満のときだけ注入する(未設定 = delegate 使用不可)。
|
||||
*/
|
||||
runDelegate?: (params: { description: string; prompt: string }) => Promise<{ result: string; status: 'success' | 'aborted' | 'needs_user_input' }>;
|
||||
/** 相 B: 現在の委譲ネスト深さ(0=トップ)。runDelegate がサブに +1 して渡す。 */
|
||||
delegationDepth?: number;
|
||||
/** 相 D0: 現在の delegate run の安定 ID。runDelegateSubAgent がサブ ctx に設定し、
|
||||
* ネストした delegate の parentRunId 導出に使う(イベントの可観測性復元用)。 */
|
||||
delegateRunId?: string;
|
||||
contextManager?: ContextManager; // Read 系 / Bash の出力サイズをコンテキスト残量に合わせて切り詰めるために使用
|
||||
/**
|
||||
* Traceability T-1: structured event log writer. Optional at the
|
||||
@@ -456,6 +467,30 @@ export function resolveAndGuard(workspacePath: string, filePath: string): string
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* ワークスペース直下の閲覧専用ディレクトリ名。ここ配下のファイルは Read 可・
|
||||
* Write/Edit 不可。ユーザーが「エージェントに編集されたくないファイル」を置く領域。
|
||||
*/
|
||||
export const READONLY_WORKSPACE_DIR = 'readonly';
|
||||
|
||||
/**
|
||||
* `resolved`(resolveAndGuard 済みの絶対パス)が `<workspace>/readonly/` 配下なら
|
||||
* 例外を投げる。Read は許可し、Write/Edit/上書きだけを拒否する。
|
||||
*
|
||||
* 注意: Bash はシェル経由でこのガードを迂回できる(完全サンドボックスは別範囲)。
|
||||
* システムプロンプトでも readonly/ への書込禁止を指示し、二層で主要動線を塞ぐ。
|
||||
*/
|
||||
export function assertWritable(workspacePath: string, resolved: string): void {
|
||||
const root = path.resolve(workspacePath);
|
||||
const readonlyRoot = path.join(root, READONLY_WORKSPACE_DIR);
|
||||
if (resolved === readonlyRoot || resolved.startsWith(readonlyRoot + path.sep)) {
|
||||
const rel = path.relative(root, resolved).split(path.sep).join('/');
|
||||
throw new Error(
|
||||
`"${rel}" は readonly/(閲覧のみ)配下です。ここはユーザー専用の領域で書き込み・編集できません。成果物は output/ に書いてください。`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWorkspaceRelativePath(workspacePath: string, targetPath: string): string {
|
||||
return path.relative(path.resolve(workspacePath), path.resolve(targetPath)).split(path.sep).join('/');
|
||||
}
|
||||
@@ -925,7 +960,8 @@ function executRead(input: Record<string, unknown>, ctx: ToolContext): ToolResul
|
||||
} catch (e) {
|
||||
return { output: `Read error: ${(e as Error).message}`, isError: true };
|
||||
}
|
||||
const text = buf.toString('utf-8');
|
||||
// BOM は先頭にしか現れない。範囲の先頭から読むときだけ剥がす。
|
||||
const text = start === 0 ? stripLeadingBom(buf.toString('utf-8')) : buf.toString('utf-8');
|
||||
const capped = effectiveLength < requested;
|
||||
if (capped) {
|
||||
const nextOffset = start + effectiveLength;
|
||||
@@ -941,7 +977,7 @@ function executRead(input: Record<string, unknown>, ctx: ToolContext): ToolResul
|
||||
|
||||
// 3. 行単位読み込み(既存挙動)+ 事前切り詰め
|
||||
try {
|
||||
const content = fs.readFileSync(resolved, 'utf-8');
|
||||
const content = stripLeadingBom(fs.readFileSync(resolved, 'utf-8'));
|
||||
const lines = content.split('\n');
|
||||
const sliced = limit !== undefined ? lines.slice(offset, offset + limit) : lines.slice(offset);
|
||||
const slicedText = sliced.join('\n');
|
||||
@@ -979,6 +1015,7 @@ function executeWrite(input: Record<string, unknown>, ctx: ToolContext): ToolRes
|
||||
// output/, which was inconsistent with overwrite (allowed anywhere) and
|
||||
// produced confusing failures for legitimate writes to other subdirs.
|
||||
resolved = resolveAndGuard(ctx.workspacePath, filePath);
|
||||
assertWritable(ctx.workspacePath, resolved);
|
||||
} catch (e) {
|
||||
return { output: (e as Error).message, isError: true };
|
||||
}
|
||||
@@ -1038,6 +1075,7 @@ function executeEdit(input: Record<string, unknown>, ctx: ToolContext): ToolResu
|
||||
let resolved: string;
|
||||
try {
|
||||
resolved = resolveAndGuard(ctx.workspacePath, filePath);
|
||||
assertWritable(ctx.workspacePath, resolved);
|
||||
} catch (e) {
|
||||
return { output: (e as Error).message, isError: true };
|
||||
}
|
||||
@@ -1123,6 +1161,8 @@ async function executeBash(input: Record<string, unknown>, ctx: ToolContext): Pr
|
||||
|
||||
const budgetTokens = getToolOutputBudgetTokens(ctx);
|
||||
const capOutput = (raw: string, label: string): string => {
|
||||
// cat 等で BOM 付きファイルを出力したときの先頭 U+FEFF を剥がす(stderr は無害)。
|
||||
raw = stripLeadingBom(raw);
|
||||
const { text, truncated } = truncateToBudget(raw, budgetTokens, {
|
||||
sourceLabel: `Bash ${label}`,
|
||||
continuationHint:
|
||||
@@ -1338,7 +1378,7 @@ function executeGrep(input: Record<string, unknown>, ctx: ToolContext): ToolResu
|
||||
const full = path.join(baseDir, rel);
|
||||
let content: string;
|
||||
try {
|
||||
content = fs.readFileSync(full, 'utf-8');
|
||||
content = stripLeadingBom(fs.readFileSync(full, 'utf-8'));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -71,6 +71,11 @@ const TOOL_DOC_ALIASES: Record<string, string> = {
|
||||
// browser.ts: InteractiveBrowse / BrowseWithSession は browseweb.md にまとめる
|
||||
interactivebrowse: 'browseweb',
|
||||
browsewithsession: 'browseweb',
|
||||
// 保存済みログインセッションの概念解説 (browse-sessions.md)。専用ツールは無いが、
|
||||
// 自然な名前で ReadToolDoc から引けるよう alias。
|
||||
browsersessions: 'browse-sessions',
|
||||
browsersession: 'browse-sessions',
|
||||
browsesessions: 'browse-sessions',
|
||||
// ssh.ts をまとめる
|
||||
sshexec: 'ssh-tools',
|
||||
sshupload: 'ssh-tools',
|
||||
@@ -93,6 +98,8 @@ const TOOL_DOC_ALIASES: Record<string, string> = {
|
||||
createapp: 'workspace-apps',
|
||||
buildapp: 'workspace-apps',
|
||||
appbridge: 'workspace-apps',
|
||||
// TestWorkspaceApp (testworkspaceapp.md)
|
||||
testworkspaceapp: 'testworkspaceapp',
|
||||
};
|
||||
|
||||
const READ_TOOL_DOC_DEF: ToolDef = {
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* index.dispatch.test.ts — Functional tests for the tool dispatch/registry
|
||||
* invariants in src/engine/tools/index.ts.
|
||||
*
|
||||
* These exercise the REAL exports (executeTool / getToolDefs) and the canonical
|
||||
* MODULE_SPECS from tool-categories.ts. They do not reimplement dispatch logic.
|
||||
*
|
||||
* Invariants under test:
|
||||
* 1. Dispatch routing: a known tool from a module routes to that module and
|
||||
* returns its result; an UNKNOWN name yields the well-formed unknown-tool
|
||||
* error (not a crash).
|
||||
* 2. first-match-wins / no duplicate names: getToolDefs() exposes each tool
|
||||
* name exactly once, and no static tool name is registered by two modules
|
||||
* (which would make the silent Object.assign / dispatch order load-bearing).
|
||||
* 3. Load-failure safety: getToolDefs() never throws and returns a stable,
|
||||
* non-empty catalog — the tryLoadModule swallow-on-failure contract.
|
||||
* 4. META_TOOLS drift guard: every name in index.ts's runtime META_TOOLS list
|
||||
* is a resolvable, always-available tool, and the tool-categories META
|
||||
* subset is ⊆ the index.ts list (the relationship the code documents).
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { executeTool, getToolDefs, type ToolContext } from './index.js';
|
||||
import {
|
||||
MODULE_SPECS,
|
||||
META_TOOLS as CATEGORIES_META_TOOLS,
|
||||
} from './tool-categories.js';
|
||||
|
||||
// The runtime META_TOOLS list lives inline in index.ts:411. It is intentionally
|
||||
// duplicated here (the source does not export it) so the test trips if the two
|
||||
// drift apart silently. The drift-guard test below also pins it against
|
||||
// getToolDefs(), so a stale copy here cannot mask a real regression.
|
||||
const INDEX_META_TOOLS = [
|
||||
'ReadToolDoc', 'CreateChecklist', 'CheckItem', 'GetChecklist', 'MissionUpdate',
|
||||
'ListUserAssets', 'RunUserScript', 'UpdateUserMemory', 'ReadUserMemory',
|
||||
'ReadUserAgents', 'UpdateUserAgents', 'WriteUserScript', 'Brainstorm',
|
||||
'ReadAppDoc', 'ListAppDocs', 'GetMyOrchestratorState', 'ReadSkill',
|
||||
'ListSkills', 'InstallSkill', 'RequestTool',
|
||||
];
|
||||
|
||||
function makeWorkspace(): string {
|
||||
return fs.mkdtempSync(path.join(tmpdir(), 'maestro-dispatch-'));
|
||||
}
|
||||
function makeContext(workspacePath: string, extra?: Partial<ToolContext>): ToolContext {
|
||||
return { workspacePath, editAllowed: true, ...extra } as ToolContext;
|
||||
}
|
||||
|
||||
describe('tools/index — dispatch routing', () => {
|
||||
let workspacePath = '';
|
||||
beforeEach(() => { workspacePath = makeWorkspace(); });
|
||||
afterEach(() => {
|
||||
if (workspacePath) { fs.rmSync(workspacePath, { recursive: true, force: true }); workspacePath = ''; }
|
||||
});
|
||||
|
||||
it('routes a core tool (Write) to the core module and returns its result', async () => {
|
||||
fs.mkdirSync(path.join(workspacePath, 'output'), { recursive: true });
|
||||
const result = await executeTool(
|
||||
'Write',
|
||||
{ file_path: 'output/hello.txt', content: 'hi' },
|
||||
makeContext(workspacePath),
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.isError).toBe(false);
|
||||
// The side effect proves the call reached the real core executor.
|
||||
expect(fs.readFileSync(path.join(workspacePath, 'output', 'hello.txt'), 'utf8')).toBe('hi');
|
||||
});
|
||||
|
||||
it('routes a checklist tool (CreateChecklist) to the checklist module', async () => {
|
||||
const result = await executeTool(
|
||||
'CreateChecklist',
|
||||
{ name: 'work', items: ['a', 'b'] },
|
||||
makeContext(workspacePath),
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
// Reached the real checklist executor: it persists a JSON file.
|
||||
expect(result.isError).toBe(false);
|
||||
const checklistPath = path.join(workspacePath, 'logs', 'checklists', 'work.json');
|
||||
expect(fs.existsSync(checklistPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('routes a docs tool (ReadToolDoc) past the docs module dispatch slot', async () => {
|
||||
// ReadToolDoc requires `name`; calling without it exercises the docs
|
||||
// executor's own validation (not the unknown-tool fallthrough).
|
||||
const result = await executeTool('ReadToolDoc', {}, makeContext(workspacePath));
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.output).toContain('ReadToolDoc');
|
||||
// Must NOT be the generic unknown-tool error — that would mean the docs
|
||||
// dispatch slot was skipped.
|
||||
expect(result.output).not.toContain('Unknown tool');
|
||||
});
|
||||
|
||||
it('returns the well-formed unknown-tool error for an unregistered name (no crash)', async () => {
|
||||
const result = await executeTool(
|
||||
'ThisToolDoesNotExist',
|
||||
{},
|
||||
makeContext(workspacePath),
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.output).toBe('Unknown tool: ThisToolDoesNotExist');
|
||||
});
|
||||
|
||||
it('treats an unknown mcp__ name distinctly (MCP path, not unknown-tool)', async () => {
|
||||
// No aggregator is registered in the test process, so an mcp__ name hits
|
||||
// the MCP guard branch — proving the mcp__ prefix routes before the static
|
||||
// module chain rather than falling through to "Unknown tool".
|
||||
const result = await executeTool(
|
||||
'mcp__nonexistent__doThing',
|
||||
{},
|
||||
makeContext(workspacePath),
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.output).not.toContain('Unknown tool');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tools/index — first-match-wins / no duplicate tool names', () => {
|
||||
it('getToolDefs() exposes every tool name at most once', async () => {
|
||||
// Request a broad allow-list so as many static tools as possible surface.
|
||||
// META tools are injected regardless. Any duplicate name in the returned
|
||||
// array would mean Object.assign silently overwrote a definition.
|
||||
const broad = [
|
||||
'Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep',
|
||||
'WebSearch', 'WebFetch', 'DownloadFile', 'ReadImage', 'SQLite',
|
||||
'ReadPdf', 'ReadExcel', 'ReadDocx', 'SpawnSubTask', 'BrowseWeb',
|
||||
'XSearch', 'SearchPlaces', 'GetYouTubeTranscript', 'SearchAmazon',
|
||||
'TranscribeAudio', 'ListPieces', 'GetPiece',
|
||||
];
|
||||
const defs = await getToolDefs(broad, true);
|
||||
const names = defs.map((d) => d.function.name);
|
||||
const seen = new Set<string>();
|
||||
const dupes = new Set<string>();
|
||||
for (const n of names) {
|
||||
if (seen.has(n)) dupes.add(n);
|
||||
seen.add(n);
|
||||
}
|
||||
expect([...dupes]).toEqual([]);
|
||||
});
|
||||
|
||||
it('no static tool name is registered by two different modules', async () => {
|
||||
// Replicate the runtime merge order using the canonical MODULE_SPECS plus
|
||||
// core. For each tool name we record EVERY module that exports it; a name
|
||||
// owned by 2+ modules means dispatch precedence / Object.assign order is
|
||||
// silently load-bearing — the invariant this test guards.
|
||||
const owners = new Map<string, string[]>();
|
||||
|
||||
// core first (ALL_TOOL_DEFS)
|
||||
try {
|
||||
const coreMod = (await import('./core.js')) as { ALL_TOOL_DEFS?: Record<string, unknown> };
|
||||
for (const name of Object.keys(coreMod.ALL_TOOL_DEFS ?? {})) {
|
||||
owners.set(name, ['core']);
|
||||
}
|
||||
} catch {
|
||||
// core should always load; if it doesn't, the other tests will fail loudly.
|
||||
}
|
||||
|
||||
for (const { category, specifier } of MODULE_SPECS) {
|
||||
const local = specifier.replace('../engine/tools/', './');
|
||||
let mod: { TOOL_DEFS?: Record<string, unknown> } | null = null;
|
||||
try {
|
||||
mod = (await import(local)) as { TOOL_DEFS?: Record<string, unknown> };
|
||||
} catch {
|
||||
continue; // module unavailable in this env — skip (runtime does the same)
|
||||
}
|
||||
for (const name of Object.keys(mod?.TOOL_DEFS ?? {})) {
|
||||
const list = owners.get(name) ?? [];
|
||||
// Same module specifier can appear twice (e.g. ssh + ssh-console share
|
||||
// category 'ssh'); only flag genuinely DIFFERENT category owners.
|
||||
if (!list.includes(category)) list.push(category);
|
||||
owners.set(name, list);
|
||||
}
|
||||
}
|
||||
|
||||
const collisions = [...owners.entries()].filter(([, mods]) => mods.length > 1);
|
||||
expect(collisions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tools/index — load-failure safety', () => {
|
||||
it('getToolDefs() never throws and returns a non-empty catalog', async () => {
|
||||
// tryLoadModule swallows import failures, so a broken optional module must
|
||||
// not break the catalog. We assert the observable invariant: the call
|
||||
// resolves with a non-empty array even with an empty allow-list (META tools
|
||||
// are always injected).
|
||||
const defs = await getToolDefs([], false);
|
||||
expect(Array.isArray(defs)).toBe(true);
|
||||
expect(defs.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('getToolDefs() is stable across repeated calls (cached module loads)', async () => {
|
||||
const a = await getToolDefs([], false);
|
||||
const b = await getToolDefs([], false);
|
||||
const namesA = a.map((d) => d.function.name).sort();
|
||||
const namesB = b.map((d) => d.function.name).sort();
|
||||
expect(namesA).toEqual(namesB);
|
||||
});
|
||||
|
||||
it('a known core tool still dispatches even though optional modules are skipped', async () => {
|
||||
// Core is loaded statically (not via tryLoadModule), so it survives any
|
||||
// optional-module load failure. Read against a missing file proves the core
|
||||
// executor ran and returned a structured error rather than crashing.
|
||||
const ws = makeWorkspace();
|
||||
try {
|
||||
const result = await executeTool('Read', { file_path: 'input/nope.txt' }, makeContext(ws));
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.output).not.toContain('Unknown tool');
|
||||
} finally {
|
||||
fs.rmSync(ws, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('tools/index — META_TOOLS drift guard', () => {
|
||||
it('every index.ts META_TOOLS name is always-available (injected without an allow-list)', async () => {
|
||||
// editAllowed:false, vlmEnabled omitted, EMPTY allowedTools — only META
|
||||
// tools (plus core defs that aren't edit-gated) should survive. Each META
|
||||
// name must resolve in the returned catalog, proving the injection wires up
|
||||
// a real registered tool (no dangling name).
|
||||
const defs = await getToolDefs([], false);
|
||||
const available = new Set(defs.map((d) => d.function.name));
|
||||
const missing = INDEX_META_TOOLS.filter((n) => !available.has(n));
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it('the tool-categories META subset is ⊆ the index.ts runtime META list', async () => {
|
||||
// tool-categories.ts documents its META_TOOLS as a SUBSET of index.ts's
|
||||
// (the runtime injection list). Assert that documented relationship: every
|
||||
// categories META name appears in the index.ts list. This holds even if the
|
||||
// index.ts list later grows beyond the catalog subset.
|
||||
const idx = new Set(INDEX_META_TOOLS);
|
||||
const notSubset = [...CATEGORIES_META_TOOLS].filter((n) => !idx.has(n));
|
||||
expect(notSubset).toEqual([]);
|
||||
});
|
||||
|
||||
it('every tool-categories META name is also an always-available registered tool', async () => {
|
||||
// The catalog META subset tags /api/tools entries as source=meta; each must
|
||||
// correspond to a real, always-injected tool too (else the catalog tags a
|
||||
// phantom).
|
||||
const defs = await getToolDefs([], false);
|
||||
const available = new Set(defs.map((d) => d.function.name));
|
||||
const missing = [...CATEGORIES_META_TOOLS].filter((n) => !available.has(n));
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
ToolResult,
|
||||
ToolsConfig,
|
||||
ALL_TOOL_DEFS,
|
||||
getToolDefs as getCoreToolDefs,
|
||||
executeCoreTools,
|
||||
runtimeLogsDir,
|
||||
} from './core.js';
|
||||
@@ -62,6 +61,7 @@ let _checklistModule: ToolModule | null | undefined = undefined;
|
||||
let _msLearnModule: ToolModule | null | undefined = undefined;
|
||||
let _slideModule: ToolModule | null | undefined = undefined;
|
||||
let _userFolderModule: ToolModule | null | undefined = undefined;
|
||||
let _appTestModule: ToolModule | null | undefined = undefined;
|
||||
|
||||
async function getWebModule(): Promise<ToolModule | null> {
|
||||
if (_webModule === undefined) {
|
||||
@@ -280,6 +280,14 @@ async function getCalendarModule(): Promise<ToolModule | null> {
|
||||
return _calendarModule;
|
||||
}
|
||||
|
||||
async function getAppTestModule(): Promise<ToolModule | null> {
|
||||
if (_appTestModule === undefined) {
|
||||
_appTestModule = await tryLoadModule('./app-test.js');
|
||||
if (_appTestModule) logger.debug('[tools/index] app-test module loaded');
|
||||
}
|
||||
return _appTestModule;
|
||||
}
|
||||
|
||||
/**
|
||||
* 全モジュールのツール定義を統合して返す。
|
||||
* allowedTools と editAllowed に応じてフィルタリングする。
|
||||
@@ -365,6 +373,9 @@ export async function getToolDefs(
|
||||
const calendarMod = await getCalendarModule();
|
||||
if (calendarMod) Object.assign(allDefs, calendarMod.TOOL_DEFS);
|
||||
|
||||
const appTestMod = await getAppTestModule();
|
||||
if (appTestMod) Object.assign(allDefs, appTestMod.TOOL_DEFS);
|
||||
|
||||
const { TOOL_DEFS: skillToolDefs } = await import('./skills.js');
|
||||
Object.assign(allDefs, skillToolDefs);
|
||||
|
||||
@@ -397,7 +408,7 @@ export async function getToolDefs(
|
||||
// - ReadAppDoc / ListAppDocs / GetMyOrchestratorState: Help アシスタント用 (#help piece)
|
||||
// ただし他の piece からも参照できるようにメタ扱い
|
||||
// - RequestTool: 足りないツールの要求を記録(タスク詳細/ピース集計で可視化)
|
||||
const META_TOOLS = ['ReadToolDoc', 'CreateChecklist', 'CheckItem', 'GetChecklist', 'MissionUpdate', 'ListUserAssets', 'RunUserScript', 'UpdateUserMemory', 'ReadUserMemory', 'ReadUserAgents', 'UpdateUserAgents', 'ReadUserTemplate', 'RenderUserTemplate', 'WriteUserScript', 'WriteUserTemplate', 'Brainstorm', 'ReadAppDoc', 'ListAppDocs', 'GetMyOrchestratorState', 'ReadSkill', 'ListSkills', 'InstallSkill', 'RequestTool'];
|
||||
const META_TOOLS = ['ReadToolDoc', 'CreateChecklist', 'CheckItem', 'GetChecklist', 'MissionUpdate', 'ListUserAssets', 'RunUserScript', 'UpdateUserMemory', 'ReadUserMemory', 'ReadUserAgents', 'UpdateUserAgents', 'WriteUserScript', 'Brainstorm', 'ReadAppDoc', 'ListAppDocs', 'GetMyOrchestratorState', 'ReadSkill', 'ListSkills', 'InstallSkill', 'RequestTool'];
|
||||
const effectiveAllowed = [...allowedTools];
|
||||
for (const meta of META_TOOLS) {
|
||||
if (!effectiveAllowed.includes(meta) && meta in allDefs) {
|
||||
@@ -645,6 +656,13 @@ async function executeToolInner(
|
||||
if (calendarResult !== null) return calendarResult;
|
||||
}
|
||||
|
||||
// app-test ツール (TestWorkspaceApp)
|
||||
const appTestMod = await getAppTestModule();
|
||||
if (appTestMod) {
|
||||
const appTestResult = await appTestMod.executeTool(name, input, ctx);
|
||||
if (appTestResult !== null) return appTestResult;
|
||||
}
|
||||
|
||||
// skills ツール (ReadSkill)
|
||||
const { executeSkillTool } = await import('./skills.js');
|
||||
const skillResult = executeSkillTool(name, input, ctx);
|
||||
@@ -685,6 +703,3 @@ export async function executeTool(
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// 同期版 getToolDefs のラッパー(後方互換のため、コアツールのみを返す同期版)
|
||||
export { getCoreToolDefs as getCoreToolDefs };
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import AdmZip from 'adm-zip';
|
||||
import { executeTool } from './office.js';
|
||||
import type { ToolContext, ToolsConfig } from './core.js';
|
||||
|
||||
// --- Helpers (mirrors office.test.ts house style) ---
|
||||
|
||||
function makeWorkspace(): string {
|
||||
return fs.mkdtempSync(path.join(tmpdir(), 'maestro-office-size-'));
|
||||
}
|
||||
|
||||
function makeContext(workspacePath: string, toolsConfig?: ToolsConfig): ToolContext {
|
||||
return { workspacePath, editAllowed: true, ...(toolsConfig ? { toolsConfig } : {}) };
|
||||
}
|
||||
|
||||
function inputPath(workspacePath: string, name: string): string {
|
||||
const dir = path.join(workspacePath, 'input');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
return path.join(dir, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a fixture that PASSES validateFileFormat (correct magic bytes for the
|
||||
* expected format) but exceeds `sizeBytes`. The size-limit check in office.ts
|
||||
* runs AFTER the magic-byte format check but BEFORE any real parse, so the file
|
||||
* does not need to be a valid Office document — only the leading magic bytes +
|
||||
* the right extension matter, plus total size > the configured cap.
|
||||
*
|
||||
* OOXML magic = "PK\x03\x04" (ReadExcel/ReadDocx/ReadPPTX). PDF magic = "%PDF-".
|
||||
*/
|
||||
function writeOversizedOoxml(filePath: string, sizeBytes: number): void {
|
||||
const buf = Buffer.alloc(sizeBytes, 0);
|
||||
// Local file header signature for a ZIP / OOXML container.
|
||||
buf[0] = 0x50; // P
|
||||
buf[1] = 0x4b; // K
|
||||
buf[2] = 0x03;
|
||||
buf[3] = 0x04;
|
||||
fs.writeFileSync(filePath, buf);
|
||||
}
|
||||
|
||||
function writeOversizedPdf(filePath: string, sizeBytes: number): void {
|
||||
const buf = Buffer.alloc(sizeBytes, 0x20); // pad with spaces
|
||||
Buffer.from('%PDF-1.4\n').copy(buf, 0);
|
||||
fs.writeFileSync(filePath, buf);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a REAL .pptx (zip) whose total UNCOMPRESSED size exceeds the configured
|
||||
* uncompressed cap, while staying tiny on disk (highly compressible zeros) — i.e.
|
||||
* a decompression-bomb shape. adm-zip records the true uncompressed size in each
|
||||
* entry's header.size, which is exactly what office.ts sums for ZIP-bomb detection.
|
||||
*/
|
||||
function writeZipBomb(filePath: string, uncompressedBytes: number): void {
|
||||
const zip = new AdmZip();
|
||||
// A single fat, highly-compressible entry: large uncompressed, tiny on disk.
|
||||
zip.addFile('ppt/slides/slide1.xml', Buffer.alloc(uncompressedBytes, 0));
|
||||
zip.writeZip(filePath);
|
||||
}
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
describe('office tools — file size limits', () => {
|
||||
let workspacePath = '';
|
||||
|
||||
afterEach(() => {
|
||||
if (workspacePath) {
|
||||
fs.rmSync(workspacePath, { recursive: true, force: true });
|
||||
workspacePath = '';
|
||||
}
|
||||
});
|
||||
|
||||
// --- 1. ReadPPTX file-size cap (officePptxMaxSizeMb) ---
|
||||
|
||||
it('ReadPPTX rejects a file exceeding officePptxMaxSizeMb', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const fp = inputPath(workspacePath, 'big.pptx');
|
||||
writeOversizedOoxml(fp, 2 * MB); // 2MB > 1MB cap
|
||||
const ctx = makeContext(workspacePath, { officePptxMaxSizeMb: 1 });
|
||||
|
||||
const result = await executeTool('ReadPPTX', { path: 'input/big.pptx' }, ctx);
|
||||
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('exceeds limit of 1MB');
|
||||
expect(result?.output).toContain('File size');
|
||||
// Distinct from the format-mismatch error family (Japanese guidance text).
|
||||
expect(result?.output).not.toContain('ReadToolDoc');
|
||||
});
|
||||
|
||||
// --- 2. ReadPPTX ZIP-bomb path (officePptxMaxUncompressedMb) ---
|
||||
|
||||
it('ReadPPTX rejects a ZIP bomb whose uncompressed size exceeds officePptxMaxUncompressedMb', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const fp = inputPath(workspacePath, 'bomb.pptx');
|
||||
// ~3MB uncompressed of zeros -> a few KB on disk (well under the 50MB file cap),
|
||||
// so it passes the file-size check and reaches the uncompressed-size guard.
|
||||
writeZipBomb(fp, 3 * MB);
|
||||
const onDisk = fs.statSync(fp).size;
|
||||
expect(onDisk).toBeLessThan(50 * MB); // sanity: stays under default file cap
|
||||
|
||||
const ctx = makeContext(workspacePath, { officePptxMaxUncompressedMb: 1 });
|
||||
const result = await executeTool('ReadPPTX', { path: 'input/bomb.pptx' }, ctx);
|
||||
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('ZIP bomb detected');
|
||||
expect(result?.output).toContain('exceeds limit of 1MB');
|
||||
});
|
||||
|
||||
it('ReadPPTX accepts a small zip when uncompressed size is under the cap', async () => {
|
||||
// Guards against the bomb check being over-eager / regressed to always-fire.
|
||||
workspacePath = makeWorkspace();
|
||||
const fp = inputPath(workspacePath, 'tiny.pptx');
|
||||
writeZipBomb(fp, 4 * 1024); // 4KB uncompressed, well under 1MB cap
|
||||
|
||||
const ctx = makeContext(workspacePath, { officePptxMaxUncompressedMb: 1 });
|
||||
const result = await executeTool('ReadPPTX', { path: 'input/tiny.pptx' }, ctx);
|
||||
|
||||
// It will fail later for other reasons (no presentation.xml / no slides parsed),
|
||||
// but it must NOT be rejected by the ZIP-bomb guard.
|
||||
expect(result?.output ?? '').not.toContain('ZIP bomb detected');
|
||||
});
|
||||
|
||||
// --- 3. ReadExcel / ReadDocx / ReadPdf each reject when size > their cap ---
|
||||
|
||||
it('ReadExcel rejects a file exceeding officeExcelMaxSizeMb', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const fp = inputPath(workspacePath, 'big.xlsx');
|
||||
writeOversizedOoxml(fp, 2 * MB);
|
||||
const ctx = makeContext(workspacePath, { officeExcelMaxSizeMb: 1 });
|
||||
|
||||
const result = await executeTool('ReadExcel', { path: 'input/big.xlsx' }, ctx);
|
||||
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('File size');
|
||||
expect(result?.output).toContain('exceeds limit of 1MB');
|
||||
// Distinct from the format-mismatch error (which names other tools / Read).
|
||||
expect(result?.output).not.toContain('ReadDocx');
|
||||
});
|
||||
|
||||
it('ReadDocx rejects a file exceeding officeDocxMaxSizeMb', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const fp = inputPath(workspacePath, 'big.docx');
|
||||
writeOversizedOoxml(fp, 2 * MB);
|
||||
const ctx = makeContext(workspacePath, { officeDocxMaxSizeMb: 1 });
|
||||
|
||||
const result = await executeTool('ReadDocx', { path: 'input/big.docx' }, ctx);
|
||||
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('File size');
|
||||
expect(result?.output).toContain('exceeds limit of 1MB');
|
||||
});
|
||||
|
||||
it('ReadPdf rejects a file exceeding officePdfMaxSizeMb', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const fp = inputPath(workspacePath, 'big.pdf');
|
||||
writeOversizedPdf(fp, 2 * MB);
|
||||
const ctx = makeContext(workspacePath, { officePdfMaxSizeMb: 1 });
|
||||
|
||||
const result = await executeTool('ReadPdf', { path: 'input/big.pdf' }, ctx);
|
||||
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('File size');
|
||||
expect(result?.output).toContain('exceeds limit of 1MB');
|
||||
});
|
||||
|
||||
// The size-limit error is structurally DISTINCT from the format-mismatch error.
|
||||
it('size-limit error differs from the format-mismatch error', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
|
||||
// (a) size-limit: valid OOXML magic + oversize -> FILE_TOO_LARGE message.
|
||||
const bigFp = inputPath(workspacePath, 'big.xlsx');
|
||||
writeOversizedOoxml(bigFp, 2 * MB);
|
||||
const sizeRes = await executeTool(
|
||||
'ReadExcel',
|
||||
{ path: 'input/big.xlsx' },
|
||||
makeContext(workspacePath, { officeExcelMaxSizeMb: 1 }),
|
||||
);
|
||||
|
||||
// (b) format-mismatch: PDF bytes handed to ReadExcel -> guidance message.
|
||||
const pdfFp = inputPath(workspacePath, 'wrong.xlsx');
|
||||
writeOversizedPdf(pdfFp, 4 * 1024); // tiny so size cap is irrelevant
|
||||
const fmtRes = await executeTool(
|
||||
'ReadExcel',
|
||||
{ path: 'input/wrong.xlsx' },
|
||||
makeContext(workspacePath, { officeExcelMaxSizeMb: 1 }),
|
||||
);
|
||||
|
||||
expect(sizeRes?.isError).toBe(true);
|
||||
expect(fmtRes?.isError).toBe(true);
|
||||
expect(sizeRes?.output).toContain('exceeds limit of');
|
||||
expect(fmtRes?.output).not.toContain('exceeds limit of');
|
||||
expect(sizeRes?.output).not.toBe(fmtRes?.output);
|
||||
});
|
||||
|
||||
// --- 4. Config override changes the threshold ---
|
||||
|
||||
it('config override: a file that passes at default fails at a lowered officeExcelMaxSizeMb', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const fp = inputPath(workspacePath, 'mid.xlsx');
|
||||
// 2MB: under the 10MB default Excel cap, over a lowered 1MB cap.
|
||||
writeOversizedOoxml(fp, 2 * MB);
|
||||
|
||||
// Default cap (no override) -> NOT rejected for size (it fails later on parse,
|
||||
// but never with the FILE_TOO_LARGE message).
|
||||
const defaultRes = await executeTool(
|
||||
'ReadExcel',
|
||||
{ path: 'input/mid.xlsx' },
|
||||
makeContext(workspacePath),
|
||||
);
|
||||
expect(defaultRes?.output ?? '').not.toContain('exceeds limit of');
|
||||
|
||||
// Lowered cap -> rejected for size.
|
||||
const loweredRes = await executeTool(
|
||||
'ReadExcel',
|
||||
{ path: 'input/mid.xlsx' },
|
||||
makeContext(workspacePath, { officeExcelMaxSizeMb: 1 }),
|
||||
);
|
||||
expect(loweredRes?.isError).toBe(true);
|
||||
expect(loweredRes?.output).toContain('exceeds limit of 1MB');
|
||||
});
|
||||
|
||||
it('config override: raising officePptxMaxUncompressedMb lets a previously-bombed zip through the guard', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const fp = inputPath(workspacePath, 'fat.pptx');
|
||||
writeZipBomb(fp, 3 * MB); // 3MB uncompressed
|
||||
|
||||
// Low cap (1MB) -> tripped by the bomb guard.
|
||||
const lowRes = await executeTool(
|
||||
'ReadPPTX',
|
||||
{ path: 'input/fat.pptx' },
|
||||
makeContext(workspacePath, { officePptxMaxUncompressedMb: 1 }),
|
||||
);
|
||||
expect(lowRes?.output).toContain('ZIP bomb detected');
|
||||
|
||||
// Raised cap (10MB) -> NOT tripped by the bomb guard (fails later on missing slides).
|
||||
const highRes = await executeTool(
|
||||
'ReadPPTX',
|
||||
{ path: 'input/fat.pptx' },
|
||||
makeContext(workspacePath, { officePptxMaxUncompressedMb: 10 }),
|
||||
);
|
||||
expect(highRes?.output ?? '').not.toContain('ZIP bomb detected');
|
||||
});
|
||||
});
|
||||
@@ -116,3 +116,38 @@ describe('executeTool', () => {
|
||||
expect(res?.output).toContain('queue full');
|
||||
});
|
||||
});
|
||||
|
||||
const baseCtx = { workspacePath: '/tmp/x', editAllowed: false } as ToolContext;
|
||||
|
||||
describe('delegate tool', () => {
|
||||
it('is registered in TOOL_DEFS', () => {
|
||||
expect(TOOL_DEFS.delegate).toBeDefined();
|
||||
expect(TOOL_DEFS.delegate.function.name).toBe('delegate');
|
||||
});
|
||||
|
||||
it('calls ctx.runDelegate and returns its result as output', async () => {
|
||||
const runDelegate = vi.fn().mockResolvedValue({ result: 'sub did the thing', status: 'success' });
|
||||
const res = await executeTool('delegate', { description: 'analyze', prompt: 'analyze the CSVs' }, { ...baseCtx, runDelegate });
|
||||
expect(runDelegate).toHaveBeenCalledWith({ description: 'analyze', prompt: 'analyze the CSVs' });
|
||||
expect(res?.output).toContain('sub did the thing');
|
||||
expect(res?.isError).toBeFalsy();
|
||||
});
|
||||
|
||||
it('returns isError when runDelegate is not wired', async () => {
|
||||
const res = await executeTool('delegate', { description: 'x', prompt: 'y' }, baseCtx);
|
||||
expect(res?.isError).toBe(true);
|
||||
});
|
||||
|
||||
it('surfaces needs_user_input as a non-error bubble-up', async () => {
|
||||
const runDelegate = vi.fn().mockResolvedValue({ result: 'need the date', status: 'needs_user_input' });
|
||||
const res = await executeTool('delegate', { description: 'x', prompt: 'y' }, { ...baseCtx, runDelegate });
|
||||
expect(res?.output).toContain('need the date');
|
||||
expect(res?.isError).toBeFalsy();
|
||||
});
|
||||
|
||||
it('marks aborted delegation as isError', async () => {
|
||||
const runDelegate = vi.fn().mockResolvedValue({ result: 'loop limit', status: 'aborted' });
|
||||
const res = await executeTool('delegate', { description: 'x', prompt: 'y' }, { ...baseCtx, runDelegate });
|
||||
expect(res?.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,21 @@ import { ToolDef } from '../../llm/openai-compat.js';
|
||||
import { ToolContext, ToolResult } from './core.js';
|
||||
|
||||
export const TOOL_DEFS: Record<string, ToolDef> = {
|
||||
delegate: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'delegate',
|
||||
description: 'サブエージェントに作業を委譲し、その最終結果だけを受け取ります(中間作業はあなたの文脈に残りません)。重い調査・分解作業を文脈を節約しつつ任せたいときに使用。直列実行(同時に1つ)。詳細は ReadToolDoc({ name: "delegate" })。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
description: { type: 'string', description: '委譲タスクの短い説明(3-6語)' },
|
||||
prompt: { type: 'string', description: 'サブエージェントへの完全な指示。サブは独立した文脈で動くため、必要な背景を自己完結で書く' },
|
||||
},
|
||||
required: ['description', 'prompt'],
|
||||
},
|
||||
},
|
||||
},
|
||||
SpawnSubTask: {
|
||||
type: 'function',
|
||||
function: {
|
||||
@@ -36,7 +51,24 @@ export async function executeTool(
|
||||
input: Record<string, unknown>,
|
||||
ctx: ToolContext,
|
||||
): Promise<ToolResult | null> {
|
||||
if (name !== 'SpawnSubTask') return null;
|
||||
if (name !== 'SpawnSubTask' && name !== 'delegate') return null;
|
||||
|
||||
if (name === 'delegate') {
|
||||
if (!ctx.runDelegate) {
|
||||
return { output: 'delegate はこのコンテキストでは使用できません(ネスト上限、または未対応の実行経路)。', isError: true };
|
||||
}
|
||||
const description = typeof input['description'] === 'string' ? input['description'] : '';
|
||||
const prompt = typeof input['prompt'] === 'string' ? input['prompt'] : '';
|
||||
if (!prompt.trim()) return { output: 'delegate: prompt が空です。', isError: true };
|
||||
try {
|
||||
const { result, status } = await ctx.runDelegate({ description, prompt });
|
||||
if (status === 'aborted') return { output: `[delegate 中断] ${result}`, isError: true };
|
||||
if (status === 'needs_user_input') return { output: `[delegate 要追加情報] ${result}`, isError: false };
|
||||
return { output: result, isError: false };
|
||||
} catch (err) {
|
||||
return { output: `delegate 失敗: ${err instanceof Error ? err.message : String(err)}`, isError: true };
|
||||
}
|
||||
}
|
||||
|
||||
const { spawnSubTask } = ctx;
|
||||
if (!spawnSubTask) {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
getToolCategoryMap,
|
||||
listToolCategories,
|
||||
SENSITIVE_CATEGORIES,
|
||||
SENSITIVE_TOOLS,
|
||||
META_TOOLS,
|
||||
} from './tool-categories.js';
|
||||
|
||||
describe('tool-categories', () => {
|
||||
it('maps core tools to core', async () => {
|
||||
const m = await getToolCategoryMap();
|
||||
expect(m.get('Read')).toBe('core');
|
||||
expect(m.get('Bash')).toBe('core');
|
||||
expect(m.get('Write')).toBe('core');
|
||||
expect(m.get('Edit')).toBe('core');
|
||||
expect(m.get('Glob')).toBe('core');
|
||||
expect(m.get('Grep')).toBe('core');
|
||||
});
|
||||
|
||||
it('maps non-core tools to their category', async () => {
|
||||
const m = await getToolCategoryMap();
|
||||
expect(m.get('BrowseWeb')).toBe('browser');
|
||||
expect(m.get('WebSearch')).toBe('web');
|
||||
expect(m.get('SQLite')).toBe('data');
|
||||
});
|
||||
|
||||
it('includes app-test category with TestWorkspaceApp', async () => {
|
||||
const m = await getToolCategoryMap();
|
||||
expect(m.get('TestWorkspaceApp')).toBe('app-test');
|
||||
});
|
||||
|
||||
it('maps calendar tools to the calendar category', async () => {
|
||||
const m = await getToolCategoryMap();
|
||||
expect(m.get('AddCalendarEvent')).toBe('calendar');
|
||||
expect(m.get('ListCalendarEvents')).toBe('calendar');
|
||||
});
|
||||
|
||||
it('lists categories including calendar (workspace-tool-policy default-on)', async () => {
|
||||
const cats = await listToolCategories();
|
||||
expect(cats).toContain('calendar');
|
||||
expect(SENSITIVE_CATEGORIES.has('calendar')).toBe(false);
|
||||
});
|
||||
|
||||
it('lists categories including core and browser', async () => {
|
||||
const cats = await listToolCategories();
|
||||
expect(cats).toContain('core');
|
||||
expect(cats).toContain('browser');
|
||||
expect(cats).toContain('web');
|
||||
expect(cats).toContain('ssh');
|
||||
expect(cats).toContain('skills');
|
||||
expect(cats).toContain('app-test');
|
||||
});
|
||||
|
||||
it('has no duplicate categories in listToolCategories', async () => {
|
||||
const cats = await listToolCategories();
|
||||
const unique = [...new Set(cats)];
|
||||
expect(cats).toEqual(unique);
|
||||
});
|
||||
|
||||
it('marks ssh and browser as sensitive categories', () => {
|
||||
expect(SENSITIVE_CATEGORIES.has('ssh')).toBe(true);
|
||||
expect(SENSITIVE_CATEGORIES.has('browser')).toBe(true);
|
||||
expect(SENSITIVE_CATEGORIES.has('core')).toBe(false);
|
||||
expect(SENSITIVE_CATEGORIES.has('web')).toBe(false);
|
||||
});
|
||||
|
||||
it('marks Bash as a sensitive tool', () => {
|
||||
expect(SENSITIVE_TOOLS.has('Bash')).toBe(true);
|
||||
expect(SENSITIVE_TOOLS.has('Read')).toBe(false);
|
||||
});
|
||||
|
||||
it('exports META_TOOLS with expected entries', () => {
|
||||
expect(META_TOOLS).toBeInstanceOf(Set);
|
||||
expect(META_TOOLS.has('ReadToolDoc')).toBe(true);
|
||||
expect(META_TOOLS.has('ReadSkill')).toBe(true);
|
||||
expect(META_TOOLS.has('CreateChecklist')).toBe(true);
|
||||
});
|
||||
|
||||
it('surfaces RequestTool in the catalog and tags it as a meta tool', async () => {
|
||||
// RequestTool lives in tool-request.ts; it must be in MODULE_SPECS so the
|
||||
// /api/tools catalog (loadBuiltinEntries) loads its def, and in META_TOOLS
|
||||
// so it is tagged source:'meta' like its sibling always-on tools.
|
||||
const m = await getToolCategoryMap();
|
||||
expect(m.get('RequestTool')).toBe('tool-request');
|
||||
expect(META_TOOLS.has('RequestTool')).toBe(true);
|
||||
});
|
||||
|
||||
it('applies first-write-wins: core tools not overridden by later modules', async () => {
|
||||
// If a tool appears in both core and another module, core wins
|
||||
const m = await getToolCategoryMap();
|
||||
// Bash is core — must stay 'core' even if any other module listed it
|
||||
expect(m.get('Bash')).toBe('core');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* tool-categories.ts — Single source of truth for tool→category mapping.
|
||||
*
|
||||
* Both /api/tools (tools-api.ts) and the workspace-tool-policy resolver
|
||||
* import from here so the two views can never diverge.
|
||||
*/
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Meta tools (catalog + workspace-policy subset)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Meta tools for two consumers:
|
||||
* - tools-api.ts catalog: tags these as source='meta' in /api/tools.
|
||||
* - workspace-tool-policy.ts: unions these into every workspace's resolved set.
|
||||
*
|
||||
* This is a SUBSET, not a mirror of index.ts. `src/engine/tools/index.ts`
|
||||
* keeps its own (larger) META_TOOLS list and injects it at dispatch in
|
||||
* getToolDefs(), so a tool's runtime availability is NOT gated by this list.
|
||||
* Adding a name here changes the /api/tools catalog tagging — do so
|
||||
* deliberately (see docs/maintenance-checklist.md).
|
||||
*/
|
||||
export const META_TOOLS = new Set<string>([
|
||||
'ReadToolDoc',
|
||||
'CreateChecklist',
|
||||
'CheckItem',
|
||||
'GetChecklist',
|
||||
'MissionUpdate',
|
||||
'ListUserAssets',
|
||||
'RunUserScript',
|
||||
'UpdateUserMemory',
|
||||
'ReadUserMemory',
|
||||
'ReadUserAgents',
|
||||
'UpdateUserAgents',
|
||||
'WriteUserScript',
|
||||
'Brainstorm',
|
||||
'ReadAppDoc',
|
||||
'ListAppDocs',
|
||||
'GetMyOrchestratorState',
|
||||
'ReadSkill',
|
||||
'ListSkills',
|
||||
'InstallSkill',
|
||||
'RequestTool',
|
||||
]);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Sensitive categories and tools
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Categories whose tools require elevated scrutiny in the workspace tool
|
||||
* policy (e.g. opt-in rather than default-allowed).
|
||||
*/
|
||||
export const SENSITIVE_CATEGORIES: ReadonlySet<string> = new Set(['ssh', 'browser']);
|
||||
|
||||
/**
|
||||
* Individual tools that require elevated scrutiny regardless of their
|
||||
* category (e.g. Bash even though its category is 'core').
|
||||
*/
|
||||
export const SENSITIVE_TOOLS: ReadonlySet<string> = new Set(['Bash']);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Module specs — canonical category → module specifier mapping
|
||||
//
|
||||
// These paths are resolved relative to src/bridge/ (the tools-api.ts import
|
||||
// context), which is why they use '../engine/tools/...'.
|
||||
//
|
||||
// NOTE: 'core' is handled separately (uses ALL_TOOL_DEFS not TOOL_DEFS) and
|
||||
// is NOT listed here. 'ssh' appears twice because ssh-console.js is a second
|
||||
// module in the same category — first-write-wins keeps the category stable.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const MODULE_SPECS: ReadonlyArray<{ category: string; specifier: string }> = [
|
||||
{ category: 'web', specifier: '../engine/tools/web.js' },
|
||||
{ category: 'image', specifier: '../engine/tools/image.js' },
|
||||
{ category: 'data', specifier: '../engine/tools/data.js' },
|
||||
{ category: 'office', specifier: '../engine/tools/office.js' },
|
||||
{ category: 'review', specifier: '../engine/tools/review.js' },
|
||||
{ category: 'x', specifier: '../engine/tools/x.js' },
|
||||
{ category: 'orchestration', specifier: '../engine/tools/orchestration.js' },
|
||||
{ category: 'browser', specifier: '../engine/tools/browser.js' },
|
||||
{ category: 'maps', specifier: '../engine/tools/maps.js' },
|
||||
{ category: 'calendar', specifier: '../engine/tools/calendar.js' },
|
||||
{ category: 'youtube', specifier: '../engine/tools/youtube.js' },
|
||||
{ category: 'pieces', specifier: '../engine/tools/pieces.js' },
|
||||
{ category: 'amazon', specifier: '../engine/tools/amazon.js' },
|
||||
{ category: 'speech', specifier: '../engine/tools/speech.js' },
|
||||
{ category: 'checklist', specifier: '../engine/tools/checklist.js' },
|
||||
{ category: 'ms-learn', specifier: '../engine/tools/ms-learn.js' },
|
||||
{ category: 'slide', specifier: '../engine/tools/slide.js' },
|
||||
{ category: 'docs', specifier: '../engine/tools/docs.js' },
|
||||
{ category: 'mission', specifier: '../engine/tools/mission.js' },
|
||||
{ category: 'user-folder', specifier: '../engine/tools/user-folder.js' },
|
||||
{ category: 'brainstorm', specifier: '../engine/tools/brainstorm.js' },
|
||||
{ category: 'app-docs', specifier: '../engine/tools/app-docs.js' },
|
||||
{ category: 'app-test', specifier: '../engine/tools/app-test.js' },
|
||||
{ category: 'ssh', specifier: '../engine/tools/ssh.js' },
|
||||
{ category: 'ssh', specifier: '../engine/tools/ssh-console.js' },
|
||||
{ category: 'skills', specifier: '../engine/tools/skills.js' },
|
||||
{ category: 'tool-request', specifier: '../engine/tools/tool-request.js' },
|
||||
];
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Shared module interface (used by tools-api.ts as well)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ToolModule {
|
||||
TOOL_DEFS?: Record<string, unknown>;
|
||||
ALL_TOOL_DEFS?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// getToolCategoryMap
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
let _cachedCategoryMap: Map<string, string> | null = null;
|
||||
|
||||
/**
|
||||
* Returns a map of tool name → category string, built with the same
|
||||
* first-write-wins logic as tools-api.ts loadBuiltinEntries().
|
||||
*
|
||||
* Core tools (ALL_TOOL_DEFS) are loaded first and assigned 'core'. Each
|
||||
* MODULE_SPECS entry is then loaded in order; the first module to register a
|
||||
* given tool name wins. Failed module loads are silently skipped (matching
|
||||
* the runtime behaviour in tools/index.ts and tools-api.ts).
|
||||
*
|
||||
* The result is memoised for the lifetime of the process.
|
||||
*/
|
||||
export async function getToolCategoryMap(): Promise<Map<string, string>> {
|
||||
if (_cachedCategoryMap) return _cachedCategoryMap;
|
||||
|
||||
const map = new Map<string, string>();
|
||||
|
||||
// 1. Core tools — always 'core', loaded from the local path
|
||||
try {
|
||||
const coreMod = (await import('./core.js')) as ToolModule;
|
||||
const defs = coreMod.ALL_TOOL_DEFS ?? {};
|
||||
for (const name of Object.keys(defs)) {
|
||||
if (!map.has(name)) map.set(name, 'core');
|
||||
}
|
||||
} catch {
|
||||
// core should always be available; if not, skip gracefully
|
||||
}
|
||||
|
||||
// 2. Non-core modules — use specifiers relative to src/bridge/ to match
|
||||
// the import paths used by tools-api.ts (../engine/tools/xxx.js).
|
||||
// From src/engine/tools/ the equivalent is ./xxx.js.
|
||||
for (const { category, specifier } of MODULE_SPECS) {
|
||||
// Convert bridge-relative path to engine/tools-relative path
|
||||
// '../engine/tools/foo.js' → './foo.js'
|
||||
const localSpecifier = specifier.replace('../engine/tools/', './');
|
||||
try {
|
||||
const mod = (await import(localSpecifier)) as ToolModule;
|
||||
const defs = mod.TOOL_DEFS ?? {};
|
||||
for (const name of Object.keys(defs)) {
|
||||
if (!map.has(name)) map.set(name, category);
|
||||
}
|
||||
} catch {
|
||||
// module not available — skip (same as tools-api.ts loadBuiltinEntries)
|
||||
}
|
||||
}
|
||||
|
||||
_cachedCategoryMap = map;
|
||||
return map;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// listToolCategories
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns a de-duplicated list of all known builtin categories, including
|
||||
* 'core' and every category appearing in MODULE_SPECS.
|
||||
*/
|
||||
export async function listToolCategories(): Promise<string[]> {
|
||||
const seen = new Set<string>(['core']);
|
||||
for (const { category } of MODULE_SPECS) {
|
||||
seen.add(category);
|
||||
}
|
||||
return [...seen];
|
||||
}
|
||||
|
||||
/** Test-only: reset the memoised category map. */
|
||||
export function _resetToolCategoryCacheForTests(): void {
|
||||
_cachedCategoryMap = null;
|
||||
}
|
||||
@@ -50,7 +50,7 @@ describe('RequestTool', () => {
|
||||
expect(rec).toHaveBeenCalledWith(expect.objectContaining({ category: 'requested' }));
|
||||
});
|
||||
|
||||
it('not in catalog → recorded as unknown with a clear message', async () => {
|
||||
it('not in catalog → ERROR (existence enforced), still recorded as unknown', async () => {
|
||||
const rec = vi.fn();
|
||||
const r = await executeTool(
|
||||
'RequestTool',
|
||||
@@ -59,6 +59,10 @@ describe('RequestTool', () => {
|
||||
);
|
||||
expect(rec).toHaveBeenCalledWith(expect.objectContaining({ toolName: 'NotARealTool', category: 'unknown' }));
|
||||
expect(r?.output).toContain('存在しません');
|
||||
// A non-existent tool is a hard error so the agent does not mistake it for a
|
||||
// pending approval / successful call.
|
||||
expect(r?.isError).toBe(true);
|
||||
expect(r?.output).not.toContain('記録しました');
|
||||
});
|
||||
|
||||
it('interactive run + not-allowed → records pending and sets the pause flag', async () => {
|
||||
|
||||
@@ -71,12 +71,17 @@ export async function executeTool(
|
||||
const category: 'requested' | 'unknown' = known.includes(toolName) || isMcp ? 'requested' : 'unknown';
|
||||
|
||||
if (category === 'unknown') {
|
||||
// The requested tool does not exist in the catalog (likely a hallucinated
|
||||
// name). Return a HARD ERROR so the agent does not mistake it for a pending
|
||||
// approval or a successful call. Still recorded for diagnostics (only when a
|
||||
// recorder is wired — `?.` is a silent no-op otherwise, so the message must
|
||||
// NOT claim it was recorded).
|
||||
ctx.recordToolRequest?.({ toolName, reason, category: 'unknown' });
|
||||
return {
|
||||
output:
|
||||
`"${toolName}" というツールは存在しません(要求は記録しました)。利用可能なツールで進めてください。` +
|
||||
` 何が使えるかは ReadToolDoc などで確認できます。`,
|
||||
isError: false,
|
||||
`"${toolName}" というツールは存在しません。RequestTool で要求できるのは実在するツール名だけです。` +
|
||||
` これは承認待ちではありません — 利用可能なツールで進めてください(一覧は ReadToolDoc 等で確認できます)。`,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user