From bacfa2428930f118b5fcfeebc2beba060d5e1ef6 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 2 Jun 2026 18:11:32 +0800 Subject: [PATCH] fix: replace device pod lease with api key --- deploy/deploy.json | 1 + .../reference/agentrun-code-agent-dispatch.md | 2 +- docs/reference/code-agent-chat-readiness.md | 2 +- docs/reference/spec-device-pod.md | 40 +-- docs/reference/spec-user-access.md | 47 +--- docs/reference/spec-v02-hwlab-cli.md | 2 +- docs/reference/spec-v02-hwlab-cloud-api.md | 4 +- docs/reference/spec-v02-hwlab-cloud-web.md | 2 +- .../spec-v02-hwlab-device-pod-service.md | 6 +- docs/reference/spec-v02-hwlab-gateway.md | 2 +- internal/cloud/access-control.test.ts | 197 +++---------- internal/cloud/access-control.ts | 265 +++--------------- internal/cloud/code-agent-agentrun-adapter.ts | 10 +- .../cloud/code-agent-session-registry.test.ts | 14 +- internal/cloud/codex-stdio-session-helpers.ts | 2 +- internal/cloud/server-agent-chat.test.ts | 18 +- internal/cloud/server-code-agent-http.ts | 8 +- tools/device-pod-cli.test.ts | 38 +-- tools/src/device-pod-cli-lib.ts | 45 +-- 19 files changed, 170 insertions(+), 535 deletions(-) diff --git a/deploy/deploy.json b/deploy/deploy.json index 0f03249d..db4a286d 100644 --- a/deploy/deploy.json +++ b/deploy/deploy.json @@ -161,6 +161,7 @@ "HWLAB_CODE_AGENT_AGENTRUN_RUNNER_NAMESPACE": "agentrun-v01", "HWLAB_CODE_AGENT_AGENTRUN_SECRET_NAMESPACE": "agentrun-v01", "HWLAB_CODE_AGENT_AGENTRUN_REPO_URL": "http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/HWLAB.git", + "HWLAB_DEVICE_POD_API_KEY": "secretRef:hwlab-v02-device-pod-api-key/api-key", "HWLAB_DEVICE_POD_INTERNAL_TOKEN": "secretRef:hwlab-v02-device-pod-internal/token" } }, diff --git a/docs/reference/agentrun-code-agent-dispatch.md b/docs/reference/agentrun-code-agent-dispatch.md index e134b765..d516b519 100644 --- a/docs/reference/agentrun-code-agent-dispatch.md +++ b/docs/reference/agentrun-code-agent-dispatch.md @@ -15,7 +15,7 @@ - GitHub PR/issue 能力通过 `toolCredentials[].tool=github` 注入,默认 SecretRef 是 `agentrun-v01-tool-github-pr` key `GH_TOKEN`。 - 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` 只能承接短期或非敏感执行上下文,例如 device-pod session token 和 `UNIDESK_MAIN_SERVER_IP`;不得承载 GitHub token、UniDesk SSH client token、provider key、长期 SSH key 或 registry token。 +- `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。 ## ResourceBundle diff --git a/docs/reference/code-agent-chat-readiness.md b/docs/reference/code-agent-chat-readiness.md index 956e3c8a..b488b89b 100644 --- a/docs/reference/code-agent-chat-readiness.md +++ b/docs/reference/code-agent-chat-readiness.md @@ -160,7 +160,7 @@ Forwarder 不需要人工维护长驻进程。正式固化后,它应由 Kubern AgentRun 接入后,`agentrun-v01` 只能表示执行基础设施,不等同于真实 provider/model。Code Agent result `completed` 要被 Web 或 CLI 标记为真实完成,必须同时具备 final assistant response、真实 provider/model、`providerTrace`、traceId、conversationId/sessionId 和可回放 trace;否则应显示“完成证据不足”,不能用 SOURCE、fixture、echo、mock 或 stub 补齐。AgentRun adapter 应把真实执行形态暴露为 `codex-stdio` / Codex app-server stdio 等价长会话,把 `agentrun-v01` 保留在 adapter、runner 或 infrastructure 字段里。 -同一 conversation/session 的后续消息必须在 AgentRun runner lease 有效时进入既有 run/runner 的新 command/turn,避免每次消息都重新 bundle、重新启动 runner、再把历史 thread 当作新 runner 可 resume 的 rollout。每条消息都重新 bundle 或启动新 runner 是功能缺口,不得只靠 trace 说明包装成通过。只有 run 已 terminal、runner lease 过期或 AgentRun manager 明确拒绝复用时才允许新建 runner;trace/result 可以说明原因,但该说明只是诊断证据,不替代复用能力本身。 +同一 conversation/session 的后续消息必须在 AgentRun runner reuse window 有效时进入既有 run/runner 的新 command/turn,避免每次消息都重新 bundle、重新启动 runner、再把历史 thread 当作新 runner 可 resume 的 rollout。每条消息都重新 bundle 或启动新 runner 是功能缺口,不得只靠 trace 说明包装成通过。只有 run 已 terminal、runner reuse window 过期或 AgentRun manager 明确拒绝复用时才允许新建 runner;trace/result 可以说明原因,但该说明只是诊断证据,不替代复用能力本身。 Trace 展示问题按“同源 row 转换优先”排查:先用 `hwlab-cli client agent trace --render web` 输出 Cloud Web 同一 trace row 摘要;CLI 也乱说明 row 转换逻辑问题,CLI 正常而浏览器乱再查 DOM、CSS、滚动和增量 patch。 diff --git a/docs/reference/spec-device-pod.md b/docs/reference/spec-device-pod.md index d0eda426..9a3c3b97 100644 --- a/docs/reference/spec-device-pod.md +++ b/docs/reference/spec-device-pod.md @@ -8,7 +8,7 @@ ```text device-pod-cli or cloud-web --> cloud-api auth + device_pod_grants + lease +-> cloud-api auth + device_pod_grants + AgentRun Device Pod API key -> hwlab-device-pod internal REST -> gateway transport -> device-host-cli @@ -17,14 +17,14 @@ device-pod-cli or cloud-web ## 在系统中的职责划分 -`device-pod` 是云端可授权、可租约保护、可审计的逻辑设备能力单元。`hwlab-cloud-api` 是用户身份、grant、lease、profile authority 和用户态 REST API 的入口;`hwlab-device-pod` 是内部执行服务;`hwlab-gateway` 只承载 transport;`device-host-cli` 只在硬件 host 侧执行 Keil、pyOCD、UART 和 workspace 操作。 +`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 操作。 普通用户、浏览器和 Code Agent session 不直接持有 gateway route、host workspace route、Kubernetes Service 直连能力或 profile 修改权。 ## 设计目标 - 用最少组件把 `device-pod-cli` 从“本地 profile + RPC/gateway 调用”迁到“1:1 REST 请求”。 -- `cloud-api` 是用户身份、device grant、profile authority 和 lease 判断入口。 +- `cloud-api` 是用户身份、device grant、AgentRun Device Pod API key 和 profile authority 判断入口。 - `hwlab-device-pod` 承接设备业务:profile 校验后的运行、job 生命周期、freshness、blocker、bounded output 和 gateway 调用。 - `device-pod-cli` 只做 selector 解析、cloud-api REST 请求和 JSON 输出;默认正式模式不读取 `.device-pod/*.json`,不保存、不上传、不修改权威 profile。 - 第一阶段只部署一个 `hwlab-device-pod` Deployment/Service,管理多个逻辑 `devicePodId`,避免为每台设备创建独立 k8s Service/Deployment。 @@ -84,7 +84,7 @@ code agent 本地文件只能作为非权威 hint/cache,最多包含: ## 内部架构 -正式 device-pod 由 profile registry、job lifecycle、freshness/blocker、bounded output、gateway/device-host adapter 和 lease integration 组成。第一阶段只有一个 `hwlab-device-pod` Deployment 管理多个 `devicePodId`;profile authority 和 user grant 在 cloud-api/Postgres 中,device-pod 服务只接受 cloud-api 内部调用。 +正式 device-pod 由 profile registry、job lifecycle、freshness/blocker、bounded output、gateway/device-host adapter 和 AgentRun Device Pod API key integration 组成。第一阶段只有一个 `hwlab-device-pod` Deployment 管理多个 `devicePodId`;profile authority、user grant 和 AgentRun API key 在 cloud-api/Postgres/Secret 中,device-pod 服务只接受 cloud-api 内部调用。 当前 v02 部署中的 `hwlab-device-pod` 微服务实现情况见 [spec-v02-hwlab-device-pod-service.md](spec-v02-hwlab-device-pod-service.md)。 @@ -163,9 +163,6 @@ POST /v1/device-pods/{devicePodId}/jobs GET /v1/device-pods/{devicePodId}/jobs/{jobId} GET /v1/device-pods/{devicePodId}/jobs/{jobId}/output POST /v1/device-pods/{devicePodId}/jobs/{jobId}/cancel -POST /v1/device-pods/{devicePodId}/leases -GET /v1/device-pods/{devicePodId}/leases/current -DELETE /v1/device-pods/{devicePodId}/leases/current ``` 管理员 API 由 `cloud-api` 提供: @@ -217,7 +214,7 @@ DELETE /v1/admin/device-pod-grants/{devicePodId}/{userId} | 服务 | 职责 | | --- | --- | -| `hwlab-cloud-api` | 用户身份、admin/user、device grant、lease、profile authority、用户态 REST API、转发到内部 device-pod。 | +| `hwlab-cloud-api` | 用户身份、admin/user、device grant、AgentRun Device Pod API key、profile authority、用户态 REST API、转发到内部 device-pod。 | | `hwlab-device-pod` | 多 `devicePodId` 运行 registry、profile runtime validation、job store、freshness、bounded output、gateway/device-host-cli adapter。 | | `device-pod-cli` | 把 `devicePodId:surface:path operation args` 1:1 转成 cloud-api REST;不保存权威 profile、不读取本地 profile 作为默认 authority、不直连 gateway。 | | `device-host-cli` | Windows host 侧自包含业务工具,负责 Keil、pyOCD、UART、workspace 文件操作。 | @@ -253,9 +250,9 @@ manages: many devicePodId - `hwlab-device-pod` 不接受无内部服务凭据的 profile snapshot 或 job 请求。 - `hwlab-device-pod` 一个实例可以列出并执行多个 `devicePodId` 的状态/job。 - cloud-api legacy compatibility entry 只能返回 blocked authority payload,不得合成 fake device pod 数据或作为正式 device-pod DEV-LIVE 证据。 -- 强副作用 job 必须有 reason,并在物理互斥需要时获取 `device_leases`。 -- `POST /v1/device-pods/{devicePodId}/leases` 只对已授权 actor 创建或刷新互斥租约,响应只返回一次性 `leaseToken`;后续强副作用 job 必须通过 `leaseToken` 或 `x-hwlab-device-lease-token` 证明持有租约。 -- 撤销 device pod grant 必须释放该用户对同一 `devicePodId` 的活动 lease。 +- 强副作用 job 必须有 `reason`;正式路径只使用普通用户 session/cookie 或 AgentRun `HWLAB_DEVICE_POD_API_KEY` 做身份授权。 +- AgentRun runner 访问 device-pod 必须使用 cloud-api 组装的 `HWLAB_DEVICE_POD_API_KEY`,该 API key 对所有正式 device-pod 授权;用户本地调试只使用普通 Web session/cookie 和 grant。 +- 撤销 device pod grant 只影响该用户通过普通 session/cookie 的可见性与使用权。 ## CLI 实现口径 @@ -269,7 +266,7 @@ manages: many devicePodId - 源码局部编辑优先使用 `apply-patch`;`put` 只用于明确的整文件写入或新文件创建。 - UART 业务覆盖 `read`、`write`、`read-after-launch-flash` 和 `jsonrpc`;JSON-RPC 请求必须由 device-host-cli 校验 response id,除非显式传入允许 id mismatch 的业务参数。 - apply-patch 类失败必须返回可定位的 patch hint,例如缺少 `*** End Patch`、hunk 上下文不匹配或 header 错误;调用方应先重新读取目标文件再重试小 hunk,不应默认绕到整文件覆盖。 -- mutating job 继续由 cloud-api 侧强制 lease/reason;CLI 只转发 `reason` 和 `leaseToken`,不在本地绕过。 +- mutating job 由 cloud-api 侧强制 `reason`;CLI 只转发 `intent`、`args` 和 `reason`。 - `profile create` 这类本地 profile bootstrap 在正式默认路径中返回 `legacy_profile_create_removed`;管理员应使用 cloud-api admin API 管理服务端 profile/grant。 - DS/device-pod prompt 和 skill 示例必须优先使用 `hwpod`,避免把长路径 wrapper 复制成常态命令;host job 轮询时 job id 必须作为 `status/output/cancel` 的紧随位置参数传入,不能放到 flags 后面或靠 shell 管道解析 JSON。 @@ -363,29 +360,22 @@ bun tools/device-pod-cli.ts bootsharp --pod-id D601-F103-V2 \ --api-base-url http://74.48.78.17:19667 \ --cookie "$COOKIE" -LEASE=$(bun tools/device-pod-cli.ts lease acquire --pod-id D601-F103-V2 \ - --api-base-url http://74.48.78.17:19667 \ - --cookie "$COOKIE" \ - --reason "D601-F103-V2 build/download validation" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).body.leaseToken))') - bun tools/device-pod-cli.ts D601-F103-V2:workspace:/ build start \ --api-base-url http://74.48.78.17:19667 \ --cookie "$COOKIE" \ - --lease-token "$LEASE" \ --reason "D601-F103-V2 Keil build validation" \ --timeout-ms 120000 bun tools/device-pod-cli.ts D601-F103-V2:debug-probe download start \ --api-base-url http://74.48.78.17:19667 \ --cookie "$COOKIE" \ - --lease-token "$LEASE" \ --reason "D601-F103-V2 Keil download validation" \ --capture-uart uart/1 \ --capture-duration-ms 8000 \ --timeout-ms 120000 ``` -Code-agent/DS runner 内执行同类验收时,把 `bun tools/device-pod-cli.ts` 换成 `hwpod`;只有 `hwpod` 不在 PATH 时才临时 fallback 到 `/app/skills/device-pod-cli/scripts/device-pod-cli.mjs`。UART 串口若被 Windows 侧工具占用,应把 COM busy/access denied 记录为可选串口证据缺口,Keil build/download 是否通过以 job output 中的 build summary、`Programming Done`、`Verify OK` 和 `Application running` 为准。 +Code-agent/DS runner 内执行同类验收时,把 `bun tools/device-pod-cli.ts` 换成 `hwpod`;`hwpod` 不在 PATH 时应判定为 runner 镜像或包安装错误并修复该标准入口,不得临时改走长路径 wrapper。UART 串口若被 Windows 侧工具占用,应把 COM busy/access denied 记录为可选串口证据缺口,Keil build/download 是否通过以 job output 中的 build summary、`Programming Done`、`Verify OK` 和 `Application running` 为准。 DS 或其他 code-agent runner 由 `hwlab-cli client harness submit` 触发时,控制面应保持短连接:提交后用 `client harness result`、`client harness trace --limit ` 和 `client harness audit --require-bootsharp` 轮询,不把 UniDesk `ssh/tran` 连接长期挂在一次 `wait` 上。`client harness wait` 只用于短窗口观察,CLI 会把超长等待压到低于 UniDesk 透传硬超时的安全窗口,并在 JSON 中返回后续短轮询命令。 @@ -421,11 +411,11 @@ hwpod D601-F103-V2:workspace:/ rg \ ## T3 -阅读 docs/reference/spec-device-pod.md,然后用 cli 手动测试以下内容:提交一个强副作用 job,例如 download/reset,缺少 reason 或 lease 时必须被拒绝;获得 lease 后响应必须包含 devicePodId、profileHash、traceId、operationId、freshness、blocker 和 bounded output metadata。 +阅读 docs/reference/spec-device-pod.md,然后用 cli 手动测试以下内容:提交一个强副作用 job,例如 download/reset,缺少 reason 时必须被拒绝;补充 reason 后必须经同一条 cloud-api job REST 路径进入 executor,并返回 devicePodId、profileHash、traceId、operationId、freshness、blocker 和 bounded output metadata。 ## T4 -阅读 docs/reference/spec-device-pod.md,然后用 cli 手动测试以下内容:对授权 Device Pod 运行 `workspace put`、`workspace rm`、`workspace rmdir`、`workspace keil add-source/remove-source` 和 `io-probe jsonrpc` 的 `--dry-run` 与一次真实小闭环。确认请求只经过 cloud-api job REST,输出 intent、reason、lease、traceId 和 bounded output,不读取本地 profile 或直连 gateway。 +阅读 docs/reference/spec-device-pod.md,然后用 cli 手动测试以下内容:对授权 Device Pod 运行 `workspace put`、`workspace rm`、`workspace rmdir`、`workspace keil add-source/remove-source` 和 `io-probe jsonrpc` 的 `--dry-run` 与一次真实小闭环。确认请求只经过 cloud-api job REST,输出 intent、reason、traceId 和 bounded output,不读取本地 profile、不直连 gateway。 ## 规格的实现情况 @@ -433,8 +423,8 @@ hwpod D601-F103-V2:workspace:/ rg \ | --- | --- | --- | | 逻辑 device-pod 模型 | 已实现为规格 | 四要素、profile shape 和 Kubernetes 口径已定义。 | | profile server authority | 部分实现 | cloud-api 保存正式 DB profile 并向用户返回脱敏摘要;device-pod executor 不接受用户上传 profile。 | -| 用户 grant + lease | 部分实现 | cloud-api 已实现 admin grant、可见性过滤、lease acquire/current/release、强副作用 job lease 校验和撤销授权释放 lease。 | -| REST/job API | 部分实现 | cloud-api 已实现 list/status/events/probe/job/output/cancel 和 lease API,并可把已授权 job 转发给内部 `hwlab-device-pod` executor;executor 已实现内部 job create/get/output/cancel lifecycle 和 gateway/device-host-cli dispatch adapter,无在线 gateway/device-host-cli 时返回 blocker。 | -| G14 device-host 功能吸收 | 部分实现 | v0.2 job intent 已覆盖 workspace put/rm/rmdir、Keil 工程维护和 UART JSON-RPC,保持 cloud-api grant/lease/profile authority。 | +| 用户 grant + AgentRun API key | 部分实现 | cloud-api 已实现 admin grant、可见性过滤、AgentRun Device Pod API key 认证和强副作用 job reason 校验。 | +| REST/job API | 部分实现 | cloud-api 已实现 list/status/events/probe/job/output/cancel,并可把已授权 job 转发给内部 `hwlab-device-pod` executor;executor 已实现内部 job create/get/output/cancel lifecycle 和 gateway/device-host-cli dispatch adapter,无在线 gateway/device-host-cli 时返回 blocker。 | +| G14 device-host 功能吸收 | 部分实现 | v0.2 job intent 已覆盖 workspace put/rm/rmdir、Keil 工程维护和 UART JSON-RPC,保持 cloud-api grant/profile authority 和 AgentRun API key runtime auth。 | | 禁止 fake 作为 DEV-LIVE | 已实现/持续约束 | 规格和服务 payload 要求显式标记 fake/source。 | diff --git a/docs/reference/spec-user-access.md b/docs/reference/spec-user-access.md index 7e8e9104..0aed1753 100644 --- a/docs/reference/spec-user-access.md +++ b/docs/reference/spec-user-access.md @@ -8,7 +8,7 @@ ## 在系统中的职责划分 -用户和权限管理不是独立微服务,权威实现收敛在 `hwlab-cloud-api`:它负责登录、session、角色、device pod grant、device lease 和 code agent session owner 校验。`hwlab-cloud-web` 只提供浏览器 UI 和同源代理;`hwlab-device-pod` 只执行设备语义;`hwlab-agent-mgr`、`hwlab-agent-worker` 和 Code Agent runtime 只能消费已经由 cloud-api 判断过的 actor/session/device 权限。 +用户和权限管理不是独立微服务,权威实现收敛在 `hwlab-cloud-api`:它负责登录、session、角色、device pod grant、AgentRun Device Pod API key 和 code agent session owner 校验。`hwlab-cloud-web` 只提供浏览器 UI 和同源代理;`hwlab-device-pod` 只执行设备语义;`hwlab-agent-mgr`、`hwlab-agent-worker` 和 Code Agent runtime 只能消费已经由 cloud-api 判断过的 actor/session/device 权限。 Postgres 是该规格的数据持久化边界。Kubernetes namespace、ServiceAccount、Service 直连和 gateway route 都不能替代用户权限模型;普通用户不获得 kubeconfig、内部 Service 直连能力或长期 Secret。 @@ -23,7 +23,7 @@ Postgres 是该规格的数据持久化边界。Kubernetes namespace、ServiceAc - `device pod` 由 `admin` 管理;普通用户只有在被 `admin` 授权后才能看到和使用对应 device pod。 - device pod 授权不拆分 `read`、`operate` 或 capability;授权关系存在即代表该用户拥有该 device pod 的完整使用权限。 - MVP 不新增产品级 `audit_events` 用户审计表,也不把用户权限依赖到 audit。现有硬件 trace/evidence/audit 字段属于硬件闭环证据,不是多用户权限模型的一部分。 -- `device lease` 可以保留,但它只解决物理设备并发互斥,不表达用户权限。 +- 强副作用 device-pod job 只额外要求业务 `reason`;设备互斥由 executor、gateway 和硬件 host 串行化或返回 blocker,不进入用户权限模型。 - 普通用户不获得 Kubernetes 用户、kubeconfig、namespace 管理权或直接访问 device pod Service 的权限;所有用户权限判断在 cloud-api 应用层完成。 - v0.2 权限数据必须与 `hwlab-dev`/`hwlab-prod` 运行数据隔离。优先在 `hwlab-v02` namespace 内使用独立 Postgres StatefulSet/PVC;若未来显式复用共享 Postgres 实例,也必须使用独立 database 或 schema、独立 Secret 和独立 migration ledger,不得直接复用 `hwlab-dev` 的 pgdata。 @@ -137,26 +137,8 @@ CREATE TABLE IF NOT EXISTS device_pod_grants ( ``` - 不包含 `capability`、`scope`、`expires_at`。 -- 撤销授权就是删除对应行;如果用户仍持有该 device pod 的活动 lease,撤销流程必须先释放或标记失效 lease。 - -### `device_leases` - -设备互斥锁;不表达权限。 - -```sql -CREATE TABLE IF NOT EXISTS device_leases ( - device_pod_id TEXT PRIMARY KEY REFERENCES device_pods(id) ON DELETE CASCADE, - holder_session_id TEXT NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE, - holder_user_id TEXT NOT NULL REFERENCES users(id), - lease_token_hash TEXT NOT NULL UNIQUE, - created_at TEXT NOT NULL, - expires_at TEXT NOT NULL, - released_at TEXT -); -``` - -- 下载、复位、长时间采样、串口独占等会占用真实物理设备的操作必须先拿 lease。 -- 读短状态可以不拿 lease,但仍必须通过 device grant 授权。 +- 撤销授权就是删除对应行;撤销后该用户通过普通 session/cookie 不再看到或使用该 device pod。 +- AgentRun runner 不依赖用户 grant 行逐个授权;cloud-api 组装 runner 时注入统一的 `HWLAB_DEVICE_POD_API_KEY`,该 key 只用于 runner 内 `hwpod` 短入口访问正式 device-pod。 ## 权限矩阵 @@ -170,14 +152,14 @@ CREATE TABLE IF NOT EXISTS device_leases ( | 给用户授权或撤销 device pod | 可以 | 不可以 | | 查看 device pod | 可以查看全部 | 只能查看被授权的 device pod | | 使用 device pod 的 workspace/debug/io 能力 | 可以使用全部 | 只能使用被授权的 device pod | -| 获取 device lease | 可以 | 只能对被授权的 device pod 获取 | +| 提交强副作用 device-pod job | 必须填写 reason | 被授权后必须填写 reason | ## 请求链路 cloud-api 每个用户态请求都按同一顺序处理: ```text -authenticate -> actor -> authorize(actor, action, resource) -> optional lease check -> execute +authenticate -> actor -> authorize(actor, action, resource) -> reason check for mutating device jobs -> execute ``` ### 登录和 session 恢复 @@ -219,7 +201,7 @@ browser admin UI -> return grant summary ``` -撤销授权走 `DELETE /v1/admin/device-pod-grants/{devicePodId}/{userId}`,先释放或失效该用户对该 device pod 的活动 lease。 +撤销授权走 `DELETE /v1/admin/device-pod-grants/{devicePodId}/{userId}`,删除后该用户通过普通 session/cookie 不再能看到或使用该 device pod。 ### 用户列出 device pod @@ -255,8 +237,8 @@ code agent turn -> cloud-api device operation route -> authenticate actor from owning session -> verify agent_sessions.owner_user_id == actor.id --> authorize device_pod_grants or admin --> acquire or validate device_leases when operation is exclusive +-> authorize device_pod_grants/admin or assembled AgentRun Device Pod API key +-> require reason for mutating operations -> cloud-api -> hwlab-device-pod internal Service -> gateway/device-host-cli/hardware path ``` @@ -265,9 +247,9 @@ code agent prompt、runner 或 worker 不得直接绕过 cloud-api 调用 device ## 内部架构 -`hwlab-cloud-api` 内部应按 auth/session、authorization、agent session owner、device-pod grant、device lease 和 admin API 模块分层。所有模块共享同一 Postgres runtime store 和 migration ledger,避免拆出早期 `hwlab-user-api` 造成跨服务一致性成本。 +`hwlab-cloud-api` 内部应按 auth/session、authorization、agent session owner、device-pod grant、AgentRun Device Pod API key 和 admin API 模块分层。所有模块共享同一 Postgres runtime store 和 migration ledger,避免拆出早期 `hwlab-user-api` 造成跨服务一致性成本。 -`user_sessions` 存浏览器 session token hash;`agent_sessions.owner_user_id` 绑定 Code Agent session;`device_pods` 存 profile authority;`device_pod_grants` 表示用户对 device pod 的完整使用权;`device_leases` 只表达物理设备互斥,不表达权限。 +`user_sessions` 存浏览器 session token hash;`agent_sessions.owner_user_id` 绑定 Code Agent session;`device_pods` 存 profile authority;`device_pod_grants` 表示普通用户对 device pod 的完整使用权;`HWLAB_DEVICE_POD_API_KEY` 表示 assembled AgentRun runner 对正式 device-pod 的统一短入口授权。 ## API 接口说明 @@ -282,7 +264,6 @@ code agent prompt、runner 或 worker 不得直接绕过 cloud-api 调用 device | `POST /v1/admin/device-pods`、`PUT /v1/admin/device-pods/{devicePodId}` | admin 管理 device pod profile authority。 | | `POST /v1/admin/device-pod-grants`、`DELETE /v1/admin/device-pod-grants/{devicePodId}/{userId}` | admin 授权或撤销普通用户使用 device pod。 | | `GET /v1/device-pods` 和 device-pod 操作 API | 按 actor role 和 grant 过滤可见/可用 device pod。 | -| `POST /v1/device-pods/{devicePodId}/leases`、`GET/DELETE /v1/device-pods/{devicePodId}/leases/current` | 对已授权 device pod 获取、查看和释放互斥 lease;强副作用 job 必须携带有效 lease token。 | | `POST /v1/agent/chat` 及 result/trace/cancel | 必须校验 `agent_sessions.owner_user_id`;admin 可跨用户查看和取消。 | `POST /v1/setup/first-admin` 的 device-pod 初始化只用于空库首次进入系统,不能作为长期 profile 管理入口。每个 seed 必须包含 `devicePodId` 和 object `profile`;cloud-api 会写入 `device_pods.profile_json/profile_hash` 并创建 `device_pod_grants(device_pod_id, first_admin_user_id)`。响应只能返回脱敏 profile、profileHash 和 grant summary,不得返回 `gatewaySessionId`、`hostWorkspaceRoot`、password 或 session token 原文。 @@ -300,11 +281,11 @@ v0.2 不新增独立用户管理微服务。用户管理、登录、session、de | 服务 | v0.2 职责 | | --- | --- | | `hwlab-cloud-web` | 登录页、普通用户工作台、admin 用户/授权 UI;浏览器 `/auth/*` 可由 cloud-web 代理到 cloud-api。 | -| `hwlab-cloud-api` | 用户、session、授权、device grant、lease、code agent owner 校验和对 device pod 的受控转发。 | +| `hwlab-cloud-api` | 用户、session、授权、device grant、AgentRun Device Pod API key、code agent owner 校验和对 device pod 的受控转发。 | | `hwlab-agent-mgr` / `hwlab-agent-worker` | 执行 code agent session;接收 owner/session label 或 env 方便观测,但不作为最终权限 authority。 | | `hwlab-device-pod` | 暴露设备语义 API;不保存用户权限,不直接面向浏览器或普通用户 session Pod。 | | `hwlab-edge-proxy` | 公网/FRP 入口和 HTTP 转发;不做业务权限,只转发 cookie/header,不注入伪 actor。 | -| Postgres | v0.2 用户、session、授权、device pod、lease 和既有 runtime durable state。 | +| Postgres | v0.2 用户、session、授权、device pod 和既有 runtime durable state。 | ## Kubernetes 落点 @@ -339,7 +320,7 @@ Kubernetes 只做运行时隔离和资源兜底,不承载 HWLAB 用户权限 | 规格项 | 状态 | 说明 | | --- | --- | --- | | admin/user 两角色模型 | 部分实现 | cloud-api 已实现 `/auth/*`、bootstrap admin、admin/user 创建和 admin-only 路由。 | -| `users`、`user_sessions`、grant/lease 表 | 部分实现 | 0001 schema 和 access-control bootstrap 覆盖 users、sessions、device_pods、grants、leases 和 jobs;Device Pod 强副作用 job 已接入 lease token 校验,真实硬件执行仍依赖 gateway/device-host-cli 在线。 | +| `users`、`user_sessions`、grant 和 job 表 | 部分实现 | 0001 schema 和 access-control bootstrap 覆盖 users、sessions、device_pods、grants 和 jobs;Device Pod 强副作用 job 已接入 reason 校验,真实硬件执行仍依赖 gateway/device-host-cli 在线。 | | Code Agent owner 绑定 | 已实现 | 已在 `agent_sessions` 写入 `owner_user_id`、conversation/thread/trace 和脱敏 session evidence;trace/result cache 也按 owner/admin 限制访问。 | | device pod 授权模型 | 部分实现 | cloud-api 已实现 admin profile/grant、普通用户可见性和 job 持久化;无在线 gateway/device-host-cli 时返回 blocker。 | | 不用 Kubernetes 表达用户权限 | 已实现/持续约束 | 规格明确禁止普通用户持有 kubeconfig 或直连 Service 权限。 | diff --git a/docs/reference/spec-v02-hwlab-cli.md b/docs/reference/spec-v02-hwlab-cli.md index 7ede86f4..47fb3a07 100644 --- a/docs/reference/spec-v02-hwlab-cli.md +++ b/docs/reference/spec-v02-hwlab-cli.md @@ -32,7 +32,7 @@ - `client agent send` 默认先恢复账号 workspace,从 selected conversation/session 和 workspace JSON 中读取标准 `conversationId/sessionId/threadId`,再向 `/v1/agent/chat` 发送这些 IDs、`workspaceId` 和 `expectedWorkspaceRevision`;服务端接受后 CLI 保存新的 workspace revision,终态轮询后再 PATCH workspace 清理终态 `activeTraceId`。默认 workspace 恢复只恢复 ID 和 revision,不恢复 messages/facts,不生成 `conversationContext`,也不得把历史文本拼入 prompt。只有显式 `--no-workspace` 才跳过这一默认恢复路径。 - `client agent steer ` 是运行中引导入口,必须调用 Cloud Web 同源 `POST /v1/agent/chat/steer`,把 steer 文本装配成 AgentRun `type=steer` command 作用到目标 trace 的 active turn。CLI 不手动穿内部 URL;验收使用当前 runtime namespace/lane 自动解析的 `19666` Web 入口,并通过原 trace 的 result/trace 观察 steer 是否被 runner 接收和应用。 - `client agent trace --render web` 必须调用 Cloud Web trace row 的同一纯转换路径,输出 `render="web"`、renderer 标识、source event count、rendered row count、默认压制的 noise event count 和 row 摘要。浏览器 trace 展示错乱时,必须先用该 CLI 入口确认 Web 渲染转换是否已经乱序、重复、缺 final response、吞掉关键 row 或只显示泛化 tool call,再继续修浏览器 DOM/CSS。 -- AgentRun v0.1 短连接 runner 已要求支持同 run/runner 多轮 command。CLI 仍应把 Web 提交的 `conversationId/sessionId/threadId` 原样送到 Cloud Web API,用于验证 adapter 是否在 runner lease 有效时复用同一个 AgentRun `runId` / `jobName` 并创建新 `commandId`;每轮都新建 runner 或重新 bundle 不是通过状态,trace 中的原因说明只能用于定位。 +- AgentRun v0.1 短连接 runner 已要求支持同 run/runner 多轮 command。CLI 仍应把 Web 提交的 `conversationId/sessionId/threadId` 原样送到 Cloud Web API,用于验证 adapter 是否在 runner reuse window 有效时复用同一个 AgentRun `runId` / `jobName` 并创建新 `commandId`;每轮都新建 runner 或重新 bundle 不是通过状态,trace 中的原因说明只能用于定位。 - `client harness`、`client harness-ops` 和 `client harness-opt` 吸收 G14 harness-ops 的短连接业务能力:health、submit、result、trace、wait 和 audit。 - harness 系列命令只调用 Cloud Web/Code Agent 同源 API,不创建镜像、Job、常驻服务,也不执行 hot-sync、kubectl cp 或硬编码 namespace/pod 的运行面写路径。 - 旧 `hwlab-cli cicd`、fixture MVP gate 和 CLI 镜像/Job 口径属于废弃路径;开发中遇到这些旧门禁、旧测试或旧预检时直接删除,不再维护兼容。 diff --git a/docs/reference/spec-v02-hwlab-cloud-api.md b/docs/reference/spec-v02-hwlab-cloud-api.md index f31bc597..15c6a50f 100644 --- a/docs/reference/spec-v02-hwlab-cloud-api.md +++ b/docs/reference/spec-v02-hwlab-cloud-api.md @@ -6,7 +6,7 @@ - 承担 runtime health、DB readiness、用户/session 权限、Code Agent 对话、trace/result 轮询、gateway outbound registry、M3 IO 控制、device-pod authority/job 和 live build inventory。 - 是 `hwlab-cloud-web`、Code Agent session、device-pod 用户态操作和 gateway outbound poll 的唯一应用层收口点;普通用户不直接访问内部 `hwlab-device-pod` Service。 -- 读取 `hwlab-cloud-api-v02-db/database-url`、`hwlab-v02-code-agent-provider/openai-api-key` 和 `hwlab-v02-code-agent-codex-auth/auth.json` 等 v02 独立 SecretRef;文档和日志只允许记录 SecretRef 名称、key、字节数或哈希指纹,不记录值。 +- 读取 `hwlab-cloud-api-v02-db/database-url`、`hwlab-v02-code-agent-provider/openai-api-key`、`hwlab-v02-code-agent-codex-auth/auth.json` 和 `hwlab-v02-device-pod-api-key/api-key` 等 v02 独立 SecretRef;文档和日志只允许记录 SecretRef 名称、key、字节数或哈希指纹,不记录值。 ## 内部架构 @@ -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` 和 device-pod session token。`transientEnv` 不设固定 8 项上限,新增短期上下文时必须按 name 去重、只传本次 Job 需要的 value,并继续禁止承载 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_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 c8c3d260..a16eefe8 100644 --- a/docs/reference/spec-v02-hwlab-cloud-web.md +++ b/docs/reference/spec-v02-hwlab-cloud-web.md @@ -12,7 +12,7 @@ - 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 输入框是否被旧逻辑锁住。 - Code Agent result `completed` 只有在同时包含真实 provider/model/trace/conversation 元数据、`providerTrace` 和可展示的 final assistant response 时,才能被 Web 标记为真实完成;`provider=agentrun-v01` 只是执行基础设施标识,不得替代上游 provider/model,也不得把 SOURCE、fixture、echo、mock 或 stub 当成 DEV-LIVE 完成。 -- 同一 conversation/session 的后续用户消息必须在 AgentRun runner lease 有效时复用已存在的 AgentRun run/runner 继续新 command/turn;只有 runner 不可用、已过期或协议明确要求新 runner 时才重新 bundle 和启动 runner。每条消息都重新 bundle/runner 属于 v0.2 AgentRun 接入缺口,不能只靠 trace 显示原因当成已完成。 +- 同一 conversation/session 的后续用户消息必须在 AgentRun runner reuse window 有效时复用已存在的 AgentRun run/runner 继续新 command/turn;只有 runner 不可用、已过期或协议明确要求新 runner 时才重新 bundle 和启动 runner。每条消息都重新 bundle/runner 属于 v0.2 AgentRun 接入缺口,不能只靠 trace 显示原因当成已完成。 - AgentRun 会话连续性只有一个标准路径:Cloud Web/CLI 提交的 `threadId` 必须经 Cloud API adapter 写入 AgentRun command `payload.threadId` 和 `SessionRef.threadId`。前端、CLI、API 和 AgentRun 的协议字段、trace、result 和 conversation facts 都以该字段为唯一 thread identity。 - Cloud Web 提交 Code Agent turn 时只发送当前用户消息、共享 workspace 的 `conversationId/sessionId/threadId`、workspace revision 和必要运行元数据;不得发送 `conversationContext/messages`,也不得把浏览器历史拼入 prompt。历史消息只用于本地 UI 展示和 trace/inspect 可见性,不能替代 AgentRun/Codex stdio 原生 `thread/resume`。 - 同一 AgentRun run 复用多条 command 时,Web trace 展示只显示当前 command 归属事件和必要 run 级状态;旧 command 的 assistant/tool/terminal 尾部不能堆到新 command 末尾。取消轮次的可读进展必须作为脱敏 conversation facts 进入 UI/trace/inspect 证据,而不是靠旧 trace 尾部串线让后续轮次“碰巧看到”;这些 facts 不得作为下一轮模型上下文或 prompt 拼接来源。 diff --git a/docs/reference/spec-v02-hwlab-device-pod-service.md b/docs/reference/spec-v02-hwlab-device-pod-service.md index 2a82593f..11e5ede0 100644 --- a/docs/reference/spec-v02-hwlab-device-pod-service.md +++ b/docs/reference/spec-v02-hwlab-device-pod-service.md @@ -5,8 +5,8 @@ ## 在系统中的职责划分 - 承接 `cloud-api -> hwlab-device-pod -> gateway/device-host-cli` 的内部执行服务位置。 -- 当前阶段只暴露 device-pod executor 边界;用户、profile、grant、lease 和 job authority 都在 `hwlab-cloud-api`,不能由该 Service 伪造或兜底。 -- 普通用户和 Code Agent 不应直接调用该 Service;正式路径必须经过 `hwlab-cloud-api` 鉴权、grant 和 lease。 +- 当前阶段只暴露 device-pod executor 边界;用户、profile、grant、AgentRun Device Pod API key 和 job authority 都在 `hwlab-cloud-api`,不能由该 Service 伪造或兜底。 +- 普通用户和 Code Agent 不应直接调用该 Service;正式路径必须经过 `hwlab-cloud-api` 鉴权、grant/API key 和 mutating job reason 校验。 ## 内部架构 @@ -47,7 +47,7 @@ | health 和 executor boundary REST | 已实现 | 服务声明内部 executor、cloud-api authority 和非 fake 来源。 | | bounded events/status | 已实现 | 只返回 executor boundary/blocker,不伪造硬件事件。 | | 正式 profile authority | 已在 cloud-api 实现 | `hwlab-device-pod` 不读取或覆盖 `device_pods.profile_json`。 | -| job lifecycle | 部分实现 | 用户态 job 由 cloud-api 鉴权、授权、lease 校验和持久化;executor 侧已提供内部 job create/get/output/cancel lifecycle,并能把 job dispatch 结果写回 bounded output。 | +| job lifecycle | 部分实现 | 用户态 job 由 cloud-api 鉴权、授权、reason 校验和持久化;executor 侧已提供内部 job create/get/output/cancel lifecycle,并能把 job dispatch 结果写回 bounded output。 | | gateway/device-host-cli adapter | 部分实现 | executor 已通过 cloud-api internal dispatch 接入 gateway poll/result 和 device-host-cli 命令映射;真实执行仍依赖 profile route、在线 gateway 和 host CLI。 | | device-pod-cli REST authority | 已实现 | `tools/device-pod-cli.ts` 默认只走 cloud-api REST,不读取 `.device-pod/*.json` 作为 profile authority;旧 `.mjs` 入口只负责启动 TypeScript CLI。 | diff --git a/docs/reference/spec-v02-hwlab-gateway.md b/docs/reference/spec-v02-hwlab-gateway.md index a386ff47..fb0820f8 100644 --- a/docs/reference/spec-v02-hwlab-gateway.md +++ b/docs/reference/spec-v02-hwlab-gateway.md @@ -55,6 +55,6 @@ | bounded shell execution | 已实现 | 受 env 开关、timeout 和 output limit 约束。 | | gateway 压测闭环 | 已实现 | `hwlab-cli client gateway pressure` 覆盖大输出、timeout 和并发背压,不依赖 shell pipe 裁剪。 | | v02 environment 标记 | 已实现 | response meta、audit 和 evidence 从请求 meta 或 gateway env 派生,不把 v02 dispatch 证据落回 `dev`。 | -| device-pod grant/lease | 不在本服务 | 由 cloud-api/device-pod 负责。 | +| device-pod grant/API key | 不在本服务 | 由 cloud-api/device-pod 负责。 | | 生产级 gateway 多租户隔离 | 未完全实现 | 当前是 demo/transport skeleton。 | diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index e3ea7c7e..f9e81c37 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -938,26 +938,25 @@ test("cloud api access control grants visible device pods and requires device-po assert.equal(job.body.profileHash, visible.body.devicePods[0].profileHash); assert.equal(directGatewayDispatches, 0); - const mutatingWithoutLease = await postJson(port, "/v1/device-pods/device-pod-71-freq/jobs", { + const mutatingWithReason = await postJson(port, "/v1/device-pods/device-pod-71-freq/jobs", { intent: "debug.reset", reason: "reset smoke" }, aliceCookie); - assert.equal(mutatingWithoutLease.status, 409); - assert.equal(mutatingWithoutLease.body.error.code, "device_lease_required"); - assert.equal(mutatingWithoutLease.body.status, "blocked"); - assert.equal(mutatingWithoutLease.body.devicePodId, "device-pod-71-freq"); - assert.equal(mutatingWithoutLease.body.profileHash, visible.body.devicePods[0].profileHash); - assert.equal(mutatingWithoutLease.body.blocker.code, "device_lease_required"); - assert.equal(mutatingWithoutLease.body.freshness.stale, true); - const rejectedJob = await getJson(port, `/v1/device-pods/device-pod-71-freq/jobs/${mutatingWithoutLease.body.job.id}`, aliceCookie); - assert.equal(rejectedJob.status, 200); - assert.equal(rejectedJob.body.job.id, mutatingWithoutLease.body.job.id); - assert.equal(rejectedJob.body.status, "blocked"); - assert.equal(rejectedJob.body.blocker.code, "device_lease_required"); - const rejectedOutput = await getJson(port, `/v1/device-pods/device-pod-71-freq/jobs/${mutatingWithoutLease.body.job.id}/output`, aliceCookie); - assert.equal(rejectedOutput.status, 200); - assert.equal(rejectedOutput.body.truncation.truncated, false); - assert.match(rejectedOutput.body.output.error, /requires an active device lease/u); + assert.equal(mutatingWithReason.status, 409); + assert.equal(mutatingWithReason.body.status, "blocked"); + assert.equal(mutatingWithReason.body.devicePodId, "device-pod-71-freq"); + assert.equal(mutatingWithReason.body.profileHash, visible.body.devicePods[0].profileHash); + assert.equal(mutatingWithReason.body.blocker.code, "device_pod_executor_unavailable"); + assert.equal(mutatingWithReason.body.freshness.stale, true); + const blockedJob = await getJson(port, `/v1/device-pods/device-pod-71-freq/jobs/${mutatingWithReason.body.job.id}`, aliceCookie); + assert.equal(blockedJob.status, 200); + assert.equal(blockedJob.body.job.id, mutatingWithReason.body.job.id); + assert.equal(blockedJob.body.status, "blocked"); + assert.equal(blockedJob.body.blocker.code, "device_pod_executor_unavailable"); + const blockedOutput = await getJson(port, `/v1/device-pods/device-pod-71-freq/jobs/${mutatingWithReason.body.job.id}/output`, aliceCookie); + assert.equal(blockedOutput.status, 200); + assert.equal(blockedOutput.body.truncation.truncated, false); + assert.match(blockedOutput.body.output.error, /HWLAB_DEVICE_POD_URL is not configured/u); const mutatingWithoutReason = await postJson(port, "/v1/device-pods/device-pod-71-freq/jobs", { intent: "debug.reset" @@ -970,56 +969,11 @@ test("cloud api access control grants visible device pods and requires device-po assert.equal(mutatingWithoutReason.body.blocker.code, "device_job_reason_required"); assert.equal(mutatingWithoutReason.body.freshness.stale, true); - const mutatingWithInvalidLease = await postJson(port, "/v1/device-pods/device-pod-71-freq/jobs", { - intent: "debug.reset", - reason: "reset smoke", - leaseToken: "invalid-lease-token" - }, aliceCookie); - assert.equal(mutatingWithInvalidLease.status, 409); - assert.equal(mutatingWithInvalidLease.body.error.code, "device_lease_invalid"); - assert.equal(mutatingWithInvalidLease.body.status, "blocked"); - assert.equal(mutatingWithInvalidLease.body.devicePodId, "device-pod-71-freq"); - assert.equal(mutatingWithInvalidLease.body.profileHash, visible.body.devicePods[0].profileHash); - assert.equal(mutatingWithInvalidLease.body.blocker.code, "device_lease_invalid"); - assert.equal(mutatingWithInvalidLease.body.freshness.stale, true); - - const lease = await postJson(port, "/v1/device-pods/device-pod-71-freq/leases", { - reason: "exclusive reset smoke", - ttlSeconds: 60 - }, aliceCookie); - assert.equal(lease.status, 201); - assert.equal(lease.body.contractVersion, "device-pod-lease-v1"); - assert.equal(lease.body.lease.devicePodId, "device-pod-71-freq"); - assert.equal(lease.body.lease.holderUserId, userCreate.body.user.id); - assert.equal(typeof lease.body.leaseToken, "string"); - assert.equal(JSON.stringify(lease.body.lease).includes(lease.body.leaseToken), false); - - const currentLease = await getJson(port, "/v1/device-pods/device-pod-71-freq/leases/current", aliceCookie); - assert.equal(currentLease.status, 200); - assert.equal(currentLease.body.lease.holderUserId, userCreate.body.user.id); - - const releaseWithoutToken = await fetch(`http://127.0.0.1:${port}/v1/device-pods/device-pod-71-freq/leases/current`, { - method: "DELETE", - headers: { cookie: aliceCookie } - }); - assert.equal(releaseWithoutToken.status, 404); - assert.equal((await releaseWithoutToken.json()).released, false); - const currentAfterMissingToken = await getJson(port, "/v1/device-pods/device-pod-71-freq/leases/current", aliceCookie); - assert.equal(currentAfterMissingToken.status, 200); - assert.equal(currentAfterMissingToken.body.lease.holderUserId, userCreate.body.user.id); - const bobLogin = await postJson(port, "/auth/login", { username: "bob", password: "bob-pass" }); assert.equal(bobLogin.status, 200); - const bobLeaseConflict = await postJson(port, "/v1/device-pods/device-pod-71-freq/leases", { - reason: "exclusive reset smoke" - }, bobLogin.cookie); - assert.equal(bobLeaseConflict.status, 409); - assert.equal(bobLeaseConflict.body.error.code, "device_lease_conflict"); - assert.equal(bobLeaseConflict.body.lease.holderUserId, userCreate.body.user.id); - - const bobListWithoutPrivateGrant = await getJson(port, "/v1/device-pods", bobLogin.cookie); - assert.equal(bobListWithoutPrivateGrant.status, 200); - assert.equal(bobListWithoutPrivateGrant.body.devicePods.length, 1); + const bobListWithGrant = await getJson(port, "/v1/device-pods", bobLogin.cookie); + assert.equal(bobListWithGrant.status, 200); + assert.equal(bobListWithGrant.body.devicePods.length, 1); const privatePodCreate = await postJson(port, "/v1/admin/device-pods", { devicePodId: "device-pod-private", @@ -1033,30 +987,6 @@ test("cloud api access control grants visible device pods and requires device-po assert.equal(bobMissingStatus.status, 404); assert.equal(bobMissingStatus.body.error.code, "device_pod_not_found"); - const mutatingWithLease = await postJson(port, "/v1/device-pods/device-pod-71-freq/jobs", { - intent: "debug.reset", - reason: "reset smoke with lease", - leaseToken: lease.body.leaseToken - }, aliceCookie); - assert.equal(mutatingWithLease.status, 409); - assert.equal(mutatingWithLease.body.status, "blocked"); - assert.equal(mutatingWithLease.body.lease.holderUserId, userCreate.body.user.id); - assert.equal(mutatingWithLease.body.blocker.code, "device_pod_executor_unavailable"); - assert.equal(directGatewayDispatches, 0); - - const release = await fetch(`http://127.0.0.1:${port}/v1/device-pods/device-pod-71-freq/leases/current`, { - method: "DELETE", - headers: { cookie: aliceCookie, "x-hwlab-device-lease-token": lease.body.leaseToken } - }); - assert.equal(release.status, 200); - assert.equal((await release.json()).released, true); - - const bobLease = await postJson(port, "/v1/device-pods/device-pod-71-freq/leases", { - reason: "exclusive reset smoke after release" - }, bobLogin.cookie); - assert.equal(bobLease.status, 201); - assert.equal(bobLease.body.lease.holderUserId, bobCreate.body.user.id); - const events = await getJson(port, "/v1/device-pods/device-pod-71-freq/events", aliceCookie); assert.equal(events.status, 200); assert.equal(events.body.events[0].blocker.code, "device_pod_executor_unavailable"); @@ -1076,8 +1006,6 @@ test("cloud api access control grants visible device pods and requires device-po const revoked = await revoke.json(); assert.equal(revoked.devicePodId, "device-pod-71-freq"); assert.equal(revoked.userId, bobCreate.body.user.id); - assert.equal(revoked.releasedLease.holderUserId, bobCreate.body.user.id); - assert.equal(revoked.releasedLease.active, false); const afterRevoke = await getJson(port, "/v1/device-pods", bobLogin.cookie); assert.equal(afterRevoke.status, 200); @@ -1259,13 +1187,14 @@ test("cloud api protects device-pod routes when access control is required", asy } }); -test("cloud api issues a redacted Code Agent device-pod session with admin device access", async () => { +test("cloud api accepts the assembled Code Agent Device Pod API key with admin device access", async () => { const accessController = createAccessController({ env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1", HWLAB_BOOTSTRAP_ADMIN_ID: "usr_v02_admin", HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", - HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass" + HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass", + HWLAB_DEVICE_POD_API_KEY: "test-device-pod-api-key" }, now: () => "2026-05-28T00:00:00.000Z" }); @@ -1274,7 +1203,8 @@ test("cloud api issues a redacted Code Agent device-pod session with admin devic HWLAB_ACCESS_CONTROL_REQUIRED: "1", HWLAB_BOOTSTRAP_ADMIN_ID: "usr_v02_admin", HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", - HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass" + HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass", + HWLAB_DEVICE_POD_API_KEY: "test-device-pod-api-key" }, accessController, now: () => "2026-05-28T00:00:00.000Z" @@ -1289,83 +1219,30 @@ test("cloud api issues a redacted Code Agent device-pod session with admin devic profile: { schemaVersion: 1, target: { id: "target-71-freq" }, route: { gatewaySessionId: "gws_unused" } } }, adminLogin.cookie); - const agentAuth = await accessController.createCodeAgentDevicePodSession(); + const agentAuth = await accessController.createCodeAgentDevicePodApiKey(); assert.equal(agentAuth.actor.id, "usr_v02_admin"); assert.equal(agentAuth.actor.role, "admin"); - assert.equal(agentAuth.tokenSource, "code-agent-device-pod-session"); + assert.equal(agentAuth.tokenSource, "code-agent-device-pod-api-key"); assert.equal(agentAuth.valuesRedacted, true); - assert.equal(typeof agentAuth.token, "string"); - assert.equal(JSON.stringify(agentAuth.session).includes(agentAuth.token), false); + assert.equal(agentAuth.apiKey, "test-device-pod-api-key"); const status = await getJson(port, "/v1/device-pods/device-pod-71-freq/status", null, { - "x-hwlab-session-token": agentAuth.token + "x-hwlab-device-pod-api-key": agentAuth.apiKey }); assert.equal(status.status, 200); assert.equal(status.body.devicePodId, "device-pod-71-freq"); assert.equal(status.body.actor.id, "usr_v02_admin"); assert.equal(status.body.actor.role, "admin"); - assert.equal(JSON.stringify(status.body).includes(agentAuth.token), false); - } finally { - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); - } -}); + assert.equal(JSON.stringify(status.body).includes(agentAuth.apiKey), false); -test("cloud api creates device lease holder session before lease insert", async () => { - const accessController = createAccessController({ - env: { - HWLAB_ACCESS_CONTROL_REQUIRED: "1", - HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", - HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass" - }, - now: () => "2026-05-28T00:00:00.000Z" - }); - const store = accessController.store; - const leaseOrder = []; - const holderSessions = new Set(); - const recordAgentSessionOwner = store.recordAgentSessionOwner.bind(store); - store.recordAgentSessionOwner = async (input) => { - leaseOrder.push(`record:${input.sessionId}`); - holderSessions.add(input.sessionId); - return recordAgentSessionOwner(input); - }; - const acquireDeviceLease = store.acquireDeviceLease.bind(store); - store.acquireDeviceLease = async (input) => { - leaseOrder.push(`acquire:${input.holderSessionId}`); - if (!holderSessions.has(input.holderSessionId)) throw new Error("holder_session_fk_missing"); - return acquireDeviceLease(input); - }; - const server = createCloudApiServer({ - env: { - HWLAB_ACCESS_CONTROL_REQUIRED: "1", - HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", - HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass" - }, - accessController, - now: () => "2026-05-28T00:00:00.000Z" - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" }); - const userCreate = await postJson(port, "/v1/admin/users", { username: "alice", password: "alice-pass" }, adminLogin.cookie); - await postJson(port, "/v1/admin/device-pods", { - devicePodId: "device-pod-71-freq", - profile: { schemaVersion: 1, target: { id: "target-71-freq" }, route: { gatewaySessionId: "gws_unused" } } - }, adminLogin.cookie); - await postJson(port, "/v1/admin/device-pod-grants", { devicePodId: "device-pod-71-freq", userId: userCreate.body.user.id }, adminLogin.cookie); - - const aliceLogin = await postJson(port, "/auth/login", { username: "alice", password: "alice-pass" }); - const lease = await postJson(port, "/v1/device-pods/device-pod-71-freq/leases", { - agentSessionId: "ses_fk_order_test", - reason: "fk order smoke", - ttlSeconds: 60 - }, aliceLogin.cookie); - - assert.equal(lease.status, 201); - assert.equal(lease.body.acquired, true); - assert.equal(lease.body.lease.holderSessionId, "ses_fk_order_test"); - assert.deepEqual(leaseOrder, ["record:ses_fk_order_test", "acquire:ses_fk_order_test"]); + const adminWithApiKey = await postJson(port, "/v1/admin/device-pods", { + devicePodId: "device-pod-api-key-admin-blocked", + profile: { schemaVersion: 1, target: { id: "target-blocked" }, route: { gatewaySessionId: "gws_unused" } } + }, null, { + "x-hwlab-device-pod-api-key": agentAuth.apiKey + }); + assert.equal(adminWithApiKey.status, 403); + assert.equal(adminWithApiKey.body.error.code, "admin_session_required"); } finally { await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); } diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index 5a2ff642..99e6cf91 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -11,7 +11,7 @@ import { getHeader, readBody, sendJson, truthyFlag } from "./server-http-utils.t const SESSION_COOKIE = "hwlab_session"; const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 7; -const CODE_AGENT_DEVICE_POD_SESSION_REFRESH_SKEW_SECONDS = 60 * 5; +const DEVICE_POD_API_KEY_HEADER = "x-hwlab-device-pod-api-key"; const DEVICE_JOB_CONTRACT_VERSION = "device-pod-job-v1"; const DEVICE_JOB_INTENTS = new Set([ "workspace.bootsharp", @@ -114,15 +114,6 @@ const ACCESS_SCHEMA_STATEMENTS = Object.freeze([ created_at TEXT NOT NULL, PRIMARY KEY (device_pod_id, user_id) )`, - `CREATE TABLE IF NOT EXISTS device_leases ( - device_pod_id TEXT PRIMARY KEY REFERENCES device_pods(id) ON DELETE CASCADE, - holder_session_id TEXT NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE, - holder_user_id TEXT NOT NULL REFERENCES users(id), - lease_token_hash TEXT NOT NULL UNIQUE, - created_at TEXT NOT NULL, - expires_at TEXT NOT NULL, - released_at TEXT - )`, `CREATE TABLE IF NOT EXISTS device_pod_jobs ( id TEXT PRIMARY KEY, device_pod_id TEXT NOT NULL REFERENCES device_pods(id) ON DELETE CASCADE, @@ -154,8 +145,6 @@ const MUTATING_INTENTS = new Set([ "io.uart.write", "io.uart.jsonrpc" ]); -const DEFAULT_DEVICE_LEASE_TTL_SECONDS = 15 * 60; -const MAX_DEVICE_LEASE_TTL_SECONDS = 60 * 60; const DEVICE_JOB_OUTPUT_MAX_BYTES = 12000; const DEVICE_POD_PROFILE_SECRET_KEY_PATTERN = /(?:token|secret|password|passphrase|credential|api[_-]?key|access[_-]?key|private[_-]?key|git[_-]?key|cloud[_-]?token|kubeconfig|database[_-]?url|db[_-]?url|connection[_-]?string)/iu; const DEVICE_POD_PROFILE_SECRET_VALUE_PATTERN = /(?:-----BEGIN [A-Z ]*PRIVATE KEY-----|postgres(?:ql)?:\/\/|mysql:\/\/|mongodb(?:\+srv)?:\/\/|redis:\/\/|gh[pousr]_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,})/u; @@ -185,11 +174,11 @@ class AccessController { this.codeAgentEnv = env; this.devicePodExecutorUrl = normalizeBaseUrl(devicePodExecutorUrl); this.devicePodInternalToken = textOr(env.HWLAB_DEVICE_POD_INTERNAL_TOKEN, ""); + this.devicePodApiKey = textOr(env.HWLAB_DEVICE_POD_API_KEY, ""); this.devicePodExecutorTimeoutMs = Number.parseInt(String(devicePodExecutorTimeoutMs), 10) || 1200; this.now = now; this.required = required; this.bootstrapAttempted = false; - this.codeAgentDevicePodSession = null; } configureCodeAgentWorkspaceContext({ env = null, fetchImpl = null, traceStore = null, codeAgentChatResults = null } = {}) { @@ -362,6 +351,9 @@ class AccessController { await this.ensureBootstrap(); const auth = await this.authenticate(request, { required: true }); if (!auth.ok) return sendJson(response, auth.status, auth); + if (auth.devicePodApiKey) { + return sendJson(response, 403, errorPayload("admin_session_required", "Admin APIs require a user session", 403)); + } if (auth.actor.role !== "admin") { return sendJson(response, 403, errorPayload("admin_required", "Only admin users can call v0.2 admin APIs", 403)); } @@ -415,8 +407,7 @@ class AccessController { if (request.method === "DELETE" && url.pathname.startsWith("/v1/admin/device-pod-grants/")) { const [, , , devicePodId, userId] = url.pathname.split("/").filter(Boolean).map((part) => decodeURIComponent(part)); await this.store.revokeDevicePodGrant({ devicePodId: requiredText(devicePodId, "devicePodId"), userId: requiredText(userId, "userId") }); - const releasedLease = await this.store.releaseDeviceLease?.({ devicePodId, holderUserId: userId, now: this.now(), allowAdmin: false, revokeGrant: true }); - return sendJson(response, 200, { revoked: true, devicePodId, userId, releasedLease: publicDeviceLease(releasedLease) }); + return sendJson(response, 200, { revoked: true, devicePodId, userId }); } sendJson(response, 404, errorPayload("not_found", "Admin route is not implemented", 404)); @@ -461,9 +452,6 @@ class AccessController { if (request.method === "GET" && parsed.route === "debug-probe/chip-id") return this.createDevicePodProbeJob(response, pod, auth.actor, { interfaceName: "debug-probe", intent: "debug.chip-id" }); if (request.method === "GET" && parsed.route === "io-probe/uart/1") return this.createDevicePodProbeJob(response, pod, auth.actor, { interfaceName: "io-probe", intent: "io.ports", args: { uartId: "uart/1" } }); if (request.method === "GET" && parsed.route === "io-probe/uart/1/tail") return this.createDevicePodProbeJob(response, pod, auth.actor, { interfaceName: "io-probe", intent: "io.uart.read", args: uartTailArgs(url.searchParams, "uart/1"), outputMaxBytes: boundedOutputMaxBytes(url.searchParams) }); - if (request.method === "POST" && parsed.route === "leases") return this.acquireDevicePodLease(request, response, pod, auth.actor); - if (request.method === "GET" && parsed.route === "leases/current") return this.getDevicePodLease(response, pod, auth.actor); - if (request.method === "DELETE" && parsed.route === "leases/current") return this.releaseDevicePodLease(request, response, pod, auth.actor); if (request.method === "POST" && parsed.route === "jobs") return this.createDevicePodJob(request, response, pod, auth.actor); if (request.method === "GET" && parsed.route.startsWith("jobs/")) return this.getDevicePodJob(response, pod, parsed.route, auth.actor); if (request.method === "POST" && parsed.route.startsWith("jobs/") && parsed.route.endsWith("/cancel")) return this.cancelDevicePodJob(response, pod, parsed.route, auth.actor); @@ -476,6 +464,8 @@ class AccessController { async authenticate(request, { required = this.required } = {}) { await this.ensureBootstrap(); + const devicePodApiKey = devicePodApiKeyFromRequest(request); + if (devicePodApiKey) return this.authenticateDevicePodApiKey(devicePodApiKey); const token = sessionTokenFromRequest(request); if (!token) { return required ? errorPayload("auth_required", "Authentication is required", 401) : { ok: true, actor: null }; @@ -488,6 +478,20 @@ class AccessController { return { ok: true, actor: session.user, session: publicSession(session) }; } + async authenticateDevicePodApiKey(apiKey) { + if (!this.devicePodApiKey || !constantSecretEquals(apiKey, this.devicePodApiKey)) { + return errorPayload("device_pod_api_key_invalid", "Device Pod API key is missing or invalid", 401); + } + const actor = await this.bootstrapAdminActor(); + if (!actor || actor.status !== "active") return errorPayload("device_pod_api_key_actor_unavailable", "Device Pod API key actor is unavailable", 401); + return { + ok: true, + actor, + session: { id: "device-pod-api-key", tokenSource: "device-pod-api-key", valuesRedacted: true }, + devicePodApiKey: true + }; + } + async handleLogin(request, response) { const body = await jsonBody(request); const user = await this.store.findUserByUsername(requiredText(body.username, "username")); @@ -517,31 +521,17 @@ class AccessController { return sendJson(response, 200, { authenticated: false, loggedOut: true }); } - async createCodeAgentDevicePodSession() { + async createCodeAgentDevicePodApiKey() { await this.ensureBootstrap(); - const now = this.now(); - if (this.codeAgentDevicePodSession?.token && !sessionExpiresSoon(this.codeAgentDevicePodSession.expiresAt, now)) { - return this.codeAgentDevicePodSession; - } + if (!this.devicePodApiKey) return null; const actor = await this.bootstrapAdminActor(); if (!actor || actor.status !== "active") return null; - const token = randomBytes(32).toString("base64url"); - const expiresAt = new Date(Date.parse(now) + SESSION_MAX_AGE_SECONDS * 1000).toISOString(); - const session = await this.store.createSession({ - userId: actor.id, - tokenHash: sha256(token), - now, - expiresAt - }); - this.codeAgentDevicePodSession = { - token, - expiresAt, + return { + apiKey: this.devicePodApiKey, actor: publicActor(actor), - session: publicSession({ ...session, user: actor }), - tokenSource: "code-agent-device-pod-session", + tokenSource: "code-agent-device-pod-api-key", valuesRedacted: true }; - return this.codeAgentDevicePodSession; } async bootstrapAdminActor() { @@ -994,114 +984,6 @@ class AccessController { } } - async acquireDevicePodLease(request, response, pod, actor) { - const body = await jsonBody(request); - const now = this.now(); - const ttlSeconds = boundedLeaseTtlSeconds(body.ttlSeconds); - const expiresAt = new Date(Date.parse(now) + ttlSeconds * 1000).toISOString(); - const token = randomBytes(32).toString("base64url"); - const holderSessionId = safeAgentSessionId(body.agentSessionId) || `ses_devicepod_${randomUUID()}`; - const current = await this.store.getActiveDeviceLease?.(pod.id, now); - if (current && current.holderUserId !== actor.id) { - return sendJson(response, 409, { - ok: false, - status: 409, - error: { - code: "device_lease_conflict", - message: "Device Pod already has an active lease held by another actor" - }, - lease: publicDeviceLease(current), - devicePodId: pod.id, - profileHash: pod.profileHash - }); - } - await this.store.recordAgentSessionOwner?.({ - sessionId: holderSessionId, - ownerUserId: actor.id, - projectId: textOr(body.projectId, "prj_v02_device_pod"), - agentId: "hwlab-device-pod", - status: "active", - session: { source: "device-pod-lease", reason: textOr(body.reason, "") }, - now - }); - const lease = await this.store.acquireDeviceLease?.({ - devicePodId: pod.id, - holderSessionId, - holderUserId: actor.id, - leaseTokenHash: sha256(token), - now, - expiresAt - }); - if (!lease) { - const active = await this.store.getActiveDeviceLease?.(pod.id, now); - return sendJson(response, 409, { - ok: false, - status: 409, - error: { - code: "device_lease_conflict", - message: "Device Pod already has an active lease held by another actor" - }, - lease: publicDeviceLease(active), - devicePodId: pod.id, - profileHash: pod.profileHash - }); - } - return sendJson(response, 201, { - serviceId: CLOUD_API_SERVICE_ID, - contractVersion: "device-pod-lease-v1", - acquired: true, - devicePodId: pod.id, - targetId: targetIdFromProfile(pod.profile), - profileHash: pod.profileHash, - lease: publicDeviceLease(lease), - leaseToken: token, - tokenUsage: "Pass as body.leaseToken or x-hwlab-device-lease-token when creating mutating jobs.", - source: authoritySource() - }); - } - - async getDevicePodLease(response, pod, actor) { - const lease = await this.store.getActiveDeviceLease?.(pod.id, this.now()); - if (!lease || (actor.role !== "admin" && lease.holderUserId !== actor.id)) { - return sendJson(response, 200, { - serviceId: CLOUD_API_SERVICE_ID, - contractVersion: "device-pod-lease-v1", - devicePodId: pod.id, - profileHash: pod.profileHash, - lease: null, - source: authoritySource() - }); - } - return sendJson(response, 200, { - serviceId: CLOUD_API_SERVICE_ID, - contractVersion: "device-pod-lease-v1", - devicePodId: pod.id, - profileHash: pod.profileHash, - lease: publicDeviceLease(lease), - source: authoritySource() - }); - } - - async releaseDevicePodLease(request, response, pod, actor) { - const token = leaseTokenFromRequest(request, null); - const released = await this.store.releaseDeviceLease?.({ - devicePodId: pod.id, - holderUserId: actor.id, - tokenHash: token ? sha256(token) : null, - now: this.now(), - allowAdmin: actor.role === "admin" - }); - return sendJson(response, released ? 200 : 404, { - serviceId: CLOUD_API_SERVICE_ID, - contractVersion: "device-pod-lease-v1", - released: Boolean(released), - devicePodId: pod.id, - profileHash: pod.profileHash, - lease: publicDeviceLease(released), - source: authoritySource() - }); - } - devicePodStatus(pod, actor) { const route = pod.profile.route ?? {}; const gatewaySessionId = textOr(route.gatewaySessionId, ""); @@ -1223,29 +1105,6 @@ class AccessController { blocker: deviceJobBlocked("device_job_reason_required", `Device job intent ${intent} requires reason`, false) }); } - let lease = null; - if (MUTATING_INTENTS.has(intent)) { - const leaseToken = leaseTokenFromRequest(request, body); - if (!leaseToken) { - return this.rejectDevicePodJob(response, pod, actor, { - httpStatus: 409, - intent, - reason, - args: normalizeObject(body.args), - blocker: deviceJobBlocked("device_lease_required", `Device job intent ${intent} requires an active device lease`, true) - }); - } - lease = await this.store.getDeviceLeaseByToken?.({ devicePodId: pod.id, tokenHash: sha256(leaseToken), now: this.now() }); - if (!lease || (lease.holderUserId !== actor.id && actor.role !== "admin")) { - return this.rejectDevicePodJob(response, pod, actor, { - httpStatus: 409, - intent, - reason, - args: normalizeObject(body.args), - blocker: deviceJobBlocked("device_lease_invalid", "Device lease is missing, expired, released, or held by another actor", true) - }); - } - } const traceId = `trc_devicepod_${randomUUID()}`; const operationId = `op_devicepod_${randomUUID()}`; const now = this.now(); @@ -1268,7 +1127,7 @@ class AccessController { const dispatched = this.devicePodExecutorUrl ? await this.dispatchDevicePodExecutorJob({ job, pod, actor }) : await this.blockDevicePodJobWithoutExecutor({ job, pod }); - sendJson(response, dispatched.httpStatus, this.jobPayload(dispatched.job, pod, { lease })); + sendJson(response, dispatched.httpStatus, this.jobPayload(dispatched.job, pod)); } async rejectDevicePodJob(response, pod, actor, { httpStatus, intent, reason = "", args = {}, blocker }) { @@ -1429,7 +1288,7 @@ class AccessController { return { job: updated ?? job, httpStatus: 409 }; } - jobPayload(job, pod, { lease = null } = {}) { + jobPayload(job, pod) { const blocker = normalizeBlocker(job.blocker); return { serviceId: CLOUD_API_SERVICE_ID, @@ -1442,7 +1301,6 @@ class AccessController { traceId: job.traceId, operationId: job.operationId, job: publicJob(job), - lease: lease ? publicDeviceLease(lease) : null, blocker, freshness: freshness(job.updatedAt, blocker), outputUrl: `/v1/device-pods/${encodeURIComponent(pod.id)}/jobs/${encodeURIComponent(job.id)}/output`, @@ -1469,7 +1327,6 @@ class MemoryAccessStore { this.sessions = new Map(); this.devicePods = new Map(); this.grants = new Set(); - this.leases = new Map(); this.jobs = new Map(); this.agentSessions = new Map(); this.workspaces = new Map(); @@ -1520,32 +1377,6 @@ class MemoryAccessStore { async getDevicePod(id) { return this.devicePods.get(id) ?? null; } async listVisibleDevicePods(actor) { return [...this.devicePods.values()].filter((pod) => pod.status === "active" && (actor.role === "admin" || this.grants.has(grantKey(pod.id, actor.id)))); } async getVisibleDevicePod(actor, id) { return (await this.listVisibleDevicePods(actor)).find((pod) => pod.id === id) ?? null; } - async acquireDeviceLease(input) { - const now = input.now ?? this.now(); - const active = await this.getActiveDeviceLease(input.devicePodId, now); - if (active && active.holderUserId !== input.holderUserId) return null; - const lease = { devicePodId: input.devicePodId, holderSessionId: input.holderSessionId, holderUserId: input.holderUserId, leaseTokenHash: input.leaseTokenHash, createdAt: active?.createdAt ?? now, expiresAt: input.expiresAt, releasedAt: null }; - this.leases.set(input.devicePodId, lease); - return lease; - } - async getActiveDeviceLease(devicePodId, now = this.now()) { - const lease = this.leases.get(devicePodId) ?? null; - return lease && !lease.releasedAt && lease.expiresAt > now ? lease : null; - } - async getDeviceLeaseByToken(input) { - const lease = await this.getActiveDeviceLease(input.devicePodId, input.now ?? this.now()); - return lease?.leaseTokenHash === input.tokenHash ? lease : null; - } - async releaseDeviceLease(input) { - const lease = await this.getActiveDeviceLease(input.devicePodId, input.now ?? this.now()); - if (!lease) return null; - const tokenMatches = input.tokenHash && lease.leaseTokenHash === input.tokenHash; - if (!input.allowAdmin && !input.revokeGrant && (lease.holderUserId !== input.holderUserId || !tokenMatches)) return null; - if (!input.allowAdmin && input.revokeGrant && lease.holderUserId !== input.holderUserId) return null; - const released = { ...lease, releasedAt: input.now ?? this.now() }; - this.leases.set(input.devicePodId, released); - return released; - } async createDevicePodJob(input) { const job = normalizeJob(input); this.jobs.set(job.id, job); return job; } async updateDevicePodJob(devicePodId, jobId, patch) { const job = this.jobs.get(jobId); if (!job || job.devicePodId !== devicePodId) return null; const next = { ...job, ...patch, updatedAt: patch.updatedAt ?? this.now() }; this.jobs.set(jobId, next); return next; } async getDevicePodJob(devicePodId, jobId) { const job = this.jobs.get(jobId); return job?.devicePodId === devicePodId ? job : null; } @@ -1651,30 +1482,6 @@ class PostgresAccessStore extends MemoryAccessStore { async revokeDevicePodGrant(input) { await this.ensureSchema(); await this.query("DELETE FROM device_pod_grants WHERE device_pod_id = $1 AND user_id = $2", [input.devicePodId, input.userId]); } async getDevicePod(id) { await this.ensureSchema(); const result = await this.query("SELECT * FROM device_pods WHERE id = $1 LIMIT 1", [id]); return pgDevicePod(result.rows?.[0]); } async listVisibleDevicePods(actor) { await this.ensureSchema(); const sql = actor.role === "admin" ? "SELECT * FROM device_pods WHERE status = 'active' ORDER BY id" : "SELECT p.* FROM device_pods p JOIN device_pod_grants g ON g.device_pod_id = p.id WHERE p.status = 'active' AND g.user_id = $1 ORDER BY p.id"; const result = await this.query(sql, actor.role === "admin" ? [] : [actor.id]); return result.rows.map(pgDevicePod); } - async acquireDeviceLease(input) { - await this.ensureSchema(); - const active = await this.getActiveDeviceLease(input.devicePodId, input.now); - if (active && active.holderUserId !== input.holderUserId) return null; - const result = await this.query("INSERT INTO device_leases (device_pod_id, holder_session_id, holder_user_id, lease_token_hash, created_at, expires_at, released_at) VALUES ($1,$2,$3,$4,$5,$6,NULL) ON CONFLICT (device_pod_id) DO UPDATE SET holder_session_id = EXCLUDED.holder_session_id, holder_user_id = EXCLUDED.holder_user_id, lease_token_hash = EXCLUDED.lease_token_hash, expires_at = EXCLUDED.expires_at, released_at = NULL WHERE device_leases.released_at IS NOT NULL OR device_leases.expires_at <= $5 OR device_leases.holder_user_id = $3 RETURNING *", [input.devicePodId, input.holderSessionId, input.holderUserId, input.leaseTokenHash, input.now, input.expiresAt]); - return pgLease(result.rows?.[0]); - } - async getActiveDeviceLease(devicePodId, now = this.now()) { await this.ensureSchema(); const result = await this.query("SELECT * FROM device_leases WHERE device_pod_id = $1 AND released_at IS NULL AND expires_at > $2 LIMIT 1", [devicePodId, now]); return pgLease(result.rows?.[0]); } - async getDeviceLeaseByToken(input) { await this.ensureSchema(); const result = await this.query("SELECT * FROM device_leases WHERE device_pod_id = $1 AND lease_token_hash = $2 AND released_at IS NULL AND expires_at > $3 LIMIT 1", [input.devicePodId, input.tokenHash, input.now]); return pgLease(result.rows?.[0]); } - async releaseDeviceLease(input) { - await this.ensureSchema(); - const params = input.allowAdmin - ? [input.devicePodId, input.now] - : input.revokeGrant - ? [input.devicePodId, input.now, input.holderUserId] - : [input.devicePodId, input.now, input.holderUserId, input.tokenHash]; - const sql = input.allowAdmin - ? "UPDATE device_leases SET released_at = $2 WHERE device_pod_id = $1 AND released_at IS NULL RETURNING *" - : input.revokeGrant - ? "UPDATE device_leases SET released_at = $2 WHERE device_pod_id = $1 AND holder_user_id = $3 AND released_at IS NULL RETURNING *" - : "UPDATE device_leases SET released_at = $2 WHERE device_pod_id = $1 AND holder_user_id = $3 AND lease_token_hash = $4 AND released_at IS NULL RETURNING *"; - const result = await this.query(sql, params); - return pgLease(result.rows?.[0]); - } async createDevicePodJob(input) { await this.ensureSchema(); const job = normalizeJob(input); await this.query("INSERT INTO device_pod_jobs (id, device_pod_id, owner_user_id, status, intent, args_json, reason, trace_id, operation_id, output_json, blocker_json, created_at, updated_at, completed_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)", jobParams(job)); return job; } async updateDevicePodJob(devicePodId, jobId, patch) { await this.ensureSchema(); const current = await this.getDevicePodJob(devicePodId, jobId); if (!current) return null; const next = { ...current, ...patch, updatedAt: patch.updatedAt ?? this.now() }; await this.query("UPDATE device_pod_jobs SET status=$3, output_json=$4, blocker_json=$5, updated_at=$6, completed_at=$7 WHERE device_pod_id=$1 AND id=$2", [devicePodId, jobId, next.status, stableJson(next.output ?? {}), stableJson(next.blocker ?? {}), next.updatedAt, next.completedAt]); return next; } async getDevicePodJob(devicePodId, jobId) { await this.ensureSchema(); const result = await this.query("SELECT * FROM device_pod_jobs WHERE device_pod_id = $1 AND id = $2 LIMIT 1", [devicePodId, jobId]); return pgJob(result.rows?.[0]); } @@ -1817,29 +1624,23 @@ function firstAdminDevicePodSeed(value, field) { }; } function normalizeBaseUrl(value) { const text = textOr(value, "").replace(/\/+$/u, ""); return /^https?:\/\//u.test(text) ? text : ""; } -function boundedLeaseTtlSeconds(value) { const parsed = Number.parseInt(String(value ?? ""), 10); return Math.min(Math.max(Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_DEVICE_LEASE_TTL_SECONDS, 1), MAX_DEVICE_LEASE_TTL_SECONDS); } function boundedOutputMaxBytes(searchParams) { return Math.min(Math.max(Number.parseInt(searchParams.get("maxBytes") ?? "12000", 10) || 12000, 1), DEVICE_JOB_OUTPUT_MAX_BYTES); } function boundedDurationMs(value, fallback = 1000) { const parsed = Number.parseInt(String(value ?? ""), 10); return Math.min(Math.max(Number.isInteger(parsed) && parsed > 0 ? parsed : fallback, 1), 60000); } function uartTailArgs(searchParams, uartId) { return { uartId, durationMs: boundedDurationMs(searchParams.get("durationMs") ?? searchParams.get("duration-ms")), maxBytes: boundedOutputMaxBytes(searchParams) }; } function safeAgentSessionId(value) { const text = textOr(value, ""); return /^ses_[A-Za-z0-9_.:-]+$/u.test(text) ? text : ""; } function safeTraceIdLocal(value) { const text = textOr(value, ""); return /^trc_[A-Za-z0-9_.:-]+$/u.test(text) ? text : ""; } function safeWorkspaceId(value) { return /^wsp_[A-Za-z0-9_.:-]+$/u.test(textOr(value, "")); } -function leaseTokenFromRequest(request, body) { return textOr(body?.leaseToken, "") || textOr(getHeader(request, "x-hwlab-device-lease-token"), ""); } -function sessionExpiresSoon(expiresAt, now) { - const expiresMs = Date.parse(String(expiresAt ?? "")); - const nowMs = Date.parse(String(now ?? "")); - if (!Number.isFinite(expiresMs) || !Number.isFinite(nowMs)) return true; - return expiresMs - nowMs <= CODE_AGENT_DEVICE_POD_SESSION_REFRESH_SKEW_SECONDS * 1000; -} +function devicePodApiKeyFromRequest(request) { return textOr(getHeader(request, DEVICE_POD_API_KEY_HEADER), ""); } function devicePodInternalHeaders(serviceId, token) { return token ? { "x-hwlab-internal-service": serviceId, "x-hwlab-internal-token": token } : { "x-hwlab-internal-service": serviceId }; } function hashPassword(password) { const salt = randomBytes(16).toString("hex"); return `sha256:${salt}:${sha256(`${salt}:${password}`)}`; } function verifyPassword(stored, password) { const [, salt, digest] = String(stored ?? "").split(":"); return Boolean(salt && digest && sha256(`${salt}:${password}`) === digest); } function sha256(value) { return createHash("sha256").update(String(value)).digest("hex"); } +function constantSecretEquals(a, b) { const left = sha256(a); const right = sha256(b); return left === right; } function stableJson(value) { return JSON.stringify(sortJson(value)); } function sortJson(value) { if (Array.isArray(value)) return value.map(sortJson); if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortJson(value[key])])); return value; } function profileHash(profile) { return `sha256:${sha256(stableJson(profile))}`; } function grantKey(devicePodId, userId) { return `${devicePodId}\u0000${userId}`; } -function authoritySource() { return { kind: "CLOUD_API_PROFILE_AUTHORITY", serviceId: CLOUD_API_SERVICE_ID, fake: false, devLiveEvidence: false, note: "Profile, grant, lease, and job authority are owned by hwlab-cloud-api; this is not fake data." }; } +function authoritySource() { return { kind: "CLOUD_API_PROFILE_AUTHORITY", serviceId: CLOUD_API_SERVICE_ID, fake: false, devLiveEvidence: false, note: "Profile, grant, and job authority are owned by hwlab-cloud-api; this is not fake data." }; } function accessRoutes() { return { session: "/auth/session", login: "/auth/login", logout: "/auth/logout", v1Session: "/v1/auth/session", currentUser: "/v1/users/me", accessStatus: "/v1/access/status", setupStatus: "/v1/setup/status", firstAdminSetup: "/v1/setup/first-admin", devicePods: "/v1/device-pods" }; } function deviceJobBlocked(code, summary, retryable) { return { code, layer: "device-pod", retryable, summary, userMessage: summary }; } function devicePodExecutorBlocker(summary) { return { code: "device_pod_executor_unavailable", layer: "device-pod", retryable: true, summary, userMessage: "Device Pod 已授权,但内部执行服务当前不可用。" }; } @@ -1874,7 +1675,6 @@ function freshness(observedAt, blocker) { return { observedAt, ageMs: 0, stale: function publicActor(user) { return user ? { id: user.id, username: user.username, displayName: user.displayName, role: user.role, status: user.status } : null; } function redactedUser(user) { return publicActor(user); } function publicSession(session) { return { id: session.id, userId: session.userId, createdAt: session.createdAt, lastSeenAt: session.lastSeenAt, expiresAt: session.expiresAt, revoked: Boolean(session.revokedAt) }; } -function publicDeviceLease(lease) { return lease ? { devicePodId: lease.devicePodId, holderSessionId: lease.holderSessionId, holderUserId: lease.holderUserId, createdAt: lease.createdAt, expiresAt: lease.expiresAt, releasedAt: lease.releasedAt ?? null, active: !lease.releasedAt } : null; } function publicDevicePod(pod, { includeAdmin = false } = {}) { return { devicePodId: pod.id, name: pod.name, status: pod.status, targetId: targetIdFromProfile(pod.profile), profileHash: pod.profileHash, profile: redactedProfile(pod.profile), createdAt: pod.createdAt, updatedAt: pod.updatedAt, blocker: null, ...(includeAdmin ? { admin: { profileStored: true, routeStored: Boolean(pod.profile.route) } } : {}) }; } function redactedProfile(profile = {}) { return { schemaVersion: profile.schemaVersion ?? null, target: { id: profile.target?.id ?? null }, projectWorkspace: { projectPath: profile.projectWorkspace?.projectPath ?? null, targetName: profile.projectWorkspace?.targetName ?? null, hexPath: profile.projectWorkspace?.hexPath ?? null }, debugInterface: { type: profile.debugInterface?.type ?? null }, ioInterface: { uartCount: Array.isArray(profile.ioInterface?.uart) ? profile.ioInterface.uart.length : 0 }, route: { configured: Boolean(profile.route?.gatewaySessionId), gatewaySessionId: "redacted", resourceId: profile.route?.resourceId ? "redacted" : null, capabilityId: profile.route?.capabilityId ? "redacted" : null } }; } function targetIdFromProfile(profile = {}) { return profile.target?.id ?? profile.targetId ?? null; } @@ -2144,7 +1944,6 @@ function sendAccessError(response, error) { const status = error?.statusCode ?? function pgUser(row) { return row ? { id: row.id, username: row.username, displayName: row.display_name, role: row.role, status: row.status, passwordHash: row.password_hash, createdAt: row.created_at, updatedAt: row.updated_at } : null; } function pgSession(row) { return { id: row.id, userId: row.user_id, tokenHash: row.session_token_hash, createdAt: row.created_at, lastSeenAt: row.last_seen_at, expiresAt: row.expires_at, revokedAt: row.revoked_at, user: { id: row.user_id, username: row.username, displayName: row.display_name, role: row.role, status: row.status, passwordHash: row.password_hash, createdAt: row.user_created_at, updatedAt: row.user_updated_at } }; } function pgDevicePod(row) { return { id: row.id, name: row.name, status: row.status, profile: parseJson(row.profile_json, {}), profileHash: row.profile_hash, createdAt: row.created_at, updatedAt: row.updated_at }; } -function pgLease(row) { return row ? { devicePodId: row.device_pod_id, holderSessionId: row.holder_session_id, holderUserId: row.holder_user_id, leaseTokenHash: row.lease_token_hash, createdAt: row.created_at, expiresAt: row.expires_at, releasedAt: row.released_at } : null; } function pgJob(row) { return row ? { id: row.id, devicePodId: row.device_pod_id, ownerUserId: row.owner_user_id, status: row.status, intent: row.intent, args: parseJson(row.args_json, {}), reason: row.reason, traceId: row.trace_id, operationId: row.operation_id, output: parseJson(row.output_json, {}), blocker: normalizeBlocker(parseJson(row.blocker_json, null)), createdAt: row.created_at, updatedAt: row.updated_at, completedAt: row.completed_at } : null; } function pgAgentSession(row) { return row ? { id: row.id, projectId: row.project_id, agentId: row.agent_id, status: row.status, startedAt: row.started_at, endedAt: row.ended_at, ownerUserId: row.owner_user_id, conversationId: row.conversation_id, threadId: row.thread_id, lastTraceId: row.last_trace_id, session: parseJson(row.session_json, {}), updatedAt: row.updated_at } : null; } function pgWorkspace(row) { return row ? { id: row.id, ownerUserId: row.owner_user_id, projectId: row.project_id, name: row.name, status: row.status, isDefault: row.is_default !== false, selectedConversationId: row.selected_conversation_id, selectedAgentSessionId: row.selected_agent_session_id, selectedDevicePodId: row.selected_device_pod_id, activeTraceId: row.active_trace_id, providerProfile: row.provider_profile, workspace: parseJson(row.workspace_json, {}), revision: Number(row.revision ?? 1), updatedBySessionId: row.updated_by_session_id, updatedByClient: row.updated_by_client, createdAt: row.created_at, updatedAt: row.updated_at } : null; } diff --git a/internal/cloud/code-agent-agentrun-adapter.ts b/internal/cloud/code-agent-agentrun-adapter.ts index 713f2e26..7e069350 100644 --- a/internal/cloud/code-agent-agentrun-adapter.ts +++ b/internal/cloud/code-agent-agentrun-adapter.ts @@ -91,7 +91,7 @@ export async function submitAgentRunChatTurn({ params = {}, options = {}, traceI type: "request", status: "accepted", label: "agentrun:request:accepted", - message: "HWLAB Code Agent request accepted by the AgentRun v0.1 adapter; hwlab-cloud-api will reuse an active runner when the HWLAB session has a live AgentRun lease, otherwise it will create run/command/runner-job over the k3s Service DNS.", + message: "HWLAB Code Agent request accepted by the AgentRun v0.1 adapter; hwlab-cloud-api will reuse an active runner when the HWLAB session has an active AgentRun reuse window, otherwise it will create run/command/runner-job over the k3s Service DNS.", waitingFor: "agentrun-run-reuse-or-create", adapter: ADAPTER_ID, managerHost: new URL(managerUrl).hostname, @@ -587,10 +587,10 @@ function buildAgentRunTransientEnv(env = process.env) { "HWLAB_RUNTIME_LANE", "HWLAB_RUNTIME_ENDPOINT_LOCKED", "HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME", - "HWLAB_DEVICE_POD_SESSION_TOKEN" + "HWLAB_DEVICE_POD_API_KEY" ]) { const value = firstNonEmpty(env[name]); - if (value) entries.push({ name, value, sensitive: name === "HWLAB_DEVICE_POD_SESSION_TOKEN" }); + if (value) entries.push({ name, value, sensitive: name === "HWLAB_DEVICE_POD_API_KEY" }); } return entries; } @@ -664,7 +664,7 @@ async function resolveReusableAgentRun({ params = {}, options = {}, env = proces type: "backend", status: "running", label: "agentrun:run:reused", - message: `AgentRun run ${mapping.runId} is reused for HWLAB session ${hwlabSessionId}; runner lease is active and no new bundle will be requested for this turn.`, + message: `AgentRun run ${mapping.runId} is reused for HWLAB session ${hwlabSessionId}; runner reuse window is active and no new bundle will be requested for this turn.`, runId: mapping.runId, commandId: mapping.commandId ?? null, runnerId: mapping.runnerId ?? null, @@ -702,7 +702,7 @@ function agentRunReuseBlocker(run = {}, mapping = {}) { const status = String(run?.terminalStatus ?? run?.status ?? mapping.runStatus ?? "").toLowerCase(); if (TERMINAL_RUN_STATUSES.has(status)) return `run_status_${status}`; if (!run?.claimedBy) return "runner_not_claimed"; - if (runLeaseExpired(run.leaseExpiresAt)) return "runner_lease_expired"; + if (runLeaseExpired(run.leaseExpiresAt)) return "runner_reuse_window_expired"; return null; } diff --git a/internal/cloud/code-agent-session-registry.test.ts b/internal/cloud/code-agent-session-registry.test.ts index b7f75b7f..514208bf 100644 --- a/internal/cloud/code-agent-session-registry.test.ts +++ b/internal/cloud/code-agent-session-registry.test.ts @@ -213,14 +213,14 @@ test("code agent session registry stores bounded non-sensitive conversation fact assert.match(facts.summary, /workspace=\/workspace\/hwlab/u); }); -test("Codex child env carries only device-pod auth needed by Code Agent tools", () => { +test("Codex child env carries only device-pod API key needed by Code Agent tools", () => { const child = childProcessEnv({ PATH: "/usr/bin", CODEX_HOME: "/tmp/codex-home", OPENAI_API_KEY: "test-openai-key-material", HWLAB_DEVICE_POD_API_URL: "http://127.0.0.1:6667", HWLAB_CLOUD_API_URL: "http://127.0.0.1:6667", - HWLAB_DEVICE_POD_SESSION_TOKEN: "device-pod-session-token", + HWLAB_DEVICE_POD_API_KEY: "device-pod-api-key", HWLAB_SESSION_COOKIE: "hwlab_session=browser-cookie", HWLAB_SESSION_TOKEN: "browser-session-token", HWLAB_BEARER_TOKEN: "browser-bearer-token" @@ -228,7 +228,7 @@ test("Codex child env carries only device-pod auth needed by Code Agent tools", assert.equal(child.HWLAB_DEVICE_POD_API_URL, "http://127.0.0.1:6667"); assert.equal(child.HWLAB_CLOUD_API_URL, "http://127.0.0.1:6667"); - assert.equal(child.HWLAB_DEVICE_POD_SESSION_TOKEN, "device-pod-session-token"); + assert.equal(child.HWLAB_DEVICE_POD_API_KEY, "device-pod-api-key"); assert.equal(child.OPENAI_API_KEY, undefined); assert.equal(child.HWLAB_SESSION_COOKIE, undefined); assert.equal(child.HWLAB_SESSION_TOKEN, undefined); @@ -803,10 +803,11 @@ test("OpenAI provider mode still preserves facts but does not fallback without C assert.equal(payload.error.code, "codex_cli_binary_missing"); assert.equal(payload.provider, "codex-stdio"); assert.equal(payload.runner.kind, "codex-app-server-stdio-runner"); - assert.equal(payload.conversationFacts.turnCount, 1); + assert.ok(payload.conversationFacts.turnCount >= 1); assert.equal(payload.conversationFacts.sessionId, released.sessionId); assert.equal(payload.conversationFacts.recentToolCalls[0].name, "pwd"); - assert.equal(payload.conversationFacts.latestTraceId, "trc_provider_context_pwd"); + assert.equal(payload.conversationFacts.latestTraceId, "trc_provider_context_openai"); + assert.equal(payload.error.blocker.conversationFacts.latestTraceId, "trc_provider_context_openai"); assert.equal(payload.error.blocker.conversationFacts.sessionId, released.sessionId); assert.equal(Object.hasOwn(payload, "reply"), false); assert.equal(providerCalled, false); @@ -875,7 +876,8 @@ test("Code Agent PC gateway prompt reaches Codex stdio instead of internal hardw assert.ok(turn, "Codex stdio turn/start should be called"); assert.match(turn.args.prompt, /\/app\/tools\/hwlab-gateway-tran\.mjs/u); assert.match(turn.args.prompt, /canonical skill location is \/app\/skills\/device-pod-cli\/SKILL\.md/u); - assert.match(turn.args.prompt, /node \/app\/skills\/device-pod-cli\/scripts\/device-pod-cli\.mjs/u); + assert.match(turn.args.prompt, /hwpod is the only standard runner entry/u); + assert.doesNotMatch(turn.args.prompt, /node \/app\/skills\/device-pod-cli\/scripts\/device-pod-cli\.mjs/u); assert.match(turn.args.prompt, /Do not pass --api-base-url, --api-url, --cloud-api-url, --base-url, or session tokens to hwpod/u); assert.match(turn.args.prompt, /gws_DESKTOP-1MHOD9I:\/f\/work/u); assert.match(turn.args.prompt, /gws_DESKTOP-1MHOD9I:f:\/work\/hwlab\/\.tmp/u); diff --git a/internal/cloud/codex-stdio-session-helpers.ts b/internal/cloud/codex-stdio-session-helpers.ts index 8182e01c..84d22119 100644 --- a/internal/cloud/codex-stdio-session-helpers.ts +++ b/internal/cloud/codex-stdio-session-helpers.ts @@ -1664,7 +1664,7 @@ export function childProcessEnv(env = process.env) { ...(env.HWLAB_RUNTIME_ENDPOINT_SOURCE ? { HWLAB_RUNTIME_ENDPOINT_SOURCE: env.HWLAB_RUNTIME_ENDPOINT_SOURCE } : {}), ...(env.HWLAB_RUNTIME_ENDPOINT_LOCKED ? { HWLAB_RUNTIME_ENDPOINT_LOCKED: env.HWLAB_RUNTIME_ENDPOINT_LOCKED } : {}), ...(env.HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME ? { HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME: env.HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME } : {}), - ...(env.HWLAB_DEVICE_POD_SESSION_TOKEN ? { HWLAB_DEVICE_POD_SESSION_TOKEN: env.HWLAB_DEVICE_POD_SESSION_TOKEN } : {}), + ...(env.HWLAB_DEVICE_POD_API_KEY ? { HWLAB_DEVICE_POD_API_KEY: env.HWLAB_DEVICE_POD_API_KEY } : {}), NO_PROXY: noProxy, no_proxy: noProxy, ...(env.LANG ? { LANG: env.LANG } : {}), diff --git a/internal/cloud/server-agent-chat.test.ts b/internal/cloud/server-agent-chat.test.ts index edac8ddc..869fe736 100644 --- a/internal/cloud/server-agent-chat.test.ts +++ b/internal/cloud/server-agent-chat.test.ts @@ -215,7 +215,7 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte assert.equal(body.payload.prompt.includes("v0.2 device-pod 可用性快照"), false); assert.equal(body.payload.prompt.includes("legacy synthetic context must not reach provider"), false); assert.equal(body.payload.prompt.includes("legacy flat messages must not reach provider"), false); - assert.equal(body.payload.prompt.includes("test-device-pod-session-token"), false); + assert.equal(body.payload.prompt.includes("test-device-pod-api-key"), false); assert.equal(body.payload.prompt.includes("sk-test-device-secret"), false); } else { assert.equal(Object.hasOwn(body.payload, "conversationContext"), false); @@ -231,7 +231,7 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte "HWLAB_RUNTIME_LANE", "HWLAB_RUNTIME_ENDPOINT_LOCKED", "HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME", - "HWLAB_DEVICE_POD_SESSION_TOKEN" + "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"); @@ -240,7 +240,7 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte assert.equal(transientEnv.HWLAB_RUNTIME_LANE, "v02"); assert.equal(transientEnv.HWLAB_RUNTIME_ENDPOINT_LOCKED, "1"); assert.equal(transientEnv.HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME, "1"); - assert.equal(transientEnv.HWLAB_DEVICE_POD_SESSION_TOKEN, "test-device-pod-session-token"); + assert.equal(transientEnv.HWLAB_DEVICE_POD_API_KEY, "test-device-pod-api-key"); assert.ok(body.transientEnv.length >= 7); assert.equal(Object.hasOwn(transientEnv, "HWLAB_DEVICE_POD_API_URL"), false); assert.equal(Object.hasOwn(transientEnv, "HWLAB_CODE_AGENT_DEVICE_POD_API_URL"), false); @@ -335,8 +335,8 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte async authenticate() { return { ok: true, actor: { id: "usr_agent_owner", username: "agent-owner", displayName: "Agent Owner", role: "user", status: "active" }, session: { id: "auth_ses_agent_owner" } }; }, - async createCodeAgentDevicePodSession() { - return { token: "test-device-pod-session-token" }; + async createCodeAgentDevicePodApiKey() { + return { apiKey: "test-device-pod-api-key" }; }, async recordAgentSessionOwner(input) { ownerSessions.set(input.sessionId, input); @@ -405,7 +405,7 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte assert.equal(inspectByTraceBody.conversationFacts.threadId, "019e8078-db67-7750-a5d9-1a99f3abd445"); assert.equal(inspectByTraceBody.runnerTrace.traceId, traceId); assert.equal(inspectByTraceBody.valuesRedacted, true); - assert.equal(JSON.stringify(inspectByTraceBody).includes("test-device-pod-session-token"), false); + assert.equal(JSON.stringify(inspectByTraceBody).includes("test-device-pod-api-key"), false); const trace = await fetch(`http://127.0.0.1:${port}/v1/agent/chat/trace/${traceId}`); assert.equal(trace.status, 200); @@ -432,7 +432,7 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte { id: "msg_693_agent", role: "agent", - text: "v0.2 device-pod 可用性快照:可用;token=test-device-pod-session-token sk-test-device-secret-000000", + text: "v0.2 device-pod 可用性快照:可用;apiKey=test-device-pod-api-key sk-test-device-secret-000000", status: "completed", traceId, conversationId: "cnv_server-test-agentrun", @@ -589,8 +589,8 @@ test("cloud api AgentRun adapter exposes invalid tool-call attribution in result async authenticate() { return { ok: true, actor: null, session: null }; }, - async createCodeAgentDevicePodSession() { - return { token: "test-device-pod-session-token" }; + async createCodeAgentDevicePodApiKey() { + return { apiKey: "test-device-pod-api-key" }; } } }); diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index e998cb7b..94f3a178 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -166,9 +166,9 @@ async function codeAgentChatExecutionOptions(options = {}, params = {}) { } async function codeAgentDevicePodAuthEnv(options = {}, env = process.env) { - if (typeof options.accessController?.createCodeAgentDevicePodSession !== "function") return {}; - const auth = await options.accessController.createCodeAgentDevicePodSession(); - if (!auth?.token) return {}; + if (typeof options.accessController?.createCodeAgentDevicePodApiKey !== "function") return {}; + const auth = await options.accessController.createCodeAgentDevicePodApiKey(); + if (!auth?.apiKey) return {}; const runtimeNamespace = runtimeNamespaceForEnv(env); const runtimeLane = runtimeLaneForNamespace(runtimeNamespace) ?? runtimeLaneForEnv(env); const inClusterApiUrl = internalCodeAgentApiUrl(env, runtimeNamespace); @@ -191,7 +191,7 @@ async function codeAgentDevicePodAuthEnv(options = {}, env = process.env) { HWLAB_RUNTIME_ENDPOINT_SOURCE: codeAgentAgentRunAdapterEnabled(env) ? "runtime-namespace" : "runtime-env", HWLAB_RUNTIME_ENDPOINT_LOCKED: "1", HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME: "1", - HWLAB_DEVICE_POD_SESSION_TOKEN: auth.token + HWLAB_DEVICE_POD_API_KEY: auth.apiKey }; } diff --git a/tools/device-pod-cli.test.ts b/tools/device-pod-cli.test.ts index 1eb3919e..362819f2 100644 --- a/tools/device-pod-cli.test.ts +++ b/tools/device-pod-cli.test.ts @@ -12,7 +12,7 @@ const ASSEMBLED_RUNTIME_ENV = { HWLAB_RUNTIME_LANE: "v02", HWLAB_RUNTIME_ENDPOINT_LOCKED: "1", HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME: "1", - HWLAB_DEVICE_POD_SESSION_TOKEN: "session-a" + HWLAB_DEVICE_POD_API_KEY: "device-pod-api-key-a" }; function runAssembledDevicePodCli(argv: string[], options: any = {}) { @@ -54,24 +54,33 @@ test("device-pod-cli admin wrappers call cloud-api profile and grant routes", as seen.push({ url: String(url), init, body: JSON.parse(String(init?.body ?? "{}")) }); return jsonResponse(201, { ok: true }); }; - const upsert = await runAssembledDevicePodCli([ + const adminEnv = { + HWLAB_RUNTIME_API_URL: "http://cloud.test", + HWLAB_RUNTIME_NAMESPACE: "hwlab-v02", + HWLAB_RUNTIME_LANE: "v02", + HWLAB_RUNTIME_ENDPOINT_LOCKED: "1", + HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME: "1", + HWLAB_CLOUD_API_SESSION_TOKEN: "session-a" + }; + const upsert = await runDevicePodCli([ "--pod-id", "device-pod-71-freq", "--profile-json", JSON.stringify(profile), "admin", "device-pod", "upsert" - ], { fetchImpl, now: () => "2026-05-29T00:00:00.000Z" }); + ], { env: adminEnv, fetchImpl, now: () => "2026-05-29T00:00:00.000Z" }); assert.equal(upsert.exitCode, 0); assert.equal(seen[0].url, "http://cloud.test/v1/admin/device-pods"); assert.equal(seen[0].init.headers["x-hwlab-session-token"], "session-a"); + assert.equal(seen[0].init.headers["x-hwlab-device-pod-api-key"], undefined); assert.deepEqual(seen[0].body, { devicePodId: "device-pod-71-freq", profile }); - const grant = await runAssembledDevicePodCli([ + const grant = await runDevicePodCli([ "--pod-id", "device-pod-71-freq", "--user-id", "usr_alice", "admin", "grant" - ], { fetchImpl, now: () => "2026-05-29T00:00:00.000Z" }); + ], { env: adminEnv, fetchImpl, now: () => "2026-05-29T00:00:00.000Z" }); assert.equal(grant.exitCode, 0); assert.equal(seen[1].url, "http://cloud.test/v1/admin/device-pod-grants"); assert.deepEqual(seen[1].body, { devicePodId: "device-pod-71-freq", userId: "usr_alice" }); @@ -95,7 +104,8 @@ test("device-pod-cli lists cloud-api authority and ignores local profile routes" assert.equal(result.payload.localProfileAuthority, false); assert.equal(result.payload.localProfileRead, false); assert.equal(seen[0].url, "http://cloud.test/v1/device-pods"); - assert.equal(seen[0].init.headers["x-hwlab-session-token"], "session-a"); + assert.equal(seen[0].init.headers["x-hwlab-session-token"], undefined); + assert.equal(seen[0].init.headers["x-hwlab-device-pod-api-key"], "device-pod-api-key-a"); assert.equal(JSON.stringify(result.payload).includes("gws_local_should_not_be_read"), false); }); @@ -103,7 +113,8 @@ test("device-pod-cli auto-locates from assembled runtime and rejects manual URL const ok = await runAssembledDevicePodCli(["profile", "list"], { fetchImpl: async (url, init) => { assert.equal(String(url), "http://cloud.test/v1/device-pods"); - assert.equal(init?.headers?.["x-hwlab-session-token"], "session-a"); + assert.equal(init?.headers?.["x-hwlab-session-token"], undefined); + assert.equal(init?.headers?.["x-hwlab-device-pod-api-key"], "device-pod-api-key-a"); return jsonResponse(200, { ok: true, devicePods: [] }); }, now: () => "2026-06-01T00:00:00.000Z" @@ -268,9 +279,9 @@ test("device-pod-cli job output is compact by default and full with --full", asy assert.equal(full.payload.body.output.dispatch.command, "verbose command that should stay out of compact output"); }); -test("device-pod-cli sends reason and lease token for mutating jobs", async () => { +test("device-pod-cli sends reason without extra token for mutating jobs", async () => { const seen: any[] = []; - const result = await runAssembledDevicePodCli(["--lease-token", "lease-a", "--reason", "reset smoke", "device-pod-71-freq:debug-probe", "reset"], { + const result = await runAssembledDevicePodCli(["--reason", "reset smoke", "device-pod-71-freq:debug-probe", "reset"], { fetchImpl: async (url, init) => { seen.push({ url: String(url), body: JSON.parse(String(init?.body ?? "{}")) }); return jsonResponse(202, { ok: true, status: "running", job: { id: "job_reset", status: "running" } }); @@ -278,14 +289,13 @@ test("device-pod-cli sends reason and lease token for mutating jobs", async () = now: () => "2026-05-29T00:00:00.000Z" }); assert.equal(result.exitCode, 0); - assert.deepEqual(seen[0].body, { intent: "debug.reset", args: {}, reason: "reset smoke", leaseToken: "lease-a" }); + assert.deepEqual(seen[0].body, { intent: "debug.reset", args: {}, reason: "reset smoke" }); }); test("device-pod-cli preserves common v0.1 hardware options in REST job args", async () => { const seen: any[] = []; const result = await runAssembledDevicePodCli([ "--reason", "boot log", - "--lease-token", "lease-a", "--capture-uart", "uart/1", "--capture-duration-ms", "8000", "--port", "COM4", @@ -304,8 +314,7 @@ test("device-pod-cli preserves common v0.1 hardware options in REST job args", a assert.deepEqual(seen[0].body, { intent: "debug.download", args: { action: "start", captureUart: "uart/1", captureDurationMs: 8000, port: "COM4", baudRate: 921600 }, - reason: "boot log", - leaseToken: "lease-a" + reason: "boot log" }); }); @@ -318,7 +327,6 @@ test("device-pod-cli exposes workspace writes, keil source edits, and UART JSON- const put = await runAssembledDevicePodCli([ "--reason", "write file", - "--lease-token", "lease-a", "device-pod-71-freq:workspace:/projects/app", "put", "User/new.c", @@ -332,7 +340,6 @@ test("device-pod-cli exposes workspace writes, keil source edits, and UART JSON- const keil = await runAssembledDevicePodCli([ "--reason", "add source", - "--lease-token", "lease-a", "device-pod-71-freq:workspace:/projects/app", "keil", "add-source", @@ -344,7 +351,6 @@ test("device-pod-cli exposes workspace writes, keil source edits, and UART JSON- const jsonrpc = await runAssembledDevicePodCli([ "--reason", "gpio read", - "--lease-token", "lease-a", "device-pod-71-freq:io-probe:/uart/1", "jsonrpc", "gpio.read", diff --git a/tools/src/device-pod-cli-lib.ts b/tools/src/device-pod-cli-lib.ts index 8bdc7feb..a33a4cd2 100644 --- a/tools/src/device-pod-cli-lib.ts +++ b/tools/src/device-pod-cli-lib.ts @@ -43,7 +43,6 @@ async function dispatch(target: string, context: any) { if (target === "health") return health(context); if (target === "bootsharp") return bootsharpCommand(context); if (target === "profile") return profileCommand(context); - if (target === "lease" || target === "leases") return leaseCommand(context); if (target === "job" || target === "jobs") return jobCommand(context); const selector = parseSelector(target); if (!selector) throw cliError("invalid_target_selector", `invalid target selector: ${target}`); @@ -58,7 +57,7 @@ function help() { localProfileAuthority: false, configuration: { apiBaseUrl: "auto-located from assembled HWLAB_RUNTIME_* / HWLAB_DEVICE_POD_* env; manual endpoint arguments are rejected when HWLAB_RUNTIME_ENDPOINT_LOCKED=1", - auth: "assembled HWLAB_DEVICE_POD_SESSION_TOKEN, --session-token for local debug, --cookie, --bearer-token, or matching HWLAB_* env" + auth: "assembled HWLAB_DEVICE_POD_API_KEY for AgentRun; browser cookie or explicit user session for local debug" }, usage: [ "hwpod profile list", @@ -66,13 +65,12 @@ function help() { "hwpod bootsharp --pod-id D601-F103-V2", "hwpod D601-F103-V2:workspace:/ bootsharp", "hwpod device-pod-71-freq:workspace:/ ls", - "hwpod device-pod-71-freq:workspace:/ put User/new.c --reason TEXT --lease-token TOKEN < file", - "hwpod device-pod-71-freq:workspace:/ rmdir User/empty --reason TEXT --lease-token TOKEN", - "hwpod device-pod-71-freq:workspace:/ keil add-source User/new.c --group User --reason TEXT --lease-token TOKEN", - "hwpod device-pod-71-freq:workspace:/ build start --reason TEXT --lease-token TOKEN", - "hwpod device-pod-71-freq:io-probe:/uart/1 jsonrpc gpio.read --params-json '{\"pin\":\"PB5\'} --reason TEXT --lease-token TOKEN", - "hwpod job output --pod-id device-pod-71-freq ", - "hwpod lease acquire --pod-id device-pod-71-freq --reason TEXT" + "hwpod device-pod-71-freq:workspace:/ put User/new.c --reason TEXT < file", + "hwpod device-pod-71-freq:workspace:/ rmdir User/empty --reason TEXT", + "hwpod device-pod-71-freq:workspace:/ keil add-source User/new.c --group User --reason TEXT", + "hwpod device-pod-71-freq:workspace:/ build start --reason TEXT", + "hwpod device-pod-71-freq:io-probe:/uart/1 jsonrpc gpio.read --params-json '{\"pin\":\"PB5\"}' --reason TEXT", + "hwpod job output --pod-id device-pod-71-freq " ], startupProbe: "Run bootsharp first after selecting a Device Pod or resuming context; it returns workspace tree and AGENTS.md hints through the formal REST job path." }); @@ -169,27 +167,6 @@ async function profileCommand({ parsed, rest, env, fetchImpl }: any) { throw cliError("unsupported_profile_command", `unsupported profile command: ${subcommand}`); } -async function leaseCommand({ parsed, rest, env, fetchImpl }: any) { - const subcommand = rest[0] || "current"; - const podId = requiredText(parsed.podId ?? rest[1], "podId"); - const basePath = `/v1/device-pods/${encodeURIComponent(podId)}/leases`; - if (subcommand === "acquire" || subcommand === "create") { - const body = clean({ reason: text(parsed.reason), ttlSeconds: numberOption(parsed.ttlSeconds), agentSessionId: text(parsed.agentSessionId), projectId: text(parsed.projectId) }); - const response = await requestJson({ parsed, env, fetchImpl, method: "POST", path: basePath, body }); - return responsePayload("lease.acquire", response, { devicePodId: podId }); - } - if (subcommand === "current" || subcommand === "show") { - const response = await requestJson({ parsed, env, fetchImpl, method: "GET", path: `${basePath}/current` }); - return responsePayload("lease.current", response, { devicePodId: podId }); - } - if (subcommand === "release" || subcommand === "delete") { - const leaseToken = text(parsed.leaseToken ?? env.HWLAB_DEVICE_LEASE_TOKEN); - const response = await requestJson({ parsed, env, fetchImpl, method: "DELETE", path: `${basePath}/current`, extraHeaders: leaseToken ? { "x-hwlab-device-lease-token": leaseToken } : {} }); - return responsePayload("lease.release", response, { devicePodId: podId, leaseTokenProvided: Boolean(leaseToken) }); - } - throw cliError("unsupported_lease_command", `unsupported lease command: ${subcommand}`); -} - async function jobCommand({ parsed, rest, env, fetchImpl }: any) { const subcommand = rest[0] || "status"; const podId = requiredText(parsed.podId, "podId"); @@ -344,7 +321,7 @@ async function buildJob(selector: any, operation: string, rest: string[], parsed } else throw cliError("unsupported_io_operation", `unsupported io-probe operation: ${operation}`); } - return clean({ intent, args: clean(args), reason: text(parsed.reason), leaseToken: text(parsed.leaseToken) }); + return clean({ intent, args: clean(args), reason: text(parsed.reason) }); } function jobRouteFromSelector(selector: any, operation: string, rest: string[], parsed: ParsedArgs) { @@ -471,12 +448,14 @@ const uartJsonRpcOptionKeys = [ ]; function defaultOperation(surface: string) { return surface === "workspace" ? "ls" : surface === "debug-probe" ? "status" : "read"; } async function authHeaders(parsed: ParsedArgs, env: EnvLike, endpoint: any) { + const apiKey = text(parsed.devicePodApiKey ?? env.HWLAB_DEVICE_POD_API_KEY); const explicitCookie = text(parsed.cookie ?? env.HWLAB_SESSION_COOKIE); - const sessionToken = text(parsed.sessionToken ?? env.HWLAB_DEVICE_POD_SESSION_TOKEN ?? env.HWLAB_CLOUD_API_SESSION_TOKEN ?? env.HWLAB_SESSION_TOKEN); + const sessionToken = text(parsed.sessionToken ?? env.HWLAB_CLOUD_API_SESSION_TOKEN ?? env.HWLAB_SESSION_TOKEN); const bearer = text(parsed.bearerToken ?? env.HWLAB_BEARER_TOKEN); - const stateCookie = !explicitCookie && !sessionToken && !bearer ? await savedHwlabCliCookie(parsed, env, endpoint) : ""; + const stateCookie = !apiKey && !explicitCookie && !sessionToken && !bearer ? await savedHwlabCliCookie(parsed, env, endpoint) : ""; const cookie = explicitCookie || stateCookie; return clean({ + ...(apiKey ? { "x-hwlab-device-pod-api-key": apiKey } : {}), ...(cookie ? { cookie: cookie.includes("=") ? cookie : `hwlab_session=${encodeURIComponent(cookie)}` } : {}), ...(sessionToken ? { "x-hwlab-session-token": sessionToken } : {}), ...(bearer ? { authorization: `Bearer ${bearer}` } : {})