P4.7 P1 收尾(内部信任认证 + name:action + manifest 填充 + Task 8)Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 让 /chat 的 read-class 业务 tool 第一次真正打通到 Hermes——通过内部信任认证绕过 runner-token(/chat 是可信容器,runner-token 是错配),补齐 name:action / manifest / MCP spawn 三个剩余阻塞。
Architecture: /chat 的 Hermes 跑在我们自己的可信 agent 容器里(agent 进程本地 spawn 的子进程),不是不可信的 remote runner。因此 tool-gateway 的 runner-token 认证对这条路是错配。方案 = gateway 加一条内部信任分支:agent 容器 用它已有的 AGENT_SERVICE_SECRET(agent↔NestJS 既有对称互信密钥)作 bearer,gateway 用 timingSafeEqual 校验后从 body orgId 合成 req.runner 放行,限 read-class,下游 tenant-safety(AgentRun{id,orgId} 二元查找 / manifest 校验 / ActionPolicy / feature flag)全保留。remote-runner 路径走原 runner-JWT,零影响,两条入口各匹配自己的信任模型。
Tech Stack: NestJS 11(RunnerTokenGuard / ConfigService / crypto.timingSafeEqual)+ Python 3.11 agent(hermes_client secret-fence / tool_gateway_client / humanwork_mcp_server)+ Hermes v0.17.0 cli-config mcp_servers。
背景与权威事实(动工前必读,全部 file:line 亲验于本 worktree HEAD 643a9028)
已完成(Task 1-7,长期分支 feat/specialist-value-output-p2plus):见 docs/superpowers/plans/2026-07-09-p4.7-phase1-read-tool-dispatch-implementation.md + 深挖文档 docs/superpowers/specs/2026-07-09-p4.7-p1-auth-gap-deepdive.md。T7 已产 slug 交集 resolveSpecialistToolsets(flag tool_dispatch_enabled 默认 OFF)。
本计划前的 4 个剩余阻塞(深挖确认,次序):
- 认证(本计划核心):MCP 子进程发的 bearer 是 runner-token(dev 容器落到无效 OpenRouter key),过不了 RunnerTokenGuard。→ 内 部信任绕过。
- name:action:交集现只产 slug(
["order_lookup"]),MCP server 要name:action(order_lookup:lookup)。权威源 =api/src/agent-api/tool-registry.tsAGENT_TOOL_REGISTRY[slug].actions。 - manifest 填充:gateway 校验
run.metadata.runtimeManifest.allowedTools(tool-gateway.service.ts:300-316),/chat draft 的 AgentRun 只写inboundMessageRole/channel(conversations.service.ts:3207)→ NOT_IN_MANIFEST 403。AgentRun 已存在(conversations.service.ts:3183createRun→save),只需往 metadata 填交集。 - Task 8 (MCP spawn):
humanwork_mcp_server.py从没被 Hermes spawn(无 cli-configmcp_servers注册),HUMANWORK_MCP_TOOLS无 producer。
关键接线点(亲验):
RunnerTokenGuard:api/src/runtime-control-plane/runner-token.guard.ts,canActivate:33-57,extractBearerToken:59-63,只注入JwtService(:31)。req.runner=完整 claims,req.user={platformRole:'runner',orgMemberships:[{orgId}]}。AGENT_SERVICE_SECRETconfig key = 字面量"AGENT_SERVICE_SECRET"(agent.client.ts:283先例)。timingSafeEqual先例:api/src/tavus/webhook-verifier.service.ts:68(先长度检查再比较)。ExecuteToolDto.orgId:tool-execution.controller.ts:18-20,body 可取(guard 先于 ValidationPipe 但 express.json 已解析 raw body)。- module
runtime-control-plane.module.ts已 importConfigModule(:102)→ guard 可加注入 ConfigService 无需改 module。 - agent secret-fence
hermes_client._runner_subprocess_env:250-343:allowlist 明文:252-256、前缀 passthroughHERMES_/HUMANWORK_:258、denied markersSECRET/TOKEN/KEY/...:259、PLATFORM_API_URL显式转发:307-309。AGENT_SERVICE_SECRET双重被拦(无前缀 + 含 SECRET),必须显式转发。 tool_gateway_client.py:token 取自platform_api_token()(:72),header 设于:102-104。8 个 caller 共用platform_api_token()——不动它,只在 tool_gateway_client 单独读 AGENT_SERVICE_SECRET。humanwork_mcp_server.py::50importexecute_tool_via_gateway,自身不读 token,无需改代码(但子进程 env 要有 AGENT_SERVICE_SECRET)。
安全 rationale(记入代码注释):转发 AGENT_SERVICE_SECRET 给 Hermes 子进程不扩大暴露面——Hermes 跑在我们自己的可信容器(这是绕过前提),该 secret 本就是容器级 agent↔NestJS 共享密钥、主进程 env 本来就有,同一信任域内转发无新增风险。信任边界完全落在 timingSafeEqual(X-Agent-Secret, AGENT_SERVICE_SECRET) + read-class 限制 + 下游 tenant-safety。
File Structure
| 文件 | 责任 | 动作 |
|---|---|---|
api/src/runtime-control-plane/runner-token.guard.ts | 加内部信任分支 | Modify |
api/src/runtime-control-plane/runner-token.guard.spec.ts | guard 内部分支测试 | Create/Modify |
api/src/conversations/tool-resolution.util.ts | 交集产 name:action | Modify |
api/src/conversations/tool-resolution.util.spec.ts | name:action 测试 | Modify |
api/src/conversations/conversations.service.ts | manifest 填充(createRun metadata) | Modify |
agent/hermes_client.py | secret-fence 转发 AGENT_SERVICE_SECRET | Modify |
agent/tool_gateway_client.py | bearer 优先 AGENT_SERVICE_SECRET | Modify |
agent/main.py (_hermes_env) | 设 HUMANWORK_MCP_TOOLS | Modify |
agent/Dockerfile 或 boot 脚本 | cli-config 注入 mcp_servers:{humanwork} | Modify |
api/src/runtime-control-plane/CLAUDE.md / agent/CLAUDE.md | 契约文档 | Modify |
Task 1: NestJS gateway 内部信任分支(read-class only)
Files:
- Modify:
api/src/runtime-control-plane/runner-token.guard.ts - Test:
api/src/runtime-control-plane/runner-token.guard.spec.ts
先 Read 真实文件对齐接口(别照抄本计划的行号,可能已漂移):runner-token.guard.ts 全文 + 一处 timingSafeEqual 先例 api/src/tavus/webhook-verifier.service.ts:60-70。
- Step 1: 写失败测试 —— 内部信任分支 3 个用例:有效 X-Agent-Secret+body.orgId → 合成 req.runner 放行;无 X-Agent-Secret → 落原 runner-JWT 路径(无 bearer 抛 UnauthorizedException);X-Agent-Secret 错误 → 不放行落原路径。
// runner-token.guard.spec.ts 增补
describe("internal-trust bypass (X-Agent-Secret)", () => {
const AGENT_SECRET = "test-agent-secret-123";
function makeGuard() {
const jwt = { verify: jest.fn() } as unknown as JwtService;
const config = { get: jest.fn((k: string) => (k === "AGENT_SERVICE_SECRET" ? AGENT_SECRET : undefined)) } as unknown as ConfigService;
return new RunnerTokenGuard(jwt, config);
}
function ctx(headers: Record<string, string>, body: Record<string, unknown>) {
const req: any = { headers, body, header: (n: string) => headers[n.toLowerCase()] };
return { switchToHttp: () => ({ getRequest: () => req }), _req: req } as any;
}
it("放行有效 X-Agent-Secret 并从 body.orgId 合成 req.runner", () => {
const guard = makeGuard();
const c = ctx({ "x-agent-secret": AGENT_SECRET }, { orgId: "org-1" });
expect(guard.canActivate(c)).toBe(true);
expect(c._req.runner).toMatchObject({ org_id: "org-1", role: "runner", aud: "runner" });
expect(c._req.user.orgMemberships[0].orgId).toBe("org-1");
});
it("X-Agent-Secret 错误 → 落原 runner-JWT 路径(无 bearer 抛 Unauthorized)", () => {
const guard = makeGuard();
const c = ctx({ "x-agent-secret": "wrong" }, { orgId: "org-1" });
expect(() => guard.canActivate(c)).toThrow(UnauthorizedException);
});
it("无 X-Agent-Secret 且无 body.orgId → 落原路径", () => {
const guard = makeGuard();
const c = ctx({}, {});
expect(() => guard.canActivate(c)).toThrow(UnauthorizedException);
});
it("有 X-Agent-Secret 但缺 body.orgId → 不放行(落原路径)", () => {
const guard = makeGuard();
const c = ctx({ "x-agent-secret": AGENT_SECRET }, {});
expect(() => guard.canActivate(c)).toThrow(UnauthorizedException);
});
});
- Step 2: 跑测试确认失败
Run: cd api && npx jest runner-token.guard --silent
Expected: FAIL(现 guard 构造只接一个参数 / 无内部分支)
- Step 3: 实现内部信任分支
在 RunnerTokenGuard 构造函数加 ConfigService,canActivate 顶部(取 req 后、extractBearerToken 前)加分支。用常量时间比较,先长度检查(照 webhook-verifier 模式)。缺 orgId 或 secret 不匹配则 fall through 到原 runner-JWT 路径(不 return false,让原逻辑决定)。
import { timingSafeEqual } from "node:crypto";
import { ConfigService } from "@nestjs/config";
// ...
constructor(
private readonly jwtService: JwtService,
private readonly config: ConfigService,
) {}
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest<RunnerRequest & { body?: any; header?: (n: string) => string | undefined; headers?: Record<string, string> }>();
// 内部信任分支:可信 /chat agent 容器用 AGENT_SERVICE_SECRET 直连(read-class tool 执行)。
// Hermes 跑在我们自己的可信容器(非 remote runner),runner-token 对这条路是错配。
// 信任边界 = timingSafeEqual(X-Agent-Secret, AGENT_SERVICE_SECRET);orgId 从 body 来,
// 下游 tenant-safety(AgentRun{id,orgId} 查找 / manifest / ActionPolicy / flag)全保留。
const agentSecret = this.config.get<string>("AGENT_SERVICE_SECRET");
const provided = req.headers?.["x-agent-secret"] ?? req.header?.("x-agent-secret");
const bodyOrgId = typeof req.body?.orgId === "string" ? req.body.orgId : undefined;
if (agentSecret && provided && bodyOrgId && this.secretMatches(provided, agentSecret)) {
const claims: RunnerTokenClaims = {
sub: `internal-agent:${bodyOrgId}`,
org_id: bodyOrgId,
role: "runner",
aud: "runner",
};
req.runner = claims;
req.user = { platformRole: "runner", orgMemberships: [{ orgId: bodyOrgId }] };
return true;
}
// 原 runner-JWT 路径(remote-runner,零影响)
const token = this.extractBearerToken(req);
// ... 保留现有逻辑不变 ...
}
private secretMatches(provided: string, expected: string): boolean {
const a = Buffer.from(provided);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}
- Step 4: 跑测试确认通过 + 原有 runner-JWT 测试不回归
Run: cd api && npx jest runner-token.guard --silent
Expected: PASS(新 4 用例 + 原有全部)
- Step 5: tsc + lint
Run: cd api && npx tsc --noEmit && npx eslint src/runtime-control-plane/runner-token.guard.ts
Expected: EXIT 0
- Step 6: Commit
git add api/src/runtime-control-plane/runner-token.guard.ts api/src/runtime-control-plane/runner-token.guard.spec.ts
git commit -m "feat(runtime): trusted internal-call bypass on RunnerTokenGuard for /chat read-tool dispatch (P4.7 P1)"
Task 2: 交集产 name:action(回改 T2)
Files:
- Modify:
api/src/conversations/tool-resolution.util.ts - Test:
api/src/conversations/tool-resolution.util.spec.ts
先 Read:tool-resolution.util.ts 全文(现 intersectReadTools 产 slug)+ api/src/agent-api/tool-registry.ts 确认 AGENT_TOOL_REGISTRY 形状({[slug]: {actions: string[], accessClass?, kind, ...}} —— 亲验字段名,深挖记 registry 有 actions,read/write 在 tool_binding schema 不在 registry)。
- Step 1: 写失败测试 —— 交集结果从 slug 扩为
slug:action(每个 slug 的每个 read action 一条),未知 slug(registry 无)跳过,确定性排序。
// tool-resolution.util.spec.ts 增补(对齐真实导出名,先 Read)
describe("name:action expansion", () => {
it("把交集 slug 扩成 registry 里的 name:action,确定性排序", () => {
// 假设 registry: order_lookup.actions=['lookup'], shopify.actions=['getOrder','getProduct']
const out = expandToNameAction(["shopify", "order_lookup"]);
expect(out).toEqual(["order_lookup:lookup", "shopify:getOrder", "shopify:getProduct"]);
});
it("registry 无此 slug → 跳过(never throw)", () => {
expect(expandToNameAction(["nonexistent_tool"])).toEqual([]);
});
it("空输入 → 空", () => {
expect(expandToNameAction([])).toEqual([]);
});
});
- Step 2: 跑测试确认失败
Run: cd api && npx jest tool-resolution.util --silent
Expected: FAIL(expandToNameAction 未定义)
- Step 3: 实现 expandToNameAction(从 AGENT_TOOL_REGISTRY 查 actions,纯函数,never-throws,确定性 sort)。决定:是新增
expandToNameAction让resolveSpecialistToolsets(T7) 调用,还是直接改intersectReadTools返回。推荐新增独立纯函数,保持 intersect 与 expand 单一职责分离。
import { AGENT_TOOL_REGISTRY } from "../agent-api/tool-registry";
/** 把 read-class 交集 slug 扩成 MCP server 要的 `name:action` 列表。
* 权威源 = AGENT_TOOL_REGISTRY.actions。registry 无此 slug → 跳过。纯函数,never-throws。 */
export function expandToNameAction(slugs: string[]): string[] {
const out: string[] = [];
for (const slug of slugs) {
const def = AGENT_TOOL_REGISTRY[slug];
if (!def?.actions) continue;
for (const action of def.actions) out.push(`${slug}:${action}`);
}
return out.sort();
}
- Step 4: 跑测试确认通过(若 registry 真实 actions 与假设不符,改测试期望值对齐真实 registry,不改实现)
Run: cd api && npx jest tool-resolution.util --silent
Expected: PASS
- Step 5: tsc + lint
Run: cd api && npx tsc --noEmit && npx eslint src/conversations/tool-resolution.util.ts
Expected: EXIT 0
- Step 6: Commit
git add api/src/conversations/tool-resolution.util.ts api/src/conversations/tool-resolution.util.spec.ts
git commit -m "feat(conversations): expand read-tool intersection to name:action from tool registry (P4.7 P1)"