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
+30 -5
View File
@@ -458,14 +458,22 @@ The monitor sends a notification when failures reach the configured threshold an
## Gmail Pub/Sub Watcher ## Gmail Pub/Sub Watcher
Monitor a Gmail inbox via Google Cloud Pub/Sub push notifications. New emails trigger the agent pipeline and route responses to a configured output channel. Falls back to polling when push notifications are unavailable. Monitor a Gmail inbox and forward new messages into the agent pipeline.
Supported delivery modes:
- **Push** (Gmail watch → Pub/Sub topic → HTTP push subscription → `POST /gmail/push`)
- **Pull** (Pub/Sub pull subscription → Flynn periodically pulls messages; no inbound webhook)
- **Polling** (Gmail History API polling fallback)
### Prerequisites ### Prerequisites
1. Create a Google Cloud project with the Gmail API and Pub/Sub API enabled 1. Create a Google Cloud project with the Gmail API enabled
2. Create OAuth2 credentials (Desktop application type) and download the JSON file 2. Create OAuth2 credentials (Desktop application type) and download the JSON file
3. Create a Pub/Sub topic (e.g. `projects/your-project/topics/gmail-push`) 3. Run `flynn gmail-auth` to complete the OAuth2 flow and store the refresh token
4. Run `flynn gmail-auth` to complete the OAuth2 flow and store the refresh token
For Pub/Sub delivery (push/pull), also enable the Pub/Sub API and create:
- A topic (e.g. `projects/your-project/topics/gmail-push`)
- A subscription (push and/or pull)
### Configuration ### Configuration
@@ -475,6 +483,16 @@ automation:
enabled: true enabled: true
credentials_file: ~/.config/flynn/gmail-credentials.json credentials_file: ~/.config/flynn/gmail-credentials.json
token_file: ~/.config/flynn/gmail-token.json # Default location token_file: ~/.config/flynn/gmail-token.json # Default location
# Push mode (optional)
pubsub_topic: projects/your-project/topics/gmail-push
disable_push: false
# Pull mode (optional; no inbound webhook required)
pubsub_subscription_id: projects/your-project/subscriptions/gmail-pull
pubsub_pull_interval: "60s"
pubsub_max_messages: 10
watch_labels: [INBOX] # Labels to watch watch_labels: [INBOX] # Labels to watch
poll_interval: "60s" # Polling fallback interval poll_interval: "60s" # Polling fallback interval
message: "New email from {{from}}: {{subject}}\n\n{{snippet}}" message: "New email from {{from}}: {{subject}}\n\n{{snippet}}"
@@ -485,6 +503,8 @@ automation:
Push notifications arrive at `POST /gmail/push` on the gateway HTTP server (bypasses gateway auth). Push notifications arrive at `POST /gmail/push` on the gateway HTTP server (bypasses gateway auth).
Pull mode uses Application Default Credentials (e.g. `GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json`) to access Pub/Sub.
### Gmail Config Fields ### Gmail Config Fields
| Field | Required | Description | | Field | Required | Description |
@@ -492,8 +512,13 @@ Push notifications arrive at `POST /gmail/push` on the gateway HTTP server (bypa
| `enabled` | no | Enable the Gmail watcher (default: `false`) | | `enabled` | no | Enable the Gmail watcher (default: `false`) |
| `credentials_file` | yes | Path to Google OAuth2 credentials JSON | | `credentials_file` | yes | Path to Google OAuth2 credentials JSON |
| `token_file` | no | Path to stored OAuth2 refresh token (default: `~/.config/flynn/gmail-token.json`) | | `token_file` | no | Path to stored OAuth2 refresh token (default: `~/.config/flynn/gmail-token.json`) |
| `pubsub_topic` | no | Pub/Sub topic for Gmail watch push notifications (`projects/<project>/topics/<topic>`) |
| `disable_push` | no | Disable watch registration even if `pubsub_topic` is set (default: `false`) |
| `pubsub_subscription_id` | no | Pub/Sub pull subscription (`projects/<project>/subscriptions/<sub>`) |
| `pubsub_pull_interval` | no | How often to pull subscription messages (default: `60s`) |
| `pubsub_max_messages` | no | Max messages per pull cycle (default: `10`) |
| `watch_labels` | no | Gmail labels to watch (default: `[INBOX]`) | | `watch_labels` | no | Gmail labels to watch (default: `[INBOX]`) |
| `poll_interval` | no | Polling fallback interval: `60s`, `5m` (default: `60s`) | | `poll_interval` | no | Polling fallback interval: `60s`, `5m` (default: `300s`) |
| `history_start` | no | ISO date string — only process emails received after this date | | `history_start` | no | ISO date string — only process emails received after this date |
| `message` | no | Template for the agent message (default: `New email from {{from}}: {{subject}}\n\n{{snippet}}`) | | `message` | no | Template for the agent message (default: `New email from {{from}}: {{subject}}\n\n{{snippet}}`) |
| `output.channel` | yes | Channel name to route the response (e.g. `telegram`) | | `output.channel` | yes | Channel name to route the response (e.g. `telegram`) |
+10
View File
@@ -128,6 +128,16 @@ hooks:
# enabled: false # enabled: false
# credentials_file: ~/.config/flynn/gmail-credentials.json # credentials_file: ~/.config/flynn/gmail-credentials.json
# token_file: ~/.config/flynn/gmail-token.json # token_file: ~/.config/flynn/gmail-token.json
#
# # Optional Pub/Sub delivery
# # Push mode: configure a topic and a push subscription that POSTs to /gmail/push
# pubsub_topic: projects/your-project/topics/gmail-push
# disable_push: false
#
# # Pull mode: no inbound webhook required (requires Application Default Credentials)
# pubsub_subscription_id: projects/your-project/subscriptions/gmail-pull
# pubsub_pull_interval: "60s"
# pubsub_max_messages: 10
# watch_labels: [INBOX] # watch_labels: [INBOX]
# poll_interval: "60s" # poll_interval: "60s"
# message: "New email from {{from}}: {{subject}}\n\n{{snippet}}" # message: "New email from {{from}}: {{subject}}\n\n{{snippet}}"
@@ -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", "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." "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": { "p0-p1-implementation-plan": {
"file": "2026-02-06-p0-p1-implementation-plan.md", "file": "2026-02-06-p0-p1-implementation-plan.md",
"status": "completed", "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": { "runtime-context-awareness": {
"status": "completed", "status": "completed",
"date": "2026-02-07", "date": "2026-02-07",
@@ -1751,7 +1827,7 @@
}, },
"overall_progress": { "overall_progress": {
"total_test_count": 1597, "total_test_count": 1617,
"all_tests_passing": true, "all_tests_passing": true,
"p0_completion": "3/3 (100%)", "p0_completion": "3/3 (100%)",
"p1_completion": "4/4 (100%)", "p1_completion": "4/4 (100%)",
+1
View File
@@ -42,6 +42,7 @@
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.39.0", "@anthropic-ai/sdk": "^0.39.0",
"@aws-sdk/client-bedrock-runtime": "^3.985.0", "@aws-sdk/client-bedrock-runtime": "^3.985.0",
"@google-cloud/pubsub": "^5.2.3",
"@google/generative-ai": "^0.24.1", "@google/generative-ai": "^0.24.1",
"@modelcontextprotocol/sdk": "^1.26.0", "@modelcontextprotocol/sdk": "^1.26.0",
"@mozilla/readability": "^0.5.0", "@mozilla/readability": "^0.5.0",
+555
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -7,3 +7,16 @@ export {
loginGitHub, loginGitHub,
type DeviceCodeResponse, type DeviceCodeResponse,
} from './github.js'; } from './github.js';
export {
loadStoredOpenAIAuth,
storeOpenAIAuth,
clearOpenAIAuth,
refreshOpenAIAuth,
ensureValidOpenAIAuth,
loginOpenAI,
parseJwtClaims,
extractAccountId,
type OpenAIOAuthInfo,
type IdTokenClaims,
} from './openai.js';
+43
View File
@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest';
import { parseJwtClaims, extractAccountId } from './openai.js';
function base64UrlEncode(obj: unknown): string {
return Buffer.from(JSON.stringify(obj)).toString('base64url');
}
function makeJwt(payload: Record<string, unknown>): string {
const header = base64UrlEncode({ alg: 'none', typ: 'JWT' });
const body = base64UrlEncode(payload);
// Signature is ignored by parseJwtClaims.
return `${header}.${body}.sig`;
}
describe('OpenAI OAuth helpers', () => {
it('parseJwtClaims returns undefined for non-jwt strings', () => {
expect(parseJwtClaims('not-a-jwt')).toBeUndefined();
});
it('parseJwtClaims parses base64url payload', () => {
const token = makeJwt({ chatgpt_account_id: 'acct_123' });
const claims = parseJwtClaims(token);
expect(claims?.chatgpt_account_id).toBe('acct_123');
});
it('extractAccountId prefers chatgpt_account_id', () => {
const tokens = {
access_token: makeJwt({ chatgpt_account_id: 'acct_a' }),
refresh_token: 'rt',
id_token: makeJwt({ chatgpt_account_id: 'acct_b' }),
};
expect(extractAccountId(tokens)).toBe('acct_b');
});
it('extractAccountId falls back to organizations[0].id', () => {
const tokens = {
access_token: makeJwt({ organizations: [{ id: 'org_1' }] }),
refresh_token: 'rt',
};
expect(extractAccountId(tokens)).toBe('org_1');
});
});
+281
View File
@@ -0,0 +1,281 @@
import { readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs';
import { resolve } from 'path';
import { homedir } from 'os';
const ISSUER = 'https://auth.openai.com';
const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
const DEVICE_URL = `${ISSUER}/codex/device`;
const DEVICE_CODE_URL = `${ISSUER}/api/accounts/deviceauth/usercode`;
const DEVICE_TOKEN_URL = `${ISSUER}/api/accounts/deviceauth/token`;
const TOKEN_URL = `${ISSUER}/oauth/token`;
const POLLING_SAFETY_MARGIN_MS = 3000;
const REFRESH_SAFETY_MARGIN_MS = 30_000;
const AUTH_DIR = resolve(homedir(), '.config/flynn');
const AUTH_FILE = resolve(AUTH_DIR, 'auth.json');
export interface OpenAIOAuthInfo {
access_token: string;
refresh_token: string;
/** Epoch millis. */
expires_at: number;
/** Optional account/org id used for subscription routing. */
account_id?: string;
created_at: string;
}
interface AuthStore {
// Leave github entry untyped here so this module does not depend on github.ts.
github?: unknown;
openai?: OpenAIOAuthInfo;
}
interface DeviceAuthResponse {
device_auth_id: string;
user_code: string;
interval: string;
}
interface DeviceTokenResponse {
authorization_code: string;
code_verifier: string;
}
interface TokenResponse {
id_token?: string;
access_token: string;
refresh_token: string;
expires_in?: number;
}
export interface IdTokenClaims {
chatgpt_account_id?: string;
organizations?: Array<{ id: string }>;
'https://api.openai.com/auth'?: {
chatgpt_account_id?: string;
};
}
function safeJsonParse<T>(raw: string): T | null {
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}
function readAuthStore(): AuthStore {
try {
const raw = readFileSync(AUTH_FILE, 'utf-8');
const parsed = safeJsonParse<AuthStore>(raw);
return parsed ?? {};
} catch {
return {};
}
}
function writeAuthStore(store: AuthStore): void {
mkdirSync(AUTH_DIR, { recursive: true });
writeFileSync(AUTH_FILE, JSON.stringify(store, null, 2) + '\n', 'utf-8');
chmodSync(AUTH_FILE, 0o600);
}
export function loadStoredOpenAIAuth(): OpenAIOAuthInfo | null {
const store = readAuthStore();
return store.openai ?? null;
}
export function storeOpenAIAuth(info: OpenAIOAuthInfo): void {
const store = readAuthStore();
store.openai = info;
writeAuthStore(store);
}
export function clearOpenAIAuth(): void {
const store = readAuthStore();
delete store.openai;
writeAuthStore(store);
}
export function parseJwtClaims(token: string): IdTokenClaims | undefined {
const parts = token.split('.');
if (parts.length !== 3) {return undefined;}
try {
return JSON.parse(Buffer.from(parts[1], 'base64url').toString()) as IdTokenClaims;
} catch {
return undefined;
}
}
function extractAccountIdFromClaims(claims: IdTokenClaims): string | undefined {
return claims.chatgpt_account_id
?? claims['https://api.openai.com/auth']?.chatgpt_account_id
?? claims.organizations?.[0]?.id;
}
export function extractAccountId(tokens: TokenResponse): string | undefined {
const idToken = tokens.id_token;
if (idToken) {
const claims = parseJwtClaims(idToken);
const id = claims && extractAccountIdFromClaims(claims);
if (id) {return id;}
}
const accessToken = tokens.access_token;
if (accessToken) {
const claims = parseJwtClaims(accessToken);
return claims ? extractAccountIdFromClaims(claims) : undefined;
}
return undefined;
}
async function requestDeviceAuth(): Promise<DeviceAuthResponse> {
const response = await fetch(DEVICE_CODE_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'flynn',
},
body: JSON.stringify({ client_id: CLIENT_ID }),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`OpenAI device auth start failed (${response.status}): ${body}`);
}
return response.json() as Promise<DeviceAuthResponse>;
}
async function pollDeviceToken(deviceAuthId: string, userCode: string, intervalMs: number): Promise<DeviceTokenResponse> {
while (true) {
await new Promise(r => setTimeout(r, intervalMs + POLLING_SAFETY_MARGIN_MS));
const response = await fetch(DEVICE_TOKEN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'flynn',
},
body: JSON.stringify({ device_auth_id: deviceAuthId, user_code: userCode }),
});
if (response.ok) {
return response.json() as Promise<DeviceTokenResponse>;
}
// OpenCode treats 403/404 as "pending".
if (response.status === 403 || response.status === 404) {
continue;
}
const body = await response.text();
throw new Error(`OpenAI device auth token failed (${response.status}): ${body}`);
}
}
async function exchangeAuthorizationCode(authCode: string, codeVerifier: string): Promise<TokenResponse> {
const response = await fetch(TOKEN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'flynn',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: authCode,
redirect_uri: `${ISSUER}/deviceauth/callback`,
client_id: CLIENT_ID,
code_verifier: codeVerifier,
}).toString(),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`OpenAI token exchange failed (${response.status}): ${body}`);
}
return response.json() as Promise<TokenResponse>;
}
export async function refreshOpenAIAuth(refreshToken: string): Promise<TokenResponse> {
const response = await fetch(TOKEN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'flynn',
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: CLIENT_ID,
}).toString(),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`OpenAI token refresh failed (${response.status}): ${body}`);
}
return response.json() as Promise<TokenResponse>;
}
/**
* Ensure we have a valid (non-expired) OpenAI OAuth access token.
* Refreshes and persists the token if needed.
*/
export async function ensureValidOpenAIAuth(): Promise<OpenAIOAuthInfo> {
const current = loadStoredOpenAIAuth();
if (!current) {
throw new Error('OpenAI OAuth is not configured. Run `flynn openai-auth` to authenticate.');
}
if (current.expires_at > Date.now() + REFRESH_SAFETY_MARGIN_MS) {
return current;
}
const refreshed = await refreshOpenAIAuth(current.refresh_token);
const expiresAt = Date.now() + (refreshed.expires_in ?? 3600) * 1000;
const accountId = extractAccountId(refreshed) ?? current.account_id;
const updated: OpenAIOAuthInfo = {
access_token: refreshed.access_token,
refresh_token: refreshed.refresh_token,
expires_at: expiresAt,
account_id: accountId,
created_at: current.created_at,
};
storeOpenAIAuth(updated);
return updated;
}
/**
* Run the OpenAI Codex device flow interactively.
* @param onPrompt Callback to display the user code and verification URL to the user.
*/
export async function loginOpenAI(
onPrompt: (userCode: string, verificationUri: string) => void,
): Promise<OpenAIOAuthInfo> {
const device = await requestDeviceAuth();
const intervalMs = Math.max(parseInt(device.interval) || 5, 1) * 1000;
onPrompt(device.user_code, DEVICE_URL);
const deviceToken = await pollDeviceToken(device.device_auth_id, device.user_code, intervalMs);
const tokens = await exchangeAuthorizationCode(deviceToken.authorization_code, deviceToken.code_verifier);
const expiresAt = Date.now() + (tokens.expires_in ?? 3600) * 1000;
const accountId = extractAccountId(tokens);
const info: OpenAIOAuthInfo = {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: expiresAt,
...(accountId ? { account_id: accountId } : {}),
created_at: new Date().toISOString(),
};
storeOpenAIAuth(info);
return info;
}
+154 -3
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { homedir } from 'os'; import { homedir } from 'os';
import { GmailWatcher } from './gmail.js'; import type { GmailWatcher as GmailWatcherType } from './gmail.js';
import type { OutboundMessage } from '../channels/types.js'; import type { OutboundMessage } from '../channels/types.js';
// Mock googleapis module // Mock googleapis module
@@ -74,6 +74,23 @@ vi.mock('googleapis', () => {
}; };
}); });
vi.mock('@google-cloud/pubsub', () => {
const pull = vi.fn().mockResolvedValue([{ receivedMessages: [] }]);
const acknowledge = vi.fn().mockResolvedValue([{}]);
const close = vi.fn().mockResolvedValue(undefined);
class SubscriberClient {
pull = pull;
acknowledge = acknowledge;
close = close;
}
return {
v1: { SubscriberClient },
_mocks: { pull, acknowledge, close },
};
});
// Mock fs operations // Mock fs operations
vi.mock('fs', async () => { vi.mock('fs', async () => {
const actual = await vi.importActual<typeof import('fs')>('fs'); const actual = await vi.importActual<typeof import('fs')>('fs');
@@ -86,6 +103,7 @@ vi.mock('fs', async () => {
installed: { installed: {
client_id: 'test-client-id', client_id: 'test-client-id',
client_secret: 'test-client-secret', client_secret: 'test-client-secret',
project_id: 'test-project',
redirect_uris: ['http://localhost'], redirect_uris: ['http://localhost'],
}, },
}); });
@@ -108,6 +126,9 @@ function createMockConfig(overrides = {}) {
enabled: true, enabled: true,
credentials_file: '~/.config/flynn/gmail-credentials.json', credentials_file: '~/.config/flynn/gmail-credentials.json',
token_file: '~/.config/flynn/gmail-token.json', token_file: '~/.config/flynn/gmail-token.json',
disable_push: false,
pubsub_pull_interval: '60s',
pubsub_max_messages: 10,
watch_labels: ['INBOX'], watch_labels: ['INBOX'],
poll_interval: '300s', poll_interval: '300s',
output: { output: {
@@ -128,12 +149,16 @@ function createMockChannelLookup() {
} }
describe('GmailWatcher', () => { describe('GmailWatcher', () => {
let watcher: GmailWatcher; let GmailWatcher: typeof GmailWatcherType;
let watcher: GmailWatcherType;
let channelLookup: ReturnType<typeof createMockChannelLookup>; let channelLookup: ReturnType<typeof createMockChannelLookup>;
beforeEach(() => { beforeEach(async () => {
vi.useFakeTimers(); vi.useFakeTimers();
channelLookup = createMockChannelLookup(); channelLookup = createMockChannelLookup();
// Import after mocks so ESM named imports (fs/googleapis) are properly mocked.
({ GmailWatcher } = await import('./gmail.js'));
}); });
afterEach(async () => { afterEach(async () => {
@@ -154,6 +179,60 @@ describe('GmailWatcher', () => {
}); });
}); });
describe('push topic resolution', () => {
it('returns null when pubsub_topic is not set', () => {
const config = createMockConfig();
watcher = new GmailWatcher(config, channelLookup);
const topic = (watcher as unknown as { resolvePubSubTopicName: () => string | null }).resolvePubSubTopicName();
expect(topic).toBe(null);
});
it('expands shorthand topic id when project_id is known', () => {
const config = createMockConfig({ pubsub_topic: 'my-topic' });
watcher = new GmailWatcher(config, channelLookup);
(watcher as unknown as { googleProjectId: string }).googleProjectId = 'test-project';
const topic = (watcher as unknown as { resolvePubSubTopicName: () => string | null }).resolvePubSubTopicName();
expect(topic).toBe('projects/test-project/topics/my-topic');
});
it('rejects invalid pubsub_topic formats', () => {
const config = createMockConfig({ pubsub_topic: 'projects/test-project/topic/my-topic' });
watcher = new GmailWatcher(config, channelLookup);
expect(() => {
(watcher as unknown as { resolvePubSubTopicName: () => string | null }).resolvePubSubTopicName();
}).toThrow(/Invalid pubsub_topic/);
});
});
describe('pull subscription resolution', () => {
it('returns null when pubsub_subscription_id is not set', () => {
const config = createMockConfig();
watcher = new GmailWatcher(config, channelLookup);
const sub = (watcher as unknown as { resolvePubSubSubscriptionName: () => string | null }).resolvePubSubSubscriptionName();
expect(sub).toBe(null);
});
it('expands shorthand subscription id when project_id is known', () => {
const config = createMockConfig({ pubsub_subscription_id: 'my-sub' });
watcher = new GmailWatcher(config, channelLookup);
(watcher as unknown as { googleProjectId: string }).googleProjectId = 'test-project';
const sub = (watcher as unknown as { resolvePubSubSubscriptionName: () => string | null }).resolvePubSubSubscriptionName();
expect(sub).toBe('projects/test-project/subscriptions/my-sub');
});
it('rejects invalid pubsub_subscription_id formats', () => {
const config = createMockConfig({ pubsub_subscription_id: 'projects/test-project/subscription/my-sub' });
watcher = new GmailWatcher(config, channelLookup);
expect(() => {
(watcher as unknown as { resolvePubSubSubscriptionName: () => string | null }).resolvePubSubSubscriptionName();
}).toThrow(/Invalid pubsub_subscription_id/);
});
});
describe('connect() with missing credentials', () => { describe('connect() with missing credentials', () => {
it('logs warning and sets status to error when credentials_file is missing', async () => { it('logs warning and sets status to error when credentials_file is missing', async () => {
const config = createMockConfig({ credentials_file: undefined }); const config = createMockConfig({ credentials_file: undefined });
@@ -200,6 +279,78 @@ describe('GmailWatcher', () => {
}); });
}); });
describe('push disable flag', () => {
it('skips watch setup when disable_push is true', async () => {
const config = createMockConfig({ disable_push: true, pubsub_topic: 'projects/test-project/topics/gmail-push' });
watcher = new GmailWatcher(config, channelLookup);
const { existsSync, readFileSync } = await import('fs');
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFileSync).mockImplementation((path: unknown) => {
const p = String(path);
if (p.includes('credentials')) {
return JSON.stringify({
installed: {
client_id: 'test-client-id',
client_secret: 'test-client-secret',
project_id: 'test-project',
redirect_uris: ['http://localhost'],
},
});
}
return JSON.stringify({
access_token: 'test-access-token',
refresh_token: 'test-refresh-token',
expiry_date: Date.now() + 3600000,
});
});
const googleapis = await import('googleapis') as unknown as {
_mocks: {
mockWatch: ReturnType<typeof vi.fn>;
mockOAuth2: ReturnType<typeof vi.fn>;
};
};
googleapis._mocks.mockOAuth2.mockImplementation(() => ({
setCredentials: vi.fn(),
on: vi.fn(),
}));
const watchSpy = googleapis._mocks.mockWatch;
await watcher.connect();
expect(watchSpy).not.toHaveBeenCalled();
expect(watcher.status).toBe('connected');
});
});
describe('pullSubscriptionMessages', () => {
it('pulls messages and acknowledges successfully processed ones', async () => {
const config = createMockConfig({ pubsub_subscription_id: 'projects/test-project/subscriptions/gmail-pull' });
watcher = new GmailWatcher(config, channelLookup);
const { _mocks: pubsubMocks } = await import('@google-cloud/pubsub') as unknown as {
_mocks: { pull: ReturnType<typeof vi.fn>; acknowledge: ReturnType<typeof vi.fn> };
};
const payload = { emailAddress: 'bob@example.com', historyId: '200' };
pubsubMocks.pull.mockResolvedValueOnce([
{
receivedMessages: [
{ ackId: 'ack-1', message: { data: Buffer.from(JSON.stringify(payload)) } },
],
},
]);
await (watcher as unknown as { pullSubscriptionMessages: () => Promise<void> }).pullSubscriptionMessages();
expect((watcher as unknown as { lastHistoryId: string }).lastHistoryId).toBe('200');
expect(pubsubMocks.acknowledge).toHaveBeenCalledWith({
subscription: 'projects/test-project/subscriptions/gmail-pull',
ackIds: ['ack-1'],
});
});
});
describe('renderTemplate', () => { describe('renderTemplate', () => {
it('replaces all placeholders correctly', () => { it('replaces all placeholders correctly', () => {
const config = createMockConfig({ const config = createMockConfig({
+221 -10
View File
@@ -2,6 +2,7 @@ import { google, type Auth } from 'googleapis';
import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from 'fs'; import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from 'fs';
import { dirname, resolve } from 'path'; import { dirname, resolve } from 'path';
import { homedir } from 'os'; import { homedir } from 'os';
import type { v1 } from '@google-cloud/pubsub';
import type { GmailConfig } from '../config/schema.js'; import type { GmailConfig } from '../config/schema.js';
import type { ChannelAdapter, ChannelStatus, InboundMessage, OutboundMessage } from '../channels/types.js'; import type { ChannelAdapter, ChannelStatus, InboundMessage, OutboundMessage } from '../channels/types.js';
import { parseInterval } from './heartbeat.js'; import { parseInterval } from './heartbeat.js';
@@ -30,9 +31,7 @@ interface PubSubNotification {
historyId: string; historyId: string;
} }
// Google Cloud Pub/Sub topic for Gmail push notifications. const DEFAULT_TOPIC_ID = 'gmail-push';
// This must be pre-configured in Google Cloud Console.
const GMAIL_PUBSUB_TOPIC = 'projects/flynn-agent/topics/gmail-push';
// Watch expires after ~7 days; renew at 6 days (in ms). // Watch expires after ~7 days; renew at 6 days (in ms).
const WATCH_RENEWAL_MS = 6 * 24 * 60 * 60 * 1000; const WATCH_RENEWAL_MS = 6 * 24 * 60 * 60 * 1000;
@@ -56,7 +55,11 @@ export class GmailWatcher implements ChannelAdapter {
private lastHistoryId?: string; private lastHistoryId?: string;
private pollTimer?: ReturnType<typeof setInterval>; private pollTimer?: ReturnType<typeof setInterval>;
private watchTimer?: ReturnType<typeof setInterval>; private watchTimer?: ReturnType<typeof setInterval>;
private pullTimer?: ReturnType<typeof setInterval>;
private pubsubSubscriber?: v1.SubscriberClient;
private pullInFlight = false;
private readonly config: NonNullable<GmailConfig>; private readonly config: NonNullable<GmailConfig>;
private googleProjectId?: string;
constructor( constructor(
config: NonNullable<GmailConfig>, config: NonNullable<GmailConfig>,
@@ -82,12 +85,28 @@ export class GmailWatcher implements ChannelAdapter {
return; return;
} }
// Set up Gmail push watch (Pub/Sub) // Set up Gmail push watch (Pub/Sub). Polling is always enabled.
if (!this.config.disable_push) {
try { try {
await this.setupWatch(); await this.setupWatch();
} catch (error) { } catch (error) {
const errMsg = error instanceof Error ? error.message : 'Unknown error'; const errMsg = error instanceof Error ? error.message : 'Unknown error';
console.warn(`GmailWatcher: Watch setup failed (will use polling only) — ${errMsg}`); const hint = this.buildWatchErrorHint(errMsg);
console.warn(`GmailWatcher: Watch setup failed (will use polling only) — ${errMsg}${hint}`);
}
} else {
const configured = (this.config.pubsub_topic ?? process.env.FLYNN_GMAIL_PUBSUB_TOPIC ?? '').trim();
if (configured) {
console.log('GmailWatcher: Push disabled (disable_push=true)');
}
}
// Set up Pub/Sub pull subscription (optional).
try {
await this.setupPullSubscription();
} catch (error) {
const errMsg = error instanceof Error ? error.message : 'Unknown error';
console.warn(`GmailWatcher: Pull setup failed (will continue without pull) — ${errMsg}`);
} }
// Start polling fallback // Start polling fallback
@@ -99,8 +118,23 @@ export class GmailWatcher implements ChannelAdapter {
}, pollMs); }, pollMs);
this._status = 'connected'; this._status = 'connected';
console.log(`GmailWatcher: Connected (poll_interval=${this.config.poll_interval ?? '300s'})`);
auditLogger?.systemStart('GmailWatcher', { poll_interval: this.config.poll_interval }); const modes: string[] = [];
const pushConfigured = Boolean((this.config.pubsub_topic ?? process.env.FLYNN_GMAIL_PUBSUB_TOPIC ?? '').trim());
const pullConfigured = Boolean((this.config.pubsub_subscription_id ?? '').trim());
if (pushConfigured && !this.config.disable_push) {modes.push('push');}
if (pullConfigured) {modes.push('pull');}
modes.push('poll');
console.log(
`GmailWatcher: Connected (${modes.join('+')}, poll_interval=${this.config.poll_interval ?? '300s'}${pullConfigured ? `, pubsub_pull_interval=${this.config.pubsub_pull_interval ?? '60s'}` : ''})`,
);
auditLogger?.systemStart('GmailWatcher', {
modes: modes.join('+'),
poll_interval: this.config.poll_interval,
pubsub_topic: pushConfigured ? 'configured' : 'none',
pubsub_subscription_id: pullConfigured ? 'configured' : 'none',
});
} }
async disconnect(): Promise<void> { async disconnect(): Promise<void> {
@@ -109,9 +143,21 @@ export class GmailWatcher implements ChannelAdapter {
this.pollTimer = undefined; this.pollTimer = undefined;
} }
if (this.watchTimer) { if (this.watchTimer) {
clearTimeout(this.watchTimer); clearInterval(this.watchTimer);
this.watchTimer = undefined; this.watchTimer = undefined;
} }
if (this.pullTimer) {
clearInterval(this.pullTimer);
this.pullTimer = undefined;
}
if (this.pubsubSubscriber) {
try {
await this.pubsubSubscriber.close();
} catch {
// Ignore shutdown errors
}
this.pubsubSubscriber = undefined;
}
this.oauth2Client = undefined; this.oauth2Client = undefined;
this._status = 'disconnected'; this._status = 'disconnected';
auditLogger?.systemStop('GmailWatcher'); auditLogger?.systemStop('GmailWatcher');
@@ -178,7 +224,10 @@ export class GmailWatcher implements ChannelAdapter {
} }
const credentials = JSON.parse(readFileSync(expandedCredsPath, 'utf-8')); const credentials = JSON.parse(readFileSync(expandedCredsPath, 'utf-8'));
const { client_id, client_secret, redirect_uris } = credentials.installed ?? credentials.web ?? {}; const { client_id, client_secret, redirect_uris, project_id } = credentials.installed ?? credentials.web ?? {};
if (project_id && typeof project_id === 'string') {
this.googleProjectId = project_id;
}
if (!client_id || !client_secret) { if (!client_id || !client_secret) {
throw new Error('Invalid credentials file — missing client_id or client_secret'); throw new Error('Invalid credentials file — missing client_id or client_secret');
@@ -217,13 +266,24 @@ export class GmailWatcher implements ChannelAdapter {
private async setupWatch(): Promise<void> { private async setupWatch(): Promise<void> {
if (!this.oauth2Client) {return;} if (!this.oauth2Client) {return;}
if (this.watchTimer) {
clearInterval(this.watchTimer);
this.watchTimer = undefined;
}
const topicName = this.resolvePubSubTopicName();
if (!topicName) {
// Push notifications are optional; polling is always enabled.
return;
}
const gmail = google.gmail({ version: 'v1', auth: this.oauth2Client }); const gmail = google.gmail({ version: 'v1', auth: this.oauth2Client });
const watchResponse = await gmail.users.watch({ const watchResponse = await gmail.users.watch({
userId: 'me', userId: 'me',
requestBody: { requestBody: {
labelIds: this.config.watch_labels ?? ['INBOX'], labelIds: this.config.watch_labels ?? ['INBOX'],
topicName: GMAIL_PUBSUB_TOPIC, topicName,
}, },
}); });
@@ -241,6 +301,157 @@ export class GmailWatcher implements ChannelAdapter {
}, WATCH_RENEWAL_MS); }, WATCH_RENEWAL_MS);
} }
private buildWatchErrorHint(errMsg: string): string {
const hints: string[] = [];
if (errMsg.includes('Invalid topicName')) {
hints.push(
`Tip: set automation.gmail.pubsub_topic to "projects/${this.googleProjectId ?? '<project-id>'}/topics/${DEFAULT_TOPIC_ID}"`,
);
}
if (/permission denied|PERMISSION_DENIED/i.test(errMsg)) {
hints.push('Tip: ensure Gmail has permission to publish to the Pub/Sub topic (IAM)');
}
hints.push('Tip: if Google cannot reach your gateway, set automation.gmail.pubsub_subscription_id for pull mode');
return hints.length > 0 ? `\n ${hints.join('\n ')}` : '';
}
/**
* Resolve the Pub/Sub topic resource name for Gmail push notifications.
*
* Priority:
* 1) automation.gmail.pubsub_topic
* 2) FLYNN_GMAIL_PUBSUB_TOPIC env var
* If neither is provided, push notifications are disabled.
*/
private resolvePubSubTopicName(): string | null {
const configured = this.config.pubsub_topic ?? process.env.FLYNN_GMAIL_PUBSUB_TOPIC;
let topic = (configured ?? '').trim();
if (!topic) {return null;}
// Allow shorthand: just the topic id (e.g. "gmail-push")
if (!topic.includes('/')) {
if (!this.googleProjectId) {
throw new Error(
`pubsub_topic '${topic}' must be fully qualified (projects/<project-id>/topics/<topic>) because project_id was not found in credentials`,
);
}
topic = `projects/${this.googleProjectId}/topics/${topic}`;
}
const isValid = /^projects\/[^/]+\/topics\/[^/]+$/.test(topic);
if (!isValid) {
throw new Error(
`Invalid pubsub_topic '${topic}'. Expected: projects/<project-id>/topics/<topic>`,
);
}
return topic;
}
private resolvePubSubSubscriptionName(): string | null {
let sub = (this.config.pubsub_subscription_id ?? '').trim();
if (!sub) {return null;}
// Allow shorthand: just the subscription id (e.g. "gmail-pull")
if (!sub.includes('/')) {
if (!this.googleProjectId) {
throw new Error(
`pubsub_subscription_id '${sub}' must be fully qualified (projects/<project-id>/subscriptions/<subscription>) because project_id was not found in credentials`,
);
}
sub = `projects/${this.googleProjectId}/subscriptions/${sub}`;
}
const isValid = /^projects\/[^/]+\/subscriptions\/[^/]+$/.test(sub);
if (!isValid) {
throw new Error(
`Invalid pubsub_subscription_id '${sub}'. Expected: projects/<project-id>/subscriptions/<subscription>`,
);
}
return sub;
}
private async setupPullSubscription(): Promise<void> {
const subscriptionName = this.resolvePubSubSubscriptionName();
if (!subscriptionName) {return;}
if (this.pullTimer) {
clearInterval(this.pullTimer);
this.pullTimer = undefined;
}
const pullMs = parseInterval(this.config.pubsub_pull_interval ?? '60s');
// Kick once immediately, then on interval.
await this.pullSubscriptionMessages().catch((err) => {
console.error('GmailWatcher: Pub/Sub pull error —', err instanceof Error ? err.message : err);
});
this.pullTimer = setInterval(() => {
this.pullSubscriptionMessages().catch((err) => {
console.error('GmailWatcher: Pub/Sub pull error —', err instanceof Error ? err.message : err);
});
}, pullMs);
console.log(
`GmailWatcher: Pull enabled (subscription=${subscriptionName}, interval=${this.config.pubsub_pull_interval ?? '60s'})`,
);
}
private async getSubscriberClient(): Promise<v1.SubscriberClient> {
if (this.pubsubSubscriber) {return this.pubsubSubscriber;}
const mod = await import('@google-cloud/pubsub');
this.pubsubSubscriber = new mod.v1.SubscriberClient();
return this.pubsubSubscriber;
}
private async pullSubscriptionMessages(): Promise<void> {
const subscription = this.resolvePubSubSubscriptionName();
if (!subscription) {return;}
if (this.pullInFlight) {return;}
this.pullInFlight = true;
try {
const client = await this.getSubscriberClient();
const maxMessages = this.config.pubsub_max_messages ?? 10;
const [response] = await client.pull({
subscription,
maxMessages,
});
const received = response.receivedMessages ?? [];
if (received.length === 0) {return;}
const ackIds: string[] = [];
for (const receivedMessage of received) {
const ackId = receivedMessage.ackId;
const data = receivedMessage.message?.data;
if (!ackId || !data) {continue;}
const base64 = Buffer.from(data as Uint8Array).toString('base64');
try {
await this.handlePushNotification(base64);
ackIds.push(ackId);
} catch {
// If processing fails, leave message unacked for retry.
}
}
if (ackIds.length > 0) {
await client.acknowledge({ subscription, ackIds });
}
} finally {
this.pullInFlight = false;
}
}
/** /**
* Poll Gmail History API for new messages since lastHistoryId. * Poll Gmail History API for new messages since lastHistoryId.
* Fallback mechanism when Pub/Sub push is not available. * Fallback mechanism when Pub/Sub push is not available.
+29
View File
@@ -228,6 +228,35 @@ describe('TelegramAdapter', () => {
expect(msg.metadata).toEqual({ isCommand: true, command: 'reset' }); expect(msg.metadata).toEqual({ isCommand: true, command: 'reset' });
}); });
it('/model command strips @bot suffix in groups', async () => {
const handler = vi.fn();
adapter.onMessage(handler);
await adapter.connect();
// Find the /model command handler
const modelCall = mockCommand.mock.calls.find((call) => call[0] === 'model');
expect(modelCall).toBeDefined();
const modelHandler = modelCall![1];
const ctx = {
message: { message_id: 123, text: '/model@flynn_bot default github/gpt-5-mini' },
chat: { id: 100 },
from: { first_name: 'Will' },
};
await modelHandler(ctx);
expect(handler).toHaveBeenCalledTimes(1);
const msg: InboundMessage = handler.mock.calls[0][0];
expect(msg.text).toBe('/model default github/gpt-5-mini');
expect(msg.metadata).toEqual({
isCommand: true,
command: 'model',
commandArgs: 'default github/gpt-5-mini',
});
});
// ── Auth middleware ─────────────────────────────────────────── // ── Auth middleware ───────────────────────────────────────────
it('auth middleware blocks unauthorized chat IDs', async () => { it('auth middleware blocks unauthorized chat IDs', async () => {
+39 -4
View File
@@ -166,7 +166,9 @@ export class TelegramAdapter implements ChannelAdapter {
this.bot.command('model', async (ctx) => { this.bot.command('model', async (ctx) => {
if (!this.messageHandler) {return;} if (!this.messageHandler) {return;}
const args = ctx.message?.text?.replace(/^\/model\s*/, '').trim() ?? ''; // Telegram can deliver group commands in the form: /model@bot_username ...
// Strip the optional @mention so args parsing is consistent across DMs/groups.
const args = ctx.message?.text?.replace(/^\/model(?:@\S+)?\s*/i, '').trim() ?? '';
this.messageHandler({ this.messageHandler({
id: String(ctx.message?.message_id ?? Date.now()), id: String(ctx.message?.message_id ?? Date.now()),
@@ -439,15 +441,48 @@ export class TelegramAdapter implements ChannelAdapter {
if (!this.bot) {throw new Error('Telegram adapter not connected');} if (!this.bot) {throw new Error('Telegram adapter not connected');}
const chatId = Number(peerId); const chatId = Number(peerId);
const text = message.text; const text = message.text ?? '';
// Telegram rejects empty text messages.
// If there is no text, skip straight to attachments.
if (!text.trim()) {
if (message.attachments && message.attachments.length > 0) {
for (const attachment of message.attachments) {
await this.sendAttachment(chatId, attachment);
}
}
return;
}
const sendChunk = async (chunk: string): Promise<void> => {
// We default to Markdown for nicer formatting, but Telegram's Markdown parsing
// is strict and can fail on unescaped characters. If Telegram rejects the
// message, retry once without parse_mode so users still get the content.
try {
await this.bot!.api.sendMessage(chatId, chunk, { parse_mode: 'Markdown' });
} catch (error) {
const description = error && typeof error === 'object' && 'description' in error
? String((error as { description?: unknown }).description)
: '';
const isParseError = description.includes("can't parse entities")
|| description.includes('message text is empty');
if (!isParseError) {
throw error;
}
await this.bot!.api.sendMessage(chatId, chunk);
}
};
// Telegram enforces a 4096-character limit per message // Telegram enforces a 4096-character limit per message
if (text.length <= 4096) { if (text.length <= 4096) {
await this.bot.api.sendMessage(chatId, text, { parse_mode: 'Markdown' }); await sendChunk(text);
} else { } else {
const chunks = splitMessage(text, 4096); const chunks = splitMessage(text, 4096);
for (const chunk of chunks) { for (const chunk of chunks) {
await this.bot.api.sendMessage(chatId, chunk, { parse_mode: 'Markdown' }); await sendChunk(chunk);
} }
} }
+69
View File
@@ -163,6 +163,75 @@ automation:
expect(gmailCheck?.detail).toContain('flynn gmail-auth'); 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 () => { it('skips downstream checks when config is invalid', async () => {
const ctx: DoctorContext = { configPath: '/nonexistent/config.yaml', dataDir: testDir }; const ctx: DoctorContext = { configPath: '/nonexistent/config.yaml', dataDir: testDir };
const results = await runChecks(ctx); 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 // Check if API key is present for providers that need one
const needsKey = ['anthropic', 'openai', 'gemini', 'openrouter']; 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> = { const envVarMap: Record<string, string> = {
anthropic: 'ANTHROPIC_API_KEY', anthropic: 'ANTHROPIC_API_KEY',
openai: 'OPENAI_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}` }; 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'); const tokenPath = expandPath(gmail.token_file ?? '~/.config/flynn/gmail-token.json');
if (!existsSync(tokenPath)) { if (!existsSync(tokenPath)) {
return { status: 'warn', label: 'Gmail configured', detail: 'run `flynn gmail-auth` to authenticate' }; 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[] = [ const allChecks: Check[] = [
+2
View File
@@ -18,6 +18,7 @@ import { registerGcalAuthCommand } from './gcal-auth.js';
import { registerGdocsAuthCommand } from './gdocs-auth.js'; import { registerGdocsAuthCommand } from './gdocs-auth.js';
import { registerGdriveAuthCommand } from './gdrive-auth.js'; import { registerGdriveAuthCommand } from './gdrive-auth.js';
import { registerGtasksAuthCommand } from './gtasks-auth.js'; import { registerGtasksAuthCommand } from './gtasks-auth.js';
import { registerOpenaiAuthCommand } from './openai-auth.js';
import { registerSkillsCommand } from './skills.js'; import { registerSkillsCommand } from './skills.js';
export function createProgram(): Command { export function createProgram(): Command {
@@ -41,6 +42,7 @@ export function createProgram(): Command {
registerGdocsAuthCommand(program); registerGdocsAuthCommand(program);
registerGdriveAuthCommand(program); registerGdriveAuthCommand(program);
registerGtasksAuthCommand(program); registerGtasksAuthCommand(program);
registerOpenaiAuthCommand(program);
registerSkillsCommand(program); registerSkillsCommand(program);
return 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'); 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', () => { it('returns error when file not found', () => {
const result = loadConfigSafe('/nonexistent/config.yaml'); const result = loadConfigSafe('/nonexistent/config.yaml');
expect(result.config).toBeUndefined(); 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 type { Config } from '../config/index.js';
import { resolve, dirname, join } from 'path'; import { resolve, dirname, join } from 'path';
import { homedir } from 'os'; 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. */ /** Get the config file path from env or default location. */
export function getConfigPath(): string { export function getConfigPath(): string {
@@ -30,6 +62,7 @@ export function resolveOverlayPath(basePath: string): string | undefined {
export function loadConfigSafe(configPath?: string): { config?: Config; error?: string } { export function loadConfigSafe(configPath?: string): { config?: Config; error?: string } {
const path = configPath ?? getConfigPath(); const path = configPath ?? getConfigPath();
try { try {
loadEnvFileIfPresent();
const overlayPath = resolveOverlayPath(path); const overlayPath = resolveOverlayPath(path);
const config = loadConfig(path, overlayPath); const config = loadConfig(path, overlayPath);
return { config }; return { config };
+28 -1
View File
@@ -1,5 +1,5 @@
import type { Command } from 'commander'; 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 { loadConfigSafe, getConfigPath } from './shared.js';
import { existsSync, mkdirSync, readFileSync } from 'fs'; import { existsSync, mkdirSync, readFileSync } from 'fs';
import { resolve } from 'path'; 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.'; 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 { export function registerTuiCommand(program: Command): void {
program program
.command('tui') .command('tui')
@@ -179,6 +199,7 @@ export function registerTuiCommand(program: Command): void {
const toolExecutor = new ToolExecutor(toolRegistry, hookEngine); const toolExecutor = new ToolExecutor(toolRegistry, hookEngine);
const session = sessionManager.getSession('tui', 'local'); const session = sessionManager.getSession('tui', 'local');
const modelProviderConfigs = buildProviderConfigMap(config);
const agent = new NativeAgent({ const agent = new NativeAgent({
modelClient: modelRouter, modelClient: modelRouter,
@@ -219,6 +240,8 @@ export function registerTuiCommand(program: Command): void {
systemPrompt, systemPrompt,
model: config.models.default.model, model: config.models.default.model,
agent, agent,
hookEngine,
modelProviderConfigs,
onExit: cleanup, onExit: cleanup,
}); });
} else { } else {
@@ -230,8 +253,10 @@ export function registerTuiCommand(program: Command): void {
modelRouter, modelRouter,
systemPrompt, systemPrompt,
agent, agent,
hookEngine,
pairingManager, pairingManager,
localProviders: config.models.local_providers, localProviders: config.models.local_providers,
modelProviderConfigs,
currentLocalProvider: config.models.local?.provider, currentLocalProvider: config.models.local?.provider,
onTransfer: (target) => { onTransfer: (target) => {
if (target === 'telegram') { if (target === 'telegram') {
@@ -263,6 +288,8 @@ export function registerTuiCommand(program: Command): void {
systemPrompt, systemPrompt,
model: config.models.default.model, model: config.models.default.model,
agent, agent,
hookEngine,
modelProviderConfigs,
onExit: cleanup, onExit: cleanup,
}); });
return; return;
+37
View File
@@ -0,0 +1,37 @@
import { describe, it, expect, vi } from 'vitest';
import { createModelCommand } from './index.js';
describe('builtin /model command', () => {
it('passes through the full argument string', async () => {
const cmd = createModelCommand();
const setModel = vi.fn(() => 'ok');
const result = await cmd.execute(['default', 'github/gpt-5-mini'], {
channel: 'test',
senderId: 'user',
sessionId: 's1',
rawInput: '/model default github/gpt-5-mini',
services: { setModel },
});
expect(setModel).toHaveBeenCalledWith('default github/gpt-5-mini');
expect(result).toEqual({ handled: true, text: 'ok' });
});
it('still works for single-argument tier switching', async () => {
const cmd = createModelCommand();
const setModel = vi.fn(() => 'switched');
const result = await cmd.execute(['fast'], {
channel: 'test',
senderId: 'user',
sessionId: 's1',
rawInput: '/model fast',
services: { setModel },
});
expect(setModel).toHaveBeenCalledWith('fast');
expect(result).toEqual({ handled: true, text: 'switched' });
});
});
+3 -1
View File
@@ -86,7 +86,9 @@ export function createModelCommand(): CommandDefinition {
return { return {
handled: true, handled: true,
text: await ctx.services.setModel(args[0]), // Pass through the full argument string so frontends can support
// richer syntax like: /model <tier> <provider/model>
text: await ctx.services.setModel(args.join(' ')),
}; };
}, },
}; };
+26
View File
@@ -49,6 +49,8 @@ const modelConfigBaseSchema = z.object({
endpoint: z.string().optional(), endpoint: z.string().optional(),
api_key: z.string().optional(), api_key: z.string().optional(),
auth_token: z.string().optional(), auth_token: z.string().optional(),
/** Use OAuth credential flow (provider-specific). */
use_oauth: z.boolean().optional(),
for: z.array(z.string()).optional(), for: z.array(z.string()).optional(),
num_gpu: z.number().optional(), num_gpu: z.number().optional(),
context_window: z.number().optional(), context_window: z.number().optional(),
@@ -178,6 +180,30 @@ const gmailSchema = z.object({
enabled: z.boolean().default(false), enabled: z.boolean().default(false),
credentials_file: z.string().optional(), credentials_file: z.string().optional(),
token_file: z.string().default('~/.config/flynn/gmail-token.json'), token_file: z.string().default('~/.config/flynn/gmail-token.json'),
/**
* Optional Google Cloud Pub/Sub topic for Gmail push notifications.
* Format: projects/<project-id>/topics/<topic>
* If omitted, push notifications are disabled and Flynn will use polling.
*/
pubsub_topic: z.string().optional(),
/**
* Explicitly disable Gmail push watch registration even if pubsub_topic is set.
* Useful for environments where Google cannot reach the gateway (e.g. tailnet-only).
*/
disable_push: z.boolean().default(false),
/**
* Optional Pub/Sub subscription for pull-based delivery (no inbound webhook required).
* Format: projects/<project-id>/subscriptions/<subscription>
*/
pubsub_subscription_id: z.string().optional(),
/** How often to pull messages from pubsub_subscription_id (e.g. '60s'). */
pubsub_pull_interval: z.string().default('60s'),
/** Max messages to pull per cycle (1..100). */
pubsub_max_messages: z.number().min(1).max(100).default(10),
watch_labels: z.array(z.string()).default(['INBOX']), watch_labels: z.array(z.string()).default(['INBOX']),
poll_interval: z.string().default('300s'), poll_interval: z.string().default('300s'),
history_start: z.string().optional(), // ISO date string — only process emails after this date history_start: z.string().optional(), // ISO date string — only process emails after this date
+28
View File
@@ -96,6 +96,34 @@ describe('createClientFromConfig', () => {
expect(client).toBeInstanceOf(OpenAIClient); expect(client).toBeInstanceOf(OpenAIClient);
}); });
it('creates OpenAIClient for zhipuai when using auth_token', () => {
const client = createClientFromConfig({
provider: 'zhipuai',
model: 'glm-4.5',
auth_token: 'oauth-access-token',
});
expect(client).toBeInstanceOf(OpenAIClient);
});
it('creates OpenAIClient for zhipuai using ZHIPUAI_AUTH_TOKEN env var', () => {
const prev = process.env.ZHIPUAI_AUTH_TOKEN;
process.env.ZHIPUAI_AUTH_TOKEN = 'oauth-access-token';
try {
const client = createClientFromConfig({
provider: 'zhipuai',
model: 'glm-4.5',
});
expect(client).toBeInstanceOf(OpenAIClient);
} finally {
if (prev === undefined) {
delete process.env.ZHIPUAI_AUTH_TOKEN;
} else {
process.env.ZHIPUAI_AUTH_TOKEN = prev;
}
}
});
it('creates BedrockClient for bedrock provider', () => { it('creates BedrockClient for bedrock provider', () => {
const client = createClientFromConfig({ const client = createClientFromConfig({
provider: 'bedrock', provider: 'bedrock',
+19 -1
View File
@@ -18,6 +18,23 @@ function requireApiKey(cfg: ModelConfig, envVar: string): string {
return key; return key;
} }
function resolveAuthCredential(cfg: ModelConfig, apiKeyEnvVar: string, authTokenEnvVar?: string): string {
const raw = cfg.api_key
?? cfg.auth_token
?? process.env[apiKeyEnvVar]
?? (authTokenEnvVar ? process.env[authTokenEnvVar] : undefined);
if (!raw) {
const envHint = authTokenEnvVar ? `${apiKeyEnvVar} or ${authTokenEnvVar}` : apiKeyEnvVar;
throw new Error(
`Credential required for ${cfg.provider}. ` +
`Set ${envHint} environment variable or provide api_key/auth_token in config.`,
);
}
return raw.startsWith('Bearer ') ? raw.slice('Bearer '.length) : raw;
}
/** /**
* Create a ModelClient from a provider config entry. * Create a ModelClient from a provider config entry.
* Dispatches on the `provider` field so all tiers and fallback entries * Dispatches on the `provider` field so all tiers and fallback entries
@@ -35,6 +52,7 @@ export function createClientFromConfig(cfg: ModelConfig): ModelClient {
return new OpenAIClient({ return new OpenAIClient({
model: cfg.model, model: cfg.model,
apiKey: cfg.api_key, apiKey: cfg.api_key,
useOAuth: Boolean(cfg.use_oauth),
}); });
case 'ollama': case 'ollama':
return new OllamaClient({ return new OllamaClient({
@@ -62,7 +80,7 @@ export function createClientFromConfig(cfg: ModelConfig): ModelClient {
case 'zhipuai': case 'zhipuai':
return new OpenAIClient({ return new OpenAIClient({
model: cfg.model, model: cfg.model,
apiKey: requireApiKey(cfg, 'ZHIPUAI_API_KEY'), apiKey: resolveAuthCredential(cfg, 'ZHIPUAI_API_KEY', 'ZHIPUAI_AUTH_TOKEN'),
baseURL: cfg.endpoint ?? 'https://api.z.ai/api/paas/v4', baseURL: cfg.endpoint ?? 'https://api.z.ai/api/paas/v4',
}); });
case 'xai': case 'xai':
+112 -8
View File
@@ -9,7 +9,7 @@ import { MemoryStore } from '../memory/index.js';
import type { Tool } from '../tools/types.js'; import type { Tool } from '../tools/types.js';
import { createMediaSendTool } from '../tools/index.js'; import { createMediaSendTool } from '../tools/index.js';
import { createSandboxedShellTool, createSandboxedProcessStartTool, SandboxManager } from '../sandbox/index.js'; import { createSandboxedShellTool, createSandboxedProcessStartTool, SandboxManager } from '../sandbox/index.js';
import type { Config } from '../config/index.js'; import { MODEL_PROVIDERS, type Config, type ModelConfig, type ModelProvider } from '../config/index.js';
import { ModelRouter, type ModelTier } from '../models/index.js'; import { ModelRouter, type ModelTier } from '../models/index.js';
import { ToolRegistry, ToolExecutor } from '../tools/index.js'; import { ToolRegistry, ToolExecutor } from '../tools/index.js';
import { SessionManager } from '../session/index.js'; import { SessionManager } from '../session/index.js';
@@ -17,6 +17,27 @@ import { AgentConfigRegistry, AgentRouter } from '../agents/index.js';
import type { CommandRegistry } from '../commands/index.js'; import type { CommandRegistry } from '../commands/index.js';
import type { ComponentRegistry } from '../intents/index.js'; import type { ComponentRegistry } from '../intents/index.js';
import type { RoutingPolicy } from '../routing/index.js'; import type { RoutingPolicy } from '../routing/index.js';
import { createClientFromConfig } from './models.js';
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;
}
/** /**
* Create the unified message handler for the channel registry. * Create the unified message handler for the channel registry.
@@ -263,14 +284,97 @@ export function createMessageRouter(deps: {
return lines.join('\n'); return lines.join('\n');
}, },
setModel: (tier) => { setModel: (tier) => {
const validTiers = deps.modelRouter.getAvailableTiers(); const raw = tier.trim();
if (!validTiers.includes(tier as ModelTier)) { if (!raw) {
return `Model tier not available: ${tier}`; return 'Usage: /model <tier> OR /model <tier> <provider/model> OR /model <tier> reset';
}
const parts = raw.split(/\s+/);
const requestedTier = parts[0];
const validTiers = deps.modelRouter.getAvailableTiers();
if (!validTiers.includes(requestedTier as ModelTier)) {
return `Model tier not available: ${requestedTier}`;
}
const modelTier = requestedTier as ModelTier;
// /model <tier>
if (parts.length === 1) {
session.setConfig('modelTier', modelTier);
agent.setModelTier(modelTier);
const label = deps.modelRouter.getLabel(modelTier);
return `Switched to model: ${modelTier} (${label})`;
}
const arg2 = parts[1];
// /model <tier> reset — restore configured provider/model and re-enable fallbacks
if (arg2.toLowerCase() === 'reset') {
const configured: ModelConfig | undefined = modelTier === 'default'
? deps.config.models.default
: modelTier === 'fast'
? deps.config.models.fast
: modelTier === 'complex'
? deps.config.models.complex
: modelTier === 'local'
? deps.config.models.local
: undefined;
if (!configured) {
return `No configured model for tier: ${modelTier}`;
}
const client = createClientFromConfig(configured);
const label = `${configured.provider}/${configured.model}`;
deps.modelRouter.setClient(modelTier, client, label);
deps.modelRouter.setTierStrict(modelTier, false);
session.setConfig('modelTier', modelTier);
agent.setModelTier(modelTier);
return `Reset ${modelTier} to: ${label}`;
}
// /model <tier> <provider/model>
const providerModel = arg2;
if (!providerModel.includes('/')) {
return 'Invalid format. Use: /model <tier> <provider/model> (e.g. /model default github/gpt-5-mini)';
}
const slashIdx = providerModel.indexOf('/');
const provider = providerModel.slice(0, slashIdx);
const model = providerModel.slice(slashIdx + 1);
if (!MODEL_PROVIDERS.includes(provider as ModelProvider)) {
return `Unknown provider "${provider}". Known providers: ${MODEL_PROVIDERS.join(', ')}`;
}
const providerType = provider as ModelProvider;
const providerConfigs = buildProviderConfigMap(deps.config);
const template = providerConfigs[providerType];
try {
const client = createClientFromConfig(
template
? { ...template, provider: providerType, model }
: { provider: providerType, model },
);
deps.modelRouter.setClient(modelTier, client, providerModel);
deps.modelRouter.setTierStrict(modelTier, true);
session.setConfig('modelTier', modelTier);
agent.setModelTier(modelTier);
const lines = [
`Set ${modelTier} to: ${providerModel}`,
`Fallbacks for ${modelTier} disabled (strict tier mode).`,
];
if (parts.length > 2) {
lines.push(`Note: ignored extra args: ${parts.slice(2).join(' ')}`);
}
return lines.join('\n');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return `Failed to switch ${modelTier} to ${providerModel}: ${message}`;
} }
session.setConfig('modelTier', tier);
agent.setModelTier(tier as ModelTier);
const label = deps.modelRouter.getLabel(tier as ModelTier);
return `Switched to model: ${tier} (${label})`;
}, },
compact: async () => { compact: async () => {
const result = await agent.compact(); const result = await agent.compact();
+2 -2
View File
@@ -124,7 +124,7 @@ Commands:
/model [name] Show or switch model tier (local, default, fast, complex) /model [name] Show or switch model tier (local, default, fast, complex)
/model <tier> <p/m> Change tier's provider/model (e.g. /model default anthropic/claude-sonnet-4) /model <tier> <p/m> Change tier's provider/model (e.g. /model default anthropic/claude-sonnet-4)
/backend [provider] Show or switch local backend (ollama, llamacpp) /backend [provider] Show or switch local backend (ollama, llamacpp)
/login [provider] Authenticate with GitHub /login [provider] Authenticate with GitHub or OpenAI
/pair List pending pairing codes and approved senders /pair List pending pairing codes and approved senders
/pair generate [label] Generate a new DM pairing code /pair generate [label] Generate a new DM pairing code
/pair revoke <ch> <id> Revoke an approved sender /pair revoke <ch> <id> Revoke an approved sender
@@ -178,7 +178,7 @@ export const COMMAND_TOOLTIPS: Record<string, string> = {
'/status': 'Show session info and token usage', '/status': 'Show session info and token usage',
'/fullscreen': 'Switch to fullscreen mode', '/fullscreen': 'Switch to fullscreen mode',
'/fs': 'Switch to fullscreen mode', '/fs': 'Switch to fullscreen mode',
'/login': 'Authenticate with GitHub (OAuth device flow)', '/login': 'Authenticate with GitHub or OpenAI (OAuth device flow)',
'/pair': 'Generate/list/revoke DM pairing codes', '/pair': 'Generate/list/revoke DM pairing codes',
'/transfer': 'Transfer session to another frontend', '/transfer': 'Transfer session to another frontend',
'/quit': 'Exit TUI', '/quit': 'Exit TUI',
+184 -59
View File
@@ -1,5 +1,5 @@
import React, { useState, useCallback, useRef, useEffect } from 'react'; import React, { useState, useCallback, useRef, useEffect } from 'react';
import { Box, useApp, useInput } from 'ink'; import { Box, Text, useApp, useInput } from 'ink';
import { StatusBar } from './StatusBar.js'; import { StatusBar } from './StatusBar.js';
import { MessageList } from './MessageList.js'; import { MessageList } from './MessageList.js';
import { InputBar } from './InputBar.js'; import { InputBar } from './InputBar.js';
@@ -7,8 +7,11 @@ import { parseCommand, getHelpText, resolveModelAlias, getCommandCompletions } f
import type { Message, ModelClient, TokenUsage } from '../../../models/types.js'; import type { Message, ModelClient, TokenUsage } from '../../../models/types.js';
import type { ModelRouter } from '../../../models/router.js'; import type { ModelRouter } from '../../../models/router.js';
import type { ManagedSession } from '../../../session/index.js'; import type { ManagedSession } from '../../../session/index.js';
import type { NativeAgent } from '../../../backends/native/agent.js'; import type { NativeAgent, ToolUseEvent } from '../../../backends/native/agent.js';
import type { ToolUseEvent } from '../../../backends/native/agent.js'; import type { HookEngine, HookResult } from '../../../hooks/index.js';
import type { ModelConfig, ModelProvider } from '../../../config/schema.js';
import { MODEL_PROVIDERS } from '../../../config/schema.js';
import { createClientFromConfig } from '../../../daemon/index.js';
/** Format a tool name like "gmail.list" -> "Gmail: List" */ /** Format a tool name like "gmail.list" -> "Gmail: List" */
function formatToolName(name: string): string { function formatToolName(name: string): string {
@@ -44,6 +47,8 @@ export interface AppProps {
systemPrompt: string; systemPrompt: string;
model: string; model: string;
agent?: NativeAgent; agent?: NativeAgent;
hookEngine?: HookEngine;
modelProviderConfigs?: Partial<Record<ModelProvider, ModelConfig>>;
onExit?: () => void; onExit?: () => void;
} }
@@ -54,6 +59,8 @@ export function App({
systemPrompt, systemPrompt,
model, model,
agent, agent,
hookEngine,
modelProviderConfigs,
onExit, onExit,
}: AppProps): React.ReactElement { }: AppProps): React.ReactElement {
const { exit } = useApp(); const { exit } = useApp();
@@ -63,13 +70,20 @@ export function App({
const [streamingContent, setStreamingContent] = useState(''); const [streamingContent, setStreamingContent] = useState('');
const [scrollOffset, setScrollOffset] = useState(0); const [scrollOffset, setScrollOffset] = useState(0);
const [tokenUsage, setTokenUsage] = useState<TokenUsage>({ inputTokens: 0, outputTokens: 0 }); const [tokenUsage, setTokenUsage] = useState<TokenUsage>({ inputTokens: 0, outputTokens: 0 });
const [currentModel, setCurrentModel] = useState(model); const [currentModel, setCurrentModel] = useState(() => {
if (!modelRouter) {return model;}
return modelRouter.getLabel(modelRouter.getTier());
});
const abortRef = useRef(false); const abortRef = useRef(false);
const toolLinesRef = useRef<string[]>([]); const toolLinesRef = useRef<string[]>([]);
const confirmResolveRef = useRef<((result: HookResult) => void) | null>(null);
const [confirmation, setConfirmation] = useState<{ tool: string; args: Record<string, unknown> } | null>(null);
// Set up an Ink-compatible onToolUse callback for the agent. // Set up an Ink-compatible onToolUse callback for the agent.
// This replaces the process.stdout.write callback (which corrupts Ink rendering) // This replaces process.stdout writes (which corrupt Ink rendering)
// with one that updates React state to show tool activity in the streaming area. // with one that updates React state to show tool activity.
useEffect(() => { useEffect(() => {
if (!agent) {return;} if (!agent) {return;}
@@ -79,7 +93,10 @@ export function App({
const argsStr = event.args ? ` (${formatToolArgs(event.args)})` : ''; const argsStr = event.args ? ` (${formatToolArgs(event.args)})` : '';
toolLinesRef.current = [...toolLinesRef.current, `> ${label}${argsStr}`]; toolLinesRef.current = [...toolLinesRef.current, `> ${label}${argsStr}`];
setStreamingContent(toolLinesRef.current.join('\n')); setStreamingContent(toolLinesRef.current.join('\n'));
} else if (event.type === 'end' && event.result) { return;
}
if (event.type === 'end' && event.result) {
const icon = event.result.success ? 'done' : 'error'; const icon = event.result.success ? 'done' : 'error';
const detail = event.result.success const detail = event.result.success
? `(${event.result.output.split('\n').length} lines)` ? `(${event.result.output.split('\n').length} lines)`
@@ -95,7 +112,43 @@ export function App({
}; };
}, [agent]); }, [agent]);
// Inline confirmations for dangerous tools (e.g. shell.exec) in fullscreen mode.
useEffect(() => {
if (!hookEngine) {return;}
hookEngine.setInteractiveConfirmer(async (pending) => {
return await new Promise<HookResult>((resolve) => {
confirmResolveRef.current = resolve;
setConfirmation({ tool: pending.tool, args: pending.args });
});
});
return () => {
hookEngine.setInteractiveConfirmer(undefined);
confirmResolveRef.current = null;
setConfirmation(null);
};
}, [hookEngine]);
useInput((inputChar, key) => { useInput((inputChar, key) => {
// Confirmation prompt mode: capture y/n and ignore everything else.
if (confirmation && confirmResolveRef.current) {
const c = inputChar.toLowerCase();
if (c === 'y') {
confirmResolveRef.current({ approved: true });
confirmResolveRef.current = null;
setConfirmation(null);
return;
}
if (c === 'n') {
confirmResolveRef.current({ approved: false, reason: 'Denied by user' });
confirmResolveRef.current = null;
setConfirmation(null);
return;
}
return;
}
if (key.escape) { if (key.escape) {
if (isStreaming) { if (isStreaming) {
abortRef.current = true; abortRef.current = true;
@@ -120,7 +173,6 @@ export function App({
return; return;
} }
// Scroll handling
if (key.upArrow && scrollOffset > 0) { if (key.upArrow && scrollOffset > 0) {
setScrollOffset(prev => Math.max(0, prev - 1)); setScrollOffset(prev => Math.max(0, prev - 1));
} }
@@ -136,12 +188,15 @@ export function App({
}); });
const handleSubmit = useCallback(async (value: string) => { const handleSubmit = useCallback(async (value: string) => {
if (confirmation) {
return;
}
const command = parseCommand(value); const command = parseCommand(value);
if (!command) {return;} if (!command) {return;}
setInput(''); setInput('');
// Handle commands
switch (command.type) { switch (command.type) {
case 'quit': case 'quit':
onExit?.(); onExit?.();
@@ -160,69 +215,124 @@ export function App({
return; return;
case 'help': { case 'help': {
// Show help as system message
const helpMsg: Message = { role: 'assistant', content: getHelpText() }; const helpMsg: Message = { role: 'assistant', content: getHelpText() };
const helpWithTs = session.addMessage(helpMsg); setMessages(prev => [...prev, session.addMessage(helpMsg)]);
setMessages(prev => [...prev, helpWithTs]);
return; return;
} }
case 'status': { case 'status': {
const status = `Session: ${session.id}\nMessages: ${messages.length}\nTokens: ${tokenUsage.inputTokens} in / ${tokenUsage.outputTokens} out`; const status = `Session: ${session.id}\nMessages: ${messages.length}\nTokens: ${tokenUsage.inputTokens} in / ${tokenUsage.outputTokens} out`;
const statusMsg: Message = { role: 'assistant', content: status }; const statusMsg: Message = { role: 'assistant', content: status };
const statusWithTs = session.addMessage(statusMsg); setMessages(prev => [...prev, session.addMessage(statusMsg)]);
setMessages(prev => [...prev, statusWithTs]);
return; return;
} }
case 'model': { case 'model': {
if (!modelRouter) { if (!modelRouter) {
const errMsg: Message = { role: 'assistant', content: 'Model switching not available.' }; setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: 'Model switching not available.' })]);
const errWithTs = session.addMessage(errMsg);
setMessages(prev => [...prev, errWithTs]);
return; return;
} }
// /model
if (!command.name) { if (!command.name) {
const info = `Current: ${modelRouter.getTier()}\nAvailable: ${modelRouter.getAvailableTiers().join(', ')}`; const current = modelRouter.getTier();
const infoMsg: Message = { role: 'assistant', content: info }; const available = modelRouter.getAvailableTiers();
const infoWithTs = session.addMessage(infoMsg); const labels = modelRouter.getAllLabels();
setMessages(prev => [...prev, infoWithTs]);
const lines: string[] = [];
lines.push(`Active tier: ${current}`);
for (const t of available) {
const label = labels[t] ?? 'unknown';
const strict = modelRouter.isTierStrict(t) ? ' (strict)' : '';
lines.push(` ${t}: ${label}${strict}${t === current ? ' ←' : ''}`);
}
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: lines.join('\n') })]);
return; return;
} }
// /model <tier> <provider/model>
if (command.providerModel) {
const tier = resolveModelAlias(command.name);
const providerModel = command.providerModel;
const slashIdx = providerModel.indexOf('/');
if (slashIdx === -1) {
setMessages(prev => [...prev, session.addMessage({
role: 'assistant',
content: 'Invalid format. Use provider/model (e.g. anthropic/claude-sonnet-4)',
})]);
return;
}
const provider = providerModel.slice(0, slashIdx);
const modelName = providerModel.slice(slashIdx + 1);
if (!MODEL_PROVIDERS.includes(provider as ModelProvider)) {
setMessages(prev => [...prev, session.addMessage({
role: 'assistant',
content: `Unknown provider "${provider}". Known providers: ${MODEL_PROVIDERS.join(', ')}`,
})]);
return;
}
try {
const providerType = provider as ModelProvider;
const template = modelProviderConfigs?.[providerType];
const client = createClientFromConfig({
...(template ?? {}),
provider: providerType,
model: modelName,
});
modelRouter.setClient(tier, client, providerModel);
modelRouter.setTierStrict(tier, true);
if (agent && tier === modelRouter.getTier()) {
agent.setModelTier(tier);
setCurrentModel(modelRouter.getLabel(tier));
}
setMessages(prev => [...prev, session.addMessage({
role: 'assistant',
content: `Set ${tier} to: ${providerModel}\nFallbacks for ${tier} disabled (strict tier mode).`,
})]);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: `Failed to create client: ${msg}` })]);
}
return;
}
// /model <tier>
const tier = resolveModelAlias(command.name); const tier = resolveModelAlias(command.name);
if (modelRouter.setTier(tier)) { if (modelRouter.setTier(tier)) {
// Also update the agent tier so chatWithRouter uses the correct client
if (agent) { if (agent) {
agent.setModelTier(tier); agent.setModelTier(tier);
} }
setCurrentModel(tier); setCurrentModel(modelRouter.getLabel(tier));
const successMsg: Message = { role: 'assistant', content: `Switched to model: ${tier}` }; setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: `Switched to model: ${tier}` })]);
const successWithTs = session.addMessage(successMsg);
setMessages(prev => [...prev, successWithTs]);
} else { } else {
const failMsg: Message = { role: 'assistant', content: `Model not available: ${command.name}` }; setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: `Model not available: ${command.name}` })]);
const failWithTs = session.addMessage(failMsg);
setMessages(prev => [...prev, failWithTs]);
} }
return; return;
} }
case 'fullscreen': case 'fullscreen':
// Already in fullscreen
return; return;
case 'transfer': { case 'transfer':
const xferMsg: Message = { role: 'assistant', content: 'Transfer not supported in fullscreen mode.' }; setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: 'Transfer not supported in fullscreen mode.' })]);
const xferWithTs = session.addMessage(xferMsg);
setMessages(prev => [...prev, xferWithTs]);
return; return;
}
case 'message': case 'message':
break; // Continue to message handling break;
} }
if (command.type !== 'message' || isStreaming) {return;} if (command.type !== 'message' || isStreaming) {
return;
}
// Add user message to UI (and session if no agent — agent adds it internally) // Add user message to UI (and session if no agent — agent adds it internally)
const userMessage: Message = { role: 'user', content: command.content }; const userMessage: Message = { role: 'user', content: command.content };
@@ -232,9 +342,8 @@ export function App({
} else { } else {
setMessages(prev => [...prev, { ...userMessage, timestamp: Date.now() }]); setMessages(prev => [...prev, { ...userMessage, timestamp: Date.now() }]);
} }
setScrollOffset(0); // Auto-scroll to bottom setScrollOffset(0);
// Process response
setIsStreaming(true); setIsStreaming(true);
setStreamingContent(''); setStreamingContent('');
toolLinesRef.current = []; toolLinesRef.current = [];
@@ -242,16 +351,11 @@ export function App({
try { try {
if (agent) { if (agent) {
// agent.process() handles session history internally await agent.process(command.content);
const response = await agent.process(command.content);
const usage = agent.getUsage(); const usage = agent.getUsage();
setTokenUsage({ inputTokens: usage.inputTokens, outputTokens: usage.outputTokens }); setTokenUsage({ inputTokens: usage.inputTokens, outputTokens: usage.outputTokens });
// Sync UI with session history (agent already added messages to session)
setMessages(session.getHistory()); setMessages(session.getHistory());
} else if (modelClient.chatStream) { } else if (modelClient.chatStream) {
// Fallback: direct streaming without tools
let fullContent = ''; let fullContent = '';
for await (const event of modelClient.chatStream({ for await (const event of modelClient.chatStream({
@@ -279,10 +383,8 @@ export function App({
} }
const assistantMessage: Message = { role: 'assistant', content: fullContent }; const assistantMessage: Message = { role: 'assistant', content: fullContent };
const assistantWithTimestamp = session.addMessage(assistantMessage); setMessages(prev => [...prev, session.addMessage(assistantMessage)]);
setMessages(prev => [...prev, assistantWithTimestamp]);
} else { } else {
// Fallback: non-streaming without tools
const response = await modelClient.chat({ const response = await modelClient.chat({
messages: session.getHistory(), messages: session.getHistory(),
system: systemPrompt, system: systemPrompt,
@@ -294,21 +396,30 @@ export function App({
})); }));
const assistantMessage: Message = { role: 'assistant', content: response.content }; const assistantMessage: Message = { role: 'assistant', content: response.content };
const assistantWithTimestamp = session.addMessage(assistantMessage); setMessages(prev => [...prev, session.addMessage(assistantMessage)]);
setMessages(prev => [...prev, assistantWithTimestamp]);
} }
} catch (error) { } catch (error) {
const errorMessage: Message = { const msg = error instanceof Error ? error.message : 'Unknown error';
role: 'assistant', setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: `Error: ${msg}` })]);
content: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
const errorWithTimestamp = session.addMessage(errorMessage);
setMessages(prev => [...prev, errorWithTimestamp]);
} finally { } finally {
setIsStreaming(false); setIsStreaming(false);
setStreamingContent(''); setStreamingContent('');
} }
}, [isStreaming, session, agent, modelClient, modelRouter, systemPrompt, exit, onExit, messages.length, tokenUsage]); }, [
confirmation,
session,
agent,
modelClient,
modelRouter,
systemPrompt,
exit,
onExit,
isStreaming,
messages.length,
tokenUsage.inputTokens,
tokenUsage.outputTokens,
modelProviderConfigs,
]);
return ( return (
<Box flexDirection="column" height="100%"> <Box flexDirection="column" height="100%">
@@ -317,13 +428,27 @@ export function App({
scrollOffset={scrollOffset} scrollOffset={scrollOffset}
streamingContent={isStreaming ? streamingContent : undefined} streamingContent={isStreaming ? streamingContent : undefined}
/> />
{confirmation ? (
<Box paddingX={1} paddingY={0} borderStyle="round" borderColor="yellow">
<Text color="yellow">
Confirmation required: {confirmation.tool}{' '}
{Object.keys(confirmation.args).length > 0 ? JSON.stringify(confirmation.args) : ''}
</Text>
<Text color="yellow">Press y to approve, n to deny.</Text>
</Box>
) : null}
<InputBar <InputBar
value={input} value={input}
onChange={setInput} onChange={setInput}
onSubmit={handleSubmit} onSubmit={handleSubmit}
isLoading={isStreaming} isLoading={isStreaming || !!confirmation}
placeholder={isStreaming ? 'Flynn is typing... (Esc to cancel)' : 'Type a message... (Esc=exit, /help)'} placeholder={confirmation
? 'Confirmation required (press y/n)'
: (isStreaming ? 'Flynn is typing... (Esc to cancel)' : 'Type a message... (Esc=exit, /help)')}
/> />
<StatusBar <StatusBar
sessionId={session.id} sessionId={session.id}
messageCount={messages.length} messageCount={messages.length}
+10
View File
@@ -5,6 +5,8 @@ import type { ManagedSession } from '../../session/index.js';
import type { ModelClient } from '../../models/types.js'; import type { ModelClient } from '../../models/types.js';
import type { ModelRouter } from '../../models/router.js'; import type { ModelRouter } from '../../models/router.js';
import type { NativeAgent } from '../../backends/native/agent.js'; import type { NativeAgent } from '../../backends/native/agent.js';
import type { HookEngine } from '../../hooks/index.js';
import type { ModelConfig, ModelProvider } from '../../config/index.js';
export interface FullscreenTuiConfig { export interface FullscreenTuiConfig {
session: ManagedSession; session: ManagedSession;
@@ -13,6 +15,8 @@ export interface FullscreenTuiConfig {
systemPrompt: string; systemPrompt: string;
model: string; model: string;
agent?: NativeAgent; agent?: NativeAgent;
hookEngine?: HookEngine;
modelProviderConfigs?: Partial<Record<ModelProvider, ModelConfig>>;
onExit?: () => void; onExit?: () => void;
} }
@@ -22,6 +26,10 @@ export async function startFullscreenTui(config: FullscreenTuiConfig): Promise<v
process.stdin.resume(); process.stdin.resume();
} }
if (config.agent && config.modelRouter) {
config.agent.setModelTier(config.modelRouter.getTier());
}
const { waitUntilExit } = render( const { waitUntilExit } = render(
React.createElement(App, { React.createElement(App, {
session: config.session, session: config.session,
@@ -30,6 +38,8 @@ export async function startFullscreenTui(config: FullscreenTuiConfig): Promise<v
systemPrompt: config.systemPrompt, systemPrompt: config.systemPrompt,
model: config.model, model: config.model,
agent: config.agent, agent: config.agent,
hookEngine: config.hookEngine,
modelProviderConfigs: config.modelProviderConfigs,
onExit: config.onExit, onExit: config.onExit,
}), }),
); );
+53
View File
@@ -118,4 +118,57 @@ describe('MinimalTui backend command', () => {
expect(mockRouter.setTier).toHaveBeenCalledWith('local'); expect(mockRouter.setTier).toHaveBeenCalledWith('local');
expect(mockAgent.setModelTier).toHaveBeenCalledWith('local'); expect(mockAgent.setModelTier).toHaveBeenCalledWith('local');
}); });
it('reuses configured provider credentials for /model <tier> <provider/model>', () => {
const prevOpenRouterKey = process.env.OPENROUTER_API_KEY;
delete process.env.OPENROUTER_API_KEY;
try {
const mockSession = {
id: 'test',
getHistory: () => [],
addMessage: vi.fn(),
clear: vi.fn(),
replaceHistory: vi.fn(),
};
const mockRouter = {
getTier: () => 'default' as const,
getAvailableTiers: () => ['default', 'local'],
setTier: vi.fn(() => true),
getLocalProviderName: () => 'ollama',
setLocalClient: vi.fn(),
setClient: vi.fn(),
setTierStrict: vi.fn(),
chat: vi.fn(),
getClient: vi.fn(),
};
const tui = new MinimalTui({
session: mockSession as any,
modelClient: mockRouter as any,
modelRouter: mockRouter as any,
systemPrompt: 'test',
modelProviderConfigs: {
openrouter: {
provider: 'openrouter',
model: 'seed-model',
api_key: 'test-key',
endpoint: 'https://openrouter.ai/api/v1',
},
},
});
(tui as any).handleModelCommand('default', 'openrouter/deepseek/deepseek-chat');
expect(mockRouter.setClient).toHaveBeenCalledOnce();
expect(mockRouter.setTierStrict).toHaveBeenCalledWith('default', true);
} finally {
if (prevOpenRouterKey) {
process.env.OPENROUTER_API_KEY = prevOpenRouterKey;
} else {
delete process.env.OPENROUTER_API_KEY;
}
}
});
}); });
+70 -8
View File
@@ -9,9 +9,10 @@ import type { ModelConfig, ModelProvider } from '../../config/schema.js';
import { MODEL_PROVIDERS } from '../../config/schema.js'; import { MODEL_PROVIDERS } from '../../config/schema.js';
import { OllamaClient, LlamaCppClient } from '../../models/index.js'; import { OllamaClient, LlamaCppClient } from '../../models/index.js';
import { createClientFromConfig } from '../../daemon/index.js'; import { createClientFromConfig } from '../../daemon/index.js';
import { loginGitHub } from '../../auth/index.js'; import { loginGitHub, loginOpenAI } from '../../auth/index.js';
import type { PairingManager } from '../../channels/pairing.js'; import type { PairingManager } from '../../channels/pairing.js';
import { getColoredBanner } from './banner.js'; import { getColoredBanner } from './banner.js';
import type { HookEngine } from '../../hooks/index.js';
export { parseCommand, type Command }; export { parseCommand, type Command };
@@ -42,8 +43,10 @@ export interface MinimalTuiConfig {
onFullscreen?: () => void; onFullscreen?: () => void;
onTransfer?: (target: string) => void; onTransfer?: (target: string) => void;
localProviders?: Record<string, ModelConfig>; localProviders?: Record<string, ModelConfig>;
modelProviderConfigs?: Partial<Record<ModelProvider, ModelConfig>>;
currentLocalProvider?: string; currentLocalProvider?: string;
pairingManager?: PairingManager; pairingManager?: PairingManager;
hookEngine?: HookEngine;
} }
export class MinimalTui { export class MinimalTui {
@@ -99,6 +102,10 @@ export class MinimalTui {
async start(): Promise<void> { async start(): Promise<void> {
this.running = true; this.running = true;
if (this.config.agent && this.config.modelRouter) {
this.config.agent.setModelTier(this.config.modelRouter.getTier());
}
this.rl = readline.createInterface({ this.rl = readline.createInterface({
input: process.stdin, input: process.stdin,
output: process.stdout, output: process.stdout,
@@ -108,6 +115,26 @@ export class MinimalTui {
}, },
}); });
// In minimal TUI we can prompt inline for tool confirmations.
// This avoids deadlocks when hooks are configured to require confirmation
// (e.g. shell.exec) and the tool loop is awaiting a decision.
if (this.config.hookEngine) {
this.config.hookEngine.setInteractiveConfirmer(async (pending) => {
const tool = pending.tool;
const args = pending.args;
const argsStr = Object.keys(args).length > 0 ? ` ${JSON.stringify(args)}` : '';
console.log(`\n${colors.bold}Confirmation required${colors.reset}`);
console.log(`${colors.gray}${tool}${colors.reset}${argsStr}`);
const answer = (await this.prompt(`${colors.orange}${colors.bold}Approve?${colors.reset} ${colors.gray}(y/N)${colors.reset} `))
.trim()
.toLowerCase();
const approved = answer === 'y' || answer === 'yes';
console.log(approved ? `${colors.gray}Approved.${colors.reset}\n` : `${colors.gray}Denied.${colors.reset}\n`);
return approved ? { approved: true } : { approved: false, reason: 'Denied by user' };
});
}
// Listen for line changes to show hints // Listen for line changes to show hints
process.stdin.on('keypress', () => { process.stdin.on('keypress', () => {
// Small delay to let readline update the line // Small delay to let readline update the line
@@ -239,9 +266,22 @@ export class MinimalTui {
} }
try { try {
const client = createClientFromConfig({ provider: provider as ModelProvider, model }); const providerType = provider as ModelProvider;
const template = this.config.modelProviderConfigs?.[providerType];
const client = createClientFromConfig({
...(template ?? {}),
provider: providerType,
model,
});
router.setClient(tier, client, providerModel); router.setClient(tier, client, providerModel);
console.log(`${colors.gray}Set ${tier} to:${colors.reset} ${providerModel}\n`); router.setTierStrict(tier, true);
if (this.config.agent && tier === router.getTier()) {
this.config.agent.setModelTier(tier);
}
console.log(`${colors.gray}Set ${tier} to:${colors.reset} ${providerModel}`);
console.log(`${colors.gray}Fallbacks for ${tier} disabled (strict tier mode).${colors.reset}\n`);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
console.log(`${colors.gray}Failed to create client:${colors.reset} ${message}\n`); console.log(`${colors.gray}Failed to create client:${colors.reset} ${message}\n`);
@@ -383,11 +423,7 @@ export class MinimalTui {
private async handleLoginCommand(provider?: string): Promise<void> { private async handleLoginCommand(provider?: string): Promise<void> {
const target = provider ?? 'github'; const target = provider ?? 'github';
if (target !== 'github') { if (target === 'github') {
console.log(`${colors.gray}Unknown login provider:${colors.reset} ${target}. Only 'github' is supported.\n`);
return;
}
console.log(`${colors.gray}Starting GitHub OAuth device flow...${colors.reset}`); console.log(`${colors.gray}Starting GitHub OAuth device flow...${colors.reset}`);
try { try {
@@ -404,6 +440,32 @@ export class MinimalTui {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
console.log(`${colors.gray}GitHub login failed:${colors.reset} ${message}\n`); console.log(`${colors.gray}GitHub login failed:${colors.reset} ${message}\n`);
} }
return;
}
if (target === 'openai') {
console.log(`${colors.gray}Starting OpenAI OAuth device flow...${colors.reset}`);
try {
await loginOpenAI((userCode, verificationUri) => {
console.log('');
console.log(`${colors.gray}Please visit:${colors.reset} ${verificationUri}`);
console.log(`${colors.gray}and enter code:${colors.reset} ${userCode}`);
console.log('');
console.log(`${colors.gray}Waiting for authorization...${colors.reset}`);
});
console.log(`${colors.gray}OpenAI authentication successful! Token stored.${colors.reset}\n`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.log(`${colors.gray}OpenAI login failed:${colors.reset} ${message}\n`);
}
return;
}
console.log(`${colors.gray}Unknown login provider:${colors.reset} ${target}. Supported: github, openai\n`);
} }
private handlePairCommand(action?: 'generate' | 'list' | 'revoke', args?: string): void { private handlePairCommand(action?: 'generate' | 'list' | 'revoke', args?: string): void {
+11 -3
View File
@@ -88,11 +88,19 @@ export function createAgentHandlers(deps: AgentHandlerDeps) {
return lines.join('\n'); return lines.join('\n');
}, },
getModel: () => `Current model tier: ${agent.getModelTier()}`, getModel: () => `Current model tier: ${agent.getModelTier()}`,
setModel: (tier) => { setModel: (input) => {
const raw = input.trim();
if (!raw) {
return 'Usage: /model <tier>';
}
const [requestedTier, ...rest] = raw.split(/\s+/);
const validTiers: ModelTier[] = ['fast', 'default', 'complex', 'local']; const validTiers: ModelTier[] = ['fast', 'default', 'complex', 'local'];
const modelTier = tier as ModelTier; const modelTier = requestedTier as ModelTier;
if (!validTiers.includes(modelTier)) { if (!validTiers.includes(modelTier)) {
return `Invalid tier: ${tier}. Available: ${validTiers.join(', ')}`; return `Invalid tier: ${requestedTier}. Available: ${validTiers.join(', ')}`;
}
if (rest.length > 0) {
return `Switched to model tier: ${modelTier}\nNote: provider/model switching is not available via gateway (/model <tier> <provider/model>).`;
} }
agent.setModelTier(modelTier); agent.setModelTier(modelTier);
if (sessionId && deps.sessionManager) { if (sessionId && deps.sessionManager) {
+15
View File
@@ -72,4 +72,19 @@ describe('HookEngine', () => {
expect(result.approved).toBe(false); expect(result.approved).toBe(false);
expect(result.reason).toBe('Too dangerous'); expect(result.reason).toBe('Too dangerous');
}); });
it('uses interactive confirmer when set (no pending queue)', async () => {
const engine = new HookEngine({ confirm: ['shell.*'], log: [], silent: [] });
const confirmer = vi.fn(async () => ({ approved: true }));
engine.setInteractiveConfirmer(confirmer);
const result = await engine.requestConfirmation('shell.exec', { cmd: 'ls' });
expect(result.approved).toBe(true);
expect(engine.getPendingConfirmations()).toHaveLength(0);
expect(confirmer).toHaveBeenCalledOnce();
expect(confirmer).toHaveBeenCalledWith(expect.objectContaining({
tool: 'shell.exec',
args: { cmd: 'ls' },
}));
});
}); });
+20
View File
@@ -1,16 +1,32 @@
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import type { HookAction, HookResult, PendingConfirmation, HookConfig } from './types.js'; import type { HookAction, HookResult, PendingConfirmation, HookConfig } from './types.js';
export type InteractiveConfirmer = (pending: {
id: string;
tool: string;
args: Record<string, unknown>;
}) => Promise<HookResult>;
export class HookEngine { export class HookEngine {
private confirmPatterns: RegExp[]; private confirmPatterns: RegExp[];
private logPatterns: RegExp[]; private logPatterns: RegExp[];
private pendingConfirmations: Map<string, PendingConfirmation> = new Map(); private pendingConfirmations: Map<string, PendingConfirmation> = new Map();
private interactiveConfirmer?: InteractiveConfirmer;
constructor(config: HookConfig) { constructor(config: HookConfig) {
this.confirmPatterns = config.confirm.map(p => this.patternToRegex(p)); this.confirmPatterns = config.confirm.map(p => this.patternToRegex(p));
this.logPatterns = config.log.map(p => this.patternToRegex(p)); this.logPatterns = config.log.map(p => this.patternToRegex(p));
} }
/**
* Optional interactive confirmation handler.
* When set, confirmation requests are handled immediately (no pending queue).
* Useful for CLI/TUI environments where we can prompt the user inline.
*/
setInteractiveConfirmer(confirmer: InteractiveConfirmer | undefined): void {
this.interactiveConfirmer = confirmer;
}
private patternToRegex(pattern: string): RegExp { private patternToRegex(pattern: string): RegExp {
const escaped = pattern const escaped = pattern
.replace(/[.+^${}()|[\]\\]/g, '\\$&') .replace(/[.+^${}()|[\]\\]/g, '\\$&')
@@ -31,6 +47,10 @@ export class HookEngine {
async requestConfirmation(tool: string, args: Record<string, unknown>): Promise<HookResult> { async requestConfirmation(tool: string, args: Record<string, unknown>): Promise<HookResult> {
const id = randomUUID(); const id = randomUUID();
if (this.interactiveConfirmer) {
return await this.interactiveConfirmer({ id, tool, args });
}
return new Promise((resolve) => { return new Promise((resolve) => {
const pending: PendingConfirmation = { const pending: PendingConfirmation = {
id, id,
+38
View File
@@ -140,6 +140,44 @@ describe('LlamaCppClient', () => {
}]); }]);
}); });
it('sanitizes web_search tool schema for llama.cpp', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({
choices: [{ message: { content: 'ok' } }],
usage: { prompt_tokens: 1, completion_tokens: 1 },
}),
});
const client = new LlamaCppClient({
endpoint: 'http://localhost:8080',
model: 'test-model',
});
await client.chat({
messages: [{ role: 'user', content: 'search' }],
tools: [{
name: 'web_search',
description: 'Search',
input_schema: {
type: 'object',
properties: {
query: { type: 'string' },
count: { type: 'number' },
},
required: ['query'],
},
}],
});
const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(requestBody.tools[0].function.parameters).toEqual({
type: 'object',
properties: { query: { type: 'string' } },
required: ['query'],
});
});
it('parses tool_calls from response', async () => { it('parses tool_calls from response', async () => {
mockFetch.mockResolvedValue({ mockFetch.mockResolvedValue({
ok: true, ok: true,
+32 -2
View File
@@ -48,6 +48,36 @@ interface LlamaCppStreamChunk {
usage?: { prompt_tokens: number; completion_tokens: number }; usage?: { prompt_tokens: number; completion_tokens: number };
} }
function sanitizeToolParametersForLlamaCpp(toolName: string, parameters: unknown): unknown {
// llama.cpp is stricter than most tool-call APIs about JSON schema.
// In particular, some builds reject extra optional properties for common tools.
// Keep the full schema for most tools, but reduce known-problematic ones.
if (toolName !== 'web_search') {
return parameters;
}
if (!parameters || typeof parameters !== 'object') {
return parameters;
}
const schema = parameters as {
type?: unknown;
properties?: Record<string, unknown>;
required?: unknown;
};
const querySchema = schema.properties?.query;
if (!querySchema) {
return parameters;
}
return {
type: 'object',
properties: { query: querySchema },
required: ['query'],
};
}
/** Message format for OpenAI-compatible chat completions API. */ /** Message format for OpenAI-compatible chat completions API. */
interface LlamaCppChatMessage { interface LlamaCppChatMessage {
role: 'system' | 'user' | 'assistant' | 'tool'; role: 'system' | 'user' | 'assistant' | 'tool';
@@ -211,7 +241,7 @@ export class LlamaCppClient implements ModelClient {
function: { function: {
name: t.name, name: t.name,
description: t.description, description: t.description,
parameters: t.input_schema, parameters: sanitizeToolParametersForLlamaCpp(t.name, t.input_schema),
}, },
})); }));
} }
@@ -292,7 +322,7 @@ export class LlamaCppClient implements ModelClient {
function: { function: {
name: t.name, name: t.name,
description: t.description, description: t.description,
parameters: t.input_schema, parameters: sanitizeToolParametersForLlamaCpp(t.name, t.input_schema),
}, },
})); }));
} }
+68
View File
@@ -0,0 +1,68 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { OpenAIClient } from './openai.js';
vi.mock('../auth/openai.js', () => ({
ensureValidOpenAIAuth: vi.fn(async () => ({
access_token: 'at',
refresh_token: 'rt',
expires_at: Date.now() + 60_000,
created_at: new Date().toISOString(),
account_id: 'acct',
})),
}));
function makeSse(events: Array<{ event: string; data: any }>): string {
return events
.map((e) => `event: ${e.event}\ndata: ${JSON.stringify(e.data)}\n\n`)
.join('');
}
describe('OpenAIClient OAuth (Codex)', () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
globalThis.fetch = originalFetch;
vi.useRealTimers();
vi.restoreAllMocks();
});
it('streams SSE and accumulates output_text.delta', async () => {
const sse = makeSse([
{ event: 'response.created', data: { type: 'response.created', response: { id: 'r1' } } },
{ event: 'response.output_text.delta', data: { type: 'response.output_text.delta', delta: 'hel' } },
{ event: 'response.output_text.delta', data: { type: 'response.output_text.delta', delta: 'lo' } },
{ event: 'response.completed', data: { type: 'response.completed', response: { usage: { input_tokens: 2, output_tokens: 2 } } } },
]);
globalThis.fetch = vi.fn(async (_url: any, init?: any) => {
const parsed = JSON.parse(init.body);
expect(parsed.store).toBe(false);
expect(parsed.stream).toBe(true);
expect(typeof parsed.instructions).toBe('string');
expect(Array.isArray(parsed.input)).toBe(true);
const stream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(sse));
controller.close();
},
});
return new Response(stream, { status: 200 });
}) as any;
const client = new OpenAIClient({ model: 'gpt-5.3-codex', useOAuth: true });
const resp = await client.chat({
system: 'You are helpful.',
messages: [{ role: 'user', content: 'hi' }],
});
expect(resp.content).toBe('hello');
expect(resp.usage).toEqual({ inputTokens: 2, outputTokens: 2 });
});
});
+20 -5
View File
@@ -1,23 +1,38 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { OpenAIClient } from './openai.js'; import { OpenAIClient } from './openai.js';
// Shared mock function so we can override per-test const { mockCreate, mockOpenAIConstructor } = vi.hoisted(() => {
const mockCreate = vi.fn().mockResolvedValue({ const mockCreate = vi.fn().mockResolvedValue({
choices: [{ message: { content: 'Hello from GPT!' }, finish_reason: 'stop' }], choices: [{ message: { content: 'Hello from GPT!' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 10, completion_tokens: 5 }, usage: { prompt_tokens: 10, completion_tokens: 5 },
}); });
const mockOpenAIConstructor = vi.fn().mockImplementation(() => ({
vi.mock('openai', () => ({
default: vi.fn().mockImplementation(() => ({
chat: { chat: {
completions: { completions: {
create: mockCreate, create: mockCreate,
}, },
}, },
})), }));
return { mockCreate, mockOpenAIConstructor };
});
vi.mock('openai', () => ({
default: mockOpenAIConstructor,
})); }));
describe('OpenAIClient', () => { describe('OpenAIClient', () => {
it('sets request timeout and disables SDK retries', () => {
new OpenAIClient({
apiKey: 'test-key',
model: 'gpt-4o',
});
expect(mockOpenAIConstructor).toHaveBeenCalledWith(expect.objectContaining({
timeout: 20_000,
maxRetries: 0,
}));
});
it('sends messages and returns response', async () => { it('sends messages and returns response', async () => {
const client = new OpenAIClient({ const client = new OpenAIClient({
apiKey: 'test-key', apiKey: 'test-key',
+147 -2
View File
@@ -1,11 +1,16 @@
import OpenAI from 'openai'; import OpenAI from 'openai';
import type { ChatRequest, ChatResponse, ModelClient, MessageContentPart } from './types.js'; import type { ChatRequest, ChatResponse, ModelClient, MessageContentPart, TokenUsage } from './types.js';
import { getMessageTextWithTools } from './media.js';
import { ensureValidOpenAIAuth } from '../auth/openai.js';
export interface OpenAIClientConfig { export interface OpenAIClientConfig {
apiKey?: string; apiKey?: string;
model: string; model: string;
maxTokens?: number; maxTokens?: number;
baseURL?: string; baseURL?: string;
timeoutMs?: number;
/** If true, use ChatGPT subscription OAuth via the Codex backend endpoint. */
useOAuth?: boolean;
} }
/** /**
@@ -52,20 +57,160 @@ function toOpenAIContent(content: string | MessageContentPart[]): string | OpenA
} }
export class OpenAIClient implements ModelClient { export class OpenAIClient implements ModelClient {
private client: OpenAI; private client?: OpenAI;
private model: string; private model: string;
private defaultMaxTokens: number; private defaultMaxTokens: number;
private useOAuth: boolean;
constructor(config: OpenAIClientConfig) { constructor(config: OpenAIClientConfig) {
const timeoutMs = config.timeoutMs ?? 20_000;
this.useOAuth = Boolean(config.useOAuth);
// OAuth mode uses a different backend (ChatGPT Codex) and a different API shape.
// Only initialize the OpenAI SDK for API-key providers.
if (!this.useOAuth) {
this.client = new OpenAI({ this.client = new OpenAI({
apiKey: config.apiKey, apiKey: config.apiKey,
baseURL: config.baseURL, baseURL: config.baseURL,
timeout: timeoutMs,
maxRetries: 0,
}); });
}
this.model = config.model; this.model = config.model;
this.defaultMaxTokens = config.maxTokens ?? 4096; this.defaultMaxTokens = config.maxTokens ?? 4096;
} }
private async chatViaOAuthCodex(request: ChatRequest): Promise<ChatResponse> {
const CODEX_API_ENDPOINT = 'https://chatgpt.com/backend-api/codex/responses';
const auth = await ensureValidOpenAIAuth();
// Codex endpoint requires:
// - instructions (non-empty)
// - input must be a list
// - store must be false
// - stream must be true (SSE)
const instructions = (request.system ?? '').trim() || 'You are helpful.';
const input = request.messages
.map((m) => {
const text = getMessageTextWithTools(m);
if (!text) {return null;}
return {
role: m.role,
content: [{ type: 'input_text', text }],
};
})
.filter((x): x is NonNullable<typeof x> => Boolean(x));
const body = {
model: this.model,
instructions,
store: false,
stream: true,
input,
// Intentionally omit max_output_tokens: Codex endpoint rejects it.
// Also omit tools/tool_choice for now.
};
const headers: Record<string, string> = {
'content-type': 'application/json',
'authorization': `Bearer ${auth.access_token}`,
'originator': 'flynn',
'user-agent': 'flynn/0.1',
'session_id': `flynn-${Date.now()}`,
};
if (auth.account_id) {
headers['ChatGPT-Account-Id'] = auth.account_id;
}
const res = await fetch(CODEX_API_ENDPOINT, {
method: 'POST',
headers,
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`${res.status} ${res.statusText}${text ? `: ${text}` : ''}`);
}
if (!res.body) {
throw new Error('OpenAI OAuth request failed: missing response body');
}
let buffer = '';
let outputText = '';
let usage: TokenUsage | undefined;
const reader = res.body.getReader();
const processBlock = (block: string): void => {
const lines = block.split('\n');
let data = '';
for (const line of lines) {
if (line.startsWith('data:')) {
data += line.slice('data:'.length).trim();
}
}
if (!data) {return;}
let obj: any;
try {
obj = JSON.parse(data);
} catch {
return;
}
if (obj.type === 'response.output_text.delta' && typeof obj.delta === 'string') {
outputText += obj.delta;
}
if (obj.type === 'response.completed') {
const u = obj.response?.usage;
if (u) {
usage = {
inputTokens: u.input_tokens ?? 0,
outputTokens: u.output_tokens ?? 0,
};
}
}
if (obj.type === 'response.failed') {
const detail = obj.response?.error?.message ?? 'OpenAI OAuth response failed';
throw new Error(detail);
}
};
while (true) {
const { value, done } = await reader.read();
if (done) {break;}
buffer += Buffer.from(value).toString('utf8');
while (true) {
const idx = buffer.indexOf('\n\n');
if (idx === -1) {break;}
const block = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
processBlock(block);
}
}
return {
content: outputText,
stopReason: 'end_turn',
usage: usage ?? { inputTokens: 0, outputTokens: 0 },
};
}
async chat(request: ChatRequest): Promise<ChatResponse> { async chat(request: ChatRequest): Promise<ChatResponse> {
if (this.useOAuth) {
return this.chatViaOAuthCodex(request);
}
if (!this.client) {
throw new Error('OpenAI client not initialized');
}
const messages: OpenAI.ChatCompletionMessageParam[] = []; const messages: OpenAI.ChatCompletionMessageParam[] = [];
if (request.system) { if (request.system) {
+8 -3
View File
@@ -4,10 +4,15 @@ import type { RetryConfig } from './retry.js';
describe('isRetryable', () => { describe('isRetryable', () => {
it('returns true for generic errors', () => { it('returns true for generic errors', () => {
const error = new Error('Connection timeout'); const error = new Error('Connection reset by peer');
expect(isRetryable(error, DEFAULT_RETRY_CONFIG.nonRetryablePatterns)).toBe(true); expect(isRetryable(error, DEFAULT_RETRY_CONFIG.nonRetryablePatterns)).toBe(true);
}); });
it('returns false for timeout errors', () => {
const error = new Error('Request timed out after 20000ms');
expect(isRetryable(error, DEFAULT_RETRY_CONFIG.nonRetryablePatterns)).toBe(false);
});
it('returns false for authentication errors', () => { it('returns false for authentication errors', () => {
const error = new Error('Invalid API key: authentication failed'); const error = new Error('Invalid API key: authentication failed');
expect(isRetryable(error, DEFAULT_RETRY_CONFIG.nonRetryablePatterns)).toBe(false); expect(isRetryable(error, DEFAULT_RETRY_CONFIG.nonRetryablePatterns)).toBe(false);
@@ -75,8 +80,8 @@ describe('withRetry', () => {
it('retries on transient failure then succeeds', async () => { it('retries on transient failure then succeeds', async () => {
const fn = vi.fn() const fn = vi.fn()
.mockRejectedValueOnce(new Error('timeout')) .mockRejectedValueOnce(new Error('temporary network issue'))
.mockRejectedValueOnce(new Error('timeout')) .mockRejectedValueOnce(new Error('temporary network issue'))
.mockResolvedValueOnce('recovered'); .mockResolvedValueOnce('recovered');
const result = await withRetry(fn, fastConfig, 'test-op'); const result = await withRetry(fn, fastConfig, 'test-op');
+3
View File
@@ -26,6 +26,9 @@ export const DEFAULT_RETRY_CONFIG: RetryConfig = {
'context_length_exceeded', 'context_length_exceeded',
'content_policy', 'content_policy',
'does not support', 'does not support',
'timeout',
'timed out',
'request aborted',
], ],
}; };
+25
View File
@@ -438,4 +438,29 @@ describe('setClient and labels', () => {
expect(newFastClient!.chat).toHaveBeenCalledTimes(1); expect(newFastClient!.chat).toHaveBeenCalledTimes(1);
expect(initialFastClient!.chat).toHaveBeenCalledTimes(1); expect(initialFastClient!.chat).toHaveBeenCalledTimes(1);
}); });
it('strict tier mode disables fallback chain for that tier', async () => {
const failingDefault = {
chat: vi.fn().mockRejectedValue(new Error('primary failed')),
} as unknown as ModelClient;
const fallback = {
chat: vi.fn().mockResolvedValue({
content: 'fallback',
stopReason: 'end_turn',
usage: { inputTokens: 1, outputTokens: 1 },
}),
} as unknown as ModelClient;
const router = new ModelRouter({
default: failingDefault,
fallbackChain: [fallback],
});
router.setTierStrict('default', true);
await expect(router.chat({ messages: [{ role: 'user', content: 'Hi' }] }, 'default'))
.rejects.toThrow('primary failed');
expect(fallback.chat).not.toHaveBeenCalled();
expect(router.isTierStrict('default')).toBe(true);
});
}); });
+22
View File
@@ -27,6 +27,7 @@ export class ModelRouter implements ModelClient {
private localProviderName?: string; private localProviderName?: string;
private retryConfig?: RetryConfig; private retryConfig?: RetryConfig;
private tierChangeListeners: Array<(tier: ModelTier) => void> = []; private tierChangeListeners: Array<(tier: ModelTier) => void> = [];
private strictTiers: Set<ModelTier> = new Set();
constructor(config: ModelRouterConfig) { constructor(config: ModelRouterConfig) {
this.clients = new Map(); this.clients = new Map();
@@ -97,6 +98,10 @@ export class ModelRouter implements ModelClient {
logger.debug(`Primary model failed: ${errors[0].message}`); logger.debug(`Primary model failed: ${errors[0].message}`);
} }
if (this.strictTiers.has(useTier)) {
throw errors[0];
}
// Try tier-specific fallbacks first // Try tier-specific fallbacks first
const tierFallbackList = this.tierFallbacks.get(useTier) ?? []; const tierFallbackList = this.tierFallbacks.get(useTier) ?? [];
for (let i = 0; i < tierFallbackList.length; i++) { for (let i = 0; i < tierFallbackList.length; i++) {
@@ -150,6 +155,11 @@ export class ModelRouter implements ModelClient {
primaryError = 'Primary client does not support streaming'; primaryError = 'Primary client does not support streaming';
} }
if (this.strictTiers.has(useTier)) {
yield { type: 'error', error: new Error(primaryError ?? 'Primary model failed') };
return;
}
// Try tier-specific fallbacks first // Try tier-specific fallbacks first
const tierFallbackList = this.tierFallbacks.get(useTier) ?? []; const tierFallbackList = this.tierFallbacks.get(useTier) ?? [];
for (let i = 0; i < tierFallbackList.length; i++) { for (let i = 0; i < tierFallbackList.length; i++) {
@@ -216,6 +226,18 @@ export class ModelRouter implements ModelClient {
this.labels.set(tier, label); this.labels.set(tier, label);
} }
setTierStrict(tier: ModelTier, strict: boolean): void {
if (strict) {
this.strictTiers.add(tier);
return;
}
this.strictTiers.delete(tier);
}
isTierStrict(tier: ModelTier): boolean {
return this.strictTiers.has(tier);
}
getLabel(tier: ModelTier): string { getLabel(tier: ModelTier): string {
return this.labels.get(tier) ?? 'unknown'; return this.labels.get(tier) ?? 'unknown';
} }
+3
View File
@@ -44,6 +44,9 @@ const testConfig: NonNullable<GmailConfig> = {
enabled: true, enabled: true,
credentials_file: '/tmp/test-creds.json', credentials_file: '/tmp/test-creds.json',
token_file: '/tmp/test-token.json', token_file: '/tmp/test-token.json',
disable_push: false,
pubsub_pull_interval: '60s',
pubsub_max_messages: 10,
watch_labels: ['INBOX'], watch_labels: ['INBOX'],
poll_interval: '300s', poll_interval: '300s',
output: { channel: 'discord', peer: '123' }, output: { channel: 'discord', peer: '123' },