feat: add OpenAI OAuth, strict model overrides, and Gmail pull mode

This commit is contained in:
William Valentin
2026-02-13 14:55:40 -08:00
parent 8f644d5e25
commit 955b9e28e0
50 changed files with 5955 additions and 160 deletions
+69
View File
@@ -163,6 +163,75 @@ automation:
expect(gmailCheck?.detail).toContain('flynn gmail-auth');
});
it('reports PASS for Gmail when enabled (poll only)', async () => {
mkdirSync(testDir, { recursive: true });
const configPath = join(testDir, 'config.yaml');
const credsPath = join(testDir, 'gmail-creds.json');
const tokenPath = join(testDir, 'gmail-token.json');
writeFileSync(credsPath, JSON.stringify({ installed: { project_id: 'test-project' } }));
writeFileSync(tokenPath, JSON.stringify({ refresh_token: 'x' }));
writeFileSync(configPath, `
telegram:
bot_token: "test-token"
allowed_chat_ids: [123]
models:
default:
provider: anthropic
model: claude-sonnet
automation:
gmail:
enabled: true
credentials_file: "${credsPath}"
token_file: "${tokenPath}"
output:
channel: telegram
peer: "123"
`);
const ctx: DoctorContext = { configPath, dataDir: testDir };
const results = await runChecks(ctx);
const gmailCheck = results.find(r => r.label.includes('Gmail configured'));
expect(gmailCheck?.status).toBe('pass');
expect(gmailCheck?.detail).toContain('poll');
});
it('reports WARN for Gmail when pubsub_topic shorthand used without project_id', async () => {
mkdirSync(testDir, { recursive: true });
const configPath = join(testDir, 'config.yaml');
const credsPath = join(testDir, 'gmail-creds.json');
const tokenPath = join(testDir, 'gmail-token.json');
writeFileSync(credsPath, '{}');
writeFileSync(tokenPath, JSON.stringify({ refresh_token: 'x' }));
writeFileSync(configPath, `
telegram:
bot_token: "test-token"
allowed_chat_ids: [123]
models:
default:
provider: anthropic
model: claude-sonnet
automation:
gmail:
enabled: true
credentials_file: "${credsPath}"
token_file: "${tokenPath}"
pubsub_topic: gmail-push
output:
channel: telegram
peer: "123"
`);
const ctx: DoctorContext = { configPath, dataDir: testDir };
const results = await runChecks(ctx);
const gmailCheck = results.find(r => r.label.includes('Gmail configured'));
expect(gmailCheck?.status).toBe('warn');
expect(gmailCheck?.detail).toContain('pubsub_topic shorthand');
});
it('skips downstream checks when config is invalid', async () => {
const ctx: DoctorContext = { configPath: '/nonexistent/config.yaml', dataDir: testDir };
const results = await runChecks(ctx);
+55 -2
View File
@@ -137,7 +137,8 @@ const checkModelConnectivity: Check = async (ctx) => {
// Check if API key is present for providers that need one
const needsKey = ['anthropic', 'openai', 'gemini', 'openrouter'];
if (needsKey.includes(model.provider) && !model.api_key && !model.auth_token) {
const openaiUsingOAuth = model.provider === 'openai' && Boolean((model as unknown as { use_oauth?: boolean }).use_oauth);
if (needsKey.includes(model.provider) && !openaiUsingOAuth && !model.api_key && !model.auth_token) {
const envVarMap: Record<string, string> = {
anthropic: 'ANTHROPIC_API_KEY',
openai: 'OPENAI_API_KEY',
@@ -256,12 +257,64 @@ const checkGmail: Check = async (ctx) => {
return { status: 'fail', label: 'Gmail configured', detail: `credentials file not found: ${credentialsPath}` };
}
let googleProjectId: string | undefined;
try {
const creds = JSON.parse(readFileSync(credentialsPath, 'utf-8')) as Record<string, unknown>;
const installed = (creds.installed as Record<string, unknown> | undefined) ?? (creds.web as Record<string, unknown> | undefined);
const projectId = installed?.project_id;
if (typeof projectId === 'string' && projectId.trim()) {
googleProjectId = projectId.trim();
}
} catch {
// Ignore JSON parse errors; doctor will still validate token and output.
}
const tokenPath = expandPath(gmail.token_file ?? '~/.config/flynn/gmail-token.json');
if (!existsSync(tokenPath)) {
return { status: 'warn', label: 'Gmail configured', detail: 'run `flynn gmail-auth` to authenticate' };
}
return { status: 'pass', label: 'Gmail configured', detail: `(output: ${gmail.output.channel}/${gmail.output.peer})` };
const modes: string[] = [];
const warnings: string[] = [];
const topicRaw = (gmail.pubsub_topic ?? process.env.FLYNN_GMAIL_PUBSUB_TOPIC ?? '').trim();
const pushEnabled = Boolean(topicRaw) && !gmail.disable_push;
if (pushEnabled) {
modes.push('push');
if (topicRaw.includes('/')) {
const ok = /^projects\/[^/]+\/topics\/[^/]+$/.test(topicRaw);
if (!ok) {
warnings.push('pubsub_topic format invalid (expected projects/<project>/topics/<topic>)');
}
} else if (!googleProjectId) {
warnings.push('pubsub_topic shorthand requires project_id in Gmail credentials');
}
if (ctx.config.server?.tailscale?.serve) {
warnings.push('push requires a public HTTPS endpoint; Tailscale Serve is typically tailnet-only');
}
} else if (gmail.disable_push && topicRaw) {
warnings.push('push disabled (disable_push=true)');
}
const subRaw = (gmail.pubsub_subscription_id ?? '').trim();
if (subRaw) {
modes.push('pull');
if (subRaw.includes('/')) {
const ok = /^projects\/[^/]+\/subscriptions\/[^/]+$/.test(subRaw);
if (!ok) {
warnings.push('pubsub_subscription_id format invalid (expected projects/<project>/subscriptions/<sub>)');
}
} else if (!googleProjectId) {
warnings.push('pubsub_subscription_id shorthand requires project_id in Gmail credentials');
}
}
modes.push('poll');
const detail = `(${modes.join(' + ')} -> ${gmail.output.channel}/${gmail.output.peer})`;
const withWarnings = warnings.length > 0 ? `${detail}${warnings.join('; ')}` : detail;
return { status: warnings.length > 0 ? 'warn' : 'pass', label: 'Gmail configured', detail: withWarnings };
};
const allChecks: Check[] = [
+2
View File
@@ -18,6 +18,7 @@ import { registerGcalAuthCommand } from './gcal-auth.js';
import { registerGdocsAuthCommand } from './gdocs-auth.js';
import { registerGdriveAuthCommand } from './gdrive-auth.js';
import { registerGtasksAuthCommand } from './gtasks-auth.js';
import { registerOpenaiAuthCommand } from './openai-auth.js';
import { registerSkillsCommand } from './skills.js';
export function createProgram(): Command {
@@ -41,6 +42,7 @@ export function createProgram(): Command {
registerGdocsAuthCommand(program);
registerGdriveAuthCommand(program);
registerGtasksAuthCommand(program);
registerOpenaiAuthCommand(program);
registerSkillsCommand(program);
return program;
+35
View File
@@ -0,0 +1,35 @@
import type { Command } from 'commander';
import { loadStoredOpenAIAuth, loginOpenAI } from '../auth/index.js';
export function registerOpenaiAuthCommand(program: Command): void {
program
.command('openai-auth')
.description('Authenticate OpenAI (ChatGPT Plus/Pro) via OAuth device flow')
.action(async () => {
const existing = loadStoredOpenAIAuth();
if (existing) {
console.log('OpenAI OAuth token already exists.');
console.log('Delete ~/.config/flynn/auth.json openai entry if you want to re-authenticate.');
process.exit(0);
}
console.log('Starting OpenAI OAuth device flow...');
console.log('');
try {
await loginOpenAI((userCode, verificationUri) => {
console.log(`Please visit: ${verificationUri}`);
console.log(`Enter code: ${userCode}`);
console.log('');
console.log('Waiting for authorization...');
});
console.log('');
console.log('OpenAI authentication successful! Token stored.');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`OpenAI login failed: ${message}`);
process.exit(1);
}
});
}
+39
View File
@@ -53,6 +53,45 @@ models:
expect(result.config!.telegram?.bot_token).toBe('test-token');
});
it('loads env vars from FLYNN_ENV_FILE before parsing config', () => {
const prevEnvFile = process.env.FLYNN_ENV_FILE;
const prevToken = process.env.TEST_BOT_TOKEN;
delete process.env.TEST_BOT_TOKEN;
mkdirSync(testDir, { recursive: true });
const envPath = join(testDir, 'cloud.env');
const configPath = join(testDir, 'config.yaml');
writeFileSync(envPath, 'TEST_BOT_TOKEN=test-token\n');
process.env.FLYNN_ENV_FILE = envPath;
writeFileSync(configPath, `
telegram:
bot_token: \${TEST_BOT_TOKEN}
allowed_chat_ids: [123]
models:
default:
provider: anthropic
model: claude-sonnet
`);
const result = loadConfigSafe(configPath);
expect(result.config).toBeDefined();
expect(result.error).toBeUndefined();
expect(result.config!.telegram?.bot_token).toBe('test-token');
if (prevEnvFile !== undefined) {
process.env.FLYNN_ENV_FILE = prevEnvFile;
} else {
delete process.env.FLYNN_ENV_FILE;
}
if (prevToken !== undefined) {
process.env.TEST_BOT_TOKEN = prevToken;
} else {
delete process.env.TEST_BOT_TOKEN;
}
});
it('returns error when file not found', () => {
const result = loadConfigSafe('/nonexistent/config.yaml');
expect(result.config).toBeUndefined();
+33
View File
@@ -2,6 +2,38 @@ import { loadConfig } from '../config/index.js';
import type { Config } from '../config/index.js';
import { resolve, dirname, join } from 'path';
import { homedir } from 'os';
import { existsSync, readFileSync } from 'fs';
function loadEnvFileIfPresent(): void {
const envFile = process.env.FLYNN_ENV_FILE ?? resolve(homedir(), '.config/flynn/cloud.env');
if (!existsSync(envFile)) {
return;
}
const raw = readFileSync(envFile, 'utf-8');
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) {
continue;
}
const idx = trimmed.indexOf('=');
if (idx <= 0) {
continue;
}
const key = trimmed.slice(0, idx).trim();
const value = trimmed.slice(idx + 1);
if (!key) {
continue;
}
// Don't override existing env vars.
if (process.env[key] === undefined) {
process.env[key] = value;
}
}
}
/** Get the config file path from env or default location. */
export function getConfigPath(): string {
@@ -30,6 +62,7 @@ export function resolveOverlayPath(basePath: string): string | undefined {
export function loadConfigSafe(configPath?: string): { config?: Config; error?: string } {
const path = configPath ?? getConfigPath();
try {
loadEnvFileIfPresent();
const overlayPath = resolveOverlayPath(path);
const config = loadConfig(path, overlayPath);
return { config };
+49 -22
View File
@@ -1,5 +1,5 @@
import type { Command } from 'commander';
import type { Config } from '../config/index.js';
import type { Config, ModelConfig, ModelProvider } from '../config/index.js';
import { loadConfigSafe, getConfigPath } from './shared.js';
import { existsSync, mkdirSync, readFileSync } from 'fs';
import { resolve } from 'path';
@@ -58,6 +58,26 @@ function loadSystemPrompt(): string {
return 'You are Flynn, a helpful personal AI assistant. Be direct, concise, and helpful. Use markdown when it improves readability.';
}
function buildProviderConfigMap(config: Config): Partial<Record<ModelProvider, ModelConfig>> {
const providerConfigs: Partial<Record<ModelProvider, ModelConfig>> = {};
const modelConfigs: ModelConfig[] = [
config.models.default,
...(config.models.fast ? [config.models.fast] : []),
...(config.models.complex ? [config.models.complex] : []),
...(config.models.local ? [config.models.local] : []),
...Object.values(config.models.local_providers ?? {}),
];
for (const modelConfig of modelConfigs) {
providerConfigs[modelConfig.provider] = modelConfig;
if (modelConfig.fallback) {
providerConfigs[modelConfig.fallback.provider] = modelConfig.fallback;
}
}
return providerConfigs;
}
export function registerTuiCommand(program: Command): void {
program
.command('tui')
@@ -179,6 +199,7 @@ export function registerTuiCommand(program: Command): void {
const toolExecutor = new ToolExecutor(toolRegistry, hookEngine);
const session = sessionManager.getSession('tui', 'local');
const modelProviderConfigs = buildProviderConfigMap(config);
const agent = new NativeAgent({
modelClient: modelRouter,
@@ -211,29 +232,33 @@ export function registerTuiCommand(program: Command): void {
process.exit(0);
});
if (opts.fullscreen) {
await startFullscreenTui({
session,
modelClient: modelRouter,
modelRouter,
systemPrompt,
model: config.models.default.model,
agent,
onExit: cleanup,
});
} else {
if (opts.fullscreen) {
await startFullscreenTui({
session,
modelClient: modelRouter,
modelRouter,
systemPrompt,
model: config.models.default.model,
agent,
hookEngine,
modelProviderConfigs,
onExit: cleanup,
});
} else {
let switchingToFullscreen = false;
const tui = new MinimalTui({
session,
modelClient: modelRouter,
modelRouter,
systemPrompt,
agent,
pairingManager,
localProviders: config.models.local_providers,
currentLocalProvider: config.models.local?.provider,
onTransfer: (target) => {
const tui = new MinimalTui({
session,
modelClient: modelRouter,
modelRouter,
systemPrompt,
agent,
hookEngine,
pairingManager,
localProviders: config.models.local_providers,
modelProviderConfigs,
currentLocalProvider: config.models.local?.provider,
onTransfer: (target) => {
if (target === 'telegram') {
if (config.telegram && config.telegram.allowed_chat_ids.length > 0) {
const telegramUserId = String(config.telegram.allowed_chat_ids[0]);
@@ -263,6 +288,8 @@ export function registerTuiCommand(program: Command): void {
systemPrompt,
model: config.models.default.model,
agent,
hookEngine,
modelProviderConfigs,
onExit: cleanup,
});
return;