From aeb106441e07fa0e7f304e4b814444d9312ed18f Mon Sep 17 00:00:00 2001 From: hellofxxkity <108869708+evilstar9527@users.noreply.github.com> Date: Sun, 5 Jul 2026 17:04:41 +0800 Subject: [PATCH] feat(toolbridge): integrate upstream SDK --- packages/cli/src/cli.ts | 17 + packages/cli/src/toolbridge.ts | 24 + packages/dashboard/package.json | 3 +- packages/gateway/package.json | 3 +- .../gateway/src/agent/harness/htbp-tools.ts | 2 +- packages/gateway/src/env.ts | 11 + packages/gateway/src/http/routes.ts | 39 ++ packages/gateway/src/tools/tool-invoker.ts | 422 +++++++++--------- .../gateway/src/tools/toolbridge-admin.ts | 280 ++++++++++++ packages/gateway/src/worker-env.d.ts | 13 + .../gateway/test/platform-toolbridge.test.ts | 75 ++++ packages/gateway/test/tools-proxy.test.ts | 30 +- packages/gateway/vitest.config.ts | 121 ++++- packages/toolbridge/README.md | 41 +- packages/toolbridge/package.json | 5 +- .../scripts/check-vendor-patches.mjs | 9 +- packages/toolbridge/src/env.d.ts | 16 + packages/toolbridge/src/index.ts | 19 +- packages/toolbridge/tsconfig.json | 2 +- packages/toolbridge/wrangler.jsonc | 6 +- pnpm-lock.yaml | 16 + pnpm-workspace.yaml | 1 + scripts/deploy-all.mjs | 8 +- 23 files changed, 875 insertions(+), 288 deletions(-) create mode 100644 packages/cli/src/toolbridge.ts create mode 100644 packages/gateway/src/tools/toolbridge-admin.ts create mode 100644 packages/gateway/test/platform-toolbridge.test.ts create mode 100644 packages/toolbridge/src/env.d.ts diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 6886d08..7c325e0 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -79,6 +79,7 @@ import { taskSignal, } from './task.ts'; import { formatMountListHuman, toolCall, toolDescribe, toolList, toolMount } from './tool.ts'; +import { parseToolBridgeArgs, toolBridgeAdminCall } from './toolbridge.ts'; import { formatWhoamiHuman, whoami } from './whoami.ts'; /** commander 的可重复选项收集器(`--metadata k=v` 累积成数组)。 */ @@ -951,6 +952,22 @@ export async function run(argv: string[], opts: RunOptions = {}): Promise', 'Tool Bridge admin method exposed by /htbp/platform/toolbridge') + .option('--args ', 'method arguments as a JSON object', '{}') + .action(async (method: string, cmdOpts: { args: string }) => { + const base = requireBaseUrl(env()); + const token = requireToken(env(), credPath, opts.fs); + const args = parseToolBridgeArgs(cmdOpts.args); + const result = await toolBridgeAdminCall(base, token, method, args, { fetch: opts.fetch }); + out(JSON.stringify(result, null, 2)); + }); + // ── watt agent(§3.1 AgentRegistry + §3.2 AgentRuntime)───────────────────── const agent = program .command('agent') diff --git a/packages/cli/src/toolbridge.ts b/packages/cli/src/toolbridge.ts new file mode 100644 index 0000000..69ffaed --- /dev/null +++ b/packages/cli/src/toolbridge.ts @@ -0,0 +1,24 @@ +import { type HttpDeps, htbpCall } from './client.ts'; +import { CliError } from './env.ts'; + +export async function toolBridgeAdminCall( + base: string, + token: string, + tool: string, + args: Record, + deps: HttpDeps = {}, +): Promise { + return htbpCall(base, token, 'toolbridge', tool, args, deps); +} + +export function parseToolBridgeArgs(raw: string): Record { + try { + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('not an object'); + } + return parsed as Record; + } catch { + throw new CliError('--args must be a JSON object', 2); + } +} diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index 9e4e11c..fa1537b 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -24,7 +24,8 @@ "react-dom": "^19.2.0", "react-router": "^7.9.4", "sonner": "^2.0.7", - "tailwind-merge": "^3.3.1" + "tailwind-merge": "^3.3.1", + "tslib": "^2.8.1" }, "devDependencies": { "@react-router/dev": "^7.9.4", diff --git a/packages/gateway/package.json b/packages/gateway/package.json index bb87ed6..337c803 100644 --- a/packages/gateway/package.json +++ b/packages/gateway/package.json @@ -18,7 +18,8 @@ "agents": "^0.17.3", "ai": "^6.0.219", "hono": "4.12.27", - "jose": "^6.2.3" + "jose": "^6.2.3", + "@tokenroll/tool-bridge": "^0.1.0" }, "devDependencies": { "@cloudflare/vitest-pool-workers": "0.18.0", diff --git a/packages/gateway/src/agent/harness/htbp-tools.ts b/packages/gateway/src/agent/harness/htbp-tools.ts index 14cf65b..89fcb2b 100644 --- a/packages/gateway/src/agent/harness/htbp-tools.ts +++ b/packages/gateway/src/agent/harness/htbp-tools.ts @@ -138,7 +138,7 @@ export function createHtbpTools(deps: HtbpToolsDeps): HarnessTool[] { } const result = await executeToolRequest( env, - { toolPath: path, op, accept: 'text/plain' }, + { toolPath: path, op, ...(op === 'skill' ? { accept: 'text/plain' } : {}) }, { trim, }, diff --git a/packages/gateway/src/env.ts b/packages/gateway/src/env.ts index e85e16b..5e62b41 100644 --- a/packages/gateway/src/env.ts +++ b/packages/gateway/src/env.ts @@ -107,6 +107,17 @@ export interface Bindings { ANTHROPIC_BASE_URL?: string; /** lurker scratch namespace TTL 秒(正整数串)——缺省 120(保 E2E 分钟级过期断言);生产设 3600。 */ LURKER_SCRATCH_TTL_SEC?: string; + /** Tool Bridge Host SDK 注册的 host id;缺省 watt。 */ + WATT_TOOLBRIDGE_HOST_ID?: string; + /** + * Tool Bridge SDK key。默认只配这一把,用于 Host SDK 同步/调用与 Admin SDK 管理面。 + * 若需要最小权限隔离,可改用下面两个分离 key。 + */ + WATT_TOOLBRIDGE_KEY?: string; + /** 可选:仅用于 Host SDK mounts.sync 与 /htbp 调用的 S2S key。 */ + WATT_TOOLBRIDGE_HOST_KEY?: string; + /** 可选:仅用于 /htbp/platform/toolbridge 管理面的 Admin SDK key。 */ + WATT_TOOLBRIDGE_ADMIN_KEY?: string; /** Root Key 的 SHA-256 摘要(hex,§6.5e)——明文永不进平台;缺省则 /oauth/root/token 报未启用。 * 设置:scripts/set-root-key.mjs(生成/覆写,明文仅展示一次)或 watt init 向导。 */ WATT_ROOT_KEY_HASH?: string; diff --git a/packages/gateway/src/http/routes.ts b/packages/gateway/src/http/routes.ts index 56a912f..adcd267 100644 --- a/packages/gateway/src/http/routes.ts +++ b/packages/gateway/src/http/routes.ts @@ -73,6 +73,7 @@ import { SecretStore } from '../secrets/secret-store.ts'; import { defaultManagerDeps, TaskManager } from '../task/task-manager.ts'; import type { ListOptions as TaskListOptions } from '../task/task-store.ts'; import { type ListOptions as ToolListOptions, ToolRegistry } from '../tools/tool-registry.ts'; +import { executeToolBridgeAdmin, TOOLBRIDGE_ADMIN_ACTIONS } from '../tools/toolbridge-admin.ts'; import { type AuthVars, authMiddleware } from './auth.ts'; import { forbiddenResponse, wattErrorResponse } from './errors.ts'; @@ -82,6 +83,7 @@ const RES_EVENT = 'platform://event'; const RES_CHANNEL = 'platform://channel'; const RES_CONTEXT = 'platform://context'; const RES_TOOL = 'platform://tool'; +const RES_TOOLBRIDGE = 'platform://toolbridge'; const RES_AGENT = 'platform://agent'; const RES_PROVIDER = 'platform://provider'; const RES_TASK = 'platform://task'; @@ -784,6 +786,43 @@ export function platformRoutes(): Hono<{ Bindings: Bindings; Variables: AuthVars } }); + // ── POST /htbp/platform/toolbridge → Tool Bridge Admin SDK 管理面 ─────── + // 覆盖 Tool Bridge SDK 管理能力:providers/publications/placements/hosts/endpoints/ + // command policies/audit/legacy servers/bridge/tree。Watt 只做平台鉴权与错误形状转换, + // 真实操作通过 Admin SDK 走 TOOLBRIDGE service binding。 + app.post('/htbp/platform/toolbridge', async (c) => { + const claims = c.get('claims'); + const authorizer = newAuthorizer(c.env, c.get('callContext').traceId); + + let call: HtbpCall; + try { + call = (await c.req.json()) as HtbpCall; + } catch { + return wattErrorResponse( + c, + wattError('invalid_argument', 'request body must be JSON', false), + ); + } + const tool = call.tool; + const args = call.arguments ?? {}; + + const action = tool ? TOOLBRIDGE_ADMIN_ACTIONS[tool] : undefined; + if (action === undefined) { + return wattErrorResponse( + c, + wattError('invalid_argument', `unknown tool: ${String(tool)}`, false), + ); + } + const decision = await authorizer.check(claims, RES_TOOLBRIDGE, action); + if (!decision.allow) { + return forbiddenResponse(c, decision.reason ?? 'access denied on platform://toolbridge'); + } + + const result = await executeToolBridgeAdmin(c.env, tool as string, args); + if (isWattError(result)) return wattErrorResponse(c, result); + return c.json(result); + }); + // ── POST /htbp/platform/agent → AgentRegistry(§3.1)+ AgentRuntime(§3.2)──────── // Registry 动词:List/Get/Write/Update;Runtime 动词:Spawn/Send/Status/Terminate/ListInstances。 // tool → action(§6.4d):读=read(List/Get/Status/ListInstances); diff --git a/packages/gateway/src/tools/tool-invoker.ts b/packages/gateway/src/tools/tool-invoker.ts index 1bca4cc..a2bb94c 100644 --- a/packages/gateway/src/tools/tool-invoker.ts +++ b/packages/gateway/src/tools/tool-invoker.ts @@ -1,47 +1,26 @@ /** - * Tool 消费面调用核心(Proto §5.1 ToolProvider / §11.3a HTBP 绑定 / §6.4d 工具动作映射)—— - * 与 Hono Context 解耦的可复用单元。两处消费方共用它: - * 1. `/htbp/tools/*` 代理路由(http/tools-proxy.ts)——外部 HTBP 消费面。 - * 2. Agent 的 HTBP 工具(agent/harness/htbp-tools.ts)——def.toolScopes 注入的三工具 execute。 + * Tool 消费面调用核心。 * - * 职责边界(P2 抽取决策):本模块承接**传输 + 形状归一 + 可见性裁剪**—— - * ① syncTenantTree(树同步写共享 KV,KV 单键 1 写/秒节流); - * ② service binding TOOLBRIDGE 转发(路径重写 /htbp/tools/ → /htbp/,call body 按 - * provider 归一化:http 拆 {arguments} 信封发裸参数 / mcp·builtin 透传,§38); - * ③ 上游 {error:{code,message}} → 裸 WattError(CODE_MAP); - * ④ ~help/~skill 的 resources[] 按注入的 check 回调逐项裁剪。 - * - * **不承接顶层 Check PEP**:两处消费方的 PEP 语义不同——tools-proxy 的 call 动作由 mount scope 推导 - * (deny→403 Response),htbp-tools 的动作钉死(htbp_call→invoke / help·skill→read,deny→错误对象 - * 回喂模型)。故顶层门禁留在各调用方,本模块只提供裁剪用的 check 回调注入点。这是有意偏离计划 - * "Check PEP 一并抽取"的措辞——两者授权动作推导与响应形状发散,强抽会引入分支耦合。 + * Watt 仍负责 ToolRegistry、用户鉴权和可见性裁剪;Tool Bridge 的树同步、解析和调用统一通过 + * Tool Bridge Host SDK 完成。 */ +import { + createToolBridgeHost, + type HostMount, + serviceBinding, + type ToolBridgeHost, +} from '@tokenroll/tool-bridge/host'; import type { ToolMount } from '@watt/core'; import { type WattError, wattError } from '@watt/shared'; import type { Bindings } from '../env.ts'; import { ToolRegistry } from './tool-registry.ts'; -/** 固定内部租户 id + Secret Key(service binding 内部转发用;租户树由本 gateway 独占写)。 */ -const PROXY_TENANT_ID = 'watt-gateway'; -const PROXY_SECRET_KEY = 'watt-internal-toolbridge-key'; - -/** 上游 tool-bridge 错误 code → WattError code。未知 code → internal。 */ -const UPSTREAM_CODE_MAP: Record = { - unauthorized: 'permission_denied', - not_found: 'not_found', - bad_request: 'invalid_argument', - method_not_allowed: 'invalid_argument', - not_supported: 'unavailable', - auth_misconfigured: 'internal', - internal_error: 'internal', -}; - export function isWattError(v: unknown): v is WattError { return typeof v === 'object' && v !== null && 'code' in v && 'message' in v && 'retryable' in v; } -/** 上游节点类型(部分)——树同步用。 */ +/** 旧树节点投影,仅保留给测试/兼容导出;新链路不再把树写入 Watt KV。 */ interface TreeNode { type: string; id: string; @@ -50,29 +29,15 @@ interface TreeNode { [k: string]: unknown; } -/** - * 把一个 ToolMount 转成上游叶子节点(除 id/type 外的字段来自 providerConfig)。 - * provider(mcp/http/builtin/...)直接作上游 type;providerConfig 透传为节点字段(endpoint / - * endpoints / builtin 等,形状由上游 registry.ts 校验)。凭证:providerConfig 里以 `$env:`/`${VAR}` - * 占位的 header 值由上游 materializeHeaders 在转发时替换(凭证不出网关,Reference §2)。 - */ function mountToLeaf(mount: ToolMount, id: string): TreeNode { const cfg = (mount.providerConfig ?? {}) as Record; const node: TreeNode = { type: mount.provider, id, ...cfg }; - node.id = id; // providerConfig 不得覆盖 id/type。 + node.id = id; node.type = mount.provider; - if (mount.virtualize !== undefined) { - // virtualize 仅对 mcp 节点的 namespace/toolOverrides 有意义;透传由上游消费。 - if (mount.virtualize.prefix !== undefined) node.namespace = mount.virtualize.prefix; - } + if (mount.virtualize?.prefix !== undefined) node.namespace = mount.virtualize.prefix; return node; } -/** - * 把 enabled 的 ToolMount 集合合并成一棵上游树(path 斜杠分段 → 目录,叶子为 provider 节点)。 - * 例:finance/reports(mcp) + echo/svc(http) → root{children:[finance{children:[reports(mcp)]}, - * echo{children:[svc(http)]}]}。 - */ export function buildUpstreamTree(mounts: ToolMount[]): TreeNode { const root: TreeNode = { type: 'directory', id: 'root', title: 'Watt Tools', children: [] }; for (const mount of mounts) { @@ -92,7 +57,6 @@ export function buildUpstreamTree(mounts: ToolMount[]): TreeNode { } const leafId = segs[segs.length - 1] as string; dir.children = dir.children ?? []; - // 同 id 叶子覆盖(后写覆盖,对齐 ToolRegistry.write 幂等语义)。 const existing = dir.children.findIndex((c) => c.id === leafId); const leaf = mountToLeaf(mount, leafId); if (existing >= 0) dir.children[existing] = leaf; @@ -101,72 +65,136 @@ export function buildUpstreamTree(mounts: ToolMount[]): TreeNode { return root; } -/** hex sha256(KV apikey 键 / 树 hash;与上游 tenant.ts sha256Hex 一致)。 */ -async function sha256Hex(value: string): Promise { - const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)); - return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join(''); +let lastMountsHash: string | undefined; + +export function resetSyncStateForTests(): void { + lastMountsHash = undefined; } -// isolate 级同步状态(KV 单键 1 写/秒限制:每请求盲写会撞限流)。 -// lastTreeHash:上次写入 KV 的树 JSON hash——树未变则跳过 tenant: 写。冷启动为 undefined, -// 首请求会 get KV 现值算 hash 比对(避免冷启动误重写),仍不匹配才写。 -// apikeyWritten:apikey: 常量映射键的 once-guard——写过一次后本 isolate 不再 get/put。 -let lastTreeHash: string | undefined; -let apikeyWritten = false; +function hostId(env: Bindings): string { + return env.WATT_TOOLBRIDGE_HOST_ID || 'watt'; +} -// 测试复位(同文件 isolate 跨用例存活模块级单例,对齐 seed once-guard 复位,toolchain §23)。 -export function resetSyncStateForTests(): void { - lastTreeHash = undefined; - apikeyWritten = false; +function hostCredential(env: Bindings): string | undefined { + return env.WATT_TOOLBRIDGE_HOST_KEY || env.WATT_TOOLBRIDGE_KEY || env.WATT_TOOLBRIDGE_ADMIN_KEY; } -/** - * 把当前 ToolRegistry 的树写入共享 KV,返回转发用的 Secret Key。 - * 写 tenant:(树 JSON)+ apikey:(key→tenant 映射),上游按 Secret Key 加载该租户树。 - * - * KV 限流规避(finding 3):**变更时才写**——树 JSON hash 与 isolate 缓存(或冷启动时 KV 现值)比对, - * 变了才 put tenant 键;apikey 常量键只在缺失时写一次(isolate once-guard + KV get 判存在)。 - * 稳态(树不变的热 isolate)对这两个键零写,不再撞 KV 单键 1 写/秒限制。 - */ -export async function syncTenantTree(env: Bindings): Promise { - const registry = new ToolRegistry(env.DB_PROVIDERS); - const page = await registry.list({ limit: 200 }); - const mounts = isWattError(page) ? [] : page.items; - const treeJson = JSON.stringify(buildUpstreamTree(mounts)); - const treeHash = await sha256Hex(treeJson); - const tenantKey = `tenant:${PROXY_TENANT_ID}`; - - if (lastTreeHash !== treeHash) { - // isolate 缓存未命中(冷启动/首请求):先看 KV 现值是否已是同树,是则只更新缓存、不写。 - if (lastTreeHash === undefined) { - const existing = await env.KV_TENANTS.get(tenantKey); - if (existing !== null && (await sha256Hex(existing)) === treeHash) { - lastTreeHash = treeHash; - } - } - if (lastTreeHash !== treeHash) { - await env.KV_TENANTS.put(tenantKey, treeJson); - lastTreeHash = treeHash; - } +function toolBridgeHost(env: Bindings): ToolBridgeHost { + return createToolBridgeHost({ + transport: serviceBinding(env.TOOLBRIDGE), + credential: hostCredential(env), + hostId: hostId(env), + }); +} + +function hashStable(value: unknown): Promise { + return crypto.subtle + .digest('SHA-256', new TextEncoder().encode(JSON.stringify(value))) + .then((digest) => + [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join(''), + ); +} + +function toolOverridesFor( + virtualize: ToolMount['virtualize'], +): Record> | undefined { + if (!virtualize) return undefined; + const overrides: Record> = {}; + const ensure = (name: string): Record => { + overrides[name] = overrides[name] ?? {}; + return overrides[name]; + }; + for (const [from, to] of Object.entries(virtualize.rename ?? {})) { + ensure(from).rename = to; } + for (const name of virtualize.hide ?? []) { + ensure(name).hide = true; + } + for (const [name, description] of Object.entries(virtualize.describeOverride ?? {})) { + ensure(name).description = description; + } + return Object.keys(overrides).length > 0 ? overrides : undefined; +} - if (!apikeyWritten) { - const apikeyKey = `apikey:${await sha256Hex(PROXY_SECRET_KEY)}`; - const existing = await env.KV_TENANTS.get(apikeyKey); - if (existing === null) { - await env.KV_TENANTS.put(apikeyKey, JSON.stringify({ tenantId: PROXY_TENANT_ID })); - } - apikeyWritten = true; +function namedSemanticsFromConfig( + cfg: Record, +): Record | undefined { + if (cfg.semantics && typeof cfg.semantics === 'object' && !Array.isArray(cfg.semantics)) { + return cfg.semantics as Record; } - return PROXY_SECRET_KEY; + const effect = typeof cfg.effect === 'string' ? cfg.effect : undefined; + const scope = typeof cfg.scope === 'string' ? cfg.scope : undefined; + const confirm = cfg.confirm === true ? true : undefined; + if (!effect && !scope && !confirm) return undefined; + + const semantics: Record> = {}; + const add = (name: unknown) => { + if (typeof name !== 'string' || name.length === 0) return; + semantics[name] = { + ...(effect ? { effect } : {}), + ...(scope ? { scope } : {}), + ...(confirm ? { confirm } : {}), + }; + }; + for (const item of Array.isArray(cfg.endpoints) ? cfg.endpoints : []) { + if (item && typeof item === 'object') add((item as { name?: unknown }).name); + } + for (const item of Array.isArray(cfg.tools) ? cfg.tools : []) { + if (item && typeof item === 'object') add((item as { name?: unknown }).name); + } + if (Object.keys(semantics).length === 0) semantics.call = { effect, scope, confirm }; + return semantics; +} + +export function mountToHostMount(mount: ToolMount): HostMount | undefined { + if (!mount.enabled) return undefined; + const cfg = (mount.providerConfig ?? {}) as Record; + const binding = { ...cfg, type: mount.provider }; + binding.type = mount.provider; + + const shaping: Record = {}; + if (mount.virtualize?.prefix) shaping.namespace = mount.virtualize.prefix; + const toolOverrides = toolOverridesFor(mount.virtualize); + if (toolOverrides) shaping.toolOverrides = toolOverrides; + + const result: HostMount = { + path: mount.path.replace(/^\/+|\/+$/g, ''), + binding, + }; + if (typeof cfg.version === 'string') result.version = cfg.version; + if (Object.keys(shaping).length > 0) result.shaping = shaping; + const semantics = namedSemanticsFromConfig(cfg); + if (semantics) result.semantics = semantics; + return result; +} + +export function mountsToHostMounts(mounts: ToolMount[]): HostMount[] { + return mounts.map(mountToHostMount).filter((m): m is HostMount => m !== undefined); +} + +async function syncToolBridgeMounts(env: Bindings): Promise { + if (!hostCredential(env)) { + return wattError('unavailable', 'Tool Bridge key is not configured', false); + } + + const registry = new ToolRegistry(env.DB_PROVIDERS); + const page = await registry.list({ limit: 200 }); + if (isWattError(page)) return page; + + const mounts = mountsToHostMounts(page.items); + const hash = await hashStable(mounts); + if (hash === lastMountsHash) return undefined; + + try { + await toolBridgeHost(env).mounts.sync(mounts, { prune: true }); + lastMountsHash = hash; + return undefined; + } catch (error) { + return convertToolBridgeError(env, error); + } } -/** - * ~help/~skill 响应的可见性裁剪:对 resources[](下一层子节点)逐项 check,deny 的剔除。 - * 量级:一次 describe 最多 check N 个直接子节点(非递归全树)——深层子树在其自身 ~help 请求时再裁剪 - * (渐进发现天然分摊)。check 回调由调用方注入(tools-proxy / htbp-tools 各用自己的 authorizer+claims)。 - */ export async function trimHelpVisibility( body: unknown, basePath: string, @@ -177,7 +205,6 @@ export async function trimHelpVisibility( if (!Array.isArray(help.resources)) return body; const kept: Array<{ name: string; path: string }> = []; for (const res of help.resources) { - // res.path 是相对路径("./child");拼成 tool:/// 做 check。 const childSeg = res.path.replace(/^\.\//, '').replace(/^\/+/, ''); const childPath = basePath ? `${basePath}/${childSeg}` : childSeg; if (await check(`tool://${childPath}`)) kept.push(res); @@ -185,20 +212,6 @@ export async function trimHelpVisibility( return { ...help, resources: kept }; } -/** 上游错误 body → 裸 WattError(保留 message)。 */ -function convertUpstreamError(status: number, body: unknown): WattError { - const err = (body as { error?: { code?: string; message?: string } })?.error; - const upstreamCode = err?.code ?? ''; - const code = UPSTREAM_CODE_MAP[upstreamCode] ?? 'internal'; - const message = err?.message ?? `tool bridge returned HTTP ${status}`; - return wattError(code, message); -} - -/** - * 从含 end-path 段的 toolPath 解出对应的 ToolMount:mount.path 是节点路径(如 echo/svc),toolPath - * 含端点/工具名(如 echo/svc/ping)。从最长前缀逐级回退 get,命中即返回(对齐上游 findNode 走到叶子、 - * 剩余段作 adapter sub 的语义)。全不命中 → undefined(provider 未知,body 按 mcp/builtin 默认透传)。 - */ async function resolveMount( registry: ToolRegistry, toolPath: string, @@ -212,133 +225,130 @@ async function resolveMount( return undefined; } -/** - * 按 provider 归一化调用 body(§38):CLI/agent 统一发 {arguments:{...}} 信封。 - * - http:上游 http adapter 把 body **整包**转发到远端 URL(不解信封)→ 代理拆掉信封发裸参数, - * 否则远端 API 收到 {arguments:{...}} 包裹(漂移面)。 - * - mcp/builtin/其他/未知:原样透传——上游 adapter 的 extractArguments 解 {arguments} 信封(或整体 - * 当参数),透传信封是它期待的形状;未知 provider 也按此保守透传(不猜测远端语义)。 - */ -function normalizeCallBody(rawBody: string, provider: string | undefined): string { - if (provider !== 'http') return rawBody; +function parseCallBody( + rawBody: string, + provider: string | undefined, +): { ok: true; body: unknown } | { ok: false; error: WattError } { let parsed: unknown; try { parsed = rawBody ? JSON.parse(rawBody) : {}; } catch { - return rawBody; // 非 JSON body 原样透传(上游会自行报错)。 + return { + ok: false, + error: wattError('invalid_argument', 'tool call body must be valid JSON', false), + }; } - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && 'arguments' in parsed) { - return JSON.stringify((parsed as { arguments?: unknown }).arguments ?? {}); + if ( + provider === 'http' && + parsed && + typeof parsed === 'object' && + !Array.isArray(parsed) && + 'arguments' in parsed + ) { + return { ok: true, body: (parsed as { arguments?: unknown }).arguments ?? {} }; + } + return { ok: true, body: parsed ?? {} }; +} + +function convertToolBridgeError(env: Bindings, error: unknown): WattError { + return toolBridgeHost(env).adapters.wattError()(error) as WattError; +} + +async function convertToolBridgeResponseError( + env: Bindings, + response: Response, +): Promise { + let code = 'internal_error'; + let message = `HTTP ${response.status}`; + let details: unknown; + try { + const body = (await response.json()) as { + error?: { code?: unknown; message?: unknown; details?: unknown }; + }; + if (typeof body.error?.code === 'string') code = body.error.code; + if (typeof body.error?.message === 'string') message = body.error.message; + details = body.error?.details; + } catch { + // keep status-derived defaults } - return rawBody; + const { TBApiError } = await import('@tokenroll/tool-bridge'); + return convertToolBridgeError(env, new TBApiError(code, response.status, message, details)); } -/** 单次工具调用请求(Hono-decoupled)。 */ export interface ToolInvokeRequest { - /** 工具节点路径,无 /htbp/tools 前缀、无 ~help/~skill 后缀。'' = 树根。 */ toolPath: string; - /** 'help' → GET ~help(渐进发现);'skill' → GET ~skill(技能文档);'call' → POST {arguments}。 */ op: 'help' | 'skill' | 'call'; - /** call 的原始 body 字符串({arguments} 信封);help/skill 忽略。 */ rawBody?: string; - /** 转发的 Accept 头(help/skill)。 */ accept?: string; } -/** 工具调用结果(成功 json / 成功 text / 失败 WattError)——调用方按需转 Response 或错误对象。 */ export type ToolInvokeResult = | { ok: true; kind: 'json'; body: unknown } | { ok: true; kind: 'text'; contentType: string; text: string } | { ok: false; error: WattError }; -/** executeToolRequest 选项:~help/~skill 的 resources[] 可见性裁剪回调(缺省不裁剪)。 */ export interface ToolInvokeOpts { - /** 逐子节点资源 → 是否可见(tools-proxy/htbp-tools 各用自己的 authorizer+claims)。 */ trim?: (resource: string) => Promise; } -/** - * 执行一次工具调用(传输 + 形状归一 + 可见性裁剪,无顶层 PEP,见文件头)。 - * op='call':先 resolveMount 取 provider 归一化 body(http 拆信封)→ POST。 - * op='help'/'skill':GET ~help/~skill,JSON 响应经 opts.trim 裁剪 resources[]。 - * 转发不可达 → unavailable;上游非 2xx → convertUpstreamError;text/* → 透传文本。 - */ +async function readSkillViaTransport( + env: Bindings, + path: string, + accept: string | undefined, +): Promise { + const clean = path.replace(/^\/+|\/+$/g, ''); + const suffix = clean ? `/${clean}` : ''; + return serviceBinding(env.TOOLBRIDGE).fetch(`/htbp${suffix}/~skill`, { + method: 'GET', + headers: { + ...(hostCredential(env) ? { Authorization: `Bearer ${hostCredential(env)}` } : {}), + ...(accept ? { Accept: accept } : {}), + }, + }); +} + export async function executeToolRequest( env: Bindings, req: ToolInvokeRequest, opts: ToolInvokeOpts = {}, ): Promise { - const isCall = req.op === 'call'; - - // call:解 provider 以归一化 body(http 拆信封发裸参数)。 - let provider: string | undefined; - let body: string | undefined; - if (isCall) { - const registry = new ToolRegistry(env.DB_PROVIDERS); - const mount = await resolveMount(registry, req.toolPath); - provider = mount?.provider; - body = normalizeCallBody(req.rawBody ?? '', provider); - } - - const secretKey = await syncTenantTree(env); - const suffix = req.op === 'help' ? '/~help' : req.op === 'skill' ? '/~skill' : ''; - const upstreamPath = `/htbp${req.toolPath ? `/${req.toolPath}` : ''}${suffix}`; - const upstreamUrl = `https://toolbridge.internal${upstreamPath}`; + const syncError = await syncToolBridgeMounts(env); + if (syncError) return { ok: false, error: syncError }; - const fwdHeaders = new Headers(); - fwdHeaders.set('Authorization', `Bearer ${secretKey}`); - if (req.accept) fwdHeaders.set('Accept', req.accept); + const client = toolBridgeHost(env); - let upstreamRes: Response; try { - if (isCall) { - fwdHeaders.set('Content-Type', 'application/json'); - upstreamRes = await env.TOOLBRIDGE.fetch(upstreamUrl, { - method: 'POST', - headers: fwdHeaders, - body, - }); - } else { - upstreamRes = await env.TOOLBRIDGE.fetch(upstreamUrl, { method: 'GET', headers: fwdHeaders }); + if (req.op === 'call') { + const registry = new ToolRegistry(env.DB_PROVIDERS); + const mount = await resolveMount(registry, req.toolPath); + const parsed = parseCallBody(req.rawBody ?? '', mount?.provider); + if (!parsed.ok) return { ok: false, error: parsed.error }; + return { ok: true, kind: 'json', body: await client.tree.call(req.toolPath, parsed.body) }; } - } catch (cause) { - const detail = cause instanceof Error ? cause.message : String(cause); - return { - ok: false, - error: wattError('unavailable', `tool bridge unreachable: ${detail}`, true), - }; - } - - const contentType = upstreamRes.headers.get('content-type') ?? ''; - if (!upstreamRes.ok) { - let errBody: unknown; - try { - errBody = await upstreamRes.json(); - } catch { - errBody = undefined; + if (req.op === 'skill') { + const response = await readSkillViaTransport(env, req.toolPath, req.accept); + if (!response.ok) { + return { ok: false, error: await convertToolBridgeResponseError(env, response) }; + } + const contentType = response.headers.get('content-type') ?? ''; + if (contentType.includes('text/plain') || contentType.includes('text/markdown')) { + return { ok: true, kind: 'text', contentType, text: await response.text() }; + } + return { ok: true, kind: 'json', body: await response.json() }; } - return { ok: false, error: convertUpstreamError(upstreamRes.status, errBody) }; - } - // text/plain·markdown(~help/~skill DSL):直接透传(裁剪只对 JSON 结构做)。 - if (contentType.includes('text/plain') || contentType.includes('text/markdown')) { - return { ok: true, kind: 'text', contentType, text: await upstreamRes.text() }; - } - - let jsonBody: unknown; - try { - jsonBody = await upstreamRes.json(); - } catch { - return { ok: false, error: wattError('internal', 'tool bridge returned malformed JSON', true) }; - } - // help/skill(describe):裁剪 resources[];call 结果原样透传。 - if (!isCall && opts.trim !== undefined) { + const wantsText = req.accept?.includes('text/plain') === true; + const body = await client.tree.help(req.toolPath, { accept: wantsText ? 'text' : 'json' }); + if (typeof body === 'string') { + return { ok: true, kind: 'text', contentType: 'text/plain; charset=utf-8', text: body }; + } return { ok: true, kind: 'json', - body: await trimHelpVisibility(jsonBody, req.toolPath, opts.trim), + body: opts.trim ? await trimHelpVisibility(body, req.toolPath, opts.trim) : body, }; + } catch (error) { + return { ok: false, error: convertToolBridgeError(env, error) }; } - return { ok: true, kind: 'json', body: jsonBody }; } diff --git a/packages/gateway/src/tools/toolbridge-admin.ts b/packages/gateway/src/tools/toolbridge-admin.ts new file mode 100644 index 0000000..b39a8cc --- /dev/null +++ b/packages/gateway/src/tools/toolbridge-admin.ts @@ -0,0 +1,280 @@ +import { createToolBridgeAdmin, serviceBinding } from '@tokenroll/tool-bridge/admin'; +import { type WattError, wattError } from '@watt/shared'; +import type { Bindings } from '../env.ts'; + +type Args = Record; + +export const TOOLBRIDGE_ADMIN_ACTIONS: Record = { + AuthConfig: 'read', + ProvidersList: 'read', + ProvidersGet: 'read', + ProvidersCreate: 'manage', + ProvidersUpdate: 'manage', + ProvidersDelete: 'manage', + ProvidersCreateKey: 'manage', + PublicationsList: 'read', + PublicationsGet: 'read', + PublicationsCreate: 'manage', + PublicationsUpdate: 'manage', + PublicationsDelete: 'manage', + PublicationsPublish: 'manage', + PlacementsList: 'read', + PlacementsPut: 'manage', + PlacementsDryRun: 'read', + PlacementsDelete: 'manage', + HostsCreate: 'manage', + HostsGet: 'read', + HostsCreateKey: 'manage', + EndpointsList: 'read', + EndpointsCreate: 'manage', + EndpointsGet: 'read', + EndpointsUpdate: 'manage', + EndpointsRevoke: 'manage', + CommandPoliciesList: 'read', + CommandPoliciesCreate: 'manage', + CommandPoliciesGet: 'read', + CommandPoliciesUpdate: 'manage', + CommandPoliciesDelete: 'manage', + AuditEvents: 'read', + ServersList: 'read', + ServersCreate: 'manage', + ServersDelete: 'manage', + ServersGet: 'read', + ServersTools: 'read', + ServersHelp: 'read', + ServersSkill: 'read', + ServersCall: 'manage', + BridgeTools: 'read', + BridgeCall: 'manage', + TreeGet: 'read', + TreeCrawl: 'read', + TreeHelp: 'read', + TreeCall: 'manage', +}; + +function str(args: Args, key: string): string { + const value = args[key]; + if (typeof value !== 'string' || value.length === 0) { + throw wattError('invalid_argument', `${key} is required`, false); + } + return value; +} + +function obj>(args: Args, key: string): T { + const value = args[key]; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw wattError('invalid_argument', `${key} is required`, false); + } + return value as T; +} + +function optObj>(args: Args, key: string): T | undefined { + const value = args[key]; + if (value === undefined) return undefined; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw wattError('invalid_argument', `${key} must be an object`, false); + } + return value as T; +} + +function credential(env: Bindings): string | WattError { + const key = + env.WATT_TOOLBRIDGE_ADMIN_KEY || env.WATT_TOOLBRIDGE_KEY || env.WATT_TOOLBRIDGE_HOST_KEY; + if (!key) { + return wattError('unavailable', 'Tool Bridge key is not configured', false); + } + return key; +} + +export function convertToolBridgeAdminError(error: unknown): WattError { + if (isWattError(error)) return error; + const e = + error && typeof error === 'object' + ? (error as { status?: number; code?: string; message?: string; retryable?: boolean }) + : {}; + const code = + e.status === 400 + ? 'invalid_argument' + : e.status === 401 + ? 'permission_denied' + : e.status === 403 + ? 'permission_denied' + : e.status === 404 + ? 'not_found' + : e.status === 502 || e.status === 503 + ? 'unavailable' + : 'internal'; + return wattError(code, e.message ?? String(error), e.retryable === true); +} + +function isWattError(v: unknown): v is WattError { + return typeof v === 'object' && v !== null && 'code' in v && 'message' in v && 'retryable' in v; +} + +export async function executeToolBridgeAdmin( + env: Bindings, + tool: string, + args: Args, +): Promise { + const key = credential(env); + if (isWattError(key)) return key; + const admin = createToolBridgeAdmin({ + transport: serviceBinding(env.TOOLBRIDGE), + credential: key, + }); + + try { + switch (tool) { + case 'AuthConfig': + return { config: await admin.auth.config() }; + + case 'ProvidersList': + return { providers: await admin.providers.list() }; + case 'ProvidersGet': + return { provider: await admin.providers.get(str(args, 'id')) }; + case 'ProvidersCreate': + return { provider: await admin.providers.create(obj(args, 'provider')) }; + case 'ProvidersUpdate': + return { provider: await admin.providers.update(str(args, 'id'), obj(args, 'patch')) }; + case 'ProvidersDelete': + await admin.providers.delete(str(args, 'id')); + return { deleted: true }; + case 'ProvidersCreateKey': + return admin.providers.createKey(str(args, 'id'), optObj(args, 'opts') ?? {}); + + case 'PublicationsList': + return { publications: await admin.publications.list(str(args, 'providerId')) }; + case 'PublicationsGet': + return { + publication: await admin.publications.get(str(args, 'providerId'), str(args, 'pubId')), + }; + case 'PublicationsCreate': + return { + publication: await admin.publications.create( + str(args, 'providerId'), + obj(args, 'publication'), + ), + }; + case 'PublicationsUpdate': + return { + publication: await admin.publications.update( + str(args, 'providerId'), + str(args, 'pubId'), + obj(args, 'patch'), + ), + }; + case 'PublicationsDelete': + await admin.publications.delete(str(args, 'providerId'), str(args, 'pubId')); + return { deleted: true }; + case 'PublicationsPublish': + return { + publication: await admin.publications.publish( + str(args, 'providerId'), + str(args, 'pubId'), + ), + }; + + case 'PlacementsList': + return { + placements: await admin.placements.list( + typeof args.tenantId === 'string' ? args.tenantId : undefined, + ), + }; + case 'PlacementsPut': + return admin.placements.put( + obj(args, 'placement') as unknown as Parameters[0], + ); + case 'PlacementsDryRun': + return admin.placements.dryRun( + obj(args, 'placement') as unknown as Parameters[0], + ); + case 'PlacementsDelete': + await admin.placements.delete( + str(args, 'id'), + typeof args.tenantId === 'string' ? args.tenantId : undefined, + optObj(args, 'opts') ?? {}, + ); + return { deleted: true }; + + case 'HostsCreate': + return { host: await admin.hosts.create(obj(args, 'host')) }; + case 'HostsGet': + return { host: await admin.hosts.get(str(args, 'id')) }; + case 'HostsCreateKey': + return admin.hosts.createKey(str(args, 'id'), optObj(args, 'opts') ?? {}); + + case 'EndpointsList': + return { endpoints: await admin.endpoints.list() }; + case 'EndpointsCreate': + return { + endpoint: await admin.endpoints.create( + obj(args, 'endpoint') as unknown as Parameters[0], + ), + }; + case 'EndpointsGet': + return { endpoint: await admin.endpoints.get(str(args, 'id')) }; + case 'EndpointsUpdate': + return { endpoint: await admin.endpoints.update(str(args, 'id'), obj(args, 'patch')) }; + case 'EndpointsRevoke': + return { endpoint: await admin.endpoints.revoke(str(args, 'id')) }; + + case 'CommandPoliciesList': + return { policies: await admin.commandPolicies.list() }; + case 'CommandPoliciesCreate': + return { policy: await admin.commandPolicies.create(obj(args, 'policy')) }; + case 'CommandPoliciesGet': + return { policy: await admin.commandPolicies.get(str(args, 'id')) }; + case 'CommandPoliciesUpdate': + return { policy: await admin.commandPolicies.update(str(args, 'id'), obj(args, 'patch')) }; + case 'CommandPoliciesDelete': + await admin.commandPolicies.delete(str(args, 'id')); + return { deleted: true }; + + case 'AuditEvents': + return admin.audit.events(optObj(args, 'opts') ?? {}); + + case 'ServersList': + return admin.servers.list(); + case 'ServersCreate': + return admin.servers.create(obj(args, 'server')); + case 'ServersDelete': + await admin.servers.delete(str(args, 'id')); + return { deleted: true }; + case 'ServersGet': + return admin.servers.get(str(args, 'id')); + case 'ServersTools': + return admin.servers.tools(str(args, 'id')); + case 'ServersHelp': + return { text: await admin.servers.help(str(args, 'id')) }; + case 'ServersSkill': + return { text: await admin.servers.skill(str(args, 'id')) }; + case 'ServersCall': + return admin.servers.call(str(args, 'id'), str(args, 'toolName'), args.arguments ?? {}); + + case 'BridgeTools': + return admin.bridge.tools( + obj(args, 'server') as unknown as Parameters[0], + ); + case 'BridgeCall': + return admin.bridge.call( + obj(args, 'server') as unknown as Parameters[0], + str(args, 'toolName'), + args.arguments ?? {}, + ); + + case 'TreeGet': + return { tree: await admin.tree.get() }; + case 'TreeCrawl': + return { tree: await admin.tree.crawl(optObj(args, 'opts') ?? {}) }; + case 'TreeHelp': + return { help: await admin.tree.help(typeof args.path === 'string' ? args.path : '') }; + case 'TreeCall': + return admin.tree.call(str(args, 'path'), args.body ?? {}); + + default: + return wattError('invalid_argument', `unknown tool: ${tool}`, false); + } + } catch (error) { + return convertToolBridgeAdminError(error); + } +} diff --git a/packages/gateway/src/worker-env.d.ts b/packages/gateway/src/worker-env.d.ts index edb26b5..a5cd6de 100644 --- a/packages/gateway/src/worker-env.d.ts +++ b/packages/gateway/src/worker-env.d.ts @@ -6,6 +6,19 @@ import type { Bindings } from './env.ts'; declare global { + interface Env extends Bindings { + ASSETS: Fetcher; + AUTH_BEARER_TOKEN?: string; + OAUTH_ISSUER?: string; + OAUTH_JWKS_URI?: string; + OAUTH_REQUIRED_AUDIENCE?: string; + MCP_SERVERS_JSON?: string; + ALLOW_INSECURE_MCP_HTTP?: string; + HTBP_REMOTE_ALLOWLIST?: string; + TENANT_MODE?: string; + TENANTS?: KVNamespace; + } + namespace Cloudflare { interface Env extends Bindings {} } diff --git a/packages/gateway/test/platform-toolbridge.test.ts b/packages/gateway/test/platform-toolbridge.test.ts new file mode 100644 index 0000000..5c16483 --- /dev/null +++ b/packages/gateway/test/platform-toolbridge.test.ts @@ -0,0 +1,75 @@ +/// +import { env, SELF } from 'cloudflare:test'; +import { importPrivateJwk, signUserToken, type TokenMeta } from '@watt/core'; +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { resetSeedGuardForTests } from '../src/authz/seed.ts'; +import { PLATFORM_KID } from '../src/env.ts'; +import { convertToolBridgeAdminError } from '../src/tools/toolbridge-admin.ts'; +import { + TEST_ADMIN_PRINCIPAL, + TEST_JWT_AUDIENCE, + TEST_JWT_ISSUER, + TEST_PRIVATE_JWK, +} from './fixtures/test-key.ts'; + +const META: TokenMeta = { issuer: TEST_JWT_ISSUER, audience: TEST_JWT_AUDIENCE }; +const BASE = 'https://gateway.test'; + +let signAdmin: () => Promise; +let signNonAdmin: () => Promise; + +beforeAll(async () => { + const { priv } = await importPrivateJwk(TEST_PRIVATE_JWK, PLATFORM_KID); + signAdmin = () => + signUserToken({ principal: TEST_ADMIN_PRINCIPAL, roles: ['admin'], trace: 'tr-t' }, priv, META); + signNonAdmin = () => signUserToken({ principal: 'user:bob', roles: ['staff'] }, priv, META); +}); + +beforeEach(async () => { + await env.DB_POLICIES.prepare('DELETE FROM policies').run(); + await env.DB_POLICIES.prepare('DELETE FROM identity_mappings').run(); + resetSeedGuardForTests(); +}); + +async function call(token: string, tool: string, args: Record = {}) { + return SELF.fetch(`${BASE}/htbp/platform/toolbridge`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + body: JSON.stringify({ tool, arguments: args }), + }); +} + +describe('POST /htbp/platform/toolbridge — Admin SDK bridge', () => { + it('admin can call read methods through Tool Bridge Admin SDK', async () => { + const res = await call(await signAdmin(), 'ProvidersList'); + expect(res.status).toBe(200); + const body = (await res.json()) as { providers: unknown[] }; + expect(Array.isArray(body.providers)).toBe(true); + }); + + it('delete methods return a JSON object when the SDK returns no body', async () => { + const res = await call(await signAdmin(), 'ProvidersDelete', { id: 'acme' }); + expect(res.status).toBe(200); + const body = (await res.json()) as { deleted: boolean }; + expect(body).toEqual({ deleted: true }); + }); + + it('non-admin is denied before Tool Bridge is called', async () => { + const res = await call(await signNonAdmin(), 'ProvidersList'); + expect(res.status).toBe(403); + const body = (await res.json()) as { code: string }; + expect(body.code).toBe('permission_denied'); + }); + + it('unknown method returns invalid_argument', async () => { + const res = await call(await signAdmin(), 'NoSuchMethod'); + expect(res.status).toBe(400); + const body = (await res.json()) as { code: string }; + expect(body.code).toBe('invalid_argument'); + }); + + it('admin error conversion handles nullish thrown values', () => { + expect(convertToolBridgeAdminError(null).code).toBe('internal'); + expect(convertToolBridgeAdminError(undefined).code).toBe('internal'); + }); +}); diff --git a/packages/gateway/test/tools-proxy.test.ts b/packages/gateway/test/tools-proxy.test.ts index 2c2dec5..80251c2 100644 --- a/packages/gateway/test/tools-proxy.test.ts +++ b/packages/gateway/test/tools-proxy.test.ts @@ -3,8 +3,10 @@ import { env, SELF } from 'cloudflare:test'; import { importPrivateJwk, signUserToken, type TokenMeta } from '@watt/core'; import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { resetSeedGuardForTests } from '../src/authz/seed.ts'; +import type { Bindings } from '../src/env.ts'; import { PLATFORM_KID } from '../src/env.ts'; import { buildUpstreamTree, resetSyncStateForTests } from '../src/http/tools-proxy.ts'; +import { executeToolRequest } from '../src/tools/tool-invoker.ts'; import { TEST_ADMIN_PRINCIPAL, TEST_JWT_AUDIENCE, @@ -176,6 +178,15 @@ describe('/htbp/tools/* — 认证与 Check PEP', () => { }); describe('/htbp/tools/* — 正常转发(路径重写 + 树同步)', () => { + it('缺少 Tool Bridge key 时同步前返回 unavailable', async () => { + const result = await executeToolRequest({} as Bindings, { toolPath: 'echo/svc', op: 'help' }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe('unavailable'); + expect(result.error.message).toBe('Tool Bridge key is not configured'); + } + }); + it('admin ~help 树根:resources 列出已挂载子树(路径已重写)', async () => { const admin = await signAdmin(); await mountHttp(admin, 'echo/svc'); @@ -275,12 +286,12 @@ describe('/htbp/tools/* — 错误形状转换', () => { }); }); -describe('/htbp/tools/* — 树同步只在变更时写 KV(finding 3,KV 单键 1 写/秒限流)', () => { - it('同树连续两请求:tenant 键只写一次(第二请求命中 isolate 缓存跳过)', async () => { +describe('/htbp/tools/* — SDK mounts.sync 只在变更时调用', () => { + it('同树连续两请求:mounts:sync 只调用一次(第二请求命中 isolate 缓存跳过)', async () => { const admin = await signAdmin(); await mountHttp(admin, 'echo/svc'); resetSyncStateForTests(); // mount 已改树;从干净 isolate 缓存态起测两次同树请求。 - const putSpy = vi.spyOn(env.KV_TENANTS, 'put'); + const fetchSpy = vi.spyOn(env.TOOLBRIDGE, 'fetch'); try { for (let i = 0; i < 2; i++) { const res = await SELF.fetch(`${BASE}/htbp/tools/~help`, { @@ -289,13 +300,14 @@ describe('/htbp/tools/* — 树同步只在变更时写 KV(finding 3,KV 单 }); expect(res.status).toBe(200); } - // tenant: 至多写一次(首请求;第二请求 hash 未变跳过)。apikey 常量键至多写一次。 - const tenantPuts = putSpy.mock.calls.filter((c) => String(c[0]).startsWith('tenant:')); - const apikeyPuts = putSpy.mock.calls.filter((c) => String(c[0]).startsWith('apikey:')); - expect(tenantPuts.length).toBeLessThanOrEqual(1); - expect(apikeyPuts.length).toBeLessThanOrEqual(1); + const syncCalls = fetchSpy.mock.calls.filter((c) => { + const input = c[0]; + const url = input instanceof Request ? input.url : String(input); + return new URL(url).pathname.endsWith('/mounts:sync'); + }); + expect(syncCalls.length).toBe(1); } finally { - putSpy.mockRestore(); + fetchSpy.mockRestore(); } }); }); diff --git a/packages/gateway/vitest.config.ts b/packages/gateway/vitest.config.ts index 9662a5b..dabf479 100644 --- a/packages/gateway/vitest.config.ts +++ b/packages/gateway/vitest.config.ts @@ -25,9 +25,10 @@ const migrationsProviders = await readD1Migrations(resolve(here, 'migrations-pro const migrationsAudit = await readD1Migrations(resolve(here, 'migrations-audit')); // Fake watt-toolbridge(service binding TOOLBRIDGE)——真实 watt-toolbridge Worker 是独立部署单元 -// (packages/toolbridge),本地 vitest 无从加载。此 fake **忠实复现** vendored 上游 packages/toolbridge/ -// vendor/ 的调用语义(逐项对齐,注释标源):读共享 KV(tenant: 树,由 gateway 代理层 syncTenantTree -// 写入)→ 按 findNode 语义解析节点 + adapter sub 段 → 按节点 type 分派 describe/call。关键忠实点(这些正是 +// (packages/toolbridge),本地 vitest 无从加载。此 fake 复现新 SDK 链路的关键契约: +// Host SDK 先 POST /api/hosts/:id/mounts:sync,把 Watt ToolMount 映射成 HostMount;随后 +// tree.help/tree.call 走 /htbp/*。fake 记录同步后的 host tree,再按上游 findNode/adapter 语义解析。 +// 关键忠实点(这些正是 // fake 曾掩盖的契约漂移面): // - findNode(registry.ts):directory 逐级下钻,走到非 directory 叶子后剩余段是 adapter sub。 // - http adapter(adapters/http.ts):call 要求 sub.length>0(endpoint 名),否则上游抛 @@ -38,8 +39,7 @@ const migrationsAudit = await readD1Migrations(resolve(here, 'migrations-audit') // - directory adapter(adapters/directory.ts):call 不可调用 → 上游抛 "not callable" → 500。 // - describe:directory/叶子·无 sub → resources[](列子节点/端点);叶子·有 sub → endpoint schema。 // - 缺省节点 → 上游 NotFoundError → {error:{code:'not_found'}}(HTTP 404)。 -// 这样"树同步 + 路径重写 + body 归一化 + 错误形状转换"被真实穿透(proxy 写 KV,fake 从同一 KV 读回并按 -// 上游语义解析),而非 mock 掉转发。 +// 这样"SDK mounts.sync + tree.help/tree.call + body 归一化 + 错误形状转换"被真实穿透。 // // 上游 tools/call 的 arguments 信封容忍逻辑(adapters/mcp.ts extractArguments,builtin 同)。 function extractArguments(input: unknown): unknown { @@ -49,10 +49,39 @@ function extractArguments(input: unknown): unknown { return input ?? {}; } +const hostTrees = new Map(); +const hostRecords = new Map>(); + +function buildTreeFromHostMounts(mounts: HostMountInput[]): TbNode { + const root: TbNode = { type: 'directory', id: 'root', title: 'Watt Tools', children: [] }; + for (const mount of mounts) { + const segs = mount.path.split('/').filter((s) => s.length > 0); + if (segs.length === 0) continue; + let dir = root; + for (let i = 0; i < segs.length - 1; i++) { + const seg = segs[i] as string; + let child = dir.children?.find((c) => c.id === seg && c.type === 'directory'); + if (!child) { + child = { type: 'directory', id: seg, children: [] }; + dir.children = dir.children ?? []; + dir.children.push(child); + } + dir = child; + } + const leafId = segs[segs.length - 1] as string; + const leaf = { id: leafId, ...mount.binding, type: String(mount.binding.type ?? 'mcp') }; + dir.children = dir.children ?? []; + const existing = dir.children.findIndex((c) => c.id === leafId); + if (existing >= 0) dir.children[existing] = leaf; + else dir.children.push(leaf); + } + return root; +} + async function fakeToolBridge( request: Request, // biome-ignore lint/suspicious/noExplicitAny: miniflare 实例的窄类型由 pool-workers 提供,此处仅取 KV。 - mf: any, + _mf: any, ): Promise { const url = new URL(request.url); const err = (status: number, code: string, message: string): Response => @@ -61,21 +90,75 @@ async function fakeToolBridge( headers: { 'content-type': 'application/json' }, }); + if (url.pathname === '/api/auth/config' && request.method === 'GET') { + return json({ mode: 'bearer' }); + } + if (url.pathname === '/api/hosts' && request.method === 'POST') { + const body = (await request.json().catch(() => ({}))) as { id?: string }; + const id = body.id ?? 'watt'; + const host = { + id, + tenantId: id, + providerId: id, + confirmDelegated: true, + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + }; + hostRecords.set(id, host); + hostTrees.set(id, { type: 'directory', id: 'root', title: id, children: [] }); + return json({ host }); + } + const hostMatch = /^\/api\/hosts\/([^/]+)(?:\/(.*))?$/.exec(url.pathname); + if (hostMatch) { + const hostId = decodeURIComponent(hostMatch[1] ?? 'watt'); + const rest = hostMatch[2] ?? ''; + if (rest === 'mounts:sync' && request.method === 'POST') { + const body = (await request.json().catch(() => ({}))) as { mounts?: HostMountInput[] }; + const mounts = Array.isArray(body.mounts) ? body.mounts : []; + hostTrees.set(hostId, buildTreeFromHostMounts(mounts)); + return json({ ok: true, applied: mounts.length, removed: 0, placements: [] }); + } + if (rest === '' && request.method === 'GET') { + return json({ + host: hostRecords.get(hostId) ?? { + id: hostId, + tenantId: hostId, + providerId: hostId, + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + }, + }); + } + if (rest === 'keys' && request.method === 'POST') { + return json({ + key: 'tbk_test_host', + record: { principal: 'host', hostId, tenantId: hostId, providerId: hostId }, + }); + } + } + if (url.pathname === '/api/providers' && request.method === 'GET') return json({ providers: [] }); + if (/^\/api\/providers\/[^/]+$/.test(url.pathname) && request.method === 'DELETE') { + return json({}); + } + if (url.pathname === '/api/placements' && request.method === 'GET') + return json({ placements: [] }); + if (url.pathname === '/api/endpoints' && request.method === 'GET') return json({ endpoints: [] }); + if (url.pathname === '/api/command-policies' && request.method === 'GET') + return json({ policies: [] }); + if (url.pathname === '/api/audit/events' && request.method === 'GET') + return json({ scope: 'test', events: [] }); + if (url.pathname === '/api/servers' && request.method === 'GET') + return json({ servers: [], dynamicEnabled: true }); + if (url.pathname === '/api/tree' && request.method === 'GET') + return json({ tree: hostTrees.get('watt') ?? { type: 'directory', id: 'root', children: [] } }); + // 断言路径已被重写为 /htbp/*(非 /htbp/tools/*)——代理契约。 if (!url.pathname.startsWith('/htbp')) return err(404, 'not_found', 'not a tb path'); if (url.pathname.startsWith('/htbp/tools')) return err(400, 'bad_request', 'path was not rewritten'); - // 从共享 KV 读租户树(proxy 已写入)——fake 与 gateway 共用 KV_TENANTS,故树同步被真实穿透。 - const token = (request.headers.get('Authorization') ?? '').replace(/^Bearer\s+/i, ''); - const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token)); - const hash = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join(''); - const kv = await mf.getKVNamespace('KV_TENANTS'); - const mapping = (await kv.get(`apikey:${hash}`, 'json')) as { tenantId?: string } | null; - if (!mapping?.tenantId) return err(401, 'unauthorized', 'unknown secret key'); - const treeRaw = await kv.get(`tenant:${mapping.tenantId}`); - if (!treeRaw) return err(404, 'not_found', 'no tenant tree'); - const tree = JSON.parse(treeRaw) as TbNode; + const tree = hostTrees.get('watt'); + if (!tree) return err(404, 'not_found', 'no synced host tree'); const rest = url.pathname.slice('/htbp'.length).replace(/^\/+/, ''); const segsAll = rest.split('/').filter((s) => s.length > 0); @@ -143,6 +226,11 @@ interface TbNode { endpoints?: Array<{ name: string }>; } +interface HostMountInput { + path: string; + binding: Record; +} + function json(data: unknown): Response { return new Response(JSON.stringify(data), { status: 200, @@ -223,6 +311,7 @@ export default defineConfig({ WEBHOOK_SECRET_TEST: 'integration-webhook-secret', // SecretStore 主密钥(§6.6,platform-secret 集成测试)——固定测试值(32B base64url)。 WATT_SECRET_ENCRYPTION_KEY: 'XOL8MO7eCWDeaTn27cjz6KkV2u3o0d1KnpKzVQxUebQ', + WATT_TOOLBRIDGE_KEY: 'tbk_test_admin', // Root Key 摘要(§6.5e,oauth-root 集成测试)——sha256('wrk_test_root_key_fixture_0001')。 WATT_ROOT_KEY_HASH: '9f5c3e27e76bb61a941267e65c0571361ca0debbb77d579f1b32466109a15cdd', // @llm 门控透传:workerd 内读不到宿主 process.env,经 binding 注入(缺省空 → 测试 skip)。 diff --git a/packages/toolbridge/README.md b/packages/toolbridge/README.md index 13a5a7e..a87e057 100644 --- a/packages/toolbridge/README.md +++ b/packages/toolbridge/README.md @@ -5,37 +5,22 @@ Watt gateway 经 **service binding** `TOOLBRIDGE` 把 `/htbp/tools/*` 代理到 错误形状转换 + 可见性裁剪都在 gateway 代理层 `packages/gateway/src/http/tools-proxy.ts`,本 Worker 只做 树解析 / `~help` / 调用 / 虚拟化 / 租户隔离)。 -## 为什么 vendored 而非依赖引用 +## 接入方式 -`vendor/` 是 `TokenRollAI/tool-bridge`(私有仓库,分支 `feat/watt-builtin-and-tool-semantics`, -commit `56ab13b`)的 **worker 源码**(`src/worker/`)拷贝。选 vendoring 的原因(实现自由决策): +`watt-toolbridge` 通过 npm 包 `@tokenroll/tool-bridge` 接入上游能力,不再依赖私有 git 仓库或 vendored +源码。当前使用的入口: -- 上游 `package.json` 的运行时依赖含 React / TanStack / vite(整套 dashboard UI),且 `deploy` 需先 - `vite build` 产出 `dist/client` 供 `ASSETS` 绑定。作为纯代理目标,Watt 不需要 UI。以 `github:` pnpm - 依赖或 npm pack 引入会把整棵 UI 依赖树 + 构建步骤拖进 monorepo,且 workspace hoisting 与私有仓库 - 认证(gh token)会让离线 typecheck / CI 不稳定。 -- worker 路径(`src/worker/`)的外部依赖只有 `jose`(monorepo 已有),无 UI 依赖 → 干净可 vendor。 -- vendored 后本包用**独立 tsconfig**(不 extends 仓库 `tsconfig.base.json`)保留上游宽松 module 语法 - (无 `verbatimModuleSyntax` / `noUnusedLocals` / `.ts` 扩展名导入要求),上游代码原样通过 typecheck, - Watt 侧不改上游一行。biome 排除 `**/vendor` 不对上游代码做本仓库风格 lint。 +- `@tokenroll/tool-bridge/worker`:本 Worker 的嵌入入口,`src/index.ts` 调用 `createBridge()` 暴露 Tool + Bridge 服务。 +- `@tokenroll/tool-bridge/host`:gateway 消费面调用 Host SDK,同步 mount 树并执行 `~help` / tool call。 +- `@tokenroll/tool-bridge/admin`:gateway 管理面调用 Admin SDK,覆盖 provider、host、endpoint、audit、 + legacy MCP server 等管理 API。 -## 对上游的唯一偏离 - -`vendor/index.ts` 与 `vendor/types.d.ts` 里各一处 `WATT VENDOR PATCH` 标注:headless 部署无 `ASSETS` -绑定,故非 `/api`·`/mcp`·`/htbp` 路径返回 404(上游此处 `env.ASSETS.fetch`)。除此之外 vendor/ 与上游 -commit `56ab13b` 逐字节一致。 - -## 升级流程(上游有新改动时) - -1. `gh repo clone TokenRollAI/tool-bridge`(gh 已有 repo scope)→ checkout 目标分支; -2. 在上游开分支实现 / 跑通其 vitest → push(LOOP §2.1 上游通道); -3. `rm -rf vendor && cp -R /src/worker/. vendor/ && find vendor -name '*.test.ts' -delete`; -4. 重新施加上述 `WATT VENDOR PATCH`(ASSETS 可选 + 404 兜底); -5. `pnpm --filter watt-toolbridge typecheck` 验证 → 更新本文件的 commit 锚点。 +这样 CI 只需要访问 npm registry,不需要 GitHub 私库读取 token。升级 Tool Bridge 时,更新 +`@tokenroll/tool-bridge` 版本并重新生成 `pnpm-lock.yaml` 即可。 ## 树配置的运行时同步 -上游从 `MCP_SERVERS_JSON` env var(全局树)或 `TENANTS` KV(`tenant:` 键,`parseTreeFromJson`) -加载树。本 Worker 开 `TENANT_MODE=true`:gateway 代理层在每次转发前,把调用者可见的 `ToolMount` 集合 -转成上游树 JSON 写入共享 KV,并以对应 Secret Key 作 Bearer 转发。KV 是运行时可写的树源,故无需重部署即可 -反映 mount 变更(详见 `tools-proxy.ts`)。 +Tool Bridge 从 `TENANTS` KV 读取租户树。本 Worker 开 `TENANT_MODE=true`:gateway 在调用前通过 Host SDK +把调用者可见的 `ToolMount` 集合同步到 Tool Bridge,再以对应 S2S key 通过 service binding 转发。 +KV 是运行时可写的树源,故无需重部署即可反映 mount 变更(详见 `packages/gateway/src/tools/tool-invoker.ts`)。 diff --git a/packages/toolbridge/package.json b/packages/toolbridge/package.json index d7c4a04..66e9b90 100644 --- a/packages/toolbridge/package.json +++ b/packages/toolbridge/package.json @@ -8,10 +8,11 @@ "dev": "wrangler dev", "deploy": "wrangler deploy", "typecheck": "tsc --noEmit", - "test": "node scripts/check-vendor-patches.mjs" + "test": "tsc --noEmit" }, "dependencies": { - "jose": "^6.2.3" + "jose": "^6.2.3", + "@tokenroll/tool-bridge": "^0.1.0" }, "devDependencies": { "@cloudflare/workers-types": "4.20260702.1", diff --git a/packages/toolbridge/scripts/check-vendor-patches.mjs b/packages/toolbridge/scripts/check-vendor-patches.mjs index e9c4584..d7707b6 100644 --- a/packages/toolbridge/scripts/check-vendor-patches.mjs +++ b/packages/toolbridge/scripts/check-vendor-patches.mjs @@ -1,11 +1,10 @@ #!/usr/bin/env node /** - * check-vendor-patches.mjs — vendored 上游 patch 漂移守卫。 + * check-vendor-patches.mjs — legacy vendored 上游 patch 漂移守卫。 * - * watt-toolbridge 的 vendor/ 源码整体照抄自上游 TokenRollAI/tool-bridge,唯一偏离是标注 - * `WATT VENDOR PATCH` 的 headless 部署改动(ASSETS 绑定可选:vendor/index.ts 的 fetch 兜底 + - * vendor/types.d.ts 的 Env.ASSETS?)。重新 vendor(升级上游)时若 patch 被覆盖冲掉,headless - * 部署会退回上游"必有 ASSETS"假设 → 运行时崩。此脚本锁定 patch 标记数量恒定,冲掉即 CI/verify 红。 + * 当前 watt-toolbridge 运行时代码来自 npm 包 @tokenroll/tool-bridge;vendor/ 目录仅作为历史拷贝保留。 + * 若后续重新启用 vendor 模式,此脚本用于确认 `WATT VENDOR PATCH` 的 headless 部署改动仍存在 + * (ASSETS 绑定可选:vendor/index.ts 的 fetch 兜底 + vendor/types.d.ts 的 Env.ASSETS?)。 * * 期望:vendor 代码内(index.ts + types.d.ts)恰好 EXPECTED_MARKS 处标记。改动 patch 时同步改常量。 */ diff --git a/packages/toolbridge/src/env.d.ts b/packages/toolbridge/src/env.d.ts new file mode 100644 index 0000000..a60f7c8 --- /dev/null +++ b/packages/toolbridge/src/env.d.ts @@ -0,0 +1,16 @@ +declare global { + interface Env { + ASSETS: Fetcher; + AUTH_BEARER_TOKEN?: string; + OAUTH_ISSUER?: string; + OAUTH_JWKS_URI?: string; + OAUTH_REQUIRED_AUDIENCE?: string; + MCP_SERVERS_JSON?: string; + ALLOW_INSECURE_MCP_HTTP?: string; + HTBP_REMOTE_ALLOWLIST?: string; + TENANT_MODE?: string; + TENANTS?: KVNamespace; + } +} + +export {}; diff --git a/packages/toolbridge/src/index.ts b/packages/toolbridge/src/index.ts index ee2673f..72eb3f4 100644 --- a/packages/toolbridge/src/index.ts +++ b/packages/toolbridge/src/index.ts @@ -1,19 +1,10 @@ /** - * watt-toolbridge Worker 入口(Phase 4 M4 Tool Gateway 部署单元)。 + * watt-toolbridge Worker 入口。 * - * 这是 `TokenRollAI/tool-bridge`(分支 feat/watt-builtin-and-tool-semantics,commit 56ab13b) - * 的 worker 源码 vendored 到 ../vendor/(见 ../README.md 说明与升级流程)。本文件只做薄再导出: - * 整棵 HTBP 树的解析/~help/调用/虚拟化/租户逻辑全在 vendor/ 的上游实现里,Watt 侧不改上游一行 - * (唯一偏离是 vendor/index.ts + vendor/types.d.ts 里标注的 "WATT VENDOR PATCH":headless 部署 - * 无 ASSETS 绑定时 404 兜底)。 - * - * 集成拓扑(方案 A,见 .llmdoc-tmp/investigations/phase4-tool-agent.md §拓扑): - * watt-toolbridge 作为独立 Worker,Watt gateway 经 service binding TOOLBRIDGE 把 /htbp/tools/* - * 代理到本 Worker 的 /htbp/*(Check PEP + 错误形状转换 + 可见性裁剪在 gateway 代理层,见 - * packages/gateway/src/http/tools-proxy.ts)。租户树由 gateway 在转发前写入共享 KV - * (TENANT_MODE=true + TENANTS 绑定 watt-tenants),本 Worker 按 Secret Key 加载对应租户树。 + * Tool Bridge 作为独立项目提供 Worker + SDK。Watt 只嵌入 Worker factory,不再 vendored 上游源码; + * Gateway 侧通过 Host/Admin SDK 走 service binding 调用本 Worker。 */ -import worker from '../vendor/index'; +import { createBridge } from '@tokenroll/tool-bridge/worker'; -export default worker; +export default createBridge(); diff --git a/packages/toolbridge/tsconfig.json b/packages/toolbridge/tsconfig.json index c063241..4692626 100644 --- a/packages/toolbridge/tsconfig.json +++ b/packages/toolbridge/tsconfig.json @@ -15,5 +15,5 @@ "noEmit": true, "types": ["@cloudflare/workers-types"] }, - "include": ["src/**/*.ts", "vendor/**/*.ts", "vendor/**/*.d.ts"] + "include": ["src/**/*.ts"] } diff --git a/packages/toolbridge/wrangler.jsonc b/packages/toolbridge/wrangler.jsonc index 665e62d..8469a79 100644 --- a/packages/toolbridge/wrangler.jsonc +++ b/packages/toolbridge/wrangler.jsonc @@ -1,7 +1,7 @@ { // watt-toolbridge Worker — Phase 4 Tool Gateway 部署单元(方案 A:独立 Worker + gateway service binding)。 // 真源:DOD.md §6(Tool Layer)、Architecture.md M4、.llmdoc-tmp/investigations/phase4-tool-agent.md。 - // worker 源码 vendored 自 TokenRollAI/tool-bridge(见 README.md);本文件是 Watt 侧的部署配置。 + // worker 运行时代码来自 npm 包 @tokenroll/tool-bridge;本文件是 Watt 侧的部署配置。 "$schema": "node_modules/wrangler/config-schema.json", "name": "watt-toolbridge", "main": "src/index.ts", @@ -14,8 +14,8 @@ "workers_dev": true, // 租户模式(TENANT_MODE=true + TENANTS 绑定):gateway 代理层在转发前把调用者可见的 ToolMount - // 集合转成上游树 JSON 写入 KV(tenant: + apikey:),本 Worker 按 Secret Key 加载该 - // 租户树——树配置的运行时同步机制。共享 gateway 的 watt-tenants KV(同 id)。 + // 集合通过 Host SDK 同步到 Tool Bridge,本 Worker 按 S2S key 加载对应租户树。共享 gateway 的 + // watt-tenants KV(同 id)。 "vars": { "TENANT_MODE": "true", "OAUTH_REQUIRED_AUDIENCE": "" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 303837e..ecd2385 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -131,6 +131,9 @@ importers: tailwind-merge: specifier: ^3.3.1 version: 3.6.0 + tslib: + specifier: ^2.8.1 + version: 2.8.1 devDependencies: '@react-router/dev': specifier: ^7.9.4 @@ -165,6 +168,9 @@ importers: '@ai-sdk/anthropic': specifier: ^3.0.92 version: 3.0.92(zod@4.1.11) + '@tokenroll/tool-bridge': + specifier: ^0.1.0 + version: 0.1.0 '@watt/core': specifier: workspace:* version: link:../core @@ -231,6 +237,9 @@ importers: packages/toolbridge: dependencies: + '@tokenroll/tool-bridge': + specifier: ^0.1.0 + version: 0.1.0 jose: specifier: ^6.2.3 version: 6.2.3 @@ -2331,6 +2340,9 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@tokenroll/tool-bridge@0.1.0': + resolution: {integrity: sha512-ICVZRbrv7hewPGwFGaJE41O/9JsAiiEfuPNOY/IoGMydm41Bhta/u5SMrXdIumtII+JYarR58S6/Gi8/8rk2fQ==} + '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} @@ -5734,6 +5746,10 @@ snapshots: tailwindcss: 4.3.2 vite: 8.1.2(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) + '@tokenroll/tool-bridge@0.1.0': + dependencies: + jose: 6.2.3 + '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ce3309f..0273e53 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -17,3 +17,4 @@ minimumReleaseAgeExclude: - wrangler@4.107.0 - '@ai-sdk/gateway@3.0.143' - ai@6.0.219 + - '@tokenroll/tool-bridge@0.1.0' diff --git a/scripts/deploy-all.mjs b/scripts/deploy-all.mjs index 6eb7e16..21fff61 100644 --- a/scripts/deploy-all.mjs +++ b/scripts/deploy-all.mjs @@ -195,7 +195,12 @@ function checkGatewaySecrets() { } // secret list 输出可能是 JSON 数组或 name 行;用 substring 匹配名字(避开 env-token banner,§7)。 const listed = `${res.stdout ?? ''}`; - const REQUIRED = ['WATT_JWT_PRIVATE_JWK', 'WATT_ADMIN_PRINCIPAL', 'WATT_SECRET_ENCRYPTION_KEY']; + const REQUIRED = [ + 'WATT_JWT_PRIVATE_JWK', + 'WATT_ADMIN_PRINCIPAL', + 'WATT_SECRET_ENCRYPTION_KEY', + 'WATT_TOOLBRIDGE_KEY', + ]; // 飞书凭据已随 P1 迁往 watt-plugin-feishu(见 checkPluginFeishuSecrets)——gateway 不再需要 FEISHU_*。 const OPTIONAL = ['ANTHROPIC_API_KEY']; const missingReq = REQUIRED.filter((n) => !listed.includes(n)); @@ -206,6 +211,7 @@ function checkGatewaySecrets() { ' 认证端点会 500(WATT_JWT_PRIVATE_JWK/WATT_ADMIN_PRINCIPAL);' + 'WATT_SECRET_ENCRYPTION_KEY 缺则 /htbp/platform/secret 报 unavailable(32B base64url,' + "生成:node -e \"process.stdout.write(require('crypto').randomBytes(32).toString('base64url'))\")。" + + 'WATT_TOOLBRIDGE_KEY 缺则 Tool Bridge SDK 同步/管理面不可用。' + ' 补:wrangler secret put (见 scripts/gen-jwt-keys.mjs)。', ); }