From 955b9e28e0bacec3011a3152bdd9988fb8e2cfdc Mon Sep 17 00:00:00 2001 From: William Valentin Date: Fri, 13 Feb 2026 14:55:40 -0800 Subject: [PATCH] feat: add OpenAI OAuth, strict model overrides, and Gmail pull mode --- README.md | 35 +- config/default.yaml | 10 + .../2026-02-13-gmail-deployment-patterns.md | 324 ++++++ ...-gmail-push-revisit-implementation-plan.md | 1007 +++++++++++++++++ .../2026-02-13-gmail-push-revisit-summary.md | 161 +++ .../plans/2026-02-13-gmail-quick-reference.md | 322 ++++++ docs/plans/openai-oauth-checklist.md | 430 +++++++ docs/plans/openai-oauth-implementation.md | 809 +++++++++++++ docs/plans/openai-oauth-summary.md | 192 ++++ docs/plans/state.json | 78 +- package.json | 1 + pnpm-lock.yaml | 555 +++++++++ src/auth/index.ts | 13 + src/auth/openai.test.ts | 43 + src/auth/openai.ts | 281 +++++ src/automation/gmail.test.ts | 157 ++- src/automation/gmail.ts | 233 +++- src/channels/telegram/adapter.test.ts | 29 + src/channels/telegram/adapter.ts | 43 +- src/cli/doctor.test.ts | 69 ++ src/cli/doctor.ts | 57 +- src/cli/index.ts | 2 + src/cli/openai-auth.ts | 35 + src/cli/shared.test.ts | 39 + src/cli/shared.ts | 33 + src/cli/tui.ts | 71 +- src/commands/builtin/index.test.ts | 37 + src/commands/builtin/index.ts | 4 +- src/config/schema.ts | 26 + src/daemon/clientFactory.test.ts | 28 + src/daemon/models.ts | 20 +- src/daemon/routing.ts | 120 +- src/frontends/tui/commands.ts | 4 +- src/frontends/tui/components/App.tsx | 243 +++- src/frontends/tui/fullscreen.ts | 10 + src/frontends/tui/minimal.test.ts | 53 + src/frontends/tui/minimal.ts | 98 +- src/gateway/handlers/agent.ts | 14 +- src/hooks/engine.test.ts | 15 + src/hooks/engine.ts | 20 + src/models/local/llamacpp.test.ts | 38 + src/models/local/llamacpp.ts | 34 +- src/models/openai.oauth.test.ts | 68 ++ src/models/openai.test.ts | 33 +- src/models/openai.ts | 157 ++- src/models/retry.test.ts | 11 +- src/models/retry.ts | 3 + src/models/router.test.ts | 25 + src/models/router.ts | 22 + src/tools/builtin/gmail.test.ts | 3 + 50 files changed, 5955 insertions(+), 160 deletions(-) create mode 100644 docs/plans/2026-02-13-gmail-deployment-patterns.md create mode 100644 docs/plans/2026-02-13-gmail-push-revisit-implementation-plan.md create mode 100644 docs/plans/2026-02-13-gmail-push-revisit-summary.md create mode 100644 docs/plans/2026-02-13-gmail-quick-reference.md create mode 100644 docs/plans/openai-oauth-checklist.md create mode 100644 docs/plans/openai-oauth-implementation.md create mode 100644 docs/plans/openai-oauth-summary.md create mode 100644 src/auth/openai.test.ts create mode 100644 src/auth/openai.ts create mode 100644 src/cli/openai-auth.ts create mode 100644 src/commands/builtin/index.test.ts create mode 100644 src/models/openai.oauth.test.ts diff --git a/README.md b/README.md index dfe1011..ef0e1f7 100644 --- a/README.md +++ b/README.md @@ -458,14 +458,22 @@ The monitor sends a notification when failures reach the configured threshold an ## 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 -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 -3. Create a Pub/Sub topic (e.g. `projects/your-project/topics/gmail-push`) -4. Run `flynn gmail-auth` to complete the OAuth2 flow and store the refresh token +3. 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 @@ -475,6 +483,16 @@ automation: enabled: true credentials_file: ~/.config/flynn/gmail-credentials.json 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 poll_interval: "60s" # Polling fallback interval 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). +Pull mode uses Application Default Credentials (e.g. `GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json`) to access Pub/Sub. + ### Gmail Config Fields | 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`) | | `credentials_file` | yes | Path to Google OAuth2 credentials 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//topics/`) | +| `disable_push` | no | Disable watch registration even if `pubsub_topic` is set (default: `false`) | +| `pubsub_subscription_id` | no | Pub/Sub pull subscription (`projects//subscriptions/`) | +| `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]`) | -| `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 | | `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`) | diff --git a/config/default.yaml b/config/default.yaml index 96c6bc0..30c36ed 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -128,6 +128,16 @@ hooks: # enabled: false # credentials_file: ~/.config/flynn/gmail-credentials.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] # poll_interval: "60s" # message: "New email from {{from}}: {{subject}}\n\n{{snippet}}" diff --git a/docs/plans/2026-02-13-gmail-deployment-patterns.md b/docs/plans/2026-02-13-gmail-deployment-patterns.md new file mode 100644 index 0000000..aacb70f --- /dev/null +++ b/docs/plans/2026-02-13-gmail-deployment-patterns.md @@ -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" diff --git a/docs/plans/2026-02-13-gmail-push-revisit-implementation-plan.md b/docs/plans/2026-02-13-gmail-push-revisit-implementation-plan.md new file mode 100644 index 0000000..8e30e36 --- /dev/null +++ b/docs/plans/2026-02-13-gmail-push-revisit-implementation-plan.md @@ -0,0 +1,1007 @@ +# Gmail Push Notifications Revisit — Implementation Plan + +**Date**: 2026-02-13 +**Status**: Planned +**Goal**: Make Gmail notifications operationally reliable and well-documented + +## Overview + +The Gmail watcher currently supports two modes: +1. **Pub/Sub push notifications** (Google sends HTTP POST to `/gmail/push`) +2. **Polling fallback** (History API queries every N seconds) + +Current implementation has operational ambiguity around: +- Deployment patterns (public webhook vs Pub/Sub pull vs hybrid) +- GCP resource setup (topic/subscription/IAM relationships) +- Network reachability constraints (Tailscale-only gateways) +- Configuration surface (implicit vs explicit controls) +- Error diagnostics (vague failure messages) + +This plan addresses these gaps without changing core functionality. + +--- + +## Problem Analysis + +### Current Behavior + +**Push setup flow** (`src/automation/gmail.ts:222-253`): +1. `resolvePubSubTopicName()` reads `pubsub_topic` config or `FLYNN_GMAIL_PUBSUB_TOPIC` env var +2. Calls `gmail.users.watch()` with the topic +3. If watch fails → logs warning, continues with polling only +4. If watch succeeds → schedules renewal every 6 days + +**Issues**: +- **Topic ownership mismatch**: OAuth credentials' `project_id` may differ from the Pub/Sub topic's project +- **Network reachability**: Google cannot reach Tailscale-only endpoints (`server.tailscale_only: true`) +- **Implicit disable**: No explicit way to disable push without deleting `pubsub_topic` field +- **Poor error messages**: "Invalid topicName" error includes no actionable guidance +- **Undocumented subscription**: Users must manually create Pub/Sub subscription → push endpoint mapping +- **Doctor check gap**: `flynn doctor` validates OAuth but not Pub/Sub setup + +### Deployment Patterns + +| Pattern | Reachability | Flynn Config | GCP Setup | Use Case | +|---------|--------------|--------------|-----------|----------| +| **Public webhook** | Public IP + DNS | `pubsub_topic` set | Topic + push subscription → `POST https://example.com/gmail/push` | Production deployment with public ingress | +| **Tailscale Serve** | Tailscale funnel | `pubsub_topic` set, `server.tailscale.funnel: true` | Topic + push subscription → `POST https://flynn.ts.net/gmail/push` | Private deployment with Tailscale funnel enabled | +| **Pub/Sub pull** | Any (no inbound) | `pubsub_subscription_id` set | Topic + pull subscription | Private deployment, Flynn polls subscription | +| **Polling only** | Any (no inbound) | `pubsub_topic` omitted | None | Development, no GCP setup | +| **Hybrid (recommended)** | Any | `pubsub_topic` + `pubsub_subscription_id` | Topic + push subscription + pull subscription | Push when reachable, pull fallback, History API tertiary | + +--- + +## Recommended Changes + +### 1. Config Schema Additions + +**File**: `src/config/schema.ts` + +Add new fields to `gmailSchema`: + +```typescript +const gmailSchema = z.object({ + enabled: z.boolean().default(false), + credentials_file: z.string().optional(), + token_file: z.string().default('~/.config/flynn/gmail-token.json'), + + // ── Push notifications ──────────────────────────────────────────────── + /** + * Pub/Sub topic for Gmail push notifications (watch API). + * Format: projects//topics/ or just (auto-prefixed). + * If omitted, push notifications are disabled (polling only). + */ + pubsub_topic: z.string().optional(), + + /** + * Pub/Sub subscription ID for pull-based message retrieval. + * Format: projects//subscriptions/ or just . + * When set, Flynn periodically pulls messages from this subscription in addition + * to waiting for push notifications at POST /gmail/push. + * Enables hybrid mode: push when reachable, pull fallback. + */ + pubsub_subscription_id: z.string().optional(), + + /** + * Pull interval for Pub/Sub subscription polling (hybrid mode). + * Only used when pubsub_subscription_id is set. Default: 60s. + */ + pubsub_pull_interval: z.string().default('60s'), + + /** + * Maximum messages to pull per request when using pull mode. Default: 10. + */ + pubsub_max_messages: z.number().min(1).max(100).default(10), + + /** + * Disable watch API even if pubsub_topic is set. + * Useful for debugging or when push endpoint is temporarily unreachable. + */ + disable_push: z.boolean().default(false), + + // ── Existing fields ──────────────────────────────────────────────────── + watch_labels: z.array(z.string()).default(['INBOX']), + poll_interval: z.string().default('300s'), // History API fallback + history_start: z.string().optional(), + output: z.object({ + channel: z.string().min(1), + peer: z.string().min(1), + }), + message: z.string().default('New email from {{from}}: {{subject}}\n\n{{snippet}}'), +}).optional(); +``` + +**Defaults**: +- `pubsub_topic`: `undefined` (no push) +- `pubsub_subscription_id`: `undefined` (no pull) +- `pubsub_pull_interval`: `'60s'` +- `pubsub_max_messages`: `10` +- `disable_push`: `false` +- `poll_interval`: `'300s'` (History API fallback) + +--- + +### 2. Implementation Changes + +#### 2.1 Add Pub/Sub Pull Support + +**File**: `src/automation/gmail.ts` + +Add new method `setupPullSubscription()`: + +```typescript +private pullSubscriptionTimer?: ReturnType; +private subscriptionPath?: string; + +/** + * Set up Pub/Sub pull subscription for periodic message retrieval. + * Alternative/fallback to push notifications when endpoint is unreachable. + */ +private async setupPullSubscription(): Promise { + if (!this.config.pubsub_subscription_id) { + return; // Pull mode not configured + } + + const subId = this.config.pubsub_subscription_id.trim(); + + // Allow shorthand: just the subscription id (e.g. "gmail-pull") + if (!subId.includes('/')) { + if (!this.googleProjectId) { + console.warn( + `GmailWatcher: pubsub_subscription_id '${subId}' must be fully qualified ` + + `(projects//subscriptions/) because project_id was not found in credentials` + ); + return; + } + this.subscriptionPath = `projects/${this.googleProjectId}/subscriptions/${subId}`; + } else { + const isValid = /^projects\/[^/]+\/subscriptions\/[^/]+$/.test(subId); + if (!isValid) { + console.warn( + `GmailWatcher: Invalid pubsub_subscription_id '${subId}'. ` + + `Expected: projects//subscriptions/` + ); + return; + } + this.subscriptionPath = subId; + } + + console.log(`GmailWatcher: Pull subscription registered (${this.subscriptionPath})`); + + // Start periodic pull + const pullMs = parseInterval(this.config.pubsub_pull_interval ?? '60s'); + this.pullSubscriptionTimer = setInterval(() => { + this.pullSubscriptionMessages().catch((err) => { + console.error('GmailWatcher: Pull subscription error —', err instanceof Error ? err.message : err); + }); + }, pullMs); +} + +/** + * Pull messages from the Pub/Sub subscription and process them. + */ +private async pullSubscriptionMessages(): Promise { + if (!this.subscriptionPath || !this.oauth2Client) { + return; + } + + try { + const { PubSub } = await import('@google-cloud/pubsub'); + const pubsub = new PubSub({ + projectId: this.googleProjectId, + authClient: this.oauth2Client, + }); + + const subscription = pubsub.subscription(this.subscriptionPath); + const [messages] = await subscription.pull({ + maxMessages: this.config.pubsub_max_messages ?? 10, + }); + + for (const message of messages) { + if (message.data) { + await this.handlePushNotification(message.data.toString('base64')); + } + + // Acknowledge the message + await subscription.ack(message); + } + } catch (error) { + console.error( + 'GmailWatcher: Failed to pull Pub/Sub messages —', + error instanceof Error ? error.message : error + ); + } +} +``` + +Update `connect()` to call `setupPullSubscription()`: + +```typescript +async connect(): Promise { + this._status = 'connecting'; + + try { + this.oauth2Client = await this.authorize(); + } catch (error) { + const errMsg = error instanceof Error ? error.message : 'Unknown error'; + console.error(`GmailWatcher: Authorization failed — ${errMsg}`); + console.error('GmailWatcher: Run "flynn gmail-auth" to set up Gmail credentials.'); + this._status = 'error'; + return; + } + + // Set up Gmail push watch (Pub/Sub) if not disabled + if (!this.config.disable_push) { + try { + await this.setupWatch(); + } catch (error) { + const errMsg = error instanceof Error ? error.message : 'Unknown error'; + const hint = this.buildWatchErrorHint(errMsg); + console.warn(`GmailWatcher: Watch setup failed (push disabled) — ${errMsg}${hint}`); + } + } else { + console.log('GmailWatcher: Push notifications disabled (disable_push: true)'); + } + + // Set up Pub/Sub pull subscription (hybrid mode) + await this.setupPullSubscription(); + + // Start History API polling fallback + const pollMs = parseInterval(this.config.poll_interval ?? '300s'); + this.pollTimer = setInterval(() => { + this.pollForNewMessages().catch((err) => { + console.error('GmailWatcher: Poll error —', err instanceof Error ? err.message : err); + }); + }, pollMs); + + this._status = 'connected'; + console.log( + `GmailWatcher: Connected (` + + `push=${!this.config.disable_push && !!this.config.pubsub_topic}, ` + + `pull=${!!this.config.pubsub_subscription_id}, ` + + `poll_interval=${this.config.poll_interval ?? '300s'})` + ); + auditLogger?.systemStart('GmailWatcher', { + push: !this.config.disable_push && !!this.config.pubsub_topic, + pull: !!this.config.pubsub_subscription_id, + poll_interval: this.config.poll_interval, + }); +} +``` + +Update `disconnect()` to clear pull timer: + +```typescript +async disconnect(): Promise { + if (this.pollTimer) { + clearInterval(this.pollTimer); + this.pollTimer = undefined; + } + if (this.watchTimer) { + clearTimeout(this.watchTimer); + this.watchTimer = undefined; + } + if (this.pullSubscriptionTimer) { + clearInterval(this.pullSubscriptionTimer); + this.pullSubscriptionTimer = undefined; + } + this.oauth2Client = undefined; + this._status = 'disconnected'; + auditLogger?.systemStop('GmailWatcher'); +} +``` + +#### 2.2 Improve Error Messages + +**File**: `src/automation/gmail.ts` + +Add helper method `buildWatchErrorHint()`: + +```typescript +/** + * Build a helpful error hint based on the watch failure message. + */ +private buildWatchErrorHint(errMsg: string): string { + if (errMsg.includes('Invalid topicName')) { + const topicHint = this.googleProjectId + ? `projects/${this.googleProjectId}/topics/gmail-push` + : '/topics/gmail-push'; + + return `\n` + + ` Tip: Set automation.gmail.pubsub_topic to "${topicHint}"\n` + + ` Ensure the topic exists in GCP and the subscription is configured to POST to /gmail/push\n` + + ` Or set automation.gmail.pubsub_subscription_id for pull-based mode (no webhook required)`; + } + + if (errMsg.includes('Permission denied') || errMsg.includes('Forbidden')) { + return `\n` + + ` Tip: Grant Gmail API permission to publish to the Pub/Sub topic:\n` + + ` gcloud pubsub topics add-iam-policy-binding \\\n` + + ` --member=serviceAccount:gmail-api-push@system.gserviceaccount.com \\\n` + + ` --role=roles/pubsub.publisher`; + } + + if (errMsg.includes('Not found') || errMsg.includes('404')) { + return `\n` + + ` Tip: Create the Pub/Sub topic first:\n` + + ` gcloud pubsub topics create gmail-push --project=`; + } + + return ''; +} +``` + +Update `setupWatch()` error handling: + +```typescript +try { + await this.setupWatch(); +} catch (error) { + const errMsg = error instanceof Error ? error.message : 'Unknown error'; + const hint = this.buildWatchErrorHint(errMsg); + console.warn(`GmailWatcher: Watch setup failed (push disabled) — ${errMsg}${hint}`); +} +``` + +--- + +### 3. Doctor Checks + +**File**: `src/cli/doctor.ts` + +Enhance `checkGmail` to validate Pub/Sub setup: + +```typescript +const checkGmail: Check = async (ctx) => { + if (!ctx.config) { + return { status: 'skip', label: 'Gmail configured', detail: '(config invalid)' }; + } + const gmail = ctx.config.automation.gmail; + if (!gmail?.enabled) { + return { status: 'skip', label: 'Gmail configured', detail: '(not enabled)' }; + } + + // 1. Check credentials file + const credentialsPath = expandPath(gmail.credentials_file ?? '~/.config/flynn/gmail-credentials.json'); + if (!existsSync(credentialsPath)) { + return { status: 'fail', label: 'Gmail configured', detail: `credentials file not found: ${credentialsPath}` }; + } + + // 2. Check token file + const tokenPath = expandPath(gmail.token_file ?? '~/.config/flynn/gmail-token.json'); + if (!existsSync(tokenPath)) { + return { status: 'warn', label: 'Gmail configured', detail: 'run `flynn gmail-auth` to authenticate' }; + } + + // 3. Parse credentials to get project_id + let projectId: string | undefined; + try { + const creds = JSON.parse(readFileSync(credentialsPath, 'utf-8')); + projectId = creds.installed?.project_id ?? creds.web?.project_id; + } catch { + // Ignore parse errors — already validated above + } + + // 4. Check Pub/Sub configuration + const modes: string[] = []; + const warnings: string[] = []; + + if (gmail.pubsub_topic && !gmail.disable_push) { + const topic = gmail.pubsub_topic.trim(); + if (!topic.includes('/') && !projectId) { + warnings.push('pubsub_topic requires project_id in credentials'); + } else { + modes.push('push'); + + // Warn if tailscale_only is enabled (Google cannot reach tailnet-only endpoints) + 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) { + const sub = gmail.pubsub_subscription_id.trim(); + if (!sub.includes('/') && !projectId) { + warnings.push('pubsub_subscription_id requires project_id in credentials'); + } else { + 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, + }; +}; +``` + +--- + +### 4. Documentation Updates + +#### 4.1 README.md + +**File**: `README.md` + +Update Gmail section (lines 461-512): + +```markdown +### Gmail Watcher + +Monitor a Gmail inbox for new messages and trigger agent responses. Flynn supports three retrieval modes: + +1. **Push notifications** (Pub/Sub watch) — Google sends HTTP POST to `/gmail/push` +2. **Pull subscription** (Pub/Sub pull) — Flynn periodically pulls messages from a subscription +3. **History API polling** (fallback) — Flynn queries the History API every N seconds + +**Recommended deployment**: Hybrid mode (push + pull + polling) for maximum reliability. + +#### Prerequisites + +1. **Google Cloud Project** with Gmail API and Pub/Sub API enabled +2. **OAuth2 credentials** (Desktop application) — download JSON from Cloud Console +3. **Pub/Sub topic** (for push or pull): + ```bash + gcloud pubsub topics create gmail-push --project=your-project + ``` +4. **Grant Gmail API publish permission**: + ```bash + gcloud pubsub topics add-iam-policy-binding projects/your-project/topics/gmail-push \ + --member=serviceAccount:gmail-api-push@system.gserviceaccount.com \ + --role=roles/pubsub.publisher + ``` +5. **Authenticate**: + ```bash + flynn gmail-auth + ``` + +#### Deployment Patterns + +| Mode | Config | GCP Setup | Network | Use Case | +|------|--------|-----------|---------|----------| +| **Push only** | `pubsub_topic` | Topic + push subscription → `POST https://example.com/gmail/push` | Public IP or Tailscale funnel | Production with ingress | +| **Pull only** | `pubsub_subscription_id` | Topic + pull subscription | Any (no inbound) | Private deployment | +| **Hybrid** ⭐ | Both | Topic + push + pull subscriptions | Any | Maximum reliability | +| **Polling only** | Neither | None | Any | Development/testing | + +#### Push Notifications Setup + +When `pubsub_topic` is set, Flynn calls `gmail.users.watch()` to register for push notifications. + +1. Create a **push subscription** in GCP: + ```bash + gcloud pubsub subscriptions create gmail-push-sub \ + --topic=gmail-push \ + --push-endpoint=https://your-domain.com/gmail/push + ``` + +2. If using **Tailscale Serve**, enable funnel: + ```yaml + server: + tailscale: + serve: true + funnel: true # ← Required for Google to reach your endpoint + ``` + +3. Set `pubsub_topic` in config: + ```yaml + automation: + gmail: + enabled: true + pubsub_topic: projects/your-project/topics/gmail-push + ``` + +**Important**: Push notifications require a publicly reachable endpoint. If `server.tailscale_only: true` and funnel is disabled, push will fail (Flynn falls back to pull/poll). + +#### Pull Subscription Setup + +When `pubsub_subscription_id` is set, Flynn periodically pulls messages from the subscription. + +1. Create a **pull subscription** in GCP: + ```bash + gcloud pubsub subscriptions create gmail-pull-sub \ + --topic=gmail-push \ + --ack-deadline=60 + ``` + +2. Set `pubsub_subscription_id` in config: + ```yaml + automation: + gmail: + enabled: true + pubsub_subscription_id: projects/your-project/topics/gmail-pull-sub + pubsub_pull_interval: "60s" + ``` + +**Note**: Pull mode works with any network setup (no inbound connections required). + +#### Hybrid Mode (Recommended) + +Combine push + pull for maximum reliability: + +```yaml +automation: + gmail: + enabled: true + credentials_file: ~/.config/flynn/gmail-credentials.json + token_file: ~/.config/flynn/gmail-token.json + + # Push notifications (when endpoint is reachable) + pubsub_topic: projects/your-project/topics/gmail-push + + # Pull fallback (always works) + pubsub_subscription_id: projects/your-project/subscriptions/gmail-pull-sub + pubsub_pull_interval: "60s" + + # History API tertiary fallback + poll_interval: "300s" + + watch_labels: [INBOX] + message: "New email from {{from}}: {{subject}}\n\n{{snippet}}" + output: + channel: telegram + peer: "123456789" +``` + +#### Configuration Reference + +| Field | Required | Default | Description | +|-------|----------|---------|-------------| +| `enabled` | no | `false` | Enable the Gmail watcher | +| `credentials_file` | yes | — | Path to OAuth2 credentials JSON | +| `token_file` | no | `~/.config/flynn/gmail-token.json` | Path to stored refresh token | +| `pubsub_topic` | no | — | Pub/Sub topic for push notifications (format: `projects//topics/`) | +| `pubsub_subscription_id` | no | — | Pub/Sub subscription for pull mode (format: `projects//subscriptions/`) | +| `pubsub_pull_interval` | no | `60s` | Pull interval when using subscription | +| `pubsub_max_messages` | no | `10` | Max messages per pull request (1-100) | +| `disable_push` | no | `false` | Disable watch API even if `pubsub_topic` is set | +| `watch_labels` | no | `[INBOX]` | Gmail labels to watch | +| `poll_interval` | no | `300s` | History API fallback interval | +| `history_start` | no | — | ISO date — only process emails after this date | +| `message` | no | `New email from {{from}}: {{subject}}\n\n{{snippet}}` | Template for agent message | +| `output.channel` | yes | — | Channel to route responses (e.g. `telegram`) | +| `output.peer` | yes | — | Peer/chat ID on output channel | + +#### Template Variables + +| Variable | Description | +|----------|-------------| +| `{{from}}` | Sender email address | +| `{{to}}` | Recipient email address | +| `{{subject}}` | Email subject line | +| `{{snippet}}` | Gmail-provided snippet (first ~200 chars) | +| `{{date}}` | Email date header | +| `{{id}}` | Gmail message ID | +| `{{labels}}` | Comma-separated label names | + +#### Troubleshooting + +**"Watch setup failed — Invalid topicName"** +- Ensure `pubsub_topic` format is correct: `projects//topics/` +- Verify the topic exists: `gcloud pubsub topics list` +- Check IAM permissions (Gmail API must be able to publish) + +**"Permission denied" or "Forbidden"** +- Grant publish permission to Gmail API: + ```bash + gcloud pubsub topics add-iam-policy-binding \ + --member=serviceAccount:gmail-api-push@system.gserviceaccount.com \ + --role=roles/pubsub.publisher + ``` + +**Push notifications not arriving** +- Verify endpoint is reachable: `curl -X POST https://your-domain.com/gmail/push` +- Check subscription configuration: `gcloud pubsub subscriptions describe ` +- Enable Tailscale funnel if using `tailscale_only: true` +- Fall back to pull or polling mode + +**Check status**: +```bash +flynn doctor # Validates config and reports active modes (push/pull/poll) +``` +``` + +#### 4.2 config/default.yaml + +**File**: `config/default.yaml` + +Update Gmail example (lines 128-136): + +```yaml +# gmail: +# enabled: false +# credentials_file: ~/.config/flynn/gmail-credentials.json +# token_file: ~/.config/flynn/gmail-token.json +# +# # Push notifications (requires public endpoint or Tailscale funnel) +# pubsub_topic: projects//topics/gmail-push +# # pubsub_topic: gmail-push # Shorthand when project_id is in credentials +# +# # Pull subscription (works with any network setup) +# # pubsub_subscription_id: projects//subscriptions/gmail-pull-sub +# # pubsub_pull_interval: "60s" +# # pubsub_max_messages: 10 +# +# # Disable push even if pubsub_topic is set (for debugging) +# # disable_push: false +# +# watch_labels: [INBOX] +# poll_interval: "60s" # History API fallback interval +# message: "New email from {{from}}: {{subject}}\n\n{{snippet}}" +# output: +# channel: telegram +# peer: "123456789" +``` + +--- + +### 5. Dependencies + +**File**: `package.json` + +Add `@google-cloud/pubsub` for pull subscription support: + +```json +{ + "dependencies": { + "@google-cloud/pubsub": "^4.0.0" + } +} +``` + +--- + +## Test Plan + +### 5.1 Unit Tests + +**File**: `src/automation/gmail.test.ts` + +Add tests for new functionality: + +```typescript +describe('GmailWatcher - Pub/Sub Pull', () => { + it('should set up pull subscription with full subscription path', async () => { + const config = { + ...baseConfig, + pubsub_subscription_id: 'projects/test-project/subscriptions/gmail-pull', + pubsub_pull_interval: '30s', + }; + const watcher = new GmailWatcher(config, channelLookup); + await watcher.connect(); + + // Verify subscriptionPath is set + expect(watcher['subscriptionPath']).toBe('projects/test-project/subscriptions/gmail-pull'); + expect(watcher['pullSubscriptionTimer']).toBeDefined(); + + await watcher.disconnect(); + expect(watcher['pullSubscriptionTimer']).toBeUndefined(); + }); + + it('should auto-prefix subscription with project_id when shorthand is used', 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'); + await watcher.disconnect(); + }); + + it('should skip pull setup when pubsub_subscription_id is not set', async () => { + const watcher = new GmailWatcher(baseConfig, channelLookup); + await watcher.connect(); + + expect(watcher['subscriptionPath']).toBeUndefined(); + expect(watcher['pullSubscriptionTimer']).toBeUndefined(); + await watcher.disconnect(); + }); + + it('should warn on invalid subscription path format', async () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const config = { + ...baseConfig, + pubsub_subscription_id: 'invalid-format', + credentials_file: '~/.config/flynn/gmail-credentials-no-project.json', + }; + + // Mock credentials without project_id + vi.mocked(readFileSync).mockImplementation((path: string) => { + if (path.includes('credentials')) { + return JSON.stringify({ + installed: { + client_id: 'test-client-id', + client_secret: 'test-client-secret', + // No project_id + }, + }); + } + return JSON.stringify({ access_token: 'token' }); + }); + + const watcher = new GmailWatcher(config, channelLookup); + await watcher.connect(); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('pubsub_subscription_id \'invalid-format\' must be fully qualified') + ); + + await watcher.disconnect(); + consoleSpy.mockRestore(); + }); +}); + +describe('GmailWatcher - disable_push', () => { + it('should skip watch setup when disable_push is true', async () => { + const config = { + ...baseConfig, + pubsub_topic: 'projects/test-project/topics/gmail-push', + disable_push: true, + }; + + const watcher = new GmailWatcher(config, channelLookup); + await watcher.connect(); + + // Watch should not be called + const mockWatch = vi.mocked(google.gmail().users.watch); + expect(mockWatch).not.toHaveBeenCalled(); + + await watcher.disconnect(); + }); + + it('should log push disabled message when disable_push is true', async () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const config = { + ...baseConfig, + pubsub_topic: 'gmail-push', + disable_push: true, + }; + + const watcher = new GmailWatcher(config, channelLookup); + await watcher.connect(); + + expect(consoleSpy).toHaveBeenCalledWith( + 'GmailWatcher: Push notifications disabled (disable_push: true)' + ); + + await watcher.disconnect(); + consoleSpy.mockRestore(); + }); +}); + +describe('GmailWatcher - buildWatchErrorHint', () => { + 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'); + }); + + it('should return IAM hint for Permission denied error', () => { + const watcher = new GmailWatcher(baseConfig, channelLookup); + const hint = watcher['buildWatchErrorHint']('Permission denied'); + + expect(hint).toContain('gcloud pubsub topics add-iam-policy-binding'); + expect(hint).toContain('gmail-api-push@system.gserviceaccount.com'); + }); + + it('should return creation hint for Not found error', () => { + const watcher = new GmailWatcher(baseConfig, channelLookup); + const hint = watcher['buildWatchErrorHint']('Not found'); + + expect(hint).toContain('gcloud pubsub topics create'); + }); +}); + +describe('GmailWatcher - connection logging', () => { + it('should log all active modes on connect', async () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const config = { + ...baseConfig, + pubsub_topic: 'gmail-push', + pubsub_subscription_id: 'gmail-pull', + }; + + const watcher = new GmailWatcher(config, channelLookup); + await watcher.connect(); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('push=true, pull=true, poll_interval=') + ); + + await watcher.disconnect(); + consoleSpy.mockRestore(); + }); +}); +``` + +### 5.2 Doctor Check Tests + +**File**: `src/cli/doctor.test.ts` + +Add tests for enhanced Gmail validation: + +```typescript +describe('doctor - checkGmail enhanced', () => { + it('should detect push mode', async () => { + const config = { + ...baseConfig, + automation: { + gmail: { + enabled: true, + credentials_file: '/mock/credentials.json', + token_file: '/mock/token.json', + pubsub_topic: 'projects/test/topics/gmail-push', + output: { channel: 'telegram', peer: '123' }, + }, + }, + }; + + const result = await checkGmail({ config }); + expect(result.status).toBe('pass'); + expect(result.detail).toContain('push + poll-fallback'); + }); + + it('should detect pull mode', async () => { + const config = { + ...baseConfig, + automation: { + gmail: { + enabled: true, + credentials_file: '/mock/credentials.json', + token_file: '/mock/token.json', + pubsub_subscription_id: 'projects/test/subscriptions/gmail-pull', + output: { channel: 'telegram', peer: '123' }, + }, + }, + }; + + const result = await checkGmail({ config }); + expect(result.status).toBe('pass'); + expect(result.detail).toContain('pull + poll-fallback'); + }); + + it('should detect hybrid mode', async () => { + const config = { + ...baseConfig, + automation: { + gmail: { + enabled: true, + credentials_file: '/mock/credentials.json', + token_file: '/mock/token.json', + 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'); + }); + + it('should warn when push requires public endpoint but tailscale_only is enabled', async () => { + const config = { + ...baseConfig, + server: { + tailscale_only: true, + }, + automation: { + gmail: { + enabled: true, + credentials_file: '/mock/credentials.json', + token_file: '/mock/token.json', + pubsub_topic: 'gmail-push', + output: { channel: 'telegram', peer: '123' }, + }, + }, + }; + + const result = await checkGmail({ config }); + expect(result.status).toBe('warn'); + expect(result.detail).toContain('push requires public endpoint or Tailscale funnel'); + }); + + it('should pass when tailscale funnel is enabled', async () => { + const config = { + ...baseConfig, + server: { + tailscale_only: true, + tailscale: { funnel: true }, + }, + automation: { + gmail: { + enabled: true, + credentials_file: '/mock/credentials.json', + token_file: '/mock/token.json', + pubsub_topic: 'gmail-push', + output: { channel: 'telegram', peer: '123' }, + }, + }, + }; + + const result = await checkGmail({ config }); + expect(result.status).toBe('pass'); + expect(result.detail).not.toContain('⚠️'); + }); +}); +``` + +### 5.3 Integration Tests + +Manual testing checklist: + +- [ ] **Push only**: Set `pubsub_topic`, start daemon, send test email, verify POST arrives at `/gmail/push`, verify agent response +- [ ] **Pull only**: Set `pubsub_subscription_id`, start daemon, send test email, verify pull retrieves message, verify agent response +- [ ] **Hybrid**: Set both, start daemon, send test email, verify either push or pull handles it (no duplicate processing) +- [ ] **Polling only**: Omit both, start daemon, send test email, verify History API poll finds it +- [ ] **Disable push**: Set `disable_push: true` with `pubsub_topic`, verify watch is not called +- [ ] **Doctor check**: Run `flynn doctor` with each mode, verify output shows correct modes +- [ ] **Error messages**: Trigger watch failure (invalid topic), verify hint contains actionable guidance +- [ ] **Tailscale warning**: Set `tailscale_only: true` without funnel, verify doctor warns about push + +--- + +## Files to Modify + +### Core Implementation + +| File | Changes | +|------|---------| +| `src/config/schema.ts` | Add `pubsub_subscription_id`, `pubsub_pull_interval`, `pubsub_max_messages`, `disable_push` fields | +| `src/automation/gmail.ts` | Add `setupPullSubscription()`, `pullSubscriptionMessages()`, `buildWatchErrorHint()`, update `connect()` / `disconnect()` | +| `src/cli/doctor.ts` | Enhance `checkGmail` with Pub/Sub mode detection and Tailscale reachability check | +| `package.json` | Add `@google-cloud/pubsub` dependency | + +### Documentation + +| File | Changes | +|------|---------| +| `README.md` | Rewrite Gmail section with deployment patterns, setup instructions, troubleshooting | +| `config/default.yaml` | Update Gmail example with new fields and comments | +| `docs/plans/state.json` | Mark `gmail-push-revisit` as completed | + +### Tests + +| File | Changes | +|------|---------| +| `src/automation/gmail.test.ts` | Add tests for pull mode, `disable_push`, error hints, connection logging | +| `src/cli/doctor.test.ts` | Add tests for enhanced Gmail validation (mode detection, Tailscale warning) | + +--- + +## Summary + +This implementation plan makes Gmail notifications operationally reliable by: + +1. **Supporting three deployment patterns**: Push (public endpoint), Pull (private), Hybrid (both) +2. **Clarifying config surface**: Explicit `pubsub_subscription_id`, `pubsub_pull_interval`, `disable_push` fields +3. **Improving error messages**: Actionable hints for common setup failures (invalid topic, missing IAM, etc.) +4. **Enhancing diagnostics**: `flynn doctor` reports active modes and warns about reachability issues +5. **Comprehensive documentation**: Setup guides for each pattern, GCP resource instructions, troubleshooting + +**Recommended default**: Hybrid mode (push + pull + polling) for maximum reliability across all network configurations. + +**Estimated effort**: 1-2 days (implementation + testing + docs) + +**No breaking changes**: Existing `pubsub_topic`-only configs continue to work (push + polling) diff --git a/docs/plans/2026-02-13-gmail-push-revisit-summary.md b/docs/plans/2026-02-13-gmail-push-revisit-summary.md new file mode 100644 index 0000000..37b4ec0 --- /dev/null +++ b/docs/plans/2026-02-13-gmail-push-revisit-summary.md @@ -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//topics/gmail-push + disable_push: false # Explicit disable + + # Pull subscription (new) + pubsub_subscription_id: projects//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 diff --git a/docs/plans/2026-02-13-gmail-quick-reference.md b/docs/plans/2026-02-13-gmail-quick-reference.md new file mode 100644 index 0000000..d450d8e --- /dev/null +++ b/docs/plans/2026-02-13-gmail-quick-reference.md @@ -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 +``` +- 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 +``` +- 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//topics/`, 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 diff --git a/docs/plans/openai-oauth-checklist.md b/docs/plans/openai-oauth-checklist.md new file mode 100644 index 0000000..19584d6 --- /dev/null +++ b/docs/plans/openai-oauth-checklist.md @@ -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 +export async function pollForOpenAIToken(deviceAuthId: string, userCode: string, interval: number): Promise +export async function refreshOpenAIToken(refreshToken: string): Promise +export function loadOpenAIToken(): OpenAIAuthStore | null +export function storeOpenAIToken(tokens: TokenResponse): void +export async function getOpenAIToken(): Promise +export async function loginOpenAI(onPrompt: (userCode: string, url: string) => void): Promise +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; + tokenSaver?: (tokens: OpenAIAuthStore) => Promise; + }; +} +``` + +#### 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 diff --git a/docs/plans/openai-oauth-implementation.md b/docs/plans/openai-oauth-implementation.md new file mode 100644 index 0000000..e8b1c5b --- /dev/null +++ b/docs/plans/openai-oauth-implementation.md @@ -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 +export function pollForOpenAIToken(deviceAuthId: string, userCode: string, interval: number): Promise +export function refreshOpenAIToken(refreshToken: string): Promise +export function loadOpenAIToken(): OpenAIAuthStore | null +export function storeOpenAIToken(tokens: TokenResponse): void +export function getOpenAIToken(): Promise +export function loginOpenAI(onPrompt: (userCode: string, url: string) => void): Promise + +// src/models/openai.ts - Enhanced config +export interface OpenAIClientConfig { + apiKey?: string; + model: string; + maxTokens?: number; + baseURL?: string; + timeoutMs?: number; + oauth?: { + enabled: boolean; + tokenLoader?: () => Promise; + tokenSaver?: (tokens: OpenAIAuthStore) => Promise; + }; +} +``` + +--- + +## 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 { + // 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 { + // 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 { + // 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; + tokenSaver?: (tokens: OpenAIAuthStore) => Promise; + }; +} +``` + +#### 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 <=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 diff --git a/docs/plans/openai-oauth-summary.md b/docs/plans/openai-oauth-summary.md new file mode 100644 index 0000000..97d00dc --- /dev/null +++ b/docs/plans/openai-oauth-summary.md @@ -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 <=21.1.0} + '@google-cloud/paginator@6.0.0': + resolution: {integrity: sha512-g5nmMnzC+94kBxOKkLGpK1ikvolTFCC3s2qtE4F+1EuArcJ7HHC23RDQVt3Ra3CqpUYZ+oXNKZ8n5Cn5yug8DA==} + engines: {node: '>=18'} + + '@google-cloud/precise-date@5.0.0': + resolution: {integrity: sha512-9h0Gvw92EvPdE8AK8AgZPbMnH5ftDyPtKm7/KUfcJVaPEPjwGDsJd1QV0H8esBDV4II41R/2lDWH1epBqIoKUw==} + engines: {node: '>=18'} + + '@google-cloud/projectify@5.0.0': + resolution: {integrity: sha512-XXQLaIcLrOAMWvRrzz+mlUGtN6vlVNja3XQbMqRi/V7XJTAVwib3VcKd7oRwyZPkp7rBVlHGcaqdyGRrcnkhlA==} + engines: {node: '>=18'} + + '@google-cloud/promisify@5.0.0': + resolution: {integrity: sha512-N8qS6dlORGHwk7WjGXKOSsLjIjNINCPicsOX6gyyLiYk7mq3MtII96NZ9N2ahwA2vnkLmZODOIH9rlNniYWvCQ==} + engines: {node: '>=18'} + + '@google-cloud/pubsub@5.2.3': + resolution: {integrity: sha512-YKsFl4Qs+nhy20CPNVeafxAt5erQ8LoJuz/gpPAP0WHGQFXnV3KcSZ5HvzgEiUNYsQs/AjjOUdqwnZ4XKaBY/Q==} + engines: {node: '>=18'} + '@google/generative-ai@0.24.1': resolution: {integrity: sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q==} engines: {node: '>=18.0.0'} @@ -504,6 +527,15 @@ packages: '@grammyjs/types@3.23.0': resolution: {integrity: sha512-D3jQ4UWERPsyR3op/YFudMMIPNTU47vy7L51uO9/73tMELmjO/+LX5N36/Y0CG5IQfIsz43MxiHI5rgsK0/k+g==} + '@grpc/grpc-js@1.14.3': + resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.8.0': + resolution: {integrity: sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==} + engines: {node: '>=6'} + hasBin: true + '@hono/node-server@1.19.9': resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} engines: {node: '>=18.14.1'} @@ -526,9 +558,16 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + '@mixmark-io/domino@2.2.0': resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} @@ -546,9 +585,61 @@ packages: resolution: {integrity: sha512-Z+CZ3QaosfFaTqvhQsIktyGrjFjSC0Fa4EMph4mqKnWhmyoGICsV/8QK+8HpXut6zV7zwfWwqDmEjtk1Qf6EgQ==} engines: {node: '>=14.0.0'} + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/core@1.30.1': + resolution: {integrity: sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.28.0': + resolution: {integrity: sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==} + engines: {node: '>=14'} + + '@opentelemetry/semantic-conventions@1.34.0': + resolution: {integrity: sha512-aKcOkyrorBGlajjRdVoJWHTxfxO1vCNHLJVlSDaRHDIdjU+pX8IYQPvPDkYiujKLbRnWU+1TBwEt0QRgSm4SGA==} + engines: {node: '>=14'} + '@pedroslopez/moduleraid@5.0.2': resolution: {integrity: sha512-wtnBAETBVYZ9GvcbgdswRVSLkFkYAGv1KzwBBTeRXvGT9sb9cPllOgFFWXCn9PyARQ0H+Ijz6mmoRrGateUDxQ==} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.4': + resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.0': + resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@puppeteer/browsers@2.12.0': resolution: {integrity: sha512-Xuq42yxcQJ54ti8ZHNzF5snFvtpgXzNToJ1bXUGQRaiO8t+B6UM8sTUJfvV+AJnqtkJU/7hdy6nbKyA12aHtRw==} engines: {node: '>=18'} @@ -922,6 +1013,10 @@ packages: resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==} engines: {node: '>=18.0.0'} + '@tootallnate/once@2.0.0': + resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} + engines: {node: '>= 10'} + '@tootallnate/quickjs-emscripten@0.23.0': resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} @@ -1107,6 +1202,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -1167,6 +1266,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + arrify@2.0.1: + resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==} + engines: {node: '>=8'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -1465,6 +1568,10 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + data-uri-to-buffer@6.0.2: resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} engines: {node: '>= 14'} @@ -1539,6 +1646,12 @@ packages: duplexer2@0.1.4: resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} + duplexify@4.1.3: + resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} @@ -1551,6 +1664,9 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -1756,6 +1872,10 @@ packages: picomatch: optional: true + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -1792,6 +1912,10 @@ packages: debug: optional: true + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + form-data-encoder@1.7.2: resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} @@ -1803,6 +1927,10 @@ packages: resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} engines: {node: '>= 12.20'} + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -1838,10 +1966,18 @@ packages: resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==} engines: {node: '>=14'} + gaxios@7.1.3: + resolution: {integrity: sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==} + engines: {node: '>=18'} + gcp-metadata@6.1.1: resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==} engines: {node: '>=14'} + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -1876,6 +2012,11 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -1884,14 +2025,26 @@ packages: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} + google-auth-library@10.5.0: + resolution: {integrity: sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==} + engines: {node: '>=18'} + google-auth-library@9.15.1: resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==} engines: {node: '>=14'} + google-gax@5.0.6: + resolution: {integrity: sha512-1kGbqVQBZPAAu4+/R1XxPQKP0ydbNYoLAr4l0ZO2bMV0kLyLW4I1gAk++qBLWt7DPORTzmWRMsCZe86gDjShJA==} + engines: {node: '>=18'} + google-logging-utils@0.0.2: resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==} engines: {node: '>=14'} + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + googleapis-common@7.2.0: resolution: {integrity: sha512-/fhDZEJZvOV3X5jmD+fKxMqma5q2Q9nZNSF3kn1F18tpxmA86BcTxAGBQdM0N89Z3bEaIs+HVznSmFJEAmMTjA==} engines: {node: '>=14.0.0'} @@ -1915,6 +2068,10 @@ packages: resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==} engines: {node: '>=14.0.0'} + gtoken@8.0.0: + resolution: {integrity: sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==} + engines: {node: '>=18'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -1931,6 +2088,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + heap-js@2.7.1: + resolution: {integrity: sha512-EQfezRg0NCZGNlhlDR3Evrw1FVL2G3LhU7EgPoxufQKruNBSYA8MiRPHeWbU+36o+Fhel0wMwM+sLEiBAlNLJA==} + engines: {node: '>=10.0.0'} + highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} @@ -1948,10 +2109,18 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + http-proxy-agent@5.0.0: + resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} + engines: {node: '>= 6'} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -2054,6 +2223,9 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-stream-ended@0.1.4: + resolution: {integrity: sha512-xj0XPvmr7bQFTvirqnFr50o0hQIh6ZItDqloxt5aJrR4NQsYeSsyFQERYGCAzfindAcnKjINnwEEgLx4IqVzQw==} + is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} @@ -2064,6 +2236,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jose@6.1.3: resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} @@ -2141,6 +2316,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} @@ -2183,9 +2361,15 @@ packages: lodash@4.17.23: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@7.18.3: resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} engines: {node: '>=12'} @@ -2256,6 +2440,10 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + mitt@3.0.1: resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} @@ -2309,6 +2497,10 @@ packages: encoding: optional: true + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-webpmux@3.1.7: resolution: {integrity: sha512-ySkL4lBCto86OyQ0blAGzylWSECcn5I0lM3bYEhe75T8Zxt/BFUMHa8ktUguR7zwXNdS/Hms31VfSsYKN1383g==} @@ -2323,6 +2515,10 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -2357,6 +2553,10 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + p-defer@3.0.0: + resolution: {integrity: sha512-ugZxsxmtTln604yeYd29EGrNhazN2lywetzpKhfmQjW/VJmhpDmWbiX+h0zL8V91R0UXkhb3KtPmyq9PZw3aYw==} + engines: {node: '>=8'} + p-finally@1.0.0: resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} engines: {node: '>=4'} @@ -2389,6 +2589,9 @@ packages: resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} engines: {node: '>= 14'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -2426,6 +2629,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-to-regexp@8.3.0: resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} @@ -2470,6 +2677,14 @@ packages: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} engines: {node: '>=0.4.0'} + proto3-json-serializer@3.0.4: + resolution: {integrity: sha512-E1sbAYg3aEbXrq0n1ojJkRHQJGE1kaE/O6GLA94y8rnJBfgvOPTOd1b9hOceQK1FFZI9qMh1vBERCyO2ifubcw==} + engines: {node: '>=18'} + + protobufjs@7.5.4: + resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} + engines: {node: '>=12.0.0'} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -2552,6 +2767,10 @@ packages: resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + retry-request@8.0.2: + resolution: {integrity: sha512-JzFPAfklk1kjR1w76f0QOIhoDkNkSqW8wYKT08n9yysTmZfB+RQ2QoXoTAeOi1HD9ZipTyTAZg3c4pM/jeqgSw==} + engines: {node: '>=18'} + retry@0.13.1: resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} engines: {node: '>= 4'} @@ -2561,6 +2780,10 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true + rimraf@5.0.10: + resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} + hasBin: true + rollup@4.57.1: resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -2631,6 +2854,10 @@ packages: signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + simple-concat@1.0.1: resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} @@ -2675,6 +2902,12 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stream-events@1.0.5: + resolution: {integrity: sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==} + + stream-shift@1.0.3: + resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} + streamx@2.23.0: resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==} @@ -2682,6 +2915,10 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + string-width@7.2.0: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} @@ -2718,6 +2955,9 @@ packages: strnum@2.1.2: resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==} + stubs@3.0.0: + resolution: {integrity: sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -2735,6 +2975,10 @@ packages: tar-stream@3.1.7: resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + teeny-request@10.1.0: + resolution: {integrity: sha512-3ZnLvgWF29jikg1sAQ1g0o+lr5JX6sVgYvfUJazn7ZjJroDBUTWp44/+cFVX0bULjv4vci+rBD+oGVAkWqhUbw==} + engines: {node: '>=18'} + text-decoder@1.2.3: resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==} @@ -2938,6 +3182,10 @@ packages: jsdom: optional: true + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + web-streams-polyfill@4.0.0-beta.3: resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} engines: {node: '>= 14'} @@ -2984,6 +3232,10 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wrap-ansi@9.0.2: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} @@ -3667,10 +3919,52 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@google-cloud/paginator@6.0.0': + dependencies: + extend: 3.0.2 + + '@google-cloud/precise-date@5.0.0': {} + + '@google-cloud/projectify@5.0.0': {} + + '@google-cloud/promisify@5.0.0': {} + + '@google-cloud/pubsub@5.2.3': + dependencies: + '@google-cloud/paginator': 6.0.0 + '@google-cloud/precise-date': 5.0.0 + '@google-cloud/projectify': 5.0.0 + '@google-cloud/promisify': 5.0.0 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.34.0 + arrify: 2.0.1 + extend: 3.0.2 + google-auth-library: 10.5.0 + google-gax: 5.0.6 + heap-js: 2.7.1 + is-stream-ended: 0.1.4 + lodash.snakecase: 4.1.1 + p-defer: 3.0.0 + transitivePeerDependencies: + - supports-color + '@google/generative-ai@0.24.1': {} '@grammyjs/types@3.23.0': {} + '@grpc/grpc-js@1.14.3': + dependencies: + '@grpc/proto-loader': 0.8.0 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.8.0': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.5.4 + yargs: 17.7.2 + '@hono/node-server@1.19.9(hono@4.11.7)': dependencies: hono: 4.11.7 @@ -3686,8 +3980,19 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.2 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + '@jridgewell/sourcemap-codec@1.5.5': {} + '@js-sdsl/ordered-map@4.4.2': {} + '@mixmark-io/domino@2.2.0': {} '@modelcontextprotocol/sdk@1.26.0(zod@3.25.76)': @@ -3714,8 +4019,45 @@ snapshots: '@mozilla/readability@0.5.0': {} + '@opentelemetry/api@1.9.0': {} + + '@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/semantic-conventions': 1.28.0 + + '@opentelemetry/semantic-conventions@1.28.0': {} + + '@opentelemetry/semantic-conventions@1.34.0': {} + '@pedroslopez/moduleraid@5.0.2': {} + '@pkgjs/parseargs@0.11.0': + optional: true + + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.4': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.0 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.0': {} + '@puppeteer/browsers@2.12.0': dependencies: debug: 4.4.3 @@ -4184,6 +4526,8 @@ snapshots: dependencies: tslib: 2.8.1 + '@tootallnate/once@2.0.0': {} + '@tootallnate/quickjs-emscripten@0.23.0': {} '@types/better-sqlite3@7.6.13': @@ -4425,6 +4769,12 @@ snapshots: acorn@8.15.0: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + agent-base@7.1.4: {} agentkeepalive@4.6.0: @@ -4506,6 +4856,8 @@ snapshots: argparse@2.0.1: {} + arrify@2.0.1: {} + assertion-error@2.0.1: {} ast-types@0.13.4: @@ -4807,6 +5159,8 @@ snapshots: csstype@3.2.3: {} + data-uri-to-buffer@4.0.1: {} + data-uri-to-buffer@6.0.2: {} debug@4.4.3: @@ -4887,6 +5241,15 @@ snapshots: readable-stream: 2.3.8 optional: true + duplexify@4.1.3: + dependencies: + end-of-stream: 1.4.5 + inherits: 2.0.4 + readable-stream: 3.6.2 + stream-shift: 1.0.3 + + eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer: 5.2.1 @@ -4897,6 +5260,8 @@ snapshots: emoji-regex@8.0.0: {} + emoji-regex@9.2.2: {} + encodeurl@2.0.0: {} end-of-stream@1.4.5: @@ -5147,6 +5512,11 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -5183,6 +5553,11 @@ snapshots: follow-redirects@1.15.11: {} + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + form-data-encoder@1.7.2: {} form-data@4.0.5: @@ -5198,6 +5573,10 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 4.0.0-beta.3 + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + forwarded@0.2.0: {} fresh@2.0.0: {} @@ -5238,6 +5617,15 @@ snapshots: - encoding - supports-color + gaxios@7.1.3: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + rimraf: 5.0.10 + transitivePeerDependencies: + - supports-color + gcp-metadata@6.1.1: dependencies: gaxios: 6.7.1 @@ -5247,6 +5635,14 @@ snapshots: - encoding - supports-color + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.1.3 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + get-caller-file@2.0.5: {} get-east-asian-width@1.4.0: {} @@ -5291,6 +5687,15 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -5303,6 +5708,18 @@ snapshots: globals@14.0.0: {} + google-auth-library@10.5.0: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.1.3 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + gtoken: 8.0.0 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + google-auth-library@9.15.1: dependencies: base64-js: 1.5.1 @@ -5315,8 +5732,26 @@ snapshots: - encoding - supports-color + google-gax@5.0.6: + dependencies: + '@grpc/grpc-js': 1.14.3 + '@grpc/proto-loader': 0.8.0 + duplexify: 4.1.3 + google-auth-library: 10.5.0 + google-logging-utils: 1.1.3 + node-fetch: 3.3.2 + object-hash: 3.0.0 + proto3-json-serializer: 3.0.4 + protobufjs: 7.5.4 + retry-request: 8.0.2 + rimraf: 5.0.10 + transitivePeerDependencies: + - supports-color + google-logging-utils@0.0.2: {} + google-logging-utils@1.1.3: {} + googleapis-common@7.2.0: dependencies: extend: 3.0.2 @@ -5360,6 +5795,13 @@ snapshots: - encoding - supports-color + gtoken@8.0.0: + dependencies: + gaxios: 7.1.3 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -5372,6 +5814,8 @@ snapshots: dependencies: function-bind: 1.1.2 + heap-js@2.7.1: {} + highlight.js@10.7.3: {} hono@4.11.7: {} @@ -5393,6 +5837,14 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http-proxy-agent@5.0.0: + dependencies: + '@tootallnate/once': 2.0.0 + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -5400,6 +5852,13 @@ snapshots: transitivePeerDependencies: - supports-color + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -5503,6 +5962,8 @@ snapshots: is-promise@4.0.0: {} + is-stream-ended@0.1.4: {} + is-stream@2.0.1: {} isarray@1.0.0: @@ -5510,6 +5971,12 @@ snapshots: isexe@2.0.0: {} + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jose@6.1.3: {} js-tokens@4.0.0: {} @@ -5598,6 +6065,8 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.camelcase@4.3.0: {} + lodash.defaults@4.2.0: optional: true @@ -5630,8 +6099,12 @@ snapshots: lodash@4.17.23: {} + long@5.3.2: {} + loupe@3.2.1: {} + lru-cache@10.4.3: {} + lru-cache@7.18.3: {} magic-bytes.js@1.13.0: {} @@ -5681,6 +6154,8 @@ snapshots: minimist@1.2.8: {} + minipass@7.1.2: {} + mitt@3.0.1: {} mkdirp-classic@0.5.3: {} @@ -5718,6 +6193,12 @@ snapshots: dependencies: whatwg-url: 5.0.0 + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + node-webpmux@3.1.7: {} normalize-path@3.0.0: @@ -5729,6 +6210,8 @@ snapshots: object-assign@4.1.1: {} + object-hash@3.0.0: {} + object-inspect@1.13.4: {} ollama@0.5.18: @@ -5771,6 +6254,8 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + p-defer@3.0.0: {} + p-finally@1.0.0: {} p-limit@3.1.0: @@ -5813,6 +6298,8 @@ snapshots: degenerator: 5.0.1 netmask: 2.0.2 + package-json-from-dist@1.0.1: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -5843,6 +6330,11 @@ snapshots: path-key@3.1.1: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + path-to-regexp@8.3.0: {} pathe@2.0.3: {} @@ -5885,6 +6377,25 @@ snapshots: progress@2.0.3: {} + proto3-json-serializer@3.0.4: + dependencies: + protobufjs: 7.5.4 + + protobufjs@7.5.4: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/node': 22.19.7 + long: 5.3.2 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -6008,6 +6519,13 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 + retry-request@8.0.2: + dependencies: + extend: 3.0.2 + teeny-request: 10.1.0 + transitivePeerDependencies: + - supports-color + retry@0.13.1: {} rimraf@2.7.1: @@ -6015,6 +6533,10 @@ snapshots: glob: 7.2.3 optional: true + rimraf@5.0.10: + dependencies: + glob: 10.5.0 + rollup@4.57.1: dependencies: '@types/estree': 1.0.8 @@ -6135,6 +6657,8 @@ snapshots: signal-exit@3.0.7: {} + signal-exit@4.1.0: {} + simple-concat@1.0.1: {} simple-get@4.0.1: @@ -6178,6 +6702,12 @@ snapshots: std-env@3.10.0: {} + stream-events@1.0.5: + dependencies: + stubs: 3.0.0 + + stream-shift@1.0.3: {} + streamx@2.23.0: dependencies: events-universal: 1.0.1 @@ -6193,6 +6723,12 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.2 + string-width@7.2.0: dependencies: emoji-regex: 10.6.0 @@ -6231,6 +6767,8 @@ snapshots: strnum@2.1.2: {} + stubs@3.0.0: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -6271,6 +6809,15 @@ snapshots: - bare-abort-controller - react-native-b4a + teeny-request@10.1.0: + dependencies: + http-proxy-agent: 5.0.0 + https-proxy-agent: 5.0.1 + node-fetch: 3.3.2 + stream-events: 1.0.5 + transitivePeerDependencies: + - supports-color + text-decoder@1.2.3: dependencies: b4a: 1.7.3 @@ -6463,6 +7010,8 @@ snapshots: - tsx - yaml + web-streams-polyfill@3.3.3: {} + web-streams-polyfill@4.0.0-beta.3: {} webdriver-bidi-protocol@0.4.0: {} @@ -6523,6 +7072,12 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.1.2 + wrap-ansi@9.0.2: dependencies: ansi-styles: 6.2.3 diff --git a/src/auth/index.ts b/src/auth/index.ts index e40914b..e9d3b75 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -7,3 +7,16 @@ export { loginGitHub, type DeviceCodeResponse, } from './github.js'; + +export { + loadStoredOpenAIAuth, + storeOpenAIAuth, + clearOpenAIAuth, + refreshOpenAIAuth, + ensureValidOpenAIAuth, + loginOpenAI, + parseJwtClaims, + extractAccountId, + type OpenAIOAuthInfo, + type IdTokenClaims, +} from './openai.js'; diff --git a/src/auth/openai.test.ts b/src/auth/openai.test.ts new file mode 100644 index 0000000..7cdd940 --- /dev/null +++ b/src/auth/openai.test.ts @@ -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 { + 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'); + }); +}); diff --git a/src/auth/openai.ts b/src/auth/openai.ts new file mode 100644 index 0000000..4792358 --- /dev/null +++ b/src/auth/openai.ts @@ -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(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(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 { + 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; +} + +async function pollDeviceToken(deviceAuthId: string, userCode: string, intervalMs: number): Promise { + 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; + } + + // 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 { + 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; +} + +export async function refreshOpenAIAuth(refreshToken: string): Promise { + 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; +} + +/** + * Ensure we have a valid (non-expired) OpenAI OAuth access token. + * Refreshes and persists the token if needed. + */ +export async function ensureValidOpenAIAuth(): Promise { + 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 { + 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; +} diff --git a/src/automation/gmail.test.ts b/src/automation/gmail.test.ts index 1a5c931..6bf8a00 100644 --- a/src/automation/gmail.test.ts +++ b/src/automation/gmail.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { homedir } from 'os'; -import { GmailWatcher } from './gmail.js'; +import type { GmailWatcher as GmailWatcherType } from './gmail.js'; import type { OutboundMessage } from '../channels/types.js'; // 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 vi.mock('fs', async () => { const actual = await vi.importActual('fs'); @@ -86,6 +103,7 @@ vi.mock('fs', async () => { installed: { client_id: 'test-client-id', client_secret: 'test-client-secret', + project_id: 'test-project', redirect_uris: ['http://localhost'], }, }); @@ -108,6 +126,9 @@ function createMockConfig(overrides = {}) { enabled: true, credentials_file: '~/.config/flynn/gmail-credentials.json', token_file: '~/.config/flynn/gmail-token.json', + disable_push: false, + pubsub_pull_interval: '60s', + pubsub_max_messages: 10, watch_labels: ['INBOX'], poll_interval: '300s', output: { @@ -128,12 +149,16 @@ function createMockChannelLookup() { } describe('GmailWatcher', () => { - let watcher: GmailWatcher; + let GmailWatcher: typeof GmailWatcherType; + let watcher: GmailWatcherType; let channelLookup: ReturnType; - beforeEach(() => { + beforeEach(async () => { vi.useFakeTimers(); channelLookup = createMockChannelLookup(); + + // Import after mocks so ESM named imports (fs/googleapis) are properly mocked. + ({ GmailWatcher } = await import('./gmail.js')); }); 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', () => { it('logs warning and sets status to error when credentials_file is missing', async () => { 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; + mockOAuth2: ReturnType; + }; + }; + 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; acknowledge: ReturnType }; + }; + 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 }).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', () => { it('replaces all placeholders correctly', () => { const config = createMockConfig({ diff --git a/src/automation/gmail.ts b/src/automation/gmail.ts index 96d8028..90ddb75 100644 --- a/src/automation/gmail.ts +++ b/src/automation/gmail.ts @@ -2,6 +2,7 @@ import { google, type Auth } from 'googleapis'; import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from 'fs'; import { dirname, resolve } from 'path'; import { homedir } from 'os'; +import type { v1 } from '@google-cloud/pubsub'; import type { GmailConfig } from '../config/schema.js'; import type { ChannelAdapter, ChannelStatus, InboundMessage, OutboundMessage } from '../channels/types.js'; import { parseInterval } from './heartbeat.js'; @@ -30,9 +31,7 @@ interface PubSubNotification { historyId: string; } -// Google Cloud Pub/Sub topic for Gmail push notifications. -// This must be pre-configured in Google Cloud Console. -const GMAIL_PUBSUB_TOPIC = 'projects/flynn-agent/topics/gmail-push'; +const DEFAULT_TOPIC_ID = 'gmail-push'; // Watch expires after ~7 days; renew at 6 days (in ms). const WATCH_RENEWAL_MS = 6 * 24 * 60 * 60 * 1000; @@ -56,7 +55,11 @@ export class GmailWatcher implements ChannelAdapter { private lastHistoryId?: string; private pollTimer?: ReturnType; private watchTimer?: ReturnType; + private pullTimer?: ReturnType; + private pubsubSubscriber?: v1.SubscriberClient; + private pullInFlight = false; private readonly config: NonNullable; + private googleProjectId?: string; constructor( config: NonNullable, @@ -82,12 +85,28 @@ export class GmailWatcher implements ChannelAdapter { 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 { + await this.setupWatch(); + } catch (error) { + const errMsg = error instanceof Error ? error.message : 'Unknown error'; + 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.setupWatch(); + await this.setupPullSubscription(); } catch (error) { const errMsg = error instanceof Error ? error.message : 'Unknown error'; - console.warn(`GmailWatcher: Watch setup failed (will use polling only) — ${errMsg}`); + console.warn(`GmailWatcher: Pull setup failed (will continue without pull) — ${errMsg}`); } // Start polling fallback @@ -99,8 +118,23 @@ export class GmailWatcher implements ChannelAdapter { }, pollMs); 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 { @@ -109,9 +143,21 @@ export class GmailWatcher implements ChannelAdapter { this.pollTimer = undefined; } if (this.watchTimer) { - clearTimeout(this.watchTimer); + clearInterval(this.watchTimer); 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._status = 'disconnected'; auditLogger?.systemStop('GmailWatcher'); @@ -178,7 +224,10 @@ export class GmailWatcher implements ChannelAdapter { } 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) { 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 { 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 watchResponse = await gmail.users.watch({ userId: 'me', requestBody: { labelIds: this.config.watch_labels ?? ['INBOX'], - topicName: GMAIL_PUBSUB_TOPIC, + topicName, }, }); @@ -241,6 +301,157 @@ export class GmailWatcher implements ChannelAdapter { }, 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 ?? ''}/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//topics/) 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//topics/`, + ); + } + + 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//subscriptions/) 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//subscriptions/`, + ); + } + + return sub; + } + + private async setupPullSubscription(): Promise { + 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 { + 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 { + 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. * Fallback mechanism when Pub/Sub push is not available. diff --git a/src/channels/telegram/adapter.test.ts b/src/channels/telegram/adapter.test.ts index 59e894d..2329fc6 100644 --- a/src/channels/telegram/adapter.test.ts +++ b/src/channels/telegram/adapter.test.ts @@ -228,6 +228,35 @@ describe('TelegramAdapter', () => { 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 ─────────────────────────────────────────── it('auth middleware blocks unauthorized chat IDs', async () => { diff --git a/src/channels/telegram/adapter.ts b/src/channels/telegram/adapter.ts index 32a556a..e419a24 100644 --- a/src/channels/telegram/adapter.ts +++ b/src/channels/telegram/adapter.ts @@ -166,7 +166,9 @@ export class TelegramAdapter implements ChannelAdapter { this.bot.command('model', async (ctx) => { 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({ 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');} 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 => { + // 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 if (text.length <= 4096) { - await this.bot.api.sendMessage(chatId, text, { parse_mode: 'Markdown' }); + await sendChunk(text); } else { const chunks = splitMessage(text, 4096); for (const chunk of chunks) { - await this.bot.api.sendMessage(chatId, chunk, { parse_mode: 'Markdown' }); + await sendChunk(chunk); } } diff --git a/src/cli/doctor.test.ts b/src/cli/doctor.test.ts index 40605b2..0dd0cbf 100644 --- a/src/cli/doctor.test.ts +++ b/src/cli/doctor.test.ts @@ -163,6 +163,75 @@ automation: expect(gmailCheck?.detail).toContain('flynn gmail-auth'); }); + it('reports PASS for Gmail when enabled (poll only)', async () => { + mkdirSync(testDir, { recursive: true }); + const configPath = join(testDir, 'config.yaml'); + const credsPath = join(testDir, 'gmail-creds.json'); + const tokenPath = join(testDir, 'gmail-token.json'); + writeFileSync(credsPath, JSON.stringify({ installed: { project_id: 'test-project' } })); + writeFileSync(tokenPath, JSON.stringify({ refresh_token: 'x' })); + + writeFileSync(configPath, ` +telegram: + bot_token: "test-token" + allowed_chat_ids: [123] +models: + default: + provider: anthropic + model: claude-sonnet +automation: + gmail: + enabled: true + credentials_file: "${credsPath}" + token_file: "${tokenPath}" + output: + channel: telegram + peer: "123" +`); + + const ctx: DoctorContext = { configPath, dataDir: testDir }; + const results = await runChecks(ctx); + + const gmailCheck = results.find(r => r.label.includes('Gmail configured')); + expect(gmailCheck?.status).toBe('pass'); + expect(gmailCheck?.detail).toContain('poll'); + }); + + it('reports WARN for Gmail when pubsub_topic shorthand used without project_id', async () => { + mkdirSync(testDir, { recursive: true }); + const configPath = join(testDir, 'config.yaml'); + const credsPath = join(testDir, 'gmail-creds.json'); + const tokenPath = join(testDir, 'gmail-token.json'); + writeFileSync(credsPath, '{}'); + writeFileSync(tokenPath, JSON.stringify({ refresh_token: 'x' })); + + writeFileSync(configPath, ` +telegram: + bot_token: "test-token" + allowed_chat_ids: [123] +models: + default: + provider: anthropic + model: claude-sonnet +automation: + gmail: + enabled: true + credentials_file: "${credsPath}" + token_file: "${tokenPath}" + pubsub_topic: gmail-push + output: + channel: telegram + peer: "123" +`); + + const ctx: DoctorContext = { configPath, dataDir: testDir }; + const results = await runChecks(ctx); + + const gmailCheck = results.find(r => r.label.includes('Gmail configured')); + expect(gmailCheck?.status).toBe('warn'); + expect(gmailCheck?.detail).toContain('pubsub_topic shorthand'); + }); + it('skips downstream checks when config is invalid', async () => { const ctx: DoctorContext = { configPath: '/nonexistent/config.yaml', dataDir: testDir }; const results = await runChecks(ctx); diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 6f14ed0..769ec14 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -137,7 +137,8 @@ const checkModelConnectivity: Check = async (ctx) => { // Check if API key is present for providers that need one const needsKey = ['anthropic', 'openai', 'gemini', 'openrouter']; - if (needsKey.includes(model.provider) && !model.api_key && !model.auth_token) { + const openaiUsingOAuth = model.provider === 'openai' && Boolean((model as unknown as { use_oauth?: boolean }).use_oauth); + if (needsKey.includes(model.provider) && !openaiUsingOAuth && !model.api_key && !model.auth_token) { const envVarMap: Record = { anthropic: 'ANTHROPIC_API_KEY', openai: 'OPENAI_API_KEY', @@ -256,12 +257,64 @@ const checkGmail: Check = async (ctx) => { return { status: 'fail', label: 'Gmail configured', detail: `credentials file not found: ${credentialsPath}` }; } + let googleProjectId: string | undefined; + try { + const creds = JSON.parse(readFileSync(credentialsPath, 'utf-8')) as Record; + const installed = (creds.installed as Record | undefined) ?? (creds.web as Record | undefined); + const projectId = installed?.project_id; + if (typeof projectId === 'string' && projectId.trim()) { + googleProjectId = projectId.trim(); + } + } catch { + // Ignore JSON parse errors; doctor will still validate token and output. + } + const tokenPath = expandPath(gmail.token_file ?? '~/.config/flynn/gmail-token.json'); if (!existsSync(tokenPath)) { return { status: 'warn', label: 'Gmail configured', detail: 'run `flynn gmail-auth` to authenticate' }; } - return { status: 'pass', label: 'Gmail configured', detail: `(output: ${gmail.output.channel}/${gmail.output.peer})` }; + const modes: string[] = []; + const warnings: string[] = []; + + const topicRaw = (gmail.pubsub_topic ?? process.env.FLYNN_GMAIL_PUBSUB_TOPIC ?? '').trim(); + const pushEnabled = Boolean(topicRaw) && !gmail.disable_push; + if (pushEnabled) { + modes.push('push'); + if (topicRaw.includes('/')) { + const ok = /^projects\/[^/]+\/topics\/[^/]+$/.test(topicRaw); + if (!ok) { + warnings.push('pubsub_topic format invalid (expected projects//topics/)'); + } + } 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//subscriptions/)'); + } + } 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[] = [ diff --git a/src/cli/index.ts b/src/cli/index.ts index 9a8930c..e677269 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -18,6 +18,7 @@ import { registerGcalAuthCommand } from './gcal-auth.js'; import { registerGdocsAuthCommand } from './gdocs-auth.js'; import { registerGdriveAuthCommand } from './gdrive-auth.js'; import { registerGtasksAuthCommand } from './gtasks-auth.js'; +import { registerOpenaiAuthCommand } from './openai-auth.js'; import { registerSkillsCommand } from './skills.js'; export function createProgram(): Command { @@ -41,6 +42,7 @@ export function createProgram(): Command { registerGdocsAuthCommand(program); registerGdriveAuthCommand(program); registerGtasksAuthCommand(program); + registerOpenaiAuthCommand(program); registerSkillsCommand(program); return program; diff --git a/src/cli/openai-auth.ts b/src/cli/openai-auth.ts new file mode 100644 index 0000000..950e2e9 --- /dev/null +++ b/src/cli/openai-auth.ts @@ -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); + } + }); +} diff --git a/src/cli/shared.test.ts b/src/cli/shared.test.ts index f572445..3a3ba54 100644 --- a/src/cli/shared.test.ts +++ b/src/cli/shared.test.ts @@ -53,6 +53,45 @@ models: expect(result.config!.telegram?.bot_token).toBe('test-token'); }); + it('loads env vars from FLYNN_ENV_FILE before parsing config', () => { + const prevEnvFile = process.env.FLYNN_ENV_FILE; + const prevToken = process.env.TEST_BOT_TOKEN; + delete process.env.TEST_BOT_TOKEN; + + mkdirSync(testDir, { recursive: true }); + const envPath = join(testDir, 'cloud.env'); + const configPath = join(testDir, 'config.yaml'); + + writeFileSync(envPath, 'TEST_BOT_TOKEN=test-token\n'); + process.env.FLYNN_ENV_FILE = envPath; + + writeFileSync(configPath, ` +telegram: + bot_token: \${TEST_BOT_TOKEN} + allowed_chat_ids: [123] +models: + default: + provider: anthropic + model: claude-sonnet +`); + + const result = loadConfigSafe(configPath); + expect(result.config).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.config!.telegram?.bot_token).toBe('test-token'); + + if (prevEnvFile !== undefined) { + process.env.FLYNN_ENV_FILE = prevEnvFile; + } else { + delete process.env.FLYNN_ENV_FILE; + } + if (prevToken !== undefined) { + process.env.TEST_BOT_TOKEN = prevToken; + } else { + delete process.env.TEST_BOT_TOKEN; + } + }); + it('returns error when file not found', () => { const result = loadConfigSafe('/nonexistent/config.yaml'); expect(result.config).toBeUndefined(); diff --git a/src/cli/shared.ts b/src/cli/shared.ts index 547e660..f8230a4 100644 --- a/src/cli/shared.ts +++ b/src/cli/shared.ts @@ -2,6 +2,38 @@ import { loadConfig } from '../config/index.js'; import type { Config } from '../config/index.js'; import { resolve, dirname, join } from 'path'; import { homedir } from 'os'; +import { existsSync, readFileSync } from 'fs'; + +function loadEnvFileIfPresent(): void { + const envFile = process.env.FLYNN_ENV_FILE ?? resolve(homedir(), '.config/flynn/cloud.env'); + if (!existsSync(envFile)) { + return; + } + + const raw = readFileSync(envFile, 'utf-8'); + for (const line of raw.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) { + continue; + } + + const idx = trimmed.indexOf('='); + if (idx <= 0) { + continue; + } + + const key = trimmed.slice(0, idx).trim(); + const value = trimmed.slice(idx + 1); + if (!key) { + continue; + } + + // Don't override existing env vars. + if (process.env[key] === undefined) { + process.env[key] = value; + } + } +} /** Get the config file path from env or default location. */ export function getConfigPath(): string { @@ -30,6 +62,7 @@ export function resolveOverlayPath(basePath: string): string | undefined { export function loadConfigSafe(configPath?: string): { config?: Config; error?: string } { const path = configPath ?? getConfigPath(); try { + loadEnvFileIfPresent(); const overlayPath = resolveOverlayPath(path); const config = loadConfig(path, overlayPath); return { config }; diff --git a/src/cli/tui.ts b/src/cli/tui.ts index 4805f10..203690b 100644 --- a/src/cli/tui.ts +++ b/src/cli/tui.ts @@ -1,5 +1,5 @@ import type { Command } from 'commander'; -import type { Config } from '../config/index.js'; +import type { Config, ModelConfig, ModelProvider } from '../config/index.js'; import { loadConfigSafe, getConfigPath } from './shared.js'; import { existsSync, mkdirSync, readFileSync } from 'fs'; import { resolve } from 'path'; @@ -58,6 +58,26 @@ function loadSystemPrompt(): string { return 'You are Flynn, a helpful personal AI assistant. Be direct, concise, and helpful. Use markdown when it improves readability.'; } +function buildProviderConfigMap(config: Config): Partial> { + const providerConfigs: Partial> = {}; + const modelConfigs: ModelConfig[] = [ + config.models.default, + ...(config.models.fast ? [config.models.fast] : []), + ...(config.models.complex ? [config.models.complex] : []), + ...(config.models.local ? [config.models.local] : []), + ...Object.values(config.models.local_providers ?? {}), + ]; + + for (const modelConfig of modelConfigs) { + providerConfigs[modelConfig.provider] = modelConfig; + if (modelConfig.fallback) { + providerConfigs[modelConfig.fallback.provider] = modelConfig.fallback; + } + } + + return providerConfigs; +} + export function registerTuiCommand(program: Command): void { program .command('tui') @@ -179,6 +199,7 @@ export function registerTuiCommand(program: Command): void { const toolExecutor = new ToolExecutor(toolRegistry, hookEngine); const session = sessionManager.getSession('tui', 'local'); + const modelProviderConfigs = buildProviderConfigMap(config); const agent = new NativeAgent({ modelClient: modelRouter, @@ -211,29 +232,33 @@ export function registerTuiCommand(program: Command): void { process.exit(0); }); - if (opts.fullscreen) { - await startFullscreenTui({ - session, - modelClient: modelRouter, - modelRouter, - systemPrompt, - model: config.models.default.model, - agent, - onExit: cleanup, - }); - } else { + if (opts.fullscreen) { + await startFullscreenTui({ + session, + modelClient: modelRouter, + modelRouter, + systemPrompt, + model: config.models.default.model, + agent, + hookEngine, + modelProviderConfigs, + onExit: cleanup, + }); + } else { let switchingToFullscreen = false; - const tui = new MinimalTui({ - session, - modelClient: modelRouter, - modelRouter, - systemPrompt, - agent, - pairingManager, - localProviders: config.models.local_providers, - currentLocalProvider: config.models.local?.provider, - onTransfer: (target) => { + const tui = new MinimalTui({ + session, + modelClient: modelRouter, + modelRouter, + systemPrompt, + agent, + hookEngine, + pairingManager, + localProviders: config.models.local_providers, + modelProviderConfigs, + currentLocalProvider: config.models.local?.provider, + onTransfer: (target) => { if (target === 'telegram') { if (config.telegram && config.telegram.allowed_chat_ids.length > 0) { const telegramUserId = String(config.telegram.allowed_chat_ids[0]); @@ -263,6 +288,8 @@ export function registerTuiCommand(program: Command): void { systemPrompt, model: config.models.default.model, agent, + hookEngine, + modelProviderConfigs, onExit: cleanup, }); return; diff --git a/src/commands/builtin/index.test.ts b/src/commands/builtin/index.test.ts new file mode 100644 index 0000000..41b322c --- /dev/null +++ b/src/commands/builtin/index.test.ts @@ -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' }); + }); +}); diff --git a/src/commands/builtin/index.ts b/src/commands/builtin/index.ts index d717234..983ca23 100644 --- a/src/commands/builtin/index.ts +++ b/src/commands/builtin/index.ts @@ -86,7 +86,9 @@ export function createModelCommand(): CommandDefinition { return { handled: true, - text: await ctx.services.setModel(args[0]), + // Pass through the full argument string so frontends can support + // richer syntax like: /model + text: await ctx.services.setModel(args.join(' ')), }; }, }; diff --git a/src/config/schema.ts b/src/config/schema.ts index ebcd00b..e2e6b70 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -49,6 +49,8 @@ const modelConfigBaseSchema = z.object({ endpoint: z.string().optional(), api_key: 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(), num_gpu: z.number().optional(), context_window: z.number().optional(), @@ -178,6 +180,30 @@ const gmailSchema = z.object({ enabled: z.boolean().default(false), credentials_file: z.string().optional(), token_file: z.string().default('~/.config/flynn/gmail-token.json'), + /** + * Optional Google Cloud Pub/Sub topic for Gmail push notifications. + * Format: projects//topics/ + * 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//subscriptions/ + */ + 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']), poll_interval: z.string().default('300s'), history_start: z.string().optional(), // ISO date string — only process emails after this date diff --git a/src/daemon/clientFactory.test.ts b/src/daemon/clientFactory.test.ts index 1f450b0..8583e57 100644 --- a/src/daemon/clientFactory.test.ts +++ b/src/daemon/clientFactory.test.ts @@ -96,6 +96,34 @@ describe('createClientFromConfig', () => { 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', () => { const client = createClientFromConfig({ provider: 'bedrock', diff --git a/src/daemon/models.ts b/src/daemon/models.ts index 14ae70f..475c746 100644 --- a/src/daemon/models.ts +++ b/src/daemon/models.ts @@ -18,6 +18,23 @@ function requireApiKey(cfg: ModelConfig, envVar: string): string { 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. * Dispatches on the `provider` field so all tiers and fallback entries @@ -35,6 +52,7 @@ export function createClientFromConfig(cfg: ModelConfig): ModelClient { return new OpenAIClient({ model: cfg.model, apiKey: cfg.api_key, + useOAuth: Boolean(cfg.use_oauth), }); case 'ollama': return new OllamaClient({ @@ -62,7 +80,7 @@ export function createClientFromConfig(cfg: ModelConfig): ModelClient { case 'zhipuai': return new OpenAIClient({ 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', }); case 'xai': diff --git a/src/daemon/routing.ts b/src/daemon/routing.ts index a36151b..8d541a4 100644 --- a/src/daemon/routing.ts +++ b/src/daemon/routing.ts @@ -9,7 +9,7 @@ import { MemoryStore } from '../memory/index.js'; import type { Tool } from '../tools/types.js'; import { createMediaSendTool } from '../tools/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 { ToolRegistry, ToolExecutor } from '../tools/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 { ComponentRegistry } from '../intents/index.js'; import type { RoutingPolicy } from '../routing/index.js'; +import { createClientFromConfig } from './models.js'; + +function buildProviderConfigMap(config: Config): Partial> { + const providerConfigs: Partial> = {}; + 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. @@ -263,14 +284,97 @@ export function createMessageRouter(deps: { return lines.join('\n'); }, setModel: (tier) => { - const validTiers = deps.modelRouter.getAvailableTiers(); - if (!validTiers.includes(tier as ModelTier)) { - return `Model tier not available: ${tier}`; + const raw = tier.trim(); + if (!raw) { + return 'Usage: /model OR /model OR /model 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 + 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 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 + const providerModel = arg2; + if (!providerModel.includes('/')) { + return 'Invalid format. Use: /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 () => { const result = await agent.compact(); diff --git a/src/frontends/tui/commands.ts b/src/frontends/tui/commands.ts index d359a78..9602ee0 100644 --- a/src/frontends/tui/commands.ts +++ b/src/frontends/tui/commands.ts @@ -124,7 +124,7 @@ Commands: /model [name] Show or switch model tier (local, default, fast, complex) /model

