Repository navigation
feat(settings): add Cloudflare Tunnel pairing intent - #14040
Pierre-Mike wants to merge 3 commits into
Conversation
Adds a fourth option to "Where will this link be opened?" so a browser outside the network can reach this Orca server through a Cloudflare Tunnel. The panel shows the `cloudflared` command with the runtime's real bound port, then accepts the https:// URL cloudflared prints. The existing Custom address field could not serve this: it rejects https:// outright, and a scheme-less host would inherit the bound local port from resolveAdvertisedPairingEndpoint and advertise ws://host:6768 against a tunnel edge that only answers on 443. The new parser always normalizes to a full wss:// URL and rejects loopback, tailnet, and LAN hosts, which would mint a link the outside world cannot open. Reach is unchanged (network), so the one-way STA-2370 widen still applies. Narrowing it for tunnels touches that invariant and belongs in its own change. Extracted the intent radios and the access-grant hook to keep both touched files under the max-lines cap without a suppression. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
📝 WalkthroughWalkthroughThe pairing settings now include Cloudflare Tunnel as a runtime pairing intent. The form validates public tunnel URLs, generates loopback Merge Risk: 🟡 Moderate · up to The PR adds Cloudflare Tunnel pairing, but the current head contains malformed Spanish localization data that can prevent the catalog from loading and should be fixed before merge. A separate low-severity failure path can leave the runtime listener exposed on network interfaces after pairing setup fails, requiring explicit owner follow-up. 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Full details: Linked Issues checkExplanation The implementation satisfies issue [ Full details: Out of Scope Changes checkExplanation The changes remain within scope. The extracted intent component, access-grant hook, shared parsing utilities, localization, and tests directly support the Cloudflare Tunnel feature or maintain existing behavior. Full details: Docstring CoverageExplanation Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 18 functions across 12 files. (5 skipped: 5 unsupported.)
Warning Some tools did not complete. Review the errors below. 🔧 Biome (2.5.7)src/renderer/src/i18n/locales/en.jsonFile contains syntax errors that prevent linting: Line 11495: expected src/renderer/src/i18n/locales/es.jsonFile contains syntax errors that prevent linting: Line 10291: expected src/renderer/src/i18n/locales/ja.jsonFile contains syntax errors that prevent linting: Line 10291: expected
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (2)
src/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsx (1)
116-117: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueReduce this comment to one line.
Keep the rationale, but state it in one concise sentence.
- // Why: the pairing offer only skips grafting the bound local port onto the advertised host when the - // address carries a scheme, so an https:// paste has to reach main as wss:// or the link dials :6768. + // Preserve `wss://` so main does not append the local WebSocket port.As per coding guidelines, comments must be concise, non-obvious, and brief—prefer one line.
Source: Coding guidelines
src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx (1)
143-145: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueKeep explanatory comments brief.
These comments repeat nearby code behavior across multiple lines. Reduce each comment to one non-obvious sentence, or remove it.
As per coding guidelines: “Comments must be concise, non-obvious, and brief—prefer one line; do not explain obvious behavior or walk through code.”
Also applies to: 177-179
Source: Coding guidelines
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: bc17dd2f-ec48-4331-9ec5-d8b5d8ad7e26
📒 Files selected for processing (16)
src/renderer/src/components/settings/CloudflareTunnelFields.tsxsrc/renderer/src/components/settings/RuntimePairingGeneratorForm.test.tsxsrc/renderer/src/components/settings/RuntimePairingGeneratorForm.tsxsrc/renderer/src/components/settings/RuntimePairingIntentOptions.tsxsrc/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsxsrc/renderer/src/components/settings/RuntimePairingUrlGenerator.tsxsrc/renderer/src/components/settings/runtime-pairing-link-state.tssrc/renderer/src/components/settings/useRuntimeAccessGrants.tssrc/renderer/src/i18n/locales/en.jsonsrc/renderer/src/i18n/locales/es.jsonsrc/renderer/src/i18n/locales/ja.jsonsrc/renderer/src/i18n/locales/ko.jsonsrc/renderer/src/i18n/locales/zh.jsonsrc/shared/network/cloudflare-tunnel-address.test.tssrc/shared/network/cloudflare-tunnel-address.tssrc/shared/network/pairing-url.ts
Review follow-ups on stablyai#14040. parseCloudflareTunnelAddress accepted any scheme normalizePairingUrl accepted, and that maps http: to ws: rather than wss:. A pasted http://host therefore passed validation and was advertised as ws://host, carrying pairing and control traffic to a public destination in cleartext while the UI copy promised wss://. Reject anything that does not normalize to wss://. useRuntimeAccessGrants: a reload in flight when a revoke landed kept the live loadId, so its pre-revocation response wrote the revoked grant back into the list. Invalidate the load and clear its loading state before filtering. Re-read the bound WebSocket port on intent change so selecting Cloudflare Tunnel picks up a listener that bound, or rebound on a new port, after the pane mounted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks — the security finding was a real bug. All three actionable items and both nitpicks are addressed in ad48e9d. 🟠
|
|
Note GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer. |
There was a problem hiding this comment.
Actionable comments posted: 2
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 1b42698c-fc47-48a2-b210-aef55ba831aa
📒 Files selected for processing (17)
src/renderer/src/components/settings/CloudflareTunnelFields.tsxsrc/renderer/src/components/settings/RuntimePairingGeneratorForm.test.tsxsrc/renderer/src/components/settings/RuntimePairingGeneratorForm.tsxsrc/renderer/src/components/settings/RuntimePairingIntentOptions.tsxsrc/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsxsrc/renderer/src/components/settings/RuntimePairingUrlGenerator.tsxsrc/renderer/src/components/settings/runtime-pairing-link-state.tssrc/renderer/src/components/settings/useRuntimeAccessGrants.test.tssrc/renderer/src/components/settings/useRuntimeAccessGrants.tssrc/renderer/src/i18n/locales/en.jsonsrc/renderer/src/i18n/locales/es.jsonsrc/renderer/src/i18n/locales/ja.jsonsrc/renderer/src/i18n/locales/ko.jsonsrc/renderer/src/i18n/locales/zh.jsonsrc/shared/network/cloudflare-tunnel-address.test.tssrc/shared/network/cloudflare-tunnel-address.tssrc/shared/network/pairing-url.ts
🚧 Files skipped from review as they are similar to previous changes (16)
- src/renderer/src/components/settings/useRuntimeAccessGrants.test.ts
- src/shared/network/cloudflare-tunnel-address.ts
- src/shared/network/pairing-url.ts
- src/renderer/src/components/settings/RuntimePairingGeneratorForm.tsx
- src/renderer/src/components/settings/useRuntimeAccessGrants.ts
- src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx
- src/renderer/src/i18n/locales/en.json
- src/renderer/src/components/settings/RuntimePairingIntentOptions.tsx
- src/renderer/src/components/settings/CloudflareTunnelFields.tsx
- src/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsx
- src/renderer/src/components/settings/runtime-pairing-link-state.ts
- src/renderer/src/components/settings/RuntimePairingGeneratorForm.test.tsx
- src/renderer/src/i18n/locales/zh.json
- src/shared/network/cloudflare-tunnel-address.test.ts
- src/renderer/src/i18n/locales/ja.json
- src/renderer/src/i18n/locales/ko.json
Included review availability: Your plan provides up to 10 included reviews per hour; 9 remain after this review.
| "cloudflareTunnel": "Cloudflare Tunnel", | ||
| "cloudflareTunnelHelp": "Reach this server from outside your network", | ||
| "copiedTunnelCommand": "Copied tunnel command." |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Keep untranslated English keys out of es.json.
cloudflareTunnelHelp and copiedTunnelCommand are English values in the Spanish catalog. Remove these entries until Spanish translations exist, while keeping the source keys in en.json. The inline translate() fallback should provide English without storing copied placeholders in the non-English catalog.
Based on learnings, the current localization workflow keeps new English-source keys out of non-English catalogs until real translations exist.
Source: Learnings
| "CloudflareTunnelFields": { | ||
| "startTunnel": "Start the tunnel on this computer", | ||
| "startTunnelHelp": "Run this where Orca is running and leave it open. It prints a public URL.", | ||
| "commandLabel": "Tunnel command", | ||
| "addressLabel": "Tunnel URL", | ||
| "addressPlaceholder": "https://example.trycloudflare.com", | ||
| "addressHint": "Paste the URL cloudflared printed. Orca connects to it over wss://.", | ||
| "addressInvalid": "Enter the public https:// or wss:// URL that cloudflared printed.", | ||
| "quickTunnelWarning": "Quick tunnel URLs change every time cloudflared restarts. Generate a new link after each restart.", | ||
| "exposureNote": "The tunnel makes this Orca server reachable from the internet while it runs. Revoke the access grant below when you are done.", | ||
| "commandUnavailable": "The runtime port is not available yet. Reopen this pane once the Orca server is listening." |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Close CloudflareTunnelFields before the next locale section.
CloudflareTunnelFields opens on Line 10280, but GlobalWorktreeVisibilitySourcesSetting starts on Line 10291 before the object closes. This makes es.json invalid JSON and prevents the Spanish catalog from loading.
Proposed fix
- "commandUnavailable": "The runtime port is not available yet. Reopen this pane once the Orca server is listening."
+ "commandUnavailable": "The runtime port is not available yet. Reopen this pane once the Orca server is listening.",
+ },
"GlobalWorktreeVisibilitySourcesSetting": {📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

ELI5
Orca can already hand you a link that opens the whole app in a browser. Today that link only works if the browser can already reach your machine — same Wi-Fi, Tailscale, or an SSH tunnel you set up yourself. This adds a "Cloudflare Tunnel" choice that shows you the one command to run, takes the URL that command prints, and turns it into a link that works from anywhere.
What Changed
A fourth option in Settings → Remote Orca Servers → Share this host → "Where will this link be opened?":
cloudflared tunnel --url http://127.0.0.1:<port>using the runtime's real bound port, with a copy button.https://….trycloudflare.comURLcloudflaredprints, and normalizes it towss://before it reaches the main process.cloudflaredrestart and silently kill a previously generated link.Supporting refactors, no behavior change: the intent radios moved to
RuntimePairingIntentOptions.tsxand the access-grant list/revoke logic to auseRuntimeAccessGrantshook.RuntimePairingUrlGenerator.tsxwas sitting at exactly the 400-linemax-linescap, so this keeps both files under it without adding a suppression.Why
The existing Custom address field cannot serve this case, for two concrete reasons:
parseServerShareAddressrejectshttp(s)://outright, so pasting whatcloudflaredactually prints fails validation.parseHostOverridepath inresolveAdvertisedPairingEndpointand inherits the bound local port —foo.trycloudflare.comwould be advertised asws://foo.trycloudflare.com:6768against a tunnel edge that only answers on 443. That link fails silently, with no clue why.So the fix is not "document the custom field." It needs a parser that always emits a full
wss://URL. That is whatparseCloudflareTunnelAddressdoes.This also makes the browser client genuinely usable remotely: the tunnel terminates TLS, so the page is served over
https://and the socket iswss://, which sidesteps the mixed-content rejection inisMixedContentWebSocketthat blocks the plain-LANhttp://+ws://combination.Linked Issue
Fixes #14039
Also related to #12535, which asks for the same thing on the Orca Mobile pairing surface (Orca Relay / LAN). This PR covers the desktop Share this host surface only, so it does not close that one. Worth deciding whether the two should be tracked together.
Visual Proof
The whole flow
Recorded from the real app: pick the option, see the command with this runtime's actual bound port, get a loopback URL rejected, paste the URL
cloudflaredprints and watch it accepted with the quick-tunnel warning, then the same state in dark mode.The recording deliberately stops before Generate Access Link — clicking it renders a real pairing token on screen, which does not belong in a public PR.
Before / after
Same viewport and pane in both, so they tab cleanly.
Before — three options:
After — four options in a 2×2 grid:
Dark mode
One thing the screenshots caught that no static check would: at four columns the cards were too narrow for the settings pane and "Another device" wrapped mid-phrase into
Another / device. Fixed by dropping to a 2×2 grid.Media is hosted on the
pr-mediabranch of the fork so it stays out of this PR's diff, per the template's "do not commit to files".Testing
Verified by driving the real app through the repo's own e2e harness (
tests/e2e/helpers/orca-app.ts, isolateduserData, built--mode e2e), navigating to the pane and exercising every state. The capture spec was removed before commit pertests/e2e/AGENTS.md— the logic is pure and already covered by DOM-level unit tests, so a permanent e2e spec would pay an Electron launch for no added coverage.Reviewer steps:
https://…URL. Generate Access Link, open it in a browser.https://127.0.0.1:6768in the field — it should be refused.pnpm lintpnpm typecheckpnpm testpnpm build(build:desktop)Pre-existing test failures, not from this PR:
src/main/providers/local-pty-shell-ready.test.ts(typeset -Upath scoping) and 2 insrc/relay/agent-exec-handler.test.ts. I stashed the entire change and reproduced the identical 3 failures on cleanHEAD— they're environmental on my machine (zsh config / process spawning).Platforms: developed and verified on macOS. No platform-specific code paths — no keyboard shortcuts, no filesystem paths, and
cloudflared tunnel --url http://127.0.0.1:<port>is byte-identical on macOS, Linux, and Windows. Not manually run on Linux or Windows.Added:
cloudflare-tunnel-address.test.ts(parser edge cases — loopback/tailnet/LAN rejection, trailing slash, scheme-less host, credential-bearing URLs), plus cases inRuntimePairingGeneratorForm.test.tsx(command rendering, withheld command, Generate gating) andRuntimePairingUrlGenerator.test.tsx(reach for the new intent, and that a pastedhttps://URL reaches main aswss://).AI Disclosure
Written with Claude Code using Claude Opus 5. Every check in the table above was run locally; the before/after screenshots come from driving the real Electron app, not from mockups.
Review
cloudflaredruns; the UI says so and points at the revocable grant list below. Pairing payloads stay in the URL fragment, so the token never reaches Cloudflare's edge in a request line orReferer. The field refuses credential-bearing URLs (normalizePairingUrlrejects user/password) and wildcard hosts.reach: 'network', same as Custom, so generating a link still fires the one-wayensureNetworkExposure()widen from STA-2370. For a Cloudflare Tunnel that widen is arguably unnecessary, becausecloudflareddials loopback. I deliberately did not narrow it:servesThisComputerOnlyonly skips the widen when the advertised host is loopback, and a tunnel's host is public, so honoring it would mean changing that invariant — with a real risk of minting links that have no listener behind them. That belongs in its own reviewed change, not as a rider on a UI addition. Flagging it as a deliberate call, not an oversight.rpcServer(isWebSocketReady/getRuntimePairingUrl), so they cannot disagree. Matches how the three existing intents already behave.webClientUrl: null, andWebConnectstill rejects a mobile-scope offer for the full web app.RuntimePairingIntentgains a renderer-local variant; the value sent to main is an ordinary advertised address string thatresolveAdvertisedPairingEndpointalready accepted.isWebSocketReady()IPC on pane mount, guarded and best-effort. Parsing is synchronous string work on input.Known overlap I chose not to refactor:
webSocketEndpointPortinsrc/shared/network/pairing-url.tsduplicatesgetWebSocketPortinsrc/main/runtime/windows-mobile-firewall.ts. The latter is main-process and separately tested; deduping it would widen this diff. Happy to fold that in if you'd rather.Not covered by tests: the live
isWebSocketReady()→ port plumbing (unit tests mock it). If that IPC changes shape, the panel silently falls back to the unavailable row instead of failing loudly. Can add a narrow e2e spec for just that if you want it.Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred)Author