fix: replace device pod lease with api key

This commit is contained in:
Codex
2026-06-02 18:11:32 +08:00
parent 332b17193a
commit bacfa24289
19 changed files with 170 additions and 535 deletions
+1
View File
@@ -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"
}
},
@@ -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
+1 -1
View File
@@ -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 <traceId> --render web` 输出 Cloud Web 同一 trace row 摘要;CLI 也乱说明 row 转换逻辑问题,CLI 正常而浏览器乱再查 DOM、CSS、滚动和增量 patch。
+15 -25
View File
@@ -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 authorityuser 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 authorityuser 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/reasonCLI 只转发 `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 <N>``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` executorexecutor 已实现内部 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` executorexecutor 已实现内部 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。 |
+14 -33
View File
@@ -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 和 jobsDevice Pod 强副作用 job 已接入 lease token 校验,真实硬件执行仍依赖 gateway/device-host-cli 在线。 |
| `users``user_sessions`、grant 和 job 表 | 部分实现 | 0001 schema 和 access-control bootstrap 覆盖 users、sessions、device_pods、grants 和 jobsDevice Pod 强副作用 job 已接入 reason 校验,真实硬件执行仍依赖 gateway/device-host-cli 在线。 |
| Code Agent owner 绑定 | 已实现 | 已在 `agent_sessions` 写入 `owner_user_id`、conversation/thread/trace 和脱敏 session evidencetrace/result cache 也按 owner/admin 限制访问。 |
| device pod 授权模型 | 部分实现 | cloud-api 已实现 admin profile/grant、普通用户可见性和 job 持久化;无在线 gateway/device-host-cli 时返回 blocker。 |
| 不用 Kubernetes 表达用户权限 | 已实现/持续约束 | 规格明确禁止普通用户持有 kubeconfig 或直连 Service 权限。 |
+1 -1
View File
@@ -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 <traceId>` 是运行中引导入口,必须调用 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 <traceId> --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 口径属于废弃路径;开发中遇到这些旧门禁、旧测试或旧预检时直接删除,不再维护兼容。
+2 -2
View File
@@ -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 运行资源,不是独立用户入口。
+1 -1
View File
@@ -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 拼接来源。
@@ -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。 |
+1 -1
View File
@@ -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。 |
+37 -160
View File
@@ -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())));
}
+32 -233
View File
@@ -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; }
@@ -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;
}
@@ -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);
@@ -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 } : {}),
+9 -9
View File
@@ -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" };
}
}
});
+4 -4
View File
@@ -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
};
}
+22 -16
View File
@@ -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",
+12 -33
View File
@@ -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 <jobId>",
"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 <jobId>"
],
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}` } : {})