diff --git a/docs/reference/spec-v01-provider-profile-management.md b/docs/reference/spec-v01-provider-profile-management.md index d4b8673..2a25096 100644 --- a/docs/reference/spec-v01-provider-profile-management.md +++ b/docs/reference/spec-v01-provider-profile-management.md @@ -40,12 +40,14 @@ Provider profile 管理 API 属于 `agentrun-mgr` 公共 REST API 的服务端 ```http GET /api/v1/provider-profiles GET /api/v1/provider-profiles/:profile +GET /api/v1/provider-profiles/:profile/config +PUT /api/v1/provider-profiles/:profile/config PUT /api/v1/provider-profiles/:profile/credential POST /api/v1/provider-profiles/:profile/validate GET /api/v1/provider-profiles/:profile/validations/:validationId ``` -所有成功和失败响应都必须是 JSON。失败响应至少包含 `failureKind`、`message` 和 `requestId`。所有响应不得包含 API Key 原文、Codex `auth.json` 明文、Codex `config.toml` 明文、base64 Secret data、Authorization header、Kubernetes token 或 provider request header。 +所有成功和失败响应都必须是 JSON。失败响应至少包含 `failureKind`、`message` 和 `requestId`。除显式 `GET /api/v1/provider-profiles/:profile/config` 返回 `config.toml` 明文供 HWLAB admin 管理页查看外,其他响应不得包含 API Key 原文、Codex `auth.json` 明文、Codex `config.toml` 明文、base64 Secret data、Authorization header、Kubernetes token 或 provider request header。 ### `GET /api/v1/provider-profiles` @@ -63,6 +65,10 @@ GET /api/v1/provider-profiles/:profile/validations/:validationId Secret 缺失时仍要返回 profile capability,并把状态标为 `configured=false` 或 `failureKind=secret-unavailable`;不得因为 Secret 未配置而隐藏 profile。 +### `GET/PUT /api/v1/provider-profiles/:profile/config` + +`GET` 返回当前 profile 的 `configToml`、SecretRef、resourceVersion 和 hash 后缀,供 HWLAB admin 管理页查看。`PUT` 接收 `configToml`,保存时只替换同一 Secret 的 `config.toml`,保留现有 `auth.json`,并返回 resourceVersion 和 `configHashSuffix`。 + ### `PUT /api/v1/provider-profiles/:profile/credential` 请求体由 HWLAB 后端或受控 CLI 发送,最小形态: @@ -143,7 +149,9 @@ AgentRun CLI 提供 operator 和综合联调入口: ```bash ./scripts/agentrun provider-profiles list ./scripts/agentrun provider-profiles show deepseek +./scripts/agentrun provider-profiles config deepseek ./scripts/agentrun provider-profiles set-key deepseek --key-stdin +./scripts/agentrun provider-profiles set-config deepseek --config-stdin ./scripts/agentrun provider-profiles validate deepseek --wait --timeout-ms 120000 ``` diff --git a/scripts/src/cli.ts b/scripts/src/cli.ts index a9b8a4f..5f38199 100644 --- a/scripts/src/cli.ts +++ b/scripts/src/cli.ts @@ -41,7 +41,9 @@ async function dispatch(args: ParsedArgs): Promise { if (group === "backends" && command === "list") return client(args).get("/api/v1/backends"); if (group === "provider-profiles" && command === "list") return client(args).get("/api/v1/provider-profiles"); if (group === "provider-profiles" && command === "show" && id) return client(args).get(`/api/v1/provider-profiles/${encodeURIComponent(normalizeProfile(id))}`); + if (group === "provider-profiles" && command === "config" && id) return client(args).get(`/api/v1/provider-profiles/${encodeURIComponent(normalizeProfile(id))}/config`); if (group === "provider-profiles" && command === "set-key" && id) return setProviderProfileKey(args, id); + if (group === "provider-profiles" && command === "set-config" && id) return setProviderProfileConfig(args, id); if (group === "provider-profiles" && command === "validate" && id) return validateProviderProfileCli(args, id); if (group === "secrets" && command === "codex" && id === "render") return renderCodexSecret(args); if (group === "sessions" && command === "ps") return listSessions(args); @@ -419,6 +421,23 @@ async function setProviderProfileKey(args: ParsedArgs, profileValue: string): Pr return await client(args).put(`/api/v1/provider-profiles/${encodeURIComponent(profile)}/credential`, body) as JsonRecord; } +async function setProviderProfileConfig(args: ParsedArgs, profileValue: string): Promise { + const profile = normalizeProfile(profileValue); + if (args.flags.get("config-stdin") !== true) throw new AgentRunError("schema-invalid", "provider-profiles set-config requires --config-stdin", { httpStatus: 2 }); + const configToml = await readStdinText(); + if (configToml.trim().length === 0) throw new AgentRunError("schema-invalid", "stdin config.toml is empty", { httpStatus: 2 }); + return await client(args).put(`/api/v1/provider-profiles/${encodeURIComponent(profile)}/config`, { + configToml, + reason: optionalFlag(args, "reason") ?? "operator-cli", + delegatedBy: { + system: optionalFlag(args, "delegated-system") ?? "operator-cli", + userId: optionalFlag(args, "delegated-user-id") ?? null, + username: optionalFlag(args, "delegated-username") ?? null, + requestId: optionalFlag(args, "delegated-request-id") ?? null, + }, + }) as JsonRecord; +} + async function validateProviderProfileCli(args: ParsedArgs, profileValue: string): Promise { const profile = normalizeProfile(profileValue); const started = await client(args).post(`/api/v1/provider-profiles/${encodeURIComponent(profile)}/validate`, {}) as JsonRecord; @@ -770,7 +789,9 @@ function help(): JsonRecord { "secrets codex render --dry-run [--profile codex|deepseek|minimax-m3] [--codex-home ] [--namespace agentrun-v01] [--secret-name ]", "provider-profiles list", "provider-profiles show ", + "provider-profiles config ", "provider-profiles set-key --key-stdin [--model ] [--base-url ]", + "provider-profiles set-config --config-stdin", "provider-profiles validate [--wait] [--timeout-ms ]", "backends list", "server start [--port ] [--host ] [--foreground]", diff --git a/src/mgr/provider-profiles.ts b/src/mgr/provider-profiles.ts index d5240fb..896ddba 100644 --- a/src/mgr/provider-profiles.ts +++ b/src/mgr/provider-profiles.ts @@ -48,6 +48,92 @@ export async function showProviderProfile(profile: string, options: ProviderProf return providerProfileStatus(validateBackendProfile(profile), options); } +export async function getProviderProfileConfig(profileValue: string, options: ProviderProfileOptions = {}): Promise { + const profile = validateBackendProfile(profileValue); + const spec = requiredSpec(profile); + const namespace = profileNamespace(options); + const secret = await kubectlGetSecret(spec.defaultSecretName, namespace, options.kubectlCommand ?? "kubectl"); + if (!secret) throw new AgentRunError("secret-unavailable", `provider profile ${profile} Secret is not configured`, { httpStatus: 404, details: { profile, secretRef: secretRefSummary(profile, namespace) } }); + const data = asOptionalRecord(secret.data); + const annotations = asOptionalRecord(asOptionalRecord(secret.metadata)?.annotations); + const configToml = configTomlFromData(data, profile); + return { + action: "provider-profile-config-read", + profile, + configured: hasRequiredKeys(data, spec.requiredSecretKeys), + secretRef: secretRefSummary(profile, namespace), + resourceVersion: stringPath(secret, ["metadata", "resourceVersion"]), + credentialHashSuffix: hashDataKey(data, "auth.json") ?? stringPath(annotations, [`${credentialAnnotationPrefix}-credential-hash-suffix`]), + configHashSuffix: shortHash(configToml), + updatedAt: stringPath(annotations, [`${credentialAnnotationPrefix}-updated-at`]) ?? stringPath(secret, ["metadata", "creationTimestamp"]), + configToml, + configTomlPrinted: true, + credentialValuesPrinted: false, + valuesPrinted: false, + }; +} + +export async function setProviderProfileConfig(profileValue: string, body: unknown, options: ProviderProfileOptions = {}): Promise { + const profile = validateBackendProfile(profileValue); + const spec = requiredSpec(profile); + const record = asRecord(body ?? {}, "providerProfileConfig"); + const configToml = configTomlField(record); + const delegatedBy = delegatedBySummary(record.delegatedBy); + const namespace = profileNamespace(options); + const existingSecret = await kubectlGetSecret(spec.defaultSecretName, namespace, options.kubectlCommand ?? "kubectl"); + if (!existingSecret) throw new AgentRunError("secret-unavailable", `provider profile ${profile} Secret is not configured`, { httpStatus: 404, details: { profile, secretRef: secretRefSummary(profile, namespace) } }); + const existingData = asOptionalRecord(existingSecret.data); + const authJsonData = dataKey(existingData, "auth.json"); + if (!authJsonData) throw new AgentRunError("secret-unavailable", `provider profile ${profile} auth.json is not configured`, { httpStatus: 400, details: { profile, secretRef: secretRefSummary(profile, namespace), key: "auth.json" } }); + const credentialHashSuffix = hashDataKey(existingData, "auth.json"); + const secretManifest: JsonRecord = { + apiVersion: "v1", + kind: "Secret", + metadata: { + name: spec.defaultSecretName, + namespace, + labels: { + "app.kubernetes.io/part-of": "agentrun", + "agentrun.pikastech.local/profile": profile, + }, + annotations: { + [`${credentialAnnotationPrefix}-profile`]: profile, + ...(credentialHashSuffix ? { [`${credentialAnnotationPrefix}-credential-hash-suffix`]: credentialHashSuffix } : {}), + [`${credentialAnnotationPrefix}-config-hash-suffix`]: shortHash(configToml), + [`${credentialAnnotationPrefix}-updated-at`]: new Date().toISOString(), + ...(delegatedBy ? { [`${credentialAnnotationPrefix}-delegated-system`]: delegatedBy.system, [`${credentialAnnotationPrefix}-delegated-request-id`]: delegatedBy.requestId ?? "" } : {}), + }, + }, + type: "Opaque", + data: { + "auth.json": authJsonData, + "config.toml": base64Data(configToml), + }, + }; + const applied = await kubectlReplaceSecret(secretManifest, options.kubectlCommand ?? "kubectl"); + return { + action: "provider-profile-config-updated", + mutation: true, + profile, + configured: true, + secretRef: secretRefSummary(profile, namespace), + resourceVersion: objectPath(applied, ["metadata", "resourceVersion"]), + credentialHashSuffix, + configHashSuffix: shortHash(configToml), + updatedAt: objectPath(applied, ["metadata", "annotations", `${credentialAnnotationPrefix}-updated-at`]) ?? new Date().toISOString(), + delegatedBy, + requiresExternalBridgeUpdate: profile === "deepseek", + configTomlPrinted: false, + credentialValuesPrinted: false, + valuesPrinted: false, + pollCommands: { + config: `./scripts/agentrun provider-profiles config ${profile}`, + show: `./scripts/agentrun provider-profiles show ${profile}`, + validate: `./scripts/agentrun provider-profiles validate ${profile} --wait --timeout-ms 120000`, + }, + }; +} + export async function setProviderProfileCredential(profileValue: string, body: unknown, options: ProviderProfileOptions = {}): Promise { const profile = validateBackendProfile(profileValue); const spec = requiredSpec(profile); @@ -287,6 +373,22 @@ function existingConfigAllowed(profile: BackendProfile, configToml: string): boo return true; } +function configTomlFromData(data: JsonRecord | null, profile: BackendProfile): string { + const encoded = dataKey(data, "config.toml"); + if (!encoded) throw new AgentRunError("secret-unavailable", `provider profile ${profile} config.toml is not configured`, { httpStatus: 404, details: { profile, key: "config.toml" } }); + try { + return Buffer.from(encoded, "base64").toString("utf8"); + } catch { + throw new AgentRunError("schema-invalid", `provider profile ${profile} config.toml is not valid base64`, { httpStatus: 400, details: { profile, key: "config.toml" } }); + } +} + +function configTomlField(record: JsonRecord): string { + const value = record.configToml; + if (typeof value !== "string" || value.trim().length === 0) throw new AgentRunError("schema-invalid", "configToml is required", { httpStatus: 400 }); + return value; +} + function configField(value: unknown, profile: BackendProfile): ProfileConfig { const defaults = defaultConfig(profile); if (value === undefined || value === null) return defaults; @@ -493,6 +595,11 @@ function base64Data(value: string): string { return Buffer.from(value, "utf8").toString("base64"); } +function dataKey(data: JsonRecord | null, key: string): string | null { + const value = data?.[key]; + return typeof value === "string" && value.length > 0 ? value : null; +} + function shortHash(value: string): string { return createHash("sha256").update(value).digest("hex").slice(0, 12); } diff --git a/src/mgr/server.ts b/src/mgr/server.ts index bd762a5..ee3b699 100644 --- a/src/mgr/server.ts +++ b/src/mgr/server.ts @@ -13,7 +13,7 @@ import { runnerJobStatusSummary } from "./runner-job-status.js"; import { createSessionPvc, deleteSessionPvc, getSessionPvcSummary, refreshSessionPvcSummary, runSessionStorageGc } from "./session-pvc.js"; import type { SessionPvcSummary } from "./session-pvc.js"; import type { SessionPvcOptions } from "./session-pvc.js"; -import { getProviderProfileValidation, listProviderProfiles, setProviderProfileCredential, showProviderProfile, validateProviderProfile } from "./provider-profiles.js"; +import { getProviderProfileConfig, getProviderProfileValidation, listProviderProfiles, setProviderProfileConfig, setProviderProfileCredential, showProviderProfile, validateProviderProfile } from "./provider-profiles.js"; function pvcOptions(defaults: { kubectlCommand?: string } | undefined): SessionPvcOptions { return defaults?.kubectlCommand ? { kubectlCommand: defaults.kubectlCommand } : {}; @@ -95,6 +95,9 @@ async function route({ method, url, body, store, sourceCommit, runnerJobDefaults if (method === "GET" && path === "/api/v1/provider-profiles") return await listProviderProfiles(providerProfileDefaults) as JsonValue; const providerProfileMatch = path.match(/^\/api\/v1\/provider-profiles\/([^/]+)$/u); if (method === "GET" && providerProfileMatch) return await showProviderProfile(providerProfileMatch[1] ?? "", providerProfileDefaults) as JsonValue; + const providerConfigMatch = path.match(/^\/api\/v1\/provider-profiles\/([^/]+)\/config$/u); + if (method === "GET" && providerConfigMatch) return await getProviderProfileConfig(providerConfigMatch[1] ?? "", providerProfileDefaults) as JsonValue; + if (method === "PUT" && providerConfigMatch) return await setProviderProfileConfig(providerConfigMatch[1] ?? "", body, providerProfileDefaults) as JsonValue; const providerCredentialMatch = path.match(/^\/api\/v1\/provider-profiles\/([^/]+)\/credential$/u); if (method === "PUT" && providerCredentialMatch) return await setProviderProfileCredential(providerCredentialMatch[1] ?? "", body, providerProfileDefaults) as JsonValue; const providerValidationCreateMatch = path.match(/^\/api\/v1\/provider-profiles\/([^/]+)\/validate$/u); diff --git a/src/selftest/cases/45-provider-profile-management.ts b/src/selftest/cases/45-provider-profile-management.ts index 7ebab5e..ef1c9d6 100644 --- a/src/selftest/cases/45-provider-profile-management.ts +++ b/src/selftest/cases/45-provider-profile-management.ts @@ -92,6 +92,27 @@ process.exit(1); assert.equal(JSON.stringify(list).includes("auth.json"), true); assert.equal(JSON.stringify(list).includes("redacted-fixture"), false); + const config = await client.get("/api/v1/provider-profiles/deepseek/config") as JsonRecord; + assert.equal(config.profile, "deepseek"); + assert.equal(config.configToml, "model = \"fixture\"\n"); + assert.equal(config.configTomlPrinted, true); + assert.equal(JSON.stringify(config).includes("redacted-fixture"), false); + + const updatedConfigToml = "model = \"fixture-updated\"\n"; + const updatedConfig = await client.put("/api/v1/provider-profiles/deepseek/config", { + configToml: updatedConfigToml, + delegatedBy: { system: "hwlab-v02", userId: "u1", username: "tester", requestId: "req-config-selftest" }, + reason: "self-test", + }) as JsonRecord; + assert.equal(updatedConfig.profile, "deepseek"); + assert.equal(updatedConfig.configTomlPrinted, false); + assert.equal(JSON.stringify(updatedConfig).includes(updatedConfigToml), false); + const configReplaceRecord = JSON.parse(await readFile(replacedSecretPath, "utf8")) as JsonRecord; + const configSecretManifest = configReplaceRecord.manifest as JsonRecord; + const configData = configSecretManifest.data as JsonRecord; + assert.equal(Buffer.from(String(configData["auth.json"]), "base64").toString("utf8").includes("redacted-fixture"), true); + assert.equal(Buffer.from(String(configData["config.toml"]), "base64").toString("utf8"), updatedConfigToml); + const updated = await client.put("/api/v1/provider-profiles/deepseek/credential", { apiKey: secretText, delegatedBy: { system: "hwlab-v02", userId: "u1", username: "tester", requestId: "req-selftest" }, @@ -147,7 +168,7 @@ process.exit(1); assert.equal(finalValidation.status, "completed"); assert.equal(JSON.stringify(finalValidation).includes(secretText), false); assertNoSecretLeak(finalValidation); - return { name: "provider-profile-management", tests: ["provider-profiles-list-redacted", "provider-profile-set-key-redacted", "provider-profile-secret-replace-annotation-cleanup", "provider-profile-deepseek-moon-bridge", "provider-profile-manager-secret-rbac", "provider-profile-validation-runner-job"] }; + return { name: "provider-profile-management", tests: ["provider-profiles-list-redacted", "provider-profile-config", "provider-profile-set-key-redacted", "provider-profile-secret-replace-annotation-cleanup", "provider-profile-deepseek-moon-bridge", "provider-profile-manager-secret-rbac", "provider-profile-validation-runner-job"] }; } finally { await new Promise((resolve) => server.server.close(() => resolve())); }