351 lines
18 KiB
TypeScript
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);
|
|
});
|
|
});
|