Gini exposes a remote URL for an instance through a tunnel provider, selected and managed by the user through a uniform RPC contract. The gateway owns a small persisted singleton (the user's provider selection + connection status) and rebuilds the provider catalog from code on every read. Every tunnel route returns the full TunnelState so a single fetch drives the whole selection / connect / connected UI without follow-up requests.
The catalog carries four drivable providers. gini-relay is always enabled; tailscale, ngrok, and cloudflare are detection-gated: a driver probe (tailscale backend running with a MagicDNS name, ngrok config check, cloudflared --version) flips each row enabled, and a missing prerequisite surfaces as requires. A select/connect of an unavailable provider is rejected with HTTP 400 carrying the machine-readable code: "provider_unavailable" — Connect is the single UI affordance, and on that code the web UI opens the provider's self-contained guide (docs/remote-access/<id>.md, served inline through the gateway's docs endpoint) instead of dead-ending on the error. Detection refreshes at boot, on GET /api/tunnel?detect=1 (panel open / CLI status, bypassing the cache TTL), and lazily before rejecting a select/connect — a freshly-installed CLI connects on the next attempt without any restart; plain polling GETs never spawn probes. The gini-relay connect flow is wired through the gini-relay client package: connectTunnel flips status to connecting and returns immediately, then a background handshake mints an OAuth-loopback consent URL (loginUrl), opens it in the host browser, awaits the session, builds + starts a native frpc tunnel (buildTunnel) that exposes the instance's gateway port (the single origin fronting UI + API; see Exposed port), and records the public https://<subdomain>.<relayDomain> url. The UI/CLI polls GET /api/tunnel until status flips to connected (with url) or error (with message).
An instance runs on the user's machine bound to loopback. To reach it from a phone or a remote device, the gateway needs a publicly reachable URL fronted by a tunnel. Users pick among several tunnel providers (the hosted Gini Relay, their own Tailscale network, ngrok, Cloudflare); the hosted relay shipped first, and the Tailscale/ngrok/Cloudflare drivers followed as detection-gated catalog rows. The UI needs the full provider catalog up front — including the disabled ones and why they're disabled — so it can render the selection panel in one pass, and it needs the live connection status to decide which view to show.
The tunnel surface is self-serve remote access, so a managed (platform-hosted) deployment — whose ingress an edge proxy terminates — hides the web's tunnel menu entirely (see ADR managed-deployment-mode.md); the backend contract below is unchanged.
Tunnel connectivity follows the established capability pattern: an opt-in singleton on RuntimeState (state.tunnel), a behavior module exposing thin functions, HTTP routes that delegate to it, and a CLI shim that goes through the gateway. The browser-connect capability carries a parallel state.browser singleton — null by default (the runtime drives its own spawned Chrome), non-null only when the user attaches their own external Chrome over CDP (issue #420 removed the third, managed/visible-window, mode).
- The tunnel selection + status are instance-level transport state, not per-agent — every agent in the instance shares the one tunnel. Audit rows for select/connect/cancel/disconnect are attributed
{ system: true }, matching relays and the browser connection. - The catalog is code, not state: it is never persisted and never accepted from a client. A client can only ask to select or connect a provider id; the gateway validates that id against the code-defined catalog and rejects unknown or disabled providers before any state mutation. This keeps a client from enabling a provider the runtime isn't ready to drive.
- The HTTP layer maps the user-input rejections (
Unknown tunnel provider…,No tunnel provider selected…,Tunnel provider … is not available…) to400so the panel surfaces the real reason rather than a generic 500. - Browser clients never receive the gateway bearer; the Next.js BFF injects it server-side (repo-wide rule). This ADR's backend is independent of any UI — the UI track wires to it in a later step.
Every route returns:
type TunnelProvider = {
id: "gini-relay" | "tailscale" | "ngrok" | "cloudflare";
name: string;
enabled: boolean;
requires?: string; // why an unavailable row can't connect yet
};
type TunnelState = {
providers: TunnelProvider[]; // catalog, drives the panel
selectedProvider: "gini-relay" | "tailscale" | "ngrok" | "cloudflare" | null;
status: "idle" | "connecting" | "connected" | "error";
url?: string; // present only when status === "connected"
message?: string; // present only when status === "error"
};View derivation from state:
selectedProvider === null& statusidle→ Selection panel.- status
idlewith a selection → Selection panel (Connect available on the selected provider). - status
connecting→ Selection panel with the selected provider's row showing "Connecting…" + Cancel (the relay's OAuth consent tab is open in the host browser, or a manual driver is bringing its tunnel up). - status
connected→ Connected popover (QR + url + disconnect + edit). - status
error→ error shown in the panel (message).
| id | name | enabled | requires (when disabled) | driver |
|---|---|---|---|---|
gini-relay |
Gini Relay | always | — | gini-relay client (OAuth + frpc) |
tailscale |
Tailscale | detected | Tailscale network |
tailscale serve (childless; lives in tailscaled; the boot resume re-publishes the same stable ts.net URL) |
ngrok |
ngrok | detected | ngrok account |
ngrok http <port> supervised child; URL scanned from agent output |
cloudflare |
Cloudflare | detected | cloudflared CLI |
named tunnel from ~/.cloudflared/config.yml (run with the gateway as origin, publishing the config's stable ingress hostname — SSE-capable) when one exists; quick-tunnel fallback otherwise. --config /dev/null in both modes (loaded ingress rules would override --url); supervised child |
Host-side install/auth steps are deliberately NOT part of the catalog: each provider's setup guidance lives in its self-contained doc (docs/remote-access/<id>.md), which clients open on provider_unavailable and the sidebar links per provider.
The manual connect flow mirrors the relay's supervision contract: connecting → background driver → connected (with the public url) or error; a supervised child's unexpected exit auto-reconnects (connected → connecting, rebuilding the tunnel — see Supervision), settling error only when the retry budget is exhausted or auto-reconnect is disabled; cancel/disconnect/supersede tear down the child or the provider-side state (tailscale serve config) — including on a provider switch, where selectProvider tears down the live manual tunnel before flipping to idle. Reconcile resumes a connected manual record after a restart when its prerequisite still detects (tailscale re-publishes the same URL; ngrok/cloudflared mint a fresh one).
Runtime-tunnel origin trust. Every tunnel-record write syncs a per-instance entry in packages/runtime/src/lib/origin-trust.ts (setRuntimeTunnelTrust): while a record is connected, its url's host is admitted by the gateway's web-bound guard exactly like a relay subdomain (the runtime established the front; the provider owns the DNS), and any transition away from connected revokes it atomically with the state write. Unsafe methods still require Origin==Host on the tunnel front. A trusted tunnel front is owner-equivalent — there is no per-device gate beyond the origin trust (see owner-token-auth.md) — so connect a tunnel only when everyone who can reach its URL is trusted.
A manually-run front (a tool the runtime isn't driving — reverse proxy, named Cloudflare tunnel, remote tailnet node) is still supported via a GINI_TRUSTED_ORIGINS entry, and is intentionally invisible to state.tunnel — the record describes only runtime-managed tunnels. The user-facing per-provider instructions and the verification matrix live in Remote Access.
RPC action style, matching /api/browser/* and /api/relays/*. Every route returns the full TunnelState.
GET /api/tunnel— state + providers catalog (one fetch drives the whole UI).POST /api/tunnel/select {provider}— save selection without connecting (status staysidle).POST /api/tunnel/connect {provider?}— begin connect; the optionalprovideroverrides the saved selection.POST /api/tunnel/cancel— abort a pending login → statusidle(keeps the selection).POST /api/tunnel/disconnect— tear down the tunnel, keepselectedProvider→ statusidle.
The singleton TunnelSelectionRecord lives at state.tunnel in the atomic JSON state.json (not jsc:serialize):
interface TunnelSelectionRecord {
instance: Instance;
selectedProvider: TunnelProviderId | null;
status: TunnelStatus;
url?: string;
subdomain?: string;
message?: string;
createdAt: string;
updatedAt: string;
}It is defaulted to null in createEmptyState and backfilled with state.tunnel ??= null in normalizeState, so legacy and hand-edited state files present a consistent shape. The catalog is rebuilt from code on every read, so adding a provider needs no state migration.
connectTunnel validates the target provider is enabled, flips the record to connecting, and returns immediately. A background handshake (runConnect) then drives the gini-relay client library.
Session reuse first. runConnect reuses an existing session from the credential store (store.readSession()) and goes straight to buildTunnel — gini-relay sessions don't expire, so one login serves indefinitely and a reconnect needs no browser and no re-login (a reconnect with a stored session settled to connected in 1 second in a live test). The OAuth steps below run only when there is no stored session, or when a reused session is rejected by the relay (e.g. it was revoked) and child.start() throws — in that case runConnect discards the stored session and falls back to a fresh login so connect self-heals. When a login is needed:
loginUrl({ store, relayUrl, loopbackPorts })binds a127.0.0.1callback server, asks the relay for a Google consent URL, and hands back{ url, waitForSession, cancel }. The URL is machine-bound — the auth code returns to this host's loopback, so it must be approved in a browser on this same machine (RFC 8252 loopback redirect + PKCE).- The consent URL is opened in the host browser via
Bun.spawn(["open", url])(theopenBrowserseam). waitForSession()resolves once the user approves: the relay exchanges the code and returns{ token, subdomain, account }, persisted by the store to the instance-scoped relay home (~/.gini/instances/<inst>/relay, viarelayHome(instance)) so concurrent instances never share a device/session or stomp each other's tunnel. The manual providers do NOT have that per-instance isolation: tailscale serve and a named Cloudflare tunnel are machine-global resources (one serve config, one hostname), so they support at most one connected instance per machine — the tailscale driver refuses to connect when the serve config already fronts a different local port, and the per-provider guides carry the caveat.buildTunnel({ session, deviceId, port, defaults })builds a supervised nativefrpcchild for the gateway port (see Exposed port), andchild.start()resolves when the proxy is actually up.- The record flips to
connectedwithurl: https://<subdomain>.<relayDomain>and thesubdomainpersisted.
On any failure — relay error, login rejection, frpc start failure — the record flips to error with the thrown message; url is cleared. If the connect was cancelled mid-flight (the supervisor was torn down by cancel/disconnect before the background flow settled), the flow logs tunnel.connect.aborted and leaves the cancel-written idle record intact rather than clobbering it with a spurious error.
The tunnel exposes the instance's gateway port (config.port). The gateway is the single origin that serves its native /api/* directly AND reverse-proxies the web app — UI, assets, the /api/runtime/* BFF namespace, and HMR — to the Next.js web child (see ADR gateway-web-reverse-proxy.md). Exposing the gateway therefore makes one relay URL serve both the API (e.g. a mobile client's direct /api/* calls) and the web UI. Resolution order (resolveLocalPort), most-authoritative first:
GINI_TUNNEL_PORTenv override (operator escape hatch / tests).- The gateway port (
config.port), which the CLI pins to the actually-bound port before launch.
Before advertising a public URL, a fresh, user-initiated connect verifies the web app is actually serving via the shared identity probe (isSupervisedWebChild → { service: "gini-web", instance } on /api/runtime/__healthz). Because that path is web-bound, the probe transits the gateway's reverse-proxy to the web child, so a green probe means the gateway is up AND the web child is reachable through it — a stale port or a down web child can't be published to a brand-new relay URL. A restart resume skips this gate (see Reconcile + resume on startup): it fronts the same already-published URL a remote client is actively watching, so it restores reachability immediately rather than waiting on the web child.
The gateway owns the host/origin trust decision for every web-bound request (loopback / relay-subdomain / GINI_TRUSTED_ORIGINS, fail-closed, plus a Sec-Fetch-Site check) and rewrites Host/Origin to loopback before proxying, so the inner web child is purely internal and needs no relay awareness. See ADR bff-trust-boundary.md.
Each instance owns at most one in-flight login + one running frpc child, tracked in a module-level supervisor registry in packages/runtime/src/integrations/tunnel.ts:
-
disconnectTunnelstops thefrpcchild (best-effort — a stop failure is swallowed) and resets toidle, keeping the selection. Its idle write skips if a newer connect claimed the instance during the (awaited) local logout, so it never clobbers a live reconnect. -
cancelTunnelaborts a pending login via the handle'scancel()(best-effort) and resets toidle.selectProviderlikewise tears down any live child before flipping toidle, so changing the selection never orphans a running tunnel. -
A fresh
connectTunneltears down any prior in-flight login / live child and claims a fresh supervisor entry synchronously, so two concurrent connects get distinct entries: the superseded run aborts (before opening the OAuth browser, and stopping any child it spawned) instead of double-spawning or clobbering the winner. -
A live child that exits on its own auto-reconnects. frp's own client loop already recovers a transient control-plane drop without exiting the process (the relay config sets
loginFailExit: false), so an exit that actually reaches the watcher is a real one (crash, OOM,SIGKILL, an unrecoverable relay rejection). Rather than dead-end aterror, the exit watcher (reconnectAfterExit) flipsconnected → connectingand rebuilds the tunnel up toGINI_TUNNEL_RECONNECT_MAX_ATTEMPTStimes (default 5) with capped exponential backoff (GINI_TUNNEL_RECONNECT_BASE_MSdefault 1 s, doubling, capped atGINI_TUNNEL_RECONNECT_MAX_MSdefault 30 s), reusing the same session-reuse machinery the boot resume uses — no browser, andgatewayReadyis already resolved because the process is running. A successful rebuild re-arms the watcher on the new child, so each later drop gets a fresh retry budget. A rebuild that settlesidle(no usable session, or the local port isn't serving — a needs-user condition) stops the loop atidle; if every attempt errors, the budget is exhausted and the record flips toerrornaming the attempt count. Setting max attempts to0disables auto-reconnect — the watcher then flips straight toerror(the pre-auto-reconnect behavior). Every state write is guarded by the same supervisor-identity + still-connectedcheck as before, so an intentional cancel/disconnect/supersede is never clobbered. While a manual provider's rebuilds fail, the loop re-assertsconnectingbetween attempts (a failed manual rebuild writeserror) so a polling client sees recovery in progress rather than a transienterror.The budget bounds consecutive failures, not a tunnel's lifetime: a successful rebuild re-arms a fresh budget, so a healthy link survives any number of separate drops over its lifetime. The deliberate consequence is that a tunnel that connects and then dies in a tight flap is retried indefinitely (no global flap ceiling) — but always paced by the capped backoff, so steady state is at worst one rebuild per
GINI_TUNNEL_RECONNECT_MAX_MS(default 30 s). That is the intended behavior for a link meant to be up 24/7: a flap ceiling would strand the tunnelerrorafter a burst of transient drops, which is strictly worse than retrying slowly. (A future flap detector could downgrade to a longer cool-off, but is not needed for correctness.) -
A relay (
frpc) connect carries a readiness timeout (GINI_TUNNEL_RELAY_READY_TIMEOUT_MS, default 45 s, passed throughbuildTunnelto the gini-relayFrpc). BecauseloginFailExit: falsekeeps frpc alive retrying, a proxy that logs in but never registers (e.g. its subdomain is still held by a prior process's control connection during an overlapping restart) would otherwise leavechild.start()pending forever and pin the record atconnecting. The timeout makesstart()reject (and kill the child), folding the stuck connect intoerror— and, under auto-reconnect, into a bounded retry instead of an infinite hang. -
Runtime shutdown stops every live child (
stopAllTunnels, awaited in the SIGTERM drain) sofrpcis torn down gracefully with the runtime rather than left forwarding past exit. The shutdown clears the supervisor registry first, so an in-flight auto-reconnect sees it was superseded and bails without rebuilding. If a reconnect was in flight (the record readsconnectingbut the entry was flaggedreconnecting, descending from a tunnel that wasconnected), the drain re-persistsconnectedbefore exit — otherwise the next boot's reconcile, which resets a staleconnectingtoidle, would silently leave the 24/7 link down across exactly the restart that interrupted its recovery. Re-persistingconnectedlets reconcile resume it like any cleanly-connected record. Both auto-reconnect (reconnectAfterExit) and the boot-resume itself flag their supervisorreconnecting, so a shutdown landing during either an exit-driven reconnect or a still-in-progress boot resume preserves the resumable record.
The tunnel link is long-lasting where the provider allows it. The relay keys the public subdomain to a stable per-instance deviceId (persisted in the instance relay home, independent of the session token), so reconnecting — even after a re-login — reuses the same https://<subdomain>.<relayDomain> URL; tailscale's machine name and a named Cloudflare hostname are equally stable. ngrok's free tier and Cloudflare quick tunnels mint a fresh subdomain on every connect — including the boot resume — so only the stable providers can promise that a shared URL keeps reaching the agent 24/7 across restarts (the connected popover's copy makes the same per-provider distinction).
A persisted connected / connecting record describes an frpc child the runtime spawned before it restarted — that child is gone after a restart, so the live status is stale and a raw connected would make GET /api/tunnel falsely read connected. reconcileTunnelOnStartup (called from packages/runtime/src/server.ts boot, mirroring the state.browser stale-record clear) handles this:
- A record that was
connected(for a still-enabled provider — manual providers re-detect their prerequisite first) is resumed: it flips toconnecting(so the firstGETnever reads a staleconnected) and kicks off a background reconnect that reuses the stored relay session — no browser login; manual drivers have no session and just reconnect (tailscale re-runsserve --bgto the same URL, ngrok/cloudflared mint a fresh quick URL while a named Cloudflare tunnel keeps its hostname) — and rebuilds the front as soon as this process owns the gateway port, without waiting on the web child. The tunnel fronts the gateway port, so its reachability does not depend on the web child finishing its (re)compile. This matters because the public URL is the only channel a remote client has to watch the restart finish: gating the rebuild on web-readiness would leave that client blind for the whole restart window — and, if the web child were slow, abandon the tunnel entirely (idle), so the client's update gate could never poll the restart to completion ("stuck on restarting"). Web-child readiness is the client's concern (its own polls) and the watchdog's, not the tunnel's. On success the stable providers (relay, tailscale, named Cloudflare) restore the same URL; ngrok/cloudflared-quick publish their fresh one.- Port-ownership gate.
reconcileTunnelOnStartupruns beforeBun.servebindsconfig.port(so the status flip lands before the gateway serves anyGET). The status flip is synchronous and safe to run that early, but the background rebuild must not expose the port until this process owns it — otherwise the stable public URL could forward to a stale/foreign listener still holdingconfig.port(and a returning browser would hand it its requests). Sopackages/runtime/src/server.tspasses agatewayReadypromise that it resolves the instantBun.servebinds; the resume awaits it before buildingfrpc(or running the manual driver). It is gated on the gateway bind alone — not the web child — so reachability still returns the moment the port is ours. A failed bind throws beforegatewayReadyresolves, so a doomed boot never publishes (and never builds anfrpcchild to leak). - Override ports.
gatewayReadyproves onlyconfig.port, so when aGINI_TUNNEL_PORToverride points the tunnel at a port this process never binds, the resume can't use it. It falls back to the same identity check a fresh connect uses, but as a bounded, cancellable poll of the override port (the override target may still be coming up after a restart), settlingidleif it never verifies within the budget — a stale override can never publish a foreign listener. The budget knobsGINI_TUNNEL_RESUME_WAIT_MS(default 60 s) andGINI_TUNNEL_RESUME_POLL_MS(default 1 s) apply to this override fallback only; the default gateway-port resume has no poll. - The resume is non-interactive and best-effort: if there is no usable stored session it settles back to
idlerather than popping an OAuth tab on a headless server or surfacing a spuriouserror.
- Port-ownership gate.
- A stale
connectingrecord (an incomplete attempt with no guaranteed session) just resets toidle. idle/errorrecords are left untouched.
The whole thing is best-effort — the boot site wraps it in a .catch() so a state-write failure can never crash startup, and the background reconnect captures its own errors into state.
Clean shutdown vs. non-graceful exit. On SIGTERM the drain awaits stopAllTunnels(), which terminates frpc/agent children cleanly AND turns childless provider-side state off (a tailscale serve config left live would route the public URL to whatever process binds the gateway port next); the record stays connected on disk so the next boot's reconcile re-publishes the same URL. A graceful shutdown therefore leaves no live front. A non-graceful exit (uncaught crash, SIGKILL, OOM, power loss) runs no drain: because frpc is spawned non-detached it is reparented rather than killed, so it can keep forwarding the public URL after the runtime dies, and the next boot's reconcile then marks state idle while that orphan is still live. The same applies to manual agent children (ngrok/cloudflared) on a non-graceful exit. Closing this residual window requires a persisted tunnel pidfile plus a signature-guarded reaper in reconcile (kill a matching live agent from a prior run) — deferred as a separate, higher-risk change since it kills processes by signature.
Overlapping-restart subdomain collision. A restart handoff (the autostart-refresh flow, or a launchd KeepAlive respawn) is gated only on the gateway port — gini autostart enable awaits waitForPortFree(config.port) before bootstrapping, and the successor's tunnel rebuild awaits only gatewayReady (the Bun.serve bind) — never on the relay subdomain. A same-instance restart reuses the same per-instance deviceId, hence the same subdomain, and the relay enforces one proxy per device with no evict hook (it drops a registration only when its frpc control connection closes). So if the successor's frpc NewProxy lands before the prior process's frpc has dropped server-side, frps rejects the duplicate proxy_name and the tunnel flaps. On the graceful path this is normally avoided: the old SIGTERM drain awaits stopAllTunnels() (which stops each frpc, severing its control connection) before the process exits and frees the port, so the successor — which can't bind the port until it's free — usually registers after the old registration is gone. The residual cases are a non-graceful old exit (no drain; the orphaned frpc keeps its registration) and a wedged stop within the bounded drain window. Both are self-healing via the readiness timeout (relayReadyTimeoutMs) + bounded auto-reconnect rather than a stuck state. For operators who want a deterministic lever, GINI_TUNNEL_RELAY_SETTLE_MS (default 0, no delay) makes a gini-relay resume wait that long after the gateway bind before registering its frpc, giving the prior registration time to clear; it is applied only to the relay resume (manual providers mint fresh subdomains or are machine-global, so they never collide on a shared subdomain) and is kept at 0 by default because the relay URL is a remote client's only channel to watch a restart finish, so added latency would regress that.
Every gini-relay dependency is injectable through setTunnelDeps (the login primitive, tunnel builder, credential store, defaults resolver, host-browser opener, and local-port resolver), so unit tests exercise the full connecting → connected / error / cancel / disconnect / reconcile matrix without touching the network, OAuth, the host browser, or a spawned frpc child. awaitTunnelSettled(instance) lets a test await the background handshake deterministically.
packages/runtime/src/types.ts—TunnelProvider,TunnelProviderId,TunnelStatus,TunnelState,TunnelSelectionRecord; optionaltunnel?: TunnelSelectionRecord | nullonRuntimeState.packages/runtime/src/state/store.ts— default increateEmptyState; backfill innormalizeState.packages/runtime/src/state/records.ts—createTunnelRecord(exported from thepackages/runtime/src/statebarrel).packages/runtime/src/integrations/tunnel.ts— behavior module (getTunnel,selectProvider,connectTunnel,cancelTunnel,disconnectTunnel,reconcileTunnelOnStartup), the supervisor registry, and thesetTunnelDeps/awaitTunnelSettledtest seams.packages/runtime/src/server.ts— callsreconcileTunnelOnStartupon boot (best-effort).packages/runtime/src/http.ts— the five thin routes + thestatusFromErrorMessage400 mappings.packages/runtime/src/cli/commands/tunnel.ts—gini tunnel [select <provider> | connect [provider] | cancel | disconnect], registered inpackages/runtime/src/cli/index.ts.gini-relay(npm/git dependency) — the client library:loginUrl,buildTunnel,createStore,resolveDefaults.
GET /api/tunnelon a fresh instance returns the four-provider catalog (gini-relay enabled, the rest disabled with theirrequiresstrings),selectedProvider: null,status: "idle".POST /api/tunnel/select {provider:"gini-relay"}saves the selection and staysidle; selecting a disabled (ngrok) or unknown provider returns400.POST /api/tunnel/connectreturnsstatus: "connecting"immediately; the background handshake then flipsGET /api/tunneltoconnected(withurl: https://<subdomain>.<relayDomain>) orerror(withmessage). An explicitproviderin the body overrides the saved selection.POST /api/tunnel/cancelaborts a pending login andPOST /api/tunnel/disconnectstops the livefrpcchild; both return tostatus: "idle"keepingselectedProvider.- A restart with a persisted
connectedrecord resumes on boot: it flips toconnectingand the background reconnect restores the front immediately (the relay reuses its stored session with no browser login, and the rebuild waits only on the gateway bind — never the web child; stable providers re-publish the same URL, ngrok/cloudflared-quick a fresh one). With no stored session it settles toidle. A staleconnectingrecord resets toidle;idle/errorrecords are left untouched. - A live child that exits unexpectedly while the runtime keeps running auto-reconnects: the record flips
connected → connecting → connectedwith no user action (session reused, no browser), supervising the rebuilt child too. Repeated rebuild failures exhaust the bounded budget and settleerrornaming the attempt count; a rebuild with no usable session stops atidle.GINI_TUNNEL_RECONNECT_MAX_ATTEMPTS=0disables it (flips straight toerror). An intentional disconnect during the backoff bails the loop without rebuilding. - A relay
frpcchild built bymakeDefaultDepscarries a positivereadyTimeoutMs(GINI_TUNNEL_RELAY_READY_TIMEOUT_MS, default 45 s), so a proxy that never registers can't hangstart()— and thus the record — atconnectingforever. - Every route returns the full
TunnelState. - The CLI commands go through the gateway routes (verified by the CLI unit tests pinning URL + method + body).
- 100% line and function coverage on
packages/runtime/src/integrations/tunnel.ts(every gini-relay seam injected viasetTunnelDeps, so tests never hit the network/OAuth/browser/frpc) andpackages/runtime/src/cli/commands/tunnel.ts; the additions torecords.ts/store.ts/http.tsare covered by the integration, http, and CLI tests.