From b6c49fa337adb608fd9e156ad62a608be1422ebd Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 3 Sep 2026 12:16:36 -0300 Subject: [PATCH 1/2] feat: register the Aside browser with agents and pick the external browser Adds programa aside , which registers Aside's MCP server (and, with --with-devtools, a chrome-devtools-mcp pointed at Aside's DevTools port) with Claude Code and Codex, plus a File menu entry that runs it. Adds Settings > Browser > Open External Links With and the matching browser.externalBrowser settings.json key so links that leave Programa can go to Aside instead of the system default. Documents the split between the embedded browser and Aside for agents. --- CLI/CLI+AgentWrappers.swift | 10 +- CLI/CLI+Aside.swift | 249 ++++++++++++++++++ CLI/CLICommandDispatcher.swift | 3 + CLI/programa.swift | 19 ++ GhosttyTabs.xcodeproj/project.pbxproj | 4 + README.md | 2 +- Resources/Localizable.xcstrings | 68 +++++ SKILL.md | 24 ++ Sources/AppDelegate.swift | 24 ++ Sources/GhosttyApp.swift | 16 +- Sources/Panels/BrowserPanel.swift | 4 +- .../Panels/BrowserPopupWindowController.swift | 8 +- Sources/Panels/BrowserSettings.swift | 44 ++++ Sources/Panels/ProgramaWebView.swift | 2 +- Sources/ProgramaApp.swift | 9 + Sources/ProgramaSettingsFileStore.swift | 9 + Sources/SettingsView.swift | 21 ++ Sources/TabItemView.swift | 10 +- ...TerminalController+BrowserAutomation.swift | 2 +- docs/agent-skill.md | 3 +- docs/aside-browser.md | 81 ++++++ docs/settings-json.md | 1 + programaTests/BrowserConfigTests.swift | 69 +++++ programaTests/WorkspaceUnitTests.swift | 56 ++++ 24 files changed, 713 insertions(+), 25 deletions(-) create mode 100644 CLI/CLI+Aside.swift create mode 100644 docs/aside-browser.md diff --git a/CLI/CLI+AgentWrappers.swift b/CLI/CLI+AgentWrappers.swift index fe5294d3..c53443d9 100644 --- a/CLI/CLI+AgentWrappers.swift +++ b/CLI/CLI+AgentWrappers.swift @@ -16,7 +16,7 @@ extension ProgramaCLI { return prefix.contains("cmux claude wrapper - injects hooks and session tracking") } - private func resolveExecutableInSearchPath( + func resolveExecutableInSearchPath( _ name: String, searchPath: String?, skip: ((String) -> Bool)? = nil @@ -33,7 +33,7 @@ extension ProgramaCLI { return nil } - private func resolveClaudeExecutable(searchPath: String?) -> String? { + func resolveClaudeExecutable(searchPath: String?) -> String? { resolveExecutableInSearchPath( "claude", searchPath: searchPath, @@ -41,6 +41,12 @@ extension ProgramaCLI { ) } + /// Resolves the `codex` CLI executable from PATH. Unlike Claude, Programa does not + /// ship a codex wrapper, so no skip predicate is needed. + func resolveCodexExecutable(searchPath: String?) -> String? { + resolveExecutableInSearchPath("codex", searchPath: searchPath) + } + private func claudeTeamsHasExplicitTeammateMode(commandArgs: [String]) -> Bool { commandArgs.contains { arg in arg == "--teammate-mode" || arg.hasPrefix("--teammate-mode=") diff --git a/CLI/CLI+Aside.swift b/CLI/CLI+Aside.swift new file mode 100644 index 00000000..574df4eb --- /dev/null +++ b/CLI/CLI+Aside.swift @@ -0,0 +1,249 @@ +import Foundation + +/// Locates the Aside CLI (https://docs.aside.com) on disk. Pure and process-free so +/// it can be unit tested without touching the real filesystem. +struct AsideCLILocator { + static func resolve(environment: [String: String], fileExists: (String) -> Bool) -> String? { + if let home = environment["HOME"], !home.isEmpty { + let localBin = (home as NSString).appendingPathComponent(".local/bin/aside") + if fileExists(localBin) { + return localBin + } + let cliApp = (home as NSString).appendingPathComponent(".aside/cli/Aside CLI.app/Contents/MacOS/aside") + if fileExists(cliApp) { + return cliApp + } + } + let entries = environment["PATH"]?.split(separator: ":").map(String.init) ?? [] + for entry in entries where !entry.isEmpty { + let candidate = (entry as NSString).appendingPathComponent("aside") + if fileExists(candidate) { + return candidate + } + } + return nil + } +} + +/// Builds the command plan for registering/removing Aside's MCP server(s) with +/// Claude Code and Codex. Pure so the exact argv can be unit tested without +/// spawning a process. +struct AsideMCPPlan { + let claudeCommands: [[String]] + let codexCommands: [[String]] + + static func build( + asidePath: String, + claudeExecutable: String?, + codexExecutable: String?, + withDevTools: Bool, + install: Bool + ) -> AsideMCPPlan { + var claudeCommands: [[String]] = [] + var codexCommands: [[String]] = [] + + if let claude = claudeExecutable { + if install { + claudeCommands.append([ + claude, "mcp", "add", "--scope", "user", "--transport", "stdio", "aside", "--", asidePath, "mcp", + ]) + if withDevTools { + claudeCommands.append([ + claude, "mcp", "add", "--scope", "user", "--transport", "stdio", "aside-devtools", + "--", "npx", "-y", "chrome-devtools-mcp@latest", "--browserUrl", "http://127.0.0.1:9223", + ]) + } + } else { + claudeCommands.append([claude, "mcp", "remove", "--scope", "user", "aside"]) + claudeCommands.append([claude, "mcp", "remove", "--scope", "user", "aside-devtools"]) + } + } + + if let codex = codexExecutable { + if install { + codexCommands.append([codex, "mcp", "add", "aside", "--", asidePath, "mcp"]) + if withDevTools { + codexCommands.append([ + codex, "mcp", "add", "aside-devtools", + "--", "npx", "-y", "chrome-devtools-mcp@latest", "--browserUrl", "http://127.0.0.1:9223", + ]) + } + } else { + codexCommands.append([codex, "mcp", "remove", "aside"]) + codexCommands.append([codex, "mcp", "remove", "aside-devtools"]) + } + } + + return AsideMCPPlan(claudeCommands: claudeCommands, codexCommands: codexCommands) + } +} + +extension ProgramaCLI { + /// Report-only probe of Aside's Chrome DevTools Protocol endpoint. Returns the + /// `webSocketDebuggerUrl` from `http://127.0.0.1:9223/json/version` when Aside is + /// running, nil otherwise (including on any network/parse failure). + func asideDevToolsEndpoint(timeout: TimeInterval = 2) -> String? { + guard let url = URL(string: "http://127.0.0.1:9223/json/version") else { return nil } + var request = URLRequest(url: url) + request.timeoutInterval = timeout + let semaphore = DispatchSemaphore(value: 0) + var result: String? + let task = URLSession.shared.dataTask(with: request) { data, _, _ in + defer { semaphore.signal() } + guard let data, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let webSocketDebuggerUrl = json["webSocketDebuggerUrl"] as? String else { return } + result = webSocketDebuggerUrl + } + task.resume() + _ = semaphore.wait(timeout: .now() + timeout + 0.5) + return result + } + + private func asideResolveClients(environment: [String: String]) -> (claude: String?, codex: String?) { + let searchPath = environment["PATH"] + return ( + resolveClaudeExecutable(searchPath: searchPath), + resolveCodexExecutable(searchPath: searchPath) + ) + } + + private func asideIsRegistered(executable: String, serverName: String) -> Bool { + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = ["mcp", "get", serverName] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + do { + try process.run() + process.waitUntilExit() + return process.terminationStatus == 0 + } catch { + return false + } + } + + private func asideRunCommand(_ command: [String]) throws { + guard let executable = command.first else { return } + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = Array(command.dropFirst()) + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + throw CLIError(message: "Command failed (\(process.terminationStatus)): \(command.joined(separator: " "))") + } + } + + func runAside(arguments: [String]) throws { + let subcommand = arguments.first?.lowercased() ?? "help" + let withDevTools = arguments.contains("--with-devtools") + let skipConfirm = arguments.contains("--yes") || arguments.contains("-y") + let environment = ProcessInfo.processInfo.environment + + let asidePath = AsideCLILocator.resolve(environment: environment) { FileManager.default.isExecutableFile(atPath: $0) } + let (claudeExecutable, codexExecutable) = asideResolveClients(environment: environment) + + switch subcommand { + case "status": + if let asidePath { + print("Aside CLI: \(asidePath)") + } else { + print("Aside CLI: not found, install from https://docs.aside.com/help/developers") + } + if let endpoint = asideDevToolsEndpoint() { + print("DevTools: \(endpoint)") + } else { + print("DevTools: not reachable (is Aside running?)") + } + if let claudeExecutable { + let registered = asideIsRegistered(executable: claudeExecutable, serverName: "aside") + print("Claude Code: \(claudeExecutable): aside \(registered ? "registered" : "not registered")") + } else { + print("Claude Code: not found") + } + if let codexExecutable { + let registered = asideIsRegistered(executable: codexExecutable, serverName: "aside") + print("Codex: \(codexExecutable): aside \(registered ? "registered" : "not registered")") + } else { + print("Codex: not found") + } + return + + case "install-mcp", "uninstall-mcp": + let install = subcommand == "install-mcp" + guard let asidePath else { + throw CLIError(message: "Aside CLI not found. Install it from https://docs.aside.com/help/developers") + } + if claudeExecutable == nil, codexExecutable == nil { + throw CLIError(message: "Neither Claude Code nor Codex CLI was found on PATH.") + } + + print("Aside CLI: \(asidePath)") + if let endpoint = asideDevToolsEndpoint() { + print("DevTools: \(endpoint)") + } + if claudeExecutable == nil { + print("Claude Code: not found, skipping") + } + if codexExecutable == nil { + print("Codex: not found, skipping") + } + + let plan = AsideMCPPlan.build( + asidePath: asidePath, + claudeExecutable: claudeExecutable, + codexExecutable: codexExecutable, + withDevTools: withDevTools, + install: install + ) + + var pendingCommands: [(client: String, serverName: String, command: [String])] = [] + for command in plan.claudeCommands { + let serverName = command.contains("aside-devtools") ? "aside-devtools" : "aside" + pendingCommands.append((client: "Claude Code", serverName: serverName, command: command)) + } + for command in plan.codexCommands { + let serverName = command.contains("aside-devtools") ? "aside-devtools" : "aside" + pendingCommands.append((client: "Codex", serverName: serverName, command: command)) + } + + guard !pendingCommands.isEmpty else { + print("Nothing to do.") + return + } + + print("") + print("The following commands will run:") + for entry in pendingCommands { + print(" \(entry.command.joined(separator: " "))") + } + + if !skipConfirm { + print("Apply these changes? [Y/n] ", terminator: "") + if let response = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + !response.isEmpty && response != "y" && response != "yes" { + print("Aborted.") + return + } + } + + for entry in pendingCommands { + if install, let executable = entry.command.first, + asideIsRegistered(executable: executable, serverName: entry.serverName) { + print("\(entry.client): \(entry.serverName) already registered, skipping") + continue + } + print("Running: \(entry.command.joined(separator: " "))") + try asideRunCommand(entry.command) + } + print("") + print(install ? "Installed." : "Removed.") + return + + default: + print("Usage: programa aside [--with-devtools] [--yes]") + throw CLIError(message: "Unknown aside subcommand: \(subcommand)") + } + } +} diff --git a/CLI/CLICommandDispatcher.swift b/CLI/CLICommandDispatcher.swift index 9fe6d9fd..bfde0ab2 100644 --- a/CLI/CLICommandDispatcher.swift +++ b/CLI/CLICommandDispatcher.swift @@ -187,6 +187,9 @@ struct CLICommandDispatcher { explicitPassword: socketPasswordArg ) return + case "aside": + try cli.runAside(arguments: commandArgs) + return default: break } diff --git a/CLI/programa.swift b/CLI/programa.swift index 96483a11..520bd5d6 100644 --- a/CLI/programa.swift +++ b/CLI/programa.swift @@ -1153,6 +1153,23 @@ struct ProgramaCLI { """, execute: nil ), + CommandDescriptor( + names: ["aside"], + helpLines: ["aside [--with-devtools] [--yes]"], + connectionPolicy: .local, + detailedUsage: """ + Usage: programa aside [--with-devtools] [--yes] + + Detect the Aside agent browser CLI and register its MCP server with + Claude Code and Codex. `status` reports the detected aside binary, + the DevTools endpoint if Aside is running, and each client's + registration state. `install-mcp` runs `claude mcp add` / `codex mcp add` + for each detected client; `--with-devtools` also registers a + chrome-devtools-mcp server pointed at Aside's DevTools port. + `uninstall-mcp` removes both. `--yes`/`-y` skips the confirmation prompt. + """, + execute: nil + ), CommandDescriptor( names: ["ping"], @@ -6470,6 +6487,8 @@ struct ProgramaCLI { return case "codex", "claude", "opencode": _ = try parse(booleans: ["yes", "y"], minPositionals: 1, maxPositionals: 1) + case "aside": + _ = try parse(booleans: ["yes", "y", "with-devtools"], minPositionals: 1, maxPositionals: 1) // Commands with richer bespoke contracts are validated by their // dedicated cases in `validateArguments`. case "ping", "focus-panel", "read-screen", "wait-surface", "set-progress", "list-log", "watch-events": diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index fea326b4..3f4e3dc0 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -255,6 +255,7 @@ 6C9DA4528D8FCFF501A5FA8C /* programa-mcp in Copy CLI */ = {isa = PBXBuildFile; fileRef = 0AC9A9E0A68EF163F87A0C83 /* programa-mcp */; }; B9000031A1B2C3D4E5F60719 /* CLI+Markdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000030A1B2C3D4E5F60719 /* CLI+Markdown.swift */; }; B9000035A1B2C3D4E5F60719 /* CLI+Browser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000034A1B2C3D4E5F60719 /* CLI+Browser.swift */; }; + A51DE0700000000000000001 /* CLI+Aside.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51DE0700000000000000002 /* CLI+Aside.swift */; }; B9000037A1B2C3D4E5F60719 /* CLI+Themes.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000036A1B2C3D4E5F60719 /* CLI+Themes.swift */; }; B9000039A1B2C3D4E5F60719 /* CLI+Tree.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000038A1B2C3D4E5F60719 /* CLI+Tree.swift */; }; B900003BA1B2C3D4E5F60719 /* CLI+TmuxCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = B900003AA1B2C3D4E5F60719 /* CLI+TmuxCompat.swift */; }; @@ -659,6 +660,7 @@ 30DC1E7B0A701824297403A5 /* FocusTools.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FocusTools.swift"; sourceTree = ""; }; B9000030A1B2C3D4E5F60719 /* CLI+Markdown.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CLI+Markdown.swift"; sourceTree = ""; }; B9000034A1B2C3D4E5F60719 /* CLI+Browser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CLI+Browser.swift"; sourceTree = ""; }; + A51DE0700000000000000002 /* CLI+Aside.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CLI+Aside.swift"; sourceTree = ""; }; B9000036A1B2C3D4E5F60719 /* CLI+Themes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CLI+Themes.swift"; sourceTree = ""; }; B9000038A1B2C3D4E5F60719 /* CLI+Tree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CLI+Tree.swift"; sourceTree = ""; }; B900003AA1B2C3D4E5F60719 /* CLI+TmuxCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CLI+TmuxCompat.swift"; sourceTree = ""; }; @@ -1073,6 +1075,7 @@ B9000001A1B2C3D4E5F60719 /* programa.swift */, B9000030A1B2C3D4E5F60719 /* CLI+Markdown.swift */, B9000034A1B2C3D4E5F60719 /* CLI+Browser.swift */, + A51DE0700000000000000002 /* CLI+Aside.swift */, B9000036A1B2C3D4E5F60719 /* CLI+Themes.swift */, B9000038A1B2C3D4E5F60719 /* CLI+Tree.swift */, B900003AA1B2C3D4E5F60719 /* CLI+TmuxCompat.swift */, @@ -1702,6 +1705,7 @@ RVPN00000000000000000010 /* CLI+Review.swift in Sources */, RCAP000001 /* CLI+Recap.swift in Sources */, B9000035A1B2C3D4E5F60719 /* CLI+Browser.swift in Sources */, + A51DE0700000000000000001 /* CLI+Aside.swift in Sources */, B9000037A1B2C3D4E5F60719 /* CLI+Themes.swift in Sources */, THTM0003 /* TerminalThemeStore.swift in Sources */, B9000039A1B2C3D4E5F60719 /* CLI+Tree.swift in Sources */, diff --git a/README.md b/README.md index 38c34525..8d056917 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Choose separate light and dark Ghostty themes in `Settings → Appearance → Te Agents running inside programa (Claude Code, Codex, OpenCode) can drive the app itself, splitting panes, reading a sibling pane's output, spawning and coordinating a helper agent, all without stealing your focus. `programa claude/codex/opencode install-integration` installs [`SKILL.md`](SKILL.md) alongside the existing hooks; see [docs/agent-skill.md](docs/agent-skill.md) for the full walkthrough. -The same control surface is also available over MCP, for agents that speak it natively. Point your client at `Programa.app/Contents/Resources/bin/programa-mcp`; see [docs/mcp-server.md](docs/mcp-server.md). +The same control surface is also available over MCP, for agents that speak it natively. Point your client at `Programa.app/Contents/Resources/bin/programa-mcp`; see [docs/mcp-server.md](docs/mcp-server.md). The MCP server also exposes programa's embedded browser as `browser_*` tools, and `programa aside install-mcp` registers the [Aside](https://aside.com) browser with Claude Code and Codex for logged-in sites; see [docs/aside-browser.md](docs/aside-browser.md). ## Community diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index cf3f5cc2..562e48eb 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -8599,6 +8599,74 @@ } } }, + "settings.browser.externalBrowser": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open External Links With" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "外部リンクを開くブラウザ" + } + } + } + }, + "settings.browser.externalBrowser.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Used for terminal links and browser links that open outside Programa." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Programa の外部で開くターミナルリンクやブラウザリンクに使用されます。" + } + } + } + }, + "settings.browser.externalBrowser.systemDefault": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "System Default" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "システムのデフォルト" + } + } + } + }, + "menu.file.installAsideMCP": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Install Aside Browser MCP…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Aside ブラウザ MCP をインストール…" + } + } + } + }, "settings.browser.externalPatterns": { "extractionState": "manual", "localizations": { diff --git a/SKILL.md b/SKILL.md index 0adb776d..66b039df 100644 --- a/SKILL.md +++ b/SKILL.md @@ -213,6 +213,30 @@ Use the same panel that renders `programa markdown open` for it, so lean on its `programa recap list` shows the slugs already saved. Keep the recap itself short and plain, the same way you'd summarize the change in chat. +## Browser work + +Two browsers, two jobs. Do not reach for a Chrome extension for either. + +- **Local previews, smoke tests, screenshots you read back, DOM checks, console errors:** use programa's embedded browser. It opens beside your pane, keeps its own profile, and never moves the user's focus: + + ```bash + programa browser open-split http://localhost:3000 # prints the new surface id + programa browser --surface surface:7 snapshot --interactive # interactive elements only + programa browser --surface surface:7 click "button.submit" --snapshot-after + programa browser --surface surface:7 screenshot --out /tmp/after.png + programa browser --surface surface:7 tab close + ``` + + `programa browser --help` lists the rest (wait, fill, eval, cookies, console, errors). Network routing, viewport, and raw input injection are not supported on WKWebView and return `not_supported`. Over MCP the same calls are the `browser_*` tools of `programa-mcp`. + +- **Logged-in sites, private dashboards, CI logs, anything that needs the user's real browser profile:** use Aside through its MCP server if it is registered (tools from the `aside` server, or `aside-devtools` for raw Chrome DevTools control), or delegate a whole task from the shell: + + ```bash + aside "Open the staging dashboard and tell me whether the last deploy is green" + ``` + + `programa aside status` says whether Aside is installed and registered; `programa aside install-mcp` registers it with Claude Code and Codex. Do not run the installer yourself unless the user asks, it edits their agent config. + ## Reference - `--workspace`/`--surface`/`--pane`/`--window` accept either a short ref (`workspace:2`, `surface:4`) or a raw UUID; omitted, they default to `$PROGRAMA_WORKSPACE_ID`/`$PROGRAMA_SURFACE_ID`. diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 08d0a4d2..af5dae52 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -4756,6 +4756,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser return workspace.id } + /// Opens a new workspace and runs `programa aside install-mcp --with-devtools` in it, + /// mirroring openOpenCodeIntegrationInstaller: no working-directory override, since + /// the installer targets the user's global Claude Code / Codex MCP config, not a project. + @discardableResult + func openAsideMCPInstaller(event: NSEvent? = nil, debugSource: String = "unspecified") -> UUID? { + discardOrphanedMainWindowContexts() + guard let context = preferredMainWindowContextForWorkspaceCreation(event: event, debugSource: debugSource) else { + openNewMainWindow(nil) + return nil + } + guard let window = resolvedWindow(for: context) else { + discardOrphanedMainWindowContext(context) + openNewMainWindow(nil) + return nil + } + setActiveMainWindow(window) + + let workspace = context.tabManager.addWorkspace( + initialTerminalInput: "programa aside install-mcp --with-devtools\n", + select: true + ) + return workspace.id + } + private func preferredMainWindowContextForWorkspaceCreation( event: NSEvent? = nil, debugSource: String = "unspecified" diff --git a/Sources/GhosttyApp.swift b/Sources/GhosttyApp.swift index 35936d21..3484dfc5 100644 --- a/Sources/GhosttyApp.swift +++ b/Sources/GhosttyApp.swift @@ -2190,7 +2190,7 @@ class GhosttyApp { dlog("link.openURL cmuxBrowser=disabled, opening externally url=\(target.url)") #endif return performOnMain { - NSWorkspace.shared.open(target.url) + BrowserLinkOpenSettings.openExternally(target.url) } } switch target { @@ -2199,7 +2199,7 @@ class GhosttyApp { dlog("link.openURL target=external, opening externally url=\(url)") #endif return performOnMain { - NSWorkspace.shared.open(url) + BrowserLinkOpenSettings.openExternally(url) } case let .embeddedBrowser(url): if BrowserLinkOpenSettings.shouldOpenExternally(url) { @@ -2207,7 +2207,7 @@ class GhosttyApp { dlog("link.openURL target=embedded but shouldOpenExternally=true url=\(url)") #endif return performOnMain { - NSWorkspace.shared.open(url) + BrowserLinkOpenSettings.openExternally(url) } } guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { @@ -2215,7 +2215,7 @@ class GhosttyApp { dlog("link.openURL target=embedded but normalizeHost=nil host=\(url.host ?? "nil") url=\(url)") #endif return performOnMain { - NSWorkspace.shared.open(url) + BrowserLinkOpenSettings.openExternally(url) } } @@ -2225,7 +2225,7 @@ class GhosttyApp { dlog("link.openURL target=embedded but hostWhitelist miss host=\(host) url=\(url)") #endif return performOnMain { - NSWorkspace.shared.open(url) + BrowserLinkOpenSettings.openExternally(url) } } let sourceWorkspaceId = callbackTabId @@ -2262,7 +2262,7 @@ class GhosttyApp { "tabId=\(sourceWorkspaceId) surfaceId=\(sourcePanelId)" ) #endif - NSWorkspace.shared.open(url) + BrowserLinkOpenSettings.openExternally(url) return } let workspace = resolved.workspace @@ -2279,14 +2279,14 @@ class GhosttyApp { dlog("link.openURL opening in existing browser pane=\(targetPane)") #endif if workspace.newBrowserSurface(inPane: targetPane, url: url, focus: true) == nil { - NSWorkspace.shared.open(url) + BrowserLinkOpenSettings.openExternally(url) } } else { #if DEBUG dlog("link.openURL opening as new browser split from surface=\(sourcePanelId)") #endif if workspace.newBrowserSplit(from: sourcePanelId, orientation: .horizontal, url: url) == nil { - NSWorkspace.shared.open(url) + BrowserLinkOpenSettings.openExternally(url) } } } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index bcaac8fa..11f2650e 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -466,7 +466,7 @@ final class BrowserPanel: Panel, ObservableObject { BrowserPasskeyHandoffAlertBuilder.configure(alert) let handleResponse: @MainActor @Sendable (NSApplication.ModalResponse) -> Void = { response in guard response == .alertFirstButtonReturn else { return } - NSWorkspace.shared.open(url) + BrowserLinkOpenSettings.openExternally(url) } if let alertWindow = insecureHTTPAlertWindowProvider() { alert.beginSheetModal(for: alertWindow, completionHandler: handleResponse) @@ -2553,7 +2553,7 @@ final class BrowserPanel: Panel, ObservableObject { } switch response { case .alertFirstButtonReturn: - NSWorkspace.shared.open(url) + BrowserLinkOpenSettings.openExternally(url) case .alertSecondButtonReturn: switch intent { case .currentTab: diff --git a/Sources/Panels/BrowserPopupWindowController.swift b/Sources/Panels/BrowserPopupWindowController.swift index e33c8505..73e9b98c 100644 --- a/Sources/Panels/BrowserPopupWindowController.swift +++ b/Sources/Panels/BrowserPopupWindowController.swift @@ -240,7 +240,7 @@ final class BrowserPopupWindowController: NSObject, NSWindowDelegate { if let opener = self?.openerPanel { opener.openLinkInNewTab(url: url) } else { - NSWorkspace.shared.open(url) + BrowserLinkOpenSettings.openExternally(url) } } @@ -369,7 +369,7 @@ final class BrowserPopupWindowController: NSObject, NSWindowDelegate { if let openerPanel { openerPanel.openLinkInNewTab(url: url) } else { - NSWorkspace.shared.open(url) + BrowserLinkOpenSettings.openExternally(url) } } @@ -384,7 +384,7 @@ final class BrowserPopupWindowController: NSObject, NSWindowDelegate { let alert = BrowserPasskeyHandoffAlertBuilder.makeAlert() let handleResponse: @MainActor @Sendable (NSApplication.ModalResponse) -> Void = { response in guard response == .alertFirstButtonReturn else { return } - NSWorkspace.shared.open(url) + BrowserLinkOpenSettings.openExternally(url) } alert.beginSheetModal(for: panel, completionHandler: handleResponse) } @@ -454,7 +454,7 @@ private class PopupUIDelegate: NSObject, WKUIDelegate { // External URL check if let url = navigationAction.request.url, browserShouldOpenURLExternally(url) { - NSWorkspace.shared.open(url) + BrowserLinkOpenSettings.openExternally(url) return nil } diff --git a/Sources/Panels/BrowserSettings.swift b/Sources/Panels/BrowserSettings.swift index 49cd7abf..26ee706a 100644 --- a/Sources/Panels/BrowserSettings.swift +++ b/Sources/Panels/BrowserSettings.swift @@ -217,6 +217,9 @@ enum BrowserLinkOpenSettings { static let browserExternalOpenPatternsKey = "browserExternalOpenPatterns" static let defaultBrowserExternalOpenPatterns: String = "" + static let externalBrowserBundleIdentifierKey = "browserExternalBrowserBundleIdentifier" + static let defaultExternalBrowserBundleIdentifier: String = "" + static func openTerminalLinksInProgramaBrowser(defaults: UserDefaults = .standard) -> Bool { if defaults.object(forKey: openTerminalLinksInProgramaBrowserKey) == nil { return defaultOpenTerminalLinksInProgramaBrowser @@ -281,6 +284,47 @@ enum BrowserLinkOpenSettings { return false } + static func externalBrowserBundleIdentifier(defaults: UserDefaults = .standard) -> String { + let raw = defaults.string(forKey: externalBrowserBundleIdentifierKey) ?? defaultExternalBrowserBundleIdentifier + return raw.trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func externalBrowserApplicationURL(bundleIdentifier: String, workspace: NSWorkspace = .shared) -> URL? { + let trimmed = bundleIdentifier.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return workspace.urlForApplication(withBundleIdentifier: trimmed) + } + + @discardableResult + static func openExternally(_ url: URL, defaults: UserDefaults = .standard, workspace: NSWorkspace = .shared) -> Bool { + let bundleIdentifier = externalBrowserBundleIdentifier(defaults: defaults) + if let appURL = externalBrowserApplicationURL(bundleIdentifier: bundleIdentifier, workspace: workspace) { + let configuration = NSWorkspace.OpenConfiguration() + workspace.open([url], withApplicationAt: appURL, configuration: configuration) + return true + } + return workspace.open(url) + } + + static func installedBrowsers(workspace: NSWorkspace = .shared) -> [(bundleIdentifier: String, name: String)] { + guard let exampleURL = URL(string: "https://example.com") else { return [] } + let ownBundleIdentifier = Bundle.main.bundleIdentifier + var seen = Set() + var results: [(bundleIdentifier: String, name: String)] = [] + for appURL in workspace.urlsForApplications(toOpen: exampleURL) { + guard let bundleIdentifier = Bundle(url: appURL)?.bundleIdentifier else { continue } + if bundleIdentifier == ownBundleIdentifier { continue } + guard !seen.contains(bundleIdentifier) else { continue } + seen.insert(bundleIdentifier) + var name = FileManager.default.displayName(atPath: appURL.path) + if name.hasSuffix(".app") { + name = String(name.dropLast(4)) + } + results.append((bundleIdentifier: bundleIdentifier, name: name)) + } + return results.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + /// Check whether a hostname matches the configured whitelist. /// Empty whitelist means "allow all" (no filtering). /// Supports exact match and wildcard prefix (`*.example.com`). diff --git a/Sources/Panels/ProgramaWebView.swift b/Sources/Panels/ProgramaWebView.swift index 881eeeeb..56bb4494 100644 --- a/Sources/Panels/ProgramaWebView.swift +++ b/Sources/Panels/ProgramaWebView.swift @@ -1171,7 +1171,7 @@ final class ProgramaWebView: WKWebView { _ = contextMenuDefaultBrowserOpener(url) return } - _ = NSWorkspace.shared.open(url) + _ = BrowserLinkOpenSettings.openExternally(url) } private func runContextMenuFallback( diff --git a/Sources/ProgramaApp.swift b/Sources/ProgramaApp.swift index bcd2b2f3..8df4e9eb 100644 --- a/Sources/ProgramaApp.swift +++ b/Sources/ProgramaApp.swift @@ -555,6 +555,15 @@ struct programaApp: App { ) { AppDelegate.shared?.openOpenCodeIntegrationInstaller(debugSource: "menu.installOpenCodeIntegration") } + + Button( + String( + localized: "menu.file.installAsideMCP", + defaultValue: "Install Aside Browser MCP…" + ) + ) { + AppDelegate.shared?.openAsideMCPInstaller(debugSource: "menu.installAsideMCP") + } } // Close tab/workspace diff --git a/Sources/ProgramaSettingsFileStore.swift b/Sources/ProgramaSettingsFileStore.swift index 333d024f..da98e2f7 100644 --- a/Sources/ProgramaSettingsFileStore.swift +++ b/Sources/ProgramaSettingsFileStore.swift @@ -781,6 +781,14 @@ final class ProgramaSettingsFileStore { } else if section.keys.contains("urlsToAlwaysOpenExternally") { logInvalid("browser.urlsToAlwaysOpenExternally", sourcePath: sourcePath) } + if let raw = section["externalBrowser"] { + if let value = jsonString(raw) { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + snapshot.managedUserDefaults[BrowserLinkOpenSettings.externalBrowserBundleIdentifierKey] = .string(trimmed) + } else { + logInvalid("browser.externalBrowser", sourcePath: sourcePath) + } + } if let values = jsonStringArray(section["insecureHttpHostsAllowedInEmbeddedBrowser"]) { let normalized = values .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } @@ -1618,6 +1626,7 @@ final class ProgramaSettingsFileStore { "interceptTerminalOpenCommandInProgramaBrowser": BrowserLinkOpenSettings.defaultInterceptTerminalOpenCommandInProgramaBrowser, "hostsToOpenInEmbeddedBrowser": [String](), "urlsToAlwaysOpenExternally": [String](), + "externalBrowser": BrowserLinkOpenSettings.defaultExternalBrowserBundleIdentifier, "insecureHttpHostsAllowedInEmbeddedBrowser": BrowserInsecureHTTPSettings.defaultAllowlistPatterns, ], ], diff --git a/Sources/SettingsView.swift b/Sources/SettingsView.swift index 47a386ab..69f3b09f 100644 --- a/Sources/SettingsView.swift +++ b/Sources/SettingsView.swift @@ -62,6 +62,8 @@ struct SettingsView: View { @AppStorage(BrowserLinkOpenSettings.browserHostWhitelistKey) private var browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist @AppStorage(BrowserLinkOpenSettings.browserExternalOpenPatternsKey) private var browserExternalOpenPatterns = BrowserLinkOpenSettings.defaultBrowserExternalOpenPatterns + @AppStorage(BrowserLinkOpenSettings.externalBrowserBundleIdentifierKey) + private var externalBrowserBundleIdentifier = BrowserLinkOpenSettings.defaultExternalBrowserBundleIdentifier @AppStorage(BrowserInsecureHTTPSettings.allowlistKey) private var browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText @AppStorage(NotificationSoundSettings.key) private var notificationSound = NotificationSoundSettings.defaultValue @AppStorage(NotificationSoundSettings.customCommandKey) private var notificationCustomCommand = NotificationSoundSettings.defaultCustomCommand @@ -105,6 +107,7 @@ struct SettingsView: View { @State private var socketPasswordStatusMessage: String? @State private var socketPasswordStatusIsError = false @State private var trustedDirectoriesDraft: String = ProgramaDirectoryTrust.shared.allTrustedPaths.joined(separator: "\n") + @State private var installedExternalBrowsers: [(bundleIdentifier: String, name: String)] = [] private var selectedWorkspacePlacement: NewWorkspacePlacement { NewWorkspacePlacement(rawValue: newWorkspacePlacement) ?? WorkspacePlacementSettings.defaultPlacement @@ -1205,6 +1208,23 @@ struct SettingsView: View { .controlSize(.small) } + SettingsCardDivider() + + SettingsPickerRow( + String(localized: "settings.browser.externalBrowser", defaultValue: "Open External Links With"), + subtitle: String(localized: "settings.browser.externalBrowser.subtitle", defaultValue: "Used for terminal links and browser links that open outside Programa."), + controlWidth: pickerColumnWidth, + selection: $externalBrowserBundleIdentifier + ) { + Text(String(localized: "settings.browser.externalBrowser.systemDefault", defaultValue: "System Default")).tag("") + ForEach(installedExternalBrowsers, id: \.bundleIdentifier) { browser in + Text(browser.name).tag(browser.bundleIdentifier) + } + } + .onAppear { + installedExternalBrowsers = BrowserLinkOpenSettings.installedBrowsers() + } + if openTerminalLinksInProgramaBrowser || interceptTerminalOpenCommandInProgramaBrowser { SettingsCardDivider() @@ -1430,6 +1450,7 @@ struct SettingsView: View { interceptTerminalOpenCommandInProgramaBrowser = BrowserLinkOpenSettings.defaultInterceptTerminalOpenCommandInProgramaBrowser browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist browserExternalOpenPatterns = BrowserLinkOpenSettings.defaultBrowserExternalOpenPatterns + externalBrowserBundleIdentifier = BrowserLinkOpenSettings.defaultExternalBrowserBundleIdentifier browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText notificationSound = NotificationSoundSettings.defaultValue diff --git a/Sources/TabItemView.swift b/Sources/TabItemView.swift index 73da3314..13142aa4 100644 --- a/Sources/TabItemView.swift +++ b/Sources/TabItemView.swift @@ -1478,11 +1478,11 @@ struct TabItemView: View, Equatable { preferSplitRight: true, insertAtEnd: true ) == nil { - NSWorkspace.shared.open(url) + BrowserLinkOpenSettings.openExternally(url) } return } - NSWorkspace.shared.open(url) + BrowserLinkOpenSettings.openExternally(url) } private func openPortLink(_ port: Int) { @@ -1495,11 +1495,11 @@ struct TabItemView: View, Equatable { preferSplitRight: true, insertAtEnd: true ) == nil { - NSWorkspace.shared.open(url) + BrowserLinkOpenSettings.openExternally(url) } return } - NSWorkspace.shared.open(url) + BrowserLinkOpenSettings.openExternally(url) } private func pullRequestStatusLabel( @@ -1982,7 +1982,7 @@ private struct SidebarMetadataEntryRow: View { if let url = entry.url { Button { onFocus() - NSWorkspace.shared.open(url) + BrowserLinkOpenSettings.openExternally(url) } label: { rowContent(underlined: true) } diff --git a/Sources/TerminalController+BrowserAutomation.swift b/Sources/TerminalController+BrowserAutomation.swift index 25424365..bb1f06b2 100644 --- a/Sources/TerminalController+BrowserAutomation.swift +++ b/Sources/TerminalController+BrowserAutomation.swift @@ -2071,7 +2071,7 @@ extension TerminalController { if let url, respectExternalOpenRules, BrowserLinkOpenSettings.shouldOpenExternally(url) { - guard NSWorkspace.shared.open(url) else { + guard BrowserLinkOpenSettings.openExternally(url) else { result = .err( code: "external_open_failed", message: "Failed to open URL externally", diff --git a/docs/agent-skill.md b/docs/agent-skill.md index 940b7fcb..f2eb9356 100644 --- a/docs/agent-skill.md +++ b/docs/agent-skill.md @@ -35,6 +35,7 @@ The first thing the skill does is check `PROGRAMA_SURFACE_ID` and `PROGRAMA_SOCK - **Waiting** — `wait-surface` gives a server-owned blocking wait on a surface: `--pattern ` resolves when new output matches, `--exit` when the process exits, both with `--timeout`. Waiting on *agent state* (idle/working/blocked) is implemented too — the underlying `surface.wait` socket method takes an `agent_state` condition (see `docs/v2-api-migration.md`), and `prompt-agent` is built directly on it to send a prompt and wait for the agent to go idle again. The tmux-compatible `wait-for` / `wait-for -S` named-signal rendezvous covers two cooperating processes. - **Authoring screen-detection manifests for an unsupported agent** — programa's agent-state detection reads a pane's own lifecycle hooks when available (Claude Code, Codex, OpenCode), and otherwise falls back to regex-matching visible screen text against a manifest. For any other agent, `programa agent-detection scaffold ` captures the current screen and writes a starter manifest to `~/.config/programa/agent-detection/.json`; fill in its `patterns` arrays, then `programa agent-detection test ` to check them against the live screen. `programa agent-detection list` shows every loaded manifest (bundled + your overrides). - **Writing a recap** — when the user asks for a summary of a change, write markdown to `.programa/recaps/.md` and open it with `programa recap open ` (`programa recap list` shows what's saved). The markdown panel renders mermaid diagrams, GitHub-style alerts, and `:::compare` before/after blocks, so the skill tells the agent to reach for those instead of plain prose when they fit. +- **Browser work** — two browsers for two jobs. `programa browser open-split` / `snapshot` / `click` / `screenshot` drive programa's embedded browser for local previews and smoke tests without moving focus; Aside (via its MCP server, or `aside ""` from the shell) handles logged-in sites. `programa aside install-mcp` registers Aside with Claude Code and Codex. See [`docs/aside-browser.md`](aside-browser.md). ## Verifying it @@ -42,4 +43,4 @@ There's no automated test for "does an agent actually behave correctly" — this ## Command reference -The skill only covers the commands relevant to agent coordination. For the full CLI surface (the in-app browser, tmux-compat commands, hooks) run `programa help`, or see [`docs/v2-api-migration.md`](v2-api-migration.md) for the underlying socket API. +The skill only covers the commands relevant to agent coordination. For the full CLI surface (the in-app browser, tmux-compat commands, hooks) run `programa help`, see [`aside-browser.md`](aside-browser.md) for the browser split between programa's panel and Aside, or see [`docs/v2-api-migration.md`](v2-api-migration.md) for the underlying socket API. diff --git a/docs/aside-browser.md b/docs/aside-browser.md new file mode 100644 index 00000000..82ede5b5 --- /dev/null +++ b/docs/aside-browser.md @@ -0,0 +1,81 @@ +# Two browsers for agents: Programa's panel and Aside + +Coding agents need a browser for two different jobs, and Programa treats them as two +different tools: + +| Job | Use | Why | +| --- | --- | --- | +| Local previews, smoke tests, screenshots the agent reads back, DOM inspection, console and error logs | **Programa's embedded browser** | It lives next to the agent's pane, has its own per-workspace profile, never moves the user's focus, and every action is a socket method (`browser.*`) that the CLI and `programa-mcp` both expose. | +| Logged-in sites, private dashboards, CI logs, anything that depends on your real browsing profile and memory | **Aside** ([aside.com](https://aside.com)), a Chromium-based agent browser | Aside owns the sessions and cookies. Programa registers it with Claude Code and Codex so the agent can hand that work off instead of driving a Chrome extension. | + +Neither replaces the other. Programa's panel is WKWebView, so it has no Chrome DevTools +Protocol and does not share cookies with Aside. Aside has the logins but is not a pane in +your workspace. + +## Programa's browser from an agent + +From a shell inside a Programa pane, the `programa browser` CLI covers the whole surface: + +```bash +programa browser open-split https://localhost:3000 # new browser split beside this pane +programa browser --surface surface:7 snapshot --interactive # accessibility tree of interactive elements +programa browser --surface surface:7 click "button.submit" --snapshot-after +programa browser --surface surface:7 screenshot --out /tmp/after.png +programa browser --surface surface:7 tab close +``` + +Over MCP, the same methods are the `browser_*` tools in `programa-mcp` (`browser_open_split`, +`browser_navigate`, `browser_snapshot`, `browser_get_text`, `browser_screenshot`, +`browser_console_list`, and so on). A few Playwright-shaped tools (network routing, viewport, +raw input injection) return `not_supported` because WKWebView has no DevTools Protocol. Only the three `focus_browser_*` tools move focus; +everything else leaves the user where they are. See [mcp-server.md](mcp-server.md) for the +full list and the setup. + +## Aside from an agent + +Aside ships a CLI (`aside`) and an MCP server (`aside mcp`, stdio). Programa can register +that server with Claude Code and Codex for you: + +```bash +programa aside status # where the aside binary is, whether Aside is running, what is registered +programa aside install-mcp # registers `aside` (aside mcp) with Claude Code and Codex +programa aside install-mcp --with-devtools +programa aside uninstall-mcp +``` + +`File > Install Aside Browser MCP…` runs the same installer in a new workspace. + +The `--with-devtools` flag adds a second server named `aside-devtools`. While Aside is +running it exposes the Chrome DevTools Protocol on `127.0.0.1:9223`, and +`chrome-devtools-mcp` pointed at that URL gives an agent full DevTools control of Aside's +tabs (navigation, clicks, console, network, performance traces) with no extension involved. +`programa aside status` reports the endpoint when it is reachable. + +Once registered, an agent can also delegate a whole task from a pane without MCP: + +```bash +aside "Open the staging dashboard and check whether the last deploy is green" +aside --session "Continue and report any failures" +``` + +Install the Aside CLI from Settings > Developer inside Aside, or with the command on +[docs.aside.com/help/developers](https://docs.aside.com/help/developers). Programa looks for +it at `~/.local/bin/aside`, then `~/.aside/cli/Aside CLI.app/Contents/MacOS/aside`, then on +`PATH`. + +## Sending links to Aside + +Terminal links open in Programa's panel when the host is local or allowlisted, and in an +external browser otherwise. `Settings > Browser > Open External Links With` picks that +external browser; choose Aside there and every cmd-click that leaves Programa lands in the +browser that has your logins and agent memory. The same setting is `browser.externalBrowser` +in `~/.config/programa/settings.json` (a bundle identifier; empty means the macOS default). + +## Aside driving Programa + +The reverse direction, Aside dispatching work into a Claude Code or Codex pane, needs Aside +to act as an MCP client. Aside does not document that today. When it does, point it at +`Programa.app/Contents/Resources/bin/programa-mcp`: the `surface_send_text`, +`surface_read_text`, `surface_split`, and `surface_wait` tools already let a client start an +agent in a pane, send it a prompt, wait for it to go idle, and read the result, without +scraping a terminal screen. Nothing on Programa's side needs to change for that. diff --git a/docs/settings-json.md b/docs/settings-json.md index 6060596e..ba6ea63e 100644 --- a/docs/settings-json.md +++ b/docs/settings-json.md @@ -92,6 +92,7 @@ Embedded browser settings from Settings > Browser. | `interceptTerminalOpenCommandInProgramaBrowser` | boolean | `true` | Intercept terminal open http(s) commands and route them through the embedded browser. | | `hostsToOpenInEmbeddedBrowser` | array | `[]` | Allowlist of hosts that should stay inside the embedded browser. | | `urlsToAlwaysOpenExternally` | array | `[]` | Rules that always open matching URLs in the system browser. | +| `externalBrowser` | string | `""` | Bundle identifier of the browser used for links that open outside Programa (for example `at.studio.AsideBrowser`, replace with the real Aside bundle id you discovered). Empty uses the macOS default browser. | | `insecureHttpHostsAllowedInEmbeddedBrowser` | array | `["localhost", "127.0.0.1", "::1", "0.0.0.0", "*.localtest.me"]` | HTTP hosts allowed in the embedded browser without a warning prompt. | | `proxy` | object | | Route the embedded browser through a proxy. Requires host and port; type defaults to socks5. | diff --git a/programaTests/BrowserConfigTests.swift b/programaTests/BrowserConfigTests.swift index 17ba4edf..8289fb56 100644 --- a/programaTests/BrowserConfigTests.swift +++ b/programaTests/BrowserConfigTests.swift @@ -4125,6 +4125,75 @@ final class BrowserLinkOpenSettingsTests: XCTestCase { ) ) } + + func testExternalBrowserApplicationURLReturnsNilForEmptyBundleIdentifier() { + XCTAssertNil(BrowserLinkOpenSettings.externalBrowserApplicationURL(bundleIdentifier: "")) + } + + func testExternalBrowserApplicationURLReturnsNilForUnknownBundleIdentifier() { + XCTAssertNil( + BrowserLinkOpenSettings.externalBrowserApplicationURL(bundleIdentifier: "com.example.does-not-exist") + ) + } + + func testExternalBrowserApplicationURLResolvesInstalledApplication() throws { + let url = try XCTUnwrap( + BrowserLinkOpenSettings.externalBrowserApplicationURL(bundleIdentifier: "com.apple.Safari") + ) + XCTAssertTrue(url.path.hasSuffix(".app")) + } + + func testOpenExternallyFallsBackToSystemOpenForUnknownBundleIdentifier() { + defaults.set("com.example.does-not-exist", forKey: BrowserLinkOpenSettings.externalBrowserBundleIdentifierKey) + let workspace = BrowserExternalOpenRecordingWorkspace() + let url = try! XCTUnwrap(URL(string: "https://example.com")) + + let opened = BrowserLinkOpenSettings.openExternally(url, defaults: defaults, workspace: workspace) + + XCTAssertTrue(opened) + XCTAssertEqual(workspace.openedURLs, [url]) + XCTAssertTrue(workspace.openedWithApplicationURLs.isEmpty) + } + + func testOpenExternallyLaunchesResolvedApplicationForKnownBundleIdentifier() throws { + defaults.set("com.apple.Safari", forKey: BrowserLinkOpenSettings.externalBrowserBundleIdentifierKey) + let workspace = BrowserExternalOpenRecordingWorkspace() + workspace.applicationURLOverride = URL(fileURLWithPath: "/Applications/Safari.app") + let url = try XCTUnwrap(URL(string: "https://example.com")) + + let opened = BrowserLinkOpenSettings.openExternally(url, defaults: defaults, workspace: workspace) + + XCTAssertTrue(opened) + XCTAssertTrue(workspace.openedURLs.isEmpty) + XCTAssertEqual(workspace.openedWithApplicationURLs.map(\.0), [url]) + XCTAssertEqual(workspace.openedWithApplicationURLs.map(\.1), [workspace.applicationURLOverride]) + } +} + +private final class BrowserExternalOpenRecordingWorkspace: NSWorkspace { + var openedURLs: [URL] = [] + var openedWithApplicationURLs: [(URL, URL)] = [] + var applicationURLOverride: URL? + + override func open(_ url: URL) -> Bool { + openedURLs.append(url) + return true + } + + override func urlForApplication(withBundleIdentifier bundleIdentifier: String) -> URL? { + applicationURLOverride + } + + override func open( + _ urls: [URL], + withApplicationAt applicationURL: URL, + configuration: NSWorkspace.OpenConfiguration, + completionHandler: (@Sendable (NSRunningApplication?, Error?) -> Void)? = nil + ) { + for url in urls { + openedWithApplicationURLs.append((url, applicationURL)) + } + } } diff --git a/programaTests/WorkspaceUnitTests.swift b/programaTests/WorkspaceUnitTests.swift index 3ff5c709..443bfdff 100644 --- a/programaTests/WorkspaceUnitTests.swift +++ b/programaTests/WorkspaceUnitTests.swift @@ -1445,6 +1445,62 @@ final class TerminalThemeSettingsTests: XCTestCase { XCTAssertEqual(reloadRequestCount, 2) } + func testSettingsFileMapsExternalBrowserToManagedDefaults() throws { + let defaults = UserDefaults.standard + let previousValue = defaults.string(forKey: BrowserLinkOpenSettings.externalBrowserBundleIdentifierKey) + defer { + restoreDefaultsValue( + previousValue, + key: BrowserLinkOpenSettings.externalBrowserBundleIdentifierKey, + defaults: defaults + ) + } + + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let settingsURL = directoryURL.appendingPathComponent("settings.json", isDirectory: false) + let configURL = directoryURL.appendingPathComponent("config.ghostty", isDirectory: false) + let themeStore = TerminalThemeStore( + fileManager: .default, + managedConfigURL: configURL, + configSearchURLs: [configURL] + ) + try writeSettingsFile( + """ + { + "browser": { + "externalBrowser": "com.apple.Safari" + } + } + """, + to: settingsURL + ) + + _ = ProgramaSettingsFileStore( + primaryPath: settingsURL.path, + fallbackPath: nil, + fileManager: .default, + notificationCenter: .default, + terminalThemeStore: themeStore, + terminalThemeReloadHandler: {}, + startWatching: false + ) + + XCTAssertEqual( + defaults.string(forKey: BrowserLinkOpenSettings.externalBrowserBundleIdentifierKey), + "com.apple.Safari" + ) + } + + private func restoreDefaultsValue(_ value: Any?, key: String, defaults: UserDefaults) { + if let value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + func testAppearanceOverridesRoundTripAllFourDirectives() throws { let directoryURL = try makeTemporaryDirectory() defer { try? FileManager.default.removeItem(at: directoryURL) } From 20b6002b1192b32e3962e4e8dd8cb74313cc4e8a Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 3 Sep 2026 12:30:44 -0300 Subject: [PATCH 2/2] fix: scope the preferred browser to web links and harden the Aside installer Only http(s) links go to the chosen external browser; mailto:, slack:// and other schemes keep their macOS handler. The popup-window insecure-HTTP alert uses the same opener. programa aside uninstall-mcp no longer needs the Aside binary, keeps going when one client fails and reports all failures at the end, and a closed stdin at the confirmation prompt aborts instead of proceeding. --- CLI/CLI+Aside.swift | 42 ++++++++++++++----- .../Panels/BrowserPopupWindowController.swift | 4 +- Sources/Panels/BrowserSettings.swift | 15 ++++++- programaTests/BrowserConfigTests.swift | 14 +++++++ 4 files changed, 61 insertions(+), 14 deletions(-) diff --git a/CLI/CLI+Aside.swift b/CLI/CLI+Aside.swift index 574df4eb..c5b0b4a6 100644 --- a/CLI/CLI+Aside.swift +++ b/CLI/CLI+Aside.swift @@ -172,14 +172,16 @@ extension ProgramaCLI { case "install-mcp", "uninstall-mcp": let install = subcommand == "install-mcp" - guard let asidePath else { + // Removal only needs the client CLIs; the Aside binary may already be gone. + if install, asidePath == nil { throw CLIError(message: "Aside CLI not found. Install it from https://docs.aside.com/help/developers") } + let planAsidePath = asidePath ?? "aside" if claudeExecutable == nil, codexExecutable == nil { throw CLIError(message: "Neither Claude Code nor Codex CLI was found on PATH.") } - print("Aside CLI: \(asidePath)") + print("Aside CLI: \(asidePath ?? "not found")") if let endpoint = asideDevToolsEndpoint() { print("DevTools: \(endpoint)") } @@ -191,7 +193,7 @@ extension ProgramaCLI { } let plan = AsideMCPPlan.build( - asidePath: asidePath, + asidePath: planAsidePath, claudeExecutable: claudeExecutable, codexExecutable: codexExecutable, withDevTools: withDevTools, @@ -221,25 +223,45 @@ extension ProgramaCLI { if !skipConfirm { print("Apply these changes? [Y/n] ", terminator: "") - if let response = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), - !response.isEmpty && response != "y" && response != "yes" { + // EOF (closed or non-interactive stdin) counts as "no": never fail open. + guard let response = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() else { + print("") + print("Aborted (no confirmation on stdin; pass --yes to skip the prompt).") + return + } + if !response.isEmpty && response != "y" && response != "yes" { print("Aborted.") return } } + var failures: [String] = [] for entry in pendingCommands { - if install, let executable = entry.command.first, - asideIsRegistered(executable: executable, serverName: entry.serverName) { + guard let executable = entry.command.first else { continue } + let registered = asideIsRegistered(executable: executable, serverName: entry.serverName) + if install, registered { print("\(entry.client): \(entry.serverName) already registered, skipping") continue } + if !install, !registered { + print("\(entry.client): \(entry.serverName) not registered, skipping") + continue + } print("Running: \(entry.command.joined(separator: " "))") - try asideRunCommand(entry.command) + do { + try asideRunCommand(entry.command) + } catch { + // Keep going so one client's failure never leaves the other client's + // registration untouched; report everything at the end. + failures.append("\(entry.client) \(entry.serverName): \(error)") + } } print("") - print(install ? "Installed." : "Removed.") - return + if failures.isEmpty { + print(install ? "Installed." : "Removed.") + return + } + throw CLIError(message: (install ? "Some registrations failed:\n " : "Some removals failed:\n ") + failures.joined(separator: "\n ")) default: print("Usage: programa aside [--with-devtools] [--yes]") diff --git a/Sources/Panels/BrowserPopupWindowController.swift b/Sources/Panels/BrowserPopupWindowController.swift index 73e9b98c..e45f6348 100644 --- a/Sources/Panels/BrowserPopupWindowController.swift +++ b/Sources/Panels/BrowserPopupWindowController.swift @@ -414,8 +414,8 @@ final class BrowserPopupWindowController: NSObject, NSWindowDelegate { } switch response { case .alertFirstButtonReturn: - // Open in default browser, cancel popup navigation - NSWorkspace.shared.open(url) + // Open outside Programa, cancel popup navigation + BrowserLinkOpenSettings.openExternally(url) decisionHandler(.cancel) case .alertSecondButtonReturn: // Proceed in popup diff --git a/Sources/Panels/BrowserSettings.swift b/Sources/Panels/BrowserSettings.swift index 26ee706a..e7e7e665 100644 --- a/Sources/Panels/BrowserSettings.swift +++ b/Sources/Panels/BrowserSettings.swift @@ -295,12 +295,23 @@ enum BrowserLinkOpenSettings { return workspace.urlForApplication(withBundleIdentifier: trimmed) } + /// Opens a web link outside Programa. Only http(s) URLs go to the preferred browser; + /// every other scheme (mailto:, slack://, file:) keeps its macOS-registered handler. + /// The preferred-browser launch is asynchronous, so `true` means the launch was + /// dispatched, not that it finished; a launch error is logged, not surfaced. @discardableResult static func openExternally(_ url: URL, defaults: UserDefaults = .standard, workspace: NSWorkspace = .shared) -> Bool { + let scheme = url.scheme?.lowercased() + let isWebLink = scheme == "http" || scheme == "https" let bundleIdentifier = externalBrowserBundleIdentifier(defaults: defaults) - if let appURL = externalBrowserApplicationURL(bundleIdentifier: bundleIdentifier, workspace: workspace) { + if isWebLink, + let appURL = externalBrowserApplicationURL(bundleIdentifier: bundleIdentifier, workspace: workspace) { let configuration = NSWorkspace.OpenConfiguration() - workspace.open([url], withApplicationAt: appURL, configuration: configuration) + workspace.open([url], withApplicationAt: appURL, configuration: configuration) { _, error in + if let error { + NSLog("BrowserLinkOpenSettings.openExternally: launching %@ for %@ failed: %@", appURL.path, url.absoluteString, error.localizedDescription) + } + } return true } return workspace.open(url) diff --git a/programaTests/BrowserConfigTests.swift b/programaTests/BrowserConfigTests.swift index 8289fb56..9425e860 100644 --- a/programaTests/BrowserConfigTests.swift +++ b/programaTests/BrowserConfigTests.swift @@ -4168,6 +4168,20 @@ final class BrowserLinkOpenSettingsTests: XCTestCase { XCTAssertEqual(workspace.openedWithApplicationURLs.map(\.0), [url]) XCTAssertEqual(workspace.openedWithApplicationURLs.map(\.1), [workspace.applicationURLOverride]) } + + func testOpenExternallyKeepsNonWebSchemesOnSystemHandlerEvenWithPreferredBrowser() throws { + defaults.set("com.apple.Safari", forKey: BrowserLinkOpenSettings.externalBrowserBundleIdentifierKey) + let workspace = BrowserExternalOpenRecordingWorkspace() + workspace.applicationURLOverride = URL(fileURLWithPath: "/Applications/Safari.app") + let mailto = try XCTUnwrap(URL(string: "mailto:someone@example.com")) + let deepLink = try XCTUnwrap(URL(string: "slack://open?team=T1")) + + XCTAssertTrue(BrowserLinkOpenSettings.openExternally(mailto, defaults: defaults, workspace: workspace)) + XCTAssertTrue(BrowserLinkOpenSettings.openExternally(deepLink, defaults: defaults, workspace: workspace)) + + XCTAssertEqual(workspace.openedURLs, [mailto, deepLink]) + XCTAssertTrue(workspace.openedWithApplicationURLs.isEmpty) + } } private final class BrowserExternalOpenRecordingWorkspace: NSWorkspace {