@link-assistant/agent 0.0.9 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/EXAMPLES.md +36 -0
  2. package/MODELS.md +72 -24
  3. package/README.md +59 -2
  4. package/TOOLS.md +20 -0
  5. package/package.json +35 -2
  6. package/src/agent/agent.ts +68 -54
  7. package/src/auth/claude-oauth.ts +426 -0
  8. package/src/auth/index.ts +28 -26
  9. package/src/auth/plugins.ts +876 -0
  10. package/src/bun/index.ts +53 -43
  11. package/src/bus/global.ts +5 -5
  12. package/src/bus/index.ts +59 -53
  13. package/src/cli/bootstrap.js +12 -12
  14. package/src/cli/bootstrap.ts +6 -6
  15. package/src/cli/cmd/agent.ts +97 -92
  16. package/src/cli/cmd/auth.ts +469 -0
  17. package/src/cli/cmd/cmd.ts +2 -2
  18. package/src/cli/cmd/export.ts +41 -41
  19. package/src/cli/cmd/mcp.ts +144 -119
  20. package/src/cli/cmd/models.ts +30 -29
  21. package/src/cli/cmd/run.ts +269 -213
  22. package/src/cli/cmd/stats.ts +185 -146
  23. package/src/cli/error.ts +17 -13
  24. package/src/cli/ui.ts +39 -24
  25. package/src/command/index.ts +26 -26
  26. package/src/config/config.ts +528 -288
  27. package/src/config/markdown.ts +15 -15
  28. package/src/file/ripgrep.ts +201 -169
  29. package/src/file/time.ts +21 -18
  30. package/src/file/watcher.ts +51 -42
  31. package/src/file.ts +1 -1
  32. package/src/flag/flag.ts +26 -11
  33. package/src/format/formatter.ts +206 -162
  34. package/src/format/index.ts +61 -61
  35. package/src/global/index.ts +21 -21
  36. package/src/id/id.ts +47 -33
  37. package/src/index.js +346 -199
  38. package/src/json-standard/index.ts +67 -51
  39. package/src/mcp/index.ts +135 -128
  40. package/src/patch/index.ts +336 -267
  41. package/src/project/bootstrap.ts +15 -15
  42. package/src/project/instance.ts +43 -36
  43. package/src/project/project.ts +47 -47
  44. package/src/project/state.ts +37 -33
  45. package/src/provider/models-macro.ts +5 -5
  46. package/src/provider/models.ts +32 -32
  47. package/src/provider/opencode.js +19 -19
  48. package/src/provider/provider.ts +518 -277
  49. package/src/provider/transform.ts +143 -102
  50. package/src/server/project.ts +21 -21
  51. package/src/server/server.ts +111 -105
  52. package/src/session/agent.js +66 -60
  53. package/src/session/compaction.ts +136 -111
  54. package/src/session/index.ts +189 -156
  55. package/src/session/message-v2.ts +312 -268
  56. package/src/session/message.ts +73 -57
  57. package/src/session/processor.ts +180 -166
  58. package/src/session/prompt.ts +678 -533
  59. package/src/session/retry.ts +26 -23
  60. package/src/session/revert.ts +76 -62
  61. package/src/session/status.ts +26 -26
  62. package/src/session/summary.ts +97 -76
  63. package/src/session/system.ts +77 -63
  64. package/src/session/todo.ts +22 -16
  65. package/src/snapshot/index.ts +92 -76
  66. package/src/storage/storage.ts +157 -120
  67. package/src/tool/bash.ts +116 -106
  68. package/src/tool/batch.ts +73 -59
  69. package/src/tool/codesearch.ts +60 -53
  70. package/src/tool/edit.ts +319 -263
  71. package/src/tool/glob.ts +32 -28
  72. package/src/tool/grep.ts +72 -53
  73. package/src/tool/invalid.ts +7 -7
  74. package/src/tool/ls.ts +77 -64
  75. package/src/tool/multiedit.ts +30 -21
  76. package/src/tool/patch.ts +121 -94
  77. package/src/tool/read.ts +140 -122
  78. package/src/tool/registry.ts +38 -38
  79. package/src/tool/task.ts +93 -60
  80. package/src/tool/todo.ts +16 -16
  81. package/src/tool/tool.ts +45 -36
  82. package/src/tool/webfetch.ts +97 -74
  83. package/src/tool/websearch.ts +78 -64
  84. package/src/tool/write.ts +21 -15
  85. package/src/util/binary.ts +27 -19
  86. package/src/util/context.ts +8 -8
  87. package/src/util/defer.ts +7 -5
  88. package/src/util/error.ts +24 -19
  89. package/src/util/eventloop.ts +16 -10
  90. package/src/util/filesystem.ts +37 -33
  91. package/src/util/fn.ts +11 -8
  92. package/src/util/iife.ts +1 -1
  93. package/src/util/keybind.ts +44 -44
  94. package/src/util/lazy.ts +7 -7
  95. package/src/util/locale.ts +20 -16
  96. package/src/util/lock.ts +43 -38
  97. package/src/util/log.ts +95 -85
  98. package/src/util/queue.ts +8 -8
  99. package/src/util/rpc.ts +35 -23
  100. package/src/util/scrap.ts +4 -4
  101. package/src/util/signal.ts +5 -5
  102. package/src/util/timeout.ts +6 -6
  103. package/src/util/token.ts +2 -2
  104. package/src/util/wildcard.ts +38 -27
