Files
maestro/src/engine/tools/tool-pack-gate.test.ts
T
oss-sync 57702b4f34
CI / build-and-test (push) Canceled after 0s
sync: update from private repo (2336949a)
2026-09-17 11:29:31 +00:00

351 lines
18 KiB
TypeScript

/**
* tool-pack-gate.test.ts — 群ごと止める仕掛けの試験(#1126 段 C)。
*
* ここで守りたいこと。
*
* 1. **設定ファイルから、エージェントが受け取るツール一覧まで通す。**
* 設定を読む純関数だけを固めても意味がない。実際に config.yaml を書いて、
* 実際の getToolDefs / loadBuiltinEntries / resolveWorkspaceTools の返りを見る。
* ここが薄いと「設定を効かせる 1 行」を消しても全部緑のままになる。
*
* 2. **旧ルートが無傷であること。**台帳に載っていない群は今までどおり全部
* 使える。何も止めていない状態の実効値が、この仕掛けを入れる前と 1 つも
* 変わらないことを、ツール名の実物で見る。
*
* 3. **走行中に入れ替わらないこと。**判定はプロセスに 1 回。設定ファイルを
* 書き換えても、同じプロセスの中ではツール一覧が動かない。動くのは
* 読み込み直した(=再起動した)ときだけ、という形で両側を見る。
*/
import { describe, it, expect, afterEach } from 'vitest';
import { mkdtempSync, writeFileSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import {
TOOL_PACKS,
listToolPackIds,
isKnownToolPackId,
normalizeToolModuleSpecifier,
toolPackIdOfModule,
} from './tool-pack-ledger.js';
import {
getToolPackGate,
getToolPackDecisions,
isToolModuleDisabled,
isToolPackDisabled,
_resetToolPackGateForTests,
_setToolPackConfigReaderForTests,
} from './tool-pack-gate.js';
import {
listToolCategories,
getToolCategoryMap,
META_TOOLS,
SENSITIVE_TOOLS,
_resetToolCategoryCacheForTests,
} from './tool-categories.js';
import {
_resetToolModuleCacheForTests,
_setToolModuleManifestOverridesForTests,
listRegisteredToolModuleSpecifiers,
loadRegisteredToolModules,
} from './module-manifest.js';
import { resolveWorkspaceTools } from '../workspace-tool-policy.js';
import { validateConfig, loadConfig } from '../../config.js';
// ─────────────────────────────────────────────────────────────────────────────
// 補助
// ─────────────────────────────────────────────────────────────────────────────
/** 群の入り切りを差し替えて、下流のキャッシュを全部捨てる */
function setDisabledPacks(disabled: string[]): void {
_setToolPackConfigReaderForTests(() => ({ toolPacks: { disabled } }));
_resetToolCategoryCacheForTests();
_resetToolModuleCacheForTests();
}
/** 差し替えを戻す */
function restoreGate(): void {
_setToolPackConfigReaderForTests(null);
_resetToolPackGateForTests();
_resetToolCategoryCacheForTests();
_resetToolModuleCacheForTests();
}
const X_TOOLS = ['XSearch', 'XUserPosts', 'XPostDetail', 'XFetchCardMedia', 'XTimeline'];
const CONTROL_TOOLS = ['WebSearch', 'Read', 'SQLite', 'RenderMermaid'];
afterEach(() => {
restoreGate();
});
// ─────────────────────────────────────────────────────────────────────────────
// 1. 台帳そのもの
// ─────────────────────────────────────────────────────────────────────────────
describe('台帳', () => {
it('名前が重複しない', () => {
const ids = listToolPackIds();
expect(new Set(ids).size).toBe(ids.length);
});
it('載っているモジュールは実在し、空でない TOOL_DEFS を持つ', async () => {
// try/catch を書かないこと。書いた瞬間、台帳の綴り間違いを見逃す試験になる。
for (const pack of TOOL_PACKS) {
for (const specifier of pack.modules) {
const mod = (await import(specifier)) as { TOOL_DEFS?: Record<string, unknown> };
expect(Object.keys(mod.TOOL_DEFS ?? {}).length, `${pack.id} / ${specifier}`).toBeGreaterThan(0);
}
}
});
it('モジュール指定はどちらの書き方でも同じ群に解決する', () => {
expect(normalizeToolModuleSpecifier('../engine/tools/x.js')).toBe('./x.js');
expect(toolPackIdOfModule('./x.js')).toBe('x');
expect(toolPackIdOfModule('../engine/tools/x.js')).toBe('x');
expect(toolPackIdOfModule('./core.js')).toBeNull();
});
it('台帳の群は、中央の常時利用一覧・既定 OFF 一覧に名前を出していない', async () => {
// 出していると、群を止めても中央の一覧経由で名前が許可一覧に残る。
// 台帳に群を足す人がこの境目を踏み抜かないための歯止め。
const central = new Set<string>([...META_TOOLS, ...SENSITIVE_TOOLS]);
const offenders: string[] = [];
for (const pack of TOOL_PACKS) {
for (const specifier of pack.modules) {
const mod = (await import(specifier)) as { TOOL_DEFS?: Record<string, unknown> };
for (const name of Object.keys(mod.TOOL_DEFS ?? {})) {
if (central.has(name)) offenders.push(`${pack.id}:${name}`);
}
}
}
expect(offenders).toEqual([]);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 2. 旧ルート — 台帳に載っていない群は今までどおり全部使える
// ─────────────────────────────────────────────────────────────────────────────
describe('旧ルート', () => {
it('何も止めていなければ、台帳の群も台帳外の群も全部使える', async () => {
setDisabledPacks([]);
const { allowedTools } = await resolveWorkspaceTools({});
for (const name of [...X_TOOLS, ...CONTROL_TOOLS, 'GetYouTubeTranscript', 'SearchPlaces', 'SearchAmazon', 'TranscribeAudio', 'SearchMicrosoftLearn', 'BuildPptx']) {
expect(allowedTools, name).toContain(name);
}
});
it('設定そのものが無い(tool_packs 未記載)ときも全部使える', async () => {
_setToolPackConfigReaderForTests(() => ({}));
_resetToolCategoryCacheForTests();
_resetToolModuleCacheForTests();
const { allowedTools } = await resolveWorkspaceTools({});
expect(allowedTools).toContain('XSearch');
expect(allowedTools).toContain('GetYouTubeTranscript');
expect(getToolPackGate().disabledPackIds.size).toBe(0);
});
it('1 つ止めても、台帳外の群は 1 つも減らない', async () => {
setDisabledPacks([]);
const before = new Set((await resolveWorkspaceTools({})).allowedTools);
setDisabledPacks(['x']);
const after = new Set((await resolveWorkspaceTools({})).allowedTools);
const removed = [...before].filter((n) => !after.has(n)).sort();
expect(removed).toEqual([...X_TOOLS].sort());
// 逆向き(勝手に増える)も見る
expect([...after].filter((n) => !before.has(n))).toEqual([]);
});
it('止めた群の分類だけが消え、他の分類は残る', async () => {
setDisabledPacks([]);
const before = await listToolCategories();
setDisabledPacks(['x', 'youtube']);
const after = await listToolCategories();
expect([...before].filter((c) => !after.includes(c)).sort()).toEqual(['x', 'youtube']);
expect(after).toContain('core');
expect(after).toContain('web');
expect(after).toContain('browser');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 5. 層の向き — 環境で外したものはワークスペースで入れられない
// ─────────────────────────────────────────────────────────────────────────────
describe('層の向き', () => {
it('環境で止めた分類は、ワークスペースの「入れたもの」に書いてあっても入らない', async () => {
// speak は分類そのものが既定 OFF。ワークスペース側で 1 回入れれば使える
// ——という既存の道が、環境で止めたときに塞がることを見る。
setDisabledPacks([]);
const on = await resolveWorkspaceTools({ enabledSensitive: ['speak'] });
expect(on.allowedTools, '前提: 環境で止めていなければ入る').toContain('Speak');
setDisabledPacks(['speak']);
const off = await resolveWorkspaceTools({ enabledSensitive: ['speak'] });
expect(off.allowedTools).not.toContain('Speak');
});
it('環境で止めた分類は、ワークスペースの設定画面の分類一覧にも出ない', async () => {
setDisabledPacks(['speak', 'x']);
const cats = await listToolCategories();
expect(cats).not.toContain('speak');
expect(cats).not.toContain('x');
});
it('止めた群のツールは分類の地図から消える(下流が全部ここを見る)', async () => {
setDisabledPacks(['amazon']);
const map = await getToolCategoryMap();
expect(map.has('SearchAmazon')).toBe(false);
expect(map.get('WebSearch')).toBe('web');
});
it('環境で止めた群のツールは、個別に「入れたもの」へ書いても入らない', async () => {
// いま台帳の群は 1 つも「既定 OFF のツール」を申告していない(上の歯止めで
// そう縛ってある)。だから、これから誰かが申告したときに層の向きが崩れないか
// ——を、申告を差し込んで先に確かめる。
// 差し込みが無いと、この経路は 1 行消しても全部緑のままになる。
_setToolModuleManifestOverridesForTests([
{ id: 'x', category: 'x', sensitiveTools: ['XSearch'] },
]);
try {
setDisabledPacks([]);
const on = await resolveWorkspaceTools({ enabledSensitive: ['XSearch'] });
expect(on.allowedTools, '前提: 止めていなければ個別の有効化で入る').toContain('XSearch');
setDisabledPacks(['x']);
const off = await resolveWorkspaceTools({ enabledSensitive: ['XSearch'] });
expect(off.allowedTools).not.toContain('XSearch');
} finally {
_setToolModuleManifestOverridesForTests(null);
}
});
it('止めた群のモジュールは、申告の収集対象からも外れる', async () => {
setDisabledPacks(['youtube']);
expect(listRegisteredToolModuleSpecifiers()).not.toContain('./youtube.js');
expect(listRegisteredToolModuleSpecifiers()).toContain('./web.js');
});
it('保存済みの設定を持つワークスペースは、環境で止めたあとも設定を保存できる', async () => {
// 保存の検査に使う一覧だけは、環境で止めた分類も「知っている名前」として
// 数える。数えないと、群を止める前に開かれたままの古いタブと、API を
// 直接叩くクライアントからの保存が、綴り間違いと同じ扱いで弾かれる。
setDisabledPacks(['x']);
expect(await listToolCategories({ includeEnvDisabled: true })).toContain('x');
expect(await listToolCategories()).not.toContain('x');
});
// 覚え書き(メモ化)は 2 本立てで、フィルタ済みと止めた群込みを別の鍵で持つ。
// **本番はフィルタ済みが必ず先に埋まる**——movement ごとに getToolDefs と
// resolveWorkspaceTools が読むため。設定画面の保存はそのあとに来る。
// 鍵が 1 本に崩れると、保存の検査がフィルタ済みの一覧を受け取り、上で
// 塞いだ罠がそのまま戻る。だから**この順番で**呼んで縛る。
it('フィルタ済みを先に読んでも、includeEnvDisabled は止めた群のツールを返す', async () => {
setDisabledPacks(['x']);
const filtered = await getToolCategoryMap(); // 本番はこっちが先
expect(filtered.has('XSearch')).toBe(false);
const all = await getToolCategoryMap({ includeEnvDisabled: true });
expect(all.has('XSearch'), '覚え書きの鍵が 1 本に崩れている').toBe(true);
});
it('フィルタ済みを先に読んでも、includeEnvDisabled は止めた群のモジュールを読み込む', async () => {
setDisabledPacks(['x']);
const filtered = await loadRegisteredToolModules(); // 本番はこっちが先
expect(filtered.loaded.some((l) => l.specifier === './x.js')).toBe(false);
const all = await loadRegisteredToolModules({ includeEnvDisabled: true });
expect(all.loaded.some((l) => l.specifier === './x.js'), '覚え書きの鍵が 1 本に崩れている').toBe(true);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 6. 知らない名前を黙って無視しない
// ─────────────────────────────────────────────────────────────────────────────
describe('知らない群の名前', () => {
it('判定の一覧に「知らない名前」として残る', () => {
setDisabledPacks(['x', 'nosuchpack']);
const gate = getToolPackGate();
expect(gate.unknownPackIds).toEqual(['nosuchpack']);
const unknown = gate.decisions.filter((d) => d.status === 'unknown');
expect(unknown).toHaveLength(1);
expect(unknown[0]!.packId).toBe('nosuchpack');
expect(unknown[0]!.reason).toBe('unknown-pack-id');
});
it('知らない名前を書いても、他の群の入り切りは正しく効く', () => {
setDisabledPacks(['nosuchpack', 'x']);
expect(isToolPackDisabled('x')).toBe(true);
expect(isToolPackDisabled('youtube')).toBe(false);
expect(isToolPackDisabled('nosuchpack')).toBe(false);
});
it('設定ファイルを読ませたときに、検査が知らない名前を指摘する', () => {
// 設定ファイルの実物を読ませる。手で組み立てた形だけを検査に通すと、
// スネークケースの読み替え(tool_packs → toolPacks)が抜けていても通る。
const dir = mkdtempSync(join(tmpdir(), 'tool-packs-cfg-'));
try {
const bad = join(dir, 'bad.yaml');
writeFileSync(bad, 'tool_packs:\n disabled:\n - x\n - nosuchpack\n', 'utf-8');
const errors = validateConfig(loadConfig(bad));
expect(errors.some((e) => e.includes('nosuchpack'))).toBe(true);
const good = join(dir, 'good.yaml');
writeFileSync(good, 'tool_packs:\n disabled:\n - x\n', 'utf-8');
const ok = validateConfig(loadConfig(good));
expect(ok.some((e) => e.includes('toolPacks'))).toBe(false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('設定ファイルのスネークケースが、そのまま群の入り切りとして効く', () => {
const dir = mkdtempSync(join(tmpdir(), 'tool-packs-cfg-'));
try {
const p = join(dir, 'c.yaml');
writeFileSync(p, 'tool_packs:\n disabled:\n - ms-learn\n', 'utf-8');
expect(loadConfig(p).toolPacks?.disabled).toEqual(['ms-learn']);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('台帳にある名前は既知と判定される', () => {
for (const id of listToolPackIds()) expect(isKnownToolPackId(id)).toBe(true);
expect(isKnownToolPackId('nosuchpack')).toBe(false);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 7. 判定が 1 か所に集まっていること(あとから群ごとの記録を出せる形)
// ─────────────────────────────────────────────────────────────────────────────
describe('判定の記録', () => {
it('台帳の全群について、いつ・なぜ・誰の設定で決まったかが 1 か所に揃う', () => {
setDisabledPacks(['x']);
const decisions = getToolPackDecisions();
const known = decisions.filter((d) => d.status !== 'unknown');
expect(known.map((d) => d.packId).sort()).toEqual(listToolPackIds().sort());
for (const d of known) {
expect(d.source).toContain('tool_packs.disabled');
expect(Number.isNaN(Date.parse(d.decidedAt))).toBe(false);
}
const x = known.find((d) => d.packId === 'x')!;
expect(x.status).toBe('disabled');
expect(x.reason).toBe('disabled-by-config');
const yt = known.find((d) => d.packId === 'youtube')!;
expect(yt.status).toBe('enabled');
expect(yt.reason).toBe('not-listed-in-config');
});
it('同じプロセスの中では、判定は何度読んでも同じもの', () => {
setDisabledPacks(['x']);
const first = getToolPackDecisions();
const second = getToolPackDecisions();
expect(second).toBe(first);
expect(isToolModuleDisabled('./x.js')).toBe(true);
expect(isToolModuleDisabled('../engine/tools/x.js')).toBe(true);
});
});