From ac92ed8c10b5411aa27788ac96237cd3c0258370 Mon Sep 17 00:00:00 2001 From: arzafran Date: Fri, 30 Oct 2026 10:25:08 -0300 Subject: [PATCH 1/3] fix: find Aside's DevTools from DevToolsActivePort and register Programa inside Aside --- .github/workflows/ci.yml | 1 + CHANGELOG.md | 2 + CLI/CLI+Aside.swift | 281 ++++++++++++++++++++++++---- docs/aside-browser.md | 41 ++-- tests/test_cli_aside_integration.py | 259 +++++++++++++++++++++++++ 5 files changed, 539 insertions(+), 45 deletions(-) create mode 100644 tests/test_cli_aside_integration.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6d0958f9..f9dd8a081 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -639,6 +639,7 @@ jobs: for t in \ tests/test_cli_noninteractive_install_regression.py \ tests/test_cli_codex_mcp_registration.py \ + tests/test_cli_aside_integration.py \ tests/test_cli_opencode_mcp_registration.py \ tests/test_cli_themes.py \ tests/test_cli_socket_no_autodiscovery_regression.py \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 708eccaa3..2d2d94f2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p - Tab colors and the Tab Color menu in the glass tab bar; pane tabs show their color as a tinted background. - `programa wait-surface --agent-state ` waits for the agent in a surface to reach a state. - On Windows, pasting text with line breaks or control characters into a program that has not turned on bracketed paste (cmd.exe, PowerShell) asks first, with a preview. +- `programa aside install-mcp` also registers Programa in Aside's own MCP settings, so Aside can start agents in Programa panes and read their results. `uninstall-mcp` removes it and `programa aside status` shows it. See `docs/aside-browser.md`. ### Changed - A workspace with a single tab hides its tab bar, along with the new-tab and split buttons, and leaves an empty strip in its place so the window can still be dragged from the top. A glass + button in that strip's right corner opens a new tab. The bar comes back when the pane gets a second tab or the workspace is split, so every split pane keeps its close and new-tab buttons. @@ -61,6 +62,7 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p ### Fixed - `programa handoff` pastes into a pane whose terminal has not started yet instead of failing. +- `programa aside install-mcp --with-devtools` connects `aside-devtools` to Aside wherever its remote debugging is listening, read from Aside's `DevToolsActivePort`, and `programa aside status` reports that endpoint. Both assumed port 9223, which Aside does not use. Turn on remote debugging in Aside at `chrome://inspect/#remote-debugging`. - `programa race --agent opencode` now hands OpenCode the prompt with `--prompt`. OpenCode read the bare argument as a project path, so the prompt never arrived. - With dozens of tabs in a pane, each tab keeps a width that shows its title instead of shrinking to one letter. The tab bar scrolls with a regular mouse wheel and keeps the selected tab in view. - Renaming a tab, or a program changing its title, updates the tab bar right away. diff --git a/CLI/CLI+Aside.swift b/CLI/CLI+Aside.swift index c5b0b4a64..c61cc29e7 100644 --- a/CLI/CLI+Aside.swift +++ b/CLI/CLI+Aside.swift @@ -1,4 +1,5 @@ import Foundation +import Darwin /// 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. @@ -32,8 +33,16 @@ struct AsideMCPPlan { let claudeCommands: [[String]] let codexCommands: [[String]] + /// `--autoConnect` makes chrome-devtools-mcp read `/DevToolsActivePort` and + /// attach to the running Aside. Without it, `--userDataDir` launches a new Chrome on + /// Aside's profile instead. + static func devToolsServerCommand(asideUserDataDir: String) -> [String] { + ["npx", "-y", "chrome-devtools-mcp@latest", "--autoConnect", "--userDataDir", asideUserDataDir] + } + static func build( asidePath: String, + asideUserDataDir: String, claudeExecutable: String?, codexExecutable: String?, withDevTools: Bool, @@ -41,6 +50,7 @@ struct AsideMCPPlan { ) -> AsideMCPPlan { var claudeCommands: [[String]] = [] var codexCommands: [[String]] = [] + let devToolsCommand = devToolsServerCommand(asideUserDataDir: asideUserDataDir) if let claude = claudeExecutable { if install { @@ -48,10 +58,9 @@ struct AsideMCPPlan { 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", - ]) + claudeCommands.append( + [claude, "mcp", "add", "--scope", "user", "--transport", "stdio", "aside-devtools", "--"] + devToolsCommand + ) } } else { claudeCommands.append([claude, "mcp", "remove", "--scope", "user", "aside"]) @@ -63,10 +72,7 @@ struct AsideMCPPlan { 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", - ]) + codexCommands.append([codex, "mcp", "add", "aside-devtools", "--"] + devToolsCommand) } } else { codexCommands.append([codex, "mcp", "remove", "aside"]) @@ -78,26 +84,210 @@ struct AsideMCPPlan { } } +/// Chrome's `DevToolsActivePort` file: the port on the first line, the browser target path on +/// the second. Aside writes it to its user data dir while remote debugging is on, and it can +/// outlive the browser, so a parsed port still needs a live listener. +enum AsideDevToolsActivePort { + static func parse(_ content: String) -> (port: Int, path: String)? { + let lines = content.split(whereSeparator: \.isNewline) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + guard lines.count >= 2, + let port = Int(lines[0]), (1...65535).contains(port), + lines[1].hasPrefix("/") else { return nil } + return (port, lines[1]) + } +} + +/// Plans Programa's entry in Aside's own MCP client settings, `mcp.servers.programa` in +/// `~/.aside/u//settings.json`. Pure: takes the parsed settings and returns the +/// settings to write, or nil when nothing changes. +enum AsideProgramaRegistration { + static let serverName = CodexMCPRegistrationPlan.serverName + + /// Aside validates `env` as a string map. A file that fails validation is rewritten by Aside + /// from its in-memory copy on its next save, which drops every external edit. + static func entry(mcpBinary: String) -> [String: Any] { + ["enabled": true, "transport": "stdio", "command": mcpBinary, "args": [String](), "env": [String: String]()] + } + + /// Only a `programa-mcp` command without arguments or environment counts as ours, so a + /// user's own `programa` server is never replaced or removed. + static func isOurs(_ server: Any?) -> Bool { + guard let server = server as? [String: Any], + let command = server["command"] as? String, + (command as NSString).lastPathComponent == CodexMCPRegistrationPlan.binaryName else { return false } + return ["args", "env"].allSatisfy { key in + switch server[key] { + case let list as [Any]: return list.isEmpty + case let map as [String: Any]: return map.isEmpty + default: return true + } + } + } + + static func apply(install: Bool, settings: [String: Any], mcpBinary: String) -> (settings: [String: Any]?, note: String?) { + if let raw = settings["mcp"], !(raw is [String: Any]) { + return (nil, "Aside's \"mcp\" setting is not an object; leaving it unchanged.") + } + var mcp = settings["mcp"] as? [String: Any] ?? [:] + if let raw = mcp["servers"], !(raw is [String: Any]) { + return (nil, "Aside's \"mcp.servers\" setting is not an object; leaving it unchanged.") + } + var servers = mcp["servers"] as? [String: Any] ?? [:] + let existing = servers[serverName] + if existing != nil, !isOurs(existing) { + return (nil, install ? "Aside already has an MCP server named \(serverName); leaving it unchanged." : nil) + } + var updated = settings + if install { + let desired = entry(mcpBinary: mcpBinary) + if let existing = existing as? NSDictionary, existing.isEqual(to: desired) { return (nil, nil) } + servers[serverName] = desired + } else { + guard existing != nil else { return (nil, nil) } + servers.removeValue(forKey: serverName) + if var inventories = mcp["inventories"] as? [String: Any] { + inventories.removeValue(forKey: serverName) + mcp["inventories"] = inventories + } + } + mcp["servers"] = servers + updated["mcp"] = mcp + return (updated, nil) + } +} + +/// A computed change to Aside's settings file, written only while the file still holds `original`. +struct AsideSettingsChange { + let path: String + let original: Data + let updated: Data + let summary: String +} + 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 + static var asideUserDataDir: String { + integrationExpandTilde("~/Library/Application Support/Aside") + } + + /// Report-only probe of Aside's Chrome DevTools Protocol endpoint. Returns + /// `ws://127.0.0.1:` from Aside's `DevToolsActivePort` when that port accepts a + /// connection, nil when remote debugging is off or Aside is not running. + func asideDevToolsEndpoint(timeout: TimeInterval = 1) -> String? { + let portFile = (Self.asideUserDataDir as NSString).appendingPathComponent("DevToolsActivePort") + guard let content = try? String(contentsOfFile: portFile, encoding: .utf8), + let active = AsideDevToolsActivePort.parse(content), + asideLoopbackPortIsOpen(active.port, timeout: timeout) else { return nil } + return "ws://127.0.0.1:\(active.port)\(active.path)" + } + + /// A TCP connect only, never a WebSocket handshake, so the probe cannot trigger Aside's + /// remote debugging consent prompt. + private func asideLoopbackPortIsOpen(_ port: Int, timeout: TimeInterval) -> Bool { + let descriptor = socket(AF_INET, SOCK_STREAM, 0) + guard descriptor >= 0 else { return false } + defer { Darwin.close(descriptor) } + _ = fcntl(descriptor, F_SETFL, fcntl(descriptor, F_GETFL) | O_NONBLOCK) + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_port = in_port_t(UInt16(port).bigEndian) + address.sin_addr.s_addr = inet_addr("127.0.0.1") + let result = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + connect(descriptor, $0, socklen_t(MemoryLayout.size)) + } + } + if result == 0 { return true } + guard errno == EINPROGRESS else { return false } + var pollDescriptor = pollfd(fd: descriptor, events: Int16(POLLOUT), revents: 0) + guard poll(&pollDescriptor, 1, Int32(timeout * 1000)) == 1 else { return false } + var socketError: Int32 = 0 + var length = socklen_t(MemoryLayout.size) + return getsockopt(descriptor, SOL_SOCKET, SO_ERROR, &socketError, &length) == 0 && socketError == 0 + } + + /// The signed-in account's settings file, or a note explaining why there is none. Reads + /// only `currentAccountId` from `accounts.json`; the rest of that file holds tokens. + private func asideAccountSettingsPath() -> (path: String?, note: String?) { + let asideHome = Self.integrationExpandTilde("~/.aside") + guard FileManager.default.fileExists(atPath: asideHome) else { + return (nil, "\(asideHome) not found, skipping (open Aside once to create it)") + } + let accountsPath = (asideHome as NSString).appendingPathComponent("accounts.json") + guard let data = FileManager.default.contents(atPath: accountsPath), + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let accountID = (root["currentAccountId"] as? Int).map(String.init) ?? root["currentAccountId"] as? String else { + return (nil, "no signed-in Aside account in \(accountsPath), skipping") + } + let settingsPath = (asideHome as NSString).appendingPathComponent("u/\(accountID)/settings.json") + guard FileManager.default.fileExists(atPath: settingsPath) else { + return (nil, "\(settingsPath) not found, skipping (open Aside once to create it)") + } + return (settingsPath, nil) + } + + private func asideProgramaMCPBinary() -> String? { + if let bundled = bundledMCPBinaryPath() { return bundled } + let installed = "/Applications/Programa.app/Contents/Resources/bin/\(CodexMCPRegistrationPlan.binaryName)" + return FileManager.default.isExecutableFile(atPath: installed) ? installed : nil + } + + /// Aside's `programa` server entry, or a note when the settings cannot be read. + private func asideProgramaServer() -> (server: Any?, note: String?) { + let located = asideAccountSettingsPath() + guard let path = located.path else { return (nil, located.note) } + guard let data = FileManager.default.contents(atPath: path), + let settings = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return (nil, "\(path) is not valid JSON") + } + let servers = (settings["mcp"] as? [String: Any])?["servers"] as? [String: Any] + return (servers?[AsideProgramaRegistration.serverName], nil) + } + + private func asideSettingsChange(install: Bool) -> (change: AsideSettingsChange?, note: String?) { + let located = asideAccountSettingsPath() + guard let path = located.path else { return (nil, located.note) } + let mcpBinary = asideProgramaMCPBinary() + if install, mcpBinary == nil { + return (nil, "\(CodexMCPRegistrationPlan.binaryName) not found next to this CLI or in /Applications/Programa.app, skipping") + } + guard let original = FileManager.default.contents(atPath: path), + let settings = try? JSONSerialization.jsonObject(with: original) as? [String: Any] else { + return (nil, "\(path) is not valid JSON, leaving it unchanged") + } + let planned = AsideProgramaRegistration.apply(install: install, settings: settings, mcpBinary: mcpBinary ?? "") + guard let updatedSettings = planned.settings, + let updated = try? JSONSerialization.data( + withJSONObject: updatedSettings, + options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + ) else { return (nil, planned.note) } + let key = "mcp.servers.\(AsideProgramaRegistration.serverName)" + let summary = install ? "set \(key) in \(path) to \(mcpBinary ?? "")" : "remove \(key) from \(path)" + return (AsideSettingsChange(path: path, original: original, updated: updated, summary: summary), nil) + } + + /// Holds Aside's own lock (`proper-lockfile` creates `settings.json.lock` as a directory) and + /// writes only while the file still holds the bytes the change was computed from. + private func applyAsideSettingsChange(_ change: AsideSettingsChange) throws { + let target = try codexResolveConfigTarget(change.path) + let lock = target + ".lock" + guard mkdir(lock, 0o755) == 0 else { + if errno == EEXIST { + throw CLIError(message: "Aside is writing \(change.path) right now. Re-run this command.") + } + throw CLIError(message: "Could not lock \(change.path): \(String(cString: strerror(errno)))") + } + defer { rmdir(lock) } + guard FileManager.default.contents(atPath: target) == change.original else { + throw CLIError(message: "\(change.path) changed while Programa was being registered. Re-run this command.") + } + do { + try codexAtomicWrite(change.updated, to: target, mode: 0o600) + } catch { + throw CLIError(message: error.localizedDescription) } - task.resume() - _ = semaphore.wait(timeout: .now() + timeout + 0.5) - return result } private func asideResolveClients(environment: [String: String]) -> (claude: String?, codex: String?) { @@ -154,7 +344,17 @@ extension ProgramaCLI { if let endpoint = asideDevToolsEndpoint() { print("DevTools: \(endpoint)") } else { - print("DevTools: not reachable (is Aside running?)") + print("DevTools: remote debugging is off. Turn it on in Aside at chrome://inspect/#remote-debugging") + } + let asideServer = asideProgramaServer() + if let note = asideServer.note { + print("Aside MCP client: \(note)") + } else if let server = asideServer.server { + let command = (server as? [String: Any])?["command"] as? String ?? "a non-stdio server" + let owner = AsideProgramaRegistration.isOurs(server) ? "" : " (not managed by Programa)" + print("Aside MCP client: programa registered: \(command)\(owner)") + } else { + print("Aside MCP client: programa not registered") } if let claudeExecutable { let registered = asideIsRegistered(executable: claudeExecutable, serverName: "aside") @@ -177,10 +377,6 @@ extension ProgramaCLI { 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 ?? "not found")") if let endpoint = asideDevToolsEndpoint() { print("DevTools: \(endpoint)") @@ -192,8 +388,17 @@ extension ProgramaCLI { print("Codex: not found, skipping") } + let asideSettings = asideSettingsChange(install: install) + if let note = asideSettings.note { + print("Aside MCP client: \(note)") + } + if claudeExecutable == nil, codexExecutable == nil, asideSettings.change == nil { + throw CLIError(message: "Neither Claude Code nor Codex CLI was found on PATH, and Aside's own settings need no change.") + } + let plan = AsideMCPPlan.build( asidePath: planAsidePath, + asideUserDataDir: Self.asideUserDataDir, claudeExecutable: claudeExecutable, codexExecutable: codexExecutable, withDevTools: withDevTools, @@ -210,7 +415,7 @@ extension ProgramaCLI { pendingCommands.append((client: "Codex", serverName: serverName, command: command)) } - guard !pendingCommands.isEmpty else { + guard !pendingCommands.isEmpty || asideSettings.change != nil else { print("Nothing to do.") return } @@ -220,6 +425,9 @@ extension ProgramaCLI { for entry in pendingCommands { print(" \(entry.command.joined(separator: " "))") } + if let change = asideSettings.change { + print(" \(change.summary)") + } if !skipConfirm { print("Apply these changes? [Y/n] ", terminator: "") @@ -256,6 +464,15 @@ extension ProgramaCLI { failures.append("\(entry.client) \(entry.serverName): \(error)") } } + if let change = asideSettings.change { + print("Aside MCP client: \(change.summary)") + do { + try applyAsideSettingsChange(change) + print("Aside reads its MCP servers when it starts; quit and reopen Aside to apply.") + } catch { + failures.append("Aside \(AsideProgramaRegistration.serverName): \(error)") + } + } print("") if failures.isEmpty { print(install ? "Installed." : "Removed.") diff --git a/docs/aside-browser.md b/docs/aside-browser.md index af340f9ee..da0d3d4e4 100644 --- a/docs/aside-browser.md +++ b/docs/aside-browser.md @@ -12,19 +12,23 @@ Aside ships a CLI (`aside`) and an MCP server (`aside mcp`, stdio). Programa can 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 status # where the aside binary is, the DevTools endpoint, what is registered where +programa aside install-mcp # registers `aside` (aside mcp) with Claude Code and Codex, and Programa with Aside 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. +The `--with-devtools` flag adds a second server named `aside-devtools`: +`chrome-devtools-mcp --autoConnect --userDataDir ~/Library/Application Support/Aside`. It gives +an agent full DevTools control of Aside's tabs (navigation, clicks, console, network, +performance traces) with no extension involved. It needs remote debugging turned on in Aside: +open `chrome://inspect/#remote-debugging` in Aside and enable it. Aside then writes the live +port and browser path to `DevToolsActivePort` in its user data directory, and +`chrome-devtools-mcp` follows that file, so the port can change between launches without +re-registering anything. `programa aside status` reports the `ws://127.0.0.1:/devtools/browser/` +endpoint while that port accepts connections, and says remote debugging is off otherwise. Once registered, an agent can also delegate a whole task from a pane without MCP: @@ -46,9 +50,20 @@ land in the browser that has your logins and agent memory. ## 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. +Aside is also an MCP client, so it can dispatch work into a Claude Code or Codex pane. +`programa aside install-mcp` registers Programa there: it adds a `programa` stdio server +pointing at the `programa-mcp` next to the CLI (`Programa.app/Contents/Resources/bin/programa-mcp`) +to `mcp.servers` in the signed-in account's `~/.aside/u//settings.json`. Every other +setting stays as it is, and the confirmation prompt lists the change before anything is +written. Aside reads its MCP servers when it starts, so quit and reopen Aside afterwards. +`programa-mcp` finds the running Programa's socket on its own and needs no arguments or +environment. + +With it, the `surface_send_text`, `surface_read_text`, `surface_split`, and `surface_wait` +tools let Aside 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. + +The installer skips this step when Aside has no signed-in account on this Mac, and leaves a +`programa` server it did not create untouched. `programa aside uninstall-mcp` removes the +entry and the tool list Aside cached for it. `programa aside status` shows whether Aside has +Programa registered. diff --git a/tests/test_cli_aside_integration.py b/tests/test_cli_aside_integration.py new file mode 100644 index 000000000..dfb34ed30 --- /dev/null +++ b/tests/test_cli_aside_integration.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +"""`programa aside` finds Aside's DevTools endpoint and registers Programa inside Aside. + +`status` reads `~/Library/Application Support/Aside/DevToolsActivePort` and reports the +WebSocket endpoint only while its port accepts connections. `install-mcp` also sets +`mcp.servers.programa` in the signed-in account's `~/.aside/u//settings.json`, keeping +every other key; `uninstall-mcp` removes it and its tool inventory. A user's own `programa` +server is never replaced or removed, and nothing changes without consent. + +Runs a copy of the built CLI under a temporary HOME with fake `aside` and `claude` binaries. +No socket, no app and no real Aside are involved. +""" + +from __future__ import annotations + +import json +import os +import shutil +import socket +import stat +import subprocess +import tempfile +import unittest +from pathlib import Path + +CLI_PATH = os.environ.get("PROGRAMA_CLI_BIN", "") +STRIPPED_ENV = ( + "PROGRAMA_WORKSPACE_ID", "PROGRAMA_SURFACE_ID", "PROGRAMA_PANEL_ID", "PROGRAMA_TAB_ID", + "PROGRAMA_SOCKET", "PROGRAMA_SOCKET_PATH", "CLAUDE_CONFIG_DIR", "CODEX_HOME", +) +ACCOUNT_ID = 7 + +FAKE_CLAUDE = r"""#!/bin/bash +printf '%s\n' "$*" >> "$FAKE_MCP_STATE/calls.log" +case "$1 $2" in + "mcp get") [[ -f "$FAKE_MCP_STATE/server-$3" ]] ;; + "mcp add") + name=""; for arg in "$@"; do [[ "$arg" == "--" ]] && break; name="$arg"; done + touch "$FAKE_MCP_STATE/server-$name" ;; + "mcp remove") rm -f "$FAKE_MCP_STATE/server-${@: -1}" ;; + *) exit 2 ;; +esac +""" + +UNRELATED_SETTINGS = { + "defaultModel": "some-model", + "experimental": {"flag": True, "ratio": 0.5}, + "customModels": [], + "mcp": { + "toolInventoryMigrationVersion": 2, + "servers": { + "other": {"enabled": True, "transport": "streamable-http", "url": "https://example.test/mcp", "auth": {"type": "none"}}, + }, + "inventories": {"other": {"tools": [], "refreshedAt": "2026-10-01T00:00:00Z"}}, + }, +} + + +def make_executable(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + +class Sandbox: + def __init__(self, root: str, with_aside_home: bool = True) -> None: + self.root = Path(root) + self.home = self.root / "home" + self.home.mkdir(parents=True) + bin_dir = self.root / "bundle-bin" + bin_dir.mkdir() + self.cli = bin_dir / "programa" + shutil.copy2(CLI_PATH, self.cli) + self.mcp_binary = bin_dir / "programa-mcp" + make_executable(self.mcp_binary, "#!/bin/sh\nexit 0\n") + make_executable(self.home / ".local/bin/aside", "#!/bin/sh\nexit 0\n") + self.fake_bin = self.root / "fake-bin" + make_executable(self.fake_bin / "claude", FAKE_CLAUDE) + self.state = self.root / "mcp-state" + self.state.mkdir() + self.user_data_dir = self.home / "Library/Application Support/Aside" + self.user_data_dir.mkdir(parents=True) + self.settings_path = self.home / f".aside/u/{ACCOUNT_ID}/settings.json" + if with_aside_home: + self.settings_path.parent.mkdir(parents=True) + (self.home / ".aside/accounts.json").write_text( + json.dumps({"currentAccountId": ACCOUNT_ID, "accounts": [{"token": "secret"}]}), encoding="utf-8" + ) + self.write_settings(UNRELATED_SETTINGS) + + def write_settings(self, settings: dict) -> None: + self.settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8") + self.settings_path.chmod(0o600) + + def settings(self) -> dict: + return json.loads(self.settings_path.read_text(encoding="utf-8")) + + def calls(self) -> list[str]: + log = self.state / "calls.log" + return log.read_text(encoding="utf-8").splitlines() if log.exists() else [] + + def run(self, *args: str, consent: bool = True) -> subprocess.CompletedProcess[str]: + env = dict(os.environ) + for key in STRIPPED_ENV: + env.pop(key, None) + sock = str(self.root / "no-such.sock") + env.update( + HOME=str(self.home), CFFIXED_USER_HOME=str(self.home), FAKE_MCP_STATE=str(self.state), + PATH=f"{self.fake_bin}:/usr/bin:/bin", PROGRAMA_SOCKET=sock, PROGRAMA_SOCKET_PATH=sock, + ) + return subprocess.run( + [str(self.cli), "aside", *args, *(["--yes"] if consent else [])], + stdin=subprocess.DEVNULL, capture_output=True, text=True, check=False, timeout=60, env=env, + ) + + +def unused_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + probe.bind(("127.0.0.1", 0)) + return probe.getsockname()[1] + + +class AsideIntegrationTests(unittest.TestCase): + def setUp(self) -> None: + self.assertTrue(CLI_PATH and os.access(CLI_PATH, os.X_OK), "PROGRAMA_CLI_BIN must point to the built CLI") + self.tmp = tempfile.TemporaryDirectory(prefix="pcli-aside-", dir="/tmp") + self.addCleanup(self.tmp.cleanup) + + def assertOK(self, proc: subprocess.CompletedProcess[str]) -> None: + self.assertEqual(proc.returncode, 0, f"stdout={proc.stdout!r} stderr={proc.stderr!r}") + + def expected_entry(self, box: Sandbox) -> dict: + return {"enabled": True, "transport": "stdio", "command": str(box.mcp_binary), "args": [], "env": {}} + + def test_status_reports_the_endpoint_from_devtools_active_port(self) -> None: + box = Sandbox(self.tmp.name) + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener: + listener.bind(("127.0.0.1", 0)) + listener.listen(1) + port = listener.getsockname()[1] + (box.user_data_dir / "DevToolsActivePort").write_text(f"{port}\n/devtools/browser/abc-123\n", encoding="utf-8") + proc = box.run("status", consent=False) + self.assertOK(proc) + self.assertIn(f"DevTools: ws://127.0.0.1:{port}/devtools/browser/abc-123", proc.stdout) + + def test_status_says_remote_debugging_is_off_without_a_live_port(self) -> None: + box = Sandbox(self.tmp.name) + cases = { + "missing file": None, + "stale port": f"{unused_port()}\n/devtools/browser/stale\n", + "garbage": "not-a-port\n", + } + for label, content in cases.items(): + with self.subTest(label): + port_file = box.user_data_dir / "DevToolsActivePort" + port_file.unlink(missing_ok=True) + if content is not None: + port_file.write_text(content, encoding="utf-8") + proc = box.run("status", consent=False) + self.assertOK(proc) + self.assertIn("remote debugging is off", proc.stdout) + self.assertIn("chrome://inspect/#remote-debugging", proc.stdout) + self.assertNotIn("ws://", proc.stdout) + + def test_install_registers_programa_in_aside_and_uninstall_removes_it(self) -> None: + box = Sandbox(self.tmp.name) + proc = box.run("install-mcp") + self.assertOK(proc) + self.assertIn("set mcp.servers.programa", proc.stdout, "the preview must list the Aside change") + settings = box.settings() + entry = settings["mcp"]["servers"]["programa"] + self.assertEqual(entry, self.expected_entry(box)) + self.assertIsInstance(entry["env"], dict, "Aside rejects a non-object env") + without_programa = json.loads(json.dumps(settings)) + del without_programa["mcp"]["servers"]["programa"] + self.assertEqual(without_programa, UNRELATED_SETTINGS, "unrelated keys must survive") + self.assertEqual(stat.S_IMODE(box.settings_path.stat().st_mode), 0o600) + self.assertFalse(Path(f"{box.settings_path}.lock").exists(), "Aside's lock must be released") + self.assertIn("programa registered: " + str(box.mcp_binary), box.run("status", consent=False).stdout) + + written = box.settings_path.read_bytes() + proc = box.run("install-mcp") + self.assertOK(proc) + self.assertNotIn("mcp.servers.programa", proc.stdout, "a reinstall must not plan an Aside change") + self.assertEqual(box.settings_path.read_bytes(), written) + + settings = box.settings() + settings["mcp"]["inventories"]["programa"] = {"tools": [], "refreshedAt": "2026-10-02T00:00:00Z"} + box.write_settings(settings) + proc = box.run("uninstall-mcp") + self.assertOK(proc) + self.assertIn("remove mcp.servers.programa", proc.stdout) + self.assertEqual(box.settings(), UNRELATED_SETTINGS) + self.assertIn("programa not registered", box.run("status", consent=False).stdout) + + def test_install_repairs_a_programa_entry_aside_cannot_validate(self) -> None: + box = Sandbox(self.tmp.name) + settings = json.loads(json.dumps(UNRELATED_SETTINGS)) + settings["mcp"]["servers"]["programa"] = { + "enabled": True, "transport": "stdio", + "command": "/Applications/Programa.app/Contents/Resources/bin/programa-mcp", "args": [], "env": [], + } + box.write_settings(settings) + self.assertOK(box.run("install-mcp")) + self.assertEqual(box.settings()["mcp"]["servers"]["programa"], self.expected_entry(box)) + + def test_foreign_programa_server_is_left_alone(self) -> None: + box = Sandbox(self.tmp.name) + foreign = {"enabled": True, "transport": "stdio", "command": "/usr/local/bin/my-own-server", "args": [], "env": {}} + settings = json.loads(json.dumps(UNRELATED_SETTINGS)) + settings["mcp"]["servers"]["programa"] = foreign + box.write_settings(settings) + before = box.settings_path.read_bytes() + proc = box.run("install-mcp") + self.assertOK(proc) + self.assertIn("leaving it unchanged", proc.stdout) + self.assertOK(box.run("uninstall-mcp")) + self.assertEqual(box.settings_path.read_bytes(), before) + + def test_without_consent_aside_settings_are_not_changed(self) -> None: + box = Sandbox(self.tmp.name) + before = box.settings_path.read_bytes() + proc = box.run("install-mcp", consent=False) + self.assertIn("set mcp.servers.programa", proc.stdout) + self.assertIn("Aborted", proc.stdout) + self.assertEqual(box.settings_path.read_bytes(), before) + self.assertEqual([call for call in box.calls() if not call.startswith("mcp get")], []) + + def test_held_aside_lock_blocks_the_write(self) -> None: + box = Sandbox(self.tmp.name) + lock = Path(f"{box.settings_path}.lock") + lock.mkdir() + before = box.settings_path.read_bytes() + proc = box.run("install-mcp") + self.assertEqual(proc.returncode, 1, f"stdout={proc.stdout!r} stderr={proc.stderr!r}") + self.assertIn("Aside is writing", proc.stdout + proc.stderr) + self.assertEqual(box.settings_path.read_bytes(), before) + self.assertTrue(lock.is_dir(), "a lock Programa did not take must be left in place") + + def test_missing_aside_home_still_registers_aside_with_claude(self) -> None: + box = Sandbox(self.tmp.name, with_aside_home=False) + proc = box.run("install-mcp") + self.assertOK(proc) + self.assertIn(".aside not found, skipping", proc.stdout) + self.assertTrue(any(call.startswith("mcp add --scope user --transport stdio aside ") for call in box.calls())) + self.assertFalse((box.home / ".aside").exists()) + + def test_with_devtools_attaches_through_the_aside_user_data_dir(self) -> None: + box = Sandbox(self.tmp.name) + self.assertOK(box.run("install-mcp", "--with-devtools")) + devtools = [call for call in box.calls() if " aside-devtools " in call] + self.assertEqual(devtools, [ + "mcp add --scope user --transport stdio aside-devtools -- npx -y chrome-devtools-mcp@latest " + f"--autoConnect --userDataDir {box.user_data_dir}" + ]) + + +if __name__ == "__main__": + unittest.main(verbosity=2) From 1252907db0451edcdb1afd4a0cb6a341306aa5ac Mon Sep 17 00:00:00 2001 From: arzafran Date: Fri, 30 Oct 2026 10:41:13 -0300 Subject: [PATCH 2/3] docs: tell users to press Connect in Aside after registering Programa --- CLI/CLI+Aside.swift | 2 +- docs/aside-browser.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CLI/CLI+Aside.swift b/CLI/CLI+Aside.swift index c61cc29e7..e947ce680 100644 --- a/CLI/CLI+Aside.swift +++ b/CLI/CLI+Aside.swift @@ -468,7 +468,7 @@ extension ProgramaCLI { print("Aside MCP client: \(change.summary)") do { try applyAsideSettingsChange(change) - print("Aside reads its MCP servers when it starts; quit and reopen Aside to apply.") + print("Open Aside's MCP settings and press Connect next to programa so its agent can use the tools.") } catch { failures.append("Aside \(AsideProgramaRegistration.serverName): \(error)") } diff --git a/docs/aside-browser.md b/docs/aside-browser.md index da0d3d4e4..ffb5fe3cb 100644 --- a/docs/aside-browser.md +++ b/docs/aside-browser.md @@ -55,7 +55,8 @@ Aside is also an MCP client, so it can dispatch work into a Claude Code or Codex pointing at the `programa-mcp` next to the CLI (`Programa.app/Contents/Resources/bin/programa-mcp`) to `mcp.servers` in the signed-in account's `~/.aside/u//settings.json`. Every other setting stays as it is, and the confirmation prompt lists the change before anything is -written. Aside reads its MCP servers when it starts, so quit and reopen Aside afterwards. +written. Aside's agent only sees a server's tools after Aside has listed them, so open +Aside's MCP settings and press Connect next to `programa` afterwards. `programa-mcp` finds the running Programa's socket on its own and needs no arguments or environment. From 8d93ef5ee10c80c24b9618a6792c5b8a9dafdcdb Mon Sep 17 00:00:00 2001 From: arzafran Date: Fri, 30 Oct 2026 10:50:25 -0300 Subject: [PATCH 3/3] docs: point users to Refresh tools in Aside's MCP menu --- CLI/CLI+Aside.swift | 2 +- docs/aside-browser.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CLI/CLI+Aside.swift b/CLI/CLI+Aside.swift index e947ce680..3153bff16 100644 --- a/CLI/CLI+Aside.swift +++ b/CLI/CLI+Aside.swift @@ -468,7 +468,7 @@ extension ProgramaCLI { print("Aside MCP client: \(change.summary)") do { try applyAsideSettingsChange(change) - print("Open Aside's MCP settings and press Connect next to programa so its agent can use the tools.") + print("Open Aside's MCP settings and choose Refresh tools from the ⋯ menu on the programa row so its agent can use the tools.") } catch { failures.append("Aside \(AsideProgramaRegistration.serverName): \(error)") } diff --git a/docs/aside-browser.md b/docs/aside-browser.md index ffb5fe3cb..949cc91b4 100644 --- a/docs/aside-browser.md +++ b/docs/aside-browser.md @@ -56,7 +56,7 @@ pointing at the `programa-mcp` next to the CLI (`Programa.app/Contents/Resources to `mcp.servers` in the signed-in account's `~/.aside/u//settings.json`. Every other setting stays as it is, and the confirmation prompt lists the change before anything is written. Aside's agent only sees a server's tools after Aside has listed them, so open -Aside's MCP settings and press Connect next to `programa` afterwards. +Aside's MCP settings and choose Refresh tools from the ⋯ menu on the `programa` row afterwards. `programa-mcp` finds the running Programa's socket on its own and needs no arguments or environment.