@tuannvm/ccodex 0.1.9 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/proxy.js CHANGED
@@ -1,26 +1,92 @@
1
- import { join } from 'path';
2
- import { createHash } from 'crypto';
1
+ import { join, delimiter, normalize, sep, resolve, dirname, isAbsolute } from 'path';
2
+ import { homedir } from 'os';
3
+ import { createHash, randomUUID } from 'crypto';
3
4
  import chalk from 'chalk';
4
- import { hasCommand, getCommandPath, execCommand, httpGet, sleep, ensureDir, fileExists, safeJsonParse, debugLog } from './utils.js';
5
+ import { hasCommand, getCommandPath, execCommand, httpGet, sleep, ensureDir, fileExists, safeJsonParse, debugLog, runCmdBounded, requireTrustedCommand, withInstallLock } from './utils.js';
5
6
  import { CONFIG, getProxyUrl, getAuthDir, getLogFilePath } from './config.js';
6
7
  // Track installed proxy binary path for this process
7
8
  let installedProxyPath = null;
9
+ /**
10
+ * Get allowed installation directories for proxy binary
11
+ * Returns paths that are considered safe sources for CLIProxyAPI
12
+ */
13
+ function getAllowedInstallDirs() {
14
+ const home = homedir();
15
+ const allowed = [
16
+ join(home, '.local', 'bin'), // User local bin
17
+ '/usr/local/bin', // System local bin
18
+ '/opt/homebrew/bin', // Homebrew Apple Silicon
19
+ '/usr/local/bin', // Homebrew Intel
20
+ join(home, 'go', 'bin'), // Go user bin
21
+ ];
22
+ // Add common Windows paths if on Windows
23
+ if (process.platform === 'win32') {
24
+ allowed.push(join(process.env.LOCALAPPDATA || '', 'Programs'), join(process.env.APPDATA || '', 'Programs'));
25
+ }
26
+ return allowed;
27
+ }
28
+ /**
29
+ * Validate that a proxy binary path is from a trusted location
30
+ * Throws if path is not absolute or not from allowed directory
31
+ * Uses realpath to detect symlink escapes
32
+ */
33
+ async function validateProxyPath(proxyPath) {
34
+ if (!isAbsolute(proxyPath)) {
35
+ throw new Error(`Proxy binary path is not absolute: ${proxyPath}`);
36
+ }
37
+ const fs = await import('fs/promises');
38
+ // Use realpath to resolve symlinks and get the actual file location
39
+ const realPath = await fs.realpath(proxyPath);
40
+ const allowedDirs = getAllowedInstallDirs();
41
+ const isAllowed = allowedDirs.some(allowedDir => {
42
+ const resolvedAllowed = resolve(allowedDir);
43
+ return realPath.startsWith(resolvedAllowed + sep) || realPath === resolvedAllowed;
44
+ });
45
+ if (!isAllowed) {
46
+ throw new Error(`Proxy binary not from trusted location.\n` +
47
+ `Path: ${realPath}\n` +
48
+ `Allowed directories: ${allowedDirs.join(', ')}\n\n` +
49
+ `For security, only proxy binaries from trusted locations are executed.\n` +
50
+ `If you installed CLIProxyAPI manually, move it to ~/.local/bin or install via Homebrew.`);
51
+ }
52
+ }
53
+ /**
54
+ * Get trusted proxy command path
55
+ * Validates that the proxy binary is from a trusted location before execution
56
+ */
57
+ export async function requireTrustedProxyCommand() {
58
+ const cmdResult = await detectProxyCommand();
59
+ // Prefer installed path from this process
60
+ if (installedProxyPath && fileExists(installedProxyPath)) {
61
+ await validateProxyPath(installedProxyPath);
62
+ return installedProxyPath;
63
+ }
64
+ // Use detected path
65
+ if (!cmdResult.path) {
66
+ throw new Error('CLIProxyAPI not found. Install it first:\n' +
67
+ ' 1. Run: npx -y @tuannvm/ccodex\n' +
68
+ ' 2. Or install manually: brew install cli-proxy-api');
69
+ }
70
+ await validateProxyPath(cmdResult.path);
71
+ return cmdResult.path;
72
+ }
8
73
  /**
9
74
  * Detect CLIProxyAPI command
10
75
  * Prefers locally installed binary from this process if available
76
+ * Supports multiple binary names: cli-proxy-api (new), CLIProxyAPI (old), cliproxy
11
77
  */
