From 509c2aa6fd62cc4fa857c62bf5103e84d3a1eab8 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 8 Jun 2026 04:19:58 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=AF=E6=8C=81=E5=8A=A8=E6=80=81=20p?= =?UTF-8?q?rovider=20profile=20slug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/src/gitops-render.ts | 7 +- src/common/backend-profiles.ts | 40 +++++++-- src/common/types.ts | 2 +- src/common/validation.ts | 12 +-- src/mgr/provider-profiles.ts | 86 ++++++++++++++++--- .../cases/45-provider-profile-management.ts | 81 +++++++++++++---- 6 files changed, 181 insertions(+), 47 deletions(-) diff --git a/scripts/src/gitops-render.ts b/scripts/src/gitops-render.ts index ce96b75..0c19002 100644 --- a/scripts/src/gitops-render.ts +++ b/scripts/src/gitops-render.ts @@ -367,11 +367,7 @@ metadata: rules: - apiGroups: [""] resources: ["secrets"] - verbs: ["create"] - - apiGroups: [""] - resources: ["secrets"] - resourceNames: ["agentrun-v01-provider-codex", "agentrun-v01-provider-deepseek", "agentrun-v01-provider-minimax-m3", "agentrun-v01-provider-dsflash-go"] - verbs: ["get", "patch", "update"] + verbs: ["create", "get", "list", "patch", "update"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding @@ -403,7 +399,6 @@ metadata: rules: - apiGroups: [""] resources: ["secrets"] - resourceNames: ["agentrun-v01-provider-codex", "agentrun-v01-provider-deepseek", "agentrun-v01-provider-minimax-m3", "agentrun-v01-provider-dsflash-go"] verbs: ["get"] --- apiVersion: rbac.authorization.k8s.io/v1 diff --git a/src/common/backend-profiles.ts b/src/common/backend-profiles.ts index a7b4909..7217c99 100644 --- a/src/common/backend-profiles.ts +++ b/src/common/backend-profiles.ts @@ -1,5 +1,7 @@ import type { BackendProfile, JsonRecord } from "./types.js"; +export const backendProfileIdPattern = /^[a-z0-9][a-z0-9-]{0,63}$/u; + export interface BackendProfileSpec { profile: BackendProfile; backendKind: "codex-app-server-stdio"; @@ -13,7 +15,7 @@ export interface BackendProfileSpec { description: string; } -export const backendProfileSpecs: readonly BackendProfileSpec[] = [ +const builtinBackendProfileSpecs: readonly BackendProfileSpec[] = [ { profile: "codex", backendKind: "codex-app-server-stdio", @@ -64,14 +66,40 @@ export const backendProfileSpecs: readonly BackendProfileSpec[] = [ }, ]; -export const backendProfiles = backendProfileSpecs.map((item) => item.profile) as readonly BackendProfile[]; +export const backendProfileSpecs = builtinBackendProfileSpecs; + +export const backendProfiles = builtinBackendProfileSpecs.map((item) => item.profile) as readonly BackendProfile[]; + +export function defaultSecretNameForProfile(profile: string): string { + return `agentrun-v01-provider-${profile}`; +} + +function dynamicBackendProfileSpec(profile: string): BackendProfileSpec { + return { + profile, + backendKind: "codex-app-server-stdio", + protocol: "codex-app-server-jsonrpc-stdio", + transport: "stdio", + command: "codex app-server --listen stdio://", + status: "registered", + requiredSecretKeys: ["auth.json", "config.toml"], + defaultSecretName: defaultSecretNameForProfile(profile), + profileIsolation: "profile-scoped-codex-home", + description: `Dynamic Codex-compatible profile ${profile}`, + }; +} export function backendProfileSpec(profile: string): BackendProfileSpec | null { - return backendProfileSpecs.find((item) => item.profile === profile) ?? null; + if (!isBackendProfileSlug(profile)) return null; + return builtinBackendProfileSpecs.find((item) => item.profile === profile) ?? dynamicBackendProfileSpec(profile); +} + +export function isBackendProfileSlug(value: string): boolean { + return backendProfileIdPattern.test(value); } export function isBackendProfile(value: string): value is BackendProfile { - return backendProfileSpec(value) !== null; + return isBackendProfileSlug(value); } export function backendCapability(spec: BackendProfileSpec): JsonRecord { @@ -111,7 +139,7 @@ export function mergeBackendCapability(profile: string, storedCapabilities: Json } export function backendCapabilities(): JsonRecord[] { - return backendProfileSpecs.map(backendCapability); + return builtinBackendProfileSpecs.map(backendCapability); } export function backendCapabilitiesSqlValues(profiles?: readonly BackendProfile[]): string { @@ -121,7 +149,7 @@ export function backendCapabilitiesSqlValues(profiles?: readonly BackendProfile[ if (!spec) throw new Error(`unknown backend profile for SQL seed: ${profile}`); return spec; }) - : backendProfileSpecs; + : builtinBackendProfileSpecs; return specs.map((spec) => { const capabilities = JSON.stringify({ backendKind: spec.backendKind, diff --git a/src/common/types.ts b/src/common/types.ts index f3a3ad2..271ec96 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -28,7 +28,7 @@ export type FailureKind = export type RunStatus = "pending" | "claimed" | "running" | "completed" | "failed" | "blocked" | "cancelled"; export type CommandState = "pending" | "acknowledged" | "completed" | "failed" | "cancelled"; export type TerminalStatus = "completed" | "failed" | "blocked" | "cancelled"; -export type BackendProfile = "codex" | "deepseek" | "minimax-m3" | "dsflash-go"; +export type BackendProfile = string; export type QueueTaskState = "pending" | "running" | "completed" | "failed" | "blocked" | "cancelled"; export type SessionExecutionState = "idle" | "running" | "terminal"; export type SessionAttentionState = "active" | "unread" | "read"; diff --git a/src/common/validation.ts b/src/common/validation.ts index 087e8eb..989acef 100644 --- a/src/common/validation.ts +++ b/src/common/validation.ts @@ -1,7 +1,9 @@ import { createHash, randomUUID } from "node:crypto"; import type { BackendProfile, CreateCommandInput, CreateQueueTaskInput, CreateRunInput, ExecutionPolicy, JsonRecord, JsonValue, QueueTaskState, ResourceBundleRef, SecretRef, SessionListState, SessionRef } from "./types.js"; import { AgentRunError } from "./errors.js"; -import { backendProfileSpec, backendProfiles, isBackendProfile } from "./backend-profiles.js"; +import { backendProfileIdPattern, backendProfileSpec, isBackendProfile } from "./backend-profiles.js"; + +const backendProfilePatternText = String(backendProfileIdPattern); const allowedTenants = new Set(["unidesk", "hwlab"]); const allowedToolCredentials = ["github", "unidesk-ssh"] as const; @@ -44,7 +46,7 @@ export function validateCreateRun(input: unknown): CreateRunInput { const tenantId = requiredString(record, "tenantId"); if (!allowedTenants.has(tenantId)) throw new AgentRunError("tenant-policy-denied", `tenantId ${tenantId} is not allowed`, { httpStatus: 403 }); const backendProfileValue = requiredString(record, "backendProfile"); - if (!isBackendProfile(backendProfileValue)) throw new AgentRunError("schema-invalid", `backendProfile ${backendProfileValue} is not supported in v0.1`, { httpStatus: 400, details: { allowedBackends: [...backendProfiles] } }); + if (!isBackendProfile(backendProfileValue)) throw new AgentRunError("schema-invalid", `backendProfile ${backendProfileValue} must be a lowercase slug`, { httpStatus: 400, details: { pattern: backendProfilePatternText } }); const backendProfile = backendProfileValue as BackendProfile; const executionPolicy = validateExecutionPolicy(requiredRecord(record, "executionPolicy")); validateBackendSecretScope(backendProfile, executionPolicy); @@ -214,7 +216,7 @@ export function validateExecutionPolicy(record: JsonRecord): ExecutionPolicy { const item = asRecord(credential, "providerCredential"); const profile = typeof item.profile === "string" ? item.profile.trim() : ""; if (profile.length === 0) throw new AgentRunError("schema-invalid", "provider credential profile is required", { httpStatus: 400 }); - if (!isBackendProfile(profile)) throw new AgentRunError("schema-invalid", `provider credential profile ${profile} is not supported in v0.1`, { httpStatus: 400, details: { allowedBackends: [...backendProfiles] } }); + if (!isBackendProfile(profile)) throw new AgentRunError("schema-invalid", `provider credential profile ${profile} must be a lowercase slug`, { httpStatus: 400, details: { pattern: backendProfilePatternText } }); const secretRef = asRecord(item.secretRef, "providerCredential.secretRef"); if (typeof secretRef.name !== "string" || secretRef.name.length === 0) throw new AgentRunError("schema-invalid", "provider credential secretRef.name is required", { httpStatus: 400 }); const keys = Array.isArray(secretRef.keys) ? secretRef.keys : []; @@ -327,7 +329,7 @@ export function validateCreateQueueTask(input: unknown): CreateQueueTaskInput { const tenantId = requiredString(record, "tenantId"); if (!allowedTenants.has(tenantId)) throw new AgentRunError("tenant-policy-denied", `tenantId ${tenantId} is not allowed`, { httpStatus: 403 }); const backendProfileValue = optionalString(record.backendProfile) ?? "codex"; - if (!isBackendProfile(backendProfileValue)) throw new AgentRunError("schema-invalid", `backendProfile ${backendProfileValue} is not supported in v0.1`, { httpStatus: 400, details: { allowedBackends: [...backendProfiles] } }); + if (!isBackendProfile(backendProfileValue)) throw new AgentRunError("schema-invalid", `backendProfile ${backendProfileValue} must be a lowercase slug`, { httpStatus: 400, details: { pattern: backendProfilePatternText } }); const queue = optionalString(record.queue) ?? "default"; const lane = optionalString(record.lane) ?? "default"; const priorityValue = record.priority ?? 0; @@ -367,5 +369,5 @@ export function validateSessionListState(value: string): SessionListState { export function validateBackendProfile(value: string): BackendProfile { if (isBackendProfile(value)) return value; - throw new AgentRunError("schema-invalid", `backendProfile ${value} is not supported in v0.1`, { httpStatus: 400, details: { allowedBackends: [...backendProfiles] } }); + throw new AgentRunError("schema-invalid", `backendProfile ${value} must be a lowercase slug`, { httpStatus: 400, details: { pattern: backendProfilePatternText } }); } diff --git a/src/mgr/provider-profiles.ts b/src/mgr/provider-profiles.ts index 2081476..86a598d 100644 --- a/src/mgr/provider-profiles.ts +++ b/src/mgr/provider-profiles.ts @@ -1,7 +1,7 @@ import { createHash, randomUUID } from "node:crypto"; import { spawn } from "node:child_process"; import { AgentRunError } from "../common/errors.js"; -import { backendProfileSpec, backendProfileSpecs } from "../common/backend-profiles.js"; +import { backendProfileSpec, backendProfileSpecs, isBackendProfileSlug } from "../common/backend-profiles.js"; import type { AgentRunStore } from "./store.js"; import type { BackendProfile, ExecutionPolicy, JsonRecord, JsonValue } from "../common/types.js"; import { asRecord, validateBackendProfile } from "../common/validation.js"; @@ -13,6 +13,7 @@ import { runnerJobStatusSummary } from "./runner-job-status.js"; const defaultNamespace = "agentrun-v01"; const credentialAnnotationPrefix = "agentrun.pikastech.local/provider-profile"; const kubectlLastAppliedAnnotation = "kubectl.kubernetes.io/last-applied-configuration"; +const providerSecretNamePrefix = "agentrun-v01-provider-"; export interface ProviderProfileOptions { namespace?: string; @@ -40,7 +41,8 @@ interface RenderedConfig { } export async function listProviderProfiles(options: ProviderProfileOptions = {}): Promise { - const items = await Promise.all(backendProfileSpecs.map((spec) => providerProfileStatus(spec.profile, options))); + const profiles = await listProviderProfileIds(options); + const items = await Promise.all(profiles.map((profile) => providerProfileStatus(profile, options))); return { items, count: items.length, valuesPrinted: false }; } @@ -81,10 +83,8 @@ export async function setProviderProfileConfig(profileValue: string, body: unkno 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 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", @@ -105,30 +105,28 @@ export async function setProviderProfileConfig(profileValue: string, body: unkno }, }, type: "Opaque", - data: { - "auth.json": authJsonData, - "config.toml": base64Data(configToml), - }, + data: providerProfileSecretData({ authJsonData, configToml }), }; const applied = await kubectlUpsertSecret(secretManifest, options.kubectlCommand ?? "kubectl"); return { action: "provider-profile-config-updated", mutation: true, profile, - configured: true, + configured: Boolean(authJsonData), secretRef: secretRefSummary(profile, namespace), resourceVersion: objectPath(applied, ["metadata", "resourceVersion"]), - credentialHashSuffix, + credentialHashSuffix: credentialHashSuffix ?? null, configHashSuffix: shortHash(configToml), updatedAt: objectPath(applied, ["metadata", "annotations", `${credentialAnnotationPrefix}-updated-at`]) ?? new Date().toISOString(), delegatedBy, - requiresExternalBridgeUpdate: profile === "deepseek" || profile === "dsflash-go", + requiresExternalBridgeUpdate: profileUsesMoonBridge(profile, configToml), configTomlPrinted: false, credentialValuesPrinted: false, valuesPrinted: false, pollCommands: { config: `./scripts/agentrun provider-profiles config ${profile}`, show: `./scripts/agentrun provider-profiles show ${profile}`, + setKey: `./scripts/agentrun provider-profiles set-key ${profile} --key-stdin`, validate: `./scripts/agentrun provider-profiles validate ${profile} --wait --timeout-ms 120000`, }, }; @@ -180,7 +178,7 @@ export async function setProviderProfileCredential(profileValue: string, body: u updatedAt: objectPath(applied, ["metadata", "annotations", `${credentialAnnotationPrefix}-updated-at`]) ?? new Date().toISOString(), configSummary: rendered.config.configSummary, delegatedBy, - requiresExternalBridgeUpdate: profile === "deepseek" || profile === "dsflash-go", + requiresExternalBridgeUpdate: profileUsesMoonBridge(profile, rendered.config.configToml), valuesPrinted: false, pollCommands: { show: `./scripts/agentrun provider-profiles show ${profile}`, @@ -284,6 +282,46 @@ async function providerProfileStatus(profile: BackendProfile, options: ProviderP }; } +async function listProviderProfileIds(options: ProviderProfileOptions): Promise { + const profiles = new Set(backendProfileSpecs.map((spec) => spec.profile)); + const namespace = profileNamespace(options); + const secrets = await kubectlListSecrets(namespace, options.kubectlCommand ?? "kubectl"); + for (const item of secrets) { + const secretProfile = providerProfileFromSecret(item); + if (secretProfile) profiles.add(secretProfile); + } + return [...profiles].sort(compareProviderProfiles); +} + +function compareProviderProfiles(left: string, right: string): number { + const leftIndex = backendProfileSpecs.findIndex((item) => item.profile === left); + const rightIndex = backendProfileSpecs.findIndex((item) => item.profile === right); + if (leftIndex >= 0 || rightIndex >= 0) { + if (leftIndex < 0) return 1; + if (rightIndex < 0) return -1; + if (leftIndex !== rightIndex) return leftIndex - rightIndex; + } + return left.localeCompare(right); +} + +function providerProfileFromSecret(secret: JsonRecord): BackendProfile | null { + const metadata = asOptionalRecord(secret.metadata); + const labels = asOptionalRecord(metadata?.labels); + const annotatedProfile = stringPath(asOptionalRecord(metadata?.annotations), [`${credentialAnnotationPrefix}-profile`]); + const labeledProfile = stringPath(labels, ["agentrun.pikastech.local/profile"]); + const namedProfile = profileFromSecretName(stringPath(metadata, ["name"])); + for (const candidate of [annotatedProfile, labeledProfile, namedProfile]) { + if (candidate && isBackendProfileSlug(candidate)) return candidate; + } + return null; +} + +function profileFromSecretName(name: string | null): string | null { + if (!name || !name.startsWith(providerSecretNamePrefix)) return null; + const profile = name.slice(providerSecretNamePrefix.length); + return profile.length > 0 ? profile : null; +} + function renderCredential(apiKey: string, config: RenderedConfig): { authJson: string; config: RenderedConfig } { const authJson = `${JSON.stringify(authPayload(apiKey))}\n`; return { @@ -292,6 +330,12 @@ function renderCredential(apiKey: string, config: RenderedConfig): { authJson: s }; } +function providerProfileSecretData(input: { authJsonData?: string | null; configToml: string }): JsonRecord { + const data: JsonRecord = { "config.toml": base64Data(input.configToml) }; + if (input.authJsonData) data["auth.json"] = input.authJsonData; + return data; +} + function authPayload(apiKey: string): JsonRecord { return { OPENAI_API_KEY: apiKey }; } @@ -445,6 +489,10 @@ function defaultConfig(profile: BackendProfile): ProfileConfig { }; } +function profileUsesMoonBridge(profile: BackendProfile, configToml: string): boolean { + return profile === "deepseek" || profile === "dsflash-go" || configToml.includes("hwlab-deepseek-proxy.hwlab-v02.svc.cluster.local"); +} + function validateBaseUrl(profile: BackendProfile, value: string): void { let url: URL; try { @@ -525,7 +573,7 @@ function secretRefSummary(profile: BackendProfile, namespace: string): JsonRecor function requiredSpec(profile: BackendProfile) { const spec = backendProfileSpec(profile); - if (!spec) throw new AgentRunError("schema-invalid", `backendProfile ${profile} is not supported in v0.1`, { httpStatus: 400 }); + if (!spec) throw new AgentRunError("schema-invalid", `backendProfile ${profile} must be a lowercase slug`, { httpStatus: 400 }); return spec; } @@ -543,6 +591,16 @@ async function kubectlGetSecret(name: string, namespace: string, kubectlCommand: return parseKubectlObject(result.stdout, "secret"); } +async function kubectlListSecrets(namespace: string, kubectlCommand: string): Promise { + const result = await runKubectl(kubectlCommand, ["get", "secrets", "-n", namespace, "-o", "json"]); + if (result.code !== 0) { + throw new AgentRunError("infra-failed", `kubectl get secrets ${namespace} failed with code ${result.code}`, { httpStatus: 502, details: redactJson({ stderr: redactText(result.stderr.slice(-2000)), stdout: redactText(result.stdout.slice(-1000)) }) }); + } + const parsed = parseKubectlObject(result.stdout, "secret-list"); + const items = Array.isArray(parsed.items) ? parsed.items : []; + return items.filter((item): item is JsonRecord => typeof item === "object" && item !== null && !Array.isArray(item)); +} + async function kubectlUpsertSecret(manifest: JsonRecord, kubectlCommand: string): Promise { const name = stringPath(manifest, ["metadata", "name"]) ?? ""; const namespace = stringPath(manifest, ["metadata", "namespace"]) ?? defaultNamespace; diff --git a/src/selftest/cases/45-provider-profile-management.ts b/src/selftest/cases/45-provider-profile-management.ts index 36c89eb..96794e4 100644 --- a/src/selftest/cases/45-provider-profile-management.ts +++ b/src/selftest/cases/45-provider-profile-management.ts @@ -12,12 +12,9 @@ const secretText = "sk-selftest-provider-profile-secret"; const selfTest: SelfTestCase = async (context) => { const gitopsRenderer = await readFile(path.join(context.root, "scripts/src/gitops-render.ts"), "utf8"); assert.equal(gitopsRenderer.includes("agentrun-v01-mgr-provider-secret-manager"), true); - assert.equal(gitopsRenderer.includes('verbs: ["create"]'), true); - assert.equal(gitopsRenderer.includes('verbs: ["get", "patch", "update"]'), true); - assert.equal(gitopsRenderer.includes('resourceNames: ["agentrun-v01-provider-codex", "agentrun-v01-provider-deepseek", "agentrun-v01-provider-minimax-m3", "agentrun-v01-provider-dsflash-go"]'), true); - for (const profile of ["codex", "deepseek", "minimax-m3", "dsflash-go"]) { - assert.equal(gitopsRenderer.includes(`agentrun-v01-provider-${profile}`), true); - } + assert.equal(gitopsRenderer.includes('verbs: ["create", "get", "list", "patch", "update"]'), true); + assert.equal(gitopsRenderer.includes('resourceNames: ["agentrun-v01-provider-codex", "agentrun-v01-provider-deepseek", "agentrun-v01-provider-minimax-m3", "agentrun-v01-provider-dsflash-go"]'), false); + assert.equal(gitopsRenderer.includes('resources: ["secrets"]'), true); const fakeKubectl = path.join(context.tmp, "fake-provider-kubectl.js"); const replacedSecretPath = path.join(context.tmp, "provider-secret-replace.json"); @@ -42,11 +39,22 @@ if (args[0] === "get" && args[1] === "secret") { console.log(await stateFile.text()); process.exit(0); } - if (name === "agentrun-v01-provider-dsflash-go") { + if (name === "agentrun-v01-provider-dsflash-go" || name === "agentrun-v01-provider-dsflash-go-cli-selftest") { console.error('Error from server (NotFound): secrets "' + name + '" not found'); process.exit(1); } - console.log(JSON.stringify(fixtureSecret(name))); + const fixture = fixtureSecret(name); + await Bun.write(secretStatePath(name), JSON.stringify(fixture)); + console.log(JSON.stringify(fixture)); + process.exit(0); +} +if (args[0] === "get" && args[1] === "secrets") { + const items = [fixtureSecret("agentrun-v01-provider-codex"), fixtureSecret("agentrun-v01-provider-deepseek"), fixtureSecret("agentrun-v01-provider-minimax-m3")]; + const dsflashState = Bun.file(secretStatePath("agentrun-v01-provider-dsflash-go")); + if (await dsflashState.exists()) items.push(JSON.parse(await dsflashState.text())); + const dynamicState = Bun.file(secretStatePath("agentrun-v01-provider-dsflash-go-cli-selftest")); + if (await dynamicState.exists()) items.push(JSON.parse(await dynamicState.text())); + console.log(JSON.stringify({ apiVersion: "v1", kind: "SecretList", items })); process.exit(0); } if (args[0] === "apply") { @@ -71,12 +79,10 @@ if (args[0] === "patch" && args[1] === "secret") { if (args[0] === "replace") { const text = await readStdin(); const manifest = JSON.parse(text); - if (manifest.metadata?.name === "agentrun-v01-provider-dsflash-go") { - const stateFile = Bun.file(secretStatePath(manifest.metadata.name)); - if (!(await stateFile.exists())) { - console.error('Error from server (NotFound): secrets "' + manifest.metadata.name + '" not found'); - process.exit(1); - } + const stateFile = Bun.file(secretStatePath(manifest.metadata.name)); + if (!(await stateFile.exists())) { + console.error('Error from server (NotFound): secrets "' + manifest.metadata.name + '" not found'); + process.exit(1); } await Bun.write(${JSON.stringify(replacedSecretPath)}, JSON.stringify({ args, manifest }, null, 2)); const annotations = manifest.metadata?.annotations ?? {}; @@ -148,6 +154,27 @@ process.exit(1); 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 dynamicProfile = "dsflash-go-cli-selftest"; + const dynamicConfigToml = "model_provider = \"opencode\"\nmodel = \"deepseek-v4-flash\"\nreview_model = \"deepseek-v4-flash\"\nbase_url = \"http://hwlab-deepseek-proxy.hwlab-v02.svc.cluster.local:4000/v1\"\n"; + const dynamicConfig = await client.put(`/api/v1/provider-profiles/${encodeURIComponent(dynamicProfile)}/config`, { + configToml: dynamicConfigToml, + delegatedBy: { system: "hwlab-v02", userId: "u-dynamic", username: "dynamic", requestId: "req-config-dynamic-selftest" }, + reason: "self-test-dynamic-config", + }) as JsonRecord; + assert.equal(dynamicConfig.profile, dynamicProfile); + assert.equal(dynamicConfig.configured, false); + assert.equal(dynamicConfig.credentialHashSuffix, null); + const dynamicCreateRecord = JSON.parse(await readFile(createdSecretPath, "utf8")) as JsonRecord; + const dynamicCreateManifest = dynamicCreateRecord.manifest as JsonRecord; + assert.equal(((dynamicCreateManifest.metadata as JsonRecord).name), `agentrun-v01-provider-${dynamicProfile}`); + const dynamicCreateData = dynamicCreateManifest.data as JsonRecord; + assert.equal(Object.hasOwn(dynamicCreateData, "auth.json"), false); + assert.equal(Buffer.from(String(dynamicCreateData["config.toml"]), "base64").toString("utf8"), dynamicConfigToml); + + const dynamicShownAfterConfig = await client.get(`/api/v1/provider-profiles/${encodeURIComponent(dynamicProfile)}`) as JsonRecord; + assert.equal(dynamicShownAfterConfig.configured, false); + assert.equal(dynamicShownAfterConfig.failureKind, "secret-unavailable"); + const updated = await client.put("/api/v1/provider-profiles/deepseek/credential", { apiKey: secretText, delegatedBy: { system: "hwlab-v02", userId: "u1", username: "tester", requestId: "req-selftest" }, @@ -205,6 +232,30 @@ process.exit(1); assert.equal(dsflashShown.failureKind, null); assertNoSecretLeak(dsflashCreated); + const dynamicCredential = await client.put(`/api/v1/provider-profiles/${encodeURIComponent(dynamicProfile)}/credential`, { + apiKey: secretText, + delegatedBy: { system: "hwlab-v02", userId: "u3", username: "tester3", requestId: "req-dynamic-create-selftest" }, + reason: "self-test-dynamic-create", + }) as JsonRecord; + assert.equal(dynamicCredential.profile, dynamicProfile); + assert.equal(dynamicCredential.resourceVersion, "rv-cleaned"); + assert.equal(dynamicCredential.requiresExternalBridgeUpdate, true); + const dynamicReplaceRecord = JSON.parse(await readFile(replacedSecretPath, "utf8")) as JsonRecord; + const dynamicReplaceManifest = dynamicReplaceRecord.manifest as JsonRecord; + assert.equal(((dynamicReplaceManifest.metadata as JsonRecord).name), `agentrun-v01-provider-${dynamicProfile}`); + const dynamicReplaceData = dynamicReplaceManifest.data as JsonRecord; + assert.equal(Buffer.from(String(dynamicReplaceData["auth.json"]), "base64").toString("utf8").includes(secretText), true); + assert.equal(Buffer.from(String(dynamicReplaceData["config.toml"]), "base64").toString("utf8"), dynamicConfigToml); + const dynamicShown = await client.get(`/api/v1/provider-profiles/${encodeURIComponent(dynamicProfile)}`) as JsonRecord; + assert.equal(dynamicShown.configured, true); + assert.equal(dynamicShown.failureKind, null); + + const listAfterDynamic = await client.get("/api/v1/provider-profiles") as JsonRecord; + assert.equal(listAfterDynamic.count, 5); + const dynamicListItems = (listAfterDynamic.items as JsonRecord[]) ?? []; + assert.equal(dynamicListItems.some((item) => item.profile === dynamicProfile), true); + assertNoSecretLeak(dynamicCredential); + await assert.rejects( () => client.put("/api/v1/provider-profiles/deepseek/credential", { apiKey: secretText, config: { baseUrl: "https://hyueapi.com/v1" } }), (error) => error instanceof Error && error.message.includes("not hyueapi.com"), @@ -228,7 +279,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-config", "provider-profile-set-key-redacted", "provider-profile-secret-replace-annotation-cleanup", "provider-profile-secret-create-upsert", "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-secret-create-upsert", "provider-profile-config-only-create", "provider-profile-dynamic-slug-roundtrip", "provider-profile-deepseek-moon-bridge", "provider-profile-manager-secret-rbac", "provider-profile-validation-runner-job"] }; } finally { await new Promise((resolve) => server.server.close(() => resolve())); }