@link-assistant/agent 0.26.15 → 0.27.1
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/README.md +49 -7
- package/package.json +51 -42
- package/src/agent/agent.ts +12 -7
- package/src/auth/azure-login.ts +241 -0
- package/src/auth/plugins.ts +9 -0
- package/src/auth/provider-logins.ts +804 -0
- package/src/cli/argv.ts +1 -1
- package/src/cli/cmd/auth.ts +4 -0
- package/src/cli/cmd/models.ts +68 -13
- package/src/cli/cmd/run.ts +7 -1
- package/src/cli/continuous-mode.js +38 -12
- package/src/cli/event-handler.js +3 -1
- package/src/cli/model-config.js +27 -8
- package/src/cli/run-options.js +144 -78
- package/src/config/config.ts +306 -105
- package/src/config/defaults.ts +13 -5
- package/src/config/file-config.ts +16 -0
- package/src/file/ripgrep.ts +38 -5
- package/src/file/time.ts +86 -9
- package/src/index.js +42 -33
- package/src/mcp/index.ts +370 -106
- package/src/permission/index.ts +22 -0
- package/src/project/instance.ts +5 -1
- package/src/project/project.ts +35 -2
- package/src/provider/env-template.ts +115 -0
- package/src/provider/extra-providers.ts +347 -0
- package/src/provider/free-tier-error.ts +1 -1
- package/src/provider/kilo-deprecation.ts +30 -0
- package/src/provider/kilo.ts +44 -7
- package/src/provider/model-discovery.ts +690 -0
- package/src/provider/models.ts +11 -1
- package/src/provider/parity-loaders.ts +595 -0
- package/src/provider/provider.ts +309 -203
- package/src/provider/retry-fetch.ts +5 -0
- package/src/provider/transform.ts +168 -17
- package/src/session/compaction.ts +18 -2
- package/src/session/index.ts +30 -3
- package/src/session/message-v2.ts +318 -32
- package/src/session/processor.ts +274 -140
- package/src/session/prompt.ts +154 -128
- package/src/session/retry.ts +26 -13
- package/src/session/summary.ts +4 -1
- package/src/tool/bash.ts +232 -45
- package/src/tool/bash.txt +1 -1
- package/src/tool/codesearch.ts +15 -78
- package/src/tool/edit.ts +111 -30
- package/src/tool/glob.ts +2 -1
- package/src/tool/grep.ts +175 -57
- package/src/tool/ls.ts +2 -1
- package/src/tool/multiedit.ts +3 -1
- package/src/tool/read.ts +169 -41
- package/src/tool/task.ts +26 -18
- package/src/tool/tool.ts +4 -1
- package/src/tool/webfetch.ts +68 -56
- package/src/tool/webfetch.txt +4 -4
- package/src/tool/websearch.ts +20 -75
- package/src/tool/websearch.txt +2 -0
- package/src/tool/write.ts +23 -6
- package/src/util/bom.ts +32 -0
- package/src/util/exa-mcp.ts +82 -0
- package/src/util/legacy-error-names.ts +39 -0
- package/src/util/read-bounded.ts +32 -0
- package/src/util/sse-usage-extractor.ts +67 -13
- package/src/util/timeout.ts +3 -4
- package/src/util/verbose-fetch.ts +40 -1
|
@@ -0,0 +1,804 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import * as http from 'node:http';
|
|
3
|
+
import { Auth } from './index';
|
|
4
|
+
import type { AuthPlugin, AuthResult } from './plugins';
|
|
5
|
+
import { Log } from '../util/log';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Subscription / account logins OpenCode offers for xAI, Poe, DigitalOcean
|
|
9
|
+
* and GitLab
|
|
10
|
+
* (https://github.com/anomalyco/opencode, packages/core/src/plugin/provider),
|
|
11
|
+
* ported to the agent's AuthPlugin shape (#334). Each provider also keeps a
|
|
12
|
+
* plain API-key method.
|
|
13
|
+
*/
|
|
14
|
+
export namespace ProviderLogins {
|
|
15
|
+
const log = Log.create({ service: 'provider-logins' });
|
|
16
|
+
|
|
17
|
+
type Fetch = typeof fetch;
|
|
18
|
+
type Sleep = (ms: number) => Promise<void>;
|
|
19
|
+
const sleep: Sleep = (ms) =>
|
|
20
|
+
new Promise((resolve) => setTimeout(resolve, ms));
|
|
21
|
+
|
|
22
|
+
// No authorize(): the CLI falls back to its masked API-key prompt.
|
|
23
|
+
const apiKeyMethod: AuthPlugin['methods'][number] = {
|
|
24
|
+
label: 'Manually enter API Key',
|
|
25
|
+
type: 'api',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function positiveSeconds(value: unknown, fallback: number) {
|
|
29
|
+
const seconds = Number(value);
|
|
30
|
+
return Number.isFinite(seconds) && seconds > 0 ? seconds : fallback;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------- xAI ----
|
|
34
|
+
|
|
35
|
+
export const XAI_CLIENT_ID = 'b1a00492-073a-47ea-816f-4c329264a828';
|
|
36
|
+
export const XAI_ISSUER = 'https://auth.x.ai/oauth2';
|
|
37
|
+
const XAI_SCOPE =
|
|
38
|
+
'openid profile email offline_access grok-cli:access api:access';
|
|
39
|
+
const XAI_DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
40
|
+
const FORM = {
|
|
41
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
42
|
+
Accept: 'application/json',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export interface XAIToken {
|
|
46
|
+
access_token: string;
|
|
47
|
+
refresh_token?: string;
|
|
48
|
+
expires_in?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface XAIDevice {
|
|
52
|
+
device_code: string;
|
|
53
|
+
user_code: string;
|
|
54
|
+
verification_uri: string;
|
|
55
|
+
verification_uri_complete?: string;
|
|
56
|
+
expires_in?: number;
|
|
57
|
+
interval?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Expiry from `expires_in`, else from the JWT `exp` claim, else 1h. */
|
|
61
|
+
export function tokenExpiration(token: XAIToken, now = Date.now()) {
|
|
62
|
+
if (token.expires_in) {
|
|
63
|
+
return now + positiveSeconds(token.expires_in, 3600) * 1000;
|
|
64
|
+
}
|
|
65
|
+
const payload = token.access_token.split('.')[1];
|
|
66
|
+
try {
|
|
67
|
+
const exp = payload
|
|
68
|
+
? JSON.parse(Buffer.from(payload, 'base64url').toString()).exp
|
|
69
|
+
: undefined;
|
|
70
|
+
if (typeof exp === 'number') return exp * 1000;
|
|
71
|
+
} catch {
|
|
72
|
+
// not a JWT
|
|
73
|
+
}
|
|
74
|
+
return now + 3600 * 1000;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Poll the device token endpoint until the user approves, following the
|
|
79
|
+
* RFC 8628 `authorization_pending` / `slow_down` replies.
|
|
80
|
+
*/
|
|
81
|
+
export async function pollXAIDevice(
|
|
82
|
+
device: XAIDevice,
|
|
83
|
+
options: { fetchImpl?: Fetch; sleep?: Sleep; now?: () => number } = {}
|
|
84
|
+
): Promise<XAIToken> {
|
|
85
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
86
|
+
const wait = options.sleep ?? sleep;
|
|
87
|
+
const now = options.now ?? Date.now;
|
|
88
|
+
const expires = now() + positiveSeconds(device.expires_in, 300) * 1000;
|
|
89
|
+
let interval = Math.max(positiveSeconds(device.interval, 5) * 1000, 1000);
|
|
90
|
+
const margin = 3000;
|
|
91
|
+
while (now() < expires) {
|
|
92
|
+
const response = await fetchImpl(`${XAI_ISSUER}/token`, {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: FORM,
|
|
95
|
+
body: new URLSearchParams({
|
|
96
|
+
grant_type: XAI_DEVICE_GRANT,
|
|
97
|
+
client_id: XAI_CLIENT_ID,
|
|
98
|
+
device_code: device.device_code,
|
|
99
|
+
}).toString(),
|
|
100
|
+
});
|
|
101
|
+
if (response.ok) return (await response.json()) as XAIToken;
|
|
102
|
+
const body = (await response.json().catch(() => ({}))) as {
|
|
103
|
+
error?: string;
|
|
104
|
+
error_description?: string;
|
|
105
|
+
};
|
|
106
|
+
if (body.error === 'authorization_pending') {
|
|
107
|
+
await wait(interval + margin);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (body.error === 'slow_down') {
|
|
111
|
+
interval += 5000;
|
|
112
|
+
await wait(interval + margin);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (
|
|
116
|
+
body.error === 'access_denied' ||
|
|
117
|
+
body.error === 'authorization_denied'
|
|
118
|
+
) {
|
|
119
|
+
throw new Error('xAI device authorization was denied');
|
|
120
|
+
}
|
|
121
|
+
if (body.error === 'expired_token') {
|
|
122
|
+
throw new Error('xAI device code expired - please re-run login');
|
|
123
|
+
}
|
|
124
|
+
const detail = body.error_description ?? body.error;
|
|
125
|
+
throw new Error(
|
|
126
|
+
`xAI device token exchange failed (${response.status})${detail ? `: ${detail}` : ''}`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
throw new Error('xAI device authorization timed out');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function refreshXAI(
|
|
133
|
+
refresh: string,
|
|
134
|
+
fetchImpl: Fetch = fetch
|
|
135
|
+
): Promise<Extract<Auth.Info, { type: 'oauth' }>> {
|
|
136
|
+
const response = await fetchImpl(`${XAI_ISSUER}/token`, {
|
|
137
|
+
method: 'POST',
|
|
138
|
+
headers: FORM,
|
|
139
|
+
body: new URLSearchParams({
|
|
140
|
+
grant_type: 'refresh_token',
|
|
141
|
+
refresh_token: refresh,
|
|
142
|
+
client_id: XAI_CLIENT_ID,
|
|
143
|
+
}).toString(),
|
|
144
|
+
});
|
|
145
|
+
if (!response.ok) {
|
|
146
|
+
throw new Error(`xAI token refresh failed (${response.status})`);
|
|
147
|
+
}
|
|
148
|
+
const token = (await response.json()) as XAIToken;
|
|
149
|
+
return {
|
|
150
|
+
type: 'oauth',
|
|
151
|
+
access: token.access_token,
|
|
152
|
+
refresh: token.refresh_token ?? refresh,
|
|
153
|
+
expires: tokenExpiration(token),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export const XAIPlugin: AuthPlugin = {
|
|
158
|
+
provider: 'xai',
|
|
159
|
+
methods: [
|
|
160
|
+
{
|
|
161
|
+
label: 'SuperGrok Subscription',
|
|
162
|
+
type: 'oauth',
|
|
163
|
+
async authorize() {
|
|
164
|
+
const response = await fetch(`${XAI_ISSUER}/device/code`, {
|
|
165
|
+
method: 'POST',
|
|
166
|
+
headers: FORM,
|
|
167
|
+
body: new URLSearchParams({
|
|
168
|
+
client_id: XAI_CLIENT_ID,
|
|
169
|
+
scope: XAI_SCOPE,
|
|
170
|
+
referrer: 'link-assistant-agent',
|
|
171
|
+
}).toString(),
|
|
172
|
+
});
|
|
173
|
+
if (!response.ok) {
|
|
174
|
+
log.error(() => ({
|
|
175
|
+
message: 'xai device code request failed',
|
|
176
|
+
status: response.status,
|
|
177
|
+
}));
|
|
178
|
+
return { type: 'failed' as const };
|
|
179
|
+
}
|
|
180
|
+
const device = (await response.json()) as XAIDevice;
|
|
181
|
+
return {
|
|
182
|
+
url: device.verification_uri_complete ?? device.verification_uri,
|
|
183
|
+
instructions: `Open ${device.verification_uri} on any device and enter code: ${device.user_code}`,
|
|
184
|
+
method: 'auto' as const,
|
|
185
|
+
async callback(): Promise<AuthResult> {
|
|
186
|
+
try {
|
|
187
|
+
const token = await pollXAIDevice(device);
|
|
188
|
+
if (!token.refresh_token) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
'xAI token response is missing refresh_token'
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
type: 'success',
|
|
195
|
+
provider: 'xai',
|
|
196
|
+
access: token.access_token,
|
|
197
|
+
refresh: token.refresh_token,
|
|
198
|
+
expires: tokenExpiration(token),
|
|
199
|
+
};
|
|
200
|
+
} catch (error) {
|
|
201
|
+
log.error(() => ({
|
|
202
|
+
message: 'xai login failed',
|
|
203
|
+
error: String(error),
|
|
204
|
+
}));
|
|
205
|
+
return { type: 'failed' };
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
apiKeyMethod,
|
|
212
|
+
],
|
|
213
|
+
async loader(getAuth) {
|
|
214
|
+
let auth = await getAuth();
|
|
215
|
+
if (auth?.type === 'api') return { apiKey: auth.key };
|
|
216
|
+
if (auth?.type !== 'oauth') return {};
|
|
217
|
+
if (auth.expires <= Date.now() + 60_000) {
|
|
218
|
+
auth = await refreshXAI(auth.refresh);
|
|
219
|
+
await Auth.set('xai', auth);
|
|
220
|
+
}
|
|
221
|
+
return { apiKey: auth.access };
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// ---------------------------------------------------------------- Poe ----
|
|
226
|
+
|
|
227
|
+
export const POE_CLIENT_ID = 'client_728290227fc048cc9262091a1ea197ea';
|
|
228
|
+
export const POE_ISSUER = 'https://poe.com';
|
|
229
|
+
const NO_EXPIRY = 8_640_000_000_000_000;
|
|
230
|
+
|
|
231
|
+
/** Validate the loopback redirect; returns an error message or undefined. */
|
|
232
|
+
export function poeCallbackError(params: URLSearchParams, state: string) {
|
|
233
|
+
if (params.get('state') !== state) return 'Invalid OAuth state';
|
|
234
|
+
if (params.has('iss') && params.get('iss') !== POE_ISSUER) {
|
|
235
|
+
return 'Invalid OAuth issuer';
|
|
236
|
+
}
|
|
237
|
+
if (params.has('error')) {
|
|
238
|
+
const detail =
|
|
239
|
+
params.get('error_description') ||
|
|
240
|
+
params.get('error') ||
|
|
241
|
+
'Authorization denied';
|
|
242
|
+
return detail.includes(state) ? 'Poe authorization failed' : detail;
|
|
243
|
+
}
|
|
244
|
+
return params.get('code')?.trim()
|
|
245
|
+
? undefined
|
|
246
|
+
: 'Missing authorization code';
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Exchange the code for a Poe-issued API key (`apikey:create` scope). */
|
|
250
|
+
export async function exchangePoeCode(
|
|
251
|
+
input: { code: string; redirect: string; verifier: string },
|
|
252
|
+
fetchImpl: Fetch = fetch,
|
|
253
|
+
now = Date.now()
|
|
254
|
+
): Promise<{ key: string; expires: number }> {
|
|
255
|
+
const response = await fetchImpl('https://api.poe.com/token', {
|
|
256
|
+
method: 'POST',
|
|
257
|
+
headers: FORM,
|
|
258
|
+
body: new URLSearchParams({
|
|
259
|
+
grant_type: 'authorization_code',
|
|
260
|
+
client_id: POE_CLIENT_ID,
|
|
261
|
+
code: input.code,
|
|
262
|
+
redirect_uri: input.redirect,
|
|
263
|
+
code_verifier: input.verifier,
|
|
264
|
+
}).toString(),
|
|
265
|
+
});
|
|
266
|
+
if (!response.ok) {
|
|
267
|
+
const body = (await response.json().catch(() => ({}))) as {
|
|
268
|
+
error?: string;
|
|
269
|
+
error_description?: string;
|
|
270
|
+
};
|
|
271
|
+
const detail = body.error_description || body.error;
|
|
272
|
+
const leaks =
|
|
273
|
+
detail && [input.code, input.verifier].some((s) => detail.includes(s));
|
|
274
|
+
throw new Error(
|
|
275
|
+
detail && !leaks
|
|
276
|
+
? `Poe token exchange failed: ${detail}`
|
|
277
|
+
: `Poe token exchange failed (${response.status})`
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
const token = (await response.json()) as {
|
|
281
|
+
api_key?: string;
|
|
282
|
+
api_key_expires_in?: number | null;
|
|
283
|
+
};
|
|
284
|
+
const key = token.api_key?.trim();
|
|
285
|
+
if (!key || /\s/.test(key)) throw new Error('Invalid Poe token response');
|
|
286
|
+
const expires =
|
|
287
|
+
token.api_key_expires_in == null
|
|
288
|
+
? NO_EXPIRY
|
|
289
|
+
: now + token.api_key_expires_in * 1000;
|
|
290
|
+
return { key, expires };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function page(title: string, message: string, script = '') {
|
|
294
|
+
const escape = (s: string) =>
|
|
295
|
+
s.replace(
|
|
296
|
+
/[&<>"']/g,
|
|
297
|
+
(c) =>
|
|
298
|
+
({
|
|
299
|
+
'&': '&',
|
|
300
|
+
'<': '<',
|
|
301
|
+
'>': '>',
|
|
302
|
+
'"': '"',
|
|
303
|
+
"'": ''',
|
|
304
|
+
})[c]!
|
|
305
|
+
);
|
|
306
|
+
return `<!doctype html><html><head><meta charset="utf-8"><title>${escape(title)}</title></head><body><h1 id="h">${escape(title)}</h1><p id="m">${escape(message)}</p>${script ? `<script>${script}</script>` : ''}</body></html>`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function listen(server: http.Server, port: number, host: string) {
|
|
310
|
+
await new Promise<void>((resolve, reject) => {
|
|
311
|
+
server.once('error', reject);
|
|
312
|
+
server.listen(port, host, () => {
|
|
313
|
+
server.off('error', reject);
|
|
314
|
+
resolve();
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
const address = server.address();
|
|
318
|
+
if (!address || typeof address === 'string') {
|
|
319
|
+
throw new Error('Missing OAuth callback port');
|
|
320
|
+
}
|
|
321
|
+
return address.port;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export const PoePlugin: AuthPlugin = {
|
|
325
|
+
provider: 'poe',
|
|
326
|
+
methods: [
|
|
327
|
+
{
|
|
328
|
+
label: 'Login with Poe (browser)',
|
|
329
|
+
type: 'oauth',
|
|
330
|
+
async authorize() {
|
|
331
|
+
const verifier = crypto.randomBytes(32).toString('base64url');
|
|
332
|
+
const challenge = crypto
|
|
333
|
+
.createHash('sha256')
|
|
334
|
+
.update(verifier)
|
|
335
|
+
.digest('base64url');
|
|
336
|
+
const state = crypto.randomBytes(32).toString('base64url');
|
|
337
|
+
let settle!: (value: { code: string } | Error) => void;
|
|
338
|
+
const received = new Promise<{ code: string } | Error>(
|
|
339
|
+
(resolve) => (settle = resolve)
|
|
340
|
+
);
|
|
341
|
+
const server = http.createServer((request, response) => {
|
|
342
|
+
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
343
|
+
if (request.method !== 'GET' || url.pathname !== '/callback') {
|
|
344
|
+
response.writeHead(404).end();
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
const error = poeCallbackError(url.searchParams, state);
|
|
348
|
+
response
|
|
349
|
+
.writeHead(error ? 400 : 200, { 'Content-Type': 'text/html' })
|
|
350
|
+
.end(
|
|
351
|
+
error
|
|
352
|
+
? page('Authorization failed', error)
|
|
353
|
+
: page(
|
|
354
|
+
'Authorization successful',
|
|
355
|
+
'You can close this window and return to the terminal.'
|
|
356
|
+
)
|
|
357
|
+
);
|
|
358
|
+
settle(
|
|
359
|
+
error
|
|
360
|
+
? new Error(error)
|
|
361
|
+
: { code: url.searchParams.get('code')!.trim() }
|
|
362
|
+
);
|
|
363
|
+
});
|
|
364
|
+
const port = await listen(server, 0, '127.0.0.1');
|
|
365
|
+
const redirect = `http://127.0.0.1:${port}/callback`;
|
|
366
|
+
return {
|
|
367
|
+
url: `${POE_ISSUER}/oauth/authorize?${new URLSearchParams({
|
|
368
|
+
response_type: 'code',
|
|
369
|
+
client_id: POE_CLIENT_ID,
|
|
370
|
+
redirect_uri: redirect,
|
|
371
|
+
scope: 'apikey:create',
|
|
372
|
+
code_challenge: challenge,
|
|
373
|
+
code_challenge_method: 'S256',
|
|
374
|
+
state,
|
|
375
|
+
})}`,
|
|
376
|
+
instructions:
|
|
377
|
+
'Complete authorization in your browser. This window will close automatically.',
|
|
378
|
+
method: 'auto' as const,
|
|
379
|
+
async callback(): Promise<AuthResult> {
|
|
380
|
+
try {
|
|
381
|
+
const result = await received;
|
|
382
|
+
if (result instanceof Error) throw result;
|
|
383
|
+
const { key } = await exchangePoeCode({
|
|
384
|
+
code: result.code,
|
|
385
|
+
redirect,
|
|
386
|
+
verifier,
|
|
387
|
+
});
|
|
388
|
+
// Poe returns an API key, stored like a manually entered one.
|
|
389
|
+
return { type: 'success', provider: 'poe', key };
|
|
390
|
+
} catch (error) {
|
|
391
|
+
log.error(() => ({
|
|
392
|
+
message: 'poe login failed',
|
|
393
|
+
error: String(error),
|
|
394
|
+
}));
|
|
395
|
+
return { type: 'failed' };
|
|
396
|
+
} finally {
|
|
397
|
+
server.close();
|
|
398
|
+
server.closeAllConnections();
|
|
399
|
+
}
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
apiKeyMethod,
|
|
405
|
+
],
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
// -------------------------------------------------------- DigitalOcean ----
|
|
409
|
+
|
|
410
|
+
export const DIGITALOCEAN_CLIENT_ID =
|
|
411
|
+
'b1a6c5158156caac821fd1b30253ca8acb52454a48fa744420e41889cb589f82';
|
|
412
|
+
const DIGITALOCEAN_AUTHORIZE =
|
|
413
|
+
'https://cloud.digitalocean.com/v1/oauth/authorize';
|
|
414
|
+
/** The redirect URI registered for the client is fixed to this port. */
|
|
415
|
+
export const DIGITALOCEAN_CALLBACK_PORT = 1456;
|
|
416
|
+
const CALLBACK_PATH = '/auth/callback';
|
|
417
|
+
// Also written literally in the bootstrap page script below.
|
|
418
|
+
const TOKEN_PATH = '/auth/token';
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* DigitalOcean uses the implicit grant: the token arrives in the URL
|
|
422
|
+
* fragment, so a small page posts it back to the loopback server.
|
|
423
|
+
*/
|
|
424
|
+
export function digitalOceanBootstrapPage() {
|
|
425
|
+
const script = `var p=new URLSearchParams(location.hash.slice(1));var b={};p.forEach(function(v,k){b[k]=v});new URLSearchParams(location.search).forEach(function(v,k){if(!(k in b))b[k]=v});fetch('/auth/token',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b)}).then(function(r){return r.json()}).then(function(j){document.getElementById('h').textContent=j.ok?'Authorization successful':'Authorization failed';document.getElementById('m').textContent=j.ok?'You can close this window.':(j.error||'')}).catch(function(e){document.getElementById('m').textContent=String(e)})`;
|
|
426
|
+
return page(
|
|
427
|
+
'Finishing sign-in',
|
|
428
|
+
'Completing your DigitalOcean authorization.',
|
|
429
|
+
script
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Parse the posted fragment. Returns the token or an error message. */
|
|
434
|
+
export function parseDigitalOceanToken(
|
|
435
|
+
raw: string,
|
|
436
|
+
state: string
|
|
437
|
+
): { access: string; expiresIn: number } | { error: string } {
|
|
438
|
+
let body: Record<string, string | undefined> = {};
|
|
439
|
+
try {
|
|
440
|
+
body = JSON.parse(raw);
|
|
441
|
+
} catch {
|
|
442
|
+
// handled below
|
|
443
|
+
}
|
|
444
|
+
const error = body.error_description || body.error;
|
|
445
|
+
if (error) return { error };
|
|
446
|
+
if (!body.access_token) return { error: 'Missing access token' };
|
|
447
|
+
if (body.state !== state) return { error: 'Invalid OAuth state' };
|
|
448
|
+
const expires = Number.parseInt(body.expires_in ?? '0', 10);
|
|
449
|
+
return {
|
|
450
|
+
access: body.access_token,
|
|
451
|
+
expiresIn:
|
|
452
|
+
Number.isFinite(expires) && expires > 0 ? expires : 60 * 60 * 24 * 30,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export const DigitalOceanPlugin: AuthPlugin = {
|
|
457
|
+
provider: 'digitalocean',
|
|
458
|
+
methods: [
|
|
459
|
+
{
|
|
460
|
+
label: 'Login with DigitalOcean',
|
|
461
|
+
type: 'oauth',
|
|
462
|
+
async authorize() {
|
|
463
|
+
const state = crypto.randomBytes(32).toString('base64url');
|
|
464
|
+
let settle!: (
|
|
465
|
+
value: { access: string; expiresIn: number } | Error
|
|
466
|
+
) => void;
|
|
467
|
+
const received = new Promise<
|
|
468
|
+
{ access: string; expiresIn: number } | Error
|
|
469
|
+
>((resolve) => (settle = resolve));
|
|
470
|
+
const server = http.createServer((request, response) => {
|
|
471
|
+
const url = new URL(request.url ?? '/', 'http://localhost');
|
|
472
|
+
if (request.method === 'GET' && url.pathname === CALLBACK_PATH) {
|
|
473
|
+
response
|
|
474
|
+
.writeHead(200, { 'Content-Type': 'text/html' })
|
|
475
|
+
.end(digitalOceanBootstrapPage());
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (request.method === 'POST' && url.pathname === TOKEN_PATH) {
|
|
479
|
+
const chunks: Buffer[] = [];
|
|
480
|
+
request.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
481
|
+
request.on('end', () => {
|
|
482
|
+
const parsed = parseDigitalOceanToken(
|
|
483
|
+
Buffer.concat(chunks).toString('utf8'),
|
|
484
|
+
state
|
|
485
|
+
);
|
|
486
|
+
const failed = 'error' in parsed;
|
|
487
|
+
response
|
|
488
|
+
.writeHead(failed ? 400 : 200, {
|
|
489
|
+
'Content-Type': 'application/json',
|
|
490
|
+
})
|
|
491
|
+
.end(
|
|
492
|
+
JSON.stringify(
|
|
493
|
+
failed ? { error: parsed.error } : { ok: true }
|
|
494
|
+
)
|
|
495
|
+
);
|
|
496
|
+
settle(failed ? new Error(parsed.error) : parsed);
|
|
497
|
+
});
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
response.writeHead(404).end('Not found');
|
|
501
|
+
});
|
|
502
|
+
try {
|
|
503
|
+
await listen(server, DIGITALOCEAN_CALLBACK_PORT, 'localhost');
|
|
504
|
+
} catch (error: any) {
|
|
505
|
+
log.error(() => ({
|
|
506
|
+
message:
|
|
507
|
+
error?.code === 'EADDRINUSE'
|
|
508
|
+
? `DigitalOcean login needs local port ${DIGITALOCEAN_CALLBACK_PORT}, but it is already in use`
|
|
509
|
+
: 'digitalocean callback server failed',
|
|
510
|
+
error: String(error),
|
|
511
|
+
}));
|
|
512
|
+
return { type: 'failed' as const };
|
|
513
|
+
}
|
|
514
|
+
const redirect = `http://localhost:${DIGITALOCEAN_CALLBACK_PORT}${CALLBACK_PATH}`;
|
|
515
|
+
return {
|
|
516
|
+
url: `${DIGITALOCEAN_AUTHORIZE}?${new URLSearchParams({
|
|
517
|
+
response_type: 'token',
|
|
518
|
+
client_id: DIGITALOCEAN_CLIENT_ID,
|
|
519
|
+
redirect_uri: redirect,
|
|
520
|
+
scope: 'genai:read inference:query',
|
|
521
|
+
state,
|
|
522
|
+
})}`,
|
|
523
|
+
instructions:
|
|
524
|
+
'Complete authorization in your browser. This window will close automatically.',
|
|
525
|
+
method: 'auto' as const,
|
|
526
|
+
async callback(): Promise<AuthResult> {
|
|
527
|
+
try {
|
|
528
|
+
const result = await received;
|
|
529
|
+
if (result instanceof Error) throw result;
|
|
530
|
+
return {
|
|
531
|
+
type: 'success',
|
|
532
|
+
provider: 'digitalocean',
|
|
533
|
+
access: result.access,
|
|
534
|
+
refresh: '',
|
|
535
|
+
expires: Date.now() + result.expiresIn * 1000,
|
|
536
|
+
};
|
|
537
|
+
} catch (error) {
|
|
538
|
+
log.error(() => ({
|
|
539
|
+
message: 'digitalocean login failed',
|
|
540
|
+
error: String(error),
|
|
541
|
+
}));
|
|
542
|
+
return { type: 'failed' };
|
|
543
|
+
} finally {
|
|
544
|
+
server.close();
|
|
545
|
+
server.closeAllConnections();
|
|
546
|
+
}
|
|
547
|
+
},
|
|
548
|
+
};
|
|
549
|
+
},
|
|
550
|
+
},
|
|
551
|
+
apiKeyMethod,
|
|
552
|
+
],
|
|
553
|
+
async loader(getAuth) {
|
|
554
|
+
const auth = await getAuth();
|
|
555
|
+
if (auth?.type === 'api') return { apiKey: auth.key };
|
|
556
|
+
if (auth?.type !== 'oauth') return {};
|
|
557
|
+
if (auth.expires && auth.expires <= Date.now()) {
|
|
558
|
+
log.warn(() => ({
|
|
559
|
+
message:
|
|
560
|
+
'DigitalOcean login expired; run `agent auth login digitalocean` again',
|
|
561
|
+
}));
|
|
562
|
+
return {};
|
|
563
|
+
}
|
|
564
|
+
return { apiKey: auth.access };
|
|
565
|
+
},
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
// ------------------------------------------------------------- GitLab ----
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* OpenCode's public GitLab OAuth application, registered on gitlab.com with
|
|
572
|
+
* the redirect URI below. Self-managed instances need their own application
|
|
573
|
+
* (`GITLAB_OAUTH_CLIENT_ID`).
|
|
574
|
+
*/
|
|
575
|
+
export const GITLAB_CLIENT_ID =
|
|
576
|
+
'fd180700a8f9c5d5557aca231632dd0611a1135a3bb510a741d5a988a3394fa7';
|
|
577
|
+
/** GitLab matches redirect_uri exactly, so the port cannot be ephemeral. */
|
|
578
|
+
export const GITLAB_REDIRECT_URI = 'http://127.0.0.1:8080/callback';
|
|
579
|
+
const GITLAB_SCOPE = 'api';
|
|
580
|
+
|
|
581
|
+
export function gitlabInstanceUrl(
|
|
582
|
+
input?: string,
|
|
583
|
+
env: Record<string, string | undefined> = process.env
|
|
584
|
+
) {
|
|
585
|
+
const url = new URL(
|
|
586
|
+
input?.trim() || env['GITLAB_INSTANCE_URL'] || 'https://gitlab.com'
|
|
587
|
+
);
|
|
588
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
589
|
+
throw new Error('GitLab instance URL must use http(s)');
|
|
590
|
+
}
|
|
591
|
+
// Keep the path so instances under a relative URL root work.
|
|
592
|
+
return `${url.origin}${url.pathname.replace(/\/+$/, '')}`;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
export function gitlabClientID(
|
|
596
|
+
env: Record<string, string | undefined> = process.env
|
|
597
|
+
) {
|
|
598
|
+
return env['GITLAB_OAUTH_CLIENT_ID']?.trim() || GITLAB_CLIENT_ID;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
export async function exchangeGitLabToken(
|
|
602
|
+
instanceUrl: string,
|
|
603
|
+
body: Record<string, string>,
|
|
604
|
+
fetchImpl: Fetch = fetch,
|
|
605
|
+
now = Date.now()
|
|
606
|
+
): Promise<Extract<Auth.Info, { type: 'oauth' }>> {
|
|
607
|
+
const response = await fetchImpl(`${instanceUrl}/oauth/token`, {
|
|
608
|
+
method: 'POST',
|
|
609
|
+
headers: FORM,
|
|
610
|
+
body: new URLSearchParams({
|
|
611
|
+
...body,
|
|
612
|
+
client_id: gitlabClientID(),
|
|
613
|
+
redirect_uri: GITLAB_REDIRECT_URI,
|
|
614
|
+
}).toString(),
|
|
615
|
+
});
|
|
616
|
+
if (!response.ok) {
|
|
617
|
+
const error = (await response.json().catch(() => ({}))) as {
|
|
618
|
+
error?: string;
|
|
619
|
+
error_description?: string;
|
|
620
|
+
};
|
|
621
|
+
const detail =
|
|
622
|
+
error.error_description || error.error || `HTTP ${response.status}`;
|
|
623
|
+
throw new Error(
|
|
624
|
+
error.error === 'invalid_grant' || error.error === 'invalid_client'
|
|
625
|
+
? `GitLab token exchange failed: ${detail}. Start a fresh login; a custom GITLAB_OAUTH_CLIENT_ID application must register ${GITLAB_REDIRECT_URI}, grant the \`api\` scope and not be confidential.`
|
|
626
|
+
: `GitLab token exchange failed: ${detail}`
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
const token = (await response.json()) as XAIToken;
|
|
630
|
+
if (!token.access_token || !token.refresh_token) {
|
|
631
|
+
throw new Error('Invalid GitLab token response');
|
|
632
|
+
}
|
|
633
|
+
return {
|
|
634
|
+
type: 'oauth',
|
|
635
|
+
access: token.access_token,
|
|
636
|
+
refresh: token.refresh_token,
|
|
637
|
+
expires: now + positiveSeconds(token.expires_in, 7200) * 1000,
|
|
638
|
+
enterpriseUrl: instanceUrl,
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// GitLab rotates the refresh token on every use: share one refresh.
|
|
643
|
+
const gitlabRefreshing = new Map<
|
|
644
|
+
string,
|
|
645
|
+
Promise<Extract<Auth.Info, { type: 'oauth' }>>
|
|
646
|
+
>();
|
|
647
|
+
|
|
648
|
+
export function refreshGitLab(
|
|
649
|
+
auth: Extract<Auth.Info, { type: 'oauth' }>,
|
|
650
|
+
fetchImpl: Fetch = fetch
|
|
651
|
+
) {
|
|
652
|
+
const instanceUrl = auth.enterpriseUrl || gitlabInstanceUrl();
|
|
653
|
+
const key = `${instanceUrl}\0${auth.refresh}`;
|
|
654
|
+
let pending = gitlabRefreshing.get(key);
|
|
655
|
+
if (!pending) {
|
|
656
|
+
pending = exchangeGitLabToken(
|
|
657
|
+
instanceUrl,
|
|
658
|
+
{ grant_type: 'refresh_token', refresh_token: auth.refresh },
|
|
659
|
+
fetchImpl
|
|
660
|
+
).finally(() => {
|
|
661
|
+
setTimeout(() => gitlabRefreshing.delete(key), 60_000).unref?.();
|
|
662
|
+
});
|
|
663
|
+
gitlabRefreshing.set(key, pending);
|
|
664
|
+
}
|
|
665
|
+
return pending;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
export const GitLabPlugin: AuthPlugin = {
|
|
669
|
+
provider: 'gitlab',
|
|
670
|
+
methods: [
|
|
671
|
+
{
|
|
672
|
+
label: 'Login with GitLab (OAuth)',
|
|
673
|
+
type: 'oauth',
|
|
674
|
+
prompts: [
|
|
675
|
+
{
|
|
676
|
+
type: 'text',
|
|
677
|
+
key: 'instanceUrl',
|
|
678
|
+
message: 'GitLab instance URL',
|
|
679
|
+
placeholder: 'https://gitlab.com',
|
|
680
|
+
validate: (value) =>
|
|
681
|
+
!value || /^https?:\/\/\S+$/.test(value.trim())
|
|
682
|
+
? undefined
|
|
683
|
+
: 'Enter an http(s) URL',
|
|
684
|
+
},
|
|
685
|
+
],
|
|
686
|
+
async authorize(inputs) {
|
|
687
|
+
const instanceUrl = gitlabInstanceUrl(inputs['instanceUrl']);
|
|
688
|
+
const custom = Boolean(process.env['GITLAB_OAUTH_CLIENT_ID']?.trim());
|
|
689
|
+
if (!custom && new URL(instanceUrl).host !== 'gitlab.com') {
|
|
690
|
+
log.error(() => ({
|
|
691
|
+
message: `The bundled GitLab OAuth application only exists on gitlab.com. Register an OAuth application on ${instanceUrl} with redirect URI ${GITLAB_REDIRECT_URI} and the \`api\` scope (not confidential), then set GITLAB_OAUTH_CLIENT_ID. A personal access token in GITLAB_TOKEN also works.`,
|
|
692
|
+
}));
|
|
693
|
+
return { type: 'failed' as const };
|
|
694
|
+
}
|
|
695
|
+
const verifier = crypto.randomBytes(32).toString('base64url');
|
|
696
|
+
const challenge = crypto
|
|
697
|
+
.createHash('sha256')
|
|
698
|
+
.update(verifier)
|
|
699
|
+
.digest('base64url');
|
|
700
|
+
const state = crypto.randomBytes(32).toString('base64url');
|
|
701
|
+
let settle!: (value: { code: string } | Error) => void;
|
|
702
|
+
const received = new Promise<{ code: string } | Error>(
|
|
703
|
+
(resolve) => (settle = resolve)
|
|
704
|
+
);
|
|
705
|
+
const server = http.createServer((request, response) => {
|
|
706
|
+
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
707
|
+
if (request.method !== 'GET' || url.pathname !== '/callback') {
|
|
708
|
+
response.writeHead(404).end();
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
const params = url.searchParams;
|
|
712
|
+
const error =
|
|
713
|
+
params.get('state') !== state
|
|
714
|
+
? 'Invalid OAuth state'
|
|
715
|
+
: params.get('error_description') ||
|
|
716
|
+
params.get('error') ||
|
|
717
|
+
(params.get('code')
|
|
718
|
+
? undefined
|
|
719
|
+
: 'Missing authorization code');
|
|
720
|
+
response
|
|
721
|
+
.writeHead(error ? 400 : 200, { 'Content-Type': 'text/html' })
|
|
722
|
+
.end(
|
|
723
|
+
error
|
|
724
|
+
? page('Authorization failed', error)
|
|
725
|
+
: page(
|
|
726
|
+
'Authorization successful',
|
|
727
|
+
'You can close this window and return to the terminal.'
|
|
728
|
+
)
|
|
729
|
+
);
|
|
730
|
+
settle(error ? new Error(error) : { code: params.get('code')! });
|
|
731
|
+
});
|
|
732
|
+
try {
|
|
733
|
+
await listen(server, 8080, '127.0.0.1');
|
|
734
|
+
} catch (error: any) {
|
|
735
|
+
log.error(() => ({
|
|
736
|
+
message:
|
|
737
|
+
error?.code === 'EADDRINUSE'
|
|
738
|
+
? 'GitLab login needs local port 8080, but it is already in use'
|
|
739
|
+
: 'gitlab callback server failed',
|
|
740
|
+
error: String(error),
|
|
741
|
+
}));
|
|
742
|
+
return { type: 'failed' as const };
|
|
743
|
+
}
|
|
744
|
+
return {
|
|
745
|
+
url: `${instanceUrl}/oauth/authorize?${new URLSearchParams({
|
|
746
|
+
client_id: gitlabClientID(),
|
|
747
|
+
redirect_uri: GITLAB_REDIRECT_URI,
|
|
748
|
+
response_type: 'code',
|
|
749
|
+
state,
|
|
750
|
+
scope: GITLAB_SCOPE,
|
|
751
|
+
code_challenge: challenge,
|
|
752
|
+
code_challenge_method: 'S256',
|
|
753
|
+
})}`,
|
|
754
|
+
instructions:
|
|
755
|
+
'Complete authorization in your browser. This window will close automatically.',
|
|
756
|
+
method: 'auto' as const,
|
|
757
|
+
async callback(): Promise<AuthResult> {
|
|
758
|
+
try {
|
|
759
|
+
const result = await received;
|
|
760
|
+
if (result instanceof Error) throw result;
|
|
761
|
+
const auth = await exchangeGitLabToken(instanceUrl, {
|
|
762
|
+
grant_type: 'authorization_code',
|
|
763
|
+
code: result.code,
|
|
764
|
+
code_verifier: verifier,
|
|
765
|
+
});
|
|
766
|
+
return {
|
|
767
|
+
type: 'success',
|
|
768
|
+
provider: 'gitlab',
|
|
769
|
+
access: auth.access,
|
|
770
|
+
refresh: auth.refresh,
|
|
771
|
+
expires: auth.expires,
|
|
772
|
+
enterpriseUrl: instanceUrl,
|
|
773
|
+
};
|
|
774
|
+
} catch (error) {
|
|
775
|
+
log.error(() => ({
|
|
776
|
+
message: 'gitlab login failed',
|
|
777
|
+
error: String(error),
|
|
778
|
+
}));
|
|
779
|
+
return { type: 'failed' };
|
|
780
|
+
} finally {
|
|
781
|
+
server.close();
|
|
782
|
+
server.closeAllConnections();
|
|
783
|
+
}
|
|
784
|
+
},
|
|
785
|
+
};
|
|
786
|
+
},
|
|
787
|
+
},
|
|
788
|
+
apiKeyMethod,
|
|
789
|
+
],
|
|
790
|
+
async loader(getAuth) {
|
|
791
|
+
let auth = await getAuth();
|
|
792
|
+
if (auth?.type === 'api') return { apiKey: auth.key };
|
|
793
|
+
if (auth?.type !== 'oauth') return {};
|
|
794
|
+
if (auth.expires <= Date.now() + 60_000) {
|
|
795
|
+
auth = await refreshGitLab(auth);
|
|
796
|
+
await Auth.set('gitlab', auth);
|
|
797
|
+
}
|
|
798
|
+
return {
|
|
799
|
+
apiKey: auth.access,
|
|
800
|
+
options: { instanceUrl: auth.enterpriseUrl || gitlabInstanceUrl() },
|
|
801
|
+
};
|
|
802
|
+
},
|
|
803
|
+
};
|
|
804
|
+
}
|