sync: update from private repo (2336949a)
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
oss-sync
2026-09-17 11:29:31 +00:00
parent 28f74c4e37
commit 57702b4f34
2139 changed files with 321270 additions and 17494 deletions
@@ -0,0 +1,8 @@
# Excel integration fixtures
Excel再計算の成立性テストは、結合セル・通常数式・shared formulaを含む最小ブックを
excel-recalculate.test.ts 内でExcelJSから生成します。バイナリfixtureを追跡しないため、
テストの前提と生成内容をソース上で確認できます。
LibreOffice実機ケースは RUN_LIBREOFFICE_TESTS=1 のとき必須です。このモードでは
soffice、Calc、Python UNOの不足をskipせず、テスト失敗として扱います。
+1 -1
View File
@@ -1,5 +1,5 @@
import { ToolDef } from '../../llm/openai-compat.js';
import type { ToolContext, ToolResult } from './core.js';
import type { ToolContext, ToolResult } from './tool-context.js';
import { logger } from '../../logger.js';
import type { StructuredBlock, AmazonProductItem } from './structured-blocks.js';
+159 -2
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'fs';
import { join } from 'path';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, statSync } from 'fs';
import { join, resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { tmpdir } from 'os';
import Database from 'better-sqlite3';
import { TOOL_DEFS, executeTool, resolveAppDocPath, setAppDocsDeps } from './app-docs.js';
@@ -184,6 +185,162 @@ describe('app-docs: ListAppDocs', () => {
});
});
// ── 利用者向けヘルプ (ui/src/content/help/*.md) ────────────────────────────────
// 既存テストと同じく「実物のリポジトリのファイル」を相手にする。
const HELP_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'ui', 'src', 'content', 'help');
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
describe('app-docs: help docs (ui/src/content/help)', () => {
it('Given ヘルプの md 名 / When resolveAppDocPath / Then help ディレクトリ配下に解決する', () => {
const r = resolveAppDocPath('help/01-intro');
expect(r).not.toBeNull();
expect(r!.label).toBe('ui/src/content/help/01-intro.md');
expect(r!.path).toBe(join(HELP_DIR, '01-intro.md'));
});
it('Given .md 付きの名前 / When resolveAppDocPath / Then 拡張子を二重に付けない', () => {
const r = resolveAppDocPath('help/01-intro.md');
expect(r).not.toBeNull();
expect(r!.path).toBe(join(HELP_DIR, '01-intro.md'));
expect(r!.path).not.toMatch(/\.md\.md$/);
});
it('Given 実在するヘルプ / When ReadAppDoc / Then 本文が読める', async () => {
const res = await executeTool('ReadAppDoc', { name: 'help/01-intro' }, baseCtx);
expect(res).not.toBeNull();
expect(res!.isError).toBe(false);
expect(res!.output).toContain('ui/src/content/help/01-intro.md');
// frontmatter と本文の両方が返る
expect(res!.output).toContain('id: intro');
expect(res!.output).toContain('MAESTRO とは');
});
it('Given 定期実行のヘルプ / When ReadAppDoc / Then 製品機能の説明が引ける', async () => {
const res = await executeTool('ReadAppDoc', { name: 'help/06-schedules' }, baseCtx);
expect(res!.isError).toBe(false);
expect(res!.output).toContain('id: schedules');
});
it('Given 存在しないヘルプ名 / When ReadAppDoc / Then エラーになる', async () => {
const res = await executeTool('ReadAppDoc', { name: 'help/no-such-help-doc' }, baseCtx);
expect(res!.isError).toBe(true);
expect(res!.output).toContain('存在しません');
});
it('Given ListAppDocs / When 呼ぶ / Then 利用者向けヘルプの区分に frontmatter の title で並ぶ', async () => {
const res = await executeTool('ListAppDocs', {}, baseCtx);
expect(res!.isError).toBe(false);
const out = res!.output;
expect(out).toContain('# 利用者向けヘルプ');
expect(out).toContain('`help/01-intro`');
expect(out).toContain('`help/06-schedules`');
// 表示名は frontmatter の title(ファイル名ではない)
expect(out).toContain('`help/01-intro` — MAESTRO とは / できること');
expect(out).toContain('`help/00-changelog` — 更新履歴(新着情報)');
// ヘルプディレクトリに紛れている .md 以外(テストファイル)は列挙しない
expect(out).not.toContain('browser-session-window');
});
it('Given TOOL_DEFS / When ReadAppDoc の説明を見る / Then help/ を引けることが LLM に伝わる', () => {
// description は毎 LLM 呼び出しに乗る唯一の案内。ここに help/ が無いと、
// 実装が読めてもエージェントは help を引こうとしない。
const fn = TOOL_DEFS.ReadAppDoc!.function;
expect(fn.description).toContain('help/');
expect(JSON.stringify(fn.parameters)).toContain('help/');
});
it('Given .. を含む名前 / When resolveAppDocPath / Then 拒否する(脱出不可)', () => {
expect(resolveAppDocPath('help/../../CLAUDE.md')).toBeNull();
expect(resolveAppDocPath('help/../../../etc/passwd')).toBeNull();
expect(resolveAppDocPath('help/..')).toBeNull();
expect(resolveAppDocPath('help/../src/config')).toBeNull();
});
it('Given 区切りや不正文字を含む名前 / When resolveAppDocPath / Then 拒否する', () => {
expect(resolveAppDocPath('help/')).toBeNull();
expect(resolveAppDocPath('help/sub/dir')).toBeNull();
expect(resolveAppDocPath('help/with space')).toBeNull();
expect(resolveAppDocPath('help/$shell')).toBeNull();
});
it('Given .. を含む名前 / When ReadAppDoc / Then エラーを返し中身は漏らさない', async () => {
const res = await executeTool('ReadAppDoc', { name: 'help/../../CLAUDE.md' }, baseCtx);
expect(res!.isError).toBe(true);
expect(res!.output).toContain('不正な name');
});
describe('打ち切り (ヘルプは 64KB)', () => {
// 追跡下に一時ファイルを書かない。更新履歴 (00-changelog.md) が実物として
// 十分に大きいので、それを相手に打ち切りを検証する。
// (Ctrl-C でテストが落ちたときにフィクスチャが残り、本番 UI に
// 「テスト用の一時ファイル」というヘルプが載る事故を避けるため)
const changelogPath = join(HELP_DIR, '00-changelog.md');
it('前提: 00-changelog.md は 64KB を超えている (この describe が空振りしない保証)', () => {
expect(statSync(changelogPath).size).toBeGreaterThan(64 * 1024);
});
it('Given 64KB 超のヘルプ / When ReadAppDoc / Then 64KB で打ち切られて注記が付く', async () => {
const res = await executeTool('ReadAppDoc', { name: 'help/00-changelog' }, baseCtx);
expect(res!.isError).toBe(false);
// 注記に載る「捨てたバイト数」で上限を見分ける。出力全体のバイト数だと
// ラベル行と注記の分 (約 410 バイト) が乗るので、32KB 打ち切りでも
// 32768 を超えてしまい、上限を 32KB に戻しても緑のまま通ってしまう。
const omitted = /\[truncated: original was \d+ bytes; (\d+) bytes omitted\]/.exec(
res!.output,
);
expect(omitted).not.toBeNull();
const changelogSize = statSync(changelogPath).size;
// 64KB 打ち切りなら捨てる量は (原本 - 64KB) 前後。32KB に戻すと
// (原本 - 32KB) になり、この上限を必ず超える。
expect(Number(omitted![1])).toBeLessThan(changelogSize - 48 * 1024);
// 本文が 64KB に収まっていること (ラベル行と注記の分だけ超える)
expect(Buffer.byteLength(res!.output, 'utf-8')).toBeLessThan(64 * 1024 + 1024);
});
it('Given 打ち切られたヘルプ / When ReadAppDoc / Then 利用者への案内まで書いてある', async () => {
// 「切られた」だけでは次の行動が決まらない。読めなかった残りを
// 利用者にどう案内すればよいかまで、注記に書いてあること。
const res = await executeTool('ReadAppDoc', { name: 'help/00-changelog' }, baseCtx);
expect(res!.output).toContain('アプリ内のヘルプ');
// どこを開けばよいか (画面上の位置) まで書いてある
expect(res!.output).toContain('画面上部の「ヘルプ」');
expect(res!.output).toContain('利用者に案内してください');
// frontmatter の title を案内に含める (どのページを開けばよいか分かる)
expect(res!.output).toContain('更新履歴(新着情報)');
});
it('Given ヘルプ以外の長い doc / When ReadAppDoc / Then 32KB のまま (上限を分けている)', async () => {
// ヘルプだけ 64KB に上げた。docs/ 側は据え置き。docs/ssh.md は 32KB 超
// 64KB 未満なので、ここが緑なら「ヘルプ専用の上限」になっている。
const sshSize = statSync(resolve(REPO_ROOT, 'docs', 'ssh.md')).size;
expect(sshSize).toBeGreaterThan(32 * 1024);
expect(sshSize).toBeLessThan(64 * 1024);
const res = await executeTool('ReadAppDoc', { name: 'docs/ssh' }, baseCtx);
expect(res!.isError).toBe(false);
expect(res!.output).toContain('[truncated: original was');
expect(Buffer.byteLength(res!.output, 'utf-8')).toBeLessThan(32 * 1024 + 1024);
});
});
});
describe('app-docs: help docs の配布 (Dockerfile)', () => {
it('Dockerfile の runtime stage が ui/src/content/help/*.md をイメージへ COPY する', () => {
// ヘルプの Markdown 原本は ui/ の中にあるが、UI のビルド成果物 (ui/dist) には
// バンドル後の形でしか入らない。ReadAppDoc / ListAppDocs は実行時に raw な
// .md を読むので、runtime stage でこのディレクトリを COPY しないと
// 「本番だけヘルプが 1 本も引けない」状態になる (テストは全部緑のまま)。
const dockerfile = readFileSync(resolve(REPO_ROOT, 'Dockerfile'), 'utf8');
// builder stage だけに在っても意味がないので、最後の FROM 以降で確認する。
const stages = dockerfile.split(/^FROM /m);
const runtimeStage = stages[stages.length - 1]!;
expect(runtimeStage).toMatch(
/COPY\s+ui\/src\/content\/help\/\*\.md\s+\.\/ui\/src\/content\/help\//,
);
});
});
describe('app-docs: GetMyOrchestratorState', () => {
let tmpDir: string;
let db: Database.Database;
+120 -18
View File
@@ -6,7 +6,7 @@
// - GetMyOrchestratorState() : 呼び出しユーザーの sanitized なスナップショット
//
// セキュリティ:
// - REPO_ROOT / DOCS_DIR / PIECES_DIR の allow-list でしかファイルを開かない
// - REPO_ROOT / DOCS_DIR / PIECES_DIR / HELP_DIR の allow-list でしかファイルを開かない
// - path.resolve した結果がいずれかの allow-list 配下であることを必ず確認
// - 秘密情報 (OAuth client secret, static token, encrypted blob) は GetMyOrchestratorState で
// 一切返さない
@@ -17,7 +17,7 @@ import { fileURLToPath } from 'url';
import { parse as parseYaml } from 'yaml';
import type Database from 'better-sqlite3';
import { ToolDef } from '../../llm/openai-compat.js';
import type { ToolContext, ToolResult } from './core.js';
import type { ToolContext, ToolResult } from './tool-context.js';
import { logger } from '../../logger.js';
// ── Paths ─────────────────────────────────────────────────────────────────────
@@ -29,10 +29,18 @@ const REPO_ROOT = resolve(__dirname, '..', '..', '..');
const DOCS_DIR = join(REPO_ROOT, 'docs');
const PIECES_DIR = join(REPO_ROOT, 'pieces');
const TOOLS_DOCS_DIR = join(DOCS_DIR, 'tools');
// アプリ内ヘルプ (利用者が Help パネルで読むのと同じ Markdown)。
// UI 側は vite の import.meta.glob でバンドルするので、ここが唯一の raw な置き場。
// Dockerfile の runtime stage でも COPY している (無いと本番だけ黙って空になる)。
const HELP_DIR = join(REPO_ROOT, 'ui', 'src', 'content', 'help');
// ── Validation ────────────────────────────────────────────────────────────────
const NAME_REGEX = /^[a-zA-Z0-9_\-/.]+$/;
const MAX_BYTES = 32 * 1024; // 32 KB cap per doc to keep tokens bounded
// 1 doc あたりの打ち切り上限。docs/ / pieces/ / tool/ は 32KB のまま。
// アプリ内ヘルプだけ 64KB にしてある: 利用者向けヘルプは 1 本が長く(管理・設定は
// 45KB 超)、このツールには範囲指定が無いので 32KB だと後半を一生読めないため。
const MAX_BYTES = 32 * 1024;
const HELP_MAX_BYTES = 64 * 1024;
// ── Injected deps (server.ts calls setAppDocsDeps) ───────────────────────────
interface AppDocsDeps {
@@ -62,8 +70,8 @@ export const TOOL_DEFS: Record<string, ToolDef> = {
function: {
name: 'ReadAppDoc',
description:
'MAESTRO のプロジェクト内ドキュメント (docs/ / pieces/) を symbolic name で読む。'
+ ' Help アシスタントが概念や操作手順を答える前のリファレンス参照に使う。'
'MAESTRO のプロジェクト内ドキュメントを symbolic name で読む'
+ ' (製品の機能・使い方は "help/<name>" で利用者向けヘルプ、ほかに "docs/<path>" / "piece/<name>" / "tool/<name>")。'
+ ' 詳細は ReadToolDoc({ name: "ReadAppDoc" })。',
parameters: {
type: 'object',
@@ -71,7 +79,7 @@ export const TOOL_DEFS: Record<string, ToolDef> = {
name: {
type: 'string',
description:
'Symbolic name. 例: "docs/mcp" / "docs/architecture" / "piece/chat" / "tool/browseweb"',
'Symbolic name. 例: "help/06-schedules" / "docs/mcp" / "piece/chat" / "tool/browseweb"',
},
},
required: ['name'],
@@ -83,7 +91,7 @@ export const TOOL_DEFS: Record<string, ToolDef> = {
function: {
name: 'ListAppDocs',
description:
'MAESTRO のプロジェクト内ドキュメント一覧を category 別に返す (docs/ / pieces/ / tool docs)。'
'MAESTRO のプロジェクト内ドキュメント一覧を category 別に返す (利用者向けヘルプ / pieces/ / docs/ / tool docs)。'
+ ' 質問に答える前に関連 doc を探すために使う。'
+ ' 詳細は ReadToolDoc({ name: "ListAppDocs" })。',
parameters: {
@@ -116,6 +124,8 @@ export const TOOL_DEFS: Record<string, ToolDef> = {
interface ResolvedDoc {
path: string;
label: string;
/** どの allow-list root から解決したか。打ち切り上限と注記の文面を分けるのに使う。 */
kind: 'help' | 'piece' | 'tool' | 'docs';
}
/**
@@ -179,7 +189,20 @@ export function resolveAppDocPath(name: string): ResolvedDoc | null {
if (!/^[a-zA-Z0-9_-]+$/.test(piece)) return null;
const path = join(PIECES_DIR, `${piece}.yaml`);
if (!isUnderRoot(path, PIECES_DIR)) return null;
return { path, label: `pieces/${piece}.yaml` };
return { path, label: `pieces/${piece}.yaml`, kind: 'piece' };
}
// 利用者向けヘルプ (ui/src/content/help/*.md)
// 役割 (admin / 一般) では絞らない。ヘルプは公開文書で、OSS ミラーが同じファイルを
// そのまま配っており秘密は入っていない。UI 側 (HelpPage.tsx) の admin 区分は画面の
// 整理であって秘匿ではないので、ここで再現する必要は無い。
if (name.startsWith('help/')) {
const doc = name.slice('help/'.length).replace(/\.md$/, '');
if (!doc || doc.includes('/')) return null;
if (!/^[a-zA-Z0-9_-]+$/.test(doc)) return null;
const path = join(HELP_DIR, `${doc}.md`);
if (!isUnderRoot(path, HELP_DIR)) return null;
return { path, label: `ui/src/content/help/${doc}.md`, kind: 'help' };
}
// Tool docs (alias to ReadToolDoc behavior)
@@ -189,7 +212,7 @@ export function resolveAppDocPath(name: string): ResolvedDoc | null {
if (!/^[a-z0-9_-]+$/.test(tool)) return null;
const path = join(TOOLS_DOCS_DIR, `${tool}.md`);
if (!isUnderRoot(path, TOOLS_DOCS_DIR)) return null;
return { path, label: `docs/tools/${tool}.md` };
return { path, label: `docs/tools/${tool}.md`, kind: 'tool' };
}
// docs/* (auto-append .md if missing)
@@ -202,7 +225,7 @@ export function resolveAppDocPath(name: string): ResolvedDoc | null {
// Reject blocked internal subpaths
const relFromDocs = relative(DOCS_DIR, resolvedPath);
if (isBlockedDocsSubpath(relFromDocs)) return null;
return { path: resolvedPath, label: `docs/${rel.replace(/\.md$/, '')}` };
return { path: resolvedPath, label: `docs/${rel.replace(/\.md$/, '')}`, kind: 'docs' };
}
return null;
@@ -216,6 +239,21 @@ function isUnderRoot(absolutePath: string, rootDir: string): boolean {
// ── ReadAppDoc implementation ────────────────────────────────────────────────
/**
* 打ち切ったときに末尾へ足す案内。バイト数だけ伝えても次の行動が決まらないので、
* 「エージェントが利用者に何と言えばよいか」まで書く。ヘルプは画面で全文が読めるため、
* 続きは利用者にアプリ内ヘルプを開いてもらうのがいちばん確実な逃げ道になる。
*/
function truncationGuidance(resolved: ResolvedDoc, body: string): string {
if (resolved.kind !== 'help') {
return '\n注記: この先は読めていません。答えるときは読めた範囲だけを根拠にしてください。';
}
const title = parseFrontmatterTitle(body);
const where = title ? `「${title}」のページ` : '該当ページ';
return '\n注記: このヘルプは長いため後半が読めていません。'
+ `読めた範囲で答えつつ、「続きはアプリ内のヘルプ(画面上部の「ヘルプ」→ ${where})を開いて確認してください」と利用者に案内してください。`;
}
async function executeReadAppDoc(
input: Record<string, unknown>,
_ctx: ToolContext,
@@ -230,7 +268,7 @@ async function executeReadAppDoc(
return {
output:
`ReadAppDoc error: 不正な name "${name}"。`
+ ' 有効な形式: "docs/<path>" / "piece/<name>" / "tool/<name>"。'
+ ' 有効な形式: "docs/<path>" / "piece/<name>" / "tool/<name>" / "help/<name>"。'
+ ' ListAppDocs() で利用可能な doc 一覧を取得できます。',
isError: true,
};
@@ -255,23 +293,27 @@ async function executeReadAppDoc(
return { output: `ReadAppDoc error: ${resolved.label} はファイルではありません`, isError: true };
}
const maxBytes = resolved.kind === 'help' ? HELP_MAX_BYTES : MAX_BYTES;
let raw: string;
try {
if (stat.size <= MAX_BYTES) {
if (stat.size <= maxBytes) {
raw = readFileSync(resolved.path, 'utf-8');
} else {
// Truncate at MAX_BYTES, walk back to a UTF-8 codepoint boundary
const buf = Buffer.alloc(MAX_BYTES);
// Truncate at maxBytes, walk back to a UTF-8 codepoint boundary
const buf = Buffer.alloc(maxBytes);
const fd = (await import('fs')).openSync(resolved.path, 'r');
try {
(await import('fs')).readSync(fd, buf, 0, MAX_BYTES, 0);
(await import('fs')).readSync(fd, buf, 0, maxBytes, 0);
} finally {
(await import('fs')).closeSync(fd);
}
let safe = buf.length;
while (safe > 0 && (buf[safe - 1]! & 0xc0) === 0x80) safe--;
raw = buf.subarray(0, safe).toString('utf-8')
+ `\n\n[truncated: original was ${stat.size} bytes; ${stat.size - safe} bytes omitted]`;
const body = buf.subarray(0, safe).toString('utf-8');
raw = body
+ `\n\n[truncated: original was ${stat.size} bytes; ${stat.size - safe} bytes omitted]`
+ truncationGuidance(resolved, body);
}
} catch (e) {
return { output: `ReadAppDoc error: ${(e as Error).message}`, isError: true };
@@ -321,6 +363,38 @@ function extractMarkdownDescription(filePath: string): string {
return '(no description)';
}
/**
* アプリ内ヘルプ (`ui/src/content/help/*.md`) の表示名を frontmatter の `title`
* から取る。frontmatter が読めない・title が無いときはファイル名にフォールバック。
*/
function extractHelpTitle(filePath: string, baseName: string): string {
try {
return parseFrontmatterTitle(readFileSync(filePath, 'utf-8')) ?? baseName;
} catch {
// ignore — fall through to the filename
}
return baseName;
}
/**
* frontmatter の `title` を取り出す。ファイル全文でも、先頭を切り出した断片でも
* 動くように「文字列を受け取る」形にしてある (打ち切り時の案内文で使い回すため)。
*/
function parseFrontmatterTitle(raw: string): string | null {
try {
const normalized = raw.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
const m = /^---\n([\s\S]*?)\n---\n?/.exec(normalized);
if (!m) return null;
const data = parseYaml(m[1]!) as { title?: unknown } | undefined;
if (typeof data?.title === 'string' && data.title.trim()) {
return data.title.trim().slice(0, 140);
}
} catch {
// ignore — 壊れた frontmatter は「title 無し」として扱う
}
return null;
}
/**
* Read a piece YAML's `description` field (first non-empty line).
*/
@@ -377,6 +451,34 @@ async function executeListAppDocs(
): Promise<ToolResult> {
const sections: string[] = [];
// 0. 利用者向けヘルプ (アプリ内 Help パネルと同じ内容)
const helpDocs: DocEntry[] = [];
if (existsSync(HELP_DIR)) {
let helpFiles: string[];
try {
helpFiles = readdirSync(HELP_DIR).filter((f) => f.endsWith('.md')).sort();
} catch {
helpFiles = [];
}
for (const file of helpFiles) {
const baseName = file.slice(0, -3);
helpDocs.push({
symbolicName: `help/${baseName}`,
description: extractHelpTitle(join(HELP_DIR, file), baseName),
});
}
}
sections.push('# 利用者向けヘルプ (`help/<name>` で読み込み — アプリ内ヘルプと同じ内容)');
sections.push('');
if (helpDocs.length === 0) {
sections.push('- (none)');
} else {
for (const entry of helpDocs) {
sections.push(`- \`${entry.symbolicName}\` — ${entry.description}`);
}
}
sections.push('');
// 1. Pieces
const pieces: DocEntry[] = [];
if (existsSync(PIECES_DIR)) {
@@ -465,7 +567,7 @@ async function executeListAppDocs(
sections.push('');
// Cap output size: if total entries > 150, append a notice
const totalEntries = pieces.length + docs.length + toolDocs.length;
const totalEntries = helpDocs.length + pieces.length + docs.length + toolDocs.length;
if (totalEntries > 150) {
sections.push(`> 注: 合計 ${totalEntries} 件の doc があります。特定の領域は ReadAppDoc({ name: "docs/<path>" }) で個別に取得してください。`);
}
+21 -9
View File
@@ -40,40 +40,44 @@ describe('buildHarnessUrl', () => {
describe('validateInput', () => {
it('rejects when app is missing', () => {
expect(() => validateInput({ space: 's1' })).toThrow(/app/);
expect(() => validateInput({})).toThrow(/app/);
});
it('rejects when space is missing', () => {
expect(() => validateInput({ app: 'my-app' })).toThrow(/space/);
it('does not accept a caller-supplied space selector', () => {
const result = validateInput({ app: 'my-app', space: 'another-space' });
expect(result).not.toHaveProperty('space');
});
it('defaults entry to apps/{app}/index.html when omitted', () => {
const result = validateInput({ space: 's1', app: 'counter' });
const result = validateInput({ app: 'counter' });
expect(result.entry).toBe('apps/counter/index.html');
});
it('preserves an explicitly supplied entry', () => {
const result = validateInput({ space: 's1', app: 'counter', entry: 'apps/counter/v2.html' });
const result = validateInput({ app: 'counter', entry: 'apps/counter/v2.html' });
expect(result.entry).toBe('apps/counter/v2.html');
});
it('clamps the whole-test timeout to 120 seconds', () => {
expect(validateInput({ app: 'counter', timeout_ms: 999_999 }).timeout_ms).toBe(120_000);
});
it('passes through steps and seed_files (canonical param name)', () => {
const steps = [{ type: 'click', selector: '#btn' }];
const seed = [{ path: 'apps/counter/index.html', content: '<html/>' }];
const result = validateInput({ space: 's1', app: 'counter', steps, seed_files: seed });
const result = validateInput({ app: 'counter', steps, seed_files: seed });
expect(result.steps).toStrictEqual(steps);
expect(result.seed_files).toStrictEqual(seed);
});
it('accepts legacy "actions" param and maps to steps', () => {
const actions = [{ type: 'click', selector: '#btn' }];
const result = validateInput({ space: 's1', app: 'counter', actions });
const result = validateInput({ app: 'counter', actions });
expect(result.steps).toStrictEqual(actions);
});
it('parses canonical expect items (kind text and file)', () => {
const raw = {
space: 's1',
app: 'counter',
expect: [
{ kind: 'text', target: '#status', contains: 'OK' },
@@ -94,7 +98,7 @@ describe('evaluateExpectations', () => {
it('kind=text passes when contains is present in pageText', () => {
const failures = evaluateExpectations(
[{ kind: 'text', target: '#status', contains: '保存OK' }],
{ pageText: 'some text 保存OK more text', workspacePath: '/tmp' },
{ pageText: 'some text 保存OK more text', targetedText: { '#status': '保存OK' }, workspacePath: '/tmp' },
);
expect(failures).toHaveLength(0);
});
@@ -109,6 +113,14 @@ describe('evaluateExpectations', () => {
expect(failures[0]).toMatch(/not found/);
});
it('does not fall back to body text when the target selector is missing', () => {
const failures = evaluateExpectations(
[{ kind: 'text', target: '#missing', contains: '保存OK' }],
{ pageText: '保存OK', targetedText: { '#missing': '' }, workspacePath: '/tmp' },
);
expect(failures).toHaveLength(1);
});
it('kind=text with empty contains always passes (empty string is substring of everything)', () => {
const failures = evaluateExpectations(
[{ kind: 'text', target: 'body', contains: '' }],
+37 -22
View File
@@ -8,7 +8,7 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { ToolDef } from '../../llm/openai-compat.js';
import type { ToolContext, ToolResult } from './core.js';
import type { ToolContext, ToolResult } from './tool-context.js';
import { resolveAndGuard } from './core.js';
import { logger } from '../../logger.js';
import { loadConfig } from '../../config.js';
@@ -49,7 +49,6 @@ export interface ExpectItem {
}
interface ValidatedInput {
space: string;
app: string;
entry: string;
steps: Record<string, unknown>[];
@@ -67,11 +66,6 @@ export function validateInput(raw: Record<string, unknown>): ValidatedInput {
if (typeof app !== 'string' || !app.trim()) {
throw new Error('TestWorkspaceApp: "app" (string) is required');
}
const space = raw['space'];
if (typeof space !== 'string' || !space.trim()) {
throw new Error('TestWorkspaceApp: "space" (string) is required');
}
const entry =
typeof raw['entry'] === 'string' && raw['entry'].trim()
? raw['entry'].trim()
@@ -105,12 +99,12 @@ export function validateInput(raw: Record<string, unknown>): ValidatedInput {
}
}
const timeout_ms =
const timeout_ms = Math.min(120_000,
typeof raw['timeout_ms'] === 'number' && raw['timeout_ms'] > 0
? raw['timeout_ms']
: 30_000;
: 30_000);
return { space: space.trim(), app: app.trim(), entry, steps, seed_files, expect: expectRaw, timeout_ms };
return { app: app.trim(), entry, steps, seed_files, expect: expectRaw, timeout_ms };
}
// ---------------------------------------------------------------------------
@@ -122,6 +116,7 @@ export interface EvaluateExpectationsContext {
pageText: string;
/** Absolute path to the workspace root */
workspacePath: string;
targetedText?: Record<string, string>;
}
export interface ExpectationFailure {
@@ -147,7 +142,10 @@ export function evaluateExpectations(
if (exp.kind === 'text') {
// target is treated as a hint/CSS selector (informational), contains is checked against page text
const needle = exp.contains ?? '';
if (!ctx.pageText.includes(needle)) {
const haystack = exp.target && exp.target !== 'body'
? (ctx.targetedText?.[exp.target] ?? '')
: ctx.pageText;
if (!haystack.includes(needle)) {
failures.push(
`text[${i}]: target="${exp.target ?? ''}" — "${needle}" not found in page text`,
);
@@ -195,10 +193,6 @@ export const TOOL_DEFS: Record<string, ToolDef> = {
parameters: {
type: 'object',
properties: {
space: {
type: 'string',
description: 'テスト対象スペースの ID',
},
app: {
type: 'string',
description: 'apps/ 配下のアプリフォルダ名(パスではなく名前のみ)',
@@ -268,7 +262,7 @@ export const TOOL_DEFS: Record<string, ToolDef> = {
description: 'ブラウザ操作全体のタイムアウト(ミリ秒、デフォルト 30000)',
},
},
required: ['space', 'app'],
required: ['app'],
},
},
},
@@ -316,7 +310,7 @@ export async function executeTool(
return { output: (err as Error).message, isError: true };
}
const { space, app, entry, steps, seed_files, expect: expectations, timeout_ms } = validated;
const { app, entry, steps, seed_files, expect: expectations, timeout_ms } = validated;
const result: AppTestResult = {
ok: false,
@@ -348,7 +342,7 @@ export async function executeTool(
try {
// Build harness URL
const harnessUrl = buildHarnessUrl(port, { space, app, entry, token });
const harnessUrl = buildHarnessUrl(port, { space: spaceId, app, entry, token });
// Dynamically import browser module (avoids Playwright load when unused)
const { runPageActions } = await import('./browser.js');
@@ -366,6 +360,8 @@ export async function executeTool(
{ type: 'getText' },
// User steps
...(steps as unknown as BrowseWebAction[]),
...expectations.filter(item => item.kind === 'text' && item.target && item.target !== 'body')
.map(item => ({ type: 'getText' as const, selector: item.target })),
// Final screenshot
{ type: 'screenshot', value: `app-test-${app}-final.png` },
];
@@ -377,19 +373,38 @@ export async function executeTool(
if (a.type === 'screenshot' && a.segments === undefined) a.segments = false;
}
const pageResult = await runPageActions(harnessUrl, builtActions, ctx, {
isolatedContext: true,
extraAllowedHosts: ['localhost', '127.0.0.1'],
});
const timeoutController = new AbortController();
const timeoutHandle = setTimeout(() => timeoutController.abort(), timeout_ms);
let pageResult: Awaited<ReturnType<typeof runPageActions>>;
try {
pageResult = await runPageActions(harnessUrl, builtActions, ctx, {
isolatedContext: true,
extraAllowedHosts: ['localhost', '127.0.0.1'],
defaultToFirstChildFrame: true,
abortSignal: timeoutController.signal,
});
if (timeoutController.signal.aborted) throw new Error(`timed out after ${timeout_ms}ms`);
} catch (error) {
if (timeoutController.signal.aborted) throw new Error(`timed out after ${timeout_ms}ms`);
throw error;
} finally {
clearTimeout(timeoutHandle);
}
result.screenshots = pageResult.screenshots;
result.console_errors = pageResult.consoleErrors;
for (const error of result.console_errors) result.failures.push(`app diagnostic: ${error}`);
// Evaluate expectations using the extracted pure function
const pageText = pageResult.texts.join('\n');
const targetedText: Record<string, string> = {};
const targeted = expectations.filter(item => item.kind === 'text' && item.target && item.target !== 'body');
const captured = pageResult.texts.slice(-targeted.length);
targeted.forEach((item, index) => { targetedText[item.target] = captured[index] ?? ''; });
const expectFailures = evaluateExpectations(expectations, {
pageText,
workspacePath: ctx.workspacePath,
targetedText,
});
result.failures.push(...expectFailures);
@@ -0,0 +1,78 @@
import { describe, it, expect } from 'vitest';
import {
parseAskQuestionsBody,
formatAskQuestionsAsText,
summarizeAskQuestions,
} from './ask-questions-format.js';
/**
* AskUser の kind='ask' コメント本文は {type:'ask_questions'} JSON。
* UI はカードを描画できるが、チャットコネクタ(Slack/Discord 返信)や
* 会話コンテキスト注入など「平文しか運べない消費者」は生 JSON を
* そのまま流してしまう。全消費者が共用する平文レンダラをここに置く。
*/
const BODY = JSON.stringify({
type: 'ask_questions',
questions: [
{
question: 'デプロイ先はどちらにしますか?',
header: 'デプロイ先',
options: [
{ label: '本番', description: '即時反映' },
{ label: 'ステージング' },
],
},
{
question: '通知は必要ですか?',
options: [{ label: 'はい' }, { label: 'いいえ' }],
multiSelect: true,
},
],
});
describe('parseAskQuestionsBody', () => {
it('ask_questions JSON を questions に解く', () => {
expect(parseAskQuestionsBody(BODY)).toHaveLength(2);
});
it('自由文・別種 JSON・壊れた JSON は null', () => {
expect(parseAskQuestionsBody('❓ 確認事項\n\nどうしますか?')).toBeNull();
expect(parseAskQuestionsBody(JSON.stringify({ type: 'thinking', text: 'x' }))).toBeNull();
expect(parseAskQuestionsBody('{broken')).toBeNull();
expect(parseAskQuestionsBody(JSON.stringify({ type: 'ask_questions', questions: [] }))).toBeNull();
});
});
describe('formatAskQuestionsAsText', () => {
it('質問と選択肢を人間が読める平文にする(生 JSON を含まない)', () => {
const text = formatAskQuestionsAsText(BODY)!;
expect(text).toContain('デプロイ先はどちらにしますか?');
expect(text).toContain('本番');
expect(text).toContain('即時反映');
expect(text).toContain('通知は必要ですか?');
expect(text).not.toContain('"type"');
expect(text).not.toContain('ask_questions');
});
it('header があれば見出しとして出す', () => {
expect(formatAskQuestionsAsText(BODY)).toContain('デプロイ先');
});
it('ask JSON でなければ null(呼び出し側は ?? body でフォールバック)', () => {
expect(formatAskQuestionsAsText('❓ 確認事項\n\nどうしますか?')).toBeNull();
});
});
describe('summarizeAskQuestions', () => {
it('activity log 用の 1 行サマリ(header 優先、無ければ質問文)', () => {
const line = summarizeAskQuestions(BODY)!;
expect(line).toContain('デプロイ先');
expect(line).toContain('通知は必要ですか?');
expect(line).not.toContain('\n');
});
it('ask JSON でなければ null', () => {
expect(summarizeAskQuestions('自由文')).toBeNull();
});
});
+61
View File
@@ -0,0 +1,61 @@
/**
* AskUser の構造化質問 ({type:'ask_questions'} JSON) の平文レンダラ。
*
* kind='ask' コメント本文は UI がカード描画するために JSON で保存されるが、
* 平文しか運べない消費者(チャットコネクタの Slack/Discord 返信、
* 会話コンテキストの直近やり取り注入、activity log)がそのまま流すと
* 生 JSON がユーザー・LLM に露出する。変換はすべてここに集約する。
* 依存ゼロの純関数モジュール(progress / bridge / engine のどこからでも
* 循環なしに import できるように分離している)。
*/
export interface AskUserOption {
label: string;
description?: string;
}
export interface AskUserQuestion {
question: string;
header?: string;
options: AskUserOption[];
multiSelect?: boolean;
}
/** kind='ask' コメント本文に載せる構造化ペイロードの判別子。UI が解釈する。 */
export const ASK_QUESTIONS_TYPE = 'ask_questions';
/** ask_questions JSON なら questions を返す。自由文・別種・壊れた JSON は null。 */
export function parseAskQuestionsBody(body: string): AskUserQuestion[] | null {
if (!body.trimStart().startsWith('{')) return null;
try {
const data = JSON.parse(body) as { type?: unknown; questions?: unknown };
if (data?.type !== ASK_QUESTIONS_TYPE || !Array.isArray(data.questions) || data.questions.length === 0) return null;
const ok = data.questions.every(
(q) => typeof (q as AskUserQuestion)?.question === 'string' && Array.isArray((q as AskUserQuestion)?.options),
);
return ok ? (data.questions as AskUserQuestion[]) : null;
} catch {
return null;
}
}
/** 質問+選択肢の箇条書きに変換する。ask JSON でなければ null(呼び出し側は ?? body)。 */
export function formatAskQuestionsAsText(body: string): string | null {
const questions = parseAskQuestionsBody(body);
if (!questions) return null;
const blocks = questions.map((q) => {
const head = q.header ? `【${q.header}】` : '';
const opts = q.options
.map((o) => `- ${o.label}${o.description ? `: ${o.description}` : ''}`)
.join('\n');
return `${head}${q.question}\n${opts}`;
});
return `❓ 確認事項\n\n${blocks.join('\n\n')}`;
}
/** activity log 用の 1 行サマリ。header 優先、無ければ質問文。 */
export function summarizeAskQuestions(body: string): string | null {
const questions = parseAskQuestionsBody(body);
if (!questions) return null;
return questions.map((q) => q.header ?? q.question).join(' / ');
}
+113
View File
@@ -0,0 +1,113 @@
/**
* AskUser — ユーザーへの質問を選択肢つきで停車する park ツール (issue #985)。
*
* これまで質問は complete({status:'needs_user_input', missing_info}) の自由文
* 1 本だった。AskUser はエージェントが選択肢を生成し、UI がタップで答えられる
* カードを出す。停車は既存の ASK sentinel に相乗りする(askPolicy / ASK 上限 /
* 再開経路は共通)。
*/
import { describe, it, expect } from 'vitest';
import { TOOL_DEFS, executeTool } from './ask-user.js';
import type { ToolContext } from './core.js';
const baseCtx = (): ToolContext =>
({ missionBrief: { read: () => null }, pendingAskUser: null }) as unknown as ToolContext;
const validQuestions = [
{
question: 'デプロイ先はどちらにしますか?',
header: 'デプロイ先',
options: [
{ label: '本番', description: '即時反映' },
{ label: 'ステージング', description: '検証後に昇格' },
],
},
];
describe('AskUser 定義', () => {
it('TOOL_DEFS に AskUser がある', () => {
expect(TOOL_DEFS.AskUser).toBeDefined();
expect(TOOL_DEFS.AskUser!.function.name).toBe('AskUser');
});
});
describe('AskUser 実行', () => {
it('妥当な質問で park フラグを立てる', async () => {
const ctx = baseCtx();
const result = await executeTool('AskUser', { questions: validQuestions }, ctx);
expect(result?.isError).toBe(false);
expect(ctx.pendingAskUser).toEqual([
expect.objectContaining({ question: 'デプロイ先はどちらにしますか?' }),
]);
});
it('multiSelect を保持する', async () => {
const ctx = baseCtx();
await executeTool('AskUser', {
questions: [{ ...validQuestions[0], multiSelect: true }],
}, ctx);
expect(ctx.pendingAskUser?.[0]?.multiSelect).toBe(true);
});
it('質問が空なら isError で park しない', async () => {
const ctx = baseCtx();
const result = await executeTool('AskUser', { questions: [] }, ctx);
expect(result?.isError).toBe(true);
expect(ctx.pendingAskUser).toBeFalsy();
});
it('選択肢が 1 個以下なら isError(選択肢の意味がない)', async () => {
const ctx = baseCtx();
const result = await executeTool('AskUser', {
questions: [{ question: 'q', options: [{ label: 'only' }] }],
}, ctx);
expect(result?.isError).toBe(true);
expect(result?.output).toContain('2');
});
it('質問は 4 件まで', async () => {
const ctx = baseCtx();
const result = await executeTool('AskUser', {
questions: Array.from({ length: 5 }, (_, i) => ({
question: `q${i}`,
options: [{ label: 'a' }, { label: 'b' }],
})),
}, ctx);
expect(result?.isError).toBe(true);
});
it('選択肢は 6 個まで', async () => {
const ctx = baseCtx();
const result = await executeTool('AskUser', {
questions: [{
question: 'q',
options: Array.from({ length: 7 }, (_, i) => ({ label: `o${i}` })),
}],
}, ctx);
expect(result?.isError).toBe(true);
});
it('local task に紐づかない実行(subtask 等)では degrade する', async () => {
const ctx = { missionBrief: undefined, pendingAskUser: null } as unknown as ToolContext;
const result = await executeTool('AskUser', { questions: validQuestions }, ctx);
expect(result?.isError).toBe(true);
expect(result?.output).toContain('complete');
expect(ctx.pendingAskUser).toBeFalsy();
});
it('delegate 実行内でも degrade する(missionBrief は親から継承されるが答える UI が無い)', async () => {
const ctx = {
missionBrief: { read: () => null },
delegateRunId: 'dlg-1',
pendingAskUser: null,
} as unknown as ToolContext;
const result = await executeTool('AskUser', { questions: validQuestions }, ctx);
expect(result?.isError).toBe(true);
expect(result?.output).toContain('complete');
expect(ctx.pendingAskUser).toBeFalsy();
});
it('別のツール名は null(dispatch を汚さない)', async () => {
expect(await executeTool('Read', {}, baseCtx())).toBeNull();
});
});
+144
View File
@@ -0,0 +1,144 @@
import type { ToolContext, ToolResult } from './tool-context.js';
import type { ToolDef } from '../../llm/openai-compat.js';
/**
* AskUser — ユーザーへの質問を選択肢つきで停車する park ツール (issue #985)。
*
* complete({status:'needs_user_input'}) は「タスクの終了」と「質問」が同居し、
* 質問は自由文 1 本しか運べなかった。AskUser はエージェントが文脈から選択肢を
* 生成し、UI がタップで答えられるカードを描画する。
*
* 停車は既存の ASK sentinel に相乗いする: ここでは one-shot フラグを立てるだけで、
* agent-loop の pending-states が MovementResult{next:'ASK'} に変換する
* (WaitSubTask の pendingSubtaskWait と同じ作法。ツール内で同期ブロックしない)。
* askPolicy・ASK 上限・waiting_human からの再開経路はすべて共通。
*
* KV キャッシュ: MissionUpdate と同じ常時登録の META_TOOL。piece やジョブ条件で
* 出し分けると tools 配列のバイトが変わり llama.cpp のキャッシュが全損するため、
* 利用不可の実行では登録は残したまま実行時に degrade する。
*/
import {
ASK_QUESTIONS_TYPE,
type AskUserOption,
type AskUserQuestion,
} from './ask-questions-format.js';
// 型・判別子と平文レンダラは ask-questions-format.ts(依存ゼロの純関数
// モジュール)に置き、チャットコネクタ・会話コンテキスト等の消費者と共用する。
export { ASK_QUESTIONS_TYPE, type AskUserOption, type AskUserQuestion };
const MAX_QUESTIONS = 4;
const MIN_OPTIONS = 2;
const MAX_OPTIONS = 6;
const MAX_TEXT = 500;
const ASK_USER_DEF: ToolDef = {
type: 'function',
function: {
name: 'AskUser',
description:
'ユーザーに選択肢つきで質問し、回答があるまでタスクを停車する。質問は1〜4件、各質問に2〜6個の相互排他な選択肢を自分で生成して渡す(「その他(自由記述)」はUIが自動で追加するので含めない)。曖昧さの解消・方針の分岐・Missionとのずれの確認に使う。自由文だけで聞きたい場合や、タスクを終了して情報を求める場合は complete({status:"needs_user_input"}) を使う。詳細は ReadToolDoc({ name: "AskUser" })。',
parameters: {
type: 'object',
properties: {
questions: {
type: 'array',
description: '質問のリスト (1〜4件)。1回の停車でまとめて聞く。',
items: {
type: 'object',
properties: {
question: { type: 'string', description: '質問文。文脈を含め、単体で意味が通るように書く' },
header: { type: 'string', description: 'カードに出す短いラベル (例: "デプロイ先")' },
options: {
type: 'array',
description: '選択肢 (2〜6個)。相互排他にする。迷うなら選択肢を絞り自由記述に任せる',
items: {
type: 'object',
properties: {
label: { type: 'string', description: '選択肢の表示名 (短く)' },
description: { type: 'string', description: '選ぶとどうなるかの補足' },
},
required: ['label'],
},
},
multiSelect: { type: 'boolean', description: '複数選択を許すなら true (既定 false)' },
},
required: ['question', 'options'],
},
},
},
required: ['questions'],
},
},
};
export const TOOL_DEFS: Record<string, ToolDef> = { AskUser: ASK_USER_DEF };
const fail = (output: string): ToolResult => ({ output, isError: true });
function parseQuestions(raw: unknown): AskUserQuestion[] | string {
if (!Array.isArray(raw) || raw.length === 0) return 'questions は 1 件以上の配列で渡してください。';
if (raw.length > MAX_QUESTIONS) return `questions は最大 ${MAX_QUESTIONS} 件です。論点を絞ってください。`;
const out: AskUserQuestion[] = [];
for (const [i, q] of raw.entries()) {
if (typeof q !== 'object' || q === null) return `questions[${i}] がオブジェクトではありません。`;
const rec = q as Record<string, unknown>;
const question = typeof rec['question'] === 'string' ? rec['question'].trim() : '';
if (!question) return `questions[${i}].question が空です。`;
const optionsRaw = rec['options'];
if (!Array.isArray(optionsRaw) || optionsRaw.length < MIN_OPTIONS) {
return `questions[${i}].options は ${MIN_OPTIONS} 個以上必要です(1 個なら質問の意味がありません。自由文で聞くなら complete を使う)。`;
}
if (optionsRaw.length > MAX_OPTIONS) return `questions[${i}].options は最大 ${MAX_OPTIONS} 個です。`;
const options: AskUserOption[] = [];
for (const [j, o] of optionsRaw.entries()) {
const orec = (typeof o === 'object' && o !== null ? o : {}) as Record<string, unknown>;
const label = typeof orec['label'] === 'string' ? orec['label'].trim() : '';
if (!label) return `questions[${i}].options[${j}].label が空です。`;
const description = typeof orec['description'] === 'string' ? orec['description'].trim() : undefined;
options.push({
label: label.slice(0, MAX_TEXT),
...(description ? { description: description.slice(0, MAX_TEXT) } : {}),
});
}
const header = typeof rec['header'] === 'string' ? rec['header'].trim().slice(0, 40) : undefined;
out.push({
question: question.slice(0, 2000),
...(header ? { header } : {}),
options,
...(rec['multiSelect'] === true ? { multiSelect: true } : {}),
});
}
return out;
}
export async function executeTool(
name: string,
input: Record<string, unknown>,
ctx: ToolContext,
): Promise<ToolResult | null> {
if (name !== 'AskUser') return null;
// local_task に紐づかない実行 (subtask / gitea issue) では答える人がいない。
// delegate 実行内も同様: ctx は親から継承されるため missionBrief を持つが、
// 停車しても質問カードはどこにも出ず、生 JSON が親の Delegate 結果に漏れる。
// MissionUpdate と同じ判定・同じ degrade 文で、自走を促す。
if (!ctx.missionBrief || ctx.delegateRunId) {
return fail(
'AskUser はこのコンテキストでは利用できません (subtask / delegate など質問カードを出せない実行)。' +
'自明な選択肢を選んで進めるか、達成できない場合は complete({status:"needs_user_input", missing_info:"..."}) で報告してください。',
);
}
const parsed = parseQuestions(input['questions']);
if (typeof parsed === 'string') return fail(`AskUser: ${parsed}`);
// 制御フロー (park シグナル): agent-loop がこのフラグを見て movement を
// ASK sentinel で停車させる。ツール内で回答を待たない (ワーカー解放を保つ)。
ctx.pendingAskUser = parsed;
return {
output: 'ユーザーに質問を提示し、回答を待って停車します。回答は次の再開時にユーザーメッセージとして届きます。',
isError: false,
};
}
+56
View File
@@ -126,6 +126,62 @@ export function stripLeadingBom(s: string): string {
return s.charCodeAt(0) === 0xfeff ? s.slice(1) : s;
}
/**
* 先頭バイトだけで画像の形式を決める(issue #1097 / 由来 #1093)。
*
* content-type と URL の拡張子はどちらも当てにならない——オブジェクトストレージの
* 署名つき URL は拡張子を持たず `application/octet-stream` で配られることが多い。
* 中身の先頭バイトなら、**意味を見ずに形式だけ**で判別できる。
*
* @returns 拡張子('.png' 等)。どの画像形式にも当たらなければ null。
*/
export function sniffImageExtension(head: Buffer): string | null {
if (startsWith(head, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return '.png'; // APNG も PNG
if (startsWith(head, [0xff, 0xd8, 0xff])) return '.jpg';
if (startsWith(head, [0x47, 0x49, 0x46, 0x38])) return '.gif';
// RIFF だけでは WAV / AVI と区別できない。8 バイト目からの形式名まで見る。
if (startsWith(head, [0x52, 0x49, 0x46, 0x46]) && head.length >= 12) {
if (head.subarray(8, 12).toString('latin1') === 'WEBP') return '.webp';
}
if (startsWith(head, [0x42, 0x4d])) return '.bmp';
if (startsWith(head, [0x49, 0x49, 0x2a, 0x00]) || startsWith(head, [0x4d, 0x4d, 0x00, 0x2a])) return '.tiff';
if (startsWith(head, [0x00, 0x00, 0x01, 0x00])) return '.ico';
// ISO-BMFF(AVIF)。'ftyp' ボックスのブランドで見分ける。
// 主ブランドが 'mif1' で 'avif' は互換ブランドの側に載る配り方があるので、
// 主ブランドだけを見ていると読める AVIF を取りこぼす。ボックスの長さの範囲で
// 互換ブランドの並びまで見る(12〜16 バイト目は版なのでブランドではない)。
if (head.length >= 12 && head.subarray(4, 8).toString('latin1') === 'ftyp') {
const boxSize = head.readUInt32BE(0);
const end = Math.min(head.length, boxSize >= 16 ? boxSize : head.length);
for (let i = 8; i + 4 <= end; i += 4) {
if (i === 12) continue; // minor_version
const brand = head.subarray(i, i + 4).toString('latin1');
if (brand === 'avif' || brand === 'avis') return '.avif';
}
}
if (looksLikeSvg(head)) return '.svg';
return null;
}
/**
* SVG はテキストなので先頭バイトのパターンでは決まらない。先頭を素直に読み、
* BOM・XML 宣言・DOCTYPE・コメントを飛ばした先に `<svg` があるかだけを見る。
*/
function looksLikeSvg(head: Buffer): boolean {
const text = head.subarray(0, 4096).toString('utf-8');
if (!/<svg[\s>]/i.test(text)) return false;
const before = text.slice(0, text.search(/<svg[\s>]/i));
// `<svg` より前に許すのは空白・BOM・XML 宣言・DOCTYPE・コメントだけ。
// HTML の中に <svg> が出てくるページを画像として書かないため。
const stripped = before
.replace(/^\uFEFF/, '')
.replace(/<\?xml[^>]*\?>/gi, '')
.replace(/<!DOCTYPE[^>]*>/gi, '')
.replace(/<!--[\s\S]*?-->/g, '')
.trim();
return stripped.length === 0;
}
export function looksLikeBinaryBytes(head: Buffer): BinaryVerdict {
const magic = matchMagic(head);
if (magic) return { binary: true, reason: magic };
+1 -1
View File
@@ -1,5 +1,5 @@
import { ToolDef } from '../../llm/openai-compat.js';
import type { ToolContext, ToolResult } from './core.js';
import type { ToolContext, ToolResult } from './tool-context.js';
import { logger } from '../../logger.js';
// Brainstorm ツール (issue #247)
@@ -0,0 +1,83 @@
import { afterEach, expect, it } from 'vitest';
import { chromium, type Browser } from 'playwright';
import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { executeTool, closeBrowser } from './browser.js';
import { recorder, type RecordedAction } from '../browser-recorder.js';
import { compileScriptBody } from '../../user-folder/script-compiler.js';
const SECRET = 'fixture-private-recorded-editor';
const VISIBLE = 'visible-editor-note';
let dir: string | undefined;
let browser: Browser | undefined;
afterEach(async () => {
await closeBrowser();
await browser?.close();
browser = undefined;
if (dir) rmSync(dir, { recursive: true, force: true });
dir = undefined;
});
function readFiles(root: string): string {
return readdirSync(root, { withFileTypes: true }).map(entry => {
const path = join(root, entry.name);
return entry.isDirectory() ? readFiles(path) : readFileSync(path, 'utf8');
}).join('\n');
}
it('masks a real contenteditable fill in BrowseWeb output, recording, and regenerated macros', async () => {
dir = mkdtempSync(join(tmpdir(), 'editable-secret-e2e-'));
const pagePath = join(dir, 'page.html');
writeFileSync(pagePath,
'<html><body><div id="panel"><div id="editor" contenteditable="plaintext-only" aria-label="Password"></div>' +
'<div contenteditable="true" aria-label="Comment">' + VISIBLE + '</div></div></body></html>');
const taskId = 'editable-secret-' + Date.now();
const ctx = { workspacePath: dir, taskId, userId: '1', toolsConfig: {} } as never;
const result = await executeTool('BrowseWeb', {
recordTo: 'editable-flow',
actions: [
{ type: 'goto', url: 'file://' + pagePath },
{ type: 'fill', selector: '#editor', value: SECRET },
{ type: 'getText', selector: '#panel' },
{ type: 'dumpHtml', selector: '#panel' },
],
}, ctx);
expect(result?.isError).toBeFalsy();
expect(result).not.toBeNull();
const output = result!.output + JSON.stringify(result!.structuredBlocks ?? []);
expect(output).not.toContain(SECRET);
expect(output).toContain('[masked]');
expect(output).toContain(VISIBLE);
const recording = recorder.flush(taskId, join(dir, 'users-root'), '1');
expect(recording).not.toBeNull();
const raw = readFileSync(recording!, 'utf8');
const actions = (JSON.parse(raw) as { actions: RecordedAction[] }).actions;
const fill = actions.find(action => action.type === 'fill');
expect(fill?.valueMasked).toBe(true);
expect(fill?.value).toBeUndefined();
expect(raw).not.toContain(SECRET);
expect(readFiles(dir)).not.toContain(SECRET);
// アプリ側のブラウザを閉じてから再生用を起動する。
await closeBrowser();
browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] });
const context = await browser.newContext();
for (const kind of ['getText', 'dumpHtml'] as const) {
const recordingForRead = actions.filter(action =>
(action.type !== 'getText' && action.type !== 'dumpHtml') || action.type === kind);
const compiled = compileScriptBody({ description: 'secret recording test', recording: recordingForRead });
expect(compiled.paramSpecs).toHaveLength(1);
expect(compiled.body).not.toContain(SECRET);
const module = { exports: undefined as unknown };
new Function('module', compiled.body)(module);
const main = module.exports as (args: { context: typeof context; params: Record<string, string> }) => Promise<string>;
const replayed = await main({ context, params: { [compiled.paramSpecs[0]!.name]: SECRET } });
expect(replayed).not.toContain(SECRET);
expect(replayed).toContain('[masked]');
expect(replayed).toContain(VISIBLE);
}
await context.close();
}, 90_000);
@@ -0,0 +1,63 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Locator } from 'playwright';
import { SNAPSHOT_SCRIPT, buildDumpHtmlScript, isSecretFieldLocator } from './browser.js';
const SECRET = 'fixture-private-editor-123';
const VISIBLE = 'visible-editor-note';
beforeEach(() => {
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({
width: 100, height: 20, top: 0, left: 0, right: 100, bottom: 20, x: 0, y: 0, toJSON() {},
} as DOMRect);
});
afterEach(() => vi.restoreAllMocks());
function setup(mode = 'true') {
document.body.innerHTML =
'<label id="secret-label">パスワード</label>' +
'<div id="editor" contenteditable="' + mode + '" aria-labelledby="secret-label">' +
'<span id="inside">' + SECRET + '</span></div>' +
'<div contenteditable="true" aria-label="コメント">' + VISIBLE + '</div>';
}
describe('contenteditable secret fields', () => {
it.each(['true', '', 'plaintext-only', 'TRUE'])('masks snapshot text for contenteditable=%j and keeps ordinary editor text', (mode) => {
setup(mode);
const output = (0, eval)(SNAPSHOT_SCRIPT).text;
expect(output).not.toContain(SECRET);
expect(output).toContain('[masked]');
expect(output).toContain(VISIBLE);
expect(document.getElementById('editor')!.textContent).toBe(SECRET);
});
it.each([undefined, '#editor', '#inside'])('masks HTML including a selected descendant (%s)', (selector) => {
setup();
const output = (0, eval)(buildDumpHtmlScript(selector, 10));
expect(output).not.toContain(SECRET);
expect(output).toContain('[masked]');
if (!selector) expect(output).toContain(VISIBLE);
expect(document.getElementById('editor')!.textContent).toBe(SECRET);
expect(document.querySelector('[data-ao-mask]')).toBeNull();
});
it('marks a fill operation as secret before it is recorded', async () => {
setup();
const el = document.getElementById('editor')!;
const locator = { evaluate: async (fn: Function) => (0, eval)('(' + fn.toString() + ')')(el) } as unknown as Locator;
expect(await isSecretFieldLocator(locator)).toBe(true);
});
it('does not expose a secret editor through its interactive parent name', () => {
document.body.innerHTML =
'<div role="group" tabindex="0">Account <div contenteditable="true" aria-label="Password">' +
SECRET + '</div></div>';
const output = (0, eval)(SNAPSHOT_SCRIPT).text;
expect(output).not.toContain(SECRET);
expect(output).toContain('Account');
});
it('does not treat non-editable password documentation as an input field', () => {
document.body.innerHTML = '<div contenteditable="false" aria-label="Password">' + VISIBLE + '</div>';
expect((0, eval)(SNAPSHOT_SCRIPT).text).toContain(VISIBLE);
});
});
@@ -0,0 +1,167 @@
// @vitest-environment jsdom
import { describe, it, expect } from 'vitest';
import { buildDumpHtmlScript } from './browser.js';
/**
* dumpHtml がページ内で走らせる文字列を jsdom の上で実際に評価して、
* 生の HTML に入力欄の中身が載らないことを見る(issue #1236 レビュー)。
*
* 入力した文字列を `value` **属性**へ書き戻すページ(React の制御された
* input など)では、打ち込んだ値がそのまま HTML に現れる。ここはその形を
* 直接置いて確かめる。ブラウザを通す経路の確認は
* browser.recording-secret.e2e.test.ts(http サーバ + 本物の Chromium)。
*/
function dump(html: string, selector?: string, depth = 3): string {
document.body.innerHTML = html;
return (0, eval)(buildDumpHtmlScript(selector, depth)) as string;
}
describe('buildDumpHtmlScript — value 属性に残った中身', () => {
it('パスワード欄の value 属性を伏せる', () => {
const out = dump('<input id="pw" type="password" name="password" value="hunter2-topsecret">');
expect(out).not.toContain('hunter2-topsecret');
expect(out).toContain('value="[masked]"');
});
it('ふつうの欄の value 属性はそのまま残す(伏せ過ぎの回帰)', () => {
const out = dump('<input id="u" type="text" name="username" value="alice-visible">');
expect(out).toContain('value="alice-visible"');
expect(out).not.toContain('[masked]');
});
it('選んだ要素そのものが入力欄でも伏せる(根は querySelectorAll に出ない)', () => {
const out = dump(
'<input id="pw" type="password" name="password" value="hunter2-topsecret">',
'#pw',
);
expect(out).not.toContain('hunter2-topsecret');
expect(out).toContain('value="[masked]"');
});
it('名札だけが手掛かりの欄も伏せる(「表示」で伏せ字を解いた欄)', () => {
const out = dump('<input id=":r3:" type="text" aria-label="パスワード" value="hunter2-topsecret">');
expect(out).not.toContain('hunter2-topsecret');
expect(out).toContain('value="[masked]"');
});
it('`<label for>` が付いただけの欄も伏せる', () => {
const out = dump(
'<label for=":r3:">パスワード</label><input id=":r3:" type="text" value="hunter2-topsecret">',
);
expect(out).not.toContain('hunter2-topsecret');
expect(out).toContain('value="[masked]"');
});
it('名札が取得範囲の外にあっても伏せる(複製の中だけを探さないこと)', () => {
// 取るのは `#field` だけ。名札はその外側にあるので、複製を作ってから
// 名札を探す実装では辿れず、パスワードが生の HTML に残る。
const out = dump(
'<label for=":r3:">パスワード</label>' +
'<div id="field"><input id=":r3:" type="text" value="hunter2-topsecret"></div>',
'#field',
);
expect(out).not.toContain('hunter2-topsecret');
expect(out).toContain('value="[masked]"');
});
it('包んでいる `<label>` が取得範囲の外でも伏せる', () => {
// 取るのは `#field` だけ。複製は文書から切り離されるので、複製側で
// 祖先を辿ると `<label>` に届かず、パスワードが生の HTML に残る。
const out = dump(
'<label>パスワード<div id="field">' +
'<input id=":r7:" type="text" value="hunter2-topsecret"></div></label>',
'#field',
);
expect(out).not.toContain('hunter2-topsecret');
expect(out).toContain('value="[masked]"');
});
it('入れ子の `<label>` に包まれた欄も伏せる', () => {
const out = dump('<label>パスワード<input id=":r4:" type="text" value="hunter2-topsecret"></label>');
expect(out).not.toContain('hunter2-topsecret');
expect(out).toContain('value="[masked]"');
});
it('aria-labelledby で名札を指している欄も伏せる', () => {
const out = dump(
'<span id="lbl">パスワード</span>' +
'<input id=":r5:" type="text" aria-labelledby="lbl" value="hunter2-topsecret">',
);
expect(out).not.toContain('hunter2-topsecret');
expect(out).toContain('value="[masked]"');
});
it('離れた名札がふつうの語なら伏せない(伏せ過ぎの回帰)', () => {
const out = dump(
'<label for=":r6:">Shipping address</label><input id=":r6:" type="text" value="tokyo-visible">',
);
expect(out).toContain('value="tokyo-visible"');
expect(out).not.toContain('[masked]');
});
it('autocomplete=one-time-code の欄も伏せる', () => {
const out = dump('<input id="c" type="text" autocomplete="one-time-code" value="482913">');
expect(out).not.toContain('482913');
expect(out).toContain('value="[masked]"');
});
it('textarea は中身(要素のテキスト)も伏せる', () => {
const out = dump('<textarea name="secretNote">hunter2-topsecret</textarea>');
expect(out).not.toContain('hunter2-topsecret');
expect(out).toContain('[masked]');
});
it('伏せる欄とふつうの欄が並んでいても、伏せるのは片方だけ', () => {
const out = dump(
'<form><input name="username" value="alice-visible">' +
'<input name="password" type="password" value="hunter2-topsecret"></form>',
);
expect(out).toContain('value="alice-visible"');
expect(out).toContain('value="[masked]"');
expect(out).not.toContain('hunter2-topsecret');
});
it('複製で印の数がずれたら、印の無い欄まで含めて全部伏せる', () => {
// 複製したときに中身が増える独自要素を置く。`cloneNode(true)` は複製した
// 独自要素をその場で昇格させるので、構築子がもう一度走って複製側だけ欄が
// 増える(issue #1236 レビュー 3 巡目の原因そのもの)。
//
// ここでは構築子が「文書の中の欄を複製して自分に足す」形にしてある。
// 印は複製に乗って運ばれるので、構築子が新しい欄を作るだけなら数はずれ
// ない(印の付いた欄は元も複製も 1 つずつ)。ずれるのは、印を付けた**後**
// に走る構築子が印付きの欄を複製したとき——複製側にだけ印が 1 つ増える。
class DupField extends HTMLElement {
constructor() {
super();
const proto = document.getElementById('proto');
if (proto) this.appendChild(proto.cloneNode(true));
}
}
if (!customElements.get('dup-field')) customElements.define('dup-field', DupField);
const out = dump(
'<div id="field">' +
'<input id="proto" type="password" name="password" value="hunter2-topsecret">' +
'<dup-field></dup-field>' +
'<input name="username" value="alice-visible">' +
'</div>',
'#field',
5,
);
// 数が合わない=どれが秘密か分からない。通す側ではなく全部伏せる側へ倒す。
// 印の付いていなかった利用者名の欄まで伏せられていることが要(合っている
// ときは「伏せるのは片方だけ」で、この欄は素通しになる)。
expect(out).not.toContain('hunter2-topsecret');
expect(out).not.toContain('alice-visible');
expect(out).toContain('value="[masked]"');
// 印は書き出しに残さない。
expect(out).not.toContain('data-ao-mask');
});
it('切り詰めや除去はこれまでどおり効く', () => {
const out = dump('<div><script>var a=1</script><p><b><i>deep</i></b></p></div>', 'div', 1);
expect(out).not.toContain('var a=1');
expect(out).toContain('truncated');
});
});
@@ -0,0 +1,161 @@
import { describe, it, expect, afterAll } from 'vitest';
import { writeFileSync, mkdtempSync, readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { executeTool, closeBrowser } from './browser.js';
/**
* 配線の検証(issue #1236 レビュー 4 巡目)。
*
* BrowseWeb の基本モードで `extractSelector` を渡した経路には、伏せ字の
* 関所が無かった。`htmlToText` はタグを剥がすだけなので、サーバが最初から
* 書き込んでいた `<textarea>` の中身(要素のテキスト)はそのまま生き残り、
* ツールの返り値・`logs/raw/`・`source/` の魚拓・`logs/browse/*.txt` へ流れる。
*
* スナップショット側(`extractSelector` 省略時)は同じ HTML を伏せるので、
* **どちらの引数で読んだかだけで秘密が漏れたり漏れなかったりする**状態だった。
* ここはその食い違いが消えたことを、本物の Chromium を通して見る。
*/
const SECRET = 'sk-live-PREFILLED-TEXTAREA-9x7q';
/** ふつうの欄。伏せ過ぎの回帰を捕まえる。 */
const VISIBLE_NOTE = 'visible-note-alpha';
/** 本文のふつうの文字。抜き出し自体が壊れて空になったら気づけるようにする。 */
const VISIBLE_TEXT = 'visible-heading-beta';
const PAGE_HTML = `<html><body>
<div id="panel">
<h1>${VISIBLE_TEXT}</h1>
<textarea id="secret" name="apiSecret">${SECRET}</textarea>
<textarea id="note" name="comment">${VISIBLE_NOTE}</textarea>
</div>
</body></html>`;
/** shadow root の中の、ふつうの文字。抜き出しが空になったら気づけるようにする。 */
const SHADOW_VISIBLE_TEXT = 'shadow-heading-zeta';
/** 開いた shadow root の中に秘密を置く。ページ内の querySelector からは辿れない。 */
const SHADOW_HTML = `<html><body>
<div id="host"></div>
<script>
const root = document.getElementById('host').attachShadow({ mode: 'open' });
root.innerHTML =
'<div id="inner"><h2>${SHADOW_VISIBLE_TEXT}</h2>' +
'<textarea name="apiSecret">${SECRET}</textarea></div>';
</script>
</body></html>`;
/** `htmlToText` がブロックごと捨てるタグ。根そのものを返すと本文が消える。 */
const HEADER_TEXT = 'header-heading-eta';
const HEADER_HTML = `<html><body>
<header><h1>${HEADER_TEXT}</h1><textarea name="apiSecret">${SECRET}</textarea></header>
</body></html>`;
afterAll(async () => {
await closeBrowser();
});
/** dir 以下の全ファイルを列挙する。 */
function walkFiles(dir: string): string[] {
const out: string[] = [];
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) out.push(...walkFiles(p));
else if (e.isFile()) out.push(p);
}
return out;
}
describe('extractSelector で抜いた本文に入力欄の中身が残らない', () => {
it('サーバが書き込んだ textarea の中身を伏せる', async () => {
const dir = mkdtempSync(join(tmpdir(), 'extract-secret-'));
const pagePath = join(dir, 'panel.html');
writeFileSync(pagePath, PAGE_HTML);
const ctx = { workspacePath: dir, taskId: `t-${Date.now()}`, userId: '1', toolsConfig: {} } as never;
const res = await executeTool(
'BrowseWeb',
{ url: `file://${pagePath}`, extractSelector: '#panel' },
ctx,
);
expect(res).not.toBeNull();
expect(res!.isError).toBeFalsy();
const output = res!.output + JSON.stringify(res!.structuredBlocks ?? []);
expect(output).not.toContain(SECRET);
expect(output).toContain('[masked]');
// 抜き出しそのものが壊れて空になっていないこと。空になれば「秘密が無い」も
// 成り立ってしまい、上の確認が素通りする。
expect(output).toContain(VISIBLE_TEXT);
// 伏せ過ぎていないこと。ふつうの欄の中身は今までどおり読める。
expect(output).toContain(VISIBLE_NOTE);
// 伏せる作業に使う印を書き出しに残さない。
expect(output).not.toContain('data-ao-mask');
expect(output).not.toContain('data-ao-extract');
// ワークスペースに落ちたファイル一式(元ページは除く。あちらは秘密を持つのが前提)。
// logs/raw への保存は tools/index.ts の dispatcher がツールの返り値をそのまま
// 書き出すもので、ここでは呼ばれない。返り値は上で確認済みなので構造上あわせて担保される。
for (const f of walkFiles(dir).filter((p) => p !== pagePath)) {
const body = readFileSync(f, 'utf-8');
expect(body, `${f} に秘密が残っている`).not.toContain(SECRET);
}
}, 90000);
it('開いた shadow root の中も、読めたうえで伏せる', async () => {
// Playwright の CSS は開いた shadow root を貫くが、ページ内の
// `document.querySelector` は貫かない。目印を打って引き直す形にすると、
// 要素は見つかったのに引き直せず読めない——という食い違いが起きる
// (issue #1236 レビュー 5 巡目)。要素そのものを evaluate へ渡すこと。
const dir = mkdtempSync(join(tmpdir(), 'extract-shadow-'));
const pagePath = join(dir, 'shadow.html');
writeFileSync(pagePath, SHADOW_HTML);
const ctx = { workspacePath: dir, taskId: `t-${Date.now()}`, userId: '1', toolsConfig: {} } as never;
const res = await executeTool(
'BrowseWeb',
{ url: `file://${pagePath}`, extractSelector: '#inner' },
ctx,
);
expect(res).not.toBeNull();
expect(res!.isError).toBeFalsy();
const output = res!.output + JSON.stringify(res!.structuredBlocks ?? []);
expect(output).not.toContain(SECRET);
// 読み取り自体が生きていること。空なら「秘密が無い」は素通りする。
expect(output).toContain(SHADOW_VISIBLE_TEXT);
expect(output).toContain('[masked]');
for (const f of walkFiles(dir).filter((p) => p !== pagePath)) {
const body = readFileSync(f, 'utf-8');
expect(body, `${f} に秘密が残っている`).not.toContain(SECRET);
}
}, 90000);
it('`<header>` を指定しても本文が消えない', async () => {
// 抜き出しは根の**中身**を返す。根そのものを含めると、`htmlToText` が
// `<nav>` / `<header>` / `<footer>` をブロックごと捨てるので本文が
// 丸ごと空になる(issue #1236 レビュー 5 巡目)。
const dir = mkdtempSync(join(tmpdir(), 'extract-header-'));
const pagePath = join(dir, 'header.html');
writeFileSync(pagePath, HEADER_HTML);
const ctx = { workspacePath: dir, taskId: `t-${Date.now()}`, userId: '1', toolsConfig: {} } as never;
const res = await executeTool(
'BrowseWeb',
{ url: `file://${pagePath}`, extractSelector: 'header' },
ctx,
);
expect(res).not.toBeNull();
expect(res!.isError).toBeFalsy();
const output = res!.output + JSON.stringify(res!.structuredBlocks ?? []);
expect(output).toContain(HEADER_TEXT);
expect(output).not.toContain(SECRET);
}, 90000);
});
@@ -0,0 +1,107 @@
// @vitest-environment jsdom
import { describe, it, expect } from 'vitest';
import type { Locator } from 'playwright';
import { isSecretFieldLocator, SECRET_FIELD_ATTRS_FN } from './browser.js';
/**
* fill の前に走る下調べ(記録に値を残すかの判定)を、jsdom の上で
* 実際に走らせて確かめる(issue #1236 レビュー)。
*
* Playwright は渡された関数の**字面**をページへ運び、要素を第 1 引数に
* 渡して呼ぶ。ここではその振る舞いを写した入れ物を使い、字面から作り直した
* 関数を呼ぶ——外の識別子を掴んでいれば、ここで ReferenceError になる。
*
* 本物のブラウザを通す経路は browser.recording-secret.e2e.test.ts。
*/
function locatorFor(html: string, id: string): Locator {
document.body.innerHTML = html;
const el = document.getElementById(id);
if (!el) throw new Error('欄が見つからない: ' + id);
return {
evaluate: async (pageFunction: unknown, arg: unknown) => {
if (typeof pageFunction !== 'function') throw new Error('関数を渡すこと');
const fn = (0, eval)('(' + pageFunction.toString() + ')') as (e: unknown, a: unknown) => unknown;
return fn(el, arg);
},
} as unknown as Locator;
}
describe('isSecretFieldLocator — fill 前の下調べ', () => {
it('type=password の欄は伏せる', async () => {
expect(await isSecretFieldLocator(locatorFor('<input id="pw" type="password">', 'pw'))).toBe(
true,
);
});
it('`<label for>` が付いただけの欄も伏せる', async () => {
// 「表示」で伏せ字を解いた欄。type は text、name 無し、id は機械生成。
const locator = locatorFor(
'<label for=":r3:">パスワード</label><input id=":r3:" type="text">',
':r3:',
);
expect(await isSecretFieldLocator(locator)).toBe(true);
});
it('入れ子の `<label>` に包まれた欄も伏せる', async () => {
const locator = locatorFor('<label>パスワード<input id=":r4:" type="text"></label>', ':r4:');
expect(await isSecretFieldLocator(locator)).toBe(true);
});
it('aria-labelledby で名札を指している欄も伏せる', async () => {
const locator = locatorFor(
'<span id="lbl">パスワード</span><input id=":r5:" type="text" aria-labelledby="lbl">',
':r5:',
);
expect(await isSecretFieldLocator(locator)).toBe(true);
});
it('aria-label / placeholder の欄も伏せる', async () => {
expect(
await isSecretFieldLocator(locatorFor('<input id="a" type="text" aria-label="パスワード">', 'a')),
).toBe(true);
expect(
await isSecretFieldLocator(locatorFor('<input id="b" type="text" placeholder="暗証番号">', 'b')),
).toBe(true);
});
it('ふつうの欄は伏せない(伏せ過ぎの回帰)', async () => {
expect(
await isSecretFieldLocator(locatorFor('<input id="u" type="text" name="username">', 'u')),
).toBe(false);
expect(
await isSecretFieldLocator(
locatorFor('<label for=":r6:">Shipping address</label><input id=":r6:" type="text">', ':r6:'),
),
).toBe(false);
expect(
await isSecretFieldLocator(locatorFor('<label>配送先<input id=":r7:" type="text"></label>', ':r7:')),
).toBe(false);
});
it('属性を読めなかったら伏せる側に倒す', async () => {
const broken = {
evaluate: async () => {
throw new Error('element is not attached');
},
} as unknown as Locator;
expect(await isSecretFieldLocator(broken)).toBe(true);
});
it('待ち時間を渡す(既定の 30 秒に引きずられない)', async () => {
const calls: unknown[] = [];
const locator = {
evaluate: async (_expr: unknown, _arg: unknown, options: unknown) => {
calls.push(options);
return { tag: 'input', type: 'text' };
},
} as unknown as Locator;
await isSecretFieldLocator(locator, 1234);
expect(calls).toEqual([{ timeout: 1234 }]);
});
it('ページへ運ばれる字面に名札の組み立てが入っている', async () => {
// 名札をここで組み立て直すと、画面の読み取り・書き出しと拾い方が
// 食い違う。共通の関数がそのまま入っていることを字面で確かめる。
expect(SECRET_FIELD_ATTRS_FN.toString()).toContain('resolveSecretFieldLabel');
});
});
@@ -0,0 +1,97 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { createServer, type Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { chromium, type Browser } from 'playwright';
import { closeBrowser, executeTool, runPageActions, setSessionManager } from './browser.js';
import { executeTool as executeWebTool } from './web.js';
import { readSourceIndex } from './source-library.js';
vi.mock('./shared/readability.js', async importOriginal => ({
...(await importOriginal<typeof import('./shared/readability.js')>()),
extractReadableMarkdown: vi.fn(() => null),
}));
const TAIL = 'end-of-full-page-after-10000';
const SECRET = 'fixture-secret-input';
const body = '<div id="range"><h1>Full document</h1><p>' + 'あ'.repeat(14_000) + '</p><p>' + TAIL + '</p></div>';
let dir: string;
let server: Server;
let url: string;
let browser: Browser | undefined;
beforeEach(async () => {
dir = mkdtempSync(join(tmpdir(), 'browser-full-text-'));
server = createServer((req, res) => {
const input = req.url === '/web' ? '' : '<textarea name="apiSecret">' + SECRET + '</textarea>';
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end('<html><body>' + input + body + '</body></html>');
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
url = 'http://127.0.0.1:' + (server.address() as AddressInfo).port;
});
afterEach(async () => {
setSessionManager(null);
await closeBrowser();
await browser?.close();
browser = undefined;
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
rmSync(dir, { recursive: true, force: true });
});
it.each(['goto archive', 'extractSelector', 'session getText', 'WebFetch fallback'] as const)(
'%s saves the complete document while keeping the tool preview bounded',
async kind => {
const ctx = { workspacePath: dir, taskId: 'full-text-' + kind, userId: '1',
toolsConfig: { webfetchAllowedHosts: ['127.0.0.1'] } } as never;
let output: string | undefined;
if (kind === 'goto archive') {
await runPageActions(url, [], ctx, { isolatedContext: true });
} else if (kind === 'extractSelector') {
const result = await executeTool('BrowseWeb', { url, extractSelector: '#range' }, ctx);
expect(result?.isError).toBeFalsy();
output = result!.output;
} else if (kind === 'session getText') {
browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] });
const context = await browser.newContext();
setSessionManager({
getSession: (id: string) => id === 'full-text-session' ? { id, context } : null,
touchSession: () => {},
} as never);
const result = await executeTool('BrowseWithSession', { sessionId: 'full-text-session', url, action: 'getText' }, ctx);
expect(result?.isError).toBeFalsy();
output = result!.output;
} else {
const result = await executeWebTool('WebFetch', { url: url + '/web' }, ctx);
expect(result?.isError).toBeFalsy();
output = result!.output;
}
const entries = readSourceIndex(dir).filter(entry => entry.kind !== 'image');
expect(entries).toHaveLength(1);
const source = readFileSync(join(dir, 'source', entries[0]!.file), 'utf8');
expect(source).toContain('Full document');
expect(source.includes(TAIL), 'source contains the document tail').toBe(true);
expect(source).not.toContain('... (truncated)');
if (kind !== 'WebFetch fallback') expect(source).not.toContain(SECRET);
if (output !== undefined) {
expect(output).not.toContain(TAIL);
expect(output.length).toBeLessThan(kind === 'WebFetch fallback' ? 10_600 : 5_600);
if (kind === 'WebFetch fallback') {
expect(output).toContain('source/');
} else {
const files = readdirSync(join(dir, 'logs', 'browse')).filter(name => name.endsWith('.txt'));
expect(files).toHaveLength(1);
const full = readFileSync(join(dir, 'logs', 'browse', files[0]!), 'utf8');
expect(full.includes(TAIL), 'saved full text contains the document tail').toBe(true);
expect(full).not.toContain(SECRET);
expect(output).toContain('full ' + full.length + ' chars saved');
expect(full).not.toContain('... (truncated)');
}
}
}, 90_000,
);
@@ -0,0 +1,424 @@
/**
* BrowseWeb → ページの魚拓の配線(issue #1097)
*
* ここで見るのは純関数ではなく **配線**。
* - 開いているページの文脈で取りに行っているか(別ツールで取り直していないか)
* - 設定した上限が実際に効くところまで届いているか
* - ワークスペースに属さないタスクでも溜まるか
*
* Playwright は起動しない。BrowseWeb が使う口(evaluate / request.get / url /
* content)だけを持つ偽のページを差し込んで、呼び出し方そのものを検査する。
*/
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import sharp from 'sharp';
import type { Page } from 'playwright';
import { archiveBrowsePage, archiveGotoPage, PAGE_IMAGE_CANDIDATE_SCRIPT } from './browser.js';
import { readSourceIndex, saveSourceFile } from './source-library.js';
import { resetPageArchiveAttempts } from './page-image-archive.js';
import type { ToolContext } from './core.js';
interface RequestCall {
url: string;
options: { headers?: Record<string, string> };
}
async function noisePng(seed: number): Promise<Buffer> {
const raw = Buffer.alloc(64 * 64 * 3);
let state = (seed % 2147483646) + 1;
for (let i = 0; i < raw.length; i++) {
state = (state * 48271) % 2147483647;
raw[i] = state & 0xff;
}
return await sharp(raw, { raw: { width: 64, height: 64, channels: 3 } }).png().toBuffer();
}
function makeFakePage(opts: {
url: string;
html?: string;
images: Array<{ url: string; width: number; height: number }>;
bodies: Record<string, Buffer>;
calls: RequestCall[];
}): Page {
const html = opts.html ?? '<html><body><p>ページ本文</p></body></html>';
return {
url: () => opts.url,
content: async () => html,
// 魚拓の本文は、伏せ字を掛ける小片をページ内で走らせて起こす(issue #1236)。
// ここは配線を見る場所なので、伏せ字そのものは実ブラウザの検査に任せ、
// 走らせた結果として HTML が返る状態だけを作る。
mainFrame: () => ({ evaluate: async () => html }),
evaluate: async () => opts.images,
request: {
get: async (url: string, options: { headers?: Record<string, string> } = {}) => {
opts.calls.push({ url, options });
const body = opts.bodies[url];
return {
ok: () => body !== undefined,
body: async () => body ?? Buffer.alloc(0),
headers: () => ({ 'content-type': 'image/png' }),
};
},
},
} as unknown as Page;
}
/**
* テスト用の ToolContext。
* 画像取得の SSRF 関所は実 DNS を引くので、テストのホストは許可リストに載せて
* 名前解決を通さない(関所そのものは下の SSRF テストで literal IP で見る)。
*/
function makeCtx(workspacePath: string, tools: Record<string, unknown> = {}): ToolContext {
return {
workspacePath,
editAllowed: false,
toolsConfig: {
webfetchAllowedHosts: ['example.com', 'cdn.example.com'],
...tools,
},
} as ToolContext;
}
describe('BrowseWeb page archive wiring', () => {
let workspacePath: string;
let calls: RequestCall[];
beforeEach(() => {
workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-archive-'));
calls = [];
resetPageArchiveAttempts(); // 再訪の記憶はプロセスに残るので検査ごとに空にする
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('ワークスペースに属さないタスク(spaceId なし)でもページと画像が溜まる', async () => {
const data = await noisePng(1);
const imageUrl = 'https://example.com/figure.png';
const page = makeFakePage({
url: 'https://example.com/article',
images: [{ url: imageUrl, width: 640, height: 480 }],
bodies: { [imageUrl]: data },
calls,
});
const ctx = makeCtx(workspacePath);
const note = await archiveBrowsePage(ctx, page, 'https://example.com/article', 'ページ本文');
expect(note).toContain('source/images/');
const entries = readSourceIndex(workspacePath);
expect(entries.filter((e) => e.kind === 'image')).toHaveLength(1);
expect(entries.filter((e) => e.kind !== 'image')).toHaveLength(1);
});
it('画像は開いているページの文脈で取る(別ツールでの取り直しをしない)', async () => {
const globalFetch = vi.fn(async () => {
throw new Error('画像を別のツールで取り直してはいけない');
});
vi.stubGlobal('fetch', globalFetch);
const data = await noisePng(2);
const imageUrl = 'https://cdn.example.com/private/figure.png?sig=abc';
const page = makeFakePage({
url: 'https://example.com/article',
images: [{ url: imageUrl, width: 640, height: 480 }],
bodies: { [imageUrl]: data },
calls,
});
const ctx = makeCtx(workspacePath);
await archiveBrowsePage(ctx, page, 'https://example.com/article', 'ページ本文');
// ページのブラウザコンテキスト経由で 1 回だけ取っている
expect(calls).toHaveLength(1);
expect(calls[0]!.url).toBe(imageUrl);
// リンク元は開いているページ。Cookie と名乗りはブラウザコンテキストが持つ
expect(calls[0]!.options.headers?.referer).toBe('https://example.com/article');
// ワークスペース外の取得経路(グローバル fetch)は 1 度も使っていない
expect(globalFetch).not.toHaveBeenCalled();
});
it('設定した枚数の上限が実際に効く(tools.browseImageMaxPerPage)', async () => {
const bodies: Record<string, Buffer> = {};
const images = [];
for (let i = 0; i < 5; i++) {
const url = `https://example.com/img-${i}.png`;
bodies[url] = await noisePng(100 + i);
images.push({ url, width: 100, height: 100 });
}
const page = makeFakePage({ url: 'https://example.com/gallery', images, bodies, calls });
const ctx = makeCtx(workspacePath, { browseImageMaxPerPage: 2 });
const note = await archiveBrowsePage(ctx, page, 'https://example.com/gallery', 'ギャラリー');
expect(readSourceIndex(workspacePath).filter((e) => e.kind === 'image')).toHaveLength(2);
expect(note).toContain('2 枚');
expect(note).toContain('打ち切り');
});
it('設定した寸法の下限が実際に効く(tools.browseImageMinDimension)', async () => {
const url = 'https://example.com/icon.png';
const page = makeFakePage({
url: 'https://example.com/p',
images: [{ url, width: 48, height: 48 }],
bodies: { [url]: await noisePng(3) },
calls,
});
const ctx = makeCtx(workspacePath, { browseImageMinDimension: 64 });
await archiveBrowsePage(ctx, page, 'https://example.com/p', 'ページ本文');
expect(calls).toHaveLength(0); // 下限で落ちたので取りに行っていない
expect(readSourceIndex(workspacePath).filter((e) => e.kind === 'image')).toHaveLength(0);
});
it('設定した容量の上限が実際に効く(tools.browseImageMaxSizeMb)', async () => {
const url = 'https://example.com/huge.png';
const page = makeFakePage({
url: 'https://example.com/p',
images: [{ url, width: 640, height: 480 }],
bodies: { [url]: await noisePng(4) },
calls,
});
const ctx = makeCtx(workspacePath, { browseImageMaxSizeMb: 0.001, browseImageMinBytes: 1 });
await archiveBrowsePage(ctx, page, 'https://example.com/p', 'ページ本文');
expect(readSourceIndex(workspacePath).filter((e) => e.kind === 'image')).toHaveLength(0);
});
it('本文を取らない操作列でも goto 直後のページを魚拓する', async () => {
const url = 'https://example.com/figure.png';
const page = makeFakePage({
url: 'https://example.com/only-goto',
html: '<html><body><h1>見出し</h1><p>本文</p></body></html>',
images: [{ url, width: 200, height: 200 }],
bodies: { [url]: await noisePng(5) },
calls,
});
const ctx = makeCtx(workspacePath);
const note = await archiveGotoPage(ctx, page);
expect(note).toContain('source/images/');
const entries = readSourceIndex(workspacePath);
expect(entries.find((e) => e.kind !== 'image')!.url).toBe('https://example.com/only-goto');
expect(entries.filter((e) => e.kind === 'image')).toHaveLength(1);
});
it('同じページを開き直しても画像の行は増えない', async () => {
const url = 'https://example.com/figure.png';
const bodies = { [url]: await noisePng(6) };
const ctx = makeCtx(workspacePath);
for (let i = 0; i < 2; i++) {
const page = makeFakePage({
url: 'https://example.com/article',
images: [{ url, width: 200, height: 200 }],
bodies,
calls,
});
await archiveBrowsePage(ctx, page, 'https://example.com/article', 'ページ本文');
}
expect(readSourceIndex(workspacePath)).toHaveLength(2); // 本文 1 行 + 画像 1 行
});
// レビュー P1-1: 「本文を読んで、図が要ると気づいてブラウザで開き直す」は
// この機能がいちばん効く流れ。本文が既出だからといって画像ごと飛ばさない。
it('先にページ取得ツールで本文を溜めた URL でも、ブラウザで開けば画像が溜まる', async () => {
const pageUrl = 'https://example.com/article';
// WebFetch が先に本文を索引に載せている状態を作る
const pageFile = saveSourceFile(workspacePath, {
url: pageUrl,
body: 'ページ本文(WebFetch が先に取ったもの)',
tool: 'WebFetch',
ext: '.md',
})!;
const imageUrl = 'https://example.com/figure.png';
const page = makeFakePage({
url: pageUrl,
images: [{ url: imageUrl, width: 640, height: 480 }],
bodies: { [imageUrl]: await noisePng(21) },
calls,
});
const note = await archiveBrowsePage(makeCtx(workspacePath), page, pageUrl, 'ページ本文');
expect(note).toContain('source/images/');
const images = readSourceIndex(workspacePath).filter((e) => e.kind === 'image');
expect(images).toHaveLength(1);
// 画像の行は、先に溜まっていた本文の行を指す
expect(images[0]!.page_file).toBe(pageFile);
expect(images[0]!.page_url).toBe(pageUrl);
// 本文は二重に溜めない
expect(readSourceIndex(workspacePath).filter((e) => e.kind !== 'image')).toHaveLength(1);
});
it('画像を溜め終えたページを開き直すと、候補の収集ごと見送る', async () => {
const pageUrl = 'https://example.com/article';
const imageUrl = 'https://example.com/figure.png';
const bodies = { [imageUrl]: await noisePng(22) };
const ctx = makeCtx(workspacePath);
let evaluated = 0;
const makePage = (): Page => {
const base = makeFakePage({ url: pageUrl, images: [{ url: imageUrl, width: 200, height: 200 }], bodies, calls });
return {
...base,
evaluate: async (...args: unknown[]) => {
evaluated += 1;
return (base.evaluate as (...a: unknown[]) => Promise<unknown>)(...args);
},
} as unknown as Page;
};
await archiveBrowsePage(ctx, makePage(), pageUrl, 'ページ本文');
// プロセス内の記憶を空にして、別のプロセスで開き直した状況にする
// (見送りが索引の側でも効くことを見るため)
resetPageArchiveAttempts();
await archiveBrowsePage(ctx, makePage(), pageUrl, 'ページ本文');
expect(evaluated).toBe(1); // 2 回目はページに問い合わせすらしない
expect(calls).toHaveLength(1);
expect(readSourceIndex(workspacePath).filter((e) => e.kind === 'image')).toHaveLength(1);
});
// レビュー P2-1: 索引の判定は画像の行があるときだけ効く。1 枚も溜まらなかった
// ページはそこを素通りするので、開き直すたびに取得の枠を丸ごと使い直していた。
it('1 枚も溜まらなかったページを開き直すと、2 回目はページに問い合わせない', async () => {
const pageUrl = 'https://example.com/dead-cdn';
const ctx = makeCtx(workspacePath, { browseImageMaxPerPage: 2 });
let evaluated = 0;
const images = [];
for (let i = 0; i < 8; i++) {
images.push({ url: `https://cdn.example.com/dead-${i}.png`, width: 200, height: 200 });
}
const makePage = (): Page => {
// bodies が空なので取得は全件失敗する(1 枚も索引に残らない)
const base = makeFakePage({ url: pageUrl, images, bodies: {}, calls });
return {
...base,
evaluate: async (...args: unknown[]) => {
evaluated += 1;
return (base.evaluate as (...a: unknown[]) => Promise<unknown>)(...args);
},
} as unknown as Page;
};
await archiveBrowsePage(ctx, makePage(), pageUrl, 'ページ本文');
const attemptsFirstVisit = calls.length;
await archiveBrowsePage(ctx, makePage(), pageUrl, 'ページ本文');
expect(attemptsFirstVisit).toBe(6); // 初回の挙動は変えない(枚数の上限 2 × 3)
expect(evaluated).toBe(1); // 2 回目はページに問い合わせすらしない
expect(calls).toHaveLength(attemptsFirstVisit); // 取りに行った回数も増えない
expect(readSourceIndex(workspacePath).filter((e) => e.kind === 'image')).toHaveLength(0);
});
it('画像の取得に失敗してもブラウザ操作は落とさない', async () => {
const page = {
url: () => 'https://example.com/p',
evaluate: async () => {
throw new Error('page closed');
},
} as unknown as Page;
const ctx = makeCtx(workspacePath);
const note = await archiveBrowsePage(ctx, page, 'https://example.com/p', 'ページ本文');
expect(note).toBe('');
// 本文は残っている(画像の失敗が本文の魚拓を巻き込まない)
expect(readSourceIndex(workspacePath)).toHaveLength(1);
});
it('file:// のページは魚拓しない', async () => {
const page = makeFakePage({ url: 'file:///tmp/x.html', images: [], bodies: {}, calls });
const ctx = makeCtx(workspacePath);
expect(await archiveGotoPage(ctx, page)).toBe('');
expect(readSourceIndex(workspacePath)).toHaveLength(0);
});
});
/**
* 候補を並べる小片は、ページの中で動く。ブラウザを起動せずに中身を見るため、
* document / performance だけ差し込んで素の関数として動かす。
*/
describe('ページが読み込んだ画像の並べ方', () => {
function runScript(doc: unknown, perf: unknown): Array<{ url: string; width: number; height: number }> {
const fn = new Function('document', 'performance', `return ${PAGE_IMAGE_CANDIDATE_SCRIPT}`) as (
d: unknown,
p: unknown,
) => Array<{ url: string; width: number; height: number }>;
return fn(doc, perf);
}
it('スタイルシートの背景として敷かれた画像も候補に入る', () => {
const out = runScript(
{ images: [{ currentSrc: 'https://e.com/photo.png', naturalWidth: 800, naturalHeight: 600 }] },
{
getEntriesByType: () => [
{ name: 'https://e.com/hero.jpg', initiatorType: 'css' },
{ name: 'https://e.com/app.js', initiatorType: 'script' },
{ name: 'https://e.com/style.css', initiatorType: 'link' },
],
},
);
expect(out.map((c) => c.url)).toEqual(['https://e.com/photo.png', 'https://e.com/hero.jpg']);
// <img> が先。取りに行く回数には上限があるので、装飾で枠を食い潰させない
expect(out[0]!.width).toBe(800);
// 背景の実寸は取れない。取れないものは残す側に倒す設計
expect(out[1]!.width).toBe(0);
});
it('同じ URL が両方に出ても 1 件にする', () => {
const out = runScript(
{ images: [{ currentSrc: 'https://e.com/a.png', naturalWidth: 100, naturalHeight: 100 }] },
{ getEntriesByType: () => [{ name: 'https://e.com/a.png', initiatorType: 'img' }] },
);
expect(out).toHaveLength(1);
});
it('資源の一覧が使えないページでも <img> だけで進む', () => {
const out = runScript(
{ images: [{ currentSrc: 'https://e.com/a.png', naturalWidth: 100, naturalHeight: 100 }] },
{
getEntriesByType: () => {
throw new Error('not supported');
},
},
);
expect(out.map((c) => c.url)).toEqual(['https://e.com/a.png']);
});
});
describe('画像取得の SSRF 関所', () => {
let workspacePath: string;
beforeEach(() => {
workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-archive-ssrf-'));
});
// page.request はページの route 傍受を通らないので、ここで止めないと
// ページに仕込まれた <img src="http://169.254.169.254/..."> で内部を取りに行ける。
it('プライベート宛の画像は取りに行かない', async () => {
const calls: RequestCall[] = [];
const internal = 'http://169.254.169.254/latest/meta-data/iam/';
const page = makeFakePage({
url: 'https://example.com/evil',
images: [{ url: internal, width: 200, height: 200 }],
bodies: { [internal]: Buffer.from('SECRET') },
calls,
});
const ctx = makeCtx(workspacePath);
await archiveBrowsePage(ctx, page, 'https://example.com/evil', 'ページ本文');
expect(calls).toHaveLength(0);
expect(readSourceIndex(workspacePath).filter((e) => e.kind === 'image')).toHaveLength(0);
});
});
@@ -0,0 +1,418 @@
import { describe, it, expect, afterAll } from 'vitest';
import { createServer, type Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import { writeFileSync, mkdtempSync, readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { chromium } from 'playwright';
import { runPageActions, executeTool, setSessionManager, closeBrowser } from './browser.js';
import { readSourceIndex } from './source-library.js';
/**
* 配線の検証(issue #1236 レビュー 4 巡目・続き)。
*
* ページ全体を `page.content()` から起こす経路には伏せ字の関所が無かった。
* `htmlToText` はタグを剥がすだけで、サーバが最初から書き込んでいた
* `<textarea>` の中身は**テキストとして生き残る**。行き先は 2 つ:
*
* - `archiveGotoPage`(`source/` の魚拓)— ページを開くたびに必ず通る
* - `BrowseWithSession` の `getText`(セレクタ無し)— 全文を取るたびに通る
*
* どちらも「引数の指定に関係なく開けば通る」ので、伏せ字を掛けた要素の
* 取り出しより露出が広い。ここは本物の Chromium を通して見る。
*/
const SECRET = 'sk-live-PREFILLED-PAGE-4k2m';
/** ふつうの欄。伏せ過ぎの回帰を捕まえる。 */
const VISIBLE_NOTE = 'visible-note-gamma';
/** 本文のふつうの文字。読み取りが壊れて空になったら気づけるようにする。 */
const VISIBLE_TEXT = 'visible-heading-delta';
const PAGE_HTML = `<html><head><title>secret-page-title</title></head><body>
<div id="panel">
<h1>${VISIBLE_TEXT}</h1>
<textarea id="secret" name="apiSecret">${SECRET}</textarea>
<textarea id="note" name="comment">${VISIBLE_NOTE}</textarea>
</div>
</body></html>`;
afterAll(async () => {
await closeBrowser();
});
/** dir 以下の全ファイルを列挙する。 */
function walkFiles(dir: string): string[] {
const out: string[] = [];
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) out.push(...walkFiles(p));
else if (e.isFile()) out.push(p);
}
return out;
}
/** ワークスペースへ落ちたファイルを全部読んで 1 本につなぐ。 */
function readAll(dir: string, skip: string[] = []): string {
return walkFiles(dir)
.filter((p) => !skip.includes(p))
.map((p) => `--- ${p}\n${readFileSync(p, 'utf-8')}`)
.join('\n');
}
describe('ページ全体を読む経路に入力欄の中身が残らない', () => {
it('魚拓(source/)に textarea の中身を残さない', async () => {
// 魚拓は http(s) のページだけが対象なので、file:// では素通りしてしまう。
// ここだけは本物の HTTP で出す。
const server: Server = createServer((_req, res) => {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end(PAGE_HTML);
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const port = (server.address() as AddressInfo).port;
const dir = mkdtempSync(join(tmpdir(), 'archive-secret-'));
const ctx = {
workspacePath: dir,
taskId: `t-${Date.now()}`,
userId: '1',
// 画像取得・遷移の SSRF 関所はローカル宛を落とすので、テストのホストだけ通す。
toolsConfig: { webfetchAllowedHosts: ['127.0.0.1'] },
} as never;
try {
await runPageActions(`http://127.0.0.1:${port}/`, [], ctx, { isolatedContext: true });
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
// 魚拓そのものが残っていること。残っていなければ「秘密が無い」は素通りする。
const entries = readSourceIndex(dir).filter((e) => e.kind !== 'image');
expect(entries).toHaveLength(1);
const dumped = readAll(dir);
expect(dumped).toContain(VISIBLE_TEXT);
expect(dumped).toContain('[masked]');
// 伏せ過ぎていないこと。ふつうの欄の中身は今までどおり読める。
expect(dumped).toContain(VISIBLE_NOTE);
expect(dumped).not.toContain(SECRET);
// 伏せる作業に使う印を書き出しに残さない。
expect(dumped).not.toContain('data-ao-mask');
}, 90000);
it('セレクタ無しの getText が textarea の中身を返さない', async () => {
const dir = mkdtempSync(join(tmpdir(), 'gettext-secret-'));
const pagePath = join(dir, 'page.html');
writeFileSync(pagePath, PAGE_HTML);
const browser = await chromium.launch({ args: ['--no-sandbox'] });
const context = await browser.newContext();
const fakeSession = { id: 'sess-1', context };
const fakeSm = {
getSession: (id: string) => (id === 'sess-1' ? fakeSession : null),
touchSession: () => {},
};
setSessionManager(fakeSm as never);
try {
const r = await executeTool(
'BrowseWithSession',
{ sessionId: 'sess-1', url: `file://${pagePath}`, action: 'getText' },
{ workspacePath: dir, toolsConfig: {} } as never,
);
expect(r.isError).toBeFalsy();
const output = r.output + JSON.stringify(r.structuredBlocks ?? []);
expect(output).not.toContain(SECRET);
expect(output).toContain('[masked]');
expect(output).toContain(VISIBLE_TEXT);
expect(output).toContain(VISIBLE_NOTE);
expect(output).not.toContain('data-ao-mask');
// 全文は logs/browse/*.txt にも落ちる。返り値だけ伏せても意味が無い。
expect(readAll(dir, [pagePath])).not.toContain(SECRET);
} finally {
setSessionManager(null);
await browser.close();
}
}, 90000);
it('`<html>` を持たない文書(SVG)でも全文が読める', async () => {
// 伏せ字はページの根から起こす。根を `html` で探すと、SVG を直に開いたときに
// 見つからず(実測: `document.body` も null)、本文がまるごと落ちる。
const dir = mkdtempSync(join(tmpdir(), 'gettext-svg-'));
const pagePath = join(dir, 'pic.svg');
writeFileSync(
pagePath,
'<svg xmlns="http://www.w3.org/2000/svg"><text x="0" y="10">svg-text-epsilon</text></svg>',
);
const browser = await chromium.launch({ args: ['--no-sandbox'] });
const context = await browser.newContext();
const fakeSm = {
getSession: (id: string) => (id === 'sess-1' ? { id: 'sess-1', context } : null),
touchSession: () => {},
};
setSessionManager(fakeSm as never);
try {
const r = await executeTool(
'BrowseWithSession',
{ sessionId: 'sess-1', url: `file://${pagePath}`, action: 'getText' },
{ workspacePath: dir, toolsConfig: {} } as never,
);
expect(r.isError).toBeFalsy();
expect(r.output + readAll(dir, [pagePath])).toContain('svg-text-epsilon');
} finally {
setSessionManager(null);
await browser.close();
}
}, 90000);
});
/**
* 範囲を指定した読み取り(issue #1236 レビュー 5 巡目)。
*
* ここは `innerText` で読んでいた。実測(Chromium): `innerText` は
* **描画されている**要素なら `<textarea>` の中身を含まないが、描画されて
* いない要素では `textContent` に落ち、中身をそのまま返す。ふつうに起きる
* 置き方は 3 つ——親が `display:none`(切り替えていないタブ)、親に `hidden`
* 属性、要素そのものが `display:none`(畳んだ折りたたみ・開いていない
* ダイアログ)。「見えていないから安全」ではなく、**見えていないときだけ漏れる**。
*
* 入口は 2 つある(BrowseWeb の getText と BrowseWithSession の getText)。
* 片方だけ直しても気づけないよう、テストも 2 本に分けてある。
*/
const HIDDEN_CASES = [
{ label: '親が display:none', selector: '#inner-a', marker: 'hidden-marker-a' },
{ label: '親に hidden 属性', selector: '#inner-b', marker: 'hidden-marker-b' },
{ label: '要素そのものが display:none', selector: '#self-c', marker: 'hidden-marker-c' },
];
const HIDDEN_HTML = `<html><body>
<h1>${VISIBLE_TEXT}</h1>
<div style="display:none">
<div id="inner-a"><span>hidden-marker-a</span><textarea name="apiSecret">${SECRET}</textarea></div>
</div>
<div hidden>
<div id="inner-b"><span>hidden-marker-b</span><textarea name="apiSecret">${SECRET}</textarea></div>
</div>
<div id="self-c" style="display:none">
<span>hidden-marker-c</span><textarea name="apiSecret">${SECRET}</textarea>
<textarea name="comment">${VISIBLE_NOTE}</textarea>
</div>
</body></html>`;
describe('描画されていない範囲を指定した読み取りに入力欄の中身が残らない', () => {
it('BrowseWeb の getText(セレクタ付き)', async () => {
const dir = mkdtempSync(join(tmpdir(), 'gettext-hidden-basic-'));
const pagePath = join(dir, 'hidden.html');
writeFileSync(pagePath, HIDDEN_HTML);
const ctx = { workspacePath: dir, taskId: `t-${Date.now()}`, userId: '1', toolsConfig: {} } as never;
for (const c of HIDDEN_CASES) {
const r = await runPageActions(
`file://${pagePath}`,
[{ type: 'getText', selector: c.selector }],
ctx,
{ isolatedContext: true },
);
const text = r.texts.join('\n');
// 読み取り自体が生きていること。空なら「秘密が無い」は素通りする。
expect(text, `${c.label}: 本文が取れていない`).toContain(c.marker);
expect(text, `${c.label}: 秘密が残っている`).not.toContain(SECRET);
expect(text, `${c.label}: 伏せた跡が無い`).toContain('[masked]');
}
// 全文は logs/browse/*.txt にも落ちる。返り値だけ伏せても意味が無い。
expect(readAll(dir, [pagePath])).not.toContain(SECRET);
}, 90000);
it('BrowseWithSession の getText(セレクタ付き)', async () => {
const dir = mkdtempSync(join(tmpdir(), 'gettext-hidden-session-'));
const pagePath = join(dir, 'hidden.html');
writeFileSync(pagePath, HIDDEN_HTML);
const browser = await chromium.launch({ args: ['--no-sandbox'] });
const context = await browser.newContext();
const fakeSm = {
getSession: (id: string) => (id === 'sess-1' ? { id: 'sess-1', context } : null),
touchSession: () => {},
};
setSessionManager(fakeSm as never);
const outputs: string[] = [];
try {
for (const c of HIDDEN_CASES) {
const r = await executeTool(
'BrowseWithSession',
{
sessionId: 'sess-1',
url: `file://${pagePath}`,
action: 'getText',
selector: c.selector,
},
{ workspacePath: dir, toolsConfig: {} } as never,
);
expect(r.isError, `${c.label}: 読み取りが失敗している`).toBeFalsy();
expect(r.output, `${c.label}: 本文が取れていない`).toContain(c.marker);
expect(r.output, `${c.label}: 秘密が残っている`).not.toContain(SECRET);
expect(r.output, `${c.label}: 伏せた跡が無い`).toContain('[masked]');
outputs.push(r.output);
}
// 伏せ過ぎていないこと。ふつうの欄の中身は今までどおり読める。
const all = outputs.join('\n') + readAll(dir, [pagePath]);
expect(all).toContain(VISIBLE_NOTE);
expect(all).not.toContain(SECRET);
// 伏せる作業に使う印を書き出しに残さない。
expect(all).not.toContain('data-ao-mask');
} finally {
setSessionManager(null);
await browser.close();
}
}, 90000);
});
/**
* 範囲を指定した読み取りで本文が削られない(issue #1236 レビュー 6・7 巡目)。
*
* `innerText` から「伏せた HTML を文字に起こす」へ替えたことで、入力欄の
* 伏せ字とは無関係な 3 つの加工を**この変更で初めて踏むようになった**。
* どれも「ページ全体から本文を起こす」前提の都合で、呼び出し側が範囲を
* 選んでいるときには当てはまらない。
*
* - 1 万文字での打ち切り。`saveBrowseText` は 5000 文字を超えると全文を
* `logs/browse/*.txt` へ書き出して「続きは Read で」と案内するが、渡る前に
* 切れているのでファイルの中身も切れている。案内どおり読みに行っても続きが無い
* - `<nav>` / `<header>` / `<footer>` のブロックごとの切り捨て。指定した範囲の
* **中**にある注記・パンくず・目次が消える
* - 表のセルの区切りが無い。`1` と `200` が `1200` になる
*
* 7 巡目でさらに 2 つ見つかった。どちらも「元の `innerText` では起きなかった」もの。
*
* - `<nav>` / `<header>` / `<footer>` / `<section>` の境目で改行が入らず、
* 隣の文字とひと続きになる(6 巡目で捨てるのをやめた結果、表に出た)
* - セルの区切りに入れたタブが、後段の空白の正規化で空白 1 個に潰れる。
* 空の列が消えて列の位置がずれる
*
* 入口は 2 つ(BrowseWeb の getText と BrowseWithSession の getText)。
* 片方だけ直しても気づけないよう、ここもテストを 2 本に分ける。
*/
const NAV_MARKER = 'nav-marker-inside';
const HEADER_MARKER = 'header-marker-inside';
const FOOTER_MARKER = 'footer-marker-inside';
const SECTION_A_MARKER = 'section-marker-alpha';
const SECTION_B_MARKER = 'section-marker-beta';
/** 1 万文字より後ろに置く目印。打ち切りが復活すると消える。 */
const TAIL_MARKER = 'tail-marker-omega';
/**
* 目印は `<p>` で包まず、区画タグの直下にじかに置く。包むと `<p>` の改行が
* 境目を埋めてしまい、区画の境目で改行が入らない不具合が見えなくなる。
* 同じ理由で、タグとタグのあいだに空白や改行を入れない。
*/
const RANGE_HTML = `<html><body>
<div id="range">
<nav>${NAV_MARKER}</nav><header>${HEADER_MARKER}</header><footer>${FOOTER_MARKER}</footer><section>${SECTION_A_MARKER}</section><section>${SECTION_B_MARKER}</section>
<table><tr><td>1</td><td>200</td></tr></table>
<p>${'x'.repeat(12000)}</p>
<p>${TAIL_MARKER}</p>
</div>
</body></html>`;
/** `logs/browse/*.txt` の中身だけをつなぐ(ファイル名は混ぜない)。 */
function readBrowseTextFiles(dir: string): string {
const browseDir = join('logs', 'browse');
return walkFiles(dir)
.filter((p) => p.includes(browseDir))
.map((p) => readFileSync(p, 'utf-8'))
.join('\n');
}
/** 範囲の中身が削られていないこと。全文(打ち切り前)に対して確かめる。 */
function expectRangeIntact(label: string, text: string): void {
expect.soft(text, `${label}: nav の中身が消えている`).toContain(NAV_MARKER);
expect.soft(text, `${label}: header の中身が消えている`).toContain(HEADER_MARKER);
expect.soft(text, `${label}: footer の中身が消えている`).toContain(FOOTER_MARKER);
// 区画の境目で改行が入らないと、隣の目印とひと続きになる。
expect
.soft(text, `${label}: nav と header がひと続きになっている`)
.not.toContain(`${NAV_MARKER}${HEADER_MARKER}`);
expect
.soft(text, `${label}: header と footer がひと続きになっている`)
.not.toContain(`${HEADER_MARKER}${FOOTER_MARKER}`);
expect
.soft(text, `${label}: 隣り合う section がひと続きになっている`)
.not.toContain(`${SECTION_A_MARKER}${SECTION_B_MARKER}`);
// 空行の本数まで固定する。`\n+` にすると、実装とブラウザの `innerText` の
// 食い違いがちょうど隠れる。ここは `innerText` なら `A\nB`、実装は `A\n\nB`
// (区画タグの開きと閉じの両方で改行を出すため)。承知のうえで実装が出す
// バイトを固定している。詳しくは `shared/html.test.ts` の同じ注記を参照。
expect
.soft(text, `${label}: section の境目の空行の本数が変わった`)
.toContain(`${SECTION_A_MARKER}\n\n${SECTION_B_MARKER}`);
expect.soft(text, `${label}: 表のセルがくっついている`).not.toContain('1200');
// 空白 1 個ではなくタブであること。空白だと空の列が消えて列がずれる。
expect.soft(text, `${label}: 表のセルの区切りがタブでない`).toContain('1\t200');
expect.soft(text, `${label}: 1 万文字で打ち切られている`).toContain(TAIL_MARKER);
}
describe('範囲を指定した読み取りで本文が削られない', () => {
it('BrowseWeb の getText(セレクタ付き)', async () => {
const dir = mkdtempSync(join(tmpdir(), 'gettext-range-basic-'));
const pagePath = join(dir, 'range.html');
writeFileSync(pagePath, RANGE_HTML);
const ctx = { workspacePath: dir, taskId: `t-${Date.now()}`, userId: '1', toolsConfig: {} } as never;
const r = await runPageActions(
`file://${pagePath}`,
[{ type: 'getText', selector: '#range' }],
ctx,
{ isolatedContext: true },
);
expectRangeIntact('BrowseWeb', r.texts.join('\n'));
// 案内先のファイルにも全文が入っていること。返り値だけ直しても
// 「続きは Read で」の案内が嘘になる。
expectRangeIntact('BrowseWeb / logs/browse', readBrowseTextFiles(dir));
}, 90000);
it('BrowseWithSession の getText(セレクタ付き)', async () => {
const dir = mkdtempSync(join(tmpdir(), 'gettext-range-session-'));
const pagePath = join(dir, 'range.html');
writeFileSync(pagePath, RANGE_HTML);
const browser = await chromium.launch({ args: ['--no-sandbox'] });
const context = await browser.newContext();
const fakeSm = {
getSession: (id: string) => (id === 'sess-1' ? { id: 'sess-1', context } : null),
touchSession: () => {},
};
setSessionManager(fakeSm as never);
try {
const r = await executeTool(
'BrowseWithSession',
{
sessionId: 'sess-1',
url: `file://${pagePath}`,
action: 'getText',
selector: '#range',
},
{ workspacePath: dir, toolsConfig: {} } as never,
);
expect(r.isError).toBeFalsy();
// 返り値は先頭 5000 文字までの下読みなので、そこに載る範囲だけ見る。
expect.soft(r.output, 'nav の中身が消えている').toContain(NAV_MARKER);
expect.soft(r.output, 'header の中身が消えている').toContain(HEADER_MARKER);
expect.soft(r.output, 'footer の中身が消えている').toContain(FOOTER_MARKER);
// 全文は案内先のファイルにある。
expectRangeIntact('BrowseWithSession / logs/browse', readBrowseTextFiles(dir));
} finally {
setSessionManager(null);
await browser.close();
}
}, 90000);
});
@@ -0,0 +1,383 @@
import { describe, it, expect, afterAll } from 'vitest';
import { writeFileSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createServer, type Server } from 'node:http';
import { executeTool, closeBrowser } from './browser.js';
import { recorder, type RecordedAction } from '../browser-recorder.js';
/**
* 配線の検証(issue #1236)。
*
* 「伏せる判定」の関数だけを固めても意味がない。実際に BrowseWeb を
* 走らせて、**記録ファイルとツールの返り値にパスワードが載らない**
* ところまで見る。ブラウザは本物の Chromium(file:// で完結)。
*/
const PASSWORD = 'hunter2-topsecret-value';
const OTP = '482913';
/** 「表示」で伏せ字を解いた欄の値。type も id も手掛かりにならず、名札だけが残る。 */
const SHOWN_PASSWORD = 'shown-secret-value';
/** 名札が離れた `<label for>` にしか無い欄の値。id にも name にも手掛かりが無い。 */
const LABELED_PASSWORD = 'labeled-secret-value';
const USERNAME = 'alice-visible';
const ADDRESS = '1 Main Street';
const PAGE_HTML = `<html><body>
<form>
<input id="u" name="username" type="text">
<input id="pw" name="password" type="password">
<input id="otp" name="otpCode" type="text" autocomplete="one-time-code">
<input id="ship" name="shippingAddress" type="text">
<input id=":r3:" type="text" aria-label="パスワード">
<label for="f8x2">パスワード</label>
<input id="f8x2" type="text">
</form>
</body></html>`;
afterAll(async () => {
await closeBrowser();
});
/** dir 以下の全ファイルを列挙する。 */
function walkFiles(dir: string): string[] {
const out: string[] = [];
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) out.push(...walkFiles(p));
else if (e.isFile()) out.push(p);
}
return out;
}
describe('BrowseWeb の記録に入力欄の中身が残らない', () => {
it('パスワード欄と使い捨てコード欄は記録にも書き出しにも値を載せない', async () => {
const dir = mkdtempSync(join(tmpdir(), 'rec-secret-'));
const userFolderRoot = join(dir, 'users-root');
mkdirSync(userFolderRoot, { recursive: true });
const page = join(dir, 'login.html');
writeFileSync(page, PAGE_HTML);
const taskId = `t-${Date.now()}`;
const ctx = { workspacePath: dir, taskId, userId: '1', toolsConfig: {} } as never;
const res = await executeTool(
'BrowseWeb',
{
recordTo: 'login-flow',
actions: [
{ type: 'goto', url: `file://${page}` },
{ type: 'fill', selector: '#u', value: USERNAME },
{ type: 'fill', selector: '#pw', value: PASSWORD },
{ type: 'fill', selector: '#otp', value: OTP },
{ type: 'fill', selector: '#ship', value: ADDRESS },
{ type: 'fill', selector: '[aria-label="パスワード"]', value: SHOWN_PASSWORD },
{ type: 'fill', selector: '#f8x2', value: LABELED_PASSWORD },
{ type: 'getText' },
],
},
ctx,
);
expect(res).not.toBeNull();
expect(res!.isError).toBeFalsy();
// ── 1. ツールの返り値(ページの書き出し)─────────────────
const output = res!.output + JSON.stringify(res!.structuredBlocks ?? []);
expect(output).not.toContain(PASSWORD);
expect(output).not.toContain(OTP);
expect(output).not.toContain(SHOWN_PASSWORD);
expect(output).not.toContain(LABELED_PASSWORD);
expect(output).toContain('value=[masked]');
// 伏せ過ぎていないこと。ふつうの欄は今までどおり見える。
expect(output).toContain(USERNAME);
expect(output).toContain(ADDRESS);
// ── 2. ワークスペースに落ちたファイル一式 ──────────────────
// このツール実行がワークスペースに落としたもの(source/ の魚拓など)を
// まとめて見る。logs/raw への保存は tools/index.ts の dispatcher が
// ツールの返り値をそのまま書き出すもので、ここでは呼ばれない。返り値自体は
// 1. で確認済みなので、そちらは構造上あわせて担保される。
// 元ページ (login.html) 自体は value を持たないので引っ掛からない。
for (const f of walkFiles(dir)) {
const body = readFileSync(f, 'utf-8');
expect(body, `${f} にパスワードが残っている`).not.toContain(PASSWORD);
expect(body, `${f} に使い捨てコードが残っている`).not.toContain(OTP);
expect(body, `${f} に名札だけが手掛かりの欄の値が残っている`).not.toContain(SHOWN_PASSWORD);
expect(body, `${f} に離れた名札の欄の値が残っている`).not.toContain(LABELED_PASSWORD);
}
// ── 3. 記録ファイル ────────────────────────────────────────
const recordingPath = recorder.flush(taskId, userFolderRoot, '1');
expect(recordingPath).not.toBeNull();
const raw = readFileSync(recordingPath!, 'utf-8');
expect(raw).not.toContain(PASSWORD);
expect(raw).not.toContain(OTP);
expect(raw).not.toContain(SHOWN_PASSWORD);
expect(raw).not.toContain(LABELED_PASSWORD);
const actions = (JSON.parse(raw) as { actions: RecordedAction[] }).actions;
const fills = actions.filter((a) => a.type === 'fill');
expect(fills).toHaveLength(6);
const byRef = (sel: string): RecordedAction =>
fills.find((a) => (a.selector ?? '') === sel || (a.originalRef ?? '') === sel)!;
const pwAction = byRef('#pw');
expect(pwAction.valueMasked).toBe(true);
expect(pwAction.value).toBeUndefined();
const otpAction = byRef('#otp');
expect(otpAction.valueMasked).toBe(true);
expect(otpAction.value).toBeUndefined();
// 名札だけが手掛かりの欄(「表示」で伏せ字を解いた欄)も伏せる。
// id は枠組みが機械生成した形(`:r3:`)で name も無いため、記録側の
// セレクタは位置指定に落ちる。並び順(5 番目の fill)で取る。
const shownAction = fills[4];
expect(shownAction.valueMasked).toBe(true);
expect(shownAction.value).toBeUndefined();
// 名札が離れた `<label for>` にしか無い欄。type=text・name 無し・
// id (`f8x2`) にも語が無いので、名札を辿れなければ素通りする。
const labeledAction = byRef('#f8x2');
expect(labeledAction.valueMasked).toBe(true);
expect(labeledAction.value).toBeUndefined();
const userAction = byRef('#u');
expect(userAction.valueMasked).toBeUndefined();
expect(userAction.value).toBe(USERNAME);
const shipAction = byRef('#ship');
expect(shipAction.value).toBe(ADDRESS);
}, 90000);
});
describe('録画中の fill が待ち時間を二重に使わない', () => {
it('当たらないセレクタでも、欄の下調べは短く切り上げる', async () => {
// 下調べ (isSecretFieldLocator) に待ち時間を渡していないと
// page.setDefaultTimeout の値がそのまま効き、「下調べで満了 → fill で
// もう一度満了」と 2 倍待つ(レビューの実測: 3.1 秒 → 6.0 秒)。
const dir = mkdtempSync(join(tmpdir(), 'rec-timeout-'));
const page = join(dir, 'login.html');
writeFileSync(page, PAGE_HTML);
const taskId = `t-${Date.now()}-timeout`;
const ctx = {
workspacePath: dir,
taskId,
userId: '1',
toolsConfig: { browserActionTimeout: 5000 },
} as never;
await executeTool(
'BrowseWeb',
{ recordTo: 'timeout-flow', actions: [{ type: 'goto', url: `file://${page}` }] },
ctx,
);
const started = Date.now();
await executeTool(
'BrowseWeb',
{
recordTo: 'timeout-flow',
actions: [{ type: 'fill', selector: '#does-not-exist', value: 'x' }],
},
ctx,
);
const elapsed = Date.now() - started;
// 下調べ 2 秒 + fill 5 秒 ≒ 7 秒。待ち時間が二重(5 + 5 = 10 秒)に
// なっていないことを見る。境目を 9 秒に置いてあるのは、CI 機が混んで
// 数百 ms ずれても偽の赤にならないようにするため。二重取りに戻ると
// 必ず 10 秒を超えるので、緩めても検出力は落ちない。
expect(elapsed, `fill が ${elapsed}ms 掛かっている(待ち時間の二重取り)`).toBeLessThan(9000);
recorder.flush(taskId, mkdtempSync(join(tmpdir(), 'rec-timeout-out-')), '1');
}, 60000);
});
/**
* dumpHtml の経路(issue #1236 のレビュー指摘)。
*
* 生の HTML を書き出す dumpHtml は、入力値を `value` **属性**へ書き戻す
* ページ(React の制御された input など)では打ち込んだ値をそのまま載せる。
* しかも書き出し先は 1 つではない——ツールの返り値・`source/` の魚拓・
* 長文のときの `logs/browse/*.txt` へ流れる。
*
* file:// では魚拓が作られない(persistBrowseSource が非 http(s) を弾く)ので、
* ここは **本物の http サーバ**を立てて `source/` に届くところまで見る。
* 魚拓は URL 単位で重複を畳むため、goto で入った URL とは別の URL
* (リンクを click して移動した先)で入力・書き出しを行う。
*/
/** 5000 文字の閾値を超えさせて logs/browse/*.txt も通す。 */
const PADDING = 'ページ本文のダミー。'.repeat(600);
const INDEX_HTML = `<html><body><a id="go" href="/login">ログインへ</a></body></html>`;
const LOGIN_HTML = `<html><body>
<form>
<input id="u" name="username" type="text">
<input id="pw" name="password" type="password">
<input id="otp" name="otpCode" type="text" autocomplete="one-time-code">
</form>
<p>${PADDING}</p>
<script>
// 制御された入力欄の再現: 打ち込んだ値を value 属性へ書き戻す。
document.addEventListener('input', function (e) {
e.target.setAttribute('value', e.target.value);
});
</script>
</body></html>`;
describe('dumpHtml の書き出しに入力欄の中身が残らない', () => {
let server: Server | null = null;
afterAll(async () => {
if (server) await new Promise<void>((resolve) => server!.close(() => resolve()));
});
it('value 属性へ書き戻すページでも、返り値・魚拓・ログに値が載らない', async () => {
server = createServer((req, res) => {
const body = (req.url ?? '/').startsWith('/login') ? LOGIN_HTML : INDEX_HTML;
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end(body);
});
await new Promise<void>((resolve) => server!.listen(0, '127.0.0.1', () => resolve()));
const port = (server.address() as { port: number }).port;
const base = `http://127.0.0.1:${port}`;
const dir = mkdtempSync(join(tmpdir(), 'dump-secret-'));
const ctx = {
workspacePath: dir,
taskId: `t-${Date.now()}`,
userId: '1',
// 127.0.0.1 は既定では SSRF で遮断される。テスト用に明示的に通す。
toolsConfig: { webfetchAllowedHosts: ['127.0.0.1'] },
} as never;
const res = await executeTool(
'BrowseWeb',
{
actions: [
{ type: 'goto', url: `${base}/` },
// goto した URL は goto の時点で魚拓が残る。入力する画面は
// リンクを辿った先にして、dumpHtml が最初の魚拓になるようにする。
{ type: 'click', selector: '#go' },
{ type: 'fill', selector: '#u', value: USERNAME },
{ type: 'fill', selector: '#pw', value: PASSWORD },
{ type: 'fill', selector: '#otp', value: OTP },
{ type: 'dumpHtml' },
],
},
ctx,
);
expect(res).not.toBeNull();
expect(res!.isError).toBeFalsy();
// ── 1. ツールの返り値 ────────────────────────────────────
const output = res!.output + JSON.stringify(res!.structuredBlocks ?? []);
expect(output).not.toContain(PASSWORD);
expect(output).not.toContain(OTP);
expect(output).toContain('value="[masked]"');
// 伏せ過ぎていないこと。書き戻されたふつうの欄はそのまま見える。
expect(output).toContain(`value="${USERNAME}"`);
// ── 2. source/ の魚拓 ────────────────────────────────────
// 「無い」ことの確認だけだと、魚拓が 1 つも作られていないときに素通りする。
// dumpHtml の書き出しが確かに魚拓へ届いていることを先に押さえる。
const sourceDir = join(dir, 'source');
expect(existsSync(sourceDir), 'source/ が作られていない').toBe(true);
const sourceBodies = walkFiles(sourceDir).map((f) => readFileSync(f, 'utf-8'));
const dumped = sourceBodies.filter((b) => b.includes(`value="${USERNAME}"`));
expect(dumped.length, 'dumpHtml の書き出しが魚拓に届いていない').toBeGreaterThan(0);
for (const body of dumped) expect(body).toContain('value="[masked]"');
// ── 3. logs/browse/*.txt(長文の退避先)──────────────────
const browseDir = join(dir, 'logs', 'browse');
expect(existsSync(browseDir), 'logs/browse/ が作られていない').toBe(true);
const browseFiles = walkFiles(browseDir);
expect(browseFiles.length).toBeGreaterThan(0);
const browseBody = readFileSync(browseFiles[0], 'utf-8');
expect(browseBody).toContain('value="[masked]"');
// ── 4. ワークスペースに落ちたファイル一式 ─────────────────
for (const f of walkFiles(dir)) {
const body = readFileSync(f, 'utf-8');
expect(body, `${f} にパスワードが残っている`).not.toContain(PASSWORD);
expect(body, `${f} に使い捨てコードが残っている`).not.toContain(OTP);
}
}, 90000);
it('独自要素が複製で子を増やすページでも、伏せる欄を取り違えない', async () => {
// `cloneNode(true)` は複製した独自要素をその場で昇格させ、構築子をもう一度
// 走らせる。構築子が自分に子を足す独自要素があると、複製側だけ要素が増える。
// 元と複製を添字で突き合わせる実装だと 1 つずれ、**利用者名を伏せて
// パスワードを素通しする**(読み手には伏せたように見える最悪の形)。
//
// jsdom は独自要素の昇格する間合いが本物のブラウザと違うので、ここは
// 本物の Chromium で見る(issue #1236 レビュー 3 巡目)。
const dir = mkdtempSync(join(tmpdir(), 'dump-ce-'));
const page = join(dir, 'ce.html');
writeFileSync(
page,
`<html><body>
<div id="root">
<app-banner></app-banner>
<form>
<input id="u" name="username" type="text">
<input id="pw" name="password" type="password">
</form>
</div>
<script>
class AppBanner extends HTMLElement {
constructor() {
super();
const s = document.createElement('span');
s.textContent = 'W';
this.appendChild(s);
this.appendChild(document.createElement('input'));
}
}
customElements.define('app-banner', AppBanner);
document.addEventListener('input', function (e) {
e.target.setAttribute('value', e.target.value);
});
</script>
</body></html>`,
);
const ctx = { workspacePath: dir, taskId: `t-${Date.now()}`, userId: '1', toolsConfig: {} } as never;
const res = await executeTool(
'BrowseWeb',
{
actions: [
{ type: 'goto', url: `file://${page}` },
{ type: 'fill', selector: '#u', value: USERNAME },
{ type: 'fill', selector: '#pw', value: PASSWORD },
// 既定の depth 3 だと入力欄が切り詰めに掛かりうるので明示する。
{ type: 'dumpHtml', selector: '#root', depth: 6 },
],
},
ctx,
);
expect(res).not.toBeNull();
expect(res!.isError).toBeFalsy();
const output = res!.output + JSON.stringify(res!.structuredBlocks ?? []);
// 取り違えると、こちらが生で出る。
expect(output).not.toContain(PASSWORD);
// 取り違えると、こちらが [masked] になる(伏せ過ぎの向きも捕まえる)。
expect(output).toContain(`value="${USERNAME}"`);
expect(output).toContain('value="[masked]"');
// 印を出力に残さない。
expect(output).not.toContain('data-ao-mask');
for (const f of walkFiles(dir).filter((p) => !p.endsWith('ce.html'))) {
const body = readFileSync(f, 'utf-8');
expect(body, `${f} にパスワードが残っている`).not.toContain(PASSWORD);
}
}, 90000);
});
@@ -0,0 +1,142 @@
// @vitest-environment jsdom
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { SNAPSHOT_SCRIPT } from './browser.js';
/**
* SNAPSHOT_SCRIPT はページ内で eval される文字列。jsdom の上で実際に
* 走らせて、書き出しにパスワードが載らないことを見る(issue #1236)。
*
* jsdom の getBoundingClientRect は常に 0×0 を返すので、そのままだと
* isVisible() が全要素を落として何も走査されない。差し替えておく。
*/
let originalRect: () => DOMRect;
beforeAll(() => {
originalRect = Element.prototype.getBoundingClientRect;
Element.prototype.getBoundingClientRect = function (): DOMRect {
return { width: 100, height: 20, top: 0, left: 0, right: 100, bottom: 20, x: 0, y: 0, toJSON() {} } as DOMRect;
};
});
afterAll(() => {
Element.prototype.getBoundingClientRect = originalRect;
});
/** html を置いてから values を実際の .value に流し込み、書き出しを取る。 */
function snapshot(html: string, values: Record<string, string> = {}): string {
document.body.innerHTML = html;
for (const [id, v] of Object.entries(values)) {
(document.getElementById(id) as HTMLInputElement | HTMLTextAreaElement).value = v;
}
const result = (0, eval)(SNAPSHOT_SCRIPT) as { text: string };
return result.text;
}
describe('SNAPSHOT_SCRIPT — 入力欄の中身', () => {
it('type=password の欄は中身を書き出さず、値があることだけ残す', () => {
const text = snapshot('<input id="pw" type="password" name="password">', { pw: 'hunter2-topsecret' });
expect(text).not.toContain('hunter2-topsecret');
expect(text).toContain('value=[masked]');
});
it('name からパスワードと分かる欄も伏せる(type=text でも)', () => {
const text = snapshot('<input id="p" type="text" name="loginPassword">', { p: 'correct-horse-battery' });
expect(text).not.toContain('correct-horse-battery');
expect(text).toContain('value=[masked]');
});
it('autocomplete=one-time-code の欄も伏せる', () => {
const text = snapshot('<input id="c" type="text" autocomplete="one-time-code">', { c: '482913' });
expect(text).not.toContain('482913');
expect(text).toContain('value=[masked]');
});
it('空のパスワード欄には印を出さない(値の有無だけが残る)', () => {
const text = snapshot('<input id="pw" type="password" name="password">');
expect(text).not.toContain('[masked]');
});
it('名札だけが手掛かりの欄も伏せる(「表示」で伏せ字を解いた欄)', () => {
// type は text に変わり、id は枠組みの機械生成。残る手掛かりは名札だけ。
const text = snapshot('<input id=":r3:" type="text" aria-label="Password">', {
':r3:': 'correct-horse-battery',
});
expect(text).not.toContain('correct-horse-battery');
expect(text).toContain('value=[masked]');
});
it('`<label for>` が付いただけの欄も伏せる(伏せ字を解いた欄)', () => {
// type=text・name 無し・機械生成の id。手掛かりは離れた場所の名札だけ。
const text = snapshot('<label for=":r3:">パスワード</label><input id=":r3:" type="text">', {
':r3:': 'correct-horse-battery',
});
expect(text).not.toContain('correct-horse-battery');
expect(text).toContain('value=[masked]');
});
it('入れ子の `<label>` に包まれた欄も伏せる', () => {
const text = snapshot('<label>パスワード<input id=":r4:" type="text"></label>', {
':r4:': 'correct-horse-battery',
});
expect(text).not.toContain('correct-horse-battery');
expect(text).toContain('value=[masked]');
});
it('aria-labelledby で名札を指している欄も伏せる', () => {
const text = snapshot(
'<span id="lbl">パスワード</span><input id=":r5:" type="text" aria-labelledby="lbl">',
{ ':r5:': 'correct-horse-battery' },
);
expect(text).not.toContain('correct-horse-battery');
expect(text).toContain('value=[masked]');
});
it('離れた名札がふつうの語なら伏せない(伏せ過ぎの回帰)', () => {
const text = snapshot('<label for=":r6:">Shipping address</label><input id=":r6:" type="text">', {
':r6:': '東京都千代田区',
});
expect(text).toContain('value="東京都千代田区"');
});
it('日本語の名札でも伏せる', () => {
const text = snapshot('<input id="x1" type="text" placeholder="パスワード">', {
x1: 'correct-horse-battery',
});
expect(text).not.toContain('correct-horse-battery');
expect(text).toContain('value=[masked]');
});
it('ふつうの名札の欄は伏せない(伏せ過ぎの回帰)', () => {
const text = snapshot('<input id="q" type="text" aria-label="検索">', { q: 'ラーメン' });
expect(text).toContain('value="ラーメン"');
});
it('ふつうの入力欄はこれまでどおり中身が載る(伏せ過ぎの回帰)', () => {
const text = snapshot('<input id="u" type="text" name="username">', { u: 'alice' });
expect(text).toContain('value="alice"');
});
it('最初から中身が入っている textarea が秘密の欄なら、その文字を書き出さない', () => {
// textarea の中身は要素のテキストそのもの。value=[masked] だけ直しても、
// 名札の代わり(elementName)と本文の走査(walk)から同じ文字が 2 回出る。
const text = snapshot('<textarea id="ta" name="apiSecret">sk-live-PREFILLED-TEXTAREA-123</textarea>');
expect(text).not.toContain('sk-live-PREFILLED-TEXTAREA-123');
expect(text).toContain('value=[masked]');
});
it('ふつうの textarea の中身はこれまでどおり載る(伏せ過ぎの回帰)', () => {
const text = snapshot('<textarea id="c" name="comment">きょうの下書き-visible</textarea>');
expect(text).toContain('きょうの下書き-visible');
expect(text).not.toContain('[masked]');
});
it('パスワード欄とふつうの欄が並んでいても、伏せるのは片方だけ', () => {
const text = snapshot(
'<form><input id="u" name="username"><input id="pw" type="password" name="password"></form>',
{ u: 'alice', pw: 'hunter2-topsecret' },
);
expect(text).toContain('value="alice"');
expect(text).toContain('value=[masked]');
expect(text).not.toContain('hunter2-topsecret');
});
});
@@ -0,0 +1,73 @@
// タスク用ブラウザ(BrowseWeb)の遮断が、寄せ集めたあとも同じままであること。
//
// 判定を shared/ssrf.ts へ移したのは検索用と共有するためだが、タスク用だけの
// 事情——ファイルの宛先をワークスペース内へ読み替える——を落とすと、
// エージェントが自分で書いた成果物を開けなくなる。そこを固定する (issue #1148)。
//
// ここで見るのは判定の中身だけ。**その判定がブラウザに掛かっているか**(配線)は
// browser.task-ssrf-wiring.test.ts で別に見る。
import { describe, expect, it } from 'vitest';
import * as os from 'os';
import * as path from 'path';
import { pathToFileURL } from 'url';
import { taskBrowserSsrfGuardOptions } from './browser.js';
import { installContextSsrfGuard } from './shared/ssrf.js';
type Handler = (route: unknown) => Promise<void>;
class FakeRoute {
aborted: string | null = null;
continued: { url?: string } | null = null;
constructor(private readonly url: string) {}
request(): { url: () => string } { return { url: () => this.url }; }
async abort(reason: string): Promise<void> { this.aborted = reason; }
async continue(opts?: { url?: string }): Promise<void> { this.continued = opts ?? {}; }
}
class FakeContext {
handler: Handler | null = null;
async route(_pattern: string, handler: Handler): Promise<void> { this.handler = handler; }
}
const workspace = path.join(os.tmpdir(), 'task-browser-ws');
async function guarded(allowedHosts: string[] = []): Promise<FakeContext> {
const context = new FakeContext();
await installContextSsrfGuard(context as never, taskBrowserSsrfGuardOptions(allowedHosts, workspace));
expect(context.handler).not.toBeNull();
return context;
}
async function run(context: FakeContext, url: string): Promise<FakeRoute> {
const route = new FakeRoute(url);
await context.handler!(route);
return route;
}
describe('タスク用ブラウザの遮断', () => {
it('ワークスペース内のファイルは読み替えて通す', async () => {
const context = await guarded();
const inside = pathToFileURL(path.join(workspace, 'output', 'report.html')).href;
const route = await run(context, inside);
expect(route.aborted).toBeNull();
expect(route.continued?.url).toBe(inside);
});
it('ワークスペースの外のファイルは断つ', async () => {
const context = await guarded();
const route = await run(context, 'file:///etc/passwd');
expect(route.aborted).toBe('blockedbyclient');
});
it('ローカル宛は断つ', async () => {
const context = await guarded();
expect((await run(context, 'http://localhost:8080/')).aborted).toBe('blockedbyclient');
expect((await run(context, 'http://127.0.0.1/')).aborted).toBe('blockedbyclient');
expect((await run(context, 'http://169.254.169.254/')).aborted).toBe('blockedbyclient');
});
it('明示的に許可された宛先は通す', async () => {
const context = await guarded(['gitea.internal']);
expect((await run(context, 'http://gitea.internal/api')).aborted).toBeNull();
});
});
@@ -0,0 +1,189 @@
// タスク用ブラウザ(BrowseWeb)の遮断が、ちゃんと**掛かっている**こと (issue #1148)。
//
// 判定の中身は browser.task-ssrf-guard.test.ts が見ている。そちらは判定を直に
// 呼ぶので、掛ける 1 行を消しても落ちない。実際、掛ける 1 行を消しても全件緑の
// ままだった。ここはその穴を埋める——ページを取る経路を通して、遮断が
// 載っているかを見る。
//
// ページを取る経路は 2 つあり、それぞれ別に落ちるようにしてある。
//
// | 経路 | ここでの名前 |
// |----------------------------------|--------------|
// | ふだんの BrowseWeb | 共有 |
// | loopback を一時的に許す使い捨て | 使い捨て |
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { pathToFileURL } from 'url';
vi.mock('../../config.js', () => ({
loadConfig: () => ({ browser: { displayMode: 'headless' } }),
}));
vi.mock('../browser-launch.js', () => ({
buildLaunchOptions: () => ({}),
applyStealthInitScript: async () => {},
applyAgentSnapshotHooks: async () => {},
}));
// getHeadlessBrowser が起動するのはこの偽ブラウザ。1 つを使い回すのは、
// browser.ts が起動済みのブラウザを覚えているため(本物と同じ)。
const poolBrowser = new (class FakeBrowser {
contextList: FakeContext[] = [];
isConnected(): boolean { return true; }
contexts(): FakeContext[] { return [...this.contextList]; }
async newContext(): Promise<FakeContext> {
const c = new FakeContext();
this.contextList.push(c);
return c;
}
})();
vi.mock('playwright', () => ({
chromium: { launch: async () => poolBrowser },
}));
const { getJobPage, openIsolatedGuardedPage } = await import('./browser.js');
type Handler = (route: unknown) => Promise<void>;
class FakeRoute {
aborted: string | null = null;
continued: { url?: string } | null = null;
constructor(private readonly url: string) {}
request(): { url: () => string } { return { url: () => this.url }; }
async abort(reason: string): Promise<void> { this.aborted = reason; }
async continue(opts?: { url?: string }): Promise<void> { this.continued = opts ?? {}; }
}
class FakePage {
private ownHandler: Handler | null = null;
/** このページに効くハンドラ(ページ単位が無ければブラウザ単位のもの) */
get handler(): Handler | null { return this.ownHandler ?? this.ctx.handler; }
/** 最初の遷移の時点で遮断が掛かり終わっていたか */
guardedAtGoto: boolean | null = null;
constructor(private readonly ctx: FakeContext) {}
context(): FakeContext { return this.ctx; }
setDefaultTimeout(): void {}
on(): void {}
mainFrame(): unknown { return {}; }
/** 本物と同じく装着には往復がある。待たずに遷移すると素通りする */
async route(_pattern: string, handler: Handler): Promise<void> {
await new Promise((r) => setTimeout(r, 0));
this.ownHandler = handler;
}
async goto(): Promise<void> { this.guardedAtGoto = this.handler !== null; }
async close(): Promise<void> {}
}
class FakeContext {
listeners: Array<(page: FakePage) => void> = [];
/** ブラウザ単位のハンドラ。このコンテキストの全ページに効く */
handler: Handler | null = null;
opened: FakePage[] = [];
browser(): unknown { return poolBrowser; }
on(event: string, listener: (page: FakePage) => void): void {
if (event === 'page') this.listeners.push(listener);
}
/** 本物と同じく往復がある */
async route(_pattern: string, handler: Handler): Promise<void> {
await new Promise((r) => setTimeout(r, 0));
this.handler = handler;
}
pages(): FakePage[] { return [...this.opened]; }
async newPage(): Promise<FakePage> {
const p = new FakePage(this);
this.opened.push(p);
for (const l of this.listeners) l(p);
return p;
}
async close(): Promise<void> {}
}
let workspace = '';
beforeEach(() => {
// ワークスペースごとにコンテキストが分かれるので、毎回別の場所を使う
workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'task-ssrf-wiring-'));
poolBrowser.contextList = [];
});
afterEach(() => {
fs.rmSync(workspace, { recursive: true, force: true });
});
function ctx(): never {
return { workspacePath: workspace, toolsConfig: {} } as never;
}
async function blocks(handler: Handler | null, url: string): Promise<boolean> {
if (!handler) return false;
const route = new FakeRoute(url);
await handler(route);
return route.aborted === 'blockedbyclient';
}
describe('共有のブラウザ(ふだんの BrowseWeb)', () => {
it('ローカル宛を断つ', async () => {
const page = await getJobPage(ctx(), [], 1_000) as unknown as FakePage;
expect(page.handler, '遮断が掛かっていない').not.toBeNull();
expect(await blocks(page.handler, 'http://127.0.0.1:11434/api/tags')).toBe(true);
expect(await blocks(page.handler, 'http://169.254.169.254/latest/meta-data/')).toBe(true);
});
it('最初の遷移より前に遮断が掛かり終わっている', async () => {
const page = await getJobPage(ctx(), [], 1_000) as unknown as FakePage;
await page.goto();
expect(page.guardedAtGoto).toBe(true);
});
it('リンクから開いた新しいページは、生まれた時点でもう掛かっている', async () => {
await getJobPage(ctx(), [], 1_000);
const context = poolBrowser.contextList[0]!;
const popup = new FakePage(context);
for (const l of context.listeners) l(popup);
// 間を置かずに最初の遷移まで進める(ポップアップは生まれた瞬間に走る)
await popup.goto();
expect(popup.guardedAtGoto).toBe(true);
expect(await blocks(popup.handler, 'http://127.0.0.1/')).toBe(true);
});
it('タスク用の判定が載っている(ワークスペース内のファイルは読み替えて通す)', async () => {
const page = await getJobPage(ctx(), [], 1_000) as unknown as FakePage;
const inside = pathToFileURL(path.join(workspace, 'output', 'report.html')).href;
const route = new FakeRoute(inside);
await page.handler!(route);
expect(route.aborted, '検索用の判定が載っている').toBeNull();
expect(route.continued?.url).toBe(inside);
});
it('明示的に許可された宛先は通す', async () => {
const page = await getJobPage(ctx(), ['gitea.internal'], 1_000) as unknown as FakePage;
const route = new FakeRoute('http://gitea.internal/api');
await page.handler!(route);
expect(route.aborted).toBeNull();
});
});
describe('使い捨てのブラウザ(loopback を一時的に許す経路)', () => {
it('ローカル宛を断つ', async () => {
const { page } = await openIsolatedGuardedPage(ctx(), []) as unknown as { page: FakePage };
expect(page.handler, '遮断が掛かっていない').not.toBeNull();
expect(await blocks(page.handler, 'http://127.0.0.1:11434/api/tags')).toBe(true);
});
it('最初の遷移より前に遮断が掛かり終わっている', async () => {
const { page } = await openIsolatedGuardedPage(ctx(), []) as unknown as { page: FakePage };
await page.goto();
expect(page.guardedAtGoto).toBe(true);
});
it('一時的に許した宛先だけ通す', async () => {
const { page } = await openIsolatedGuardedPage(ctx(), ['localhost']) as unknown as { page: FakePage };
const allowed = new FakeRoute('http://localhost:8123/');
await page.handler!(allowed);
expect(allowed.aborted).toBeNull();
expect(await blocks(page.handler, 'http://10.0.0.10/')).toBe(true);
});
});
+18 -9
View File
@@ -488,6 +488,22 @@ describe('persistBrowseSource (source library)', () => {
workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-source-'));
});
// issue #1097: ワークスペースに属さないタスクでも溜まる。絞り込みを外した
// 箇所を守る唯一の網なので、これが通らなくなったら外し忘れを疑う。
it('writes a source file + index entry for a task with NO spaceId', () => {
const file = persistBrowseSource(
{ workspacePath },
'https://example.com/non-space',
'ワークスペースに属さないタスクで取得したページ本文。',
);
expect(file).toBeTruthy();
const entries = readSourceIndex(workspacePath);
expect(entries).toHaveLength(1);
expect(entries[0]!.url).toBe('https://example.com/non-space');
expect(fs.existsSync(path.join(workspacePath, 'source', entries[0]!.file))).toBe(true);
});
it('writes a source file + index entry when spaceId is set', () => {
persistBrowseSource(
{ workspacePath, spaceId: 'space-1' },
@@ -505,15 +521,8 @@ describe('persistBrowseSource (source library)', () => {
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('skips when there is no workspace path at all', () => {
expect(persistBrowseSource({ workspacePath: '' }, 'https://example.com/x', '本文')).toBeNull();
});
it('dedupes the same URL browsed twice', () => {
File diff suppressed because it is too large Load Diff
+107
View File
@@ -34,6 +34,7 @@ describe('calendar tools', () => {
it('exposes TOOL_DEFS entries', () => {
expect(TOOL_DEFS.AddCalendarEvent?.function.name).toBe('AddCalendarEvent');
expect(TOOL_DEFS.ListCalendarEvents?.function.name).toBe('ListCalendarEvents');
expect(TOOL_DEFS.DeleteCalendarEvent?.function.name).toBe('DeleteCalendarEvent');
expect(TOOL_DEFS.CreateReminder?.function.name).toBe('CreateReminder');
expect(TOOL_DEFS.ListReminders?.function.name).toBe('ListReminders');
expect(TOOL_DEFS.CancelReminder?.function.name).toBe('CancelReminder');
@@ -152,6 +153,112 @@ describe('calendar tools', () => {
expect(result?.output).toMatch(/予定はありません/);
});
// ── DeleteCalendarEvent(#1178)────────────────────────────────────────
//
// 守る対象は「利用者が手で入れた予定」。エージェントが作った予定だけを消せる。
// 別スペースの予定は、存在そのものを漏らさないため「見つからない」に倒す。
it('DeleteCalendarEvent: agent が作った予定は消える', async () => {
const ev = await repo.createCalendarEvent({
spaceId: 'space-A', date: '2026-07-01', time: '10:00', title: 'agent の予定', createdBy: 'agent',
});
const result = await executeTool('DeleteCalendarEvent', { id: ev.id }, ctx());
expect(result?.isError).toBe(false);
expect(result?.output).toContain('agent の予定');
expect(await repo.getCalendarEvent(ev.id)).toBeNull();
});
it('DeleteCalendarEvent: 利用者が作った予定は消えず、失敗が返る', async () => {
const ev = await repo.createCalendarEvent({
spaceId: 'space-A', date: '2026-07-01', title: '人の予定', createdBy: 'user',
});
const result = await executeTool('DeleteCalendarEvent', { id: ev.id }, ctx());
expect(result?.isError).toBe(true);
expect(result?.output).toContain('利用者が作成した');
// 行が残っていることまで見る。isError だけだと削除の有無を測れない。
expect(await repo.getCalendarEvent(ev.id)).not.toBeNull();
});
it('DeleteCalendarEvent: created_by 未設定の古い行も利用者扱いで消えない', async () => {
// createdBy を渡さない = 保存時の既定 'user'。列が無かった頃の行と同じ扱い。
const ev = await repo.createCalendarEvent({ spaceId: 'space-A', date: '2026-07-01', title: '古い予定' });
const result = await executeTool('DeleteCalendarEvent', { id: ev.id }, ctx());
expect(result?.isError).toBe(true);
expect(await repo.getCalendarEvent(ev.id)).not.toBeNull();
});
it('DeleteCalendarEvent: 別スペースの予定は消えず、見つからないと返る', async () => {
const ev = await repo.createCalendarEvent({
spaceId: 'space-B', date: '2026-07-01', title: '別スペースの予定', createdBy: 'agent',
});
const result = await executeTool('DeleteCalendarEvent', { id: ev.id }, ctx());
expect(result?.isError).toBe(true);
expect(result?.output).not.toContain('別スペースの予定');
expect(await repo.getCalendarEvent(ev.id)).not.toBeNull();
});
it('DeleteCalendarEvent: 別スペースの文面は、存在しない id の文面と一字一句同じ', async () => {
// 文面が違うと、その id の予定が別スペースにあることを本文の差で漏らす。
const other = await repo.createCalendarEvent({
spaceId: 'space-B', date: '2026-07-01', title: 'x', createdBy: 'agent',
});
const otherUser = await repo.createCalendarEvent({
spaceId: 'space-B', date: '2026-07-02', title: 'y', createdBy: 'user',
});
const missing = await executeTool('DeleteCalendarEvent', { id: 999_999 }, ctx());
const crossSpaceAgent = await executeTool('DeleteCalendarEvent', { id: other.id }, ctx());
// 別スペースの「利用者が作った予定」でも作成者の文面を出さないこと
// (判定の順序が逆だとここで漏れる)。
const crossSpaceUser = await executeTool('DeleteCalendarEvent', { id: otherUser.id }, ctx());
expect(missing?.isError).toBe(true);
expect(crossSpaceAgent?.output).toBe(missing?.output);
expect(crossSpaceUser?.output).toBe(missing?.output);
});
it('DeleteCalendarEvent: すでに消えている id は見つからないと返る', async () => {
const ev = await repo.createCalendarEvent({
spaceId: 'space-A', date: '2026-07-01', title: '一度だけ', createdBy: 'agent',
});
expect((await executeTool('DeleteCalendarEvent', { id: ev.id }, ctx()))?.isError).toBe(false);
const second = await executeTool('DeleteCalendarEvent', { id: ev.id }, ctx());
expect(second?.isError).toBe(true);
expect(second?.output).toContain('見つかりません');
});
it('DeleteCalendarEvent: 数字の文字列で渡された id も受ける', async () => {
const ev = await repo.createCalendarEvent({
spaceId: 'space-A', date: '2026-07-01', title: '文字列 id', createdBy: 'agent',
});
const result = await executeTool('DeleteCalendarEvent', { id: String(ev.id) }, ctx());
expect(result?.isError).toBe(false);
expect(await repo.getCalendarEvent(ev.id)).toBeNull();
});
it('DeleteCalendarEvent: 不正な id は、見つからないではなく id の不正として失敗する', async () => {
// 文面まで見る。「見つかりません」で通ると、id の検証そのものを外しても
// 試験が素通りする(実測済み)。
for (const bad of [undefined, null, 0, -1, 1.5, '', ' ', 'abc', '1.5', '01x', {}, []]) {
const result = await executeTool('DeleteCalendarEvent', { id: bad }, ctx());
expect(result?.isError).toBe(true);
expect(result?.output).toBe('id は ListCalendarEvents が返す正の整数です');
}
});
it('DeleteCalendarEvent: スペースに属さないタスクからは呼べない', async () => {
const ev = await repo.createCalendarEvent({
spaceId: 'space-A', date: '2026-07-01', title: 'agent の予定', createdBy: 'agent',
});
const result = await executeTool('DeleteCalendarEvent', { id: ev.id }, ctx({ spaceId: undefined }));
expect(result?.isError).toBe(true);
expect(await repo.getCalendarEvent(ev.id)).not.toBeNull();
});
it('DeleteCalendarEvent: repo 未注入なら失敗する', async () => {
setCalendarRepo(null);
const result = await executeTool('DeleteCalendarEvent', { id: 1 }, ctx());
expect(result?.isError).toBe(true);
});
it('returns null for unrelated tool names', async () => {
const result = await executeTool('SomethingElse', {}, ctx());
expect(result).toBeNull();
+79 -2
View File
@@ -1,5 +1,5 @@
import type { ToolDef } from '../../llm/openai-compat.js';
import type { ToolContext, ToolResult } from './core.js';
import type { ToolContext, ToolResult } from './tool-context.js';
import type { Repository } from '../../db/repository.js';
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
@@ -59,6 +59,22 @@ const LIST_CALENDAR_EVENTS_DEF: ToolDef = {
},
};
const DELETE_CALENDAR_EVENT_DEF: ToolDef = {
type: 'function',
function: {
name: 'DeleteCalendarEvent',
description:
'実行中タスクのスペースのカレンダーから、エージェントが作成した予定(created_by=agent)を1件削除する。詳細は ReadToolDoc({ name: "DeleteCalendarEvent" }) で取得可能。',
parameters: {
type: 'object',
properties: {
id: { type: 'number', description: 'ListCalendarEvents が返す予定の id' },
},
required: ['id'],
},
},
};
const CREATE_REMINDER_DEF: ToolDef = {
type: 'function',
function: {
@@ -79,6 +95,7 @@ const CANCEL_REMINDER_DEF: ToolDef = { type: 'function', function: { name: 'Canc
export const TOOL_DEFS: Record<string, ToolDef> = {
AddCalendarEvent: ADD_CALENDAR_EVENT_DEF,
ListCalendarEvents: LIST_CALENDAR_EVENTS_DEF,
DeleteCalendarEvent: DELETE_CALENDAR_EVENT_DEF,
CreateReminder: CREATE_REMINDER_DEF,
ListReminders: LIST_REMINDERS_DEF,
CancelReminder: CANCEL_REMINDER_DEF,
@@ -91,6 +108,7 @@ export async function executeTool(
): Promise<ToolResult | null> {
if (name === 'AddCalendarEvent') return executeAddCalendarEvent(input, ctx);
if (name === 'ListCalendarEvents') return executeListCalendarEvents(input, ctx);
if (name === 'DeleteCalendarEvent') return executeDeleteCalendarEvent(input, ctx);
if (name === 'CreateReminder') return executeCreateReminder(input, ctx);
if (name === 'ListReminders') return executeListReminders(ctx);
if (name === 'CancelReminder') return executeCancelReminder(input, ctx);
@@ -123,7 +141,7 @@ async function executeCreateReminder(input: Record<string, unknown>, ctx: ToolCo
due = new Date(Date.now() + input.delay_minutes * 60_000);
}
if (due.getTime() <= Date.now()) return { output: '過去の時刻には登録できません。明示的な未来時刻を指定してください', isError: true };
const reminder = _repo.createAgentReminder({ ownerId, spaceId: ctx.spaceId ?? null, title: typeof title === 'string' ? title : null, body: body.trim(), imageUrl: typeof imageUrl === 'string' ? imageUrl : null, dueAt: due.toISOString() });
const reminder = _repo.createAgentReminder({ ownerId, spaceId: ctx.spaceId ?? null, title: typeof title === 'string' ? title : null, body: body.trim(), imageUrl: typeof imageUrl === 'string' ? imageUrl : null, dueAt: due.toISOString(), timezoneId: 'UTC' });
return { output: `リマインダーを登録しました: ${reminder.dueAt} (id=${reminder.id})`, isError: false };
}
@@ -267,3 +285,62 @@ async function executeListCalendarEvents(
});
return { output: lines.join('\n'), isError: false };
}
/**
* 予定が見つからないときの文面。**存在しない id・すでに消えた id・別スペースの id で
* バイト単位まで同一にすること。**別スペースだけ違う文面にすると、そのスペースに
* その id の予定があることを(本文の差だけで)漏らす。
*/
const CALENDAR_EVENT_NOT_FOUND = '指定された id の予定はこのスペースに見つかりません';
/** 利用者が作成した予定を渡されたときの文面。手順は書かない(#1165 / #1170)。 */
const CALENDAR_EVENT_CREATED_BY_USER =
'この予定は利用者が作成したため削除できません。削除できるのはエージェントが作成した予定(created_by=agent)だけです';
/** 予定 id を正の整数に正規化する。数値でも数字文字列でも受ける。不正なら null。 */
function normalizeEventId(raw: unknown): number | null {
if (typeof raw === 'number') return Number.isSafeInteger(raw) && raw > 0 ? raw : null;
if (typeof raw === 'string') {
const trimmed = raw.trim();
if (!/^\d+$/.test(trimmed)) return null;
const parsed = Number(trimmed);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
}
return null;
}
async function executeDeleteCalendarEvent(
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 eventId = normalizeEventId(input['id']);
if (eventId == null) {
return { output: 'id は ListCalendarEvents が返す正の整数です', isError: true };
}
const ev = await _repo.getCalendarEvent(eventId);
// 判定の順序が効いている。**スペースの一致を先に見ること。**作成者を先に見ると、
// 別スペースの「利用者が作成した予定」に対して作成者の文面が返り、その予定が
// 存在することを漏らす。
if (!ev || ev.spaceId !== spaceId) {
return { output: CALENDAR_EVENT_NOT_FOUND, isError: true };
}
if (ev.createdBy !== 'agent') {
return { output: CALENDAR_EVENT_CREATED_BY_USER, isError: true };
}
await _repo.deleteCalendarEvent(eventId);
const timeLabel = ev.time ? (ev.endTime ? `${ev.time}–${ev.endTime}` : ev.time) : '(終日)';
return {
output: `予定を削除しました: ${ev.date}${ev.endDate ? `–${ev.endDate}` : ''} ${timeLabel} 「${ev.title}」(id=${ev.id})`,
isError: false,
};
}
+16 -1
View File
@@ -1,8 +1,9 @@
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 type { ToolContext, ToolResult } from './tool-context.js';
import { runtimeLogsDir } from './core.js';
import type { ToolModuleManifest } from './module-manifest.js';
// --- Name validation ---
const NAME_REGEX = /^[a-z0-9][a-z0-9-]{0,62}$/;
@@ -104,6 +105,20 @@ export const TOOL_DEFS: Record<string, ToolDef> = {
GetChecklist: GET_CHECKLIST_DEF,
};
/**
* このモジュールの自己申告(#1126)。以前は index.ts と tool-categories.ts の
* 2 か所に名前が直書きされていた。
*
* 3 本とも常時利用(META)にしている理由: 手順が多いタスクで進捗を見失わない
* ための道具で、ワークフローの側で「使ってよい」と書くかどうかに関わらず要る。
* ワークスペースの設定で切られていても載る。
*/
export const TOOL_MODULE: ToolModuleManifest = {
id: 'checklist',
category: 'checklist',
metaTools: ['CreateChecklist', 'CheckItem', 'GetChecklist'],
};
// --- Helpers ---
// 計画5: 実行ログ root(ctx.runtimeDir ?? workspacePath/logs)配下に直接書く。
@@ -0,0 +1,66 @@
/**
* 実行文脈の外部ネットワーク可否が、隔離環境の起動引数まで届くこと(issue #1015)。
*
* ここが鎖の最後の輪。`--unshare-net` が付くかどうかが実際の遮断そのものなので、
* 「設定は保存できるが網は開いたまま/閉じたまま」を防ぐにはこの通しが要る。
*/
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const { executeSandboxedBashMock } = vi.hoisted(() => ({ executeSandboxedBashMock: vi.fn() }));
vi.mock('./sandbox.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./sandbox.js')>();
return { ...actual, executeSandboxedBash: executeSandboxedBashMock };
});
import { executeCoreTools, type ToolContext } from './core.js';
import { buildBwrapArgs } from './sandbox.js';
/** Bash を 1 回走らせ、隔離環境へ渡された「外部ネットワーク可否」を取り出す。 */
async function allowNetworkPassedToSandbox(ctx: Partial<ToolContext>, workspacePath: string) {
executeSandboxedBashMock.mockResolvedValue({ output: 'ok', isError: false });
await executeCoreTools(
'Bash',
{ command: 'echo hi' },
// 'always' で隔離環境の経路に固定する(この環境で bwrap が動くかに依存させない)
{ workspacePath, editAllowed: false, bashSandbox: 'always', bashUnrestricted: true, ...ctx } as ToolContext,
);
return executeSandboxedBashMock.mock.calls[0]![6] as boolean;
}
describe('実行文脈から隔離環境の起動引数まで', () => {
let workspacePath = '';
beforeEach(() => {
executeSandboxedBashMock.mockReset();
workspacePath = mkdtempSync(join(tmpdir(), 'core-net-test-'));
});
afterEach(() => {
if (workspacePath) {
rmSync(workspacePath, { recursive: true, force: true });
workspacePath = '';
}
});
it('許可なら隔離環境へ許可が渡り、起動引数から --unshare-net が外れる', async () => {
const allow = await allowNetworkPassedToSandbox({ bashAllowNetwork: true }, workspacePath);
expect(allow).toBe(true);
expect(buildBwrapArgs('echo hi', workspacePath, undefined, {}, allow)).not.toContain('--unshare-net');
});
it('遮断なら隔離環境へ遮断が渡り、起動引数に --unshare-net が入る', async () => {
const allow = await allowNetworkPassedToSandbox({ bashAllowNetwork: false }, workspacePath);
expect(allow).toBe(false);
expect(buildBwrapArgs('echo hi', workspacePath, undefined, {}, allow)).toContain('--unshare-net');
});
it('未設定は遮断として扱う(fail-closed)', async () => {
const allow = await allowNetworkPassedToSandbox({}, workspacePath);
expect(allow).toBe(false);
expect(buildBwrapArgs('echo hi', workspacePath, undefined, {}, allow)).toContain('--unshare-net');
});
});
+279 -21
View File
@@ -2,8 +2,9 @@ import * as fs from 'fs';
import * as path from 'path';
import { tmpdir } from 'os';
import { afterEach, describe, expect, it } from 'vitest';
import { executeCoreTools, resolveAndGuard, resolveOutputPathWithin, checkBlockedInstallPatterns, checkAllowedCommand, checkBashPathScope, DEFAULT_ALLOWED_COMMANDS, getToolDefs, documentRangeHint, type ToolContext } from './core.js';
import { executeCoreTools, resolveAndGuard, resolveOutputPathWithin, checkBlockedInstallPatterns, checkAllowedCommand, checkBashPathScope, DEFAULT_ALLOWED_COMMANDS, getToolDefs, documentRangeRejection, textRangeRejection, type ToolContext } from './core.js';
import { looksLikeBinaryBytes } from './binary-detect.js';
import { __setBwrapProbeForTest, __resetBwrapProbeForTest } from './sandbox.js';
import iconv from 'iconv-lite';
function makeWorkspace(): string {
@@ -703,6 +704,57 @@ describe('Bash history logging', () => {
}
});
// issue #1215: 「そのとき実際にどう走ったか」を 1 行ごとに残す。
// 後から設定を読み直しても当時の値は分からないので、記録側に持たせる。
describe('実行モードと遮断の実際値を記録する', () => {
afterEach(() => { __resetBwrapProbeForTest(); });
const readEntry = (ws: string) =>
JSON.parse(fs.readFileSync(path.join(ws, 'logs', 'bash-history.jsonl'), 'utf-8').trim().split('\n')[0]);
it('隔離ありで走った実行は mode=sandboxed / networkBlocked=true と残る', async () => {
const tmpWs = fs.mkdtempSync(path.join(tmpdir(), 'bash-log-'));
try {
// 実際に bwrap を起動しなくても記録は残る材料として、弾かれるコマンドを使う
await executeCoreTools('Bash', { command: 'apt install foo' },
{ workspacePath: tmpWs, editAllowed: false, bashSandbox: 'always' });
const entry = readEntry(tmpWs);
expect(entry.mode).toBe('sandboxed');
expect(entry.networkBlocked).toBe(true);
} finally {
fs.rmSync(tmpWs, { recursive: true, force: true });
}
});
it('ネットワークを許可した実行は networkBlocked=false と残る', async () => {
const tmpWs = fs.mkdtempSync(path.join(tmpdir(), 'bash-log-'));
try {
await executeCoreTools('Bash', { command: 'apt install foo' },
{ workspacePath: tmpWs, editAllowed: false, bashSandbox: 'always', bashAllowNetwork: true });
const entry = readEntry(tmpWs);
expect(entry.mode).toBe('sandboxed');
expect(entry.networkBlocked).toBe(false);
} finally {
fs.rmSync(tmpWs, { recursive: true, force: true });
}
});
it('隔離が使えず hardened に落ちた実行は、遮断の設定のままでも networkBlocked=false と残る', async () => {
__setBwrapProbeForTest(false, 'bwrap not found at /usr/bin/bwrap');
const tmpWs = fs.mkdtempSync(path.join(tmpdir(), 'bash-log-'));
try {
// bashAllowNetwork は未設定 = 設定上は「遮断する」。それでも効いていない。
await executeCoreTools('Bash', { command: 'echo hi' },
{ workspacePath: tmpWs, editAllowed: false, bashSandbox: 'auto' });
const entry = readEntry(tmpWs);
expect(entry.mode).toBe('hardened');
expect(entry.networkBlocked).toBe(false);
} finally {
fs.rmSync(tmpWs, { recursive: true, force: true });
}
});
});
it('accumulates multiple entries across calls', async () => {
const tmpWs = fs.mkdtempSync(path.join(tmpdir(), 'bash-log-'));
try {
@@ -1299,44 +1351,131 @@ describe('Read: document formats vs text-only range params', () => {
expect(props.limit?.description ?? '').toMatch(/テキスト/);
});
it('documentRangeHint steers PDF offset/limit users to page_range', () => {
const hint = documentRangeHint('.pdf', { offset: 100 });
expect(hint).toBeTruthy();
expect(hint!).toContain('page_range');
it('documentRangeRejection steers PDF offset/limit users to page_range', () => {
const message = documentRangeRejection('.pdf', { offset: 100 });
expect(message).toBeTruthy();
expect(message!).toContain('page_range');
// 「読まなかった」ことを本文の先頭で言い切る。以前は「無視されました」と
// だけ書いて本文を続けていたため、エージェントが続きを正解として扱った。
expect(message!).toContain('読みませんでした');
});
it('documentRangeHint steers Excel to sheet/range', () => {
const hint = documentRangeHint('.xlsx', { limit: 50 });
expect(hint).toBeTruthy();
expect(hint!).toMatch(/range/);
expect(hint!).toMatch(/sheet/);
it('documentRangeRejection names the parameters that were rejected', () => {
const message = documentRangeRejection('.pdf', { offset: 100, limit: 50 });
expect(message!).toContain('offset');
expect(message!).toContain('limit');
});
it('documentRangeHint points other formats to ReadToolDoc', () => {
const hint = documentRangeHint('.docx', { byte_offset: 10 });
expect(hint).toBeTruthy();
expect(hint!).toContain('ReadToolDoc');
it('documentRangeRejection steers Excel to sheet/range', () => {
const message = documentRangeRejection('.xlsx', { limit: 50 });
expect(message).toBeTruthy();
expect(message!).toMatch(/range/);
expect(message!).toMatch(/sheet/);
});
it('documentRangeHint returns null when no text-range param is used', () => {
expect(documentRangeHint('.pdf', { page_range: '5-10' })).toBeNull();
expect(documentRangeHint('.pdf', {})).toBeNull();
it('documentRangeRejection names the real parameter for docx, not a doc lookup', () => {
// 「ReadToolDoc を見に行け」で済ませると、もう一往復増えるうえ従わない
// ことがある。docx が実際に読む max_paragraphs を直接示す。
const message = documentRangeRejection('.docx', { byte_offset: 10 });
expect(message!).toContain('max_paragraphs');
});
it('Read on a PDF with offset prepends the page_range steering note', async () => {
it('documentRangeRejection steers pptx to page_range (#1165 段 B)', () => {
const message = documentRangeRejection('.pptx', { offset: 1 });
expect(message!).toContain('page_range');
// 実装の無い max_slides を勧めていた頃の名残が残っていないこと。
expect(message!).not.toContain('max_slides');
expect(message!).not.toContain('この形式に範囲指定はありません');
// 案内する名前は 1 つだけ。2 つ並べると、どちらを使うか迷わせる。
expect(message!).not.toContain('slide_range');
});
it('documentRangeRejection returns null when no text-range param is used', () => {
expect(documentRangeRejection('.pdf', { page_range: '5-10' })).toBeNull();
expect(documentRangeRejection('.pdf', {})).toBeNull();
// PowerPoint の page_range は効くようになったので、ここで弾かない(#1165 段 B)。
expect(documentRangeRejection('.pptx', { page_range: '2-4' })).toBeNull();
});
it('Read on a PDF with offset returns an ERROR and no document body (#1009)', async () => {
ws = fs.mkdtempSync(path.join(tmpdir(), 'maestro-pdf-'));
fs.mkdirSync(path.join(ws, 'input'), { recursive: true });
fs.writeFileSync(path.join(ws, 'input', 'doc.pdf'), '%PDF-1.4\n');
const result = await executeCoreTools('Read', { file_path: 'input/doc.pdf', offset: 100, limit: 50 }, { workspacePath: ws, editAllowed: true });
const result = await executeCoreTools(
'Read',
{ file_path: 'input/doc.pdf', offset: 100, limit: 50 },
{ workspacePath: ws, editAllowed: true },
);
// 出力があるかぎりエージェントは成功として扱う。範囲指定が効かないなら、
// 部分的な本文を渡すより何も渡さないほうが安全(大きな PDF の先頭数
// ページだけで作業を進めてしまう事故を防ぐ)。
expect(result?.isError).toBe(true);
expect(result?.output).toContain('page_range');
expect(result?.output).not.toContain('%PDF');
});
it('Read on a PDF without offset does not add the steering note', async () => {
it('textRangeRejection steers text readers back to offset/limit', () => {
const message = textRangeRejection('.txt', { page_range: '5-10' });
expect(message).toBeTruthy();
expect(message!).toContain('読みませんでした');
expect(message!).toContain('offset');
expect(message!).toContain('limit');
});
it('textRangeRejection covers the parameters Read never declared', () => {
// sheet / range / max_paragraphs / query は Read の宣言に無いが、ツールの
// ドキュメントとヘルプが案内している以上モデルは普通に渡してくる。
// 宣言の有無は「来ない」の根拠にならない。
for (const key of ['sheet', 'range', 'max_paragraphs', 'query']) {
expect(textRangeRejection('.md', { [key]: 'x' })).toContain(key);
}
});
it('textRangeRejection returns null for a plain text read', () => {
expect(textRangeRejection('.txt', { offset: 10, limit: 5 })).toBeNull();
expect(textRangeRejection('.txt', {})).toBeNull();
});
it('Read on a TEXT file with page_range returns an ERROR and no content (#1041)', async () => {
ws = fs.mkdtempSync(path.join(tmpdir(), 'maestro-txt-'));
fs.mkdirSync(path.join(ws, 'input'), { recursive: true });
fs.writeFileSync(path.join(ws, 'input', 'notes.txt'), 'SECRET-MARKER line one\nline two\n');
const result = await executeCoreTools(
'Read',
{ file_path: 'input/notes.txt', page_range: '5-10' },
{ workspacePath: ws, editAllowed: true },
);
// #1009 と同じ理由。全文を返すと「5〜10 ページ目が取れた」と誤読される。
expect(result?.isError).toBe(true);
expect(result?.output).not.toContain('SECRET-MARKER');
expect(result?.output).toContain('offset');
});
it('Read on a TEXT file with offset/limit still works', async () => {
ws = fs.mkdtempSync(path.join(tmpdir(), 'maestro-txt-ok-'));
fs.mkdirSync(path.join(ws, 'input'), { recursive: true });
fs.writeFileSync(path.join(ws, 'input', 'notes.txt'), 'a\nb\nc\n');
const result = await executeCoreTools(
'Read',
{ file_path: 'input/notes.txt', offset: 1, limit: 1 },
{ workspacePath: ws, editAllowed: true },
);
expect(result?.isError).toBeFalsy();
expect(result?.output).toContain('b');
});
it('Read on a PDF without offset is not rejected', async () => {
ws = fs.mkdtempSync(path.join(tmpdir(), 'maestro-pdf-'));
fs.mkdirSync(path.join(ws, 'input'), { recursive: true });
fs.writeFileSync(path.join(ws, 'input', 'doc.pdf'), '%PDF-1.4\n');
const result = await executeCoreTools('Read', { file_path: 'input/doc.pdf' }, { workspacePath: ws, editAllowed: true });
expect(result?.output ?? '').not.toContain('offset/limit');
expect(result?.output ?? '').not.toContain('読みませんでした');
});
});
@@ -1387,3 +1526,122 @@ describe('Grep output budget', () => {
expect(result?.output).toContain('small.txt:1:');
});
});
// #1170: Read でファイルの範囲外を指しても失敗にならず、エージェントが
// 「まだ続きがある」と誤解していた。行指定は何も検証しておらず空文字列を成功で
// 返し、バイト指定は検出しておきながら isError: false で返していた。
// すべて Read の入口(executeCoreTools)を通して確かめる。
describe('Read: 範囲外の位置は失敗として返す (#1170)', () => {
let ws = '';
afterEach(() => {
if (ws) {
fs.rmSync(ws, { recursive: true, force: true });
ws = '';
}
});
function write(rel: string, content: string): void {
ws = ws || makeWorkspace();
const abs = path.join(ws, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content, 'utf-8');
}
it('offset が行数を超えたらエラーになり、指定値と実際の行数を返す', async () => {
write('input/three.txt', 'a\nb\nc\n');
const result = await executeCoreTools('Read', { file_path: 'input/three.txt', offset: 10 }, makeContext(ws));
expect(result?.isError).toBe(true);
expect(result?.output).toContain('offset 10');
expect(result?.output).toContain('3 行');
// 復旧手順は本文に書かない(#1165: ツールの返答の本文は命令として扱われない)。
expect(result?.output).not.toContain('してください');
});
it('末尾の改行が作る空要素を「読めた」ことにしない("a\\nb\\n" に offset=2)', async () => {
write('input/trailing.txt', 'a\nb\n');
const result = await executeCoreTools('Read', { file_path: 'input/trailing.txt', offset: 2 }, makeContext(ws));
expect(result?.isError).toBe(true);
expect(result?.output).toContain('offset 2');
expect(result?.output).toContain('2 行');
});
it('末尾ちょうど(offset = 行数、末尾改行なし)もエラーになる', async () => {
write('input/exact.txt', 'a\nb\nc');
const result = await executeCoreTools('Read', { file_path: 'input/exact.txt', offset: 3 }, makeContext(ws));
expect(result?.isError).toBe(true);
expect(result?.output).toContain('3 行');
});
it('空ファイルに offset 0 を指定したらエラーになり、0 行と返す', async () => {
write('input/empty.txt', '');
const result = await executeCoreTools('Read', { file_path: 'input/empty.txt', offset: 0 }, makeContext(ws));
expect(result?.isError).toBe(true);
expect(result?.output).toContain('offset 0');
expect(result?.output).toContain('0 行');
});
it('空ファイルを offset なしで読むのは成功のまま', async () => {
write('input/empty2.txt', '');
const result = await executeCoreTools('Read', { file_path: 'input/empty2.txt' }, makeContext(ws));
expect(result?.isError).toBeFalsy();
expect(result?.output).toBe('');
});
it('offset が範囲内なら limit が余っても成功のまま', async () => {
write('input/two.txt', 'a\nb\n');
const result = await executeCoreTools('Read', { file_path: 'input/two.txt', offset: 1, limit: 100 }, makeContext(ws));
expect(result?.isError).toBeFalsy();
expect(result?.output).toContain('b');
});
it('byte_offset がファイルサイズ以上ならエラーになり、指定値とバイト数を返す', async () => {
write('input/bytes.txt', 'x'.repeat(1234));
const result = await executeCoreTools('Read', { file_path: 'input/bytes.txt', byte_offset: 5000, byte_length: 100 }, makeContext(ws));
expect(result?.isError).toBe(true);
expect(result?.output).toContain('byte_offset 5000');
expect(result?.output).toContain('1234 バイト');
expect(result?.output).not.toContain('してください');
});
it('byte_offset がファイルサイズちょうどでもエラーになる', async () => {
write('input/bytes2.txt', 'x'.repeat(10));
const result = await executeCoreTools('Read', { file_path: 'input/bytes2.txt', byte_offset: 10 }, makeContext(ws));
expect(result?.isError).toBe(true);
expect(result?.output).toContain('10 バイト');
});
it('空ファイルに byte_offset 0 を指定したらエラーになる', async () => {
write('input/empty3.txt', '');
const result = await executeCoreTools('Read', { file_path: 'input/empty3.txt', byte_offset: 0, byte_length: 10 }, makeContext(ws));
expect(result?.isError).toBe(true);
expect(result?.output).toContain('0 バイト');
});
it('byte_offset が範囲内なら成功のまま', async () => {
write('input/bytes3.txt', 'abcdefghij');
const result = await executeCoreTools('Read', { file_path: 'input/bytes3.txt', byte_offset: 4, byte_length: 3 }, makeContext(ws));
expect(result?.isError).toBeFalsy();
expect(result?.output).toBe('efg');
});
});
+282 -522
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,78 @@
import * as fs from 'fs';
import * as path from 'path';
import { tmpdir } from 'os';
import { execFileSync } from 'child_process';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { executeCoreTools, type ToolContext } from './core.js';
import { resolveHistoryGitDir, waitForFileHistoryIdle } from '../../workspace-history.js';
// issue #989 Phase 1: Write / Edit がワークスペースの git 履歴に記録されること
// (フックは recordProvenanceSafe 関所。provenance コールバック未配線でも記録される)
function makeWorkspace(): string {
return fs.mkdtempSync(path.join(tmpdir(), 'maestro-core-history-'));
}
let runtimeDir = '';
function makeContext(workspacePath: string): ToolContext {
return { workspacePath, runtimeDir, editAllowed: true, jobId: 'job-hist-1' } as ToolContext;
}
function gitLog(): string[] {
const out = execFileSync(
// 履歴はファイルごとの枝(refs/maestro/files/*)に積まれるので --all で辿る
'git', ['--git-dir', resolveHistoryGitDir(runtimeDir), 'log', '--all', '--format=%s'],
{ encoding: 'utf-8' },
);
return out.trim().split('\n').filter(Boolean);
}
describe('Write/Edit の git 履歴記録 (#989)', () => {
let workspacePath = '';
beforeEach(() => {
runtimeDir = fs.mkdtempSync(path.join(tmpdir(), 'maestro-core-history-rt-'));
});
afterEach(() => {
if (workspacePath) {
fs.rmSync(workspacePath, { recursive: true, force: true });
workspacePath = '';
}
if (runtimeDir) {
fs.rmSync(runtimeDir, { recursive: true, force: true });
runtimeDir = '';
}
});
it('Write 成功でコミットが 1 つ積まれる(jobId 入り)', async () => {
workspacePath = makeWorkspace();
const res = await executeCoreTools('Write', { file_path: 'output/report.md', content: 'v1' }, makeContext(workspacePath));
expect(res!.isError).toBeFalsy();
await waitForFileHistoryIdle();
const log = gitLog();
expect(log.length).toBe(1);
expect(log[0]).toContain('output/report.md');
expect(log[0]).toContain('job-hist-1');
});
it('Edit 成功でさらにコミットが積まれる', async () => {
workspacePath = makeWorkspace();
await executeCoreTools('Write', { file_path: 'output/report.md', content: 'hello world' }, makeContext(workspacePath));
const res = await executeCoreTools('Edit', { file_path: 'output/report.md', old_string: 'world', new_string: 'git' }, makeContext(workspacePath));
expect(res!.isError).toBeFalsy();
await waitForFileHistoryIdle();
expect(gitLog().length).toBe(2);
});
it('Write が失敗した場合はコミットされない', async () => {
workspacePath = makeWorkspace();
const res = await executeCoreTools('Write', { file_path: '../outside.txt', content: 'x' }, makeContext(workspacePath));
expect(res!.isError).toBe(true);
await waitForFileHistoryIdle();
expect(() => gitLog()).toThrow(); // repo 化されない
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
import Database from 'better-sqlite3';
import { ToolDef } from '../../llm/openai-compat.js';
import type { ToolContext, ToolResult } from './core.js';
import type { ToolContext, ToolResult } from './tool-context.js';
import { resolveAndGuard } from './core.js';
import { logger } from '../../logger.js';
+9 -1
View File
@@ -9,7 +9,7 @@ import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
import { ToolDef } from '../../llm/openai-compat.js';
import type { ToolContext, ToolResult } from './core.js';
import type { ToolContext, ToolResult } from './tool-context.js';
import { logger } from '../../logger.js';
type McpToolLookup = (serverId: string, toolName: string) => { description: string | null; input_schema: string | null } | null;
@@ -31,8 +31,11 @@ const TOOL_DOC_ALIASES: Record<string, string> = {
// calendar.md にまとめる
addcalendarevent: 'calendar',
listcalendarevents: 'calendar',
deletecalendarevent: 'calendar',
// updateuseragents.md にまとめる
readuseragents: 'updateuseragents',
// searchworkspacetasks.md にまとめる
listworkspacetasks: 'searchworkspacetasks',
// checklist.md にまとめる
createchecklist: 'checklist',
checkitem: 'checklist',
@@ -44,6 +47,8 @@ const TOOL_DOC_ALIASES: Record<string, string> = {
xtimeline: 'xsearch',
// youtube.ts をまとめる
searchyoutube: 'getyoutubetranscript',
// filestore.ts をまとめる
listfilestorefiles: 'listfilestorelocations',
// maps.ts をまとめる
getdirections: 'searchplaces',
reversegeocode: 'searchplaces',
@@ -58,6 +63,9 @@ const TOOL_DOC_ALIASES: Record<string, string> = {
pdftoimages: 'office',
splitexcelsheets: 'office',
splitdocxsections: 'office',
editexcel: 'office',
editdocx: 'office',
editpptx: 'office',
// skills.ts をまとめる (skills.md)
installskill: 'skills',
readskill: 'skills',
@@ -0,0 +1,820 @@
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
import { mkdtemp } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import AdmZip from 'adm-zip';
import ExcelJS from 'exceljs';
import { afterEach, describe, expect, it } from 'vitest';
import type { ToolContext } from './core.js';
import { ExcelFileSafetyError } from './excel-file-safety.js';
import {
runExcelEditPipeline,
type ExcelEditPipelineDependencies,
} from './excel-edit-pipeline.js';
import {
readSheetDataXml,
writeXlsxWithCleanSheetAndCollidingSecondSheet,
writeXlsxWithCollidingIdsAndCleanSheet,
writeXlsxWithCollidingSharedFormulaIds,
writeXlsxWithEmptySharedFormulaMaster,
writeXlsxWithOrphanSharedFormulaFollowers,
writeXlsxWithSharedFormulaMasterAfterFollowers,
writeXlsxWithSharedFormulaBlock,
writeXlsxWithSharedFormulaBlocksOnTwoSheets,
writeXlsxWithSharedFormulaOf,
} from '../../office/__fixtures__/forge-zip.js';
const workspaces: string[] = [];
async function workspace(): Promise<string> {
const path = await mkdtemp(join(tmpdir(), 'excel-edit-pipeline-'));
workspaces.push(path);
return path;
}
async function writeSource(path: string): Promise<void> {
mkdirSync(dirname(path), { recursive: true });
const workbook = new ExcelJS.Workbook();
workbook.addWorksheet('Sheet1');
await workbook.xlsx.writeFile(path);
}
const context = (workspacePath: string): ToolContext => ({ workspacePath, editAllowed: true });
/** 断ったときに元のファイルが 1 バイトも動いていないことを見るための控え。 */
function digestOf(path: string): string {
return createHash('sha256').update(readFileSync(path)).digest('hex');
}
function recalculation(
formulaErrorCodes: Record<string, number> = {},
): ExcelEditPipelineDependencies['recalculate'] {
return async input => {
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(input.inputPath);
for (const sheet of workbook.worksheets) {
sheet.eachRow(row => row.eachCell(cell => {
if (!cell.formula) return;
const result = cell.formula === 'SUM(A1:B1)' ? 3 : 2;
cell.value = { formula: cell.formula, result };
}));
}
await workbook.xlsx.writeFile(input.outputPath);
return { ok: true, canaryValue: 2, formulaErrorCodes };
};
}
afterEach(() => {
for (const path of workspaces.splice(0)) rmSync(path, { recursive: true, force: true });
});
describe('runExcelEditPipeline', () => {
it('applies merge, color, formula and validates the calculated expectation before commit', async () => {
const workspacePath = await workspace();
await writeSource(join(workspacePath, 'input/source.xlsx'));
const result = await runExcelEditPipeline({
path: 'input/source.xlsx',
output_path: 'output/report.xlsx',
operations: [
{ op: 'set_values', sheet: 'Sheet1', range: 'A1:B1', values: [[1, 2]] },
{ op: 'set_formula', sheet: 'Sheet1', cell: 'C1', formula: 'SUM(A1:B1)', expect: 3 },
{ op: 'merge', sheet: 'Sheet1', range: 'A3:B3' },
{ op: 'style_range', sheet: 'Sheet1', range: 'A1:C1', style: { fill: '#FFF2CC', bold: true } },
],
}, context(workspacePath), { recalculate: recalculation() });
expect(result).toMatchObject({
ok: true,
phase: 'commit',
candidate_path: 'output/report.xlsx',
summary: { checked_formula_cells: 1 },
});
const output = join(workspacePath, 'output/report.xlsx');
expect(existsSync(output)).toBe(true);
expect(existsSync(join(workspacePath, '.conflict'))).toBe(false);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(output);
const sheet = workbook.getWorksheet('Sheet1')!;
expect(sheet.getCell('C1').formula).toBe('SUM(A1:B1)');
expect(sheet.getCell('A1').fill).toMatchObject({ fgColor: { argb: 'FFFFF2CC' } });
expect((sheet.model as { merges?: string[] }).merges).toContain('A3:B3');
});
it('does not publish a candidate when recalculation reports a formula error', async () => {
const workspacePath = await workspace();
await writeSource(join(workspacePath, 'input/source.xlsx'));
const result = await runExcelEditPipeline({
path: 'input/source.xlsx',
output_path: 'output/report.xlsx',
operations: [
{ op: 'set_formula', sheet: 'Sheet1', cell: 'C1', formula: '"text"+1' },
],
}, context(workspacePath), {
recalculate: recalculation({ 'Sheet1!C1': 519 }),
});
expect(result).toMatchObject({
ok: false,
phase: 'calculation',
candidate_path: null,
violations: [expect.objectContaining({ code: 'FORMULA_ERROR', sheet: 'Sheet1', cell: 'C1' })],
});
expect(existsSync(join(workspacePath, 'output/report.xlsx'))).toBe(false);
});
/**
* 実行時例外を「ファイルが壊れている」と診断しない(#1067 診断 1 件目)。
*
* ここへ来る時点で元ファイルは package 段階の検査を通っている。外部プロセスや
* ライブラリが落ちただけの例外を PACKAGE_INVALID にすると、正常なブックが
* 壊れていると報告される。
*/
it('reports an unexpected runtime failure as INTERNAL_ERROR, not a broken workbook', async () => {
const workspacePath = await workspace();
await writeSource(join(workspacePath, 'input/source.xlsx'));
const result = await runExcelEditPipeline({
path: 'input/source.xlsx',
output_path: 'output/report.xlsx',
operations: [
{ op: 'set_formula', sheet: 'Sheet1', cell: 'C1', formula: '1+1', expect: 2 },
],
}, context(workspacePath), {
recalculate: async () => { throw new Error('spawn soffice ENOMEM'); },
});
expect(result.ok).toBe(false);
expect(result.violations).toHaveLength(1);
const [violation] = result.violations;
expect(violation.code).toBe('INTERNAL_ERROR');
expect(violation.code).not.toBe('PACKAGE_INVALID');
// 生の例外文言だけを返さない(何が起きたのかを添えて返す)。
expect(violation.message).toContain('内部エラー');
expect(violation.message).toContain('spawn soffice ENOMEM');
expect(violation.message).not.toBe('spawn soffice ENOMEM');
// 元のブックは無事で、候補も残らない。
expect(existsSync(join(workspacePath, 'input/source.xlsx'))).toBe(true);
expect(existsSync(join(workspacePath, 'output/report.xlsx'))).toBe(false);
});
/**
* 反対側も固定する。理由の分かっている失敗(ExcelFileSafetyError)は、
* INTERNAL_ERROR に丸めず、その診断のまま返す(#1067 レビュー P2-3)。
*/
it('keeps a known file-safety failure with its own diagnosis', async () => {
const workspacePath = await workspace();
await writeSource(join(workspacePath, 'input/source.xlsx'));
const result = await runExcelEditPipeline({
path: 'input/source.xlsx',
output_path: 'output/report.xlsx',
operations: [
{ op: 'set_formula', sheet: 'Sheet1', cell: 'C1', formula: '1+1', expect: 2 },
],
}, context(workspacePath), {
recalculate: async () => {
throw new ExcelFileSafetyError('PACKAGE_INVALID', '壊れたZIP/OOXMLパッケージです');
},
});
expect(result.ok).toBe(false);
expect(result.violations).toHaveLength(1);
const [violation] = result.violations;
expect(violation.code).toBe('PACKAGE_INVALID');
expect(violation.code).not.toBe('INTERNAL_ERROR');
expect(violation.message).toBe('壊れたZIP/OOXMLパッケージです');
expect(violation.message).not.toContain('内部エラー');
expect(existsSync(join(workspacePath, 'output/report.xlsx'))).toBe(false);
});
/**
* `operations` の経路は共通の関所を通らない(手前で分岐して返る)。展開後
* サイズの上限が設定キーを見ずに 200MB 固定だったのを、同じキーへ繋いだ
* (#1067 レビュー P2-4)。同じファイルで、設定を絞ると断り、既定なら通る。
*/
it('honours officePptxMaxUncompressedMb on the operations path', async () => {
const workspacePath = await workspace();
const source = join(workspacePath, 'input/source.xlsx');
await writeSource(source);
const zip = new AdmZip(source);
zip.addFile('docProps/thumbnail.bin', Buffer.alloc(2 * 1024 * 1024, 0));
zip.writeZip(source);
const request = {
path: 'input/source.xlsx',
output_path: 'output/report.xlsx',
operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'A1:B1', values: [[1, 2]] }],
};
const rejected = await runExcelEditPipeline(
request,
{ workspacePath, editAllowed: true, toolsConfig: { officePptxMaxUncompressedMb: 1 } },
{ recalculate: recalculation() },
);
expect(rejected.ok).toBe(false);
expect(rejected.violations[0]?.code).toBe('PACKAGE_INVALID');
expect(existsSync(join(workspacePath, 'output/report.xlsx'))).toBe(false);
// 同じファイルでも、既定(200MB)なら通る=断ったのは設定キーの効果。
const accepted = await runExcelEditPipeline(
request,
context(workspacePath),
{ recalculate: recalculation() },
);
expect(accepted.ok).toBe(true);
});
/**
* 項目数の上限も同じ設定キーで動く。共通の関所とこちらで値が食い違うと、
* 同じファイルが経路によって通ったり断られたりする(#1067 レビュー P2-2)。
*/
it('honours officeMaxEntries on the operations path', async () => {
const workspacePath = await workspace();
const source = join(workspacePath, 'input/source.xlsx');
await writeSource(source);
const request = {
path: 'input/source.xlsx',
output_path: 'output/report.xlsx',
operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'A1:B1', values: [[1, 2]] }],
};
const rejected = await runExcelEditPipeline(
request,
{ workspacePath, editAllowed: true, toolsConfig: { officeMaxEntries: 1 } },
{ recalculate: recalculation() },
);
expect(rejected.ok).toBe(false);
expect(rejected.violations[0]?.code).toBe('PACKAGE_INVALID');
expect(rejected.violations[0]?.message).toContain('部品数');
expect(existsSync(join(workspacePath, 'output/report.xlsx'))).toBe(false);
// 同じファイルでも、既定(10000 件)なら通る=断ったのは設定キーの効果。
const accepted = await runExcelEditPipeline(
request,
context(workspacePath),
{ recalculate: recalculation() },
);
expect(accepted.ok).toBe(true);
});
it('fully validates dry_run but leaves no output file', async () => {
const workspacePath = await workspace();
await writeSource(join(workspacePath, 'input/source.xlsx'));
const result = await runExcelEditPipeline({
path: 'input/source.xlsx',
output_path: 'output/report.xlsx',
dry_run: true,
operations: [
{ op: 'set_formula', sheet: 'Sheet1', cell: 'A1', formula: '1+1', expect: 2 },
],
}, context(workspacePath), { recalculate: recalculation() });
expect(result).toMatchObject({ ok: true, phase: 'commit', candidate_path: null });
expect(existsSync(join(workspacePath, 'output/report.xlsx'))).toBe(false);
});
it('compiles gantt holiday and progress colors after applying data operations', async () => {
const workspacePath = await workspace();
await writeSource(join(workspacePath, 'input/source.xlsx'));
const result = await runExcelEditPipeline({
path: 'input/source.xlsx',
output_path: 'output/gantt.xlsx',
operations: [
{ op: 'set_values', sheet: 'Sheet1', range: 'D1:H1', values: [['2026-08-07', '2026-08-08', '2026-08-09', '2026-08-10', '2026-08-11']] },
{ op: 'set_values', sheet: 'Sheet1', range: 'A2:C2', values: [['2026-08-07', '2026-08-11', 0.5]] },
],
gantt: {
sheet: 'Sheet1',
timeline_header: 'D1:H1',
task_rows: '2',
start_column: 'A',
end_column: 'B',
progress_column: 'C',
progress_scale: 'fraction',
holidays: ['2026-08-10'],
roles: {
weekend: { fill: '#EEEEEE' },
holiday: { fill: '#FFCCCC' },
planned: { fill: '#99CCFF' },
completed: { fill: '#66CC66' },
},
},
}, context(workspacePath), { recalculate: recalculation() });
expect(result.ok, JSON.stringify(result)).toBe(true);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(join(workspacePath, 'output/gantt.xlsx'));
const sheet = workbook.getWorksheet('Sheet1')!;
expect(sheet.getCell('G1').fill).toMatchObject({ fgColor: { argb: 'FFFFCCCC' } });
expect(sheet.getCell('D2').fill).toMatchObject({ fgColor: { argb: 'FF66CC66' } });
expect(sheet.getCell('H2').fill).toMatchObject({ fgColor: { argb: 'FF99CCFF' } });
});
it('counts user and gantt operations against one combined budget', async () => {
const workspacePath = await workspace();
const source = join(workspacePath, 'input/source.xlsx');
await writeSource(source);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(source);
const sheet = workbook.getWorksheet('Sheet1')!;
sheet.getCell('D1').value = '2026-08-08';
sheet.getCell('A2').value = '2026-08-08';
sheet.getCell('B2').value = '2026-08-08';
sheet.getCell('C2').value = 0.5;
await workbook.xlsx.writeFile(source);
const result = await runExcelEditPipeline({
path: 'input/source.xlsx',
output_path: 'output/too-many.xlsx',
operations: Array.from({ length: 500 }, () => ({
op: 'freeze_panes', sheet: 'Sheet1', rows: 1, columns: 1,
})),
gantt: {
sheet: 'Sheet1',
timeline_header: 'D1:D1',
task_rows: '2',
start_column: 'A',
end_column: 'B',
progress_column: 'C',
progress_scale: 'fraction',
holidays: [],
roles: {
weekend: { fill: '#EEEEEE' },
holiday: { fill: '#FFCCCC' },
planned: { fill: '#99CCFF' },
completed: { fill: '#66CC66' },
},
},
}, context(workspacePath), { recalculate: recalculation() });
expect(result).toMatchObject({
ok: false,
phase: 'normalize',
violations: [expect.objectContaining({ code: 'INPUT_INVALID' })],
});
expect(existsSync(join(workspacePath, 'output/too-many.xlsx'))).toBe(false);
});
/**
* コピーした式のおおもと(#1351 段 2)。
*
* ExcelPatch は読んで書き直す作りなので、**もとのファイルにかたまりがあれば
* そのまま残る**。段 1 まではおおもとへの書き込みを断っていた。段 2 では、
* 追随するセルへ式を配ってから編集を通す。**画面の保存側だけ通せるように
* すると、こちらが断ったままの抜け道になる。**
*/
describe('コピーした式のおおもと', () => {
it('おおもとに式を書く指示は通り、追随するセルは元の式を全文で持つ', async () => {
const workspacePath = await workspace();
const source = join(workspacePath, 'input/shared.xlsx');
mkdirSync(dirname(source), { recursive: true });
await writeXlsxWithSharedFormulaBlock(source);
const result = await runExcelEditPipeline({
path: 'input/shared.xlsx',
output_path: 'output/report.xlsx',
operations: [{ op: 'set_formula', sheet: 'Sheet1', cell: 'C2', formula: 'A2+B2' }],
}, context(workspacePath), { recalculate: recalculation() });
expect(result.ok).toBe(true);
const sheetXml = readSheetDataXml(join(workspacePath, 'output/report.xlsx'));
// 頼んだのは C2 だけ。残りは「おおもとと同じ式」ではなく自分の式になる。
expect(sheetXml).toContain('<f>A2+B2</f>');
expect(sheetXml).toContain('<f>A3*B3</f>');
expect(sheetXml).toContain('<f>A4*B4</f>');
expect(sheetXml).not.toContain('t="shared"');
});
it('おおもとに値を書く指示も通り、追随するセルの式は残る', async () => {
const workspacePath = await workspace();
const source = join(workspacePath, 'input/shared.xlsx');
mkdirSync(dirname(source), { recursive: true });
await writeXlsxWithSharedFormulaBlock(source);
const result = await runExcelEditPipeline({
path: 'input/shared.xlsx',
output_path: 'output/report.xlsx',
operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'C2', values: }],
}, context(workspacePath), { recalculate: recalculation() });
expect(result.ok).toBe(true);
const sheetXml = readSheetDataXml(join(workspacePath, 'output/report.xlsx'));
expect(sheetXml).toContain('<f>A3*B3</f>');
expect(sheetXml).toContain('<f>A4*B4</f>');
});
/** 訳せない形(列をまるごと指す参照)は、段 1 と同じく理由を添えて断る。 */
it('訳せない式のおおもとは、理由の分かる形で断る', async () => {
const workspacePath = await workspace();
const source = join(workspacePath, 'input/shared.xlsx');
mkdirSync(dirname(source), { recursive: true });
await writeXlsxWithSharedFormulaOf(source, 'SUM(A:A)*B2');
const result = await runExcelEditPipeline({
path: 'input/shared.xlsx',
output_path: 'output/report.xlsx',
operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'C2', values: }],
}, context(workspacePath), { recalculate: recalculation() });
expect(result).toMatchObject({
ok: false,
violations: [expect.objectContaining({
code: 'SHARED_FORMULA_MASTER',
sheet: 'Sheet1',
cell: 'C2',
})],
});
expect(result.violations[0]!.message).toContain('C2:C4');
expect(existsSync(join(workspacePath, 'output/report.xlsx'))).toBe(false);
});
/**
* 同じ番号(`si`)を名乗るかたまりが 2 つある書類(#1378)。
*
* この経路はブックを exceljs で丸ごと読み込んで丸ごと書き戻す。exceljs はこの
* 並びを「あとに見たおおもとが勝つ」で読み、Excel / LibreOffice は「先に見た
* おおもとが勝つ」で読む。だから**書き込み先がどこであっても**、触っていない
* C のかたまりに E のかたまりの式が流れ込む(LibreOffice で計算し直すと
* C3 が 33 から 90、E3 が 40 から 630 に変わる)。
*
* **関係の無いマスへの書き込みでも断ること。**#1378 の穴はここだった。
* おおもとを書くときだけ断っていたので、`A1` を書く指示は素通りしていた。
*/
it('番号がぶつかった書類では、無関係なマス(A1)への書き込みも断る', async () => {
const workspacePath = await workspace();
const source = join(workspacePath, 'input/collide.xlsx');
mkdirSync(dirname(source), { recursive: true });
await writeXlsxWithCollidingSharedFormulaIds(source);
const before = digestOf(source);
const result = await runExcelEditPipeline({
path: 'input/collide.xlsx',
output_path: 'output/report.xlsx',
operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'A1', values: }],
}, context(workspacePath), { recalculate: recalculation() });
expect(result.ok).toBe(false);
expect(result.violations.map(item => [item.code, item.sheet, item.cell]))
.toEqual([
['SHARED_FORMULA_MASTER', 'Sheet1', 'C2'],
['SHARED_FORMULA_MASTER', 'Sheet1', 'E2'],
]);
// 「おおもとに書こうとしたから断った」と読める文面にしない。書いたのは A1 で、
// 利用者はどのかたまりにも触っていない。
expect(result.violations[0]!.message).toContain('どのマスを書き換えても');
// この並びは `edits` なら通る(`xlsx-shared-formula.test.ts` で裏取り済み)。
// 案内文もそう言い切っている。通らない並びと同じ文面にしてはいけない。
expect(result.violations[0]!.message)
.toContain('edits(値・数式の差し替え)で頼めば、この書き方のままでも通ります');
expect(digestOf(source)).toBe(before);
});
/** 断ったら、候補のファイルも残さない。書きかけを拾わせない。 */
it('番号がぶつかった書類を断ったとき、出力ファイルは作られない', async () => {
const workspacePath = await workspace();
const source = join(workspacePath, 'input/collide.xlsx');
mkdirSync(dirname(source), { recursive: true });
await writeXlsxWithCollidingSharedFormulaIds(source);
const result = await runExcelEditPipeline({
path: 'input/collide.xlsx',
output_path: 'output/report.xlsx',
operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'A1', values: }],
}, context(workspacePath), { recalculate: recalculation() });
expect(result.ok).toBe(false);
expect(existsSync(join(workspacePath, 'output/report.xlsx'))).toBe(false);
});
/**
* **書き換えるシートと壊れるシートは別**(#1378)。
*
* 丸ごと書き戻す経路なので、`Sheet2` を書く指示でも `Sheet1` のかたまりが
* 化ける。書き込み先のシートだけを見る関所では捕まらない。
*
* `Sheet2` も `si="0"` を使っている。シートをまたいだ番号を「ぶつかっている」と
* 読む関所なら、この材料で断りすぎが出る。
*/
it('別のシートを編集する指示でも、番号のぶつかったシートを理由に断る', async () => {
const workspacePath = await workspace();
const source = join(workspacePath, 'input/collide.xlsx');
mkdirSync(dirname(source), { recursive: true });
await writeXlsxWithCollidingIdsAndCleanSheet(source);
const before = digestOf(source);
const result = await runExcelEditPipeline({
path: 'input/collide.xlsx',
output_path: 'output/report.xlsx',
operations: [{ op: 'set_values', sheet: 'Sheet2', range: 'A1', values: }],
}, context(workspacePath), { recalculate: recalculation() });
expect(result.ok).toBe(false);
// 断る理由は Sheet1 のかたまりだけ。Sheet2 は普通のかたまりなので挙げない。
expect(result.violations.map(item => [item.sheet, item.cell]))
.toEqual([['Sheet1', 'C2'], ['Sheet1', 'E2']]);
expect(existsSync(join(workspacePath, 'output/report.xlsx'))).toBe(false);
expect(digestOf(source)).toBe(before);
});
/**
* **壊れているのは 2 枚目のシートだけ**(#1378)。
*
* 1 枚目は `si="0"` のかたまりが 1 つあるだけの普通のシートで、いくら見ても
* 何も出ない。関所が 1 枚目で歩みを止めていると、この書類は素通りする。
* 上の「別のシートを編集する指示でも〜」は壊れている側が 1 枚目なので、
* 1 枚目しか見ない関所でも断れてしまい、この穴を塞げない。
*
* 書き込み先は普通のほうの `Sheet1`。**利用者が触っているシートには何の問題も
* 無い**のに、丸ごと書き戻す経路では `Sheet2` のかたまりが化ける。
*/
it('2 枚目のシートにだけ番号のぶつかったかたまりがある書類も断る', async () => {
const workspacePath = await workspace();
const source = join(workspacePath, 'input/second-sheet.xlsx');
mkdirSync(dirname(source), { recursive: true });
await writeXlsxWithCleanSheetAndCollidingSecondSheet(source);
const before = digestOf(source);
const result = await runExcelEditPipeline({
path: 'input/second-sheet.xlsx',
output_path: 'output/report.xlsx',
operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'A1', values: }],
}, context(workspacePath), { recalculate: recalculation() });
expect(result.ok).toBe(false);
// 断る理由は Sheet2 のかたまりだけ。Sheet1 は普通のかたまりなので挙げない。
expect(result.violations.map(item => [item.code, item.sheet, item.cell]))
.toEqual([
['SHARED_FORMULA_MASTER', 'Sheet2', 'C2'],
['SHARED_FORMULA_MASTER', 'Sheet2', 'E2'],
]);
expect(existsSync(join(workspacePath, 'output/report.xlsx'))).toBe(false);
expect(digestOf(source)).toBe(before);
});
/**
* 文面の無いおおもとを持つ書類(#1376)。
*
* 以前は exceljs が式を平行移動しようとして `TypeError` を投げ、`normalize` 段の
* `INTERNAL_ERROR` になっていた。符号の意味(こちら側の実行時例外)と実態
* (ファイル側が原因)が食い違うので、利用者は原因にたどり着けない。
* **入口で理由を添えて断る。**
*/
it('文面の無いおおもとを持つ書類は、内部エラーではなく理由を添えて断る', async () => {
const workspacePath = await workspace();
const source = join(workspacePath, 'input/empty-master.xlsx');
mkdirSync(dirname(source), { recursive: true });
await writeXlsxWithEmptySharedFormulaMaster(source);
const before = digestOf(source);
const result = await runExcelEditPipeline({
path: 'input/empty-master.xlsx',
output_path: 'output/report.xlsx',
operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'A1', values: }],
}, context(workspacePath), { recalculate: recalculation() });
expect(result).toMatchObject({
ok: false,
violations: [expect.objectContaining({
code: 'SHARED_FORMULA_MASTER',
sheet: 'Sheet1',
cell: 'C2',
})],
});
expect(result.violations.some(item => item.code === 'INTERNAL_ERROR')).toBe(false);
expect(result.violations[0]!.message).toContain('配るはずの式の文面がありません');
expect(existsSync(join(workspacePath, 'output/report.xlsx'))).toBe(false);
expect(digestOf(source)).toBe(before);
});
/**
* おおもとがただの値に置き換わり、追随側だけが残った書類(#1378 の 3 つ目の形)。
*
* これも成功として返っていた。書き出されるのは中身の無い式(`<f/>`)で、
* LibreOffice で開くと `Err:520` になる(実測)。
*/
it('おおもとの消えた追随マスが残っている書類も断る', async () => {
const workspacePath = await workspace();
const source = join(workspacePath, 'input/orphan.xlsx');
mkdirSync(dirname(source), { recursive: true });
await writeXlsxWithOrphanSharedFormulaFollowers(source);
const before = digestOf(source);
const result = await runExcelEditPipeline({
path: 'input/orphan.xlsx',
output_path: 'output/report.xlsx',
operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'A1', values: }],
}, context(workspacePath), { recalculate: recalculation() });
expect(result.ok).toBe(false);
expect(result.violations.map(item => [item.code, item.cell]))
.toEqual([['SHARED_FORMULA_MASTER', 'C3'], ['SHARED_FORMULA_MASTER', 'C4']]);
expect(result.violations[0]!.message).toContain('Err:520');
expect(existsSync(join(workspacePath, 'output/report.xlsx'))).toBe(false);
expect(digestOf(source)).toBe(before);
});
/**
* 追随マスが**おおもとより前**に並んでいる書類(#1378 レビュー P1-1)。
*
* 範囲(`ref`)は追随マスを含んでいるので、範囲だけを見る判定はこの並びを
* 通してしまう。ところが exceljs は読みながら採番表を作るので、まだ読んで
* いない番号を引いた C2・C3 は持ち主が `undefined` になり、書き出しで
* 中身の無い式(`<f/>`)になる。**成功として返ったうえで値が消えていた。**
*/
it('おおもとが追随マスより後ろに並んだ書類も断る', async () => {
const workspacePath = await workspace();
const source = join(workspacePath, 'input/master-after.xlsx');
mkdirSync(dirname(source), { recursive: true });
await writeXlsxWithSharedFormulaMasterAfterFollowers(source);
const before = digestOf(source);
const result = await runExcelEditPipeline({
path: 'input/master-after.xlsx',
output_path: 'output/report.xlsx',
operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'A1', values: }],
}, context(workspacePath), { recalculate: recalculation() });
expect(result.ok).toBe(false);
expect(result.violations.map(item => [item.code, item.sheet, item.cell]))
.toEqual([
['SHARED_FORMULA_MASTER', 'Sheet1', 'C2'],
['SHARED_FORMULA_MASTER', 'Sheet1', 'C3'],
]);
// 「おおもとがどこにも無い」書類とは症状が違う。文面も分ける。
expect(result.violations[0]!.message).toContain('C4');
expect(result.violations[0]!.message).toContain('後ろ');
expect(result.violations[0]!.message).toContain('Err:520');
// この並びは `edits` なら通る(`xlsx-shared-formula.test.ts` で裏取り済み)。
expect(result.violations[0]!.message)
.toContain('edits(値・数式の差し替え)で頼めば、この書き方のままでも通ります');
expect(existsSync(join(workspacePath, 'output/report.xlsx'))).toBe(false);
expect(digestOf(source)).toBe(before);
});
/**
* **普通の複数シートのブックは通す**(#1378)。
*
* 共有式の番号はシートごとに閉じているので、普通のブックは素で「どのシートも
* `si="0"`」になる。入口の関所が 1 冊まとめて見ていると、この形が全部断られる。
* 断りすぎに倒したときに気づくための検査。
*/
it('どのシートも si=0 を使う普通のブックは通る', async () => {
const workspacePath = await workspace();
const source = join(workspacePath, 'input/two-sheets.xlsx');
mkdirSync(dirname(source), { recursive: true });
await writeXlsxWithSharedFormulaBlocksOnTwoSheets(source);
const result = await runExcelEditPipeline({
path: 'input/two-sheets.xlsx',
output_path: 'output/report.xlsx',
operations: [{ op: 'set_values', sheet: 'Sheet2', range: 'A1', values: }],
}, context(workspacePath), { recalculate: recalculation() });
expect(result).toMatchObject({ ok: true, phase: 'commit' });
// かたまりはどちらのシートでも無傷のまま残る。番地はシートごとに違う
// (同じ番地だと、2 枚を 1 枚として見る誤りが潰れて見えなくなる)。
const out = join(workspacePath, 'output/report.xlsx');
expect(readSheetDataXml(out, 'xl/worksheets/sheet1.xml'))
.toContain('<f t="shared" ref="C2:C4" si="0">A2*B2</f>');
expect(readSheetDataXml(out, 'xl/worksheets/sheet2.xml'))
.toContain('<f t="shared" ref="E2:E4" si="0">A2*B2</f>');
});
it('追随側のセルと、本体への書式だけの指示は通す', async () => {
const workspacePath = await workspace();
const source = join(workspacePath, 'input/shared.xlsx');
mkdirSync(dirname(source), { recursive: true });
await writeXlsxWithSharedFormulaBlock(source);
const result = await runExcelEditPipeline({
path: 'input/shared.xlsx',
output_path: 'output/report.xlsx',
operations: [
{ op: 'set_values', sheet: 'Sheet1', range: 'C3', values: },
{ op: 'style_range', sheet: 'Sheet1', range: 'C2', style: { bold: true } },
],
}, context(workspacePath), { recalculate: recalculation() });
expect(result.ok).toBe(true);
const sheetXml = readSheetDataXml(join(workspacePath, 'output/report.xlsx'));
expect(sheetXml).toContain('<f t="shared" ref="C2:C4" si="0">A2*B2</f>');
});
});
});
describe('配列数式の保護', () => {
it('本体の上書きを具体的な理由で断り、元ファイルを維持する', async () => {
const root = await workspace();
const path = join(root, 'array.xlsx');
const book = new ExcelJS.Workbook();
const sheet = book.addWorksheet('Sheet1');
sheet.getCell('A1').value = { formula: 'B1:B3*2', shareType: 'array', ref: 'A1:A3', result: 2 };
sheet.getCell('A2').value = 4;
sheet.getCell('A3').value = 6;
await book.xlsx.writeFile(path);
const before = digestOf(path);
const result = await runExcelEditPipeline({
path: 'array.xlsx', output_path: 'output/array.xlsx',
operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'A1', values: }],
}, context(root));
expect(result.violations).toEqual(expect.arrayContaining([expect.objectContaining({
code: 'UNSUPPORTED_WORKBOOK_FEATURE',
message: expect.stringContaining('array formula'),
})]));
expect(digestOf(path)).toBe(before);
expect(existsSync(join(root, 'output/array.xlsx'))).toBe(false);
});
});
describe('数式の計算済みゼロ値 (#1373)', () => {
it('再計算した通常数式の数値ゼロを期待値として検証し、他の結果型も維持する', async () => {
const workspacePath = await workspace();
await writeSource(join(workspacePath, 'input/source.xlsx'));
const expected = [0, 'zero', true, 7] as const;
const result = await runExcelEditPipeline({
path: 'input/source.xlsx',
output_path: 'output/report.xlsx',
operations: expected.map((value, index) => ({
op: 'set_formula',
sheet: 'Sheet1',
cell: `A${index + 1}`,
formula: `${index + 1}`,
expect: value,
})),
}, context(workspacePath), {
recalculate: async input => {
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(input.inputPath);
for (const sheet of workbook.worksheets) {
sheet.eachRow(row => row.eachCell(cell => {
if (!cell.formula) return;
const rowIndex = Number.parseInt(cell.address.slice(1), 10) - 1;
const cached = sheet.name === 'Sheet1' ? expected[rowIndex] : 2;
cell.value = { formula: cell.formula, result: cached };
}));
}
await workbook.xlsx.writeFile(input.outputPath);
return { ok: true, canaryValue: 2, formulaErrorCodes: {} };
},
});
expect(result.ok, JSON.stringify(result)).toBe(true);
});
it('共有数式を実体化しても追随セルの数値ゼロを残す', async () => {
const workspacePath = await workspace();
const source = join(workspacePath, 'input/shared.xlsx');
mkdirSync(dirname(source), { recursive: true });
await writeXlsxWithSharedFormulaOf(source, 'A2*B2');
const result = await runExcelEditPipeline({
path: 'input/shared.xlsx',
output_path: 'output/report.xlsx',
operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'C2', values: }],
}, context(workspacePath), { recalculate: recalculation() });
expect(result.ok, JSON.stringify(result)).toBe(true);
const sheetXml = readSheetDataXml(join(workspacePath, 'output/report.xlsx'));
expect(sheetXml).toContain('<c r="C3"><f>A3*B3</f><v>0</v></c>');
expect(sheetXml).toContain('<c r="C4"><f>A4*B4</f><v>0</v></c>');
});
});
describe('共有数式の偽値キャッシュ (#1373)', () => {
it('実体化した追随セルの false を真偽値のまま残す', async () => {
const workspacePath = await workspace();
const source = join(workspacePath, 'input/shared-false.xlsx');
mkdirSync(dirname(source), { recursive: true });
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Sheet1');
for (let row = 2; row <= 4; row += 1) {
sheet.getCell(`A${row}`).value = row;
sheet.getCell(`B${row}`).value = row * 10;
}
sheet.getCell('C2').value = {
formula: 'A2*B2', shareType: 'shared', ref: 'C2:C4', result: 40,
};
sheet.getCell('C3').value = { sharedFormula: 'C2', result: false };
sheet.getCell('C4').value = { sharedFormula: 'C2', result: 160 };
await workbook.xlsx.writeFile(source);
const result = await runExcelEditPipeline({
path: 'input/shared-false.xlsx',
output_path: 'output/report.xlsx',
operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'C2', values: }],
}, context(workspacePath), { recalculate: recalculation() });
expect(result.ok, JSON.stringify(result)).toBe(true);
const sheetXml = readSheetDataXml(join(workspacePath, 'output/report.xlsx'));
expect(sheetXml).toContain('<c r="C3" t="b"><f>A3*B3</f><v>0</v></c>');
expect(sheetXml).toContain('<c r="C4"><f>A4*B4</f><v>160</v></c>');
});
});
+654
View File
@@ -0,0 +1,654 @@
import { readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import ExcelJS from 'exceljs';
import type { ToolContext } from './tool-context.js';
import {
ExcelFileSafetyError,
createExcelFileTransaction,
type ExcelFileTransaction,
} from './excel-file-safety.js';
import { compileGanttStyles, type ExcelGanttSpec } from './excel-gantt.js';
import {
applyExcelPlan,
materializeSharedFormulaBlocks,
normalizePatchOperations,
parseExcelPatchInput,
snapshotWorkbookStructure,
type ExcelPatchOperation,
type ExcelStyleSpec,
type NormalizedExcelPlan,
} from './excel-patch.js';
import { preflightExcel } from './excel-preflight.js';
import { DEFAULT_MAX_ENTRIES, DEFAULT_MAX_UNCOMPRESSED_MB } from '../../office/ooxml-package.js';
import { createOoxmlEntryReader } from '../../office/ooxml-package.js';
import { locateSheets } from '../../office/xlsx-sheet-locator.js';
import {
findSharedFormulaSheetDoubts,
type SharedFormulaSheetDoubt,
} from '../../office/xlsx-sheet-edit.js';
import {
addRecalculationCanary,
compareWorkbookStructure,
runLibreOfficeRecalculation,
workbookStructure,
type LibreOfficeRecalculationInput,
type RecalculationHelperResult,
} from './excel-recalculate.js';
import {
buildExcelResult,
EXCEL_LIMITS,
type ExcelPhase,
type ExcelResult,
type ExcelViolation,
type ExcelViolationCode,
type ExcelWarning,
} from './excel-types.js';
import {
validateCalculatedWorkbook,
validateStaticWorkbookFormulas,
type ExcelCellExpectation,
} from './excel-validation.js';
const DEFAULT_MAX_COMPRESSED_BYTES = 10 * 1024 * 1024;
export interface ExcelEditPipelineDependencies {
recalculate?: (
input: LibreOfficeRecalculationInput,
) => Promise<RecalculationHelperResult>;
}
interface PipelineStats {
operations: number;
changedCells: number;
formulaCells: number;
checkedFormulaCells: number;
}
class PipelineAbort extends Error {
constructor(public readonly result: ExcelResult) {
super(result.violations[0]?.message ?? 'Excel editing stopped');
this.name = 'PipelineAbort';
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function isExpectationScalar(value: unknown): value is ExcelCellExpectation['equals'] {
return value === null
|| typeof value === 'string'
|| typeof value === 'boolean'
|| (typeof value === 'number' && Number.isFinite(value));
}
function result(
phase: ExcelPhase,
stats: PipelineStats,
warnings: readonly ExcelWarning[],
violations: readonly ExcelViolation[] = [],
candidatePath: string | null = null,
): ExcelResult {
return buildExcelResult({
phase,
candidatePath,
operations: stats.operations,
changedCells: stats.changedCells,
formulaCells: stats.formulaCells,
checkedFormulaCells: stats.checkedFormulaCells,
violations,
warnings,
});
}
function abort(
phase: ExcelPhase,
stats: PipelineStats,
warnings: readonly ExcelWarning[],
violations: readonly ExcelViolation[],
): never {
throw new PipelineAbort(result(phase, stats, warnings, violations));
}
function violation(code: ExcelViolationCode, message: string): ExcelViolation {
return { code, message };
}
function maxCompressedBytes(ctx: ToolContext): number {
const configured = ctx.toolsConfig?.officeExcelMaxSizeMb;
if (typeof configured !== 'number' || !Number.isFinite(configured) || configured <= 0) {
return DEFAULT_MAX_COMPRESSED_BYTES;
}
return Math.max(1, Math.floor(configured * 1024 * 1024));
}
/**
* 展開後サイズの上限。`operations` の検証つき経路は共通の関所を通らない
* (手前で分岐して返る)ので、設定キーを見ずに 200MB 固定になっていた。
* 管理者が値を上げ下げしても効かないのは文書と食い違うため、ここで同じキーを
* 読む(#1067 レビュー P2-4)。
*/
function maxUncompressedBytes(ctx: ToolContext): number {
const configured = ctx.toolsConfig?.officePptxMaxUncompressedMb;
const mb = typeof configured === 'number' && Number.isFinite(configured) && configured > 0
? configured
: DEFAULT_MAX_UNCOMPRESSED_MB;
return Math.max(1, Math.floor(mb)) * 1024 * 1024;
}
/**
* 項目数の上限。共通の関所と同じ設定キーを読む——片方だけ動かせると、同じ
* ファイルが経路によって通ったり断られたりする(#1067 レビュー P2-2)。
*/
function maxParts(ctx: ToolContext): number {
const configured = ctx.toolsConfig?.officeMaxEntries;
if (typeof configured !== 'number' || !Number.isFinite(configured) || configured < 1) {
return DEFAULT_MAX_ENTRIES;
}
return Math.floor(configured);
}
function preflightLimits(ctx: ToolContext): { maxCompressedBytes: number; maxUncompressedBytes: number; maxParts: number } {
return {
maxCompressedBytes: maxCompressedBytes(ctx),
maxUncompressedBytes: maxUncompressedBytes(ctx),
maxParts: maxParts(ctx),
};
}
function parseExpectation(
raw: unknown,
sheet: string,
cell: string,
): { value?: ExcelCellExpectation; violation?: ExcelViolation } {
if (isExpectationScalar(raw)) return { value: { sheet, cell, equals: raw } };
if (!isRecord(raw) || !Object.hasOwn(raw, 'equals') || !isExpectationScalar(raw.equals)) {
return {
violation: {
code: 'INPUT_INVALID',
message: '数式のexpectは値、またはequalsとtoleranceを持つオブジェクトで指定してください',
sheet,
cell,
expected: raw,
},
};
}
const tolerance = raw.tolerance;
if (tolerance !== undefined && (
typeof tolerance !== 'number' || !Number.isFinite(tolerance) || tolerance < 0
)) {
return {
violation: {
code: 'INPUT_INVALID',
message: '数式のexpect.toleranceは0以上の有限数で指定してください',
sheet,
cell,
expected: raw,
},
};
}
return {
value: {
sheet,
cell,
equals: raw.equals,
...(tolerance !== undefined ? { tolerance } : {}),
},
};
}
function expectationsFromPlan(plan: NormalizedExcelPlan): {
expectations: ExcelCellExpectation[];
violations: ExcelViolation[];
} {
const expectations: ExcelCellExpectation[] = [];
const violations: ExcelViolation[] = [];
for (const operation of plan.operations) {
if (operation.op !== 'set_cell_formula' || !Object.hasOwn(operation, 'expect')) continue;
const parsed = parseExpectation(operation.expect, operation.sheet, operation.cell);
if (parsed.value) expectations.push(parsed.value);
if (parsed.violation) violations.push(parsed.violation);
}
return { expectations, violations };
}
function parseGanttStyle(
sheet: string,
role: string,
raw: unknown,
): { value?: ExcelStyleSpec; violation?: ExcelViolation } {
const parsed = parseExcelPatchInput({
output_path: '__gantt_style__.xlsx',
operations: [{ op: 'style_range', sheet, range: 'A1', style: raw }],
});
if (!parsed.ok) {
return {
violation: {
code: 'MISSING_STYLE_ROLE',
message: `ガントのスタイル役割 ${role} が不正です`,
sheet,
},
};
}
const operation = parsed.value.operations[0];
return operation?.op === 'style_range'
? { value: operation.style }
: { violation: { code: 'MISSING_STYLE_ROLE', message: `ガントのスタイル役割 ${role} が不正です`, sheet } };
}
function externalStyle(style: ExcelStyleSpec): Record<string, unknown> {
return {
...(style.fill ? { fill: `#${style.fill}` } : {}),
...(style.fontColor ? { font_color: `#${style.fontColor}` } : {}),
...(style.borderColor ? { border_color: `#${style.borderColor}` } : {}),
...(style.bold !== undefined ? { bold: style.bold } : {}),
...(style.italic !== undefined ? { italic: style.italic } : {}),
...(style.underline !== undefined ? { underline: style.underline } : {}),
...(style.numberFormat ? { number_format: style.numberFormat } : {}),
...(style.horizontal ? { horizontal: style.horizontal } : {}),
...(style.vertical ? { vertical: style.vertical } : {}),
...(style.wrapText !== undefined ? { wrap_text: style.wrapText } : {}),
};
}
function parseGantt(raw: unknown): {
value?: ExcelGanttSpec;
violations: ExcelViolation[];
} {
if (raw === undefined) return { violations: [] };
if (!isRecord(raw)) {
return { violations: [violation('INPUT_INVALID', 'ganttはオブジェクトで指定してください')] };
}
const mappings = [
['sheet', 'sheet'],
['timeline_header', 'timelineHeader'],
['task_rows', 'taskRows'],
['start_column', 'startColumn'],
['end_column', 'endColumn'],
['progress_column', 'progressColumn'],
['progress_scale', 'progressScale'],
] as const;
const strings: Record<string, string> = {};
const violations: ExcelViolation[] = [];
for (const [external, internal] of mappings) {
if (typeof raw[external] !== 'string' || raw[external].length === 0) {
violations.push(violation('INPUT_INVALID', `gantt.${external}は必須です`));
} else {
strings[internal] = raw[external];
}
}
if (!Array.isArray(raw.holidays) || !raw.holidays.every(item => typeof item === 'string')) {
violations.push(violation('INPUT_INVALID', 'gantt.holidaysは日付文字列の配列で指定してください'));
}
if (!isRecord(raw.roles)) {
violations.push(violation('INPUT_INVALID', 'gantt.rolesは必須です'));
}
if (violations.length > 0) return { violations };
const sheet = strings.sheet!;
const roles = {} as ExcelGanttSpec['roles'];
for (const name of ['weekend', 'holiday', 'planned', 'completed'] as const) {
const parsed = parseGanttStyle(sheet, name, (raw.roles as Record<string, unknown>)[name]);
if (parsed.value) roles[name] = parsed.value;
if (parsed.violation) violations.push(parsed.violation);
}
if (violations.length > 0) return { violations };
return {
violations,
value: {
sheet,
timelineHeader: strings.timelineHeader!,
taskRows: strings.taskRows!,
startColumn: strings.startColumn!,
endColumn: strings.endColumn!,
progressColumn: strings.progressColumn!,
progressScale: strings.progressScale! as ExcelGanttSpec['progressScale'],
holidays: raw.holidays as string[],
roles,
},
};
}
function recalculationViolation(error: string): ExcelViolation {
if (error === 'recalculation_timeout') {
return violation('RECALCULATION_TIMEOUT', 'Excel数式の再計算がタイムアウトしました');
}
if (error === 'recalculation_not_confirmed') {
return violation('RECALCULATION_NOT_CONFIRMED', 'Excel数式が再計算されたことを確認できません');
}
return violation('RECALCULATION_FAILED', `Excel数式を再計算できません: ${error}`);
}
async function validateCandidate(
candidatePath: string,
validationDir: string,
expectations: readonly ExcelCellExpectation[],
stats: PipelineStats,
warnings: ExcelWarning[],
ctx: ToolContext,
dependencies: ExcelEditPipelineDependencies,
): Promise<void> {
const packageViolations = await preflightExcel(candidatePath, preflightLimits(ctx));
if (packageViolations.length > 0) abort('structure', stats, warnings, packageViolations);
if (stats.formulaCells === 0) return;
const originalStructure = await workbookStructure(candidatePath);
const canary = addRecalculationCanary(await readFile(candidatePath));
const validationInput = join(validationDir, 'input-with-canary.xlsx');
const calculatedOutput = join(validationDir, 'calculated.xlsx');
await writeFile(validationInput, canary.bytes);
const recalculated = await (dependencies.recalculate ?? runLibreOfficeRecalculation)({
inputPath: validationInput,
outputPath: calculatedOutput,
workingDirectory: validationDir,
});
if (!recalculated.ok) {
abort('recalculation', stats, warnings, [recalculationViolation(recalculated.error)]);
}
let calculatedStructure;
let calculatedWorkbook: ExcelJS.Workbook;
try {
calculatedStructure = await workbookStructure(calculatedOutput);
calculatedWorkbook = new ExcelJS.Workbook();
await calculatedWorkbook.xlsx.readFile(calculatedOutput);
} catch {
abort('calculation', stats, warnings, [
violation('RECALCULATION_FAILED', '再計算後のExcelファイルを読み取れません'),
]);
}
const structureViolations = compareWorkbookStructure(originalStructure, calculatedStructure);
if (structureViolations.length > 0) abort('recalculation', stats, warnings, structureViolations);
const canarySheet = calculatedWorkbook.getWorksheet(canary.sheetName);
if (!canarySheet) {
abort('recalculation', stats, warnings, [
violation('RECALCULATION_NOT_CONFIRMED', '再計算確認用のシートが結果にありません'),
]);
}
calculatedWorkbook.removeWorksheet(canarySheet.id);
const calculated = validateCalculatedWorkbook(
calculatedWorkbook,
recalculated.formulaErrorCodes,
expectations,
);
stats.checkedFormulaCells = calculated.checkedFormulaCells;
const formulaViolations = calculated.violations.filter(item => item.code === 'FORMULA_ERROR');
if (formulaViolations.length > 0) abort('calculation', stats, warnings, formulaViolations);
const expectationViolations = calculated.violations.filter(item => item.code !== 'FORMULA_ERROR');
if (expectationViolations.length > 0) {
abort('expectation_calculated', stats, warnings, expectationViolations);
}
warnings.push({
code: 'ENGINE_DIFFERENCE_POSSIBLE',
message: 'LibreOfficeで検証済みですが、Excelとの計算エンジン差が生じる場合があります',
});
}
/**
* 入口で断るときの文面を組み立てる(#1378 / #1376)。
*
* **どれも「おおもとに書こうとした」から断っているのではない。**関係の無いマスを
* 1 つ書くだけでも断る。理由をそう書かないと、利用者は自分の指示のどこが悪かったのか
* を探し続けることになる。
*
* `kind` ごとに「他の頼み方なら通るのか」が違う。ここを一括りにすると案内が嘘になる。
*/
function sharedFormulaEntryMessage(sheet: string, doubt: SharedFormulaSheetDoubt): string {
const at = `${sheet}!${doubt.cell}`;
const block = doubt.range ?? doubt.cell;
const tail = 'このファイルは検証つき詳細編集(operations)では扱えません。'
+ 'ファイルには手を付けていません。';
if (doubt.kind === 'si_collision') {
return `${at} にあるコピー式のかたまり(${block})と同じ番号を名乗る別のかたまりが、`
+ `同じシートにあります(${doubt.reason})。`
+ 'この形のファイルは、どのマスを書き換えても——書き換えないマスであっても——'
+ 'どちらのかたまりの式か決められないまま保存され、触っていないかたまりの式が'
+ `別のかたまりの式に置き換わります。${tail}`
+ '同じ書き直しを edits(値・数式の差し替え)で頼めば、この書き方のままでも通ります';
}
if (doubt.kind === 'ownership_unknown') {
return `${at} にあるコピー式のかたまり(${block})は、残りのマスがどのかたまりに`
+ `属するのかを決められない書き方になっています(${doubt.reason})。`
+ 'この形のファイルは、どのマスを書き換えても、触っていないかたまりへ'
+ `別のかたまりの式が流れ込みます。${tail}`
+ `edits(値・数式の差し替え)なら、${doubt.cell} に触らない書き直しは通ります`;
}
if (doubt.kind === 'follower_before_master') {
return `${at} は、コピー式のかたまり(${block})に属するマスとして書かれているのに、`
+ `そのかたまりのおおもとが同じシートの、このマスより後ろにあります(${doubt.reason})。`
+ 'この形のファイルは、どのマスを書き換えても——書き換えないマスであっても——'
+ 'ファイルを上から順に読む読み手がこのマスの式を見つけられず、中身の無い式が'
+ `書かれます。Excel の表示は Err:520 になります。${tail}`
+ '同じ書き直しを edits(値・数式の差し替え)で頼めば、この書き方のままでも通ります';
}
if (doubt.kind === 'orphan_follower') {
return `${at} は、コピー式のかたまりに属するマスとして書かれているのに、`
+ `そのかたまりのおおもとがシートのどこにもありません(${doubt.reason})。`
+ 'この形のファイルは、どのマスを書き換えても、このマスに中身の無い式が書かれ、'
+ `Excel の表示が Err:520 になります。${tail}`
+ '同じ書き直しを edits(値・数式の差し替え)で頼めば、この書き方のままでも通ります';
}
return `${at} にはコピー式のかたまり(${block})の宣言があるのに、配るはずの式の文面が`
+ `ありません(${doubt.reason})。`
+ 'この形のファイルは、どのマスを書き換えても、残りのマスへ配る式を作れないまま'
+ `保存に失敗します。${tail}`
+ `edits(値・数式の差し替え)なら、${doubt.cell} に触らない書き直しは通ります`;
}
/**
* **ファイルを開いた時点で**、コピー式のかたまりを見て断る(#1378 / #1376)。
*
* この経路はブックを exceljs で丸ごと読み込んで丸ごと書き戻すので、壊れるのは
* 書き換えたマスとは限らない。番号(`si`)がぶつかっているシートでは、`A1` に値を
* 1 つ入れただけで、触っていないかたまりの式が別のかたまりの式に化ける。だから
* **書き込み先を組み立てる前に、シートごとに 1 回ずつ**通す。押した時点で断る、
* という形にするための置き場所でもある。
*
* 判定は `findSharedFormulaSheetDoubts`(生のシート XML を見る)に寄せてある。
* **同じ判定を書き込み側にも置かないこと。**片方だけ直されたときに食い違う。
*/
function sharedFormulaEntryViolations(sourcePath: string): ExcelViolation[] {
// 読み手は 1 本にまとめる。シートの割り出しと本文の読み出しで別々に開くと、
// 包みを項目の数だけ開き直すことになる(#1378 レビュー P2-2)。
const readEntry = createOoxmlEntryReader(sourcePath);
const violations: ExcelViolation[] = [];
for (const { name, entry } of locateSheets(sourcePath, readEntry)) {
for (const doubt of findSharedFormulaSheetDoubts(readEntry(entry))) {
violations.push({
code: 'SHARED_FORMULA_MASTER',
sheet: name,
cell: doubt.cell,
message: sharedFormulaEntryMessage(name, doubt),
});
}
}
return violations;
}
/**
* かたまりの本体(コピーされた式のおおもと)に書く前に、追随するマスの式を
* 実体化する(#1351 段 2)。
*
* 画面の保存・`EditExcel` の側は `xlsx-sheet-edit.ts` で同じことをする。こちらは
* exceljs で読み書きする別経路なので、同じ扱いをここにも置く。**片方だけ通せる
* ようにすると、もう片方が断ったままの抜け道になる。**
*
* 断るのは 1 通りだけ。訳せない形(列や行をまるごと指す参照・表の外へ出る参照)で、
* 段 1 と同じ。持ち主の答えが読み手ごとに変わる並びは、**ファイルを開いた時点で**
* `sharedFormulaEntryViolations` が断っているので、ここまで来ない(#1378)。
*/
function sharedFormulaMasterViolations(
workbook: ExcelJS.Workbook,
plan: NormalizedExcelPlan,
): ExcelViolation[] {
return materializeSharedFormulaBlocks(workbook, plan)
.map(({ sheet, cell, range, reason }) => ({
code: 'SHARED_FORMULA_MASTER' as const,
sheet,
cell,
message: `${sheet}!${cell} には、コピーされた式のおおもと(${range} 全体の式)が`
+ `入っています。${range} の残りのセルへ式を書き直そうとしましたが、この式は`
+ `書き直せません(${reason})。`
+ '残りのセルから式も計算結果も消えるため、書き込みを中止しました',
}));
}
export async function runExcelEditPipeline(
input: Record<string, unknown>,
ctx: ToolContext,
dependencies: ExcelEditPipelineDependencies = {},
): Promise<ExcelResult> {
const stats: PipelineStats = {
operations: 0,
changedCells: 0,
formulaCells: 0,
checkedFormulaCells: 0,
};
const warnings: ExcelWarning[] = [];
const parsed = parseExcelPatchInput(input);
if (!parsed.ok) return result('input', stats, warnings, parsed.violations);
const gantt = parseGantt(input.gantt);
if (gantt.violations.length > 0) return result('input', stats, warnings, gantt.violations);
const request = parsed.value;
stats.operations = request.operations.length;
let transaction: ExcelFileTransaction | undefined;
let stage: ExcelPhase = 'input';
let committed = false;
try {
transaction = await createExcelFileTransaction({
workspacePath: ctx.workspacePath,
...(request.path ? { sourcePath: request.path } : {}),
outputPath: request.outputPath,
overwrite: request.overwrite,
expectedSourceSha256: request.expectedSourceSha256,
...(ctx.conflictDetection ? {
conflictDir: ctx.conflictDir ?? join(ctx.workspacePath, '.conflict'),
} : {}),
recordFileProvenance: ctx.recordFileProvenance,
});
stage = 'package';
if (transaction.sourcePath) {
const packageViolations = await preflightExcel(transaction.sourcePath, preflightLimits(ctx));
if (packageViolations.length > 0) abort(stage, stats, warnings, packageViolations);
}
stage = 'load';
// exceljs にファイルを渡す前に断る。渡してしまうと、番号(`si`)がぶつかって
// いた事実がモデルから消えて、以降どこで見ても気づけない(#1378)。
if (transaction.sourcePath) {
const sharedFormulaDoubts = sharedFormulaEntryViolations(transaction.sourcePath);
if (sharedFormulaDoubts.length > 0) abort(stage, stats, warnings, sharedFormulaDoubts);
}
const workbook = new ExcelJS.Workbook();
if (transaction.sourcePath) await workbook.xlsx.readFile(transaction.sourcePath);
stage = 'normalize';
const normalized = normalizePatchOperations(request, snapshotWorkbookStructure(workbook));
if (!normalized.ok) abort(stage, stats, warnings, normalized.violations);
const expectationData = expectationsFromPlan(normalized.value);
if (expectationData.violations.length > 0) {
abort('expectation_static', stats, warnings, expectationData.violations);
}
stage = 'patch';
const sharedMasters = sharedFormulaMasterViolations(workbook, normalized.value);
if (sharedMasters.length > 0) abort(stage, stats, warnings, sharedMasters);
const applied = await applyExcelPlan(workbook, normalized.value);
stats.changedCells = applied.changedCells;
stats.formulaCells = applied.formulaCells;
if (gantt.value) {
const compiled = compileGanttStyles(workbook, gantt.value);
warnings.push(...compiled.warnings);
if (compiled.violations.length > 0) abort('normalize', stats, warnings, compiled.violations);
if (compiled.operations.length > 0) {
const ganttRequest = parseExcelPatchInput({
output_path: request.outputPath,
operations: compiled.operations.map(operation => operation.op === 'style_range'
? { ...operation, style: externalStyle(operation.style) }
: operation
),
});
if (!ganttRequest.ok) abort('normalize', stats, warnings, ganttRequest.violations);
const ganttPlan = normalizePatchOperations(
ganttRequest.value,
snapshotWorkbookStructure(workbook),
);
if (!ganttPlan.ok) abort('normalize', stats, warnings, ganttPlan.violations);
const budgetViolations: ExcelViolation[] = [];
const combinedOperations = stats.operations + compiled.operations.length;
const combinedChangedCells = stats.changedCells + ganttPlan.value.changedCells;
if (combinedOperations > EXCEL_LIMITS.operations) {
budgetViolations.push(violation(
'INPUT_INVALID',
`operationsとganttの生成操作は合計最大${EXCEL_LIMITS.operations}件です`,
));
}
if (combinedChangedCells > EXCEL_LIMITS.changedCells) {
budgetViolations.push(violation(
'INPUT_INVALID',
`operationsとganttの変更対象セルは合計最大${EXCEL_LIMITS.changedCells}件です`,
));
}
if (budgetViolations.length > 0) {
abort('normalize', stats, warnings, budgetViolations);
}
const ganttMasters = sharedFormulaMasterViolations(workbook, ganttPlan.value);
if (ganttMasters.length > 0) abort('patch', stats, warnings, ganttMasters);
const ganttApplied = await applyExcelPlan(workbook, ganttPlan.value);
stats.operations += compiled.operations.length;
stats.changedCells += ganttApplied.changedCells;
stats.formulaCells = ganttApplied.formulaCells;
}
}
stage = 'expectation_static';
const staticValidation = validateStaticWorkbookFormulas(workbook);
stats.formulaCells = staticValidation.formulaCells;
if (staticValidation.violations.length > 0) {
abort(stage, stats, warnings, staticValidation.violations);
}
stage = 'structure';
await workbook.xlsx.writeFile(transaction.candidatePath);
await validateCandidate(
transaction.candidatePath,
transaction.validationDir,
expectationData.expectations,
stats,
warnings,
ctx,
dependencies,
);
stage = 'commit';
if (request.dryRun) {
await transaction.cleanup();
return result(stage, stats, warnings);
}
const commit = await transaction.commit();
committed = true;
return result(stage, stats, warnings, [], commit.outputPath);
} catch (error) {
if (error instanceof PipelineAbort) return error.result;
// 理由の分かっている失敗(ExcelFileSafetyError)は、その診断のまま返す。
if (error instanceof ExcelFileSafetyError) {
return result(stage, stats, warnings, [violation(error.code, error.message)]);
}
// それ以外の実行時例外を「ファイルが壊れている」「入力が不正だ」と診断しない。
// ここへ来る時点で、元ファイルは package 段階の検査(ZIP 構造・必須部品・
// workbook と worksheet の XML の整形式)を通っている。ライブラリや外部
// プロセスが落ちただけの例外を PACKAGE_INVALID にすると、正常なブックを
// 壊れていると診断することになる(#1067 診断 1 件目)。
const detail = error instanceof Error ? error.message : String(error);
return result(stage, stats, warnings, [violation(
'INTERNAL_ERROR',
`Excel編集が${stage}段階で失敗しました(内部エラー。ファイルが壊れているとは限りません): ${detail}`,
)]);
} finally {
if (transaction && !committed) await transaction.cleanup();
}
}
+234
View File
@@ -0,0 +1,234 @@
import { createHash } from 'node:crypto';
import {
existsSync,
mkdirSync,
readFileSync,
renameSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import ExcelJS from 'exceljs';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
ExcelFileSafetyError,
createExcelFileTransaction,
} from './excel-file-safety.js';
const workspaces: string[] = [];
async function workspace(): Promise<string> {
const path = await mkdtemp(join(tmpdir(), 'excel-file-safety-'));
workspaces.push(path);
return path;
}
async function writeWorkbook(path: string, value: string): Promise<void> {
mkdirSync(dirname(path), { recursive: true });
const workbook = new ExcelJS.Workbook();
workbook.addWorksheet('Sheet1').getCell('A1').value = value;
await workbook.xlsx.writeFile(path);
}
function sha256(path: string): string {
return createHash('sha256').update(readFileSync(path)).digest('hex');
}
afterEach(() => {
for (const path of workspaces.splice(0)) rmSync(path, { recursive: true, force: true });
});
describe('createExcelFileTransaction', () => {
it.each([
['outside path', '../escape.xlsx'],
['readonly path', 'readonly/book.xlsx'],
])('rejects %s', async (_name, outputPath) => {
const workspacePath = await workspace();
await expect(createExcelFileTransaction({ workspacePath, outputPath })).rejects.toMatchObject({
code: 'INPUT_INVALID',
});
});
it('requires the same path and matching hash for overwrite', async () => {
const workspacePath = await workspace();
const sourcePath = join(workspacePath, 'input/book.xlsx');
await writeWorkbook(sourcePath, 'source');
const sourceSha256 = sha256(sourcePath);
await expect(createExcelFileTransaction({
workspacePath,
sourcePath: 'input/book.xlsx',
outputPath: 'output/book.xlsx',
overwrite: true,
expectedSourceSha256: sourceSha256,
})).rejects.toMatchObject({ code: 'OUTPUT_CONFLICT' });
await expect(createExcelFileTransaction({
workspacePath,
sourcePath: 'input/book.xlsx',
outputPath: 'input/book.xlsx',
overwrite: true,
expectedSourceSha256: '0'.repeat(64),
})).rejects.toMatchObject({ code: 'SOURCE_CHANGED_DURING_EDIT' });
await expect(createExcelFileTransaction({
workspacePath,
sourcePath: 'input/book.xlsx',
outputPath: 'input/book.xlsx',
overwrite: true,
})).rejects.toMatchObject({ code: 'INPUT_INVALID' });
await expect(createExcelFileTransaction({
workspacePath,
outputPath: 'output/book.xlsx',
expectedSourceSha256: sourceSha256,
})).rejects.toMatchObject({ code: 'INPUT_INVALID' });
});
it('successfully overwrites only with the matching source hash', async () => {
const workspacePath = await workspace();
const sourcePath = join(workspacePath, 'input/book.xlsx');
await writeWorkbook(sourcePath, 'source');
const tx = await createExcelFileTransaction({
workspacePath,
sourcePath: 'input/book.xlsx',
outputPath: 'input/book.xlsx',
overwrite: true,
expectedSourceSha256: sha256(sourcePath).toUpperCase(),
});
await writeWorkbook(tx.candidatePath, 'candidate');
const candidateSha = sha256(tx.candidatePath);
const result = await tx.commit();
expect(result).toEqual({ outputPath: 'input/book.xlsx', sha256: candidateSha });
expect(sha256(sourcePath)).toBe(candidateSha);
expect(existsSync(tx.candidatePath)).toBe(false);
expect(existsSync(tx.validationDir)).toBe(false);
});
it('rechecks the source hash immediately before commit', async () => {
const workspacePath = await workspace();
const sourcePath = join(workspacePath, 'input/book.xlsx');
await writeWorkbook(sourcePath, 'source');
const originalSha = sha256(sourcePath);
const tx = await createExcelFileTransaction({
workspacePath,
sourcePath: 'input/book.xlsx',
outputPath: 'input/book.xlsx',
overwrite: true,
expectedSourceSha256: originalSha,
});
await writeWorkbook(tx.candidatePath, 'candidate');
await writeWorkbook(sourcePath, 'external edit');
const externalSha = sha256(sourcePath);
await expect(tx.commit()).rejects.toMatchObject({ code: 'SOURCE_CHANGED_DURING_EDIT' });
expect(sha256(sourcePath)).toBe(externalSha);
expect(existsSync(tx.candidatePath)).toBe(false);
});
it('rejects a destination created after validation when overwrite=false', async () => {
const workspacePath = await workspace();
const tx = await createExcelFileTransaction({ workspacePath, outputPath: 'output/book.xlsx' });
await writeWorkbook(tx.candidatePath, 'candidate');
writeFileSync(tx.destinationPath, 'external');
const externalSha = sha256(tx.destinationPath);
await expect(tx.commit()).rejects.toMatchObject({ code: 'OUTPUT_CONFLICT' });
expect(sha256(tx.destinationPath)).toBe(externalSha);
});
it.runIf(process.platform !== 'win32')(
'rejects a destination directory swapped to an outside symlink before commit',
async () => {
const workspacePath = await workspace();
const outsidePath = await workspace();
const tx = await createExcelFileTransaction({ workspacePath, outputPath: 'output/book.xlsx' });
await writeWorkbook(tx.candidatePath, 'candidate');
const parkedOutput = join(workspacePath, 'parked-output');
renameSync(dirname(tx.destinationPath), parkedOutput);
symlinkSync(outsidePath, dirname(tx.destinationPath), 'dir');
const outsideCandidate = join(outsidePath, tx.candidatePath.split('/').at(-1)!);
writeFileSync(outsideCandidate, 'outside');
await expect(tx.commit()).rejects.toMatchObject({ code: 'INPUT_INVALID' });
expect(readFileSync(outsideCandidate, 'utf8')).toBe('outside');
},
);
it('places the candidate beside the destination', async () => {
const workspacePath = await workspace();
const tx = await createExcelFileTransaction({ workspacePath, outputPath: 'output/book.xlsx' });
expect(dirname(tx.candidatePath)).toBe(dirname(tx.destinationPath));
expect(tx.candidatePath).toMatch(/\.book\.xlsx\.[0-9a-f-]+\.candidate\.xlsx$/);
await tx.cleanup();
});
it('always performs the mandatory ExcelJS reread before a custom validator', async () => {
const workspacePath = await workspace();
const validateCandidate = vi.fn(async () => undefined);
const tx = await createExcelFileTransaction({
workspacePath,
outputPath: 'output/book.xlsx',
validateCandidate,
});
writeFileSync(tx.candidatePath, 'not an xlsx package');
await expect(tx.commit()).rejects.toMatchObject({ code: 'PACKAGE_INVALID' });
expect(validateCandidate).not.toHaveBeenCalled();
expect(existsSync(tx.candidatePath)).toBe(false);
});
it('removes candidate and validation data after an injected failure', async () => {
const workspacePath = await workspace();
const tx = await createExcelFileTransaction({
workspacePath,
outputPath: 'output/book.xlsx',
validateCandidate: async (_candidatePath, validationDir) => {
mkdirSync(join(validationDir, 'profile'), { recursive: true });
await writeFile(join(validationDir, 'validation.xlsx'), 'temporary');
throw new Error('injected validation failure');
},
});
await writeWorkbook(tx.candidatePath, 'candidate');
await expect(tx.commit()).rejects.toThrow('injected validation failure');
expect(existsSync(tx.candidatePath)).toBe(false);
expect(existsSync(tx.validationDir)).toBe(false);
expect(existsSync(tx.destinationPath)).toBe(false);
});
it('commits a valid candidate and returns its hash', async () => {
const workspacePath = await workspace();
const tx = await createExcelFileTransaction({
workspacePath,
outputPath: 'output/book.xlsx',
recordFileProvenance: () => { throw new Error('provenance unavailable'); },
});
await writeWorkbook(tx.candidatePath, 'candidate');
const candidateSha = sha256(tx.candidatePath);
const result = await tx.commit();
expect(result).toEqual({ outputPath: 'output/book.xlsx', sha256: candidateSha });
expect(sha256(tx.destinationPath)).toBe(candidateSha);
expect(existsSync(tx.candidatePath)).toBe(false);
expect(existsSync(tx.validationDir)).toBe(false);
await expect(tx.commit()).rejects.toMatchObject({ code: 'OUTPUT_CONFLICT' });
await expect(tx.cleanup()).resolves.toBeUndefined();
expect(sha256(tx.destinationPath)).toBe(candidateSha);
});
it('exposes stable safety error codes', () => {
expect(new ExcelFileSafetyError('OUTPUT_CONFLICT', 'conflict')).toMatchObject({
name: 'ExcelFileSafetyError',
code: 'OUTPUT_CONFLICT',
});
});
});
+326
View File
@@ -0,0 +1,326 @@
import { createHash, randomUUID } from 'node:crypto';
import { createReadStream, existsSync } from 'node:fs';
import {
link,
lstat,
mkdir,
realpath,
rename,
rm,
stat,
unlink,
} from 'node:fs/promises';
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
import ExcelJS from 'exceljs';
import { recordVersion } from './conflict-guard.js';
import type { FileProvenanceEvent } from './tool-context.js';
import { assertWritable, resolveAndGuard } from './core.js';
import type { ExcelViolationCode } from './excel-types.js';
export class ExcelFileSafetyError extends Error {
constructor(
public readonly code: ExcelViolationCode,
message: string,
) {
super(message);
this.name = 'ExcelFileSafetyError';
}
}
export interface ExcelFileTransaction {
sourcePath?: string;
sourceSha256?: string;
destinationPath: string;
candidatePath: string;
validationDir: string;
assertSourceUnchanged(): Promise<void>;
commit(): Promise<{ outputPath: string; sha256: string }>;
cleanup(): Promise<void>;
}
export interface CreateExcelFileTransactionOptions {
workspacePath: string;
sourcePath?: string;
outputPath: string;
overwrite?: boolean;
expectedSourceSha256?: string | null;
conflictDir?: string;
recordFileProvenance?: (event: FileProvenanceEvent) => void;
validateCandidate?: (candidatePath: string, validationDir: string) => Promise<void>;
}
async function sha256File(path: string): Promise<string> {
const hash = createHash('sha256');
await new Promise<void>((resolve, reject) => {
const stream = createReadStream(path);
stream.on('data', (chunk: string | Buffer) => { hash.update(chunk); });
stream.once('error', reject);
stream.once('end', resolve);
});
return hash.digest('hex');
}
async function isFile(path: string): Promise<boolean> {
try {
return (await stat(path)).isFile();
} catch {
return false;
}
}
function workspaceRelative(workspacePath: string, path: string): string {
return relative(resolve(workspacePath), path).split(sep).join('/');
}
function isPathWithin(root: string, path: string): boolean {
return path === root || path.startsWith(root + sep);
}
async function validateWithExcelJs(candidatePath: string): Promise<void> {
try {
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(candidatePath);
} catch {
throw new ExcelFileSafetyError('PACKAGE_INVALID', '候補Excelファイルを再読込できません');
}
}
function safetyPath(
workspacePath: string,
path: string,
writable: boolean,
): string {
try {
const guarded = resolveAndGuard(workspacePath, path);
if (writable) assertWritable(workspacePath, guarded);
return guarded;
} catch (error) {
throw new ExcelFileSafetyError('INPUT_INVALID', (error as Error).message);
}
}
export async function createExcelFileTransaction(
options: CreateExcelFileTransactionOptions,
): Promise<ExcelFileTransaction> {
const destinationPath = safetyPath(options.workspacePath, options.outputPath, true);
const sourcePath = options.sourcePath
? safetyPath(options.workspacePath, options.sourcePath, false)
: undefined;
const overwrite = options.overwrite === true;
let workspaceRealPath: string;
try {
workspaceRealPath = await realpath(resolve(options.workspacePath));
} catch {
throw new ExcelFileSafetyError('INPUT_INVALID', 'ワークスペースを解決できません');
}
if (sourcePath && !(await isFile(sourcePath))) {
throw new ExcelFileSafetyError('INPUT_INVALID', '入力Excelファイルが存在しません');
}
const sourceSha256 = sourcePath ? await sha256File(sourcePath) : undefined;
const expectedSourceSha256 = options.expectedSourceSha256?.toLowerCase() ?? null;
if (expectedSourceSha256 && !sourcePath) {
throw new ExcelFileSafetyError(
'INPUT_INVALID',
'expected_source_sha256には入力Excelファイルが必要です',
);
}
if (expectedSourceSha256 && expectedSourceSha256 !== sourceSha256) {
throw new ExcelFileSafetyError(
'SOURCE_CHANGED_DURING_EDIT',
'入力ExcelファイルのSHA-256が期待値と一致しません',
);
}
if (overwrite) {
if (!sourcePath || sourcePath !== destinationPath) {
throw new ExcelFileSafetyError(
'OUTPUT_CONFLICT',
'上書きには同一の入力パスと出力パスが必要です',
);
}
if (!expectedSourceSha256) {
throw new ExcelFileSafetyError(
'INPUT_INVALID',
'上書きにはexpected_source_sha256が必要です',
);
}
} else if (existsSync(destinationPath)) {
throw new ExcelFileSafetyError('OUTPUT_CONFLICT', '出力先は既に存在します');
}
await mkdir(dirname(destinationPath), { recursive: true });
safetyPath(options.workspacePath, destinationPath, true);
const id = randomUUID();
const name = basename(destinationPath);
const candidatePath = safetyPath(
options.workspacePath,
join(dirname(destinationPath), `.${name}.${id}.candidate.xlsx`),
true,
);
const validationDir = safetyPath(
options.workspacePath,
join(dirname(destinationPath), `.${name}.${id}.validation`),
true,
);
await mkdir(validationDir, { recursive: false });
const outputPath = workspaceRelative(options.workspacePath, destinationPath);
const destinationExisted = existsSync(destinationPath);
let committed = false;
const resolveSafeTemporaryPath = async (path: string): Promise<string | null> => {
try {
const realParent = await realpath(dirname(path));
if (!isPathWithin(workspaceRealPath, realParent)) return null;
return join(realParent, basename(path));
} catch {
return null;
}
};
const cleanup = async (): Promise<void> => {
const [safeCandidatePath, safeValidationDir] = await Promise.all([
resolveSafeTemporaryPath(candidatePath),
resolveSafeTemporaryPath(validationDir),
]);
await Promise.allSettled([
safeCandidatePath ? unlink(safeCandidatePath) : Promise.resolve(),
safeValidationDir
? rm(safeValidationDir, { recursive: true, force: true })
: Promise.resolve(),
]);
};
const resolveCommitPaths = async (): Promise<{
candidate: string;
destination: string;
}> => {
try {
const [realCandidateDir, realDestinationDir] = await Promise.all([
realpath(dirname(candidatePath)),
realpath(dirname(destinationPath)),
]);
if (
realCandidateDir !== realDestinationDir
|| !isPathWithin(workspaceRealPath, realDestinationDir)
) {
throw new ExcelFileSafetyError(
'INPUT_INVALID',
'出力先ディレクトリがワークスペース外へ変更されました',
);
}
const candidate = join(realCandidateDir, basename(candidatePath));
const destination = join(realDestinationDir, basename(destinationPath));
assertWritable(workspaceRealPath, destination);
const candidateStat = await lstat(candidate);
if (!candidateStat.isFile() || candidateStat.isSymbolicLink()) {
throw new ExcelFileSafetyError('PACKAGE_INVALID', '候補Excelファイルが通常ファイルではありません');
}
return { candidate, destination };
} catch (error) {
if (error instanceof ExcelFileSafetyError) throw error;
throw new ExcelFileSafetyError('INPUT_INVALID', '確定対象の実パスを検証できません');
}
};
const assertSourceUnchanged = async (): Promise<void> => {
if (!sourcePath || !sourceSha256) return;
let currentSha256: string;
try {
currentSha256 = await sha256File(sourcePath);
} catch {
throw new ExcelFileSafetyError(
'SOURCE_CHANGED_DURING_EDIT',
'入力Excelファイルが編集中に変更されました',
);
}
if (currentSha256 !== sourceSha256) {
throw new ExcelFileSafetyError(
'SOURCE_CHANGED_DURING_EDIT',
'入力Excelファイルが編集中に変更されました',
);
}
};
const recordSuccess = (sha256: string): void => {
if (options.conflictDir) {
try {
recordVersion(options.workspacePath, outputPath, options.conflictDir);
} catch {
// Conflict recording is best-effort and must not invalidate a commit.
}
}
if (options.recordFileProvenance) {
try {
options.recordFileProvenance({
relPath: outputPath,
event: destinationExisted ? 'modify' : 'create',
sourceKind: 'agent_output',
checksum: sha256,
});
} catch {
// Provenance is best-effort and must not invalidate a committed file.
}
}
};
const commit = async (): Promise<{ outputPath: string; sha256: string }> => {
if (committed) {
throw new ExcelFileSafetyError('OUTPUT_CONFLICT', 'このExcelトランザクションは確定済みです');
}
try {
await assertSourceUnchanged();
const commitPaths = await resolveCommitPaths();
await validateWithExcelJs(commitPaths.candidate);
if (options.validateCandidate) {
await options.validateCandidate(commitPaths.candidate, validationDir);
}
const candidateSha256 = await sha256File(commitPaths.candidate);
// Keep these checks immediately adjacent to the atomic filesystem operation.
await assertSourceUnchanged();
const finalPaths = await resolveCommitPaths();
if (finalPaths.candidate !== commitPaths.candidate) {
throw new ExcelFileSafetyError('INPUT_INVALID', '候補ファイルの実パスが検証後に変更されました');
}
if (overwrite) {
await rename(finalPaths.candidate, finalPaths.destination);
} else {
try {
await link(finalPaths.candidate, finalPaths.destination);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
throw new ExcelFileSafetyError('OUTPUT_CONFLICT', '出力先が確定前に作成されました');
}
throw error;
}
await unlink(finalPaths.candidate).catch(() => undefined);
}
committed = true;
const committedSha256 = await sha256File(finalPaths.destination)
.catch(() => candidateSha256);
recordSuccess(committedSha256);
await cleanup();
return { outputPath, sha256: committedSha256 };
} catch (error) {
if (!committed) await cleanup();
throw error;
}
};
return {
sourcePath,
sourceSha256,
destinationPath,
candidatePath,
validationDir,
assertSourceUnchanged,
commit,
cleanup,
};
}
@@ -0,0 +1,38 @@
import ExcelJS from 'exceljs';
import { describe, expect, it } from 'vitest';
import { compileGanttStyles, type ExcelGanttSpec } from './excel-gantt.js';
const roles: ExcelGanttSpec['roles'] = {
weekend: { fill: 'FFEEEEEE' },
holiday: { fill: 'FFFFEEEE' },
planned: { fill: 'FF99CCFF' },
completed: { fill: 'FF0066CC' },
};
describe('Excel Gantt adversarial limits', () => {
it('rejects an enormous task-row scan before iterating through the workbook', () => {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Plan');
sheet.getCell('F1').value = new Date(Date.UTC(2026, 0, 1));
const result = compileGanttStyles(workbook, {
sheet: 'Plan',
timelineHeader: 'F1:F1',
taskRows: '2:1048576',
startColumn: 'C',
endColumn: 'D',
progressColumn: 'E',
progressScale: 'fraction',
holidays: [],
roles,
});
expect(result).toMatchObject({
operations: [],
violations: [expect.objectContaining({
code: 'INPUT_INVALID',
range: '2:1048576',
})],
});
});
});
@@ -0,0 +1,44 @@
import ExcelJS from 'exceljs';
import { describe, expect, it } from 'vitest';
import { compileGanttStyles, type ExcelGanttSpec } from './excel-gantt.js';
const roles: ExcelGanttSpec['roles'] = {
weekend: { fill: 'FFEEEEEE' },
holiday: { fill: 'FFFFEEEE' },
planned: { fill: 'FF99CCFF' },
completed: { fill: 'FF0066CC' },
};
function spec(): ExcelGanttSpec {
return {
sheet: 'Plan',
timelineHeader: 'F1:F1',
taskRows: '2:2',
startColumn: 'C',
endColumn: 'D',
progressColumn: 'E',
progressScale: 'fraction',
holidays: [],
roles,
};
}
describe('Excel Gantt date bounds', () => {
it('rejects a finite numeric date outside Excel supported years', () => {
const workbook = new ExcelJS.Workbook();
workbook.addWorksheet('Plan').getCell('F1').value = Number.MAX_SAFE_INTEGER;
expect(compileGanttStyles(workbook, spec())).toMatchObject({
operations: [],
violations: [expect.objectContaining({ code: 'INPUT_INVALID', cell: 'F1' })],
});
});
it('rejects holiday dates before the Excel epoch', () => {
const workbook = new ExcelJS.Workbook();
workbook.addWorksheet('Plan').getCell('F1').value = new Date(Date.UTC(2026, 0, 1));
expect(compileGanttStyles(workbook, { ...spec(), holidays: ['1899-12-31'] }))
.toMatchObject({ operations: [], violations: [expect.objectContaining({ code: 'INPUT_INVALID' })] });
});
});
@@ -0,0 +1,43 @@
import ExcelJS from 'exceljs';
import { describe, expect, it } from 'vitest';
import { compileGanttStyles, type ExcelGanttSpec } from './excel-gantt.js';
const roles: ExcelGanttSpec['roles'] = {
weekend: { fill: 'FFEEEEEE' },
holiday: { fill: 'FFFFEEEE' },
planned: { fill: 'FF99CCFF' },
completed: { fill: 'FF0066CC' },
};
describe('Excel Gantt 1904 date system', () => {
it('interprets numeric dates using the workbook date1904 setting', () => {
const workbook = new ExcelJS.Workbook();
workbook.properties.date1904 = true;
const sheet = workbook.addWorksheet('Plan');
const epoch1904 = Date.UTC(1904, 0, 1) / 86_400_000;
const friday = Date.UTC(2026, 7, 14) / 86_400_000 - epoch1904;
sheet.getCell('F2').value = friday;
sheet.getCell('G2').value = friday + 1;
sheet.getCell('C3').value = friday;
sheet.getCell('D3').value = friday + 1;
sheet.getCell('E3').value = 1;
const result = compileGanttStyles(workbook, {
sheet: 'Plan',
timelineHeader: 'F2:G2',
taskRows: '3:3',
startColumn: 'C',
endColumn: 'D',
progressColumn: 'E',
progressScale: 'fraction',
holidays: [],
roles,
});
expect(result.violations).toEqual([]);
expect(result.operations).toEqual([
{ op: 'style_range', sheet: 'Plan', range: 'G2:G3', style: roles.weekend },
{ op: 'style_range', sheet: 'Plan', range: 'F3', style: roles.completed },
]);
});
});
+86
View File
@@ -0,0 +1,86 @@
import ExcelJS from 'exceljs';
import { describe, expect, it } from 'vitest';
import { compileGanttStyles, type ExcelGanttSpec } from './excel-gantt.js';
const roles: ExcelGanttSpec['roles'] = {
weekend: { fill: 'FFEEEEEE' },
holiday: { fill: 'FFFFEEEE' },
planned: { fill: 'FF99CCFF' },
completed: { fill: 'FF0066CC' },
};
function baseWorkbook(): ExcelJS.Workbook {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Plan');
for (let offset = 0; offset < 7; offset += 1) {
sheet.getCell(2, 6 + offset).value = new Date(Date.UTC(2026, 7, 10 + offset));
}
return workbook;
}
function baseSpec(overrides: Partial<ExcelGanttSpec> = {}): ExcelGanttSpec {
return {
sheet: 'plan',
timelineHeader: 'F2:L2',
taskRows: '3:3',
startColumn: 'C',
endColumn: 'D',
progressColumn: 'E',
progressScale: 'fraction',
holidays: [],
roles,
...overrides,
};
}
describe('Excel Gantt edge cases', () => {
it('calculates completion against the full task before clipping to the visible timeline', () => {
const workbook = baseWorkbook();
const sheet = workbook.getWorksheet('Plan')!;
sheet.getCell('C3').value = new Date(Date.UTC(2026, 7, 3));
sheet.getCell('D3').value = new Date(Date.UTC(2026, 7, 16));
sheet.getCell('E3').value = 0.5;
const result = compileGanttStyles(workbook, baseSpec());
expect(result.violations).toEqual([]);
expect(result.operations).not.toContainEqual(expect.objectContaining({
style: roles.completed,
range: expect.stringContaining('3'),
}));
expect(result.operations).toContainEqual({
op: 'style_range', sheet: 'Plan', range: 'F3:J3', style: roles.planned,
});
});
it('accepts Excel serial dates and resolves the actual sheet name case-insensitively', () => {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Plan');
const serial = Date.UTC(2026, 7, 10) / 86_400_000 + 25_569;
sheet.getCell('F2').value = serial;
sheet.getCell('G2').value = serial + 1;
sheet.getCell('C3').value = serial;
sheet.getCell('D3').value = serial + 1;
sheet.getCell('E3').value = 1;
const result = compileGanttStyles(workbook, baseSpec({ timelineHeader: 'F2:G2' }));
expect(result.violations).toEqual([]);
expect(result.operations).toContainEqual({
op: 'style_range', sheet: 'Plan', range: 'F3:G3', style: roles.completed,
});
});
it('rejects a header at or below the first task row', () => {
const workbook = baseWorkbook();
const result = compileGanttStyles(workbook, baseSpec({
timelineHeader: 'F3:L3',
taskRows: '3:4',
}));
expect(result).toMatchObject({
operations: [],
violations: [expect.objectContaining({ code: 'INPUT_INVALID', range: 'F3:L3' })],
});
});
});
+209
View File
@@ -0,0 +1,209 @@
import ExcelJS from 'exceljs';
import { describe, expect, it } from 'vitest';
import { compileGanttStyles, type ExcelGanttSpec } from './excel-gantt.js';
const roles: ExcelGanttSpec['roles'] = {
weekend: { fill: 'FFEEEEEE' },
holiday: { fill: 'FFFFEEEE' },
planned: { fill: 'FF99CCFF' },
completed: { fill: 'FF0066CC' },
};
function spec(overrides: Partial<ExcelGanttSpec> = {}): ExcelGanttSpec {
return {
sheet: 'Plan',
timelineHeader: 'F1:G1',
taskRows: '2:2',
startColumn: 'C',
endColumn: 'D',
progressColumn: 'E',
progressScale: 'fraction',
holidays: [],
roles,
...overrides,
};
}
describe('Excel Gantt Fable review regressions', () => {
it('rejects excessive timeline width before creating sparse cells', () => {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Plan');
sheet.getCell('F1').value = new Date(Date.UTC(2026, 0, 1));
const result = compileGanttStyles(workbook, spec({
timelineHeader: 'F1:XFD1',
taskRows: '2:501',
}));
expect(result.operations).toEqual([]);
expect(result.violations).toEqual([
expect.objectContaining({ code: 'INPUT_INVALID', range: 'F1:XFD1' }),
]);
expect(sheet.findRow(1)?.findCell(7)).toBeUndefined();
expect(sheet.findRow(2)).toBeUndefined();
});
it('does not materialize missing cells while validating a sparse sheet', () => {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Plan');
sheet.getCell('F1').value = new Date(Date.UTC(2026, 0, 1));
compileGanttStyles(workbook, spec());
expect(sheet.findRow(1)?.findCell(7)).toBeUndefined();
expect(sheet.findRow(2)).toBeUndefined();
});
it('rejects ambiguous fractional percentages above one', () => {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Plan');
sheet.getCell('F1').value = '2026-08-03';
sheet.getCell('G1').value = '2026-08-04';
sheet.getCell('C2').value = '2026-08-03';
sheet.getCell('D2').value = '2026-08-04';
sheet.getCell('E2').value = 1.01;
expect(compileGanttStyles(workbook, spec())).toMatchObject({
operations: [],
violations: [expect.objectContaining({ code: 'INPUT_INVALID', cell: 'E2' })],
});
});
it('handles the Excel 1900 leap-year bug and rejects fictional serial 60', () => {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Plan');
sheet.getCell('F1').value = 1;
sheet.getCell('G1').value = 2;
sheet.getCell('C2').value = 1;
sheet.getCell('D2').value = 2;
sheet.getCell('E2').value = 1;
expect(compileGanttStyles(workbook, spec()).operations).toContainEqual({
op: 'style_range',
sheet: 'Plan',
range: 'F2:G2',
style: roles.completed,
});
sheet.getCell('F1').value = 60;
expect(compileGanttStyles(workbook, spec())).toMatchObject({
operations: [],
violations: [expect.objectContaining({ code: 'INPUT_INVALID', cell: 'F1' })],
});
});
it('classifies shared formulas as unsupported Gantt inputs', () => {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Plan');
sheet.getCell('F1').value = '2026-08-03';
sheet.getCell('G1').value = '2026-08-04';
sheet.getCell('C2').value = { formula: 'TODAY()', result: 1 };
sheet.getCell('D2').value = { sharedFormula: 'C2', result: 2 };
sheet.getCell('E2').value = 0.5;
expect(compileGanttStyles(workbook, spec()).violations).toEqual(expect.arrayContaining([
expect.objectContaining({ code: 'GANTT_FORMULA_INPUT_UNSUPPORTED', cell: 'C2' }),
expect.objectContaining({ code: 'GANTT_FORMULA_INPUT_UNSUPPORTED', cell: 'D2' }),
]));
});
it('rejects task data columns that overlap the timeline', () => {
const workbook = new ExcelJS.Workbook();
workbook.addWorksheet('Plan');
expect(compileGanttStyles(workbook, spec({ startColumn: 'F' }))).toMatchObject({
operations: [],
violations: [expect.objectContaining({ code: 'INPUT_INVALID', cell: 'F' })],
});
});
it('styles only the header and task rows when there is a layout gap', () => {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Plan');
sheet.getCell('F1').value = '2026-08-08';
sheet.getCell('G1').value = '2026-08-09';
sheet.getCell('C5').value = '2026-08-08';
sheet.getCell('D5').value = '2026-08-09';
sheet.getCell('E5').value = 1;
const result = compileGanttStyles(workbook, spec({ taskRows: '5:5' }));
expect(result.violations).toEqual([]);
expect(result.operations).toEqual([
{ op: 'style_range', sheet: 'Plan', range: 'F1:G1', style: roles.weekend },
{ op: 'style_range', sheet: 'Plan', range: 'F5:G5', style: roles.weekend },
]);
expect(result.operations[0]!.style).not.toBe(roles.weekend);
});
it('warns when a valid task is completely outside the visible timeline', () => {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Plan');
sheet.getCell('F1').value = '2026-08-03';
sheet.getCell('G1').value = '2026-08-04';
sheet.getCell('C2').value = '2027-01-01';
sheet.getCell('D2').value = '2027-01-02';
sheet.getCell('E2').value = 0.5;
expect(compileGanttStyles(workbook, spec()).warnings).toEqual([
expect.objectContaining({
code: 'GANTT_TASK_OUTSIDE_TIMELINE',
sheet: 'Plan',
range: 'C2:D2',
}),
]);
});
it('uses an explicit percent scale so 1 means one percent', () => {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Plan');
sheet.getCell('F1').value = '2026-08-03';
sheet.getCell('G1').value = '2026-08-04';
sheet.getCell('C2').value = '2026-08-03';
sheet.getCell('D2').value = '2026-08-04';
sheet.getCell('E2').value = 1;
const result = compileGanttStyles(workbook, spec({ progressScale: 'percent' }));
expect(result.violations).toEqual([]);
expect(result.operations).toContainEqual({
op: 'style_range',
sheet: 'Plan',
range: 'F2:G2',
style: roles.planned,
});
});
it('normalizes lowercase task columns', () => {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Plan');
sheet.getCell('F1').value = '2026-08-03';
sheet.getCell('G1').value = '2026-08-04';
sheet.getCell('C2').value = '2026-08-03';
sheet.getCell('D2').value = '2026-08-04';
sheet.getCell('E2').value = 1;
const result = compileGanttStyles(workbook, spec({
startColumn: 'c',
endColumn: 'd',
progressColumn: 'e',
}));
expect(result.violations).toEqual([]);
expect(result.operations).toContainEqual(
expect.objectContaining({ range: 'F2:G2', style: roles.completed }),
);
});
it('rejects duplicate start, end and progress columns', () => {
const workbook = new ExcelJS.Workbook();
workbook.addWorksheet('Plan');
expect(compileGanttStyles(workbook, spec({ endColumn: 'C' }))).toMatchObject({
operations: [],
violations: [expect.objectContaining({
code: 'INPUT_INVALID',
message: expect.stringContaining('異なる列'),
})],
});
});
});
@@ -0,0 +1,50 @@
import ExcelJS from 'exceljs';
import { describe, expect, it } from 'vitest';
import { compileGanttStyles, type ExcelGanttSpec } from './excel-gantt.js';
const roles: ExcelGanttSpec['roles'] = {
weekend: { fill: 'FFEEEEEE' },
holiday: { fill: 'FFFFEEEE' },
planned: { fill: 'FF99CCFF' },
completed: { fill: 'FF0066CC' },
};
function workbook(): ExcelJS.Workbook {
const result = new ExcelJS.Workbook();
result.addWorksheet('Plan').getCell('F1').value = new Date(Date.UTC(2026, 0, 1));
return result;
}
function spec(overrides: Partial<ExcelGanttSpec>): ExcelGanttSpec {
return {
sheet: 'Plan',
timelineHeader: 'F1:F1',
taskRows: '2:2',
startColumn: 'C',
endColumn: 'D',
progressColumn: 'E',
progressScale: 'fraction',
holidays: [],
roles,
...overrides,
};
}
describe('Excel Gantt bounded inputs', () => {
it('rejects more task rows than the operation budget before scanning them', () => {
expect(compileGanttStyles(workbook(), spec({ taskRows: '2:502' }))).toMatchObject({
operations: [],
violations: [expect.objectContaining({ code: 'INPUT_INVALID', range: '2:502' })],
});
});
it('rejects a holiday list larger than the operation budget', () => {
const holidays = Array.from({ length: 501 }, (_, index) => (
new Date(Date.UTC(2026, 0, 1 + index)).toISOString().slice(0, 10)
));
expect(compileGanttStyles(workbook(), spec({ holidays }))).toMatchObject({
operations: [],
violations: [expect.objectContaining({ code: 'INPUT_INVALID' })],
});
});
});
+153
View File
@@ -0,0 +1,153 @@
import ExcelJS from 'exceljs';
import { describe, expect, it } from 'vitest';
import {
compileGanttStyles,
type ExcelGanttSpec,
} from './excel-gantt.js';
const roles: ExcelGanttSpec['roles'] = {
weekend: { fill: 'FFE2E8F0' },
holiday: { fill: 'FFFECACA' },
planned: { fill: 'FF93C5FD' },
completed: { fill: 'FF2563EB' },
};
function workbookWithTimeline(): ExcelJS.Workbook {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('工程表');
for (let offset = 0; offset < 7; offset += 1) {
sheet.getCell(4, 6 + offset).value = new Date(Date.UTC(2026, 7, 3 + offset));
}
sheet.getCell('C5').value = new Date(Date.UTC(2026, 7, 3));
sheet.getCell('D5').value = new Date(Date.UTC(2026, 7, 9));
sheet.getCell('E5').value = 0.5;
return workbook;
}
function spec(overrides: Partial<ExcelGanttSpec> = {}): ExcelGanttSpec {
return {
sheet: '工程表',
timelineHeader: 'F4:L4',
taskRows: '5:5',
startColumn: 'C',
endColumn: 'D',
progressColumn: 'E',
progressScale: 'fraction',
holidays: ['2026-08-05'],
roles,
...overrides,
};
}
describe('compileGanttStyles', () => {
it('emits holiday/weekend backgrounds and progress bars in override order', () => {
const workbook = workbookWithTimeline();
const before = workbook.getWorksheet('工程表')!.getCell('F5').style;
const result = compileGanttStyles(workbook, spec());
expect(result.violations).toEqual([]);
expect(result.warnings).toEqual([]);
expect(result.operations).toEqual([
{ op: 'style_range', sheet: '工程表', range: 'H4:H5', style: roles.holiday },
{ op: 'style_range', sheet: '工程表', range: 'K4:L5', style: roles.weekend },
{ op: 'style_range', sheet: '工程表', range: 'F5:G5', style: roles.completed },
{ op: 'style_range', sheet: '工程表', range: 'I5:J5', style: roles.planned },
]);
expect(workbook.getWorksheet('工程表')!.getCell('F5').style).toBe(before);
});
it('accepts percentage values from 0 to 100 and skips completely blank task rows', () => {
const workbook = workbookWithTimeline();
workbook.getWorksheet('工程表')!.getCell('E5').value = 50;
const result = compileGanttStyles(workbook, spec({ taskRows: '5:6', progressScale: 'percent' }));
expect(result.violations).toEqual([]);
expect(result.operations.filter((operation) => operation.range.endsWith('6'))).toEqual([
expect.objectContaining({ range: 'H4:H6' }),
expect.objectContaining({ range: 'K4:L6' }),
]);
expect(result.operations).toContainEqual(
{ op: 'style_range', sheet: '工程表', range: 'F5:G5', style: roles.completed },
);
});
it('fails closed when any mandatory visual role is missing', () => {
const workbook = workbookWithTimeline();
const result = compileGanttStyles(workbook, spec({
roles: { ...roles, holiday: undefined } as unknown as ExcelGanttSpec['roles'],
}));
expect(result).toMatchObject({
operations: [],
violations: [expect.objectContaining({
code: 'MISSING_STYLE_ROLE',
message: expect.stringContaining('holiday'),
})],
});
});
it.each([
['timeline header', 'F4'],
['start date', 'C5'],
['end date', 'D5'],
['progress', 'E5'],
])('rejects formula-driven %s cells instead of trusting stale results', (_label, address) => {
const workbook = workbookWithTimeline();
workbook.getWorksheet('工程表')!.getCell(address).value = { formula: 'TODAY()', result: 1 };
const result = compileGanttStyles(workbook, spec());
expect(result.violations).toEqual(expect.arrayContaining([
expect.objectContaining({
code: 'GANTT_FORMULA_INPUT_UNSUPPORTED',
cell: address,
}),
]));
expect(result.operations).toEqual([]);
});
it('warns about holidays outside the visible timeline', () => {
const result = compileGanttStyles(workbookWithTimeline(), spec({
holidays: ['2026-08-05', '2026-08-20'],
}));
expect(result.violations).toEqual([]);
expect(result.warnings).toEqual([
expect.objectContaining({
code: 'GANTT_HOLIDAY_OUTSIDE_TIMELINE',
actual: '2026-08-20',
}),
]);
});
it('rejects a non-daily timeline, reversed task dates and invalid progress', () => {
const workbook = workbookWithTimeline();
const sheet = workbook.getWorksheet('工程表')!;
sheet.getCell('G4').value = new Date(Date.UTC(2026, 7, 5));
sheet.getCell('C5').value = new Date(Date.UTC(2026, 7, 9));
sheet.getCell('D5').value = new Date(Date.UTC(2026, 7, 3));
sheet.getCell('E5').value = 101;
const result = compileGanttStyles(workbook, spec());
expect(result.operations).toEqual([]);
expect(result.violations).toEqual(expect.arrayContaining([
expect.objectContaining({ code: 'INPUT_INVALID', cell: 'G4' }),
expect.objectContaining({ code: 'INPUT_INVALID', range: 'C5:D5' }),
expect.objectContaining({ code: 'INPUT_INVALID', cell: 'E5' }),
]));
});
it('rejects invalid ranges, missing sheets and malformed holiday dates', () => {
const workbook = workbookWithTimeline();
expect(compileGanttStyles(workbook, spec({ timelineHeader: 'F4:F5' })))
.toMatchObject({ violations: [expect.objectContaining({ code: 'INPUT_INVALID' })] });
expect(compileGanttStyles(workbook, spec({ sheet: 'Missing' })))
.toMatchObject({ violations: [expect.objectContaining({ code: 'INPUT_INVALID', sheet: 'Missing' })] });
expect(compileGanttStyles(workbook, spec({ holidays: ['2026-02-30'] })))
.toMatchObject({ violations: [expect.objectContaining({ code: 'INPUT_INVALID' })] });
});
});
+555
View File
@@ -0,0 +1,555 @@
import type ExcelJS from 'exceljs';
import { EXCEL_LIMITS, type ExcelViolation, type ExcelWarning } from './excel-types.js';
import {
type ExcelPatchOperation,
type ExcelStyleSpec,
} from './excel-patch.js';
import {
columnNameToNumber,
columnNumberToName,
parseCellAddress,
} from '../../office/excel-formula.js';
const MILLISECONDS_PER_DAY = 86_400_000;
const EXCEL_UNIX_EPOCH_SERIAL = 25_569;
const EXCEL_1904_UNIX_EPOCH_DAY = -24_107;
const MIN_EXCEL_EPOCH_DAY = Math.floor(Date.UTC(1900, 0, 1) / MILLISECONDS_PER_DAY);
const MAX_EXCEL_EPOCH_DAY = Math.floor(Date.UTC(9999, 11, 31) / MILLISECONDS_PER_DAY);
const REQUIRED_ROLES = ['weekend', 'holiday', 'planned', 'completed'] as const;
export interface ExcelGanttStyleRoles {
weekend: ExcelStyleSpec;
holiday: ExcelStyleSpec;
planned: ExcelStyleSpec;
completed: ExcelStyleSpec;
}
export interface ExcelGanttSpec {
sheet: string;
timelineHeader: string;
taskRows: string;
startColumn: string;
endColumn: string;
progressColumn: string;
progressScale: 'fraction' | 'percent';
holidays: string[];
roles: ExcelGanttStyleRoles;
}
export interface ExcelGanttCompilation {
operations: ExcelPatchOperation[];
violations: ExcelViolation[];
warnings: ExcelWarning[];
}
interface TimelineRange {
row: number;
startColumn: number;
endColumn: number;
}
interface TaskRowRange {
startRow: number;
endRow: number;
}
interface ValidatedTask {
row: number;
startDay: number;
endDay: number;
progress: number;
}
interface ReadonlyGanttCell {
address: string;
value: ExcelJS.CellValue;
hasFormula: boolean;
}
function readCell(sheet: ExcelJS.Worksheet, row: number, column: number): ReadonlyGanttCell {
const cell = sheet.findRow(row)?.findCell(column);
const value = cell?.value ?? null;
const hasSharedFormula = Boolean(
value
&& typeof value === 'object'
&& 'sharedFormula' in value
&& (value as { sharedFormula?: unknown }).sharedFormula,
);
return {
address: `${columnNumberToName(column)}${row}`,
value,
hasFormula: Boolean(cell?.formula) || hasSharedFormula,
};
}
function violation(
message: string,
location: Pick<ExcelViolation, 'sheet' | 'cell' | 'range' | 'actual'> = {},
code: ExcelViolation['code'] = 'INPUT_INVALID',
): ExcelViolation {
return { code, message, ...location };
}
function parseTimelineRange(value: string): TimelineRange | null {
const parts = value.split(':');
if (parts.length !== 2) return null;
const start = parseCellAddress(parts[0]!);
const end = parseCellAddress(parts[1]!);
if (!start || !end || start.row !== end.row || start.column > end.column) return null;
return { row: start.row, startColumn: start.column, endColumn: end.column };
}
function parseTaskRowRange(value: string): TaskRowRange | null {
const match = value.match(/^([1-9]\d*)(?::([1-9]\d*))?$/);
if (!match) return null;
const startRow = Number(match[1]);
const endRow = Number(match[2] ?? match[1]);
if (
!Number.isSafeInteger(startRow)
|| !Number.isSafeInteger(endRow)
|| startRow > endRow
|| endRow > 1_048_576
) return null;
return { startRow, endRow };
}
function parseColumn(value: string): number | null {
if (!/^[A-Za-z]{1,3}$/.test(value)) return null;
const column = columnNameToNumber(value);
return Number.isInteger(column) && column >= 1 && column <= 16_384 ? column : null;
}
function boundedExcelEpochDay(day: number): number | null {
return Number.isSafeInteger(day) && day >= MIN_EXCEL_EPOCH_DAY && day <= MAX_EXCEL_EPOCH_DAY
? day
: null;
}
function epochDayFromIso(value: string): number | null {
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) return null;
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const timestamp = Date.UTC(year, month - 1, day);
const date = new Date(timestamp);
if (
date.getUTCFullYear() !== year
|| date.getUTCMonth() !== month - 1
|| date.getUTCDate() !== day
) return null;
return boundedExcelEpochDay(Math.floor(timestamp / MILLISECONDS_PER_DAY));
}
function epochDayFromValue(value: ExcelJS.CellValue, date1904: boolean): number | null {
if (value instanceof Date && Number.isFinite(value.getTime())) {
return boundedExcelEpochDay(Math.floor(Date.UTC(
value.getUTCFullYear(),
value.getUTCMonth(),
value.getUTCDate(),
) / MILLISECONDS_PER_DAY));
}
if (typeof value === 'number' && Number.isFinite(value)) {
const serial = Math.floor(value);
if (serial < (date1904 ? 0 : 1)) return null;
if (date1904) return boundedExcelEpochDay(serial + EXCEL_1904_UNIX_EPOCH_DAY);
if (serial === 60) return null;
const day = serial < 60
? MIN_EXCEL_EPOCH_DAY + serial - 1
: serial - EXCEL_UNIX_EPOCH_SERIAL;
return boundedExcelEpochDay(day);
}
if (typeof value === 'string') return epochDayFromIso(value);
return null;
}
function normalizedProgress(
value: ExcelJS.CellValue,
scale: ExcelGanttSpec['progressScale'],
): number | null {
if (value === null) return 0;
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return null;
if (scale === 'fraction') return value <= 1 ? value : null;
if (scale === 'percent') return value <= 100 ? value / 100 : null;
return null;
}
function validRole(role: ExcelStyleSpec | undefined): role is ExcelStyleSpec {
return Boolean(role?.fill && /^[0-9A-F]{8}$/i.test(role.fill));
}
function styleOperation(
sheet: string,
startColumn: number,
startRow: number,
endColumn: number,
endRow: number,
style: ExcelStyleSpec,
): ExcelPatchOperation {
const start = `${columnNumberToName(startColumn)}${startRow}`;
const end = `${columnNumberToName(endColumn)}${endRow}`;
return {
op: 'style_range',
sheet,
range: start === end ? start : `${start}:${end}`,
style: { ...style },
};
}
function operationCellCount(operation: ExcelPatchOperation): number {
if (operation.op !== 'style_range') return 0;
const [startText, endText = startText] = operation.range.split(':');
const start = parseCellAddress(startText)!;
const end = parseCellAddress(endText)!;
return (end.row - start.row + 1) * (end.column - start.column + 1);
}
function isWeekend(day: number): boolean {
const weekday = new Date(day * MILLISECONDS_PER_DAY).getUTCDay();
return weekday === 0 || weekday === 6;
}
function isWorkingDay(day: number, holidays: ReadonlySet<number>): boolean {
return !isWeekend(day) && !holidays.has(day);
}
function workingDaysBetween(startDay: number, endDay: number, holidays: ReadonlySet<number>): number {
if (endDay < startDay) return 0;
const totalDays = endDay - startDay + 1;
const wholeWeeks = Math.floor(totalDays / 7);
let workingDays = wholeWeeks * 5;
for (let offset = wholeWeeks * 7; offset < totalDays; offset += 1) {
if (!isWeekend(startDay + offset)) workingDays += 1;
}
for (const holiday of holidays) {
if (holiday >= startDay && holiday <= endDay && !isWeekend(holiday)) workingDays -= 1;
}
return workingDays;
}
function completedThroughDay(task: ValidatedTask, holidays: ReadonlySet<number>): number {
const totalWorkingDays = workingDaysBetween(task.startDay, task.endDay, holidays);
const completedWorkingDays = Math.round(totalWorkingDays * task.progress);
if (completedWorkingDays === 0) return task.startDay - 1;
let low = task.startDay;
let high = task.endDay;
while (low < high) {
const middle = Math.floor((low + high) / 2);
if (workingDaysBetween(task.startDay, middle, holidays) >= completedWorkingDays) high = middle;
else low = middle + 1;
}
return low;
}
export function compileGanttStyles(
workbook: ExcelJS.Workbook,
spec: ExcelGanttSpec,
): ExcelGanttCompilation {
const violations: ExcelViolation[] = [];
const warnings: ExcelWarning[] = [];
const empty = (): ExcelGanttCompilation => ({ operations: [], violations, warnings });
for (const role of REQUIRED_ROLES) {
if (!validRole(spec.roles?.[role])) {
violations.push(violation(
`ガントのスタイル役割 ${role} には8桁ARGBのfillが必要です`,
{ sheet: spec.sheet },
'MISSING_STYLE_ROLE',
));
}
}
if (violations.length > 0) return empty();
const timeline = parseTimelineRange(spec.timelineHeader);
const taskRows = parseTaskRowRange(spec.taskRows);
const startColumn = parseColumn(spec.startColumn);
const endColumn = parseColumn(spec.endColumn);
const progressColumn = parseColumn(spec.progressColumn);
if (spec.progressScale !== 'fraction' && spec.progressScale !== 'percent') {
violations.push(violation('progressScale は fraction または percent で指定してください', {
sheet: spec.sheet,
}));
}
if (
startColumn
&& endColumn
&& progressColumn
&& new Set([startColumn, endColumn, progressColumn]).size !== 3
) {
violations.push(violation('開始日・終了日・進捗は異なる列に配置してください', {
sheet: spec.sheet,
range: `${spec.startColumn},${spec.endColumn},${spec.progressColumn}`,
}));
}
if (!timeline) violations.push(violation('timelineHeader は同じ行のセル範囲で指定してください', {
sheet: spec.sheet,
range: spec.timelineHeader,
}));
if (!taskRows) violations.push(violation('taskRows は有効な行範囲で指定してください', {
sheet: spec.sheet,
range: spec.taskRows,
}));
if (!startColumn || !endColumn || !progressColumn) {
violations.push(violation('startColumn/endColumn/progressColumn が不正です', { sheet: spec.sheet }));
}
if (!Array.isArray(spec.holidays) || spec.holidays.length > EXCEL_LIMITS.operations) {
violations.push(violation(`holidays は最大${EXCEL_LIMITS.operations}件です`, {
sheet: spec.sheet,
}));
}
if (timeline && taskRows && timeline.row >= taskRows.startRow) {
violations.push(violation('timelineHeader は最初のタスク行より上に配置してください', {
sheet: spec.sheet,
range: spec.timelineHeader,
}));
}
if (taskRows && taskRows.endRow - taskRows.startRow + 1 > EXCEL_LIMITS.operations) {
violations.push(violation('ガントのタスク行数が処理上限を超えています', {
sheet: spec.sheet,
range: spec.taskRows,
}));
}
if (
timeline
&& taskRows
&& taskRows.endRow - taskRows.startRow + 1 <= EXCEL_LIMITS.operations
) {
const timelineWidth = timeline.endColumn - timeline.startColumn + 1;
const taskRowCount = taskRows.endRow - taskRows.startRow + 1;
const worstCaseChangedCells = timelineWidth * (taskRowCount * 2 + 1);
if (
timelineWidth > EXCEL_LIMITS.operations
|| worstCaseChangedCells > EXCEL_LIMITS.changedCells
) {
violations.push(violation('ガントのタイムライン幅が処理上限を超えています', {
sheet: spec.sheet,
range: spec.timelineHeader,
actual: timelineWidth,
}));
}
}
if (timeline) {
const dataColumns = [
['startColumn', spec.startColumn, startColumn],
['endColumn', spec.endColumn, endColumn],
['progressColumn', spec.progressColumn, progressColumn],
] as const;
for (const [name, label, column] of dataColumns) {
if (column && column >= timeline.startColumn && column <= timeline.endColumn) {
violations.push(violation(`${name} はタイムライン範囲外の列にしてください`, {
sheet: spec.sheet,
cell: label.toUpperCase(),
}));
}
}
}
const sheet = workbook.worksheets.find(
(candidate) => candidate.name.toLocaleLowerCase('en-US') === spec.sheet.toLocaleLowerCase('en-US'),
);
if (!sheet) violations.push(violation(`シートが存在しません: ${spec.sheet}`, { sheet: spec.sheet }));
if (violations.length > 0 || !timeline || !taskRows || !startColumn || !endColumn || !progressColumn || !sheet) {
return empty();
}
const date1904 = workbook.properties.date1904 === true;
const timelineDays: number[] = [];
for (let column = timeline.startColumn; column <= timeline.endColumn; column += 1) {
const cell = readCell(sheet, timeline.row, column);
if (cell.hasFormula) {
violations.push(violation(
'ガントの日付・進捗入力に数式は使用できません',
{ sheet: sheet.name, cell: cell.address },
'GANTT_FORMULA_INPUT_UNSUPPORTED',
));
timelineDays.push(Number.NaN);
continue;
}
const day = epochDayFromValue(cell.value, date1904);
if (day === null) {
violations.push(violation('タイムライン見出しは日付で指定してください', {
sheet: sheet.name,
cell: cell.address,
}));
timelineDays.push(Number.NaN);
continue;
}
const previous = timelineDays.at(-1);
if (previous !== undefined && Number.isFinite(previous) && day !== previous + 1) {
violations.push(violation('タイムラインの日付は1日ずつ連続させてください', {
sheet: sheet.name,
cell: cell.address,
}));
}
timelineDays.push(day);
}
const holidayDays = new Set<number>();
for (const holiday of spec.holidays ?? []) {
const day = epochDayFromIso(holiday);
if (day === null) {
violations.push(violation(`祝日の日付が不正です: ${holiday}`, { sheet: sheet.name, actual: holiday }));
continue;
}
holidayDays.add(day);
}
const tasks: ValidatedTask[] = [];
for (let row = taskRows.startRow; row <= taskRows.endRow; row += 1) {
const startCell = readCell(sheet, row, startColumn);
const endCell = readCell(sheet, row, endColumn);
const progressCell = readCell(sheet, row, progressColumn);
const formulaCells = [startCell, endCell, progressCell].filter(cell => cell.hasFormula);
if (formulaCells.length > 0) {
for (const cell of formulaCells) {
violations.push(violation(
'ガントの日付・進捗入力に数式は使用できません',
{ sheet: sheet.name, cell: cell.address },
'GANTT_FORMULA_INPUT_UNSUPPORTED',
));
}
continue;
}
if (startCell.value === null && endCell.value === null && progressCell.value === null) continue;
const startDay = epochDayFromValue(startCell.value, date1904);
const endDay = epochDayFromValue(endCell.value, date1904);
const progress = normalizedProgress(progressCell.value, spec.progressScale);
if (startDay === null || endDay === null || endDay < startDay) {
violations.push(violation('開始日と終了日が不正です', {
sheet: sheet.name,
range: `${startCell.address}:${endCell.address}`,
}));
}
if (progress === null) {
violations.push(violation('進捗が progressScale の範囲外です', {
sheet: sheet.name,
cell: progressCell.address,
actual: progressCell.value,
}));
}
if (startDay !== null && endDay !== null && endDay >= startDay && progress !== null) {
tasks.push({ row, startDay, endDay, progress });
}
}
if (violations.length > 0) return empty();
const firstTimelineDay = timelineDays[0]!;
const lastTimelineDay = timelineDays.at(-1)!;
for (const day of holidayDays) {
if (day < firstTimelineDay || day > lastTimelineDay) {
warnings.push({
code: 'GANTT_HOLIDAY_OUTSIDE_TIMELINE',
message: '祝日が表示期間の外にあります',
sheet: sheet.name,
actual: new Date(day * MILLISECONDS_PER_DAY).toISOString().slice(0, 10),
});
}
}
const operations: ExcelPatchOperation[] = [];
let backgroundStart: number | null = null;
let backgroundRole: 'holiday' | 'weekend' | null = null;
const flushBackground = (endOffsetExclusive: number): void => {
if (backgroundStart === null || backgroundRole === null) return;
const startColumn = timeline.startColumn + backgroundStart;
const endColumn = timeline.startColumn + endOffsetExclusive - 1;
if (timeline.row + 1 === taskRows.startRow) {
operations.push(styleOperation(
sheet.name,
startColumn,
timeline.row,
endColumn,
taskRows.endRow,
spec.roles[backgroundRole],
));
} else {
operations.push(styleOperation(
sheet.name,
startColumn,
timeline.row,
endColumn,
timeline.row,
spec.roles[backgroundRole],
));
operations.push(styleOperation(
sheet.name,
startColumn,
taskRows.startRow,
endColumn,
taskRows.endRow,
spec.roles[backgroundRole],
));
}
backgroundStart = null;
backgroundRole = null;
};
for (const [offset, day] of timelineDays.entries()) {
const role = holidayDays.has(day) ? 'holiday' : (isWeekend(day) ? 'weekend' : null);
if (role === backgroundRole) continue;
flushBackground(offset);
if (role) {
backgroundStart = offset;
backgroundRole = role;
}
}
flushBackground(timelineDays.length);
for (const task of tasks) {
const startOffset = timelineDays.findIndex((day) => day >= task.startDay);
let endOffset = -1;
for (let offset = timelineDays.length - 1; offset >= 0; offset -= 1) {
if (timelineDays[offset]! <= task.endDay) {
endOffset = offset;
break;
}
}
if (startOffset < 0 || endOffset < startOffset) {
warnings.push({
code: 'GANTT_TASK_OUTSIDE_TIMELINE',
message: 'タスクが表示期間の外にあるためバーを描画できません',
sheet: sheet.name,
range: `${columnNumberToName(startColumn)}${task.row}:${columnNumberToName(endColumn)}${task.row}`,
});
continue;
}
const completedEnd = completedThroughDay(task, holidayDays);
let runStart: number | null = null;
let runRole: 'completed' | 'planned' | null = null;
const flushRun = (endOffsetExclusive: number): void => {
if (runStart === null || runRole === null) return;
operations.push(styleOperation(
sheet.name,
timeline.startColumn + runStart,
task.row,
timeline.startColumn + endOffsetExclusive - 1,
task.row,
spec.roles[runRole],
));
runStart = null;
runRole = null;
};
for (let offset = startOffset; offset <= endOffset; offset += 1) {
const day = timelineDays[offset]!;
const role = isWorkingDay(day, holidayDays)
? (day <= completedEnd ? 'completed' : 'planned')
: null;
if (role === runRole) continue;
flushRun(offset);
if (role) {
runStart = offset;
runRole = role;
}
}
flushRun(endOffset + 1);
}
const changedCells = operations.reduce((total, operation) => total + operationCellCount(operation), 0);
if (operations.length > EXCEL_LIMITS.operations || changedCells > EXCEL_LIMITS.changedCells) {
violations.push(violation('ガントの展開結果がExcel編集上限を超えています', {
sheet: sheet.name,
range: `${spec.timelineHeader},${spec.taskRows}`,
}));
return empty();
}
return { operations, violations, warnings };
}
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { normalizePatchOperations, parseExcelPatchInput } from './excel-patch.js';
describe('Excel patch edge cases', () => {
it('supports a valid sheet name containing an exclamation mark', () => {
const parsed = parseExcelPatchInput({
output_path: 'output/book.xlsx',
operations: [
{ op: 'add_sheet', name: 'Ops!' },
{ op: 'set_values', sheet: 'Ops!', range: 'A1', values: },
],
});
if (!parsed.ok) throw new Error(parsed.violations.map((item) => item.message).join('; '));
const normalized = normalizePatchOperations(parsed.value, { sheets: [], formulaCells: 0 });
expect(normalized).toMatchObject({ ok: true });
});
it.each([Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY])(
'rejects non-finite cell value %s',
(value) => {
expect(parseExcelPatchInput({
output_path: 'output/book.xlsx',
operations: [
{ op: 'set_values', sheet: 'Sheet1', range: 'A1', values: },
],
})).toMatchObject({ ok: false });
},
);
});
+146
View File
@@ -0,0 +1,146 @@
import ExcelJS from 'exceljs';
import { describe, expect, it } from 'vitest';
import { EXCEL_LIMITS } from './excel-types.js';
import { tokenizeFormula, translateFormulaA1 } from '../../office/excel-formula.js';
import {
applyExcelPlan,
normalizePatchOperations,
parseExcelPatchInput,
type WorkbookStructureSnapshot,
} from './excel-patch.js';
function parse(operations: unknown[]) {
const result = parseExcelPatchInput({ output_path: 'output/book.xlsx', operations });
if (!result.ok) throw new Error(result.violations.map((item) => item.message).join('; '));
return result.value;
}
function snapshot(options: {
merges?: string[];
occupiedCells?: string[];
formulaCells?: number;
formulaCellAddresses?: string[];
} = {}): WorkbookStructureSnapshot {
return {
sheets: [{
name: 'Sheet1',
merges: options.merges ?? [],
occupiedCells: options.occupiedCells ?? [],
formulaCells: options.formulaCellAddresses ?? [],
}],
formulaCells: options.formulaCells ?? 0,
};
}
describe('fable review regressions for Excel patch', () => {
it('rejects a merge over a pre-existing occupied non-anchor cell', () => {
const normalized = normalizePatchOperations(parse([
{ op: 'merge', sheet: 'Sheet1', range: 'A1:B1' },
]), snapshot({ occupiedCells: ['B1'] }));
expect(normalized).toMatchObject({
ok: false,
violations: [expect.objectContaining({ code: 'OPERATION_CONFLICT', cell: 'B1' })],
});
});
it('prevalidates stale workbook state before mutating anything', async () => {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Sheet1');
sheet.getCell('B1').value = 'existing';
const normalized = normalizePatchOperations(parse([
{ op: 'set_values', sheet: 'Sheet1', range: 'C1', values: [['new']] },
{ op: 'merge', sheet: 'Sheet1', range: 'A1:B1' },
]), snapshot());
if (!normalized.ok) throw new Error(normalized.violations.map((item) => item.message).join('; '));
await expect(applyExcelPlan(workbook, normalized.value)).rejects.toThrow(/B1/);
expect(sheet.getCell('C1').value).toBeNull();
expect(sheet.getCell('B1').value).toBe('existing');
});
it('does not double-count overwriting an existing formula at the limit', () => {
const normalized = normalizePatchOperations(parse([
{ op: 'set_formula', sheet: 'Sheet1', cell: 'A1', formula: '2+2' },
]), snapshot({
formulaCells: EXCEL_LIMITS.formulaCells,
formulaCellAddresses: ['A1'],
occupiedCells: ['A1'],
}));
expect(normalized).toMatchObject({
ok: true,
value: { formulaCells: EXCEL_LIMITS.formulaCells },
});
});
it('rejects whole-column or whole-row references for fill_formula', () => {
expect(parseExcelPatchInput({
output_path: 'output/book.xlsx',
operations: [
{ op: 'fill_formula', sheet: 'Sheet1', range: 'A1:B1', formula: 'SUM(A:A)' },
],
})).toMatchObject({ ok: false });
expect(parseExcelPatchInput({
output_path: 'output/book.xlsx',
operations: [
{ op: 'fill_formula', sheet: 'Sheet1', range: 'A1:A2', formula: 'SUM(1:1)' },
],
})).toMatchObject({ ok: false });
});
it('rejects degenerate freeze panes, reserved sheet names and malformed strings', () => {
expect(parseExcelPatchInput({
output_path: 'output/book.xlsx',
operations: [{ op: 'freeze_panes', sheet: 'Sheet1', rows: 0, columns: 0 }],
})).toMatchObject({ ok: false });
expect(parseExcelPatchInput({
output_path: 'output/book.xlsx',
operations: [{ op: 'add_sheet', name: 'History' }],
})).toMatchObject({ ok: false });
expect(parseExcelPatchInput({
output_path: 'output/book.xlsx',
operations: [{ op: 'add_sheet', name: '_MAESTRO_RECALC_user' }],
})).toMatchObject({ ok: false });
expect(parseExcelPatchInput({
output_path: 'output/book.xlsx',
operations: [{ op: 'add_sheet', name: ' ' }],
})).toMatchObject({ ok: false });
expect(parseExcelPatchInput({
output_path: 'output/book.xlsx',
operations: [{ op: 'set_formula', sheet: 'Sheet1', cell: 'A1', formula: '="unterminated' }],
})).toMatchObject({ ok: false });
});
it('deduplicates identical writes and keeps border sides independent', async () => {
const normalized = normalizePatchOperations(parse([
{ op: 'set_values', sheet: 'Sheet1', range: 'A1', values: },
{ op: 'set_values', sheet: 'Sheet1', range: 'A1', values: },
{ op: 'style_range', sheet: 'Sheet1', range: 'B1', style: { border_color: '#112233' } },
]), snapshot());
if (!normalized.ok) throw new Error(normalized.violations.map((item) => item.message).join('; '));
expect(normalized.value.operations.filter((item) => item.op === 'set_cell_value')).toHaveLength(1);
const workbook = new ExcelJS.Workbook();
workbook.addWorksheet('Sheet1');
await applyExcelPlan(workbook, normalized.value);
const border = workbook.getWorksheet('Sheet1')!.getCell('B1').border;
expect(border.top).not.toBe(border.bottom);
expect(border.left).not.toBe(border.right);
});
});
describe('fable review regressions for formula references', () => {
it('keeps out-of-grid A1-like names as identifiers', () => {
const tokens = tokenizeFormula('=XFE1+A1048577+A1');
expect(tokens.filter((token) => token.type === 'identifier').map((token) => token.text))
.toEqual(['XFE1', 'A1048577']);
expect(tokens.filter((token) => token.type === 'reference').map((token) => token.text))
.toEqual(['A1']);
expect(translateFormulaA1('=XFE1+A1', 1, 1)).toBe('=XFE1+B2');
});
it('translates an unquoted sheet-qualified reference', () => {
expect(translateFormulaA1('=Sheet2!A1', 1, 1)).toBe('=Sheet2!B2');
});
});
+377
View File
@@ -0,0 +1,377 @@
import { rmSync } from 'node:fs';
import { mkdtemp } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import ExcelJS from 'exceljs';
import { afterEach, describe, expect, it } from 'vitest';
import { EXCEL_LIMITS } from './excel-types.js';
import { MAX_ROW_HEIGHT } from '../../office/cell-edit-input.js';
import {
applyExcelPlan,
findSharedFormulaMasterWrites,
materializeSharedFormulaBlocks,
normalizePatchOperations,
parseExcelPatchInput,
type ExcelPatchRequest,
type NormalizedExcelPlan,
type WorkbookStructureSnapshot,
} from './excel-patch.js';
const tempDirs: string[] = [];
function rawRequest(operations: unknown[]): Record<string, unknown> {
return {
output_path: 'output/book.xlsx',
operations,
};
}
function parsedRequest(operations: unknown[]): ExcelPatchRequest {
const result = parseExcelPatchInput(rawRequest(operations));
if (!result.ok) throw new Error(result.violations.map((item) => item.message).join('; '));
return result.value;
}
function snapshot(
merges: string[] = [],
extraSheets: string[] = [],
): WorkbookStructureSnapshot {
return {
sheets: [
{ name: 'Sheet1', merges },
...extraSheets.map((name) => ({ name, merges: [] })),
],
formulaCells: 0,
};
}
afterEach(() => {
for (const path of tempDirs.splice(0)) rmSync(path, { recursive: true, force: true });
});
describe('parseExcelPatchInput limits and schema', () => {
it('accepts 500 operations and rejects 501', () => {
const operation = { op: 'freeze_panes', sheet: 'Sheet1', rows: 1, columns: 0 };
expect(parseExcelPatchInput(rawRequest(Array.from({ length: EXCEL_LIMITS.operations }, () => operation))).ok)
.toBe(true);
const rejected = parseExcelPatchInput(rawRequest(
Array.from({ length: EXCEL_LIMITS.operations + 1 }, () => operation),
));
expect(rejected).toMatchObject({ ok: false });
});
it('enforces changed-cell and formula-cell expansion limits', () => {
expect(parseExcelPatchInput(rawRequest([
{ op: 'style_range', sheet: 'Sheet1', range: 'A1:A100000', style: { fill: '#FF0000' } },
])).ok).toBe(true);
expect(parseExcelPatchInput(rawRequest([
{ op: 'style_range', sheet: 'Sheet1', range: 'A1:A100001', style: { fill: '#FF0000' } },
]))).toMatchObject({ ok: false });
expect(parseExcelPatchInput(rawRequest([
{ op: 'fill_formula', sheet: 'Sheet1', range: 'A1:A10000', formula: 'ROW()' },
])).ok).toBe(true);
expect(parseExcelPatchInput(rawRequest([
{ op: 'fill_formula', sheet: 'Sheet1', range: 'A1:A10001', formula: 'ROW()' },
]))).toMatchObject({ ok: false });
});
it('accepts XFD1048576 and rejects coordinates beyond it', () => {
expect(parseExcelPatchInput(rawRequest([
{ op: 'set_formula', sheet: 'Sheet1', cell: 'XFD1048576', formula: '1+1' },
])).ok).toBe(true);
expect(parseExcelPatchInput(rawRequest([
{ op: 'set_formula', sheet: 'Sheet1', cell: 'XFE1048576', formula: '1+1' },
]))).toMatchObject({ ok: false });
expect(parseExcelPatchInput(rawRequest([
{ op: 'set_formula', sheet: 'Sheet1', cell: 'XFD1048577', formula: '1+1' },
]))).toMatchObject({ ok: false });
});
it('rejects a values matrix that does not match its range', () => {
expect(parseExcelPatchInput(rawRequest([
{ op: 'set_values', sheet: 'Sheet1', range: 'A1:B2', values: [[1, 2]] },
]))).toMatchObject({ ok: false });
});
it('rejects forbidden and case-only duplicate sheet names', () => {
expect(parseExcelPatchInput(rawRequest([
{ op: 'add_sheet', name: 'Bad/Name' },
]))).toMatchObject({ ok: false });
expect(parseExcelPatchInput(rawRequest([
{ op: 'add_sheet', name: 'Plan' },
{ op: 'add_sheet', name: 'plan' },
]))).toMatchObject({ ok: false });
});
});
describe('normalizePatchOperations conflicts and phases', () => {
it('rejects contradictory writes to the same cell', () => {
const result = normalizePatchOperations(parsedRequest([
{ op: 'set_values', sheet: 'Sheet1', range: 'A1', values: },
{ op: 'set_values', sheet: 'Sheet1', range: 'A1', values: },
]), snapshot());
expect(result).toMatchObject({
ok: false,
violations: [expect.objectContaining({ code: 'OPERATION_CONFLICT', cell: 'A1' })],
});
});
it('rejects intersecting new and existing merges', () => {
expect(normalizePatchOperations(parsedRequest([
{ op: 'merge', sheet: 'Sheet1', range: 'A1:B2' },
{ op: 'merge', sheet: 'Sheet1', range: 'B2:C3' },
]), snapshot())).toMatchObject({ ok: false });
expect(normalizePatchOperations(parsedRequest([
{ op: 'merge', sheet: 'Sheet1', range: 'B2:C3' },
]), snapshot(['A1:B2']))).toMatchObject({ ok: false });
});
it('allows replacing an existing merge after an explicit unmerge', () => {
const result = normalizePatchOperations(parsedRequest([
{ op: 'merge', sheet: 'Sheet1', range: 'A1:C1' },
{ op: 'unmerge', sheet: 'Sheet1', range: 'A1:B1' },
]), snapshot(['A1:B1']));
expect(result.ok).toBe(true);
});
it('rejects writes to a merged non-anchor cell', () => {
expect(normalizePatchOperations(parsedRequest([
{ op: 'set_values', sheet: 'Sheet1', range: 'B1', values: [['lost']] },
{ op: 'merge', sheet: 'Sheet1', range: 'A1:B1' },
]), snapshot())).toMatchObject({ ok: false });
});
it('rejects operations for a missing sheet', () => {
expect(normalizePatchOperations(parsedRequest([
{ op: 'style_range', sheet: 'Missing', range: 'A1', style: { fill: '#FF0000' } },
]), snapshot())).toMatchObject({ ok: false });
});
it('normalizes phases independent of input order and expands fill_formula', () => {
const result = normalizePatchOperations(parsedRequest([
{ op: 'freeze_panes', sheet: 'New', rows: 1, columns: 1 },
{ op: 'style_range', sheet: 'New', range: 'A1:B2', style: { fill: '#112233' } },
{ op: 'merge', sheet: 'New', range: 'A3:B3' },
{ op: 'fill_formula', sheet: 'New', range: 'A1:B2', formula: 'A1+$C$1' },
{ op: 'unmerge', sheet: 'Sheet1', range: 'D1:E1' },
{ op: 'add_sheet', name: 'New' },
]), snapshot(['D1:E1']));
if (!result.ok) throw new Error(result.violations.map((item) => item.message).join('; '));
expect(result.value.operations.map((operation) => operation.phase)).toEqual([
'add_sheet',
'unmerge',
'values_formulas', 'values_formulas', 'values_formulas', 'values_formulas',
'merge',
'styles',
'layout',
]);
expect(result.value.operations.filter((operation) => operation.op === 'set_cell_formula'))
.toEqual(expect.arrayContaining([
expect.objectContaining({ cell: 'A1', formula: 'A1+$C$1' }),
expect.objectContaining({ cell: 'B2', formula: 'B2+$C$1' }),
]));
});
});
/**
* 行の高さの上限は、同じ道具の中で 2 通りあってはならない(#1322 PR レビュー)。
* 「セルだけを直す方法」が 409.5 を通すのに詳細編集が 409 で断ると、利用者には
* 「さっき通った高さが通らない」と見える。409.5 は Excel で作れる最大。
*/
describe('set_row_height の上限', () => {
const heightOf = (height: unknown) =>
parseExcelPatchInput(rawRequest([{ op: 'set_row_height', sheet: 'Sheet1', rows: '1:2', height }]));
it('セルだけを直す方法と同じ 409.5 まで通す', () => {
expect(MAX_ROW_HEIGHT).toBe(409.5);
expect(heightOf(409.5).ok).toBe(true);
expect(heightOf(409).ok).toBe(true);
});
it('409.5 を超えたら断る', () => {
expect(heightOf(409.6).ok).toBe(false);
expect(heightOf(0).ok).toBe(false);
});
});
/**
* コピーされた式のおおもとを見分ける判定(#1351 段 1)。
*
* 見ているのは 2 つ——`shareType` が `shared` であることと、範囲(`ref`)を
* 持つこと。2 つは重なって効いているので、片方を落としても素通りする素材しか
* 無いと、あとから「整理」した人が黙って穴を開けられる。**片方ずつが効く形**を
* 置いて釘付けする。
*/
describe('findSharedFormulaMasterWrites', () => {
function planFor(operations: unknown[]): NormalizedExcelPlan {
const normalized = normalizePatchOperations(parsedRequest(operations), snapshot());
if (!normalized.ok) throw new Error(normalized.violations.map((item) => item.message).join('; '));
return normalized.value;
}
it('範囲を持つおおもとに書く指示を拾う', () => {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Sheet1');
sheet.getCell('C2').value = { formula: 'A2*B2', shareType: 'shared', ref: 'C2:C4', result: 40 };
const found = findSharedFormulaMasterWrites(workbook, planFor([
{ op: 'set_values', sheet: 'Sheet1', range: 'C2', values: },
]));
expect(found).toEqual([{ sheet: 'Sheet1', cell: 'C2', range: 'C2:C4' }]);
});
/**
* 配列数式も範囲を持つ。範囲があるというだけで拾うと、かたまりとは別の
* しくみのこちらまで書けなくなる(本体が消える件はこの段では扱わない)。
*/
it('配列数式のおおもとは拾わない(範囲は持っている)', () => {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Sheet1');
sheet.getCell('D1').value = { formula: 'SUM(A1:A3)', shareType: 'array', ref: 'D1:D3', result: 6 };
const found = findSharedFormulaMasterWrites(workbook, planFor([
{ op: 'set_values', sheet: 'Sheet1', range: 'D1', values: },
]));
expect(found).toEqual([]);
});
/**
* 範囲を持たない側は追随側で、書き換えてもおおもとの定義は壊れない。拾うと
* 直しすぎになる。
*/
it('範囲を持たないセルは拾わない', () => {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Sheet1');
sheet.getCell('C3').value = { formula: 'A3*B3', shareType: 'shared', result: 90 };
const found = findSharedFormulaMasterWrites(workbook, planFor([
{ op: 'set_values', sheet: 'Sheet1', range: 'C3', values: },
]));
expect(found).toEqual([]);
});
});
/**
* おおもとに書く前の実体化(#1351 段 2)。`ExcelPatch` 側の経路。
*
* 画面の保存側は XML を直に書き換えるので別の検査がある(`xlsx-shared-formula.test.ts`)。
* こちらは exceljs の値の形で同じことをしている。**計算済みの値を持ち越すところ**は、
* まとまり全体を通す検査だと計算し直しの段で上書きされて見えなくなるので、ここで直に釘付けする。
*
* **持ち主が決まるかどうかの判定はここには無い**(#1378)。ファイルを開いた時点で
* 関所が全シートを見るようになったので、判定は `findSharedFormulaSheetDoubts` の側
* (`xlsx-shared-formula.test.ts`)に寄せた。ここに同じ検査を戻さないこと。
*/
describe('materializeSharedFormulaBlocks', () => {
function planFor(operations: unknown[]): NormalizedExcelPlan {
const normalized = normalizePatchOperations(parsedRequest(operations), snapshot());
if (!normalized.ok) throw new Error(normalized.violations.map((item) => item.message).join('; '));
return normalized.value;
}
function sharedBook(formula: string): ExcelJS.Workbook {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Sheet1');
sheet.getCell('C2').value = { formula, shareType: 'shared', ref: 'C2:C4', result: 40 };
sheet.getCell('C3').value = { sharedFormula: 'C2', result: 90 } as ExcelJS.CellValue;
sheet.getCell('C4').value = { sharedFormula: 'C2', result: 160 } as ExcelJS.CellValue;
return workbook;
}
it('追随側に訳した式が入り、計算済みの値はそのまま残る', () => {
const workbook = sharedBook('A2*B2');
const failures = materializeSharedFormulaBlocks(workbook, planFor([
{ op: 'set_values', sheet: 'Sheet1', range: 'C2', values: },
]));
expect(failures).toEqual([]);
const sheet = workbook.getWorksheet('Sheet1')!;
expect(sheet.getCell('C3').value).toEqual({ formula: 'A3*B3', result: 90 });
expect(sheet.getCell('C4').value).toEqual({ formula: 'A4*B4', result: 160 });
});
it('おおもとからは、かたまりの宣言(範囲と種別)が外れる', () => {
const workbook = sharedBook('A2*B2');
materializeSharedFormulaBlocks(workbook, planFor([
{ op: 'set_values', sheet: 'Sheet1', range: 'C2', values: },
]));
expect(workbook.getWorksheet('Sheet1')!.getCell('C2').value).toEqual({ formula: 'A2*B2', result: 40 });
});
/** 訳せないかたまりが 1 つでもあれば、そのブックには 1 文字も書かない。 */
it('訳せないかたまりは理由を返し、ブックには手を付けない', () => {
const workbook = sharedBook('SUM(A:A)');
const failures = materializeSharedFormulaBlocks(workbook, planFor([
{ op: 'set_values', sheet: 'Sheet1', range: 'C2', values: },
]));
expect(failures).toHaveLength(1);
expect(failures[0]!.sheet).toBe('Sheet1');
expect(failures[0]!.cell).toBe('C2');
expect(failures[0]!.range).toBe('C2:C4');
const sheet = workbook.getWorksheet('Sheet1')!;
expect(sheet.getCell('C3').value).toEqual({ sharedFormula: 'C2', result: 90 });
expect(sheet.getCell('C2').value).toEqual({ formula: 'SUM(A:A)', shareType: 'shared', ref: 'C2:C4', result: 40 });
});
});
describe('applyExcelPlan', () => {
it('applies all operation phases, styles empty cells and survives round-trip', async () => {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Sheet1');
sheet.mergeCells('F1:G1');
sheet.getCell('I1').fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF00FF00' } };
const request = parsedRequest([
{ op: 'set_values', sheet: 'Sheet1', range: 'A1:B2', values: [[1, 2], [3, 4]] },
{ op: 'set_formula', sheet: 'Sheet1', cell: 'C1', formula: '=SUM(A1:B1)' },
{ op: 'fill_formula', sheet: 'Sheet1', range: 'C2:C3', formula: 'A1+B1' },
{ op: 'unmerge', sheet: 'Sheet1', range: 'F1:G1' },
{ op: 'merge', sheet: 'Sheet1', range: 'D1:E1' },
{ op: 'style_range', sheet: 'Sheet1', range: 'H1:H2', style: { fill: '#112233', bold: true } },
{ op: 'set_column_width', sheet: 'Sheet1', columns: 'A:C', width: 14 },
{ op: 'set_row_height', sheet: 'Sheet1', rows: '1:2', height: 24 },
{ op: 'freeze_panes', sheet: 'Sheet1', rows: 1, columns: 1 },
{ op: 'add_sheet', name: 'Summary' },
]);
const normalized = normalizePatchOperations(request, snapshot(['F1:G1']));
if (!normalized.ok) throw new Error(normalized.violations.map((item) => item.message).join('; '));
const result = await applyExcelPlan(workbook, normalized.value);
expect(result.formulaCells).toBe(3);
expect(result.changedCells).toBeGreaterThan(0);
expect(sheet.getCell('H1').style).not.toBe(sheet.getCell('H2').style);
expect(sheet.getCell('H2').fill).toMatchObject({ fgColor: { argb: 'FF112233' } });
expect(sheet.getCell('I1').fill).toMatchObject({ fgColor: { argb: 'FF00FF00' } });
expect(workbook.calcProperties.fullCalcOnLoad).toBe(true);
const dir = await mkdtemp(join(tmpdir(), 'excel-patch-'));
tempDirs.push(dir);
const path = join(dir, 'book.xlsx');
await workbook.xlsx.writeFile(path);
const reopened = new ExcelJS.Workbook();
await reopened.xlsx.readFile(path);
const reopenedSheet = reopened.getWorksheet('Sheet1')!;
expect(reopened.worksheets.map((item) => item.name)).toContain('Summary');
expect(reopenedSheet.getCell('C1').formula).toBe('SUM(A1:B1)');
expect(reopenedSheet.getCell('C3').formula).toBe('A2+B2');
expect(reopenedSheet.getCell('H2').value).toBeNull();
expect(reopenedSheet.getCell('H2').fill).toMatchObject({ fgColor: { argb: 'FF112233' } });
expect(reopenedSheet.getColumn(1).width).toBe(14);
expect(reopenedSheet.getRow(1).height).toBe(24);
expect(reopenedSheet.views[0]).toMatchObject({ state: 'frozen', xSplit: 1, ySplit: 1 });
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,161 @@
import { rmSync, writeFileSync } from 'node:fs';
import { mkdtemp } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import AdmZip from 'adm-zip';
import ExcelJS from 'exceljs';
import { afterEach, describe, expect, it } from 'vitest';
import { preflightExcel } from './excel-preflight.js';
const tempDirs: string[] = [];
const limits = {
maxCompressedBytes: 20 * 1024 * 1024,
maxUncompressedBytes: 200 * 1024 * 1024,
maxParts: 10_000,
};
async function makeWorkbook(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'excel-preflight-adversarial-'));
tempDirs.push(dir);
const path = join(dir, 'book.xlsx');
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Sheet1');
sheet.getCell('A1').value = 1;
await workbook.xlsx.writeFile(path);
return path;
}
function findEntryHeaders(buffer: Buffer, targetName: string): { central: number; local: number } {
const eocdOffset = buffer.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]));
if (eocdOffset < 0) throw new Error('EOCD not found');
const centralSize = buffer.readUInt32LE(eocdOffset + 12);
let central = buffer.readUInt32LE(eocdOffset + 16);
const centralEnd = central + centralSize;
while (central < centralEnd) {
if (buffer.readUInt32LE(central) !== 0x02014b50) throw new Error('central header not found');
const nameLength = buffer.readUInt16LE(central + 28);
const extraLength = buffer.readUInt16LE(central + 30);
const commentLength = buffer.readUInt16LE(central + 32);
const name = buffer.subarray(central + 46, central + 46 + nameLength).toString('utf8');
if (name === targetName) return { central, local: buffer.readUInt32LE(central + 42) };
central += 46 + nameLength + extraLength + commentLength;
}
throw new Error(`entry not found: ${targetName}`);
}
afterEach(() => {
for (const path of tempDirs.splice(0)) rmSync(path, { recursive: true, force: true });
});
describe('preflightExcel adversarial OOXML cases', () => {
it('matches unsupported part names case-insensitively', async () => {
const path = await makeWorkbook();
const zip = new AdmZip(path);
zip.addFile('XL/VBAPROJECT.BIN', Buffer.from('macro'));
zip.writeZip(path);
expect(await preflightExcel(path, limits)).toContainEqual(expect.objectContaining({
code: 'UNSUPPORTED_WORKBOOK_FEATURE',
message: expect.stringContaining('VBA macro'),
}));
});
it('finds an array-formula worksheet relocated through Content Types', async () => {
const path = await makeWorkbook();
const zip = new AdmZip(path);
const contentTypesPart = '[Content_Types].xml';
const contentTypes = zip.readAsText(contentTypesPart).replace(
'</Types>',
'<Override PartName="/custom/array-sheet.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/></Types>',
);
zip.updateFile(contentTypesPart, Buffer.from(contentTypes));
zip.addFile(
'custom/array-sheet.xml',
Buffer.from('<worksheet><sheetData><row><c r="A1"><f t="array">1+1</f></c></row></sheetData></worksheet>'),
);
zip.writeZip(path);
expect(await preflightExcel(path, limits)).toContainEqual(expect.objectContaining({
code: 'UNSUPPORTED_WORKBOOK_FEATURE',
message: expect.stringContaining('array formula'),
actual: 'custom/array-sheet.xml',
}));
});
it('fails closed for UTF-16 OOXML instead of silently skipping checks', async () => {
const path = await makeWorkbook();
const zip = new AdmZip(path);
const workbookXml = zip.readAsText('xl/workbook.xml')
.replace('date1904="0"', 'date1904="1"');
zip.updateFile(
'xl/workbook.xml',
Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(workbookXml, 'utf16le')]),
);
zip.writeZip(path);
expect(await preflightExcel(path, limits)).toContainEqual(expect.objectContaining({
code: 'PACKAGE_INVALID',
message: expect.stringContaining('XML'),
}));
});
it('fails safely when a deflated entry forges a smaller uncompressed size', async () => {
const path = await makeWorkbook();
const bytes = Buffer.from(new AdmZip(path).toBuffer());
const { central, local } = findEntryHeaders(bytes, 'xl/workbook.xml');
bytes.writeUInt32LE(1, central + 24);
bytes.writeUInt32LE(1, local + 22);
writeFileSync(path, bytes);
expect(await preflightExcel(path, limits)).toContainEqual(expect.objectContaining({
code: 'PACKAGE_INVALID',
}));
});
it('bounds actual inflation for non-control parts even when ZIP sizes are forged', async () => {
const path = await makeWorkbook();
const zip = new AdmZip(path);
zip.addFile('xl/sharedStrings.xml', Buffer.alloc(1024 * 1024, 0x41));
zip.writeZip(path);
const bytes = Buffer.from(new AdmZip(path).toBuffer());
const { central, local } = findEntryHeaders(bytes, 'xl/sharedStrings.xml');
bytes.writeUInt32LE(1, central + 24);
bytes.writeUInt32LE(1, local + 22);
writeFileSync(path, bytes);
expect(await preflightExcel(path, {
...limits,
maxUncompressedBytes: 128 * 1024,
})).toContainEqual(expect.objectContaining({
code: 'PACKAGE_INVALID',
message: expect.stringMatching(/展開|サイズ/),
}));
});
it('rejects an entry with the ZIP encryption flag', async () => {
const path = await makeWorkbook();
const bytes = Buffer.from(new AdmZip(path).toBuffer());
const { central, local } = findEntryHeaders(bytes, 'xl/workbook.xml');
bytes.writeUInt16LE(bytes.readUInt16LE(central + 8) | 1, central + 8);
bytes.writeUInt16LE(bytes.readUInt16LE(local + 6) | 1, local + 6);
writeFileSync(path, bytes);
expect(await preflightExcel(path, limits)).toContainEqual(expect.objectContaining({
code: 'PACKAGE_INVALID',
message: expect.stringContaining('暗号化'),
}));
});
it('bounds attacker-controlled part names in diagnostics', async () => {
const path = await makeWorkbook();
const zip = new AdmZip(path);
zip.addFile(`xl/charts/${'a'.repeat(300)}.xml`, Buffer.from('<chart/>'));
zip.writeZip(path);
const [chartViolation] = (await preflightExcel(path, limits))
.filter((item) => item.message.includes('chart'));
expect(chartViolation).toBeDefined();
expect(String(chartViolation?.actual).length).toBeLessThanOrEqual(128);
expect(chartViolation?.message.length).toBeLessThan(400);
});
});
@@ -0,0 +1,110 @@
import { rmSync } from 'node:fs';
import { mkdtemp } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import AdmZip from 'adm-zip';
import ExcelJS from 'exceljs';
import { afterEach, describe, expect, it } from 'vitest';
import { preflightExcel } from './excel-preflight.js';
const tempDirs: string[] = [];
const limits = {
maxCompressedBytes: 20 * 1024 * 1024,
maxUncompressedBytes: 200 * 1024 * 1024,
maxParts: 10_000,
};
async function workbookPath(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'excel-preflight-opc-'));
tempDirs.push(dir);
const path = join(dir, 'book.xlsx');
const workbook = new ExcelJS.Workbook();
workbook.addWorksheet('Sheet1').getCell('A1').value = 1;
await workbook.xlsx.writeFile(path);
return path;
}
function appendContentType(zip: AdmZip, declaration: string): void {
const part = '[Content_Types].xml';
zip.updateFile(part, Buffer.from(zip.readAsText(part).replace('</Types>', `${declaration}</Types>`)));
}
afterEach(() => {
for (const path of tempDirs.splice(0)) rmSync(path, { recursive: true, force: true });
});
describe('preflightExcel OPC discovery', () => {
it('resolves worksheet types declared through Default extension mappings', async () => {
const path = await workbookPath();
const zip = new AdmZip(path);
appendContentType(
zip,
'<Default Extension="abc" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>',
);
zip.addFile(
'custom/array-sheet.abc',
Buffer.from('<worksheet><sheetData><row><c r="A1"><f t="array">1+1</f></c></row></sheetData></worksheet>'),
);
zip.writeZip(path);
expect(await preflightExcel(path, limits)).toContainEqual(expect.objectContaining({
code: 'UNSUPPORTED_WORKBOOK_FEATURE',
actual: 'custom/array-sheet.abc',
message: expect.stringContaining('array formula'),
}));
});
it('inspects a relocated workbook main part instead of a clean fixed-name decoy', async () => {
const path = await workbookPath();
const zip = new AdmZip(path);
appendContentType(
zip,
'<Override PartName="/custom/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>',
);
zip.addFile('custom/workbook.xml', Buffer.from('<workbook><workbookPr date1904="1"/></workbook>'));
const rootRelsPart = '_rels/.rels';
zip.updateFile(
rootRelsPart,
Buffer.from(zip.readAsText(rootRelsPart).replace('Target="xl/workbook.xml"', 'Target="custom/workbook.xml"')),
);
zip.writeZip(path);
expect(await preflightExcel(path, limits)).toContainEqual(expect.objectContaining({
code: 'UNSUPPORTED_WORKBOOK_FEATURE',
message: expect.stringContaining('date1904'),
}));
});
it('rejects relocated sheet metadata by content type', async () => {
const path = await workbookPath();
const zip = new AdmZip(path);
appendContentType(
zip,
'<Override PartName="/custom/meta.bin" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheetMetadata+xml"/>',
);
zip.addFile('custom/meta.bin', Buffer.from('metadata'));
zip.writeZip(path);
expect(await preflightExcel(path, limits)).toContainEqual(expect.objectContaining({
code: 'UNSUPPORTED_WORKBOOK_FEATURE',
message: expect.stringContaining('dynamic array'),
}));
});
it('rejects DTD declarations before XML parsing', async () => {
const path = await workbookPath();
const zip = new AdmZip(path);
const part = 'xl/workbook.xml';
const xml = zip.readAsText(part).replace(
/(<\?xml[^?]*\?>)/,
'$1<!DOCTYPE workbook [<!ENTITY x "expanded">]>',
);
zip.updateFile(part, Buffer.from(xml));
zip.writeZip(path);
expect(await preflightExcel(path, limits)).toContainEqual(expect.objectContaining({
code: 'PACKAGE_INVALID',
message: expect.stringContaining('DTD'),
}));
});
});
+235
View File
@@ -0,0 +1,235 @@
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { mkdtemp } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import AdmZip from 'adm-zip';
import ExcelJS from 'exceljs';
import { afterEach, describe, expect, it } from 'vitest';
import { preflightExcel } from './excel-preflight.js';
const tempDirs: string[] = [];
const defaultLimits = {
maxCompressedBytes: 20 * 1024 * 1024,
maxUncompressedBytes: 200 * 1024 * 1024,
maxParts: 10_000,
};
async function tempDir(): Promise<string> {
const path = await mkdtemp(join(tmpdir(), 'excel-preflight-'));
tempDirs.push(path);
return path;
}
async function simpleWorkbookBuffer(): Promise<Buffer> {
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet('Sheet1');
worksheet.getCell('A1').value = 1;
worksheet.getCell('B1').value = { formula: 'A1+1', result: 2 };
return Buffer.from(await workbook.xlsx.writeBuffer());
}
async function workbookPath(extension = '.xlsx'): Promise<string> {
const dir = await tempDir();
const path = join(dir, `book${extension}`);
writeFileSync(path, await simpleWorkbookBuffer());
return path;
}
async function makeOoxmlWithPart(part: string, content = '<root/>'): Promise<string> {
const path = await workbookPath();
const zip = new AdmZip(path);
zip.addFile(part, Buffer.from(content));
zip.writeZip(path);
return path;
}
function replaceEntryName(buffer: Buffer, before: string, after: string): Buffer {
const from = Buffer.from(before);
const to = Buffer.from(after);
if (from.length !== to.length) throw new Error('entry names must have equal byte length');
const output = Buffer.from(buffer);
let offset = 0;
let replacements = 0;
while ((offset = output.indexOf(from, offset)) >= 0) {
to.copy(output, offset);
offset += to.length;
replacements += 1;
}
if (replacements !== 2) throw new Error(`expected local + central names, got ${replacements}`);
return output;
}
function duplicateFirstCentralEntry(buffer: Buffer): Buffer {
const eocdSignature = Buffer.from([0x50, 0x4b, 0x05, 0x06]);
const eocdOffset = buffer.lastIndexOf(eocdSignature);
if (eocdOffset < 0) throw new Error('EOCD not found');
const centralOffset = buffer.readUInt32LE(eocdOffset + 16);
const nameLength = buffer.readUInt16LE(centralOffset + 28);
const extraLength = buffer.readUInt16LE(centralOffset + 30);
const commentLength = buffer.readUInt16LE(centralOffset + 32);
const entryLength = 46 + nameLength + extraLength + commentLength;
const duplicate = buffer.subarray(centralOffset, centralOffset + entryLength);
const eocd = Buffer.from(buffer.subarray(eocdOffset));
eocd.writeUInt16LE(eocd.readUInt16LE(8) + 1, 8);
eocd.writeUInt16LE(eocd.readUInt16LE(10) + 1, 10);
eocd.writeUInt32LE(eocd.readUInt32LE(12) + entryLength, 12);
return Buffer.concat([buffer.subarray(0, eocdOffset), duplicate, eocd]);
}
afterEach(() => {
for (const path of tempDirs.splice(0)) rmSync(path, { recursive: true, force: true });
});
describe('preflightExcel unsupported workbook features', () => {
it.each(['.xls', '.xlsm', '.xlsb'])('rejects the %s format before loading OOXML', async (extension) => {
const path = await workbookPath(extension);
const violations = await preflightExcel(path, defaultLimits);
expect(violations).toContainEqual(expect.objectContaining({
code: 'UNSUPPORTED_WORKBOOK_FEATURE',
}));
});
it('rejects a CFB/encrypted Office container', async () => {
const dir = await tempDir();
const path = join(dir, 'encrypted.xlsx');
writeFileSync(path, Buffer.from('d0cf11e0a1b11ae1', 'hex'));
expect(await preflightExcel(path, defaultLimits)).toContainEqual(expect.objectContaining({
code: 'PACKAGE_INVALID',
message: expect.stringContaining('暗号化'),
}));
});
it.each([
['xl/vbaProject.bin', 'VBA macro'],
['xl/externalLinks/externalLink1.xml', 'external link'],
['xl/connections.xml', 'connection'],
['xl/pivotTables/pivotTable1.xml', 'pivot'],
['xl/slicers/slicer1.xml', 'slicer'],
['xl/charts/chart1.xml', 'chart'],
['xl/embeddings/oleObject1.bin', 'OLE object'],
['xl/tables/table1.xml', 'Excel table'],
['xl/activeX/activeX1.bin', 'ActiveX'],
['_xmlsignatures/sig1.xml', 'digital signature'],
])('rejects unsupported OOXML part %s', async (part, label) => {
const path = await makeOoxmlWithPart(part);
expect(await preflightExcel(path, defaultLimits)).toContainEqual(expect.objectContaining({
code: 'UNSUPPORTED_WORKBOOK_FEATURE',
message: expect.stringContaining(label),
actual: part,
}));
});
it('rejects array and dynamic-array metadata', async () => {
const arrayPath = await workbookPath();
const arrayZip = new AdmZip(arrayPath);
const sheetPart = 'xl/worksheets/sheet1.xml';
const sheetXml = arrayZip.readAsText(sheetPart).replace('<f>A1+1</f>', '<f t="array" ref="B1:B1">A1+1</f>');
arrayZip.updateFile(sheetPart, Buffer.from(sheetXml));
arrayZip.writeZip(arrayPath);
expect(await preflightExcel(arrayPath, defaultLimits)).toContainEqual(expect.objectContaining({
code: 'UNSUPPORTED_WORKBOOK_FEATURE',
message: expect.stringContaining('array formula'),
}));
const dynamicPath = await makeOoxmlWithPart('xl/metadata.xml', '<metadata/>');
expect(await preflightExcel(dynamicPath, defaultLimits)).toContainEqual(expect.objectContaining({
code: 'UNSUPPORTED_WORKBOOK_FEATURE',
message: expect.stringContaining('dynamic array'),
}));
});
it('rejects custom defined names but permits print built-ins', async () => {
const addNames = async (names: string[]): Promise<string> => {
const path = await workbookPath();
const zip = new AdmZip(path);
const workbookPart = 'xl/workbook.xml';
const definedNames = names.map((name) => `<definedName name="${name}">Sheet1!$A$1</definedName>`).join('');
const xml = zip.readAsText(workbookPart).replace('</workbook>', `<definedNames>${definedNames}</definedNames></workbook>`);
zip.updateFile(workbookPart, Buffer.from(xml));
zip.writeZip(path);
return path;
};
const custom = await addNames(['My_Custom_Name']);
expect(await preflightExcel(custom, defaultLimits)).toContainEqual(expect.objectContaining({
code: 'UNSUPPORTED_WORKBOOK_FEATURE',
message: expect.stringContaining('defined name'),
}));
const printNames = await addNames([
'_xlnm.Print_Area',
'_xlnm.Print_Titles',
'_xlnm._FilterDatabase',
]);
expect(await preflightExcel(printNames, defaultLimits)).toEqual([]);
});
it('rejects date1904 workbooks and accepts a simple workbook', async () => {
const datePath = await workbookPath();
const zip = new AdmZip(datePath);
const workbookPart = 'xl/workbook.xml';
const xml = zip.readAsText(workbookPart).replace('<workbookPr', '<workbookPr date1904="1"');
zip.updateFile(workbookPart, Buffer.from(xml));
zip.writeZip(datePath);
expect(await preflightExcel(datePath, defaultLimits)).toContainEqual(expect.objectContaining({
code: 'UNSUPPORTED_WORKBOOK_FEATURE',
message: expect.stringContaining('date1904'),
}));
expect(await preflightExcel(await workbookPath(), defaultLimits)).toEqual([]);
});
});
describe('preflightExcel ZIP defenses', () => {
it('enforces compressed, uncompressed and part-count limits', async () => {
const path = await workbookPath();
const fileBytes = readFileSync(path).byteLength;
expect(await preflightExcel(path, { ...defaultLimits, maxCompressedBytes: fileBytes - 1 }))
.toContainEqual(expect.objectContaining({ code: 'PACKAGE_INVALID' }));
expect(await preflightExcel(path, { ...defaultLimits, maxUncompressedBytes: 1 }))
.toContainEqual(expect.objectContaining({ code: 'PACKAGE_INVALID' }));
expect(await preflightExcel(path, { ...defaultLimits, maxParts: 1 }))
.toContainEqual(expect.objectContaining({ code: 'PACKAGE_INVALID' }));
});
it('rejects duplicate, absolute and traversal entry names', async () => {
const valid = await simpleWorkbookBuffer();
const dir = await tempDir();
const duplicatePath = join(dir, 'duplicate.xlsx');
writeFileSync(duplicatePath, duplicateFirstCentralEntry(valid));
expect(await preflightExcel(duplicatePath, defaultLimits)).toContainEqual(expect.objectContaining({
code: 'PACKAGE_INVALID',
message: expect.stringContaining('重複'),
}));
const traversalZip = new AdmZip(valid);
traversalZip.addFile('xx/evil.xml', Buffer.from('x'));
const traversalPath = join(dir, 'traversal.xlsx');
writeFileSync(traversalPath, replaceEntryName(traversalZip.toBuffer(), 'xx/evil.xml', '../evil.xml'));
expect(await preflightExcel(traversalPath, defaultLimits)).toContainEqual(expect.objectContaining({
code: 'PACKAGE_INVALID',
message: expect.stringContaining('危険'),
}));
const absoluteZip = new AdmZip(valid);
absoluteZip.addFile('safe.xml', Buffer.from('x'));
const absolutePath = join(dir, 'absolute.xlsx');
writeFileSync(absolutePath, replaceEntryName(absoluteZip.toBuffer(), 'safe.xml', '/abs.xml'));
expect(await preflightExcel(absolutePath, defaultLimits)).toContainEqual(expect.objectContaining({
code: 'PACKAGE_INVALID',
message: expect.stringContaining('危険'),
}));
});
it('rejects a corrupt ZIP package', async () => {
const dir = await tempDir();
const path = join(dir, 'corrupt.xlsx');
mkdirSync(dir, { recursive: true });
writeFileSync(path, 'not a zip');
expect(await preflightExcel(path, defaultLimits)).toContainEqual(expect.objectContaining({
code: 'PACKAGE_INVALID',
}));
});
});
+469
View File
@@ -0,0 +1,469 @@
import { open, readFile, stat } from 'node:fs/promises';
import { extname } from 'node:path';
import AdmZip from 'adm-zip';
import { XMLParser, XMLValidator } from 'fast-xml-parser';
import type { ExcelViolation } from './excel-types.js';
import { DEFAULT_MAX_ENTRIES, DEFAULT_MAX_UNCOMPRESSED_MB } from '../../office/ooxml-package.js';
import { walkCentralDirectory } from '../../office/zip-central-directory.js';
import { measureUncompressedBytes } from '../../office/zip-inflate.js';
const CFB_MAGIC = Buffer.from('d0cf11e0a1b11ae1', 'hex');
const DEFAULT_MAX_UNCOMPRESSED_BYTES = DEFAULT_MAX_UNCOMPRESSED_MB * 1024 * 1024;
/** 項目数の上限は、包みの層の共通の関所と同じ値を使う(片方だけ動かさない)。 */
const DEFAULT_MAX_PARTS = DEFAULT_MAX_ENTRIES;
const MAX_DIAGNOSTIC_TEXT = 128;
const ALLOWED_DEFINED_NAMES = new Set([
'_xlnm.Print_Area',
'_xlnm.Print_Titles',
'_xlnm._FilterDatabase',
]);
interface PreflightOptions {
maxCompressedBytes: number;
maxUncompressedBytes?: number;
maxParts?: number;
}
interface UnsupportedRule {
matches: (value: string) => boolean;
label: string;
}
const UNSUPPORTED_PARTS: UnsupportedRule[] = [
{ matches: (name) => name === 'xl/vbaproject.bin', label: 'VBA macro' },
{ matches: (name) => name.startsWith('xl/externallinks/'), label: 'external link' },
{ matches: (name) => name === 'xl/connections.xml', label: 'connection' },
{
matches: (name) => name.startsWith('xl/pivottables/') || name.startsWith('xl/pivotcache/'),
label: 'pivot',
},
{
matches: (name) => name.startsWith('xl/slicers/') || name.startsWith('xl/slicercaches/'),
label: 'slicer',
},
{ matches: (name) => name.startsWith('xl/charts/'), label: 'chart' },
{
matches: (name) => name.startsWith('xl/embeddings/') || name.startsWith('xl/oleobjects/'),
label: 'OLE object',
},
{ matches: (name) => name.startsWith('xl/tables/'), label: 'Excel table' },
{ matches: (name) => name.startsWith('xl/activex/'), label: 'ActiveX' },
{ matches: (name) => name.startsWith('_xmlsignatures/'), label: 'digital signature' },
{ matches: (name) => name.startsWith('xl/macrosheets/'), label: 'XLM macro sheet' },
{ matches: (name) => name.startsWith('xl/querytables/'), label: 'query table' },
{ matches: (name) => name.startsWith('xl/webextensions/'), label: 'web extension' },
{ matches: (name) => name.startsWith('customxml/'), label: 'custom XML' },
{ matches: (name) => name.startsWith('xl/richdata'), label: 'rich data' },
{ matches: (name) => name.startsWith('xl/threadedcomments/'), label: 'threaded comment' },
{ matches: (name) => name.startsWith('xl/persons/'), label: 'comment person metadata' },
{ matches: (name) => name.startsWith('xl/ctrlprops/'), label: 'control properties' },
{ matches: (name) => name.startsWith('xl/timelines/'), label: 'timeline' },
{ matches: (name) => name === 'xl/xmlmaps.xml', label: 'XML map' },
{ matches: (name) => name.startsWith('xl/model/'), label: 'data model' },
];
const UNSUPPORTED_CONTENT_TYPES: UnsupportedRule[] = [
{ matches: (type) => type.includes('vbaproject'), label: 'VBA macro' },
{ matches: (type) => type.includes('macroenabled'), label: 'macro-enabled workbook' },
{ matches: (type) => type.includes('externallink'), label: 'external link' },
{ matches: (type) => type.includes('connections'), label: 'connection' },
{ matches: (type) => type.includes('pivot'), label: 'pivot' },
{ matches: (type) => type.includes('slicer'), label: 'slicer' },
{ matches: (type) => type.includes('drawingml.chart'), label: 'chart' },
{ matches: (type) => type.includes('oleobject'), label: 'OLE object' },
{ matches: (type) => type.includes('spreadsheetml.table'), label: 'Excel table' },
{ matches: (type) => type.includes('activex'), label: 'ActiveX' },
{ matches: (type) => type.includes('digital-signature'), label: 'digital signature' },
{ matches: (type) => type.includes('macrosheet'), label: 'XLM macro sheet' },
{ matches: (type) => type.includes('querytable'), label: 'query table' },
{ matches: (type) => type.includes('webextension'), label: 'web extension' },
{ matches: (type) => type.includes('customxml'), label: 'custom XML' },
{ matches: (type) => type.includes('richdata'), label: 'rich data' },
{ matches: (type) => type.includes('threadedcomments'), label: 'threaded comment' },
{ matches: (type) => type.includes('sheetmetadata'), label: 'dynamic array metadata' },
{ matches: (type) => type.endsWith('.person+xml'), label: 'comment person metadata' },
{ matches: (type) => type.includes('controlproperties'), label: 'control properties' },
{ matches: (type) => type.includes('timeline'), label: 'timeline' },
{ matches: (type) => type.includes('xmlmaps'), label: 'XML map' },
];
const xmlParser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
removeNSPrefix: true,
parseAttributeValue: false,
trimValues: false,
processEntities: false,
});
class OoxmlXmlError extends Error {}
function safeText(value: string): string {
const cleaned = value.replace(/[\u0000-\u001f\u007f]/g, '�');
return cleaned.length <= MAX_DIAGNOSTIC_TEXT
? cleaned
: `${cleaned.slice(0, MAX_DIAGNOSTIC_TEXT - 1)}…`;
}
function safeActual(actual: unknown): unknown {
return typeof actual === 'string' ? safeText(actual) : actual;
}
function violation(
code: ExcelViolation['code'],
message: string,
actual?: unknown,
): ExcelViolation {
return actual === undefined
? { code, message }
: { code, message, actual: safeActual(actual) };
}
function packageViolation(message: string, actual?: unknown): ExcelViolation[] {
return [violation('PACKAGE_INVALID', message, actual)];
}
function rawEntryName(entry: AdmZip.IZipEntry): string {
return entry.rawEntryName.toString('utf8');
}
function normalizedPartName(name: string): string {
return name.replace(/^\//, '').toLowerCase();
}
function isDangerousEntryName(name: string, isDirectory: boolean): boolean {
if (
name.startsWith('/')
|| name.startsWith('\\')
|| /^[A-Za-z]:[\\/]/.test(name)
|| name.includes('\\')
|| /[\u0000-\u001f\u007f]/.test(name)
) return true;
const segments = name.split('/');
if (isDirectory && segments.at(-1) === '') segments.pop();
return segments.some((segment) => segment === '' || segment === '.' || segment === '..');
}
function nodesNamed(value: unknown, targetName: string, output: unknown[] = []): unknown[] {
if (Array.isArray(value)) {
for (const item of value) nodesNamed(item, targetName, output);
return output;
}
if (!value || typeof value !== 'object') return output;
for (const [name, child] of Object.entries(value as Record<string, unknown>)) {
if (name === targetName) {
if (Array.isArray(child)) output.push(...child);
else output.push(child);
}
nodesNamed(child, targetName, output);
}
return output;
}
function xmlObject(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function readXml(entry: AdmZip.IZipEntry): unknown {
let data: Buffer;
try {
data = entry.getData();
} catch {
throw new OoxmlXmlError('XML部品を安全に展開できません');
}
if (data.length !== entry.header.size) {
throw new OoxmlXmlError('XML部品の宣言サイズと実サイズが一致しません');
}
const head = data.subarray(0, Math.min(data.length, 256));
const utf16Bom = head.length >= 2
&& ((head[0] === 0xff && head[1] === 0xfe) || (head[0] === 0xfe && head[1] === 0xff));
const nullDense = head.length > 8
&& [...head].filter((byte) => byte === 0).length > head.length / 4;
if (utf16Bom || nullDense) {
throw new OoxmlXmlError('UTF-16 XMLは事前検査できません');
}
const xml = data.toString('utf8').replace(/^\uFEFF/, '');
if (/<!DOCTYPE\b/i.test(xml)) {
throw new OoxmlXmlError('DTD宣言を含むXMLは検査できません');
}
const declarationEncoding = xml.match(/^\s*<\?xml\b[^>]*\bencoding\s*=\s*["']([^"']+)["']/i)?.[1];
if (declarationEncoding && !/^(?:utf-?8|us-ascii)$/i.test(declarationEncoding)) {
throw new OoxmlXmlError(`非対応のXML文字コードです: ${safeText(declarationEncoding)}`);
}
if (XMLValidator.validate(xml) !== true) {
throw new OoxmlXmlError('XML構文が壊れています');
}
try {
return xmlParser.parse(xml);
} catch {
throw new OoxmlXmlError('XML部品を解析できません');
}
}
function inspectWorkbookXml(document: unknown): ExcelViolation[] {
const violations: ExcelViolation[] = [];
for (const node of nodesNamed(document, 'workbookPr')) {
const date1904 = xmlObject(node)?.['@_date1904'];
if (date1904 === '1' || String(date1904).toLowerCase() === 'true') {
violations.push(violation(
'UNSUPPORTED_WORKBOOK_FEATURE',
'date1904=true のブックは編集できません。標準の日付方式で単純な .xlsx として保存し直してください',
'date1904=true',
));
}
}
for (const node of nodesNamed(document, 'definedName')) {
const nameValue = xmlObject(node)?.['@_name'];
const name = typeof nameValue === 'string' ? nameValue : null;
if (name && !ALLOWED_DEFINED_NAMES.has(name)) {
const safeName = safeText(name);
violations.push(violation(
'UNSUPPORTED_WORKBOOK_FEATURE',
`custom defined name "${safeName}" は編集できません。名前定義を削除して単純な .xlsx として保存し直してください`,
name,
));
}
}
return violations;
}
function inspectWorksheetXml(document: unknown, partName: string): ExcelViolation[] {
const violations: ExcelViolation[] = [];
const safePart = safeText(partName);
if (nodesNamed(document, 'f').some((node) => xmlObject(node)?.['@_t'] === 'array')) {
violations.push(violation(
'UNSUPPORTED_WORKBOOK_FEATURE',
`array formula (${safePart}) は編集できません。通常の数式へ変換して保存し直してください`,
partName,
));
}
if (nodesNamed(document, 'dynamicArrayProperties').length > 0) {
violations.push(violation(
'UNSUPPORTED_WORKBOOK_FEATURE',
`dynamic array (${safePart}) は編集できません。動的配列を値へ変換して保存し直してください`,
partName,
));
}
return violations;
}
async function hasCfbMagic(path: string): Promise<boolean> {
const handle = await open(path, 'r');
try {
const head = Buffer.alloc(CFB_MAGIC.length);
const { bytesRead } = await handle.read(head, 0, head.length, 0);
return bytesRead === CFB_MAGIC.length && head.equals(CFB_MAGIC);
} finally {
await handle.close();
}
}
export async function preflightExcel(
absolutePath: string,
opts: PreflightOptions,
): Promise<ExcelViolation[]> {
const extension = extname(absolutePath).toLowerCase();
if (extension !== '.xlsx') {
return [violation(
'UNSUPPORTED_WORKBOOK_FEATURE',
`${safeText(extension || '拡張子なし')} は編集できません。マクロや旧形式を除いた単純な .xlsx として保存し直してください`,
extension,
)];
}
let compressedBytes: number;
try {
compressedBytes = (await stat(absolutePath)).size;
} catch {
return packageViolation('Excelファイルを読み取れません');
}
if (compressedBytes > opts.maxCompressedBytes) {
return packageViolation('圧縮済みExcelファイルがサイズ上限を超えています', compressedBytes);
}
try {
if (await hasCfbMagic(absolutePath)) {
return packageViolation('暗号化または旧Office形式のコンテナは編集できません');
}
} catch {
return packageViolation('Excelファイルを読み取れません');
}
// 中央目録を自前で歩き、申告件数と突き合わせる(#1084 段階 1)。
//
// この経路だけは共通の関所ではなくここを通るが、**隠した項目が実害を出すのは
// この経路**(列挙が申告件数で止まったまま ExcelJS へ渡り、あちらは目録を
// 最後まで読むので隠した項目まで展開する)。関所と同じ規則をここにも置く。
// 二重実装を 1 つに寄せるのは段階 3。
let raw: Buffer;
try {
raw = await readFile(absolutePath);
} catch {
return packageViolation('Excelファイルを読み取れません');
}
const index = walkCentralDirectory(raw, opts.maxParts ?? DEFAULT_MAX_PARTS);
if (index.status === 'declared-exceeded') {
return packageViolation('OOXML部品数が上限を超えています', index.declaredEntries);
}
if (index.status === 'mismatch') {
return packageViolation(
'ZIP/OOXMLパッケージの目録が申告する部品数と、実際に並んでいる部品数が食い違います。'
+ 'Excelで開いて保存し直したファイルを使ってください',
index.walkedEntries,
);
}
if (index.status === 'unreadable') {
return packageViolation(
`ZIP/OOXMLパッケージの目録を読み取れません(${index.detail ?? 'unknown reason'})。`
+ 'Excelで開いて保存し直したファイルを使ってください',
);
}
let entries: AdmZip.IZipEntry[];
try {
entries = new AdmZip(raw).getEntries();
} catch {
return packageViolation('壊れたZIP/OOXMLパッケージです');
}
if (entries.length === 0) return packageViolation('空のZIP/OOXMLパッケージです');
if (entries.length > (opts.maxParts ?? DEFAULT_MAX_PARTS)) {
return packageViolation('OOXML部品数が上限を超えています', entries.length);
}
const maxUncompressedBytes = opts.maxUncompressedBytes ?? DEFAULT_MAX_UNCOMPRESSED_BYTES;
const entriesByName = new Map<string, AdmZip.IZipEntry>();
for (const entry of entries) {
const rawName = rawEntryName(entry);
const name = normalizedPartName(rawName);
if (entriesByName.has(name)) {
return packageViolation(`重複したZIP entryがあります: ${safeText(rawName)}`, rawName);
}
if (isDangerousEntryName(rawName, entry.isDirectory)) {
return packageViolation(`危険なZIP entry名があります: ${safeText(rawName)}`, rawName);
}
const encrypted = (entry.header.flags & 0x1) !== 0;
if (encrypted) {
return packageViolation(`暗号化されたZIP entryがあります: ${safeText(rawName)}`, rawName);
}
if (
!Number.isSafeInteger(entry.header.size)
|| !Number.isSafeInteger(entry.header.compressedSize)
|| entry.header.compressedSize > compressedBytes
) return packageViolation('ZIP entryのサイズ情報が不正です', rawName);
entriesByName.set(name, entry);
}
// 実際に展開して展開後サイズを測る。**判定は共通の関所と同じ部品を通す**
// (#1084 段階 3)。ここにだけ実展開があったのが二重実装の元で、包みの層の
// 関所は申告値しか見ていなかった。いまは同じ `measureUncompressedBytes` を
// 両方が呼ぶ。文言だけこちら側のもの(この入口は日本語で返す)。
const measured = measureUncompressedBytes(entries, maxUncompressedBytes);
if (measured.status === 'exceeded') {
return packageViolation('OOXMLの展開後サイズが上限を超えています', measured.atLeastBytes);
}
if (measured.status === 'entry-unreadable') {
return packageViolation(
`ZIP entryをサイズ上限内で展開できません: ${safeText(measured.entryName)}`,
measured.entryName,
);
}
if (measured.status === 'entry-size-mismatch') {
return packageViolation(
`ZIP entryの宣言サイズと展開サイズが一致しません: ${safeText(measured.entryName)}`,
measured.entryName,
);
}
const contentTypesEntry = entriesByName.get('[content_types].xml');
const workbookEntry = entriesByName.get('xl/workbook.xml');
if (!contentTypesEntry || !workbookEntry) return packageViolation('必須OOXML部品が不足しています');
const violations: ExcelViolation[] = [];
for (const [name, entry] of entriesByName) {
if (entry.isDirectory) continue;
const rule = UNSUPPORTED_PARTS.find((candidate) => candidate.matches(name));
if (!rule) continue;
const rawName = rawEntryName(entry);
violations.push(violation(
'UNSUPPORTED_WORKBOOK_FEATURE',
`${rule.label} (${safeText(rawName)}) は編集できません。機能を削除して単純な .xlsx として保存し直してください`,
rawName,
));
}
if (entriesByName.has('xl/metadata.xml')) {
violations.push(violation(
'UNSUPPORTED_WORKBOOK_FEATURE',
'dynamic array metadata (xl/metadata.xml) は編集できません。動的配列を値へ変換して保存し直してください',
'xl/metadata.xml',
));
}
try {
const contentTypes = readXml(contentTypesEntry);
const overrides = new Map<string, string>();
const defaults = new Map<string, string>();
for (const node of nodesNamed(contentTypes, 'Override')) {
const object = xmlObject(node);
const partName = object?.['@_PartName'];
const contentType = object?.['@_ContentType'];
if (typeof partName === 'string' && typeof contentType === 'string') {
overrides.set(normalizedPartName(partName), contentType.toLowerCase());
}
}
for (const node of nodesNamed(contentTypes, 'Default')) {
const object = xmlObject(node);
const extension = object?.['@_Extension'];
const contentType = object?.['@_ContentType'];
if (typeof extension === 'string' && typeof contentType === 'string') {
defaults.set(extension.toLowerCase(), contentType.toLowerCase());
}
}
const worksheetParts = new Set<string>();
const workbookParts = new Set<string>();
const reportedParts = new Set(
violations
.map((item) => typeof item.actual === 'string' ? normalizedPartName(item.actual) : null)
.filter((item): item is string => item !== null),
);
for (const [partName, entry] of entriesByName) {
if (entry.isDirectory) continue;
const extension = partName.includes('.') ? partName.slice(partName.lastIndexOf('.') + 1) : '';
const contentType = overrides.get(partName) ?? defaults.get(extension);
if (!contentType) continue;
if (contentType.includes('spreadsheetml.worksheet')) worksheetParts.add(partName);
if (contentType.includes('spreadsheetml.sheet.main+xml')) workbookParts.add(partName);
const rule = UNSUPPORTED_CONTENT_TYPES.find((candidate) => candidate.matches(contentType));
if (rule && !reportedParts.has(partName)) {
const rawName = rawEntryName(entry);
violations.push(violation(
'UNSUPPORTED_WORKBOOK_FEATURE',
`${rule.label} (${safeText(rawName)}) は編集できません。機能を削除して単純な .xlsx として保存し直してください`,
rawName,
));
reportedParts.add(partName);
}
}
if (workbookParts.size === 0) {
throw new OoxmlXmlError('workbook main部品のContent Typeがありません');
}
for (const partName of workbookParts) {
const entry = entriesByName.get(partName);
if (!entry) throw new OoxmlXmlError(`workbook部品がありません: ${safeText(partName)}`);
violations.push(...inspectWorkbookXml(readXml(entry)));
}
for (const partName of worksheetParts) {
const entry = entriesByName.get(partName);
if (!entry) throw new OoxmlXmlError(`worksheet部品がありません: ${safeText(partName)}`);
violations.push(...inspectWorksheetXml(readXml(entry), rawEntryName(entry)));
}
} catch (error) {
const detail = error instanceof OoxmlXmlError ? `: ${safeText(error.message)}` : '';
return packageViolation(`OOXMLのXML部品を読み取れません${detail}`);
}
return violations;
}
+192
View File
@@ -0,0 +1,192 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import AdmZip from 'adm-zip';
import ExcelJS from 'exceljs';
import { afterEach, describe, expect, it } from 'vitest';
import {
addRecalculationCanary,
compareWorkbookStructure,
parseRecalculationHelperResponse,
runLibreOfficeRecalculation,
workbookStructure,
} from './excel-recalculate.js';
// 本体(excel-recalculate.ts)と同じ解き方。作業ディレクトリからの相対にすると、
// リポジトリの根以外から 1 ファイルだけ走らせたときに落ちる(#1067 レビュー P3-4)。
const HELPER_PATH = fileURLToPath(new URL('../../../runtime/excel-recalculate.py', import.meta.url));
const directories: string[] = [];
afterEach(() => {
for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true });
});
async function workbookBytes(): Promise<Buffer> {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('工程表');
sheet.mergeCells('B1:C1');
sheet.getCell('A2').value = { formula: '1+1', result: 2 };
sheet.getCell('D2').value = { formula: '1/0', result: 0 };
sheet.getCell('A3').value = { sharedFormula: 'A2', result: 3 };
return Buffer.from(await workbook.xlsx.writeBuffer());
}
describe('Excel LibreOffice recalculation boundary', () => {
it('adds one hidden stale canary without mutating the candidate bytes', async () => {
const candidate = await workbookBytes();
const before = Buffer.from(candidate);
const validation = addRecalculationCanary(candidate, 'fixedsuffix123456789');
const zip = new AdmZip(validation.bytes);
expect(candidate).toEqual(before);
expect(validation.sheetName).toHaveLength(31);
expect(zip.readAsText('xl/workbook.xml')).toContain(`name="${validation.sheetName}"`);
expect(zip.readAsText('xl/workbook.xml')).toContain('state="hidden"');
expect(zip.readAsText(`xl/worksheets/${validation.sheetFile}`)).toContain('<v>-1</v>');
});
it('rejects a candidate that already uses the reserved canary prefix', async () => {
const workbook = new ExcelJS.Workbook();
workbook.addWorksheet('_MAESTRO_RECALC_existing');
const bytes = Buffer.from(await workbook.xlsx.writeBuffer());
expect(() => addRecalculationCanary(bytes)).toThrow(/予約/);
});
it('returns a stable failure when the working directory cannot be prepared', async () => {
const directory = mkdtempSync(join(tmpdir(), 'excel-recalc-bad-workdir-'));
directories.push(directory);
const file = join(directory, 'not-a-directory');
writeFileSync(file, 'x');
await expect(runLibreOfficeRecalculation({
inputPath: join(directory, 'input.xlsx'),
outputPath: join(directory, 'output.xlsx'),
workingDirectory: file,
})).resolves.toEqual({ ok: false, error: 'workdir_setup_failed' });
});
it('fails closed on malformed, oversized or unconfirmed helper output', () => {
expect(parseRecalculationHelperResponse('not json')).toMatchObject({
ok: false, error: 'invalid_helper_response',
});
expect(parseRecalculationHelperResponse('x'.repeat(65_537))).toMatchObject({
ok: false, error: 'helper_response_too_large',
});
expect(parseRecalculationHelperResponse(JSON.stringify({
ok: true, canary_value: -1, formula_error_codes: {},
}))).toMatchObject({ ok: false, error: 'recalculation_not_confirmed' });
expect(parseRecalculationHelperResponse(JSON.stringify({
ok: true, canary_value: 2, formula_error_codes: { '工程表!D2': 519 },
}))).toEqual({
ok: true, canaryValue: 2, formulaErrorCodes: { '工程表!D2': 519 },
});
});
it('rejects helper locations and error codes outside the bounded contract', () => {
expect(parseRecalculationHelperResponse(JSON.stringify({
ok: true, canary_value: 2, formula_error_codes: { '工程表!XFE1': 519 },
}))).toMatchObject({ ok: false, error: 'invalid_helper_response' });
expect(parseRecalculationHelperResponse(JSON.stringify({
ok: true, canary_value: 2, formula_error_codes: { '工程表!A1': -1 },
}))).toMatchObject({ ok: false, error: 'invalid_helper_response' });
});
/**
* 再計算を任せる Python の中身が、渡してよい範囲に収まっていることを見る。
* 実装を約 100 行複製した spike が持っていた検査で、複製を消すときにここへ
* 引き取った(#1067 重複・後始末)。**マクロを絶対に実行しない**指定が要なので、
* 落とすと勝手にマクロが走る文書を開きうる。
*/
it('ships a trusted stdin/stdout UNO helper', () => {
const source = readFileSync(HELPER_PATH, 'utf8');
expect(source).toContain('calculateAll()');
expect(source).toMatch(/property_value\(\s*"MacroExecutionMode",\s*uno\.getConstantByName\(\s*"com\.sun\.star\.document\.MacroExecMode\.NEVER_EXECUTE"\s*\)\s*,?\s*\)/);
expect(source).toContain('queryContentCells');
expect(source).toContain('input_and_output_paths_must_differ');
expect(source).toContain('Calc MS Excel 2007 XML');
});
it('fails promptly with a stable code when LibreOffice cannot start', async () => {
const directory = mkdtempSync(join(tmpdir(), 'excel-recalc-missing-office-'));
directories.push(directory);
const result = await runLibreOfficeRecalculation({
inputPath: join(directory, 'input.xlsx'),
outputPath: join(directory, 'output.xlsx'),
workingDirectory: directory,
sofficeBinary: '/definitely-missing-maestro-soffice',
timeoutMs: 2_000,
});
expect(result).toEqual({ ok: false, error: 'libreoffice_start_failed' });
});
it('rejects identical input and output paths before starting processes', async () => {
const directory = mkdtempSync(join(tmpdir(), 'excel-recalc-same-path-'));
directories.push(directory);
const path = join(directory, 'same.xlsx');
await expect(runLibreOfficeRecalculation({
inputPath: path,
outputPath: path,
workingDirectory: directory,
})).resolves.toEqual({ ok: false, error: 'input_and_output_paths_must_differ' });
});
it.skipIf(process.env.RUN_LIBREOFFICE_TESTS !== '1')(
'recalculates formulas, reports errors and preserves structure through production boundary',
async () => {
const directory = mkdtempSync(join(tmpdir(), 'excel-recalc-integration-'));
directories.push(directory);
const candidatePath = join(directory, 'candidate.xlsx');
const validationInputPath = join(directory, 'validation-input.xlsx');
const validationOutputPath = join(directory, 'validation-output.xlsx');
const candidate = await workbookBytes();
writeFileSync(candidatePath, candidate);
writeFileSync(validationInputPath, addRecalculationCanary(candidate).bytes);
const result = await runLibreOfficeRecalculation({
inputPath: validationInputPath,
outputPath: validationOutputPath,
workingDirectory: directory,
});
expect(result).toMatchObject({
ok: true,
canaryValue: 2,
formulaErrorCodes: expect.objectContaining({ '工程表!D2': expect.any(Number) }),
});
expect(compareWorkbookStructure(
await workbookStructure(candidatePath),
await workbookStructure(validationOutputPath),
)).toEqual([]);
},
180_000,
);
it('detects formula and merge structure mutation while ignoring the canary', async () => {
const directory = mkdtempSync(join(tmpdir(), 'excel-structure-test-'));
directories.push(directory);
const beforePath = join(directory, 'before.xlsx');
const afterPath = join(directory, 'after.xlsx');
writeFileSync(beforePath, await workbookBytes());
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(readFileSync(beforePath));
const sheet = workbook.getWorksheet('工程表')!;
sheet.unMergeCells('B1:C1');
sheet.getCell('A2').value = { formula: '2+2', result: 4 };
workbook.addWorksheet('_MAESTRO_RECALC_ignored');
await workbook.xlsx.writeFile(afterPath);
const before = await workbookStructure(beforePath);
const after = await workbookStructure(afterPath);
const violations = compareWorkbookStructure(before, after);
expect(violations).toEqual([
expect.objectContaining({ code: 'RECALCULATION_MUTATED_STRUCTURE', sheet: '工程表' }),
]);
});
});
+312
View File
@@ -0,0 +1,312 @@
import { spawn } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { mkdirSync } from 'node:fs';
import { resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import AdmZip from 'adm-zip';
import ExcelJS from 'exceljs';
import { parseCellAddress } from '../../office/excel-formula.js';
import { EXCEL_RECALC_CANARY_PREFIX, type ExcelViolation } from './excel-types.js';
export { EXCEL_RECALC_CANARY_PREFIX } from './excel-types.js';
const MAX_HELPER_RESPONSE_BYTES = 65_536;
const MAX_FORMULA_ERRORS = 10_000;
const MAX_OOXML_CONTROL_PART_BYTES = 2 * 1024 * 1024;
const DEFAULT_HELPER_PATH = fileURLToPath(new URL('../../../runtime/excel-recalculate.py', import.meta.url));
export interface RecalculationHelperSuccess {
ok: true;
canaryValue: 2;
formulaErrorCodes: Record<string, number>;
}
export interface RecalculationHelperFailure {
ok: false;
error: string;
}
export type RecalculationHelperResult = RecalculationHelperSuccess | RecalculationHelperFailure;
export interface LibreOfficeRecalculationInput {
inputPath: string;
outputPath: string;
workingDirectory: string;
sofficeBinary?: string;
pythonBinary?: string;
helperPath?: string;
timeoutMs?: number;
}
export interface WorkbookSheetStructure {
formulas: Array<{ cell: string; formula: string }>;
merges: string[];
}
export type WorkbookStructure = Record<string, WorkbookSheetStructure>;
function replaceRequired(source: string, marker: string, replacement: string): string {
const first = source.indexOf(marker);
if (first < 0 || first !== source.lastIndexOf(marker)) {
throw new Error(`Excelパッケージに必要な要素 ${marker} が一意にありません`);
}
return source.slice(0, first) + replacement + source.slice(first + marker.length);
}
function safeCanarySuffix(suffix: string): string {
const normalized = suffix.replaceAll(/[^A-Za-z0-9]/g, '');
const requiredLength = 31 - EXCEL_RECALC_CANARY_PREFIX.length;
if (normalized.length < requiredLength) {
return normalized.padEnd(requiredLength, '0');
}
return normalized.slice(0, requiredLength);
}
function readBoundedZipText(zip: AdmZip, entryName: string): string {
const entry = zip.getEntry(entryName);
if (!entry || entry.header.size > MAX_OOXML_CONTROL_PART_BYTES) {
throw new Error(`Excelパッケージの制御XMLがないか大きすぎます: ${entryName}`);
}
return entry.getData().toString('utf8');
}
export function addRecalculationCanary(
candidate: Buffer,
suffix = randomUUID().replaceAll('-', ''),
): { bytes: Buffer; sheetName: string; sheetFile: string } {
const zip = new AdmZip(candidate);
const workbookXml = readBoundedZipText(zip, 'xl/workbook.xml');
const relationshipsXml = readBoundedZipText(zip, 'xl/_rels/workbook.xml.rels');
const contentTypesXml = readBoundedZipText(zip, '[Content_Types].xml');
if (!workbookXml || !relationshipsXml || !contentTypesXml) {
throw new Error('Excelパッケージの必須XMLがありません');
}
if (workbookXml.includes(`name="${EXCEL_RECALC_CANARY_PREFIX}`)) {
throw new Error('予約済みの再計算カナリア接頭辞を持つシートがあります');
}
const sheetNumbers = zip.getEntries()
.map(entry => /^xl\/worksheets\/sheet(\d+)\.xml$/.exec(entry.entryName)?.[1])
.filter((value): value is string => value !== undefined)
.map(Number);
const sheetNumber = Math.max(0, ...sheetNumbers) + 1;
const sheetIds = [...workbookXml.matchAll(/\bsheetId="(\d+)"/g)].map(match => Number(match[1]));
const sheetId = Math.max(0, ...sheetIds) + 1;
const relationshipIds = new Set(
[...relationshipsXml.matchAll(/\bId="([^"]+)"/g)].map(match => match[1]!),
);
let relationshipNumber = relationshipIds.size + 1;
while (relationshipIds.has(`rId${relationshipNumber}`)) relationshipNumber += 1;
const relationshipId = `rId${relationshipNumber}`;
const sheetName = `${EXCEL_RECALC_CANARY_PREFIX}${safeCanarySuffix(suffix)}`;
const sheetFile = `sheet${sheetNumber}.xml`;
const entryName = `xl/worksheets/${sheetFile}`;
zip.updateFile('xl/workbook.xml', Buffer.from(replaceRequired(
workbookXml,
'</sheets>',
`<sheet name="${sheetName}" sheetId="${sheetId}" state="hidden" r:id="${relationshipId}"/></sheets>`,
)));
zip.updateFile('xl/_rels/workbook.xml.rels', Buffer.from(replaceRequired(
relationshipsXml,
'</Relationships>',
`<Relationship Id="${relationshipId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/${sheetFile}"/></Relationships>`,
)));
zip.updateFile('[Content_Types].xml', Buffer.from(replaceRequired(
contentTypesXml,
'</Types>',
`<Override PartName="/${entryName}" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/></Types>`,
)));
zip.addFile(entryName, Buffer.from(
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
+ '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
+ '<sheetData><row r="1"><c r="A1"><f>1+1</f><v>-1</v></c></row></sheetData></worksheet>',
));
return { bytes: zip.toBuffer(), sheetName, sheetFile };
}
function validFormulaErrorMap(value: unknown): value is Record<string, number> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const entries = Object.entries(value);
if (entries.length > MAX_FORMULA_ERRORS) return false;
return entries.every(([location, code]) => {
const separator = location.lastIndexOf('!');
if (separator <= 0) return false;
const sheet = location.slice(0, separator);
const cell = location.slice(separator + 1);
return sheet.length <= 31
&& !/[\\/?*\[\]:]/.test(sheet)
&& parseCellAddress(cell) !== null
&& Number.isInteger(code)
&& code > 0
&& code <= 65_535;
});
}
export function parseRecalculationHelperResponse(stdout: string): RecalculationHelperResult {
if (Buffer.byteLength(stdout, 'utf8') > MAX_HELPER_RESPONSE_BYTES) {
return { ok: false, error: 'helper_response_too_large' };
}
let response: unknown;
try {
response = JSON.parse(stdout.trim());
} catch {
return { ok: false, error: 'invalid_helper_response' };
}
if (!response || typeof response !== 'object' || Array.isArray(response)) {
return { ok: false, error: 'invalid_helper_response' };
}
const raw = response as Record<string, unknown>;
if (raw.ok !== true) {
const error = typeof raw.error === 'string' && /^[A-Za-z][A-Za-z0-9_]{0,79}$/.test(raw.error)
? raw.error
: 'invalid_helper_response';
return { ok: false, error };
}
if (raw.canary_value !== 2) return { ok: false, error: 'recalculation_not_confirmed' };
if (!validFormulaErrorMap(raw.formula_error_codes)) {
return { ok: false, error: 'invalid_helper_response' };
}
return { ok: true, canaryValue: 2, formulaErrorCodes: raw.formula_error_codes };
}
export function runLibreOfficeRecalculation(
input: LibreOfficeRecalculationInput,
): Promise<RecalculationHelperResult> {
const inputPath = resolve(input.inputPath);
const outputPath = resolve(input.outputPath);
if (inputPath === outputPath) {
return Promise.resolve({ ok: false, error: 'input_and_output_paths_must_differ' });
}
const workingDirectory = resolve(input.workingDirectory);
const profile = resolve(workingDirectory, 'profile');
try {
mkdirSync(profile, { recursive: true });
} catch {
return Promise.resolve({ ok: false, error: 'workdir_setup_failed' });
}
return new Promise(resolvePromise => {
const pipe = `maestro-${randomUUID()}`;
const timeoutMs = Number.isFinite(input.timeoutMs) && (input.timeoutMs ?? 0) > 0
? Math.min(input.timeoutMs!, 180_000)
: 120_000;
let settled = false;
let timeout: ReturnType<typeof setTimeout> | undefined;
let helper: ReturnType<typeof spawn> | undefined;
const office = spawn(input.sofficeBinary ?? process.env.SOFFICE_BIN ?? 'soffice', [
'--headless',
'--nologo',
'--nodefault',
'--nofirststartwizard',
'--norestore',
`--accept=pipe,name=${pipe};urp;StarOffice.ComponentContext`,
`-env:UserInstallation=${pathToFileURL(profile).href}`,
], { detached: true, stdio: 'ignore' });
const terminate = (processToStop: ReturnType<typeof spawn> | undefined, group = false) => {
if (!processToStop?.pid || processToStop.exitCode !== null) return;
try {
process.kill(group ? -processToStop.pid : processToStop.pid, 'SIGKILL');
} catch {
// The process has already exited.
}
};
const finish = (result: RecalculationHelperResult) => {
if (settled) return;
settled = true;
if (timeout) clearTimeout(timeout);
terminate(helper);
terminate(office, true);
resolvePromise(result);
};
office.once('error', () => finish({ ok: false, error: 'libreoffice_start_failed' }));
office.once('close', () => {
finish({ ok: false, error: 'libreoffice_exited' });
});
const helperProcess = spawn(
input.pythonBinary ?? process.env.PYTHON_BIN ?? 'python3',
[resolve(input.helperPath ?? DEFAULT_HELPER_PATH)],
{ stdio: ['pipe', 'pipe', 'pipe'] },
);
helper = helperProcess;
const stdoutChunks: Buffer[] = [];
let stdoutBytes = 0;
let stderrBytes = 0;
helperProcess.stdout.on('data', chunk => {
if (settled) return;
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
stdoutBytes += bytes.length;
if (stdoutBytes > MAX_HELPER_RESPONSE_BYTES) {
finish({ ok: false, error: 'helper_response_too_large' });
return;
}
stdoutChunks.push(bytes);
});
helperProcess.stderr.on('data', chunk => {
stderrBytes += chunk.length;
if (stderrBytes > MAX_HELPER_RESPONSE_BYTES) {
finish({ ok: false, error: 'helper_stderr_too_large' });
}
});
helperProcess.stdin.on('error', () => {
if (!settled) finish({ ok: false, error: 'helper_stdin_failed' });
});
helperProcess.stdin.end(`${JSON.stringify({
input_path: inputPath,
output_path: outputPath,
pipe,
profile_uri: pathToFileURL(profile).href,
})}\n`);
timeout = setTimeout(
() => finish({ ok: false, error: 'recalculation_timeout' }),
timeoutMs,
);
helperProcess.once('error', () => finish({ ok: false, error: 'python_start_failed' }));
helperProcess.once('close', code => {
if (settled) return;
const response = parseRecalculationHelperResponse(Buffer.concat(stdoutChunks).toString('utf8'));
if (code !== 0 && response.ok) {
finish({ ok: false, error: 'helper_failed' });
return;
}
finish(response);
});
});
}
export async function workbookStructure(path: string): Promise<WorkbookStructure> {
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(path);
return Object.fromEntries(workbook.worksheets
.filter(sheet => !sheet.name.startsWith(EXCEL_RECALC_CANARY_PREFIX))
.map(sheet => {
const formulas: Array<{ cell: string; formula: string }> = [];
sheet.eachRow(row => row.eachCell(cell => {
if (cell.formula) formulas.push({ cell: cell.address, formula: cell.formula });
}));
formulas.sort((left, right) => left.cell.localeCompare(right.cell));
const merges = [...(((sheet.model as unknown as { merges?: string[] }).merges) ?? [])].sort();
return [sheet.name, { formulas, merges }];
}));
}
export function compareWorkbookStructure(
expected: WorkbookStructure,
actual: WorkbookStructure,
): ExcelViolation[] {
const sheetNames = new Set([...Object.keys(expected), ...Object.keys(actual)]);
const violations: ExcelViolation[] = [];
for (const sheet of [...sheetNames].sort()) {
if (JSON.stringify(expected[sheet]) === JSON.stringify(actual[sheet])) continue;
violations.push({
code: 'RECALCULATION_MUTATED_STRUCTURE',
message: 'LibreOffice の再計算でシート構造または数式が変化しました',
sheet,
expected: expected[sheet] ?? null,
actual: actual[sheet] ?? null,
});
}
return violations;
}
+78
View File
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest';
import {
EXCEL_LIMITS,
buildExcelResult,
serializeExcelResult,
type ExcelViolation,
} from './excel-types.js';
describe('Excel result contract', () => {
it('serializes a stable diagnostic result', () => {
const result = buildExcelResult({
phase: 'calculation',
formulaCells: 8,
checkedFormulaCells: 8,
violations: [
{
code: 'FORMULA_ERROR',
sheet: '工程表',
cell: 'C8',
actual: '#DIV/0!',
message: 'D8が0です',
},
],
});
expect(JSON.parse(serializeExcelResult(result))).toMatchObject({
ok: false,
phase: 'calculation',
candidate_path: null,
summary: {
operations: 0,
formula_cells: 8,
checked_formula_cells: 8,
violations: 1,
},
});
});
it('caps returned violations but preserves the total count', () => {
const violations: ExcelViolation[] = Array.from(
{ length: EXCEL_LIMITS.returnedViolations + 1 },
(_, index) => ({
code: 'EXPECTATION_FAILED',
cell: `A${index + 1}`,
message: '期待値と一致しません',
}),
);
const result = buildExcelResult({ phase: 'expectation_static', violations });
expect(result.violations).toHaveLength(EXCEL_LIMITS.returnedViolations);
expect(result.summary.violations).toBe(EXCEL_LIMITS.returnedViolations + 1);
expect(result.violations_truncated).toBe(true);
expect(violations).toHaveLength(EXCEL_LIMITS.returnedViolations + 1);
});
it('marks a result without violations as successful', () => {
const result = buildExcelResult({
phase: 'commit',
candidatePath: 'output/schedule.xlsx',
operations: 3,
violations: [],
warnings: [
{
code: 'ENGINE_DIFFERENCE_POSSIBLE',
message: '計算エンジンに差が生じる可能性があります',
},
],
});
expect(result).toMatchObject({
ok: true,
candidate_path: 'output/schedule.xlsx',
summary: { operations: 3, violations: 0 },
violations_truncated: false,
});
});
});
+125
View File
@@ -0,0 +1,125 @@
export const EXCEL_LIMITS = {
operations: 500,
changedCells: 100_000,
formulaCells: 10_000,
returnedViolations: 200,
lockWaitMs: 60_000,
} as const;
export const EXCEL_RECALC_CANARY_PREFIX = '_MAESTRO_RECALC_';
export type ExcelPhase =
| 'input'
| 'package'
| 'load'
| 'normalize'
| 'patch'
| 'structure'
| 'expectation_static'
| 'recalculation'
| 'calculation'
| 'expectation_calculated'
| 'commit';
export type ExcelViolationCode =
| 'INPUT_INVALID'
| 'PACKAGE_INVALID'
/** 入力でもファイルでもなく、こちら側(ライブラリ・外部プロセス)の失敗。 */
| 'INTERNAL_ERROR'
| 'OUTPUT_CONFLICT'
| 'OPERATION_CONFLICT'
| 'UNSUPPORTED_WORKBOOK_FEATURE'
| 'UNSUPPORTED_FORMULA_FUNCTION'
| 'FORMULA_REFERENCE_INVALID'
| 'FORMULA_ERROR'
| 'EXPECTATION_FAILED'
| 'MISSING_STYLE_ROLE'
| 'STYLE_COLOR_UNRESOLVED'
| 'SOURCE_CHANGED_DURING_EDIT'
| 'RECALCULATION_FAILED'
| 'RECALCULATION_TIMEOUT'
| 'RECALCULATION_NOT_CONFIRMED'
| 'RECALCULATION_MUTATED_STRUCTURE'
| 'GANTT_FORMULA_INPUT_UNSUPPORTED'
/**
* 書き込み先が、コピーされた式のおおもと(共有数式の本体)だった(#1351 段 1)。
* ここを上書きすると、同じかたまりの残りのセルから式も計算結果も消える。
*/
| 'SHARED_FORMULA_MASTER';
export type ExcelWarningCode =
| 'ENGINE_DIFFERENCE_POSSIBLE'
| 'GANTT_HOLIDAY_OUTSIDE_TIMELINE'
| 'GANTT_TASK_OUTSIDE_TIMELINE';
interface ExcelDiagnosticLocation {
sheet?: string;
cell?: string;
range?: string;
formula?: string;
expected?: unknown;
actual?: unknown;
}
export interface ExcelViolation extends ExcelDiagnosticLocation {
code: ExcelViolationCode;
message: string;
}
export interface ExcelWarning extends ExcelDiagnosticLocation {
code: ExcelWarningCode;
message: string;
}
export interface ExcelResultSummary {
operations: number;
changed_cells: number;
formula_cells: number;
checked_formula_cells: number;
violations: number;
}
export interface ExcelResult {
ok: boolean;
phase: ExcelPhase;
candidate_path: string | null;
summary: ExcelResultSummary;
violations: ExcelViolation[];
violations_truncated: boolean;
warnings: ExcelWarning[];
}
export interface BuildExcelResultInput {
phase: ExcelPhase;
candidatePath?: string | null;
operations?: number;
changedCells?: number;
formulaCells?: number;
checkedFormulaCells?: number;
violations?: readonly ExcelViolation[];
warnings?: readonly ExcelWarning[];
}
export function buildExcelResult(input: BuildExcelResultInput): ExcelResult {
const violations = input.violations ?? [];
return {
ok: violations.length === 0,
phase: input.phase,
candidate_path: input.candidatePath ?? null,
summary: {
operations: input.operations ?? 0,
changed_cells: input.changedCells ?? 0,
formula_cells: input.formulaCells ?? 0,
checked_formula_cells: input.checkedFormulaCells ?? 0,
violations: violations.length,
},
violations: violations.slice(0, EXCEL_LIMITS.returnedViolations),
violations_truncated: violations.length > EXCEL_LIMITS.returnedViolations,
warnings: [...(input.warnings ?? [])],
};
}
export function serializeExcelResult(result: ExcelResult): string {
return JSON.stringify(result);
}
+122
View File
@@ -0,0 +1,122 @@
import ExcelJS from 'exceljs';
import { describe, expect, it } from 'vitest';
import {
validateCalculatedWorkbook,
validateStaticWorkbookFormulas,
type ExcelCellExpectation,
} from './excel-validation.js';
describe('Excel formula validation', () => {
it('rejects cached Excel formula errors and UNO error codes', () => {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('集計');
sheet.getCell('B2').value = {
formula: '1/0',
result: { error: '#DIV/0!' },
} as ExcelJS.CellFormulaValue;
const result = validateCalculatedWorkbook(workbook, {
'集計!C3': 519,
});
expect(result.checkedFormulaCells).toBe(1);
expect(result.violations).toEqual(expect.arrayContaining([
expect.objectContaining({
code: 'FORMULA_ERROR',
sheet: '集計',
cell: 'B2',
actual: '#DIV/0!',
}),
expect.objectContaining({
code: 'FORMULA_ERROR',
sheet: '集計',
cell: 'C3',
actual: 519,
}),
]));
});
it('checks explicit values with numeric tolerance after recalculation', () => {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('工程表');
sheet.getCell('D5').value = { formula: '1/3', result: 0.3333333 };
sheet.getCell('E5').value = '完了';
const expectations: ExcelCellExpectation[] = [
{ sheet: '工程表', cell: 'D5', equals: 1 / 3, tolerance: 0.000001 },
{ sheet: '工程表', cell: 'E5', equals: '未完了' },
{ sheet: '存在しない', cell: 'A1', equals: 1 },
];
const result = validateCalculatedWorkbook(workbook, {}, expectations);
expect(result.violations).toEqual([
expect.objectContaining({
code: 'EXPECTATION_FAILED', sheet: '工程表', cell: 'E5',
expected: '未完了', actual: '完了',
}),
expect.objectContaining({
code: 'EXPECTATION_FAILED', sheet: '存在しない', cell: 'A1',
}),
]);
});
it('rejects malformed expectations rather than throwing', () => {
const workbook = new ExcelJS.Workbook();
workbook.addWorksheet('Sheet1').getCell('A1').value = 1;
const expectations = [
{ sheet: 'Sheet1', cell: 'XFE1', equals: 1 },
{ sheet: 'Sheet1', cell: 'A1', equals: 1, tolerance: -1 },
] as ExcelCellExpectation[];
const result = validateCalculatedWorkbook(workbook, {}, expectations);
expect(result.violations).toHaveLength(2);
expect(result.violations.every(item => item.code === 'INPUT_INVALID')).toBe(true);
});
it('statically rejects missing sheet references, malformed strings and formula overflow', () => {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('集計');
sheet.getCell('A1').value = { formula: "'明細'!A1+1", result: 1 };
sheet.getCell('A2').value = { formula: 'IF(A1="未終了,1,0)', result: 0 };
const result = validateStaticWorkbookFormulas(workbook, { formulaCells: 1 });
expect(result.formulaCells).toBe(2);
expect(result.violations).toEqual(expect.arrayContaining([
expect.objectContaining({ code: 'FORMULA_REFERENCE_INVALID', cell: 'A1' }),
expect.objectContaining({ code: 'INPUT_INVALID', cell: 'A2' }),
expect.objectContaining({ code: 'INPUT_INVALID', message: expect.stringContaining('上限') }),
]));
});
it('accepts escaped quoted sheet names case-insensitively', () => {
const workbook = new ExcelJS.Workbook();
workbook.addWorksheet("O'Brien");
workbook.addWorksheet('集計').getCell('A1').value = {
formula: "'o''brien'!A1+1",
result: 1,
};
expect(validateStaticWorkbookFormulas(workbook).violations).toEqual([]);
});
});
it('normalizes formula dates and rejects malformed direct UNO locations', () => {
const workbook = new ExcelJS.Workbook();
const date = new Date(Date.UTC(2026, 7, 5));
workbook.addWorksheet('Sheet1').getCell('A1').value = {
formula: 'DATE(2026,8,5)',
result: date,
};
const result = validateCalculatedWorkbook(
workbook,
{ malformed: 1 },
[{ sheet: 'Sheet1', cell: 'A1', equals: date.toISOString() }],
);
expect(result.violations).toEqual([
expect.objectContaining({ code: 'INPUT_INVALID', actual: 'malformed' }),
]);
});
+225
View File
@@ -0,0 +1,225 @@
import ExcelJS from 'exceljs';
import { EXCEL_LIMITS, type ExcelViolation } from './excel-types.js';
import { parseCellAddress, tokenizeFormula } from '../../office/excel-formula.js';
export interface ExcelCellExpectation {
sheet: string;
cell: string;
equals: string | number | boolean | null;
tolerance?: number;
}
export interface StaticFormulaValidationOptions {
formulaCells?: number;
}
export interface StaticFormulaValidationResult {
formulaCells: number;
violations: ExcelViolation[];
}
export interface CalculatedWorkbookValidationResult {
checkedFormulaCells: number;
violations: ExcelViolation[];
}
function formulaError(result: unknown): string | null {
if (!result || typeof result !== 'object' || !('error' in result)) return null;
const error = (result as { error?: unknown }).error;
return typeof error === 'string' && error.startsWith('#') ? error : null;
}
function referencedSheetName(prefix: string): string | null {
if (!prefix.endsWith('!')) return null;
const withoutBang = prefix.slice(0, -1);
if (withoutBang.startsWith("'") && withoutBang.endsWith("'")) {
return withoutBang.slice(1, -1).replaceAll("''", "'");
}
return withoutBang;
}
function worksheetCaseInsensitive(
workbook: ExcelJS.Workbook,
requestedName: string,
): ExcelJS.Worksheet | undefined {
const normalized = requestedName.toLocaleLowerCase('en-US');
return workbook.worksheets.find(sheet => sheet.name.toLocaleLowerCase('en-US') === normalized);
}
export function validateStaticWorkbookFormulas(
workbook: ExcelJS.Workbook,
options: StaticFormulaValidationOptions = {},
): StaticFormulaValidationResult {
const formulaLimit = options.formulaCells ?? EXCEL_LIMITS.formulaCells;
const sheetNames = new Set(
workbook.worksheets.map(sheet => sheet.name.toLocaleLowerCase('en-US')),
);
const violations: ExcelViolation[] = [];
let formulaCells = 0;
for (const sheet of workbook.worksheets) {
sheet.eachRow(row => row.eachCell(cell => {
const formula = cell.formula;
if (!formula) return;
formulaCells += 1;
const tokens = tokenizeFormula(formula);
if (tokens.some(token => token.type === 'string' && token.closed === false)) {
violations.push({
code: 'INPUT_INVALID',
message: '数式内の文字列が閉じられていません',
sheet: sheet.name,
cell: cell.address,
formula,
});
}
const reportedSheets = new Set<string>();
for (const token of tokens) {
if (token.type !== 'reference' || !token.reference?.sheetPrefix) continue;
const referenced = referencedSheetName(token.reference.sheetPrefix);
if (
referenced
&& !sheetNames.has(referenced.toLocaleLowerCase('en-US'))
&& !reportedSheets.has(referenced.toLocaleLowerCase('en-US'))
) {
reportedSheets.add(referenced.toLocaleLowerCase('en-US'));
violations.push({
code: 'FORMULA_REFERENCE_INVALID',
message: `数式が存在しないシート「${referenced}」を参照しています`,
sheet: sheet.name,
cell: cell.address,
formula,
actual: referenced,
});
}
}
}));
}
if (!Number.isSafeInteger(formulaLimit) || formulaLimit < 0 || formulaCells > formulaLimit) {
violations.push({
code: 'INPUT_INVALID',
message: `数式セル数 ${formulaCells} が上限 ${formulaLimit} を超えています`,
actual: formulaCells,
expected: formulaLimit,
});
}
return { formulaCells, violations };
}
function expectationActual(cell: ExcelJS.Cell): string | number | boolean | null {
const value = cell.formula ? cell.result : cell.value;
if (value instanceof Date) return value.toISOString();
if (
value === null
|| typeof value === 'string'
|| typeof value === 'number'
|| typeof value === 'boolean'
) return value;
return formulaError(value) ?? '[non-scalar]';
}
function expectationMatches(actual: unknown, expectation: ExcelCellExpectation): boolean {
if (
typeof actual === 'number'
&& typeof expectation.equals === 'number'
&& expectation.tolerance !== undefined
) {
return Math.abs(actual - expectation.equals) <= expectation.tolerance;
}
return Object.is(actual, expectation.equals);
}
export function validateCalculatedWorkbook(
workbook: ExcelJS.Workbook,
unoFormulaErrorCodes: Readonly<Record<string, number>> = {},
expectations: readonly ExcelCellExpectation[] = [],
): CalculatedWorkbookValidationResult {
const violations: ExcelViolation[] = [];
const formulaErrorLocations = new Set<string>();
let checkedFormulaCells = 0;
for (const sheet of workbook.worksheets) {
sheet.eachRow(row => row.eachCell(cell => {
if (!cell.formula) return;
checkedFormulaCells += 1;
const error = formulaError(cell.result);
if (!error) return;
const location = `${sheet.name}!${cell.address}`;
formulaErrorLocations.add(location);
violations.push({
code: 'FORMULA_ERROR',
message: `再計算後の数式が ${error} になっています`,
sheet: sheet.name,
cell: cell.address,
formula: cell.formula,
actual: error,
});
}));
}
for (const [location, errorCode] of Object.entries(unoFormulaErrorCodes)) {
if (formulaErrorLocations.has(location)) continue;
const separator = location.lastIndexOf('!');
const sheet = location.slice(0, separator);
const cell = location.slice(separator + 1);
if (
separator <= 0
|| !parseCellAddress(cell)
|| !Number.isInteger(errorCode)
|| errorCode <= 0
) {
violations.push({
code: 'INPUT_INVALID',
message: 'LibreOffice 数式エラー位置の形式が不正です',
actual: location,
});
continue;
}
violations.push({
code: 'FORMULA_ERROR',
message: `LibreOffice の再計算で数式エラー ${errorCode} が検出されました`,
sheet,
cell,
actual: errorCode,
});
}
for (const expectation of expectations) {
const toleranceValid = expectation.tolerance === undefined
|| (Number.isFinite(expectation.tolerance) && expectation.tolerance >= 0);
if (!parseCellAddress(expectation.cell) || !toleranceValid) {
violations.push({
code: 'INPUT_INVALID',
message: '期待値のセル番地または許容誤差が不正です',
sheet: expectation.sheet,
cell: expectation.cell,
expected: expectation.equals,
});
continue;
}
const sheet = worksheetCaseInsensitive(workbook, expectation.sheet);
if (!sheet) {
violations.push({
code: 'EXPECTATION_FAILED',
message: `期待値を確認するシート「${expectation.sheet}」がありません`,
sheet: expectation.sheet,
cell: expectation.cell,
expected: expectation.equals,
});
continue;
}
const actual = expectationActual(sheet.getCell(expectation.cell));
if (!expectationMatches(actual, expectation)) {
violations.push({
code: 'EXPECTATION_FAILED',
message: '再計算後の値が期待値と一致しません',
sheet: sheet.name,
cell: expectation.cell.toUpperCase(),
expected: expectation.equals,
actual,
});
}
}
return { checkedFormulaCells, violations };
}
+24 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { executeTool } from './file-provenance.js';
import { executeTool, TOOL_DEFS } from './file-provenance.js';
import type { ToolContext, FileProvenanceRecord, FileProvenanceIO } from './core.js';
function rec(partial: Partial<FileProvenanceRecord> & { relPath: string }): FileProvenanceRecord {
@@ -94,6 +94,29 @@ describe('ListWorkspaceFiles', () => {
});
});
it('一覧に日付を出す——最終変更、無ければ最初に見つけた日 (#1280)', async () => {
const ctx = ctxWith([
rec({ relPath: 'input/a.csv', firstSeenAt: '2026-08-01T10:00:00Z', lastModifiedAt: '2026-08-25T09:00:00Z' }),
rec({ relPath: 'input/b.csv', firstSeenAt: '2026-08-02T10:00:00Z' }),
rec({ relPath: 'input/c.csv' }),
]);
const res = await executeTool('ListWorkspaceFiles', {}, ctx);
expect(res!.output).toContain('- 2026-08-25 input/a.csv');
expect(res!.output).toContain('- 2026-08-02 input/b.csv');
expect(res!.output).toContain('- 日付不明 input/c.csv');
});
it('since / until を台帳へそのまま渡す (#1280)', async () => {
const ctx = ctxWith([rec({ relPath: 'input/a.csv' })]) as ToolContext & { _lastFilters?: any };
await executeTool('ListWorkspaceFiles', { since: '2026-08-01', until: '2026-08-25T00:00:00Z' }, ctx);
expect(ctx._lastFilters).toMatchObject({ since: '2026-08-01', until: '2026-08-25T00:00:00Z' });
});
it('日付は道具の引数として宣言されている(宣言が無いと LLM は渡せない)', () => {
const props = (TOOL_DEFS['ListWorkspaceFiles'].function.parameters as any).properties;
expect(Object.keys(props)).toEqual(expect.arrayContaining(['since', 'until', 'createdByTaskId']));
});
it('caps limit at 200', async () => {
const ctx = ctxWith([rec({ relPath: 'output/a.md' })]) as ToolContext & { _lastFilters?: any };
await executeTool('ListWorkspaceFiles', { limit: 9999 }, ctx);
+42 -3
View File
@@ -21,7 +21,32 @@
*/
import { ToolDef } from '../../llm/openai-compat.js';
import type { ToolContext, ToolResult, FileProvenanceRecord } from './core.js';
import { logger } from '../../logger.js';
import type { ToolContext, ToolResult, FileProvenanceRecord, FileProvenanceEvent } from './tool-context.js';
/**
* 台帳への best-effort 書き込み。**記録は付随情報**なので、失敗しても本来の
* 処理(ダウンロード・添付の取り出し)は落とさない。ただし黙って握り潰さず
* ログには出す。
*
* core.ts の `recordProvenanceSafe` とは別に置いてある。あちらは Write / Edit /
* Bash の関所なので git のファイル履歴(`recordFileVersion`)も一緒に回すが、
* 外から持ち込むファイル(DownloadFile の取得物、メールの添付)まで履歴 repo に
* 積むのは狙いが違う。台帳への合流点(`ctx.recordFileProvenance` → Repository の
* `recordProvenance`)は同じ 1 本を通る。
*
* `ctx.recordFileProvenance` は worker が「数値のタスク ID が引ける実行」にだけ
* 束ねる。サブタスクや Gitea issue の実行では未束縛なので、ここは何もしない
* (Write / Edit と同じ既存の制限。この段では広げない)。
*/
export function recordProvenanceBestEffort(ctx: ToolContext, ev: FileProvenanceEvent): void {
if (!ctx.recordFileProvenance) return;
try {
ctx.recordFileProvenance(ev);
} catch (e) {
logger.warn(`[provenance] record skipped rel=${ev.relPath}: ${(e as Error).message}`);
}
}
const GET_DEF: ToolDef = {
type: 'function',
@@ -44,7 +69,7 @@ const LIST_DEF: ToolDef = {
function: {
name: 'ListWorkspaceFiles',
description:
'ワークスペースの既知ファイルを来歴サマリ付きで一覧する(source種別/作成タスク/最終変更タスクで絞り込み可、件数上限あり・本文は返さない)。詳細は ReadToolDoc({ name: "ListWorkspaceFiles" })。',
'ワークスペースの既知ファイルを来歴サマリ付きで一覧する(日付/source種別/作成タスク/最終変更タスクで絞り込み可、件数上限あり・本文は返さない)。詳細は ReadToolDoc({ name: "ListWorkspaceFiles" })。',
parameters: {
type: 'object',
properties: {
@@ -55,6 +80,8 @@ const LIST_DEF: ToolDef = {
},
createdByTaskId: { type: 'number', description: '作成タスク ID で絞り込む。' },
lastModifiedByTaskId: { type: 'number', description: '最終変更タスク ID で絞り込む。' },
since: { type: 'string', description: 'この日以降のファイルだけ(YYYY-MM-DD または ISO 文字列、その日を含む)。' },
until: { type: 'string', description: 'この日以前のファイルだけ(YYYY-MM-DD または ISO 文字列、その日を含む)。' },
includeUnknown: { type: 'boolean', description: 'false で unknown / imported_existing を除外(既定 true)。' },
limit: { type: 'number', description: '最大件数(既定 50、最大 200)。' },
},
@@ -75,6 +102,16 @@ function normalizeRelPath(p: string): string {
return p.trim().replace(/^\.\//, '').replace(/^\/+/, '').replace(/\/+$/, '');
}
/**
* ファイルの「日付」(#1280)。最終変更、無ければ最初に見つけた日付を `YYYY-MM-DD`
* に丸める。since / until の絞り込みも DB 側で同じ式を使っているので、一覧に出た
* 日付をそのまま範囲指定に渡せる。
*/
function effectiveDate(rec: FileProvenanceRecord): string | null {
const raw = rec.lastModifiedAt ?? rec.firstSeenAt;
return raw ? raw.slice(0, 10) : null;
}
/** Format one record compactly (task/job IDs only — never titles/user ids). */
function formatRecord(rec: FileProvenanceRecord): string {
const lines = [
@@ -119,6 +156,8 @@ function list(input: Record<string, unknown>, ctx: ToolContext): ToolResult {
createdByTaskId: typeof input['createdByTaskId'] === 'number' ? (input['createdByTaskId'] as number) : undefined,
lastModifiedByTaskId: typeof input['lastModifiedByTaskId'] === 'number' ? (input['lastModifiedByTaskId'] as number) : undefined,
includeUnknown: input['includeUnknown'] === false ? false : undefined,
since: typeof input['since'] === 'string' ? (input['since'] as string) : undefined,
until: typeof input['until'] === 'string' ? (input['until'] as string) : undefined,
limit,
});
if (records.length === 0) {
@@ -129,7 +168,7 @@ function list(input: Record<string, unknown>, ctx: ToolContext): ToolResult {
for (const r of shown) {
const created = r.createdByTaskId != null ? `task ${r.createdByTaskId}` : '不明';
const modified = r.lastModifiedByTaskId != null ? `task ${r.lastModifiedByTaskId}` : '—';
lines.push(`- ${r.relPath} [${r.sourceKind}] created_by=${created} last_modified_by=${modified}`);
lines.push(`- ${effectiveDate(r) ?? '日付不明'} ${r.relPath} [${r.sourceKind}] created_by=${created} last_modified_by=${modified}`);
}
return { output: lines.join('\n'), isError: false };
}
+309
View File
@@ -0,0 +1,309 @@
/**
* filestore.test.ts — 道具の側(issue #1204 第 2 段)。
*
* 見張るのは 3 つ。
* 1. 関所(agent-access)の答えが、そのまま道具の出力になること。中身を勝手に
* 足したり、断られたのに成功として返したりしないこと。
* 2. 束ねられていない実行では、失敗ではなく「使えない」として先へ進ませること。
* 3. 分類 `filestore` が設定画面に並び、既定では入っていて、管理者が明示的に
* 切れること。
*
* 実際の dispatch(tools/index.ts 経由)でも呼べることまで見る——モジュールを
* 作っただけで index.ts への登録を忘れると、ここが落ちる。
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TOOL_DEFS, TOOL_MODULE, executeTool } from './filestore.js';
import { executeTool as dispatchTool, getToolDefs } from './index.js';
import type { ToolContext } from './tool-context.js';
import type {
FileStoreAgentIO,
FileStoreListResult,
FileStoreLocationSummary,
} from '../../filestore/agent-access.js';
import { resolveWorkspaceTools } from '../workspace-tool-policy.js';
import {
_resetToolCategoryCacheForTests,
getToolCategoryMap,
listToolCategories,
} from './tool-categories.js';
import { _resetToolModuleCacheForTests } from './module-manifest.js';
beforeEach(() => {
_resetToolCategoryCacheForTests();
_resetToolModuleCacheForTests();
});
const LOCATION: FileStoreLocationSummary = {
id: 'loc-1',
provider: 'microsoft',
path: '/Shared Documents/請求書',
driveName: 'Shared Documents',
siteWebUrl: 'https://contoso.sharepoint.com/sites/sales',
connectionName: '営業部 SharePoint',
};
/** Box には「ライブラリ」に当たる階層が無いので driveName が空になる。 */
const BOX_LOCATION: FileStoreLocationSummary = {
id: 'loc-b',
provider: 'box',
path: '/経理/請求書',
driveName: '',
siteWebUrl: 'https://app.box.com/folder/12345',
connectionName: '経理の Box',
};
function ctxWith(io: FileStoreAgentIO | undefined): ToolContext {
return { workspacePath: '/tmp/x', editAllowed: false, fileStore: io } as ToolContext;
}
function io(over: Partial<FileStoreAgentIO>): FileStoreAgentIO {
return {
listLocations: async () => [],
listFiles: async () => ({ ok: false, reason: 'unknown_location' }) as FileStoreListResult,
...over,
};
}
describe('道具の定義', () => {
it('2 つの道具を出す', () => {
expect(Object.keys(TOOL_DEFS).sort()).toEqual(['ListFileStoreFiles', 'ListFileStoreLocations']);
});
it('説明は 1 文で、詳しい手順の在りかを示して終わる', () => {
for (const def of Object.values(TOOL_DEFS)) {
expect(def.function.description).toMatch(/詳細は ReadToolDoc\(\{ name: "\w+" \}\) で取得可能。$/);
}
});
it('分類を自己申告している', () => {
expect(TOOL_MODULE).toMatchObject({ id: 'filestore', category: 'filestore' });
});
});
describe('束ねられていない実行', () => {
it('場所の一覧は、断らずに「使えない」と伝えて先へ進ませる', async () => {
const res = await executeTool('ListFileStoreLocations', {}, ctxWith(undefined));
expect(res!.isError).toBe(false);
expect(res!.output).toContain('使えません');
});
it('ファイルの一覧も同じ', async () => {
const res = await executeTool('ListFileStoreFiles', { locationId: 'loc-1' }, ctxWith(undefined));
expect(res!.isError).toBe(false);
expect(res!.output).toContain('使えません');
});
});
describe('場所の一覧', () => {
it('関所が返した場所を、id つきで並べる', async () => {
const res = await executeTool(
'ListFileStoreLocations',
{},
ctxWith(io({ listLocations: async () => [LOCATION] })),
);
expect(res!.isError).toBe(false);
expect(res!.output).toContain('loc-1');
expect(res!.output).toContain('/Shared Documents/請求書');
expect(res!.output).toContain('営業部 SharePoint');
});
/**
* docs/tools/listfilestorelocations.md が約束している出方をそのまま固定する。
* どの保存先かは行の見た目からしか分からないので、service の行が消えると
* エージェントは SharePoint と Box を取り違える。
*/
it('どの保存先かを service の行で示す', async () => {
const res = await executeTool(
'ListFileStoreLocations',
{},
ctxWith(io({ listLocations: async () => [LOCATION, BOX_LOCATION] })),
);
expect(res!.output).toContain(' service: SharePoint / OneDrive');
expect(res!.output).toContain(' service: Box');
});
it('Box にはライブラリの階層が無いので、library の行を出さない', async () => {
const res = await executeTool(
'ListFileStoreLocations',
{},
ctxWith(io({ listLocations: async () => [BOX_LOCATION] })),
);
expect(res!.output).toContain(' service: Box');
expect(res!.output).not.toContain('library:');
expect(res!.output).toContain(' site: https://app.box.com/folder/12345');
expect(res!.output).toContain(' connection: 経理の Box');
});
it('知らない提供元は、その名前をそのまま出す(空欄にしない)', async () => {
const res = await executeTool(
'ListFileStoreLocations',
{},
ctxWith(
io({
listLocations: async () => [
{ ...BOX_LOCATION, provider: 'dropbox' as FileStoreLocationSummary['provider'] },
],
}),
),
);
expect(res!.output).toContain(' service: dropbox');
});
it('1 件も無ければ、登録の仕方を案内する', async () => {
const res = await executeTool('ListFileStoreLocations', {}, ctxWith(io({})));
expect(res!.isError).toBe(false);
expect(res!.output).toContain('登録されている保存場所はありません');
});
});
describe('ファイルの一覧', () => {
it('関所が返したメタデータだけを並べる', async () => {
const listFiles = vi.fn(
async (): Promise<FileStoreListResult> => ({
ok: true,
listing: {
location: LOCATION,
entries: [
{ name: '2026-07.xlsx', isFolder: false, size: 20480, lastModifiedAt: '2026-07-31T09:00:00Z' },
{ name: '控え', isFolder: true, size: null, lastModifiedAt: null },
],
truncated: false,
},
}),
);
const res = await executeTool('ListFileStoreFiles', { locationId: 'loc-1' }, ctxWith(io({ listFiles })));
expect(listFiles).toHaveBeenCalledWith('loc-1');
expect(res!.isError).toBe(false);
expect(res!.output).toContain('2026-07.xlsx');
expect(res!.output).toContain('20.0 KB');
expect(res!.output).toContain('[フォルダ] 控え');
expect(res!.output).toContain('直下だけ');
});
it('途中までしか取れていないことを、出力の側でも言う', async () => {
const res = await executeTool(
'ListFileStoreFiles',
{ locationId: 'loc-1' },
ctxWith(
io({
listFiles: async () => ({
ok: true,
listing: {
location: LOCATION,
entries: [{ name: 'a.txt', isFolder: false, size: 1, lastModifiedAt: null }],
truncated: true,
},
}),
}),
),
);
expect(res!.output).toContain('これで全部ではありません');
});
/**
* 見出しの括弧には Microsoft ならライブラリ名が入る。Box にはその階層が無く
* driveName が空なので、そのまま出すと `()` になる。保存先の名前で埋める。
*/
it('ライブラリ名が無い Box では、見出しの括弧に保存先の名前を出す', async () => {
const res = await executeTool(
'ListFileStoreFiles',
{ locationId: 'loc-b' },
ctxWith(
io({
listFiles: async () => ({
ok: true,
listing: {
location: BOX_LOCATION,
entries: [{ name: 'a.txt', isFolder: false, size: 1, lastModifiedAt: null }],
truncated: false,
},
}),
}),
),
);
expect(res!.output).toContain('## /経理/請求書(Box)— 1 件');
expect(res!.output).not.toContain('()');
});
it('locationId が空なら、外へ問い合わせずに指定し直させる', async () => {
const listFiles = vi.fn(async () => ({ ok: true }) as FileStoreListResult);
const res = await executeTool('ListFileStoreFiles', { locationId: ' ' }, ctxWith(io({ listFiles })));
expect(res!.isError).toBe(true);
expect(listFiles).not.toHaveBeenCalled();
});
const refusals: Array<[string, string]> = [
['unknown_location', '一覧を取り直し'],
['not_connected', '接続してもらう'],
['reconnect_required', '接続し直して'],
['provider_error', '問い合わせが失敗'],
];
it.each(refusals)('断られた理由(%s)は、次の一手つきで伝える', async (reason, expected) => {
const res = await executeTool(
'ListFileStoreFiles',
{ locationId: 'loc-1' },
ctxWith(io({ listFiles: async () => ({ ok: false, reason }) as FileStoreListResult })),
);
expect(res!.isError).toBe(true);
expect(res!.output).toContain(expected);
});
});
describe('全体の dispatch に載っている', () => {
it('tools/index.ts 経由で呼べる(登録漏れならここが落ちる)', async () => {
const res = await dispatchTool(
'ListFileStoreLocations',
{},
ctxWith(io({ listLocations: async () => [LOCATION] })) as never,
);
expect(res.isError).toBe(false);
expect(res.output).toContain('loc-1');
});
it('既定のワークスペース設定で、LLM に渡す定義にも 2 つとも載る', async () => {
const { allowedTools } = await resolveWorkspaceTools({});
const defs = await getToolDefs(allowedTools, false);
const names = defs.map((d) => d.function.name);
expect(names).toContain('ListFileStoreLocations');
expect(names).toContain('ListFileStoreFiles');
});
it('分類を切ると、LLM に渡す定義から消える', async () => {
const { allowedTools } = await resolveWorkspaceTools({ disabledSafe: ['filestore'] });
const names = (await getToolDefs(allowedTools, false)).map((d) => d.function.name);
expect(names).not.toContain('ListFileStoreLocations');
expect(names).not.toContain('ListFileStoreFiles');
});
});
describe('分類 filestore の入り切り', () => {
it('設定画面のトグルの元になる一覧に並ぶ', async () => {
expect(await listToolCategories()).toContain('filestore');
});
it('道具の分類が filestore になっている', async () => {
const map = await getToolCategoryMap();
expect(map.get('ListFileStoreLocations')).toBe('filestore');
expect(map.get('ListFileStoreFiles')).toBe('filestore');
});
it('既定では使える(接続が無ければどのみち何も返らない)', async () => {
const { allowedTools } = await resolveWorkspaceTools({});
expect(allowedTools).toContain('ListFileStoreLocations');
expect(allowedTools).toContain('ListFileStoreFiles');
});
it('管理者が明示的に切れる', async () => {
const { allowedTools } = await resolveWorkspaceTools({ disabledSafe: ['filestore'] });
expect(allowedTools).not.toContain('ListFileStoreLocations');
expect(allowedTools).not.toContain('ListFileStoreFiles');
});
it('切っても他の分類は巻き添えにしない', async () => {
const { allowedTools } = await resolveWorkspaceTools({ disabledSafe: ['filestore'] });
expect(allowedTools).toContain('Read');
});
});
+179
View File
@@ -0,0 +1,179 @@
/**
* filestore.ts — ListFileStoreLocations / ListFileStoreFiles(issue #1204 第 2 段、
* 第 4 段で Box も同じ口から出るようになった)。
*
* 利用者が画面で「ここだけ見てよい」と決めて記録したフォルダについて、その
* **直下の一覧だけ**をエージェントに見せる。名前・大きさ・更新日時・フォルダか
* どうかまで。**中身は 1 バイトも取ってこない**——ファイルを読むのは第 3 段で、
* この群にはそのための関数が無い。
*
* 境界はここには無い。持ち主の確認・スペースの可視性・接続の有効/無効・資格
* 情報の取り出しは、すべて `ctx.fileStore`(src/filestore/agent-access.ts)の
* 側に寄せてある。ワーカーがジョブの**持ち主**とスペースで束ねて渡すので、
* ここから対象の人を選ぶことはできない。束ねられていない実行(持ち主やスペースが
* 決まらない実行、鍵が未設定の環境)では ctx.fileStore が無く、その場合は
* 断るのではなく「使えない」と伝えて先へ進ませる。
*/
import { ToolDef } from '../../llm/openai-compat.js';
import type { ToolModuleManifest } from './module-manifest.js';
import type { ToolContext, ToolResult } from './tool-context.js';
import type { FileStoreFailureReason, FileStoreLocationSummary } from '../../filestore/agent-access.js';
const LIST_LOCATIONS_DEF: ToolDef = {
type: 'function',
function: {
name: 'ListFileStoreLocations',
description:
'利用者が SharePoint / OneDrive / Box で読み取りを許可した保存場所(記録済みフォルダ)を一覧する。詳細は ReadToolDoc({ name: "ListFileStoreLocations" }) で取得可能。',
parameters: { type: 'object', properties: {} },
},
};
const LIST_FILES_DEF: ToolDef = {
type: 'function',
function: {
name: 'ListFileStoreFiles',
description:
'記録済みフォルダの直下にあるファイルとフォルダを、名前・大きさ・更新日時だけ一覧する(中身は取得しない)。詳細は ReadToolDoc({ name: "ListFileStoreFiles" }) で取得可能。',
parameters: {
type: 'object',
properties: {
locationId: {
type: 'string',
description: 'ListFileStoreLocations が返した保存場所の id。',
},
},
required: ['locationId'],
},
},
};
export const TOOL_DEFS: Record<string, ToolDef> = {
ListFileStoreLocations: LIST_LOCATIONS_DEF,
ListFileStoreFiles: LIST_FILES_DEF,
};
export const TOOL_MODULE: ToolModuleManifest = {
id: 'filestore',
category: 'filestore',
};
// 分類が切られている場合はそもそもこの道具が一覧に出ないので、ここに来る理由には
// 挙げない。到達するのは「この環境で外部ファイル保存の仕組み自体が動いていない」か
// 「この実行に持ち主とスペースが結び付いていない」かのどちらか。
const NO_CONTEXT_MSG =
'外部ファイル保存(SharePoint / OneDrive / Box)はこの実行では使えません。この環境で機能が有効になっていないか、この実行が特定の利用者とスペースに結び付いていません。手元のワークスペースにあるファイルで進めてください。';
const FAILURE_MSG: Record<FileStoreFailureReason, string> = {
unknown_location:
'その id の保存場所は見つかりません。ListFileStoreLocations で一覧を取り直し、そこに出た id を使ってください。',
not_connected:
'この保存場所の接続がまだ済んでいません。利用者に、設定画面の「外部ファイル保存」から接続してもらう必要があります。',
reconnect_required:
'この保存場所の認可の期限が切れており、自動では更新できません。利用者に、設定画面の「外部ファイル保存」から接続し直してもらう必要があります。',
provider_error:
'保存場所の提供元への問い合わせが失敗しました。時間をおいて試すか、他の手段で進めてください。',
};
function formatSize(size: number | null): string {
if (size === null) return '—';
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
/** どの保存先か。Box には「ライブラリ」に当たる階層が無いのでここで補う。 */
const PROVIDER_LABEL: Record<string, string> = {
microsoft: 'SharePoint / OneDrive',
box: 'Box',
};
function providerLabel(provider: string): string {
return PROVIDER_LABEL[provider] ?? provider;
}
function formatLocation(loc: FileStoreLocationSummary): string {
const lines = [
`- id: ${loc.id}`,
` service: ${providerLabel(loc.provider)}`,
` path: ${loc.path}`,
];
// Box は folder id ひとつで場所が決まるので、上位の入れ物の名前が無い。
if (loc.driveName) lines.push(` library: ${loc.driveName}`);
lines.push(` site: ${loc.siteWebUrl}`, ` connection: ${loc.connectionName}`);
return lines.join('\n');
}
async function listLocations(ctx: ToolContext): Promise<ToolResult> {
const io = ctx.fileStore;
if (!io) return { output: NO_CONTEXT_MSG, isError: false };
const locations = await io.listLocations();
if (locations.length === 0) {
return {
output:
'## 外部ファイル保存の場所\n\n登録されている保存場所はありません。利用者が設定画面の「外部ファイル保存」でフォルダを登録すると、ここに出ます。',
isError: false,
};
}
return {
output: [
`## 外部ファイル保存の場所 — ${locations.length} 件`,
'',
...locations.map(formatLocation),
'',
'中身の取得はできません。一覧できるのは、それぞれの場所の直下にある名前と大きさ・更新日時だけです。',
].join('\n'),
isError: false,
};
}
async function listFiles(input: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
const io = ctx.fileStore;
if (!io) return { output: NO_CONTEXT_MSG, isError: false };
const raw = input['locationId'];
const locationId = typeof raw === 'string' ? raw.trim() : '';
if (!locationId) {
return {
output: 'locationId が空です。ListFileStoreLocations が返した id を指定してください。',
isError: true,
};
}
const result = await io.listFiles(locationId);
if (!result.ok) {
return { output: FAILURE_MSG[result.reason], isError: true };
}
const { location, entries, truncated } = result.listing;
const where = location.driveName || providerLabel(location.provider);
const header = `## ${location.path}(${where})— ${entries.length} 件${truncated ? '(上限に達したため、これで全部ではありません)' : ''}`;
if (entries.length === 0) {
return { output: `${header}\n\nこのフォルダは空です。`, isError: false };
}
const lines = entries.map((e) =>
e.isFolder
? `- [フォルダ] ${e.name} updated=${e.lastModifiedAt ?? '—'}`
: `- ${e.name} size=${formatSize(e.size)} updated=${e.lastModifiedAt ?? '—'}`,
);
return {
output: [
header,
'',
...lines,
'',
'ここに出るのは直下だけです。フォルダの中は開けません。中身の取得もできません。',
].join('\n'),
isError: false,
};
}
export async function executeTool(
name: string,
input: Record<string, unknown>,
ctx: ToolContext,
): Promise<ToolResult | null> {
if (name === 'ListFileStoreLocations') return listLocations(ctx);
if (name === 'ListFileStoreFiles') return listFiles(input, ctx);
return null;
}
+76
View File
@@ -0,0 +1,76 @@
/**
* 画像認識の呼び出しに資格情報を載せる (#1022)。
*
* 生 fetch のまま OpenAICompatClient には寄せない(クライアントはストリーミング
* 専用のため)。鍵は ToolsConfig 経由で届き、あるときだけ Authorization を付ける。
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { callVisionModel } from './image.js';
import type { ToolContext } from './core.js';
function ctx(toolsConfig: Record<string, unknown>): ToolContext {
return { workspacePath: '/tmp', userId: 'u1', toolsConfig } as unknown as ToolContext;
}
function okFetch() {
return vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
json: async () => ({ model: 'qwen2-vl', choices: [{ message: { content: 'a cat' } }] }),
});
}
function headersOfLastCall(f: ReturnType<typeof okFetch>): Record<string, string> {
return (f.mock.calls[0]![1] as { headers: Record<string, string> }).headers;
}
afterEach(() => { vi.unstubAllGlobals(); });
describe('callVisionModel の認証ヘッダー', () => {
it('鍵があるとき Bearer を付ける', async () => {
const f = okFetch();
vi.stubGlobal('fetch', f);
const res = await callVisionModel('data:image/png;base64,xxx', 'describe', ctx({
visionBaseUrl: 'https://gw.example/v1', visionModel: 'qwen2-vl', visionApiKey: 'sk-vision',
}));
expect(res.isError).toBe(false);
expect(headersOfLastCall(f)['Authorization']).toBe('Bearer sk-vision');
});
it('鍵が無いときは Authorization を付けない', async () => {
const f = okFetch();
vi.stubGlobal('fetch', f);
await callVisionModel('data:image/png;base64,xxx', 'describe', ctx({
visionBaseUrl: 'http://vision-host:11434/v1', visionModel: 'qwen2-vl',
}));
expect(headersOfLastCall(f)['Authorization']).toBeUndefined();
expect(headersOfLastCall(f)['Content-Type']).toBe('application/json');
});
it('空文字の鍵は未設定として扱う(空 Bearer を送らない)', async () => {
const f = okFetch();
vi.stubGlobal('fetch', f);
await callVisionModel('data:image/png;base64,xxx', '', ctx({
visionBaseUrl: 'http://vision-host:11434/v1', visionModel: 'qwen2-vl', visionApiKey: ' ',
}));
expect(headersOfLastCall(f)['Authorization']).toBeUndefined();
});
});
it('uses the canonical image endpoint, model, token limit and API key on the actual tool request', async () => {
const f = okFetch();
vi.stubGlobal('fetch', f);
const res = await callVisionModel('data:image/png;base64,xxx', 'describe', ctx({
modalityEndpoints: { image: { baseUrl: 'https://image.test/v1', model: 'image-model', apiKey: 'image-private', maxTokens: 123 } },
}));
expect(res.isError).toBe(false);
expect(f.mock.calls[0]![0]).toBe('https://image.test/v1/chat/completions');
expect(headersOfLastCall(f).Authorization).toBe('Bearer image-private');
expect(JSON.parse(f.mock.calls[0]![1].body)).toMatchObject({ model: 'image-model', max_tokens: 123 });
});
+72 -1
View File
@@ -2,6 +2,7 @@ import * as fs from 'fs';
import * as path from 'path';
import { tmpdir } from 'os';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import sharp from 'sharp';
import type { ToolContext } from './core.js';
import { executeTool } from './image.js';
@@ -54,6 +55,76 @@ describe('image tools', () => {
expect(result?.images?.[0]?.label).toBe('input/capture.jpg');
});
// ページの魚拓(issue #1097)が溜める形式は、そのまま読める側に揃える。
it('モデルが直接受け取れない形式は PNG に起こして渡す(TIFF)', async () => {
workspacePath = makeWorkspace();
fs.mkdirSync(path.join(workspacePath, 'input'), { recursive: true });
const tiff = await sharp({
create: { width: 48, height: 48, channels: 3, background: { r: 10, g: 20, b: 30 } },
})
.tiff()
.toBuffer();
fs.writeFileSync(path.join(workspacePath, 'input', 'scan.tiff'), tiff);
const result = await executeTool('ReadImage', { file_path: 'input/scan.tiff' }, makeContext(workspacePath));
expect(result?.isError).toBe(false);
const dataUrl = result?.images?.[0]?.dataUrl ?? '';
expect(dataUrl.startsWith('data:image/png;base64,')).toBe(true);
expect((await sharp(Buffer.from(dataUrl.split(',')[1]!, 'base64')).metadata()).width).toBe(48);
});
it('PNG を格納した .ico はその PNG を取り出して渡す', async () => {
workspacePath = makeWorkspace();
fs.mkdirSync(path.join(workspacePath, 'input'), { recursive: true });
const payload = await sharp({
create: { width: 32, height: 32, channels: 3, background: { r: 200, g: 100, b: 50 } },
})
.png()
.toBuffer();
const header = Buffer.alloc(6);
header.writeUInt16LE(1, 2); // type=icon
header.writeUInt16LE(1, 4); // 1 件
const entry = Buffer.alloc(16);
entry[0] = 32; // width
entry[1] = 32; // height
entry.writeUInt16LE(1, 4); // color planes
entry.writeUInt16LE(32, 6); // bits per pixel
entry.writeUInt32LE(payload.length, 8);
entry.writeUInt32LE(22, 12); // offset
fs.writeFileSync(
path.join(workspacePath, 'input', 'favicon.ico'),
Buffer.concat([header, entry, payload]),
);
const result = await executeTool('ReadImage', { file_path: 'input/favicon.ico' }, makeContext(workspacePath));
expect(result?.isError).toBe(false);
const dataUrl = result?.images?.[0]?.dataUrl ?? '';
expect(Buffer.from(dataUrl.split(',')[1]!, 'base64').equals(payload)).toBe(true);
});
it('BMP を格納した古い .ico は読めない理由を返す(黙って壊さない)', async () => {
workspacePath = makeWorkspace();
fs.mkdirSync(path.join(workspacePath, 'input'), { recursive: true });
const header = Buffer.alloc(6);
header.writeUInt16LE(1, 2);
header.writeUInt16LE(1, 4);
const entry = Buffer.alloc(16);
entry[0] = 16;
entry[1] = 16;
entry.writeUInt32LE(40, 8);
entry.writeUInt32LE(22, 12);
const dib = Buffer.alloc(40);
dib.writeUInt32LE(40, 0); // BITMAPINFOHEADER
fs.writeFileSync(path.join(workspacePath, 'input', 'old.ico'), Buffer.concat([header, entry, dib]));
const result = await executeTool('ReadImage', { file_path: 'input/old.ico' }, makeContext(workspacePath));
expect(result?.isError).toBe(true);
expect(result?.output).toContain('PNG を格納していない');
});
it('rejects ReadImage when vlmEnabled is false', async () => {
workspacePath = makeWorkspace();
fs.mkdirSync(path.join(workspacePath, 'input'), { recursive: true });
@@ -64,7 +135,7 @@ describe('image tools', () => {
}, makeContext(workspacePath, { vlmEnabled: false }));
expect(result?.isError).toBe(true);
expect(result?.output).toContain('VLM-enabled worker');
expect(result?.output).toContain('image-capable worker');
});
describe('AnnotateImage', () => {
+243 -24
View File
@@ -1,15 +1,45 @@
import { getImageEndpoint } from '../../config-media.js';
import * as fs from 'fs';
import * as path from 'path';
import sharp from 'sharp';
import { ToolDef } from '../../llm/openai-compat.js';
import type { ToolContext, ToolResult } from './core.js';
import {
measurementsToCallDetail,
resolveCompletionMeasurements,
ToolDef,
} from '../../llm/openai-compat.js';
import type { ToolContext, ToolResult } from './tool-context.js';
import { resolveAndGuard, resolveOutputPathWithin } from './core.js';
import { logger } from '../../logger.js';
import { recordLlmUsage } from '../../llm/usage-recorder.js';
import { readGatewayBackendId } from '../../llm/gateway-headers.js';
import { acquireSlot, releaseSlot, SlotWaitAbortedError, type SlotHandle } from '../worker-slots.js';
import type { VisionWorkerTarget } from '../vision-offload.js';
// --- Supported image extensions ---
const SUPPORTED_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp']);
/** そのままモデルへ渡せる形式(データ URL の中身を作り替えない) */
const DIRECT_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp']);
/**
* PNG に起こしてから渡す形式。
*
* ページの魚拓(issue #1097)は SVG・AVIF・TIFF・ICO も溜める。利用者が #1093 で
* 名指ししたのは「アイコンとか SVG とか」で、溜めておいて読めないのでは意味がない。
* モデルはこれらを直接受け取れないので、こちら側で PNG に起こす。
*/
const CONVERTED_EXTENSIONS = new Set(['svg', 'avif', 'tiff', 'tif', 'ico']);
const SUPPORTED_EXTENSIONS = new Set([...DIRECT_EXTENSIONS, ...CONVERTED_EXTENSIONS]);
/**
* 三点セット (tools.vision_*) の接続先を直接叩いたときの所在 (#1117)。
*
* あの接続先は llm.workers のワーカーとは別の機械。実行側のワーカー id を書くと、
* そのワーカーでは動くはずのないモデル名が、そのワーカーの実績として集計される。
* かといって所在を落とすと「誰も使っていない」ように見えて費用も負荷も追えない。
* 固定の名前に寄せ、どの接続先かは route に残す。
*/
export const VISION_ENDPOINT_WORKER_ID = 'vision-endpoint';
const DEFAULT_IMAGE_PROMPT = '画像の内容を日本語で説明してください。';
// Normalize extension for MIME type (jpg -> jpeg)
@@ -173,12 +203,12 @@ const READIMAGE_DEF: ToolDef = {
type: 'function',
function: {
name: 'ReadImage',
description: '画像ファイルを LLM に直接渡して内容を認識させる(VLM 対応 worker のみ)。詳細は ReadToolDoc({ name: "ReadImage" })。',
description: '画像ファイルの内容を認識させる。画像を扱えないワーカーでは対応ワーカーへ回した 1 回きりの説明が返るため prompt に知りたいことを書く。詳細は ReadToolDoc({ name: "ReadImage" })。',
parameters: {
type: 'object',
properties: {
file_path: { type: 'string', description: 'workspace 内の画像ファイルパス' },
prompt: { type: 'string', description: '画像について質問したいこと(省略可)' },
prompt: { type: 'string', description: '画像の何を知りたいか。回した実行では追い質問ができないので、必要な観点をここに全部書く' },
},
required: ['file_path'],
},
@@ -231,10 +261,49 @@ export const TOOL_DEFS: Record<string, ToolDef> = {
// --- Tool execution ---
export function resolveImagePath(
/**
* ICO は sharp(libvips)が読めない。中身が PNG のもの——いまの web のアイコンの
* ほとんど——は、格納されている PNG をそのまま取り出せば読める。
* BMP を格納した古い ICO はここでは取り出せない(null を返す)。
*/
function extractIcoPngPayload(buf: Buffer): Buffer | null {
if (buf.length < 22 || buf.readUInt16LE(0) !== 0 || buf.readUInt16LE(2) !== 1) return null;
const count = buf.readUInt16LE(4);
let best: { size: number; offset: number; pixels: number } | null = null;
for (let i = 0; i < count; i++) {
const entry = 6 + i * 16;
if (entry + 16 > buf.length) break;
const width = buf[entry] === 0 ? 256 : buf[entry]!;
const height = buf[entry + 1] === 0 ? 256 : buf[entry + 1]!;
const size = buf.readUInt32LE(entry + 8);
const offset = buf.readUInt32LE(entry + 12);
if (offset + size > buf.length) continue;
const pixels = width * height;
if (!best || pixels > best.pixels) best = { size, offset, pixels };
}
if (!best) return null;
const payload = buf.subarray(best.offset, best.offset + best.size);
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47];
if (payload.length < 4 || !PNG_SIGNATURE.every((b, i) => payload[i] === b)) return null;
return payload;
}
/** モデルが直接受け取れない形式を PNG に起こす */
async function toPngBuffer(buf: Buffer, ext: string): Promise<Buffer> {
if (ext === 'ico') {
const payload = extractIcoPngPayload(buf);
if (!payload) {
throw new Error('この .ico は PNG を格納していないため読めません(BMP 格納の古い形式)');
}
return payload;
}
return await sharp(buf).png().toBuffer();
}
export async function resolveImagePath(
filePath: string,
ctx: ToolContext,
): ToolResult | { resolved: string; ext: string; dataUrl: string } {
): Promise<ToolResult | { resolved: string; ext: string; dataUrl: string }> {
let resolved: string;
try {
resolved = resolveAndGuard(ctx.workspacePath, filePath);
@@ -250,36 +319,74 @@ export function resolveImagePath(
};
}
let base64data: string;
let buf: Buffer;
try {
const buf = fs.readFileSync(resolved);
base64data = buf.toString('base64');
buf = fs.readFileSync(resolved);
} catch (e) {
return { output: `Failed to read image: ${(e as Error).message}`, isError: true };
}
if (CONVERTED_EXTENSIONS.has(ext)) {
try {
const png = await toPngBuffer(buf, ext);
return { resolved, ext, dataUrl: `data:image/png;base64,${png.toString('base64')}` };
} catch (e) {
return {
output: `Failed to convert .${ext} image for the model: ${(e as Error).message}`,
isError: true,
};
}
}
const mimeExt = extToMime(ext);
return {
resolved,
ext,
dataUrl: `data:image/${mimeExt};base64,${base64data}`,
dataUrl: `data:image/${mimeExt};base64,${buf.toString('base64')}`,
};
}
/**
* この呼び出しの宛先を決める(issue #1056)。3 通りしかない。
*
* 1. 実行中のワーカーが画像を扱える → 従来どおり自分自身(toolsConfig は
* buildVisionToolsConfig が自分の endpoint に差し替え済み)。回さない。
* 2. 回せる設定で候補が居る → そのワーカー。呼び出しのたびに選び直す。
* 3. それ以外 → 従来の三点セット(tools.vision_*)。後方互換。
*
* ここが唯一の分岐点。ReadImage の委譲も PDF の OCR フォールバックも同じ道を
* 通るので、宛先の決め方が 2 か所に増えない。
*/
async function resolveVisionTarget(ctx: ToolContext): Promise<VisionWorkerTarget | null> {
if (ctx.vlmEnabled) return null;
return ctx.resolveVisionWorker?.() ?? null;
}
export async function callVisionModel(
dataUrl: string,
prompt: string,
ctx: ToolContext,
): Promise<ToolResult> {
// Read synchronously so an in-flight call cannot observe a later loop iteration.
const iteration = ctx.iteration;
const finalPrompt = prompt.trim() || DEFAULT_IMAGE_PROMPT;
const toolsConfig = ctx.toolsConfig ?? {};
const visionModel = toolsConfig.visionModel ?? 'qwen2-vl:8b-instruct';
// Neutral placeholder — set tools.vision_base_url in config.yaml for a real endpoint.
const imageEndpoint = getImageEndpoint(toolsConfig);
const target = await resolveVisionTarget(ctx);
const visionModel = target ? (target.model || 'qwen2-vl:8b-instruct') : (imageEndpoint.model ?? 'qwen2-vl:8b-instruct');
// Neutral placeholder — set tools.modality_endpoints.image.base_url in config.yaml for a real endpoint.
// (Workers usually override this with their own endpoint; see worker.ts)
const visionBaseUrl = toolsConfig.visionBaseUrl ?? 'http://10.0.0.10:11434/v1';
const visionTimeout = (toolsConfig.visionTimeout ?? 60) * 1000;
const visionMaxTokens = toolsConfig.visionMaxTokens ?? 1024;
const visionBaseUrl = target ? target.endpoint : (imageEndpoint.baseUrl ?? 'http://10.0.0.10:11434/v1');
const visionTimeout = (imageEndpoint.timeout ?? 60) * 1000;
const visionMaxTokens = imageEndpoint.maxTokens ?? 1024;
// 認証必須の接続先(ゲートウェイ / API キーを要求するサーバー)に向いている場合、
// これが無いと常に 401 になる(#1022)。空文字は未設定として扱う。
//
// 回した呼び出しでは **宛先ワーカーの鍵だけ**を使う。無ければ Authorization を
// 付けない。tools.vision_api_key に落とすと、その鍵は宛先と無関係のホスト向けに
// 設定されたものなので、本来と違う先へ飛ぶ。
const visionApiKey = target ? target.apiKey : (imageEndpoint.apiKey?.trim() || undefined);
const requestBody = {
model: visionModel,
messages: [
@@ -296,13 +403,48 @@ export async function callVisionModel(
logger.debug(`[ReadImage] calling ${visionBaseUrl}/chat/completions model=${visionModel}`);
// 回した呼び出しは既存のスロット台帳から枠を取る。取らないと
// max_concurrency: 1 のワーカーへ全ジョブから無制限に同時リクエストが飛ぶ。
//
// ただし**宛先が自分自身のときは取らない**。自分のジョブ枠を握ったまま
// 自分に枠を要求すると、上限 1 台で永久に待つ(自己デッドロック)。
let slot: SlotHandle | null = null;
if (target && target.workerId !== ctx.workerId) {
try {
slot = await acquireSlot(
target.workerId,
target.maxConcurrency,
// 依頼元の利用者を渡す。落とすと台帳の写しがこの枠を読み飛ばし
// (利用者の付かない枠は写しに載らない)、宛先ワーカーの「使用中」が
// 空欄のままになる。実行中ジョブの一覧の側も、あちらが結び付けるのは
// ジョブを実行しているワーカーなので、ここは拾われない(issue #1114)。
//
// 落とし方は辞書・文書チャットと同じ「表示名 → id → 不明」。
{ kind: 'vision', user: ctx.userDisplayName?.trim() || ctx.userId || 'unknown' },
{ signal: ctx.abortSignal },
);
} catch (e) {
if (e instanceof SlotWaitAbortedError) {
return { output: 'Vision API call cancelled while waiting for the vision worker', isError: true };
}
throw e;
}
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), visionTimeout);
// 中断シグナルを実際の fetch まで繋ぐ。繋がっていないと、中断を押しても
// タイムアウトぶん(PDF ならページ数ぶん)止まったままになる。
const onCancel = () => controller.abort();
if (ctx.abortSignal?.aborted) controller.abort();
else ctx.abortSignal?.addEventListener('abort', onCancel, { once: true });
try {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (visionApiKey) headers['Authorization'] = `Bearer ${visionApiKey}`;
const response = await fetch(`${visionBaseUrl}/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers,
body: JSON.stringify(requestBody),
signal: controller.signal,
});
@@ -315,7 +457,12 @@ export async function callVisionModel(
const json = (await response.json()) as {
model?: string;
choices?: Array<{ message?: { content?: string } }>;
usage?: { prompt_tokens?: number; completion_tokens?: number };
usage?: {
prompt_tokens?: number;
completion_tokens?: number;
prompt_tokens_details?: { cached_tokens?: unknown };
};
timings?: Record<string, unknown>;
};
const content = json.choices?.[0]?.message?.content;
@@ -325,26 +472,83 @@ export async function callVisionModel(
// This vision call is a raw fetch outside OpenAICompatClient, so it would
// otherwise miss the per-user usage ledger. Record it directly (issue
// #499). Vision uses its own endpoint directly (never the gateway).
// #499).
//
// 所在は捏造しない(不変条件 D10-2): visionBaseUrl がゲートウェイを指している
// とき、その host をそのまま所在にすると「直結の backend」として記録され、
// 複数 backend の計測が同一系列に混ざる。
//
// ゲートウェイ判定の手がかりは 2 つあり、両方を見る。入口が 2 本あるため
// 片方だけでは塞げない:
// - visionViaGateway: vlm worker の endpoint 経由(worker.ts が proxy 設定から渡す)
// - 応答ヘッダー: config.yaml の tools.vision_base_url に直接ゲートウェイを
// 書いた経路。ここには worker の設定が届かないので、ゲートウェイが必ず付ける
// backend ヘッダーの有無で判定する
// ヘッダーが取れなければ backend は 'unknown'(ゲートウェイの host は焼き込まない)。
//
// 回した呼び出しでは宛先ワーカーの接続種別から立てる。実行中ワーカーの
// toolsConfig.visionViaGateway は宛先と無関係なので使わない。
const backendId = readGatewayBackendId(response.headers);
const viaGateway = (target ? target.viaGateway : toolsConfig.visionViaGateway === true) || backendId !== null;
let route = 'unknown';
try { route = new URL(visionBaseUrl).host || 'unknown'; } catch { /* keep 'unknown' */ }
if (viaGateway) {
route = backendId ?? 'unknown';
} else {
try { route = new URL(visionBaseUrl).host || 'unknown'; } catch { /* keep 'unknown' */ }
}
recordLlmUsage({
userId: ctx.userId ?? 'local',
source: 'direct',
source: viaGateway ? 'gateway' : 'direct',
model: json.model || visionModel,
route,
tokensIn: json.usage?.prompt_tokens ?? 0,
tokensOut: json.usage?.completion_tokens ?? 0,
detail: {
purpose: 'vision',
...(ctx.jobId != null ? { jobId: ctx.jobId } : {}),
...(ctx.rootTaskId !== undefined ? { rootTaskId: ctx.rootTaskId } : {}),
...(ctx.pieceName !== undefined ? { piece: ctx.pieceName } : {}),
...(ctx.movementName !== undefined ? { movement: ctx.movementName } : {}),
...(iteration !== undefined ? { iteration } : {}),
// 所在は宛先で記録する。回したのに実行側の id を書くと、経路だけ宛先・
// ワーカー id だけ実行側という一番気づきにくい食い違いになる。
//
// 宛先が居ない=三点セット (tools.vision_*) の接続先を直接叩く場合は、
// **実行側のワーカーに付けない** (#1117)。あちらはワーカーとは別の機械で、
// そのワーカーでは動くはずのないモデル名が、そのワーカーの実績として
// 集計されてしまう (利用者が使用量タブで見つけた症状そのもの)。
// 消すと「誰も使っていない」ように見えて費用も負荷も追えなくなるので、
// 接続先を表す固定の名前に寄せる。どの接続先かは route に残る。
workerId: target?.workerId ?? VISION_ENDPOINT_WORKER_ID,
...(ctx.contextLimitTokens !== undefined
? { contextLimitTokens: ctx.contextLimitTokens }
: {}),
// 委譲の中で走った画像認識は、その実行のグラフに乗せる。落とすと
// 親の線に混ざり、圧縮やレビューについて直したのと同じ誤りになる。
...(ctx.delegateRunId !== undefined ? { delegateRunId: ctx.delegateRunId } : {}),
// ストリーミングしない呼び出しでも測定値は返る(実機確認済み)。
// 判定は解決器に任せ、ここでは値を運ぶだけにする。
...measurementsToCallDetail(resolveCompletionMeasurements(json)),
// 値は明細テーブルまで運ぶが、台帳の prefill 集計には数えない。
// この生 fetch は PR 前まで prefill 時間を運んでいなかったので、
// 素通しすると台帳 2 列の母集団が黙って広がる(issue #1065)。
excludeFromLedgerPrefill: true,
},
});
return { output: content, isError: false };
} catch (e) {
if ((e as Error).name === 'AbortError') {
if (ctx.abortSignal?.aborted) {
return { output: 'Vision API call cancelled', isError: true };
}
return { output: `Vision API timed out after ${visionTimeout / 1000}s`, isError: true };
}
return { output: `Vision API request failed: ${(e as Error).message}`, isError: true };
} finally {
clearTimeout(timer);
ctx.abortSignal?.removeEventListener('abort', onCancel);
releaseSlot(slot);
}
}
@@ -352,16 +556,31 @@ async function executeReadImage(
input: Record<string, unknown>,
ctx: ToolContext,
): Promise<ToolResult> {
if (!ctx.vlmEnabled) {
return { output: 'ReadImage requires VLM-enabled worker (vlm: true)', isError: true };
if (!ctx.vlmEnabled && !ctx.visionOffloadAvailable) {
return { output: 'ReadImage requires an image-capable worker (capabilities.image: true)', isError: true };
}
const filePath = input['file_path'] as string;
const prompt = typeof input['prompt'] === 'string' ? input['prompt'] : '';
const resolvedImage = resolveImagePath(filePath, ctx);
const resolvedImage = await resolveImagePath(filePath, ctx);
if ('isError' in resolvedImage) return resolvedImage;
// 回した実行では、画像そのものは会話に載せない。載せると継続実行で
// 画像入りの履歴が非対応モデルへ再生されて #1049 が再発する。返るのは
// 対応ワーカーが書いた説明文だけ。
if (!ctx.vlmEnabled) {
const answer = await callVisionModel(resolvedImage.dataUrl, prompt, ctx);
if (answer.isError) return answer;
const header = prompt
? `画像を対応ワーカーに読ませました: ${filePath}\n指示: ${prompt}`
: `画像を対応ワーカーに読ませました: ${filePath}`;
return {
output: `${header}\n\n--- 認識結果(1 回きりの問い合わせ。画像そのものは見えていないので、追加で知りたいことがあれば prompt を変えて呼び直す) ---\n${answer.output}`,
isError: false,
};
}
const description = prompt
? `画像を読み込みました: ${filePath}\n指示: ${prompt}`
: `画像を読み込みました: ${filePath}`;
@@ -0,0 +1,425 @@
/**
* 画像認識を対応ワーカーへ回した呼び出し(issue #1056)。
*
* ここで固定するのは、設定の値が**実際にリクエストに載るところ**まで。
* 純関数だけ固めて呼び出しの 1 行が無検査、という穴を作らない。
*
* - 宛先の endpoint / model / 鍵が wire に載ること(鍵は宛先のものだけ)
* - スロットを取ること/自分自身のときは取らないこと
* - 中断シグナルが fetch まで繋がっていること
* - 計測の所在が宛先のワーカー id になること
* - 委譲では画像そのものが返らないこと
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { callVisionModel, executeTool, VISION_ENDPOINT_WORKER_ID } from './image.js';
import type { ToolContext } from './core.js';
import type { VisionWorkerTarget } from '../vision-offload.js';
import { resetSlotsForTest, tryAcquireSlot, usedSlots, countSlotsByKind } from '../worker-slots.js';
import { setLlmUsageRecorder, type LlmUsageEvent } from '../../llm/usage-recorder.js';
import { collectWorkerStatuses } from '../../bridge/dashboard-workers.js';
import type { Repository } from '../../db/repository.js';
import type { WorkerDef } from '../../config.js';
const DATA_URL = 'data:image/png;base64,xxx';
const target = (overrides: Partial<VisionWorkerTarget> = {}): VisionWorkerTarget => ({
workerId: 'vision-a',
endpoint: 'http://vision-a:11434/v1',
model: 'qwen-vl',
apiKey: undefined,
viaGateway: false,
maxConcurrency: 1,
...overrides,
});
/** 委譲が効いている実行の ToolContext。vlmEnabled は**偽のまま**。 */
function offloadCtx(t: VisionWorkerTarget | null, overrides: Partial<ToolContext> = {}): ToolContext {
return {
workspacePath: '/tmp',
userId: 'u1',
vlmEnabled: false,
visionOffloadAvailable: t !== null,
resolveVisionWorker: () => t,
toolsConfig: {
visionBaseUrl: 'http://legacy-vision:11434/v1',
visionModel: 'legacy-model',
visionApiKey: 'sk-legacy',
visionViaGateway: true,
},
...overrides,
} as unknown as ToolContext;
}
function okFetch(content = 'a cat', extra: Record<string, unknown> = {}) {
return vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
json: async () => ({ model: 'qwen-vl', choices: [{ message: { content } }], ...extra }),
});
}
const lastCall = (f: ReturnType<typeof okFetch>) => ({
url: f.mock.calls[0]![0] as string,
init: f.mock.calls[0]![1] as { headers: Record<string, string>; body: string; signal: AbortSignal },
});
beforeEach(() => resetSlotsForTest());
afterEach(() => {
resetSlotsForTest();
setLlmUsageRecorder(null);
vi.unstubAllGlobals();
});
describe('宛先が実際のリクエストに載る', () => {
it('回した呼び出しは宛先の endpoint と model を使う(三点セットではなく)', async () => {
const f = okFetch();
vi.stubGlobal('fetch', f);
await callVisionModel(DATA_URL, 'describe', offloadCtx(target()));
const { url, init } = lastCall(f);
expect(url).toBe('http://vision-a:11434/v1/chat/completions');
expect(JSON.parse(init.body).model).toBe('qwen-vl');
});
it('鍵は宛先ワーカーのものだけを使う', async () => {
const f = okFetch();
vi.stubGlobal('fetch', f);
await callVisionModel(DATA_URL, 'describe', offloadCtx(target({ apiKey: 'sk-vision-a' })));
expect(lastCall(f).init.headers['Authorization']).toBe('Bearer sk-vision-a');
});
it('宛先に鍵が無ければ Authorization を付けない(三点セットの鍵に落とさない)', async () => {
const f = okFetch();
vi.stubGlobal('fetch', f);
// toolsConfig.visionApiKey は 'sk-legacy' が入っている。宛先が入れ替わる
// 経路でこれに落ちると、その鍵が本来と違うホストへ飛ぶ。
await callVisionModel(DATA_URL, 'describe', offloadCtx(target({ apiKey: undefined })));
expect(lastCall(f).init.headers['Authorization']).toBeUndefined();
});
it('回さない実行では従来どおり三点セットを使う', async () => {
const f = okFetch();
vi.stubGlobal('fetch', f);
await callVisionModel(DATA_URL, 'describe', offloadCtx(null));
const { url, init } = lastCall(f);
expect(url).toBe('http://legacy-vision:11434/v1/chat/completions');
expect(init.headers['Authorization']).toBe('Bearer sk-legacy');
});
it('実行中のワーカーが画像を扱えるなら、宛先を解決せず自分自身へ送る', async () => {
const f = okFetch();
vi.stubGlobal('fetch', f);
const resolveVisionWorker = vi.fn(() => target());
await callVisionModel(DATA_URL, 'describe', offloadCtx(null, {
vlmEnabled: true,
resolveVisionWorker,
} as Partial<ToolContext>));
expect(resolveVisionWorker).not.toHaveBeenCalled();
expect(lastCall(f).url).toBe('http://legacy-vision:11434/v1/chat/completions');
});
});
describe('計測の所在', () => {
it('回した呼び出しは宛先のワーカー id で記録する', async () => {
const events: LlmUsageEvent[] = [];
setLlmUsageRecorder(e => events.push(e));
vi.stubGlobal('fetch', okFetch());
await callVisionModel(DATA_URL, 'describe', offloadCtx(target(), { workerId: 'text-worker' }));
expect(events[0]!.detail).toMatchObject({ workerId: 'vision-a', purpose: 'vision' });
});
it('ゲートウェイ判定は宛先の接続種別から立てる', async () => {
const events: LlmUsageEvent[] = [];
setLlmUsageRecorder(e => events.push(e));
vi.stubGlobal('fetch', okFetch());
// toolsConfig.visionViaGateway は true。宛先は直結なので direct になる。
await callVisionModel(DATA_URL, 'describe', offloadCtx(target({ viaGateway: false })));
expect(events[0]!.source).toBe('direct');
expect(events[0]!.route).toBe('vision-a:11434');
events.length = 0;
await callVisionModel(DATA_URL, 'describe', offloadCtx(target({ viaGateway: true })));
expect(events[0]!.source).toBe('gateway');
});
it('回さない実行では従来どおり三点セットの申告を使う', async () => {
const events: LlmUsageEvent[] = [];
setLlmUsageRecorder(e => events.push(e));
vi.stubGlobal('fetch', okFetch());
await callVisionModel(DATA_URL, 'describe', offloadCtx(null, { workerId: 'text-worker' }));
expect(events[0]!.source).toBe('gateway');
// #1117: 三点セットの接続先はワーカーとは別の機械。実行側の id を書くと、
// そのワーカーでは動くはずのないモデル名がそのワーカーの実績として集計され、
// 使用量タブに「動いていないはずのモデル」が並ぶ。所在は接続先に寄せる。
expect(events[0]!.detail).toMatchObject({ workerId: VISION_ENDPOINT_WORKER_ID });
expect(events[0]!.detail).not.toMatchObject({ workerId: 'text-worker' });
});
});
describe('同時実行の枠', () => {
it('回した呼び出しは宛先の枠を取り、終わったら返す', async () => {
let observed = -1;
vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => {
observed = countSlotsByKind('vision-a', 'vision');
return { ok: true, headers: new Headers(), json: async () => ({ choices: [{ message: { content: 'ok' } }] }) };
}));
await callVisionModel(DATA_URL, 'describe', offloadCtx(target()));
expect(observed).toBe(1);
expect(usedSlots('vision-a')).toBe(0);
});
it('枠が埋まっていれば空くまで待つ(無制限には飛ばさない)', async () => {
vi.stubGlobal('fetch', okFetch());
const held = tryAcquireSlot('vision-a', 1, { kind: 'job' })!;
expect(held).not.toBeNull();
let settled = false;
const promise = callVisionModel(DATA_URL, 'describe', offloadCtx(target({ maxConcurrency: 1 })))
.then(r => { settled = true; return r; });
await new Promise(resolve => setImmediate(resolve));
expect(settled).toBe(false); // まだ待っている
const { releaseSlot } = await import('../worker-slots.js');
releaseSlot(held);
expect((await promise).isError).toBe(false);
});
it('宛先が自分自身のときは枠を取らない(自己デッドロック回避)', async () => {
vi.stubGlobal('fetch', okFetch());
// 自分のジョブ枠を握った状態(上限 1 台)。ここで自分に枠を要求すると
// 永久に待つ。
expect(tryAcquireSlot('self-worker', 1, { kind: 'job' })).not.toBeNull();
const result = await Promise.race([
callVisionModel(DATA_URL, 'describe', offloadCtx(
target({ workerId: 'self-worker', maxConcurrency: 1 }),
{ workerId: 'self-worker' },
)),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('自己デッドロック')), 300)),
]);
expect(result.isError).toBe(false);
expect(countSlotsByKind('self-worker', 'vision')).toBe(0);
});
});
/**
* 誰が使っているかが**画面へ渡る写しに載るところまで**(issue #1114)。
*
* スロットを直接叩くだけでは足りない。実際に落ちていたのは「枠は取るが利用者を
* 渡さない」1 行で、台帳の写しは利用者の付かない枠を読み飛ばす。実行中ジョブの
* 一覧は**実行側**のワーカーにしか紐づかないので、回した先はそちらにも載らない。
* だから画像認識の呼び出しから状態一覧の組み立てまでを通しで見る。
*/
describe('使用中の表示に載る', () => {
/** 状態一覧が触るのはこの 2 つだけ。ジョブの一覧は空(回した先は載らない)。 */
const emptyJobsRepo = {
listRunningJobOwnersByWorker: () => new Map<string, Array<{ user: string; kind: string }>>(),
isWorkerBusy: () => false,
} as unknown as Repository;
const workerDefs = [
{ id: 'vision-a', endpoint: 'http://vision-a:11434/v1' },
{ id: 'text-worker', endpoint: 'http://text:11434/v1' },
] as WorkerDef[];
/** 呼び出しの最中に状態一覧を組み立てる(枠を握っているあいだ)。 */
async function statusesDuringVisionCall(ctx: ToolContext) {
let rows: Awaited<ReturnType<typeof collectWorkerStatuses>> = [];
vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => {
rows = await collectWorkerStatuses(emptyJobsRepo, workerDefs, null, { includeOwners: true });
return { ok: true, headers: new Headers(), json: async () => ({ choices: [{ message: { content: 'ok' } }] }) };
}));
await callVisionModel(DATA_URL, 'describe', ctx);
return rows;
}
it('回した先のワーカーに、依頼元の利用者名つきで現れる', async () => {
const rows = await statusesDuringVisionCall(offloadCtx(target(), {
workerId: 'text-worker',
userId: 'u-42',
userDisplayName: '山田 太郎',
} as Partial<ToolContext>));
expect(rows.find(r => r.id === 'vision-a')?.occupants)
.toEqual([{ user: '山田 太郎', kind: 'vision' }]);
});
it('表示名が空でも識別できる文字列が出る(表示名 → id → 不明)', async () => {
const byId = await statusesDuringVisionCall(offloadCtx(target(), {
workerId: 'text-worker', userId: 'u-42', userDisplayName: ' ',
} as Partial<ToolContext>));
expect(byId.find(r => r.id === 'vision-a')?.occupants)
.toEqual([{ user: 'u-42', kind: 'vision' }]);
resetSlotsForTest();
const byUnknown = await statusesDuringVisionCall(offloadCtx(target(), {
workerId: 'text-worker', userId: undefined, userDisplayName: undefined,
} as Partial<ToolContext>));
expect(byUnknown.find(r => r.id === 'vision-a')?.occupants)
.toEqual([{ user: 'unknown', kind: 'vision' }]);
});
it('呼び出しが終われば消える', async () => {
await statusesDuringVisionCall(offloadCtx(target(), {
workerId: 'text-worker', userDisplayName: '山田 太郎',
} as Partial<ToolContext>));
const after = await collectWorkerStatuses(emptyJobsRepo, workerDefs, null, { includeOwners: true });
expect(after.find(r => r.id === 'vision-a')?.occupants).toBeUndefined();
});
it('宛先が自分自身なら枠を取らないので、写しにも出ない(自己デッドロック回避のまま)', async () => {
const rows = await statusesDuringVisionCall(offloadCtx(
target({ workerId: 'text-worker' }),
{ workerId: 'text-worker', userDisplayName: '山田 太郎' } as Partial<ToolContext>,
));
expect(rows.find(r => r.id === 'text-worker')?.occupants).toBeUndefined();
});
});
describe('中断シグナル', () => {
it('呼び出し中に中断すると fetch も中断される', async () => {
const controller = new AbortController();
let seen: AbortSignal | undefined;
vi.stubGlobal('fetch', vi.fn().mockImplementation(async (_url: string, init: { signal: AbortSignal }) => {
seen = init.signal;
controller.abort();
// 実物の fetch と同じく、シグナルが立ったら AbortError を投げる
const error = new Error('aborted');
error.name = 'AbortError';
throw error;
}));
const result = await callVisionModel(DATA_URL, 'describe', offloadCtx(target(), {
abortSignal: controller.signal,
} as Partial<ToolContext>));
expect(seen?.aborted).toBe(true);
expect(result.output).toContain('cancelled');
});
it('待っている最中に中断すると、枠を待たずに抜ける', async () => {
vi.stubGlobal('fetch', okFetch());
const controller = new AbortController();
expect(tryAcquireSlot('vision-a', 1, { kind: 'job' })).not.toBeNull();
const promise = callVisionModel(DATA_URL, 'describe', offloadCtx(target({ maxConcurrency: 1 }), {
abortSignal: controller.signal,
} as Partial<ToolContext>));
await new Promise(resolve => setImmediate(resolve));
controller.abort();
const result = await promise;
expect(result.isError).toBe(true);
expect(result.output).toContain('cancelled');
});
it('中断していないタイムアウトは従来どおりタイムアウトとして返る', async () => {
vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => {
const error = new Error('timeout');
error.name = 'AbortError';
throw error;
}));
const result = await callVisionModel(DATA_URL, 'describe', offloadCtx(target()));
expect(result.output).toContain('timed out');
});
});
describe('ReadImage が返すもの', () => {
let workspacePath = '';
beforeEach(() => {
workspacePath = mkdtempSync(join(tmpdir(), 'readimage-offload-'));
mkdirSync(join(workspacePath, 'input'), { recursive: true });
writeFileSync(join(workspacePath, 'input', 'a.png'), Buffer.from('89504e470d0a1a0a', 'hex'));
});
afterEach(() => {
if (workspacePath) rmSync(workspacePath, { recursive: true, force: true });
workspacePath = '';
});
it('回した実行では画像を返さない(説明文だけ)', async () => {
vi.stubGlobal('fetch', okFetch('青い折れ線グラフ'));
const result = await executeTool('ReadImage', { file_path: 'input/a.png', prompt: '縦軸は?' },
offloadCtx(target(), { workspacePath } as Partial<ToolContext>));
expect(result!.isError).toBe(false);
expect(result!.images).toBeUndefined();
expect(result!.output).toContain('青い折れ線グラフ');
expect(result!.output).toContain('縦軸は?');
});
it('自分で読む実行では従来どおり画像を返す', async () => {
const result = await executeTool('ReadImage', { file_path: 'input/a.png' },
offloadCtx(null, { workspacePath, vlmEnabled: true } as Partial<ToolContext>));
expect(result!.images).toHaveLength(1);
expect(result!.images![0]!.dataUrl.startsWith('data:image/png;base64,')).toBe(true);
});
it('回せもせず自分でも読めない実行では断る', async () => {
const result = await executeTool('ReadImage', { file_path: 'input/a.png' },
offloadCtx(null, { workspacePath } as Partial<ToolContext>));
expect(result!.isError).toBe(true);
expect(result!.output).toContain('capabilities.image: true');
});
it('回した先が失敗したらそのまま失敗として返す(成功を装わない)', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false, status: 503, headers: new Headers(), text: async () => 'busy',
}));
const result = await executeTool('ReadImage', { file_path: 'input/a.png' },
offloadCtx(target(), { workspacePath } as Partial<ToolContext>));
expect(result!.isError).toBe(true);
expect(result!.images).toBeUndefined();
});
});
describe('宛先の非同期解決', () => {
it('解決した配信名をリクエストに載せ、待機中も元の iteration を保持する', async () => {
const f = okFetch();
vi.stubGlobal('fetch', f);
const events: LlmUsageEvent[] = [];
setLlmUsageRecorder(e => events.push(e));
let finish!: (value: VisionWorkerTarget) => void;
const resolved = new Promise<VisionWorkerTarget>(resolve => { finish = resolve; });
const context = offloadCtx(null, { iteration: 2, resolveVisionWorker: () => resolved });
const pending = callVisionModel(DATA_URL, 'describe', context);
expect(f).not.toHaveBeenCalled();
context.iteration = 9;
finish(target({ model: '/models/vision.gguf' }));
const result = await pending;
expect(result.isError).not.toBe(true);
expect(lastCall(f).url).toBe('http://vision-a:11434/v1/chat/completions');
expect(JSON.parse(lastCall(f).init.body).model).toBe('/models/vision.gguf');
expect(events[0]?.detail?.iteration).toBe(2);
});
});
+225 -3
View File
@@ -4,15 +4,16 @@
* directly via recordLlmUsage.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { callVisionModel } from './image.js';
import { callVisionModel, VISION_ENDPOINT_WORKER_ID } from './image.js';
import type { ToolContext } from './core.js';
import { setLlmUsageRecorder, type LlmUsageEvent } from '../../llm/usage-recorder.js';
function ctx(): ToolContext {
function ctx(overrides: Partial<ToolContext> = {}): ToolContext {
return {
workspacePath: '/tmp',
userId: 'u1',
toolsConfig: { visionBaseUrl: 'http://vision-host:11434/v1', visionModel: 'qwen2-vl' },
...overrides,
} as unknown as ToolContext;
}
@@ -27,6 +28,7 @@ describe('callVisionModel usage recording', () => {
setLlmUsageRecorder((e) => events.push(e));
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
json: async () => ({
model: 'qwen2-vl',
choices: [{ message: { content: 'a cat' } }],
@@ -34,12 +36,75 @@ describe('callVisionModel usage recording', () => {
}),
}));
const res = await callVisionModel('data:image/png;base64,xxx', 'describe', ctx());
const res = await callVisionModel('data:image/png;base64,xxx', 'describe', ctx({
jobId: 'job-1',
rootTaskId: 42,
pieceName: 'research',
movementName: 'analyze',
iteration: 3,
workerId: 'vision-worker-1',
contextLimitTokens: 128_000,
}));
expect(res.isError).toBe(false);
expect(events).toHaveLength(1);
expect(events[0]).toEqual({
userId: 'u1', source: 'direct', model: 'qwen2-vl', route: 'vision-host:11434',
tokensIn: 250, tokensOut: 12,
detail: {
purpose: 'vision',
jobId: 'job-1',
rootTaskId: 42,
piece: 'research',
movement: 'analyze',
iteration: 3,
// #1117: 三点セットの接続先はワーカーとは別の機械なので、実行側の
// ワーカー id(vision-worker-1)ではなく接続先に寄せる。
workerId: VISION_ENDPOINT_WORKER_ID,
contextLimitTokens: 128_000,
// 生 fetch なので台帳の prefill 集計からは外れる(issue #1065)。
excludeFromLedgerPrefill: true,
},
});
});
it('snapshots iteration before the asynchronous vision request settles', async () => {
const events: LlmUsageEvent[] = [];
setLlmUsageRecorder((e) => events.push(e));
let resolveResponse!: (response: unknown) => void;
const response = new Promise(resolve => { resolveResponse = resolve; });
vi.stubGlobal('fetch', vi.fn(() => response));
const context = ctx({ iteration: 4 });
const pending = callVisionModel('data:image/png;base64,xxx', 'describe', context);
context.iteration = 5;
resolveResponse({
ok: true,
headers: new Headers(),
json: async () => ({ choices: [{ message: { content: 'ok' } }] }),
});
await pending;
expect(events[0]?.detail?.iteration).toBe(4);
});
it('leaves job attribution absent for a headless vision call', async () => {
const events: LlmUsageEvent[] = [];
setLlmUsageRecorder((e) => events.push(e));
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
json: async () => ({ choices: [{ message: { content: 'x' } }] }),
}));
await callVisionModel('data:image/png;base64,xxx', '', ctx());
// #1117: ジョブの帰属は無いままだが、所在(どの接続先で動いたか)は残す。
// 実行側のワーカーには付けない——別の機械なので、そのワーカーの実績にすると
// 「動いていないはずのモデル」が使用量タブに並ぶ。
expect(events[0]?.detail).toEqual({
purpose: 'vision',
workerId: VISION_ENDPOINT_WORKER_ID,
excludeFromLedgerPrefill: true,
});
});
@@ -48,12 +113,82 @@ describe('callVisionModel usage recording', () => {
setLlmUsageRecorder((e) => events.push(e));
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
json: async () => ({ choices: [{ message: { content: 'x' } }] }),
}));
await callVisionModel('data:image/png;base64,xxx', '', ctx());
expect(events[0]).toMatchObject({ tokensIn: 0, tokensOut: 0, model: 'qwen2-vl' });
});
/**
* issue #1065: 画像認識はストリーミングしない生 fetch だが、実機計測
* (designreview-1065.md §0 計測 3 が stream:false)のとおり測定値は
* 完全な形で返る。欠測扱いにせず、同じ解決器を通して取り込む。
*/
it('Given 非ストリーミング応答に測定値が乗る, When 記録する, Then 生成速度が関所へ渡る', async () => {
const events: LlmUsageEvent[] = [];
setLlmUsageRecorder((e) => events.push(e));
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
json: async () => ({
model: 'qwen2-vl',
choices: [{ message: { content: 'a cat' } }],
usage: {
prompt_tokens: 15_576,
completion_tokens: 2,
prompt_tokens_details: { cached_tokens: 0 },
},
timings: {
cache_n: 0,
prompt_n: 15_576,
prompt_ms: 6585.309,
prompt_per_second: 2365.2648645644417,
predicted_n: 2,
predicted_ms: 18.064,
predicted_per_second: 110.71744906997343,
},
}),
}));
await callVisionModel('data:image/png;base64,xxx', 'describe', ctx());
expect(events[0]?.detail).toMatchObject({
purpose: 'vision',
decodeTps: 110.71744906997343,
decodeTokens: 2,
decodeMs: 18.064,
prefillTps: 2365.2648645644417,
prefillTokens: 15_576,
// 解決器を通す以上、再利用量と prefill 時間も同じコールから一貫して来る。
cachedPromptTokens: 0,
prefillMs: 6585.309,
});
});
it('Given 測定値を返さない視覚サーバー, When 記録する, Then 5 項目は欠測のままで 0 にならない', async () => {
const events: LlmUsageEvent[] = [];
setLlmUsageRecorder((e) => events.push(e));
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
json: async () => ({
model: 'qwen2-vl',
choices: [{ message: { content: 'a cat' } }],
usage: { prompt_tokens: 250, completion_tokens: 12 },
}),
}));
await callVisionModel('data:image/png;base64,xxx', 'describe', ctx());
const detail = events[0]?.detail;
expect(detail?.decodeTps).toBeUndefined();
expect(detail?.decodeTokens).toBeUndefined();
expect(detail?.decodeMs).toBeUndefined();
expect(detail?.prefillTps).toBeUndefined();
expect(detail?.prefillTokens).toBeUndefined();
});
it('does not record on a vision API error', async () => {
const events: LlmUsageEvent[] = [];
setLlmUsageRecorder((e) => events.push(e));
@@ -63,3 +198,90 @@ describe('callVisionModel usage recording', () => {
expect(events).toHaveLength(0);
});
});
/**
* 所在は捏造しない(不変条件 D10-2)。vlm worker の endpoint がゲートウェイの
* ときは visionBaseUrl もゲートウェイなので、host を直結 backend として
* 記録してはいけない。
*/
describe('callVisionModel — ゲートウェイ経由の所在', () => {
function gatewayCtx(): ToolContext {
return ctx({
toolsConfig: {
visionBaseUrl: 'http://aao-gateway:4000/v1',
visionModel: 'qwen2-vl',
visionViaGateway: true,
},
});
}
function response(headers: Record<string, string>) {
return {
ok: true,
headers: new Headers(headers),
json: async () => ({
model: 'qwen2-vl',
choices: [{ message: { content: 'a cat' } }],
usage: { prompt_tokens: 10, completion_tokens: 2 },
}),
};
}
it('ゲートウェイ経由なら source=gateway になり、所在は応答ヘッダーの backend になる', async () => {
const events: LlmUsageEvent[] = [];
setLlmUsageRecorder((e) => events.push(e));
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response({ 'x-litellm-model-id': 'gpu-node-3' })));
await callVisionModel('data:image/png;base64,xxx', '', gatewayCtx());
expect(events[0]).toMatchObject({ source: 'gateway', route: 'gpu-node-3' });
});
it('backend ヘッダーが無ければ所在は unknown(ゲートウェイの host を焼き込まない)', async () => {
const events: LlmUsageEvent[] = [];
setLlmUsageRecorder((e) => events.push(e));
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response({})));
await callVisionModel('data:image/png;base64,xxx', '', gatewayCtx());
expect(events[0]).toMatchObject({ source: 'gateway', route: 'unknown' });
expect(events[0]?.route).not.toContain('aao-gateway');
});
it('worker 由来の旗が無くても、backend ヘッダーがあればゲートウェイと判定する', async () => {
// config.yaml の tools.vision_base_url に直接ゲートウェイを書いた経路。
// worker の proxy 設定は届かないので、旗だけでは塞げない。
const events: LlmUsageEvent[] = [];
setLlmUsageRecorder((e) => events.push(e));
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response({ 'x-litellm-model-id': 'gpu-c' })));
await callVisionModel('data:image/png;base64,xxx', '', ctx({
toolsConfig: { visionBaseUrl: 'http://aao-gateway:4000/v1', visionModel: 'qwen2-vl' },
}));
expect(events[0]).toMatchObject({ source: 'gateway', route: 'gpu-c' });
expect(events[0]?.route).not.toContain('aao-gateway');
});
it('旗もヘッダーも無ければ従来どおり直結として host を所在にする', async () => {
const events: LlmUsageEvent[] = [];
setLlmUsageRecorder((e) => events.push(e));
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response({})));
await callVisionModel('data:image/png;base64,xxx', '', ctx({
toolsConfig: { visionBaseUrl: 'http://vision-host:11434/v1', visionModel: 'qwen2-vl' },
}));
expect(events[0]).toMatchObject({ source: 'direct', route: 'vision-host:11434' });
});
it('空白のみの backend ヘッダーも欠落として扱う', async () => {
const events: LlmUsageEvent[] = [];
setLlmUsageRecorder((e) => events.push(e));
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response({ 'x-litellm-model-id': ' ' })));
await callVisionModel('data:image/png;base64,xxx', '', gatewayCtx());
expect(events[0]).toMatchObject({ source: 'gateway', route: 'unknown' });
});
});
+23 -33
View File
@@ -14,27 +14,30 @@
* (which would make the silent Object.assign / dispatch order load-bearing).
* 3. Load-failure safety: getToolDefs() never throws and returns a stable,
* non-empty catalog — the tryLoadModule swallow-on-failure contract.
* 4. META_TOOLS drift guard: every name in index.ts's runtime META_TOOLS list
* is a resolvable, always-available tool, and the tool-categories META
* subset is ⊆ the index.ts list (the relationship the code documents).
* 4. META_TOOLS drift guard: every always-available tool name resolves to a
* real registered tool, and the resolved set matches the pinned list.
*
* #1126: index.ts が持っていた META の直書き一覧は撤去し、
* tool-categories.ts の META_TOOLS ∪ 各モジュールの TOOL_MODULE 申告
* (resolveMetaToolNames)が唯一の出どころになった。以前ここにあった
* 「tool-categories 側は index.ts 側の部分集合」という検査は、寄せた時点で
* 常に真になるので、実効集合そのものの固定へ置き換えてある。
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import { tmpdir } from 'os';
import { executeTool, getToolDefs, type ToolContext } from './index.js';
import {
MODULE_SPECS,
META_TOOLS as CATEGORIES_META_TOOLS,
} from './tool-categories.js';
import { MODULE_SPECS } from './tool-categories.js';
import { resolveMetaToolNames } from './module-manifest.js';
// The runtime META_TOOLS list lives inline in index.ts:411. It is intentionally
// duplicated here (the source does not export it) so the test trips if the two
// drift apart silently. The drift-guard test below also pins it against
// getToolDefs(), so a stale copy here cannot mask a real regression.
const INDEX_META_TOOLS = [
'ReadToolDoc', 'CreateChecklist', 'CheckItem', 'GetChecklist', 'MissionUpdate',
// 常時利用ツールの期待値。実装(tool-categories.ts の一覧 + 各モジュールの
// 申告)とは別に手で書いてある。片方をこっそり削っても、ここと突き合わせて
// 落ちるようにするため。名前を足す・消すときは意図して両方を直すこと。
const EXPECTED_META_TOOLS = [
'ReadToolDoc', 'CreateChecklist', 'CheckItem', 'GetChecklist', 'MissionUpdate', 'AskUser',
'SearchTaskConversation', 'ReadTaskConversation',
'SearchWorkspaceTasks', 'ListWorkspaceTasks',
'GetFileProvenance', 'ListWorkspaceFiles',
'ListUserAssets', 'RunUserScript', 'UpdateUserMemory', 'ReadUserMemory',
'ReadUserAgents', 'UpdateUserAgents', 'WriteUserScript', 'Brainstorm',
@@ -218,34 +221,21 @@ describe('tools/index — load-failure safety', () => {
});
describe('tools/index — META_TOOLS drift guard', () => {
it('every index.ts META_TOOLS name is always-available (injected without an allow-list)', async () => {
it('期待した META ツールがすべて、許可一覧なしで実際に載る', async () => {
// editAllowed:false, vlmEnabled omitted, EMPTY allowedTools — only META
// tools (plus core defs that aren't edit-gated) should survive. Each META
// name must resolve in the returned catalog, proving the injection wires up
// a real registered tool (no dangling name).
const defs = await getToolDefs([], false);
const available = new Set(defs.map((d) => d.function.name));
const missing = INDEX_META_TOOLS.filter((n) => !available.has(n));
const missing = EXPECTED_META_TOOLS.filter((n) => !available.has(n));
expect(missing).toEqual([]);
});
it('the tool-categories META subset is ⊆ the index.ts runtime META list', async () => {
// tool-categories.ts documents its META_TOOLS as a SUBSET of index.ts's
// (the runtime injection list). Assert that documented relationship: every
// categories META name appears in the index.ts list. This holds even if the
// index.ts list later grows beyond the catalog subset.
const idx = new Set(INDEX_META_TOOLS);
const notSubset = [...CATEGORIES_META_TOOLS].filter((n) => !idx.has(n));
expect(notSubset).toEqual([]);
});
it('every tool-categories META name is also an always-available registered tool', async () => {
// The catalog META subset tags /api/tools entries as source=meta; each must
// correspond to a real, always-injected tool too (else the catalog tags a
// phantom).
const defs = await getToolDefs([], false);
const available = new Set(defs.map((d) => d.function.name));
const missing = [...CATEGORIES_META_TOOLS].filter((n) => !available.has(n));
expect(missing).toEqual([]);
it('解決後の META 集合が、期待した顔ぶれと過不足なく一致する', async () => {
// 中央の一覧とモジュールの申告のどちらを削っても、ここで落ちる。
// 「片方だけ直る」事故を止めるのがこの 1 本。
const resolved = [...(await resolveMetaToolNames())].sort();
expect(resolved).toEqual(EXPECTED_META_TOOLS.slice().sort());
});
});
+16
View File
@@ -10,6 +10,22 @@ import { describe, it, expect } from 'vitest';
import { getToolDefs } from './index.js';
describe('tool catalog', () => {
// 画像認識の委譲(issue #1056)。載せる条件は「自分で読める」か「回せる」か。
it('registers ReadImage for a VLM worker', async () => {
const defs = await getToolDefs(['ReadImage'], false, { vlmEnabled: true });
expect(defs.find(d => d.function.name === 'ReadImage')).toBeDefined();
});
it('registers ReadImage on a non-VLM worker when a vision target exists', async () => {
const defs = await getToolDefs(['ReadImage'], false, { vlmEnabled: false, visionOffloadAvailable: true });
expect(defs.find(d => d.function.name === 'ReadImage')).toBeDefined();
});
it('omits ReadImage on a non-VLM worker with no vision target', async () => {
const defs = await getToolDefs(['ReadImage'], false, { vlmEnabled: false, visionOffloadAvailable: false });
expect(defs.find(d => d.function.name === 'ReadImage')).toBeUndefined();
});
it('includes SshConsole* tools when piece allows them', async () => {
const defs = await getToolDefs(
['SshConsoleEnsure', 'SshConsoleSend', 'SshConsoleSnapshot'],
+147 -30
View File
@@ -1,19 +1,23 @@
import { ToolDef } from '../../llm/openai-compat.js';
import { logger } from '../../logger.js';
import type { ToolResult, ToolsConfig } from './tool-context.js';
import type { ToolContext as CoreToolContext } from './tool-context.js';
import { ALL_TOOL_DEFS, executeCoreTools, runtimeLogsDir } from './core.js';
import { saveRawData, logRawDownload } from './raw-save.js';
import {
ToolContext,
ToolResult,
ToolsConfig,
ALL_TOOL_DEFS,
executeCoreTools,
runtimeLogsDir,
} from './core.js';
import { saveRawData, logRawDownload, RAW_SAVE_TOOLS, RAW_LOG_ONLY_TOOLS } from './raw-save.js';
resolveMetaToolNames,
resolveRawSaveToolNames,
resolveRawLogOnlyToolNames,
} from './module-manifest.js';
import { isToolModuleDisabled } from './tool-pack-gate.js';
import { saveStructuredBlocks } from './structured-blocks.js';
import type { McpAggregator } from '../../mcp/aggregator.js';
import type { McpRuntimeConfig } from '../../mcp/config.js';
export type { ToolContext, ToolResult, ToolsConfig };
export type ToolContext = CoreToolContext & {
groundingCollector?: import('../grounding-collector.js').GroundingCollector;
};
export type { ToolResult, ToolsConfig };
let _mcpAggregator: McpAggregator | null = null;
export function setMcpAggregator(agg: McpAggregator | null): void {
@@ -34,7 +38,13 @@ interface ToolModule {
}
// 外部モジュールを動的にロード(存在しない場合は null)
//
// 環境全体で止められている群のモジュールは、**読み込む前に断る**(#1126 段 C)。
// 読んでから捨てる形にしないのは 2 つ理由がある。読み込み自体の副作用を
// 起こさないため、そして段 F で実体を消したときにここが素通りするため。
// 判定は起動時に凍っているので、この関数は呼ばれるたびに同じ答えを返す。
async function tryLoadModule(specifier: string): Promise<ToolModule | null> {
if (isToolModuleDisabled(specifier)) return null;
try {
const mod = await import(specifier) as ToolModule;
return mod;
@@ -48,15 +58,18 @@ let _webModule: ToolModule | null | undefined = undefined;
let _imageModule: ToolModule | null | undefined = undefined;
let _dataModule: ToolModule | null | undefined = undefined;
let _officeModule: ToolModule | null | undefined = undefined;
let _mermaidModule: ToolModule | null | undefined = undefined;
let _reviewModule: ToolModule | null | undefined = undefined;
let _xModule: ToolModule | null | undefined = undefined;
let _orchestrationModule: ToolModule | null | undefined = undefined;
let _taskSpawnModule: ToolModule | null | undefined = undefined;
let _browserModule: ToolModule | null | undefined = undefined;
let _mapsModule: ToolModule | null | undefined = undefined;
let _youtubeModule: ToolModule | null | undefined = undefined;
let _piecesModule: ToolModule | null | undefined = undefined;
let _amazonModule: ToolModule | null | undefined = undefined;
let _speechModule: ToolModule | null | undefined = undefined;
let _speakModule: ToolModule | null | undefined = undefined;
let _checklistModule: ToolModule | null | undefined = undefined;
let _msLearnModule: ToolModule | null | undefined = undefined;
let _slideModule: ToolModule | null | undefined = undefined;
@@ -95,6 +108,14 @@ async function getOfficeModule(): Promise<ToolModule | null> {
return _officeModule;
}
async function getMermaidModule(): Promise<ToolModule | null> {
if (_mermaidModule === undefined) {
_mermaidModule = await tryLoadModule('./mermaid.js');
if (_mermaidModule) logger.debug('[tools/index] mermaid module loaded');
}
return _mermaidModule;
}
async function getReviewModule(): Promise<ToolModule | null> {
if (_reviewModule === undefined) {
_reviewModule = await tryLoadModule('./review.js');
@@ -119,6 +140,19 @@ async function getOrchestrationModule(): Promise<ToolModule | null> {
return _orchestrationModule;
}
/**
* SpinOffTask(切り出し・#993 段階 3-B)。サブタスク起動と同じ orchestration の
* 分類に置くが、**ファイルは分ける**——中身は「いまの作業を分割して待つ」ではなく
* 「別に取っておいて放す」で、目的が違う(設計 §5)。
*/
async function getTaskSpawnModule(): Promise<ToolModule | null> {
if (_taskSpawnModule === undefined) {
_taskSpawnModule = await tryLoadModule('./task-spawn.js');
if (_taskSpawnModule) logger.debug('[tools/index] task-spawn module loaded');
}
return _taskSpawnModule;
}
async function getBrowserModule(): Promise<ToolModule | null> {
if (_browserModule === undefined) {
_browserModule = await tryLoadModule('./browser.js');
@@ -167,6 +201,14 @@ async function getSpeechModule(): Promise<ToolModule | null> {
return _speechModule;
}
async function getSpeakModule(): Promise<ToolModule | null> {
if (_speakModule === undefined) {
_speakModule = await tryLoadModule('./speak.js');
if (_speakModule) logger.debug('[tools/index] speak module loaded');
}
return _speakModule;
}
async function getChecklistModule(): Promise<ToolModule | null> {
if (_checklistModule === undefined) {
_checklistModule = await tryLoadModule('./checklist.js');
@@ -209,6 +251,15 @@ async function getMissionModule(): Promise<ToolModule | null> {
return _missionModule;
}
let _askUserModule: ToolModule | null | undefined;
async function getAskUserModule(): Promise<ToolModule | null> {
if (_askUserModule === undefined) {
_askUserModule = await tryLoadModule('./ask-user.js');
if (_askUserModule) logger.debug('[tools/index] ask-user module loaded');
}
return _askUserModule;
}
let _taskConversationModule: ToolModule | null | undefined;
async function getTaskConversationModule(): Promise<ToolModule | null> {
if (_taskConversationModule === undefined) {
@@ -236,6 +287,15 @@ async function getFileProvenanceModule(): Promise<ToolModule | null> {
return _fileProvenanceModule;
}
let _fileStoreModule: ToolModule | null | undefined;
async function getFileStoreModule(): Promise<ToolModule | null> {
if (_fileStoreModule === undefined) {
_fileStoreModule = await tryLoadModule('./filestore.js');
if (_fileStoreModule) logger.debug('[tools/index] filestore module loaded');
}
return _fileStoreModule;
}
let _toolRequestModule: ToolModule | null | undefined;
async function getToolRequestModule(): Promise<ToolModule | null> {
if (_toolRequestModule === undefined) {
@@ -331,7 +391,7 @@ async function getAppTestModule(): Promise<ToolModule | null> {
export async function getToolDefs(
allowedTools: string[],
editAllowed: boolean,
options?: { vlmEnabled?: boolean; ownerId?: string | null; mcpDisabled?: boolean; spaceId?: string | null },
options?: { vlmEnabled?: boolean; visionOffloadAvailable?: boolean; ownerId?: string | null; mcpDisabled?: boolean; spaceId?: string | null },
): Promise<ToolDef[]> {
// 全ツール定義を収集
const allDefs: Record<string, ToolDef> = { ...ALL_TOOL_DEFS };
@@ -347,6 +407,8 @@ export async function getToolDefs(
const officeMod = await getOfficeModule();
if (officeMod) Object.assign(allDefs, officeMod.TOOL_DEFS);
const mermaidMod = await getMermaidModule();
if (mermaidMod) Object.assign(allDefs, mermaidMod.TOOL_DEFS);
const reviewMod = await getReviewModule();
if (reviewMod) Object.assign(allDefs, reviewMod.TOOL_DEFS);
@@ -357,6 +419,9 @@ export async function getToolDefs(
const orchestrationMod = await getOrchestrationModule();
if (orchestrationMod) Object.assign(allDefs, orchestrationMod.TOOL_DEFS);
const taskSpawnMod = await getTaskSpawnModule();
if (taskSpawnMod) Object.assign(allDefs, taskSpawnMod.TOOL_DEFS);
const browserMod = await getBrowserModule();
if (browserMod) Object.assign(allDefs, browserMod.TOOL_DEFS);
@@ -375,6 +440,9 @@ export async function getToolDefs(
const speechMod = await getSpeechModule();
if (speechMod) Object.assign(allDefs, speechMod.TOOL_DEFS);
const speakMod = await getSpeakModule();
if (speakMod) Object.assign(allDefs, speakMod.TOOL_DEFS);
const checklistMod = await getChecklistModule();
if (checklistMod) Object.assign(allDefs, checklistMod.TOOL_DEFS);
@@ -390,6 +458,9 @@ export async function getToolDefs(
const missionMod = await getMissionModule();
if (missionMod) Object.assign(allDefs, missionMod.TOOL_DEFS);
const askUserMod = await getAskUserModule();
if (askUserMod) Object.assign(allDefs, askUserMod.TOOL_DEFS);
const taskConversationMod = await getTaskConversationModule();
if (taskConversationMod) Object.assign(allDefs, taskConversationMod.TOOL_DEFS);
@@ -399,6 +470,9 @@ export async function getToolDefs(
const fileProvenanceMod = await getFileProvenanceModule();
if (fileProvenanceMod) Object.assign(allDefs, fileProvenanceMod.TOOL_DEFS);
const fileStoreMod = await getFileStoreModule();
if (fileStoreMod) Object.assign(allDefs, fileStoreMod.TOOL_DEFS);
const userFolderMod = await getUserFolderModule();
if (userFolderMod) Object.assign(allDefs, userFolderMod.TOOL_DEFS);
@@ -442,24 +516,25 @@ export async function getToolDefs(
// snapshot becomes movement-dependent and the global must move into ctx
// instead — otherwise concurrent movements would misclassify each other's
// tools as `unknown`.
//
// 群の関所(#1126 段 C)はこの不変条件を破らない。判定はプロセス起動時に
// 1 回だけ決まって凍るので、movement / ownerId / spaceId のどれにも依存せず、
// 同時に走る書き手はやはり同一の配列を作る。止められた群のツールは
// 「最初から存在しないツール」として一様に扱われる。
_allKnownToolNames = Object.keys(allDefs);
// メタツール: ワークスペースのツール設定に関係なく常に利用可能
// - ReadToolDoc: 全ツールのドキュメント参照
// - CreateChecklist / CheckItem / GetChecklist: 進捗管理 (複数ステップタスクで使用)
// - MissionUpdate: タスクの目標 / 進捗のピン止めメモを更新 (会話が長くなって
// 最初の要件を見失わないため。常時上書き可能、未指定フィールドは保持)
// - ListUserAssets / RunUserScript: ユーザーフォルダのスクリプト探索・実行
// - ReadUserAgents / UpdateUserAgents: per-user AGENTS.md (常時指示書) の読み書き
// (UpdateUserMemory が事実断片、こちらは振る舞い方針そのもの)
// - Brainstorm: 着手前 or 行き詰まり時の多アプローチ比較 (issue #247)
// - ReadAppDoc / ListAppDocs / GetMyOrchestratorState: Help アシスタント用 (#help piece)
// ただし他の piece からも参照できるようにメタ扱い
// - RequestTool: 足りないツールの要求を記録(タスク詳細/ピース集計で可視化)
// - RequestPackage: 足りない Python パッケージ(wheel)を申請(承認でスペース overlay へ install)
const META_TOOLS = ['ReadToolDoc', 'CreateChecklist', 'CheckItem', 'GetChecklist', 'MissionUpdate', 'SearchTaskConversation', 'ReadTaskConversation', 'SearchWorkspaceTasks', 'GetFileProvenance', 'ListWorkspaceFiles', 'ListUserAssets', 'RunUserScript', 'UpdateUserMemory', 'ReadUserMemory', 'ReadUserAgents', 'UpdateUserAgents', 'WriteUserScript', 'Brainstorm', 'ReadAppDoc', 'ListAppDocs', 'GetMyOrchestratorState', 'ReadSkill', 'ListSkills', 'InstallSkill', 'RequestTool', 'RequestPackage'];
// メタツール: ワークスペースのツール設定に関係なく常に利用可能。
//
// 一覧はここに直書きしていた(tool-categories.ts にも同じものがあり、2 か所
// あった)。#1126 で 1 つに寄せ、いまは 2 つの出どころの和になっている。
// - tool-categories.ts の META_TOOLS: 自己申告を持たない既存モジュールの分
// - 各モジュールの TOOL_MODULE.metaTools: 自分で申告した分
// どちらか片方だけ見る形に戻さないこと(片方だけ直る事故が起きる)。
//
// 個々のツールを META にした理由は、それぞれの出どころ側に書いてある。
const metaToolNames = await resolveMetaToolNames();
const effectiveAllowed = [...allowedTools];
for (const meta of META_TOOLS) {
for (const meta of metaToolNames) {
if (!effectiveAllowed.includes(meta) && meta in allDefs) {
effectiveAllowed.push(meta);
}
@@ -468,7 +543,9 @@ export async function getToolDefs(
const staticDefs = effectiveAllowed
.filter((name) => {
if (!editAllowed && (name === 'Write' || name === 'Edit')) return false;
if (!options?.vlmEnabled && name === 'ReadImage') return false;
// 画像を扱えないワーカーでも、対応ワーカーへ回せる実行なら ReadImage を
// 載せる(issue #1056)。回した実行では画像は返らず説明文だけが返る。
if (!options?.vlmEnabled && !options?.visionOffloadAvailable && name === 'ReadImage') return false;
return name in allDefs;
})
.map((name) => allDefs[name]!);
@@ -559,6 +636,13 @@ async function executeToolInner(
if (officeResult !== null) return officeResult;
}
// mermaid ツール
const mermaidMod = await getMermaidModule();
if (mermaidMod) {
const mermaidResult = await mermaidMod.executeTool(name, input, ctx);
if (mermaidResult !== null) return mermaidResult;
}
// review ツール
const reviewMod = await getReviewModule();
if (reviewMod) {
@@ -580,6 +664,13 @@ async function executeToolInner(
if (orchestrationResult !== null) return orchestrationResult;
}
// 切り出し(SpinOffTask)ツール
const taskSpawnMod = await getTaskSpawnModule();
if (taskSpawnMod) {
const taskSpawnResult = await taskSpawnMod.executeTool(name, input, ctx);
if (taskSpawnResult !== null) return taskSpawnResult;
}
// browser ツール
const browserMod = await getBrowserModule();
if (browserMod) {
@@ -622,6 +713,13 @@ async function executeToolInner(
if (speechResult !== null) return speechResult;
}
// speak ツール(読み上げ)
const speakMod = await getSpeakModule();
if (speakMod) {
const speakResult = await speakMod.executeTool(name, input, ctx);
if (speakResult !== null) return speakResult;
}
// checklist ツール
const checklistMod = await getChecklistModule();
if (checklistMod) {
@@ -657,6 +755,13 @@ async function executeToolInner(
if (missionResult !== null) return missionResult;
}
// ask-user ツール (AskUser: 選択肢つき質問の park)
const askUserMod = await getAskUserModule();
if (askUserMod) {
const askUserResult = await askUserMod.executeTool(name, input, ctx);
if (askUserResult !== null) return askUserResult;
}
// task-conversation ツール (SearchTaskConversation / ReadTaskConversation)
const taskConversationMod = await getTaskConversationModule();
if (taskConversationMod) {
@@ -678,6 +783,13 @@ async function executeToolInner(
if (fileProvenanceResult !== null) return fileProvenanceResult;
}
// filestore ツール (ListFileStoreLocations / ListFileStoreFiles)
const fileStoreMod = await getFileStoreModule();
if (fileStoreMod) {
const fileStoreResult = await fileStoreMod.executeTool(name, input, ctx);
if (fileStoreResult !== null) return fileStoreResult;
}
// RequestTool: 足りないツールの要求を記録。分類用に既知ツール一覧を注入する
// (getToolDefs が movement 開始時に更新した最新スナップショット)。
const toolRequestMod = await getToolRequestModule();
@@ -730,7 +842,7 @@ async function executeToolInner(
if (sshConsoleResult !== null) return sshConsoleResult;
}
// calendar ツール (AddCalendarEvent / ListCalendarEvents)
// calendar ツール (AddCalendarEvent / ListCalendarEvents / DeleteCalendarEvent)
const calendarMod = await getCalendarModule();
if (calendarMod) {
const calendarResult = await calendarMod.executeTool(name, input, ctx);
@@ -766,11 +878,16 @@ export async function executeTool(
const result = await executeToolInner(name, input, ctx);
// raw保存ラッパー: 成功した対象ツールの結果を {logsRoot}/raw/ に保存(計画5: runtimeDir 解決)
//
// 対象は「中央の一覧 ∪ 各モジュールの申告」。saveRawData にも同じ集合を渡す
// ——あちらにも関門があり、渡さないと申告した分がそこで落ちる(#1126)。
if (ctx.workspacePath && !result.isError) {
const logsRoot = runtimeLogsDir(ctx);
if (RAW_SAVE_TOOLS.has(name)) {
saveRawData(logsRoot, name, result.output);
} else if (RAW_LOG_ONLY_TOOLS.has(name)) {
const rawSaveTools = await resolveRawSaveToolNames();
const rawLogOnlyTools = await resolveRawLogOnlyToolNames();
if (rawSaveTools.has(name)) {
saveRawData(logsRoot, name, result.output, rawSaveTools);
} else if (rawLogOnlyTools.has(name)) {
const pathMatch = result.output.match(/-> (.+?) \(/);
if (pathMatch?.[1]) {
logRawDownload(logsRoot, name, pathMatch[1], result.output.length);
+1 -1
View File
@@ -10,7 +10,7 @@
*/
import { ToolDef } from '../../llm/openai-compat.js';
import { ToolContext, ToolResult } from './core.js';
import type { ToolContext, ToolResult } from './tool-context.js';
import { logger } from '../../logger.js';
import { writeFileSync, mkdirSync, existsSync } from 'fs';
import { join } from 'path';
+121
View File
@@ -0,0 +1,121 @@
/**
* Locating and replacing Mermaid blocks in agent-written files (#1025).
*
* The agent has no guidance on how to emit Mermaid, so it writes whatever the
* model produces — fenced blocks in Markdown, `<pre>`/`<div class="mermaid">`
* in HTML, usually paired with a CDN script tag whose version nobody pinned.
* These helpers find those blocks so they can be checked, and swap the HTML
* ones for a rendered SVG so the diagram displays without one. Only the blocks
* are touched — the rest of the page, scripts included, is the author's.
*/
import { describe, it, expect } from 'vitest';
import { extractMermaidBlocks, replaceHtmlBlocksWithSvg, decodeSourceAttribute, mermaidCheckHint } from './mermaid-blocks.js';
describe('extractMermaidBlocks — markdown', () => {
it('finds fenced mermaid blocks and keeps the source verbatim', () => {
const md = ['# Title', '', '```mermaid', 'graph TD', ' A --> B', '```', '', 'text'].join('\n');
const blocks = extractMermaidBlocks(md, 'md');
expect(blocks).toHaveLength(1);
expect(blocks[0].code).toBe('graph TD\n A --> B');
});
it('ignores other languages and unlabelled fences', () => {
const md = '```ts\nconst a = 1;\n```\n\n```\nplain\n```';
expect(extractMermaidBlocks(md, 'md')).toHaveLength(0);
});
it('finds every block in a document', () => {
const md = '```mermaid\ngraph TD\n A-->B\n```\ntext\n```mermaid\nsequenceDiagram\n A->>B: hi\n```';
expect(extractMermaidBlocks(md, 'md').map(b => b.code.split('\n')[0])).toEqual(['graph TD', 'sequenceDiagram']);
});
});
describe('extractMermaidBlocks — html', () => {
it('finds pre and div blocks and decodes entities', () => {
const html =
'<body><pre class="mermaid">graph TD\n A[a] --&gt; B</pre>' +
'<div class="mermaid">sequenceDiagram\n A-&gt;&gt;B: hi</div></body>';
const blocks = extractMermaidBlocks(html, 'html');
expect(blocks).toHaveLength(2);
// The browser decodes entities before Mermaid ever sees the text, so the
// checker has to look at the same thing.
expect(blocks[0].code).toBe('graph TD\n A[a] --> B');
expect(blocks[1].code).toBe('sequenceDiagram\n A->>B: hi');
});
it('tolerates extra classes and attribute order', () => {
const html = '<pre id="d1" class="diagram mermaid">graph TD\n A-->B</pre>';
expect(extractMermaidBlocks(html, 'html')).toHaveLength(1);
});
it('ignores elements that merely mention mermaid', () => {
const html = '<p class="mermaid-note">about mermaid</p><pre class="language-mermaid-ish">x</pre>';
expect(extractMermaidBlocks(html, 'html')).toHaveLength(0);
});
});
describe('replaceHtmlBlocksWithSvg', () => {
const html = '<body><pre class="mermaid">graph TD\n A[a] --&gt; B</pre>\n<p>after</p></body>';
it('swaps the block for the rendered svg and drops nothing else', () => {
const blocks = extractMermaidBlocks(html, 'html');
const out = replaceHtmlBlocksWithSvg(html, blocks, ['<svg id="s1">rendered</svg>']);
expect(out).toContain('<svg id="s1">rendered</svg>');
expect(out).not.toContain('<pre class="mermaid">');
expect(out).toContain('<p>after</p>');
});
it('keeps the original source so the diagram can be edited later', () => {
const blocks = extractMermaidBlocks(html, 'html');
const out = replaceHtmlBlocksWithSvg(html, blocks, ['<svg/>']);
// Round-trips: an attribute the browser will hand back unchanged.
const match = out.match(/data-mermaid-source="([^"]*)"/);
expect(match).toBeTruthy();
expect(decodeSourceAttribute(match![1])).toBe('graph TD\n A[a] --> B');
});
it('leaves a block alone when its render failed', () => {
const two = '<pre class="mermaid">graph TD\n A-->B</pre><pre class="mermaid">broken</pre>';
const blocks = extractMermaidBlocks(two, 'html');
const out = replaceHtmlBlocksWithSvg(two, blocks, ['<svg id="ok"/>', null]);
expect(out).toContain('<svg id="ok"/>');
// The unrendered one stays as it was, so nothing is lost.
expect(out).toContain('<pre class="mermaid">broken</pre>');
});
it('replaces from the end so earlier offsets stay valid', () => {
const two = '<pre class="mermaid">one</pre><pre class="mermaid">two</pre>';
const blocks = extractMermaidBlocks(two, 'html');
const out = replaceHtmlBlocksWithSvg(two, blocks, ['<svg id="a"/>', '<svg id="b"/>']);
expect(out.indexOf('id="a"')).toBeLessThan(out.indexOf('id="b"'));
expect(out).not.toContain('<pre');
});
});
describe('mermaidCheckHint', () => {
it('nudges right after a file with diagrams is written', () => {
// Where the reminder has to live: the agent must be told to look at its own
// diagram at the moment it writes one, not by hoping it remembers a rule.
const hint = mermaidCheckHint('output/report.html', '<pre class="mermaid">graph TD\n A-->B</pre>');
expect(hint).toContain('RenderMermaid');
expect(hint).toContain('output/report.html');
});
it('counts the diagrams so a partial check is obvious', () => {
const md = '```mermaid\ngraph TD\n A-->B\n```\n```mermaid\ngraph LR\n C-->D\n```';
expect(mermaidCheckHint('output/doc.md', md)).toMatch(/2/);
});
it('stays quiet for files without diagrams', () => {
expect(mermaidCheckHint('output/report.html', '<p>no diagrams</p>')).toBeNull();
expect(mermaidCheckHint('output/notes.md', '# heading')).toBeNull();
});
it('stays quiet for file types that never render diagrams', () => {
expect(mermaidCheckHint('output/app.ts', '```mermaid\ngraph TD\n A-->B\n```')).toBeNull();
});
});
+132
View File
@@ -0,0 +1,132 @@
/**
* Finding and replacing Mermaid blocks in agent-written files (#1025).
*
* Pure string work, kept apart from the browser so it can be tested without
* launching Chromium. The renderer (mermaid.ts) supplies the SVGs.
*/
export interface MermaidBlock {
/** Diagram source as Mermaid will see it (HTML entities already decoded). */
code: string;
/** Offsets of the whole element/fence in the original text. */
start: number;
end: number;
}
export type MermaidSourceKind = 'md' | 'html';
/** ```mermaid … ``` — the fence may be indented and carry trailing spaces. */
const MD_FENCE = /^([ \t]*)```[ \t]*mermaid[ \t]*\r?\n([\s\S]*?)^[ \t]*```[ \t]*$/gm;
/**
* `<pre class="mermaid">` / `<div class="mermaid">`, with any other attributes
* and any class order. `mermaid` must be a whole class token — `mermaid-note`
* and `language-mermaid-ish` are not diagrams.
*/
const HTML_BLOCK = /<(pre|div)\b([^>]*\bclass\s*=\s*(?:"[^"]*"|'[^']*')[^>]*)>([\s\S]*?)<\/\1\s*>/gi;
function hasMermaidClass(attrs: string): boolean {
const m = attrs.match(/\bclass\s*=\s*(?:"([^"]*)"|'([^']*)')/i);
const value = m?.[1] ?? m?.[2] ?? '';
return value.split(/\s+/).includes('mermaid');
}
/**
* Undo the entity encoding an HTML author must use inside a diagram. The
* browser decodes these before Mermaid parses, so a checker that skipped this
* would reject `A --&gt; B` — which renders perfectly.
*/
function decodeHtmlEntities(text: string): string {
return text
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&'); // last: an encoded &amp;lt; must survive as &lt;
}
function encodeAttribute(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
/** Inverse of the attribute encoding above (also used by tests and callers). */
export function decodeSourceAttribute(value: string): string {
return decodeHtmlEntities(value);
}
export function extractMermaidBlocks(text: string, kind: MermaidSourceKind): MermaidBlock[] {
const blocks: MermaidBlock[] = [];
if (kind === 'md') {
for (const m of text.matchAll(MD_FENCE)) {
const body = m[2] ?? '';
blocks.push({
code: body.replace(/\r?\n$/, ''),
start: m.index ?? 0,
end: (m.index ?? 0) + m[0].length,
});
}
return blocks;
}
for (const m of text.matchAll(HTML_BLOCK)) {
if (!hasMermaidClass(m[2] ?? '')) continue;
blocks.push({
code: decodeHtmlEntities(m[3] ?? '').replace(/^\r?\n/, '').replace(/\s+$/, ''),
start: m.index ?? 0,
end: (m.index ?? 0) + m[0].length,
});
}
return blocks;
}
/**
* Swap each successfully rendered block for its SVG, keeping the diagram source
* in `data-mermaid-source` — the one real drawback of shipping images is that
* the diagram stops being editable, and this removes it.
*
* `svgs[i] === null` means that diagram failed to render; the block is left
* exactly as it was rather than silently disappearing from the document.
*/
export function replaceHtmlBlocksWithSvg(
html: string,
blocks: MermaidBlock[],
svgs: (string | null)[],
): string {
let out = html;
// Back to front: replacing shifts everything after it.
for (let i = blocks.length - 1; i >= 0; i--) {
const svg = svgs[i];
if (!svg) continue;
const block = blocks[i];
const figure =
`<figure class="mermaid-diagram" data-mermaid-source="${encodeAttribute(block.code)}">${svg}</figure>`;
out = out.slice(0, block.start) + figure + out.slice(block.end);
}
return out;
}
/**
* Reminder appended to a Write/Edit result when the file just written contains
* diagrams. The point of the tool is a verify step that actually happens, and a
* rule in a document only fires if the model remembers it — this fires at the
* one moment it matters, in the agent's own context, with the path already
* filled in.
*/
export function mermaidCheckHint(filePath: string, content: string): string | null {
const kind: MermaidSourceKind | null = /\.html?$/i.test(filePath)
? 'html'
: /\.(md|markdown)$/i.test(filePath)
? 'md'
: null;
if (!kind) return null;
const count = extractMermaidBlocks(content, kind).length;
if (count === 0) return null;
return (
`\nThis file contains ${count} Mermaid diagram(s). Run RenderMermaid({ path: "${filePath}" }) to check ` +
'they parse, then look at the rendered PNG with ReadImage before treating this file as done.'
);
}
+445
View File
@@ -0,0 +1,445 @@
/**
* RenderMermaid (#1025).
*
* The render tests drive the REAL bundled Chromium and the REAL Mermaid bundle:
* the whole point of the tool is that its verdict matches what the browser
* actually does, and a mocked renderer would prove nothing about that.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { parseMermaidError, renderMermaidDiagrams, resolveMermaidBundle, executeTool, capPngPixels, TOOL_DEFS } from './mermaid.js';
import { estimateImagesTokens } from '../context/token-estimate.js';
import type { ToolContext } from './core.js';
describe('parseMermaidError', () => {
it('pairs the reported line with the source line that caused it', () => {
const code = 'graph TD\n A[a (b)] --> B\n B --> C';
const d = parseMermaidError('Parse error on line 2:\n...expecting SQE', code);
expect(d.line).toBe(2);
expect(d.sourceLine).toBe(' A[a (b)] --> B');
expect(d.message).toBe('Parse error on line 2:');
});
it('keeps the message when no line is reported', () => {
const d = parseMermaidError('No diagram type detected matching given configuration', 'wobble');
expect(d.line).toBeUndefined();
expect(d.message).toContain('No diagram type detected');
});
it('does not invent a source line when the number is out of range', () => {
const d = parseMermaidError('Parse error on line 9:', 'graph TD');
expect(d.line).toBe(9);
expect(d.sourceLine).toBeUndefined();
});
});
describe('tool definition', () => {
it('is one tool that both checks and renders', () => {
expect(Object.keys(TOOL_DEFS)).toEqual(['RenderMermaid']);
// House rule: one sentence, ending in the ReadToolDoc pointer.
expect(TOOL_DEFS.RenderMermaid.function.description).toMatch(/ReadToolDoc\({ name: "RenderMermaid" }\)/);
});
});
describe('renderMermaidDiagrams (real Chromium + real Mermaid)', () => {
let bundlePath: string;
beforeAll(() => { bundlePath = resolveMermaidBundle(); });
it('renders a valid diagram to SVG and PNG in one pass', async () => {
const { version, results } = await renderMermaidDiagrams(['graph TD\n A[Start] --> B[End]'], { bundlePath });
expect(version).toMatch(/^\d+\./);
expect(results[0].ok).toBe(true);
expect(results[0].svg).toContain('<svg');
// A real raster, not an empty canvas: the agent has to be able to look at it.
expect(results[0].png!.subarray(0, 8)).toEqual(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
expect(results[0].png!.length).toBeGreaterThan(1000);
}, 60_000);
it('reports the failing line for the mistake agents actually make', async () => {
// Unquoted parentheses in a label — the one case that failed out of a dozen
// realistic diagrams, and the reason a theme-retry strategy would be futile.
const code = 'graph TD\n A[read config (config.yaml)] --> B[start]';
const { results } = await renderMermaidDiagrams([code], { bundlePath });
expect(results[0].ok).toBe(false);
expect(results[0].diagnostic!.line).toBe(2);
expect(results[0].diagnostic!.sourceLine).toContain('config.yaml');
}, 60_000);
it('keeps going after a failure so one bad diagram does not hide the rest', async () => {
const { results } = await renderMermaidDiagrams(
['graph TD\n A[x (y)] --> B', 'graph TD\n A --> B'],
{ bundlePath },
);
expect(results.map(r => r.ok)).toEqual([false, true]);
}, 60_000);
});
describe('executeTool', () => {
let dir: string;
let ctx: ToolContext;
beforeAll(() => {
dir = mkdtempSync(join(tmpdir(), 'mermaid-tool-'));
mkdirSync(join(dir, 'output'), { recursive: true });
ctx = { workspacePath: dir, editAllowed: true } as ToolContext;
});
afterAll(() => rmSync(dir, { recursive: true, force: true }));
it('ignores tool names it does not own', async () => {
expect(await executeTool('Read', {}, ctx)).toBeNull();
});
it('asks for input when given none', async () => {
const res = await executeTool('RenderMermaid', {}, ctx);
expect(res!.isError).toBe(true);
expect(res!.output).toContain('`code` or `path`');
});
it('renders inline code to a PNG under output/', async () => {
const res = await executeTool('RenderMermaid', { code: 'graph TD\n A --> B' }, ctx);
expect(res!.isError).toBe(false);
expect(existsSync(join(dir, 'output', 'diagrams', 'diagram.png'))).toBe(true);
expect(res!.output).toContain('ReadImage');
}, 60_000);
it('embeds diagrams into an HTML file so the diagram displays without a script', async () => {
const file = join(dir, 'output', 'report.html');
writeFileSync(file, '<body><h1>t</h1><pre class="mermaid">graph TD\n A[a] --&gt; B</pre></body>');
const res = await executeTool('RenderMermaid', { path: 'output/report.html' }, ctx);
const html = readFileSync(file, 'utf8');
expect(res!.isError).toBe(false);
expect(html).toContain('<svg');
expect(html).not.toContain('<pre class="mermaid">');
// The source survives, so the diagram is still editable afterwards.
expect(html).toContain('data-mermaid-source=');
}, 60_000);
it('leaves the page own scripts exactly as the author wrote them', async () => {
// The UMD pattern the model writes most often: a loader tag plus a plain
// block that initializes Mermaid and then does the author's own work. An
// earlier version deleted the loader and kept the block, so `mermaid` was
// undefined and every statement after it stopped running — with the tool
// still reporting success (#1027 P1). Nothing here is ours to delete.
const file = join(dir, 'output', 'umd.html');
const loader = '<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>';
const bootstrap =
'<script>\n' +
'mermaid.initialize({ startOnLoad: true });\n' +
"document.getElementById('x').addEventListener('click', () => console.log('author code'));\n" +
'</script>';
writeFileSync(file, `<body><pre class="mermaid">graph TD\n A[a] --&gt; B</pre>\n${loader}\n${bootstrap}</body>`);
const res = await executeTool('RenderMermaid', { path: 'output/umd.html' }, ctx);
const html = readFileSync(file, 'utf8');
expect(html).toContain('<svg');
expect(html).toContain(loader);
expect(html).toContain(bootstrap);
// And the report must not say otherwise.
expect(res!.output).not.toMatch(/removed/i);
}, 60_000);
it('keeps a module script that merely imports something mermaid-shaped', async () => {
const file = join(dir, 'output', 'module.html');
const mod =
'<script type="module">\n' +
'import { drawMermaidLegend } from "./charts.js";\n' +
'import "./mermaid-theme-config.js";\n' +
'drawMermaidLegend(document.body);\n' +
'</script>';
const block = '<pre class="mermaid">graph TD\n A --&gt; B</pre>';
const original = `<body>${block}\n${mod}</body>`;
writeFileSync(file, original);
await executeTool('RenderMermaid', { path: 'output/module.html' }, ctx);
const html = readFileSync(file, 'utf8');
// Put the diagram back and the file is byte-identical: the block is the
// only thing that changed.
expect(html.replace(/<figure class="mermaid-diagram"[\s\S]*?<\/figure>/, block)).toBe(original);
}, 60_000);
it('embeds the diagram that rendered and changes nothing else', async () => {
const file = join(dir, 'output', 'mixed.html');
const good = '<pre class="mermaid">graph TD\n A --&gt; B</pre>';
const bad = '<pre class="mermaid">graph TD\n A[x (y)] --&gt; B</pre>';
const original = `<body><h1>t</h1>\n${good}\n${bad}\n<p>after</p></body>`;
writeFileSync(file, original);
const res = await executeTool('RenderMermaid', { path: 'output/mixed.html' }, ctx);
const html = readFileSync(file, 'utf8');
expect(res!.isError).toBe(true);
expect(res!.output).toContain('line 2');
expect(html).toContain('<svg');
// The failed one keeps its block, so the page still shows Mermaid's own
// error card and the agent can fix the source in place.
expect(html).toContain(bad);
expect(html.replace(/<figure class="mermaid-diagram"[\s\S]*?<\/figure>/, good)).toBe(original);
}, 60_000);
it('gives files of the same name in different folders different PNGs', async () => {
// Naming by basename alone made the second render overwrite the first, and
// the agent then inspected a stale image (#1027 P3).
mkdirSync(join(dir, 'output', 'sub'), { recursive: true });
writeFileSync(join(dir, 'output', 'same.html'), '<pre class="mermaid">graph TD\n A --&gt; B</pre>');
writeFileSync(join(dir, 'output', 'sub', 'same.html'), '<pre class="mermaid">graph TD\n C --&gt; D</pre>');
const first = await executeTool('RenderMermaid', { path: 'output/same.html', embed: false }, ctx);
const second = await executeTool('RenderMermaid', { path: 'output/sub/same.html', embed: false }, ctx);
const pngOf = (out: string) => out.match(/output\/diagrams\/\S+\.png/)![0];
expect(pngOf(first!.output)).not.toBe(pngOf(second!.output));
expect(existsSync(join(dir, pngOf(first!.output)))).toBe(true);
expect(existsSync(join(dir, pngOf(second!.output)))).toBe(true);
}, 60_000);
it('leaves a broken diagram in place and says which line broke', async () => {
const file = join(dir, 'output', 'broken.html');
writeFileSync(file, '<body><pre class="mermaid">graph TD\n A[x (y)] --&gt; B</pre></body>');
const res = await executeTool('RenderMermaid', { path: 'output/broken.html' }, ctx);
expect(res!.isError).toBe(true);
expect(res!.output).toContain('line 2');
// Nothing is lost from the document when the render fails.
expect(readFileSync(file, 'utf8')).toContain('<pre class="mermaid">');
}, 60_000);
it('refuses to rewrite a file under readonly/ before it renders anything', async () => {
// readonly/ is where the user puts files the agent must not touch. Embedding
// only swaps a diagram for its picture, but the whole point of that folder is
// that the agent does not decide what counts as a harmless edit (#1027 P1).
// Refusing up front also keeps the refusal honest: no PNG appears on disk
// under a message that says nothing was written (#1027 P3).
mkdirSync(join(dir, 'readonly'), { recursive: true });
const file = join(dir, 'readonly', 'user.html');
const original = '<body><pre class="mermaid">graph TD\n A --&gt; B</pre></body>';
writeFileSync(file, original);
const res = await executeTool('RenderMermaid', { path: 'readonly/user.html' }, ctx);
expect(readFileSync(file, 'utf8')).toBe(original);
expect(res!.output).toMatch(/readonly/);
expect(res!.output).toContain('embed: false');
// Not "Error: Error: …" — the sentence already says what failed.
expect(res!.output).not.toMatch(/: Error:/);
expect(existsSync(join(dir, 'output', 'diagrams', 'readonly_user.png'))).toBe(false);
// Every diagram rendered, but the file was not updated — that must not read
// as success (#1027 P3).
expect(res!.isError).toBe(true);
}, 60_000);
it('checks a readonly file when asked not to embed', async () => {
mkdirSync(join(dir, 'readonly'), { recursive: true });
writeFileSync(join(dir, 'readonly', 'user.html'), '<body><pre class="mermaid">graph TD\n A --&gt; B</pre></body>');
const res = await executeTool('RenderMermaid', { path: 'readonly/user.html', embed: false }, ctx);
expect(res!.isError).toBe(false);
expect(existsSync(join(dir, 'output', 'diagrams', 'readonly_user.png'))).toBe(true);
}, 60_000);
it('writes nothing at all when the task may not edit', async () => {
// Every path through this tool saves a PNG, so there is nothing it can do
// for a task that may not write. It says so instead of writing images and
// then reporting a refusal (#1027 P3) — same as PdfToImages.
const file = join(dir, 'output', 'no-edit.html');
const original = '<body><pre class="mermaid">graph TD\n A --&gt; B</pre></body>';
writeFileSync(file, original);
const res = await executeTool(
'RenderMermaid',
{ path: 'output/no-edit.html' },
{ ...ctx, editAllowed: false } as ToolContext,
);
expect(readFileSync(file, 'utf8')).toBe(original);
expect(res!.output).toContain('edit flag is false');
expect(res!.isError).toBe(true);
expect(existsSync(join(dir, 'output', 'diagrams', 'output_no-edit.png'))).toBe(false);
}, 60_000);
});
describe('diagram resolution (#1257)', () => {
/** PNG carries its pixel size in the IHDR chunk: width at byte 16, height at 20. */
const pngSize = (png: Buffer) => ({ width: png.readUInt32BE(16), height: png.readUInt32BE(20) });
it('rasterises the picture at twice the size the browser lays it out at', async () => {
// A stub bundle returning a 100x50 SVG, so the arithmetic is exact and no
// font metric can drift it. The stage adds 8px of padding on each side, so
// the CSS box is 116x66 — and the PNG used to be 116x66 too, which is why
// the labels turned to mush the moment anyone zoomed or printed (#1257).
const tmp = mkdtempSync(join(tmpdir(), 'mermaid-scale-'));
const bundlePath = join(tmp, 'fixed-size.js');
writeFileSync(
bundlePath,
'window.mermaid = { initialize() {}, parse() { return Promise.resolve(true); },' +
' render() { return Promise.resolve({ svg: \'<svg xmlns="http://www.w3.org/2000/svg"' +
' width="100" height="50" style="display:block"><rect width="100" height="50" fill="#333"></rect></svg>\' }); } };',
);
try {
const { results } = await renderMermaidDiagrams(['graph TD\n A-->B'], { bundlePath });
expect(results[0].ok).toBe(true);
expect(pngSize(results[0].png!)).toEqual({ width: 232, height: 132 });
} finally {
rmSync(tmp, { recursive: true, force: true });
}
}, 60_000);
it('saves that resolution to the file the agent is told to open', async () => {
// The value has to survive all the way onto disk, not just into a buffer.
// The expected size is read back out of the diagram that actually rendered,
// so no font metric is baked into the test.
const dir = mkdtempSync(join(tmpdir(), 'mermaid-scale-tool-'));
try {
mkdirSync(join(dir, 'output'), { recursive: true });
const ctx = { workspacePath: dir, editAllowed: true } as ToolContext;
writeFileSync(
join(dir, 'output', 'scale.html'),
'<body><pre class="mermaid">graph TD\n A[Start] --&gt; B[End]</pre></body>',
);
const res = await executeTool('RenderMermaid', { path: 'output/scale.html' }, ctx);
expect(res!.isError).toBe(false);
const html = readFileSync(join(dir, 'output', 'scale.html'), 'utf8');
const cssWidth = Number(html.match(/viewBox="0 0 ([\d.]+) /)![1]) + 16; // + stage padding
const { width } = pngSize(readFileSync(join(dir, 'output', 'diagrams', 'output_scale.png')));
expect(width).toBeGreaterThan(cssWidth * 1.9);
expect(width).toBeLessThan(cssWidth * 2.1);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}, 60_000);
});
describe('image budget cap (#1257 review)', () => {
const pngSize = (png: Buffer) => ({ width: png.readUInt32BE(16), height: png.readUInt32BE(20) });
/** Same ceiling the injection gate divides by five: 8,000 / 5 = 1,600 tokens. */
const PER_IMAGE_TOKEN_BUDGET = 1_600;
const asDataUrl = (png: Buffer) => `data:image/png;base64,${png.toString('base64')}`;
it('keeps a tall diagram inside the share of the image budget it may take', async () => {
// A stub bundle returning a 400x3200 SVG — the shape a long vertical chain
// takes. At scale 2 that is 832x6432 = 5.35 Mpx, which the estimator charges
// its maximum 3,279 tokens for, so three such diagrams could never enter the
// conversation at all: the gate drops every image of the round at 8,000.
const tmp = mkdtempSync(join(tmpdir(), 'mermaid-cap-'));
const bundlePath = join(tmp, 'oversized.js');
writeFileSync(
bundlePath,
'window.mermaid = { initialize() {}, parse() { return Promise.resolve(true); },' +
' render() { return Promise.resolve({ svg: \'<svg xmlns="http://www.w3.org/2000/svg"' +
' width="400" height="3200" style="display:block"><rect width="400" height="3200" fill="#333"></rect></svg>\' }); } };',
);
try {
const { results } = await renderMermaidDiagrams(['graph TD\n A-->B'], { bundlePath });
expect(results[0].ok).toBe(true);
const { width, height } = pngSize(results[0].png!);
expect(width * height).toBeLessThanOrEqual(1_200_000);
// Shape kept: 832/6432 within a pixel of rounding.
expect(width / height).toBeCloseTo(832 / 6432, 3);
// The constraint that actually matters — five of these still fit.
expect(estimateImagesTokens([asDataUrl(results[0].png!)])).toBeLessThanOrEqual(PER_IMAGE_TOKEN_BUDGET);
// Shrunk to the cap and no further — the sharpness scale 2 bought is only
// given up to the extent the budget forces. (This stub is deliberately
// past even the CSS box, 416x3216: that is the documented cost of the cap
// for extreme diagrams, and normal ones never reach it.)
expect(width * height).toBeGreaterThan(1_200_000 * 0.98);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
}, 60_000);
it('applies the cap to the file the agent is told to open', async () => {
// Real Mermaid, a 40-step chain: past the cap by a wide margin whatever the
// fonts do. What is under test is that the shrink happens before the write,
// not just somewhere in a buffer.
const dir = mkdtempSync(join(tmpdir(), 'mermaid-cap-tool-'));
try {
mkdirSync(join(dir, 'output'), { recursive: true });
const ctx = { workspacePath: dir, editAllowed: true } as ToolContext;
const steps = Array.from({ length: 40 }, (_, i) => ` N${i}[Step ${i}] --> N${i + 1}[Step ${i + 1}]`);
const res = await executeTool('RenderMermaid', { code: `graph TD\n${steps.join('\n')}` }, ctx);
expect(res!.isError).toBe(false);
const png = readFileSync(join(dir, 'output', 'diagrams', 'diagram.png'));
const { width, height } = pngSize(png);
expect(width * height).toBeLessThanOrEqual(1_200_000);
expect(estimateImagesTokens([asDataUrl(png)])).toBeLessThanOrEqual(PER_IMAGE_TOKEN_BUDGET);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}, 90_000);
it('leaves a diagram that is already inside the budget untouched', async () => {
// The shrink must not cost the small diagrams anything: 232x132 is 30k px,
// nowhere near the cap, and it comes back byte for byte.
const png = Buffer.alloc(24);
png.writeUInt32BE(0x89504e47, 0);
png.writeUInt32BE(232, 16);
png.writeUInt32BE(132, 20);
expect(await capPngPixels(png)).toBe(png);
});
});
describe('render budget', () => {
it('gives up on the whole call instead of spending the budget on every diagram', async () => {
// Real Chromium and the real timeout path; only the bundle stands in,
// because no valid diagram wedges the renderer on demand. What is under
// test is the budget, not the rendering: before this, a timeout only ended
// ONE diagram, so 30 of them could hold a worker for 600s (#1027 P2).
const tmp = mkdtempSync(join(tmpdir(), 'mermaid-budget-'));
const bundlePath = join(tmp, 'never-answers.js');
writeFileSync(
bundlePath,
'window.mermaid = { initialize() {}, parse() { return new Promise(() => {}); },' +
' render() { return new Promise(() => {}); } };',
);
try {
const started = Date.now();
const { results } = await renderMermaidDiagrams(
['graph TD\n A-->B', 'graph TD\n C-->D', 'graph TD\n E-->F', 'graph TD\n G-->H'],
{ bundlePath, timeoutMs: 2_500, totalTimeoutMs: 30_000 },
);
const elapsed = Date.now() - started;
expect(results.map(r => r.ok)).toEqual([false, false, false, false]);
expect(results[0].diagnostic!.message).toContain('timed out');
expect(results[3].diagnostic!.message).toContain('stopped responding');
// One stall, not four: 4 × 2.5s plus a browser launch would blow this.
expect(elapsed).toBeLessThan(7_000);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
}, 60_000);
});
describe('input limits', () => {
it('refuses an absurdly long diagram instead of handing it to the browser', async () => {
// page.evaluate has no timeout of its own, so the cheap defence is at the
// door: a diagram this size is a mistake, not a drawing (#1027 P2).
const dir = mkdtempSync(join(tmpdir(), 'mermaid-limit-'));
try {
const ctx = { workspacePath: dir, editAllowed: true } as ToolContext;
const res = await executeTool('RenderMermaid', { code: 'graph TD\n' + 'A-->B\n'.repeat(5000) }, ctx);
expect(res!.isError).toBe(true);
expect(res!.output).toContain('the limit is');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
+494
View File
@@ -0,0 +1,494 @@
/**
* RenderMermaid (#1025) — check a diagram, then show it.
*
* The agent gets no guidance anywhere about how to write Mermaid, so it picks a
* CDN and a version per document and the syntax it writes often doesn't match
* the version that ends up running. This tool closes that loop: it parses with
* the version WE ship, hands back the failing line, renders a PNG the agent can
* actually look at (ReadImage takes no SVG), and — for HTML — swaps the diagram
* source for an inline SVG so the diagram itself needs no script, no network and
* no version agreement with anything.
*
* One render pass produces both files, so what the agent inspects and what the
* document carries can never disagree.
*
* The rest of the page is the author's. This tool replaces diagram blocks and
* touches nothing else — in particular it never deletes the page's scripts, not
* even a Mermaid loader the SVG has made redundant (#1027).
*/
import { readFileSync, mkdirSync, writeFileSync, existsSync } from 'fs';
import { dirname, join, extname, resolve } from 'path';
import { fileURLToPath } from 'url';
import { chromium } from 'playwright';
import sharp from 'sharp';
import type { ToolDef } from '../../llm/openai-compat.js';
import type { ToolContext, ToolResult } from './tool-context.js';
import { resolveAndGuard, resolveOutputPathWithin, assertWritable } from './core.js';
import { buildLaunchOptions } from '../browser-launch.js';
import { logger } from '../../logger.js';
import { loadConfig } from '../../config.js';
import { extractMermaidBlocks, replaceHtmlBlocksWithSvg, type MermaidSourceKind } from './mermaid-blocks.js';
const __dirnameMermaid = dirname(fileURLToPath(import.meta.url));
/**
* Budget for the WHOLE call, shared by every page operation. Per-diagram limits
* alone don't bound anything: 30 diagrams × 20s is ten minutes of worker slot
* (#1027 P2), so the deadline is what actually protects it.
*/
const RENDER_TIMEOUT_MS = 60_000;
/** Ceiling for one diagram, capped further by whatever is left of the budget. */
const DIAGRAM_TIMEOUT_MS = 20_000;
const MAX_DIAGRAMS = 30;
/** A diagram this long is a mistake, and it is cheaper to refuse than to render. */
const MAX_CODE_CHARS = 20_000;
/**
* The PNG is a screenshot, so it carries exactly as many pixels as the browser
* says the device has. At 1 the labels came out at the CSS size and turned to
* mush the moment anyone zoomed or printed the page (#1257). 2 doubles the
* raster without touching the layout — the SVG in the HTML output is unaffected.
*/
const DEVICE_SCALE_FACTOR = 2;
/**
* Ceiling on the pixel count of the PNG we write, applied after the screenshot.
*
* The doc tells the agent to open the file with ReadImage, and ReadImage does
* not shrink anything — the bytes go into the conversation as they are. The gate
* in agent-loop/image-context.ts allows at most 8,000 tokens of image per round
* and, when the total is over, drops EVERY image of that round, not just the big
* one. context/token-estimate.ts charges pixels / 750 and stops counting at
* 1568×1568 = 2,458,624 px = 3,279 tokens.
*
* So the cap has to sit well under that plateau. Cutting at 2.45 Mpx would buy
* nothing: a measured 25-step chain came out 504×5174 = 2,607,696 px at scale 2,
* already charged the full 3,279, so three of them are 9,837 — over the ceiling
* no matter how much context is free. Dividing the ceiling by five diagrams
* gives 1,600 tokens each, and 1,600 × 750 = 1,200,000 px. Five capped images
* total exactly 8,000, and the gate compares with `>`, so they still get in. The
* same 25-step chain measures 341×3501 = 1,193,841 px after the cap: 1,592
* tokens, three of them 4,776.
*
* The cost: nothing until the picture is past 1.2 Mpx (the 10-step chain at
* 1,035,216 px is untouched), and output below what scale 1 used to give only
* once the CSS box ITSELF is past 1.2 Mpx — a chain of roughly 45 steps in the
* shape measured above. The capped 25-step is still 1.8× its own CSS box.
*/
const MAX_PNG_PIXELS = 1_200_000;
/**
* page.evaluate takes no timeout of its own — Playwright's default only covers
* navigation and locator work. Without this, a pathological diagram parks a
* Chromium instance and a worker slot forever; the reject unwinds into the
* finally that closes the browser.
*/
async function withTimeout<T>(work: Promise<T>, ms: number, what: string): Promise<T> {
let timer: NodeJS.Timeout | undefined;
try {
return await Promise.race([
work,
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`${what} timed out after ${ms}ms`)), ms);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
/** A PNG carries its pixel size in the IHDR chunk: width at byte 16, height at 20. */
export function readPngPixelSize(png: Buffer): { width: number; height: number } {
if (png.length < 24 || png.readUInt32BE(0) !== 0x89504e47) throw new Error('not a PNG');
return { width: png.readUInt32BE(16), height: png.readUInt32BE(20) };
}
/**
* Shrink the screenshot to MAX_PNG_PIXELS if it is over, keeping the shape.
*
* Both sides are floored on purpose: floor × floor is always at or under the
* cap, which is what makes the token estimate provably bounded. Scaling one side
* and letting the other be derived can round the product a few hundred pixels
* past the cap, and the estimator rounds tokens up.
*/
export async function capPngPixels(png: Buffer, maxPixels = MAX_PNG_PIXELS): Promise<Buffer> {
const { width, height } = readPngPixelSize(png);
const pixels = width * height;
if (pixels <= maxPixels) return png;
const scale = Math.sqrt(maxPixels / pixels);
const w = Math.max(1, Math.floor(width * scale));
const h = Math.max(1, Math.floor(height * scale));
// fit 'inside' preserves the aspect ratio; sharp's default ('cover') crops.
return await sharp(png).resize(w, h, { fit: 'inside' }).png().toBuffer();
}
/**
* Where the Mermaid bundle lives at runtime. `vendor/` is what the Docker image
* ships (the builder copies it out of the UI's node_modules); the node_modules
* path is the source checkout. Deliberately ONE source — the UI's own
* dependency — so the version that validates is the version that renders.
*/
export function mermaidBundleCandidates(rootDir: string): string[] {
return [
join(rootDir, 'vendor', 'mermaid', 'mermaid.min.js'),
join(rootDir, 'ui', 'node_modules', 'mermaid', 'dist', 'mermaid.min.js'),
];
}
export function resolveMermaidBundle(rootDir = resolve(__dirnameMermaid, '../../..')): string {
for (const candidate of mermaidBundleCandidates(rootDir)) {
if (existsSync(candidate)) return candidate;
}
throw new Error(
'Mermaid bundle not found. Run `npm run build:ui` (it copies mermaid into vendor/) or install the UI dependencies.',
);
}
/**
* The UMD bundle doesn't carry its own version, so read it from the sidecar the
* copy script writes (vendor/) or from the package we copied it out of (dev).
* Reporting it matters: "which version said this was fine" is the question the
* whole tool exists to answer.
*/
export function readMermaidVersion(bundlePath: string): string {
const sidecar = join(dirname(bundlePath), 'version.txt');
if (existsSync(sidecar)) return readFileSync(sidecar, 'utf8').trim();
const pkg = join(dirname(bundlePath), '..', 'package.json');
if (existsSync(pkg)) {
try {
return String(JSON.parse(readFileSync(pkg, 'utf8')).version ?? 'unknown');
} catch { /* fall through */ }
}
return 'unknown';
}
export interface MermaidDiagnostic {
message: string;
/** 1-based line within the diagram source, when the parser reported one. */
line?: number;
/** The offending line itself — the message alone rarely points anywhere useful. */
sourceLine?: string;
}
/**
* Turn a Mermaid parse error into something the agent can act on. Mermaid says
* "Parse error on line 2:" and then quotes a flattened, context-free excerpt;
* pairing the number with the real line is what makes it fixable.
*/
export function parseMermaidError(message: string, code: string): MermaidDiagnostic {
const firstLine = message.split('\n')[0].trim();
const match = message.match(/(?:on|at) line (\d+)/i);
if (!match) return { message: firstLine };
const line = Number(match[1]);
const sourceLine = code.split('\n')[line - 1];
return { message: firstLine, line, ...(sourceLine !== undefined ? { sourceLine } : {}) };
}
export interface MermaidRenderResult {
ok: boolean;
svg?: string;
png?: Buffer;
diagnostic?: MermaidDiagnostic;
}
/**
* Render every diagram in ONE browser. Launching Chromium costs about a second,
* so a page per diagram would make checking a document with five of them
* pointlessly slow.
*/
export async function renderMermaidDiagrams(
codes: string[],
opts: { bundlePath?: string; timeoutMs?: number; totalTimeoutMs?: number } = {},
): Promise<{ version: string; results: MermaidRenderResult[] }> {
const totalMs = opts.totalTimeoutMs ?? RENDER_TIMEOUT_MS;
const perDiagramMs = Math.min(opts.timeoutMs ?? DIAGRAM_TIMEOUT_MS, totalMs);
const deadline = Date.now() + totalMs;
const remaining = () => deadline - Date.now();
const bundlePath = opts.bundlePath ?? resolveMermaidBundle();
const bundle = readFileSync(bundlePath, 'utf8');
// Honour the operator's browser settings (executable path / channel) exactly
// like the browser tool and the session manager do — this was the only launch
// site ignoring them.
const browser = await chromium.launch(buildLaunchOptions(loadConfig().browser, true));
try {
const page = await browser.newPage({ deviceScaleFactor: DEVICE_SCALE_FACTOR });
page.setDefaultTimeout(perDiagramMs);
await page.setContent('<!doctype html><body><div id="stage"></div></body>');
await page.addScriptTag({ content: bundle });
await page.evaluate(() => {
const m = (window as unknown as { mermaid: { initialize: (c: unknown) => void } }).mermaid;
// Same options the UI initializes with (FilePreview) — a diagram that
// passes here must behave the same when the app renders it.
m.initialize({ startOnLoad: false, theme: 'default' });
});
const version = readMermaidVersion(bundlePath);
const results: MermaidRenderResult[] = [];
// A page that stopped answering does not come back, so the first timeout
// ends the run. Carrying on would spend the full per-diagram budget on
// every remaining diagram for nothing (#1027 P2).
let giveUp: string | null = null;
for (let i = 0; i < codes.length; i++) {
if (!giveUp && remaining() <= 0) giveUp = `the ${totalMs}ms render budget ran out`;
if (giveUp) {
results.push({ ok: false, diagnostic: { message: `Not rendered: ${giveUp}.` } });
continue;
}
const budget = Math.min(perDiagramMs, remaining());
const rendered = await withTimeout(
page.evaluate(
async ([code, id]) => {
const m = (window as unknown as {
mermaid: { parse: (c: string) => Promise<unknown>; render: (id: string, c: string) => Promise<{ svg: string }> };
}).mermaid;
try {
await m.parse(code);
const { svg } = await m.render(id, code);
return { ok: true as const, svg };
} catch (e) {
const err = e as { str?: string; message?: string };
return { ok: false as const, message: String(err?.str ?? err?.message ?? e) };
}
},
[codes[i], `render-${i}`] as [string, string],
),
budget,
`diagram ${i + 1}`,
).catch((e: unknown) => ({ ok: false as const, timedOut: true, message: (e as Error).message ?? String(e) }));
if (!rendered.ok) {
if ('timedOut' in rendered) giveUp = `the renderer stopped responding (diagram ${i + 1})`;
results.push({ ok: false, diagnostic: parseMermaidError(rendered.message, codes[i]) });
continue;
}
// Screenshot the SVG we just produced — the PNG is a picture OF the
// embedded artwork, not a second, independent render. Both steps draw on
// the same budget, so the whole call stays bounded.
const png = await withTimeout(
(async () => {
await page.evaluate((svg) => {
const stage = document.getElementById('stage')!;
stage.innerHTML = svg;
stage.style.cssText = 'display:inline-block;background:#fff;padding:8px';
}, rendered.svg);
return page.locator('#stage').screenshot({ type: 'png' });
})(),
Math.max(1, Math.min(perDiagramMs, remaining())),
`diagram ${i + 1} screenshot`,
).catch((e: unknown) => {
giveUp = `the renderer stopped responding (diagram ${i + 1})`;
logger.warn(`[mermaid] screenshot failed: ${(e as Error).message ?? String(e)}`);
return undefined;
});
// Scale 2 is what makes the labels readable, but it also quadruples the
// pixels, and a tall diagram then blows the image budget and takes every
// other image of that round down with it. Shrink here, once, so the file
// on disk is the one the agent can actually open.
const capped = png
? await capPngPixels(png).catch((e: unknown) => {
logger.warn(`[mermaid] downscale failed: ${(e as Error).message ?? String(e)}`);
return undefined;
})
: undefined;
if (!capped) {
results.push({ ok: false, diagnostic: { message: `Rendered, but the image could not be produced (diagram ${i + 1}).` } });
continue;
}
results.push({ ok: true, svg: rendered.svg, png: capped });
}
return { version, results };
} finally {
await browser.close().catch(() => {});
}
}
// --- Tool surface -----------------------------------------------------------
export const TOOL_DEFS: Record<string, ToolDef> = {
RenderMermaid: {
type: 'function',
function: {
name: 'RenderMermaid',
description:
'Mermaid 図の構文を検査して PNG 画像に描画し、HTML ファイルなら図を SVG に置き換えて外部依存を無くす。詳細は ReadToolDoc({ name: "RenderMermaid" }) で取得可能。',
parameters: {
type: 'object',
properties: {
code: { type: 'string', description: 'Mermaid diagram source to check and render.' },
path: {
type: 'string',
description:
'Workspace file (.md / .html) to check instead: every Mermaid block in it is validated and rendered.',
},
embed: {
type: 'boolean',
description:
'HTML only, default true: replace each diagram with the rendered SVG so it displays without a script.',
},
},
},
},
},
};
function detectKind(filePath: string): MermaidSourceKind {
return /\.html?$/i.test(filePath) ? 'html' : 'md';
}
/**
* PNG name for a source file. Flattening the WHOLE relative path matters:
* naming by basename alone made `output/a.html` and `output/sub/a.html` write
* the same file, so the second overwrote the first and the agent then looked at
* the wrong diagram (#1027 P3).
*/
function outputStem(filePath: string | undefined): string {
if (!filePath) return join('output', 'diagrams', 'diagram');
const withoutExt = filePath.slice(0, filePath.length - extname(filePath).length);
const flat = withoutExt.replace(/[^\w.-]+/g, '_').replace(/^_+/, '') || 'diagram';
return join('output', 'diagrams', flat);
}
/** `String(e)` on an Error prints "Error: …" into a sentence that already says
* what went wrong (#1027 P3). */
function errText(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
/** Returns null for names this module doesn't own — the dispatch chain in
* tools/index.ts relies on that to fall through to the next module. */
export async function executeTool(name: string, input: unknown, ctx: ToolContext): Promise<ToolResult | null> {
if (name !== 'RenderMermaid') return null;
const args = (input ?? {}) as { code?: unknown; path?: unknown; embed?: unknown };
const codeArg = typeof args.code === 'string' ? args.code : undefined;
const pathArg = typeof args.path === 'string' ? args.path : undefined;
if (!codeArg && !pathArg) return { isError: true, output: 'RenderMermaid needs either `code` or `path`.' };
if (codeArg && pathArg) return { isError: true, output: 'RenderMermaid takes `code` or `path`, not both.' };
// Every path through this tool saves a PNG, so a task that may not write
// files cannot use it at all. Say so before launching a browser, the way
// PdfToImages does — rendering first and refusing afterwards left images on
// disk under a report that said nothing had been written (#1027 P3).
if (!ctx.editAllowed) {
return { isError: true, output: 'RenderMermaid is not allowed: edit flag is false' };
}
let sourceText = '';
let absPath: string | undefined;
let kind: MermaidSourceKind = 'md';
if (pathArg) {
try {
absPath = resolveAndGuard(ctx.workspacePath, pathArg);
sourceText = readFileSync(absPath, 'utf8');
} catch (e) {
return { isError: true, output: `Could not read ${pathArg}: ${errText(e)}` };
}
kind = detectKind(pathArg);
}
// Same reasoning, and the same rule Write/Edit enforce: readonly/ is the
// user's own area. Refuse before rendering rather than after, so the refusal
// and what is left on disk agree.
const wantsEmbed = Boolean(pathArg) && kind === 'html' && args.embed !== false;
if (wantsEmbed && absPath) {
try {
assertWritable(ctx.workspacePath, absPath);
} catch (e) {
return {
isError: true,
output:
`Could not update ${pathArg}: ${errText(e)}\n` +
'Pass embed: false to check the diagrams and get their PNGs without rewriting the file.',
};
}
}
const blocks = codeArg ? [{ code: codeArg, start: 0, end: 0 }] : extractMermaidBlocks(sourceText, kind);
if (blocks.length === 0) {
return { isError: false, output: `No Mermaid blocks found in ${pathArg}.` };
}
if (blocks.length > MAX_DIAGRAMS) {
return { isError: true, output: `Too many diagrams (${blocks.length}); the limit is ${MAX_DIAGRAMS}.` };
}
const oversized = blocks.findIndex(b => b.code.length > MAX_CODE_CHARS);
if (oversized >= 0) {
return {
isError: true,
output:
`Diagram ${oversized + 1} is ${blocks[oversized].code.length} characters; the limit is ${MAX_CODE_CHARS}. ` +
'Split it into several smaller diagrams — one that large is unreadable anyway.',
};
}
let version: string;
let results: MermaidRenderResult[];
try {
({ version, results } = await renderMermaidDiagrams(blocks.map(b => b.code)));
} catch (e) {
logger.warn(`[mermaid] render failed: ${errText(e)}`);
return { isError: true, output: `Mermaid rendering is unavailable: ${errText(e)}` };
}
const stem = outputStem(pathArg);
const lines: string[] = [`mermaid ${version} — ${blocks.length} diagram(s)`];
const svgs: (string | null)[] = [];
for (let i = 0; i < results.length; i++) {
const r = results[i];
const label = blocks.length === 1 ? '' : ` #${i + 1}`;
if (!r.ok) {
svgs.push(null);
const d = r.diagnostic!;
lines.push(
`FAILED${label}: ${d.message}` +
(d.line ? `\n line ${d.line}: ${d.sourceLine ?? ''}` : ''),
);
continue;
}
svgs.push(r.svg ?? null);
const pngRel = `${stem}${blocks.length === 1 ? '' : `-${i + 1}`}.png`;
try {
const pngAbs = resolveOutputPathWithin(ctx.workspacePath, pngRel, ['output']);
mkdirSync(dirname(pngAbs), { recursive: true });
writeFileSync(pngAbs, r.png!);
lines.push(`OK${label}: ${pngRel} — open it with ReadImage to check the diagram is what you meant.`);
} catch (e) {
lines.push(`OK${label}: rendered, but the image could not be saved (${errText(e)}).`);
}
}
const failed = results.filter(r => !r.ok).length;
// Failing to write is a result the agent must not read past — every diagram
// can render fine and the file still not be updated (#1027 P3).
let embedError: string | null = null;
if (wantsEmbed && absPath) {
const rendered = svgs.filter(Boolean).length;
if (rendered > 0) {
try {
writeFileSync(absPath, replaceHtmlBlocksWithSvg(sourceText, blocks, svgs), 'utf8');
// Claim only what is true, and only about the diagrams: nothing else in
// the file was touched, so nothing else can be promised about it.
lines.push(
`Embedded ${rendered} diagram(s) into ${pathArg} as inline SVG — they display with no script and no ` +
'network. Everything else in the file is untouched, and the original source is kept in ' +
'data-mermaid-source for later edits.',
);
} catch (e) {
embedError = errText(e);
lines.push(`Could not update ${pathArg}: ${embedError}`);
}
}
}
if (failed > 0) {
lines.push(
`${failed} diagram(s) failed. Common cause: unquoted parentheses or brackets in a label — ` +
'write A["text (note)"] instead of A[text (note)].',
);
}
return { isError: failed > 0 || embedError !== null, output: lines.join('\n') };
}
+1 -1
View File
@@ -20,7 +20,7 @@
*/
import { ToolDef } from '../../llm/openai-compat.js';
import type { ToolContext, ToolResult } from './core.js';
import type { ToolContext, ToolResult } from './tool-context.js';
import { MISSION_BRIEF_FIELDS, type MissionBriefField } from '../../db/repository.js';
// Per-field 1-line descriptions, iterated to build the tool params so a new
+280
View File
@@ -0,0 +1,280 @@
/**
* module-manifest.test.ts — ツール群の自己申告と、その収集口の試験(#1126)。
*
* ここで守りたいこと。
*
* 1. **組み立て済みの状態を通す。**手で組み立てた部品を個別に確かめるのではなく、
* 実際の登録一覧から実際に読み込ませて、実際の解決関数の返りを見る。
* 参考にした製品(DeepSeek Harness)で、単体試験 178 本・網羅率 100% を
* 通過したまま実機で落ちた事故がある。束ね方の経路を試験が通っていなかった。
*
* 2. **握り潰しの上に立たない。**index.ts の tryLoadModule は読み込み失敗を
* 握り潰す。だからモジュールが読めなくなっても、ふつうに書いた試験は緑のまま
* ツールだけが黙って消える。ここでは try/catch を挟まず直接 import して、
* 1 つでも読めなければ落ちるようにしてある。
*
* 3. **移す前後で実効値が変わらない。**属性を中央の一覧からモジュールの申告へ
* 移す操作は、外から見て何も変えてはいけない。移し忘れ・片側だけの修正は
* 実効集合の固定値との突き合わせで落ちる。
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
import { tmpdir } from 'os';
import {
listRegisteredToolModuleSpecifiers,
loadRegisteredToolModules,
collectToolModuleManifests,
resolveMetaToolNames,
resolveRawSaveToolNames,
resolveRawLogOnlyToolNames,
resolveSensitiveToolNames,
resolveDeclaredCategories,
_resetToolModuleCacheForTests,
_setToolModuleManifestOverridesForTests,
EXTRA_MODULE_SPECIFIERS,
} from './module-manifest.js';
import { MODULE_SPECS, getToolCategoryMap, listToolCategories, _resetToolCategoryCacheForTests } from './tool-categories.js';
import { executeTool, getToolDefs, type ToolContext } from './index.js';
const HERE = path.dirname(fileURLToPath(import.meta.url));
// ─────────────────────────────────────────────────────────────────────────────
// 1. 読み込むはずのモジュールが全部読めていること
// ─────────────────────────────────────────────────────────────────────────────
describe('module-manifest — 登録済みモジュールが全部読めていること', () => {
it('登録されたモジュールはすべて import でき、空でない TOOL_DEFS を持つ', async () => {
// ここが本丸。try/catch を書かないこと——書いた瞬間、この試験は
// tryLoadModule と同じ握り潰しになり、存在価値が消える。
const specifiers = listRegisteredToolModuleSpecifiers();
expect(specifiers.length).toBeGreaterThan(20);
const empty: string[] = [];
for (const specifier of specifiers) {
const mod = (await import(specifier)) as { TOOL_DEFS?: Record<string, unknown> };
if (Object.keys(mod.TOOL_DEFS ?? {}).length === 0) empty.push(specifier);
}
expect(empty).toEqual([]);
});
it('収集口も読み込み失敗を 1 件も出さない', async () => {
// loadRegisteredToolModules は実行時の寛容さを保つため例外を飲むが、
// 飲んだ事実は failures に残す。試験はそこを直接見る。
_resetToolModuleCacheForTests();
const { loaded, failures } = await loadRegisteredToolModules();
expect(failures).toEqual([]);
expect(loaded.length).toBe(listRegisteredToolModuleSpecifiers().length);
});
it('TOOL_DEFS を持つファイルは、登録一覧のどれかに必ず載っている', async () => {
// 固定の追加一覧(EXTRA_MODULE_SPECIFIERS)だけに頼ると、MODULE_SPECS が
// 抜けたのと同じ形でまた抜ける。ディレクトリを実際に走査して照合する。
const registered = new Set(listRegisteredToolModuleSpecifiers());
const missing: string[] = [];
for (const file of fs.readdirSync(HERE)) {
if (!file.endsWith('.ts') || file.endsWith('.test.ts') || file.endsWith('.d.ts')) continue;
const source = fs.readFileSync(path.join(HERE, file), 'utf-8');
if (!/^export const TOOL_DEFS/m.test(source)) continue;
const specifier = `./${file.replace(/\.ts$/, '.js')}`;
if (!registered.has(specifier)) missing.push(specifier);
}
expect(missing).toEqual([]);
});
it('MODULE_SPECS に無いが実行時に読まれるモジュールを、追加一覧が拾っている', async () => {
// 発見した食い違いの記録。この 2 本は index.ts が dispatch しているのに
// MODULE_SPECS に無く、/api/tools のツール一覧にも出ていない。
// MODULE_SPECS へ足すと設定画面の見た目が変わるのでこの段では足さず、
// 読み込み検査の対象にだけ入れてある。
const fromSpecs = new Set(MODULE_SPECS.map((s) => s.specifier.replace('../engine/tools/', './')));
for (const extra of EXTRA_MODULE_SPECIFIERS) {
expect(fromSpecs.has(extra)).toBe(false);
expect(listRegisteredToolModuleSpecifiers()).toContain(extra);
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 2. 実効値の固定(移す前の値と一致していること)
// ─────────────────────────────────────────────────────────────────────────────
describe('module-manifest — 実効値が移す前と一致すること', () => {
beforeEach(() => {
_setToolModuleManifestOverridesForTests(null);
_resetToolModuleCacheForTests();
});
it('常時利用(META)の実効集合は 28 個で、移す前と同じ顔ぶれ', async () => {
const expected = [
'ReadToolDoc', 'CreateChecklist', 'CheckItem', 'GetChecklist', 'MissionUpdate', 'AskUser',
'SearchTaskConversation', 'ReadTaskConversation',
'SearchWorkspaceTasks', 'ListWorkspaceTasks',
'GetFileProvenance', 'ListWorkspaceFiles',
'ListUserAssets', 'RunUserScript', 'UpdateUserMemory', 'ReadUserMemory',
'ReadUserAgents', 'UpdateUserAgents', 'WriteUserScript', 'Brainstorm',
'ReadAppDoc', 'ListAppDocs', 'GetMyOrchestratorState', 'ReadSkill',
'ListSkills', 'InstallSkill', 'RequestTool', 'RequestPackage',
];
const actual = [...(await resolveMetaToolNames())];
expect(actual.slice().sort()).toEqual(expected.slice().sort());
expect(actual.length).toBe(28);
});
it('生データ保存の実効集合は 15 個で、移す前と同じ顔ぶれ', async () => {
const expected = [
'WebFetch', 'WebSearch',
'XSearch', 'XUserPosts', 'XPostDetail', 'XTimeline',
'BrowseWeb',
'GetYouTubeTranscript', 'SearchYouTube',
'SearchAmazon', 'TranscribeAudio',
'SearchMicrosoftLearn', 'FetchMicrosoftLearn',
'SearchMicrosoftLearnCache', 'RefreshMicrosoftLearnCache',
];
const actual = [...(await resolveRawSaveToolNames())];
expect(actual.slice().sort()).toEqual(expected.slice().sort());
});
// Speak は中央の一覧ではなく speak.ts の申告から来ている(#1145)。
// 音声の本体は output/ に置くので、logs/raw/ には二重に持たずパスだけ記録する。
it('パスだけ記録の実効集合は DownloadFile と Speak', async () => {
expect([...(await resolveRawLogOnlyToolNames())].sort()).toEqual(['DownloadFile', 'Speak']);
});
it('既定 OFF の実効集合は 3 本で、移す前と同じ顔ぶれ', async () => {
const actual = [...(await resolveSensitiveToolNames())].sort();
expect(actual).toEqual(['Bash', 'SpawnSubTask', 'SpinOffTask']);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 3. 申告が実際に効いていること(中央の一覧との重複ではないこと)
// ─────────────────────────────────────────────────────────────────────────────
describe('module-manifest — 申告が実際に効いていること', () => {
beforeEach(() => {
_setToolModuleManifestOverridesForTests(null);
_resetToolModuleCacheForTests();
});
it('移した 4 モジュールが申告を出しており、中身が期待どおり', async () => {
const { manifests } = await collectToolModuleManifests();
const byId = new Map(manifests.map((m) => [m.id, m]));
expect(byId.get('x')?.rawSaveTools).toEqual(['XSearch', 'XUserPosts', 'XPostDetail', 'XTimeline']);
expect(byId.get('youtube')?.rawSaveTools).toEqual(['GetYouTubeTranscript', 'SearchYouTube']);
expect(byId.get('checklist')?.metaTools).toEqual(['CreateChecklist', 'CheckItem', 'GetChecklist']);
expect(byId.get('task-spawn')?.sensitiveTools).toEqual(['SpinOffTask']);
});
it('移した名前は中央の一覧から実際に抜けている(申告が唯一の出どころ)', async () => {
// 両方に書いてあると、申告を消しても実効値が変わらない=申告が飾りになる。
const { META_TOOLS, SENSITIVE_TOOLS } = await import('./tool-categories.js');
const { RAW_SAVE_TOOLS } = await import('./raw-save.js');
for (const n of ['CreateChecklist', 'CheckItem', 'GetChecklist']) {
expect(META_TOOLS.has(n)).toBe(false);
}
expect(SENSITIVE_TOOLS.has('SpinOffTask')).toBe(false);
for (const n of ['XSearch', 'XUserPosts', 'XPostDetail', 'XTimeline', 'GetYouTubeTranscript', 'SearchYouTube']) {
expect(RAW_SAVE_TOOLS.has(n)).toBe(false);
}
});
it('申告した分類が、分類表に反映される', async () => {
_resetToolCategoryCacheForTests();
const declared = await resolveDeclaredCategories();
expect(declared.get('./x.js')).toBe('x');
expect(declared.get('./task-spawn.js')).toBe('orchestration');
const catMap = await getToolCategoryMap();
expect(catMap.get('XSearch')).toBe('x');
expect(catMap.get('SpinOffTask')).toBe('orchestration');
expect(catMap.get('CreateChecklist')).toBe('checklist');
expect(catMap.get('GetYouTubeTranscript')).toBe('youtube');
});
it('申告した分類は、既知の分類一覧に必ず含まれる', async () => {
// 既知でない分類を申告すると、ワークスペースのツール解決で
// **そのモジュールのツールが全部黙って消える**。
// 解決側は listToolCategories()(MODULE_SPECS + core)から有効な分類を
// 組み立てるので、そこに無い分類のツールはどの分類にも属さず落ちる。
// 申告が守るべき唯一の約束をここで縛る。
// (パック化の段で分類を外から増やせるようにするなら、この検査も一緒に直す)
const known = new Set(await listToolCategories());
const unknown: string[] = [];
for (const [specifier, category] of await resolveDeclaredCategories()) {
if (!known.has(category)) unknown.push(`${specifier} -> ${category}`);
}
expect(unknown).toEqual([]);
});
it('TOOL_DEFS に無い名前を申告しても採用しない(幽霊を生やさない)', async () => {
_setToolModuleManifestOverridesForTests(null);
_resetToolModuleCacheForTests();
const { phantoms } = await collectToolModuleManifests();
// 実物の申告に幽霊は無いこと
expect(phantoms).toEqual([]);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 4. 組み立て済みの状態を通す配線試験
// ─────────────────────────────────────────────────────────────────────────────
describe('module-manifest — 組み立て済みの経路で申告が効くこと', () => {
let workspacePath = '';
beforeEach(() => {
workspacePath = fs.mkdtempSync(path.join(tmpdir(), 'manifest-wiring-'));
_setToolModuleManifestOverridesForTests(null);
_resetToolModuleCacheForTests();
});
afterEach(() => {
_setToolModuleManifestOverridesForTests(null);
_resetToolModuleCacheForTests();
if (workspacePath) {
fs.rmSync(workspacePath, { recursive: true, force: true });
workspacePath = '';
}
});
it('申告だけを出どころとする META ツールが、実際の道具一覧に載る', async () => {
// 許可一覧を空にして getToolDefs を通す。CreateChecklist は中央の一覧から
// 抜いてあるので、載っているなら申告が注入経路まで届いている。
const defs = await getToolDefs([], false);
const names = defs.map((d) => d.function.name);
expect(names).toContain('CreateChecklist');
expect(names).toContain('CheckItem');
expect(names).toContain('GetChecklist');
});
it('申告した生データ保存が、実行してファイルが落ちるところまで効く', async () => {
// 本物の生データ保存対象はすべてネットワーク越しで、この場では実行できない。
// そこで密閉して動かせる CreateChecklist を申告側で対象にし、
// executeTool → 保存 の配線を端から端まで通す。
_setToolModuleManifestOverridesForTests([
{ id: 'test-override', category: 'checklist', rawSaveTools: ['CreateChecklist'] },
]);
const ctx = { workspacePath, editAllowed: true } as ToolContext;
const result = await executeTool('CreateChecklist', { name: 'wiring', items: ['a'] }, ctx);
expect(result.isError).toBe(false);
const rawDir = path.join(workspacePath, 'logs', 'raw');
expect(fs.existsSync(rawDir)).toBe(true);
const files = fs.readdirSync(rawDir);
expect(files.some((f) => f.startsWith('createchecklist-'))).toBe(true);
});
it('申告しなければ生データは残らない(上の試験が空振りしていない証拠)', async () => {
const ctx = { workspacePath, editAllowed: true } as ToolContext;
const result = await executeTool('CreateChecklist', { name: 'nowiring', items: ['a'] }, ctx);
expect(result.isError).toBe(false);
expect(fs.existsSync(path.join(workspacePath, 'logs', 'raw'))).toBe(false);
});
});
+299
View File
@@ -0,0 +1,299 @@
/**
* module-manifest.ts — ツール群(モジュール)の自己申告と、その収集口。
*
* # なぜあるか
*
* ツールを 1 本足すと、いままでは「そのツールの属性」を書く先が中央の一覧に
* 散っていた。分類は tool-categories.ts、常時利用の扱いは index.ts と
* tool-categories.ts の 2 か所、生データ保存は raw-save.ts、既定 OFF は
* tool-categories.ts。足す人はモジュールを書いたあと、離れた 4 ファイルを
* 追って直す必要があり、1 か所落とすと黙って効かない。
*
* ここでは向きを反転させる。**モジュールが自分の属性を自分で申告し**、
* 収集はこの 1 ファイルがやる。
*
* # 旧ルートは残す(重要)
*
* 申告(TOOL_MODULE)を持たないモジュールは、いままでどおり中央の一覧で
* 決まる。resolve* 系はどれも「中央の一覧 ∪ 申告」を返すので、移していない
* モジュールの挙動は 1 バイトも変わらない。一度に全部は移さない。
*
* # 依存の向き
*
* このファイルは tool-categories.ts から MODULE_SPECS(データ)だけを取り、
* tool-categories.ts はこのファイルから収集関数を取る。相互参照になるが、
* どちらも**関数の中でしか相手を読まない**ので初期化順の問題は起きない。
* トップレベルで MODULE_SPECS を読む形に変えないこと。
*/
import { logger } from '../../logger.js';
import {
MODULE_SPECS,
META_TOOLS,
SENSITIVE_TOOLS,
} from './tool-categories.js';
import { RAW_SAVE_TOOLS, RAW_LOG_ONLY_TOOLS } from './raw-save.js';
import { isToolModuleDisabled } from './tool-pack-gate.js';
import { normalizeToolModuleSpecifier } from './tool-pack-ledger.js';
// ─────────────────────────────────────────────────────────────────────────────
// 申告の形
// ─────────────────────────────────────────────────────────────────────────────
/**
* モジュールが `export const TOOL_MODULE` で出す自己申告。
*
* 載せる項目は「いま中央の一覧に散っている、ツール単位の属性」に限っている。
* 逆に、次は載せていない。
* - 分類そのものが慎重扱いか(SENSITIVE_CATEGORIES): 分類の属性であって
* モジュールの属性ではない。効く範囲が API とスペース設定まで広がるので
* この段では触らない
* - ワークフロー(pieces)の同梱: パック化の段(次)の話
*/
export interface ToolModuleManifest {
/** モジュールの識別子。ファイル名(拡張子なし)と一致させる。診断とテストの照合に使う */
id: string;
/** 分類。tool-categories.ts の MODULE_SPECS と同じ意味・同じ語彙 */
category: string;
/** ワークスペースのツール設定に関係なく常時載せるツール名(META) */
metaTools?: readonly string[];
/** 実行結果を logs/raw/ に生のまま残すツール名 */
rawSaveTools?: readonly string[];
/** 保存先パスだけを記録するツール名(本体は二重に持たない) */
rawLogOnlyTools?: readonly string[];
/** 分類が既定 ON でも、個別に既定 OFF(明示の有効化が要る)にするツール名 */
sensitiveTools?: readonly string[];
}
/** 申告と定義を持ちうるモジュールの形。どちらも省略可(旧ルートのモジュール) */
export interface ManifestBearingModule {
TOOL_MODULE?: ToolModuleManifest;
TOOL_DEFS?: Record<string, unknown>;
}
// ─────────────────────────────────────────────────────────────────────────────
// 読み込む先の集合
// ─────────────────────────────────────────────────────────────────────────────
/**
* MODULE_SPECS に載っていないが、index.ts が実行時に読み込んでいるモジュール。
*
* この 2 本のツールはすべて META(常時利用)なので、MODULE_SPECS に無くても
* 実行時には届いてしまう。届いてしまうがゆえに、抜けていることに誰も気づけない
* ——現に /api/tools の一覧には出ていない。
*
* MODULE_SPECS 自体に足すと設定画面のツール一覧が変わる(利用者から見える
* 変化になる)ので、この段では足さない。ここに置いて**読み込み検査の対象には
* 入れる**にとどめる。次に MODULE_SPECS を触る人が判断できるよう、
* docs/maintenance-checklist.md に食い違いとして書いてある。
*/
export const EXTRA_MODULE_SPECIFIERS: readonly string[] = [
'./workspace-task-search.js',
'./package-request.js',
];
/**
* 読み込むはずのモジュール指定の全体。MODULE_SPECS ∪ EXTRA_MODULE_SPECIFIERS。
* 同じファイルが 2 分類に出てくる場合(ssh / ssh-console など)は 1 本に畳む。
*
* 環境全体で止められている群のモジュールは外す(#1126 段 C)。ここを外すことで、
* 申告の収集・分類の地図・生データ保存の対象が同時に消える——収集の入口が
* この 1 本しかないので、止め忘れる先が生まれない。
*
* 台帳に載っていない群は、いままでどおり全部通る。
*/
export function listRegisteredToolModuleSpecifiers(
opts?: { includeEnvDisabled?: boolean },
): string[] {
const seen = new Set<string>();
for (const { specifier } of MODULE_SPECS) seen.add(normalizeToolModuleSpecifier(specifier));
for (const s of EXTRA_MODULE_SPECIFIERS) seen.add(s);
if (opts?.includeEnvDisabled === true) return [...seen];
return [...seen].filter((s) => !isToolModuleDisabled(s));
}
// ─────────────────────────────────────────────────────────────────────────────
// 読み込みと収集
// ─────────────────────────────────────────────────────────────────────────────
export interface LoadedToolModule {
specifier: string;
module: ManifestBearingModule;
}
export interface ToolModuleLoadResult {
loaded: LoadedToolModule[];
/** 読めなかったモジュール。実行時は素通りするが、テストはここが空であることを見る */
failures: Array<{ specifier: string; message: string }>;
}
let _cachedLoad: ToolModuleLoadResult | null = null;
let _cachedLoadIncludingDisabled: ToolModuleLoadResult | null = null;
/**
* 登録済みモジュールをすべて読み込む。1 プロセス 1 回。
*
* **失敗は握り潰さず、返り値に載せる。**実行時の呼び出し側は failures を
* 無視して動き続ける(index.ts の tryLoadModule と同じ寛容さを保つ)が、
* テストは failures が空であることを直接見る。握り潰しの上にテストを
* 立てないための分担。
*/
export async function loadRegisteredToolModules(
opts?: { includeEnvDisabled?: boolean },
): Promise<ToolModuleLoadResult> {
const all = opts?.includeEnvDisabled === true;
const cached = all ? _cachedLoadIncludingDisabled : _cachedLoad;
if (cached) return cached;
const loaded: LoadedToolModule[] = [];
const failures: Array<{ specifier: string; message: string }> = [];
for (const specifier of listRegisteredToolModuleSpecifiers({ includeEnvDisabled: all })) {
try {
const mod = (await import(specifier)) as ManifestBearingModule;
loaded.push({ specifier, module: mod });
} catch (err) {
failures.push({ specifier, message: (err as Error).message });
logger.warn(`[tool-manifest] module load failed specifier=${specifier} err=${(err as Error).message}`);
}
}
const result = { loaded, failures };
if (all) _cachedLoadIncludingDisabled = result;
else _cachedLoad = result;
return result;
}
export interface CollectedManifests {
manifests: ToolModuleManifest[];
failures: Array<{ specifier: string; message: string }>;
/** 申告に書かれているが、そのモジュールの TOOL_DEFS に無い名前(幽霊申告) */
phantoms: Array<{ specifier: string; name: string }>;
}
let _overrideManifests: ToolModuleManifest[] | null = null;
let _cachedCollected: CollectedManifests | null = null;
/**
* 全モジュールの自己申告を集める。
*
* 申告に書かれた名前が、そのモジュールの TOOL_DEFS に無い場合は phantoms に
* 積んで**採用しない**。新しい仕組みで存在しないツール名を生やさないため。
*/
export async function collectToolModuleManifests(): Promise<CollectedManifests> {
if (_cachedCollected) return _cachedCollected;
const { loaded, failures } = await loadRegisteredToolModules();
const manifests: ToolModuleManifest[] = [];
const phantoms: Array<{ specifier: string; name: string }> = [];
for (const { specifier, module } of loaded) {
const declared = module.TOOL_MODULE;
if (!declared) continue; // 旧ルートのモジュール
const own = new Set(Object.keys(module.TOOL_DEFS ?? {}));
const clean: ToolModuleManifest = {
id: declared.id,
category: declared.category,
metaTools: filterOwn(declared.metaTools, own, specifier, phantoms),
rawSaveTools: filterOwn(declared.rawSaveTools, own, specifier, phantoms),
rawLogOnlyTools: filterOwn(declared.rawLogOnlyTools, own, specifier, phantoms),
sensitiveTools: filterOwn(declared.sensitiveTools, own, specifier, phantoms),
};
manifests.push(clean);
}
if (_overrideManifests) manifests.push(..._overrideManifests);
_cachedCollected = { manifests, failures, phantoms };
return _cachedCollected;
}
function filterOwn(
names: readonly string[] | undefined,
own: Set<string>,
specifier: string,
phantoms: Array<{ specifier: string; name: string }>,
): readonly string[] {
if (!names) return [];
const kept: string[] = [];
for (const name of names) {
if (own.has(name)) kept.push(name);
else {
phantoms.push({ specifier, name });
logger.warn(`[tool-manifest] declared name not in TOOL_DEFS specifier=${specifier} name=${name}`);
}
}
return kept;
}
// ─────────────────────────────────────────────────────────────────────────────
// 解決 — 中央の一覧 ∪ 申告
// ─────────────────────────────────────────────────────────────────────────────
async function unionWith(
base: Iterable<string>,
pick: (m: ToolModuleManifest) => readonly string[] | undefined,
): Promise<Set<string>> {
const out = new Set<string>(base);
const { manifests } = await collectToolModuleManifests();
for (const m of manifests) for (const name of pick(m) ?? []) out.add(name);
return out;
}
/** 常時利用(META)の実効集合。中央の META_TOOLS ∪ 各モジュールの申告 */
export async function resolveMetaToolNames(): Promise<Set<string>> {
return unionWith(META_TOOLS, (m) => m.metaTools);
}
/** 生データ保存の実効集合 */
export async function resolveRawSaveToolNames(): Promise<Set<string>> {
return unionWith(RAW_SAVE_TOOLS, (m) => m.rawSaveTools);
}
/** パスだけ記録の実効集合 */
export async function resolveRawLogOnlyToolNames(): Promise<Set<string>> {
return unionWith(RAW_LOG_ONLY_TOOLS, (m) => m.rawLogOnlyTools);
}
/** 個別に既定 OFF にするツールの実効集合 */
export async function resolveSensitiveToolNames(): Promise<Set<string>> {
return unionWith(SENSITIVE_TOOLS, (m) => m.sensitiveTools);
}
/**
* 申告された分類。モジュール指定 → 分類。
* 申告があるモジュールは、MODULE_SPECS 側の分類より**申告を優先**する。
* (優先しないと申告が飾りになり、書いても効かない項目が生まれる)
*/
export async function resolveDeclaredCategories(): Promise<Map<string, string>> {
const { loaded } = await loadRegisteredToolModules();
const out = new Map<string, string>();
for (const { specifier, module } of loaded) {
if (module.TOOL_MODULE) out.set(specifier, module.TOOL_MODULE.category);
}
return out;
}
// ─────────────────────────────────────────────────────────────────────────────
// テスト用
// ─────────────────────────────────────────────────────────────────────────────
/** テスト専用: 読み込み・収集のキャッシュを捨てる */
export function _resetToolModuleCacheForTests(): void {
_cachedLoad = null;
_cachedLoadIncludingDisabled = null;
_cachedCollected = null;
}
/**
* テスト専用: 申告を差し込む。
*
* 生データ保存の対象ツールは全部ネットワーク越しなので、実物では配線を
* 密閉して確かめられない。密閉できるツール(CreateChecklist など)を
* 申告側で対象にして、実際にファイルが落ちるところまで見るために使う。
*/
export function _setToolModuleManifestOverridesForTests(
manifests: ToolModuleManifest[] | null,
): void {
_overrideManifests = manifests;
_cachedCollected = null;
}
+1 -1
View File
@@ -17,7 +17,7 @@ import { mkdirSync, existsSync } from 'fs';
import { join, resolve } from 'path';
import Database from 'better-sqlite3';
import { ToolDef } from '../../llm/openai-compat.js';
import type { ToolContext, ToolResult } from './core.js';
import type { ToolContext, ToolResult } from './tool-context.js';
import { logger } from '../../logger.js';
// --- Cache DB -----------------------------------------------------------
+87
View File
@@ -58,6 +58,47 @@ describe('stripHtml', () => {
expect(stripHtml('<div>line1</div><div>line2</div>')).toBe('line1\nline2');
});
it('drops everything after an unclosed script block', () => {
expect(stripHtml('<p>Keep</p><script>var x = 1;')).toBe('Keep');
});
it('does not treat <scriptx> as a script tag', () => {
expect(stripHtml('<scriptx>visible</scriptx>')).toBe('visible');
});
it('handles script tags case-insensitively and with attributes', () => {
expect(stripHtml('<p>a</p><SCRIPT type="text/javascript">alert(1)</SCRIPT><p>b</p>')).toBe('a\nb');
});
it('does not mistake </scriptx> for a script closing tag', () => {
const html = '<p>a</p><script>var s = "</scriptx>"; evil()</script><p>b</p>';
expect(stripHtml(html)).toBe('a\nb');
});
it('keeps a literal < with no closing > as text (e.g. "5 < 10" bodies)', () => {
expect(stripHtml('price is < 10')).toBe('price is < 10');
});
it('stays linear on adversarial input flooding unmatched < characters', () => {
const adversarial = '<p>x</p>' + 'a<'.repeat(100_000);
const started = Date.now();
const out = stripHtml(adversarial);
expect(Date.now() - started).toBeLessThan(3_000);
expect(out.startsWith('x')).toBe(true);
});
it('stays linear on adversarial input with many unclosed script openings', () => {
// 遅延マッチ正規表現だと各 <script> から末尾まで走査して二次時間になる入力。
// 線形実装なら瞬時 (数十 ms) に終わる。vitest のデフォルトタイムアウト内で
// 完走すること自体が退行検知になる。
const adversarial = '<p>x</p><script>'.repeat(50_000) + 'tail';
const started = Date.now();
const out = stripHtml(adversarial);
expect(Date.now() - started).toBeLessThan(3_000);
// 最初の <script> が未閉鎖なので、それ以降はすべて破棄される。
expect(out).toBe('x');
});
it('decodes valid numeric entities', () => {
expect(stripHtml('<p>&#65;&#x42;</p>')).toBe('AB');
});
@@ -275,6 +316,52 @@ describe('executeReadMsg (integration)', () => {
}
});
// 取り出した添付の来歴(issue #1280 第 1 段)。取り出したのはエージェント
// なので、利用者が画面から渡したもの(user_input)とは分けて記録する。
it('取り出した添付を台帳に agent_output として記録し、note に展開元を残す', async () => {
const events: Array<{ relPath: string; event: string; sourceKind?: string; note?: string }> = [];
const recording: ToolContext = {
...ctx(),
recordFileProvenance: (ev) => { events.push(ev); },
} as ToolContext;
await executeReadMsg({ file_path: 'mail.msg' }, recording);
expect(events.map((e) => e.relPath).sort()).toEqual([
'input/jpg.jpg', 'input/png.png', 'input/tif.tif',
]);
for (const ev of events) {
expect(ev.event).toBe('create');
expect(ev.sourceKind).toBe('agent_output');
expect(ev.sourceKind).not.toBe('user_input');
expect(ev.note).toContain('mail.msg');
}
});
it('読み取り専用のステップでは添付を保存せず、台帳にも書かない', async () => {
const events: unknown[] = [];
const readOnly: ToolContext = {
workspacePath: workspace,
editAllowed: false,
recordFileProvenance: () => { events.push(1); },
} as ToolContext;
await executeReadMsg({ file_path: 'mail.msg' }, readOnly);
expect(events).toHaveLength(0);
expect(fs.existsSync(path.join(workspace, 'input', 'jpg.jpg'))).toBe(false);
});
it('台帳への書き込みが落ちても添付の取り出しは成功する(記録は付随情報)', async () => {
const failing: ToolContext = {
...ctx(),
recordFileProvenance: () => { throw new Error('db down'); },
} as ToolContext;
const result = await executeReadMsg({ file_path: 'mail.msg' }, failing);
expect(result.isError).toBeFalsy();
expect(fs.existsSync(path.join(workspace, 'input', 'jpg.jpg'))).toBe(true);
});
it('rejects paths outside the workspace', async () => {
const result = await executeReadMsg({ file_path: '../../etc/passwd' }, ctx());
expect(result.isError).toBe(true);
+170 -15
View File
@@ -2,8 +2,9 @@ import * as fs from 'fs';
import * as path from 'path';
import MsgReaderImport from '@kenjiuno/msgreader';
import { ToolDef } from '../../llm/openai-compat.js';
import type { ToolContext, ToolResult } from './core.js';
import { resolveAndGuard, truncateToBudget, getToolOutputBudgetTokens } from './core.js';
import type { ToolContext, ToolResult } from './tool-context.js';
import { resolveAndGuard, truncateToBudget, getToolOutputBudgetTokens, countTextLines } from './core.js';
import { recordProvenanceBestEffort } from './file-provenance.js';
import { logger } from '../../logger.js';
// CJS/ESM interop: under native Node ESM (the built dist), a default import of
@@ -17,6 +18,10 @@ const MsgReader = (
) as typeof MsgReaderImport;
type MsgReaderInstance = InstanceType<typeof MsgReader>;
// bridge/msg-preview.ts が同じ interop 済みコンストラクタを使う (二重実装防止)。
export { MsgReader as MsgReaderClass };
export type { MsgReaderInstance };
const DEFAULT_MSG_MAX_SIZE_MB = 25;
export interface MsgAddress {
@@ -78,21 +83,110 @@ function safeFromCodePoint(code: number, original: string): string {
function decodeEntities(s: string): string {
let out = s.replace(/&nbsp;|&amp;|&lt;|&gt;|&quot;|&#39;|&apos;/g, (m) => NAMED_ENTITIES[m]);
out = out.replace(/&#(\d+);/g, (m, code) => safeFromCodePoint(Number(code), m));
out = out.replace(/&#x([0-9a-fA-F]+);/g, (m, code) => safeFromCodePoint(parseInt(code, 16), m));
// Bounded quantifiers: valid code points fit in 7 digits / 6 hex chars; an
// unbounded run would make each failed match rescan to its end (quadratic).
out = out.replace(/&#(\d{1,7});/g, (m, code) => safeFromCodePoint(Number(code), m));
out = out.replace(/&#x([0-9a-fA-F]{1,6});/g, (m, code) => safeFromCodePoint(parseInt(code, 16), m));
return out;
}
/**
* Drop `<tag ...>...</tag>` blocks (including contents) in linear time.
* A lazy-regex equivalent (`[\s\S]*?</tag>`) goes quadratic when the input has
* many unclosed opening tags — a crafted 1.5MB email body could block the event
* loop for minutes (ReDoS). An unclosed block drops everything to the end.
*/
function dropTagBlocks(s: string, tag: string): string {
const lower = s.toLowerCase();
const open = `<${tag}`;
const close = `</${tag}`;
const parts: string[] = [];
let pos = 0;
while (pos < s.length) {
const start = lower.indexOf(open, pos);
if (start === -1) {
parts.push(s.slice(pos));
break;
}
// Word boundary after the tag name (`<scriptx>` is NOT a script tag).
const next = lower.charAt(start + open.length);
if (next !== '' && !/[\s/>]/.test(next)) {
parts.push(s.slice(pos, start + open.length));
pos = start + open.length;
continue;
}
parts.push(s.slice(pos, start));
// Find a REAL closing tag: `</scriptx>` inside a script block is content,
// not a closer — require a name boundary (whitespace / '>' / EOF) after it.
let closeIdx = -1;
let searchFrom = start + open.length;
while (true) {
const cand = lower.indexOf(close, searchFrom);
if (cand === -1) break;
const after = lower.charAt(cand + close.length);
if (after === '' || after === '>' || /\s/.test(after)) {
closeIdx = cand;
break;
}
searchFrom = cand + close.length;
}
if (closeIdx === -1) break; // unclosed: discard the rest
const gt = lower.indexOf('>', closeIdx);
if (gt === -1) break;
pos = gt + 1;
}
return parts.join('');
}
// Closing tags that mark a block boundary (rendered as a newline).
const BLOCK_CLOSE_TAGS = new Set([
'p', 'div', 'li', 'tr', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'ul', 'ol', 'table', 'blockquote', 'section', 'article',
]);
/**
* Remove all remaining tags in a single linear pass, emitting a newline for
* `<br>` and block-level closing tags. A regex like `/<[^>]+>/g` goes quadratic
* when the input floods unmatched `<` characters (each attempt rescans to the
* end of the string) — same ReDoS class as the script/style lazy match.
* A `<` with no `>` anywhere after it stays as literal text (matches the old
* regex, which simply found no tag to remove — think "5 < 10" in a text body).
*/
function stripTagsLinear(s: string): string {
const parts: string[] = [];
let pos = 0;
while (pos < s.length) {
const lt = s.indexOf('<', pos);
if (lt === -1) {
parts.push(s.slice(pos));
break;
}
parts.push(s.slice(pos, lt));
const gt = s.indexOf('>', lt + 1);
if (gt === -1) {
parts.push(s.slice(lt));
break;
}
const tag = s.slice(lt + 1, gt).trim().toLowerCase();
const nameMatch = /^(\/?)([a-z][a-z0-9]*)/.exec(tag);
if (nameMatch) {
const [, closing, name] = nameMatch;
if (!closing && name === 'br') parts.push('\n');
else if (closing && BLOCK_CLOSE_TAGS.has(name)) parts.push('\n');
}
pos = gt + 1;
}
return parts.join('');
}
/** Convert an HTML fragment into readable plain text. */
export function stripHtml(html: string): string {
let s = html;
// Drop script/style blocks including their contents.
s = s.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, '');
// Treat <br> and block-level boundaries as newlines.
s = s.replace(/<br\s*\/?>/gi, '\n');
s = s.replace(/<\/(p|div|li|tr|h[1-6]|ul|ol|table|blockquote|section|article)\s*>/gi, '\n');
// Remove all remaining tags.
s = s.replace(/<[^>]+>/g, '');
// Drop script/style blocks including their contents (linear-time scan).
s = dropTagBlocks(dropTagBlocks(s, 'script'), 'style');
// Remove all tags, turning <br> and block-level boundaries into newlines
// (single linear pass — no per-tag regexes, see stripTagsLinear).
s = stripTagsLinear(s);
s = decodeEntities(s);
// Normalize whitespace: collapse intra-line runs, trim each line, collapse blank runs.
s = s
@@ -204,15 +298,52 @@ export function formatMsgOutput(view: MsgView): string {
* Headers and the attachment list (with saved input/ paths) always survive —
* attachments are already written to disk and the caller needs their paths.
*/
export function assembleMsgOutput(view: MsgView, budgetTokens: number, sourceLabel: string): string {
export function assembleMsgOutput(
view: MsgView,
budgetTokens: number,
sourceLabel: string,
baseLine = 0,
): string {
const shell = formatMsgOutput({ ...view, body: { text: '', format: 'plain' } });
const reserveTokens = Math.ceil(shell.length / 4) + 64;
const bodyBudget = Math.max(500, budgetTokens - reserveTokens);
const bodyText = view.body.format === 'none' ? '' : view.body.text;
const truncatedBody = truncateToBudget(bodyText, bodyBudget, { sourceLabel }).text;
// baseLine は offset で切り出した本文の開始行。渡さないと切り詰めの注記が
// 「切り出した先頭からの行数」を次の offset として勧めてしまい、既に読んだ
// 範囲をもう一度指すことになる。
const truncatedBody = truncateToBudget(bodyText, bodyBudget, { sourceLabel, baseLine }).text;
return formatMsgOutput({ ...view, body: { text: truncatedBody, format: view.body.format } });
}
/**
* .msg 本文の行範囲を切り出す(#1165 段 B)。
*
* .msg で絞る単位は**抽出した本文の行**にした。件名・送受信者・添付の一覧は
* 出力の骨格で、量を食っているのは本文だけだからである(切り詰めも本文にしか
* 掛かっていない)。添付は input/ に保存され、その一覧は範囲指定に関わらず
* 常に全件残す。行なのでテキスト読みと同じ offset / limit をそのまま使う。
*
* 本文の行数の外を指す offset は失敗として返す(#1170 と同じ扱い)。空の本文に
* offset: 0 を渡した場合も、読める行が 1 行も無いので失敗。
*/
export function sliceMsgBody(
bodyText: string,
offset: number | undefined,
limit: number | undefined,
): { text: string; baseLine: number } | { error: string } {
const lineCount = countTextLines(bodyText);
if (offset !== undefined && offset >= lineCount) {
return {
error: `ReadMsg: offset ${offset} は本文の範囲外です(この本文は ${lineCount} 行、offset は 0 起点)`,
};
}
const start = offset ?? 0;
if (start === 0 && limit === undefined) return { text: bodyText, baseLine: 0 };
const lines = bodyText.split('\n');
const sliced = limit !== undefined ? lines.slice(start, start + limit) : lines.slice(start);
return { text: sliced.join('\n'), baseLine: start };
}
/**
* msgreader's getFileData() returns `{ error: 'Unsupported file type!' }` (not a
* throw) for CFBF files that aren't Outlook messages (legacy .doc/.xls, broken
@@ -334,6 +465,16 @@ export async function executeReadMsg(
};
}
// 本文の行範囲は添付の保存より **前** に検証する。範囲外で断る呼び出しが
// input/ にファイルを書き残すと、失敗したのに副作用だけ残ることになる。
const offset = typeof input['offset'] === 'number' ? input['offset'] : undefined;
const limit = typeof input['limit'] === 'number' ? input['limit'] : undefined;
const fullBody = selectMsgBody(fields);
const slicedBody = sliceMsgBody(fullBody.text, offset, limit);
if ('error' in slicedBody) {
return { output: slicedBody.error, isError: true };
}
const recipients = fields.recipients ?? [];
const to = recipients
.filter((r) => (r.recipType ?? 'to') === 'to')
@@ -380,6 +521,15 @@ export async function executeReadMsg(
fs.mkdirSync(inputDir, { recursive: true });
const dest = path.join(inputDir, name);
fs.writeFileSync(dest, Buffer.from(data.content));
// 来歴の台帳(#1280)。メールの添付を取り出したのはエージェントなので
// `user_input` にはしない(利用者が画面から渡したものと混ぜない)。
// 展開元の .msg が note に残るので、あとから出どころを辿れる。
recordProvenanceBestEffort(ctx, {
relPath: path.posix.join('input', name),
event: 'create',
sourceKind: 'agent_output',
note: `ReadMsg attachment: ${path.basename(resolved)}`,
});
attachments.push({
fileName: name,
contentLength: data.content.length,
@@ -404,11 +554,16 @@ export async function executeReadMsg(
to,
cc,
date: fields.messageDeliveryTime || fields.clientSubmitTime,
body: selectMsgBody(fields),
body: { text: slicedBody.text, format: fullBody.format },
attachments,
};
const output = assembleMsgOutput(view, getToolOutputBudgetTokens(ctx), path.basename(resolved));
const output = assembleMsgOutput(
view,
getToolOutputBudgetTokens(ctx),
path.basename(resolved),
slicedBody.baseLine,
);
logger.info(
`[ReadMsg] ${path.basename(resolved)}: attachments=${attachments.length} bodyFormat=${view.body.format}`,
);
+143
View File
@@ -0,0 +1,143 @@
/**
* EditDocx ツール(#1035 文書編)。
*
* 入り口の約束を固定する: 閲覧専用の領域を守る、段落番号の誤りを分かる形で断る、
* .docx 以外は断る、一覧だけ取れる。中身は src/office/ 側のテストで押さえている。
*/
import * as fs from 'fs';
import * as path from 'path';
import { tmpdir } from 'os';
import AdmZip from 'adm-zip';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { executeTool } from './office.js';
import type { ToolContext } from './core.js';
import { readDocxParagraphs } from '../../office/docx-edit.js';
let workspace: string;
const ctx = (): ToolContext => ({ workspacePath: workspace, editAllowed: true });
function buildDocx(filePath: string): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const zip = new AdmZip();
zip.addFile('[Content_Types].xml', Buffer.from('<Types/>', 'utf-8'));
zip.addFile('word/document.xml', Buffer.from(
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>'
+ '<w:p><w:r><w:t>一段落目</w:t></w:r></w:p>'
+ '<w:p><w:r><w:t>二段落目</w:t></w:r></w:p>'
+ '</w:body></w:document>', 'utf-8'));
zip.writeZip(filePath);
}
beforeEach(() => { workspace = fs.mkdtempSync(path.join(tmpdir(), 'maestro-edit-docx-')); });
afterEach(() => fs.rmSync(workspace, { recursive: true, force: true }));
describe('EditDocx', () => {
it('段落を書き換える', async () => {
const file = path.join(workspace, 'output', 'doc.docx');
buildDocx(file);
const result = await executeTool('EditDocx', {
path: 'output/doc.docx',
edits: [{ paragraph: 1, text: '直した二段落目' }],
}, ctx());
expect(result?.isError).toBeFalsy();
expect(readDocxParagraphs(file)).toEqual(['一段落目', '直した二段落目']);
});
it('番号を知るための一覧だけ取れる', async () => {
buildDocx(path.join(workspace, 'output', 'doc.docx'));
const result = await executeTool('EditDocx', {
path: 'output/doc.docx',
list_paragraphs: true,
}, ctx());
expect(result?.isError).toBeFalsy();
expect(result?.output).toContain('0: 一段落目');
expect(result?.output).toContain('1: 二段落目');
});
it('editAllowed=false では書き込みを拒否する', async () => {
const source = path.join(workspace, 'input', 'doc.docx');
buildDocx(source);
const result = await executeTool('EditDocx', {
path: 'input/doc.docx',
output_path: 'output/new.docx',
edits: [{ paragraph: 0, text: '書けてはいけない' }],
}, { ...ctx(), editAllowed: false });
expect(result?.isError).toBe(true);
expect(result?.output).toMatch(/not allowed|edit flag/);
expect(fs.existsSync(path.join(workspace, 'output', 'new.docx'))).toBe(false);
// 元のファイルもそのまま。
expect(readDocxParagraphs(source)).toEqual(['一段落目', '二段落目']);
});
it('editAllowed=false では output/ 配下への書き込みも拒否する', async () => {
const file = path.join(workspace, 'output', 'doc.docx');
buildDocx(file);
const result = await executeTool('EditDocx', {
path: 'output/doc.docx',
edits: [{ paragraph: 1, text: '書けてはいけない' }],
}, { ...ctx(), editAllowed: false });
expect(result?.isError).toBe(true);
expect(result?.output).toMatch(/not allowed|edit flag/);
expect(readDocxParagraphs(file)).toEqual(['一段落目', '二段落目']);
});
it('閲覧専用の領域は書き換えない', async () => {
const file = path.join(workspace, 'readonly', 'doc.docx');
buildDocx(file);
const result = await executeTool('EditDocx', {
path: 'readonly/doc.docx',
edits: [{ paragraph: 0, text: 'x' }],
}, ctx());
expect(result?.isError).toBe(true);
expect(result?.output).toContain('readonly/');
expect(readDocxParagraphs(file)).toEqual(['一段落目', '二段落目']);
});
// 検証は共有の層(paragraph-edit-input)が持つ。ここで見るのは**配線**——
// その層の断り文句がそのまま利用者に届くこと(#1067 重複・後始末)。
it('段落番号の誤りと空の編集を、共有の層の文言で断る', async () => {
buildDocx(path.join(workspace, 'output', 'doc.docx'));
const bad = await executeTool('EditDocx', {
path: 'output/doc.docx',
edits: [{ paragraph: -1, text: 'x' }],
}, ctx());
expect(bad?.isError).toBe(true);
expect(bad?.output).toBe('Invalid paragraph "-1": use a 0-based integer');
const empty = await executeTool('EditDocx', { path: 'output/doc.docx', edits: [] }, ctx());
expect(empty?.isError).toBe(true);
expect(empty?.output).toBe('edits is empty: give at least one paragraph to change');
const notArray = await executeTool('EditDocx', { path: 'output/doc.docx' }, ctx());
expect(notArray?.output).toBe('edits must be an array (or pass list_paragraphs: true)');
const badText = await executeTool('EditDocx', {
path: 'output/doc.docx',
edits: [{ paragraph: 1, text: 42 }],
}, ctx());
expect(badText?.output).toBe('Paragraph 1: text must be a string');
});
it('.docx 以外は断る', async () => {
fs.mkdirSync(path.join(workspace, 'output'), { recursive: true });
fs.writeFileSync(path.join(workspace, 'output', 'note.txt'), 'hello');
const result = await executeTool('EditDocx', {
path: 'output/note.txt',
edits: [{ paragraph: 0, text: 'x' }],
}, ctx());
expect(result?.isError).toBe(true);
});
});
+528
View File
@@ -0,0 +1,528 @@
/**
* EditExcel ツール(#1035 段階 1)。
*
* エージェントが Excel を直せるようにする。約束は「指定したセル以外は変えない」
* こと。ここではツールの入り口として、閲覧専用の領域を守ること・置き場所の
* 越境を防ぐこと・入力の誤りを分かる形で断ることを固定する。
* 中身(壊さずに書き換える処理)は src/office/ 側のテストで押さえている。
*/
import * as fs from 'fs';
import * as path from 'path';
import { tmpdir } from 'os';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import ExcelJS from 'exceljs';
import {
readSheetDataXml,
writeXlsxWithSharedFormulaBlock,
writeXlsxWithSharedFormulaOf,
} from '../../office/__fixtures__/forge-zip.js';
import { executeTool, TOOL_DEFS } from './office.js';
import type { ToolContext } from './core.js';
let workspace: string;
const ctx = (): ToolContext => ({ workspacePath: workspace, editAllowed: true });
async function writeBook(filePath: string): Promise<void> {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet('Sheet1');
ws.getCell('A1').value = 'name';
ws.getCell('A1').font = { bold: true };
ws.getCell('B1').value = 10;
await wb.xlsx.writeFile(filePath);
}
const readCell = async (filePath: string, cell: string): Promise<unknown> => {
const wb = new ExcelJS.Workbook();
await wb.xlsx.readFile(filePath);
return wb.getWorksheet('Sheet1')!.getCell(cell).value;
};
beforeEach(() => { workspace = fs.mkdtempSync(path.join(tmpdir(), 'maestro-edit-excel-')); });
afterEach(() => fs.rmSync(workspace, { recursive: true, force: true }));
describe('EditExcel', () => {
it('セルを書き換え、書式は残す', async () => {
const file = path.join(workspace, 'output', 'book.xlsx');
await writeBook(file);
const result = await executeTool('EditExcel', {
path: 'output/book.xlsx',
edits: [{ cell: 'B1', value: 42 }],
}, ctx());
expect(result?.isError).toBeFalsy();
expect(await readCell(file, 'B1')).toBe(42);
const wb = new ExcelJS.Workbook();
await wb.xlsx.readFile(file);
expect(wb.getWorksheet('Sheet1')!.getCell('A1').font?.bold).toBe(true);
});
/**
* **書き換えたセル自身**の書式が往復で残ること(#1067 テスト 2 件目)。
*
* これまでは触っていないセルの書式しか見ていなかった。書き換えたセルの書式は
* 元のセルから引き継いでいるので、そこが落ちると「値を直したら表示形式と色が
* 消えた」という形で利用者に当たる。
*/
it('書き換えたセル自身の書式(表示形式・太字・塗り)が残る', async () => {
const file = path.join(workspace, 'output', 'styled.xlsx');
fs.mkdirSync(path.dirname(file), { recursive: true });
const wb0 = new ExcelJS.Workbook();
const ws0 = wb0.addWorksheet('Sheet1');
const cell = ws0.getCell('B2');
cell.value = 1000;
cell.numFmt = '#,##0"円"';
cell.font = { bold: true, color: { argb: 'FFFF0000' } };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFF2CC' } };
await wb0.xlsx.writeFile(file);
const result = await executeTool('EditExcel', {
path: 'output/styled.xlsx',
edits: [{ cell: 'B2', value: 2500 }],
}, ctx());
expect(result?.isError).toBeFalsy();
const wb = new ExcelJS.Workbook();
await wb.xlsx.readFile(file);
const after = wb.getWorksheet('Sheet1')!.getCell('B2');
expect(after.value).toBe(2500);
expect(after.numFmt).toBe('#,##0"円"');
expect(after.font?.bold).toBe(true);
expect(after.font?.color?.argb).toBe('FFFF0000');
expect((after.fill as ExcelJS.FillPattern)?.fgColor?.argb).toBe('FFFFF2CC');
});
it('数式に書き換えたセルでも、そのセルの書式が残る', async () => {
const file = path.join(workspace, 'output', 'styled-formula.xlsx');
fs.mkdirSync(path.dirname(file), { recursive: true });
const wb0 = new ExcelJS.Workbook();
const ws0 = wb0.addWorksheet('Sheet1');
ws0.getCell('A1').value = 3;
ws0.getCell('A2').value = 4;
const cell = ws0.getCell('A3');
cell.value = 0;
cell.numFmt = '#,##0"円"';
cell.font = { bold: true };
await wb0.xlsx.writeFile(file);
const result = await executeTool('EditExcel', {
path: 'output/styled-formula.xlsx',
edits: [{ cell: 'A3', formula: 'SUM(A1:A2)' }],
}, ctx());
expect(result?.isError).toBeFalsy();
const wb = new ExcelJS.Workbook();
await wb.xlsx.readFile(file);
const after = wb.getWorksheet('Sheet1')!.getCell('A3');
expect((after.value as { formula: string }).formula).toBe('SUM(A1:A2)');
expect(after.numFmt).toBe('#,##0"円"');
expect(after.font?.bold).toBe(true);
});
it('別名で保存できる(元のファイルは残る)', async () => {
await writeBook(path.join(workspace, 'input', 'book.xlsx'));
await executeTool('EditExcel', {
path: 'input/book.xlsx',
output_path: 'output/edited.xlsx',
edits: [{ cell: 'B1', value: 1 }],
}, ctx());
expect(await readCell(path.join(workspace, 'output', 'edited.xlsx'), 'B1')).toBe(1);
expect(await readCell(path.join(workspace, 'input', 'book.xlsx'), 'B1')).toBe(10);
});
it('閲覧専用の領域は書き換えない', async () => {
const file = path.join(workspace, 'readonly', 'book.xlsx');
await writeBook(file);
const result = await executeTool('EditExcel', {
path: 'readonly/book.xlsx',
edits: [{ cell: 'B1', value: 99 }],
}, ctx());
expect(result?.isError).toBe(true);
expect(result?.output).toContain('readonly/');
expect(await readCell(file, 'B1')).toBe(10);
});
it('編集の指定が空なら断る', async () => {
await writeBook(path.join(workspace, 'output', 'book.xlsx'));
const result = await executeTool('EditExcel', { path: 'output/book.xlsx', edits: [] }, ctx());
expect(result?.isError).toBe(true);
});
it('値と数式を同時に指定したら断る(どちらを書くのか決まらない)', async () => {
await writeBook(path.join(workspace, 'output', 'book.xlsx'));
const result = await executeTool('EditExcel', {
path: 'output/book.xlsx',
edits: [{ cell: 'B1', value: 1, formula: 'A1' }],
}, ctx());
expect(result?.isError).toBe(true);
expect(result?.output).toMatch(/formula|数式/);
});
it('セルの書き方が正しくなければ、その場所を挙げて断る', async () => {
await writeBook(path.join(workspace, 'output', 'book.xlsx'));
const result = await executeTool('EditExcel', {
path: 'output/book.xlsx',
edits: [{ cell: '1B', value: 1 }],
}, ctx());
expect(result?.isError).toBe(true);
expect(result?.output).toContain('1B');
});
it('Excel でないファイルは断る', async () => {
fs.mkdirSync(path.join(workspace, 'output'), { recursive: true });
fs.writeFileSync(path.join(workspace, 'output', 'note.txt'), 'hello');
const result = await executeTool('EditExcel', {
path: 'output/note.txt',
edits: [{ cell: 'A1', value: 1 }],
}, ctx());
expect(result?.isError).toBe(true);
});
it('operations モードで値・結合・色を検証後に別名保存する', async () => {
await writeBook(path.join(workspace, 'input', 'book.xlsx'));
const result = await executeTool('EditExcel', {
path: 'input/book.xlsx',
output_path: 'output/planned.xlsx',
operations: [
{ op: 'set_values', sheet: 'Sheet1', range: 'A2:B2', values: [['計画', 25]] },
{ op: 'merge', sheet: 'Sheet1', range: 'A3:B3' },
{ op: 'style_range', sheet: 'Sheet1', range: 'A2:B2', style: { fill: '#FFF2CC' } },
],
}, ctx());
expect(result?.isError).toBe(false);
expect(JSON.parse(result!.output)).toMatchObject({
ok: true,
phase: 'commit',
candidate_path: 'output/planned.xlsx',
});
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(path.join(workspace, 'output', 'planned.xlsx'));
const sheet = workbook.getWorksheet('Sheet1')!;
expect(sheet.getCell('A2').value).toBe('計画');
expect(sheet.getCell('A2').fill).toMatchObject({ fgColor: { argb: 'FFFFF2CC' } });
expect((sheet.model as { merges?: string[] }).merges).toContain('A3:B3');
});
it('edits と operations の同時指定は曖昧なので拒否する', async () => {
await writeBook(path.join(workspace, 'output', 'book.xlsx'));
const result = await executeTool('EditExcel', {
path: 'output/book.xlsx',
edits: [{ cell: 'A1', value: 'legacy' }],
output_path: 'output/new.xlsx',
operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'A1', values: [['new']] }],
}, ctx());
expect(result?.isError).toBe(true);
expect(result?.output).toMatch(/edits.*operations|operations.*edits/);
expect(fs.existsSync(path.join(workspace, 'output', 'new.xlsx'))).toBe(false);
});
it('legacy edits は operations 専用の安全フラグを黙って無視しない', async () => {
const file = path.join(workspace, 'output', 'book.xlsx');
await writeBook(file);
const result = await executeTool('EditExcel', {
path: 'output/book.xlsx',
edits: [{ cell: 'B1', value: 99 }],
dry_run: true,
}, ctx());
expect(result?.isError).toBe(true);
expect(result?.output).toMatch(/dry_run|operations/);
expect(await readCell(file, 'B1')).toBe(10);
});
it('editAllowed=false では operations の書き込みを拒否する', async () => {
await writeBook(path.join(workspace, 'input', 'book.xlsx'));
const result = await executeTool('EditExcel', {
path: 'input/book.xlsx',
output_path: 'output/new.xlsx',
operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'A1', values: [['blocked']] }],
}, { ...ctx(), editAllowed: false });
expect(result?.isError).toBe(true);
expect(result?.output).toMatch(/not allowed|edit flag/);
expect(fs.existsSync(path.join(workspace, 'output', 'new.xlsx'))).toBe(false);
});
/**
* 書式と行の高さが、ツールの入口を通ってファイルへ載るか(#1322 段 1)。
*/
it('書式の差分がファイルに載る(値には触れない)', async () => {
const file = path.join(workspace, 'output', 'book.xlsx');
await writeBook(file);
const result = await executeTool('EditExcel', {
path: 'output/book.xlsx',
edits: [{ cell: 'A1', style: { fill_color: '#00FF00', border: { edges: ['bottom'], style: 'thick' } } }],
}, ctx());
expect(result?.isError).toBeFalsy();
const wb = new ExcelJS.Workbook();
await wb.xlsx.readFile(file);
const cell = wb.getWorksheet('Sheet1')!.getCell('A1');
expect(cell.value).toBe('name');
expect(cell.font?.bold).toBe(true); // 元の太字は残る
expect((cell.fill as ExcelJS.FillPattern).fgColor?.argb).toBe('FF00FF00');
expect(cell.border?.bottom?.style).toBe('thick');
});
it('行の高さがファイルに載る(セルの指定が 0 件でも通る)', async () => {
const file = path.join(workspace, 'output', 'book.xlsx');
await writeBook(file);
const result = await executeTool('EditExcel', {
path: 'output/book.xlsx',
rows: [{ row: 1, height: 36 }],
}, ctx());
expect(result?.isError).toBeFalsy();
expect(result?.output).toContain('1 row height(s)');
const wb = new ExcelJS.Workbook();
await wb.xlsx.readFile(file);
expect(wb.getWorksheet('Sheet1')!.getRow(1).height).toBe(36);
});
it('書式の指定が読めなければ、その値を挙げて断る', async () => {
const file = path.join(workspace, 'output', 'book.xlsx');
await writeBook(file);
const before = fs.readFileSync(file);
const result = await executeTool('EditExcel', {
path: 'output/book.xlsx',
edits: [{ cell: 'A1', style: { font_color: 'みどり' } }],
}, ctx());
expect(result?.isError).toBe(true);
expect(result?.output).toContain('みどり');
expect(fs.readFileSync(file).equals(before)).toBe(true);
});
it('行の高さは operations 側では受け付けない(別の道具があるため)', async () => {
const file = path.join(workspace, 'output', 'book.xlsx');
await writeBook(file);
const result = await executeTool('EditExcel', {
path: 'output/book.xlsx',
output_path: 'output/out.xlsx',
operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'A1:A1', values: [['x']] }],
rows: [{ row: 1, height: 20 }],
}, ctx());
expect(result?.isError).toBe(true);
expect(result?.output).toContain('rows');
});
it('公開スキーマが宣言的操作・ガント・安全な上書き条件を案内する', () => {
const schema = TOOL_DEFS.EditExcel!.function.parameters as {
properties: Record<string, unknown>;
anyOf: Array<{ required: string[] }>;
};
expect(schema.properties).toEqual(expect.objectContaining({
edits: expect.any(Object),
rows: expect.any(Object),
operations: expect.any(Object),
gantt: expect.any(Object),
expected_source_sha256: expect.any(Object),
dry_run: expect.any(Object),
}));
expect(schema.anyOf).toEqual(expect.arrayContaining([
{ required: ['path', 'edits'] },
{ required: ['path', 'rows'] },
{ required: ['output_path', 'operations'] },
]));
// 書式の欄が edits に載っていること(画面 #1264 が使う口と同じ形)
const editItems = (schema.properties['edits'] as { items: { properties: Record<string, unknown> } }).items;
expect(editItems.properties).toEqual(expect.objectContaining({ style: expect.any(Object) }));
const style = editItems.properties['style'] as { properties: Record<string, unknown> };
expect(style.properties).toEqual(expect.objectContaining({
italic: expect.any(Object),
underline: expect.any(Object),
strike: expect.any(Object),
font_name: expect.any(Object),
font_size: expect.any(Object),
}));
const alignment = style.properties['alignment'] as { properties: Record<string, unknown> };
expect(alignment.properties).toHaveProperty('text_rotation');
});
});
/**
* セルの結合と列の幅を、軽量編集(edits と同じ経路)で受ける(#1322 段 3)。
*
* 中身の処理は src/office/ 側で押さえてある。ここで見るのは**配線**——
* ツールに渡した merges / columns が、実際にファイルへ届くところ。
*/
describe('EditExcel — 結合と列幅', () => {
it('セルだけを直す経路で結合できる(edits を書かなくてよい)', async () => {
const file = path.join(workspace, 'output', 'book.xlsx');
await writeBook(file);
const result = await executeTool('EditExcel', {
path: 'output/book.xlsx',
merges: [{ range: 'A3:C3', merged: true }],
}, ctx());
expect(result?.isError).toBeFalsy();
expect(result?.output).toContain('1 merge change(s)');
const wb = new ExcelJS.Workbook();
await wb.xlsx.readFile(file);
expect((wb.getWorksheet('Sheet1')!.model as { merges?: string[] }).merges).toContain('A3:C3');
});
it('列の幅を変えられる', async () => {
const file = path.join(workspace, 'output', 'book.xlsx');
await writeBook(file);
const result = await executeTool('EditExcel', {
path: 'output/book.xlsx',
columns: [{ columns: 'A:B', width: 22 }],
}, ctx());
expect(result?.isError).toBeFalsy();
expect(result?.output).toContain('1 column width change(s)');
const wb = new ExcelJS.Workbook();
await wb.xlsx.readFile(file);
expect(wb.getWorksheet('Sheet1')!.getColumn(2).width).toBe(22);
});
it('中身の入ったセルを巻き込む結合は、消さずに断る', async () => {
const file = path.join(workspace, 'output', 'book.xlsx');
await writeBook(file);
const result = await executeTool('EditExcel', {
path: 'output/book.xlsx',
merges: [{ range: 'A1:B1', merged: true }],
}, ctx());
expect(result?.isError).toBe(true);
expect(result?.output).toContain('B1');
expect(await readCell(file, 'B1')).toBe(10);
});
it('逆順の範囲は、ファイルに触る前に断る', async () => {
const file = path.join(workspace, 'output', 'book.xlsx');
await writeBook(file);
const before = fs.readFileSync(file);
const result = await executeTool('EditExcel', {
path: 'output/book.xlsx',
merges: [{ range: 'C1:A1', merged: true }],
}, ctx());
expect(result?.isError).toBe(true);
expect(result?.output).toContain('backwards');
expect(fs.readFileSync(file).equals(before)).toBe(true);
});
it('幅の上限(255)を超えたら断る', async () => {
await writeBook(path.join(workspace, 'output', 'book.xlsx'));
const result = await executeTool('EditExcel', {
path: 'output/book.xlsx',
columns: [{ columns: 'A', width: 300 }],
}, ctx());
expect(result?.isError).toBe(true);
expect(result?.output).toContain('255');
});
it('merges と operations の同時指定は断る', async () => {
await writeBook(path.join(workspace, 'output', 'book.xlsx'));
const result = await executeTool('EditExcel', {
path: 'output/book.xlsx',
merges: [{ range: 'A3:C3', merged: true }],
output_path: 'output/new.xlsx',
operations: [{ op: 'merge', sheet: 'Sheet1', range: 'A3:C3' }],
}, ctx());
expect(result?.isError).toBe(true);
expect(result?.output).toContain('merges');
expect(fs.existsSync(path.join(workspace, 'output', 'new.xlsx'))).toBe(false);
});
it('ツールの説明に merges と columns の受け口がある', () => {
const params = TOOL_DEFS.EditExcel.function.parameters as {
properties: Record<string, unknown>;
anyOf: Array<{ required: string[] }>;
};
expect(params.properties).toHaveProperty('merges');
expect(params.properties).toHaveProperty('columns');
// edits を書かずに merges / columns だけで呼べる
expect(params.anyOf).toContainEqual({ required: ['path', 'merges'] });
expect(params.anyOf).toContainEqual({ required: ['path', 'columns'] });
});
/**
* コピーした式のおおもと(#1351 段 2)。画面の保存側だけ通せるようにして、
* エージェント側の口が断ったままだと、同じ書類が経路で違う扱いになる。
* **同じ層を通っていることを、この口からも見る。**
*/
describe('コピーした式のおおもと', () => {
it('本体のセルを書き換えると、追随するセルに式が全文で入る', async () => {
const file = path.join(workspace, 'output', 'shared.xlsx');
fs.mkdirSync(path.dirname(file), { recursive: true });
await writeXlsxWithSharedFormulaBlock(file);
const result = await executeTool('EditExcel', {
path: 'output/shared.xlsx',
edits: [{ cell: 'C2', value: 999 }],
}, ctx());
expect(result?.isError).toBeFalsy();
expect(readSheetDataXml(file)).toContain('<c r="C3"><f>A3*B3</f><v>90</v></c>');
expect(readSheetDataXml(file)).toContain('<c r="C4"><f>A4*B4</f><v>160</v></c>');
});
it('訳せない式のおおもとは断り、ファイルは変わらない', async () => {
const file = path.join(workspace, 'output', 'whole.xlsx');
fs.mkdirSync(path.dirname(file), { recursive: true });
await writeXlsxWithSharedFormulaOf(file, 'SUM(A:A)*B2');
const before = fs.readFileSync(file);
const result = await executeTool('EditExcel', {
path: 'output/whole.xlsx',
edits: [{ cell: 'C2', value: 999 }],
}, ctx());
expect(result?.isError).toBe(true);
expect(result?.output).toContain('C2');
expect(result?.output).toContain('C2:C4');
expect(fs.readFileSync(file).equals(before)).toBe(true);
});
it('追随側のセルは今までどおり書き換えられる', async () => {
const file = path.join(workspace, 'output', 'shared.xlsx');
fs.mkdirSync(path.dirname(file), { recursive: true });
await writeXlsxWithSharedFormulaBlock(file);
const result = await executeTool('EditExcel', {
path: 'output/shared.xlsx',
edits: [{ cell: 'C3', value: 777 }],
}, ctx());
expect(result?.isError).toBeFalsy();
expect(readSheetDataXml(file)).toContain('<c r="C3"><v>777</v></c>');
expect(readSheetDataXml(file)).toContain('<c r="C2"><f t="shared" ref="C2:C4" si="0">A2*B2</f>');
});
});
});
+139
View File
@@ -0,0 +1,139 @@
/**
* EditPptx ツール(#1035 スライド編)。
*
* 入り口の約束を固定する: 編集が許されていなければ書かない、閲覧専用の領域を
* 守る、.pptx 以外は断る。中身は src/office/ 側のテストで押さえている。
*/
import * as fs from 'fs';
import * as path from 'path';
import { tmpdir } from 'os';
import AdmZip from 'adm-zip';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { executeTool } from './office.js';
import type { ToolContext } from './core.js';
import { readPptxSlideParagraphs } from '../../office/pptx-edit.js';
let workspace: string;
const ctx = (): ToolContext => ({ workspacePath: workspace, editAllowed: true });
const slideXml = (paragraphs: string[]): string =>
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
+ '<p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"'
+ ' xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">'
+ '<p:cSld><p:spTree><p:sp><p:txBody><a:bodyPr/>'
+ paragraphs.map(t => `<a:p><a:r><a:rPr lang="ja-JP"/><a:t>${t}</a:t></a:r></a:p>`).join('')
+ '</p:txBody></p:sp></p:spTree></p:cSld></p:sld>';
function buildPptx(filePath: string): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const zip = new AdmZip();
zip.addFile('[Content_Types].xml', Buffer.from(
'<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
+ '<Default Extension="xml" ContentType="application/xml"/></Types>', 'utf-8'));
zip.addFile('_rels/.rels', Buffer.from(
'<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
+ '<Relationship Id="rId1" Type="officeDocument" Target="ppt/presentation.xml"/></Relationships>', 'utf-8'));
zip.addFile('ppt/presentation.xml', Buffer.from(
'<?xml version="1.0"?><p:presentation xmlns:p="p" xmlns:r="r">'
+ '<p:sldIdLst><p:sldId id="256" r:id="rIdA"/></p:sldIdLst>'
+ '<p:sldSz cx="9144000" cy="6858000"/></p:presentation>', 'utf-8'));
zip.addFile('ppt/_rels/presentation.xml.rels', Buffer.from(
'<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
+ '<Relationship Id="rIdA" Type="slide" Target="slides/slide1.xml"/>'
+ '</Relationships>', 'utf-8'));
zip.addFile('ppt/slides/slide1.xml', Buffer.from(slideXml(['タイトル', '本文']), 'utf-8'));
zip.writeZip(filePath);
}
beforeEach(() => { workspace = fs.mkdtempSync(path.join(tmpdir(), 'maestro-edit-pptx-')); });
afterEach(() => fs.rmSync(workspace, { recursive: true, force: true }));
describe('EditPptx', () => {
it('段落を書き換える', async () => {
const file = path.join(workspace, 'output', 'deck.pptx');
buildPptx(file);
const result = await executeTool('EditPptx', {
path: 'output/deck.pptx',
edits: [{ slide: 1, paragraph: 1, text: '直した本文' }],
}, ctx());
expect(result?.isError).toBeFalsy();
expect(readPptxSlideParagraphs(file)).toEqual([['タイトル', '直した本文']]);
});
it('editAllowed=false では書き込みを拒否する', async () => {
const source = path.join(workspace, 'input', 'deck.pptx');
buildPptx(source);
const result = await executeTool('EditPptx', {
path: 'input/deck.pptx',
output_path: 'output/new.pptx',
edits: [{ slide: 1, paragraph: 0, text: '書けてはいけない' }],
}, { ...ctx(), editAllowed: false });
expect(result?.isError).toBe(true);
expect(result?.output).toMatch(/not allowed|edit flag/);
expect(fs.existsSync(path.join(workspace, 'output', 'new.pptx'))).toBe(false);
expect(readPptxSlideParagraphs(source)).toEqual([['タイトル', '本文']]);
});
it('editAllowed=false では output/ 配下への書き込みも拒否する', async () => {
const file = path.join(workspace, 'output', 'deck.pptx');
buildPptx(file);
const result = await executeTool('EditPptx', {
path: 'output/deck.pptx',
edits: [{ slide: 1, paragraph: 1, text: '書けてはいけない' }],
}, { ...ctx(), editAllowed: false });
expect(result?.isError).toBe(true);
expect(result?.output).toMatch(/not allowed|edit flag/);
expect(readPptxSlideParagraphs(file)).toEqual([['タイトル', '本文']]);
});
// 検証は文書側と共有の層(paragraph-edit-input)が持つ。ここで見るのは配線
// ——その層の断り文句がそのまま利用者に届くこと(#1067 重複・後始末)。
it('入力の誤りを、共有の層の文言で断る', async () => {
const file = path.join(workspace, 'output', 'deck.pptx');
buildPptx(file);
const badSlide = await executeTool('EditPptx', {
path: 'output/deck.pptx',
edits: [{ slide: 0, paragraph: 0, text: 'x' }],
}, ctx());
expect(badSlide?.isError).toBe(true);
expect(badSlide?.output).toBe('Invalid slide "0": use a 1-based integer');
const badParagraph = await executeTool('EditPptx', {
path: 'output/deck.pptx',
edits: [{ slide: 1, paragraph: -1, text: 'x' }],
}, ctx());
expect(badParagraph?.output).toBe('Invalid paragraph "-1": use a 0-based integer');
const badText = await executeTool('EditPptx', {
path: 'output/deck.pptx',
edits: [{ slide: 1, paragraph: 0, text: null }],
}, ctx());
expect(badText?.output).toBe('Slide 1 paragraph 0: text must be a string');
const empty = await executeTool('EditPptx', { path: 'output/deck.pptx', edits: [] }, ctx());
expect(empty?.output).toBe('edits is empty: give at least one paragraph to change');
// 断ったときはファイルに手を付けない。
expect(readPptxSlideParagraphs(file)).toEqual([['タイトル', '本文']]);
});
it('.pptx 以外は断る', async () => {
fs.mkdirSync(path.join(workspace, 'output'), { recursive: true });
fs.writeFileSync(path.join(workspace, 'output', 'note.txt'), 'ただの文字');
const result = await executeTool('EditPptx', {
path: 'output/note.txt',
edits: [{ slide: 1, paragraph: 0, text: 'x' }],
}, ctx());
expect(result?.isError).toBe(true);
expect(result?.output).toMatch(/\.pptx/);
});
});
@@ -0,0 +1,195 @@
/**
* Excel を読んだとき、返っていないものが出力から分かることを見る(#1165 段 A)。
*
* セル数の上限はブック全体の合計で数える。上限に達するとシートを回す処理が
* そこで止まるので、1 枚目で使い切ると 2 枚目以降は出力に一切現れなかった。
* そのとき出る文は「1000 セルまで読みました」の 1 行だけで、シートが落ちた
* ことに触れていない。受け取った側からは、そのブックに 1 枚しかシートが無い
* ように見える。
*
* ここで固定するのは 3 点。
* 1. シートの一覧(名前・行数・列数)は、切り捨ての有無に関係なく必ず載る
* 2. 読めなかったシートは、名前と「読めていない」ことが分かる
* 3. 途中で切れたシートは、どこまで読んだかが分かる
*
* 検査は Read の経路(core.ts の拡張子ディスパッチ)を通す。抽出関数だけを
* 直に呼ぶと、配線が外れていても素通りする。
*/
import * as fs from 'fs';
import * as path from 'path';
import { tmpdir } from 'os';
import ExcelJS from 'exceljs';
import { afterEach, describe, expect, it } from 'vitest';
import { executeCoreTools } from './core.js';
import type { ToolContext } from './core.js';
const workspaces: string[] = [];
function makeWorkspace(): string {
const ws = fs.mkdtempSync(path.join(tmpdir(), 'maestro-xlsx-sheets-'));
fs.mkdirSync(path.join(ws, 'input'), { recursive: true });
workspaces.push(ws);
return ws;
}
function ctx(workspacePath: string): ToolContext {
return { workspacePath, editAllowed: true };
}
afterEach(() => {
while (workspaces.length > 0) {
const ws = workspaces.pop()!;
try { fs.rmSync(ws, { recursive: true, force: true }); } catch { /* 後始末の失敗は検査の結果に含めない */ }
}
});
/** sheets の各枚を rows 行 × cols 列で埋めたブックを書く。 */
async function writeWorkbook(
filePath: string,
sheets: Array<{ name: string; rows: number; cols: number }>,
): Promise<void> {
const wb = new ExcelJS.Workbook();
for (const spec of sheets) {
const ws = wb.addWorksheet(spec.name);
for (let r = 1; r <= spec.rows; r++) {
for (let c = 1; c <= spec.cols; c++) {
ws.getCell(r, c).value = `${spec.name}-r${r}c${c}`;
}
}
}
await wb.xlsx.writeFile(filePath);
}
async function readXlsx(ws: string, input: Record<string, unknown> = {}): Promise<string> {
const res = await executeCoreTools('Read', { file_path: 'input/book.xlsx', ...input }, ctx(ws));
expect(res).not.toBeNull();
expect(res!.isError).toBe(false);
return res!.output;
}
describe('Read(Excel): 返っていないものが出力から分かる', () => {
it('1 枚目で上限を使い切っても、2 枚目以降の名前が出力に載る', async () => {
const ws = makeWorkspace();
// 1 枚目だけで既定の上限 1000 セル(20 列 × 50 行)を使い切る。
await writeWorkbook(path.join(ws, 'input', 'book.xlsx'), [
{ name: 'Sales', rows: 50, cols: 20 },
{ name: 'Costs', rows: 12, cols: 4 },
{ name: 'Notes', rows: 3, cols: 2 },
]);
const output = await readXlsx(ws);
expect(output).toContain('Costs');
expect(output).toContain('Notes');
});
it('読めなかったシートは、行数・列数と「読めていない」ことが分かる', async () => {
const ws = makeWorkspace();
await writeWorkbook(path.join(ws, 'input', 'book.xlsx'), [
{ name: 'Sales', rows: 50, cols: 20 },
{ name: 'Costs', rows: 12, cols: 4 },
{ name: 'Notes', rows: 3, cols: 2 },
]);
const output = await readXlsx(ws);
// 「Costs は 12 行 4 列あるが 1 セルも読めていない」が 1 行で読み取れること。
const costs = output.split('\n').find((l) => l.includes('| Costs '));
expect(costs, `Costs の行が無い:\n${output}`).toBeDefined();
expect(costs).toContain('| 12 |');
expect(costs).toContain('| 4 |');
expect(costs).toContain('NOT READ');
// Notes は上限に達したあとの枚。ここを載せるにはシートを回し切る必要がある。
const notes = output.split('\n').find((l) => l.includes('| Notes '));
expect(notes, `Notes の行が無い:\n${output}`).toBeDefined();
expect(notes).toContain('| 3 |');
expect(notes).toContain('| 2 |');
expect(notes).toContain('NOT READ');
// 読めていないシートを「(empty)」の表で出さない。中身があるのに空だと言うことになる。
expect(output).not.toContain('## Sheet: Costs');
});
it('sheet の指定が当たらないときは、これまでどおり断る', async () => {
const ws = makeWorkspace();
await writeWorkbook(path.join(ws, 'input', 'book.xlsx'), [
{ name: 'Sales', rows: 2, cols: 2 },
]);
const res = await executeCoreTools('Read', { file_path: 'input/book.xlsx', sheet: 'Nope' }, ctx(ws));
expect(res?.isError).toBe(true);
expect(res?.output).toContain('Sheet "Nope" not found');
});
it('途中で切れたシートは、どこまで読んだかが分かる', async () => {
const ws = makeWorkspace();
await writeWorkbook(path.join(ws, 'input', 'book.xlsx'), [
{ name: 'Sales', rows: 4, cols: 4 },
{ name: 'Costs', rows: 2, cols: 2 },
]);
// 6 セル = 1 行目の 4 つと 2 行目の 2 つ。止まった位置は B2。
const output = await readXlsx(ws, { max_cells: 6 });
const line = output.split('\n').find((l) => l.includes('| Sales '));
expect(line, `Sales の行が無い:\n${output}`).toBeDefined();
expect(line).toContain('PARTIAL');
expect(line).toContain('B2');
// 上限の警告文だけでも、止まった場所と落ちたシートが分かること。
expect(output).toContain('PARSE_PARTIAL_SUCCESS');
expect(output).toMatch(/Sales.*B2/);
expect(output).toMatch(/Costs/);
});
it('切り捨てが起きていなくても、シートの一覧は同じ形で出る', async () => {
const ws = makeWorkspace();
await writeWorkbook(path.join(ws, 'input', 'book.xlsx'), [
{ name: 'Sales', rows: 3, cols: 2 },
{ name: 'Costs', rows: 2, cols: 2 },
]);
const output = await readXlsx(ws);
expect(output).not.toContain('PARSE_PARTIAL_SUCCESS');
expect(output).toContain('## Sheets');
const sales = output.split('\n').find((l) => l.includes('| Sales '));
const costs = output.split('\n').find((l) => l.includes('| Costs '));
expect(sales, `Sales の行が無い:\n${output}`).toBeDefined();
expect(costs, `Costs の行が無い:\n${output}`).toBeDefined();
expect(sales).toContain('| 3 |');
expect(costs).toContain('| 2 |');
expect(sales).not.toContain('NOT READ');
expect(costs).not.toContain('NOT READ');
});
it('シートの一覧は、末尾が切られても残る位置に出る', async () => {
const ws = makeWorkspace();
await writeWorkbook(path.join(ws, 'input', 'book.xlsx'), [
{ name: 'Sales', rows: 50, cols: 20 },
{ name: 'Costs', rows: 12, cols: 4 },
]);
const output = await readXlsx(ws);
const inventoryAt = output.indexOf('## Sheets');
const firstTableAt = output.indexOf('## Sheet: Sales');
expect(inventoryAt).toBeGreaterThanOrEqual(0);
expect(firstTableAt).toBeGreaterThanOrEqual(0);
expect(inventoryAt).toBeLessThan(firstTableAt);
});
it('sheet を指定したときは、選ばれなかったシートもその理由つきで一覧に出る', async () => {
const ws = makeWorkspace();
await writeWorkbook(path.join(ws, 'input', 'book.xlsx'), [
{ name: 'Sales', rows: 3, cols: 2 },
{ name: 'Costs', rows: 2, cols: 2 },
]);
const output = await readXlsx(ws, { sheet: 'Costs' });
const sales = output.split('\n').find((l) => l.includes('| Sales '));
expect(sales, `Sales の行が無い:\n${output}`).toBeDefined();
expect(sales).toContain('NOT READ');
expect(sales).toContain('sheet=');
});
});
@@ -0,0 +1,93 @@
/**
* 読み取りライブラリが落ちるブックを、Excel を読む口が受け止めること(#1142)。
*
* 利用者の報告は「Read に特定の Excel を渡すとタスクごと終了する」。ライブラリは
* テーブル(ListObject)の絞り込み列がテーブル列より多いと投げる。素材は
* その場で組み立てる(`forge-zip` の `writeXlsxWithOversizedAutoFilter`)。
*/
import * as fs from 'fs';
import * as path from 'path';
import { tmpdir } from 'os';
import { describe, expect, it } from 'vitest';
import ExcelJS from 'exceljs';
import { executeTool } from './office.js';
import type { ToolContext } from './core.js';
import {
writeXlsxWithOversizedAutoFilter,
writeXlsxWithTable,
} from '../../office/__fixtures__/forge-zip.js';
function makeWorkspace(): string {
return fs.mkdtempSync(path.join(tmpdir(), 'maestro-excel-unreadable-'));
}
function inputPath(workspacePath: string, name: string): string {
const dir = path.join(workspacePath, 'input');
fs.mkdirSync(dir, { recursive: true });
return path.join(dir, name);
}
const ctx = (workspacePath: string): ToolContext =>
({ workspacePath, editAllowed: true }) as ToolContext;
describe('読めない Excel の受け止め', () => {
it('素材が本当に読めないこと(この 1 件が無いと以下が空振りする)', async () => {
const ws = makeWorkspace();
const file = inputPath(ws, 'broken.xlsx');
await writeXlsxWithOversizedAutoFilter(file);
await expect(new ExcelJS.Workbook().xlsx.readFile(file))
.rejects.toThrow("Cannot set properties of undefined (setting 'filterButton')");
});
it('細工していない同じ形のブックは読める(細工そのものが原因だと示す)', async () => {
const ws = makeWorkspace();
const file = inputPath(ws, 'fine.xlsx');
await writeXlsxWithTable(file);
const result = await executeTool('ReadExcel', { path: 'input/fine.xlsx' }, ctx(ws));
expect(result?.isError).toBe(false);
expect(result?.output).toContain('りんご');
});
it('ReadExcel は投げずに、何が起きたか分かる文面で断る', async () => {
const ws = makeWorkspace();
await writeXlsxWithOversizedAutoFilter(inputPath(ws, 'broken.xlsx'));
const result = await executeTool('ReadExcel', { path: 'input/broken.xlsx' }, ctx(ws));
expect(result?.isError).toBe(true);
expect(result?.output).toContain('Failed to read Excel file');
expect(result?.output).toContain('autofilter');
expect(result?.output).toContain('Re-saving the file in Excel');
});
it('SplitExcelSheets も投げずに断る', async () => {
const ws = makeWorkspace();
await writeXlsxWithOversizedAutoFilter(inputPath(ws, 'broken.xlsx'));
const result = await executeTool('SplitExcelSheets', { path: 'input/broken.xlsx' }, ctx(ws));
expect(result?.isError).toBe(true);
expect(result?.output).toContain('Failed to read Excel file');
});
it('利用者が実際に通った経路(Read → 拡張子で ReadExcel)でも断る', async () => {
const ws = makeWorkspace();
await writeXlsxWithOversizedAutoFilter(inputPath(ws, 'broken.xlsx'));
const { executeCoreTools } = await import('./core.js');
const result = await executeCoreTools('Read', { file_path: 'input/broken.xlsx' }, ctx(ws));
expect(result?.isError).toBe(true);
expect(result?.output).toContain('Failed to read Excel file');
});
it('関係のない読み取り失敗には、テーブルの説明を足さない', async () => {
const ws = makeWorkspace();
const file = inputPath(ws, 'notzip.xlsx');
// OOXML の先頭 4 バイトだけ合わせた、中身のない包み
fs.writeFileSync(file, Buffer.concat([Buffer.from([0x50, 0x4b, 0x03, 0x04]), Buffer.alloc(64)]));
const result = await executeTool('ReadExcel', { path: 'input/notzip.xlsx' }, ctx(ws));
expect(result?.isError).toBe(true);
expect(result?.output).not.toContain('autofilter');
});
});
+589
View File
@@ -3,8 +3,10 @@ import * as path from 'path';
import { tmpdir } from 'os';
import { afterEach, describe, expect, it } from 'vitest';
import AdmZip from 'adm-zip';
import ExcelJS from 'exceljs';
import { executeTool } from './office.js';
import type { ToolContext, ToolsConfig } from './core.js';
import { hideEntries, lieAboutUncompressedSize, rewriteAsZip64 } from '../../office/__fixtures__/forge-zip.js';
// --- Helpers (mirrors office.test.ts house style) ---
@@ -62,6 +64,45 @@ function writeZipBomb(filePath: string, uncompressedBytes: number): void {
const MB = 1024 * 1024;
/** そのままなら編集も読み取りも成功する、本物のブック(ExcelJS が書き出す形)。 */
async function writeWorkbook(filePath: string): Promise<void> {
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet('Sheet1');
ws.getCell('B1').value = 10;
await wb.xlsx.writeFile(filePath);
}
const readCell = async (filePath: string, cell: string): Promise<unknown> => {
const wb = new ExcelJS.Workbook();
await wb.xlsx.readFile(filePath);
return wb.getWorksheet('Sheet1')!.getCell(cell).value;
};
function writeDocx(filePath: string): void {
const zip = new AdmZip();
zip.addFile('[Content_Types].xml', Buffer.from('<Types/>', 'utf-8'));
zip.addFile('word/document.xml', Buffer.from(
'<w:document xmlns:w="w"><w:body><w:p><w:r><w:t>もとの本文</w:t></w:r></w:p></w:body></w:document>',
'utf-8'));
zip.writeZip(filePath);
}
function writePptx(filePath: string): void {
const zip = new AdmZip();
zip.addFile('[Content_Types].xml', Buffer.from('<Types/>', 'utf-8'));
zip.addFile('ppt/presentation.xml', Buffer.from(
'<p:presentation xmlns:p="p" xmlns:r="r"><p:sldIdLst><p:sldId id="256" r:id="rId1"/></p:sldIdLst></p:presentation>',
'utf-8'));
zip.addFile('ppt/_rels/presentation.xml.rels', Buffer.from(
'<Relationships><Relationship Id="rId1" Type="slide" Target="slides/slide1.xml"/></Relationships>',
'utf-8'));
zip.addFile('ppt/slides/slide1.xml', Buffer.from(
'<p:sld xmlns:p="p" xmlns:a="a"><p:cSld><p:spTree><p:sp><p:txBody>'
+ '<a:p><a:r><a:rPr lang="ja-JP"/><a:t>もとの見出し</a:t></a:r></a:p>'
+ '</p:txBody></p:sp></p:spTree></p:cSld></p:sld>', 'utf-8'));
zip.writeZip(filePath);
}
describe('office tools — file size limits', () => {
let workspacePath = '';
@@ -243,3 +284,551 @@ describe('office tools — file size limits', () => {
expect(highRes?.output ?? '').not.toContain('ZIP bomb detected');
});
});
/**
* 書き換える口(EditExcel / EditDocx / EditPptx)の展開後サイズの関所(#1067)。
*
* 素材は **そのままなら編集が成功する本物のファイル**に、開くと膨らむ項目を
* 1 つ足したもの。関所を外すと編集が通ってファイルが変わるので、上限を戻した
* ときに必ず落ちる。
*/
describe('office edit tools — uncompressed size limits', () => {
let workspacePath = '';
afterEach(() => {
if (workspacePath) {
fs.rmSync(workspacePath, { recursive: true, force: true });
workspacePath = '';
}
});
function outputPath(name: string): string {
const dir = path.join(workspacePath, 'output');
fs.mkdirSync(dir, { recursive: true });
return path.join(dir, name);
}
/** 開くと膨らむ項目を 1 つ足す(圧縮すると数 KB のゼロ埋め)。 */
function inflate(filePath: string, uncompressedBytes: number): void {
const zip = new AdmZip(filePath);
zip.addFile('docProps/thumbnail.bin', Buffer.alloc(uncompressedBytes, 0));
zip.writeZip(filePath);
}
it('EditExcel は展開後サイズが上限を超えるブックを、書き換えずに断る', async () => {
workspacePath = makeWorkspace();
const fp = outputPath('fat.xlsx');
await writeWorkbook(fp);
inflate(fp, 3 * MB);
expect(fs.statSync(fp).size).toBeLessThan(MB); // ファイルの大きさでは弾けない
const result = await executeTool(
'EditExcel',
{ path: 'output/fat.xlsx', edits: [{ cell: 'B1', value: 42 }] },
makeContext(workspacePath, { officePptxMaxUncompressedMb: 1 }),
);
expect(result?.isError).toBe(true);
expect(result?.output).toContain('ZIP bomb detected');
expect(await readCell(fp, 'B1')).toBe(10); // 中身は変わっていない
});
it('EditExcel は上限に収まるブックなら、そのまま書き換えられる', async () => {
workspacePath = makeWorkspace();
const fp = outputPath('ok.xlsx');
await writeWorkbook(fp);
inflate(fp, 3 * MB);
const result = await executeTool(
'EditExcel',
{ path: 'output/ok.xlsx', edits: [{ cell: 'B1', value: 42 }] },
makeContext(workspacePath, { officePptxMaxUncompressedMb: 10 }),
);
expect(result?.isError).toBeFalsy();
expect(await readCell(fp, 'B1')).toBe(42);
});
it('EditDocx は展開後サイズが上限を超える文書を、書き換えずに断る', async () => {
workspacePath = makeWorkspace();
const fp = outputPath('fat.docx');
writeDocx(fp);
inflate(fp, 3 * MB);
const before = fs.readFileSync(fp);
const result = await executeTool(
'EditDocx',
{ path: 'output/fat.docx', edits: [{ paragraph: 0, text: '書き換えた' }] },
makeContext(workspacePath, { officePptxMaxUncompressedMb: 1 }),
);
expect(result?.isError).toBe(true);
expect(result?.output).toContain('ZIP bomb detected');
expect(fs.readFileSync(fp).equals(before)).toBe(true);
});
it('EditDocx は上限に収まる文書なら、そのまま書き換えられる', async () => {
workspacePath = makeWorkspace();
const fp = outputPath('ok.docx');
writeDocx(fp);
inflate(fp, 3 * MB);
const result = await executeTool(
'EditDocx',
{ path: 'output/ok.docx', edits: [{ paragraph: 0, text: '書き換えた' }] },
makeContext(workspacePath, { officePptxMaxUncompressedMb: 10 }),
);
expect(result?.isError).toBeFalsy();
expect(new AdmZip(fp).readAsText('word/document.xml')).toContain('書き換えた');
});
it('EditPptx は展開後サイズが上限を超えるスライドを、書き換えずに断る', async () => {
workspacePath = makeWorkspace();
const fp = outputPath('fat.pptx');
writePptx(fp);
inflate(fp, 3 * MB);
const before = fs.readFileSync(fp);
const result = await executeTool(
'EditPptx',
{ path: 'output/fat.pptx', edits: [{ slide: 1, paragraph: 0, text: '書き換えた' }] },
makeContext(workspacePath, { officePptxMaxUncompressedMb: 1 }),
);
expect(result?.isError).toBe(true);
expect(result?.output).toContain('ZIP bomb detected');
expect(fs.readFileSync(fp).equals(before)).toBe(true);
});
it('EditPptx は上限に収まるスライドなら、そのまま書き換えられる', async () => {
workspacePath = makeWorkspace();
const fp = outputPath('ok.pptx');
writePptx(fp);
inflate(fp, 3 * MB);
const result = await executeTool(
'EditPptx',
{ path: 'output/ok.pptx', edits: [{ slide: 1, paragraph: 0, text: '書き換えた' }] },
makeContext(workspacePath, { officePptxMaxUncompressedMb: 10 }),
);
expect(result?.isError).toBeFalsy();
expect(new AdmZip(fp).readAsText('ppt/slides/slide1.xml')).toContain('書き換えた');
});
/**
* 申告する展開後サイズだけを書き換える(中身は小さいまま)。既定の上限が
* 効いていることを、200MB のファイルを実際に作らずに確かめるため。
* 中央ディレクトリを正しく辿るので、圧縮データの中の偶然の一致は踏まない。
*/
function declareUncompressedSize(filePath: string, bytes: number): void {
const buf = fs.readFileSync(filePath);
const eocd = buf.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]));
const count = buf.readUInt16LE(eocd + 10);
let at = buf.readUInt32LE(eocd + 16);
for (let i = 0; i < count; i += 1) {
buf.writeUInt32LE(bytes, at + 24); // 中央ディレクトリ側
buf.writeUInt32LE(bytes, buf.readUInt32LE(at + 42) + 22); // ローカルヘッダ側
at += 46 + buf.readUInt16LE(at + 28) + buf.readUInt16LE(at + 30) + buf.readUInt16LE(at + 32);
}
fs.writeFileSync(filePath, buf);
}
/**
* 設定していない環境(本番の既定)で関所が効くことを見る。上限を実質無制限に
* 書き換えると、このテストだけが落ちる(#1067 レビュー P2-2)。
*/
it('設定が無くても、既定の展開後上限(200MB)で断る', async () => {
workspacePath = makeWorkspace();
const fp = outputPath('default.docx');
writeDocx(fp);
declareUncompressedSize(fp, 250 * MB);
const before = fs.readFileSync(fp);
const result = await executeTool(
'EditDocx',
{ path: 'output/default.docx', edits: [{ paragraph: 0, text: '書き換えた' }] },
makeContext(workspacePath), // 設定なし=本番の既定
);
expect(result?.isError).toBe(true);
expect(result?.output).toContain('ZIP bomb detected');
expect(result?.output).toContain('exceeds limit of 200MB');
expect(fs.readFileSync(fp).equals(before)).toBe(true);
});
/**
* 中身が空の項目を大量に並べた包みは、申告の合計が 0 バイトなのでサイズの
* 上限をいくつにしても素通りする。項目数の関所で断る(#1067 レビュー P2-1)。
*/
it('中身が空の項目を大量に並べた包みは、展開後サイズが 0 でも断る', async () => {
workspacePath = makeWorkspace();
const fp = outputPath('many.docx');
writeDocx(fp);
const zip = new AdmZip(fp);
for (let i = 0; i < 10_001; i += 1) zip.addFile(`docProps/pad${i}.bin`, Buffer.alloc(0));
zip.writeZip(fp);
const before = fs.readFileSync(fp);
const result = await executeTool(
'EditDocx',
{ path: 'output/many.docx', edits: [{ paragraph: 0, text: '書き換えた' }] },
// 展開後サイズの上限は緩めておく。ここで効くのは項目数のほう。
makeContext(workspacePath, { officePptxMaxUncompressedMb: 200 }),
);
expect(result?.isError).toBe(true);
expect(result?.output).toContain('entries exceeds limit of 10000');
expect(fs.readFileSync(fp).equals(before)).toBe(true);
});
/**
* 項目数の上限は設定で動かせる(#1067 レビュー P2-2 / P3-3)。
*
* 読むだけの口にも同じ関所が乗ったので、当たった利用者に回避手段が要る。
* 下げれば普通のファイルでも断り、上げれば通ることを両方向で見る。
*/
it('項目数の上限は officeMaxEntries で下げられる(書き換える口)', async () => {
workspacePath = makeWorkspace();
const fp = outputPath('small.docx');
writeDocx(fp); // 項目は 2 つ
const before = fs.readFileSync(fp);
const result = await executeTool(
'EditDocx',
{ path: 'output/small.docx', edits: [{ paragraph: 0, text: '書き換えた' }] },
makeContext(workspacePath, { officeMaxEntries: 1 }),
);
expect(result?.isError).toBe(true);
expect(result?.output).toContain('entries exceeds limit of 1');
expect(fs.readFileSync(fp).equals(before)).toBe(true);
});
it('項目数の上限は officeMaxEntries で上げられる(読むだけの口)', async () => {
workspacePath = makeWorkspace();
const fp = inputPath(workspacePath, 'small.pptx');
writePptx(fp); // 項目は 4 つ
const lowered = await executeTool(
'ReadPPTX',
{ path: 'input/small.pptx' },
makeContext(workspacePath, { officeMaxEntries: 2 }),
);
expect(lowered?.isError).toBe(true);
expect(lowered?.output).toContain('entries exceeds limit of 2');
const raised = await executeTool(
'ReadPPTX',
{ path: 'input/small.pptx' },
makeContext(workspacePath, { officeMaxEntries: 50 }),
);
expect(raised?.output ?? '').not.toContain('ZIP bomb detected');
});
// 一覧を返すだけの呼び出しでも、包みは開く。同じ関所を通す。
it('EditPptx のスライド一覧も、展開後サイズが上限を超えれば断る', async () => {
workspacePath = makeWorkspace();
const fp = outputPath('list.pptx');
writePptx(fp);
inflate(fp, 3 * MB);
const result = await executeTool(
'EditPptx',
{ path: 'output/list.pptx', list_paragraphs: true },
makeContext(workspacePath, { officePptxMaxUncompressedMb: 1 }),
);
expect(result?.isError).toBe(true);
expect(result?.output).toContain('ZIP bomb detected');
});
it('EditDocx の段落一覧も、展開後サイズが上限を超えれば断る', async () => {
workspacePath = makeWorkspace();
const fp = outputPath('list.docx');
writeDocx(fp);
inflate(fp, 3 * MB);
const result = await executeTool(
'EditDocx',
{ path: 'output/list.docx', list_paragraphs: true },
makeContext(workspacePath, { officePptxMaxUncompressedMb: 1 }),
);
expect(result?.isError).toBe(true);
expect(result?.output).toContain('ZIP bomb detected');
});
});
/**
* 申告を偽った包みを、関所が乗っている入口すべてで断る(#1084 段階 1)。
*
* 経路は表で持つ。**入口を足したときに追加漏れが起きにくい形**にしてある
* (段階 2 で関所が乗る 5 経路も、同じ表に並べる)。
*
* 素材はその場で組む。正当なファイル(この製品が自分で書き出したもの)に項目を
* 足したうえで、末尾の記録の件数欄を元に戻す。関所からは足した項目が見えず、
* 読み込み層からは見える——その食い違いが穴の本体。
*/
describe('office tools — 申告を偽った包みを断る', () => {
let workspacePath = '';
afterEach(() => {
if (workspacePath) {
fs.rmSync(workspacePath, { recursive: true, force: true });
workspacePath = '';
}
});
interface Route {
name: string;
file: string;
build: (filePath: string) => Promise<void> | void;
input: (relative: string) => Record<string, unknown>;
}
const ROUTES: Route[] = [
{
name: 'ReadPPTX',
file: 'input/deck.pptx',
build: writePptx,
input: (relative) => ({ path: relative }),
},
{
name: 'EditExcel',
file: 'output/book.xlsx',
build: writeWorkbook,
input: (relative) => ({ path: relative, edits: [{ cell: 'B1', value: 42 }] }),
},
{
name: 'EditDocx',
file: 'output/report.docx',
build: writeDocx,
input: (relative) => ({ path: relative, edits: [{ paragraph: 0, text: '書き換えた' }] }),
},
{
name: 'EditPptx',
file: 'output/deck.pptx',
build: writePptx,
input: (relative) => ({ path: relative, edits: [{ slide: 1, paragraph: 0, text: '書き換えた' }] }),
},
];
for (const route of ROUTES) {
it(`${route.name} は、隠し項目を持つ包みを断る(中身は変えない)`, async () => {
workspacePath = makeWorkspace();
const filePath = path.join(workspacePath, route.file);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
await route.build(filePath);
hideEntries(filePath, [{ name: 'zzz-hidden.bin', content: Buffer.alloc(64 * 1024, 0) }]);
const before = fs.readFileSync(filePath);
const result = await executeTool(route.name, route.input(route.file), makeContext(workspacePath));
expect(result?.isError).toBe(true);
expect(result?.output).toContain('Refusing to open this package');
expect(fs.readFileSync(filePath).equals(before)).toBe(true);
});
it(`${route.name} は、同じ素材の**偽っていない**ほうは断らない`, async () => {
workspacePath = makeWorkspace();
const filePath = path.join(workspacePath, route.file);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
await route.build(filePath);
// 同じ項目を、件数を偽らずに足す(正直な包み)
const zip = new AdmZip(filePath);
zip.addFile('zzz-hidden.bin', Buffer.alloc(64 * 1024, 0));
zip.writeZip(filePath);
const result = await executeTool(route.name, route.input(route.file), makeContext(workspacePath));
expect(result?.output ?? '').not.toContain('Refusing to open this package');
});
}
/**
* 詳細編集(operations)は共通の関所ではなく preflight を通るが、**隠した項目が
* 実害を出すのはこの経路**(列挙が申告件数で止まったまま ExcelJS へ渡り、
* あちらは目録を最後まで読む)。同じ規則が乗っていることを固定する。
*/
it('EditExcel の詳細編集も、隠し項目を持つ包みを断る(増幅が起きる経路)', async () => {
workspacePath = makeWorkspace();
const filePath = path.join(workspacePath, 'input', 'book.xlsx');
fs.mkdirSync(path.dirname(filePath), { recursive: true });
await writeWorkbook(filePath);
hideEntries(filePath, [{ name: 'zzz-hidden.bin', content: Buffer.alloc(64 * 1024, 0) }]);
const result = await executeTool('EditExcel', {
path: 'input/book.xlsx',
output_path: 'output/planned.xlsx',
operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'A2', values: [['計画']] }],
}, makeContext(workspacePath));
expect(result?.isError).toBe(true);
expect(result?.output).toContain('食い違います');
expect(fs.existsSync(path.join(workspacePath, 'output', 'planned.xlsx'))).toBe(false);
});
it('EditExcel の詳細編集は、偽っていないブックなら断らない', async () => {
workspacePath = makeWorkspace();
const filePath = path.join(workspacePath, 'input', 'book.xlsx');
fs.mkdirSync(path.dirname(filePath), { recursive: true });
await writeWorkbook(filePath);
const result = await executeTool('EditExcel', {
path: 'input/book.xlsx',
output_path: 'output/planned.xlsx',
operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'A2', values: [['計画']] }],
}, makeContext(workspacePath));
expect(result?.isError).toBe(false);
expect(await readCell(path.join(workspacePath, 'output', 'planned.xlsx'), 'A2')).toBe('計画');
});
it('ZIP64 の包みは、どの入口でも誤って断られない', async () => {
workspacePath = makeWorkspace();
const filePath = path.join(workspacePath, 'output', 'book.xlsx');
fs.mkdirSync(path.dirname(filePath), { recursive: true });
await writeWorkbook(filePath);
rewriteAsZip64(filePath);
const result = await executeTool(
'EditExcel',
{ path: 'output/book.xlsx', edits: [{ cell: 'B1', value: 42 }] },
makeContext(workspacePath),
);
expect(result?.isError).toBeFalsy();
expect(await readCell(filePath, 'B1')).toBe(42);
});
});
/**
* 申告する**展開後サイズ**を偽った包みを、関所が乗っている入口すべてで断る
* (#1084 段階 3)。
*
* 段階 1 で塞いだのは「項目の**件数**を偽る」形。ここで塞ぐのは「1 項目の
* **大きさ**を偽る」形。どちらも同じ非対称——関所が使う道具(adm-zip)は
* 申告ぶんしか受け皿を作らず、読み込み層(JSZip)は最後まで展開する。
*
* 経路は上と同じ表を使う。入口を足したら、そちらに 1 行足せば両方の細工で
* 見られる。
*/
describe('office tools — 展開後サイズの申告を偽った包みを断る(段階 3)', () => {
let workspacePath = '';
afterEach(() => {
if (workspacePath) {
fs.rmSync(workspacePath, { recursive: true, force: true });
workspacePath = '';
}
});
interface Route {
name: string;
file: string;
build: (filePath: string) => Promise<void> | void;
input: (relative: string) => Record<string, unknown>;
}
const ROUTES: Route[] = [
{
name: 'ReadPPTX',
file: 'input/deck.pptx',
build: writePptx,
input: (relative) => ({ path: relative }),
},
{
name: 'EditExcel',
file: 'output/book.xlsx',
build: writeWorkbook,
input: (relative) => ({ path: relative, edits: [{ cell: 'B1', value: 42 }] }),
},
{
name: 'EditDocx',
file: 'output/report.docx',
build: writeDocx,
input: (relative) => ({ path: relative, edits: [{ paragraph: 0, text: '書き換えた' }] }),
},
{
name: 'EditPptx',
file: 'output/deck.pptx',
build: writePptx,
input: (relative) => ({ path: relative, edits: [{ slide: 1, paragraph: 0, text: '書き換えた' }] }),
},
];
/** 開くと 8MB 膨らむ項目を足し、その申告だけ 100 バイトに偽る。 */
async function buildLiar(route: Route): Promise<string> {
workspacePath = makeWorkspace();
const filePath = path.join(workspacePath, route.file);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
await route.build(filePath);
const zip = new AdmZip(filePath);
zip.addFile('zzz-payload.bin', Buffer.alloc(8 * MB, 0));
zip.writeZip(filePath);
lieAboutUncompressedSize(filePath, 'zzz-payload.bin', 100);
return filePath;
}
for (const route of ROUTES) {
it(`${route.name} は、展開後サイズを偽った包みを断る(中身は変えない)`, async () => {
const filePath = await buildLiar(route);
const before = fs.readFileSync(filePath);
// ディスク上の大きさでも件数でも弾けないことを、素材の側で確かめる。
expect(fs.statSync(filePath).size).toBeLessThan(MB);
expect(new AdmZip(filePath).getEntries().reduce((sum, e) => sum + e.header.size, 0))
.toBeLessThan(MB);
const result = await executeTool(
route.name,
route.input(route.file),
makeContext(workspacePath, { officePptxMaxUncompressedMb: 1 } as ToolsConfig),
);
expect(result?.isError).toBe(true);
expect(result?.output).toContain('ZIP bomb detected');
expect(fs.readFileSync(filePath).equals(before)).toBe(true);
});
it(`${route.name} は、同じ素材の**偽っていない**ほうは上限内なら断らない`, async () => {
workspacePath = makeWorkspace();
const filePath = path.join(workspacePath, route.file);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
await route.build(filePath);
const zip = new AdmZip(filePath);
zip.addFile('zzz-payload.bin', Buffer.alloc(8 * MB, 0));
zip.writeZip(filePath);
const result = await executeTool(
route.name,
route.input(route.file),
makeContext(workspacePath, { officePptxMaxUncompressedMb: 20 } as ToolsConfig),
);
expect(result?.output ?? '').not.toContain('ZIP bomb detected');
expect(result?.isError ?? false).toBe(false);
});
}
it('EditExcel の詳細編集も、展開後サイズを偽った包みを断る', async () => {
workspacePath = makeWorkspace();
const filePath = path.join(workspacePath, 'input', 'book.xlsx');
fs.mkdirSync(path.dirname(filePath), { recursive: true });
await writeWorkbook(filePath);
const zip = new AdmZip(filePath);
zip.addFile('zzz-payload.bin', Buffer.alloc(8 * MB, 0));
zip.writeZip(filePath);
lieAboutUncompressedSize(filePath, 'zzz-payload.bin', 100);
const result = await executeTool('EditExcel', {
path: 'input/book.xlsx',
output_path: 'output/planned.xlsx',
operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'A2', values: [['計画']] }],
}, makeContext(workspacePath, { officePptxMaxUncompressedMb: 1 } as ToolsConfig));
expect(result?.isError).toBe(true);
expect(fs.existsSync(path.join(workspacePath, 'output', 'planned.xlsx'))).toBe(false);
});
});
+758 -26
View File
@@ -7,8 +7,17 @@ import AdmZip from 'adm-zip';
import { XMLParser } from 'fast-xml-parser';
import { PDFParse } from 'pdf-parse';
import { ToolDef } from '../../llm/openai-compat.js';
import type { ToolContext, ToolResult } from './core.js';
import { resolveAndGuard, resolveOutputPathWithin, truncateToBudget, getToolOutputBudgetTokens } from './core.js';
import type { ToolContext, ToolResult } from './tool-context.js';
import { resolveAndGuard, resolveOutputPathWithin, truncateToBudget, getToolOutputBudgetTokens, assertWritable } from './core.js';
import { editXlsxCells } from '../../office/xlsx-edit.js';
import { parseCellEdits, parseColumnEdits, parseMergeEdits, parseRowEdits } from '../../office/cell-edit-input.js';
import { MAX_FONT_NAME_LENGTH, MAX_FONT_SIZE } from '../../office/cell-font-input.js';
import { editPptxParagraphs, readPptxSlideParagraphs, type PptxParagraphEdit } from '../../office/pptx-edit.js';
import { editDocxParagraphs, readDocxParagraphs } from '../../office/docx-edit.js';
import { parseParagraphEdits, parseSlideParagraphEdits } from '../../office/paragraph-edit-input.js';
import { checkOoxmlPackageLimits, DEFAULT_MAX_ENTRIES, DEFAULT_MAX_UNCOMPRESSED_MB } from '../../office/ooxml-package.js';
import { runExcelEditPipeline } from './excel-edit-pipeline.js';
import { serializeExcelResult } from './excel-types.js';
import { resolveThemePalette, extractSheetStyles } from './excel-styles.js';
import { executeReadMsg } from './msg.js';
import { logger } from '../../logger.js';
@@ -36,7 +45,6 @@ const DEFAULT_EXCEL_MAX_SIZE_MB = 10;
const DEFAULT_DOCX_MAX_SIZE_MB = 10;
const DEFAULT_PDF_MAX_SIZE_MB = 10;
const DEFAULT_PPTX_MAX_SIZE_MB = 50;
const DEFAULT_PPTX_MAX_UNCOMPRESSED_MB = 200;
const MACRO_EXTENSIONS = new Set(['.xlsm', '.docm', '.pptm', '.xlsb']);
function mbToBytes(mb: number): number {
@@ -49,6 +57,46 @@ function resolveMaxSize(ctx: ToolContext, key: keyof NonNullable<ToolContext['to
return mbToBytes(mb);
}
/**
* 包みに入っていてよい項目数の上限。設定は `tools.office_max_entries`。
*
* 大きさと違って MB ではなく件数なので、切り上げの仕方も既定値も別(1 未満は
* 受け付けない)。読むだけの口にも同じ上限がかかるため、当たった利用者が
* 管理者に頼めば緩められる形にしてある(#1067 レビュー P2-2)。
*/
function resolveMaxEntries(ctx: ToolContext): number {
const v = ctx.toolsConfig?.officeMaxEntries;
if (typeof v !== 'number' || !Number.isFinite(v) || v < 1) return DEFAULT_MAX_ENTRIES;
return Math.floor(v);
}
/**
* 包みを開く前に、展開後サイズと項目数の上限を確かめる。超えていれば断る
* 結果を返す。
*
* このモジュールの中で 7 か所が呼ぶ——書き換える口 3 種(#1067 安全性 1 件目)、
* 表計算・文書を読む口 2 種、分割 2 種(後半 4 つは #1084 段階 2)。スライドを
* 読む口は上限の出どころが違うので同じ関所を直接呼び、画面から来る口は
* `office-preview.ts` / `local-files-api.ts` 側から呼ぶ。経路を足すときは、
* 包みを開くより手前・出力を作るより手前に置くこと。
*
* 包みとして開けない場合は null を返し、その先の処理が本来のエラー(壊れた
* ZIP など)を出すのに任せる。
*/
function rejectIfZipBomb(filePath: string, ctx: ToolContext): ToolResult | null {
let message: string | null;
try {
message = checkOoxmlPackageLimits(
filePath,
resolveMaxSize(ctx, 'officePptxMaxUncompressedMb', DEFAULT_MAX_UNCOMPRESSED_MB),
resolveMaxEntries(ctx),
);
} catch {
return null;
}
return message ? { output: message, isError: true } : null;
}
// 拡張子と magic byte sniff の組み合わせで、Office/PDF 系 tool に
// **別フォーマットのファイル**が渡されたケースを早期検出する。
// JSZip / pdf-parse は内部で cryptic な error ("Can't find end of central
@@ -262,6 +310,253 @@ const PDF_TO_IMAGES_DEF: ToolDef = {
},
};
const EXCEL_STYLE_SCHEMA = {
type: 'object',
description: '既存書式へ重ねるセル書式。色は #RRGGBB または #AARRGGBB。',
properties: {
fill: { type: 'string', description: '背景色' },
font_color: { type: 'string', description: '文字色' },
border_color: { type: 'string', description: '四辺の罫線色' },
bold: { type: 'boolean' },
italic: { type: 'boolean' },
underline: { type: 'boolean' },
number_format: { type: 'string', description: 'Excel表示形式(例: 0.0%, yyyy-mm-dd)' },
horizontal: { type: 'string', enum: ['left', 'center', 'right'] },
vertical: { type: 'string', enum: ['top', 'middle', 'bottom'] },
wrap_text: { type: 'boolean' },
},
};
const EDIT_EXCEL_DEF: ToolDef = {
type: 'function',
function: {
name: 'EditExcel',
description: 'Excelを編集する。値/数式/書式(太字・色・罫線・揃え・表示形式)/行高/セル結合/列幅の差替えはedits・rows・merges・columns、数式検証とガントはoperationsを使う。operationsは検証に合格した場合だけ原子的に保存する。詳細は ReadToolDoc({ name: "EditExcel" })。',
parameters: {
type: 'object',
properties: {
path: { type: 'string', description: '入力に使うworkspace内の.xlsxパス。新規作成時は省略可能。' },
edits: {
type: 'array',
description: '後方互換の軽量編集。指定セル以外のOOXML部品を保持する。operationsとは併用しない。',
items: {
type: 'object',
properties: {
sheet: { type: 'string', description: 'シート名(省略時は先頭シート)' },
cell: { type: 'string', description: 'A1 形式のセル参照' },
value: { description: '書き込む値(文字列 / 数値 / 真偽値 / null=空にする)' },
formula: { type: 'string', description: '数式(= は不要)。value とは併用しない' },
style: {
type: 'object',
description: '書式の差分。指定した項目だけ今の書式に重ねる。値も数式も省くと書式だけを変える。',
properties: {
bold: { type: 'boolean' },
italic: { type: 'boolean', description: '斜体。false で解除' },
underline: { type: 'boolean', description: '単一下線。false で下線を解除' },
strike: { type: 'boolean', description: '取り消し線。false で解除' },
font_name: { maxLength: MAX_FONT_NAME_LENGTH, description: `書体名(最大${MAX_FONT_NAME_LENGTH}文字)。null でセル固有の指定を解除` },
font_size: { minimum: 1, maximum: MAX_FONT_SIZE, multipleOf: 0.5, description: `フォントサイズ(1〜${MAX_FONT_SIZE}ポイント、0.5刻み)。null でセル固有の指定を解除` },
font_color: { description: '文字色 "#RRGGBB"。null で既定色' },
fill_color: { description: '背景色 "#RRGGBB"。null で塗りなし' },
border: {
description: '罫線。null で 4 辺とも消す',
type: 'object',
properties: {
edges: { type: 'array', items: { type: 'string', enum: ['left', 'right', 'top', 'bottom'] }, description: '省略時は 4 辺' },
style: { type: 'string', enum: ['none', 'thin', 'medium', 'thick', 'dashed', 'dotted', 'double', 'hair'] },
color: { type: 'string', description: '"#RRGGBB"' },
},
},
alignment: {
description: '揃え。null で揃えの指定を消す',
type: 'object',
properties: {
horizontal: { type: 'string', enum: ['general', 'left', 'center', 'right', 'fill', 'justify', 'centerContinuous', 'distributed'] },
vertical: { type: 'string', enum: ['top', 'center', 'bottom', 'justify', 'distributed'] },
wrap_text: { type: 'boolean' },
text_rotation: { description: '文字の回転(-90〜90の整数、vertical、null=回転解除)' },
},
},
number_format: { description: '表示形式の書式コード "#,##0" / "0.0%" / "yyyy/mm/dd" 等。null か "General" で標準' },
},
},
},
required: ['cell'],
},
},
rows: {
type: 'array',
description: 'editsと組で使う行の高さ。operationsとは併用しない。',
items: {
type: 'object',
properties: {
sheet: { type: 'string', description: 'シート名(省略時は先頭シート)' },
row: { type: 'integer', description: '1 始まりの行番号' },
height: { description: '高さ(ポイント、0 より大きく 409.5 以下)。null で自動' },
},
required: ['row', 'height'],
},
},
merges: {
type: 'array',
description: 'セルの結合・解除。editsと同じ軽量編集の一部で、operationsとは併用しない。',
items: {
type: 'object',
properties: {
sheet: { type: 'string', description: 'シート名(省略時は先頭シート)' },
range: { type: 'string', description: 'A1:C1 形式(左上:右下)。逆順・単独セル・範囲外は拒否' },
merged: { type: 'boolean', description: 'true で結合、false で結合を解除' },
},
required: ['range', 'merged'],
},
},
columns: {
type: 'array',
description: '列の幅。editsと同じ軽量編集の一部で、operationsとは併用しない。',
items: {
type: 'object',
properties: {
sheet: { type: 'string', description: 'シート名(省略時は先頭シート)' },
columns: { type: 'string', description: '"A:C"(範囲)または "B"(1 列)' },
width: { description: '幅(標準書体の「0」の文字幅。0 より大きく 255 以下)。null で指定を外す' },
},
required: ['columns', 'width'],
},
},
operations: {
type: 'array',
maxItems: 500,
description: '宣言的編集。op別の必須項目は各フィールド説明を参照。editsとは併用しない。',
items: {
type: 'object',
properties: {
op: {
type: 'string',
enum: ['add_sheet', 'set_values', 'set_formula', 'fill_formula', 'merge', 'unmerge', 'style_range', 'set_column_width', 'set_row_height', 'freeze_panes'],
},
name: { type: 'string', description: 'add_sheetのシート名' },
sheet: { type: 'string', description: '対象シート名' },
range: { type: 'string', description: 'A1:B3形式。値・数式充填・結合・書式で使用。' },
cell: { type: 'string', description: 'set_formulaのA1形式セル' },
values: { type: 'array', description: 'set_values用。rangeと同じ行列サイズの二次元配列。' },
formula: { type: 'string', description: 'set_formula/fill_formulaの数式(先頭=は省略可能)' },
expect: { description: '再計算後の期待値。値、または {equals, tolerance}。不一致なら保存しない。' },
style: EXCEL_STYLE_SCHEMA,
columns: {
anyOf: [{ type: 'string' }, { type: 'integer' }],
description: '列幅ではA:C形式、freeze_panesでは固定する列数。',
},
width: { type: 'number', description: 'set_column_widthの列幅(0より大きく255以下)' },
rows: {
anyOf: [{ type: 'string' }, { type: 'integer' }],
description: '行高では1:3形式、freeze_panesでは固定する行数。',
},
height: { type: 'number', description: 'set_row_heightの行高(0より大きく409.5以下)' },
},
required: ['op'],
},
},
gantt: {
type: 'object',
description: 'operationsで日付・開始日・終了日・進捗を書いた後、休日背景と計画/完了バーを着色する。',
properties: {
sheet: { type: 'string' },
timeline_header: { type: 'string', description: '連続する日付見出し(例: D1:AH1)' },
task_rows: { type: 'string', description: 'タスク行(例: 2:20)' },
start_column: { type: 'string' },
end_column: { type: 'string' },
progress_column: { type: 'string' },
progress_scale: { type: 'string', enum: ['fraction', 'percent'] },
holidays: { type: 'array', items: { type: 'string' }, description: 'YYYY-MM-DD形式' },
roles: {
type: 'object',
properties: {
weekend: EXCEL_STYLE_SCHEMA,
holiday: EXCEL_STYLE_SCHEMA,
planned: EXCEL_STYLE_SCHEMA,
completed: EXCEL_STYLE_SCHEMA,
},
required: ['weekend', 'holiday', 'planned', 'completed'],
},
},
required: ['sheet', 'timeline_header', 'task_rows', 'start_column', 'end_column', 'progress_column', 'progress_scale', 'holidays', 'roles'],
},
output_path: { type: 'string', description: '出力パス。operationsでは必須。editsでは省略時にpathを上書き。' },
overwrite: { type: 'boolean', description: 'operationsで入力と同じpathを安全に上書きする場合だけtrue' },
expected_source_sha256: { type: 'string', description: 'operationsの上書き時に必須。編集開始時の入力SHA-256。' },
dry_run: { type: 'boolean', description: 'operationsを再計算・検証まで行うが、ファイルを保存しない。' },
},
anyOf: [
{ required: ['path', 'edits'] },
{ required: ['path', 'rows'] },
{ required: ['path', 'merges'] },
{ required: ['path', 'columns'] },
{ required: ['output_path', 'operations'] },
],
},
},
};
const EDIT_DOCX_DEF: ToolDef = {
type: 'function',
function: {
name: 'EditDocx',
description: 'Word の指定した段落だけを書き換える(スタイル・表・画像・ヘッダーは保持)。詳細は ReadToolDoc({ name: "EditDocx" })。',
parameters: {
type: 'object',
properties: {
path: { type: 'string', description: 'workspace 内の .docx パス' },
edits: {
type: 'array',
description: '書き換える段落の一覧',
items: {
type: 'object',
properties: {
paragraph: { type: 'number', description: '0 始まりの段落番号(list_paragraphs で確認できる)' },
text: { type: 'string', description: '差し替える文字列。空文字なら段落を残して中身だけ消す' },
},
required: ['paragraph', 'text'],
},
},
list_paragraphs: { type: 'boolean', description: 'true なら書き換えず、段落番号と本文の一覧だけ返す' },
output_path: { type: 'string', description: '別名保存する場合の出力パス(省略時は上書き)' },
},
required: ['path'],
},
},
};
const EDIT_PPTX_DEF: ToolDef = {
type: 'function',
function: {
name: 'EditPptx',
description: 'PowerPoint の指定した段落だけを書き換える(配置・図形・画像・アニメーション・ノートは保持)。詳細は ReadToolDoc({ name: "EditPptx" })。',
parameters: {
type: 'object',
properties: {
path: { type: 'string', description: 'workspace 内の .pptx パス' },
edits: {
type: 'array',
description: '書き換える段落の一覧',
items: {
type: 'object',
properties: {
slide: { type: 'number', description: '1 始まりのスライド番号(見た目の並び順)' },
paragraph: { type: 'number', description: '0 始まりの段落番号(list_paragraphs で確認できる)' },
text: { type: 'string', description: '差し替える文字列。空文字なら段落を残して中身だけ消す' },
},
required: ['slide', 'paragraph', 'text'],
},
},
list_paragraphs: { type: 'boolean', description: 'true なら書き換えず、スライド・段落番号と本文の一覧だけ返す' },
output_path: { type: 'string', description: '別名保存する場合の出力パス(省略時は上書き)' },
},
required: ['path'],
},
},
};
// ReadExcel / ReadDocx / ReadPdf / ReadPPTX / ReadMsg は Read に統合され、独立ツール
// としては未登録。抽出ロジックは executeTool の内部 dispatch target として残る(Read が
// 拡張子判定で呼び出す)。カタログ (TOOL_DEFS) には出さない。
@@ -269,6 +564,9 @@ export const TOOL_DEFS: Record<string, ToolDef> = {
SplitExcelSheets: SPLIT_EXCEL_SHEETS_DEF,
SplitDocxSections: SPLIT_DOCX_SECTIONS_DEF,
PdfToImages: PDF_TO_IMAGES_DEF,
EditExcel: EDIT_EXCEL_DEF,
EditDocx: EDIT_DOCX_DEF,
EditPptx: EDIT_PPTX_DEF,
};
// --- Excel column letter helpers ---
@@ -446,8 +744,105 @@ function renderSheetMarkdownForHumans(cells: SheetCell[], sheetName: string, ran
return md.join('\n');
}
/**
* ExcelJS が読み取りで投げた例外を、次に何をすればよいか分かる文面にする(#1142)。
*
* 同梱の ExcelJS 4.4.0 は、シート内のテーブル(ListObject)で絞り込み
* (オートフィルタ)の列数がテーブルの列数を超えていると
* `Cannot set properties of undefined (setting 'filterButton')` で落ちる
* (`lib/xlsx/xform/table/table-xform.js` の parseClose が、絞り込み側の並び順を
* そのままテーブル列の添字に使うため)。素の例外文だけでは、受け取った側は
* 何が起きたのか分からない。
*
* 同梱ライブラリには手を入れない。読めるようにする細工(テーブル定義を書き換えて
* 読み直す等)も入れない。断る文面をここで具体的にする。
*/
export function describeExcelReadFailure(e: unknown): string {
const detail = e instanceof Error ? e.message : String(e);
if (detail.includes("setting 'filterButton'")) {
return `Failed to read Excel file: ${detail}`
+ ' — this workbook contains a table whose autofilter declares more columns'
+ ' than the table itself has, which this reader cannot parse.'
+ ' Re-saving the file in Excel usually repairs the table definition.';
}
return `Failed to read Excel file: ${detail}`;
}
// --- ReadExcel implementation ---
/** ブックの 1 枚がどう扱われたか(#1165 段 A)。 */
export interface ExcelSheetReadSummary {
name: string;
/** シートの大きさ。ExcelJS が読んだ寸法で、読めたセル数とは別。 */
rows: number;
cols: number;
cellsRead: number;
/** 読めたセルの外枠。1 セルも読めていなければ undefined。 */
range?: string;
/** 途中で切れたシートで、最後に読めたセルの番地。 */
stoppedAfter?: string;
status: 'read' | 'partial' | 'skipped_limit' | 'skipped_filter';
}
function escapeTableCell(text: string): string {
return text.replace(/\|/g, '\\|').replace(/\n/g, ' ');
}
/**
* ブックにあるシートの一覧を表にする。
*
* セル数の上限はブック全体の合計で数えるので、1 枚目で使い切ると 2 枚目以降は
* 1 セルも読まれない。以前はそれが出力に一切現れず、受け取った側からは
* 1 枚しか無いブックに見えていた(#1165)。この表は**切り捨ての有無に関係なく
* 必ず出す**。上限に達したときだけ出す作りにすると、切り捨てのたびに書式が
* 変わって読み手が混乱する。
*
* 置き場所は本文の先頭。末尾は出力の文字数と持ち分の両方で切られるため、
* 後ろに置くと肝心のときに消える。
*/
export function renderSheetInventory(summaries: ExcelSheetReadSummary[], maxCells: number, sheetFilter?: string): string {
const lines: string[] = [];
lines.push(`## Sheets (${summaries.length} in this workbook)`);
lines.push('');
lines.push('| Sheet | Rows | Cols | Read |');
lines.push('| --- | --- | --- | --- |');
for (const s of summaries) {
let read: string;
switch (s.status) {
case 'read':
read = `read — ${s.cellsRead} cells${s.range ? ` (${s.range})` : ''}`;
break;
case 'partial':
read = `PARTIAL — ${s.cellsRead} cells${s.range ? ` (${s.range})` : ''}`
+ `, stopped after ${s.stoppedAfter}, max_cells=${maxCells} reached`;
break;
case 'skipped_limit':
read = `NOT READ — 0 cells, max_cells=${maxCells} was already reached`;
break;
case 'skipped_filter':
read = `NOT READ — 0 cells, sheet="${escapeTableCell(sheetFilter ?? '')}" was requested`;
break;
}
lines.push(`| ${escapeTableCell(s.name)} | ${s.rows} | ${s.cols} | ${read} |`);
}
lines.push('');
return lines.join('\n');
}
/** 上限で切れたときの一行。どこで止まり、どのシートが残っているかを書く(#1165)。 */
export function describeCellLimitStop(summaries: ExcelSheetReadSummary[], maxCells: number): string {
const partial = summaries.find((s) => s.status === 'partial');
const unread = summaries.filter((s) => s.status === 'skipped_limit');
const where = partial
? `stopped in sheet "${partial.name}" after ${partial.stoppedAfter}`
: 'stopped before the first cell';
const rest = unread.length > 0
? `; ${unread.length} sheet(s) not read at all: ${unread.map((s) => `"${s.name}"`).join(', ')}`
: '';
return `Cell limit reached: only first ${maxCells} cells were read, ${where}${rest}`
+ '. The "Sheets" table above lists every sheet in this workbook with its size.';
}
async function executeReadExcel(
input: Record<string, unknown>,
ctx: ToolContext,
@@ -485,6 +880,11 @@ async function executeReadExcel(
return { output: warnings.map((w) => w.message).join('\n'), isError: true };
}
// 開くと巨大になる包みを断る。ファイルの大きさしか見ていなかったので、
// 数十 KB のファイルで数百 MB を確保させられた(#1084 設計 §2-2 の 7 番)。
const bomb = rejectIfZipBomb(resolved, ctx);
if (bomb) return bomb;
const fileSizeBytes = (() => {
try { return fs.statSync(resolved).size; } catch { return undefined; }
})();
@@ -500,7 +900,7 @@ async function executeReadExcel(
try {
await wb.xlsx.readFile(resolved);
} catch (e) {
return { output: `Failed to read Excel file: ${(e as Error).message}`, isError: true };
return { output: describeExcelReadFailure(e), isError: true };
}
const themeXml = includeStyles ? ((wb.model as unknown as { themes?: { theme1?: string } }).themes?.theme1) : undefined;
@@ -508,6 +908,7 @@ async function executeReadExcel(
const filename = path.basename(resolved);
const sheetBlocks: ExcelSheetBlock[] = [];
const summaries: ExcelSheetReadSummary[] = [];
let totalCells = 0;
let truncated = false;
@@ -520,8 +921,20 @@ async function executeReadExcel(
};
for (const ws of wb.worksheets) {
if (sheetFilter && ws.name !== sheetFilter) continue;
if (truncated) break;
// シートの大きさは読む前に控える。上限で 1 セルも読めなかった枚でも、
// 何行何列あるのかは返す(#1165)。
const sheetSize = { rows: ws.rowCount ?? 0, cols: ws.columnCount ?? 0 };
if (sheetFilter && ws.name !== sheetFilter) {
summaries.push({ name: ws.name, ...sheetSize, cellsRead: 0, status: 'skipped_filter' });
continue;
}
// 上限に達していても、残りのシートを一覧に載せるためループは回し切る。
// ここで break すると、落ちたシートが出力から丸ごと消える(#1165)。
if (truncated) {
summaries.push({ name: ws.name, ...sheetSize, cellsRead: 0, status: 'skipped_limit' });
continue;
}
const rangeBounds = rangeFilter ? parseRange(rangeFilter) : null;
const cells: SheetCell[] = [];
@@ -590,6 +1003,24 @@ async function executeReadExcel(
rangeStr = `${colIndexToLetter(minC)}${minR}:${colIndexToLetter(maxC)}${maxR}`;
}
const summary: ExcelSheetReadSummary = {
name: ws.name,
...sheetSize,
cellsRead: cells.length,
status: truncated ? (cells.length > 0 ? 'partial' : 'skipped_limit') : 'read',
};
if (cells.length > 0) {
summary.range = rangeStr;
// セルは行→列の順に積むので、末尾が最後に読めた番地になる。
if (truncated) summary.stoppedAfter = cells[cells.length - 1]!.address;
}
summaries.push(summary);
// 上限がこのシートの 1 セル目で尽きた場合、表を出すと「(empty)」と書かれる。
// 中身のあるシートを空だと言うことになるので、表は出さず一覧の
// NOT READ に任せる(#1165)。
if (summary.status === 'skipped_limit') continue;
const block: ExcelSheetBlock = {
type: 'excel.sheet',
sheetName: ws.name,
@@ -603,11 +1034,14 @@ async function executeReadExcel(
if (truncated) {
doc.warnings.push({
code: 'PARSE_PARTIAL_SUCCESS',
message: `Cell limit reached: only first ${maxCells} cells were read`,
message: describeCellLimitStop(summaries, maxCells),
});
}
if (sheetBlocks.length === 0) {
// 「読む対象が 1 枚も無い」ときだけ断る。上限が尽きて中身を出せなかった場合は
// 断らずに一覧を返す(どのシートが残っているかは、そこにしか書けない)。
const hasSelectedSheet = summaries.some((s) => s.status !== 'skipped_filter');
if (!hasSelectedSheet) {
const msg = sheetFilter
? `Sheet "${sheetFilter}" not found in ${filename}`
: `No sheets found in ${filename}`;
@@ -615,6 +1049,7 @@ async function executeReadExcel(
}
const parts: string[] = [`# ${filename}\n`];
parts.push(renderSheetInventory(summaries, maxCells, sheetFilter));
for (const block of sheetBlocks) {
parts.push(formatCellsAsTable(block.cells, block.sheetName, block.range));
parts.push('');
@@ -682,10 +1117,26 @@ async function executeSplitExcelSheets(
}
if (!fs.existsSync(resolved)) return { output: `File not found: ${filePath}`, isError: true };
// 読む口と同じ 2 つの上限を、包みを開く前に通す。ここは大きさの検査すら
// 通っていなかった(#1084 設計 §2-2 の 8 番)。断るときに出力ディレクトリを
// 作らないよう、`mkdirSync` より手前に置く。
const sizeWarnings = validateOfficeFile(resolved, ['.xlsx', '.xls', '.xlsm', '.xlsb'], resolveMaxSize(ctx, 'officeExcelMaxSizeMb', DEFAULT_EXCEL_MAX_SIZE_MB));
const tooLarge = sizeWarnings.find((w) => w.code === 'FILE_TOO_LARGE');
if (tooLarge) return { output: tooLarge.message, isError: true };
const bomb = rejectIfZipBomb(resolved, ctx);
if (bomb) return bomb;
fs.mkdirSync(outputDir, { recursive: true });
const wb = new ExcelJS.Workbook();
await wb.xlsx.readFile(resolved);
// 読む口(executeReadExcel)と同じく、読み取りの例外はここで受け止める。
// ここだけ素通しだったので、読めないブックを渡すと例外が呼出側を突き抜けた(#1142)。
try {
await wb.xlsx.readFile(resolved);
} catch (e) {
return { output: describeExcelReadFailure(e), isError: true };
}
const base = sanitizeFilename(path.basename(resolved, path.extname(resolved)));
const records: Array<{ sheet: string; file: string; rows: number; cols: number; cells: number; truncated: boolean }> = [];
@@ -756,6 +1207,14 @@ async function executeSplitDocxSections(
}
if (!fs.existsSync(resolved)) return { output: `File not found: ${filePath}`, isError: true };
// 読む口と同じ 2 つの上限を、包みを開く前に通す(#1084 設計 §2-2 の 10 番)。
const sizeWarnings = validateOfficeFile(resolved, ['.docx', '.docm'], resolveMaxSize(ctx, 'officeDocxMaxSizeMb', DEFAULT_DOCX_MAX_SIZE_MB));
const tooLarge = sizeWarnings.find((w) => w.code === 'FILE_TOO_LARGE');
if (tooLarge) return { output: tooLarge.message, isError: true };
const bomb = rejectIfZipBomb(resolved, ctx);
if (bomb) return bomb;
fs.mkdirSync(outputDir, { recursive: true });
const raw = await mammoth.extractRawText({ path: resolved });
@@ -892,6 +1351,11 @@ async function executeReadDocx(
return { output: warnings.map((w) => w.message).join('\n'), isError: true };
}
// 開くと巨大になる包みを断る(#1084 設計 §2-2 の 9 番)。1 回通せば、
// 本文の取り出しと HTML への変換の両方が同じ関所の内側に入る。
const bomb = rejectIfZipBomb(resolved, ctx);
if (bomb) return bomb;
const fileSizeBytes = (() => {
try { return fs.statSync(resolved).size; } catch { return undefined; }
})();
@@ -1272,7 +1736,7 @@ async function executeReadPdf(
const ocrPages: { pageNo: number; text: string }[] = [];
for (let i = 0; i < imagePaths.length; i++) {
const pageNo = i + 1;
const imgResult = resolveImagePath(imagePaths[i], ctx);
const imgResult = await resolveImagePath(imagePaths[i], ctx);
if ('isError' in imgResult) {
const msg = `(image OCR failed: ${imgResult.output})`;
ocrResults.push(`## Page ${pageNo}\n\n${msg}`);
@@ -1986,7 +2450,19 @@ async function executeReadPptx(
ctx: ToolContext,
): Promise<ToolResult> {
const filePath = input['path'] as string;
const slideRangeStr = typeof input['slideRange'] === 'string' ? input['slideRange'] : undefined;
// 宣言と説明に出しているのは page_range だけ(#1165 段 B)。PDF と同じ名前に
// したのは、形式ごとに覚える名前を増やさないため。slide_range / slideRange は
// 黙って受け続ける。モデルが素直に思いつく名前で失敗させると、この issue が
// 減らそうとしている「合わない組み合わせのエラー」を 1 つ増やすことになる。
// 拒否文・案内文では page_range だけを名指しする(2 つ並べると迷わせる)。
const slideRangeRaw = [
input['page_range'],
input['pageRange'],
input['slide_range'],
input['slideRange'],
].find((value) => value !== undefined);
const slideRangeGiven = slideRangeRaw !== undefined;
const slideRangeStr = typeof slideRangeRaw === 'string' ? slideRangeRaw : undefined;
let resolved: string;
try {
@@ -2026,24 +2502,33 @@ async function executeReadPptx(
const startMs = Date.now();
const filename = path.basename(resolved);
const slideRange = parseSlideRange(slideRangeStr);
// 書式が読めない範囲は失敗として返す(#1165 段 B)。以前は parseSlideRange が
// null を返し、そのまま**全スライド**が返っていた。受け取った側からは指定した
// 範囲が取れたように見えるため、PDF の page_range と同じく読まずに断る。
if (slideRangeGiven && !slideRange) {
return { output: `Invalid page_range: ${JSON.stringify(slideRangeRaw)}`, isError: true };
}
// 関所は目録を自前で歩くので中身のバイト列が要る。読むのは 1 回だけにして、
// 同じバッファを包みを開く側にも渡す(`new AdmZip(path)` も内部で全部
// 読むので、ここが増えるわけではない)。
let zip: AdmZip;
let raw: Buffer;
try {
zip = new AdmZip(resolved);
raw = fs.readFileSync(resolved);
zip = new AdmZip(raw);
} catch (e) {
return { output: `Failed to open PPTX file: ${(e as Error).message}`, isError: true };
}
// ZIP bomb detection: check total uncompressed size
const maxUncompressed = resolveMaxSize(ctx, 'officePptxMaxUncompressedMb', DEFAULT_PPTX_MAX_UNCOMPRESSED_MB);
const totalUncompressed = zip.getEntries().reduce((sum, e) => sum + e.header.size, 0);
if (totalUncompressed > maxUncompressed) {
const limitMB = (maxUncompressed / 1024 / 1024).toFixed(0);
return {
output: `ZIP bomb detected: total uncompressed size ${(totalUncompressed / 1024 / 1024).toFixed(1)}MB exceeds limit of ${limitMB}MB`,
isError: true,
};
}
// 判定は書き換える口と同じ共有の関所を通す(写経すると片方だけ抜ける)。
const bomb = checkOoxmlPackageLimits(
raw,
resolveMaxSize(ctx, 'officePptxMaxUncompressedMb', DEFAULT_MAX_UNCOMPRESSED_MB),
resolveMaxEntries(ctx),
);
if (bomb) return { output: bomb, isError: true };
// スライドの順序を presentation.xml から解決
const orderedSlidePaths = resolveSlideOrder(zip);
@@ -2128,11 +2613,19 @@ async function executeReadPptx(
// フォーマット出力
const parts: string[] = [`# ${filename}`, ''];
// 範囲が 1 枚も掴まなかった場合は失敗として返す(#1165 段 B)。以前は
// 「No slides found in range」を成功として返しており、受け取った側からは
// 「その範囲は空だった」と読めた。実際の枚数を本文に入れて、指定し直せる
// ようにする(PDF の page_range と同じ扱い)。
if (slideRange && slideBlocks.length === 0) {
return {
output: `No slides matched page_range=${slideRangeStr} (presentation has ${slidePaths.length} slide(s))`,
isError: true,
};
}
if (slideBlocks.length === 0) {
const msg = slideRange
? `No slides found in range ${slideRangeStr}`
: 'No slides found in file';
parts.push(msg);
parts.push('No slides found in file');
} else {
for (const slide of slideBlocks) {
// スライドタイトルは最初のテキスト要素から推測
@@ -2175,7 +2668,8 @@ async function executeReadPptx(
}
output = truncateToBudget(output, getToolOutputBudgetTokens(ctx), {
sourceLabel: `ReadPPTX(${filename})`,
continuationHint: 'スライド範囲を絞るか、max_slides を調整してください',
// max_slides という指定は実装が無い(#1165)。実在する page_range に直す。
continuationHint: 'page_range でスライド範囲を絞ってください',
}).text;
return { output, isError: false };
})();
@@ -2185,6 +2679,238 @@ async function executeReadPptx(
// --- Exports ---
/**
* Excel の指定セルだけを書き換える(#1035 段階 1)。
*
* legacy edits は対象シートのOOXMLだけを書き換え、未対応部品も保持する。
* operations は単純な.xlsxだけを受け付け、ExcelJS編集後にLibreOfficeで数式を
* 再計算・検証し、合格した候補だけを原子的に確定する。
*/
async function executeEditExcel(
input: Record<string, unknown>,
ctx: ToolContext,
): Promise<ToolResult> {
if (!ctx.editAllowed) {
return { output: 'EditExcel is not allowed: edit flag is false', isError: true };
}
const hasOperations = Object.hasOwn(input, 'operations');
// 軽量編集の入口は 4 つ(セル・行高・結合・列幅)。どれか 1 つでも来ていたら
// 軽量編集として扱う。1 つ足すたびにここを直し忘れると、その項目だけが
// operations と併用できてしまう。
const LIGHT_FIELDS = ['edits', 'rows', 'merges', 'columns'] as const;
const lightFields = LIGHT_FIELDS.filter(name => Object.hasOwn(input, name));
if (hasOperations && lightFields.length > 0) {
return { output: `Give either ${lightFields.join('/')} or operations, not both`, isError: true };
}
if (lightFields.length > 0) {
const operationsOnlyFlag = ['gantt', 'dry_run', 'overwrite', 'expected_source_sha256']
.find(name => Object.hasOwn(input, name));
if (operationsOnlyFlag) {
return {
output: `${operationsOnlyFlag} is available with operations only`,
isError: true,
};
}
}
if (hasOperations) {
const pipelineResult = await runExcelEditPipeline(input, ctx);
return { output: serializeExcelResult(pipelineResult), isError: !pipelineResult.ok };
}
const filePath = typeof input['path'] === 'string' ? input['path'] : '';
if (!filePath) return { output: 'path is required', isError: true };
const parsedRows = parseRowEdits(input['rows']);
if (!parsedRows.ok) return { output: parsedRows.error, isError: true };
const rowEdits = parsedRows.rows;
const parsedMerges = parseMergeEdits(input['merges']);
if (!parsedMerges.ok) return { output: parsedMerges.error, isError: true };
const mergeEdits = parsedMerges.merges;
const parsedColumns = parseColumnEdits(input['columns']);
if (!parsedColumns.ok) return { output: parsedColumns.error, isError: true };
const columnEdits = parsedColumns.columns;
const otherEdits = rowEdits.length + mergeEdits.length + columnEdits.length;
const parsed = parseCellEdits(input['edits'], { allowEmpty: otherEdits > 0 });
if (!parsed.ok) return { output: parsed.error, isError: true };
const edits = parsed.edits;
let resolved: string;
let target: string;
try {
resolved = resolveAndGuard(ctx.workspacePath, filePath);
const outputInput = typeof input['output_path'] === 'string' ? input['output_path'] : filePath;
target = resolveAndGuard(ctx.workspacePath, outputInput);
assertWritable(ctx.workspacePath, target);
} catch (e) {
return { output: (e as Error).message, isError: true };
}
if (!fs.existsSync(resolved)) return { output: `File not found: ${filePath}`, isError: true };
if (path.extname(resolved).toLowerCase() !== '.xlsx') {
return { output: `EditExcel handles .xlsx only (got ${path.extname(resolved) || 'no extension'})`, isError: true };
}
const warnings = validateOfficeFile(resolved, ['.xlsx'], resolveMaxSize(ctx, 'officeExcelMaxSizeMb', DEFAULT_EXCEL_MAX_SIZE_MB));
const tooLarge = warnings.find(w => w.code === 'FILE_TOO_LARGE');
if (tooLarge) return { output: tooLarge.message, isError: true };
const bomb = rejectIfZipBomb(resolved, ctx);
if (bomb) return bomb;
try {
fs.mkdirSync(path.dirname(target), { recursive: true });
const result = editXlsxCells(resolved, target, edits, rowEdits, {
merges: mergeEdits,
columns: columnEdits,
});
const where = target === resolved ? filePath : path.relative(ctx.workspacePath, target).split(path.sep).join('/');
const notes = [
result.rows > 0 ? `${result.rows} row height(s)` : '',
result.merges > 0 ? `${result.merges} merge change(s)` : '',
result.columns > 0 ? `${result.columns} column width change(s)` : '',
].filter(Boolean);
const rowNote = notes.length > 0 ? `, ${notes.join(', ')}` : '';
return {
output: `Updated ${result.cells} cell(s)${rowNote} in ${result.sheets.join(', ')} → ${where}\n`
+ `Untouched parts kept byte-identical: ${result.untouchedEntries}`,
isError: false,
};
} catch (e) {
return { output: `EditExcel failed: ${(e as Error).message}`, isError: true };
}
}
/**
* Word の指定した段落だけを書き換える(#1035 文書編)。
*
* 段落番号は本文に現れる順の 0 始まり。番号が分からないうちに書き換えると
* 別の段落を潰すので、まず list_paragraphs で一覧を取る使い方を想定している。
*/
async function executeEditDocx(
input: Record<string, unknown>,
ctx: ToolContext,
): Promise<ToolResult> {
if (!ctx.editAllowed) {
return { output: 'EditDocx is not allowed: edit flag is false', isError: true };
}
const filePath = typeof input['path'] === 'string' ? input['path'] : '';
if (!filePath) return { output: 'path is required', isError: true };
let resolved: string;
let target: string;
try {
resolved = resolveAndGuard(ctx.workspacePath, filePath);
const outputInput = typeof input['output_path'] === 'string' ? input['output_path'] : filePath;
target = resolveAndGuard(ctx.workspacePath, outputInput);
} catch (e) {
return { output: (e as Error).message, isError: true };
}
if (!fs.existsSync(resolved)) return { output: `File not found: ${filePath}`, isError: true };
if (path.extname(resolved).toLowerCase() !== '.docx') {
return { output: `EditDocx handles .docx only (got ${path.extname(resolved) || 'no extension'})`, isError: true };
}
const warnings = validateOfficeFile(resolved, ['.docx'], resolveMaxSize(ctx, 'officeDocxMaxSizeMb', DEFAULT_DOCX_MAX_SIZE_MB));
const tooLarge = warnings.find(w => w.code === 'FILE_TOO_LARGE');
if (tooLarge) return { output: tooLarge.message, isError: true };
const bomb = rejectIfZipBomb(resolved, ctx);
if (bomb) return bomb;
if (input['list_paragraphs'] === true) {
try {
const paragraphs = readDocxParagraphs(resolved);
const listed = paragraphs
.map((text, index) => `${index}: ${text.length > 200 ? `${text.slice(0, 200)}…` : text}`)
.join('\n');
return { output: `${paragraphs.length} paragraph(s)\n${listed}`, isError: false };
} catch (e) {
return { output: `EditDocx failed: ${(e as Error).message}`, isError: true };
}
}
// 検証はスライド側と同じ層を通す。写経すると片方だけ直すドリフトが起きる。
const parsed = parseParagraphEdits(input['edits']);
if (!parsed.ok) return { output: parsed.error, isError: true };
const edits = parsed.edits;
try {
assertWritable(ctx.workspacePath, target);
fs.mkdirSync(path.dirname(target), { recursive: true });
const result = editDocxParagraphs(resolved, target, edits);
const where = target === resolved ? filePath : path.relative(ctx.workspacePath, target).split(path.sep).join('/');
return {
output: `Updated ${result.paragraphs} paragraph(s) → ${where}\n`
+ `Untouched parts kept byte-identical: ${result.untouchedEntries}`,
isError: false,
};
} catch (e) {
return { output: `EditDocx failed: ${(e as Error).message}`, isError: true };
}
}
async function executeEditPptx(
input: Record<string, unknown>,
ctx: ToolContext,
): Promise<ToolResult> {
if (!ctx.editAllowed) {
return { output: 'EditPptx is not allowed: edit flag is false', isError: true };
}
const filePath = typeof input['path'] === 'string' ? input['path'] : '';
if (!filePath) return { output: 'path is required', isError: true };
let resolved: string;
let target: string;
try {
resolved = resolveAndGuard(ctx.workspacePath, filePath);
const outputInput = typeof input['output_path'] === 'string' ? input['output_path'] : filePath;
target = resolveAndGuard(ctx.workspacePath, outputInput);
} catch (e) {
return { output: (e as Error).message, isError: true };
}
if (!fs.existsSync(resolved)) return { output: `File not found: ${filePath}`, isError: true };
if (path.extname(resolved).toLowerCase() !== '.pptx') {
return { output: `EditPptx handles .pptx only (got ${path.extname(resolved) || 'no extension'})`, isError: true };
}
const warnings = validateOfficeFile(resolved, ['.pptx'], resolveMaxSize(ctx, 'officePptxMaxSizeMb', DEFAULT_PPTX_MAX_SIZE_MB));
const tooLarge = warnings.find(w => w.code === 'FILE_TOO_LARGE');
if (tooLarge) return { output: tooLarge.message, isError: true };
const bomb = rejectIfZipBomb(resolved, ctx);
if (bomb) return bomb;
if (input['list_paragraphs'] === true) {
try {
const slides = readPptxSlideParagraphs(resolved);
const listed = slides
.map((paragraphs, slideIndex) => {
const head = `## Slide ${slideIndex + 1} (${paragraphs.length} paragraph(s))`;
const body = paragraphs
.map((text, index) => `${index}: ${text.length > 200 ? `${text.slice(0, 200)}…` : text}`)
.join('\n');
return paragraphs.length > 0 ? `${head}\n${body}` : head;
})
.join('\n');
return { output: `${slides.length} slide(s)\n${listed}`, isError: false };
} catch (e) {
return { output: `EditPptx failed: ${(e as Error).message}`, isError: true };
}
}
// 検証は文書側と同じ層を通す(違いはスライド番号の 1 項目だけ)。
const parsed = parseSlideParagraphEdits(input['edits']);
if (!parsed.ok) return { output: parsed.error, isError: true };
const edits: PptxParagraphEdit[] = parsed.edits;
try {
assertWritable(ctx.workspacePath, target);
fs.mkdirSync(path.dirname(target), { recursive: true });
const result = editPptxParagraphs(resolved, target, edits);
const where = target === resolved ? filePath : path.relative(ctx.workspacePath, target).split(path.sep).join('/');
return {
output: `Updated ${result.paragraphs} paragraph(s) on slide(s) ${result.slides.join(', ')} → ${where}\n`
+ `Untouched parts kept byte-identical: ${result.untouchedEntries}`,
isError: false,
};
} catch (e) {
return { output: `EditPptx failed: ${(e as Error).message}`, isError: true };
}
}
export async function executeTool(
name: string,
input: Record<string, unknown>,
@@ -2207,6 +2933,12 @@ export async function executeTool(
return executeSplitDocxSections(input, ctx);
case 'PdfToImages':
return executePdfToImages(input, ctx);
case 'EditExcel':
return executeEditExcel(input, ctx);
case 'EditDocx':
return executeEditDocx(input, ctx);
case 'EditPptx':
return executeEditPptx(input, ctx);
default:
return null;
}
+15 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'fs';
import { existsSync, mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import type { ToolContext } from './core.js';
@@ -32,6 +32,20 @@ describe('TOOL_DEFS', () => {
expect(def?.function.name).toBe('SpawnSubTask');
expect(def?.function.parameters?.['required']).toEqual(['title', 'instruction']);
});
// #1126: piece 引数の description は毎回 LLM に渡る。ここに載っている名前が
// pieces/ に無いと、LLM は存在しない piece を指定してサブタスク作成に失敗する。
it('lists only pieces that actually exist for the piece argument', () => {
const def = TOOL_DEFS['SpawnSubTask'];
const props = def?.function.parameters?.['properties'] as Record<string, { description?: string }>;
const desc = props?.['piece']?.description ?? '';
const listed = (desc.match(/(([^)]+))/)?.[1] ?? '').split(',').map(s => s.trim()).filter(Boolean);
expect(listed).toEqual(['general', 'research', 'data-process', 'office-process']);
const piecesDir = join(process.cwd(), 'pieces');
for (const name of listed) {
expect(existsSync(join(piecesDir, `${name}.yaml`)), `${name} が pieces/ に無い`).toBe(true);
}
});
});
describe('executeTool', () => {
+2 -2
View File
@@ -1,7 +1,7 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { ToolDef } from '../../llm/openai-compat.js';
import { ToolContext, ToolResult } from './core.js';
import type { ToolContext, ToolResult } from './tool-context.js';
export const TOOL_DEFS: Record<string, ToolDef> = {
delegate: {
@@ -37,7 +37,7 @@ export const TOOL_DEFS: Record<string, ToolDef> = {
},
piece: {
type: 'string',
description: '使用するピース名(general, research, brainstorming, data-process, office-process)。デフォルトは general。',
description: '使用するピース名(general, research, data-process, office-process)。デフォルトは general。',
},
},
required: ['title', 'instruction'],
+1 -1
View File
@@ -24,7 +24,7 @@
*/
import { ToolDef } from '../../llm/openai-compat.js';
import type { ToolContext, ToolResult } from './core.js';
import type { ToolContext, ToolResult } from './tool-context.js';
import { parseAndValidateSpec, assertNotShadowing } from '../python-packages.js';
const REQUEST_PACKAGE_DEF: ToolDef = {
+534
View File
@@ -0,0 +1,534 @@
/**
* ページに載っていた画像の魚拓(issue #1097)
*
* 取得の口(fetchImage)を差し込めるので、ブラウザ無しで全部の歯止めを見られる。
* 「その 1 行を消したら落ちるか」を基準にしている。
*/
import { describe, expect, it, beforeEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import sharp from 'sharp';
import {
archivePageImages,
decodeDataUrl,
dedupeCandidates,
DEFAULT_PAGE_IMAGE_ARCHIVE_LIMITS,
formatArchiveNote,
isBelowDimensionFloor,
resolveImageExtension,
resolvePageImageArchiveLimits,
type PageImageArchiveLimits,
type PageImageFetcher,
} from './page-image-archive.js';
import { readSourceIndex, saveSourceFile } from './source-library.js';
import { executeTool as executeImageTool } from './image.js';
import type { ToolContext } from './core.js';
const PNG = 'image/png';
function makeWorkspace(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'page-archive-'));
}
/** 中身の違う PNG を作る(指紋が別になる) */
async function png(width: number, height: number, seed: number): Promise<Buffer> {
return await sharp({
create: {
width,
height,
channels: 3,
background: { r: seed % 255, g: (seed * 7) % 255, b: (seed * 13) % 255 },
},
})
.png()
.toBuffer();
}
/** minBytes(1024 バイト)を確実に超える PNG。圧縮が効かないノイズを載せる */
async function bigPng(width: number, height: number, seed: number): Promise<Buffer> {
const raw = Buffer.alloc(width * height * 3);
let state = (seed % 2147483646) + 1;
for (let i = 0; i < raw.length; i++) {
state = (state * 48271) % 2147483647; // Lehmer(2^53 を超えないので下位ビットが死なない)
raw[i] = state & 0xff;
}
return await sharp(raw, { raw: { width, height, channels: 3 } }).png().toBuffer();
}
function fetcherFor(map: Record<string, { data: Buffer; contentType?: string }>): PageImageFetcher {
return async (url: string) => map[url] ?? null;
}
const LIMITS: PageImageArchiveLimits = { ...DEFAULT_PAGE_IMAGE_ARCHIVE_LIMITS };
/** 1024 バイト(容量の下限)を超える SVG。XML 宣言とコメントを頭に置く */
const SVG_BYTES = Buffer.from(
'<?xml version="1.0" encoding="UTF-8"?>\n<!-- generated -->\n' +
'<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64">' +
'<rect width="64" height="64" fill="#3366cc"/>' +
`<desc>${'図の説明。'.repeat(120)}</desc></svg>`,
'utf-8',
);
describe('resolvePageImageArchiveLimits', () => {
it('falls back to the documented defaults', () => {
expect(resolvePageImageArchiveLimits(undefined)).toEqual({
maxPerPage: 20,
maxSizeMb: 5,
minBytes: 1024,
minDimension: 32,
});
});
it('takes the values configured under tools.*', () => {
expect(
resolvePageImageArchiveLimits({
browseImageMaxPerPage: 3,
browseImageMaxSizeMb: 1,
browseImageMinBytes: 10,
browseImageMinDimension: 8,
}),
).toEqual({ maxPerPage: 3, maxSizeMb: 1, minBytes: 10, minDimension: 8 });
});
it('ignores nonsense values (negative / NaN) and keeps the default', () => {
expect(resolvePageImageArchiveLimits({ browseImageMaxPerPage: -1 }).maxPerPage).toBe(20);
expect(resolvePageImageArchiveLimits({ browseImageMinBytes: Number.NaN }).minBytes).toBe(1024);
});
});
describe('resolveImageExtension(中身の先頭バイトで決める)', () => {
it('形式ごとの先頭バイトを見分ける', async () => {
const src = await png(32, 32, 3);
expect(resolveImageExtension(src)).toBe('.png');
expect(resolveImageExtension(await sharp(src).jpeg().toBuffer())).toBe('.jpg');
expect(resolveImageExtension(await sharp(src).gif().toBuffer())).toBe('.gif');
expect(resolveImageExtension(await sharp(src).webp().toBuffer())).toBe('.webp');
expect(resolveImageExtension(await sharp(src).tiff().toBuffer())).toBe('.tiff');
expect(resolveImageExtension(await sharp(src).avif().toBuffer())).toBe('.avif');
expect(resolveImageExtension(SVG_BYTES)).toBe('.svg');
});
// レビュー P3-1: 主ブランドが 'mif1' で 'avif' が互換ブランドの側に載る
// 配り方がある。主ブランドしか見ていないと、読める AVIF を捨てていた。
it('主ブランドが mif1 の AVIF も見分ける(互換ブランドまで見る)', () => {
const ftyp = Buffer.concat([
Buffer.from([0, 0, 0, 24]), // ボックスの長さ
Buffer.from('ftyp'),
Buffer.from('mif1'), // 主ブランド
Buffer.from([0, 0, 0, 0]), // 版
Buffer.from('mif1'),
Buffer.from('avif'), // 互換ブランド
]);
expect(resolveImageExtension(ftyp)).toBe('.avif');
});
it('RIFF だけでは webp と決めない(WAV / AVI と同じ先頭 4 バイト)', () => {
const wav = Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WAVEfmt ')]);
expect(resolveImageExtension(wav)).toBeNull();
});
it('拡張子も種別も無い画像を残す(期限つき署名の URL がこの形)', async () => {
// content-type は application/octet-stream、URL に拡張子なし。
// それでも中身が画像なら残す(#1093 で利用者が名指しした形)。
expect(resolveImageExtension(await png(64, 64, 11))).toBe('.png');
});
it('中身が画像でなければ残さない(HTML のエラーページ)', () => {
expect(resolveImageExtension(Buffer.from('<!DOCTYPE html><html><body>404</body></html>'))).toBeNull();
});
it('HTML の中に <svg> があるだけのページを SVG として書かない', () => {
expect(resolveImageExtension(Buffer.from('<html><body><svg width="10"></svg></body></html>'))).toBeNull();
});
});
describe('isBelowDimensionFloor', () => {
it('drops what is measured below the floor', () => {
expect(isBelowDimensionFloor({ url: 'u', width: 1, height: 1 }, 32)).toBe(true);
expect(isBelowDimensionFloor({ url: 'u', width: 800, height: 2 }, 32)).toBe(true);
});
it('keeps what is measured at or above the floor', () => {
expect(isBelowDimensionFloor({ url: 'u', width: 32, height: 32 }, 32)).toBe(false);
});
it('keeps images whose size could not be measured (飾りは入る前提)', () => {
expect(isBelowDimensionFloor({ url: 'u' }, 32)).toBe(false);
expect(isBelowDimensionFloor({ url: 'u', width: 0, height: 0 }, 32)).toBe(false);
});
});
describe('dedupeCandidates', () => {
it('collapses the same URL and keeps the largest measured size', () => {
const out = dedupeCandidates([
{ url: 'https://e.com/a.png', width: 10, height: 10 },
{ url: 'https://e.com/a.png', width: 100, height: 80 },
]);
expect(out).toEqual([{ url: 'https://e.com/a.png', width: 100, height: 80 }]);
});
it('drops schemes we cannot fetch from the page (blob:)', () => {
expect(dedupeCandidates([{ url: 'blob:https://e.com/1234' }])).toHaveLength(0);
});
});
describe('decodeDataUrl', () => {
it('decodes base64 payloads without any fetch', () => {
const got = decodeDataUrl('data:image/png;base64,aGVsbG8=');
expect(got?.data.toString('utf-8')).toBe('hello');
expect(got?.contentType).toBe('image/png');
});
});
describe('archivePageImages', () => {
let workspacePath: string;
beforeEach(() => {
workspacePath = makeWorkspace();
});
it('stores image bytes unchanged (バイト列のまま壊れない)', async () => {
const data = await bigPng(64, 64, 3);
const url = 'https://example.com/photo.png';
const result = await archivePageImages({
workspacePath,
pageUrl: 'https://example.com/article',
pageFile: 'article.md',
candidates: [{ url, width: 64, height: 64 }],
fetchImage: fetcherFor({ [url]: { data, contentType: PNG } }),
limits: LIMITS,
});
expect(result.saved).toBe(1);
const entry = readSourceIndex(workspacePath).find((e) => e.kind === 'image')!;
const stored = fs.readFileSync(path.join(workspacePath, 'source', entry.file));
expect(stored.equals(data)).toBe(true);
// 文字列として書いていたら UTF-8 置換文字で壊れて長さが変わる
expect(stored.length).toBe(data.length);
});
it('索引の画像行が、載っていたページを指している', async () => {
const data = await bigPng(64, 64, 5);
const url = 'https://example.com/figure.png';
await archivePageImages({
workspacePath,
pageUrl: 'https://example.com/article',
pageFile: 'example.com-article.md',
candidates: [{ url, width: 640, height: 480 }],
fetchImage: fetcherFor({ [url]: { data, contentType: PNG } }),
limits: LIMITS,
});
const entry = readSourceIndex(workspacePath).find((e) => e.kind === 'image')!;
expect(entry.page_url).toBe('https://example.com/article');
expect(entry.page_file).toBe('example.com-article.md');
expect(entry.url).toBe(url);
expect(entry.width).toBe(640);
expect(entry.height).toBe(480);
expect(entry.sha256).toMatch(/^[0-9a-f]{64}$/);
expect(entry.file.startsWith('images/')).toBe(true);
});
it('同じ画像が複数ページに載っていても実ファイルは 1 枚(索引の行は両方に残る)', async () => {
const data = await bigPng(64, 64, 9);
const common = { data, contentType: PNG };
for (const page of ['https://example.com/a', 'https://example.com/b']) {
await archivePageImages({
workspacePath,
pageUrl: page,
pageFile: `${page.slice(-1)}.md`,
// ページごとに URL も変えて、指紋だけが畳む理由になるようにする
candidates: [{ url: `${page}/logo.png`, width: 64, height: 64 }],
fetchImage: fetcherFor({
'https://example.com/a/logo.png': common,
'https://example.com/b/logo.png': common,
}),
limits: LIMITS,
});
}
const images = readSourceIndex(workspacePath).filter((e) => e.kind === 'image');
expect(images).toHaveLength(2);
expect(new Set(images.map((e) => e.file)).size).toBe(1);
expect(images.map((e) => e.page_url).sort()).toEqual([
'https://example.com/a',
'https://example.com/b',
]);
const files = fs.readdirSync(path.join(workspacePath, 'source', 'images'));
expect(files).toHaveLength(1);
});
it('下限(容量)を下回る画像は残さない', async () => {
const tiny = Buffer.from('12345');
const url = 'https://example.com/pixel.png';
const result = await archivePageImages({
workspacePath,
pageUrl: 'https://example.com/p',
candidates: [{ url, width: 100, height: 100 }],
fetchImage: fetcherFor({ [url]: { data: tiny, contentType: PNG } }),
limits: { ...LIMITS, minBytes: 1024 },
});
expect(result.saved).toBe(0);
expect(result.skipped).toBe(1);
expect(readSourceIndex(workspacePath).filter((e) => e.kind === 'image')).toHaveLength(0);
});
it('下限(寸法)を下回る画像は取りに行きすらしない', async () => {
let fetched = 0;
const result = await archivePageImages({
workspacePath,
pageUrl: 'https://example.com/p',
candidates: [{ url: 'https://example.com/spacer.gif', width: 1, height: 1 }],
fetchImage: async () => {
fetched += 1;
return null;
},
limits: { ...LIMITS, minDimension: 32 },
});
expect(fetched).toBe(0);
expect(result.skipped).toBe(1);
});
it('上限(1 枚あたりの容量)を超える画像は残さない', async () => {
const data = await bigPng(200, 200, 11);
const url = 'https://example.com/huge.png';
const result = await archivePageImages({
workspacePath,
pageUrl: 'https://example.com/p',
candidates: [{ url, width: 200, height: 200 }],
fetchImage: fetcherFor({ [url]: { data, contentType: PNG } }),
// data は数十 KB あるので 0.001MB(≈1KB) の上限に必ず引っかかる
limits: { ...LIMITS, maxSizeMb: 0.001, minBytes: 1 },
});
expect(result.saved).toBe(0);
expect(result.skipped).toBe(1);
});
it('上限(1 ページあたりの枚数)で打ち切る', async () => {
const map: Record<string, { data: Buffer; contentType: string }> = {};
const candidates = [];
for (let i = 0; i < 6; i++) {
const url = `https://example.com/img-${i}.png`;
map[url] = { data: await bigPng(64, 64, i + 20), contentType: PNG };
candidates.push({ url, width: 64, height: 64 });
}
const result = await archivePageImages({
workspacePath,
pageUrl: 'https://example.com/gallery',
candidates,
fetchImage: fetcherFor(map),
limits: { ...LIMITS, maxPerPage: 2 },
});
expect(result.saved).toBe(2);
expect(result.truncated).toBe(true);
expect(readSourceIndex(workspacePath).filter((e) => e.kind === 'image')).toHaveLength(2);
expect(formatArchiveNote(result)).toContain('打ち切り');
});
// レビュー P3-3: 打ち切りの印を「列に残りがあるか」だけで立てていたので、
// ひとまとまり(同時に取りに行く 4 件)の途中で上限に達した分が抜けていた。
// 候補が 4 件以下だと列に残りが出ないので、この形でないと再現しない。
it('最後のひとまとまりの途中で上限に達しても打ち切りの印が立つ', async () => {
const map: Record<string, { data: Buffer; contentType: string }> = {};
const candidates = [];
for (let i = 0; i < 4; i++) {
const url = `https://example.com/last-${i}.png`;
map[url] = { data: await bigPng(64, 64, i + 40), contentType: PNG };
candidates.push({ url, width: 64, height: 64 });
}
const result = await archivePageImages({
workspacePath,
pageUrl: 'https://example.com/gallery',
candidates,
fetchImage: fetcherFor(map),
limits: { ...LIMITS, maxPerPage: 2 },
});
expect(result.saved).toBe(2);
// 取りに行ったのに捨てた 2 件はどの数にも入らない。上限で切られたことは
// 打ち切りの印でしか伝わらない。
expect(result.stopReason).toBe('max_per_page');
expect(result.truncated).toBe(true);
expect(formatArchiveNote(result)).toContain('枚数の上限で打ち切り');
});
it('取れなかった画像はページの処理を止めない', async () => {
const data = await bigPng(64, 64, 31);
const ok = 'https://example.com/ok.png';
const result = await archivePageImages({
workspacePath,
pageUrl: 'https://example.com/p',
candidates: [
{ url: 'https://example.com/403.png', width: 64, height: 64 },
{ url: ok, width: 64, height: 64 },
],
fetchImage: async (url) => {
if (url === ok) return { data, contentType: PNG };
throw new Error('403 Forbidden');
},
limits: LIMITS,
});
expect(result.saved).toBe(1);
expect(result.failed).toBe(1); // 取得できなかったのであって、上限・下限ではない
});
it('画像と判別できない中身は残さない(HTML のエラーページ等)', async () => {
const url = 'https://example.com/notfound';
const result = await archivePageImages({
workspacePath,
pageUrl: 'https://example.com/p',
candidates: [{ url, width: 64, height: 64 }],
fetchImage: fetcherFor({
[url]: { data: Buffer.alloc(5000, 65), contentType: 'text/html' },
}),
limits: LIMITS,
});
expect(result.saved).toBe(0);
// 「上限・下限で対象外」とは別に数える(落ちた理由が違う)
expect(result.unknownFormat).toBe(1);
expect(result.skipped).toBe(0);
expect(formatArchiveNote(result)).toContain('画像として読めない中身');
});
it('種別が image でも中身が HTML なら残さない(content-type は当てにしない)', async () => {
const url = 'https://example.com/broken.png';
const result = await archivePageImages({
workspacePath,
pageUrl: 'https://example.com/p',
candidates: [{ url, width: 64, height: 64 }],
fetchImage: fetcherFor({
[url]: { data: Buffer.from(`<!DOCTYPE html><html>${'e'.repeat(2000)}</html>`), contentType: PNG },
}),
limits: LIMITS,
});
expect(result.saved).toBe(0);
expect(result.unknownFormat).toBe(1);
});
it('拡張子も種別も無い画像を残す(期限つき署名の URL)', async () => {
const url = 'https://files.example.com/uploads/9f3a2b?sig=abc&exp=1700000000';
const data = await bigPng(64, 64, 77);
const result = await archivePageImages({
workspacePath,
pageUrl: 'https://example.com/article',
candidates: [{ url, width: 640, height: 480 }],
fetchImage: fetcherFor({ [url]: { data, contentType: 'application/octet-stream' } }),
limits: LIMITS,
});
expect(result.saved).toBe(1);
const entry = readSourceIndex(workspacePath).find((e) => e.kind === 'image')!;
expect(entry.file.endsWith('.png')).toBe(true);
expect(fs.readFileSync(path.join(workspacePath, 'source', entry.file)).equals(data)).toBe(true);
});
it('取りに行く回数に上限がある(残した枚数だけでは止まらない)', async () => {
let attempts = 0;
const candidates = [];
for (let i = 0; i < 300; i++) {
// 実寸は取れている(寸法の下限では落ちない)が、取得は全部失敗する
candidates.push({ url: `https://example.com/dead-${i}.png`, width: 200, height: 200 });
}
const result = await archivePageImages({
workspacePath,
pageUrl: 'https://example.com/broken-cdn',
candidates,
fetchImage: async () => {
attempts += 1;
return null;
},
limits: { ...LIMITS, maxPerPage: 2 },
});
expect(attempts).toBe(6); // maxPerPage 2 × 3
expect(result.attempts).toBe(6);
expect(result.saved).toBe(0);
expect(result.truncated).toBe(true);
expect(result.stopReason).toBe('max_attempts');
expect(formatArchiveNote(result)).toContain('試行回数の上限で打ち切り');
});
it('本文の索引行と画像の索引行が同じ index.jsonl に並ぶ', async () => {
const pageUrl = 'https://example.com/article';
const pageFile = saveSourceFile(workspacePath, {
url: pageUrl,
body: 'ページ本文',
tool: 'BrowseWeb',
ext: '.md',
})!;
const data = await bigPng(64, 64, 41);
const url = 'https://example.com/figure.png';
await archivePageImages({
workspacePath,
pageUrl,
pageFile,
candidates: [{ url, width: 64, height: 64 }],
fetchImage: fetcherFor({ [url]: { data, contentType: PNG } }),
limits: LIMITS,
});
const entries = readSourceIndex(workspacePath);
expect(entries).toHaveLength(2);
expect(entries[0]!.kind).toBeUndefined(); // 本文の行(既存の形のまま)
expect(entries[1]!.kind).toBe('image');
expect(entries[1]!.page_file).toBe(entries[0]!.file);
});
// 配線: 溜めた画像がエージェントの手元でそのまま読めなければ意味がない。
it('保存した画像を既存の ReadImage がそのまま読める', async () => {
const data = await png(64, 64, 7);
const url = 'https://example.com/figure.png';
await archivePageImages({
workspacePath,
pageUrl: 'https://example.com/article',
candidates: [{ url, width: 64, height: 64 }],
fetchImage: fetcherFor({ [url]: { data, contentType: PNG } }),
limits: { ...LIMITS, minBytes: 1 },
});
const entry = readSourceIndex(workspacePath).find((e) => e.kind === 'image')!;
const relPath = path.posix.join('source', entry.file);
const ctx = { workspacePath, editAllowed: false, vlmEnabled: true } as ToolContext;
const result = await executeImageTool('ReadImage', { file_path: relPath }, ctx);
expect(result?.isError).toBe(false);
const dataUrl = result?.images?.[0]?.dataUrl ?? '';
expect(dataUrl.startsWith('data:image/png;base64,')).toBe(true);
expect(Buffer.from(dataUrl.split(',')[1]!, 'base64').equals(data)).toBe(true);
});
// 利用者が #1093 で名指ししたのは「アイコンとか SVG とか」。
// 溜めておいて読み手に渡せないのでは意味がない。
it('溜めた SVG を既存の ReadImage が読める(PNG に起こして渡る)', async () => {
const url = 'https://example.com/diagram.svg';
await archivePageImages({
workspacePath,
pageUrl: 'https://example.com/article',
candidates: [{ url, width: 64, height: 64 }],
fetchImage: fetcherFor({ [url]: { data: SVG_BYTES, contentType: 'image/svg+xml' } }),
limits: LIMITS,
});
const entry = readSourceIndex(workspacePath).find((e) => e.kind === 'image')!;
expect(entry.file.endsWith('.svg')).toBe(true);
// 溜めた中身は SVG のまま(読ませるときだけ起こす)
expect(fs.readFileSync(path.join(workspacePath, 'source', entry.file)).equals(SVG_BYTES)).toBe(true);
const ctx = { workspacePath, editAllowed: false, vlmEnabled: true } as ToolContext;
const result = await executeImageTool(
'ReadImage',
{ file_path: path.posix.join('source', entry.file) },
ctx,
);
expect(result?.isError).toBe(false);
const dataUrl = result?.images?.[0]?.dataUrl ?? '';
expect(dataUrl.startsWith('data:image/png;base64,')).toBe(true);
const meta = await sharp(Buffer.from(dataUrl.split(',')[1]!, 'base64')).metadata();
expect(meta.format).toBe('png');
expect(meta.width).toBe(64);
});
});
+373
View File
@@ -0,0 +1,373 @@
/**
* ページに載っていた画像の魚拓(issue #1097 / 由来 #1093)
*
* ブラウザが開いているページから画像を集めて `source/images/` に溜め、
* 索引 `source/index.jsonl` に「どのページに載っていた画像か」を残す。
*
* 注意: これはヘルパーモジュールであり、ツールではない(TOOL_DEFS を持たない)。
* source-library.ts と同じ位置づけ。
*
* ここには Playwright を持ち込まない。取得の口(fetchImage)は呼び出し側が
* 差し込む。ブラウザ無しでこの処理を検査できるようにするため。
*
* **意味で選り分けない。**「これは飾りだから捨てる」という判定は書かない。
* 歯止めは意味を見ないものだけ——中身の指紋で畳む・寸法と容量の下限・
* 1 ページあたりの枚数の上限・1 枚あたりの容量の上限。飾りは入る前提。
*/
import { logger } from '../../logger.js';
import { sniffImageExtension } from './binary-detect.js';
import { saveSourceImage, type SourceTool } from './source-library.js';
/** ページから拾った画像 1 件(実際にページが読み込んだ URL と実寸) */
export interface PageImageCandidate {
url: string;
/** ページ上での実寸(取れなかったときは 0 / undefined) */
width?: number;
height?: number;
}
/** 取得結果。取れなかったときは null を返す */
export interface PageImageFetchResult {
data: Buffer;
contentType?: string;
}
/** 開いているページの文脈で 1 枚取りに行く口 */
export type PageImageFetcher = (url: string) => Promise<PageImageFetchResult | null>;
/** 歯止め(どれも意味を見ない) */
export interface PageImageArchiveLimits {
/** 1 ページあたりに残す枚数の上限 */
maxPerPage: number;
/** 1 枚あたりの容量上限(MB) */
maxSizeMb: number;
/** これ未満のバイト数は落とす */
minBytes: number;
/** 縦横のどちらかがこれ未満なら落とす(実寸が取れたときだけ効く) */
minDimension: number;
}
/**
* 既定値。
* - 20 枚 / 5MB: 写真の多いページ 1 枚で数十メガ持っていかれるのを止める上限。
* - 1024 バイト / 32 ピクセル: 計測用の 1 ピクセル画像や区切り線だけが落ちる下限。
* アイコン(多くは 32 ピクセル以上)は落とさない——指紋で 1 枚に畳まれるので
* 積み上がらない。
*/
export const DEFAULT_PAGE_IMAGE_ARCHIVE_LIMITS: PageImageArchiveLimits = {
maxPerPage: 20,
maxSizeMb: 5,
minBytes: 1024,
minDimension: 32,
};
/** config.yaml の tools 配下(Office 系の上限と同じ作法)から歯止めを解決する */
export function resolvePageImageArchiveLimits(toolsConfig?: {
browseImageMaxPerPage?: number;
browseImageMaxSizeMb?: number;
browseImageMinBytes?: number;
browseImageMinDimension?: number;
}): PageImageArchiveLimits {
const pick = (value: number | undefined, fallback: number): number =>
typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback;
return {
maxPerPage: pick(toolsConfig?.browseImageMaxPerPage, DEFAULT_PAGE_IMAGE_ARCHIVE_LIMITS.maxPerPage),
maxSizeMb: pick(toolsConfig?.browseImageMaxSizeMb, DEFAULT_PAGE_IMAGE_ARCHIVE_LIMITS.maxSizeMb),
minBytes: pick(toolsConfig?.browseImageMinBytes, DEFAULT_PAGE_IMAGE_ARCHIVE_LIMITS.minBytes),
minDimension: pick(toolsConfig?.browseImageMinDimension, DEFAULT_PAGE_IMAGE_ARCHIVE_LIMITS.minDimension),
};
}
export interface PageImageArchiveResult {
/** 実ファイルとして新しく書いた枚数 */
saved: number;
/** 中身が同じで既存ファイルに畳んだ枚数(索引の行は足す) */
folded: number;
/** 既に同じページの同じ画像として記録済みだった枚数 */
duplicate: number;
/** 歯止め(寸法・容量)で残さなかった枚数 */
skipped: number;
/** 取れたが、どの画像形式にも当たらなかった枚数(HTML のエラーページ等) */
unknownFormat: number;
/** 取りに行けなかった・中身が空だった・書けなかった枚数 */
failed: number;
/** 実際に取りに行った回数(残した枚数とは別に上限を持つ) */
attempts: number;
/** 候補を残したまま打ち切ったか */
truncated: boolean;
/** 打ち切った理由(打ち切っていなければ undefined) */
stopReason?: 'max_per_page' | 'max_attempts' | 'aborted';
}
/**
* 中身の先頭バイトから拡張子を決める。画像と判別できなければ null。
*
* content-type と URL の拡張子は見ない。**どちらも当てにならない**からで、
* オブジェクトストレージの署名つき URL は拡張子を持たず `application/octet-stream`
* で配られることが多い(#1093 で利用者が名指しした形)。逆に content-type が
* `image/png` でも中身が HTML のエラーページということもある。先頭バイトなら
* 意味を見ずに形式だけで決まる。
*/
export function resolveImageExtension(data: Buffer): string | null {
return sniffImageExtension(data.subarray(0, IMAGE_SNIFF_HEAD_BYTES));
}
/** 形式の判別に使う先頭バイト数(SVG の XML 宣言・コメントを跨ぐのに足りる長さ) */
const IMAGE_SNIFF_HEAD_BYTES = 4096;
/** data: URL をその場で復号する(取りに行く必要がない) */
export function decodeDataUrl(url: string): PageImageFetchResult | null {
const m = /^data:([^;,]*)(;base64)?,(.*)$/is.exec(url);
if (!m) return null;
const contentType = m[1] || undefined;
const isBase64 = Boolean(m[2]);
try {
const data = isBase64
? Buffer.from(m[3] ?? '', 'base64')
: Buffer.from(decodeURIComponent(m[3] ?? ''), 'utf-8');
return { data, ...(contentType ? { contentType } : {}) };
} catch {
return null;
}
}
/**
* 同じ URL を 1 件に寄せる。実寸は大きいほうを採る(同じ画像が縮小表示と
* 原寸で 2 回出てくるページがあるため)。
*/
export function dedupeCandidates(candidates: PageImageCandidate[]): PageImageCandidate[] {
const byUrl = new Map<string, PageImageCandidate>();
for (const c of candidates) {
const url = (c.url ?? '').trim();
if (!url) continue;
if (!/^(https?:|data:)/i.test(url)) continue; // blob: 等は取りに行けない
const prev = byUrl.get(url);
if (!prev) {
byUrl.set(url, { url, ...(c.width ? { width: c.width } : {}), ...(c.height ? { height: c.height } : {}) });
continue;
}
byUrl.set(url, {
url,
width: Math.max(prev.width ?? 0, c.width ?? 0) || undefined,
height: Math.max(prev.height ?? 0, c.height ?? 0) || undefined,
} as PageImageCandidate);
}
return [...byUrl.values()];
}
/**
* 実寸の下限で落とすか判定する。
* **実寸が取れたときだけ効く。**取れていない(0 / undefined)ものは残す側に倒す
* ——飾りは入る前提なので、分からないものを捨てない。
*/
export function isBelowDimensionFloor(candidate: PageImageCandidate, minDimension: number): boolean {
const w = candidate.width ?? 0;
const h = candidate.height ?? 0;
if (w <= 0 || h <= 0) return false;
return Math.min(w, h) < minDimension;
}
/**
* 「このページの収集はもう試した」というプロセス内の記憶(レビュー P2-1)。
*
* 再訪の見送りは索引に画像の行があるかで判断しているので、**1 枚も残らなかった
* ページ**——配信元が落ちている・全部が容量の下限を下回る・候補が全部フォント
* ——では素通りし、開き直すたびに取得の枠を丸ごと使い直す。索引に痕跡が残らない
* 以上、記憶はプロセス内で持つしかない。初回の挙動は変えない。
*/
const attemptedPages = new Set<string>();
/** 記憶の上限。長く動くプロセスで際限なく積み上がらないようにする */
const ATTEMPT_MEMORY_LIMIT = 2000;
function attemptKey(workspacePath: string, pageUrl: string): string {
return `${workspacePath}\u0000${pageUrl}`;
}
/** このプロセスでこのページの収集を試したか */
export function hasPageArchiveAttempt(workspacePath: string, pageUrl: string): boolean {
return attemptedPages.has(attemptKey(workspacePath, pageUrl));
}
/** このページの収集を試したことを記憶する */
export function markPageArchiveAttempted(workspacePath: string, pageUrl: string): void {
if (attemptedPages.size >= ATTEMPT_MEMORY_LIMIT) {
// 入った順に消える(Set は挿入順を保つ)。古い記憶が落ちても、
// もう一度取りに行くだけで壊れるものは無い。
const oldest = attemptedPages.values().next();
if (!oldest.done) attemptedPages.delete(oldest.value);
}
attemptedPages.add(attemptKey(workspacePath, pageUrl));
}
/** 検査用。プロセス内の記憶を空にする */
export function resetPageArchiveAttempts(): void {
attemptedPages.clear();
}
const FETCH_CONCURRENCY = 4;
/**
* 取りに行く回数の上限=残す枚数の上限 × これ。
*
* 残した枚数だけを数えていると、1 枚も残らないページ(配信元が落ちている・
* 下限を下回る画像ばかり)で候補の全件に取りに行ってしまう。1 枚 15 秒待つので、
* 候補が数百あるページを開いただけでブラウザ操作が何分も返らなくなる。
* 落とした分も数に入れて、取りに行く回数そのものに天井を置く。
*/
const ATTEMPT_BUDGET_MULTIPLIER = 3;
/**
* ページに載っていた画像を溜める。
*
* fetchImage は「開いているページの文脈で取りに行く口」。ここでは取得の
* 方法を知らないので、別のツールで取り直す経路が紛れ込まない。
*/
export async function archivePageImages(params: {
workspacePath: string;
pageUrl: string;
/** 索引にある、このページの本文の行(あれば参照を張る) */
pageFile?: string;
candidates: PageImageCandidate[];
fetchImage: PageImageFetcher;
limits: PageImageArchiveLimits;
tool?: SourceTool;
/** true を返したら以降を打ち切る(ジョブのキャンセル・実行時間の上限) */
shouldStop?: () => boolean;
}): Promise<PageImageArchiveResult> {
const { workspacePath, pageUrl, pageFile, limits } = params;
const tool: SourceTool = params.tool ?? 'BrowseWeb';
const result: PageImageArchiveResult = {
saved: 0,
folded: 0,
duplicate: 0,
skipped: 0,
unknownFormat: 0,
failed: 0,
attempts: 0,
truncated: false,
};
if (!workspacePath) return result;
const maxBytes = Math.round(limits.maxSizeMb * 1024 * 1024);
const queue = dedupeCandidates(params.candidates).filter((c) => {
if (isBelowDimensionFloor(c, limits.minDimension)) {
result.skipped += 1;
return false;
}
return true;
});
const maxAttempts = Math.max(1, Math.ceil(limits.maxPerPage * ATTEMPT_BUDGET_MULTIPLIER));
let cursor = 0;
// 取りに行った分ではなく **中身まで見終えた分** を数える。ひとまとまりの
// 途中で上限に達したときは cursor が列の末尾まで進んでいるので、cursor で
// 判定すると「最後のひとまとまりで切った」場合だけ打ち切りの印が立たない。
let processed = 0;
const stopHere = (reason: NonNullable<PageImageArchiveResult['stopReason']>): void => {
result.stopReason = reason;
if (processed < queue.length) result.truncated = true;
};
while (cursor < queue.length) {
if (params.shouldStop?.()) {
stopHere('aborted');
break;
}
if (result.saved + result.folded >= limits.maxPerPage) {
stopHere('max_per_page');
break;
}
if (result.attempts >= maxAttempts) {
stopHere('max_attempts');
break;
}
const room = Math.min(FETCH_CONCURRENCY, maxAttempts - result.attempts);
const batch = queue.slice(cursor, cursor + room);
cursor += batch.length;
result.attempts += batch.length;
const fetched = await Promise.all(
batch.map(async (candidate) => {
try {
return { candidate, fetched: await params.fetchImage(candidate.url) };
} catch (e) {
logger.debug(`[page-archive] fetch failed url=${candidate.url} error=${(e as Error).message}`);
return { candidate, fetched: null };
}
}),
);
for (const { candidate, fetched: got } of fetched) {
if (result.saved + result.folded >= limits.maxPerPage) {
stopHere('max_per_page');
break;
}
processed += 1;
if (!got || got.data.length === 0) {
result.failed += 1;
continue;
}
if (got.data.length < limits.minBytes || got.data.length > maxBytes) {
result.skipped += 1;
continue;
}
const ext = resolveImageExtension(got.data);
if (!ext) {
result.unknownFormat += 1;
continue;
}
try {
const stored = saveSourceImage(workspacePath, {
url: candidate.url,
data: got.data,
ext,
tool,
pageUrl,
...(pageFile ? { pageFile } : {}),
...(candidate.width ? { width: candidate.width } : {}),
...(candidate.height ? { height: candidate.height } : {}),
});
if (!stored) {
result.duplicate += 1;
} else if (stored.folded) {
result.folded += 1;
} else {
result.saved += 1;
}
} catch (e) {
logger.warn(`[page-archive] failed to store image: ${(e as Error).message}`);
result.failed += 1;
}
}
}
return result;
}
/**
* BrowseWeb の戻り値に足す 1 行(枚数と索引の場所)。何も溜まらなければ空文字。
*
* 落ちた理由は混ぜない。「上限・下限で対象外」と「画像として読めない中身」と
* 「取得できなかった」は別のことなので、別の言葉で出す。
*/
export function formatArchiveNote(result: PageImageArchiveResult): string {
const kept = result.saved + result.folded;
const dropped = result.skipped + result.unknownFormat + result.failed;
if (kept === 0 && dropped === 0) return '';
const parts = [`[archive] source/images/ に ${kept} 枚(索引: source/index.jsonl)`];
if (result.folded > 0) parts.push(`うち ${result.folded} 枚は既存と同じ画像`);
if (result.skipped > 0) parts.push(`${result.skipped} 枚は上限・下限で対象外`);
if (result.unknownFormat > 0) parts.push(`${result.unknownFormat} 枚は画像として読めない中身`);
if (result.failed > 0) parts.push(`${result.failed} 枚は取得できず`);
if (result.truncated) {
parts.push(
result.stopReason === 'max_attempts'
? '取得の試行回数の上限で打ち切り'
: result.stopReason === 'aborted'
? '中断で打ち切り'
: '枚数の上限で打ち切り',
);
}
return parts.join(' / ');
}
+13
View File
@@ -86,6 +86,19 @@ movements:
expect(existsSync(join(customDir, 'test-fresh-piece.yaml'))).toBe(true);
});
it('rejects COMPLETE in rules[].next and shows how to write a terminal movement (#1116)', async () => {
const yaml = VALID_NEW_PIECE_YAML
.replace('test-fresh-piece', 'test-terminal-rule')
.replace(/next: .*/, 'next: COMPLETE');
const result = await executeTool('CreatePiece', { yaml_content: yaml }, ctx());
expect(result?.isError).toBe(true);
expect(result?.output).toMatch(/rules\[\]\.next cannot be "COMPLETE"/);
// 行き先の案内まで含めること。
expect(result?.output).toContain('default_next: COMPLETE');
expect(result?.output).toContain('rules: []');
expect(existsSync(join(customDir, 'test-terminal-rule.yaml'))).toBe(false);
});
it('tolerates (ignores) a legacy shared_tools field on a created piece', async () => {
// PR B: shared_tools is no longer part of the schema — a piece that carries
// it (even a malformed one) is accepted; the field is simply ignored.
+4 -2
View File
@@ -2,7 +2,7 @@ import { resolve, join } from 'path';
import { mkdirSync, readdirSync, readFileSync, writeFileSync, existsSync } from 'fs';
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
import { ToolDef } from '../../llm/openai-compat.js';
import type { ToolContext, ToolResult } from './core.js';
import type { ToolContext, ToolResult } from './tool-context.js';
const BUILTIN_PIECES_DIR = resolve(process.cwd(), 'pieces');
const VALID_NAME = /^[a-z0-9-]+$/;
@@ -74,7 +74,9 @@ function validatePiece(piece: any): string | null {
for (const r of m.rules) {
if (!validRuleNexts.has(r.next)) {
if (r.next === 'COMPLETE' || r.next === 'ABORT' || r.next === 'ASK') {
return `movement "${m.name}": rules[].next cannot be "${r.next}" (use the \`complete\` tool for terminal moves)`;
// 断り文に YAML での正しい書き方を添える (#1116)。pieces-api.ts /
// piece-runner.ts の validatePieceDef と同じ案内。
return `movement "${m.name}": rules[].next cannot be "${r.next}" (use the \`complete\` tool for terminal moves). In YAML, write the terminal movement as:\n default_next: COMPLETE\n rules: []`;
}
return `movement "${m.name}": rule next "${r.next}" is invalid`;
}
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { summarizePresentation } from './presentation-summary.js';
describe('summarizePresentation', () => {
it('summarizes web search without returning result content', () => {
expect(summarizePresentation({
type: 'web_search_results',
data: { query: 'React animation', results: [{ title: 'secret' }] },
})).toEqual({ kind: 'web_search_results', label: '「React animation」のWeb検索結果', count: 1 });
});
it('truncates a long query', () => {
const summary = summarizePresentation({ type: 'amazon_products', data: { query: 'あ'.repeat(50), products: [] } });
expect(summary?.label).toContain('あ'.repeat(39) + '…');
expect(summary?.count).toBe(0);
});
it('summarizes a browser page without exposing its text', () => {
expect(summarizePresentation({
type: 'browser_page',
title: 'fallback',
data: { title: 'Example', text: 'private body' },
})).toEqual({ kind: 'browser_page', label: 'Exampleのブラウザ調査' });
});
it('summarizes grounding sources with a count and the first cited title', () => {
expect(summarizePresentation({
type: 'grounding_sources',
data: {
cited: [{ id: 'S1', kind: 'web', title: 'Example Source', snippets: ['s'], citable: true }],
referenced: [{ id: 'S2', kind: 'file', path: 'notes.md', snippets: [], citable: false }],
invalidRefs: [],
collectorFailed: false,
},
})).toEqual({ kind: 'grounding_sources', label: '📚 根拠資料「Example Source」', count: 2 });
});
it('falls back to the first cited path when there is no title, and handles no citations', () => {
expect(summarizePresentation({
type: 'grounding_sources',
data: { cited: [{ id: 'S1', kind: 'file', path: 'report.md', snippets: [], citable: true }], referenced: [], invalidRefs: [], collectorFailed: false },
})).toEqual({ kind: 'grounding_sources', label: '📚 根拠資料「report.md」', count: 1 });
expect(summarizePresentation({
type: 'grounding_sources',
data: { cited: [], referenced: [], invalidRefs: [], collectorFailed: false },
})).toEqual({ kind: 'grounding_sources', label: '📚 根拠資料', count: 0 });
});
});
+48
View File
@@ -0,0 +1,48 @@
import type { BlockType } from './structured-blocks.js';
export interface PresentationSummary {
kind: BlockType;
label: string;
count?: number;
}
const truncateLabel = (value: unknown): string => {
const text = typeof value === 'string' ? value.trim() : '';
const chars = Array.from(text);
return chars.length <= 40 ? text : chars.slice(0, 39).join('') + '…';
};
export const summarizePresentation = (value: unknown): PresentationSummary | null => {
if (!value || typeof value !== 'object') return null;
const block = value as { type?: unknown; title?: unknown; data?: unknown };
if (typeof block.type !== 'string' || !block.data || typeof block.data !== 'object') return null;
const data = block.data as Record<string, unknown>;
const query = truncateLabel(data.query);
const definitions: Partial<Record<BlockType, { suffix: string; items: string }>> = {
web_search_results: { suffix: 'Web検索結果', items: 'results' },
amazon_products: { suffix: 'Amazon検索結果', items: 'products' },
map_places: { suffix: '場所検索結果', items: 'places' },
x_posts: { suffix: 'Xの投稿', items: 'posts' },
youtube_videos: { suffix: 'YouTube検索結果', items: 'videos' },
};
const definition = definitions[block.type as BlockType];
if (definition) {
const items = data[definition.items];
const count = Array.isArray(items) ? items.length : undefined;
const subject = query ? '「' + query + '」の' : '';
return { kind: block.type as BlockType, label: subject + definition.suffix, ...(count === undefined ? {} : { count }) };
}
if (block.type === 'browser_page') {
const title = truncateLabel(data.title) || truncateLabel(block.title) || 'Webページ';
return { kind: 'browser_page', label: title + 'のブラウザ調査' };
}
if (block.type === 'grounding_sources') {
const cited = Array.isArray(data.cited) ? data.cited : [];
const referenced = Array.isArray(data.referenced) ? data.referenced : [];
const first = cited[0] as { title?: unknown; path?: unknown } | undefined;
const detail = truncateLabel(first?.title) || truncateLabel(first?.path);
const label = '📚 根拠資料' + (detail ? '「' + detail + '」' : '');
return { kind: 'grounding_sources', label, count: cited.length + referenced.length };
}
return null;
};

Some files were not shown because too many files have changed in this diff Show More