From d6769bf1d3d409e793e52c4e420a74316f1e8714 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Fri, 5 Jun 2026 14:00:13 +0800 Subject: [PATCH] fix: remove v02 authorization residue --- deploy/deploy.json | 1 - .../reference/agentrun-code-agent-dispatch.md | 4 +- docs/reference/spec-device-pod.md | 6 +- docs/reference/spec-user-access.md | 25 ++----- docs/reference/spec-v02-auth.md | 6 +- docs/reference/spec-v02-cicd.md | 2 +- docs/reference/spec-v02-hwlab-cli.md | 2 +- docs/reference/spec-v02-hwlab-cloud-api.md | 2 +- .../spec-v02-openfga-authorization.md | 15 ++-- internal/cloud/access-control.test.ts | 57 +++++---------- internal/cloud/access-control.ts | 72 +++---------------- internal/cloud/code-agent-agentrun-adapter.ts | 4 +- internal/cloud/openfga-authorization.test.ts | 20 +++--- internal/cloud/openfga-authorization.ts | 43 +++++++++-- internal/cloud/server-agent-chat.test.ts | 15 +--- .../migrations/0001_cloud_core_skeleton.sql | 2 - internal/db/schema.test.ts | 2 - .../dev-entrypoint/cloud-web-routes.test.mjs | 2 +- .../dev-entrypoint/cloud-web-runtime.test.mjs | 2 +- tools/device-pod-cli.test.ts | 14 ++-- tools/hwlab-cli/client.test.ts | 4 -- tools/hwpod-harness.test.ts | 2 +- tools/src/device-pod-cli-lib.ts | 7 +- tools/src/hwpod-harness-lib.ts | 4 +- 24 files changed, 117 insertions(+), 196 deletions(-) diff --git a/deploy/deploy.json b/deploy/deploy.json index 6675b904..64d8db88 100644 --- a/deploy/deploy.json +++ b/deploy/deploy.json @@ -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" } }, diff --git a/docs/reference/agentrun-code-agent-dispatch.md b/docs/reference/agentrun-code-agent-dispatch.md index 76ae79d8..9eb7aae4 100644 --- a/docs/reference/agentrun-code-agent-dispatch.md +++ b/docs/reference/agentrun-code-agent-dispatch.md @@ -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 diff --git a/docs/reference/spec-device-pod.md b/docs/reference/spec-device-pod.md index 691e21a7..656921e2 100644 --- a/docs/reference/spec-device-pod.md +++ b/docs/reference/spec-device-pod.md @@ -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 ` 和 `:workspace:/ bootsharp` 都转换为 `workspace.bootsharp` job,用于返回 workspace tree、AGENTS.md 摘要和当前路径提示;该入口是上下文恢复和 DS 派单的首个探测动作,不读取本地 profile。 - workspace 写操作覆盖 `apply-patch`、`put`、`rm`、`rmdir`、`build` 和 `keil add-source/remove-source` 等 Keil 工程维护动作。 diff --git a/docs/reference/spec-user-access.md b/docs/reference/spec-user-access.md index 82a7b979..b4356d73 100644 --- a/docs/reference/spec-user-access.md +++ b/docs/reference/spec-user-access.md @@ -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 权限。 | diff --git a/docs/reference/spec-v02-auth.md b/docs/reference/spec-v02-auth.md index 2f2b9117..c4c3163b 100644 --- a/docs/reference/spec-v02-auth.md +++ b/docs/reference/spec-v02-auth.md @@ -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。 ## 规格的实现情况 diff --git a/docs/reference/spec-v02-cicd.md b/docs/reference/spec-v02-cicd.md index d0af0ef3..d8672e59 100644 --- a/docs/reference/spec-v02-cicd.md +++ b/docs/reference/spec-v02-cicd.md @@ -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 必须包含: diff --git a/docs/reference/spec-v02-hwlab-cli.md b/docs/reference/spec-v02-hwlab-cli.md index e2303b9c..d7d5e971 100644 --- a/docs/reference/spec-v02-hwlab-cli.md +++ b/docs/reference/spec-v02-hwlab-cli.md @@ -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//.json`。切换到其他账号再切回原账号时,`client workbench restore/status` 必须从服务端账号 workspace 恢复之前的 `workspaceId`、`conversationId`、`sessionId`、`threadId`、`activeTraceId` 和 revision,而不是只依赖本地文件。 - `client workbench restore/status/watch/reset` 是账号 workspace 的非视觉入口:`restore/status` 对应 `GET /v1/workbench/workspace`,`watch` 对应 `/events?afterRevision=`,`reset --confirm` 对应服务端 reset。输出必须显示 workspace revision、selected conversation/session、active trace 和本地 state file,且不得保存 password、session token 原文以外的 Secret 值。 diff --git a/docs/reference/spec-v02-hwlab-cloud-api.md b/docs/reference/spec-v02-hwlab-cloud-api.md index ae3e302f..ac71fead 100644 --- a/docs/reference/spec-v02-hwlab-cloud-api.md +++ b/docs/reference/spec-v02-hwlab-cloud-api.md @@ -6,7 +6,7 @@ - 承担 runtime health、DB readiness、登录鉴权、`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。 ## 内部架构 diff --git a/docs/reference/spec-v02-openfga-authorization.md b/docs/reference/spec-v02-openfga-authorization.md index ac9ce3f3..8d209fe5 100644 --- a/docs/reference/spec-v02-openfga-authorization.md +++ b/docs/reference/spec-v02-openfga-authorization.md @@ -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。 | diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index cac3538d..d9090e15 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -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 })); diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index 1f7cb9c2..0b040776 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -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; diff --git a/internal/cloud/code-agent-agentrun-adapter.ts b/internal/cloud/code-agent-agentrun-adapter.ts index 6fa7ffb9..c90f0854 100644 --- a/internal/cloud/code-agent-agentrun-adapter.ts +++ b/internal/cloud/code-agent-agentrun-adapter.ts @@ -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, diff --git a/internal/cloud/openfga-authorization.test.ts b/internal/cloud/openfga-authorization.test.ts index d0d501f7..47ceb4da 100644 --- a/internal/cloud/openfga-authorization.test.ts +++ b/internal/cloud/openfga-authorization.test.ts @@ -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" ]); }); diff --git a/internal/cloud/openfga-authorization.ts b/internal/cloud/openfga-authorization.ts index b5ce66a7..eff08a6b 100644 --- a/internal/cloud/openfga-authorization.ts +++ b/internal/cloud/openfga-authorization.ts @@ -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) { diff --git a/internal/cloud/server-agent-chat.test.ts b/internal/cloud/server-agent-chat.test.ts index 34528e21..0940aba5 100644 --- a/internal/cloud/server-agent-chat.test.ts +++ b/internal/cloud/server-agent-chat.test.ts @@ -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); diff --git a/internal/db/migrations/0001_cloud_core_skeleton.sql b/internal/db/migrations/0001_cloud_core_skeleton.sql index 6aeab92f..9377fa38 100644 --- a/internal/db/migrations/0001_cloud_core_skeleton.sql +++ b/internal/db/migrations/0001_cloud_core_skeleton.sql @@ -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, diff --git a/internal/db/schema.test.ts b/internal/db/schema.test.ts index b272c1a3..37ddb7e4 100644 --- a/internal/db/schema.test.ts +++ b/internal/db/schema.test.ts @@ -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 () => { diff --git a/internal/dev-entrypoint/cloud-web-routes.test.mjs b/internal/dev-entrypoint/cloud-web-routes.test.mjs index e73c1296..f699f2f3 100644 --- a/internal/dev-entrypoint/cloud-web-routes.test.mjs +++ b/internal/dev-entrypoint/cloud-web-routes.test.mjs @@ -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, diff --git a/internal/dev-entrypoint/cloud-web-runtime.test.mjs b/internal/dev-entrypoint/cloud-web-runtime.test.mjs index 281e1086..7b9d105a 100644 --- a/internal/dev-entrypoint/cloud-web-runtime.test.mjs +++ b/internal/dev-entrypoint/cloud-web-runtime.test.mjs @@ -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 = ""; diff --git a/tools/device-pod-cli.test.ts b/tools/device-pod-cli.test.ts index 9d8f71e8..cef5b083 100644 --- a/tools/device-pod-cli.test.ts +++ b/tools/device-pod-cli.test.ts @@ -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) { 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()))); diff --git a/tools/hwlab-cli/client.test.ts b/tools/hwlab-cli/client.test.ts index 60323ebd..f84c3954 100644 --- a/tools/hwlab-cli/client.test.ts +++ b/tools/hwlab-cli/client.test.ts @@ -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 () => { diff --git a/tools/hwpod-harness.test.ts b/tools/hwpod-harness.test.ts index 9ea8f60c..131e3fd9 100644 --- a/tools/hwpod-harness.test.ts +++ b/tools/hwpod-harness.test.ts @@ -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"); diff --git a/tools/src/device-pod-cli-lib.ts b/tools/src/device-pod-cli-lib.ts index c4fde126..fc745d4a 100644 --- a/tools/src/device-pod-cli-lib.ts +++ b/tools/src/device-pod-cli-lib.ts @@ -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 } : {}), diff --git a/tools/src/hwpod-harness-lib.ts b/tools/src/hwpod-harness-lib.ts index 43f5b7ab..0e1dd6d6 100644 --- a/tools/src/hwpod-harness-lib.ts +++ b/tools/src/hwpod-harness-lib.ts @@ -424,8 +424,8 @@ async function submitHwpodNodeOpsPlan({ parsed, env, fetchImpl, plan }: { parsed function authHeaders(parsed: ParsedArgs, env: EnvLike) { const headers: Record = { "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);