From 7408ba9da7d2c71b8221486c8d05280f746db011 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 8 Jun 2026 05:29:11 +0800 Subject: [PATCH] feat: add provider profile removal delegation --- docs/reference/spec-v02-hwlab-cli.md | 1 + docs/reference/spec-v02-hwlab-cloud-api.md | 2 +- .../reference/spec-v02-provider-management.md | 7 +++- .../cloud/provider-profile-management.test.ts | 37 +++++++++++++++++++ internal/cloud/provider-profile-management.ts | 13 ++++++- internal/dev-entrypoint/cloud-web-routes.mjs | 2 + .../dev-entrypoint/cloud-web-routes.test.mjs | 6 +++ tools/hwlab-cli/client.test.ts | 20 ++++++++++ tools/src/hwlab-cli-lib.ts | 12 ++++++ 9 files changed, 95 insertions(+), 5 deletions(-) diff --git a/docs/reference/spec-v02-hwlab-cli.md b/docs/reference/spec-v02-hwlab-cli.md index 04d38dc3..ce27505a 100644 --- a/docs/reference/spec-v02-hwlab-cli.md +++ b/docs/reference/spec-v02-hwlab-cli.md @@ -83,6 +83,7 @@ Code Agent session 是显式资源,不再由普通 `client agent send`、Workb | `hwlab-cli client access tools grant/revoke USER TOOL` | `PUT/DELETE /v1/admin/access/users/{userId}/tools/{toolId}/can-use` | 授予或撤销 `hwpod`、`unidesk_ssh`、`trans_cmd`、GitHub 写工具等 capability。 | | `hwlab-cli client access check --user USER --relation REL --object OBJECT` | `POST /v1/admin/access/check` | 管理员调试单次 authorization check,输出 decision 和 redacted actor/object。 | | `hwlab-cli client provider-profiles list` | `GET /v1/admin/provider-profiles` | 管理页的非视觉状态入口;输出 actor、profile、SecretRef、resourceVersion、hash 后缀和最近验证结果,不输出 API Key 或 Secret data。 | +| `hwlab-cli client provider-profiles remove PROFILE` | `DELETE /v1/admin/provider-profiles/{profile}` | 删除遗留 provider profile;Cloud API 鉴权后委托 AgentRun 删除对应 Secret,内建 profile capability 保留、动态 slug 从列表消失。 | | `hwlab-cli client provider-profiles set-key PROFILE --key-stdin` | `PUT /v1/admin/provider-profiles/{profile}/credential` | 从 stdin 写入 provider API Key,Cloud API 鉴权后委托 AgentRun;默认只输出 resourceVersion/hash 后缀和 failureKind。 | | `hwlab-cli client provider-profiles validate PROFILE --wait` | `POST /v1/admin/provider-profiles/{profile}/validate` + `GET /validations/{id}` | 触发 provider canary 并短连接轮询,输出 validationId、runId、commandId、jobName、traceId、status、failureKind 和 redacted bridge 摘要。 | | `hwlab-cli client request POST /v1/hwpod-node-ops` | `POST /v1/hwpod-node-ops` | HWPOD node-ops 同路径 smoke;Code Agent 侧正式业务入口仍是 `hwpod`,由 `hwpod-compiler-cli` 生成 node-ops。 | diff --git a/docs/reference/spec-v02-hwlab-cloud-api.md b/docs/reference/spec-v02-hwlab-cloud-api.md index 687f7955..40621725 100644 --- a/docs/reference/spec-v02-hwlab-cloud-api.md +++ b/docs/reference/spec-v02-hwlab-cloud-api.md @@ -49,7 +49,7 @@ Provider API Key 管理属于 Cloud API 的 authenticated admin surface:前端 | `POST /v1/admin/users` | `admin` 管理用户 role/status 的入口。 | | `GET/PATCH/PUT/DELETE /v1/admin/access...` | Admin Access API;唯一正式授权管理入口,读写 OpenFGA 细粒度授权、tool capability、role/status 和 effective matrix。 | | `POST /v1/admin/access/check` | 管理员调试授权 check;返回 mode、decision、object/relation 和 redacted actor,不返回 OpenFGA token。 | -| `GET/PUT/POST /v1/admin/provider-profiles...` | Provider API Key 管理入口;Cloud API 鉴权和审计后委托 AgentRun 后端,返回脱敏 profile 状态、SecretRef 摘要和 canary 结果,不返回 Secret value。 | +| `GET/PUT/POST/DELETE /v1/admin/provider-profiles...` | Provider API Key 管理入口;Cloud API 鉴权和审计后委托 AgentRun 后端,返回脱敏 profile 状态、SecretRef 摘要和 canary 结果,不返回 Secret value。 | | `GET/PATCH /v1/workbench/workspace`、`POST /v1/workbench/workspace/{id}/reset`、`GET /events` | 账号级共享 workspace authority;所有读写按 ownerUserId 隔离,写入使用 revision 观测冲突,active trace 只表示最近活动/当前选中 trace,不作为同账号 Code Agent 并发互斥锁。 | | `POST /v1/agent/sessions`、`GET/PATCH /v1/agent/sessions...` | 显式 Code Agent session 生命周期入口;创建/选择/状态标记 session,并与账号 workspace selection 同步。 | | `GET /v1/m3/status`、`POST /v1/m3/io` | M3 只读/受控 IO 入口;写操作必须有明确 approval。 | diff --git a/docs/reference/spec-v02-provider-management.md b/docs/reference/spec-v02-provider-management.md index 3c5da513..a8d0453f 100644 --- a/docs/reference/spec-v02-provider-management.md +++ b/docs/reference/spec-v02-provider-management.md @@ -50,13 +50,14 @@ view: provider API Key 管理 ```http GET /v1/provider-profiles GET /v1/admin/provider-profiles +DELETE /v1/admin/provider-profiles/:profile PUT /v1/admin/provider-profiles/:profile/credential POST /v1/admin/provider-profiles/:profile/validate GET /v1/admin/provider-profiles/:profile/validations/:validationId ``` - `/v1/provider-profiles` 是已认证用户可读的公开 catalog,只返回工作台下拉选择所需的 `profile/configured/backendKind/bridge` 等非敏感字段,用于工作台和设置页展示动态 slug。 -- `/v1/admin/provider-profiles*` 继续是管理员管理入口,负责查看 SecretRef/resourceVersion/hash 后缀、写 key/config 和触发 validate。 +- `/v1/admin/provider-profiles*` 继续是管理员管理入口,负责查看 SecretRef/resourceVersion/hash 后缀、删除旧 slug、写 key/config 和触发 validate。 所有接口必须先通过 [spec-v02-auth.md](spec-v02-auth.md) 恢复 `AuthPrincipal`,再按 [spec-user-access.md](spec-user-access.md) 和 [spec-v02-openfga-authorization.md](spec-v02-openfga-authorization.md) 判定是否允许访问管理能力。首版允许只对 `admin` 开放;如果后续引入 `provider_manager` 或等价 relation,必须先写入用户权限规格。 @@ -116,6 +117,7 @@ AgentRun deepseek profile ```bash bun tools/hwlab-cli/bin/hwlab-cli.ts client provider-profiles list +bun tools/hwlab-cli/bin/hwlab-cli.ts client provider-profiles remove ofcx-go bun tools/hwlab-cli/bin/hwlab-cli.ts client provider-profiles set-key deepseek --key-stdin bun tools/hwlab-cli/bin/hwlab-cli.ts client provider-profiles validate deepseek --wait --timeout-ms 120000 ``` @@ -124,6 +126,7 @@ bun tools/hwlab-cli/bin/hwlab-cli.ts client provider-profiles validate deepseek - CLI 默认只使用 `HWLAB_API_KEY` 恢复同一个 HWLAB actor;已登录 Web session 只允许作为显式 `--web-session`/`--cookie` 诊断入口,不作为 provider 管理命令的默认鉴权来源。 - `set-key --key-stdin` 从 stdin 读取 API Key,默认输出只显示 hash/resourceVersion,不打印 key。 +- `remove PROFILE` 通过 Cloud API 委托 AgentRun 删除对应 profile Secret;内建 profile 删除后 capability 仍保留,动态 slug 删除后从列表消失。 - `validate --wait` 短连接 submit-and-poll,输出 validationId、runId、commandId、jobName、traceId、status、failureKind 和 redacted bridge upstream 摘要。 - 这些命令不得直接调用 AgentRun 内部 URL,不得直接读写 Kubernetes Secret。 @@ -162,6 +165,6 @@ AgentRun event/trace 只允许显示 provider profile、SecretRef 摘要、valid | HWLAB 管理页规格 | 已定义/已接入 | 本文为 `v0.2` provider API Key 管理的 HWLAB 权威规格;Cloud Web 已提供 `#/management` 管理页。 | | Cloud API 委托接口 | 已实现 | `/v1/admin/provider-profiles*` 由 HWLAB Cloud API 鉴权授权后委托 AgentRun `/api/v1/provider-profiles*`,HWLAB 不直写 AgentRun Secret。 | | AgentRun provider profile 管理 API | 已对接 | 依赖 AgentRun `v0.1` `agentrun-mgr` provider profile 管理 API;后端长期规格和后续硬化项见 [pikasTech/agentrun#28](https://github.com/pikasTech/agentrun/issues/28)。 | -| CLI 同路径入口 | 已实现 | `client provider-profiles list/set-key/validate/validation` 走 HWLAB Cloud Web 同源 API,用作管理页的非视觉同路径验收入口。 | +| CLI 同路径入口 | 已实现 | `client provider-profiles list/remove/set-key/validate/validation` 走 HWLAB Cloud Web 同源 API,用作管理页的非视觉同路径验收入口。 | | DeepSeek canary | 已验证 | DeepSeek profile 经 `hwlab-deepseek-proxy.hwlab-v02.svc.cluster.local:4000/v1` 到 `/v1/responses`,验收必须继续证明不访问 `hyueapi.com`。 | | Secret 脱敏 | 已实现/持续约束 | Web、Cloud API、CLI 和 issue 证据不得回显完整 API Key;AgentRun Secret 写入的 `last-applied-configuration` 硬化由 AgentRun 侧继续跟踪。 | diff --git a/internal/cloud/provider-profile-management.test.ts b/internal/cloud/provider-profile-management.test.ts index 976ce287..d2048187 100644 --- a/internal/cloud/provider-profile-management.test.ts +++ b/internal/cloud/provider-profile-management.test.ts @@ -182,6 +182,39 @@ test("provider profile config reads and writes config.toml through AgentRun", as } }); +test("provider profile remove delegates through HWLAB and preserves redaction", async () => { + const calls: Array<{ method?: string; path?: string; body?: any }> = []; + const agentRunServer = await startAgentRunServer(calls); + const { port: agentRunPort } = agentRunServer.address() as { port: number }; + const server = createCloudApiServer({ + env: testEnv(agentRunPort), + accessController: fakeAccessController({ actor: adminActor, authMethod: "api-key" }) + }); + await listen(server); + const { port } = server.address() as { port: number }; + + try { + const response = await fetch(`http://127.0.0.1:${port}/v1/admin/provider-profiles/deepseek`, { + method: "DELETE", + headers: { authorization: "Bearer hwlab-test" } + }); + const body = await response.json(); + assert.equal(response.status, 200); + assert.equal(body.ok, true); + assert.equal(body.status, "removed"); + assert.equal(body.profile, "deepseek"); + assert.equal(body.data.removed, true); + assert.equal(body.data.builtinCapabilityRetained, true); + assert.equal(JSON.stringify(body).includes("sk-test"), false); + assert.equal(calls[0].method, "DELETE"); + assert.equal(calls[0].path, "/api/v1/provider-profiles/deepseek"); + assert.equal(calls[0].body, null); + } finally { + await close(server); + await close(agentRunServer); + } +}); + function testEnv(port: number) { return { HWLAB_CODE_AGENT_AGENTRUN_MGR_URL: `http://127.0.0.1:${port}`, @@ -231,6 +264,10 @@ async function startAgentRunServer(calls: Array<{ method?: string; path?: string response.end(`${JSON.stringify({ ok: true, traceId: "trc_set_config", data: { action: "provider-profile-config-updated", mutation: true, profile: "deepseek", configured: true, secretRef: { namespace: "agentrun-v01", name: "agentrun-v01-provider-deepseek", keys: ["auth.json", "config.toml"] }, credentialHashSuffix: "abc12345", configHashSuffix: "fedcba98", updatedAt: "2026-06-05T08:01:00.000Z", valuesPrinted: false } })}\n`); return; } + if (request.method === "DELETE" && url.pathname === "/api/v1/provider-profiles/deepseek") { + response.end(`${JSON.stringify({ ok: true, traceId: "trc_remove", data: { action: "provider-profile-removed", mutation: true, profile: "deepseek", configured: false, removed: true, builtinCapabilityRetained: true, secretRef: { namespace: "agentrun-v01", name: "agentrun-v01-provider-deepseek", keys: ["auth.json", "config.toml"] }, deletedResourceVersion: "rv-removed", credentialHashSuffix: "abc12345", configHashSuffix: "def67890", updatedAt: "2026-06-05T08:02:00.000Z", valuesPrinted: false } })}\n`); + return; + } response.end(`${JSON.stringify({ ok: false, error: { code: "unexpected", message: `${request.method} ${url.pathname}` } })}\n`); }); await listen(server); diff --git a/internal/cloud/provider-profile-management.ts b/internal/cloud/provider-profile-management.ts index 50504a98..fa8d6acd 100644 --- a/internal/cloud/provider-profile-management.ts +++ b/internal/cloud/provider-profile-management.ts @@ -57,6 +57,11 @@ export async function handleProviderProfilesHttp(request, response, url, options return sendDelegated(response, delegated, auth, route, managerUrl, requestId, normalizeListData(delegated.data)); } + if (route.kind === "remove") { + const delegated = await agentRunJson(fetchImpl, managerUrl, `/api/v1/provider-profiles/${encodeURIComponent(route.agentRunProfile)}`, { method: "DELETE", requestId, timeoutMs }); + return sendDelegated(response, delegated, auth, route, managerUrl, requestId, normalizeProfileData(delegated.data)); + } + if (route.kind === "credential") { const body = await readJsonObject(request, options.bodyLimitBytes); const apiKey = text(body.apiKey); @@ -140,6 +145,10 @@ function parseProviderProfileRoute(pathname, method) { if (method !== "PUT") throw routeError(405, "method_not_allowed", "Provider profile credential only supports PUT"); return { kind: "credential", ...profile }; } + if (!suffix) { + if (method !== "DELETE") throw routeError(405, "method_not_allowed", "Provider profile root only supports DELETE"); + return { kind: "remove", ...profile }; + } if (suffix === "config") { if (method !== "GET" && method !== "PUT") throw routeError(405, "method_not_allowed", "Provider profile config only supports GET and PUT"); return { kind: "config", method, ...profile }; @@ -218,11 +227,11 @@ async function agentRunJson(fetchImpl, managerUrl, path, { method = "GET", body, } function sendDelegated(response, delegated, auth, route, managerUrl, requestId, data, options = {}) { - const mutation = route.kind === "credential" || (route.kind === "config" && route.method === "PUT"); + const mutation = route.kind === "remove" || route.kind === "credential" || (route.kind === "config" && route.method === "PUT"); const status = delegated.ok ? (mutation ? 200 : delegated.httpStatus || 200) : delegated.httpStatus || 502; const base = { ok: delegated.ok, - status: delegated.ok ? (mutation ? "updated" : "ok") : "failed", + status: delegated.ok ? (route.kind === "remove" ? "removed" : mutation ? "updated" : "ok") : "failed", contractVersion: CONTRACT_VERSION, actor: actorSummary(auth.actor), profile: route.publicProfile ?? undefined, diff --git a/internal/dev-entrypoint/cloud-web-routes.mjs b/internal/dev-entrypoint/cloud-web-routes.mjs index 358c20b3..40439a07 100644 --- a/internal/dev-entrypoint/cloud-web-routes.mjs +++ b/internal/dev-entrypoint/cloud-web-routes.mjs @@ -66,6 +66,7 @@ export function isCloudApiProxyRoute(method, pathname) { } if (normalizedMethod === "DELETE") { return isAdminAccessWriteProxyRoute(normalizedMethod, normalizedPath) || + isProviderProfileManagementProxyRoute(normalizedMethod, normalizedPath) || isApiKeyWriteProxyRoute(normalizedMethod, normalizedPath) || isAgentConversationWriteProxyRoute(normalizedMethod, normalizedPath); } @@ -86,6 +87,7 @@ function isAdminAccessWriteProxyRoute(method, pathname) { function isProviderProfileManagementProxyRoute(method, pathname) { const parts = pathname.split("/").filter(Boolean); if (parts.length < 4 || parts[0] !== "v1" || parts[1] !== "admin" || parts[2] !== "provider-profiles") return false; + if (method === "DELETE" && parts.length === 4) return true; if (method === "PUT" && parts.length === 5 && parts[4] === "credential") return true; if (method === "PUT" && parts.length === 5 && parts[4] === "config") return true; if (method === "POST" && parts.length === 5 && parts[4] === "validate") return true; diff --git a/internal/dev-entrypoint/cloud-web-routes.test.mjs b/internal/dev-entrypoint/cloud-web-routes.test.mjs index 5f854137..72c9d3ab 100644 --- a/internal/dev-entrypoint/cloud-web-routes.test.mjs +++ b/internal/dev-entrypoint/cloud-web-routes.test.mjs @@ -136,6 +136,12 @@ test("cloud web proxies authenticated provider profile management write routes", publicRoute: false, routeKey: "POST /v1/admin/provider-profiles/deepseek/validate" }); + assert.deepEqual(cloudWebProxyRoutePolicy("DELETE", "/v1/admin/provider-profiles/deepseek"), { + proxy: true, + authRequired: true, + publicRoute: false, + routeKey: "DELETE /v1/admin/provider-profiles/deepseek" + }); assert.deepEqual(cloudWebProxyRoutePolicy("DELETE", "/v1/admin/provider-profiles/deepseek/credential"), { proxy: false, authRequired: false, diff --git a/tools/hwlab-cli/client.test.ts b/tools/hwlab-cli/client.test.ts index f3328bf1..92a84db5 100644 --- a/tools/hwlab-cli/client.test.ts +++ b/tools/hwlab-cli/client.test.ts @@ -156,6 +156,26 @@ test("hwlab-cli client provider-profiles config reads and writes config.toml", a assert.equal(JSON.stringify(write.payload).includes("# edited"), false); }); +test("hwlab-cli client provider-profiles remove delegates to Cloud Web admin route", async () => { + const calls: any[] = []; + const result = await runHwlabCli(["client", "provider-profiles", "remove", "deepseek", "--base-url", "http://web.test"], { + env: { HWLAB_API_KEY: "hwl_live_admin_test_key" }, + fetchImpl: async (url, init) => { + calls.push({ url: String(url), init }); + return new Response(JSON.stringify({ ok: true, status: "removed", profile: "deepseek", data: { profile: "deepseek", configured: false, removed: true, builtinCapabilityRetained: true, deletedResourceVersion: "rv-removed", secretRef: { namespace: "agentrun-v01", name: "agentrun-v01-provider-deepseek", keys: ["auth.json", "config.toml"] }, credentialHashSuffix: "abc12345", configHashSuffix: "def67890", valuesPrinted: false }, valuesPrinted: false }), { status: 200 }); + } + }); + + assert.equal(result.exitCode, 0); + assert.equal(calls[0].url, "http://web.test/v1/admin/provider-profiles/deepseek"); + assert.equal(calls[0].init.method, "DELETE"); + assert.equal(calls[0].init.headers.authorization, "Bearer hwl_live_admin_test_key"); + assert.equal(result.payload.route.path, "/v1/admin/provider-profiles/deepseek"); + assert.equal(result.payload.body.profileStatus.profile, "deepseek"); + assert.equal(result.payload.body.profileStatus.removed, true); + assert.equal(result.payload.body.profileStatus.builtinCapabilityRetained, true); +}); + test("hwlab-cli client provider-profiles validate can wait by polling validation result", async () => { const calls: any[] = []; const result = await runHwlabCli(["client", "provider-profiles", "validate", "deepseek", "--wait", "--timeout-ms", "5000", "--poll-interval-ms", "10", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-admin"], { diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index 2398a7f9..e52f5430 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -311,6 +311,12 @@ async function providerProfilesCommand(context: any) { const response = await requestJson({ ...context, method: "GET", path: pathName }); return responsePayload("client.provider-profiles.list", response, context, { route: route("GET", pathName), body: providerProfileBodyForCli(response.body, context.parsed) }); } + if (subcommand === "remove" || subcommand === "delete" || subcommand === "rm") { + const profile = normalizeProviderProfile(context.parsed.profileName ?? context.parsed.profileId ?? context.rest[1]); + const pathName = `/v1/admin/provider-profiles/${encodeURIComponent(profile)}`; + const response = await requestJson({ ...context, method: "DELETE", path: pathName, timeoutMs: numberOption(context.parsed.submitTimeoutMs) ?? DEFAULT_TIMEOUT_MS }); + return responsePayload("client.provider-profiles.remove", response, context, { route: route("DELETE", pathName), profile, body: providerProfileBodyForCli(response.body, context.parsed), valuesPrinted: false }); + } if (subcommand === "set-key" || subcommand === "set" || subcommand === "credential") { const profile = normalizeProviderProfile(context.parsed.profileName ?? context.parsed.profileId ?? context.rest[1]); if (context.parsed.keyStdin !== true) throw cliError("key_stdin_required", "set-key requires --key-stdin so the API key is not passed in argv", { command: "provider-profiles set-key PROFILE --key-stdin" }); @@ -397,6 +403,7 @@ function providerProfilesHelp() { commands: [ "list", "config PROFILE", + "remove PROFILE", "set-config PROFILE --config-stdin", "set-key PROFILE --key-stdin", "validate PROFILE [--wait] [--timeout-ms N] [--poll-interval-ms N]", @@ -405,6 +412,7 @@ function providerProfilesHelp() { routes: { list: route("GET", "/v1/admin/provider-profiles"), config: route("GET", "/v1/admin/provider-profiles/:profile/config"), + remove: route("DELETE", "/v1/admin/provider-profiles/:profile"), setConfig: route("PUT", "/v1/admin/provider-profiles/:profile/config"), setKey: route("PUT", "/v1/admin/provider-profiles/:profile/credential"), validate: route("POST", "/v1/admin/provider-profiles/:profile/validate"), @@ -3019,9 +3027,13 @@ function providerProfileStatusForCli(value: any) { backendProfile: text(value?.backendProfile), backendKind: text(value?.backendKind), configured: value?.configured === true, + removed: value?.removed === true ? true : undefined, + alreadyAbsent: value?.alreadyAbsent === true ? true : undefined, + builtinCapabilityRetained: value?.builtinCapabilityRetained === true ? true : undefined, failureKind: text(value?.failureKind) || undefined, secretRef: providerSecretRefForCli(value?.secretRef), resourceVersion: text(value?.resourceVersion) || undefined, + deletedResourceVersion: text(value?.deletedResourceVersion) || undefined, credentialHashSuffix: text(value?.credentialHashSuffix) || undefined, configHashSuffix: text(value?.configHashSuffix) || undefined, updatedAt: text(value?.updatedAt) || undefined,