diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 7f9c74cc0b0f..32284acfeb19 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -58362,6 +58362,108 @@ } } }, + "settings.browser.hostBlacklist": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hosts to Block from Embedded Browser" + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "内蔵ブラウザからブロックするホスト" + } + } + } + }, + "settings.browser.hostBlacklist.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Applies to terminal link clicks and intercepted `open https://...` calls. These hosts always open in your default browser. All other hosts open in cmux. One host or wildcard per line (for example: example.com, *.internal.example). Leave empty to open all hosts in cmux." + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "ターミナルのリンククリックおよびインターセプトされた`open https://...`呼び出しに適用されます。これらのホストは常にデフォルトブラウザで開きます。その他のホストはcmuxで開きます。1行に1つのホストまたはワイルドカード(例: example.com, *.internal.example)。空欄にするとすべてのホストをcmuxで開きます。" + } + } + } + }, + "settings.browser.hostListMode": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Host List Mode" + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "ホストリストモード" + } + } + } + }, + "settings.browser.hostListMode.blacklist": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Blacklist" + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "ブラックリスト" + } + } + } + }, + "settings.browser.hostListMode.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Whitelist: only listed hosts open in cmux. Blacklist: listed hosts always open in your default browser." + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "ホワイトリスト:リストのホストのみcmuxで開きます。ブラックリスト:リストのホストは常にデフォルトブラウザで開きます。" + } + } + } + }, + "settings.browser.hostListMode.whitelist": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Whitelist" + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "ホワイトリスト" + } + } + } + }, "settings.browser.hostWhitelist": { "extractionState": "manual", "localizations": { diff --git a/Resources/bin/open b/Resources/bin/open index 203ba1db3ee1..28753f2e47fa 100755 --- a/Resources/bin/open +++ b/Resources/bin/open @@ -27,8 +27,9 @@ elif command -v python3 >/dev/null 2>&1; then fi settings_domain="${CMUX_BUNDLE_ID:-}" -whitelist_raw="" -whitelist_patterns=() +host_list_raw="" +host_list_patterns=() +host_list_mode="whitelist" system_open() { exec "$SYSTEM_OPEN_BIN" "$@" @@ -338,15 +339,15 @@ host_matches_pattern() { [[ "$host" == "$pattern" ]] } -host_matches_whitelist() { +host_matches_list_pattern() { local url="$1" - if [[ ${#whitelist_patterns[@]} -eq 0 ]]; then + if [[ ${#host_list_patterns[@]} -eq 0 ]]; then return 0 fi local host host="$(normalize_host "$url")" || return 1 - for pattern in "${whitelist_patterns[@]}"; do + for pattern in "${host_list_patterns[@]}"; do if host_matches_pattern "$host" "$pattern"; then return 0 fi @@ -354,13 +355,33 @@ host_matches_whitelist() { return 1 } -load_whitelist_patterns() { +# Determine whether a URL's host should open in cmux, respecting the +# configured host-list mode (whitelist or blacklist). +should_open_in_cmux() { + local url="$1" + if [[ ${#host_list_patterns[@]} -eq 0 ]]; then + return 0 + fi + + if [[ "$host_list_mode" == "blacklist" ]]; then + # Blacklist mode: if the host matches a pattern, it should NOT open in cmux. + if host_matches_list_pattern "$url"; then + return 1 + fi + return 0 + fi + + # Whitelist mode (default): delegate directly. + host_matches_list_pattern "$url" +} + +load_host_list_patterns() { local raw="$1" local line while IFS= read -r line || [[ -n "$line" ]]; do local normalized normalized="$(normalize_whitelist_pattern "$line")" || continue - whitelist_patterns+=("$normalized") + host_list_patterns+=("$normalized") done <<< "$raw" } @@ -433,9 +454,14 @@ if [[ -n "$settings_domain" ]]; then system_open "$@" fi - whitelist_raw="$("$DEFAULTS_BIN" read "$settings_domain" browserHostWhitelist 2>/dev/null || true)" - if [[ -n "$whitelist_raw" ]]; then - load_whitelist_patterns "$whitelist_raw" + host_list_raw="$("$DEFAULTS_BIN" read "$settings_domain" browserHostWhitelist 2>/dev/null || true)" + if [[ -n "$host_list_raw" ]]; then + load_host_list_patterns "$host_list_raw" + fi + + host_list_mode_raw="$("$DEFAULTS_BIN" read "$settings_domain" browserHostListMode 2>/dev/null || true)" + if [[ "$host_list_mode_raw" == "blacklist" ]]; then + host_list_mode="blacklist" fi fi @@ -453,7 +479,7 @@ fi # 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 + if is_http_url "$url" && ! should_open_in_cmux "$url"; then failed_urls+=("$url") continue fi diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index da5491e65839..927b7428b2b3 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -8672,9 +8672,18 @@ struct ContentView: View { var openedCount = 0 if openSidebarPullRequestLinksInCmuxBrowser { for pullRequest in pullRequests { - if tabManager.openBrowser(url: pullRequest.url, insertAtEnd: true) != nil { + let url = pullRequest.url + // Respect external-open patterns and host whitelist/blacklist settings. + let shouldOpenExternally = BrowserLinkOpenSettings.shouldOpenExternally(url) + || { + guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return false } + return !BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: host) + }() + if shouldOpenExternally { + if NSWorkspace.shared.open(url) { openedCount += 1 } + } else if tabManager.openBrowser(url: url, insertAtEnd: true) != nil { openedCount += 1 - } else if NSWorkspace.shared.open(pullRequest.url) { + } else if NSWorkspace.shared.open(url) { openedCount += 1 } } @@ -13793,6 +13802,16 @@ private struct TabItemView: View, Equatable { private func openPullRequestLink(_ url: URL) { updateSelection() if openSidebarPullRequestLinksInCmuxBrowser { + // Respect external-open patterns and host whitelist/blacklist settings. + if BrowserLinkOpenSettings.shouldOpenExternally(url) { + NSWorkspace.shared.open(url) + return + } + if let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? ""), + !BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: host) { + NSWorkspace.shared.open(url) + return + } if tabManager.openBrowser( inWorkspace: tab.id, url: url, diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 6f502b38c7dc..46d042bcaf61 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3123,10 +3123,11 @@ class GhosttyApp { } } - // If a host whitelist is configured and this host isn't in it, open externally. - if !BrowserLinkOpenSettings.hostMatchesWhitelist(host) { + // If the host-list rules (whitelist or blacklist mode) say this host should not + // open in cmux, open externally. + if !BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: host) { #if DEBUG - dlog("link.openURL target=embedded but hostWhitelist miss host=\(host) url=\(url)") + dlog("link.openURL target=embedded but hostList miss host=\(host) url=\(url)") #endif return performOnMain { NSWorkspace.shared.open(url) diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index 276754dbd1b0..4a4504aaa9e4 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -80,6 +80,7 @@ final class CmuxSettingsFileStore { "browser.theme", "browser.openTerminalLinksInCmuxBrowser", "browser.interceptTerminalOpenCommandInCmuxBrowser", + "browser.hostListMode", "browser.hostsToOpenInEmbeddedBrowser", "browser.urlsToAlwaysOpenExternally", "browser.insecureHttpHostsAllowedInEmbeddedBrowser", diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 64ec6cad492e..84324ac9f85f 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -546,9 +546,26 @@ enum BrowserLinkOpenSettings { static let browserHostWhitelistKey = "browserHostWhitelist" static let defaultBrowserHostWhitelist: String = "" + static let browserHostListModeKey = "browserHostListMode" + static let defaultBrowserHostListMode: String = "whitelist" static let browserExternalOpenPatternsKey = "browserExternalOpenPatterns" static let defaultBrowserExternalOpenPatterns: String = "" + /// The two supported host-list modes. + /// - `whitelist`: only listed hosts open in cmux (empty list = allow all). + /// - `blacklist`: listed hosts always open externally (empty list = allow all in cmux). + enum HostListMode: String, CaseIterable, Identifiable { + case whitelist + case blacklist + + var id: String { rawValue } + } + + static func hostListMode(defaults: UserDefaults = .standard) -> HostListMode { + let raw = defaults.string(forKey: browserHostListModeKey) ?? defaultBrowserHostListMode + return HostListMode(rawValue: raw) ?? .whitelist + } + static func openTerminalLinksInCmuxBrowser(defaults: UserDefaults = .standard) -> Bool { if defaults.object(forKey: openTerminalLinksInCmuxBrowserKey) == nil { return defaultOpenTerminalLinksInCmuxBrowser @@ -627,12 +644,26 @@ enum BrowserLinkOpenSettings { return false } - /// Check whether a hostname matches the configured whitelist. - /// Empty whitelist means "allow all" (no filtering). - /// Supports exact match and wildcard prefix (`*.example.com`). - static func hostMatchesWhitelist(_ host: String, defaults: UserDefaults = .standard) -> Bool { + /// Determine whether a host should open in the cmux embedded browser, + /// respecting the configured host-list mode (whitelist or blacklist). + /// + /// - Whitelist mode (default): empty list = allow all; non-empty = only listed hosts open in cmux. + /// - Blacklist mode: empty list = allow all; non-empty = listed hosts open externally, everything else in cmux. + static func shouldOpenInCmuxBrowser(host: String, defaults: UserDefaults = .standard) -> Bool { let rawPatterns = hostWhitelist(defaults: defaults) if rawPatterns.isEmpty { return true } + + let matchesEntry = hostMatchesEntry(host, rawPatterns: rawPatterns) + switch hostListMode(defaults: defaults) { + case .whitelist: + return matchesEntry + case .blacklist: + return !matchesEntry + } + } + + /// Check whether a host matches any entry in the provided raw patterns list. + private static func hostMatchesEntry(_ host: String, rawPatterns: [String]) -> Bool { guard let normalizedHost = BrowserInsecureHTTPSettings.normalizeHost(host) else { return false } for rawPattern in rawPatterns { guard let pattern = normalizeWhitelistPattern(rawPattern) else { continue } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 8ca1b26cfd58..463b3a92a9e1 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -4079,6 +4079,23 @@ struct SettingsView: View { @AppStorage(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowserKey) private var interceptTerminalOpenCommandInCmuxBrowser = BrowserLinkOpenSettings.initialInterceptTerminalOpenCommandInCmuxBrowserValue() @AppStorage(BrowserLinkOpenSettings.browserHostWhitelistKey) private var browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist + @AppStorage(BrowserLinkOpenSettings.browserHostListModeKey) private var browserHostListMode = BrowserLinkOpenSettings.defaultBrowserHostListMode + + private var resolvedBrowserHostListMode: BrowserLinkOpenSettings.HostListMode { + BrowserLinkOpenSettings.HostListMode(rawValue: browserHostListMode) ?? .whitelist + } + + private var browserHostListModeSelection: Binding { + Binding( + get: { resolvedBrowserHostListMode.rawValue }, + set: { newValue in + browserHostListMode = + BrowserLinkOpenSettings.HostListMode(rawValue: newValue)?.rawValue + ?? BrowserLinkOpenSettings.defaultBrowserHostListMode + } + ) + } + @AppStorage(BrowserLinkOpenSettings.browserExternalOpenPatternsKey) private var browserExternalOpenPatterns = BrowserLinkOpenSettings.defaultBrowserExternalOpenPatterns @AppStorage(BrowserInsecureHTTPSettings.allowlistKey) private var browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText @@ -5617,57 +5634,7 @@ struct SettingsView: View { } if openTerminalLinksInCmuxBrowser || interceptTerminalOpenCommandInCmuxBrowser { - SettingsCardDivider() - - VStack(alignment: .leading, spacing: 6) { - SettingsCardRow( - configurationReview: .json("browser.hostsToOpenInEmbeddedBrowser"), - String(localized: "settings.browser.hostWhitelist", defaultValue: "Hosts to Open in Embedded Browser"), - subtitle: String(localized: "settings.browser.hostWhitelist.subtitle", defaultValue: "Applies to terminal link clicks and intercepted `open https://...` calls. Only these hosts open in cmux. Others open in your default browser. One host or wildcard per line (for example: example.com, *.internal.example). Leave empty to open all hosts in cmux.") - ) { - EmptyView() - } - - TextEditor(text: $browserHostWhitelist) - .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() - - VStack(alignment: .leading, spacing: 6) { - SettingsCardRow( - configurationReview: .json("browser.urlsToAlwaysOpenExternally"), - String(localized: "settings.browser.externalPatterns", defaultValue: "URLs to Always Open Externally"), - subtitle: String(localized: "settings.browser.externalPatterns.subtitle", defaultValue: "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) - } + browserHostListSettingsSection() } SettingsCardDivider() @@ -6096,6 +6063,85 @@ struct SettingsView: View { NSApplication.shared.terminate(nil) } + @ViewBuilder + private func browserHostListSettingsSection() -> some View { + SettingsCardDivider() + + SettingsCardRow( + configurationReview: .json("browser.hostListMode"), + String(localized: "settings.browser.hostListMode", defaultValue: "Host List Mode"), + subtitle: String(localized: "settings.browser.hostListMode.subtitle", defaultValue: "Whitelist: only listed hosts open in cmux. Blacklist: listed hosts always open in your default browser.") + ) { + Picker("", selection: browserHostListModeSelection) { + Text(String(localized: "settings.browser.hostListMode.whitelist", defaultValue: "Whitelist")) + .tag(BrowserLinkOpenSettings.HostListMode.whitelist.rawValue) + Text(String(localized: "settings.browser.hostListMode.blacklist", defaultValue: "Blacklist")) + .tag(BrowserLinkOpenSettings.HostListMode.blacklist.rawValue) + } + .labelsHidden() + .pickerStyle(.segmented) + .frame(width: 180) + } + + SettingsCardDivider() + + VStack(alignment: .leading, spacing: 6) { + let hostListTitle: String = resolvedBrowserHostListMode == .blacklist + ? String(localized: "settings.browser.hostBlacklist", defaultValue: "Hosts to Block from Embedded Browser") + : String(localized: "settings.browser.hostWhitelist", defaultValue: "Hosts to Open in Embedded Browser") + let hostListSubtitle: String = resolvedBrowserHostListMode == .blacklist + ? String(localized: "settings.browser.hostBlacklist.subtitle", defaultValue: "Applies to terminal link clicks and intercepted `open https://...` calls. These hosts always open in your default browser. All other hosts open in cmux. One host or wildcard per line (for example: example.com, *.internal.example). Leave empty to open all hosts in cmux.") + : String(localized: "settings.browser.hostWhitelist.subtitle", defaultValue: "Applies to terminal link clicks and intercepted `open https://...` calls. Only these hosts open in cmux. Others open in your default browser. One host or wildcard per line (for example: example.com, *.internal.example). Leave empty to open all hosts in cmux.") + SettingsCardRow( + configurationReview: .json("browser.hostsToOpenInEmbeddedBrowser"), + hostListTitle, + subtitle: hostListSubtitle + ) { + EmptyView() + } + + TextEditor(text: $browserHostWhitelist) + .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() + + VStack(alignment: .leading, spacing: 6) { + SettingsCardRow( + configurationReview: .json("browser.urlsToAlwaysOpenExternally"), + String(localized: "settings.browser.externalPatterns", defaultValue: "URLs to Always Open Externally"), + subtitle: String(localized: "settings.browser.externalPatterns.subtitle", defaultValue: "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) + } + } + private func resetAllSettings() { isResettingSettings = true appLanguage = LanguageSettings.defaultLanguage.rawValue @@ -6120,6 +6166,7 @@ struct SettingsView: View { openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser interceptTerminalOpenCommandInCmuxBrowser = BrowserLinkOpenSettings.defaultInterceptTerminalOpenCommandInCmuxBrowser browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist + browserHostListMode = BrowserLinkOpenSettings.defaultBrowserHostListMode browserExternalOpenPatterns = BrowserLinkOpenSettings.defaultBrowserExternalOpenPatterns browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText diff --git a/cmuxTests/BrowserConfigTests.swift b/cmuxTests/BrowserConfigTests.swift index 7ee49c5733a6..e22edbab0373 100644 --- a/cmuxTests/BrowserConfigTests.swift +++ b/cmuxTests/BrowserConfigTests.swift @@ -3332,49 +3332,49 @@ final class BrowserHostWhitelistTests: XCTestCase { } func testEmptyWhitelistAllowsAll() { - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults)) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "example.com", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "localhost", defaults: defaults)) } func testExactMatch() { defaults.set("localhost\n127.0.0.1", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults)) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("127.0.0.1", defaults: defaults)) - XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "localhost", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "127.0.0.1", defaults: defaults)) + XCTAssertFalse(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "example.com", defaults: defaults)) } func testExactMatchIsCaseInsensitive() { defaults.set("LocalHost", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults)) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("LOCALHOST", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "localhost", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "LOCALHOST", defaults: defaults)) } func testWildcardSuffix() { defaults.set("*.localtest.me", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("app.localtest.me", defaults: defaults)) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("sub.app.localtest.me", defaults: defaults)) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localtest.me", defaults: defaults)) - XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "app.localtest.me", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "sub.app.localtest.me", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "localtest.me", defaults: defaults)) + XCTAssertFalse(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "example.com", defaults: defaults)) } func testWildcardIsCaseInsensitive() { defaults.set("*.Example.COM", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("sub.example.com", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "sub.example.com", defaults: defaults)) } func testBlankLinesAndWhitespaceIgnored() { defaults.set(" localhost \n\n 127.0.0.1 \n", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults)) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("127.0.0.1", defaults: defaults)) - XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "localhost", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "127.0.0.1", defaults: defaults)) + XCTAssertFalse(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "example.com", defaults: defaults)) } func testMixedExactAndWildcard() { defaults.set("localhost\n127.0.0.1\n*.local.dev", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults)) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("127.0.0.1", defaults: defaults)) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("app.local.dev", defaults: defaults)) - XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("github.com", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "localhost", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "127.0.0.1", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "app.local.dev", defaults: defaults)) + XCTAssertFalse(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "github.com", defaults: defaults)) } func testDefaultWhitelistIsEmpty() { @@ -3384,24 +3384,24 @@ final class BrowserHostWhitelistTests: XCTestCase { func testWildcardRequiresDotBoundary() { defaults.set("*.example.com", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) - XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("badexample.com", defaults: defaults)) - XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com.evil", defaults: defaults)) + XCTAssertFalse(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "badexample.com", defaults: defaults)) + XCTAssertFalse(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "example.com.evil", defaults: defaults)) } func testWhitelistNormalizesSchemesPortsAndTrailingDots() { defaults.set("https://LOCALHOST:3000/path\n*.Example.COM:443", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost.", defaults: defaults)) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("api.example.com", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "localhost.", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "api.example.com", defaults: defaults)) } func testInvalidWhitelistEntriesDoNotImplicitlyAllowAll() { defaults.set("http://\n*.\n", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) - XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults)) + XCTAssertFalse(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "example.com", defaults: defaults)) } func testUnicodeWhitelistEntryMatchesPunycodeHost() { defaults.set("b\u{00FC}cher.example", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("xn--bcher-kva.example", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "xn--bcher-kva.example", defaults: defaults)) } } diff --git a/tests/test_open_wrapper.py b/tests/test_open_wrapper.py index b2b98a51756c..2726c814a92c 100755 --- a/tests/test_open_wrapper.py +++ b/tests/test_open_wrapper.py @@ -24,7 +24,11 @@ def make_executable(path: Path, content: str) -> None: def read_log(path: Path) -> list[str]: if not path.exists(): return [] - return [line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] + return [ + line.strip() + for line in path.read_text(encoding="utf-8").splitlines() + if line.strip() + ] def run_wrapper( @@ -33,6 +37,7 @@ def run_wrapper( intercept_setting: str | None, legacy_open_setting: str | None = None, whitelist: str | None, + host_list_mode: str | None = None, external_patterns: str | None = None, fail_urls: list[str] | None = None, local_files: list[str] | None = None, @@ -95,6 +100,13 @@ def run_wrapper( fi exit 1 ;; + browserHostListMode) + if [[ "${FAKE_DEFAULTS_HOST_LIST_MODE+x}" == "x" ]]; then + printf '%s' "$FAKE_DEFAULTS_HOST_LIST_MODE" + exit 0 + fi + exit 1 + ;; *) exit 1 ;; @@ -127,7 +139,9 @@ def run_wrapper( for relative_path in local_files: target = tmp / relative_path target.parent.mkdir(parents=True, exist_ok=True) - target.write_text("fixture", encoding="utf-8") + target.write_text( + "fixture", encoding="utf-8" + ) env = os.environ.copy() env["CMUX_SOCKET_PATH"] = "/tmp/cmux-open-wrapper-test.sock" @@ -161,6 +175,11 @@ def run_wrapper( else: env["FAKE_DEFAULTS_EXTERNAL_PATTERNS"] = external_patterns + if host_list_mode is None: + env.pop("FAKE_DEFAULTS_HOST_LIST_MODE", None) + else: + env["FAKE_DEFAULTS_HOST_LIST_MODE"] = host_list_mode + if fail_urls: env["FAKE_CMUX_FAIL_URLS"] = ",".join(fail_urls) else: @@ -175,7 +194,12 @@ def run_wrapper( check=False, ) - return read_log(open_log), read_log(cmux_log), result.returncode, result.stderr.strip() + return ( + read_log(open_log), + read_log(cmux_log), + result.returncode, + result.stderr.strip(), + ) def expect(condition: bool, message: str, failures: list[str]) -> None: @@ -191,8 +215,16 @@ def test_toggle_disabled_passthrough(failures: list[str]) -> None: whitelist="", ) expect(code == 0, f"toggle off: wrapper exited {code}: {stderr}", failures) - expect(cmux_log == [], f"toggle off: cmux should not be called, got {cmux_log}", failures) - expect(open_log == [url], f"toggle off: expected system open [{url}], got {open_log}", failures) + expect( + cmux_log == [], + f"toggle off: cmux should not be called, got {cmux_log}", + failures, + ) + expect( + open_log == [url], + f"toggle off: expected system open [{url}], got {open_log}", + failures, + ) def test_toggle_disabled_case_insensitive_passthrough(failures: list[str]) -> None: @@ -202,7 +234,11 @@ def test_toggle_disabled_case_insensitive_passthrough(failures: list[str]) -> No intercept_setting=" FaLsE ", whitelist="", ) - expect(code == 0, f"toggle off (case-insensitive): wrapper exited {code}: {stderr}", failures) + expect( + code == 0, + f"toggle off (case-insensitive): wrapper exited {code}: {stderr}", + failures, + ) expect( cmux_log == [], f"toggle off (case-insensitive): cmux should not be called, got {cmux_log}", @@ -223,8 +259,16 @@ def test_whitelist_miss_passthrough(failures: list[str]) -> None: whitelist="localhost\n127.0.0.1", ) expect(code == 0, f"whitelist miss: wrapper exited {code}: {stderr}", failures) - expect(cmux_log == [], f"whitelist miss: cmux should not be called, got {cmux_log}", failures) - expect(open_log == [url], f"whitelist miss: expected system open [{url}], got {open_log}", failures) + expect( + cmux_log == [], + f"whitelist miss: cmux should not be called, got {cmux_log}", + failures, + ) + expect( + open_log == [url], + f"whitelist miss: expected system open [{url}], got {open_log}", + failures, + ) def test_whitelist_match_routes_to_cmux(failures: list[str]) -> None: @@ -235,8 +279,16 @@ def test_whitelist_match_routes_to_cmux(failures: list[str]) -> None: whitelist="*.example.com", ) expect(code == 0, f"whitelist match: wrapper exited {code}: {stderr}", failures) - expect(open_log == [], f"whitelist match: system open should not be called, got {open_log}", failures) - expect(cmux_log == [f"browser open {url}"], f"whitelist match: unexpected cmux log {cmux_log}", failures) + expect( + open_log == [], + f"whitelist match: system open should not be called, got {open_log}", + failures, + ) + 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: @@ -247,7 +299,11 @@ def test_external_literal_pattern_is_deferred_to_app(failures: list[str]) -> Non whitelist="", external_patterns="platform.openai.com/account/usage", ) - expect(code == 0, f"external literal deferred: wrapper exited {code}: {stderr}", failures) + 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}", @@ -268,7 +324,9 @@ def test_external_regex_pattern_is_deferred_to_app(failures: list[str]) -> None: 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( + 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}", @@ -281,7 +339,9 @@ def test_external_regex_pattern_is_deferred_to_app(failures: list[str]) -> None: ) -def test_external_regex_with_icu_features_is_deferred_to_app(failures: list[str]) -> None: +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], @@ -289,7 +349,11 @@ def test_external_regex_with_icu_features_is_deferred_to_app(failures: list[str] 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( + 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}", @@ -310,7 +374,9 @@ def test_external_invalid_regex_is_ignored_silently(failures: list[str]) -> None whitelist="", external_patterns=r"re:[unclosed", ) - expect(code == 0, f"external invalid regex: wrapper exited {code}: {stderr}", failures) + 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}", @@ -360,11 +426,21 @@ def test_legacy_toggle_fallback_passthrough(failures: list[str]) -> None: whitelist="", ) expect(code == 0, f"legacy fallback: wrapper exited {code}: {stderr}", failures) - expect(cmux_log == [], f"legacy fallback: cmux should not be called, got {cmux_log}", failures) - expect(open_log == [url], f"legacy fallback: expected system open [{url}], got {open_log}", failures) + expect( + cmux_log == [], + f"legacy fallback: cmux should not be called, got {cmux_log}", + failures, + ) + expect( + open_log == [url], + f"legacy fallback: expected system open [{url}], got {open_log}", + failures, + ) -def test_legacy_toggle_fallback_case_insensitive_passthrough(failures: list[str]) -> None: +def test_legacy_toggle_fallback_case_insensitive_passthrough( + failures: list[str], +) -> None: url = "https://example.com" open_log, cmux_log, code, stderr = run_wrapper( args=[url], @@ -372,7 +448,11 @@ def test_legacy_toggle_fallback_case_insensitive_passthrough(failures: list[str] legacy_open_setting=" Off ", whitelist="", ) - expect(code == 0, f"legacy fallback (case-insensitive): wrapper exited {code}: {stderr}", failures) + expect( + code == 0, + f"legacy fallback (case-insensitive): wrapper exited {code}: {stderr}", + failures, + ) expect( cmux_log == [], f"legacy fallback (case-insensitive): cmux should not be called, got {cmux_log}", @@ -393,8 +473,16 @@ def test_uppercase_scheme_routes_to_cmux(failures: list[str]) -> None: whitelist="*.example.com", ) expect(code == 0, f"uppercase scheme: wrapper exited {code}: {stderr}", failures) - expect(open_log == [], f"uppercase scheme: system open should not be called, got {open_log}", failures) - expect(cmux_log == [f"browser open {url}"], f"uppercase scheme: unexpected cmux log {cmux_log}", failures) + expect( + open_log == [], + f"uppercase scheme: system open should not be called, got {open_log}", + failures, + ) + expect( + cmux_log == [f"browser open {url}"], + f"uppercase scheme: unexpected cmux log {cmux_log}", + failures, + ) def test_local_html_file_routes_to_cmux(failures: list[str]) -> None: @@ -406,8 +494,16 @@ def test_local_html_file_routes_to_cmux(failures: list[str]) -> None: local_files=[filename], ) expect(code == 0, f"local html file: wrapper exited {code}: {stderr}", failures) - expect(open_log == [], f"local html file: system open should not be called, got {open_log}", failures) - expect(len(cmux_log) == 1, f"local html file: expected exactly one cmux call, got {cmux_log}", failures) + expect( + open_log == [], + f"local html file: system open should not be called, got {open_log}", + failures, + ) + expect( + len(cmux_log) == 1, + f"local html file: expected exactly one cmux call, got {cmux_log}", + failures, + ) if cmux_log: expect( cmux_log[0].startswith("browser open file://"), @@ -429,11 +525,21 @@ def test_file_url_html_routes_to_cmux(failures: list[str]) -> None: whitelist="", ) expect(code == 0, f"file url html: wrapper exited {code}: {stderr}", failures) - expect(open_log == [], f"file url html: system open should not be called, got {open_log}", failures) - expect(cmux_log == [f"browser open {url}"], f"file url html: unexpected cmux log {cmux_log}", failures) + expect( + open_log == [], + f"file url html: system open should not be called, got {open_log}", + failures, + ) + expect( + cmux_log == [f"browser open {url}"], + f"file url html: unexpected cmux log {cmux_log}", + failures, + ) -def test_file_url_html_routes_to_cmux_without_python_binary(failures: list[str]) -> None: +def test_file_url_html_routes_to_cmux_without_python_binary( + failures: list[str], +) -> None: url = "file:///tmp/cmux-open-wrapper-fixture.html" open_log, cmux_log, code, stderr = run_wrapper( args=[url], @@ -441,7 +547,11 @@ def test_file_url_html_routes_to_cmux_without_python_binary(failures: list[str]) whitelist="", python_bin="/definitely/missing/python3", ) - expect(code == 0, f"file url html no-python fallback: wrapper exited {code}: {stderr}", failures) + expect( + code == 0, + f"file url html no-python fallback: wrapper exited {code}: {stderr}", + failures, + ) expect( open_log == [], f"file url html no-python fallback: system open should not be called, got {open_log}", @@ -454,7 +564,9 @@ def test_file_url_html_routes_to_cmux_without_python_binary(failures: list[str]) ) -def test_local_html_file_routes_to_cmux_without_python_binary(failures: list[str]) -> None: +def test_local_html_file_routes_to_cmux_without_python_binary( + failures: list[str], +) -> None: filename = "fixtures/no python fallback.html" open_log, cmux_log, code, stderr = run_wrapper( args=[filename], @@ -463,8 +575,16 @@ def test_local_html_file_routes_to_cmux_without_python_binary(failures: list[str local_files=[filename], python_bin="/definitely/missing/python3", ) - expect(code == 0, f"local html no-python fallback: wrapper exited {code}: {stderr}", failures) - expect(open_log == [], f"local html no-python fallback: system open should not be called, got {open_log}", failures) + expect( + code == 0, + f"local html no-python fallback: wrapper exited {code}: {stderr}", + failures, + ) + expect( + open_log == [], + f"local html no-python fallback: system open should not be called, got {open_log}", + failures, + ) expect( len(cmux_log) == 1, f"local html no-python fallback: expected exactly one cmux call, got {cmux_log}", @@ -490,7 +610,11 @@ def test_domain_like_html_argument_passthrough(failures: list[str]) -> None: intercept_setting="1", whitelist="", ) - expect(code == 0, f"domain-like html argument: wrapper exited {code}: {stderr}", failures) + expect( + code == 0, + f"domain-like html argument: wrapper exited {code}: {stderr}", + failures, + ) expect( cmux_log == [], f"domain-like html argument: cmux should not be called, got {cmux_log}", @@ -510,9 +634,19 @@ def test_non_file_scheme_html_passthrough(failures: list[str]) -> None: intercept_setting="1", whitelist="", ) - expect(code == 0, f"non-file scheme html: wrapper exited {code}: {stderr}", failures) - expect(cmux_log == [], f"non-file scheme html: cmux should not be called, got {cmux_log}", failures) - expect(open_log == [url], f"non-file scheme html: expected system open [{url}], got {open_log}", failures) + expect( + code == 0, f"non-file scheme html: wrapper exited {code}: {stderr}", failures + ) + expect( + cmux_log == [], + f"non-file scheme html: cmux should not be called, got {cmux_log}", + failures, + ) + expect( + open_log == [url], + f"non-file scheme html: expected system open [{url}], got {open_log}", + failures, + ) def test_mailto_html_passthrough(failures: list[str]) -> None: @@ -523,8 +657,16 @@ def test_mailto_html_passthrough(failures: list[str]) -> None: whitelist="", ) expect(code == 0, f"mailto html: wrapper exited {code}: {stderr}", failures) - expect(cmux_log == [], f"mailto html: cmux should not be called, got {cmux_log}", failures) - expect(open_log == [url], f"mailto html: expected system open [{url}], got {open_log}", failures) + expect( + cmux_log == [], + f"mailto html: cmux should not be called, got {cmux_log}", + failures, + ) + expect( + open_log == [url], + f"mailto html: expected system open [{url}], got {open_log}", + failures, + ) def test_local_non_html_file_passthrough(failures: list[str]) -> None: @@ -536,8 +678,16 @@ def test_local_non_html_file_passthrough(failures: list[str]) -> None: local_files=[filename], ) expect(code == 0, f"local non-html file: wrapper exited {code}: {stderr}", failures) - expect(cmux_log == [], f"local non-html file: cmux should not be called, got {cmux_log}", failures) - expect(open_log == [filename], f"local non-html file: expected system open [{filename}], got {open_log}", failures) + expect( + cmux_log == [], + f"local non-html file: cmux should not be called, got {cmux_log}", + failures, + ) + expect( + open_log == [filename], + f"local non-html file: expected system open [{filename}], got {open_log}", + failures, + ) def test_unicode_whitelist_matches_punycode_url(failures: list[str]) -> None: @@ -548,8 +698,16 @@ def test_unicode_whitelist_matches_punycode_url(failures: list[str]) -> None: whitelist="bücher.example", ) expect(code == 0, f"unicode whitelist: wrapper exited {code}: {stderr}", failures) - expect(open_log == [], f"unicode whitelist: system open should not be called, got {open_log}", failures) - expect(cmux_log == [f"browser open {url}"], f"unicode whitelist: unexpected cmux log {cmux_log}", failures) + expect( + open_log == [], + f"unicode whitelist: system open should not be called, got {open_log}", + failures, + ) + expect( + cmux_log == [f"browser open {url}"], + f"unicode whitelist: unexpected cmux log {cmux_log}", + failures, + ) def test_punycode_whitelist_matches_unicode_url(failures: list[str]) -> None: @@ -560,8 +718,126 @@ def test_punycode_whitelist_matches_unicode_url(failures: list[str]) -> None: whitelist="xn--bcher-kva.example", ) expect(code == 0, f"punycode whitelist: wrapper exited {code}: {stderr}", failures) - expect(open_log == [], f"punycode whitelist: system open should not be called, got {open_log}", failures) - expect(cmux_log == [f"browser open {url}"], f"punycode whitelist: unexpected cmux log {cmux_log}", failures) + expect( + open_log == [], + f"punycode whitelist: system open should not be called, got {open_log}", + failures, + ) + expect( + cmux_log == [f"browser open {url}"], + f"punycode whitelist: unexpected cmux log {cmux_log}", + failures, + ) + + +def test_blacklist_mode_match_opens_externally(failures: list[str]) -> None: + """In blacklist mode, hosts in the list should open externally (system browser).""" + url = "https://ads.example.com/banner" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + intercept_setting="1", + whitelist="ads.example.com\ntracker.net", + host_list_mode="blacklist", + ) + expect(code == 0, f"blacklist match: wrapper exited {code}: {stderr}", failures) + expect( + cmux_log == [], + f"blacklist match: cmux should not be called, got {cmux_log}", + failures, + ) + expect( + open_log == [url], + f"blacklist match: expected system open [{url}], got {open_log}", + failures, + ) + + +def test_blacklist_mode_miss_routes_to_cmux(failures: list[str]) -> None: + """In blacklist mode, hosts NOT in the list should open in cmux.""" + url = "https://github.com/repo" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + intercept_setting="1", + whitelist="ads.example.com", + host_list_mode="blacklist", + ) + expect(code == 0, f"blacklist miss: wrapper exited {code}: {stderr}", failures) + expect( + open_log == [], + f"blacklist miss: system open should not be called, got {open_log}", + failures, + ) + expect( + cmux_log == [f"browser open {url}"], + f"blacklist miss: unexpected cmux log {cmux_log}", + failures, + ) + + +def test_blacklist_mode_wildcard(failures: list[str]) -> None: + """In blacklist mode, wildcard patterns should block matching hosts.""" + url = "https://sub.blocked.com/page" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + intercept_setting="1", + whitelist="*.blocked.com", + host_list_mode="blacklist", + ) + expect(code == 0, f"blacklist wildcard: wrapper exited {code}: {stderr}", failures) + expect( + cmux_log == [], + f"blacklist wildcard: cmux should not be called, got {cmux_log}", + failures, + ) + expect( + open_log == [url], + f"blacklist wildcard: expected system open [{url}], got {open_log}", + failures, + ) + + +def test_blacklist_mode_empty_list_allows_all(failures: list[str]) -> None: + """In blacklist mode with an empty list, all hosts should open in cmux.""" + url = "https://example.com/page" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + intercept_setting="1", + whitelist="", + host_list_mode="blacklist", + ) + expect(code == 0, f"blacklist empty: wrapper exited {code}: {stderr}", failures) + expect( + open_log == [], + f"blacklist empty: system open should not be called, got {open_log}", + failures, + ) + expect( + cmux_log == [f"browser open {url}"], + f"blacklist empty: unexpected cmux log {cmux_log}", + failures, + ) + + +def test_whitelist_mode_explicit_setting(failures: list[str]) -> None: + """Explicit whitelist mode should behave the same as the default.""" + url = "https://example.com/page" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + intercept_setting="1", + whitelist="localhost", + host_list_mode="whitelist", + ) + expect(code == 0, f"explicit whitelist: wrapper exited {code}: {stderr}", failures) + expect( + cmux_log == [], + f"explicit whitelist: cmux should not be called, got {cmux_log}", + failures, + ) + expect( + open_log == [url], + f"explicit whitelist: expected system open [{url}], got {open_log}", + failures, + ) def main() -> int: @@ -588,6 +864,11 @@ def main() -> int: test_local_non_html_file_passthrough(failures) test_unicode_whitelist_matches_punycode_url(failures) test_punycode_whitelist_matches_unicode_url(failures) + test_blacklist_mode_match_opens_externally(failures) + test_blacklist_mode_miss_routes_to_cmux(failures) + test_blacklist_mode_wildcard(failures) + test_blacklist_mode_empty_list_allows_all(failures) + test_whitelist_mode_explicit_setting(failures) if failures: print("open wrapper regression tests failed:")