Onboard: persist env-backed API keys as secret refs

This commit is contained in:
joshavant
2026-02-21 14:43:28 -08:00
committed by Peter Steinberger
parent 363334253b
commit 7e1557b8c9
2 changed files with 232 additions and 114 deletions

View File

@@ -0,0 +1,85 @@
import { afterEach, describe, expect, it } from "vitest";
import { setCloudflareAiGatewayConfig, setMoonshotApiKey } from "./onboard-auth.js";
import {
createAuthTestLifecycle,
readAuthProfilesForAgent,
setupAuthTestEnv,
} from "./test-wizard-helpers.js";
describe("onboard auth credentials secret refs", () => {
const lifecycle = createAuthTestLifecycle([
"OPENCLAW_STATE_DIR",
"OPENCLAW_AGENT_DIR",
"PI_CODING_AGENT_DIR",
"MOONSHOT_API_KEY",
"CLOUDFLARE_AI_GATEWAY_API_KEY",
]);
afterEach(async () => {
await lifecycle.cleanup();
});
it("stores env-backed moonshot key as keyRef", async () => {
const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-");
lifecycle.setStateDir(env.stateDir);
process.env.MOONSHOT_API_KEY = "sk-moonshot-env";
await setMoonshotApiKey("sk-moonshot-env");
const parsed = await readAuthProfilesForAgent<{
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
}>(env.agentDir);
expect(parsed.profiles?.["moonshot:default"]).toMatchObject({
keyRef: { source: "env", id: "MOONSHOT_API_KEY" },
});
expect(parsed.profiles?.["moonshot:default"]?.key).toBeUndefined();
});
it("stores ${ENV} moonshot input as keyRef even when env value is unset", async () => {
const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-inline-ref-");
lifecycle.setStateDir(env.stateDir);
await setMoonshotApiKey("${MOONSHOT_API_KEY}");
const parsed = await readAuthProfilesForAgent<{
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
}>(env.agentDir);
expect(parsed.profiles?.["moonshot:default"]).toMatchObject({
keyRef: { source: "env", id: "MOONSHOT_API_KEY" },
});
expect(parsed.profiles?.["moonshot:default"]?.key).toBeUndefined();
});
it("keeps plaintext moonshot key when no env ref applies", async () => {
const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-plaintext-");
lifecycle.setStateDir(env.stateDir);
process.env.MOONSHOT_API_KEY = "sk-moonshot-other";
await setMoonshotApiKey("sk-moonshot-plaintext");
const parsed = await readAuthProfilesForAgent<{
profiles?: Record<string, { key?: string; keyRef?: unknown }>;
}>(env.agentDir);
expect(parsed.profiles?.["moonshot:default"]).toMatchObject({
key: "sk-moonshot-plaintext",
});
expect(parsed.profiles?.["moonshot:default"]?.keyRef).toBeUndefined();
});
it("preserves cloudflare metadata when storing keyRef", async () => {
const env = await setupAuthTestEnv("openclaw-onboard-auth-credentials-cloudflare-");
lifecycle.setStateDir(env.stateDir);
process.env.CLOUDFLARE_AI_GATEWAY_API_KEY = "cf-secret";
await setCloudflareAiGatewayConfig("account-1", "gateway-1", "cf-secret");
const parsed = await readAuthProfilesForAgent<{
profiles?: Record<string, { key?: string; keyRef?: unknown; metadata?: unknown }>;
}>(env.agentDir);
expect(parsed.profiles?.["cloudflare-ai-gateway:default"]).toMatchObject({
keyRef: { source: "env", id: "CLOUDFLARE_AI_GATEWAY_API_KEY" },
metadata: { accountId: "account-1", gatewayId: "gateway-1" },
});
expect(parsed.profiles?.["cloudflare-ai-gateway:default"]?.key).toBeUndefined();
});
});

View File

