opencode-copilot-account-switcher 0.12.4 → 0.13.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,39 @@
1
+ export type TokenResponse = {
2
+ id_token?: string;
3
+ access_token?: string;
4
+ refresh_token?: string;
5
+ expires_in?: number;
6
+ };
7
+ export type IdTokenClaims = {
8
+ chatgpt_account_id?: string;
9
+ organizations?: Array<{
10
+ id: string;
11
+ }>;
12
+ email?: string;
13
+ "https://api.openai.com/auth"?: {
14
+ chatgpt_account_id?: string;
15
+ };
16
+ };
17
+ export type CodexOAuthAccount = {
18
+ refresh?: string;
19
+ access?: string;
20
+ expires?: number;
21
+ accountId?: string;
22
+ email?: string;
23
+ };
24
+ type OAuthMode = "browser" | "headless";
25
+ type RunCodexOAuthInput = {
26
+ now?: () => number;
27
+ timeoutMs?: number;
28
+ fetchImpl?: typeof globalThis.fetch;
29
+ selectMode?: () => Promise<OAuthMode | undefined>;
30
+ runBrowserAuth?: () => Promise<TokenResponse>;
31
+ runDeviceAuth?: () => Promise<TokenResponse>;
32
+ openUrl?: (url: string) => Promise<void>;
33
+ log?: (message: string) => void;
34
+ };
35
+ export declare function parseJwtClaims(token: string): IdTokenClaims | undefined;
36
+ export declare function extractAccountIdFromClaims(claims: IdTokenClaims): string | undefined;
37
+ export declare function extractAccountId(tokens: TokenResponse): string | undefined;
38
+ export declare function runCodexOAuth(input?: RunCodexOAuthInput): Promise<CodexOAuthAccount | undefined>;
39
+ export {};
@@ -0,0 +1,316 @@
1
+ import { randomBytes, createHash } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+ import { spawn } from "node:child_process";
4
+ import os from "node:os";
5
+ import { createInterface } from "node:readline/promises";
6
+ import { stdin as input, stdout as output, platform } from "node:process";
7
+ const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
8
+ const ISSUER = "https://auth.openai.com";
9
+ const OAUTH_PORT = 1455;
10
+ const OAUTH_TIMEOUT_MS = 5 * 60 * 1000;
11
+ const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000;
12
+ const USER_AGENT = `opencode-copilot-account-switcher (${platform} ${os.release()}; ${os.arch()})`;
13
+ const HTML_SUCCESS = `<!doctype html>
14
+ <html>
15
+ <head>
16
+ <title>Codex Authorization Successful</title>
17
+ </head>
18
+ <body>
19
+ <h1>Authorization Successful</h1>
20
+ <p>You can close this window and return to OpenCode.</p>
21
+ <script>
22
+ setTimeout(() => window.close(), 2000)
23
+ </script>
24
+ </body>
25
+ </html>`;
26
+ const htmlError = (message) => `<!doctype html>
27
+ <html>
28
+ <head>
29
+ <title>Codex Authorization Failed</title>
30
+ </head>
31
+ <body>
32
+ <h1>Authorization Failed</h1>
33
+ <p>${message}</p>
34
+ </body>
35
+ </html>`;
36
+ function base64UrlEncode(input) {
37
+ const bytes = input instanceof Uint8Array ? input : new Uint8Array(input);
38
+ return Buffer.from(bytes)
39
+ .toString("base64")
40
+ .replace(/\+/g, "-")
41
+ .replace(/\//g, "_")
42
+ .replace(/=+$/g, "");
43
+ }
44
+ function generateRandomString(length) {
45
+ const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
46
+ const bytes = randomBytes(length);
47
+ return Array.from(bytes, (byte) => chars[byte % chars.length]).join("");
48
+ }
49
+ async function generatePKCE() {
50
+ const verifier = generateRandomString(43);
51
+ const challenge = base64UrlEncode(createHash("sha256").update(verifier).digest());
52
+ return { verifier, challenge };
53
+ }
54
+ function generateState() {
55
+ return base64UrlEncode(randomBytes(32));
56
+ }
57
+ export function parseJwtClaims(token) {
58
+ const parts = token.split(".");
59
+ if (parts.length !== 3)
60
+ return undefined;
61
+ try {
62
+ return JSON.parse(Buffer.from(parts[1], "base64url").toString());
63
+ }
64
+ catch {
65
+ return undefined;
66
+ }
67
+ }
68
+ export function extractAccountIdFromClaims(claims) {
69
+ return (claims.chatgpt_account_id
70
+ || claims["https://api.openai.com/auth"]?.chatgpt_account_id
71
+ || claims.organizations?.[0]?.id);
72
+ }
73
+ export function extractAccountId(tokens) {
74
+ if (tokens.id_token) {
75
+ const claims = parseJwtClaims(tokens.id_token);
76
+ const accountId = claims && extractAccountIdFromClaims(claims);
77
+ if (accountId)
78
+ return accountId;
79
+ }
80
+ if (!tokens.access_token)
81
+ return undefined;
82
+ const claims = parseJwtClaims(tokens.access_token);
83
+ return claims ? extractAccountIdFromClaims(claims) : undefined;
84
+ }
85
+ function extractEmail(tokens) {
86
+ if (tokens.id_token) {
87
+ const claims = parseJwtClaims(tokens.id_token);
88
+ if (claims?.email)
89
+ return claims.email;
90
+ }
91
+ if (!tokens.access_token)
92
+ return undefined;
93
+ return parseJwtClaims(tokens.access_token)?.email;
94
+ }
95
+ function buildAuthorizeUrl(redirectUri, pkce, state) {
96
+ const params = new URLSearchParams({
97
+ response_type: "code",
98
+ client_id: CLIENT_ID,
99
+ redirect_uri: redirectUri,
100
+ scope: "openid profile email offline_access",
101
+ code_challenge: pkce.challenge,
102
+ code_challenge_method: "S256",
103
+ id_token_add_organizations: "true",
104
+ codex_cli_simplified_flow: "true",
105
+ state,
106
+ originator: "opencode",
107
+ });
108
+ return `${ISSUER}/oauth/authorize?${params.toString()}`;
109
+ }
110
+ async function promptText(message) {
111
+ const rl = createInterface({ input, output });
112
+ try {
113
+ return (await rl.question(message)).trim();
114
+ }
115
+ finally {
116
+ rl.close();
117
+ }
118
+ }
119
+ async function selectModeDefault() {
120
+ const value = (await promptText("OpenAI/Codex login mode ([1] browser, [2] headless, Enter to cancel): ")).toLowerCase();
121
+ if (!value)
122
+ return undefined;
123
+ if (value === "1" || value === "browser" || value === "b")
124
+ return "browser";
125
+ if (value === "2" || value === "headless" || value === "h" || value === "device")
126
+ return "headless";
127
+ return undefined;
128
+ }
129
+ async function openUrlDefault(url) {
130
+ if (process.platform === "win32") {
131
+ await new Promise((resolve, reject) => {
132
+ const child = spawn("cmd", ["/c", "start", "", url], { stdio: "ignore", windowsHide: true });
133
+ child.on("error", reject);
134
+ child.on("exit", (code) => {
135
+ if (code && code !== 0)
136
+ reject(new Error(`failed to open browser: ${code}`));
137
+ else
138
+ resolve();
139
+ });
140
+ });
141
+ return;
142
+ }
143
+ const command = process.platform === "darwin" ? "open" : "xdg-open";
144
+ await new Promise((resolve, reject) => {
145
+ const child = spawn(command, [url], { stdio: "ignore" });
146
+ child.on("error", reject);
147
+ child.on("exit", (code) => {
148
+ if (code && code !== 0)
149
+ reject(new Error(`failed to open browser: ${code}`));
150
+ else
151
+ resolve();
152
+ });
153
+ });
154
+ }
155
+ async function exchangeCodeForTokens(input) {
156
+ const response = await input.fetchImpl(`${ISSUER}/oauth/token`, {
157
+ method: "POST",
158
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
159
+ body: new URLSearchParams({
160
+ grant_type: "authorization_code",
161
+ code: input.code,
162
+ redirect_uri: input.redirectUri,
163
+ client_id: CLIENT_ID,
164
+ code_verifier: input.verifier,
165
+ }).toString(),
166
+ });
167
+ if (!response.ok) {
168
+ throw new Error(`Token exchange failed: ${response.status}`);
169
+ }
170
+ return response.json();
171
+ }
172
+ async function runBrowserAuthDefault(input) {
173
+ const pkce = await generatePKCE();
174
+ const state = generateState();
175
+ const redirectUri = `http://localhost:${OAUTH_PORT}/auth/callback`;
176
+ const authUrl = buildAuthorizeUrl(redirectUri, pkce, state);
177
+ const tokens = await new Promise((resolve, reject) => {
178
+ let closed = false;
179
+ const finish = (handler) => {
180
+ if (closed)
181
+ return;
182
+ closed = true;
183
+ clearTimeout(timeout);
184
+ void server.close(() => handler());
185
+ };
186
+ const respond = (res, status, body) => {
187
+ res.statusCode = status;
188
+ res.setHeader("Content-Type", "text/html");
189
+ res.end(body);
190
+ };
191
+ const server = createServer((req, res) => {
192
+ const url = new URL(req.url ?? "/", redirectUri);
193
+ if (url.pathname !== "/auth/callback") {
194
+ respond(res, 404, htmlError("Not found"));
195
+ return;
196
+ }
197
+ const code = url.searchParams.get("code");
198
+ const returnedState = url.searchParams.get("state");
199
+ const error = url.searchParams.get("error");
200
+ const errorDescription = url.searchParams.get("error_description");
201
+ if (error) {
202
+ const message = errorDescription || error;
203
+ respond(res, 400, htmlError(message));
204
+ finish(() => reject(new Error(message)));
205
+ return;
206
+ }
207
+ if (!code) {
208
+ respond(res, 400, htmlError("Missing authorization code"));
209
+ finish(() => reject(new Error("Missing authorization code")));
210
+ return;
211
+ }
212
+ if (returnedState !== state) {
213
+ respond(res, 400, htmlError("Invalid state - potential CSRF attack"));
214
+ finish(() => reject(new Error("Invalid state - potential CSRF attack")));
215
+ return;
216
+ }
217
+ respond(res, 200, HTML_SUCCESS);
218
+ void exchangeCodeForTokens({
219
+ code,
220
+ redirectUri,
221
+ verifier: pkce.verifier,
222
+ fetchImpl: input.fetchImpl,
223
+ }).then((result) => finish(() => resolve(result)), (error) => finish(() => reject(error instanceof Error ? error : new Error(String(error)))));
224
+ });
225
+ server.on("error", reject);
226
+ server.listen(OAUTH_PORT, async () => {
227
+ try {
228
+ input.log("Opening browser for OpenAI/Codex authorization...");
229
+ await input.openUrl(authUrl);
230
+ }
231
+ catch (error) {
232
+ finish(() => reject(error instanceof Error ? error : new Error(String(error))));
233
+ }
234
+ });
235
+ const timeout = setTimeout(() => {
236
+ finish(() => reject(new Error("OAuth callback timeout - authorization took too long")));
237
+ }, input.timeoutMs);
238
+ });
239
+ return tokens;
240
+ }
241
+ async function runDeviceAuthDefault(input) {
242
+ const deadline = Date.now() + input.timeoutMs;
243
+ const deviceResponse = await input.fetchImpl(`${ISSUER}/api/accounts/deviceauth/usercode`, {
244
+ method: "POST",
245
+ headers: {
246
+ "Content-Type": "application/json",
247
+ "User-Agent": USER_AGENT,
248
+ },
249
+ body: JSON.stringify({ client_id: CLIENT_ID }),
250
+ });
251
+ if (!deviceResponse.ok)
252
+ throw new Error("Failed to initiate device authorization");
253
+ const deviceData = await deviceResponse.json();
254
+ const interval = Math.max(parseInt(deviceData.interval) || 5, 1) * 1000;
255
+ input.log(`Open ${ISSUER}/codex/device and enter code: ${deviceData.user_code}`);
256
+ while (true) {
257
+ if (Date.now() >= deadline) {
258
+ throw new Error("Device authorization timeout - authorization took too long");
259
+ }
260
+ const response = await input.fetchImpl(`${ISSUER}/api/accounts/deviceauth/token`, {
261
+ method: "POST",
262
+ headers: {
263
+ "Content-Type": "application/json",
264
+ "User-Agent": USER_AGENT,
265
+ },
266
+ body: JSON.stringify({
267
+ device_auth_id: deviceData.device_auth_id,
268
+ user_code: deviceData.user_code,
269
+ }),
270
+ });
271
+ if (response.ok) {
272
+ const data = await response.json();
273
+ return exchangeCodeForTokens({
274
+ code: data.authorization_code,
275
+ redirectUri: `${ISSUER}/deviceauth/callback`,
276
+ verifier: data.code_verifier,
277
+ fetchImpl: input.fetchImpl,
278
+ });
279
+ }
280
+ if (response.status !== 403 && response.status !== 404) {
281
+ throw new Error(`Device authorization failed: ${response.status}`);
282
+ }
283
+ if (Date.now() + interval + OAUTH_POLLING_SAFETY_MARGIN_MS >= deadline) {
284
+ throw new Error("Device authorization timeout - authorization took too long");
285
+ }
286
+ await new Promise((resolve) => setTimeout(resolve, interval + OAUTH_POLLING_SAFETY_MARGIN_MS));
287
+ }
288
+ }
289
+ function normalizeTokens(tokens, now) {
290
+ const refresh = tokens.refresh_token;
291
+ const access = tokens.access_token;
292
+ if (!refresh && !access)
293
+ return undefined;
294
+ return {
295
+ refresh,
296
+ access,
297
+ expires: now() + (tokens.expires_in ?? 3600) * 1000,
298
+ accountId: extractAccountId(tokens),
299
+ email: extractEmail(tokens),
300
+ };
301
+ }
302
+ export async function runCodexOAuth(input = {}) {
303
+ const now = input.now ?? Date.now;
304
+ const timeoutMs = input.timeoutMs ?? OAUTH_TIMEOUT_MS;
305
+ const fetchImpl = input.fetchImpl ?? globalThis.fetch;
306
+ const selectMode = input.selectMode ?? selectModeDefault;
307
+ const openUrl = input.openUrl ?? openUrlDefault;
308
+ const log = input.log ?? console.log;
309
+ const mode = await selectMode();
310
+ if (!mode)
311
+ return undefined;
312
+ const runBrowserAuth = input.runBrowserAuth ?? (() => runBrowserAuthDefault({ fetchImpl, openUrl, log, timeoutMs }));
313
+ const runDeviceAuth = input.runDeviceAuth ?? (() => runDeviceAuthDefault({ fetchImpl, log, timeoutMs }));
314
+ const tokens = mode === "headless" ? await runDeviceAuth() : await runBrowserAuth();
315
+ return normalizeTokens(tokens, now);
316
+ }
@@ -1,6 +1,6 @@
1
1
  import { resolveCodexAuthSource } from "./codex-auth-source.js";
2
2
  import { fetchCodexStatus } from "./codex-status-fetcher.js";
3
- import { readCodexStore, writeCodexStore } from "./codex-store.js";
3
+ import { getActiveCodexAccount, normalizeCodexStore, readCodexStore, writeCodexStore, } from "./codex-store.js";
4
4
  import { readAuth } from "./store.js";
5
5
  export class CodexStatusCommandHandledError extends Error {
6
6
  constructor() {
@@ -64,25 +64,58 @@ function renderStatus(status) {
64
64
  ].join("\n");
65
65
  }
66
66
  function renderCachedStatus(store) {
67
+ const active = getActiveCodexAccount(store);
68
+ const entry = active?.entry;
69
+ const snapshot = entry?.snapshot;
67
70
  return [
68
71
  "[identity]",
69
- `account: ${value(store.account?.id ?? store.activeAccountId)}`,
70
- `email: ${value(store.account?.email ?? store.activeEmail)}`,
71
- `plan: ${value(store.account?.plan)}`,
72
+ `account: ${value(entry?.accountId ?? active?.name)}`,
73
+ `email: ${value(entry?.email)}`,
74
+ `plan: ${value(snapshot?.plan)}`,
72
75
  "[usage]",
73
- renderWindow("5h", store.status?.premium ?? {}),
76
+ renderWindow("5h", snapshot?.usage5h ?? {}),
77
+ "week: n/a",
78
+ "credits: n/a",
79
+ ].join("\n");
80
+ }
81
+ function getCachedAccountForSource(store, input) {
82
+ const accountId = input.accountId;
83
+ if (accountId) {
84
+ const match = Object.entries(store.accounts).find(([, entry]) => entry.accountId === accountId);
85
+ if (match) {
86
+ return {
87
+ name: match[0],
88
+ entry: match[1],
89
+ };
90
+ }
91
+ }
92
+ return getActiveCodexAccount(store);
93
+ }
94
+ function renderCachedStatusForAccount(store, input) {
95
+ const active = getCachedAccountForSource(store, input);
96
+ const entry = active?.entry;
97
+ const snapshot = entry?.snapshot;
98
+ return [
99
+ "[identity]",
100
+ `account: ${value(entry?.accountId ?? active?.name)}`,
101
+ `email: ${value(entry?.email)}`,
102
+ `plan: ${value(snapshot?.plan)}`,
103
+ "[usage]",
104
+ renderWindow("5h", snapshot?.usage5h ?? {}),
74
105
  "week: n/a",
75
106
  "credits: n/a",
76
107
  ].join("\n");
77
108
  }
78
109
  function hasCachedStore(store) {
79
- return Boolean(store.activeAccountId
80
- || store.activeEmail
81
- || store.account?.id
82
- || store.account?.email
83
- || store.account?.plan
84
- || store.status?.premium?.entitlement !== undefined
85
- || store.status?.premium?.remaining !== undefined);
110
+ const active = getActiveCodexAccount(store);
111
+ const entry = active?.entry;
112
+ const usage5h = entry?.snapshot?.usage5h;
113
+ return Boolean(active
114
+ || entry?.accountId
115
+ || entry?.email
116
+ || entry?.snapshot?.plan
117
+ || usage5h?.entitlement !== undefined
118
+ || usage5h?.remaining !== undefined);
86
119
  }
87
120
  async function defaultLoadAuth(client) {
88
121
  return defaultLoadAuthWithFallback({
@@ -197,11 +230,12 @@ export async function handleCodexStatusCommand(input) {
197
230
  },
198
231
  }));
199
232
  if (!fetched.ok) {
200
- const cached = await readStore().catch(() => ({}));
233
+ const cachedRaw = await readStore().catch(() => ({}));
234
+ const cached = normalizeCodexStore(cachedRaw);
201
235
  if (hasCachedStore(cached)) {
202
236
  await showToast({
203
237
  client: input.client,
204
- message: `Codex status fetch failed (${fetched.error.message}); showing cached snapshot.\n${renderCachedStatus(cached)}`,
238
+ message: `Codex status fetch failed (${fetched.error.message}); showing cached snapshot.\n${renderCachedStatusForAccount(cached, { accountId: source.accountId })}`,
205
239
  variant: "warning",
206
240
  });
207
241
  }
@@ -224,22 +258,45 @@ export async function handleCodexStatusCommand(input) {
224
258
  });
225
259
  });
