fix: enforce api key auth boundary

This commit is contained in:
Codex Agent
2026-06-05 21:58:11 +08:00
parent 2adc2f678f
commit a3ffd45e55
10 changed files with 193 additions and 104 deletions
+2
View File
@@ -139,6 +139,8 @@
"HWLAB_BOOTSTRAP_ADMIN_USERNAME": "admin",
"HWLAB_BOOTSTRAP_ADMIN_DISPLAY_NAME": "HWLAB v0.2 Admin",
"HWLAB_BOOTSTRAP_ADMIN_PASSWORD_HASH": "secretRef:hwlab-v02-bootstrap-admin/password-hash",
"HWLAB_BOOTSTRAP_ADMIN_API_KEY_ID": "key_master_server_admin",
"HWLAB_BOOTSTRAP_ADMIN_API_KEY": "secretRef:hwlab-v02-master-server-admin-api-key/api-key",
"HWLAB_KEYCLOAK_ISSUER": "https://auth.74-48-78-17.nip.io/realms/hwlab",
"HWLAB_KEYCLOAK_CLIENT_ID": "hwlab-cloud-web",
"HWLAB_KEYCLOAK_CLIENT_SECRET": "secretRef:hwlab-cloud-web-client/client-secret",
+2
View File
@@ -190,11 +190,13 @@ 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>`
- 受保护的 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。
- `client auth whoami``client request GET /v1/users/me` 必须能用 API key 返回与 Web 同一用户的 `AuthPrincipal` 摘要。
- `client auth login --username ...` 和本地 cookie profile 属于 legacy 兼容入口;目标体验不再把它作为一等 CLI 登录。
- `hwpod` 在 runner 中必须自动使用 `HWLAB_RUNTIME_API_URL``HWLAB_API_KEY` 直连 `hwlab-cloud-api`,并让 cloud-api 按该 key 对应用户的 role/grant 做授权。
- v0.2 运行面允许配置一个 master server 管理员 API keySecretRef 固定为 `hwlab-v02-master-server-admin-api-key/api-key`Cloud API 通过 `HWLAB_BOOTSTRAP_ADMIN_API_KEY` 和固定 id `key_master_server_admin` 同步到 bootstrap admin `usr_v02_admin`。该 key 归属 HWLAB 管理员账号,scope 摘要为 `admin``system:hwlab``tool:*`,并在 bootstrap 时授予当前 `HWLAB_TOOL_IDS``can_use` capability。完整 key 只保存在受控 Secret 和 master server 本地 0600 配置文件中,不进入 Git、issue、长期文档、日志、trace 或 CLI 默认输出。
API key 数据结构:
+2 -2
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 auth login --username ...`、本地 cookie session 和 profile cookie 只属于当前实现的 legacy 兼容入口;后续目标验收不得要求 CLI 打开浏览器、跳转 Web 或输入 Keycloak 密码。
登录鉴权目标见 [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 密码。
正式复现和验收必须通过运行时装配解析 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。
@@ -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 原文。
阅读 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。
## T2
+1 -1
View File
@@ -8,7 +8,7 @@ Provider API Key 管理属于 Cloud API 的 authenticated admin surface:前端
- 承担 runtime health、DB readiness、登录鉴权、`AuthPrincipal`、OpenFGA 授权 check/write、用户/session/API key 权限、Code Agent 对话、trace/result 轮询、gateway outbound registry、M3 IO 控制、HWPOD node-ops 转发和 live build inventory。
- 是 `hwlab-cloud-web`、Code Agent session、HWPOD CLI、Admin Access API、AgentRun 工具注入和 gateway outbound poll 的唯一应用层收口点;普通用户不直接访问 OpenFGA Service 或 host 侧 node 资源。
- 读取 `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`,HWPOD 用户态授权只能从该表恢复到用户 actor。文档和日志只允许记录 SecretRef 名称、key、字节数或哈希指纹,不记录值。
- 读取 `hwlab-cloud-api-v02-db/database-url``hwlab-v02-master-server-admin-api-key/api-key``hwlab-v02-code-agent-provider/openai-api-key``hwlab-v02-code-agent-codex-auth/auth.json` 等 v02 独立 SecretRef;用户 API key 存在 Postgres `api_keys`,HWPOD 用户态授权只能从该表恢复到用户 actor。master server 管理员 API key 通过 bootstrap env 同步为 `usr_v02_admin` 的固定 `key_master_server_admin`,只记录 SecretRef、key id、scope 摘要、字节数或哈希指纹,不记录值。
- 读取 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。
## 内部架构
@@ -117,7 +117,7 @@ bun tools/hwlab-cli/bin/hwlab-cli.ts client provider-profiles validate deepseek
约束:
- CLI 使用 `HWLAB_API_KEY` 或已登录 Web session 恢复同一个 HWLAB actor。
- CLI 默认只使用 `HWLAB_API_KEY` 恢复同一个 HWLAB actor;已登录 Web session 只允许作为显式 `--web-session`/`--cookie` 诊断入口,不作为 provider 管理命令的默认鉴权来源
- `set-key --key-stdin` 从 stdin 读取 API Key,默认输出只显示 hash/resourceVersion,不打印 key。
- `validate --wait` 短连接 submit-and-poll,输出 validationId、runId、commandId、jobName、traceId、status、failureKind 和 redacted bridge upstream 摘要。
- 这些命令不得直接调用 AgentRun 内部 URL,不得直接读写 Kubernetes Secret。
+54
View File
@@ -1746,6 +1746,60 @@ test("cloud api accepts user API keys as Bearer auth for the same account", asyn
}
});
test("cloud api bootstraps the master server admin API key from env", async () => {
const apiKey = "hwl_live_master_server_admin_test_key_930";
const openFgaAuthorizer = createFakeOpenFgaAuthorizer();
const accessController = createAccessController({
env: {
HWLAB_ACCESS_CONTROL_REQUIRED: "1",
HWLAB_BOOTSTRAP_ADMIN_ID: "usr_v02_admin",
HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin",
HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass",
HWLAB_BOOTSTRAP_ADMIN_API_KEY: apiKey,
HWLAB_BOOTSTRAP_ADMIN_API_KEY_ID: "key_master_server_admin"
},
openFgaAuthorizer,
now: () => "2026-06-05T00:00:00.000Z"
});
const server = createCloudApiServer({
env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" },
accessController,
now: () => "2026-06-05T00:00:00.000Z"
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const { port } = server.address();
const me = await getJson(port, "/v1/users/me", null, { authorization: `Bearer ${apiKey}` });
assert.equal(me.status, 200);
assert.equal(me.body.authMethod, "api-key");
assert.equal(me.body.actor.id, "usr_v02_admin");
assert.equal(me.body.actor.username, "admin");
assert.equal(me.body.actor.role, "admin");
assert.equal(JSON.stringify(me.body).includes(apiKey), false);
const summary = await getJson(port, "/v1/admin/access/summary", null, { authorization: `Bearer ${apiKey}` });
assert.equal(summary.status, 200);
assert.equal(summary.body.actor.id, "usr_v02_admin");
assert.equal(summary.body.supported.toolIds.includes("hwpod"), true);
for (const toolId of summary.body.supported.toolIds) {
const check = await postJson(port, "/v1/admin/access/check", { userId: "usr_v02_admin", relation: "can_use", object: `tool:${toolId}` }, null, { authorization: `Bearer ${apiKey}` });
assert.equal(check.status, 200);
assert.equal(check.body.authorization.allowed, true, `expected admin key to access ${toolId}`);
}
const keys = await accessController.store.listApiKeysForUser("usr_v02_admin");
const masterKey = keys.find((key) => key.id === "key_master_server_admin");
assert.ok(masterKey);
assert.equal(masterKey.name, "Master server admin API key");
assert.equal(masterKey.displaySecret, null);
assert.deepEqual(masterKey.scopes, ["admin", "system:hwlab", "tool:*"]);
} finally {
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
});
test("cloud api exposes v1 access status routes and returns structured REST errors", async () => {
const server = createCloudApiServer({
env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" },
+30
View File
@@ -31,6 +31,7 @@ function sessionCookieAttributes(maxAge, { secure = false } = {}) {
const API_KEY_PREFIX = "hwl_live_";
const API_KEY_RANDOM_BYTES = 32;
const API_KEY_DEFAULT_NAME = "Default API key";
const ADMIN_BOOTSTRAP_API_KEY_NAME = "Master server admin 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";
@@ -799,6 +800,7 @@ class AccessController {
now: this.now()
});
await this.syncExistingAdminTuples();
await this.ensureBootstrapAdminApiKey();
return;
}
if (!passwordHash) return;
@@ -812,6 +814,34 @@ class AccessController {
now: this.now()
});
await this.syncExistingAdminTuples();
await this.ensureBootstrapAdminApiKey();
}
async ensureBootstrapAdminApiKey() {
const secret = textOr(this.env.HWLAB_BOOTSTRAP_ADMIN_API_KEY, "");
if (!secret) return null;
if (!isApiKeySecret(secret)) throw Object.assign(new Error("HWLAB_BOOTSTRAP_ADMIN_API_KEY must start with hwl_live_"), { statusCode: 500, code: "bootstrap_admin_api_key_invalid" });
const actor = await this.bootstrapAdminActor();
if (!actor || actor.role !== "admin") return null;
const id = textOr(this.env.HWLAB_BOOTSTRAP_ADMIN_API_KEY_ID, "key_master_server_admin");
const prefix = apiKeyPrefixOf(secret);
const hash = hashApiKey(secret);
const key = await this.store.createApiKey?.({
id,
userId: actor.id,
name: ADMIN_BOOTSTRAP_API_KEY_NAME,
keyPrefix: prefix,
keyHash: hash,
displaySecret: null,
scopes: ["admin", "system:hwlab", "tool:*"],
status: "active",
now: this.now()
}) ?? null;
await this.syncUserAdminTuple(actor, actor);
for (const toolId of HWLAB_TOOL_IDS) {
await this.grantAccessTuple({ userId: actor.id, relation: "can_use", object: openFgaObject("tool", toolId), admin: actor });
}
return key;
}
async setupRequired() {
+2
View File
@@ -980,6 +980,8 @@ function transformWorkloads({ workloads, deploy, catalog, source, sourceBranch =
upsertEnv(container.env, "HWLAB_BOOTSTRAP_ADMIN_USERNAME", "admin");
upsertEnv(container.env, "HWLAB_BOOTSTRAP_ADMIN_DISPLAY_NAME", "HWLAB v0.2 Admin");
upsertEnvEntry(container.env, deployEnvEntry("HWLAB_BOOTSTRAP_ADMIN_PASSWORD_HASH", "secretRef:hwlab-v02-bootstrap-admin/password-hash", namespace, profile));
upsertEnv(container.env, "HWLAB_BOOTSTRAP_ADMIN_API_KEY_ID", "key_master_server_admin");
upsertEnvEntry(container.env, deployEnvEntry("HWLAB_BOOTSTRAP_ADMIN_API_KEY", "secretRef:hwlab-v02-master-server-admin-api-key/api-key", namespace, profile));
upsertEnv(container.env, "HWLAB_OPENFGA_MODE", "enforce");
upsertEnv(container.env, "HWLAB_OPENFGA_API_URL", "http://hwlab-openfga.hwlab-v02.svc.cluster.local:8080");
upsertEnvEntry(container.env, deployEnvEntry("HWLAB_OPENFGA_AUTHN_TOKEN", "secretRef:hwlab-v02-openfga/authn-preshared-key", namespace, profile));
+39 -57
View File
@@ -45,16 +45,10 @@ test("hwlab-cli client login uses Cloud Web auth and stores only cookie session"
assert.equal(JSON.stringify(session).includes("secret-password"), false);
});
test("hwlab-cli client access uses Cloud Web admin access routes", async () => {
const cwd = await mkdtemp(path.join(os.tmpdir(), "hwlab-cli-client-access-"));
await runHwlabCli(["client", "auth", "login", "--base-url", "http://web.test", "--username", "admin", "--password", "pw"], {
cwd,
fetchImpl: async () => new Response(JSON.stringify({ authenticated: true, user: { id: "usr_admin", username: "admin", role: "admin" } }), { status: 200, headers: { "set-cookie": "hwlab_session=session-admin; Path=/" } })
});
test("hwlab-cli client access uses API key auth on Cloud Web admin access routes", async () => {
const calls: any[] = [];
const grant = await runHwlabCli(["client", "access", "tools", "grant", "usr_alice", "hwpod", "--base-url", "http://web.test"], {
cwd,
env: { HWLAB_API_KEY: "hwl_live_admin_test_key" },
fetchImpl: async (url, init) => {
calls.push({ url: String(url), init, body: String(init?.body ?? "") });
return new Response(JSON.stringify({ ok: true, changed: true, access: { user: { id: "usr_alice" }, tools: {}, tuples: [], openfga: { mode: "enforce", ready: true } } }), { status: 201 });
@@ -64,12 +58,14 @@ test("hwlab-cli client access uses Cloud Web admin access routes", async () => {
assert.equal(grant.payload.action, "client.access.tools.grant");
assert.equal(calls[0].url, "http://web.test/v1/admin/access/users/usr_alice/tools/hwpod/can-use");
assert.equal(calls[0].init.method, "PUT");
assert.equal(calls[0].init.headers.cookie, "hwlab_session=session-admin");
assert.equal(calls[0].init.headers.authorization, "Bearer hwl_live_admin_test_key");
assert.equal(calls[0].init.headers.cookie, undefined);
assert.equal(grant.payload.auth.authMethod, "api-key");
assert.equal(grant.payload.route.path, "/v1/admin/access/users/usr_alice/tools/hwpod/can-use");
assert.equal(grant.payload.body.openfga.mode, "enforce");
const user = await runHwlabCli(["client", "access", "users", "inspect", "usr_alice", "--base-url", "http://web.test"], {
cwd,
env: { HWLAB_API_KEY: "hwl_live_admin_test_key" },
fetchImpl: async (url, init) => {
calls.push({ url: String(url), init });
return new Response(JSON.stringify({ contractVersion: "admin-access-v1", user: { id: "usr_alice", username: "alice" }, tools: {}, tuples: [], openfga: { mode: "enforce", ready: true } }), { status: 200 });
@@ -78,6 +74,7 @@ test("hwlab-cli client access uses Cloud Web admin access routes", async () => {
assert.equal(user.exitCode, 0);
assert.equal(calls[1].url, "http://web.test/v1/admin/access/users/usr_alice");
assert.equal(calls[1].init.method, "GET");
assert.equal(calls[1].init.headers.authorization, "Bearer hwl_live_admin_test_key");
assert.equal(user.payload.route.path, "/v1/admin/access/users/usr_alice");
assert.equal(user.payload.body.user.username, "alice");
});
@@ -148,25 +145,20 @@ test("hwlab-cli client provider-profiles validate can wait by polling validation
assert.equal(result.payload.body.validation.jobName, "agentrun-v01-runner-test");
});
test("hwlab-cli client request reports auth username from the selected session", async () => {
const cwd = await mkdtemp(path.join(os.tmpdir(), "hwlab-cli-client-profile-user-"));
test("hwlab-cli client request defaults to API key instead of selected Web session", async () => {
const calls: any[] = [];
await runHwlabCli(["client", "auth", "login", "--base-url", "http://web.test", "--profile", "alice", "--username", "alice", "--password", "pw"], {
cwd,
fetchImpl: async () => new Response(JSON.stringify({ authenticated: true, user: { id: "usr_alice", username: "alice", role: "user" } }), { status: 200, headers: { "set-cookie": "hwlab_session=session-alice; Path=/" } })
});
const result = await runHwlabCli(["client", "request", "GET", "/v1/access/status", "--base-url", "http://web.test", "--profile", "alice"], {
cwd,
env: { HWLAB_API_KEY: "hwl_live_alice_test_key" },
fetchImpl: async (url, init) => {
calls.push({ url: String(url), init });
return new Response(JSON.stringify({ ok: true, authenticated: true, actor: { username: "alice" } }), { status: 200 });
return new Response(JSON.stringify({ ok: true, authenticated: true, actor: { username: "alice" }, authMethod: "api-key" }), { status: 200 });
}
});
assert.equal(result.exitCode, 0);
assert.equal(calls[0].init.headers.cookie, "hwlab_session=session-alice");
assert.equal(result.payload.auth.username, "alice");
assert.equal(result.payload.auth.localSession.user.username, "alice");
assert.equal(calls[0].init.headers.authorization, "Bearer hwl_live_alice_test_key");
assert.equal(calls[0].init.headers.cookie, undefined);
assert.equal(result.payload.auth.authMethod, "api-key");
});
test("hwlab-cli client auth status exposes local session state and next commands", async () => {
@@ -177,7 +169,9 @@ test("hwlab-cli client auth status exposes local session state and next commands
assert.equal(missing.payload.localSession.exists, false);
assert.equal(missing.payload.localSession.usable, false);
assert.equal(missing.payload.localSession.ignoredReason, "state_file_missing");
assert.match(missing.payload.nextCommands[1], /client auth login/u);
assert.equal(missing.payload.authBoundary.defaultAuthMethod, "api-key");
assert.equal(missing.payload.authBoundary.localSessionDefault, false);
assert.match(missing.payload.nextCommands[1], /client auth whoami/u);
await runHwlabCli(["client", "auth", "login", "--base-url", "http://old-web.test", "--username", "admin", "--password", "pw"], {
cwd,
@@ -189,11 +183,11 @@ test("hwlab-cli client auth status exposes local session state and next commands
assert.equal(mismatch.payload.localSession.usable, false);
assert.equal(mismatch.payload.localSession.ignoredReason, "base_url_mismatch");
assert.equal(JSON.stringify(mismatch.payload).includes("session-old"), false);
assert.match(mismatch.payload.nextCommands[1], /^bun tools\/hwlab-cli\/bin\/hwlab-cli\.ts client auth login/u);
assert.match(mismatch.payload.nextCommands[1], /^bun tools\/hwlab-cli\/bin\/hwlab-cli\.ts client auth whoami/u);
assert.equal(JSON.stringify(mismatch.payload.nextCommands).includes("run-bun.mjs"), false);
});
test("hwlab-cli reuses public v02 session when runtime namespace resolves host endpoint", async () => {
test("hwlab-cli auth status reports public v02 session but keeps it out of default CLI auth", async () => {
const cwd = await mkdtemp(path.join(os.tmpdir(), "hwlab-cli-client-runtime-scope-"));
await runHwlabCli(["client", "auth", "login", "--base-url", "http://74.48.78.17:19666", "--username", "admin", "--password", "pw"], {
cwd,
@@ -216,6 +210,8 @@ test("hwlab-cli reuses public v02 session when runtime namespace resolves host e
assert.equal(status.payload.localSession.ignoredReason, null);
assert.equal(status.payload.localSession.baseUrlMatch, "exact");
assert.equal(status.payload.localSession.baseUrl, "http://74.48.78.17:19666");
assert.equal(status.payload.authBoundary.defaultAuthMethod, "api-key");
assert.equal(status.payload.authBoundary.localSessionDefault, false);
});
test("hwlab-cli resolves runtime namespace to service DNS inside kubernetes", async () => {
@@ -1768,7 +1764,7 @@ test("hwlab-cli Web trace render keeps the full final-response body without trun
assert.equal(terminalRow.body?.includes("..."), false);
});
test("hwlab-cli client agent trace auto logs in when protected trace has no local session", async () => {
test("hwlab-cli client agent trace does not auto login with Web session credentials", async () => {
const cwd = await mkdtemp(path.join(os.tmpdir(), "hwlab-cli-client-auth-"));
const calls: any[] = [];
const result = await runHwlabCli([
@@ -1785,29 +1781,23 @@ test("hwlab-cli client agent trace auto logs in when protected trace has no loca
env: { HWLAB_PASSWORD: "secret-password" },
fetchImpl: async (url, init) => {
calls.push({ url: String(url), init, body: init?.body ? JSON.parse(String(init.body)) : null });
if (String(url).endsWith("/auth/login")) {
return new Response(JSON.stringify({ authenticated: true, user: { username: "admin" } }), {
status: 200,
headers: { "set-cookie": "hwlab_session=session-auto; Path=/; HttpOnly" }
});
}
return new Response(JSON.stringify({ status: "completed", traceId: "trc_protected", events: [] }), { status: 200 });
return new Response(JSON.stringify({ ok: false, error: { code: "auth_required" } }), { status: 401 });
},
now: () => "2026-05-31T07:20:00.000Z"
});
assert.equal(result.exitCode, 0);
assert.equal(result.exitCode, 1);
assert.deepEqual(calls.map((call) => call.url), [
"http://web.test/auth/login",
"http://web.test/v1/agent/chat/trace/trc_protected"
]);
assert.equal(calls[1].init.headers.cookie, "hwlab_session=session-auto");
assert.equal(calls[0].init.headers.cookie, undefined);
assert.equal(calls[0].init.headers.authorization, undefined);
assert.equal(result.payload.auth.requiredAuthMethod, "api-key");
assert.equal(result.payload.authDiagnosis.code, "api_key_required");
assert.equal(JSON.stringify(result.payload).includes("secret-password"), false);
const session = JSON.parse(await readFile(path.join(cwd, ".state/hwlab-cli/session.json"), "utf8"));
assert.equal(session.cookie, "hwlab_session=session-auto");
});
test("hwlab-cli client agent result refreshes expired session after 401 and reports compact result", async () => {
test("hwlab-cli client agent result ignores stored Web session by default after 401", async () => {
const cwd = await mkdtemp(path.join(os.tmpdir(), "hwlab-cli-client-auth-refresh-"));
await runHwlabCli(["client", "auth", "login", "--base-url", "http://web.test", "--username", "admin", "--password", "old"], {
cwd,
@@ -1828,31 +1818,22 @@ test("hwlab-cli client agent result refreshes expired session after 401 and repo
env: { HWLAB_PASSWORD: "new-password" },
fetchImpl: async (url, init) => {
calls.push({ url: String(url), init, body: init?.body ? JSON.parse(String(init.body)) : null });
if (String(url).endsWith("/auth/login")) {
return new Response(JSON.stringify({ authenticated: true, user: { username: "admin" } }), {
status: 200,
headers: { "set-cookie": "hwlab_session=session-new; Path=/" }
});
}
if (init?.headers?.cookie === "hwlab_session=session-old") {
return new Response(JSON.stringify({ error: { code: "auth_required" } }), { status: 401 });
}
return new Response(JSON.stringify({ status: "completed", traceId: "trc_refresh", reply: { role: "assistant", content: "done" } }), { status: 200 });
return new Response(JSON.stringify({ error: { code: "auth_required" } }), { status: 401 });
},
sleep: async () => {},
now: () => "2026-05-31T07:21:00.000Z"
});
assert.equal(result.exitCode, 0);
assert.equal(result.exitCode, 1);
assert.deepEqual(calls.map((call) => call.url), [
"http://web.test/v1/agent/chat/result/trc_refresh",
"http://web.test/auth/login",
"http://web.test/v1/agent/chat/result/trc_refresh"
]);
assert.equal(calls[0].init.headers.cookie, "hwlab_session=session-old");
assert.equal(calls[2].init.headers.cookie, "hwlab_session=session-new");
assert.equal(calls[0].init.headers.cookie, undefined);
assert.equal(result.payload.auth.requiredAuthMethod, "api-key");
assert.equal(result.payload.auth.webSessionIgnoredReason, "cli_requires_api_key");
assert.equal(result.payload.auth.localSession.usable, true);
assert.equal(result.payload.action, "client.agent.result");
assert.equal(result.payload.body.assistantText, "done");
assert.equal(result.payload.authDiagnosis.code, "api_key_required");
});
test("hwlab-cli client protected agent commands return structured auth diagnosis for forbidden trace", async () => {
@@ -1866,7 +1847,7 @@ test("hwlab-cli client protected agent commands return structured auth diagnosis
assert.equal(result.payload.authDiagnosis.cookieSource, "explicit");
assert.match(result.payload.authDiagnosis.message, /访/u);
assert.equal(result.payload.request.url, "http://web.test/v1/agent/chat/trace/trc_forbidden");
assert.match(result.payload.authDiagnosis.nextCommands[1], /client auth login/u);
assert.match(result.payload.authDiagnosis.nextCommands[1], /client auth whoami/u);
});
test("hwlab-cli client protected agent command reports missing credentials with state visibility", async () => {
@@ -1880,9 +1861,10 @@ test("hwlab-cli client protected agent command reports missing credentials with
assert.equal(result.payload.action, "client.agent.result");
assert.equal(result.payload.auth.required, true);
assert.equal(result.payload.auth.localSession.exists, false);
assert.equal(result.payload.authDiagnosis.code, "auth_credentials_missing");
assert.equal(result.payload.auth.requiredAuthMethod, "api-key");
assert.equal(result.payload.authDiagnosis.code, "api_key_required");
assert.equal(result.payload.authDiagnosis.localSession.ignoredReason, "state_file_missing");
assert.match(result.payload.authDiagnosis.nextCommands[1], /--password-env HWLAB_PASSWORD/u);
assert.match(result.payload.authDiagnosis.nextCommands[0], /HWLAB_API_KEY/u);
assert.equal(result.payload.request.httpStatus, 401);
});
+60 -43
View File
@@ -111,10 +111,10 @@ function help() {
imagePublished: false,
jobTemplate: false,
usage: [
"HWLAB_RUNTIME_NAMESPACE=hwlab-v02 hwlab-cli client auth login --username USER --password-env HWLAB_PASSWORD",
"HWLAB_RUNTIME_NAMESPACE=hwlab-v02 hwlab-cli client auth login --profile NAME --username USER --password-env HWLAB_PASSWORD",
"HWLAB_API_KEY=hwl_live_... HWLAB_RUNTIME_NAMESPACE=hwlab-v02 hwlab-cli client auth whoami",
"HWLAB_API_KEY=hwl_live_... HWLAB_RUNTIME_NAMESPACE=hwlab-v02 hwlab-cli client request GET /v1/users/me",
"hwlab-cli client auth status",
"hwlab-cli client auth session",
"hwlab-cli client auth session --web-session",
"hwlab-cli client auth profiles",
"hwlab-cli client access summary",
"hwlab-cli client access users list",
@@ -182,11 +182,11 @@ async function authCommand(context: any) {
}
if (subcommand === "session") {
const localSession = sessionStateSummary(await readSessionState({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() }));
const response = await requestJson({ ...context, method: "GET", path: "/auth/session" });
const response = await requestJson({ ...context, method: "GET", path: "/auth/session", allowWebSession: true });
return responsePayload("client.auth.session", response, context, { route: route("GET", "/auth/session"), localSession });
}
if (subcommand === "logout") {
const response = await requestJson({ ...context, method: "POST", path: "/auth/logout" });
const response = await requestJson({ ...context, method: "POST", path: "/auth/logout", allowWebSession: true });
await clearSession(context);
return responsePayload("client.auth.logout", response, context, { route: route("POST", "/auth/logout"), localSessionCleared: true });
}
@@ -198,13 +198,13 @@ function authHelp() {
serviceRuntime: false,
imagePublished: false,
commands: [
"login --username USER --password-env HWLAB_PASSWORD [--profile NAME]",
"oidc-login",
"status [--profile NAME]",
"whoami",
"session [--profile NAME]",
"status [--profile NAME]",
"session [--profile NAME] --web-session",
"oidc-login",
"profiles",
"logout [--profile NAME]"
"logout [--profile NAME] --web-session",
"login --username USER --password-env HWLAB_PASSWORD [--profile NAME] (legacy web-session debug)"
]
});
}
@@ -441,9 +441,15 @@ async function authStatus(context: any) {
return ok("client.auth.status", {
baseUrl: endpoint.baseUrl,
runtimeEndpoint: runtimeEndpointVisibility(endpoint),
authBoundary: {
defaultAuthMethod: "api-key",
webSessionDefault: false,
webSessionDiagnosticFlags: ["--web-session", "--cookie", "HWLAB_SESSION_COOKIE"],
localSessionDefault: false
},
stateFile: localSession.stateFile,
localSession,
apiKey: apiKey ? { source: "env-or-explicit", prefix: apiKeyPrefixSummary(apiKey) } : { source: null, prefix: null },
apiKey: apiKey ? { source: "env-or-explicit", prefix: apiKeyPrefixSummary(apiKey), authMethod: "api-key" } : { source: null, prefix: null, authMethod: null },
nextCommands: authNextCommands({ baseUrl: endpoint.baseUrl, username: text(context.parsed.username ?? context.env.HWLAB_USERNAME) || "admin", stateFile: localSession.stateFile })
});
}
@@ -2091,6 +2097,9 @@ async function requestJson(context: any) {
required: effectiveAuth,
baseUrl: resolvedBaseUrl,
username: null,
authMethod: "api-key",
requiredAuthMethod: "api-key",
credentialSource: "api-key",
stateFile: null,
localSession: null,
cookieSource: null,
@@ -2104,46 +2113,34 @@ async function requestJson(context: any) {
return { ...response, runtimeEndpoint: endpoint, auth: authVisibility(authState), authDiagnosis: authDiagnosis(response, authState) };
}
const explicitCookie = explicitAuthCookie(parsed, env);
const webSessionAllowed = effectiveAuth && (context.allowWebSession === true || parsed.webSession === true || Boolean(explicitCookie));
const sessionState = effectiveAuth && !explicitCookie && parsed.noSession !== true
? await readSessionState({ parsed, env, cwd: cwd ?? process.cwd() })
: null;
const session = sessionState?.session ?? null;
let cookie = effectiveAuth ? explicitCookie || session?.cookie || null : null;
const webSession = webSessionAllowed ? explicitCookie || session?.cookie || null : null;
const requestedUsername = text(parsed.username ?? env.HWLAB_USERNAME);
const sessionUsername = text(session?.user?.username ?? sessionState?.user?.username);
const authState: any = {
required: effectiveAuth,
baseUrl: resolvedBaseUrl,
username: requestedUsername || sessionUsername || (explicitCookie ? undefined : "admin"),
authMethod: webSession ? "web-session" : null,
requiredAuthMethod: webSessionAllowed ? "web-session" : "api-key",
credentialSource: webSession ? explicitCookie ? "explicit-web-session" : "state-web-session" : null,
stateFile: stateFile(parsed, cwd ?? process.cwd(), env),
localSession: sessionState ? sessionStateSummary(sessionState) : null,
cookieSource: cookie ? explicitCookie ? "explicit" : "state" : null,
cookieSource: webSession ? explicitCookie ? "explicit" : "state" : null,
webSessionAllowed,
webSessionIgnoredReason: !webSessionAllowed && sessionState?.usable ? "cli_requires_api_key" : null,
apiKeySource: null,
apiKeyPrefix: null,
autoLoginAttempted: false,
autoLoginStatus: null,
autoLoginHttpStatus: null,
retryAfterLogin: false
};
if (effectiveAuth && !cookie && autoAuthAllowed(parsed)) {
const login = await autoLoginSession(context, controller.signal);
authState.autoLoginAttempted = true;
authState.autoLoginStatus = login.status;
authState.autoLoginHttpStatus = login.httpStatus ?? null;
authState.cookieSource = login.cookie ? "auto-login" : null;
cookie = login.cookie ?? null;
}
let response = await sendJsonRequest({ context, url, method, body, cookie, extraHeaders, signal: controller.signal, pathName, timeoutMs: requestTimeoutMs });
if (effectiveAuth && response.status === 401 && !explicitCookie && autoAuthAllowed(parsed)) {
const login = await autoLoginSession(context, controller.signal, { force: true });
authState.autoLoginAttempted = true;
authState.autoLoginStatus = login.status;
authState.autoLoginHttpStatus = login.httpStatus ?? null;
if (login.cookie) {
authState.cookieSource = "auto-login";
authState.retryAfterLogin = true;
cookie = login.cookie;
response = await sendJsonRequest({ context, url, method, body, cookie, extraHeaders, signal: controller.signal, pathName, timeoutMs: requestTimeoutMs });
}
}
const response = await sendJsonRequest({ context, url, method, body, cookie: webSession, extraHeaders, signal: controller.signal, pathName, timeoutMs: requestTimeoutMs });
return { ...response, runtimeEndpoint: endpoint, auth: authVisibility(authState), authDiagnosis: authDiagnosis(response, authState) };
} finally {
clearTimeout(timer);
@@ -2424,6 +2421,9 @@ function authVisibility(authState: any) {
if (!authState?.required) return { required: false };
return pruneUndefined({
required: true,
authMethod: authState.authMethod,
requiredAuthMethod: authState.requiredAuthMethod,
credentialSource: authState.credentialSource,
baseUrl: authState.baseUrl,
username: authState.username,
cookieSource: authState.cookieSource,
@@ -2431,6 +2431,8 @@ function authVisibility(authState: any) {
localSession: authState.localSession,
apiKeySource: authState.apiKeySource,
apiKeyPrefix: authState.apiKeyPrefix,
webSessionAllowed: authState.webSessionAllowed,
webSessionIgnoredReason: authState.webSessionIgnoredReason,
autoLoginAttempted: authState.autoLoginAttempted,
autoLoginStatus: authState.autoLoginStatus,
autoLoginHttpStatus: authState.autoLoginHttpStatus,
@@ -2458,6 +2460,8 @@ function authDiagnosis(response: any, authState: any) {
status: "forbidden",
message: "当前账号无权访问该受保护资源,可能是 trace/result 不属于当前 owner,或当前用户不是 admin。",
httpStatus: response.status,
authMethod: authState.authMethod,
requiredAuthMethod: authState.requiredAuthMethod,
cookieSource: authState.cookieSource,
autoLoginAttempted: authState.autoLoginAttempted,
retryAfterLogin: authState.retryAfterLogin,
@@ -2466,14 +2470,30 @@ function authDiagnosis(response: any, authState: any) {
nextCommands: authNextCommands(authState)
};
}
if (authState.requiredAuthMethod === "api-key") {
return {
code: "api_key_required",
status: "credentials_missing",
message: "CLI protected commands require HWLAB_API_KEY / Authorization: Bearer hwl_live_...; local Web session state is not used by default.",
httpStatus: response.status,
authMethod: authState.authMethod,
requiredAuthMethod: "api-key",
webSessionIgnoredReason: authState.webSessionIgnoredReason,
stateFile: authState.stateFile,
localSession: authState.localSession,
nextCommands: authNextCommands(authState)
};
}
const missingCredentials = authState.autoLoginAttempted && authState.autoLoginStatus === "credentials_missing";
return {
code: missingCredentials ? "auth_credentials_missing" : "auth_required_or_expired",
code: missingCredentials ? "web_session_credentials_missing" : "web_session_required_or_expired",
status: missingCredentials ? "credentials_missing" : "unauthorized",
message: missingCredentials
? "需要登录后访问该受保护资源;请提供 --password-env、--password、--password-stdin 或 HWLAB_PASSWORD,让 CLI 自动登录。"
: "登录态不存在或已过期;CLI 已按可用凭据尝试自动登录,仍未通过认证。",
? "Web session 诊断需要显式登录态;请先用浏览器或 legacy client auth login 建立 cookie。"
: "Web session 不存在或已过期;CLI 默认鉴权请改用 HWLAB_API_KEY。",
httpStatus: response.status,
authMethod: authState.authMethod,
requiredAuthMethod: authState.requiredAuthMethod,
cookieSource: authState.cookieSource,
autoLoginAttempted: authState.autoLoginAttempted,
autoLoginStatus: authState.autoLoginStatus,
@@ -2504,14 +2524,11 @@ function sessionStateSummary(state: any) {
}
function authNextCommands(authState: any) {
const resolvedBaseUrl = text(authState?.baseUrl);
const username = text(authState?.username) || "admin";
const stateFileArg = text(authState?.stateFile) ? ` --state-file ${shellArg(authState.stateFile)}` : "";
const cli = "bun tools/hwlab-cli/bin/hwlab-cli.ts";
return [
`export HWLAB_PASSWORD='<password>'`,
`${cli} client auth login --runtime-namespace <hwlab-namespace> --username ${shellArg(username)} --password-env HWLAB_PASSWORD${stateFileArg}`,
`${cli} client auth status --runtime-namespace <hwlab-namespace>${stateFileArg}`
`export HWLAB_API_KEY='hwl_live_...'`,
`${cli} client auth whoami --runtime-namespace <hwlab-namespace>`,
`${cli} client auth status --runtime-namespace <hwlab-namespace>`
];
}