From 8e9bea487d265ee22db45a8569eb2a6e1acca674 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 2 Jun 2026 10:48:50 +0800 Subject: [PATCH] fix: proxy code agent steer through cloud web --- docs/reference/spec-v02-hwlab-cloud-web.md | 4 +- internal/dev-entrypoint/cloud-web-routes.mjs | 4 +- .../dev-entrypoint/cloud-web-routes.test.mjs | 9 +++ internal/dev-entrypoint/http.mjs | 65 ++++++++++++------- internal/dev-entrypoint/http.test.mjs | 6 ++ 5 files changed, 61 insertions(+), 27 deletions(-) diff --git a/docs/reference/spec-v02-hwlab-cloud-web.md b/docs/reference/spec-v02-hwlab-cloud-web.md index 714d2d10..357adea1 100644 --- a/docs/reference/spec-v02-hwlab-cloud-web.md +++ b/docs/reference/spec-v02-hwlab-cloud-web.md @@ -31,7 +31,7 @@ | `GET /help` | 返回可用 route 摘要。 | | `GET /v1`、`GET /v1/...` | 同源代理到 `hwlab-cloud-api`;公开的 Code Agent result/trace 轮询按 route policy 处理。 | | `GET/PATCH /v1/workbench/workspace...` | 同源代理到 cloud-api 的账号 workspace authority,用于 Web/CLI 共享工作区和 revision 冲突保护。 | -| `POST /v1/agent/chat`、`POST /v1/agent/chat/cancel` | 同源代理到 cloud-api 的 Code Agent 入口。 | +| `POST /v1/agent/chat`、`POST /v1/agent/chat/steer`、`POST /v1/agent/chat/cancel` | 同源代理到 cloud-api 的 Code Agent 入口;steer 必须走同一个 `19666` Web path,由 cloud-api/AgentRun 判断目标 turn 是否可接收。 | | `POST /v1/device-pods/...` | 受控同源代理到 cloud-api 的 Device Pod job/操作入口;只要 Cloud API 已提供对应能力,Cloud Web 不能只代理 list/status 而让 job POST 在 `19666` 返回 404。 | | `POST /v1/m3/io`、`POST /json-rpc` | 同源代理到受控 API;不能绕过 cloud-api 直连硬件服务。 | @@ -53,6 +53,8 @@ Cloud Web check 通过后仍需执行 bundle build 和 dist freshness 校验, 阅读 docs/reference/spec-v02-hwlab-cloud-web.md,然后用 cli 手动测试以下内容:从同源 `19666` 提交 Code Agent 短连接请求并轮询 result,确认请求经 cloud-web proxy 到 `hwlab-cloud-api`,且 trace 可回放。 +阅读 docs/reference/spec-v02-hwlab-cloud-web.md,然后用 cli 手动测试以下内容:对运行中 Code Agent trace 调用 `hwlab-cli client agent steer --message ...`,确认请求走 Cloud Web 同源 `POST /v1/agent/chat/steer`;Web 层不能返回 `serviceId=hwlab-cloud-web` 的 404,目标不存在、非运行中或 runner 拒绝时必须透传 cloud-api/AgentRun 的结构化业务状态。 + ## T2.1 阅读 docs/reference/spec-v02-hwlab-cloud-web.md,然后用 cli 手动测试以下内容:对浏览器暴露的 Code Agent trace 运行 `hwlab-cli client agent trace --render web --limit 80`,确认 CLI 与 Web 使用同一 trace row 转换;若 final response 缺失、assistant row 顺序错乱或噪声事件过多,先用 CLI 固定复现再修实现。 diff --git a/internal/dev-entrypoint/cloud-web-routes.mjs b/internal/dev-entrypoint/cloud-web-routes.mjs index 575eeb17..988f051a 100644 --- a/internal/dev-entrypoint/cloud-web-routes.mjs +++ b/internal/dev-entrypoint/cloud-web-routes.mjs @@ -6,6 +6,7 @@ const POST_PROXY_ROUTES = new Set([ "/json-rpc", "/v1/agent/chat", "/v1/agent/chat/cancel", + "/v1/agent/chat/steer", "/v1/m3/io", "/v1/skills/uploads" ]); @@ -14,7 +15,8 @@ const PUBLIC_PROXY_ROUTES = new Set([ "POST /auth/login", "POST /auth/logout", "POST /v1/agent/chat", - "POST /v1/agent/chat/cancel" + "POST /v1/agent/chat/cancel", + "POST /v1/agent/chat/steer" ]); export function cloudWebProxyRoutePolicy(method, pathname) { diff --git a/internal/dev-entrypoint/cloud-web-routes.test.mjs b/internal/dev-entrypoint/cloud-web-routes.test.mjs index 5c995778..5d7cc298 100644 --- a/internal/dev-entrypoint/cloud-web-routes.test.mjs +++ b/internal/dev-entrypoint/cloud-web-routes.test.mjs @@ -89,3 +89,12 @@ test("cloud web proxies authenticated account workbench write routes", () => { routeKey: "POST /v1/workbench/workspace/ws_default/events" }); }); + +test("cloud web proxies Code Agent steer through the Web-equivalent public API path", () => { + assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/agent/chat/steer"), { + proxy: true, + authRequired: false, + publicRoute: true, + routeKey: "POST /v1/agent/chat/steer" + }); +}); diff --git a/internal/dev-entrypoint/http.mjs b/internal/dev-entrypoint/http.mjs index 8a5df80f..9c3d9987 100644 --- a/internal/dev-entrypoint/http.mjs +++ b/internal/dev-entrypoint/http.mjs @@ -109,31 +109,16 @@ export function proxyHttpRequest({ request, response, upstream, timeoutMs = DEFA const target = new URL(request.url || "/", upstream); const traceId = request.headers["x-trace-id"] ?? null; let timedOut = false; - const timeout = setTimeout(() => { - timedOut = true; - proxy.destroy(new Error(`upstream timed out after ${timeoutMs}ms`)); - }, timeoutMs); - const proxy = httpRequest( - target, - { - method: request.method, - headers: { - ...request.headers, - host: target.host - } - }, - (upstreamResponse) => { - response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers); - upstreamResponse.on("end", () => clearTimeout(timeout)); - upstreamResponse.on("close", () => clearTimeout(timeout)); - upstreamResponse.pipe(response); - } - ); let settled = false; - - proxy.on("error", (error) => { - clearTimeout(timeout); - if (settled || response.headersSent) { + let timeout = null; + const clearProxyTimeout = () => { + if (timeout) clearTimeout(timeout); + timeout = null; + }; + const sendProxyFailure = (error) => { + clearProxyTimeout(); + if (settled) return; + if (response.headersSent) { response.destroy(error); return; } @@ -170,7 +155,37 @@ export function proxyHttpRequest({ request, response, upstream, timeoutMs = DEFA traceId, message: userMessage }); - }); + }; + const proxy = httpRequest( + target, + { + method: request.method, + headers: { + ...request.headers, + host: target.host + } + }, + (upstreamResponse) => { + if (settled) { + upstreamResponse.resume(); + return; + } + settled = true; + response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers); + upstreamResponse.on("end", clearProxyTimeout); + upstreamResponse.on("close", clearProxyTimeout); + upstreamResponse.pipe(response); + } + ); + + timeout = setTimeout(() => { + timedOut = true; + const error = new Error(`upstream timed out after ${timeoutMs}ms`); + sendProxyFailure(error); + proxy.destroy(error); + }, timeoutMs); + + proxy.on("error", sendProxyFailure); request.pipe(proxy); } diff --git a/internal/dev-entrypoint/http.test.mjs b/internal/dev-entrypoint/http.test.mjs index 3caf3034..a35c9387 100644 --- a/internal/dev-entrypoint/http.test.mjs +++ b/internal/dev-entrypoint/http.test.mjs @@ -19,6 +19,12 @@ test("cloud web route policy proxies public Code Agent chat without gating other publicRoute: true, routeKey: "POST /v1/agent/chat/cancel" }); + assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/agent/chat/steer"), { + proxy: true, + authRequired: false, + publicRoute: true, + routeKey: "POST /v1/agent/chat/steer" + }); assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/m3/io"), { proxy: true, authRequired: true,