feat: add provider profile removal delegation

This commit is contained in:
Codex Agent
2026-06-08 05:29:11 +08:00
parent 466a5350de
commit 7408ba9da7
9 changed files with 95 additions and 5 deletions
+1
View File
@@ -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 profileCloud 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 KeyCloud 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 同路径 smokeCode Agent 侧正式业务入口仍是 `hwpod`,由 `hwpod-compiler-cli` 生成 node-ops。 |
+1 -1
View File
@@ -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。 |
@@ -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 KeyAgentRun Secret 写入的 `last-applied-configuration` 硬化由 AgentRun 侧继续跟踪。 |
@@ -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);
+11 -2
View File
@@ -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,
@@ -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;
@@ -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,
+20
View File
@@ -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"], {
+12
View File
@@ -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,