@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.
- package/EXAMPLES.md +36 -0
- package/MODELS.md +72 -24
- package/README.md +59 -2
- package/TOOLS.md +20 -0
- package/package.json +35 -2
- package/src/agent/agent.ts +68 -54
- package/src/auth/claude-oauth.ts +426 -0
- package/src/auth/index.ts +28 -26
- package/src/auth/plugins.ts +876 -0
- package/src/bun/index.ts +53 -43
- package/src/bus/global.ts +5 -5
- package/src/bus/index.ts +59 -53
- package/src/cli/bootstrap.js +12 -12
- package/src/cli/bootstrap.ts +6 -6
- package/src/cli/cmd/agent.ts +97 -92
- package/src/cli/cmd/auth.ts +469 -0
- package/src/cli/cmd/cmd.ts +2 -2
- package/src/cli/cmd/export.ts +41 -41
- package/src/cli/cmd/mcp.ts +144 -119
- package/src/cli/cmd/models.ts +30 -29
- package/src/cli/cmd/run.ts +269 -213
- package/src/cli/cmd/stats.ts +185 -146
- package/src/cli/error.ts +17 -13
- package/src/cli/ui.ts +39 -24
- package/src/command/index.ts +26 -26
- package/src/config/config.ts +528 -288
- package/src/config/markdown.ts +15 -15
- package/src/file/ripgrep.ts +201 -169
- package/src/file/time.ts +21 -18
- package/src/file/watcher.ts +51 -42
- package/src/file.ts +1 -1
- package/src/flag/flag.ts +26 -11
- package/src/format/formatter.ts +206 -162
- package/src/format/index.ts +61 -61
- package/src/global/index.ts +21 -21
- package/src/id/id.ts +47 -33
- package/src/index.js +346 -199
- package/src/json-standard/index.ts +67 -51
- package/src/mcp/index.ts +135 -128
- package/src/patch/index.ts +336 -267
- package/src/project/bootstrap.ts +15 -15
- package/src/project/instance.ts +43 -36
- package/src/project/project.ts +47 -47
- package/src/project/state.ts +37 -33
- package/src/provider/models-macro.ts +5 -5
- package/src/provider/models.ts +32 -32
- package/src/provider/opencode.js +19 -19
- package/src/provider/provider.ts +518 -277
- package/src/provider/transform.ts +143 -102
- package/src/server/project.ts +21 -21
- package/src/server/server.ts +111 -105
- package/src/session/agent.js +66 -60
- package/src/session/compaction.ts +136 -111
- package/src/session/index.ts +189 -156
- package/src/session/message-v2.ts +312 -268
- package/src/session/message.ts +73 -57
- package/src/session/processor.ts +180 -166
- package/src/session/prompt.ts +678 -533
- package/src/session/retry.ts +26 -23
- package/src/session/revert.ts +76 -62
- package/src/session/status.ts +26 -26
- package/src/session/summary.ts +97 -76
- package/src/session/system.ts +77 -63
- package/src/session/todo.ts +22 -16
- package/src/snapshot/index.ts +92 -76
- package/src/storage/storage.ts +157 -120
- package/src/tool/bash.ts +116 -106
- package/src/tool/batch.ts +73 -59
- package/src/tool/codesearch.ts +60 -53
- package/src/tool/edit.ts +319 -263
- package/src/tool/glob.ts +32 -28
- package/src/tool/grep.ts +72 -53
- package/src/tool/invalid.ts +7 -7
- package/src/tool/ls.ts +77 -64
- package/src/tool/multiedit.ts +30 -21
- package/src/tool/patch.ts +121 -94
- package/src/tool/read.ts +140 -122
- package/src/tool/registry.ts +38 -38
- package/src/tool/task.ts +93 -60
- package/src/tool/todo.ts +16 -16
- package/src/tool/tool.ts +45 -36
- package/src/tool/webfetch.ts +97 -74
- package/src/tool/websearch.ts +78 -64
- package/src/tool/write.ts +21 -15
- package/src/util/binary.ts +27 -19
- package/src/util/context.ts +8 -8
- package/src/util/defer.ts +7 -5
- package/src/util/error.ts +24 -19
- package/src/util/eventloop.ts +16 -10
- package/src/util/filesystem.ts +37 -33
- package/src/util/fn.ts +11 -8
- package/src/util/iife.ts +1 -1
- package/src/util/keybind.ts +44 -44
- package/src/util/lazy.ts +7 -7
- package/src/util/locale.ts +20 -16
- package/src/util/lock.ts +43 -38
- package/src/util/log.ts +95 -85
- package/src/util/queue.ts +8 -8
- package/src/util/rpc.ts +35 -23
- package/src/util/scrap.ts +4 -4
- package/src/util/signal.ts +5 -5
- package/src/util/timeout.ts +6 -6
- package/src/util/token.ts +2 -2
- 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
|
+
}
|