Files
orca/src/shared/remote-runtime-tailscale-hint.ts
T
3f39d7548b Recommend Tailscale when the remote Orca runtime is unreachable (#6637)
* Recommend Tailscale when the remote Orca runtime is unreachable

When a remote-runtime connection fails (RemoteRuntimeClientError "Could not
connect to the remote Orca runtime."), append an actionable Tailscale hint to
the user-facing error, branched on whether the endpoint is already on a tailnet:

- Non-Tailscale endpoint: recommend connecting both devices over Tailscale and
  pairing with its Tailscale address, with a download link.
- Tailscale endpoint (*.ts.net or 100.64.0.0/10): point at the real causes —
  server offline on the tailnet, or Funnel reverted to tailnet-only — and note
  that already-paired devices reconnect without re-pairing.

Applied at the desktop transport chokepoint (status probe, in-use calls, and
subscriptions — connection failures reject, so the hint is applied to the thrown
error, not just ok:false responses) and at the web client's connect/timeout
sites. New pure shared helper mirrors withMacTailscaleDnsHint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Address review: scope CGNAT hint to IPv4 literals, track re-paired endpoint

- isTailscaleEndpoint: gate the 100.64.0.0/10 check on a full IPv4 literal so
  DNS names like 100.64.0.1.example.com no longer get tailnet-specific advice.
- callRuntimeEnvironment: capture the endpoint the queued closure actually used,
  so a re-pair between enqueue and dispatch can't append the wrong hint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Recognize Tailscale IPv6 endpoints and trailing-dot FQDNs in hint

The remote-runtime Tailscale hint classified IPv6 Tailscale nodes
(fd7a:115c:a1e0::/48) and trailing-dot FQDNs as non-Tailscale, so a
user already reaching their server over Tailscale by IPv6 literal was
wrongly told to 'connect both devices to Tailscale'. Pairing endpoints
can carry bracketed IPv6 literals (resolvePairingEndpoint), so this is
a reachable path. Normalize the extracted host (strip brackets and the
trailing FQDN dot) and add an IPv6 ULA-range check.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: s546126 <268420947+s546126@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
2026-06-29 13:41:13 -07:00

81 lines
3.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Appends an actionable Tailscale recommendation to remote-runtime connection
* failures, mirroring `withMacTailscaleDnsHint`. Lives in `shared` as a pure,
* dependency-free function so both the main process (desktop transport) and the
* renderer (web client) can route their user-facing errors through it without
* leaking presentation copy into the shared error constructors (which the CLI,
* logs, and mobile typecheck also consume).
*/
const TAILSCALE_DOWNLOAD_URL = 'https://tailscale.com/download'
// Why: only the "runtime is unreachable" family of failures has a Tailscale
// remedy; auth/protocol errors pass through untouched.
const REMOTE_RUNTIME_UNREACHABLE_RE =
/could not connect to the remote orca runtime|remote orca runtime closed the connection|timed out (?:waiting for|while connecting to) the remote orca runtime/i
const TAILSCALE_MAGIC_DNS_SUFFIX_RE = /(?:^|\.)ts\.net$/i
// Why: gate the CGNAT check on a full IPv4 literal — the range regex alone also
// matches DNS names like `100.64.0.1.example.com`, which aren't Tailscale IPs.
const IPV4_LITERAL_RE =
/^(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/
// Tailscale assigns node IPs from the 100.64.0.0/10 CGNAT range (second octet 64–127).
const TAILSCALE_CGNAT_RE = /^100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./
// Tailscale also assigns each node an IPv6 address from the fd7a:115c:a1e0::/48 ULA
// block, and pairing endpoints can carry an IPv6 literal (see resolvePairingEndpoint).
const TAILSCALE_IPV6_RE = /^fd7a:115c:a1e0:/i
function extractHost(endpoint: string): string | null {
let host: string | null
try {
host = new URL(endpoint).hostname || null
} catch {
// Why: a bare host (no scheme) isn't a valid URL; strip any scheme and take
// the authority up to the first port/path/query delimiter.
host = endpoint.replace(/^[a-z]+:\/\//i, '').split(/[/:?#]/, 1)[0] || null
}
if (!host) {
return null
}
// Why: WHATWG URL keeps IPv6 literals bracketed (`[fd7a:…]`) and FQDNs can carry
// a trailing dot; normalize both so the host checks below see a bare address/name.
return host.replace(/^\[|\]$/g, '').replace(/\.$/, '') || null
}
export function isTailscaleEndpoint(endpoint: string | null | undefined): boolean {
if (!endpoint) {
return false
}
const host = extractHost(endpoint)
if (!host) {
return false
}
return (
TAILSCALE_MAGIC_DNS_SUFFIX_RE.test(host) ||
(IPV4_LITERAL_RE.test(host) && TAILSCALE_CGNAT_RE.test(host)) ||
TAILSCALE_IPV6_RE.test(host)
)
}
export function withRemoteRuntimeTailscaleHint(
message: string,
endpoint: string | null | undefined
): string {
if (!REMOTE_RUNTIME_UNREACHABLE_RE.test(message)) {
return message
}
// Why: keep the hint idempotent so a message routed through this helper twice
// (e.g. re-wrapped error response) isn't suffixed with duplicate guidance.
if (/tailscale/i.test(message)) {
return message
}
if (isTailscaleEndpoint(endpoint)) {
// Why: a server already reached over Tailscale fails for tailnet-specific
// reasons, so "use Tailscale" would be useless — point at the real causes.
// Already-paired devices keep their saved token across server restarts, so
// re-pairing only matters when adding a new device.
return `${message} The server may be offline on your tailnet, or its Tailscale Funnel reverted to tailnet-only. Confirm it's reachable; re-pair only when adding a new device, since already-paired devices reconnect with their saved token.`
}
return `${message} If the server is on another network, connect both devices to Tailscale and pair using its Tailscale address (100.x or a *.ts.net name). See ${TAILSCALE_DOWNLOAD_URL}.`
}