Compare commits

...

2 Commits

Author SHA1 Message Date
Peter Steinberger
7c9a839ff6 fix: honor apiKey auth-choice tokens (#1485) (thanks @iHildy) 2026-01-23 07:27:34 +00:00
iHildy
15c865f340 fix: support direct token and provider in auth apply commands 2026-01-23 07:12:17 +00:00
10 changed files with 403 additions and 100 deletions

View File

@ -32,6 +32,7 @@ Docs: https://docs.clawd.bot
- Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer.
- Agents: sanitize assistant history text to strip tool-call markers. (#1456) Thanks @zerone0x.
- Discord: clarify Message Content Intent onboarding hint. (#1487) Thanks @kyleok.
- Onboarding: honor `--auth-choice apiKey` token provider + token flags to skip prompts. (#1485) Thanks @iHildy.
- Agents: surface concrete API error details instead of generic AI service errors.
- Exec: fall back to non-PTY when PTY spawn fails (EBADF). (#1484)
- Exec approvals: allow per-segment allowlists for chained shell commands on gateway + node hosts. (#1458) Thanks @czekaj.

View File

@ -295,8 +295,8 @@ Options:
- `--mode <local|remote>`
- `--flow <quickstart|advanced|manual>` (manual is an alias for advanced)
- `--auth-choice <setup-token|claude-cli|token|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|codex-cli|gemini-api-key|zai-api-key|apiKey|minimax-api|opencode-zen|skip>`
- `--token-provider <id>` (non-interactive; used with `--auth-choice token`)
- `--token <token>` (non-interactive; used with `--auth-choice token`)
- `--token-provider <id>` (non-interactive; used with `--auth-choice token` or `apiKey`)
- `--token <token>` (non-interactive; used with `--auth-choice token` or `apiKey`)
- `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`)
- `--token-expires-in <duration>` (non-interactive; e.g. `365d`, `12h`)
- `--anthropic-api-key <key>`

View File

@ -56,9 +56,12 @@ export function registerOnboardCommand(program: Command) {
)
.option(
"--token-provider <id>",
"Token provider id (non-interactive; used with --auth-choice token)",
"Token provider id (non-interactive; used with --auth-choice token or apiKey)",
)
.option(
"--token <token>",
"Token value (non-interactive; used with --auth-choice token or apiKey)",
)
.option("--token <token>", "Token value (non-interactive; used with --auth-choice token)")
.option(
"--token-profile-id <id>",
"Auth profile id (non-interactive; default: <provider>:manual)",

View File

@ -0,0 +1,183 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { AUTH_STORE_VERSION } from "../agents/auth-profiles/constants.js";
import { applyAuthChoice } from "./auth-choice.js";
const noopAsync = async () => {};
const noop = () => {};
const authProfilePathFor = (agentDir: string) => path.join(agentDir, "auth-profiles.json");
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
const previousAnthropicKey = process.env.ANTHROPIC_API_KEY;
const previousOpenaiKey = process.env.OPENAI_API_KEY;
let tempStateDir: string | null = null;
async function setupTempState() {
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-auth-"));
process.env.CLAWDBOT_STATE_DIR = tempStateDir;
process.env.CLAWDBOT_AGENT_DIR = path.join(tempStateDir, "agent");
process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
await fs.mkdir(process.env.CLAWDBOT_AGENT_DIR, { recursive: true });
}
function buildPrompter() {
const text = vi.fn(async () => "");
const confirm = vi.fn(async () => false);
const prompter: WizardPrompter = {
intro: vi.fn(noopAsync),
outro: vi.fn(noopAsync),
note: vi.fn(noopAsync),
select: vi.fn(async () => "" as never),
multiselect: vi.fn(async () => []),
text,
confirm,
progress: vi.fn(() => ({ update: noop, stop: noop })),
};
return { prompter, text, confirm };
}
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number) => {
throw new Error(`exit:${code}`);
}),
};
afterEach(async () => {
if (tempStateDir) {
await fs.rm(tempStateDir, { recursive: true, force: true });
tempStateDir = null;
}
if (previousStateDir === undefined) {
delete process.env.CLAWDBOT_STATE_DIR;
} else {
process.env.CLAWDBOT_STATE_DIR = previousStateDir;
}
if (previousAgentDir === undefined) {
delete process.env.CLAWDBOT_AGENT_DIR;
} else {
process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
}
if (previousPiAgentDir === undefined) {
delete process.env.PI_CODING_AGENT_DIR;
} else {
process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
}
if (previousAnthropicKey === undefined) {
delete process.env.ANTHROPIC_API_KEY;
} else {
process.env.ANTHROPIC_API_KEY = previousAnthropicKey;
}
if (previousOpenaiKey === undefined) {
delete process.env.OPENAI_API_KEY;
} else {
process.env.OPENAI_API_KEY = previousOpenaiKey;
}
});
describe("applyAuthChoice with apiKey flags", () => {
it("uses provided openrouter token when authChoice=apiKey", async () => {
await setupTempState();
const agentDir = process.env.CLAWDBOT_AGENT_DIR ?? "";
const authProfilePath = authProfilePathFor(agentDir);
await fs.writeFile(
authProfilePath,
JSON.stringify({
version: AUTH_STORE_VERSION,
profiles: {
"openrouter:legacy": {
type: "oauth",
provider: "openrouter",
access: "access",
refresh: "refresh",
expires: Date.now() + 60_000,
},
},
}),
"utf8",
);
const { prompter, text, confirm } = buildPrompter();
const result = await applyAuthChoice({
authChoice: "apiKey",
config: {},
prompter,
runtime,
setDefaultModel: true,
opts: {
tokenProvider: "openrouter",
token: "sk-openrouter-flag",
},
});
expect(text).not.toHaveBeenCalled();
expect(confirm).not.toHaveBeenCalled();
expect(result.config.auth?.profiles?.["openrouter:default"]).toMatchObject({
provider: "openrouter",
mode: "api_key",
});
const raw = await fs.readFile(authProfilePath, "utf8");
const parsed = JSON.parse(raw) as { profiles?: Record<string, { key?: string }> };
expect(parsed.profiles?.["openrouter:default"]?.key).toBe("sk-openrouter-flag");
});
it("uses provided openai token when authChoice=apiKey", async () => {
await setupTempState();
const { prompter, text, confirm } = buildPrompter();
await applyAuthChoice({
authChoice: "apiKey",
config: {},
prompter,
runtime,
setDefaultModel: true,
opts: {
tokenProvider: "openai",
token: "sk-openai-flag",
},
});
expect(text).not.toHaveBeenCalled();
expect(confirm).not.toHaveBeenCalled();
expect(process.env.OPENAI_API_KEY).toBe("sk-openai-flag");
const envPath = path.join(process.env.CLAWDBOT_STATE_DIR ?? "", ".env");
const envContents = await fs.readFile(envPath, "utf8");
expect(envContents).toContain("OPENAI_API_KEY=sk-openai-flag");
});
it("uses provided anthropic token when authChoice=apiKey", async () => {
await setupTempState();
process.env.ANTHROPIC_API_KEY = "sk-env-test";
const { prompter, text, confirm } = buildPrompter();
await applyAuthChoice({
authChoice: "apiKey",
config: {},
prompter,
runtime,
setDefaultModel: true,
opts: {
tokenProvider: "anthropic",
token: "sk-anthropic-flag",
},
});
expect(text).not.toHaveBeenCalled();
expect(confirm).not.toHaveBeenCalled();
const authProfilePath = authProfilePathFor(process.env.CLAWDBOT_AGENT_DIR ?? "");
const raw = await fs.readFile(authProfilePath, "utf8");
const parsed = JSON.parse(raw) as { profiles?: Record<string, { key?: string }> };
expect(parsed.profiles?.["anthropic:default"]?.key).toBe("sk-anthropic-flag");
});
});

View File

@ -8,6 +8,7 @@ import {
normalizeApiKeyInput,
validateApiKeyInput,
} from "./auth-choice.api-key.js";
import { normalizeProviderId } from "../agents/model-selection.js";
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
import { buildTokenProfileId, validateAnthropicSetupToken } from "./auth-token.js";
import { applyAuthProfileConfig, setAnthropicApiKey } from "./onboard-auth.js";
@ -198,10 +199,23 @@ export async function applyAuthChoiceAnthropic(
}
if (params.authChoice === "apiKey") {
const tokenProvider = params.opts?.tokenProvider
? normalizeProviderId(params.opts.tokenProvider)
: undefined;
const explicitToken =
tokenProvider === "anthropic" ? normalizeApiKeyInput(params.opts?.token ?? "") : "";
if (tokenProvider && tokenProvider !== "anthropic") {
return null;
}
let nextConfig = params.config;
let hasCredential = false;
const envKey = process.env.ANTHROPIC_API_KEY?.trim();
if (envKey) {
if (explicitToken) {
await setAnthropicApiKey(explicitToken, params.agentDir);
hasCredential = true;
}
if (!hasCredential && envKey) {
const useExisting = await params.prompter.confirm({
message: `Use existing ANTHROPIC_API_KEY (env, ${formatApiKeyPreview(envKey)})?`,
initialValue: true,

View File

@ -1,4 +1,5 @@
import { ensureAuthProfileStore, resolveAuthProfileOrder } from "../agents/auth-profiles.js";
import { normalizeProviderId } from "../agents/model-selection.js";
import { resolveEnvApiKey } from "../agents/model-auth.js";
import {
formatApiKeyPreview,
@ -6,6 +7,7 @@ import {
validateApiKeyInput,
} from "./auth-choice.api-key.js";
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
import type { AuthChoice } from "./onboard-types.js";
import { applyDefaultModelChoice } from "./auth-choice.default-model.js";
import {
applyGoogleGeminiModelDefault,
@ -56,7 +58,41 @@ export async function applyAuthChoiceApiProviders(
);
};
if (params.authChoice === "openrouter-api-key") {
const tokenProvider = params.opts?.tokenProvider
? normalizeProviderId(params.opts.tokenProvider)
: undefined;
const tokenValue = params.opts?.token;
let authChoice = params.authChoice;
if (
authChoice === "apiKey" &&
tokenProvider &&
tokenProvider !== "anthropic" &&
tokenProvider !== "openai"
) {
const mapped: Partial<Record<string, AuthChoice>> = {
openrouter: "openrouter-api-key",
"vercel-ai-gateway": "ai-gateway-api-key",
moonshot: "moonshot-api-key",
"kimi-code": "kimi-code-api-key",
google: "gemini-api-key",
zai: "zai-api-key",
synthetic: "synthetic-api-key",
opencode: "opencode-zen",
};
authChoice = mapped[tokenProvider] ?? authChoice;
}
if (authChoice === "openrouter-api-key") {
let profileId = "openrouter:default";
let mode: "api_key" | "oauth" | "token" = "api_key";
let hasCredential = false;
const explicitToken =
tokenProvider === "openrouter" ? normalizeApiKeyInput(tokenValue ?? "") : "";
if (explicitToken) {
await setOpenrouterApiKey(explicitToken, params.agentDir);
hasCredential = true;
} else {
const store = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: false,
});
@ -65,12 +101,10 @@ export async function applyAuthChoiceApiProviders(
store,
provider: "openrouter",
});
const existingProfileId = profileOrder.find((profileId) => Boolean(store.profiles[profileId]));
const existingProfileId = profileOrder.find((profileId) =>
Boolean(store.profiles[profileId]),
);
const existingCred = existingProfileId ? store.profiles[existingProfileId] : undefined;
let profileId = "openrouter:default";
let mode: "api_key" | "oauth" | "token" = "api_key";
let hasCredential = false;
if (existingProfileId && existingCred?.type) {
profileId = existingProfileId;
mode =
@ -104,6 +138,7 @@ export async function applyAuthChoiceApiProviders(
await setOpenrouterApiKey(normalizeApiKeyInput(String(key)), params.agentDir);
hasCredential = true;
}
}
if (hasCredential) {
nextConfig = applyAuthProfileConfig(nextConfig, {
@ -129,10 +164,16 @@ export async function applyAuthChoiceApiProviders(
return { config: nextConfig, agentModelOverride };
}
if (params.authChoice === "ai-gateway-api-key") {
if (authChoice === "ai-gateway-api-key") {
let hasCredential = false;
const explicitToken =
tokenProvider === "vercel-ai-gateway" ? normalizeApiKeyInput(tokenValue ?? "") : "";
if (explicitToken) {
await setVercelAiGatewayApiKey(explicitToken, params.agentDir);
hasCredential = true;
}
const envKey = resolveEnvApiKey("vercel-ai-gateway");
if (envKey) {
if (!hasCredential && envKey) {
const useExisting = await params.prompter.confirm({
message: `Use existing AI_GATEWAY_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`,
initialValue: true,
@ -171,10 +212,16 @@ export async function applyAuthChoiceApiProviders(
return { config: nextConfig, agentModelOverride };
}
if (params.authChoice === "moonshot-api-key") {
if (authChoice === "moonshot-api-key") {
let hasCredential = false;
const explicitToken =
tokenProvider === "moonshot" ? normalizeApiKeyInput(tokenValue ?? "") : "";
if (explicitToken) {
await setMoonshotApiKey(explicitToken, params.agentDir);
hasCredential = true;
}
const envKey = resolveEnvApiKey("moonshot");
if (envKey) {
if (!hasCredential && envKey) {
const useExisting = await params.prompter.confirm({
message: `Use existing MOONSHOT_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`,
initialValue: true,
@ -212,7 +259,15 @@ export async function applyAuthChoiceApiProviders(
return { config: nextConfig, agentModelOverride };
}
if (params.authChoice === "kimi-code-api-key") {
if (authChoice === "kimi-code-api-key") {
let hasCredential = false;
const explicitToken =
tokenProvider === "kimi-code" ? normalizeApiKeyInput(tokenValue ?? "") : "";
if (explicitToken) {
await setKimiCodeApiKey(explicitToken, params.agentDir);
hasCredential = true;
}
if (!hasCredential) {
await params.prompter.note(
[
"Kimi Code uses a dedicated endpoint and API key.",
@ -220,9 +275,9 @@ export async function applyAuthChoiceApiProviders(
].join("\n"),
"Kimi Code",
);
let hasCredential = false;
}
const envKey = resolveEnvApiKey("kimi-code");
if (envKey) {
if (!hasCredential && envKey) {
const useExisting = await params.prompter.confirm({
message: `Use existing KIMICODE_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`,
initialValue: true,
@ -261,10 +316,15 @@ export async function applyAuthChoiceApiProviders(
return { config: nextConfig, agentModelOverride };
}
if (params.authChoice === "gemini-api-key") {
if (authChoice === "gemini-api-key") {
let hasCredential = false;
const explicitToken = tokenProvider === "google" ? normalizeApiKeyInput(tokenValue ?? "") : "";
if (explicitToken) {
await setGeminiApiKey(explicitToken, params.agentDir);
hasCredential = true;
}
const envKey = resolveEnvApiKey("google");
if (envKey) {
if (!hasCredential && envKey) {
const useExisting = await params.prompter.confirm({
message: `Use existing GEMINI_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`,
initialValue: true,
@ -302,10 +362,15 @@ export async function applyAuthChoiceApiProviders(
return { config: nextConfig, agentModelOverride };
}
if (params.authChoice === "zai-api-key") {
if (authChoice === "zai-api-key") {
let hasCredential = false;
const explicitToken = tokenProvider === "zai" ? normalizeApiKeyInput(tokenValue ?? "") : "";
if (explicitToken) {
await setZaiApiKey(explicitToken, params.agentDir);
hasCredential = true;
}
const envKey = resolveEnvApiKey("zai");
if (envKey) {
if (!hasCredential && envKey) {
const useExisting = await params.prompter.confirm({
message: `Use existing ZAI_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`,
initialValue: true,
@ -359,12 +424,18 @@ export async function applyAuthChoiceApiProviders(
return { config: nextConfig, agentModelOverride };
}
if (params.authChoice === "synthetic-api-key") {
if (authChoice === "synthetic-api-key") {
const explicitToken =
tokenProvider === "synthetic" ? normalizeApiKeyInput(tokenValue ?? "") : "";
if (explicitToken) {
await setSyntheticApiKey(explicitToken, params.agentDir);
} else {
const key = await params.prompter.text({
message: "Enter Synthetic API key",
validate: (value) => (value?.trim() ? undefined : "Required"),
});
await setSyntheticApiKey(String(key).trim(), params.agentDir);
}
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "synthetic:default",
provider: "synthetic",
@ -387,7 +458,15 @@ export async function applyAuthChoiceApiProviders(
return { config: nextConfig, agentModelOverride };
}
if (params.authChoice === "opencode-zen") {
if (authChoice === "opencode-zen") {
let hasCredential = false;
const explicitToken =
tokenProvider === "opencode" ? normalizeApiKeyInput(tokenValue ?? "") : "";
if (explicitToken) {
await setOpencodeZenApiKey(explicitToken, params.agentDir);
hasCredential = true;
}
if (!hasCredential) {
await params.prompter.note(
[
"OpenCode Zen provides access to Claude, GPT, Gemini, and more models.",
@ -396,9 +475,9 @@ export async function applyAuthChoiceApiProviders(
].join("\n"),
"OpenCode Zen",
);
let hasCredential = false;
}
const envKey = resolveEnvApiKey("opencode");
if (envKey) {
if (!hasCredential && envKey) {
const useExisting = await params.prompter.confirm({
message: `Use existing OPENCODE_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`,
initialValue: true,

View File

@ -1,5 +1,6 @@
import { loginOpenAICodex } from "@mariozechner/pi-ai";
import { CODEX_CLI_PROFILE_ID, ensureAuthProfileStore } from "../agents/auth-profiles.js";
import { normalizeProviderId } from "../agents/model-selection.js";
import { resolveEnvApiKey } from "../agents/model-auth.js";
import { upsertSharedEnvVar } from "../infra/env-file.js";
import { isRemoteEnvironment } from "./oauth-env.js";
@ -20,7 +21,18 @@ import {
export async function applyAuthChoiceOpenAI(
params: ApplyAuthChoiceParams,
): Promise<ApplyAuthChoiceResult | null> {
if (params.authChoice === "openai-api-key") {
const tokenProvider = params.opts?.tokenProvider
? normalizeProviderId(params.opts.tokenProvider)
: undefined;
const explicitToken =
tokenProvider === "openai" ? normalizeApiKeyInput(params.opts?.token ?? "") : "";
let authChoice = params.authChoice;
if (authChoice === "apiKey" && tokenProvider === "openai") {
authChoice = "openai-api-key";
}
if (authChoice === "openai-api-key") {
if (!explicitToken) {
const envKey = resolveEnvApiKey("openai");
if (envKey) {
const useExisting = await params.prompter.confirm({
@ -42,8 +54,11 @@ export async function applyAuthChoiceOpenAI(
return { config: params.config };
}
}
}
const key = await params.prompter.text({
const key = explicitToken
? explicitToken
: await params.prompter.text({
message: "Enter OpenAI API key",
validate: validateApiKeyInput,
});

View File

@ -21,6 +21,10 @@ export type ApplyAuthChoiceParams = {
agentDir?: string;
setDefaultModel: boolean;
agentId?: string;
opts?: {
tokenProvider?: string;
token?: string;
};
};
export type ApplyAuthChoiceResult = {

View File

@ -50,9 +50,9 @@ export type OnboardOptions = {
acceptRisk?: boolean;
reset?: boolean;
authChoice?: AuthChoice;
/** Used when `authChoice=token` in non-interactive mode. */
/** Used when `authChoice=token` in non-interactive mode, or `authChoice=apiKey` in wizard mode. */
tokenProvider?: string;
/** Used when `authChoice=token` in non-interactive mode. */
/** Used when `authChoice=token` in non-interactive mode, or `authChoice=apiKey` in wizard mode. */
token?: string;
/** Used when `authChoice=token` in non-interactive mode. */
tokenProfileId?: string;

View File

@ -356,6 +356,10 @@ export async function runOnboardingWizard(
prompter,
runtime,
setDefaultModel: true,
opts: {
tokenProvider: opts.tokenProvider,
token: opts.authChoice === "apiKey" && opts.token ? opts.token : undefined,
},
});
nextConfig = authResult.config;