feat: support token auth profiles
This commit is contained in:
@@ -19,7 +19,7 @@ export type AuthProfileHealthStatus =
|
||||
export type AuthProfileHealth = {
|
||||
profileId: string;
|
||||
provider: string;
|
||||
type: "oauth" | "api_key";
|
||||
type: "oauth" | "token" | "api_key";
|
||||
status: AuthProfileHealthStatus;
|
||||
expiresAt?: number;
|
||||
remainingMs?: number;
|
||||
@@ -109,6 +109,39 @@ function buildProfileHealth(params: {
|
||||
};
|
||||
}
|
||||
|
||||
if (credential.type === "token") {
|
||||
const expiresAt =
|
||||
typeof credential.expires === "number" &&
|
||||
Number.isFinite(credential.expires)
|
||||
? credential.expires
|
||||
: undefined;
|
||||
if (!expiresAt || expiresAt <= 0) {
|
||||
return {
|
||||
profileId,
|
||||
provider: credential.provider,
|
||||
type: "token",
|
||||
status: "static",
|
||||
source,
|
||||
label,
|
||||
};
|
||||
}
|
||||
const { status, remainingMs } = resolveOAuthStatus(
|
||||
expiresAt,
|
||||
now,
|
||||
warnAfterMs,
|
||||
);
|
||||
return {
|
||||
profileId,
|
||||
provider: credential.provider,
|
||||
type: "token",
|
||||
status,
|
||||
expiresAt,
|
||||
remainingMs,
|
||||
source,
|
||||
label,
|
||||
};
|
||||
}
|
||||
|
||||
const { status, remainingMs } = resolveOAuthStatus(
|
||||
credential.expires,
|
||||
now,
|
||||
@@ -192,16 +225,18 @@ export function buildAuthHealthSummary(params: {
|
||||
}
|
||||
|
||||
const oauthProfiles = provider.profiles.filter((p) => p.type === "oauth");
|
||||
const tokenProfiles = provider.profiles.filter((p) => p.type === "token");
|
||||
const apiKeyProfiles = provider.profiles.filter(
|
||||
(p) => p.type === "api_key",
|
||||
);
|
||||
|
||||
if (oauthProfiles.length === 0) {
|
||||
const expirable = [...oauthProfiles, ...tokenProfiles];
|
||||
if (expirable.length === 0) {
|
||||
provider.status = apiKeyProfiles.length > 0 ? "static" : "missing";
|
||||
continue;
|
||||
}
|
||||
|
||||
const expiryCandidates = oauthProfiles
|
||||
const expiryCandidates = expirable
|
||||
.map((p) => p.expiresAt)
|
||||
.filter((v): v is number => typeof v === "number" && Number.isFinite(v));
|
||||
if (expiryCandidates.length > 0) {
|
||||
@@ -209,7 +244,7 @@ export function buildAuthHealthSummary(params: {
|
||||
provider.remainingMs = provider.expiresAt - now;
|
||||
}
|
||||
|
||||
const statuses = oauthProfiles.map((p) => p.status);
|
||||
const statuses = expirable.map((p) => p.status);
|
||||
if (statuses.includes("expired") || statuses.includes("missing")) {
|
||||
provider.status = "expired";
|
||||
} else if (statuses.includes("expiring")) {
|
||||
|
||||
@@ -428,7 +428,7 @@ describe("external CLI credential sync", () => {
|
||||
);
|
||||
expect(store.profiles[CLAUDE_CLI_PROFILE_ID]).toBeDefined();
|
||||
expect(
|
||||
(store.profiles[CLAUDE_CLI_PROFILE_ID] as { access: string }).access,
|
||||
(store.profiles[CLAUDE_CLI_PROFILE_ID] as { token: string }).token,
|
||||
).toBe("fresh-access-token");
|
||||
expect(
|
||||
(store.profiles[CLAUDE_CLI_PROFILE_ID] as { expires: number }).expires,
|
||||
@@ -537,7 +537,7 @@ describe("external CLI credential sync", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does not overwrite fresher store OAuth with older Claude CLI credentials", () => {
|
||||
it("does not overwrite fresher store token with older Claude CLI credentials", () => {
|
||||
const agentDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "clawdbot-cli-no-downgrade-"),
|
||||
);
|
||||
@@ -567,10 +567,9 @@ describe("external CLI credential sync", () => {
|
||||
version: 1,
|
||||
profiles: {
|
||||
[CLAUDE_CLI_PROFILE_ID]: {
|
||||
type: "oauth",
|
||||
type: "token",
|
||||
provider: "anthropic",
|
||||
access: "store-access",
|
||||
refresh: "store-refresh",
|
||||
token: "store-access",
|
||||
expires: Date.now() + 60 * 60 * 1000,
|
||||
},
|
||||
},
|
||||
@@ -579,7 +578,7 @@ describe("external CLI credential sync", () => {
|
||||
|
||||
const store = ensureAuthProfileStore(agentDir);
|
||||
expect(
|
||||
(store.profiles[CLAUDE_CLI_PROFILE_ID] as { access: string }).access,
|
||||
(store.profiles[CLAUDE_CLI_PROFILE_ID] as { token: string }).token,
|
||||
).toBe("store-access");
|
||||
} finally {
|
||||
restoreHomeEnv(originalHome);
|
||||
|
||||
@@ -48,13 +48,29 @@ export type ApiKeyCredential = {
|
||||
email?: string;
|
||||
};
|
||||
|
||||
export type TokenCredential = {
|
||||
/**
|
||||
* Static bearer-style token (often OAuth access token / PAT).
|
||||
* Not refreshable by clawdbot (unlike `type: "oauth"`).
|
||||
*/
|
||||
type: "token";
|
||||
provider: string;
|
||||
token: string;
|
||||
/** Optional expiry timestamp (ms since epoch). */
|
||||
expires?: number;
|
||||
email?: string;
|
||||
};
|
||||
|
||||
export type OAuthCredential = OAuthCredentials & {
|
||||
type: "oauth";
|
||||
provider: OAuthProvider;
|
||||
email?: string;
|
||||
};
|
||||
|
||||
export type AuthProfileCredential = ApiKeyCredential | OAuthCredential;
|
||||
export type AuthProfileCredential =
|
||||
| ApiKeyCredential
|
||||
| TokenCredential
|
||||
| OAuthCredential;
|
||||
|
||||
/** Per-profile usage statistics for round-robin and cooldown tracking */
|
||||
export type ProfileUsageStats = {
|
||||
@@ -220,7 +236,13 @@ function coerceLegacyStore(raw: unknown): LegacyAuthStore | null {
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (!value || typeof value !== "object") continue;
|
||||
const typed = value as Partial<AuthProfileCredential>;
|
||||
if (typed.type !== "api_key" && typed.type !== "oauth") continue;
|
||||
if (
|
||||
typed.type !== "api_key" &&
|
||||
typed.type !== "oauth" &&
|
||||
typed.type !== "token"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
entries[key] = {
|
||||
...typed,
|
||||
provider: typed.provider ?? (key as OAuthProvider),
|
||||
@@ -238,7 +260,13 @@ function coerceAuthStore(raw: unknown): AuthProfileStore | null {
|
||||
for (const [key, value] of Object.entries(profiles)) {
|
||||
if (!value || typeof value !== "object") continue;
|
||||
const typed = value as Partial<AuthProfileCredential>;
|
||||
if (typed.type !== "api_key" && typed.type !== "oauth") continue;
|
||||
if (
|
||||
typed.type !== "api_key" &&
|
||||
typed.type !== "oauth" &&
|
||||
typed.type !== "token"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (!typed.provider) continue;
|
||||
normalized[key] = typed as AuthProfileCredential;
|
||||
}
|
||||
@@ -285,7 +313,7 @@ function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
|
||||
*/
|
||||
function readClaudeCliCredentials(options?: {
|
||||
allowKeychainPrompt?: boolean;
|
||||
}): OAuthCredential | null {
|
||||
}): TokenCredential | null {
|
||||
if (process.platform === "darwin" && options?.allowKeychainPrompt !== false) {
|
||||
const keychainCreds = readClaudeCliKeychainCredentials();
|
||||
if (keychainCreds) {
|
||||
@@ -306,18 +334,15 @@ function readClaudeCliCredentials(options?: {
|
||||
if (!claudeOauth || typeof claudeOauth !== "object") return null;
|
||||
|
||||
const accessToken = claudeOauth.accessToken;
|
||||
const refreshToken = claudeOauth.refreshToken;
|
||||
const expiresAt = claudeOauth.expiresAt;
|
||||
|
||||
if (typeof accessToken !== "string" || !accessToken) return null;
|
||||
if (typeof refreshToken !== "string" || !refreshToken) return null;
|
||||
if (typeof expiresAt !== "number" || expiresAt <= 0) return null;
|
||||
|
||||
return {
|
||||
type: "oauth",
|
||||
type: "token",
|
||||
provider: "anthropic",
|
||||
access: accessToken,
|
||||
refresh: refreshToken,
|
||||
token: accessToken,
|
||||
expires: expiresAt,
|
||||
};
|
||||
}
|
||||
@@ -326,7 +351,7 @@ function readClaudeCliCredentials(options?: {
|
||||
* Read Claude Code credentials from macOS keychain.
|
||||
* Uses the `security` CLI to access keychain without native dependencies.
|
||||
*/
|
||||
function readClaudeCliKeychainCredentials(): OAuthCredential | null {
|
||||
function readClaudeCliKeychainCredentials(): TokenCredential | null {
|
||||
try {
|
||||
const result = execSync(
|
||||
'security find-generic-password -s "Claude Code-credentials" -w',
|
||||
@@ -338,18 +363,15 @@ function readClaudeCliKeychainCredentials(): OAuthCredential | null {
|
||||
if (!claudeOauth || typeof claudeOauth !== "object") return null;
|
||||
|
||||
const accessToken = claudeOauth.accessToken;
|
||||
const refreshToken = claudeOauth.refreshToken;
|
||||
const expiresAt = claudeOauth.expiresAt;
|
||||
|
||||
if (typeof accessToken !== "string" || !accessToken) return null;
|
||||
if (typeof refreshToken !== "string" || !refreshToken) return null;
|
||||
if (typeof expiresAt !== "number" || expiresAt <= 0) return null;
|
||||
|
||||
return {
|
||||
type: "oauth",
|
||||
type: "token",
|
||||
provider: "anthropic",
|
||||
access: accessToken,
|
||||
refresh: refreshToken,
|
||||
token: accessToken,
|
||||
expires: expiresAt,
|
||||
};
|
||||
} catch {
|
||||
@@ -416,6 +438,20 @@ function shallowEqualOAuthCredentials(
|
||||
);
|
||||
}
|
||||
|
||||
function shallowEqualTokenCredentials(
|
||||
a: TokenCredential | undefined,
|
||||
b: TokenCredential,
|
||||
): boolean {
|
||||
if (!a) return false;
|
||||
if (a.type !== "token") return false;
|
||||
return (
|
||||
a.provider === b.provider &&
|
||||
a.token === b.token &&
|
||||
a.expires === b.expires &&
|
||||
a.email === b.email
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync OAuth credentials from external CLI tools (Claude CLI, Codex CLI) into the store.
|
||||
* This allows clawdbot to use the same credentials as these tools without requiring
|
||||
@@ -434,25 +470,28 @@ function syncExternalCliCredentials(
|
||||
const claudeCreds = readClaudeCliCredentials(options);
|
||||
if (claudeCreds) {
|
||||
const existing = store.profiles[CLAUDE_CLI_PROFILE_ID];
|
||||
const existingOAuth = existing?.type === "oauth" ? existing : undefined;
|
||||
const existingToken = existing?.type === "token" ? existing : undefined;
|
||||
|
||||
// Update if: no existing profile, existing is not oauth, or CLI has newer/valid token
|
||||
const shouldUpdate =
|
||||
!existingOAuth ||
|
||||
existingOAuth.provider !== "anthropic" ||
|
||||
existingOAuth.expires <= now ||
|
||||
(claudeCreds.expires > now &&
|
||||
claudeCreds.expires > existingOAuth.expires);
|
||||
!existingToken ||
|
||||
existingToken.provider !== "anthropic" ||
|
||||
(existingToken.expires ?? 0) <= now ||
|
||||
((claudeCreds.expires ?? 0) > now &&
|
||||
(claudeCreds.expires ?? 0) > (existingToken.expires ?? 0));
|
||||
|
||||
if (
|
||||
shouldUpdate &&
|
||||
!shallowEqualOAuthCredentials(existingOAuth, claudeCreds)
|
||||
!shallowEqualTokenCredentials(existingToken, claudeCreds)
|
||||
) {
|
||||
store.profiles[CLAUDE_CLI_PROFILE_ID] = claudeCreds;
|
||||
mutated = true;
|
||||
log.info("synced anthropic credentials from claude cli", {
|
||||
profileId: CLAUDE_CLI_PROFILE_ID,
|
||||
expires: new Date(claudeCreds.expires).toISOString(),
|
||||
expires:
|
||||
typeof claudeCreds.expires === "number"
|
||||
? new Date(claudeCreds.expires).toISOString()
|
||||
: "unknown",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -515,6 +554,16 @@ export function loadAuthProfileStore(): AuthProfileStore {
|
||||
key: cred.key,
|
||||
...(cred.email ? { email: cred.email } : {}),
|
||||
};
|
||||
} else if (cred.type === "token") {
|
||||
store.profiles[profileId] = {
|
||||
type: "token",
|
||||
provider: cred.provider ?? (provider as OAuthProvider),
|
||||
token: cred.token,
|
||||
...(typeof cred.expires === "number"
|
||||
? { expires: cred.expires }
|
||||
: {}),
|
||||
...(cred.email ? { email: cred.email } : {}),
|
||||
};
|
||||
} else {
|
||||
store.profiles[profileId] = {
|
||||
type: "oauth",
|
||||
@@ -570,6 +619,16 @@ export function ensureAuthProfileStore(
|
||||
key: cred.key,
|
||||
...(cred.email ? { email: cred.email } : {}),
|
||||
};
|
||||
} else if (cred.type === "token") {
|
||||
store.profiles[profileId] = {
|
||||
type: "token",
|
||||
provider: cred.provider ?? (provider as OAuthProvider),
|
||||
token: cred.token,
|
||||
...(typeof cred.expires === "number"
|
||||
? { expires: cred.expires }
|
||||
: {}),
|
||||
...(cred.email ? { email: cred.email } : {}),
|
||||
};
|
||||
} else {
|
||||
store.profiles[profileId] = {
|
||||
type: "oauth",
|
||||
@@ -882,16 +941,17 @@ function orderProfilesByMode(
|
||||
// Then by lastUsed (oldest first = round-robin within type)
|
||||
const scored = available.map((profileId) => {
|
||||
const type = store.profiles[profileId]?.type;
|
||||
const typeScore = type === "oauth" ? 0 : type === "api_key" ? 1 : 2;
|
||||
const typeScore =
|
||||
type === "oauth" ? 0 : type === "token" ? 1 : type === "api_key" ? 2 : 3;
|
||||
const lastUsed = store.usageStats?.[profileId]?.lastUsed ?? 0;
|
||||
return { profileId, typeScore, lastUsed };
|
||||
});
|
||||
|
||||
// Primary sort: type preference (oauth > api_key).
|
||||
// Primary sort: type preference (oauth > token > api_key).
|
||||
// Secondary sort: lastUsed (oldest first for round-robin within type).
|
||||
const sorted = scored
|
||||
.sort((a, b) => {
|
||||
// First by type (oauth > api_key)
|
||||
// First by type (oauth > token > api_key)
|
||||
if (a.typeScore !== b.typeScore) return a.typeScore - b.typeScore;
|
||||
// Then by lastUsed (oldest first)
|
||||
return a.lastUsed - b.lastUsed;
|
||||
@@ -921,11 +981,27 @@ export async function resolveApiKeyForProfile(params: {
|
||||
if (!cred) return null;
|
||||
const profileConfig = cfg?.auth?.profiles?.[profileId];
|
||||
if (profileConfig && profileConfig.provider !== cred.provider) return null;
|
||||
if (profileConfig && profileConfig.mode !== cred.type) return null;
|
||||
if (profileConfig && profileConfig.mode !== cred.type) {
|
||||
// Compatibility: treat "oauth" config as compatible with stored token profiles.
|
||||
if (!(profileConfig.mode === "oauth" && cred.type === "token")) return null;
|
||||
}
|
||||
|
||||
if (cred.type === "api_key") {
|
||||
return { apiKey: cred.key, provider: cred.provider, email: cred.email };
|
||||
}
|
||||
if (cred.type === "token") {
|
||||
const token = cred.token?.trim();
|
||||
if (!token) return null;
|
||||
if (
|
||||
typeof cred.expires === "number" &&
|
||||
Number.isFinite(cred.expires) &&
|
||||
cred.expires > 0 &&
|
||||
Date.now() >= cred.expires
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { apiKey: token, provider: cred.provider, email: cred.email };
|
||||
}
|
||||
if (Date.now() < cred.expires) {
|
||||
return {
|
||||
apiKey: buildOAuthApiKey(cred.provider, cred),
|
||||
|
||||
@@ -100,7 +100,7 @@ export async function resolveApiKeyForProvider(params: {
|
||||
}
|
||||
|
||||
export type EnvApiKeyResult = { apiKey: string; source: string };
|
||||
export type ModelAuthMode = "api-key" | "oauth" | "mixed" | "unknown";
|
||||
export type ModelAuthMode = "api-key" | "oauth" | "token" | "mixed" | "unknown";
|
||||
|
||||
export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
|
||||
const applied = new Set(getShellEnvAppliedKeys());
|
||||
@@ -158,10 +158,14 @@ export function resolveModelAuthMode(
|
||||
const modes = new Set(
|
||||
profiles
|
||||
.map((id) => authStore.profiles[id]?.type)
|
||||
.filter((mode): mode is "api_key" | "oauth" => Boolean(mode)),
|
||||
.filter((mode): mode is "api_key" | "oauth" | "token" => Boolean(mode)),
|
||||
);
|
||||
if (modes.has("oauth") && modes.has("api_key")) return "mixed";
|
||||
const distinct = ["oauth", "token", "api_key"].filter((k) =>
|
||||
modes.has(k as "oauth" | "token" | "api_key"),
|
||||
);
|
||||
if (distinct.length >= 2) return "mixed";
|
||||
if (modes.has("oauth")) return "oauth";
|
||||
if (modes.has("token")) return "token";
|
||||
if (modes.has("api_key")) return "api-key";
|
||||
}
|
||||
|
||||
|
||||
@@ -236,6 +236,10 @@ function resolveModelAuthLabel(
|
||||
if (profile.type === "oauth") {
|
||||
return `oauth${label ? ` (${label})` : ""}`;
|
||||
}
|
||||
if (profile.type === "token") {
|
||||
const snippet = formatApiKeySnippet(profile.token);
|
||||
return `token ${snippet}${label ? ` (${label})` : ""}`;
|
||||
}
|
||||
const snippet = formatApiKeySnippet(profile.key);
|
||||
return `api-key ${snippet}${label ? ` (${label})` : ""}`;
|
||||
}
|
||||
|
||||
@@ -88,13 +88,18 @@ const resolveAuthLabel = async (
|
||||
!profile ||
|
||||
(configProfile?.provider &&
|
||||
configProfile.provider !== profile.provider) ||
|
||||
(configProfile?.mode && configProfile.mode !== profile.type)
|
||||
(configProfile?.mode &&
|
||||
configProfile.mode !== profile.type &&
|
||||
!(configProfile.mode === "oauth" && profile.type === "token"))
|
||||
) {
|
||||
return `${profileId}=missing`;
|
||||
}
|
||||
if (profile.type === "api_key") {
|
||||
return `${profileId}=${maskApiKey(profile.key)}`;
|
||||
}
|
||||
if (profile.type === "token") {
|
||||
return `${profileId}=token:${maskApiKey(profile.token)}`;
|
||||
}
|
||||
const display = resolveAuthProfileDisplayLabel({
|
||||
cfg,
|
||||
store,
|
||||
|
||||
@@ -61,7 +61,7 @@ export function buildAuthChoiceOptions(params: {
|
||||
}
|
||||
|
||||
const claudeCli = params.store.profiles[CLAUDE_CLI_PROFILE_ID];
|
||||
if (claudeCli?.type === "oauth") {
|
||||
if (claudeCli?.type === "oauth" || claudeCli?.type === "token") {
|
||||
options.push({
|
||||
value: "claude-cli",
|
||||
label: "Anthropic OAuth (Claude CLI)",
|
||||
@@ -75,7 +75,11 @@ export function buildAuthChoiceOptions(params: {
|
||||
});
|
||||
}
|
||||
|
||||
options.push({ value: "oauth", label: "Anthropic OAuth (Claude Pro/Max)" });
|
||||
options.push({
|
||||
value: "oauth",
|
||||
label: "Anthropic token (setup-token)",
|
||||
hint: "Runs `claude setup-token`",
|
||||
});
|
||||
|
||||
options.push({
|
||||
value: "openai-codex",
|
||||
@@ -87,6 +91,11 @@ export function buildAuthChoiceOptions(params: {
|
||||
});
|
||||
options.push({ value: "gemini-api-key", label: "Google Gemini API key" });
|
||||
options.push({ value: "apiKey", label: "Anthropic API key" });
|
||||
options.push({
|
||||
value: "token",
|
||||
label: "Paste token (advanced)",
|
||||
hint: "Stores as a non-refreshable token profile",
|
||||
});
|
||||
options.push({ value: "minimax", label: "Minimax M2.1 (LM Studio)" });
|
||||
if (params.includeSkip) {
|
||||
options.push({ value: "skip", label: "Skip for now" });
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
loginAnthropic,
|
||||
loginOpenAICodex,
|
||||
type OAuthCredentials,
|
||||
type OAuthProvider,
|
||||
@@ -10,6 +9,7 @@ import {
|
||||
CODEX_CLI_PROFILE_ID,
|
||||
ensureAuthProfileStore,
|
||||
listProfilesForProvider,
|
||||
upsertAuthProfile,
|
||||
} from "../agents/auth-profiles.js";
|
||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
|
||||
import {
|
||||
@@ -17,7 +17,11 @@ import {
|
||||
resolveEnvApiKey,
|
||||
} from "../agents/model-auth.js";
|
||||
import { loadModelCatalog } from "../agents/model-catalog.js";
|
||||
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
|
||||
import {
|
||||
normalizeProviderId,
|
||||
resolveConfiguredModelRef,
|
||||
} from "../agents/model-selection.js";
|
||||
import { parseDurationMs } from "../cli/parse-duration.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import type { WizardPrompter } from "../wizard/prompts.js";
|
||||
@@ -134,44 +138,62 @@ export async function applyAuthChoice(params: {
|
||||
|
||||
if (params.authChoice === "oauth") {
|
||||
await params.prompter.note(
|
||||
"Browser will open. Paste the code shown after login (code#state).",
|
||||
"Anthropic OAuth",
|
||||
[
|
||||
"This will run `claude setup-token` to create a long-lived Anthropic token.",
|
||||
"Requires an interactive TTY and a Claude Pro/Max subscription.",
|
||||
].join("\n"),
|
||||
"Anthropic token",
|
||||
);
|
||||
const spin = params.prompter.progress("Waiting for authorization…");
|
||||
let oauthCreds: OAuthCredentials | null = null;
|
||||
try {
|
||||
oauthCreds = await loginAnthropic(
|
||||
async (url) => {
|
||||
await openUrl(url);
|
||||
params.runtime.log(`Open: ${url}`);
|
||||
},
|
||||
async () => {
|
||||
const code = await params.prompter.text({
|
||||
message: "Paste authorization code (code#state)",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
});
|
||||
return String(code);
|
||||
},
|
||||
);
|
||||
spin.stop("OAuth complete");
|
||||
if (oauthCreds) {
|
||||
await writeOAuthCredentials("anthropic", oauthCreds, params.agentDir);
|
||||
const profileId = `anthropic:${oauthCreds.email ?? "default"}`;
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId,
|
||||
provider: "anthropic",
|
||||
mode: "oauth",
|
||||
email: oauthCreds.email ?? undefined,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
spin.stop("OAuth failed");
|
||||
params.runtime.error(String(err));
|
||||
|
||||
if (!process.stdin.isTTY) {
|
||||
await params.prompter.note(
|
||||
"Trouble with OAuth? See https://docs.clawd.bot/start/faq",
|
||||
"OAuth help",
|
||||
"`claude setup-token` requires an interactive TTY.",
|
||||
"Anthropic token",
|
||||
);
|
||||
return { config: nextConfig, agentModelOverride };
|
||||
}
|
||||
|
||||
const proceed = await params.prompter.confirm({
|
||||
message: "Run `claude setup-token` now?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (!proceed) return { config: nextConfig, agentModelOverride };
|
||||
|
||||
const res = await (async () => {
|
||||
const { spawnSync } = await import("node:child_process");
|
||||
return spawnSync("claude", ["setup-token"], { stdio: "inherit" });
|
||||
})();
|
||||
if (res.error) {
|
||||
await params.prompter.note(
|
||||
`Failed to run claude: ${String(res.error)}`,
|
||||
"Anthropic token",
|
||||
);
|
||||
return { config: nextConfig, agentModelOverride };
|
||||
}
|
||||
if (typeof res.status === "number" && res.status !== 0) {
|
||||
await params.prompter.note(
|
||||
`claude setup-token failed (exit ${res.status})`,
|
||||
"Anthropic token",
|
||||
);
|
||||
return { config: nextConfig, agentModelOverride };
|
||||
}
|
||||
|
||||
const store = ensureAuthProfileStore(params.agentDir, {
|
||||
allowKeychainPrompt: true,
|
||||
});
|
||||
if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) {
|
||||
await params.prompter.note(
|
||||
`No Claude CLI credentials found after setup-token. Expected ${CLAUDE_CLI_PROFILE_ID}.`,
|
||||
"Anthropic token",
|
||||
);
|
||||
return { config: nextConfig, agentModelOverride };
|
||||
}
|
||||
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: CLAUDE_CLI_PROFILE_ID,
|
||||
provider: "anthropic",
|
||||
mode: "token",
|
||||
});
|
||||
} else if (params.authChoice === "claude-cli") {
|
||||
const store = ensureAuthProfileStore(params.agentDir, {
|
||||
allowKeychainPrompt: false,
|
||||
@@ -202,18 +224,108 @@ export async function applyAuthChoice(params: {
|
||||
});
|
||||
|
||||
if (!storeWithKeychain.profiles[CLAUDE_CLI_PROFILE_ID]) {
|
||||
await params.prompter.note(
|
||||
process.platform === "darwin"
|
||||
? 'No Claude CLI credentials found in Keychain ("Claude Code-credentials") or ~/.claude/.credentials.json.'
|
||||
: "No Claude CLI credentials found at ~/.claude/.credentials.json.",
|
||||
"Claude CLI OAuth",
|
||||
);
|
||||
return { config: nextConfig, agentModelOverride };
|
||||
if (process.stdin.isTTY) {
|
||||
const runNow = await params.prompter.confirm({
|
||||
message: "Run `claude setup-token` now?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (runNow) {
|
||||
const res = await (async () => {
|
||||
const { spawnSync } = await import("node:child_process");
|
||||
return spawnSync("claude", ["setup-token"], { stdio: "inherit" });
|
||||
})();
|
||||
if (res.error) {
|
||||
await params.prompter.note(
|
||||
`Failed to run claude: ${String(res.error)}`,
|
||||
"Claude setup-token",
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await params.prompter.note(
|
||||
"`claude setup-token` requires an interactive TTY.",
|
||||
"Claude setup-token",
|
||||
);
|
||||
}
|
||||
|
||||
const refreshed = ensureAuthProfileStore(params.agentDir, {
|
||||
allowKeychainPrompt: true,
|
||||
});
|
||||
if (!refreshed.profiles[CLAUDE_CLI_PROFILE_ID]) {
|
||||
await params.prompter.note(
|
||||
process.platform === "darwin"
|
||||
? 'No Claude CLI credentials found in Keychain ("Claude Code-credentials") or ~/.claude/.credentials.json.'
|
||||
: "No Claude CLI credentials found at ~/.claude/.credentials.json.",
|
||||
"Claude CLI OAuth",
|
||||
);
|
||||
return { config: nextConfig, agentModelOverride };
|
||||
}
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: CLAUDE_CLI_PROFILE_ID,
|
||||
provider: "anthropic",
|
||||
mode: "oauth",
|
||||
mode: "token",
|
||||
});
|
||||
} else if (params.authChoice === "token") {
|
||||
const providerRaw = await params.prompter.text({
|
||||
message: "Token provider id (e.g. anthropic)",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
});
|
||||
const provider = normalizeProviderId(String(providerRaw).trim());
|
||||
const defaultProfileId = `${provider}:manual`;
|
||||
|
||||
const profileIdRaw = await params.prompter.text({
|
||||
message: "Auth profile id",
|
||||
initialValue: defaultProfileId,
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
});
|
||||
const profileId = String(profileIdRaw).trim();
|
||||
|
||||
const tokenRaw = await params.prompter.text({
|
||||
message: `Paste token for ${provider}`,
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
});
|
||||
const token = String(tokenRaw).trim();
|
||||
|
||||
const wantsExpiry = await params.prompter.confirm({
|
||||
message: "Does this token expire?",
|
||||
initialValue: false,
|
||||
});
|
||||
const expiresInRaw = wantsExpiry
|
||||
? await params.prompter.text({
|
||||
message: "Expires in (duration)",
|
||||
initialValue: "365d",
|
||||
validate: (value) => {
|
||||
try {
|
||||
parseDurationMs(String(value ?? ""), { defaultUnit: "d" });
|
||||
return undefined;
|
||||
} catch {
|
||||
return "Invalid duration (e.g. 365d, 12h, 30m)";
|
||||
}
|
||||
},
|
||||
})
|
||||
: "";
|
||||
|
||||
const expiresIn = String(expiresInRaw).trim();
|
||||
const expires = expiresIn
|
||||
? Date.now() + parseDurationMs(expiresIn, { defaultUnit: "d" })
|
||||
: undefined;
|
||||
|
||||
upsertAuthProfile({
|
||||
profileId,
|
||||
agentDir: params.agentDir,
|
||||
credential: {
|
||||
type: "token",
|
||||
provider,
|
||||
token,
|
||||
...(expires ? { expires } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId,
|
||||
provider,
|
||||
mode: "token",
|
||||
});
|
||||
} else if (params.authChoice === "openai-codex") {
|
||||
const isRemote = isRemoteEnvironment();
|
||||
|
||||
@@ -86,7 +86,7 @@ export async function noteAuthProfileHealth(params: {
|
||||
const findIssues = () =>
|
||||
summary.profiles.filter(
|
||||
(profile) =>
|
||||
profile.type === "oauth" &&
|
||||
(profile.type === "oauth" || profile.type === "token") &&
|
||||
(profile.status === "expired" ||
|
||||
profile.status === "expiring" ||
|
||||
profile.status === "missing"),
|
||||
@@ -96,13 +96,15 @@ export async function noteAuthProfileHealth(params: {
|
||||
if (issues.length === 0) return;
|
||||
|
||||
const shouldRefresh = await params.prompter.confirmRepair({
|
||||
message: "Refresh expiring OAuth tokens now?",
|
||||
message: "Refresh expiring OAuth tokens now? (static tokens need re-auth)",
|
||||
initialValue: true,
|
||||
});
|
||||
|
||||
if (shouldRefresh) {
|
||||
const refreshTargets = issues.filter((issue) =>
|
||||
["expired", "expiring", "missing"].includes(issue.status),
|
||||
const refreshTargets = issues.filter(
|
||||
(issue) =>
|
||||
issue.type === "oauth" &&
|
||||
["expired", "expiring", "missing"].includes(issue.status),
|
||||
);
|
||||
const errors: string[] = [];
|
||||
for (const profile of refreshTargets) {
|
||||
|
||||
@@ -159,6 +159,7 @@ type ProviderAuthOverview = {
|
||||
profiles: {
|
||||
count: number;
|
||||
oauth: number;
|
||||
token: number;
|
||||
apiKey: number;
|
||||
labels: string[];
|
||||
};
|
||||
@@ -180,6 +181,9 @@ function resolveProviderAuthOverview(params: {
|
||||
if (profile.type === "api_key") {
|
||||
return `${profileId}=${maskApiKey(profile.key)}`;
|
||||
}
|
||||
if (profile.type === "token") {
|
||||
return `${profileId}=token:${maskApiKey(profile.token)}`;
|
||||
}
|
||||
const display = resolveAuthProfileDisplayLabel({ cfg, store, profileId });
|
||||
const suffix =
|
||||
display === profileId
|
||||
@@ -192,6 +196,9 @@ function resolveProviderAuthOverview(params: {
|
||||
const oauthCount = profiles.filter(
|
||||
(id) => store.profiles[id]?.type === "oauth",
|
||||
).length;
|
||||
const tokenCount = profiles.filter(
|
||||
(id) => store.profiles[id]?.type === "token",
|
||||
).length;
|
||||
const apiKeyCount = profiles.filter(
|
||||
(id) => store.profiles[id]?.type === "api_key",
|
||||
).length;
|
||||
@@ -227,6 +234,7 @@ function resolveProviderAuthOverview(params: {
|
||||
profiles: {
|
||||
count: profiles.length,
|
||||
oauth: oauthCount,
|
||||
token: tokenCount,
|
||||
apiKey: apiKeyCount,
|
||||
labels,
|
||||
},
|
||||
@@ -739,11 +747,16 @@ export async function modelsStatusCommand(
|
||||
|
||||
const providersWithOauth = providerAuth
|
||||
.filter(
|
||||
(entry) => entry.profiles.oauth > 0 || entry.env?.value === "OAuth (env)",
|
||||
(entry) =>
|
||||
entry.profiles.oauth > 0 ||
|
||||
entry.profiles.token > 0 ||
|
||||
entry.env?.value === "OAuth (env)",
|
||||
)
|
||||
.map((entry) => {
|
||||
const count =
|
||||
entry.profiles.oauth || (entry.env?.value === "OAuth (env)" ? 1 : 0);
|
||||
entry.profiles.oauth +
|
||||
entry.profiles.token +
|
||||
(entry.env?.value === "OAuth (env)" ? 1 : 0);
|
||||
return `${entry.provider} (${count})`;
|
||||
});
|
||||
|
||||
@@ -754,7 +767,7 @@ export async function modelsStatusCommand(
|
||||
providers,
|
||||
});
|
||||
const oauthProfiles = authHealth.profiles.filter(
|
||||
(profile) => profile.type === "oauth",
|
||||
(profile) => profile.type === "oauth" || profile.type === "token",
|
||||
);
|
||||
|
||||
const checkStatus = (() => {
|
||||
@@ -926,7 +939,7 @@ export async function modelsStatusCommand(
|
||||
);
|
||||
runtime.log(
|
||||
`${label(
|
||||
`Providers w/ OAuth (${providersWithOauth.length || 0})`,
|
||||
`Providers w/ OAuth/tokens (${providersWithOauth.length || 0})`,
|
||||
)}${colorize(rich, theme.muted, ":")} ${colorize(
|
||||
rich,
|
||||
providersWithOauth.length ? theme.info : theme.muted,
|
||||
@@ -953,7 +966,7 @@ export async function modelsStatusCommand(
|
||||
bits.push(
|
||||
formatKeyValue(
|
||||
"profiles",
|
||||
`${entry.profiles.count} (oauth=${entry.profiles.oauth}, api_key=${entry.profiles.apiKey})`,
|
||||
`${entry.profiles.count} (oauth=${entry.profiles.oauth}, token=${entry.profiles.token}, api_key=${entry.profiles.apiKey})`,
|
||||
rich,
|
||||
),
|
||||
);
|
||||
@@ -1003,7 +1016,7 @@ export async function modelsStatusCommand(
|
||||
}
|
||||
|
||||
runtime.log("");
|
||||
runtime.log(colorize(rich, theme.heading, "OAuth status"));
|
||||
runtime.log(colorize(rich, theme.heading, "OAuth/token status"));
|
||||
if (oauthProfiles.length === 0) {
|
||||
runtime.log(colorize(rich, theme.muted, "- none"));
|
||||
return;
|
||||
@@ -1011,6 +1024,7 @@ export async function modelsStatusCommand(
|
||||
|
||||
const formatStatus = (status: string) => {
|
||||
if (status === "ok") return colorize(rich, theme.success, "ok");
|
||||
if (status === "static") return colorize(rich, theme.muted, "static");
|
||||
if (status === "expiring") return colorize(rich, theme.warn, "expiring");
|
||||
if (status === "missing") return colorize(rich, theme.warn, "unknown");
|
||||
return colorize(rich, theme.error, "expired");
|
||||
@@ -1020,9 +1034,12 @@ export async function modelsStatusCommand(
|
||||
const labelText = profile.label || profile.profileId;
|
||||
const label = colorize(rich, theme.accent, labelText);
|
||||
const status = formatStatus(profile.status);
|
||||
const expiry = profile.expiresAt
|
||||
? ` expires in ${formatRemainingShort(profile.remainingMs)}`
|
||||
: " expires unknown";
|
||||
const expiry =
|
||||
profile.status === "static"
|
||||
? ""
|
||||
: profile.expiresAt
|
||||
? ` expires in ${formatRemainingShort(profile.remainingMs)}`
|
||||
: " expires unknown";
|
||||
const source =
|
||||
profile.source !== "store"
|
||||
? colorize(rich, theme.muted, ` (${profile.source})`)
|
||||
|
||||
@@ -52,7 +52,7 @@ describe("writeOAuthCredentials", () => {
|
||||
expires: Date.now() + 60_000,
|
||||
} satisfies OAuthCredentials;
|
||||
|
||||
await writeOAuthCredentials("anthropic", creds);
|
||||
await writeOAuthCredentials("openai-codex", creds);
|
||||
|
||||
// Now writes to the multi-agent path: agents/main/agent
|
||||
const authProfilePath = path.join(
|
||||
@@ -66,7 +66,7 @@ describe("writeOAuthCredentials", () => {
|
||||
const parsed = JSON.parse(raw) as {
|
||||
profiles?: Record<string, OAuthCredentials & { type?: string }>;
|
||||
};
|
||||
expect(parsed.profiles?.["anthropic:default"]).toMatchObject({
|
||||
expect(parsed.profiles?.["openai-codex:default"]).toMatchObject({
|
||||
refresh: "refresh-token",
|
||||
access: "access-token",
|
||||
type: "oauth",
|
||||
|
||||
@@ -51,7 +51,7 @@ export function applyAuthProfileConfig(
|
||||
params: {
|
||||
profileId: string;
|
||||
provider: string;
|
||||
mode: "api_key" | "oauth";
|
||||
mode: "api_key" | "oauth" | "token";
|
||||
email?: string;
|
||||
preferProfileFirst?: boolean;
|
||||
},
|
||||
|
||||
@@ -151,7 +151,7 @@ export async function runNonInteractiveOnboarding(
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: CLAUDE_CLI_PROFILE_ID,
|
||||
provider: "anthropic",
|
||||
mode: "oauth",
|
||||
mode: "token",
|
||||
});
|
||||
} else if (authChoice === "codex-cli") {
|
||||
const store = ensureAuthProfileStore();
|
||||
@@ -169,17 +169,18 @@ export async function runNonInteractiveOnboarding(
|
||||
} else if (authChoice === "minimax") {
|
||||
nextConfig = applyMinimaxConfig(nextConfig);
|
||||
} else if (
|
||||
authChoice === "token" ||
|
||||
authChoice === "oauth" ||
|
||||
authChoice === "openai-codex" ||
|
||||
authChoice === "antigravity"
|
||||
) {
|
||||
runtime.error(
|
||||
`${
|
||||
authChoice === "oauth" || authChoice === "openai-codex"
|
||||
? "OAuth"
|
||||
: "Antigravity"
|
||||
} requires interactive mode.`,
|
||||
);
|
||||
const label =
|
||||
authChoice === "antigravity"
|
||||
? "Antigravity"
|
||||
: authChoice === "token"
|
||||
? "Token"
|
||||
: "OAuth";
|
||||
runtime.error(`${label} requires interactive mode.`);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export type OnboardMode = "local" | "remote";
|
||||
export type AuthChoice =
|
||||
| "oauth"
|
||||
| "claude-cli"
|
||||
| "token"
|
||||
| "openai-codex"
|
||||
| "codex-cli"
|
||||
| "antigravity"
|
||||
|
||||
@@ -985,7 +985,13 @@ export type ModelsConfig = {
|
||||
|
||||
export type AuthProfileConfig = {
|
||||
provider: string;
|
||||
mode: "api_key" | "oauth";
|
||||
/**
|
||||
* Credential type expected in auth-profiles.json for this profile id.
|
||||
* - api_key: static provider API key
|
||||
* - oauth: refreshable OAuth credentials (access+refresh+expires)
|
||||
* - token: static bearer-style token (optionally expiring; no refresh)
|
||||
*/
|
||||
mode: "api_key" | "oauth" | "token";
|
||||
email?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -895,7 +895,11 @@ export const ClawdbotSchema = z.object({
|
||||
z.string(),
|
||||
z.object({
|
||||
provider: z.string(),
|
||||
mode: z.union([z.literal("api_key"), z.literal("oauth")]),
|
||||
mode: z.union([
|
||||
z.literal("api_key"),
|
||||
z.literal("oauth"),
|
||||
z.literal("token"),
|
||||
]),
|
||||
email: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user