feat: support token auth profiles
This commit is contained in:
@@ -19,7 +19,7 @@ export type AuthProfileHealthStatus =
|
|||||||
export type AuthProfileHealth = {
|
export type AuthProfileHealth = {
|
||||||
profileId: string;
|
profileId: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
type: "oauth" | "api_key";
|
type: "oauth" | "token" | "api_key";
|
||||||
status: AuthProfileHealthStatus;
|
status: AuthProfileHealthStatus;
|
||||||
expiresAt?: number;
|
expiresAt?: number;
|
||||||
remainingMs?: 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(
|
const { status, remainingMs } = resolveOAuthStatus(
|
||||||
credential.expires,
|
credential.expires,
|
||||||
now,
|
now,
|
||||||
@@ -192,16 +225,18 @@ export function buildAuthHealthSummary(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const oauthProfiles = provider.profiles.filter((p) => p.type === "oauth");
|
const oauthProfiles = provider.profiles.filter((p) => p.type === "oauth");
|
||||||
|
const tokenProfiles = provider.profiles.filter((p) => p.type === "token");
|
||||||
const apiKeyProfiles = provider.profiles.filter(
|
const apiKeyProfiles = provider.profiles.filter(
|
||||||
(p) => p.type === "api_key",
|
(p) => p.type === "api_key",
|
||||||
);
|
);
|
||||||
|
|
||||||
if (oauthProfiles.length === 0) {
|
const expirable = [...oauthProfiles, ...tokenProfiles];
|
||||||
|
if (expirable.length === 0) {
|
||||||
provider.status = apiKeyProfiles.length > 0 ? "static" : "missing";
|
provider.status = apiKeyProfiles.length > 0 ? "static" : "missing";
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const expiryCandidates = oauthProfiles
|
const expiryCandidates = expirable
|
||||||
.map((p) => p.expiresAt)
|
.map((p) => p.expiresAt)
|
||||||
.filter((v): v is number => typeof v === "number" && Number.isFinite(v));
|
.filter((v): v is number => typeof v === "number" && Number.isFinite(v));
|
||||||
if (expiryCandidates.length > 0) {
|
if (expiryCandidates.length > 0) {
|
||||||
@@ -209,7 +244,7 @@ export function buildAuthHealthSummary(params: {
|
|||||||
provider.remainingMs = provider.expiresAt - now;
|
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")) {
|
if (statuses.includes("expired") || statuses.includes("missing")) {
|
||||||
provider.status = "expired";
|
provider.status = "expired";
|
||||||
} else if (statuses.includes("expiring")) {
|
} 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]).toBeDefined();
|
||||||
expect(
|
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");
|
).toBe("fresh-access-token");
|
||||||
expect(
|
expect(
|
||||||
(store.profiles[CLAUDE_CLI_PROFILE_ID] as { expires: number }).expires,
|
(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(
|
const agentDir = fs.mkdtempSync(
|
||||||
path.join(os.tmpdir(), "clawdbot-cli-no-downgrade-"),
|
path.join(os.tmpdir(), "clawdbot-cli-no-downgrade-"),
|
||||||
);
|
);
|
||||||
@@ -567,10 +567,9 @@ describe("external CLI credential sync", () => {
|
|||||||
version: 1,
|
version: 1,
|
||||||
profiles: {
|
profiles: {
|
||||||
[CLAUDE_CLI_PROFILE_ID]: {
|
[CLAUDE_CLI_PROFILE_ID]: {
|
||||||
type: "oauth",
|
type: "token",
|
||||||
provider: "anthropic",
|
provider: "anthropic",
|
||||||
access: "store-access",
|
token: "store-access",
|
||||||
refresh: "store-refresh",
|
|
||||||
expires: Date.now() + 60 * 60 * 1000,
|
expires: Date.now() + 60 * 60 * 1000,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -579,7 +578,7 @@ describe("external CLI credential sync", () => {
|
|||||||
|
|
||||||
const store = ensureAuthProfileStore(agentDir);
|
const store = ensureAuthProfileStore(agentDir);
|
||||||
expect(
|
expect(
|
||||||
(store.profiles[CLAUDE_CLI_PROFILE_ID] as { access: string }).access,
|
(store.profiles[CLAUDE_CLI_PROFILE_ID] as { token: string }).token,
|
||||||
).toBe("store-access");
|
).toBe("store-access");
|
||||||
} finally {
|
} finally {
|
||||||
restoreHomeEnv(originalHome);
|
restoreHomeEnv(originalHome);
|
||||||
|
|||||||
@@ -48,13 +48,29 @@ export type ApiKeyCredential = {
|
|||||||
email?: string;
|
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 & {
|
export type OAuthCredential = OAuthCredentials & {
|
||||||
type: "oauth";
|
type: "oauth";
|
||||||
provider: OAuthProvider;
|
provider: OAuthProvider;
|
||||||
email?: string;
|
email?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AuthProfileCredential = ApiKeyCredential | OAuthCredential;
|
export type AuthProfileCredential =
|
||||||
|
| ApiKeyCredential
|
||||||
|
| TokenCredential
|
||||||
|
| OAuthCredential;
|
||||||
|
|
||||||
/** Per-profile usage statistics for round-robin and cooldown tracking */
|
/** Per-profile usage statistics for round-robin and cooldown tracking */
|
||||||
export type ProfileUsageStats = {
|
export type ProfileUsageStats = {
|
||||||
@@ -220,7 +236,13 @@ function coerceLegacyStore(raw: unknown): LegacyAuthStore | null {
|
|||||||
for (const [key, value] of Object.entries(record)) {
|
for (const [key, value] of Object.entries(record)) {
|
||||||
if (!value || typeof value !== "object") continue;
|
if (!value || typeof value !== "object") continue;
|
||||||
const typed = value as Partial<AuthProfileCredential>;
|
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] = {
|
entries[key] = {
|
||||||
...typed,
|
...typed,
|
||||||
provider: typed.provider ?? (key as OAuthProvider),
|
provider: typed.provider ?? (key as OAuthProvider),
|
||||||
@@ -238,7 +260,13 @@ function coerceAuthStore(raw: unknown): AuthProfileStore | null {
|
|||||||
for (const [key, value] of Object.entries(profiles)) {
|
for (const [key, value] of Object.entries(profiles)) {
|
||||||
if (!value || typeof value !== "object") continue;
|
if (!value || typeof value !== "object") continue;
|
||||||
const typed = value as Partial<AuthProfileCredential>;
|
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;
|
if (!typed.provider) continue;
|
||||||
normalized[key] = typed as AuthProfileCredential;
|
normalized[key] = typed as AuthProfileCredential;
|
||||||
}
|
}
|
||||||
@@ -285,7 +313,7 @@ function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
|
|||||||
*/
|
*/
|
||||||
function readClaudeCliCredentials(options?: {
|
function readClaudeCliCredentials(options?: {
|
||||||
allowKeychainPrompt?: boolean;
|
allowKeychainPrompt?: boolean;
|
||||||
}): OAuthCredential | null {
|
}): TokenCredential | null {
|
||||||
if (process.platform === "darwin" && options?.allowKeychainPrompt !== false) {
|
if (process.platform === "darwin" && options?.allowKeychainPrompt !== false) {
|
||||||
const keychainCreds = readClaudeCliKeychainCredentials();
|
const keychainCreds = readClaudeCliKeychainCredentials();
|
||||||
if (keychainCreds) {
|
if (keychainCreds) {
|
||||||
@@ -306,18 +334,15 @@ function readClaudeCliCredentials(options?: {
|
|||||||
if (!claudeOauth || typeof claudeOauth !== "object") return null;
|
if (!claudeOauth || typeof claudeOauth !== "object") return null;
|
||||||
|
|
||||||
const accessToken = claudeOauth.accessToken;
|
const accessToken = claudeOauth.accessToken;
|
||||||
const refreshToken = claudeOauth.refreshToken;
|
|
||||||
const expiresAt = claudeOauth.expiresAt;
|
const expiresAt = claudeOauth.expiresAt;
|
||||||
|
|
||||||
if (typeof accessToken !== "string" || !accessToken) return null;
|
if (typeof accessToken !== "string" || !accessToken) return null;
|
||||||
if (typeof refreshToken !== "string" || !refreshToken) return null;
|
|
||||||
if (typeof expiresAt !== "number" || expiresAt <= 0) return null;
|
if (typeof expiresAt !== "number" || expiresAt <= 0) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: "oauth",
|
type: "token",
|
||||||
provider: "anthropic",
|
provider: "anthropic",
|
||||||
access: accessToken,
|
token: accessToken,
|
||||||
refresh: refreshToken,
|
|
||||||
expires: expiresAt,
|
expires: expiresAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -326,7 +351,7 @@ function readClaudeCliCredentials(options?: {
|
|||||||
* Read Claude Code credentials from macOS keychain.
|
* Read Claude Code credentials from macOS keychain.
|
||||||
* Uses the `security` CLI to access keychain without native dependencies.
|
* Uses the `security` CLI to access keychain without native dependencies.
|
||||||
*/
|
*/
|
||||||
function readClaudeCliKeychainCredentials(): OAuthCredential | null {
|
function readClaudeCliKeychainCredentials(): TokenCredential | null {
|
||||||
try {
|
try {
|
||||||
const result = execSync(
|
const result = execSync(
|
||||||
'security find-generic-password -s "Claude Code-credentials" -w',
|
'security find-generic-password -s "Claude Code-credentials" -w',
|
||||||
@@ -338,18 +363,15 @@ function readClaudeCliKeychainCredentials(): OAuthCredential | null {
|
|||||||
if (!claudeOauth || typeof claudeOauth !== "object") return null;
|
if (!claudeOauth || typeof claudeOauth !== "object") return null;
|
||||||
|
|
||||||
const accessToken = claudeOauth.accessToken;
|
const accessToken = claudeOauth.accessToken;
|
||||||
const refreshToken = claudeOauth.refreshToken;
|
|
||||||
const expiresAt = claudeOauth.expiresAt;
|
const expiresAt = claudeOauth.expiresAt;
|
||||||
|
|
||||||
if (typeof accessToken !== "string" || !accessToken) return null;
|
if (typeof accessToken !== "string" || !accessToken) return null;
|
||||||
if (typeof refreshToken !== "string" || !refreshToken) return null;
|
|
||||||
if (typeof expiresAt !== "number" || expiresAt <= 0) return null;
|
if (typeof expiresAt !== "number" || expiresAt <= 0) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: "oauth",
|
type: "token",
|
||||||
provider: "anthropic",
|
provider: "anthropic",
|
||||||
access: accessToken,
|
token: accessToken,
|
||||||
refresh: refreshToken,
|
|
||||||
expires: expiresAt,
|
expires: expiresAt,
|
||||||
};
|
};
|
||||||
} catch {
|
} 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.
|
* 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
|
* This allows clawdbot to use the same credentials as these tools without requiring
|
||||||
@@ -434,25 +470,28 @@ function syncExternalCliCredentials(
|
|||||||
const claudeCreds = readClaudeCliCredentials(options);
|
const claudeCreds = readClaudeCliCredentials(options);
|
||||||
if (claudeCreds) {
|
if (claudeCreds) {
|
||||||
const existing = store.profiles[CLAUDE_CLI_PROFILE_ID];
|
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
|
// Update if: no existing profile, existing is not oauth, or CLI has newer/valid token
|
||||||
const shouldUpdate =
|
const shouldUpdate =
|
||||||
!existingOAuth ||
|
!existingToken ||
|
||||||
existingOAuth.provider !== "anthropic" ||
|
existingToken.provider !== "anthropic" ||
|
||||||
existingOAuth.expires <= now ||
|
(existingToken.expires ?? 0) <= now ||
|
||||||
(claudeCreds.expires > now &&
|
((claudeCreds.expires ?? 0) > now &&
|
||||||
claudeCreds.expires > existingOAuth.expires);
|
(claudeCreds.expires ?? 0) > (existingToken.expires ?? 0));
|
||||||
|
|
||||||
if (
|
if (
|
||||||
shouldUpdate &&
|
shouldUpdate &&
|
||||||
!shallowEqualOAuthCredentials(existingOAuth, claudeCreds)
|
!shallowEqualTokenCredentials(existingToken, claudeCreds)
|
||||||
) {
|
) {
|
||||||
store.profiles[CLAUDE_CLI_PROFILE_ID] = claudeCreds;
|
store.profiles[CLAUDE_CLI_PROFILE_ID] = claudeCreds;
|
||||||
mutated = true;
|
mutated = true;
|
||||||
log.info("synced anthropic credentials from claude cli", {
|
log.info("synced anthropic credentials from claude cli", {
|
||||||
profileId: CLAUDE_CLI_PROFILE_ID,
|
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,
|
key: cred.key,
|
||||||
...(cred.email ? { email: cred.email } : {}),
|
...(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 {
|
} else {
|
||||||
store.profiles[profileId] = {
|
store.profiles[profileId] = {
|
||||||
type: "oauth",
|
type: "oauth",
|
||||||
@@ -570,6 +619,16 @@ export function ensureAuthProfileStore(
|
|||||||
key: cred.key,
|
key: cred.key,
|
||||||
...(cred.email ? { email: cred.email } : {}),
|
...(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 {
|
} else {
|
||||||
store.profiles[profileId] = {
|
store.profiles[profileId] = {
|
||||||
type: "oauth",
|
type: "oauth",
|
||||||
@@ -882,16 +941,17 @@ function orderProfilesByMode(
|
|||||||
// Then by lastUsed (oldest first = round-robin within type)
|
// Then by lastUsed (oldest first = round-robin within type)
|
||||||
const scored = available.map((profileId) => {
|
const scored = available.map((profileId) => {
|
||||||
const type = store.profiles[profileId]?.type;
|
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;
|
const lastUsed = store.usageStats?.[profileId]?.lastUsed ?? 0;
|
||||||
return { profileId, typeScore, lastUsed };
|
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).
|
// Secondary sort: lastUsed (oldest first for round-robin within type).
|
||||||
const sorted = scored
|
const sorted = scored
|
||||||
.sort((a, b) => {
|
.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;
|
if (a.typeScore !== b.typeScore) return a.typeScore - b.typeScore;
|
||||||
// Then by lastUsed (oldest first)
|
// Then by lastUsed (oldest first)
|
||||||
return a.lastUsed - b.lastUsed;
|
return a.lastUsed - b.lastUsed;
|
||||||
@@ -921,11 +981,27 @@ export async function resolveApiKeyForProfile(params: {
|
|||||||
if (!cred) return null;
|
if (!cred) return null;
|
||||||
const profileConfig = cfg?.auth?.profiles?.[profileId];
|
const profileConfig = cfg?.auth?.profiles?.[profileId];
|
||||||
if (profileConfig && profileConfig.provider !== cred.provider) return null;
|
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") {
|
if (cred.type === "api_key") {
|
||||||
return { apiKey: cred.key, provider: cred.provider, email: cred.email };
|
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) {
|
if (Date.now() < cred.expires) {
|
||||||
return {
|
return {
|
||||||
apiKey: buildOAuthApiKey(cred.provider, cred),
|
apiKey: buildOAuthApiKey(cred.provider, cred),
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export async function resolveApiKeyForProvider(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type EnvApiKeyResult = { apiKey: string; source: string };
|
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 {
|
export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
|
||||||
const applied = new Set(getShellEnvAppliedKeys());
|
const applied = new Set(getShellEnvAppliedKeys());
|
||||||
@@ -158,10 +158,14 @@ export function resolveModelAuthMode(
|
|||||||
const modes = new Set(
|
const modes = new Set(
|
||||||
profiles
|
profiles
|
||||||
.map((id) => authStore.profiles[id]?.type)
|
.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("oauth")) return "oauth";
|
||||||
|
if (modes.has("token")) return "token";
|
||||||
if (modes.has("api_key")) return "api-key";
|
if (modes.has("api_key")) return "api-key";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -236,6 +236,10 @@ function resolveModelAuthLabel(
|
|||||||
if (profile.type === "oauth") {
|
if (profile.type === "oauth") {
|
||||||
return `oauth${label ? ` (${label})` : ""}`;
|
return `oauth${label ? ` (${label})` : ""}`;
|
||||||
}
|
}
|
||||||
|
if (profile.type === "token") {
|
||||||
|
const snippet = formatApiKeySnippet(profile.token);
|
||||||
|
return `token ${snippet}${label ? ` (${label})` : ""}`;
|
||||||
|
}
|
||||||
const snippet = formatApiKeySnippet(profile.key);
|
const snippet = formatApiKeySnippet(profile.key);
|
||||||
return `api-key ${snippet}${label ? ` (${label})` : ""}`;
|
return `api-key ${snippet}${label ? ` (${label})` : ""}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,13 +88,18 @@ const resolveAuthLabel = async (
|
|||||||
!profile ||
|
!profile ||
|
||||||
(configProfile?.provider &&
|
(configProfile?.provider &&
|
||||||
configProfile.provider !== profile.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`;
|
return `${profileId}=missing`;
|
||||||
}
|
}
|
||||||
if (profile.type === "api_key") {
|
if (profile.type === "api_key") {
|
||||||
return `${profileId}=${maskApiKey(profile.key)}`;
|
return `${profileId}=${maskApiKey(profile.key)}`;
|
||||||
}
|
}
|
||||||
|
if (profile.type === "token") {
|
||||||
|
return `${profileId}=token:${maskApiKey(profile.token)}`;
|
||||||
|
}
|
||||||
const display = resolveAuthProfileDisplayLabel({
|
const display = resolveAuthProfileDisplayLabel({
|
||||||
cfg,
|
cfg,
|
||||||
store,
|
store,
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export function buildAuthChoiceOptions(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const claudeCli = params.store.profiles[CLAUDE_CLI_PROFILE_ID];
|
const claudeCli = params.store.profiles[CLAUDE_CLI_PROFILE_ID];
|
||||||
if (claudeCli?.type === "oauth") {
|
if (claudeCli?.type === "oauth" || claudeCli?.type === "token") {
|
||||||
options.push({
|
options.push({
|
||||||
value: "claude-cli",
|
value: "claude-cli",
|
||||||
label: "Anthropic OAuth (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({
|
options.push({
|
||||||
value: "openai-codex",
|
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: "gemini-api-key", label: "Google Gemini API key" });
|
||||||
options.push({ value: "apiKey", label: "Anthropic 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)" });
|
options.push({ value: "minimax", label: "Minimax M2.1 (LM Studio)" });
|
||||||
if (params.includeSkip) {
|
if (params.includeSkip) {
|
||||||
options.push({ value: "skip", label: "Skip for now" });
|
options.push({ value: "skip", label: "Skip for now" });
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
loginAnthropic,
|
|
||||||
loginOpenAICodex,
|
loginOpenAICodex,
|
||||||
type OAuthCredentials,
|
type OAuthCredentials,
|
||||||
type OAuthProvider,
|
type OAuthProvider,
|
||||||
@@ -10,6 +9,7 @@ import {
|
|||||||
CODEX_CLI_PROFILE_ID,
|
CODEX_CLI_PROFILE_ID,
|
||||||
ensureAuthProfileStore,
|
ensureAuthProfileStore,
|
||||||
listProfilesForProvider,
|
listProfilesForProvider,
|
||||||
|
upsertAuthProfile,
|
||||||
} from "../agents/auth-profiles.js";
|
} from "../agents/auth-profiles.js";
|
||||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
|
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
|
||||||
import {
|
import {
|
||||||
@@ -17,7 +17,11 @@ import {
|
|||||||
resolveEnvApiKey,
|
resolveEnvApiKey,
|
||||||
} from "../agents/model-auth.js";
|
} from "../agents/model-auth.js";
|
||||||
import { loadModelCatalog } from "../agents/model-catalog.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 { ClawdbotConfig } from "../config/config.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import type { WizardPrompter } from "../wizard/prompts.js";
|
import type { WizardPrompter } from "../wizard/prompts.js";
|
||||||
@@ -134,44 +138,62 @@ export async function applyAuthChoice(params: {
|
|||||||
|
|
||||||
if (params.authChoice === "oauth") {
|
if (params.authChoice === "oauth") {
|
||||||
await params.prompter.note(
|
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;
|
if (!process.stdin.isTTY) {
|
||||||
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));
|
|
||||||
await params.prompter.note(
|
await params.prompter.note(
|
||||||
"Trouble with OAuth? See https://docs.clawd.bot/start/faq",
|
"`claude setup-token` requires an interactive TTY.",
|
||||||
"OAuth help",
|
"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") {
|
} else if (params.authChoice === "claude-cli") {
|
||||||
const store = ensureAuthProfileStore(params.agentDir, {
|
const store = ensureAuthProfileStore(params.agentDir, {
|
||||||
allowKeychainPrompt: false,
|
allowKeychainPrompt: false,
|
||||||
@@ -202,18 +224,108 @@ export async function applyAuthChoice(params: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!storeWithKeychain.profiles[CLAUDE_CLI_PROFILE_ID]) {
|
if (!storeWithKeychain.profiles[CLAUDE_CLI_PROFILE_ID]) {
|
||||||
await params.prompter.note(
|
if (process.stdin.isTTY) {
|
||||||
process.platform === "darwin"
|
const runNow = await params.prompter.confirm({
|
||||||
? 'No Claude CLI credentials found in Keychain ("Claude Code-credentials") or ~/.claude/.credentials.json.'
|
message: "Run `claude setup-token` now?",
|
||||||
: "No Claude CLI credentials found at ~/.claude/.credentials.json.",
|
initialValue: true,
|
||||||
"Claude CLI OAuth",
|
});
|
||||||
);
|
if (runNow) {
|
||||||
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)}`,
|
||||||
|
"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, {
|
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||||
profileId: CLAUDE_CLI_PROFILE_ID,
|
profileId: CLAUDE_CLI_PROFILE_ID,
|
||||||
provider: "anthropic",
|
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") {
|
} else if (params.authChoice === "openai-codex") {
|
||||||
const isRemote = isRemoteEnvironment();
|
const isRemote = isRemoteEnvironment();
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export async function noteAuthProfileHealth(params: {
|
|||||||
const findIssues = () =>
|
const findIssues = () =>
|
||||||
summary.profiles.filter(
|
summary.profiles.filter(
|
||||||
(profile) =>
|
(profile) =>
|
||||||
profile.type === "oauth" &&
|
(profile.type === "oauth" || profile.type === "token") &&
|
||||||
(profile.status === "expired" ||
|
(profile.status === "expired" ||
|
||||||
profile.status === "expiring" ||
|
profile.status === "expiring" ||
|
||||||
profile.status === "missing"),
|
profile.status === "missing"),
|
||||||
@@ -96,13 +96,15 @@ export async function noteAuthProfileHealth(params: {
|
|||||||
if (issues.length === 0) return;
|
if (issues.length === 0) return;
|
||||||
|
|
||||||
const shouldRefresh = await params.prompter.confirmRepair({
|
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,
|
initialValue: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (shouldRefresh) {
|
if (shouldRefresh) {
|
||||||
const refreshTargets = issues.filter((issue) =>
|
const refreshTargets = issues.filter(
|
||||||
["expired", "expiring", "missing"].includes(issue.status),
|
(issue) =>
|
||||||
|
issue.type === "oauth" &&
|
||||||
|
["expired", "expiring", "missing"].includes(issue.status),
|
||||||
);
|
);
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
for (const profile of refreshTargets) {
|
for (const profile of refreshTargets) {
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ type ProviderAuthOverview = {
|
|||||||
profiles: {
|
profiles: {
|
||||||
count: number;
|
count: number;
|
||||||
oauth: number;
|
oauth: number;
|
||||||
|
token: number;
|
||||||
apiKey: number;
|
apiKey: number;
|
||||||
labels: string[];
|
labels: string[];
|
||||||
};
|
};
|
||||||
@@ -180,6 +181,9 @@ function resolveProviderAuthOverview(params: {
|
|||||||
if (profile.type === "api_key") {
|
if (profile.type === "api_key") {
|
||||||
return `${profileId}=${maskApiKey(profile.key)}`;
|
return `${profileId}=${maskApiKey(profile.key)}`;
|
||||||
}
|
}
|
||||||
|
if (profile.type === "token") {
|
||||||
|
return `${profileId}=token:${maskApiKey(profile.token)}`;
|
||||||
|
}
|
||||||
const display = resolveAuthProfileDisplayLabel({ cfg, store, profileId });
|
const display = resolveAuthProfileDisplayLabel({ cfg, store, profileId });
|
||||||
const suffix =
|
const suffix =
|
||||||
display === profileId
|
display === profileId
|
||||||
@@ -192,6 +196,9 @@ function resolveProviderAuthOverview(params: {
|
|||||||
const oauthCount = profiles.filter(
|
const oauthCount = profiles.filter(
|
||||||
(id) => store.profiles[id]?.type === "oauth",
|
(id) => store.profiles[id]?.type === "oauth",
|
||||||
).length;
|
).length;
|
||||||
|
const tokenCount = profiles.filter(
|
||||||
|
(id) => store.profiles[id]?.type === "token",
|
||||||
|
).length;
|
||||||
const apiKeyCount = profiles.filter(
|
const apiKeyCount = profiles.filter(
|
||||||
(id) => store.profiles[id]?.type === "api_key",
|
(id) => store.profiles[id]?.type === "api_key",
|
||||||
).length;
|
).length;
|
||||||
@@ -227,6 +234,7 @@ function resolveProviderAuthOverview(params: {
|
|||||||
profiles: {
|
profiles: {
|
||||||
count: profiles.length,
|
count: profiles.length,
|
||||||
oauth: oauthCount,
|
oauth: oauthCount,
|
||||||
|
token: tokenCount,
|
||||||
apiKey: apiKeyCount,
|
apiKey: apiKeyCount,
|
||||||
labels,
|
labels,
|
||||||
},
|
},
|
||||||
@@ -739,11 +747,16 @@ export async function modelsStatusCommand(
|
|||||||
|
|
||||||
const providersWithOauth = providerAuth
|
const providersWithOauth = providerAuth
|
||||||
.filter(
|
.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) => {
|
.map((entry) => {
|
||||||
const count =
|
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})`;
|
return `${entry.provider} (${count})`;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -754,7 +767,7 @@ export async function modelsStatusCommand(
|
|||||||
providers,
|
providers,
|
||||||
});
|
});
|
||||||
const oauthProfiles = authHealth.profiles.filter(
|
const oauthProfiles = authHealth.profiles.filter(
|
||||||
(profile) => profile.type === "oauth",
|
(profile) => profile.type === "oauth" || profile.type === "token",
|
||||||
);
|
);
|
||||||
|
|
||||||
const checkStatus = (() => {
|
const checkStatus = (() => {
|
||||||
@@ -926,7 +939,7 @@ export async function modelsStatusCommand(
|
|||||||
);
|
);
|
||||||
runtime.log(
|
runtime.log(
|
||||||
`${label(
|
`${label(
|
||||||
`Providers w/ OAuth (${providersWithOauth.length || 0})`,
|
`Providers w/ OAuth/tokens (${providersWithOauth.length || 0})`,
|
||||||
)}${colorize(rich, theme.muted, ":")} ${colorize(
|
)}${colorize(rich, theme.muted, ":")} ${colorize(
|
||||||
rich,
|
rich,
|
||||||
providersWithOauth.length ? theme.info : theme.muted,
|
providersWithOauth.length ? theme.info : theme.muted,
|
||||||
@@ -953,7 +966,7 @@ export async function modelsStatusCommand(
|
|||||||
bits.push(
|
bits.push(
|
||||||
formatKeyValue(
|
formatKeyValue(
|
||||||
"profiles",
|
"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,
|
rich,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1003,7 +1016,7 @@ export async function modelsStatusCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
runtime.log("");
|
runtime.log("");
|
||||||
runtime.log(colorize(rich, theme.heading, "OAuth status"));
|
runtime.log(colorize(rich, theme.heading, "OAuth/token status"));
|
||||||
if (oauthProfiles.length === 0) {
|
if (oauthProfiles.length === 0) {
|
||||||
runtime.log(colorize(rich, theme.muted, "- none"));
|
runtime.log(colorize(rich, theme.muted, "- none"));
|
||||||
return;
|
return;
|
||||||
@@ -1011,6 +1024,7 @@ export async function modelsStatusCommand(
|
|||||||
|
|
||||||
const formatStatus = (status: string) => {
|
const formatStatus = (status: string) => {
|
||||||
if (status === "ok") return colorize(rich, theme.success, "ok");
|
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 === "expiring") return colorize(rich, theme.warn, "expiring");
|
||||||
if (status === "missing") return colorize(rich, theme.warn, "unknown");
|
if (status === "missing") return colorize(rich, theme.warn, "unknown");
|
||||||
return colorize(rich, theme.error, "expired");
|
return colorize(rich, theme.error, "expired");
|
||||||
@@ -1020,9 +1034,12 @@ export async function modelsStatusCommand(
|
|||||||
const labelText = profile.label || profile.profileId;
|
const labelText = profile.label || profile.profileId;
|
||||||
const label = colorize(rich, theme.accent, labelText);
|
const label = colorize(rich, theme.accent, labelText);
|
||||||
const status = formatStatus(profile.status);
|
const status = formatStatus(profile.status);
|
||||||
const expiry = profile.expiresAt
|
const expiry =
|
||||||
? ` expires in ${formatRemainingShort(profile.remainingMs)}`
|
profile.status === "static"
|
||||||
: " expires unknown";
|
? ""
|
||||||
|
: profile.expiresAt
|
||||||
|
? ` expires in ${formatRemainingShort(profile.remainingMs)}`
|
||||||
|
: " expires unknown";
|
||||||
const source =
|
const source =
|
||||||
profile.source !== "store"
|
profile.source !== "store"
|
||||||
? colorize(rich, theme.muted, ` (${profile.source})`)
|
? colorize(rich, theme.muted, ` (${profile.source})`)
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ describe("writeOAuthCredentials", () => {
|
|||||||
expires: Date.now() + 60_000,
|
expires: Date.now() + 60_000,
|
||||||
} satisfies OAuthCredentials;
|
} satisfies OAuthCredentials;
|
||||||
|
|
||||||
await writeOAuthCredentials("anthropic", creds);
|
await writeOAuthCredentials("openai-codex", creds);
|
||||||
|
|
||||||
// Now writes to the multi-agent path: agents/main/agent
|
// Now writes to the multi-agent path: agents/main/agent
|
||||||
const authProfilePath = path.join(
|
const authProfilePath = path.join(
|
||||||
@@ -66,7 +66,7 @@ describe("writeOAuthCredentials", () => {
|
|||||||
const parsed = JSON.parse(raw) as {
|
const parsed = JSON.parse(raw) as {
|
||||||
profiles?: Record<string, OAuthCredentials & { type?: string }>;
|
profiles?: Record<string, OAuthCredentials & { type?: string }>;
|
||||||
};
|
};
|
||||||
expect(parsed.profiles?.["anthropic:default"]).toMatchObject({
|
expect(parsed.profiles?.["openai-codex:default"]).toMatchObject({
|
||||||
refresh: "refresh-token",
|
refresh: "refresh-token",
|
||||||
access: "access-token",
|
access: "access-token",
|
||||||
type: "oauth",
|
type: "oauth",
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function applyAuthProfileConfig(
|
|||||||
params: {
|
params: {
|
||||||
profileId: string;
|
profileId: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
mode: "api_key" | "oauth";
|
mode: "api_key" | "oauth" | "token";
|
||||||
email?: string;
|
email?: string;
|
||||||
preferProfileFirst?: boolean;
|
preferProfileFirst?: boolean;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ export async function runNonInteractiveOnboarding(
|
|||||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||||
profileId: CLAUDE_CLI_PROFILE_ID,
|
profileId: CLAUDE_CLI_PROFILE_ID,
|
||||||
provider: "anthropic",
|
provider: "anthropic",
|
||||||
mode: "oauth",
|
mode: "token",
|
||||||
});
|
});
|
||||||
} else if (authChoice === "codex-cli") {
|
} else if (authChoice === "codex-cli") {
|
||||||
const store = ensureAuthProfileStore();
|
const store = ensureAuthProfileStore();
|
||||||
@@ -169,17 +169,18 @@ export async function runNonInteractiveOnboarding(
|
|||||||
} else if (authChoice === "minimax") {
|
} else if (authChoice === "minimax") {
|
||||||
nextConfig = applyMinimaxConfig(nextConfig);
|
nextConfig = applyMinimaxConfig(nextConfig);
|
||||||
} else if (
|
} else if (
|
||||||
|
authChoice === "token" ||
|
||||||
authChoice === "oauth" ||
|
authChoice === "oauth" ||
|
||||||
authChoice === "openai-codex" ||
|
authChoice === "openai-codex" ||
|
||||||
authChoice === "antigravity"
|
authChoice === "antigravity"
|
||||||
) {
|
) {
|
||||||
runtime.error(
|
const label =
|
||||||
`${
|
authChoice === "antigravity"
|
||||||
authChoice === "oauth" || authChoice === "openai-codex"
|
? "Antigravity"
|
||||||
? "OAuth"
|
: authChoice === "token"
|
||||||
: "Antigravity"
|
? "Token"
|
||||||
} requires interactive mode.`,
|
: "OAuth";
|
||||||
);
|
runtime.error(`${label} requires interactive mode.`);
|
||||||
runtime.exit(1);
|
runtime.exit(1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export type OnboardMode = "local" | "remote";
|
|||||||
export type AuthChoice =
|
export type AuthChoice =
|
||||||
| "oauth"
|
| "oauth"
|
||||||
| "claude-cli"
|
| "claude-cli"
|
||||||
|
| "token"
|
||||||
| "openai-codex"
|
| "openai-codex"
|
||||||
| "codex-cli"
|
| "codex-cli"
|
||||||
| "antigravity"
|
| "antigravity"
|
||||||
|
|||||||
@@ -985,7 +985,13 @@ export type ModelsConfig = {
|
|||||||
|
|
||||||
export type AuthProfileConfig = {
|
export type AuthProfileConfig = {
|
||||||
provider: string;
|
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;
|
email?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -895,7 +895,11 @@ export const ClawdbotSchema = z.object({
|
|||||||
z.string(),
|
z.string(),
|
||||||
z.object({
|
z.object({
|
||||||
provider: z.string(),
|
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(),
|
email: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user