fix: verify tunnel is alive before returning URL to pair-agent

Root cause: when ngrok dies externally (pkill, crash, timeout), the server
still reports tunnelActive=true with a dead URL. pair-agent prints an
instruction block pointing at a dead tunnel. The remote agent gets
"endpoint offline" and the user has to manually restart everything.

Three-layer fix:
- Server /pair endpoint: probes tunnel URL before returning it. If dead,
  resets tunnelActive/tunnelUrl and returns null (triggers CLI restart).
- Server /tunnel/start: probes cached tunnel before returning already_active.
  If dead, falls through to restart ngrok automatically.
- CLI pair-agent: double-checks tunnel URL from server before printing
  instruction block. Falls through to auto-start on failure.

4 regression tests verify all three probe points + CLI verification.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-04-05 22:58:37 -07:00
parent 35bc7e34b1
commit 7f25d4786b
3 changed files with 105 additions and 5 deletions
+19
View File
@@ -622,6 +622,25 @@ async function handlePairAgent(state: ServerState, args: string[]): Promise<void
// Determine the URL to use
let serverUrl: string;
if (pairData.tunnel_url) {
// Server already verified the tunnel is alive, but double-check from CLI side
// in case of race condition between server probe and our request
try {
const cliProbe = await fetch(`${pairData.tunnel_url}/health`, {
headers: { 'ngrok-skip-browser-warning': 'true' },
signal: AbortSignal.timeout(5000),
});
if (cliProbe.ok) {
serverUrl = pairData.tunnel_url;
} else {
console.warn(`[browse] Tunnel returned HTTP ${cliProbe.status}, attempting restart...`);
pairData.tunnel_url = null; // fall through to restart logic
}
} catch {
console.warn('[browse] Tunnel unreachable from CLI, attempting restart...');
pairData.tunnel_url = null; // fall through to restart logic
}
}
if (pairData.tunnel_url) {
serverUrl = pairData.tunnel_url;
} else if (!localHost) {
+42 -5
View File
@@ -1445,11 +1445,34 @@ async function start() {
domains: pairBody.domains,
rateLimit: pairBody.rateLimit,
});
// Verify tunnel is actually alive before reporting it (ngrok may have died externally)
let verifiedTunnelUrl: string | null = null;
if (tunnelActive && tunnelUrl) {
try {
const probe = await fetch(`${tunnelUrl}/health`, {
headers: { 'ngrok-skip-browser-warning': 'true' },
signal: AbortSignal.timeout(5000),
});
if (probe.ok) {
verifiedTunnelUrl = tunnelUrl;
} else {
console.warn(`[browse] Tunnel probe failed (HTTP ${probe.status}), marking tunnel as dead`);
tunnelActive = false;
tunnelUrl = null;
tunnelListener = null;
}
} catch {
console.warn('[browse] Tunnel probe timed out or unreachable, marking tunnel as dead');
tunnelActive = false;
tunnelUrl = null;
tunnelListener = null;
}
}
return new Response(JSON.stringify({
setup_key: setupKey.token,
expires_at: setupKey.expiresAt,
scopes: setupKey.scopes,
tunnel_url: tunnelActive ? tunnelUrl : null,
tunnel_url: verifiedTunnelUrl,
server_url: `http://127.0.0.1:${server?.port || 0}`,
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
} catch {
@@ -1466,10 +1489,24 @@ async function start() {
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' },
});
if (tunnelActive && tunnelUrl) {
// Verify tunnel is still alive before returning cached URL
try {
const probe = await fetch(`${tunnelUrl}/health`, {
headers: { 'ngrok-skip-browser-warning': 'true' },
signal: AbortSignal.timeout(5000),
});
if (probe.ok) {
return new Response(JSON.stringify({ url: tunnelUrl, already_active: true }), {
status: 200, headers: { 'Content-Type': 'application/json' },
});
}
} catch {}
// Tunnel is dead, reset and fall through to restart
console.warn('[browse] Cached tunnel is dead, restarting...');
tunnelActive = false;
tunnelUrl = null;
tunnelListener = null;
}
try {
// Read ngrok authtoken: env var > ~/.gstack/ngrok.env > ngrok native config