From ba192850469c9499c84228026de18edd2279b753 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 3 Aug 2026 14:36:19 -0700 Subject: [PATCH 1/4] Add failing Pi auto-naming credential regression test --- tests/test_pi_extension_install.py | 253 +++++++++++++++++++++++++++++ 1 file changed, 253 insertions(+) diff --git a/tests/test_pi_extension_install.py b/tests/test_pi_extension_install.py index e41614897ca1..5e18e54328ad 100644 --- a/tests/test_pi_extension_install.py +++ b/tests/test_pi_extension_install.py @@ -6,15 +6,19 @@ from __future__ import annotations import base64 +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 +from typing import Iterator from claude_teams_test_utils import ( FOCUSED_SURFACE_ID, @@ -96,6 +100,246 @@ 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")} + self.wfile.write((json.dumps(response) + "\n").encode("utf-8")) + self.wfile.flush() + + +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]]: + 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, + extension_path: Path, + cli_path: str, +) -> int: + 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 +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", + } + ) + 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: @@ -1049,6 +1293,15 @@ def main() -> int: print(f"FAIL: extension captured wrong Pi launch argv; expected {expected_argv!r}, got {decoded_argv!r}") return 1 + auto_name_result = check_auto_naming_from_generated_hook_environment( + bun=bun, + root=root, + extension_path=extension_path, + 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 From ec5656dbfdafb762cdbf10e3e6c00a29dc2b134f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 3 Aug 2026 15:27:23 -0700 Subject: [PATCH 2/4] Preserve Pi auto-naming provider credentials --- CLI/CMUXCLI+PiExtensionSourceDispatch.swift | 4 +- CLI/CMUXCLI+PiExtensionSourcePart1.swift | 72 +++++++++++++++++- tests/test_pi_extension_install.py | 81 +++++++++++++++------ 3 files changed, 129 insertions(+), 28 deletions(-) diff --git a/CLI/CMUXCLI+PiExtensionSourceDispatch.swift b/CLI/CMUXCLI+PiExtensionSourceDispatch.swift index 4605324fc10b..b409cbf72fff 100644 --- a/CLI/CMUXCLI+PiExtensionSourceDispatch.swift +++ b/CLI/CMUXCLI+PiExtensionSourceDispatch.swift @@ -423,8 +423,10 @@ class PiCmuxCommandDispatcher { }); try { + const includeAutoNamingProviderEnv = + args[0] === "hooks" && args[1] === "pi" && args[2] === "stop"; const child = spawn(cmuxExecutable(), args, { - env: hookEnvironment(cwd, true), + env: hookEnvironment(cwd, true, includeAutoNamingProviderEnv), stdio: ["pipe", "pipe", "pipe"], }); child.stdout.setEncoding("utf8"); diff --git a/CLI/CMUXCLI+PiExtensionSourcePart1.swift b/CLI/CMUXCLI+PiExtensionSourcePart1.swift index 5babceaac3e1..1c6369c2f1f6 100644 --- a/CLI/CMUXCLI+PiExtensionSourcePart1.swift +++ b/CLI/CMUXCLI+PiExtensionSourcePart1.swift @@ -282,6 +282,67 @@ 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_CONTAINER_CREDENTIALS_FULL_URI", + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", + "AWS_DEFAULT_REGION", + "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", +]); + function safePiEnvKey(key: string): boolean { return ( key === "PI_CODING_AGENT_DIR" || @@ -314,7 +375,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; @@ -327,11 +389,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) { diff --git a/tests/test_pi_extension_install.py b/tests/test_pi_extension_install.py index 5e18e54328ad..4802621dcc3b 100644 --- a/tests/test_pi_extension_install.py +++ b/tests/test_pi_extension_install.py @@ -157,8 +157,11 @@ def handle(self) -> None: else: result = {} response = {"ok": True, "result": result, "id": request.get("id")} - self.wfile.write((json.dumps(response) + "\n").encode("utf-8")) - self.wfile.flush() + try: + self.wfile.write((json.dumps(response) + "\n").encode("utf-8")) + self.wfile.flush() + except BrokenPipeError: + return class _AutoNamingSocketServer(socketserver.ThreadingUnixStreamServer): @@ -603,24 +606,22 @@ 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 "${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 @@ -681,7 +682,8 @@ def main() -> int: 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["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" @@ -1006,7 +1008,7 @@ def main() -> int: timeout=20.0, expected_substrings=('"hook_event_name":"PostToolUse"',), ) - env_log = wait_for_text(fake_env_log, 38 * 3, timeout=20.0) + env_log = wait_for_text(fake_env_log, 38, timeout=20.0) for expected in [ "hooks pi session-start", "hooks pi prompt-submit", @@ -1252,10 +1254,33 @@ 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)) + stop_env_records = [record for record in env_records if "hooks pi stop" in record[0]] + if not stop_env_records or any( + not {"ANTHROPIC_API_KEY", "OPENAI_API_KEY"}.issubset(present) + for _, present in stop_env_records + ): + print(f"FAIL: Pi Stop hooks did not receive auto-naming provider credentials: {stop_env_records!r}") + return 1 + provider_leaks = [ + (command, present & {"ANTHROPIC_API_KEY", "OPENAI_API_KEY"}) + for command, present in env_records + if "hooks pi stop" not in command + and present & {"ANTHROPIC_API_KEY", "OPENAI_API_KEY"} + ] + if provider_leaks: + print(f"FAIL: extension passed auto-naming provider credentials outside Pi Stop: {provider_leaks!r}") + return 1 leaked = [ name for name in [ - "OPENAI_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CUSTOM_PASSWORD", "AMP_API_KEY", @@ -1274,7 +1299,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 From 8acfacf889c597ea2ced07164fe01fb02aa4d820 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 3 Aug 2026 18:49:16 -0700 Subject: [PATCH 3/4] Harden Pi auto-naming credential handoff --- CLI/CMUXCLI+PiExtensionSourceDispatch.swift | 12 +- CLI/CMUXCLI+PiExtensionSourcePart1.swift | 39 +++++- tests/test_pi_extension_dispatch.py | 1 + tests/test_pi_extension_install.py | 137 +++++++++++++++++++- 4 files changed, 177 insertions(+), 12 deletions(-) diff --git a/CLI/CMUXCLI+PiExtensionSourceDispatch.swift b/CLI/CMUXCLI+PiExtensionSourceDispatch.swift index b409cbf72fff..6c672eb7ea6c 100644 --- a/CLI/CMUXCLI+PiExtensionSourceDispatch.swift +++ b/CLI/CMUXCLI+PiExtensionSourceDispatch.swift @@ -423,10 +423,14 @@ class PiCmuxCommandDispatcher { }); try { - const includeAutoNamingProviderEnv = - args[0] === "hooks" && args[1] === "pi" && args[2] === "stop"; - const child = spawn(cmuxExecutable(), args, { - env: hookEnvironment(cwd, true, includeAutoNamingProviderEnv), + 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 1bd6388cb122..c11f56f69f0a 100644 --- a/CLI/CMUXCLI+PiExtensionSourcePart1.swift +++ b/CLI/CMUXCLI+PiExtensionSourcePart1.swift @@ -294,9 +294,13 @@ const piAutoNamingProviderEnvKeys = new Set([ "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", @@ -342,6 +346,14 @@ const piAutoNamingProviderEnvKeys = new Set([ "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 { @@ -575,8 +587,31 @@ function warn( } } -function cmuxExecutable(): string { - return process.env.CMUX_PI_CMUX_BIN || "cmux"; +interface CmuxExecutableResolution { + executable: string; + trustedForCredentials: boolean; +} + +function trustedBundledCmuxExecutable(): string | null { + const configured = firstString(process.env.CMUX_BUNDLED_CLI_PATH); + 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 bundled = trustedBundledCmuxExecutable(); + if (bundled) return { executable: bundled, trustedForCredentials: true }; + return { + executable: process.env.CMUX_PI_CMUX_BIN || "cmux", + trustedForCredentials: false, + }; } interface PiFeedCommand { diff --git a/tests/test_pi_extension_dispatch.py b/tests/test_pi_extension_dispatch.py index a1610fd84508..a7df06d2a08f 100644 --- a/tests/test_pi_extension_dispatch.py +++ b/tests/test_pi_extension_dispatch.py @@ -30,6 +30,7 @@ def run_extension( 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 2524477333ad..016515e9cbb3 100644 --- a/tests/test_pi_extension_install.py +++ b/tests/test_pi_extension_install.py @@ -219,6 +219,15 @@ def check_auto_naming_from_generated_hook_environment( 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' """, @@ -283,6 +292,11 @@ def check_auto_naming_from_generated_hook_environment( "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( @@ -609,6 +623,12 @@ def main() -> int: 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 @@ -661,6 +681,21 @@ 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" "$@" +""", + ) + check_env = env.copy() for key in ( "CMUX_AGENT_LAUNCH_ARGV_B64", @@ -673,17 +708,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(fake_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["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" @@ -1336,21 +1380,36 @@ def main() -> int: 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 {"ANTHROPIC_API_KEY", "OPENAI_API_KEY"}.issubset(present) + not auto_naming_env_keys.issubset(present) for _, present in stop_env_records ): - print(f"FAIL: Pi Stop hooks did not receive auto-naming provider credentials: {stop_env_records!r}") + print(f"FAIL: Pi Stop hooks did not receive the auto-naming provider environment: {stop_env_records!r}") return 1 provider_leaks = [ - (command, present & {"ANTHROPIC_API_KEY", "OPENAI_API_KEY"}) + (command, present & auto_naming_env_keys) for command, present in env_records if "hooks pi stop" not in command - and present & {"ANTHROPIC_API_KEY", "OPENAI_API_KEY"} + and present & auto_naming_env_keys ] if provider_leaks: - print(f"FAIL: extension passed auto-naming provider credentials outside Pi Stop: {provider_leaks!r}") + 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 @@ -1400,6 +1459,72 @@ 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_env = check_env.copy() + untrusted_env["CMUX_BUNDLED_CLI_PATH"] = "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, From ebc03ed792fd3ce078303e3a4ab6a6d2a6e3fb59 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 3 Aug 2026 19:05:01 -0700 Subject: [PATCH 4/4] Pin trusted CLI in generated Pi extension --- CLI/CMUXCLI+PiExtension.swift | 42 +++++++++++++++++++----- CLI/CMUXCLI+PiExtensionSourcePart1.swift | 13 +++++--- tests/claude_teams_test_utils.py | 12 +++++++ tests/test_pi_extension_dispatch.py | 7 +++- tests/test_pi_extension_install.py | 33 ++++++++++++++++--- 5 files changed, 88 insertions(+), 19 deletions(-) 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+PiExtensionSourcePart1.swift b/CLI/CMUXCLI+PiExtensionSourcePart1.swift index c11f56f69f0a..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 { @@ -592,8 +595,8 @@ interface CmuxExecutableResolution { trustedForCredentials: boolean; } -function trustedBundledCmuxExecutable(): string | null { - const configured = firstString(process.env.CMUX_BUNDLED_CLI_PATH); +function trustedPinnedCmuxExecutable(): string | null { + const configured = firstString(pinnedCmuxExecutable); if (!configured || !path.isAbsolute(configured)) return null; try { const resolved = fs.realpathSync(configured); @@ -606,10 +609,10 @@ function trustedBundledCmuxExecutable(): string | null { } function resolveCmuxExecutable(): CmuxExecutableResolution { - const bundled = trustedBundledCmuxExecutable(); - if (bundled) return { executable: bundled, trustedForCredentials: true }; + const pinned = trustedPinnedCmuxExecutable(); + if (pinned) return { executable: pinned, trustedForCredentials: true }; return { - executable: process.env.CMUX_PI_CMUX_BIN || "cmux", + executable: process.env.CMUX_PI_CMUX_BIN || process.env.CMUX_BUNDLED_CLI_PATH || "cmux", trustedForCredentials: false, }; } 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 a7df06d2a08f..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,6 +31,7 @@ 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) diff --git a/tests/test_pi_extension_install.py b/tests/test_pi_extension_install.py index 016515e9cbb3..6b002e845f87 100644 --- a/tests/test_pi_extension_install.py +++ b/tests/test_pi_extension_install.py @@ -6,6 +6,7 @@ from __future__ import annotations import base64 +from collections.abc import Iterator from contextlib import contextmanager import fcntl import json @@ -18,13 +19,13 @@ import threading import time from pathlib import Path -from typing import Iterator from claude_teams_test_utils import ( FOCUSED_SURFACE_ID, FOCUSED_WORKSPACE_ID, install_pi_extension, resolve_cmux_cli, + set_pi_extension_pinned_cli, ) NONBLOCKING_LOCK_TIMEOUT_SECONDS = 5.0 @@ -179,6 +180,8 @@ 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) @@ -196,9 +199,13 @@ def check_auto_naming_from_generated_hook_environment( *, bun: str, root: Path, - extension_path: 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" @@ -388,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" @@ -695,6 +713,7 @@ def main() -> int: exec "$CMUX_TEST_PI_TRUSTED_CLI" "$@" """, ) + set_pi_extension_pinned_cli(extension_path, fake_cmux) check_env = env.copy() for key in ( @@ -709,7 +728,7 @@ def main() -> int: check_env["CMUX_SURFACE_ID"] = "surface-pi-test" check_env["CMUX_WORKSPACE_ID"] = "workspace-pi-test" check_env["CMUX_PI_CMUX_BIN"] = "cmux" - check_env["CMUX_BUNDLED_CLI_PATH"] = str(fake_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) @@ -1461,8 +1480,13 @@ def main() -> int: 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_BUNDLED_CLI_PATH"] = "cmux" + 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; @@ -1528,7 +1552,6 @@ def main() -> int: auto_name_result = check_auto_naming_from_generated_hook_environment( bun=bun, root=root, - extension_path=extension_path, cli_path=cli_path, ) if auto_name_result != 0: