diff --git a/src/agents/auth-profiles/oauth.ts b/src/agents/auth-profiles/oauth.ts index 25d99348a..7303a2ec0 100644 --- a/src/agents/auth-profiles/oauth.ts +++ b/src/agents/auth-profiles/oauth.ts @@ -99,6 +99,8 @@ type ResolveApiKeyForProfileParams = { agentDir?: string; }; +type SecretDefaults = NonNullable["defaults"]; + function adoptNewerMainOAuthCredential(params: { store: AuthProfileStore; profileId: string; @@ -236,6 +238,57 @@ async function tryResolveOAuthProfile( }); } +async function resolveProfileSecretString(params: { + profileId: string; + provider: string; + value: string | undefined; + valueRef: unknown; + refDefaults: SecretDefaults | undefined; + configForRefResolution: OpenClawConfig; + cache: SecretRefResolveCache; + inlineFailureMessage: string; + refFailureMessage: string; +}): Promise { + let resolvedValue = params.value?.trim(); + if (resolvedValue) { + const inlineRef = coerceSecretRef(resolvedValue, params.refDefaults); + if (inlineRef) { + try { + resolvedValue = await resolveSecretRefString(inlineRef, { + config: params.configForRefResolution, + env: process.env, + cache: params.cache, + }); + } catch (err) { + log.debug(params.inlineFailureMessage, { + profileId: params.profileId, + provider: params.provider, + error: err instanceof Error ? err.message : String(err), + }); + } + } + } + + const explicitRef = coerceSecretRef(params.valueRef, params.refDefaults); + if (!resolvedValue && explicitRef) { + try { + resolvedValue = await resolveSecretRefString(explicitRef, { + config: params.configForRefResolution, + env: process.env, + cache: params.cache, + }); + } catch (err) { + log.debug(params.refFailureMessage, { + profileId: params.profileId, + provider: params.provider, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + return resolvedValue; +} + export async function resolveApiKeyForProfile( params: ResolveApiKeyForProfileParams, ): Promise<{ apiKey: string; provider: string; email?: string } | null> { @@ -262,82 +315,34 @@ export async function resolveApiKeyForProfile( const refDefaults = configForRefResolution.secrets?.defaults; if (cred.type === "api_key") { - let key = cred.key?.trim(); - if (key) { - const inlineRef = coerceSecretRef(key, refDefaults); - if (inlineRef) { - try { - key = await resolveSecretRefString(inlineRef, { - config: configForRefResolution, - env: process.env, - cache: refResolveCache, - }); - } catch (err) { - log.debug("failed to resolve inline auth profile api_key ref", { - profileId, - provider: cred.provider, - error: err instanceof Error ? err.message : String(err), - }); - } - } - } - const keyRef = coerceSecretRef(cred.keyRef, refDefaults); - if (!key && keyRef) { - try { - key = await resolveSecretRefString(keyRef, { - config: configForRefResolution, - env: process.env, - cache: refResolveCache, - }); - } catch (err) { - log.debug("failed to resolve auth profile api_key ref", { - profileId, - provider: cred.provider, - error: err instanceof Error ? err.message : String(err), - }); - } - } + const key = await resolveProfileSecretString({ + profileId, + provider: cred.provider, + value: cred.key, + valueRef: cred.keyRef, + refDefaults, + configForRefResolution, + cache: refResolveCache, + inlineFailureMessage: "failed to resolve inline auth profile api_key ref", + refFailureMessage: "failed to resolve auth profile api_key ref", + }); if (!key) { return null; } return buildApiKeyProfileResult({ apiKey: key, provider: cred.provider, email: cred.email }); } if (cred.type === "token") { - let token = cred.token?.trim(); - if (token) { - const inlineRef = coerceSecretRef(token, refDefaults); - if (inlineRef) { - try { - token = await resolveSecretRefString(inlineRef, { - config: configForRefResolution, - env: process.env, - cache: refResolveCache, - }); - } catch (err) { - log.debug("failed to resolve inline auth profile token ref", { - profileId, - provider: cred.provider, - error: err instanceof Error ? err.message : String(err), - }); - } - } - } - const tokenRef = coerceSecretRef(cred.tokenRef, refDefaults); - if (!token && tokenRef) { - try { - token = await resolveSecretRefString(tokenRef, { - config: configForRefResolution, - env: process.env, - cache: refResolveCache, - }); - } catch (err) { - log.debug("failed to resolve auth profile token ref", { - profileId, - provider: cred.provider, - error: err instanceof Error ? err.message : String(err), - }); - } - } + const token = await resolveProfileSecretString({ + profileId, + provider: cred.provider, + value: cred.token, + valueRef: cred.tokenRef, + refDefaults, + configForRefResolution, + cache: refResolveCache, + inlineFailureMessage: "failed to resolve inline auth profile token ref", + refFailureMessage: "failed to resolve auth profile token ref", + }); if (!token) { return null; } diff --git a/src/commands/auth-choice.apply-helpers.test.ts b/src/commands/auth-choice.apply-helpers.test.ts index 148696c74..4dcf60b3f 100644 --- a/src/commands/auth-choice.apply-helpers.test.ts +++ b/src/commands/auth-choice.apply-helpers.test.ts @@ -184,6 +184,34 @@ describe("ensureApiKeyFromEnvOrPrompt", () => { expect(text).not.toHaveBeenCalled(); }); + it("fails ref mode without select when fallback env var is missing", async () => { + delete process.env.MINIMAX_API_KEY; + delete process.env.MINIMAX_OAUTH_TOKEN; + + const { confirm, text } = createPromptSpies({ + confirmResult: true, + textResult: "prompt-key", + }); + const setCredential = vi.fn(async () => undefined); + + await expect( + ensureApiKeyFromEnvOrPrompt({ + config: {}, + provider: "minimax", + envLabel: "MINIMAX_API_KEY", + promptMessage: "Enter key", + normalize: (value) => value.trim(), + validate: () => undefined, + prompter: createPrompter({ confirm, text }), + secretInputMode: "ref", + setCredential, + }), + ).rejects.toThrow( + 'Environment variable "MINIMAX_API_KEY" is required for --secret-input-mode ref in non-interactive onboarding.', + ); + expect(setCredential).not.toHaveBeenCalled(); + }); + it("re-prompts after provider ref validation failure and succeeds with env ref", async () => { process.env.MINIMAX_API_KEY = "env-key"; delete process.env.MINIMAX_OAUTH_TOKEN; diff --git a/src/commands/auth-choice.apply-helpers.ts b/src/commands/auth-choice.apply-helpers.ts index 1424027dd..e455b15bf 100644 --- a/src/commands/auth-choice.apply-helpers.ts +++ b/src/commands/auth-choice.apply-helpers.ts @@ -1,12 +1,12 @@ import { resolveEnvApiKey } from "../agents/model-auth.js"; import type { OpenClawConfig } from "../config/types.js"; -import { - DEFAULT_SECRET_PROVIDER_ALIAS, - type SecretInput, - type SecretRef, -} from "../config/types.secrets.js"; +import { type SecretInput, type SecretRef } from "../config/types.secrets.js"; import { encodeJsonPointerToken } from "../secrets/json-pointer.js"; import { PROVIDER_ENV_VARS } from "../secrets/provider-env-vars.js"; +import { + isValidFileSecretRefId, + resolveDefaultSecretProviderAlias, +} from "../secrets/ref-contract.js"; import { resolveSecretRefString } from "../secrets/resolve.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { formatApiKeyPreview } from "./auth-choice.api-key.js"; @@ -16,20 +16,9 @@ import type { SecretInputMode } from "./onboard-types.js"; const ENV_SOURCE_LABEL_RE = /(?:^|:\s)([A-Z][A-Z0-9_]*)$/; const ENV_SECRET_REF_ID_RE = /^[A-Z][A-Z0-9_]{0,127}$/; -const FILE_SECRET_REF_SEGMENT_RE = /^(?:[^~]|~0|~1)*$/; type SecretRefChoice = "env" | "provider"; -function isValidFileSecretRefId(value: string): boolean { - if (!value.startsWith("/")) { - return false; - } - return value - .slice(1) - .split("/") - .every((segment) => FILE_SECRET_REF_SEGMENT_RE.test(segment)); -} - function formatErrorMessage(error: unknown): string { if (error instanceof Error && typeof error.message === "string" && error.message.trim()) { return error.message; @@ -51,59 +40,33 @@ function resolveDefaultFilePointerId(provider: string): string { return `/providers/${encodeJsonPointerToken(provider)}/apiKey`; } -function resolveDefaultProviderAlias( - config: OpenClawConfig, - source: "env" | "file" | "exec", -): string { - const configured = - source === "env" - ? config.secrets?.defaults?.env - : source === "file" - ? config.secrets?.defaults?.file - : config.secrets?.defaults?.exec; - if (configured?.trim()) { - return configured.trim(); - } - const providers = config.secrets?.providers; - if (providers) { - for (const [providerName, provider] of Object.entries(providers)) { - if (provider?.source === source) { - return providerName; - } - } - } - return DEFAULT_SECRET_PROVIDER_ALIAS; -} - function resolveRefFallbackInput(params: { config: OpenClawConfig; provider: string; preferredEnvVar?: string; - envKeyValue?: string; -}): { input: SecretInput; resolvedValue: string } { +}): { ref: SecretRef; resolvedValue: string } { const fallbackEnvVar = params.preferredEnvVar ?? resolveDefaultProviderEnvVar(params.provider); - if (fallbackEnvVar) { - const value = process.env[fallbackEnvVar]?.trim(); - if (value) { - return { - input: { - source: "env", - provider: resolveDefaultProviderAlias(params.config, "env"), - id: fallbackEnvVar, - }, - resolvedValue: value, - }; - } + if (!fallbackEnvVar) { + throw new Error( + `No default environment variable mapping found for provider "${params.provider}". Set a provider-specific env var, or re-run onboarding in an interactive terminal to configure a ref.`, + ); } - if (params.envKeyValue?.trim()) { - return { - input: params.envKeyValue.trim(), - resolvedValue: params.envKeyValue.trim(), - }; + const value = process.env[fallbackEnvVar]?.trim(); + if (!value) { + throw new Error( + `Environment variable "${fallbackEnvVar}" is required for --secret-input-mode ref in non-interactive onboarding.`, + ); } - throw new Error( - `No environment variable found for provider "${params.provider}". Re-run onboarding in an interactive terminal to set a secret reference.`, - ); + return { + ref: { + source: "env", + provider: resolveDefaultSecretProviderAlias(params.config, "env", { + preferFirstProviderForSource: true, + }), + id: fallbackEnvVar, + }, + resolvedValue: value, + }; } async function resolveApiKeyRefForOnboarding(params: { @@ -163,7 +126,9 @@ async function resolveApiKeyRefForOnboarding(params: { } const ref: SecretRef = { source: "env", - provider: resolveDefaultProviderAlias(params.config, "env"), + provider: resolveDefaultSecretProviderAlias(params.config, "env", { + preferFirstProviderForSource: true, + }), id: envVar, }; const resolvedValue = await resolveSecretRefString(ref, { @@ -187,7 +152,9 @@ async function resolveApiKeyRefForOnboarding(params: { ); continue; } - const defaultProvider = resolveDefaultProviderAlias(params.config, "file"); + const defaultProvider = resolveDefaultSecretProviderAlias(params.config, "file", { + preferFirstProviderForSource: true, + }); const selectedProvider = await params.prompter.select({ message: "Select secret provider", initialValue: @@ -477,9 +444,8 @@ export async function ensureApiKeyFromEnvOrPrompt(params: { config: params.config, provider: params.provider, preferredEnvVar: envKey?.source ? extractEnvVarFromSourceLabel(envKey.source) : undefined, - envKeyValue: envKey?.apiKey, }); - await params.setCredential(fallback.input, selectedMode); + await params.setCredential(fallback.ref, selectedMode); return fallback.resolvedValue; } const resolved = await resolveApiKeyRefForOnboarding({ diff --git a/src/commands/onboard-non-interactive.provider-auth.test.ts b/src/commands/onboard-non-interactive.provider-auth.test.ts index 468f24647..077b2c6d6 100644 --- a/src/commands/onboard-non-interactive.provider-auth.test.ts +++ b/src/commands/onboard-non-interactive.provider-auth.test.ts @@ -442,6 +442,36 @@ describe("onboard (non-interactive): provider auth", () => { }, ); + it("stores the detected env alias as keyRef for opencode ref mode", async () => { + await withOnboardEnv("openclaw-onboard-ref-opencode-alias-", async ({ runtime }) => { + await withEnvAsync( + { + OPENCODE_API_KEY: undefined, + OPENCODE_ZEN_API_KEY: "opencode-zen-env-key", + }, + async () => { + await runNonInteractiveOnboardingWithDefaults(runtime, { + authChoice: "opencode-zen", + secretInputMode: "ref", + skipSkills: true, + }); + + const store = ensureAuthProfileStore(); + const profile = store.profiles["opencode:default"]; + expect(profile?.type).toBe("api_key"); + if (profile?.type === "api_key") { + expect(profile.key).toBeUndefined(); + expect(profile.keyRef).toEqual({ + source: "env", + provider: "default", + id: "OPENCODE_ZEN_API_KEY", + }); + } + }, + ); + }); + }); + it("rejects vLLM auth choice in non-interactive mode", async () => { await withOnboardEnv("openclaw-onboard-vllm-non-interactive-", async ({ runtime }) => { await expect( diff --git a/src/commands/onboard-non-interactive/api-keys.ts b/src/commands/onboard-non-interactive/api-keys.ts index 1ae231036..e55943e22 100644 --- a/src/commands/onboard-non-interactive/api-keys.ts +++ b/src/commands/onboard-non-interactive/api-keys.ts @@ -11,6 +11,14 @@ import type { SecretInputMode } from "../onboard-types.js"; export type NonInteractiveApiKeySource = "flag" | "env" | "profile"; +function parseEnvVarNameFromSourceLabel(source: string | undefined): string | undefined { + if (!source) { + return undefined; + } + const match = /^(?:shell env: |env: )([A-Z][A-Z0-9_]*)$/.exec(source.trim()); + return match?.[1]; +} + async function resolveApiKeyFromProfiles(params: { provider: string; cfg: OpenClawConfig; @@ -52,7 +60,7 @@ export async function resolveNonInteractiveApiKey(params: { allowProfile?: boolean; required?: boolean; secretInputMode?: SecretInputMode; -}): Promise<{ key: string; source: NonInteractiveApiKeySource } | null> { +}): Promise<{ key: string; source: NonInteractiveApiKeySource; envVarName?: string } | null> { const flagKey = normalizeOptionalSecretInput(params.flagValue); const envResolved = resolveEnvApiKey(params.provider); const explicitEnvVar = params.envVarName?.trim(); @@ -60,6 +68,7 @@ export async function resolveNonInteractiveApiKey(params: { ? normalizeOptionalSecretInput(process.env[explicitEnvVar]) : undefined; const resolvedEnvKey = envResolved?.apiKey ?? explicitEnvKey; + const resolvedEnvVarName = parseEnvVarNameFromSourceLabel(envResolved?.source) ?? explicitEnvVar; if (params.secretInputMode === "ref") { if (!resolvedEnvKey && flagKey) { @@ -73,7 +82,17 @@ export async function resolveNonInteractiveApiKey(params: { return null; } if (resolvedEnvKey) { - return { key: resolvedEnvKey, source: "env" }; + if (!resolvedEnvVarName) { + params.runtime.error( + [ + `--secret-input-mode ref requires an explicit environment variable for provider "${params.provider}".`, + `Set ${params.envVar} in env and retry, or use --secret-input-mode plaintext.`, + ].join("\n"), + ); + params.runtime.exit(1); + return null; + } + return { key: resolvedEnvKey, source: "env", envVarName: resolvedEnvVarName }; } } @@ -82,7 +101,7 @@ export async function resolveNonInteractiveApiKey(params: { } if (resolvedEnvKey) { - return { key: resolvedEnvKey, source: "env" }; + return { key: resolvedEnvKey, source: "env", envVarName: resolvedEnvVarName }; } if (params.allowProfile ?? true) { diff --git a/src/commands/onboard-non-interactive/local/auth-choice.ts b/src/commands/onboard-non-interactive/local/auth-choice.ts index 0222eda4f..54a38d844 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.ts @@ -4,6 +4,7 @@ import { parseDurationMs } from "../../../cli/parse-duration.js"; import type { OpenClawConfig } from "../../../config/config.js"; import type { SecretInput } from "../../../config/types.secrets.js"; import type { RuntimeEnv } from "../../../runtime.js"; +import { resolveDefaultSecretProviderAlias } from "../../../secrets/ref-contract.js"; import { normalizeSecretInput } from "../../../utils/normalize-secret-input.js"; import { normalizeSecretInputModeInput } from "../../auth-choice.apply-helpers.js"; import { buildTokenProfileId, validateAnthropicSetupToken } from "../../auth-token.js"; @@ -67,6 +68,10 @@ import { applyOpenAIConfig } from "../../openai-model-default.js"; import { detectZaiEndpoint } from "../../zai-endpoint-detect.js"; import { resolveNonInteractiveApiKey } from "../api-keys.js"; +type ResolvedNonInteractiveApiKey = NonNullable< + Awaited> +>; + export async function applyNonInteractiveAuthChoice(params: { nextConfig: OpenClawConfig; authChoice: AuthChoice; @@ -85,13 +90,50 @@ export async function applyNonInteractiveAuthChoice(params: { const apiKeyStorageOptions = requestedSecretInputMode ? { secretInputMode: requestedSecretInputMode } : undefined; - const resolveApiKey = ( - input: Parameters[0], - ): ReturnType => + const toStoredSecretInput = (resolved: ResolvedNonInteractiveApiKey): SecretInput | null => { + if (requestedSecretInputMode !== "ref") { + return resolved.key; + } + if (resolved.source !== "env") { + return resolved.key; + } + if (!resolved.envVarName) { + runtime.error( + [ + `Unable to determine which environment variable to store as a ref for provider "${authChoice}".`, + "Set an explicit provider env var and retry, or use --secret-input-mode plaintext.", + ].join("\n"), + ); + runtime.exit(1); + return null; + } + return { + source: "env", + provider: resolveDefaultSecretProviderAlias(baseConfig, "env", { + preferFirstProviderForSource: true, + }), + id: resolved.envVarName, + }; + }; + const resolveApiKey = (input: Parameters[0]) => resolveNonInteractiveApiKey({ ...input, secretInputMode: requestedSecretInputMode, }); + const maybeSetResolvedApiKey = async ( + resolved: ResolvedNonInteractiveApiKey, + setter: (value: SecretInput) => Promise | void, + ): Promise => { + if (resolved.source === "profile") { + return true; + } + const stored = toStoredSecretInput(resolved); + if (!stored) { + return false; + } + await setter(stored); + return true; + }; if (authChoice === "claude-cli" || authChoice === "codex-cli") { runtime.error( @@ -138,8 +180,12 @@ export async function applyNonInteractiveAuthChoice(params: { if (!resolved) { return null; } - if (resolved.source !== "profile") { - await setAnthropicApiKey(resolved.key, undefined, apiKeyStorageOptions); + if ( + !(await maybeSetResolvedApiKey(resolved, (value) => + setAnthropicApiKey(value, undefined, apiKeyStorageOptions), + )) + ) { + return null; } return applyAuthProfileConfig(nextConfig, { profileId: "anthropic:default", @@ -215,8 +261,12 @@ export async function applyNonInteractiveAuthChoice(params: { if (!resolved) { return null; } - if (resolved.source !== "profile") { - await setGeminiApiKey(resolved.key, undefined, apiKeyStorageOptions); + if ( + !(await maybeSetResolvedApiKey(resolved, (value) => + setGeminiApiKey(value, undefined, apiKeyStorageOptions), + )) + ) { + return null; } nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "google:default", @@ -244,8 +294,12 @@ export async function applyNonInteractiveAuthChoice(params: { if (!resolved) { return null; } - if (resolved.source !== "profile") { - await setZaiApiKey(resolved.key, undefined, apiKeyStorageOptions); + if ( + !(await maybeSetResolvedApiKey(resolved, (value) => + setZaiApiKey(value, undefined, apiKeyStorageOptions), + )) + ) { + return null; } nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "zai:default", @@ -293,8 +347,12 @@ export async function applyNonInteractiveAuthChoice(params: { if (!resolved) { return null; } - if (resolved.source !== "profile") { - await setXiaomiApiKey(resolved.key, undefined, apiKeyStorageOptions); + if ( + !(await maybeSetResolvedApiKey(resolved, (value) => + setXiaomiApiKey(value, undefined, apiKeyStorageOptions), + )) + ) { + return null; } nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "xiaomi:default", @@ -316,8 +374,12 @@ export async function applyNonInteractiveAuthChoice(params: { if (!resolved) { return null; } - if (resolved.source !== "profile") { - setXaiApiKey(resolved.key, undefined, apiKeyStorageOptions); + if ( + !(await maybeSetResolvedApiKey(resolved, (value) => + setXaiApiKey(value, undefined, apiKeyStorageOptions), + )) + ) { + return null; } nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "xai:default", @@ -339,8 +401,12 @@ export async function applyNonInteractiveAuthChoice(params: { if (!resolved) { return null; } - if (resolved.source !== "profile") { - await setMistralApiKey(resolved.key, undefined, apiKeyStorageOptions); + if ( + !(await maybeSetResolvedApiKey(resolved, (value) => + setMistralApiKey(value, undefined, apiKeyStorageOptions), + )) + ) { + return null; } nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "mistral:default", @@ -362,8 +428,12 @@ export async function applyNonInteractiveAuthChoice(params: { if (!resolved) { return null; } - if (resolved.source !== "profile") { - await setVolcengineApiKey(resolved.key, undefined, apiKeyStorageOptions); + if ( + !(await maybeSetResolvedApiKey(resolved, (value) => + setVolcengineApiKey(value, undefined, apiKeyStorageOptions), + )) + ) { + return null; } nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "volcengine:default", @@ -385,8 +455,12 @@ export async function applyNonInteractiveAuthChoice(params: { if (!resolved) { return null; } - if (resolved.source !== "profile") { - await setByteplusApiKey(resolved.key, undefined, apiKeyStorageOptions); + if ( + !(await maybeSetResolvedApiKey(resolved, (value) => + setByteplusApiKey(value, undefined, apiKeyStorageOptions), + )) + ) { + return null; } nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "byteplus:default", @@ -408,8 +482,12 @@ export async function applyNonInteractiveAuthChoice(params: { if (!resolved) { return null; } - if (resolved.source !== "profile") { - setQianfanApiKey(resolved.key, undefined, apiKeyStorageOptions); + if ( + !(await maybeSetResolvedApiKey(resolved, (value) => + setQianfanApiKey(value, undefined, apiKeyStorageOptions), + )) + ) { + return null; } nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "qianfan:default", @@ -431,8 +509,12 @@ export async function applyNonInteractiveAuthChoice(params: { if (!resolved) { return null; } - if (resolved.source !== "profile") { - await setOpenaiApiKey(resolved.key, undefined, apiKeyStorageOptions); + if ( + !(await maybeSetResolvedApiKey(resolved, (value) => + setOpenaiApiKey(value, undefined, apiKeyStorageOptions), + )) + ) { + return null; } nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "openai:default", @@ -454,8 +536,12 @@ export async function applyNonInteractiveAuthChoice(params: { if (!resolved) { return null; } - if (resolved.source !== "profile") { - await setOpenrouterApiKey(resolved.key, undefined, apiKeyStorageOptions); + if ( + !(await maybeSetResolvedApiKey(resolved, (value) => + setOpenrouterApiKey(value, undefined, apiKeyStorageOptions), + )) + ) { + return null; } nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "openrouter:default", @@ -477,8 +563,12 @@ export async function applyNonInteractiveAuthChoice(params: { if (!resolved) { return null; } - if (resolved.source !== "profile") { - await setKilocodeApiKey(resolved.key, undefined, apiKeyStorageOptions); + if ( + !(await maybeSetResolvedApiKey(resolved, (value) => + setKilocodeApiKey(value, undefined, apiKeyStorageOptions), + )) + ) { + return null; } nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "kilocode:default", @@ -500,8 +590,12 @@ export async function applyNonInteractiveAuthChoice(params: { if (!resolved) { return null; } - if (resolved.source !== "profile") { - await setLitellmApiKey(resolved.key, undefined, apiKeyStorageOptions); + if ( + !(await maybeSetResolvedApiKey(resolved, (value) => + setLitellmApiKey(value, undefined, apiKeyStorageOptions), + )) + ) { + return null; } nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "litellm:default", @@ -523,8 +617,12 @@ export async function applyNonInteractiveAuthChoice(params: { if (!resolved) { return null; } - if (resolved.source !== "profile") { - await setVercelAiGatewayApiKey(resolved.key, undefined, apiKeyStorageOptions); + if ( + !(await maybeSetResolvedApiKey(resolved, (value) => + setVercelAiGatewayApiKey(value, undefined, apiKeyStorageOptions), + )) + ) { + return null; } nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "vercel-ai-gateway:default", @@ -559,10 +657,14 @@ export async function applyNonInteractiveAuthChoice(params: { return null; } if (resolved.source !== "profile") { + const stored = toStoredSecretInput(resolved); + if (!stored) { + return null; + } await setCloudflareAiGatewayConfig( accountId, gatewayId, - resolved.key, + stored, undefined, apiKeyStorageOptions, ); @@ -592,8 +694,12 @@ export async function applyNonInteractiveAuthChoice(params: { if (!resolved) { return null; } - if (resolved.source !== "profile") { - await setMoonshotApiKey(resolved.key, undefined, apiKeyStorageOptions); + if ( + !(await maybeSetResolvedApiKey(resolved, (value) => + setMoonshotApiKey(value, undefined, apiKeyStorageOptions), + )) + ) { + return null; } nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "moonshot:default", @@ -623,8 +729,12 @@ export async function applyNonInteractiveAuthChoice(params: { if (!resolved) { return null; } - if (resolved.source !== "profile") { - await setKimiCodingApiKey(resolved.key, undefined, apiKeyStorageOptions); + if ( + !(await maybeSetResolvedApiKey(resolved, (value) => + setKimiCodingApiKey(value, undefined, apiKeyStorageOptions), + )) + ) { + return null; } nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "kimi-coding:default", @@ -646,8 +756,12 @@ export async function applyNonInteractiveAuthChoice(params: { if (!resolved) { return null; } - if (resolved.source !== "profile") { - await setSyntheticApiKey(resolved.key, undefined, apiKeyStorageOptions); + if ( + !(await maybeSetResolvedApiKey(resolved, (value) => + setSyntheticApiKey(value, undefined, apiKeyStorageOptions), + )) + ) { + return null; } nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "synthetic:default", @@ -669,8 +783,12 @@ export async function applyNonInteractiveAuthChoice(params: { if (!resolved) { return null; } - if (resolved.source !== "profile") { - await setVeniceApiKey(resolved.key, undefined, apiKeyStorageOptions); + if ( + !(await maybeSetResolvedApiKey(resolved, (value) => + setVeniceApiKey(value, undefined, apiKeyStorageOptions), + )) + ) { + return null; } nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "venice:default", @@ -700,8 +818,12 @@ export async function applyNonInteractiveAuthChoice(params: { if (!resolved) { return null; } - if (resolved.source !== "profile") { - await setMinimaxApiKey(resolved.key, undefined, profileId, apiKeyStorageOptions); + if ( + !(await maybeSetResolvedApiKey(resolved, (value) => + setMinimaxApiKey(value, undefined, profileId, apiKeyStorageOptions), + )) + ) { + return null; } nextConfig = applyAuthProfileConfig(nextConfig, { profileId, @@ -731,8 +853,12 @@ export async function applyNonInteractiveAuthChoice(params: { if (!resolved) { return null; } - if (resolved.source !== "profile") { - await setOpencodeZenApiKey(resolved.key, undefined, apiKeyStorageOptions); + if ( + !(await maybeSetResolvedApiKey(resolved, (value) => + setOpencodeZenApiKey(value, undefined, apiKeyStorageOptions), + )) + ) { + return null; } nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "opencode:default", @@ -754,8 +880,12 @@ export async function applyNonInteractiveAuthChoice(params: { if (!resolved) { return null; } - if (resolved.source !== "profile") { - await setTogetherApiKey(resolved.key, undefined, apiKeyStorageOptions); + if ( + !(await maybeSetResolvedApiKey(resolved, (value) => + setTogetherApiKey(value, undefined, apiKeyStorageOptions), + )) + ) { + return null; } nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "together:default", @@ -777,8 +907,12 @@ export async function applyNonInteractiveAuthChoice(params: { if (!resolved) { return null; } - if (resolved.source !== "profile") { - await setHuggingfaceApiKey(resolved.key, undefined, apiKeyStorageOptions); + if ( + !(await maybeSetResolvedApiKey(resolved, (value) => + setHuggingfaceApiKey(value, undefined, apiKeyStorageOptions), + )) + ) { + return null; } nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "huggingface:default", @@ -812,10 +946,18 @@ export async function applyNonInteractiveAuthChoice(params: { runtime, required: false, }); - const customApiKeyInput: SecretInput | undefined = - requestedSecretInputMode === "ref" && resolvedCustomApiKey?.source === "env" - ? { source: "env", provider: "default", id: "CUSTOM_API_KEY" } - : resolvedCustomApiKey?.key; + let customApiKeyInput: SecretInput | undefined; + if (resolvedCustomApiKey) { + if (requestedSecretInputMode === "ref") { + const stored = toStoredSecretInput(resolvedCustomApiKey); + if (!stored) { + return null; + } + customApiKeyInput = stored; + } else { + customApiKeyInput = resolvedCustomApiKey.key; + } + } const result = applyCustomApiConfig({ config: nextConfig, baseUrl: customAuth.baseUrl, diff --git a/src/config/config.secrets-schema.test.ts b/src/config/config.secrets-schema.test.ts index cf9f12945..623d8e540 100644 --- a/src/config/config.secrets-schema.test.ts +++ b/src/config/config.secrets-schema.test.ts @@ -65,6 +65,31 @@ describe("config secret refs schema", () => { expect(result.ok).toBe(true); }); + it('accepts file refs with id "value" for raw mode providers', () => { + const result = validateConfigObjectRaw({ + secrets: { + providers: { + rawfile: { + source: "file", + path: "~/.openclaw/token.txt", + mode: "raw", + }, + }, + }, + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: { source: "file", provider: "rawfile", id: "value" }, + models: [{ id: "gpt-5", name: "gpt-5" }], + }, + }, + }, + }); + + expect(result.ok).toBe(true); + }); + it("rejects invalid secret ref id", () => { const result = validateConfigObjectRaw({ models: { diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 9f300a666..07a0de83c 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -1,26 +1,16 @@ import path from "node:path"; import { z } from "zod"; import { isSafeExecutableValue } from "../infra/exec-safety.js"; +import { isValidFileSecretRefId } from "../secrets/ref-contract.js"; import { createAllowDenyChannelRulesSchema } from "./zod-schema.allowdeny.js"; import { sensitive } from "./zod-schema.sensitive.js"; const ENV_SECRET_REF_ID_PATTERN = /^[A-Z][A-Z0-9_]{0,127}$/; const SECRET_PROVIDER_ALIAS_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/; const EXEC_SECRET_REF_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/; -const FILE_SECRET_REF_SEGMENT_PATTERN = /^(?:[^~]|~0|~1)*$/; const WINDOWS_ABS_PATH_PATTERN = /^[A-Za-z]:[\\/]/; const WINDOWS_UNC_PATH_PATTERN = /^\\\\[^\\]+\\[^\\]+/; -function isValidFileSecretRefId(value: string): boolean { - if (!value.startsWith("/")) { - return false; - } - return value - .slice(1) - .split("/") - .every((segment) => FILE_SECRET_REF_SEGMENT_PATTERN.test(segment)); -} - function isAbsolutePath(value: string): boolean { return ( path.isAbsolute(value) || @@ -60,7 +50,7 @@ const FileSecretRefSchema = z .string() .refine( isValidFileSecretRefId, - 'File secret reference id must be an absolute JSON pointer (example: "/providers/openai/apiKey").', + 'File secret reference id must be an absolute JSON pointer (example: "/providers/openai/apiKey"), or "value" for raw mode.', ), }) .strict(); diff --git a/src/secrets/ref-contract.ts b/src/secrets/ref-contract.ts new file mode 100644 index 000000000..641dbb156 --- /dev/null +++ b/src/secrets/ref-contract.ts @@ -0,0 +1,66 @@ +import { + DEFAULT_SECRET_PROVIDER_ALIAS, + type SecretRef, + type SecretRefSource, +} from "../config/types.secrets.js"; + +const FILE_SECRET_REF_SEGMENT_PATTERN = /^(?:[^~]|~0|~1)*$/; + +export const RAW_FILE_REF_ID = "value"; + +export type SecretRefDefaultsCarrier = { + secrets?: { + defaults?: { + env?: string; + file?: string; + exec?: string; + }; + providers?: Record; + }; +}; + +export function secretRefKey(ref: SecretRef): string { + return `${ref.source}:${ref.provider}:${ref.id}`; +} + +export function resolveDefaultSecretProviderAlias( + config: SecretRefDefaultsCarrier, + source: SecretRefSource, + options?: { preferFirstProviderForSource?: boolean }, +): string { + const configured = + source === "env" + ? config.secrets?.defaults?.env + : source === "file" + ? config.secrets?.defaults?.file + : config.secrets?.defaults?.exec; + if (configured?.trim()) { + return configured.trim(); + } + + if (options?.preferFirstProviderForSource) { + const providers = config.secrets?.providers; + if (providers) { + for (const [providerName, provider] of Object.entries(providers)) { + if (provider?.source === source) { + return providerName; + } + } + } + } + + return DEFAULT_SECRET_PROVIDER_ALIAS; +} + +export function isValidFileSecretRefId(value: string): boolean { + if (value === RAW_FILE_REF_ID) { + return true; + } + if (!value.startsWith("/")) { + return false; + } + return value + .slice(1) + .split("/") + .every((segment) => FILE_SECRET_REF_SEGMENT_PATTERN.test(segment)); +} diff --git a/src/secrets/resolve.ts b/src/secrets/resolve.ts index bb83654a4..22c61f113 100644 --- a/src/secrets/resolve.ts +++ b/src/secrets/resolve.ts @@ -9,12 +9,16 @@ import type { SecretRef, SecretRefSource, } from "../config/types.secrets.js"; -import { DEFAULT_SECRET_PROVIDER_ALIAS } from "../config/types.secrets.js"; import { inspectPathPermissions, safeStat } from "../security/audit-fs.js"; import { isPathInside } from "../security/scan-paths.js"; import { resolveUserPath } from "../utils.js"; import { runTasksWithConcurrency } from "../utils/run-with-concurrency.js"; import { readJsonPointer } from "./json-pointer.js"; +import { + RAW_FILE_REF_ID, + resolveDefaultSecretProviderAlias, + secretRefKey, +} from "./ref-contract.js"; import { isNonEmptyString, isRecord, normalizePositiveInt } from "./shared.js"; const DEFAULT_PROVIDER_CONCURRENCY = 4; @@ -25,8 +29,6 @@ const DEFAULT_FILE_TIMEOUT_MS = 5_000; const DEFAULT_EXEC_TIMEOUT_MS = 5_000; const DEFAULT_EXEC_NO_OUTPUT_TIMEOUT_MS = 2_000; const DEFAULT_EXEC_MAX_OUTPUT_BYTES = 1024 * 1024; -const RAW_FILE_REF_ID = "value"; - const WINDOWS_ABS_PATH_PATTERN = /^[A-Za-z]:[\\/]/; const WINDOWS_UNC_PATH_PATTERN = /^\\\\[^\\]+\\[^\\]+/; @@ -57,16 +59,6 @@ function isAbsolutePathname(value: string): boolean { ); } -function resolveSourceDefaultAlias(source: SecretRefSource, config: OpenClawConfig): string { - const configured = - source === "env" - ? config.secrets?.defaults?.env - : source === "file" - ? config.secrets?.defaults?.file - : config.secrets?.defaults?.exec; - return configured?.trim() || DEFAULT_SECRET_PROVIDER_ALIAS; -} - function resolveResolutionLimits(config: OpenClawConfig): ResolutionLimits { const resolution = config.secrets?.resolution; return { @@ -82,10 +74,6 @@ function resolveResolutionLimits(config: OpenClawConfig): ResolutionLimits { }; } -function toRefKey(ref: SecretRef): string { - return `${ref.source}:${ref.provider}:${ref.id}`; -} - function toProviderKey(source: SecretRefSource, provider: string): string { return `${source}:${provider}`; } @@ -93,7 +81,7 @@ function toProviderKey(source: SecretRefSource, provider: string): string { function resolveConfiguredProvider(ref: SecretRef, config: OpenClawConfig): SecretProviderConfig { const providerConfig = config.secrets?.providers?.[ref.provider]; if (!providerConfig) { - if (ref.source === "env" && ref.provider === resolveSourceDefaultAlias("env", config)) { + if (ref.source === "env" && ref.provider === resolveDefaultSecretProviderAlias(config, "env")) { return { source: "env" }; } throw new Error( @@ -602,7 +590,7 @@ export async function resolveSecretRefValues( if (!id) { throw new Error("Secret reference id is empty."); } - uniqueRefs.set(toRefKey(ref), { ...ref, id }); + uniqueRefs.set(secretRefKey(ref), { ...ref, id }); } const grouped = new Map< @@ -656,7 +644,7 @@ export async function resolveSecretRefValues( `Secret provider "${result.group.providerName}" did not return id "${ref.id}".`, ); } - resolved.set(toRefKey(ref), result.values.get(ref.id)); + resolved.set(secretRefKey(ref), result.values.get(ref.id)); } } return resolved; @@ -667,7 +655,7 @@ export async function resolveSecretRefValue( options: ResolveSecretRefOptions, ): Promise { const cache = options.cache; - const key = toRefKey(ref); + const key = secretRefKey(ref); if (cache?.resolvedByRefKey?.has(key)) { return await (cache.resolvedByRefKey.get(key) as Promise); } diff --git a/src/secrets/runtime.ts b/src/secrets/runtime.ts index 98a0afd39..cb79fbc35 100644 --- a/src/secrets/runtime.ts +++ b/src/secrets/runtime.ts @@ -13,6 +13,7 @@ import { } from "../config/config.js"; import { coerceSecretRef, type SecretRef } from "../config/types.secrets.js"; import { resolveUserPath } from "../utils.js"; +import { secretRefKey } from "./ref-contract.js"; import { resolveSecretRefValues, type SecretRefResolveCache } from "./resolve.js"; import { isNonEmptyString, isRecord } from "./shared.js"; @@ -72,11 +73,9 @@ type ResolverContext = { assignments: SecretAssignment[]; }; -let activeSnapshot: PreparedSecretsRuntimeSnapshot | null = null; +type SecretDefaults = NonNullable["defaults"]; -function toRefKey(ref: SecretRef): string { - return `${ref.source}:${ref.provider}:${ref.id}`; -} +let activeSnapshot: PreparedSecretsRuntimeSnapshot | null = null; function cloneSnapshot(snapshot: PreparedSecretsRuntimeSnapshot): PreparedSecretsRuntimeSnapshot { return { @@ -90,6 +89,112 @@ function cloneSnapshot(snapshot: PreparedSecretsRuntimeSnapshot): PreparedSecret }; } +function pushAssignment(context: ResolverContext, assignment: SecretAssignment): void { + context.assignments.push(assignment); +} + +function collectModelProviderAssignments(params: { + providers: Record; + defaults: SecretDefaults | undefined; + context: ResolverContext; +}): void { + for (const [providerId, provider] of Object.entries(params.providers)) { + const ref = coerceSecretRef(provider.apiKey, params.defaults); + if (!ref) { + continue; + } + pushAssignment(params.context, { + ref, + path: `models.providers.${providerId}.apiKey`, + expected: "string", + apply: (value) => { + provider.apiKey = value; + }, + }); + } +} + +function collectSkillAssignments(params: { + entries: Record; + defaults: SecretDefaults | undefined; + context: ResolverContext; +}): void { + for (const [skillKey, entry] of Object.entries(params.entries)) { + const ref = coerceSecretRef(entry.apiKey, params.defaults); + if (!ref) { + continue; + } + pushAssignment(params.context, { + ref, + path: `skills.entries.${skillKey}.apiKey`, + expected: "string", + apply: (value) => { + entry.apiKey = value; + }, + }); + } +} + +function collectGoogleChatAccountAssignment(params: { + target: GoogleChatAccountLike; + path: string; + defaults: SecretDefaults | undefined; + context: ResolverContext; +}): void { + const explicitRef = coerceSecretRef(params.target.serviceAccountRef, params.defaults); + const inlineRef = coerceSecretRef(params.target.serviceAccount, params.defaults); + const ref = explicitRef ?? inlineRef; + if (!ref) { + return; + } + if ( + explicitRef && + params.target.serviceAccount !== undefined && + !coerceSecretRef(params.target.serviceAccount, params.defaults) + ) { + params.context.warnings.push({ + code: "SECRETS_REF_OVERRIDES_PLAINTEXT", + path: params.path, + message: `${params.path}: serviceAccountRef is set; runtime will ignore plaintext serviceAccount.`, + }); + } + pushAssignment(params.context, { + ref, + path: `${params.path}.serviceAccount`, + expected: "string-or-object", + apply: (value) => { + params.target.serviceAccount = value; + }, + }); +} + +function collectGoogleChatAssignments(params: { + googleChat: GoogleChatAccountLike; + defaults: SecretDefaults | undefined; + context: ResolverContext; +}): void { + collectGoogleChatAccountAssignment({ + target: params.googleChat, + path: "channels.googlechat", + defaults: params.defaults, + context: params.context, + }); + if (!isRecord(params.googleChat.accounts)) { + return; + } + for (const [accountId, account] of Object.entries(params.googleChat.accounts)) { + if (!isRecord(account)) { + continue; + } + collectGoogleChatAccountAssignment({ + target: account as GoogleChatAccountLike, + path: `channels.googlechat.accounts.${accountId}`, + defaults: params.defaults, + context: params.context, + }); + } +} + function collectConfigAssignments(params: { config: OpenClawConfig; context: ResolverContext; @@ -97,85 +202,92 @@ function collectConfigAssignments(params: { const defaults = params.context.sourceConfig.secrets?.defaults; const providers = params.config.models?.providers as Record | undefined; if (providers) { - for (const [providerId, provider] of Object.entries(providers)) { - const ref = coerceSecretRef(provider.apiKey, defaults); - if (!ref) { - continue; - } - params.context.assignments.push({ - ref, - path: `models.providers.${providerId}.apiKey`, - expected: "string", - apply: (value) => { - provider.apiKey = value; - }, - }); - } + collectModelProviderAssignments({ + providers, + defaults, + context: params.context, + }); } const skillEntries = params.config.skills?.entries as Record | undefined; if (skillEntries) { - for (const [skillKey, entry] of Object.entries(skillEntries)) { - const ref = coerceSecretRef(entry.apiKey, defaults); - if (!ref) { - continue; - } - params.context.assignments.push({ - ref, - path: `skills.entries.${skillKey}.apiKey`, - expected: "string", - apply: (value) => { - entry.apiKey = value; - }, - }); - } - } - - const collectGoogleChatAssignments = (target: GoogleChatAccountLike, path: string) => { - const explicitRef = coerceSecretRef(target.serviceAccountRef, defaults); - const inlineRef = coerceSecretRef(target.serviceAccount, defaults); - const ref = explicitRef ?? inlineRef; - if (!ref) { - return; - } - if ( - explicitRef && - target.serviceAccount !== undefined && - !coerceSecretRef(target.serviceAccount, defaults) - ) { - params.context.warnings.push({ - code: "SECRETS_REF_OVERRIDES_PLAINTEXT", - path, - message: `${path}: serviceAccountRef is set; runtime will ignore plaintext serviceAccount.`, - }); - } - params.context.assignments.push({ - ref, - path: `${path}.serviceAccount`, - expected: "string-or-object", - apply: (value) => { - target.serviceAccount = value; - }, + collectSkillAssignments({ + entries: skillEntries, + defaults, + context: params.context, }); - }; + } const googleChat = params.config.channels?.googlechat as GoogleChatAccountLike | undefined; if (googleChat) { - collectGoogleChatAssignments(googleChat, "channels.googlechat"); - if (isRecord(googleChat.accounts)) { - for (const [accountId, account] of Object.entries(googleChat.accounts)) { - if (!isRecord(account)) { - continue; - } - collectGoogleChatAssignments( - account as GoogleChatAccountLike, - `channels.googlechat.accounts.${accountId}`, - ); - } - } + collectGoogleChatAssignments({ + googleChat, + defaults, + context: params.context, + }); } } +function collectApiKeyProfileAssignment(params: { + profile: ApiKeyCredentialLike; + profileId: string; + agentDir: string; + defaults: SecretDefaults | undefined; + context: ResolverContext; +}): void { + const keyRef = coerceSecretRef(params.profile.keyRef, params.defaults); + const inlineKeyRef = keyRef ? null : coerceSecretRef(params.profile.key, params.defaults); + const resolvedKeyRef = keyRef ?? inlineKeyRef; + if (!resolvedKeyRef) { + return; + } + if (keyRef && isNonEmptyString(params.profile.key)) { + params.context.warnings.push({ + code: "SECRETS_REF_OVERRIDES_PLAINTEXT", + path: `${params.agentDir}.auth-profiles.${params.profileId}.key`, + message: `auth-profiles ${params.profileId}: keyRef is set; runtime will ignore plaintext key.`, + }); + } + pushAssignment(params.context, { + ref: resolvedKeyRef, + path: `${params.agentDir}.auth-profiles.${params.profileId}.key`, + expected: "string", + apply: (value) => { + params.profile.key = String(value); + }, + }); +} + +function collectTokenProfileAssignment(params: { + profile: TokenCredentialLike; + profileId: string; + agentDir: string; + defaults: SecretDefaults | undefined; + context: ResolverContext; +}): void { + const tokenRef = coerceSecretRef(params.profile.tokenRef, params.defaults); + const inlineTokenRef = tokenRef ? null : coerceSecretRef(params.profile.token, params.defaults); + const resolvedTokenRef = tokenRef ?? inlineTokenRef; + if (!resolvedTokenRef) { + return; + } + if (tokenRef && isNonEmptyString(params.profile.token)) { + params.context.warnings.push({ + code: "SECRETS_REF_OVERRIDES_PLAINTEXT", + path: `${params.agentDir}.auth-profiles.${params.profileId}.token`, + message: `auth-profiles ${params.profileId}: tokenRef is set; runtime will ignore plaintext token.`, + }); + } + pushAssignment(params.context, { + ref: resolvedTokenRef, + path: `${params.agentDir}.auth-profiles.${params.profileId}.token`, + expected: "string", + apply: (value) => { + params.profile.token = String(value); + }, + }); +} + function collectAuthStoreAssignments(params: { store: AuthProfileStore; context: ResolverContext; @@ -184,53 +296,22 @@ function collectAuthStoreAssignments(params: { const defaults = params.context.sourceConfig.secrets?.defaults; for (const [profileId, profile] of Object.entries(params.store.profiles)) { if (profile.type === "api_key") { - const apiProfile = profile as ApiKeyCredentialLike; - const keyRef = coerceSecretRef(apiProfile.keyRef, defaults); - const inlineKeyRef = keyRef ? null : coerceSecretRef(apiProfile.key, defaults); - const resolvedKeyRef = keyRef ?? inlineKeyRef; - if (!resolvedKeyRef) { - continue; - } - if (keyRef && isNonEmptyString(apiProfile.key)) { - params.context.warnings.push({ - code: "SECRETS_REF_OVERRIDES_PLAINTEXT", - path: `${params.agentDir}.auth-profiles.${profileId}.key`, - message: `auth-profiles ${profileId}: keyRef is set; runtime will ignore plaintext key.`, - }); - } - params.context.assignments.push({ - ref: resolvedKeyRef, - path: `${params.agentDir}.auth-profiles.${profileId}.key`, - expected: "string", - apply: (value) => { - apiProfile.key = String(value); - }, + collectApiKeyProfileAssignment({ + profile: profile as ApiKeyCredentialLike, + profileId, + agentDir: params.agentDir, + defaults, + context: params.context, }); continue; } - if (profile.type === "token") { - const tokenProfile = profile as TokenCredentialLike; - const tokenRef = coerceSecretRef(tokenProfile.tokenRef, defaults); - const inlineTokenRef = tokenRef ? null : coerceSecretRef(tokenProfile.token, defaults); - const resolvedTokenRef = tokenRef ?? inlineTokenRef; - if (!resolvedTokenRef) { - continue; - } - if (tokenRef && isNonEmptyString(tokenProfile.token)) { - params.context.warnings.push({ - code: "SECRETS_REF_OVERRIDES_PLAINTEXT", - path: `${params.agentDir}.auth-profiles.${profileId}.token`, - message: `auth-profiles ${profileId}: tokenRef is set; runtime will ignore plaintext token.`, - }); - } - params.context.assignments.push({ - ref: resolvedTokenRef, - path: `${params.agentDir}.auth-profiles.${profileId}.token`, - expected: "string", - apply: (value) => { - tokenProfile.token = String(value); - }, + collectTokenProfileAssignment({ + profile: profile as TokenCredentialLike, + profileId, + agentDir: params.agentDir, + defaults, + context: params.context, }); } } @@ -241,7 +322,7 @@ function applyAssignments(params: { resolved: Map; }): void { for (const assignment of params.assignments) { - const key = toRefKey(assignment.ref); + const key = secretRefKey(assignment.ref); if (!params.resolved.has(key)) { throw new Error(`Secret reference "${key}" resolved to no value.`); }