sync: update from private repo (edc775f2)
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync
2026-07-06 01:04:12 +00:00
parent 747377bef9
commit b1292e34b2
322 changed files with 28001 additions and 4686 deletions
+7
View File
@@ -370,6 +370,13 @@ export async function executeTool(
{ type: 'screenshot', value: `app-test-${app}-final.png` },
];
// TestWorkspaceApp keeps single-image screenshots (design scope: do not adopt
// BrowseWeb's default viewport segmentation here). Force full-page single
// capture for any screenshot action unless a step explicitly opts in.
for (const a of builtActions) {
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'],
+1 -1
View File
@@ -53,7 +53,7 @@ function matchMagic(head: Buffer): string | null {
return null;
}
function controlCharRatio(head: Buffer): number {
export function controlCharRatio(head: Buffer): number {
if (head.length === 0) return 0;
let ctrl = 0;
for (const byte of head) {
+1 -1
View File
@@ -50,7 +50,7 @@ const BRAINSTORM_DEF: ToolDef = {
items: {
type: 'object',
properties: {
name: { type: 'string', description: '解法の短い名前 (例: "ReadExcel 直接", "CSV エクスポート経由")' },
name: { type: 'string', description: '解法の短い名前 (例: "Read で直接", "CSV エクスポート経由")' },
description: { type: 'string', description: '1-2 文で具体的な手順' },
reliability: { type: 'string', enum: ['high', 'medium', 'low'], description: '確実性 (副作用無し / 後戻り可能 = high)' },
speed: { type: 'string', enum: ['fast', 'medium', 'slow'], description: '所要時間の概算' },
@@ -0,0 +1,161 @@
import { describe, it, expect } from 'vitest';
import { writeFileSync, mkdtempSync, existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { chromium } from 'playwright';
import { runPageActions, executeTool, setSessionManager } from './browser.js';
// Real-browser screenshot behavior. Requires a launchable Chromium.
// CONTAINER=1 disables the Chromium sandbox (bwrap absent in this environment).
const CONTAINER = process.env['CONTAINER'] === '1';
/** Parse width/height (px) from a PNG file's IHDR chunk. */
function pngSize(path: string): { width: number; height: number } {
const buf = readFileSync(path);
// 8-byte signature, then IHDR: len(4) + "IHDR"(4) + width(4 BE) + height(4 BE)
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
}
function writeTallPage(dir: string, heightPx: number): string {
const file = join(dir, 'tall.html');
writeFileSync(
file,
`<html><body style="margin:0"><div style="height:${heightPx}px;` +
`background:linear-gradient(#f00,#00f)"></div></body></html>`,
);
return file;
}
describe.skipIf(!CONTAINER)('BrowseWeb segmented screenshots (requires CONTAINER=1)', () => {
it('splits a tall page into multiple viewport-sized images by default', async () => {
const dir = mkdtempSync(join(tmpdir(), 'seg-'));
const file = writeTallPage(dir, 6000);
const ctx = { workspacePath: dir, toolsConfig: {} } as any;
const r = await runPageActions(
`file://${file}`,
[{ type: 'screenshot', value: 'shot.png' }],
ctx,
);
// Default is segmented → more than one image for a 6000px page.
expect(r.screenshots.length).toBeGreaterThanOrEqual(2);
// Zero-padded, sequential, suffixed names under output/.
expect(r.screenshots[0]).toBe('output/shot-001.png');
expect(r.screenshots[1]).toBe('output/shot-002.png');
const sizes = r.screenshots.map((rel) => {
const p = join(dir, rel);
expect(existsSync(p)).toBe(true);
return pngSize(p);
});
const h0 = sizes[0].height;
expect(h0).toBeGreaterThan(0);
// Every tile shares the viewport width.
for (const s of sizes) expect(s.width).toBe(sizes[0].width);
// The leading tiles are full viewport-height slices; the trailing tile may be
// shorter (remaining content only, no blank padding). None exceed a full tile.
expect(sizes[1].height).toBe(h0);
for (const s of sizes) {
expect(s.height).toBeGreaterThan(0);
expect(s.height).toBeLessThanOrEqual(h0);
}
// Combined coverage spans well beyond a single screen (tall page captured).
const totalCaptured = sizes.reduce((sum, s) => sum + s.height, 0);
expect(totalCaptured).toBeGreaterThan(2 * h0);
expect(r.consoleErrors).toEqual([]);
}, 60000);
it('keeps a short page as a single file with the original name', async () => {
const dir = mkdtempSync(join(tmpdir(), 'seg-short-'));
const file = writeTallPage(dir, 200);
const ctx = { workspacePath: dir, toolsConfig: {} } as any;
const r = await runPageActions(
`file://${file}`,
[{ type: 'screenshot', value: 'shot.png' }],
ctx,
);
expect(r.screenshots).toEqual(['output/shot.png']);
expect(existsSync(join(dir, 'output/shot.png'))).toBe(true);
}, 60000);
it('caps the number of segments at maxSegments', async () => {
const dir = mkdtempSync(join(tmpdir(), 'seg-cap-'));
const file = writeTallPage(dir, 100000);
const ctx = { workspacePath: dir, toolsConfig: {} } as any;
const r = await runPageActions(
`file://${file}`,
[{ type: 'screenshot', value: 'shot.png', maxSegments: 2 }],
ctx,
);
expect(r.screenshots).toHaveLength(2);
expect(r.screenshots).toEqual(['output/shot-001.png', 'output/shot-002.png']);
}, 60000);
it('capping to a single segment still yields a bounded viewport tile, not a full-page image', async () => {
const dir = mkdtempSync(join(tmpdir(), 'seg-cap1-'));
const file = writeTallPage(dir, 6000);
const ctx = { workspacePath: dir, toolsConfig: {} } as any;
const r = await runPageActions(
`file://${file}`,
[{ type: 'screenshot', value: 'shot.png', maxSegments: 1 }],
ctx,
);
// maxSegments:1 must NOT fall back to an unbounded full-page capture.
expect(r.screenshots).toEqual(['output/shot-001.png']);
const { height } = pngSize(join(dir, 'output/shot-001.png'));
expect(height).toBeGreaterThan(0);
expect(height).toBeLessThan(3000); // one viewport tile, not the whole 6000px page
}, 60000);
it('BrowseWithSession screenshot segments a tall page, consistent with BrowseWeb', async () => {
const dir = mkdtempSync(join(tmpdir(), 'seg-session-'));
const file = writeTallPage(dir, 6000);
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://${file}`, action: 'screenshot' },
{ workspacePath: dir, toolsConfig: {} } as never,
);
expect(r.isError).toBeFalsy();
const names = [...r.output.matchAll(/output\/(screenshot-[^\s,]+\.png)/g)].map((m) => m[1]);
// A 6000px page must yield multiple viewport-sized segments (not one tall image).
expect(names.length).toBeGreaterThanOrEqual(2);
expect(names[0]).toMatch(/-001\.png$/);
for (const name of names) expect(existsSync(join(dir, 'output', name))).toBe(true);
} finally {
setSessionManager(null);
await browser.close();
}
}, 60000);
it('produces one tall full-page image when segments is false', async () => {
const dir = mkdtempSync(join(tmpdir(), 'seg-off-'));
const file = writeTallPage(dir, 6000);
const ctx = { workspacePath: dir, toolsConfig: {} } as any;
const r = await runPageActions(
`file://${file}`,
[{ type: 'screenshot', value: 'shot.png', segments: false }],
ctx,
);
expect(r.screenshots).toEqual(['output/shot.png']);
const { height } = pngSize(join(dir, 'output/shot.png'));
// Full-page capture of a 6000px page is much taller than one viewport.
expect(height).toBeGreaterThan(3000);
}, 60000);
});
@@ -0,0 +1,89 @@
import { describe, it, expect } from 'vitest';
import {
planScreenshotSegments,
segmentFilename,
ABSOLUTE_MAX_SEGMENTS,
} from './browser.js';
// Pure segmentation math — no browser required, runs in plain node.
describe('planScreenshotSegments', () => {
it('returns a single offset when the page fits within one viewport', () => {
expect(planScreenshotSegments(500, 800, 10)).toEqual([0]);
});
it('treats a page exactly one viewport tall as a single segment', () => {
expect(planScreenshotSegments(800, 800, 10)).toEqual([0]);
});
it('splits into two segments when the page is slightly taller than the viewport', () => {
expect(planScreenshotSegments(900, 800, 10)).toEqual([0, 800]);
});
it('produces one offset per viewport-sized slice for a tall page', () => {
expect(planScreenshotSegments(2500, 1000, 10)).toEqual([0, 1000, 2000]);
});
it('does not add a trailing empty segment when height is an exact multiple', () => {
expect(planScreenshotSegments(2000, 1000, 10)).toEqual([0, 1000]);
});
it('caps the number of segments at maxSegments', () => {
const offsets = planScreenshotSegments(100_000, 1000, 10);
expect(offsets).toHaveLength(10);
expect(offsets[0]).toBe(0);
expect(offsets[9]).toBe(9000);
});
it('defensively returns a single segment for a non-positive viewport height', () => {
expect(planScreenshotSegments(2000, 0, 10)).toEqual([0]);
});
it('clamps maxSegments to at least one segment', () => {
expect(planScreenshotSegments(2500, 1000, 0)).toEqual([0]);
});
it('never exceeds ABSOLUTE_MAX_SEGMENTS even with a huge maxSegments', () => {
const offsets = planScreenshotSegments(1_000_000, 100, 1_000_000);
expect(offsets).toHaveLength(ABSOLUTE_MAX_SEGMENTS);
});
it('never exceeds ABSOLUTE_MAX_SEGMENTS even with a huge page and default cap', () => {
// A pathological scrollHeight must not blow past the absolute ceiling.
const offsets = planScreenshotSegments(10_000_000, 100, 999);
expect(offsets.length).toBeLessThanOrEqual(ABSOLUTE_MAX_SEGMENTS);
});
it('falls back to the default cap for a non-finite maxSegments', () => {
expect(planScreenshotSegments(2500, 1000, Number.NaN)).toEqual([0, 1000, 2000]);
expect(planScreenshotSegments(2500, 1000, Infinity)).toEqual([0, 1000, 2000]);
});
});
describe('segmentFilename', () => {
it('inserts a zero-padded 1-based index before the extension', () => {
expect(segmentFilename('report.png', 1)).toBe('report-001.png');
expect(segmentFilename('report.png', 2)).toBe('report-002.png');
});
it('pads to three digits and preserves the original extension', () => {
expect(segmentFilename('shot.jpeg', 12)).toBe('shot-012.jpeg');
});
it('defaults to a .png extension when the filename has none', () => {
expect(segmentFilename('report', 1)).toBe('report-001.png');
});
it('splits on the last dot for multi-dot filenames', () => {
expect(segmentFilename('a.b.png', 1)).toBe('a.b-001.png');
});
it('does not truncate indices beyond three digits', () => {
expect(segmentFilename('x.png', 1000)).toBe('x-1000.png');
});
it('only treats a dot in the basename as the extension separator', () => {
// A dot in a directory segment must not be mistaken for the extension.
expect(segmentFilename('sub.dir/report', 1)).toBe('sub.dir/report-001.png');
expect(segmentFilename('v1.2/page.png', 1)).toBe('v1.2/page-001.png');
});
});
+204 -23
View File
@@ -166,6 +166,156 @@ export interface BrowseWebAction {
depth?: number;
/** dumpHtml: 戻り値プレビュー長 (ファイルにはフル保存)。デフォルト 5000 */
maxChars?: number;
/**
* screenshot: 縦長ページを 1 画面ぶん (ビューポート高さ) ごとに区切って
* 複数枚 (name-001.png, name-002.png, ...) に分割保存する。
* BrowseWeb では未指定=既定で分割。segments:false でフルページ 1 枚に戻す。
*/
segments?: boolean;
/** screenshot: 分割枚数の上限 (無限スクロール暴走防止)。デフォルト 10 */
maxSegments?: number;
}
/** screenshot 分割の既定上限。無限スクロールページでの暴走を防ぐ。 */
export const DEFAULT_MAX_SEGMENTS = 10;
/**
* screenshot 分割の絶対上限。ユーザー(LLM)指定の maxSegments が
* どれだけ大きくても、または巨大な scrollHeight を返すページでも、
* 撮影枚数がこの値を超えないようにする最終防波堤。
*/
export const ABSOLUTE_MAX_SEGMENTS = 50;
/**
* 縦長ページを 1 画面ぶん (ビューポート高さ) ごとに区切るための Y オフセット
* 配列を返す純関数。返り値の要素数がスクリーンショット枚数になる。
*
* - ページがビューポートに収まる場合は [0] (単一セグメント)
* - それ以外は [0, vh, 2vh, ...] を ceil(total/vh) 個、maxSegments で頭打ち
* - viewportHeight <= 0 は防御的に単一セグメント扱い
* - maxSegments は非有限(NaN/Infinity)なら既定値、いずれにせよ
* ABSOLUTE_MAX_SEGMENTS を超えない (資源枯渇防止)
*/
export function planScreenshotSegments(
totalHeight: number,
viewportHeight: number,
maxSegments: number,
): number[] {
if (!(viewportHeight > 0)) return [0];
const needed = Math.max(1, Math.ceil(totalHeight / viewportHeight));
const requested = Number.isFinite(maxSegments)
? Math.floor(maxSegments)
: DEFAULT_MAX_SEGMENTS;
const cap = Math.min(Math.max(1, requested), ABSOLUTE_MAX_SEGMENTS);
const count = Math.min(needed, cap);
const offsets: number[] = [];
for (let i = 0; i < count; i++) offsets.push(i * viewportHeight);
return offsets;
}
/**
* 分割スクリーンショットの連番ファイル名を生成する。index は 1 始まり。
* 例: segmentFilename('report.png', 2) => 'report-002.png'
* 拡張子が無い場合は .png を補う。
*/
export function segmentFilename(filename: string, index: number): string {
// basename (最後の '/' 以降) の中のドットだけを拡張子区切りとみなす。
// ディレクトリ名に含まれるドットを拡張子と誤認しないため。
const slash = filename.lastIndexOf('/');
const dot = filename.lastIndexOf('.');
const extAt = dot > slash && dot > 0 ? dot : -1;
const base = extAt >= 0 ? filename.slice(0, extAt) : filename;
const ext = extAt >= 0 ? filename.slice(extAt) : '.png';
const num = String(index).padStart(3, '0');
return `${base}-${num}${ext}`;
}
/**
* スクリーンショットを保存する共通ヘルパー。
*
* - `opts.segments === false`: フルページ 1 枚 (旧挙動)。`output/<filename>` に保存。
* - 既定 (segments 未指定 or true): 縦長ページを 1 画面ぶんずつ分割保存。
* - 1 画面に収まる場合は連番を付けず `output/<filename>` 1 枚のまま。
* - 2 画面以上なら `output/<name>-001.<ext>`, `-002` … と連番保存。
* - `maxSegments` (既定 DEFAULT_MAX_SEGMENTS) で枚数を頭打ちし、
* 打ち切った場合は note で通知する (無音打ち切りにしない)。
*
* 分割は `fullPage: true` + `clip` でページを縦に切り出す方式。スクロールしない
* ので (1) position:fixed/sticky なヘッダーが各セグメント先頭に重複して本文を
* 隠すことがなく、(2) 撮影後にページのスクロール位置を変えないため後続アクション
* (ref/selector クリック等) にも影響しない。
*
* 戻り値の `saved` は workspace 相対パス (例: "output/shot-001.png") の配列。
* パス検証 (resolveOutputPathWithin) は失敗時に throw するので呼び出し側で握る。
*/
async function captureScreenshots(
page: import('playwright').Page,
workspacePath: string,
filename: string,
opts: { segments?: boolean; maxSegments?: number; abortSignal?: AbortSignal },
): Promise<{ saved: string[]; note?: string }> {
const { mkdirSync } = await import('fs');
const saveAt = (name: string): string => {
const p = resolveOutputPathWithin(workspacePath, path.join('output', name), ['output']);
mkdirSync(path.dirname(p), { recursive: true });
return p;
};
// 明示オプトアウト → フルページ 1 枚 (旧挙動)
if (opts.segments === false) {
await page.screenshot({ path: saveAt(filename), fullPage: true });
return { saved: [`output/${filename}`] };
}
const { total, vh, vw } = await page.evaluate(() => ({
total: Math.max(
document.documentElement.scrollHeight,
document.body ? document.body.scrollHeight : 0,
),
vh: window.innerHeight,
vw: window.innerWidth,
}));
const cap = opts.maxSegments ?? DEFAULT_MAX_SEGMENTS;
const offsets = planScreenshotSegments(total, vh, cap);
const needed = vh > 0 ? Math.max(1, Math.ceil(total / vh)) : 1;
// ページが 1 画面に収まる (or 計測不能) → 連番を付けず単一ファイル。フルページ撮影で
// 短ページのスクショを従来 (fullPage) 挙動と厳密一致させる。
// ※ needed で判定する。offsets.length===1 でも「maxSegments で 1 枚に潰れた」場合は
// フルページに落とさず、下のループでビューポート 1 枚に clip して打ち切りを通知する。
if (needed <= 1) {
await page.screenshot({ path: saveAt(filename), fullPage: true });
return { saved: [`output/${filename}`] };
}
const saved: string[] = [];
let interrupted = false;
for (let i = 0; i < offsets.length; i++) {
// ジョブの cancel/deadline をセグメント撮影の合間でも尊重する。Playwright の
// screenshot は AbortSignal を取らないため、最大 ABSOLUTE_MAX_SEGMENTS 枚の
// ループが上限時間を無視しないよう、外側のアクションループと同じ体裁で確認する。
if (opts.abortSignal?.aborted) {
interrupted = true;
break;
}
const y = offsets[i];
// 最後のセグメントは残り高さぶんだけ切り出す (下端の余白を作らない)。
const height = Math.min(vh, total - y);
const segName = segmentFilename(filename, i + 1);
await page.screenshot({
path: saveAt(segName),
fullPage: true,
clip: { x: 0, y, width: vw, height },
});
saved.push(`output/${segName}`);
}
let note: string | undefined;
if (interrupted) {
note = `[screenshot] interrupted — captured ${saved.length} of planned ${offsets.length} segments`;
} else if (needed > offsets.length) {
note = `[screenshot] page too tall — captured first ${offsets.length} of ~${needed} segments (raise maxSegments to capture more)`;
}
return { saved, note };
}
const BROWSEWEB_DEF: ToolDef = {
@@ -174,7 +324,7 @@ const BROWSEWEB_DEF: ToolDef = {
name: 'BrowseWeb',
description:
'ヘッドレスブラウザでWebページを操作する。同一ジョブ内ではセッション(Cookie・ログイン状態等)が維持される。\n' +
'基本モード: url を指定してページのテキストを取得。screenshot でスクリーンショットも保存可能。\n' +
'基本モード: url を指定してページのテキストを取得。screenshot でスクリーンショットも保存可能(縦長ページは既定で 1 画面ぶんずつ複数枚に分割保存)。\n' +
'アクションモード: actions 配列で goto/click/fill/screenshot/getText/wait/dumpHtml を連続実行。\n' +
'出力には操作可能要素が {e1 button "..."} 形式の ref 注釈付きで埋め込まれ、click/fill で ref を直接指定できる。<div role="button"> 等の ARIA ベース要素・addEventListener で click handler が後付けされた要素・open shadow DOM・iframe (cross-origin 含む) の中身も検出される。iframe 内の ref は {f1.e3 ...} のように frame ID で prefix される。状態属性 (expanded/checked/selected/pressed/disabled/haspopup) は注釈末尾に列挙。\n' +
'ref で当たらない or 構造を直接見たいときは dumpHtml アクションで該当要素の outerHTML を取得できる(脱出口)。\n' +
@@ -195,7 +345,15 @@ const BROWSEWEB_DEF: ToolDef = {
},
screenshot: {
type: 'string',
description: 'スクリーンショットを保存するファイル名(例: "page.png")。output/ に保存される',
description: 'スクリーンショットを保存するファイル名(例: "page.png")。output/ に保存される。縦長ページは既定で 1 画面ぶんずつ page-001.png, page-002.png … と分割保存(1 画面に収まる場合は連番なしで 1 枚)',
},
screenshotSegments: {
type: 'boolean',
description: 'false にすると分割せずフルページ 1 枚で保存(既定: 分割あり)',
},
screenshotMaxSegments: {
type: 'number',
description: '分割スクリーンショットの最大枚数(既定: 10。無限スクロール暴走防止)',
},
actions: {
type: 'array',
@@ -210,9 +368,11 @@ const BROWSEWEB_DEF: ToolDef = {
},
selector: { type: 'string', description: 'CSS セレクタ (click, fill, getText, dumpHtml) — ref があれば不要' },
ref: { type: 'string', description: '前回スナップショットで割り振られた要素 ref (e1, e2, ...) — click/fill/dumpHtml で selector の代わりに使える' },
value: { type: 'string', description: '入力値 (fill) またはファイル名 (screenshot)' },
value: { type: 'string', description: '入力値 (fill) またはファイル名 (screenshot)。screenshot は縦長ページを既定で 1 画面ぶんずつ分割保存' },
url: { type: 'string', description: 'URL (goto)。ローカルファイルを開く場合は workspace ルートからの相対パス (例: "output/viewer.html")' },
ms: { type: 'number', description: '待機ミリ秒 (wait)' },
segments: { type: 'boolean', description: 'screenshot: false でフルページ 1 枚に(既定: 1 画面ぶんずつ分割)' },
maxSegments: { type: 'number', description: 'screenshot: 分割の最大枚数 (デフォルト 10)' },
depth: { type: 'number', description: 'dumpHtml: 包含する子孫の階層数 (デフォルト 3)' },
maxChars: { type: 'number', description: 'dumpHtml: 戻り値プレビュー長 (デフォルト 5000)。フル HTML は logs/browse/ に保存' },
},
@@ -437,7 +597,7 @@ const _pageRefs = new WeakMap<Page, Map<string, RefTarget>>();
// --- Download capture (Playwright `page.on('download')`) ---
//
// クリック等で発生したダウンロードを workspace の output/ に保存して、agent から
// Read / ReadPdf / 等で続けて操作できるようにする。各 BrowseWeb / BrowseWithSession
// Read 等で続けて操作できるようにする。各 BrowseWeb / BrowseWithSession
// の戻り値末尾に `[download] saved output/foo.csv (12345 bytes)` を追加する。
export interface BrowserDownloadEntry {
@@ -1310,6 +1470,8 @@ async function executeSimple(
const waitFor = input['waitFor'] as string | undefined;
const extractSelector = input['extractSelector'] as string | undefined;
const screenshotFile = input['screenshot'] as string | undefined;
const screenshotSegments = input['screenshotSegments'] as boolean | undefined;
const screenshotMaxSegments = input['screenshotMaxSegments'] as number | undefined;
const pageTimeout = typeof input['timeout'] === 'number'
? input['timeout']
: (ctx.toolsConfig?.browserPageTimeout ?? 60000);
@@ -1355,14 +1517,16 @@ async function executeSimple(
content = await saveBrowseText(ctx, page.url(), fullText, 'snapshot');
}
// スクリーンショット
// スクリーンショット (既定で 1 画面ぶんずつ分割保存)
if (screenshotFile) {
try {
const savePath = resolveOutputPathWithin(ctx.workspacePath, path.join('output', screenshotFile), ['output']);
const { mkdirSync } = await import('fs');
mkdirSync(path.dirname(savePath), { recursive: true });
await page.screenshot({ path: savePath, fullPage: true });
content += `\n\n[Screenshot saved to output/${screenshotFile}]`;
const { saved, note } = await captureScreenshots(page, ctx.workspacePath, screenshotFile, {
segments: screenshotSegments,
maxSegments: screenshotMaxSegments,
abortSignal: ctx.abortSignal,
});
content += `\n\n[Screenshot saved to ${saved.join(', ')}]`;
if (note) content += `\n${note}`;
} catch (e) {
content += `\n\n[Screenshot error: ${(e as Error).message}]`;
}
@@ -1487,6 +1651,14 @@ async function runPageActionsInternal(
const results: string[] = [];
for (const action of actions) {
// Honor the job cancel/deadline between actions. Playwright doesn't take an
// AbortSignal, so a runaway action sequence could otherwise ignore the
// deadline until each per-action timeout elapses. Each action still has its
// own timeout; this bounds the total to (current action + remaining = none).
if (ctx.abortSignal?.aborted) {
results.push('[browse] キャンセルまたは実行時間の上限によりブラウザ操作を中断しました。');
break;
}
switch (action.type) {
case 'goto': {
const gotoUrl = action.url;
@@ -1584,18 +1756,21 @@ async function runPageActionsInternal(
}
case 'screenshot': {
const filename = action.value ?? 'screenshot.png';
let savePath: string;
try {
savePath = resolveOutputPathWithin(ctx.workspacePath, path.join('output', filename), ['output']);
const { saved, note } = await captureScreenshots(page, ctx.workspacePath, filename, {
segments: action.segments,
maxSegments: action.maxSegments,
abortSignal: ctx.abortSignal,
});
for (const rel of saved) {
results.push(`[screenshot] saved to ${rel}`);
screenshots.push(rel);
}
if (note) results.push(note);
} catch (e) {
results.push(`[screenshot] error: ${(e as Error).message}`);
break;
}
const { mkdirSync } = await import('fs');
mkdirSync(path.dirname(savePath), { recursive: true });
await page.screenshot({ path: savePath, fullPage: true });
results.push(`[screenshot] saved to output/${filename}`);
screenshots.push(`output/${filename}`);
tryRecord({ type: 'screenshot', value: filename, frameChain: [] });
break;
}
@@ -1754,7 +1929,7 @@ const BROWSEWITHSESSION_DEF: ToolDef = {
name: 'BrowseWithSession',
description:
'InteractiveBrowse でユーザーが手動操作した直後のセッションを使って agent が後続操作を続けるためのツール。Cookie・ログイン状態・DOM がそのまま引き継がれる。\n' +
'sessionId は InteractiveBrowse の戻り値から取得する。actions(getText/screenshot/click/fill)と selector/value で操作できる。詳細は ReadToolDoc({ name: "BrowseWithSession" }) で取得可能。',
'sessionId は InteractiveBrowse の戻り値から取得する。actions(getText/screenshot/click/fill)と selector/value で操作できる。screenshot は BrowseWeb と同じく縦長ページを既定で 1 画面ぶんずつ複数枚に分割保存する。詳細は ReadToolDoc({ name: "BrowseWithSession" }) で取得可能。',
parameters: {
type: 'object',
properties: {
@@ -1767,6 +1942,8 @@ const BROWSEWITHSESSION_DEF: ToolDef = {
},
selector: { type: 'string', description: 'CSSセレクタ(click/fill/getText で使用)' },
value: { type: 'string', description: '入力値(fill で使用)' },
segments: { type: 'boolean', description: 'screenshot: false でフルページ 1 枚に(既定: 1 画面ぶんずつ分割)' },
maxSegments: { type: 'number', description: 'screenshot: 分割の最大枚数(デフォルト 10)' },
},
required: ['sessionId', 'url'],
},
@@ -1898,12 +2075,16 @@ async function executeBrowseWithSession(
return { output: out, isError: false };
}
case 'screenshot': {
// BrowseWeb と同じく、縦長ページは既定で 1 画面ぶんずつ分割保存する
// (同じブラウザなのにツールで挙動が変わらないよう共通ヘルパーを使う)。
const filename = `screenshot-${Date.now()}.png`;
const savePath = path.join(ctx.workspacePath, 'output', filename);
const { mkdirSync } = await import('fs');
mkdirSync(path.dirname(savePath), { recursive: true });
await page.screenshot({ path: savePath, fullPage: true });
return { output: `Screenshot saved to output/${filename}`, isError: false };
const { saved, note } = await captureScreenshots(page, ctx.workspacePath, filename, {
segments: input['segments'] as boolean | undefined,
maxSegments: input['maxSegments'] as number | undefined,
abortSignal: ctx.abortSignal,
});
const out = `Screenshot saved to ${saved.join(', ')}${note ? `\n${note}` : ''}`;
return { output: out, isError: false };
}
case 'click': {
if (!selector) {
+1 -1
View File
@@ -39,7 +39,7 @@ const CREATE_CHECKLIST_DEF: ToolDef = {
type: 'function',
function: {
name: 'CreateChecklist',
description: '複数アイテム処理のためのチェックリストを作成する。workspace/logs/checklists/{name}.json に保存。「1件処理→即CheckItem」のループで使う。詳細は ReadToolDoc({ name: "CreateChecklist" }) で取得可能。',
description: '複数アイテム処理のためのチェックリストを作成する(logs/checklists/{name}.json に保存)。「1件処理→即CheckItem」のループで使う。詳細は ReadToolDoc({ name: "CreateChecklist" })。',
parameters: {
type: 'object',
properties: {
+27 -12
View File
@@ -42,19 +42,24 @@ describe('conflict-guard', () => {
expect(r.targetRelPath).toBe('a.txt');
});
it('overwriting a file changed on disk since the recorded version → competing copy', () => {
it('overwriting a file changed on disk since the recorded version → archives old file', () => {
writeFileSync(join(ws, 'a.txt'), 'v1');
recordVersion(ws, 'a.txt', conflictDir); // base = v1
writeFileSync(join(ws, 'a.txt'), 'EXTERNAL'); // someone else changed it
const r = resolveWriteTarget(ws, 'a.txt', 'v2', conflictDir);
expect(r.conflict).toBe(true);
expect(r.targetRelPath).toMatch(/^a \(競合コピー \d+\)\.txt$/);
expect(r.targetRelPath).toBe('a.txt');
expect(r.archivedRelPath).toBe('old/a_old1.txt');
expect(readFileSync(join(ws, 'old', 'a_old1.txt'), 'utf-8')).toBe('EXTERNAL');
expect(existsSync(join(ws, 'a.txt'))).toBe(false);
});
it('overwriting an existing file with NO recorded base (never read by this agent) → competing copy', () => {
it('overwriting an existing file with NO recorded base (never read by this agent) → archives old file', () => {
writeFileSync(join(ws, 'a.txt'), 'pre-existing');
const r = resolveWriteTarget(ws, 'a.txt', 'v2', conflictDir);
expect(r.conflict).toBe(true); // blind overwrite of unknown content is treated as conflict
expect(r.targetRelPath).toBe('a.txt');
expect(r.archivedRelPath).toBe('old/a_old1.txt');
});
it('the ledger is created under the given conflictDir, NOT under files/ or logs/', () => {
@@ -84,15 +89,25 @@ describe('conflict-guard', () => {
for (let i = 0; i < 25; i++) expect(led[`f${i}.txt`]).toBeTruthy();
});
it('competing-copy reservation uses O_EXCL: two conflicts of the same base get distinct names', () => {
it('old archive names do not overwrite existing old files', () => {
mkdirSync(join(ws, 'old'), { recursive: true });
writeFileSync(join(ws, 'old', 'a_old1.txt'), 'previous-old');
writeFileSync(join(ws, 'a.txt'), 'pre-existing');
const r1 = resolveWriteTarget(ws, 'a.txt', 'v2', conflictDir);
expect(r1.conflict).toBe(true);
// The first reservation must have materialised on disk (O_EXCL reserves the name).
expect(existsSync(join(ws, r1.targetRelPath))).toBe(true);
const r2 = resolveWriteTarget(ws, 'a.txt', 'v3', conflictDir);
expect(r2.conflict).toBe(true);
expect(r2.targetRelPath).not.toBe(r1.targetRelPath);
expect(existsSync(join(ws, r2.targetRelPath))).toBe(true);
const r = resolveWriteTarget(ws, 'a.txt', 'v2', conflictDir);
expect(r.conflict).toBe(true);
expect(r.targetRelPath).toBe('a.txt');
expect(r.archivedRelPath).toBe('old/a_old2.txt');
expect(readFileSync(join(ws, 'old', 'a_old1.txt'), 'utf-8')).toBe('previous-old');
expect(readFileSync(join(ws, 'old', 'a_old2.txt'), 'utf-8')).toBe('pre-existing');
});
it('archives old files into old/ beside nested targets', () => {
mkdirSync(join(ws, 'output'), { recursive: true });
writeFileSync(join(ws, 'output', 'report.md'), 'old report');
const r = resolveWriteTarget(ws, 'output/report.md', 'new report', conflictDir);
expect(r.conflict).toBe(true);
expect(r.targetRelPath).toBe('output/report.md');
expect(r.archivedRelPath).toBe('output/old/report_old1.md');
expect(readFileSync(join(ws, 'output', 'old', 'report_old1.md'), 'utf-8')).toBe('old report');
});
});
+21 -17
View File
@@ -1,6 +1,6 @@
import { createHash } from 'node:crypto';
import {
existsSync, readFileSync, writeFileSync, mkdirSync, openSync, closeSync, renameSync, statSync,
constants, copyFileSync, existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, statSync, unlinkSync,
} from 'node:fs';
import { join, dirname, extname } from 'node:path';
@@ -16,8 +16,8 @@ import { join, dirname, extname } from 'node:path';
* read-modify-write 自体は同期実行(better-sqlite3 / tools は単一スレッド同期)なので、
* 一回の呼び出し内では中断されない。temp+rename により、別エージェントの並行書込で
* 半端な JSON を観測したり last-writer-wins でキーを取りこぼす窓を除去する。
* - 競合コピー名は `openSync(path, 'wx')`(O_EXCL)で予約してから書く。`while(existsSync)`
* スキャンの TOCTOU を排除し、二者が同じ base を同時解決しても別名を得る。
* - 旧版退避先は `copyFileSync(..., COPYFILE_EXCL)` で確保してから元ファイルを消す。
* これにより、二者が同じ base を同時解決しても old/ 内の既存ファイルを上書きしない。
*
* 残存制限(spec §10.3): per-file の書込ロックは持たない。同一成果物への真の同時上書きは
* last-writer-wins のまま。台帳のアトミック性とは別レイヤーの課題。
@@ -66,34 +66,39 @@ export function recordVersion(ws: string, relPath: string, conflictDir: string):
}
export interface WriteResolution {
/** 実際に書き込むべき相対パス(競合時は競合コピー名) */
/** 実際に書き込むべき相対パス */
targetRelPath: string;
/** 競合時に退避した旧ファイルの相対パス */
archivedRelPath?: string;
conflict: boolean;
}
/**
* 競合コピー名を O_EXCL で予約する。`{stem} (競合コピー N){ext}` を N=1 から順に試し、
* 排他作成に成功した最初の名前を返す。空ファイルを予約として作るので、呼び出し側は
* その名前へ後から書き込む(盲目上書きの窓が無い)。
* 既存ファイルを同じ階層の old/ へ退避する。退避名は `{stem}_oldN{ext}`。
* COPYFILE_EXCL により、既存の退避ファイルを上書きしない。
*/
function reserveCompetingCopy(ws: string, relPath: string): string {
function archiveExistingFile(ws: string, relPath: string): string {
const ext = extname(relPath);
const stem = relPath.slice(0, relPath.length - ext.length);
const baseName = relPath.slice(relPath.lastIndexOf('/') + 1);
const stem = baseName.slice(0, baseName.length - ext.length);
const parent = dirname(relPath);
const oldDir = parent === '.' ? 'old' : join(parent, 'old');
const sourceAbs = join(ws, relPath);
// 同一プロセス内の暴走に対する上限(実用上は到達しない)。
for (let n = 1; n < 100000; n++) {
const candidate = `${stem} (競合コピー ${n})${ext}`;
const candidate = join(oldDir, `${stem}_old${n}${ext}`);
const abs = join(ws, candidate);
try {
mkdirSync(dirname(abs), { recursive: true });
const fd = openSync(abs, 'wx'); // O_EXCL: 既存なら EEXIST
closeSync(fd);
copyFileSync(sourceAbs, abs, constants.COPYFILE_EXCL);
unlinkSync(sourceAbs);
return candidate;
} catch (e) {
if ((e as NodeJS.ErrnoException).code === 'EEXIST') continue;
throw e;
}
}
throw new Error('reserveCompetingCopy: exhausted candidate names');
throw new Error('archiveExistingFile: exhausted candidate names');
}
/**
@@ -101,7 +106,7 @@ function reserveCompetingCopy(ws: string, relPath: string): string {
* - 対象が存在しない → そのまま書く(競合なし)。
* - 対象が存在し、ディスク版が台帳の記録と一致 → 普通に上書き(競合なし)。
* - 対象が存在するが台帳に記録が無い、または記録と食い違う → 競合。
* O_EXCL で予約した `{name} (競合コピー N).{ext}` を返し、元ファイルは触らない。
* 既存ファイルを同じ階層の `old/{name}_oldN.{ext}` へ退避し、元パスへ新しい内容を書く。
*/
export function resolveWriteTarget(
ws: string,
@@ -115,7 +120,6 @@ export function resolveWriteTarget(
const base = led[relPath];
const diskHash = hash(readFileSync(abs));
if (base && base === diskHash) return { targetRelPath: relPath, conflict: false };
// 競合: O_EXCL で空いている連番のコピー名を予約する(TOCTOU 排除)。
const candidate = reserveCompetingCopy(ws, relPath);
return { targetRelPath: candidate, conflict: true };
const archivedRelPath = archiveExistingFile(ws, relPath);
return { targetRelPath: relPath, archivedRelPath, conflict: true };
}
@@ -0,0 +1,117 @@
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import { tmpdir } from 'os';
import { executeCoreTools, type ToolContext, type FileProvenanceEvent } from './core.js';
import { Repository } from '../../db/repository.js';
function makeWs(): string {
const ws = fs.mkdtempSync(path.join(tmpdir(), 'maestro-core-prov-'));
fs.mkdirSync(path.join(ws, 'output'), { recursive: true });
return ws;
}
describe('core tools record file provenance (best-effort)', () => {
let ws = '';
let events: FileProvenanceEvent[] = [];
let ctx: ToolContext;
beforeEach(() => {
ws = makeWs();
events = [];
ctx = {
workspacePath: ws,
editAllowed: true,
bashSandbox: 'off',
bashUnrestricted: true,
recordFileProvenance: (ev) => events.push(ev),
} as ToolContext;
});
afterEach(() => {
if (ws) fs.rmSync(ws, { recursive: true, force: true });
ws = '';
});
it('Write new file records a create/agent_output event', async () => {
await executeCoreTools('Write', { file_path: 'output/new.txt', content: 'hi' }, ctx);
expect(events).toEqual([{ relPath: 'output/new.txt', event: 'create', sourceKind: 'agent_output' }]);
});
it('Write over an existing file records a modify event', async () => {
fs.writeFileSync(path.join(ws, 'output', 'exists.txt'), 'old');
await executeCoreTools('Write', { file_path: 'output/exists.txt', content: 'new' }, ctx);
expect(events).toEqual([{ relPath: 'output/exists.txt', event: 'modify', sourceKind: 'agent_output' }]);
});
it('Edit records a modify event (preserving creator upstream)', async () => {
fs.writeFileSync(path.join(ws, 'output', 'edit.txt'), 'hello world');
await executeCoreTools('Edit', { file_path: 'output/edit.txt', old_string: 'world', new_string: 'there' }, ctx);
expect(events).toEqual([{ relPath: 'output/edit.txt', event: 'modify', sourceKind: 'agent_edit' }]);
});
it('never breaks tool execution when the recorder throws', async () => {
ctx.recordFileProvenance = vi.fn(() => { throw new Error('boom'); });
const res = await executeCoreTools('Write', { file_path: 'output/ok.txt', content: 'x' }, ctx);
expect(res!.isError).toBe(false);
expect(fs.readFileSync(path.join(ws, 'output', 'ok.txt'), 'utf8')).toBe('x');
});
it('Bash detects a newly created output/ file as bash_generated', async () => {
await executeCoreTools('Bash', { command: 'echo hi > output/made.txt' }, ctx);
const made = events.filter((e) => e.relPath === 'output/made.txt');
expect(made).toHaveLength(1);
expect(made[0]).toMatchObject({ event: 'create', sourceKind: 'bash_generated' });
});
it('Bash detects a changed output/ file as modify', async () => {
fs.writeFileSync(path.join(ws, 'output', 'pre.txt'), 'v1');
// ensure a distinct mtime
const past = new Date(Date.now() - 5000);
fs.utimesSync(path.join(ws, 'output', 'pre.txt'), past, past);
await executeCoreTools('Bash', { command: 'echo v2 >> output/pre.txt' }, ctx);
const changed = events.filter((e) => e.relPath === 'output/pre.txt');
expect(changed).toHaveLength(1);
expect(changed[0]!.event).toBe('modify');
});
it('does nothing when no recorder is bound', async () => {
const noRec = { workspacePath: ws, editAllowed: true } as ToolContext;
const res = await executeCoreTools('Write', { file_path: 'output/x.txt', content: 'y' }, noRec);
expect(res!.isError).toBe(false);
});
// Review D1/#4: a Bash command that does not touch an existing output/ file
// must emit NO event for it (guards against false-modify spam).
it('Bash emits no event for an untouched existing output/ file', async () => {
fs.writeFileSync(path.join(ws, 'output', 'untouched.txt'), 'stable');
const past = new Date(Date.now() - 5000);
fs.utimesSync(path.join(ws, 'output', 'untouched.txt'), past, past);
await executeCoreTools('Bash', { command: 'echo hi > output/other.txt' }, ctx);
expect(events.filter((e) => e.relPath === 'output/untouched.txt')).toHaveLength(0);
});
});
// Review #2: the split unit tests never wire core's emitted event to a REAL
// Repository, so a field-mapping bug in the worker glue would go unnoticed.
// Exercise the round trip: executeCoreTools emits → recorder forwards to
// repo.recordProvenance → getProvenance returns the persisted record.
describe('core recording glue → real Repository round trip', () => {
it('a Write recorded through repo.recordProvenance is queryable with the right creator/kind', async () => {
const ws = makeWs();
const repo = new Repository(path.join(ws, 'prov.db'));
const ctx = {
workspacePath: ws, editAllowed: true,
recordFileProvenance: (ev: FileProvenanceEvent) =>
repo.recordProvenance({ workspacePath: ws, taskId: 7, jobId: 'j7', piece: 'chat', movement: 'respond', ...ev }),
} as ToolContext;
await executeCoreTools('Write', { file_path: 'output/z.txt', content: 'hi' }, ctx);
const rec = repo.getProvenance(ws, 'output/z.txt');
expect(rec).not.toBeNull();
expect(rec!.createdByTaskId).toBe(7);
expect(rec!.sourceKind).toBe('agent_output');
expect(rec!.createdByPiece).toBe('chat');
fs.rmSync(ws, { recursive: true, force: true });
});
});
+399 -25
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, type ToolContext } from './core.js';
import { executeCoreTools, resolveAndGuard, resolveOutputPathWithin, checkBlockedInstallPatterns, checkAllowedCommand, checkBashPathScope, DEFAULT_ALLOWED_COMMANDS, getToolDefs, documentRangeHint, type ToolContext } from './core.js';
import { looksLikeBinaryBytes } from './binary-detect.js';
import iconv from 'iconv-lite';
function makeWorkspace(): string {
return fs.mkdtempSync(path.join(tmpdir(), 'maestro-core-'));
@@ -79,7 +80,11 @@ describe('core tools', () => {
expect(fromStart?.output.startsWith('ABC')).toBe(true);
});
it('blocks Read on PDF files and suggests ReadPdf', async () => {
// Read is now the single entry point: it dispatches Office/PDF by extension to
// the extraction handlers instead of telling the user to call a separate tool.
// These stubs are corrupt magic-byte files, so the handler reports a parse
// failure — the point is that Read *attempts extraction* (no "Use ReadX" redirect).
it('dispatches Read on a PDF to the PDF extractor (no redirect)', async () => {
workspacePath = makeWorkspace();
fs.mkdirSync(path.join(workspacePath, 'input'), { recursive: true });
fs.writeFileSync(path.join(workspacePath, 'input', 'manual.pdf'), '%PDF-1.4\n');
@@ -87,11 +92,11 @@ describe('core tools', () => {
const result = await executeCoreTools('Read', { file_path: 'input/manual.pdf' }, makeContext(workspacePath));
expect(result).not.toBeNull();
expect(result?.isError).toBe(true);
expect(result?.output).toContain('ReadPdf');
expect(result?.output).not.toMatch(/Use ReadPdf/i);
expect(result?.output).toContain('PDF');
});
it('blocks Read on xlsx and suggests ReadExcel', async () => {
it('dispatches Read on an xlsx to the Excel extractor (no redirect)', async () => {
workspacePath = makeWorkspace();
fs.mkdirSync(path.join(workspacePath, 'output'), { recursive: true });
// PK\x03\x04 = ZIP signature (xlsx is a zip)
@@ -100,30 +105,30 @@ describe('core tools', () => {
const result = await executeCoreTools('Read', { file_path: 'output/components.xlsx' }, makeContext(workspacePath));
expect(result).not.toBeNull();
expect(result?.isError).toBe(true);
expect(result?.output).toContain('ReadExcel');
expect(result?.output).not.toMatch(/Use ReadExcel/i);
expect(result?.output).toContain('Excel');
});
it('blocks Read on docx and suggests ReadDocx', async () => {
it('dispatches Read on a docx to the Word extractor (no redirect)', async () => {
workspacePath = makeWorkspace();
fs.mkdirSync(path.join(workspacePath, 'input'), { recursive: true });
fs.writeFileSync(path.join(workspacePath, 'input', 'report.docx'), Buffer.from([0x50, 0x4b, 0x03, 0x04]));
const result = await executeCoreTools('Read', { file_path: 'input/report.docx' }, makeContext(workspacePath));
expect(result?.isError).toBe(true);
expect(result?.output).toContain('ReadDocx');
expect(result?.output).not.toMatch(/Use ReadDocx/i);
expect(result?.output).toContain('Word');
});
it('blocks Read on pptx and suggests ReadPPTX', async () => {
it('dispatches Read on a pptx to the PowerPoint extractor (no redirect)', async () => {
workspacePath = makeWorkspace();
fs.mkdirSync(path.join(workspacePath, 'input'), { recursive: true });
fs.writeFileSync(path.join(workspacePath, 'input', 'deck.pptx'), Buffer.from([0x50, 0x4b, 0x03, 0x04]));
const result = await executeCoreTools('Read', { file_path: 'input/deck.pptx' }, makeContext(workspacePath));
expect(result?.isError).toBe(true);
expect(result?.output).toContain('ReadPPTX');
expect(result?.output).not.toMatch(/Use ReadPPTX/i);
expect(result?.output).toContain('PPTX');
});
it('blocks Read on opaque binary extensions like .zip', async () => {
@@ -768,7 +773,7 @@ describe('conflict detection (persistent workspace)', () => {
return { workspacePath: ws, editAllowed: true, conflictDetection: true };
}
it('(a) Write to a pre-existing file never read by the agent → competing copy, original preserved', async () => {
it('(a) Write to a pre-existing file never read by the agent → archives old file, writes new content in place', async () => {
workspacePath = makeWorkspace();
fs.mkdirSync(path.join(workspacePath, 'output'), { recursive: true });
fs.writeFileSync(path.join(workspacePath, 'output', 'a.txt'), 'pre-existing', 'utf-8');
@@ -776,16 +781,13 @@ describe('conflict detection (persistent workspace)', () => {
const r = await executeCoreTools('Write', { file_path: 'output/a.txt', content: 'agent-version' }, makeGuardedContext(workspacePath));
expect(r?.isError).toBe(false);
expect(r?.output).toContain('競合');
expect(r?.output).toContain('output/old/a_old1.txt');
// original preserved
expect(fs.readFileSync(path.join(workspacePath, 'output', 'a.txt'), 'utf-8')).toBe('pre-existing');
// a competing copy was created carrying the agent's content
const copies = fs.readdirSync(path.join(workspacePath, 'output')).filter((f) => /^a \(競合コピー \d+\)\.txt$/.test(f));
expect(copies.length).toBe(1);
expect(fs.readFileSync(path.join(workspacePath, 'output', copies[0]!), 'utf-8')).toBe('agent-version');
expect(fs.readFileSync(path.join(workspacePath, 'output', 'a.txt'), 'utf-8')).toBe('agent-version');
expect(fs.readFileSync(path.join(workspacePath, 'output', 'old', 'a_old1.txt'), 'utf-8')).toBe('pre-existing');
});
it('(b) Read then Write the same file (no external change) → overwrites in place, no competing copy', async () => {
it('(b) Read then Write the same file (no external change) → overwrites in place, no archive', async () => {
workspacePath = makeWorkspace();
fs.mkdirSync(path.join(workspacePath, 'output'), { recursive: true });
fs.writeFileSync(path.join(workspacePath, 'output', 'b.txt'), 'base', 'utf-8');
@@ -798,8 +800,7 @@ describe('conflict detection (persistent workspace)', () => {
expect(write?.output).not.toContain('競合');
expect(fs.readFileSync(path.join(workspacePath, 'output', 'b.txt'), 'utf-8')).toBe('updated');
const copies = fs.readdirSync(path.join(workspacePath, 'output')).filter((f) => /競合コピー/.test(f));
expect(copies.length).toBe(0);
expect(fs.existsSync(path.join(workspacePath, 'output', 'old'))).toBe(false);
});
it('(c) conflict detection OFF (default) → Write overwrites silently (backward compat)', async () => {
@@ -813,8 +814,7 @@ describe('conflict detection (persistent workspace)', () => {
expect(r?.output).not.toContain('競合');
expect(fs.readFileSync(path.join(workspacePath, 'output', 'c.txt'), 'utf-8')).toBe('overwritten');
const copies = fs.readdirSync(path.join(workspacePath, 'output')).filter((f) => /競合コピー/.test(f));
expect(copies.length).toBe(0);
expect(fs.existsSync(path.join(workspacePath, 'output', 'old'))).toBe(false);
});
it('(d) path confinement holds with conflict detection on: ../ and absolute outside-workspace are rejected', async () => {
@@ -965,3 +965,377 @@ describe('readonly/ enforcement (assertWritable)', () => {
expect(fs.existsSync(path.join(workspacePath, 'readonly-notes.txt'))).toBe(true);
});
});
describe('Read skills/ ENOENT hints', () => {
let workspacePath = '';
afterEach(() => {
if (workspacePath) {
fs.rmSync(workspacePath, { recursive: true, force: true });
workspacePath = '';
}
});
// Minimal SkillCatalog stub: only the two methods the hint path uses.
function catalogWith(names: string[]): ToolContext['skillCatalog'] {
return {
getUserRoot: () => '/nonexistent-user-root',
getForFolder: () => names.map((name) => ({ name, source: 'user', description: '' })),
} as unknown as ToolContext['skillCatalog'];
}
it('points at ReadSkill when a known, un-materialized skill is Read directly', async () => {
workspacePath = makeWorkspace();
const ctx: ToolContext = {
workspacePath,
editAllowed: true,
userId: 'local',
skillCatalog: catalogWith(['report-writer']),
};
const result = await executeCoreTools('Read', { file_path: 'skills/report-writer/SKILL.md' }, ctx);
expect(result?.isError).toBe(true);
expect(result?.output).toContain('ReadSkill');
expect(result?.output).toContain('report-writer');
// Must NOT be a bare ENOENT dump.
expect(result?.output).not.toContain('ENOENT');
});
it('tells the agent the skill is already materialized when only the subpath is missing', async () => {
workspacePath = makeWorkspace();
// Simulate a materialized dir-skill: skills/report-writer/ exists but the
// requested sub-file does not.
fs.mkdirSync(path.join(workspacePath, 'skills', 'report-writer'), { recursive: true });
const ctx: ToolContext = {
workspacePath,
editAllowed: true,
userId: 'local',
skillCatalog: catalogWith(['report-writer']),
};
const result = await executeCoreTools('Read', { file_path: 'skills/report-writer/references/missing.md' }, ctx);
expect(result?.isError).toBe(true);
expect(result?.output).toContain('展開済み');
// Should not misdirect toward ReadSkill (already materialized).
expect(result?.output).not.toContain('ReadSkill(');
});
it('gives a generic ListSkills/ReadSkill hint for an unknown skills/ path', async () => {
workspacePath = makeWorkspace();
const ctx: ToolContext = {
workspacePath,
editAllowed: true,
userId: 'local',
skillCatalog: catalogWith([]),
};
const result = await executeCoreTools('Read', { file_path: 'skills/does-not-exist/SKILL.md' }, ctx);
expect(result?.isError).toBe(true);
expect(result?.output).toContain('ListSkills');
});
it('leaves a non-skills ENOENT error untouched (no ReadSkill hint)', async () => {
workspacePath = makeWorkspace();
const ctx: ToolContext = {
workspacePath,
editAllowed: true,
userId: 'local',
skillCatalog: catalogWith(['report-writer']),
};
const result = await executeCoreTools('Read', { file_path: 'output/missing.txt' }, ctx);
expect(result?.isError).toBe(true);
expect(result?.output).toContain('ENOENT');
expect(result?.output).not.toContain('ReadSkill');
});
});
describe('non-UTF-8 text handling (Read / Grep / Edit)', () => {
let ws = '';
afterEach(() => {
if (ws) {
fs.rmSync(ws, { recursive: true, force: true });
ws = '';
}
});
it('Read opens a Shift_JIS .txt as readable Japanese instead of rejecting it as binary', async () => {
ws = makeWorkspace();
fs.mkdirSync(path.join(ws, 'input'), { recursive: true });
const abs = path.join(ws, 'input', 'sjis.txt');
const body = 'これはShift_JISで保存されたテキストです。\n二行目もあります。\n';
fs.writeFileSync(abs, iconv.encode(body, 'Shift_JIS'));
const result = await executeCoreTools('Read', { file_path: 'input/sjis.txt' }, makeContext(ws));
expect(result?.isError).toBe(false);
expect(result?.output).toContain('Shift_JISで保存されたテキスト');
expect(result?.output).toContain('二行目もあります');
// no U+FFFD mojibake leaked through
expect(result?.output).not.toContain('�');
});
it('Grep skips a PNG file so binary bytes never reach the output', async () => {
ws = makeWorkspace();
fs.mkdirSync(path.join(ws, 'output'), { recursive: true });
// PNG magic + an ASCII chunk name the pattern would otherwise match.
const png = Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
Buffer.from('IHDR'),
Buffer.from([0x00, 0x0a, 0xff, 0xd8, 0x00, 0x9a, 0xbc]),
Buffer.from('tEXtComment'),
Buffer.from([0x00, 0xfe, 0x0a]),
]);
fs.writeFileSync(path.join(ws, 'output', 'image.png'), png);
fs.writeFileSync(path.join(ws, 'output', 'notes.txt'), 'IHDR appears here in real text\n', 'utf-8');
const result = await executeCoreTools('Grep', { pattern: 'IHDR', path: 'output' }, makeContext(ws));
expect(result?.isError).toBe(false);
// The text file match is returned…
expect(result?.output).toContain('notes.txt');
// …but nothing from the PNG (no path, no replacement chars).
expect(result?.output).not.toContain('image.png');
expect(result?.output).not.toContain('�');
});
it('Grep still searches Shift_JIS text files by transcoding them', async () => {
ws = makeWorkspace();
fs.mkdirSync(path.join(ws, 'output'), { recursive: true });
fs.writeFileSync(path.join(ws, 'output', 'memo.txt'), iconv.encode('検索対象の日本語行\n', 'Shift_JIS'));
const result = await executeCoreTools('Grep', { pattern: '検索対象', path: 'output' }, makeContext(ws));
expect(result?.isError).toBe(false);
expect(result?.output).toContain('memo.txt');
});
it('Edit on a Shift_JIS file updates content and keeps the file Shift_JIS on disk', async () => {
ws = makeWorkspace();
fs.mkdirSync(path.join(ws, 'output'), { recursive: true });
const abs = path.join(ws, 'output', 'edit.txt');
fs.writeFileSync(abs, iconv.encode('旧タイトル:説明文\n', 'Shift_JIS'));
const result = await executeCoreTools(
'Edit',
{ file_path: 'output/edit.txt', old_string: '旧タイトル', new_string: '新タイトル' },
makeContext(ws),
);
expect(result?.isError).toBe(false);
const rawBytes = fs.readFileSync(abs);
// Content changed, and the file is still Shift_JIS (round-trips via iconv, not utf-8).
expect(iconv.decode(rawBytes, 'Shift_JIS')).toBe('新タイトル:説明文\n');
expect(rawBytes.toString('utf-8')).not.toContain('新タイトル');
});
it('Read rejects byte-range requests on a non-UTF-8 file instead of silently returning the start', async () => {
ws = makeWorkspace();
fs.mkdirSync(path.join(ws, 'input'), { recursive: true });
fs.writeFileSync(path.join(ws, 'input', 'sjis2.txt'), iconv.encode('先頭から\n途中の行\n末尾\n', 'Shift_JIS'));
const result = await executeCoreTools(
'Read',
{ file_path: 'input/sjis2.txt', byte_offset: 4, byte_length: 6 },
makeContext(ws),
);
expect(result?.isError).toBe(true);
expect(result?.output).toContain('byte_offset');
});
it('Read decodes a Shift_JIS file whose first 8KB is ASCII (whole-file detection)', async () => {
ws = makeWorkspace();
fs.mkdirSync(path.join(ws, 'input'), { recursive: true });
const header = Buffer.from('# '.padEnd(9000, 'english-header ') + '\n', 'ascii');
const tail = iconv.encode('ここから日本語の本文です。\n', 'Shift_JIS');
fs.writeFileSync(path.join(ws, 'input', 'long.csv'), Buffer.concat([header, tail]));
const result = await executeCoreTools('Read', { file_path: 'input/long.csv' }, makeContext(ws));
expect(result?.isError).toBe(false);
expect(result?.output).toContain('ここから日本語の本文です');
expect(result?.output).not.toContain('�');
});
it('Edit refuses to save when the replacement is not representable in the file encoding', async () => {
ws = makeWorkspace();
fs.mkdirSync(path.join(ws, 'output'), { recursive: true });
const abs = path.join(ws, 'output', 'sjis-edit.txt');
const before = iconv.encode('タイトル:説明\n', 'Shift_JIS');
fs.writeFileSync(abs, before);
const result = await executeCoreTools(
'Edit',
{ file_path: 'output/sjis-edit.txt', old_string: 'タイトル', new_string: 'タイトル😀' },
makeContext(ws),
);
expect(result?.isError).toBe(true);
expect(result?.output).toContain('表現できない');
// File is left byte-for-byte unchanged (no corrupt '?' write).
expect(fs.readFileSync(abs).equals(before)).toBe(true);
});
it('Grep skips a PDF whose bytes are valid UTF-8 (magic byte still wins)', async () => {
ws = makeWorkspace();
fs.mkdirSync(path.join(ws, 'output'), { recursive: true });
// A "text-y" PDF: valid UTF-8, but the %PDF magic must keep it out of results.
fs.writeFileSync(path.join(ws, 'output', 'doc.pdf'), Buffer.from('%PDF-1.4\nneedle in a pdf\n', 'ascii'));
fs.writeFileSync(path.join(ws, 'output', 'real.txt'), 'needle in real text\n', 'utf-8');
const result = await executeCoreTools('Grep', { pattern: 'needle', path: 'output' }, makeContext(ws));
expect(result?.isError).toBe(false);
expect(result?.output).toContain('real.txt');
expect(result?.output).not.toContain('doc.pdf');
});
it('Read rejects a byte-range request on an ASCII-prefixed Shift_JIS file', async () => {
ws = makeWorkspace();
fs.mkdirSync(path.join(ws, 'input'), { recursive: true });
const header = Buffer.from('# '.padEnd(9000, 'english-header ') + '\n', 'ascii');
const tail = iconv.encode('日本語の本文。\n', 'Shift_JIS');
fs.writeFileSync(path.join(ws, 'input', 'mixed.csv'), Buffer.concat([header, tail]));
const result = await executeCoreTools(
'Read',
{ file_path: 'input/mixed.csv', byte_offset: 0, byte_length: 20 },
makeContext(ws),
);
expect(result?.isError).toBe(true);
expect(result?.output).toContain('byte_offset');
});
it('Write preserves Shift_JIS encoding when overwriting even with conflict detection on', async () => {
ws = makeWorkspace();
fs.mkdirSync(path.join(ws, 'output'), { recursive: true });
const abs = path.join(ws, 'output', 'ws.txt');
fs.writeFileSync(abs, iconv.encode('元の内容\n', 'Shift_JIS'));
const ctx: ToolContext = { ...makeContext(ws), conflictDetection: true };
const result = await executeCoreTools('Write', { file_path: 'output/ws.txt', content: '書き換えた内容\n' }, ctx);
expect(result?.isError).toBe(false);
const raw = fs.readFileSync(abs);
expect(iconv.decode(raw, 'Shift_JIS')).toBe('書き換えた内容\n');
expect(raw.toString('utf-8')).not.toContain('書き換えた内容');
});
it('Write refuses to overwrite a Shift_JIS file with unrepresentable content (conflict detection on)', async () => {
ws = makeWorkspace();
fs.mkdirSync(path.join(ws, 'output'), { recursive: true });
const abs = path.join(ws, 'output', 'ws2.txt');
const before = iconv.encode('元の内容\n', 'Shift_JIS');
fs.writeFileSync(abs, before);
const ctx: ToolContext = { ...makeContext(ws), conflictDetection: true };
const result = await executeCoreTools('Write', { file_path: 'output/ws2.txt', content: '絵文字😀入り\n' }, ctx);
expect(result?.isError).toBe(true);
expect(result?.output).toContain('表現できない');
expect(fs.readFileSync(abs).equals(before)).toBe(true);
});
it('Edit on a BOM-tagged UTF-16LE file keeps the BOM so it stays readable next time', async () => {
ws = makeWorkspace();
fs.mkdirSync(path.join(ws, 'output'), { recursive: true });
const abs = path.join(ws, 'output', 'u16.txt');
const body = iconv.encode('旧タイトル 行\n', 'utf-16le');
fs.writeFileSync(abs, Buffer.concat([Buffer.from([0xff, 0xfe]), body]));
const edit = await executeCoreTools(
'Edit',
{ file_path: 'output/u16.txt', old_string: '旧タイトル', new_string: '新タイトル' },
makeContext(ws),
);
expect(edit?.isError).toBe(false);
// BOM preserved on disk…
const raw = fs.readFileSync(abs);
expect([raw[0], raw[1]]).toEqual([0xff, 0xfe]);
// …and a subsequent Read succeeds (not rejected as binary) with the new text.
const read = await executeCoreTools('Read', { file_path: 'output/u16.txt' }, makeContext(ws));
expect(read?.isError).toBe(false);
expect(read?.output).toContain('新タイトル');
});
it('Edit on a UTF-8 BOM file keeps the BOM on disk', async () => {
ws = makeWorkspace();
fs.mkdirSync(path.join(ws, 'output'), { recursive: true });
const abs = path.join(ws, 'output', 'bom-edit.txt');
fs.writeFileSync(abs, Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from('旧タイトル\n本文', 'utf-8')]));
const result = await executeCoreTools(
'Edit',
{ file_path: 'output/bom-edit.txt', old_string: '旧タイトル', new_string: '新タイトル' },
makeContext(ws),
);
expect(result?.isError).toBe(false);
const raw = fs.readFileSync(abs);
expect([raw[0], raw[1], raw[2]]).toEqual([0xef, 0xbb, 0xbf]);
expect(raw.subarray(3).toString('utf-8')).toContain('新タイトル');
});
it('does not record a conflict-ledger version when a byte-range Read fails on a non-UTF-8 file', async () => {
// Regression (codex): recordVersion ran before the non-UTF-8 byte-range error,
// marking the file "seen" without returning content, so a later Write would
// silently overwrite. With the fix the Write must still detect a conflict.
ws = makeWorkspace();
fs.mkdirSync(path.join(ws, 'output'), { recursive: true });
const abs = path.join(ws, 'output', 'led.txt');
fs.writeFileSync(abs, iconv.encode('既存の内容\n', 'Shift_JIS'));
const ctx: ToolContext = { ...makeContext(ws), conflictDetection: true };
const read = await executeCoreTools('Read', { file_path: 'output/led.txt', byte_offset: 0, byte_length: 8 }, ctx);
expect(read?.isError).toBe(true);
const write = await executeCoreTools('Write', { file_path: 'output/led.txt', content: '新しい内容\n' }, ctx);
expect(write?.isError).toBe(false);
// The agent never actually saw the content, so the write is treated as a conflict.
expect(write?.output).toContain('競合');
});
it('Read still rejects genuine binary with an unknown extension', async () => {
ws = makeWorkspace();
fs.mkdirSync(path.join(ws, 'output'), { recursive: true });
fs.writeFileSync(path.join(ws, 'output', 'blob.unknownext'), Buffer.from([0x00, 0x01, 0x02, 0x00, 0xff, 0x00, 0x13]));
const result = await executeCoreTools('Read', { file_path: 'output/blob.unknownext' }, makeContext(ws));
expect(result?.isError).toBe(true);
expect(result?.output).toContain('binary');
});
});
describe('Read: document formats vs text-only range params', () => {
let ws = '';
afterEach(() => {
if (ws) { fs.rmSync(ws, { recursive: true, force: true }); ws = ''; }
});
it('Read schema exposes page_range and marks offset/limit as text-only', () => {
const def = getToolDefs(['Read'], true)[0]!;
const props = def.function.parameters.properties as Record<string, { description?: string }>;
expect(props.page_range).toBeTruthy();
// offset/limit descriptions must say they are text-only so the agent does not
// try to paginate a PDF with them.
expect(props.offset?.description ?? '').toMatch(/テキスト/);
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('documentRangeHint steers Excel to sheet/range', () => {
const hint = documentRangeHint('.xlsx', { limit: 50 });
expect(hint).toBeTruthy();
expect(hint!).toMatch(/range/);
expect(hint!).toMatch(/sheet/);
});
it('documentRangeHint points other formats to ReadToolDoc', () => {
const hint = documentRangeHint('.docx', { byte_offset: 10 });
expect(hint).toBeTruthy();
expect(hint!).toContain('ReadToolDoc');
});
it('documentRangeHint returns null when no text-range param is used', () => {
expect(documentRangeHint('.pdf', { page_range: '5-10' })).toBeNull();
expect(documentRangeHint('.pdf', {})).toBeNull();
});
it('Read on a PDF with offset prepends the page_range steering note', 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 });
expect(result?.output).toContain('page_range');
});
it('Read on a PDF without offset does not add the steering note', 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');
});
});
+532 -60
View File
@@ -7,7 +7,9 @@ import { logger } from '../../logger.js';
import type { SearchFilterConfig } from '../../config.js';
import type { ContextManager } from '../context-manager.js';
import { executeSandboxedBash, isBwrapAvailable, buildSandboxEnv, type SandboxedBashResult } from './sandbox.js';
import { resolveSandboxOverlay } from '../python-packages.js';
import { looksLikeBinaryBytes, SNIFF_HEAD_BYTES, stripLeadingBom } from './binary-detect.js';
import { detectTextEncoding, decodeFileText, encodeFileText, UTF8_BOM_LABEL } from './text-decode.js';
import { recordVersion, resolveWriteTarget } from './conflict-guard.js';
export interface ToolsConfig {
@@ -87,6 +89,13 @@ export interface ToolContext {
* Phase 1 では値を配線するだけで、まだ誰も参照しない(no-op、後方互換)。
*/
spaceId?: string;
/**
* このジョブのスペースが持つ Python パッケージ overlay の `current` シンボリック
* リンクパス(`{root}/{spaceKey}/current`)。admin が入れた wheels を read-only
* bind + PYTHONPATH で sandbox に露出する。存在しない(未インストール)なら
* undefined。worker が spaceId + config から事前解決して渡す。
*/
pythonPackagesDir?: string;
/**
* ローカルタスク ID (string)。BrowseWeb / InteractiveBrowse が
* per-task noVNC session を使うときのキー。subtask 実行のときは親の
@@ -156,6 +165,34 @@ export interface ToolContext {
* aren't bound to a local_task leave this unset.
*/
missionBrief?: MissionBriefIO;
/**
* Per-task conversation access for SearchTaskConversation / ReadTaskConversation.
* Set by piece-runner only when bound to a local task (mirrors missionBrief);
* unset for subtasks / gitea-issue runs so the tools degrade to a friendly
* no-op instead of leaking other tasks' logs.
*/
taskConversation?: TaskConversationIO;
/**
* Workspace-wide task search for SearchWorkspaceTasks / ReadWorkspaceTaskAround.
* Bound by the worker to this run's local task + owner (scopes FTS search to the
* same space the owner can see); unset for subtask / gitea-issue runs so the tool
* degrades to a no-context message instead of leaking other users' tasks.
*/
workspaceTaskSearch?: WorkspaceTaskSearchIO;
/**
* File-provenance READ side for GetFileProvenance / ListWorkspaceFiles.
* Bound to this run's workspace_path by the worker (mirrors taskConversation);
* unset for subtask / gitea-issue runs so the tools degrade to a no-op.
*/
fileProvenance?: FileProvenanceIO;
/**
* File-provenance WRITE side: best-effort recorder invoked by Write / Edit /
* Bash when a workspace file is created or modified. Bound by the worker to
* task/job/space + workspace_path; piece + movement injected by piece-runner.
* MUST never throw into tool execution (callers wrap in try/catch). Undefined
* (unit tests / non-persistent runs) => recording is skipped.
*/
recordFileProvenance?: (ev: FileProvenanceEvent) => void;
/** Decrypted Playwright storageState for the browser session profile bound to this job. */
browserSessionState?: object;
/** Profile id for audit/expiry callbacks. */
@@ -165,11 +202,12 @@ export interface ToolContext {
/** Worker-provided callback invoked when BrowseWeb detects auth expiry. */
onAuthExpired?: (profileId: number, reason: string) => void;
/**
* Phase 4: per-movement SSH connection allowlist forwarded from piece YAML
* `allowed_ssh_connections`. UUID list, or `['*']` for "any registered
* connection". undefined = SSH tools (Phase 7) will reject with
* `no_allowed_connections_declared` before any other check (so we don't
* leak the existence of connections the piece can't use).
* SSH connection allowlist for this job — the workspace/space-scoped UUID list
* resolved once per job by the worker (`connectionRepo.listBySpace(spaceId)`)
* and applied to every movement. Per-piece `allowed_ssh_connections` was
* removed in the tool-consolidation work. undefined/empty = SSH tools reject
* (fail-closed) before any other check, so we don't leak the existence of
* connections outside this workspace.
*/
allowedSshConnections?: string[];
/**
@@ -180,7 +218,7 @@ export interface ToolContext {
pieceName?: string;
/**
* Tool-request mechanism: the current movement's name + its effective
* allowed-tool set (shared_tools ∪ allowed_tools ∪ META_TOOLS), so RequestTool
* allowed-tool set (workspace tool policy ∪ per-task grants ∪ META_TOOLS), so RequestTool
* can tell "already available" from "not allowed here", and the passive
* blocked-call capture knows which movement to attribute. Plumbed by
* piece-runner from the prepared movement.
@@ -237,8 +275,8 @@ export interface ToolContext {
* Spaces foundation (plan 3): conflict detection for persistent (shared)
* workspaces. When `true`, Read/Edit/Write record per-file content versions
* in a workspace sidecar ledger, and Write to a file that changed on disk
* since the agent last saw it (or was never read) is diverted to a
* "(競合コピー N)" copy instead of blind-overwriting. Default `undefined`
* since the agent last saw it (or was never read) archives the old file under
* old/ before writing the new content in place. Default `undefined`
* (= OFF): Read/Write/Edit behave byte-identically to legacy. Only the
* worker sets it `true`, for persistent local-task workspaces.
*/
@@ -256,6 +294,9 @@ export interface MissionBriefValue {
done: string;
open: string;
clarifications: string;
user_constraints?: string;
decisions?: string;
current_focus?: string;
}
export interface MissionBriefIO {
@@ -263,6 +304,125 @@ export interface MissionBriefIO {
update(patch: Partial<MissionBriefValue>): MissionBriefValue | null;
}
/** Minimal comment shape the conversation tools need (subset of LocalTaskComment). */
export interface TaskConversationComment {
id: number;
author: string;
kind: string;
body: string;
createdAt: string;
}
/**
* Per-task conversation access for SearchTaskConversation / ReadTaskConversation.
* Bound to a single local task (comments) + this run's transcript path so the
* tools can never reach another task's logs. Constructed by the worker layer
* (which owns the repo) and threaded through ToolContext, mirroring MissionBriefIO.
*/
export interface TaskConversationIO {
listComments(): Promise<TaskConversationComment[]>;
/** Absolute path to this run's transcript.jsonl (may not exist yet). */
transcriptPath?: string;
}
/**
* Workspace-wide task-comment search for SearchWorkspaceTasks / ReadWorkspaceTaskAround.
* Scoped by the worker layer to the current task's space + owner; the tool can only
* ever search/read within that scope. `fts5Available` lets the tool degrade gracefully
* (e.g. LIKE-based fallback messaging) when the FTS5 index table doesn't exist.
*/
export interface WorkspaceTaskSearchIO {
fts5Available: boolean;
search(
query: string,
opts: { limit?: number; kind?: string; author?: string; taskId?: number },
): Promise<
Array<{
taskId: number;
taskTitle: string;
commentId: number;
author: string;
kind: string;
createdAt: string;
text: string;
}>
>;
around(
commentId: number,
before: number,
after: number,
): Promise<{
taskId: number;
taskTitle: string;
comments: Array<{
commentId: number;
author: string;
kind: string;
createdAt: string;
body: string;
isCenter: boolean;
}>;
} | null>;
}
/** How a workspace file first came to exist (its creator's kind). */
export type FileProvenanceSourceKind =
| 'user_input'
| 'agent_output'
| 'agent_edit'
| 'bash_generated'
| 'subtask_output'
| 'imported_existing'
| 'unknown';
/** Compact provenance record for one workspace file (no file contents). */
export interface FileProvenanceRecord {
relPath: string;
sourceKind: FileProvenanceSourceKind;
createdByTaskId: number | null;
createdByJobId: string | null;
createdByPiece: string | null;
createdByMovement: string | null;
firstSeenAt: string | null;
lastModifiedByTaskId: number | null;
lastModifiedByJobId: string | null;
lastModifiedAt: string | null;
checksum: string | null;
note: string | null;
}
export interface FileProvenanceListFilters {
/** workspace-relative path prefix, e.g. 'output/'. */
pathPrefix?: string;
sourceKind?: string;
createdByTaskId?: number;
lastModifiedByTaskId?: number;
/** When false, unknown / imported_existing rows are excluded. Default true. */
includeUnknown?: boolean;
limit?: number;
}
/**
* Read side of the provenance ledger, bound by the worker to a SINGLE
* workspace_path so the agent tools (GetFileProvenance / ListWorkspaceFiles)
* can only ever see the current run's workspace — never another workspace's
* (and thus never another user's) file lineage. Unset for subtask / gitea-issue
* runs, in which case the tools degrade to a friendly no-op.
*/
export interface FileProvenanceIO {
get(relPath: string): FileProvenanceRecord | null;
list(filters?: FileProvenanceListFilters): FileProvenanceRecord[];
}
/** Write side: a best-effort recorder bound by the worker to task/job/space. */
export interface FileProvenanceEvent {
relPath: string;
event: 'create' | 'modify';
/** Creator kind — only consulted when this event first inserts the row. */
sourceKind?: FileProvenanceSourceKind;
checksum?: string;
}
export interface ToolResult {
output: string;
isError: boolean;
@@ -279,12 +439,9 @@ const BLOCKED_BINARY_EXTENSIONS = new Set([
'.bmp',
]);
const BLOCKED_DOCUMENT_EXTENSIONS = new Set([
'.pdf',
]);
// Office 系: 拡張子から専用ツールに誘導する
const OFFICE_EXTENSION_TOOL: Record<string, string> = {
// ドキュメント系: 拡張子から office.ts の抽出ハンドラへ内部ディスパッチする。
// Read が唯一の入口で、旧 ReadExcel / ReadDocx / ReadPdf / ReadPPTX は登録ツールとしては廃止。
const DOCUMENT_EXTENSION_TOOL: Record<string, string> = {
'.xlsx': 'ReadExcel',
'.xls': 'ReadExcel',
'.xlsm': 'ReadExcel',
@@ -292,8 +449,45 @@ const OFFICE_EXTENSION_TOOL: Record<string, string> = {
'.doc': 'ReadDocx',
'.pptx': 'ReadPPTX',
'.ppt': 'ReadPPTX',
'.pdf': 'ReadPdf',
};
// Outlook メール: 添付保存の副作用があるため office.ts の ReadMsg ハンドラへ委譲する。
const MSG_EXTENSIONS = new Set(['.msg']);
/**
* offset/limit/byte_offset/byte_length はテキスト読み取り専用。PDF/Office/.msg の
* 抽出ハンドラはこれらを読まないため、指定しても黙って無視され「常に先頭から」に
* なる(PDF を offset でずらして読もうとして毎回 1 ページ目に戻る典型不具合)。
* ドキュメント形式にこれらが渡されたら、無視する代わりに正しい範囲指定パラメータ
* (PDF=page_range、Excel=sheet/range 等)へ誘導する注記を返す。該当が無ければ null。
*/
export function documentRangeHint(ext: string, input: Record<string, unknown>): string | null {
const usedTextRange =
input['offset'] !== undefined ||
input['limit'] !== undefined ||
input['byte_offset'] !== undefined ||
input['byte_length'] !== undefined;
if (!usedTextRange) return null;
const perFormat: Record<string, string> = {
'.pdf':
'PDF はページを指定して読むには page_range を使ってください(例: Read({ file_path, page_range: "5-10" }))。特定の語の周辺だけ読むなら query も使えます。',
'.xlsx': 'Excel は sheet と range で範囲を指定してください(例: Read({ file_path, sheet: "Sheet1", range: "A1:D50" }))。',
'.xls': 'Excel は sheet と range で範囲を指定してください(例: Read({ file_path, sheet: "Sheet1", range: "A1:D50" }))。',
'.xlsm': 'Excel は sheet と range で範囲を指定してください(例: Read({ file_path, sheet: "Sheet1", range: "A1:D50" }))。',
};
const specific = perFormat[ext] ?? 'この形式での範囲指定方法は ReadToolDoc({ name: "Read" }) を参照してください。';
return `ℹ️ offset/limit/byte_offset はテキスト読み取り専用で、この形式(${ext})では無視されました。${specific}`;
}
/** Prepend the range-param steering note (if any) to a document read result. */
function withDocumentRangeHint(ext: string, input: Record<string, unknown>, base: ToolResult): ToolResult {
const hint = documentRangeHint(ext, input);
if (!hint) return base;
return { ...base, output: `${hint}\n\n${base.output}` };
}
// 一般的に Read で開く価値が無いバイナリ拡張子(誘導先なし)
const BLOCKED_OPAQUE_BINARY_EXTENSIONS = new Set([
'.zip', '.tar', '.gz', '.tgz', '.bz2', '.xz', '.7z', '.rar',
@@ -323,14 +517,47 @@ function readHeadBytes(filePath: string, maxBytes: number): Buffer | null {
}
}
function looksLikeBinaryByContent(filePath: string): boolean {
const head = readHeadBytes(filePath, SNIFF_HEAD_BYTES);
if (!head) return false;
return looksLikeBinaryBytes(head).binary;
/**
* Encode Write content for disk. When overwriting an existing file that is
* readable non-UTF-8 text (e.g. Shift_JIS), keep that encoding so a full
* rewrite does not silently flip the file to UTF-8. New files and UTF-8 files
* are written as UTF-8. Encoding is judged from the whole existing file (an
* ASCII prefix must not hide a Shift_JIS tail). If the new content has chars the
* original encoding cannot represent, returns an error instead of a lossy save.
*/
function encodeWriteContent(
resolved: string,
content: string,
existedBefore: boolean,
): { bytes: Buffer } | { error: string } {
if (existedBefore) {
try {
const decoded = decodeFileText(fs.readFileSync(resolved));
if (!decoded.binary && decoded.encoding !== 'utf-8') {
const bytes = encodeFileText(content, decoded.encoding);
if (bytes === null) {
return {
error:
`内容に ${decoded.encoding} で表現できない文字(絵文字など)が含まれるため、破損を避けて保存を中止しました。` +
`既存ファイルの文字コードは ${decoded.encoding} です。UTF-8 の別ファイルに保存するか、表現可能な文字を使ってください。`,
};
}
return { bytes };
}
} catch {
/* fall through to UTF-8 */
}
}
return { bytes: Buffer.from(content, 'utf-8') };
}
const BASH_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
// Read the whole file into memory to detect its encoding only up to this size.
// Beyond it we fall back to head-only detection (byte-range's main use is huge
// minified/log files, which are UTF-8/ASCII) so a 2 GB target cannot OOM us.
const FULL_DETECT_MAX_BYTES = 25 * 1024 * 1024;
// --- コンテキスト予算に基づく切り詰め ---
const CHARS_TO_TOKENS = 1.5; // 保守的(ASCII worst case: 1 char ≈ 1.5 tokens)
@@ -506,6 +733,46 @@ function normalizeWorkspaceRelativePath(workspacePath: string, targetPath: strin
return path.relative(path.resolve(workspacePath), path.resolve(targetPath)).split(path.sep).join('/');
}
/**
* Skills live OUTSIDE the file jail (`data/spaces/{id}/skills`, sibling of
* `files/`) and only appear under `{workspace}/skills/{name}/` after ReadSkill
* materializes a directory-type skill. Agents often reach for `Read("skills/…")`
* out of habit and hit a bare ENOENT with no signpost. Given the
* workspace-relative path of a failed Read, return an actionable hint pointing
* at ReadSkill/ListSkills — or null when the path is not under `skills/`.
*
* `relPath` is already resolveAndGuard-normalized (jail-confined, forward
* slashes, no `..` escape), so the first segment is a safe skill-name candidate.
*/
function skillReadEnoentHint(ctx: ToolContext, relPath: string): string | null {
const segments = relPath.split('/').filter(Boolean);
if (segments[0] !== 'skills') return null;
const skillName = segments[1];
// `Read("skills")` itself, or no name segment → generic guidance.
if (!skillName) {
return '`skills/` はスキルストアで、Read では直接読めません。ListSkills で一覧を確認し、ReadSkill({ name }) で本文を取得してください。';
}
// Already materialized (dir exists) but the requested sub-file is missing:
// do NOT misdirect toward ReadSkill — the path itself is wrong.
if (fs.existsSync(path.join(ctx.workspacePath, 'skills', skillName))) {
return `スキル "${skillName}" は既に workspace の skills/${skillName}/ に展開済みですが、指定したファイル "${relPath}" が見つかりません。skills/${skillName}/ 配下の正しいパスを Glob か Bash の \`ls\` で確認してください。`;
}
// Known skill, not yet materialized → the ReadSkill gateway.
const folder = ctx.folderContext
? { root: ctx.folderContext.rootDir, leaf: ctx.folderContext.leafId }
: { root: ctx.skillCatalog?.getUserRoot() ?? '', leaf: ctx.userId ?? 'local' };
const known = ctx.skillCatalog?.getForFolder(folder.root, folder.leaf).some((s) => s.name === skillName);
if (known) {
return `スキル "${skillName}" は Read では直接読めません(まだ展開されていません)。ReadSkill({ name: "${skillName}" }) を呼ぶと本文が返り、ディレクトリ型スキルなら skills/${skillName}/ に展開されて以後 Read で個別ファイルを読めます。`;
}
// Under skills/ but no catalog match → generic guidance.
return `"${relPath}" は見つかりません。\`skills/\` 配下はスキルストアで、Read では直接読めません。ListSkills で一覧を確認し、ReadSkill({ name }) で本文を取得してください(ディレクトリ型スキルは ReadSkill 後に skills/<name>/ が Read 可能になります)。`;
}
function normalizeAllowedPrefix(prefix: string): string {
return prefix.replace(/^\/+/, '').replace(/\/+$/, '');
}
@@ -753,15 +1020,16 @@ const READ_DEF: ToolDef = {
type: 'function',
function: {
name: 'Read',
description: 'ファイルを読み込む。offset/limit で行範囲、byte_offset/byte_length でバイト範囲を指定可能。大きすぎる場合はコンテキストに収まる範囲に自動切り詰めされる。',
description: 'テキストや Excel/Word/PDF/PowerPoint/Outlook メール(.msg) を拡張子で自動判定して読み込む。テキストは offset/limit(行)・byte_offset/byte_length(バイト)、PDF はページ送りに page_range、Excel は sheet/range で範囲指定する(offset/limit はテキスト専用)。大きすぎる場合は自動で切り詰める。詳細は ReadToolDoc({ name: "Read" })。',
parameters: {
type: 'object',
properties: {
file_path: { type: 'string', description: 'workspace 内の相対または絶対パス' },
offset: { type: 'number', description: '読み始める行番号 (0-indexed, 省略時は先頭)' },
limit: { type: 'number', description: '読む最大行数 (省略時は全行)' },
byte_offset: { type: 'number', description: '読み始めるバイト位置。改行のない大容量ファイル向け。offset/limit と排他' },
byte_length: { type: 'number', description: '読むバイト数。byte_offset と併用' },
offset: { type: 'number', description: '読み始める行番号 (0-indexed, 省略時は先頭)。テキスト読みのみ有効(PDF/Office では無視される)' },
limit: { type: 'number', description: '読む最大行数 (省略時は全行)。テキスト読みのみ有効(PDF/Office では無視される)' },
byte_offset: { type: 'number', description: '読み始めるバイト位置。改行のない大容量テキスト向け。offset/limit と排他。テキスト読みのみ有効' },
byte_length: { type: 'number', description: '読むバイト数。byte_offset と併用。テキスト読みのみ有効' },
page_range: { type: 'string', description: 'PDF のページ範囲(例 "5-10" や "3")。PDF を途中から/一部だけ読むときはこれを使う(offset/limit ではなく)' },
},
required: ['file_path'],
},
@@ -871,7 +1139,7 @@ export function getToolDefs(allowedTools: string[], editAllowed: boolean): ToolD
// --- ツール実行実装 ---
function executRead(input: Record<string, unknown>, ctx: ToolContext): ToolResult {
async function executRead(input: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
const filePath = input['file_path'] as string;
const offset = typeof input['offset'] === 'number' ? input['offset'] : 0;
const limit = typeof input['limit'] === 'number' ? input['limit'] : undefined;
@@ -892,18 +1160,20 @@ function executRead(input: Record<string, unknown>, ctx: ToolContext): ToolResul
isError: true,
};
}
if (BLOCKED_DOCUMENT_EXTENSIONS.has(ext)) {
return {
output: `Read cannot open binary document files like "${filePath}". Use ReadPdf to extract text from PDF files.`,
isError: true,
};
// ドキュメント (Office/PDF) は拡張子から抽出ハンドラへディスパッチする。office.ts は
// core.ts を静的 import するため、循環を避けて動的 import する。
const documentTool = DOCUMENT_EXTENSION_TOOL[ext];
if (documentTool) {
const office = await import('./office.js');
const result = await office.executeTool(documentTool, { ...input, path: filePath }, ctx);
const base = result ?? { output: `Read: 内部ディスパッチ (${ext}) が結果を返しませんでした`, isError: true };
return withDocumentRangeHint(ext, input, base);
}
const officeTool = OFFICE_EXTENSION_TOOL[ext];
if (officeTool) {
return {
output: `Read cannot open Office files like "${filePath}". Use ${officeTool} to extract text from this file.`,
isError: true,
};
if (MSG_EXTENSIONS.has(ext)) {
const office = await import('./office.js');
const result = await office.executeTool('ReadMsg', input, ctx);
const base = result ?? { output: `Read: 内部ディスパッチ (.msg) が結果を返しませんでした`, isError: true };
return withDocumentRangeHint(ext, input, base);
}
if (BLOCKED_OPAQUE_BINARY_EXTENSIONS.has(ext)) {
return {
@@ -928,29 +1198,74 @@ function executRead(input: Record<string, unknown>, ctx: ToolContext): ToolResul
}
totalBytes = st.size;
} catch (e) {
// A missing file under `skills/` almost always means the agent tried to
// Read a skill directly instead of going through ReadSkill. Return an
// actionable hint instead of a bare ENOENT so the agent can recover.
if ((e as NodeJS.ErrnoException).code === 'ENOENT') {
const relPath = normalizeWorkspaceRelativePath(ctx.workspacePath, resolved);
const hint = skillReadEnoentHint(ctx, relPath);
if (hint) return { output: hint, isError: true };
}
return { output: `Read error: ${(e as Error).message}`, isError: true };
}
// 拡張子フォールバック: 先頭 8KB を sniff し binary(magic byte / NUL / 不正 UTF-8 /
// 制御文字比率)と判定したら拒否する。拡張子のホワイト/ブラックリストで漏れた未知のバイナリを止める。
if (totalBytes > 0 && looksLikeBinaryByContent(resolved)) {
return {
output: `Read cannot open "${filePath}" (binary content detected in head ${SNIFF_HEAD_BYTES} bytes). バイナリを読み込むと LLM コンテキストが壊れます。Bash で \`file\` や \`head -c 200 ${filePath} | xxd\` を使って必要部分だけ確認してください。`,
isError: true,
};
// 拡張子フォールバック: 先頭 8KB を sniff。真のバイナリ(magic byte / NUL /
// 高制御文字比率)は拒否するが、UTF-8 でデコードできないだけの非 UTF-8 テキスト
// (Shift_JIS/CP932・EUC-JP 等)は検出して UTF-8 に変換して読む。拡張子の
// ホワイト/ブラックリストで漏れた未知バイナリを止めつつ、日本語 txt/CSV を救う。
let readEncoding = 'utf-8';
if (totalBytes > 0) {
const head = readHeadBytes(resolved, SNIFF_HEAD_BYTES);
const verdict = head ? detectTextEncoding(head) : { binary: false as const, encoding: 'utf-8' };
if (verdict.binary) {
return {
output: `Read cannot open "${filePath}" (binary content detected in head ${SNIFF_HEAD_BYTES} bytes). バイナリを読み込むと LLM コンテキストが壊れます。Bash で \`file\` や \`head -c 200 ${filePath} | xxd\` を使って必要部分だけ確認してください。`,
isError: true,
};
}
readEncoding = verdict.encoding;
}
const budgetTokens = getToolOutputBudgetTokens(ctx);
const budgetBytes = estimateCharsForTokenBudget(budgetTokens);
// バイト範囲指定は UTF-8 のみ対応。非 UTF-8 のバイトオフセットは元エンコの
// バイト境界を指し、多バイト文字が途中で割れる。黙って先頭を返すと契約違反に
// なるので、明示的にエラーで断り、行範囲(offset/limit)へ誘導する。
// エンコ判定は先頭 8KB だけでなく全文で行う(先頭 ASCII・末尾 Shift_JIS を
// 取りこぼさない)。ただしバイト範囲の主用途は巨大 minified/ログ(UTF-8/ASCII)
// なので、OOM を避けて一定サイズ超のファイルは先頭判定にフォールバックする。
if (byteOffset !== undefined || byteLength !== undefined) {
let rangeEncoding = readEncoding;
if (totalBytes > 0 && totalBytes <= FULL_DETECT_MAX_BYTES) {
try {
const verdict = detectTextEncoding(fs.readFileSync(resolved));
if (!verdict.binary) rangeEncoding = verdict.encoding;
} catch {
/* keep the head-based estimate */
}
}
if (rangeEncoding !== 'utf-8') {
return {
output:
`Read: "${filePath}" は ${rangeEncoding} テキストです。バイト範囲指定 (byte_offset/byte_length) は UTF-8 ファイルのみ対応しています。` +
`offset/limit で行範囲を指定してください(全文は UTF-8 に変換して読み込めます)。`,
isError: true,
};
}
}
// 競合検知 (persistent workspace): Read はファイルを変更しないので、ここで
// 現在のディスク版を「エージェントが見た版」として台帳に記録する。後続 Write は
// この版を基準に外部変更を検出する。OFF (既定) のときは完全に no-op。
// 内容を返さずエラーで終わる分岐(バイナリ拒否・非 UTF-8 の byte 範囲など)の
// 後に置くこと。エラー時に記録すると、見ていない版を「見た」ことにして後続
// Write の競合検知をすり抜けさせてしまう。
if (ctx.conflictDetection) {
const relPath = normalizeWorkspaceRelativePath(ctx.workspacePath, resolved);
recordVersion(ctx.workspacePath, relPath, conflictDirOf(ctx));
}
const budgetTokens = getToolOutputBudgetTokens(ctx);
const budgetBytes = estimateCharsForTokenBudget(budgetTokens);
// 2. バイト範囲指定が優先(改行のないファイル向け)
if (byteOffset !== undefined || byteLength !== undefined) {
const start = Math.max(0, byteOffset ?? 0);
@@ -988,7 +1303,17 @@ function executRead(input: Record<string, unknown>, ctx: ToolContext): ToolResul
// 3. 行単位読み込み(既存挙動)+ 事前切り詰め
try {
const content = stripLeadingBom(fs.readFileSync(resolved, 'utf-8'));
const rawFull = fs.readFileSync(resolved);
// 全文でエンコを確定する(先頭 8KB が ASCII でも末尾が Shift_JIS のことがある)。
const decodedFile = decodeFileText(rawFull);
if (decodedFile.binary) {
return {
output: `Read cannot open "${filePath}" (binary content detected). バイナリを読み込むと LLM コンテキストが壊れます。Bash で \`file\` や \`head -c 200 ${filePath} | xxd\` を使って必要部分だけ確認してください。`,
isError: true,
};
}
const fileEncoding = decodedFile.encoding;
const content = stripLeadingBom(decodedFile.text);
const lines = content.split('\n');
const sliced = limit !== undefined ? lines.slice(offset, offset + limit) : lines.slice(offset);
const slicedText = sliced.join('\n');
@@ -1005,12 +1330,85 @@ function executRead(input: Record<string, unknown>, ctx: ToolContext): ToolResul
if (truncated) {
logger.info(`[read] truncated file=${filePath} original_lines=${lines.length} original_bytes=${totalBytes} budget_tokens=${effectiveBudgetTokens}`);
}
return { output: text, isError: false };
// UTF-8(BOM 付き含む)は変換なしなので注記しない。非 UTF-8 のみ注記する。
const encNote =
fileEncoding === 'utf-8' || fileEncoding === UTF8_BOM_LABEL
? ''
: `[検出エンコーディング: ${fileEncoding} → UTF-8 に変換して表示しています。ファイルはディスク上では ${fileEncoding} のままです]\n`;
return { output: encNote + text, isError: false };
} catch (e) {
return { output: `Read error: ${(e as Error).message}`, isError: true };
}
}
/**
* Best-effort provenance recorder. Wrapped so a ledger failure (bad DB, unbound
* recorder, etc.) can NEVER break tool execution — mirrors recordToolRequest.
*/
function recordProvenanceSafe(ctx: ToolContext, ev: FileProvenanceEvent): void {
if (!ctx.recordFileProvenance) return;
try {
ctx.recordFileProvenance(ev);
} catch (e) {
logger.debug(`[provenance] record skipped: ${(e as Error).message}`);
}
}
/**
* Snapshot mtime (ms) of every file under output/. Kept intentionally small:
* Bash provenance only covers the output/ subtree (the deliverables dir) so a
* command that rewrites a large input/ or logs/ tree does not trigger a scan.
*/
// Provenance snapshots run automatically on every Bash call (unlike Glob's
// on-demand walk), so they must stay cheap. Skip heavy generated dirs and bail
// past a file cap (returns null → caller skips Bash provenance for this call)
// rather than paying an O(files) synchronous stat storm on a large output/ tree
// (e.g. a git clone or npm install). Adversarial-review D1.
const PROV_SNAPSHOT_IGNORE = new Set(['.git', 'node_modules', '.venv', '__pycache__', '.cache']);
const PROV_SNAPSHOT_FILE_CAP = 2000;
function snapshotOutputMtimes(workspacePath: string): Map<string, number> | null {
const map = new Map<string, number>();
const stack = [path.join(workspacePath, 'output')];
while (stack.length > 0) {
const dir = stack.pop()!;
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (!PROV_SNAPSHOT_IGNORE.has(entry.name)) stack.push(full);
continue;
}
if (map.size >= PROV_SNAPSHOT_FILE_CAP) return null; // too large → skip provenance
try {
map.set(path.relative(workspacePath, full), fs.statSync(full).mtimeMs);
} catch {
/* file vanished between walk and stat */
}
}
}
return map;
}
/** After a Bash command, record new output/ files (bash_generated) + changed ones (modify). */
function recordBashOutputProvenance(ctx: ToolContext, before: Map<string, number> | null): void {
const after = snapshotOutputMtimes(ctx.workspacePath);
if (!before || !after) return; // tree too large / unavailable → best-effort skip
for (const [relPath, mtime] of after) {
const prev = before.get(relPath);
if (prev === undefined) {
recordProvenanceSafe(ctx, { relPath, event: 'create', sourceKind: 'bash_generated' });
} else if (prev !== mtime) {
recordProvenanceSafe(ctx, { relPath, event: 'modify', sourceKind: 'bash_generated' });
}
}
}
function executeWrite(input: Record<string, unknown>, ctx: ToolContext): ToolResult {
if (!ctx.editAllowed) {
return { output: 'Write is not allowed: edit flag is false', isError: true };
@@ -1031,8 +1429,20 @@ function executeWrite(input: Record<string, unknown>, ctx: ToolContext): ToolRes
return { output: (e as Error).message, isError: true };
}
// Provenance: a new file → this task is the creator (agent_output); an
// existing file → this task is only the last modifier. Captured before write.
const existedBefore = fs.existsSync(resolved);
// 既存の非 UTF-8 ファイルはエンコを保持して書き(Read/Edit と揃える)、表現
// 不能文字は破損保存せず拒否する。競合検知パスは対象ファイルを退避(move)して
// しまうため、判定はこの時点(退避前)に済ませ、両パスで同じバイト列を書く。
const encoded = encodeWriteContent(resolved, content, existedBefore);
if ('error' in encoded) {
return { output: `Write error: ${encoded.error}`, isError: true };
}
// 競合検知 (persistent workspace): 対象がエージェントの見た版から外部変更
// されている / そもそも読まれていない場合は、黙って上書きせず競合コピーへ逃がす。
// されている / そもそも読まれていない場合は、古いファイルを old/ へ退避してから上書きする。
// OFF (既定) のときはこのブロックを完全にスキップし、従来挙動を保つ。
if (ctx.conflictDetection) {
const relPath = normalizeWorkspaceRelativePath(ctx.workspacePath, resolved);
@@ -1040,7 +1450,7 @@ function executeWrite(input: Record<string, unknown>, ctx: ToolContext): ToolRes
let writeResolved = resolved;
if (resolution.conflict) {
try {
// 競合コピー名を再ガード (念のためルート内であることを保証)。
// 書込先を再ガード (念のためルート内であることを保証)。
writeResolved = resolveAndGuard(ctx.workspacePath, resolution.targetRelPath);
} catch (e) {
return { output: (e as Error).message, isError: true };
@@ -1048,14 +1458,22 @@ function executeWrite(input: Record<string, unknown>, ctx: ToolContext): ToolRes
}
try {
fs.mkdirSync(path.dirname(writeResolved), { recursive: true });
fs.writeFileSync(writeResolved, content, 'utf-8');
fs.writeFileSync(writeResolved, encoded.bytes);
// 書き込んだパスの版を台帳に記録 (このエージェントの最新版)。
recordVersion(ctx.workspacePath, resolution.targetRelPath, conflictDirOf(ctx));
// Provenance: a conflict wrote a fresh (renamed) file → create; otherwise
// it followed the plain existed-before semantics.
recordProvenanceSafe(ctx, {
relPath: resolution.targetRelPath,
event: resolution.conflict || !existedBefore ? 'create' : 'modify',
sourceKind: 'agent_output',
});
if (resolution.conflict) {
const name = resolution.targetRelPath.split('/').pop() ?? resolution.targetRelPath;
const archived = resolution.archivedRelPath ?? 'old/';
return {
output:
`⚠️ 競合を検知したため「${name}」として保存しました(元ファイルは保持)。` +
`⚠️ 競合を検知したため古いファイルを「${archived}」へ退避し、` +
`新しい内容を「${resolution.targetRelPath}」として保存しました。` +
` Written ${content.length} bytes to ${resolution.targetRelPath}`,
isError: false,
};
@@ -1068,7 +1486,12 @@ function executeWrite(input: Record<string, unknown>, ctx: ToolContext): ToolRes
try {
fs.mkdirSync(path.dirname(resolved), { recursive: true });
fs.writeFileSync(resolved, content, 'utf-8');
fs.writeFileSync(resolved, encoded.bytes);
recordProvenanceSafe(ctx, {
relPath: normalizeWorkspaceRelativePath(ctx.workspacePath, resolved),
event: existedBefore ? 'modify' : 'create',
sourceKind: 'agent_output',
});
return { output: `Written ${content.length} bytes to ${filePath}`, isError: false };
} catch (e) {
return { output: `Write error: ${(e as Error).message}`, isError: true };
@@ -1092,19 +1515,41 @@ function executeEdit(input: Record<string, unknown>, ctx: ToolContext): ToolResu
}
try {
const original = fs.readFileSync(resolved, 'utf-8');
// Read が非 UTF-8 テキストを UTF-8 に変換して見せるので、Edit も同じ変換を
// かけて old_string を照合しないと一致しない。書き戻しは元エンコを保持する
// (Shift_JIS のファイルは Shift_JIS のまま保存し、外部ツールの前提を壊さない)。
// エンコ判定は全文で行う(先頭 ASCII・末尾 Shift_JIS の取りこぼしを防ぐ)。
const raw = fs.readFileSync(resolved);
const decoded = decodeFileText(raw);
const editEncoding = decoded.binary ? 'utf-8' : decoded.encoding;
const original = decoded.binary ? raw.toString('utf-8') : decoded.text;
const idx = original.indexOf(oldString);
if (idx === -1) {
return { output: `Edit error: old_string not found in ${filePath}`, isError: true };
}
const updated = original.slice(0, idx) + newString + original.slice(idx + oldString.length);
fs.writeFileSync(resolved, updated, 'utf-8');
// 元エンコで表現できない文字(Shift_JIS に絵文字等)が入ると iconv は '?' に
// 置換して例外を投げない。往復一致を確認し、失われるなら保存せずエラーにする。
const outBytes = encodeFileText(updated, editEncoding);
if (outBytes === null) {
return {
output: `Edit error: 置換後の内容に ${editEncoding} で表現できない文字(絵文字など)が含まれるため、破損を避けて保存を中止しました。ファイルの文字コードは ${editEncoding} です。UTF-8 の別ファイルに保存するか、表現可能な文字を使ってください。`,
isError: true,
};
}
fs.writeFileSync(resolved, outBytes);
// 競合検知: Edit は in-place 書き換えなので、書き込んだ版を台帳に記録するだけ
// (Edit は old_string 一致を要求するので、そもそも盲目上書きの問題は起きない)。
if (ctx.conflictDetection) {
const relPath = normalizeWorkspaceRelativePath(ctx.workspacePath, resolved);
recordVersion(ctx.workspacePath, relPath, conflictDirOf(ctx));
}
// Provenance: Edit only ever modifies an existing file; the creator is kept.
recordProvenanceSafe(ctx, {
relPath: normalizeWorkspaceRelativePath(ctx.workspacePath, resolved),
event: 'modify',
sourceKind: 'agent_edit',
});
return { output: `Edited ${filePath}: replaced ${oldString.length} chars`, isError: false };
} catch (e) {
return { output: `Edit error: ${(e as Error).message}`, isError: true };
@@ -1194,11 +1639,27 @@ async function executeBash(input: Record<string, unknown>, ctx: ToolContext): Pr
return { output: (e as Error).message, isError: true };
}
// Provenance (best-effort, SMALL): snapshot the output/ file set + mtimes so
// new/changed deliverables can be attributed to this task after the command
// runs. Limitation: only output/ is diffed (not input/ or logs/), so a
// command writing elsewhere is not tracked — a deliberate trade-off to keep
// the scan bounded.
const provBefore = ctx.recordFileProvenance ? snapshotOutputMtimes(ctx.workspacePath) : null;
const mode = await resolveBashMode(ctx);
const applyWhitelist = !ctx.bashUnrestricted;
// Per-space Python package overlay (admin-installed wheels). Bound read-only +
// put on PYTHONPATH so python3 can import them; the agent's sandbox keeps
// --unshare-net (the packages were fetched out-of-band, not here). Exposed
// ONLY in the bwrap-sandboxed path — the hardened (no-bwrap) path has neither
// the read-only bind nor network isolation, so surfacing an overlay there
// would be fail-open (codex P1). Feature therefore requires bwrap.
const pyOverlay = mode === 'sandboxed' ? resolveSandboxOverlay(ctx.pythonPackagesDir) : null;
if (mode === 'sandboxed') {
const skillBinds = ctx.skillCatalog?.getSkillBinds?.(ctx.userId ?? 'local') ?? [];
const binds = pyOverlay ? [...skillBinds, pyOverlay.bind] : skillBinds;
if (applyWhitelist) {
try {
checkAllowedCommand(command, ctx.allowedCommands ?? DEFAULT_ALLOWED_COMMANDS);
@@ -1208,13 +1669,14 @@ async function executeBash(input: Record<string, unknown>, ctx: ToolContext): Pr
}
}
const result: SandboxedBashResult = await executeSandboxedBash(
command, ctx.workspacePath, timeoutSec, BASH_MAX_BUFFER_BYTES, ctx.abortSignal, skillBinds,
ctx.bashAllowNetwork === true,
command, ctx.workspacePath, timeoutSec, BASH_MAX_BUFFER_BYTES, ctx.abortSignal, binds,
ctx.bashAllowNetwork === true, pyOverlay?.sandboxPythonPath,
);
const out = result.isError ? result.output : capOutput(result.output, 'stdout');
logBashHistory(logsDir, command, result.isError, Date.now() - startedAt, {
outputBytes: Buffer.byteLength(out, 'utf-8'),
});
if (provBefore) recordBashOutputProvenance(ctx, provBefore);
return { output: out, isError: result.isError };
}
@@ -1241,7 +1703,7 @@ async function executeBash(input: Record<string, unknown>, ctx: ToolContext): Pr
encoding: 'utf-8',
maxBuffer: BASH_MAX_BUFFER_BYTES,
signal: ctx.abortSignal,
env: buildSandboxEnv(process.env, ctx.workspacePath), // scrub secrets
env: buildSandboxEnv(process.env, ctx.workspacePath), // scrub secrets (no overlay: hardened has no isolation)
},
(error, stdout, stderr) => {
if (!error) {
@@ -1249,6 +1711,7 @@ async function executeBash(input: Record<string, unknown>, ctx: ToolContext): Pr
logBashHistory(logsDir, command, false, Date.now() - startedAt, {
outputBytes: Buffer.byteLength(out, 'utf-8'),
});
if (provBefore) recordBashOutputProvenance(ctx, provBefore);
resolve({ output: out, isError: false });
return;
}
@@ -1270,6 +1733,8 @@ async function executeBash(input: Record<string, unknown>, ctx: ToolContext): Pr
logBashHistory(logsDir, command, true, Date.now() - startedAt, {
outputBytes: Buffer.byteLength(output, 'utf-8'),
});
// A failed command can still have written files before erroring.
if (provBefore) recordBashOutputProvenance(ctx, provBefore);
resolve({ output, isError: true });
},
);
@@ -1389,7 +1854,14 @@ function executeGrep(input: Record<string, unknown>, ctx: ToolContext): ToolResu
const full = path.join(baseDir, rel);
let content: string;
try {
content = stripLeadingBom(fs.readFileSync(full, 'utf-8'));
const raw = fs.readFileSync(full);
// 真バイナリ(png 等)はスキップ。無条件に utf-8 デコードすると、パターンが
// バイナリ中の ASCII 断片(IHDR/IDAT 等)にマッチしてバイナリ行が結果に混入し、
// LLM コンテキストを壊す。非 UTF-8 テキストは検出したエンコで変換して検索する。
// エンコ判定は全文で行う(先頭 ASCII・末尾 Shift_JIS の取りこぼしを防ぐ)。
const decoded = decodeFileText(raw);
if (decoded.binary) continue;
content = stripLeadingBom(decoded.text);
} catch {
continue;
}
@@ -1418,7 +1890,7 @@ export async function executeCoreTools(
switch (name) {
case 'Read':
return executRead(input, ctx);
return await executRead(input, ctx);
case 'Write':
return executeWrite(input, ctx);
case 'Edit':
+13 -8
View File
@@ -47,12 +47,14 @@ const TOOL_DOC_ALIASES: Record<string, string> = {
// maps.ts をまとめる
getdirections: 'searchplaces',
reversegeocode: 'searchplaces',
// office.ts をまとめる
readpdf: 'office',
readexcel: 'office',
readdocx: 'office',
readpptx: 'office',
readmsg: 'office',
// ReadExcel/ReadDocx/ReadPdf/ReadPPTX/ReadMsg は Read に統合。旧名の ReadToolDoc は
// 統合後の read.md に解決する(後方互換)。
readpdf: 'read',
readexcel: 'read',
readdocx: 'read',
readpptx: 'read',
readmsg: 'read',
// office.ts の残存ツール (前処理系) は office.md にまとめる
pdftoimages: 'office',
splitexcelsheets: 'office',
splitdocxsections: 'office',
@@ -102,6 +104,9 @@ const TOOL_DOC_ALIASES: Record<string, string> = {
testworkspaceapp: 'testworkspaceapp',
// orchestration.ts: WaitSubTask は spawnsubtask.md にまとめる
waitsubtask: 'spawnsubtask',
// file-provenance.ts: GetFileProvenance / ListWorkspaceFiles を1つの doc にまとめる
getfileprovenance: 'file-provenance',
listworkspacefiles: 'file-provenance',
};
const READ_TOOL_DOC_DEF: ToolDef = {
@@ -109,8 +114,8 @@ const READ_TOOL_DOC_DEF: ToolDef = {
function: {
name: 'ReadToolDoc',
description:
'ツールの詳細な使い方ドキュメントを読み込む。各ツールの description は概要のみで、詳細な手順や例が必要なときはこれを呼ぶ。'
+ 'docs/tools/{name}.md(リポジトリ内固定パス)を参照する。',
'ツールの詳細な使い方ドキュメント(docs/tools/{name}.md、リポジトリ内固定パス)を読み込む。各ツールの description は概要のみなので、詳しい手順や例が必要なときに呼ぶ。'
+ 'MCP ツール名(mcp__server__tool)を渡すと、キャッシュ済みの説明と入力スキーマを返す。',
parameters: {
type: 'object',
properties: {
+102
View File
@@ -0,0 +1,102 @@
import { describe, it, expect } from 'vitest';
import { executeTool } from './file-provenance.js';
import type { ToolContext, FileProvenanceRecord, FileProvenanceIO } from './core.js';
function rec(partial: Partial<FileProvenanceRecord> & { relPath: string }): FileProvenanceRecord {
return {
relPath: partial.relPath,
sourceKind: partial.sourceKind ?? 'agent_output',
createdByTaskId: partial.createdByTaskId ?? null,
createdByJobId: partial.createdByJobId ?? null,
createdByPiece: partial.createdByPiece ?? null,
createdByMovement: partial.createdByMovement ?? null,
firstSeenAt: partial.firstSeenAt ?? null,
lastModifiedByTaskId: partial.lastModifiedByTaskId ?? null,
lastModifiedByJobId: partial.lastModifiedByJobId ?? null,
lastModifiedAt: partial.lastModifiedAt ?? null,
checksum: partial.checksum ?? null,
note: partial.note ?? null,
};
}
function ctxWith(records: FileProvenanceRecord[]): ToolContext {
let lastFilters: Parameters<FileProvenanceIO['list']>[0];
const io: FileProvenanceIO = {
get: (relPath) => records.find((r) => r.relPath === relPath) ?? null,
list: (filters) => {
lastFilters = filters;
return records;
},
};
const ctx = { workspacePath: '/ws', editAllowed: true, fileProvenance: io } as ToolContext & {
_lastFilters?: unknown;
};
Object.defineProperty(ctx, '_lastFilters', { get: () => lastFilters });
return ctx;
}
describe('GetFileProvenance', () => {
it('returns a compact record with task ids (never titles/user ids)', async () => {
const ctx = ctxWith([rec({ relPath: 'output/a.md', createdByTaskId: 7, createdByPiece: 'chat', lastModifiedByTaskId: 9 })]);
const res = await executeTool('GetFileProvenance', { path: 'output/a.md' }, ctx);
expect(res!.isError).toBe(false);
expect(res!.output).toContain('created_by_task_id: 7');
expect(res!.output).toContain('last_modified_by_task_id: 9');
expect(res!.output).not.toMatch(/user_id|title/i);
});
it('normalizes ./ and leading slash', async () => {
const ctx = ctxWith([rec({ relPath: 'output/a.md', createdByTaskId: 1 })]);
const res = await executeTool('GetFileProvenance', { path: './output/a.md' }, ctx);
expect(res!.output).toContain('created_by_task_id: 1');
});
it('handles a missing row without crashing (backfill/unknown message)', async () => {
const ctx = ctxWith([]);
const res = await executeTool('GetFileProvenance', { path: 'output/missing.md' }, ctx);
expect(res!.isError).toBe(false);
expect(res!.output).toContain('unknown');
});
it('degrades to a no-op when provenance IO is unbound', async () => {
const ctx = { workspacePath: '/ws', editAllowed: true } as ToolContext;
const res = await executeTool('GetFileProvenance', { path: 'output/a.md' }, ctx);
expect(res!.isError).toBe(false);
expect(res!.output).toContain('利用できません');
});
});
describe('ListWorkspaceFiles', () => {
it('lists bounded provenance summaries without file contents', async () => {
const ctx = ctxWith([
rec({ relPath: 'output/a.md', sourceKind: 'agent_output', createdByTaskId: 1 }),
rec({ relPath: 'input/b.csv', sourceKind: 'user_input', createdByTaskId: 1, lastModifiedByTaskId: 2 }),
]);
const res = await executeTool('ListWorkspaceFiles', {}, ctx);
expect(res!.output).toContain('output/a.md');
expect(res!.output).toContain('[user_input]');
expect(res!.output).toContain('last_modified_by=task 2');
});
it('passes filters through to the IO (sourceKind, createdByTaskId, includeUnknown, prefix)', async () => {
const ctx = ctxWith([rec({ relPath: 'output/a.md', createdByTaskId: 1 })]) as ToolContext & { _lastFilters?: any };
await executeTool('ListWorkspaceFiles', {
path: 'output',
sourceKind: 'agent_output',
createdByTaskId: 1,
includeUnknown: false,
}, ctx);
expect(ctx._lastFilters).toMatchObject({
pathPrefix: 'output/',
sourceKind: 'agent_output',
createdByTaskId: 1,
includeUnknown: false,
});
});
it('caps limit at 200', async () => {
const ctx = ctxWith([rec({ relPath: 'output/a.md' })]) as ToolContext & { _lastFilters?: any };
await executeTool('ListWorkspaceFiles', { limit: 9999 }, ctx);
expect(ctx._lastFilters.limit).toBe(200);
});
});
+145
View File
@@ -0,0 +1,145 @@
/**
* file-provenance.ts — GetFileProvenance / ListWorkspaceFiles.
*
* META tools (always available) that let the agent ask WHO created or last
* modified a workspace file, so it can avoid clobbering another task's inputs
* or outputs in a persistent (shared) workspace.
*
* SECURITY / SCOPING GUARANTEE
* ----------------------------
* All reads go through `ctx.fileProvenance`, an IO the worker binds to THIS
* run's `workspace_path` only. Every underlying query is `WHERE workspace_path
* = <bound path>`, so a tool call can never surface ANOTHER WORKSPACE's rows.
* Within one persistent (space-shared) workspace, rows are shared across the
* tasks — possibly of different users — that already share that physical tree,
* so `created_by_task_id` can be another (even private) task's opaque ID. That
* is metadata about files the caller can already Read; the tools expose only
* task IDs (opaque integers) — NEVER task titles or user IDs — so nothing
* human-identifying leaks beyond what the plain file listing already reveals.
* When the IO is unbound (subtask / gitea-issue runs) the tools degrade to a
* friendly no-op rather than reaching into shared logs.
*/
import { ToolDef } from '../../llm/openai-compat.js';
import type { ToolContext, ToolResult, FileProvenanceRecord } from './core.js';
const GET_DEF: ToolDef = {
type: 'function',
function: {
name: 'GetFileProvenance',
description:
'ワークスペース内の1ファイルの来歴(どのタスクが作成/アップロード/最終変更したか、種別)を返す。共有ワークスペースで他タスクのファイルを誤って上書きする前に確認する。詳細は ReadToolDoc({ name: "GetFileProvenance" })。',
parameters: {
type: 'object',
properties: {
path: { type: 'string', description: 'workspace 相対パス(例 output/report.md, input/data.csv)。' },
},
required: ['path'],
},
},
};
const LIST_DEF: ToolDef = {
type: 'function',
function: {
name: 'ListWorkspaceFiles',
description:
'ワークスペースの既知ファイルを来歴サマリ付きで一覧する(source種別/作成タスク/最終変更タスクで絞り込み可、件数上限あり・本文は返さない)。詳細は ReadToolDoc({ name: "ListWorkspaceFiles" })。',
parameters: {
type: 'object',
properties: {
path: { type: 'string', description: 'パス接頭辞で絞り込む(例 output/)。' },
sourceKind: {
type: 'string',
description: 'source_kind で絞り込む(user_input / agent_output / agent_edit / bash_generated / subtask_output / imported_existing / unknown)。',
},
createdByTaskId: { type: 'number', description: '作成タスク ID で絞り込む。' },
lastModifiedByTaskId: { type: 'number', description: '最終変更タスク ID で絞り込む。' },
includeUnknown: { type: 'boolean', description: 'false で unknown / imported_existing を除外(既定 true)。' },
limit: { type: 'number', description: '最大件数(既定 50、最大 200)。' },
},
},
},
};
export const TOOL_DEFS: Record<string, ToolDef> = {
GetFileProvenance: GET_DEF,
ListWorkspaceFiles: LIST_DEF,
};
const NO_CONTEXT_MSG =
'ファイル来歴はこのコンテキストでは利用できません(subtask など永続ワークスペースに紐付かない実行)。見えている文脈で判断してください。';
/** Normalize a caller path to the workspace-relative form the ledger stores. */
function normalizeRelPath(p: string): string {
return p.trim().replace(/^\.\//, '').replace(/^\/+/, '').replace(/\/+$/, '');
}
/** Format one record compactly (task/job IDs only — never titles/user ids). */
function formatRecord(rec: FileProvenanceRecord): string {
const lines = [
`- path: ${rec.relPath}`,
` source_kind: ${rec.sourceKind}`,
];
if (rec.createdByTaskId != null) lines.push(` created_by_task_id: ${rec.createdByTaskId}`);
if (rec.createdByPiece) lines.push(` created_by_piece: ${rec.createdByPiece}${rec.createdByMovement ? `/${rec.createdByMovement}` : ''}`);
if (rec.firstSeenAt) lines.push(` first_seen_at: ${rec.firstSeenAt}`);
if (rec.lastModifiedByTaskId != null) lines.push(` last_modified_by_task_id: ${rec.lastModifiedByTaskId}`);
if (rec.lastModifiedAt) lines.push(` last_modified_at: ${rec.lastModifiedAt}`);
return lines.join('\n');
}
function get(input: Record<string, unknown>, ctx: ToolContext): ToolResult {
const io = ctx.fileProvenance;
if (!io) return { output: NO_CONTEXT_MSG, isError: false };
const raw = typeof input['path'] === 'string' ? (input['path'] as string) : '';
const relPath = normalizeRelPath(raw);
if (!relPath) return { output: 'path が空です。workspace 相対パスを指定してください。', isError: true };
const rec = io.get(relPath);
if (!rec) {
return {
output:
`## File provenance — ${relPath}\n\n` +
'この機能が導入される前から存在するか、来歴が未記録です(source_kind=unknown 相当)。' +
'共有ワークスペースでは、他タスクの成果物の可能性があるため上書き前に内容を確認してください。',
isError: false,
};
}
return { output: `## File provenance — ${relPath}\n\n${formatRecord(rec)}`, isError: false };
}
function list(input: Record<string, unknown>, ctx: ToolContext): ToolResult {
const io = ctx.fileProvenance;
if (!io) return { output: NO_CONTEXT_MSG, isError: false };
const rawLimit = typeof input['limit'] === 'number' ? Math.floor(input['limit'] as number) : 50;
const limit = Math.min(Math.max(rawLimit, 1), 200);
const records = io.list({
pathPrefix: typeof input['path'] === 'string' ? normalizeRelPath(input['path'] as string) + '/' : undefined,
sourceKind: typeof input['sourceKind'] === 'string' ? (input['sourceKind'] as string) : undefined,
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,
limit,
});
if (records.length === 0) {
return { output: '## Workspace files (provenance)\n\n該当する来歴レコードはありません。', isError: false };
}
const shown = records.slice(0, limit);
const lines = [`## Workspace files (provenance) — ${shown.length} 件${records.length > limit ? `(上限 ${limit})` : ''}`, ''];
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}`);
}
return { output: lines.join('\n'), isError: false };
}
export async function executeTool(
name: string,
input: Record<string, unknown>,
ctx: ToolContext,
): Promise<ToolResult | null> {
if (name === 'GetFileProvenance') return get(input, ctx);
if (name === 'ListWorkspaceFiles') return list(input, ctx);
return null;
}
+2
View File
@@ -34,6 +34,8 @@ import {
// getToolDefs(), so a stale copy here cannot mask a real regression.
const INDEX_META_TOOLS = [
'ReadToolDoc', 'CreateChecklist', 'CheckItem', 'GetChecklist', 'MissionUpdate',
'SearchTaskConversation', 'ReadTaskConversation',
'GetFileProvenance', 'ListWorkspaceFiles',
'ListUserAssets', 'RunUserScript', 'UpdateUserMemory', 'ReadUserMemory',
'ReadUserAgents', 'UpdateUserAgents', 'WriteUserScript', 'Brainstorm',
'ReadAppDoc', 'ListAppDocs', 'GetMyOrchestratorState', 'ReadSkill',
+7
View File
@@ -19,4 +19,11 @@ describe('tool catalog', () => {
expect(defs.find((d) => d.function.name === 'SshConsoleSend')).toBeDefined();
expect(defs.find((d) => d.function.name === 'SshConsoleSnapshot')).toBeDefined();
});
it('exposes SearchWorkspaceTasks as an always-available meta tool', async () => {
// allowedTools is empty on purpose: META tools must appear regardless of
// what the piece/workspace tool policy grants explicitly.
const defs = await getToolDefs([], false);
expect(defs.some((d) => d.function.name === 'SearchWorkspaceTasks')).toBe(true);
});
});
+62 -2
View File
@@ -209,6 +209,33 @@ async function getMissionModule(): Promise<ToolModule | null> {
return _missionModule;
}
let _taskConversationModule: ToolModule | null | undefined;
async function getTaskConversationModule(): Promise<ToolModule | null> {
if (_taskConversationModule === undefined) {
_taskConversationModule = await tryLoadModule('./task-conversation.js');
if (_taskConversationModule) logger.debug('[tools/index] task-conversation module loaded');
}
return _taskConversationModule;
}
let _workspaceTaskSearchModule: ToolModule | null | undefined;
async function getWorkspaceTaskSearchModule(): Promise<ToolModule | null> {
if (_workspaceTaskSearchModule === undefined) {
_workspaceTaskSearchModule = await tryLoadModule('./workspace-task-search.js');
if (_workspaceTaskSearchModule) logger.debug('[tools/index] workspace-task-search module loaded');
}
return _workspaceTaskSearchModule;
}
let _fileProvenanceModule: ToolModule | null | undefined;
async function getFileProvenanceModule(): Promise<ToolModule | null> {
if (_fileProvenanceModule === undefined) {
_fileProvenanceModule = await tryLoadModule('./file-provenance.js');
if (_fileProvenanceModule) logger.debug('[tools/index] file-provenance module loaded');
}
return _fileProvenanceModule;
}
let _toolRequestModule: ToolModule | null | undefined;
async function getToolRequestModule(): Promise<ToolModule | null> {
if (_toolRequestModule === undefined) {
@@ -354,6 +381,15 @@ export async function getToolDefs(
const missionMod = await getMissionModule();
if (missionMod) Object.assign(allDefs, missionMod.TOOL_DEFS);
const taskConversationMod = await getTaskConversationModule();
if (taskConversationMod) Object.assign(allDefs, taskConversationMod.TOOL_DEFS);
const workspaceTaskSearchMod = await getWorkspaceTaskSearchModule();
if (workspaceTaskSearchMod) Object.assign(allDefs, workspaceTaskSearchMod.TOOL_DEFS);
const fileProvenanceMod = await getFileProvenanceModule();
if (fileProvenanceMod) Object.assign(allDefs, fileProvenanceMod.TOOL_DEFS);
const userFolderMod = await getUserFolderModule();
if (userFolderMod) Object.assign(allDefs, userFolderMod.TOOL_DEFS);
@@ -396,7 +432,7 @@ export async function getToolDefs(
// tools as `unknown`.
_allKnownToolNames = Object.keys(allDefs);
// メタツール: piece の allowed_tools に書かれていなくても常に利用可能
// メタツール: ワークスペースのツール設定に関係なく常に利用可能
// - ReadToolDoc: 全ツールのドキュメント参照
// - CreateChecklist / CheckItem / GetChecklist: 進捗管理 (複数ステップタスクで使用)
// - MissionUpdate: タスクの目標 / 進捗のピン止めメモを更新 (会話が長くなって
@@ -408,7 +444,7 @@ export async function getToolDefs(
// - ReadAppDoc / ListAppDocs / GetMyOrchestratorState: Help アシスタント用 (#help piece)
// ただし他の piece からも参照できるようにメタ扱い
// - RequestTool: 足りないツールの要求を記録(タスク詳細/ピース集計で可視化)
const META_TOOLS = ['ReadToolDoc', 'CreateChecklist', 'CheckItem', 'GetChecklist', 'MissionUpdate', 'ListUserAssets', 'RunUserScript', 'UpdateUserMemory', 'ReadUserMemory', 'ReadUserAgents', 'UpdateUserAgents', 'WriteUserScript', 'Brainstorm', 'ReadAppDoc', 'ListAppDocs', 'GetMyOrchestratorState', 'ReadSkill', 'ListSkills', 'InstallSkill', 'RequestTool'];
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'];
const effectiveAllowed = [...allowedTools];
for (const meta of META_TOOLS) {
if (!effectiveAllowed.includes(meta) && meta in allDefs) {
@@ -465,6 +501,9 @@ async function executeToolInner(
jobId: ctxWithMcp.jobId ?? '',
config: ctxWithMcp.mcpConfig,
quotaState: ctxWithMcp.mcpQuotaState,
// Job cancel/deadline signal so a slow/hung MCP server is interrupted
// instead of pinning the worker slot past the deadline.
abortSignal: ctx.abortSignal,
// Per-space isolation guard (defense in depth). undefined = legacy
// NULL-space job → only NULL-space servers are callable.
spaceId: ctx.spaceId ?? null,
@@ -605,6 +644,27 @@ async function executeToolInner(
if (missionResult !== null) return missionResult;
}
// task-conversation ツール (SearchTaskConversation / ReadTaskConversation)
const taskConversationMod = await getTaskConversationModule();
if (taskConversationMod) {
const taskConversationResult = await taskConversationMod.executeTool(name, input, ctx);
if (taskConversationResult !== null) return taskConversationResult;
}
// workspace-task-search ツール (SearchWorkspaceTasks)
const workspaceTaskSearchMod2 = await getWorkspaceTaskSearchModule();
if (workspaceTaskSearchMod2) {
const r = await workspaceTaskSearchMod2.executeTool(name, input, ctx);
if (r !== null) return r;
}
// file-provenance ツール (GetFileProvenance / ListWorkspaceFiles)
const fileProvenanceMod = await getFileProvenanceModule();
if (fileProvenanceMod) {
const fileProvenanceResult = await fileProvenanceMod.executeTool(name, input, ctx);
if (fileProvenanceResult !== null) return fileProvenanceResult;
}
// RequestTool: 足りないツールの要求を記録。分類用に既知ツール一覧を注入する
// (getToolDefs が movement 開始時に更新した最新スナップショット)。
const toolRequestMod = await getToolRequestModule();
+26 -33
View File
@@ -21,33 +21,31 @@
import { ToolDef } from '../../llm/openai-compat.js';
import type { ToolContext, ToolResult } from './core.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
// field is a one-line addition (matching MISSION_BRIEF_FIELDS in repository.ts).
const FIELD_DESCRIPTIONS: Record<MissionBriefField, string> = {
goal: 'このタスク全体のゴール (ユーザーが最初に依頼した本質的な要件)。Markdown 可。',
done: 'これまでに完了した主要マイルストーン。Markdown 箇条書き推奨。重複作業を避けるための参照。',
open: '残っている作業 / 未解決のブロッカー。Markdown 箇条書き推奨。',
clarifications: 'ユーザーから途中で追加された補足・制約。「これは壊さないで」など。Markdown 可。',
user_constraints: 'ユーザーが明示した恒久的な制約 (「X は変えないで」「認証フローは維持」等)。可能なら comment:N / transcript:N を出典として添える。',
decisions: '検討の末に確定した設計判断とその理由。後で蒸し返さないための記録。',
current_focus: 'いま取り組んでいる作業の焦点。movement をまたいで現在地を見失わないため。',
};
const MISSION_UPDATE_DEF: ToolDef = {
type: 'function',
function: {
name: 'MissionUpdate',
description:
'タスクの Mission Brief (goal / done / open / clarifications) を更新する。常時利用可能で META_TOOL 扱い。**新規タスクの最初のツール呼び出しで goal を必ず set すること** ── ユーザー要件を verbatim に固定し、会話が長くなった後でも参照点として残す。以降は節目で done / open を更新。指定したフィールドだけ置き換わり、未指定は変更なし。詳細は ReadToolDoc({ name: "MissionUpdate" })。',
'タスクの Mission Brief (goal / done / open / clarifications / user_constraints / decisions / current_focus) を更新する。常時利用可能で META_TOOL 扱い。**新規タスクの最初のツール呼び出しで goal を必ず set すること** ── ユーザー要件を verbatim に固定し、会話が長くなった後でも参照点として残す。以降は節目で done / open / current_focus を更新し、恒久的な制約・判断は user_constraints / decisions に pin する。指定したフィールドだけ置き換わり、未指定は変更なし。詳細は ReadToolDoc({ name: "MissionUpdate" })。',
parameters: {
type: 'object',
properties: {
goal: {
type: 'string',
description: 'このタスク全体のゴール (ユーザーが最初に依頼した本質的な要件)。Markdown 可。',
},
done: {
type: 'string',
description: 'これまでに完了した主要マイルストーン。Markdown 箇条書き推奨。重複作業を避けるための参照。',
},
open: {
type: 'string',
description: '残っている作業 / 未解決のブロッカー。Markdown 箇条書き推奨。',
},
clarifications: {
type: 'string',
description: 'ユーザーから途中で追加された補足・制約。「これは壊さないで」など。Markdown 可。',
},
},
properties: Object.fromEntries(
MISSION_BRIEF_FIELDS.map((field) => [field, { type: 'string', description: FIELD_DESCRIPTIONS[field] }]),
),
},
},
};
@@ -80,23 +78,18 @@ export async function executeTool(
};
}
const patch: Record<string, string | undefined> = {
goal: clamp(input['goal']),
done: clamp(input['done']),
open: clamp(input['open']),
clarifications: clamp(input['clarifications']),
};
// Strip undefined so the IO layer treats them as "not provided" rather
// than "set to empty string".
const filtered: Partial<{ goal: string; done: string; open: string; clarifications: string }> = {};
for (const key of ['goal', 'done', 'open', 'clarifications'] as const) {
if (patch[key] !== undefined) filtered[key] = patch[key]!;
// Only fields explicitly provided are written; undefined leaves existing
// values intact (partial-replace). Iterate the shared field list so a new
// field needs no change here.
const filtered: Partial<Record<MissionBriefField, string>> = {};
for (const key of MISSION_BRIEF_FIELDS) {
const clamped = clamp(input[key]);
if (clamped !== undefined) filtered[key] = clamped;
}
if (Object.keys(filtered).length === 0) {
return {
output: '更新するフィールドが1つも指定されませんでした。goal / done / open / clarifications のいずれかを指定してください。',
output: `更新するフィールドが1つも指定されませんでした。${MISSION_BRIEF_FIELDS.join(' / ')} のいずれかを指定してください。`,
isError: true,
};
}
@@ -110,7 +103,7 @@ export async function executeTool(
};
}
const summary = ['Mission Brief を更新しました:'];
for (const key of ['goal', 'done', 'open', 'clarifications'] as const) {
for (const key of MISSION_BRIEF_FIELDS) {
const value = merged[key];
if (value) summary.push(`- ${key} (${value.length} chars)`);
}
+5 -2
View File
@@ -315,8 +315,11 @@ describe('executeReadMsg (integration)', () => {
expect(result.output).toMatch(/size|limit|too large/i);
});
it('is registered and routed through the office module dispatch', async () => {
expect(OFFICE_TOOL_DEFS.ReadMsg).toBeDefined();
it('is routed through the office module dispatch (merged into Read, not a catalog tool)', async () => {
// ReadMsg was merged into Read: no longer registered in the office catalog,
// but the extraction handler stays reachable via the internal dispatch that
// Read uses for .msg files.
expect(OFFICE_TOOL_DEFS.ReadMsg).toBeUndefined();
const result = await officeExecuteTool('ReadMsg', { file_path: 'mail.msg' }, ctx());
expect(result?.isError).toBeFalsy();
expect(result?.output).toContain('Subject: attachmentFiles');
+6 -93
View File
@@ -10,7 +10,7 @@ import { ToolDef } from '../../llm/openai-compat.js';
import type { ToolContext, ToolResult } from './core.js';
import { resolveAndGuard, resolveOutputPathWithin, truncateToBudget, getToolOutputBudgetTokens } from './core.js';
import { resolveThemePalette, extractSheetStyles } from './excel-styles.js';
import { READ_MSG_DEF, executeReadMsg } from './msg.js';
import { executeReadMsg } from './msg.js';
import { logger } from '../../logger.js';
import { callVisionModel, resolveImagePath } from './image.js';
import type {
@@ -145,7 +145,7 @@ function validateFileFormat(filePath: string, opts: FormatValidationOpts): strin
case 'cfb':
return `${opts.toolName} は旧バイナリ形式 (.xls/.doc/.ppt の CFB) を読めません。Excel/Word/PowerPoint で .xlsx/.docx/.pptx として保存し直すか、CSV エクスポートして Read で読んでください。`;
case 'pdf':
return `${opts.toolName} に渡されたファイルは実体が PDF です。代わりに ReadPdf({ path }) を使ってください。`;
return `${opts.toolName} に渡されたファイルは実体が PDF です。Read({ file_path }) で読み直してください。`;
case 'html':
return `${opts.toolName} に渡されたファイルは実体が HTML です。Read({ file_path }) で読むか、BrowseWeb({ url: "<path>" }) でレンダリング表示してください。`;
case 'text':
@@ -156,7 +156,7 @@ function validateFileFormat(filePath: string, opts: FormatValidationOpts): strin
} else if (opts.expectedFormat === 'pdf') {
switch (detected) {
case 'ooxml':
return `${opts.toolName} に渡されたファイルは実体が Office (OOXML/.xlsx/.docx/.pptx) です。拡張子に応じて ReadExcel / ReadDocx / ReadPPTX を使ってください。`;
return `${opts.toolName} に渡されたファイルは実体が Office (OOXML/.xlsx/.docx/.pptx) です。Read({ file_path }) で読み直してください。`;
case 'cfb':
return `${opts.toolName} に渡されたファイルは旧バイナリ Office (.xls/.doc/.ppt) です。Office で PDF/xlsx として保存し直してください。`;
case 'html':
@@ -202,43 +202,6 @@ function validateOfficeFile(filePath: string, expectedExts: string[], maxSize: n
// --- Tool definitions ---
const READ_EXCEL_DEF: ToolDef = {
type: 'function',
function: {
name: 'ReadExcel',
description: 'Excel (.xlsx) を読み取りテキストで返す。include_styles:true でセル装飾も取得可。詳細は ReadToolDoc({ name: "ReadExcel" })。',
parameters: {
type: 'object',
properties: {
path: { type: 'string', description: 'Excel ファイルのパス' },
sheet: { type: 'string', description: 'シート名(省略時は全シート)' },
range: { type: 'string', description: 'セル範囲(例: A1:D10、省略時はシート全体)' },
max_cells: { type: 'number', description: '最大セル数(デフォルト: 1000)' },
include_styles: { type: 'boolean', description: 'true でセル装飾(背景色/フォント/罫線/書式/結合)を ### Styles として追記。デフォルト false' },
max_style_ranges: { type: 'number', description: 'include_styles 時に出力する style range の上限(デフォルト 250)' },
},
required: ['path'],
},
},
};
const READ_DOCX_DEF: ToolDef = {
type: 'function',
function: {
name: 'ReadDocx',
description: 'Word (.docx) を読み取り本文+表を抽出する。詳細は ReadToolDoc({ name: "ReadDocx" })。',
parameters: {
type: 'object',
properties: {
path: { type: 'string', description: 'Word ファイルのパス' },
mode: { type: 'string', enum: ['text', 'text+tables'], description: '出力モード(デフォルト: text+tables)' },
max_paragraphs: { type: 'number', description: '最大段落数(デフォルト: 200)' },
},
required: ['path'],
},
},
};
const SPLIT_EXCEL_SHEETS_DEF: ToolDef = {
type: 'function',
function: {
@@ -272,54 +235,6 @@ const SPLIT_DOCX_SECTIONS_DEF: ToolDef = {
},
};
const READ_PPTX_DEF: ToolDef = {
type: 'function',
function: {
name: 'ReadPPTX',
description: 'PowerPoint (.pptx) からスライドテキスト・表・ノートを抽出。詳細は ReadToolDoc({ name: "ReadPPTX" })。',
parameters: {
type: 'object',
properties: {
path: { type: 'string', description: 'PowerPoint ファイルのパス' },
slideRange: { type: 'string', description: 'スライド範囲(例: "1-5" または "3"、省略時は全スライド)' },
},
required: ['path'],
},
},
};
const READ_PDF_DEF: ToolDef = {
type: 'function',
function: {
name: 'ReadPdf',
description: 'PDF からページごとのテキストを抽出する(スキャン PDF は PdfToImages → ReadImage 等に切替、query 指定で grep -n 風の検索結果を返す)。詳細は ReadToolDoc({ name: "ReadPdf" })。',
parameters: {
type: 'object',
properties: {
path: { type: 'string', description: 'PDF ファイルのパス' },
page_range: { type: 'string', description: 'ページ範囲(例: "1-5" または "3"、省略時は全ページ)' },
pageRange: { type: 'string', description: 'page_range の別名' },
max_pages: { type: 'number', description: '抽出する最大ページ数(省略時は制限なし)' },
max_chars: { type: 'number', description: `返却する最大文字数(デフォルト: ${MAX_OUTPUT_CHARS})` },
query: {
type: 'string',
description: '指定するとマッチしたページのみを grep -n 風 (周辺行付き) で返す。省略時は全文抽出。',
},
query_mode: {
type: 'string',
enum: ['substring', 'regex', 'iregex'],
description: 'query の解釈: substring (デフォルト、大小無視の部分一致) / regex (大小区別の正規表現) / iregex (大小無視の正規表現)',
},
context_lines: {
type: 'number',
description: 'query マッチ時にマッチ行前後で出力するコンテキスト行数(デフォルト: 2、最大: 20)',
},
},
required: ['path'],
},
},
};
const PDF_TO_IMAGES_DEF: ToolDef = {
type: 'function',
function: {
@@ -347,12 +262,10 @@ const PDF_TO_IMAGES_DEF: ToolDef = {
},
};
// ReadExcel / ReadDocx / ReadPdf / ReadPPTX / ReadMsg は Read に統合され、独立ツール
// としては未登録。抽出ロジックは executeTool の内部 dispatch target として残る(Read が
// 拡張子判定で呼び出す)。カタログ (TOOL_DEFS) には出さない。
export const TOOL_DEFS: Record<string, ToolDef> = {
ReadExcel: READ_EXCEL_DEF,
ReadDocx: READ_DOCX_DEF,
ReadPdf: READ_PDF_DEF,
ReadPPTX: READ_PPTX_DEF,
ReadMsg: READ_MSG_DEF,
SplitExcelSheets: SPLIT_EXCEL_SHEETS_DEF,
SplitDocxSections: SPLIT_DOCX_SECTIONS_DEF,
PdfToImages: PDF_TO_IMAGES_DEF,
+5 -13
View File
@@ -86,23 +86,15 @@ movements:
expect(existsSync(join(customDir, 'test-fresh-piece.yaml'))).toBe(true);
});
it('rejects shared_tools that is not an array (tool-path validator parity)', async () => {
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.
const yaml = VALID_NEW_PIECE_YAML
.replace('test-fresh-piece', 'test-shared-bad')
.replace('test-fresh-piece', 'test-shared-legacy')
.replace('description:', 'shared_tools: WebSearch\ndescription:');
const result = await executeTool('CreatePiece', { yaml_content: yaml }, ctx());
expect(result?.isError).toBe(true);
expect(result?.output).toMatch(/shared_tools must be an array/);
expect(existsSync(join(customDir, 'test-shared-bad.yaml'))).toBe(false);
});
it('accepts a valid shared_tools array', async () => {
const yaml = VALID_NEW_PIECE_YAML
.replace('test-fresh-piece', 'test-shared-ok')
.replace('description:', 'shared_tools:\n - WebSearch\n - ReadToolDoc\ndescription:');
const result = await executeTool('CreatePiece', { yaml_content: yaml }, ctx());
expect(result?.isError).toBe(false);
expect(existsSync(join(customDir, 'test-shared-ok.yaml'))).toBe(true);
expect(existsSync(join(customDir, 'test-shared-legacy.yaml'))).toBe(true);
});
});
+3 -15
View File
@@ -58,18 +58,6 @@ function validatePiece(piece: any): string | null {
if (typeof piece.max_movements !== 'number' || !Number.isFinite(piece.max_movements) || piece.max_movements <= 0) {
return 'max_movements is required (positive integer, e.g. 50 for short tasks, 999 for open-ended ones)';
}
// Piece-level shared tools (union into every movement). Optional; when
// present must be an array of non-empty strings. Kept in sync with the
// HTTP-layer validator in src/bridge/pieces-api.ts.
if (piece.shared_tools !== undefined) {
if (!Array.isArray(piece.shared_tools)) return 'shared_tools must be an array';
for (let i = 0; i < piece.shared_tools.length; i++) {
const t = piece.shared_tools[i];
if (typeof t !== 'string' || t.trim().length === 0) {
return `shared_tools[${i}] must be a non-empty string`;
}
}
}
const names = new Set(piece.movements.map((m: any) => m.name));
if (!names.has(piece.initial_movement)) return 'initial_movement must reference an existing movement';
// Phase 6b: rules[].next only accepts existing movement names + WAIT_SUBTASKS.
@@ -102,7 +90,7 @@ const LIST_PIECES_DEF: ToolDef = {
type: 'function',
function: {
name: 'ListPieces',
description: '全 Piece(実行テンプレート: ツール制限・movement フロー制御)の一覧を取得する。Skill(参照知識)の一覧は ListSkills を使うこと。新規作成前に必ず実行。詳細は ReadToolDoc({ name: "ListPieces" })。',
description: '全 Piece(実行テンプレート: movement フロー制御)の一覧を取得する。Skill(参照知識)の一覧は ListSkills を使うこと。新規作成前に必ず実行。詳細は ReadToolDoc({ name: "ListPieces" })。',
parameters: {
type: 'object',
properties: {},
@@ -130,7 +118,7 @@ const CREATE_PIECE_DEF: ToolDef = {
type: 'function',
function: {
name: 'CreatePiece',
description: '新 Piece(実行テンプレート: movement + allowed_tools を定義)を YAML から作成する。Skill の追加には InstallSkill を使うこと。詳細は ReadToolDoc({ name: "CreatePiece" })。',
description: '新 Piece(実行テンプレート: movement フロー = persona/instruction/rules を定義)を YAML から作成する。ツールの可否は設定→ツールで決まるため piece には書かない。Skill の追加には InstallSkill を使うこと。詳細は ReadToolDoc({ name: "CreatePiece" })。',
parameters: {
type: 'object',
properties: {
@@ -148,7 +136,7 @@ const UPDATE_PIECE_DEF: ToolDef = {
type: 'function',
function: {
name: 'UpdatePiece',
description: '既存 Piece を完全な YAML で全体置換する(差分更新ではない)。詳細は ReadToolDoc({ name: "UpdatePiece" })。',
description: '既存のカスタム Piece を完全な YAML で全体置換する(差分更新ではない。組み込み Piece は編集不可 — CreatePiece で別名を作ること)。詳細は ReadToolDoc({ name: "UpdatePiece" })。',
parameters: {
type: 'object',
properties: {
+271
View File
@@ -0,0 +1,271 @@
/**
* read-consolidation.test.ts — pins the "one Read to rule them all" refactor.
*
* The former ReadExcel / ReadDocx / ReadPdf / ReadPPTX / ReadMsg tools are
* merged into a single `Read` that auto-detects the format by extension and
* dispatches to the (unchanged) office/msg extraction handlers. These tests
* cover the behaviours the merge must preserve:
* - Read dispatches each document format to its extraction handler.
* - PDF advanced options (query) pass through Read to the handler verbatim.
* - Read on a .msg still performs the attachment-save side effect (and skips
* it in a read-only phase).
* - Plain text still uses the byte/line Read path.
* - Cache routing keys a Read of an office extension into the OFFICE cache
* namespace (so sheet/range/query args don't collide), plain text into the
* READ namespace, and a .msg read is uncached (side-effectful).
* - The 5 old reader names are absent from the presented catalog and the
* tool-category map, but ReadToolDoc({ name: "ReadExcel" }) still resolves
* to the unified read doc.
* - A Read of a .msg is NOT parallel-safe (attachment write side effect),
* while a Read of plain text is.
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { fileURLToPath } from 'url';
import { afterEach, describe, expect, it } from 'vitest';
import type { ToolCall } from '../../llm/openai-compat.js';
import { executeCoreTools, type ToolContext } from './core.js';
import { executeTool as officeExecuteTool } from './office.js';
import { executeTool as docsExecuteTool } from './docs.js';
import { getToolDefs } from './index.js';
import { getToolCategoryMap, _resetToolCategoryCacheForTests } from './tool-categories.js';
import { routeToolThroughCache } from '../agent-loop/tool-cache-routing.js';
import { canExecuteInParallel } from '../agent-loop/tool-dispatcher.js';
import { ToolResultCache } from '../context/tool-result-cache.js';
import { buildOfficeCacheKey, buildReadCacheKey } from '../context/cache-key.js';
const RETIRED_READERS = ['ReadExcel', 'ReadDocx', 'ReadPdf', 'ReadPPTX', 'ReadMsg'];
const MSG_FIXTURE = path.join(
path.dirname(fileURLToPath(import.meta.url)),
'__fixtures__',
'attachmentFiles.msg',
);
function makeWorkspace(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'maestro-readcons-'));
}
function ctx(workspacePath: string, editAllowed = true): ToolContext {
return { workspacePath, editAllowed };
}
const toolCall = (name: string, args: Record<string, unknown>): ToolCall => ({
id: `tc-${name}`,
type: 'function',
function: { name, arguments: JSON.stringify(args) },
});
// Minimal single-page PDF containing `text`. /Length is computed so multi-word
// strings survive intact (needed for the query passthrough test).
function writeMinimalPdf(filePath: string, text: string): void {
const stream = `BT\n/F1 24 Tf\n100 100 Td\n(${text}) Tj\nET\n`;
const streamLen = Buffer.byteLength(stream, 'utf-8');
const pdf = `%PDF-1.4
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>
endobj
4 0 obj
<< /Length ${streamLen} >>
stream
${stream}endstream
endobj
5 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
endobj
trailer
<< /Root 1 0 R /Size 6 >>
%%EOF
`;
fs.writeFileSync(filePath, pdf, 'utf-8');
}
describe('Read consolidation — dispatch by extension', () => {
let ws = '';
afterEach(() => {
if (ws) { fs.rmSync(ws, { recursive: true, force: true }); ws = ''; }
});
it('dispatches .xlsx to the Excel extraction (real content)', async () => {
ws = makeWorkspace();
fs.mkdirSync(path.join(ws, 'input'), { recursive: true });
const ExcelJS = (await import('exceljs')).default;
const wb = new ExcelJS.Workbook();
wb.addWorksheet('Sheet1').addRow(['alpha', 'beta']);
await wb.xlsx.writeFile(path.join(ws, 'input', 'data.xlsx'));
const res = await executeCoreTools('Read', { file_path: 'input/data.xlsx' }, ctx(ws));
expect(res?.isError).toBe(false);
expect(res?.output).toContain('Sheet1');
expect(res?.output).toContain('alpha');
});
it('dispatches .pdf to the PDF extraction (no longer a "use ReadPdf" rejection)', async () => {
ws = makeWorkspace();
fs.mkdirSync(path.join(ws, 'input'), { recursive: true });
writeMinimalPdf(path.join(ws, 'input', 'doc.pdf'), 'hello world');
const res = await executeCoreTools('Read', { file_path: 'input/doc.pdf' }, ctx(ws));
expect(res?.isError).toBe(false);
expect(res?.output).toContain('hello world');
});
it('passes PDF advanced options (query) through Read to the handler verbatim', async () => {
ws = makeWorkspace();
fs.mkdirSync(path.join(ws, 'input'), { recursive: true });
writeMinimalPdf(path.join(ws, 'input', 'doc.pdf'), 'find KEYWORD here');
const viaRead = await executeCoreTools(
'Read',
{ file_path: 'input/doc.pdf', query: 'KEYWORD' },
ctx(ws),
);
const viaHandler = await officeExecuteTool(
'ReadPdf',
{ path: 'input/doc.pdf', query: 'KEYWORD' },
ctx(ws),
);
expect(viaRead?.output).toEqual(viaHandler?.output);
expect(viaRead?.isError).toBe(viaHandler?.isError);
});
it('performs the .msg attachment-save side effect via Read', async () => {
ws = makeWorkspace();
fs.copyFileSync(MSG_FIXTURE, path.join(ws, 'mail.msg'));
const res = await executeCoreTools('Read', { file_path: 'mail.msg' }, ctx(ws));
expect(res?.isError).toBeFalsy();
expect(res?.output).toContain('attachmentFiles');
// Attachments extracted to input/ (the side effect ReadMsg used to own).
const inputFiles = fs.existsSync(path.join(ws, 'input'))
? fs.readdirSync(path.join(ws, 'input'))
: [];
expect(inputFiles.length).toBeGreaterThan(0);
});
it('does not save .msg attachments in a read-only phase', async () => {
ws = makeWorkspace();
fs.copyFileSync(MSG_FIXTURE, path.join(ws, 'mail.msg'));
const res = await executeCoreTools('Read', { file_path: 'mail.msg' }, ctx(ws, false));
expect(res?.isError).toBeFalsy();
const inputFiles = fs.existsSync(path.join(ws, 'input'))
? fs.readdirSync(path.join(ws, 'input'))
: [];
expect(inputFiles.length).toBe(0);
});
it('still uses the byte/line path for plain text', async () => {
ws = makeWorkspace();
fs.mkdirSync(path.join(ws, 'input'), { recursive: true });
fs.writeFileSync(path.join(ws, 'input', 'notes.txt'), 'line0\nline1\nline2\nline3\n');
const res = await executeCoreTools('Read', { file_path: 'input/notes.txt', offset: 1, limit: 2 }, ctx(ws));
expect(res?.isError).toBe(false);
expect(res?.output).toContain('line1');
expect(res?.output).toContain('line2');
expect(res?.output).not.toContain('line0');
});
it('still rejects image files (ReadImage is a separate tool)', async () => {
ws = makeWorkspace();
fs.writeFileSync(path.join(ws, 'pic.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
const res = await executeCoreTools('Read', { file_path: 'pic.png' }, ctx(ws));
expect(res?.isError).toBe(true);
expect(res?.output).toContain('ReadImage');
});
});
describe('Read consolidation — cache routing by resolved format', () => {
const WS = '/tmp/ws-readcons';
it('keys a Read of a .xlsx into the OFFICE cache namespace (captures sheet/range)', () => {
const cache = new ToolResultCache();
const route = routeToolThroughCache(
toolCall('Read', { file_path: 'input/data.xlsx', sheet: 'Q1', range: 'A1:D10' }),
cache,
WS,
);
expect(route).not.toBeNull();
expect(route!.cacheKey).toBe(
buildOfficeCacheKey({
workspacePath: WS,
toolName: 'Read',
filePath: 'input/data.xlsx',
range: 'range="A1:D10"&sheet="Q1"',
}),
);
});
it('gives distinct office cache keys for different page_range args', () => {
const cache = new ToolResultCache();
const a = routeToolThroughCache(toolCall('Read', { file_path: 'a.pdf', page_range: '1-3' }), cache, WS);
const b = routeToolThroughCache(toolCall('Read', { file_path: 'a.pdf', page_range: '4-6' }), cache, WS);
expect(a!.cacheKey).not.toBe(b!.cacheKey);
});
it('keys plain text into the READ cache namespace', () => {
const cache = new ToolResultCache();
const route = routeToolThroughCache(
toolCall('Read', { file_path: 'input/notes.txt', offset: 2, limit: 5 }),
cache,
WS,
);
expect(route!.cacheKey).toBe(
buildReadCacheKey({ workspacePath: WS, filePath: 'input/notes.txt', offset: 2, limit: 5 }),
);
});
it('does not cache a .msg read (side-effectful)', () => {
const cache = new ToolResultCache();
expect(routeToolThroughCache(toolCall('Read', { file_path: 'mail.msg' }), cache, WS)).toBeNull();
});
});
describe('Read consolidation — old reader names are gone from the surface', () => {
it('none of the 5 reader names appear in the presented catalog', async () => {
const defs = await getToolDefs(['Read', ...RETIRED_READERS], true, { vlmEnabled: true });
const names = new Set(defs.map((d) => d.function.name));
expect(names.has('Read')).toBe(true);
for (const retired of RETIRED_READERS) {
expect(names.has(retired)).toBe(false);
}
});
it('none of the 5 reader names appear in the tool-category map', async () => {
_resetToolCategoryCacheForTests();
const map = await getToolCategoryMap();
for (const retired of RETIRED_READERS) {
expect(map.has(retired)).toBe(false);
}
// Sanity: a still-registered office tool remains categorised.
expect(map.get('PdfToImages')).toBe('office');
});
it('ReadToolDoc({ name: "ReadExcel" }) still resolves to the unified read doc', async () => {
const res = await docsExecuteTool('ReadToolDoc', { name: 'ReadExcel' }, ctx('/tmp'));
expect(res).not.toBeNull();
expect(res?.isError).toBeFalsy();
expect(res?.output).toContain('# Read');
expect(res?.output).not.toContain('存在しません');
});
});
describe('Read consolidation — parallel safety is extension-aware', () => {
it('a Read of a .msg is NOT parallel-safe (attachment write side effect)', async () => {
const regularTools = await getToolDefs(['Read'], true, { vlmEnabled: true });
expect(canExecuteInParallel(toolCall('Read', { file_path: 'mail.msg' }), regularTools)).toBe(false);
});
it('a Read of plain text IS parallel-safe', async () => {
const regularTools = await getToolDefs(['Read'], true, { vlmEnabled: true });
expect(canExecuteInParallel(toolCall('Read', { file_path: 'notes.txt' }), regularTools)).toBe(true);
});
});
+22
View File
@@ -232,6 +232,28 @@ describe('buildSandboxEnv', () => {
it('falls back to C.UTF-8 when LANG absent', () => {
expect(buildSandboxEnv({ PATH: '/usr/bin' }, '/work/ws').LANG).toBe('C.UTF-8');
});
it('omits PYTHONPATH when no overlay path is given', () => {
expect(buildSandboxEnv({ PATH: '/usr/bin' }, '/work/ws').PYTHONPATH).toBeUndefined();
});
it('injects PYTHONPATH when an overlay path is given', () => {
const env = buildSandboxEnv({ PATH: '/usr/bin' }, '/work/ws', '/opt/py-packages');
expect(env.PYTHONPATH).toBe('/opt/py-packages');
});
});
describe('buildBwrapArgs python overlay', () => {
const workspace = '/var/lib/maestro/workspaces/local/42';
it('sets PYTHONPATH via --setenv when pythonPath is passed', () => {
const args = buildBwrapArgs('python3 -c "import requests"', workspace, undefined, process.env, false, '/opt/py-packages');
const idx = args.indexOf('PYTHONPATH');
expect(idx).toBeGreaterThanOrEqual(0);
expect(args[idx - 1]).toBe('--setenv');
expect(args[idx + 1]).toBe('/opt/py-packages');
});
it('does not set PYTHONPATH when pythonPath is absent', () => {
const args = buildBwrapArgs('ls', workspace);
expect(args).not.toContain('PYTHONPATH');
});
});
describe('buildBwrapArgs sandboxing', () => {
+10 -2
View File
@@ -14,10 +14,15 @@ export interface ExtraReadOnlyBind {
/**
* Build the minimal, secret-free environment handed to sandboxed bash.
* Allowlist only — any var not listed (incl. all secrets) is dropped.
*
* `pythonPath` (when set) exposes an admin-approved, read-only per-space
* package overlay to python3 via PYTHONPATH. Names that would shadow the
* stdlib are rejected at install time (see engine/python-packages.ts).
*/
export function buildSandboxEnv(
parentEnv: NodeJS.ProcessEnv,
workspacePath: string,
pythonPath?: string,
): Record<string, string> {
const env: Record<string, string> = {
PATH: parentEnv.PATH ?? '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
@@ -28,6 +33,7 @@ export function buildSandboxEnv(
};
if (parentEnv.LC_ALL) env.LC_ALL = parentEnv.LC_ALL;
if (parentEnv.TZ) env.TZ = parentEnv.TZ;
if (pythonPath) env.PYTHONPATH = pythonPath;
return env;
}
@@ -37,6 +43,7 @@ export function buildBwrapArgs(
extraReadOnlyBinds?: ExtraReadOnlyBind[],
parentEnv: NodeJS.ProcessEnv = process.env,
allowNetwork: boolean = false,
pythonPath?: string,
): string[] {
const args: string[] = [];
@@ -80,7 +87,7 @@ export function buildBwrapArgs(
}
args.push('--clearenv');
const sandboxEnv = buildSandboxEnv(parentEnv, workspacePath);
const sandboxEnv = buildSandboxEnv(parentEnv, workspacePath, pythonPath);
for (const [k, v] of Object.entries(sandboxEnv)) {
args.push('--setenv', k, v);
}
@@ -132,8 +139,9 @@ export async function executeSandboxedBash(
abortSignal?: AbortSignal,
extraReadOnlyBinds?: ExtraReadOnlyBind[],
allowNetwork: boolean = false,
pythonPath?: string,
): Promise<SandboxedBashResult> {
const args = buildBwrapArgs(command, workspacePath, extraReadOnlyBinds, process.env, allowNetwork);
const args = buildBwrapArgs(command, workspacePath, extraReadOnlyBinds, process.env, allowNetwork, pythonPath);
if (abortSignal?.aborted) {
return { output: 'Cancelled before sandbox bash launch', isError: true };
+44
View File
@@ -417,6 +417,50 @@ describe('InstallSkill tool', () => {
expect(fs.existsSync(path.join(targetDir, 'scripts', 'run.sh'))).toBe(true);
});
it('resolves a workspace-relative sourcePath against the workspace root', () => {
makeSourceSkill('rel-skill', { scripts: true });
const ctx = makeDirCtx();
// Agents naturally pass workspace-relative paths (not absolute ones).
const result = executeSkillTool('InstallSkill', {
sourcePath: 'rel-skill',
name: 'rel-skill',
scope: 'user',
}, ctx);
expect(result!.isError).toBe(false);
expect(result!.output).toContain('installed');
const targetDir = path.join(userRoot, 'user1', 'skills', 'rel-skill');
expect(fs.existsSync(path.join(targetDir, 'SKILL.md'))).toBe(true);
expect(fs.existsSync(path.join(targetDir, 'scripts', 'run.sh'))).toBe(true);
});
it('rejects a relative sourcePath that escapes the workspace', () => {
const ctx = makeDirCtx();
const result = executeSkillTool('InstallSkill', {
sourcePath: '../escape-skill',
name: 'escape-skill',
scope: 'user',
}, ctx);
expect(result!.isError).toBe(true);
});
it('rejects a non-string sourcePath without throwing', () => {
const ctx = makeDirCtx();
// A malformed tool call could pass a non-string truthy value; must return
// an error result, not crash the task with an uncaught TypeError.
const result = executeSkillTool('InstallSkill', {
sourcePath: 12345 as unknown as string,
name: 'bad-type',
scope: 'user',
}, ctx);
expect(result!.isError).toBe(true);
expect(result!.output).toContain('string');
});
it('rejects sourcePath outside workspace', () => {
const outsideDir = makeTempDir();
dirs.push(outsideDir);
+15 -5
View File
@@ -1,5 +1,5 @@
import { mkdirSync, writeFileSync, renameSync, unlinkSync, realpathSync, cpSync, rmSync, existsSync, readdirSync, lstatSync } from 'fs';
import { join } from 'path';
import { join, isAbsolute, resolve } from 'path';
import type { ToolDef } from '../../llm/openai-compat.js';
import type { ToolContext, ToolResult } from './core.js';
import { VALID_SKILL_NAME } from '../skills.js';
@@ -99,7 +99,7 @@ export const TOOL_DEFS: Record<string, ToolDef> = {
type: 'function',
function: {
name: 'InstallSkill',
description: 'スキル(参照知識: 手順書・ガイド)をインストール。Piece(実行テンプレート)とは異なる。通常は content に SKILL.md 全文を渡す。workspace 内にスキルディレクトリを構築済みの場合のみ sourcePath を使用。',
description: 'スキル(参照知識: 手順書・ガイド)をインストール。Piece(実行テンプレート)とは異なる。通常は content に SKILL.md 全文を渡し、workspace 内にディレクトリを構築済みの場合のみ sourcePath を使う。詳細は ReadToolDoc({ name: "InstallSkill" })。',
parameters: {
type: 'object',
properties: {
@@ -116,7 +116,7 @@ export const TOOL_DEFS: Record<string, ToolDef> = {
type: 'function',
function: {
name: 'ReadSkill',
description: 'スキル(参照知識: 手順書・ガイド・規約)の全文を取得する。Piece の定義取得には GetPiece を使うこと。利用可能なスキル一覧はシステムプロンプトの Skills Index を参照。',
description: 'スキル(参照知識: 手順書・ガイド・規約)の全文を取得する(ディレクトリ型は scripts 等を workspace の skills/ にコピーして実行可能にする)。一覧はシステムプロンプトの Skills Index を参照。詳細は ReadToolDoc({ name: "ReadSkill" })。',
parameters: {
type: 'object',
properties: {
@@ -130,7 +130,7 @@ export const TOOL_DEFS: Record<string, ToolDef> = {
type: 'function',
function: {
name: 'ListSkills',
description: 'インストール済みスキル(参照知識)の一覧を返す。Piece(実行テンプレート)の一覧は ListPieces を使うこと。',
description: 'インストール済みスキル(参照知識)の一覧を返す。Piece(実行テンプレート)の一覧は ListPieces を使うこと。詳細は ReadToolDoc({ name: "ListSkills" })。',
parameters: {
type: 'object',
properties: {},
@@ -192,10 +192,20 @@ function executeInstallSkill(
// --- sourcePath mode: copy directory from workspace ---
if (sourcePath) {
if (typeof sourcePath !== 'string') {
return { output: 'InstallSkill: "sourcePath" must be a string', isError: true };
}
let realSource: string;
let realWorkspace: string;
try {
realSource = realpathSync(sourcePath);
// Resolve relative paths against the workspace root, mirroring
// resolveAndGuard() used by Read/Write/Edit. Without this, a natural
// workspace-relative path (e.g. "output/my-skill") would resolve against
// the orchestrator's process cwd and get wrongly rejected as outside the
// workspace. Kept inside try so any path-resolution error returns a
// friendly result instead of crashing the task.
const absSource = isAbsolute(sourcePath) ? sourcePath : resolve(ctx.workspacePath, sourcePath);
realSource = realpathSync(absSource);
realWorkspace = realpathSync(ctx.workspacePath);
} catch (e) {
return { output: `InstallSkill: sourcePath does not exist or is not accessible. Use "content" parameter instead to pass SKILL.md text directly.`, isError: true };
+267
View File
@@ -0,0 +1,267 @@
/**
* Regression tests for SSH Console per-space isolation (spec §11).
*
* Bug: the console open path (openConsoleSession + the agent SshConsoleEnsure
* tool + the user-initiated REST route) never threaded the job/task's space
* into the access resolver. With `spaceId` missing, resolveAccess treated the
* caller as a no-space (legacy) job and denied EVERY space-scoped connection
* with `space_mismatch` — i.e. every connection registered inside a workspace
* became unusable from the console, even though SshExec (which DOES pass
* ctx.spaceId) worked fine.
*
* Strategy: bring up a real in-memory SQLite + real repos + the REAL access
* resolver (the stubbed resolver in ssh-console.test.ts always allows, so it
* cannot catch this). `openShellChannel` is stubbed to THROW a sentinel: a
* connection that CLEARS preflight reaches it and fails with
* `open_shell_failed`; a connection denied at preflight never gets there and
* fails with `preflight_denied` + a space_mismatch message. That lets us
* distinguish "preflight passed" from "space_mismatch denial" without
* constructing a live ConsoleSession.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Database from 'better-sqlite3';
import { runMigrations } from '../../db/migrate.js';
import { createConnectionRepo, type SshConnectionRepo } from '../../ssh/connection-repo.js';
import { createGrantsRepo } from '../../ssh/grants-repo.js';
import { createAuditRepo } from '../../ssh/audit-repo.js';
import { createAbuseRepo } from '../../ssh/abuse-repo.js';
import { createAccessResolver } from '../../ssh/access.js';
import { SSH_DEFAULTS } from '../../ssh/config.js';
import { preflight, setSshSubsystem, type SshSubsystem } from './ssh.js';
import { openConsoleSession } from './ssh-console.js';
import { CONSOLE_SPACE_DENY } from '../../bridge/console-space-resolver.js';
import { executeTool } from './index.js';
import type { ToolContext } from './core.js';
const VALID_KEY = 'a'.repeat(64);
const SHELL_SENTINEL = 'SENTINEL_openShellChannel_reached';
function bootstrapDb(): Database.Database {
process.env.MCP_ENCRYPTION_KEY = VALID_KEY;
const db = new Database(':memory:');
db.pragma('foreign_keys = ON');
db.exec(`CREATE TABLE users (id TEXT PRIMARY KEY, role TEXT);`);
db.exec(`CREATE TABLE jobs (id TEXT PRIMARY KEY, wait_reason TEXT);`);
db.exec(`CREATE TABLE local_tasks (id INTEGER PRIMARY KEY AUTOINCREMENT);`);
runMigrations(db);
db.prepare(`INSERT INTO users (id, role) VALUES (?, ?)`).run('alice', 'member');
return db;
}
function makeSubsystem(db: Database.Database): { sub: SshSubsystem; conn: SshConnectionRepo } {
const conn = createConnectionRepo(db);
const grants = createGrantsRepo(db);
const audit = createAuditRepo(db);
const abuse = createAbuseRepo(db, { windowMinutes: 10, failureThreshold: 5, lockMinutes: 30 });
const access = createAccessResolver(grants, { adminBypassesGrants: true });
const sub = {
connectionRepo: conn,
auditRepo: audit,
abuseRepo: abuse,
accessResolver: access,
// identity decrypt — we never reach a real SSH handshake (openShellChannel throws).
decryptKeyMaterial: (_o: string | null, blob: Buffer) => Buffer.from(blob),
decryptPassphrase: (_o: string | null, blob: Buffer | null) => (blob ? Buffer.from(blob) : null),
getUserAccess: (_userId: string) => ({ isAdmin: false, orgIds: [] }),
sshExec: async () => ({ outputJson: '{}', exitCode: 0, durationMs: 1, hostFingerprint: 'x' }),
sshUpload: async () => ({ bytes: 0, durationMs: 1, hostFingerprint: 'x' }),
sshDownload: async () => ({ bytes: 0, durationMs: 1, hostFingerprint: 'x' }),
maintenance: { isActive: () => false, snapshot: () => ({ active: false }), enter: () => undefined, exit: () => undefined },
// Enable the console (disabled by default) so the agent-tool wrapper reaches preflight.
config: { ...SSH_DEFAULTS, console: { ...SSH_DEFAULTS.console, enabled: true } },
sessionRegistry: {
register: () => undefined,
get: () => null,
listAll: () => [],
listForConnection: () => [],
closeForTask: async () => undefined,
enforceCap: () => [],
sweep: async () => undefined,
startSweepTimer: () => undefined,
stopSweepTimer: () => undefined,
shutdown: async () => undefined,
},
// A cleared-preflight session reaches this and fails here (open_shell_failed).
openShellChannel: async () => {
throw new Error(SHELL_SENTINEL);
},
} as unknown as SshSubsystem;
return { sub, conn };
}
/** A space-scoped connection with a verified host key (console requires it). */
function createSpaceConn(conn: SshConnectionRepo, db: Database.Database, spaceId: string): string {
const created = conn.create({
ownerId: 'alice',
spaceId,
label: `srv-${spaceId}`,
host: 'srv.example.com',
port: 22,
username: 'deploy',
privateKeyEnc: Buffer.from('encrypted-pem'),
keyFingerprint: 'SHA256:fp',
remotePathPrefix: '/srv/agent',
});
db.prepare(
`UPDATE ssh_connections SET host_key_b64=?, host_key_fingerprint=?, host_key_verified_at=? WHERE id=?`,
).run('hostb64', 'SHA256:host', new Date().toISOString(), created.id);
return created.id;
}
describe('SSH Console — per-space isolation (spec §11)', () => {
let db: Database.Database;
let sub: SshSubsystem;
let conn: SshConnectionRepo;
let connA: string;
beforeEach(() => {
db = bootstrapDb();
({ sub, conn } = makeSubsystem(db));
connA = createSpaceConn(conn, db, 'space-A');
});
afterEach(() => {
setSshSubsystem(null);
db.close();
});
describe('openConsoleSession core', () => {
it('a job in space A can open a console on the space-A connection (space threaded)', async () => {
const result = await openConsoleSession(
{ sub, preflight },
{
taskId: 'task-1',
connectionId: connA,
ownerId: 'alice',
userId: 'alice',
pieceName: 'ops',
allowedConnections: ['*'],
initiator: 'user',
spaceId: 'space-A',
},
);
// Preflight must PASS: it proceeds to openShellChannel (our sentinel).
expect(result.error).not.toBe('preflight_denied');
expect(result.message ?? '').not.toMatch(/space_mismatch/);
expect(result.error).toBe('open_shell_failed');
});
it('a job in space B is still denied the space-A connection (space_mismatch)', async () => {
const result = await openConsoleSession(
{ sub, preflight },
{
taskId: 'task-1',
connectionId: connA,
ownerId: 'alice',
userId: 'alice',
pieceName: 'ops',
allowedConnections: ['*'],
initiator: 'user',
spaceId: 'space-B',
},
);
expect(result.error).toBe('preflight_denied');
expect(result.message ?? '').toMatch(/space_mismatch/);
});
it('a no-space job is denied a space-scoped connection (space_mismatch)', async () => {
const result = await openConsoleSession(
{ sub, preflight },
{
taskId: 'task-1',
connectionId: connA,
ownerId: 'alice',
userId: 'alice',
pieceName: 'ops',
allowedConnections: ['*'],
initiator: 'user',
// no spaceId
},
);
expect(result.error).toBe('preflight_denied');
expect(result.message ?? '').toMatch(/space_mismatch/);
});
});
describe('deny sentinel blocks the legacy space-less path', () => {
/** A space-LESS (legacy/system) connection owned by alice, host-key verified. */
function createSpacelessConn(): string {
const created = conn.create({
ownerId: 'alice',
spaceId: null,
label: 'legacy-srv',
host: 'legacy.example.com',
port: 22,
username: 'deploy',
privateKeyEnc: Buffer.from('encrypted-pem'),
keyFingerprint: 'SHA256:fp',
remotePathPrefix: '/srv/agent',
});
db.prepare(
`UPDATE ssh_connections SET host_key_b64=?, host_key_fingerprint=?, host_key_verified_at=? WHERE id=?`,
).run('hostb64', 'SHA256:host', new Date().toISOString(), created.id);
return created.id;
}
it('genuine no-space (null) reaches a space-less connection via the owner legacy path', async () => {
const spaceless = createSpacelessConn();
const result = await openConsoleSession(
{ sub, preflight },
{
taskId: 'task-1', connectionId: spaceless, ownerId: 'alice', userId: 'alice',
pieceName: 'ops', allowedConnections: ['*'], initiator: 'user',
spaceId: null, // genuine no-space legacy task
},
);
expect(result.error).not.toBe('preflight_denied'); // owner path allows → shell open
expect(result.error).toBe('open_shell_failed');
});
it('DENY sentinel is NOT no-space: it blocks the same space-less connection', async () => {
const spaceless = createSpacelessConn();
const result = await openConsoleSession(
{ sub, preflight },
{
taskId: 'task-1', connectionId: spaceless, ownerId: 'alice', userId: 'alice',
pieceName: 'ops', allowedConnections: ['*'], initiator: 'user',
spaceId: CONSOLE_SPACE_DENY, // unauthorized space-scoped task → hard deny
},
);
expect(result.error).toBe('preflight_denied');
expect(result.message ?? '').toMatch(/space_mismatch/);
});
});
describe('SshConsoleEnsure agent tool', () => {
function mkCtx(spaceId: string | undefined): ToolContext {
return {
workspacePath: '/tmp',
editAllowed: false,
taskId: 'task-1',
userId: 'alice',
ownerId: 'alice',
jobId: 'job-1',
pieceName: 'ops',
// Worker resolves the space-scoped list; the connection IS registered here.
allowedSshConnections: [connA],
spaceId,
} as ToolContext;
}
it('threads ctx.spaceId so a space-A connection clears preflight', async () => {
setSshSubsystem(sub);
const res = await executeTool('SshConsoleEnsure', { connection_id: connA }, mkCtx('space-A'));
// Preflight passes → we fail at the (stubbed) shell open, NOT at space_mismatch.
expect(res.isError).toBe(true);
expect(res.output).not.toMatch(/space_mismatch/);
expect(res.output).toMatch(/failed to open shell channel/);
});
it('a space-B job cannot open the space-A connection', async () => {
setSshSubsystem(sub);
const res = await executeTool('SshConsoleEnsure', { connection_id: connA }, mkCtx('space-B'));
expect(res.isError).toBe(true);
expect(res.output).toMatch(/space_mismatch/);
});
});
});
+464 -45
View File
@@ -7,8 +7,16 @@
* SshExec tests in ssh.test.ts.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { executeTool, TOOL_DEFS, unescapeAiInput } from './ssh-console.js';
import { setSshSubsystem, type SshSubsystem } from './ssh.js';
import {
executeTool,
TOOL_DEFS,
unescapeAiInput,
openConsoleSession,
resolveTargetSession,
__resetAgentConnectionPointer,
type OpenConsoleParams,
} from './ssh-console.js';
import { setSshSubsystem, preflight, type SshSubsystem } from './ssh.js';
import type { ToolContext } from './core.js';
describe('unescapeAiInput', () => {
@@ -104,6 +112,8 @@ function mkStubSubsystem() {
register: vi.fn(),
enforceCap: vi.fn().mockReturnValue([]),
closeForTask: vi.fn().mockResolvedValue(undefined),
closeSession: vi.fn().mockResolvedValue(undefined),
listForTask: vi.fn().mockReturnValue([]),
listAll: vi.fn().mockReturnValue([]),
listForConnection: vi.fn().mockReturnValue([]),
sweep: vi.fn(),
@@ -164,6 +174,8 @@ function mkStubSubsystem() {
maxSessionDurationSeconds: 600,
scrollbackBytes: 4096,
maxSessionsPerConnection: 3,
maxSessionsPerTask: 5,
maxSessionsPerUser: 20,
maxInputBytesPerSend: 1024,
autoInjectScreenLines: 24,
defaultCols: 80,
@@ -189,7 +201,10 @@ function mkCtx(overrides: Partial<ToolContext> = {}): ToolContext {
}
describe('SshConsoleEnsure', () => {
beforeEach(() => setSshSubsystem(null));
beforeEach(() => {
setSshSubsystem(null);
__resetAgentConnectionPointer();
});
it('is registered in TOOL_DEFS', () => {
expect(TOOL_DEFS.SshConsoleEnsure).toBeDefined();
@@ -222,39 +237,49 @@ describe('SshConsoleEnsure', () => {
expect(openShellChannel).not.toHaveBeenCalled();
});
it('rejects mismatching connection_id by default and surfaces the active id', async () => {
const { sub, registry } = mkStubSubsystem();
registry.get.mockReturnValue({
it('opening a DIFFERENT connection adds a session and does NOT close the existing one', async () => {
// Task 3: add-not-replace. conn-OLD is active; opening conn-NEW just
// opens a second session. No force_replace needed, nothing closed.
const { sub, registry, openShellChannel } = mkStubSubsystem();
const old = {
localTaskId: 'task-1',
connectionId: 'conn-OLD',
isClosed: false,
startedByUserId: 'u1',
startedAt: Date.now() - 60_000,
lastActivityAt: Date.now() - 5_000,
});
};
// Key-aware: only conn-OLD has an existing session.
registry.get.mockImplementation((_t: string, c: string) => (c === 'conn-OLD' ? old : null));
registry.listForTask.mockReturnValue([old]);
setSshSubsystem(sub);
const res = await executeTool('SshConsoleEnsure', { connection_id: 'conn-NEW' }, mkCtx());
expect(res?.isError).toBe(true);
expect(res?.output).toContain('conn-OLD');
expect(res?.output).toContain('force_replace');
expect(res?.isError).toBe(false);
expect(openShellChannel).toHaveBeenCalledTimes(1);
expect(registry.closeForTask).not.toHaveBeenCalled();
expect(registry.closeSession).not.toHaveBeenCalled();
});
it('closes old session and opens new when force_replace=true', async () => {
const { sub, registry } = mkStubSubsystem();
registry.get.mockReturnValue({
it('force_replace on the SAME connection closes just that connection then reopens', async () => {
const { sub, registry, openShellChannel } = mkStubSubsystem();
const existing = {
localTaskId: 'task-1',
connectionId: 'conn-OLD',
connectionId: 'conn-1',
isClosed: false,
startedByUserId: 'u1',
startedAt: Date.now() - 60_000,
lastActivityAt: Date.now() - 5_000,
});
};
registry.get.mockImplementation((_t: string, c: string) => (c === 'conn-1' ? existing : null));
setSshSubsystem(sub);
const res = await executeTool(
'SshConsoleEnsure',
{ connection_id: 'conn-NEW', force_replace: true },
{ connection_id: 'conn-1', force_replace: true },
mkCtx(),
);
expect(registry.closeForTask).toHaveBeenCalledWith('task-1', 'connection_change');
expect(registry.closeSession).toHaveBeenCalledWith('task-1', 'conn-1', 'connection_change');
expect(registry.closeForTask).not.toHaveBeenCalled();
expect(openShellChannel).toHaveBeenCalledTimes(1);
expect(res?.isError).toBe(false);
});
@@ -267,8 +292,221 @@ describe('SshConsoleEnsure', () => {
});
});
// ──────────────────────────────────────────────────────────────────────
// Task 3: openConsoleSession add-not-replace + task/user caps + TOCTOU lock
// ──────────────────────────────────────────────────────────────────────
/** A minimal stateful registry mirroring SessionRegistry's (task, conn) keying
* so we can assert listForTask length, add-not-replace, and concurrency. */
function mkStatefulRegistry(opts: { maxSessionsPerConnection?: number } = {}) {
const byTask = new Map<string, Map<string, any>>();
const reg = {
get: vi.fn((taskId: string, connId: string) => {
const s = byTask.get(taskId)?.get(connId);
return s && !s.isClosed ? s : null;
}),
listForTask: vi.fn((taskId: string) =>
[...(byTask.get(taskId)?.values() ?? [])].filter((s: any) => !s.isClosed)),
listAll: vi.fn(() => {
const all: any[] = [];
for (const inner of byTask.values()) for (const s of inner.values()) if (!s.isClosed) all.push(s);
return all;
}),
register: vi.fn((session: any) => {
let inner = byTask.get(session.localTaskId);
if (!inner) { inner = new Map(); byTask.set(session.localTaskId, inner); }
inner.set(session.connectionId, session);
}),
closeSession: vi.fn(async (taskId: string, connId: string) => {
const inner = byTask.get(taskId);
if (inner?.has(connId)) {
inner.delete(connId);
if (inner.size === 0) byTask.delete(taskId);
}
}),
closeForTask: vi.fn(async () => {}),
enforceCap: vi.fn((connId: string) => {
const max = opts.maxSessionsPerConnection ?? 999;
const sessions: any[] = [];
for (const inner of byTask.values())
for (const s of inner.values()) if (!s.isClosed && s.connectionId === connId) sessions.push(s);
const over = sessions.length - max;
if (over <= 0) return [];
sessions.sort((a, b) => a.startedAt - b.startedAt);
return sessions.slice(0, over);
}),
listForConnection: vi.fn(() => []),
sweep: vi.fn(), startSweepTimer: vi.fn(), stopSweepTimer: vi.fn(), shutdown: vi.fn(),
};
return { reg, byTask };
}
function mkPreSession(taskId: string, connId: string, userId = 'u1') {
const now = Date.now();
return {
localTaskId: taskId, connectionId: connId, startedByUserId: userId,
cols: 80, rows: 24, isClosed: false, startedAt: now, lastActivityAt: now,
};
}
function mkOpenParams(overrides: Partial<OpenConsoleParams> = {}): OpenConsoleParams {
return {
taskId: 'task-1',
connectionId: 'conn-1',
ownerId: 'u1',
userId: 'u1',
pieceName: 'p',
allowedConnections: ['*'],
initiator: 'agent',
jobId: 'j1',
spaceId: null,
...overrides,
};
}
describe('openConsoleSession (Task 3 multi-session core)', () => {
beforeEach(() => {
setSshSubsystem(null);
__resetAgentConnectionPointer();
});
it('(a) opening connection B while A is active leaves A in listForTask', async () => {
const { sub } = mkStubSubsystem();
const { reg, byTask } = mkStatefulRegistry();
(sub as any).sessionRegistry = reg;
// Seed an active session on conn-A.
reg.register(mkPreSession('task-1', 'conn-A'));
const res = await openConsoleSession(
{ sub, preflight },
mkOpenParams({ connectionId: 'conn-B' }),
);
expect(res.ok).toBe(true);
expect(res.alreadyActive).toBe(false);
const live = byTask.get('task-1')!;
expect(live.has('conn-A')).toBe(true);
expect(live.has('conn-B')).toBe(true);
expect(reg.listForTask('task-1').length).toBe(2);
expect(reg.closeSession).not.toHaveBeenCalled();
});
it('(b) opening the SAME connection twice reuses (alreadyActive)', async () => {
const { sub, openShellChannel } = mkStubSubsystem();
const { reg } = mkStatefulRegistry();
(sub as any).sessionRegistry = reg;
const first = await openConsoleSession({ sub, preflight }, mkOpenParams({ connectionId: 'conn-1' }));
expect(first.ok).toBe(true);
expect(first.alreadyActive).toBe(false);
expect(openShellChannel).toHaveBeenCalledTimes(1);
const second = await openConsoleSession({ sub, preflight }, mkOpenParams({ connectionId: 'conn-1' }));
expect(second.ok).toBe(true);
expect(second.alreadyActive).toBe(true);
// No second channel open — the live session was reused.
expect(openShellChannel).toHaveBeenCalledTimes(1);
expect(reg.listForTask('task-1').length).toBe(1);
});
it('(c) force_replace on same connection closes only that connection', async () => {
const { sub, openShellChannel } = mkStubSubsystem();
const { reg } = mkStatefulRegistry();
(sub as any).sessionRegistry = reg;
reg.register(mkPreSession('task-1', 'conn-1'));
reg.register(mkPreSession('task-1', 'conn-2')); // a sibling that must survive
const res = await openConsoleSession(
{ sub, preflight },
mkOpenParams({ connectionId: 'conn-1', forceReplace: true }),
);
expect(res.ok).toBe(true);
expect(reg.closeSession).toHaveBeenCalledWith('task-1', 'conn-1', 'connection_change');
// Only conn-1 was closed; conn-2 sibling remains.
const calls = reg.closeSession.mock.calls.filter((c: any[]) => c[2] === 'connection_change');
expect(calls.every((c: any[]) => c[1] === 'conn-1')).toBe(true);
expect(reg.get('task-1', 'conn-2')).not.toBeNull();
expect(openShellChannel).toHaveBeenCalledTimes(1);
});
it('(d) at task cap, a new connection returns task_session_cap and closes nothing', async () => {
const { sub, openShellChannel } = mkStubSubsystem();
sub.config.console.maxSessionsPerTask = 1;
const { reg } = mkStatefulRegistry();
(sub as any).sessionRegistry = reg;
reg.register(mkPreSession('task-1', 'conn-A'));
const res = await openConsoleSession(
{ sub, preflight },
mkOpenParams({ connectionId: 'conn-B' }),
);
expect(res.ok).toBe(false);
expect(res.error).toBe('task_session_cap');
expect(openShellChannel).not.toHaveBeenCalled();
expect(reg.closeSession).not.toHaveBeenCalled();
expect(reg.listForTask('task-1').length).toBe(1);
});
it('(d2) at per-user cap, a new session returns user_session_cap', async () => {
const { sub, openShellChannel } = mkStubSubsystem();
sub.config.console.maxSessionsPerUser = 1;
const { reg } = mkStatefulRegistry();
(sub as any).sessionRegistry = reg;
// u1 already owns a session on a DIFFERENT task.
reg.register(mkPreSession('task-OTHER', 'conn-X', 'u1'));
const res = await openConsoleSession(
{ sub, preflight },
mkOpenParams({ connectionId: 'conn-1', userId: 'u1' }),
);
expect(res.ok).toBe(false);
expect(res.error).toBe('user_session_cap');
expect(openShellChannel).not.toHaveBeenCalled();
});
it('(d3) per-user cap of 0 means unlimited', async () => {
const { sub } = mkStubSubsystem();
sub.config.console.maxSessionsPerUser = 0;
const { reg } = mkStatefulRegistry();
(sub as any).sessionRegistry = reg;
for (let i = 0; i < 3; i++) reg.register(mkPreSession(`task-${i}`, `conn-${i}`, 'u1'));
const res = await openConsoleSession(
{ sub, preflight },
mkOpenParams({ connectionId: 'conn-1' }),
);
expect(res.ok).toBe(true);
});
it('(e) two concurrent opens for the same new (task,conn) register exactly one session', async () => {
const { sub, openShellChannel } = mkStubSubsystem();
const { reg, byTask } = mkStatefulRegistry();
(sub as any).sessionRegistry = reg;
// Make channel-open slow so both calls would interleave without the lock.
const orig = openShellChannel;
(sub as any).openShellChannel = vi.fn(async (args: any) => {
await new Promise((r) => setTimeout(r, 25));
return orig(args);
});
const [r1, r2] = await Promise.all([
openConsoleSession({ sub, preflight }, mkOpenParams({ connectionId: 'conn-1' })),
openConsoleSession({ sub, preflight }, mkOpenParams({ connectionId: 'conn-1' })),
]);
expect(r1.ok).toBe(true);
expect(r2.ok).toBe(true);
// Exactly one channel opened, exactly one registered session — no orphan.
expect((sub as any).openShellChannel).toHaveBeenCalledTimes(1);
expect(byTask.get('task-1')!.size).toBe(1);
// One of them opened, the other reused.
expect([r1.alreadyActive, r2.alreadyActive].sort()).toEqual([false, true]);
});
});
describe('SshConsoleSend', () => {
beforeEach(() => setSshSubsystem(null));
beforeEach(() => {
setSshSubsystem(null);
__resetAgentConnectionPointer();
});
it('writes input to session and returns screen snapshot', async () => {
const { sub, registry } = mkStubSubsystem();
@@ -426,7 +664,7 @@ describe('SshConsoleSend', () => {
connectionRepo.resolveConnection.mockReturnValue(mkConn());
const writes: Buffer[] = [];
const now = Date.now();
registry.get.mockReturnValue({
const fakeSession = {
localTaskId: 'task-1',
connectionId: 'conn-1',
cols: 80, rows: 24,
@@ -437,24 +675,38 @@ describe('SshConsoleSend', () => {
write: (b: Buffer) => writes.push(b),
snapshotScreen: () => ({ cols: 80, rows: 24, text: 'ok', cursor: { x: 0, y: 0 } }),
totalOutputBytes: 0,
} as any);
};
// Task 4: with connection_id omitted and no pointer set yet,
// resolveTargetSession falls back to the task's sole live session —
// registry.get is keyed by (task, connection), so listForTask must
// reflect that same single session too.
registry.get.mockReturnValue(fakeSession as any);
registry.listForTask.mockReturnValue([fakeSession]);
setSshSubsystem(sub);
const res = await executeTool('SshConsoleSend', { input: 'whoami\n' }, mkCtx());
expect(res?.isError).toBe(false);
expect(writes[0]!.toString()).toBe('whoami\n');
});
it('rejects mismatching connection_id and surfaces the active id', async () => {
it('explicit connection_id for a non-existent session errors and does not fall back to another live session', async () => {
// Task 4: registry is now (task, connection)-keyed — an explicit
// connection_id does an exact lookup, not a "does this match the one
// active session" comparison. A live session on a DIFFERENT connection
// must not be silently used.
const { sub, registry } = mkStubSubsystem();
registry.get.mockReturnValue({
localTaskId: 'task-1',
connectionId: 'conn-ACTIVE',
cols: 80, rows: 24,
isClosed: false,
write: vi.fn(),
snapshotScreen: () => ({ cols: 80, rows: 24, text: '', cursor: { x: 0, y: 0 } }),
totalOutputBytes: 0,
} as any);
registry.get.mockImplementation((_taskId: string, connId: string) =>
connId === 'conn-ACTIVE'
? {
localTaskId: 'task-1',
connectionId: 'conn-ACTIVE',
cols: 80, rows: 24,
isClosed: false,
write: vi.fn(),
snapshotScreen: () => ({ cols: 80, rows: 24, text: '', cursor: { x: 0, y: 0 } }),
totalOutputBytes: 0,
}
: null,
);
setSshSubsystem(sub);
const res = await executeTool(
'SshConsoleSend',
@@ -462,8 +714,8 @@ describe('SshConsoleSend', () => {
mkCtx(),
);
expect(res?.isError).toBe(true);
expect(res?.output).toContain('conn-ACTIVE');
expect(res?.output).toContain('force_replace');
expect(res?.output).toContain('conn-WRONG');
expect(res?.output).toContain('SshConsoleEnsure');
});
it('errors when no active session and no connection_id provided', async () => {
@@ -607,7 +859,10 @@ describe('SshConsoleSend', () => {
});
describe('SshConsoleSnapshot', () => {
beforeEach(() => setSshSubsystem(null));
beforeEach(() => {
setSshSubsystem(null);
__resetAgentConnectionPointer();
});
it('returns screen when kind=screen', async () => {
const { sub, registry } = mkStubSubsystem();
@@ -668,14 +923,18 @@ describe('SshConsoleSnapshot', () => {
it('omitting connection_id uses the active session', async () => {
const { sub, registry } = mkStubSubsystem();
registry.get.mockReturnValue({
const fakeSession = {
localTaskId: 'task-1',
connectionId: 'conn-1',
cols: 80, rows: 24,
isClosed: false,
snapshotScreen: () => ({ cols: 80, rows: 24, text: 'auto', cursor: { x: 0, y: 0 } }),
snapshotScrollback: () => ({ text: '', byteCount: 0, truncated: false }),
} as any);
};
// Task 4: omitted connection_id + no pointer yet falls back to the
// task's sole live session via listForTask, not just registry.get.
registry.get.mockReturnValue(fakeSession as any);
registry.listForTask.mockReturnValue([fakeSession]);
setSshSubsystem(sub);
const res = await executeTool('SshConsoleSnapshot', {}, mkCtx());
expect(res?.isError).toBe(false);
@@ -683,15 +942,21 @@ describe('SshConsoleSnapshot', () => {
expect(data.text).toBe('auto');
});
it('rejects mismatching connection_id and surfaces the active id', async () => {
it('explicit connection_id for a non-existent session errors and does not fall back to another live session', async () => {
// Task 4: exact (task, connection) lookup — a live session on a
// DIFFERENT connection must not be silently used.
const { sub, registry } = mkStubSubsystem();
registry.get.mockReturnValue({
localTaskId: 'task-1',
connectionId: 'conn-ACTIVE',
cols: 80, rows: 24,
isClosed: false,
snapshotScreen: () => ({ cols: 80, rows: 24, text: '', cursor: { x: 0, y: 0 } }),
} as any);
registry.get.mockImplementation((_taskId: string, connId: string) =>
connId === 'conn-ACTIVE'
? {
localTaskId: 'task-1',
connectionId: 'conn-ACTIVE',
cols: 80, rows: 24,
isClosed: false,
snapshotScreen: () => ({ cols: 80, rows: 24, text: '', cursor: { x: 0, y: 0 } }),
}
: null,
);
setSshSubsystem(sub);
const res = await executeTool(
'SshConsoleSnapshot',
@@ -699,12 +964,16 @@ describe('SshConsoleSnapshot', () => {
mkCtx(),
);
expect(res?.isError).toBe(true);
expect(res?.output).toContain('conn-ACTIVE');
expect(res?.output).toContain('conn-WRONG');
expect(res?.output).toContain('SshConsoleEnsure');
});
});
describe('SshConsoleRun', () => {
beforeEach(() => setSshSubsystem(null));
beforeEach(() => {
setSshSubsystem(null);
__resetAgentConnectionPointer();
});
/** Build a fake session that supports onOutput, write (spy), scrollbackBytes, snapshotScreen,
* lastHumanInputAt, lastAiInputAt, idleMs, startedAt — matching the ConsoleSession public surface. */
@@ -845,3 +1114,153 @@ describe('SshConsoleRun', () => {
expect(data.timed_out).toBe(false);
});
});
// ──────────────────────────────────────────────────────────────────────
// Task 4: agent current-connection pointer + resolveTargetSession routing
// ──────────────────────────────────────────────────────────────────────
//
// Task 3 made openConsoleSession add-not-replace, so a task can hold several
// live sessions at once. These tests cover the routing an omitted
// connection_id gets in that world: the agent's current-connection pointer
// (set by the most recent successful Ensure/Send/Run/Snapshot), falling
// back to the task's sole session when unambiguous, and a clear error
// otherwise. Uses the same (task, connection)-keyed stateful registry stub
// as the Task 3 tests above (mkStatefulRegistry / mkPreSession).
/** A session stub with everything SshConsoleSend needs (write spy,
* snapshotScreen, liveness fields) AND the registry keying fields
* (localTaskId/connectionId/isClosed) so mkStatefulRegistry can hold it. */
function mkRoutableSession(taskId: string, connId: string) {
const now = Date.now();
return {
localTaskId: taskId,
connectionId: connId,
cols: 80,
rows: 24,
isClosed: false,
startedAt: now - 20_000,
totalOutputBytes: 0,
idleMs: vi.fn().mockReturnValue(10_000),
lastAiInputAt: now - 10_000,
write: vi.fn(),
snapshotScreen: () => ({ cols: 80, rows: 24, text: 'ok', cursor: { x: 0, y: 0 } }),
};
}
describe('resolveTargetSession (Task 4 routing)', () => {
beforeEach(() => {
setSshSubsystem(null);
__resetAgentConnectionPointer();
});
it('(a) two sessions, no connection_id: a Send after the agent used connection A routes to A', async () => {
const { sub } = mkStubSubsystem();
const { reg } = mkStatefulRegistry();
(sub as any).sessionRegistry = reg;
const sessA = mkRoutableSession('task-1', 'conn-A');
const sessB = mkRoutableSession('task-1', 'conn-B');
reg.register(sessA);
reg.register(sessB);
setSshSubsystem(sub);
// The agent explicitly acts on connection A first — this sets the
// per-task current-connection pointer to conn-A.
const first = await executeTool(
'SshConsoleSend',
{ connection_id: 'conn-A', input: 'ls\n' },
mkCtx(),
);
expect(first?.isError).toBe(false);
expect(sessA.write).toHaveBeenCalledTimes(1);
// A later Send with connection_id omitted must route to the pointer
// (conn-A), not error out just because the task now has 2 sessions.
const second = await executeTool('SshConsoleSend', { input: 'pwd\n' }, mkCtx());
expect(second?.isError).toBe(false);
expect(sessA.write).toHaveBeenCalledTimes(2);
expect(sessB.write).not.toHaveBeenCalled();
});
it('(b) two sessions, no pointer set yet, no connection_id → ambiguity error', async () => {
const { sub } = mkStubSubsystem();
const { reg } = mkStatefulRegistry();
(sub as any).sessionRegistry = reg;
reg.register(mkRoutableSession('task-1', 'conn-A'));
reg.register(mkRoutableSession('task-1', 'conn-B'));
setSshSubsystem(sub);
const res = await executeTool('SshConsoleSend', { input: 'ls\n' }, mkCtx());
expect(res?.isError).toBe(true);
expect(res?.output).toContain('connection_id required (multiple sessions open');
});
it('(c) one session, omitted connection_id → routes to the sole session', async () => {
const { sub } = mkStubSubsystem();
const { reg } = mkStatefulRegistry();
(sub as any).sessionRegistry = reg;
const sess = mkRoutableSession('task-1', 'conn-solo');
reg.register(sess);
setSshSubsystem(sub);
const res = await executeTool('SshConsoleSend', { input: 'ls\n' }, mkCtx());
expect(res?.isError).toBe(false);
expect(sess.write).toHaveBeenCalledTimes(1);
});
it('(d) explicit connection_id for a non-existent session → clear error (no fallback to another live session)', async () => {
const { sub } = mkStubSubsystem();
const { reg } = mkStatefulRegistry();
(sub as any).sessionRegistry = reg;
reg.register(mkRoutableSession('task-1', 'conn-real'));
setSshSubsystem(sub);
const res = await executeTool(
'SshConsoleSend',
{ connection_id: 'conn-ghost', input: 'ls\n' },
mkCtx(),
);
expect(res?.isError).toBe(true);
expect(res?.output).toContain('conn-ghost');
expect(res?.output).toContain('SshConsoleEnsure');
});
// ── Direct unit coverage of resolveTargetSession itself ──────────────
it('unit: falls back through pointer → sole session → ambiguous → none in precedence order', () => {
const { sub } = mkStubSubsystem();
const { reg } = mkStatefulRegistry();
(sub as any).sessionRegistry = reg;
// none: no sessions at all.
expect(resolveTargetSession(sub, 'task-1', undefined)).toEqual({ ok: false, error: 'none' });
// sole session: unambiguous even without a pointer.
const solo = mkRoutableSession('task-1', 'conn-solo');
reg.register(solo);
expect(resolveTargetSession(sub, 'task-1', undefined)).toEqual({
ok: true,
session: solo,
connectionId: 'conn-solo',
});
// ambiguous: a second session with no pointer set.
const second = mkRoutableSession('task-1', 'conn-second');
reg.register(second);
const ambiguous = resolveTargetSession(sub, 'task-1', undefined);
expect(ambiguous.ok).toBe(false);
if (!ambiguous.ok) expect(ambiguous.error).toBe('ambiguous');
// explicit connection_id for a session that doesn't exist → not_found,
// independent of how many OTHER sessions are live.
const notFound = resolveTargetSession(sub, 'task-1', 'conn-missing');
expect(notFound).toEqual({ ok: false, error: 'not_found', connectionId: 'conn-missing' });
// explicit connection_id for a session that DOES exist always resolves,
// even with multiple sessions live and no pointer set.
expect(resolveTargetSession(sub, 'task-1', 'conn-second')).toEqual({
ok: true,
session: second,
connectionId: 'conn-second',
});
});
});
+364 -105
View File
@@ -6,10 +6,12 @@
* (piece membership, access decision, enabled / abuse / host-key state)
* via the exported `preflight` helper in ssh.ts. After that:
*
* - SshConsoleEnsure: find-or-open the session keyed by (localTaskId).
* If a session already exists for a different connection on this task,
* close it with reason 'connection_change' and open a fresh one. Apply
* per-connection session cap (evict oldest with 'session_cap_evict').
* - SshConsoleEnsure: find-or-open the session keyed by
* (localTaskId, connectionId). A task can hold multiple concurrent
* sessions, one per connection: opening a DIFFERENT connection ADDS a
* session (it does not close the others). `force_replace` only restarts
* the SAME connection's session (close 'connection_change' + reopen).
* Enforces per-task, per-user, and per-connection session caps.
*
* - SshConsoleSend: deny-list check the input lines, then forward the
* bytes to the session (which writes them straight to the PTY — same
@@ -63,7 +65,7 @@ export interface OpenConsoleParams {
userId: string;
/** For the grant check (access resolver matches against this piece). */
pieceName: string;
/** Piece allowed_ssh_connections (['*'] for user-initiated). */
/** Workspace/space-scoped SSH connection IDs (['*'] for user-initiated console). */
allowedConnections: string[];
cols?: number;
rows?: number;
@@ -72,6 +74,15 @@ export interface OpenConsoleParams {
initiator: 'agent' | 'user';
/** Optional job id for the audit rows (agent path passes ctx.jobId). */
jobId?: string | null;
/**
* Per-space isolation (spec §11): the space of the job/task opening the
* console. Gates access to space-scoped connections in the SAME way SshExec
* does via ctx.spaceId. MUST be the already-resolved space id (personal-WS
* NULL→personal-space handled by the caller, mirroring the worker). When
* omitted, resolveAccess treats the caller as a no-space (legacy) job and
* denies every space-scoped connection with `space_mismatch`.
*/
spaceId?: string | null;
}
export interface OpenConsoleResult {
@@ -86,7 +97,8 @@ export interface OpenConsoleResult {
| 'no_grant'
| 'host_key_not_verified'
| 'host_key_mismatch'
| 'connection_change_requires_force'
| 'task_session_cap'
| 'user_session_cap'
| 'abuse_locked'
| 'connection_disabled'
| 'connection_not_found'
@@ -104,6 +116,46 @@ export interface OpenConsoleResult {
session?: ConsoleSession;
}
// ──────────────────────────────────────────────────────────────────────
// Per-task open lock (TOCTOU guard for the find-or-open core)
// ──────────────────────────────────────────────────────────────────────
//
// Opening a console session is a check-then-act sequence: look up any
// existing (task, connection) session, enforce the per-task / per-user caps,
// open the SSH channel, then register. Two concurrent opens for the SAME task
// must not both pass the cap check, and must not both register a fresh session
// for the same (task, connection) — the later register() would silently
// overwrite (and orphan) the first, leaking its SSH client + PTY. We serialize
// the whole existence-check → cap-check → channel-open → register sequence per
// task with a minimal chained-promise mutex (opens are infrequent, so the
// per-task serialization cost is negligible).
const taskOpenLocks = new Map<string, Promise<void>>();
async function withTaskLock<T>(taskId: string, fn: () => Promise<T>): Promise<T> {
const prev = taskOpenLocks.get(taskId) ?? Promise.resolve();
let release!: () => void;
const gate = new Promise<void>((r) => {
release = r;
});
const chained = prev.then(() => gate);
taskOpenLocks.set(taskId, chained);
// Wait for the previous holder to release. Swallow its rejection — each
// holder is independent, so one open failing must not wedge the queue.
await prev.catch(() => {});
try {
return await fn();
} finally {
release();
// If nobody queued behind us, drop the map entry so it can't grow
// unbounded across tasks. Safe under JS's single-threaded model: any
// later waiter's synchronous set() has already replaced this entry
// before this finally runs.
if (taskOpenLocks.get(taskId) === chained) {
taskOpenLocks.delete(taskId);
}
}
}
/**
* Find-or-open a ConsoleSession bound to (taskId, connectionId). Runs the
* full preflight (piece membership, access decision, enabled / abuse /
@@ -111,6 +163,13 @@ export interface OpenConsoleResult {
* registers the ConsoleSession, enforces the per-connection cap, and writes
* the `ssh.console.open` audit (with `initiator` in the detail).
*
* Add-not-replace: a task may hold several sessions at once, one per
* connection. Opening a NEW connection adds a session; it never closes the
* task's other connections. Only `force_replace` on the SAME connection
* restarts that one shell. The whole sequence is serialized per task
* (`withTaskLock`) so concurrent opens can't exceed the caps or orphan a
* session by double-registering the same (task, connection).
*
* Returns a structured OpenConsoleResult. On the happy path / reuse,
* `ok: true` with `session` set; on any gate failure, `ok: false` with a
* structured `error` code + `message`.
@@ -119,7 +178,6 @@ export async function openConsoleSession(
deps: OpenConsoleDeps,
params: OpenConsoleParams,
): Promise<OpenConsoleResult> {
const { sub } = deps;
const connectionId = params.connectionId;
if (!connectionId) {
return { ok: false, error: 'missing_connection_id', message: 'SshConsoleEnsure: connection_id is required.' };
@@ -133,13 +191,30 @@ export async function openConsoleSession(
};
}
// If a session already exists for this task, branch on whether it's the
// same connection. Same → reuse. Different → reject by default (so a
// single LLM connection_id slip can't kill the user's live shell), opt
// into the swap with force_replace=true.
const existing = sub.sessionRegistry.get(localTaskId);
// Serialize the existence-check → cap-check → channel-open → register for
// this task. See withTaskLock above for why (TOCTOU on caps + register).
return withTaskLock(localTaskId, () => openConsoleSessionLocked(deps, params, connectionId, localTaskId));
}
async function openConsoleSessionLocked(
deps: OpenConsoleDeps,
params: OpenConsoleParams,
connectionId: string,
localTaskId: string,
): Promise<OpenConsoleResult> {
const { sub } = deps;
// Existence check is per (task, connection) now. Same connection already
// live → reuse (or restart on force_replace). A DIFFERENT connection is
// simply a new session below — opening it never touches other connections.
const existing = sub.sessionRegistry.get(localTaskId, connectionId);
if (existing) {
if (existing.connectionId === connectionId) {
if (params.forceReplace === true) {
// Same-connection restart: close ONLY this connection's session, then
// fall through to open a fresh one. Sibling sessions on other
// connections of this task are left untouched.
await sub.sessionRegistry.closeSession(localTaskId, connectionId, 'connection_change');
} else {
return {
ok: true,
alreadyActive: true,
@@ -149,22 +224,40 @@ export async function openConsoleSession(
session: existing,
};
}
const forceReplace = params.forceReplace === true;
if (!forceReplace) {
const ageSec = Math.max(0, Math.floor((Date.now() - existing.startedAt) / 1000));
const idleSec = Math.max(0, Math.floor((Date.now() - existing.lastActivityAt) / 1000));
}
// Per-task cap: how many live sessions this task already holds across all
// its connections. At/over the cap → refuse, closing nothing.
const maxSessionsPerTask = sub.config.console.maxSessionsPerTask;
if (
typeof maxSessionsPerTask === 'number' &&
sub.sessionRegistry.listForTask(localTaskId).length >= maxSessionsPerTask
) {
return {
ok: false,
error: 'task_session_cap',
message:
`SshConsoleEnsure: this task already has the maximum of ${maxSessionsPerTask} open console ` +
`sessions. Close one (or reuse an existing connection) before opening another.`,
};
}
// Per-user cap: how many live sessions this initiator owns across ALL
// tasks/connections. 0 = unlimited. At/over the cap → refuse.
const maxSessionsPerUser = sub.config.console.maxSessionsPerUser;
if (typeof maxSessionsPerUser === 'number' && maxSessionsPerUser > 0) {
const ownedByUser = sub.sessionRegistry
.listAll()
.filter((s) => s.startedByUserId === params.userId).length;
if (ownedByUser >= maxSessionsPerUser) {
return {
ok: false,
error: 'connection_change_requires_force',
connectionId: existing.connectionId,
error: 'user_session_cap',
message:
`SshConsoleEnsure: this task already has an active session on connection ${existing.connectionId} ` +
`(age=${ageSec}s, last_activity=${idleSec}s ago). ` +
`Use connection_id="${existing.connectionId}" to continue working in the existing shell, ` +
`or pass force_replace=true to close it and open a new session on ${connectionId}.`,
`SshConsoleEnsure: you already have the maximum of ${maxSessionsPerUser} open console ` +
`sessions across all tasks. Close one before opening another.`,
};
}
await sub.sessionRegistry.closeForTask(localTaskId, 'connection_change');
}
// Build the minimal ToolContext-shaped object the shared `preflight`
@@ -180,6 +273,11 @@ export async function openConsoleSession(
jobId: params.jobId ?? undefined,
pieceName: params.pieceName,
allowedSshConnections: params.allowedConnections,
// Per-space isolation (spec §11): thread the task/job space so the shared
// preflight's resolveAccess gates space-scoped connections identically to
// SshExec. Without this every workspace-registered connection is denied
// with `space_mismatch`.
spaceId: params.spaceId ?? undefined,
} as ToolContext;
// Full 12-step preflight (same path as SshExec).
@@ -334,10 +432,12 @@ export async function openConsoleSession(
'success',
);
// Enforce the per-connection session cap (evict oldest).
// Enforce the per-connection session cap (evict oldest). Close each evicted
// session by its exact (task, connection) key — NOT closeForTask, which
// would collaterally kill other connections the evicted task holds.
const evict = sub.sessionRegistry.enforceCap(connectionId);
for (const e of evict) {
sub.sessionRegistry.closeForTask(e.localTaskId, 'session_cap_evict').catch((err) =>
sub.sessionRegistry.closeSession(e.localTaskId, e.connectionId, 'session_cap_evict').catch((err) =>
logger.warn(`[ssh-console] evict close error: ${(err as Error).message}`),
);
}
@@ -345,6 +445,180 @@ export async function openConsoleSession(
return { ok: true, alreadyActive: false, connectionId, cols, rows, session };
}
// ──────────────────────────────────────────────────────────────────────
// Agent current-connection pointer + shared session routing (Task 4)
// ──────────────────────────────────────────────────────────────────────
//
// Task 3 made openConsoleSession add-not-replace, so a task can now hold
// several live console sessions at once (one per connection). Send / Run /
// Snapshot need to resolve an omitted connection_id to the RIGHT one rather
// than an arbitrary "the" active session. We track, per task, which
// connection the agent last acted on (Ensure/Send/Run/Snapshot) and use
// that as the default when connection_id is omitted and there's more than
// one live session.
//
// Populated by ensureSessionInternal (the agent-facing wrapper) — NOT by
// the shared openConsoleSession core, which is also used by the
// human-initiated HTTP console endpoint. Attributing a human's console
// open to "the agent's current connection" would be wrong.
const agentCurrentConnection = new Map<string, string>();
function markAgentConnection(taskId: string, connectionId: string): void {
agentCurrentConnection.set(taskId, connectionId);
}
/** Test-only: clear pointer state between test cases (module-scope Map
* would otherwise leak taskId reuse — e.g. 'task-1' — across test files). */
export function __resetAgentConnectionPointer(): void {
agentCurrentConnection.clear();
}
export type ResolveSessionResult =
| { ok: true; session: ConsoleSession; connectionId: string }
| { ok: false; error: 'not_found'; connectionId: string }
| { ok: false; error: 'ambiguous'; connectionIds: string[] }
| { ok: false; error: 'none' };
/**
* Shared routing for SshConsoleSend / SshConsoleRun / SshConsoleSnapshot
* when connection_id may be omitted:
*
* 1. connection_id given → exact (taskId, connectionId) lookup. No live
* session there → 'not_found'. We do NOT auto-open here: silently
* opening a fresh session for an explicit-but-wrong id would mask a
* typo'd connection_id instead of surfacing it.
* 2. else the agent's current-connection pointer, if set and still live.
* A pointer whose session has since closed is treated as unset (and
* dropped) rather than resolved.
* 3. else, if the task has exactly one live session, that one
* (unambiguous single-session case — most tasks).
* 4. else, if the task has more than one live session, 'ambiguous' — the
* agent must disambiguate with connection_id.
* 5. else 'none' — no live session at all for this task.
*/
export function resolveTargetSession(
sub: SshSubsystem,
taskId: string,
connectionIdMaybe: string | undefined,
): ResolveSessionResult {
if (connectionIdMaybe) {
const session = sub.sessionRegistry.get(taskId, connectionIdMaybe);
if (!session) return { ok: false, error: 'not_found', connectionId: connectionIdMaybe };
return { ok: true, session, connectionId: connectionIdMaybe };
}
const pointerConnId = agentCurrentConnection.get(taskId);
if (pointerConnId) {
const session = sub.sessionRegistry.get(taskId, pointerConnId);
if (session) return { ok: true, session, connectionId: pointerConnId };
// Stale pointer (session closed since) — drop it and fall through to
// the listForTask-based resolution below.
agentCurrentConnection.delete(taskId);
}
const sessions = sub.sessionRegistry.listForTask(taskId);
if (sessions.length === 1) {
const only = sessions[0]!;
return { ok: true, session: only, connectionId: only.connectionId };
}
if (sessions.length > 1) {
return { ok: false, error: 'ambiguous', connectionIds: sessions.map((s) => s.connectionId) };
}
return { ok: false, error: 'none' };
}
/**
* Screen-auto-inject lookup consumed by buildGlobalPreamble (prompt.ts) via
* __setActiveSessionLookup — shows the agent every one of its live console
* screens at the top of each movement's system prompt (one block per open
* connection). Returns ALL live sessions for the task, unfiltered; prompt.ts
* is responsible for scoping the result down to the current movement's
* `allowedSshConnections` (Task 5) — this function must not do that
* filtering itself, since it has no visibility into which movement is
* asking.
*/
export function listAgentConsoleSessions(sub: SshSubsystem, taskId: string): ConsoleSession[] {
return sub.sessionRegistry.listForTask(taskId);
}
/**
* Manually close a single (task, connection) console session on behalf of a
* human actor (task owner/admin or the session's own starter — the HTTP route
* enforces that gate). Closes via the registry with reason `user_close` AND
* records an audit row naming the ACTOR.
*
* Why the extra audit row: ConsoleSession.close() already writes an
* `ssh.console.close` audit, but it attributes the action to
* `startedByUserId` — correct for idle/duration/transport teardown, WRONG for
* a manual close by a different user (e.g. an admin killing someone's stray
* session). This row captures "who actually pressed close" with the original
* starter preserved in the detail for forensics.
*
* Returns `{ ok: false, error: 'not_found' }` when no live session exists for
* the pair (raced with idle sweep / another close) so the caller can 404.
*/
export async function closeConsoleSessionByUser(
sub: SshSubsystem,
args: { taskId: string; connectionId: string; actorUserId: string },
): Promise<{ ok: boolean; error?: 'not_found' }> {
const session = sub.sessionRegistry.get(args.taskId, args.connectionId);
if (!session) return { ok: false, error: 'not_found' };
sub.auditRepo.beginAndComplete(
{
action: 'ssh.console.close',
connectionId: args.connectionId,
ownerId: session.ownerId,
actingUserId: args.actorUserId,
detail: {
reason: 'user_close',
initiator: 'user',
manual: true,
started_by_user_id: session.startedByUserId,
},
},
'success',
);
await sub.sessionRegistry.closeSession(args.taskId, args.connectionId, 'user_close');
return { ok: true };
}
/** No-session-at-all wording differs slightly per tool (kept close to the
* pre-Task-4 text so existing agent guidance / tests stay stable). */
function noActiveSessionMessage(toolName: string): string {
if (toolName === 'SshConsoleSnapshot') {
return `${toolName}: no live session for this task. Call SshConsoleEnsure({connection_id}) first.`;
}
return (
`${toolName}: connection_id is required when this task has no active session. ` +
`Call SshListConnections() to find the right UUID, then SshConsoleEnsure({connection_id}) to open one.`
);
}
function notFoundMessage(toolName: string, connectionId: string): string {
if (toolName === 'SshConsoleSnapshot') {
return `${toolName}: no live session for connection ${connectionId} on this task. Call SshConsoleEnsure({connection_id: "${connectionId}"}) first.`;
}
return `${toolName}: no session for connection ${connectionId}; call SshConsoleEnsure({connection_id: "${connectionId}"}) first.`;
}
function ambiguousMessage(toolName: string, connectionIds: string[]): string {
return `${toolName}: connection_id required (multiple sessions open: ${connectionIds.join(', ')}).`;
}
function resolveErrorToToolResult(
toolName: string,
resolved: Exclude<ResolveSessionResult, { ok: true }>,
): ToolResult {
switch (resolved.error) {
case 'not_found':
return err(notFoundMessage(toolName, resolved.connectionId));
case 'ambiguous':
return err(ambiguousMessage(toolName, resolved.connectionIds));
case 'none':
return err(noActiveSessionMessage(toolName));
}
}
// ──────────────────────────────────────────────────────────────────────
// Tool definitions
// ──────────────────────────────────────────────────────────────────────
@@ -355,7 +629,7 @@ export const TOOL_DEFS: Record<string, ToolDef> = {
function: {
name: 'SshConsoleEnsure',
description:
'SSH console セッションを確保します(無ければ open、有れば再利用)。既存セッションが別 connection_id にある場合はデフォルトでエラー、force_replace=true で強制置換。詳細は ReadToolDoc({ name: "SshConsoleEnsure" })。',
'SSH console セッションを確保します(無ければ open、有れば再利用)。接続ごとに1セッションで、別の connection_id を指定すると既存を閉じずに新しいセッションを追加します(タスクあたりの上限あり)。force_replace=true は同じ接続のセッションを閉じて張り直す用途。詳細は ReadToolDoc({ name: "SshConsoleEnsure" })。',
parameters: {
type: 'object',
properties: {
@@ -364,7 +638,7 @@ export const TOOL_DEFS: Record<string, ToolDef> = {
rows: { type: 'number' },
force_replace: {
type: 'boolean',
description: '既存セッションが別 connection_id にあるとき、true なら旧セッションを閉じて新規 open。default=false (mismatch ならエラー返却)。',
description: '同じ connection_id の既存セッションを閉じて新規 open し直す(詰まり時の再接続用)。default=false。別の接続を開く場合は不要——自動で追加される。',
},
},
required: ['connection_id'],
@@ -376,11 +650,11 @@ export const TOOL_DEFS: Record<string, ToolDef> = {
function: {
name: 'SshConsoleSend',
description:
'console に入力を送る。通常のシェルコマンド実行には SshConsoleRun を使うこと(blocking + exit_code 取得)。SshConsoleSend は対話操作 (vim/REPL/sudo/TUI) と本当の中断専用。printable な shell コマンドには server が自動で末尾に "\\n" を付加して実行する (例: "ls -la" でも実行される)。TUI 操作 / sudo password / control byte (Ctrl-C 等) は raw のまま送られるので "\\n" を含めるかどうかは呼び出し側次第。connection_id はタスクに active session があれば省略可。詳細は ReadToolDoc({ name: "SshConsoleSend" })。',
'console に入力を送る。通常のシェルコマンド実行には SshConsoleRun を使うこと(blocking + exit_code 取得)。SshConsoleSend は対話操作 (vim/REPL/sudo/TUI) と本当の中断専用。printable な shell コマンドには server が自動で末尾に "\\n" を付加して実行する (例: "ls -la" でも実行される)。TUI 操作 / sudo password / control byte (Ctrl-C 等) は raw のまま送られるので "\\n" を含めるかどうかは呼び出し側次第。connection_id は開いているセッションが1つなら省略可(複数なら必須)。詳細は ReadToolDoc({ name: "SshConsoleSend" })。',
parameters: {
type: 'object',
properties: {
connection_id: { type: 'string', description: '省略時はこのタスクの active session を自動採用。明示する場合は active session の id と一致する必要あり (mismatch はエラー)。' },
connection_id: { type: 'string', description: '省略時は、開いているセッションが1つならそれを自動採用(複数開いている場合は connection_id 必須)。明示するとその接続のセッションに送る。' },
input: { type: 'string' },
wait_ms: { type: 'number' },
confirm_interrupt: {
@@ -401,7 +675,7 @@ export const TOOL_DEFS: Record<string, ToolDef> = {
parameters: {
type: 'object',
properties: {
connection_id: { type: 'string', description: '省略時はこのタスクの active session を自動採用。' },
connection_id: { type: 'string', description: '省略時は、開いているセッションが1つならそれを自動採用(複数開いている場合は connection_id 必須)。' },
kind: { type: 'string', enum: ['screen', 'scrollback'] },
max_bytes: { type: 'number' },
},
@@ -419,7 +693,7 @@ export const TOOL_DEFS: Record<string, ToolDef> = {
type: 'object',
properties: {
command: { type: 'string', description: '実行するシェルコマンド。' },
connection_id: { type: 'string', description: '省略時はこのタスクの active session を自動採用。' },
connection_id: { type: 'string', description: '省略時は、開いているセッションが1つならそれを自動採用(複数開いている場合は connection_id 必須)。' },
timeout_ms: { type: 'number', description: 'タイムアウト(ms)。デフォルト 120000、最大 600000。タイムアウト時はコマンドを kill しない。' },
idle_ms: { type: 'number', description: '出力が途切れた後のアイドル判定時間(ms)。0=無効(デフォルト)。' },
},
@@ -509,12 +783,22 @@ async function ensureSessionInternal(
forceReplace: input.force_replace === true,
initiator: 'agent',
jobId: ctx.jobId ?? undefined,
// Per-space isolation (spec §11): the worker already resolved ctx.spaceId
// (personal-WS NULL→personal-space). Pass it so the console gate matches
// SshExec — otherwise workspace-registered connections fail space_mismatch.
spaceId: ctx.spaceId ?? null,
},
);
if (!result.ok || !result.session) {
return err(result.message ?? 'SshConsoleEnsure: failed to open session.');
}
// Record this as the agent's current connection for this task — Send /
// Run / Snapshot resolve an omitted connection_id to this pointer when
// the task has more than one live session. ctx.taskId is guaranteed
// non-empty here (openConsoleSession already rejected an empty taskId
// with 'missing_task_context' before result.ok could be true).
markAgentConnection(ctx.taskId as string, result.session.connectionId);
return { opened: !result.alreadyActive, session: result.session };
}
@@ -589,28 +873,18 @@ async function sendInput(
return err('SshConsoleSend: this tool requires a local task context (ctx.taskId).');
}
// Resolve connection_id: explicit arg or active session's id.
// - omitted + active session → use active session's connection_id (recommended)
// - omitted + no active session → instruct the agent to discover via SshListConnections
// - explicit + matches active → fine
// - explicit + mismatches active → reject with surface of the actual id (don't auto-swap)
let connectionId = typeof input.connection_id === 'string' ? input.connection_id : '';
const existingSession = sub.sessionRegistry.get(localTaskId);
if (!connectionId) {
if (!existingSession) {
return err(
'SshConsoleSend: connection_id is required when this task has no active session. ' +
'Call SshListConnections() to find the right UUID, then SshConsoleEnsure({connection_id}) to open one.',
);
}
connectionId = existingSession.connectionId;
} else if (existingSession && existingSession.connectionId !== connectionId) {
return err(
`SshConsoleSend: this task has an active session on connection ${existingSession.connectionId}, not ${connectionId}. ` +
`Either omit connection_id (uses the active one), pass connection_id="${existingSession.connectionId}", ` +
`or call SshConsoleEnsure({connection_id: "${connectionId}", force_replace: true}) to swap intentionally.`,
);
// Resolve the target session: explicit connection_id (exact lookup, no
// auto-open), else the agent's current-connection pointer, else the
// task's sole live session, else an ambiguity/no-session error. See
// resolveTargetSession for the full precedence (Task 4: a task can now
// hold several live sessions at once).
const connectionIdInput =
typeof input.connection_id === 'string' && input.connection_id ? input.connection_id : undefined;
const resolved = resolveTargetSession(sub, localTaskId, connectionIdInput);
if (!resolved.ok) {
return resolveErrorToToolResult('SshConsoleSend', resolved);
}
const { session, connectionId } = resolved;
// Many LLMs serialize tool args correctly so "\n" in input means an
// actual LF byte. But some surface escape sequences *literally*
@@ -644,18 +918,9 @@ async function sendInput(
);
}
// Find-or-open the session. We already resolved/validated connectionId
// above (either matches the existing session or there is none), so
// ensureSessionInternal will always reuse the live session or open a
// brand-new one without ever hitting its mismatch-reject path.
const ensured = await ensureSessionInternal({ connection_id: connectionId }, ctx, sub);
if ('isError' in ensured) return ensured;
const session = ensured.session;
// Re-run preflight to get the canonical connection for deny-list eval +
// audit logging. (ensureSessionInternal already did the same call but
// discarded the result; we want the latest snapshot in case admin
// policy changed between Ensure and Send.)
// Preflight to get the canonical connection for deny-list eval + audit
// logging (latest snapshot in case admin policy changed since the
// session was opened).
const pre = preflight({
toolName: 'SshExec',
connectionId,
@@ -741,6 +1006,11 @@ async function sendInput(
// (auto-newline was applied earlier — agent self-correction is no longer needed)
// Record this as the agent's current connection now that the write
// actually happened (the interrupt-guard early return above never
// reaches here, since it didn't write anything).
markAgentConnection(localTaskId, connectionId);
sub.auditRepo.beginAndComplete(
{
action: 'ssh.console.send',
@@ -799,26 +1069,17 @@ async function snapshot(
}
// Snapshot does NOT auto-Ensure — if there is no live session, the LLM
// should call SshConsoleEnsure / Send first.
const session = sub.sessionRegistry.get(localTaskId);
if (!session) {
return err(
'SshConsoleSnapshot: no live session for this task. Call SshConsoleEnsure({connection_id}) first.',
);
}
// Resolve connection_id: explicit arg or the active session's id.
// Mismatch (explicit id ≠ active session id) → reject with surface of
// the actual id so the LLM can self-correct.
let connectionId = typeof input.connection_id === 'string' ? input.connection_id : '';
if (!connectionId) {
connectionId = session.connectionId;
} else if (session.connectionId !== connectionId) {
return err(
`SshConsoleSnapshot: this task has an active session on connection ${session.connectionId}, not ${connectionId}. ` +
`Either omit connection_id (uses the active one) or pass connection_id="${session.connectionId}".`,
);
// should call SshConsoleEnsure / Send first. resolveTargetSession routes
// an omitted connection_id to the agent's current-connection pointer, the
// task's sole live session, or a clear ambiguity/no-session error (Task 4:
// a task can now hold several live sessions at once).
const connectionIdInput =
typeof input.connection_id === 'string' && input.connection_id ? input.connection_id : undefined;
const resolved = resolveTargetSession(sub, localTaskId, connectionIdInput);
if (!resolved.ok) {
return resolveErrorToToolResult('SshConsoleSnapshot', resolved);
}
const { session, connectionId } = resolved;
// Preflight (for audit + access check; we still want to see read attempts
// in the audit log).
@@ -832,6 +1093,10 @@ async function snapshot(
if (!pre.ok) return pre.error;
const { connection, actingUserId, pieceName } = pre;
// Record this as the agent's current connection now that access has been
// confirmed (the operation cannot fail past this point).
markAgentConnection(localTaskId, connectionId);
const kind = input.kind === 'scrollback' ? 'scrollback' : 'screen';
if (kind === 'screen') {
@@ -952,28 +1217,17 @@ async function run(
return err('SshConsoleRun: this tool requires a local task context (ctx.taskId).');
}
// Resolve connection_id: explicit arg or active session's id.
let connectionId = typeof input.connection_id === 'string' ? input.connection_id : '';
const existingSession = sub.sessionRegistry.get(localTaskId);
if (!connectionId) {
if (!existingSession) {
return err(
'SshConsoleRun: connection_id is required when this task has no active session. ' +
'Call SshListConnections() to find the right UUID, then SshConsoleEnsure({connection_id}) to open one.',
);
}
connectionId = existingSession.connectionId;
} else if (existingSession && existingSession.connectionId !== connectionId) {
return err(
`SshConsoleRun: this task has an active session on connection ${existingSession.connectionId}, not ${connectionId}. ` +
`Either omit connection_id (uses the active one) or pass connection_id="${existingSession.connectionId}".`,
);
// Resolve the target session: explicit connection_id (exact lookup, no
// auto-open), else the agent's current-connection pointer, else the
// task's sole live session, else an ambiguity/no-session error (Task 4:
// a task can now hold several live sessions at once).
const connectionIdInput =
typeof input.connection_id === 'string' && input.connection_id ? input.connection_id : undefined;
const resolved = resolveTargetSession(sub, localTaskId, connectionIdInput);
if (!resolved.ok) {
return resolveErrorToToolResult('SshConsoleRun', resolved);
}
// Find-or-open session.
const ensured = await ensureSessionInternal({ connection_id: connectionId }, ctx, sub);
if ('isError' in ensured) return ensured;
const session = ensured.session;
const { session, connectionId } = resolved;
// Preflight for audit + access check.
const pre = preflight({
@@ -986,6 +1240,11 @@ async function run(
if (!pre.ok) return pre.error;
const { connection, actingUserId, pieceName } = pre;
// Record this as the agent's current connection now that access has been
// confirmed (the run below can still time out / be interrupted, but it
// did act on this connection).
markAgentConnection(localTaskId, connectionId);
// Deny-check the command.
const denyResult = checkConsoleInput(
command + '\n',
+7 -3
View File
@@ -372,14 +372,18 @@ describe.skipIf(skip)('engine/tools/ssh-console e2e (in-process ssh2 server)', (
async function waitForScreen(taskId: string, needle: string, timeoutMs = 2000): Promise<string> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const session = sessionRegistry.get(taskId);
// The registry is keyed by (taskId, connectionId) now (Task 5); every
// test in this describe block opens a single connection (`connId`,
// set in beforeEach), so it's the right second key everywhere this
// helper is called.
const session = sessionRegistry.get(taskId, connId);
if (session) {
const text = session.snapshotScreen().text;
if (text.includes(needle)) return text;
}
await new Promise((res) => setTimeout(res, 50));
}
const session = sessionRegistry.get(taskId);
const session = sessionRegistry.get(taskId, connId);
return session ? session.snapshotScreen().text : '';
}
@@ -460,6 +464,6 @@ describe.skipIf(skip)('engine/tools/ssh-console e2e (in-process ssh2 server)', (
const secondData = JSON.parse(second!.output);
expect(secondData.opened).toBe(false);
// Same registry slot still holds the live session.
expect(sessionRegistry.get('5')).not.toBeNull();
expect(sessionRegistry.get('5', connId)).not.toBeNull();
});
});
+6 -6
View File
@@ -211,18 +211,18 @@ describe('engine/tools/ssh — preflight (piece + access + state)', () => {
afterEach(() => setSshSubsystem(null));
it('rejects when allowed_ssh_connections is undefined', async () => {
it('rejects when no SSH connections are available (allowed undefined)', async () => {
const ctx = await ctxWithWorkspace({}); // allowed: undefined
const r = await executeTool('SshExec', { connection_id: connId, command: 'ls' }, ctx);
expect(r.isError).toBe(true);
expect(r.output).toMatch(/does not declare allowed_ssh_connections/);
expect(r.output).toMatch(/no SSH connections are available for this workspace/);
});
it('rejects when connection_id is not in allowed list', async () => {
it('rejects when connection_id is not registered to the workspace', async () => {
const ctx = await ctxWithWorkspace({ allowed: ['00000000-other'] });
const r = await executeTool('SshExec', { connection_id: connId, command: 'ls' }, ctx);
expect(r.isError).toBe(true);
expect(r.output).toMatch(/not in this piece's allowed_ssh_connections/);
expect(r.output).toMatch(/is not registered to this workspace/);
const rows = repos.audit.listForConnection(connId, 10);
expect(rows[0].outcome).toBe('denied');
expect(rows[0].detail).toMatchObject({ reason: 'piece_not_allowed' });
@@ -647,11 +647,11 @@ describe('engine/tools/ssh SshListConnections', () => {
db.close();
});
it('rejects when piece does not declare allowed_ssh_connections', async () => {
it('rejects when no SSH connections are available for the workspace', async () => {
const ctx = await ctxWithWorkspace({ allowed: undefined });
const r = await executeTool('SshListConnections', {}, ctx);
expect(r.isError).toBe(true);
expect(r.output).toMatch(/does not declare allowed_ssh_connections/);
expect(r.output).toMatch(/no SSH connections are available for this workspace/);
});
it('returns empty array when no connections exist', async () => {
+5 -5
View File
@@ -111,7 +111,7 @@ export const TOOL_DEFS: Record<string, ToolDef> = {
properties: {
connection_id: {
type: 'string',
description: '使用する SSH 接続の UUID。piece の allowed_ssh_connections に含まれている必要があります。',
description: '使用する SSH 接続の UUID。設定→SSH でこのワークスペースに登録された接続である必要があります。',
},
command: {
type: 'string',
@@ -382,12 +382,12 @@ function preflight(args: PreflightArgs): PreflightResult {
const pieceName = ctx.pieceName;
const allowed = ctx.allowedSshConnections;
// Step 2: piece must declare allowed_ssh_connections
// Step 2: workspace must have SSH connections resolved for this job's space.
if (allowed === undefined) {
return {
ok: false,
error: err(
`${toolName}: this movement does not declare allowed_ssh_connections. Add the field to the piece YAML.`,
`${toolName}: no SSH connections are available for this workspace. Register a connection under Settings → SSH (and enable the SSH tool category).`,
),
};
}
@@ -416,7 +416,7 @@ function preflight(args: PreflightArgs): PreflightResult {
return {
ok: false,
error: err(
`${toolName}: connection ${connectionId} is not in this piece's allowed_ssh_connections list.`,
`${toolName}: connection ${connectionId} is not registered to this workspace. Register it under Settings → SSH.`,
),
};
}
@@ -1007,7 +1007,7 @@ async function runListConnections(
const allowed = ctx.allowedSshConnections;
if (allowed === undefined) {
return err(
'SshListConnections: this movement does not declare allowed_ssh_connections. Add the field to the piece YAML.',
'SshListConnections: no SSH connections are available for this workspace. Register a connection under Settings → SSH (and enable the SSH tool category).',
);
}
+204
View File
@@ -0,0 +1,204 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { executeTool, TOOL_DEFS } from './task-conversation.js';
import type { ToolContext, TaskConversationComment, TaskConversationIO } from './core.js';
import { Repository } from '../../db/repository.js';
let tempDir = '';
function writeTranscript(name: string, lines: object[]): string {
const p = join(tempDir, name);
writeFileSync(p, lines.map((l) => JSON.stringify({ ...l, _meta: { ts: Date.now() } })).join('\n') + '\n');
return p;
}
function makeCtx(io: TaskConversationIO | undefined): ToolContext {
return { workspacePath: tempDir, taskId: '1', taskConversation: io } as unknown as ToolContext;
}
function makeIO(comments: TaskConversationComment[], transcriptPath?: string): TaskConversationIO {
return { listComments: async () => comments, transcriptPath };
}
const commentsA: TaskConversationComment[] = [
{ id: 10, author: 'user', kind: 'request', body: 'Please keep the existing auth flow unchanged.', createdAt: '2026-06-30T10:00:00Z' },
{ id: 11, author: 'agent', kind: 'progress', body: 'Inspected the login module.', createdAt: '2026-06-30T10:05:00Z' },
{ id: 12, author: 'user', kind: 'interjection', body: 'Do not rewrite the unrelated UI components.', createdAt: '2026-06-30T10:10:00Z' },
];
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'maestro-tc-'));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
describe('TOOL_DEFS', () => {
it('registers both conversation tools', () => {
expect(TOOL_DEFS.SearchTaskConversation).toBeDefined();
expect(TOOL_DEFS.ReadTaskConversation).toBeDefined();
});
});
describe('SearchTaskConversation', () => {
it('finds old user comments by keyword (case-insensitive)', async () => {
const ctx = makeCtx(makeIO(commentsA));
const res = await executeTool('SearchTaskConversation', { query: 'AUTH flow', source: 'comments' }, ctx);
expect(res!.isError).toBe(false);
expect(res!.output).toContain('comment:10');
expect(res!.output).not.toContain('comment:11'); // no match
expect(res!.output).not.toContain('comment:12');
});
it('finds transcript entries when transcript.jsonl exists, with stable refs', async () => {
const tp = writeTranscript('transcript.jsonl', [
{ role: 'system', content: 'preamble' },
{ role: 'user', content: 'Constraint: never touch the payment code.' },
{ role: 'assistant', content: 'Understood, I will avoid the payment module.' },
]);
const ctx = makeCtx(makeIO([], tp));
const res = await executeTool('SearchTaskConversation', { query: 'payment', source: 'transcript' }, ctx);
expect(res!.isError).toBe(false);
expect(res!.output).toContain('transcript:1');
expect(res!.output).toContain('transcript:2');
});
it('filters by author', async () => {
const ctx = makeCtx(makeIO(commentsA));
const res = await executeTool('SearchTaskConversation', { query: 'the', author: 'user' }, ctx);
expect(res!.output).toContain('comment:10');
expect(res!.output).toContain('comment:12');
expect(res!.output).not.toContain('comment:11');
});
it('caps results at the limit and truncates excerpts to a bounded length', async () => {
const many: TaskConversationComment[] = Array.from({ length: 30 }, (_, i) => ({
id: i + 1,
author: 'user',
kind: 'comment',
body: 'needle ' + 'x'.repeat(500),
createdAt: '2026-06-30T10:00:00Z',
}));
const ctx = makeCtx(makeIO(many));
const res = await executeTool('SearchTaskConversation', { query: 'needle', limit: 3 }, ctx);
const refCount = (res!.output.match(/comment:\d+/g) ?? []).length;
expect(refCount).toBe(3);
// No single line should carry the full 500-char body.
for (const line of res!.output.split('\n')) {
expect(line.length).toBeLessThan(260);
}
expect(res!.output).toContain('…');
});
it('rejects an empty query', async () => {
const ctx = makeCtx(makeIO(commentsA));
const res = await executeTool('SearchTaskConversation', { query: ' ' }, ctx);
expect(res!.isError).toBe(true);
});
it('a kind filter excludes the transcript source entirely', async () => {
const tp = writeTranscript('transcript.jsonl', [{ role: 'user', content: 'needle in transcript' }]);
const ctx = makeCtx(makeIO([{ id: 1, author: 'user', kind: 'request', body: 'needle in comment', createdAt: '2026-06-30T10:00:00Z' }], tp));
const res = await executeTool('SearchTaskConversation', { query: 'needle', kind: 'request' }, ctx);
expect(res!.output).toContain('comment:1');
expect(res!.output).not.toContain('transcript:'); // kind is a comment concept
});
it('exposes no transcript when the transcript path is unbound (security fallback)', async () => {
// The worker binds transcriptPath only to a per-task runtimeDir; when it is
// unset the transcript source must yield nothing rather than risk a
// shared-workspace leak. Comments still work.
const ctx = makeCtx(makeIO([{ id: 1, author: 'user', kind: 'request', body: 'needle here', createdAt: '2026-06-30T10:00:00Z' }], undefined));
const res = await executeTool('SearchTaskConversation', { query: 'needle', source: 'transcript' }, ctx);
expect(res!.isError).toBe(false);
expect(res!.output).not.toContain('transcript:');
});
it('skips malformed transcript lines instead of crashing', async () => {
const p = join(tempDir, 'transcript.jsonl');
writeFileSync(p, ['{ not json', JSON.stringify({ role: 'user', content: 'needle ok' })].join('\n') + '\n');
const ctx = makeCtx(makeIO([], p));
const res = await executeTool('SearchTaskConversation', { query: 'needle', source: 'transcript' }, ctx);
expect(res!.isError).toBe(false);
expect(res!.output).toContain('transcript:1'); // index 0 was the malformed line
});
it('caps limit at 50 even when a larger value is requested', async () => {
const many: TaskConversationComment[] = Array.from({ length: 80 }, (_, i) => ({
id: i + 1, author: 'user', kind: 'comment', body: 'needle', createdAt: '2026-06-30T10:00:00Z',
}));
const ctx = makeCtx(makeIO(many));
const res = await executeTool('SearchTaskConversation', { query: 'needle', limit: 999 }, ctx);
const refCount = (res!.output.match(/comment:\d+/g) ?? []).length;
expect(refCount).toBe(50);
});
it('degrades to a friendly non-error message when no task context is bound', async () => {
const ctx = makeCtx(undefined);
const res = await executeTool('SearchTaskConversation', { query: 'x' }, ctx);
expect(res!.isError).toBe(false);
expect(res!.output.length).toBeGreaterThan(0);
});
});
// P0/P1 (review): the REAL scoping guarantee comes from
// repo.makeTaskConversationIO(taskId) → listLocalTaskComments(taskId) WHERE
// task_id = ?. Exercise it end-to-end with a real temp-DB Repository and two
// tasks; task A's tool must not see task B's comment. This has teeth: rebinding
// the IO to the wrong taskId turns it red.
describe('SearchTaskConversation — real repository task scoping (integration)', () => {
it('cannot see another task\'s comments through the real per-task IO', async () => {
const repo = new Repository(join(tempDir, 'scope.db'));
const a = await repo.createLocalTask({ title: 'A', body: 'a' });
const b = await repo.createLocalTask({ title: 'B', body: 'b' });
await repo.addLocalTaskComment(a.id, 'user', 'Task A: keep the AUTHFLOW unchanged', 'request');
await repo.addLocalTaskComment(b.id, 'user', 'Task B AUTHFLOW SECRET must not leak', 'request');
const ioA = repo.makeTaskConversationIO(a.id);
const ctxA = { workspacePath: tempDir, taskId: String(a.id), taskConversation: ioA } as unknown as ToolContext;
const res = await executeTool('SearchTaskConversation', { query: 'AUTHFLOW', source: 'comments' }, ctxA);
expect(res!.output).toContain('keep the AUTHFLOW');
expect(res!.output).not.toContain('SECRET'); // task B must never leak into task A
});
});
describe('ReadTaskConversation', () => {
it('returns bounded neighbors around a comment ref', async () => {
const ctx = makeCtx(makeIO(commentsA));
const res = await executeTool('ReadTaskConversation', { ref: 'comment:11', before: 1, after: 1 }, ctx);
expect(res!.isError).toBe(false);
expect(res!.output).toContain('comment:10');
expect(res!.output).toContain('comment:11');
expect(res!.output).toContain('comment:12');
});
it('clamps before/after to at most 5 each', async () => {
const tp = writeTranscript('transcript.jsonl', Array.from({ length: 40 }, (_, i) => ({ role: 'user', content: `line ${i}` })));
const ctx = makeCtx(makeIO([], tp));
const res = await executeTool('ReadTaskConversation', { ref: 'transcript:20', before: 100, after: 100 }, ctx);
const refCount = (res!.output.match(/transcript:\d+/g) ?? []).length;
expect(refCount).toBeLessThanOrEqual(11); // 5 before + self + 5 after
});
it('rejects a malformed ref', async () => {
const ctx = makeCtx(makeIO(commentsA));
const res = await executeTool('ReadTaskConversation', { ref: 'bogus' }, ctx);
expect(res!.isError).toBe(true);
});
it('reports a well-formed but non-existent ref as an error', async () => {
const ctx = makeCtx(makeIO(commentsA));
const res = await executeTool('ReadTaskConversation', { ref: 'comment:999' }, ctx);
expect(res!.isError).toBe(true);
expect(res!.output).toContain('999');
});
it('degrades to a friendly non-error message when no task context is bound', async () => {
const ctx = makeCtx(undefined);
const res = await executeTool('ReadTaskConversation', { ref: 'comment:10' }, ctx);
expect(res!.isError).toBe(false);
expect(res!.output.length).toBeGreaterThan(0);
});
});
+241
View File
@@ -0,0 +1,241 @@
/**
* Conversation recall tools — SearchTaskConversation / ReadTaskConversation.
*
* META tools (always available) that let the agent retrieve older parts of the
* CURRENT task's conversation instead of asking the user to repeat themselves
* or stuffing the whole transcript into prompt context.
*
* Sources:
* - DB local task comments (user requests / interjections / agent progress),
* via the per-task `listComments()` accessor.
* - `transcript.jsonl` (the raw ReAct thread), read line-by-line.
*
* Scoping is enforced by the bound `ctx.taskConversation` IO: comments come from
* `listLocalTaskComments(taskId)` (WHERE task_id = ?), and the transcript path is
* bound by the worker ONLY to a per-task `runtimeDir` — never the space-shared
* `workspacePath/logs` fallback — so a tool call can never read another task's
* logs. When the transcript path is unset the transcript source yields nothing
* (comments still work); when the whole IO is unset (subtasks / gitea-issue runs)
* the tools degrade to a friendly non-error message.
*/
import { existsSync, readFileSync } from 'node:fs';
import { ToolDef } from '../../llm/openai-compat.js';
import type { ToolContext, ToolResult, TaskConversationComment } from './core.js';
const SEARCH_DEF: ToolDef = {
type: 'function',
function: {
name: 'SearchTaskConversation',
description:
'このタスクの過去の会話 (コメント + transcript) をキーワードで検索し、出典 ref 付きの短い抜粋を返す。長い/継続タスクでユーザーに聞き直す前に過去の制約・判断を思い出すために使う。詳細は ReadToolDoc({ name: "SearchTaskConversation" })。',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: '検索キーワード (部分一致・大文字小文字無視)。' },
source: { type: 'string', enum: ['comments', 'transcript', 'both'], description: '検索対象。既定 both。' },
author: { type: 'string', enum: ['user', 'agent', 'system'], description: '発言者で絞り込む。' },
kind: { type: 'string', description: 'コメント種別で絞り込む (request/comment/interjection/result/ask/progress/handoff)。transcript には適用されない。' },
limit: { type: 'number', description: '最大件数。既定 10、最大 50。' },
},
required: ['query'],
},
},
};
const READ_DEF: ToolDef = {
type: 'function',
function: {
name: 'ReadTaskConversation',
description:
'SearchTaskConversation が返した ref (comment:<id> または transcript:<index>) の前後を数件だけ読み、当時の文脈を確認する。詳細は ReadToolDoc({ name: "ReadTaskConversation" })。',
parameters: {
type: 'object',
properties: {
ref: { type: 'string', description: '"comment:123" または "transcript:42"。' },
before: { type: 'number', description: '前に含める件数。既定 2、最大 5。' },
after: { type: 'number', description: '後に含める件数。既定 2、最大 5。' },
},
required: ['ref'],
},
},
};
export const TOOL_DEFS: Record<string, ToolDef> = {
SearchTaskConversation: SEARCH_DEF,
ReadTaskConversation: READ_DEF,
};
const NO_CONTEXT_MSG =
'会話検索はこのコンテキストでは利用できません (subtask など local_task と紐付かない実行)。現在見えている文脈で判断してください。';
const EXCERPT_MAX = 200;
const READ_EXCERPT_MAX = 500;
type Author = 'user' | 'agent' | 'system';
interface TranscriptEntry {
index: number;
author: Author;
content: string;
}
/** Map a transcript message role to a coarse author bucket. */
function roleToAuthor(role: unknown): Author {
if (role === 'user') return 'user';
if (role === 'assistant' || role === 'tool') return 'agent';
return 'system';
}
function loadTranscript(path: string | undefined): TranscriptEntry[] {
if (!path || !existsSync(path)) return [];
const out: TranscriptEntry[] = [];
const lines = readFileSync(path, 'utf8').split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
if (!line.trim()) continue;
try {
const parsed = JSON.parse(line) as { role?: unknown; content?: unknown };
const content = typeof parsed.content === 'string' ? parsed.content : '';
out.push({ index: i, author: roleToAuthor(parsed.role), content });
} catch {
/* skip malformed line */
}
}
return out;
}
function collapse(s: string): string {
return s.replace(/\s+/g, ' ').trim();
}
/** Compact excerpt centered on the first query hit, bounded to `max` chars. */
function excerpt(body: string, query: string, max: number): string {
const flat = collapse(body);
if (flat.length <= max && !query) return flat;
const idx = query ? flat.toLowerCase().indexOf(query.toLowerCase()) : 0;
if (idx < 0 || flat.length <= max) {
return flat.length <= max ? flat : flat.slice(0, max - 1) + '…';
}
const start = Math.max(0, idx - 40);
const end = Math.min(flat.length, start + max);
return (start > 0 ? '…' : '') + flat.slice(start, end) + (end < flat.length ? '…' : '');
}
function clampLimit(v: unknown, def: number, max: number): number {
const n = typeof v === 'number' && Number.isFinite(v) ? Math.floor(v) : def;
return Math.min(Math.max(n, 1), max);
}
function clampNeighbors(v: unknown, def: number, max: number): number {
const n = typeof v === 'number' && Number.isFinite(v) ? Math.floor(v) : def;
return Math.min(Math.max(n, 0), max);
}
async function search(input: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
const io = ctx.taskConversation;
if (!io) return { output: NO_CONTEXT_MSG, isError: false };
const query = typeof input['query'] === 'string' ? (input['query'] as string) : '';
if (!query.trim()) {
return { output: 'query が空です。検索キーワードを指定してください。', isError: true };
}
const source = (input['source'] as string) ?? 'both';
const author = input['author'] as Author | undefined;
const kind = typeof input['kind'] === 'string' ? (input['kind'] as string) : undefined;
const limit = clampLimit(input['limit'], 10, 50);
const q = query.toLowerCase();
type Hit = { ref: string; header: string; body: string };
const hits: Hit[] = [];
if (source === 'comments' || source === 'both') {
const comments = await io.listComments();
for (const c of comments) {
if (author && c.author !== author) continue;
if (kind && c.kind !== kind) continue;
if (!c.body.toLowerCase().includes(q)) continue;
hits.push({ ref: `comment:${c.id}`, header: `${c.author}/${c.kind} ${c.createdAt}`, body: c.body });
}
}
if (source === 'transcript' || source === 'both') {
// kind is a comment concept; transcript entries have none, so a kind filter
// excludes the transcript source entirely.
if (!kind) {
for (const e of loadTranscript(io.transcriptPath)) {
if (author && e.author !== author) continue;
if (!e.content.toLowerCase().includes(q)) continue;
hits.push({ ref: `transcript:${e.index}`, header: e.author, body: e.content });
}
}
}
if (hits.length === 0) {
return { output: `## Conversation Search Results\n\n"${query}" に一致する会話は見つかりませんでした。`, isError: false };
}
const shown = hits.slice(0, limit);
const lines = [`## Conversation Search Results (query: "${query}", ${hits.length} hit(s)${hits.length > limit ? `, showing ${limit}` : ''})`, ''];
for (const h of shown) {
lines.push(`- ${h.ref} ${h.header}`);
lines.push(` ${excerpt(h.body, query, EXCERPT_MAX)}`);
lines.push('');
}
lines.push('前後の文脈は ReadTaskConversation({ ref }) で確認できます。');
return { output: lines.join('\n'), isError: false };
}
async function read(input: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
const io = ctx.taskConversation;
if (!io) return { output: NO_CONTEXT_MSG, isError: false };
const ref = typeof input['ref'] === 'string' ? (input['ref'] as string) : '';
const m = /^(comment|transcript):(\d+)$/.exec(ref.trim());
if (!m) {
return { output: 'ref が不正です。"comment:<id>" または "transcript:<index>" を指定してください。', isError: true };
}
const kind = m[1] as 'comment' | 'transcript';
const id = Number(m[2]);
const before = clampNeighbors(input['before'], 2, 5);
const after = clampNeighbors(input['after'], 2, 5);
if (kind === 'comment') {
const comments = await io.listComments();
const pos = comments.findIndex((c) => c.id === id);
if (pos < 0) return { output: `comment:${id} は見つかりませんでした。`, isError: true };
const slice = comments.slice(Math.max(0, pos - before), pos + after + 1);
const lines = [`## ReadTaskConversation — comment #${id} (前 ${before} / 後 ${after})`, ''];
for (const c of slice) {
const marker = c.id === id ? ' ←' : '';
lines.push(`- comment:${c.id} ${c.author}/${c.kind} ${c.createdAt}${marker}`);
lines.push(` ${excerpt(c.body, '', READ_EXCERPT_MAX)}`);
}
return { output: lines.join('\n'), isError: false };
}
const entries = loadTranscript(io.transcriptPath);
const pos = entries.findIndex((e) => e.index === id);
if (pos < 0) return { output: `transcript:${id} は見つかりませんでした。`, isError: true };
const slice = entries.slice(Math.max(0, pos - before), pos + after + 1);
const lines = [`## ReadTaskConversation — transcript #${id} (前 ${before} / 後 ${after})`, ''];
for (const e of slice) {
const marker = e.index === id ? ' ←' : '';
lines.push(`- transcript:${e.index} ${e.author}${marker}`);
lines.push(` ${excerpt(e.content, '', READ_EXCERPT_MAX)}`);
}
return { output: lines.join('\n'), isError: false };
}
export async function executeTool(
name: string,
input: Record<string, unknown>,
ctx: ToolContext,
): Promise<ToolResult | null> {
if (name === 'SearchTaskConversation') return search(input, ctx);
if (name === 'ReadTaskConversation') return read(input, ctx);
return null;
}
// Keep the type import referenced for consumers of this module's typings.
export type { TaskConversationComment };
+180
View File
@@ -0,0 +1,180 @@
import { describe, expect, it } from 'vitest';
import iconv from 'iconv-lite';
import { detectTextEncoding, decodeToUtf8, encodeFromUtf8, decodeFileText, encodeFileText } from './text-decode.js';
const JP = 'こんにちは世界、これはテストです。\n日本語のテキスト。\n';
describe('detectTextEncoding', () => {
it('keeps plain ASCII as utf-8', () => {
const v = detectTextEncoding(Buffer.from('hello world\nsecond line\n', 'ascii'));
expect(v).toEqual({ binary: false, encoding: 'utf-8' });
});
it('keeps valid UTF-8 Japanese as utf-8', () => {
const v = detectTextEncoding(Buffer.from(JP, 'utf-8'));
expect(v).toEqual({ binary: false, encoding: 'utf-8' });
});
it('recovers Shift_JIS (CP932) Japanese text as text, not binary', () => {
const v = detectTextEncoding(iconv.encode(JP, 'Shift_JIS'));
expect(v.binary).toBe(false);
if (!v.binary) expect(v.encoding).toBe('shift_jis');
});
it('recovers EUC-JP Japanese text as text, not binary', () => {
const v = detectTextEncoding(iconv.encode(JP, 'EUC-JP'));
expect(v.binary).toBe(false);
if (!v.binary) expect(v.encoding).toBe('euc-jp');
});
it('distinguishes kana-heavy EUC-JP from Shift_JIS (tie-break, codex regression)', () => {
// Both decode without mojibake, but EUC-JP kana read as Shift_JIS lands in
// halfwidth katakana; the body-text tie-break must pick the right one.
const kana = 'あいうえお、こんにちは。さようなら\n';
const euc = detectTextEncoding(iconv.encode(kana, 'EUC-JP'));
expect(euc.binary).toBe(false);
if (!euc.binary) expect(euc.encoding).toBe('euc-jp');
const sjis = detectTextEncoding(iconv.encode(kana, 'Shift_JIS'));
expect(sjis.binary).toBe(false);
if (!sjis.binary) expect(sjis.encoding).toBe('shift_jis');
});
it('recovers windows-1252 / Latin-1 accented text as text', () => {
const v = detectTextEncoding(iconv.encode('café résumé naïve Zürich\n', 'windows-1252'));
expect(v.binary).toBe(false);
});
it('still reports a PNG as binary (magic byte)', () => {
const png = Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
Buffer.from('IHDR'),
Buffer.from([0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0]),
]);
const v = detectTextEncoding(png);
expect(v.binary).toBe(true);
});
it('still reports NUL-heavy binary as binary', () => {
const v = detectTextEncoding(Buffer.from([0x00, 0x01, 0x02, 0x00, 0x03, 0x00, 0xff, 0x00]));
expect(v.binary).toBe(true);
});
it('rejects high-entropy binary that windows-1252 would decode without replacement chars', () => {
// Regression (codex P2): bytes 0xA0–0xFF have no NUL/magic/C0 controls and
// decode cleanly under cp1252, but are a symbol soup — not real text.
const blob = Buffer.from(Array.from({ length: 100 }, (_, i) => 0xa0 + ((i * 37) % 96)));
const v = detectTextEncoding(blob);
expect(v.binary).toBe(true);
});
it('reports high-control random bytes (not valid UTF-8) as binary', () => {
// 0x81/0x8f... with lots of C0 controls: not utf-8, high control ratio → binary.
const junk = Buffer.from([
0x81, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x8f, 0x90, 0x9d,
]);
const v = detectTextEncoding(junk);
expect(v.binary).toBe(true);
});
});
describe('decodeToUtf8 / encodeFromUtf8 round-trip', () => {
it('decodes Shift_JIS bytes to the original UTF-8 string', () => {
const decoded = decodeToUtf8(iconv.encode(JP, 'Shift_JIS'), 'shift_jis');
expect(decoded).toBe(JP);
});
it('re-encodes a UTF-8 edit back into Shift_JIS bytes losslessly', () => {
const edited = JP.replace('テスト', 'サンプル');
const bytes = encodeFromUtf8(edited, 'shift_jis');
expect(iconv.decode(bytes, 'Shift_JIS')).toBe(edited);
// and the bytes are genuinely Shift_JIS (not utf-8): decoding as utf-8 differs
expect(bytes.toString('utf-8')).not.toBe(edited);
});
it('passes utf-8 through unchanged', () => {
expect(decodeToUtf8(Buffer.from(JP, 'utf-8'), 'utf-8')).toBe(JP);
expect(encodeFromUtf8(JP, 'utf-8').equals(Buffer.from(JP, 'utf-8'))).toBe(true);
});
});
describe('decodeFileText (whole-file detection)', () => {
it('detects Shift_JIS even when the first 8KB is pure ASCII', () => {
// Long ASCII header (> SNIFF_HEAD_BYTES) then Shift_JIS Japanese tail.
const header = Buffer.from('# '.padEnd(9000, 'header-comment ') + '\n', 'ascii');
const tail = iconv.encode('本文は日本語です。\n', 'Shift_JIS');
const full = Buffer.concat([header, tail]);
const r = decodeFileText(full);
expect(r.binary).toBe(false);
if (!r.binary) {
expect(r.encoding).toBe('shift_jis');
expect(r.text).toContain('本文は日本語です');
expect(r.text).not.toContain('�');
}
});
it('reads a pure UTF-8 file via the fast path', () => {
const r = decodeFileText(Buffer.from(JP, 'utf-8'));
expect(r).toEqual({ binary: false, encoding: 'utf-8', text: JP });
});
it('does not silently swallow a truncated trailing UTF-8 byte as empty text', () => {
// Regression (codex): streaming decode returned {binary:false, text:''} for a
// lone 0xE3; a complete valid body followed by a truncated byte must keep the body.
const lone = decodeFileText(Buffer.from([0xe3]));
expect(lone.binary === false && lone.text === '').toBe(false);
const truncated = decodeFileText(Buffer.concat([Buffer.from('あいう', 'utf-8'), Buffer.from([0xe3])]));
expect(truncated.binary).toBe(false);
if (!truncated.binary) expect(truncated.text).toContain('あいう');
});
it('rejects binary hidden behind a > 8KB ASCII prefix (NUL in the tail)', () => {
// Regression (codex): head-only detection saw pure ASCII and NUL is valid
// UTF-8, so a NUL-bearing tail slipped through as text. Whole-file NUL check.
const asciiHead = Buffer.from('a'.repeat(9000), 'ascii');
const nulTail = Buffer.from([0x00, 0x01, 0x02, 0x00, 0xff, 0x00]);
expect(decodeFileText(Buffer.concat([asciiHead, nulTail])).binary).toBe(true);
});
it('preserves a UTF-8 BOM through decode/encode (labels it utf-8-bom)', () => {
const withBom = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from('見出し\n本文', 'utf-8')]);
const r = decodeFileText(withBom);
expect(r.binary).toBe(false);
if (!r.binary) {
expect(r.encoding).toBe('utf-8-bom');
expect(r.text.charCodeAt(0)).not.toBe(0xfeff); // BOM stripped from the text view
const reencoded = encodeFileText(r.text, r.encoding)!;
expect([reencoded[0], reencoded[1], reencoded[2]]).toEqual([0xef, 0xbb, 0xbf]);
}
});
it('rejects a magic-byte binary whose bytes are valid UTF-8 (PDF/ZIP header)', () => {
// '%PDF-1.4\nneedle\n' is valid UTF-8, but the magic byte must win over the
// UTF-8 fast path so Grep does not leak it.
expect(decodeFileText(Buffer.from('%PDF-1.4\nneedle\n', 'ascii')).binary).toBe(true);
expect(decodeFileText(Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x6e, 0x65, 0x65, 0x64])).binary).toBe(true);
});
});
describe('encodeFileText (round-trip guard)', () => {
it('returns bytes when the text is representable in the target encoding', () => {
const bytes = encodeFileText('日本語テキスト\n', 'shift_jis');
expect(bytes).not.toBeNull();
expect(iconv.decode(bytes!, 'Shift_JIS')).toBe('日本語テキスト\n');
});
it('returns null when the text has chars the encoding cannot represent (emoji into Shift_JIS)', () => {
expect(encodeFileText('絵文字😀を含む\n', 'shift_jis')).toBeNull();
});
it('re-adds the BOM when encoding UTF-16 so the file stays readable next time', () => {
const le = encodeFromUtf8('日本語X\n', 'utf-16le');
expect([le[0], le[1]]).toEqual([0xff, 0xfe]);
const be = encodeFromUtf8('日本語X\n', 'utf-16be');
expect([be[0], be[1]]).toEqual([0xfe, 0xff]);
// Round-trips through decodeFileText as UTF-16, not rejected as binary.
const back = decodeFileText(le);
expect(back.binary).toBe(false);
if (!back.binary) expect(back.text.replace(/^/, '')).toBe('日本語X\n');
});
});
+287
View File
@@ -0,0 +1,287 @@
/**
* Encoding-aware text layer on top of the pure byte sniffer in binary-detect.ts.
*
* binary-detect.ts answers a binary yes/no and recognizes UTF-8 / BOM-tagged
* UTF-16 only — every other text encoding (Shift_JIS/CP932, EUC-JP, ISO-2022-JP,
* windows-1252/Latin-1, …) trips its strict-UTF-8 gate and is reported as
* `utf8-decode-fail`, i.e. "binary". That is wrong for the common case of a
* Japanese .txt / CSV saved on Windows (CP932).
*
* This module recovers those: when the sniffer's only complaint is
* `utf8-decode-fail`, it trial-decodes a short ladder of candidate encodings
* with iconv-lite and picks the one that produces the least mojibake. Callers
* (Read/Grep/Edit) then transcode the whole file to UTF-8 for the LLM while
* keeping the raw bytes so edits round-trip back to the original encoding.
*
* iconv-lite is pure JS (no ICU dependency), so Shift_JIS/EUC-JP decoding works
* the same on small-icu Node builds where the native TextDecoder lacks those
* labels. Only UTF-8 and BOM-tagged UTF-16 stay on the built-in decoder.
*/
import iconv from 'iconv-lite';
import {
looksLikeBinaryBytes,
controlCharRatio,
CONTROL_CHAR_RATIO_THRESHOLD,
SNIFF_HEAD_BYTES,
stripLeadingBom,
} from './binary-detect.js';
const UTF16LE_BOM = Buffer.from([0xff, 0xfe]);
const UTF16BE_BOM = Buffer.from([0xfe, 0xff]);
const UTF8_BOM = Buffer.from([0xef, 0xbb, 0xbf]);
// Internal encoding label for UTF-8 files that carry a leading BOM. TextDecoder
// strips the BOM on read, so without tracking it separately an Edit/Write would
// silently drop it and break Windows tools that expect it. Treated as UTF-8
// everywhere except that the BOM is re-added on encode.
export const UTF8_BOM_LABEL = 'utf-8-bom';
function hasUtf8Bom(buf: Buffer): boolean {
return buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf;
}
/**
* Strict UTF-8 decode of a COMPLETE buffer. Unlike binary-detect's streaming
* `decodeText` (used for 8 KB head sniffing, where a multibyte char cut at the
* sample boundary must be tolerated), this flushes at EOF, so a genuinely
* truncated trailing sequence is fatal instead of being silently dropped.
*/
function strictUtf8Whole(buf: Buffer): string | null {
try {
return new TextDecoder('utf-8', { fatal: true }).decode(buf);
} catch {
return null;
}
}
export type TextEncodingVerdict =
| { binary: true; reason: string }
| { binary: false; encoding: string };
export type DecodedFile =
| { binary: true; reason: string }
| { binary: false; encoding: string; text: string };
/**
* Candidate encodings tried, in priority order, when a buffer is not valid
* UTF-8 but also shows no hard binary signal. Japanese-first because that is
* the dominant real case (Windows CP932). windows-1252 is last: it decodes
* almost any byte, so it must only win when the multibyte candidates all fail
* (strict `<` tie-break below keeps the earliest candidate on equal scores).
*/
// Japanese-first, and deliberately Japanese-only among the multibyte codecs:
// GBK/Big5 (Chinese) would grab ambiguous kana/halfwidth Japanese and mojibake
// it. windows-1252 last as the single-byte Western fallback (see the tie-break).
const CANDIDATE_ENCODINGS = ['Shift_JIS', 'EUC-JP', 'ISO-2022-JP', 'windows-1252'];
/** Accept a trial decode only when at most this fraction of chars are garbage. */
const MOJIBAKE_ACCEPT_RATIO = 0.02;
/**
* A near-zero mojibake score is not enough on its own: windows-1252 maps almost
* every byte and multibyte CJK encodings (GBK/Big5) accept many random byte
* pairs, so high-entropy binary can decode "cleanly" into a soup of symbols and
* rare ideographs. Require that the decoded chars are also *predominantly*
* ordinary text — ASCII, Japanese kana/kanji, CJK punctuation, fullwidth forms,
* or Latin accented letters. Real text scores ~1.0; binary-as-cp1252/GBK scores
* well below this because it is dominated by symbol/rare-block code points.
*/
const PLAUSIBLE_TEXT_MIN = 0.8;
const REPLACEMENT_CHAR = 0xfffd;
/** Fraction of decoded chars that are U+FFFD or non-whitespace C0/C1 controls. */
function mojibakeRatio(decoded: string): number {
if (decoded.length === 0) return 1;
let bad = 0;
for (let i = 0; i < decoded.length; i++) {
const code = decoded.charCodeAt(i);
if (code === REPLACEMENT_CHAR) {
bad++;
} else if (
(code <= 0x08 || code === 0x0b || code === 0x0c || (code >= 0x0e && code <= 0x1f) ||
code === 0x7f || (code >= 0x80 && code <= 0x9f))
) {
bad++;
}
}
return bad / decoded.length;
}
/** True for code points that appear in ordinary human text (see PLAUSIBLE_TEXT_MIN). */
function isPlausibleTextChar(code: number): boolean {
return (
code === 0x09 || code === 0x0a || code === 0x0d || // tab / newlines
(code >= 0x20 && code <= 0x7e) || // ASCII printable
(code >= 0x00c0 && code <= 0x00ff) || // Latin-1 accented letters
(code >= 0x3000 && code <= 0x303f) || // CJK symbols & punctuation
(code >= 0x3040 && code <= 0x309f) || // Hiragana
(code >= 0x30a0 && code <= 0x30ff) || // Katakana
(code >= 0x4e00 && code <= 0x9fff) || // CJK Unified Ideographs
(code >= 0xff00 && code <= 0xffef) // Fullwidth / halfwidth forms
);
}
/** Fraction of decoded chars that are ordinary text (not stray symbols/rare blocks). */
function plausibleTextRatio(decoded: string): number {
if (decoded.length === 0) return 0;
let ok = 0;
for (let i = 0; i < decoded.length; i++) {
if (isPlausibleTextChar(decoded.charCodeAt(i))) ok++;
}
return ok / decoded.length;
}
/**
* Fraction of chars that are *body-text* Japanese/Latin — like isPlausibleTextChar
* but EXCLUDING halfwidth katakana (U+FF61–FF9F) and Latin-1 symbols. Used only to
* break ties between candidates: EUC-JP kana decoded (wrongly) as Shift_JIS lands
* in halfwidth katakana, so the candidate whose decode is richer in fullwidth
* kana/kanji is the right one. Two encodings can both hit zero mojibake, so this
* secondary key is what stops EUC-JP text from being mislabeled Shift_JIS.
*/
function preferredTextRatio(decoded: string): number {
if (decoded.length === 0) return 0;
let ok = 0;
for (let i = 0; i < decoded.length; i++) {
const code = decoded.charCodeAt(i);
if (
code === 0x09 || code === 0x0a || code === 0x0d ||
(code >= 0x20 && code <= 0x7e) || // ASCII printable
(code >= 0x00c0 && code <= 0x00ff) || // Latin-1 accented letters
(code >= 0x3000 && code <= 0x303f) || // CJK symbols & punctuation
(code >= 0x3040 && code <= 0x309f) || // Hiragana
(code >= 0x30a0 && code <= 0x30ff) || // full Katakana
(code >= 0x4e00 && code <= 0x9fff) || // CJK Unified Ideographs
(code >= 0xff01 && code <= 0xff60) // Fullwidth forms (not halfwidth)
) {
ok++;
}
}
return ok / decoded.length;
}
/**
* Extend the pure binary sniffer with non-UTF-8 text recovery.
* Returns a concrete encoding label callers pass to decodeToUtf8.
*/
export function detectTextEncoding(head: Buffer): TextEncodingVerdict {
const base = looksLikeBinaryBytes(head);
if (!base.binary) return { binary: false, encoding: base.encoding };
// Only a strict-UTF-8 failure is potentially recoverable text. A magic-byte
// signature, an embedded NUL, or a high control-char ratio is a hard binary
// signal — leave those rejected exactly as before.
if (base.reason !== 'utf8-decode-fail') return base;
// utf8-decode-fail returns before binary-detect's own control-ratio gate, so
// re-check it here: high control density means binary, not exotic-encoded text.
if (controlCharRatio(head) > CONTROL_CHAR_RATIO_THRESHOLD) {
return { binary: true, reason: base.reason };
}
let best: string | null = null;
let bestScore = Infinity;
let bestMojibake = 1;
let bestPlausible = 0;
for (const enc of CANDIDATE_ENCODINGS) {
if (!iconv.encodingExists(enc)) continue;
const decoded = iconv.decode(head, enc);
const mojibake = mojibakeRatio(decoded);
// Primary key: mojibake (×10 so it dominates). Secondary: prefer the decode
// richer in body-text Japanese/Latin, which separates true EUC-JP from the
// same bytes decoded as Shift_JIS halfwidth katakana.
const score = mojibake * 10 + (1 - preferredTextRatio(decoded));
if (score < bestScore) {
bestScore = score;
best = enc;
bestMojibake = mojibake;
bestPlausible = plausibleTextRatio(decoded);
}
}
if (best && bestMojibake <= MOJIBAKE_ACCEPT_RATIO && bestPlausible >= PLAUSIBLE_TEXT_MIN) {
return { binary: false, encoding: best.toLowerCase() };
}
return { binary: true, reason: base.reason };
}
/** Decode raw file bytes of a known encoding into a UTF-8 JS string. */
export function decodeToUtf8(buf: Buffer, encoding: string): string {
if (encoding === 'utf-8') return buf.toString('utf-8');
if (encoding === UTF8_BOM_LABEL) return stripLeadingBom(buf.toString('utf-8'));
return iconv.decode(buf, encoding);
}
/**
* Encode an (edited) UTF-8 string back into bytes of the original encoding.
*
* For UTF-16 the BOM is re-added: the encoding label is only produced for
* BOM-tagged files (iconv strips the BOM on decode and does not re-add it on
* encode), and a BOMless UTF-16 file would be rejected as binary on the next
* Read (leading NUL). Re-adding it keeps the round-trip readable.
*/
export function encodeFromUtf8(text: string, encoding: string): Buffer {
if (encoding === 'utf-8') return Buffer.from(text, 'utf-8');
if (encoding === UTF8_BOM_LABEL) return Buffer.concat([UTF8_BOM, Buffer.from(text, 'utf-8')]);
const body = iconv.encode(text, encoding);
if (encoding === 'utf-16le') return Buffer.concat([UTF16LE_BOM, body]);
if (encoding === 'utf-16be') return Buffer.concat([UTF16BE_BOM, body]);
return body;
}
/**
* Decode a WHOLE file buffer to a UTF-8 string, choosing the encoding.
*
* Detection runs over the entire buffer, not just the head: a file whose first
* 8 KiB is pure ASCII (a long English header/comment) but whose tail is
* Shift_JIS must not be mistaken for UTF-8 and mangled to U+FFFD. The fast path
* is a single whole-file strict UTF-8 decode; only when that fails do we run the
* candidate ladder.
*/
export function decodeFileText(full: Buffer): DecodedFile {
// Hard binary signals (magic byte / NUL / high control ratio) MUST be checked
// before any UTF-8 fast path: a PDF/ZIP/GIF header can be valid UTF-8, and NUL
// is itself valid UTF-8, so trusting the fast path would let Grep leak binary.
const head = full.length > SNIFF_HEAD_BYTES ? full.subarray(0, SNIFF_HEAD_BYTES) : full;
const headVerdict = detectTextEncoding(head);
if (headVerdict.binary) return headVerdict;
// BOM-tagged UTF-16 legitimately contains NUL bytes; decode via its encoding
// and skip the NUL gate below (which would otherwise reject it as binary).
if (headVerdict.encoding === 'utf-16le' || headVerdict.encoding === 'utf-16be') {
return { binary: false, encoding: headVerdict.encoding, text: decodeToUtf8(full, headVerdict.encoding) };
}
// Whole-file hard-binary check: an ASCII head can hide NUL / high control ratio
// in the tail (the head-only verdict above cannot see it).
if (full.includes(0)) return { binary: true, reason: 'nul-byte' };
if (controlCharRatio(full) > CONTROL_CHAR_RATIO_THRESHOLD) {
return { binary: true, reason: `control-ratio:${controlCharRatio(full).toFixed(2)}` };
}
// Whole-file UTF-8 validation (an ASCII prefix must not hide a Shift_JIS tail;
// a truncated trailing multibyte must not be silently dropped either). Track a
// leading BOM so Edit/Write can re-add it.
const utf8 = strictUtf8Whole(full);
if (utf8 !== null) {
return { binary: false, encoding: hasUtf8Bom(full) ? UTF8_BOM_LABEL : 'utf-8', text: utf8 };
}
const verdict = detectTextEncoding(full);
if (verdict.binary) return verdict;
return { binary: false, encoding: verdict.encoding, text: decodeToUtf8(full, verdict.encoding) };
}
/**
* Encode a UTF-8 string back to bytes of `encoding`, but only if it round-trips.
* iconv-lite silently substitutes unrepresentable chars (e.g. an emoji written
* into a Shift_JIS file) with '?', which would corrupt the save. Returns null so
* the caller can refuse rather than write bytes that differ from the request.
*/
export function encodeFileText(text: string, encoding: string): Buffer | null {
const bytes = encodeFromUtf8(text, encoding);
if (encoding === 'utf-8') return bytes;
return decodeToUtf8(bytes, encoding) === text ? bytes : null;
}
+6
View File
@@ -26,6 +26,8 @@ export const META_TOOLS = new Set<string>([
'CheckItem',
'GetChecklist',
'MissionUpdate',
'SearchTaskConversation',
'ReadTaskConversation',
'ListUserAssets',
'RunUserScript',
'UpdateUserMemory',
@@ -37,6 +39,8 @@ export const META_TOOLS = new Set<string>([
'ReadAppDoc',
'ListAppDocs',
'GetMyOrchestratorState',
'GetFileProvenance',
'ListWorkspaceFiles',
'ReadSkill',
'ListSkills',
'InstallSkill',
@@ -99,6 +103,8 @@ export const MODULE_SPECS: ReadonlyArray<{ category: string; specifier: string }
{ category: 'slide', specifier: '../engine/tools/slide.js' },
{ category: 'docs', specifier: '../engine/tools/docs.js' },
{ category: 'mission', specifier: '../engine/tools/mission.js' },
{ category: 'task-conversation', specifier: '../engine/tools/task-conversation.js' },
{ category: 'file-provenance', specifier: '../engine/tools/file-provenance.js' },
{ category: 'user-folder', specifier: '../engine/tools/user-folder.js' },
{ category: 'brainstorm', specifier: '../engine/tools/brainstorm.js' },
{ category: 'app-docs', specifier: '../engine/tools/app-docs.js' },
+7 -6
View File
@@ -3,10 +3,11 @@
*
* When an agent realises it needs a tool that this movement does not expose,
* it calls RequestTool({ name, reason }). The request is recorded so the gap
* becomes visible in the task detail and per-piece aggregation, letting the
* piece author add the tool to `allowed_tools` / `shared_tools`. This PR
* (recording infra) does NOT grant the tool — it records and guides. The
* inline approval/grant flow is layered on top in a later PR.
* becomes visible in the task detail and per-piece aggregation, so an operator
* can enable the tool for the workspace (Settings → Tools). When the run can
* reach a user, the interactive approval path (`toolApprovalInteractive`)
* pauses for consent and lets the run continue with the tool once approved;
* otherwise the request is recorded and the agent proceeds without it.
*
* Classification (recorded as `category`):
* - already_available → the tool IS exposed here; just call it (not recorded).
@@ -27,7 +28,7 @@ const REQUEST_TOOL_DEF: ToolDef = {
function: {
name: 'RequestTool',
description:
'この movement に無いツールがどうしても必要なときに要求する。name と理由(reason)を渡すと記録され、ピース作者が allowed_tools / shared_tools に追加できる。要求しても今すぐ使えるようにはならない — 無しで進めるか、無理なら complete({status:"needs_user_input"}) でユーザーに依頼すること。詳細は ReadToolDoc({ name: "RequestTool" })。',
'この movement に無いツールが必要なとき name と理由(reason)を渡して要求する。ユーザーが応答できる実行では承認を求め、承認されればそのまま使える。それ以外は記録のみで即時には使えないので、無しで進めるか complete({status:"needs_user_input"}) でユーザーに依頼する(恒久的に許可するにはワークスペースの設定 → ツールで有効化)。詳細は ReadToolDoc({ name: "RequestTool" })。',
parameters: {
type: 'object',
properties: {
@@ -103,7 +104,7 @@ export async function executeTool(
output:
`"${toolName}" はこの movement では許可されていないため使えません(要求を記録しました)。` +
` このツール無しで進めるか、達成できない場合は complete({status:"needs_user_input", missing_info:"..."}) でユーザーに依頼してください。` +
` ピースの allowed_tools / shared_tools に追加すると次回から使えます。`,
` ワークスペースの設定(設定 → ツール)で許可すると次回から使えます。`,
isError: false,
};
}
+3 -2
View File
@@ -75,7 +75,7 @@ describe('web tools', () => {
vi.restoreAllMocks();
});
it('blocks WebFetch on PDF responses and suggests ReadPdf', async () => {
it('blocks WebFetch on PDF responses and suggests DownloadFile + Read', async () => {
workspacePath = makeWorkspace();
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('%PDF-1.4', {
status: 200,
@@ -86,7 +86,8 @@ describe('web tools', () => {
expect(result).not.toBeNull();
expect(result?.isError).toBe(true);
expect(result?.output).toContain('ReadPdf');
expect(result?.output).toContain('Read');
expect(result?.output).not.toContain('ReadPdf');
const history = fs.readFileSync(path.join(workspacePath, 'logs', 'webfetch-history.jsonl'), 'utf-8').trim().split('\n').map((line) => JSON.parse(line) as Record<string, unknown>);
expect(history).toHaveLength(1);
expect(history[0]?.url).toBe('https://example.com/manual.pdf');
+14 -4
View File
@@ -4,6 +4,7 @@ import { resolveOutputPathWithin } from './core.js';
import { logger } from '../../logger.js';
import type { SearchFilterConfig } from '../../config.js';
import { checkSSRF, ssrfSafeFetch } from './shared/ssrf.js';
import { forwardAbort } from '../cancellation.js';
import { htmlToText } from './shared/html.js';
import { extractReadableMarkdown, truncateForAgent } from './shared/readability.js';
import * as fs from 'fs';
@@ -45,7 +46,7 @@ const WEBSEARCH_DEF: ToolDef = {
type: 'function',
function: {
name: 'WebSearch',
description: 'Google 検索でインターネットを検索する。検索クエリにプライベートIP、内部ドメイン、メールアドレス、電話番号等の機密情報を含めないでください。詳細は ReadToolDoc({ name: "WebSearch" })。',
description: 'Web を検索する(Google 優先、失敗時は Brave / Yahoo / SearXNG に自動フォールバック)。検索クエリにプライベートIP、内部ドメイン、メールアドレス、電話番号等の機密情報を含めないでください。詳細は ReadToolDoc({ name: "WebSearch" })。',
parameters: {
type: 'object',
properties: {
@@ -61,7 +62,7 @@ const WEBFETCH_DEF: ToolDef = {
type: 'function',
function: {
name: 'WebFetch',
description: 'URL からページのテキスト内容を取得する(静的ページ向け、軽量・高速)。動的レンダリングや操作が必要なら BrowseWeb。詳細は ReadToolDoc({ name: "WebFetch" })。',
description: 'URL からページ本文をクリーンな Markdown で取得する(静的ページ向け、VLM 有効時はファーストビューのスクショも添付)。動的レンダリングや操作が必要なら BrowseWeb。詳細は ReadToolDoc({ name: "WebFetch" })。',
parameters: {
type: 'object',
properties: {
@@ -266,10 +267,12 @@ export async function searchViaSearxng(
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutSec * 1000);
const unlink = forwardAbort(controller, ctx.abortSignal);
try {
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timer);
unlink();
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${response.statusText}`);
@@ -286,6 +289,7 @@ export async function searchViaSearxng(
}));
} catch (e) {
clearTimeout(timer);
unlink();
throw e;
}
}
@@ -885,12 +889,14 @@ async function executeWebFetch(
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutSec * 1000);
const unlink = forwardAbort(controller, ctx.abortSignal);
try {
// ssrfSafeFetch re-validates each redirect hop so a public URL cannot
// 30x-bounce into a private/metadata address.
const response = await ssrfSafeFetch(rawUrl, allowedHosts, { signal: controller.signal });
clearTimeout(timer);
unlink();
if (!response.ok) {
appendWebFetchHistory(ctx, {
@@ -920,7 +926,7 @@ async function executeWebFetch(
error: 'PDF content blocked',
});
return {
output: `WebFetch cannot read PDF content from "${rawUrl}". Use ReadPdf for local PDF files instead of fetching the binary document as text.`,
output: `WebFetch cannot read PDF content from "${rawUrl}". Save it with DownloadFile and open the local file with Read instead of fetching the binary document as text.`,
isError: true,
};
}
@@ -952,7 +958,7 @@ async function executeWebFetch(
error: `binary content detected (${sniffed.reason})`,
});
return {
output: `WebFetch blocked binary content from "${rawUrl}" (detected: ${sniffed.reason}). コンテキストに展開していません。DownloadFile で input/ に保存し、ReadExcel/ReadPdf 等で処理してください。`,
output: `WebFetch blocked binary content from "${rawUrl}" (detected: ${sniffed.reason}). コンテキストに展開していません。DownloadFile で input/ に保存し、Read で開いてください(拡張子から Excel/PDF 等を自動判定します)。`,
isError: true,
};
}
@@ -1030,6 +1036,7 @@ async function executeWebFetch(
return { output: text, isError: false, ...(images ? { images } : {}) };
} catch (e) {
clearTimeout(timer);
unlink();
const msg = (e as Error).message ?? String(e);
logger.warn(`[WebFetch] error: ${msg}`);
appendWebFetchHistory(ctx, {
@@ -1108,10 +1115,12 @@ async function executeDownloadFile(
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutSec * 1000);
const unlink = forwardAbort(controller, ctx.abortSignal);
try {
const response = await ssrfSafeFetch(rawUrl, allowedHosts, { signal: controller.signal });
clearTimeout(timer);
unlink();
if (!response.ok) {
appendDownloadHistory(ctx, {
@@ -1154,6 +1163,7 @@ async function executeDownloadFile(
};
} catch (e) {
clearTimeout(timer);
unlink();
const msg = (e as Error).message ?? String(e);
logger.warn(`[DownloadFile] error: ${msg}`);
appendDownloadHistory(ctx, {
@@ -0,0 +1,118 @@
import { describe, it, expect } from 'vitest';
import { executeTool } from './workspace-task-search.js';
import type { ToolContext } from './core.js';
function ctxWith(io: any): ToolContext {
return { workspacePath: '/tmp', editAllowed: false, workspaceTaskSearch: io } as any;
}
describe('SearchWorkspaceTasks tool', () => {
it('returns a friendly no-context message when IO is unset', async () => {
const res = await executeTool('SearchWorkspaceTasks', { query: 'x' }, ctxWith(undefined));
expect(res!.isError).toBe(false);
expect(res!.output).toMatch(/利用できません|利用不可/);
});
it('formats hits with taskId, title and excerpt', async () => {
const io = {
fts5Available: true,
search: async () => [{ taskId: 42, taskTitle: '請求書OCR', commentId: 7, author: 'agent', kind: 'result', createdAt: '2026-06-28', text: 'Gitea 認証エラーは再ログインで解消' }],
around: async () => null,
};
const res = await executeTool('SearchWorkspaceTasks', { query: '認証エラー' }, ctxWith(io));
expect(res!.isError).toBe(false);
expect(res!.output).toContain('task:42');
expect(res!.output).toContain('請求書OCR');
expect(res!.output).toContain('comment:7');
});
it('degrades when FTS5 unavailable', async () => {
const io = { fts5Available: false, search: async () => [], around: async () => null };
const res = await executeTool('SearchWorkspaceTasks', { query: 'x' }, ctxWith(io));
expect(res!.output).toMatch(/利用不可|この環境/);
});
it('returns null for an unowned tool name', async () => {
expect(await executeTool('SomethingElse', {}, ctxWith(undefined))).toBeNull();
});
it('errors when both query and around_ref are given', async () => {
const io = { fts5Available: true, search: async () => [], around: async () => null };
const res = await executeTool('SearchWorkspaceTasks', { query: 'x', around_ref: 'comment:1' }, ctxWith(io));
expect(res!.isError).toBe(true);
expect(res!.output).toMatch(/どちらか一方/);
});
it('errors when neither query nor around_ref is given', async () => {
const io = { fts5Available: true, search: async () => [], around: async () => null };
const res = await executeTool('SearchWorkspaceTasks', {}, ctxWith(io));
expect(res!.isError).toBe(true);
expect(res!.output).toMatch(/どちらか一方/);
});
it('around_ref renders the comment window with the center marked', async () => {
const io = {
fts5Available: true,
search: async () => [],
around: async (id: number, before: number, after: number) => ({
taskId: 9,
taskTitle: '設計タスク',
comments: [
{ commentId: 4, author: 'user', kind: 'comment', createdAt: 'd1', body: '前のコメント', isCenter: false },
{ commentId: 5, author: 'user', kind: 'request', createdAt: 'd2', body: '中心の依頼', isCenter: true },
{ commentId: 6, author: 'agent', kind: 'progress', createdAt: 'd3', body: '後のコメント', isCenter: false },
],
}),
};
const res = await executeTool('SearchWorkspaceTasks', { around_ref: 'comment:5' }, ctxWith(io));
expect(res!.isError).toBe(false);
expect(res!.output).toContain('task:9');
expect(res!.output).toContain('設計タスク');
expect(res!.output).toContain('comment:5');
expect(res!.output).toContain('←'); // center marker
expect(res!.output).toContain('中心の依頼'); // raw body returned
});
it('around_ref with an invalid ref format is an error', async () => {
const io = { fts5Available: true, search: async () => [], around: async () => null };
const res = await executeTool('SearchWorkspaceTasks', { around_ref: 'not-a-ref' }, ctxWith(io));
expect(res!.isError).toBe(true);
expect(res!.output).toMatch(/不正|comment:/);
});
it('around_ref that resolves to null (out of scope) is a friendly non-error', async () => {
const io = { fts5Available: true, search: async () => [], around: async () => null };
const res = await executeTool('SearchWorkspaceTasks', { around_ref: 'comment:123' }, ctxWith(io));
expect(res!.isError).toBe(false);
expect(res!.output).toMatch(/見つからない|スコープ外/);
});
it('context>0 attaches neighbor comments under each hit', async () => {
let aroundCalledWith: { id: number; before: number; after: number } | null = null;
const io = {
fts5Available: true,
search: async () => [{ taskId: 42, taskTitle: '請求書OCR', commentId: 7, author: 'agent', kind: 'result', createdAt: '2026-06-28', text: '認証エラーは再ログインで解消' }],
around: async (id: number, before: number, after: number) => {
aroundCalledWith = { id, before, after };
return {
taskId: 42,
taskTitle: '請求書OCR',
comments: [
{ commentId: 6, author: 'user', kind: 'request', createdAt: 'd0', body: '直前のコメント本文', isCenter: false },
{ commentId: 7, author: 'agent', kind: 'result', createdAt: '2026-06-28', body: '認証エラーは再ログインで解消', isCenter: true },
{ commentId: 8, author: 'agent', kind: 'progress', createdAt: 'd2', body: '直後のコメント本文', isCenter: false },
],
};
},
};
const res = await executeTool('SearchWorkspaceTasks', { query: '認証エラー', context: 2 }, ctxWith(io));
expect(res!.isError).toBe(false);
// around() was invoked with the hit's commentId and the clamped context window
expect(aroundCalledWith).toEqual({ id: 7, before: 2, after: 2 });
// neighbors rendered (indented comment lines for the surrounding comments)
expect(res!.output).toContain('comment:6');
expect(res!.output).toContain('comment:8');
expect(res!.output).toContain('直前のコメント本文');
expect(res!.output).toContain('直後のコメント本文');
});
});
+174
View File
@@ -0,0 +1,174 @@
/**
* SearchWorkspaceTasks — cross-task conversation search within the same workspace/space.
*
* Unlike SearchTaskConversation (task-conversation.ts), which is scoped to the
* CURRENT task's own comments + transcript, this tool searches OTHER tasks'
* comments in the same space via a shared FTS5 index. Scoping to "same space
* the owner can see" is enforced entirely by the bound `ctx.workspaceTaskSearch`
* IO (constructed by the worker layer) — this module never touches the DB
* directly, so it can't widen or bypass that scope.
*
* Degrades gracefully (non-error, friendly message) in two situations:
* - `ctx.workspaceTaskSearch` unset (subtask / gitea-issue runs not bound to
* a local task + owner).
* - `fts5Available === false` (environment's sqlite build lacks FTS5). We
* must not call `search()` in that case — the underlying virtual table
* doesn't exist and querying it throws "no such table".
*/
import { ToolDef } from '../../llm/openai-compat.js';
import type { ToolContext, ToolResult } from './core.js';
const SEARCH_DEF: ToolDef = {
type: 'function',
function: {
name: 'SearchWorkspaceTasks',
description:
'同じワークスペース内の他タスクの会話をキーワード横断検索し、出典 ref (comment:<id>) 付きの抜粋を返す。詳細は ReadToolDoc({ name: "SearchWorkspaceTasks" })。',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: '検索キーワード。複数語はスペース区切りで暗黙 AND (全語を含むものにヒット)。around_ref とは排他 (どちらか一方のみ指定)。' },
around_ref: { type: 'string', description: '"comment:<id>" 形式。指定した comment の前後を読む。query とは排他。' },
context: { type: 'number', description: '各ヒットに付す前後コメント件数 (grep -C 相当)。既定 0、最大 5。' },
limit: { type: 'number', description: '最大ヒット件数。既定 10、最大 30。' },
kind: { type: 'string', description: 'コメント種別で絞り込む (request/comment/interjection/result/ask/progress/handoff)。' },
author: { type: 'string', description: '発言者で絞り込む。' },
task_id: { type: 'number', description: '特定タスク ID に絞り込む。' },
},
},
},
};
export const TOOL_DEFS: Record<string, ToolDef> = {
SearchWorkspaceTasks: SEARCH_DEF,
};
const NO_CONTEXT_MSG =
'ワークスペース横断検索はこのコンテキストでは利用できません (subtask など local_task と紐付かない実行)。現在見えている文脈で判断してください。';
const FTS5_UNAVAILABLE_MSG =
'ワークスペース横断検索はこの環境では利用不可です (FTS5 インデックスが存在しません)。現在見えている文脈で判断してください。';
const EXCERPT_MAX = 200;
function collapse(s: string): string {
return s.replace(/\s+/g, ' ').trim();
}
/** Compact excerpt centered on the first query hit, bounded to `max` chars. */
function excerpt(body: string, query: string, max: number): string {
const flat = collapse(body);
if (flat.length <= max && !query) return flat;
const idx = query ? flat.toLowerCase().indexOf(query.toLowerCase()) : 0;
if (idx < 0 || flat.length <= max) {
return flat.length <= max ? flat : flat.slice(0, max - 1) + '…';
}
const start = Math.max(0, idx - 40);
const end = Math.min(flat.length, start + max);
return (start > 0 ? '…' : '') + flat.slice(start, end) + (end < flat.length ? '…' : '');
}
function clampLimit(v: unknown, def: number, max: number): number {
const n = typeof v === 'number' && Number.isFinite(v) ? Math.floor(v) : def;
return Math.min(Math.max(n, 1), max);
}
function clampContext(v: unknown, def: number, max: number): number {
const n = typeof v === 'number' && Number.isFinite(v) ? Math.floor(v) : def;
return Math.min(Math.max(n, 0), max);
}
async function search(input: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
const io = ctx.workspaceTaskSearch;
if (!io) return { output: NO_CONTEXT_MSG, isError: false };
const query = typeof input['query'] === 'string' ? (input['query'] as string) : undefined;
const aroundRef = typeof input['around_ref'] === 'string' ? (input['around_ref'] as string) : undefined;
const hasQuery = !!query && query.trim().length > 0;
const hasAroundRef = !!aroundRef && aroundRef.trim().length > 0;
if (hasQuery === hasAroundRef) {
return {
output: 'query と around_ref はどちらか一方のみ指定してください (両方または両方未指定は不可)。',
isError: true,
};
}
const context = clampContext(input['context'], 0, 5);
if (hasAroundRef) {
return searchAroundRef(aroundRef!, context, io);
}
// query mode
if (io.fts5Available === false) {
return { output: FTS5_UNAVAILABLE_MSG, isError: false };
}
if (!query!.trim()) {
return { output: 'query が空です。検索キーワードを指定してください。', isError: true };
}
const limit = clampLimit(input['limit'], 10, 30);
const kind = typeof input['kind'] === 'string' ? (input['kind'] as string) : undefined;
const author = typeof input['author'] === 'string' ? (input['author'] as string) : undefined;
const taskId = typeof input['task_id'] === 'number' && Number.isFinite(input['task_id']) ? (input['task_id'] as number) : undefined;
const hits = await io.search(query!, { limit, kind, author, taskId });
if (hits.length === 0) {
return { output: `## Workspace Task Search Results\n\n"${query}" に一致する会話は見つかりませんでした。`, isError: false };
}
const lines = [`## Workspace Task Search Results (query: "${query}", ${hits.length} hit(s))`, ''];
for (const h of hits) {
lines.push(`- task:${h.taskId} "${h.taskTitle}" / comment:${h.commentId} ${h.author}/${h.kind} ${h.createdAt}`);
lines.push(` ${excerpt(h.text, query!, EXCERPT_MAX)}`);
if (context > 0) {
const around = await io.around(h.commentId, context, context);
if (around) {
for (const c of around.comments) {
const marker = c.isCenter ? ' ←' : '';
lines.push(` - comment:${c.commentId} ${c.author}/${c.kind} ${c.createdAt}${marker}`);
lines.push(` ${excerpt(c.body, '', EXCERPT_MAX)}`);
}
}
}
lines.push('');
}
lines.push('前後の文脈をさらに確認するには around_ref: "comment:<id>" を指定してください。');
return { output: lines.join('\n'), isError: false };
}
async function searchAroundRef(ref: string, context: number, io: NonNullable<ToolContext['workspaceTaskSearch']>): Promise<ToolResult> {
const m = /^comment:(\d+)$/.exec(ref.trim());
if (!m) {
return { output: 'around_ref が不正です。"comment:<id>" の形式で指定してください。', isError: true };
}
const id = Number(m[1]);
const window = context > 0 ? context : 2;
const around = await io.around(id, window, window);
if (!around) {
return { output: `comment:${id} は見つからないか、このワークスペースのスコープ外です。`, isError: false };
}
const lines = [`## Workspace Task Search — task:${around.taskId} "${around.taskTitle}" (comment:${id} 前後 ${window} 件)`, ''];
for (const c of around.comments) {
const marker = c.isCenter ? ' ←' : '';
lines.push(`- comment:${c.commentId} ${c.author}/${c.kind} ${c.createdAt}${marker}`);
lines.push(` ${excerpt(c.body, '', EXCERPT_MAX)}`);
}
return { output: lines.join('\n'), isError: false };
}
export async function executeTool(
name: string,
input: Record<string, unknown>,
ctx: ToolContext,
): Promise<ToolResult | null> {
if (name === 'SearchWorkspaceTasks') return search(input, ctx);
return null;
}