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 () => {
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,
});

View File

@ -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<ResolvedNonInteractiveApiKey | null>;
maybeSetResolvedApiKey: (
@ -48,7 +50,7 @@ async function applyGigachatNonInteractiveApiKeyChoice(params: {
setter: (value: SecretInput) => Promise<void> | void,
) => Promise<boolean>;
}): Promise<OpenClawConfig | null> {
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<ResolvedNonInteractiveApiKey | null>;
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;

View File

@ -3,7 +3,9 @@ import type { OpenClawConfig } from "../../../config/config.js";
import { applyNonInteractiveAuthChoice } from "./auth-choice.js";
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", () => ({
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",
}),
);
});
});

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 { 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<typeof resolveNonInteractiveApiKey>[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,