Onboarding: scope non-interactive API keys by agent

This commit is contained in:
Alexander Davydov 2026-03-20 00:55:14 +03:00
parent 673b3f186c
commit f8f55fb0d7
4 changed files with 77 additions and 8 deletions

View File

@ -44,6 +44,7 @@ describe("applySimpleNonInteractiveApiKeyChoice", () => {
}); });
it("disables profile fallback for GigaChat personal OAuth onboarding", async () => { it("disables profile fallback for GigaChat personal OAuth onboarding", async () => {
const agentDir = "/tmp/openclaw-agents/work/agent";
const nextConfig = { agents: { defaults: {} } } as OpenClawConfig; const nextConfig = { agents: { defaults: {} } } as OpenClawConfig;
const resolveApiKey = vi.fn(async () => ({ const resolveApiKey = vi.fn(async () => ({
key: "gigachat-oauth-credentials", key: "gigachat-oauth-credentials",
@ -60,6 +61,7 @@ describe("applySimpleNonInteractiveApiKeyChoice", () => {
baseConfig: nextConfig, baseConfig: nextConfig,
opts: {} as never, opts: {} as never,
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() } as never, runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() } as never,
agentDir,
apiKeyStorageOptions: undefined, apiKeyStorageOptions: undefined,
resolveApiKey, resolveApiKey,
maybeSetResolvedApiKey, maybeSetResolvedApiKey,
@ -70,13 +72,14 @@ describe("applySimpleNonInteractiveApiKeyChoice", () => {
provider: "gigachat", provider: "gigachat",
flagName: "--gigachat-api-key", flagName: "--gigachat-api-key",
envVar: "GIGACHAT_CREDENTIALS", envVar: "GIGACHAT_CREDENTIALS",
agentDir,
allowProfile: false, allowProfile: false,
}), }),
); );
expect(maybeSetResolvedApiKey).toHaveBeenCalledOnce(); expect(maybeSetResolvedApiKey).toHaveBeenCalledOnce();
expect(setGigachatApiKey).toHaveBeenCalledWith( expect(setGigachatApiKey).toHaveBeenCalledWith(
"gigachat-oauth-credentials", "gigachat-oauth-credentials",
undefined, agentDir,
undefined, undefined,
{ {
authMode: "oauth", authMode: "oauth",
@ -87,6 +90,7 @@ describe("applySimpleNonInteractiveApiKeyChoice", () => {
}); });
it("accepts the generic --token input for GigaChat non-interactive OAuth", async () => { it("accepts the generic --token input for GigaChat non-interactive OAuth", async () => {
const agentDir = "/tmp/openclaw-agents/work/agent";
const nextConfig = { agents: { defaults: {} } } as OpenClawConfig; const nextConfig = { agents: { defaults: {} } } as OpenClawConfig;
const resolveApiKey = vi.fn(async () => ({ const resolveApiKey = vi.fn(async () => ({
key: "gigachat-token-credentials", key: "gigachat-token-credentials",
@ -103,6 +107,7 @@ describe("applySimpleNonInteractiveApiKeyChoice", () => {
baseConfig: nextConfig, baseConfig: nextConfig,
opts: { token: "gigachat-token-credentials" } as never, opts: { token: "gigachat-token-credentials" } as never,
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() } as never, runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() } as never,
agentDir,
apiKeyStorageOptions: undefined, apiKeyStorageOptions: undefined,
resolveApiKey, resolveApiKey,
maybeSetResolvedApiKey, maybeSetResolvedApiKey,
@ -114,12 +119,13 @@ describe("applySimpleNonInteractiveApiKeyChoice", () => {
flagValue: "gigachat-token-credentials", flagValue: "gigachat-token-credentials",
flagName: "--gigachat-api-key", flagName: "--gigachat-api-key",
envVar: "GIGACHAT_CREDENTIALS", envVar: "GIGACHAT_CREDENTIALS",
agentDir,
allowProfile: false, allowProfile: false,
}), }),
); );
expect(setGigachatApiKey).toHaveBeenCalledWith( expect(setGigachatApiKey).toHaveBeenCalledWith(
"gigachat-token-credentials", "gigachat-token-credentials",
undefined, agentDir,
undefined, undefined,
{ {
authMode: "oauth", authMode: "oauth",
@ -130,6 +136,7 @@ describe("applySimpleNonInteractiveApiKeyChoice", () => {
}); });
it("rejects Basic-shaped GIGACHAT_CREDENTIALS in the OAuth onboarding path", async () => { it("rejects Basic-shaped GIGACHAT_CREDENTIALS in the OAuth onboarding path", async () => {
const agentDir = "/tmp/openclaw-agents/work/agent";
const nextConfig = { agents: { defaults: {} } } as OpenClawConfig; const nextConfig = { agents: { defaults: {} } } as OpenClawConfig;
const runtime: RuntimeEnv = { const runtime: RuntimeEnv = {
error: vi.fn(), error: vi.fn(),
@ -148,6 +155,7 @@ describe("applySimpleNonInteractiveApiKeyChoice", () => {
baseConfig: nextConfig, baseConfig: nextConfig,
opts: {} as never, opts: {} as never,
runtime, runtime,
agentDir,
apiKeyStorageOptions: undefined, apiKeyStorageOptions: undefined,
resolveApiKey, resolveApiKey,
maybeSetResolvedApiKey, maybeSetResolvedApiKey,
@ -163,6 +171,7 @@ describe("applySimpleNonInteractiveApiKeyChoice", () => {
}); });
it("resets the GigaChat provider base URL when replacing a Basic profile with OAuth", async () => { it("resets the GigaChat provider base URL when replacing a Basic profile with OAuth", async () => {
const agentDir = "/tmp/openclaw-agents/work/agent";
const nextConfig = { agents: { defaults: {} } } as OpenClawConfig; const nextConfig = { agents: { defaults: {} } } as OpenClawConfig;
const basicProfile: ApiKeyCredential = { const basicProfile: ApiKeyCredential = {
type: "api_key", type: "api_key",
@ -203,11 +212,13 @@ describe("applySimpleNonInteractiveApiKeyChoice", () => {
} as OpenClawConfig, } as OpenClawConfig,
opts: { token: "gigachat-oauth-credentials" } as never, opts: { token: "gigachat-oauth-credentials" } as never,
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() } as never, runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() } as never,
agentDir,
apiKeyStorageOptions: undefined, apiKeyStorageOptions: undefined,
resolveApiKey, resolveApiKey,
maybeSetResolvedApiKey, maybeSetResolvedApiKey,
}); });
expect(loadAuthProfileStoreForSecretsRuntime).toHaveBeenCalledWith(agentDir);
expect(applyGigachatConfig).toHaveBeenCalledWith(expect.any(Object), { expect(applyGigachatConfig).toHaveBeenCalledWith(expect.any(Object), {
baseUrl: GIGACHAT_BASE_URL, baseUrl: GIGACHAT_BASE_URL,
}); });

View File

@ -19,8 +19,8 @@ type ResolvedNonInteractiveApiKey = {
source: "profile" | "env" | "flag"; source: "profile" | "env" | "flag";
}; };
function hadStoredGigachatBasicProfile(): boolean { function hadStoredGigachatBasicProfile(agentDir?: string): boolean {
const profile = loadAuthProfileStoreForSecretsRuntime().profiles["gigachat:default"]; const profile = loadAuthProfileStoreForSecretsRuntime(agentDir).profiles["gigachat:default"];
return ( return (
profile?.type === "api_key" && profile?.type === "api_key" &&
profile.provider === "gigachat" && profile.provider === "gigachat" &&
@ -33,6 +33,7 @@ async function applyGigachatNonInteractiveApiKeyChoice(params: {
baseConfig: OpenClawConfig; baseConfig: OpenClawConfig;
opts: OnboardOptions; opts: OnboardOptions;
runtime: RuntimeEnv; runtime: RuntimeEnv;
agentDir?: string;
apiKeyStorageOptions?: ApiKeyStorageOptions; apiKeyStorageOptions?: ApiKeyStorageOptions;
resolveApiKey: (input: { resolveApiKey: (input: {
provider: string; provider: string;
@ -41,6 +42,7 @@ async function applyGigachatNonInteractiveApiKeyChoice(params: {
flagName: `--${string}`; flagName: `--${string}`;
envVar: string; envVar: string;
runtime: RuntimeEnv; runtime: RuntimeEnv;
agentDir?: string;
allowProfile?: boolean; allowProfile?: boolean;
}) => Promise<ResolvedNonInteractiveApiKey | null>; }) => Promise<ResolvedNonInteractiveApiKey | null>;
maybeSetResolvedApiKey: ( maybeSetResolvedApiKey: (
@ -48,7 +50,7 @@ async function applyGigachatNonInteractiveApiKeyChoice(params: {
setter: (value: SecretInput) => Promise<void> | void, setter: (value: SecretInput) => Promise<void> | void,
) => Promise<boolean>; ) => Promise<boolean>;
}): Promise<OpenClawConfig | null> { }): Promise<OpenClawConfig | null> {
const resetGigachatBaseUrl = hadStoredGigachatBasicProfile(); const resetGigachatBaseUrl = hadStoredGigachatBasicProfile(params.agentDir);
const resolved = await params.resolveApiKey({ const resolved = await params.resolveApiKey({
provider: "gigachat", provider: "gigachat",
cfg: params.baseConfig, cfg: params.baseConfig,
@ -56,6 +58,7 @@ async function applyGigachatNonInteractiveApiKeyChoice(params: {
flagName: "--gigachat-api-key", flagName: "--gigachat-api-key",
envVar: "GIGACHAT_CREDENTIALS", envVar: "GIGACHAT_CREDENTIALS",
runtime: params.runtime, runtime: params.runtime,
agentDir: params.agentDir,
// Personal OAuth onboarding must not silently reuse an existing Basic // Personal OAuth onboarding must not silently reuse an existing Basic
// username:password profile and then rewrite the provider to OAuth config. // username:password profile and then rewrite the provider to OAuth config.
allowProfile: false, allowProfile: false,
@ -76,7 +79,7 @@ async function applyGigachatNonInteractiveApiKeyChoice(params: {
} }
if ( if (
!(await params.maybeSetResolvedApiKey(resolved, (value) => !(await params.maybeSetResolvedApiKey(resolved, (value) =>
setGigachatApiKey(value, undefined, params.apiKeyStorageOptions, { setGigachatApiKey(value, params.agentDir, params.apiKeyStorageOptions, {
authMode: "oauth", authMode: "oauth",
insecureTls: "false", insecureTls: "false",
scope: "GIGACHAT_API_PERS", scope: "GIGACHAT_API_PERS",
@ -101,6 +104,7 @@ export async function applySimpleNonInteractiveApiKeyChoice(params: {
baseConfig: OpenClawConfig; baseConfig: OpenClawConfig;
opts: OnboardOptions; opts: OnboardOptions;
runtime: RuntimeEnv; runtime: RuntimeEnv;
agentDir?: string;
apiKeyStorageOptions?: ApiKeyStorageOptions; apiKeyStorageOptions?: ApiKeyStorageOptions;
resolveApiKey: (input: { resolveApiKey: (input: {
provider: string; provider: string;
@ -109,6 +113,7 @@ export async function applySimpleNonInteractiveApiKeyChoice(params: {
flagName: `--${string}`; flagName: `--${string}`;
envVar: string; envVar: string;
runtime: RuntimeEnv; runtime: RuntimeEnv;
agentDir?: string;
allowProfile?: boolean; allowProfile?: boolean;
}) => Promise<ResolvedNonInteractiveApiKey | null>; }) => Promise<ResolvedNonInteractiveApiKey | null>;
maybeSetResolvedApiKey: ( maybeSetResolvedApiKey: (
@ -131,13 +136,14 @@ export async function applySimpleNonInteractiveApiKeyChoice(params: {
flagName: "--litellm-api-key", flagName: "--litellm-api-key",
envVar: "LITELLM_API_KEY", envVar: "LITELLM_API_KEY",
runtime: params.runtime, runtime: params.runtime,
agentDir: params.agentDir,
}); });
if (!resolved) { if (!resolved) {
return null; return null;
} }
if ( if (
!(await params.maybeSetResolvedApiKey(resolved, (value) => !(await params.maybeSetResolvedApiKey(resolved, (value) =>
setLitellmApiKey(value, undefined, params.apiKeyStorageOptions), setLitellmApiKey(value, params.agentDir, params.apiKeyStorageOptions),
)) ))
) { ) {
return null; return null;

View File

@ -3,7 +3,9 @@ import type { OpenClawConfig } from "../../../config/config.js";
import { applyNonInteractiveAuthChoice } from "./auth-choice.js"; import { applyNonInteractiveAuthChoice } from "./auth-choice.js";
const applySimpleNonInteractiveApiKeyChoice = vi.hoisted(() => const applySimpleNonInteractiveApiKeyChoice = vi.hoisted(() =>
vi.fn<() => Promise<OpenClawConfig | null | undefined>>(async () => undefined), vi.fn<typeof import("./auth-choice.api-key-providers.js").applySimpleNonInteractiveApiKeyChoice>(
async () => undefined,
),
); );
vi.mock("./auth-choice.api-key-providers.js", () => ({ vi.mock("./auth-choice.api-key-providers.js", () => ({
applySimpleNonInteractiveApiKeyChoice, applySimpleNonInteractiveApiKeyChoice,
@ -50,4 +52,50 @@ describe("applyNonInteractiveAuthChoice", () => {
expect(applyNonInteractivePluginProviderChoice).toHaveBeenCalledOnce(); expect(applyNonInteractivePluginProviderChoice).toHaveBeenCalledOnce();
expect(applySimpleNonInteractiveApiKeyChoice).not.toHaveBeenCalled(); expect(applySimpleNonInteractiveApiKeyChoice).not.toHaveBeenCalled();
}); });
it("passes the target agent dir into builtin non-interactive API key flows", async () => {
const runtime = createRuntime();
const nextConfig = {
agents: {
defaults: {},
list: [
{
id: "work",
default: true,
agentDir: "/tmp/openclaw-agents/work/agent",
},
],
},
} as OpenClawConfig;
applySimpleNonInteractiveApiKeyChoice.mockImplementationOnce(async ({ resolveApiKey }) => {
await resolveApiKey({
provider: "gigachat",
cfg: nextConfig,
flagName: "--gigachat-api-key",
envVar: "GIGACHAT_CREDENTIALS",
runtime: runtime as never,
});
return null;
});
resolveNonInteractiveApiKey.mockResolvedValueOnce(null);
await applyNonInteractiveAuthChoice({
nextConfig,
authChoice: "gigachat-api-key",
opts: {} as never,
runtime: runtime as never,
baseConfig: nextConfig,
});
expect(applySimpleNonInteractiveApiKeyChoice).toHaveBeenCalledWith(
expect.objectContaining({
agentDir: "/tmp/openclaw-agents/work/agent",
}),
);
expect(resolveNonInteractiveApiKey).toHaveBeenCalledWith(
expect.objectContaining({
agentDir: "/tmp/openclaw-agents/work/agent",
}),
);
});
}); });

View File

@ -1,3 +1,4 @@
import { resolveAgentDir, resolveDefaultAgentId } from "../../../agents/agent-scope.js";
import type { ApiKeyCredential } from "../../../agents/auth-profiles/types.js"; import type { ApiKeyCredential } from "../../../agents/auth-profiles/types.js";
import type { OpenClawConfig } from "../../../config/config.js"; import type { OpenClawConfig } from "../../../config/config.js";
import type { SecretInput } from "../../../config/types.secrets.js"; import type { SecretInput } from "../../../config/types.secrets.js";
@ -38,6 +39,7 @@ export async function applyNonInteractiveAuthChoice(params: {
env: process.env, env: process.env,
}); });
let nextConfig = params.nextConfig; let nextConfig = params.nextConfig;
const agentDir = resolveAgentDir(nextConfig, resolveDefaultAgentId(nextConfig));
const requestedSecretInputMode = normalizeSecretInputModeInput(opts.secretInputMode); const requestedSecretInputMode = normalizeSecretInputModeInput(opts.secretInputMode);
if (opts.secretInputMode && !requestedSecretInputMode) { if (opts.secretInputMode && !requestedSecretInputMode) {
runtime.error('Invalid --secret-input-mode. Use "plaintext" or "ref".'); runtime.error('Invalid --secret-input-mode. Use "plaintext" or "ref".');
@ -76,6 +78,7 @@ export async function applyNonInteractiveAuthChoice(params: {
const resolveApiKey = (input: Parameters<typeof resolveNonInteractiveApiKey>[0]) => const resolveApiKey = (input: Parameters<typeof resolveNonInteractiveApiKey>[0]) =>
resolveNonInteractiveApiKey({ resolveNonInteractiveApiKey({
...input, ...input,
agentDir,
secretInputMode: requestedSecretInputMode, secretInputMode: requestedSecretInputMode,
}); });
const toApiKeyCredential = (params: { const toApiKeyCredential = (params: {
@ -179,6 +182,7 @@ export async function applyNonInteractiveAuthChoice(params: {
baseConfig, baseConfig,
opts, opts,
runtime, runtime,
agentDir,
apiKeyStorageOptions, apiKeyStorageOptions,
resolveApiKey, resolveApiKey,
maybeSetResolvedApiKey, maybeSetResolvedApiKey,