@rayrun/cli 0.1.0 → 0.3.0
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 +63 -1
- package/package.json +11 -6
- package/src/execution.js +862 -0
- package/src/main.js +16 -2
- package/src/management.js +377 -0
- package/src/oauth.js +599 -0
- package/src/setup.js +2 -2
package/src/oauth.js
ADDED
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
import { atomicWrite, assertNoSymbolicLinkComponents } from './setup.js';
|
|
2
|
+
// cspell:ignore nosniff rundll
|
|
3
|
+
/* eslint-disable node/no-process-env -- the OAuth client intentionally uses the invoking user's Rayrun configuration */
|
|
4
|
+
import {
|
|
5
|
+
Client,
|
|
6
|
+
StreamableHTTPClientTransport,
|
|
7
|
+
UnauthorizedError,
|
|
8
|
+
} from '@modelcontextprotocol/client';
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
11
|
+
import { chmod, lstat, mkdir, readFile, readdir, rename, rm } from 'node:fs/promises';
|
|
12
|
+
import { createServer } from 'node:http';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
|
|
16
|
+
const AUTHORIZATION_TIMEOUT_MILLISECONDS = 5 * 60 * 1_000;
|
|
17
|
+
const LOCK_ACQUIRE_ATTEMPTS = 5;
|
|
18
|
+
const LOCK_CLAIM_REGEX = /^(active|waiting)-([A-Za-z\d_-]+)\.json$/u;
|
|
19
|
+
const SESSION_VERSION = 1;
|
|
20
|
+
|
|
21
|
+
const isFileSystemError = (error, code) => {
|
|
22
|
+
return error && typeof error === 'object' && 'code' in error && error.code === code;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const executionRoot = ({
|
|
26
|
+
environment = process.env,
|
|
27
|
+
homeDirectory = os.homedir(),
|
|
28
|
+
platform = process.platform,
|
|
29
|
+
} = {}) => {
|
|
30
|
+
const configuredRoot = environment.RAYRUN_CONFIG_HOME;
|
|
31
|
+
const root = configuredRoot ?? path.join(homeDirectory, '.rayrun');
|
|
32
|
+
if (!path.isAbsolute(root)) throw new Error('RAYRUN_CONFIG_HOME must be an absolute path.');
|
|
33
|
+
if (platform === 'win32' && configuredRoot !== undefined) {
|
|
34
|
+
const relative = path.relative(homeDirectory, root);
|
|
35
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
36
|
+
throw new Error('On Windows, RAYRUN_CONFIG_HOME must stay inside the current user profile.');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return path.join(root, 'execution');
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const endpointKey = (endpoint) => createHash('sha256').update(endpoint).digest('hex');
|
|
43
|
+
|
|
44
|
+
const sessionPath = (endpoint, options) => {
|
|
45
|
+
return path.join(executionRoot(options), 'sessions', `${endpointKey(endpoint)}.json`);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const ensurePrivateDirectory = async (directory) => {
|
|
49
|
+
await assertNoSymbolicLinkComponents(directory);
|
|
50
|
+
await mkdir(directory, { mode: 0o700, recursive: true });
|
|
51
|
+
await assertNoSymbolicLinkComponents(directory);
|
|
52
|
+
await chmod(directory, 0o700);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const readPrivateJson = async (target, { platform = process.platform } = {}) => {
|
|
56
|
+
await assertNoSymbolicLinkComponents(target);
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const metadata = await lstat(target);
|
|
60
|
+
if (!metadata.isFile() || (platform !== 'win32' && (metadata.mode & 0o077) !== 0)) {
|
|
61
|
+
throw new Error(`Refusing to read OAuth credentials without 0600 permissions: ${target}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return JSON.parse(await readFile(target, 'utf8'));
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (isFileSystemError(error, 'ENOENT')) return null;
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const emptySession = (endpoint) => ({
|
|
72
|
+
clientInformationByIssuer: {},
|
|
73
|
+
discoveryState: null,
|
|
74
|
+
endpoint,
|
|
75
|
+
latestClientIssuer: null,
|
|
76
|
+
latestTokenIssuer: null,
|
|
77
|
+
tokensByIssuer: {},
|
|
78
|
+
verifier: null,
|
|
79
|
+
version: SESSION_VERSION,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const isValidSession = (stored, endpoint) => {
|
|
83
|
+
return (
|
|
84
|
+
stored &&
|
|
85
|
+
typeof stored === 'object' &&
|
|
86
|
+
stored.version === SESSION_VERSION &&
|
|
87
|
+
stored.endpoint === endpoint &&
|
|
88
|
+
stored.clientInformationByIssuer &&
|
|
89
|
+
typeof stored.clientInformationByIssuer === 'object' &&
|
|
90
|
+
!Array.isArray(stored.clientInformationByIssuer) &&
|
|
91
|
+
stored.tokensByIssuer &&
|
|
92
|
+
typeof stored.tokensByIssuer === 'object' &&
|
|
93
|
+
!Array.isArray(stored.tokensByIssuer)
|
|
94
|
+
);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const readSession = async (endpoint, options) => {
|
|
98
|
+
const stored = await readPrivateJson(sessionPath(endpoint, options), options);
|
|
99
|
+
if (stored === null) return emptySession(endpoint);
|
|
100
|
+
|
|
101
|
+
if (!isValidSession(stored, endpoint)) {
|
|
102
|
+
throw new Error('The saved Rayrun CLI OAuth session is invalid. Run rayrun logout and retry.');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return stored;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const writeSession = async (session, options) => {
|
|
109
|
+
const target = sessionPath(session.endpoint, options);
|
|
110
|
+
await ensurePrivateDirectory(path.dirname(target));
|
|
111
|
+
await atomicWrite({
|
|
112
|
+
contents: Buffer.from(`${JSON.stringify(session, null, 2)}\n`),
|
|
113
|
+
mode: 0o600,
|
|
114
|
+
target,
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const processIsRunning = (processId) => {
|
|
119
|
+
try {
|
|
120
|
+
process.kill(processId, 0);
|
|
121
|
+
return true;
|
|
122
|
+
} catch (error) {
|
|
123
|
+
return !isFileSystemError(error, 'ESRCH');
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
class LockCollisionError extends Error {}
|
|
128
|
+
|
|
129
|
+
const readLockClaim = async (target) => {
|
|
130
|
+
try {
|
|
131
|
+
const parsed = JSON.parse(await readFile(target, 'utf8'));
|
|
132
|
+
return parsed &&
|
|
133
|
+
typeof parsed === 'object' &&
|
|
134
|
+
Number.isSafeInteger(parsed.processId) &&
|
|
135
|
+
parsed.processId > 0 &&
|
|
136
|
+
typeof parsed.hostname === 'string' &&
|
|
137
|
+
typeof parsed.nonce === 'string'
|
|
138
|
+
? parsed
|
|
139
|
+
: null;
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (isFileSystemError(error, 'ENOENT') || error instanceof SyntaxError) return null;
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const acquireSessionLock = async (
|
|
147
|
+
endpoint,
|
|
148
|
+
options,
|
|
149
|
+
{
|
|
150
|
+
afterActivation = async () => {},
|
|
151
|
+
afterActivationScan = async () => {},
|
|
152
|
+
beforeActivation = async () => {},
|
|
153
|
+
createNonce = () => randomBytes(16).toString('base64url'),
|
|
154
|
+
hostname = os.hostname(),
|
|
155
|
+
isProcessRunning = processIsRunning,
|
|
156
|
+
processId = process.pid,
|
|
157
|
+
waitForRetry = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
|
|
158
|
+
} = {},
|
|
159
|
+
) => {
|
|
160
|
+
const locksDirectory = path.join(executionRoot(options), 'locks');
|
|
161
|
+
const lockDirectory = path.join(locksDirectory, endpointKey(endpoint));
|
|
162
|
+
await ensurePrivateDirectory(lockDirectory);
|
|
163
|
+
|
|
164
|
+
const liveClaims = async () => {
|
|
165
|
+
const live = [];
|
|
166
|
+
for (const name of await readdir(lockDirectory)) {
|
|
167
|
+
const match = LOCK_CLAIM_REGEX.exec(name);
|
|
168
|
+
if (!match) continue;
|
|
169
|
+
const target = path.join(lockDirectory, name);
|
|
170
|
+
let metadata;
|
|
171
|
+
let stored;
|
|
172
|
+
try {
|
|
173
|
+
metadata = await lstat(target);
|
|
174
|
+
stored = await readLockClaim(target);
|
|
175
|
+
if (stored === null) {
|
|
176
|
+
await lstat(target);
|
|
177
|
+
}
|
|
178
|
+
} catch (error) {
|
|
179
|
+
if (isFileSystemError(error, 'ENOENT')) continue;
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
const claimIsValid =
|
|
183
|
+
metadata.isFile() && stored?.nonce === match[2] && stored?.hostname !== undefined;
|
|
184
|
+
if (!claimIsValid) {
|
|
185
|
+
throw new Error(`Rayrun CLI lock claim is invalid; remove it before retrying: ${target}`);
|
|
186
|
+
}
|
|
187
|
+
if (stored.hostname !== hostname) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
'Rayrun CLI execution storage must be local to one host. Use a local per-host RAYRUN_CONFIG_HOME.',
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
const claimIsLive = isProcessRunning(stored.processId);
|
|
193
|
+
if (!claimIsLive) {
|
|
194
|
+
// Claim filenames contain never-reused random generations, so cleanup cannot unlink a
|
|
195
|
+
// replacement created by another process (the ABA flaw of a canonical lock path).
|
|
196
|
+
await rm(target, { force: true });
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
live.push({ kind: match[1], name, target });
|
|
200
|
+
}
|
|
201
|
+
return live;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
for (let attempt = 0; attempt < LOCK_ACQUIRE_ATTEMPTS; attempt += 1) {
|
|
205
|
+
const claim = { hostname, nonce: createNonce(), processId };
|
|
206
|
+
const waitingName = `waiting-${claim.nonce}.json`;
|
|
207
|
+
const activeName = `active-${claim.nonce}.json`;
|
|
208
|
+
const waitingPath = path.join(lockDirectory, waitingName);
|
|
209
|
+
const activePath = path.join(lockDirectory, activeName);
|
|
210
|
+
try {
|
|
211
|
+
await atomicWrite({
|
|
212
|
+
contents: Buffer.from(`${JSON.stringify(claim)}\n`),
|
|
213
|
+
mode: 0o600,
|
|
214
|
+
target: waitingPath,
|
|
215
|
+
});
|
|
216
|
+
const initialClaims = await liveClaims();
|
|
217
|
+
const activeClaim = initialClaims.find(({ kind }) => kind === 'active');
|
|
218
|
+
if (activeClaim) {
|
|
219
|
+
throw new Error(
|
|
220
|
+
`Another Rayrun CLI execution command is using this endpoint. If none is running, remove the stale claim and retry: ${activeClaim.target}`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
const elected = initialClaims
|
|
224
|
+
.filter(({ kind }) => kind === 'waiting')
|
|
225
|
+
.sort((left, right) => left.name.localeCompare(right.name))[0];
|
|
226
|
+
if (elected.name !== waitingName) {
|
|
227
|
+
throw new Error(
|
|
228
|
+
`Another Rayrun CLI execution command is using this endpoint. If none is running, remove the stale claim and retry: ${elected.target}`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
await beforeActivation({ attempt });
|
|
233
|
+
await rename(waitingPath, activePath);
|
|
234
|
+
await afterActivation({ attempt });
|
|
235
|
+
const activeClaims = (await liveClaims()).filter(({ kind }) => kind === 'active');
|
|
236
|
+
await afterActivationScan({ activeClaims, attempt });
|
|
237
|
+
if (activeClaims.some(({ name }) => name !== activeName)) {
|
|
238
|
+
throw new LockCollisionError();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let released = false;
|
|
242
|
+
return async () => {
|
|
243
|
+
if (released) return;
|
|
244
|
+
released = true;
|
|
245
|
+
await rm(activePath, { force: true });
|
|
246
|
+
};
|
|
247
|
+
} catch (error) {
|
|
248
|
+
await rm(waitingPath, { force: true });
|
|
249
|
+
await rm(activePath, { force: true });
|
|
250
|
+
if (error instanceof LockCollisionError && attempt + 1 < LOCK_ACQUIRE_ATTEMPTS) {
|
|
251
|
+
await waitForRetry(5 + ((claim.nonce.codePointAt(0) ?? 0) % 20));
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (error instanceof LockCollisionError) {
|
|
255
|
+
throw new Error('Another Rayrun CLI execution command is using this endpoint.');
|
|
256
|
+
}
|
|
257
|
+
throw error;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
throw new Error('Another Rayrun CLI execution command is using this endpoint.');
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const secureEqual = (left, right) => {
|
|
264
|
+
const leftBuffer = Buffer.from(left);
|
|
265
|
+
const rightBuffer = Buffer.from(right);
|
|
266
|
+
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const callbackPage = (response, status, message) => {
|
|
270
|
+
response.writeHead(status, {
|
|
271
|
+
'cache-control': 'no-store',
|
|
272
|
+
'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'",
|
|
273
|
+
'content-type': 'text/html; charset=utf-8',
|
|
274
|
+
'x-content-type-options': 'nosniff',
|
|
275
|
+
});
|
|
276
|
+
response.end(
|
|
277
|
+
`<!doctype html><meta charset="utf-8"><title>Rayrun CLI</title><style>body{font:16px system-ui;max-width:42rem;margin:10vh auto;padding:1rem}</style><h1>Rayrun CLI</h1><p>${message}</p>`,
|
|
278
|
+
);
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
export const createLoopbackAuthorizationReceiver = async ({
|
|
282
|
+
createServerImplementation = createServer,
|
|
283
|
+
timeoutMilliseconds = AUTHORIZATION_TIMEOUT_MILLISECONDS,
|
|
284
|
+
} = {}) => {
|
|
285
|
+
let callbackSettled = false;
|
|
286
|
+
let expectedState;
|
|
287
|
+
let rejectCallback;
|
|
288
|
+
let resolveCallback;
|
|
289
|
+
let timeout;
|
|
290
|
+
let closed = false;
|
|
291
|
+
const callback = new Promise((resolve, reject) => {
|
|
292
|
+
rejectCallback = reject;
|
|
293
|
+
resolveCallback = resolve;
|
|
294
|
+
});
|
|
295
|
+
// The rejection is observed by waitForCallback, but a browser may cancel before connect reaches it.
|
|
296
|
+
void callback.catch(() => {});
|
|
297
|
+
|
|
298
|
+
const server = createServerImplementation((request, response) => {
|
|
299
|
+
if (request.method !== 'GET') {
|
|
300
|
+
callbackPage(response, 405, 'This callback accepts GET requests only.');
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const requestUrl = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
305
|
+
if (requestUrl.pathname !== '/oauth/callback') {
|
|
306
|
+
callbackPage(response, 404, 'This is not the Rayrun authorization callback.');
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (!expectedState || !secureEqual(requestUrl.searchParams.get('state') ?? '', expectedState)) {
|
|
310
|
+
callbackPage(response, 400, 'The authorization state did not match. Return to the terminal.');
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (callbackSettled) {
|
|
314
|
+
callbackPage(response, 409, 'This authorization callback was already used.');
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
callbackSettled = true;
|
|
319
|
+
clearTimeout(timeout);
|
|
320
|
+
if (requestUrl.searchParams.has('error') || !requestUrl.searchParams.has('code')) {
|
|
321
|
+
callbackPage(response, 400, 'Authorization was not completed. Return to the terminal.');
|
|
322
|
+
rejectCallback(new Error('Rayrun authorization was not completed.'));
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
callbackPage(response, 200, 'Authorization received. You can close this window.');
|
|
327
|
+
resolveCallback(requestUrl.searchParams);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
await new Promise((resolve, reject) => {
|
|
331
|
+
server.once('error', reject);
|
|
332
|
+
server.listen(0, '127.0.0.1', () => {
|
|
333
|
+
server.off('error', reject);
|
|
334
|
+
resolve();
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
const address = server.address();
|
|
338
|
+
if (!address || typeof address === 'string') {
|
|
339
|
+
server.close();
|
|
340
|
+
throw new Error('Rayrun CLI could not open a loopback OAuth callback.');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
close: async () => {
|
|
345
|
+
if (closed) return;
|
|
346
|
+
closed = true;
|
|
347
|
+
clearTimeout(timeout);
|
|
348
|
+
await new Promise((resolve, reject) => {
|
|
349
|
+
server.close((error) => {
|
|
350
|
+
if (error && error.code !== 'ERR_SERVER_NOT_RUNNING') reject(error);
|
|
351
|
+
else resolve();
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
},
|
|
355
|
+
redirectUrl: `http://127.0.0.1:${String(address.port)}/oauth/callback`,
|
|
356
|
+
waitForCallback: async (state) => {
|
|
357
|
+
expectedState = state;
|
|
358
|
+
timeout = setTimeout(() => {
|
|
359
|
+
if (callbackSettled) return;
|
|
360
|
+
callbackSettled = true;
|
|
361
|
+
rejectCallback(new Error('Rayrun authorization timed out after 5 minutes.'));
|
|
362
|
+
}, timeoutMilliseconds);
|
|
363
|
+
timeout.unref?.();
|
|
364
|
+
return await callback;
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
export const openExternalUrl = async (
|
|
370
|
+
url,
|
|
371
|
+
{
|
|
372
|
+
errorOutput = process.stderr,
|
|
373
|
+
noOpen = false,
|
|
374
|
+
platform = process.platform,
|
|
375
|
+
spawnImplementation = spawn,
|
|
376
|
+
} = {},
|
|
377
|
+
) => {
|
|
378
|
+
errorOutput.write(`Open: ${url}\n`);
|
|
379
|
+
if (noOpen) return false;
|
|
380
|
+
|
|
381
|
+
const command =
|
|
382
|
+
platform === 'darwin'
|
|
383
|
+
? { arguments_: [url], executable: 'open' }
|
|
384
|
+
: platform === 'win32'
|
|
385
|
+
? { arguments_: ['url.dll,FileProtocolHandler', url], executable: 'rundll32.exe' }
|
|
386
|
+
: { arguments_: [url], executable: 'xdg-open' };
|
|
387
|
+
|
|
388
|
+
return await new Promise((resolve) => {
|
|
389
|
+
const child = spawnImplementation(command.executable, command.arguments_, {
|
|
390
|
+
detached: true,
|
|
391
|
+
stdio: 'ignore',
|
|
392
|
+
});
|
|
393
|
+
child.once('error', () => resolve(false));
|
|
394
|
+
child.once('spawn', () => {
|
|
395
|
+
child.unref();
|
|
396
|
+
resolve(true);
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
export class FileOAuthClientProvider {
|
|
402
|
+
constructor({ onAuthorization, redirectUrl, session, state, storageOptions }) {
|
|
403
|
+
this.onAuthorization = onAuthorization;
|
|
404
|
+
this.redirectUrlValue = redirectUrl;
|
|
405
|
+
this.session = session;
|
|
406
|
+
this.stateValue = state;
|
|
407
|
+
this.storageOptions = storageOptions;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
get redirectUrl() {
|
|
411
|
+
return this.redirectUrlValue;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
get clientMetadata() {
|
|
415
|
+
return {
|
|
416
|
+
application_type: 'native',
|
|
417
|
+
client_name: 'Rayrun CLI',
|
|
418
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
419
|
+
redirect_uris: [this.redirectUrlValue],
|
|
420
|
+
response_types: ['code'],
|
|
421
|
+
scope: 'mcp:tools',
|
|
422
|
+
token_endpoint_auth_method: 'none',
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
state() {
|
|
427
|
+
return this.stateValue;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
clientInformation(context) {
|
|
431
|
+
const issuer = context?.issuer ?? this.session.latestClientIssuer;
|
|
432
|
+
return issuer ? this.session.clientInformationByIssuer[issuer] : undefined;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async saveClientInformation(clientInformation, context) {
|
|
436
|
+
const issuer = context?.issuer ?? clientInformation.issuer;
|
|
437
|
+
if (!issuer) throw new Error('Rayrun OAuth client registration did not identify its issuer.');
|
|
438
|
+
this.session.clientInformationByIssuer[issuer] = clientInformation;
|
|
439
|
+
this.session.latestClientIssuer = issuer;
|
|
440
|
+
await writeSession(this.session, this.storageOptions);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
tokens(context) {
|
|
444
|
+
const issuer = context?.issuer ?? this.session.latestTokenIssuer;
|
|
445
|
+
return issuer ? this.session.tokensByIssuer[issuer] : undefined;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async saveTokens(tokens, context) {
|
|
449
|
+
const issuer = context?.issuer ?? tokens.issuer;
|
|
450
|
+
if (!issuer) throw new Error('Rayrun OAuth tokens did not identify their issuer.');
|
|
451
|
+
this.session.tokensByIssuer[issuer] = tokens;
|
|
452
|
+
this.session.latestTokenIssuer = issuer;
|
|
453
|
+
this.session.verifier = null;
|
|
454
|
+
await writeSession(this.session, this.storageOptions);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async redirectToAuthorization(authorizationUrl) {
|
|
458
|
+
await this.onAuthorization(authorizationUrl);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async saveCodeVerifier(verifier) {
|
|
462
|
+
this.session.verifier = verifier;
|
|
463
|
+
await writeSession(this.session, this.storageOptions);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
codeVerifier() {
|
|
467
|
+
if (!this.session.verifier) throw new Error('The Rayrun OAuth verifier is missing.');
|
|
468
|
+
return this.session.verifier;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async saveDiscoveryState(discoveryState) {
|
|
472
|
+
this.session.discoveryState = discoveryState;
|
|
473
|
+
await writeSession(this.session, this.storageOptions);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
discoveryState() {
|
|
477
|
+
return this.session.discoveryState ?? undefined;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async invalidateCredentials(scope) {
|
|
481
|
+
if (scope === 'all' || scope === 'client') {
|
|
482
|
+
this.session.clientInformationByIssuer = {};
|
|
483
|
+
this.session.latestClientIssuer = null;
|
|
484
|
+
}
|
|
485
|
+
if (scope === 'all' || scope === 'tokens') {
|
|
486
|
+
this.session.tokensByIssuer = {};
|
|
487
|
+
this.session.latestTokenIssuer = null;
|
|
488
|
+
}
|
|
489
|
+
if (scope === 'all' || scope === 'verifier') this.session.verifier = null;
|
|
490
|
+
if (scope === 'all' || scope === 'discovery') this.session.discoveryState = null;
|
|
491
|
+
await writeSession(this.session, this.storageOptions);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const createMcpClient = ({ version }) => {
|
|
496
|
+
const client = new Client(
|
|
497
|
+
{ name: 'rayrun-cli', version },
|
|
498
|
+
{
|
|
499
|
+
inputRequired: { autoFulfill: false },
|
|
500
|
+
versionNegotiation: { mode: 'auto' },
|
|
501
|
+
},
|
|
502
|
+
);
|
|
503
|
+
client.registerCapabilities({ elicitation: { url: {} } });
|
|
504
|
+
return client;
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
export const connectExecutionMcp = async ({
|
|
508
|
+
createClient = createMcpClient,
|
|
509
|
+
createReceiver = createLoopbackAuthorizationReceiver,
|
|
510
|
+
createTransport = ({ endpoint: transportEndpoint, provider }) =>
|
|
511
|
+
new StreamableHTTPClientTransport(new URL(transportEndpoint), { authProvider: provider }),
|
|
512
|
+
endpoint,
|
|
513
|
+
errorOutput = process.stderr,
|
|
514
|
+
lockOptions,
|
|
515
|
+
noOpen = false,
|
|
516
|
+
openUrl = openExternalUrl,
|
|
517
|
+
storageOptions,
|
|
518
|
+
version,
|
|
519
|
+
}) => {
|
|
520
|
+
const releaseLock = await acquireSessionLock(endpoint, storageOptions, lockOptions);
|
|
521
|
+
let receiver;
|
|
522
|
+
let client;
|
|
523
|
+
let transport;
|
|
524
|
+
|
|
525
|
+
try {
|
|
526
|
+
const session = await readSession(endpoint, storageOptions);
|
|
527
|
+
receiver = await createReceiver();
|
|
528
|
+
const state = randomBytes(32).toString('base64url');
|
|
529
|
+
const provider = new FileOAuthClientProvider({
|
|
530
|
+
onAuthorization: async (authorizationUrl) => {
|
|
531
|
+
await openUrl(String(authorizationUrl), { errorOutput, noOpen });
|
|
532
|
+
},
|
|
533
|
+
redirectUrl: receiver.redirectUrl,
|
|
534
|
+
session,
|
|
535
|
+
state,
|
|
536
|
+
storageOptions,
|
|
537
|
+
});
|
|
538
|
+
client = createClient({ version });
|
|
539
|
+
transport = createTransport({ endpoint, provider });
|
|
540
|
+
// Arm the state check before the browser can complete an already-authenticated redirect.
|
|
541
|
+
const callback = receiver.waitForCallback(state);
|
|
542
|
+
void callback.catch(() => {});
|
|
543
|
+
|
|
544
|
+
try {
|
|
545
|
+
await client.connect(transport);
|
|
546
|
+
} catch (error) {
|
|
547
|
+
if (!(error instanceof UnauthorizedError)) throw error;
|
|
548
|
+
const callbackParameters = await callback;
|
|
549
|
+
await transport.finishAuth(callbackParameters);
|
|
550
|
+
await transport.close().catch(() => {});
|
|
551
|
+
transport = createTransport({ endpoint, provider });
|
|
552
|
+
await client.connect(transport);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
await receiver.close();
|
|
556
|
+
receiver = undefined;
|
|
557
|
+
let closed = false;
|
|
558
|
+
|
|
559
|
+
return {
|
|
560
|
+
client,
|
|
561
|
+
close: async () => {
|
|
562
|
+
if (closed) return;
|
|
563
|
+
closed = true;
|
|
564
|
+
try {
|
|
565
|
+
await transport.terminateSession().catch(() => {});
|
|
566
|
+
await client.close();
|
|
567
|
+
} finally {
|
|
568
|
+
await releaseLock();
|
|
569
|
+
}
|
|
570
|
+
},
|
|
571
|
+
};
|
|
572
|
+
} catch (error) {
|
|
573
|
+
await receiver?.close().catch(() => {});
|
|
574
|
+
await client?.close().catch(() => {});
|
|
575
|
+
await releaseLock();
|
|
576
|
+
throw error;
|
|
577
|
+
}
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
export const clearOAuthSession = async (endpoint, options = {}, whileLocked) => {
|
|
581
|
+
const releaseLock = await acquireSessionLock(endpoint, options);
|
|
582
|
+
try {
|
|
583
|
+
const target = sessionPath(endpoint, options);
|
|
584
|
+
let stored;
|
|
585
|
+
try {
|
|
586
|
+
stored = await readPrivateJson(target, options);
|
|
587
|
+
} catch (error) {
|
|
588
|
+
if (!(error instanceof SyntaxError)) throw error;
|
|
589
|
+
stored = null;
|
|
590
|
+
}
|
|
591
|
+
const session = isValidSession(stored, endpoint) ? stored : emptySession(endpoint);
|
|
592
|
+
await assertNoSymbolicLinkComponents(target);
|
|
593
|
+
await rm(target, { force: true });
|
|
594
|
+
await whileLocked?.();
|
|
595
|
+
return session;
|
|
596
|
+
} finally {
|
|
597
|
+
await releaseLock();
|
|
598
|
+
}
|
|
599
|
+
};
|
package/src/setup.js
CHANGED
|
@@ -9,7 +9,7 @@ import path from 'node:path';
|
|
|
9
9
|
|
|
10
10
|
const checksum = (value) => createHash('sha256').update(value).digest('hex');
|
|
11
11
|
|
|
12
|
-
const assertNoSymbolicLinkComponents = async (target) => {
|
|
12
|
+
export const assertNoSymbolicLinkComponents = async (target) => {
|
|
13
13
|
const absoluteTarget = path.resolve(target);
|
|
14
14
|
const root = path.parse(absoluteTarget).root;
|
|
15
15
|
const components = path.relative(root, absoluteTarget).split(path.sep).filter(Boolean);
|
|
@@ -52,7 +52,7 @@ const readSnapshot = async (target) => {
|
|
|
52
52
|
}
|
|
53
53
|
};
|
|
54
54
|
|
|
55
|
-
const atomicWrite = async ({ contents, mode, target }) => {
|
|
55
|
+
export const atomicWrite = async ({ contents, mode, target }) => {
|
|
56
56
|
await assertNoSymbolicLinkComponents(target);
|
|
57
57
|
await mkdir(path.dirname(target), { mode: 0o700, recursive: true });
|
|
58
58
|
await assertNoSymbolicLinkComponents(path.dirname(target));
|