Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/browser/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,15 @@ const webApi: IPCApi = {
voice: {
transcribe: (audioBase64) => invokeIPC(IPC_CHANNELS.VOICE_TRANSCRIBE, audioBase64),
},
oauth: {
anthropic: {
start: (mode) => invokeIPC(IPC_CHANNELS.OAUTH_ANTHROPIC_START, mode),
exchange: (code, verifier, state) =>
invokeIPC(IPC_CHANNELS.OAUTH_ANTHROPIC_EXCHANGE, code, verifier, state),
status: () => invokeIPC(IPC_CHANNELS.OAUTH_ANTHROPIC_STATUS),
logout: () => invokeIPC(IPC_CHANNELS.OAUTH_ANTHROPIC_LOGOUT),
},
},
update: {
check: () => invokeIPC(IPC_CHANNELS.UPDATE_CHECK),
download: () => invokeIPC(IPC_CHANNELS.UPDATE_DOWNLOAD),
Expand Down
9 changes: 9 additions & 0 deletions src/browser/stories/mockFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,15 @@ export function createMockAPI(options: MockAPIOptions): IPCApi {
voice: {
transcribe: () => Promise.resolve({ success: false, error: "Not implemented in mock" }),
},
oauth: {
anthropic: {
start: () =>
Promise.resolve({ authUrl: "https://claude.ai/oauth", verifier: "mock", state: "mock" }),
exchange: () => Promise.resolve({ success: false, error: "Not implemented in mock" }),
status: () => Promise.resolve({ authenticated: false }),
logout: () => Promise.resolve(),
},
},
update: {
check: () => Promise.resolve(undefined),
download: () => Promise.resolve(undefined),
Expand Down
6 changes: 6 additions & 0 deletions src/common/constants/ipc-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ export const IPC_CHANNELS = {
// Voice channels
VOICE_TRANSCRIBE: "voice:transcribe",

// OAuth channels
OAUTH_ANTHROPIC_START: "oauth:anthropic:start",
OAUTH_ANTHROPIC_EXCHANGE: "oauth:anthropic:exchange",
OAUTH_ANTHROPIC_STATUS: "oauth:anthropic:status",
OAUTH_ANTHROPIC_LOGOUT: "oauth:anthropic:logout",

// Dynamic channel prefixes
WORKSPACE_CHAT_PREFIX: "workspace:chat:",
WORKSPACE_METADATA: "workspace:metadata",
Expand Down
14 changes: 14 additions & 0 deletions src/common/types/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,20 @@ export interface IPCApi {
/** Transcribe audio using OpenAI Whisper. Audio should be base64-encoded webm/opus. */
transcribe(audioBase64: string): Promise<Result<string, string>>;
};
oauth: {
anthropic: {
/** Start OAuth flow, returns URL to open in browser, verifier (secret), and state (CSRF token) */
start(
mode?: "max" | "console"
): Promise<{ authUrl: string; verifier: string; state: string }>;
/** Exchange authorization code for tokens (verifier and state from start()) */
exchange(code: string, verifier: string, state: string): Promise<Result<void, string>>;
/** Get current OAuth status */
status(): Promise<{ authenticated: boolean; expiresAt?: number }>;
/** Logout (clear OAuth credentials) */
logout(): Promise<void>;
};
};
update: {
check(): Promise<void>;
download(): Promise<void>;
Expand Down
10 changes: 10 additions & 0 deletions src/desktop/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,16 @@ const api: IPCApi = {
transcribe: (audioBase64: string) =>
ipcRenderer.invoke(IPC_CHANNELS.VOICE_TRANSCRIBE, audioBase64),
},
oauth: {
anthropic: {
start: (mode?: "max" | "console") =>
ipcRenderer.invoke(IPC_CHANNELS.OAUTH_ANTHROPIC_START, mode),
exchange: (code: string, verifier: string, state: string) =>
ipcRenderer.invoke(IPC_CHANNELS.OAUTH_ANTHROPIC_EXCHANGE, code, verifier, state),
status: () => ipcRenderer.invoke(IPC_CHANNELS.OAUTH_ANTHROPIC_STATUS),
logout: () => ipcRenderer.invoke(IPC_CHANNELS.OAUTH_ANTHROPIC_LOGOUT),
},
},
update: {
check: () => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_CHECK),
download: () => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_DOWNLOAD),
Expand Down
86 changes: 86 additions & 0 deletions src/node/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as jsonc from "jsonc-parser";
import writeFileAtomic from "write-file-atomic";
import type { WorkspaceMetadata, FrontendWorkspaceMetadata } from "@/common/types/workspace";
import type { Secret, SecretsConfig } from "@/common/types/secrets";
import type { AnthropicOAuthCredentials } from "@/node/services/anthropicOAuth";
import type { Workspace, ProjectConfig, ProjectsConfig } from "@/common/types/project";
import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace";
import { isIncompatibleRuntimeConfig } from "@/common/utils/runtimeCompatibility";
Expand Down Expand Up @@ -37,6 +38,7 @@ export class Config {
private readonly configFile: string;
private readonly providersFile: string;
private readonly secretsFile: string;
private readonly oauthFile: string;

constructor(rootDir?: string) {
this.rootDir = rootDir ?? getMuxHome();
Expand All @@ -45,6 +47,7 @@ export class Config {
this.configFile = path.join(this.rootDir, "config.json");
this.providersFile = path.join(this.rootDir, "providers.jsonc");
this.secretsFile = path.join(this.rootDir, "secrets.json");
this.oauthFile = path.join(this.rootDir, "oauth.json");
}

loadConfigOrDefault(): ProjectsConfig {
Expand Down Expand Up @@ -543,6 +546,89 @@ ${jsonString}`;
config[projectPath] = secrets;
await this.saveSecretsConfig(config);
}

// ============================================================================
// OAuth Credentials Management
// ============================================================================

/**
* Load Anthropic OAuth credentials from file
* @returns OAuth credentials or null if not configured
*/
loadAnthropicOAuthCredentials(): AnthropicOAuthCredentials | null {
try {
if (fs.existsSync(this.oauthFile)) {
const data = fs.readFileSync(this.oauthFile, "utf-8");
const parsed = JSON.parse(data) as { anthropic?: AnthropicOAuthCredentials };
return parsed.anthropic ?? null;
}
} catch (error) {
console.error("Error loading OAuth credentials:", error);
}
return null;
}

/**
* Save Anthropic OAuth credentials to file
* @param credentials The OAuth credentials to save
*/
async saveAnthropicOAuthCredentials(credentials: AnthropicOAuthCredentials): Promise<void> {
try {
if (!fs.existsSync(this.rootDir)) {
fs.mkdirSync(this.rootDir, { recursive: true });
}

// Load existing file to preserve other provider credentials
let existing: Record<string, unknown> = {};
if (fs.existsSync(this.oauthFile)) {
try {
existing = JSON.parse(fs.readFileSync(this.oauthFile, "utf-8")) as Record<
string,
unknown
>;
} catch {
// Ignore parse errors, start fresh
}
}

existing.anthropic = credentials;
await writeFileAtomic(this.oauthFile, JSON.stringify(existing, null, 2), "utf-8");
} catch (error) {
console.error("Error saving OAuth credentials:", error);
throw error;
}
}

/**
* Clear Anthropic OAuth credentials (logout)
*/
async clearAnthropicOAuthCredentials(): Promise<void> {
try {
if (fs.existsSync(this.oauthFile)) {
const data = fs.readFileSync(this.oauthFile, "utf-8");
const parsed = JSON.parse(data) as Record<string, unknown>;
delete parsed.anthropic;
if (Object.keys(parsed).length === 0) {
fs.unlinkSync(this.oauthFile);
} else {
await writeFileAtomic(this.oauthFile, JSON.stringify(parsed, null, 2), "utf-8");
}
}
} catch (error) {
console.error("Error clearing OAuth credentials:", error);
throw error;
}
}

/**
* Check if Anthropic OAuth is configured and valid
*/
hasValidAnthropicOAuth(): boolean {
const credentials = this.loadAnthropicOAuthCredentials();
if (!credentials) return false;
// Check if we have a refresh token (access token may be expired but we can refresh)
return Boolean(credentials.refreshToken);
}
}

// Default instance for application use
Expand Down
57 changes: 43 additions & 14 deletions src/node/services/aiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import type {
} from "@/common/types/stream";
import { applyToolPolicy, type ToolPolicy } from "@/common/utils/tools/toolPolicy";
import { MockScenarioPlayer } from "./mock/mockScenarioPlayer";
import { createOAuthFetch, type AnthropicOAuthCredentials } from "./anthropicOAuth";
import { Agent } from "undici";

// Export a standalone version of getToolsForModel for use in backend
Expand Down Expand Up @@ -383,18 +384,20 @@ export class AIService extends EventEmitter {

// Handle Anthropic provider
if (providerName === "anthropic") {
// Anthropic API key can come from:
// 1. providers.jsonc config (providerConfig.apiKey)
// 2. ANTHROPIC_API_KEY env var (SDK reads this automatically)
// 3. ANTHROPIC_AUTH_TOKEN env var (we pass this explicitly since SDK doesn't check it)
// We allow env var passthrough so users don't need explicit config.

// Anthropic authentication priority:
// 1. OAuth credentials (from ~/.mux/oauth.json) - uses Claude Pro/Max subscription
// 2. providers.jsonc config (providerConfig.apiKey)
// 3. ANTHROPIC_API_KEY env var (SDK reads this automatically)
// 4. ANTHROPIC_AUTH_TOKEN env var (we pass this explicitly since SDK doesn't check it)

const oauthCredentials = this.config.loadAnthropicOAuthCredentials();
const hasOAuth = Boolean(oauthCredentials?.refreshToken);
const hasApiKeyInConfig = Boolean(providerConfig.apiKey);
const hasApiKeyEnvVar = Boolean(process.env.ANTHROPIC_API_KEY);
const hasAuthTokenEnvVar = Boolean(process.env.ANTHROPIC_AUTH_TOKEN);

// Return structured error if no credentials available anywhere
if (!hasApiKeyInConfig && !hasApiKeyEnvVar && !hasAuthTokenEnvVar) {
if (!hasOAuth && !hasApiKeyInConfig && !hasApiKeyEnvVar && !hasAuthTokenEnvVar) {
return Err({
type: "api_key_not_found",
provider: providerName,
Expand All @@ -403,7 +406,7 @@ export class AIService extends EventEmitter {

// If SDK won't find a key (no config, no ANTHROPIC_API_KEY), use ANTHROPIC_AUTH_TOKEN
let configWithApiKey = providerConfig;
if (!hasApiKeyInConfig && !hasApiKeyEnvVar && hasAuthTokenEnvVar) {
if (!hasOAuth && !hasApiKeyInConfig && !hasApiKeyEnvVar && hasAuthTokenEnvVar) {
configWithApiKey = { ...providerConfig, apiKey: process.env.ANTHROPIC_AUTH_TOKEN };
}

Expand All @@ -429,15 +432,41 @@ export class AIService extends EventEmitter {

// Lazy-load Anthropic provider to reduce startup time
const { createAnthropic } = await PROVIDER_REGISTRY.anthropic();
// Wrap fetch to inject cache_control on tools and messages
// (SDK doesn't translate providerOptions to cache_control for these)
// Use getProviderFetch to preserve any user-configured custom fetch (e.g., proxies)
const baseFetch = getProviderFetch(providerConfig);
const fetchWithCacheControl = wrapFetchWithAnthropicCacheControl(baseFetch);

// Build the fetch chain:
// 1. Start with base fetch (user-configured or default)
// 2. Wrap with cache control injection
// 3. If OAuth, wrap with OAuth authentication
let providerFetch: typeof fetch = getProviderFetch(providerConfig);
providerFetch = wrapFetchWithAnthropicCacheControl(providerFetch);

// Use OAuth if available (takes priority over API key)
if (hasOAuth && oauthCredentials) {
log.info("Using Anthropic OAuth authentication (Claude Pro/Max subscription)");
const oauthFetch = createOAuthFetch(
oauthCredentials,
async (newCredentials: AnthropicOAuthCredentials) => {
await this.config.saveAnthropicOAuthCredentials(newCredentials);
},
providerFetch
);
// OAuth doesn't need an API key - use a placeholder since SDK requires one
// The OAuth fetch wrapper will replace the auth header anyway
const oauthConfig = { ...normalizedConfig, apiKey: "oauth" };
const provider = createAnthropic({
...oauthConfig,
headers,
// Cast is safe: oauthFetch is compatible with SDK's expected fetch signature
fetch: oauthFetch as typeof fetch,
});
return Ok(provider(modelId));
}

// Standard API key authentication
const provider = createAnthropic({
...normalizedConfig,
headers,
fetch: fetchWithCacheControl,
fetch: providerFetch,
});
return Ok(provider(modelId));
}
Expand Down
Loading