diff --git a/src/servers/api/email/accounts.ts b/src/servers/api/email/accounts.ts index eb6a1f54..6457c0e4 100644 --- a/src/servers/api/email/accounts.ts +++ b/src/servers/api/email/accounts.ts @@ -5,9 +5,9 @@ import { getEmailAccount, createEmailAccount, deleteEmailAccount, - getUserIntegration, updateEmailAccountStatus, } from 'officerdb'; +import { getValidGoogleAccessToken } from '../integrations/google-auth'; import { validateImapConnection } from './imap-validate'; import { enqueueJob, listAllJobs } from '../../queue/init'; import { openEmailDb, getSyncMeta } from './email-db'; @@ -237,11 +237,12 @@ async function resolveAuth( const integrationId = credentials.userIntegrationId as number | undefined; if (!integrationId) return { ok: false, error: 'Missing userIntegrationId for OAuth' }; - const integration = await getUserIntegration(userId, 'google'); - if (!integration) return { ok: false, error: 'Google integration not found' }; - - const config = integration.config as Record; - const accessToken = config.accessToken as string | undefined; + let accessToken: string | null; + try { + accessToken = await getValidGoogleAccessToken(userId); + } catch (err) { + return { ok: false, error: `Token refresh failed — reconnect Google account: ${err instanceof Error ? err.message : err}` }; + } if (!accessToken) return { ok: false, error: 'No access token available — reconnect Google account' }; return { ok: true, auth: { accessToken } }; diff --git a/src/servers/api/integrations/google-auth.ts b/src/servers/api/integrations/google-auth.ts index e461db27..b602dff4 100644 --- a/src/servers/api/integrations/google-auth.ts +++ b/src/servers/api/integrations/google-auth.ts @@ -1,4 +1,4 @@ -import { getServerIntegration } from 'officerdb'; +import { getServerIntegration, getUserIntegration, upsertUserIntegration } from 'officerdb'; import { PermanentError } from '../../queue/types'; type TokenRefreshResult = { accessToken: string; expiresAt: number }; @@ -32,3 +32,30 @@ export async function refreshGoogleAccessToken(refreshToken: string): Promise { + const integration = await getUserIntegration(userId, 'google'); + const config = integration?.config as Record | undefined; + if (!config) return null; + + const accessToken = config.accessToken as string | undefined; + const refreshToken = config.refreshToken as string | undefined; + const expiresAt = config.expiresAt as number | undefined; + if (!accessToken) return null; + + const expired = !expiresAt || expiresAt < Date.now() + 60_000; + if (!expired || !refreshToken) return accessToken; + + const refreshed = await refreshGoogleAccessToken(refreshToken); + const serverGoogle = await getServerIntegration('google'); + await upsertUserIntegration({ + userId, + provider: 'google', + serverIntegrationId: serverGoogle?.id, + config: { ...config, accessToken: refreshed.accessToken, expiresAt: refreshed.expiresAt }, + }); + return refreshed.accessToken; +} diff --git a/src/servers/api/integrations/integrations.ts b/src/servers/api/integrations/integrations.ts index f3317bc2..fadab005 100644 --- a/src/servers/api/integrations/integrations.ts +++ b/src/servers/api/integrations/integrations.ts @@ -10,6 +10,7 @@ import { getDockPaths, setDockPaths, } from 'officerdb'; +import { getValidGoogleAccessToken } from './google-auth'; const GOOGLE_SCOPES = [ 'https://mail.google.com/', @@ -166,6 +167,41 @@ integrationsRouter.delete('/google/connection', async (ctx) => { return ctx.json({ ok: true }); }); +// --- Gmail proxy: forwards arbitrary Gmail REST calls with auto-refreshed OAuth --- + +type GmailProxyBody = { method?: string; path?: string; body?: unknown }; +const GMAIL_ALLOWED_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']); + +integrationsRouter.post('/google/gmail-proxy', async (ctx) => { + const user = ctx.get('user'); + const body = ctx.get('body') as GmailProxyBody; + + const method = (body.method ?? '').toUpperCase(); + const path = body.path ?? ''; + if (!GMAIL_ALLOWED_METHODS.has(method)) throw BAD_REQUEST('Invalid method'); + if (!path.startsWith('/')) throw BAD_REQUEST('path must start with /'); + + const accessToken = await getValidGoogleAccessToken(user.id); + if (!accessToken) throw BAD_REQUEST('No Google account connected — connect in Settings → Integrations'); + + const url = `https://gmail.googleapis.com/gmail/v1${path}`; + const init: RequestInit = { + method, + headers: { Authorization: `Bearer ${accessToken}` }, + }; + if (body.body !== undefined && method !== 'GET' && method !== 'DELETE') { + (init.headers as Record)['Content-Type'] = 'application/json'; + init.body = JSON.stringify(body.body); + } + + const upstream = await fetch(url, init); + const text = await upstream.text(); + let parsed: unknown; + try { parsed = JSON.parse(text); } catch { parsed = text; } + + return ctx.json({ status: upstream.status, ok: upstream.ok, body: parsed }); +}); + // --- OAuth flow: authorize (protected — user must be logged in) --- integrationsRouter.get('/google/authorize', async (ctx) => { diff --git a/src/servers/api/pi/list-models.ts b/src/servers/api/pi/list-models.ts index c5681c24..7da7fe10 100644 --- a/src/servers/api/pi/list-models.ts +++ b/src/servers/api/pi/list-models.ts @@ -50,7 +50,7 @@ export async function listPiModels(): Promise { env: { ...process.env, PI_CODING_AGENT_DIR: PI_CONFIG_DIR }, }); - const output = proc.stdout.toString(); + const output = proc.stdout.toString() || proc.stderr.toString(); if (proc.exitCode !== 0) { const stderrText = proc.stderr.toString(); diff --git a/src/servers/mcp-tool-server.ts b/src/servers/mcp-tool-server.ts index 0b168a6c..699fa7f2 100644 --- a/src/servers/mcp-tool-server.ts +++ b/src/servers/mcp-tool-server.ts @@ -18,7 +18,7 @@ import { join, dirname } from 'node:path'; // ── Types ── -type ToolParamType = 'string' | 'number' | 'boolean' | 'enum'; +type ToolParamType = 'string' | 'number' | 'boolean' | 'enum' | 'object'; type ToolParam = { type: ToolParamType; @@ -178,6 +178,9 @@ function buildZodSchema(inputs: Record): z.ZodRawShape { case 'boolean': field = z.boolean().describe(param.description); break; + case 'object': + field = z.record(z.string(), z.unknown()).describe(param.description); + break; default: field = z.string().describe(param.description); } diff --git a/src/servers/sidecar/claude/proxy.ts b/src/servers/sidecar/claude/proxy.ts index 1dee1aee..ef22355a 100644 --- a/src/servers/sidecar/claude/proxy.ts +++ b/src/servers/sidecar/claude/proxy.ts @@ -176,9 +176,17 @@ export function startAnthropicProxy() { // Build upstream URL const upstream = `${ANTHROPIC_API_BASE}${url.pathname}${url.search}`; - // Clone headers, replace proxy secret with real OAuth token + // Clone headers, swap proxy secret for the OAuth bearer token. Anthropic + // only accepts Claude Pro/Max OAuth tokens via Authorization + the oauth beta header. + // Preserve any anthropic-beta values Claude Code sent (context-management, etc.) + // and append oauth-2025-04-20 alongside them. const headers = new Headers(req.headers); - headers.set('x-api-key', token); + headers.delete('x-api-key'); + headers.set('Authorization', `Bearer ${token}`); + const existingBeta = headers.get('anthropic-beta'); + const betas = existingBeta ? existingBeta.split(',').map((s) => s.trim()).filter(Boolean) : []; + if (!betas.includes('oauth-2025-04-20')) betas.push('oauth-2025-04-20'); + headers.set('anthropic-beta', betas.join(',')); headers.delete('host'); // Read request body fully before forwarding @@ -203,7 +211,7 @@ export function startAnthropicProxy() { await writeCredentials(creds); } - headers.set('x-api-key', refreshed.accessToken.trim()); + headers.set('Authorization', `Bearer ${refreshed.accessToken.trim()}`); const retryBody = body ? new Uint8Array(body) : null; const retryRes = await fetch(upstream, { method: req.method, diff --git a/src/servers/sidecar/claude/user-instance.ts b/src/servers/sidecar/claude/user-instance.ts index 287b5580..79ba66d2 100644 --- a/src/servers/sidecar/claude/user-instance.ts +++ b/src/servers/sidecar/claude/user-instance.ts @@ -6,6 +6,8 @@ import { setMcpConfigPath } from './claude-manager'; import { SANDBOX_DATA } from '../sandbox'; import * as claudeManager from './claude-manager'; import { createSidecarConnector } from '../connect'; +import { sign } from '../../jwt'; +import { getUserByEmail } from 'officerdb'; const email = process.env.CLAUDE_USER_EMAIL; if (!email) { @@ -15,8 +17,20 @@ if (!email) { const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data'); const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`; +const OFFICER_API_URL = process.env.OFFICER_API_URL ?? `http://127.0.0.1:${process.env.PORT ?? '9010'}`; const MCP_SERVER_SCRIPT = resolve(import.meta.dir, '../../mcp-tool-server.ts'); +// Mint a long-lived JWT for this user so tools (e.g. gmail) can call back to dev-platform as them +const dbUser = await getUserByEmail(email); +if (!dbUser) { + console.error(`[user-instance] no user found for ${email}`); + process.exit(1); +} +const OFFICER_AUTH_TOKEN = await sign( + { id: dbUser.id, email, username: dbUser.username, role: dbUser.role }, + '30d', +); + const homeDir = join(DATA_PATH, email, 'home'); const globalToolsDir = join(DATA_PATH, 'tools'); const userToolsDir = join(DATA_PATH, email, 'tools'); @@ -73,6 +87,8 @@ function generateMcpConfig(): McpPaths { PI_TOOLS_DIRS: sandboxToolsDirs, OFFICER_EMAIL_DB: `${SANDBOX_DATA}/emails.db`, MCP_TOOLS_LOG: `${SANDBOX_DATA}/logs/mcp-tools.log`, + OFFICER_API_URL, + OFFICER_AUTH_TOKEN, }, }, }, @@ -91,6 +107,8 @@ function generateMcpConfig(): McpPaths { PI_TOOLS_DIRS: hostToolsDirs, OFFICER_EMAIL_DB: join(userRoot, 'emails.db'), MCP_TOOLS_LOG: join(userRoot, 'logs', 'mcp-tools.log'), + OFFICER_API_URL, + OFFICER_AUTH_TOKEN, }, }, }, diff --git a/src/servers/sidecar/email-cron.ts b/src/servers/sidecar/email-cron.ts index 1e6dd86f..63eac8ba 100644 --- a/src/servers/sidecar/email-cron.ts +++ b/src/servers/sidecar/email-cron.ts @@ -1,4 +1,5 @@ -import { getAllSyncedAccounts, getUserById, getUserIntegration } from 'officerdb'; +import { getAllSyncedAccounts, getUserById } from 'officerdb'; +import { getValidGoogleAccessToken } from '../api/integrations/google-auth'; import * as queueRunner from './queue-runner'; const INTERVAL_MS = 10 * 60 * 1000; // 10 minutes @@ -26,9 +27,13 @@ async function tick() { // Resolve IMAP auth const imapAuth: Record = { user: account.email }; if (account.authType === 'oauth') { - const integration = await getUserIntegration(account.userId, 'google'); - const config = integration?.config as Record | undefined; - const accessToken = config?.accessToken as string | undefined; + let accessToken: string | null = null; + try { + accessToken = await getValidGoogleAccessToken(account.userId); + } catch (err) { + console.log(`[email-cron] Skipping ${account.email}: token refresh failed —`, err instanceof Error ? err.message : err); + continue; + } if (!accessToken) { console.log(`[email-cron] Skipping ${account.email}: no OAuth access token`); continue; diff --git a/src/servers/sidecar/pi/pi-manager.ts b/src/servers/sidecar/pi/pi-manager.ts index 9e9d0658..8473ecdb 100644 --- a/src/servers/sidecar/pi/pi-manager.ts +++ b/src/servers/sidecar/pi/pi-manager.ts @@ -3,6 +3,7 @@ import { readdirSync, existsSync, mkdirSync } from 'node:fs'; import type { Subprocess } from 'bun'; import type { PiEvent, MessageCost } from '../../api/pi/types'; import type { PiSpawnParams, PiSessionInfo } from '../protocol'; +import { sign } from '../../jwt'; import { buildSandboxPrefix, buildRunuserSuffix, @@ -13,6 +14,8 @@ import { SANDBOX_HOME, } from '../sandbox'; +const OFFICER_API_URL = process.env.OFFICER_API_URL ?? `http://127.0.0.1:${process.env.PORT ?? '9010'}`; + const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data'); const PI_CONFIG_DIR = join(require('node:os').homedir(), '.pi', 'agent'); const getHomeDir = (email: string) => join(DATA_PATH, email, 'home'); @@ -310,6 +313,10 @@ export async function spawnPi(options: PiSpawnOptions): Promise { const homeDir = getHomeDirForRole(email, role); const toolsDirs = [getGlobalToolsDir(), getUserToolsDir(email)].join(':'); + // Per-session JWT so tools (e.g. the gmail proxy) can call back to dev-platform + // as the owning user. Mirrors the signin payload shape so userMiddleware accepts it. + const officerAuthToken = await sign({ id: userId, email, username, role }, '24h'); + let proc: Subprocess; if (isSuperAdmin) { @@ -322,6 +329,8 @@ export async function spawnPi(options: PiSpawnOptions): Promise { PI_CODING_AGENT_DIR: PI_CONFIG_DIR, PI_TOOLS_DIRS: toolsDirs, OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'), + OFFICER_API_URL, + OFFICER_AUTH_TOKEN: officerAuthToken, TERM: 'xterm-256color', }; if (PI_NODE_MODULES) env.NODE_PATH = PI_NODE_MODULES; @@ -338,6 +347,8 @@ export async function spawnPi(options: PiSpawnOptions): Promise { prefix.push('--setenv', 'PI_CODING_AGENT_DIR', `${SANDBOX_HOME}/.pi/agent`); prefix.push('--setenv', 'PI_TOOLS_DIRS', sandboxToolsDirs); prefix.push('--setenv', 'OFFICER_EMAIL_DB', `${SANDBOX_DATA}/emails.db`); + prefix.push('--setenv', 'OFFICER_API_URL', OFFICER_API_URL); + prefix.push('--setenv', 'OFFICER_AUTH_TOKEN', officerAuthToken); prefix.push('--setenv', 'TERM', 'xterm-256color'); if (PI_NODE_MODULES) prefix.push('--setenv', 'NODE_PATH', PI_NODE_MODULES); diff --git a/src/servers/tool-loader-source.ts b/src/servers/tool-loader-source.ts index 8e1d1b8b..1d8f1f02 100644 --- a/src/servers/tool-loader-source.ts +++ b/src/servers/tool-loader-source.ts @@ -3,7 +3,7 @@ import { Type, type TSchema } from '@sinclair/typebox'; import { readdirSync, existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; -type ToolParamType = 'string' | 'number' | 'boolean' | 'enum'; +type ToolParamType = 'string' | 'number' | 'boolean' | 'enum' | 'object'; type ToolParam = { type: ToolParamType; @@ -133,6 +133,9 @@ function buildSchema(inputs: Record): TSchema { case 'boolean': schema = Type.Boolean({ description: param.description }); break; + case 'object': + schema = Type.Record(Type.String(), Type.Unknown(), { description: param.description }); + break; default: schema = Type.String({ description: param.description }); }