Change tier's provider/model (e.g. /model default anthropic/claude-sonnet-4) /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 generate [label] Generate a new DM pairing code /pair revoke Revoke an approved sender @@ -178,7 +178,7 @@ export const COMMAND_TOOLTIPS: Record = { '/status': 'Show session info and token usage', '/fullscreen': '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', '/transfer': 'Transfer session to another frontend', '/quit': 'Exit TUI', diff --git a/src/frontends/tui/components/App.tsx b/src/frontends/tui/components/App.tsx index b365f50..7373bf8 100644 --- a/src/frontends/tui/components/App.tsx +++ b/src/frontends/tui/components/App.tsx @@ -1,5 +1,5 @@ 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 { MessageList } from './MessageList.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 { ModelRouter } from '../../../models/router.js'; import type { ManagedSession } from '../../../session/index.js'; -import type { NativeAgent } from '../../../backends/native/agent.js'; -import type { ToolUseEvent } from '../../../backends/native/agent.js'; +import type { NativeAgent, 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" */ function formatToolName(name: string): string { @@ -44,6 +47,8 @@ export interface AppProps { systemPrompt: string; model: string; agent?: NativeAgent; + hookEngine?: HookEngine; + modelProviderConfigs?: Partial>; onExit?: () => void; } @@ -54,6 +59,8 @@ export function App({ systemPrompt, model, agent, + hookEngine, + modelProviderConfigs, onExit, }: AppProps): React.ReactElement { const { exit } = useApp(); @@ -63,13 +70,20 @@ export function App({ const [streamingContent, setStreamingContent] = useState(''); const [scrollOffset, setScrollOffset] = useState(0); const [tokenUsage, setTokenUsage] = useState({ 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 toolLinesRef = useRef([]); + const confirmResolveRef = useRef<((result: HookResult) => void) | null>(null); + const [confirmation, setConfirmation] = useState<{ tool: string; args: Record } | null>(null); + // Set up an Ink-compatible onToolUse callback for the agent. - // This replaces the process.stdout.write callback (which corrupts Ink rendering) - // with one that updates React state to show tool activity in the streaming area. + // This replaces process.stdout writes (which corrupt Ink rendering) + // with one that updates React state to show tool activity. useEffect(() => { if (!agent) {return;} @@ -79,7 +93,10 @@ export function App({ const argsStr = event.args ? ` (${formatToolArgs(event.args)})` : ''; toolLinesRef.current = [...toolLinesRef.current, `> ${label}${argsStr}`]; 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 detail = event.result.success ? `(${event.result.output.split('\n').length} lines)` @@ -95,7 +112,43 @@ export function App({ }; }, [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((resolve) => { + confirmResolveRef.current = resolve; + setConfirmation({ tool: pending.tool, args: pending.args }); + }); + }); + + return () => { + hookEngine.setInteractiveConfirmer(undefined); + confirmResolveRef.current = null; + setConfirmation(null); + }; + }, [hookEngine]); + 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 (isStreaming) { abortRef.current = true; @@ -120,7 +173,6 @@ export function App({ return; } - // Scroll handling if (key.upArrow && scrollOffset > 0) { setScrollOffset(prev => Math.max(0, prev - 1)); } @@ -136,12 +188,15 @@ export function App({ }); const handleSubmit = useCallback(async (value: string) => { + if (confirmation) { + return; + } + const command = parseCommand(value); if (!command) {return;} setInput(''); - // Handle commands switch (command.type) { case 'quit': onExit?.(); @@ -160,69 +215,124 @@ export function App({ return; case 'help': { - // Show help as system message const helpMsg: Message = { role: 'assistant', content: getHelpText() }; - const helpWithTs = session.addMessage(helpMsg); - setMessages(prev => [...prev, helpWithTs]); + setMessages(prev => [...prev, session.addMessage(helpMsg)]); return; } case 'status': { const status = `Session: ${session.id}\nMessages: ${messages.length}\nTokens: ${tokenUsage.inputTokens} in / ${tokenUsage.outputTokens} out`; const statusMsg: Message = { role: 'assistant', content: status }; - const statusWithTs = session.addMessage(statusMsg); - setMessages(prev => [...prev, statusWithTs]); + setMessages(prev => [...prev, session.addMessage(statusMsg)]); return; } case 'model': { if (!modelRouter) { - const errMsg: Message = { role: 'assistant', content: 'Model switching not available.' }; - const errWithTs = session.addMessage(errMsg); - setMessages(prev => [...prev, errWithTs]); + setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: 'Model switching not available.' })]); return; } + + // /model if (!command.name) { - const info = `Current: ${modelRouter.getTier()}\nAvailable: ${modelRouter.getAvailableTiers().join(', ')}`; - const infoMsg: Message = { role: 'assistant', content: info }; - const infoWithTs = session.addMessage(infoMsg); - setMessages(prev => [...prev, infoWithTs]); + const current = modelRouter.getTier(); + const available = modelRouter.getAvailableTiers(); + const labels = modelRouter.getAllLabels(); + + 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; } + + // /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 const tier = resolveModelAlias(command.name); if (modelRouter.setTier(tier)) { - // Also update the agent tier so chatWithRouter uses the correct client if (agent) { agent.setModelTier(tier); } - setCurrentModel(tier); - const successMsg: Message = { role: 'assistant', content: `Switched to model: ${tier}` }; - const successWithTs = session.addMessage(successMsg); - setMessages(prev => [...prev, successWithTs]); + setCurrentModel(modelRouter.getLabel(tier)); + setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: `Switched to model: ${tier}` })]); } else { - const failMsg: Message = { role: 'assistant', content: `Model not available: ${command.name}` }; - const failWithTs = session.addMessage(failMsg); - setMessages(prev => [...prev, failWithTs]); + setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: `Model not available: ${command.name}` })]); } return; } case 'fullscreen': - // Already in fullscreen return; - case 'transfer': { - const xferMsg: Message = { role: 'assistant', content: 'Transfer not supported in fullscreen mode.' }; - const xferWithTs = session.addMessage(xferMsg); - setMessages(prev => [...prev, xferWithTs]); + case 'transfer': + setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: 'Transfer not supported in fullscreen mode.' })]); return; - } 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) const userMessage: Message = { role: 'user', content: command.content }; @@ -232,9 +342,8 @@ export function App({ } else { setMessages(prev => [...prev, { ...userMessage, timestamp: Date.now() }]); } - setScrollOffset(0); // Auto-scroll to bottom + setScrollOffset(0); - // Process response setIsStreaming(true); setStreamingContent(''); toolLinesRef.current = []; @@ -242,16 +351,11 @@ export function App({ try { if (agent) { - // agent.process() handles session history internally - const response = await agent.process(command.content); - + await agent.process(command.content); const usage = agent.getUsage(); setTokenUsage({ inputTokens: usage.inputTokens, outputTokens: usage.outputTokens }); - - // Sync UI with session history (agent already added messages to session) setMessages(session.getHistory()); } else if (modelClient.chatStream) { - // Fallback: direct streaming without tools let fullContent = ''; for await (const event of modelClient.chatStream({ @@ -279,10 +383,8 @@ export function App({ } const assistantMessage: Message = { role: 'assistant', content: fullContent }; - const assistantWithTimestamp = session.addMessage(assistantMessage); - setMessages(prev => [...prev, assistantWithTimestamp]); + setMessages(prev => [...prev, session.addMessage(assistantMessage)]); } else { - // Fallback: non-streaming without tools const response = await modelClient.chat({ messages: session.getHistory(), system: systemPrompt, @@ -294,21 +396,30 @@ export function App({ })); const assistantMessage: Message = { role: 'assistant', content: response.content }; - const assistantWithTimestamp = session.addMessage(assistantMessage); - setMessages(prev => [...prev, assistantWithTimestamp]); + setMessages(prev => [...prev, session.addMessage(assistantMessage)]); } } catch (error) { - const errorMessage: Message = { - role: 'assistant', - content: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`, - }; - const errorWithTimestamp = session.addMessage(errorMessage); - setMessages(prev => [...prev, errorWithTimestamp]); + const msg = error instanceof Error ? error.message : 'Unknown error'; + setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: `Error: ${msg}` })]); } finally { setIsStreaming(false); 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 ( @@ -317,13 +428,27 @@ export function App({ scrollOffset={scrollOffset} streamingContent={isStreaming ? streamingContent : undefined} /> + + {confirmation ? ( + + + Confirmation required: {confirmation.tool}{' '} + {Object.keys(confirmation.args).length > 0 ? JSON.stringify(confirmation.args) : ''} + + Press y to approve, n to deny. + + ) : null} + + >; onExit?: () => void; } @@ -22,6 +26,10 @@ export async function startFullscreenTui(config: FullscreenTuiConfig): Promise { expect(mockRouter.setTier).toHaveBeenCalledWith('local'); expect(mockAgent.setModelTier).toHaveBeenCalledWith('local'); }); + + it('reuses configured provider credentials for /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; + } + } + }); }); diff --git a/src/frontends/tui/minimal.ts b/src/frontends/tui/minimal.ts index 4310d31..88afe03 100644 --- a/src/frontends/tui/minimal.ts +++ b/src/frontends/tui/minimal.ts @@ -9,9 +9,10 @@ import type { ModelConfig, ModelProvider } from '../../config/schema.js'; import { MODEL_PROVIDERS } from '../../config/schema.js'; import { OllamaClient, LlamaCppClient } from '../../models/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 { getColoredBanner } from './banner.js'; +import type { HookEngine } from '../../hooks/index.js'; export { parseCommand, type Command }; @@ -42,8 +43,10 @@ export interface MinimalTuiConfig { onFullscreen?: () => void; onTransfer?: (target: string) => void; localProviders?: Record; + modelProviderConfigs?: Partial>; currentLocalProvider?: string; pairingManager?: PairingManager; + hookEngine?: HookEngine; } export class MinimalTui { @@ -99,6 +102,10 @@ export class MinimalTui { async start(): Promise { this.running = true; + if (this.config.agent && this.config.modelRouter) { + this.config.agent.setModelTier(this.config.modelRouter.getTier()); + } + this.rl = readline.createInterface({ input: process.stdin, 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 process.stdin.on('keypress', () => { // Small delay to let readline update the line @@ -239,9 +266,22 @@ export class MinimalTui { } 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); - 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) { const message = error instanceof Error ? error.message : String(error); console.log(`${colors.gray}Failed to create client:${colors.reset} ${message}\n`); @@ -383,27 +423,49 @@ export class MinimalTui { private async handleLoginCommand(provider?: string): Promise { const target = provider ?? 'github'; - if (target !== 'github') { - console.log(`${colors.gray}Unknown login provider:${colors.reset} ${target}. Only 'github' is supported.\n`); + if (target === 'github') { + console.log(`${colors.gray}Starting GitHub OAuth device flow...${colors.reset}`); + + try { + await loginGitHub((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}GitHub authentication successful! Token stored.${colors.reset}\n`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.log(`${colors.gray}GitHub login failed:${colors.reset} ${message}\n`); + } + return; } - console.log(`${colors.gray}Starting GitHub OAuth device flow...${colors.reset}`); + if (target === 'openai') { + console.log(`${colors.gray}Starting OpenAI OAuth device flow...${colors.reset}`); - try { - await loginGitHub((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}`); - }); + 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}GitHub authentication successful! Token stored.${colors.reset}\n`); - } catch (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}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 { diff --git a/src/gateway/handlers/agent.ts b/src/gateway/handlers/agent.ts index d8e4316..bcf87c9 100644 --- a/src/gateway/handlers/agent.ts +++ b/src/gateway/handlers/agent.ts @@ -88,11 +88,19 @@ export function createAgentHandlers(deps: AgentHandlerDeps) { return lines.join('\n'); }, getModel: () => `Current model tier: ${agent.getModelTier()}`, - setModel: (tier) => { + setModel: (input) => { + const raw = input.trim(); + if (!raw) { + return 'Usage: /model '; + } + const [requestedTier, ...rest] = raw.split(/\s+/); const validTiers: ModelTier[] = ['fast', 'default', 'complex', 'local']; - const modelTier = tier as ModelTier; + const modelTier = requestedTier as 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 ).`; } agent.setModelTier(modelTier); if (sessionId && deps.sessionManager) { diff --git a/src/hooks/engine.test.ts b/src/hooks/engine.test.ts index c08453b..d11fdc5 100644 --- a/src/hooks/engine.test.ts +++ b/src/hooks/engine.test.ts @@ -72,4 +72,19 @@ describe('HookEngine', () => { expect(result.approved).toBe(false); 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' }, + })); + }); }); diff --git a/src/hooks/engine.ts b/src/hooks/engine.ts index 4a3ed3d..13b2501 100644 --- a/src/hooks/engine.ts +++ b/src/hooks/engine.ts @@ -1,16 +1,32 @@ import { randomUUID } from 'crypto'; import type { HookAction, HookResult, PendingConfirmation, HookConfig } from './types.js'; +export type InteractiveConfirmer = (pending: { + id: string; + tool: string; + args: Record; +}) => Promise; + export class HookEngine { private confirmPatterns: RegExp[]; private logPatterns: RegExp[]; private pendingConfirmations: Map = new Map(); + private interactiveConfirmer?: InteractiveConfirmer; constructor(config: HookConfig) { this.confirmPatterns = config.confirm.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 { const escaped = pattern .replace(/[.+^${}()|[\]\\]/g, '\\$&') @@ -31,6 +47,10 @@ export class HookEngine { async requestConfirmation(tool: string, args: Record): Promise { const id = randomUUID(); + if (this.interactiveConfirmer) { + return await this.interactiveConfirmer({ id, tool, args }); + } + return new Promise((resolve) => { const pending: PendingConfirmation = { id, diff --git a/src/models/local/llamacpp.test.ts b/src/models/local/llamacpp.test.ts index ad446ac..e3fcebc 100644 --- a/src/models/local/llamacpp.test.ts +++ b/src/models/local/llamacpp.test.ts @@ -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 () => { mockFetch.mockResolvedValue({ ok: true, diff --git a/src/models/local/llamacpp.ts b/src/models/local/llamacpp.ts index 7415590..063e241 100644 --- a/src/models/local/llamacpp.ts +++ b/src/models/local/llamacpp.ts @@ -48,6 +48,36 @@ interface LlamaCppStreamChunk { 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; + 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. */ interface LlamaCppChatMessage { role: 'system' | 'user' | 'assistant' | 'tool'; @@ -211,7 +241,7 @@ export class LlamaCppClient implements ModelClient { function: { name: t.name, description: t.description, - parameters: t.input_schema, + parameters: sanitizeToolParametersForLlamaCpp(t.name, t.input_schema), }, })); } @@ -292,7 +322,7 @@ export class LlamaCppClient implements ModelClient { function: { name: t.name, description: t.description, - parameters: t.input_schema, + parameters: sanitizeToolParametersForLlamaCpp(t.name, t.input_schema), }, })); } diff --git a/src/models/openai.oauth.test.ts b/src/models/openai.oauth.test.ts new file mode 100644 index 0000000..30d6235 --- /dev/null +++ b/src/models/openai.oauth.test.ts @@ -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 }); + }); +}); diff --git a/src/models/openai.test.ts b/src/models/openai.test.ts index 2a9c14f..a5ca8cd 100644 --- a/src/models/openai.test.ts +++ b/src/models/openai.test.ts @@ -1,23 +1,38 @@ import { describe, it, expect, vi } from 'vitest'; import { OpenAIClient } from './openai.js'; -// Shared mock function so we can override per-test -const mockCreate = vi.fn().mockResolvedValue({ - choices: [{ message: { content: 'Hello from GPT!' }, finish_reason: 'stop' }], - usage: { prompt_tokens: 10, completion_tokens: 5 }, -}); - -vi.mock('openai', () => ({ - default: vi.fn().mockImplementation(() => ({ +const { mockCreate, mockOpenAIConstructor } = vi.hoisted(() => { + const mockCreate = vi.fn().mockResolvedValue({ + choices: [{ message: { content: 'Hello from GPT!' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 10, completion_tokens: 5 }, + }); + const mockOpenAIConstructor = vi.fn().mockImplementation(() => ({ chat: { completions: { create: mockCreate, }, }, - })), + })); + return { mockCreate, mockOpenAIConstructor }; +}); + +vi.mock('openai', () => ({ + default: mockOpenAIConstructor, })); 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 () => { const client = new OpenAIClient({ apiKey: 'test-key', diff --git a/src/models/openai.ts b/src/models/openai.ts index 92db24b..a5b74c1 100644 --- a/src/models/openai.ts +++ b/src/models/openai.ts @@ -1,11 +1,16 @@ 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 { apiKey?: string; model: string; maxTokens?: number; 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 { - private client: OpenAI; + private client?: OpenAI; private model: string; private defaultMaxTokens: number; + private useOAuth: boolean; constructor(config: OpenAIClientConfig) { - this.client = new OpenAI({ - apiKey: config.apiKey, - baseURL: config.baseURL, - }); + 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({ + apiKey: config.apiKey, + baseURL: config.baseURL, + timeout: timeoutMs, + maxRetries: 0, + }); + } this.model = config.model; this.defaultMaxTokens = config.maxTokens ?? 4096; } + private async chatViaOAuthCodex(request: ChatRequest): Promise { + 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 => 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 = { + '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 { + if (this.useOAuth) { + return this.chatViaOAuthCodex(request); + } + + if (!this.client) { + throw new Error('OpenAI client not initialized'); + } + const messages: OpenAI.ChatCompletionMessageParam[] = []; if (request.system) { diff --git a/src/models/retry.test.ts b/src/models/retry.test.ts index c00a52c..85f5a79 100644 --- a/src/models/retry.test.ts +++ b/src/models/retry.test.ts @@ -4,10 +4,15 @@ import type { RetryConfig } from './retry.js'; describe('isRetryable', () => { 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); }); + 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', () => { const error = new Error('Invalid API key: authentication failed'); expect(isRetryable(error, DEFAULT_RETRY_CONFIG.nonRetryablePatterns)).toBe(false); @@ -75,8 +80,8 @@ describe('withRetry', () => { it('retries on transient failure then succeeds', async () => { const fn = vi.fn() - .mockRejectedValueOnce(new Error('timeout')) - .mockRejectedValueOnce(new Error('timeout')) + .mockRejectedValueOnce(new Error('temporary network issue')) + .mockRejectedValueOnce(new Error('temporary network issue')) .mockResolvedValueOnce('recovered'); const result = await withRetry(fn, fastConfig, 'test-op'); diff --git a/src/models/retry.ts b/src/models/retry.ts index 5fc2fbc..cb69d7d 100644 --- a/src/models/retry.ts +++ b/src/models/retry.ts @@ -26,6 +26,9 @@ export const DEFAULT_RETRY_CONFIG: RetryConfig = { 'context_length_exceeded', 'content_policy', 'does not support', + 'timeout', + 'timed out', + 'request aborted', ], }; diff --git a/src/models/router.test.ts b/src/models/router.test.ts index 7baad40..55f140d 100644 --- a/src/models/router.test.ts +++ b/src/models/router.test.ts @@ -438,4 +438,29 @@ describe('setClient and labels', () => { expect(newFastClient!.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); + }); }); diff --git a/src/models/router.ts b/src/models/router.ts index a6d0e2e..0724b4d 100644 --- a/src/models/router.ts +++ b/src/models/router.ts @@ -27,6 +27,7 @@ export class ModelRouter implements ModelClient { private localProviderName?: string; private retryConfig?: RetryConfig; private tierChangeListeners: Array<(tier: ModelTier) => void> = []; + private strictTiers: Set = new Set(); constructor(config: ModelRouterConfig) { this.clients = new Map(); @@ -97,6 +98,10 @@ export class ModelRouter implements ModelClient { logger.debug(`Primary model failed: ${errors[0].message}`); } + if (this.strictTiers.has(useTier)) { + throw errors[0]; + } + // Try tier-specific fallbacks first const tierFallbackList = this.tierFallbacks.get(useTier) ?? []; for (let i = 0; i < tierFallbackList.length; i++) { @@ -150,6 +155,11 @@ export class ModelRouter implements ModelClient { 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 const tierFallbackList = this.tierFallbacks.get(useTier) ?? []; for (let i = 0; i < tierFallbackList.length; i++) { @@ -216,6 +226,18 @@ export class ModelRouter implements ModelClient { 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 { return this.labels.get(tier) ?? 'unknown'; } diff --git a/src/tools/builtin/gmail.test.ts b/src/tools/builtin/gmail.test.ts index a5f3472..8cf5718 100644 --- a/src/tools/builtin/gmail.test.ts +++ b/src/tools/builtin/gmail.test.ts @@ -44,6 +44,9 @@ const testConfig: NonNullable = { enabled: true, credentials_file: '/tmp/test-creds.json', token_file: '/tmp/test-token.json', + disable_push: false, + pubsub_pull_interval: '60s', + pubsub_max_messages: 10, watch_labels: ['INBOX'], poll_interval: '300s', output: { channel: 'discord', peer: '123' },