@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/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 +6 -0
- package/dist/proxy.d.ts.map +1 -1
- package/dist/proxy.js +662 -253
- package/dist/proxy.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +17 -2
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +205 -10
- package/dist/utils.js.map +1 -1
- package/package.json +1 -1
package/dist/proxy.js
CHANGED
|
@@ -1,26 +1,92 @@
|
|
|
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
|
+
* 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: '
|
|
81
|
+
return { cmd: 'cli-proxy-api', path: installedProxyPath };
|
|
16
82
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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);
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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 (
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
//
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
324
|
-
|
|
463
|
+
if (!cliArch) {
|
|
464
|
+
throw new Error(`Unsupported architecture: ${arch}\n` +
|
|
465
|
+
`Supported architectures: ${Object.keys(archMap).join(', ')}`);
|
|
325
466
|
}
|
|
326
|
-
//
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
if (
|
|
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
|
-
|
|
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 (
|
|
365
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
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)');
|
|
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
|
|
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(
|
|
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
|
|
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) => {
|