diff --git a/CLI/cmux.swift b/CLI/cmux.swift index e83a0121..f6f0d760 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -2390,6 +2390,17 @@ struct CMUXCLI { let (workspaceOpt, argsAfterWorkspace) = parseOption(subArgs, name: "--workspace") let (windowOpt, urlArgs) = parseOption(argsAfterWorkspace, name: "--window") let url = urlArgs.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) + let respectExternalOpenRules: Bool = { + guard let raw = ProcessInfo.processInfo.environment["CMUX_RESPECT_EXTERNAL_OPEN_RULES"] else { + return false + } + switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "1", "true", "yes", "on": + return true + default: + return false + } + }() if surfaceRaw != nil, subcommand == "open" { // Treat `browser open ` as navigate for agent-browser ergonomics. @@ -2415,6 +2426,9 @@ struct CMUXCLI { params["workspace_id"] = workspace } } + if respectExternalOpenRules { + params["respect_external_open_rules"] = true + } if let windowRaw = windowOpt { if let window = try normalizeWindowHandle(windowRaw, client: client) { params["window_id"] = window diff --git a/Resources/bin/open b/Resources/bin/open index 0b0ab639..203ba1db 100755 --- a/Resources/bin/open +++ b/Resources/bin/open @@ -437,6 +437,7 @@ if [[ -n "$settings_domain" ]]; then if [[ -n "$whitelist_raw" ]]; then load_whitelist_patterns "$whitelist_raw" fi + fi # Find cmux CLI (same directory as this script). @@ -448,13 +449,15 @@ if [[ ! -x "$CMUX_CLI" ]]; then fi # Open each URL in cmux's in-app browser; track failures individually. +# External-open pattern rules are evaluated in-app (NSRegularExpression) so +# terminal link clicks and intercepted `open` commands share one regex dialect. failed_urls=() for url in "${cmux_targets[@]}"; do if is_http_url "$url" && ! host_matches_whitelist "$url"; then failed_urls+=("$url") continue fi - "$CMUX_CLI" browser open "$url" 2>/dev/null || failed_urls+=("$url") + CMUX_RESPECT_EXTERNAL_OPEN_RULES=1 "$CMUX_CLI" browser open "$url" 2>/dev/null || failed_urls+=("$url") done # Fall back to system open for unmatched args and URLs that failed. diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index cd120bb1..303b4e44 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1368,6 +1368,11 @@ class GhosttyApp { NSWorkspace.shared.open(url) } case let .embeddedBrowser(url): + if BrowserLinkOpenSettings.shouldOpenExternally(url) { + return performOnMain { + NSWorkspace.shared.open(url) + } + } guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return performOnMain { NSWorkspace.shared.open(url) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 1f4b72ad..37d38fd7 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -186,6 +186,8 @@ enum BrowserLinkOpenSettings { static let browserHostWhitelistKey = "browserHostWhitelist" static let defaultBrowserHostWhitelist: String = "" + static let browserExternalOpenPatternsKey = "browserExternalOpenPatterns" + static let defaultBrowserExternalOpenPatterns: String = "" static func openTerminalLinksInCmuxBrowser(defaults: UserDefaults = .standard) -> Bool { if defaults.object(forKey: openTerminalLinksInCmuxBrowserKey) == nil { @@ -226,6 +228,38 @@ enum BrowserLinkOpenSettings { .filter { !$0.isEmpty } } + static func externalOpenPatterns(defaults: UserDefaults = .standard) -> [String] { + let raw = defaults.string(forKey: browserExternalOpenPatternsKey) ?? defaultBrowserExternalOpenPatterns + return raw + .components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty && !$0.hasPrefix("#") } + } + + static func shouldOpenExternally(_ url: URL, defaults: UserDefaults = .standard) -> Bool { + shouldOpenExternally(url.absoluteString, defaults: defaults) + } + + static func shouldOpenExternally(_ rawURL: String, defaults: UserDefaults = .standard) -> Bool { + let target = rawURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard !target.isEmpty else { return false } + + for rawPattern in externalOpenPatterns(defaults: defaults) { + guard let (isRegex, value) = parseExternalPattern(rawPattern) else { continue } + if isRegex { + guard let regex = try? NSRegularExpression(pattern: value, options: [.caseInsensitive]) else { continue } + let range = NSRange(target.startIndex.. (isRegex: Bool, value: String)? { + let trimmed = rawPattern.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + if trimmed.lowercased().hasPrefix("re:") { + let regexPattern = String(trimmed.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines) + guard !regexPattern.isEmpty else { return nil } + return (isRegex: true, value: regexPattern) + } + + return (isRegex: false, value: trimmed) + } } enum BrowserInsecureHTTPSettings { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 9eefcd40..116c293c 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -5252,6 +5252,7 @@ class TerminalController { } let urlStr = v2String(params, "url") let url = urlStr.flatMap { URL(string: $0) } + let respectExternalOpenRules = v2Bool(params, "respect_external_open_rules") ?? false var result: V2CallResult = .err(code: "internal_error", message: "Failed to create browser", data: nil) v2MainSync { @@ -5259,6 +5260,34 @@ class TerminalController { result = .err(code: "not_found", message: "Workspace not found", data: nil) return } + if let url, + respectExternalOpenRules, + BrowserLinkOpenSettings.shouldOpenExternally(url) { + guard NSWorkspace.shared.open(url) else { + result = .err( + code: "external_open_failed", + message: "Failed to open URL externally", + data: ["url": url.absoluteString] + ) + return + } + let windowId = v2ResolveWindowId(tabManager: tabManager) + result = .ok([ + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "workspace_id": ws.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), + "pane_id": v2OrNull(nil), + "pane_ref": v2Ref(kind: .pane, uuid: nil), + "surface_id": v2OrNull(nil), + "surface_ref": v2Ref(kind: .surface, uuid: nil), + "created_split": false, + "placement_strategy": "external", + "opened_externally": true, + "url": url.absoluteString + ]) + return + } v2MaybeFocusWindow(for: tabManager) v2MaybeSelectWorkspace(tabManager, workspace: ws) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 41b8f160..9936536a 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2730,6 +2730,8 @@ struct SettingsView: View { @AppStorage(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowserKey) private var interceptTerminalOpenCommandInCmuxBrowser = BrowserLinkOpenSettings.initialInterceptTerminalOpenCommandInCmuxBrowserValue() @AppStorage(BrowserLinkOpenSettings.browserHostWhitelistKey) private var browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist + @AppStorage(BrowserLinkOpenSettings.browserExternalOpenPatternsKey) + private var browserExternalOpenPatterns = BrowserLinkOpenSettings.defaultBrowserExternalOpenPatterns @AppStorage(BrowserInsecureHTTPSettings.allowlistKey) private var browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText @AppStorage(NotificationBadgeSettings.dockBadgeEnabledKey) private var notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled @AppStorage(QuitWarningSettings.warnBeforeQuitKey) private var warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit @@ -3354,6 +3356,31 @@ struct SettingsView: View { .padding(.horizontal, 16) .padding(.bottom, 12) } + + SettingsCardDivider() + + VStack(alignment: .leading, spacing: 6) { + SettingsCardRow( + "URLs to Always Open Externally", + subtitle: "Applies to terminal link clicks and intercepted `open https://...` calls. One rule per line. Plain text matches any URL substring, or prefix with `re:` for regex (for example: openai.com/usage, re:^https?://[^/]*\\.example\\.com/(billing|usage))." + ) { + EmptyView() + } + + TextEditor(text: $browserExternalOpenPatterns) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 60, maxHeight: 120) + .scrollContentBackground(.hidden) + .padding(6) + .background(Color(nsColor: .controlBackgroundColor)) + .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) + ) + .padding(.horizontal, 16) + .padding(.bottom, 12) + } } SettingsCardDivider() @@ -3602,6 +3629,7 @@ struct SettingsView: View { openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser interceptTerminalOpenCommandInCmuxBrowser = BrowserLinkOpenSettings.defaultInterceptTerminalOpenCommandInCmuxBrowser browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist + browserExternalOpenPatterns = BrowserLinkOpenSettings.defaultBrowserExternalOpenPatterns browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 547fda50..7f5ddb4c 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -7935,6 +7935,56 @@ final class BrowserLinkOpenSettingsTests: XCTestCase { defaults.set(true, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) XCTAssertTrue(BrowserLinkOpenSettings.initialInterceptTerminalOpenCommandInCmuxBrowserValue(defaults: defaults)) } + + func testExternalOpenPatternsDefaultToEmpty() { + XCTAssertTrue(BrowserLinkOpenSettings.externalOpenPatterns(defaults: defaults).isEmpty) + } + + func testExternalOpenLiteralPatternMatchesCaseInsensitively() { + defaults.set("openai.com/account/usage", forKey: BrowserLinkOpenSettings.browserExternalOpenPatternsKey) + XCTAssertTrue( + BrowserLinkOpenSettings.shouldOpenExternally( + "https://platform.OPENAI.com/account/usage", + defaults: defaults + ) + ) + } + + func testExternalOpenRegexPatternMatchesCaseInsensitively() { + defaults.set( + "re:^https?://[^/]*\\.example\\.com/(billing|usage)", + forKey: BrowserLinkOpenSettings.browserExternalOpenPatternsKey + ) + XCTAssertTrue( + BrowserLinkOpenSettings.shouldOpenExternally( + "https://FOO.example.com/BILLING", + defaults: defaults + ) + ) + } + + func testExternalOpenRegexPatternSupportsDigitCharacterClass() { + defaults.set( + "re:^https://example\\.com/usage/\\d+$", + forKey: BrowserLinkOpenSettings.browserExternalOpenPatternsKey + ) + XCTAssertTrue( + BrowserLinkOpenSettings.shouldOpenExternally( + "https://example.com/usage/42", + defaults: defaults + ) + ) + } + + func testExternalOpenPatternsIgnoreInvalidRegexEntries() { + defaults.set("re:(\nexample.com", forKey: BrowserLinkOpenSettings.browserExternalOpenPatternsKey) + XCTAssertTrue( + BrowserLinkOpenSettings.shouldOpenExternally( + "https://example.com/path", + defaults: defaults + ) + ) + } } final class TerminalOpenURLTargetResolutionTests: XCTestCase { diff --git a/tests/test_open_wrapper.py b/tests/test_open_wrapper.py index e54f134f..b2b98a51 100755 --- a/tests/test_open_wrapper.py +++ b/tests/test_open_wrapper.py @@ -33,6 +33,7 @@ def run_wrapper( intercept_setting: str | None, legacy_open_setting: str | None = None, whitelist: str | None, + external_patterns: str | None = None, fail_urls: list[str] | None = None, local_files: list[str] | None = None, python_bin: str | None = None, @@ -87,6 +88,13 @@ case "$key" in fi exit 1 ;; + browserExternalOpenPatterns) + if [[ "${FAKE_DEFAULTS_EXTERNAL_PATTERNS+x}" == "x" ]]; then + printf '%s' "$FAKE_DEFAULTS_EXTERNAL_PATTERNS" + exit 0 + fi + exit 1 + ;; *) exit 1 ;; @@ -148,6 +156,11 @@ exit 0 else: env["FAKE_DEFAULTS_WHITELIST"] = whitelist + if external_patterns is None: + env.pop("FAKE_DEFAULTS_EXTERNAL_PATTERNS", None) + else: + env["FAKE_DEFAULTS_EXTERNAL_PATTERNS"] = external_patterns + if fail_urls: env["FAKE_CMUX_FAIL_URLS"] = ",".join(fail_urls) else: @@ -226,6 +239,95 @@ def test_whitelist_match_routes_to_cmux(failures: list[str]) -> None: expect(cmux_log == [f"browser open {url}"], f"whitelist match: unexpected cmux log {cmux_log}", failures) +def test_external_literal_pattern_is_deferred_to_app(failures: list[str]) -> None: + url = "https://platform.openai.com/account/usage" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + intercept_setting="1", + whitelist="", + external_patterns="platform.openai.com/account/usage", + ) + expect(code == 0, f"external literal deferred: wrapper exited {code}: {stderr}", failures) + expect( + cmux_log == [f"browser open {url}"], + f"external literal deferred: expected wrapper to pass URL to cmux, got {cmux_log}", + failures, + ) + expect( + open_log == [], + f"external literal deferred: system open should not be called by wrapper, got {open_log}", + failures, + ) + + +def test_external_regex_pattern_is_deferred_to_app(failures: list[str]) -> None: + url = "https://foo.example.com/billing" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + intercept_setting="1", + whitelist="*.example.com", + external_patterns=r"re:^https?://[^/]*\.example\.com/(billing|usage)", + ) + expect(code == 0, f"external regex deferred: wrapper exited {code}: {stderr}", failures) + expect( + cmux_log == [f"browser open {url}"], + f"external regex deferred: expected wrapper to pass URL to cmux, got {cmux_log}", + failures, + ) + expect( + open_log == [], + f"external regex deferred: system open should not be called by wrapper, got {open_log}", + failures, + ) + + +def test_external_regex_with_icu_features_is_deferred_to_app(failures: list[str]) -> None: + url = "https://example.com/usage/42" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + intercept_setting="1", + whitelist="example.com", + external_patterns=r"re:^https://example\.com/usage/\d+$", + ) + expect(code == 0, f"external regex icu deferred: wrapper exited {code}: {stderr}", failures) + expect( + cmux_log == [f"browser open {url}"], + f"external regex icu deferred: expected wrapper to pass URL to cmux, got {cmux_log}", + failures, + ) + expect( + open_log == [], + f"external regex icu deferred: system open should not be called by wrapper, got {open_log}", + failures, + ) + + +def test_external_invalid_regex_is_ignored_silently(failures: list[str]) -> None: + url = "https://example.com/path" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + intercept_setting="1", + whitelist="", + external_patterns=r"re:[unclosed", + ) + expect(code == 0, f"external invalid regex: wrapper exited {code}: {stderr}", failures) + expect( + cmux_log == [f"browser open {url}"], + f"external invalid regex: expected cmux open for {url}, got {cmux_log}", + failures, + ) + expect( + open_log == [], + f"external invalid regex: expected no system open calls, got {open_log}", + failures, + ) + expect( + "invalid regular expression" not in stderr.lower(), + f"external invalid regex: stderr should stay clean, got {stderr!r}", + failures, + ) + + def test_partial_failures_only_fallback_failed_urls(failures: list[str]) -> None: good = "https://api.example.com" failed = "https://fail.example.com" @@ -468,6 +570,10 @@ def main() -> int: test_toggle_disabled_case_insensitive_passthrough(failures) test_whitelist_miss_passthrough(failures) test_whitelist_match_routes_to_cmux(failures) + test_external_literal_pattern_is_deferred_to_app(failures) + test_external_regex_pattern_is_deferred_to_app(failures) + test_external_regex_with_icu_features_is_deferred_to_app(failures) + test_external_invalid_regex_is_ignored_silently(failures) test_partial_failures_only_fallback_failed_urls(failures) test_legacy_toggle_fallback_passthrough(failures) test_legacy_toggle_fallback_case_insensitive_passthrough(failures)