@huui/cdx-switcher 1.8.7 → 1.9.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/API.md +239 -0
- package/README.md +17 -12
- package/account-switch-BDEmlyn5.mjs +1536 -0
- package/api.d.mts +129 -0
- package/api.mjs +164 -0
- package/cdx.d.mts +105 -0
- package/cdx.mjs +7 -1524
- package/package.json +8 -1
|
@@ -0,0 +1,1536 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import * as p from "@clack/prompts";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { deletePassword, getPassword, listBackends, setPassword, useBackend } from "@bjesuiter/cross-keychain";
|
|
8
|
+
import { createInterface } from "node:readline/promises";
|
|
9
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
10
|
+
import { Decrypter, Encrypter } from "age-encryption";
|
|
11
|
+
//#region lib/platform/path-resolver.ts
|
|
12
|
+
const envValue = (env, key) => {
|
|
13
|
+
const value = env[key];
|
|
14
|
+
if (!value) return void 0;
|
|
15
|
+
const trimmed = value.trim();
|
|
16
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
17
|
+
};
|
|
18
|
+
const resolvePiAuthPath = (env, homeDir, platform) => {
|
|
19
|
+
const piAgentDir = envValue(env, "PI_CODING_AGENT_DIR");
|
|
20
|
+
if (piAgentDir) return platform === "win32" ? path.win32.join(piAgentDir, "auth.json") : path.join(piAgentDir, "auth.json");
|
|
21
|
+
return platform === "win32" ? path.win32.join(homeDir, ".pi", "agent", "auth.json") : path.join(homeDir, ".pi", "agent", "auth.json");
|
|
22
|
+
};
|
|
23
|
+
const resolveXdgPaths = (env, homeDir, platform) => {
|
|
24
|
+
const configHome = envValue(env, "XDG_CONFIG_HOME") ?? path.join(homeDir, ".config");
|
|
25
|
+
const dataHome = envValue(env, "XDG_DATA_HOME") ?? path.join(homeDir, ".local", "share");
|
|
26
|
+
const configDir = path.join(configHome, "cdx");
|
|
27
|
+
return {
|
|
28
|
+
profile: "xdg",
|
|
29
|
+
configDir,
|
|
30
|
+
configPath: path.join(configDir, "accounts.json"),
|
|
31
|
+
authPath: path.join(dataHome, "opencode", "auth.json"),
|
|
32
|
+
codexAuthPath: path.join(homeDir, ".codex", "auth.json"),
|
|
33
|
+
piAuthPath: resolvePiAuthPath(env, homeDir, platform)
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
const resolveWindowsPaths = (env, homeDir) => {
|
|
37
|
+
const winPath = path.win32;
|
|
38
|
+
const appData = envValue(env, "APPDATA") ?? winPath.join(homeDir, "AppData", "Roaming");
|
|
39
|
+
const localAppData = envValue(env, "LOCALAPPDATA") ?? winPath.join(homeDir, "AppData", "Local");
|
|
40
|
+
const configDir = winPath.join(appData, "cdx");
|
|
41
|
+
return {
|
|
42
|
+
profile: "windows-appdata",
|
|
43
|
+
configDir,
|
|
44
|
+
configPath: winPath.join(configDir, "accounts.json"),
|
|
45
|
+
authPath: winPath.join(localAppData, "opencode", "auth.json"),
|
|
46
|
+
codexAuthPath: winPath.join(homeDir, ".codex", "auth.json"),
|
|
47
|
+
piAuthPath: resolvePiAuthPath(env, homeDir, "win32")
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
const resolveRuntimePaths = (input) => {
|
|
51
|
+
if (input.platform === "win32") return resolveWindowsPaths(input.env, input.homeDir);
|
|
52
|
+
return resolveXdgPaths(input.env, input.homeDir, input.platform);
|
|
53
|
+
};
|
|
54
|
+
//#endregion
|
|
55
|
+
//#region lib/paths.ts
|
|
56
|
+
const toPathConfig = (paths) => ({
|
|
57
|
+
configDir: paths.configDir,
|
|
58
|
+
configPath: paths.configPath,
|
|
59
|
+
authPath: paths.authPath,
|
|
60
|
+
codexAuthPath: paths.codexAuthPath,
|
|
61
|
+
piAuthPath: paths.piAuthPath
|
|
62
|
+
});
|
|
63
|
+
const createDefaultPaths = () => {
|
|
64
|
+
const resolved = resolveRuntimePaths({
|
|
65
|
+
platform: process.platform,
|
|
66
|
+
env: process.env,
|
|
67
|
+
homeDir: os.homedir()
|
|
68
|
+
});
|
|
69
|
+
return {
|
|
70
|
+
paths: toPathConfig(resolved),
|
|
71
|
+
resolution: {
|
|
72
|
+
platform: process.platform,
|
|
73
|
+
profile: resolved.profile
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
const initial = createDefaultPaths();
|
|
78
|
+
let currentPaths = initial.paths;
|
|
79
|
+
let currentResolution = initial.resolution;
|
|
80
|
+
const getPaths = () => currentPaths;
|
|
81
|
+
const getPathResolutionInfo = () => currentResolution;
|
|
82
|
+
const setPaths = (paths) => {
|
|
83
|
+
currentPaths = {
|
|
84
|
+
...currentPaths,
|
|
85
|
+
...paths
|
|
86
|
+
};
|
|
87
|
+
if (paths.configDir && !paths.configPath) currentPaths.configPath = path.join(paths.configDir, "accounts.json");
|
|
88
|
+
};
|
|
89
|
+
const resetPaths = () => {
|
|
90
|
+
const next = createDefaultPaths();
|
|
91
|
+
currentPaths = next.paths;
|
|
92
|
+
currentResolution = next.resolution;
|
|
93
|
+
};
|
|
94
|
+
const createTestPaths = (testDir) => ({
|
|
95
|
+
configDir: path.join(testDir, "config"),
|
|
96
|
+
configPath: path.join(testDir, "config", "accounts.json"),
|
|
97
|
+
authPath: path.join(testDir, "auth", "auth.json"),
|
|
98
|
+
codexAuthPath: path.join(testDir, "codex", "auth.json"),
|
|
99
|
+
piAuthPath: path.join(testDir, "pi", "auth.json")
|
|
100
|
+
});
|
|
101
|
+
//#endregion
|
|
102
|
+
//#region lib/config.ts
|
|
103
|
+
const isSecretStoreSelection = (value) => value === "auto" || value === "legacy-keychain";
|
|
104
|
+
const loadConfiguredSecretStoreSelection = async () => {
|
|
105
|
+
const { configPath } = getPaths();
|
|
106
|
+
if (!existsSync(configPath)) return;
|
|
107
|
+
try {
|
|
108
|
+
const raw = await readFile(configPath, "utf8");
|
|
109
|
+
const parsed = JSON.parse(raw);
|
|
110
|
+
return isSecretStoreSelection(parsed.secretStore) ? parsed.secretStore : void 0;
|
|
111
|
+
} catch {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
const loadConfig = async () => {
|
|
116
|
+
const { configPath } = getPaths();
|
|
117
|
+
if (!existsSync(configPath)) throw new Error(`Missing config at ${configPath}. Create accounts.json to list Keychain services.`);
|
|
118
|
+
const raw = await readFile(configPath, "utf8");
|
|
119
|
+
const parsed = JSON.parse(raw);
|
|
120
|
+
if (!Array.isArray(parsed.accounts) || parsed.accounts.length === 0) throw new Error("accounts.json must include a non-empty accounts array.");
|
|
121
|
+
if (typeof parsed.current !== "number" || Number.isNaN(parsed.current)) parsed.current = 0;
|
|
122
|
+
if (!isSecretStoreSelection(parsed.secretStore)) delete parsed.secretStore;
|
|
123
|
+
return parsed;
|
|
124
|
+
};
|
|
125
|
+
const saveConfig = async (config) => {
|
|
126
|
+
const { configDir, configPath } = getPaths();
|
|
127
|
+
await mkdir(configDir, { recursive: true });
|
|
128
|
+
await writeFile(configPath, JSON.stringify(config, null, 2), "utf8");
|
|
129
|
+
};
|
|
130
|
+
const configExists = () => {
|
|
131
|
+
const { configPath } = getPaths();
|
|
132
|
+
return existsSync(configPath);
|
|
133
|
+
};
|
|
134
|
+
//#endregion
|
|
135
|
+
//#region lib/auth.ts
|
|
136
|
+
const readExistingJson = async (filePath) => {
|
|
137
|
+
if (!existsSync(filePath)) return {};
|
|
138
|
+
try {
|
|
139
|
+
const raw = await readFile(filePath, "utf8");
|
|
140
|
+
const parsed = JSON.parse(raw);
|
|
141
|
+
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
142
|
+
} catch {
|
|
143
|
+
return {};
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
const writeAuthFile = async (payload) => {
|
|
147
|
+
const { authPath } = getPaths();
|
|
148
|
+
await mkdir(path.dirname(authPath), { recursive: true });
|
|
149
|
+
const existing = await readExistingJson(authPath);
|
|
150
|
+
existing.openai = {
|
|
151
|
+
type: "oauth",
|
|
152
|
+
refresh: payload.refresh,
|
|
153
|
+
access: payload.access,
|
|
154
|
+
expires: payload.expires,
|
|
155
|
+
accountId: payload.accountId
|
|
156
|
+
};
|
|
157
|
+
await writeFile(authPath, JSON.stringify(existing, null, 2), "utf8");
|
|
158
|
+
};
|
|
159
|
+
const writeCodexAuthFile = async (payload) => {
|
|
160
|
+
const { codexAuthPath } = getPaths();
|
|
161
|
+
await mkdir(path.dirname(codexAuthPath), { recursive: true });
|
|
162
|
+
const existing = await readExistingJson(codexAuthPath);
|
|
163
|
+
const existingTokens = typeof existing.tokens === "object" && existing.tokens !== null ? existing.tokens : {};
|
|
164
|
+
existing.auth_mode = "chatgpt";
|
|
165
|
+
if (!("OPENAI_API_KEY" in existing)) existing.OPENAI_API_KEY = null;
|
|
166
|
+
existing.tokens = {
|
|
167
|
+
...existingTokens,
|
|
168
|
+
id_token: payload.idToken ?? null,
|
|
169
|
+
access_token: payload.access,
|
|
170
|
+
refresh_token: payload.refresh,
|
|
171
|
+
account_id: payload.accountId
|
|
172
|
+
};
|
|
173
|
+
existing.last_refresh = (/* @__PURE__ */ new Date()).toISOString();
|
|
174
|
+
await writeFile(codexAuthPath, JSON.stringify(existing, null, 2), "utf8");
|
|
175
|
+
};
|
|
176
|
+
const writePiAuthFile = async (payload) => {
|
|
177
|
+
const { piAuthPath } = getPaths();
|
|
178
|
+
await mkdir(path.dirname(piAuthPath), { recursive: true });
|
|
179
|
+
const existing = await readExistingJson(piAuthPath);
|
|
180
|
+
existing["openai-codex"] = {
|
|
181
|
+
type: "oauth",
|
|
182
|
+
access: payload.access,
|
|
183
|
+
refresh: payload.refresh,
|
|
184
|
+
expires: payload.expires,
|
|
185
|
+
accountId: payload.accountId
|
|
186
|
+
};
|
|
187
|
+
await writeFile(piAuthPath, JSON.stringify(existing, null, 2), "utf8");
|
|
188
|
+
};
|
|
189
|
+
const writeAllAuthFiles = async (payload) => {
|
|
190
|
+
await writeAuthFile(payload);
|
|
191
|
+
await writePiAuthFile(payload);
|
|
192
|
+
if (payload.idToken) {
|
|
193
|
+
await writeCodexAuthFile(payload);
|
|
194
|
+
return {
|
|
195
|
+
piWritten: true,
|
|
196
|
+
codexWritten: true,
|
|
197
|
+
codexMissingIdToken: false,
|
|
198
|
+
codexCleared: false
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
const { codexAuthPath } = getPaths();
|
|
202
|
+
let codexCleared = false;
|
|
203
|
+
if (existsSync(codexAuthPath)) try {
|
|
204
|
+
await rm(codexAuthPath);
|
|
205
|
+
codexCleared = true;
|
|
206
|
+
} catch {
|
|
207
|
+
codexCleared = false;
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
piWritten: true,
|
|
211
|
+
codexWritten: false,
|
|
212
|
+
codexMissingIdToken: true,
|
|
213
|
+
codexCleared
|
|
214
|
+
};
|
|
215
|
+
};
|
|
216
|
+
//#endregion
|
|
217
|
+
//#region lib/keychain.ts
|
|
218
|
+
const SERVICE_PREFIX$3 = "cdx-openai-";
|
|
219
|
+
const getKeychainService = (accountId) => {
|
|
220
|
+
return `${SERVICE_PREFIX$3}${accountId}`;
|
|
221
|
+
};
|
|
222
|
+
const runSecurity = (args) => {
|
|
223
|
+
const result = Bun.spawnSync({
|
|
224
|
+
cmd: ["security", ...args],
|
|
225
|
+
stderr: "pipe",
|
|
226
|
+
stdout: "pipe"
|
|
227
|
+
});
|
|
228
|
+
if (result.exitCode !== 0) {
|
|
229
|
+
const message = result.stderr.toString().trim();
|
|
230
|
+
throw new Error(message || "Keychain command failed");
|
|
231
|
+
}
|
|
232
|
+
return result.stdout.toString();
|
|
233
|
+
};
|
|
234
|
+
const runSecuritySafe = (args) => {
|
|
235
|
+
const result = Bun.spawnSync({
|
|
236
|
+
cmd: ["security", ...args],
|
|
237
|
+
stderr: "pipe",
|
|
238
|
+
stdout: "pipe"
|
|
239
|
+
});
|
|
240
|
+
return {
|
|
241
|
+
success: result.exitCode === 0,
|
|
242
|
+
output: result.exitCode === 0 ? result.stdout.toString() : result.stderr.toString()
|
|
243
|
+
};
|
|
244
|
+
};
|
|
245
|
+
const runSecuritySafeAsync = async (args) => {
|
|
246
|
+
const childProcess = Bun.spawn(["security", ...args], {
|
|
247
|
+
stderr: "pipe",
|
|
248
|
+
stdout: "pipe"
|
|
249
|
+
});
|
|
250
|
+
const stdoutPromise = childProcess.stdout ? new Response(childProcess.stdout).text() : Promise.resolve("");
|
|
251
|
+
const stderrPromise = childProcess.stderr ? new Response(childProcess.stderr).text() : Promise.resolve("");
|
|
252
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
253
|
+
childProcess.exited,
|
|
254
|
+
stdoutPromise,
|
|
255
|
+
stderrPromise
|
|
256
|
+
]);
|
|
257
|
+
return {
|
|
258
|
+
success: exitCode === 0,
|
|
259
|
+
output: exitCode === 0 ? stdout : stderr
|
|
260
|
+
};
|
|
261
|
+
};
|
|
262
|
+
const saveKeychainPayload = (accountId, payload) => {
|
|
263
|
+
runSecurity([
|
|
264
|
+
"add-generic-password",
|
|
265
|
+
"-a",
|
|
266
|
+
accountId,
|
|
267
|
+
"-s",
|
|
268
|
+
getKeychainService(accountId),
|
|
269
|
+
"-w",
|
|
270
|
+
JSON.stringify(payload),
|
|
271
|
+
"-U"
|
|
272
|
+
]);
|
|
273
|
+
};
|
|
274
|
+
const loadKeychainPayload = (accountId) => {
|
|
275
|
+
const raw = runSecurity([
|
|
276
|
+
"find-generic-password",
|
|
277
|
+
"-s",
|
|
278
|
+
getKeychainService(accountId),
|
|
279
|
+
"-w"
|
|
280
|
+
]).trim();
|
|
281
|
+
if (!raw) throw new Error(`No Keychain payload found for account ${accountId}.`);
|
|
282
|
+
const parsed = JSON.parse(raw);
|
|
283
|
+
if (!parsed.refresh || !parsed.access || !parsed.expires || !parsed.accountId) throw new Error(`Keychain payload for account ${accountId} is missing required fields.`);
|
|
284
|
+
return parsed;
|
|
285
|
+
};
|
|
286
|
+
const deleteKeychainPayload = (accountId) => {
|
|
287
|
+
runSecurity([
|
|
288
|
+
"delete-generic-password",
|
|
289
|
+
"-s",
|
|
290
|
+
getKeychainService(accountId)
|
|
291
|
+
]);
|
|
292
|
+
};
|
|
293
|
+
const keychainPayloadExists = (accountId) => {
|
|
294
|
+
return runSecuritySafe([
|
|
295
|
+
"find-generic-password",
|
|
296
|
+
"-s",
|
|
297
|
+
getKeychainService(accountId)
|
|
298
|
+
]).success;
|
|
299
|
+
};
|
|
300
|
+
const listKeychainAccounts = () => {
|
|
301
|
+
const result = Bun.spawnSync({
|
|
302
|
+
cmd: ["security", "dump-keychain"],
|
|
303
|
+
stderr: "pipe",
|
|
304
|
+
stdout: "pipe"
|
|
305
|
+
});
|
|
306
|
+
if (result.exitCode !== 0) return [];
|
|
307
|
+
const output = result.stdout.toString();
|
|
308
|
+
const accounts = [];
|
|
309
|
+
const serviceRegex = new RegExp(`"svce"<blob>="${SERVICE_PREFIX$3}([^"]+)"`, "g");
|
|
310
|
+
let match;
|
|
311
|
+
while ((match = serviceRegex.exec(output)) !== null) if (match[1]) accounts.push(match[1]);
|
|
312
|
+
return [...new Set(accounts)];
|
|
313
|
+
};
|
|
314
|
+
//#endregion
|
|
315
|
+
//#region lib/secrets/cross-keychain-overrides.ts
|
|
316
|
+
const LEGACY_MAX_PASSWORD_LENGTH = 4096;
|
|
317
|
+
const parseMaxPasswordLength = (value) => {
|
|
318
|
+
if (!value) return null;
|
|
319
|
+
const parsed = Number.parseInt(value, 10);
|
|
320
|
+
if (!Number.isInteger(parsed) || parsed <= LEGACY_MAX_PASSWORD_LENGTH) return null;
|
|
321
|
+
return parsed;
|
|
322
|
+
};
|
|
323
|
+
const getCrossKeychainBackendOverrides = () => {
|
|
324
|
+
return { max_password_length: parseMaxPasswordLength(process.env.CDX_CROSS_KEYCHAIN_MAX_PASSWORD_LENGTH) ?? 16384 };
|
|
325
|
+
};
|
|
326
|
+
//#endregion
|
|
327
|
+
//#region lib/secrets/fallback-consent.ts
|
|
328
|
+
const CONSENT_FILE = "secure-store-fallback-consent.json";
|
|
329
|
+
const CONSENT_ENV_BYPASS = "CDX_ALLOW_SECURE_STORE_FALLBACK";
|
|
330
|
+
const isBypassEnabled = () => {
|
|
331
|
+
const value = process.env[CONSENT_ENV_BYPASS];
|
|
332
|
+
if (!value) return false;
|
|
333
|
+
return [
|
|
334
|
+
"1",
|
|
335
|
+
"true",
|
|
336
|
+
"yes",
|
|
337
|
+
"y"
|
|
338
|
+
].includes(value.trim().toLowerCase());
|
|
339
|
+
};
|
|
340
|
+
const consentFilePath = () => path.join(getPaths().configDir, CONSENT_FILE);
|
|
341
|
+
const loadConsentMap = async () => {
|
|
342
|
+
try {
|
|
343
|
+
const raw = await readFile(consentFilePath(), "utf8");
|
|
344
|
+
return JSON.parse(raw).accepted ?? {};
|
|
345
|
+
} catch {
|
|
346
|
+
return {};
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
const saveConsentMap = async (accepted) => {
|
|
350
|
+
const { configDir } = getPaths();
|
|
351
|
+
await mkdir(configDir, { recursive: true });
|
|
352
|
+
const payload = { accepted };
|
|
353
|
+
await writeFile(consentFilePath(), JSON.stringify(payload, null, 2), "utf8");
|
|
354
|
+
};
|
|
355
|
+
const promptConsent = async (message) => {
|
|
356
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
|
|
357
|
+
process.stdout.write(`\n${message}\n\n`);
|
|
358
|
+
const rl = createInterface({
|
|
359
|
+
input: process.stdin,
|
|
360
|
+
output: process.stdout
|
|
361
|
+
});
|
|
362
|
+
try {
|
|
363
|
+
const normalized = (await rl.question("Do you want to continue with this fallback? [y/N]: ")).trim().toLowerCase();
|
|
364
|
+
return normalized === "y" || normalized === "yes";
|
|
365
|
+
} finally {
|
|
366
|
+
rl.close();
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
const ensureFallbackConsent = async (scope, warningMessage) => {
|
|
370
|
+
if (isBypassEnabled()) return;
|
|
371
|
+
const accepted = await loadConsentMap();
|
|
372
|
+
if (accepted[scope]) return;
|
|
373
|
+
if (!await promptConsent(warningMessage)) throw new Error(`Secure-store fallback usage was not approved for '${scope}'. Re-run in an interactive terminal to confirm, or set ${CONSENT_ENV_BYPASS}=1 if you accept the fallback risk.`);
|
|
374
|
+
accepted[scope] = { acceptedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
375
|
+
await saveConsentMap(accepted);
|
|
376
|
+
};
|
|
377
|
+
//#endregion
|
|
378
|
+
//#region lib/secrets/linux-cross-keychain.ts
|
|
379
|
+
const SERVICE_PREFIX$2 = "cdx-openai-";
|
|
380
|
+
const LINUX_FALLBACK_SCOPE = "linux:cross-keychain:secret-service";
|
|
381
|
+
const MISSING_ENTRY_MARKERS = [
|
|
382
|
+
"no matching entry found in secure storage",
|
|
383
|
+
"password not found",
|
|
384
|
+
"no stored credentials found",
|
|
385
|
+
"credential not found",
|
|
386
|
+
"no result found"
|
|
387
|
+
];
|
|
388
|
+
const STORE_UNAVAILABLE_MARKERS = [
|
|
389
|
+
"unable to initialize linux secure-store backend",
|
|
390
|
+
"no keyring backend could be initialized",
|
|
391
|
+
"native keyring module not available",
|
|
392
|
+
"linux secure store is unavailable",
|
|
393
|
+
"secret service operation failed",
|
|
394
|
+
"couldn't access platform secure storage",
|
|
395
|
+
"dbus",
|
|
396
|
+
"d-bus",
|
|
397
|
+
"org.freedesktop.secrets",
|
|
398
|
+
"service unavailable"
|
|
399
|
+
];
|
|
400
|
+
let backendInitPromise$2 = null;
|
|
401
|
+
let selectedBackend$2 = null;
|
|
402
|
+
const getErrorMessage = (error) => error instanceof Error ? error.message : String(error);
|
|
403
|
+
const classifyLinuxSecureStoreError = (error) => {
|
|
404
|
+
const message = getErrorMessage(error).toLowerCase();
|
|
405
|
+
if (MISSING_ENTRY_MARKERS.some((marker) => message.includes(marker))) return "missing_entry";
|
|
406
|
+
if (STORE_UNAVAILABLE_MARKERS.some((marker) => message.includes(marker))) return "store_unavailable";
|
|
407
|
+
return "other";
|
|
408
|
+
};
|
|
409
|
+
const createLinuxSecureStoreUnavailableError = (details) => {
|
|
410
|
+
const guidance = "Linux secure store is unavailable. Ensure Secret Service is installed/running (for example gnome-keyring with secret-tool), then retry login.";
|
|
411
|
+
if (!details) return new Error(guidance);
|
|
412
|
+
return /* @__PURE__ */ new Error(`${guidance} Technical details: ${details}`);
|
|
413
|
+
};
|
|
414
|
+
const tryUseBackend$2 = async (backendId) => {
|
|
415
|
+
try {
|
|
416
|
+
await useBackend(backendId, getCrossKeychainBackendOverrides());
|
|
417
|
+
return true;
|
|
418
|
+
} catch {
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
const selectBackend$2 = async () => {
|
|
423
|
+
const backends = await listBackends();
|
|
424
|
+
const available = new Set(backends.map((backend) => backend.id));
|
|
425
|
+
if (available.has("native-linux") && await tryUseBackend$2("native-linux")) return "native-linux";
|
|
426
|
+
if (available.has("secret-service") && await tryUseBackend$2("secret-service")) return "secret-service";
|
|
427
|
+
if (await tryUseBackend$2("native-linux")) return "native-linux";
|
|
428
|
+
if (await tryUseBackend$2("secret-service")) return "secret-service";
|
|
429
|
+
throw new Error("Unable to initialize Linux secure-store backend via cross-keychain.");
|
|
430
|
+
};
|
|
431
|
+
const setActiveBackend = (backendId) => {
|
|
432
|
+
selectedBackend$2 = backendId;
|
|
433
|
+
backendInitPromise$2 = Promise.resolve();
|
|
434
|
+
};
|
|
435
|
+
const trySwitchBackend = async (backendId, options = {}) => {
|
|
436
|
+
if (options.forWrite && backendId === "secret-service") await ensureFallbackConsent(LINUX_FALLBACK_SCOPE, "⚠ Security warning: only the cross-keychain Linux fallback backend is available.\nThis path relies on shell-based `secret-tool` operations for Secret Service access.\nCompared to native bindings, secrets may be more exposed to process inspection/logging while helper commands run.");
|
|
437
|
+
try {
|
|
438
|
+
await useBackend(backendId, getCrossKeychainBackendOverrides());
|
|
439
|
+
setActiveBackend(backendId);
|
|
440
|
+
return true;
|
|
441
|
+
} catch {
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
const ensureLinuxBackend = async (options = {}) => {
|
|
446
|
+
if (!backendInitPromise$2) backendInitPromise$2 = (async () => {
|
|
447
|
+
selectedBackend$2 = await selectBackend$2();
|
|
448
|
+
})();
|
|
449
|
+
try {
|
|
450
|
+
await backendInitPromise$2;
|
|
451
|
+
} catch {
|
|
452
|
+
backendInitPromise$2 = null;
|
|
453
|
+
selectedBackend$2 = null;
|
|
454
|
+
throw new Error("Unable to initialize Linux secure-store backend via cross-keychain.");
|
|
455
|
+
}
|
|
456
|
+
if (options.forWrite && selectedBackend$2 === "secret-service") await ensureFallbackConsent(LINUX_FALLBACK_SCOPE, "⚠ Security warning: only the cross-keychain Linux fallback backend is available.\nThis path relies on shell-based `secret-tool` operations for Secret Service access.\nCompared to native bindings, secrets may be more exposed to process inspection/logging while helper commands run.");
|
|
457
|
+
};
|
|
458
|
+
const getLinuxCrossKeychainService = (accountId) => `${SERVICE_PREFIX$2}${accountId}`;
|
|
459
|
+
const parsePayload$2 = (accountId, raw) => {
|
|
460
|
+
let parsed;
|
|
461
|
+
try {
|
|
462
|
+
parsed = JSON.parse(raw);
|
|
463
|
+
} catch {
|
|
464
|
+
throw new Error(`Stored credential payload for account ${accountId} is not valid JSON.`);
|
|
465
|
+
}
|
|
466
|
+
if (!parsed.refresh || !parsed.access || !parsed.expires || !parsed.accountId) throw new Error(`Stored credential payload for account ${accountId} is missing required fields.`);
|
|
467
|
+
return parsed;
|
|
468
|
+
};
|
|
469
|
+
const withService$1 = async (accountId, run, options = {}) => {
|
|
470
|
+
try {
|
|
471
|
+
await ensureLinuxBackend(options);
|
|
472
|
+
} catch (error) {
|
|
473
|
+
throw createLinuxSecureStoreUnavailableError(getErrorMessage(error));
|
|
474
|
+
}
|
|
475
|
+
try {
|
|
476
|
+
return await run(getLinuxCrossKeychainService(accountId));
|
|
477
|
+
} catch (error) {
|
|
478
|
+
if (classifyLinuxSecureStoreError(error) === "store_unavailable") throw createLinuxSecureStoreUnavailableError(getErrorMessage(error));
|
|
479
|
+
throw error;
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
const isInteractiveTerminal = () => Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
|
|
483
|
+
const applyEnvAssignments = (raw) => {
|
|
484
|
+
const lines = raw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
485
|
+
for (const line of lines) {
|
|
486
|
+
const match = line.match(/^([A-Z0-9_]+)=(.*);?$/);
|
|
487
|
+
if (!match) continue;
|
|
488
|
+
const key = match[1];
|
|
489
|
+
const value = match[2].replace(/;$/, "");
|
|
490
|
+
if (key) process.env[key] = value;
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
const runCommandWithInput = async (command, args, input) => await new Promise((resolve) => {
|
|
494
|
+
const child = spawn(command, args, { stdio: [
|
|
495
|
+
"pipe",
|
|
496
|
+
"pipe",
|
|
497
|
+
"pipe"
|
|
498
|
+
] });
|
|
499
|
+
let stdout = "";
|
|
500
|
+
let stderr = "";
|
|
501
|
+
let spawnError = null;
|
|
502
|
+
child.stdout?.on("data", (chunk) => {
|
|
503
|
+
stdout += chunk.toString();
|
|
504
|
+
});
|
|
505
|
+
child.stderr?.on("data", (chunk) => {
|
|
506
|
+
stderr += chunk.toString();
|
|
507
|
+
});
|
|
508
|
+
child.once("error", (error) => {
|
|
509
|
+
spawnError = error.message;
|
|
510
|
+
});
|
|
511
|
+
child.once("close", (code) => {
|
|
512
|
+
resolve({
|
|
513
|
+
ok: spawnError === null && code === 0,
|
|
514
|
+
stdout: stdout.trim(),
|
|
515
|
+
stderr: stderr.trim(),
|
|
516
|
+
...spawnError ? { error: spawnError } : {}
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
child.stdin?.write(input);
|
|
520
|
+
child.stdin?.end();
|
|
521
|
+
});
|
|
522
|
+
const attemptInteractiveLinuxKeyringUnlock = async () => {
|
|
523
|
+
if (!isInteractiveTerminal()) return false;
|
|
524
|
+
const shouldUnlock = await p.confirm({
|
|
525
|
+
message: "Linux keyring appears locked. Unlock it now?",
|
|
526
|
+
initialValue: true
|
|
527
|
+
});
|
|
528
|
+
if (p.isCancel(shouldUnlock) || !shouldUnlock) return false;
|
|
529
|
+
const passphrase = await p.password({
|
|
530
|
+
message: "Enter Linux keyring password:",
|
|
531
|
+
validate: (value) => {
|
|
532
|
+
if (!value || !value.trim()) return "Password is required to unlock keyring.";
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
if (p.isCancel(passphrase) || !passphrase) return false;
|
|
536
|
+
const result = await runCommandWithInput("gnome-keyring-daemon", ["--unlock", "--components=secrets"], `${passphrase}\n`);
|
|
537
|
+
if (!result.ok) {
|
|
538
|
+
const details = result.error || result.stderr || result.stdout;
|
|
539
|
+
if (details) process.stderr.write(`cdx: keyring unlock failed (${details})\n`);
|
|
540
|
+
return false;
|
|
541
|
+
}
|
|
542
|
+
applyEnvAssignments(result.stdout);
|
|
543
|
+
return true;
|
|
544
|
+
};
|
|
545
|
+
const withLinuxUnlockRetry = async (run, options = {}) => {
|
|
546
|
+
let unlockAttempted = false;
|
|
547
|
+
while (true) try {
|
|
548
|
+
return await run();
|
|
549
|
+
} catch (error) {
|
|
550
|
+
const kind = classifyLinuxSecureStoreError(error);
|
|
551
|
+
if (!(!unlockAttempted && (kind === "store_unavailable" || kind === "missing_entry" && options.forWrite && options.retryOnMissingEntryForNativeWrite && selectedBackend$2 === "native-linux"))) throw error;
|
|
552
|
+
unlockAttempted = true;
|
|
553
|
+
if (!await attemptInteractiveLinuxKeyringUnlock()) throw error;
|
|
554
|
+
backendInitPromise$2 = null;
|
|
555
|
+
selectedBackend$2 = null;
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
const trySaveWithSecretServiceFallback = async (accountId, serializedPayload) => {
|
|
559
|
+
if (!await trySwitchBackend("secret-service", { forWrite: true })) return {
|
|
560
|
+
ok: false,
|
|
561
|
+
error: /* @__PURE__ */ new Error("Unable to switch Linux secure-store backend to secret-service fallback.")
|
|
562
|
+
};
|
|
563
|
+
try {
|
|
564
|
+
await setPassword(getLinuxCrossKeychainService(accountId), accountId, serializedPayload);
|
|
565
|
+
return { ok: true };
|
|
566
|
+
} catch (error) {
|
|
567
|
+
return {
|
|
568
|
+
ok: false,
|
|
569
|
+
error
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
const saveLinuxCrossKeychainPayload = async (accountId, payload) => {
|
|
574
|
+
const serialized = JSON.stringify(payload);
|
|
575
|
+
try {
|
|
576
|
+
await withLinuxUnlockRetry(() => withService$1(accountId, (service) => setPassword(service, accountId, serialized), { forWrite: true }), {
|
|
577
|
+
forWrite: true,
|
|
578
|
+
retryOnMissingEntryForNativeWrite: true
|
|
579
|
+
});
|
|
580
|
+
return;
|
|
581
|
+
} catch (error) {
|
|
582
|
+
const kind = classifyLinuxSecureStoreError(error);
|
|
583
|
+
if (kind === "missing_entry" && selectedBackend$2 === "native-linux") {
|
|
584
|
+
const fallbackResult = await trySaveWithSecretServiceFallback(accountId, serialized);
|
|
585
|
+
if (fallbackResult.ok) return;
|
|
586
|
+
throw createLinuxSecureStoreUnavailableError(`Native backend could not create the credential entry (${getErrorMessage(error)}). Fallback secret-service backend also failed (${getErrorMessage(fallbackResult.error)}).`);
|
|
587
|
+
}
|
|
588
|
+
if (kind === "store_unavailable") throw createLinuxSecureStoreUnavailableError(getErrorMessage(error));
|
|
589
|
+
throw error;
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
const loadLinuxCrossKeychainPayload = async (accountId) => {
|
|
593
|
+
try {
|
|
594
|
+
const raw = await withLinuxUnlockRetry(() => withService$1(accountId, (service) => getPassword(service, accountId)));
|
|
595
|
+
if (raw === null) throw new Error(`No stored credentials found for account ${accountId}.`);
|
|
596
|
+
return parsePayload$2(accountId, raw);
|
|
597
|
+
} catch (error) {
|
|
598
|
+
if (classifyLinuxSecureStoreError(error) === "missing_entry") throw new Error(`No stored credentials found for account ${accountId}.`);
|
|
599
|
+
throw error;
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
const deleteLinuxCrossKeychainPayload = async (accountId) => {
|
|
603
|
+
try {
|
|
604
|
+
await withLinuxUnlockRetry(() => withService$1(accountId, (service) => deletePassword(service, accountId)));
|
|
605
|
+
} catch (error) {
|
|
606
|
+
if (classifyLinuxSecureStoreError(error) === "missing_entry") return;
|
|
607
|
+
throw error;
|
|
608
|
+
}
|
|
609
|
+
};
|
|
610
|
+
const linuxCrossKeychainPayloadExists = async (accountId) => {
|
|
611
|
+
try {
|
|
612
|
+
return await withLinuxUnlockRetry(() => withService$1(accountId, async (service) => await getPassword(service, accountId) !== null));
|
|
613
|
+
} catch (error) {
|
|
614
|
+
if (classifyLinuxSecureStoreError(error) === "missing_entry") return false;
|
|
615
|
+
throw error;
|
|
616
|
+
}
|
|
617
|
+
};
|
|
618
|
+
//#endregion
|
|
619
|
+
//#region lib/secrets/macos-cross-keychain.ts
|
|
620
|
+
const SERVICE_PREFIX$1 = "cdx-openai-";
|
|
621
|
+
const MACOS_FALLBACK_SCOPE = "darwin:cross-keychain:macos";
|
|
622
|
+
let backendInitPromise$1 = null;
|
|
623
|
+
let selectedBackend$1 = null;
|
|
624
|
+
const tryUseBackend$1 = async (backendId) => {
|
|
625
|
+
try {
|
|
626
|
+
await useBackend(backendId, getCrossKeychainBackendOverrides());
|
|
627
|
+
return true;
|
|
628
|
+
} catch {
|
|
629
|
+
return false;
|
|
630
|
+
}
|
|
631
|
+
};
|
|
632
|
+
const selectBackend$1 = async () => {
|
|
633
|
+
const backends = await listBackends();
|
|
634
|
+
const available = new Set(backends.map((backend) => backend.id));
|
|
635
|
+
if (available.has("native-macos") && await tryUseBackend$1("native-macos")) return "native-macos";
|
|
636
|
+
if (available.has("macos") && await tryUseBackend$1("macos")) return "macos";
|
|
637
|
+
if (await tryUseBackend$1("native-macos")) return "native-macos";
|
|
638
|
+
if (await tryUseBackend$1("macos")) return "macos";
|
|
639
|
+
throw new Error("Unable to initialize macOS keychain backend via cross-keychain.");
|
|
640
|
+
};
|
|
641
|
+
const ensureMacOSBackend = async (options = {}) => {
|
|
642
|
+
if (!backendInitPromise$1) backendInitPromise$1 = (async () => {
|
|
643
|
+
selectedBackend$1 = await selectBackend$1();
|
|
644
|
+
})();
|
|
645
|
+
try {
|
|
646
|
+
await backendInitPromise$1;
|
|
647
|
+
} catch {
|
|
648
|
+
backendInitPromise$1 = null;
|
|
649
|
+
selectedBackend$1 = null;
|
|
650
|
+
throw new Error("Unable to initialize macOS keychain backend via cross-keychain.");
|
|
651
|
+
}
|
|
652
|
+
if (options.forWrite && selectedBackend$1 === "macos") await ensureFallbackConsent(MACOS_FALLBACK_SCOPE, "⚠ Security warning: only the cross-keychain macOS fallback backend is available.\nThis path uses the `security` command to access Keychain.\nCompared to native bindings, secrets may be more exposed to process inspection/logging while helper commands run.");
|
|
653
|
+
};
|
|
654
|
+
const resolveMacOSCrossKeychainBackendId$1 = async () => {
|
|
655
|
+
await ensureMacOSBackend();
|
|
656
|
+
if (!selectedBackend$1) throw new Error("Unable to initialize macOS keychain backend via cross-keychain.");
|
|
657
|
+
return selectedBackend$1;
|
|
658
|
+
};
|
|
659
|
+
const getMacOSCrossKeychainService = (accountId) => `${SERVICE_PREFIX$1}${accountId}`;
|
|
660
|
+
const parsePayload$1 = (accountId, raw) => {
|
|
661
|
+
let parsed;
|
|
662
|
+
try {
|
|
663
|
+
parsed = JSON.parse(raw);
|
|
664
|
+
} catch {
|
|
665
|
+
throw new Error(`Stored credential payload for account ${accountId} is not valid JSON.`);
|
|
666
|
+
}
|
|
667
|
+
if (!parsed.refresh || !parsed.access || !parsed.expires || !parsed.accountId) throw new Error(`Stored credential payload for account ${accountId} is missing required fields.`);
|
|
668
|
+
return parsed;
|
|
669
|
+
};
|
|
670
|
+
const withService = async (accountId, run, options = {}) => {
|
|
671
|
+
await ensureMacOSBackend(options);
|
|
672
|
+
return run(getMacOSCrossKeychainService(accountId));
|
|
673
|
+
};
|
|
674
|
+
const saveMacOSCrossKeychainPayload = async (accountId, payload) => withService(accountId, (service) => setPassword(service, accountId, JSON.stringify(payload)), { forWrite: true });
|
|
675
|
+
const loadMacOSCrossKeychainPayload = async (accountId) => {
|
|
676
|
+
const raw = await withService(accountId, (service) => getPassword(service, accountId));
|
|
677
|
+
if (raw === null) throw new Error(`No stored credentials found for account ${accountId}.`);
|
|
678
|
+
return parsePayload$1(accountId, raw);
|
|
679
|
+
};
|
|
680
|
+
const deleteMacOSCrossKeychainPayload = async (accountId) => withService(accountId, (service) => deletePassword(service, accountId));
|
|
681
|
+
const macosCrossKeychainPayloadExists = async (accountId) => withService(accountId, async (service) => await getPassword(service, accountId) !== null);
|
|
682
|
+
//#endregion
|
|
683
|
+
//#region lib/secrets/windows-cross-keychain.ts
|
|
684
|
+
const SERVICE_PREFIX = "cdx-openai-";
|
|
685
|
+
const WINDOWS_FALLBACK_SCOPE = "win32:cross-keychain:windows";
|
|
686
|
+
const WINDOWS_VAULT_FILE = "accounts.windows.age";
|
|
687
|
+
const WINDOWS_VAULT_VERSION = 1;
|
|
688
|
+
const WINDOWS_VAULT_KEY_SERVICE = "cdx-openai-vault-passphrase";
|
|
689
|
+
const WINDOWS_VAULT_KEY_ACCOUNT = "windows-v1";
|
|
690
|
+
let backendInitPromise = null;
|
|
691
|
+
let selectedBackend = null;
|
|
692
|
+
const tryUseBackend = async (backendId) => {
|
|
693
|
+
try {
|
|
694
|
+
await useBackend(backendId, getCrossKeychainBackendOverrides());
|
|
695
|
+
return true;
|
|
696
|
+
} catch {
|
|
697
|
+
return false;
|
|
698
|
+
}
|
|
699
|
+
};
|
|
700
|
+
const selectBackend = async () => {
|
|
701
|
+
const backends = await listBackends();
|
|
702
|
+
const available = new Set(backends.map((backend) => backend.id));
|
|
703
|
+
if (available.has("native-windows") && await tryUseBackend("native-windows")) return "native-windows";
|
|
704
|
+
if (available.has("windows") && await tryUseBackend("windows")) return "windows";
|
|
705
|
+
if (await tryUseBackend("native-windows")) return "native-windows";
|
|
706
|
+
if (await tryUseBackend("windows")) return "windows";
|
|
707
|
+
throw new Error("Unable to initialize Windows credential backend via cross-keychain.");
|
|
708
|
+
};
|
|
709
|
+
const ensureWindowsBackend = async (options = {}) => {
|
|
710
|
+
if (!backendInitPromise) backendInitPromise = (async () => {
|
|
711
|
+
selectedBackend = await selectBackend();
|
|
712
|
+
})();
|
|
713
|
+
try {
|
|
714
|
+
await backendInitPromise;
|
|
715
|
+
} catch {
|
|
716
|
+
backendInitPromise = null;
|
|
717
|
+
selectedBackend = null;
|
|
718
|
+
throw new Error("Unable to initialize Windows credential backend via cross-keychain.");
|
|
719
|
+
}
|
|
720
|
+
if (options.forWrite && selectedBackend === "windows") await ensureFallbackConsent(WINDOWS_FALLBACK_SCOPE, "⚠ Security warning: only the cross-keychain Windows fallback backend is available.\nThis path runs a PowerShell helper to access Windows Credential Manager.\nCompared to native bindings, secrets may be more exposed to process inspection/logging while the helper runs.");
|
|
721
|
+
};
|
|
722
|
+
const withWindowsBackend = async (run, options = {}) => {
|
|
723
|
+
await ensureWindowsBackend(options);
|
|
724
|
+
return run();
|
|
725
|
+
};
|
|
726
|
+
const getWindowsVaultPath = () => path.join(getPaths().configDir, WINDOWS_VAULT_FILE);
|
|
727
|
+
const createVaultPassphrase = () => randomBytes(32).toString("hex");
|
|
728
|
+
const getVaultPassphrase = async (options = {}) => {
|
|
729
|
+
const current = await getPassword(WINDOWS_VAULT_KEY_SERVICE, WINDOWS_VAULT_KEY_ACCOUNT);
|
|
730
|
+
if (current) return current;
|
|
731
|
+
if (!options.createIfMissing) return null;
|
|
732
|
+
const generated = createVaultPassphrase();
|
|
733
|
+
await setPassword(WINDOWS_VAULT_KEY_SERVICE, WINDOWS_VAULT_KEY_ACCOUNT, generated);
|
|
734
|
+
return generated;
|
|
735
|
+
};
|
|
736
|
+
const createEmptyVault = () => ({
|
|
737
|
+
version: WINDOWS_VAULT_VERSION,
|
|
738
|
+
accounts: {}
|
|
739
|
+
});
|
|
740
|
+
const parsePayload = (accountId, input) => {
|
|
741
|
+
if (!input || typeof input !== "object") throw new Error(`Stored credential payload for account ${accountId} is not valid JSON.`);
|
|
742
|
+
const parsed = input;
|
|
743
|
+
if (!parsed.refresh || !parsed.access || !parsed.expires || !parsed.accountId) throw new Error(`Stored credential payload for account ${accountId} is missing required fields.`);
|
|
744
|
+
return {
|
|
745
|
+
refresh: parsed.refresh,
|
|
746
|
+
access: parsed.access,
|
|
747
|
+
expires: parsed.expires,
|
|
748
|
+
accountId: parsed.accountId,
|
|
749
|
+
...parsed.idToken ? { idToken: parsed.idToken } : {}
|
|
750
|
+
};
|
|
751
|
+
};
|
|
752
|
+
const parseVault = (raw, source) => {
|
|
753
|
+
let parsed;
|
|
754
|
+
try {
|
|
755
|
+
parsed = JSON.parse(raw);
|
|
756
|
+
} catch {
|
|
757
|
+
throw new Error(`Stored Windows credential vault (${source}) is not valid JSON.`);
|
|
758
|
+
}
|
|
759
|
+
if (!parsed || typeof parsed !== "object") throw new Error(`Stored Windows credential vault (${source}) is not valid JSON.`);
|
|
760
|
+
const vault = parsed;
|
|
761
|
+
const rawAccounts = vault.accounts;
|
|
762
|
+
if (!rawAccounts || typeof rawAccounts !== "object") throw new Error(`Stored Windows credential vault (${source}) is missing account data.`);
|
|
763
|
+
const accounts = {};
|
|
764
|
+
for (const [accountId, payload] of Object.entries(rawAccounts)) accounts[accountId] = parsePayload(accountId, payload);
|
|
765
|
+
return {
|
|
766
|
+
version: typeof vault.version === "number" ? vault.version : WINDOWS_VAULT_VERSION,
|
|
767
|
+
accounts
|
|
768
|
+
};
|
|
769
|
+
};
|
|
770
|
+
const decryptVault = async (ciphertext, passphrase, source) => {
|
|
771
|
+
const decrypter = new Decrypter();
|
|
772
|
+
decrypter.addPassphrase(passphrase);
|
|
773
|
+
let plaintext;
|
|
774
|
+
try {
|
|
775
|
+
plaintext = await decrypter.decrypt(ciphertext, "text");
|
|
776
|
+
} catch {
|
|
777
|
+
throw new Error(`Failed to decrypt Windows credential vault (${source}). Stored passphrase or vault file may be invalid.`);
|
|
778
|
+
}
|
|
779
|
+
return parseVault(plaintext, source);
|
|
780
|
+
};
|
|
781
|
+
const encryptVault = async (vault, passphrase) => {
|
|
782
|
+
const encrypter = new Encrypter();
|
|
783
|
+
encrypter.setPassphrase(passphrase);
|
|
784
|
+
return encrypter.encrypt(JSON.stringify(vault));
|
|
785
|
+
};
|
|
786
|
+
const loadVault = async (passphrase) => {
|
|
787
|
+
const vaultPath = getWindowsVaultPath();
|
|
788
|
+
let ciphertext;
|
|
789
|
+
try {
|
|
790
|
+
ciphertext = await readFile(vaultPath);
|
|
791
|
+
} catch (error) {
|
|
792
|
+
if (error?.code === "ENOENT") return createEmptyVault();
|
|
793
|
+
throw error;
|
|
794
|
+
}
|
|
795
|
+
if (ciphertext.length === 0) return createEmptyVault();
|
|
796
|
+
return decryptVault(ciphertext, passphrase, vaultPath);
|
|
797
|
+
};
|
|
798
|
+
const saveVault = async (vault, passphrase) => {
|
|
799
|
+
const { configDir } = getPaths();
|
|
800
|
+
const vaultPath = getWindowsVaultPath();
|
|
801
|
+
await mkdir(configDir, { recursive: true });
|
|
802
|
+
await writeFile(vaultPath, await encryptVault(vault, passphrase));
|
|
803
|
+
};
|
|
804
|
+
const loadLegacyPayload = async (accountId) => {
|
|
805
|
+
const raw = await getPassword(getWindowsCrossKeychainService(accountId), accountId);
|
|
806
|
+
if (raw === null) return null;
|
|
807
|
+
let parsed;
|
|
808
|
+
try {
|
|
809
|
+
parsed = JSON.parse(raw);
|
|
810
|
+
} catch {
|
|
811
|
+
throw new Error(`Stored credential payload for account ${accountId} is not valid JSON.`);
|
|
812
|
+
}
|
|
813
|
+
return parsePayload(accountId, parsed);
|
|
814
|
+
};
|
|
815
|
+
const deleteLegacyPayload = async (accountId) => {
|
|
816
|
+
const service = getWindowsCrossKeychainService(accountId);
|
|
817
|
+
try {
|
|
818
|
+
await deletePassword(service, accountId);
|
|
819
|
+
} catch {}
|
|
820
|
+
};
|
|
821
|
+
const getWindowsCrossKeychainService = (accountId) => `${SERVICE_PREFIX}${accountId}`;
|
|
822
|
+
const saveWindowsCrossKeychainPayload = async (accountId, payload) => withWindowsBackend(async () => {
|
|
823
|
+
const passphrase = await getVaultPassphrase({ createIfMissing: true });
|
|
824
|
+
if (!passphrase) throw new Error("Unable to resolve Windows credential vault passphrase.");
|
|
825
|
+
const vault = await loadVault(passphrase);
|
|
826
|
+
vault.accounts[accountId] = payload;
|
|
827
|
+
await saveVault(vault, passphrase);
|
|
828
|
+
await deleteLegacyPayload(accountId);
|
|
829
|
+
}, { forWrite: true });
|
|
830
|
+
const loadWindowsCrossKeychainPayload = async (accountId) => withWindowsBackend(async () => {
|
|
831
|
+
const passphrase = await getVaultPassphrase();
|
|
832
|
+
if (passphrase) {
|
|
833
|
+
const payload = (await loadVault(passphrase)).accounts[accountId];
|
|
834
|
+
if (payload) return payload;
|
|
835
|
+
}
|
|
836
|
+
const legacyPayload = await loadLegacyPayload(accountId);
|
|
837
|
+
if (legacyPayload) return legacyPayload;
|
|
838
|
+
throw new Error(`No stored credentials found for account ${accountId}.`);
|
|
839
|
+
});
|
|
840
|
+
const deleteWindowsCrossKeychainPayload = async (accountId) => withWindowsBackend(async () => {
|
|
841
|
+
const passphrase = await getVaultPassphrase();
|
|
842
|
+
if (passphrase) {
|
|
843
|
+
const vault = await loadVault(passphrase);
|
|
844
|
+
if (vault.accounts[accountId]) {
|
|
845
|
+
delete vault.accounts[accountId];
|
|
846
|
+
if (Object.keys(vault.accounts).length === 0) await rm(getWindowsVaultPath(), { force: true });
|
|
847
|
+
else await saveVault(vault, passphrase);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
await deleteLegacyPayload(accountId);
|
|
851
|
+
});
|
|
852
|
+
const windowsCrossKeychainPayloadExists = async (accountId) => withWindowsBackend(async () => {
|
|
853
|
+
const passphrase = await getVaultPassphrase();
|
|
854
|
+
if (passphrase) {
|
|
855
|
+
if ((await loadVault(passphrase)).accounts[accountId]) return true;
|
|
856
|
+
}
|
|
857
|
+
return await loadLegacyPayload(accountId) !== null;
|
|
858
|
+
});
|
|
859
|
+
//#endregion
|
|
860
|
+
//#region lib/secrets/store.ts
|
|
861
|
+
const MISSING_SECRET_STORE_ERROR_MARKERS = [
|
|
862
|
+
"no stored credentials found",
|
|
863
|
+
"no keychain payload found",
|
|
864
|
+
"password not found",
|
|
865
|
+
"no matching entry found in secure storage",
|
|
866
|
+
"no result found"
|
|
867
|
+
];
|
|
868
|
+
const isMissingSecretStoreEntryError = (error) => {
|
|
869
|
+
if (!(error instanceof Error)) return false;
|
|
870
|
+
const message = error.message.toLowerCase();
|
|
871
|
+
return MISSING_SECRET_STORE_ERROR_MARKERS.some((marker) => message.includes(marker));
|
|
872
|
+
};
|
|
873
|
+
const createMissingSecretStoreEntryError = (accountId) => /* @__PURE__ */ new Error(`No stored credentials found for account ${accountId}.`);
|
|
874
|
+
const CACHED_ADAPTER_SYMBOL = Symbol.for("cdx.secretStore.cachedAdapter");
|
|
875
|
+
const withSecretStoreCache = (adapter) => {
|
|
876
|
+
if (adapter[CACHED_ADAPTER_SYMBOL]) return adapter;
|
|
877
|
+
const payloadCache = /* @__PURE__ */ new Map();
|
|
878
|
+
const existsCache = /* @__PURE__ */ new Map();
|
|
879
|
+
const missingAccounts = /* @__PURE__ */ new Set();
|
|
880
|
+
const inFlightLoads = /* @__PURE__ */ new Map();
|
|
881
|
+
const markPresent = (accountId, payload) => {
|
|
882
|
+
payloadCache.set(accountId, payload);
|
|
883
|
+
existsCache.set(accountId, true);
|
|
884
|
+
missingAccounts.delete(accountId);
|
|
885
|
+
};
|
|
886
|
+
const markMissing = (accountId) => {
|
|
887
|
+
payloadCache.delete(accountId);
|
|
888
|
+
existsCache.set(accountId, false);
|
|
889
|
+
missingAccounts.add(accountId);
|
|
890
|
+
};
|
|
891
|
+
const loadAndCache = async (accountId) => {
|
|
892
|
+
const existingPromise = inFlightLoads.get(accountId);
|
|
893
|
+
if (existingPromise) return existingPromise;
|
|
894
|
+
const promise = (async () => {
|
|
895
|
+
try {
|
|
896
|
+
const payload = await adapter.load(accountId);
|
|
897
|
+
markPresent(accountId, payload);
|
|
898
|
+
return payload;
|
|
899
|
+
} catch (error) {
|
|
900
|
+
if (isMissingSecretStoreEntryError(error)) markMissing(accountId);
|
|
901
|
+
throw error;
|
|
902
|
+
} finally {
|
|
903
|
+
inFlightLoads.delete(accountId);
|
|
904
|
+
}
|
|
905
|
+
})();
|
|
906
|
+
inFlightLoads.set(accountId, promise);
|
|
907
|
+
return promise;
|
|
908
|
+
};
|
|
909
|
+
return {
|
|
910
|
+
id: adapter.id,
|
|
911
|
+
label: adapter.label,
|
|
912
|
+
getServiceName: (accountId) => adapter.getServiceName(accountId),
|
|
913
|
+
save: async (accountId, payload) => {
|
|
914
|
+
await adapter.save(accountId, payload);
|
|
915
|
+
markPresent(accountId, payload);
|
|
916
|
+
},
|
|
917
|
+
load: async (accountId) => {
|
|
918
|
+
const cachedPayload = payloadCache.get(accountId);
|
|
919
|
+
if (cachedPayload) return cachedPayload;
|
|
920
|
+
if (missingAccounts.has(accountId)) throw createMissingSecretStoreEntryError(accountId);
|
|
921
|
+
return loadAndCache(accountId);
|
|
922
|
+
},
|
|
923
|
+
delete: async (accountId) => {
|
|
924
|
+
try {
|
|
925
|
+
await adapter.delete(accountId);
|
|
926
|
+
} catch (error) {
|
|
927
|
+
if (isMissingSecretStoreEntryError(error)) markMissing(accountId);
|
|
928
|
+
throw error;
|
|
929
|
+
}
|
|
930
|
+
markMissing(accountId);
|
|
931
|
+
},
|
|
932
|
+
exists: async (accountId) => {
|
|
933
|
+
if (payloadCache.has(accountId)) return true;
|
|
934
|
+
if (missingAccounts.has(accountId)) return false;
|
|
935
|
+
const cachedExists = existsCache.get(accountId);
|
|
936
|
+
if (cachedExists !== void 0) return cachedExists;
|
|
937
|
+
try {
|
|
938
|
+
await loadAndCache(accountId);
|
|
939
|
+
return true;
|
|
940
|
+
} catch (error) {
|
|
941
|
+
if (isMissingSecretStoreEntryError(error)) return false;
|
|
942
|
+
}
|
|
943
|
+
const exists = await adapter.exists(accountId);
|
|
944
|
+
existsCache.set(accountId, exists);
|
|
945
|
+
if (!exists) missingAccounts.add(accountId);
|
|
946
|
+
return exists;
|
|
947
|
+
},
|
|
948
|
+
listAccountIds: async () => {
|
|
949
|
+
const accountIds = await adapter.listAccountIds();
|
|
950
|
+
for (const accountId of accountIds) {
|
|
951
|
+
existsCache.set(accountId, true);
|
|
952
|
+
missingAccounts.delete(accountId);
|
|
953
|
+
}
|
|
954
|
+
return accountIds;
|
|
955
|
+
},
|
|
956
|
+
getCapability: () => adapter.getCapability(),
|
|
957
|
+
[CACHED_ADAPTER_SYMBOL]: true
|
|
958
|
+
};
|
|
959
|
+
};
|
|
960
|
+
const unsupportedError = (platform) => /* @__PURE__ */ new Error(`No default secret store adapter configured for platform '${platform}'. Only macOS, Windows, and Linux adapters are wired by default right now.`);
|
|
961
|
+
const loadConfiguredAccountIds = async () => {
|
|
962
|
+
if (!configExists()) return [];
|
|
963
|
+
return (await loadConfig()).accounts.map((account) => account.accountId);
|
|
964
|
+
};
|
|
965
|
+
const createMacOSCrossKeychainAdapter = () => ({
|
|
966
|
+
id: "macos-cross-keychain",
|
|
967
|
+
label: "macOS Keychain (cross-keychain)",
|
|
968
|
+
getServiceName: getMacOSCrossKeychainService,
|
|
969
|
+
save: saveMacOSCrossKeychainPayload,
|
|
970
|
+
load: loadMacOSCrossKeychainPayload,
|
|
971
|
+
delete: deleteMacOSCrossKeychainPayload,
|
|
972
|
+
exists: macosCrossKeychainPayloadExists,
|
|
973
|
+
listAccountIds: async () => {
|
|
974
|
+
const accountIds = await loadConfiguredAccountIds();
|
|
975
|
+
return (await Promise.all(accountIds.map(async (accountId) => ({
|
|
976
|
+
accountId,
|
|
977
|
+
exists: await macosCrossKeychainPayloadExists(accountId)
|
|
978
|
+
})))).filter((item) => item.exists).map((item) => item.accountId);
|
|
979
|
+
},
|
|
980
|
+
getCapability: () => ({ available: true })
|
|
981
|
+
});
|
|
982
|
+
const createMacOSLegacyKeychainAdapter = () => ({
|
|
983
|
+
id: "macos-legacy-keychain",
|
|
984
|
+
label: "macOS Keychain (legacy security CLI)",
|
|
985
|
+
getServiceName: getKeychainService,
|
|
986
|
+
save: async (accountId, payload) => {
|
|
987
|
+
saveKeychainPayload(accountId, payload);
|
|
988
|
+
},
|
|
989
|
+
load: async (accountId) => loadKeychainPayload(accountId),
|
|
990
|
+
delete: async (accountId) => {
|
|
991
|
+
deleteKeychainPayload(accountId);
|
|
992
|
+
},
|
|
993
|
+
exists: async (accountId) => keychainPayloadExists(accountId),
|
|
994
|
+
listAccountIds: async () => listKeychainAccounts(),
|
|
995
|
+
getCapability: () => ({ available: true })
|
|
996
|
+
});
|
|
997
|
+
const createWindowsCrossKeychainAdapter = () => ({
|
|
998
|
+
id: "windows-cross-keychain",
|
|
999
|
+
label: "Windows Credential Manager (cross-keychain)",
|
|
1000
|
+
getServiceName: getWindowsCrossKeychainService,
|
|
1001
|
+
save: saveWindowsCrossKeychainPayload,
|
|
1002
|
+
load: loadWindowsCrossKeychainPayload,
|
|
1003
|
+
delete: deleteWindowsCrossKeychainPayload,
|
|
1004
|
+
exists: windowsCrossKeychainPayloadExists,
|
|
1005
|
+
listAccountIds: async () => {
|
|
1006
|
+
const accountIds = await loadConfiguredAccountIds();
|
|
1007
|
+
return (await Promise.all(accountIds.map(async (accountId) => ({
|
|
1008
|
+
accountId,
|
|
1009
|
+
exists: await windowsCrossKeychainPayloadExists(accountId)
|
|
1010
|
+
})))).filter((item) => item.exists).map((item) => item.accountId);
|
|
1011
|
+
},
|
|
1012
|
+
getCapability: () => ({ available: true })
|
|
1013
|
+
});
|
|
1014
|
+
const createLinuxCrossKeychainAdapter = () => ({
|
|
1015
|
+
id: "linux-cross-keychain",
|
|
1016
|
+
label: "Linux Secret Service (cross-keychain)",
|
|
1017
|
+
getServiceName: getLinuxCrossKeychainService,
|
|
1018
|
+
save: saveLinuxCrossKeychainPayload,
|
|
1019
|
+
load: loadLinuxCrossKeychainPayload,
|
|
1020
|
+
delete: deleteLinuxCrossKeychainPayload,
|
|
1021
|
+
exists: linuxCrossKeychainPayloadExists,
|
|
1022
|
+
listAccountIds: async () => {
|
|
1023
|
+
const accountIds = await loadConfiguredAccountIds();
|
|
1024
|
+
return (await Promise.all(accountIds.map(async (accountId) => ({
|
|
1025
|
+
accountId,
|
|
1026
|
+
exists: await linuxCrossKeychainPayloadExists(accountId)
|
|
1027
|
+
})))).filter((item) => item.exists).map((item) => item.accountId);
|
|
1028
|
+
},
|
|
1029
|
+
getCapability: () => ({ available: true })
|
|
1030
|
+
});
|
|
1031
|
+
const createUnsupportedAdapter = (platform) => ({
|
|
1032
|
+
id: "unsupported",
|
|
1033
|
+
label: "Unsupported (no adapter configured)",
|
|
1034
|
+
getServiceName: (accountId) => `cdx-openai-${accountId}`,
|
|
1035
|
+
save: async () => {
|
|
1036
|
+
throw unsupportedError(platform);
|
|
1037
|
+
},
|
|
1038
|
+
load: async () => {
|
|
1039
|
+
throw unsupportedError(platform);
|
|
1040
|
+
},
|
|
1041
|
+
delete: async () => {
|
|
1042
|
+
throw unsupportedError(platform);
|
|
1043
|
+
},
|
|
1044
|
+
exists: async () => false,
|
|
1045
|
+
listAccountIds: async () => [],
|
|
1046
|
+
getCapability: () => ({
|
|
1047
|
+
available: false,
|
|
1048
|
+
reason: "No default secure-store adapter available for this platform."
|
|
1049
|
+
})
|
|
1050
|
+
});
|
|
1051
|
+
const createRuntimeSecretStoreAdapter = (platform = process.platform) => {
|
|
1052
|
+
if (platform === "darwin") return createMacOSCrossKeychainAdapter();
|
|
1053
|
+
if (platform === "win32") return createWindowsCrossKeychainAdapter();
|
|
1054
|
+
if (platform === "linux") return createLinuxCrossKeychainAdapter();
|
|
1055
|
+
return createUnsupportedAdapter(platform);
|
|
1056
|
+
};
|
|
1057
|
+
const createSecretStoreAdapterFromSelection = (selection = "auto", platform = process.platform) => {
|
|
1058
|
+
if (selection === "legacy-keychain") {
|
|
1059
|
+
if (platform !== "darwin") throw new Error("The legacy keychain adapter is only available on macOS (darwin).");
|
|
1060
|
+
return createMacOSLegacyKeychainAdapter();
|
|
1061
|
+
}
|
|
1062
|
+
return createRuntimeSecretStoreAdapter(platform);
|
|
1063
|
+
};
|
|
1064
|
+
const resolveMacOSCrossKeychainBackendId = async (platform = process.platform) => {
|
|
1065
|
+
if (platform !== "darwin") return null;
|
|
1066
|
+
return resolveMacOSCrossKeychainBackendId$1();
|
|
1067
|
+
};
|
|
1068
|
+
let currentSecretStoreAdapter = withSecretStoreCache(createRuntimeSecretStoreAdapter());
|
|
1069
|
+
const getSecretStoreAdapter = () => currentSecretStoreAdapter;
|
|
1070
|
+
const setSecretStoreAdapter = (adapter) => {
|
|
1071
|
+
currentSecretStoreAdapter = withSecretStoreCache(adapter);
|
|
1072
|
+
};
|
|
1073
|
+
const resetSecretStoreAdapter = () => {
|
|
1074
|
+
currentSecretStoreAdapter = withSecretStoreCache(createRuntimeSecretStoreAdapter());
|
|
1075
|
+
};
|
|
1076
|
+
const getSecretStoreCapability = () => {
|
|
1077
|
+
const adapter = getSecretStoreAdapter();
|
|
1078
|
+
const capability = adapter.getCapability();
|
|
1079
|
+
return {
|
|
1080
|
+
id: adapter.id,
|
|
1081
|
+
label: adapter.label,
|
|
1082
|
+
available: capability.available,
|
|
1083
|
+
...capability.reason ? { reason: capability.reason } : {}
|
|
1084
|
+
};
|
|
1085
|
+
};
|
|
1086
|
+
//#endregion
|
|
1087
|
+
//#region lib/oauth/constants.ts
|
|
1088
|
+
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
1089
|
+
const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
|
|
1090
|
+
const DEVICE_CODE_URL = "https://auth.openai.com/oauth/device/code";
|
|
1091
|
+
const TOKEN_URL = "https://auth.openai.com/oauth/token";
|
|
1092
|
+
const REDIRECT_URI = "http://localhost:1455/auth/callback";
|
|
1093
|
+
const SCOPE = "openid profile email offline_access api.connectors.read api.connectors.invoke";
|
|
1094
|
+
const CALLBACK_PORT = 1455;
|
|
1095
|
+
/**
|
|
1096
|
+
* Preserve an explicit Codex Desktop originator override when it is present.
|
|
1097
|
+
* Regular terminals use the Desktop default above.
|
|
1098
|
+
*/
|
|
1099
|
+
const resolveOriginator = (env = process.env) => {
|
|
1100
|
+
return env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE?.trim() || "Codex Desktop";
|
|
1101
|
+
};
|
|
1102
|
+
//#endregion
|
|
1103
|
+
//#region lib/oauth/auth.ts
|
|
1104
|
+
/**
|
|
1105
|
+
* Create an RFC 7636 S256 PKCE verifier/challenge pair using Node's built-in
|
|
1106
|
+
* cryptography. Keeping this small primitive local avoids shipping an OAuth
|
|
1107
|
+
* helper dependency solely for PKCE generation.
|
|
1108
|
+
*/
|
|
1109
|
+
const createPKCE = () => {
|
|
1110
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
1111
|
+
return {
|
|
1112
|
+
verifier,
|
|
1113
|
+
challenge: createHash("sha256").update(verifier).digest("base64url")
|
|
1114
|
+
};
|
|
1115
|
+
};
|
|
1116
|
+
const createState = () => {
|
|
1117
|
+
return randomBytes(32).toString("base64url");
|
|
1118
|
+
};
|
|
1119
|
+
const createAuthorizationFlow = async () => {
|
|
1120
|
+
const pkce = createPKCE();
|
|
1121
|
+
const state = createState();
|
|
1122
|
+
const url = new URL(AUTHORIZE_URL);
|
|
1123
|
+
url.searchParams.set("response_type", "code");
|
|
1124
|
+
url.searchParams.set("client_id", CLIENT_ID);
|
|
1125
|
+
url.searchParams.set("redirect_uri", REDIRECT_URI);
|
|
1126
|
+
url.searchParams.set("scope", SCOPE);
|
|
1127
|
+
url.searchParams.set("code_challenge", pkce.challenge);
|
|
1128
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
1129
|
+
url.searchParams.set("id_token_add_organizations", "true");
|
|
1130
|
+
url.searchParams.set("codex_cli_simplified_flow", "true");
|
|
1131
|
+
url.searchParams.set("state", state);
|
|
1132
|
+
url.searchParams.set("originator", resolveOriginator());
|
|
1133
|
+
return {
|
|
1134
|
+
pkce,
|
|
1135
|
+
state,
|
|
1136
|
+
url: url.toString().replaceAll("+", "%20")
|
|
1137
|
+
};
|
|
1138
|
+
};
|
|
1139
|
+
const truncateForLog = (value, maxLength = 300) => {
|
|
1140
|
+
if (value.length <= maxLength) return value;
|
|
1141
|
+
return `${value.slice(0, maxLength)}…`;
|
|
1142
|
+
};
|
|
1143
|
+
const isCloudflareChallengeResponse = (headers, bodyText) => {
|
|
1144
|
+
if (headers.get("cf-mitigated")?.toLowerCase() === "challenge") return true;
|
|
1145
|
+
if (!bodyText) return false;
|
|
1146
|
+
return [
|
|
1147
|
+
/<title>Just a moment\.\.\.<\/title>/i,
|
|
1148
|
+
/Enable JavaScript and cookies to continue/i,
|
|
1149
|
+
/challenge-platform/i,
|
|
1150
|
+
/_cf_chl_opt/i
|
|
1151
|
+
].some((pattern) => pattern.test(bodyText));
|
|
1152
|
+
};
|
|
1153
|
+
const parseOAuthErrorResponse = async (res) => {
|
|
1154
|
+
let rawBody;
|
|
1155
|
+
try {
|
|
1156
|
+
rawBody = await res.text();
|
|
1157
|
+
} catch {
|
|
1158
|
+
rawBody = void 0;
|
|
1159
|
+
}
|
|
1160
|
+
const failureReason = isCloudflareChallengeResponse(res.headers, rawBody) ? "cloudflare_challenge" : void 0;
|
|
1161
|
+
if (!rawBody) return { ...failureReason ? { failureReason } : {} };
|
|
1162
|
+
const trimmed = rawBody.trim();
|
|
1163
|
+
if (!trimmed) return { ...failureReason ? { failureReason } : {} };
|
|
1164
|
+
try {
|
|
1165
|
+
const json = JSON.parse(trimmed);
|
|
1166
|
+
return {
|
|
1167
|
+
...json.error ? { oauthError: json.error } : {},
|
|
1168
|
+
...typeof json.interval === "number" ? { interval: json.interval } : {},
|
|
1169
|
+
responseBody: truncateForLog(JSON.stringify({
|
|
1170
|
+
...json.error ? { error: json.error } : {},
|
|
1171
|
+
...json.error_description ? { error_description: json.error_description } : {},
|
|
1172
|
+
...typeof json.interval === "number" ? { interval: json.interval } : {}
|
|
1173
|
+
})),
|
|
1174
|
+
...failureReason ? { failureReason } : {}
|
|
1175
|
+
};
|
|
1176
|
+
} catch {
|
|
1177
|
+
return {
|
|
1178
|
+
responseBody: failureReason === "cloudflare_challenge" ? "Cloudflare challenge response detected (HTML page)" : truncateForLog(trimmed),
|
|
1179
|
+
...failureReason ? { failureReason } : {}
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
};
|
|
1183
|
+
const startDeviceAuthorizationFlow = async () => {
|
|
1184
|
+
try {
|
|
1185
|
+
const res = await fetch(DEVICE_CODE_URL, {
|
|
1186
|
+
method: "POST",
|
|
1187
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1188
|
+
body: new URLSearchParams({
|
|
1189
|
+
client_id: CLIENT_ID,
|
|
1190
|
+
scope: SCOPE
|
|
1191
|
+
})
|
|
1192
|
+
});
|
|
1193
|
+
if (!res.ok) {
|
|
1194
|
+
const { oauthError, responseBody, failureReason } = await parseOAuthErrorResponse(res);
|
|
1195
|
+
return {
|
|
1196
|
+
type: "failed",
|
|
1197
|
+
error: failureReason === "cloudflare_challenge" ? "Device code request was blocked by a Cloudflare challenge response." : `Device code request failed with HTTP ${res.status} ${res.statusText}`,
|
|
1198
|
+
status: res.status,
|
|
1199
|
+
...oauthError ? { oauthError } : {},
|
|
1200
|
+
...responseBody ? { responseBody } : {},
|
|
1201
|
+
...failureReason ? { failureReason } : {}
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
const json = await res.json();
|
|
1205
|
+
if (!json?.device_code || !json?.user_code || !json?.verification_uri || typeof json?.expires_in !== "number") return {
|
|
1206
|
+
type: "failed",
|
|
1207
|
+
error: "Device code response is missing required fields.",
|
|
1208
|
+
responseBody: truncateForLog(JSON.stringify(json))
|
|
1209
|
+
};
|
|
1210
|
+
return {
|
|
1211
|
+
type: "success",
|
|
1212
|
+
flow: {
|
|
1213
|
+
deviceCode: json.device_code,
|
|
1214
|
+
userCode: json.user_code,
|
|
1215
|
+
verificationUri: json.verification_uri,
|
|
1216
|
+
verificationUriComplete: json.verification_uri_complete,
|
|
1217
|
+
expiresIn: json.expires_in,
|
|
1218
|
+
interval: typeof json.interval === "number" && json.interval > 0 ? json.interval : 5
|
|
1219
|
+
}
|
|
1220
|
+
};
|
|
1221
|
+
} catch (error) {
|
|
1222
|
+
return {
|
|
1223
|
+
type: "failed",
|
|
1224
|
+
error: `Device code request failed: ${error instanceof Error ? error.message : String(error)}`
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
};
|
|
1228
|
+
const pollDeviceAuthorizationToken = async (deviceCode) => {
|
|
1229
|
+
try {
|
|
1230
|
+
const res = await fetch(TOKEN_URL, {
|
|
1231
|
+
method: "POST",
|
|
1232
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1233
|
+
body: new URLSearchParams({
|
|
1234
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
1235
|
+
device_code: deviceCode,
|
|
1236
|
+
client_id: CLIENT_ID
|
|
1237
|
+
})
|
|
1238
|
+
});
|
|
1239
|
+
if (res.ok) {
|
|
1240
|
+
const json = await res.json();
|
|
1241
|
+
if (!json?.access_token || !json?.refresh_token || typeof json?.expires_in !== "number") return {
|
|
1242
|
+
type: "failed",
|
|
1243
|
+
error: "Device token response is missing access_token/refresh_token/expires_in.",
|
|
1244
|
+
responseBody: truncateForLog(JSON.stringify(json))
|
|
1245
|
+
};
|
|
1246
|
+
return {
|
|
1247
|
+
type: "success",
|
|
1248
|
+
access: json.access_token,
|
|
1249
|
+
refresh: json.refresh_token,
|
|
1250
|
+
expires: Date.now() + json.expires_in * 1e3,
|
|
1251
|
+
idToken: json.id_token
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
const { oauthError: errorCode, interval, responseBody, failureReason } = await parseOAuthErrorResponse(res);
|
|
1255
|
+
if (errorCode === "authorization_pending") return {
|
|
1256
|
+
type: "pending",
|
|
1257
|
+
interval: typeof interval === "number" && interval > 0 ? interval : 5
|
|
1258
|
+
};
|
|
1259
|
+
if (errorCode === "slow_down") return {
|
|
1260
|
+
type: "slow_down",
|
|
1261
|
+
interval: typeof interval === "number" && interval > 0 ? interval : 10
|
|
1262
|
+
};
|
|
1263
|
+
if (errorCode === "access_denied") return { type: "access_denied" };
|
|
1264
|
+
if (errorCode === "expired_token") return { type: "expired" };
|
|
1265
|
+
return {
|
|
1266
|
+
type: "failed",
|
|
1267
|
+
error: failureReason === "cloudflare_challenge" ? "Device token polling was blocked by a Cloudflare challenge response." : `Device token polling failed with HTTP ${res.status} ${res.statusText}`,
|
|
1268
|
+
status: res.status,
|
|
1269
|
+
...errorCode ? { oauthError: errorCode } : {},
|
|
1270
|
+
...responseBody ? { responseBody } : {},
|
|
1271
|
+
...failureReason ? { failureReason } : {}
|
|
1272
|
+
};
|
|
1273
|
+
} catch (error) {
|
|
1274
|
+
return {
|
|
1275
|
+
type: "failed",
|
|
1276
|
+
error: `Device token polling request failed: ${error instanceof Error ? error.message : String(error)}`
|
|
1277
|
+
};
|
|
1278
|
+
}
|
|
1279
|
+
};
|
|
1280
|
+
const exchangeAuthorizationCode = async (code, verifier) => {
|
|
1281
|
+
const res = await fetch(TOKEN_URL, {
|
|
1282
|
+
method: "POST",
|
|
1283
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1284
|
+
body: new URLSearchParams({
|
|
1285
|
+
grant_type: "authorization_code",
|
|
1286
|
+
client_id: CLIENT_ID,
|
|
1287
|
+
code,
|
|
1288
|
+
code_verifier: verifier,
|
|
1289
|
+
redirect_uri: REDIRECT_URI
|
|
1290
|
+
})
|
|
1291
|
+
});
|
|
1292
|
+
if (!res.ok) return { type: "failed" };
|
|
1293
|
+
const json = await res.json();
|
|
1294
|
+
if (!json?.access_token || !json?.refresh_token || typeof json?.expires_in !== "number") return { type: "failed" };
|
|
1295
|
+
return {
|
|
1296
|
+
type: "success",
|
|
1297
|
+
access: json.access_token,
|
|
1298
|
+
refresh: json.refresh_token,
|
|
1299
|
+
expires: Date.now() + json.expires_in * 1e3,
|
|
1300
|
+
idToken: json.id_token
|
|
1301
|
+
};
|
|
1302
|
+
};
|
|
1303
|
+
const refreshAccessToken = async (refreshToken) => {
|
|
1304
|
+
try {
|
|
1305
|
+
const response = await fetch(TOKEN_URL, {
|
|
1306
|
+
method: "POST",
|
|
1307
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1308
|
+
body: new URLSearchParams({
|
|
1309
|
+
grant_type: "refresh_token",
|
|
1310
|
+
refresh_token: refreshToken,
|
|
1311
|
+
client_id: CLIENT_ID
|
|
1312
|
+
})
|
|
1313
|
+
});
|
|
1314
|
+
if (!response.ok) return { type: "failed" };
|
|
1315
|
+
const json = await response.json();
|
|
1316
|
+
if (!json?.access_token || !json?.refresh_token || typeof json?.expires_in !== "number") return { type: "failed" };
|
|
1317
|
+
return {
|
|
1318
|
+
type: "success",
|
|
1319
|
+
access: json.access_token,
|
|
1320
|
+
refresh: json.refresh_token,
|
|
1321
|
+
expires: Date.now() + json.expires_in * 1e3,
|
|
1322
|
+
idToken: json.id_token
|
|
1323
|
+
};
|
|
1324
|
+
} catch {
|
|
1325
|
+
return { type: "failed" };
|
|
1326
|
+
}
|
|
1327
|
+
};
|
|
1328
|
+
const decodeJWT = (token) => {
|
|
1329
|
+
try {
|
|
1330
|
+
const parts = token.split(".");
|
|
1331
|
+
if (parts.length !== 3) return null;
|
|
1332
|
+
const payload = parts[1];
|
|
1333
|
+
const decoded = Buffer.from(payload, "base64url").toString("utf-8");
|
|
1334
|
+
return JSON.parse(decoded);
|
|
1335
|
+
} catch {
|
|
1336
|
+
return null;
|
|
1337
|
+
}
|
|
1338
|
+
};
|
|
1339
|
+
const extractAccountId = (accessToken) => {
|
|
1340
|
+
const payload = decodeJWT(accessToken);
|
|
1341
|
+
if (!payload) return null;
|
|
1342
|
+
const authClaim = payload["https://api.openai.com/auth"];
|
|
1343
|
+
if (payload.chatgpt_account_id) return payload.chatgpt_account_id;
|
|
1344
|
+
if (authClaim?.chatgpt_account_id) return authClaim.chatgpt_account_id;
|
|
1345
|
+
const organizationId = payload.organizations?.find((organization) => typeof organization?.id === "string" && organization.id.length > 0)?.id;
|
|
1346
|
+
if (organizationId) return organizationId;
|
|
1347
|
+
if (authClaim?.user_id) return authClaim.user_id;
|
|
1348
|
+
return payload.sub ?? null;
|
|
1349
|
+
};
|
|
1350
|
+
/**
|
|
1351
|
+
* Codex persists the ChatGPT account ID from the ID token. Prefer that token
|
|
1352
|
+
* because its claims are tailored for the Codex CLI, then fall back to the
|
|
1353
|
+
* access token for OAuth responses that do not include an ID token.
|
|
1354
|
+
*/
|
|
1355
|
+
const extractAccountIdFromTokens = (idToken, accessToken) => {
|
|
1356
|
+
return (idToken ? extractAccountId(idToken) : null) ?? extractAccountId(accessToken);
|
|
1357
|
+
};
|
|
1358
|
+
//#endregion
|
|
1359
|
+
//#region lib/usage.ts
|
|
1360
|
+
const USAGE_ENDPOINT = "https://chatgpt.com/backend-api/wham/usage";
|
|
1361
|
+
const USER_AGENT = "cdx-cli";
|
|
1362
|
+
/**
|
|
1363
|
+
* Hits the undocumented OpenAI usage endpoint (may change without notice).
|
|
1364
|
+
*/
|
|
1365
|
+
const fetchUsageRaw = async (accessToken, accountId) => {
|
|
1366
|
+
const headers = {
|
|
1367
|
+
Authorization: `Bearer ${accessToken}`,
|
|
1368
|
+
"User-Agent": USER_AGENT,
|
|
1369
|
+
Accept: "application/json"
|
|
1370
|
+
};
|
|
1371
|
+
if (accountId) headers["ChatGPT-Account-Id"] = accountId;
|
|
1372
|
+
return fetch(USAGE_ENDPOINT, { headers });
|
|
1373
|
+
};
|
|
1374
|
+
/**
|
|
1375
|
+
* Fetches usage for an account. On 401, refreshes the token and retries once.
|
|
1376
|
+
*/
|
|
1377
|
+
const fetchUsage = async (accountId, secretStore = getSecretStoreAdapter()) => {
|
|
1378
|
+
let payload;
|
|
1379
|
+
try {
|
|
1380
|
+
payload = await secretStore.load(accountId);
|
|
1381
|
+
} catch (err) {
|
|
1382
|
+
return {
|
|
1383
|
+
ok: false,
|
|
1384
|
+
error: {
|
|
1385
|
+
type: "auth_failed",
|
|
1386
|
+
message: err instanceof Error ? err.message : "Failed to load credentials"
|
|
1387
|
+
}
|
|
1388
|
+
};
|
|
1389
|
+
}
|
|
1390
|
+
try {
|
|
1391
|
+
let response = await fetchUsageRaw(payload.access, payload.accountId);
|
|
1392
|
+
if (response.status === 401) {
|
|
1393
|
+
const refreshResult = await refreshAccessToken(payload.refresh);
|
|
1394
|
+
if (refreshResult.type === "failed") return {
|
|
1395
|
+
ok: false,
|
|
1396
|
+
error: {
|
|
1397
|
+
type: "auth_failed",
|
|
1398
|
+
message: "Token expired and refresh failed. Try 'cdx login' to re-authenticate."
|
|
1399
|
+
}
|
|
1400
|
+
};
|
|
1401
|
+
const updatedPayload = {
|
|
1402
|
+
...payload,
|
|
1403
|
+
access: refreshResult.access,
|
|
1404
|
+
refresh: refreshResult.refresh,
|
|
1405
|
+
expires: refreshResult.expires,
|
|
1406
|
+
idToken: refreshResult.idToken ?? payload.idToken
|
|
1407
|
+
};
|
|
1408
|
+
await secretStore.save(accountId, updatedPayload);
|
|
1409
|
+
response = await fetchUsageRaw(updatedPayload.access, updatedPayload.accountId);
|
|
1410
|
+
if (!response.ok) return {
|
|
1411
|
+
ok: false,
|
|
1412
|
+
error: {
|
|
1413
|
+
type: "auth_failed",
|
|
1414
|
+
message: `Usage API returned ${response.status} after token refresh.`
|
|
1415
|
+
}
|
|
1416
|
+
};
|
|
1417
|
+
} else if (!response.ok) return {
|
|
1418
|
+
ok: false,
|
|
1419
|
+
error: {
|
|
1420
|
+
type: "unexpected",
|
|
1421
|
+
message: `Usage API returned ${response.status}: ${response.statusText}`
|
|
1422
|
+
}
|
|
1423
|
+
};
|
|
1424
|
+
return {
|
|
1425
|
+
ok: true,
|
|
1426
|
+
data: await response.json()
|
|
1427
|
+
};
|
|
1428
|
+
} catch (err) {
|
|
1429
|
+
return {
|
|
1430
|
+
ok: false,
|
|
1431
|
+
error: {
|
|
1432
|
+
type: "network_error",
|
|
1433
|
+
message: err instanceof Error ? err.message : "Network request failed"
|
|
1434
|
+
}
|
|
1435
|
+
};
|
|
1436
|
+
}
|
|
1437
|
+
};
|
|
1438
|
+
const formatWindowLabel = (seconds) => {
|
|
1439
|
+
const hours = seconds / 3600;
|
|
1440
|
+
if (hours >= 24) {
|
|
1441
|
+
const days = Math.round(hours / 24);
|
|
1442
|
+
return days === 7 ? "weekly" : `${days}d`;
|
|
1443
|
+
}
|
|
1444
|
+
return `${Math.round(hours)}h`;
|
|
1445
|
+
};
|
|
1446
|
+
const formatResetCountdown = (resetAtUnix) => {
|
|
1447
|
+
const diff = resetAtUnix * 1e3 - Date.now();
|
|
1448
|
+
if (diff <= 0) return "now";
|
|
1449
|
+
const minutes = Math.floor(diff / 6e4);
|
|
1450
|
+
const hours = Math.floor(minutes / 60);
|
|
1451
|
+
const remainingMinutes = minutes % 60;
|
|
1452
|
+
if (hours > 0) return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
|
|
1453
|
+
return `${minutes}m`;
|
|
1454
|
+
};
|
|
1455
|
+
const formatPercentageBar = (usedPercent) => {
|
|
1456
|
+
const width = 20;
|
|
1457
|
+
const filled = Math.round(usedPercent / 100 * width);
|
|
1458
|
+
const empty = width - filled;
|
|
1459
|
+
return `[${"█".repeat(filled)}${"░".repeat(empty)}] ${usedPercent}% used`;
|
|
1460
|
+
};
|
|
1461
|
+
const formatWindow = (label, w) => {
|
|
1462
|
+
return [
|
|
1463
|
+
`${label} (${formatWindowLabel(w.limit_window_seconds)} window):`,
|
|
1464
|
+
` ${formatPercentageBar(w.used_percent)}`,
|
|
1465
|
+
` Resets in: ${formatResetCountdown(w.reset_at)}`
|
|
1466
|
+
];
|
|
1467
|
+
};
|
|
1468
|
+
const formatUsage = (usage) => {
|
|
1469
|
+
const lines = [];
|
|
1470
|
+
const plan = usage.plan_type ?? "unknown";
|
|
1471
|
+
lines.push(`Plan: ${plan}`);
|
|
1472
|
+
lines.push("");
|
|
1473
|
+
if (usage.rate_limit?.primary_window) lines.push(...formatWindow("Primary", usage.rate_limit.primary_window));
|
|
1474
|
+
if (usage.rate_limit?.secondary_window) lines.push(...formatWindow("Secondary", usage.rate_limit.secondary_window));
|
|
1475
|
+
if (usage.credits) {
|
|
1476
|
+
lines.push("");
|
|
1477
|
+
if (usage.credits.unlimited) lines.push("Credits: unlimited");
|
|
1478
|
+
else if (usage.credits.has_credits && usage.credits.balance !== void 0) lines.push(`Credits: $${Number(usage.credits.balance).toFixed(2)}`);
|
|
1479
|
+
else if (!usage.credits.has_credits) lines.push("Credits: none");
|
|
1480
|
+
}
|
|
1481
|
+
return lines.join("\n");
|
|
1482
|
+
};
|
|
1483
|
+
const formatUsageBars = (usage, indent = " ") => {
|
|
1484
|
+
const windows = [];
|
|
1485
|
+
if (usage.rate_limit?.primary_window) windows.push({
|
|
1486
|
+
label: formatWindowLabel(usage.rate_limit.primary_window.limit_window_seconds),
|
|
1487
|
+
window: usage.rate_limit.primary_window
|
|
1488
|
+
});
|
|
1489
|
+
if (usage.rate_limit?.secondary_window) windows.push({
|
|
1490
|
+
label: formatWindowLabel(usage.rate_limit.secondary_window.limit_window_seconds),
|
|
1491
|
+
window: usage.rate_limit.secondary_window
|
|
1492
|
+
});
|
|
1493
|
+
const maxLabelLen = Math.max(...windows.map((w) => w.label.length), 0);
|
|
1494
|
+
return windows.map(({ label, window: w }) => {
|
|
1495
|
+
return `${indent}${label.padEnd(maxLabelLen)} ${formatPercentageBar(w.used_percent)} resets in ${formatResetCountdown(w.reset_at)}`;
|
|
1496
|
+
});
|
|
1497
|
+
};
|
|
1498
|
+
const formatUsageOverview = (entries) => {
|
|
1499
|
+
const lines = [];
|
|
1500
|
+
for (let i = 0; i < entries.length; i++) {
|
|
1501
|
+
const entry = entries[i];
|
|
1502
|
+
const marker = entry.isCurrent ? "→ " : " ";
|
|
1503
|
+
if (entry.result.ok) {
|
|
1504
|
+
const usage = entry.result.data;
|
|
1505
|
+
const plan = usage.plan_type ?? "unknown";
|
|
1506
|
+
lines.push(`${marker}${entry.displayName} (${plan})`);
|
|
1507
|
+
lines.push(...formatUsageBars(usage));
|
|
1508
|
+
} else lines.push(`${marker}${entry.displayName}: [error] ${entry.result.error.message}`);
|
|
1509
|
+
if (i < entries.length - 1) lines.push("");
|
|
1510
|
+
}
|
|
1511
|
+
return lines.join("\n");
|
|
1512
|
+
};
|
|
1513
|
+
//#endregion
|
|
1514
|
+
//#region lib/account-switch.ts
|
|
1515
|
+
/**
|
|
1516
|
+
* Executes the existing account-switch sequence without producing CLI output.
|
|
1517
|
+
* Callers are responsible for presenting the result to their own interface.
|
|
1518
|
+
*/
|
|
1519
|
+
const switchAccountAtIndex = async (config, targetIndex, secretStore = getSecretStoreAdapter()) => {
|
|
1520
|
+
const account = config.accounts[targetIndex];
|
|
1521
|
+
if (!account?.accountId) throw new Error("Account entry missing accountId.");
|
|
1522
|
+
const previousIndex = config.current;
|
|
1523
|
+
const payload = await secretStore.load(account.accountId);
|
|
1524
|
+
const authResult = await writeAllAuthFiles(payload);
|
|
1525
|
+
config.current = targetIndex;
|
|
1526
|
+
await saveConfig(config);
|
|
1527
|
+
return {
|
|
1528
|
+
previousIndex,
|
|
1529
|
+
currentIndex: targetIndex,
|
|
1530
|
+
account,
|
|
1531
|
+
payload,
|
|
1532
|
+
authResult
|
|
1533
|
+
};
|
|
1534
|
+
};
|
|
1535
|
+
//#endregion
|
|
1536
|
+
export { createTestPaths as A, writeAuthFile as C, loadConfig as D, configExists as E, getPaths as M, resetPaths as N, loadConfiguredSecretStoreSelection as O, setPaths as P, writeAllAuthFiles as S, writePiAuthFile as T, isMissingSecretStoreEntryError as _, formatUsageOverview as a, setSecretStoreAdapter as b, extractAccountIdFromTokens as c, CALLBACK_PORT as d, createMacOSLegacyKeychainAdapter as f, getSecretStoreCapability as g, getSecretStoreAdapter as h, formatUsageBars as i, getPathResolutionInfo as j, saveConfig as k, pollDeviceAuthorizationToken as l, createSecretStoreAdapterFromSelection as m, fetchUsage as n, createAuthorizationFlow as o, createRuntimeSecretStoreAdapter as p, formatUsage as r, exchangeAuthorizationCode as s, switchAccountAtIndex as t, startDeviceAuthorizationFlow as u, resetSecretStoreAdapter as v, writeCodexAuthFile as w, runSecuritySafeAsync as x, resolveMacOSCrossKeychainBackendId as y };
|