gmail proxy tool with token refresh, claude pro bearer auth, pi --list-models stderr fallback, tool object input type
- add getValidGoogleAccessToken helper and use it in email-cron, email account auth resolver, and the new gmail proxy - POST /api/integrations/google/gmail-proxy forwards arbitrary gmail rest calls server-side, with auto-refreshed oauth - pi-manager and claude user-instance inject OFFICER_API_URL + per-session JWT so tools can call back as the user - claude anthropic proxy uses Authorization: Bearer + preserves any anthropic-beta headers (pro oauth tokens are rejected via x-api-key, and overwriting the beta header broke context_management) - pi --list-models: fall back to stderr when stdout is empty (pi v0.73.1 writes the table to stderr) - mcp tool server + pi tool loader: accept type: object inputs so json bodies stay structured Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, unknown>;
|
||||
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 } };
|
||||
|
||||
@@ -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<To
|
||||
expiresAt: Date.now() + data.expires_in * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
// Returns a non-expired Google access token for the user, refreshing and
|
||||
// persisting a new one when the stored token is within 60s of expiry.
|
||||
// Returns null if the user has no stored token to work with.
|
||||
export async function getValidGoogleAccessToken(userId: number): Promise<string | null> {
|
||||
const integration = await getUserIntegration(userId, 'google');
|
||||
const config = integration?.config as Record<string, unknown> | 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;
|
||||
}
|
||||
|
||||
@@ -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<string, string>)['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) => {
|
||||
|
||||
@@ -50,7 +50,7 @@ export async function listPiModels(): Promise<ModelInfo[]> {
|
||||
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();
|
||||
|
||||
@@ -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<string, ToolParam>): 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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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<string, unknown> = { user: account.email };
|
||||
if (account.authType === 'oauth') {
|
||||
const integration = await getUserIntegration(account.userId, 'google');
|
||||
const config = integration?.config as Record<string, unknown> | 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;
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
|
||||
|
||||
@@ -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<string, ToolParam>): 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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user