Add external URL bypass rules for embedded browser opens (#768)
* Add external URL bypass rules for embedded browser opens * Align open-wrapper external regex handling with app-side matcher
This commit is contained in:
parent
a6f6485e3c
commit
fdc38a3326
8 changed files with 283 additions and 1 deletions
|
|
@ -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 <surface> open <url>` 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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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..<target.endIndex, in: target)
|
||||
if regex.firstMatch(in: target, options: [], range: range) != nil {
|
||||
return true
|
||||
}
|
||||
} else if target.range(of: value, options: [.caseInsensitive]) != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
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`).
|
||||
|
|
@ -264,6 +298,19 @@ enum BrowserLinkOpenSettings {
|
|||
}
|
||||
return host == pattern
|
||||
}
|
||||
|
||||
private static func parseExternalPattern(_ rawPattern: String) -> (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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue