From 0f7e7fe361bc6cc3b502408d1e8629c654a18c0c Mon Sep 17 00:00:00 2001 From: comlibmb <1844410276@qq.com> Date: Mon, 4 May 2026 13:04:11 +0800 Subject: [PATCH 1/4] fix: tunnel reconnection stability and manual pairing reliability - Add TunnelNotifier with URL format normalization (wss://) and 5x retry with exponential backoff - Preserve pairing_code in Supabase during tunnel restarts (don't overwrite with null) - Distinguish graceful disconnect from network interruption (keep sessions 5min TTL) - Add session TTL mechanism: clearAllTakenOver runs on timeout, cancelSessionTTL on reconnect - Add connected event listener to cancel session TTL when mobile reconnects Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 112 +++++++++++++++++++- package.json | 1 + src/config.ts | 59 +++++++++++ src/index.ts | 197 +++++++++++++++++++++++++++++------- src/startup.ts | 2 +- src/tools/claude-process.ts | 39 +++++-- src/tunnel-notifier.ts | 91 +++++++++++++++++ src/tunnel.ts | 178 ++++++++++++++++++++++++++++++++ src/websocket.ts | 6 +- 9 files changed, 639 insertions(+), 46 deletions(-) create mode 100644 src/tunnel-notifier.ts create mode 100644 src/tunnel.ts diff --git a/package-lock.json b/package-lock.json index 250569c..7c8f26b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "forkoff", - "version": "1.1.2", + "version": "1.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "forkoff", - "version": "1.1.2", + "version": "1.1.5", "license": "MIT", "dependencies": { "@noble/hashes": "^1.8.0", + "@supabase/supabase-js": "^2.105.1", "chalk": "^4.1.2", "chokidar": "^3.6.0", "commander": "^14.0.2", @@ -37,6 +38,9 @@ "ts-jest": "^29.4.6", "ts-node": "^10.9.2", "typescript": "^5.9.3" + }, + "engines": { + "node": ">=18.0.0" } }, "node_modules/@babel/code-frame": { @@ -1561,6 +1565,92 @@ "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", "license": "MIT" }, + "node_modules/@supabase/auth-js": { + "version": "2.105.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.105.1.tgz", + "integrity": "sha512-zc4s8Xg4truwE1Q4Q8M8oUVDARMd05pKh73NyQsMbYU1HDdDN2iiKzena/yu+yJze3WrD4c092FdckPiK1rLQw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.105.1", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.105.1.tgz", + "integrity": "sha512-dTk1e7oE51VGc1lS2S0J0NLo0Wp4JYChj74ArJKbIWgoWuFwO0wcJYjeyOV3AAEpKst8/LQWUZOUKO1tRXBrpA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.1.tgz", + "integrity": "sha512-hWGJkDAfWUNY8k0C080u3sGNFd2ncl9erhKgP7hnGkgJWEfT5Pd/SXal4QmWXBECVlZrannMAc9sBaaRyWpiUA==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.105.1", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.105.1.tgz", + "integrity": "sha512-6SbtsoWC55xfsm7gbfLqvF+yIwTQEbjt+jFGf4klDpwSnUy17Hv5x0Dq52oqwTQlw6Ta0h1D5gTP0/pApqNojA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.105.1", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.105.1.tgz", + "integrity": "sha512-3X3cUEl5cJ4lRQHr1hXHx0b98OaL97RRO2vrRZ98FD91JV/MquZHhrGJSv/+IkOnjF6E2e0RUOxE8P3Zi035ow==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "^0.4.1", + "@types/ws": "^8.18.1", + "tslib": "2.8.1", + "ws": "^8.18.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.105.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.105.1.tgz", + "integrity": "sha512-owfdCNH5ikXXDusjzsgU6LavEBqGUoueOnL/9XIucld70/WJ/rbqp89K//c9QPICDNuegsmpoeasydDAiucLKQ==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.105.1", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.105.1.tgz", + "integrity": "sha512-4gn6HmsAkCCVU7p8JmgKGhHJ5Btod4ZzSp8qKZf4JHaTxbhaIK86/usHzeLxWv7EJJDhBmILDmJOSOf9iF4CLA==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.105.1", + "@supabase/functions-js": "2.105.1", + "@supabase/postgrest-js": "2.105.1", + "@supabase/realtime-js": "2.105.1", + "@supabase/storage-js": "2.105.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -1739,6 +1829,15 @@ "@types/node": "*" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -3269,6 +3368,15 @@ "node": ">=10.17.0" } }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", diff --git a/package.json b/package.json index e6b8355..22763b5 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "license": "MIT", "dependencies": { "@noble/hashes": "^1.8.0", + "@supabase/supabase-js": "^2.105.1", "chalk": "^4.1.2", "chokidar": "^3.6.0", "commander": "^14.0.2", diff --git a/src/config.ts b/src/config.ts index e2f9c77..6963018 100644 --- a/src/config.ts +++ b/src/config.ts @@ -60,6 +60,9 @@ interface DeviceConfig { relayMode: 'cloud' | 'local'; relayToken: string | null; pairId: string | null; + allowedDirs: string[]; + tunnelProvider: 'cloudflared' | null; + tunnelUrl: string | null; } const defaultConfig: DeviceConfig = { @@ -76,6 +79,9 @@ const defaultConfig: DeviceConfig = { relayMode: 'cloud', relayToken: null, pairId: null, + allowedDirs: [], + tunnelProvider: null, + tunnelUrl: null, }; class Config { @@ -309,6 +315,59 @@ class Config { getPath(): string { return this.configPath; } + + get allowedDirs(): string[] { + return this.data.allowedDirs || []; + } + + set allowedDirs(value: string[]) { + this.data.allowedDirs = value; + this.save(); + } + + get tunnelProvider(): 'cloudflared' | null { + return this.data.tunnelProvider; + } + + set tunnelProvider(value: 'cloudflared' | null) { + this.data.tunnelProvider = value; + this.save(); + } + + get tunnelUrl(): string | null { + return this.data.tunnelUrl; + } + + set tunnelUrl(value: string | null) { + this.data.tunnelUrl = value; + this.save(); + } + + /** + * Check if a resolved path is allowed for file/directory access. + * Path is allowed if it's under home directory or any configured allowedDirs entry. + */ + isPathAllowed(resolvedPath: string): boolean { + const homeDir = os.homedir(); + const isWin = os.platform() === 'win32'; + + // Check home directory + const relToHome = path.relative(homeDir, resolvedPath); + if (!(relToHome.startsWith('..') || path.isAbsolute(relToHome))) { + return true; + } + + // Check whitelist + for (const allowedDir of this.allowedDirs) { + const normalizedAllowed = path.normalize(allowedDir); + const isUnder = isWin + ? resolvedPath.toLowerCase().startsWith(normalizedAllowed.toLowerCase()) + : resolvedPath.startsWith(normalizedAllowed); + if (isUnder) return true; + } + + return false; + } } export const config = new Config(); diff --git a/src/index.ts b/src/index.ts index 0a0a0cf..f5ae1a2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,10 +13,15 @@ import { transcriptStreamer } from './transcript-streamer'; import { setQuiet, setDebug, closeDebugLog, cleanupOldLogs, getLogFilePath, createSpinner } from './logger'; import { UsageTracker } from './usage-tracker'; import { enableStartup, disableStartup, isStartupRegistered, getBinaryPath } from './startup'; +import { TunnelManager } from './tunnel'; +import { TunnelNotifier } from './tunnel-notifier'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +// Module-level tunnel manager for cleanup on exit +let activeTunnel: TunnelManager | null = null; + /** Get the local network IP (first non-internal IPv4 address) */ function getLocalIp(): string { const interfaces = os.networkInterfaces(); @@ -56,6 +61,7 @@ program .description('Configure ForkOff CLI settings') .option('-p, --port ', 'Set relay server port') .option('-n, --name ', 'Set device name') + .option('--allowed-dirs ', 'Set allowed directories (comma-separated, e.g. "D:\\datas,C:\\Projects")') .option('--show', 'Show current configuration') .option('--reset', 'Reset all configuration') .action(async (options) => { @@ -80,7 +86,14 @@ program console.log(chalk.green(`Device name set to: ${options.name}`)); } - if (options.show || (!options.port && !options.name && !options.reset)) { + if (options.allowedDirs) { + const dirs = (options.allowedDirs as string).split(',').map((d: string) => d.trim()).filter(Boolean); + config.allowedDirs = dirs; + console.log(chalk.green(`Allowed directories set to:`)); + dirs.forEach((d: string) => console.log(chalk.cyan(` - ${d}`))); + } + + if (options.show || (!options.port && !options.name && !options.reset && !options.allowedDirs)) { const localIp = getLocalIp(); const isCloud = config.relayMode === 'cloud'; console.log(chalk.bold('\nCurrent Configuration:')); @@ -99,6 +112,10 @@ program ? chalk.dim('Not configured') : config.startupEnabled ? chalk.green('Enabled') : chalk.yellow('Disabled'); console.log(` Startup: ${startupStatus}`); + const allowedDirsStr = config.allowedDirs.length > 0 + ? config.allowedDirs.map(d => chalk.cyan(`\n - ${d}`)).join('') + : chalk.dim('None (home directory only)'); + console.log(` Allowed Dirs:${allowedDirsStr}`); } }); @@ -107,9 +124,11 @@ program .command('pair') .description('Generate pairing code to connect with mobile app') .option('--local', 'Use local network relay instead of cloud relay') + .option('--tunnel', 'Use cloudflared tunnel for public internet access') .action(async (options) => { const isLocal = options.local; - const spinner = createSpinner(isLocal ? 'Starting local relay server...' : 'Connecting to cloud relay...').start(); + const useTunnel = options.tunnel; + const spinner = createSpinner(isLocal ? 'Starting local relay server...' : useTunnel ? 'Starting tunnel relay...' : 'Connecting to cloud relay...').start(); try { // Ensure we have a deviceId @@ -119,7 +138,54 @@ program const pairingCode = crypto.randomBytes(4).toString('hex').toUpperCase().slice(0, 8); config.pairingCode = pairingCode; - if (isLocal) { + if (useTunnel) { + // Tunnel mode: start local relay + cloudflared tunnel + config.relayMode = 'local'; + config.tunnelProvider = 'cloudflared'; + await wsClient.startServer(config.relayPort); + wsClient.setPairingCode(pairingCode); + + // Start cloudflared tunnel + activeTunnel = new TunnelManager(); + const tunnelUrl = await activeTunnel.start(config.relayPort); + config.tunnelUrl = tunnelUrl; + + // Mobile expects wss:// format in QR code relay parameter + const relayUrl = tunnelUrl.replace('https://', 'wss://').replace('http://', 'ws://'); + + spinner.succeed(`Tunnel relay started\n`); + + // Notify Supabase so mobile can auto-reconnect on tunnel restart + await TunnelNotifier.notifyTunnelUrl(config.deviceId!, tunnelUrl, pairingCode); + + // QR includes tunnel URL + const pairingUrl = `forkoff://pair/${pairingCode}?relay=${encodeURIComponent(relayUrl)}`; + console.log(chalk.bold('Scan this QR code with the ForkOff mobile app:\n')); + qrcode.generate(pairingUrl, { small: true }, (code) => { + console.log(code); + }); + + console.log(chalk.bold('\nOr enter this code manually:\n')); + console.log(chalk.bgBlue.white.bold(` ${pairingCode} `)); + console.log(); + console.log(chalk.dim(`Tunnel: ${tunnelUrl}`)); + console.log(chalk.dim(`Relay: ws://localhost:${config.relayPort} → ${relayUrl}`)); + + // Listen for tunnel URL changes and notify mobile + activeTunnel.on('url_changed', async (newUrl: string) => { + console.log(chalk.cyan(`[Tunnel] URL changed: ${newUrl}`)); + config.tunnelUrl = newUrl; + await TunnelNotifier.notifyTunnelUrl(config.deviceId!, newUrl); + }); + + activeTunnel.on('error', async (err: Error) => { + console.log(chalk.red(`[Tunnel] Error: ${err.message}`)); + if (err.message.includes('giving up')) { + await TunnelNotifier.markTunnelOffline(config.deviceId!); + } + }); + + } else if (isLocal) { // Local mode: start embedded relay server (existing behavior) config.relayMode = 'local'; await wsClient.startServer(config.relayPort); @@ -230,6 +296,7 @@ program .command('connect') .description('Reconnect to ForkOff (for previously paired devices)') .option('--local', 'Use local network relay instead of cloud relay') + .option('--tunnel', 'Use cloudflared tunnel for public internet access') .action(async (options) => { if (!config.deviceId) { console.log(chalk.yellow('Device not registered. Run "forkoff pair" first.')); @@ -251,10 +318,44 @@ program } } - // Determine relay mode: explicit flag > saved config - const useLocal = options.local || config.relayMode === 'local'; + const useTunnel = options.tunnel || config.tunnelProvider === 'cloudflared'; + + if (useTunnel) { + // Tunnel mode: start local relay + cloudflared tunnel + config.relayMode = 'local'; + config.tunnelProvider = 'cloudflared'; + await wsClient.startServer(config.relayPort); + console.log(chalk.cyan(`Local relay server started on port ${config.relayPort}`)); + + // Start cloudflared tunnel + activeTunnel = new TunnelManager(); + try { + const tunnelUrl = await activeTunnel.start(config.relayPort); + config.tunnelUrl = tunnelUrl; + console.log(chalk.green(`Tunnel started: ${tunnelUrl}`)); + + // Notify Supabase so mobile can connect / auto-reconnect + await TunnelNotifier.notifyTunnelUrl(config.deviceId!, tunnelUrl); + + // Listen for tunnel URL changes + activeTunnel.on('url_changed', async (newUrl: string) => { + console.log(chalk.cyan(`[Tunnel] URL changed: ${newUrl}`)); + config.tunnelUrl = newUrl; + await TunnelNotifier.notifyTunnelUrl(config.deviceId!, newUrl); + }); - if (useLocal) { + activeTunnel.on('error', async (err: Error) => { + console.log(chalk.red(`[Tunnel] Error: ${err.message}`)); + if (err.message.includes('giving up')) { + await TunnelNotifier.markTunnelOffline(config.deviceId!); + } + }); + } catch (err: any) { + console.log(chalk.red(`Failed to start tunnel: ${err.message}`)); + console.log(chalk.yellow('Falling back to local mode...')); + activeTunnel = null; + } + } else if (options.local || config.relayMode === 'local') { // Local mode: start embedded relay server const localIp = getLocalIp(); const relayUrl = `ws://${localIp}:${config.relayPort}`; @@ -644,6 +745,16 @@ async function startConnection(): Promise { // Claude is only spawned when the user actually sends a message (via user_message event) // This prevents duplicate transcript entries from double spawns wsClient.on('claude_resume_session', async (data: any) => { + // Prevent duplicate resume for the same session (avoid registration loop) + if (claudeProcessManager.isSessionTakenOver(data.terminalSessionId)) { + console.log(chalk.dim(`[Claude] Session ${data.sessionKey?.substring(0, 8)}... already registered, resending ready`)); + wsClient.sendClaudeSessionEvent({ + sessionKey: data.sessionKey, + event: { type: 'ready' }, + }); + return; + } + console.log(chalk.cyan(`[Claude] Resume session request`)); // Look up the correct directory from our locally scanned sessions @@ -709,14 +820,9 @@ async function startConnection(): Promise { // SECURITY: Normalize and validate path to prevent traversal attacks resolvedPath = path.resolve(resolvedPath); - const homeDir = os.homedir(); - - // SECURITY: Only allow access to directories under home directory - // This prevents accessing sensitive system files like /etc/passwd - // Uses path.relative() for cross-platform safety (handles Windows case-insensitivity) - const dirRelative = path.relative(homeDir, resolvedPath); - if (dirRelative.startsWith('..') || path.isAbsolute(dirRelative)) { - console.warn(chalk.yellow(`[Dir] Access denied — path outside home directory`)); + + if (!config.isPathAllowed(resolvedPath)) { + console.warn(chalk.yellow(`[Dir] Access denied — path not in allowed directories`)); wsClient.sendDirectoryListResponse({ requestId: data.requestId, entries: [], currentPath: data.path }); return; } @@ -766,12 +872,8 @@ async function startConnection(): Promise { } resolvedPath = path.resolve(resolvedPath); - // SECURITY: Only allow access under home directory - // Uses path.relative() for cross-platform safety (handles Windows case-insensitivity) - const homeDir = os.homedir(); - const fileRelative = path.relative(homeDir, resolvedPath); - if (fileRelative.startsWith('..') || path.isAbsolute(fileRelative)) { - console.warn(chalk.yellow(`[File] Access denied — path outside home directory`)); + if (!config.isPathAllowed(resolvedPath)) { + console.warn(chalk.yellow(`[File] Access denied — path not in allowed directories`)); wsClient.sendReadFileResponse({ requestId: data.requestId, exists: false, @@ -1082,16 +1184,24 @@ async function startConnection(): Promise { return; } - // Session not taken over — user must press "Take Over" first - console.log(chalk.yellow(`[Claude] Session not taken over: ${terminalSessionId} — watch-only mode`)); - wsClient.sendClaudeSessionEvent({ - sessionKey: terminalSessionId, - event: { - type: 'error', - message: 'You must take over this session before sending messages.', - }, - }); - return; + // Session was taken over before but cleared on reconnect — auto re-register + const knownSession = claudeSessionDetector.getSessions().find(s => s.sessionKey === terminalSessionId); + if (knownSession) { + console.log(chalk.cyan(`[Claude] Auto re-registering session ${terminalSessionId.substring(0, 8)}... after reconnect`)); + const dir = knownSession.directory || data.directory; + claudeProcessManager.registerSession(terminalSessionId, dir, terminalSessionId, false, true, true); + claudeProcessManager.markTakenOver(terminalSessionId); + } else { + console.log(chalk.yellow(`[Claude] Session not taken over: ${terminalSessionId} — watch-only mode`)); + wsClient.sendClaudeSessionEvent({ + sessionKey: terminalSessionId, + event: { + type: 'error', + message: 'You must take over this session before sending messages.', + }, + }); + return; + } } console.log(chalk.dim(`[Claude] Sending to session: ${terminalSessionId}`)); @@ -1197,12 +1307,26 @@ async function startConnection(): Promise { }); }); + wsClient.on('connected', () => { + claudeProcessManager.cancelSessionTTL(); + }); + wsClient.on('disconnected', (reason) => { console.log(chalk.yellow(`\nMobile disconnected: ${reason}`)); - console.log(chalk.dim('Waiting for mobile to reconnect...')); claudeProcessManager.resolveAllPendingPrompts('deny', 'mobile disconnected'); - claudeProcessManager.cleanupAllPermissionState(); - claudeProcessManager.clearAllTakenOver(); + + // Distinguish graceful disconnect (user closed app) from network interruption + const isGraceful = reason === 'client namespace disconnect'; + if (isGraceful) { + claudeProcessManager.cleanupAllPermissionState(); + claudeProcessManager.clearAllTakenOver(); + } else { + // Network interruption — keep sessions for 5 min in case mobile reconnects + console.log(chalk.dim('Network interruption — keeping sessions for 5 min')); + claudeProcessManager.startSessionTTL(5 * 60 * 1000); + } + + console.log(chalk.dim('Waiting for mobile to reconnect...')); }); wsClient.on('session_release', (data: any) => { @@ -1215,8 +1339,13 @@ async function startConnection(): Promise { }); // Keep the process running - process.on('SIGINT', () => { + process.on('SIGINT', async () => { console.log(chalk.yellow('\nDisconnecting...')); + if (activeTunnel) { + await TunnelNotifier.clearTunnelSession(config.deviceId!); + await activeTunnel.stop(); + activeTunnel = null; + } claudeProcessManager.cleanupAllPermissionState(); usageTracker.flush(); claudeSessionDetector.stopWatching(); diff --git a/src/startup.ts b/src/startup.ts index 8dcb7b1..9703040 100644 --- a/src/startup.ts +++ b/src/startup.ts @@ -121,7 +121,7 @@ async function enableStartupWindows(binaryPath: string): Promise { // WScript.Shell.Run with windowStyle 0 = hidden, False = don't wait. const vbsPath = getVbsPath(); const escapedPath = cmdPath.replace(/"/g, '""'); - const vbsContent = `CreateObject("WScript.Shell").Run """${escapedPath}"" connect --quiet", 0, False\r\n`; + const vbsContent = `CreateObject("WScript.Shell").Run """${escapedPath}"" connect --quiet --tunnel", 0, False\r\n`; fs.writeFileSync(vbsPath, vbsContent, { mode: 0o600 }); // Use HKCU Run key — no admin required, runs on user logon diff --git a/src/tools/claude-process.ts b/src/tools/claude-process.ts index 19ed37a..f3832c0 100644 --- a/src/tools/claude-process.ts +++ b/src/tools/claude-process.ts @@ -895,15 +895,11 @@ class ClaudeProcessManager extends EventEmitter { resolved = path.resolve(dir); } - // SECURITY: Prevent path traversal — resolved path must be under home directory - const homeDir = os.homedir(); const normalized = path.normalize(resolved); - // On Windows, paths are case-insensitive — use lowercase comparison - const isUnderHome = os.platform() === 'win32' - ? normalized.toLowerCase().startsWith(homeDir.toLowerCase()) - : normalized.startsWith(homeDir); - if (!isUnderHome) { - throw new Error('Invalid directory path: path traversal detected (must be under home directory)'); + + // Validate resolved path exists as absolute and has no traversal components + if (!path.isAbsolute(normalized) || normalized.includes('..')) { + throw new Error(`Invalid directory path: ${normalized}`); } return normalized; @@ -1032,6 +1028,33 @@ class ClaudeProcessManager extends EventEmitter { console.log(`[Claude Process] All taken-over sessions cleared`); } + private sessionTTLTimer: NodeJS.Timeout | null = null; + + startSessionTTL(ttlMs: number): void { + this.cancelSessionTTL(); + this.sessionTTLTimer = setTimeout(() => { + console.log('[ClaudeProcess] Session TTL expired, clearing'); + this.clearAllTakenOver(); + this.cleanupAllPermissionState(); + this.sessionTTLTimer = null; + }, ttlMs); + } + + cancelSessionTTL(): void { + if (this.sessionTTLTimer) { + clearTimeout(this.sessionTTLTimer); + this.sessionTTLTimer = null; + } + } + + /** + * Check if a session is already registered/taken over. + * Used to prevent duplicate resume requests. + */ + isSessionTakenOver(terminalSessionId: string): boolean { + return this.takenOverSessions.has(terminalSessionId); + } + /** * Release a single session — clean up its hooks and IPC. * Called when mobile navigates away from the session screen after taking over. diff --git a/src/tunnel-notifier.ts b/src/tunnel-notifier.ts new file mode 100644 index 0000000..8abe2e9 --- /dev/null +++ b/src/tunnel-notifier.ts @@ -0,0 +1,91 @@ +import { createClient } from '@supabase/supabase-js'; + +const supabaseUrl = 'https://iqfvfcncnvjbompqjhnh.supabase.co'; +const supabaseAnonKey = 'sb_publishable_PMYuGKey4HCmxWP5vHuUmw_a7TQ_y1B'; + +const supabase = createClient(supabaseUrl, supabaseAnonKey); + +function normalizeTunnelUrl(url: string): string { + if (url.startsWith('https://')) return url.replace('https://', 'wss://'); + if (url.startsWith('http://')) return url.replace('http://', 'ws://'); + return url; +} + +export class TunnelNotifier { + static async notifyTunnelUrl( + deviceId: string, + tunnelUrl: string, + pairingCode?: string + ): Promise { + const normalizedUrl = normalizeTunnelUrl(tunnelUrl); + const maxRetries = 5; + const delays = [1000, 2000, 4000, 8000, 16000]; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + const upsertData: any = { + device_id: deviceId, + tunnel_url: normalizedUrl, + provider: 'cloudflared', + expires_at: new Date(Date.now() + 4 * 60 * 60 * 1000).toISOString(), + }; + // Only set pairing_code when provided — don't overwrite existing code on tunnel restart + if (pairingCode) { + upsertData.pairing_code = pairingCode; + } + + const { error } = await supabase.from('tunnel_sessions').upsert( + upsertData, + { onConflict: 'device_id' } + ); + + if (!error) { + console.log('[TunnelNotifier] Tunnel URL notified to Supabase'); + return; + } + console.warn(`[TunnelNotifier] Attempt ${attempt + 1} failed: ${error.message}`); + } catch (err: any) { + console.warn(`[TunnelNotifier] Attempt ${attempt + 1} error: ${err.message}`); + } + + if (attempt < maxRetries - 1) { + await new Promise(r => setTimeout(r, delays[attempt])); + } + } + console.error('[TunnelNotifier] All retries exhausted'); + } + + static async clearTunnelSession(deviceId: string): Promise { + try { + const { error } = await supabase + .from('tunnel_sessions') + .delete() + .eq('device_id', deviceId); + + if (error) { + console.warn('[TunnelNotifier] Failed to clear tunnel session:', error.message); + } else { + console.log('[TunnelNotifier] Tunnel session cleared'); + } + } catch (err: any) { + console.warn('[TunnelNotifier] Error clearing tunnel session:', err.message); + } + } + + static async markTunnelOffline(deviceId: string): Promise { + try { + const { error } = await supabase.from('tunnel_sessions').upsert({ + device_id: deviceId, + tunnel_url: '', + provider: 'cloudflared', + expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }, { onConflict: 'device_id' }); + + if (error) { + console.warn('[TunnelNotifier] Failed to mark tunnel offline:', error.message); + } + } catch (err: any) { + console.warn('[TunnelNotifier] Error marking tunnel offline:', err.message); + } + } +} diff --git a/src/tunnel.ts b/src/tunnel.ts new file mode 100644 index 0000000..2da03b7 --- /dev/null +++ b/src/tunnel.ts @@ -0,0 +1,178 @@ +import { EventEmitter } from 'events'; +import { spawn, ChildProcess } from 'child_process'; + +const TUNNEL_URL_REGEX = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/; +const MAX_RETRIES = 10; +const RETRY_DELAY_MS = 5000; +const URL_TIMEOUT_MS = 30000; + +export interface TunnelManagerEvents { + url_changed: (url: string) => void; + error: (error: Error) => void; + retry: (attempt: number, maxRetries: number) => void; +} + +export class TunnelManager extends EventEmitter { + private process: ChildProcess | null = null; + private currentUrl: string | null = null; + private retryCount = 0; + private stopped = false; + private cloudflaredPath: string; + + constructor(cloudflaredPath?: string) { + super(); + // Default to full path on Windows since cloudflared may not be in PATH + if (cloudflaredPath) { + this.cloudflaredPath = cloudflaredPath; + } else if (process.platform === 'win32') { + // Try common locations + const candidates = [ + 'D:\\software\\cloudflared.exe', + 'C:\\Program Files\\cloudflared\\cloudflared.exe', + 'cloudflared', + ]; + this.cloudflaredPath = candidates.find(p => { + try { return require('fs').existsSync(p); } catch { return false; } + }) || 'cloudflared'; + } else { + this.cloudflaredPath = 'cloudflared'; + } + } + + async start(localPort: number = 3000): Promise { + this.stopped = false; + this.retryCount = 0; + return this.spawnProcess(localPort); + } + + async stop(): Promise { + this.stopped = true; + if (this.process) { + try { + this.process.kill('SIGTERM'); + // Give it 3 seconds to exit gracefully + await new Promise((resolve) => { + const timeout = setTimeout(() => { + try { this.process?.kill('SIGKILL'); } catch {} + resolve(); + }, 3000); + this.process!.on('exit', () => { + clearTimeout(timeout); + resolve(); + }); + }); + } catch {} + this.process = null; + } + this.currentUrl = null; + } + + getCurrentUrl(): string | null { + return this.currentUrl; + } + + private spawnProcess(localPort: number): Promise { + return new Promise((resolve, reject) => { + try { + const args = ['tunnel', '--url', `http://localhost:${localPort}`]; + + this.process = spawn(this.cloudflaredPath, args, { + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + + let urlResolved = false; + let stderrOutput = ''; + + this.process.stdout!.on('data', (data: Buffer) => { + const text = data.toString(); + const match = text.match(TUNNEL_URL_REGEX); + if (match && !urlResolved) { + urlResolved = true; + this.currentUrl = match[0]; + this.retryCount = 0; + this.emit('url_changed', this.currentUrl); + resolve(this.currentUrl); + } + }); + + this.process.stderr!.on('data', (data: Buffer) => { + const text = data.toString(); + stderrOutput += text; + // cloudflared outputs the URL to stderr too + const match = text.match(TUNNEL_URL_REGEX); + if (match && !urlResolved) { + urlResolved = true; + this.currentUrl = match[0]; + this.retryCount = 0; + this.emit('url_changed', this.currentUrl); + resolve(this.currentUrl); + } + }); + + this.process.on('error', (err: Error) => { + if (!urlResolved) { + urlResolved = true; + reject(new Error(`Failed to start cloudflared: ${err.message}. Install with: winget install cloudflare.cloudflared`)); + } + this.emit('error', err); + }); + + this.process.on('exit', (code) => { + this.process = null; + if (this.stopped) return; + + if (!urlResolved) { + urlResolved = true; + // Process exited before we got a URL — retry + this.handleCrash(localPort, reject); + return; + } + + // Process exited after we had a URL — auto-restart + console.log(`[Tunnel] cloudflared exited with code ${code}, restarting...`); + this.handleCrash(localPort, () => {}); + }); + + // Timeout waiting for URL + setTimeout(() => { + if (!urlResolved) { + urlResolved = true; + this.process?.kill(); + reject(new Error(`Timed out waiting for tunnel URL. cloudflared output:\n${stderrOutput.slice(-500)}`)); + } + }, URL_TIMEOUT_MS); + + } catch (err: any) { + reject(new Error(`Failed to spawn cloudflared: ${err.message}. Install with: winget install cloudflare.cloudflared`)); + } + }); + } + + private async handleCrash(localPort: number, initialReject: (err: Error) => void): Promise { + if (this.stopped) return; + + this.retryCount++; + if (this.retryCount > MAX_RETRIES) { + const err = new Error(`cloudflared exited ${MAX_RETRIES} times — giving up`); + this.emit('error', err); + initialReject(err); + return; + } + + this.emit('retry', this.retryCount, MAX_RETRIES); + console.log(`[Tunnel] Restart attempt ${this.retryCount}/${MAX_RETRIES} in ${RETRY_DELAY_MS / 1000}s...`); + + await new Promise(r => setTimeout(r, RETRY_DELAY_MS)); + + if (this.stopped) return; + + try { + const newUrl = await this.spawnProcess(localPort); + console.log(`[Tunnel] Tunnel restarted: ${newUrl}`); + } catch (err: any) { + // spawnProcess will have already called handleCrash recursively if retryable + this.emit('error', err); + } + } +} diff --git a/src/websocket.ts b/src/websocket.ts index 95a03fd..b45a48b 100644 --- a/src/websocket.ts +++ b/src/websocket.ts @@ -245,6 +245,10 @@ export class WebSocketClient extends EventEmitter { private _keyExchangePending = false; private _keyExchangeDebounceTimer: ReturnType | null = null; private _keyExchangeDebounceTarget: string | null = null; + private _keyExchangeRetryCount = 0; + private _keyExchangeRetryTimer: ReturnType | null = null; + private static readonly KEY_EXCHANGE_MAX_RETRIES = 3; + private static readonly KEY_EXCHANGE_RETRY_DELAY_MS = 3000; // Queue for sensitive messages waiting for E2EE session establishment private pendingSensitiveMessages: PendingSensitiveMessage[] = []; private static readonly SENSITIVE_QUEUE_TTL_MS = 120_000; // 2 minutes max wait @@ -315,7 +319,7 @@ export class WebSocketClient extends EventEmitter { // A fresh key exchange will re-establish the session after debounce. if (this.e2eePeerDeviceId) { console.log(`[E2EE] Clearing stale peer state (mobile reconnected)`); - this.e2eeManager?.clearSession(this.e2eePeerDeviceId); + this.e2eeManager?.resetPeerTrust(this.e2eePeerDeviceId); this.e2eePeerDeviceId = null; } From 7399e78eeaf1f8d7d8a5f017750c287c0d5dc1e8 Mon Sep 17 00:00:00 2001 From: comlibmb <1844410276@qq.com> Date: Mon, 4 May 2026 13:18:50 +0800 Subject: [PATCH 2/4] fix: move Supabase credentials to environment variables Replace hardcoded Supabase URL and anon key with FORKOFF_SUPABASE_URL and FORKOFF_SUPABASE_ANON_KEY environment variables. Co-Authored-By: Claude Opus 4.6 --- .env.example | 3 +++ src/tunnel-notifier.ts | 9 +++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ac9de0e --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# ForkOff CLI Environment Variables +FORKOFF_SUPABASE_URL= +FORKOFF_SUPABASE_ANON_KEY= diff --git a/src/tunnel-notifier.ts b/src/tunnel-notifier.ts index 8abe2e9..33b382d 100644 --- a/src/tunnel-notifier.ts +++ b/src/tunnel-notifier.ts @@ -1,7 +1,12 @@ import { createClient } from '@supabase/supabase-js'; -const supabaseUrl = 'https://iqfvfcncnvjbompqjhnh.supabase.co'; -const supabaseAnonKey = 'sb_publishable_PMYuGKey4HCmxWP5vHuUmw_a7TQ_y1B'; +// Supabase credentials loaded from environment variables +const supabaseUrl = process.env.FORKOFF_SUPABASE_URL || process.env.SUPABASE_URL || ''; +const supabaseAnonKey = process.env.FORKOFF_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY || ''; + +if (!supabaseUrl || !supabaseAnonKey) { + console.warn('[TunnelNotifier] Supabase credentials not configured. Set FORKOFF_SUPABASE_URL and FORKOFF_SUPABASE_ANON_KEY environment variables.'); +} const supabase = createClient(supabaseUrl, supabaseAnonKey); From 62268d19aa37c42ec0687476380467d523cd3a78 Mon Sep 17 00:00:00 2001 From: comlibmb <1844410276@qq.com> Date: Mon, 4 May 2026 14:12:45 +0800 Subject: [PATCH 3/4] feat: load Supabase credentials from environment variables - Add dotenv to load .env file - Import dotenv/config in entry point - Supabase URL/key no longer hardcoded in source Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 13 +++++++++++++ package.json | 1 + src/index.ts | 1 + 3 files changed, 15 insertions(+) diff --git a/package-lock.json b/package-lock.json index 7c8f26b..abf1763 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "chokidar": "^3.6.0", "commander": "^14.0.2", "cross-spawn": "^7.0.6", + "dotenv": "^17.4.2", "inquirer": "^12.11.1", "keytar": "^7.9.0", "node-machine-id": "^1.1.12", @@ -2947,6 +2948,18 @@ "node": ">=0.3.1" } }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", diff --git a/package.json b/package.json index 22763b5..aee6bbd 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "chokidar": "^3.6.0", "commander": "^14.0.2", "cross-spawn": "^7.0.6", + "dotenv": "^17.4.2", "inquirer": "^12.11.1", "keytar": "^7.9.0", "node-machine-id": "^1.1.12", diff --git a/src/index.ts b/src/index.ts index f5ae1a2..39ced7a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node +import 'dotenv/config'; import { Command } from 'commander'; import chalk from 'chalk'; import qrcode from 'qrcode-terminal'; From 2dd2261fa1f62f686185aa40b680d67641866e50 Mon Sep 17 00:00:00 2001 From: comlibmb <1844410276@qq.com> Date: Wed, 6 May 2026 01:01:44 +0800 Subject: [PATCH 4/4] fix: extend session TTL to 30 minutes on network interruption Users who put phone away for 10-15 minutes should not lose taken-over sessions. Previous 5-minute TTL was too short for real-world usage. Co-Authored-By: Claude Opus 4.7 --- src/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 39ced7a..648daa1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1322,9 +1322,9 @@ async function startConnection(): Promise { claudeProcessManager.cleanupAllPermissionState(); claudeProcessManager.clearAllTakenOver(); } else { - // Network interruption — keep sessions for 5 min in case mobile reconnects - console.log(chalk.dim('Network interruption — keeping sessions for 5 min')); - claudeProcessManager.startSessionTTL(5 * 60 * 1000); + // Network interruption — keep sessions for 30 min in case mobile reconnects + console.log(chalk.dim('Network interruption — keeping sessions for 30 min')); + claudeProcessManager.startSessionTTL(30 * 60 * 1000); } console.log(chalk.dim('Waiting for mobile to reconnect...'));