diff --git a/src/mgr/provider-profiles.ts b/src/mgr/provider-profiles.ts index a447f42..44bd15c 100644 --- a/src/mgr/provider-profiles.ts +++ b/src/mgr/provider-profiles.ts @@ -167,10 +167,9 @@ export async function setProviderProfileCredential(profileValue: string, body: u const profile = validateBackendProfile(profileValue); const spec = requiredSpec(profile); const record = asRecord(body ?? {}, "providerProfileCredential"); - const apiKey = stringField(record, "apiKey"); - if (apiKey.length < 8) throw new AgentRunError("secret-unavailable", "apiKey is too short", { httpStatus: 400 }); + const credential = credentialAuthJson(record); const delegatedBy = delegatedBySummary(record.delegatedBy); - const rendered = renderCredential(apiKey, await renderedConfigForWrite(profile, record, options)); + const renderedConfig = await renderedConfigForWrite(profile, record, options); const namespace = profileNamespace(options); const secretManifest: JsonRecord = { apiVersion: "v1", @@ -184,16 +183,16 @@ export async function setProviderProfileCredential(profileValue: string, body: u }, annotations: { [`${credentialAnnotationPrefix}-profile`]: profile, - [`${credentialAnnotationPrefix}-credential-hash-suffix`]: shortHash(rendered.authJson), - [`${credentialAnnotationPrefix}-config-hash-suffix`]: shortHash(rendered.config.configToml), + [`${credentialAnnotationPrefix}-credential-hash-suffix`]: shortHash(credential.authJson), + [`${credentialAnnotationPrefix}-config-hash-suffix`]: shortHash(renderedConfig.configToml), [`${credentialAnnotationPrefix}-updated-at`]: new Date().toISOString(), ...(delegatedBy ? { [`${credentialAnnotationPrefix}-delegated-system`]: delegatedBy.system, [`${credentialAnnotationPrefix}-delegated-request-id`]: delegatedBy.requestId ?? "" } : {}), }, }, type: "Opaque", data: { - "auth.json": base64Data(rendered.authJson), - "config.toml": base64Data(rendered.config.configToml), + "auth.json": base64Data(credential.authJson), + "config.toml": base64Data(renderedConfig.configToml), }, }; const applied = await kubectlUpsertSecret(secretManifest, options.kubectlCommand ?? "kubectl"); @@ -204,12 +203,13 @@ export async function setProviderProfileCredential(profileValue: string, body: u configured: true, secretRef: secretRefSummary(profile, namespace), resourceVersion: objectPath(applied, ["metadata", "resourceVersion"]), - credentialHashSuffix: shortHash(rendered.authJson), - configHashSuffix: shortHash(rendered.config.configToml), + credentialHashSuffix: shortHash(credential.authJson), + configHashSuffix: shortHash(renderedConfig.configToml), updatedAt: objectPath(applied, ["metadata", "annotations", `${credentialAnnotationPrefix}-updated-at`]) ?? new Date().toISOString(), - configSummary: rendered.config.configSummary, + credentialSource: credential.source, + configSummary: renderedConfig.configSummary, delegatedBy, - requiresExternalBridgeUpdate: profileUsesMoonBridge(profile, rendered.config.configToml), + requiresExternalBridgeUpdate: profileUsesMoonBridge(profile, renderedConfig.configToml), valuesPrinted: false, pollCommands: { show: `./scripts/agentrun provider-profiles show ${profile}`, @@ -357,20 +357,32 @@ function profileFromSecretName(name: string | null): string | null { return profile.length > 0 ? profile : null; } -function renderCredential(apiKey: string, config: RenderedConfig): { authJson: string; config: RenderedConfig } { - const authJson = `${JSON.stringify(authPayload(apiKey))}\n`; - return { - authJson, - config, - }; -} - 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 credentialAuthJson(record: JsonRecord): { authJson: string; source: "auth-json" | "api-key" } { + const authJson = record.authJson; + if (typeof authJson === "string" && authJson.trim().length > 0) return { authJson: authJsonField(authJson), source: "auth-json" }; + const apiKey = stringField(record, "apiKey"); + if (apiKey.length < 8) throw new AgentRunError("secret-unavailable", "apiKey is too short", { httpStatus: 400 }); + return { authJson: `${JSON.stringify(authPayload(apiKey))}\n`, source: "api-key" }; +} + +function authJsonField(value: string): string { + if (!value.trim()) throw new AgentRunError("schema-invalid", "authJson is required", { httpStatus: 400 }); + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new AgentRunError("schema-invalid", "authJson must be valid JSON", { httpStatus: 400 }); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new AgentRunError("schema-invalid", "authJson must be a JSON object", { httpStatus: 400 }); + return value; +} + function authPayload(apiKey: string): JsonRecord { return { OPENAI_API_KEY: apiKey }; } diff --git a/src/selftest/cases/45-provider-profile-management.ts b/src/selftest/cases/45-provider-profile-management.ts index 979d96a..3425476 100644 --- a/src/selftest/cases/45-provider-profile-management.ts +++ b/src/selftest/cases/45-provider-profile-management.ts @@ -23,11 +23,12 @@ const selfTest: SelfTestCase = async (context) => { const createdJobPath = path.join(context.tmp, "provider-validation-job.json"); const secretStateDir = path.join(context.tmp, "provider-secret-state"); await writeFile(fakeKubectl, `#!/usr/bin/env bun -import { rmSync } from "node:fs"; +import { mkdirSync, rmSync } from "node:fs"; const args = Bun.argv.slice(2); const secretStateDir = ${JSON.stringify(secretStateDir)}; const secretStatePath = (name) => secretStateDir + "/" + name + ".json"; const deletedMarkerPath = (name) => secretStateDir + "/" + name + ".deleted"; +mkdirSync(secretStateDir, { recursive: true }); const fixtureSecret = (name) => ({ apiVersion: "v1", kind: "Secret", metadata: { name, namespace: "agentrun-v01", resourceVersion: "rv-selftest", creationTimestamp: "2026-06-05T00:00:00.000Z" }, data: { "auth.json": Buffer.from(JSON.stringify({ token: "redacted-fixture" })).toString("base64"), "config.toml": Buffer.from("model = \\\"fixture\\\"\\n").toString("base64") } }); const readStdin = async () => { const chunks = []; @@ -119,7 +120,7 @@ if (args[0] === "create") { if (args[0] === "delete" && args[1] === "secret") { const name = args[2]; try { rmSync(secretStatePath(name)); } catch {} - await Bun.write(deletedMarkerPath(name), "deleted\n"); + await Bun.write(deletedMarkerPath(name), "deleted\\n"); console.log(JSON.stringify({ kind: "Status", status: "Success", details: { name } })); process.exit(0); } @@ -265,6 +266,24 @@ process.exit(1); 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 directAuthJsonSecret = "sk-selftest-auth-json-direct"; + const directAuthJson = `${JSON.stringify({ OPENAI_API_KEY: directAuthJsonSecret })}\n`; + const dynamicAuthJsonCredential = await client.put(`/api/v1/provider-profiles/${encodeURIComponent(dynamicProfile)}/credential`, { + authJson: directAuthJson, + delegatedBy: { system: "hwlab-v02", userId: "u4", username: "tester4", requestId: "req-dynamic-auth-json-selftest" }, + reason: "self-test-dynamic-auth-json", + }) as JsonRecord; + assert.equal(dynamicAuthJsonCredential.profile, dynamicProfile); + assert.equal(dynamicAuthJsonCredential.credentialSource, "auth-json"); + assert.equal(JSON.stringify(dynamicAuthJsonCredential).includes(directAuthJsonSecret), false); + assertNoSecretLeak(dynamicAuthJsonCredential); + const dynamicAuthJsonReplaceRecord = JSON.parse(await readFile(replacedSecretPath, "utf8")) as JsonRecord; + const dynamicAuthJsonReplaceManifest = dynamicAuthJsonReplaceRecord.manifest as JsonRecord; + const dynamicAuthJsonData = dynamicAuthJsonReplaceManifest.data as JsonRecord; + assert.equal(Buffer.from(String(dynamicAuthJsonData["auth.json"]), "base64").toString("utf8"), directAuthJson); + assert.equal(Buffer.from(String(dynamicAuthJsonData["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); @@ -322,7 +341,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-config-only-create", "provider-profile-dynamic-slug-roundtrip", "provider-profile-remove-builtin", "provider-profile-remove-dynamic-slug", "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-set-auth-json-redacted", "provider-profile-secret-replace-annotation-cleanup", "provider-profile-secret-create-upsert", "provider-profile-config-only-create", "provider-profile-dynamic-slug-roundtrip", "provider-profile-remove-builtin", "provider-profile-remove-dynamic-slug", "provider-profile-deepseek-moon-bridge", "provider-profile-manager-secret-rbac", "provider-profile-validation-runner-job"] }; } finally { await new Promise((resolve) => server.server.close(() => resolve())); }