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>
This commit is contained in:
s546126 2026-06-29 13:41:13 -07:00 committed by GitHub
parent 004b4d2991
commit 3f39d7548b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 377 additions and 59 deletions

View File

@ -0,0 +1,82 @@
import { mkdtempSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { generateKeyPair, publicKeyToBase64 } from '../../shared/e2ee-crypto'
import { encodePairingOffer, type PairingOffer } from '../../shared/pairing'
import { addEnvironmentFromPairingCode } from '../../shared/runtime-environment-store'
import {
callRuntimeEnvironment,
getRuntimeEnvironmentStatus,
subscribeRuntimeEnvironment
} from './runtime-environment-transport-routing'
// Why: prove the wiring, not just the helper — an unreachable endpoint exercises
// the real WebSocket failure → reject → Tailscale-hint join points the settings
// probe (returned ok:false) and in-use calls (thrown) actually use.
let userDataPath: string
function seedEnvironment(name: string, endpoint: string): string {
// A valid Curve25519 public key lets the client reach the socket-connect step
// (and fail there) instead of bailing out early on key parsing.
const keyPair = generateKeyPair()
const offer: PairingOffer = {
v: 2,
endpoint,
deviceToken: 'a'.repeat(48),
publicKeyB64: publicKeyToBase64(keyPair.publicKey)
}
const environment = addEnvironmentFromPairingCode(userDataPath, {
name,
pairingCode: encodePairingOffer(offer)
})
return environment.id
}
beforeEach(() => {
userDataPath = mkdtempSync(join(tmpdir(), 'orca-tailscale-hint-'))
})
afterEach(() => {
rmSync(userDataPath, { recursive: true, force: true })
})
describe('Tailscale hint on remote runtime connection failure', () => {
it('recommends Tailscale on the settings status probe for a non-tailnet endpoint', async () => {
const id = seedEnvironment('lan-host', 'ws://127.0.0.1:9')
const response = await getRuntimeEnvironmentStatus(userDataPath, id, 1000)
expect(response.ok).toBe(false)
if (response.ok === false) {
expect(response.error.message).toContain('connect both devices to Tailscale')
expect(response.error.message).toContain('https://tailscale.com/download')
}
})
it('gives tailnet-specific guidance on the status probe for a Tailscale endpoint', async () => {
const id = seedEnvironment('ts-host', 'ws://100.64.0.1:9')
const response = await getRuntimeEnvironmentStatus(userDataPath, id, 800)
expect(response.ok).toBe(false)
if (response.ok === false) {
expect(response.error.message).toContain('Funnel reverted to tailnet-only')
expect(response.error.message).not.toContain('https://tailscale.com/download')
}
})
it('augments the thrown error for in-use calls (the toast path)', async () => {
const id = seedEnvironment('lan-host', 'ws://127.0.0.1:9')
await expect(callRuntimeEnvironment(userDataPath, id, 'files.read', {}, 1000)).rejects.toThrow(
/connect both devices to Tailscale/
)
})
it('augments a subscription that fails to connect initially', async () => {
const id = seedEnvironment('lan-host', 'ws://127.0.0.1:9')
await expect(
subscribeRuntimeEnvironment(userDataPath, id, 'files.watch', {}, 1000, {
onEvent: () => {},
onClose: () => {}
})
).rejects.toThrow(/connect both devices to Tailscale/)
})
})

View File

@ -11,6 +11,7 @@ import {
subscribeRemoteRuntimeRequest,
type RemoteRuntimeSubscription
} from '../../shared/remote-runtime-client'
import { withRemoteRuntimeTailscaleHint } from '../../shared/remote-runtime-tailscale-hint'
import { enqueueRuntimeCall } from './runtime-environment-call-queue'
import {
sendRemoteRuntimeConnectionRequest,
@ -30,16 +31,35 @@ export function clearSharedControlSupport(environmentId: string): void {
sharedControlSupport.delete(environmentId)
}
// Why: when a remote host is unreachable, point the user at Tailscale as the
// connectivity remedy; the helper no-ops on non-connectivity errors.
function withTailscaleHintForResponse<TResult>(
response: RuntimeRpcResponse<TResult>,
endpoint: string
): RuntimeRpcResponse<TResult> {
if (response.ok === true) {
return response
}
return {
...response,
error: {
...response.error,
message: withRemoteRuntimeTailscaleHint(response.error.message, endpoint)
}
}
}
export async function getRuntimeEnvironmentStatus(
userDataPath: string,
selector: string,
timeoutMs?: number
): Promise<RuntimeRpcResponse<RuntimeStatus>> {
const environment = resolveEnvironment(userDataPath, selector)
const pairing = getPreferredPairingOffer(environment)
let response: RuntimeRpcResponse<RuntimeStatus>
try {
response = await sendRemoteRuntimeRequest<RuntimeStatus>(
getPreferredPairingOffer(environment),
pairing,
'status.get',
undefined,
timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS
@ -48,22 +68,28 @@ export async function getRuntimeEnvironmentStatus(
// Why: the status UI needs shared-control diagnostics most when the
// fresh status probe failed and the host is reconnecting/offline.
return attachRemoteControlDiagnostics(
{
id: 'status.get',
ok: false,
error: {
code: 'runtime_unavailable',
message: error instanceof Error ? error.message : String(error)
withTailscaleHintForResponse(
{
id: 'status.get',
ok: false,
error: {
code: 'runtime_unavailable',
message: error instanceof Error ? error.message : String(error)
},
_meta: { runtimeId: environment.runtimeId }
},
_meta: { runtimeId: environment.runtimeId }
},
pairing.endpoint
),
environment.id
)
}
if (response.ok === true) {
markEnvironmentUsed(userDataPath, environment.id, { runtimeId: response._meta.runtimeId })
}
return attachRemoteControlDiagnostics(response, environment.id)
return attachRemoteControlDiagnostics(
withTailscaleHintForResponse(response, pairing.endpoint),
environment.id
)
}
export async function callRuntimeEnvironment(
@ -74,41 +100,55 @@ export async function callRuntimeEnvironment(
timeoutMs?: number
): Promise<RuntimeRpcResponse<unknown>> {
const environment = resolveEnvironment(userDataPath, selector)
return enqueueRuntimeCall(environment.id, method, async () => {
const currentEnvironment = resolveEnvironment(userDataPath, environment.id)
const pairing = getPreferredPairingOffer(currentEnvironment)
const effectiveTimeoutMs = timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS
if (shouldUseCachedRequestConnection(method)) {
const response = await sendRemoteRuntimeConnectionRequest(
currentEnvironment.id,
pairing,
method,
params,
effectiveTimeoutMs
)
// Why: connection failures reject (they don't resolve as ok:false), so the
// Tailscale hint is applied to the thrown error here — wrapping the resolved
// value would miss the in-use connect/timeout case the toast surfaces.
// Track the endpoint the queued closure actually used: it re-resolves the
// environment, so a re-pair between enqueue and dispatch can change it.
let endpoint = getPreferredPairingOffer(environment).endpoint
try {
return await enqueueRuntimeCall(environment.id, method, async () => {
const currentEnvironment = resolveEnvironment(userDataPath, environment.id)
const pairing = getPreferredPairingOffer(currentEnvironment)
endpoint = pairing.endpoint
const effectiveTimeoutMs = timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS
if (shouldUseCachedRequestConnection(method)) {
const response = await sendRemoteRuntimeConnectionRequest(
currentEnvironment.id,
pairing,
method,
params,
effectiveTimeoutMs
)
markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response)
return response
}
if (
method !== 'status.get' &&
(await supportsSharedControl(userDataPath, currentEnvironment, pairing, effectiveTimeoutMs))
) {
const response = await sendRemoteRuntimeSharedControlRequest(
currentEnvironment.id,
pairing,
method,
params,
effectiveTimeoutMs
)
markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response)
return response
}
// Why: startup/control-plane RPCs use the proven one-shot path so repo
// hydration cannot be coupled to a stale terminal-control connection.
const response = await sendRemoteRuntimeRequest(pairing, method, params, effectiveTimeoutMs)
markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response)
return response
})
} catch (error) {
if (error instanceof Error) {
error.message = withRemoteRuntimeTailscaleHint(error.message, endpoint)
}
if (
method !== 'status.get' &&
(await supportsSharedControl(userDataPath, currentEnvironment, pairing, effectiveTimeoutMs))
) {
const response = await sendRemoteRuntimeSharedControlRequest(
currentEnvironment.id,
pairing,
method,
params,
effectiveTimeoutMs
)
markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response)
return response
}
// Why: startup/control-plane RPCs use the proven one-shot path so repo
// hydration cannot be coupled to a stale terminal-control connection.
const response = await sendRemoteRuntimeRequest(pairing, method, params, effectiveTimeoutMs)
markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response)
return response
})
throw error
}
}
export async function subscribeRuntimeEnvironment(
@ -149,33 +189,46 @@ export async function subscribeRuntimeEnvironment(
onBinary: (bytes: Uint8Array<ArrayBufferLike>) =>
callbacks.onEvent({ type: 'binary' as const, bytes }),
onError: (error: { code: string; message: string }) =>
callbacks.onEvent({ type: 'error' as const, code: error.code, message: error.message }),
callbacks.onEvent({
type: 'error' as const,
code: error.code,
message: withRemoteRuntimeTailscaleHint(error.message, pairing.endpoint)
}),
onClose: () => {
callbacks.onEvent({ type: 'close' as const })
callbacks.onClose()
}
}
if (
shouldUseSharedControlSubscription(method) &&
!shouldKeepDedicatedSubscriptionSocket(method) &&
(await supportsSharedControl(userDataPath, environment, pairing, effectiveTimeoutMs))
) {
return await subscribeRemoteRuntimeSharedControlRequest(
environment.id,
// Why: an initial-connect failure rejects (mid-stream drops go through
// onError above), so the hint is applied to the thrown error here too.
try {
if (
shouldUseSharedControlSubscription(method) &&
!shouldKeepDedicatedSubscriptionSocket(method) &&
(await supportsSharedControl(userDataPath, environment, pairing, effectiveTimeoutMs))
) {
return await subscribeRemoteRuntimeSharedControlRequest(
environment.id,
pairing,
method,
params,
effectiveTimeoutMs,
callbacksWithMarkUsed
)
}
return await subscribeRemoteRuntimeRequest(
pairing,
method,
params,
effectiveTimeoutMs,
callbacksWithMarkUsed
)
} catch (error) {
if (error instanceof Error) {
error.message = withRemoteRuntimeTailscaleHint(error.message, pairing.endpoint)
}
throw error
}
return await subscribeRemoteRuntimeRequest(
pairing,
method,
params,
effectiveTimeoutMs,
callbacksWithMarkUsed
)
}
function markEnvironmentUsedFromResponse(

View File

@ -4,6 +4,7 @@
import type { RuntimeRpcResponse, RuntimeRpcSuccess } from '../../../shared/runtime-rpc-envelope'
import { isKeepaliveFrame } from '../../../shared/runtime-rpc-envelope'
import type { WebPairingOffer } from './web-pairing'
import { withRemoteRuntimeTailscaleHint } from '../../../shared/remote-runtime-tailscale-hint'
import {
decrypt,
decryptBytes,
@ -407,7 +408,14 @@ export class WebRuntimeClient {
ws.onclose = () => this.handleSocketClosed(ws)
ws.onerror = () => {
if (this.state === 'connecting') {
this.rejectAllWaiters(new Error('Could not connect to the remote Orca runtime.'))
this.rejectAllWaiters(
new Error(
withRemoteRuntimeTailscaleHint(
'Could not connect to the remote Orca runtime.',
this.pairing.endpoint
)
)
)
}
}
}
@ -555,7 +563,14 @@ export class WebRuntimeClient {
if (index !== -1) {
this.waiters.splice(index, 1)
}
reject(new Error('Timed out while connecting to the remote Orca runtime.'))
reject(
new Error(
withRemoteRuntimeTailscaleHint(
'Timed out while connecting to the remote Orca runtime.',
this.pairing.endpoint
)
)
)
}, timeoutMs)
this.waiters.push({
resolve: () => {

View File

@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest'
import {
isTailscaleEndpoint,
withRemoteRuntimeTailscaleHint
} from './remote-runtime-tailscale-hint'
const UNREACHABLE = 'Could not connect to the remote Orca runtime.'
describe('isTailscaleEndpoint', () => {
it('matches MagicDNS hostnames', () => {
expect(isTailscaleEndpoint('wss://example-host.tailnet.ts.net')).toBe(true)
expect(isTailscaleEndpoint('ws://host.ts.net:6768')).toBe(true)
})
it('matches the 100.64.0.0/10 CGNAT range', () => {
expect(isTailscaleEndpoint('ws://100.64.0.5:6768')).toBe(true)
expect(isTailscaleEndpoint('ws://100.127.255.255:6768')).toBe(true)
})
it('matches Tailscale IPv6 (fd7a:115c:a1e0::/48) literals', () => {
// Pairing endpoints can carry a bracketed IPv6 literal (resolvePairingEndpoint).
expect(isTailscaleEndpoint('wss://[fd7a:115c:a1e0::1]:443')).toBe(true)
expect(isTailscaleEndpoint('ws://[fd7a:115c:a1e0:ab12:4843:cd96:626b:1]:6768')).toBe(true)
expect(isTailscaleEndpoint('ws://[2001:db8::1]:6768')).toBe(false)
expect(isTailscaleEndpoint('ws://[::1]:6768')).toBe(false)
})
it('rejects non-Tailscale hosts and the surrounding 100.x space', () => {
expect(isTailscaleEndpoint('ws://192.168.1.10:6768')).toBe(false)
expect(isTailscaleEndpoint('wss://orca.example.com')).toBe(false)
expect(isTailscaleEndpoint('ws://100.63.0.1:6768')).toBe(false)
expect(isTailscaleEndpoint('ws://100.128.0.1:6768')).toBe(false)
expect(isTailscaleEndpoint('ws://notts.net.evil.com')).toBe(false)
// A DNS name that merely starts with a CGNAT-shaped label is not a TS IP.
expect(isTailscaleEndpoint('ws://100.64.0.1.example.com:6768')).toBe(false)
})
it('handles bare hosts without a scheme and empty input', () => {
expect(isTailscaleEndpoint('host.ts.net')).toBe(true)
// A trailing-dot FQDN is still the same tailnet host.
expect(isTailscaleEndpoint('wss://host.ts.net.')).toBe(true)
expect(isTailscaleEndpoint('')).toBe(false)
expect(isTailscaleEndpoint(null)).toBe(false)
expect(isTailscaleEndpoint(undefined)).toBe(false)
})
})
describe('withRemoteRuntimeTailscaleHint', () => {
it('recommends switching to Tailscale when the endpoint is not on a tailnet', () => {
const result = withRemoteRuntimeTailscaleHint(UNREACHABLE, 'ws://192.168.1.10:6768')
expect(result).toContain(UNREACHABLE)
expect(result).toContain('connect both devices to Tailscale')
expect(result).toContain('https://tailscale.com/download')
})
it('points at tailnet-specific causes when the endpoint is already Tailscale', () => {
const result = withRemoteRuntimeTailscaleHint(UNREACHABLE, 'wss://example-host.tailnet.ts.net')
expect(result).toContain('Funnel reverted to tailnet-only')
expect(result).toContain('already-paired devices reconnect with their saved token')
expect(result).not.toContain('https://tailscale.com/download')
})
it('covers the close and timeout failure variants', () => {
expect(
withRemoteRuntimeTailscaleHint(
'Remote Orca runtime closed the connection.',
'ws://192.168.1.10:6768'
)
).toContain('connect both devices to Tailscale')
expect(
withRemoteRuntimeTailscaleHint(
'Timed out while connecting to the remote Orca runtime.',
'wss://host.ts.net'
)
).toContain('Funnel reverted to tailnet-only')
})
it('leaves non-connectivity errors untouched', () => {
const auth = 'Remote Orca runtime rejected the pairing token.'
expect(withRemoteRuntimeTailscaleHint(auth, 'ws://192.168.1.10:6768')).toBe(auth)
})
it('is idempotent — does not append the hint twice', () => {
const once = withRemoteRuntimeTailscaleHint(UNREACHABLE, 'ws://192.168.1.10:6768')
const twice = withRemoteRuntimeTailscaleHint(once, 'ws://192.168.1.10:6768')
expect(twice).toBe(once)
})
})

View File

@ -0,0 +1,80 @@
/**
* 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 64127).
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}.`
}