fix: remove v02 authorization residue
This commit is contained in:
@@ -157,7 +157,6 @@
|
||||
"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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
- 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` 只能承接本次 runner job 需要的短期执行上下文,例如 `HWLAB_DEVICE_POD_API_KEY` 和 `UNIDESK_MAIN_SERVER_IP`;`HWLAB_DEVICE_POD_API_KEY` 必须标记 sensitive,不得承载 GitHub token、UniDesk SSH client token、provider key、长期 SSH key 或 registry token。
|
||||
- HWLAB v0.2 device-pod 能力在 runner 内只通过 `hwpod -> HWLAB_RUNTIME_API_URL -> hwlab-cloud-api` 进入。`HWLAB_RUNTIME_API_URL` 指向当前 namespace 的 `hwlab-cloud-api` Service;`HWLAB_RUNTIME_WEB_URL` 仅供需要浏览器同源语义的工具查看 Cloud Web。AgentRun 不把 `HWLAB_DEVICE_POD_API_KEY` 发往 Cloud Web,也不以 Cloud Web 代理替代设备 API。
|
||||
- `runnerJob.transientEnv` 只能承接本次 runner job 需要的短期执行上下文,例如 owner-scoped `HWLAB_API_KEY`、`HWLAB_RUNTIME_API_URL` 和 `UNIDESK_MAIN_SERVER_IP`;敏感项必须标记 sensitive,不得承载 GitHub token、UniDesk SSH client token、provider key、长期 SSH key 或 registry token。
|
||||
- HWLAB v0.2 device-pod 能力在 runner 内只通过 `hwpod -> HWLAB_RUNTIME_API_URL -> hwlab-cloud-api` 进入。`HWLAB_RUNTIME_API_URL` 指向当前 namespace 的 `hwlab-cloud-api` Service;`HWLAB_RUNTIME_WEB_URL` 仅供需要浏览器同源语义的工具查看 Cloud Web。AgentRun 不以 Cloud Web 代理替代设备 API。
|
||||
|
||||
## ResourceBundle
|
||||
|
||||
|
||||
@@ -149,8 +149,6 @@ CREATE TABLE IF NOT EXISTS device_pods (
|
||||
|
||||
历史版本中的 `profile_ref`、`gateway_ref` 或 `device_pod_json` 可以在迁移时折叠进 `profile_json`。第一阶段不新增 `device_pod_profile_revisions`;需要审计版本、回滚或多环境批准时再引入 profile revision 表。
|
||||
|
||||
历史版本中的 `device_pod_grants` 不是当前 v0.2 device pod 授权契约。目标状态的 device pod 可见、操作、profile 修改和 job 提交权限只由 OpenFGA relation 表达;旧表若仍存在于已发布数据库中,只能作为历史迁移输入,不得作为写入口、健康必需表或 allow 判定来源。
|
||||
|
||||
## REST API
|
||||
|
||||
## API 接口说明
|
||||
@@ -255,7 +253,7 @@ manages: many devicePodId
|
||||
- `hwlab-device-pod` 一个实例可以列出并执行多个 `devicePodId` 的状态/job。
|
||||
- cloud-api legacy compatibility entry 只能返回 blocked authority payload,不得合成 fake device pod 数据或作为正式 device-pod DEV-LIVE 证据。
|
||||
- 强副作用 job 必须有 `reason`;正式路径只使用 Web session/cookie 或映射到具体用户的 `HWLAB_API_KEY` 做身份授权。
|
||||
- AgentRun runner 访问 device-pod 必须使用 cloud-api 组装的用户 `HWLAB_API_KEY`,该 key 恢复为发起 Code Agent session 的 owner 用户;不得使用跨用户共享、对所有正式 device-pod 授权的 system key。
|
||||
- AgentRun runner 访问 device-pod 必须使用 cloud-api 组装的用户 `HWLAB_API_KEY`,该 key 恢复为发起 Code Agent session 的 owner 用户;权限继续由 OpenFGA relation 判定。
|
||||
- 撤销 device pod relation 必须同时影响该用户通过 Web session、CLI API key 和 AgentRun `hwpod` 的可见性与使用权;revoke API key 后 CLI 和 runner 内旧 key 都必须失效。
|
||||
|
||||
## CLI 实现口径
|
||||
@@ -264,7 +262,7 @@ manages: many devicePodId
|
||||
|
||||
- `profile list/show` 调用 cloud-api `/v1/device-pods` 和 `/status`,只显示服务端脱敏 profile 摘要和 `profileHash`。
|
||||
- AgentRun runner 中只使用装配好的 `HWLAB_RUNTIME_API_URL` 和映射到当前用户的 `HWLAB_API_KEY`,直接访问 `hwlab-cloud-api`;不得手动传 `--api-base-url`,也不得改走 Cloud Web 同源代理。
|
||||
- `setup first-admin` 和 `admin device-pod upsert` 只作为 cloud-api REST wrapper,用于首次空库 seed 或 admin profile 管理;device pod 授权统一使用 `hwlab-cli client access device-pods grant/revoke` 的 Admin Access API,不走 `hwpod admin grant`。
|
||||
- `setup first-admin` 和 `admin device-pod upsert` 只作为 cloud-api REST wrapper,用于首次空库 seed 或 admin profile 管理;device pod 授权统一使用 `hwlab-cli client access device-pods grant/revoke` 的 Admin Access API。
|
||||
- `devicePodId:workspace|debug-probe|io-probe...` selector 转换为 `POST /v1/device-pods/{devicePodId}/jobs` 或 job status/output/cancel REST,不直接调用 `/v1/rpc/hardware.invoke.shell`。
|
||||
- `bootsharp --pod-id <devicePodId>` 和 `<devicePodId>:workspace:/ bootsharp` 都转换为 `workspace.bootsharp` job,用于返回 workspace tree、AGENTS.md 摘要和当前路径提示;该入口是上下文恢复和 DS 派单的首个探测动作,不读取本地 profile。
|
||||
- workspace 写操作覆盖 `apply-patch`、`put`、`rm`、`rmdir`、`build` 和 `keil add-source/remove-source` 等 Keil 工程维护动作。
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
本规格与 [spec-device-pod.md](spec-device-pod.md) 配套:用户和权限规格定义谁可以看见、创建和使用 device pod;device-pod 规格定义 profile authority、REST/job 和硬件执行边界。
|
||||
|
||||
登录入口、Keycloak OIDC、Web session、CLI API key 和 `AuthPrincipal` 归一见 [spec-v02-auth.md](spec-v02-auth.md)。OpenFGA、Admin Access WebUI 和同路径 CLI 细节见 [spec-v02-openfga-authorization.md](spec-v02-openfga-authorization.md)。本文只定义认证完成后的角色、资源归属、device pod capability、tool capability 和 code agent owner 授权;正式用户鉴权只有 Web session 与 CLI/API key 两类,除非明确标注为 bootstrap/legacy,不再把本地账号密码登录或 device-pod 系统 key 作为目标登录体验。
|
||||
登录入口、Keycloak OIDC、Web session、CLI API key 和 `AuthPrincipal` 归一见 [spec-v02-auth.md](spec-v02-auth.md)。OpenFGA、Admin Access WebUI 和同路径 CLI 细节见 [spec-v02-openfga-authorization.md](spec-v02-openfga-authorization.md)。本文只定义认证完成后的角色、资源归属、device pod capability、tool capability 和 code agent owner 授权;正式用户鉴权只有 Web session 与 CLI/API key 两类。
|
||||
|
||||
实施跟踪见 [pikasTech/HWLAB#531](https://github.com/pikasTech/HWLAB/issues/531),原 `docs/plan/v02-multi-user-migration.md` 迁移计划全文已迁入该 issue 评论。
|
||||
|
||||
@@ -23,7 +23,7 @@ Postgres 是用户、session、业务对象和迁移 ledger 的持久化边界
|
||||
- 只保留两类角色:`admin` 和 `user`。
|
||||
- `code agent session` 直接归属于创建它的用户;普通用户只能查看、继续和取消自己的 session。
|
||||
- `device pod` 由 `admin` 或被授予 `profile_editor` 的用户管理;普通用户只有在被授权后才能看到、操作或提交对应 device pod job。
|
||||
- device pod 授权按 `viewer`、`operator`、`profile_editor`、`job_submitter` 等 OpenFGA relation 表达;旧 `device_pod_grants` 不再代表目标状态的完整使用权,也不得作为 allow 判定或管理入口。
|
||||
- device pod 授权按 `viewer`、`operator`、`profile_editor`、`job_submitter` 等 OpenFGA relation 表达。
|
||||
- 工具能力必须独立授权,例如 `hwpod`、`unidesk_ssh`、`trans_cmd` 和 GitHub 写工具;拥有 Code Agent session 不等于拥有这些工具。
|
||||
- MVP 不新增产品级 `audit_events` 用户审计表,也不把用户权限依赖到 audit。现有硬件 trace/evidence/audit 字段属于硬件闭环证据,不是多用户权限模型的一部分。
|
||||
- 强副作用 device-pod job 只额外要求业务 `reason`;设备互斥由 executor、gateway 和硬件 host 串行化或返回 blocker,不进入用户权限模型。
|
||||
@@ -125,16 +125,6 @@ CREATE TABLE IF NOT EXISTS device_pods (
|
||||
|
||||
`profile_json` 中的 gateway route、host workspace、probe UID、串口端口和 host CLI 都是服务端权威字段;普通用户响应只能看到脱敏 profile 摘要和 `profile_hash`。
|
||||
|
||||
### 历史 `device_pod_grants`
|
||||
|
||||
历史版本曾用 `device_pod_grants` 表表达普通用户对 device pod 的全权限兼容授权。当前 v0.2 目标状态以 OpenFGA tuple 为准;该表不再由当前 schema 创建,不是 runtime readiness 必需表,不得新增写入口,也不得作为正式 allow source。若已有数据库仍保留该表,只能作为一次性迁移输入读取,迁移完成后权限必须写入 OpenFGA relation。
|
||||
|
||||
- 不新增 `capability`、`scope`、`expires_at` 字段;细粒度 relation 不回写到该表,统一写 OpenFGA。
|
||||
- 在 OpenFGA `enforce` 模式下,撤销授权就是删除对应 tuple;该表若仍有 legacy 行也不能放行请求。
|
||||
- AgentRun runner 不使用跨用户共享的 device-pod 系统 key。runner 内 `hwpod` 只能使用映射到 Code Agent session owner 的 `HWLAB_API_KEY`,并按同一用户的 OpenFGA device pod relation 与 tool capability 授权。
|
||||
- 当前 schema 和 runtime schema ensure 必须以 `DROP TABLE IF EXISTS device_pod_grants` 表达收敛,不得重新出现 `CREATE TABLE IF NOT EXISTS device_pod_grants`。如果 live Postgres 中仍存在该表,应按历史迁移残留处理,迁移或确认无效后删除;不能把它作为健康检查、授权回退或 Admin Access 展示来源。
|
||||
- 代码中允许保留的 `device_pod_grants` 引用只限于历史说明、`DROP TABLE` 和“不得创建旧表”的测试断言。Web、CLI、Cloud API 和 AgentRun 路径不得新增旧表读写 helper、旧 grant fallback 或旧 route 兼容入口。
|
||||
|
||||
## 权限矩阵
|
||||
|
||||
| 操作 | `admin` | `user` |
|
||||
@@ -176,7 +166,7 @@ cli
|
||||
-> AuthPrincipal(authMethod='api-key')
|
||||
```
|
||||
|
||||
登录、Keycloak issuer、CLI API key 和 24 小时 Web session 规则见 [spec-v02-auth.md](spec-v02-auth.md)。资源授权模块只消费已经恢复出的 actor/AuthPrincipal。`/auth/session` 使用 cookie 查 `user_sessions`,再查 `users` 得到 `actor`;API key 认证直接从 `api_keys -> users` 得到同一 actor。`/auth/logout` 标记 `user_sessions.revoked_at`。Keycloak access token、refresh token、realm role 和 device-pod 内部系统 key 都不能绕过这里的 actor 恢复与资源授权。
|
||||
登录、Keycloak issuer、CLI API key 和 24 小时 Web session 规则见 [spec-v02-auth.md](spec-v02-auth.md)。资源授权模块只消费已经恢复出的 actor/AuthPrincipal。`/auth/session` 使用 cookie 查 `user_sessions`,再查 `users` 得到 `actor`;API key 认证直接从 `api_keys -> users` 得到同一 actor。`/auth/logout` 标记 `user_sessions.revoked_at`。Keycloak access token、refresh token、realm role 和内部 service token 都不能绕过这里的 actor 恢复与资源授权。
|
||||
|
||||
### admin 创建用户
|
||||
|
||||
@@ -203,9 +193,9 @@ browser admin Access UI
|
||||
-> return effective permission matrix
|
||||
```
|
||||
|
||||
撤销授权走 `DELETE /v1/admin/access/users/{userId}/device-pods/{devicePodId}/{relation}`。`/v1/admin/device-pod-grants` 旧全权限入口不再保留;管理员只能通过 Admin Access API 写入或删除 OpenFGA relation。
|
||||
撤销授权走 `DELETE /v1/admin/access/users/{userId}/device-pods/{devicePodId}/{relation}`。管理员只能通过 Admin Access API 写入或删除 OpenFGA relation。
|
||||
|
||||
旧 `hwpod admin grant`、`device-pod-cli admin grant` 或等价本地全权限 grant 命令不属于 v0.2 授权路径。需要给用户开通 device pod 或工具能力时,统一使用 Admin Access API、Admin Access WebUI 或同路径 `hwlab-cli client access ...`,并按具体 relation 或 `tool:*#can_use` 写入 OpenFGA tuple。
|
||||
需要给用户开通 device pod 或工具能力时,统一使用 Admin Access API、Admin Access WebUI 或同路径 `hwlab-cli client access ...`,并按具体 relation 或 `tool:*#can_use` 写入 OpenFGA tuple。
|
||||
|
||||
### 用户列出 device pod
|
||||
|
||||
@@ -251,9 +241,9 @@ code agent prompt、runner 或 worker 不得直接绕过 cloud-api 调用 device
|
||||
|
||||
## 内部架构
|
||||
|
||||
`hwlab-cloud-api` 内部应按 auth/session/API key、authorization、agent session owner、device-pod grant 和 admin API 模块分层。所有模块共享同一 Postgres runtime store 和 migration ledger,避免拆出早期 `hwlab-user-api` 造成跨服务一致性成本。
|
||||
`hwlab-cloud-api` 内部应按 auth/session/API key、authorization、agent session owner、device-pod relation 和 admin API 模块分层。所有模块共享同一 Postgres runtime store 和 migration ledger,避免拆出早期 `hwlab-user-api` 造成跨服务一致性成本。
|
||||
|
||||
`user_sessions` 存浏览器 session token hash;`api_keys` 存映射到用户的 CLI/runner API key;`agent_sessions.owner_user_id` 绑定 Code Agent session;`device_pods` 存 profile authority;OpenFGA tuple 表示用户对 device pod、agent session 和工具的细粒度能力。`device_pod_grants` 只能作为迁移时读取旧数据的历史表,不得作为正式授权写入口或 allow 决策来源。不得再引入对所有正式 device pod 授权的共享用户 key;`HWLAB_DEVICE_POD_API_KEY` 如仍存在,只能作为 cloud-api 到 device-pod 的内部服务凭据。
|
||||
`user_sessions` 存浏览器 session token hash;`api_keys` 存映射到用户的 CLI/runner API key;`agent_sessions.owner_user_id` 绑定 Code Agent session;`device_pods` 存 profile authority;OpenFGA tuple 表示用户对 device pod、agent session 和工具的细粒度能力。cloud-api 调用 device-pod 内部执行服务使用内部 service token,该 token 不参与用户鉴权、不写入 runner env,也不产生 actor。
|
||||
|
||||
## API 接口说明
|
||||
|
||||
@@ -330,5 +320,4 @@ Kubernetes 只做运行时隔离和资源兜底,不承载 HWLAB 用户权限
|
||||
| `users`、`user_sessions`、OpenFGA relation 和 job 表 | 部分实现 | access-control bootstrap 覆盖 users、sessions、device_pods、access_tuples 和 jobs;Device Pod 强副作用 job 已接入 reason 校验,真实硬件执行仍依赖 gateway/device-host-cli 在线。 |
|
||||
| Code Agent owner 绑定 | 已实现 | 已在 `agent_sessions` 写入 `owner_user_id`、conversation/thread/trace 和脱敏 session evidence;trace/result cache 也按 owner/admin 限制访问。 |
|
||||
| OpenFGA 细粒度授权模型 | 核心已实现/持续约束 | v0.2 enforce runtime 已通过 Admin Access API 和同路径 CLI 管理 device pod relation 与 tool capability;后续扩展仍必须按 [spec-v02-openfga-authorization.md](spec-v02-openfga-authorization.md) 保持同一 authority。 |
|
||||
| legacy device pod grant 兼容 | 已退出目标路径 | `/v1/admin/device-pod-grants`、旧全权限 grant fallback 和 `device_pod_grants` authority 不再保留;历史表只可作为一次性迁移输入,当前 schema 只允许 `DROP TABLE IF EXISTS device_pod_grants`。 |
|
||||
| 不用 Kubernetes 表达用户权限 | 已实现/持续约束 | 规格明确禁止普通用户持有 kubeconfig 或直连 Service 权限。 |
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
- Web 用户通过 Keycloak OIDC 登录或注册,HWLAB 不再把本地账号密码表单作为目标登录体验。
|
||||
- Web 使用 `hwlab-cloud-api` 发行的 httpOnly `hwlab_session`,每个 session token 最长 24 小时;第一版不做复杂 refresh token 管理,但必须可撤销、可重新登录轮换。
|
||||
- CLI 是纯 CLI 体验,不打开浏览器、不跳转 Web、不做 device-code flow,也不直接消费 Keycloak access token;标准凭据是环境变量 `HWLAB_API_KEY`。
|
||||
- AgentRun runner 内的 `hwpod` 也必须使用同一类用户 API key 认证,映射到发起 Code Agent session 的 `users.id`;`HWLAB_DEVICE_POD_API_KEY` 只能作为 cloud-api 到 device-pod 的内部服务凭据或迁移期兼容项,不能作为正式用户鉴权方式。
|
||||
- AgentRun runner 内的 `hwpod` 也必须使用同一类用户 API key 认证,映射到发起 Code Agent session 的 `users.id`;device-pod 内部 service token 只允许 cloud-api 调用内部执行服务,不能恢复用户 actor。
|
||||
- 每个用户在首次登录后自动拥有一个默认 API key;用户也可以在 Web 中创建、查看、失效或重新生成 API key。
|
||||
- API key 长效有效,除非用户或管理员手动 revoke/regenerate;它不跟 Web session 的 24 小时过期绑定。
|
||||
- Keycloak 只做身份认证和账号注册;HWLAB 的 `users.role`、`users.status`、OpenFGA relation 和 Code Agent owner 仍是应用层授权 source of truth。
|
||||
- device-pod 权限只由 `users.role/status`、Code Agent session owner 和 OpenFGA relation 控制;不存在“对所有正式 device pod 授权”的共享 runner key。
|
||||
- device-pod 权限只由 `users.role/status`、Code Agent session owner 和 OpenFGA relation 控制;runner 只接收当前 owner 的用户 API key。
|
||||
|
||||
## 系统边界
|
||||
|
||||
@@ -276,7 +276,7 @@ API key 行为:
|
||||
|
||||
## T6
|
||||
|
||||
阅读 docs/reference/spec-v02-auth.md,然后检查 cloud-api 日志、trace、CLI 默认输出和 issue 复现材料,确认不出现 Keycloak client secret、session token、完整 API key、历史 `HWLAB_DEVICE_POD_API_KEY` 或 Postgres URL 原文;AgentRun runner 中也不得存在跨用户共享的 device-pod 系统 key。
|
||||
阅读 docs/reference/spec-v02-auth.md,然后检查 cloud-api 日志、trace、CLI 默认输出和 issue 复现材料,确认不出现 Keycloak client secret、session token、完整 API key 或 Postgres URL 原文;AgentRun runner 中只能出现映射到当前 owner 的用户 API key。
|
||||
|
||||
## 规格的实现情况
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ devops-infra git mirror 仍是 PipelineRun 和 Argo CD 的集群内读写源。`
|
||||
- Tekton/Argo 的 rendered runtime desired state。
|
||||
- image digest、publish state、reuse evidence 或 CI 生成的 rollout metadata。
|
||||
|
||||
如果权限、schema 或 runtime desired state 发生迁移,必须分别核对 source schema/代码、`v0.2-gitops` rendered YAML 和 live runtime。以 `device_pod_grants` 这类旧路径为例:source branch 应只保留当前 migration/ensure 逻辑,`v0.2-gitops` 与 live ConfigMap 应体现发布后的 rendered state,fixed workspace 下 ignored `runtime-v02/postgres.yaml` 即使存在也不能代表真相。
|
||||
如果权限、schema 或 runtime desired state 发生迁移,必须分别核对 source schema/代码、`v0.2-gitops` rendered YAML 和 live runtime。source branch 只保留当前目标 schema 和实现,`v0.2-gitops` 与 live ConfigMap 体现发布后的 rendered state,fixed workspace 下 ignored `runtime-v02/postgres.yaml` 即使存在也不能代表真相。
|
||||
|
||||
`v0.2-gitops` branch 必须包含:
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ Code Agent session 是显式资源,不再由普通 `client agent send`、Workb
|
||||
- `client access ...` 是 Admin Access 页面的同路径 CLI,不直连 OpenFGA,不手动传 OpenFGA token,不把 `19667` Cloud API 当作 Web 等价验收路径。所有授权读写都必须输出 runtimeEndpoint、route、actor、mode、decision 和 effective matrix 摘要。
|
||||
- `client gateway pressure` 是 device-pod/gateway 高频故障的真实业务传输压测入口;必须覆盖 small stdout、大 stdout、长单行 stdout、stderr flood、结构化 timeout 和超出 gateway inflight 上限的并发请求。所有场景必须返回 JSON、HTTP/route/traceId/requestId、字节数、truncated 标记、sha256 和 bounded preview;失败必须明确是 `http_*`、`stdout_not_truncated`、`stderr_not_truncated`、`timeout_not_observed`、`structured_gateway_busy` 等可定位原因,禁止无输出、长时间黑洞或只靠 shell pipe 截断。
|
||||
- 输出默认是 JSON;任何失败都要有 `ok:false`、`action`、`status`、HTTP 状态、route 和可定位错误,不允许无 stdout 成功。可能返回大对象的 `client` 子命令默认返回紧凑摘要,避免高频排障输出爆炸;需要完整响应体时显式加 `--full`。
|
||||
- `device-pod-cli`/`hwpod` 在 AgentRun runner 中是设备 API 标准短入口,必须自动使用装配的 `HWLAB_RUNTIME_API_URL` 直达 `hwlab-cloud-api`,并使用映射到当前 Code Agent session owner 的 `HWLAB_API_KEY`;不能把 Cloud Web 同源代理当作设备 API 通道,也不能手动传 URL、session token 或历史 `HWLAB_DEVICE_POD_API_KEY`。`job output` 默认也必须返回紧凑 JSON:保留 job/status/blocker/freshness/text/evidence 摘要,省略嵌套 gateway dispatch 和长命令;需要完整 payload 时显式加 `--full`。Code Agent 和人工不得用 `| head`、`grep` 或 shell 管道作为默认输出压缩方式,避免 stdout pipe、子进程信号转发或长输出造成 commandExecution 黑洞。
|
||||
- `device-pod-cli`/`hwpod` 在 AgentRun runner 中是设备 API 标准短入口,必须自动使用装配的 `HWLAB_RUNTIME_API_URL` 直达 `hwlab-cloud-api`,并使用映射到当前 Code Agent session owner 的 `HWLAB_API_KEY`;不能把 Cloud Web 同源代理当作设备 API 通道,也不能手动传 URL 或 session token。`job output` 默认也必须返回紧凑 JSON:保留 job/status/blocker/freshness/text/evidence 摘要,省略嵌套 gateway dispatch 和长命令;需要完整 payload 时显式加 `--full`。Code Agent 和人工不得用 `| head`、`grep` 或 shell 管道作为默认输出压缩方式,避免 stdout pipe、子进程信号转发或长输出造成 commandExecution 黑洞。
|
||||
- Code Agent 交互必须默认暴露 `traceId`、`resultUrl`、终态和 assistant 回复文本摘要;不能要求用户先拉全量 trace 再手工查找回复。
|
||||
- CLI 本地登录态必须支持 `--profile NAME` 隔离,同一 base URL 下不同 profile 写入 `.state/hwlab-cli/profiles/<base-url-hash>/<profile>.json`。切换到其他账号再切回原账号时,`client workbench restore/status` 必须从服务端账号 workspace 恢复之前的 `workspaceId`、`conversationId`、`sessionId`、`threadId`、`activeTraceId` 和 revision,而不是只依赖本地文件。
|
||||
- `client workbench restore/status/watch/reset` 是账号 workspace 的非视觉入口:`restore/status` 对应 `GET /v1/workbench/workspace`,`watch` 对应 `/events?afterRevision=`,`reset --confirm` 对应服务端 reset。输出必须显示 workspace revision、selected conversation/session、active trace 和本地 state file,且不得保存 password、session token 原文以外的 Secret 值。
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
- 承担 runtime health、DB readiness、登录鉴权、`AuthPrincipal`、OpenFGA 授权 check/write、用户/session/API key 权限、Code Agent 对话、trace/result 轮询、gateway outbound registry、M3 IO 控制、device-pod authority/job 和 live build inventory。
|
||||
- 是 `hwlab-cloud-web`、Code Agent session、device-pod 用户态操作、Admin Access API、AgentRun 工具注入和 gateway outbound poll 的唯一应用层收口点;普通用户不直接访问内部 `hwlab-device-pod` Service 或 OpenFGA 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;用户 API key 存在 Postgres `api_keys`,不得再以 `hwlab-v02-device-pod-api-key/api-key` 这类跨用户 SecretRef 授权 device-pod。文档和日志只允许记录 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` 等 v02 独立 SecretRef;用户 API key 存在 Postgres `api_keys`,device-pod 用户态授权只能从该表恢复到用户 actor。文档和日志只允许记录 SecretRef 名称、key、字节数或哈希指纹,不记录值。
|
||||
- 读取 OpenFGA URL、auth token、store/model 指针和 mode 时只能通过 env/SecretRef/Postgres runtime config;`/health/live` 和 `/v1/admin/access/summary` 只输出 readiness、mode、storeId/modelId 摘要和 degraded reason,不输出 token、Postgres URL 或 tuple secret。
|
||||
|
||||
## 内部架构
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
| HWLAB v0.2 Postgres | 业务对象、用户、session、API key、access_tuples 摘要、OpenFGA store/model 指针和迁移 ledger 的 durable source。 |
|
||||
| `hwlab-cloud-web` | Admin Access 页面和同源 API proxy;不直接调用 OpenFGA。 |
|
||||
| `hwlab-cli client` | Web 等价非视觉授权管理入口;通过 `19666` Cloud Web 同源 path 调 cloud-api,不直连 OpenFGA。 |
|
||||
| AgentRun v0.1 runner | 只消费 cloud-api 根据用户权限注入的短期工具环境;不持有跨用户共享 device-pod key、GitHub token 或 UniDesk SSH token。 |
|
||||
| AgentRun v0.1 runner | 只消费 cloud-api 根据用户权限注入的短期工具环境;工具凭据必须按 tool capability 独立授权。 |
|
||||
|
||||
OpenFGA 是 HWLAB 应用层授权基础设施,不是 Kubernetes RBAC、ServiceAccount、NetworkPolicy 或 Keycloak realm role 的替代。普通用户不获得 kubeconfig、内部 Service 直连、OpenFGA token 或 Keycloak admin 权限。
|
||||
|
||||
@@ -121,7 +121,7 @@ authenticate -> AuthPrincipal -> load domain object -> openfga check -> reason c
|
||||
模式语义:
|
||||
|
||||
- `enforce` 是唯一正式运行模式:device pod、agent session 和工具能力以 OpenFGA check 为准。OpenFGA 不可达、store/model 未就绪或 check 超时时,高风险写操作 fail closed;低风险只读可以返回 degraded blocker,不得静默放行。
|
||||
- `off` / `shadow` 不作为 v0.2 runtime 目标路径;旧文档、测试或 render 如再次把它们作为业务 allow source,应优先删除而不是兼容。
|
||||
- `off` / `shadow` 不作为 v0.2 runtime 目标路径;文档、测试或 render 如再次把它们作为业务 allow source,应优先删除而不是兼容。
|
||||
|
||||
tuple 写入必须由 cloud-api admin API 统一完成,并和 Postgres domain state 保持事务级或可恢复一致:
|
||||
|
||||
@@ -148,7 +148,7 @@ Cloud Web 和 CLI 只能通过 cloud-api 的 admin API 管理授权。第一版
|
||||
|
||||
所有 write API 必须要求当前 actor 具备 `access_manager system:hwlab` 或 admin tuple;普通 `user` 不可调用。响应必须包含结构化 `authorization` 字段:`mode`、`allowed`、`decisionSource`、`storeId`、`modelId`、`relation`、`object` 和 redacted actor。
|
||||
|
||||
Cloud Web 必须把 Admin Access 读写 API 作为同源代理路径转发给 cloud-api,包括 `POST /v1/admin/access/check`、`PATCH /v1/admin/access/users/{userId}`、device pod relation 的 `PUT/DELETE` 和 tool capability 的 `PUT/DELETE`。不得在 Cloud Web 本地保留 `/v1/admin/device-pod-grants`、旧全权限 grant fallback 或旧表读取逻辑;旧 route 只可在迁移排查时作为“不存在”的负向烟测,不得作为兼容 API 合同维护。
|
||||
Cloud Web 必须把 Admin Access 读写 API 作为同源代理路径转发给 cloud-api,包括 `POST /v1/admin/access/check`、`PATCH /v1/admin/access/users/{userId}`、device pod relation 的 `PUT/DELETE` 和 tool capability 的 `PUT/DELETE`。Cloud Web 不保存授权副本,也不直接访问 OpenFGA。
|
||||
|
||||
## Admin Access WebUI
|
||||
|
||||
@@ -187,8 +187,6 @@ CLI 输出必须是 JSON,包含 `runtimeEndpoint`、HTTP route、actor 摘要
|
||||
|
||||
权限变更的真实入口验收必须以 Cloud Web 同源 origin 为准。最小闭环是:`client access summary` 确认 `openfga.mode=enforce` 且 ready;对一个普通用户执行某个 device pod relation 的 `check false -> grant -> check true -> revoke -> check false`;对至少一个工具能力执行同样闭环,`trans_cmd` 必须覆盖;最后 `users inspect` 确认测试 tuple 已撤回。验收报告必须记录 `baseUrl`、method/path、actor、relation/object、HTTP status、OpenFGA decision 和最终 effective matrix 摘要。
|
||||
|
||||
旧路径清理的验收不以旧 API 兼容为目标。可以用 `POST /v1/admin/device-pod-grants` 返回 Cloud Web `404` 证明旧入口不存在,但源码测试不应为了历史 route 维护长期行为;长期测试只表达 Admin Access/OpenFGA 当前目标路径。
|
||||
|
||||
## AgentRun 工具能力边界
|
||||
|
||||
Cloud API 在创建 AgentRun command/runner 时必须按 OpenFGA 决策装配 transient env 和工具说明:
|
||||
@@ -231,7 +229,7 @@ Cloud API 在创建 AgentRun command/runner 时必须按 OpenFGA 决策装配 tr
|
||||
|
||||
## T8
|
||||
|
||||
阅读 docs/reference/spec-v02-openfga-authorization.md 和 docs/reference/spec-user-access.md,然后检查 source schema、`v0.2-gitops` rendered ConfigMap 和 live Postgres:确认 source 不创建 `device_pod_grants`,render/live schema 包含 `DROP TABLE IF EXISTS device_pod_grants`,live `information_schema.tables` 中该表数量为 `0`。只允许历史说明、DROP 和“不得创建旧表”的测试断言引用该名称。
|
||||
阅读 docs/reference/spec-v02-openfga-authorization.md 和 docs/reference/spec-user-access.md,然后检查 source schema、`v0.2-gitops` rendered ConfigMap 和 live Postgres:确认 source、render 和 live schema 都只保留 OpenFGA/Admin Access 当前授权结构;live Postgres 中不应存在任何已退出的 device pod 授权表或兼容写入口。
|
||||
|
||||
## 规格的实现情况
|
||||
|
||||
@@ -239,9 +237,8 @@ Cloud API 在创建 AgentRun command/runner 时必须按 OpenFGA 决策装配 tr
|
||||
| --- | --- | --- |
|
||||
| OpenFGA 作为 v0.2 内部授权服务 | 已实现/持续约束 | v0.2 runtime 以 `enforce` 模式运行,summary 暴露 redacted store/model 和 readiness;OpenFGA 不向公网、浏览器或 CLI 暴露。 |
|
||||
| Cloud API OpenFGA client/bootstrap/check/write | 已实现 | Admin Access API 可 check/write tuple,响应包含 structured decision 和 redacted OpenFGA 状态。 |
|
||||
| 细粒度 device pod / session / tool 授权 | 核心已实现/持续扩展 | device pod relation 和 tool capability 已替代旧全权限 grant;后续 session/tool 扩展必须继续写 OpenFGA tuple。 |
|
||||
| 细粒度 device pod / session / tool 授权 | 核心已实现/持续扩展 | device pod relation 和 tool capability 统一写 OpenFGA tuple;后续 session/tool 扩展必须继续保持同一 authority。 |
|
||||
| Admin Access WebUI | 部分实现/持续约束 | Cloud Web Access 页面使用同一 Admin Access API;浏览器交互深测可作为专项验收,但不得新增第二条授权路径。 |
|
||||
| 同路径 CLI | 已实现 | `client access ...` 走 Cloud Web 同源 path,已覆盖 summary、users、check、device pod relation grant/revoke 和 tool grant/revoke。 |
|
||||
| AgentRun 工具注入按用户权限过滤 | 部分实现/持续约束 | `hwpod`、`unidesk_ssh`、`trans_cmd`、GitHub 写工具必须独立授权;实现和验收不得退回共享系统 key。 |
|
||||
| legacy `device_pod_grants` authority | 已退出目标路径 | source schema 不再创建旧表,runtime schema ensure 保留 DROP,旧 `/v1/admin/device-pod-grants` 不作为 API 合同。 |
|
||||
| AgentRun 工具注入按用户权限过滤 | 部分实现/持续约束 | `hwpod`、`unidesk_ssh`、`trans_cmd`、GitHub 写工具必须独立授权;runner 中的 `HWLAB_API_KEY` 必须映射到当前 Code Agent session owner。 |
|
||||
|
||||
|
||||
@@ -73,23 +73,6 @@ test("Postgres workspace update query preserves parameter numbering", async () =
|
||||
assert.equal(update.params[17], "user");
|
||||
});
|
||||
|
||||
test("Postgres access schema removes legacy device_pod_grants authority", async () => {
|
||||
const calls = [];
|
||||
const accessController = createAccessController({
|
||||
runtimeStore: {
|
||||
query: async (sql, params = []) => {
|
||||
calls.push({ sql, params });
|
||||
return { rows: [] };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await accessController.store.countUsers();
|
||||
|
||||
assert.ok(calls.some((call) => call.sql === "DROP TABLE IF EXISTS device_pod_grants"));
|
||||
assert.equal(calls.some((call) => /CREATE TABLE IF NOT EXISTS device_pod_grants\b/u.test(call.sql)), false);
|
||||
});
|
||||
|
||||
test("cloud api /auth/oidc/login returns 302 to Keycloak when issuer and client are configured", async () => {
|
||||
const server = createCloudApiServer({
|
||||
env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1", HWLAB_KEYCLOAK_ISSUER: OIDC_ISSUER, HWLAB_KEYCLOAK_CLIENT_ID: OIDC_CLIENT_ID, HWLAB_PUBLIC_ENDPOINT },
|
||||
@@ -2112,15 +2095,16 @@ test("cloud api protects device-pod routes when access control is required", asy
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api accepts the assembled Code Agent Device Pod API key with admin device access", async () => {
|
||||
test("cloud api accepts user API key Bearer auth for authorized device access", async () => {
|
||||
const openFgaAuthorizer = createFakeOpenFgaAuthorizer({ mode: "enforce" });
|
||||
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_DEVICE_POD_API_KEY: "test-device-pod-api-key"
|
||||
HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass"
|
||||
},
|
||||
openFgaAuthorizer,
|
||||
now: () => "2026-05-28T00:00:00.000Z"
|
||||
});
|
||||
const server = createCloudApiServer({
|
||||
@@ -2128,8 +2112,7 @@ test("cloud api accepts the assembled Code Agent Device Pod API key with admin d
|
||||
HWLAB_ACCESS_CONTROL_REQUIRED: "1",
|
||||
HWLAB_BOOTSTRAP_ADMIN_ID: "usr_v02_admin",
|
||||
HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin",
|
||||
HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass",
|
||||
HWLAB_DEVICE_POD_API_KEY: "test-device-pod-api-key"
|
||||
HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass"
|
||||
},
|
||||
accessController,
|
||||
now: () => "2026-05-28T00:00:00.000Z"
|
||||
@@ -2143,31 +2126,25 @@ test("cloud api accepts the assembled Code Agent Device Pod API key with admin d
|
||||
devicePodId: "device-pod-71-freq",
|
||||
profile: { schemaVersion: 1, target: { id: "target-71-freq" }, route: { gatewaySessionId: "gws_unused" } }
|
||||
}, adminLogin.cookie);
|
||||
|
||||
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-api-key");
|
||||
assert.equal(agentAuth.valuesRedacted, true);
|
||||
assert.equal(agentAuth.apiKey, "test-device-pod-api-key");
|
||||
const userApiKey = (await postJson(port, "/v1/api-keys", { name: "Device access test" }, adminLogin.cookie)).body.key.displaySecret;
|
||||
assert.match(userApiKey, /^hwl_live_/u);
|
||||
|
||||
const status = await getJson(port, "/v1/device-pods/device-pod-71-freq/status", null, {
|
||||
"x-hwlab-device-pod-api-key": agentAuth.apiKey
|
||||
authorization: `Bearer ${userApiKey}`
|
||||
});
|
||||
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.apiKey), false);
|
||||
assert.equal(JSON.stringify(status.body).includes(userApiKey), false);
|
||||
|
||||
const adminWithApiKey = await postJson(port, "/v1/admin/device-pods", {
|
||||
devicePodId: "device-pod-api-key-admin-blocked",
|
||||
const adminWithUserApiKey = await postJson(port, "/v1/admin/device-pods", {
|
||||
devicePodId: "device-pod-user-api-key-admin",
|
||||
profile: { schemaVersion: 1, target: { id: "target-blocked" }, route: { gatewaySessionId: "gws_unused" } }
|
||||
}, null, {
|
||||
"x-hwlab-device-pod-api-key": agentAuth.apiKey
|
||||
authorization: `Bearer ${userApiKey}`
|
||||
});
|
||||
assert.equal(adminWithApiKey.status, 403);
|
||||
assert.equal(adminWithApiKey.body.error.code, "admin_session_required");
|
||||
assert.equal(adminWithUserApiKey.status, 201);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
||||
}
|
||||
@@ -2722,8 +2699,10 @@ test("cloud api exposes v1 access status routes and returns structured REST erro
|
||||
});
|
||||
|
||||
test("cloud api first-admin setup opens access when bootstrap secret is absent", async () => {
|
||||
const openFgaAuthorizer = createFakeOpenFgaAuthorizer({ mode: "enforce" });
|
||||
const server = createCloudApiServer({
|
||||
env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" },
|
||||
accessController: createAccessController({ env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, openFgaAuthorizer, now: () => "2026-05-28T00:00:00.000Z" }),
|
||||
now: () => "2026-05-28T00:00:00.000Z"
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
@@ -3036,10 +3015,10 @@ function createFakeOpenFgaAuthorizer({ mode = "enforce" } = {}) {
|
||||
async describe() {
|
||||
return { contractVersion: "openfga-authorization-v1", mode, configured: true, ready: true, storeId: "fake-store", modelId: "fake-model", tupleCount: tuples.size };
|
||||
},
|
||||
async check({ actor, userId, relation, object, legacyAllowed = false }) {
|
||||
async check({ actor, userId, relation, object }) {
|
||||
const subject = actor?.id ?? userId;
|
||||
const allowed = actor?.role === "admin" || tuples.has(key({ userId: subject, relation, object }));
|
||||
return { contractVersion: "openfga-authorization-v1", mode, user: `user:${subject}`, relation, object, allowed, legacyAllowed, decisionSource: "fake-openfga", degraded: false };
|
||||
const allowed = tuples.has(key({ userId: subject, relation, object })) || tuples.has(key({ userId: subject, relation: "admin", object: "system:hwlab" }));
|
||||
return { contractVersion: "openfga-authorization-v1", mode, user: `user:${subject}`, relation, object, allowed, decisionSource: allowed && !tuples.has(key({ userId: subject, relation, object })) ? "fake-openfga-admin" : "fake-openfga", degraded: false };
|
||||
},
|
||||
async writeTuple({ userId, relation, object }) {
|
||||
tuples.add(key({ userId, relation, object }));
|
||||
|
||||
@@ -30,14 +30,12 @@ function sessionCookieAttributes(maxAge, { secure = false } = {}) {
|
||||
.filter(Boolean)
|
||||
.join("; ");
|
||||
}
|
||||
const DEVICE_POD_API_KEY_HEADER = "x-hwlab-device-pod-api-key";
|
||||
const API_KEY_PREFIX = "hwl_live_";
|
||||
const API_KEY_RANDOM_BYTES = 32;
|
||||
const API_KEY_DEFAULT_NAME = "Default API key";
|
||||
const AUTH_METHOD_API_KEY = "api-key";
|
||||
const AUTH_METHOD_WEB_SESSION = "web-session";
|
||||
const AUTH_METHOD_LEGACY_LOCAL_SESSION = "legacy-local-session";
|
||||
const AUTH_METHOD_DEVICE_POD_API_KEY = "device-pod-api-key";
|
||||
const DEVICE_JOB_CONTRACT_VERSION = "device-pod-job-v1";
|
||||
const DEVICE_JOB_INTENTS = new Set([
|
||||
"workspace.bootsharp",
|
||||
@@ -194,8 +192,7 @@ const ACCESS_SCHEMA_STATEMENTS = Object.freeze([
|
||||
PRIMARY KEY (user_id, relation, object)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_access_tuples_user ON access_tuples(user_id, object)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_access_tuples_object ON access_tuples(object, relation)`,
|
||||
`DROP TABLE IF EXISTS device_pod_grants`
|
||||
`CREATE INDEX IF NOT EXISTS idx_access_tuples_object ON access_tuples(object, relation)`
|
||||
]);
|
||||
|
||||
const MUTATING_INTENTS = new Set([
|
||||
@@ -251,7 +248,6 @@ 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.devicePodSyncTimeoutMs = Number.parseInt(String(devicePodSyncTimeoutMs), 10) || 120000;
|
||||
this.now = now;
|
||||
@@ -418,6 +414,7 @@ class AccessController {
|
||||
if (!user) {
|
||||
return sendJson(response, 409, errorPayload("setup_already_completed", "First admin setup is closed because at least one user already exists", 409));
|
||||
}
|
||||
const adminAccess = await this.syncUserAdminTuple(user, user);
|
||||
const devicePodsInitialized = [];
|
||||
for (const seed of devicePodSeeds) {
|
||||
const pod = await this.store.upsertDevicePod({ ...seed, now });
|
||||
@@ -445,6 +442,7 @@ class AccessController {
|
||||
actor: publicActor(user),
|
||||
session: publicSession({ ...session, user }),
|
||||
setupRequired: false,
|
||||
adminAccess,
|
||||
devicePodBootstrap: {
|
||||
requested: devicePodSeeds.length,
|
||||
initialized: devicePodsInitialized.length
|
||||
@@ -498,9 +496,6 @@ 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));
|
||||
}
|
||||
@@ -620,8 +615,7 @@ class AccessController {
|
||||
const object = requiredText(body.object, "object");
|
||||
const user = await this.store.getUserById(userId);
|
||||
if (!user) return sendJson(response, 404, errorPayload("user_not_found", "User was not found", 404));
|
||||
const legacyAllowed = this.roleAllowedForRelation({ user, relation, object });
|
||||
const authorization = await this.openFga.check({ actor: user, relation, object, legacyAllowed });
|
||||
const authorization = await this.openFga.check({ actor: user, relation, object });
|
||||
return sendJson(response, 200, { contractVersion: "admin-access-v1", actor: publicActor(auth.actor), subject: publicActor(user), authorization });
|
||||
}
|
||||
return sendJson(response, 404, errorPayload("not_found", "Admin Access route is not implemented", 404));
|
||||
@@ -713,20 +707,11 @@ class AccessController {
|
||||
return result;
|
||||
}
|
||||
|
||||
roleAllowedForRelation({ user, relation, object }) {
|
||||
if (user.role === "admin") return true;
|
||||
const parsed = parseOpenFgaObject(object);
|
||||
if (parsed?.type === "tool") return false;
|
||||
if (parsed?.type === "system") return user.role === "admin";
|
||||
return false;
|
||||
}
|
||||
|
||||
async listVisibleDevicePods(actor) {
|
||||
const pods = await this.store.listDevicePods?.() ?? [];
|
||||
const visible = [];
|
||||
for (const pod of pods.filter((item) => item.status === "active")) {
|
||||
const legacyAllowed = actor.role === "admin";
|
||||
const authorization = await this.openFga.check({ actor, relation: "viewer", object: openFgaObject("device_pod", pod.id), legacyAllowed });
|
||||
const authorization = await this.openFga.check({ actor, relation: "viewer", object: openFgaObject("device_pod", pod.id) });
|
||||
if (authorization.allowed) visible.push(pod);
|
||||
}
|
||||
return visible;
|
||||
@@ -736,8 +721,8 @@ class AccessController {
|
||||
return (await this.listVisibleDevicePods(actor)).find((pod) => pod.id === id) ?? null;
|
||||
}
|
||||
|
||||
async authorizeDevicePodOperation({ actor, pod, relation, legacyAllowed = false }) {
|
||||
const authorization = await this.openFga.check({ actor, relation, object: openFgaObject("device_pod", pod.id), legacyAllowed: actor.role === "admin" || legacyAllowed });
|
||||
async authorizeDevicePodOperation({ actor, pod, relation }) {
|
||||
const authorization = await this.openFga.check({ actor, relation, object: openFgaObject("device_pod", pod.id) });
|
||||
return authorization;
|
||||
}
|
||||
|
||||
@@ -789,8 +774,6 @@ class AccessController {
|
||||
|
||||
async authenticate(request, { required = this.required } = {}) {
|
||||
await this.ensureBootstrap();
|
||||
const devicePodApiKey = devicePodApiKeyFromRequest(request);
|
||||
if (devicePodApiKey) return this.authenticateDevicePodApiKey(devicePodApiKey);
|
||||
const apiKeySecret = apiKeyFromRequest(request);
|
||||
if (apiKeySecret) return this.authenticateApiKey(apiKeySecret);
|
||||
const token = sessionCookieFromRequest(request);
|
||||
@@ -805,20 +788,6 @@ class AccessController {
|
||||
return { ok: true, actor: session.user, session: publicSession(session), authMethod: AUTH_METHOD_WEB_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 authenticateApiKey(secret) {
|
||||
if (!isApiKeySecret(secret)) return errorPayload("api_key_invalid", "API key is missing or invalid", 401);
|
||||
const prefix = apiKeyPrefixOf(secret);
|
||||
@@ -1023,28 +992,13 @@ class AccessController {
|
||||
return sendJson(response, 200, { authenticated: false, loggedOut: true });
|
||||
}
|
||||
|
||||
async createCodeAgentDevicePodApiKey() {
|
||||
await this.ensureBootstrap();
|
||||
if (!this.devicePodApiKey) return null;
|
||||
const actor = await this.bootstrapAdminActor();
|
||||
if (!actor || actor.status !== "active") return null;
|
||||
return {
|
||||
apiKey: this.devicePodApiKey,
|
||||
actor: publicActor(actor),
|
||||
tokenSource: "code-agent-device-pod-api-key",
|
||||
valuesRedacted: true
|
||||
};
|
||||
}
|
||||
|
||||
async codeAgentToolCapabilitiesForOwner(ownerUserId) {
|
||||
await this.ensureBootstrap();
|
||||
if (this.openFga.mode === "off") return null;
|
||||
const actor = await this.store.getUserById(ownerUserId);
|
||||
if (!actor || actor.status !== "active") return defaultDeniedToolCapabilities();
|
||||
const entries = {};
|
||||
for (const toolId of HWLAB_TOOL_IDS) {
|
||||
const legacyAllowed = false;
|
||||
const authorization = await this.openFga.check({ actor, relation: "can_use", object: openFgaObject("tool", toolId), legacyAllowed });
|
||||
const authorization = await this.openFga.check({ actor, relation: "can_use", object: openFgaObject("tool", toolId) });
|
||||
entries[toolId] = { allowed: authorization.allowed, authorization };
|
||||
}
|
||||
return { contractVersion: "admin-access-v1", actor: publicActor(actor), tools: entries, valuesRedacted: true };
|
||||
@@ -1778,8 +1732,7 @@ class AccessController {
|
||||
if (!DEVICE_JOB_INTENTS.has(intent) || MUTATING_INTENTS.has(intent)) {
|
||||
throw Object.assign(new Error(`Device probe intent ${intent} is not supported as a read-only probe`), { statusCode: 400, code: "unsupported_device_probe_intent" });
|
||||
}
|
||||
const legacyAllowed = actor.role === "admin";
|
||||
const authorization = await this.authorizeDevicePodOperation({ actor, pod, relation: "viewer", legacyAllowed });
|
||||
const authorization = await this.authorizeDevicePodOperation({ actor, pod, relation: "viewer" });
|
||||
if (!authorization.allowed) {
|
||||
return {
|
||||
job: await this.createBlockedAuthorizationDevicePodJob({ pod, actor, intent, args, reason: "", authorization }),
|
||||
@@ -1824,9 +1777,8 @@ class AccessController {
|
||||
});
|
||||
}
|
||||
const args = normalizeObject(body.args);
|
||||
const legacyAllowed = actor.role === "admin";
|
||||
const relation = MUTATING_INTENTS.has(intent) ? "job_submitter" : "operator";
|
||||
const authorization = await this.authorizeDevicePodOperation({ actor, pod, relation, legacyAllowed });
|
||||
const authorization = await this.authorizeDevicePodOperation({ actor, pod, relation });
|
||||
if (!authorization.allowed) {
|
||||
return this.rejectDevicePodJob(response, pod, actor, {
|
||||
httpStatus: 403,
|
||||
@@ -2499,7 +2451,6 @@ function uartTailArgs(searchParams, uartId) { return { uartId, durationMs: bound
|
||||
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 devicePodApiKeyFromRequest(request) { return textOr(getHeader(request, DEVICE_POD_API_KEY_HEADER), ""); }
|
||||
function apiKeyFromRequest(request) {
|
||||
const auth = getHeader(request, "authorization");
|
||||
if (!/^Bearer\s+/iu.test(String(auth ?? ""))) return "";
|
||||
@@ -2528,7 +2479,6 @@ function hashApiKey(secret) {
|
||||
return `sha256:${sha256(`${prefix}:${secret}`)}`;
|
||||
}
|
||||
function isApiKeySecret(value) { return textOr(value, "").startsWith(API_KEY_PREFIX); }
|
||||
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))}`; }
|
||||
@@ -2589,7 +2539,7 @@ function boundedOutput(output, maxBytes = DEVICE_JOB_OUTPUT_MAX_BYTES) {
|
||||
function freshness(observedAt, blocker) { return { observedAt, ageMs: 0, stale: Boolean(blocker), source: blocker ? "blocked" : "cloud-api" }; }
|
||||
function publicActor(user) { return user ? { id: user.id, username: user.username, displayName: user.displayName, role: user.role, status: user.status } : null; }
|
||||
function publicAuthMethod(method) {
|
||||
return [AUTH_METHOD_API_KEY, AUTH_METHOD_WEB_SESSION, AUTH_METHOD_LEGACY_LOCAL_SESSION, AUTH_METHOD_DEVICE_POD_API_KEY].includes(method) ? method : AUTH_METHOD_WEB_SESSION;
|
||||
return [AUTH_METHOD_API_KEY, AUTH_METHOD_WEB_SESSION, AUTH_METHOD_LEGACY_LOCAL_SESSION].includes(method) ? method : AUTH_METHOD_WEB_SESSION;
|
||||
}
|
||||
function publicApiKey(key, { includeSecret = false } = {}) {
|
||||
if (!key) return null;
|
||||
|
||||
@@ -834,9 +834,7 @@ function buildAgentRunRunnerJobInput({ env, traceId, commandId, ownerApiKey, too
|
||||
const baseTransient = buildAgentRunTransientEnv(env);
|
||||
const hwpodAllowed = toolCapabilityAllowed(toolCapabilities, "hwpod");
|
||||
const transientEnv = ownerApiKey && hwpodAllowed
|
||||
? baseTransient
|
||||
.filter((entry) => entry.name !== "HWLAB_DEVICE_POD_API_KEY")
|
||||
.concat([{ name: "HWLAB_API_KEY", value: ownerApiKey, sensitive: true }])
|
||||
? baseTransient.concat([{ name: "HWLAB_API_KEY", value: ownerApiKey, sensitive: true }])
|
||||
: baseTransient;
|
||||
return {
|
||||
commandId,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { test } from "bun:test";
|
||||
|
||||
import { createOpenFgaAuthorizer } from "./openfga-authorization.ts";
|
||||
|
||||
test("OpenFGA shadow mode is normalized to enforce and does not allow legacy fallback", async () => {
|
||||
test("OpenFGA checks use tuples instead of local role fallback", async () => {
|
||||
const calls: Array<{ path: string; method: string }> = [];
|
||||
const authorizer = createOpenFgaAuthorizer({
|
||||
env: { HWLAB_OPENFGA_MODE: "shadow", HWLAB_OPENFGA_API_URL: "http://openfga.test" },
|
||||
@@ -13,26 +13,30 @@ test("OpenFGA shadow mode is normalized to enforce and does not allow legacy fal
|
||||
if (parsed.pathname === "/healthz") return jsonResponse({}, 200);
|
||||
if (parsed.pathname === "/stores") return jsonResponse({ id: "store-shadow" }, 201);
|
||||
if (parsed.pathname === "/stores/store-shadow/authorization-models") return jsonResponse({ authorization_model_id: "model-shadow" }, 201);
|
||||
if (parsed.pathname === "/stores/store-shadow/check") return jsonResponse({ allowed: false }, 200);
|
||||
if (parsed.pathname === "/stores/store-shadow/check") {
|
||||
const body = JSON.parse(String(init.body ?? "{}"));
|
||||
const tuple = body.tuple_key ?? {};
|
||||
return jsonResponse({ allowed: tuple.relation === "admin" && tuple.object === "system:hwlab" }, 200);
|
||||
}
|
||||
return jsonResponse({ error: "unexpected path" }, 404);
|
||||
}
|
||||
});
|
||||
|
||||
const decision = await authorizer.check({
|
||||
actor: { id: "usr_shadow", role: "user" },
|
||||
actor: { id: "usr_shadow", role: "admin" },
|
||||
relation: "viewer",
|
||||
object: "device_pod:device-pod-71-freq",
|
||||
legacyAllowed: true
|
||||
object: "device_pod:device-pod-71-freq"
|
||||
});
|
||||
|
||||
assert.equal(decision.mode, "enforce");
|
||||
assert.equal(decision.allowed, false);
|
||||
assert.equal(decision.fgaAllowed, false);
|
||||
assert.equal(decision.decisionSource, "openfga");
|
||||
assert.equal(decision.allowed, true);
|
||||
assert.equal(decision.fgaAllowed, true);
|
||||
assert.equal(decision.decisionSource, "openfga-admin");
|
||||
assert.deepEqual(calls.map((call) => call.path), [
|
||||
"/healthz",
|
||||
"/stores",
|
||||
"/stores/store-shadow/authorization-models",
|
||||
"/stores/store-shadow/check",
|
||||
"/stores/store-shadow/check"
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -59,13 +59,12 @@ export class OpenFgaAuthorizer {
|
||||
};
|
||||
}
|
||||
|
||||
async check({ actor = null, userId = "", relation = "", object = "", legacyAllowed = false } = {}) {
|
||||
async check({ actor = null, userId = "", relation = "", object = "" } = {}) {
|
||||
const actorId = textOr(userId, actor?.id ?? "");
|
||||
const rel = textOr(relation, "");
|
||||
const obj = textOr(object, "");
|
||||
if (!actorId || !rel || !obj) return decision({ mode: this.mode, allowed: false, decisionSource: "invalid-request", relation: rel, object: obj, userId: actorId });
|
||||
if (actor?.role === "admin") return decision({ mode: this.mode, allowed: true, decisionSource: "role-admin", relation: rel, object: obj, userId: actorId });
|
||||
if (this.mode === "off") return decision({ mode: this.mode, allowed: Boolean(legacyAllowed), decisionSource: "legacy", relation: rel, object: obj, userId: actorId });
|
||||
if (this.mode === "off") return decision({ mode: this.mode, allowed: false, decisionSource: "openfga-disabled", relation: rel, object: obj, userId: actorId, degradedReason: "openfga_disabled" });
|
||||
|
||||
const readiness = await this.ensureReady();
|
||||
if (!readiness.ready) {
|
||||
@@ -82,10 +81,42 @@ export class OpenFgaAuthorizer {
|
||||
}
|
||||
|
||||
const fga = await this.checkOpenFga({ user: openFgaUser(actorId), relation: rel, object: obj, readiness });
|
||||
if (fga.allowed) {
|
||||
return decision({
|
||||
mode: this.mode,
|
||||
allowed: true,
|
||||
fgaAllowed: true,
|
||||
decisionSource: "openfga",
|
||||
relation: rel,
|
||||
object: obj,
|
||||
userId: actorId,
|
||||
storeId: readiness.storeId,
|
||||
modelId: readiness.modelId,
|
||||
degradedReason: null
|
||||
});
|
||||
}
|
||||
const systemAdminObject = openFgaObject("system", "hwlab");
|
||||
if (!(rel === "admin" && obj === systemAdminObject)) {
|
||||
const admin = await this.checkOpenFga({ user: openFgaUser(actorId), relation: "admin", object: systemAdminObject, readiness });
|
||||
if (admin.allowed) {
|
||||
return decision({
|
||||
mode: this.mode,
|
||||
allowed: true,
|
||||
fgaAllowed: true,
|
||||
decisionSource: "openfga-admin",
|
||||
relation: rel,
|
||||
object: obj,
|
||||
userId: actorId,
|
||||
storeId: readiness.storeId,
|
||||
modelId: readiness.modelId,
|
||||
degradedReason: null
|
||||
});
|
||||
}
|
||||
}
|
||||
return decision({
|
||||
mode: this.mode,
|
||||
allowed: Boolean(fga.allowed),
|
||||
fgaAllowed: fga.allowed,
|
||||
allowed: false,
|
||||
fgaAllowed: false,
|
||||
decisionSource: "openfga",
|
||||
relation: rel,
|
||||
object: obj,
|
||||
@@ -108,7 +139,7 @@ export class OpenFgaAuthorizer {
|
||||
const sanitizedWrites = writes.map(sanitizeTuple).filter(Boolean);
|
||||
const sanitizedDeletes = deletes.map(sanitizeTuple).filter(Boolean);
|
||||
if (this.mode === "off") {
|
||||
return { ok: true, mode: this.mode, written: sanitizedWrites.length, deleted: sanitizedDeletes.length, decisionSource: "openfga-off" };
|
||||
return { ok: false, mode: this.mode, status: 503, error: { code: "openfga_disabled", message: "OpenFGA is disabled" }, written: 0, deleted: 0 };
|
||||
}
|
||||
const readiness = await this.ensureReady();
|
||||
if (!readiness.ready) {
|
||||
|
||||
@@ -349,7 +349,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-api-key"), false);
|
||||
assert.equal(body.payload.prompt.includes("hwl_live_owner_secret"), false);
|
||||
assert.equal(body.payload.prompt.includes("sk-test-device-secret"), false);
|
||||
} else {
|
||||
assert.equal(Object.hasOwn(body.payload, "conversationContext"), false);
|
||||
@@ -476,9 +476,6 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte
|
||||
async authenticate() {
|
||||
return { ok: true, actor: TEST_AGENT_ACTOR, session: TEST_AUTH_SESSION };
|
||||
},
|
||||
async createCodeAgentDevicePodApiKey() {
|
||||
return { apiKey: "test-device-pod-api-key" };
|
||||
},
|
||||
async recordAgentSessionOwner(input) {
|
||||
const record = testAgentSessionRecord(input);
|
||||
ownerSessions.set(record.id, record);
|
||||
@@ -580,7 +577,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-api-key"), false);
|
||||
assert.equal(JSON.stringify(inspectByTraceBody).includes("hwl_live_owner_secret"), false);
|
||||
|
||||
const trace = await fetch(`http://127.0.0.1:${port}/v1/agent/chat/trace/${traceId}`);
|
||||
assert.equal(trace.status, 200);
|
||||
@@ -608,7 +605,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 可用性快照:可用;apiKey=test-device-pod-api-key sk-test-device-secret-000000",
|
||||
text: "v0.2 device-pod 可用性快照:可用;apiKey=hwl_live_owner_secret sk-test-device-secret-000000",
|
||||
status: "completed",
|
||||
traceId,
|
||||
conversationId: "cnv_server-test-agentrun",
|
||||
@@ -834,9 +831,6 @@ test("cloud api AgentRun adapter reports persistent thread resume when a complet
|
||||
async authenticate() {
|
||||
return { ok: true, actor: TEST_AGENT_ACTOR, session: TEST_AUTH_SESSION };
|
||||
},
|
||||
async createCodeAgentDevicePodApiKey() {
|
||||
return { apiKey: "test-device-pod-api-key" };
|
||||
},
|
||||
async recordAgentSessionOwner(input) {
|
||||
const record = testAgentSessionRecord(input);
|
||||
ownerSessions.set(record.id, record);
|
||||
@@ -971,9 +965,6 @@ test("cloud api AgentRun adapter exposes invalid tool-call attribution in result
|
||||
async authenticate() {
|
||||
return { ok: true, actor: TEST_AGENT_ACTOR, session: TEST_AUTH_SESSION };
|
||||
},
|
||||
async createCodeAgentDevicePodApiKey() {
|
||||
return { apiKey: "test-device-pod-api-key" };
|
||||
},
|
||||
async recordAgentSessionOwner(input) {
|
||||
const record = testAgentSessionRecord(input);
|
||||
ownerSessions.set(record.id, record);
|
||||
|
||||
@@ -184,8 +184,6 @@ CREATE TABLE IF NOT EXISTS device_pods (
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
DROP TABLE IF EXISTS device_pod_grants;
|
||||
|
||||
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,
|
||||
|
||||
@@ -39,8 +39,6 @@ test("initial migration skeleton declares every frozen table", async () => {
|
||||
assert.match(sql, /\btarget\b/);
|
||||
assert.match(sql, /\bresult\b/);
|
||||
assert.match(sql, /\btimestamp\b/);
|
||||
assert.doesNotMatch(sql, /CREATE TABLE IF NOT EXISTS device_pod_grants\b/u);
|
||||
assert.match(sql, /DROP TABLE IF EXISTS device_pod_grants\b/u);
|
||||
});
|
||||
|
||||
test("initial migration exposes columns required by the durable runtime adapter", async () => {
|
||||
|
||||
@@ -75,7 +75,7 @@ test("cloud web proxies authenticated API key management write routes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("cloud web proxies authenticated Admin Access write routes without legacy grant fallback", () => {
|
||||
test("cloud web proxies authenticated Admin Access write routes", () => {
|
||||
assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/admin/access/check"), {
|
||||
proxy: true,
|
||||
authRequired: true,
|
||||
|
||||
@@ -46,7 +46,7 @@ test("cloud web auth requests are proxied once", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud web proxies Admin Access write routes and leaves legacy grants local", async () => {
|
||||
test("cloud web proxies Admin Access write routes", async () => {
|
||||
const upstreamRequests = [];
|
||||
const upstream = createServer(async (request, response) => {
|
||||
let body = "";
|
||||
|
||||
@@ -13,7 +13,7 @@ const ASSEMBLED_RUNTIME_ENV = {
|
||||
HWLAB_RUNTIME_LANE: "v02",
|
||||
HWLAB_RUNTIME_ENDPOINT_LOCKED: "1",
|
||||
HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME: "1",
|
||||
HWLAB_DEVICE_POD_API_KEY: "device-pod-api-key-a"
|
||||
HWLAB_API_KEY: "hwl_live_user-default-key-1234567890"
|
||||
};
|
||||
|
||||
function runAssembledDevicePodCli(argv: string[], options: any = {}) {
|
||||
@@ -91,7 +91,6 @@ test("device-pod-cli admin wrapper calls only cloud-api profile routes", async (
|
||||
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 runDevicePodCli([
|
||||
@@ -124,7 +123,7 @@ test("device-pod-cli lists cloud-api authority and ignores local profile routes"
|
||||
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"], undefined);
|
||||
assert.equal(seen[0].init.headers["x-hwlab-device-pod-api-key"], "device-pod-api-key-a");
|
||||
assert.equal(seen[0].init.headers.authorization, "Bearer hwl_live_user-default-key-1234567890");
|
||||
assert.equal(JSON.stringify(result.payload).includes("gws_local_should_not_be_read"), false);
|
||||
});
|
||||
|
||||
@@ -133,7 +132,7 @@ test("device-pod-cli auto-locates from assembled runtime and rejects manual URL
|
||||
fetchImpl: async (url, init) => {
|
||||
assert.equal(String(url), "http://cloud.test/v1/device-pods");
|
||||
assert.equal(init?.headers?.["x-hwlab-session-token"], undefined);
|
||||
assert.equal(init?.headers?.["x-hwlab-device-pod-api-key"], "device-pod-api-key-a");
|
||||
assert.equal(init?.headers?.authorization, "Bearer hwl_live_user-default-key-1234567890");
|
||||
return jsonResponse(200, { ok: true, devicePods: [] });
|
||||
},
|
||||
now: () => "2026-06-01T00:00:00.000Z"
|
||||
@@ -174,7 +173,7 @@ test("device-pod-cli default transport uses native HTTP for assembled cloud-api
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.equal(seen[0].method, "GET");
|
||||
assert.equal(seen[0].url, "/v1/device-pods");
|
||||
assert.equal(seen[0].headers["x-hwlab-device-pod-api-key"], "device-pod-api-key-a");
|
||||
assert.equal(seen[0].headers.authorization, "Bearer hwl_live_user-default-key-1234567890");
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
@@ -891,7 +890,7 @@ function serverUrl(server: ReturnType<typeof createServer>) {
|
||||
return `http://127.0.0.1:${address.port}`;
|
||||
}
|
||||
|
||||
test("device-pod-cli prefers HWLAB_API_KEY over HWLAB_DEVICE_POD_API_KEY when both are set", async () => {
|
||||
test("device-pod-cli uses HWLAB_API_KEY as a user Bearer token", async () => {
|
||||
const seen: any[] = [];
|
||||
const server = createServer((request, response) => {
|
||||
let chunks: Buffer[] = [];
|
||||
@@ -901,7 +900,6 @@ test("device-pod-cli prefers HWLAB_API_KEY over HWLAB_DEVICE_POD_API_KEY when bo
|
||||
seen.push({
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
devicePodKey: request.headers["x-hwlab-device-pod-api-key"] ?? null,
|
||||
authorization: request.headers["authorization"] ?? null
|
||||
});
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
@@ -918,14 +916,12 @@ test("device-pod-cli prefers HWLAB_API_KEY over HWLAB_DEVICE_POD_API_KEY when bo
|
||||
HWLAB_RUNTIME_LANE: "v02",
|
||||
HWLAB_RUNTIME_ENDPOINT_LOCKED: "1",
|
||||
HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME: "1",
|
||||
HWLAB_DEVICE_POD_API_KEY: "device-pod-api-key-a",
|
||||
HWLAB_API_KEY: "hwl_live_user-default-key-1234567890"
|
||||
},
|
||||
now: () => "2026-06-03T00:00:00.000Z"
|
||||
});
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.equal(seen.length, 1);
|
||||
assert.equal(seen[0].devicePodKey, null);
|
||||
assert.equal(seen[0].authorization, "Bearer hwl_live_user-default-key-1234567890");
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
||||
|
||||
@@ -103,10 +103,6 @@ test("hwlab-cli client access uses Cloud Web admin access routes", async () => {
|
||||
assert.equal(calls[1].init.method, "GET");
|
||||
assert.equal(user.payload.route.path, "/v1/admin/access/users/usr_alice");
|
||||
assert.equal(user.payload.body.user.username, "alice");
|
||||
|
||||
const legacy = await runHwlabCli(["client", "access", "grant", "usr_alice", "device-pod", "device-pod-71-freq", "viewer", "--base-url", "http://web.test"], { cwd });
|
||||
assert.equal(legacy.exitCode, 1);
|
||||
assert.equal(legacy.payload.error.code, "unsupported_access_command");
|
||||
});
|
||||
|
||||
test("hwlab-cli client request reports auth username from the selected session", async () => {
|
||||
|
||||
@@ -124,7 +124,7 @@ test("hwpod-cli submits compiled node ops to hwlab-api when not dry-run", async
|
||||
assert.equal(result.payload.compilerInvocation.compiler, "hwpod-compiler-cli");
|
||||
assert.equal(result.payload.compilerInvocation.mode, "subprocess");
|
||||
assert.equal(seen[0].url, "http://cloud.test/v1/hwpod-node-ops");
|
||||
assert.equal(seen[0].init.headers["x-hwlab-device-pod-api-key"], "hwl_live_test");
|
||||
assert.equal(seen[0].init.headers.authorization, "Bearer hwl_live_test");
|
||||
assert.equal(seen[0].body.contractVersion, "hwpod-node-ops-v1");
|
||||
assert.equal(seen[0].body.source.compiler, "hwpod-compiler-cli");
|
||||
assert.equal(seen[0].body.ops[0].op, "workspace.ls");
|
||||
|
||||
@@ -60,7 +60,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: "preferred HWLAB_API_KEY (hwl_live_...) for AgentRun and CLI; legacy HWLAB_DEVICE_POD_API_KEY or cookie session for transition",
|
||||
auth: "HWLAB_API_KEY (hwl_live_...) for AgentRun and CLI; cookie session only for web-session debugging",
|
||||
},
|
||||
usage: [
|
||||
"hwpod profile list",
|
||||
@@ -561,16 +561,13 @@ 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 userApiKey = text(parsed.apiKey ?? parsed.bearerToken ?? env.HWLAB_API_KEY ?? env.HWLAB_BEARER_TOKEN);
|
||||
const legacyDevicePodApiKey = text(parsed.devicePodApiKey ?? env.HWLAB_DEVICE_POD_API_KEY);
|
||||
const useUserApiKey = userApiKey.startsWith("hwl_live_");
|
||||
const apiKey = useUserApiKey ? "" : legacyDevicePodApiKey;
|
||||
const explicitCookie = text(parsed.cookie ?? env.HWLAB_SESSION_COOKIE);
|
||||
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 = !apiKey && !explicitCookie && !sessionToken && !bearer ? await savedHwlabCliCookie(parsed, env, endpoint) : "";
|
||||
const stateCookie = !userApiKey && !explicitCookie && !sessionToken && !bearer ? await savedHwlabCliCookie(parsed, env, endpoint) : "";
|
||||
const cookie = explicitCookie || stateCookie;
|
||||
return clean({
|
||||
...(apiKey ? { "x-hwlab-device-pod-api-key": apiKey } : {}),
|
||||
...(useUserApiKey ? { authorization: `Bearer ${userApiKey}` } : {}),
|
||||
...(cookie ? { cookie: cookie.includes("=") ? cookie : `hwlab_session=${encodeURIComponent(cookie)}` } : {}),
|
||||
...(sessionToken ? { "x-hwlab-session-token": sessionToken } : {}),
|
||||
|
||||
@@ -424,8 +424,8 @@ async function submitHwpodNodeOpsPlan({ parsed, env, fetchImpl, plan }: { parsed
|
||||
function authHeaders(parsed: ParsedArgs, env: EnvLike) {
|
||||
const headers: Record<string, string> = { "content-type": "application/json" };
|
||||
if (parsed.noAuth === true) return headers;
|
||||
const apiKey = text(parsed.apiKey ?? env.HWLAB_API_KEY ?? env.HWLAB_DEVICE_POD_API_KEY ?? env.HWLAB_CODE_AGENT_DEVICE_POD_API_KEY);
|
||||
if (apiKey) headers["x-hwlab-device-pod-api-key"] = apiKey;
|
||||
const apiKey = text(parsed.apiKey ?? env.HWLAB_API_KEY ?? env.HWLAB_BEARER_TOKEN);
|
||||
if (apiKey) headers.authorization = /^Bearer\s+/iu.test(apiKey) ? apiKey : `Bearer ${apiKey}`;
|
||||
const sessionToken = text(parsed.sessionToken ?? env.HWLAB_CLOUD_API_SESSION_TOKEN ?? env.HWLAB_SESSION_TOKEN);
|
||||
if (sessionToken) headers["x-hwlab-session-token"] = sessionToken;
|
||||
const cookie = text(parsed.cookie ?? env.HWLAB_SESSION_COOKIE);
|
||||
|
||||
Reference in New Issue
Block a user