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
@@ -0,0 +1,324 @@
# Gmail Push Notifications — Deployment Patterns
## Architecture Overview
```
┌─────────────────────────────────────────────────────────────────────┐
│ Google Cloud Platform │
│ │
│ ┌─────────────┐ ┌──────────────────────────────────────┐ │
│ │ Gmail API │────────▶│ Pub/Sub Topic: gmail-push │ │
│ └─────────────┘ └──────────────────────────────────────┘ │
│ │ New email │ │
│ │ triggers ├──── Push Subscription │
│ │ watch() │ (pushEndpoint) │
│ │ │ │
│ │ └──── Pull Subscription │
│ │ (no endpoint) │
│ │ │
└───────┼─────────────────────────────────────────────────────────────┘
│ │
│ │
│ History API │
│ (fallback poll) │
│ │
▼ ▼
┌────────────────────────────────────────────────────────────────────┐
│ Flynn │
│ │
│ ┌──────────────┐ ┌────────────────┐ ┌────────────────────┐ │
│ │ Push Handler │◀───│ Gateway Server │ │ Pull Subscriber │ │
│ │ │ │ │ │ │ │
│ │ POST /gmail/ │ │ Port 18800 │ │ Pull every 60s │ │
│ │ push │ └────────────────┘ └────────────────────┘ │
│ └──────────────┘ │ │
│ │ │ │
│ └──────────────┬───────────────────────────┘ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ GmailWatcher │ │
│ │ (ChannelAdapter)│ │
│ └──────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ AgentOrchestrator│ │
│ └──────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ Output Channel │ │
│ │ (Telegram/etc) │ │
│ └──────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
```
---
## Pattern 1: Push Only (Public Endpoint)
**Config**:
```yaml
automation:
gmail:
pubsub_topic: projects/my-project/topics/gmail-push
```
**Flow**:
1. Gmail API sends notification to Pub/Sub topic
2. Push subscription forwards to `POST https://flynn.example.com/gmail/push`
3. Flynn processes immediately (~real-time)
4. History API polls every 5min (fallback)
**Requirements**:
- Public IP or domain
- DNS A/AAAA record
- SSL/TLS certificate
- Firewall allows inbound HTTP/HTTPS
**Network**:
```
Internet ──▶ Public IP ──▶ Flynn Gateway ──▶ GmailWatcher
(Port 443) (Port 18800)
```
---
## Pattern 2: Push Only (Tailscale Funnel)
**Config**:
```yaml
server:
tailscale:
serve: true
funnel: true # ← Required for Google to reach endpoint
automation:
gmail:
pubsub_topic: gmail-push
```
**Flow**:
1. Gmail API sends notification to Pub/Sub topic
2. Push subscription forwards to `POST https://flynn.tailnet-name.ts.net/gmail/push`
3. Tailscale funnel proxies to local Flynn
4. Flynn processes immediately (~real-time)
**Requirements**:
- Tailscale installed and authenticated
- Funnel enabled (exposes service to public internet)
**Network**:
```
Internet ──▶ Tailscale Funnel ──▶ Flynn Gateway ──▶ GmailWatcher
(*.ts.net) (Port 18800)
```
---
## Pattern 3: Pull Only (Private Deployment)
**Config**:
```yaml
automation:
gmail:
pubsub_subscription_id: projects/my-project/subscriptions/gmail-pull
pubsub_pull_interval: "60s"
```
**Flow**:
1. Gmail API sends notification to Pub/Sub topic
2. Message sits in pull subscription queue
3. Flynn polls subscription every 60s
4. Flynn pulls and processes messages
5. History API polls every 5min (fallback)
**Requirements**:
- None (no inbound connections)
- Works behind NAT, firewall, VPN
**Network**:
```
Flynn ──(poll)──▶ GCP Pub/Sub API ──▶ Pull Subscription
(HTTPS outbound)
```
---
## Pattern 4: Hybrid (Recommended)
**Config**:
```yaml
automation:
gmail:
pubsub_topic: gmail-push
pubsub_subscription_id: gmail-pull
pubsub_pull_interval: "60s"
poll_interval: "300s"
```
**Flow**:
1. Gmail API sends notification to Pub/Sub topic
2. **Push subscription** forwards to Flynn gateway (if reachable)
- Processes immediately (~real-time)
3. **Pull subscription** also receives message (60s latency)
- Deduplicates via historyId comparison
4. **History API** polls every 5min (tertiary fallback)
**Requirements**:
- Push subscription: Public endpoint OR Tailscale funnel
- Pull subscription: Always works (no inbound)
**Network**:
```
┌──────────────────────────────────────────────┐
│ Primary: Internet ──▶ Flynn (push) │ ~Real-time
├──────────────────────────────────────────────┤
│ Fallback: Flynn ──(poll)──▶ GCP (pull) │ ~60s latency
├──────────────────────────────────────────────┤
│ Tertiary: Flynn ──(poll)──▶ Gmail History │ ~300s latency
└──────────────────────────────────────────────┘
```
**Benefits**:
-**Best latency** when push is reachable
-**Always reliable** (pull fallback)
-**Network-agnostic** (works behind NAT/firewall)
-**Self-healing** (network changes don't break it)
---
## Pattern 5: Polling Only (Development)
**Config**:
```yaml
automation:
gmail:
poll_interval: "60s" # No pubsub_topic or pubsub_subscription_id
```
**Flow**:
1. Flynn polls Gmail History API every 60s
2. Fetches new messages since last historyId
3. No GCP Pub/Sub setup required
**Requirements**:
- None (just OAuth2 credentials)
**Network**:
```
Flynn ──(poll)──▶ Gmail History API
(HTTPS outbound)
```
**Use Case**:
- Development/testing
- Quick setup without GCP project
- Low-volume inboxes (polling is fine)
---
## Comparison Table
| Pattern | Latency | Reliability | Network Req | GCP Setup | Recommended For |
|---------|---------|-------------|-------------|-----------|-----------------|
| **Push only (public)** | ~1s | High* | Public IP | Topic + push sub | Production with ingress |
| **Push only (funnel)** | ~1s | High* | Tailscale funnel | Topic + push sub | Private with funnel |
| **Pull only** | ~60s | High | Any | Topic + pull sub | Private behind NAT |
| **Hybrid** ⭐ | ~1s† | Highest | Any | Topic + both subs | All production |
| **Polling only** | ~300s | Medium | Any | None | Development only |
\* Single point of failure (push endpoint unreachable = delayed notification)
† Falls back to pull (~60s) if push fails
---
## GCP Setup Commands
### 1. Create Topic
```bash
gcloud pubsub topics create gmail-push --project=my-project
```
### 2. Grant Gmail API Permission
```bash
gcloud pubsub topics add-iam-policy-binding projects/my-project/topics/gmail-push \
--member=serviceAccount:gmail-api-push@system.gserviceaccount.com \
--role=roles/pubsub.publisher
```
### 3. Create Push Subscription (for push mode)
```bash
gcloud pubsub subscriptions create gmail-push-sub \
--topic=gmail-push \
--push-endpoint=https://flynn.example.com/gmail/push \
--ack-deadline=60
```
### 4. Create Pull Subscription (for pull mode)
```bash
gcloud pubsub subscriptions create gmail-pull-sub \
--topic=gmail-push \
--ack-deadline=60
```
### 5. Verify Setup
```bash
# List subscriptions
gcloud pubsub subscriptions list --filter="topic:gmail-push"
# Test pull subscription
gcloud pubsub subscriptions pull gmail-pull-sub --limit=5
```
---
## Troubleshooting Decision Tree
```
Start here: flynn doctor
├─▶ ✓ Gmail configured (push + pull + poll-fallback)
│ └─▶ ✅ Hybrid mode active (best config)
├─▶ ✓ Gmail configured (push + poll-fallback)
│ └─▶ ⚠️ Single point of failure (add pull subscription)
├─▶ ⚠️ Gmail configured (push ⚠️ push requires public endpoint)
│ └─▶ Enable Tailscale funnel OR add pull subscription
├─▶ ✓ Gmail configured (pull + poll-fallback)
│ └─▶ ✅ Reliable but slower (60s latency)
└─▶ ✓ Gmail configured (poll)
└─▶ ⚠️ Slow (300s latency) — add push or pull
```
---
## Migration Guide
### From: Polling Only
```yaml
# Before
automation:
gmail:
poll_interval: "60s"
```
### To: Hybrid (Recommended)
```yaml
# After
automation:
gmail:
pubsub_topic: gmail-push # Add push
pubsub_subscription_id: gmail-pull-sub # Add pull
pubsub_pull_interval: "60s"
poll_interval: "300s" # Keep as fallback
```
**Steps**:
1. Create GCP topic and subscriptions (see above)
2. Update config with new fields
3. Restart Flynn: `systemctl restart flynn`
4. Verify: `flynn doctor` shows "push + pull + poll-fallback"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,161 @@
# Gmail Push Notifications Revisit — Summary
**Date**: 2026-02-13
**Status**: Planned
**Implementation Plan**: `2026-02-13-gmail-push-revisit-implementation-plan.md`
## Overview
Enhance Gmail notification reliability through better deployment pattern support, improved configuration surface, actionable error messages, and comprehensive diagnostics.
## Problems Addressed
1. **Deployment ambiguity**: No clear guidance on supported patterns (push vs pull vs hybrid)
2. **GCP setup confusion**: Topic/subscription/IAM relationships not documented
3. **Network constraints**: Tailscale-only gateways cannot receive push webhooks
4. **Poor error messages**: Failures lack actionable guidance
5. **Config gaps**: No explicit disable, no pull subscription support
6. **Diagnostics gap**: Doctor doesn't validate Pub/Sub setup
## Solution
### Supported Deployment Patterns
| Pattern | Config | Network | Reliability |
|---------|--------|---------|-------------|
| **Push only** | `pubsub_topic` | Public IP or Tailscale funnel | High (when reachable) |
| **Pull only** | `pubsub_subscription_id` | Any | High |
| **Hybrid** ⭐ | Both | Any | Highest |
| **Polling only** | Neither | Any | Medium (5min latency) |
### New Config Fields
```yaml
automation:
gmail:
# Push notifications (watch API)
pubsub_topic: projects/<project>/topics/gmail-push
disable_push: false # Explicit disable
# Pull subscription (new)
pubsub_subscription_id: projects/<project>/subscriptions/gmail-pull
pubsub_pull_interval: "60s"
pubsub_max_messages: 10
# History API fallback
poll_interval: "300s"
```
### Enhanced Doctor Check
```bash
$ flynn doctor
✓ Gmail configured (push + pull + poll-fallback → telegram/123456789)
⚠ Gmail configured (push + poll-fallback ⚠️ push requires public endpoint or Tailscale funnel)
✓ Gmail configured (poll → telegram/123456789)
```
### Improved Error Messages
**Before**:
```
GmailWatcher: Watch setup failed (will use polling only) — Invalid topicName
```
**After**:
```
GmailWatcher: Watch setup failed (push disabled) — Invalid topicName
Tip: Set automation.gmail.pubsub_topic to "projects/your-project/topics/gmail-push"
Ensure the topic exists in GCP and the subscription is configured to POST to /gmail/push
Or set automation.gmail.pubsub_subscription_id for pull-based mode (no webhook required)
```
## Files Modified
### Core (4 files)
- `src/config/schema.ts` — Add new fields
- `src/automation/gmail.ts` — Add pull support, error hints
- `src/cli/doctor.ts` — Enhanced validation
- `package.json` — Add `@google-cloud/pubsub`
### Docs (3 files)
- `README.md` — Rewrite Gmail section
- `config/default.yaml` — Update examples
- `docs/plans/state.json` — Mark completed
### Tests (2 files)
- `src/automation/gmail.test.ts` — +7 test cases
- `src/cli/doctor.test.ts` — +5 test cases
## Implementation Checklist
- [ ] Add config schema fields (`pubsub_subscription_id`, `pubsub_pull_interval`, `pubsub_max_messages`, `disable_push`)
- [ ] Implement `setupPullSubscription()` method
- [ ] Implement `pullSubscriptionMessages()` method
- [ ] Implement `buildWatchErrorHint()` method
- [ ] Update `connect()` to handle `disable_push` and call pull setup
- [ ] Update `disconnect()` to clear pull timer
- [ ] Enhance `checkGmail` doctor check with mode detection
- [ ] Add `@google-cloud/pubsub` dependency
- [ ] Write unit tests for pull mode
- [ ] Write unit tests for `disable_push`
- [ ] Write unit tests for error hints
- [ ] Write doctor check tests
- [ ] Update README Gmail section
- [ ] Update `config/default.yaml` example
- [ ] Manual integration testing (4 modes)
- [ ] Update `state.json`
## Test Plan
### Unit Tests (12 new tests)
- Pull subscription setup (full path)
- Pull subscription setup (shorthand with project_id)
- Skip pull when not configured
- Invalid subscription path warning
- `disable_push` skips watch setup
- `disable_push` logs message
- Error hints (Invalid topicName)
- Error hints (Permission denied)
- Error hints (Not found)
- Connection logging shows modes
- Doctor: push mode detection
- Doctor: pull mode detection
- Doctor: hybrid mode detection
- Doctor: Tailscale reachability warning
### Integration Tests (Manual)
- [ ] Push only deployment
- [ ] Pull only deployment
- [ ] Hybrid deployment
- [ ] Polling only deployment
- [ ] Disable push with topic set
- [ ] Doctor check output for each mode
- [ ] Error message hints for common failures
- [ ] Tailscale warning validation
## Recommended Default
**Hybrid mode** (push + pull + polling):
- Push: ~Real-time when endpoint is reachable
- Pull: 60s latency, always works
- Poll: 300s latency, final fallback
## Estimated Effort
**Total**: 1-2 days
- Implementation: 4-6 hours
- Testing: 2-4 hours
- Documentation: 2-3 hours
## Breaking Changes
**None** — existing `pubsub_topic`-only configs continue to work.
## Benefits
1.**Clarity**: Explicit deployment patterns with setup instructions
2.**Flexibility**: Pull mode supports private deployments (no inbound connections)
3.**Reliability**: Hybrid mode ensures delivery across all network configs
4.**Debuggability**: Error hints + doctor checks catch misconfigurations
5.**Documentation**: Comprehensive README with GCP setup, troubleshooting
@@ -0,0 +1,322 @@
# Gmail Push Revisit — Quick Reference
## New Config Fields
```typescript
// src/config/schema.ts (line ~179)
const gmailSchema = z.object({
// ... existing fields ...
pubsub_subscription_id: z.string().optional(),
pubsub_pull_interval: z.string().default('60s'),
pubsub_max_messages: z.number().min(1).max(100).default(10),
disable_push: z.boolean().default(false),
});
```
## New Methods
### setupPullSubscription()
```typescript
// src/automation/gmail.ts
private async setupPullSubscription(): Promise<void>
```
- Parses `pubsub_subscription_id` (shorthand or full path)
- Validates format
- Starts interval timer with `pubsub_pull_interval`
### pullSubscriptionMessages()
```typescript
// src/automation/gmail.ts
private async pullSubscriptionMessages(): Promise<void>
```
- Uses `@google-cloud/pubsub` SDK
- Pulls up to `pubsub_max_messages`
- Calls `handlePushNotification()` for each message
- Acknowledges messages after processing
### buildWatchErrorHint()
```typescript
// src/automation/gmail.ts
private buildWatchErrorHint(errMsg: string): string
```
- Matches error patterns ("Invalid topicName", "Permission denied", "Not found")
- Returns actionable hints with gcloud commands
## Modified Methods
### connect()
```typescript
// Changes:
if (!this.config.disable_push) {
await this.setupWatch();
} else {
console.log('Push disabled');
}
await this.setupPullSubscription();
console.log(`Connected (push=${...}, pull=${...}, poll_interval=${...})`);
```
### disconnect()
```typescript
// Add:
if (this.pullSubscriptionTimer) {
clearInterval(this.pullSubscriptionTimer);
this.pullSubscriptionTimer = undefined;
}
```
## Doctor Check Enhancement
```typescript
// src/cli/doctor.ts (line ~246)
const checkGmail: Check = async (ctx) => {
// ... existing validation ...
// NEW: Mode detection
const modes: string[] = [];
const warnings: string[] = [];
if (gmail.pubsub_topic && !gmail.disable_push) {
modes.push('push');
if (ctx.config.server?.tailscale_only && !ctx.config.server?.tailscale?.funnel) {
warnings.push('push requires public endpoint or Tailscale funnel');
}
}
if (gmail.pubsub_subscription_id) {
modes.push('pull');
}
if (modes.length === 0) {
modes.push('poll');
} else {
modes.push('poll-fallback');
}
const modeStr = modes.join(' + ');
const detail = warnings.length > 0
? `${modeStr} (⚠️ ${warnings.join(', ')})`
: `${modeStr}${gmail.output.channel}/${gmail.output.peer}`;
return { status: warnings.length > 0 ? 'warn' : 'pass', label: 'Gmail configured', detail };
};
```
## Test Scenarios
### Pull Subscription
```typescript
it('should set up pull subscription with full path', async () => {
const config = {
...baseConfig,
pubsub_subscription_id: 'projects/test-project/subscriptions/gmail-pull',
};
const watcher = new GmailWatcher(config, channelLookup);
await watcher.connect();
expect(watcher['subscriptionPath']).toBe('projects/test-project/subscriptions/gmail-pull');
expect(watcher['pullSubscriptionTimer']).toBeDefined();
});
```
### Shorthand Auto-Prefix
```typescript
it('should auto-prefix subscription with project_id', async () => {
const config = {
...baseConfig,
pubsub_subscription_id: 'gmail-pull',
};
const watcher = new GmailWatcher(config, channelLookup);
await watcher.connect();
expect(watcher['subscriptionPath']).toBe('projects/test-project/subscriptions/gmail-pull');
});
```
### Disable Push
```typescript
it('should skip watch when disable_push is true', async () => {
const config = {
...baseConfig,
pubsub_topic: 'gmail-push',
disable_push: true,
};
const watcher = new GmailWatcher(config, channelLookup);
await watcher.connect();
const mockWatch = vi.mocked(google.gmail().users.watch);
expect(mockWatch).not.toHaveBeenCalled();
});
```
### Error Hints
```typescript
it('should return topic hint for Invalid topicName error', () => {
const watcher = new GmailWatcher(baseConfig, channelLookup);
const hint = watcher['buildWatchErrorHint']('Invalid topicName');
expect(hint).toContain('Set automation.gmail.pubsub_topic');
expect(hint).toContain('projects/test-project/topics/gmail-push');
});
```
### Doctor Modes
```typescript
it('should detect hybrid mode', async () => {
const config = {
...baseConfig,
automation: {
gmail: {
enabled: true,
pubsub_topic: 'gmail-push',
pubsub_subscription_id: 'gmail-pull',
output: { channel: 'telegram', peer: '123' },
},
},
};
const result = await checkGmail({ config });
expect(result.status).toBe('pass');
expect(result.detail).toContain('push + pull + poll-fallback');
});
```
## Deployment Pattern Config Examples
### Public Endpoint (Push Only)
```yaml
automation:
gmail:
enabled: true
credentials_file: ~/.config/flynn/gmail-credentials.json
pubsub_topic: projects/my-project/topics/gmail-push
output:
channel: telegram
peer: "123456789"
```
### Tailscale Funnel (Push Only)
```yaml
server:
tailscale:
serve: true
funnel: true
automation:
gmail:
enabled: true
credentials_file: ~/.config/flynn/gmail-credentials.json
pubsub_topic: gmail-push # Shorthand OK
output:
channel: telegram
peer: "123456789"
```
### Private Network (Pull Only)
```yaml
automation:
gmail:
enabled: true
credentials_file: ~/.config/flynn/gmail-credentials.json
pubsub_subscription_id: projects/my-project/subscriptions/gmail-pull
pubsub_pull_interval: "60s"
output:
channel: telegram
peer: "123456789"
```
### Hybrid (Recommended)
```yaml
automation:
gmail:
enabled: true
credentials_file: ~/.config/flynn/gmail-credentials.json
pubsub_topic: gmail-push
pubsub_subscription_id: gmail-pull
pubsub_pull_interval: "60s"
poll_interval: "300s"
output:
channel: telegram
peer: "123456789"
```
## GCP Setup Checklist
- [ ] Create project: `gcloud projects create my-flynn-project`
- [ ] Enable APIs: `gcloud services enable gmail.googleapis.com pubsub.googleapis.com`
- [ ] Create topic: `gcloud pubsub topics create gmail-push`
- [ ] Grant IAM: `gcloud pubsub topics add-iam-policy-binding ... --member=serviceAccount:gmail-api-push@system.gserviceaccount.com --role=roles/pubsub.publisher`
- [ ] Create OAuth credentials (Desktop app) via Cloud Console
- [ ] Download credentials JSON to `~/.config/flynn/gmail-credentials.json`
- [ ] Create push subscription (if using push): `gcloud pubsub subscriptions create gmail-push-sub --topic=gmail-push --push-endpoint=https://flynn.example.com/gmail/push`
- [ ] Create pull subscription (if using pull): `gcloud pubsub subscriptions create gmail-pull-sub --topic=gmail-push`
- [ ] Authenticate: `flynn gmail-auth`
- [ ] Verify: `flynn doctor`
## Debugging Commands
```bash
# Check if topic exists
gcloud pubsub topics list --filter="name:gmail-push"
# Check subscriptions
gcloud pubsub subscriptions list --filter="topic:gmail-push"
# Test pull subscription
gcloud pubsub subscriptions pull gmail-pull-sub --limit=5
# Check IAM
gcloud pubsub topics get-iam-policy projects/my-project/topics/gmail-push
# Verify Flynn config
flynn doctor
# Check Flynn logs
journalctl -u flynn -f
```
## Error Message Mapping
| Error | Hint |
|-------|------|
| `Invalid topicName` | Set `pubsub_topic` to `projects/<project>/topics/<topic>`, ensure topic exists, check subscription configuration |
| `Permission denied` / `Forbidden` | Grant Gmail API publish permission: `gcloud pubsub topics add-iam-policy-binding ...` |
| `Not found` / `404` | Create topic: `gcloud pubsub topics create gmail-push` |
| Other | Generic hint about pull subscription fallback |
## Dependencies
```json
{
"dependencies": {
"@google-cloud/pubsub": "^4.0.0"
}
}
```
## Files Modified Summary
| File | Lines Changed | New Code | Description |
|------|---------------|----------|-------------|
| `src/config/schema.ts` | +8 | 4 fields | Config schema additions |
| `src/automation/gmail.ts` | +120 | 3 methods | Pull support, error hints |
| `src/cli/doctor.ts` | +40 | Mode detection | Enhanced validation |
| `package.json` | +1 | Dependency | `@google-cloud/pubsub` |
| `README.md` | +150 | Full rewrite | Deployment patterns, setup |
| `config/default.yaml` | +15 | Comments | Example configs |
## Implementation Order
1. ✅ Config schema fields
2.`buildWatchErrorHint()` method (standalone)
3.`setupPullSubscription()` method (requires new dependency)
4.`pullSubscriptionMessages()` method
5. ✅ Update `connect()` to call pull setup and check `disable_push`
6. ✅ Update `disconnect()` to clear pull timer
7. ✅ Enhance `checkGmail` doctor check
8. ✅ Write unit tests
9. ✅ Update documentation
10. ✅ Manual integration testing
+430
View File
@@ -0,0 +1,430 @@
# OpenAI OAuth Implementation - File Changes Checklist
## Quick Summary
Add ChatGPT Plus/Pro OAuth to Flynn using device flow + Codex responses endpoint.
**Core Change**: OAuth device flow → token storage → auto-refresh → custom fetch interceptor → Codex endpoint routing.
---
## File Changes
### 1. NEW: `src/auth/openai.ts` (~300 lines)
```typescript
// Key exports:
export interface OpenAIAuthStore {
id_token: string;
access_token: string;
refresh_token: string;
expires_at: number;
account_id?: string;
}
export async function requestOpenAIDeviceCode(): Promise<DeviceAuthResponse>
export async function pollForOpenAIToken(deviceAuthId: string, userCode: string, interval: number): Promise<TokenResponse>
export async function refreshOpenAIToken(refreshToken: string): Promise<TokenResponse>
export function loadOpenAIToken(): OpenAIAuthStore | null
export function storeOpenAIToken(tokens: TokenResponse): void
export async function getOpenAIToken(): Promise<OpenAIAuthStore | null>
export async function loginOpenAI(onPrompt: (userCode: string, url: string) => void): Promise<OpenAIAuthStore>
export function extractAccountId(tokens: TokenResponse): string | undefined
export function parseJwtClaims(token: string): IdTokenClaims | undefined
```
**Constants**:
```typescript
const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
const ISSUER = 'https://auth.openai.com';
const CODEX_API_ENDPOINT = 'https://chatgpt.com/backend-api/codex/responses';
const AUTH_FILE = '~/.config/flynn/auth.json';
```
---
### 2. MODIFY: `src/auth/index.ts` (+8 lines)
```typescript
// Add exports:
export {
requestOpenAIDeviceCode,
pollForOpenAIToken,
refreshOpenAIToken,
loadOpenAIToken,
storeOpenAIToken,
getOpenAIToken,
loginOpenAI,
extractAccountId,
parseJwtClaims,
type OpenAIAuthStore,
} from './openai.js';
```
---
### 3. MODIFY: `src/models/openai.ts` (+100 lines)
#### Add to interface (line ~4-10):
```typescript
export interface OpenAIClientConfig {
apiKey?: string; // Now optional when OAuth
model: string;
maxTokens?: number;
baseURL?: string;
timeoutMs?: number;
oauth?: { // NEW
enabled: boolean;
tokenLoader?: () => Promise<OpenAIAuthStore | null>;
tokenSaver?: (tokens: OpenAIAuthStore) => Promise<void>;
};
}
```
#### Add to class (line ~55-60):
```typescript
export class OpenAIClient implements ModelClient {
private client: OpenAI;
private model: string;
private defaultMaxTokens: number;
private oauthConfig?: OpenAIClientConfig['oauth']; // NEW
constructor(config: OpenAIClientConfig) {
const timeoutMs = config.timeoutMs ?? 20_000;
this.oauthConfig = config.oauth; // NEW
this.client = new OpenAI({
apiKey: config.apiKey ?? (config.oauth?.enabled ? 'dummy-oauth-key' : undefined), // MODIFY
baseURL: config.baseURL,
timeout: timeoutMs,
maxRetries: 0,
fetch: config.oauth?.enabled ? this.createOAuthFetch() : undefined, // NEW
});
this.model = config.model;
this.defaultMaxTokens = config.maxTokens ?? 4096;
}
// NEW METHOD
private createOAuthFetch() {
return async (url: RequestInfo | URL, init?: RequestInit) => {
if (!this.oauthConfig?.tokenLoader) {
throw new Error('OAuth enabled but no token loader provided');
}
// Load + refresh token if needed
let auth = await this.oauthConfig.tokenLoader();
if (!auth || auth.expires_at < Date.now()) {
const { refreshOpenAIToken } = await import('../auth/openai.js');
if (!auth?.refresh_token) {
throw new Error('OAuth token expired - run `flynn login openai`');
}
const tokens = await refreshOpenAIToken(auth.refresh_token);
auth = {
id_token: tokens.id_token,
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: Date.now() + (tokens.expires_in ?? 3600) * 1000,
account_id: extractAccountId(tokens) || auth.account_id,
};
if (this.oauthConfig.tokenSaver) {
await this.oauthConfig.tokenSaver(auth);
}
}
// Build headers
const headers = new Headers(init?.headers);
headers.set('Authorization', `Bearer ${auth.access_token}`);
if (auth.account_id) {
headers.set('ChatGPT-Account-Id', auth.account_id);
}
headers.set('originator', 'flynn');
// Rewrite to Codex endpoint for supported models
const CODEX_API_ENDPOINT = 'https://chatgpt.com/backend-api/codex/responses';
const isCodexModel = this.model.includes('codex') ||
['gpt-5.1', 'gpt-5.2', 'gpt-5.3'].some(v => this.model.includes(v));
const targetUrl = isCodexModel ? new URL(CODEX_API_ENDPOINT) :
(typeof url === 'string' ? new URL(url) : url);
return fetch(targetUrl, { ...init, headers });
};
}
// ... rest unchanged
}
```
---
### 4. MODIFY: `src/config/schema.ts` (+1 line)
Line ~46-56:
```typescript
const modelConfigBaseSchema = z.object({
provider: z.enum(MODEL_PROVIDERS),
model: z.string(),
endpoint: z.string().optional(),
api_key: z.string().optional(),
auth_token: z.string().optional(),
oauth_enabled: z.boolean().optional(), // NEW
for: z.array(z.string()).optional(),
num_gpu: z.number().optional(),
context_window: z.number().optional(),
supports_audio: z.boolean().optional(),
});
```
---
### 5. MODIFY: `src/daemon/models.ts` (+15 lines)
Line ~51-55 (openai case):
```typescript
case 'openai':
return new OpenAIClient({
model: cfg.model,
apiKey: cfg.api_key,
oauth: cfg.oauth_enabled ? { // NEW
enabled: true,
tokenLoader: async () => {
const { getOpenAIToken } = await import('../auth/openai.js');
return getOpenAIToken();
},
tokenSaver: async (tokens) => {
const { storeOpenAIToken } = await import('../auth/openai.js');
storeOpenAIToken(tokens);
},
} : undefined,
});
```
---
### 6. NEW: `src/cli/commands/login.ts` (~80 lines)
```typescript
import { Command } from 'commander';
import { loginOpenAI } from '../../auth/openai.js';
import { loginGitHub } from '../../auth/github.js';
export function createLoginCommand(): Command {
const cmd = new Command('login')
.description('Authenticate with external services');
cmd
.command('openai')
.description('Authenticate with OpenAI (ChatGPT Plus/Pro)')
.action(async () => {
console.log('Starting OpenAI OAuth login...\n');
try {
const auth = await loginOpenAI((userCode, url) => {
console.log(`\nVisit: ${url}`);
console.log(`Enter code: ${userCode}\n`);
console.log('Waiting for authorization...');
});
console.log('\n✓ Successfully authenticated!');
if (auth.account_id) {
console.log(` Account ID: ${auth.account_id}`);
}
console.log('\nUpdate config.yaml:');
console.log(' models.default.oauth_enabled: true');
} catch (error) {
console.error('Login failed:', error instanceof Error ? error.message : error);
process.exit(1);
}
});
cmd
.command('github')
.description('Authenticate with GitHub Copilot')
.action(async () => {
try {
await loginGitHub((userCode, verificationUri) => {
console.log(`\nVisit: ${verificationUri}`);
console.log(`Enter code: ${userCode}\n`);
});
console.log('✓ GitHub authentication successful!');
} catch (error) {
console.error('Login failed:', error instanceof Error ? error.message : error);
process.exit(1);
}
});
return cmd;
}
```
---
### 7. MODIFY: `src/cli/index.ts` (+2 lines)
Add import:
```typescript
import { createLoginCommand } from './commands/login.js';
```
Register command (after other commands):
```typescript
program.addCommand(createLoginCommand());
```
---
### 8. NEW: `src/auth/openai.test.ts` (~150 lines)
```typescript
import { describe, test, expect } from 'vitest';
import { parseJwtClaims, extractAccountId } from './openai.js';
function createTestJwt(payload: object): string {
const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url');
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
return `${header}.${body}.sig`;
}
describe('parseJwtClaims', () => {
test('parses valid JWT', () => {
const token = createTestJwt({ email: 'test@example.com' });
const claims = parseJwtClaims(token);
expect(claims).toMatchObject({ email: 'test@example.com' });
});
test('returns undefined for invalid JWT', () => {
expect(parseJwtClaims('invalid')).toBeUndefined();
expect(parseJwtClaims('only.two')).toBeUndefined();
});
});
describe('extractAccountId', () => {
test('extracts from chatgpt_account_id', () => {
const tokens = {
id_token: createTestJwt({ chatgpt_account_id: 'acc-123' }),
access_token: '',
refresh_token: '',
};
expect(extractAccountId(tokens)).toBe('acc-123');
});
test('extracts from organizations', () => {
const tokens = {
id_token: createTestJwt({ organizations: [{ id: 'org-123' }] }),
access_token: '',
refresh_token: '',
};
expect(extractAccountId(tokens)).toBe('org-123');
});
test('returns undefined when no account found', () => {
const tokens = {
id_token: createTestJwt({ email: 'test@example.com' }),
access_token: '',
refresh_token: '',
};
expect(extractAccountId(tokens)).toBeUndefined();
});
});
```
---
## Usage Flow
### 1. Login
```bash
flynn login openai
# Output:
# Visit: https://auth.openai.com/codex/device
# Enter code: ABCD-1234
#
# ✓ Successfully authenticated!
# Account ID: org-xyz
#
# Update config.yaml:
# models.default.oauth_enabled: true
```
### 2. Update Config
```yaml
# config.yaml
models:
default:
provider: openai
model: gpt-5.2-codex # or gpt-5.3-codex, gpt-5.1-codex-max, etc.
oauth_enabled: true
```
### 3. Start Daemon
```bash
flynn start
# OAuth token loaded automatically
# Requests route to Codex endpoint
# Token auto-refreshes when expired
```
---
## Testing Commands
```bash
# Build
pnpm build
# Unit tests
pnpm test src/auth/openai.test.ts
# Manual integration test
flynn login openai
flynn start --config /tmp/test-oauth.yaml
# Send message via Telegram/TUI
```
---
## API Endpoints Used
| Step | Endpoint | Method |
|------|----------|--------|
| 1. Device code | `https://auth.openai.com/api/accounts/deviceauth/usercode` | POST |
| 2. Poll auth | `https://auth.openai.com/api/accounts/deviceauth/token` | POST |
| 3. Exchange token | `https://auth.openai.com/oauth/token` | POST |
| 4. Refresh token | `https://auth.openai.com/oauth/token` | POST |
| 5. Codex request | `https://chatgpt.com/backend-api/codex/responses` | POST |
---
## Error Handling
```typescript
// Token expired + no refresh
throw new Error('OAuth token expired - run `flynn login openai`');
// Subscription lapsed
// OpenAI returns 403 with body: { detail: "subscription_required" }
// Invalid model
// OpenAI returns 404 if model not available for user tier
```
---
## Rollout Checklist
- [ ] Implement `src/auth/openai.ts`
- [ ] Add unit tests (JWT parsing, account extraction)
- [ ] Update `src/models/openai.ts` with OAuth support
- [ ] Update config schema
- [ ] Update model factory
- [ ] Implement `flynn login openai` command
- [ ] Manual test with real ChatGPT Plus account
- [ ] Document in README.md
- [ ] Commit with message: "feat: add OpenAI OAuth support for ChatGPT Plus/Pro"
---
## Success Metrics
✅ User authenticates with `flynn login openai`
✅ Token persists in `~/.config/flynn/auth.json`
✅ Daemon starts with `oauth_enabled: true`
✅ Requests succeed using Codex endpoint
✅ Token auto-refreshes on expiry
✅ Graceful error on subscription lapse
+809
View File
@@ -0,0 +1,809 @@
# OpenAI OAuth Implementation Plan
## Executive Summary
Add ChatGPT Plus/Pro OAuth authentication to Flynn, enabling use of OpenAI models (including Codex) via OAuth tokens instead of API keys. This mimics OpenCode's `CodexAuthPlugin` pattern.
**Minimal Viable Approach**: Implement OAuth device flow + token refresh + Codex responses endpoint routing.
---
## Design Overview
### Key Components
1. **OAuth Module** (`src/auth/openai.ts`) - Device flow + token management
2. **OpenAI Client Enhancement** (`src/models/openai.ts`) - OAuth token support
3. **Config Schema Update** (`src/config/schema.ts`) - OAuth config fields
4. **CLI Command** (`src/cli/commands/login.ts`) - Interactive login
5. **Testing Strategy** - Unit tests + integration tests
### API Surfaces
```typescript
// src/auth/openai.ts
export interface OpenAIAuthStore {
id_token: string;
access_token: string;
refresh_token: string;
expires_at: number;
account_id?: string;
}
export interface DeviceAuthResponse {
device_auth_id: string;
user_code: string;
interval: string;
}
export function requestOpenAIDeviceCode(): Promise<DeviceAuthResponse>
export function pollForOpenAIToken(deviceAuthId: string, userCode: string, interval: number): Promise<TokenResponse>
export function refreshOpenAIToken(refreshToken: string): Promise<TokenResponse>
export function loadOpenAIToken(): OpenAIAuthStore | null
export function storeOpenAIToken(tokens: TokenResponse): void
export function getOpenAIToken(): Promise<OpenAIAuthStore | null>
export function loginOpenAI(onPrompt: (userCode: string, url: string) => void): Promise<OpenAIAuthStore>
// src/models/openai.ts - Enhanced config
export interface OpenAIClientConfig {
apiKey?: string;
model: string;
maxTokens?: number;
baseURL?: string;
timeoutMs?: number;
oauth?: {
enabled: boolean;
tokenLoader?: () => Promise<OpenAIAuthStore | null>;
tokenSaver?: (tokens: OpenAIAuthStore) => Promise<void>;
};
}
```
---
## Implementation Details
### 1. OAuth Module (`src/auth/openai.ts`)
**Purpose**: Handle OAuth device flow for ChatGPT Plus/Pro authentication.
**Key Constants**:
```typescript
const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
const ISSUER = 'https://auth.openai.com';
const CODEX_API_ENDPOINT = 'https://chatgpt.com/backend-api/codex/responses';
```
**Core Functions**:
#### a. Device Flow Initiation
```typescript
async function requestOpenAIDeviceCode(): Promise<DeviceAuthResponse> {
// POST to https://auth.openai.com/api/accounts/deviceauth/usercode
// Body: { client_id: CLIENT_ID }
// Returns: { device_auth_id, user_code, interval }
}
```
#### b. Token Polling
```typescript
async function pollForOpenAIToken(
deviceAuthId: string,
userCode: string,
interval: number
): Promise<TokenResponse> {
// Poll https://auth.openai.com/api/accounts/deviceauth/token
// Body: { device_auth_id, user_code }
// On success, exchange authorization_code for tokens via /oauth/token
}
```
#### c. Token Refresh
```typescript
async function refreshOpenAIToken(refreshToken: string): Promise<TokenResponse> {
// POST https://auth.openai.com/oauth/token
// Body: {
// grant_type: 'refresh_token',
// refresh_token: refreshToken,
// client_id: CLIENT_ID
// }
}
```
#### d. Token Storage
```typescript
// Storage path: ~/.config/flynn/auth.json
interface AuthStore {
github?: { ... };
openai?: {
id_token: string;
access_token: string;
refresh_token: string;
expires_at: number;
account_id?: string;
};
}
```
#### e. Account ID Extraction
```typescript
function extractAccountId(tokens: TokenResponse): string | undefined {
// Parse JWT claims from id_token or access_token
// Priority:
// 1. claims.chatgpt_account_id
// 2. claims['https://api.openai.com/auth'].chatgpt_account_id
// 3. claims.organizations[0].id
}
```
**File Structure**:
```
src/auth/
├── github.ts # Existing
├── openai.ts # NEW - OAuth device flow
└── index.ts # Export openai.ts exports
```
---
### 2. OpenAI Client Enhancement (`src/models/openai.ts`)
**Changes**:
#### a. Config Extension
```typescript
export interface OpenAIClientConfig {
apiKey?: string; // Now optional when OAuth is used
model: string;
maxTokens?: number;
baseURL?: string;
timeoutMs?: number;
oauth?: {
enabled: boolean;
tokenLoader?: () => Promise<OpenAIAuthStore | null>;
tokenSaver?: (tokens: OpenAIAuthStore) => Promise<void>;
};
}
```
#### b. Client Constructor Changes
```typescript
constructor(config: OpenAIClientConfig) {
this.oauthConfig = config.oauth;
this.client = new OpenAI({
apiKey: config.apiKey ?? 'dummy-key-for-oauth', // OpenAI SDK requires this
baseURL: config.baseURL,
timeout: config.timeoutMs ?? 20_000,
maxRetries: 0,
fetch: this.oauthConfig?.enabled ? this.createOAuthFetch() : undefined,
});
// ...
}
```
#### c. Custom Fetch for OAuth
```typescript
private createOAuthFetch() {
return async (url: RequestInfo | URL, init?: RequestInit) => {
if (!this.oauthConfig?.tokenLoader) {
throw new Error('OAuth enabled but no token loader provided');
}
// Load token
let auth = await this.oauthConfig.tokenLoader();
// Refresh if expired
if (!auth || auth.expires_at < Date.now()) {
if (!auth?.refresh_token) {
throw new Error('OAuth token expired and no refresh token available');
}
const tokens = await refreshOpenAIToken(auth.refresh_token);
auth = {
id_token: tokens.id_token,
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: Date.now() + (tokens.expires_in ?? 3600) * 1000,
account_id: extractAccountId(tokens) || auth.account_id,
};
if (this.oauthConfig.tokenSaver) {
await this.oauthConfig.tokenSaver(auth);
}
}
// Build headers
const headers = new Headers(init?.headers);
headers.set('Authorization', `Bearer ${auth.access_token}`);
if (auth.account_id) {
headers.set('ChatGPT-Account-Id', auth.account_id);
}
headers.set('originator', 'flynn');
// Rewrite URL to Codex endpoint for supported models
const isCodexModel = this.model.includes('codex') ||
['gpt-5.1', 'gpt-5.2', 'gpt-5.3'].some(v => this.model.includes(v));
const targetUrl = isCodexModel ?
new URL(CODEX_API_ENDPOINT) :
(typeof url === 'string' ? new URL(url) : url);
return fetch(targetUrl, { ...init, headers });
};
}
```
---
### 3. Config Schema Update (`src/config/schema.ts`)
**Changes**:
```typescript
// Line ~46-56: Enhance modelConfigBaseSchema
const modelConfigBaseSchema = z.object({
provider: z.enum(MODEL_PROVIDERS),
model: z.string(),
endpoint: z.string().optional(),
api_key: z.string().optional(),
auth_token: z.string().optional(),
oauth_enabled: z.boolean().optional(), // NEW
for: z.array(z.string()).optional(),
num_gpu: z.number().optional(),
context_window: z.number().optional(),
supports_audio: z.boolean().optional(),
});
```
**Example Config**:
```yaml
models:
default:
provider: openai
model: gpt-5.2-codex
oauth_enabled: true # Use OAuth instead of API key
```
---
### 4. Model Factory Update (`src/daemon/models.ts`)
**Changes**:
```typescript
// Line ~51-55: createClientFromConfig() openai case
case 'openai':
return new OpenAIClient({
model: cfg.model,
apiKey: cfg.api_key,
oauth: cfg.oauth_enabled ? {
enabled: true,
tokenLoader: async () => {
const { getOpenAIToken } = await import('../auth/openai.js');
return getOpenAIToken();
},
tokenSaver: async (tokens) => {
const { storeOpenAIToken } = await import('../auth/openai.js');
storeOpenAIToken(tokens);
},
} : undefined,
});
```
---
### 5. CLI Command (`src/cli/commands/login.ts`)
**New File**:
```typescript
import { Command } from 'commander';
import { loginOpenAI } from '../../auth/openai.js';
export function createLoginCommand(): Command {
const cmd = new Command('login');
cmd
.command('openai')
.description('Authenticate with OpenAI using ChatGPT Plus/Pro account')
.action(async () => {
console.log('Starting OpenAI OAuth login...\n');
try {
const auth = await loginOpenAI((userCode, url) => {
console.log(`\nPlease visit: ${url}`);
console.log(`Enter code: ${userCode}\n`);
console.log('Waiting for authorization...');
});
console.log('\n✓ Successfully authenticated with OpenAI!');
if (auth.account_id) {
console.log(` Account ID: ${auth.account_id}`);
}
console.log('\nYou can now use OpenAI models with oauth_enabled: true in your config.');
} catch (error) {
console.error('Login failed:', error instanceof Error ? error.message : error);
process.exit(1);
}
});
cmd
.command('github')
.description('Authenticate with GitHub Copilot')
.action(async () => {
const { loginGitHub } = await import('../../auth/github.js');
// ... existing GitHub login logic
});
return cmd;
}
```
**Register in** `src/cli/index.ts`:
```typescript
import { createLoginCommand } from './commands/login.js';
// ...
program.addCommand(createLoginCommand());
```
---
## Exact Flow Steps
### User Authentication Flow
```
1. User runs: flynn login openai
2. CLI requests device code from OpenAI:
POST https://auth.openai.com/api/accounts/deviceauth/usercode
Body: { client_id: 'app_EMoamEEZ73f0CkXaXp7hrann' }
3. OpenAI returns:
{
device_auth_id: "uuid-v4",
user_code: "ABCD-1234",
interval: "5"
}
4. CLI displays:
"Visit: https://auth.openai.com/codex/device"
"Enter code: ABCD-1234"
5. CLI polls for token:
POST https://auth.openai.com/api/accounts/deviceauth/token
Body: { device_auth_id, user_code }
Every 5 seconds (+ 3s safety margin)
6. When user authorizes, OpenAI returns:
{
authorization_code: "auth-code-xyz",
code_verifier: "pkce-verifier"
}
7. CLI exchanges code for tokens:
POST https://auth.openai.com/oauth/token
Body: {
grant_type: 'authorization_code',
code: authorization_code,
redirect_uri: 'https://auth.openai.com/deviceauth/callback',
client_id: CLIENT_ID,
code_verifier: code_verifier
}
8. OpenAI returns tokens:
{
id_token: "jwt-id-token",
access_token: "jwt-access-token",
refresh_token: "refresh-token",
expires_in: 3600
}
9. CLI extracts account_id from JWT claims
10. CLI stores to ~/.config/flynn/auth.json:
{
openai: {
id_token: "...",
access_token: "...",
refresh_token: "...",
expires_at: 1739123456789,
account_id: "org-xxx"
}
}
11. User updates config.yaml:
models:
default:
provider: openai
model: gpt-5.2-codex
oauth_enabled: true
12. Flynn daemon starts, creates OpenAI client with OAuth enabled
```
### Request Flow (OAuth Mode)
```
1. User sends message via Telegram/TUI
2. Agent calls modelRouter.chat(request)
3. OpenAIClient.chat() invoked
4. Custom fetch interceptor:
a. Load token from ~/.config/flynn/auth.json
b. Check if expires_at < Date.now()
c. If expired:
- POST refresh token to /oauth/token
- Update stored token
d. Set headers:
- Authorization: Bearer {access_token}
- ChatGPT-Account-Id: {account_id}
- originator: flynn
e. Rewrite URL to Codex endpoint if model includes 'codex'
f. Execute fetch with modified request
5. OpenAI returns response (free for ChatGPT Plus/Pro subscribers)
6. Response parsed and returned to agent
```
---
## File Changes Summary
### New Files
```
src/auth/openai.ts # OAuth device flow (300 lines)
src/cli/commands/login.ts # Login command (80 lines)
docs/plans/openai-oauth-implementation.md # This document
```
### Modified Files
```
src/auth/index.ts # Export openai.ts functions (10 lines changed)
src/models/openai.ts # Add OAuth support (150 lines changed)
src/config/schema.ts # Add oauth_enabled field (5 lines changed)
src/daemon/models.ts # Add OAuth tokenLoader/Saver (15 lines changed)
src/cli/index.ts # Register login command (3 lines changed)
```
### Test Files (New)
```
src/auth/openai.test.ts # Unit tests for OAuth flow
src/models/openai.oauth.test.ts # Integration tests for OAuth client
```
---
## Testing Strategy
### Unit Tests
#### 1. JWT Parsing (`src/auth/openai.test.ts`)
```typescript
describe('parseJwtClaims', () => {
test('parses valid JWT', () => {
const token = createTestJwt({ email: 'test@example.com' });
expect(parseJwtClaims(token)).toMatchObject({ email: 'test@example.com' });
});
test('returns undefined for invalid JWT', () => {
expect(parseJwtClaims('invalid')).toBeUndefined();
});
});
describe('extractAccountId', () => {
test('extracts from chatgpt_account_id', () => {
const tokens = {
id_token: createTestJwt({ chatgpt_account_id: 'acc-123' }),
access_token: '',
refresh_token: '',
};
expect(extractAccountId(tokens)).toBe('acc-123');
});
test('extracts from organizations array', () => {
const tokens = {
id_token: createTestJwt({ organizations: [{ id: 'org-123' }] }),
access_token: '',
refresh_token: '',
};
expect(extractAccountId(tokens)).toBe('org-123');
});
});
```
#### 2. Token Refresh Logic (`src/models/openai.oauth.test.ts`)
```typescript
describe('OpenAIClient OAuth', () => {
test('refreshes expired token before request', async () => {
const expiredAuth = {
access_token: 'expired',
refresh_token: 'valid-refresh',
expires_at: Date.now() - 1000, // Expired
};
const client = new OpenAIClient({
model: 'gpt-5.2-codex',
oauth: {
enabled: true,
tokenLoader: async () => expiredAuth,
tokenSaver: vi.fn(),
},
});
// Mock refreshOpenAIToken
vi.mock('../auth/openai.js', () => ({
refreshOpenAIToken: vi.fn().mockResolvedValue({
access_token: 'new-access',
refresh_token: 'valid-refresh',
expires_in: 3600,
}),
}));
await client.chat({
messages: [{ role: 'user', content: 'test' }],
});
expect(refreshOpenAIToken).toHaveBeenCalledWith('valid-refresh');
});
});
```
### Integration Tests
#### 3. End-to-End OAuth Flow (Manual)
```bash
# 1. Run login command
flynn login openai
# 2. Verify token stored
cat ~/.config/flynn/auth.json | jq '.openai'
# 3. Start daemon with OAuth config
cat > /tmp/flynn-oauth-test.yaml <<EOF
models:
default:
provider: openai
model: gpt-5.2-codex
oauth_enabled: true
telegram:
bot_token: \${TELEGRAM_BOT_TOKEN}
allowed_chat_ids: [123456789]
EOF
flynn start --config /tmp/flynn-oauth-test.yaml
# 4. Send test message and verify response
```
### Mock Testing Setup
```typescript
// test/mocks/openai-oauth.ts
export function mockOpenAIAuthEndpoints() {
return {
deviceCode: vi.fn().mockResolvedValue({
device_auth_id: 'test-device-id',
user_code: 'ABCD-1234',
interval: '1',
}),
deviceToken: vi.fn().mockResolvedValue({
authorization_code: 'test-auth-code',
code_verifier: 'test-verifier',
}),
exchangeToken: vi.fn().mockResolvedValue({
id_token: createTestJwt({ chatgpt_account_id: 'acc-test' }),
access_token: createTestJwt({ sub: 'user-123' }),
refresh_token: 'test-refresh',
expires_in: 3600,
}),
refreshToken: vi.fn().mockResolvedValue({
access_token: 'new-access-token',
refresh_token: 'test-refresh',
expires_in: 3600,
}),
};
}
```
---
## Migration & Rollout
### Phase 1: Core Implementation (Week 1)
- [ ] Implement `src/auth/openai.ts` with device flow
- [ ] Add unit tests for JWT parsing and account ID extraction
- [ ] Update `src/models/openai.ts` with OAuth support
- [ ] Add mock-based tests for OAuth client
### Phase 2: CLI & Config (Week 2)
- [ ] Implement `flynn login openai` command
- [ ] Update config schema for `oauth_enabled`
- [ ] Update daemon model factory
- [ ] Manual end-to-end testing
### Phase 3: Documentation & Polish (Week 3)
- [ ] Add user documentation (USAGE.md)
- [ ] Add examples to README.md
- [ ] Error handling improvements (expired subscriptions, network failures)
- [ ] Add token status command: `flynn auth status`
---
## Known Limitations & Future Enhancements
### Current Limitations
1. **No browser-based OAuth flow** - Only device flow (headless-friendly)
2. **No automatic subscription verification** - Assumes user has ChatGPT Plus/Pro
3. **Single account support** - No multi-account switching
### Future Enhancements
1. **Browser OAuth flow** - Add local HTTP server for callback (like OpenCode)
2. **Subscription checker** - Verify user has Plus/Pro before allowing config
3. **Token rotation** - Automatic background refresh before expiry
4. **Multi-account** - Support multiple OpenAI accounts with profiles
5. **TUI integration** - In-app login flow instead of separate CLI command
---
## Security Considerations
1. **Token Storage**:
- Store in `~/.config/flynn/auth.json` with `chmod 600`
- Never log access tokens in debug output
- Clear tokens on logout: `flynn logout openai`
2. **Token Refresh**:
- Refresh tokens valid for ~6 months (OpenAI policy)
- Handle refresh failures gracefully (prompt re-login)
3. **Rate Limiting**:
- ChatGPT Plus/Pro has usage limits (not documented publicly)
- Implement exponential backoff on 429 responses
4. **PKCE Verification**:
- Device flow includes code_verifier for PKCE
- Prevent CSRF by validating state (future browser flow)
---
## Dependencies
**No new dependencies required** - Uses existing:
- `openai` (already in package.json)
- Native `fetch` API (Node.js >=18)
- Native `crypto` API for JWT parsing
---
## Success Criteria
1. ✅ User can run `flynn login openai` and authenticate
2. ✅ Token persists across daemon restarts
3. ✅ Expired tokens refresh automatically
4. ✅ OpenAI Codex models work via OAuth (free for Plus/Pro users)
5. ✅ Fallback to API key mode if `oauth_enabled: false`
6. ✅ Error messages guide users to fix auth issues
---
## API Endpoint Reference
### OpenAI OAuth Endpoints
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `https://auth.openai.com/api/accounts/deviceauth/usercode` | POST | Initiate device flow |
| `https://auth.openai.com/api/accounts/deviceauth/token` | POST | Poll for authorization |
| `https://auth.openai.com/oauth/token` | POST | Exchange code / refresh token |
| `https://chatgpt.com/backend-api/codex/responses` | POST | Codex completions endpoint |
### Request/Response Formats
#### Device Code Request
```json
POST /api/accounts/deviceauth/usercode
{
"client_id": "app_EMoamEEZ73f0CkXaXp7hrann"
}
```
**Response**:
```json
{
"device_auth_id": "uuid-v4",
"user_code": "ABCD-1234",
"interval": "5"
}
```
#### Token Polling Request
```json
POST /api/accounts/deviceauth/token
{
"device_auth_id": "uuid-v4",
"user_code": "ABCD-1234"
}
```
**Response (pending)**:
```json
{ "status": 403 } // Keep polling
```
**Response (authorized)**:
```json
{
"authorization_code": "auth-code-xyz",
"code_verifier": "pkce-verifier"
}
```
#### Token Exchange Request
```json
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=auth-code-xyz
&redirect_uri=https://auth.openai.com/deviceauth/callback
&client_id=app_EMoamEEZ73f0CkXaXp7hrann
&code_verifier=pkce-verifier
```
**Response**:
```json
{
"id_token": "eyJhbGc...",
"access_token": "eyJhbGc...",
"refresh_token": "refresh-token-xyz",
"expires_in": 3600,
"token_type": "Bearer"
}
```
#### Refresh Token Request
```json
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token
&refresh_token=refresh-token-xyz
&client_id=app_EMoamEEZ73f0CkXaXp7hrann
```
**Response**: Same as token exchange
#### Codex Request
```json
POST /backend-api/codex/responses
Authorization: Bearer {access_token}
ChatGPT-Account-Id: {account_id}
originator: flynn
{
"model": "gpt-5.2-codex",
"messages": [...],
"max_tokens": 4096
}
```
---
## Conclusion
This plan provides a **minimal, production-ready** OAuth implementation for Flynn, closely modeled after OpenCode's proven `CodexAuthPlugin`. The device flow approach is headless-friendly and aligns with Flynn's daemon architecture.
**Total implementation effort**: ~600 lines of new code + ~200 lines of modifications + comprehensive tests.
**Key advantages**:
- Free ChatGPT Plus/Pro model access (no API costs)
- Proven OAuth flow (matches OpenCode)
- Clean separation of concerns (auth module + client enhancement)
- Backward compatible (API key mode still works)
**Next steps**:
1. Review this plan
2. Implement Phase 1 (core OAuth + tests)
3. Test with real ChatGPT Plus account
4. Iterate on UX and error handling
+192
View File
@@ -0,0 +1,192 @@
# OpenAI OAuth Implementation Summary
## Goal
Enable Flynn to use OpenAI models (Codex) via ChatGPT Plus/Pro OAuth tokens instead of API keys.
## Minimal Viable Approach
**Device Flow + Token Refresh + Codex Endpoint Routing**
Based on OpenCode's proven `CodexAuthPlugin` implementation.
---
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ User: flynn login openai │
└────────────────┬────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 1. Device Flow (src/auth/openai.ts) │
│ - Request device code from auth.openai.com │
│ - Display user_code + URL │
│ - Poll for authorization │
│ - Exchange code for tokens (access, refresh, id) │
│ - Extract account_id from JWT claims │
│ - Store to ~/.config/flynn/auth.json (chmod 600) │
└────────────────┬────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 2. Config Update (config.yaml) │
│ models: │
│ default: │
│ provider: openai │
│ model: gpt-5.2-codex │
│ oauth_enabled: true ← NEW │
└────────────────┬────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 3. Daemon Startup (src/daemon/models.ts) │
│ - createClientFromConfig() detects oauth_enabled │
│ - Passes tokenLoader + tokenSaver callbacks │
│ - OpenAIClient created with OAuth mode │
└────────────────┬────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 4. Chat Request (src/models/openai.ts) │
│ - Custom fetch() interceptor checks token expiry │
│ - Auto-refresh if needed via refreshOpenAIToken() │
│ - Set headers: │
│ * Authorization: Bearer {access_token} │
│ * ChatGPT-Account-Id: {account_id} │
│ * originator: flynn │
│ - Rewrite URL to Codex endpoint for codex/gpt-5.x models │
│ - Execute request │
└─────────────────────────────────────────────────────────────────┘
```
---
## File Changes
| File | Type | Lines | Purpose |
|------|------|-------|---------|
| `src/auth/openai.ts` | **NEW** | ~300 | Device flow + token management |
| `src/auth/index.ts` | MODIFY | +8 | Export openai.ts functions |
| `src/models/openai.ts` | MODIFY | +100 | OAuth support + custom fetch |
| `src/config/schema.ts` | MODIFY | +1 | Add `oauth_enabled` field |
| `src/daemon/models.ts` | MODIFY | +15 | Wire up OAuth callbacks |
| `src/cli/commands/login.ts` | **NEW** | ~80 | `flynn login openai` command |
| `src/cli/index.ts` | MODIFY | +2 | Register login command |
| `src/auth/openai.test.ts` | **NEW** | ~150 | Unit tests |
**Total**: ~650 lines of new code + ~130 lines of modifications.
---
## API Flow
### 1. Login Flow
```
flynn login openai
├─► POST https://auth.openai.com/api/accounts/deviceauth/usercode
│ Body: { client_id: "app_EMoamEEZ73f0CkXaXp7hrann" }
│ Response: { device_auth_id, user_code, interval }
├─► Display: "Visit https://auth.openai.com/codex/device"
│ "Enter code: ABCD-1234"
├─► Poll POST https://auth.openai.com/api/accounts/deviceauth/token
│ Body: { device_auth_id, user_code }
│ Response (authorized): { authorization_code, code_verifier }
├─► POST https://auth.openai.com/oauth/token
│ Body: { grant_type: authorization_code, code, code_verifier, ... }
│ Response: { id_token, access_token, refresh_token, expires_in }
├─► Parse JWT to extract account_id
└─► Save to ~/.config/flynn/auth.json
```
### 2. Request Flow
```
User message via Telegram/TUI
├─► modelRouter.chat(request)
├─► OpenAIClient.chat()
├─► Custom fetch() interceptor:
│ ├─► Load token from auth.json
│ ├─► Check expires_at < Date.now()
│ ├─► If expired: POST refresh_token to /oauth/token
│ ├─► Set Authorization header
│ ├─► Rewrite URL to Codex endpoint
│ └─► fetch(codex_url, { headers })
└─► Response returned to agent
```
---
## Key Endpoints
| Purpose | Endpoint |
|---------|----------|
| Device code | `https://auth.openai.com/api/accounts/deviceauth/usercode` |
| Poll auth | `https://auth.openai.com/api/accounts/deviceauth/token` |
| Token exchange | `https://auth.openai.com/oauth/token` |
| Token refresh | `https://auth.openai.com/oauth/token` |
| Codex API | `https://chatgpt.com/backend-api/codex/responses` |
---
## Configuration
### Before (API Key)
```yaml
models:
default:
provider: openai
model: gpt-4
api_key: sk-proj-...
```
### After (OAuth)
```yaml
models:
default:
provider: openai
model: gpt-5.2-codex # or gpt-5.3-codex, gpt-5.1-codex-max
oauth_enabled: true
```
---
## Testing Strategy
### Unit Tests
```bash
pnpm test src/auth/openai.test.ts
```
Tests:
- JWT parsing (valid/invalid)
- Account ID extraction (multiple claim locations)
- Token storage/loading
### Manual Integration Test
```bash
# 1. Login
flynn login openai
# 2. Verify token stored
cat ~/.config/flynn/auth.json | jq '.openai'
# 3. Create test config
cat > /tmp/flynn-oauth-test.yaml <<EOF
models:
default:
provider: openai
model: gpt-5.2-codex
oauth_enabled: true
telegram:
bot_token: \${TELEGRAM_BOT_TOKEN}
allowed_chat_ids: [123456789]
+77 -1
View File
@@ -11,6 +11,33 @@
"updated": "2026-02-07",
"summary": "Comprehensive comparison of Flynn vs OpenClaw. 116 features compared: 75 match (65%), 2 partial (2%), 38 missing (33%). Updated 2026-02-07 after full codebase audit revealed 33+ features previously marked MISSING were actually implemented."
},
"tui-model-switch-strict-tier-fix": {
"status": "completed",
"date": "2026-02-13",
"summary": "Fixed minimal TUI model switching for provider/model overrides by reusing provider credentials, syncing agent tier with router tier, enabling strict-tier mode for explicit overrides so fallback chains do not silently route requests to local models, and adding Zhipu OAuth token credential support (auth_token + ZHIPUAI_AUTH_TOKEN).",
"files_modified": [
"src/frontends/tui/minimal.ts",
"src/frontends/tui/minimal.test.ts",
"src/frontends/tui/fullscreen.ts",
"src/frontends/tui/components/App.tsx",
"src/cli/tui.ts",
"src/daemon/models.ts",
"src/daemon/clientFactory.test.ts",
"src/models/router.ts",
"src/models/router.test.ts",
"src/models/openai.ts",
"src/models/openai.test.ts",
"src/models/retry.ts",
"src/models/retry.test.ts",
"src/hooks/engine.ts",
"src/hooks/engine.test.ts",
"src/cli/shared.ts",
"src/cli/shared.test.ts",
"src/models/local/llamacpp.ts",
"src/models/local/llamacpp.test.ts"
],
"test_status": "pnpm test:run (targeted suites) + pnpm typecheck passing"
},
"p0-p1-implementation-plan": {
"file": "2026-02-06-p0-p1-implementation-plan.md",
"status": "completed",
@@ -694,6 +721,55 @@
}
}
},
"gmail-push-revisit": {
"status": "completed",
"date": "2026-02-13",
"summary": "Hardened Gmail watcher operational model: explicit push disable flag, added Pub/Sub pull subscription support (no inbound webhook), improved watch error hints, enhanced doctor diagnostics (mode detection + push viability warning for Tailnet-only), and updated docs/examples.",
"files_created": [
"docs/plans/2026-02-13-gmail-push-revisit-implementation-plan.md",
"docs/plans/2026-02-13-gmail-push-revisit-summary.md",
"docs/plans/2026-02-13-gmail-deployment-patterns.md",
"docs/plans/2026-02-13-gmail-quick-reference.md"
],
"files_modified": [
"src/config/schema.ts",
"src/automation/gmail.ts",
"src/automation/gmail.test.ts",
"src/cli/doctor.ts",
"src/cli/doctor.test.ts",
"src/tools/builtin/gmail.test.ts",
"README.md",
"config/default.yaml",
"package.json",
"pnpm-lock.yaml"
],
"new_dependencies": ["@google-cloud/pubsub"],
"test_status": "pnpm typecheck + pnpm test:run passing"
},
"openai-oauth-codex": {
"status": "completed",
"date": "2026-02-13",
"summary": "Added OpenAI OAuth (ChatGPT Plus/Pro) device-flow auth and Codex backend support for the OpenAI provider (SSE-based /backend-api/codex/responses). Supports `flynn openai-auth`, `use_oauth: true` in model config, and `/login openai` in the minimal TUI.",
"files_created": [
"src/auth/openai.ts",
"src/auth/openai.test.ts",
"src/cli/openai-auth.ts",
"src/models/openai.oauth.test.ts"
],
"files_modified": [
"src/auth/index.ts",
"src/models/openai.ts",
"src/daemon/models.ts",
"src/config/schema.ts",
"src/cli/index.ts",
"src/cli/doctor.ts",
"src/frontends/tui/minimal.ts",
"src/frontends/tui/commands.ts"
],
"test_status": "pnpm typecheck + pnpm test:run passing"
},
"runtime-context-awareness": {
"status": "completed",
"date": "2026-02-07",
@@ -1751,7 +1827,7 @@
},
"overall_progress": {
"total_test_count": 1597,
"total_test_count": 1617,
"all_tests_passing": true,
"p0_completion": "3/3 (100%)",
"p1_completion": "4/4 (100%)",