From 7f97b414044c71282a765be284b4ba8614490961 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 2 Jun 2026 18:48:56 +0800 Subject: [PATCH] fix: route hwpod through cloud-api --- .../reference/agentrun-code-agent-dispatch.md | 1 + docs/reference/spec-device-pod.md | 5 +- docs/reference/spec-v02-hwlab-cli.md | 2 +- docs/reference/spec-v02-hwlab-cloud-api.md | 2 +- docs/reference/spec-v02-hwlab-cloud-web.md | 1 + internal/cloud/server-agent-chat.test.ts | 2 +- internal/cloud/server-code-agent-http.ts | 14 ++- internal/dev-entrypoint/cloud-web-proxy.mjs | 3 +- internal/dev-entrypoint/cloud-web-routes.mjs | 1 - .../dev-entrypoint/cloud-web-routes.test.mjs | 6 -- internal/dev-entrypoint/http.test.mjs | 4 +- tools/device-pod-cli.test.ts | 49 ++++++++++ tools/src/device-pod-cli-lib.ts | 91 +++++++++++++++---- 13 files changed, 146 insertions(+), 35 deletions(-) diff --git a/docs/reference/agentrun-code-agent-dispatch.md b/docs/reference/agentrun-code-agent-dispatch.md index d516b519..42ccd51d 100644 --- a/docs/reference/agentrun-code-agent-dispatch.md +++ b/docs/reference/agentrun-code-agent-dispatch.md @@ -16,6 +16,7 @@ - UniDesk SSH passthrough 通过 `toolCredentials[].tool=unidesk-ssh` 注入,默认 SecretRef 是 `agentrun-v01-tool-unidesk-ssh` key `UNIDESK_SSH_CLIENT_TOKEN`。 - `UNIDESK_SSH_CLIENT_TOKEN` 只授予 UniDesk frontend `/ws/ssh` scoped client 能力;route allowlist 由 UniDesk frontend 配置控制。HWLAB 不持有 provider token、主 server SSH key 或完整 frontend 登录态。 - `runnerJob.transientEnv` 只能承接本次 runner job 需要的短期执行上下文,例如 `HWLAB_DEVICE_POD_API_KEY` 和 `UNIDESK_MAIN_SERVER_IP`;`HWLAB_DEVICE_POD_API_KEY` 必须标记 sensitive,不得承载 GitHub token、UniDesk SSH client token、provider key、长期 SSH key 或 registry token。 +- HWLAB v0.2 device-pod 能力在 runner 内只通过 `hwpod -> HWLAB_RUNTIME_API_URL -> hwlab-cloud-api` 进入。`HWLAB_RUNTIME_API_URL` 指向当前 namespace 的 `hwlab-cloud-api` Service;`HWLAB_RUNTIME_WEB_URL` 仅供需要浏览器同源语义的工具查看 Cloud Web。AgentRun 不把 `HWLAB_DEVICE_POD_API_KEY` 发往 Cloud Web,也不以 Cloud Web 代理替代设备 API。 ## ResourceBundle diff --git a/docs/reference/spec-device-pod.md b/docs/reference/spec-device-pod.md index 9a3c3b97..06962acc 100644 --- a/docs/reference/spec-device-pod.md +++ b/docs/reference/spec-device-pod.md @@ -7,7 +7,7 @@ 旧的 `device-pod-cli` 本地 profile 闭环只用于 CLI MVP 和真实硬件最小验证。进入正式多用户系统后,所有用户态设备访问必须收敛到: ```text -device-pod-cli or cloud-web +browser Cloud Web UI or hwpod/device-pod-cli -> cloud-api auth + device_pod_grants + AgentRun Device Pod API key -> hwlab-device-pod internal REST -> gateway transport @@ -15,6 +15,8 @@ device-pod-cli or cloud-web -> Keil / pyOCD / UART / target ``` +AgentRun runner 和 `hwpod` 的标准设备 API 入口是 `HWLAB_RUNTIME_API_URL=http://hwlab-cloud-api..svc.cluster.local:6667`,不是 `hwlab-cloud-web`。Cloud Web 只服务浏览器同源 UI 代理;它不得承载 AgentRun Device Pod API key,也不得作为 `hwpod` 的设备 API 替代入口。内部 `:6667` 属于 WHATWG bad port 时,`hwpod` 必须使用 Node `http/https` 原生请求层访问 cloud-api,不能为了规避 bad-port 把设备 API 改走 Cloud Web。 + ## 在系统中的职责划分 `device-pod` 是云端可授权、可审计的逻辑设备能力单元。`hwlab-cloud-api` 是用户身份、grant、AgentRun Device Pod API key、profile authority 和用户态 REST API 的入口;`hwlab-device-pod` 是内部执行服务;`hwlab-gateway` 只承载 transport;`device-host-cli` 只在硬件 host 侧执行 Keil、pyOCD、UART 和 workspace 操作。 @@ -259,6 +261,7 @@ manages: many devicePodId `tools/device-pod-cli.ts` 是 v0.2 正式 CLI 实现;HWLAB code-agent runner 内的唯一稳定短入口是 `hwpod`。runner 镜像必须把 `hwpod` 放入 PATH;缺少 `hwpod` 时应判定为 runner image/package 错误并修复镜像或封装,不得改走 `/app/skills/device-pod-cli/scripts/device-pod-cli.mjs` 长路径。正式 CLI 的默认行为是: - `profile list/show` 调用 cloud-api `/v1/device-pods` 和 `/status`,只显示服务端脱敏 profile 摘要和 `profileHash`。 +- AgentRun runner 中只使用装配好的 `HWLAB_RUNTIME_API_URL` 和 `HWLAB_DEVICE_POD_API_KEY`,直接访问 `hwlab-cloud-api`;不得手动传 `--api-base-url`,也不得改走 Cloud Web 同源代理。 - `setup first-admin` 和 `admin device-pod upsert/grant` 只作为 cloud-api REST wrapper,用于首次空库 seed 或 admin profile/grant 管理;它们接受显式 `--profile-json` 或 `--device-pod-json`,不得读取本地 `.device-pod/*.json` 作为权威 profile。 - `devicePodId:workspace|debug-probe|io-probe...` selector 转换为 `POST /v1/device-pods/{devicePodId}/jobs` 或 job status/output/cancel REST,不直接调用 `/v1/rpc/hardware.invoke.shell`。 - `bootsharp --pod-id ` 和 `:workspace:/ bootsharp` 都转换为 `workspace.bootsharp` job,用于返回 workspace tree、AGENTS.md 摘要和当前路径提示;该入口是上下文恢复和 DS 派单的首个探测动作,不读取本地 profile。 diff --git a/docs/reference/spec-v02-hwlab-cli.md b/docs/reference/spec-v02-hwlab-cli.md index 47fb3a07..3f18c705 100644 --- a/docs/reference/spec-v02-hwlab-cli.md +++ b/docs/reference/spec-v02-hwlab-cli.md @@ -21,7 +21,7 @@ - 专用子命令覆盖高频用户工作台;`client request METHOD /path` 覆盖 WEB 同源代理允许的其他非视觉 API。`client request` 只接受以 `/` 开头的 Cloud Web 相对路径,禁止绝对 URL,避免绕过 Cloud Web 直接打内部服务。 - `client gateway pressure` 是 device-pod/gateway 高频故障的真实业务传输压测入口;必须覆盖 small stdout、大 stdout、长单行 stdout、stderr flood、结构化 timeout 和超出 gateway inflight 上限的并发请求。所有场景必须返回 JSON、HTTP/route/traceId/requestId、字节数、truncated 标记、sha256 和 bounded preview;失败必须明确是 `http_*`、`stdout_not_truncated`、`stderr_not_truncated`、`timeout_not_observed`、`structured_gateway_busy` 等可定位原因,禁止无输出、长时间黑洞或只靠 shell pipe 截断。 - 输出默认是 JSON;任何失败都要有 `ok:false`、`action`、`status`、HTTP 状态、route 和可定位错误,不允许无 stdout 成功。可能返回大对象的 `client` 子命令默认返回紧凑摘要,避免高频排障输出爆炸;需要完整响应体时显式加 `--full`。 -- `device-pod-cli`/`hwpod` 的 `job output` 默认也必须返回紧凑 JSON:保留 job/status/blocker/freshness/text/evidence 摘要,省略嵌套 gateway dispatch 和长命令;需要完整 payload 时显式加 `--full`。Code Agent 和人工不得用 `| head`、`grep` 或 shell 管道作为默认输出压缩方式,避免 stdout pipe、子进程信号转发或长输出造成 commandExecution 黑洞。 +- `device-pod-cli`/`hwpod` 在 AgentRun runner 中是设备 API 标准短入口,必须自动使用装配的 `HWLAB_RUNTIME_API_URL` 直达 `hwlab-cloud-api` 和 `HWLAB_DEVICE_POD_API_KEY`,不能把 Cloud Web 同源代理当作设备 API 通道,也不能手动传 URL 或 session token。`job output` 默认也必须返回紧凑 JSON:保留 job/status/blocker/freshness/text/evidence 摘要,省略嵌套 gateway dispatch 和长命令;需要完整 payload 时显式加 `--full`。Code Agent 和人工不得用 `| head`、`grep` 或 shell 管道作为默认输出压缩方式,避免 stdout pipe、子进程信号转发或长输出造成 commandExecution 黑洞。 - Code Agent 交互必须默认暴露 `traceId`、`resultUrl`、终态和 assistant 回复文本摘要;不能要求用户先拉全量 trace 再手工查找回复。 - CLI 本地登录态必须支持 `--profile NAME` 隔离,同一 base URL 下不同 profile 写入 `.state/hwlab-cli/profiles//.json`。切换到其他账号再切回原账号时,`client workbench restore/status` 必须从服务端账号 workspace 恢复之前的 `workspaceId`、`conversationId`、`sessionId`、`threadId`、`activeTraceId` 和 revision,而不是只依赖本地文件。 - `client workbench restore/status/watch/reset` 是账号 workspace 的非视觉入口:`restore/status` 对应 `GET /v1/workbench/workspace`,`watch` 对应 `/events?afterRevision=`,`reset --confirm` 对应服务端 reset。输出必须显示 workspace revision、selected conversation/session、active trace 和本地 state file,且不得保存 password、session token 原文以外的 Secret 值。 diff --git a/docs/reference/spec-v02-hwlab-cloud-api.md b/docs/reference/spec-v02-hwlab-cloud-api.md index 15c6a50f..cce75973 100644 --- a/docs/reference/spec-v02-hwlab-cloud-api.md +++ b/docs/reference/spec-v02-hwlab-cloud-api.md @@ -19,7 +19,7 @@ - AgentRun v0.1 接入只使用标准 `threadId` 路径:`POST /v1/agent/chat` 收到的 `conversationId/sessionId/threadId` 必须写入 AgentRun command `payload.threadId` 和 `SessionRef.threadId`;协议字段、trace、result 和 conversation facts 都以该字段为唯一 thread identity。 - AgentRun run 级 events 写回 HWLAB trace 时必须按当前 `commandId` 归属过滤;同一 run 的旧 command 尾部事件不能混入后续 command trace。取消、失败或 blocked 轮次如果已有 assistant/tool 可读进展,必须以脱敏、限长的 conversation facts 写入 UI/trace/inspect 证据,供后续 `inspect`/`--from-trace` 可见性使用;这些 facts 不得作为下一轮模型上下文或 prompt 拼接来源。 - AgentRun completed 轮次续接必须依赖 Codex stdio 原生 session continuation。Cloud API 只把本轮原始 `message/prompt` 和标准 `conversationId/sessionId/threadId` 写入 AgentRun command payload 与 `SessionRef`;不得从请求、account workspace 或 account conversation 生成 `conversationContext`,不得把历史消息拼入 prompt,也不得把请求体里的 `conversationContext/messages` 当作模型上下文。历史 conversation facts 只用于 UI、inspect、trace 和 `--from-trace` 的可见性证据;收到 synthetic context 字段时只能记录 ignored trace 并剥离。`thread/resume` 失败时按 AgentRun `thread-resume-failed` 终止本轮。 -- Cloud API 通过 AgentRun v0.1 `runner-jobs.transientEnv` 传递本次 Code Agent turn 的短期上下文,例如 `HWLAB_RUNTIME_*`、`HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME` 和 `HWLAB_DEVICE_POD_API_KEY`。`transientEnv` 不设固定 8 项上限,新增短期上下文时必须按 name 去重、只传本次 Job 需要的 value;`HWLAB_DEVICE_POD_API_KEY` 只能作为 assembled runner 内 `hwpod` 访问正式 device-pod 的统一授权,必须标记 sensitive,并继续禁止承载 GitHub token、provider key、长期 SSH key 或其他可复用 credential;文档、日志和 trace 只允许保留脱敏后的 name、来源或摘要,不打印 Secret 值。 +- Cloud API 通过 AgentRun v0.1 `runner-jobs.transientEnv` 传递本次 Code Agent turn 的短期上下文,例如 `HWLAB_RUNTIME_*`、`HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME` 和 `HWLAB_DEVICE_POD_API_KEY`。`transientEnv` 不设固定 8 项上限,新增短期上下文时必须按 name 去重、只传本次 Job 需要的 value;`HWLAB_RUNTIME_API_URL` 必须指向当前 namespace 内的 `hwlab-cloud-api` Service,`HWLAB_RUNTIME_WEB_URL` 才指向 `hwlab-cloud-web`;`HWLAB_DEVICE_POD_API_KEY` 只能作为 assembled runner 内 `hwpod` 访问正式 device-pod 的统一授权,必须标记 sensitive,并继续禁止承载 GitHub token、provider key、长期 SSH key 或其他可复用 credential;文档、日志和 trace 只允许保留脱敏后的 name、来源或摘要,不打印 Secret 值。 - 同 Pod sidecar `hwlab-codex-api-forwarder` 监听 `127.0.0.1:49280/responses`,用于 `codex-api` profile 直连 hyueapi,并保持 hyueapi 在 `NO_PROXY` 中。 - `hwlab-code-agent-workspace` PVC 挂载到 `/workspace/hwlab`,用于长会话 workspace;它是 cloud-api 运行资源,不是独立用户入口。 diff --git a/docs/reference/spec-v02-hwlab-cloud-web.md b/docs/reference/spec-v02-hwlab-cloud-web.md index a16eefe8..d4bb8dca 100644 --- a/docs/reference/spec-v02-hwlab-cloud-web.md +++ b/docs/reference/spec-v02-hwlab-cloud-web.md @@ -8,6 +8,7 @@ - 只消费 `hwlab-cloud-api`,不直接访问 Postgres、gateway、device-pod Service、FRP、Kubernetes 或 provider Secret。 - 为浏览器提供同源代理,避免前端直接跨域调用内部 ClusterIP。 - Cloud Web 与 `hwlab-cli client` 必须共享同一组非视觉业务 API。浏览器遇到的 Code Agent continuation、trace/result、device-pod list/status 和 device-pod job 问题,必须能通过 `hwlab-cli client` 走同一 `19666` Cloud Web path 复现;不能让 CLI 长期绕到 `19667` Cloud API 后把 Web 路径缺口误判为业务已通过。 +- Cloud Web 只承担浏览器 UI 和 `hwlab-cli client` 的同源代理。AgentRun runner 内的 `hwpod` 不走 Cloud Web;Cloud Web 不转发 AgentRun Device Pod API key,也不保留 device-pod lease 路由。 - 浏览器启动后必须从 `GET /v1/workbench/workspace` hydrate 账号 workspace;同一个账号在多个浏览器标签页、多个浏览器或 CLI profile 中应看到同一个 `workspaceId`、selected conversation/session/thread、provider profile 和 active trace。浏览器 localStorage 只能作为短期缓存,并必须绑定 actor,不能作为 workspace authority。 - Cloud Web trace 展示与 `hwlab-cli client agent trace --render web` 必须共享同一套 trace row 纯转换路径。Web 发生 row 顺序错乱、final response 缺失、assistant 消息被吞、tool call 只显示泛化占位或噪声事件淹没时,先用 CLI 输出同一渲染 row 摘要和 noise event count 复现;CLI 可复现说明是 trace row 转换问题,CLI 不可复现再进入 DOM/CSS/滚动状态调查。默认展示应压制 AgentRun backend echo、token/rate-limit/status/terminal echo 等低价值事件,但原始 trace JSON 仍必须保留用于 `--full`/下载排障。 - Cloud Web Code Agent composer 必须无锁:运行中 turn 不得把输入框或发送按钮 disabled。浏览器提交时必须按共享 composer policy 自动分流,空闲/终态走 `POST /v1/agent/chat` 开新 turn,存在 active running trace 时走 `POST /v1/agent/chat/steer` 引导当前 turn。`hwlab-cli client agent composer status` 必须能用同一 policy 输出 `locked=false`、`disabled=false`、`submitMode=turn|steer`、`route` 和 `targetTraceId`,用于复现 Web 输入框是否被旧逻辑锁住。 diff --git a/internal/cloud/server-agent-chat.test.ts b/internal/cloud/server-agent-chat.test.ts index 869fe736..ca2987f3 100644 --- a/internal/cloud/server-agent-chat.test.ts +++ b/internal/cloud/server-agent-chat.test.ts @@ -234,7 +234,7 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte "HWLAB_DEVICE_POD_API_KEY" ]); const transientEnv = Object.fromEntries(body.transientEnv.map((entry) => [entry.name, entry.value])); - assert.equal(transientEnv.HWLAB_RUNTIME_API_URL, "http://hwlab-cloud-web.hwlab-v02.svc.cluster.local:8080"); + assert.equal(transientEnv.HWLAB_RUNTIME_API_URL, "http://hwlab-cloud-api.hwlab-v02.svc.cluster.local:6667"); assert.equal(transientEnv.HWLAB_RUNTIME_WEB_URL, "http://hwlab-cloud-web.hwlab-v02.svc.cluster.local:8080"); assert.equal(transientEnv.HWLAB_RUNTIME_NAMESPACE, "hwlab-v02"); assert.equal(transientEnv.HWLAB_RUNTIME_LANE, "v02"); diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index 94f3a178..3a2e0e3f 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -172,6 +172,7 @@ async function codeAgentDevicePodAuthEnv(options = {}, env = process.env) { const runtimeNamespace = runtimeNamespaceForEnv(env); const runtimeLane = runtimeLaneForNamespace(runtimeNamespace) ?? runtimeLaneForEnv(env); const inClusterApiUrl = internalCodeAgentApiUrl(env, runtimeNamespace); + const inClusterWebUrl = internalCodeAgentWebUrl(env, runtimeNamespace); const apiUrl = firstNonEmptyValue( codeAgentAgentRunAdapterEnabled(env) ? inClusterApiUrl : null, env.HWLAB_RUNTIME_API_URL, @@ -185,7 +186,7 @@ async function codeAgentDevicePodAuthEnv(options = {}, env = process.env) { HWLAB_DEVICE_POD_API_URL: apiUrl, HWLAB_CLOUD_API_URL: apiUrl, HWLAB_RUNTIME_API_URL: apiUrl, - HWLAB_RUNTIME_WEB_URL: apiUrl, + HWLAB_RUNTIME_WEB_URL: firstNonEmptyValue(codeAgentAgentRunAdapterEnabled(env) ? inClusterWebUrl : null, env.HWLAB_RUNTIME_WEB_URL, apiUrl), HWLAB_RUNTIME_NAMESPACE: runtimeNamespace, HWLAB_RUNTIME_LANE: runtimeLane, HWLAB_RUNTIME_ENDPOINT_SOURCE: codeAgentAgentRunAdapterEnabled(env) ? "runtime-namespace" : "runtime-env", @@ -196,9 +197,14 @@ async function codeAgentDevicePodAuthEnv(options = {}, env = process.env) { } function internalCodeAgentApiUrl(env = process.env, namespace = runtimeNamespaceForEnv(env)) { - // Fetch/undici blocks cloud-api's 6667 as a bad port, so AgentRun runners use the internal web proxy. - const serviceName = firstNonEmptyValue(env.HWLAB_CODE_AGENT_INTERNAL_API_SERVICE_NAME, env.HWLAB_CLOUD_WEB_SERVICE_NAME, "hwlab-cloud-web"); - const port = parsePositiveInteger(firstNonEmptyValue(env.HWLAB_CODE_AGENT_INTERNAL_API_PORT, env.HWLAB_CLOUD_WEB_PORT), 8080); + const serviceName = firstNonEmptyValue(env.HWLAB_CODE_AGENT_INTERNAL_API_SERVICE_NAME, env.HWLAB_CLOUD_API_SERVICE_NAME, "hwlab-cloud-api"); + const port = parsePositiveInteger(firstNonEmptyValue(env.HWLAB_CODE_AGENT_INTERNAL_API_PORT, env.HWLAB_CLOUD_API_PORT), 6667); + return `http://${serviceName}.${namespace}.svc.cluster.local:${port}`; +} + +function internalCodeAgentWebUrl(env = process.env, namespace = runtimeNamespaceForEnv(env)) { + const serviceName = firstNonEmptyValue(env.HWLAB_CODE_AGENT_INTERNAL_WEB_SERVICE_NAME, env.HWLAB_CLOUD_WEB_SERVICE_NAME, "hwlab-cloud-web"); + const port = parsePositiveInteger(firstNonEmptyValue(env.HWLAB_CODE_AGENT_INTERNAL_WEB_PORT, env.HWLAB_CLOUD_WEB_PORT), 8080); return `http://${serviceName}.${namespace}.svc.cluster.local:${port}`; } diff --git a/internal/dev-entrypoint/cloud-web-proxy.mjs b/internal/dev-entrypoint/cloud-web-proxy.mjs index 7fc4755a..aa27a688 100644 --- a/internal/dev-entrypoint/cloud-web-proxy.mjs +++ b/internal/dev-entrypoint/cloud-web-proxy.mjs @@ -12,8 +12,7 @@ const FORWARDED_REQUEST_HEADERS = Object.freeze([ "x-trace-id", "x-request-id", "x-actor-id", - "x-source-service-id", - "x-hwlab-session-token" + "x-source-service-id" ]); export function isCloudWebSseRoute(pathname) { diff --git a/internal/dev-entrypoint/cloud-web-routes.mjs b/internal/dev-entrypoint/cloud-web-routes.mjs index 988f051a..beec533c 100644 --- a/internal/dev-entrypoint/cloud-web-routes.mjs +++ b/internal/dev-entrypoint/cloud-web-routes.mjs @@ -58,7 +58,6 @@ function isDevicePodPostProxyRoute(pathname) { const parts = pathname.split("/").filter(Boolean); if (parts.length < 4 || parts[0] !== "v1" || parts[1] !== "device-pods") return false; if (parts.length === 4 && parts[3] === "jobs") return true; - if (parts.length === 4 && parts[3] === "leases") return true; if (parts.length === 6 && parts[3] === "jobs" && parts[5] === "cancel") return true; return false; } diff --git a/internal/dev-entrypoint/cloud-web-routes.test.mjs b/internal/dev-entrypoint/cloud-web-routes.test.mjs index 5d7cc298..5813346e 100644 --- a/internal/dev-entrypoint/cloud-web-routes.test.mjs +++ b/internal/dev-entrypoint/cloud-web-routes.test.mjs @@ -37,12 +37,6 @@ test("cloud web proxies only supported authenticated device-pod write routes", ( publicRoute: false, routeKey: "POST /v1/device-pods/D601-F103-V2/jobs/job_1/cancel" }); - assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/device-pods/D601-F103-V2/leases"), { - proxy: true, - authRequired: true, - publicRoute: false, - routeKey: "POST /v1/device-pods/D601-F103-V2/leases" - }); assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/device-pods/D601-F103-V2/status"), { proxy: false, authRequired: false, diff --git a/internal/dev-entrypoint/http.test.mjs b/internal/dev-entrypoint/http.test.mjs index a35c9387..c347f78d 100644 --- a/internal/dev-entrypoint/http.test.mjs +++ b/internal/dev-entrypoint/http.test.mjs @@ -90,7 +90,7 @@ test("cloud web route policy delegates auth authority to cloud-api", () => { }); }); -test("cloud web proxy forwards device-pod session tokens without forwarding bearer credentials", () => { +test("cloud web proxy forwards browser cookies without forwarding token credentials", () => { const headers = upstreamRequestHeaders({ headers: { accept: "application/json", @@ -101,7 +101,7 @@ test("cloud web proxy forwards device-pod session tokens without forwarding bear } }); - assert.equal(headers["x-hwlab-session-token"], "device-pod-session-a"); + assert.equal(headers["x-hwlab-session-token"], undefined); assert.equal(headers.cookie, "hwlab_session=session-a"); assert.equal(headers["x-trace-id"], "trc_device_pod_session_proxy"); assert.equal(headers.authorization, undefined); diff --git a/tools/device-pod-cli.test.ts b/tools/device-pod-cli.test.ts index 362819f2..97db02ba 100644 --- a/tools/device-pod-cli.test.ts +++ b/tools/device-pod-cli.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; import os from "node:os"; import path from "node:path"; import { test } from "bun:test"; @@ -132,6 +133,35 @@ test("device-pod-cli auto-locates from assembled runtime and rejects manual URL assert.equal(rejected.payload.error.code, "runtime_endpoint_manual_url_forbidden"); }); +test("device-pod-cli default transport uses native HTTP for assembled cloud-api URLs", async () => { + const seen: any[] = []; + const server = createServer((request, response) => { + let body = ""; + request.on("data", (chunk) => { body += chunk; }); + request.on("end", () => { + seen.push({ method: request.method, url: request.url, headers: request.headers, body }); + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ ok: true, devicePods: [] })); + }); + }); + await listen(server); + try { + const result = await runDevicePodCli(["profile", "list"], { + env: { + ...ASSEMBLED_RUNTIME_ENV, + HWLAB_RUNTIME_API_URL: serverUrl(server) + }, + now: () => "2026-06-02T00:00:00.000Z" + }); + assert.equal(result.exitCode, 0); + assert.equal(seen[0].method, "GET"); + assert.equal(seen[0].url, "/v1/device-pods"); + assert.equal(seen[0].headers["x-hwlab-device-pod-api-key"], "device-pod-api-key-a"); + } finally { + await close(server); + } +}); + test("device-pod-cli reuses matching hwlab-cli web session cookie when no explicit auth is assembled", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-device-pod-cli-session-")); await mkdir(path.join(root, ".state/hwlab-cli"), { recursive: true }); @@ -473,3 +503,22 @@ test("device-pod-cli accepts explicit workspace rg pattern and path options", as function jsonResponse(status: number, body: unknown) { return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); } + +function listen(server: ReturnType) { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); +} + +function close(server: ReturnType) { + return new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()); + }); +} + +function serverUrl(server: ReturnType) { + const address = server.address(); + if (!address || typeof address === "string") throw new Error("server address is unavailable"); + return `http://127.0.0.1:${address.port}`; +} diff --git a/tools/src/device-pod-cli-lib.ts b/tools/src/device-pod-cli-lib.ts index a33a4cd2..36e321a9 100644 --- a/tools/src/device-pod-cli-lib.ts +++ b/tools/src/device-pod-cli-lib.ts @@ -1,3 +1,5 @@ +import { request as httpRequest } from "node:http"; +import { request as httpsRequest } from "node:https"; import { readFile } from "node:fs/promises"; import path from "node:path"; @@ -20,7 +22,7 @@ export async function main(argv = process.argv.slice(2), options: { env?: EnvLik export async function runDevicePodCli(argv: string[], options: { env?: EnvLike; fetchImpl?: FetchLike; stdinText?: string; now?: () => string } = {}) { const env = options.env ?? process.env; - const fetchImpl = options.fetchImpl ?? fetch; + const fetchImpl = options.fetchImpl; const now = options.now ?? (() => new Date().toISOString()); try { const parsed = parseOptions(argv); @@ -347,22 +349,79 @@ function workspaceRgPattern(parsed: ParsedArgs, positional: unknown) { async function requestJson({ parsed, env, fetchImpl, method, path, body, auth = true, extraHeaders = {} }: any) { const endpoint = resolveRuntimeEndpoint({ kind: "api", parsed, env }); const baseUrl = endpoint.baseUrl; - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), numberOption(parsed.timeoutMs) ?? DEFAULT_TIMEOUT_MS); - try { - const url = `${baseUrl}${path}`; - const authHeaderValues = auth ? await authHeaders(parsed, env, endpoint) : {}; - const response = await fetchImpl(`${baseUrl}${path}`, { - method, - headers: clean({ accept: "application/json", ...(body ? { "content-type": "application/json" } : {}), ...authHeaderValues, ...extraHeaders }), - body: body ? JSON.stringify(body) : undefined, - signal: controller.signal - }); - const textBody = await response.text(); - return { status: response.status, headers: response.headers, body: parseJson(textBody), url, method, path, runtimeEndpoint: endpoint }; - } finally { - clearTimeout(timeout); + const url = `${baseUrl}${path}`; + const authHeaderValues = auth ? await authHeaders(parsed, env, endpoint) : {}; + const requestBody = body ? JSON.stringify(body) : undefined; + const headers = clean({ + accept: "application/json", + ...(requestBody ? { "content-type": "application/json", "content-length": Buffer.byteLength(requestBody) } : {}), + ...authHeaderValues, + ...extraHeaders + }); + const timeoutMs = numberOption(parsed.timeoutMs) ?? DEFAULT_TIMEOUT_MS; + if (fetchImpl) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetchImpl(`${baseUrl}${path}`, { + method, + headers, + body: requestBody, + signal: controller.signal + }); + const textBody = await response.text(); + return { status: response.status, headers: response.headers, body: parseJson(textBody), url, method, path, runtimeEndpoint: endpoint }; + } finally { + clearTimeout(timeout); + } } + return requestJsonWithNodeHttp({ url, method, path, headers, body: requestBody, timeoutMs, runtimeEndpoint: endpoint }); +} + +function requestJsonWithNodeHttp({ url, method, path, headers, body, timeoutMs, runtimeEndpoint }: any): Promise { + return new Promise((resolve, reject) => { + const target = new URL(url); + const requestImpl = target.protocol === "https:" ? httpsRequest : httpRequest; + const request = requestImpl(target, { method, headers: nodeHttpHeaders(headers) }, (response) => { + const chunks: Buffer[] = []; + response.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); + response.on("end", () => { + const textBody = Buffer.concat(chunks).toString("utf8"); + resolve({ + status: response.statusCode ?? 0, + headers: nodeResponseHeaders(response.headers), + body: parseJson(textBody), + url, + method, + path, + runtimeEndpoint + }); + }); + }); + request.setTimeout(timeoutMs, () => request.destroy(cliError("request_timeout", `request timed out after ${timeoutMs}ms`))); + request.on("error", reject); + if (body) request.write(body); + request.end(); + }); +} + +function nodeHttpHeaders(headers: Record) { + const out: Record = {}; + for (const [key, value] of Object.entries(headers)) { + if (value === undefined || value === null) continue; + out[key] = String(value); + } + return out; +} + +function nodeResponseHeaders(headers: Record) { + return { + get(name: string) { + const value = headers[String(name || "").toLowerCase()]; + if (Array.isArray(value)) return value.join("; "); + return value === undefined || value === null ? null : String(value); + } + }; } function parseOptions(argv: string[]): ParsedArgs {