@@ -3,14 +3,121 @@ import path from "node:path";
import type { OAuthCredentials } from "@mariozechner/pi-ai";
import { resolveOpenClawAgentDir } from "../agents/agent-paths.js";
import { upsertAuthProfile } from "../agents/auth-profiles.js";
import type { SecretInput, SecretRef } from "../config/types.secrets.js";
import { resolveStateDir } from "../config/paths.js";
import { KILOCODE_DEFAULT_MODEL_REF } from "../providers/kilocode-shared.js";
import { normalizeSecretInput } from "../utils/normalize-secret-input.js";
export { CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF } from "../agents/cloudflare-ai-gateway.js";
export { MISTRAL_DEFAULT_MODEL_REF, XAI_DEFAULT_MODEL_REF } from "./onboard-auth.models.js";
export { KILOCODE_DEFAULT_MODEL_REF };
const resolveAuthAgentDir = (agentDir?: string) => agentDir ?? resolveOpenClawAgentDir();
const ENV_REF_PATTERN = /^\$\{([A-Z][A-Z0-9_]*)\}$/;
const PROVIDER_ENV_VARS: Record<string, readonly string[]> = {
anthropic: ["ANTHROPIC_API_KEY"],
google: ["GEMINI_API_KEY"],
minimax: ["MINIMAX_API_KEY"],
"minimax-cn": ["MINIMAX_API_KEY"],
moonshot: ["MOONSHOT_API_KEY"],
"kimi-coding": ["KIMI_API_KEY", "KIMICODE_API_KEY"],
synthetic: ["SYNTHETIC_API_KEY"],
venice: ["VENICE_API_KEY"],
zai: ["ZAI_API_KEY", "Z_AI_API_KEY"],
xiaomi: ["XIAOMI_API_KEY"],
openrouter: ["OPENROUTER_API_KEY"],
"cloudflare-ai-gateway": ["CLOUDFLARE_AI_GATEWAY_API_KEY"],
litellm: ["LITELLM_API_KEY"],
"vercel-ai-gateway": ["AI_GATEWAY_API_KEY"],
opencode: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"],
together: ["TOGETHER_API_KEY"],
huggingface: ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"],
qianfan: ["QIANFAN_API_KEY"],
xai: ["XAI_API_KEY"],
};
function isSecretRef(value: unknown): value is SecretRef {
return (
typeof value === "object" &&
value !== null &&
(value as SecretRef).source !== undefined &&
(value as SecretRef).id !== undefined &&
((value as SecretRef).source === "env" || (value as SecretRef).source === "file") &&
typeof (value as SecretRef).id === "string"
);
}
function buildEnvSecretRef(id: string): SecretRef {
return { source: "env", id };
}
function parseEnvSecretRef(value: string): SecretRef | null {
const match = ENV_REF_PATTERN.exec(value);
if (!match) {
return null;
}
return buildEnvSecretRef(match[1]);
}
function inferProviderEnvSecretRef(provider: string, value: string): SecretRef | null {
const envVars = PROVIDER_ENV_VARS[provider];
if (!envVars || value.length === 0) {
return null;
}
for (const envVar of envVars) {
const envValue = normalizeSecretInput(process.env[envVar] ?? "");
if (envValue && envValue === value) {
return buildEnvSecretRef(envVar);
}
}
return null;
}
function resolveApiKeySecretInput(provider: string, input: SecretInput): SecretInput {
if (isSecretRef(input)) {
return input;
}
const normalized = normalizeSecretInput(input);
const inlineEnvRef = parseEnvSecretRef(normalized);
if (inlineEnvRef) {
return inlineEnvRef;
}
const inferredEnvRef = inferProviderEnvSecretRef(provider, normalized);
if (inferredEnvRef) {
return inferredEnvRef;
}
return normalized;
}
function buildApiKeyCredential(
provider: string,
input: SecretInput,
metadata?: Record<string, string>,
): {
type: "api_key";
provider: string;
key?: string;
keyRef?: SecretRef;
metadata?: Record<string, string>;
} {
const secretInput = resolveApiKeySecretInput(provider, input);
if (typeof secretInput === "string") {
return {
type: "api_key",
provider,
key: secretInput,
...(metadata ? { metadata } : {}),
};
}
return {
type: "api_key",
provider,
keyRef: secretInput,
...(metadata ? { metadata } : {}),
};
}
export type WriteOAuthCredentialsOptions = {
syncSiblingAgents?: boolean;
};
@@ -112,34 +219,26 @@ export async function writeOAuthCredentials(
return profileId;
}
export async function setAnthropicApiKey(key: string, agentDir?: string) {
export async function setAnthropicApiKey(key: SecretInput, agentDir?: string) {
// Write to resolved agent dir so gateway finds credentials on startup.
upsertAuthProfile({
profileId: "anthropic:default",
credential: {
type: "api_key",
provider: "anthropic",
key,
},
credential: buildApiKeyCredential("anthropic", key),
agentDir: resolveAuthAgentDir(agentDir),
});
}
export async function setGeminiApiKey(key: string, agentDir?: string) {
export async function setGeminiApiKey(key: SecretInput, agentDir?: string) {
// Write to resolved agent dir so gateway finds credentials on startup.
upsertAuthProfile({
profileId: "google:default",
credential: {
type: "api_key",
provider: "google",
key,
},
credential: buildApiKeyCredential("google", key),
agentDir: resolveAuthAgentDir(agentDir),
});
}
export async function setMinimaxApiKey(
key: string,
key: SecretInput,
agentDir?: string,
profileId: string = "minimax:default",
) {
@@ -147,63 +246,43 @@ export async function setMinimaxApiKey(
// Write to resolved agent dir so gateway finds credentials on startup.
upsertAuthProfile({
profileId,
credential: {
type: "api_key",
provider,
key,
},
credential: buildApiKeyCredential(provider, key),
agentDir: resolveAuthAgentDir(agentDir),
});
}
export async function setMoonshotApiKey(key: string, agentDir?: string) {
export async function setMoonshotApiKey(key: SecretInput, agentDir?: string) {
// Write to resolved agent dir so gateway finds credentials on startup.
upsertAuthProfile({
profileId: "moonshot:default",
credential: {
type: "api_key",
provider: "moonshot",
key,
},
credential: buildApiKeyCredential("moonshot", key),
agentDir: resolveAuthAgentDir(agentDir),
});
}
export async function setKimiCodingApiKey(key: string, agentDir?: string) {
export async function setKimiCodingApiKey(key: SecretInput, agentDir?: string) {
// Write to resolved agent dir so gateway finds credentials on startup.
upsertAuthProfile({
profileId: "kimi-coding:default",
credential: {
type: "api_key",
provider: "kimi-coding",
key,
},
credential: buildApiKeyCredential("kimi-coding", key),
agentDir: resolveAuthAgentDir(agentDir),
});
}
export async function setSyntheticApiKey(key: string, agentDir?: string) {
export async function setSyntheticApiKey(key: SecretInput, agentDir?: string) {
// Write to resolved agent dir so gateway finds credentials on startup.
upsertAuthProfile({
profileId: "synthetic:default",
credential: {
type: "api_key",
provider: "synthetic",
key,
},
credential: buildApiKeyCredential("synthetic", key),
agentDir: resolveAuthAgentDir(agentDir),
});
}
export async function setVeniceApiKey(key: string, agentDir?: string) {
export async function setVeniceApiKey(key: SecretInput, agentDir?: string) {
// Write to resolved agent dir so gateway finds credentials on startup.
upsertAuthProfile({
profileId: "venice:default",
credential: {
type: "api_key",
provider: "venice",
key,
},
credential: buildApiKeyCredential("venice", key),
agentDir: resolveAuthAgentDir(agentDir),
});
}
@@ -216,41 +295,29 @@ export const TOGETHER_DEFAULT_MODEL_REF = "together/moonshotai/Kimi-K2.5";
export const LITELLM_DEFAULT_MODEL_REF = "litellm/claude-opus-4-6";
export const VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF = "vercel-ai-gateway/anthropic/claude-opus-4.6";
export async function setZaiApiKey(key: string, agentDir?: string) {
export async function setZaiApiKey(key: SecretInput, agentDir?: string) {
// Write to resolved agent dir so gateway finds credentials on startup.
upsertAuthProfile({
profileId: "zai:default",
credential: {
type: "api_key",
provider: "zai",
key,
},
credential: buildApiKeyCredential("zai", key),
agentDir: resolveAuthAgentDir(agentDir),
});
}
export async function setXiaomiApiKey(key: string, agentDir?: string) {
export async function setXiaomiApiKey(key: SecretInput, agentDir?: string) {
upsertAuthProfile({
profileId: "xiaomi:default",
credential: {
type: "api_key",
provider: "xiaomi",
key,
},
credential: buildApiKeyCredential("xiaomi", key),
agentDir: resolveAuthAgentDir(agentDir),
});
}
export async function setOpenrouterApiKey(key: string, agentDir?: string) {
export async function setOpenrouterApiKey(key: SecretInput, agentDir?: string) {
// Never persist the literal "undefined" (e.g. when prompt returns undefined and caller used String(key)).
const safeKey = key === "undefined" ? "" : key;
const safeKey = typeof key === "string" && key === "undefined" ? "" : key;
upsertAuthProfile({
profileId: "openrouter:default",
credential: {
type: "api_key",
provider: "openrouter",
key: safeKey,
},
credential: buildApiKeyCredential("openrouter", safeKey),
agentDir: resolveAuthAgentDir(agentDir),
});
}
@@ -258,107 +325,73 @@ export async function setOpenrouterApiKey(key: string, agentDir?: string) {
export async function setCloudflareAiGatewayConfig(
accountId: string,
gatewayId: string,
apiKey: string,
apiKey: SecretInput,
agentDir?: string,
) {
const normalizedAccountId = accountId.trim();
const normalizedGatewayId = gatewayId.trim();
const normalizedKey = apiKey.trim();
upsertAuthProfile({
profileId: "cloudflare-ai-gateway:default",
credential: {
type: "api_key",
provider: "cloudflare-ai-gateway",
key: normalizedKey,
metadata: {
accountId: normalizedAccountId,
gatewayId: normalizedGatewayId,
},
},
credential: buildApiKeyCredential("cloudflare-ai-gateway", apiKey, {
accountId: normalizedAccountId,
gatewayId: normalizedGatewayId,
}),
agentDir: resolveAuthAgentDir(agentDir),
});
}
export async function setLitellmApiKey(key: string, agentDir?: string) {
export async function setLitellmApiKey(key: SecretInput, agentDir?: string) {
upsertAuthProfile({
profileId: "litellm:default",
credential: {
type: "api_key",
provider: "litellm",
key,
},
credential: buildApiKeyCredential("litellm", key),
agentDir: resolveAuthAgentDir(agentDir),
});
}
export async function setVercelAiGatewayApiKey(key: string, agentDir?: string) {
export async function setVercelAiGatewayApiKey(key: SecretInput, agentDir?: string) {
upsertAuthProfile({
profileId: "vercel-ai-gateway:default",
credential: {
type: "api_key",
provider: "vercel-ai-gateway",
key,
},
credential: buildApiKeyCredential("vercel-ai-gateway", key),
agentDir: resolveAuthAgentDir(agentDir),
});
}
export async function setOpencodeZenApiKey(key: string, agentDir?: string) {
export async function setOpencodeZenApiKey(key: SecretInput, agentDir?: string) {
upsertAuthProfile({
profileId: "opencode:default",
credential: {
type: "api_key",
provider: "opencode",
key,
},
credential: buildApiKeyCredential("opencode", key),
agentDir: resolveAuthAgentDir(agentDir),
});
}
export async function setTogetherApiKey(key: string, agentDir?: string) {
export async function setTogetherApiKey(key: SecretInput, agentDir?: string) {
upsertAuthProfile({
profileId: "together:default",
credential: {
type: "api_key",
provider: "together",
key,
},
credential: buildApiKeyCredential("together", key),
agentDir: resolveAuthAgentDir(agentDir),
});
}
export async function setHuggingfaceApiKey(key: string, agentDir?: string) {
export async function setHuggingfaceApiKey(key: SecretInput, agentDir?: string) {
upsertAuthProfile({
profileId: "huggingface:default",
credential: {
type: "api_key",
provider: "huggingface",
key,
},
credential: buildApiKeyCredential("huggingface", key),
agentDir: resolveAuthAgentDir(agentDir),
});
}
export function setQianfanApiKey(key: string, agentDir?: string) {
export function setQianfanApiKey(key: SecretInput, agentDir?: string) {
upsertAuthProfile({
profileId: "qianfan:default",
credential: {
type: "api_key",
provider: "qianfan",
key,
},
credential: buildApiKeyCredential("qianfan", key),
agentDir: resolveAuthAgentDir(agentDir),
});
}
export function setXaiApiKey(key: string, agentDir?: string) {
export function setXaiApiKey(key: SecretInput, agentDir?: string) {
upsertAuthProfile({
profileId: "xai:default",
credential: {
type: "api_key",
provider: "xai",
key,
},
credential: buildApiKeyCredential("xai", key),
agentDir: resolveAuthAgentDir(agentDir),
});
}