@tuannvm/ccodex 0.1.1 → 0.2.9

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