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:
2026-05-30 17:07:51 +00:00
co-authored by Claude Opus 4.7
parent 8a583da19b
commit 3540d53a00
10 changed files with 129 additions and 17 deletions
+11 -3
View File
@@ -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,
},
},
},
+9 -4
View File
@@ -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;
+11
View File
@@ -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);