Archived
MVP底座⑤:自定义工具导入路由 —— 与第一方同门两审
顺手补两处底座:试运行审此前传空连接,靠 endpoint 干活的工具一律过不了门(这道门 只对不需要连接的工具有效);现传本次申报的连接值。未注册第一方处理器的工具走通用 HTTP 实现 —— 导入的工具由此可真调、可真审:端点不通就是不通,不入目录。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
@@ -33,4 +33,11 @@
|
||||
- **凭证隔离**:连接值加密落库,运行时只把**申报过的连接键**交给处理器,不透传其它环境值
|
||||
- 停用后调用返回结构化错误(`ToolDisabledError`),不静默成功
|
||||
|
||||
近变(2026-07-28):模块面未变,**试运行审的行为修正** ——
|
||||
此前 `register` 给试运行传空连接,靠 `endpoint` 干活的工具永远「无实现」过不了门,
|
||||
这道门于是只对不需要连接的工具有效;现改为传入本次注册申报的 `connectionValues`。
|
||||
运行时仍只把**契约申报过的键**交给处理器,凭证隔离不变。
|
||||
app 侧配套:未注册第一方处理器的工具走通用 HTTP 实现(`tools/http-tool.ts`),
|
||||
导入的自定义工具由此可真调、可真审。
|
||||
|
||||
影响半径: index/contract/port 未变 → 对外零影响
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 注册两审之**试运行审** —— 这道门要真调得动工具才算数。
|
||||
*
|
||||
* 守一条曾经形同虚设的规矩:靠连接干活的工具,试运行必须拿到本次申报的连接值。
|
||||
* 从前这里传空对象,凡是需要 `endpoint` 的工具一律「无实现」过不了门 ——
|
||||
* 于是这道门只对不需要连接的工具有效。
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createToolboxService } from '../internal/usecase/toolbox-service.ts';
|
||||
import type { ToolContract } from '../internal/domain/tool-contract.ts';
|
||||
|
||||
const CONTRACT: ToolContract = {
|
||||
name: 'erp.stock-read',
|
||||
version: '1.0.0',
|
||||
description: '读取 ERP 现货库存:PN → 库存量',
|
||||
input: { type: 'object', required: ['pn'], properties: { pn: { type: 'string' } } },
|
||||
output: { type: 'object', properties: { qty: { type: 'number' } } },
|
||||
effect: 'read',
|
||||
errors: [],
|
||||
idempotent: true,
|
||||
timeoutMs: 5_000,
|
||||
retries: 1,
|
||||
connectionKeys: ['endpoint'],
|
||||
};
|
||||
|
||||
/** 只认端点的处理器 —— 拿不到 endpoint 就报无实现,正是导入工具的真实处境 */
|
||||
const runtime = {
|
||||
execute: vi.fn(async ({ connection }: { connection: Readonly<Record<string, string>> }) =>
|
||||
connection['endpoint'] === undefined
|
||||
? { ok: false as const, error: { code: 'tool-unimplemented', retryable: false, message: '工具无实现' } }
|
||||
: { ok: true as const, output: { qty: 120 } },
|
||||
),
|
||||
};
|
||||
|
||||
function serviceWith() {
|
||||
const saved: Array<{ contract: ToolContract; cipher: unknown }> = [];
|
||||
const toolbox = createToolboxService({
|
||||
registry: {
|
||||
get: async () => undefined,
|
||||
upsert: async (contract: ToolContract, cipher: unknown) => {
|
||||
saved.push({ contract, cipher });
|
||||
},
|
||||
list: async () => [],
|
||||
setAllowlist: async () => undefined,
|
||||
allowlistOf: async () => [],
|
||||
connectionOf: async () => ({}),
|
||||
setEnabled: async () => undefined,
|
||||
},
|
||||
runtime,
|
||||
schemas: { validate: () => ({ ok: true, errors: [] }) },
|
||||
secrets: { seal: (v: string) => `sealed:${v}`, open: (v: string) => v.replace('sealed:', '') },
|
||||
ledger: { register: async () => undefined },
|
||||
clock: { now: () => new Date('2026-07-28T00:00:00Z') },
|
||||
} as never);
|
||||
return { toolbox, saved };
|
||||
}
|
||||
|
||||
describe('register —— 试运行审拿得到连接值', () => {
|
||||
it('申报了 endpoint:试运行按真连接跑,过门入目录', async () => {
|
||||
const { toolbox, saved } = serviceWith();
|
||||
const verdict = await toolbox.register({
|
||||
contract: CONTRACT,
|
||||
samples: [{ input: { pn: 'LM358DR' }, expectShape: { type: 'object' } }],
|
||||
connectionValues: { endpoint: 'http://localhost:9099/stock' },
|
||||
});
|
||||
expect(verdict.trialRun.passed).toBe(1);
|
||||
expect(verdict.accepted).toBe(true);
|
||||
// 连接值加密落库,不明文存
|
||||
expect(saved[0]?.cipher).toEqual({ endpoint: 'sealed:http://localhost:9099/stock' });
|
||||
});
|
||||
|
||||
it('没给连接值:试运行如实失败,门拦住 —— 调不通的工具不入目录', async () => {
|
||||
const { toolbox, saved } = serviceWith();
|
||||
const verdict = await toolbox.register({
|
||||
contract: CONTRACT,
|
||||
samples: [{ input: { pn: 'LM358DR' }, expectShape: { type: 'object' } }],
|
||||
});
|
||||
expect(verdict.accepted).toBe(false);
|
||||
expect(verdict.trialRun.failures[0]).toContain('工具无实现');
|
||||
expect(saved).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -170,10 +170,17 @@ export type ToolHandler = (input: {
|
||||
readonly idempotencyKey: string | undefined;
|
||||
}) => Promise<{ ok: boolean; output?: Record<string, unknown>; error?: { code: string; retryable: boolean; message: string } }>;
|
||||
|
||||
export function createToolRuntime(handlers: ReadonlyMap<string, ToolHandler>): ToolRuntime {
|
||||
/**
|
||||
* `fallback` 供**导入进来的工具**用:第一方各有处理器,导入的没有 ——
|
||||
* 由 app 给一个通用实现(按申报的 endpoint 调),没给就如实报「无实现」。
|
||||
*/
|
||||
export function createToolRuntime(
|
||||
handlers: ReadonlyMap<string, ToolHandler>,
|
||||
fallback?: ToolHandler,
|
||||
): ToolRuntime {
|
||||
return {
|
||||
async execute({ contract, connection, call, idempotencyKey }) {
|
||||
const handler = handlers.get(contract.name);
|
||||
const handler = handlers.get(contract.name) ?? fallback;
|
||||
if (handler === undefined) {
|
||||
return { ok: false, error: { code: 'tool-unimplemented', retryable: false, message: `工具无实现:${contract.name}` } };
|
||||
}
|
||||
|
||||
@@ -35,7 +35,12 @@ export function createToolboxService(deps: ToolboxServiceDeps): ToolboxService {
|
||||
}
|
||||
const run = await deps.runtime.execute({
|
||||
contract: input.contract,
|
||||
connection: {},
|
||||
/*
|
||||
* 试运行**要用真连接**:此前这里传空对象,凡是靠 endpoint 干活的工具
|
||||
* 一律「无实现」过不了门 —— 那道门就只对不需要连接的工具有效,形同虚设。
|
||||
* 运行时只把契约申报的键交给处理器,多传的环境值不会漏出去。
|
||||
*/
|
||||
connection: input.connectionValues ?? {},
|
||||
call: { tool: input.contract.name, input: sample.input, activityId: '__trial__' },
|
||||
});
|
||||
if (!run.ok) {
|
||||
|
||||
Reference in New Issue
Block a user