fix: route hwpod through cloud-api

This commit is contained in:
Codex
2026-06-02 18:48:56 +08:00
parent 93c648383c
commit 7f97b41404
13 changed files with 146 additions and 35 deletions
@@ -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
+4 -1
View File
@@ -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.<namespace>.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 <devicePodId>``<devicePodId>:workspace:/ bootsharp` 都转换为 `workspace.bootsharp` job,用于返回 workspace tree、AGENTS.md 摘要和当前路径提示;该入口是上下文恢复和 DS 派单的首个探测动作,不读取本地 profile。
+1 -1
View File
@@ -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/<base-url-hash>/<profile>.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 值。
+1 -1
View File
@@ -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 运行资源,不是独立用户入口。
@@ -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 WebCloud 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 输入框是否被旧逻辑锁住。
+1 -1
View File
@@ -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");
+10 -4
View File
@@ -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}`;
}
+1 -2
View File
@@ -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) {
@@ -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;
}
@@ -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,
+2 -2
View File
@@ -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);
+49
View File
@@ -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<typeof createServer>) {
return new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
}
function close(server: ReturnType<typeof createServer>) {
return new Promise<void>((resolve, reject) => {
server.close((error) => error ? reject(error) : resolve());
});
}
function serverUrl(server: ReturnType<typeof createServer>) {
const address = server.address();
if (!address || typeof address === "string") throw new Error("server address is unavailable");
return `http://127.0.0.1:${address.port}`;
}
+75 -16
View File
@@ -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<any> {
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<string, unknown>) {
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(headers)) {
if (value === undefined || value === null) continue;
out[key] = String(value);
}
return out;
}
function nodeResponseHeaders(headers: Record<string, unknown>) {
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 {