From 8e7509f65b4dd891634ee295efadc18a24839fb8 Mon Sep 17 00:00:00 2001 From: 18628386721 <18628386721@163.com> Date: Tue, 28 Jul 2026 01:58:38 +0800 Subject: [PATCH] =?UTF-8?q?MVP=E5=BA=95=E5=BA=A7=E2=91=A4:=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E5=B7=A5=E5=85=B7=E5=AF=BC=E5=85=A5=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=20=E2=80=94=E2=80=94=20=E4=B8=8E=E7=AC=AC=E4=B8=80?= =?UTF-8?q?=E6=96=B9=E5=90=8C=E9=97=A8=E4=B8=A4=E5=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 顺手补两处底座:试运行审此前传空连接,靠 endpoint 干活的工具一律过不了门(这道门 只对不需要连接的工具有效);现传本次申报的连接值。未注册第一方处理器的工具走通用 HTTP 实现 —— 导入的工具由此可真调、可真审:端点不通就是不通,不入目录。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m --- .../contracts/src/api/governance.d.ts | 49 +++++++++++ apps/api/src/app/container.ts | 4 +- apps/api/src/app/routes/governance-ops.ts | 28 +++++++ apps/api/src/app/tools/http-tool.ts | 69 ++++++++++++++++ apps/api/src/modules/toolbox/CONTRACT.md | 7 ++ .../toolbox/__tests__/register-trial.test.ts | 82 +++++++++++++++++++ .../toolbox/internal/adapter/toolbox-store.ts | 11 ++- .../internal/usecase/toolbox-service.ts | 7 +- packages/contracts/src/api/governance.ts | 39 +++++++++ 9 files changed, 292 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/app/tools/http-tool.ts create mode 100644 apps/api/src/modules/toolbox/__tests__/register-trial.test.ts diff --git a/api-report/packages/contracts/src/api/governance.d.ts b/api-report/packages/contracts/src/api/governance.d.ts index 7c9cff9..56d66c8 100644 --- a/api-report/packages/contracts/src/api/governance.d.ts +++ b/api-report/packages/contracts/src/api/governance.d.ts @@ -1531,6 +1531,55 @@ export declare const saveWorkGroupRoute: { }, z.core.$strip>; }, z.core.$strip>; }; +/** + * 导入自定义工具 —— 与第一方工具走**同一条门**:静态审 + 试运行审。 + * + * 工具承载权力,约束严于活动包:契约对活动、实现对系统、凭证对治理台(OS 7.3)。 + * 连接配置**只声明键**;值随本次导入送到治理台加密落库,此后只读得到「是否已绑定」。 + */ +export declare const registerToolRoute: { + readonly method: "POST"; + readonly path: "/api/gov/tools"; + readonly body: z.ZodObject<{ + contract: z.ZodObject<{ + name: z.ZodString; + version: z.ZodString; + description: z.ZodString; + input: z.ZodRecord; + output: z.ZodRecord; + effect: z.ZodEnum<{ + read: "read"; + write: "write"; + }>; + errors: z.ZodDefault>>; + idempotent: z.ZodDefault; + timeoutMs: z.ZodDefault; + retries: z.ZodDefault; + connectionKeys: z.ZodDefault>; + }, z.core.$strip>; + connectionValues: z.ZodOptional>; + samples: z.ZodArray; + }, z.core.$strip>>; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + accepted: z.ZodBoolean; + findings: z.ZodArray>; + trialRun: z.ZodObject<{ + ran: z.ZodNumber; + passed: z.ZodNumber; + failures: z.ZodArray; + }, z.core.$strip>; + }, z.core.$strip>; +}; export declare const listActivitiesRoute: { readonly method: "GET"; readonly path: "/api/gov/activities"; diff --git a/apps/api/src/app/container.ts b/apps/api/src/app/container.ts index 4d9037a..3614a0b 100644 --- a/apps/api/src/app/container.ts +++ b/apps/api/src/app/container.ts @@ -62,6 +62,7 @@ import type { TriggerService } from '@modules/trigger'; import type { MinuteService } from '@modules/minute'; import type { ReportService } from '@modules/report'; import { FIRST_PARTY_TOOLS } from './tools/first-party.ts'; +import { httpToolHandler } from './tools/http-tool.ts'; import { wireEngines } from './wiring/engines.ts'; import { wireDeskElements } from './wiring/desk-elements.ts'; import type { ChannelService } from '@modules/channel'; @@ -262,7 +263,8 @@ export async function createContainer(config: AppConfig): Promise { }); const toolRegistry = createToolRegistry(db); - const toolRuntime = createToolRuntime(firstPartyToolHandlers); + // 导入的自定义工具走通用 HTTP 实现:契约申报 endpoint,运行时只把那一个连接目标交出去 + const toolRuntime = createToolRuntime(firstPartyToolHandlers, httpToolHandler); const schemas = createSchemaValidator(); const toolbox = createToolboxService({ registry: toolRegistry, diff --git a/apps/api/src/app/routes/governance-ops.ts b/apps/api/src/app/routes/governance-ops.ts index 75dbb68..023c7ac 100644 --- a/apps/api/src/app/routes/governance-ops.ts +++ b/apps/api/src/app/routes/governance-ops.ts @@ -23,6 +23,7 @@ import { listToolsRoute, listTriggersRoute, publishProcessRoute, + registerToolRoute, saveProcessRoute, reassignTaskRoute, resumeTaskRoute, @@ -63,6 +64,7 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai const governTrigger = guard('trigger', 'govern'); const readActivity = guard('activity', 'read'); const readTool = guard('tool', 'read'); + const governTool = guard('tool', 'govern'); const readData = guard('data', 'read'); const readMemory = guard('memory', 'read'); @@ -376,6 +378,32 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai /* ── 工具管理 ────────────────────────────────────────────────────── */ + /** + * 导入自定义工具 —— 与第一方走同一条门:静态审 + 试运行审,过了才入目录。 + * 入目录不等于能用:还要治理台把它放进某个活动的白名单(授权在白名单与契约,不在活动代码)。 + */ + app.post(registerToolRoute.path, async (c) => { + const denied = await governTool(c.get('account')); + if (denied) return c.json(denied, denyStatus(denied)); + const parsed = registerToolRoute.body.safeParse(await c.req.json()); + if (!parsed.success) { + const body: ApiError = { code: 'validation-failed', message: '工具契约不合 schema', detail: parsed.error.issues }; + return c.json(body, 400); + } + const { contract, samples, connectionValues } = parsed.data; + const verdict = await container.toolbox.register({ + contract, + // 期望形状统一按「出参是个对象」收:细的形状由契约 output schema 自己说 + samples: samples.map((s) => ({ input: s.input, expectShape: { type: 'object' } })), + ...(connectionValues === undefined ? {} : { connectionValues }), + }); + return c.json({ + accepted: verdict.accepted, + findings: verdict.staticFindings.map((f) => ({ code: f.code, severity: f.severity, message: f.message })), + trialRun: { ran: verdict.trialRun.ran, passed: verdict.trialRun.passed, failures: [...verdict.trialRun.failures] }, + }); + }); + app.get(listToolsRoute.path, async (c) => { const denied = await readTool(c.get('account')); if (denied) return c.json(denied, denyStatus(denied)); diff --git a/apps/api/src/app/tools/http-tool.ts b/apps/api/src/app/tools/http-tool.ts new file mode 100644 index 0000000..a58f13b --- /dev/null +++ b/apps/api/src/app/tools/http-tool.ts @@ -0,0 +1,69 @@ +/** + * 导入工具的通用实现 —— 「实现对系统」的兜底一件(编码规范 §3;语义出处 OS 7.3)。 + * + * 第一方工具各有各的处理器;**导入进来的自定义工具**没有处理器, + * 但它在契约里申报了 `endpoint` 连接键 —— 这个处理器就把入参 POST 到那个端点。 + * + * 运行时受限照旧:只拿得到申报的连接目标,没有别的环境能力。 + * 端点不通 = 试运行审不过 = 进不了目录 —— 这是**门**,不是障碍: + * 一件调不通的工具入了目录,活动调它时才失败,那才是真的坏。 + */ +import type { ToolHandler } from '@modules/toolbox'; + +/** 未在第一方注册的工具:申报了 endpoint 就按 HTTP 调,没申报就如实说没有实现 */ +export const httpToolHandler: ToolHandler = async ({ input, connection, operation, idempotencyKey }) => { + const endpoint = connection['endpoint']; + if (endpoint === undefined || endpoint.trim() === '') { + return { + ok: false, + error: { + code: 'tool-unimplemented', + retryable: false, + message: '工具无实现:未申报 endpoint 连接键,系统侧也没有第一方处理器', + }, + }; + } + if (!/^https?:\/\//.test(endpoint)) { + return { + ok: false, + error: { code: 'endpoint-invalid', retryable: false, message: `端点须是 http(s) 地址:${endpoint}` }, + }; + } + + try { + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'content-type': 'application/json', + // 幂等键透传给对端:写类动作重投时由对端去重(契约声明 idempotent 才有意义) + ...(idempotencyKey === undefined ? {} : { 'idempotency-key': idempotencyKey }), + }, + body: JSON.stringify({ input, ...(operation === undefined ? {} : { operation }) }), + }); + if (!response.ok) { + return { + ok: false, + error: { + code: `http-${response.status}`, + // 5xx 可重试,4xx 是我们自己请求得不对,重试没用 + retryable: response.status >= 500, + message: `端点返回 ${response.status}`, + }, + }; + } + const body: unknown = await response.json(); + if (typeof body !== 'object' || body === null || Array.isArray(body)) { + return { ok: false, error: { code: 'output-invalid', retryable: false, message: '端点返回的不是 JSON 对象' } }; + } + return { ok: true, output: body as Record }; + } catch (error) { + return { + ok: false, + error: { + code: 'endpoint-unreachable', + retryable: true, + message: `端点调用失败:${error instanceof Error ? error.message : String(error)}`, + }, + }; + } +}; diff --git a/apps/api/src/modules/toolbox/CONTRACT.md b/apps/api/src/modules/toolbox/CONTRACT.md index dfacb74..4e258e1 100644 --- a/apps/api/src/modules/toolbox/CONTRACT.md +++ b/apps/api/src/modules/toolbox/CONTRACT.md @@ -33,4 +33,11 @@ - **凭证隔离**:连接值加密落库,运行时只把**申报过的连接键**交给处理器,不透传其它环境值 - 停用后调用返回结构化错误(`ToolDisabledError`),不静默成功 +近变(2026-07-28):模块面未变,**试运行审的行为修正** —— + 此前 `register` 给试运行传空连接,靠 `endpoint` 干活的工具永远「无实现」过不了门, + 这道门于是只对不需要连接的工具有效;现改为传入本次注册申报的 `connectionValues`。 + 运行时仍只把**契约申报过的键**交给处理器,凭证隔离不变。 + app 侧配套:未注册第一方处理器的工具走通用 HTTP 实现(`tools/http-tool.ts`), + 导入的自定义工具由此可真调、可真审。 + 影响半径: index/contract/port 未变 → 对外零影响 diff --git a/apps/api/src/modules/toolbox/__tests__/register-trial.test.ts b/apps/api/src/modules/toolbox/__tests__/register-trial.test.ts new file mode 100644 index 0000000..e66a458 --- /dev/null +++ b/apps/api/src/modules/toolbox/__tests__/register-trial.test.ts @@ -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> }) => + 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); + }); +}); diff --git a/apps/api/src/modules/toolbox/internal/adapter/toolbox-store.ts b/apps/api/src/modules/toolbox/internal/adapter/toolbox-store.ts index 1fe76ce..cdb9ec4 100644 --- a/apps/api/src/modules/toolbox/internal/adapter/toolbox-store.ts +++ b/apps/api/src/modules/toolbox/internal/adapter/toolbox-store.ts @@ -170,10 +170,17 @@ export type ToolHandler = (input: { readonly idempotencyKey: string | undefined; }) => Promise<{ ok: boolean; output?: Record; error?: { code: string; retryable: boolean; message: string } }>; -export function createToolRuntime(handlers: ReadonlyMap): ToolRuntime { +/** + * `fallback` 供**导入进来的工具**用:第一方各有处理器,导入的没有 —— + * 由 app 给一个通用实现(按申报的 endpoint 调),没给就如实报「无实现」。 + */ +export function createToolRuntime( + handlers: ReadonlyMap, + 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}` } }; } diff --git a/apps/api/src/modules/toolbox/internal/usecase/toolbox-service.ts b/apps/api/src/modules/toolbox/internal/usecase/toolbox-service.ts index 236dab3..4df25c1 100644 --- a/apps/api/src/modules/toolbox/internal/usecase/toolbox-service.ts +++ b/apps/api/src/modules/toolbox/internal/usecase/toolbox-service.ts @@ -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) { diff --git a/packages/contracts/src/api/governance.ts b/packages/contracts/src/api/governance.ts index c6dc943..bfc6dfd 100644 --- a/packages/contracts/src/api/governance.ts +++ b/packages/contracts/src/api/governance.ts @@ -278,6 +278,45 @@ export const saveWorkGroupRoute = { output: z.object({ group: workGroupSchema }), } as const satisfies RouteContract; +/* ── 工具管理 · 导入自定义工具 ─────────────────────────────────────── */ + +/** + * 导入自定义工具 —— 与第一方工具走**同一条门**:静态审 + 试运行审。 + * + * 工具承载权力,约束严于活动包:契约对活动、实现对系统、凭证对治理台(OS 7.3)。 + * 连接配置**只声明键**;值随本次导入送到治理台加密落库,此后只读得到「是否已绑定」。 + */ +export const registerToolRoute = { + method: 'POST', + path: '/api/gov/tools', + body: z.object({ + contract: z.object({ + name: z.string().min(1), + version: z.string().min(1), + description: z.string().min(1), + input: z.record(z.string(), z.unknown()), + output: z.record(z.string(), z.unknown()), + effect: z.enum(['read', 'write']), + errors: z + .array(z.object({ code: z.string(), retryable: z.boolean(), message: z.string() })) + .default([]), + idempotent: z.boolean().default(true), + timeoutMs: z.number().int().positive().default(10_000), + retries: z.number().int().min(0).default(1), + connectionKeys: z.array(z.string()).default([]), + }), + /** 连接值只写不读 —— 键在契约里,值密封在治理台 */ + connectionValues: z.record(z.string(), z.string()).optional(), + /** 契约测试集:没有样例就没有试运行审,门形同虚设 */ + samples: z.array(z.object({ input: z.record(z.string(), z.unknown()) })).min(1), + }), + output: z.object({ + accepted: z.boolean(), + findings: z.array(z.object({ code: z.string(), severity: z.string(), message: z.string() })), + trialRun: z.object({ ran: z.number().int(), passed: z.number().int(), failures: z.array(z.string()) }), + }), +} as const satisfies RouteContract; + /* ── 活动库管理:两维分类 × 包版本 × 资产版本 ─────────────────────────── */ export const listActivitiesRoute = {