diff --git a/CLI/CLICommandDispatcher.swift b/CLI/CLICommandDispatcher.swift index 98ba1469..0cc279b8 100644 --- a/CLI/CLICommandDispatcher.swift +++ b/CLI/CLICommandDispatcher.swift @@ -28,6 +28,7 @@ struct CLICommandDispatcher { var idFormatArg: String? = nil var windowId: String? = nil var socketPasswordArg: String? = nil + var passwordFromStdin = false var index = 1 while index < args.count { @@ -58,6 +59,11 @@ struct CLICommandDispatcher { index += width continue } + if arg == "--password-stdin" { + passwordFromStdin = true + index += 1 + continue + } if arg == "-v" || arg == "--version" { cli.printVersion(jsonOutput: jsonOutput) return @@ -77,6 +83,11 @@ struct CLICommandDispatcher { throw CLIError(message: "Missing command") } + if passwordFromStdin { + guard socketPasswordArg == nil else { throw CLIError(message: "--password and --password-stdin cannot be used together") } + socketPasswordArg = try readPasswordLine() + } + let command = args[index] let commandArgs = Array(args[(index + 1)...]) @@ -200,10 +211,11 @@ struct CLICommandDispatcher { return } - // If the user explicitly targets a window, focus it first so commands route correctly. + // `--window` scopes requests to that window without focusing it (socket focus policy). if let windowId { let normalizedWindow = try cli.normalizeWindowHandle(windowId, client: client) ?? windowId - _ = try client.sendV2(method: V2MethodNames.windowFocus, params: ["window_id": normalizedWindow]) + try requireWindowExists(normalizedWindow, client: client) + client.routingWindowId = normalizedWindow } let ctx = CommandContext( @@ -219,4 +231,20 @@ struct CLICommandDispatcher { try execute(ctx) } + /// Rejects an unknown `--window` through a read-only query; methods without `window_id` ignore it. + private func requireWindowExists(_ handle: String, client: SocketClient) throws { + let windows = (try client.sendV2(method: V2MethodNames.windowList)["windows"] as? [[String: Any]]) ?? [] + guard windows.contains(where: { ($0["ref"] as? String) == handle + || ($0["id"] as? String)?.caseInsensitiveCompare(handle) == .orderedSame }) else { + throw CLIError(message: "not_found: Window not found") + } + } + + /// Reads one stdin line byte by byte, so later input stays unread for commands that read stdin. + private func readPasswordLine() throws -> String { + var bytes: [UInt8] = [], byte: UInt8 = 0 + while Darwin.read(STDIN_FILENO, &byte, 1) == 1, byte != UInt8(ascii: "\n") { bytes.append(byte) } + guard !bytes.isEmpty else { throw CLIError(message: "--password-stdin read an empty password") } + return String(decoding: bytes, as: UTF8.self) + } } diff --git a/CLI/SocketClient.swift b/CLI/SocketClient.swift index ca115e17..977c2e73 100644 --- a/CLI/SocketClient.swift +++ b/CLI/SocketClient.swift @@ -43,10 +43,27 @@ final class SocketClient { return defaultResponseTimeoutSeconds }() + /// The global `--window` target. Window-scoped requests that name no window, + /// workspace, surface or tab are routed to it, so `--window` never focuses the window. + var routingWindowId: String? + init(path: String) { self.path = path } + /// Adds `window_id` to a window-scoped request that carries none of the selectors the + /// server resolves a target window from (`v2ResolveTabManager`). A request that already + /// names its target keeps it: an explicit workspace in another window must not be + /// overridden by the routing window. + static func routedParams(_ params: [String: Any], method: String, windowId: String?) -> [String: Any] { + guard let windowId, V2MethodNames.windowScoped.contains(method) else { return params } + let selectors = ["window_id", "workspace_id", "surface_id", "tab_id"] + guard !selectors.contains(where: { params[$0] != nil && !(params[$0] is NSNull) }) else { return params } + var routed = params + routed["window_id"] = windowId + return routed + } + var socketPath: String { path } @@ -413,7 +430,10 @@ final class SocketClient { } func sendV2(method: String, params: [String: Any] = [:], minimumReceiveTimeout: TimeInterval? = nil) throws -> [String: Any] { - let requestLine = try Self.encodeV2RequestLine(method: method, params: params) + let requestLine = try Self.encodeV2RequestLine( + method: method, + params: Self.routedParams(params, method: method, windowId: routingWindowId) + ) let raw = try send(command: requestLine, minimumReceiveTimeout: minimumReceiveTimeout) // The server may return plain-text errors (e.g., "ERROR: Access denied ...") @@ -449,7 +469,10 @@ final class SocketClient { /// frames for a connection that keeps receiving events indefinitely. func sendV2RequestOnly(method: String, params: [String: Any] = [:]) throws { guard socketFD >= 0 else { throw CLIError(message: "Not connected") } - let requestLine = try Self.encodeV2RequestLine(method: method, params: params) + let requestLine = try Self.encodeV2RequestLine( + method: method, + params: Self.routedParams(params, method: method, windowId: routingWindowId) + ) try writeAll( Data((requestLine + "\n").utf8), timeoutMessage: "Command timed out", diff --git a/CLI/V2MethodNames.swift b/CLI/V2MethodNames.swift index e574faa0..7167e61e 100644 --- a/CLI/V2MethodNames.swift +++ b/CLI/V2MethodNames.swift @@ -172,4 +172,86 @@ enum V2MethodNames { static let worktreeList = "worktree.list" static let worktreeOpen = "worktree.open" static let worktreeRemove = "worktree.remove" + + /// Methods whose params accept `window_id`. The global `--window` option routes + /// these to its window instead of focusing that window first. + static let windowScoped: Set = [ + "agent.detection.classify", + "agent.prompt", + "debug.command_palette.rename_input.delete_backward", + "debug.command_palette.rename_input.interact", + "debug.command_palette.rename_input.selection", + "debug.command_palette.rename_tab.open", + "debug.command_palette.results", + "debug.command_palette.selection", + "debug.command_palette.toggle", + "debug.command_palette.visible", + "debug.sidebar.visible", + "feedback.open", + "layout.apply", + "layout.save", + "markdown.open", + "notification.create", + "notification.create_for_surface", + "notification.create_for_target", + "pane.break", + "pane.create", + "pane.focus", + "pane.last", + "pane.list", + "pane.resize", + "pane.surfaces", + "review.comment.add", + "review.comment.list", + "review.comment.remove", + "review.open", + "review.refresh", + "review.send_comments", + "surface.action", + "surface.clear_history", + "surface.close", + "surface.create", + "surface.current", + "surface.drag_to_split", + "surface.focus", + "surface.health", + "surface.list", + "surface.move", + "surface.read_text", + "surface.refresh", + "surface.send_if_unchanged", + "surface.send_key", + "surface.send_paste", + "surface.send_text", + "surface.split", + "surface.trigger_flash", + "surface.wait", + "system.identify", + "tab.action", + "window.close", + "window.focus", + "workspace.action", + "workspace.clear_meta_block", + "workspace.close", + "workspace.create", + "workspace.current", + "workspace.equalize_splits", + "workspace.last", + "workspace.list", + "workspace.list_log", + "workspace.list_meta_blocks", + "workspace.list_status", + "workspace.move_to_window", + "workspace.next", + "workspace.previous", + "workspace.rename", + "workspace.reorder", + "workspace.reset_sidebar", + "workspace.select", + "workspace.sidebar_state", + "worktree.create", + "worktree.list", + "worktree.open", + "worktree.remove", + ] } diff --git a/CLI/programa.swift b/CLI/programa.swift index fdbe6f76..a27f8c39 100644 --- a/CLI/programa.swift +++ b/CLI/programa.swift @@ -5958,8 +5958,9 @@ struct ProgramaCLI { --socket Socket to connect to (default: see PROGRAMA_SOCKET_PATH below) --json JSON output; errors print {"ok":false,"error":{...}} on stdout --id-format Handle format in output (default: refs) - --window Target this window (focuses it first) - --password Socket password + --window Run the command in this window without focusing it + --password Socket password (visible in ps; prefer --password-stdin or PROGRAMA_SOCKET_PASSWORD) + --password-stdin Read the socket password from the first line of stdin -v, --version Print the version and exit -h, --help Print this help and exit @@ -5969,7 +5970,7 @@ struct ProgramaCLI { Output defaults to refs; pass --id-format uuids or --id-format both to include UUIDs. Socket Auth: - --password takes precedence, then PROGRAMA_SOCKET_PASSWORD env var, then password saved in Settings. + --password or --password-stdin takes precedence, then PROGRAMA_SOCKET_PASSWORD env var, then password saved in Settings. Commands: \(commandsHelpBlock()) diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 00b149ae..8f1b406b 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -45,7 +45,7 @@ Read by the app, the CLI and `programa-mcp`. Modes and discovery are described i |---|---|---| | `PROGRAMA_SOCKET_PATH` | CLI, MCP, app | Socket path to use. The app honors it for Debug and Staging builds, or when `PROGRAMA_ALLOW_SOCKET_OVERRIDE` is truthy. | | `PROGRAMA_SOCKET` | CLI, MCP | Second choice for the socket path. `PROGRAMA_SOCKET_PATH` wins when both are set. The app never reads it. | -| `PROGRAMA_SOCKET_PASSWORD` | CLI, MCP, app | Socket password for `password` mode. The CLI checks `--password` first. The app checks it before the saved password file. | +| `PROGRAMA_SOCKET_PASSWORD` | CLI, MCP, app | Socket password for `password` mode. The CLI checks `--password` and `--password-stdin` first. The app checks it before the saved password file. | | `PROGRAMA_SOCKET_MODE` | app | Socket mode for this launch: `off`, `programaOnly`, `automation`, `password` or `allowAll`. Overrides the setting. | | `PROGRAMA_SOCKET_ENABLE` | app | `1`/`true`/`yes`/`on` or `0`/`false`/`no`/`off`. `0` turns the socket off regardless of the mode. | | `PROGRAMA_ALLOW_SOCKET_OVERRIDE` | app | Truthy value lets `PROGRAMA_SOCKET_PATH` move the socket in a Release build or a tagged Debug build. | diff --git a/docs/socket-api.md b/docs/socket-api.md index bef8c9a8..b07afb10 100644 --- a/docs/socket-api.md +++ b/docs/socket-api.md @@ -137,8 +137,16 @@ script run there talks to that app. To drive a different instance, such as a tag 2. the file `~/Library/Application Support/programa/socket-control-password` (mode `0600`, written by **Settings > Automation**; `automation.socketPassword` in `settings.json`) -The CLI takes `--password ` first, then `PROGRAMA_SOCKET_PASSWORD`, then the saved -password. `programa-mcp` takes `PROGRAMA_SOCKET_PASSWORD`, then the saved password. +The CLI takes `--password ` or `--password-stdin` first, then `PROGRAMA_SOCKET_PASSWORD`, +then the saved password. `--password` puts the password in the process arguments, where other +processes can read it with `ps`. Prefer `PROGRAMA_SOCKET_PASSWORD`, the saved password, or +`--password-stdin`, which reads the password from the first line of stdin: + +```bash +printf '%s\n' "$SECRET" | programa --password-stdin ping +``` + +`programa-mcp` takes `PROGRAMA_SOCKET_PASSWORD`, then the saved password. A client authenticates once per connection: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 002c8f3f..b8629170 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -46,8 +46,9 @@ Common symptoms: - `Access denied — only processes started inside Programa can connect`: the socket is in the default `programaOnly` mode and the caller was not started from a Programa terminal. Change `automation.socketControlMode` or run the command from inside Programa. -- `auth_required` or `auth_failed`: the socket is in `password` mode. Pass `--password`, or set - `PROGRAMA_SOCKET_PASSWORD`. +- `auth_required` or `auth_failed`: the socket is in `password` mode. Set + `PROGRAMA_SOCKET_PASSWORD`, or pipe the password to `--password-stdin`. `--password ` + also works but leaves the password visible in `ps`. - Connection refused or no such file: Programa is not running, or the socket mode is `off`. ## High memory use diff --git a/scripts/gen-v2-contract.py b/scripts/gen-v2-contract.py index 450644d8..a55e9164 100644 --- a/scripts/gen-v2-contract.py +++ b/scripts/gen-v2-contract.py @@ -3,7 +3,8 @@ Outputs (all overwritten in place, each carrying a "GENERATED, do not edit" header): - Sources/V2CommandCatalog.swift (base/debug method-name arrays) - - CLI/V2MethodNames.swift (one named constant per v2 method) + - CLI/V2MethodNames.swift (one named constant per v2 method, plus the set of + methods whose params accept `window_id`) Handlers change only after the contract does (see docs/socket-api.md "Contract"). Run scripts/check-v2-contract.sh to verify the checked-in files match this contract. @@ -110,6 +111,17 @@ def gen_v2_method_names(contract) -> str: continue seen.add(name) body += f' static let {name} = "{m}"\n' + scoped = [ + m for m in methods + if "window_id" in contract["methods"][m].get("params", {}).get("properties", {}) + ] + body += "\n" + body += " /// Methods whose params accept `window_id`. The global `--window` option routes\n" + body += " /// these to its window instead of focusing that window first.\n" + body += " static let windowScoped: Set = [\n" + for m in scoped: + body += f' "{m}",\n' + body += " ]\n" body += "}\n" return body diff --git a/tests/test_cli_registry_behavior.py b/tests/test_cli_registry_behavior.py index af0b5a12..a7edf6fd 100755 --- a/tests/test_cli_registry_behavior.py +++ b/tests/test_cli_registry_behavior.py @@ -115,6 +115,8 @@ def _serve_connection(self, connection: socket.socket) -> None: def _result_for(self, method: str, params: dict[str, Any]) -> dict[str, Any]: if method == "system.ping": return {"pong": True} + if method == "window.list": + return {"windows": [{"id": WINDOW_ID.upper(), "ref": "window:1", "key": False}]} if method == "workspace.list": return {"workspaces": self.workspace_items} if method == "agent.spawn": @@ -604,14 +606,82 @@ def expect_without_connection( f"Claude Teams helper lifecycle order changed: {recorder.frames!r}", ) - # Global window targeting stays ordered and shares one lazy connection. + # Global window targeting validates the window read-only, never focuses it, + # and shares one lazy connection. with tempfile.TemporaryDirectory(prefix="pcli-", dir="/tmp") as directory: with SocketRecorder(directory) as recorder: ping = run_cli(recorder.path, ["--window", WINDOW_ID, "ping"]) check(ping.returncode == 0, f"window-targeted ping failed: {merged_output(ping)!r}") check(recorder.accept_count == 1, f"window-targeted ping accepted {recorder.accept_count} connections, want 1") methods = [frame.get("method") for frame in recorder.frames] - check(methods == ["window.focus", "system.ping"], f"window focus/ping order changed: {methods!r}") + check(methods == ["window.list", "system.ping"], f"window-targeted ping frames changed: {methods!r}") + check("window_id" not in recorder.frames[-1].get("params", {}), + f"system.ping takes no window_id: {recorder.frames!r}") + + # A non-focus command scoped by --window routes window_id into its requests + # instead of sending window.focus, which raises the window and activates the app. + for args, method in [ + (["list-workspaces"], "workspace.list"), + (["current-workspace"], "workspace.current"), + (["new-workspace"], "workspace.create"), + ]: + for handle in (WINDOW_ID, "window:1"): + with tempfile.TemporaryDirectory(prefix="pcli-window-", dir="/tmp") as directory: + with SocketRecorder(directory) as recorder: + process = run_cli(recorder.path, ["--window", handle, *args], env_overrides={ + "PROGRAMA_WORKSPACE_ID": WORKSPACE_ID, "PROGRAMA_SURFACE_ID": SURFACE_ID, + }) + name = f"--window {handle} {' '.join(args)}" + check(process.returncode == 0, f"{name} failed: {merged_output(process)!r}") + check(not recorder.errors, f"{name}: recorder errors: {recorder.errors}") + check(not any(frame.get("method") == "window.focus" for frame in recorder.frames), + f"{name} focused the window: {recorder.frames!r}") + requests = [frame for frame in recorder.frames if frame.get("method") == method] + check(len(requests) == 1 and requests[0].get("params", {}).get("window_id") == handle, + f"{name} did not route {method} to the window: {recorder.frames!r}") + + # An explicit target in the request wins over the --window routing default. + with tempfile.TemporaryDirectory(prefix="pcli-window-", dir="/tmp") as directory: + with SocketRecorder(directory) as recorder: + process = run_cli(recorder.path, ["--window", WINDOW_ID, "send", "--surface", SURFACE_ID, "hi"]) + sends = [frame for frame in recorder.frames if frame.get("method") == "surface.send_text"] + check(process.returncode == 0, f"--window send failed: {merged_output(process)!r}") + check(len(sends) == 1 and sends[0].get("params", {}).get("surface_id") == SURFACE_ID + and "window_id" not in sends[0].get("params", {}), + f"--window overrode an explicit surface target: {recorder.frames!r}") + check(not any(frame.get("method") == "window.focus" for frame in recorder.frames), + f"--window send focused the window: {recorder.frames!r}") + + # An unknown --window still fails loudly before the command runs. + with tempfile.TemporaryDirectory(prefix="pcli-window-", dir="/tmp") as directory: + with SocketRecorder(directory) as recorder: + process = run_cli(recorder.path, ["--window", "window:9", "list-workspaces"]) + methods = [frame.get("method") for frame in recorder.frames] + check(process.returncode != 0 and "not found" in merged_output(process).lower(), + f"unknown --window did not fail: {merged_output(process)!r}") + check(methods == ["window.list"], f"unknown --window ran the command or focused: {methods!r}") + + # --password-stdin authenticates with the first stdin line and keeps the + # secret out of argv; it cannot be combined with --password. + with tempfile.TemporaryDirectory(prefix="pcli-password-stdin-") as home: + stdin_env = {"HOME": home, "CFFIXED_USER_HOME": home} + with tempfile.TemporaryDirectory(prefix="pcli-", dir="/tmp") as directory: + with SocketRecorder(directory) as recorder: + process = run_cli(recorder.path, ["--password-stdin", "ping"], + env_overrides=stdin_env, input_text="stdin-secret\nleftover\n") + logins = [frame for frame in recorder.frames if frame.get("method") == "auth.login"] + check(process.returncode == 0, f"--password-stdin ping failed: {merged_output(process)!r}") + check(len(logins) == 1 and logins[0].get("params", {}).get("password") == "stdin-secret", + f"--password-stdin did not authenticate with the first stdin line: {recorder.frames!r}") + for name, args, stdin in [ + ("--password-stdin empty", ["--password-stdin", "ping"], ""), + ("--password with --password-stdin", ["--password", "argv-secret", "--password-stdin", "ping"], "stdin-secret\n"), + ]: + with tempfile.TemporaryDirectory(prefix="pcli-", dir="/tmp") as directory: + with SocketRecorder(directory) as recorder: + process = run_cli(recorder.path, args, env_overrides=stdin_env, input_text=stdin) + check(process.returncode != 0, f"{name}: unexpectedly exited 0; output={merged_output(process)!r}") + check(recorder.accept_count == 0, f"{name}: connected before rejecting: {recorder.frames!r}") # Claude's native subagent hooks automatically create and finish one stable # virtual helper record in the sidebar helpers popover.