fix: enforce single hwlab api key env

This commit is contained in:
Codex Agent
2026-06-05 22:33:39 +08:00
parent b11cdc001a
commit cc21ad40cb
8 changed files with 87 additions and 9 deletions
+5
View File
@@ -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 sessionCLI 保护命令缺少 `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)。
+2 -1
View File
@@ -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 <key>`
- `hwlab-cli` 默认且唯一`HWLAB_API_KEY` 读取 key,并发送 `Authorization: Bearer <key>``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。
+3 -3
View File
@@ -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
@@ -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", () => {
+23
View File
@@ -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 });
+24
View File
@@ -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 });
}
});
+14 -1
View File
@@ -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 "";
+15 -1
View File
@@ -563,7 +563,7 @@ 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_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<string, string>; body: string; timeoutMs: number }) {
return new Promise<any>((resolve, reject) => {
const url = new URL(urlValue);