@@ -0,0 +1,876 @@
1
+ import crypto from 'crypto';
2
+ import { Auth } from './index';
3
+ import { Log } from '../util/log';
4
+
5
+ /**
6
+ * Auth Plugins Module
7
+ *
8
+ * Provides OAuth and API authentication methods for various providers.
9
+ * Based on OpenCode's plugin system (opencode-anthropic-auth, opencode-copilot-auth).
10
+ */
11
+
12
+ const log = Log.create({ service: 'auth-plugins' });
13
+
14
+ /**
15
+ * OAuth callback result types
16
+ */
17
+ export type AuthResult =
18
+ | { type: 'failed' }
19
+ | {
20
+ type: 'success';
21
+ provider?: string;
22
+ refresh: string;
23
+ access: string;
24
+ expires: number;
25
+ enterpriseUrl?: string;
26
+ }
27
+ | { type: 'success'; provider?: string; key: string };
28
+
29
+ /**
30
+ * Auth method prompt configuration
31
+ */
32
+ export interface AuthPrompt {
33
+ type: 'text' | 'select';
34
+ key: string;
35
+ message: string;
36
+ placeholder?: string;
37
+ options?: Array<{ label: string; value: string; hint?: string }>;
38
+ condition?: (inputs: Record<string, string>) => boolean;
39
+ validate?: (value: string) => string | undefined;
40
+ }
41
+
42
+ /**
43
+ * OAuth authorization result
44
+ */
45
+ export interface AuthorizeResult {
46
+ url?: string;
47
+ instructions?: string;
48
+ method: 'code' | 'auto';
49
+ callback: (code?: string) => Promise<AuthResult>;
50
+ }
51
+
52
+ /**
53
+ * Auth method definition
54
+ */
55
+ export interface AuthMethod {
56
+ label: string;
57
+ type: 'oauth' | 'api';
58
+ prompts?: AuthPrompt[];
59
+ authorize?: (
60
+ inputs: Record<string, string>
61
+ ) => Promise<AuthorizeResult | AuthResult>;
62
+ }
63
+
64
+ /**
65
+ * Auth plugin definition
66
+ */
67
+ export interface AuthPlugin {
68
+ provider: string;
69
+ methods: AuthMethod[];
70
+ loader?: (
71
+ getAuth: () => Promise<Auth.Info | undefined>,
72
+ provider: any
73
+ ) => Promise<{
74
+ apiKey?: string;
75
+ baseURL?: string;
76
+ fetch?: typeof fetch;
77
+ }>;
78
+ }
79
+
80
+ /**
81
+ * PKCE utilities
82
+ */
83
+ function generateRandomString(length: number): string {
84
+ return crypto.randomBytes(length).toString('base64url');
85
+ }
86
+
87
+ function generateCodeChallenge(verifier: string): string {
88
+ return crypto.createHash('sha256').update(verifier).digest('base64url');
89
+ }
90
+
91
+ async function generatePKCE() {
92
+ const verifier = generateRandomString(32);
93
+ const challenge = generateCodeChallenge(verifier);
94
+ return { verifier, challenge };
95
+ }
96
+
97
+ /**
98
+ * Anthropic OAuth Configuration
99
+ * Used for Claude Pro/Max subscription authentication
100
+ */
101
+ const ANTHROPIC_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
102
+
103
+ /**
104
+ * Anthropic OAuth Plugin
105
+ * Supports:
106
+ * - Claude Pro/Max OAuth login
107
+ * - API key creation via OAuth
108
+ * - Manual API key entry
109
+ */
110
+ const AnthropicPlugin: AuthPlugin = {
111
+ provider: 'anthropic',
112
+ methods: [
113
+ {
114
+ label: 'Claude Pro/Max',
115
+ type: 'oauth',
116
+ async authorize() {
117
+ const pkce = await generatePKCE();
118
+
119
+ const url = new URL('https://claude.ai/oauth/authorize');
120
+ url.searchParams.set('code', 'true');
121
+ url.searchParams.set('client_id', ANTHROPIC_CLIENT_ID);
122
+ url.searchParams.set('response_type', 'code');
123
+ url.searchParams.set(
124
+ 'redirect_uri',
125
+ 'https://console.anthropic.com/oauth/code/callback'
126
+ );
127
+ url.searchParams.set(
128
+ 'scope',
129
+ 'org:create_api_key user:profile user:inference'
130
+ );
131
+ url.searchParams.set('code_challenge', pkce.challenge);
132
+ url.searchParams.set('code_challenge_method', 'S256');
133
+ url.searchParams.set('state', pkce.verifier);
134
+
135
+ return {
136
+ url: url.toString(),
137
+ instructions: 'Paste the authorization code here: ',
138
+ method: 'code' as const,
139
+ async callback(code?: string): Promise<AuthResult> {
140
+ if (!code) return { type: 'failed' };
141
+
142
+ const splits = code.split('#');
143
+ const result = await fetch(
144
+ 'https://console.anthropic.com/v1/oauth/token',
145
+ {
146
+ method: 'POST',
147
+ headers: {
148
+ 'Content-Type': 'application/json',
149
+ },
150
+ body: JSON.stringify({
151
+ code: splits[0],
152
+ state: splits[1],
153
+ grant_type: 'authorization_code',
154
+ client_id: ANTHROPIC_CLIENT_ID,
155
+ redirect_uri:
156
+ 'https://console.anthropic.com/oauth/code/callback',
157
+ code_verifier: pkce.verifier,
158
+ }),
159
+ }
160
+ );
161
+
162
+ if (!result.ok) {
163
+ log.error('anthropic oauth token exchange failed', {
164
+ status: result.status,
165
+ });
166
+ return { type: 'failed' };
167
+ }
168
+
169
+ const json = await result.json();
170
+ return {
171
+ type: 'success',
172
+ refresh: json.refresh_token,
173
+ access: json.access_token,
174
+ expires: Date.now() + json.expires_in * 1000,
175
+ };
176
+ },
177
+ };
178
+ },
179
+ },
180
+ {
181
+ label: 'Create an API Key',
182
+ type: 'oauth',
183
+ async authorize() {
184
+ const pkce = await generatePKCE();
185
+
186
+ const url = new URL('https://console.anthropic.com/oauth/authorize');
187
+ url.searchParams.set('code', 'true');
188
+ url.searchParams.set('client_id', ANTHROPIC_CLIENT_ID);
189
+ url.searchParams.set('response_type', 'code');
190
+ url.searchParams.set(
191
+ 'redirect_uri',
192
+ 'https://console.anthropic.com/oauth/code/callback'
193
+ );
194
+ url.searchParams.set(
195
+ 'scope',
196
+ 'org:create_api_key user:profile user:inference'
197
+ );
198
+ url.searchParams.set('code_challenge', pkce.challenge);
199
+ url.searchParams.set('code_challenge_method', 'S256');
200
+ url.searchParams.set('state', pkce.verifier);
201
+
202
+ return {
203
+ url: url.toString(),
204
+ instructions: 'Paste the authorization code here: ',
205
+ method: 'code' as const,
206
+ async callback(code?: string): Promise<AuthResult> {
207
+ if (!code) return { type: 'failed' };
208
+
209
+ const splits = code.split('#');
210
+ const tokenResult = await fetch(
211
+ 'https://console.anthropic.com/v1/oauth/token',
212
+ {
213
+ method: 'POST',
214
+ headers: {
215
+ 'Content-Type': 'application/json',
216
+ },
217
+ body: JSON.stringify({
218
+ code: splits[0],
219
+ state: splits[1],
220
+ grant_type: 'authorization_code',
221
+ client_id: ANTHROPIC_CLIENT_ID,
222
+ redirect_uri:
223
+ 'https://console.anthropic.com/oauth/code/callback',
224
+ code_verifier: pkce.verifier,
225
+ }),
226
+ }
227
+ );
228
+
229
+ if (!tokenResult.ok) {
230
+ log.error('anthropic oauth token exchange failed', {
231
+ status: tokenResult.status,
232
+ });
233
+ return { type: 'failed' };
234
+ }
235
+
236
+ const credentials = await tokenResult.json();
237
+
238
+ // Create API key using the access token
239
+ const apiKeyResult = await fetch(
240
+ 'https://api.anthropic.com/api/oauth/claude_cli/create_api_key',
241
+ {
242
+ method: 'POST',
243
+ headers: {
244
+ 'Content-Type': 'application/json',
245
+ Authorization: `Bearer ${credentials.access_token}`,
246
+ },
247
+ }
248
+ ).then((r) => r.json());
249
+
250
+ return { type: 'success', key: apiKeyResult.raw_key };
251
+ },
252
+ };
253
+ },
254
+ },
255
+ {
256
+ label: 'Manually enter API Key',
257
+ type: 'api',
258
+ },
259
+ ],
260
+ async loader(getAuth, provider) {
261
+ const auth = await getAuth();
262
+ if (!auth || auth.type !== 'oauth') return {};
263
+
264
+ // Zero out cost for max plan users
265
+ if (provider?.models) {
266
+ for (const model of Object.values(provider.models)) {
267
+ (model as any).cost = {
268
+ input: 0,
269
+ output: 0,
270
+ cache: {
271
+ read: 0,
272
+ write: 0,
273
+ },
274
+ };
275
+ }
276
+ }
277
+
278
+ return {
279
+ apiKey: '',
280
+ async fetch(input: RequestInfo | URL, init?: RequestInit) {
281
+ let currentAuth = await getAuth();
282
+ if (!currentAuth || currentAuth.type !== 'oauth')
283
+ return fetch(input, init);
284
+
285
+ // Refresh token if expired
286
+ if (!currentAuth.access || currentAuth.expires < Date.now()) {
287
+ log.info('refreshing anthropic oauth token');
288
+ const response = await fetch(
289
+ 'https://console.anthropic.com/v1/oauth/token',
290
+ {
291
+ method: 'POST',
292
+ headers: {
293
+ 'Content-Type': 'application/json',
294
+ },
295
+ body: JSON.stringify({
296
+ grant_type: 'refresh_token',
297
+ refresh_token: currentAuth.refresh,
298
+ client_id: ANTHROPIC_CLIENT_ID,
299
+ }),
300
+ }
301
+ );
302
+
303
+ if (!response.ok) {
304
+ throw new Error(`Token refresh failed: ${response.status}`);
305
+ }
306
+
307
+ const json = await response.json();
308
+ await Auth.set('anthropic', {
309
+ type: 'oauth',
310
+ refresh: json.refresh_token,
311
+ access: json.access_token,
312
+ expires: Date.now() + json.expires_in * 1000,
313
+ });
314
+ currentAuth = {
315
+ type: 'oauth',
316
+ refresh: json.refresh_token,
317
+ access: json.access_token,
318
+ expires: Date.now() + json.expires_in * 1000,
319
+ };
320
+ }
321
+
322
+ // Add oauth beta and other required betas
323
+ const incomingBeta =
324
+ (init?.headers as Record<string, string>)?.['anthropic-beta'] || '';
325
+ const incomingBetasList = incomingBeta
326
+ .split(',')
327
+ .map((b) => b.trim())
328
+ .filter(Boolean);
329
+
330
+ const mergedBetas = [
331
+ ...new Set([
332
+ 'oauth-2025-04-20',
333
+ 'claude-code-20250219',
334
+ 'interleaved-thinking-2025-05-14',
335
+ 'fine-grained-tool-streaming-2025-05-14',
336
+ ...incomingBetasList,
337
+ ]),
338
+ ].join(',');
339
+
340
+ const headers: Record<string, string> = {
341
+ ...(init?.headers as Record<string, string>),
342
+ authorization: `Bearer ${currentAuth.access}`,
343
+ 'anthropic-beta': mergedBetas,
344
+ };
345
+ delete headers['x-api-key'];
346
+
347
+ return fetch(input, {
348
+ ...init,
349
+ headers,
350
+ });
351
+ },
352
+ };
353
+ },
354
+ };
355
+
356
+ /**
357
+ * GitHub Copilot OAuth Configuration
358
+ */
359
+ const COPILOT_CLIENT_ID = 'Iv1.b507a08c87ecfe98';
360
+ const COPILOT_HEADERS = {
361
+ 'User-Agent': 'GitHubCopilotChat/0.32.4',
362
+ 'Editor-Version': 'vscode/1.105.1',
363
+ 'Editor-Plugin-Version': 'copilot-chat/0.32.4',
364
+ 'Copilot-Integration-Id': 'vscode-chat',
365
+ };
366
+
367
+ function normalizeDomain(url: string): string {
368
+ return url.replace(/^https?:\/\//, '').replace(/\/$/, '');
369
+ }
370
+
371
+ function getCopilotUrls(domain: string) {
372
+ return {
373
+ DEVICE_CODE_URL: `https://${domain}/login/device/code`,
374
+ ACCESS_TOKEN_URL: `https://${domain}/login/oauth/access_token`,
375
+ COPILOT_API_KEY_URL: `https://api.${domain}/copilot_internal/v2/token`,
376
+ };
377
+ }
378
+
379
+ /**
380
+ * GitHub Copilot OAuth Plugin
381
+ * Supports:
382
+ * - GitHub.com Copilot
383
+ * - GitHub Enterprise Copilot
384
+ */
385
+ const GitHubCopilotPlugin: AuthPlugin = {
386
+ provider: 'github-copilot',
387
+ methods: [
388
+ {
389
+ type: 'oauth',
390
+ label: 'Login with GitHub Copilot',
391
+ prompts: [
392
+ {
393
+ type: 'select',
394
+ key: 'deploymentType',
395
+ message: 'Select GitHub deployment type',
396
+ options: [
397
+ {
398
+ label: 'GitHub.com',
399
+ value: 'github.com',
400
+ hint: 'Public',
401
+ },
402
+ {
403
+ label: 'GitHub Enterprise',
404
+ value: 'enterprise',
405
+ hint: 'Data residency or self-hosted',
406
+ },
407
+ ],
408
+ },
409
+ {
410
+ type: 'text',
411
+ key: 'enterpriseUrl',
412
+ message: 'Enter your GitHub Enterprise URL or domain',
413
+ placeholder: 'company.ghe.com or https://company.ghe.com',
414
+ condition: (inputs) => inputs.deploymentType === 'enterprise',
415
+ validate: (value) => {
416
+ if (!value) return 'URL or domain is required';
417
+ try {
418
+ const url = value.includes('://')
419
+ ? new URL(value)
420
+ : new URL(`https://${value}`);
421
+ if (!url.hostname) return 'Please enter a valid URL or domain';
422
+ return undefined;
423
+ } catch {
424
+ return 'Please enter a valid URL (e.g., company.ghe.com or https://company.ghe.com)';
425
+ }
426
+ },
427
+ },
428
+ ],
429
+ async authorize(inputs = {}): Promise<AuthorizeResult> {
430
+ const deploymentType = inputs.deploymentType || 'github.com';
431
+
432
+ let domain = 'github.com';
433
+ let actualProvider = 'github-copilot';
434
+
435
+ if (deploymentType === 'enterprise') {
436
+ const enterpriseUrl = inputs.enterpriseUrl;
437
+ domain = normalizeDomain(enterpriseUrl);
438
+ actualProvider = 'github-copilot-enterprise';
439
+ }
440
+
441
+ const urls = getCopilotUrls(domain);
442
+
443
+ const deviceResponse = await fetch(urls.DEVICE_CODE_URL, {
444
+ method: 'POST',
445
+ headers: {
446
+ Accept: 'application/json',
447
+ 'Content-Type': 'application/json',
448
+ 'User-Agent': 'GitHubCopilotChat/0.35.0',
449
+ },
450
+ body: JSON.stringify({
451
+ client_id: COPILOT_CLIENT_ID,
452
+ scope: 'read:user',
453
+ }),
454
+ });
455
+
456
+ if (!deviceResponse.ok) {
457
+ throw new Error('Failed to initiate device authorization');
458
+ }
459
+
460
+ const deviceData = (await deviceResponse.json()) as {
461
+ verification_uri: string;
462
+ user_code: string;
463
+ device_code: string;
464
+ interval: number;
465
+ };
466
+
467
+ return {
468
+ url: deviceData.verification_uri,
469
+ instructions: `Enter code: ${deviceData.user_code}`,
470
+ method: 'auto',
471
+ async callback(): Promise<AuthResult> {
472
+ while (true) {
473
+ const response = await fetch(urls.ACCESS_TOKEN_URL, {
474
+ method: 'POST',
475
+ headers: {
476
+ Accept: 'application/json',
477
+ 'Content-Type': 'application/json',
478
+ 'User-Agent': 'GitHubCopilotChat/0.35.0',
479
+ },
480
+ body: JSON.stringify({
481
+ client_id: COPILOT_CLIENT_ID,
482
+ device_code: deviceData.device_code,
483
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
484
+ }),
485
+ });
486
+
487
+ if (!response.ok) return { type: 'failed' };
488
+
489
+ const data = (await response.json()) as {
490
+ access_token?: string;
491
+ error?: string;
492
+ };
493
+
494
+ if (data.access_token) {
495
+ const result: AuthResult = {
496
+ type: 'success',
497
+ refresh: data.access_token,
498
+ access: '',
499
+ expires: 0,
500
+ };
501
+
502
+ if (actualProvider === 'github-copilot-enterprise') {
503
+ (result as any).provider = 'github-copilot-enterprise';
504
+ (result as any).enterpriseUrl = domain;
505
+ }
506
+
507
+ return result;
508
+ }
509
+
510
+ if (data.error === 'authorization_pending') {
511
+ await new Promise((resolve) =>
512
+ setTimeout(resolve, deviceData.interval * 1000)
513
+ );
514
+ continue;
515
+ }
516
+
517
+ if (data.error) return { type: 'failed' };
518
+
519
+ await new Promise((resolve) =>
520
+ setTimeout(resolve, deviceData.interval * 1000)
521
+ );
522
+ }
523
+ },
524
+ };
525
+ },
526
+ },
527
+ ],
528
+ async loader(getAuth, provider) {
529
+ const info = await getAuth();
530
+ if (!info || info.type !== 'oauth') return {};
531
+
532
+ // Zero out cost for copilot users
533
+ if (provider?.models) {
534
+ for (const model of Object.values(provider.models)) {
535
+ (model as any).cost = {
536
+ input: 0,
537
+ output: 0,
538
+ cache: {
539
+ read: 0,
540
+ write: 0,
541
+ },
542
+ };
543
+ }
544
+ }
545
+
546
+ // Set baseURL based on deployment type
547
+ const enterpriseUrl = (info as any).enterpriseUrl;
548
+ const baseURL = enterpriseUrl
549
+ ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}`
550
+ : 'https://api.githubcopilot.com';
551
+
552
+ return {
553
+ baseURL,
554
+ apiKey: '',
555
+ async fetch(input: RequestInfo | URL, init?: RequestInit) {
556
+ let currentInfo = await getAuth();
557
+ if (!currentInfo || currentInfo.type !== 'oauth')
558
+ return fetch(input, init);
559
+
560
+ // Refresh token if expired
561
+ if (!currentInfo.access || currentInfo.expires < Date.now()) {
562
+ const domain = (currentInfo as any).enterpriseUrl
563
+ ? normalizeDomain((currentInfo as any).enterpriseUrl)
564
+ : 'github.com';
565
+ const urls = getCopilotUrls(domain);
566
+
567
+ log.info('refreshing github copilot token');
568
+ const response = await fetch(urls.COPILOT_API_KEY_URL, {
569
+ headers: {
570
+ Accept: 'application/json',
571
+ Authorization: `Bearer ${currentInfo.refresh}`,
572
+ ...COPILOT_HEADERS,
573
+ },
574
+ });
575
+
576
+ if (!response.ok) {
577
+ throw new Error(`Token refresh failed: ${response.status}`);
578
+ }
579
+
580
+ const tokenData = (await response.json()) as {
581
+ token: string;
582
+ expires_at: number;
583
+ };
584
+
585
+ const saveProviderID = (currentInfo as any).enterpriseUrl
586
+ ? 'github-copilot-enterprise'
587
+ : 'github-copilot';
588
+ await Auth.set(saveProviderID, {
589
+ type: 'oauth',
590
+ refresh: currentInfo.refresh,
591
+ access: tokenData.token,
592
+ expires: tokenData.expires_at * 1000,
593
+ ...((currentInfo as any).enterpriseUrl && {
594
+ enterpriseUrl: (currentInfo as any).enterpriseUrl,
595
+ }),
596
+ } as Auth.Info);
597
+
598
+ currentInfo = {
599
+ type: 'oauth',
600
+ refresh: currentInfo.refresh,
601
+ access: tokenData.token,
602
+ expires: tokenData.expires_at * 1000,
603
+ };
604
+ }
605
+
606
+ // Detect agent calls and vision requests
607
+ let isAgentCall = false;
608
+ let isVisionRequest = false;
609
+ try {
610
+ const body =
611
+ typeof init?.body === 'string' ? JSON.parse(init.body) : init?.body;
612
+ if (body?.messages) {
613
+ isAgentCall = body.messages.some(
614
+ (msg: any) => msg.role && ['tool', 'assistant'].includes(msg.role)
615
+ );
616
+ isVisionRequest = body.messages.some(
617
+ (msg: any) =>
618
+ Array.isArray(msg.content) &&
619
+ msg.content.some((part: any) => part.type === 'image_url')
620
+ );
621
+ }
622
+ } catch {}
623
+
624
+ const headers: Record<string, string> = {
625
+ ...(init?.headers as Record<string, string>),
626
+ ...COPILOT_HEADERS,
627
+ Authorization: `Bearer ${currentInfo.access}`,
628
+ 'Openai-Intent': 'conversation-edits',
629
+ 'X-Initiator': isAgentCall ? 'agent' : 'user',
630
+ };
631
+
632
+ if (isVisionRequest) {
633
+ headers['Copilot-Vision-Request'] = 'true';
634
+ }
635
+
636
+ delete headers['x-api-key'];
637
+ delete headers['authorization'];
638
+
639
+ return fetch(input, {
640
+ ...init,
641
+ headers,
642
+ });
643
+ },
644
+ };
645
+ },
646
+ };
647
+
648
+ /**
649
+ * OpenAI ChatGPT OAuth Configuration
650
+ * Used for ChatGPT Plus/Pro subscription authentication via Codex backend
651
+ */
652
+ const OPENAI_CLIENT_ID = 'app_EMoamEEEZ73f0CkXaXp7hrann';
653
+ const OPENAI_AUTHORIZE_URL = 'https://auth.openai.com/oauth/authorize';
654
+ const OPENAI_TOKEN_URL = 'https://auth.openai.com/oauth/token';
655
+ const OPENAI_REDIRECT_URI = 'http://localhost:1455/auth/callback';
656
+ const OPENAI_SCOPE = 'openid profile email offline_access';
657
+
658
+ /**
659
+ * OpenAI ChatGPT OAuth Plugin
660
+ * Supports:
661
+ * - ChatGPT Plus/Pro OAuth login
662
+ * - Manual API key entry
663
+ *
664
+ * Note: This is a simplified implementation that uses manual code entry.
665
+ * The full opencode-openai-codex-auth plugin uses a local server on port 1455.
666
+ */
667
+ const OpenAIPlugin: AuthPlugin = {
668
+ provider: 'openai',
669
+ methods: [
670
+ {
671
+ label: 'ChatGPT Plus/Pro (OAuth)',
672
+ type: 'oauth',
673
+ async authorize() {
674
+ const pkce = await generatePKCE();
675
+ const state = generateRandomString(16);
676
+
677
+ const url = new URL(OPENAI_AUTHORIZE_URL);
678
+ url.searchParams.set('response_type', 'code');
679
+ url.searchParams.set('client_id', OPENAI_CLIENT_ID);
680
+ url.searchParams.set('redirect_uri', OPENAI_REDIRECT_URI);
681
+ url.searchParams.set('scope', OPENAI_SCOPE);
682
+ url.searchParams.set('code_challenge', pkce.challenge);
683
+ url.searchParams.set('code_challenge_method', 'S256');
684
+ url.searchParams.set('state', state);
685
+ url.searchParams.set('id_token_add_organizations', 'true');
686
+ url.searchParams.set('codex_cli_simplified_flow', 'true');
687
+ url.searchParams.set('originator', 'codex_cli_rs');
688
+
689
+ return {
690
+ url: url.toString(),
691
+ instructions:
692
+ 'After authorizing, copy the URL from your browser address bar and paste it here (or just the code parameter): ',
693
+ method: 'code' as const,
694
+ async callback(input?: string): Promise<AuthResult> {
695
+ if (!input) return { type: 'failed' };
696
+
697
+ // Parse authorization input - can be full URL, code#state, or just code
698
+ let code: string | undefined;
699
+ let receivedState: string | undefined;
700
+
701
+ try {
702
+ const inputUrl = new URL(input.trim());
703
+ code = inputUrl.searchParams.get('code') ?? undefined;
704
+ receivedState = inputUrl.searchParams.get('state') ?? undefined;
705
+ } catch {
706
+ // Not a URL, try other formats
707
+ if (input.includes('#')) {
708
+ const [c, s] = input.split('#', 2);
709
+ code = c;
710
+ receivedState = s;
711
+ } else if (input.includes('code=')) {
712
+ const params = new URLSearchParams(input);
713
+ code = params.get('code') ?? undefined;
714
+ receivedState = params.get('state') ?? undefined;
715
+ } else {
716
+ code = input.trim();
717
+ }
718
+ }
719
+
720
+ if (!code) {
721
+ log.error('openai oauth no code provided');
722
+ return { type: 'failed' };
723
+ }
724
+
725
+ // Exchange authorization code for tokens
726
+ const tokenResult = await fetch(OPENAI_TOKEN_URL, {
727
+ method: 'POST',
728
+ headers: {
729
+ 'Content-Type': 'application/x-www-form-urlencoded',
730
+ },
731
+ body: new URLSearchParams({
732
+ grant_type: 'authorization_code',
733
+ client_id: OPENAI_CLIENT_ID,
734
+ code,
735
+ code_verifier: pkce.verifier,
736
+ redirect_uri: OPENAI_REDIRECT_URI,
737
+ }),
738
+ });
739
+
740
+ if (!tokenResult.ok) {
741
+ log.error('openai oauth token exchange failed', {
742
+ status: tokenResult.status,
743
+ });
744
+ return { type: 'failed' };
745
+ }
746
+
747
+ const json = await tokenResult.json();
748
+ if (
749
+ !json.access_token ||
750
+ !json.refresh_token ||
751
+ typeof json.expires_in !== 'number'
752
+ ) {
753
+ log.error('openai oauth token response missing fields');
754
+ return { type: 'failed' };
755
+ }
756
+
757
+ return {
758
+ type: 'success',
759
+ refresh: json.refresh_token,
760
+ access: json.access_token,
761
+ expires: Date.now() + json.expires_in * 1000,
762
+ };
763
+ },
764
+ };
765
+ },
766
+ },
767
+ {
768
+ label: 'Manually enter API Key',
769
+ type: 'api',
770
+ },
771
+ ],
772
+ async loader(getAuth, provider) {
773
+ const auth = await getAuth();
774
+ if (!auth || auth.type !== 'oauth') return {};
775
+
776
+ // Note: Full OpenAI Codex support would require additional request transformations
777
+ // For now, this provides basic OAuth token management
778
+ return {
779
+ apiKey: '',
780
+ baseURL: 'https://chatgpt.com/backend-api',
781
+ async fetch(input: RequestInfo | URL, init?: RequestInit) {
782
+ let currentAuth = await getAuth();
783
+ if (!currentAuth || currentAuth.type !== 'oauth')
784
+ return fetch(input, init);
785
+
786
+ // Refresh token if expired
787
+ if (!currentAuth.access || currentAuth.expires < Date.now()) {
788
+ log.info('refreshing openai oauth token');
789
+ const response = await fetch(OPENAI_TOKEN_URL, {
790
+ method: 'POST',
791
+ headers: {
792
+ 'Content-Type': 'application/x-www-form-urlencoded',
793
+ },
794
+ body: new URLSearchParams({
795
+ grant_type: 'refresh_token',
796
+ refresh_token: currentAuth.refresh,
797
+ client_id: OPENAI_CLIENT_ID,
798
+ }),
799
+ });
800
+
801
+ if (!response.ok) {
802
+ throw new Error(`Token refresh failed: ${response.status}`);
803
+ }
804
+
805
+ const json = await response.json();
806
+ await Auth.set('openai', {
807
+ type: 'oauth',
808
+ refresh: json.refresh_token,
809
+ access: json.access_token,
810
+ expires: Date.now() + json.expires_in * 1000,
811
+ });
812
+ currentAuth = {
813
+ type: 'oauth',
814
+ refresh: json.refresh_token,
815
+ access: json.access_token,
816
+ expires: Date.now() + json.expires_in * 1000,
817
+ };
818
+ }
819
+
820
+ const headers: Record<string, string> = {
821
+ ...(init?.headers as Record<string, string>),
822
+ authorization: `Bearer ${currentAuth.access}`,
823
+ };
824
+ delete headers['x-api-key'];
825
+
826
+ return fetch(input, {
827
+ ...init,
828
+ headers,
829
+ });
830
+ },
831
+ };
832
+ },
833
+ };
834
+
835
+ /**
836
+ * Registry of all auth plugins
837
+ */
838
+ const plugins: Record<string, AuthPlugin> = {
839
+ anthropic: AnthropicPlugin,
840
+ 'github-copilot': GitHubCopilotPlugin,
841
+ openai: OpenAIPlugin,
842
+ };
843
+
844
+ /**
845
+ * Auth Plugins namespace
846
+ */
847
+ export namespace AuthPlugins {
848
+ /**
849
+ * Get a plugin by provider ID
850
+ */
851
+ export function getPlugin(providerId: string): AuthPlugin | undefined {
852
+ return plugins[providerId];
853
+ }
854
+
855
+ /**
856
+ * Get all plugins
857
+ */
858
+ export function getAllPlugins(): AuthPlugin[] {
859
+ return Object.values(plugins);
860
+ }
861
+
862
+ /**
863
+ * Get the loader for a provider
864
+ */
865
+ export async function getLoader(providerId: string) {
866
+ const plugin = plugins[providerId];
867
+ if (!plugin?.loader) return undefined;
868
+
869
+ return async (
870
+ getAuth: () => Promise<Auth.Info | undefined>,
871
+ provider: any
872
+ ) => {
873
+ return plugin.loader!(getAuth, provider);
874
+ };
875
+ }
876
+ }