From f8f55fb0d70ba76e5cf7ca724dfd19fd9cd60571 Mon Sep 17 00:00:00 2001 From: Alexander Davydov Date: Fri, 20 Mar 2026 00:55:14 +0300 Subject: [PATCH] Onboarding: scope non-interactive API keys by agent --- .../auth-choice.api-key-providers.test.ts | 15 +++++- .../local/auth-choice.api-key-providers.ts | 16 ++++-- .../local/auth-choice.test.ts | 50 ++++++++++++++++++- .../local/auth-choice.ts | 4 ++ 4 files changed, 77 insertions(+), 8 deletions(-) diff --git a/src/commands/onboard-non-interactive/local/auth-choice.api-key-providers.test.ts b/src/commands/onboard-non-interactive/local/auth-choice.api-key-providers.test.ts index 8bf01ed1693..8ee246d6703 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.api-key-providers.test.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.api-key-providers.test.ts @@ -44,6 +44,7 @@ describe("applySimpleNonInteractiveApiKeyChoice", () => { }); it("disables profile fallback for GigaChat personal OAuth onboarding", async () => { + const agentDir = "/tmp/openclaw-agents/work/agent"; const nextConfig = { agents: { defaults: {} } } as OpenClawConfig; const resolveApiKey = vi.fn(async () => ({ key: "gigachat-oauth-credentials", @@ -60,6 +61,7 @@ describe("applySimpleNonInteractiveApiKeyChoice", () => { baseConfig: nextConfig, opts: {} as never, runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() } as never, + agentDir, apiKeyStorageOptions: undefined, resolveApiKey, maybeSetResolvedApiKey, @@ -70,13 +72,14 @@ describe("applySimpleNonInteractiveApiKeyChoice", () => { provider: "gigachat", flagName: "--gigachat-api-key", envVar: "GIGACHAT_CREDENTIALS", + agentDir, allowProfile: false, }), ); expect(maybeSetResolvedApiKey).toHaveBeenCalledOnce(); expect(setGigachatApiKey).toHaveBeenCalledWith( "gigachat-oauth-credentials", - undefined, + agentDir, undefined, { authMode: "oauth", @@ -87,6 +90,7 @@ describe("applySimpleNonInteractiveApiKeyChoice", () => { }); 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 resolveApiKey = vi.fn(async () => ({ key: "gigachat-token-credentials", @@ -103,6 +107,7 @@ describe("applySimpleNonInteractiveApiKeyChoice", () => { baseConfig: nextConfig, opts: { token: "gigachat-token-credentials" } as never, runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() } as never, + agentDir, apiKeyStorageOptions: undefined, resolveApiKey, maybeSetResolvedApiKey, @@ -114,12 +119,13 @@ describe("applySimpleNonInteractiveApiKeyChoice", () => { flagValue: "gigachat-token-credentials", flagName: "--gigachat-api-key", envVar: "GIGACHAT_CREDENTIALS", + agentDir, allowProfile: false, }), ); expect(setGigachatApiKey).toHaveBeenCalledWith( "gigachat-token-credentials", - undefined, + agentDir, undefined, { authMode: "oauth", @@ -130,6 +136,7 @@ describe("applySimpleNonInteractiveApiKeyChoice", () => { }); 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 runtime: RuntimeEnv = { error: vi.fn(), @@ -148,6 +155,7 @@ describe("applySimpleNonInteractiveApiKeyChoice", () => { baseConfig: nextConfig, opts: {} as never, runtime, + agentDir, apiKeyStorageOptions: undefined, resolveApiKey, maybeSetResolvedApiKey, @@ -163,6 +171,7 @@ describe("applySimpleNonInteractiveApiKeyChoice", () => { }); 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 basicProfile: ApiKeyCredential = { type: "api_key", @@ -203,11 +212,13 @@ describe("applySimpleNonInteractiveApiKeyChoice", () => { } as OpenClawConfig, opts: { token: "gigachat-oauth-credentials" } as never, runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() } as never, + agentDir, apiKeyStorageOptions: undefined, resolveApiKey, maybeSetResolvedApiKey, }); + expect(loadAuthProfileStoreForSecretsRuntime).toHaveBeenCalledWith(agentDir); expect(applyGigachatConfig).toHaveBeenCalledWith(expect.any(Object), { baseUrl: GIGACHAT_BASE_URL, }); diff --git a/src/commands/onboard-non-interactive/local/auth-choice.api-key-providers.ts b/src/commands/onboard-non-interactive/local/auth-choice.api-key-providers.ts index 577e9589915..4d7b1d0e568 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.api-key-providers.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.api-key-providers.ts @@ -19,8 +19,8 @@ type ResolvedNonInteractiveApiKey = { source: "profile" | "env" | "flag"; }; -function hadStoredGigachatBasicProfile(): boolean { - const profile = loadAuthProfileStoreForSecretsRuntime().profiles["gigachat:default"]; +function hadStoredGigachatBasicProfile(agentDir?: string): boolean { + const profile = loadAuthProfileStoreForSecretsRuntime(agentDir).profiles["gigachat:default"]; return ( profile?.type === "api_key" && profile.provider === "gigachat" && @@ -33,6 +33,7 @@ async function applyGigachatNonInteractiveApiKeyChoice(params: { baseConfig: OpenClawConfig; opts: OnboardOptions; runtime: RuntimeEnv; + agentDir?: string; apiKeyStorageOptions?: ApiKeyStorageOptions; resolveApiKey: (input: { provider: string; @@ -41,6 +42,7 @@ async function applyGigachatNonInteractiveApiKeyChoice(params: { flagName: `--${string}`; envVar: string; runtime: RuntimeEnv; + agentDir?: string; allowProfile?: boolean; }) => Promise; maybeSetResolvedApiKey: ( @@ -48,7 +50,7 @@ async function applyGigachatNonInteractiveApiKeyChoice(params: { setter: (value: SecretInput) => Promise | void, ) => Promise; }): Promise { - const resetGigachatBaseUrl = hadStoredGigachatBasicProfile(); + const resetGigachatBaseUrl = hadStoredGigachatBasicProfile(params.agentDir); const resolved = await params.resolveApiKey({ provider: "gigachat", cfg: params.baseConfig, @@ -56,6 +58,7 @@ async function applyGigachatNonInteractiveApiKeyChoice(params: { flagName: "--gigachat-api-key", envVar: "GIGACHAT_CREDENTIALS", runtime: params.runtime, + agentDir: params.agentDir, // Personal OAuth onboarding must not silently reuse an existing Basic // username:password profile and then rewrite the provider to OAuth config. allowProfile: false, @@ -76,7 +79,7 @@ async function applyGigachatNonInteractiveApiKeyChoice(params: { } if ( !(await params.maybeSetResolvedApiKey(resolved, (value) => - setGigachatApiKey(value, undefined, params.apiKeyStorageOptions, { + setGigachatApiKey(value, params.agentDir, params.apiKeyStorageOptions, { authMode: "oauth", insecureTls: "false", scope: "GIGACHAT_API_PERS", @@ -101,6 +104,7 @@ export async function applySimpleNonInteractiveApiKeyChoice(params: { baseConfig: OpenClawConfig; opts: OnboardOptions; runtime: RuntimeEnv; + agentDir?: string; apiKeyStorageOptions?: ApiKeyStorageOptions; resolveApiKey: (input: { provider: string; @@ -109,6 +113,7 @@ export async function applySimpleNonInteractiveApiKeyChoice(params: { flagName: `--${string}`; envVar: string; runtime: RuntimeEnv; + agentDir?: string; allowProfile?: boolean; }) => Promise; maybeSetResolvedApiKey: ( @@ -131,13 +136,14 @@ export async function applySimpleNonInteractiveApiKeyChoice(params: { flagName: "--litellm-api-key", envVar: "LITELLM_API_KEY", runtime: params.runtime, + agentDir: params.agentDir, }); if (!resolved) { return null; } if ( !(await params.maybeSetResolvedApiKey(resolved, (value) => - setLitellmApiKey(value, undefined, params.apiKeyStorageOptions), + setLitellmApiKey(value, params.agentDir, params.apiKeyStorageOptions), )) ) { return null; diff --git a/src/commands/onboard-non-interactive/local/auth-choice.test.ts b/src/commands/onboard-non-interactive/local/auth-choice.test.ts index b3255e7b4bb..8a3386ba746 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.test.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.test.ts @@ -3,7 +3,9 @@ import type { OpenClawConfig } from "../../../config/config.js"; import { applyNonInteractiveAuthChoice } from "./auth-choice.js"; const applySimpleNonInteractiveApiKeyChoice = vi.hoisted(() => - vi.fn<() => Promise>(async () => undefined), + vi.fn( + async () => undefined, + ), ); vi.mock("./auth-choice.api-key-providers.js", () => ({ applySimpleNonInteractiveApiKeyChoice, @@ -50,4 +52,50 @@ describe("applyNonInteractiveAuthChoice", () => { expect(applyNonInteractivePluginProviderChoice).toHaveBeenCalledOnce(); 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", + }), + ); + }); }); diff --git a/src/commands/onboard-non-interactive/local/auth-choice.ts b/src/commands/onboard-non-interactive/local/auth-choice.ts index 34f30df2a10..270db3499b5 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.ts @@ -1,3 +1,4 @@ +import { resolveAgentDir, resolveDefaultAgentId } from "../../../agents/agent-scope.js"; import type { ApiKeyCredential } from "../../../agents/auth-profiles/types.js"; import type { OpenClawConfig } from "../../../config/config.js"; import type { SecretInput } from "../../../config/types.secrets.js"; @@ -38,6 +39,7 @@ export async function applyNonInteractiveAuthChoice(params: { env: process.env, }); let nextConfig = params.nextConfig; + const agentDir = resolveAgentDir(nextConfig, resolveDefaultAgentId(nextConfig)); const requestedSecretInputMode = normalizeSecretInputModeInput(opts.secretInputMode); if (opts.secretInputMode && !requestedSecretInputMode) { runtime.error('Invalid --secret-input-mode. Use "plaintext" or "ref".'); @@ -76,6 +78,7 @@ export async function applyNonInteractiveAuthChoice(params: { const resolveApiKey = (input: Parameters[0]) => resolveNonInteractiveApiKey({ ...input, + agentDir, secretInputMode: requestedSecretInputMode, }); const toApiKeyCredential = (params: { @@ -179,6 +182,7 @@ export async function applyNonInteractiveAuthChoice(params: { baseConfig, opts, runtime, + agentDir, apiKeyStorageOptions, resolveApiKey, maybeSetResolvedApiKey,