12
78
  export async function detectProxyCommand() {
13
79
  // Prefer locally installed binary from this process
14
80
  if (installedProxyPath && fileExists(installedProxyPath)) {
15
- return { cmd: 'cliproxyapi', path: installedProxyPath };
81
+ return { cmd: 'cli-proxy-api', path: installedProxyPath };
16
82
  }
17
- if (await hasCommand('cliproxyapi')) {
18
- const resolved = await getCommandPath('cliproxyapi');
19
- return { cmd: 'cliproxyapi', path: resolved };
20
- }
21
- if (await hasCommand('cliproxy')) {
22
- const resolved = await getCommandPath('cliproxy');
23
- return { cmd: 'cliproxy', path: resolved };
83
+ // Try new name first, then legacy names
84
+ const commandNames = ['cli-proxy-api', 'CLIProxyAPI', 'cliproxy'];
85
+ for (const name of commandNames) {
86
+ if (await hasCommand(name)) {
87
+ const resolved = await getCommandPath(name);
88
+ return { cmd: name, path: resolved };
89
+ }
24
90
  }
25
91
  return { cmd: null, path: null };
26
92
  }
@@ -55,23 +121,19 @@ export async function checkAuthConfigured() {
55
121
  }
56
122
  // Check auth via proxy status
57
123
  let hasAuthEntries = false;
58
- const cmdResult = await detectProxyCommand();
59
- if (cmdResult.cmd) {
60
- try {
61
- // Use absolute path if available, otherwise command name
62
- const proxyExe = cmdResult.path || cmdResult.cmd;
63
- const output = await execCommand(proxyExe, ['status']);
64
- // Match "N auth entries" or "N auth files" where N > 0
65
- const match = output.match(/(\d+)\s+(auth entries|auth files)/);
66
- if (match) {
67
- const count = parseInt(match[1], 10);
68
- hasAuthEntries = count > 0;
69
- }
70
- }
71
- catch (error) {
72
- debugLog('Failed to check proxy status:', error);
124
+ try {
125
+ const proxyExe = await requireTrustedProxyCommand();
126
+ const output = await execCommand(proxyExe, ['status']);
127
+ // Match "N auth entries" or "N auth files" where N > 0
128
+ const match = output.match(/(\d+)\s+(auth entries|auth files)/);
129
+ if (match) {
130
+ const count = parseInt(match[1], 10);
131
+ hasAuthEntries = count > 0;
73
132
  }
74
133
  }
134
+ catch (error) {
135
+ debugLog('Failed to check proxy status:', error);
136
+ }
75
137
  // Check via API
76
138
  let hasModels = false;
77
139
  try {
@@ -106,8 +168,11 @@ function sha256Hex(data) {
106
168
  * Supports common checksum formats:
107
169
  * - SHA256SUMS: "a1b2c3... filename" or "a1b2c3... *filename"
108
170
  * - checksums.txt: "a1b2c3... filename"
171
+ * Handles path prefixes: "./filename", "subdir/filename", "subdir\filename" (Windows)
172
+ * Prefers exact filename match over basename match to avoid collisions
109
173
  */
110
174
  function parseExpectedSha256(content, fileName) {
175
+ // First pass: try exact filename match (with or without path separators)
111
176
  for (const line of content.split('\n')) {
112
177
  const trimmed = line.trim();
113
178
  if (!trimmed)
@@ -116,7 +181,11 @@ function parseExpectedSha256(content, fileName) {
116
181
  const match = trimmed.match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/);
117
182
  if (match) {
118
183
  const [, hash, name] = match;
119
- if (name.trim() === fileName) {
184
+ const normalizedName = name.trim();
185
+ // Normalize path separators to / and strip any path prefix
186
+ const normalizedBase = normalizedName.replace(/\\/g, '/').replace(/^.*\//, '');
187
+ if (normalizedBase === fileName) {
188
+ // Found exact basename match
120
189
  return hash.toLowerCase();
121
190
  }
122
191
  }
@@ -152,6 +221,181 @@ async function getLatestReleaseTag() {
152
221
  'Please check your internet connection or install CLIProxyAPI manually.');
153
222
  }
154
223
  }
224
+ /**
225
+ * Check if an archive entry path is unsafe (contains path traversal or absolute paths)
226
+ */
227
+ function isUnsafeArchivePath(raw) {
228
+ if (!raw)
229
+ return true;
230
+ // Normalize zip/tar separators and strip leading "./"
231
+ const p = raw.replace(/\\/g, '/').replace(/^\.\/+/, '');
232
+ if (!p)
233
+ return true;
234
+ // Reject absolute, drive-letter, UNC-like
235
+ if (p.startsWith('/') || /^[a-zA-Z]:\//.test(p) || p.startsWith('//'))
236
+ return true;
237
+ // Reject traversal segments
238
+ const parts = p.split('/').filter(Boolean);
239
+ if (parts.some(seg => seg === '..'))
240
+ return true;
241
+ return false;
242
+ }
243
+ // Resource limits for archive extraction (prevent tar/zip bombs)
244
+ const MAX_ENTRIES = 1000;
245
+ const MAX_ARCHIVE_BYTES = 100 * 1024 * 1024; // 100 MB compressed
246
+ const MAX_EXTRACTED_BYTES = 500 * 1024 * 1024; // 500 MB uncompressed
247
+ /**
248
+ * Parse tar verbose output line to extract path and type
249
+ * Handles GNU/BSD tar verbose output with special bits and spaces in filenames
250
+ * Returns null for lines that don't match expected tar verbose format (treated as suspicious)
251
+ */
252
+ function parseTarVerboseLine(line) {
253
+ // Tar verbose format typically:
254
+ // permissions owner/group size date time... path
255
+ // Or with links: permissions ... path -> target
256
+ //
257
+ // We parse by:
258
+ // 1. Finding the path segment (everything after the date/time)
259
+ // 2. Checking the first character for file type
260
+ //
261
+ // Handle special bits that may appear: s/S (setuid/setgid), t/T (sticky), + (ACL), @ (extended attributes)
262
+ // The first character indicates type: - (file), d (dir), l (symlink), h (hardlink), c (char), b (block), p (fifo)
263
+ if (!line || line.length === 0)
264
+ return null;
265
+ const firstChar = line.charAt(0);
266
+ const fileTypeMap = {
267
+ '-': 'file',
268
+ 'd': 'dir',
269
+ 'l': 'symlink',
270
+ 'h': 'hardlink',
271
+ 'c': 'char',
272
+ 'b': 'block',
273
+ 'p': 'fifo',
274
+ };
275
+ const type = fileTypeMap[firstChar];
276
+ if (!type) {
277
+ // Unknown file type character - treat as suspicious
278
+ return null;
279
+ }
280
+ // Find the path by splitting on whitespace
281
+ // Tar verbose format fields are separated by whitespace
282
+ // The path is typically the last field (or second-to-last before "-> target")
283
+ const parts = line.split(/\s+/);
284
+ // For very long lines with many fields, the path might be split further
285
+ // Find the part that looks like a path (contains '/', or ends with ' -> ', or is just a name)
286
+ let pathWithTarget = '';
287
+ let foundPath = false;
288
+ // Iterate from the end to find the path
289
+ for (let i = parts.length - 1; i >= 0; i--) {
290
+ const part = parts[i];
291
+ if (part.includes('/') || part.includes(' -> ') || (part.length > 0 && !foundPath)) {
292
+ pathWithTarget = part + (pathWithTarget ? ' ' + pathWithTarget : '');
293
+ foundPath = true;
294
+ }
295
+ else if (foundPath) {
296
+ // We've collected all path parts
297
+ break;
298
+ }
299
+ }
300
+ if (!pathWithTarget)
301
+ return null;
302
+ // Extract just the path (before " -> " for symlinks)
303
+ const arrowIndex = pathWithTarget.indexOf(' -> ');
304
+ const path = arrowIndex >= 0 ? pathWithTarget.substring(0, arrowIndex).trim() : pathWithTarget.trim();
305
+ if (!path)
306
+ return null;
307
+ return { path, type };
308
+ }
309
+ /**
310
+ * List entries in a tar archive with resource limits and link type validation
311
+ */
312
+ async function listTarEntries(archivePath) {
313
+ const fs = await import('fs/promises');
314
+ // Check archive file size before extraction
315
+ const archiveStat = await fs.stat(archivePath);
316
+ if (archiveStat.size > MAX_ARCHIVE_BYTES) {
317
+ throw new Error(`Archive file is too large (${(archiveStat.size / 1024 / 1024).toFixed(1)} MB > ${MAX_ARCHIVE_BYTES / 1024 / 1024} MB). ` +
318
+ 'This may be a tar bomb.');
319
+ }
320
+ // Use verbose mode with -z to explicitly handle gzip compression
321
+ // Some tar versions auto-detect compression, but -z ensures consistency
322
+ // Use trusted tar path to avoid PATH hijacking
323
+ const tarPath = await requireTrustedCommand('tar');
324
+ const result = await runCmdBounded(tarPath, ['-ztvf', archivePath], 30000); // 30 second timeout
325
+ if (result.code !== 0) {
326
+ throw new Error(`tar list failed with code ${result.code}`);
327
+ }
328
+ const lines = result.stdout.split('\n').map(s => s.trim()).filter(Boolean);
329
+ const entries = [];
330
+ // Resource limits: prevent tar/zip bombs
331
+ if (lines.length > MAX_ENTRIES) {
332
+ throw new Error(`Archive has too many entries (${lines.length} > ${MAX_ENTRIES}). This may be a tar bomb.`);
333
+ }
334
+ // Parse and validate each entry
335
+ for (const line of lines) {
336
+ const parsed = parseTarVerboseLine(line);
337
+ // Treat unparsable lines as suspicious - fail closed instead of skipping
338
+ if (!parsed) {
339
+ throw new Error(`Unrecognized tar output format (possible archive corruption): ${line.substring(0, 100)}`);
340
+ }
341
+ // Check for unsafe paths
342
+ if (isUnsafeArchivePath(parsed.path)) {
343
+ throw new Error(`Unsafe archive path: ${parsed.path}`);
344
+ }
345
+ // Reject symlinks and hardlinks for security
346
+ if (parsed.type === 'symlink' || parsed.type === 'hardlink') {
347
+ throw new Error(`Archive contains forbidden link entry: ${parsed.path} (${parsed.type})`);
348
+ }
349
+ // Reject character/block devices, fifos (unusual in CLIProxyAPI archives)
350
+ if (parsed.type === 'char' || parsed.type === 'block' || parsed.type === 'fifo') {
351
+ throw new Error(`Archive contains unusual entry type: ${parsed.path} (${parsed.type})`);
352
+ }
353
+ entries.push(parsed.path);
354
+ }
355
+ if (entries.length === 0) {
356
+ throw new Error('Archive is empty or contains no valid entries');
357
+ }
358
+ return entries;
359
+ }
360
+ /**
361
+ * Validate archive listing for unsafe paths and link types
362
+ * Note: Windows installation is not currently supported (throws early in installProxyApi)
363
+ */
364
+ async function validateArchiveListing(archivePath) {
365
+ // listTarEntries now does all the validation (path safety, link rejection, resource limits)
366
+ await listTarEntries(archivePath);
367
+ }
368
+ /**
369
+ * Assert that all extracted files are confined within the target directory
370
+ * Validates that realpath of all files stays within the target directory
371
+ * Symlinks and hardlinks are already rejected during tar parsing
372
+ */
373
+ async function assertRealpathConfinement(rootDir) {
374
+ const fs = await import('fs/promises');
375
+ const rootReal = await fs.realpath(rootDir);
376
+ const stack = [rootDir];
377
+ while (stack.length > 0) {
378
+ const cur = stack.pop();
379
+ const entries = await fs.readdir(cur, { withFileTypes: true });
380
+ for (const ent of entries) {
381
+ const full = join(cur, ent.name);
382
+ // Double-check for symlinks (defensive: should have been caught during tar parsing)
383
+ const lst = await fs.lstat(full);
384
+ if (lst.isSymbolicLink()) {
385
+ throw new Error(`Symlink not allowed in extracted archive: ${full}`);
386
+ }
387
+ // Verify realpath stays within target directory
388
+ const rp = await fs.realpath(full);
389
+ const confined = rp === rootReal || rp.startsWith(rootReal + sep);
390
+ if (!confined) {
391
+ throw new Error(`Extracted path escapes target directory: ${full}`);
392
+ }
393
+ if (ent.isDirectory()) {
394
+ stack.push(full);
395
+ }
396
+ }
397
+ }
398
+ }
155
399
  /**
156
400
  * Install CLIProxyAPI via Homebrew or Go binary fallback
157
401
  */
@@ -161,6 +405,8 @@ export async function installProxyApi() {
161
405
  if (!home) {
162
406
  throw new Error('Cannot determine home directory. Please set HOME environment variable.');
163
407
  }
408
+ // Install lock file path (in the target install directory)
409
+ const lockPath = join(home, '.local', 'bin', '.cli-proxy-api.install.lock');
164
410
  // Check platform
165
411
  const platform = process.platform;
166
412
  const arch = process.arch;
@@ -169,238 +415,411 @@ export async function installProxyApi() {
169
415
  'Please install CLIProxyAPI manually and ensure it\'s in your PATH.\n' +
170
416
  'See CLIProxyAPI documentation for Windows installation instructions.');
171
417
  }
172
- // Try Homebrew first (preferred)
173
- if (await hasCommand('brew')) {
174
- console.log('Installing CLIProxyAPI via Homebrew...');
175
- const spawnCmd = (await import('cross-spawn')).default;
176
- try {
177
- await new Promise((resolve, reject) => {
178
- const child = spawnCmd('brew', ['install', 'cliproxyapi'], {
179
- stdio: 'inherit',
180
- });
181
- child.on('close', (code) => {
182
- if (code === 0) {
183
- console.log('CLIProxyAPI installed successfully via Homebrew');
184
- resolve();
185
- }
186
- else {
187
- reject(new Error('Failed to install CLIProxyAPI via Homebrew'));
188
- }
189
- });
190
- child.on('error', (error) => reject(error));
191
- });
192
- return;
193
- }
194
- catch (error) {
195
- debugLog('Homebrew installation failed, falling back to Go binary:', error);
196
- // Fall through to Go binary installation
197
- }
198
- }
199
- // Fallback: Install Go binary directly
200
- console.log('Installing CLIProxyAPI via Go binary...');
201
- // Validate and determine platform suffix
202
- const supportedPlatforms = ['darwin', 'linux'];
203
- const supportedArches = ['arm64', 'x64'];
204
- if (!supportedPlatforms.includes(platform)) {
205
- throw new Error(`Unsupported platform: ${platform}\n` +
206
- `Supported platforms: ${supportedPlatforms.join(', ')}`);
207
- }
208
- if (!supportedArches.includes(arch)) {
209
- throw new Error(`Unsupported architecture: ${arch}\n` +
210
- `Supported architectures: ${supportedArches.join(', ')}`);
211
- }
212
- const platformSuffix = `${platform}-${arch === 'arm64' ? 'arm64' : 'amd64'}`;
213
- const binaryFileName = `cliproxyapi-${platformSuffix}`;
418
+ // Ensure lock directory exists before acquiring lock
214
419
  const installDir = join(home, '.local', 'bin');
215
- const binaryPath = join(installDir, 'cliproxyapi');
216
- const tempPath = join(installDir, `cliproxyapi.tmp.${Date.now()}`);
217
- // Ensure install directory exists
218
420
  await ensureDir(installDir);
219
- // Resolve exact release tag first for security (avoid moving 'latest' redirects)
220
- console.log('Resolving latest release tag from GitHub API...');
221
- const releaseTag = await getLatestReleaseTag();
222
- console.log(`Latest release: ${releaseTag}`);
223
- const baseUrl = `https://github.com/router-for-me/CLIProxyAPI/releases/download/${releaseTag}`;
224
- const binaryUrl = `${baseUrl}/${binaryFileName}`;
225
- console.log(`Downloading ${binaryFileName} from GitHub releases...`);
226
- const fs = await import('fs/promises');
227
- try {
228
- // Download binary with timeout
229
- const controller = new AbortController();
230
- const timeout = setTimeout(() => controller.abort(), 60000); // 60 second timeout
231
- let response;
232
- try {
233
- response = await fetch(binaryUrl, { signal: controller.signal });
234
- }
235
- finally {
236
- clearTimeout(timeout);
237
- }
238
- if (!response.ok) {
239
- throw new Error(`HTTP ${response.status} ${response.statusText}\n` +
240
- `URL: ${binaryUrl}\n` +
241
- `Platform/Arch: ${platformSuffix}`);
242
- }
243
- const buffer = await response.arrayBuffer();
244
- const uint8Array = new Uint8Array(buffer);
245
- // Calculate SHA-256 checksum of downloaded binary
246
- console.log('Calculating SHA-256 checksum...');
247
- const actualHash = sha256Hex(uint8Array);
248
- // Try to download and verify checksums file
249
- let checksumVerified = false;
250
- let checksumMismatchError = null;
251
- const checksumUrls = [
252
- `${baseUrl}/SHA256SUMS`,
253
- `${baseUrl}/checksums.txt`,
254
- `${baseUrl}/checksums.sha256`,
255
- ];
256
- for (const checksumUrl of checksumUrls) {
421
+ // Wrap entire installation process (both Homebrew and Go binary paths) in lock
422
+ await withInstallLock(lockPath, async () => {
423
+ // Try Homebrew first (preferred)
424
+ if (await hasCommand('brew')) {
425
+ console.log('Installing CLIProxyAPI via Homebrew...');
426
+ const brewPath = await requireTrustedCommand('brew');
427
+ const spawnCmd = (await import('cross-spawn')).default;
257
428
  try {
258
- console.log(`Attempting checksum verification from: ${new URL(checksumUrl).pathname}`);
259
- const checksumResponse = await fetch(checksumUrl, { signal: AbortSignal.timeout(10000) });
260
- if (checksumResponse.ok) {
261
- const checksumContent = await checksumResponse.text();
262
- const expectedHash = parseExpectedSha256(checksumContent, binaryFileName);
263
- if (expectedHash) {
264
- if (actualHash === expectedHash) {
265
- console.log(chalk.green('✓ Checksum verification passed'));
266
- checksumVerified = true;
267
- break;
429
+ await new Promise((resolve, reject) => {
430
+ const child = spawnCmd(brewPath, ['install', 'cli-proxy-api'], {
431
+ stdio: 'inherit',
432
+ });
433
+ child.on('close', (code) => {
434
+ if (code === 0) {
435
+ console.log('CLIProxyAPI installed successfully via Homebrew');
436
+ resolve();
268
437
  }
269
438
  else {
270
- // Checksum mismatch - this is FATAL, do not continue
271
- await fs.unlink(tempPath).catch(() => { });
272
- checksumMismatchError = new Error(`Checksum verification failed!\n` +
273
- `Expected: ${expectedHash}\n` +
274
- `Actual: ${actualHash}\n\n` +
275
- `The downloaded binary may be corrupted or tampered with.\n` +
276
- `Please try again or install CLIProxyAPI manually.`);
277
- break; // Exit loop immediately on mismatch
439
+ reject(new Error('Failed to install CLIProxyAPI via Homebrew'));
278
440
  }
279
- }
280
- }
441
+ });
442
+ child.on('error', (error) => reject(error));
443
+ });
444
+ return;
281
445
  }
282
- catch (checksumError) {
283
- // Only catch network/parsing errors - let checksum mismatches fail hard
284
- const errorMsg = checksumError instanceof Error ? checksumError.message : String(checksumError);
285
- if (errorMsg.includes('Checksum verification failed')) {
286
- // Re-throw checksum mismatch errors
287
- throw checksumError;
288
- }
289
- debugLog(`Checksum verification failed for ${checksumUrl}:`, checksumError);
290
- // Try next checksum URL on network errors
446
+ catch (error) {
447
+ debugLog('Homebrew installation failed, falling back to Go binary:', error);
448
+ // Fall through to Go binary installation
291
449
  }
292
450
  }
293
- // If we found a checksum mismatch, fail hard
294
- if (checksumMismatchError) {
295
- throw checksumMismatchError;
296
- }
297
- // SECURITY: Fail closed if checksum verification is not available
298
- // This prevents installation of potentially tampered binaries
299
- if (!checksumVerified) {
300
- await fs.unlink(tempPath).catch(() => { });
301
- throw new Error(chalk.red('Checksum verification required but failed.\n\n') +
302
- 'The downloaded binary could not be verified against a checksum file.\n' +
303
- 'This is a security requirement to prevent installation of tampered binaries.\n\n' +
304
- 'Possible reasons:\n' +
305
- ' - Network issues prevented checksum file download\n' +
306
- ' - Checksum files are not published for this release\n' +
307
- ' - GitHub releases are temporarily unavailable\n\n' +
308
- 'To install CLIProxyAPI safely:\n' +
309
- ` 1. Visit ${baseUrl}/\n` +
310
- ' 2. Download the binary and checksum files manually\n' +
311
- ' 3. Verify the checksums match\n' +
312
- ' 4. Place the binary in a directory in your PATH\n' +
313
- ' 5. Make it executable: chmod +x cliproxyapi\n\n' +
314
- 'Then run ccodex again.');
315
- }
316
- // Atomic write: download to temp file first
317
- await fs.writeFile(tempPath, uint8Array, { mode: 0o755 });
318
- // Sync to disk
319
- const fileHandle = await fs.open(tempPath, 'r');
320
- try {
321
- await fileHandle.sync();
451
+ // Fallback: Install Go binary directly
452
+ console.log('Installing CLIProxyAPI via Go binary...');
453
+ // Determine platform/arch for CLIProxyAPI release asset format
454
+ // CLIProxyAPI uses: CLIProxyAPI_{version}_{platform}_{arch}.{ext}
455
+ const platformMap = { darwin: 'darwin', linux: 'linux', win32: 'windows' };
456
+ const archMap = { arm64: 'arm64', x64: 'amd64' };
457
+ const cliPlatform = platformMap[platform];
458
+ const cliArch = archMap[arch];
459
+ if (!cliPlatform) {
460
+ throw new Error(`Unsupported platform: ${platform}\n` +
461
+ `Supported platforms: ${Object.keys(platformMap).join(', ')}`);
322
462
  }
323
- finally {
324
- await fileHandle.close();
463
+ if (!cliArch) {
464
+ throw new Error(`Unsupported architecture: ${arch}\n` +
465
+ `Supported architectures: ${Object.keys(archMap).join(', ')}`);
325
466
  }
326
- // Validate the binary works by running it
467
+ // Resolve exact release tag first for security (avoid moving 'latest' redirects)
468
+ console.log('Resolving latest release tag from GitHub API...');
469
+ const releaseTag = await getLatestReleaseTag();
470
+ console.log(`Latest release: ${releaseTag}`);
471
+ // Strip 'v' prefix from tag for version (e.g., v6.9.5 -> 6.9.5)
472
+ const version = releaseTag.startsWith('v') ? releaseTag.slice(1) : releaseTag;
473
+ // Determine archive extension
474
+ const isWindows = platform === 'win32';
475
+ const archiveExt = isWindows ? 'zip' : 'tar.gz';
476
+ const archiveFileName = `CLIProxyAPI_${version}_${cliPlatform}_${cliArch}.${archiveExt}`;
477
+ // installDir is already defined and ensured at function start
478
+ const binaryName = isWindows ? 'cli-proxy-api.exe' : 'cli-proxy-api';
479
+ const binaryPath = join(installDir, binaryName);
480
+ // Use crypto.randomUUID() for temp files to avoid collision in concurrent installs
481
+ const randomSuffix = randomUUID();
482
+ const archivePath = join(installDir, `cli-proxy-api-${randomSuffix}.${archiveExt}`);
483
+ const extractDir = join(installDir, `cli-proxy-api-extract-${randomSuffix}`);
484
+ const baseUrl = `https://github.com/router-for-me/CLIProxyAPI/releases/download/${releaseTag}`;
485
+ const archiveUrl = `${baseUrl}/${archiveFileName}`;
486
+ console.log(`Downloading ${archiveFileName} from GitHub releases...`);
487
+ const fs = await import('fs/promises');
327
488
  try {
328
- const { spawnSync } = await import('child_process');
329
- const testResult = spawnSync(tempPath, ['--version'], { timeout: 5000 });
330
- // Fail on any error, signal termination, or non-zero exit
331
- if (testResult.error || testResult.signal !== null || testResult.status !== 0) {
332
- const reason = testResult.error?.message ||
333
- (testResult.signal ? `killed by signal ${testResult.signal}` : null) ||
334
- (testResult.status ? `exited with code ${testResult.status}` : 'unknown error');
335
- throw new Error(`Binary validation failed: ${reason}`);
489
+ // Download archive with streaming and byte limits
490
+ console.log(`Downloading ${archiveFileName} from GitHub releases...`);
491
+ const controller = new AbortController();
492
+ const timeout = setTimeout(() => controller.abort(), 120000); // 120 second timeout
493
+ let response;
494
+ try {
495
+ response = await fetch(archiveUrl, { signal: controller.signal });
336
496
  }
337
- }
338
- catch (validationError) {
339
- // Clean up invalid binary
340
- await fs.unlink(tempPath).catch(() => { });
341
- throw new Error(`Downloaded binary failed validation: ${validationError instanceof Error ? validationError.message : String(validationError)}\n\n` +
342
- 'The binary may be corrupted or incompatible with your system.');
343
- }
344
- // Backup existing binary if present, but be ready to rollback
345
- let backupPath = null;
346
- let didBackup = false;
347
- if (await fileExists(binaryPath)) {
348
- backupPath = `${binaryPath}.backup.${Date.now()}`;
349
- await fs.rename(binaryPath, backupPath);
350
- didBackup = true;
351
- }
352
- // Move temp file to final location (atomic on most filesystems)
353
- try {
354
- await fs.rename(tempPath, binaryPath);
355
- // Store the installed path for this process
356
- installedProxyPath = binaryPath;
357
- }
358
- catch (renameError) {
359
- // Rollback: restore backup if we had one
360
- if (didBackup && backupPath) {
497
+ finally {
498
+ clearTimeout(timeout);
499
+ }
500
+ if (!response.ok) {
501
+ if (response.status === 404) {
502
+ // 404 means the archive doesn't exist for this platform/arch in this release
503
+ throw new Error(`Archive not found for ${cliPlatform}_${cliArch}\n` +
504
+ `URL: ${archiveUrl}\n\n` +
505
+ `This could mean:\n` +
506
+ ` - ${cliPlatform}_${cliArch} archives are not available in release ${releaseTag}\n` +
507
+ ` - Check the CLIProxyAPI releases page for available platforms\n\n` +
508
+ `Suggested alternatives:\n` +
509
+ ` 1. Try Homebrew installation: brew install cli-proxy-api\n` +
510
+ ` 2. Check available releases: ${baseUrl}\n` +
511
+ ` 3. Download manually from: https://github.com/router-for-me/CLIProxyAPI/releases\n\n` +
512
+ `Available platforms for CLIProxyAPI may vary by release.`);
513
+ }
514
+ throw new Error(`HTTP ${response.status} ${response.statusText}\n` +
515
+ `URL: ${archiveUrl}\n` +
516
+ `Platform/Arch: ${cliPlatform}_${cliArch}`);
517
+ }
518
+ // Pre-check content-length if available
519
+ const contentLength = Number(response.headers.get('content-length') || 0);
520
+ if (contentLength > MAX_ARCHIVE_BYTES) {
521
+ throw new Error(`Archive too large (Content-Length: ${(contentLength / 1024 / 1024).toFixed(1)} MB > ${MAX_ARCHIVE_BYTES / 1024 / 1024} MB). ` +
522
+ 'This may be a tar bomb.');
523
+ }
524
+ // Stream download with byte limit and incremental hash
525
+ if (!response.body) {
526
+ throw new Error('Response body is null');
527
+ }
528
+ const fileHandle = await fs.open(archivePath, 'w');
529
+ const hash = createHash('sha256');
530
+ let downloadedBytes = 0;
531
+ try {
532
+ for await (const chunk of response.body) {
533
+ downloadedBytes += chunk.byteLength;
534
+ if (downloadedBytes > MAX_ARCHIVE_BYTES) {
535
+ throw new Error(`Archive exceeded size limit during download (${(downloadedBytes / 1024 / 1024).toFixed(1)} MB > ${MAX_ARCHIVE_BYTES / 1024 / 1024} MB). ` +
536
+ 'This may be a tar bomb.');
537
+ }
538
+ hash.update(chunk);
539
+ await fileHandle.write(chunk);
540
+ }
541
+ }
542
+ finally {
543
+ await fileHandle.close();
544
+ }
545
+ console.log(`Downloaded ${(downloadedBytes / 1024 / 1024).toFixed(1)} MB`);
546
+ // Get the calculated hash
547
+ const actualHash = hash.digest('hex');
548
+ // Try to download and verify checksums file
549
+ let checksumVerified = false;
550
+ let checksumMismatchError = null;
551
+ const checksumUrls = [
552
+ `${baseUrl}/SHA256SUMS`,
553
+ `${baseUrl}/checksums.txt`,
554
+ `${baseUrl}/checksums.sha256`,
555
+ ];
556
+ for (const checksumUrl of checksumUrls) {
361
557
  try {
362
- await fs.rename(backupPath, binaryPath);
558
+ console.log(`Attempting checksum verification from: ${new URL(checksumUrl).pathname}`);
559
+ const checksumResponse = await fetch(checksumUrl, { signal: AbortSignal.timeout(10000) });
560
+ if (checksumResponse.ok) {
561
+ const checksumContent = await checksumResponse.text();
562
+ const expectedHash = parseExpectedSha256(checksumContent, archiveFileName);
563
+ if (expectedHash) {
564
+ if (actualHash === expectedHash) {
565
+ console.log(chalk.green('✓ Checksum verification passed'));
566
+ checksumVerified = true;
567
+ break;
568
+ }
569
+ else {
570
+ // Checksum mismatch - this is FATAL, do not continue
571
+ await fs.unlink(archivePath).catch(() => { });
572
+ checksumMismatchError = new Error(`Checksum verification failed!\n` +
573
+ `Expected: ${expectedHash}\n` +
574
+ `Actual: ${actualHash}\n\n` +
575
+ `The downloaded archive may be corrupted or tampered with.\n` +
576
+ `Please try again or install CLIProxyAPI manually.`);
577
+ break; // Exit loop immediately on mismatch
578
+ }
579
+ }
580
+ }
363
581
  }
364
- catch (rollbackError) {
365
- debugLog('Failed to rollback after rename failure:', rollbackError);
582
+ catch (checksumError) {
583
+ // Only catch network/parsing errors - let checksum mismatches fail hard
584
+ const errorMsg = checksumError instanceof Error ? checksumError.message : String(checksumError);
585
+ if (errorMsg.includes('Checksum verification failed')) {
586
+ // Re-throw checksum mismatch errors
587
+ throw checksumError;
588
+ }
589
+ debugLog(`Checksum verification failed for ${checksumUrl}:`, checksumError);
590
+ // Try next checksum URL on network errors
366
591
  }
367
592
  }
368
- throw new Error(`Failed to move binary to final location: ${renameError instanceof Error ? renameError.message : String(renameError)}`);
369
- }
370
- // Clean up backup on success
371
- if (backupPath) {
372
- fs.unlink(backupPath).catch(() => { });
593
+ // If we found a checksum mismatch, fail hard
594
+ if (checksumMismatchError) {
595
+ throw checksumMismatchError;
596
+ }
597
+ // SECURITY: Fail closed if checksum verification is not available
598
+ // This prevents installation of potentially tampered binaries
599
+ if (!checksumVerified) {
600
+ await fs.unlink(archivePath).catch(() => { });
601
+ throw new Error(chalk.red('Checksum verification required but failed.\n\n') +
602
+ 'The downloaded archive could not be verified against a checksum file.\n' +
603
+ 'This is a security requirement to prevent installation of tampered binaries.\n\n' +
604
+ 'Possible reasons:\n' +
605
+ ' - Network issues prevented checksum file download\n' +
606
+ ' - Checksum files are not published for this release\n' +
607
+ ' - GitHub releases are temporarily unavailable\n\n' +
608
+ 'To install CLIProxyAPI safely:\n' +
609
+ ` 1. Visit ${baseUrl}/\n` +
610
+ ' 2. Download the archive and checksum files manually\n' +
611
+ ' 3. Verify the checksums match\n' +
612
+ ' 4. Extract the archive\n' +
613
+ ' 5. Place the binary in a directory in your PATH\n' +
614
+ ' 6. Make it executable: chmod +x cli-proxy-api\n\n' +
615
+ 'Then run ccodex again.');
616
+ }
617
+ // Archive was already written to disk during streaming download
618
+ // Extract archive using hardened extraction strategy
619
+ console.log(`Extracting ${archiveExt} archive...`);
620
+ await ensureDir(extractDir);
621
+ // Preflight: validate archive listing before extraction
622
+ console.log('Validating archive contents...');
623
+ await validateArchiveListing(archivePath);
624
+ try {
625
+ // Unix/macOS: use tar with portable hardened flags
626
+ // Note: --no-same-owner and --no-same-permissions are supported by both GNU and BSD tar
627
+ // We avoid GNU-specific flags like --delay-directory-restore for macOS compatibility
628
+ // Use bounded execution with timeout (60 seconds for extraction) and trusted tar path
629
+ const tarPath = await requireTrustedCommand('tar');
630
+ const result = await runCmdBounded(tarPath, [
631
+ '-xzf', archivePath,
632
+ '-C', extractDir,
633
+ '--no-same-owner',
634
+ '--no-same-permissions',
635
+ ], 60000);
636
+ if (result.code !== 0) {
637
+ throw new Error(`tar extraction failed with code ${result.code}`);
638
+ }
639
+ }
640
+ catch (extractError) {
641
+ // Clean up on extraction failure
642
+ await fs.unlink(archivePath).catch(() => { });
643
+ await fs.rm(extractDir, { recursive: true, force: true }).catch(() => { });
644
+ throw new Error(`Failed to extract archive: ${extractError instanceof Error ? extractError.message : String(extractError)}\n\n` +
645
+ 'The archive may be corrupted or incompatible with your system.');
646
+ }
647
+ // Post-extraction: validate extracted size (prevent zip bomb)
648
+ console.log('Validating extracted size...');
649
+ let extractedBytes = 0;
650
+ async function getDirSize(dir) {
651
+ const entries = await fs.readdir(dir, { withFileTypes: true });
652
+ let size = 0;
653
+ for (const ent of entries) {
654
+ const full = join(dir, ent.name);
655
+ const stat = await fs.stat(full);
656
+ if (ent.isDirectory()) {
657
+ size += await getDirSize(full);
658
+ }
659
+ else {
660
+ size += stat.size;
661
+ }
662
+ }
663
+ return size;
664
+ }
665
+ try {
666
+ extractedBytes = await getDirSize(extractDir);
667
+ if (extractedBytes > MAX_EXTRACTED_BYTES) {
668
+ throw new Error(`Extracted content is too large (${(extractedBytes / 1024 / 1024).toFixed(1)} MB > ${MAX_EXTRACTED_BYTES / 1024 / 1024} MB). ` +
669
+ 'This may be a zip bomb.');
670
+ }
671
+ }
672
+ catch (sizeError) {
673
+ // Clean up on size check failure
674
+ await fs.unlink(archivePath).catch(() => { });
675
+ await fs.rm(extractDir, { recursive: true, force: true }).catch(() => { });
676
+ throw sizeError;
677
+ }
678
+ // Post-extraction: validate realpath confinement
679
+ // This detects path traversal via symlinks, hardlinks, or other escape mechanisms
680
+ console.log('Validating extraction safety...');
681
+ try {
682
+ await assertRealpathConfinement(extractDir);
683
+ }
684
+ catch (confinementError) {
685
+ // Clean up on confinement failure
686
+ await fs.unlink(archivePath).catch(() => { });
687
+ await fs.rm(extractDir, { recursive: true, force: true }).catch(() => { });
688
+ throw confinementError;
689
+ }
690
+ // Find the extracted binary
691
+ // CLIProxyAPI archives contain a binary named 'cli-proxy-api' (new) or 'CLIProxyAPI' (old)
692
+ // On Windows it may have .exe extension
693
+ const extractedFiles = await fs.readdir(extractDir);
694
+ const binaryNames = isWindows
695
+ ? ['cli-proxy-api.exe', 'CLIProxyAPI.exe']
696
+ : ['cli-proxy-api', 'CLIProxyAPI'];
697
+ const extractedBinary = extractedFiles.find(f => binaryNames.includes(f));
698
+ if (!extractedBinary) {
699
+ // Clean up
700
+ await fs.unlink(archivePath).catch(() => { });
701
+ await fs.rm(extractDir, { recursive: true, force: true }).catch(() => { });
702
+ throw new Error(`Could not find CLIProxyAPI binary in extracted archive.\n` +
703
+ `Files found: ${extractedFiles.join(', ')}\n\n` +
704
+ 'The archive format may have changed. Please report this issue.');
705
+ }
706
+ const extractedBinaryPath = join(extractDir, extractedBinary);
707
+ // Set executable permission on extracted binary before validation (Unix/macOS only)
708
+ if (!isWindows) {
709
+ try {
710
+ await fs.chmod(extractedBinaryPath, 0o755);
711
+ }
712
+ catch (chmodError) {
713
+ debugLog('Warning: Could not set executable permission on extracted binary:', chmodError);
714
+ // Continue anyway - the archive may already have execute bits set
715
+ }
716
+ }
717
+ // Validate the extracted binary works by running it
718
+ console.log('Validating extracted binary...');
719
+ try {
720
+ const testResult = await runCmdBounded(extractedBinaryPath, ['--version'], 5000);
721
+ // Fail on non-zero exit
722
+ if (testResult.code !== 0) {
723
+ throw new Error(`Binary validation failed: exited with code ${testResult.code}`);
724
+ }
725
+ }
726
+ catch (validationError) {
727
+ // Clean up invalid binary
728
+ await fs.unlink(archivePath).catch(() => { });
729
+ await fs.rm(extractDir, { recursive: true, force: true }).catch(() => { });
730
+ throw new Error(`Extracted binary failed validation: ${validationError instanceof Error ? validationError.message : String(validationError)}\n\n` +
731
+ 'The binary may be corrupted or incompatible with your system.');
732
+ }
733
+ // Backup existing binary if present, but be ready to rollback
734
+ let backupPath = null;
735
+ let didBackup = false;
736
+ if (await fileExists(binaryPath)) {
737
+ backupPath = `${binaryPath}.backup.${randomUUID()}`;
738
+ await fs.rename(binaryPath, backupPath);
739
+ didBackup = true;
740
+ }
741
+ // Copy extracted binary to final location
742
+ try {
743
+ await fs.copyFile(extractedBinaryPath, binaryPath);
744
+ // Set executable permission on Unix/macOS
745
+ if (!isWindows) {
746
+ await fs.chmod(binaryPath, 0o755);
747
+ }
748
+ // Store the installed path for this process
749
+ installedProxyPath = binaryPath;
750
+ }
751
+ catch (copyError) {
752
+ // Rollback: restore backup if we had one
753
+ if (didBackup && backupPath) {
754
+ try {
755
+ await fs.rename(backupPath, binaryPath);
756
+ }
757
+ catch (rollbackError) {
758
+ debugLog('Failed to rollback after copy failure:', rollbackError);
759
+ }
760
+ }
761
+ // Clean up
762
+ await fs.unlink(archivePath).catch(() => { });
763
+ await fs.rm(extractDir, { recursive: true, force: true }).catch(() => { });
764
+ throw new Error(`Failed to copy binary to final location: ${copyError instanceof Error ? copyError.message : String(copyError)}`);
765
+ }
766
+ // Clean up on success
767
+ await fs.unlink(archivePath).catch((err) => {
768
+ debugLog('Warning: Failed to cleanup archive file:', err);
769
+ });
770
+ await fs.rm(extractDir, { recursive: true, force: true }).catch((err) => {
771
+ debugLog('Warning: Failed to cleanup extract directory:', err);
772
+ });
773
+ // Clean up backup on success
774
+ if (backupPath) {
775
+ await fs.unlink(backupPath).catch((err) => {
776
+ debugLog('Warning: Failed to cleanup backup file:', err);
777
+ });
778
+ }
779
+ console.log(`CLIProxyAPI installed successfully to: ${binaryPath}`);
780
+ // Check if install dir is in PATH (use platform-specific delimiter and case-insensitive on Windows)
781
+ const pathEnv = process.env.PATH || '';
782
+ // Filter empty segments to avoid false positives from '::' in PATH
783
+ const pathDirs = pathEnv.split(delimiter).filter(p => p.length > 0);
784
+ // Normalize paths for comparison: resolve to absolute paths, normalize separators, case-insensitive on Windows
785
+ const normalizePath = (p) => {
786
+ const resolved = resolve(p);
787
+ const normalized = normalize(resolved);
788
+ return isWindows ? normalized.toLowerCase() : normalized;
789
+ };
790
+ const binInPath = pathDirs.some(dir => normalizePath(dir) === normalizePath(installDir));
791
+ if (!binInPath) {
792
+ console.log('');
793
+ console.log('⚠️ WARNING: ~/.local/bin is not in your PATH');
794
+ console.log('');
795
+ console.log('To use ccodex, add ~/.local/bin to your PATH:');
796
+ console.log('');
797
+ console.log(' For bash (add to ~/.bashrc):');
798
+ console.log(' export PATH="$HOME/.local/bin:$PATH"');
799
+ console.log('');
800
+ console.log(' For zsh (add to ~/.zshrc):');
801
+ console.log(' export PATH="$HOME/.local/bin:$PATH"');
802
+ console.log('');
803
+ console.log('Then reload your shell: source ~/.bashrc (or ~/.zshrc)');
804
+ }
373
805
  }
374
- console.log(`CLIProxyAPI installed successfully to: ${binaryPath}`);
375
- // Check if install dir is in PATH
376
- const pathEnv = process.env.PATH || '';
377
- const pathDirs = pathEnv.split(':');
378
- const binInPath = pathDirs.some(dir => dir === installDir);
379
- if (!binInPath) {
380
- console.log('');
381
- console.log('⚠️ WARNING: ~/.local/bin is not in your PATH');
382
- console.log('');
383
- console.log('To use ccodex, add ~/.local/bin to your PATH:');
384
- console.log('');
385
- console.log(' For bash (add to ~/.bashrc):');
386
- console.log(' export PATH="$HOME/.local/bin:$PATH"');
387
- console.log('');
388
- console.log(' For zsh (add to ~/.zshrc):');
389
- console.log(' export PATH="$HOME/.local/bin:$PATH"');
390
- console.log('');
391
- console.log('Then reload your shell: source ~/.bashrc (or ~/.zshrc)');
806
+ catch (error) {
807
+ // Clean up archive and extract dir on error
808
+ await fs.unlink(archivePath).catch((err) => {
809
+ debugLog('Warning: Failed to cleanup archive file during error handling:', err);
810
+ });
811
+ await fs.rm(extractDir, { recursive: true, force: true }).catch((err) => {
812
+ debugLog('Warning: Failed to cleanup extract directory during error handling:', err);
813
+ });
814
+ throw new Error(`Failed to install CLIProxyAPI: ${error instanceof Error ? error.message : String(error)}\n\n` +
815
+ 'Please install CLIProxyAPI manually:\n' +
816
+ ' 1. Visit https://github.com/router-for-me/CLIProxyAPI/releases\n' +
817
+ ` 2. Download ${archiveFileName} for your system\n` +
818
+ ' 3. Extract the archive\n' +
819
+ ' 4. Place the binary in a directory in your PATH\n' +
820
+ ' 5. Make it executable: chmod +x cli-proxy-api');
392
821
  }
393
- }
394
- catch (error) {
395
- // Clean up temp file on error
396
- fs.unlink(tempPath).catch(() => { });
397
- throw new Error(`Failed to install CLIProxyAPI: ${error instanceof Error ? error.message : String(error)}\n\n` +
398
- 'Please install CLIProxyAPI manually:\n' +
399
- ' 1. Visit https://github.com/router-for-me/CLIProxyAPI/releases\n' +
400
- ` 2. Download ${binaryFileName} for your system\n` +
401
- ' 3. Place it in a directory in your PATH\n' +
402
- ' 4. Make it executable: chmod +x cliproxyapi');
403
- }
822
+ });
404
823
  }
405
824
  /**
406
825
  * Start proxy in background
@@ -409,15 +828,10 @@ export async function startProxy() {
409
828
  if (await isProxyRunning()) {
410
829
  return;
411
830
  }
412
- const cmdResult = await detectProxyCommand();
413
- // Use absolute path if available, otherwise command name
414
- const proxyExe = cmdResult.path || cmdResult.cmd;
415
- if (!proxyExe) {
416
- throw new Error('CLIProxyAPI not found. Run: npx -y @tuannvm/ccodex');
417
- }
831
+ const proxyExe = await requireTrustedProxyCommand();
418
832
  console.log('Starting CLIProxyAPI in background...');
419
833
  const logFile = getLogFilePath();
420
- await ensureDir(join(logFile, '..'));
834
+ await ensureDir(dirname(logFile));
421
835
  const { spawn } = await import('child_process');
422
836
  const fs = await import('fs/promises');
423
837
  let out = null;
@@ -466,12 +880,7 @@ export async function startProxy() {
466
880
  * Launch OAuth login
467
881
  */
468
882
  export async function launchLogin() {
469
- const cmdResult = await detectProxyCommand();
470
- // Use absolute path if available, otherwise command name
471
- const proxyExe = cmdResult.path || cmdResult.cmd;
472
- if (!proxyExe) {
473
- throw new Error('CLIProxyAPI not found. Run: npx -y @tuannvm/ccodex');
474
- }
883
+ const proxyExe = await requireTrustedProxyCommand();
475
884
  console.log('Launching ChatGPT/Codex OAuth login in browser...');
476
885
  const spawnCmd = (await import('cross-spawn')).default;
477
886
  return new Promise((resolve, reject) => {