226
260
  }
227
- const previousStore = await readStore().catch(() => ({}));
261
+ const previousRaw = await readStore().catch(() => ({}));
262
+ const previousStore = normalizeCodexStore(previousRaw);
263
+ const previousActive = getActiveCodexAccount(previousStore);
264
+ const nextActive = fetched.status.identity.accountId
265
+ ?? source.accountId
266
+ ?? previousActive?.entry.accountId
267
+ ?? previousActive?.name
268
+ ?? "default";
269
+ const previousEntry = previousStore.accounts[nextActive] ?? {};
228
270
  const nextStore = {
229
271
  ...previousStore,
230
- activeProvider: "codex",
231
- activeAccountId: fetched.status.identity.accountId ?? source.accountId ?? previousStore.activeAccountId,
232
- activeEmail: fetched.status.identity.email ?? previousStore.activeEmail,
233
- lastStatusRefresh: fetched.status.updatedAt,
234
- account: {
235
- id: fetched.status.identity.accountId ?? previousStore.account?.id,
236
- email: fetched.status.identity.email ?? previousStore.account?.email,
237
- plan: fetched.status.identity.plan ?? previousStore.account?.plan,
238
- },
239
- status: {
240
- premium: {
241
- entitlement: fetched.status.windows.primary.entitlement,
242
- remaining: fetched.status.windows.primary.remaining,
272
+ active: nextActive,
273
+ lastSnapshotRefresh: fetched.status.updatedAt,
274
+ accounts: {
275
+ ...previousStore.accounts,
276
+ [nextActive]: {
277
+ ...previousEntry,
278
+ name: previousEntry.name ?? nextActive,
279
+ providerId: previousEntry.providerId ?? "codex",
280
+ accountId: fetched.status.identity.accountId ?? previousEntry.accountId ?? source.accountId,
281
+ email: fetched.status.identity.email ?? previousEntry.email,
282
+ lastUsed: fetched.status.updatedAt,
283
+ snapshot: {
284
+ ...(previousEntry.snapshot ?? {}),
285
+ plan: fetched.status.identity.plan ?? previousEntry.snapshot?.plan,
286
+ usage5h: {
287
+ entitlement: fetched.status.windows.primary.entitlement,
288
+ remaining: fetched.status.windows.primary.remaining,
289
+ used: fetched.status.windows.primary.used,
290
+ resetAt: fetched.status.windows.primary.resetAt,
291
+ },
292
+ usageWeek: {
293
+ entitlement: fetched.status.windows.secondary.entitlement,
294
+ remaining: fetched.status.windows.secondary.remaining,
295
+ used: fetched.status.windows.secondary.used,
296
+ resetAt: fetched.status.windows.secondary.resetAt,
297
+ },
298
+ updatedAt: fetched.status.updatedAt,
299
+ },
243
300
  },
244
301
  },
245
302
  };
@@ -1,25 +1,48 @@
1
+ type CodexUsageWindow = {
2
+ entitlement?: number;
3
+ remaining?: number;
4
+ used?: number;
5
+ resetAt?: number;
6
+ };
1
7
  export type CodexAccountSnapshot = {
2
- id?: string;
3
- email?: string;
4
8
  plan?: string;
9
+ usage5h?: CodexUsageWindow;
10
+ usageWeek?: CodexUsageWindow;
11
+ updatedAt?: number;
12
+ error?: string;
5
13
  };
6
- export type CodexStatusSnapshot = {
7
- premium?: {
8
- entitlement?: number;
9
- remaining?: number;
10
- };
14
+ export type CodexAccountEntry = {
15
+ name?: string;
16
+ providerId?: string;
17
+ refresh?: string;
18
+ access?: string;
19
+ expires?: number;
20
+ accountId?: string;
21
+ email?: string;
22
+ addedAt?: number;
23
+ lastUsed?: number;
24
+ source?: string;
25
+ snapshot?: CodexAccountSnapshot;
11
26
  };
12
27
  export type CodexStoreFile = {
13
- activeProvider?: string;
14
- activeAccountId?: string;
15
- activeEmail?: string;
16
- lastStatusRefresh?: number;
17
- account?: CodexAccountSnapshot;
18
- status?: CodexStatusSnapshot;
28
+ accounts: Record<string, CodexAccountEntry>;
29
+ active?: string;
30
+ activeAccountNames?: string[];
31
+ autoRefresh?: boolean;
32
+ refreshMinutes?: number;
33
+ lastSnapshotRefresh?: number;
34
+ bootstrapAuthImportTried?: boolean;
35
+ bootstrapAuthImportAt?: number;
19
36
  };
37
+ export declare function normalizeCodexStore(input: unknown): CodexStoreFile;
20
38
  export declare function parseCodexStore(raw: string): CodexStoreFile;
39
+ export declare function getActiveCodexAccount(store: CodexStoreFile): {
40
+ name: string;
41
+ entry: CodexAccountEntry;
42
+ } | undefined;
21
43
  export declare function codexStorePath(): string;
22
44
  export declare function readCodexStore(filePath?: string): Promise<CodexStoreFile>;
23
45
  export declare function writeCodexStore(store: CodexStoreFile, options?: {
24
46
  filePath?: string;
25
47
  }): Promise<void>;
48
+ export {};