@desplega.ai/agent-swarm 1.63.0 → 1.63.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.
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Conversion utilities between CodexOAuthCredentials (our internal type)
3
+ * and ~/.codex/auth.json (the format the Codex CLI reads natively).
4
+ *
5
+ * The Codex CLI expects auth.json in this exact format:
6
+ * {
7
+ * "auth_mode": "chatgpt",
8
+ * "OPENAI_API_KEY": null,
9
+ * "tokens": {
10
+ * "id_token": "...",
11
+ * "access_token": "...",
12
+ * "refresh_token": "...",
13
+ * "account_id": "..."
14
+ * },
15
+ * "last_refresh": "<ISO 8601>"
16
+ * }
17
+ *
18
+ * Note: `id_token` is set to the `access_token` value because the token
19
+ * exchange endpoint doesn't return a separate `id_token`. This matches
20
+ * the `codex login --with-api-key` behavior which also doesn't have a
21
+ * separate id_token. The Codex CLI uses id_token primarily for display
22
+ * purposes and doesn't validate it as a separate JWT.
23
+ */
24
+
25
+ import type { CodexAuthJson, CodexOAuthCredentials } from "./types.js";
26
+
27
+ export function authJsonToCredentialSelection(auth: CodexAuthJson) {
28
+ return {
29
+ selected: auth.tokens.account_id,
30
+ index: 0,
31
+ total: 1,
32
+ keySuffix: auth.tokens.account_id.slice(-5),
33
+ keyType: "CODEX_OAUTH",
34
+ };
35
+ }
36
+
37
+ export function credentialsToAuthJson(creds: CodexOAuthCredentials): CodexAuthJson {
38
+ return {
39
+ auth_mode: "chatgpt",
40
+ OPENAI_API_KEY: null,
41
+ tokens: {
42
+ id_token: creds.access,
43
+ access_token: creds.access,
44
+ refresh_token: creds.refresh,
45
+ account_id: creds.accountId,
46
+ },
47
+ last_refresh: new Date(creds.expires).toISOString(),
48
+ };
49
+ }
50
+
51
+ export function authJsonToCredentials(auth: CodexAuthJson): CodexOAuthCredentials {
52
+ return {
53
+ access: auth.tokens.access_token,
54
+ refresh: auth.tokens.refresh_token,
55
+ expires: new Date(auth.last_refresh).getTime(),
56
+ accountId: auth.tokens.account_id,
57
+ };
58
+ }
@@ -0,0 +1,368 @@
1
+ /**
2
+ * OpenAI Codex (ChatGPT OAuth) flow.
3
+ *
4
+ * Ported from pi-mono's `utils/oauth/openai-codex.js` with adaptations
5
+ * for agent-swarm's types and runtime. Uses `node:http` for the loopback
6
+ * server (compatible with both Bun and Node.js).
7
+ */
8
+
9
+ import { randomBytes } from "node:crypto";
10
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
11
+ import { generatePKCE } from "./pkce.js";
12
+ import type { CodexOAuthCallbacks, CodexOAuthCredentials, TokenResult } from "./types.js";
13
+
14
+ /** Custom fetch for testing. Override via setFetchForTesting(). */
15
+ const _fetchHolder: { current: typeof fetch } = { current: globalThis.fetch };
16
+
17
+ /** Replace fetch for unit tests. Call resetFetchForTesting() in afterEach. */
18
+ export function setFetchForTesting(customFetch: typeof fetch): void {
19
+ _fetchHolder.current = customFetch;
20
+ }
21
+
22
+ /** Restore original fetch after testing. */
23
+ export function resetFetchForTesting(): void {
24
+ _fetchHolder.current = globalThis.fetch;
25
+ }
26
+
27
+ export const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
28
+ export const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
29
+ export const TOKEN_URL = "https://auth.openai.com/oauth/token";
30
+ export const REDIRECT_URI = "http://localhost:1455/auth/callback";
31
+ export const SCOPE = "openid profile email offline_access";
32
+ export const JWT_CLAIM_PATH = "https://api.openai.com/auth";
33
+
34
+ const SUCCESS_HTML = `<!doctype html>
35
+ <html lang="en">
36
+ <head>
37
+ <meta charset="utf-8" />
38
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
39
+ <title>Authentication successful</title>
40
+ </head>
41
+ <body>
42
+ <p>Authentication successful. Return to your terminal to continue.</p>
43
+ </body>
44
+ </html>`;
45
+
46
+ export function createState(): string {
47
+ return randomBytes(16).toString("hex");
48
+ }
49
+
50
+ export function parseAuthorizationInput(input: string): {
51
+ code?: string;
52
+ state?: string;
53
+ } {
54
+ const value = input.trim();
55
+ if (!value) return {};
56
+
57
+ try {
58
+ const url = new URL(value);
59
+ return {
60
+ code: url.searchParams.get("code") ?? undefined,
61
+ state: url.searchParams.get("state") ?? undefined,
62
+ };
63
+ } catch {
64
+ // not a URL
65
+ }
66
+
67
+ if (value.includes("#")) {
68
+ const [code, state] = value.split("#", 2);
69
+ return { code, state };
70
+ }
71
+
72
+ if (value.includes("code=")) {
73
+ const params = new URLSearchParams(value);
74
+ return {
75
+ code: params.get("code") ?? undefined,
76
+ state: params.get("state") ?? undefined,
77
+ };
78
+ }
79
+
80
+ return { code: value };
81
+ }
82
+
83
+ export function decodeJwt(token: string): Record<string, unknown> | null {
84
+ try {
85
+ const parts = token.split(".");
86
+ if (parts.length !== 3) return null;
87
+ const payload = parts[1] ?? "";
88
+ const decoded = atob(payload);
89
+ return JSON.parse(decoded);
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ export async function exchangeAuthorizationCode(
96
+ code: string,
97
+ verifier: string,
98
+ redirectUri: string = REDIRECT_URI,
99
+ ): Promise<TokenResult> {
100
+ const response = await _fetchHolder.current(TOKEN_URL, {
101
+ method: "POST",
102
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
103
+ body: new URLSearchParams({
104
+ grant_type: "authorization_code",
105
+ client_id: CLIENT_ID,
106
+ code,
107
+ code_verifier: verifier,
108
+ redirect_uri: redirectUri,
109
+ }),
110
+ });
111
+
112
+ if (!response.ok) {
113
+ const text = await response.text().catch(() => "");
114
+ console.error("[codex-oauth] code->token failed:", response.status, text);
115
+ return { type: "failed" };
116
+ }
117
+
118
+ const json = (await response.json()) as Record<string, unknown>;
119
+ if (!json.access_token || !json.refresh_token || typeof json.expires_in !== "number") {
120
+ console.error("[codex-oauth] token response missing fields:", json);
121
+ return { type: "failed" };
122
+ }
123
+
124
+ return {
125
+ type: "success",
126
+ access: json.access_token as string,
127
+ refresh: json.refresh_token as string,
128
+ expires: Date.now() + (json.expires_in as number) * 1000,
129
+ };
130
+ }
131
+
132
+ export async function refreshAccessToken(refreshToken: string): Promise<TokenResult> {
133
+ try {
134
+ const response = await _fetchHolder.current(TOKEN_URL, {
135
+ method: "POST",
136
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
137
+ body: new URLSearchParams({
138
+ grant_type: "refresh_token",
139
+ refresh_token: refreshToken,
140
+ client_id: CLIENT_ID,
141
+ }),
142
+ });
143
+
144
+ if (!response.ok) {
145
+ const text = await response.text().catch(() => "");
146
+ console.error("[codex-oauth] Token refresh failed:", response.status, text);
147
+ return { type: "failed" };
148
+ }
149
+
150
+ const json = (await response.json()) as Record<string, unknown>;
151
+ if (!json.access_token || !json.refresh_token || typeof json.expires_in !== "number") {
152
+ console.error("[codex-oauth] Token refresh response missing fields:", json);
153
+ return { type: "failed" };
154
+ }
155
+
156
+ return {
157
+ type: "success",
158
+ access: json.access_token as string,
159
+ refresh: json.refresh_token as string,
160
+ expires: Date.now() + (json.expires_in as number) * 1000,
161
+ };
162
+ } catch (error) {
163
+ console.error("[codex-oauth] Token refresh error:", error);
164
+ return { type: "failed" };
165
+ }
166
+ }
167
+
168
+ export function getAccountId(accessToken: string): string | null {
169
+ const payload = decodeJwt(accessToken);
170
+ const auth = payload?.[JWT_CLAIM_PATH] as Record<string, unknown> | undefined;
171
+ const accountId = auth?.chatgpt_account_id;
172
+ return typeof accountId === "string" && accountId.length > 0 ? accountId : null;
173
+ }
174
+
175
+ export async function createAuthorizationFlow(
176
+ originator = "agent-swarm",
177
+ ): Promise<{ verifier: string; state: string; url: string }> {
178
+ const { verifier, challenge } = await generatePKCE();
179
+ const state = createState();
180
+ const url = new URL(AUTHORIZE_URL);
181
+ url.searchParams.set("response_type", "code");
182
+ url.searchParams.set("client_id", CLIENT_ID);
183
+ url.searchParams.set("redirect_uri", REDIRECT_URI);
184
+ url.searchParams.set("scope", SCOPE);
185
+ url.searchParams.set("code_challenge", challenge);
186
+ url.searchParams.set("code_challenge_method", "S256");
187
+ url.searchParams.set("state", state);
188
+ url.searchParams.set("id_token_add_organizations", "true");
189
+ url.searchParams.set("codex_cli_simplified_flow", "true");
190
+ url.searchParams.set("originator", originator);
191
+ return { verifier, state, url: url.toString() };
192
+ }
193
+
194
+ type LocalOAuthServer = {
195
+ close: () => void;
196
+ cancelWait: () => void;
197
+ waitForCode: () => Promise<{ code: string } | null>;
198
+ };
199
+
200
+ export function startLocalOAuthServer(state: string): Promise<LocalOAuthServer> {
201
+ let lastCode: string | null = null;
202
+ let cancelled = false;
203
+
204
+ const server: Server = createServer((req: IncomingMessage, res: ServerResponse) => {
205
+ try {
206
+ const url = new URL(req.url || "/", "http://localhost");
207
+ if (url.pathname !== "/auth/callback") {
208
+ res.statusCode = 404;
209
+ res.end("Not found");
210
+ return;
211
+ }
212
+ if (url.searchParams.get("state") !== state) {
213
+ res.statusCode = 400;
214
+ res.end("State mismatch");
215
+ return;
216
+ }
217
+ const code = url.searchParams.get("code");
218
+ if (!code) {
219
+ res.statusCode = 400;
220
+ res.end("Missing authorization code");
221
+ return;
222
+ }
223
+ res.statusCode = 200;
224
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
225
+ res.end(SUCCESS_HTML);
226
+ lastCode = code;
227
+ } catch {
228
+ res.statusCode = 500;
229
+ res.end("Internal error");
230
+ }
231
+ });
232
+
233
+ return new Promise((resolve) => {
234
+ server
235
+ .listen(1455, "127.0.0.1", () => {
236
+ resolve({
237
+ close: () => server.close(),
238
+ cancelWait: () => {
239
+ cancelled = true;
240
+ },
241
+ waitForCode: async () => {
242
+ const sleep = () => new Promise((r) => setTimeout(r, 100));
243
+ for (let i = 0; i < 600; i += 1) {
244
+ if (lastCode) return { code: lastCode };
245
+ if (cancelled) return null;
246
+ await sleep();
247
+ }
248
+ return null;
249
+ },
250
+ });
251
+ })
252
+ .on("error", (err: NodeJS.ErrnoException) => {
253
+ console.error(
254
+ `[codex-oauth] Failed to bind http://127.0.0.1:1455 (${err.code}). Falling back to manual paste.`,
255
+ );
256
+ resolve({
257
+ close: () => {
258
+ try {
259
+ server.close();
260
+ } catch {
261
+ // ignore
262
+ }
263
+ },
264
+ cancelWait: () => {},
265
+ waitForCode: async () => null,
266
+ });
267
+ });
268
+ });
269
+ }
270
+
271
+ export async function loginCodexOAuth(
272
+ callbacks: CodexOAuthCallbacks,
273
+ ): Promise<CodexOAuthCredentials> {
274
+ const { verifier, state, url } = await createAuthorizationFlow(callbacks.originator);
275
+ const server = await startLocalOAuthServer(state);
276
+ callbacks.onAuth({
277
+ url,
278
+ instructions: "A browser window should open. Complete login to finish.",
279
+ });
280
+
281
+ let code: string | undefined;
282
+
283
+ try {
284
+ if (callbacks.onManualCodeInput) {
285
+ let manualCode: string | undefined;
286
+ let manualError: Error | undefined;
287
+ const manualPromise = callbacks
288
+ .onManualCodeInput()
289
+ .then((input: string) => {
290
+ manualCode = input;
291
+ server.cancelWait();
292
+ })
293
+ .catch((err: unknown) => {
294
+ manualError = err instanceof Error ? err : new Error(String(err));
295
+ server.cancelWait();
296
+ });
297
+
298
+ const result = await server.waitForCode();
299
+
300
+ if (manualError) {
301
+ throw manualError;
302
+ }
303
+
304
+ if (result?.code) {
305
+ code = result.code;
306
+ } else if (manualCode) {
307
+ const parsed = parseAuthorizationInput(manualCode);
308
+ if (parsed.state && parsed.state !== state) {
309
+ throw new Error("State mismatch");
310
+ }
311
+ code = parsed.code;
312
+ }
313
+
314
+ if (!code) {
315
+ await manualPromise;
316
+ if (manualError) {
317
+ throw manualError;
318
+ }
319
+ if (manualCode) {
320
+ const parsed = parseAuthorizationInput(manualCode);
321
+ if (parsed.state && parsed.state !== state) {
322
+ throw new Error("State mismatch");
323
+ }
324
+ code = parsed.code;
325
+ }
326
+ }
327
+ } else {
328
+ const result = await server.waitForCode();
329
+ if (result?.code) {
330
+ code = result.code;
331
+ }
332
+ }
333
+
334
+ if (!code) {
335
+ const input = await callbacks.onPrompt({
336
+ message: "Paste the authorization code (or full redirect URL):",
337
+ });
338
+ const parsed = parseAuthorizationInput(input);
339
+ if (parsed.state && parsed.state !== state) {
340
+ throw new Error("State mismatch");
341
+ }
342
+ code = parsed.code;
343
+ }
344
+
345
+ if (!code) {
346
+ throw new Error("Missing authorization code");
347
+ }
348
+
349
+ const tokenResult = await exchangeAuthorizationCode(code, verifier);
350
+ if (tokenResult.type !== "success") {
351
+ throw new Error("Token exchange failed");
352
+ }
353
+
354
+ const accountId = getAccountId(tokenResult.access);
355
+ if (!accountId) {
356
+ throw new Error("Failed to extract accountId from token");
357
+ }
358
+
359
+ return {
360
+ access: tokenResult.access,
361
+ refresh: tokenResult.refresh,
362
+ expires: tokenResult.expires,
363
+ accountId,
364
+ };
365
+ } finally {
366
+ server.close();
367
+ }
368
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * PKCE utilities using Web Crypto API.
3
+ * Cross-runtime compatible (Bun + Node.js 20+).
4
+ * Ported from pi-mono's `utils/oauth/pkce.js`.
5
+ */
6
+
7
+ function base64urlEncode(bytes: Uint8Array): string {
8
+ let binary = "";
9
+ for (const byte of bytes) {
10
+ binary += String.fromCharCode(byte);
11
+ }
12
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
13
+ }
14
+
15
+ export async function generatePKCE(): Promise<{ verifier: string; challenge: string }> {
16
+ const verifierBytes = new Uint8Array(32);
17
+ crypto.getRandomValues(verifierBytes);
18
+ const verifier = base64urlEncode(verifierBytes);
19
+
20
+ const encoder = new TextEncoder();
21
+ const data = encoder.encode(verifier);
22
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
23
+ const challenge = base64urlEncode(new Uint8Array(hashBuffer));
24
+
25
+ return { verifier, challenge };
26
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Config store persistence for Codex OAuth credentials.
3
+ *
4
+ * Stores/retrieves credentials via the swarm API config store at global scope.
5
+ * The entrypoint fetches them at boot and writes ~/.codex/auth.json.
6
+ */
7
+
8
+ import { refreshAccessToken } from "./flow.js";
9
+ import type { CodexOAuthCredentials } from "./types.js";
10
+
11
+ const CODEX_OAUTH_KEY = "codex_oauth";
12
+
13
+ export async function storeCodexOAuth(
14
+ apiUrl: string,
15
+ apiKey: string,
16
+ creds: CodexOAuthCredentials,
17
+ ): Promise<void> {
18
+ const res = await fetch(`${apiUrl}/api/config`, {
19
+ method: "PUT",
20
+ headers: {
21
+ "Content-Type": "application/json",
22
+ Authorization: `Bearer ${apiKey}`,
23
+ },
24
+ body: JSON.stringify({
25
+ scope: "global",
26
+ key: CODEX_OAUTH_KEY,
27
+ value: JSON.stringify(creds),
28
+ isSecret: true,
29
+ description: "Codex ChatGPT OAuth credentials (stored by codex-login)",
30
+ }),
31
+ });
32
+
33
+ if (!res.ok) {
34
+ const text = await res.text().catch(() => "");
35
+ throw new Error(`Failed to store codex_oauth config: HTTP ${res.status} ${text}`);
36
+ }
37
+ }
38
+
39
+ export async function loadCodexOAuth(
40
+ apiUrl: string,
41
+ apiKey: string,
42
+ ): Promise<CodexOAuthCredentials | null> {
43
+ let res: Response;
44
+ try {
45
+ res = await fetch(`${apiUrl}/api/config/resolved?includeSecrets=true&key=${CODEX_OAUTH_KEY}`, {
46
+ headers: { Authorization: `Bearer ${apiKey}` },
47
+ });
48
+ } catch {
49
+ return null;
50
+ }
51
+
52
+ if (!res.ok) {
53
+ return null;
54
+ }
55
+
56
+ const data = (await res.json()) as { configs: Array<{ key: string; value: string }> };
57
+ const entry = data.configs?.find((c) => c.key === CODEX_OAUTH_KEY);
58
+ if (!entry?.value) return null;
59
+
60
+ try {
61
+ return JSON.parse(entry.value) as CodexOAuthCredentials;
62
+ } catch {
63
+ console.error("[codex-oauth] Failed to parse codex_oauth config value");
64
+ return null;
65
+ }
66
+ }
67
+
68
+ export async function deleteCodexOAuth(apiUrl: string, apiKey: string): Promise<void> {
69
+ const res = await fetch(
70
+ `${apiUrl}/api/config/resolved?includeSecrets=true&key=${CODEX_OAUTH_KEY}`,
71
+ {
72
+ headers: { Authorization: `Bearer ${apiKey}` },
73
+ },
74
+ );
75
+
76
+ if (!res.ok) return;
77
+
78
+ const data = (await res.json()) as { configs: Array<{ id: string; key: string }> };
79
+ const entry = data.configs?.find((c) => c.key === CODEX_OAUTH_KEY);
80
+ if (!entry) return;
81
+
82
+ await fetch(`${apiUrl}/api/config/${entry.id}`, {
83
+ method: "DELETE",
84
+ headers: { Authorization: `Bearer ${apiKey}` },
85
+ });
86
+ }
87
+
88
+ export async function getValidCodexOAuth(
89
+ apiUrl: string,
90
+ apiKey: string,
91
+ ): Promise<CodexOAuthCredentials | null> {
92
+ const creds = await loadCodexOAuth(apiUrl, apiKey);
93
+ if (!creds) return null;
94
+
95
+ if (Date.now() < creds.expires) {
96
+ return creds;
97
+ }
98
+
99
+ console.log("[codex-oauth] Token expired, refreshing...");
100
+ const result = await refreshAccessToken(creds.refresh);
101
+ if (result.type !== "success") {
102
+ console.error("[codex-oauth] Token refresh failed");
103
+ return null;
104
+ }
105
+
106
+ const accountId = creds.accountId;
107
+ const refreshed: CodexOAuthCredentials = {
108
+ access: result.access,
109
+ refresh: result.refresh,
110
+ expires: result.expires,
111
+ accountId,
112
+ };
113
+
114
+ try {
115
+ await storeCodexOAuth(apiUrl, apiKey, refreshed);
116
+ } catch (err) {
117
+ console.error("[codex-oauth] Failed to store refreshed credentials:", err);
118
+ }
119
+
120
+ return refreshed;
121
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * TypeScript types for the Codex ChatGPT OAuth flow.
3
+ */
4
+
5
+ export type CodexOAuthCredentials = {
6
+ access: string;
7
+ refresh: string;
8
+ expires: number;
9
+ accountId: string;
10
+ };
11
+
12
+ export type CodexOAuthCallbacks = {
13
+ onAuth: (info: { url: string; instructions?: string }) => void;
14
+ onPrompt: (prompt: { message: string; placeholder?: string }) => Promise<string>;
15
+ onProgress?: (message: string) => void;
16
+ onManualCodeInput?: () => Promise<string>;
17
+ originator?: string;
18
+ signal?: AbortSignal;
19
+ };
20
+
21
+ export type CodexAuthJson = {
22
+ auth_mode: "chatgpt";
23
+ OPENAI_API_KEY: null;
24
+ tokens: {
25
+ id_token: string;
26
+ access_token: string;
27
+ refresh_token: string;
28
+ account_id: string;
29
+ };
30
+ last_refresh: string;
31
+ };
32
+
33
+ type TokenResult =
34
+ | { type: "success"; access: string; refresh: string; expires: number }
35
+ | { type: "failed" };
36
+
37
+ export type { TokenResult };