diff --git a/CLI/CMUXCLI+PiExtension.swift b/CLI/CMUXCLI+PiExtension.swift index c52cc9abfaec..56cd2f39229a 100644 --- a/CLI/CMUXCLI+PiExtension.swift +++ b/CLI/CMUXCLI+PiExtension.swift @@ -4,6 +4,30 @@ import Darwin extension CMUXCLI { private static let piExtensionMarker = "cmux-pi-session-extension-marker" private static let piExtensionFilename = "cmux-session.ts" + private static let piPinnedExecutableTemplate = + "const pinnedCmuxExecutable: string | null = null; // cmux-pinned-executable" + + private func renderedPiExtensionSource(fileManager: FileManager = .default) -> String { + // CLIExecutableLocator uses _NSGetExecutablePath, so Pi's mutable environment + // cannot redirect the credential-trusted executable baked into this extension. + guard let executableURL = CLIExecutableLocator.currentExecutableURL() else { + return Self.piExtensionSource + } + let executablePath = executableURL.path + var isDirectory = ObjCBool(false) + guard fileManager.fileExists(atPath: executablePath, isDirectory: &isDirectory), + !isDirectory.boolValue, + fileManager.isExecutableFile(atPath: executablePath), + let encodedPath = try? JSONEncoder().encode(executablePath), + let pathLiteral = String(data: encodedPath, encoding: .utf8) + else { + return Self.piExtensionSource + } + return Self.piExtensionSource.replacingOccurrences( + of: Self.piPinnedExecutableTemplate, + with: "const pinnedCmuxExecutable: string | null = \(pathLiteral); // cmux-pinned-executable" + ) + } private func piExtensionURL(for def: AgentHookDef) -> URL { URL(fileURLWithPath: def.resolvedConfigDir(), isDirectory: true) @@ -78,6 +102,7 @@ extension CMUXCLI { func refreshManagedPiExtensionIfNeeded(_ def: AgentHookDef) { let extensionURL = piExtensionURL(for: def) let fileManager = FileManager.default + let extensionSource = renderedPiExtensionSource(fileManager: fileManager) guard fileManager.fileExists(atPath: extensionURL.path) else { return } do { try withPiExtensionMutationLock( @@ -89,11 +114,11 @@ extension CMUXCLI { guard fileManager.fileExists(atPath: extensionURL.path) else { return } let existing = try existingPiExtensionContents(at: extensionURL, fileManager: fileManager) if existing.isEmpty { - try Self.piExtensionSource.write(to: extensionURL, atomically: true, encoding: .utf8) + try extensionSource.write(to: extensionURL, atomically: true, encoding: .utf8) return } guard existing.contains(Self.piExtensionMarker), - existing != Self.piExtensionSource + existing != extensionSource else { return } @@ -103,7 +128,7 @@ extension CMUXCLI { guard try existingPiExtensionContents(at: extensionURL, fileManager: fileManager) == existing else { return } - try Self.piExtensionSource.write(to: extensionURL, atomically: true, encoding: .utf8) + try extensionSource.write(to: extensionURL, atomically: true, encoding: .utf8) } } catch { // Hook delivery must continue when a managed extension cannot be refreshed. @@ -113,10 +138,11 @@ extension CMUXCLI { func installPiExtensionHooks(_ def: AgentHookDef) throws { let extensionURL = piExtensionURL(for: def) let fileManager = FileManager.default + let extensionSource = renderedPiExtensionSource(fileManager: fileManager) let skipConfirm = ProcessInfo.processInfo.arguments.contains("--yes") || ProcessInfo.processInfo.arguments.contains("-y") let existing = try existingPiExtensionContents(at: extensionURL, fileManager: fileManager) - if existing == Self.piExtensionSource { + if existing == extensionSource { print(String.localizedStringWithFormat( String( localized: "cli.hooks.pi.alreadyUpToDate", @@ -139,8 +165,8 @@ extension CMUXCLI { Self.printInstallPreview( path: extensionURL.path, oldContent: existing, - newContent: Self.piExtensionSource, - fallbackContent: Self.piExtensionSource + newContent: extensionSource, + fallbackContent: extensionSource ) print(String(localized: "cli.hooks.pi.confirmProceed", defaultValue: "\nProceed? [y/N] "), terminator: "") guard readLine()?.lowercased().hasPrefix("y") == true else { @@ -163,8 +189,8 @@ extension CMUXCLI { extensionURL.path )) } - if current != Self.piExtensionSource { - try Self.piExtensionSource.write(to: extensionURL, atomically: true, encoding: .utf8) + if current != extensionSource { + try extensionSource.write(to: extensionURL, atomically: true, encoding: .utf8) } } print(String.localizedStringWithFormat( diff --git a/CLI/CMUXCLI+PiExtensionSourceDispatch.swift b/CLI/CMUXCLI+PiExtensionSourceDispatch.swift index 4605324fc10b..6c672eb7ea6c 100644 --- a/CLI/CMUXCLI+PiExtensionSourceDispatch.swift +++ b/CLI/CMUXCLI+PiExtensionSourceDispatch.swift @@ -423,8 +423,14 @@ class PiCmuxCommandDispatcher { }); try { - const child = spawn(cmuxExecutable(), args, { - env: hookEnvironment(cwd, true), + const cmux = resolveCmuxExecutable(); + const isPiStop = args[0] === "hooks" && args[1] === "pi" && args[2] === "stop"; + const child = spawn(cmux.executable, args, { + env: hookEnvironment( + cwd, + cmux.trustedForCredentials, + isPiStop && cmux.trustedForCredentials, + ), stdio: ["pipe", "pipe", "pipe"], }); child.stdout.setEncoding("utf8"); diff --git a/CLI/CMUXCLI+PiExtensionSourcePart1.swift b/CLI/CMUXCLI+PiExtensionSourcePart1.swift index 37b34ef54596..cdeea40a33c8 100644 --- a/CLI/CMUXCLI+PiExtensionSourcePart1.swift +++ b/CLI/CMUXCLI+PiExtensionSourcePart1.swift @@ -11,6 +11,9 @@ import * as fs from "node:fs"; import * as path from "node:path"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; +// Replaced at install/refresh from the running cmux process, never from hook environment. +const pinnedCmuxExecutable: string | null = null; // cmux-pinned-executable + type HookExtra = Record; interface PendingCompletion { @@ -283,6 +286,79 @@ function secretLikeEnvKey(key: string): boolean { return /(TOKEN|SECRET|PASSWORD|PASSWD|API[_-]?KEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|CREDENTIAL|AUTHORIZATION|COOKIE)/i.test(key); } +// Pi's built-in providers read these values when --no-extensions removes a +// custom provider and the auto-namer falls back to a built-in model. Keep this +// exact allowlist aligned with Pi's provider environment contract; it is only +// enabled for the Stop command that can launch the tool-disabled auto-namer. +const piAutoNamingProviderEnvKeys = new Set([ + "AI_GATEWAY_API_KEY", + "ANTHROPIC_API_KEY", + "ANTHROPIC_OAUTH_TOKEN", + "ANT_LING_API_KEY", + "AWS_ACCESS_KEY_ID", + "AWS_BEARER_TOKEN_BEDROCK", + "AWS_BEDROCK_FORCE_CACHE", + "AWS_BEDROCK_FORCE_HTTP1", + "AWS_BEDROCK_SKIP_AUTH", + "AWS_CONTAINER_CREDENTIALS_FULL_URI", + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", + "AWS_DEFAULT_REGION", + "AWS_ENDPOINT_URL_BEDROCK_RUNTIME", + "AWS_PROFILE", + "AWS_REGION", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_WEB_IDENTITY_TOKEN_FILE", + "AZURE_OPENAI_API_KEY", + "AZURE_OPENAI_API_VERSION", + "AZURE_OPENAI_BASE_URL", + "AZURE_OPENAI_DEPLOYMENT_NAME_MAP", + "AZURE_OPENAI_RESOURCE_NAME", + "CEREBRAS_API_KEY", + "CLOUDFLARE_ACCOUNT_ID", + "CLOUDFLARE_API_KEY", + "CLOUDFLARE_GATEWAY_ID", + "COPILOT_GITHUB_TOKEN", + "DEEPSEEK_API_KEY", + "FIREWORKS_API_KEY", + "GCLOUD_PROJECT", + "GEMINI_API_KEY", + "GOOGLE_APPLICATION_CREDENTIALS", + "GOOGLE_CLOUD_API_KEY", + "GOOGLE_CLOUD_LOCATION", + "GOOGLE_CLOUD_PROJECT", + "GROQ_API_KEY", + "HF_TOKEN", + "KIMI_API_KEY", + "MINIMAX_API_KEY", + "MINIMAX_CN_API_KEY", + "MISTRAL_API_KEY", + "MOONSHOT_API_KEY", + "NVIDIA_API_KEY", + "OPENCODE_API_KEY", + "OPENAI_API_KEY", + "OPENROUTER_API_KEY", + "QWEN_TOKEN_PLAN_API_KEY", + "QWEN_TOKEN_PLAN_CN_API_KEY", + "RADIUS_API_KEY", + "TOGETHER_API_KEY", + "XAI_API_KEY", + "XIAOMI_API_KEY", + "XIAOMI_TOKEN_PLAN_AMS_API_KEY", + "XIAOMI_TOKEN_PLAN_CN_API_KEY", + "XIAOMI_TOKEN_PLAN_SGP_API_KEY", + "ZAI_API_KEY", + "ZAI_CODING_CN_API_KEY", + "ALL_PROXY", + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "all_proxy", + "http_proxy", + "https_proxy", + "no_proxy", +]); + function safePiEnvKey(key: string): boolean { return ( key === "PI_CODING_AGENT_DIR" || @@ -315,7 +391,8 @@ function safeCmuxEnvKey(key: string): boolean { return false; } -function shouldPreserveEnvKey(key: string): boolean { +function shouldPreserveEnvKey(key: string, includeAutoNamingProviderEnv = false): boolean { + if (includeAutoNamingProviderEnv && piAutoNamingProviderEnvKeys.has(key)) return true; if (safeCmuxEnvKey(key)) return true; if (safePiEnvKey(key)) return true; if (safeNodeEnvKey(key)) return true; @@ -328,11 +405,15 @@ function shouldPreserveEnvKey(key: string): boolean { return false; } -function hookEnvironment(cwd: string, includeSocketPassword = false): NodeJS.ProcessEnv { +function hookEnvironment( + cwd: string, + includeSocketPassword = false, + includeAutoNamingProviderEnv = false, +): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = {}; for (const [key, value] of Object.entries(process.env)) { if (value === undefined) continue; - if (shouldPreserveEnvKey(key)) env[key] = value; + if (shouldPreserveEnvKey(key, includeAutoNamingProviderEnv)) env[key] = value; } // Only cmux CLI children need the socket credential; keep it out of the generic allowlist. if (includeSocketPassword) { @@ -509,8 +590,31 @@ function warn( } } -function cmuxExecutable(): string { - return process.env.CMUX_PI_CMUX_BIN || "cmux"; +interface CmuxExecutableResolution { + executable: string; + trustedForCredentials: boolean; +} + +function trustedPinnedCmuxExecutable(): string | null { + const configured = firstString(pinnedCmuxExecutable); + if (!configured || !path.isAbsolute(configured)) return null; + try { + const resolved = fs.realpathSync(configured); + if (!fs.statSync(resolved).isFile()) return null; + fs.accessSync(resolved, fs.constants.X_OK); + return resolved; + } catch (_) { + return null; + } +} + +function resolveCmuxExecutable(): CmuxExecutableResolution { + const pinned = trustedPinnedCmuxExecutable(); + if (pinned) return { executable: pinned, trustedForCredentials: true }; + return { + executable: process.env.CMUX_PI_CMUX_BIN || process.env.CMUX_BUNDLED_CLI_PATH || "cmux", + trustedForCredentials: false, + }; } interface PiFeedCommand { diff --git a/tests/claude_teams_test_utils.py b/tests/claude_teams_test_utils.py index ccbc7a978c76..21cfcf653051 100644 --- a/tests/claude_teams_test_utils.py +++ b/tests/claude_teams_test_utils.py @@ -209,3 +209,15 @@ def install_pi_extension(config_dir: Path, cli_path: str | None = None) -> Path: if override: shutil.copyfile(override, extension_path) return extension_path + + +def set_pi_extension_pinned_cli(extension_path: Path, cli_path: str | Path | None) -> None: + """Replace the generated extension's installer-pinned cmux executable fixture.""" + source = extension_path.read_text(encoding="utf-8") + marker = "// cmux-pinned-executable" + pinned_lines = [line for line in source.splitlines() if marker in line] + if len(pinned_lines) != 1: + raise RuntimeError(f"expected one pinned cmux executable line, got {pinned_lines!r}") + literal = "null" if cli_path is None else json.dumps(str(Path(cli_path).resolve())) + replacement = f"const pinnedCmuxExecutable: string | null = {literal}; {marker}" + extension_path.write_text(source.replace(pinned_lines[0], replacement), encoding="utf-8") diff --git a/tests/test_pi_extension_dispatch.py b/tests/test_pi_extension_dispatch.py index a1610fd84508..181543df434b 100644 --- a/tests/test_pi_extension_dispatch.py +++ b/tests/test_pi_extension_dispatch.py @@ -10,7 +10,11 @@ import tempfile from pathlib import Path -from claude_teams_test_utils import install_pi_extension, resolve_cmux_cli +from claude_teams_test_utils import ( + install_pi_extension, + resolve_cmux_cli, + set_pi_extension_pinned_cli, +) def make_executable(path: Path, content: str) -> None: @@ -27,9 +31,11 @@ def run_extension( source: str, extra_env: dict[str, str], ) -> subprocess.CompletedProcess[str]: + set_pi_extension_pinned_cli(extension_path, fake_cmux) env = os.environ.copy() env["CMUX_TEST_PI_EXTENSION_PATH"] = str(extension_path) env["CMUX_PI_CMUX_BIN"] = str(fake_cmux) + env["CMUX_BUNDLED_CLI_PATH"] = str(fake_cmux) env["CMUX_SURFACE_ID"] = "00000000-0000-0000-0000-000000008672" env["CMUX_WORKSPACE_ID"] = "00000000-0000-0000-0000-000000008673" env.update(extra_env) diff --git a/tests/test_pi_extension_install.py b/tests/test_pi_extension_install.py index 2d03b2a7a9ad..6b002e845f87 100644 --- a/tests/test_pi_extension_install.py +++ b/tests/test_pi_extension_install.py @@ -6,13 +6,17 @@ from __future__ import annotations import base64 +from collections.abc import Iterator +from contextlib import contextmanager import fcntl import json import os import signal import shutil +import socketserver import subprocess import tempfile +import threading import time from pathlib import Path @@ -21,6 +25,7 @@ FOCUSED_WORKSPACE_ID, install_pi_extension, resolve_cmux_cli, + set_pi_extension_pinned_cli, ) NONBLOCKING_LOCK_TIMEOUT_SECONDS = 5.0 @@ -96,6 +101,269 @@ def payloads_from_log(text: str) -> list[dict[str, object]]: return payloads +class _AutoNamingSocketHandler(socketserver.StreamRequestHandler): + def handle(self) -> None: + while line := self.rfile.readline(): + decoded = line.decode("utf-8").rstrip("\r\n") + if decoded.startswith("auth "): + self.wfile.write(b"OK\n") + self.wfile.flush() + continue + try: + request = json.loads(decoded) + except json.JSONDecodeError: + self.wfile.write(b"OK\n") + self.wfile.flush() + continue + + method = str(request.get("method", "")) + params = request.get("params") or {} + self.server.requests.append(request) # type: ignore[attr-defined] + workspace_id = self.server.workspace_id # type: ignore[attr-defined] + surface_id = self.server.surface_id # type: ignore[attr-defined] + if method == "agent.resolve_delivery_target": + result: dict[str, object] = { + "source": "surface", + "workspace_id": workspace_id, + "surface_id": surface_id, + } + elif method == "surface.list": + result = { + "workspace_id": workspace_id, + "surfaces": [ + { + "id": surface_id, + "ref": "surface:1", + "index": 1, + "focused": True, + } + ], + } + elif method == "workspace.set_auto_title" and params.get("probe") is True: + result = { + "enabled": True, + "summarizer_agent": None, + "workspace_user_owned": False, + } + elif method == "workspace.set_auto_title" and "failure" in params: + result = {"recorded": True, "enabled": True} + elif method == "workspace.set_auto_title": + result = { + "workspace_applied": True, + "surface_applied": False, + "enabled": True, + } + elif method == "surface.resume.get": + result = {"resume_binding": None} + else: + result = {} + response = {"ok": True, "result": result, "id": request.get("id")} + try: + self.wfile.write((json.dumps(response) + "\n").encode("utf-8")) + self.wfile.flush() + except BrokenPipeError: + return + + +class _AutoNamingSocketServer(socketserver.ThreadingUnixStreamServer): + allow_reuse_address = True + + def __init__(self, socket_path: str, workspace_id: str, surface_id: str) -> None: + self.workspace_id = workspace_id + self.surface_id = surface_id + self.requests: list[dict[str, object]] = [] + super().__init__(socket_path, _AutoNamingSocketHandler) + + +@contextmanager +def auto_naming_socket_server( + workspace_id: str, + surface_id: str, +) -> Iterator[tuple[str, _AutoNamingSocketServer]]: + # Pin to /tmp: macOS AF_UNIX paths are limited to roughly 104 bytes, while + # the default TMPDIR under /var/folders can exceed that limit. + with tempfile.TemporaryDirectory(prefix="cmux-pi-autoname-socket-", dir="/tmp") as socket_dir: + socket_path = str(Path(socket_dir) / "cmux.sock") + server = _AutoNamingSocketServer(socket_path, workspace_id, surface_id) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield socket_path, server + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + +def check_auto_naming_from_generated_hook_environment( + *, + bun: str, + root: Path, + cli_path: str, +) -> int: + try: + extension_path = install_pi_extension(root / "auto-name-pi-agent", cli_path) + except RuntimeError as exc: + print(f"FAIL: auto-name Pi extension install failed: {exc}") + return 1 + workspace_id = "11111111-1111-4111-8111-111111111111" + surface_id = "44444444-4444-4444-8444-444444444444" + session_id = "pi-auto-name-restricted-environment" + state_dir = root / "auto-name-state" + state_dir.mkdir() + state_path = state_dir / "pi-hook-sessions.json" + auto_name_bin = root / "auto-name-bin" + auto_name_bin.mkdir() + auto_name_log = root / "auto-name-pi.log" + fake_pi = auto_name_bin / "pi" + make_executable( + fake_pi, + f"""#!/usr/bin/env bash +set -euo pipefail +printf 'argv=%s\n' "$*" >> {str(auto_name_log)!r} +if [ "${{ANTHROPIC_API_KEY-}}" != "pi-autoname-provider-key" ]; then + printf 'exit=1 No API key found for the selected model.\n' >> {str(auto_name_log)!r} + printf 'No API key found for the selected model.\n' >&2 + exit 1 +fi +if [ "${{AWS_ENDPOINT_URL_BEDROCK_RUNTIME-}}" != "https://bedrock-proxy.example.invalid/runtime" ] \ + || [ "${{AWS_BEDROCK_SKIP_AUTH-}}" != "1" ] \ + || [ "${{AWS_BEDROCK_FORCE_HTTP1-}}" != "1" ] \ + || [ "${{AWS_BEDROCK_FORCE_CACHE-}}" != "1" ] \ + || [ "${{HTTPS_PROXY-}}" != "http://provider-proxy.example.invalid:8080" ]; then + printf 'exit=1 Bedrock provider environment missing.\n' >> {str(auto_name_log)!r} + printf 'Bedrock provider environment missing.\n' >&2 + exit 1 +fi +printf 'exit=0 title=Repair Pi Auto Naming\n' >> {str(auto_name_log)!r} +printf 'Repair Pi Auto Naming\n' +""", + ) + + modern_package = root / "auto-name-node-modules" / "@earendil-works" / "pi-coding-agent" + modern_cli = modern_package / "dist" / "cli.js" + modern_cli.parent.mkdir(parents=True) + make_executable(modern_cli, "#!/usr/bin/env node\n") + (modern_package / "package.json").write_text( + json.dumps({"name": "@earendil-works/pi-coding-agent", "version": "0.81.1"}), + encoding="utf-8", + ) + + source = """ +const extensionPath = process.env.CMUX_TEST_PI_EXTENSION_PATH; +const mod = await import(extensionPath); +const handlers = new Map(); +mod.default({ on(name, handler) { handlers.set(name, handler); } }); +process.argv.splice( + 0, + process.argv.length, + "/opt/homebrew/bin/node", + process.env.CMUX_TEST_PI_MODERN_SCRIPT_PATH, + "--model", + "pi-codex/gpt-5.4" +); +const ctx = { + cwd: "/tmp/pi-auto-name-project", + isIdle() { return true; }, + sessionManager: { + getSessionId() { return "pi-auto-name-restricted-environment"; } + } +}; +handlers.get("before_agent_start")({ + prompt: "Fix Pi workspace auto naming after a resumed session" +}, ctx); +handlers.get("agent_end")({ + messages: [ + { role: "user", content: "Fix Pi workspace auto naming after a resumed session" }, + { role: "assistant", content: "The restricted hook environment drops the fallback provider credential" } + ], + stopReason: "completed" +}, ctx); +handlers.get("agent_settled")({}, ctx); +await handlers.get("session_shutdown")({ reason: "test complete" }, ctx); +""" + + with auto_naming_socket_server(workspace_id, surface_id) as (socket_path, server): + env = os.environ.copy() + env.update( + { + "PATH": str(auto_name_bin) + os.pathsep + env.get("PATH", ""), + "PI_CODING_AGENT_DIR": str(extension_path.parent.parent), + "CMUX_TEST_PI_EXTENSION_PATH": str(extension_path), + "CMUX_TEST_PI_MODERN_SCRIPT_PATH": str(modern_cli), + "CMUX_PI_CMUX_BIN": cli_path, + "CMUX_BUNDLED_CLI_PATH": cli_path, + "CMUX_SOCKET_PATH": socket_path, + "CMUX_WORKSPACE_ID": workspace_id, + "CMUX_SURFACE_ID": surface_id, + "CMUX_AGENT_HOOK_STATE_DIR": str(state_dir), + "CMUX_CLI_SENTRY_DISABLED": "1", + "ANTHROPIC_API_KEY": "pi-autoname-provider-key", + "AWS_ENDPOINT_URL_BEDROCK_RUNTIME": "https://bedrock-proxy.example.invalid/runtime", + "AWS_BEDROCK_SKIP_AUTH": "1", + "AWS_BEDROCK_FORCE_HTTP1": "1", + "AWS_BEDROCK_FORCE_CACHE": "1", + "HTTPS_PROXY": "http://provider-proxy.example.invalid:8080", + } + ) + result = subprocess.run( + [bun, "--eval", source], + cwd=root, + capture_output=True, + text=True, + check=False, + env=env, + timeout=30, + ) + if result.returncode != 0: + print( + "FAIL: Pi turn-end auto-name harness failed: " + f"exit={result.returncode} stdout={result.stdout!r} stderr={result.stderr!r}" + ) + return 1 + + deadline = time.monotonic() + 10 + record: dict[str, object] = {} + while time.monotonic() < deadline: + try: + store = json.loads(state_path.read_text(encoding="utf-8")) + record = (store.get("sessions") or {}).get(session_id) or {} + except (FileNotFoundError, json.JSONDecodeError): + record = {} + if record.get("autoNameLastAttemptAt") and record.get("autoNameLastNamedAt"): + break + time.sleep(0.05) + + pi_log = auto_name_log.read_text(encoding="utf-8") if auto_name_log.exists() else "" + if "--no-extensions" not in pi_log: + print(f"FAIL: Pi auto-name did not run with --no-extensions: {pi_log!r}") + return 1 + if "exit=0 title=Repair Pi Auto Naming" not in pi_log: + print( + "FAIL: Pi auto-name lost its selected fallback provider credential under " + f"hookEnvironment(): log={pi_log!r} record={record!r}" + ) + return 1 + if not record.get("autoNameLastAttemptAt") or not record.get("autoNameLastNamedAt"): + print(f"FAIL: successful Pi turn-end naming did not persist attempt/name timestamps: {record!r}") + return 1 + if record.get("autoNameLastTitle") != "Repair Pi Auto Naming": + print(f"FAIL: Pi auto-name did not persist the returned title: {record!r}") + return 1 + applied_titles = [ + request.get("params", {}).get("title") + for request in server.requests + if request.get("method") == "workspace.set_auto_title" + and isinstance(request.get("params"), dict) + and "title" in request.get("params", {}) + ] + if applied_titles != ["Repair Pi Auto Naming"]: + print(f"FAIL: Pi turn-end naming did not apply the returned title: {applied_titles!r}") + return 1 + + return 0 + + def main() -> int: bun = shutil.which("bun") if bun is None: @@ -127,6 +395,17 @@ def main() -> int: if "@earendil-works/pi-coding-agent" not in extension_text: print("FAIL: generated Pi extension does not import the current Pi package") return 1 + pinned_line = next( + (line for line in extension_text.splitlines() if "// cmux-pinned-executable" in line), + "", + ) + try: + pinned_cli_path = Path(json.loads(pinned_line.split("=", 1)[1].split(";", 1)[0].strip())) + except (IndexError, json.JSONDecodeError, TypeError): + pinned_cli_path = Path() + if not pinned_cli_path.is_file() or not pinned_cli_path.samefile(cli_path): + print("FAIL: generated Pi extension did not pin the installing cmux executable") + return 1 extension_path.write_text( "// cmux-pi-session-extension-marker v2\n" @@ -359,24 +638,28 @@ def main() -> int: printf '%s\n' "$*" >> "$CMUX_TEST_PI_ARGS_LOG" payload="$(cat)" printf '%s\n' "$payload" >> "$CMUX_TEST_PI_STDIN_LOG" -{ - printf 'kind=%s\n' "${CMUX_AGENT_LAUNCH_KIND-}" - printf 'cwd=%s\n' "${CMUX_AGENT_LAUNCH_CWD-}" - printf 'argv=%s\n' "${CMUX_AGENT_LAUNCH_ARGV_B64-}" - if [ -n "${OPENAI_API_KEY-}" ]; then printf 'OPENAI_API_KEY=present\n'; fi - if [ -n "${ANTHROPIC_AUTH_TOKEN-}" ]; then printf 'ANTHROPIC_AUTH_TOKEN=present\n'; fi - if [ -n "${CUSTOM_PASSWORD-}" ]; then printf 'CUSTOM_PASSWORD=present\n'; fi - if [ -n "${AMP_API_KEY-}" ]; then printf 'AMP_API_KEY=present\n'; fi - if [ -n "${CMUX_LEAK_TOKEN-}" ]; then printf 'CMUX_LEAK_TOKEN=present\n'; fi - if [ -n "${DATABASE_URL-}" ]; then printf 'DATABASE_URL=present\n'; fi - if [ -n "${DB_PASS-}" ]; then printf 'DB_PASS=present\n'; fi - if [ -n "${SENTRY_DSN-}" ]; then printf 'SENTRY_DSN=present\n'; fi - if [ -n "${GH_PAT-}" ]; then printf 'GH_PAT=present\n'; fi - if [ -n "${CLOUDFLARE_AUTH_KEY-}" ]; then printf 'CLOUDFLARE_AUTH_KEY=present\n'; fi - if [ -n "${STRIPE_SK-}" ]; then printf 'STRIPE_SK=present\n'; fi - if [ -n "${SLACK_WEBHOOK_URL-}" ]; then printf 'SLACK_WEBHOOK_URL=present\n'; fi - if [ -n "${CMUX_TEST_PI_TOKEN-}" ]; then printf 'CMUX_TEST_PI_TOKEN=present\n'; fi -} >> "$CMUX_TEST_PI_ENV_LOG" +record="command=$*|kind=${CMUX_AGENT_LAUNCH_KIND-}|cwd=${CMUX_AGENT_LAUNCH_CWD-}|argv=${CMUX_AGENT_LAUNCH_ARGV_B64-}" +if [ -n "${ANTHROPIC_API_KEY-}" ]; then record="$record|ANTHROPIC_API_KEY=present"; fi +if [ -n "${OPENAI_API_KEY-}" ]; then record="$record|OPENAI_API_KEY=present"; fi +if [ -n "${AWS_ENDPOINT_URL_BEDROCK_RUNTIME-}" ]; then record="$record|AWS_ENDPOINT_URL_BEDROCK_RUNTIME=present"; fi +if [ -n "${AWS_BEDROCK_SKIP_AUTH-}" ]; then record="$record|AWS_BEDROCK_SKIP_AUTH=present"; fi +if [ -n "${AWS_BEDROCK_FORCE_HTTP1-}" ]; then record="$record|AWS_BEDROCK_FORCE_HTTP1=present"; fi +if [ -n "${AWS_BEDROCK_FORCE_CACHE-}" ]; then record="$record|AWS_BEDROCK_FORCE_CACHE=present"; fi +if [ -n "${HTTPS_PROXY-}" ]; then record="$record|HTTPS_PROXY=present"; fi +if [ -n "${CMUX_SOCKET_PASSWORD-}" ]; then record="$record|CMUX_SOCKET_PASSWORD=present"; fi +if [ -n "${ANTHROPIC_AUTH_TOKEN-}" ]; then record="$record|ANTHROPIC_AUTH_TOKEN=present"; fi +if [ -n "${CUSTOM_PASSWORD-}" ]; then record="$record|CUSTOM_PASSWORD=present"; fi +if [ -n "${AMP_API_KEY-}" ]; then record="$record|AMP_API_KEY=present"; fi +if [ -n "${CMUX_LEAK_TOKEN-}" ]; then record="$record|CMUX_LEAK_TOKEN=present"; fi +if [ -n "${DATABASE_URL-}" ]; then record="$record|DATABASE_URL=present"; fi +if [ -n "${DB_PASS-}" ]; then record="$record|DB_PASS=present"; fi +if [ -n "${SENTRY_DSN-}" ]; then record="$record|SENTRY_DSN=present"; fi +if [ -n "${GH_PAT-}" ]; then record="$record|GH_PAT=present"; fi +if [ -n "${CLOUDFLARE_AUTH_KEY-}" ]; then record="$record|CLOUDFLARE_AUTH_KEY=present"; fi +if [ -n "${STRIPE_SK-}" ]; then record="$record|STRIPE_SK=present"; fi +if [ -n "${SLACK_WEBHOOK_URL-}" ]; then record="$record|SLACK_WEBHOOK_URL=present"; fi +if [ -n "${CMUX_TEST_PI_TOKEN-}" ]; then record="$record|CMUX_TEST_PI_TOKEN=present"; fi +printf '%s\n' "$record" >> "$CMUX_TEST_PI_ENV_LOG" case "$*" in *"hooks pi notification"*) if printf '%s' "$payload" | grep -q 'pi-session-notification-fails'; then @@ -416,6 +699,22 @@ def main() -> int: """, ) + shadow_cmux = bin_dir / "cmux" + shadow_env_log = root / "shadow-cmux-env.log" + make_executable( + shadow_cmux, + """#!/usr/bin/env bash +set -euo pipefail +record="command=$*" +for key in ANTHROPIC_API_KEY OPENAI_API_KEY AWS_ENDPOINT_URL_BEDROCK_RUNTIME AWS_BEDROCK_SKIP_AUTH AWS_BEDROCK_FORCE_HTTP1 AWS_BEDROCK_FORCE_CACHE HTTPS_PROXY CMUX_SOCKET_PASSWORD; do + if [ -n "${!key-}" ]; then record="$record|$key=present"; fi +done +printf '%s\n' "$record" >> "$CMUX_TEST_PI_SHADOW_ENV_LOG" +exec "$CMUX_TEST_PI_TRUSTED_CLI" "$@" +""", + ) + set_pi_extension_pinned_cli(extension_path, fake_cmux) + check_env = env.copy() for key in ( "CMUX_AGENT_LAUNCH_ARGV_B64", @@ -428,16 +727,26 @@ def main() -> int: check_env["CMUX_TEST_PI_EXTENSION_PATH"] = str(extension_path) check_env["CMUX_SURFACE_ID"] = "surface-pi-test" check_env["CMUX_WORKSPACE_ID"] = "workspace-pi-test" - check_env["CMUX_PI_CMUX_BIN"] = str(fake_cmux) + check_env["CMUX_PI_CMUX_BIN"] = "cmux" + check_env["CMUX_BUNDLED_CLI_PATH"] = str(shadow_cmux) check_env["CMUX_TEST_PI_ARGS_LOG"] = str(fake_args_log) check_env["CMUX_TEST_PI_STDIN_LOG"] = str(fake_stdin_log) check_env["CMUX_TEST_PI_ENV_LOG"] = str(fake_env_log) check_env["CMUX_TEST_PI_BINDING_FILE"] = str(fake_binding) + check_env["CMUX_TEST_PI_SHADOW_ENV_LOG"] = str(shadow_env_log) + check_env["CMUX_TEST_PI_TRUSTED_CLI"] = str(fake_cmux) check_env["CMUX_TEST_PI_MODERN_SCRIPT_PATH"] = str(modern_cli) check_env["CMUX_TEST_PI_LEGACY_SCRIPT_PATH"] = str(legacy_pi) check_env["CMUX_TEST_PI_UNKNOWN_SCRIPT_PATH"] = str(root / "unknown-bin" / "pi") check_env["CMUX_TEST_PI_MALFORMED_SCRIPT_PATH"] = str(malformed_cli) - check_env["OPENAI_API_KEY"] = "openai-secret-should-not-leak" + check_env["ANTHROPIC_API_KEY"] = "anthropic-autoname-provider-key" + check_env["OPENAI_API_KEY"] = "openai-autoname-provider-key" + check_env["AWS_ENDPOINT_URL_BEDROCK_RUNTIME"] = "https://bedrock-proxy.example.invalid/runtime" + check_env["AWS_BEDROCK_SKIP_AUTH"] = "1" + check_env["AWS_BEDROCK_FORCE_HTTP1"] = "1" + check_env["AWS_BEDROCK_FORCE_CACHE"] = "1" + check_env["HTTPS_PROXY"] = "http://provider-proxy.example.invalid:8080" + check_env["CMUX_SOCKET_PASSWORD"] = "socket-password-for-trusted-cli" check_env["ANTHROPIC_AUTH_TOKEN"] = "anthropic-secret-should-not-leak" check_env["CUSTOM_PASSWORD"] = "password-should-not-leak" check_env["AMP_API_KEY"] = "amp-secret-should-not-leak" @@ -813,7 +1122,7 @@ def main() -> int: timeout=20.0, expected_substrings=('"hook_event_name":"PostToolUse"',), ) - env_log = wait_for_text(fake_env_log, 50 * 3, timeout=20.0) + env_log = wait_for_text(fake_env_log, 50, timeout=20.0) for expected in [ "hooks pi session-start", "hooks pi prompt-submit", @@ -1082,10 +1391,48 @@ def main() -> int: if "kind=pi" not in env_log or "cwd=/tmp/pi-project" not in env_log or "argv=" not in env_log: print(f"FAIL: extension did not pass launch metadata environment, got {env_log!r}") return 1 + env_records = [] + for raw_record in env_log.splitlines(): + fields = [field for field in raw_record.split("|") if field] + if not fields: + continue + command = next((field.removeprefix("command=") for field in fields if field.startswith("command=")), "") + present = {field.removesuffix("=present") for field in fields if field.endswith("=present")} + env_records.append((command, present)) + auto_naming_env_keys = { + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "AWS_ENDPOINT_URL_BEDROCK_RUNTIME", + "AWS_BEDROCK_SKIP_AUTH", + "AWS_BEDROCK_FORCE_HTTP1", + "AWS_BEDROCK_FORCE_CACHE", + "HTTPS_PROXY", + } + stop_env_records = [record for record in env_records if "hooks pi stop" in record[0]] + if not stop_env_records or any( + not auto_naming_env_keys.issubset(present) + for _, present in stop_env_records + ): + print(f"FAIL: Pi Stop hooks did not receive the auto-naming provider environment: {stop_env_records!r}") + return 1 + provider_leaks = [ + (command, present & auto_naming_env_keys) + for command, present in env_records + if "hooks pi stop" not in command + and present & auto_naming_env_keys + ] + if provider_leaks: + print(f"FAIL: extension passed the auto-naming provider environment outside Pi Stop: {provider_leaks!r}") + return 1 + if shadow_env_log.exists() and shadow_env_log.read_text(encoding="utf-8").strip(): + print( + "FAIL: generated Pi extension ignored the trusted bundled CLI and invoked a PATH shadow: " + f"{shadow_env_log.read_text(encoding='utf-8')!r}" + ) + return 1 leaked = [ name for name in [ - "OPENAI_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CUSTOM_PASSWORD", "AMP_API_KEY", @@ -1104,7 +1451,15 @@ def main() -> int: if leaked: print(f"FAIL: extension leaked secret environment keys to hook subprocesses: {leaked}; env={env_log!r}") return 1 - argv_line = next((line for line in env_log.splitlines() if line.startswith("argv=")), "") + argv_line = next( + ( + field + for line in env_log.splitlines() + for field in line.split("|") + if field.startswith("argv=") + ), + "", + ) try: decoded_argv = [ value @@ -1123,6 +1478,85 @@ def main() -> int: print(f"FAIL: extension captured wrong Pi launch argv; expected {expected_argv!r}, got {decoded_argv!r}") return 1 + untrusted_state_dir = root / "untrusted-cmux-state" + untrusted_state_dir.mkdir() + untrusted_extension_path = root / "untrusted-cmux-session.ts" + shutil.copyfile(extension_path, untrusted_extension_path) + set_pi_extension_pinned_cli(untrusted_extension_path, None) + untrusted_env = check_env.copy() + untrusted_env["CMUX_TEST_PI_EXTENSION_PATH"] = str(untrusted_extension_path) + untrusted_env.pop("CMUX_PI_CMUX_BIN", None) + untrusted_env["CMUX_BUNDLED_CLI_PATH"] = str(shadow_cmux) + untrusted_env["CMUX_AGENT_HOOK_STATE_DIR"] = str(untrusted_state_dir) + untrusted_source = """ +const extensionPath = process.env.CMUX_TEST_PI_EXTENSION_PATH; +const mod = await import(extensionPath); +const handlers = new Map(); +mod.default({ on(name, handler) { handlers.set(name, handler); } }); +process.argv.splice( + 0, + process.argv.length, + "/opt/homebrew/bin/node", + process.env.CMUX_TEST_PI_MODERN_SCRIPT_PATH, + "--model", + "anthropic/claude-sonnet-4-5" +); +const ctx = { + cwd: "/tmp/pi-untrusted-cmux-project", + isIdle() { return true; }, + sessionManager: { + getSessionId() { return "pi-session-untrusted-cmux"; } + } +}; +await handlers.get("session_start")({}, ctx); +await handlers.get("before_agent_start")({ prompt: "verify credential boundary" }, ctx); +await handlers.get("agent_end")({ + messages: [{ role: "assistant", content: "done" }], + stopReason: "completed" +}, ctx); +await handlers.get("agent_settled")({}, ctx); +await handlers.get("session_shutdown")({ reason: "test complete" }, ctx); +""" + untrusted_result = subprocess.run( + [bun, "--eval", untrusted_source], + cwd=root, + capture_output=True, + text=True, + check=False, + env=untrusted_env, + timeout=30, + ) + if untrusted_result.returncode != 0: + print( + "FAIL: untrusted cmux fallback harness failed: " + f"exit={untrusted_result.returncode} stdout={untrusted_result.stdout!r} " + f"stderr={untrusted_result.stderr!r}" + ) + return 1 + shadow_env = wait_for_text( + shadow_env_log, + 1, + timeout=10.0, + expected_substrings=("hooks pi stop",), + ) + credential_names = auto_naming_env_keys | {"CMUX_SOCKET_PASSWORD"} + shadow_leaks = [ + (line, sorted(name for name in credential_names if f"|{name}=present" in line)) + for line in shadow_env.splitlines() + if any(f"|{name}=present" in line for name in credential_names) + ] + if shadow_leaks: + print(f"FAIL: untrusted cmux fallback received credentials: {shadow_leaks!r}") + return 1 + + auto_name_result = check_auto_naming_from_generated_hook_environment( + bun=bun, + root=root, + cli_path=cli_path, + ) + if auto_name_result != 0: + return auto_name_result + print("PASS: generated Pi extension installs and emits cmux hooks") return 0