diff --git a/deploy/deploy.json b/deploy/deploy.json index 4721878c..e8564d98 100644 --- a/deploy/deploy.json +++ b/deploy/deploy.json @@ -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", diff --git a/docs/reference/spec-v02-auth.md b/docs/reference/spec-v02-auth.md index a9566958..1ace5582 100644 --- a/docs/reference/spec-v02-auth.md +++ b/docs/reference/spec-v02-auth.md @@ -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 `。 +- 受保护的 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 key:SecretRef 固定为 `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 数据结构: diff --git a/docs/reference/spec-v02-hwlab-cli.md b/docs/reference/spec-v02-hwlab-cli.md index 77e2aebe..979b4f9b 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 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 diff --git a/docs/reference/spec-v02-hwlab-cloud-api.md b/docs/reference/spec-v02-hwlab-cloud-api.md index df0e70bc..687f7955 100644 --- a/docs/reference/spec-v02-hwlab-cloud-api.md +++ b/docs/reference/spec-v02-hwlab-cloud-api.md @@ -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。 ## 内部架构 diff --git a/docs/reference/spec-v02-provider-management.md b/docs/reference/spec-v02-provider-management.md index aa808e61..cbc43b7c 100644 --- a/docs/reference/spec-v02-provider-management.md +++ b/docs/reference/spec-v02-provider-management.md @@ -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。 diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index 493dd571..64936115 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -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" }, diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index da08a086..c8c6814b 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -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() { diff --git a/scripts/g14-gitops-render.mjs b/scripts/g14-gitops-render.mjs index 62eda496..be441bbf 100644 --- a/scripts/g14-gitops-render.mjs +++ b/scripts/g14-gitops-render.mjs @@ -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)); diff --git a/tools/hwlab-cli/client.test.ts b/tools/hwlab-cli/client.test.ts index f4ecf734..73af994d 100644 --- a/tools/hwlab-cli/client.test.ts +++ b/tools/hwlab-cli/client.test.ts @@ -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); }); diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index 1cc0cb98..966ba855 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -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=''`, - `${cli} client auth login --runtime-namespace --username ${shellArg(username)} --password-env HWLAB_PASSWORD${stateFileArg}`, - `${cli} client auth status --runtime-namespace ${stateFileArg}` + `export HWLAB_API_KEY='hwl_live_...'`, + `${cli} client auth whoami --runtime-namespace `, + `${cli} client auth status --runtime-namespace ` ]; }