diff --git a/src/server.tsx b/src/server.tsx index ab8bd058..40190e6e 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -12,6 +12,7 @@ import { desktopWebsocket } from './servers/api/desktop/websocket'; import { findEntryByProxyId, touchEntry } from './servers/api/dev-server/router'; import officerWeb from './apps/officer-web/index.html'; import { startBrowserRelay } from './servers/api/browser/relay'; +import { startAnthropicProxy } from './servers/api/anthropic-proxy'; import { toShellUsername } from './servers/data-path'; const { PORT = '5000' } = process.env; @@ -228,6 +229,12 @@ try { console.error('[browser-relay] failed to start:', err instanceof Error ? err.message : err); } +try { + startAnthropicProxy(); +} catch (err) { + console.error('[anthropic-proxy] failed to start:', err instanceof Error ? err.message : err); +} + void initTerminalSidecars(); diff --git a/src/servers/api/anthropic-proxy.ts b/src/servers/api/anthropic-proxy.ts new file mode 100644 index 00000000..0b38f4af --- /dev/null +++ b/src/servers/api/anthropic-proxy.ts @@ -0,0 +1,92 @@ +import { join } from 'node:path'; +import { homedir } from 'node:os'; + +const PROXY_PORT = Number(process.env.ANTHROPIC_PROXY_PORT ?? '5051'); +const ANTHROPIC_API_BASE = 'https://api.anthropic.com'; +const CREDENTIALS_PATH = join(homedir(), '.claude', '.credentials.json'); + +// Generate a random proxy secret at startup — shared with Claude Code spawns +// Prefix with sk-ant- so Claude Code accepts it as a valid API key format +export const proxySecret = `sk-ant-proxy01-${crypto.randomUUID()}`; + +type CredentialsFile = { + claudeAiOauth?: { + accessToken?: string; + }; +}; + +async function readOAuthToken(): Promise { + try { + const file = Bun.file(CREDENTIALS_PATH); + if (!(await file.exists())) return null; + const data = (await file.json()) as CredentialsFile; + return data.claudeAiOauth?.accessToken?.trim() || null; + } catch { + return null; + } +} + +export function startAnthropicProxy() { + Bun.serve({ + port: PROXY_PORT, + hostname: '127.0.0.1', + idleTimeout: 0, + + async fetch(req) { + const url = new URL(req.url); + + // Validate proxy secret + const incomingKey = req.headers.get('x-api-key'); + if (incomingKey !== proxySecret) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Read fresh OAuth token + const token = await readOAuthToken(); + if (!token) { + return new Response(JSON.stringify({ error: 'No OAuth token available' }), { + status: 502, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Build upstream URL + const upstream = `${ANTHROPIC_API_BASE}${url.pathname}${url.search}`; + + // Clone headers, replace proxy secret with real OAuth token + const headers = new Headers(req.headers); + headers.set('x-api-key', token); + headers.delete('host'); + + // Read request body fully before forwarding — avoids stream-in-stream issues + const body = req.body ? await req.arrayBuffer() : null; + + // Forward request + const upstreamRes = await fetch(upstream, { + method: req.method, + headers, + body, + }); + + // Build clean response headers + const resHeaders = new Headers(); + for (const [key, value] of upstreamRes.headers) { + const lower = key.toLowerCase(); + // Skip hop-by-hop and encoding headers that may have been consumed by fetch + if (lower === 'content-encoding' || lower === 'content-length' || lower === 'transfer-encoding') continue; + resHeaders.set(key, value); + } + + return new Response(upstreamRes.body, { + status: upstreamRes.status, + statusText: upstreamRes.statusText, + headers: resHeaders, + }); + }, + }); + + console.log(`[anthropic-proxy] listening on 127.0.0.1:${PROXY_PORT}`); +} diff --git a/src/servers/api/server-settings/claude-code.ts b/src/servers/api/server-settings/claude-code.ts index bb87f5fd..8f4afc65 100644 --- a/src/servers/api/server-settings/claude-code.ts +++ b/src/servers/api/server-settings/claude-code.ts @@ -56,9 +56,11 @@ claudeCodeRouter.get('/auth', async (ctx) => { const proc = Bun.spawn(['claude', 'auth', 'status'], { stdout: 'pipe', stderr: 'pipe' }); const output = await new Response(proc.stdout).text(); await proc.exited; - if (proc.exitCode !== 0) return ctx.json({ authenticated: false }); - const status = JSON.parse(output.trim()); - return ctx.json({ authenticated: status.loggedIn ?? false, ...status }); + if (proc.exitCode === 0) { + const status = JSON.parse(output.trim()); + if (status.loggedIn) return ctx.json({ authenticated: true, ...status }); + } + return ctx.json({ authenticated: false }); } catch { return ctx.json({ authenticated: false }); } diff --git a/src/servers/channels/send-claude-code.ts b/src/servers/channels/send-claude-code.ts index dd38dace..b35ccc33 100644 --- a/src/servers/channels/send-claude-code.ts +++ b/src/servers/channels/send-claude-code.ts @@ -1,8 +1,6 @@ import { join } from 'node:path'; -import { existsSync } from 'node:fs'; import { getHomeDir, - DATA_PATH, getNativeToolsDir, getGlobalToolsDir, getUserToolsDir, @@ -15,9 +13,11 @@ import { readToolDirs, parseFrontmatter as parseToolFrontmatter } from '@@/api/t import { readSkillDirs, parseFrontmatter as parseSkillFrontmatter } from '@@/api/skills/skills'; import { buildHostToolEnv } from '@@/api/pi/pi-bridge'; import { logger } from '@@/api/pi/logger'; +import { proxySecret } from '@@/api/anthropic-proxy'; import type { MessageCost, PiEvent } from '@@/api/pi/types'; const SEND_TIMEOUT_MS = 5 * 60 * 1000; +const PROXY_PORT = process.env.ANTHROPIC_PROXY_PORT ?? '5051'; // Resolve absolute path to claude binary so sudo -u can find it regardless of target user's PATH const CLAUDE_BIN = (() => { @@ -162,10 +162,13 @@ export async function sendClaudeCode(params: ClaudeCodeParams): Promise = { ...toolEnv, - ...(isServiceUser ? { HOME: process.env.HOME ?? '' } : {}), + ...(isServiceUser + ? { HOME: process.env.HOME ?? '' } + : { ANTHROPIC_BASE_URL: `http://127.0.0.1:${PROXY_PORT}`, ANTHROPIC_API_KEY: proxySecret }), PATH: process.env.PATH ?? '', TERM: 'xterm-256color', }; @@ -317,10 +320,13 @@ export async function sendClaudeCodeStreaming(params: ClaudeCodeStreamingParams) const isServiceUser = shellUsername === (process.env.USER ?? ''); - // For service user, keep real HOME so Claude Code finds its credentials + // For service user, keep real HOME so Claude Code finds its credentials. + // For other users, route through the local Anthropic proxy. const env: Record = { ...toolEnv, - ...(isServiceUser ? { HOME: cleanEnv.HOME ?? '' } : {}), + ...(isServiceUser + ? { HOME: cleanEnv.HOME ?? '' } + : { ANTHROPIC_BASE_URL: `http://127.0.0.1:${PROXY_PORT}`, ANTHROPIC_API_KEY: proxySecret }), PATH: cleanEnv.PATH ?? '', TERM: 'xterm-256color', };