Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 34 additions & 8 deletions CLI/CMUXCLI+PiExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand All @@ -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
}
Expand All @@ -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.
Expand All @@ -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",
Expand All @@ -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 {
Expand All @@ -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(
Expand Down
10 changes: 8 additions & 2 deletions CLI/CMUXCLI+PiExtensionSourceDispatch.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
114 changes: 109 additions & 5 deletions CLI/CMUXCLI+PiExtensionSourcePart1.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;

interface PendingCompletion {
Expand Down Expand Up @@ -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" ||
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down
12 changes: 12 additions & 0 deletions tests/claude_teams_test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
8 changes: 7 additions & 1 deletion tests/test_pi_extension_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand Down
Loading
Loading