fix: restore anthropic setup-token auth flow

This commit is contained in:
Peter Steinberger
2026-04-05 04:31:14 +09:00
parent 334c4be73e
commit b4216d197d
8 changed files with 348 additions and 97 deletions

View File

@@ -1,19 +1,24 @@
import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime";
import { formatCliCommand, parseDurationMs } from "openclaw/plugin-sdk/cli-runtime";
import type {
OpenClawPluginApi,
ProviderAuthContext,
ProviderAuthMethodNonInteractiveContext,
ProviderResolveDynamicModelContext,
ProviderRuntimeModel,
} from "openclaw/plugin-sdk/plugin-entry";
import {
applyAuthProfileConfig,
createProviderApiKeyAuthMethod,
buildTokenProfileId,
ensureApiKeyFromOptionEnvOrPrompt,
listProfilesForProvider,
normalizeApiKeyInput,
type OpenClawConfig as ProviderAuthConfig,
suggestOAuthProfileIdForLegacyDefault,
type AuthProfileStore,
type ProviderAuthResult,
upsertAuthProfile,
validateAnthropicSetupToken,
validateApiKeyInput,
} from "openclaw/plugin-sdk/provider-auth";
import { cloneFirstTemplateModel } from "openclaw/plugin-sdk/provider-model-shared";
@@ -50,6 +55,129 @@ const ANTHROPIC_OAUTH_ALLOWLIST = [
"anthropic/claude-sonnet-4-5",
"anthropic/claude-haiku-4-5",
] as const;
const ANTHROPIC_SETUP_TOKEN_NOTE_LINES = [
"Anthropic setup-token auth is a legacy/manual path in OpenClaw.",
"Anthropic told OpenClaw users that OpenClaw counts as a third-party harness, so this path requires Extra Usage on the Claude account.",
`If you want a direct API billing path instead, use ${formatCliCommand("openclaw models auth login --provider anthropic --method api-key --set-default")} or ${formatCliCommand("openclaw models auth login --provider anthropic --method cli --set-default")}.`,
] as const;
function normalizeAnthropicSetupTokenInput(value: string): string {
return value.replaceAll(/\s+/g, "").trim();
}
function resolveAnthropicSetupTokenProfileId(rawProfileId?: unknown): string {
if (typeof rawProfileId === "string") {
const trimmed = rawProfileId.trim();
if (trimmed.length > 0) {
if (trimmed.startsWith(`${PROVIDER_ID}:`)) {
return trimmed;
}
return buildTokenProfileId({ provider: PROVIDER_ID, name: trimmed });
}
}
return `${PROVIDER_ID}:default`;
}
function resolveAnthropicSetupTokenExpiry(rawExpiresIn?: unknown): number | undefined {
if (typeof rawExpiresIn !== "string" || rawExpiresIn.trim().length === 0) {
return undefined;
}
return Date.now() + parseDurationMs(rawExpiresIn.trim(), { defaultUnit: "d" });
}
async function runAnthropicSetupTokenAuth(ctx: ProviderAuthContext): Promise<ProviderAuthResult> {
const providedToken =
typeof ctx.opts?.token === "string" && ctx.opts.token.trim().length > 0
? normalizeAnthropicSetupTokenInput(ctx.opts.token)
: undefined;
const token =
providedToken ??
normalizeAnthropicSetupTokenInput(
await ctx.prompter.text({
message: "Paste Anthropic setup-token",
validate: (value) => validateAnthropicSetupToken(normalizeAnthropicSetupTokenInput(value)),
}),
);
const tokenError = validateAnthropicSetupToken(token);
if (tokenError) {
throw new Error(tokenError);
}
const profileId = resolveAnthropicSetupTokenProfileId(ctx.opts?.tokenProfileId);
const expires = resolveAnthropicSetupTokenExpiry(ctx.opts?.tokenExpiresIn);
return {
profiles: [
{
profileId,
credential: {
type: "token",
provider: PROVIDER_ID,
token,
...(expires ? { expires } : {}),
},
},
],
defaultModel: DEFAULT_ANTHROPIC_MODEL,
notes: [...ANTHROPIC_SETUP_TOKEN_NOTE_LINES],
};
}
async function runAnthropicSetupTokenNonInteractive(
ctx: ProviderAuthMethodNonInteractiveContext,
): Promise<ProviderAuthConfig | null> {
const rawToken =
typeof ctx.opts.token === "string" ? normalizeAnthropicSetupTokenInput(ctx.opts.token) : "";
const tokenError = validateAnthropicSetupToken(rawToken);
if (tokenError) {
ctx.runtime.error(
["Anthropic setup-token auth requires --token with a valid setup-token.", tokenError].join(
"\n",
),
);
ctx.runtime.exit(1);
return null;
}
const profileId = resolveAnthropicSetupTokenProfileId(ctx.opts.tokenProfileId);
const expires = resolveAnthropicSetupTokenExpiry(ctx.opts.tokenExpiresIn);
upsertAuthProfile({
profileId,
credential: {
type: "token",
provider: PROVIDER_ID,
token: rawToken,
...(expires ? { expires } : {}),
},
agentDir: ctx.agentDir,
});
ctx.runtime.log(ANTHROPIC_SETUP_TOKEN_NOTE_LINES[0]);
ctx.runtime.log(ANTHROPIC_SETUP_TOKEN_NOTE_LINES[1]);
const withProfile = applyAuthProfileConfig(ctx.config, {
profileId,
provider: PROVIDER_ID,
mode: "token",
});
const existingModelConfig =
withProfile.agents?.defaults?.model && typeof withProfile.agents.defaults.model === "object"
? withProfile.agents.defaults.model
: {};
return {
...withProfile,
agents: {
...withProfile.agents,
defaults: {
...withProfile.agents?.defaults,
model: {
...existingModelConfig,
primary: DEFAULT_ANTHROPIC_MODEL,
},
},
},
};
}
function resolveAnthropic46ForwardCompatModel(params: {
ctx: ProviderResolveDynamicModelContext;
@@ -256,6 +384,25 @@ export function registerAnthropicPlugin(api: OpenClawPluginApi): void {
runtime: ctx.runtime,
}),
},
createProviderApiKeyAuthMethod({
{
id: "setup-token",
label: "Anthropic setup-token",
hint: "Legacy/manual bearer token path; requires Extra Usage when used through OpenClaw",
kind: "token",
wizard: {
choiceId: "setup-token",
choiceLabel: "Anthropic setup-token",
choiceHint: "Legacy/manual path; requires Extra Usage in OpenClaw",
assistantPriority: 40,
groupId: "anthropic",
groupLabel: "Anthropic",
groupHint: "Claude CLI + API key + legacy token",
},
run: async (ctx: ProviderAuthContext) => await runAnthropicSetupTokenAuth(ctx),
runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) =>
await runAnthropicSetupTokenNonInteractive(ctx),
},
createProviderApiKeyAuthMethod({
providerId,
methodId: "api-key",

View File

@@ -10,13 +10,22 @@ export function normalizeApiKeyTokenProviderAuthChoice(params: {
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): AuthChoice {
if (params.authChoice !== "apiKey" || !params.tokenProvider) {
if (!params.tokenProvider) {
return params.authChoice;
}
const normalizedTokenProvider = normalizeTokenProviderInput(params.tokenProvider);
if (!normalizedTokenProvider) {
return params.authChoice;
}
if (
(params.authChoice === "token" || params.authChoice === "setup-token") &&
normalizedTokenProvider === "anthropic"
) {
return "setup-token";
}
if (params.authChoice !== "apiKey") {
return params.authChoice;
}
return (
(resolveManifestProviderApiKeyChoice({
providerId: normalizedTokenProvider,

View File

@@ -56,19 +56,20 @@ export async function applyAuthChoice(
}
}
if (
normalizedParams.authChoice === "token" ||
normalizedParams.authChoice === "setup-token" ||
normalizedParams.authChoice === "oauth"
) {
if (normalizedParams.authChoice === "token" || normalizedParams.authChoice === "setup-token") {
throw new Error(
[
`Auth choice "${normalizedParams.authChoice}" is no longer supported for Anthropic setup in OpenClaw.`,
"Existing Anthropic token profiles still run if they are already configured.",
'Use "anthropic-cli" or "apiKey" instead.',
`Auth choice "${normalizedParams.authChoice}" was not matched to a provider setup flow.`,
'For Anthropic legacy token auth, use "setup-token" with tokenProvider="anthropic" or choose the Anthropic setup-token entry explicitly.',
].join("\n"),
);
}
if (normalizedParams.authChoice === "oauth") {
throw new Error(
'Auth choice "oauth" is no longer supported directly. Use "setup-token" for Anthropic legacy token auth or a provider-specific OAuth entry.',
);
}
return { config: normalizedParams.config };
}

View File

@@ -10,7 +10,7 @@ import { MINIMAX_CN_API_BASE_URL } from "../plugin-sdk/minimax.js";
import { ZAI_CODING_CN_BASE_URL, ZAI_CODING_GLOBAL_BASE_URL } from "../plugin-sdk/zai.js";
import { createProviderApiKeyAuthMethod } from "../plugins/provider-api-key-auth.js";
import { providerApiKeyAuthRuntime } from "../plugins/provider-api-key-auth.runtime.js";
import type { ProviderAuthMethod, ProviderPlugin } from "../plugins/types.js";
import type { ProviderAuthMethod, ProviderAuthResult, ProviderPlugin } from "../plugins/types.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { applyAuthChoice, resolvePreferredProviderForAuthChoice } from "./auth-choice.js";
import type { AuthChoice } from "./onboard-types.js";
@@ -51,6 +51,7 @@ vi.mock("./zai-endpoint-detect.js", () => ({
type StoredAuthProfile = {
key?: string;
token?: string;
keyRef?: { source: string; provider: string; id: string };
access?: string;
refresh?: string;
@@ -651,24 +652,58 @@ describe("applyAuthChoice", () => {
resolvePluginProviders.mockReturnValue(createDefaultProviderPlugins());
it("rejects legacy Anthropic token setup aliases", async () => {
it("applies Anthropic setup-token auth when the provider exposes the setup flow", async () => {
await setupTempState();
await expect(
applyAuthChoice({
authChoice: "token",
config: {} as OpenClawConfig,
prompter: createPrompter({}),
runtime: createExitThrowingRuntime(),
setDefaultModel: true,
opts: { tokenProvider: "anthropic" },
resolvePluginProviders.mockReturnValue([
createFixedChoiceProvider({
providerId: "anthropic",
label: "Anthropic",
choiceId: "setup-token",
method: {
id: "setup-token",
label: "Anthropic setup-token",
kind: "token",
run: vi.fn(
async (): Promise<ProviderAuthResult> => ({
profiles: [
{
profileId: "anthropic:default",
credential: {
type: "token",
provider: "anthropic",
token: `sk-ant-oat01-${"a".repeat(80)}`,
},
},
],
defaultModel: "anthropic/claude-sonnet-4-6",
}),
),
},
}),
).rejects.toThrow(
[
'Auth choice "token" is no longer supported for Anthropic setup in OpenClaw.',
"Existing Anthropic token profiles still run if they are already configured.",
'Use "anthropic-cli" or "apiKey" instead.',
].join("\n"),
]);
const result = await applyAuthChoice({
authChoice: "token",
config: {} as OpenClawConfig,
prompter: createPrompter({}),
runtime: createExitThrowingRuntime(),
setDefaultModel: true,
opts: {
tokenProvider: "anthropic",
token: `sk-ant-oat01-${"a".repeat(80)}`,
},
});
expect(result.config.auth?.profiles?.["anthropic:default"]).toMatchObject({
provider: "anthropic",
mode: "token",
});
expect(resolveAgentModelPrimaryValue(result.config.agents?.defaults?.model)).toBe(
"anthropic/claude-sonnet-4-6",
);
expect((await readAuthProfile("anthropic:default"))?.token).toBe(
`sk-ant-oat01-${"a".repeat(80)}`,
);
});

View File

@@ -381,14 +381,27 @@ describe("modelsAuthLoginCommand", () => {
});
});
it("rejects pasted Anthropic token setup", async () => {
it("writes pasted Anthropic setup-tokens and logs the legacy warning", async () => {
const runtime = createRuntime();
mocks.clackText.mockResolvedValue(`sk-ant-oat01-${"a".repeat(80)}`);
await expect(modelsAuthPasteTokenCommand({ provider: "anthropic" }, runtime)).rejects.toThrow(
"Anthropic setup-token auth is no longer available for new setup in OpenClaw.",
await modelsAuthPasteTokenCommand({ provider: "anthropic" }, runtime);
expect(mocks.upsertAuthProfile).toHaveBeenCalledWith({
profileId: "anthropic:manual",
credential: {
type: "token",
provider: "anthropic",
token: `sk-ant-oat01-${"a".repeat(80)}`,
},
agentDir: "/tmp/openclaw/agents/main",
});
expect(runtime.log).toHaveBeenCalledWith(
"Anthropic setup-token auth is a legacy/manual path in OpenClaw.",
);
expect(runtime.log).toHaveBeenCalledWith(
"Anthropic told OpenClaw users this path requires Extra Usage on the Claude account.",
);
expect(mocks.upsertAuthProfile).not.toHaveBeenCalled();
});
it("runs token auth for any token-capable provider plugin", async () => {
@@ -434,9 +447,21 @@ describe("modelsAuthLoginCommand", () => {
});
});
it("rejects setup-token for Anthropic even when explicitly requested", async () => {
it("runs setup-token for Anthropic when the provider exposes the method", async () => {
const runtime = createRuntime();
const runTokenAuth = vi.fn();
const runTokenAuth = vi.fn().mockResolvedValue({
profiles: [
{
profileId: "anthropic:default",
credential: {
type: "token",
provider: "anthropic",
token: `sk-ant-oat01-${"b".repeat(80)}`,
},
},
],
defaultModel: "anthropic/claude-sonnet-4-6",
});
mocks.resolvePluginProviders.mockReturnValue([
{
id: "anthropic",
@@ -452,13 +477,17 @@ describe("modelsAuthLoginCommand", () => {
},
]);
await expect(
modelsAuthSetupTokenCommand({ provider: "anthropic", yes: true }, runtime),
).rejects.toThrow(
"Anthropic setup-token auth is no longer available for new setup in OpenClaw.",
);
await modelsAuthSetupTokenCommand({ provider: "anthropic", yes: true }, runtime);
expect(runTokenAuth).not.toHaveBeenCalled();
expect(mocks.upsertAuthProfile).not.toHaveBeenCalled();
expect(runTokenAuth).toHaveBeenCalledOnce();
expect(mocks.upsertAuthProfile).toHaveBeenCalledWith({
profileId: "anthropic:default",
credential: {
type: "token",
provider: "anthropic",
token: `sk-ant-oat01-${"b".repeat(80)}`,
},
agentDir: "/tmp/openclaw/agents/main",
});
});
});

View File

@@ -33,6 +33,7 @@ import type {
import type { RuntimeEnv } from "../../runtime.js";
import { stylePromptHint, stylePromptMessage } from "../../terminal/prompt-style.js";
import { createClackPrompter } from "../../wizard/clack-prompter.js";
import { validateAnthropicSetupToken } from "../auth-token.js";
import { isRemoteEnvironment } from "../oauth-env.js";
import { createVpsAwareOAuthHandlers } from "../oauth-flow.js";
import { openUrl } from "../onboard-helpers.js";
@@ -81,19 +82,6 @@ function resolveDefaultTokenProfileId(provider: string): string {
return `${normalizeProviderId(provider)}:manual`;
}
function throwIfAnthropicTokenSetupDisabled(provider: string): void {
if (normalizeProviderId(provider) !== "anthropic") {
return;
}
throw new Error(
[
"Anthropic setup-token auth is no longer available for new setup in OpenClaw.",
"Existing Anthropic token profiles still run if they are already configured.",
`Use ${formatCliCommand("openclaw models auth login --provider anthropic --method cli --set-default")} or an Anthropic API key instead.`,
].join("\n"),
);
}
type ResolvedModelsAuthContext = {
config: OpenClawConfig;
agentDir: string;
@@ -334,7 +322,6 @@ export async function modelsAuthSetupTokenCommand(
if (!provider) {
throw new Error("No token-capable provider is available.");
}
throwIfAnthropicTokenSetupDisabled(provider.id);
if (!opts.yes) {
const proceed = await confirm({
@@ -377,14 +364,27 @@ export async function modelsAuthPasteTokenCommand(
throw new Error("Missing --provider.");
}
const provider = normalizeProviderId(rawProvider);
throwIfAnthropicTokenSetupDisabled(provider);
const profileId = opts.profileId?.trim() || resolveDefaultTokenProfileId(provider);
const tokenInput = await text({
message: `Paste token for ${provider}`,
validate: (value) => (value?.trim() ? undefined : "Required"),
validate: (value) => {
const trimmed = value?.trim();
if (!trimmed) {
return "Required";
}
if (provider === "anthropic") {
return validateAnthropicSetupToken(trimmed.replaceAll(/\s+/g, ""));
}
return undefined;
},
});
const token = String(tokenInput ?? "").trim();
const token =
provider === "anthropic"
? String(tokenInput ?? "")
.replaceAll(/\s+/g, "")
.trim()
: String(tokenInput ?? "").trim();
const expires =
opts.expiresIn?.trim() && opts.expiresIn.trim().length > 0
@@ -406,6 +406,12 @@ export async function modelsAuthPasteTokenCommand(
logConfigUpdated(runtime);
runtime.log(`Auth profile: ${profileId} (${provider}/token)`);
if (provider === "anthropic") {
runtime.log("Anthropic setup-token auth is a legacy/manual path in OpenClaw.");
runtime.log(
"Anthropic told OpenClaw users this path requires Extra Usage on the Claude account.",
);
}
}
export async function modelsAuthAddCommand(_opts: Record<string, never>, runtime: RuntimeEnv) {

View File

@@ -331,6 +331,42 @@ vi.mock("./onboard-non-interactive/local/auth-choice.plugin-providers.js", async
};
const choiceMap = new Map<string, ChoiceHandler>([
[
"setup-token",
{
providerId: "anthropic",
label: "Anthropic setup-token",
async runNonInteractive(ctx) {
const token = normalizeText(ctx.opts.token);
if (!token) {
ctx.runtime.error("Anthropic setup-token auth requires --token.");
ctx.runtime.exit(1);
return null;
}
upsertAuthProfile({
profileId: (ctx.opts.tokenProfileId as string | undefined) ?? "anthropic:default",
credential: {
type: "token",
provider: "anthropic",
token,
} as never,
agentDir: ctx.agentDir,
});
const withProfile = providerApiKeyAuthRuntime.applyAuthProfileConfig(
ctx.config as never,
{
profileId: (ctx.opts.tokenProfileId as string | undefined) ?? "anthropic:default",
provider: "anthropic",
mode: "token",
},
);
return providerApiKeyAuthRuntime.applyPrimaryModel(
withProfile,
"anthropic/claude-sonnet-4-6",
);
},
},
],
[
"apiKey",
createApiKeyChoice({
@@ -1059,22 +1095,27 @@ describe("onboard (non-interactive): provider auth", () => {
});
});
it("rejects legacy Anthropic token onboarding", async () => {
it("stores legacy Anthropic setup-token onboarding again when explicitly selected", async () => {
await withOnboardEnv("openclaw-onboard-token-", async ({ configPath, runtime }) => {
const cleanToken = `sk-ant-oat01-${"a".repeat(80)}`;
const token = `${cleanToken.slice(0, 30)}\r${cleanToken.slice(30)}`;
await expect(
runNonInteractiveSetupWithDefaults(runtime, {
authChoice: "token",
tokenProvider: "anthropic",
token,
tokenProfileId: "anthropic:default",
}),
).rejects.toThrow('Auth choice "token" is no longer supported for Anthropic onboarding.');
await runNonInteractiveSetupWithDefaults(runtime, {
authChoice: "token",
tokenProvider: "anthropic",
token,
tokenProfileId: "anthropic:default",
});
await expect(fs.access(configPath)).rejects.toMatchObject({ code: "ENOENT" });
expect(ensureAuthProfileStore().profiles["anthropic:default"]).toBeUndefined();
const cfg = await readJsonFile<ProviderAuthConfigSnapshot>(configPath);
expect(cfg.auth?.profiles?.["anthropic:default"]?.provider).toBe("anthropic");
expect(cfg.auth?.profiles?.["anthropic:default"]?.mode).toBe("token");
expect(cfg.agents?.defaults?.model?.primary).toBe("anthropic/claude-sonnet-4-6");
expect(ensureAuthProfileStore().profiles["anthropic:default"]).toMatchObject({
provider: "anthropic",
type: "token",
token: cleanToken,
});
});
});

View File

@@ -127,30 +127,6 @@ export async function applyNonInteractiveAuthChoice(params: {
return null;
}
if (authChoice === "setup-token") {
runtime.error(
[
'Auth choice "setup-token" is no longer supported for Anthropic onboarding.',
"Existing Anthropic token profiles still run if they are already configured.",
'Use "--auth-choice anthropic-cli" or "--auth-choice apiKey" instead.',
].join("\n"),
);
runtime.exit(1);
return null;
}
if (authChoice === "token") {
runtime.error(
[
'Auth choice "token" is no longer supported for Anthropic onboarding.',
"Existing Anthropic token profiles still run if they are already configured.",
'Use "--auth-choice anthropic-cli" or "--auth-choice apiKey" instead.',
].join("\n"),
);
runtime.exit(1);
return null;
}
const pluginProviderChoice = await applyNonInteractivePluginProviderChoice({
nextConfig,
authChoice,
@@ -169,6 +145,17 @@ export async function applyNonInteractiveAuthChoice(params: {
return pluginProviderChoice;
}
if (authChoice === "setup-token" || authChoice === "token") {
runtime.error(
[
`Auth choice "${params.authChoice}" was not matched to a provider setup flow.`,
'For Anthropic legacy token auth, use "--auth-choice setup-token --token-provider anthropic --token <token>" or pass "--auth-choice token --token-provider anthropic".',
].join("\n"),
);
runtime.exit(1);
return null;
}
const deprecatedChoice = resolveManifestDeprecatedProviderAuthChoice(authChoice as string, {
config: nextConfig,
env: process.env,
@@ -261,11 +248,7 @@ export async function applyNonInteractiveAuthChoice(params: {
) {
runtime.error(
authChoice === "oauth"
? [
'Auth choice "oauth" is no longer supported for Anthropic onboarding.',
"Existing Anthropic token profiles still run if they are already configured.",
'Use "--auth-choice anthropic-cli" or "--auth-choice apiKey" instead.',
].join("\n")
? 'Auth choice "oauth" is no longer supported directly. Use "--auth-choice setup-token --token-provider anthropic" for Anthropic legacy token auth, or a provider-specific OAuth choice.'
: "OAuth requires interactive mode.",
);
runtime.exit(1);