From cc21ad40cbec3e4d53b19dddc1cfd45f96b0f82e Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Fri, 5 Jun 2026 22:33:39 +0800 Subject: [PATCH] fix: enforce single hwlab api key env --- AGENTS.md | 5 ++++ docs/reference/spec-v02-auth.md | 3 ++- docs/reference/spec-v02-hwlab-cli.md | 6 ++--- .../cloud/code-agent-session-registry.test.ts | 4 +--- tools/hwlab-cli/client.test.ts | 23 ++++++++++++++++++ tools/hwpod-harness.test.ts | 24 +++++++++++++++++++ tools/src/hwlab-cli-lib.ts | 15 +++++++++++- tools/src/hwpod-harness-lib.ts | 16 ++++++++++++- 8 files changed, 87 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d4cbebca..0b5557be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,11 @@ HWLAB 是硬件实验室运行面和控制面项目。本文是 agent、指挥 - P0: 短连接 CLI、临时工具、文档和轻量 helper 不套用常驻服务、镜像、Job、GitOps、PR、CI/CD 或重型发布流程的旧门禁;如果旧流程要求与最新架构定位冲突,以最新架构定位为准并删除旧流程入口。 - P0: 任何测试、预检或自检只允许表达当前最新目标行为;旧历史断言不得作为回归保护保留,避免把旧路线固化成长期摩擦。 +## P0 CLI 鉴权边界 + +- P0: HWLAB CLI 和 AgentRun/HWPOD runner 只允许从 `HWLAB_API_KEY` 读取用户 API key;不得新增或复活 `API_KEY`、`HWLAB_BEARER_TOKEN`、`--api-key`、`--bearer-token` 等别名入口。HTTP `Authorization: Bearer` 只是 CLI 从 `HWLAB_API_KEY` 生成的协议 header,不是第二个配置来源。 +- P0: Web 只走 `hwlab_session` Web session;CLI 保护命令缺少 `HWLAB_API_KEY` 时必须返回 `api_key_required` 或 `unsupported_api_key_source`,不得回退 Web session、cookie、password login 或 Keycloak token。 + ## P0 G14 原生 k8s/GitOps 运行面归一 - G14 是当前 HWLAB DEV/PROD 原生 k8s 与 GitOps 运行面真相;`hwlab-dev` 与 `hwlab-prod` 均由 G14 k3s、Tekton、`G14-gitops` 和 Argo CD 管理,详见 [docs/reference/g14-gitops-cicd.md](docs/reference/g14-gitops-cicd.md)。 diff --git a/docs/reference/spec-v02-auth.md b/docs/reference/spec-v02-auth.md index 1ace5582..63ef8076 100644 --- a/docs/reference/spec-v02-auth.md +++ b/docs/reference/spec-v02-auth.md @@ -189,7 +189,8 @@ bun tools/hwlab-cli/bin/hwlab-cli.ts client auth whoami 约束: - CLI 不跳转浏览器,不依赖 Web cookie,不要求 username/password 交互。 -- `hwlab-cli` 默认从 `HWLAB_API_KEY` 读取 key,并发送 `Authorization: Bearer `。 +- `hwlab-cli` 默认且唯一从 `HWLAB_API_KEY` 读取 key,并发送 `Authorization: Bearer `;`Authorization: Bearer` 是 HTTP 协议 header,不是第二个用户配置来源。 +- `HWLAB_API_KEY` 是 CLI 和 AgentRun/HWPOD runner 唯一用户 API key 环境变量;不得支持或新增 `API_KEY`、`HWLAB_BEARER_TOKEN`、`--api-key`、`--bearer-token` 等别名。发现旧别名时必须返回结构化 `unsupported_api_key_source`,不能静默忽略或降级到 Web session。 - 受保护的 CLI 业务命令默认只接受 API key;`.state/hwlab-cli/session.json`、profile cookie 和 `client auth login` 产生的 Web session 不参与默认请求鉴权。Web session 只允许用于 `client auth session --web-session`、`client auth logout --web-session` 或显式 `--cookie`/`HWLAB_SESSION_COOKIE` 的浏览器同路径诊断,CLI 输出必须用 `authMethod=api-key|web-session` 和 `requiredAuthMethod=api-key|web-session` 暴露实际边界。 - AgentRun runner 的 transient env 只允许注入映射到当前 Code Agent session owner 的 `HWLAB_API_KEY`;可以使用该用户默认 key,也可以使用同一 `api_keys` 表中为该用户创建的 runner 专用 key,但绝不能使用跨用户共享系统 key 或 Keycloak token。 - `client auth status` 必须显示 endpoint、是否检测到 `HWLAB_API_KEY`、key prefix 和用户摘要;不得输出完整 API key。 diff --git a/docs/reference/spec-v02-hwlab-cli.md b/docs/reference/spec-v02-hwlab-cli.md index 979b4f9b..5783f730 100644 --- a/docs/reference/spec-v02-hwlab-cli.md +++ b/docs/reference/spec-v02-hwlab-cli.md @@ -6,7 +6,7 @@ 标准调用入口是直接使用 Bun 运行 TypeScript 入口:`bun tools/hwlab-cli/bin/hwlab-cli.ts client ...`。不要把 `node scripts/run-bun.mjs ...` 作为 v0.2 手动验收入口;该 wrapper 只保留历史兼容价值,曾经在远端交互中输出 wrapper usage 并遮蔽真实 CLI 行为。长期文档、issue 复现步骤和手动验收命令都应使用直接 Bun 入口。 -登录鉴权目标见 [spec-v02-auth.md](spec-v02-auth.md):CLI 必须是一等纯 CLI 体验,默认从环境变量 `HWLAB_API_KEY` 读取用户 API key,并发送 `Authorization: Bearer hwl_live_...`。受保护的 `client request`、`client access`、`client agent`、provider 管理和 workbench 命令不得默认复用 `.state/hwlab-cli/session.json`、profile cookie 或 `client auth login` 产生的 Web session;缺少 API key 时应返回结构化 `api_key_required`,同时在 `auth.requiredAuthMethod=api-key`、`auth.webSessionIgnoredReason=cli_requires_api_key` 和 `client auth status.authBoundary` 中暴露边界。`client auth session --web-session`、`client auth logout --web-session` 和显式 `--cookie`/`HWLAB_SESSION_COOKIE` 只作为浏览器同路径诊断入口;后续目标验收不得要求 CLI 打开浏览器、跳转 Web 或输入 Keycloak 密码。 +登录鉴权目标见 [spec-v02-auth.md](spec-v02-auth.md):CLI 必须是一等纯 CLI 体验,默认且唯一从环境变量 `HWLAB_API_KEY` 读取用户 API key,并发送 `Authorization: Bearer hwl_live_...`。`Authorization: Bearer` 是 HTTP 协议 header,不是第二个配置入口;不得支持或新增 `API_KEY`、`HWLAB_BEARER_TOKEN`、`--api-key`、`--bearer-token` 等别名。受保护的 `client request`、`client access`、`client agent`、provider 管理和 workbench 命令不得默认复用 `.state/hwlab-cli/session.json`、profile cookie 或 `client auth login` 产生的 Web session;缺少 API key 时应返回结构化 `api_key_required`,遇到 API key 别名时应返回结构化 `unsupported_api_key_source`,同时在 `auth.requiredAuthMethod=api-key`、`auth.webSessionIgnoredReason=cli_requires_api_key` 和 `client auth status.authBoundary` 中暴露边界。`client auth session --web-session`、`client auth logout --web-session` 和显式 `--cookie`/`HWLAB_SESSION_COOKIE` 只作为浏览器同路径诊断入口;后续目标验收不得要求 CLI 打开浏览器、跳转 Web 或输入 Keycloak 密码。 正式复现和验收必须通过运行时装配解析 endpoint,而不是在命令里手动传 URL。标准环境是 `HWLAB_RUNTIME_NAMESPACE=hwlab-v02`、`HWLAB_RUNTIME_LANE=v02`、`HWLAB_RUNTIME_ENDPOINT_LOCKED=1` 和 `HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME=1`;CLI 输出必须包含 `runtimeEndpoint.source=runtime-namespace`、`runtimeEndpoint.explicitOverride=false` 和解析出的 `baseUrl`。`--base-url`、`--api-base-url`、`HWLAB_CLIENT_BASE_URL` 或等价显式 URL 只允许在本地 debug 且未设置 endpoint locked 时使用;issue 复现、最终验收、Web 等价 CLI、AgentRun runner 和 `hwpod` 都不得靠人工判断 17666/19666/19667。 @@ -61,7 +61,7 @@ Code Agent session 是显式资源,不再由普通 `client agent send`、Workb - `tools/hwlab-cli/bin/hwlab-cli.ts` 是唯一可执行入口,使用 Bun 运行。 - `tools/src/hwlab-cli-lib.ts` 承载参数解析、cookie jar、HTTP 请求、轮询和 JSON 输出。 - `tools/hwlab-cli/bin/hwlab-cli.mjs` 只允许作为兼容 shim 调用 Bun TS 入口;新功能不得继续写入 `.mjs` 旧库。 -- 目标认证状态来自 `HWLAB_API_KEY` 环境变量;CLI 不默认把用户 API key 写入 `.state/hwlab-cli/session.json`。本地状态只保存 base URL、cookie、actor 摘要和过期时间,不保存 password、完整 API key 或 Secret 原文。 +- 目标认证状态只来自 `HWLAB_API_KEY` 环境变量;CLI 不默认把用户 API key 写入 `.state/hwlab-cli/session.json`,也不接受 API key 环境变量或 flag 别名。本地状态只保存 base URL、cookie、actor 摘要和过期时间,不保存 password、完整 API key 或 Secret 原文。 - 所有 `client` 子命令都是短连接;长耗时 Code Agent 只提交 async request 并短轮询 result/trace,单次命令必须有 timeout 和中间状态输出。 ## API 接口说明 @@ -109,7 +109,7 @@ Code Agent session 是显式资源,不再由普通 `client agent send`、Workb ## T1 -阅读 docs/reference/spec-v02-hwlab-cli.md 和 docs/reference/spec-v02-auth.md,然后在 `G14:/root/hwlab-v02` 或当前 v0.2 worktree 用 cli 手动测试以下内容:先设置 `HWLAB_RUNTIME_NAMESPACE=hwlab-v02 HWLAB_RUNTIME_LANE=v02 HWLAB_RUNTIME_ENDPOINT_LOCKED=1 HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME=1 HWLAB_API_KEY=<用户API_KEY>`,再运行 `bun tools/hwlab-cli/bin/hwlab-cli.ts client auth whoami`,确认返回 JSON、HTTP 200、当前 actor 摘要、`authMethod=api-key`、`runtimeEndpoint.source=runtime-namespace` 和 `runtimeEndpoint.explicitOverride=false`,输出不包含完整 API key、password 或 Secret 原文。随后去掉 `HWLAB_API_KEY` 运行 `client request GET /v1/users/me`,即使本地存在 Web session state,也必须返回 `api_key_required`,不得自动登录或发送 cookie。 +阅读 docs/reference/spec-v02-hwlab-cli.md 和 docs/reference/spec-v02-auth.md,然后在 `G14:/root/hwlab-v02` 或当前 v0.2 worktree 用 cli 手动测试以下内容:先设置 `HWLAB_RUNTIME_NAMESPACE=hwlab-v02 HWLAB_RUNTIME_LANE=v02 HWLAB_RUNTIME_ENDPOINT_LOCKED=1 HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME=1 HWLAB_API_KEY=<用户API_KEY>`,再运行 `bun tools/hwlab-cli/bin/hwlab-cli.ts client auth whoami`,确认返回 JSON、HTTP 200、当前 actor 摘要、`authMethod=api-key`、`runtimeEndpoint.source=runtime-namespace` 和 `runtimeEndpoint.explicitOverride=false`,输出不包含完整 API key、password 或 Secret 原文。随后去掉 `HWLAB_API_KEY` 运行 `client request GET /v1/users/me`,即使本地存在 Web session state,也必须返回 `api_key_required`,不得自动登录或发送 cookie。最后设置任意 API key 别名或传入 API key flag,必须返回 `unsupported_api_key_source`,不能发送请求。 ## T2 diff --git a/internal/cloud/code-agent-session-registry.test.ts b/internal/cloud/code-agent-session-registry.test.ts index d2116c87..c7749447 100644 --- a/internal/cloud/code-agent-session-registry.test.ts +++ b/internal/cloud/code-agent-session-registry.test.ts @@ -222,8 +222,7 @@ test("Codex child env carries only HWPOD runtime API key needed by Code Agent to HWLAB_RUNTIME_API_URL: "http://127.0.0.1:6667", HWLAB_API_KEY: "hwl_live_user-default-key", HWLAB_SESSION_COOKIE: "hwlab_session=browser-cookie", - HWLAB_SESSION_TOKEN: "browser-session-token", - HWLAB_BEARER_TOKEN: "browser-bearer-token" + HWLAB_SESSION_TOKEN: "browser-session-token" }); assert.equal(child.HWLAB_CLOUD_API_URL, "http://127.0.0.1:6667"); @@ -232,7 +231,6 @@ test("Codex child env carries only HWPOD runtime API key needed by Code Agent to assert.equal(child.OPENAI_API_KEY, undefined); assert.equal(child.HWLAB_SESSION_COOKIE, undefined); assert.equal(child.HWLAB_SESSION_TOKEN, undefined); - assert.equal(child.HWLAB_BEARER_TOKEN, undefined); }); test("code agent session registry inspect locates conversation, session, and trace summary", () => { diff --git a/tools/hwlab-cli/client.test.ts b/tools/hwlab-cli/client.test.ts index 73af994d..fadce736 100644 --- a/tools/hwlab-cli/client.test.ts +++ b/tools/hwlab-cli/client.test.ts @@ -161,6 +161,29 @@ test("hwlab-cli client request defaults to API key instead of selected Web sessi assert.equal(result.payload.auth.authMethod, "api-key"); }); +test("hwlab-cli rejects API key aliases and requires HWLAB_API_KEY", async () => { + const aliasFromEnv = await runHwlabCli(["client", "auth", "whoami", "--base-url", "http://web.test"], { + env: { HWLAB_BEARER_TOKEN: "hwl_live_legacy_alias" }, + fetchImpl: async () => { + throw new Error("request should not be sent when an API key alias is present"); + } + }); + assert.equal(aliasFromEnv.exitCode, 1); + assert.equal(aliasFromEnv.payload.error.code, "unsupported_api_key_source"); + assert.deepEqual(aliasFromEnv.payload.error.details.unsupported, ["HWLAB_BEARER_TOKEN"]); + assert.equal(JSON.stringify(aliasFromEnv.payload).includes("hwl_live_legacy_alias"), false); + + const aliasFromFlag = await runHwlabCli(["client", "auth", "whoami", "--base-url", "http://web.test", "--api-key", "hwl_live_flag_alias"], { + fetchImpl: async () => { + throw new Error("request should not be sent when an API key flag alias is present"); + } + }); + assert.equal(aliasFromFlag.exitCode, 1); + assert.equal(aliasFromFlag.payload.error.code, "unsupported_api_key_source"); + assert.deepEqual(aliasFromFlag.payload.error.details.unsupported, ["--api-key"]); + assert.equal(JSON.stringify(aliasFromFlag.payload).includes("hwl_live_flag_alias"), false); +}); + test("hwlab-cli client auth status exposes local session state and next commands", async () => { const cwd = await mkdtemp(path.join(os.tmpdir(), "hwlab-cli-client-auth-status-")); const missing = await runHwlabCli(["client", "auth", "status", "--base-url", "http://web.test"], { cwd }); diff --git a/tools/hwpod-harness.test.ts b/tools/hwpod-harness.test.ts index 631abc38..9208fc7f 100644 --- a/tools/hwpod-harness.test.ts +++ b/tools/hwpod-harness.test.ts @@ -179,3 +179,27 @@ test("hwpod-cli submits compiled node ops to hwlab-api when not dry-run", async await rm(root, { recursive: true, force: true }); } }); + +test("hwpod-cli rejects API key aliases and requires HWLAB_API_KEY", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-cli-api-key-alias-")); + const specPath = path.join(root, ".hwlab", "hwpod-spec.yaml"); + try { + await runHwpodCtl(["spec", "init", "--spec", specPath, "--node", "pc-host-1"], { now: () => NOW }); + const result = await runHwpodCli(["workspace", "ls", "src", "--spec", specPath], { + env: { + HWLAB_RUNTIME_API_URL: "http://cloud.test", + HWLAB_BEARER_TOKEN: "hwl_live_legacy_alias" + }, + fetchImpl: async () => { + throw new Error("request should not be sent when an API key alias is present"); + }, + now: () => NOW + }); + assert.equal(result.exitCode, 1); + assert.equal(result.payload.error.code, "unsupported_api_key_source"); + assert.deepEqual(result.payload.error.details.unsupported, ["HWLAB_BEARER_TOKEN"]); + assert.equal(JSON.stringify(result.payload).includes("hwl_live_legacy_alias"), false); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index 966ba855..dad86cf8 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -2267,11 +2267,24 @@ function explicitAuthCookie(parsed: ParsedArgs, env: EnvLike) { const HWLAB_API_KEY_PREFIX = "hwl_live_"; function explicitApiKey(parsed: ParsedArgs, env: EnvLike) { - const explicit = text(parsed.apiKey ?? parsed.bearerToken ?? env.HWLAB_API_KEY ?? env.HWLAB_BEARER_TOKEN); + assertOnlyHwlabApiKeySource(parsed, env); + const explicit = text(env.HWLAB_API_KEY); if (!explicit) return ""; if (explicit.startsWith(HWLAB_API_KEY_PREFIX)) return explicit; return ""; } +function assertOnlyHwlabApiKeySource(parsed: ParsedArgs, env: EnvLike) { + const unsupported = [ + text(parsed.apiKey) ? "--api-key" : "", + text(parsed.bearerToken) ? "--bearer-token" : "", + text(env.HWLAB_BEARER_TOKEN) ? "HWLAB_BEARER_TOKEN" : "" + ].filter(Boolean); + if (unsupported.length === 0) return; + throw cliError("unsupported_api_key_source", "HWLAB CLI auth uses only HWLAB_API_KEY; remove API key aliases and export HWLAB_API_KEY instead.", { + allowed: "HWLAB_API_KEY", + unsupported + }); +} function apiKeyPrefixSummary(secret) { const text = String(secret ?? ""); if (!text) return ""; diff --git a/tools/src/hwpod-harness-lib.ts b/tools/src/hwpod-harness-lib.ts index 681e1ea4..f34da71d 100644 --- a/tools/src/hwpod-harness-lib.ts +++ b/tools/src/hwpod-harness-lib.ts @@ -563,7 +563,7 @@ 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_BEARER_TOKEN); + const apiKey = hwpodApiKey(parsed, env); if (apiKey) headers.authorization = `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; @@ -572,6 +572,20 @@ function authHeaders(parsed: ParsedArgs, env: EnvLike) { return headers; } +function hwpodApiKey(parsed: ParsedArgs, env: EnvLike) { + const unsupported = [ + text(parsed.apiKey) ? "--api-key" : "", + text(env.HWLAB_BEARER_TOKEN) ? "HWLAB_BEARER_TOKEN" : "" + ].filter(Boolean); + if (unsupported.length > 0) { + throw cliError("unsupported_api_key_source", "HWPOD auth uses only HWLAB_API_KEY; remove API key aliases and export HWLAB_API_KEY instead.", { + allowed: "HWLAB_API_KEY", + unsupported + }); + } + return text(env.HWLAB_API_KEY); +} + function postJsonNative(urlValue: string, { method, headers, body, timeoutMs }: { method: string; headers: Record; body: string; timeoutMs: number }) { return new Promise((resolve, reject) => { const url = new URL(urlValue);