This commit is contained in:
@@ -164,14 +164,16 @@ describe('app-docs: ListAppDocs', () => {
|
||||
expect(out).toContain('piece/chat');
|
||||
});
|
||||
|
||||
it('does not list CLAUDE.md / AGENTS.md / README.md', async () => {
|
||||
it('does not list CLAUDE.md / AGENTS.md / README.md as doc entries', async () => {
|
||||
const res = await executeTool('ListAppDocs', {}, baseCtx);
|
||||
const out = res!.output;
|
||||
// メタ系プロジェクト doc は「doc エントリ(slug)」として列挙されない。
|
||||
expect(out).not.toContain('claude-md');
|
||||
expect(out).not.toContain('agents-md');
|
||||
expect(out).not.toContain('readme');
|
||||
expect(out).not.toContain('CLAUDE.md');
|
||||
expect(out).not.toContain('AGENTS.md');
|
||||
// 注: 生文字列 "AGENTS.md" / "CLAUDE.md" は、それを読み書きするツール
|
||||
// (UpdateUserAgents 等) の説明文に正当に現れる。これらツールは列挙されてよいので、
|
||||
// 生文字列での除外検証はしない(slug ベースで検証する)。
|
||||
});
|
||||
|
||||
it('does not list docs/superpowers or docs/maintenance-checklist', async () => {
|
||||
@@ -218,6 +220,7 @@ describe('app-docs: GetMyOrchestratorState', () => {
|
||||
name TEXT,
|
||||
auth_kind TEXT,
|
||||
owner_id TEXT,
|
||||
space_id TEXT,
|
||||
enabled INTEGER
|
||||
);
|
||||
CREATE TABLE user_mcp_tokens (
|
||||
@@ -234,10 +237,13 @@ describe('app-docs: GetMyOrchestratorState', () => {
|
||||
db.prepare('INSERT INTO local_tasks (title, piece_name, owner_id, state, created_at) VALUES (?, ?, ?, ?, ?)')
|
||||
.run('Other task', 'research', 'alice', 'open', '2026-05-09 09:00:00');
|
||||
|
||||
db.prepare('INSERT INTO mcp_servers (id, name, auth_kind, owner_id, enabled) VALUES (?, ?, ?, ?, ?)')
|
||||
.run('canva', 'Canva', 'oauth', null, 1);
|
||||
db.prepare('INSERT INTO mcp_servers (id, name, auth_kind, owner_id, enabled) VALUES (?, ?, ?, ?, ?)')
|
||||
.run('tundoc', 'Tundoc', 'api_key', 'alice', 1);
|
||||
// canva: global / legacy NULL-space server. tundoc: scoped to alice's
|
||||
// personal space 'sp-alice'. They are DISJOINT — a task resolves to exactly
|
||||
// one effective space, so the status tool must show only that space's set.
|
||||
db.prepare('INSERT INTO mcp_servers (id, name, auth_kind, owner_id, space_id, enabled) VALUES (?, ?, ?, ?, ?, ?)')
|
||||
.run('canva', 'Canva', 'oauth', null, null, 1);
|
||||
db.prepare('INSERT INTO mcp_servers (id, name, auth_kind, owner_id, space_id, enabled) VALUES (?, ?, ?, ?, ?, ?)')
|
||||
.run('tundoc', 'Tundoc', 'api_key', 'alice', 'sp-alice', 1);
|
||||
|
||||
db.prepare('INSERT INTO user_mcp_tokens (user_id, server_id, expires_at) VALUES (?, ?, ?)')
|
||||
.run('alice', 'canva', '2027-01-01 00:00:00');
|
||||
@@ -257,8 +263,8 @@ describe('app-docs: GetMyOrchestratorState', () => {
|
||||
expect(res!.output).toContain('authenticated user');
|
||||
});
|
||||
|
||||
it('returns sections covering user / tasks / MCP / user folder', async () => {
|
||||
const ctx: ToolContext = { ...baseCtx, userId: 'alice' };
|
||||
it('returns sections covering user / tasks / MCP / user folder (scoped to the task space)', async () => {
|
||||
const ctx: ToolContext = { ...baseCtx, userId: 'alice', spaceId: 'sp-alice' };
|
||||
const res = await executeTool('GetMyOrchestratorState', {}, ctx);
|
||||
expect(res!.isError).toBe(false);
|
||||
const out = res!.output;
|
||||
@@ -267,13 +273,31 @@ describe('app-docs: GetMyOrchestratorState', () => {
|
||||
expect(out).toContain('## 最近のタスク');
|
||||
expect(out).toContain('Hello task');
|
||||
expect(out).toContain('## MCP サーバー');
|
||||
expect(out).toContain('canva');
|
||||
expect(out).toContain('連携済み');
|
||||
// tundoc is scoped to sp-alice → shown; canva (NULL-space) → NOT shown.
|
||||
expect(out).toContain('tundoc');
|
||||
expect(out).toContain('api_key');
|
||||
expect(out).not.toContain('canva');
|
||||
expect(out).toContain('## ユーザーフォルダ');
|
||||
});
|
||||
|
||||
it('scopes MCP servers to the task effective space (no cross-space leak)', async () => {
|
||||
// In the personal space sp-alice: only tundoc.
|
||||
const personal = await executeTool('GetMyOrchestratorState', {}, { ...baseCtx, userId: 'alice', spaceId: 'sp-alice' });
|
||||
expect(personal!.output).toContain('tundoc');
|
||||
expect(personal!.output).not.toContain('canva');
|
||||
|
||||
// In an unrelated space: neither server is available.
|
||||
const other = await executeTool('GetMyOrchestratorState', {}, { ...baseCtx, userId: 'alice', spaceId: 'sp-other' });
|
||||
expect(other!.output).not.toContain('tundoc');
|
||||
expect(other!.output).not.toContain('canva');
|
||||
expect(other!.output).toContain('利用可能なサーバーなし');
|
||||
|
||||
// No space (legacy / NULL-space context): only the NULL-space global server.
|
||||
const nullSpace = await executeTool('GetMyOrchestratorState', {}, { ...baseCtx, userId: 'alice' });
|
||||
expect(nullSpace!.output).toContain('canva');
|
||||
expect(nullSpace!.output).not.toContain('tundoc');
|
||||
});
|
||||
|
||||
it('handles empty memory/scripts dirs cleanly', async () => {
|
||||
// Build empty user folder
|
||||
mkdirSync(join(tmpDir, 'alice'));
|
||||
|
||||
@@ -585,14 +585,18 @@ async function executeGetMyOrchestratorState(
|
||||
let mcpServers: McpServerRow[] = [];
|
||||
let userTokens: McpTokenRow[] = [];
|
||||
try {
|
||||
mcpServers = db
|
||||
.prepare(`
|
||||
SELECT id, name, auth_kind, owner_id, enabled
|
||||
FROM mcp_servers
|
||||
WHERE enabled = 1 AND (owner_id IS NULL OR owner_id = ?)
|
||||
ORDER BY id
|
||||
`)
|
||||
.all(userId) as McpServerRow[];
|
||||
// Report ONLY the servers actually exposed to the agent in THIS task, i.e.
|
||||
// scoped to the task's effective space — exactly mirroring the aggregator's
|
||||
// registry.listEnabledForSpace(ctx.spaceId). The old owner-scoped query
|
||||
// listed servers from other spaces too, so it told the agent a server was
|
||||
// "connected" when it was not callable here (cross-space). See
|
||||
// docs/superpowers/specs/2026-06-19-personal-space-mcp-resolution-design.md
|
||||
const effectiveSpaceId = ctx.spaceId ?? null;
|
||||
mcpServers = (
|
||||
effectiveSpaceId === null
|
||||
? db.prepare(`SELECT id, name, auth_kind, owner_id, enabled FROM mcp_servers WHERE enabled = 1 AND space_id IS NULL ORDER BY id`).all()
|
||||
: db.prepare(`SELECT id, name, auth_kind, owner_id, enabled FROM mcp_servers WHERE enabled = 1 AND space_id = ? ORDER BY id`).all(effectiveSpaceId)
|
||||
) as McpServerRow[];
|
||||
userTokens = db
|
||||
.prepare('SELECT server_id, expires_at FROM user_mcp_tokens WHERE user_id = ?')
|
||||
.all(userId) as McpTokenRow[];
|
||||
@@ -602,7 +606,7 @@ async function executeGetMyOrchestratorState(
|
||||
|
||||
const tokenSet = new Set(userTokens.map((t) => t.server_id));
|
||||
|
||||
lines.push('## MCP サーバー');
|
||||
lines.push('## MCP サーバー (このタスクで利用可能)');
|
||||
if (mcpServers.length === 0) {
|
||||
lines.push('- (利用可能なサーバーなし)');
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
import { TOOL_DEFS, executeTool, normalizeFileUrlForWorkspace } from './browser.js';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { TOOL_DEFS, executeTool, normalizeFileUrlForWorkspace, persistBrowseSource } from './browser.js';
|
||||
import { recorder } from '../browser-recorder.js';
|
||||
import type { SourceIndexEntry } from './source-library.js';
|
||||
|
||||
function readSourceIndex(workspacePath: string): SourceIndexEntry[] {
|
||||
const idx = path.join(workspacePath, 'source', 'index.jsonl');
|
||||
if (!fs.existsSync(idx)) return [];
|
||||
return fs
|
||||
.readFileSync(idx, 'utf-8')
|
||||
.split('\n')
|
||||
.filter((l) => l.trim())
|
||||
.map((l) => JSON.parse(l) as SourceIndexEntry);
|
||||
}
|
||||
|
||||
describe('browser TOOL_DEFS', () => {
|
||||
it('exports BrowseWeb definition', () => {
|
||||
@@ -466,3 +480,62 @@ describe('BrowseWeb recording', () => {
|
||||
expect(recorder.bufferSize('')).toBe(0);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe('persistBrowseSource (source library)', () => {
|
||||
let workspacePath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-source-'));
|
||||
});
|
||||
|
||||
it('writes a source file + index entry when spaceId is set', () => {
|
||||
persistBrowseSource(
|
||||
{ workspacePath, spaceId: 'space-1' },
|
||||
'https://example.com/article',
|
||||
'これは BrowseWeb が取得したページ本文です。',
|
||||
);
|
||||
|
||||
const entries = readSourceIndex(workspacePath);
|
||||
expect(entries).toHaveLength(1);
|
||||
const entry = entries[0]!;
|
||||
expect(entry.tool).toBe('BrowseWeb');
|
||||
expect(entry.url).toBe('https://example.com/article');
|
||||
expect(entry.bytes).toBeGreaterThan(0);
|
||||
// 実ファイルが source/ に書かれている
|
||||
expect(fs.existsSync(path.join(workspacePath, 'source', entry.file))).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT create source/ when spaceId is undefined (legacy behavior)', () => {
|
||||
persistBrowseSource(
|
||||
{ workspacePath },
|
||||
'https://example.com/article',
|
||||
'spaceId 無しでは保存されないはずの本文。',
|
||||
);
|
||||
|
||||
expect(fs.existsSync(path.join(workspacePath, 'source'))).toBe(false);
|
||||
expect(readSourceIndex(workspacePath)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('dedupes the same URL browsed twice', () => {
|
||||
const url = 'https://example.com/dup';
|
||||
persistBrowseSource({ workspacePath, spaceId: 'space-1' }, url, '一回目の本文');
|
||||
persistBrowseSource({ workspacePath, spaceId: 'space-1' }, url, '二回目の本文(重複)');
|
||||
|
||||
const entries = readSourceIndex(workspacePath);
|
||||
expect(entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('skips empty / whitespace-only text', () => {
|
||||
persistBrowseSource({ workspacePath, spaceId: 'space-1' }, 'https://example.com/empty', ' ');
|
||||
expect(readSourceIndex(workspacePath)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('skips non-http(s) URLs (e.g. file://)', () => {
|
||||
persistBrowseSource(
|
||||
{ workspacePath, spaceId: 'space-1' },
|
||||
'file:///etc/passwd',
|
||||
'読み取れてしまったローカルファイル内容',
|
||||
);
|
||||
expect(readSourceIndex(workspacePath)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import { loadConfig } from '../../config.js';
|
||||
import { buildNovncPath } from '../../bridge/novnc-proxy.js';
|
||||
import { checkSSRF, isPrivateIPv4, isPrivateIPv6, isHostAllowed } from './shared/ssrf.js';
|
||||
import { htmlToText } from './shared/html.js';
|
||||
import { saveSourceFile } from './source-library.js';
|
||||
import { detectAuthExpiry } from '../browser-session-expiry.js';
|
||||
|
||||
export { detectAuthExpiry as runAuthCheck } from '../browser-session-expiry.js';
|
||||
@@ -1005,13 +1006,44 @@ async function snapshotPage(page: Page): Promise<string> {
|
||||
*/
|
||||
const BROWSE_TEXT_PREVIEW_CHARS = 5000;
|
||||
|
||||
/**
|
||||
* スペース文脈 (ctx.spaceId != null) なら BrowseWeb で取得したページ本文を
|
||||
* source/ に蓄積する(WebFetch / DownloadFile と同じソースライブラリ)。
|
||||
* 非スペースタスク(spaceId undefined)や file:// は従来どおり保存しない。
|
||||
* 同一 URL の重複は saveSourceFile 側の dedupe に任せる。これは純粋な追記サイドライト。
|
||||
*
|
||||
* 切り出してあるのは Playwright を介さずユニットテストできるようにするため。
|
||||
*/
|
||||
export function persistBrowseSource(
|
||||
ctx: { workspacePath: string; spaceId?: string },
|
||||
url: string,
|
||||
text: string,
|
||||
): void {
|
||||
if (ctx.spaceId == null) return; // 非スペース → 従来どおり source/ なし
|
||||
if (!text || text.trim().length === 0) return; // 空・エラー結果はスキップ
|
||||
if (!/^https?:\/\//i.test(url)) return; // file:// 等の非 http(s) はソース化しない
|
||||
try {
|
||||
saveSourceFile(ctx.workspacePath, {
|
||||
url,
|
||||
body: text,
|
||||
tool: 'BrowseWeb',
|
||||
ext: '.md',
|
||||
});
|
||||
} catch (e) {
|
||||
logger.warn(`[BrowseWeb] failed to persist source: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveBrowseText(
|
||||
ctx: { workspacePath: string },
|
||||
ctx: { workspacePath: string; spaceId?: string },
|
||||
url: string,
|
||||
text: string,
|
||||
label?: string,
|
||||
previewLimit: number = BROWSE_TEXT_PREVIEW_CHARS,
|
||||
): Promise<string> {
|
||||
// ソースライブラリへの蓄積は本文長に関係なく行う(短いページも取りこぼさない)。
|
||||
persistBrowseSource(ctx, url, text);
|
||||
|
||||
if (text.length <= previewLimit) return text;
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const crypto = await import('crypto');
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { Repository } from '../../db/repository.js';
|
||||
import { executeTool, setCalendarRepo, TOOL_DEFS } from './calendar.js';
|
||||
import type { ToolContext } from './core.js';
|
||||
|
||||
function ctx(over: Partial<ToolContext & { ownerId: string | null }> = {}): ToolContext {
|
||||
return {
|
||||
workspacePath: '/tmp/dummy',
|
||||
editAllowed: true,
|
||||
spaceId: 'space-A',
|
||||
taskId: '7',
|
||||
ownerId: 'u1',
|
||||
...over,
|
||||
} as ToolContext;
|
||||
}
|
||||
|
||||
describe('calendar tools', () => {
|
||||
let tmpDir: string;
|
||||
let repo: Repository;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'calendar-tool-test-'));
|
||||
repo = new Repository(join(tmpDir, 'test.db'));
|
||||
setCalendarRepo(repo);
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
setCalendarRepo(null);
|
||||
});
|
||||
|
||||
it('exposes TOOL_DEFS entries', () => {
|
||||
expect(TOOL_DEFS.AddCalendarEvent?.function.name).toBe('AddCalendarEvent');
|
||||
expect(TOOL_DEFS.ListCalendarEvents?.function.name).toBe('ListCalendarEvents');
|
||||
});
|
||||
|
||||
it('AddCalendarEvent happy path: row created with createdBy=agent + sourceTaskId', async () => {
|
||||
const result = await executeTool(
|
||||
'AddCalendarEvent',
|
||||
{ title: 'kickoff', date: '2026-07-01', time: '10:00', description: 'd' },
|
||||
ctx(),
|
||||
);
|
||||
expect(result?.isError).toBe(false);
|
||||
const events = await repo.listCalendarEvents('space-A');
|
||||
expect(events).toHaveLength(1);
|
||||
const ev = events[0]!;
|
||||
expect(ev.title).toBe('kickoff');
|
||||
expect(ev.time).toBe('10:00');
|
||||
expect(ev.createdBy).toBe('agent');
|
||||
expect(ev.sourceTaskId).toBe(7);
|
||||
expect(ev.ownerId).toBe('u1');
|
||||
});
|
||||
|
||||
it('AddCalendarEvent all-day when time omitted', async () => {
|
||||
await executeTool('AddCalendarEvent', { title: 'deadline', date: '2026-07-10' }, ctx());
|
||||
const ev = (await repo.listCalendarEvents('space-A'))[0]!;
|
||||
expect(ev.time).toBeNull();
|
||||
});
|
||||
|
||||
it('AddCalendarEvent: missing spaceId → isError, no row written', async () => {
|
||||
const result = await executeTool(
|
||||
'AddCalendarEvent',
|
||||
{ title: 'x', date: '2026-07-01' },
|
||||
ctx({ spaceId: undefined }),
|
||||
);
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toMatch(/スペース/);
|
||||
});
|
||||
|
||||
it('AddCalendarEvent: bad date format → isError', async () => {
|
||||
const result = await executeTool('AddCalendarEvent', { title: 'x', date: '2026/07/01' }, ctx());
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toMatch(/date/);
|
||||
});
|
||||
|
||||
it('AddCalendarEvent: bad time format → isError', async () => {
|
||||
const result = await executeTool(
|
||||
'AddCalendarEvent',
|
||||
{ title: 'x', date: '2026-07-01', time: '25:99' },
|
||||
ctx(),
|
||||
);
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toMatch(/time/);
|
||||
});
|
||||
|
||||
it('AddCalendarEvent: missing title → isError', async () => {
|
||||
const result = await executeTool('AddCalendarEvent', { date: '2026-07-01' }, ctx());
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toMatch(/title/);
|
||||
});
|
||||
|
||||
it('AddCalendarEvent: repo not injected → isError', async () => {
|
||||
setCalendarRepo(null);
|
||||
const result = await executeTool('AddCalendarEvent', { title: 'x', date: '2026-07-01' }, ctx());
|
||||
expect(result?.isError).toBe(true);
|
||||
});
|
||||
|
||||
it('ListCalendarEvents returns the space events', async () => {
|
||||
await repo.createCalendarEvent({ spaceId: 'space-A', date: '2026-07-01', title: 'a' });
|
||||
await repo.createCalendarEvent({ spaceId: 'space-A', date: '2026-07-02', time: '09:00', title: 'b' });
|
||||
await repo.createCalendarEvent({ spaceId: 'space-B', date: '2026-07-01', title: 'other' });
|
||||
const result = await executeTool('ListCalendarEvents', {}, ctx());
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(result?.output).toMatch(/a/);
|
||||
expect(result?.output).toMatch(/b/);
|
||||
expect(result?.output).not.toMatch(/other/);
|
||||
});
|
||||
|
||||
it('ListCalendarEvents respects from/to range', async () => {
|
||||
await repo.createCalendarEvent({ spaceId: 'space-A', date: '2026-07-01', title: 'in' });
|
||||
await repo.createCalendarEvent({ spaceId: 'space-A', date: '2026-08-01', title: 'out' });
|
||||
const result = await executeTool('ListCalendarEvents', { from: '2026-07-01', to: '2026-07-31' }, ctx());
|
||||
expect(result?.output).toMatch(/in/);
|
||||
expect(result?.output).not.toMatch(/out/);
|
||||
});
|
||||
|
||||
it('ListCalendarEvents: empty range message', async () => {
|
||||
const result = await executeTool('ListCalendarEvents', {}, ctx());
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(result?.output).toMatch(/予定はありません/);
|
||||
});
|
||||
|
||||
it('returns null for unrelated tool names', async () => {
|
||||
const result = await executeTool('SomethingElse', {}, ctx());
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { ToolDef } from '../../llm/openai-compat.js';
|
||||
import type { ToolContext, ToolResult } from './core.js';
|
||||
import type { Repository } from '../../db/repository.js';
|
||||
|
||||
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const TIME_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
const MAX_TITLE_LEN = 200;
|
||||
const MAX_DESC_LEN = 4000;
|
||||
|
||||
let _repo: Repository | null = null;
|
||||
|
||||
export function setCalendarRepo(repo: Repository | null): void {
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
const ADD_CALENDAR_EVENT_DEF: ToolDef = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'AddCalendarEvent',
|
||||
description:
|
||||
'実行中タスクのスペースのカレンダーに予定を1件追加する(created_by=agent)。詳細は ReadToolDoc({ name: "AddCalendarEvent" }) で取得可能。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
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。省略すると終日扱い' },
|
||||
description: { type: 'string', description: '予定の補足説明(任意)' },
|
||||
},
|
||||
required: ['title', 'date'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const LIST_CALENDAR_EVENTS_DEF: ToolDef = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'ListCalendarEvents',
|
||||
description:
|
||||
'実行中タスクのスペースのカレンダー予定を日付範囲で一覧する。詳細は ReadToolDoc({ name: "AddCalendarEvent" }) で取得可能。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
from: { type: 'string', description: '範囲開始 YYYY-MM-DD(任意)' },
|
||||
to: { type: 'string', description: '範囲終了 YYYY-MM-DD(任意)' },
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const TOOL_DEFS: Record<string, ToolDef> = {
|
||||
AddCalendarEvent: ADD_CALENDAR_EVENT_DEF,
|
||||
ListCalendarEvents: LIST_CALENDAR_EVENTS_DEF,
|
||||
};
|
||||
|
||||
export async function executeTool(
|
||||
name: string,
|
||||
input: Record<string, unknown>,
|
||||
ctx: ToolContext,
|
||||
): Promise<ToolResult | null> {
|
||||
if (name === 'AddCalendarEvent') return executeAddCalendarEvent(input, ctx);
|
||||
if (name === 'ListCalendarEvents') return executeListCalendarEvents(input, ctx);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function executeAddCalendarEvent(
|
||||
input: Record<string, unknown>,
|
||||
ctx: ToolContext,
|
||||
): Promise<ToolResult> {
|
||||
if (!_repo) {
|
||||
return { output: 'Calendar repo is not initialized', isError: true };
|
||||
}
|
||||
const spaceId = ctx.spaceId;
|
||||
if (!spaceId) {
|
||||
return {
|
||||
output:
|
||||
'このタスクはスペースに属していないため予定を追加できません(旧 local タスクには spaceId がありません)',
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
const title = input['title'];
|
||||
const date = input['date'];
|
||||
const endDateInput = input['end_date'];
|
||||
const time = input['time'];
|
||||
const description = input['description'];
|
||||
|
||||
if (typeof title !== 'string' || !title.trim()) {
|
||||
return { output: 'title は必須です', isError: true };
|
||||
}
|
||||
if (title.length > MAX_TITLE_LEN) {
|
||||
return { output: `title が長すぎます(最大 ${MAX_TITLE_LEN} 文字)`, isError: true };
|
||||
}
|
||||
if (typeof date !== 'string' || !DATE_PATTERN.test(date)) {
|
||||
return { output: 'date は YYYY-MM-DD 形式で指定してください', isError: true };
|
||||
}
|
||||
let endDate: string | null = null;
|
||||
if (endDateInput !== undefined && endDateInput !== null && endDateInput !== '') {
|
||||
if (typeof endDateInput !== 'string' || !DATE_PATTERN.test(endDateInput)) {
|
||||
return { output: 'end_date は YYYY-MM-DD 形式で指定してください', isError: true };
|
||||
}
|
||||
if (endDateInput < date) {
|
||||
return { output: 'end_date は date 以降の日付にしてください', isError: true };
|
||||
}
|
||||
endDate = endDateInput;
|
||||
}
|
||||
if (time !== undefined && time !== null && time !== '') {
|
||||
if (typeof time !== 'string' || !TIME_PATTERN.test(time)) {
|
||||
return { output: 'time は HH:MM 形式で指定してください', isError: true };
|
||||
}
|
||||
}
|
||||
if (description !== undefined && description !== null) {
|
||||
if (typeof description !== 'string') {
|
||||
return { output: 'description は文字列で指定してください', isError: true };
|
||||
}
|
||||
if (description.length > MAX_DESC_LEN) {
|
||||
return { output: `description が長すぎます(最大 ${MAX_DESC_LEN} 文字)`, isError: true };
|
||||
}
|
||||
}
|
||||
|
||||
const sourceTaskId = ctx.taskId ? Number(ctx.taskId) : null;
|
||||
const ev = await _repo.createCalendarEvent({
|
||||
spaceId,
|
||||
ownerId: ctx.ownerId ?? null,
|
||||
date,
|
||||
endDate,
|
||||
time: typeof time === 'string' && time !== '' ? time : null,
|
||||
title: title.trim(),
|
||||
description: typeof description === 'string' ? description : null,
|
||||
createdBy: 'agent',
|
||||
sourceTaskId: Number.isFinite(sourceTaskId) ? sourceTaskId : null,
|
||||
});
|
||||
return {
|
||||
output: `予定を追加しました: ${ev.date}${ev.time ? ` ${ev.time}` : '(終日)'} 「${ev.title}」(id=${ev.id})`,
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function executeListCalendarEvents(
|
||||
input: Record<string, unknown>,
|
||||
ctx: ToolContext,
|
||||
): Promise<ToolResult> {
|
||||
if (!_repo) {
|
||||
return { output: 'Calendar repo is not initialized', isError: true };
|
||||
}
|
||||
const spaceId = ctx.spaceId;
|
||||
if (!spaceId) {
|
||||
return {
|
||||
output: 'このタスクはスペースに属していないため予定を一覧できません',
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
const from = input['from'];
|
||||
const to = input['to'];
|
||||
if (from !== undefined && (typeof from !== 'string' || !DATE_PATTERN.test(from))) {
|
||||
return { output: 'from は YYYY-MM-DD 形式で指定してください', isError: true };
|
||||
}
|
||||
if (to !== undefined && (typeof to !== 'string' || !DATE_PATTERN.test(to))) {
|
||||
return { output: 'to は YYYY-MM-DD 形式で指定してください', isError: true };
|
||||
}
|
||||
const events = await _repo.listCalendarEvents(spaceId, {
|
||||
from: typeof from === 'string' ? from : undefined,
|
||||
to: typeof to === 'string' ? to : undefined,
|
||||
});
|
||||
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})`,
|
||||
);
|
||||
return { output: lines.join('\n'), isError: false };
|
||||
}
|
||||
@@ -343,7 +343,7 @@ describe('checklist', () => {
|
||||
describe('buildChecklistContext', () => {
|
||||
// Test 1: returns empty string when no checklists exist
|
||||
it('returns empty string when no checklists exist', () => {
|
||||
const result = buildChecklistContext(tempDir);
|
||||
const result = buildChecklistContext(join(tempDir, 'logs'));
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
@@ -372,7 +372,7 @@ describe('checklist', () => {
|
||||
|
||||
writeFileSync(join(checklistsDir, 'test-checklist.json'), JSON.stringify(checklistData));
|
||||
|
||||
const result = buildChecklistContext(tempDir);
|
||||
const result = buildChecklistContext(join(tempDir, 'logs'));
|
||||
|
||||
expect(result).toContain('## 作業チェックシート');
|
||||
expect(result).toContain('test-checklist');
|
||||
@@ -404,7 +404,7 @@ describe('checklist', () => {
|
||||
writeFileSync(join(checklistsDir, `checklist-${i}.json`), JSON.stringify(checklistData));
|
||||
}
|
||||
|
||||
const result = buildChecklistContext(tempDir);
|
||||
const result = buildChecklistContext(join(tempDir, 'logs'));
|
||||
|
||||
// Only 5 newest (by updated_at) should appear
|
||||
expect(result).toContain('checklist-7');
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { ToolDef } from '../../llm/openai-compat.js';
|
||||
import type { ToolContext, ToolResult } from './core.js';
|
||||
import { resolveAndGuard } from './core.js';
|
||||
import { runtimeLogsDir } from './core.js';
|
||||
|
||||
// --- Name validation ---
|
||||
const NAME_REGEX = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
||||
@@ -106,8 +106,12 @@ export const TOOL_DEFS: Record<string, ToolDef> = {
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function checklistPath(workspacePath: string, name: string): string {
|
||||
return resolveAndGuard(workspacePath, `logs/checklists/${name}.json`);
|
||||
// 計画5: 実行ログ root(ctx.runtimeDir ?? workspacePath/logs)配下に直接書く。
|
||||
// runtime_dir は files jail の外になりうるため resolveAndGuard を経由させない。
|
||||
// name は NAME_REGEX (/^[a-z0-9][a-z0-9-]{0,62}$/) で検証済みなのでパストラバーサル不可。
|
||||
// Phase A では runtimeDir 未指定=従来の workspace/logs/checklists と byte 一致。
|
||||
function checklistPath(ctx: ToolContext, name: string): string {
|
||||
return path.join(runtimeLogsDir(ctx), 'checklists', `${name}.json`);
|
||||
}
|
||||
|
||||
function computeSummary(items: ChecklistItem[]): ChecklistSummary {
|
||||
@@ -160,7 +164,7 @@ function executeCreateChecklist(input: Record<string, unknown>, ctx: ToolContext
|
||||
|
||||
let filePath: string;
|
||||
try {
|
||||
filePath = checklistPath(ctx.workspacePath, name);
|
||||
filePath = checklistPath(ctx, name);
|
||||
} catch (e) {
|
||||
return { output: (e as Error).message, isError: true };
|
||||
}
|
||||
@@ -203,7 +207,7 @@ function executeCheckItem(input: Record<string, unknown>, ctx: ToolContext): Too
|
||||
|
||||
let filePath: string;
|
||||
try {
|
||||
filePath = checklistPath(ctx.workspacePath, name);
|
||||
filePath = checklistPath(ctx, name);
|
||||
} catch (e) {
|
||||
return { output: (e as Error).message, isError: true };
|
||||
}
|
||||
@@ -238,7 +242,7 @@ function executeGetChecklist(input: Record<string, unknown>, ctx: ToolContext):
|
||||
|
||||
let filePath: string;
|
||||
try {
|
||||
filePath = checklistPath(ctx.workspacePath, name);
|
||||
filePath = checklistPath(ctx, name);
|
||||
} catch (e) {
|
||||
return { output: (e as Error).message, isError: true };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync, mkdirSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { recordVersion, resolveWriteTarget } from './conflict-guard.js';
|
||||
|
||||
describe('conflict-guard', () => {
|
||||
let ws: string;
|
||||
let conflictDir: string;
|
||||
beforeEach(() => {
|
||||
ws = mkdtempSync(join(tmpdir(), 'cg-'));
|
||||
// 計画5: 台帳は files ツリーの外(スペース単位 .conflict/)に住む。
|
||||
conflictDir = join(ws, '.conflict');
|
||||
});
|
||||
afterEach(() => { rmSync(ws, { recursive: true, force: true }); });
|
||||
|
||||
it('recordVersion is best-effort: a directory / missing path never throws and records nothing', () => {
|
||||
mkdirSync(join(ws, 'adir'), { recursive: true });
|
||||
writeFileSync(join(ws, 'real.txt'), 'hello');
|
||||
recordVersion(ws, 'real.txt', conflictDir); // sanity: a real file IS recorded
|
||||
// Regression: readFileSync(dir) → EISDIR previously propagated and crashed
|
||||
// the job. recordVersion must swallow it (and record nothing for a dir).
|
||||
expect(() => recordVersion(ws, 'adir', conflictDir)).not.toThrow();
|
||||
expect(() => recordVersion(ws, 'does-not-exist.txt', conflictDir)).not.toThrow();
|
||||
const ledger = JSON.parse(readFileSync(join(conflictDir, 'file-versions.json'), 'utf-8'));
|
||||
expect(ledger).toHaveProperty('real.txt'); // real file recorded
|
||||
expect(ledger).not.toHaveProperty('adir'); // directory NOT recorded
|
||||
expect(ledger).not.toHaveProperty('does-not-exist.txt');
|
||||
});
|
||||
|
||||
it('a brand-new file writes to its own path (no conflict)', () => {
|
||||
const r = resolveWriteTarget(ws, 'output/a.txt', 'hello', conflictDir);
|
||||
expect(r.conflict).toBe(false);
|
||||
expect(r.targetRelPath).toBe('output/a.txt');
|
||||
});
|
||||
|
||||
it('overwriting a file whose disk content matches the recorded version is NOT a conflict', () => {
|
||||
writeFileSync(join(ws, 'a.txt'), 'v1');
|
||||
recordVersion(ws, 'a.txt', conflictDir); // agent read/wrote it, base = v1
|
||||
const r = resolveWriteTarget(ws, 'a.txt', 'v2', conflictDir);
|
||||
expect(r.conflict).toBe(false);
|
||||
expect(r.targetRelPath).toBe('a.txt');
|
||||
});
|
||||
|
||||
it('overwriting a file changed on disk since the recorded version → competing copy', () => {
|
||||
writeFileSync(join(ws, 'a.txt'), 'v1');
|
||||
recordVersion(ws, 'a.txt', conflictDir); // base = v1
|
||||
writeFileSync(join(ws, 'a.txt'), 'EXTERNAL'); // someone else changed it
|
||||
const r = resolveWriteTarget(ws, 'a.txt', 'v2', conflictDir);
|
||||
expect(r.conflict).toBe(true);
|
||||
expect(r.targetRelPath).toMatch(/^a \(競合コピー \d+\)\.txt$/);
|
||||
});
|
||||
|
||||
it('overwriting an existing file with NO recorded base (never read by this agent) → competing copy', () => {
|
||||
writeFileSync(join(ws, 'a.txt'), 'pre-existing');
|
||||
const r = resolveWriteTarget(ws, 'a.txt', 'v2', conflictDir);
|
||||
expect(r.conflict).toBe(true); // blind overwrite of unknown content is treated as conflict
|
||||
});
|
||||
|
||||
it('the ledger is created under the given conflictDir, NOT under files/ or logs/', () => {
|
||||
writeFileSync(join(ws, 'a.txt'), 'v1');
|
||||
recordVersion(ws, 'a.txt', conflictDir);
|
||||
expect(existsSync(join(conflictDir, 'file-versions.json'))).toBe(true);
|
||||
// 旧 logs/.file-versions.json には書かない。
|
||||
expect(existsSync(join(ws, 'logs', '.file-versions.json'))).toBe(false);
|
||||
expect(existsSync(join(ws, '.file-versions.json'))).toBe(false);
|
||||
});
|
||||
|
||||
it('files under logs/ are now versioned normally (exclusion removed)', () => {
|
||||
// 台帳が files の外に出たので logs/ を特別扱いしない。
|
||||
writeFileSync(join(ws, 'logs.txt'), 'a log');
|
||||
recordVersion(ws, 'logs.txt', conflictDir);
|
||||
const led = JSON.parse(readFileSync(join(conflictDir, 'file-versions.json'), 'utf-8'));
|
||||
expect(led['logs.txt']).toBeTruthy();
|
||||
});
|
||||
|
||||
it('sequential recordVersion calls do not drop keys (no last-writer-wins)', () => {
|
||||
for (let i = 0; i < 25; i++) {
|
||||
writeFileSync(join(ws, `f${i}.txt`), `content-${i}`);
|
||||
recordVersion(ws, `f${i}.txt`, conflictDir);
|
||||
}
|
||||
const led = JSON.parse(readFileSync(join(conflictDir, 'file-versions.json'), 'utf-8'));
|
||||
expect(Object.keys(led)).toHaveLength(25);
|
||||
for (let i = 0; i < 25; i++) expect(led[`f${i}.txt`]).toBeTruthy();
|
||||
});
|
||||
|
||||
it('competing-copy reservation uses O_EXCL: two conflicts of the same base get distinct names', () => {
|
||||
writeFileSync(join(ws, 'a.txt'), 'pre-existing');
|
||||
const r1 = resolveWriteTarget(ws, 'a.txt', 'v2', conflictDir);
|
||||
expect(r1.conflict).toBe(true);
|
||||
// The first reservation must have materialised on disk (O_EXCL reserves the name).
|
||||
expect(existsSync(join(ws, r1.targetRelPath))).toBe(true);
|
||||
const r2 = resolveWriteTarget(ws, 'a.txt', 'v3', conflictDir);
|
||||
expect(r2.conflict).toBe(true);
|
||||
expect(r2.targetRelPath).not.toBe(r1.targetRelPath);
|
||||
expect(existsSync(join(ws, r2.targetRelPath))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
existsSync, readFileSync, writeFileSync, mkdirSync, openSync, closeSync, renameSync, statSync,
|
||||
} from 'node:fs';
|
||||
import { join, dirname, extname } from 'node:path';
|
||||
|
||||
/**
|
||||
* 競合台帳(計画5 / spec §10.3)。
|
||||
*
|
||||
* 台帳は files ツリーの外 — スペース単位の `.conflict/file-versions.json` — に住む。
|
||||
* Phase A 以前は `logs/.file-versions.json` で、logs/** を除外する特別扱いが必要だった。
|
||||
* 台帳が files の外に出たので、その除外ロジックは撤廃した(logs 配下も普通に version 記録される)。
|
||||
*
|
||||
* アトミック性(同一プロセス内の複数エージェント並行を前提とした堅牢化):
|
||||
* - 台帳書込は temp ファイル → `renameSync`(rename は同一 FS でアトミック)。
|
||||
* read-modify-write 自体は同期実行(better-sqlite3 / tools は単一スレッド同期)なので、
|
||||
* 一回の呼び出し内では中断されない。temp+rename により、別エージェントの並行書込で
|
||||
* 半端な JSON を観測したり last-writer-wins でキーを取りこぼす窓を除去する。
|
||||
* - 競合コピー名は `openSync(path, 'wx')`(O_EXCL)で予約してから書く。`while(existsSync)`
|
||||
* スキャンの TOCTOU を排除し、二者が同じ base を同時解決しても別名を得る。
|
||||
*
|
||||
* 残存制限(spec §10.3): per-file の書込ロックは持たない。同一成果物への真の同時上書きは
|
||||
* last-writer-wins のまま。台帳のアトミック性とは別レイヤーの課題。
|
||||
*/
|
||||
|
||||
function hash(buf: Buffer | string): string {
|
||||
return createHash('sha256').update(buf).digest('hex');
|
||||
}
|
||||
|
||||
function ledgerPath(conflictDir: string): string {
|
||||
return join(conflictDir, 'file-versions.json');
|
||||
}
|
||||
|
||||
function readLedger(conflictDir: string): Record<string, string> {
|
||||
try { return JSON.parse(readFileSync(ledgerPath(conflictDir), 'utf-8')); } catch { return {}; }
|
||||
}
|
||||
|
||||
/** 台帳を temp ファイル経由でアトミックに書く(rename は同一 FS でアトミック)。 */
|
||||
function writeLedger(conflictDir: string, led: Record<string, string>): void {
|
||||
mkdirSync(conflictDir, { recursive: true });
|
||||
const target = ledgerPath(conflictDir);
|
||||
const tmp = `${target}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
|
||||
writeFileSync(tmp, JSON.stringify(led), 'utf-8');
|
||||
renameSync(tmp, target);
|
||||
}
|
||||
|
||||
/**
|
||||
* 現在のディスク版を台帳に記録する(Read / Edit / 正常 Write の後に呼ぶ)。
|
||||
* ベストエフォート: 記録に失敗しても **絶対に throw しない**。これはツールの
|
||||
* 副次的な簿記であり、対象が存在しない / ディレクトリ / 権限不足 / レース等で
|
||||
* readFileSync が EISDIR/ENOENT 等を投げても、ジョブをクラッシュさせてはならない
|
||||
* (未記録なら後続 Write が競合を検知できないだけで、安全側に縮退する)。
|
||||
*/
|
||||
export function recordVersion(ws: string, relPath: string, conflictDir: string): void {
|
||||
try {
|
||||
const abs = join(ws, relPath);
|
||||
// 通常ファイルのみ版管理する。ディレクトリ/特殊ファイルは readFileSync が
|
||||
// 投げる(EISDIR 等)ので明示的にスキップする。statSync は欠損で throw → catch。
|
||||
if (!statSync(abs).isFile()) return;
|
||||
const led = readLedger(conflictDir);
|
||||
led[relPath] = hash(readFileSync(abs));
|
||||
writeLedger(conflictDir, led);
|
||||
} catch {
|
||||
// best-effort: 簿記の失敗でツール/ジョブを落とさない。
|
||||
}
|
||||
}
|
||||
|
||||
export interface WriteResolution {
|
||||
/** 実際に書き込むべき相対パス(競合時は競合コピー名) */
|
||||
targetRelPath: string;
|
||||
conflict: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 競合コピー名を O_EXCL で予約する。`{stem} (競合コピー N){ext}` を N=1 から順に試し、
|
||||
* 排他作成に成功した最初の名前を返す。空ファイルを予約として作るので、呼び出し側は
|
||||
* その名前へ後から書き込む(盲目上書きの窓が無い)。
|
||||
*/
|
||||
function reserveCompetingCopy(ws: string, relPath: string): string {
|
||||
const ext = extname(relPath);
|
||||
const stem = relPath.slice(0, relPath.length - ext.length);
|
||||
// 同一プロセス内の暴走に対する上限(実用上は到達しない)。
|
||||
for (let n = 1; n < 100000; n++) {
|
||||
const candidate = `${stem} (競合コピー ${n})${ext}`;
|
||||
const abs = join(ws, candidate);
|
||||
try {
|
||||
mkdirSync(dirname(abs), { recursive: true });
|
||||
const fd = openSync(abs, 'wx'); // O_EXCL: 既存なら EEXIST
|
||||
closeSync(fd);
|
||||
return candidate;
|
||||
} catch (e) {
|
||||
if ((e as NodeJS.ErrnoException).code === 'EEXIST') continue;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
throw new Error('reserveCompetingCopy: exhausted candidate names');
|
||||
}
|
||||
|
||||
/**
|
||||
* Write の衝突解決(spec §2.4)。
|
||||
* - 対象が存在しない → そのまま書く(競合なし)。
|
||||
* - 対象が存在し、ディスク版が台帳の記録と一致 → 普通に上書き(競合なし)。
|
||||
* - 対象が存在するが台帳に記録が無い、または記録と食い違う → 競合。
|
||||
* O_EXCL で予約した `{name} (競合コピー N).{ext}` を返し、元ファイルは触らない。
|
||||
*/
|
||||
export function resolveWriteTarget(
|
||||
ws: string,
|
||||
relPath: string,
|
||||
_newContent: string,
|
||||
conflictDir: string,
|
||||
): WriteResolution {
|
||||
const abs = join(ws, relPath);
|
||||
if (!existsSync(abs)) return { targetRelPath: relPath, conflict: false };
|
||||
const led = readLedger(conflictDir);
|
||||
const base = led[relPath];
|
||||
const diskHash = hash(readFileSync(abs));
|
||||
if (base && base === diskHash) return { targetRelPath: relPath, conflict: false };
|
||||
// 競合: O_EXCL で空いている連番のコピー名を予約する(TOCTOU 排除)。
|
||||
const candidate = reserveCompetingCopy(ws, relPath);
|
||||
return { targetRelPath: candidate, conflict: true };
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { executeCoreTools, resolveOutputPathWithin, checkBlockedInstallPatterns, checkAllowedCommand, checkBashPathScope, DEFAULT_ALLOWED_COMMANDS, type ToolContext } from './core.js';
|
||||
import { executeCoreTools, resolveAndGuard, resolveOutputPathWithin, checkBlockedInstallPatterns, checkAllowedCommand, checkBashPathScope, DEFAULT_ALLOWED_COMMANDS, type ToolContext } from './core.js';
|
||||
import { looksLikeBinaryBytes } from './binary-detect.js';
|
||||
|
||||
function makeWorkspace(): string {
|
||||
@@ -38,6 +38,18 @@ describe('core tools', () => {
|
||||
expect(result?.output).toContain('ReadImage');
|
||||
});
|
||||
|
||||
it('returns a tool error (does not throw EISDIR) when Read targets a directory', async () => {
|
||||
// Regression: Reading a directory inside a persistent space workspace hit
|
||||
// recordVersion()'s unguarded readFileSync(dir) → EISDIR → crashed the job.
|
||||
workspacePath = makeWorkspace();
|
||||
fs.mkdirSync(path.join(workspacePath, 'files', 'sub'), { recursive: true });
|
||||
const ctx: ToolContext = { ...makeContext(workspacePath), conflictDetection: true };
|
||||
const result = await executeCoreTools('Read', { file_path: 'files', limit: 500 }, ctx);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toContain('ディレクトリ');
|
||||
});
|
||||
|
||||
it('blocks Read on PDF files and suggests ReadPdf', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
fs.mkdirSync(path.join(workspacePath, 'input'), { recursive: true });
|
||||
@@ -714,6 +726,118 @@ describe('resolveBashMode + env scrub (hardened fallback)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('conflict detection (persistent workspace)', () => {
|
||||
let workspacePath = '';
|
||||
afterEach(() => {
|
||||
if (workspacePath) {
|
||||
fs.rmSync(workspacePath, { recursive: true, force: true });
|
||||
workspacePath = '';
|
||||
}
|
||||
});
|
||||
|
||||
function makeGuardedContext(ws: string): ToolContext {
|
||||
return { workspacePath: ws, editAllowed: true, conflictDetection: true };
|
||||
}
|
||||
|
||||
it('(a) Write to a pre-existing file never read by the agent → competing copy, original preserved', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
fs.mkdirSync(path.join(workspacePath, 'output'), { recursive: true });
|
||||
fs.writeFileSync(path.join(workspacePath, 'output', 'a.txt'), 'pre-existing', 'utf-8');
|
||||
|
||||
const r = await executeCoreTools('Write', { file_path: 'output/a.txt', content: 'agent-version' }, makeGuardedContext(workspacePath));
|
||||
expect(r?.isError).toBe(false);
|
||||
expect(r?.output).toContain('競合');
|
||||
|
||||
// original preserved
|
||||
expect(fs.readFileSync(path.join(workspacePath, 'output', 'a.txt'), 'utf-8')).toBe('pre-existing');
|
||||
// a competing copy was created carrying the agent's content
|
||||
const copies = fs.readdirSync(path.join(workspacePath, 'output')).filter((f) => /^a \(競合コピー \d+\)\.txt$/.test(f));
|
||||
expect(copies.length).toBe(1);
|
||||
expect(fs.readFileSync(path.join(workspacePath, 'output', copies[0]!), 'utf-8')).toBe('agent-version');
|
||||
});
|
||||
|
||||
it('(b) Read then Write the same file (no external change) → overwrites in place, no competing copy', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
fs.mkdirSync(path.join(workspacePath, 'output'), { recursive: true });
|
||||
fs.writeFileSync(path.join(workspacePath, 'output', 'b.txt'), 'base', 'utf-8');
|
||||
|
||||
const read = await executeCoreTools('Read', { file_path: 'output/b.txt' }, makeGuardedContext(workspacePath));
|
||||
expect(read?.isError).toBe(false);
|
||||
|
||||
const write = await executeCoreTools('Write', { file_path: 'output/b.txt', content: 'updated' }, makeGuardedContext(workspacePath));
|
||||
expect(write?.isError).toBe(false);
|
||||
expect(write?.output).not.toContain('競合');
|
||||
|
||||
expect(fs.readFileSync(path.join(workspacePath, 'output', 'b.txt'), 'utf-8')).toBe('updated');
|
||||
const copies = fs.readdirSync(path.join(workspacePath, 'output')).filter((f) => /競合コピー/.test(f));
|
||||
expect(copies.length).toBe(0);
|
||||
});
|
||||
|
||||
it('(c) conflict detection OFF (default) → Write overwrites silently (backward compat)', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
fs.mkdirSync(path.join(workspacePath, 'output'), { recursive: true });
|
||||
fs.writeFileSync(path.join(workspacePath, 'output', 'c.txt'), 'pre-existing', 'utf-8');
|
||||
|
||||
// makeContext() does NOT set conflictDetection → default OFF
|
||||
const r = await executeCoreTools('Write', { file_path: 'output/c.txt', content: 'overwritten' }, makeContext(workspacePath));
|
||||
expect(r?.isError).toBe(false);
|
||||
expect(r?.output).not.toContain('競合');
|
||||
|
||||
expect(fs.readFileSync(path.join(workspacePath, 'output', 'c.txt'), 'utf-8')).toBe('overwritten');
|
||||
const copies = fs.readdirSync(path.join(workspacePath, 'output')).filter((f) => /競合コピー/.test(f));
|
||||
expect(copies.length).toBe(0);
|
||||
});
|
||||
|
||||
it('(d) path confinement holds with conflict detection on: ../ and absolute outside-workspace are rejected', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
|
||||
const escapeWrite = await executeCoreTools('Write', { file_path: '../escape.txt', content: 'no' }, makeGuardedContext(workspacePath));
|
||||
expect(escapeWrite?.isError).toBe(true);
|
||||
expect(fs.existsSync(path.join(workspacePath, '..', 'escape.txt'))).toBe(false);
|
||||
|
||||
const absWrite = await executeCoreTools('Write', { file_path: path.join(tmpdir(), 'maestro-escape-abs.txt'), content: 'no' }, makeGuardedContext(workspacePath));
|
||||
expect(absWrite?.isError).toBe(true);
|
||||
|
||||
const escapeEdit = await executeCoreTools('Edit', { file_path: '../escape.txt', old_string: 'a', new_string: 'b' }, makeGuardedContext(workspacePath));
|
||||
expect(escapeEdit?.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveAndGuard symlink escape', () => {
|
||||
let ws = '';
|
||||
let outside = '';
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of [ws, outside]) {
|
||||
if (dir) fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
ws = '';
|
||||
outside = '';
|
||||
});
|
||||
|
||||
it('rejects a path that escapes the workspace through an in-workspace symlink', () => {
|
||||
ws = makeWorkspace();
|
||||
outside = fs.mkdtempSync(path.join(tmpdir(), 'maestro-outside-'));
|
||||
fs.writeFileSync(path.join(outside, 'secret.txt'), 'top secret');
|
||||
// Agent creates a symlink inside the workspace pointing outside (e.g. via Bash ln -s).
|
||||
fs.symlinkSync(outside, path.join(ws, 'evil'));
|
||||
|
||||
expect(() => resolveAndGuard(ws, 'evil/secret.txt')).toThrow(/Path traversal detected/);
|
||||
});
|
||||
|
||||
it('still resolves a normal in-workspace path', () => {
|
||||
ws = makeWorkspace();
|
||||
fs.mkdirSync(path.join(ws, 'output'), { recursive: true });
|
||||
const resolved = resolveAndGuard(ws, 'output/a.txt');
|
||||
expect(resolved).toBe(path.resolve(ws, 'output/a.txt'));
|
||||
});
|
||||
|
||||
it('still rejects a plain ../ escape (regression)', () => {
|
||||
ws = makeWorkspace();
|
||||
expect(() => resolveAndGuard(ws, '../escape')).toThrow(/Path traversal detected/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Read delegates to shared binary detector', () => {
|
||||
it('flags a magic-byte binary with no NUL in head', () => {
|
||||
const head = Buffer.from([0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1, 0x41, 0x42]);
|
||||
|
||||
+182
-33
@@ -8,6 +8,7 @@ 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 { recordVersion, resolveWriteTarget } from './conflict-guard.js';
|
||||
|
||||
export interface ToolsConfig {
|
||||
searxngUrl?: string; // default: http://searxng:8080
|
||||
@@ -44,8 +45,6 @@ export interface ToolsConfig {
|
||||
speechServerUrl?: string; // 音声認識サーバー URL (例: http://localhost:8000/v1)
|
||||
speechTimeout?: number; // タイムアウト秒 (default: 300)
|
||||
speechLanguage?: string; // デフォルト言語 (default: ja)
|
||||
knowledgeServiceUrl?: string; // DKS サーバー URL (例: http://dks-server:8100)
|
||||
knowledgeNamespaces?: Record<string, { apiKey: string }>; // namespace ごとの API キー
|
||||
officeExcelMaxSizeMb?: number; // ReadExcel の最大ファイルサイズ (default: 10)
|
||||
officeDocxMaxSizeMb?: number; // ReadDocx の最大ファイルサイズ (default: 10)
|
||||
officePdfMaxSizeMb?: number; // ReadPdf の最大ファイルサイズ (default: 10)
|
||||
@@ -58,6 +57,13 @@ export interface ToolsConfig {
|
||||
|
||||
export interface ToolContext {
|
||||
workspacePath: string; // ツール実行のルートディレクトリ
|
||||
/**
|
||||
* 実行ログ root(計画5 / spec §10)。logs/bash-history・raw・checklist 等の
|
||||
* 実行ログ書込先。未指定時は `join(workspacePath, 'logs')` にフォールバック
|
||||
* (後方互換: Phase A では常に undefined なので現行と byte 一致)。
|
||||
* runtime_dir は files jail の外になりうるため resolveAndGuard を経由させず直接書く。
|
||||
*/
|
||||
runtimeDir?: string;
|
||||
editAllowed: boolean; // movement の edit フラグ
|
||||
vlmEnabled?: boolean; // worker の vlm=true → ReadImage で AgentLoop LLM に画像注入可能
|
||||
allowedCommands?: string[]; // Bash ツールで許可するコマンド名一覧 (省略時は DEFAULT_ALLOWED_COMMANDS)
|
||||
@@ -68,6 +74,19 @@ export interface ToolContext {
|
||||
toolsConfig?: ToolsConfig;
|
||||
searchFilter?: SearchFilterConfig; // AppConfig.searchFilter (トップレベル)
|
||||
customPiecesDir?: string | string[];
|
||||
/**
|
||||
* Resolved space folder context (spaces foundation). When set, AGENTS.md /
|
||||
* memory / custom-piece resolution uses `(rootDir, leafId)` instead of the
|
||||
* legacy `(userFolderRoot, userId)`. Absent => legacy personal-folder
|
||||
* resolution (backward compatible; space_id=null tasks behave as before).
|
||||
*/
|
||||
folderContext?: { rootDir: string; leafId: string };
|
||||
/**
|
||||
* 実行ジョブが属するスペース ID(spaces foundation)。MCP/SSH の per-space
|
||||
* 解決に使う予定。null/undefined = global/legacy(全体スコープで解決)。
|
||||
* Phase 1 では値を配線するだけで、まだ誰も参照しない(no-op、後方互換)。
|
||||
*/
|
||||
spaceId?: string;
|
||||
/**
|
||||
* ローカルタスク ID (string)。BrowseWeb / InteractiveBrowse が
|
||||
* per-task noVNC session を使うときのキー。subtask 実行のときは親の
|
||||
@@ -143,34 +162,71 @@ export interface ToolContext {
|
||||
* Plumbed by piece-runner from `piece.name`.
|
||||
*/
|
||||
pieceName?: string;
|
||||
/**
|
||||
* Tool-request mechanism: the current movement's name + its effective
|
||||
* allowed-tool set (shared_tools ∪ allowed_tools ∪ META_TOOLS), so RequestTool
|
||||
* can tell "already available" from "not allowed here", and the passive
|
||||
* blocked-call capture knows which movement to attribute. Plumbed by
|
||||
* piece-runner from the prepared movement.
|
||||
*/
|
||||
movementName?: string;
|
||||
effectiveToolNames?: string[];
|
||||
/**
|
||||
* Full catalog of known tool names (static + meta), injected by the tool
|
||||
* dispatcher only for RequestTool, so it can classify an unknown tool name
|
||||
* (possible hallucination) vs one that exists but isn't allowed here.
|
||||
*/
|
||||
knownToolNames?: string[];
|
||||
/**
|
||||
* Recorder for tool requests (RequestTool active declaration + passive
|
||||
* blocked-call capture). Provided by the worker bound to task/job/space;
|
||||
* piece-runner wraps it to inject piece + movement. Undefined => no-op
|
||||
* (e.g. unit tests, contexts without a repo).
|
||||
*/
|
||||
recordToolRequest?: (req: {
|
||||
toolName: string;
|
||||
reason?: string | null;
|
||||
category: 'requested' | 'blocked' | 'unknown';
|
||||
status?: 'pending' | 'approved' | 'denied' | 'auto_denied';
|
||||
}) => void;
|
||||
/**
|
||||
* Tool-request mechanism: true when this run can pause for inline approval
|
||||
* (a user is reachable — a local task, not a subtask/headless run). When
|
||||
* false, RequestTool records `auto_denied` and the agent proceeds without
|
||||
* the tool (the user finds the request later in the task detail).
|
||||
*/
|
||||
toolApprovalInteractive?: boolean;
|
||||
/**
|
||||
* Set by RequestTool to signal the movement loop to pause (waiting_human)
|
||||
* for inline approval of the named tool. Read + reset by agent-loop.
|
||||
*/
|
||||
pendingToolApproval?: { toolName: string } | null;
|
||||
/** Per-task option: when true, MCP tools are not loaded/dispatched. */
|
||||
mcpDisabled?: boolean;
|
||||
/** Per-task option: when true, skill index is not injected into the system prompt. */
|
||||
skillsDisabled?: boolean;
|
||||
/**
|
||||
* Shared-knowledge notes service. When set, buildSystemPrompt injects a
|
||||
* "## Subscribed Notes" section for inject-mode subscriptions owned by
|
||||
* the job's user. Constructed once in the worker and forwarded here.
|
||||
* Role of the job owner ('admin' | 'user'). Used to gate admin-only tool
|
||||
* actions (e.g. system-scope InstallSkill). Defaults to 'user' when unset
|
||||
* (no-auth mode or unresolved owner).
|
||||
*/
|
||||
notesService?: import('../../notes/notes-service.js').NotesService;
|
||||
userRole?: 'admin' | 'user';
|
||||
/**
|
||||
* Resolved inject config (merged with defaults). Forwarded alongside
|
||||
* notesService so buildSystemPrompt can apply the same budget limits.
|
||||
* Spaces foundation (plan 3): conflict detection for persistent (shared)
|
||||
* workspaces. When `true`, Read/Edit/Write record per-file content versions
|
||||
* in a workspace sidecar ledger, and Write to a file that changed on disk
|
||||
* since the agent last saw it (or was never read) is diverted to a
|
||||
* "(競合コピー N)" copy instead of blind-overwriting. Default `undefined`
|
||||
* (= OFF): Read/Write/Edit behave byte-identically to legacy. Only the
|
||||
* worker sets it `true`, for persistent local-task workspaces.
|
||||
*/
|
||||
notesInjectConfig?: import('../../config.js').NotesInjectConfig;
|
||||
conflictDetection?: boolean;
|
||||
/**
|
||||
* orgIds for the job owner. Required by notesService.listInjectableForConsumer
|
||||
* which calls buildVisibilityWhere. The worker resolves this from
|
||||
* repo.listUserGiteaOrgs at job start. Undefined when auth is disabled
|
||||
* (no-auth mode — treated as empty list).
|
||||
* 競合台帳 dir(計画5 / spec §10.3)。台帳は files ツリーの外に住む:
|
||||
* スペースタスクは `space/{id}/.conflict`、それ以外は `join(workspacePath,'.conflict')`。
|
||||
* 未指定時は後方互換で `join(workspacePath,'.conflict')` にフォールバックする。
|
||||
*/
|
||||
notesUserOrgIds?: string[];
|
||||
/**
|
||||
* Role for the job owner ('admin' | 'user'). Required to construct the
|
||||
* minimal Express.User object passed to buildInjectSection. Defaults to
|
||||
* 'user' when not set.
|
||||
*/
|
||||
notesUserRole?: 'admin' | 'user';
|
||||
conflictDir?: string;
|
||||
}
|
||||
|
||||
export interface MissionBriefValue {
|
||||
@@ -377,10 +433,26 @@ export function truncateToBudget(
|
||||
// --- パスガード ---
|
||||
|
||||
export function resolveAndGuard(workspacePath: string, filePath: string): string {
|
||||
const resolved = path.resolve(workspacePath, filePath);
|
||||
if (!resolved.startsWith(path.resolve(workspacePath) + path.sep) && resolved !== path.resolve(workspacePath)) {
|
||||
const root = path.resolve(workspacePath);
|
||||
const resolved = path.resolve(root, filePath);
|
||||
// 1) 字句的な封じ込めチェック(従来動作)
|
||||
if (!resolved.startsWith(root + path.sep) && resolved !== root) {
|
||||
throw new Error(`Path traversal detected: "${filePath}" is outside workspace`);
|
||||
}
|
||||
// 2) symlink 脱出チェック。ワークスペース root が実在するときだけ行う
|
||||
// (未作成 root を渡すテスト等の挙動を保つため)。
|
||||
if (fs.existsSync(root)) {
|
||||
const realRoot = fs.realpathSync(root);
|
||||
// resolved から上に辿り、実在する最も深い祖先を見つける
|
||||
let probe = resolved;
|
||||
while (!fs.existsSync(probe) && path.dirname(probe) !== probe) {
|
||||
probe = path.dirname(probe);
|
||||
}
|
||||
const realProbe = fs.realpathSync(probe);
|
||||
if (realProbe !== realRoot && !realProbe.startsWith(realRoot + path.sep)) {
|
||||
throw new Error(`Path traversal detected (symlink escape): "${filePath}" resolves outside workspace`);
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
@@ -794,10 +866,21 @@ function executRead(input: Record<string, unknown>, ctx: ToolContext): ToolResul
|
||||
};
|
||||
}
|
||||
|
||||
// 1. ファイルサイズ取得
|
||||
// 1. ファイルサイズ取得 + ディレクトリ拒否
|
||||
let totalBytes = 0;
|
||||
try {
|
||||
totalBytes = fs.statSync(resolved).size;
|
||||
const st = fs.statSync(resolved);
|
||||
// ディレクトリは Read 不可。readFileSync(dir) は EISDIR を投げる。これを
|
||||
// ここで弾かないと、後続の競合検知 recordVersion()(persistent ワークスペース
|
||||
// で ON)が dir を readFileSync して EISDIR を投げ、ジョブごとクラッシュする。
|
||||
// ツールエラー (isError) として返せばエージェントは回復できる。
|
||||
if (st.isDirectory()) {
|
||||
return {
|
||||
output: `Read: "${filePath}" はディレクトリです。ファイルパスを指定してください(一覧は Glob か Bash の \`ls\` を使用)。`,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
totalBytes = st.size;
|
||||
} catch (e) {
|
||||
return { output: `Read error: ${(e as Error).message}`, isError: true };
|
||||
}
|
||||
@@ -811,6 +894,14 @@ function executRead(input: Record<string, unknown>, ctx: ToolContext): ToolResul
|
||||
};
|
||||
}
|
||||
|
||||
// 競合検知 (persistent workspace): Read はファイルを変更しないので、ここで
|
||||
// 現在のディスク版を「エージェントが見た版」として台帳に記録する。後続 Write は
|
||||
// この版を基準に外部変更を検出する。OFF (既定) のときは完全に no-op。
|
||||
if (ctx.conflictDetection) {
|
||||
const relPath = normalizeWorkspaceRelativePath(ctx.workspacePath, resolved);
|
||||
recordVersion(ctx.workspacePath, relPath, conflictDirOf(ctx));
|
||||
}
|
||||
|
||||
const budgetTokens = getToolOutputBudgetTokens(ctx);
|
||||
const budgetBytes = estimateCharsForTokenBudget(budgetTokens);
|
||||
|
||||
@@ -892,6 +983,41 @@ function executeWrite(input: Record<string, unknown>, ctx: ToolContext): ToolRes
|
||||
return { output: (e as Error).message, isError: true };
|
||||
}
|
||||
|
||||
// 競合検知 (persistent workspace): 対象がエージェントの見た版から外部変更
|
||||
// されている / そもそも読まれていない場合は、黙って上書きせず競合コピーへ逃がす。
|
||||
// OFF (既定) のときはこのブロックを完全にスキップし、従来挙動を保つ。
|
||||
if (ctx.conflictDetection) {
|
||||
const relPath = normalizeWorkspaceRelativePath(ctx.workspacePath, resolved);
|
||||
const resolution = resolveWriteTarget(ctx.workspacePath, relPath, content, conflictDirOf(ctx));
|
||||
let writeResolved = resolved;
|
||||
if (resolution.conflict) {
|
||||
try {
|
||||
// 競合コピー名を再ガード (念のためルート内であることを保証)。
|
||||
writeResolved = resolveAndGuard(ctx.workspacePath, resolution.targetRelPath);
|
||||
} catch (e) {
|
||||
return { output: (e as Error).message, isError: true };
|
||||
}
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(writeResolved), { recursive: true });
|
||||
fs.writeFileSync(writeResolved, content, 'utf-8');
|
||||
// 書き込んだパスの版を台帳に記録 (このエージェントの最新版)。
|
||||
recordVersion(ctx.workspacePath, resolution.targetRelPath, conflictDirOf(ctx));
|
||||
if (resolution.conflict) {
|
||||
const name = resolution.targetRelPath.split('/').pop() ?? resolution.targetRelPath;
|
||||
return {
|
||||
output:
|
||||
`⚠️ 競合を検知したため「${name}」として保存しました(元ファイルは保持)。` +
|
||||
` Written ${content.length} bytes to ${resolution.targetRelPath}`,
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
return { output: `Written ${content.length} bytes to ${filePath}`, isError: false };
|
||||
} catch (e) {
|
||||
return { output: `Write error: ${(e as Error).message}`, isError: true };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(resolved), { recursive: true });
|
||||
fs.writeFileSync(resolved, content, 'utf-8');
|
||||
@@ -924,21 +1050,43 @@ function executeEdit(input: Record<string, unknown>, ctx: ToolContext): ToolResu
|
||||
}
|
||||
const updated = original.slice(0, idx) + newString + original.slice(idx + oldString.length);
|
||||
fs.writeFileSync(resolved, updated, 'utf-8');
|
||||
// 競合検知: Edit は in-place 書き換えなので、書き込んだ版を台帳に記録するだけ
|
||||
// (Edit は old_string 一致を要求するので、そもそも盲目上書きの問題は起きない)。
|
||||
if (ctx.conflictDetection) {
|
||||
const relPath = normalizeWorkspaceRelativePath(ctx.workspacePath, resolved);
|
||||
recordVersion(ctx.workspacePath, relPath, conflictDirOf(ctx));
|
||||
}
|
||||
return { output: `Edited ${filePath}: replaced ${oldString.length} chars`, isError: false };
|
||||
} catch (e) {
|
||||
return { output: `Edit error: ${(e as Error).message}`, isError: true };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 実行ログ root を解決する(計画5)。ctx.runtimeDir があればそれ、
|
||||
* 無ければ従来の `workspacePath/logs`(後方互換)。runtime_dir は files jail
|
||||
* の外になりうるため resolveAndGuard を経由させず直接書く。
|
||||
*/
|
||||
export function runtimeLogsDir(ctx: ToolContext): string {
|
||||
return ctx.runtimeDir ?? path.join(ctx.workspacePath, 'logs');
|
||||
}
|
||||
|
||||
/**
|
||||
* 競合台帳 dir を解決する(計画5)。ctx.conflictDir があればそれ、無ければ
|
||||
* 後方互換で `workspacePath/.conflict`(files ツリーの外)。
|
||||
*/
|
||||
function conflictDirOf(ctx: ToolContext): string {
|
||||
return ctx.conflictDir ?? path.join(ctx.workspacePath, '.conflict');
|
||||
}
|
||||
|
||||
function logBashHistory(
|
||||
workspacePath: string,
|
||||
logsDir: string,
|
||||
command: string,
|
||||
isError: boolean,
|
||||
durationMs: number,
|
||||
extra?: { blocked?: boolean; outputBytes?: number },
|
||||
): void {
|
||||
try {
|
||||
const logsDir = path.join(workspacePath, 'logs');
|
||||
fs.mkdirSync(logsDir, { recursive: true });
|
||||
const entry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -971,6 +1119,7 @@ async function executeBash(input: Record<string, unknown>, ctx: ToolContext): Pr
|
||||
let timeoutSec = typeof input['timeout'] === 'number' ? input['timeout'] : 30;
|
||||
timeoutSec = Math.min(timeoutSec, 300);
|
||||
const startedAt = Date.now();
|
||||
const logsDir = runtimeLogsDir(ctx);
|
||||
|
||||
const budgetTokens = getToolOutputBudgetTokens(ctx);
|
||||
const capOutput = (raw: string, label: string): string => {
|
||||
@@ -990,7 +1139,7 @@ async function executeBash(input: Record<string, unknown>, ctx: ToolContext): Pr
|
||||
try {
|
||||
checkBlockedInstallPatterns(command);
|
||||
} catch (e) {
|
||||
logBashHistory(ctx.workspacePath, command, true, Date.now() - startedAt, { blocked: true });
|
||||
logBashHistory(logsDir, command, true, Date.now() - startedAt, { blocked: true });
|
||||
return { output: (e as Error).message, isError: true };
|
||||
}
|
||||
|
||||
@@ -1003,7 +1152,7 @@ async function executeBash(input: Record<string, unknown>, ctx: ToolContext): Pr
|
||||
try {
|
||||
checkAllowedCommand(command, ctx.allowedCommands ?? DEFAULT_ALLOWED_COMMANDS);
|
||||
} catch (e) {
|
||||
logBashHistory(ctx.workspacePath, command, true, Date.now() - startedAt, { blocked: true });
|
||||
logBashHistory(logsDir, command, true, Date.now() - startedAt, { blocked: true });
|
||||
return { output: (e as Error).message, isError: true };
|
||||
}
|
||||
}
|
||||
@@ -1012,7 +1161,7 @@ async function executeBash(input: Record<string, unknown>, ctx: ToolContext): Pr
|
||||
ctx.bashAllowNetwork === true,
|
||||
);
|
||||
const out = result.isError ? result.output : capOutput(result.output, 'stdout');
|
||||
logBashHistory(ctx.workspacePath, command, result.isError, Date.now() - startedAt, {
|
||||
logBashHistory(logsDir, command, result.isError, Date.now() - startedAt, {
|
||||
outputBytes: Buffer.byteLength(out, 'utf-8'),
|
||||
});
|
||||
return { output: out, isError: result.isError };
|
||||
@@ -1023,13 +1172,13 @@ async function executeBash(input: Record<string, unknown>, ctx: ToolContext): Pr
|
||||
if (applyWhitelist) checkAllowedCommand(command, ctx.allowedCommands ?? DEFAULT_ALLOWED_COMMANDS);
|
||||
checkBashPathScope(command, ctx.workspacePath);
|
||||
} catch (e) {
|
||||
logBashHistory(ctx.workspacePath, command, true, Date.now() - startedAt, { blocked: true });
|
||||
logBashHistory(logsDir, command, true, Date.now() - startedAt, { blocked: true });
|
||||
return { output: (e as Error).message, isError: true };
|
||||
}
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
if (ctx.abortSignal?.aborted) {
|
||||
logBashHistory(ctx.workspacePath, command, true, Date.now() - startedAt);
|
||||
logBashHistory(logsDir, command, true, Date.now() - startedAt);
|
||||
resolve({ output: 'Cancelled before bash launch', isError: true });
|
||||
return;
|
||||
}
|
||||
@@ -1046,7 +1195,7 @@ async function executeBash(input: Record<string, unknown>, ctx: ToolContext): Pr
|
||||
(error, stdout, stderr) => {
|
||||
if (!error) {
|
||||
const out = capOutput(stdout, 'stdout');
|
||||
logBashHistory(ctx.workspacePath, command, false, Date.now() - startedAt, {
|
||||
logBashHistory(logsDir, command, false, Date.now() - startedAt, {
|
||||
outputBytes: Buffer.byteLength(out, 'utf-8'),
|
||||
});
|
||||
resolve({ output: out, isError: false });
|
||||
@@ -1067,7 +1216,7 @@ async function executeBash(input: Record<string, unknown>, ctx: ToolContext): Pr
|
||||
details.push(`Signal: ${execError.signal}`);
|
||||
}
|
||||
const output = [...details, msg].filter(Boolean).join('\n');
|
||||
logBashHistory(ctx.workspacePath, command, true, Date.now() - startedAt, {
|
||||
logBashHistory(logsDir, command, true, Date.now() - startedAt, {
|
||||
outputBytes: Buffer.byteLength(output, 'utf-8'),
|
||||
});
|
||||
resolve({ output, isError: true });
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { Repository } from '../../db/repository.js';
|
||||
import { executeTool, setDashboardRepo, TOOL_DEFS } from './dashboard.js';
|
||||
import type { ToolContext } from './core.js';
|
||||
|
||||
function ctx(ownerId: string | null): ToolContext & { ownerId: string | null } {
|
||||
return {
|
||||
workspacePath: '/tmp/dummy',
|
||||
editAllowed: true,
|
||||
ownerId,
|
||||
};
|
||||
}
|
||||
|
||||
describe('UpdateDashboardWidget tool', () => {
|
||||
let tmpDir: string;
|
||||
let repo: Repository;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'dashboard-tool-test-'));
|
||||
repo = new Repository(join(tmpDir, 'test.db'));
|
||||
setDashboardRepo(repo);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
setDashboardRepo(null);
|
||||
});
|
||||
|
||||
it('exposes a TOOL_DEFS entry', () => {
|
||||
expect(TOOL_DEFS.UpdateDashboardWidget).toBeDefined();
|
||||
expect(TOOL_DEFS.UpdateDashboardWidget!.function.name).toBe('UpdateDashboardWidget');
|
||||
});
|
||||
|
||||
it('creates a new widget when slug does not exist', async () => {
|
||||
const result = await executeTool('UpdateDashboardWidget',
|
||||
{ slug: 'news', title: 'News', content: 'first' },
|
||||
ctx('u1'),
|
||||
);
|
||||
expect(result?.isError).toBe(false);
|
||||
const list = await repo.listDashboardWidgets('u1');
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0]!.slug).toBe('news');
|
||||
expect(list[0]!.markdownContent).toBe('first');
|
||||
});
|
||||
|
||||
it('updates existing widget when slug exists', async () => {
|
||||
await repo.createDashboardWidget({ userId: 'u1', slug: 'news', title: 'News', content: 'old' });
|
||||
const result = await executeTool('UpdateDashboardWidget',
|
||||
{ slug: 'news', content: 'new' },
|
||||
ctx('u1'),
|
||||
);
|
||||
expect(result?.isError).toBe(false);
|
||||
const list = await repo.listDashboardWidgets('u1');
|
||||
expect(list[0]!.markdownContent).toBe('new');
|
||||
});
|
||||
|
||||
it('appends when mode=append', async () => {
|
||||
await repo.createDashboardWidget({ userId: 'u1', slug: 'log', title: 'L', content: 'a' });
|
||||
await executeTool('UpdateDashboardWidget',
|
||||
{ slug: 'log', content: 'b', mode: 'append' },
|
||||
ctx('u1'),
|
||||
);
|
||||
const list = await repo.listDashboardWidgets('u1');
|
||||
expect(list[0]!.markdownContent).toBe('a\n\nb');
|
||||
});
|
||||
|
||||
it('rejects new widget without title', async () => {
|
||||
const result = await executeTool('UpdateDashboardWidget',
|
||||
{ slug: 'new-one', content: 'x' },
|
||||
ctx('u1'),
|
||||
);
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toMatch(/title/i);
|
||||
});
|
||||
|
||||
it('rejects invalid slug', async () => {
|
||||
const result = await executeTool('UpdateDashboardWidget',
|
||||
{ slug: 'Bad Slug!', title: 't', content: 'x' },
|
||||
ctx('u1'),
|
||||
);
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toMatch(/slug/i);
|
||||
});
|
||||
|
||||
it('rejects content larger than 64KB', async () => {
|
||||
const big = 'x'.repeat(65 * 1024);
|
||||
const result = await executeTool('UpdateDashboardWidget',
|
||||
{ slug: 'big', title: 'B', content: big },
|
||||
ctx('u1'),
|
||||
);
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toMatch(/size|limit|64/i);
|
||||
});
|
||||
|
||||
it('rejects when ownerId missing', async () => {
|
||||
const result = await executeTool('UpdateDashboardWidget',
|
||||
{ slug: 'x', title: 'X', content: 'y' },
|
||||
ctx(null),
|
||||
);
|
||||
expect(result?.isError).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects when repo not injected', async () => {
|
||||
setDashboardRepo(null);
|
||||
const result = await executeTool('UpdateDashboardWidget',
|
||||
{ slug: 'x', title: 'X', content: 'y' },
|
||||
ctx('u1'),
|
||||
);
|
||||
expect(result?.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,115 +0,0 @@
|
||||
import type { ToolDef } from '../../llm/openai-compat.js';
|
||||
import type { ToolContext, ToolResult } from './core.js';
|
||||
import type { Repository } from '../../db/repository.js';
|
||||
|
||||
const SLUG_PATTERN = /^[a-z0-9-]+$/;
|
||||
const MAX_SLUG_LEN = 32;
|
||||
const MAX_TITLE_LEN = 64;
|
||||
const MAX_CONTENT_BYTES = 64 * 1024;
|
||||
|
||||
let _repo: Repository | null = null;
|
||||
|
||||
export function setDashboardRepo(repo: Repository | null): void {
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
const UPDATE_DASHBOARD_WIDGET_DEF: ToolDef = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'UpdateDashboardWidget',
|
||||
description: 'ユーザーの個人ダッシュボード Markdown widget を upsert する(既存 slug は更新、未存在は新規作成)。詳細は ReadToolDoc({ name: "UpdateDashboardWidget" })。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
slug: {
|
||||
type: 'string',
|
||||
description: 'Widget の安定 ID。kebab-case、a-z 0-9 ハイフンのみ、32 文字以内(例: memo, news, todo)',
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
description: 'Markdown 本文。64KB まで',
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
description: '表示タイトル。新規 slug では必須、既存 slug では無視',
|
||||
},
|
||||
mode: {
|
||||
type: 'string',
|
||||
enum: ['replace', 'append'],
|
||||
description: 'replace (default) | append (既存末尾に "\\n\\n" 区切りで追記)',
|
||||
},
|
||||
},
|
||||
required: ['slug', 'content'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const TOOL_DEFS: Record<string, ToolDef> = {
|
||||
UpdateDashboardWidget: UPDATE_DASHBOARD_WIDGET_DEF,
|
||||
};
|
||||
|
||||
type ExecuteCtx = ToolContext & { ownerId?: string | null };
|
||||
|
||||
export async function executeTool(
|
||||
name: string,
|
||||
input: Record<string, unknown>,
|
||||
ctx: ToolContext,
|
||||
): Promise<ToolResult | null> {
|
||||
if (name !== 'UpdateDashboardWidget') return null;
|
||||
return executeUpdateDashboardWidget(input, ctx as ExecuteCtx);
|
||||
}
|
||||
|
||||
async function executeUpdateDashboardWidget(
|
||||
input: Record<string, unknown>,
|
||||
ctx: ExecuteCtx,
|
||||
): Promise<ToolResult> {
|
||||
if (!_repo) {
|
||||
return { output: 'Dashboard repo is not initialized', isError: true };
|
||||
}
|
||||
const userId = ctx.ownerId;
|
||||
if (!userId) {
|
||||
return { output: 'ownerId not present in tool context — UpdateDashboardWidget requires an authenticated task owner', isError: true };
|
||||
}
|
||||
const slug = input['slug'];
|
||||
const content = input['content'];
|
||||
const title = input['title'];
|
||||
const mode = input['mode'];
|
||||
|
||||
if (typeof slug !== 'string' || !SLUG_PATTERN.test(slug) || slug.length > MAX_SLUG_LEN) {
|
||||
return { output: `invalid slug: must match ${SLUG_PATTERN} and be <= ${MAX_SLUG_LEN} chars`, isError: true };
|
||||
}
|
||||
if (typeof content !== 'string') {
|
||||
return { output: 'content must be string', isError: true };
|
||||
}
|
||||
if (Buffer.byteLength(content, 'utf8') > MAX_CONTENT_BYTES) {
|
||||
return { output: `content exceeds size limit (${MAX_CONTENT_BYTES} bytes / 64KB)`, isError: true };
|
||||
}
|
||||
if (mode !== undefined && mode !== 'replace' && mode !== 'append') {
|
||||
return { output: 'mode must be "replace" or "append"', isError: true };
|
||||
}
|
||||
if (title !== undefined && (typeof title !== 'string' || title.length === 0 || title.length > MAX_TITLE_LEN)) {
|
||||
return { output: `title must be a non-empty string up to ${MAX_TITLE_LEN} chars`, isError: true };
|
||||
}
|
||||
|
||||
const existing = (await _repo.listDashboardWidgets(userId)).find(w => w.slug === slug);
|
||||
if (!existing && (typeof title !== 'string' || title.length === 0)) {
|
||||
return { output: `slug "${slug}" does not exist yet — title is required when creating a new widget`, isError: true };
|
||||
}
|
||||
|
||||
try {
|
||||
const widget = await _repo.upsertDashboardWidgetBySlug({
|
||||
userId,
|
||||
slug,
|
||||
title: typeof title === 'string' ? title : undefined,
|
||||
content,
|
||||
mode: mode === 'append' ? 'append' : 'replace',
|
||||
});
|
||||
const verb = existing ? (mode === 'append' ? 'appended to' : 'updated') : 'created';
|
||||
return {
|
||||
output: `Widget "${slug}" ${verb} (id=${widget.id}, ${Buffer.byteLength(widget.markdownContent, 'utf8')} bytes)`,
|
||||
isError: false,
|
||||
};
|
||||
} catch (e) {
|
||||
return { output: `Failed to update widget: ${(e as Error).message}`, isError: true };
|
||||
}
|
||||
}
|
||||
+15
-10
@@ -28,17 +28,15 @@ const DOCS_DIR = path.join(REPO_ROOT, 'docs', 'tools');
|
||||
// 関連ツールが同じ doc を参照できるようエイリアスを定義
|
||||
// キー・値ともに小文字
|
||||
const TOOL_DOC_ALIASES: Record<string, string> = {
|
||||
// calendar.md にまとめる
|
||||
addcalendarevent: 'calendar',
|
||||
listcalendarevents: 'calendar',
|
||||
// updateuseragents.md にまとめる
|
||||
readuseragents: 'updateuseragents',
|
||||
// checklist.md にまとめる
|
||||
createchecklist: 'checklist',
|
||||
checkitem: 'checklist',
|
||||
getchecklist: 'checklist',
|
||||
// searchknowledge.md にまとめる
|
||||
listnamespaces: 'searchknowledge',
|
||||
listdocuments: 'searchknowledge',
|
||||
ingestdocument: 'searchknowledge',
|
||||
ingeststatus: 'searchknowledge',
|
||||
// x.ts ツールをまとめる
|
||||
xuserposts: 'xsearch',
|
||||
xpostdetail: 'xsearch',
|
||||
@@ -58,6 +56,10 @@ const TOOL_DOC_ALIASES: Record<string, string> = {
|
||||
pdftoimages: 'office',
|
||||
splitexcelsheets: 'office',
|
||||
splitdocxsections: 'office',
|
||||
// skills.ts をまとめる (skills.md)
|
||||
installskill: 'skills',
|
||||
readskill: 'skills',
|
||||
listskills: 'skills',
|
||||
// pieces.ts をまとめる
|
||||
getpiece: 'listpieces',
|
||||
createpiece: 'listpieces',
|
||||
@@ -84,10 +86,13 @@ const TOOL_DOC_ALIASES: Record<string, string> = {
|
||||
addslide: 'slide',
|
||||
buildpptx: 'slide',
|
||||
resetslides: 'slide',
|
||||
// notes.ts をまとめる
|
||||
searchnotes: 'notes',
|
||||
readnote: 'notes',
|
||||
writenote: 'notes',
|
||||
// ワークスペース・アプリの作り方 (workspace-apps.md)。専用ツールは無いが、
|
||||
// 「ワークスペース・アプリを作って」依頼時にエージェントが仕様を引けるよう alias。
|
||||
workspaceapp: 'workspace-apps',
|
||||
workspaceapps: 'workspace-apps',
|
||||
createapp: 'workspace-apps',
|
||||
buildapp: 'workspace-apps',
|
||||
appbridge: 'workspace-apps',
|
||||
};
|
||||
|
||||
const READ_TOOL_DOC_DEF: ToolDef = {
|
||||
@@ -102,7 +107,7 @@ const READ_TOOL_DOC_DEF: ToolDef = {
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
description: '読みたいツール名(例: "BrowseWeb", "SearchKnowledge")',
|
||||
description: '読みたいツール名(例: "BrowseWeb", "WebFetch")',
|
||||
},
|
||||
},
|
||||
required: ['name'],
|
||||
|
||||
+74
-58
@@ -7,6 +7,7 @@ import {
|
||||
ALL_TOOL_DEFS,
|
||||
getToolDefs as getCoreToolDefs,
|
||||
executeCoreTools,
|
||||
runtimeLogsDir,
|
||||
} from './core.js';
|
||||
import { saveRawData, logRawDownload, RAW_SAVE_TOOLS, RAW_LOG_ONLY_TOOLS } from './raw-save.js';
|
||||
import { saveStructuredBlocks } from './structured-blocks.js';
|
||||
@@ -58,7 +59,6 @@ let _piecesModule: ToolModule | null | undefined = undefined;
|
||||
let _amazonModule: ToolModule | null | undefined = undefined;
|
||||
let _speechModule: ToolModule | null | undefined = undefined;
|
||||
let _checklistModule: ToolModule | null | undefined = undefined;
|
||||
let _knowledgeModule: ToolModule | null | undefined = undefined;
|
||||
let _msLearnModule: ToolModule | null | undefined = undefined;
|
||||
let _slideModule: ToolModule | null | undefined = undefined;
|
||||
let _userFolderModule: ToolModule | null | undefined = undefined;
|
||||
@@ -175,14 +175,6 @@ async function getChecklistModule(): Promise<ToolModule | null> {
|
||||
return _checklistModule;
|
||||
}
|
||||
|
||||
async function getKnowledgeModule(): Promise<ToolModule | null> {
|
||||
if (_knowledgeModule === undefined) {
|
||||
_knowledgeModule = await tryLoadModule('./knowledge.js');
|
||||
if (_knowledgeModule) logger.debug('[tools/index] knowledge module loaded');
|
||||
}
|
||||
return _knowledgeModule;
|
||||
}
|
||||
|
||||
async function getSlideModule(): Promise<ToolModule | null> {
|
||||
if (_slideModule === undefined) {
|
||||
_slideModule = await tryLoadModule('./slide.js');
|
||||
@@ -217,6 +209,24 @@ async function getMissionModule(): Promise<ToolModule | null> {
|
||||
return _missionModule;
|
||||
}
|
||||
|
||||
let _toolRequestModule: ToolModule | null | undefined;
|
||||
async function getToolRequestModule(): Promise<ToolModule | null> {
|
||||
if (_toolRequestModule === undefined) {
|
||||
_toolRequestModule = await tryLoadModule('./tool-request.js');
|
||||
if (_toolRequestModule) logger.debug('[tools/index] tool-request module loaded');
|
||||
}
|
||||
return _toolRequestModule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot of all known static tool names, refreshed every getToolDefs() call
|
||||
* (which runs at movement start, before any tool executes). Injected into
|
||||
* RequestTool's ctx so it can classify an unknown tool name vs one that exists
|
||||
* but isn't allowed in the current movement. MCP names are handled separately
|
||||
* by RequestTool via the `mcp__` prefix, so they need not be in this set.
|
||||
*/
|
||||
let _allKnownToolNames: string[] = [];
|
||||
|
||||
async function getUserFolderModule(): Promise<ToolModule | null> {
|
||||
if (_userFolderModule === undefined) {
|
||||
_userFolderModule = await tryLoadModule('./user-folder.js');
|
||||
@@ -261,22 +271,13 @@ async function getSshConsoleModule(): Promise<ToolModule | null> {
|
||||
return _sshConsoleModule;
|
||||
}
|
||||
|
||||
let _notesModule: ToolModule | null | undefined;
|
||||
async function getNotesModule(): Promise<ToolModule | null> {
|
||||
if (_notesModule === undefined) {
|
||||
_notesModule = await tryLoadModule('./notes.js');
|
||||
if (_notesModule) logger.debug('[tools/index] notes module loaded');
|
||||
let _calendarModule: ToolModule | null | undefined = undefined;
|
||||
async function getCalendarModule(): Promise<ToolModule | null> {
|
||||
if (_calendarModule === undefined) {
|
||||
_calendarModule = await tryLoadModule('./calendar.js');
|
||||
if (_calendarModule) logger.debug('[tools/index] calendar module loaded');
|
||||
}
|
||||
return _notesModule;
|
||||
}
|
||||
|
||||
let _dashboardModule: ToolModule | null | undefined = undefined;
|
||||
async function getDashboardModule(): Promise<ToolModule | null> {
|
||||
if (_dashboardModule === undefined) {
|
||||
_dashboardModule = await tryLoadModule('./dashboard.js');
|
||||
if (_dashboardModule) logger.debug('[tools/index] dashboard module loaded');
|
||||
}
|
||||
return _dashboardModule;
|
||||
return _calendarModule;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -286,7 +287,7 @@ async function getDashboardModule(): Promise<ToolModule | null> {
|
||||
export async function getToolDefs(
|
||||
allowedTools: string[],
|
||||
editAllowed: boolean,
|
||||
options?: { vlmEnabled?: boolean; ownerId?: string | null; mcpDisabled?: boolean },
|
||||
options?: { vlmEnabled?: boolean; ownerId?: string | null; mcpDisabled?: boolean; spaceId?: string | null },
|
||||
): Promise<ToolDef[]> {
|
||||
// 全ツール定義を収集
|
||||
const allDefs: Record<string, ToolDef> = { ...ALL_TOOL_DEFS };
|
||||
@@ -333,9 +334,6 @@ export async function getToolDefs(
|
||||
const checklistMod = await getChecklistModule();
|
||||
if (checklistMod) Object.assign(allDefs, checklistMod.TOOL_DEFS);
|
||||
|
||||
const knowledgeMod = await getKnowledgeModule();
|
||||
if (knowledgeMod) Object.assign(allDefs, knowledgeMod.TOOL_DEFS);
|
||||
|
||||
const msLearnMod = await getMsLearnModule();
|
||||
if (msLearnMod) Object.assign(allDefs, msLearnMod.TOOL_DEFS);
|
||||
|
||||
@@ -363,15 +361,30 @@ export async function getToolDefs(
|
||||
const sshConsoleMod = await getSshConsoleModule();
|
||||
if (sshConsoleMod) Object.assign(allDefs, sshConsoleMod.TOOL_DEFS);
|
||||
|
||||
const notesMod = await getNotesModule();
|
||||
if (notesMod) Object.assign(allDefs, notesMod.TOOL_DEFS);
|
||||
|
||||
const dashboardMod = await getDashboardModule();
|
||||
if (dashboardMod) Object.assign(allDefs, dashboardMod.TOOL_DEFS);
|
||||
const calendarMod = await getCalendarModule();
|
||||
if (calendarMod) Object.assign(allDefs, calendarMod.TOOL_DEFS);
|
||||
|
||||
const { TOOL_DEFS: skillToolDefs } = await import('./skills.js');
|
||||
Object.assign(allDefs, skillToolDefs);
|
||||
|
||||
const toolRequestMod = await getToolRequestModule();
|
||||
if (toolRequestMod) Object.assign(allDefs, toolRequestMod.TOOL_DEFS);
|
||||
|
||||
// Refresh the known-tool snapshot for RequestTool classification.
|
||||
//
|
||||
// CONCURRENCY INVARIANT (must hold): this is taken HERE — after every static
|
||||
// module is merged into `allDefs`, but BEFORE the per-job MCP defs are
|
||||
// appended below. The static catalog does NOT depend on movement / ownerId /
|
||||
// spaceId / mcpDisabled, so every concurrent getToolDefs() writer produces an
|
||||
// IDENTICAL array. That is why this module-global is safe to read from a
|
||||
// different movement's RequestTool dispatch. If you ever make a STATIC tool's
|
||||
// presence in `allDefs` conditional (feature flag, per-space, etc.), this
|
||||
// snapshot becomes movement-dependent and the global must move into ctx
|
||||
// instead — otherwise concurrent movements would misclassify each other's
|
||||
// tools as `unknown`.
|
||||
_allKnownToolNames = Object.keys(allDefs);
|
||||
|
||||
// メタツール: piece の allowed_tools に書かれていなくても常に利用可能
|
||||
// - ReadToolDoc: 全ツールのドキュメント参照
|
||||
// - CreateChecklist / CheckItem / GetChecklist: 進捗管理 (複数ステップタスクで使用)
|
||||
@@ -383,7 +396,8 @@ export async function getToolDefs(
|
||||
// - Brainstorm: 着手前 or 行き詰まり時の多アプローチ比較 (issue #247)
|
||||
// - ReadAppDoc / ListAppDocs / GetMyOrchestratorState: Help アシスタント用 (#help piece)
|
||||
// ただし他の piece からも参照できるようにメタ扱い
|
||||
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', 'ReadUserTemplate', 'RenderUserTemplate', 'WriteUserScript', 'WriteUserTemplate', 'Brainstorm', 'ReadAppDoc', 'ListAppDocs', 'GetMyOrchestratorState', 'ReadSkill', 'ListSkills', 'InstallSkill', 'RequestTool'];
|
||||
const effectiveAllowed = [...allowedTools];
|
||||
for (const meta of META_TOOLS) {
|
||||
if (!effectiveAllowed.includes(meta) && meta in allDefs) {
|
||||
@@ -399,9 +413,12 @@ export async function getToolDefs(
|
||||
})
|
||||
.map((name) => allDefs[name]!);
|
||||
|
||||
// Per-space isolation: the MCP server set is scoped to the job's space.
|
||||
// options.spaceId === undefined → aggregator falls back to the legacy
|
||||
// NULL-space set (non-space jobs keep prior behaviour).
|
||||
const mcpDefs =
|
||||
_mcpAggregator && options?.ownerId && !options?.mcpDisabled
|
||||
? await _mcpAggregator.getToolDefs(options.ownerId, allowedTools)
|
||||
? await _mcpAggregator.getToolDefs(options.ownerId, allowedTools, options.spaceId)
|
||||
: [];
|
||||
|
||||
return [...staticDefs, ...mcpDefs];
|
||||
@@ -437,6 +454,9 @@ async function executeToolInner(
|
||||
jobId: ctxWithMcp.jobId ?? '',
|
||||
config: ctxWithMcp.mcpConfig,
|
||||
quotaState: ctxWithMcp.mcpQuotaState,
|
||||
// Per-space isolation guard (defense in depth). undefined = legacy
|
||||
// NULL-space job → only NULL-space servers are callable.
|
||||
spaceId: ctx.spaceId ?? null,
|
||||
});
|
||||
if (result === null) {
|
||||
return { output: `MCP ツール dispatch が ${name} に対して null を返しました`, isError: true };
|
||||
@@ -546,13 +566,6 @@ async function executeToolInner(
|
||||
if (checklistResult !== null) return checklistResult;
|
||||
}
|
||||
|
||||
// knowledge ツール
|
||||
const knowledgeMod = await getKnowledgeModule();
|
||||
if (knowledgeMod) {
|
||||
const knowledgeResult = await knowledgeMod.executeTool(name, input, ctx);
|
||||
if (knowledgeResult !== null) return knowledgeResult;
|
||||
}
|
||||
|
||||
// ms-learn ツール
|
||||
const msLearnMod = await getMsLearnModule();
|
||||
if (msLearnMod) {
|
||||
@@ -581,6 +594,15 @@ async function executeToolInner(
|
||||
if (missionResult !== null) return missionResult;
|
||||
}
|
||||
|
||||
// RequestTool: 足りないツールの要求を記録。分類用に既知ツール一覧を注入する
|
||||
// (getToolDefs が movement 開始時に更新した最新スナップショット)。
|
||||
const toolRequestMod = await getToolRequestModule();
|
||||
if (toolRequestMod) {
|
||||
if (name === 'RequestTool') ctx.knownToolNames = _allKnownToolNames;
|
||||
const toolRequestResult = await toolRequestMod.executeTool(name, input, ctx);
|
||||
if (toolRequestResult !== null) return toolRequestResult;
|
||||
}
|
||||
|
||||
// user-folder ツール (ListUserAssets / RunUserScript)
|
||||
const userFolderMod = await getUserFolderModule();
|
||||
if (userFolderMod) {
|
||||
@@ -616,18 +638,11 @@ async function executeToolInner(
|
||||
if (sshConsoleResult !== null) return sshConsoleResult;
|
||||
}
|
||||
|
||||
// notes ツール (SearchNotes / ReadNote / WriteNote)
|
||||
const notesMod = await getNotesModule();
|
||||
if (notesMod) {
|
||||
const notesResult = await notesMod.executeTool(name, input, ctx);
|
||||
if (notesResult !== null) return notesResult;
|
||||
}
|
||||
|
||||
// dashboard ツール (UpdateDashboardWidget)
|
||||
const dashboardMod = await getDashboardModule();
|
||||
if (dashboardMod) {
|
||||
const dashboardResult = await dashboardMod.executeTool(name, input, ctx);
|
||||
if (dashboardResult !== null) return dashboardResult;
|
||||
// calendar ツール (AddCalendarEvent / ListCalendarEvents)
|
||||
const calendarMod = await getCalendarModule();
|
||||
if (calendarMod) {
|
||||
const calendarResult = await calendarMod.executeTool(name, input, ctx);
|
||||
if (calendarResult !== null) return calendarResult;
|
||||
}
|
||||
|
||||
// skills ツール (ReadSkill)
|
||||
@@ -651,19 +666,20 @@ export async function executeTool(
|
||||
|
||||
const result = await executeToolInner(name, input, ctx);
|
||||
|
||||
// raw保存ラッパー: 成功した対象ツールの結果を logs/raw/ に保存
|
||||
// raw保存ラッパー: 成功した対象ツールの結果を {logsRoot}/raw/ に保存(計画5: runtimeDir 解決)
|
||||
if (ctx.workspacePath && !result.isError) {
|
||||
const logsRoot = runtimeLogsDir(ctx);
|
||||
if (RAW_SAVE_TOOLS.has(name)) {
|
||||
saveRawData(ctx.workspacePath, name, result.output);
|
||||
saveRawData(logsRoot, name, result.output);
|
||||
} else if (RAW_LOG_ONLY_TOOLS.has(name)) {
|
||||
const pathMatch = result.output.match(/-> (.+?) \(/);
|
||||
if (pathMatch?.[1]) {
|
||||
logRawDownload(ctx.workspacePath, name, pathMatch[1], result.output.length);
|
||||
logRawDownload(logsRoot, name, pathMatch[1], result.output.length);
|
||||
}
|
||||
}
|
||||
// 構造化データ保存: structuredBlocks があれば logs/structured/ に保存
|
||||
// 構造化データ保存: structuredBlocks があれば {logsRoot}/structured/ に保存
|
||||
if (result.structuredBlocks?.length) {
|
||||
saveStructuredBlocks(ctx.workspacePath, result.structuredBlocks);
|
||||
saveStructuredBlocks(logsRoot, result.structuredBlocks);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { TOOL_DEFS, executeTool } from './knowledge.js';
|
||||
import type { ToolContext } from './core.js';
|
||||
|
||||
// mock fetch
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch as any;
|
||||
|
||||
function makeCtx(workspacePath: string): ToolContext {
|
||||
return {
|
||||
workspacePath,
|
||||
editAllowed: false,
|
||||
toolsConfig: {
|
||||
knowledgeServiceUrl: 'http://dks:8100',
|
||||
knowledgeNamespaces: {
|
||||
'test-ns': { apiKey: 'sk-test-key' },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('knowledge tools', () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
describe('TOOL_DEFS', () => {
|
||||
it('exports all knowledge tool definitions', () => {
|
||||
expect(TOOL_DEFS).toHaveProperty('IngestDocument');
|
||||
expect(TOOL_DEFS).toHaveProperty('IngestStatus');
|
||||
expect(TOOL_DEFS).toHaveProperty('SearchKnowledge');
|
||||
expect(TOOL_DEFS).toHaveProperty('ListNamespaces');
|
||||
expect(TOOL_DEFS).toHaveProperty('ListDocuments');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SearchKnowledge', () => {
|
||||
it('calls DKS search API and formats response with local image paths', async () => {
|
||||
// First call: search API, second call: image download
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
sections: [
|
||||
{ title: 'Section 1', content: 'テスト内容', pages: [1, 2], doc_id: 'doc-1', doc_name: 'manual.pdf' },
|
||||
],
|
||||
page_image_urls: ['/namespaces/test-ns/documents/doc-1/pages/page_001.png'],
|
||||
system_status: { ingest_active: false, ingest_jobs: 0, message: '' },
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
arrayBuffer: async () => new ArrayBuffer(8),
|
||||
});
|
||||
|
||||
const result = await executeTool('SearchKnowledge', {
|
||||
namespace: 'test-ns',
|
||||
query: 'セットアップ方法',
|
||||
}, makeCtx('/tmp/knowledge-test'));
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.isError).toBe(false);
|
||||
expect(result!.output).toContain('Section 1');
|
||||
expect(result!.output).toContain('input/knowledge/test-ns/page_001.png');
|
||||
expect(result!.output).toContain('ReadImage');
|
||||
});
|
||||
|
||||
it('reports image download errors without failing the search', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
sections: [
|
||||
{ title: 'Section 1', content: 'テスト内容', pages: [1], doc_id: 'doc-1', doc_name: 'manual.pdf' },
|
||||
],
|
||||
page_image_urls: ['/namespaces/test-ns/documents/doc-1/pages/page_001.png'],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: false, status: 404 });
|
||||
|
||||
const result = await executeTool('SearchKnowledge', {
|
||||
namespace: 'test-ns',
|
||||
query: 'test',
|
||||
}, makeCtx('/tmp/knowledge-test'));
|
||||
|
||||
expect(result!.isError).toBe(false);
|
||||
expect(result!.output).toContain('Section 1');
|
||||
expect(result!.output).toContain('画像ダウンロードエラー');
|
||||
});
|
||||
|
||||
it('returns error for unknown namespace', async () => {
|
||||
const result = await executeTool('SearchKnowledge', {
|
||||
namespace: 'unknown-ns',
|
||||
query: 'test',
|
||||
}, makeCtx('/tmp/ws'));
|
||||
|
||||
expect(result!.isError).toBe(true);
|
||||
expect(result!.output).toContain('not configured');
|
||||
});
|
||||
});
|
||||
|
||||
describe('IngestStatus', () => {
|
||||
it('returns job status', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
job_id: 'ingest-123',
|
||||
status: 'processing',
|
||||
document_name: 'manual.pdf',
|
||||
progress: { total_pages: 45, text_extracted: 45, vlm_completed: 20, tree_built: false },
|
||||
attempt: 1,
|
||||
error: null,
|
||||
completed_at: null,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await executeTool('IngestStatus', {
|
||||
namespace: 'test-ns',
|
||||
job_id: 'ingest-123',
|
||||
}, makeCtx('/tmp/ws'));
|
||||
|
||||
expect(result!.isError).toBe(false);
|
||||
expect(result!.output).toContain('ingest-123');
|
||||
expect(result!.output).toContain('20/45');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ListDocuments', () => {
|
||||
it('returns document list', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
documents: [
|
||||
{ id: 'doc-1', name: 'manual.pdf', namespace: 'test-ns', page_count: 10, created_at: '2026-01-01' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await executeTool('ListDocuments', {
|
||||
namespace: 'test-ns',
|
||||
}, makeCtx('/tmp/ws'));
|
||||
|
||||
expect(result!.isError).toBe(false);
|
||||
expect(result!.output).toContain('manual.pdf');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ListNamespaces', () => {
|
||||
it('returns configured namespaces', async () => {
|
||||
const result = await executeTool('ListNamespaces', {}, makeCtx('/tmp/ws'));
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.isError).toBe(false);
|
||||
expect(result!.output).toContain('test-ns');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for unknown tool', async () => {
|
||||
const result = await executeTool('UnknownTool', {}, makeCtx('/tmp/ws'));
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
describe('with missing toolsConfig', () => {
|
||||
it('returns error when knowledge config is not set', async () => {
|
||||
const ctx: ToolContext = { workspacePath: '/tmp/ws', editAllowed: false };
|
||||
const result = await executeTool('SearchKnowledge', {
|
||||
namespace: 'test-ns',
|
||||
query: 'test',
|
||||
}, ctx);
|
||||
|
||||
expect(result!.isError).toBe(true);
|
||||
expect(result!.output).toContain('not configured');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,477 +0,0 @@
|
||||
// knowledge.ts — DKS (Document Knowledge Service) client tools
|
||||
import { readFileSync, mkdirSync, writeFileSync, appendFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { ToolDef } from '../../llm/openai-compat.js';
|
||||
import type { ToolContext, ToolResult } from './core.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import { generateRawFilename } from './raw-save.js';
|
||||
|
||||
// --- Config access ---
|
||||
|
||||
interface KnowledgeNamespaceConfig {
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
function getServiceUrl(ctx: ToolContext): string | null {
|
||||
return ctx.toolsConfig?.knowledgeServiceUrl ?? null;
|
||||
}
|
||||
|
||||
function getNamespaces(ctx: ToolContext): Record<string, KnowledgeNamespaceConfig> | null {
|
||||
return ctx.toolsConfig?.knowledgeNamespaces ?? null;
|
||||
}
|
||||
|
||||
function getApiKey(ctx: ToolContext, namespace: string): string | null {
|
||||
return ctx.toolsConfig?.knowledgeNamespaces?.[namespace]?.apiKey ?? null;
|
||||
}
|
||||
|
||||
// --- Fetch helper ---
|
||||
|
||||
async function dksFetch(
|
||||
serviceUrl: string,
|
||||
path: string,
|
||||
apiKey: string,
|
||||
options: RequestInit & { timeoutMs?: number } = {},
|
||||
): Promise<Response> {
|
||||
const url = `${serviceUrl.replace(/\/+$/, '')}${path}`;
|
||||
const timeoutMs = options.timeoutMs ?? 10000;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const { timeoutMs: _, ...fetchOptions } = options;
|
||||
const headers: Record<string, string> = {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
...(fetchOptions.headers as Record<string, string> || {}),
|
||||
};
|
||||
|
||||
return await fetch(url, {
|
||||
...fetchOptions,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
// --- History logging ---
|
||||
|
||||
interface KnowledgeHistoryRecord {
|
||||
timestamp: string;
|
||||
tool: string;
|
||||
namespace?: string;
|
||||
query?: string;
|
||||
sectionsFound?: number;
|
||||
imagesDownloaded?: number;
|
||||
error?: string;
|
||||
durationMs?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function appendKnowledgeHistory(ctx: ToolContext, record: KnowledgeHistoryRecord): void {
|
||||
try {
|
||||
const logsDir = join(ctx.workspacePath, 'logs');
|
||||
const historyPath = join(logsDir, 'knowledge-history.jsonl');
|
||||
mkdirSync(logsDir, { recursive: true });
|
||||
appendFileSync(historyPath, `${JSON.stringify(record)}\n`, 'utf-8');
|
||||
} catch (e) {
|
||||
logger.warn(`[knowledge] failed to write history: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Raw response save helper ---
|
||||
|
||||
function saveRawResponse(ctx: ToolContext, toolName: string, data: unknown): void {
|
||||
try {
|
||||
const rawDir = join(ctx.workspacePath, 'logs', 'raw');
|
||||
mkdirSync(rawDir, { recursive: true });
|
||||
const filename = generateRawFilename(toolName, '.json');
|
||||
writeFileSync(join(rawDir, filename), JSON.stringify(data, null, 2), 'utf-8');
|
||||
|
||||
const indexPath = join(ctx.workspacePath, 'logs', 'rawdata-history.jsonl');
|
||||
const content = JSON.stringify(data);
|
||||
appendFileSync(indexPath, JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
tool: toolName,
|
||||
filename,
|
||||
bytes: Buffer.byteLength(content, 'utf-8'),
|
||||
}) + '\n', 'utf-8');
|
||||
} catch (e) {
|
||||
logger.warn(`[knowledge] failed to save raw response: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Page image download helper ---
|
||||
|
||||
async function downloadPageImages(
|
||||
serviceUrl: string,
|
||||
apiKey: string,
|
||||
imageUrls: string[],
|
||||
ctx: ToolContext,
|
||||
namespace: string,
|
||||
): Promise<{ localPaths: string[]; errors: string[] }> {
|
||||
const localPaths: string[] = [];
|
||||
const errors: string[] = [];
|
||||
if (imageUrls.length === 0) return { localPaths, errors };
|
||||
|
||||
const saveDir = join(ctx.workspacePath, 'input', 'knowledge', namespace);
|
||||
mkdirSync(saveDir, { recursive: true });
|
||||
|
||||
for (const relUrl of imageUrls) {
|
||||
const fullUrl = `${serviceUrl.replace(/\/+$/, '')}${relUrl}`;
|
||||
try {
|
||||
const resp = await dksFetch(serviceUrl, relUrl, apiKey, { timeoutMs: 15000 });
|
||||
if (!resp.ok) {
|
||||
errors.push(`${relUrl}: HTTP ${resp.status}`);
|
||||
continue;
|
||||
}
|
||||
const buffer = Buffer.from(await resp.arrayBuffer());
|
||||
// Extract filename from URL path (e.g. /pages/abc123.png -> abc123.png)
|
||||
const urlPath = relUrl.split('/').pop() || `page-${Date.now()}.png`;
|
||||
const localPath = join(saveDir, urlPath);
|
||||
writeFileSync(localPath, buffer);
|
||||
localPaths.push(`input/knowledge/${namespace}/${urlPath}`);
|
||||
} catch (e) {
|
||||
const msg = (e as Error).name === 'AbortError' ? 'timeout' : (e as Error).message;
|
||||
errors.push(`${fullUrl}: ${msg}`);
|
||||
}
|
||||
}
|
||||
return { localPaths, errors };
|
||||
}
|
||||
|
||||
// --- Tool Definitions ---
|
||||
|
||||
const INGEST_DOCUMENT_DEF: ToolDef = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'IngestDocument',
|
||||
description: 'ドキュメントをナレッジベースに取り込む(非同期)。PDF, Word, PowerPoint, Excel, 画像, CSV/TSV に対応。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
namespace: { type: 'string', description: '対象ネームスペース' },
|
||||
file_path: { type: 'string', description: 'ワークスペース内のファイルパス' },
|
||||
},
|
||||
required: ['namespace', 'file_path'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const INGEST_STATUS_DEF: ToolDef = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'IngestStatus',
|
||||
description: '取込ジョブの進捗状況を確認する。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
namespace: { type: 'string', description: '対象ネームスペース' },
|
||||
job_id: { type: 'string', description: 'IngestDocument で返却されたジョブID' },
|
||||
},
|
||||
required: ['namespace', 'job_id'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const SEARCH_KNOWLEDGE_DEF: ToolDef = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'SearchKnowledge',
|
||||
description: 'DKS(社内ナレッジ)を自然言語で検索する。関連セクション(テキスト)+ ページ画像が返り、画像は input/knowledge/{ns}/ に自動保存され ReadImage で閲覧可能。詳細は ReadToolDoc({ name: "SearchKnowledge" })。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
namespace: { type: 'string', description: '検索対象ネームスペース' },
|
||||
query: { type: 'string', description: '検索クエリ(自然言語)' },
|
||||
},
|
||||
required: ['namespace', 'query'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const LIST_NAMESPACES_DEF: ToolDef = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'ListNamespaces',
|
||||
description: '利用可能なナレッジベースのネームスペース一覧を表示する。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const LIST_DOCUMENTS_DEF: ToolDef = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'ListDocuments',
|
||||
description: 'ネームスペース内の文書一覧を表示する。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
namespace: { type: 'string', description: '対象ネームスペース' },
|
||||
},
|
||||
required: ['namespace'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const TOOL_DEFS: Record<string, ToolDef> = {
|
||||
IngestDocument: INGEST_DOCUMENT_DEF,
|
||||
IngestStatus: INGEST_STATUS_DEF,
|
||||
SearchKnowledge: SEARCH_KNOWLEDGE_DEF,
|
||||
ListNamespaces: LIST_NAMESPACES_DEF,
|
||||
ListDocuments: LIST_DOCUMENTS_DEF,
|
||||
};
|
||||
|
||||
// --- Tool Execution ---
|
||||
|
||||
export async function executeTool(
|
||||
name: string,
|
||||
input: Record<string, unknown>,
|
||||
ctx: ToolContext,
|
||||
): Promise<ToolResult | null> {
|
||||
if (name === 'IngestDocument') return executeIngestDocument(input, ctx);
|
||||
if (name === 'IngestStatus') return executeIngestStatus(input, ctx);
|
||||
if (name === 'SearchKnowledge') return executeSearchKnowledge(input, ctx);
|
||||
if (name === 'ListNamespaces') return executeListNamespaces(ctx);
|
||||
if (name === 'ListDocuments') return executeListDocuments(input, ctx);
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- Tool Implementations ---
|
||||
|
||||
async function executeIngestDocument(input: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||
const serviceUrl = getServiceUrl(ctx);
|
||||
if (!serviceUrl) return { output: 'Knowledge service not configured', isError: true };
|
||||
|
||||
const namespace = input.namespace as string;
|
||||
const filePath = input.file_path as string;
|
||||
const apiKey = getApiKey(ctx, namespace);
|
||||
if (!apiKey) return { output: `Namespace "${namespace}" not configured`, isError: true };
|
||||
|
||||
const startMs = Date.now();
|
||||
try {
|
||||
const resolvedPath = filePath.startsWith('/') ? filePath : `${ctx.workspacePath}/${filePath}`;
|
||||
const fileData = readFileSync(resolvedPath);
|
||||
const fileName = resolvedPath.split('/').pop() || 'unknown';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', new Blob([fileData]), fileName);
|
||||
|
||||
const resp = await dksFetch(serviceUrl, `/namespaces/${namespace}/ingest`, apiKey, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const errText = await resp.text();
|
||||
const output = `Ingest failed (${resp.status}): ${errText}`;
|
||||
appendKnowledgeHistory(ctx, {
|
||||
timestamp: new Date().toISOString(), tool: 'IngestDocument',
|
||||
namespace, error: output, durationMs: Date.now() - startMs,
|
||||
});
|
||||
return { output, isError: true };
|
||||
}
|
||||
|
||||
const data = await resp.json() as any;
|
||||
const output = `取込を開始しました (job: ${data.job_id}, ${data.pages_detected}ページ検出)。完了確認は IngestStatus で可能です。他の作業を続行できます。`;
|
||||
appendKnowledgeHistory(ctx, {
|
||||
timestamp: new Date().toISOString(), tool: 'IngestDocument',
|
||||
namespace, fileName, jobId: data.job_id, pagesDetected: data.pages_detected,
|
||||
durationMs: Date.now() - startMs,
|
||||
});
|
||||
return { output, isError: false };
|
||||
} catch (e: any) {
|
||||
const error = e.name === 'AbortError'
|
||||
? `IngestDocument timeout: DKS server did not respond within 10s`
|
||||
: `IngestDocument error: ${e.message}`;
|
||||
appendKnowledgeHistory(ctx, {
|
||||
timestamp: new Date().toISOString(), tool: 'IngestDocument',
|
||||
namespace, error, durationMs: Date.now() - startMs,
|
||||
});
|
||||
return { output: error, isError: true };
|
||||
}
|
||||
}
|
||||
|
||||
async function executeIngestStatus(input: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||
const serviceUrl = getServiceUrl(ctx);
|
||||
if (!serviceUrl) return { output: 'Knowledge service not configured', isError: true };
|
||||
|
||||
const namespace = input.namespace as string;
|
||||
const jobId = input.job_id as string;
|
||||
const apiKey = getApiKey(ctx, namespace);
|
||||
if (!apiKey) return { output: `Namespace "${namespace}" not configured`, isError: true };
|
||||
|
||||
try {
|
||||
const resp = await dksFetch(serviceUrl, `/namespaces/${namespace}/jobs/${jobId}`, apiKey);
|
||||
if (!resp.ok) {
|
||||
return { output: `Job not found (${resp.status})`, isError: true };
|
||||
}
|
||||
|
||||
const data = await resp.json() as any;
|
||||
const p = data.progress || {};
|
||||
const statusLine = data.status === 'completed'
|
||||
? `完了 (${data.document_name})`
|
||||
: data.status === 'failed'
|
||||
? `失敗: ${data.error || 'unknown'}`
|
||||
: `処理中: VLM ${p.vlm_completed || 0}/${p.total_pages || 0}ページ, ツリー構築: ${p.tree_built ? '完了' : '未完了'}`;
|
||||
|
||||
return { output: `ジョブ ${data.job_id}: ${statusLine}`, isError: false };
|
||||
} catch (e: any) {
|
||||
if (e.name === 'AbortError') {
|
||||
return { output: `IngestStatus timeout: DKS server did not respond within 10s`, isError: true };
|
||||
}
|
||||
return { output: `IngestStatus error: ${e.message}`, isError: true };
|
||||
}
|
||||
}
|
||||
|
||||
async function executeSearchKnowledge(input: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||
const serviceUrl = getServiceUrl(ctx);
|
||||
if (!serviceUrl) return { output: 'Knowledge service not configured', isError: true };
|
||||
|
||||
const namespace = input.namespace as string;
|
||||
const query = input.query as string;
|
||||
const apiKey = getApiKey(ctx, namespace);
|
||||
if (!apiKey) return { output: `Namespace "${namespace}" not configured`, isError: true };
|
||||
|
||||
const startMs = Date.now();
|
||||
try {
|
||||
const resp = await dksFetch(serviceUrl, `/namespaces/${namespace}/search`, apiKey, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query }),
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const errText = await resp.text();
|
||||
const output = `Search failed (${resp.status}): ${errText}`;
|
||||
appendKnowledgeHistory(ctx, {
|
||||
timestamp: new Date().toISOString(), tool: 'SearchKnowledge',
|
||||
namespace, query, error: output, durationMs: Date.now() - startMs,
|
||||
});
|
||||
return { output, isError: true };
|
||||
}
|
||||
|
||||
const data = await resp.json() as any;
|
||||
|
||||
// DKS 生レスポンスを logs/raw/ に保存
|
||||
saveRawResponse(ctx, 'SearchKnowledge', { query, namespace, response: data });
|
||||
|
||||
const sections = data.sections || [];
|
||||
const rawImageUrls: string[] = data.page_image_urls || [];
|
||||
|
||||
// Download page images to workspace so ReadImage can access them
|
||||
const { localPaths, errors: imgErrors } = await downloadPageImages(
|
||||
serviceUrl, apiKey, rawImageUrls, ctx, namespace,
|
||||
);
|
||||
|
||||
// Format response
|
||||
const lines: string[] = [];
|
||||
for (const section of sections) {
|
||||
lines.push(`## ${section.title} (${section.doc_name}, pages: ${section.pages.join(', ')})`);
|
||||
lines.push(section.content);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (localPaths.length > 0) {
|
||||
lines.push('### ページ画像(ReadImage で閲覧可能)');
|
||||
for (const p of localPaths) {
|
||||
lines.push(`- ${p}`);
|
||||
}
|
||||
}
|
||||
if (imgErrors.length > 0) {
|
||||
lines.push(`\n[画像ダウンロードエラー: ${imgErrors.length}件]`);
|
||||
}
|
||||
|
||||
if (data.system_status?.message) {
|
||||
lines.push(`\n[Info] ${data.system_status.message}`);
|
||||
}
|
||||
|
||||
const output = lines.join('\n');
|
||||
|
||||
appendKnowledgeHistory(ctx, {
|
||||
timestamp: new Date().toISOString(), tool: 'SearchKnowledge',
|
||||
namespace, query, sectionsFound: sections.length,
|
||||
imagesDownloaded: localPaths.length, imageErrors: imgErrors.length,
|
||||
durationMs: Date.now() - startMs,
|
||||
});
|
||||
|
||||
return { output, isError: false };
|
||||
} catch (e: any) {
|
||||
const error = e.name === 'AbortError'
|
||||
? `SearchKnowledge timeout: DKS server did not respond within 30s`
|
||||
: `SearchKnowledge error: ${e.message}`;
|
||||
appendKnowledgeHistory(ctx, {
|
||||
timestamp: new Date().toISOString(), tool: 'SearchKnowledge',
|
||||
namespace, query, error, durationMs: Date.now() - startMs,
|
||||
});
|
||||
return { output: error, isError: true };
|
||||
}
|
||||
}
|
||||
|
||||
async function executeListNamespaces(ctx: ToolContext): Promise<ToolResult> {
|
||||
const serviceUrl = getServiceUrl(ctx);
|
||||
const namespaces = getNamespaces(ctx);
|
||||
|
||||
if (!serviceUrl || !namespaces) {
|
||||
return { output: 'Knowledge service not configured', isError: true };
|
||||
}
|
||||
|
||||
const names = Object.keys(namespaces);
|
||||
if (names.length === 0) {
|
||||
return { output: '利用可能なネームスペースはありません', isError: false };
|
||||
}
|
||||
|
||||
appendKnowledgeHistory(ctx, {
|
||||
timestamp: new Date().toISOString(), tool: 'ListNamespaces',
|
||||
namespacesFound: names.length,
|
||||
});
|
||||
|
||||
return { output: `利用可能なネームスペース:\n${names.map(n => `- ${n}`).join('\n')}`, isError: false };
|
||||
}
|
||||
|
||||
async function executeListDocuments(input: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||
const serviceUrl = getServiceUrl(ctx);
|
||||
if (!serviceUrl) return { output: 'Knowledge service not configured', isError: true };
|
||||
|
||||
const namespace = input.namespace as string;
|
||||
const apiKey = getApiKey(ctx, namespace);
|
||||
if (!apiKey) return { output: `Namespace "${namespace}" not configured`, isError: true };
|
||||
|
||||
const startMs = Date.now();
|
||||
try {
|
||||
const resp = await dksFetch(serviceUrl, `/namespaces/${namespace}/documents`, apiKey);
|
||||
if (!resp.ok) {
|
||||
return { output: `ListDocuments failed (${resp.status})`, isError: true };
|
||||
}
|
||||
|
||||
const data = await resp.json() as any;
|
||||
const docs = data.documents || [];
|
||||
if (docs.length === 0) {
|
||||
return { output: `"${namespace}" にはまだ文書がありません`, isError: false };
|
||||
}
|
||||
|
||||
const lines = docs.map((d: any) => `- ${d.name} (${d.page_count}ページ, id: ${d.id})`);
|
||||
const output = `"${namespace}" の文書一覧:\n${lines.join('\n')}`;
|
||||
|
||||
appendKnowledgeHistory(ctx, {
|
||||
timestamp: new Date().toISOString(), tool: 'ListDocuments',
|
||||
namespace, documentsFound: docs.length, durationMs: Date.now() - startMs,
|
||||
});
|
||||
|
||||
return { output, isError: false };
|
||||
} catch (e: any) {
|
||||
const error = e.name === 'AbortError'
|
||||
? `ListDocuments timeout: DKS server did not respond within 10s`
|
||||
: `ListDocuments error: ${e.message}`;
|
||||
appendKnowledgeHistory(ctx, {
|
||||
timestamp: new Date().toISOString(), tool: 'ListDocuments',
|
||||
namespace, error, durationMs: Date.now() - startMs,
|
||||
});
|
||||
return { output: error, isError: true };
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { runMigrations } from '../../db/migrate.js';
|
||||
import { NotesRepository } from '../../notes/notes-repository.js';
|
||||
import { NotesService } from '../../notes/notes-service.js';
|
||||
import { executeNotesTools, TOOL_DEFS } from './notes.js';
|
||||
|
||||
describe('notes tools', () => {
|
||||
let tmpRoot: string;
|
||||
let db: Database.Database;
|
||||
let service: NotesService;
|
||||
let ctx: { notesService: NotesService; user: Express.User };
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = mkdtempSync(join(tmpdir(), 'notes-tools-test-'));
|
||||
db = new Database(join(tmpRoot, 'test.db'));
|
||||
runMigrations(db);
|
||||
db.prepare(`INSERT INTO users (id, email, name) VALUES ('alice','[email protected]','Alice')`).run();
|
||||
const repo = new NotesRepository(db);
|
||||
service = new NotesService({ db, repo, userFolderRoot: tmpRoot, getUserOrgIds: () => [] });
|
||||
ctx = {
|
||||
notesService: service,
|
||||
user: {
|
||||
id: 'alice',
|
||||
role: 'user',
|
||||
orgIds: [],
|
||||
email: '[email protected]',
|
||||
name: 'Alice',
|
||||
avatarUrl: null,
|
||||
status: 'active',
|
||||
defaultVisibility: 'private',
|
||||
defaultVisibilityOrgId: null,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => { db.close(); rmSync(tmpRoot, { recursive: true, force: true }); });
|
||||
|
||||
it('exposes 3 TOOL_DEFS', () => {
|
||||
expect(Object.keys(TOOL_DEFS).sort()).toEqual(['ReadNote', 'SearchNotes', 'WriteNote']);
|
||||
});
|
||||
|
||||
it('WriteNote writes a note and returns path', async () => {
|
||||
const result = await executeNotesTools('WriteNote', {
|
||||
folder: 'cve',
|
||||
file_name: 'foo.md',
|
||||
content: '---\nvisibility: public\n---\nbody',
|
||||
}, ctx);
|
||||
expect(result?.isError).toBeFalsy();
|
||||
expect(result?.output).toContain('cve/foo.md');
|
||||
});
|
||||
|
||||
it('SearchNotes finds a written note via FTS', async () => {
|
||||
await executeNotesTools('WriteNote', {
|
||||
folder: 'cve',
|
||||
file_name: 'foo.md',
|
||||
content: '---\ntitle: kubernetes pod crash\nvisibility: public\n---\nbody',
|
||||
}, ctx);
|
||||
const result = await executeNotesTools('SearchNotes', { query: 'kubernetes' }, ctx);
|
||||
expect(result?.isError).toBeFalsy();
|
||||
expect(result?.output).toContain('foo.md');
|
||||
});
|
||||
|
||||
it('ReadNote returns full body and FM', async () => {
|
||||
await executeNotesTools('WriteNote', {
|
||||
folder: 'cve',
|
||||
file_name: 'foo.md',
|
||||
content: '---\nvisibility: public\n---\nbody content',
|
||||
}, ctx);
|
||||
const result = await executeNotesTools('ReadNote', {
|
||||
owner_id: 'alice', folder: 'cve', file_name: 'foo.md',
|
||||
}, ctx);
|
||||
expect(result?.isError).toBeFalsy();
|
||||
expect(result?.output).toContain('body content');
|
||||
});
|
||||
});
|
||||
@@ -1,190 +0,0 @@
|
||||
/**
|
||||
* notes.ts — SearchNotes / ReadNote / WriteNote tools
|
||||
*
|
||||
* Agents can search, read, and write shared knowledge notes
|
||||
* stored under data/users/{userId}/notes/{folder}/{file}.md.
|
||||
*/
|
||||
|
||||
import { ToolDef } from '../../llm/openai-compat.js';
|
||||
import { NotesService } from '../../notes/notes-service.js';
|
||||
import type { ToolContext, ToolResult } from './core.js';
|
||||
|
||||
export interface NotesToolContext {
|
||||
notesService: NotesService;
|
||||
user: Express.User;
|
||||
}
|
||||
|
||||
export const TOOL_DEFS: Record<string, ToolDef> = {
|
||||
SearchNotes: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'SearchNotes',
|
||||
description:
|
||||
'購読中の knowledge notes を全文検索 (FTS5)。' +
|
||||
'詳細は ReadToolDoc({ name: "SearchNotes" }) で取得可能。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description: '検索クエリ文字列(FTS5 構文)',
|
||||
},
|
||||
folder: {
|
||||
type: 'string',
|
||||
description: '特定フォルダーのみに絞り込む(省略可)',
|
||||
},
|
||||
limit: {
|
||||
type: 'integer',
|
||||
description: '最大取得件数(デフォルト 10、最大 100)',
|
||||
},
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
ReadNote: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'ReadNote',
|
||||
description:
|
||||
'特定 note の全文(frontmatter + 本文)を取得。可視性チェックあり。' +
|
||||
'詳細は ReadToolDoc({ name: "ReadNote" }) で取得可能。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
owner_id: {
|
||||
type: 'string',
|
||||
description: 'note の所有者 user ID',
|
||||
},
|
||||
folder: {
|
||||
type: 'string',
|
||||
description: 'フォルダー名',
|
||||
},
|
||||
file_name: {
|
||||
type: 'string',
|
||||
description: 'ファイル名(例: foo.md)',
|
||||
},
|
||||
},
|
||||
required: ['owner_id', 'folder', 'file_name'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
WriteNote: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'WriteNote',
|
||||
description:
|
||||
'自分の notes/{folder}/{file}.md に Markdown note を書き込む(作成 / 更新)。' +
|
||||
'詳細は ReadToolDoc({ name: "WriteNote" }) で取得可能。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
folder: {
|
||||
type: 'string',
|
||||
description: 'フォルダー名(英数字・. - _ のみ)',
|
||||
},
|
||||
file_name: {
|
||||
type: 'string',
|
||||
description: 'ファイル名(.md で終わる)',
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
description: 'YAML frontmatter を含む完全な Markdown 内容',
|
||||
},
|
||||
},
|
||||
required: ['folder', 'file_name', 'content'],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute a notes tool.
|
||||
* Returns null when the tool name is not handled (allows index.ts to fall through).
|
||||
*/
|
||||
export async function executeNotesTools(
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
ctx: NotesToolContext | ToolContext,
|
||||
): Promise<ToolResult | null> {
|
||||
// Extract notes-specific context. When called via index.ts the ctx is a full
|
||||
// ToolContext; when called from tests it's a minimal NotesToolContext.
|
||||
const notesCtx = ctx as NotesToolContext & ToolContext;
|
||||
const notesService = notesCtx.notesService;
|
||||
|
||||
if (!notesService) return null;
|
||||
|
||||
// Reconstruct a minimal Express.User from ToolContext fields if needed.
|
||||
const user: Express.User = notesCtx.user ?? {
|
||||
id: notesCtx.userId ?? '',
|
||||
role: (notesCtx as ToolContext).notesUserRole ?? 'user',
|
||||
orgIds: (notesCtx as ToolContext).notesUserOrgIds ?? [],
|
||||
email: '',
|
||||
name: null,
|
||||
avatarUrl: null,
|
||||
status: 'active',
|
||||
defaultVisibility: 'private',
|
||||
defaultVisibilityOrgId: null,
|
||||
};
|
||||
|
||||
switch (name) {
|
||||
case 'SearchNotes': {
|
||||
const rawQuery = String(args['query'] ?? '');
|
||||
// Wrap in FTS5 phrase quotes to avoid query syntax injection
|
||||
const safeQuery = '"' + rawQuery.replace(/"/g, '""') + '"';
|
||||
const rows = notesService.searchNotes({
|
||||
user,
|
||||
query: safeQuery,
|
||||
folder: args['folder'] ? String(args['folder']) : undefined,
|
||||
limit: args['limit'] ? Math.min(parseInt(String(args['limit']), 10), 100) : 10,
|
||||
consumerSubscribed: true,
|
||||
});
|
||||
if (rows.length === 0) {
|
||||
return { output: 'No notes found matching the query.', isError: false };
|
||||
}
|
||||
const lines = rows.map(
|
||||
(r) =>
|
||||
`- ${r.owner_id}/${r.folder}/${r.file_name}` +
|
||||
(r.title ? `: ${r.title}` : '') +
|
||||
(r.tags_json && r.tags_json !== '[]' ? ` [tags: ${r.tags_json}]` : '')
|
||||
);
|
||||
return { output: lines.join('\n'), isError: false };
|
||||
}
|
||||
|
||||
case 'ReadNote': {
|
||||
const ownerId = String(args['owner_id'] ?? '');
|
||||
const folder = String(args['folder'] ?? '');
|
||||
const fileName = String(args['file_name'] ?? '');
|
||||
const out = notesService.getCrossUserNote({ user, ownerId, folder, fileName });
|
||||
if (!out) {
|
||||
return { output: 'Note not found or not accessible.', isError: true };
|
||||
}
|
||||
return { output: out.content, isError: false };
|
||||
}
|
||||
|
||||
case 'WriteNote': {
|
||||
try {
|
||||
const result = notesService.writeNote({
|
||||
ownerId: user.id,
|
||||
folder: String(args['folder'] ?? ''),
|
||||
fileName: String(args['file_name'] ?? ''),
|
||||
content: String(args['content'] ?? ''),
|
||||
});
|
||||
return {
|
||||
output: `Wrote ${result.row.owner_id}/${result.row.folder}/${result.row.file_name}`,
|
||||
isError: false,
|
||||
};
|
||||
} catch (err) {
|
||||
return { output: `WriteNote error: ${(err as Error).message}`, isError: true };
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export as executeTool for ToolModule interface compatibility
|
||||
export const executeTool = executeNotesTools;
|
||||
@@ -85,6 +85,25 @@ movements:
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(existsSync(join(customDir, 'test-fresh-piece.yaml'))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects shared_tools that is not an array (tool-path validator parity)', async () => {
|
||||
const yaml = VALID_NEW_PIECE_YAML
|
||||
.replace('test-fresh-piece', 'test-shared-bad')
|
||||
.replace('description:', 'shared_tools: WebSearch\ndescription:');
|
||||
const result = await executeTool('CreatePiece', { yaml_content: yaml }, ctx());
|
||||
expect(result?.isError).toBe(true);
|
||||
expect(result?.output).toMatch(/shared_tools must be an array/);
|
||||
expect(existsSync(join(customDir, 'test-shared-bad.yaml'))).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a valid shared_tools array', async () => {
|
||||
const yaml = VALID_NEW_PIECE_YAML
|
||||
.replace('test-fresh-piece', 'test-shared-ok')
|
||||
.replace('description:', 'shared_tools:\n - WebSearch\n - ReadToolDoc\ndescription:');
|
||||
const result = await executeTool('CreatePiece', { yaml_content: yaml }, ctx());
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(existsSync(join(customDir, 'test-shared-ok.yaml'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('customPiecesDir as array — P1-a regression', () => {
|
||||
|
||||
@@ -58,6 +58,18 @@ function validatePiece(piece: any): string | null {
|
||||
if (typeof piece.max_movements !== 'number' || !Number.isFinite(piece.max_movements) || piece.max_movements <= 0) {
|
||||
return 'max_movements is required (positive integer, e.g. 50 for short tasks, 999 for open-ended ones)';
|
||||
}
|
||||
// Piece-level shared tools (union into every movement). Optional; when
|
||||
// present must be an array of non-empty strings. Kept in sync with the
|
||||
// HTTP-layer validator in src/bridge/pieces-api.ts.
|
||||
if (piece.shared_tools !== undefined) {
|
||||
if (!Array.isArray(piece.shared_tools)) return 'shared_tools must be an array';
|
||||
for (let i = 0; i < piece.shared_tools.length; i++) {
|
||||
const t = piece.shared_tools[i];
|
||||
if (typeof t !== 'string' || t.trim().length === 0) {
|
||||
return `shared_tools[${i}] must be a non-empty string`;
|
||||
}
|
||||
}
|
||||
}
|
||||
const names = new Set(piece.movements.map((m: any) => m.name));
|
||||
if (!names.has(piece.initial_movement)) return 'initial_movement must reference an existing movement';
|
||||
// Phase 6b: rules[].next only accepts existing movement names + WAIT_SUBTASKS.
|
||||
|
||||
@@ -17,7 +17,7 @@ describe('raw-save', () => {
|
||||
});
|
||||
|
||||
it('saves text data to logs/raw/', () => {
|
||||
saveRawData(tempDir, 'WebFetch', 'Hello World');
|
||||
saveRawData(join(tempDir, 'logs'), 'WebFetch', 'Hello World');
|
||||
const rawDir = join(tempDir, 'logs', 'raw');
|
||||
expect(existsSync(rawDir)).toBe(true);
|
||||
const files = readdirSync(rawDir) as string[];
|
||||
@@ -27,7 +27,7 @@ describe('raw-save', () => {
|
||||
});
|
||||
|
||||
it('appends to rawdata-history.jsonl', () => {
|
||||
saveRawData(tempDir, 'XSearch', 'search results');
|
||||
saveRawData(join(tempDir, 'logs'), 'XSearch', 'search results');
|
||||
const historyPath = join(tempDir, 'logs', 'rawdata-history.jsonl');
|
||||
expect(existsSync(historyPath)).toBe(true);
|
||||
const entry = JSON.parse(readFileSync(historyPath, 'utf-8').trim());
|
||||
@@ -37,7 +37,7 @@ describe('raw-save', () => {
|
||||
});
|
||||
|
||||
it('does not save for non-target tools', () => {
|
||||
saveRawData(tempDir, 'Read', 'file contents');
|
||||
saveRawData(join(tempDir, 'logs'), 'Read', 'file contents');
|
||||
const rawDir = join(tempDir, 'logs', 'raw');
|
||||
expect(existsSync(rawDir)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -41,18 +41,19 @@ export function generateRawFilename(toolName: string, ext: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* ツール実行結果を logs/raw/ に保存する。
|
||||
* ツール実行結果を {logsRoot}/raw/ に保存する。
|
||||
* RAW_SAVE_TOOLS に含まれないツールの場合は何もしない。
|
||||
* `logsRoot` は実行ログ root(計画5: ctx.runtimeDir ?? workspacePath/logs)。
|
||||
*/
|
||||
export function saveRawData(
|
||||
workspacePath: string,
|
||||
logsRoot: string,
|
||||
toolName: string,
|
||||
content: string,
|
||||
): void {
|
||||
if (!RAW_SAVE_TOOLS.has(toolName)) return;
|
||||
|
||||
try {
|
||||
const rawDir = join(workspacePath, 'logs', 'raw');
|
||||
const rawDir = join(logsRoot, 'raw');
|
||||
mkdirSync(rawDir, { recursive: true });
|
||||
|
||||
const filename = generateRawFilename(toolName, '.txt');
|
||||
@@ -66,7 +67,7 @@ export function saveRawData(
|
||||
bytes: Buffer.byteLength(content, 'utf-8'),
|
||||
};
|
||||
appendFileSync(
|
||||
join(workspacePath, 'logs', 'rawdata-history.jsonl'),
|
||||
join(logsRoot, 'rawdata-history.jsonl'),
|
||||
JSON.stringify(logEntry) + '\n',
|
||||
'utf-8',
|
||||
);
|
||||
@@ -77,9 +78,10 @@ export function saveRawData(
|
||||
|
||||
/**
|
||||
* DownloadFile のパス情報を rawdata-history.jsonl に記録する(ファイルコピーはしない)。
|
||||
* `logsRoot` は実行ログ root(計画5: ctx.runtimeDir ?? workspacePath/logs)。
|
||||
*/
|
||||
export function logRawDownload(
|
||||
workspacePath: string,
|
||||
logsRoot: string,
|
||||
toolName: string,
|
||||
savedPath: string,
|
||||
bytes: number,
|
||||
@@ -93,7 +95,7 @@ export function logRawDownload(
|
||||
type: 'reference',
|
||||
};
|
||||
appendFileSync(
|
||||
join(workspacePath, 'logs', 'rawdata-history.jsonl'),
|
||||
join(logsRoot, 'rawdata-history.jsonl'),
|
||||
JSON.stringify(logEntry) + '\n',
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { extractReadableMarkdown, truncateForAgent } from './readability.js';
|
||||
|
||||
const ARTICLE_HTML = `<!DOCTYPE html>
|
||||
<html><head><title>Test Article Title</title></head>
|
||||
<body>
|
||||
<nav><a href="/">Home</a><a href="/about">About</a><a href="/contact">Contact</a></nav>
|
||||
<header>SITE BANNER — SUBSCRIBE NOW — ADVERTISEMENT</header>
|
||||
<aside>Related links sidebar junk and tracking pixels</aside>
|
||||
<article>
|
||||
<h1>The Main Heading</h1>
|
||||
<p>The first paragraph of the article body, with a <a href="https://example.com/page">helpful link</a> inside it. This needs to be long enough that readability accepts it as real content rather than boilerplate noise.</p>
|
||||
<h2>A Subsection</h2>
|
||||
<ul><li>first bullet point</li><li>second bullet point</li><li>third bullet point</li></ul>
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Value</th></tr></thead>
|
||||
<tbody><tr><td>alpha</td><td>1</td></tr><tr><td>beta</td><td>2</td></tr></tbody>
|
||||
</table>
|
||||
<p>A closing paragraph with sufficient length so the readability heuristics treat this document as a genuine article and extract the body cleanly. Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>
|
||||
</article>
|
||||
<footer>FOOTER COPYRIGHT — PRIVACY POLICY — TERMS</footer>
|
||||
</body></html>`;
|
||||
|
||||
describe('extractReadableMarkdown', () => {
|
||||
it('記事本文を抽出しボイラープレートを除去する', () => {
|
||||
const result = extractReadableMarkdown(ARTICLE_HTML);
|
||||
expect(result).not.toBeNull();
|
||||
const md = result!.markdown;
|
||||
// 本文は残る
|
||||
expect(md).toContain('first paragraph of the article body');
|
||||
expect(md).toContain('closing paragraph');
|
||||
// ナビ・広告・フッターは除去される
|
||||
expect(md).not.toContain('SITE BANNER');
|
||||
expect(md).not.toContain('FOOTER COPYRIGHT');
|
||||
expect(md).not.toContain('PRIVACY POLICY');
|
||||
});
|
||||
|
||||
it('見出しが Markdown の atx 記法で残る', () => {
|
||||
const md = extractReadableMarkdown(ARTICLE_HTML)!.markdown;
|
||||
expect(md).toMatch(/#+\s+The Main Heading/);
|
||||
expect(md).toMatch(/#+\s+A Subsection/);
|
||||
});
|
||||
|
||||
it('リストが Markdown 箇条書きで残る', () => {
|
||||
const md = extractReadableMarkdown(ARTICLE_HTML)!.markdown;
|
||||
expect(md).toMatch(/-\s+first bullet point/);
|
||||
expect(md).toMatch(/-\s+second bullet point/);
|
||||
});
|
||||
|
||||
it('テーブルが GFM テーブル記法で残る', () => {
|
||||
const md = extractReadableMarkdown(ARTICLE_HTML)!.markdown;
|
||||
expect(md).toContain('| Name | Value |');
|
||||
expect(md).toContain('| --- | --- |');
|
||||
expect(md).toMatch(/\| alpha \| 1 \|/);
|
||||
});
|
||||
|
||||
it('リンクが Markdown リンク記法で残る', () => {
|
||||
const md = extractReadableMarkdown(ARTICLE_HTML)!.markdown;
|
||||
expect(md).toContain('[helpful link](https://example.com/page)');
|
||||
});
|
||||
|
||||
it('タイトルを抽出する', () => {
|
||||
const result = extractReadableMarkdown(ARTICLE_HTML);
|
||||
expect(result!.title).toBe('Test Article Title');
|
||||
});
|
||||
|
||||
it('空 HTML では null を返す(フォールバック用)', () => {
|
||||
expect(extractReadableMarkdown('')).toBeNull();
|
||||
expect(extractReadableMarkdown(' ')).toBeNull();
|
||||
});
|
||||
|
||||
it('本文のないガベージ HTML では null を返す', () => {
|
||||
const garbage = '<html><body><div></div><span></span></body></html>';
|
||||
expect(extractReadableMarkdown(garbage)).toBeNull();
|
||||
});
|
||||
|
||||
it('selector 指定時はそのサブツリーだけを抽出する', () => {
|
||||
const html = `<html><body>
|
||||
<div id="wanted"><h2>Picked Section</h2><p>only this part should be extracted here</p></div>
|
||||
<div id="other"><p>this other part must NOT appear in the output</p></div>
|
||||
</body></html>`;
|
||||
const result = extractReadableMarkdown(html, { selector: '#wanted' });
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.markdown).toContain('Picked Section');
|
||||
expect(result!.markdown).toContain('only this part');
|
||||
expect(result!.markdown).not.toContain('other part');
|
||||
});
|
||||
|
||||
it('selector が見つからなければ null を返す(フォールバック用)', () => {
|
||||
expect(extractReadableMarkdown(ARTICLE_HTML, { selector: '#does-not-exist' })).toBeNull();
|
||||
});
|
||||
|
||||
it('baseUrl 指定時にルート相対リンクを絶対化する', () => {
|
||||
const html = `<html><body><article>
|
||||
<h1>Heading For Length</h1>
|
||||
<p>A paragraph long enough to pass readability with a <a href="/docs/page">root relative link</a> embedded so we can verify absolutization works correctly here. Lorem ipsum dolor sit amet consectetur.</p>
|
||||
</article></body></html>`;
|
||||
const md = extractReadableMarkdown(html, { baseUrl: 'https://site.example/blog/post' })!.markdown;
|
||||
expect(md).toContain('https://site.example/docs/page');
|
||||
expect(md).not.toContain('](/docs/page)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncateForAgent', () => {
|
||||
it('上限以下ならそのまま返す', () => {
|
||||
expect(truncateForAgent('short', 100)).toBe('short');
|
||||
});
|
||||
|
||||
it('上限超過で切り詰め、source 保存済みなら full 版の在処を案内する', () => {
|
||||
const long = 'x'.repeat(50);
|
||||
const out = truncateForAgent(long, 10, 'article.md');
|
||||
expect(out.startsWith('x'.repeat(10))).toBe(true);
|
||||
expect(out).toContain('full version saved to source/article.md');
|
||||
});
|
||||
|
||||
it('上限超過で source 未保存なら素の truncated note を付ける', () => {
|
||||
const out = truncateForAgent('y'.repeat(50), 10, null);
|
||||
expect(out).toContain('... (truncated)');
|
||||
expect(out).not.toContain('saved to source');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Readability ベースのクリーン本文抽出ヘルパー。
|
||||
*
|
||||
* WebFetch が取得した生 HTML を「記事本文だけ」のクリーンな Markdown に変換する。
|
||||
* ナビ・広告・ヘッダー・フッター等のボイラープレートを除去し、見出し・リスト・
|
||||
* テーブル・リンクといった構造を保ったまま Markdown 化する。
|
||||
*
|
||||
* パイプライン: HTML → linkedom で DOM 化 → @mozilla/readability で記事抽出
|
||||
* → turndown(+gfm) で HTML→Markdown 変換。
|
||||
*
|
||||
* 抽出に失敗(非記事ページ・SPA・空など)したら null を返す。呼び出し側は
|
||||
* 既存の素朴な htmlToText にフォールバックして WebFetch を絶対に hard-fail させない。
|
||||
*
|
||||
* 注意: これはヘルパーモジュールであり、ツールではない(TOOL_DEFS を持たない)。
|
||||
*
|
||||
* ESM/CJS 注意: turndown は CJS-main / turndown-plugin-gfm も CJS-main。
|
||||
* ESM ビルド("type":"module")で `not a constructor` 系の罠を踏まないよう、
|
||||
* default / named import を明示し、dist 実機での import を検証済み
|
||||
* (@kenjiuno/msgreader インシデントの教訓)。
|
||||
*/
|
||||
|
||||
import { Readability } from '@mozilla/readability';
|
||||
import { parseHTML } from 'linkedom';
|
||||
import TurndownService from 'turndown';
|
||||
import { gfm } from 'turndown-plugin-gfm';
|
||||
import { logger } from '../../../logger.js';
|
||||
|
||||
/** クリーン抽出の結果 */
|
||||
export interface ReadableExtraction {
|
||||
/** 記事タイトル(取得できれば) */
|
||||
title?: string;
|
||||
/** クリーンな Markdown 本文(全文・未切り詰め) */
|
||||
markdown: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* turndown インスタンスは生成コストがあるので使い回す。
|
||||
* - headingStyle: atx(`#` 記法)
|
||||
* - codeBlockStyle: fenced(``` 記法)
|
||||
* - gfm プラグインでテーブル・打消し線・タスクリストを Markdown 化
|
||||
*/
|
||||
let _turndown: TurndownService | null = null;
|
||||
function getTurndown(): TurndownService {
|
||||
if (_turndown) return _turndown;
|
||||
const td = new TurndownService({
|
||||
headingStyle: 'atx',
|
||||
codeBlockStyle: 'fenced',
|
||||
bulletListMarker: '-',
|
||||
hr: '---',
|
||||
});
|
||||
td.use(gfm);
|
||||
// 画像は alt テキスト + URL を残す(agent が参照できるように)
|
||||
_turndown = td;
|
||||
return _turndown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生 HTML から記事本文を抽出してクリーンな Markdown を返す。
|
||||
* 抽出できなければ null(呼び出し側で htmlToText にフォールバック)。
|
||||
*
|
||||
* @param html 生 HTML 文字列
|
||||
* @param opts.baseUrl 相対リンク解決用の URL(任意)。Markdown 内の相対リンクを絶対化する
|
||||
* @param opts.selector CSS セレクタ(任意)。指定すると、その要素のサブツリーだけを
|
||||
* クリーン抽出する。要素が見つからなければ null(呼び出し側でフォールバック)。
|
||||
*/
|
||||
export function extractReadableMarkdown(
|
||||
html: string,
|
||||
opts: { baseUrl?: string; selector?: string } = {},
|
||||
): ReadableExtraction | null {
|
||||
if (!html || html.trim().length === 0) return null;
|
||||
const { baseUrl, selector } = opts;
|
||||
|
||||
let articleHtml: string;
|
||||
let title: string | undefined;
|
||||
try {
|
||||
// linkedom で DOM を構築。
|
||||
const { document } = parseHTML(html);
|
||||
|
||||
// selector 指定時は、その要素サブツリーを直接 Markdown 化する。
|
||||
if (selector) {
|
||||
let el: Element | null = null;
|
||||
try {
|
||||
el = document.querySelector(selector) as unknown as Element | null;
|
||||
} catch {
|
||||
el = null; // 不正なセレクタは無視(フォールバックさせる)
|
||||
}
|
||||
if (!el) return null;
|
||||
const inner = (el as unknown as { innerHTML?: string }).innerHTML ?? '';
|
||||
if (!inner.trim()) return null;
|
||||
articleHtml = inner;
|
||||
const docTitle = (document as unknown as { title?: string }).title;
|
||||
title = docTitle?.trim() || undefined;
|
||||
} else {
|
||||
const reader = new Readability(document as unknown as Document, {
|
||||
// 短い記事でも諦めないよう閾値を下げる(デフォルト 500)
|
||||
charThreshold: 250,
|
||||
});
|
||||
const parsed = reader.parse();
|
||||
if (!parsed || !parsed.content || parsed.content.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
articleHtml = parsed.content;
|
||||
title = parsed.title?.trim() || undefined;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.debug(`[readability] extraction failed: ${(e as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
let markdown = getTurndown().turndown(articleHtml).trim();
|
||||
if (markdown.length === 0) return null;
|
||||
// baseUrl があれば、Markdown 内のルート相対 / プロトコル相対リンクを絶対化する。
|
||||
if (baseUrl) markdown = absolutizeLinks(markdown, baseUrl);
|
||||
return { ...(title ? { title } : {}), markdown };
|
||||
} catch (e) {
|
||||
logger.debug(`[readability] turndown conversion failed: ${(e as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown 内の相対リンク `](/path)` `](path)` を baseUrl 基準で絶対化する。
|
||||
* 既に絶対 (http/https/mailto/data 等) や アンカー (#) のものはそのまま。
|
||||
*/
|
||||
function absolutizeLinks(markdown: string, baseUrl: string): string {
|
||||
let base: URL;
|
||||
try {
|
||||
base = new URL(baseUrl);
|
||||
} catch {
|
||||
return markdown;
|
||||
}
|
||||
// Markdown のリンク / 画像の URL 部分のみ書き換える: ](URL) と ](URL "title")
|
||||
return markdown.replace(/(\]\()([^)\s]+)(\s+"[^"]*")?(\))/g, (_m, open, url, titlePart, close) => {
|
||||
let abs = url;
|
||||
if (!/^[a-z][a-z0-9+.-]*:/i.test(url) && !url.startsWith('#')) {
|
||||
try {
|
||||
abs = new URL(url, base).toString();
|
||||
} catch {
|
||||
abs = url;
|
||||
}
|
||||
}
|
||||
return `${open}${abs}${titlePart ?? ''}${close}`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* agent のコンテキストに返すために markdown を上限文字数で切り詰める。
|
||||
* 切り詰めた場合は note を付与(source 保存済みなら full 版の在処を案内)。
|
||||
*
|
||||
* @param markdown 全文クリーン Markdown
|
||||
* @param limit 上限文字数
|
||||
* @param savedSourceFile source/ に保存された相対ファイル名(あれば note に含める)
|
||||
*/
|
||||
export function truncateForAgent(
|
||||
markdown: string,
|
||||
limit: number,
|
||||
savedSourceFile?: string | null,
|
||||
): string {
|
||||
if (markdown.length <= limit) return markdown;
|
||||
const note = savedSourceFile
|
||||
? `\n\n... (truncated; full version saved to source/${savedSourceFile})`
|
||||
: '\n\n... (truncated)';
|
||||
return markdown.slice(0, limit) + note;
|
||||
}
|
||||
@@ -237,7 +237,7 @@ This is the skill body.
|
||||
dirs.push(systemDir, userRoot);
|
||||
|
||||
const catalog = new SkillCatalog(systemDir, userRoot);
|
||||
const ctx = makeCtx({ skillCatalog: catalog, notesUserRole: 'admin' });
|
||||
const ctx = makeCtx({ skillCatalog: catalog, userRole: 'admin' });
|
||||
|
||||
const result = executeSkillTool('InstallSkill', {
|
||||
name: 'my-skill',
|
||||
@@ -395,7 +395,7 @@ describe('InstallSkill tool', () => {
|
||||
editAllowed: true,
|
||||
skillCatalog: catalog,
|
||||
userId: 'user1',
|
||||
notesUserRole: opts?.role ?? 'user',
|
||||
userRole: opts?.role ?? 'user',
|
||||
} as ToolContext;
|
||||
}
|
||||
|
||||
@@ -492,3 +492,144 @@ describe('InstallSkill tool', () => {
|
||||
expect(fs.existsSync(path.join(targetDir, 'extra.txt'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workspace (folderContext) scoping — user-scope skills follow the job's folder
|
||||
// so a skill created in a shared (case) workspace is visible to its members.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('InstallSkill / ListSkills / ReadSkill — workspace (folderContext) scope', () => {
|
||||
const dirs: string[] = [];
|
||||
let systemDir: string;
|
||||
let userRoot: string;
|
||||
let spacesRoot: string;
|
||||
const SPACE_ID = 'space-42';
|
||||
|
||||
const SKILL_MD = `---
|
||||
name: shared-skill
|
||||
description: A skill created inside a shared workspace
|
||||
---
|
||||
# Shared
|
||||
Body text.
|
||||
`;
|
||||
|
||||
beforeEach(() => {
|
||||
systemDir = makeTempDir();
|
||||
userRoot = makeTempDir();
|
||||
spacesRoot = makeTempDir();
|
||||
dirs.push(systemDir, userRoot, spacesRoot);
|
||||
setSkillToolDeps({ userFolderRoot: userRoot });
|
||||
});
|
||||
afterEach(() => {
|
||||
for (const d of dirs) fs.rmSync(d, { recursive: true, force: true });
|
||||
dirs.length = 0;
|
||||
setSkillToolDeps(null);
|
||||
});
|
||||
|
||||
function spaceCtx(userId: string): ToolContext {
|
||||
return {
|
||||
workspacePath: '/tmp',
|
||||
editAllowed: false,
|
||||
skillCatalog: new SkillCatalog(systemDir, userRoot),
|
||||
userId,
|
||||
folderContext: { rootDir: spacesRoot, leafId: SPACE_ID },
|
||||
} as unknown as ToolContext;
|
||||
}
|
||||
|
||||
function personalCtx(userId: string, catalog?: SkillCatalog): ToolContext {
|
||||
return {
|
||||
workspacePath: '/tmp',
|
||||
editAllowed: false,
|
||||
skillCatalog: catalog ?? new SkillCatalog(systemDir, userRoot),
|
||||
userId,
|
||||
} as unknown as ToolContext;
|
||||
}
|
||||
|
||||
it('writes user-scope skill to the SPACE skills dir, not the personal folder', () => {
|
||||
const res = executeSkillTool('InstallSkill', { content: SKILL_MD, name: 'shared-skill', scope: 'user' }, spaceCtx('user1'));
|
||||
expect(res!.isError).toBe(false);
|
||||
|
||||
const spacePath = path.join(spacesRoot, SPACE_ID, 'skills', 'shared-skill', 'SKILL.md');
|
||||
const personalPath = path.join(userRoot, 'user1', 'skills', 'shared-skill', 'SKILL.md');
|
||||
expect(fs.existsSync(spacePath)).toBe(true);
|
||||
expect(fs.existsSync(personalPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('ListSkills / ReadSkill in the same workspace read it back', () => {
|
||||
executeSkillTool('InstallSkill', { content: SKILL_MD, name: 'shared-skill', scope: 'user' }, spaceCtx('user1'));
|
||||
|
||||
const list = executeSkillTool('ListSkills', {}, spaceCtx('user1'));
|
||||
expect(list!.output).toContain('shared-skill');
|
||||
|
||||
const read = executeSkillTool('ReadSkill', { name: 'shared-skill' }, spaceCtx('user1'));
|
||||
expect(read!.isError).toBe(false);
|
||||
expect(read!.output).toContain('Body text.');
|
||||
});
|
||||
|
||||
it('a different member (same folderContext) sees the workspace skill', () => {
|
||||
executeSkillTool('InstallSkill', { content: SKILL_MD, name: 'shared-skill', scope: 'user' }, spaceCtx('user1'));
|
||||
|
||||
// user2 — same space, different userId — must see it.
|
||||
const list = executeSkillTool('ListSkills', {}, spaceCtx('user2'));
|
||||
expect(list!.output).toContain('shared-skill');
|
||||
const read = executeSkillTool('ReadSkill', { name: 'shared-skill' }, spaceCtx('user2'));
|
||||
expect(read!.isError).toBe(false);
|
||||
});
|
||||
|
||||
it('a ctx WITHOUT that folderContext (personal) does NOT see the workspace skill', () => {
|
||||
executeSkillTool('InstallSkill', { content: SKILL_MD, name: 'shared-skill', scope: 'user' }, spaceCtx('user1'));
|
||||
|
||||
const list = executeSkillTool('ListSkills', {}, personalCtx('user1'));
|
||||
expect(list!.output).not.toContain('shared-skill');
|
||||
const read = executeSkillTool('ReadSkill', { name: 'shared-skill' }, personalCtx('user1'));
|
||||
expect(read!.isError).toBe(true);
|
||||
});
|
||||
|
||||
it('without folderContext, user-scope still writes/reads the personal folder (unchanged)', () => {
|
||||
const catalog = new SkillCatalog(systemDir, userRoot);
|
||||
const res = executeSkillTool('InstallSkill', { content: SKILL_MD, name: 'shared-skill', scope: 'user' }, personalCtx('user1', catalog));
|
||||
expect(res!.isError).toBe(false);
|
||||
|
||||
const personalPath = path.join(userRoot, 'user1', 'skills', 'shared-skill', 'SKILL.md');
|
||||
const spacePath = path.join(spacesRoot, SPACE_ID, 'skills', 'shared-skill', 'SKILL.md');
|
||||
expect(fs.existsSync(personalPath)).toBe(true);
|
||||
expect(fs.existsSync(spacePath)).toBe(false);
|
||||
|
||||
const list = executeSkillTool('ListSkills', {}, personalCtx('user1', catalog));
|
||||
expect(list!.output).toContain('shared-skill');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SkillCatalog.buildIndexForFolder — the Skills Index advertises the folder's skills
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('SkillCatalog.buildIndexForFolder', () => {
|
||||
const dirs: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const d of dirs) fs.rmSync(d, { recursive: true, force: true });
|
||||
dirs.length = 0;
|
||||
});
|
||||
|
||||
it('returns the space folder skills for a space (rootDir, leafId)', () => {
|
||||
const systemDir = makeTempDir();
|
||||
const userRoot = makeTempDir();
|
||||
const spacesRoot = makeTempDir();
|
||||
dirs.push(systemDir, userRoot, spacesRoot);
|
||||
|
||||
// Skill lives only in the space folder.
|
||||
const spaceSkillDir = path.join(spacesRoot, 'space-42', 'skills', 'space-only');
|
||||
writeSkill(spaceSkillDir, 'SKILL.md', `---\nname: space-only\ndescription: only in the space\n---\nbody\n`);
|
||||
// A different skill in user1's personal folder.
|
||||
const personalSkillDir = path.join(userRoot, 'user1', 'skills', 'personal-only');
|
||||
writeSkill(personalSkillDir, 'SKILL.md', `---\nname: personal-only\ndescription: only personal\n---\nbody\n`);
|
||||
|
||||
const catalog = new SkillCatalog(systemDir, userRoot);
|
||||
const spaceIndex = catalog.buildIndexForFolder(spacesRoot, 'space-42');
|
||||
expect(spaceIndex).toContain('space-only');
|
||||
expect(spaceIndex).not.toContain('personal-only');
|
||||
|
||||
// buildIndex (personal) still resolves the personal folder.
|
||||
const personalIndex = catalog.buildIndex('user1');
|
||||
expect(personalIndex).toContain('personal-only');
|
||||
expect(personalIndex).not.toContain('space-only');
|
||||
});
|
||||
});
|
||||
|
||||
+35
-14
@@ -19,6 +19,25 @@ export function setSkillToolDeps(deps: SkillToolDeps | null): void {
|
||||
_deps = deps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the folder `(root, leaf)` that user-scope skills should read/write,
|
||||
* mirroring AGENTS.md / memory (user-folder.ts folderTarget). When the job
|
||||
* belongs to a (case) workspace, the worker wires `ctx.folderContext`
|
||||
* (`{rootDir, leafId}`) → skills land in `data/spaces/{id}/skills/` and are
|
||||
* shared with members. Without folderContext (gitea-issue / legacy / personal)
|
||||
* we fall back to the caller's personal folder — byte-identical to before
|
||||
* (the personal root is taken from the catalog so it matches getUserSkillDir).
|
||||
*
|
||||
* `system` scope is unaffected: that path uses catalog.getSystemDir() directly.
|
||||
*/
|
||||
function skillFolderTarget(ctx: ToolContext): { root: string; leaf: string } {
|
||||
if (ctx.folderContext) {
|
||||
return { root: ctx.folderContext.rootDir, leaf: ctx.folderContext.leafId };
|
||||
}
|
||||
const personalRoot = ctx.skillCatalog?.getUserRoot() ?? _deps?.userFolderRoot ?? './data/users';
|
||||
return { root: personalRoot, leaf: ctx.userId ?? 'local' };
|
||||
}
|
||||
|
||||
// ── Skill materialization ───────────────────────────────────────────────────
|
||||
// When the agent ReadSkill's a directory-based skill, copy its files into the
|
||||
// task workspace (`{workspace}/skills/{name}/`) so its scripts are usable in
|
||||
@@ -151,7 +170,7 @@ function executeInstallSkill(
|
||||
if (!VALID_SKILL_NAME.test(skillName)) {
|
||||
return { output: `InstallSkill: invalid skill name "${skillName}". Only [a-z0-9_-] allowed.`, isError: true };
|
||||
}
|
||||
if (scope === 'system' && ctx.notesUserRole !== 'admin') {
|
||||
if (scope === 'system' && ctx.userRole !== 'admin') {
|
||||
return { output: 'InstallSkill: system-scope install requires admin role', isError: true };
|
||||
}
|
||||
|
||||
@@ -160,10 +179,12 @@ function executeInstallSkill(
|
||||
return { output: 'InstallSkill: skill catalog not available', isError: true };
|
||||
}
|
||||
const userId = ctx.userId ?? 'local';
|
||||
// user-scope skills resolve to the job's folder (space → shared, else personal).
|
||||
const { root: folderRoot, leaf: folderLeaf } = skillFolderTarget(ctx);
|
||||
|
||||
// Count check (user scope only)
|
||||
// Count check (user scope only) — counts skills in the resolved folder.
|
||||
if (scope === 'user') {
|
||||
const userSkillCount = catalog.getForUser(userId).filter(s => s.source === 'user').length;
|
||||
const userSkillCount = catalog.getForFolder(folderRoot, folderLeaf).filter(s => s.source === 'user').length;
|
||||
if (userSkillCount >= 50) {
|
||||
return { output: `InstallSkill: user skill limit reached (${userSkillCount}/50)`, isError: true };
|
||||
}
|
||||
@@ -205,7 +226,7 @@ function executeInstallSkill(
|
||||
return { output: `InstallSkill: blocked by security scan:\n${details}`, isError: true };
|
||||
}
|
||||
|
||||
const targetBase = scope === 'system' ? catalog.getSystemDir() : catalog.getUserSkillDir(userId);
|
||||
const targetBase = scope === 'system' ? catalog.getSystemDir() : catalog.getSkillDirForFolder(folderRoot, folderLeaf);
|
||||
const target = join(targetBase, skillName);
|
||||
const tmpTarget = target + '.tmp-' + Date.now();
|
||||
|
||||
@@ -221,8 +242,8 @@ function executeInstallSkill(
|
||||
return { output: `InstallSkill: install failed: ${e}`, isError: true };
|
||||
}
|
||||
|
||||
if (scope === 'system') { catalog.refreshSystem(); } else { catalog.invalidate(userId); }
|
||||
_deps?.auditLog?.('skill_installed', { skillName, scope, userId, format: 'directory', fileCount: stats.fileCount }, ctx.taskId ?? null);
|
||||
if (scope === 'system') { catalog.refreshSystem(); } else { catalog.invalidateFolder(folderRoot, folderLeaf); }
|
||||
_deps?.auditLog?.('skill_installed', { skillName, scope, userId, folderRoot, folderLeaf, format: 'directory', fileCount: stats.fileCount }, ctx.taskId ?? null);
|
||||
|
||||
const mediumFindings = findings.filter(f => f.severity === 'medium');
|
||||
let msg = `InstallSkill: installed "${skillName}" to ${scope} scope (${stats.fileCount} files)`;
|
||||
@@ -246,7 +267,7 @@ function executeInstallSkill(
|
||||
return { output: `InstallSkill: blocked by security scan:\n${details}`, isError: true };
|
||||
}
|
||||
|
||||
const targetDir = scope === 'system' ? catalog.getSystemDir() : catalog.getUserSkillDir(userId);
|
||||
const targetDir = scope === 'system' ? catalog.getSystemDir() : catalog.getSkillDirForFolder(folderRoot, folderLeaf);
|
||||
const skillDir = join(targetDir, skillName);
|
||||
const tmpDir = join(targetDir, `.${skillName}.tmp.${Date.now()}`);
|
||||
|
||||
@@ -262,8 +283,8 @@ function executeInstallSkill(
|
||||
return { output: `InstallSkill: write failed: ${(err as Error).message}`, isError: true };
|
||||
}
|
||||
|
||||
if (scope === 'system') { catalog.refreshSystem(); } else { catalog.invalidate(userId); }
|
||||
_deps?.auditLog?.('skill_installed', { skillName, scope, userId, scanSeverity: severity, findingsCount: findings.length }, ctx.taskId ?? null);
|
||||
if (scope === 'system') { catalog.refreshSystem(); } else { catalog.invalidateFolder(folderRoot, folderLeaf); }
|
||||
_deps?.auditLog?.('skill_installed', { skillName, scope, userId, folderRoot, folderLeaf, scanSeverity: severity, findingsCount: findings.length }, ctx.taskId ?? null);
|
||||
|
||||
const mediumFindings = findings.filter(f => f.severity === 'medium');
|
||||
let msg = `InstallSkill: installed "${skillName}" to ${scope} scope`;
|
||||
@@ -329,8 +350,8 @@ export function executeSkillTool(
|
||||
if (name === 'ListSkills') {
|
||||
const catalog = ctx.skillCatalog;
|
||||
if (!catalog) return { output: 'Error: skill catalog not available', isError: true };
|
||||
const userId = ctx.userId ?? 'local';
|
||||
const entries = catalog.getForUser(userId);
|
||||
const { root, leaf } = skillFolderTarget(ctx);
|
||||
const entries = catalog.getForFolder(root, leaf);
|
||||
if (entries.length === 0) return { output: 'No skills installed.', isError: false };
|
||||
const lines = entries.map(e => {
|
||||
const dirSuffix = 'dirPath' in e && e.dirPath ? ' (has scripts/)' : '';
|
||||
@@ -351,10 +372,10 @@ export function executeSkillTool(
|
||||
return { output: 'Error: skill catalog not available', isError: true };
|
||||
}
|
||||
|
||||
const userId = ctx.userId ?? 'local';
|
||||
const result = catalog.getSkillContent(skillName, userId);
|
||||
const { root, leaf } = skillFolderTarget(ctx);
|
||||
const result = catalog.getSkillContentForFolder(skillName, root, leaf);
|
||||
if (result === null) {
|
||||
const available = catalog.getForUser(userId).map(s => s.name).join(', ');
|
||||
const available = catalog.getForFolder(root, leaf).map(s => s.name).join(', ');
|
||||
return {
|
||||
output: `Skill "${skillName}" not found. Available skills: ${available || '(none)'}`,
|
||||
isError: true,
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
sanitizeSourceName,
|
||||
appendSourceIndex,
|
||||
saveSourceFile,
|
||||
findSourceByUrl,
|
||||
resolveUniqueSourceFile,
|
||||
sourceDir,
|
||||
sourceIndexPath,
|
||||
type SourceIndexEntry,
|
||||
} from './source-library.js';
|
||||
|
||||
function makeWorkspace(): string {
|
||||
return fs.mkdtempSync(path.join(tmpdir(), 'maestro-source-'));
|
||||
}
|
||||
|
||||
function readIndex(workspacePath: string): SourceIndexEntry[] {
|
||||
const raw = fs.readFileSync(sourceIndexPath(workspacePath), 'utf-8');
|
||||
return raw.trim().split('\n').filter(Boolean).map((l) => JSON.parse(l) as SourceIndexEntry);
|
||||
}
|
||||
|
||||
describe('sanitizeSourceName', () => {
|
||||
it('derives a safe name from a URL with slashes and query', () => {
|
||||
const name = sanitizeSourceName('https://example.com/a/b/report.html?x=1&y=2');
|
||||
expect(name).not.toContain('/');
|
||||
expect(name).not.toContain('..');
|
||||
expect(name).not.toContain('?');
|
||||
expect(name).toContain('example.com');
|
||||
});
|
||||
|
||||
it('strips path traversal and separators from plain titles', () => {
|
||||
const name = sanitizeSourceName('../../etc/passwd');
|
||||
expect(name).not.toContain('/');
|
||||
expect(name).not.toContain('..');
|
||||
});
|
||||
|
||||
it('removes Windows-forbidden and control characters', () => {
|
||||
const name = sanitizeSourceName('a:b*c?"<>|d');
|
||||
expect(name).not.toMatch(/[:*?"<>|]/);
|
||||
});
|
||||
|
||||
it('caps length at 120 chars', () => {
|
||||
const name = sanitizeSourceName('x'.repeat(500));
|
||||
expect(name.length).toBeLessThanOrEqual(120);
|
||||
});
|
||||
|
||||
it('falls back to "source" for empty/garbage input', () => {
|
||||
expect(sanitizeSourceName('')).toBe('source');
|
||||
expect(sanitizeSourceName('///')).toBe('source');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveUniqueSourceFile', () => {
|
||||
let workspacePath = '';
|
||||
afterEach(() => {
|
||||
if (workspacePath) fs.rmSync(workspacePath, { recursive: true, force: true });
|
||||
workspacePath = '';
|
||||
});
|
||||
|
||||
it('returns the preferred name when no collision', () => {
|
||||
workspacePath = makeWorkspace();
|
||||
expect(resolveUniqueSourceFile(workspacePath, 'page', '.md')).toBe('page.md');
|
||||
});
|
||||
|
||||
it('versions with a numeric suffix on collision', () => {
|
||||
workspacePath = makeWorkspace();
|
||||
fs.mkdirSync(sourceDir(workspacePath), { recursive: true });
|
||||
fs.writeFileSync(path.join(sourceDir(workspacePath), 'page.md'), 'x');
|
||||
expect(resolveUniqueSourceFile(workspacePath, 'page', '.md')).toBe('page-1.md');
|
||||
fs.writeFileSync(path.join(sourceDir(workspacePath), 'page-1.md'), 'x');
|
||||
expect(resolveUniqueSourceFile(workspacePath, 'page', '.md')).toBe('page-2.md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendSourceIndex', () => {
|
||||
let workspacePath = '';
|
||||
afterEach(() => {
|
||||
if (workspacePath) fs.rmSync(workspacePath, { recursive: true, force: true });
|
||||
workspacePath = '';
|
||||
});
|
||||
|
||||
it('creates source/ and appends a jsonl line', () => {
|
||||
workspacePath = makeWorkspace();
|
||||
appendSourceIndex(workspacePath, {
|
||||
file: 'a.md', url: 'https://example.com/a', fetched_at: '2026-06-19T00:00:00.000Z',
|
||||
tool: 'WebFetch', bytes: 10,
|
||||
});
|
||||
const entries = readIndex(workspacePath);
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]?.url).toBe('https://example.com/a');
|
||||
expect(entries[0]?.tool).toBe('WebFetch');
|
||||
expect(entries[0]?.bytes).toBe(10);
|
||||
});
|
||||
|
||||
it('appends multiple lines', () => {
|
||||
workspacePath = makeWorkspace();
|
||||
appendSourceIndex(workspacePath, { file: 'a.md', url: 'u1', fetched_at: 't', tool: 'WebFetch', bytes: 1 });
|
||||
appendSourceIndex(workspacePath, { file: 'b.md', url: 'u2', fetched_at: 't', tool: 'DownloadFile', bytes: 2 });
|
||||
expect(readIndex(workspacePath)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSourceByUrl', () => {
|
||||
let workspacePath = '';
|
||||
afterEach(() => {
|
||||
if (workspacePath) fs.rmSync(workspacePath, { recursive: true, force: true });
|
||||
workspacePath = '';
|
||||
});
|
||||
|
||||
it('returns null when no index exists', () => {
|
||||
workspacePath = makeWorkspace();
|
||||
expect(findSourceByUrl(workspacePath, 'https://x')).toBeNull();
|
||||
});
|
||||
|
||||
it('finds an existing url and ignores corrupt lines', () => {
|
||||
workspacePath = makeWorkspace();
|
||||
fs.mkdirSync(sourceDir(workspacePath), { recursive: true });
|
||||
fs.writeFileSync(sourceIndexPath(workspacePath),
|
||||
'not-json\n' + JSON.stringify({ file: 'a.md', url: 'https://found', fetched_at: 't', tool: 'WebFetch', bytes: 1 }) + '\n');
|
||||
expect(findSourceByUrl(workspacePath, 'https://found')?.file).toBe('a.md');
|
||||
expect(findSourceByUrl(workspacePath, 'https://missing')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveSourceFile', () => {
|
||||
let workspacePath = '';
|
||||
afterEach(() => {
|
||||
if (workspacePath) fs.rmSync(workspacePath, { recursive: true, force: true });
|
||||
workspacePath = '';
|
||||
});
|
||||
|
||||
it('writes a .md under source/ and appends an index entry', () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const rel = saveSourceFile(workspacePath, {
|
||||
url: 'https://example.com/page', body: '# Hello', tool: 'WebFetch',
|
||||
});
|
||||
expect(rel).toBeTruthy();
|
||||
const saved = fs.readFileSync(path.join(sourceDir(workspacePath), rel!), 'utf-8');
|
||||
expect(saved).toBe('# Hello');
|
||||
const entries = readIndex(workspacePath);
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]?.file).toBe(rel);
|
||||
expect(entries[0]?.url).toBe('https://example.com/page');
|
||||
expect(entries[0]?.bytes).toBe(Buffer.byteLength('# Hello'));
|
||||
});
|
||||
|
||||
it('skips empty body', () => {
|
||||
workspacePath = makeWorkspace();
|
||||
expect(saveSourceFile(workspacePath, { url: 'u', body: ' ', tool: 'WebFetch' })).toBeNull();
|
||||
expect(fs.existsSync(sourceDir(workspacePath))).toBe(false);
|
||||
});
|
||||
|
||||
it('dedupes by url (skips re-save of same url)', () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const first = saveSourceFile(workspacePath, { url: 'https://dup', body: 'a', tool: 'WebFetch' });
|
||||
expect(first).toBeTruthy();
|
||||
const second = saveSourceFile(workspacePath, { url: 'https://dup', body: 'b', tool: 'WebFetch' });
|
||||
expect(second).toBeNull();
|
||||
expect(readIndex(workspacePath)).toHaveLength(1);
|
||||
// original content untouched
|
||||
expect(fs.readFileSync(path.join(sourceDir(workspacePath), first!), 'utf-8')).toBe('a');
|
||||
});
|
||||
|
||||
it('versions filename when different urls collide on the same name', () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const a = saveSourceFile(workspacePath, { url: 'https://example.com/page', body: 'a', tool: 'WebFetch' });
|
||||
const b = saveSourceFile(workspacePath, { url: 'https://example.com/page?v=2', body: 'b', tool: 'WebFetch' });
|
||||
expect(a).not.toBe(b);
|
||||
expect(readIndex(workspacePath)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('uses title for the filename when provided', () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const rel = saveSourceFile(workspacePath, {
|
||||
url: 'https://example.com/x', title: 'My Report Title', body: 'a', tool: 'WebFetch',
|
||||
});
|
||||
expect(rel).toContain('My-Report-Title');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* ソースライブラリ・ヘルパー(NotebookLM 型)
|
||||
*
|
||||
* スペースタスクのエージェント取得物(WebFetch / DownloadFile / BrowseWeb)を
|
||||
* `{workspacePath}/source/` に蓄積し、出典メタを `source/index.jsonl` に追記する。
|
||||
*
|
||||
* 注意: これはヘルパーモジュールであり、ツールではない(TOOL_DEFS を持たない)。
|
||||
* raw-save.ts と同じ位置づけ。
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { logger } from '../../logger.js';
|
||||
|
||||
/** ソースを生成したツール種別 */
|
||||
export type SourceTool = 'WebFetch' | 'DownloadFile' | 'BrowseWeb';
|
||||
|
||||
/** source/index.jsonl の 1 行 */
|
||||
export interface SourceIndexEntry {
|
||||
/** source/ 配下の相対ファイル名 */
|
||||
file: string;
|
||||
url: string;
|
||||
title?: string;
|
||||
/** ISO 8601 */
|
||||
fetched_at: string;
|
||||
tool: SourceTool;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
const INDEX_FILENAME = 'index.jsonl';
|
||||
const MAX_FILENAME_LEN = 120;
|
||||
|
||||
/** source/ ディレクトリの絶対パス */
|
||||
export function sourceDir(workspacePath: string): string {
|
||||
return path.join(workspacePath, 'source');
|
||||
}
|
||||
|
||||
/** index.jsonl の絶対パス */
|
||||
export function sourceIndexPath(workspacePath: string): string {
|
||||
return path.join(sourceDir(workspacePath), INDEX_FILENAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* URL / タイトルから安全なファイル名(拡張子なし)を生成する。
|
||||
* - パス区切り `/`, `\`, `..`, 制御文字, Windows 禁止文字を除去
|
||||
* - 長さを MAX_FILENAME_LEN に制限
|
||||
* - 空になったら 'source' にフォールバック
|
||||
*/
|
||||
export function sanitizeSourceName(input: string): string {
|
||||
let name = (input ?? '').trim();
|
||||
// URL らしき入力はパス末尾+ホストでヒントを作る
|
||||
try {
|
||||
const u = new URL(name);
|
||||
const last = u.pathname.split('/').filter(Boolean).pop() ?? '';
|
||||
name = [u.hostname, last].filter(Boolean).join('-') || u.hostname;
|
||||
} catch {
|
||||
/* URL でなければそのまま使う(タイトル等) */
|
||||
}
|
||||
// パス区切り・親参照・禁止文字・制御文字を安全な区切りに置換
|
||||
name = name
|
||||
.replace(/[\\/]+/g, '-')
|
||||
.replace(/\.\.+/g, '-')
|
||||
.replace(/[\x00-\x1f<>:"|?*]+/g, '-')
|
||||
.replace(/[\s]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^[-.]+|[-.]+$/g, '');
|
||||
if (!name) name = 'source';
|
||||
if (name.length > MAX_FILENAME_LEN) name = name.slice(0, MAX_FILENAME_LEN).replace(/[-.]+$/, '');
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 既存 index から url 済みエントリを探す(重複検出用)。
|
||||
* 読めない / 不正行はスキップ。
|
||||
*/
|
||||
export function findSourceByUrl(workspacePath: string, url: string): SourceIndexEntry | null {
|
||||
const idx = sourceIndexPath(workspacePath);
|
||||
let raw: string;
|
||||
try {
|
||||
raw = fs.readFileSync(idx, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
for (const line of raw.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const entry = JSON.parse(trimmed) as SourceIndexEntry;
|
||||
if (entry.url === url) return entry;
|
||||
} catch {
|
||||
/* 壊れた行は無視 */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** index.jsonl に 1 行追記する(source/ は mkdir -p)。 */
|
||||
export function appendSourceIndex(workspacePath: string, entry: SourceIndexEntry): void {
|
||||
try {
|
||||
fs.mkdirSync(sourceDir(workspacePath), { recursive: true });
|
||||
fs.appendFileSync(sourceIndexPath(workspacePath), `${JSON.stringify(entry)}\n`, 'utf-8');
|
||||
} catch (e) {
|
||||
logger.warn(`[source-library] failed to append index: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* source/ 配下に `preferredName{.ext}` で書き込む。既存ファイルと衝突したら
|
||||
* `-1`, `-2`, ... の数値サフィックスで版を分ける(既存を上書きしない)。
|
||||
* @returns source/ 配下からの相対ファイル名(index.jsonl に記録する値)
|
||||
*/
|
||||
export function resolveUniqueSourceFile(workspacePath: string, preferredName: string, ext: string): string {
|
||||
const dir = sourceDir(workspacePath);
|
||||
const normalizedExt = ext && !ext.startsWith('.') ? `.${ext}` : ext;
|
||||
let candidate = `${preferredName}${normalizedExt}`;
|
||||
let n = 1;
|
||||
while (fs.existsSync(path.join(dir, candidate))) {
|
||||
candidate = `${preferredName}-${n}${normalizedExt}`;
|
||||
n += 1;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* テキスト本文を source/ に保存し、index.jsonl に追記する共有ヘルパー。
|
||||
*
|
||||
* 重複ポリシー: 同一 URL のエントリが既にあれば再保存せず null を返す(skip)。
|
||||
* 異なる URL で同じファイル名になった場合のみ数値サフィックスで版分けする。
|
||||
*
|
||||
* @returns 保存した相対ファイル名。skip した場合は null。
|
||||
*/
|
||||
export function saveSourceFile(
|
||||
workspacePath: string,
|
||||
opts: {
|
||||
url: string;
|
||||
title?: string;
|
||||
body: string;
|
||||
tool: SourceTool;
|
||||
/** 拡張子('.md' / '.txt' 等)。既定 '.md' */
|
||||
ext?: string;
|
||||
/** ISO timestamp。未指定なら now */
|
||||
fetchedAt?: string;
|
||||
},
|
||||
): string | null {
|
||||
const { url, title, body, tool } = opts;
|
||||
if (!body || body.trim().length === 0) return null; // 空本文は保存しない
|
||||
|
||||
// 同一 URL は重複保存しない(最も単純で正しい挙動)
|
||||
if (findSourceByUrl(workspacePath, url)) return null;
|
||||
|
||||
const ext = opts.ext ?? '.md';
|
||||
const baseName = sanitizeSourceName(title?.trim() || url);
|
||||
const relFile = resolveUniqueSourceFile(workspacePath, baseName, ext);
|
||||
|
||||
const dir = sourceDir(workspacePath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const buf = Buffer.from(body, 'utf-8');
|
||||
fs.writeFileSync(path.join(dir, relFile), buf);
|
||||
|
||||
appendSourceIndex(workspacePath, {
|
||||
file: relFile,
|
||||
url,
|
||||
...(title ? { title } : {}),
|
||||
fetched_at: opts.fetchedAt ?? new Date().toISOString(),
|
||||
tool,
|
||||
bytes: buf.length,
|
||||
});
|
||||
|
||||
return relFile;
|
||||
}
|
||||
@@ -233,8 +233,8 @@ export async function openConsoleSession(
|
||||
let pemBuf: Buffer | null = null;
|
||||
let passBuf: Buffer | null = null;
|
||||
try {
|
||||
pemBuf = sub.decryptKeyMaterial(connection.ownerId, connection.privateKeyEnc);
|
||||
passBuf = sub.decryptPassphrase(connection.ownerId, connection.passphraseEnc);
|
||||
pemBuf = sub.decryptKeyMaterial(connection.ownerId, connection.privateKeyEnc, connection.spaceId);
|
||||
passBuf = sub.decryptPassphrase(connection.ownerId, connection.passphraseEnc, connection.spaceId);
|
||||
} catch (e) {
|
||||
if (pemBuf) clearBuffer(pemBuf);
|
||||
if (passBuf) clearBuffer(passBuf);
|
||||
|
||||
@@ -149,6 +149,7 @@ async function ctxWithWorkspace(opts: {
|
||||
pieceName?: string;
|
||||
allowed?: string[];
|
||||
jobId?: string;
|
||||
spaceId?: string | null;
|
||||
}): Promise<ToolContext> {
|
||||
const workspace = opts.workspace ?? (await fsp.mkdtemp(path.join(os.tmpdir(), 'ssh-tool-')));
|
||||
return {
|
||||
@@ -159,6 +160,7 @@ async function ctxWithWorkspace(opts: {
|
||||
pieceName: opts.pieceName ?? 'ops',
|
||||
allowedSshConnections: opts.allowed,
|
||||
jobId: opts.jobId ?? 'job1',
|
||||
spaceId: opts.spaceId ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -295,6 +297,59 @@ describe('engine/tools/ssh — preflight (piece + access + state)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('engine/tools/ssh — per-space isolation (spec §11)', () => {
|
||||
let db: Database.Database;
|
||||
let sub: SshSubsystem;
|
||||
let repos: ReturnType<typeof makeSubsystem>['repos'];
|
||||
let connA: string;
|
||||
let connB: string;
|
||||
|
||||
function verifyHostKey(id: string): void {
|
||||
db.prepare(
|
||||
`UPDATE ssh_connections SET host_key_b64=?, host_key_fingerprint=?, host_key_verified_at=? WHERE id=?`,
|
||||
).run('hostb64', 'SHA256:host', new Date().toISOString(), id);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
db = bootstrapDb();
|
||||
({ sub, repos } = makeSubsystem({ db }));
|
||||
setSshSubsystem(sub);
|
||||
// alice owns BOTH connections, but they live in different spaces.
|
||||
const a = repos.conn.create(baseConnInput({ ownerId: 'alice', label: 'srvA', spaceId: 'space-A' }));
|
||||
const b = repos.conn.create(baseConnInput({ ownerId: 'alice', label: 'srvB', spaceId: 'space-B' }));
|
||||
verifyHostKey(a.id);
|
||||
verifyHostKey(b.id);
|
||||
connA = a.id;
|
||||
connB = b.id;
|
||||
});
|
||||
|
||||
afterEach(() => setSshSubsystem(null));
|
||||
|
||||
it('a job in space A can use the space-A connection', async () => {
|
||||
const ctx = await ctxWithWorkspace({ allowed: ['*'], spaceId: 'space-A' });
|
||||
const r = await executeTool('SshExec', { connection_id: connA, command: 'ls' }, ctx);
|
||||
expect(r.isError).toBe(false);
|
||||
});
|
||||
|
||||
it('a job in space A CANNOT use a space-B connection even though the user owns it', async () => {
|
||||
const ctx = await ctxWithWorkspace({ allowed: ['*'], spaceId: 'space-A' });
|
||||
const r = await executeTool('SshExec', { connection_id: connB, command: 'ls' }, ctx);
|
||||
expect(r.isError).toBe(true);
|
||||
expect(r.output).toMatch(/access denied \(space_mismatch\)/);
|
||||
});
|
||||
|
||||
it('SshListConnections in space A lists only the space-A connection', async () => {
|
||||
const ctx = await ctxWithWorkspace({ allowed: ['*'], spaceId: 'space-A' });
|
||||
const r = await executeTool('SshListConnections', {}, ctx);
|
||||
expect(r.isError).toBe(false);
|
||||
const parsed = JSON.parse(r.output) as { connections?: Array<{ id: string; label: string }> };
|
||||
const list = parsed.connections ?? [];
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].id).toBe(connA);
|
||||
expect(list.some((c) => c.id === connB)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('engine/tools/ssh SshExec', () => {
|
||||
let db: Database.Database;
|
||||
let sub: SshSubsystem;
|
||||
|
||||
+24
-9
@@ -69,8 +69,8 @@ export interface SshSubsystem {
|
||||
auditRepo: SshAuditRepo;
|
||||
abuseRepo: SshAbuseRepo;
|
||||
accessResolver: SshAccessResolver;
|
||||
decryptKeyMaterial(ownerId: string | null, blob: Buffer): Buffer;
|
||||
decryptPassphrase(ownerId: string | null, blob: Buffer | null): Buffer | null;
|
||||
decryptKeyMaterial(ownerId: string | null, blob: Buffer, spaceId?: string | null): Buffer;
|
||||
decryptPassphrase(ownerId: string | null, blob: Buffer | null, spaceId?: string | null): Buffer | null;
|
||||
/** Resolve admin role + org membership for an authenticated user id. */
|
||||
getUserAccess(userId: string): { isAdmin: boolean; orgIds: string[] };
|
||||
sshExec(args: ExecArgs, hooks: SessionHooks): Promise<ExecResult>;
|
||||
@@ -454,6 +454,10 @@ function preflight(args: PreflightArgs): PreflightResult {
|
||||
isAdmin: access.isAdmin,
|
||||
pieceName: pieceName ?? '',
|
||||
orgIds: access.orgIds,
|
||||
// Per-space isolation (spec §11): the job's space gates use of a
|
||||
// space-scoped connection. A job in space A cannot use a space-B
|
||||
// connection even if the acting user owns it.
|
||||
spaceId: ctx.spaceId ?? null,
|
||||
});
|
||||
if (!decision.allowed) {
|
||||
sub.auditRepo.beginAndComplete(
|
||||
@@ -594,8 +598,8 @@ async function runExec(
|
||||
let passBuf: Buffer | null = null;
|
||||
try {
|
||||
try {
|
||||
pemBuf = sub.decryptKeyMaterial(connection.ownerId, connection.privateKeyEnc);
|
||||
passBuf = sub.decryptPassphrase(connection.ownerId, connection.passphraseEnc);
|
||||
pemBuf = sub.decryptKeyMaterial(connection.ownerId, connection.privateKeyEnc, connection.spaceId);
|
||||
passBuf = sub.decryptPassphrase(connection.ownerId, connection.passphraseEnc, connection.spaceId);
|
||||
} catch (e) {
|
||||
sub.auditRepo.complete(auditId, 'failed', {
|
||||
error: 'decrypt_failed',
|
||||
@@ -764,8 +768,8 @@ async function runUpload(
|
||||
let passBuf: Buffer | null = null;
|
||||
try {
|
||||
try {
|
||||
pemBuf = sub.decryptKeyMaterial(connection.ownerId, connection.privateKeyEnc);
|
||||
passBuf = sub.decryptPassphrase(connection.ownerId, connection.passphraseEnc);
|
||||
pemBuf = sub.decryptKeyMaterial(connection.ownerId, connection.privateKeyEnc, connection.spaceId);
|
||||
passBuf = sub.decryptPassphrase(connection.ownerId, connection.passphraseEnc, connection.spaceId);
|
||||
} catch (e) {
|
||||
sub.auditRepo.complete(auditId, 'failed', {
|
||||
error: 'decrypt_failed',
|
||||
@@ -920,8 +924,8 @@ async function runDownload(
|
||||
let passBuf: Buffer | null = null;
|
||||
try {
|
||||
try {
|
||||
pemBuf = sub.decryptKeyMaterial(connection.ownerId, connection.privateKeyEnc);
|
||||
passBuf = sub.decryptPassphrase(connection.ownerId, connection.passphraseEnc);
|
||||
pemBuf = sub.decryptKeyMaterial(connection.ownerId, connection.privateKeyEnc, connection.spaceId);
|
||||
passBuf = sub.decryptPassphrase(connection.ownerId, connection.passphraseEnc, connection.spaceId);
|
||||
} catch (e) {
|
||||
sub.auditRepo.complete(auditId, 'failed', {
|
||||
error: 'decrypt_failed',
|
||||
@@ -1009,13 +1013,23 @@ async function runListConnections(
|
||||
|
||||
const wildcard = allowed.length === 1 && allowed[0] === '*';
|
||||
const access = sub.getUserAccess(actingUserId);
|
||||
const jobSpaceId = ctx.spaceId ?? null;
|
||||
|
||||
// Per-space isolation (spec §11): the agent's candidate set is scoped to the
|
||||
// job's space. A space-scoped job sees ONLY its space's connections
|
||||
// (listBySpace); a no-space (legacy) job falls back to the full set so the
|
||||
// access gate can apply the legacy owner/admin/grant rules to space-less
|
||||
// connections. resolveAccess re-enforces the space rule per row regardless.
|
||||
const candidates = jobSpaceId !== null
|
||||
? sub.connectionRepo.listBySpace(jobSpaceId)
|
||||
: sub.connectionRepo.listAll();
|
||||
|
||||
// Filter chain: enabled + piece-membership + access-grant. We deliberately
|
||||
// skip the abuse lock here — a temporarily-locked connection is still a
|
||||
// valid choice once the lock expires, and surfacing it lets the LLM tell
|
||||
// the user "this one is locked until X" instead of going silent.
|
||||
const visible: Array<Record<string, unknown>> = [];
|
||||
for (const conn of sub.connectionRepo.listAll()) {
|
||||
for (const conn of candidates) {
|
||||
if (!conn.enabled || conn.disabledByAdmin) continue;
|
||||
if (!wildcard && !allowed.includes(conn.id)) continue;
|
||||
const decision = sub.accessResolver.resolveAccess({
|
||||
@@ -1024,6 +1038,7 @@ async function runListConnections(
|
||||
isAdmin: access.isAdmin,
|
||||
pieceName: ctx.pieceName ?? '',
|
||||
orgIds: access.orgIds,
|
||||
spaceId: jobSpaceId,
|
||||
});
|
||||
if (!decision.allowed) continue;
|
||||
visible.push({
|
||||
|
||||
@@ -24,7 +24,7 @@ const block = (refId: string, overrides: Partial<StructuredBlock> = {}): Structu
|
||||
|
||||
describe('saveStructuredBlocks', () => {
|
||||
it('writes one JSON file per block under logs/structured', () => {
|
||||
saveStructuredBlocks(ws, [block('ref-1'), block('ref-2', { type: 'map_places' })]);
|
||||
saveStructuredBlocks(join(ws, 'logs'), [block('ref-1'), block('ref-2', { type: 'map_places' })]);
|
||||
const dir = join(ws, 'logs', 'structured');
|
||||
expect(readdirSync(dir).sort()).toEqual(['ref-1.json', 'ref-2.json']);
|
||||
const parsed = JSON.parse(readFileSync(join(dir, 'ref-1.json'), 'utf-8')) as StructuredBlock;
|
||||
@@ -34,12 +34,12 @@ describe('saveStructuredBlocks', () => {
|
||||
});
|
||||
|
||||
it('does nothing for an empty block list', () => {
|
||||
saveStructuredBlocks(ws, []);
|
||||
saveStructuredBlocks(join(ws, 'logs'), []);
|
||||
expect(existsSync(join(ws, 'logs', 'structured'))).toBe(false);
|
||||
});
|
||||
|
||||
it('round-trips unicode data intact', () => {
|
||||
saveStructuredBlocks(ws, [block('jp', { title: '日本語タイトル', data: { text: '絵文字🎉' } })]);
|
||||
saveStructuredBlocks(join(ws, 'logs'), [block('jp', { title: '日本語タイトル', data: { text: '絵文字🎉' } })]);
|
||||
const parsed = JSON.parse(
|
||||
readFileSync(join(ws, 'logs', 'structured', 'jp.json'), 'utf-8'),
|
||||
) as StructuredBlock;
|
||||
@@ -50,7 +50,7 @@ describe('saveStructuredBlocks', () => {
|
||||
it('swallows write failures instead of throwing', () => {
|
||||
// Point the workspace at a path whose parent is a regular file.
|
||||
const bogus = join(ws, 'not-a-dir');
|
||||
saveStructuredBlocks(ws, [block('pre')]); // creates logs/structured
|
||||
saveStructuredBlocks(join(ws, 'logs'), [block('pre')]); // creates logs/structured
|
||||
expect(() =>
|
||||
saveStructuredBlocks(join(ws, 'logs', 'structured', 'pre.json'), [block('x')]),
|
||||
).not.toThrow();
|
||||
|
||||
@@ -82,10 +82,10 @@ export interface YouTubeVideosData {
|
||||
/**
|
||||
* structuredBlocks を logs/structured/ に保存する。
|
||||
*/
|
||||
export function saveStructuredBlocks(workspacePath: string, blocks: StructuredBlock[]): void {
|
||||
export function saveStructuredBlocks(logsRoot: string, blocks: StructuredBlock[]): void {
|
||||
if (!blocks.length) return;
|
||||
try {
|
||||
const dir = join(workspacePath, 'logs', 'structured');
|
||||
const dir = join(logsRoot, 'structured');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
for (const block of blocks) {
|
||||
const filePath = join(dir, `${block.refId}.json`);
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { executeTool } from './tool-request.js';
|
||||
import type { ToolContext } from './core.js';
|
||||
|
||||
function ctx(overrides: Partial<ToolContext> = {}): ToolContext {
|
||||
return { workspacePath: '/tmp/x', editAllowed: false, ...overrides } as ToolContext;
|
||||
}
|
||||
|
||||
describe('RequestTool', () => {
|
||||
it('returns null for non-RequestTool names', async () => {
|
||||
expect(await executeTool('Read', {}, ctx())).toBeNull();
|
||||
});
|
||||
|
||||
it('requires name and reason', async () => {
|
||||
expect((await executeTool('RequestTool', { reason: 'x' }, ctx()))?.isError).toBe(true);
|
||||
expect((await executeTool('RequestTool', { name: 'Bash' }, ctx()))?.isError).toBe(true);
|
||||
});
|
||||
|
||||
it('already-available tool → not recorded, told to just call it', async () => {
|
||||
const rec = vi.fn();
|
||||
const r = await executeTool(
|
||||
'RequestTool',
|
||||
{ name: 'Bash', reason: 'r' },
|
||||
ctx({ effectiveToolNames: ['Bash'], recordToolRequest: rec }),
|
||||
);
|
||||
expect(r?.isError).toBe(false);
|
||||
expect(r?.output).toContain('既に利用可能');
|
||||
expect(rec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('exists in catalog but not allowed here → recorded as requested', async () => {
|
||||
const rec = vi.fn();
|
||||
await executeTool(
|
||||
'RequestTool',
|
||||
{ name: 'WebSearch', reason: 'need search' },
|
||||
ctx({ effectiveToolNames: ['Read'], knownToolNames: ['Read', 'WebSearch'], recordToolRequest: rec }),
|
||||
);
|
||||
expect(rec).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ toolName: 'WebSearch', category: 'requested', reason: 'need search' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('mcp__ tool not in catalog → still recorded as requested (cannot enumerate MCP)', async () => {
|
||||
const rec = vi.fn();
|
||||
await executeTool(
|
||||
'RequestTool',
|
||||
{ name: 'mcp__foo__bar', reason: 'r' },
|
||||
ctx({ effectiveToolNames: [], knownToolNames: [], recordToolRequest: rec }),
|
||||
);
|
||||
expect(rec).toHaveBeenCalledWith(expect.objectContaining({ category: 'requested' }));
|
||||
});
|
||||
|
||||
it('not in catalog → recorded as unknown with a clear message', async () => {
|
||||
const rec = vi.fn();
|
||||
const r = await executeTool(
|
||||
'RequestTool',
|
||||
{ name: 'NotARealTool', reason: 'r' },
|
||||
ctx({ effectiveToolNames: [], knownToolNames: ['Read'], recordToolRequest: rec }),
|
||||
);
|
||||
expect(rec).toHaveBeenCalledWith(expect.objectContaining({ toolName: 'NotARealTool', category: 'unknown' }));
|
||||
expect(r?.output).toContain('存在しません');
|
||||
});
|
||||
|
||||
it('interactive run + not-allowed → records pending and sets the pause flag', async () => {
|
||||
const rec = vi.fn();
|
||||
const c = ctx({ effectiveToolNames: ['Read'], knownToolNames: ['Read', 'WebSearch'], recordToolRequest: rec, toolApprovalInteractive: true });
|
||||
const r = await executeTool('RequestTool', { name: 'WebSearch', reason: 'need it' }, c);
|
||||
expect(rec).toHaveBeenCalledWith(expect.objectContaining({ toolName: 'WebSearch', category: 'requested', status: 'pending' }));
|
||||
expect(c.pendingToolApproval).toEqual({ toolName: 'WebSearch' });
|
||||
expect(r?.output).toContain('承認');
|
||||
});
|
||||
|
||||
it('non-interactive run + not-allowed → auto_denied, no pause flag', async () => {
|
||||
const rec = vi.fn();
|
||||
const c = ctx({ effectiveToolNames: ['Read'], knownToolNames: ['Read', 'WebSearch'], recordToolRequest: rec });
|
||||
await executeTool('RequestTool', { name: 'WebSearch', reason: 'r' }, c);
|
||||
expect(rec).toHaveBeenCalledWith(expect.objectContaining({ status: 'auto_denied' }));
|
||||
expect(c.pendingToolApproval).toBeFalsy();
|
||||
});
|
||||
|
||||
it('never throws when no recorder is wired', async () => {
|
||||
const r = await executeTool('RequestTool', { name: 'WebSearch', reason: 'r' }, ctx({ knownToolNames: ['WebSearch'] }));
|
||||
expect(r?.isError).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* RequestTool — META_TOOL for the tool-request mechanism.
|
||||
*
|
||||
* When an agent realises it needs a tool that this movement does not expose,
|
||||
* it calls RequestTool({ name, reason }). The request is recorded so the gap
|
||||
* becomes visible in the task detail and per-piece aggregation, letting the
|
||||
* piece author add the tool to `allowed_tools` / `shared_tools`. This PR
|
||||
* (recording infra) does NOT grant the tool — it records and guides. The
|
||||
* inline approval/grant flow is layered on top in a later PR.
|
||||
*
|
||||
* Classification (recorded as `category`):
|
||||
* - already_available → the tool IS exposed here; just call it (not recorded).
|
||||
* - available_not_allowed (`requested`) → exists in the catalog but not in
|
||||
* this movement → recorded as a genuine omission candidate.
|
||||
* - unknown → not in the catalog (possible hallucination / capability gap).
|
||||
*
|
||||
* Plumbing: ToolContext carries `effectiveToolNames` (this movement's set),
|
||||
* `knownToolNames` (full catalog, injected by the dispatcher), and
|
||||
* `recordToolRequest` (bound to task/job/space by the worker).
|
||||
*/
|
||||
|
||||
import { ToolDef } from '../../llm/openai-compat.js';
|
||||
import type { ToolContext, ToolResult } from './core.js';
|
||||
|
||||
const REQUEST_TOOL_DEF: ToolDef = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'RequestTool',
|
||||
description:
|
||||
'この movement に無いツールがどうしても必要なときに要求する。name と理由(reason)を渡すと記録され、ピース作者が allowed_tools / shared_tools に追加できる。要求しても今すぐ使えるようにはならない — 無しで進めるか、無理なら complete({status:"needs_user_input"}) でユーザーに依頼すること。詳細は ReadToolDoc({ name: "RequestTool" })。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: '必要なツール名(例: WebSearch, Bash)。' },
|
||||
reason: { type: 'string', description: 'なぜそのツールが必要か(具体的に)。' },
|
||||
},
|
||||
required: ['name', 'reason'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const TOOL_DEFS: Record<string, ToolDef> = {
|
||||
RequestTool: REQUEST_TOOL_DEF,
|
||||
};
|
||||
|
||||
export async function executeTool(
|
||||
name: string,
|
||||
input: Record<string, unknown>,
|
||||
ctx: ToolContext,
|
||||
): Promise<ToolResult | null> {
|
||||
if (name !== 'RequestTool') return null;
|
||||
|
||||
const toolName = typeof input['name'] === 'string' ? input['name'].trim() : '';
|
||||
const reason = typeof input['reason'] === 'string' ? input['reason'].trim() : '';
|
||||
if (!toolName) return { output: 'RequestTool: name は必須です。', isError: true };
|
||||
if (!reason) return { output: 'RequestTool: reason(なぜ必要か)は必須です。', isError: true };
|
||||
|
||||
// Already available in THIS movement → nothing to record, just tell it to call.
|
||||
const effective = ctx.effectiveToolNames ?? [];
|
||||
if (effective.includes(toolName)) {
|
||||
return {
|
||||
output: `"${toolName}" はこの movement で既に利用可能です。そのまま呼び出してください。`,
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Classify against the full catalog. MCP tools (mcp__*) can't be cheaply
|
||||
// enumerated, so treat them as "exists but not allowed here".
|
||||
const known = ctx.knownToolNames ?? [];
|
||||
const isMcp = toolName.startsWith('mcp__');
|
||||
const category: 'requested' | 'unknown' = known.includes(toolName) || isMcp ? 'requested' : 'unknown';
|
||||
|
||||
if (category === 'unknown') {
|
||||
ctx.recordToolRequest?.({ toolName, reason, category: 'unknown' });
|
||||
return {
|
||||
output:
|
||||
`"${toolName}" というツールは存在しません(要求は記録しました)。利用可能なツールで進めてください。` +
|
||||
` 何が使えるかは ReadToolDoc などで確認できます。`,
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
|
||||
// available_not_allowed. If this run can reach a user, pause for inline
|
||||
// approval (blocking permission-prompt). Otherwise record auto_denied and
|
||||
// let the agent proceed without it (the user finds the request later).
|
||||
if (ctx.toolApprovalInteractive) {
|
||||
ctx.recordToolRequest?.({ toolName, reason, category: 'requested', status: 'pending' });
|
||||
ctx.pendingToolApproval = { toolName };
|
||||
return {
|
||||
output:
|
||||
`"${toolName}" の利用にはユーザーの承認が必要です。承認/拒否を待っています。` +
|
||||
` 承認されればこのまま続行でき、拒否されればそのツール無しで進めることになります。`,
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
ctx.recordToolRequest?.({ toolName, reason, category: 'requested', status: 'auto_denied' });
|
||||
return {
|
||||
output:
|
||||
`"${toolName}" はこの movement では許可されていないため使えません(要求を記録しました)。` +
|
||||
` このツール無しで進めるか、達成できない場合は complete({status:"needs_user_input", missing_info:"..."}) でユーザーに依頼してください。` +
|
||||
` ピースの allowed_tools / shared_tools に追加すると次回から使えます。`,
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
@@ -888,3 +888,112 @@ describe('ReadUserAgents / UpdateUserAgents', () => {
|
||||
expect(TOOL_DEFS['UpdateUserAgents']).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── spaces foundation: AGENTS.md / memory は folderContext を尊重する ──────────
|
||||
// バグ回帰: ケーススペースで作業中、AGENTS.md / memory が個人ユーザーフォルダ
|
||||
// (userFolderRoot/userId)に書かれてしまい、スペースの AGENTS.md を編集できなかった。
|
||||
// folderContext が在れば (rootDir, leafId) を使い、無ければ従来の個人フォルダ。
|
||||
describe('user-folder tools honor ctx.folderContext (space resolution)', () => {
|
||||
let spacesRoot: string;
|
||||
const SPACE_ID = 'space-xyz';
|
||||
|
||||
beforeEach(() => {
|
||||
spacesRoot = join(tempDir, 'spaces');
|
||||
mkdirSync(join(spacesRoot, SPACE_ID, 'memory'), { recursive: true });
|
||||
mkdirSync(join(spacesRoot, SPACE_ID, 'trash'), { recursive: true });
|
||||
});
|
||||
|
||||
const spaceCtx = () =>
|
||||
buildCtx({ userId: TEST_USER, folderContext: { rootDir: spacesRoot, leafId: SPACE_ID } });
|
||||
|
||||
it('UpdateUserAgents writes to the SPACE folder, not the personal user folder', async () => {
|
||||
const res = await executeTool(
|
||||
'UpdateUserAgents',
|
||||
{ mode: 'append', new_text: '# Space rules\nuse the space brief' },
|
||||
spaceCtx(),
|
||||
);
|
||||
expect(res.isError).toBe(false);
|
||||
// Written under spaces/<id>/AGENTS.md ...
|
||||
const spaceAgents = join(spacesRoot, SPACE_ID, 'AGENTS.md');
|
||||
expect(readFileSync(spaceAgents, 'utf-8')).toContain('use the space brief');
|
||||
// ... and NOT under the personal user folder.
|
||||
expect(() => readFileSync(join(userFolderRoot, TEST_USER, 'AGENTS.md'), 'utf-8')).toThrow();
|
||||
});
|
||||
|
||||
it('ReadUserAgents reads the SPACE AGENTS.md', async () => {
|
||||
writeFileSync(join(spacesRoot, SPACE_ID, 'AGENTS.md'), 'SPACE-ONLY-MARKER');
|
||||
// personal one would mislead if resolution were wrong
|
||||
writeFileSync(join(userFolderRoot, TEST_USER, 'AGENTS.md'), 'PERSONAL-MARKER');
|
||||
const res = await executeTool('ReadUserAgents', {}, spaceCtx());
|
||||
expect(res.output).toContain('SPACE-ONLY-MARKER');
|
||||
expect(res.output).not.toContain('PERSONAL-MARKER');
|
||||
});
|
||||
|
||||
it('UpdateUserMemory / ReadUserMemory target the SPACE memory dir', async () => {
|
||||
const up = await executeTool(
|
||||
'UpdateUserMemory',
|
||||
{ action: 'upsert', name: 'sp-note', type: 'project', description: 'd', body: 'space body' },
|
||||
spaceCtx(),
|
||||
);
|
||||
expect(up.isError).toBe(false);
|
||||
expect(readFileSync(join(spacesRoot, SPACE_ID, 'memory', 'sp-note.md'), 'utf-8')).toContain('space body');
|
||||
// Not in the personal memory dir.
|
||||
expect(() => readFileSync(join(userFolderRoot, TEST_USER, 'memory', 'sp-note.md'), 'utf-8')).toThrow();
|
||||
// And readable back through the space context.
|
||||
const rd = await executeTool('ReadUserMemory', { name: 'sp-note' }, spaceCtx());
|
||||
expect(rd.isError).toBe(false);
|
||||
expect(rd.output).toContain('space body');
|
||||
});
|
||||
|
||||
it('without folderContext, still writes to the personal user folder (legacy)', async () => {
|
||||
const res = await executeTool(
|
||||
'UpdateUserAgents',
|
||||
{ mode: 'append', new_text: 'personal rule' },
|
||||
buildCtx({ userId: TEST_USER }),
|
||||
);
|
||||
expect(res.isError).toBe(false);
|
||||
expect(readFileSync(join(userFolderRoot, TEST_USER, 'AGENTS.md'), 'utf-8')).toContain('personal rule');
|
||||
});
|
||||
|
||||
// ── workstream 2: browser-macros / recordings honor folderContext ──────────
|
||||
it('WriteUserScript writes the macro into the SPACE browser-macros/, not personal', async () => {
|
||||
mkdirSync(join(spacesRoot, SPACE_ID, 'browser-macros'), { recursive: true });
|
||||
const res = await executeTool(
|
||||
'WriteUserScript',
|
||||
{ name: 'space-macro', content: 'async function main() { return 1; }' },
|
||||
spaceCtx(),
|
||||
);
|
||||
expect(res.isError).toBe(false);
|
||||
expect(readFileSync(join(spacesRoot, SPACE_ID, 'browser-macros', 'space-macro.js'), 'utf-8')).toContain('main');
|
||||
// NOT in the personal folder.
|
||||
expect(() => readFileSync(join(userFolderRoot, TEST_USER, 'browser-macros', 'space-macro.js'), 'utf-8')).toThrow();
|
||||
});
|
||||
|
||||
it('RunUserScript resolves the macro from the SPACE folder', async () => {
|
||||
mkdirSync(join(spacesRoot, SPACE_ID, 'browser-macros'), { recursive: true });
|
||||
writeFileSync(join(spacesRoot, SPACE_ID, 'browser-macros', 'sp-run.js'), SCRIPT_NO_FM);
|
||||
const res = await executeTool('RunUserScript', { name: 'sp-run' }, spaceCtx());
|
||||
expect(res.isError).toBe(false);
|
||||
expect(res.output).toContain('42');
|
||||
});
|
||||
|
||||
it('ListUserAssets lists the SPACE browser-macros, not the personal ones', async () => {
|
||||
mkdirSync(join(spacesRoot, SPACE_ID, 'browser-macros'), { recursive: true });
|
||||
writeFileSync(join(spacesRoot, SPACE_ID, 'browser-macros', 'sp-macro.js'), SCRIPT_BAR);
|
||||
writeFileSync(join(userFolderRoot, TEST_USER, 'browser-macros', 'personal-macro.js'), SCRIPT_FOO);
|
||||
const res = await executeTool('ListUserAssets', { kind: 'browser-macros' }, spaceCtx());
|
||||
expect(res.isError).toBe(false);
|
||||
expect(res.output).toContain('sp-macro.js');
|
||||
expect(res.output).not.toContain('personal-macro.js');
|
||||
});
|
||||
|
||||
it('without folderContext, WriteUserScript still targets the personal folder', async () => {
|
||||
const res = await executeTool(
|
||||
'WriteUserScript',
|
||||
{ name: 'personal-macro', content: 'async function main() { return 2; }' },
|
||||
buildCtx({ userId: TEST_USER }),
|
||||
);
|
||||
expect(res.isError).toBe(false);
|
||||
expect(readFileSync(join(userFolderRoot, TEST_USER, 'browser-macros', 'personal-macro.js'), 'utf-8')).toContain('main');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,6 +62,23 @@ function getUserFolderRoot(): string {
|
||||
return _deps?.userFolderRoot ?? './data/users';
|
||||
}
|
||||
|
||||
/**
|
||||
* AGENTS.md / memory が対象とすべきフォルダ `(root, leaf)` を返す。
|
||||
*
|
||||
* spaces foundation: ジョブがスペースに属するとき worker が `ctx.folderContext`
|
||||
* (`{rootDir, leafId}`)を配線する。ケーススペースなら `(spacesRoot, spaceId)`、
|
||||
* 個人スペースなら `(userFolderRoot, ownerId)`。これを尊重しないと、ケース
|
||||
* スペースで作業中でも個人ユーザーの AGENTS.md / memory を読み書きしてしまう
|
||||
* (prompt 注入は folderContext を見るのにツールは見ていなかった不整合)。
|
||||
* folderContext 不在(gitea-issue / legacy)のときは従来どおり個人フォルダ。
|
||||
*/
|
||||
function folderTarget(ctx: ToolContext): { root: string; leaf: string } {
|
||||
if (ctx.folderContext) {
|
||||
return { root: ctx.folderContext.rootDir, leaf: ctx.folderContext.leafId };
|
||||
}
|
||||
return { root: getUserFolderRoot(), leaf: ctx.userId as string };
|
||||
}
|
||||
|
||||
// ── Tool definitions ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Regex for valid memory entry names: alphanumeric, dash, underscore; no extension. */
|
||||
@@ -274,10 +291,10 @@ async function executeUpdateUserMemory(
|
||||
};
|
||||
}
|
||||
|
||||
const folderRoot = getUserFolderRoot();
|
||||
const { root: folderRoot, leaf } = folderTarget(ctx);
|
||||
|
||||
if (action === 'delete') {
|
||||
const deleted = deleteMemoryEntry(folderRoot, ctx.userId, name);
|
||||
const deleted = deleteMemoryEntry(folderRoot, leaf, name);
|
||||
if (!deleted) {
|
||||
return { output: `UpdateUserMemory: entry "${name}" not found`, isError: true };
|
||||
}
|
||||
@@ -304,7 +321,7 @@ async function executeUpdateUserMemory(
|
||||
}
|
||||
|
||||
try {
|
||||
upsertMemoryEntry(folderRoot, ctx.userId, {
|
||||
upsertMemoryEntry(folderRoot, leaf, {
|
||||
name,
|
||||
type: rawType as MemoryType,
|
||||
description: description.trim(),
|
||||
@@ -337,8 +354,8 @@ async function executeReadUserMemory(
|
||||
};
|
||||
}
|
||||
|
||||
const folderRoot = getUserFolderRoot();
|
||||
const entry = readMemoryEntry(folderRoot, ctx.userId, name);
|
||||
const { root: folderRoot, leaf } = folderTarget(ctx);
|
||||
const entry = readMemoryEntry(folderRoot, leaf, name);
|
||||
if (!entry) {
|
||||
return { output: `ReadUserMemory: entry "${name}" not found`, isError: true };
|
||||
}
|
||||
@@ -363,7 +380,8 @@ async function executeReadUserAgents(
|
||||
if (!ctx.userId) {
|
||||
return { output: 'ReadUserAgents requires an authenticated user', isError: true };
|
||||
}
|
||||
const content = readUserAgentsMdRaw(getUserFolderRoot(), ctx.userId);
|
||||
const { root, leaf } = folderTarget(ctx);
|
||||
const content = readUserAgentsMdRaw(root, leaf);
|
||||
if (content === null || content.length === 0) {
|
||||
return {
|
||||
output:
|
||||
@@ -394,8 +412,8 @@ async function executeUpdateUserAgents(
|
||||
return { output: 'UpdateUserAgents: "new_text" is required', isError: true };
|
||||
}
|
||||
|
||||
const folderRoot = getUserFolderRoot();
|
||||
const current = readUserAgentsMdRaw(folderRoot, ctx.userId) ?? '';
|
||||
const { root: folderRoot, leaf } = folderTarget(ctx);
|
||||
const current = readUserAgentsMdRaw(folderRoot, leaf) ?? '';
|
||||
|
||||
let next: string;
|
||||
if (mode === 'replace') {
|
||||
@@ -436,20 +454,20 @@ async function executeUpdateUserAgents(
|
||||
// Snapshot the prior version before overwriting (best-effort; missing/empty → null).
|
||||
let backup: string | null = null;
|
||||
try {
|
||||
backup = snapshotUserAgentsMd(folderRoot, ctx.userId);
|
||||
backup = snapshotUserAgentsMd(folderRoot, leaf);
|
||||
} catch {
|
||||
/* snapshot is best-effort; proceed with the write */
|
||||
}
|
||||
|
||||
try {
|
||||
writeUserAgentsMd(folderRoot, ctx.userId, next);
|
||||
writeUserAgentsMd(folderRoot, leaf, next);
|
||||
} catch (err) {
|
||||
return { output: `UpdateUserAgents: ${(err as Error).message}`, isError: true };
|
||||
}
|
||||
|
||||
_deps?.auditLog?.(
|
||||
'user_agents_updated',
|
||||
{ userId: ctx.userId, mode, bytes: Buffer.byteLength(next, 'utf-8'), snapshotted: backup !== null },
|
||||
{ userId: ctx.userId, target: leaf, mode, bytes: Buffer.byteLength(next, 'utf-8'), snapshotted: backup !== null },
|
||||
ctx.taskId ?? null,
|
||||
);
|
||||
|
||||
@@ -469,12 +487,13 @@ async function executeListUserAssets(
|
||||
return { output: 'ListUserAssets requires an authenticated user', isError: true };
|
||||
}
|
||||
|
||||
// owner is always ctx.userId — folder is per-user, no cross-user param
|
||||
// Space-aware: when running inside a space (ctx.folderContext set), browse the
|
||||
// space's browser-macros / recordings; otherwise the personal folder.
|
||||
const kind = (input['kind'] as string | undefined) ?? 'all';
|
||||
const folderRoot = getUserFolderRoot();
|
||||
const userDir = userRoot(folderRoot, ctx.userId);
|
||||
const { root: folderRoot, leaf } = folderTarget(ctx);
|
||||
const userDir = userRoot(folderRoot, leaf);
|
||||
|
||||
const lines: string[] = [`User folder for ${ctx.userId}:`];
|
||||
const lines: string[] = [`User folder for ${leaf}:`];
|
||||
|
||||
// Browser Macros (Playwright runtime)
|
||||
if (kind === 'browser-macros' || kind === 'all') {
|
||||
@@ -602,9 +621,13 @@ async function executeRunUserScript(
|
||||
|
||||
const scriptBaseName = (rawName.endsWith('.js') ? rawName : `${rawName}.js`).replace(/\.js$/, '');
|
||||
|
||||
// Space-aware folder resolution: a macro saved while working in a space lives
|
||||
// under that space's browser-macros/; folderTarget falls back to the personal
|
||||
// folder when ctx.folderContext is absent.
|
||||
const { root: scriptRoot, leaf: scriptLeaf } = folderTarget(ctx);
|
||||
const result = await resolveAndRunUserScript({
|
||||
rootDir: getUserFolderRoot(),
|
||||
userId: ctx.userId,
|
||||
rootDir: scriptRoot,
|
||||
userId: scriptLeaf,
|
||||
name: rawName,
|
||||
params,
|
||||
kind,
|
||||
@@ -724,12 +747,14 @@ async function executeWriteUserScript(
|
||||
};
|
||||
}
|
||||
|
||||
const userFolderRoot = getUserFolderRoot();
|
||||
// Space-aware: write the macro into the active space's browser-macros/ when
|
||||
// ctx.folderContext is set, else the personal folder.
|
||||
const { root: writeRoot, leaf: writeLeaf } = folderTarget(ctx);
|
||||
const subdir = 'browser-macros';
|
||||
|
||||
let targetPath: string;
|
||||
try {
|
||||
targetPath = resolveUserSubdir(userFolderRoot, ctx.userId, subdir, `${baseName}.js`);
|
||||
targetPath = resolveUserSubdir(writeRoot, writeLeaf, subdir, `${baseName}.js`);
|
||||
} catch (err) {
|
||||
return { output: `WriteUserScript: ${(err as Error).message}`, isError: true };
|
||||
}
|
||||
|
||||
@@ -162,6 +162,193 @@ describe('web tools', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('source library routing', () => {
|
||||
let workspacePath = '';
|
||||
|
||||
afterEach(() => {
|
||||
if (workspacePath) {
|
||||
fs.rmSync(workspacePath, { recursive: true, force: true });
|
||||
workspacePath = '';
|
||||
}
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('DownloadFile section=source saves under source/ and appends index.jsonl', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('PNGDATA', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'image/png' },
|
||||
})));
|
||||
|
||||
const result = await executeTool(
|
||||
'DownloadFile',
|
||||
{ url: 'https://example.com/chart.png', filename: 'chart.png', section: 'source' },
|
||||
makeContext(workspacePath),
|
||||
);
|
||||
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(fs.existsSync(path.join(workspacePath, 'source', 'chart.png'))).toBe(true);
|
||||
const idx = fs.readFileSync(path.join(workspacePath, 'source', 'index.jsonl'), 'utf-8')
|
||||
.trim().split('\n').map((l) => JSON.parse(l) as Record<string, unknown>);
|
||||
expect(idx).toHaveLength(1);
|
||||
expect(idx[0]?.url).toBe('https://example.com/chart.png');
|
||||
expect(idx[0]?.tool).toBe('DownloadFile');
|
||||
expect(idx[0]?.file).toBe('chart.png');
|
||||
expect(idx[0]?.bytes).toBe(Buffer.byteLength('PNGDATA'));
|
||||
});
|
||||
|
||||
it('DownloadFile section=input does NOT write source/index.jsonl', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('DATA', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/octet-stream' },
|
||||
})));
|
||||
|
||||
const result = await executeTool(
|
||||
'DownloadFile',
|
||||
{ url: 'https://example.com/file.bin', filename: 'file.bin' },
|
||||
makeContext(workspacePath),
|
||||
);
|
||||
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(fs.existsSync(path.join(workspacePath, 'input', 'file.bin'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(workspacePath, 'source', 'index.jsonl'))).toBe(false);
|
||||
});
|
||||
|
||||
it('WebFetch persists source .md + index entry when ctx.spaceId is set', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(
|
||||
'<html><body><h1>Title</h1><p>Body content here</p></body></html>',
|
||||
{ status: 200, headers: { 'content-type': 'text/html; charset=utf-8' } },
|
||||
)));
|
||||
|
||||
const ctx: ToolContext = {
|
||||
workspacePath,
|
||||
editAllowed: false,
|
||||
spaceId: 'space-123',
|
||||
toolsConfig: { webfetchAllowedHosts: ['example.com'] },
|
||||
};
|
||||
const result = await executeTool('WebFetch', { url: 'https://example.com/article' }, ctx);
|
||||
|
||||
expect(result?.isError).toBe(false);
|
||||
const idx = fs.readFileSync(path.join(workspacePath, 'source', 'index.jsonl'), 'utf-8')
|
||||
.trim().split('\n').map((l) => JSON.parse(l) as Record<string, unknown>);
|
||||
expect(idx).toHaveLength(1);
|
||||
expect(idx[0]?.url).toBe('https://example.com/article');
|
||||
expect(idx[0]?.tool).toBe('WebFetch');
|
||||
const savedFile = idx[0]?.file as string;
|
||||
expect(savedFile.endsWith('.md')).toBe(true);
|
||||
const body = fs.readFileSync(path.join(workspacePath, 'source', savedFile), 'utf-8');
|
||||
expect(body).toContain('Body content here');
|
||||
});
|
||||
|
||||
it('WebFetch dedupes the same URL within a space (no second source file)', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(
|
||||
'<html><body><p>Stuff</p></body></html>',
|
||||
{ status: 200, headers: { 'content-type': 'text/html' } },
|
||||
)));
|
||||
|
||||
const ctx: ToolContext = {
|
||||
workspacePath,
|
||||
editAllowed: false,
|
||||
spaceId: 'space-1',
|
||||
toolsConfig: { webfetchAllowedHosts: ['example.com'] },
|
||||
};
|
||||
await executeTool('WebFetch', { url: 'https://example.com/dup' }, ctx);
|
||||
await executeTool('WebFetch', { url: 'https://example.com/dup' }, ctx);
|
||||
|
||||
const idx = fs.readFileSync(path.join(workspacePath, 'source', 'index.jsonl'), 'utf-8')
|
||||
.trim().split('\n').filter(Boolean);
|
||||
expect(idx).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('WebFetch does NOT create source/ when ctx.spaceId is undefined (legacy)', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(
|
||||
'<html><body><p>Non-space content</p></body></html>',
|
||||
{ status: 200, headers: { 'content-type': 'text/html' } },
|
||||
)));
|
||||
|
||||
const result = await executeTool('WebFetch', { url: 'https://example.com/page' }, makeContext(workspacePath));
|
||||
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(result?.output).toContain('Non-space content');
|
||||
expect(fs.existsSync(path.join(workspacePath, 'source'))).toBe(false);
|
||||
});
|
||||
|
||||
it('WebFetch saves FULL clean markdown to source/ but returns TRUNCATED text to the agent', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
// 本文を 10k 文字を大きく超える長さにして、source(全文) と agent(切り詰め) の差を作る。
|
||||
const longBody = 'word '.repeat(6000); // ~30k chars
|
||||
const html = `<html><head><title>Long Article</title></head><body>
|
||||
<nav>NAV MENU LINKS</nav>
|
||||
<article><h1>Long Article Heading</h1><p>${longBody}</p></article>
|
||||
<footer>FOOTER JUNK</footer>
|
||||
</body></html>`;
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(
|
||||
html, { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' } },
|
||||
)));
|
||||
|
||||
const ctx: ToolContext = {
|
||||
workspacePath,
|
||||
editAllowed: false,
|
||||
spaceId: 'space-long',
|
||||
toolsConfig: { webfetchAllowedHosts: ['example.com'] },
|
||||
};
|
||||
const result = await executeTool('WebFetch', { url: 'https://example.com/long' }, ctx);
|
||||
expect(result?.isError).toBe(false);
|
||||
|
||||
// agent への返却は切り詰め + source 案内ノート
|
||||
expect(result!.output.length).toBeLessThan(11000);
|
||||
expect(result!.output).toContain('full version saved to source/');
|
||||
// クリーン抽出されているので boilerplate は出ない
|
||||
expect(result!.output).not.toContain('NAV MENU LINKS');
|
||||
expect(result!.output).not.toContain('FOOTER JUNK');
|
||||
|
||||
// source/ には全文(agent 返却より長い)が保存される
|
||||
const idx = fs.readFileSync(path.join(workspacePath, 'source', 'index.jsonl'), 'utf-8')
|
||||
.trim().split('\n').map((l) => JSON.parse(l) as Record<string, unknown>);
|
||||
expect(idx).toHaveLength(1);
|
||||
const savedFile = idx[0]?.file as string;
|
||||
const saved = fs.readFileSync(path.join(workspacePath, 'source', savedFile), 'utf-8');
|
||||
expect(saved.length).toBeGreaterThan(20000);
|
||||
expect(saved.length).toBeGreaterThan(result!.output.length);
|
||||
expect(saved).not.toContain('NAV MENU LINKS');
|
||||
});
|
||||
|
||||
it('WebFetch (non-space) returns clean truncated text without a source note', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
const longBody = 'lorem '.repeat(6000);
|
||||
const html = `<html><head><title>NS</title></head><body>
|
||||
<article><h1>Heading</h1><p>${longBody}</p></article></body></html>`;
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(
|
||||
html, { status: 200, headers: { 'content-type': 'text/html' } },
|
||||
)));
|
||||
|
||||
const result = await executeTool('WebFetch', { url: 'https://example.com/ns' }, makeContext(workspacePath));
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(result!.output.length).toBeLessThan(11000);
|
||||
expect(result!.output).toContain('... (truncated)');
|
||||
expect(result!.output).not.toContain('saved to source');
|
||||
expect(fs.existsSync(path.join(workspacePath, 'source'))).toBe(false);
|
||||
});
|
||||
|
||||
it('WebFetch falls back to naive extraction on non-article HTML (never hard-fails)', async () => {
|
||||
workspacePath = makeWorkspace();
|
||||
// readability が本文を見つけられない最小 HTML
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(
|
||||
'<html><body>bare text with no article structure</body></html>',
|
||||
{ status: 200, headers: { 'content-type': 'text/html' } },
|
||||
)));
|
||||
|
||||
const result = await executeTool('WebFetch', { url: 'https://example.com/bare' }, makeContext(workspacePath));
|
||||
expect(result?.isError).toBe(false);
|
||||
expect(result!.output).toContain('bare text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSearchResultsFromText', () => {
|
||||
it('extracts URLs with surrounding text', () => {
|
||||
const text = [
|
||||
|
||||
+81
-9
@@ -5,6 +5,7 @@ import { logger } from '../../logger.js';
|
||||
import type { SearchFilterConfig } from '../../config.js';
|
||||
import { checkSSRF, ssrfSafeFetch } from './shared/ssrf.js';
|
||||
import { htmlToText } from './shared/html.js';
|
||||
import { extractReadableMarkdown, truncateForAgent } from './shared/readability.js';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as crypto from 'crypto';
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
SNIFF_HEAD_BYTES,
|
||||
type BinaryVerdict,
|
||||
} from './binary-detect.js';
|
||||
import { saveSourceFile, appendSourceIndex } from './source-library.js';
|
||||
|
||||
const BINARY_CONTENT_TYPE_PREFIXES = [
|
||||
'application/pdf',
|
||||
@@ -29,6 +31,14 @@ const BINARY_CONTENT_TYPE_PREFIXES = [
|
||||
// WebFetch text body は最大 5MB で打ち切る(巨大 HTML による context 膨張防止)
|
||||
const MAX_WEBFETCH_BODY_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
// agent のコンテキストに返すクリーン Markdown の上限文字数。
|
||||
// 全文は source/ に保存されるので、ここは context 節約のための切り詰め。
|
||||
const MAX_AGENT_RETURN_CHARS = 10000;
|
||||
|
||||
// source/ に保存するクリーン Markdown の上限バイト数(病的に巨大なページ対策)。
|
||||
// 5MB body cap が入力を制限済みだが、Markdown 化後にも保険をかける。
|
||||
const MAX_SOURCE_MARKDOWN_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
// --- ツール定義 ---
|
||||
|
||||
const WEBSEARCH_DEF: ToolDef = {
|
||||
@@ -67,13 +77,13 @@ const DOWNLOADFILE_DEF: ToolDef = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'DownloadFile',
|
||||
description: 'URL からファイル(PDF・画像・バイナリ等)をダウンロードし、workspace の input/ または output/ に保存する。WebFetch では取得できない PDF や画像ファイルに使う。詳細・命名規約は ReadToolDoc({ name: "DownloadFile" })。',
|
||||
description: 'URL からファイル(PDF・画像・バイナリ等)をダウンロードし、workspace の input/・output/・source/(調査資料をスペースのソースライブラリに蓄積)に保存する。詳細・命名規約は ReadToolDoc({ name: "DownloadFile" })。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: { type: 'string', description: 'ダウンロードする URL' },
|
||||
filename: { type: 'string', description: '保存するファイル名(省略時は URL から推定)' },
|
||||
section: { type: 'string', description: '保存先: "input" または "output" (デフォルト: "input")' },
|
||||
section: { type: 'string', description: '保存先: "input" / "output" / "source"(調査資料の蓄積用)(デフォルト: "input")' },
|
||||
},
|
||||
required: ['url'],
|
||||
},
|
||||
@@ -810,8 +820,8 @@ async function executeWebFetch(
|
||||
ctx: ToolContext,
|
||||
): Promise<ToolResult> {
|
||||
const rawUrl = input['url'] as string;
|
||||
// selector パラメータは受け取るが、cheerio なしでは CSS セレクト不可のため無視
|
||||
// (仕様上 selector は省略可能で、対応できない旨をログに残す)
|
||||
// selector 指定時は、linkedom + querySelector でそのサブツリーだけをクリーン抽出する。
|
||||
// 要素が見つからなければ全ページの readability 抽出にフォールバックする。
|
||||
const selector = input['selector'] as string | undefined;
|
||||
|
||||
const timeoutSec = ctx.toolsConfig?.webfetchTimeout ?? 30;
|
||||
@@ -854,7 +864,7 @@ async function executeWebFetch(
|
||||
}
|
||||
|
||||
if (selector) {
|
||||
logger.debug(`[WebFetch] selector "${selector}" is specified but CSS selection is not supported without cheerio — fetching full page`);
|
||||
logger.debug(`[WebFetch] selector "${selector}" specified — will clean-extract that subtree (falls back to full page if not found)`);
|
||||
}
|
||||
|
||||
logger.debug(`[WebFetch] fetching ${rawUrl}`);
|
||||
@@ -932,9 +942,59 @@ async function executeWebFetch(
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
const text =
|
||||
htmlToText(sniffed.text) +
|
||||
(sniffed.truncated ? '\n\n[truncated: body exceeded 5MB]' : '');
|
||||
// Readability ベースのクリーン抽出を試みる。記事本文だけを構造保持で
|
||||
// Markdown 化する。失敗(非記事・SPA・空)したら従来の素朴な htmlToText に
|
||||
// フォールバックして WebFetch を絶対に hard-fail させない。
|
||||
const bodyTruncatedNote = sniffed.truncated ? '\n\n[truncated: body exceeded 5MB]' : '';
|
||||
let extracted = extractReadableMarkdown(sniffed.text, {
|
||||
baseUrl: rawUrl,
|
||||
...(selector ? { selector } : {}),
|
||||
});
|
||||
// selector が見つからなかった場合は、ページ全体の readability 抽出を試す。
|
||||
if (!extracted && selector) {
|
||||
extracted = extractReadableMarkdown(sniffed.text, { baseUrl: rawUrl });
|
||||
}
|
||||
|
||||
let fullClean: string;
|
||||
let extractionTitle: string | undefined;
|
||||
if (extracted) {
|
||||
fullClean = extracted.markdown;
|
||||
extractionTitle = extracted.title;
|
||||
} else {
|
||||
// フォールバック: 旧 htmlToText(内部で 10k 切り詰め済み)
|
||||
logger.debug(`[WebFetch] readability extraction empty, falling back to naive htmlToText for ${rawUrl}`);
|
||||
fullClean = htmlToText(sniffed.text);
|
||||
}
|
||||
fullClean = (fullClean + bodyTruncatedNote).trim();
|
||||
|
||||
// source 保存用は全文だが、病的に巨大なページに備えて上限でガードする。
|
||||
// 5MB body cap が入力を既に制限しているが、Markdown 化後にも保険をかける。
|
||||
let sourceBody = fullClean;
|
||||
if (Buffer.byteLength(sourceBody, 'utf-8') > MAX_SOURCE_MARKDOWN_BYTES) {
|
||||
const sliced = sourceBody.slice(0, MAX_SOURCE_MARKDOWN_BYTES);
|
||||
sourceBody = sliced + '\n\n... (truncated: extracted markdown exceeded source size cap)';
|
||||
}
|
||||
|
||||
// スペース文脈 (ctx.spaceId != null) なら全文クリーン Markdown を source/ に蓄積する。
|
||||
// ephemeral / 非スペースタスク(spaceId undefined)は従来どおり保存しない。
|
||||
// 既存の logs/raw への保存挙動は別経路(raw-save.ts)なので変更しない。
|
||||
let savedSourceFile: string | null = null;
|
||||
if (ctx.spaceId != null && sourceBody.trim().length > 0) {
|
||||
try {
|
||||
savedSourceFile = saveSourceFile(ctx.workspacePath, {
|
||||
url: rawUrl,
|
||||
...(extractionTitle ? { title: extractionTitle } : {}),
|
||||
body: sourceBody,
|
||||
tool: 'WebFetch',
|
||||
ext: '.md',
|
||||
});
|
||||
} catch (e) {
|
||||
logger.warn(`[WebFetch] failed to persist source: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// agent のコンテキストに返すのは切り詰めた版。全文は source/ に保存済み。
|
||||
const text = truncateForAgent(fullClean, MAX_AGENT_RETURN_CHARS, savedSourceFile);
|
||||
|
||||
// vlmEnabled 時はファーストビューのスクショを並行取得して画像を添付する。
|
||||
// 失敗時は警告ログのみで WebFetch 自体は成功扱いとする。
|
||||
@@ -982,7 +1042,7 @@ async function executeDownloadFile(
|
||||
): Promise<ToolResult> {
|
||||
const rawUrl = input['url'] as string;
|
||||
const rawSection = typeof input['section'] === 'string' ? input['section'] : 'input';
|
||||
const section = rawSection === 'output' ? 'output' : 'input';
|
||||
const section = rawSection === 'output' || rawSection === 'source' ? rawSection : 'input';
|
||||
const timeoutSec = ctx.toolsConfig?.webfetchTimeout ?? 60;
|
||||
const allowedHosts = ctx.toolsConfig?.webfetchAllowedHosts ?? [];
|
||||
|
||||
@@ -1062,6 +1122,18 @@ async function executeDownloadFile(
|
||||
bytes: buf.length, outcome: 'success',
|
||||
});
|
||||
|
||||
// section='source' のとき出典メタを source/index.jsonl に追記する。
|
||||
// (ファイルはすでに source/ 配下に保存済みなので、ここでは index 行だけ追記)
|
||||
if (section === 'source') {
|
||||
appendSourceIndex(ctx.workspacePath, {
|
||||
file: filename,
|
||||
url: rawUrl,
|
||||
fetched_at: new Date().toISOString(),
|
||||
tool: 'DownloadFile',
|
||||
bytes: buf.length,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
output: `Downloaded ${buf.length} bytes from ${rawUrl} -> ${section}/${filename} (content-type: ${contentType || 'unknown'})`,
|
||||
isError: false,
|
||||
|
||||
Reference in New Issue
Block a user