diff --git a/browse/src/cli.ts b/browse/src/cli.ts index 4d1ff86d..09593344 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -617,72 +617,26 @@ async function handlePairAgent(state: ServerState, args: string[]): Promise null); - // Wait for server to come back, then restart with tunnel - await Bun.sleep(1000); - } catch {} - // Restart the server process with BROWSE_TUNNEL=1 - console.log('[browse] Restarting server with tunnel...'); - const serverScript = resolveServerScript(); - const proc = Bun.spawn(['bun', 'run', serverScript], { - stdio: ['ignore', 'pipe', 'pipe'], - env: { ...process.env, BROWSE_STATE_FILE: config.stateFile, BROWSE_TUNNEL: '1' }, - }); - proc.unref(); - // Wait for server to come back with tunnel - const deadline = Date.now() + 15000; - let tunnelUrl: string | null = null; - while (Date.now() < deadline) { - await Bun.sleep(500); - const newState = readState(); - if (newState && await isServerHealthy(newState.port)) { - try { - const healthResp = await fetch(`http://127.0.0.1:${newState.port}/health`, { - signal: AbortSignal.timeout(2000), - }); - const health = await healthResp.json() as any; - if (health.tunnel?.url) { - tunnelUrl = health.tunnel.url; - // Update state for the rest of the function - state.port = newState.port; - state.token = newState.token; - break; - } - } catch {} - } - } - if (tunnelUrl) { - console.log(`[browse] Tunnel active: ${tunnelUrl}\n`); - serverUrl = tunnelUrl; - // Re-create setup key with the new server (old one used old root token) - const newPairResp = await fetch(`http://127.0.0.1:${state.port}/pair`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${state.token}`, - }, - body: JSON.stringify({ clientId: clientName, admin }), - signal: AbortSignal.timeout(5000), + headers: { 'Authorization': `Bearer ${state.token}` }, + signal: AbortSignal.timeout(15000), }); - if (newPairResp.ok) { - const newData = await newPairResp.json() as typeof pairData; - pairData.setup_key = newData.setup_key; - pairData.expires_at = newData.expires_at; - pairData.scopes = newData.scopes; + const tunnelData = await tunnelResp.json() as any; + if (tunnelResp.ok && tunnelData.url) { + console.log(`[browse] Tunnel active: ${tunnelData.url}\n`); + serverUrl = tunnelData.url; + } else { + console.warn(`[browse] Tunnel failed: ${tunnelData.error || 'unknown error'}`); + if (tunnelData.hint) console.warn(`[browse] ${tunnelData.hint}`); + console.warn('[browse] Using localhost (same-machine only).\n'); + serverUrl = pairData.server_url; } - } else { - console.warn('[browse] Failed to start tunnel. Using localhost (same-machine only).\n'); + } catch (err: any) { + console.warn(`[browse] Tunnel failed: ${err.message}`); + console.warn('[browse] Using localhost (same-machine only).\n'); serverUrl = pairData.server_url; } } else { diff --git a/browse/src/server.ts b/browse/src/server.ts index cb2688f6..097326ed 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -1374,6 +1374,77 @@ async function start() { } } + // ─── /tunnel/start — start ngrok tunnel on demand (root-only) ── + if (url.pathname === '/tunnel/start' && req.method === 'POST') { + if (!isRootRequest(req)) { + return new Response(JSON.stringify({ error: 'Root token required' }), { + status: 403, headers: { 'Content-Type': 'application/json' }, + }); + } + if (tunnelActive) { + return new Response(JSON.stringify({ url: tunnelUrl, already_active: true }), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }); + } + try { + // Read ngrok authtoken: env var > ~/.gstack/ngrok.env > ngrok native config + let authtoken = process.env.NGROK_AUTHTOKEN; + if (!authtoken) { + const ngrokEnvPath = path.join(process.env.HOME || '', '.gstack', 'ngrok.env'); + if (fs.existsSync(ngrokEnvPath)) { + const envContent = fs.readFileSync(ngrokEnvPath, 'utf-8'); + const match = envContent.match(/^NGROK_AUTHTOKEN=(.+)$/m); + if (match) authtoken = match[1].trim(); + } + } + if (!authtoken) { + // Check ngrok's native config files + const ngrokConfigs = [ + path.join(process.env.HOME || '', 'Library', 'Application Support', 'ngrok', 'ngrok.yml'), + path.join(process.env.HOME || '', '.config', 'ngrok', 'ngrok.yml'), + path.join(process.env.HOME || '', '.ngrok2', 'ngrok.yml'), + ]; + for (const conf of ngrokConfigs) { + try { + const content = fs.readFileSync(conf, 'utf-8'); + const match = content.match(/authtoken:\s*(.+)/); + if (match) { authtoken = match[1].trim(); break; } + } catch {} + } + } + if (!authtoken) { + return new Response(JSON.stringify({ + error: 'No ngrok authtoken found', + hint: 'Run: ngrok config add-authtoken YOUR_TOKEN', + }), { status: 400, headers: { 'Content-Type': 'application/json' } }); + } + const ngrok = await import('@ngrok/ngrok'); + const domain = process.env.NGROK_DOMAIN; + const forwardOpts: any = { addr: server!.port, authtoken }; + if (domain) forwardOpts.domain = domain; + + tunnelListener = await ngrok.forward(forwardOpts); + tunnelUrl = tunnelListener.url(); + tunnelActive = true; + console.log(`[browse] Tunnel started on demand: ${tunnelUrl}`); + + // Update state file + const stateContent = JSON.parse(fs.readFileSync(config.stateFile, 'utf-8')); + stateContent.tunnel = { url: tunnelUrl, domain: domain || null, startedAt: new Date().toISOString() }; + const tmpState = config.stateFile + '.tmp'; + fs.writeFileSync(tmpState, JSON.stringify(stateContent, null, 2), { mode: 0o600 }); + fs.renameSync(tmpState, config.stateFile); + + return new Response(JSON.stringify({ url: tunnelUrl }), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }); + } catch (err: any) { + return new Response(JSON.stringify({ + error: `Failed to start tunnel: ${err.message}`, + }), { status: 500, headers: { 'Content-Type': 'application/json' } }); + } + } + // Refs endpoint — auth required, does NOT reset idle timer if (url.pathname === '/refs') { if (!validateAuth(req)) {