fix(secrets): align ref contracts and non-interactive ref persistence
This commit is contained in:
committed by
Peter Steinberger
parent
86622ebea9
commit
8944b75e16
@@ -99,6 +99,8 @@ type ResolveApiKeyForProfileParams = {
|
||||
agentDir?: string;
|
||||
};
|
||||
|
||||
type SecretDefaults = NonNullable<OpenClawConfig["secrets"]>["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<string | undefined> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string>({
|
||||
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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<ReturnType<typeof resolveNonInteractiveApiKey>>
|
||||
>;
|
||||
|
||||
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<typeof resolveNonInteractiveApiKey>[0],
|
||||
): ReturnType<typeof resolveNonInteractiveApiKey> =>
|
||||
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<typeof resolveNonInteractiveApiKey>[0]) =>
|
||||
resolveNonInteractiveApiKey({
|
||||
...input,
|
||||
secretInputMode: requestedSecretInputMode,
|
||||
});
|
||||
const maybeSetResolvedApiKey = async (
|
||||
resolved: ResolvedNonInteractiveApiKey,
|
||||
setter: (value: SecretInput) => Promise<void> | void,
|
||||
): Promise<boolean> => {
|
||||
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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
|
||||
66
src/secrets/ref-contract.ts
Normal file
66
src/secrets/ref-contract.ts
Normal file
@@ -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<string, { source?: string }>;
|
||||
};
|
||||
};
|
||||
|
||||
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));
|
||||
}
|
||||
@@ -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<unknown> {
|
||||
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<unknown>);
|
||||
}
|
||||
|
||||
@@ -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<OpenClawConfig["secrets"]>["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<string, ProviderLike>;
|
||||
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<string, SkillEntryLike>;
|
||||
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<string, ProviderLike> | 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<string, SkillEntryLike> | 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<string, unknown>;
|
||||
}): 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.`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user