@slashfi/agents-sdk 0.39.0 → 0.40.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/dist/adk.d.ts +13 -21
- package/dist/adk.d.ts.map +1 -1
- package/dist/adk.js +354 -51
- package/dist/adk.js.map +1 -1
- package/dist/cjs/codegen.js +1 -1
- package/dist/cjs/codegen.js.map +1 -1
- package/dist/cjs/config-store.js +727 -0
- package/dist/cjs/config-store.js.map +1 -0
- package/dist/cjs/index.js +7 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/local-fs.js +63 -0
- package/dist/cjs/local-fs.js.map +1 -0
- package/dist/cjs/registry.js +3 -0
- package/dist/cjs/registry.js.map +1 -1
- package/dist/codegen.d.ts +1 -0
- package/dist/codegen.d.ts.map +1 -1
- package/dist/codegen.js +1 -1
- package/dist/codegen.js.map +1 -1
- package/dist/config-store.d.ts +137 -0
- package/dist/config-store.d.ts.map +1 -0
- package/dist/config-store.js +691 -0
- package/dist/config-store.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/local-fs.d.ts +18 -0
- package/dist/local-fs.d.ts.map +1 -0
- package/dist/local-fs.js +59 -0
- package/dist/local-fs.js.map +1 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +3 -0
- package/dist/registry.js.map +1 -1
- package/dist/validate.d.ts +4 -4
- package/package.json +1 -1
- package/src/adk.ts +313 -53
- package/src/codegen.ts +2 -2
- package/src/config-store.ts +910 -0
- package/src/index.ts +14 -0
- package/src/local-fs.ts +68 -0
- package/src/registry.ts +3 -0
|
@@ -0,0 +1,910 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADK Config Store — programmatic API for managing registries and refs.
|
|
3
|
+
*
|
|
4
|
+
* Provides a `createAdk(fs, options?)` factory that returns an object
|
|
5
|
+
* with `registry.*` and `ref.*` namespaces. Backed by a pluggable
|
|
6
|
+
* FsStore so it works with local filesystem (CLI) or VCS (atlas).
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* import { createAdk, createLocalFsStore } from '@slashfi/agents-sdk';
|
|
11
|
+
*
|
|
12
|
+
* const adk = createAdk(createLocalFsStore());
|
|
13
|
+
* await adk.registry.add({ url: 'https://registry.slash.com', name: 'slash' });
|
|
14
|
+
* await adk.registry.browse('slash');
|
|
15
|
+
* await adk.ref.add({ ref: 'notion', registry: 'slash' });
|
|
16
|
+
* await adk.ref.call('notion', 'notion-search', { query: 'hello' });
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { FsStore } from "./agent-definitions/config.js";
|
|
21
|
+
import type {
|
|
22
|
+
ConsumerConfig,
|
|
23
|
+
RefEntry,
|
|
24
|
+
RegistryEntry,
|
|
25
|
+
ResolvedRef,
|
|
26
|
+
} from "./define-config.js";
|
|
27
|
+
import { normalizeRef } from "./define-config.js";
|
|
28
|
+
import { createRegistryConsumer } from "./registry-consumer.js";
|
|
29
|
+
import type {
|
|
30
|
+
AgentListing,
|
|
31
|
+
RegistryConfiguration,
|
|
32
|
+
RegistryConsumer,
|
|
33
|
+
} from "./registry-consumer.js";
|
|
34
|
+
import type { CallAgentResponse, SecuritySchemeSummary } from "./types.js";
|
|
35
|
+
import { decryptSecret, encryptSecret } from "./crypto.js";
|
|
36
|
+
import {
|
|
37
|
+
discoverOAuthMetadata,
|
|
38
|
+
dynamicClientRegistration,
|
|
39
|
+
buildOAuthAuthorizeUrl,
|
|
40
|
+
exchangeCodeForTokens,
|
|
41
|
+
} from "./mcp-client.js";
|
|
42
|
+
|
|
43
|
+
const CONFIG_PATH = "consumer-config.json";
|
|
44
|
+
const SECRET_PREFIX = "secret:";
|
|
45
|
+
|
|
46
|
+
// ============================================
|
|
47
|
+
// Types
|
|
48
|
+
// ============================================
|
|
49
|
+
|
|
50
|
+
export interface AdkOptions {
|
|
51
|
+
/** Passphrase for encrypting/decrypting secret: values */
|
|
52
|
+
encryptionKey?: string;
|
|
53
|
+
/** Bearer token for authenticated registries */
|
|
54
|
+
token?: string;
|
|
55
|
+
/**
|
|
56
|
+
* OAuth callback URL. Defaults to http://localhost:8919/callback.
|
|
57
|
+
* Set this to your server's callback endpoint in non-local environments
|
|
58
|
+
* (e.g. atlas), then call adk.handleCallback() when it arrives.
|
|
59
|
+
*/
|
|
60
|
+
oauthCallbackUrl?: string;
|
|
61
|
+
/** Port for local OAuth callback server (default 8919) */
|
|
62
|
+
oauthCallbackPort?: number;
|
|
63
|
+
/** Client name for OAuth dynamic client registration (default: "Claude Code") */
|
|
64
|
+
oauthClientName?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface RegistryTestResult {
|
|
68
|
+
name: string;
|
|
69
|
+
url: string;
|
|
70
|
+
status: "active" | "error";
|
|
71
|
+
issuer?: string;
|
|
72
|
+
error?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface AdkRegistryApi {
|
|
76
|
+
add(entry: RegistryEntry): Promise<void>;
|
|
77
|
+
remove(nameOrUrl: string): Promise<boolean>;
|
|
78
|
+
list(): Promise<RegistryEntry[]>;
|
|
79
|
+
get(name: string): Promise<RegistryEntry | null>;
|
|
80
|
+
update(name: string, updates: Partial<RegistryEntry>): Promise<boolean>;
|
|
81
|
+
browse(name: string, query?: string): Promise<AgentListing[]>;
|
|
82
|
+
inspect(name: string): Promise<RegistryConfiguration>;
|
|
83
|
+
test(name?: string): Promise<RegistryTestResult[]>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Describes what auth a ref needs and what's already provided */
|
|
87
|
+
export interface RefAuthStatus {
|
|
88
|
+
name: string;
|
|
89
|
+
security: SecuritySchemeSummary | null;
|
|
90
|
+
/** All required secret fields are present (may be untested) */
|
|
91
|
+
complete: boolean;
|
|
92
|
+
/** Fields that still need to be provided */
|
|
93
|
+
missing: string[];
|
|
94
|
+
/** Fields already stored */
|
|
95
|
+
present: string[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface OAuthResult {
|
|
99
|
+
accessToken: string;
|
|
100
|
+
refreshToken?: string;
|
|
101
|
+
expiresIn?: number;
|
|
102
|
+
clientId: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface AuthStartResult {
|
|
106
|
+
type: string;
|
|
107
|
+
complete: boolean;
|
|
108
|
+
/** For OAuth: the URL to open in the browser */
|
|
109
|
+
authorizeUrl?: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface AdkRefApi {
|
|
113
|
+
add(entry: RefEntry): Promise<{ security: SecuritySchemeSummary | null }>;
|
|
114
|
+
remove(name: string): Promise<boolean>;
|
|
115
|
+
list(): Promise<ResolvedRef[]>;
|
|
116
|
+
get(name: string): Promise<RefEntry | null>;
|
|
117
|
+
update(name: string, updates: Partial<RefEntry>): Promise<boolean>;
|
|
118
|
+
inspect(name: string, options?: { full?: boolean }): Promise<AgentListing | null>;
|
|
119
|
+
call(name: string, tool: string, params?: Record<string, unknown>): Promise<CallAgentResponse>;
|
|
120
|
+
resources(name: string): Promise<CallAgentResponse>;
|
|
121
|
+
read(name: string, uris: string[]): Promise<CallAgentResponse>;
|
|
122
|
+
/** Check auth status — what's needed vs what's stored */
|
|
123
|
+
authStatus(name: string): Promise<RefAuthStatus>;
|
|
124
|
+
/**
|
|
125
|
+
* Start the auth flow for a ref. Returns the authorize URL for OAuth.
|
|
126
|
+
* Call adk.handleCallback() when the callback arrives, or use
|
|
127
|
+
* adk.ref.authLocal() to spin up a local server and block.
|
|
128
|
+
*/
|
|
129
|
+
auth(name: string, opts?: {
|
|
130
|
+
/** For API key / bearer auth: the key/token value */
|
|
131
|
+
apiKey?: string;
|
|
132
|
+
}): Promise<AuthStartResult>;
|
|
133
|
+
/**
|
|
134
|
+
* Run the full OAuth flow locally: start auth, spin up a callback
|
|
135
|
+
* server, open the browser, wait for the redirect, exchange tokens.
|
|
136
|
+
* Resolves when auth is complete or times out.
|
|
137
|
+
*/
|
|
138
|
+
authLocal(name: string, opts?: {
|
|
139
|
+
/** Called with the authorize URL (e.g. to open in browser) */
|
|
140
|
+
onAuthorizeUrl?: (url: string) => void;
|
|
141
|
+
/** Timeout in ms (default 300_000 = 5 min) */
|
|
142
|
+
timeoutMs?: number;
|
|
143
|
+
}): Promise<{ complete: boolean }>;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface Adk {
|
|
147
|
+
registry: AdkRegistryApi;
|
|
148
|
+
ref: AdkRefApi;
|
|
149
|
+
readConfig(): Promise<ConsumerConfig>;
|
|
150
|
+
writeConfig(config: ConsumerConfig): Promise<void>;
|
|
151
|
+
/**
|
|
152
|
+
* Handle an OAuth callback. Works in any environment.
|
|
153
|
+
* Parse the callback query params and pass them here.
|
|
154
|
+
* @returns the ref name and whether auth is complete
|
|
155
|
+
*/
|
|
156
|
+
handleCallback(params: { code: string; state: string }): Promise<{ refName: string; complete: boolean }>;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ============================================
|
|
160
|
+
// Internal helpers
|
|
161
|
+
// ============================================
|
|
162
|
+
|
|
163
|
+
function refName(entry: RefEntry): string {
|
|
164
|
+
return normalizeRef(entry).name;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function registryDisplayName(r: string | RegistryEntry): string {
|
|
168
|
+
return typeof r === "string" ? r : (r.name ?? r.url);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function registryUrl(r: string | RegistryEntry): string {
|
|
172
|
+
return typeof r === "string" ? r : r.url;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function findRegistry(
|
|
176
|
+
registries: Array<string | RegistryEntry>,
|
|
177
|
+
nameOrUrl: string,
|
|
178
|
+
): (string | RegistryEntry) | undefined {
|
|
179
|
+
return registries.find((r) => {
|
|
180
|
+
if (typeof r === "string") return r === nameOrUrl;
|
|
181
|
+
return (r.name ?? r.url) === nameOrUrl || r.url === nameOrUrl;
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Walk an object and decrypt any string values starting with "secret:".
|
|
187
|
+
*/
|
|
188
|
+
async function decryptConfigSecrets(
|
|
189
|
+
obj: Record<string, unknown>,
|
|
190
|
+
encryptionKey: string,
|
|
191
|
+
): Promise<Record<string, unknown>> {
|
|
192
|
+
const result: Record<string, unknown> = {};
|
|
193
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
194
|
+
if (typeof value === "string" && value.startsWith(SECRET_PREFIX)) {
|
|
195
|
+
result[key] = await decryptSecret(value.slice(SECRET_PREFIX.length), encryptionKey);
|
|
196
|
+
} else if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
197
|
+
result[key] = await decryptConfigSecrets(value as Record<string, unknown>, encryptionKey);
|
|
198
|
+
} else {
|
|
199
|
+
result[key] = value;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return result;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ============================================
|
|
206
|
+
// Factory
|
|
207
|
+
// ============================================
|
|
208
|
+
|
|
209
|
+
export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
|
|
210
|
+
|
|
211
|
+
async function readConfig(): Promise<ConsumerConfig> {
|
|
212
|
+
const content = await fs.readFile(CONFIG_PATH);
|
|
213
|
+
if (!content) return {};
|
|
214
|
+
try {
|
|
215
|
+
return JSON.parse(content) as ConsumerConfig;
|
|
216
|
+
} catch {
|
|
217
|
+
return {};
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function writeConfig(config: ConsumerConfig): Promise<void> {
|
|
222
|
+
await fs.writeFile(CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Store a secret value in a ref's config, encrypted if encryptionKey is set.
|
|
227
|
+
* The value is stored inline as "secret:<encrypted>" in consumer-config.json.
|
|
228
|
+
*/
|
|
229
|
+
async function storeRefSecret(name: string, key: string, value: string): Promise<void> {
|
|
230
|
+
const stored = options.encryptionKey
|
|
231
|
+
? `${SECRET_PREFIX}${await encryptSecret(value, options.encryptionKey)}`
|
|
232
|
+
: value;
|
|
233
|
+
const config = await readConfig();
|
|
234
|
+
const refs = (config.refs ?? []).map((r): RefEntry => {
|
|
235
|
+
if (refName(r) !== name) return r;
|
|
236
|
+
return { ...r, config: { ...r.config, [key]: stored } };
|
|
237
|
+
});
|
|
238
|
+
await writeConfig({ ...config, refs });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function readRefSecret(name: string, key: string): Promise<string | null> {
|
|
242
|
+
const config = await readConfig();
|
|
243
|
+
const entry = (config.refs ?? []).find((r) => refName(r) === name);
|
|
244
|
+
const value = entry?.config?.[key];
|
|
245
|
+
if (typeof value !== "string") return null;
|
|
246
|
+
if (value.startsWith(SECRET_PREFIX) && options.encryptionKey) {
|
|
247
|
+
return decryptSecret(value.slice(SECRET_PREFIX.length), options.encryptionKey);
|
|
248
|
+
}
|
|
249
|
+
return value;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
const PENDING_OAUTH_PATH = "pending-oauth.json";
|
|
255
|
+
|
|
256
|
+
interface PendingOAuthState {
|
|
257
|
+
refName: string;
|
|
258
|
+
codeVerifier: string;
|
|
259
|
+
clientId: string;
|
|
260
|
+
clientSecret?: string;
|
|
261
|
+
tokenEndpoint: string;
|
|
262
|
+
redirectUri: string;
|
|
263
|
+
createdAt: number;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function readPendingOAuth(): Promise<Record<string, PendingOAuthState>> {
|
|
267
|
+
const content = await fs.readFile(PENDING_OAUTH_PATH);
|
|
268
|
+
if (!content) return {};
|
|
269
|
+
try { return JSON.parse(content); } catch { return {}; }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function writePendingOAuth(pending: Record<string, PendingOAuthState>): Promise<void> {
|
|
273
|
+
await fs.writeFile(PENDING_OAUTH_PATH, JSON.stringify(pending, null, 2));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function storePendingOAuth(state: string, data: PendingOAuthState): Promise<void> {
|
|
277
|
+
const pending = await readPendingOAuth();
|
|
278
|
+
pending[state] = data;
|
|
279
|
+
await writePendingOAuth(pending);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function consumePendingOAuth(state: string): Promise<PendingOAuthState | null> {
|
|
283
|
+
const pending = await readPendingOAuth();
|
|
284
|
+
const data = pending[state] ?? null;
|
|
285
|
+
if (data) {
|
|
286
|
+
delete pending[state];
|
|
287
|
+
await writePendingOAuth(pending);
|
|
288
|
+
}
|
|
289
|
+
return data;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Call an MCP server directly with a bearer token (bypasses registry). */
|
|
293
|
+
async function callMcpDirect(
|
|
294
|
+
serverUrl: string,
|
|
295
|
+
toolName: string,
|
|
296
|
+
params: Record<string, unknown>,
|
|
297
|
+
token: string,
|
|
298
|
+
): Promise<CallAgentResponse> {
|
|
299
|
+
const url = serverUrl.replace(/\/$/, "");
|
|
300
|
+
const headers: Record<string, string> = {
|
|
301
|
+
"Content-Type": "application/json",
|
|
302
|
+
Accept: "application/json, text/event-stream",
|
|
303
|
+
Authorization: `Bearer ${token}`,
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
let reqId = 0;
|
|
307
|
+
let sessionId: string | undefined;
|
|
308
|
+
async function rpc(method: string, rpcParams?: Record<string, unknown>) {
|
|
309
|
+
const reqHeaders = { ...headers, ...(sessionId ? { "Mcp-Session-Id": sessionId } : {}) };
|
|
310
|
+
const res = await globalThis.fetch(url, {
|
|
311
|
+
method: "POST",
|
|
312
|
+
headers: reqHeaders,
|
|
313
|
+
body: JSON.stringify({
|
|
314
|
+
jsonrpc: "2.0",
|
|
315
|
+
id: ++reqId,
|
|
316
|
+
method,
|
|
317
|
+
...(rpcParams && { params: rpcParams }),
|
|
318
|
+
}),
|
|
319
|
+
});
|
|
320
|
+
if (!res.ok) {
|
|
321
|
+
throw new Error(`MCP ${method} failed (${res.status}): ${await res.text().catch(() => "unknown")}`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
325
|
+
|
|
326
|
+
// Capture session ID from response
|
|
327
|
+
const newSessionId = res.headers.get("mcp-session-id");
|
|
328
|
+
if (newSessionId) sessionId = newSessionId;
|
|
329
|
+
|
|
330
|
+
// SSE response — parse events to find the JSON-RPC result
|
|
331
|
+
if (contentType.includes("text/event-stream")) {
|
|
332
|
+
const text = await res.text();
|
|
333
|
+
const lines = text.split("\n");
|
|
334
|
+
for (const line of lines) {
|
|
335
|
+
if (line.startsWith("data: ")) {
|
|
336
|
+
try {
|
|
337
|
+
const json = JSON.parse(line.slice(6));
|
|
338
|
+
if (json.id === reqId) {
|
|
339
|
+
if (json.error) throw new Error(`MCP RPC error: ${json.error.message}`);
|
|
340
|
+
return json.result;
|
|
341
|
+
}
|
|
342
|
+
} catch (e) {
|
|
343
|
+
if (e instanceof Error && e.message.startsWith("MCP RPC")) throw e;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return undefined;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const json = await res.json() as { result?: unknown; error?: { message: string } };
|
|
351
|
+
if (json.error) throw new Error(`MCP RPC error: ${json.error.message}`);
|
|
352
|
+
return json.result;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
try {
|
|
356
|
+
await rpc("initialize", {
|
|
357
|
+
protocolVersion: "2024-11-05",
|
|
358
|
+
capabilities: {},
|
|
359
|
+
clientInfo: { name: "adk", version: "1.0.0" },
|
|
360
|
+
});
|
|
361
|
+
await rpc("notifications/initialized").catch(() => {});
|
|
362
|
+
|
|
363
|
+
const result = await rpc("tools/call", { name: toolName, arguments: params }) as
|
|
364
|
+
{ content?: Array<{ type: string; text?: string }>; isError?: boolean };
|
|
365
|
+
|
|
366
|
+
const textContent = result?.content?.find((c) => c.type === "text");
|
|
367
|
+
if (textContent?.text) {
|
|
368
|
+
try { return { success: true, result: JSON.parse(textContent.text) } as CallAgentResponse; }
|
|
369
|
+
catch { return { success: true, result: textContent.text } as CallAgentResponse; }
|
|
370
|
+
}
|
|
371
|
+
return { success: true, result } as CallAgentResponse;
|
|
372
|
+
} catch (err) {
|
|
373
|
+
return { success: false, error: err instanceof Error ? err.message : String(err) } as CallAgentResponse;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function callbackUrl(): string {
|
|
378
|
+
const port = options.oauthCallbackPort ?? 8919;
|
|
379
|
+
return options.oauthCallbackUrl ?? `http://localhost:${port}/callback`;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Try fetching a URL directly as OAuth metadata (it may already be a discovery URL). */
|
|
383
|
+
async function tryFetchOAuthMetadata(url: string): Promise<import("./mcp-client.js").OAuthServerMetadata | null> {
|
|
384
|
+
try {
|
|
385
|
+
const res = await globalThis.fetch(url);
|
|
386
|
+
if (!res.ok) return null;
|
|
387
|
+
const data = await res.json() as Record<string, unknown>;
|
|
388
|
+
if (data.authorization_endpoint && data.token_endpoint) {
|
|
389
|
+
return data as unknown as import("./mcp-client.js").OAuthServerMetadata;
|
|
390
|
+
}
|
|
391
|
+
return null;
|
|
392
|
+
} catch {
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Build a registryConsumer from the current config.
|
|
399
|
+
* Decrypts secret: values in registry headers/auth before connecting.
|
|
400
|
+
*/
|
|
401
|
+
async function buildConsumer(
|
|
402
|
+
registryFilter?: string,
|
|
403
|
+
): Promise<RegistryConsumer> {
|
|
404
|
+
const config = await readConfig();
|
|
405
|
+
let registries = config.registries ?? [];
|
|
406
|
+
|
|
407
|
+
if (registryFilter) {
|
|
408
|
+
const target = findRegistry(registries, registryFilter);
|
|
409
|
+
if (!target) {
|
|
410
|
+
throw new Error(
|
|
411
|
+
`Registry "${registryFilter}" not found. Available: ${registries.map(registryDisplayName).join(", ")}`,
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
registries = [target];
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Decrypt secret: values in registry entries if encryption key is set
|
|
418
|
+
const resolved = options.encryptionKey
|
|
419
|
+
? await Promise.all(
|
|
420
|
+
registries.map(async (r) => {
|
|
421
|
+
if (typeof r === "string") return r;
|
|
422
|
+
const decrypted = await decryptConfigSecrets(
|
|
423
|
+
r as unknown as Record<string, unknown>,
|
|
424
|
+
options.encryptionKey!,
|
|
425
|
+
);
|
|
426
|
+
return decrypted as unknown as RegistryEntry;
|
|
427
|
+
}),
|
|
428
|
+
)
|
|
429
|
+
: registries;
|
|
430
|
+
|
|
431
|
+
return createRegistryConsumer(
|
|
432
|
+
{ registries: resolved, refs: config.refs ?? [] },
|
|
433
|
+
{ token: options.token },
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ==========================================
|
|
438
|
+
// Registry API
|
|
439
|
+
// ==========================================
|
|
440
|
+
|
|
441
|
+
const registry: AdkRegistryApi = {
|
|
442
|
+
async add(entry: RegistryEntry): Promise<void> {
|
|
443
|
+
const config = await readConfig();
|
|
444
|
+
const alias = entry.name ?? entry.url;
|
|
445
|
+
const registries = (config.registries ?? []).filter(
|
|
446
|
+
(r) => registryDisplayName(r) !== alias,
|
|
447
|
+
);
|
|
448
|
+
registries.push(entry);
|
|
449
|
+
await writeConfig({ ...config, registries });
|
|
450
|
+
},
|
|
451
|
+
|
|
452
|
+
async remove(nameOrUrl: string): Promise<boolean> {
|
|
453
|
+
const config = await readConfig();
|
|
454
|
+
if (!config.registries?.length) return false;
|
|
455
|
+
const before = config.registries.length;
|
|
456
|
+
const registries = config.registries.filter(
|
|
457
|
+
(r) => registryDisplayName(r) !== nameOrUrl && registryUrl(r) !== nameOrUrl,
|
|
458
|
+
);
|
|
459
|
+
if (registries.length === before) return false;
|
|
460
|
+
await writeConfig({ ...config, registries });
|
|
461
|
+
return true;
|
|
462
|
+
},
|
|
463
|
+
|
|
464
|
+
async list(): Promise<RegistryEntry[]> {
|
|
465
|
+
const config = await readConfig();
|
|
466
|
+
return (config.registries ?? []).map((r) =>
|
|
467
|
+
typeof r === "string" ? { url: r } : r,
|
|
468
|
+
);
|
|
469
|
+
},
|
|
470
|
+
|
|
471
|
+
async get(name: string): Promise<RegistryEntry | null> {
|
|
472
|
+
const config = await readConfig();
|
|
473
|
+
const target = findRegistry(config.registries ?? [], name);
|
|
474
|
+
if (!target) return null;
|
|
475
|
+
return typeof target === "string" ? { url: target } : target;
|
|
476
|
+
},
|
|
477
|
+
|
|
478
|
+
async update(name: string, updates: Partial<RegistryEntry>): Promise<boolean> {
|
|
479
|
+
const config = await readConfig();
|
|
480
|
+
if (!config.registries?.length) return false;
|
|
481
|
+
let found = false;
|
|
482
|
+
const registries = config.registries.map((r): string | RegistryEntry => {
|
|
483
|
+
const rName = registryDisplayName(r);
|
|
484
|
+
if (rName !== name && registryUrl(r) !== name) return r;
|
|
485
|
+
found = true;
|
|
486
|
+
const existing: RegistryEntry = typeof r === "string" ? { url: r } : { ...r };
|
|
487
|
+
if (updates.url) existing.url = updates.url;
|
|
488
|
+
if (updates.name) existing.name = updates.name;
|
|
489
|
+
if (updates.auth) existing.auth = updates.auth;
|
|
490
|
+
if (updates.headers) existing.headers = { ...existing.headers, ...updates.headers };
|
|
491
|
+
return existing;
|
|
492
|
+
});
|
|
493
|
+
if (!found) return false;
|
|
494
|
+
await writeConfig({ ...config, registries });
|
|
495
|
+
return true;
|
|
496
|
+
},
|
|
497
|
+
|
|
498
|
+
async browse(name: string, query?: string): Promise<AgentListing[]> {
|
|
499
|
+
const consumer = await buildConsumer(name);
|
|
500
|
+
const config = await readConfig();
|
|
501
|
+
const target = findRegistry(config.registries ?? [], name);
|
|
502
|
+
const url = target ? registryUrl(target) : name;
|
|
503
|
+
return consumer.browse(url, query);
|
|
504
|
+
},
|
|
505
|
+
|
|
506
|
+
async inspect(name: string): Promise<RegistryConfiguration> {
|
|
507
|
+
const consumer = await buildConsumer(name);
|
|
508
|
+
const config = await readConfig();
|
|
509
|
+
const target = findRegistry(config.registries ?? [], name);
|
|
510
|
+
const url = target ? registryUrl(target) : name;
|
|
511
|
+
return consumer.discover(url);
|
|
512
|
+
},
|
|
513
|
+
|
|
514
|
+
async test(name?: string): Promise<RegistryTestResult[]> {
|
|
515
|
+
const config = await readConfig();
|
|
516
|
+
const registries = config.registries ?? [];
|
|
517
|
+
const targets = name
|
|
518
|
+
? registries.filter((r) => registryDisplayName(r) === name || registryUrl(r) === name)
|
|
519
|
+
: registries;
|
|
520
|
+
|
|
521
|
+
const results = await Promise.allSettled(
|
|
522
|
+
targets.map(async (r): Promise<RegistryTestResult> => {
|
|
523
|
+
const url = registryUrl(r);
|
|
524
|
+
const rName = registryDisplayName(r);
|
|
525
|
+
try {
|
|
526
|
+
const consumer = await createRegistryConsumer({ registries: [r] }, { token: options.token });
|
|
527
|
+
const disc = await consumer.discover(url);
|
|
528
|
+
return { name: rName, url, status: "active", issuer: disc.issuer };
|
|
529
|
+
} catch (err: unknown) {
|
|
530
|
+
const msg = err instanceof Error ? err.message : "unknown";
|
|
531
|
+
return { name: rName, url, status: "error", error: msg };
|
|
532
|
+
}
|
|
533
|
+
}),
|
|
534
|
+
);
|
|
535
|
+
|
|
536
|
+
return results.map((r) =>
|
|
537
|
+
r.status === "fulfilled"
|
|
538
|
+
? r.value
|
|
539
|
+
: { name: "unknown", url: "unknown", status: "error" as const, error: "unknown" },
|
|
540
|
+
);
|
|
541
|
+
},
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
// ==========================================
|
|
545
|
+
// Ref API
|
|
546
|
+
// ==========================================
|
|
547
|
+
|
|
548
|
+
const ref: AdkRefApi = {
|
|
549
|
+
async add(entry: RefEntry): Promise<{ security: SecuritySchemeSummary | null }> {
|
|
550
|
+
const config = await readConfig();
|
|
551
|
+
const name = refName(entry);
|
|
552
|
+
const refs = (config.refs ?? []).filter((r) => refName(r) !== name);
|
|
553
|
+
refs.push(entry);
|
|
554
|
+
await writeConfig({ ...config, refs });
|
|
555
|
+
|
|
556
|
+
// Check security requirements
|
|
557
|
+
let security: SecuritySchemeSummary | null = null;
|
|
558
|
+
try {
|
|
559
|
+
const consumer = await buildConsumer();
|
|
560
|
+
const info = await consumer.inspect(entry.ref);
|
|
561
|
+
if (info?.security) security = info.security;
|
|
562
|
+
} catch {
|
|
563
|
+
// Non-fatal — registry might be unreachable
|
|
564
|
+
}
|
|
565
|
+
return { security };
|
|
566
|
+
},
|
|
567
|
+
|
|
568
|
+
async remove(name: string): Promise<boolean> {
|
|
569
|
+
const config = await readConfig();
|
|
570
|
+
if (!config.refs?.length) return false;
|
|
571
|
+
const before = config.refs.length;
|
|
572
|
+
const refs = config.refs.filter((r) => refName(r) !== name);
|
|
573
|
+
if (refs.length === before) return false;
|
|
574
|
+
await writeConfig({ ...config, refs });
|
|
575
|
+
return true;
|
|
576
|
+
},
|
|
577
|
+
|
|
578
|
+
async list(): Promise<ResolvedRef[]> {
|
|
579
|
+
const config = await readConfig();
|
|
580
|
+
return (config.refs ?? []).map(normalizeRef);
|
|
581
|
+
},
|
|
582
|
+
|
|
583
|
+
async get(name: string): Promise<RefEntry | null> {
|
|
584
|
+
const config = await readConfig();
|
|
585
|
+
return (config.refs ?? []).find((r) => refName(r) === name) ?? null;
|
|
586
|
+
},
|
|
587
|
+
|
|
588
|
+
async update(name: string, updates: Partial<RefEntry>): Promise<boolean> {
|
|
589
|
+
const config = await readConfig();
|
|
590
|
+
if (!config.refs?.length) return false;
|
|
591
|
+
let found = false;
|
|
592
|
+
const refs = config.refs.map((r): RefEntry => {
|
|
593
|
+
if (refName(r) !== name) return r;
|
|
594
|
+
found = true;
|
|
595
|
+
const updated = { ...r };
|
|
596
|
+
if (updates.url) updated.url = updates.url;
|
|
597
|
+
if (updates.as) updated.as = updates.as;
|
|
598
|
+
if (updates.scheme) updated.scheme = updates.scheme;
|
|
599
|
+
if (updates.config) updated.config = { ...updated.config, ...updates.config };
|
|
600
|
+
if (updates.sourceRegistry) updated.sourceRegistry = updates.sourceRegistry;
|
|
601
|
+
return updated;
|
|
602
|
+
});
|
|
603
|
+
if (!found) return false;
|
|
604
|
+
await writeConfig({ ...config, refs });
|
|
605
|
+
return true;
|
|
606
|
+
},
|
|
607
|
+
|
|
608
|
+
async inspect(name: string, opts?: { full?: boolean }): Promise<AgentListing | null> {
|
|
609
|
+
const config = await readConfig();
|
|
610
|
+
const entry = (config.refs ?? []).find((r) => refName(r) === name);
|
|
611
|
+
if (!entry) throw new Error(`Ref "${name}" not found`);
|
|
612
|
+
|
|
613
|
+
const consumer = await buildConsumer();
|
|
614
|
+
return consumer.inspect(entry.ref, undefined, opts);
|
|
615
|
+
},
|
|
616
|
+
|
|
617
|
+
async call(name: string, tool: string, params?: Record<string, unknown>): Promise<CallAgentResponse> {
|
|
618
|
+
const config = await readConfig();
|
|
619
|
+
const entry = (config.refs ?? []).find((r) => refName(r) === name);
|
|
620
|
+
if (!entry) throw new Error(`Ref "${name}" not found`);
|
|
621
|
+
|
|
622
|
+
const accessToken = await readRefSecret(name, "access_token");
|
|
623
|
+
|
|
624
|
+
// If we have a direct access_token from OAuth, call the agent's MCP server
|
|
625
|
+
// directly instead of going through the registry
|
|
626
|
+
if (accessToken) {
|
|
627
|
+
const info = await ref.inspect(name);
|
|
628
|
+
// Use upstream URL from registry if available, fall back to deriving from OAuth URL
|
|
629
|
+
const upstream = (info as { upstream?: string } | null)?.upstream;
|
|
630
|
+
const security = info?.security as { type: string; flows?: { authorizationCode?: { authorizationUrl?: string } } } | undefined;
|
|
631
|
+
const mcpUrl = upstream
|
|
632
|
+
?? (security?.flows?.authorizationCode?.authorizationUrl
|
|
633
|
+
? `${new URL(security.flows.authorizationCode.authorizationUrl).origin}/mcp`
|
|
634
|
+
: null);
|
|
635
|
+
if (mcpUrl) {
|
|
636
|
+
return callMcpDirect(mcpUrl, tool, params ?? {}, accessToken);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const consumer = await buildConsumer();
|
|
641
|
+
const reg = consumer.registries()[0];
|
|
642
|
+
if (!reg) throw new Error("No registry available");
|
|
643
|
+
|
|
644
|
+
return consumer.callRegistry(reg, {
|
|
645
|
+
action: "execute_tool",
|
|
646
|
+
path: entry.ref,
|
|
647
|
+
tool,
|
|
648
|
+
params: params ?? {},
|
|
649
|
+
});
|
|
650
|
+
},
|
|
651
|
+
|
|
652
|
+
async resources(name: string): Promise<CallAgentResponse> {
|
|
653
|
+
const config = await readConfig();
|
|
654
|
+
const entry = (config.refs ?? []).find((r) => refName(r) === name);
|
|
655
|
+
if (!entry) throw new Error(`Ref "${name}" not found`);
|
|
656
|
+
|
|
657
|
+
const consumer = await buildConsumer();
|
|
658
|
+
const reg = consumer.registries()[0];
|
|
659
|
+
if (!reg) throw new Error("No registry available");
|
|
660
|
+
|
|
661
|
+
return consumer.callRegistry(reg, {
|
|
662
|
+
action: "list_resources",
|
|
663
|
+
path: entry.ref,
|
|
664
|
+
});
|
|
665
|
+
},
|
|
666
|
+
|
|
667
|
+
async read(name: string, uris: string[]): Promise<CallAgentResponse> {
|
|
668
|
+
const config = await readConfig();
|
|
669
|
+
const entry = (config.refs ?? []).find((r) => refName(r) === name);
|
|
670
|
+
if (!entry) throw new Error(`Ref "${name}" not found`);
|
|
671
|
+
|
|
672
|
+
const consumer = await buildConsumer();
|
|
673
|
+
const reg = consumer.registries()[0];
|
|
674
|
+
if (!reg) throw new Error("No registry available");
|
|
675
|
+
|
|
676
|
+
return consumer.callRegistry(reg, {
|
|
677
|
+
action: "read_resources",
|
|
678
|
+
path: entry.ref,
|
|
679
|
+
uris,
|
|
680
|
+
});
|
|
681
|
+
},
|
|
682
|
+
|
|
683
|
+
async authStatus(name: string): Promise<RefAuthStatus> {
|
|
684
|
+
const config = await readConfig();
|
|
685
|
+
const entry = (config.refs ?? []).find((r) => refName(r) === name);
|
|
686
|
+
if (!entry) throw new Error(`Ref "${name}" not found`);
|
|
687
|
+
|
|
688
|
+
let security: SecuritySchemeSummary | null = null;
|
|
689
|
+
try {
|
|
690
|
+
const consumer = await buildConsumer();
|
|
691
|
+
const info = await consumer.inspect(entry.ref);
|
|
692
|
+
if (info?.security) security = info.security;
|
|
693
|
+
} catch {
|
|
694
|
+
// Can't reach registry
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const configKeys = Object.keys(entry.config ?? {});
|
|
698
|
+
|
|
699
|
+
if (!security || security.type === "none") {
|
|
700
|
+
return { name, security, complete: true, missing: [], present: configKeys };
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const requiredFields = (() => {
|
|
704
|
+
switch (security.type) {
|
|
705
|
+
case "oauth2": return ["access_token"];
|
|
706
|
+
case "apiKey": return ["api_key"];
|
|
707
|
+
case "http": return ["token"];
|
|
708
|
+
default: return [];
|
|
709
|
+
}
|
|
710
|
+
})();
|
|
711
|
+
|
|
712
|
+
const missing = requiredFields.filter((f) => !configKeys.includes(f));
|
|
713
|
+
const present = requiredFields.filter((f) => configKeys.includes(f));
|
|
714
|
+
|
|
715
|
+
return { name, security, complete: missing.length === 0, missing, present };
|
|
716
|
+
},
|
|
717
|
+
|
|
718
|
+
async auth(name: string, opts?: {
|
|
719
|
+
apiKey?: string;
|
|
720
|
+
}): Promise<AuthStartResult> {
|
|
721
|
+
const status = await ref.authStatus(name);
|
|
722
|
+
const security = status.security;
|
|
723
|
+
|
|
724
|
+
if (!security || security.type === "none") {
|
|
725
|
+
return { type: "none", complete: true };
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
if (security.type === "apiKey") {
|
|
729
|
+
if (!opts?.apiKey) return { type: "apiKey", complete: false };
|
|
730
|
+
await storeRefSecret(name, "api_key", opts.apiKey);
|
|
731
|
+
return { type: "apiKey", complete: true };
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
if (security.type === "http") {
|
|
735
|
+
if (!opts?.apiKey) return { type: "http", complete: false };
|
|
736
|
+
await storeRefSecret(name, "token", opts.apiKey);
|
|
737
|
+
return { type: "http", complete: true };
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
if (security.type === "oauth2") {
|
|
741
|
+
const flows = (security as { flows?: { authorizationCode?: { authorizationUrl?: string; tokenUrl?: string } } }).flows;
|
|
742
|
+
const authCodeFlow = flows?.authorizationCode;
|
|
743
|
+
if (!authCodeFlow?.authorizationUrl) {
|
|
744
|
+
return { type: "oauth2", complete: false };
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// The authorizationUrl might be the discovery URL itself or a base URL
|
|
748
|
+
const authUrl = authCodeFlow.authorizationUrl;
|
|
749
|
+
let metadata = await tryFetchOAuthMetadata(authUrl);
|
|
750
|
+
if (!metadata) {
|
|
751
|
+
// Try base origin
|
|
752
|
+
const origin = new URL(authUrl).origin;
|
|
753
|
+
metadata = await discoverOAuthMetadata(origin);
|
|
754
|
+
}
|
|
755
|
+
if (!metadata) {
|
|
756
|
+
throw new Error(`Could not discover OAuth metadata from ${authUrl}`);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const redirectUri = callbackUrl();
|
|
760
|
+
|
|
761
|
+
// Dynamic client registration if supported
|
|
762
|
+
let clientId: string;
|
|
763
|
+
let clientSecret: string | undefined;
|
|
764
|
+
if (metadata.registration_endpoint) {
|
|
765
|
+
const supportedAuthMethods = metadata.token_endpoint_auth_methods_supported ?? ["none"];
|
|
766
|
+
const preferredMethod = supportedAuthMethods.includes("none")
|
|
767
|
+
? "none"
|
|
768
|
+
: supportedAuthMethods[0] ?? "client_secret_post";
|
|
769
|
+
|
|
770
|
+
const securityClientName = (security as { clientName?: string }).clientName;
|
|
771
|
+
const reg = await dynamicClientRegistration(metadata.registration_endpoint, {
|
|
772
|
+
clientName: securityClientName ?? options.oauthClientName ?? "adk",
|
|
773
|
+
redirectUris: [redirectUri],
|
|
774
|
+
grantTypes: ["authorization_code"],
|
|
775
|
+
tokenEndpointAuthMethod: preferredMethod,
|
|
776
|
+
});
|
|
777
|
+
clientId = reg.clientId;
|
|
778
|
+
clientSecret = reg.clientSecret;
|
|
779
|
+
await storeRefSecret(name, "client_id", clientId);
|
|
780
|
+
if (clientSecret) {
|
|
781
|
+
await storeRefSecret(name, "client_secret", clientSecret);
|
|
782
|
+
}
|
|
783
|
+
} else {
|
|
784
|
+
const stored = await readRefSecret(name, "client_id");
|
|
785
|
+
if (!stored) {
|
|
786
|
+
throw new Error(
|
|
787
|
+
"OAuth server doesn't support dynamic client registration. " +
|
|
788
|
+
"Store a client_id first.",
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
clientId = stored;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// State ties the callback back to this ref
|
|
795
|
+
const state = `${name}:${Date.now()}`;
|
|
796
|
+
|
|
797
|
+
const { url: authorizeUrl, codeVerifier } = await buildOAuthAuthorizeUrl({
|
|
798
|
+
authorizationEndpoint: metadata.authorization_endpoint,
|
|
799
|
+
clientId,
|
|
800
|
+
redirectUri,
|
|
801
|
+
scopes: metadata.scopes_supported,
|
|
802
|
+
state,
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
// Persist pending state so handleCallback works across processes
|
|
806
|
+
await storePendingOAuth(state, {
|
|
807
|
+
refName: name,
|
|
808
|
+
codeVerifier,
|
|
809
|
+
clientId,
|
|
810
|
+
clientSecret,
|
|
811
|
+
tokenEndpoint: metadata.token_endpoint,
|
|
812
|
+
redirectUri,
|
|
813
|
+
createdAt: Date.now(),
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
return { type: "oauth2", complete: false, authorizeUrl };
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
return { type: security.type, complete: false };
|
|
820
|
+
},
|
|
821
|
+
|
|
822
|
+
async authLocal(name: string, opts?: {
|
|
823
|
+
onAuthorizeUrl?: (url: string) => void;
|
|
824
|
+
timeoutMs?: number;
|
|
825
|
+
}): Promise<{ complete: boolean }> {
|
|
826
|
+
const result = await ref.auth(name);
|
|
827
|
+
|
|
828
|
+
if (result.complete) return { complete: true };
|
|
829
|
+
if (result.type !== "oauth2" || !result.authorizeUrl) {
|
|
830
|
+
throw new Error(`authLocal only handles OAuth2. Auth type: ${result.type}`);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
if (opts?.onAuthorizeUrl) {
|
|
834
|
+
opts.onAuthorizeUrl(result.authorizeUrl);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// Spin up local callback server
|
|
838
|
+
const port = options.oauthCallbackPort ?? 8919;
|
|
839
|
+
const timeout = opts?.timeoutMs ?? 300_000;
|
|
840
|
+
|
|
841
|
+
const { createServer } = await import("node:http");
|
|
842
|
+
return new Promise<{ complete: boolean }>((resolve, reject) => {
|
|
843
|
+
const server = createServer(async (req, res) => {
|
|
844
|
+
const reqUrl = new URL(req.url ?? "/", `http://localhost:${port}`);
|
|
845
|
+
if (reqUrl.pathname !== "/callback") return;
|
|
846
|
+
|
|
847
|
+
const code = reqUrl.searchParams.get("code");
|
|
848
|
+
const state = reqUrl.searchParams.get("state");
|
|
849
|
+
|
|
850
|
+
if (!code || !state) {
|
|
851
|
+
const error = reqUrl.searchParams.get("error") ?? "missing code/state";
|
|
852
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
853
|
+
res.end(`<h1>Error</h1><p>${error}</p>`);
|
|
854
|
+
server.close();
|
|
855
|
+
reject(new Error(`OAuth denied: ${error}`));
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
try {
|
|
860
|
+
const cbResult = await handleCallback({ code, state });
|
|
861
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
862
|
+
res.end("<h1>Authorized!</h1><p>You can close this tab.</p>");
|
|
863
|
+
server.close();
|
|
864
|
+
resolve({ complete: cbResult.complete });
|
|
865
|
+
} catch (err) {
|
|
866
|
+
res.writeHead(500, { "Content-Type": "text/html" });
|
|
867
|
+
res.end(`<h1>Error</h1><p>${err instanceof Error ? err.message : String(err)}</p>`);
|
|
868
|
+
server.close();
|
|
869
|
+
reject(err);
|
|
870
|
+
}
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
server.listen(port);
|
|
874
|
+
const timer = setTimeout(() => {
|
|
875
|
+
server.close();
|
|
876
|
+
reject(new Error("OAuth callback timed out"));
|
|
877
|
+
}, timeout);
|
|
878
|
+
server.on("close", () => clearTimeout(timer));
|
|
879
|
+
});
|
|
880
|
+
},
|
|
881
|
+
};
|
|
882
|
+
|
|
883
|
+
// ==========================================
|
|
884
|
+
// Top-level callback handler
|
|
885
|
+
// ==========================================
|
|
886
|
+
|
|
887
|
+
async function handleCallback(params: { code: string; state: string }): Promise<{ refName: string; complete: boolean }> {
|
|
888
|
+
const pending = await consumePendingOAuth(params.state);
|
|
889
|
+
if (!pending) {
|
|
890
|
+
throw new Error(`No pending OAuth flow for state "${params.state}".`);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
const tokens = await exchangeCodeForTokens(pending.tokenEndpoint, {
|
|
894
|
+
code: params.code,
|
|
895
|
+
codeVerifier: pending.codeVerifier,
|
|
896
|
+
clientId: pending.clientId,
|
|
897
|
+
clientSecret: pending.clientSecret,
|
|
898
|
+
redirectUri: pending.redirectUri,
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
await storeRefSecret(pending.refName, "access_token", tokens.accessToken);
|
|
902
|
+
if (tokens.refreshToken) {
|
|
903
|
+
await storeRefSecret(pending.refName, "refresh_token", tokens.refreshToken);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
return { refName: pending.refName, complete: true };
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
return { registry, ref, readConfig, writeConfig, handleCallback };
|
|
910
|
+
}
|