@tuannvm/ccodex 0.1.9 → 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/README.md +2 -2
- package/dist/aliases.js +2 -2
- package/dist/aliases.js.map +1 -1
- package/dist/claude.d.ts.map +1 -1
- package/dist/claude.js +8 -2
- package/dist/claude.js.map +1 -1
- package/dist/cli.js +20 -1
- package/dist/cli.js.map +1 -1
- package/dist/powershell.js +2 -2
- package/dist/powershell.js.map +1 -1
- package/dist/proxy.d.ts +5 -0
- package/dist/proxy.d.ts.map +1 -1
- package/dist/proxy.js +645 -246
- package/dist/proxy.js.map +1 -1
- package/dist/utils.d.ts +17 -2
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +160 -10
- package/dist/utils.js.map +1 -1
- package/package.json +1 -1
package/dist/proxy.js
CHANGED
|
@@ -1,10 +1,72 @@
|
|
|
1
|
-
import { join } from 'path';
|
|
2
|
-
import {
|
|
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
|
+
*/
|
|
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
|
+
}
|
|
8
70
|
/**
|
|
9
71
|
* Detect CLIProxyAPI command
|
|
10
72
|
* Prefers locally installed binary from this process if available
|
|
@@ -55,23 +117,19 @@ export async function checkAuthConfigured() {
|
|
|
55
117
|
}
|
|
56
118
|
// Check auth via proxy status
|
|
57
119
|
let hasAuthEntries = false;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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 {
|
|
@@ -106,8 +164,11 @@ function sha256Hex(data) {
|
|
|
106
164
|
* Supports common checksum formats:
|
|
107
165
|
* - SHA256SUMS: "a1b2c3... filename" or "a1b2c3... *filename"
|
|
108
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
|
|
109
169
|
*/
|
|
110
170
|
function parseExpectedSha256(content, fileName) {
|
|
171
|
+
// First pass: try exact filename match (with or without path separators)
|
|
111
172
|
for (const line of content.split('\n')) {
|
|
112
173
|
const trimmed = line.trim();
|
|
113
174
|
if (!trimmed)
|
|
@@ -116,7 +177,11 @@ function parseExpectedSha256(content, fileName) {
|
|
|
116
177
|
const match = trimmed.match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/);
|
|
117
178
|
if (match) {
|
|
118
179
|
const [, hash, name] = match;
|
|
119
|
-
|
|
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
|
|
120
185
|
return hash.toLowerCase();
|
|
121
186
|
}
|
|
122
187
|
}
|
|
@@ -152,6 +217,181 @@ async function getLatestReleaseTag() {
|
|
|
152
217
|
'Please check your internet connection or install CLIProxyAPI manually.');
|
|
153
218
|
}
|
|
154
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
|
+
}
|
|
155
395
|
/**
|
|
156
396
|
* Install CLIProxyAPI via Homebrew or Go binary fallback
|
|
157
397
|
*/
|
|
@@ -161,6 +401,8 @@ export async function installProxyApi() {
|
|
|
161
401
|
if (!home) {
|
|
162
402
|
throw new Error('Cannot determine home directory. Please set HOME environment variable.');
|
|
163
403
|
}
|
|
404
|
+
// Install lock file path (in the target install directory)
|
|
405
|
+
const lockPath = join(home, '.local', 'bin', '.cliproxyapi.install.lock');
|
|
164
406
|
// Check platform
|
|
165
407
|
const platform = process.platform;
|
|
166
408
|
const arch = process.arch;
|
|
@@ -169,238 +411,405 @@ export async function installProxyApi() {
|
|
|
169
411
|
'Please install CLIProxyAPI manually and ensure it\'s in your PATH.\n' +
|
|
170
412
|
'See CLIProxyAPI documentation for Windows installation instructions.');
|
|
171
413
|
}
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
await
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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));
|
|
189
436
|
});
|
|
190
|
-
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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}`;
|
|
214
|
-
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
|
-
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 });
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
catch (error) {
|
|
440
|
+
debugLog('Homebrew installation failed, falling back to Go binary:', error);
|
|
441
|
+
// Fall through to Go binary installation
|
|
442
|
+
}
|
|
234
443
|
}
|
|
235
|
-
|
|
236
|
-
|
|
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(', ')}`);
|
|
237
455
|
}
|
|
238
|
-
if (!
|
|
239
|
-
throw new Error(`
|
|
240
|
-
`
|
|
241
|
-
`Platform/Arch: ${platformSuffix}`);
|
|
456
|
+
if (!cliArch) {
|
|
457
|
+
throw new Error(`Unsupported architecture: ${arch}\n` +
|
|
458
|
+
`Supported architectures: ${Object.keys(archMap).join(', ')}`);
|
|
242
459
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
console.log(
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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;
|
|
257
488
|
try {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
+
}
|
|
278
573
|
}
|
|
279
574
|
}
|
|
280
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.');
|
|
281
610
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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}`);
|
|
288
632
|
}
|
|
289
|
-
debugLog(`Checksum verification failed for ${checksumUrl}:`, checksumError);
|
|
290
|
-
// Try next checksum URL on network errors
|
|
291
633
|
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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();
|
|
322
|
-
}
|
|
323
|
-
finally {
|
|
324
|
-
await fileHandle.close();
|
|
325
|
-
}
|
|
326
|
-
// Validate the binary works by running it
|
|
327
|
-
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}`);
|
|
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.');
|
|
336
640
|
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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) {
|
|
361
699
|
try {
|
|
362
|
-
await fs.
|
|
700
|
+
await fs.chmod(extractedBinaryPath, 0o755);
|
|
363
701
|
}
|
|
364
|
-
catch (
|
|
365
|
-
debugLog('
|
|
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
|
|
366
705
|
}
|
|
367
706
|
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
+
}
|
|
373
795
|
}
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
console.log(' export PATH="$HOME/.local/bin:$PATH"');
|
|
390
|
-
console.log('');
|
|
391
|
-
console.log('Then reload your shell: source ~/.bashrc (or ~/.zshrc)');
|
|
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');
|
|
392
811
|
}
|
|
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
|
-
}
|
|
812
|
+
});
|
|
404
813
|
}
|
|
405
814
|
/**
|
|
406
815
|
* Start proxy in background
|
|
@@ -409,15 +818,10 @@ export async function startProxy() {
|
|
|
409
818
|
if (await isProxyRunning()) {
|
|
410
819
|
return;
|
|
411
820
|
}
|
|
412
|
-
const
|
|
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
|
-
}
|
|
821
|
+
const proxyExe = await requireTrustedProxyCommand();
|
|
418
822
|
console.log('Starting CLIProxyAPI in background...');
|
|
419
823
|
const logFile = getLogFilePath();
|
|
420
|
-
await ensureDir(
|
|
824
|
+
await ensureDir(dirname(logFile));
|
|
421
825
|
const { spawn } = await import('child_process');
|
|
422
826
|
const fs = await import('fs/promises');
|
|
423
827
|
let out = null;
|
|
@@ -466,12 +870,7 @@ export async function startProxy() {
|
|
|
466
870
|
* Launch OAuth login
|
|
467
871
|
*/
|
|
468
872
|
export async function launchLogin() {
|
|
469
|
-
const
|
|
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
|
-
}
|
|
873
|
+
const proxyExe = await requireTrustedProxyCommand();
|
|
475
874
|
console.log('Launching ChatGPT/Codex OAuth login in browser...');
|
|
476
875
|
const spawnCmd = (await import('cross-spawn')).default;
|
|
477
876
|
return new Promise((resolve, reject) => {
|