From ffb6d1df9fe8664709170b380202c2d09c3a3435 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Sun, 6 Sep 2026 12:02:16 -0700 Subject: [PATCH 01/34] test: reproduce Claude NODE_OPTIONS tmpdir purge --- .github/workflows/ci.yml | 1 + ...pper_node_options_survives_tmpdir_purge.py | 151 ++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100755 tests/test_claude_wrapper_node_options_survives_tmpdir_purge.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e04c9183bca5..e8ce06a9c871 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1389,6 +1389,7 @@ jobs: CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_socket_autodiscovery.py python3 tests/test_codex_wrapper_resume_hooks.py python3 tests/test_claude_wrapper_hooks.py + python3 tests/test_claude_wrapper_node_options_survives_tmpdir_purge.py python3 tests/test_codex_wrapper_computer_use_mcp.py python3 tests/test_cmux_cua_mcp_smoke.py python3 tests/test_cmux_cua_build_cache_safety.py diff --git a/tests/test_claude_wrapper_node_options_survives_tmpdir_purge.py b/tests/test_claude_wrapper_node_options_survives_tmpdir_purge.py new file mode 100755 index 000000000000..ac99f37e8249 --- /dev/null +++ b/tests/test_claude_wrapper_node_options_survives_tmpdir_purge.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Regression coverage for Claude children after macOS purges ``$TMPDIR``.""" + +from __future__ import annotations + +import os +import re +import shutil +import socket +import subprocess +import tempfile +import time +from pathlib import Path + +from node_runtime import ensure_node_on_path + + +ROOT = Path(__file__).resolve().parents[1] +SOURCE_WRAPPER = ROOT / "Resources" / "bin" / "cmux-claude-wrapper" + + +def make_executable(path: Path, content: str) -> None: + path.write_text(content, encoding="utf-8") + path.chmod(0o755) + + +def wait_for_text(path: Path, timeout: float = 10.0) -> str: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if path.exists(): + value = path.read_text(encoding="utf-8").strip() + if value: + return value + time.sleep(0.02) + raise TimeoutError(f"timed out waiting for {path}") + + +def main() -> int: + node_path = ensure_node_on_path() + if node_path is None: + print("SKIP: node runtime not found; Claude child probe requires node") + return 0 + + with tempfile.TemporaryDirectory(prefix="cmux-claude-wrapper-tmpdir-purge-") as td: + root = Path(td) + wrapper_dir = root / "cmux.app" / "Contents" / "Resources" / "bin" + real_dir = root / "real-bin" + home_dir = root / "home" + session_tmpdir = root / "session-tmp" + wrapper_dir.mkdir(parents=True) + real_dir.mkdir() + home_dir.mkdir() + session_tmpdir.mkdir() + + wrapper = wrapper_dir / "cmux-claude-wrapper" + shutil.copy2(SOURCE_WRAPPER, wrapper) + wrapper.chmod(0o755) + + ready_path = root / "ready" + continue_path = root / "continue" + make_executable( + real_dir / "claude", + """#!/usr/bin/env bash +set -euo pipefail +printf '%s\\n' "${NODE_OPTIONS-__UNSET__}" > "$FAKE_READY_PATH" +while [[ ! -e "$FAKE_CONTINUE_PATH" ]]; do + sleep 0.02 +done +exec node -e 'process.stdout.write("node-child-survived")' +""", + ) + make_executable( + wrapper_dir / "cmux", + """#!/usr/bin/env bash +set -euo pipefail +exit 0 +""", + ) + + socket_path = root / "cmux.sock" + test_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + test_socket.bind(str(socket_path)) + try: + environment = { + key: value + for key, value in os.environ.items() + if not key.startswith("CMUX_") and key not in {"NODE_OPTIONS", "XDG_CACHE_HOME"} + } + environment.update( + { + "PATH": f"{Path(node_path).parent}:{wrapper_dir}:{real_dir}:/usr/bin:/bin", + "HOME": str(home_dir), + "TMPDIR": str(session_tmpdir), + "CMUX_SURFACE_ID": "surface:test", + "CMUX_SOCKET_PATH": str(socket_path), + "CMUX_BUNDLED_CLI_PATH": str(wrapper_dir / "cmux"), + "CMUX_CUSTOM_CLAUDE_PATH": str(real_dir / "claude"), + "FAKE_READY_PATH": str(ready_path), + "FAKE_CONTINUE_PATH": str(continue_path), + } + ) + process = subprocess.Popen( + [str(wrapper), "hello"], + cwd=root, + env=environment, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + try: + node_options = wait_for_text(ready_path) + match = re.search(r"--require=(\S+)", node_options) + if match is None: + print(f"FAIL: wrapped Claude did not receive a restore preload: {node_options!r}") + return 1 + + restore_path = Path(match.group(1)) + legacy_root = session_tmpdir / "cmux-claude-node-options" + shutil.rmtree(legacy_root, ignore_errors=True) + continue_path.touch() + stdout, stderr = process.communicate(timeout=10) + except (TimeoutError, subprocess.TimeoutExpired) as exc: + process.kill() + stdout, stderr = process.communicate() + print(f"FAIL: wrapped Claude child did not finish after TMPDIR purge: {exc}") + print(f"stdout={stdout!r}") + print(f"stderr={stderr!r}") + return 1 + finally: + test_socket.close() + + if process.returncode != 0: + print("FAIL: Node child died after the restore shim's TMPDIR directory was purged") + print(f"exit={process.returncode}") + print(f"stdout={stdout!r}") + print(f"stderr={stderr!r}") + return 1 + if stdout != "node-child-survived": + print(f"FAIL: unexpected child output after TMPDIR purge: {stdout!r}") + return 1 + if str(restore_path).startswith(str(session_tmpdir)): + print(f"FAIL: restore shim still lives under TMPDIR: {restore_path}") + return 1 + + print("PASS: Claude Node children survive a TMPDIR purge") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 844094682a93e6277e4e5afa3582f1ff631cd8b5 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Sun, 6 Sep 2026 12:20:32 -0700 Subject: [PATCH 02/34] fix: keep Claude NODE_OPTIONS restore shim durable --- CLI/CMUXCLI+ClaudeNodeOptions.swift | 200 ++++++++++++++++++ CLI/cmux.swift | 83 -------- Resources/bin/cmux-claude-wrapper | 108 ++++++++-- cmux.xcodeproj/project.pbxproj | 4 + tests/test_claude_wrapper_hooks.py | 13 +- ...pper_node_options_survives_tmpdir_purge.py | 6 +- tests/test_cli_claude_teams_env.py | 4 +- 7 files changed, 311 insertions(+), 107 deletions(-) create mode 100644 CLI/CMUXCLI+ClaudeNodeOptions.swift diff --git a/CLI/CMUXCLI+ClaudeNodeOptions.swift b/CLI/CMUXCLI+ClaudeNodeOptions.swift new file mode 100644 index 000000000000..ac26f7cf4f9c --- /dev/null +++ b/CLI/CMUXCLI+ClaudeNodeOptions.swift @@ -0,0 +1,200 @@ +import Darwin +import Foundation + +extension CMUXCLI { + private static let claudeNodeOptionsRestoreModule = """ + const hadOriginalNodeOptions = process.env.CMUX_ORIGINAL_NODE_OPTIONS_PRESENT === "1"; + if (hadOriginalNodeOptions) { + process.env.NODE_OPTIONS = process.env.CMUX_ORIGINAL_NODE_OPTIONS ?? ""; + } else { + delete process.env.NODE_OPTIONS; + } + delete process.env.CMUX_ORIGINAL_NODE_OPTIONS; + delete process.env.CMUX_ORIGINAL_NODE_OPTIONS_PRESENT; + """ + "\n" + + private struct ClaudeNodeOptionsCachePathError: LocalizedError { + let reason: String + let path: String + + var errorDescription: String? { + "Claude NODE_OPTIONS restore module \(reason): \(path)" + } + } + + private struct CachePathState { + let owner: uid_t + let isSymbolicLink: Bool + } + + private static let unsafeNodeOptionsPathCharacters = CharacterSet(charactersIn: "\\\"'") + + private static func nodeOptionsPathIsSafe(_ path: String) -> Bool { + path.hasPrefix("/") + && path.rangeOfCharacter(from: unsafeNodeOptionsPathCharacters) == nil + } + + private static func cachePathState(at url: URL) throws -> CachePathState? { + var statValue = stat() + guard lstat(url.path, &statValue) == 0 else { + guard errno == ENOENT else { + throw NSError( + domain: NSPOSIXErrorDomain, + code: Int(errno), + userInfo: [NSFilePathErrorKey: url.path] + ) + } + return nil + } + return CachePathState( + owner: statValue.st_uid, + isSymbolicLink: (statValue.st_mode & mode_t(S_IFMT)) == mode_t(S_IFLNK) + ) + } + + private static func validateCachePath(_ url: URL, expectedOwner: uid_t) throws { + guard let state = try cachePathState(at: url) else { return } + guard !state.isSymbolicLink else { + throw ClaudeNodeOptionsCachePathError(reason: "cache path is a symlink", path: url.path) + } + guard state.owner == expectedOwner else { + throw ClaudeNodeOptionsCachePathError( + reason: "cache path is owned by a different uid", + path: url.path + ) + } + } + + private static func claudeNodeOptionsRestoreModuleRoot() throws -> URL { + let environment = ProcessInfo.processInfo.environment + guard let home = environment["HOME"], !home.isEmpty else { + throw ClaudeNodeOptionsCachePathError(reason: "HOME is unavailable", path: "") + } + let root = URL(fileURLWithPath: home, isDirectory: true) + .appendingPathComponent(".cmuxterm", isDirectory: true) + .appendingPathComponent("cmux-claude-node-options", isDirectory: true) + guard nodeOptionsPathIsSafe(root.path) else { + throw ClaudeNodeOptionsCachePathError(reason: "path is unsafe for --require", path: root.path) + } + return root + } + + private func nodeOptionsRequirePaths(_ existing: String) -> [String] { + let tokens = existing.split(whereSeparator: \.isWhitespace).map(String.init) + var paths: [String] = [] + var index = 0 + while index < tokens.count { + let token = tokens[index] + if token == "--require" || token == "-r" { + if index + 1 < tokens.count { + paths.append(tokens[index + 1]) + index += 2 + continue + } + } else if token.hasPrefix("--require=") { + paths.append(String(token.dropFirst("--require=".count))) + } else if token.hasPrefix("-r=") { + paths.append(String(token.dropFirst("-r=".count))) + } + index += 1 + } + return paths + } + + private func recreateLegacyNodeOptionsRestoreModules(excluding currentURL: URL) { + guard let existing = ProcessInfo.processInfo.environment["NODE_OPTIONS"] else { return } + let legacySuffix = "/cmux-claude-node-options/restore-node-options.cjs" + for path in nodeOptionsRequirePaths(existing) { + guard path.hasSuffix(legacySuffix), + Self.nodeOptionsPathIsSafe(path), + path != currentURL.path else { continue } + let url = URL(fileURLWithPath: path, isDirectory: false) + let directory = url.deletingLastPathComponent() + do { + if try Self.cachePathState(at: directory)?.isSymbolicLink == true { continue } + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + guard !FileManager.default.fileExists(atPath: url.path) else { continue } + try writeShimIfChanged(Self.claudeNodeOptionsRestoreModule, to: url) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + } catch { + continue + } + } + } + + func createClaudeNodeOptionsRestoreModule() throws -> URL { + let root = try Self.claudeNodeOptionsRestoreModuleRoot() + try Self.validateCachePath(root, expectedOwner: getuid()) + try FileManager.default.createDirectory( + at: root, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try Self.validateCachePath(root, expectedOwner: getuid()) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: root.path) + + let restoreModuleURL = root.appendingPathComponent("restore-node-options.cjs", isDirectory: false) + try writeShimIfChanged(Self.claudeNodeOptionsRestoreModule, to: restoreModuleURL) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: restoreModuleURL.path) + recreateLegacyNodeOptionsRestoreModules(excluding: restoreModuleURL) + return restoreModuleURL + } + + func mergedNodeOptions(existing: String?, restoreModulePath: String) -> String { + let escapedPath = restoreModulePath + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + let requireOption = "--require=\"\(escapedPath)\"" + let memoryOption = "--max-old-space-size=4096" + let cleanedExisting = cleanedNodeOptions(existing) + guard !cleanedExisting.isEmpty else { + return "\(requireOption) \(memoryOption)" + } + return "\(requireOption) \(memoryOption) \(cleanedExisting)" + } + + private func cleanedNodeOptions(_ existing: String?) -> String { + let tokens = (existing ?? "").split(whereSeparator: \.isWhitespace).map(String.init) + guard !tokens.isEmpty else { return "" } + + var filtered: [String] = [] + var index = 0 + while index < tokens.count { + let token = tokens[index] + if token == "--max-old-space-size" { + index += min(2, tokens.count - index) + continue + } + if token.hasPrefix("--max-old-space-size=") { + index += 1 + continue + } + filtered.append(token) + index += 1 + } + return filtered.joined(separator: " ") + } + + func normalizedNodeOptionsForRestore(_ existing: String) -> String { + let tokens = existing.split(whereSeparator: \.isWhitespace).map(String.init) + guard !tokens.isEmpty else { return "" } + + var normalized: [String] = [] + var index = 0 + while index < tokens.count { + let token = tokens[index] + if token == "--max-old-space-size", index + 1 < tokens.count { + normalized.append("--max-old-space-size=\(tokens[index + 1])") + index += 2 + continue + } + normalized.append(token) + index += 1 + } + return normalized.joined(separator: " ") + } +} diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 9a2d701252ab..a9e8da16fb4a 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -23898,17 +23898,6 @@ struct CMUXCLI { print("TMUX_PANE=\(dump("TMUX_PANE"))") } - private static let claudeNodeOptionsRestoreModule = """ - const hadOriginalNodeOptions = process.env.CMUX_ORIGINAL_NODE_OPTIONS_PRESENT === "1"; - if (hadOriginalNodeOptions) { - process.env.NODE_OPTIONS = process.env.CMUX_ORIGINAL_NODE_OPTIONS ?? ""; - } else { - delete process.env.NODE_OPTIONS; - } - delete process.env.CMUX_ORIGINAL_NODE_OPTIONS; - delete process.env.CMUX_ORIGINAL_NODE_OPTIONS_PRESENT; - """ - private func configureClaudeTeamsEnvironment( processEnvironment: [String: String], shimDirectory: URL, @@ -23977,23 +23966,6 @@ struct CMUXCLI { return root } - private func createClaudeNodeOptionsRestoreModule() throws -> URL { - let rawTemporaryDirectory = ProcessInfo.processInfo.environment["TMPDIR"]? - .trimmingCharacters(in: .whitespacesAndNewlines) - let temporaryDirectory: String - if let rawTemporaryDirectory, !rawTemporaryDirectory.isEmpty { - temporaryDirectory = rawTemporaryDirectory - } else { - temporaryDirectory = NSTemporaryDirectory() - } - let root = URL(fileURLWithPath: temporaryDirectory, isDirectory: true) - .appendingPathComponent("cmux-claude-node-options", isDirectory: true) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true, attributes: nil) - let restoreModuleURL = root.appendingPathComponent("restore-node-options.cjs", isDirectory: false) - try writeShimIfChanged(Self.claudeNodeOptionsRestoreModule, to: restoreModuleURL) - return restoreModuleURL - } - private func runClaudeTeams( commandArgs: [String], socketPath: String, @@ -31542,61 +31514,6 @@ struct CMUXCLI { return false } - private func mergedNodeOptions(existing: String?, restoreModulePath: String) -> String { - let requireOption = "--require=\(restoreModulePath)" - let memoryOption = "--max-old-space-size=4096" - let cleanedExisting = cleanedNodeOptions(existing) - guard !cleanedExisting.isEmpty else { - return "\(requireOption) \(memoryOption)" - } - return "\(requireOption) \(memoryOption) \(cleanedExisting)" - } - - private func cleanedNodeOptions(_ existing: String?) -> String { - let tokens = (existing ?? "") - .split(whereSeparator: \.isWhitespace) - .map(String.init) - guard !tokens.isEmpty else { return "" } - - var filtered: [String] = [] - var index = 0 - while index < tokens.count { - let token = tokens[index] - if token == "--max-old-space-size" { - index += min(2, tokens.count - index) - continue - } - if token.hasPrefix("--max-old-space-size=") { - index += 1 - continue - } - filtered.append(token) - index += 1 - } - return filtered.joined(separator: " ") - } - - private func normalizedNodeOptionsForRestore(_ existing: String) -> String { - let tokens = existing - .split(whereSeparator: \.isWhitespace) - .map(String.init) - guard !tokens.isEmpty else { return "" } - - var normalized: [String] = [] - var index = 0 - while index < tokens.count { - let token = tokens[index] - if token == "--max-old-space-size", index + 1 < tokens.count { - normalized.append("--max-old-space-size=\(tokens[index + 1])") - index += 2 - continue - } - normalized.append(token) - index += 1 - } - return normalized.joined(separator: " ") - } - // MARK: - Codex hooks /// The hooks.json content that cmux installs into ~/.codex/. diff --git a/Resources/bin/cmux-claude-wrapper b/Resources/bin/cmux-claude-wrapper index 06daffe3c057..3d162ae58851 100755 --- a/Resources/bin/cmux-claude-wrapper +++ b/Resources/bin/cmux-claude-wrapper @@ -848,23 +848,20 @@ exec_real_claude_passthrough() { REAL_CLAUDE="$(find_real_claude)" || { echo "Error: claude not found in PATH" >&2; exit 127; } ensure_node_options_restore_module() { - local guard_dir="${TMPDIR:-/tmp}" - guard_dir="${guard_dir%/}/cmux-claude-node-options" + local guard_dir + guard_dir="$(cmux_claude_wrapper_node_options_cache_dir)" || return 1 local guard_path="$guard_dir/restore-node-options.cjs" + [[ ! -L "$guard_dir" ]] || return 1 mkdir -p "$guard_dir" || return 1 + chmod 0700 "$guard_dir" || return 1 + [[ ! -L "$guard_path" ]] || return 1 local temp_path - temp_path="$(mktemp "$guard_dir/restore-node-options.cjs.XXXXXX")" || return 1 - cat >"$temp_path" <<'EOF' -const hadOriginalNodeOptions = process.env.CMUX_ORIGINAL_NODE_OPTIONS_PRESENT === "1"; -if (hadOriginalNodeOptions) { - process.env.NODE_OPTIONS = process.env.CMUX_ORIGINAL_NODE_OPTIONS ?? ""; -} else { - delete process.env.NODE_OPTIONS; -} -delete process.env.CMUX_ORIGINAL_NODE_OPTIONS; -delete process.env.CMUX_ORIGINAL_NODE_OPTIONS_PRESENT; -EOF - chmod 0644 "$temp_path" || { + temp_path="$(mktemp "$guard_dir/.restore-node-options.cjs.XXXXXX")" || return 1 + cmux_claude_wrapper_node_options_restore_source >"$temp_path" || { + rm -f "$temp_path" + return 1 + } + chmod 0600 "$temp_path" || { rm -f "$temp_path" return 1 } @@ -876,12 +873,89 @@ EOF return 1 } fi + cmux_claude_wrapper_recreate_legacy_node_options_modules printf '%s' "$guard_path" } +cmux_claude_wrapper_node_options_restore_source() { + cat <<'EOF' +const hadOriginalNodeOptions = process.env.CMUX_ORIGINAL_NODE_OPTIONS_PRESENT === "1"; +if (hadOriginalNodeOptions) { + process.env.NODE_OPTIONS = process.env.CMUX_ORIGINAL_NODE_OPTIONS ?? ""; +} else { + delete process.env.NODE_OPTIONS; +} +delete process.env.CMUX_ORIGINAL_NODE_OPTIONS; +delete process.env.CMUX_ORIGINAL_NODE_OPTIONS_PRESENT; +EOF +} + +cmux_claude_wrapper_node_options_path_is_safe() { + local path="$1" + [[ "$path" == /* ]] || return 1 + [[ "$path" != *[\\\"\']* ]] +} + +cmux_claude_wrapper_node_options_cache_dir() { + local home="${HOME:-}" + [[ -n "$home" ]] || return 1 + local cache_root="$home/.cmuxterm" + cmux_claude_wrapper_node_options_path_is_safe "$cache_root" || return 1 + printf '%s/cmux-claude-node-options' "$cache_root" +} + +cmux_claude_wrapper_recreate_legacy_node_options_modules() { + local existing="${NODE_OPTIONS:-}" + local -a tokens=() + local token + local path="" + local legacy_dir + local legacy_path + local temp_path + + [[ -n "$existing" ]] || return 0 + read -r -a tokens <<<"$existing" + local skip_next=0 + for token in "${tokens[@]}"; do + if (( skip_next )); then + path="$token" + skip_next=0 + elif [[ "$token" == "--require" || "$token" == "-r" ]]; then + skip_next=1 + continue + elif [[ "$token" == --require=* ]]; then + path="${token#--require=}" + elif [[ "$token" == -r=* ]]; then + path="${token#-r=}" + else + continue + fi + + [[ "$path" == */cmux-claude-node-options/restore-node-options.cjs ]] || continue + legacy_path="$path" + legacy_dir="${legacy_path%/*}" + [[ "$legacy_path" != "$(cmux_claude_wrapper_node_options_cache_dir)/restore-node-options.cjs" ]] || continue + [[ ! -L "$legacy_dir" && ! -L "$legacy_path" ]] || continue + mkdir -p "$legacy_dir" 2>/dev/null || continue + [[ -f "$legacy_path" ]] && continue + temp_path="$(mktemp "$legacy_dir/.restore-node-options.cjs.XXXXXX" 2>/dev/null)" || continue + cmux_claude_wrapper_node_options_restore_source >"$temp_path" || { + rm -f "$temp_path" + continue + } + chmod 0600 "$temp_path" 2>/dev/null || { + rm -f "$temp_path" + continue + } + mv -f "$temp_path" "$legacy_path" 2>/dev/null || rm -f "$temp_path" + done +} + merge_node_options() { local guard_path="$1" - local require_flag="--require=$guard_path" + local escaped_guard_path="${guard_path//\/\\}" + escaped_guard_path="${escaped_guard_path//\"/\\\"}" + local require_flag="--require=\"$escaped_guard_path\"" local memory_flag="--max-old-space-size=4096" local existing="${NODE_OPTIONS:-}" local -a filtered=() @@ -949,7 +1023,9 @@ normalize_node_options_for_restore() { install_cmux_node_options() { local guard_path if guard_path="$(ensure_node_options_restore_module)"; then - if [[ ${NODE_OPTIONS+x} && "${NODE_OPTIONS:-}" == *"--require=$guard_path"* ]]; then + local escaped_guard_path="${guard_path//\/\\}" + escaped_guard_path="${escaped_guard_path//\"/\\\"}" + if [[ ${NODE_OPTIONS+x} && "${NODE_OPTIONS:-}" == *"--require=\"$escaped_guard_path\""* ]]; then return 0 fi if [[ ${NODE_OPTIONS+x} ]]; then diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 467024af4f05..1c2f4369c323 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -738,6 +738,7 @@ C1ADE60002A1B2C3D4E5F719 /* cmux-hermes-sitecustomize.py in Copy CLI */ = {isa = PBXBuildFile; fileRef = C1ADE60001A1B2C3D4E5F719 /* cmux-hermes-sitecustomize.py */; }; A5001623 /* cmux.sdef in Resources */ = {isa = PBXBuildFile; fileRef = A5001622 /* cmux.sdef */; }; B9000002A1B2C3D4E5F60719 /* cmux.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000001A1B2C3D4E5F60719 /* cmux.swift */; }; + C0DE11300000000000000001 /* CMUXCLI+ClaudeNodeOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE11300000000000000002 /* CMUXCLI+ClaudeNodeOptions.swift */; }; C0DE1A060000000000000001 /* cmux_layout.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE1A060000000000000002 /* cmux_layout.swift */; }; B9000034A1B2C3D4E5F60719 /* cmux_open.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000030A1B2C3D4E5F60719 /* cmux_open.swift */; }; A5001654 /* CmuxActionTrust.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001655 /* CmuxActionTrust.swift */; }; @@ -4051,6 +4052,7 @@ A5001000 /* cmux.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = cmux.app; sourceTree = BUILT_PRODUCTS_DIR; }; A5001622 /* cmux.sdef */ = {isa = PBXFileReference; lastKnownFileType = text.sdef; path = cmux.sdef; sourceTree = ""; }; B9000001A1B2C3D4E5F60719 /* cmux.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = cmux.swift; sourceTree = ""; }; + C0DE11300000000000000002 /* CMUXCLI+ClaudeNodeOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+ClaudeNodeOptions.swift"; sourceTree = ""; }; C0DE1A060000000000000002 /* cmux_layout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = cmux_layout.swift; sourceTree = ""; }; B9000030A1B2C3D4E5F60719 /* cmux_open.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = cmux_open.swift; sourceTree = ""; }; A5001655 /* CmuxActionTrust.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxActionTrust.swift; sourceTree = ""; }; @@ -8861,6 +8863,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef DCF61A4F72B75DC4EA47DF6F /* CMUXCLI+SSHReconnectPrompt.swift */, D7AB0000000000000000000E /* CMUXCLI+MoveTabToNewWorkspace.swift */, B9000047A1B2C3D4E5F60719 /* CMUXCLI+ExecutableResolution.swift */, + C0DE11300000000000000002 /* CMUXCLI+ClaudeNodeOptions.swift */, 7837A0027837A0027837A002 /* CMUXCLI+TmuxCompatResizePane.swift */, C0DE11260000000000000022 /* CMUXCLI+TmuxCompatStore.swift */, 9009A0019009A0019009A002 /* CMUXCLI+TmuxCompatLaunchContext.swift */, @@ -12509,6 +12512,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef B900004AA1B2C3D4E5F60719 /* CLISocketPathResolver.swift in Sources */, C12985000000000000000002 /* CLISocketSentryTelemetry.swift in Sources */, B9000002A1B2C3D4E5F60719 /* cmux.swift in Sources */, + C0DE11300000000000000001 /* CMUXCLI+ClaudeNodeOptions.swift in Sources */, C0DE1A060000000000000001 /* cmux_layout.swift in Sources */, B9000034A1B2C3D4E5F60719 /* cmux_open.swift in Sources */, D4461A487C13945177EEE9A1 /* CMUXCLI+AgentHookCatalog.swift in Sources */, diff --git a/tests/test_claude_wrapper_hooks.py b/tests/test_claude_wrapper_hooks.py index d09bd7499072..29786fce4e67 100644 --- a/tests/test_claude_wrapper_hooks.py +++ b/tests/test_claude_wrapper_hooks.py @@ -186,6 +186,9 @@ def run_wrapper( test_socket.bind(socket_path) env = os.environ.copy() + sandbox_home = tmp / "home" + sandbox_home.mkdir(parents=True, exist_ok=True) + env["HOME"] = str(sandbox_home) env["PATH"] = f"{wrapper_dir}:{real_dir}:{env.get('PATH', '/usr/bin:/bin')}" env["CMUX_SURFACE_ID"] = "surface:test" env["CMUX_SOCKET_PATH"] = socket_path @@ -2438,7 +2441,7 @@ def test_live_socket_enforces_heap_cap_for_space_separated_flag(failures: list[s expect(child_node_options == restored, f"space-separated heap flag: expected child NODE_OPTIONS restored, got {child_node_options!r}", failures) -def test_live_socket_tmpdir_failure_skips_node_options_injection(failures: list[str]) -> None: +def test_live_socket_tmpdir_failure_does_not_block_node_options_injection(failures: list[str]) -> None: with tempfile.TemporaryDirectory(prefix="cmux-claude-wrapper-bad-tmp-") as td: bad_tmpdir = Path(td) / "not-a-directory" bad_tmpdir.write_text("occupied", encoding="utf-8") @@ -2452,7 +2455,11 @@ def test_live_socket_tmpdir_failure_skips_node_options_injection(failures: list[ expect("--session-id" in real_argv, f"tmpdir failure: missing --session-id in args: {real_argv}", failures) expect(any(" ping" in line for line in cmux_log), f"tmpdir failure: expected cmux ping, got {cmux_log}", failures) expect(claudecode == "__UNSET__", f"tmpdir failure: expected CLAUDECODE unset, got {claudecode!r}", failures) - expect(node_options == "__UNSET__", f"tmpdir failure: expected NODE_OPTIONS injection to be skipped, got {node_options!r}", failures) + expect( + node_options.startswith("--require=") and str(bad_tmpdir) not in node_options, + f"tmpdir failure: expected durable NODE_OPTIONS injection, got {node_options!r}", + failures, + ) expect(runtime_node_options == "__UNSET__", f"tmpdir failure: expected runtime NODE_OPTIONS passthrough, got {runtime_node_options!r}", failures) expect(child_node_options == "__UNSET__", f"tmpdir failure: expected child NODE_OPTIONS passthrough, got {child_node_options!r}", failures) @@ -2673,7 +2680,7 @@ def main() -> int: test_live_socket_auto_preserve_accepts_all_documented_truthy_variants(failures) test_live_socket_explicit_key_list_is_additive_to_vertex_auto_preserve(failures) test_live_socket_enforces_heap_cap_for_space_separated_flag(failures) - test_live_socket_tmpdir_failure_skips_node_options_injection(failures) + test_live_socket_tmpdir_failure_does_not_block_node_options_injection(failures) test_live_socket_preserves_explicit_bypass_availability_flag(failures) test_live_socket_stale_mktemp_literal_does_not_warn(failures) test_missing_socket_skips_hook_injection(failures) diff --git a/tests/test_claude_wrapper_node_options_survives_tmpdir_purge.py b/tests/test_claude_wrapper_node_options_survives_tmpdir_purge.py index ac99f37e8249..afcd763a58e6 100755 --- a/tests/test_claude_wrapper_node_options_survives_tmpdir_purge.py +++ b/tests/test_claude_wrapper_node_options_survives_tmpdir_purge.py @@ -45,7 +45,7 @@ def main() -> int: root = Path(td) wrapper_dir = root / "cmux.app" / "Contents" / "Resources" / "bin" real_dir = root / "real-bin" - home_dir = root / "home" + home_dir = root / "home with spaces" session_tmpdir = root / "session-tmp" wrapper_dir.mkdir(parents=True) real_dir.mkdir() @@ -110,12 +110,12 @@ def main() -> int: try: node_options = wait_for_text(ready_path) - match = re.search(r"--require=(\S+)", node_options) + match = re.search(r'--require="([^"]+)"|--require=(\S+)', node_options) if match is None: print(f"FAIL: wrapped Claude did not receive a restore preload: {node_options!r}") return 1 - restore_path = Path(match.group(1)) + restore_path = Path(match.group(1) or match.group(2)) legacy_root = session_tmpdir / "cmux-claude-node-options" shutil.rmtree(legacy_root, ignore_errors=True) continue_path.touch() diff --git a/tests/test_cli_claude_teams_env.py b/tests/test_cli_claude_teams_env.py index 393b7375fe34..33f262b73574 100644 --- a/tests/test_cli_claude_teams_env.py +++ b/tests/test_cli_claude_teams_env.py @@ -528,9 +528,9 @@ def main() -> int: print(f"stderr={proc.stderr.strip()}") return 1 - if node_options_value != "--trace-warnings": + if not node_options_value.startswith("--require=") or str(bad_tmpdir) in node_options_value: print( - "FAIL: expected claude-teams to skip restore preload injection when TMPDIR is unusable, " + "FAIL: expected claude-teams to use the durable restore preload when TMPDIR is unusable, " f"got {node_options_value!r}" ) return 1 From b079d3e7c3c18ada5de4310d9ca34f2db039530d Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Sun, 6 Sep 2026 12:29:51 -0700 Subject: [PATCH 03/34] chore: normalize CLI source wiring --- cmux.xcodeproj/project.pbxproj | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 65e1403f0d27..1bf1aa90b85f 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -738,7 +738,6 @@ C1ADE60002A1B2C3D4E5F719 /* cmux-hermes-sitecustomize.py in Copy CLI */ = {isa = PBXBuildFile; fileRef = C1ADE60001A1B2C3D4E5F719 /* cmux-hermes-sitecustomize.py */; }; A5001623 /* cmux.sdef in Resources */ = {isa = PBXBuildFile; fileRef = A5001622 /* cmux.sdef */; }; B9000002A1B2C3D4E5F60719 /* cmux.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000001A1B2C3D4E5F60719 /* cmux.swift */; }; - C0DE11300000000000000001 /* CMUXCLI+ClaudeNodeOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE11300000000000000002 /* CMUXCLI+ClaudeNodeOptions.swift */; }; C0DE1A060000000000000001 /* cmux_layout.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE1A060000000000000002 /* cmux_layout.swift */; }; B9000034A1B2C3D4E5F60719 /* cmux_open.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000030A1B2C3D4E5F60719 /* cmux_open.swift */; }; A5001654 /* CmuxActionTrust.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001655 /* CmuxActionTrust.swift */; }; @@ -799,6 +798,7 @@ C79630010000000000000002 /* CMUXCLI+ClaudeHookAck.swift in Sources */ = {isa = PBXBuildFile; fileRef = C79630010000000000000001 /* CMUXCLI+ClaudeHookAck.swift */; }; 79390007AA11BB22CC33DD07 /* CMUXCLI+ClaudeHookDeliveryTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79390008AA11BB22CC33DD08 /* CMUXCLI+ClaudeHookDeliveryTarget.swift */; }; C72280000000000000000002 /* CMUXCLI+ClaudeHookWorkspaceRouting.swift in Sources */ = {isa = PBXBuildFile; fileRef = C72280000000000000000001 /* CMUXCLI+ClaudeHookWorkspaceRouting.swift */; }; + C0DE11300000000000000001 /* CMUXCLI+ClaudeNodeOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE11300000000000000002 /* CMUXCLI+ClaudeNodeOptions.swift */; }; 2DA8D48D99FB520EB179B015 /* CMUXCLI+ClaudePushNotificationHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF59318F1D5B2195AC77C28 /* CMUXCLI+ClaudePushNotificationHook.swift */; }; C10D0A100000000000000001 /* CMUXCLI+CloudDomains.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10D0A100000000000000002 /* CMUXCLI+CloudDomains.swift */; }; C0DE60C0000000000000A022 /* CMUXCLI+Coderouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE60C0000000000000A021 /* CMUXCLI+Coderouter.swift */; }; @@ -4068,7 +4068,6 @@ A5001000 /* cmux.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = cmux.app; sourceTree = BUILT_PRODUCTS_DIR; }; A5001622 /* cmux.sdef */ = {isa = PBXFileReference; lastKnownFileType = text.sdef; path = cmux.sdef; sourceTree = ""; }; B9000001A1B2C3D4E5F60719 /* cmux.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = cmux.swift; sourceTree = ""; }; - C0DE11300000000000000002 /* CMUXCLI+ClaudeNodeOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+ClaudeNodeOptions.swift"; sourceTree = ""; }; C0DE1A060000000000000002 /* cmux_layout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = cmux_layout.swift; sourceTree = ""; }; B9000030A1B2C3D4E5F60719 /* cmux_open.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = cmux_open.swift; sourceTree = ""; }; A5001655 /* CmuxActionTrust.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxActionTrust.swift; sourceTree = ""; }; @@ -4110,6 +4109,7 @@ C79630010000000000000001 /* CMUXCLI+ClaudeHookAck.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+ClaudeHookAck.swift"; sourceTree = ""; }; 79390008AA11BB22CC33DD08 /* CMUXCLI+ClaudeHookDeliveryTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+ClaudeHookDeliveryTarget.swift"; sourceTree = ""; }; C72280000000000000000001 /* CMUXCLI+ClaudeHookWorkspaceRouting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+ClaudeHookWorkspaceRouting.swift"; sourceTree = ""; }; + C0DE11300000000000000002 /* CMUXCLI+ClaudeNodeOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+ClaudeNodeOptions.swift"; sourceTree = ""; }; 4CF59318F1D5B2195AC77C28 /* CMUXCLI+ClaudePushNotificationHook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+ClaudePushNotificationHook.swift"; sourceTree = ""; }; C10D0A100000000000000002 /* CMUXCLI+CloudDomains.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+CloudDomains.swift"; sourceTree = ""; }; C0DE60C0000000000000A021 /* CMUXCLI+Coderouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+Coderouter.swift"; sourceTree = ""; }; @@ -12569,7 +12569,6 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef B900004AA1B2C3D4E5F60719 /* CLISocketPathResolver.swift in Sources */, C12985000000000000000002 /* CLISocketSentryTelemetry.swift in Sources */, B9000002A1B2C3D4E5F60719 /* cmux.swift in Sources */, - C0DE11300000000000000001 /* CMUXCLI+ClaudeNodeOptions.swift in Sources */, C0DE1A060000000000000001 /* cmux_layout.swift in Sources */, B9000034A1B2C3D4E5F60719 /* cmux_open.swift in Sources */, D4461A487C13945177EEE9A1 /* CMUXCLI+AgentHookCatalog.swift in Sources */, @@ -12599,6 +12598,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef C79630010000000000000002 /* CMUXCLI+ClaudeHookAck.swift in Sources */, 79390007AA11BB22CC33DD07 /* CMUXCLI+ClaudeHookDeliveryTarget.swift in Sources */, C72280000000000000000002 /* CMUXCLI+ClaudeHookWorkspaceRouting.swift in Sources */, + C0DE11300000000000000001 /* CMUXCLI+ClaudeNodeOptions.swift in Sources */, 2DA8D48D99FB520EB179B015 /* CMUXCLI+ClaudePushNotificationHook.swift in Sources */, C10D0A100000000000000001 /* CMUXCLI+CloudDomains.swift in Sources */, C0DE60C0000000000000A022 /* CMUXCLI+Coderouter.swift in Sources */, From 1d7a35859cabe154680b5ed63ef5935cc694525b Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Sun, 6 Sep 2026 12:40:33 -0700 Subject: [PATCH 04/34] test: clean up Claude wrapper regression harness --- tests/test_claude_wrapper_hooks.py | 1 - .../test_claude_wrapper_node_options_survives_tmpdir_purge.py | 3 +++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_claude_wrapper_hooks.py b/tests/test_claude_wrapper_hooks.py index 29786fce4e67..e150186c9468 100644 --- a/tests/test_claude_wrapper_hooks.py +++ b/tests/test_claude_wrapper_hooks.py @@ -187,7 +187,6 @@ def run_wrapper( env = os.environ.copy() sandbox_home = tmp / "home" - sandbox_home.mkdir(parents=True, exist_ok=True) env["HOME"] = str(sandbox_home) env["PATH"] = f"{wrapper_dir}:{real_dir}:{env.get('PATH', '/usr/bin:/bin')}" env["CMUX_SURFACE_ID"] = "surface:test" diff --git a/tests/test_claude_wrapper_node_options_survives_tmpdir_purge.py b/tests/test_claude_wrapper_node_options_survives_tmpdir_purge.py index afcd763a58e6..0b33ab3d84c3 100755 --- a/tests/test_claude_wrapper_node_options_survives_tmpdir_purge.py +++ b/tests/test_claude_wrapper_node_options_survives_tmpdir_purge.py @@ -112,6 +112,9 @@ def main() -> int: node_options = wait_for_text(ready_path) match = re.search(r'--require="([^"]+)"|--require=(\S+)', node_options) if match is None: + continue_path.touch() + process.kill() + stdout, stderr = process.communicate() print(f"FAIL: wrapped Claude did not receive a restore preload: {node_options!r}") return 1 From d692992c3fcd0bd81f28c35556a686eb85e5acea Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Sun, 6 Sep 2026 12:48:54 -0700 Subject: [PATCH 05/34] fix: restore legacy Claude preload paths --- CLI/CMUXCLI+ClaudeNodeOptions.swift | 16 ++++-- Resources/bin/cmux-claude-wrapper | 75 ++++++++++++++++++----------- 2 files changed, 59 insertions(+), 32 deletions(-) diff --git a/CLI/CMUXCLI+ClaudeNodeOptions.swift b/CLI/CMUXCLI+ClaudeNodeOptions.swift index ac26f7cf4f9c..e5b42d5879f9 100644 --- a/CLI/CMUXCLI+ClaudeNodeOptions.swift +++ b/CLI/CMUXCLI+ClaudeNodeOptions.swift @@ -34,6 +34,16 @@ extension CMUXCLI { && path.rangeOfCharacter(from: unsafeNodeOptionsPathCharacters) == nil } + private static func unquoteNodeOptionsPath(_ path: String) -> String { + guard path.count >= 2, + let first = path.first, + let last = path.last, + (first == "\"" && last == "\"") || (first == "'" && last == "'") else { + return path + } + return String(path.dropFirst().dropLast()) + } + private static func cachePathState(at url: URL) throws -> CachePathState? { var statValue = stat() guard lstat(url.path, &statValue) == 0 else { @@ -87,14 +97,14 @@ extension CMUXCLI { let token = tokens[index] if token == "--require" || token == "-r" { if index + 1 < tokens.count { - paths.append(tokens[index + 1]) + paths.append(Self.unquoteNodeOptionsPath(tokens[index + 1])) index += 2 continue } } else if token.hasPrefix("--require=") { - paths.append(String(token.dropFirst("--require=".count))) + paths.append(Self.unquoteNodeOptionsPath(String(token.dropFirst("--require=".count)))) } else if token.hasPrefix("-r=") { - paths.append(String(token.dropFirst("-r=".count))) + paths.append(Self.unquoteNodeOptionsPath(String(token.dropFirst("-r=".count)))) } index += 1 } diff --git a/Resources/bin/cmux-claude-wrapper b/Resources/bin/cmux-claude-wrapper index 3d162ae58851..34a6c7dc9424 100755 --- a/Resources/bin/cmux-claude-wrapper +++ b/Resources/bin/cmux-claude-wrapper @@ -904,6 +904,15 @@ cmux_claude_wrapper_node_options_cache_dir() { printf '%s/cmux-claude-node-options' "$cache_root" } +cmux_claude_wrapper_unquote_node_options_path() { + local path="$1" + case "$path" in + \"*\") path="${path:1:${#path}-2}" ;; + \'*\') path="${path:1:${#path}-2}" ;; + esac + printf '%s' "$path" +} + cmux_claude_wrapper_recreate_legacy_node_options_modules() { local existing="${NODE_OPTIONS:-}" local -a tokens=() @@ -915,39 +924,47 @@ cmux_claude_wrapper_recreate_legacy_node_options_modules() { [[ -n "$existing" ]] || return 0 read -r -a tokens <<<"$existing" - local skip_next=0 - for token in "${tokens[@]}"; do - if (( skip_next )); then - path="$token" - skip_next=0 - elif [[ "$token" == "--require" || "$token" == "-r" ]]; then - skip_next=1 - continue - elif [[ "$token" == --require=* ]]; then - path="${token#--require=}" - elif [[ "$token" == -r=* ]]; then - path="${token#-r=}" - else + local index=0 + while (( index < ${#tokens[@]} )); do + token="${tokens[$index]}" + path="" + case "$token" in + --require|-r) + index=$((index + 1)) + (( index < ${#tokens[@]} )) || break + path="${tokens[$index]}" + ;; + --require=*) path="${token#--require=}" ;; + -r=*) path="${token#-r=}" ;; + *) + index=$((index + 1)) + continue + ;; + esac + path="$(cmux_claude_wrapper_unquote_node_options_path "$path")" + + if [[ "$path" != */cmux-claude-node-options/restore-node-options.cjs ]]; then + index=$((index + 1)) continue fi - - [[ "$path" == */cmux-claude-node-options/restore-node-options.cjs ]] || continue legacy_path="$path" legacy_dir="${legacy_path%/*}" - [[ "$legacy_path" != "$(cmux_claude_wrapper_node_options_cache_dir)/restore-node-options.cjs" ]] || continue - [[ ! -L "$legacy_dir" && ! -L "$legacy_path" ]] || continue - mkdir -p "$legacy_dir" 2>/dev/null || continue - [[ -f "$legacy_path" ]] && continue - temp_path="$(mktemp "$legacy_dir/.restore-node-options.cjs.XXXXXX" 2>/dev/null)" || continue - cmux_claude_wrapper_node_options_restore_source >"$temp_path" || { - rm -f "$temp_path" - continue - } - chmod 0600 "$temp_path" 2>/dev/null || { - rm -f "$temp_path" - continue - } - mv -f "$temp_path" "$legacy_path" 2>/dev/null || rm -f "$temp_path" + if [[ "$legacy_path" != "$(cmux_claude_wrapper_node_options_cache_dir)/restore-node-options.cjs" && + ! -L "$legacy_dir" && ! -L "$legacy_path" ]]; then + mkdir -p "$legacy_dir" 2>/dev/null && + if [[ ! -f "$legacy_path" ]]; then + temp_path="$(mktemp "$legacy_dir/.restore-node-options.cjs.XXXXXX" 2>/dev/null)" || temp_path="" + if [[ -n "$temp_path" ]]; then + if cmux_claude_wrapper_node_options_restore_source >"$temp_path" && + chmod 0600 "$temp_path"; then + mv -f "$temp_path" "$legacy_path" 2>/dev/null || rm -f "$temp_path" + else + rm -f "$temp_path" + fi + fi + fi + fi + index=$((index + 1)) done } From 5a069758549d57abae12c2cd8fb39e72ba531eb7 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Sun, 6 Sep 2026 13:14:05 -0700 Subject: [PATCH 06/34] test: cover legacy Claude preload migration --- tests/test_claude_wrapper_hooks.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/test_claude_wrapper_hooks.py b/tests/test_claude_wrapper_hooks.py index e150186c9468..6e1f420a9aa6 100644 --- a/tests/test_claude_wrapper_hooks.py +++ b/tests/test_claude_wrapper_hooks.py @@ -2463,6 +2463,22 @@ def test_live_socket_tmpdir_failure_does_not_block_node_options_injection(failur expect(child_node_options == "__UNSET__", f"tmpdir failure: expected child NODE_OPTIONS passthrough, got {child_node_options!r}", failures) +def test_live_socket_recreates_legacy_node_options_restore_module(failures: list[str]) -> None: + with tempfile.TemporaryDirectory(prefix="cmux-claude-wrapper-legacy-node-options-") as td: + legacy_path = Path(td) / "cmux-claude-node-options" / "restore-node-options.cjs" + code, _, _, stderr, _, _, runtime_node_options, child_node_options, _, _ = run_wrapper( + socket_state="live", + argv=["hello"], + node_options=f'--require="{legacy_path}"', + tmpdir=td, + ) + legacy_recreated = legacy_path.is_file() + expect(code == 0, f"legacy NODE_OPTIONS restore: wrapper exited {code}: {stderr}", failures) + expect(legacy_recreated, f"legacy NODE_OPTIONS restore: path was not recreated: {legacy_path}", failures) + expect(runtime_node_options == "__UNSET__", f"legacy NODE_OPTIONS restore: runtime leaked NODE_OPTIONS: {runtime_node_options!r}", failures) + expect(child_node_options == "__UNSET__", f"legacy NODE_OPTIONS restore: child leaked NODE_OPTIONS: {child_node_options!r}", failures) + + def test_live_socket_preserves_explicit_bypass_availability_flag(failures: list[str]) -> None: cases = [ ("allow/plain", ["--allow-dangerously-skip-permissions", "hello"], True, "--allow-dangerously-skip-permissions"), @@ -2680,6 +2696,7 @@ def main() -> int: test_live_socket_explicit_key_list_is_additive_to_vertex_auto_preserve(failures) test_live_socket_enforces_heap_cap_for_space_separated_flag(failures) test_live_socket_tmpdir_failure_does_not_block_node_options_injection(failures) + test_live_socket_recreates_legacy_node_options_restore_module(failures) test_live_socket_preserves_explicit_bypass_availability_flag(failures) test_live_socket_stale_mktemp_literal_does_not_warn(failures) test_missing_socket_skips_hook_injection(failures) From e5ec22e757febe5a12770d4ab966bce55cb11714 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Sun, 6 Sep 2026 13:22:02 -0700 Subject: [PATCH 07/34] fix: preserve quoted Claude node options --- CLI/CMUXCLI+ClaudeNodeOptions.swift | 57 ++++++++++++++++++-- Resources/bin/cmux-claude-wrapper | 80 +++++++++++++++++++++++++++-- 2 files changed, 131 insertions(+), 6 deletions(-) diff --git a/CLI/CMUXCLI+ClaudeNodeOptions.swift b/CLI/CMUXCLI+ClaudeNodeOptions.swift index e5b42d5879f9..ffe238c33aaf 100644 --- a/CLI/CMUXCLI+ClaudeNodeOptions.swift +++ b/CLI/CMUXCLI+ClaudeNodeOptions.swift @@ -44,6 +44,57 @@ extension CMUXCLI { return String(path.dropFirst().dropLast()) } + private static func nodeOptionsTokens(_ existing: String) -> [String]? { + var tokens: [String] = [] + var current = "" + var quote: Character? + var escaped = false + var tokenStarted = false + + for character in existing { + if escaped { + current.append(character) + escaped = false + tokenStarted = true + continue + } + if character == "\\" && quote != "'" { + current.append(character) + escaped = true + tokenStarted = true + continue + } + if let activeQuote = quote { + current.append(character) + if character == activeQuote { + quote = nil + } + tokenStarted = true + continue + } + if character == "'" || character == "\"" { + quote = character + current.append(character) + tokenStarted = true + } else if character.isWhitespace { + if tokenStarted { + tokens.append(current) + current = "" + tokenStarted = false + } + } else { + current.append(character) + tokenStarted = true + } + } + + guard !escaped, quote == nil else { return nil } + if tokenStarted { + tokens.append(current) + } + return tokens + } + private static func cachePathState(at url: URL) throws -> CachePathState? { var statValue = stat() guard lstat(url.path, &statValue) == 0 else { @@ -90,7 +141,7 @@ extension CMUXCLI { } private func nodeOptionsRequirePaths(_ existing: String) -> [String] { - let tokens = existing.split(whereSeparator: \.isWhitespace).map(String.init) + guard let tokens = Self.nodeOptionsTokens(existing) else { return [] } var paths: [String] = [] var index = 0 while index < tokens.count { @@ -168,7 +219,7 @@ extension CMUXCLI { } private func cleanedNodeOptions(_ existing: String?) -> String { - let tokens = (existing ?? "").split(whereSeparator: \.isWhitespace).map(String.init) + guard let tokens = Self.nodeOptionsTokens(existing ?? "") else { return existing ?? "" } guard !tokens.isEmpty else { return "" } var filtered: [String] = [] @@ -190,7 +241,7 @@ extension CMUXCLI { } func normalizedNodeOptionsForRestore(_ existing: String) -> String { - let tokens = existing.split(whereSeparator: \.isWhitespace).map(String.init) + guard let tokens = Self.nodeOptionsTokens(existing) else { return existing } guard !tokens.isEmpty else { return "" } var normalized: [String] = [] diff --git a/Resources/bin/cmux-claude-wrapper b/Resources/bin/cmux-claude-wrapper index 34a6c7dc9424..4615887e1487 100755 --- a/Resources/bin/cmux-claude-wrapper +++ b/Resources/bin/cmux-claude-wrapper @@ -913,6 +913,67 @@ cmux_claude_wrapper_unquote_node_options_path() { printf '%s' "$path" } +cmux_claude_wrapper_tokenize_node_options() { + local input="$1" + local current="" + local char + local index=0 + local quote="" + local escaped=0 + local token_started=0 + CMUX_CLAUDE_WRAPPER_NODE_OPTIONS_TOKENS=() + + while (( index < ${#input} )); do + char="${input:$index:1}" + if (( escaped )); then + current+="$char" + escaped=0 + index=$((index + 1)) + continue + fi + if [[ "$char" == "\\" && "$quote" != "'" ]]; then + current+="$char" + escaped=1 + token_started=1 + index=$((index + 1)) + continue + fi + if [[ -n "$quote" ]]; then + current+="$char" + if [[ "$char" == "$quote" ]]; then + quote="" + fi + token_started=1 + index=$((index + 1)) + continue + fi + case "$char" in + "'"|\") + quote="$char" + current+="$char" + token_started=1 + ;; + [[:space:]]) + if (( token_started )); then + CMUX_CLAUDE_WRAPPER_NODE_OPTIONS_TOKENS+=("$current") + current="" + token_started=0 + fi + ;; + *) + current+="$char" + token_started=1 + ;; + esac + index=$((index + 1)) + done + + [[ "$escaped" == 0 && -z "$quote" ]] || return 1 + if (( token_started )); then + CMUX_CLAUDE_WRAPPER_NODE_OPTIONS_TOKENS+=("$current") + fi +} + cmux_claude_wrapper_recreate_legacy_node_options_modules() { local existing="${NODE_OPTIONS:-}" local -a tokens=() @@ -923,7 +984,8 @@ cmux_claude_wrapper_recreate_legacy_node_options_modules() { local temp_path [[ -n "$existing" ]] || return 0 - read -r -a tokens <<<"$existing" + cmux_claude_wrapper_tokenize_node_options "$existing" || return 0 + tokens=("${CMUX_CLAUDE_WRAPPER_NODE_OPTIONS_TOKENS[@]}") local index=0 while (( index < ${#tokens[@]} )); do token="${tokens[$index]}" @@ -947,6 +1009,10 @@ cmux_claude_wrapper_recreate_legacy_node_options_modules() { index=$((index + 1)) continue fi + cmux_claude_wrapper_node_options_path_is_safe "$path" || { + index=$((index + 1)) + continue + } legacy_path="$path" legacy_dir="${legacy_path%/*}" if [[ "$legacy_path" != "$(cmux_claude_wrapper_node_options_cache_dir)/restore-node-options.cjs" && @@ -985,7 +1051,11 @@ merge_node_options() { return 0 fi - read -r -a tokens <<<"$existing" + if cmux_claude_wrapper_tokenize_node_options "$existing"; then + tokens=("${CMUX_CLAUDE_WRAPPER_NODE_OPTIONS_TOKENS[@]}") + else + read -r -a tokens <<<"$existing" + fi for token in "${tokens[@]}"; do if (( skip_next )); then skip_next=0 @@ -1016,7 +1086,11 @@ normalize_node_options_for_restore() { local token local index=0 - read -r -a tokens <<<"$existing" + if cmux_claude_wrapper_tokenize_node_options "$existing"; then + tokens=("${CMUX_CLAUDE_WRAPPER_NODE_OPTIONS_TOKENS[@]}") + else + read -r -a tokens <<<"$existing" + fi while (( index < ${#tokens[@]} )); do token="${tokens[$index]}" if [[ "$token" == "--max-old-space-size" && $((index + 1)) -lt ${#tokens[@]} ]]; then From 41a82ffe98f689e9323942643735c54d70b1aaaf Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Sun, 6 Sep 2026 13:34:36 -0700 Subject: [PATCH 08/34] fix: localize Claude node options error --- CLI/CMUXCLI+ClaudeNodeOptions.swift | 5 ++++- Resources/Localizable.xcstrings | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CLI/CMUXCLI+ClaudeNodeOptions.swift b/CLI/CMUXCLI+ClaudeNodeOptions.swift index ffe238c33aaf..c091491355f7 100644 --- a/CLI/CMUXCLI+ClaudeNodeOptions.swift +++ b/CLI/CMUXCLI+ClaudeNodeOptions.swift @@ -18,7 +18,10 @@ extension CMUXCLI { let path: String var errorDescription: String? { - "Claude NODE_OPTIONS restore module \(reason): \(path)" + String( + localized: "cli.claude.nodeOptionsRestoreError", + defaultValue: "Claude integration could not prepare its runtime environment." + ) } } diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index d26295f50651..74cc7ab9ca17 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -293012,5 +293012,22 @@ "cloudTree.link.tunnelFailed": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"This Mac's tunnel to your Cloud VM network could not start: %@"}},"ja":{"stringUnit":{"state":"translated","value":"この Mac からクラウド VM ネットワークへのトンネルを開始できませんでした: %@"}}}}, "cloudTree.link.tunnelOffAppManaged": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"This Mac's tunnel to your Cloud VM network is down; reopen the machine to start it."}},"ja":{"stringUnit":{"state":"translated","value":"この Mac からクラウド VM ネットワークへのトンネルは切断されています。マシンを再度開くと開始されます。"}}}}, "cloudTunnel.error.configurationNotInstalled": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"The VPN configuration was not saved before the tunnel was started."}},"ja":{"stringUnit":{"state":"translated","value":"トンネルの開始前に VPN 構成が保存されていませんでした。"}}}}, + "cli.claude.nodeOptionsRestoreError": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Claude integration could not prepare its runtime environment." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Claude 連携の実行環境を準備できませんでした。" + } + } + } + }, "version": "1.0" } From a70322abc8a123b32d67c9681061d22351bf732e Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Sun, 6 Sep 2026 13:53:25 -0700 Subject: [PATCH 09/34] fix: place Claude error localization in catalog --- Resources/Localizable.xcstrings | 34 ++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index e7d6687944b5..6c45f58dc9a2 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -292883,6 +292883,23 @@ } } } + }, + "cli.claude.nodeOptionsRestoreError": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Claude integration could not prepare its runtime environment." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Claude 連携の実行環境を準備できませんでした。" + } + } + } } }, "browser.chromium.automation.commandTimedOut": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Timed out waiting for Chromium CDP command"}},"ja":{"stringUnit":{"state":"translated","value":"ChromiumのCDPコマンドを待機中にタイムアウトしました"}}}}, @@ -293012,23 +293029,6 @@ "cloudTree.link.tunnelFailed": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"This Mac's tunnel to your Cloud VM network could not start: %@"}},"ja":{"stringUnit":{"state":"translated","value":"この Mac からクラウド VM ネットワークへのトンネルを開始できませんでした: %@"}}}}, "cloudTree.link.tunnelOffAppManaged": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"This Mac's tunnel to your Cloud VM network is down; reopen the machine to start it."}},"ja":{"stringUnit":{"state":"translated","value":"この Mac からクラウド VM ネットワークへのトンネルは切断されています。マシンを再度開くと開始されます。"}}}}, "cloudTunnel.error.configurationNotInstalled": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"The VPN configuration was not saved before the tunnel was started."}},"ja":{"stringUnit":{"state":"translated","value":"トンネルの開始前に VPN 構成が保存されていませんでした。"}}}}, - "cli.claude.nodeOptionsRestoreError": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Claude integration could not prepare its runtime environment." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Claude 連携の実行環境を準備できませんでした。" - } - } - } - }, "cloudTree.ports.loading": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Discovering ports…"}},"ja":{"stringUnit":{"state":"translated","value":"ポートを検出中…"}}}}, "cloudTree.ports.failed": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Couldn’t discover ports. Refresh to retry."}},"ja":{"stringUnit":{"state":"translated","value":"ポートを検出できませんでした。更新して再試行してください。"}}}}, "cloudTree.ports.asleep": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Open the machine to discover ports"}},"ja":{"stringUnit":{"state":"translated","value":"マシンを開いてポートを検出"}}}}, From a0bc8961c381cfed056cf1fb84d7b907c6e427b0 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:01:18 -0700 Subject: [PATCH 10/34] fix: restore main build compatibility --- Sources/RightSidebarPanelView.swift | 2 +- Sources/Surfaces/SurfaceSocketCommands.swift | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/RightSidebarPanelView.swift b/Sources/RightSidebarPanelView.swift index 5c0103a3b2ff..b50cf6941a17 100644 --- a/Sources/RightSidebarPanelView.swift +++ b/Sources/RightSidebarPanelView.swift @@ -169,7 +169,7 @@ struct RightSidebarPanelView: View { private var featureAvailableModes: [RightSidebarMode] { _ = managedPolicyRevision - RightSidebarMode.availableModes( + return RightSidebarMode.availableModes( feedEnabled: feedEnabled, dockEnabled: dockEnabled, machinesEnabled: CloudMachinesFeature.isEnabled diff --git a/Sources/Surfaces/SurfaceSocketCommands.swift b/Sources/Surfaces/SurfaceSocketCommands.swift index c277a3b1c404..d092616d5070 100644 --- a/Sources/Surfaces/SurfaceSocketCommands.swift +++ b/Sources/Surfaces/SurfaceSocketCommands.swift @@ -14,7 +14,7 @@ import Foundation // Focus policy: `focus` defaults to true for explicit opens (the caller asked for a pane) // and false for desktop/port opens; the catalog never activates the app either way. extension TerminalController { - private nonisolated static func cloudDisabledSocketError(id: Any?) -> String? { + private nonisolated func cloudDisabledSocketError(id: Any?) -> String? { guard ManagedDevicePolicy().isEnforced(.disableCloud) else { return nil } return v2Error( id: id, @@ -27,7 +27,7 @@ extension TerminalController { switch method { case "surface.catalog": let machine = Self.surfaceMachineFilter(params["machine"]) - if let machine, machine.cloudMachineID != nil, let error = Self.cloudDisabledSocketError(id: id) { return error } + if let machine, machine.cloudMachineID != nil, let error = cloudDisabledSocketError(id: id) { return error } let refresh = Self.surfaceBool(params["refresh"]) ?? false return v2VmCall(id: id, timeoutSeconds: 120) { if refresh { @@ -45,7 +45,7 @@ extension TerminalController { guard let raw = Self.surfaceString(params["resource"]), let resource = SurfaceResourceID(rawValue: raw) else { return v2Error(id: id, code: "invalid_params", message: "surface.project requires `resource` (an id from `cmux surface ls --json`, e.g. vivid-newt/terminal/term_…).") } - if resource.machine.cloudMachineID != nil, let error = Self.cloudDisabledSocketError(id: id) { return error } + if resource.machine.cloudMachineID != nil, let error = cloudDisabledSocketError(id: id) { return error } let focus = Self.surfaceBool(params["focus"]) ?? true let reuse = Self.surfaceBool(params["reuse"]) ?? true let remoteTabID = Self.surfaceString(params["remote_tab_id"]) @@ -76,7 +76,7 @@ extension TerminalController { return v2Error(id: id, code: "invalid_params", message: "surface.new_terminal requires `machine` (\"local\" or a cloud machine id).") } let machine = SurfaceMachineID(rawValue: machineRaw) - if machine.cloudMachineID != nil, let error = Self.cloudDisabledSocketError(id: id) { return error } + if machine.cloudMachineID != nil, let error = cloudDisabledSocketError(id: id) { return error } let command = Self.surfaceStringArray(params["command"]) let cwd = Self.surfaceString(params["cwd"]) let name = Self.surfaceString(params["name"]) From b909c31141c4f0e17e876e20b181785ce95f75d5 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Sun, 6 Sep 2026 14:49:48 -0700 Subject: [PATCH 11/34] fix(cloud): restore catalog rename compatibility APIs --- ...aceCatalog+CloudRenameReconciliation.swift | 245 ++++++++++++++++++ Sources/Surfaces/SurfaceCatalog.swift | 22 +- cmux.xcodeproj/project.pbxproj | 4 + 3 files changed, 267 insertions(+), 4 deletions(-) create mode 100644 Sources/Surfaces/SurfaceCatalog+CloudRenameReconciliation.swift diff --git a/Sources/Surfaces/SurfaceCatalog+CloudRenameReconciliation.swift b/Sources/Surfaces/SurfaceCatalog+CloudRenameReconciliation.swift new file mode 100644 index 000000000000..d43d1f5970e4 --- /dev/null +++ b/Sources/Surfaces/SurfaceCatalog+CloudRenameReconciliation.swift @@ -0,0 +1,245 @@ +import Foundation + +/// Cursor state for the legacy versioned resource snapshot API. Current providers publish the +/// typed `CloudVMState`; this state only keeps older callers from regressing an accepted graph +/// while they migrate to that boundary. +struct CloudResourceCompatibilityState: Sendable { + var cursor: CloudVMCursor + var canonicalWorkspaces: [SurfaceRemoteWorkspace] + var acceptedGenerations: Set +} + +struct CloudWorkspaceRenameKey: Hashable, Sendable { + let machine: SurfaceMachineID + let workspaceID: String +} + +struct CloudWorkspaceRenameIntent: Sendable { + let tokenID: UUID + let baseName: String + let name: String + var receipt: CloudVMCursor? +} + +struct CloudWorkspaceRenameToken: Hashable, Sendable { + let id: UUID + let key: CloudWorkspaceRenameKey +} + +extension SurfaceCatalog { + /// Replaces a cloud machine's legacy resource rows only when the cursor is current. Equal + /// cursors are no-ops unless they carry the exact name covered by a read-your-write receipt; + /// all other equal and older snapshots are stale. + @discardableResult + func replaceCloudResources( + _ list: [SurfaceResource], + on machine: SurfaceMachineID, + info: SurfaceMachineInfo, + cursor: CloudVMCursor, + from source: (any SurfaceProvider)? = nil + ) -> Bool { + guard case .cloud = machine, + info.id == machine, + cloudStates[machine] == nil, + accepts(writeFor: machine, from: source) else { return false } + for resource in list { + precondition(resource.machine == machine, "resource \(resource.id) reported by the wrong cloud machine") + } + guard acceptsCloudCompatibilitySnapshot(info: info, machine: machine, cursor: cursor) else { + return false + } + + let previous = cloudResourceCompatibility[machine] + var generations = previous?.acceptedGenerations ?? [] + generations.insert(cursor.generation) + cloudResourceCompatibility[machine] = CloudResourceCompatibilityState( + cursor: cursor, + canonicalWorkspaces: info.remoteWorkspaces ?? [], + acceptedGenerations: generations + ) + + retireCloudWorkspaceRenameIntents( + observed: info.remoteWorkspaces ?? [], + cursor: cursor, + machine: machine + ) + let effectiveInfo = cloudResourceCompatibilityMachineInfo(info) + let effectiveResources = cloudWorkspaceRenameEffectiveResources(list, machine: machine) + return replaceResources(effectiveResources, on: machine, info: effectiveInfo, from: source) + } + + /// Begins an optimistic workspace rename and returns a token that can later be committed or + /// rolled back. Multiple tokens for one identity retain the original base name, so an older + /// completion cannot undo a newer intent. + func beginCloudWorkspaceRename( + machine: SurfaceMachineID, + workspaceID: String, + name: String + ) throws -> CloudWorkspaceRenameToken { + guard case .cloud = machine, + cloudResourceCompatibility[machine] != nil, + accepts(writeFor: machine) else { throw SurfaceCatalogError.noProvider(machine) } + let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedName.isEmpty else { + throw SurfaceCatalogError.unsupported( + String( + localized: "cloudTree.error.renameWorkspaceEmptyName", + defaultValue: "A workspace name cannot be empty." + ) + ) + } + let key = CloudWorkspaceRenameKey(machine: machine, workspaceID: workspaceID) + guard let machineInfo = snapshot.machines.first(where: { $0.id == machine }), + let workspace = machineInfo.remoteWorkspaces?.first(where: { $0.id == workspaceID }) else { + throw SurfaceCatalogError.unsupported( + String( + localized: "cloudTree.error.renameWorkspaceNotFound", + defaultValue: "This remote workspace is no longer available." + ) + ) + } + let intents = cloudWorkspaceRenameIntents[key] ?? [] + let baseName = intents.first?.baseName ?? workspace.name + let token = CloudWorkspaceRenameToken(id: UUID(), key: key) + cloudWorkspaceRenameIntents[key, default: []].append( + CloudWorkspaceRenameIntent( + tokenID: token.id, + baseName: baseName, + name: normalizedName, + receipt: nil + ) + ) + applyCloudWorkspaceRenameOverlay(machine: machine) + return token + } + + /// Records the daemon's read-your-write cursor. The intent remains visible until an + /// accepted snapshot reaches that cursor and proves the requested workspace name. + func commitCloudWorkspaceRename(_ token: CloudWorkspaceRenameToken, receipt: CloudVMCursor) { + guard var intents = cloudWorkspaceRenameIntents[token.key], + let index = intents.firstIndex(where: { $0.tokenID == token.id }) else { return } + intents[index].receipt = receipt + cloudWorkspaceRenameIntents[token.key] = intents + } + + /// Removes one optimistic intent. If a newer intent remains, its name stays projected; when + /// the final intent is removed, the catalog returns to the accepted canonical workspace name. + func rollbackCloudWorkspaceRename(_ token: CloudWorkspaceRenameToken) { + guard var intents = cloudWorkspaceRenameIntents[token.key], + let index = intents.firstIndex(where: { $0.tokenID == token.id }) else { return } + intents.remove(at: index) + if intents.isEmpty { + cloudWorkspaceRenameIntents[token.key] = nil + } else { + cloudWorkspaceRenameIntents[token.key] = intents + } + applyCloudWorkspaceRenameOverlay(machine: token.key.machine) + } + + func pendingCloudWorkspaceRenameName(machine: SurfaceMachineID, workspaceID: String) -> String? { + cloudWorkspaceRenameIntents[ + CloudWorkspaceRenameKey(machine: machine, workspaceID: workspaceID) + ]?.last?.name + } + + /// Called by the existing cursorless machine-update path. It preserves the last accepted + /// workspace graph while still exposing the newest local optimistic name. + func cloudResourceCompatibilityMachineInfo(_ info: SurfaceMachineInfo) -> SurfaceMachineInfo { + guard let state = cloudResourceCompatibility[info.id] else { return info } + var adjusted = info + var workspaces = state.canonicalWorkspaces + for index in workspaces.indices { + let key = CloudWorkspaceRenameKey(machine: info.id, workspaceID: workspaces[index].id) + if let name = cloudWorkspaceRenameIntents[key]?.last?.name { + workspaces[index].name = name + } + } + adjusted.remoteWorkspaces = workspaces + return adjusted + } + + private func acceptsCloudCompatibilitySnapshot( + info: SurfaceMachineInfo, + machine: SurfaceMachineID, + cursor: CloudVMCursor + ) -> Bool { + guard let current = cloudResourceCompatibility[machine] else { return true } + if cursor.generation != current.cursor.generation { + return !current.acceptedGenerations.contains(cursor.generation) + } + if cursor.revision < current.cursor.revision { return false } + + var matchedReceiptAtCursor = false + for (key, intents) in cloudWorkspaceRenameIntents where key.machine == machine { + for intent in intents { + guard let receipt = intent.receipt, receipt.generation == cursor.generation else { continue } + if cursor.revision < receipt.revision { return false } + if cursor.revision == receipt.revision, + info.remoteWorkspaces?.first(where: { $0.id == key.workspaceID })?.name != intent.name { + return false + } + if cursor.revision == receipt.revision { + matchedReceiptAtCursor = true + } + } + } + if cursor.revision == current.cursor.revision, + info.remoteWorkspaces == current.canonicalWorkspaces { + return true + } + if cursor.revision > current.cursor.revision { return true } + return matchedReceiptAtCursor + } + + private func retireCloudWorkspaceRenameIntents( + observed workspaces: [SurfaceRemoteWorkspace], + cursor: CloudVMCursor, + machine: SurfaceMachineID + ) { + for (key, intents) in cloudWorkspaceRenameIntents where key.machine == machine { + let observedName = workspaces.first(where: { $0.id == key.workspaceID })?.name + let remaining = intents.filter { intent in + guard let receipt = intent.receipt else { return true } + if cursor.generation != receipt.generation { return false } + if cursor.revision > receipt.revision { return false } + if cursor.revision == receipt.revision && observedName == intent.name { return false } + return true + } + cloudWorkspaceRenameIntents[key] = remaining.isEmpty ? nil : remaining + } + } + + private func applyCloudWorkspaceRenameOverlay(machine: SurfaceMachineID) { + guard let info = snapshot.machines.first(where: { $0.id == machine }) else { return } + let effectiveInfo = cloudResourceCompatibilityMachineInfo(info) + let effectiveResources = cloudWorkspaceRenameEffectiveResources( + snapshot.resources(on: machine), + machine: machine + ) + _ = replaceResources(effectiveResources, on: machine, info: effectiveInfo) + } + + private func cloudWorkspaceRenameEffectiveResources( + _ resources: [SurfaceResource], + machine: SurfaceMachineID + ) -> [SurfaceResource] { + resources.map { resource in + guard resource.machine == machine else { return resource } + var adjusted = resource + if let workspace = adjusted.remoteWorkspace { + let key = CloudWorkspaceRenameKey(machine: machine, workspaceID: workspace.id) + if let name = cloudWorkspaceRenameIntents[key]?.last?.name { + adjusted.remoteWorkspace?.name = name + } + } + adjusted.remoteViews = adjusted.remoteViews?.map { view in + let key = CloudWorkspaceRenameKey(machine: machine, workspaceID: view.workspace.id) + guard let name = cloudWorkspaceRenameIntents[key]?.last?.name else { return view } + var adjustedView = view + adjustedView.workspace.name = name + return adjustedView + } + return adjusted + } + } +} diff --git a/Sources/Surfaces/SurfaceCatalog.swift b/Sources/Surfaces/SurfaceCatalog.swift index 1abc46dc7901..01e5835b0ec3 100644 --- a/Sources/Surfaces/SurfaceCatalog.swift +++ b/Sources/Surfaces/SurfaceCatalog.swift @@ -234,6 +234,13 @@ final class SurfaceCatalog { /// from `CloudVMState` because freshness is local observation metadata, not /// part of the daemon document or its cursor. private(set) var cloudStateObservations: [SurfaceMachineID: CloudVMStateObservation] = [:] + /// Compatibility state for the versioned snapshot API used by legacy cloud callers. The + /// typed `CloudVMState` path remains authoritative for current providers. + @ObservationIgnored + var cloudResourceCompatibility: [SurfaceMachineID: CloudResourceCompatibilityState] = [:] + /// Compatibility rename intents are retained until a versioned snapshot confirms them. + @ObservationIgnored + var cloudWorkspaceRenameIntents: [CloudWorkspaceRenameKey: [CloudWorkspaceRenameIntent]] = [:] private var providers: [SurfaceMachineID: any SurfaceProvider] = [:] /// The process-wide ordering owner for remote rename intents. A remote identity can /// have projections in several local windows, so this cannot live in a TabManager. @@ -401,6 +408,8 @@ final class SurfaceCatalog { for record in pending { pendingRestoredProjections[record] = nil } cloudStates[machine] = nil cloudStateObservations[machine] = nil + cloudResourceCompatibility[machine] = nil + cloudWorkspaceRenameIntents = cloudWorkspaceRenameIntents.filter { $0.key.machine != machine } projections = projections.filter { $0.resource.machine != machine } notifyChange() } @@ -414,7 +423,7 @@ final class SurfaceCatalog { /// pruned can still finish an in-flight refresh and write its machine back; /// accepting that write brings a machine the backend already destroyed back /// as a sidebar row nothing can refresh or delete. - private func accepts(writeFor machine: SurfaceMachineID, from source: (any SurfaceProvider)? = nil) -> Bool { + func accepts(writeFor machine: SurfaceMachineID, from source: (any SurfaceProvider)? = nil) -> Bool { if machine.isLocal { return true } guard let registered = providers[machine] else { #if DEBUG @@ -712,7 +721,10 @@ final class SurfaceCatalog { func clearCloudState(on machine: SurfaceMachineID) { let removedState = cloudStates.removeValue(forKey: machine) != nil let removedObservation = cloudStateObservations.removeValue(forKey: machine) != nil - guard removedState || removedObservation else { return } + let removedCompatibility = cloudResourceCompatibility.removeValue(forKey: machine) != nil + let removedRenameIntents = cloudWorkspaceRenameIntents.contains { $0.key.machine == machine } + cloudWorkspaceRenameIntents = cloudWorkspaceRenameIntents.filter { $0.key.machine != machine } + guard removedState || removedObservation || removedCompatibility || removedRenameIntents else { return } notifyChange() } @@ -768,8 +780,10 @@ final class SurfaceCatalog { _ info: SurfaceMachineInfo, state: CloudVMState? = nil ) -> SurfaceMachineInfo { - guard case .cloud = info.id, - let state = state ?? cloudStates[info.id] else { return info } + guard case .cloud = info.id else { return info } + guard let state = state ?? cloudStates[info.id] else { + return cloudResourceCompatibilityMachineInfo(info) + } var adjusted = info let canonical = state.workspaces.map { SurfaceRemoteWorkspace(id: $0.id, name: $0.name, index: $0.index, focused: $0.focused) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index e490cfff86f8..f5a499b29a5c 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2557,6 +2557,7 @@ C0DE70530000000000000002 /* submit-cmux-profile in Copy CLI */ = {isa = PBXBuildFile; fileRef = C0DE70530000000000000001 /* submit-cmux-profile */; }; D7AB00000000000000B041 /* SupersededPhoneDismissBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000B040 /* SupersededPhoneDismissBuffer.swift */; }; F11347000000000000000001 /* SurfaceCatalog+CloudPorts.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11347000000000000000002 /* SurfaceCatalog+CloudPorts.swift */; }; + D12055000000000000000002 /* SurfaceCatalog+CloudRenameReconciliation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D12055000000000000000001 /* SurfaceCatalog+CloudRenameReconciliation.swift */; }; DD3A13AD76F5196C5FC8C52A /* SurfaceCatalog+Groups.swift in Sources */ = {isa = PBXBuildFile; fileRef = 660286CE22B478E887361407 /* SurfaceCatalog+Groups.swift */; }; F7F3BED81BD5FE96D52055C0 /* SurfaceCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA63D377742D3A6A0BAB499B /* SurfaceCatalog.swift */; }; 31B62CAEA63AA5B0F737F227 /* SurfaceCatalogModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66CA02CE5FE3E9DC871AD810 /* SurfaceCatalogModel.swift */; }; @@ -5787,6 +5788,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef C0DE70530000000000000001 /* submit-cmux-profile */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "Resources/bin/submit-cmux-profile"; sourceTree = SOURCE_ROOT; }; D7AB00000000000000B040 /* SupersededPhoneDismissBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupersededPhoneDismissBuffer.swift; sourceTree = ""; }; F11347000000000000000002 /* SurfaceCatalog+CloudPorts.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "SurfaceCatalog+CloudPorts.swift"; sourceTree = ""; }; + D12055000000000000000001 /* SurfaceCatalog+CloudRenameReconciliation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "SurfaceCatalog+CloudRenameReconciliation.swift"; sourceTree = ""; }; 660286CE22B478E887361407 /* SurfaceCatalog+Groups.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "SurfaceCatalog+Groups.swift"; sourceTree = ""; }; EA63D377742D3A6A0BAB499B /* SurfaceCatalog.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SurfaceCatalog.swift; sourceTree = ""; }; 66CA02CE5FE3E9DC871AD810 /* SurfaceCatalogModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SurfaceCatalogModel.swift; sourceTree = ""; }; @@ -6880,6 +6882,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef C11323220000000000000001 /* CmuxTuiSurfaceProvider+ManualMirror.swift */, 660286CE22B478E887361407 /* SurfaceCatalog+Groups.swift */, F11347000000000000000002 /* SurfaceCatalog+CloudPorts.swift */, + D12055000000000000000001 /* SurfaceCatalog+CloudRenameReconciliation.swift */, 278BEFB3583AED9627966DFF /* SurfaceSocketCommands.swift */, 47BBA1A8703756FDDFD53CF3 /* Workspace+SurfaceCatalog.swift */, C5797FE9CD70513E4FFAC6EB /* Workspace+CloudPaneRouting.swift */, @@ -12021,6 +12024,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef D35B71010000000000000001 /* StartupBreadcrumbLog.swift in Sources */, D7AB00000000000000B041 /* SupersededPhoneDismissBuffer.swift in Sources */, F11347000000000000000001 /* SurfaceCatalog+CloudPorts.swift in Sources */, + D12055000000000000000002 /* SurfaceCatalog+CloudRenameReconciliation.swift in Sources */, DD3A13AD76F5196C5FC8C52A /* SurfaceCatalog+Groups.swift in Sources */, F7F3BED81BD5FE96D52055C0 /* SurfaceCatalog.swift in Sources */, 31B62CAEA63AA5B0F737F227 /* SurfaceCatalogModel.swift in Sources */, From 8635e54041c401c80e75ec81d05715d5466a6a65 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:38:36 -0700 Subject: [PATCH 12/34] fix: clear current main warning budget --- Sources/TerminalController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 18dda3db65c0..883f91bea70d 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -8013,7 +8013,7 @@ class TerminalController { if let dict = value as? [String: Any], let ok = dict["ok"] as? Bool, ok { - var payload: [String: Any] = [ + let payload: [String: Any] = [ "workspace_id": ctx.workspaceId.uuidString, "surface_id": surfaceId.uuidString, "action": actionName, From 5af2d062acb6965d6b41ceddda33097ad25e263b Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:01:47 -0700 Subject: [PATCH 13/34] fix: correct browser payload mutability --- Sources/TerminalController.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 883f91bea70d..ceba2f903822 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -8013,7 +8013,7 @@ class TerminalController { if let dict = value as? [String: Any], let ok = dict["ok"] as? Bool, ok { - let payload: [String: Any] = [ + var payload: [String: Any] = [ "workspace_id": ctx.workspaceId.uuidString, "surface_id": surfaceId.uuidString, "action": actionName, @@ -8760,7 +8760,7 @@ class TerminalController { switch ctx.webView.replayBrowserKeyboardEvent(event, action: action) { case .delivered: - var payload: [String: Any] = [ + let payload: [String: Any] = [ "workspace_id": ctx.workspaceId.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ctx.workspaceId), "surface_id": ctx.surfaceId.uuidString, From 1aaabcc9d586dd44b598069b80c505d8f13a5d9a Mon Sep 17 00:00:00 2001 From: Austin Yang Date: Tue, 8 Sep 2026 07:35:53 -0700 Subject: [PATCH 14/34] fix: wire local tmux helpers into app-host tests --- cmux.xcodeproj/project.pbxproj | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 82ee220dd60d..73c1536ef350 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -13285,6 +13285,11 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 87C609D63F03597AC12C8B0A /* LastSurfaceClosePreferenceTests.swift in Sources */, 9492CAFE0000000000000002 /* LastTerminalChildExitRecoveryTests.swift in Sources */, 540A9F26DCDFD0C16B257D6F /* LocalSurfaceProviderTests.swift in Sources */, + C0DE10651000000000000002 /* LocalTmuxCommandBuilder.swift in Sources */, + C0DE10651000000000000003 /* LocalTmuxProcessRunner.swift in Sources */, + C0DE1065100000000000000B /* LocalTmuxSessionBinding.swift in Sources */, + C0DE10651000000000000008 /* LocalTmuxSessionIdentity.swift in Sources */, + C0DE1065100000000000000A /* LocalTmuxSessionListParser.swift in Sources */, 484A89A4B4AA08EF63E0DB51 /* MachineCreateCoordinatorTests.swift in Sources */, 7690848956F4C55873F767F6 /* MachinesPanelModelTests.swift in Sources */, 98E6802001D61AC24F2ABB09 /* MachinesPanelUncappedPlanTests.swift in Sources */, From 60607509fcefbfc5f2096d3a354bad0ee7c46e93 Mon Sep 17 00:00:00 2001 From: Austin Yang Date: Tue, 8 Sep 2026 08:26:26 -0700 Subject: [PATCH 15/34] fix: filter duplicate Claude restore preloads --- CLI/CMUXCLI+ClaudeNodeOptions.swift | 42 +++++++- Resources/bin/cmux-claude-wrapper | 102 ++++++++++++++---- ...pper_node_options_survives_tmpdir_purge.py | 32 +++++- 3 files changed, 154 insertions(+), 22 deletions(-) diff --git a/CLI/CMUXCLI+ClaudeNodeOptions.swift b/CLI/CMUXCLI+ClaudeNodeOptions.swift index c091491355f7..9819a59cd3c1 100644 --- a/CLI/CMUXCLI+ClaudeNodeOptions.swift +++ b/CLI/CMUXCLI+ClaudeNodeOptions.swift @@ -165,9 +165,14 @@ extension CMUXCLI { return paths } + private func isClaudeNodeOptionsRestorePath(_ path: String) -> Bool { + path.hasSuffix("/cmux-claude-node-options/restore-node-options.cjs") + } + private func recreateLegacyNodeOptionsRestoreModules(excluding currentURL: URL) { guard let existing = ProcessInfo.processInfo.environment["NODE_OPTIONS"] else { return } let legacySuffix = "/cmux-claude-node-options/restore-node-options.cjs" + let expectedOwner = getuid() for path in nodeOptionsRequirePaths(existing) { guard path.hasSuffix(legacySuffix), Self.nodeOptionsPathIsSafe(path), @@ -175,15 +180,20 @@ extension CMUXCLI { let url = URL(fileURLWithPath: path, isDirectory: false) let directory = url.deletingLastPathComponent() do { - if try Self.cachePathState(at: directory)?.isSymbolicLink == true { continue } + try Self.validateCachePath(directory, expectedOwner: expectedOwner) try FileManager.default.createDirectory( at: directory, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700] ) - guard !FileManager.default.fileExists(atPath: url.path) else { continue } + try Self.validateCachePath(directory, expectedOwner: expectedOwner) + if try Self.cachePathState(at: url) != nil { + try Self.validateCachePath(url, expectedOwner: expectedOwner) + continue + } try writeShimIfChanged(Self.claudeNodeOptionsRestoreModule, to: url) try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + try Self.validateCachePath(url, expectedOwner: expectedOwner) } catch { continue } @@ -229,6 +239,20 @@ extension CMUXCLI { var index = 0 while index < tokens.count { let token = tokens[index] + if token == "--require" || token == "-r", + index + 1 < tokens.count, + isClaudeNodeOptionsRestorePath(Self.unquoteNodeOptionsPath(tokens[index + 1])) { + index += 2 + continue + } + if token.hasPrefix("--require=") || token.hasPrefix("-r=") { + let separator = token.hasPrefix("--require=") ? "--require=" : "-r=" + let path = Self.unquoteNodeOptionsPath(String(token.dropFirst(separator.count))) + if isClaudeNodeOptionsRestorePath(path) { + index += 1 + continue + } + } if token == "--max-old-space-size" { index += min(2, tokens.count - index) continue @@ -251,6 +275,20 @@ extension CMUXCLI { var index = 0 while index < tokens.count { let token = tokens[index] + if token == "--require" || token == "-r", + index + 1 < tokens.count, + isClaudeNodeOptionsRestorePath(Self.unquoteNodeOptionsPath(tokens[index + 1])) { + index += 2 + continue + } + if token.hasPrefix("--require=") || token.hasPrefix("-r=") { + let separator = token.hasPrefix("--require=") ? "--require=" : "-r=" + let path = Self.unquoteNodeOptionsPath(String(token.dropFirst(separator.count))) + if isClaudeNodeOptionsRestorePath(path) { + index += 1 + continue + } + } if token == "--max-old-space-size", index + 1 < tokens.count { normalized.append("--max-old-space-size=\(tokens[index + 1])") index += 2 diff --git a/Resources/bin/cmux-claude-wrapper b/Resources/bin/cmux-claude-wrapper index 4615887e1487..332bd2a3f497 100755 --- a/Resources/bin/cmux-claude-wrapper +++ b/Resources/bin/cmux-claude-wrapper @@ -913,6 +913,20 @@ cmux_claude_wrapper_unquote_node_options_path() { printf '%s' "$path" } +cmux_claude_wrapper_is_node_options_restore_path() { + local path + path="$(cmux_claude_wrapper_unquote_node_options_path "$1")" + [[ "$path" == */cmux-claude-node-options/restore-node-options.cjs ]] +} + +cmux_claude_wrapper_path_owned_by_current_user() { + local path="$1" + local owner + [[ ! -L "$path" ]] || return 1 + owner="$(/usr/bin/stat -f '%u' "$path" 2>/dev/null)" || return 1 + [[ "$owner" == "$(/usr/bin/id -u)" ]] +} + cmux_claude_wrapper_tokenize_node_options() { local input="$1" local current="" @@ -1015,20 +1029,34 @@ cmux_claude_wrapper_recreate_legacy_node_options_modules() { } legacy_path="$path" legacy_dir="${legacy_path%/*}" - if [[ "$legacy_path" != "$(cmux_claude_wrapper_node_options_cache_dir)/restore-node-options.cjs" && - ! -L "$legacy_dir" && ! -L "$legacy_path" ]]; then - mkdir -p "$legacy_dir" 2>/dev/null && - if [[ ! -f "$legacy_path" ]]; then - temp_path="$(mktemp "$legacy_dir/.restore-node-options.cjs.XXXXXX" 2>/dev/null)" || temp_path="" - if [[ -n "$temp_path" ]]; then - if cmux_claude_wrapper_node_options_restore_source >"$temp_path" && - chmod 0600 "$temp_path"; then - mv -f "$temp_path" "$legacy_path" 2>/dev/null || rm -f "$temp_path" - else - rm -f "$temp_path" - fi - fi + if [[ "$legacy_path" == "$(cmux_claude_wrapper_node_options_cache_dir)/restore-node-options.cjs" ]]; then + index=$((index + 1)) + continue + fi + if [[ -e "$legacy_dir" || -L "$legacy_dir" ]] && + ! cmux_claude_wrapper_path_owned_by_current_user "$legacy_dir"; then + index=$((index + 1)) + continue + fi + if [[ -e "$legacy_path" || -L "$legacy_path" ]]; then + cmux_claude_wrapper_path_owned_by_current_user "$legacy_path" || { + index=$((index + 1)) + continue + } + index=$((index + 1)) + continue + fi + if mkdir -p "$legacy_dir" 2>/dev/null && + cmux_claude_wrapper_path_owned_by_current_user "$legacy_dir"; then + temp_path="$(mktemp "$legacy_dir/.restore-node-options.cjs.XXXXXX" 2>/dev/null)" || temp_path="" + if [[ -n "$temp_path" ]]; then + if cmux_claude_wrapper_node_options_restore_source >"$temp_path" && + chmod 0600 "$temp_path"; then + mv -f "$temp_path" "$legacy_path" 2>/dev/null || rm -f "$temp_path" + else + rm -f "$temp_path" fi + fi fi index=$((index + 1)) done @@ -1045,6 +1073,7 @@ merge_node_options() { local -a tokens=() local token local skip_next=0 + local require_path if [[ -z "$guard_path" ]]; then printf '%s' "$existing" @@ -1056,19 +1085,43 @@ merge_node_options() { else read -r -a tokens <<<"$existing" fi - for token in "${tokens[@]}"; do + local index=0 + while (( index < ${#tokens[@]} )); do + token="${tokens[$index]}" if (( skip_next )); then skip_next=0 + index=$((index + 1)) continue fi + if [[ "$token" == "--require" || "$token" == "-r" ]] && + (( index + 1 < ${#tokens[@]} )); then + require_path="$(cmux_claude_wrapper_unquote_node_options_path "${tokens[$((index + 1))]}")" + if cmux_claude_wrapper_is_node_options_restore_path "$require_path"; then + index=$((index + 2)) + continue + fi + filtered+=("$token") + index=$((index + 1)) + continue + fi + if [[ "$token" == --require=* || "$token" == -r=* ]]; then + require_path="$(cmux_claude_wrapper_unquote_node_options_path "${token#*=}")" + if cmux_claude_wrapper_is_node_options_restore_path "$require_path"; then + index=$((index + 1)) + continue + fi + fi if [[ "$token" == "--max-old-space-size" ]]; then skip_next=1 + index=$((index + 1)) continue fi if [[ "$token" == --max-old-space-size=* ]]; then + index=$((index + 1)) continue fi filtered+=("$token") + index=$((index + 1)) done if (( ${#filtered[@]} == 0 )); then @@ -1085,6 +1138,7 @@ normalize_node_options_for_restore() { local -a normalized=() local token local index=0 + local require_path if cmux_claude_wrapper_tokenize_node_options "$existing"; then tokens=("${CMUX_CLAUDE_WRAPPER_NODE_OPTIONS_TOKENS[@]}") @@ -1093,6 +1147,21 @@ normalize_node_options_for_restore() { fi while (( index < ${#tokens[@]} )); do token="${tokens[$index]}" + if [[ "$token" == "--require" || "$token" == "-r" ]] && + (( index + 1 < ${#tokens[@]} )); then + require_path="$(cmux_claude_wrapper_unquote_node_options_path "${tokens[$((index + 1))]}")" + if cmux_claude_wrapper_is_node_options_restore_path "$require_path"; then + index=$((index + 2)) + continue + fi + fi + if [[ "$token" == --require=* || "$token" == -r=* ]]; then + require_path="$(cmux_claude_wrapper_unquote_node_options_path "${token#*=}")" + if cmux_claude_wrapper_is_node_options_restore_path "$require_path"; then + index=$((index + 1)) + continue + fi + fi if [[ "$token" == "--max-old-space-size" && $((index + 1)) -lt ${#tokens[@]} ]]; then normalized+=("--max-old-space-size=${tokens[$((index + 1))]}") index=$((index + 2)) @@ -1114,11 +1183,6 @@ normalize_node_options_for_restore() { install_cmux_node_options() { local guard_path if guard_path="$(ensure_node_options_restore_module)"; then - local escaped_guard_path="${guard_path//\/\\}" - escaped_guard_path="${escaped_guard_path//\"/\\\"}" - if [[ ${NODE_OPTIONS+x} && "${NODE_OPTIONS:-}" == *"--require=\"$escaped_guard_path\""* ]]; then - return 0 - fi if [[ ${NODE_OPTIONS+x} ]]; then export CMUX_ORIGINAL_NODE_OPTIONS_PRESENT=1 export CMUX_ORIGINAL_NODE_OPTIONS="$(normalize_node_options_for_restore "$NODE_OPTIONS")" diff --git a/tests/test_claude_wrapper_node_options_survives_tmpdir_purge.py b/tests/test_claude_wrapper_node_options_survives_tmpdir_purge.py index 0b33ab3d84c3..8827d7825638 100755 --- a/tests/test_claude_wrapper_node_options_survives_tmpdir_purge.py +++ b/tests/test_claude_wrapper_node_options_survives_tmpdir_purge.py @@ -51,6 +51,23 @@ def main() -> int: real_dir.mkdir() home_dir.mkdir() session_tmpdir.mkdir() + legacy_root = session_tmpdir / "cmux-claude-node-options" + legacy_root.mkdir() + legacy_path = legacy_root / "restore-node-options.cjs" + legacy_path.write_text( + """const hadOriginalNodeOptions = process.env.CMUX_ORIGINAL_NODE_OPTIONS_PRESENT === "1"; +if (hadOriginalNodeOptions) { + process.env.NODE_OPTIONS = process.env.CMUX_ORIGINAL_NODE_OPTIONS ?? ""; +} else { + delete process.env.NODE_OPTIONS; +} +delete process.env.CMUX_ORIGINAL_NODE_OPTIONS; +delete process.env.CMUX_ORIGINAL_NODE_OPTIONS_PRESENT; +""", + encoding="utf-8", + ) + user_preload = root / "user-preload.cjs" + user_preload.write_text("// preserved user preload\n", encoding="utf-8") wrapper = wrapper_dir / "cmux-claude-wrapper" shutil.copy2(SOURCE_WRAPPER, wrapper) @@ -95,6 +112,7 @@ def main() -> int: "CMUX_SOCKET_PATH": str(socket_path), "CMUX_BUNDLED_CLI_PATH": str(wrapper_dir / "cmux"), "CMUX_CUSTOM_CLAUDE_PATH": str(real_dir / "claude"), + "NODE_OPTIONS": f'--require="{legacy_path}" --require="{user_preload}"', "FAKE_READY_PATH": str(ready_path), "FAKE_CONTINUE_PATH": str(continue_path), } @@ -119,7 +137,19 @@ def main() -> int: return 1 restore_path = Path(match.group(1) or match.group(2)) - legacy_root = session_tmpdir / "cmux-claude-node-options" + if node_options.count("restore-node-options.cjs") != 1: + continue_path.touch() + process.kill() + stdout, stderr = process.communicate() + print(f"FAIL: expected one restore preload, got {node_options!r}") + return 1 + if str(user_preload) not in node_options: + continue_path.touch() + process.kill() + stdout, stderr = process.communicate() + print(f"FAIL: user NODE_OPTIONS preload was dropped: {node_options!r}") + return 1 + shutil.rmtree(legacy_root, ignore_errors=True) continue_path.touch() stdout, stderr = process.communicate(timeout=10) From 1afa1ee1ff922144c6c0f5c55df58e430daf8c6b Mon Sep 17 00:00:00 2001 From: Austin Yang Date: Tue, 8 Sep 2026 08:47:22 -0700 Subject: [PATCH 16/34] fix: remove duplicate cli definitions after main extraction --- CLI/cmux.swift | 196 ------------------------------------------------- 1 file changed, 196 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 2b516cc7cae6..eaaed6076c09 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -14,52 +14,6 @@ import LocalAuthentication import Security #endif -struct CLIError: Error, CustomStringConvertible { - enum SocketFailureKind: Equatable { - case pathMissing - case pathInspectionFailed - case pathTypeConflict - case pathOwnershipConflict - case startupTimeout - } - - let message: String - let exitCode: Int32 - /// Structured v2 protocol error code when the failure came from a v2 error response. - let v2Code: String? - /// Whether this error was decoded directly from a socket v2 error response. - /// Local CLI sentinels may reuse ``v2Code`` for their own exit-status contract. - let isStructuredProtocolResponse: Bool - /// Cloud VM backend error code (e.g. "vm_create_failed") passed through the - /// v2 error's data payload, so callers can make idempotency decisions - /// structurally instead of parsing display text. - let vmBackendCode: String? - /// HTTP status from the Cloud VM backend, when the app forwarded an HTTP - /// failure through the local v2 socket. - let vmBackendHTTPStatus: Int? - let socketFailureKind: SocketFailureKind? - - init( - message: String, - exitCode: Int32 = 1, - v2Code: String? = nil, - isStructuredProtocolResponse: Bool = false, - vmBackendCode: String? = nil, - vmBackendHTTPStatus: Int? = nil, - socketFailureKind: SocketFailureKind? = nil - ) { - self.message = message - self.exitCode = exitCode - self.v2Code = v2Code - self.isStructuredProtocolResponse = isStructuredProtocolResponse - self.vmBackendCode = vmBackendCode - self.vmBackendHTTPStatus = vmBackendHTTPStatus - self.socketFailureKind = socketFailureKind - } - - var description: String { message } -} - struct WindowInfo { let index: Int let id: String @@ -5044,63 +4998,6 @@ struct CMUXCLI { return explicitValue == defaultValue ? catalogValue : explicitValue } - /// Run the separately installed CodeRouter CLI without routing through the - /// cmux socket. Replace this process after resolving the executable so - /// stdin/stdout/stderr, signals, and the child exit status retain their - /// normal terminal semantics. The argv is built directly; arguments such - /// as prompts, paths, and shell metacharacters are never interpreted by a - /// shell. - private func runCoderouterAlias(commandArgs: [String]) throws { - let candidates = ["coderouter", "cr"] - guard let executablePath = candidates.lazy - .compactMap({ resolveExecutableInPath($0) }) - .first else { - throw CLIError( - message: localizedCoderouterNotFound(), - exitCode: 127 - ) - } - - // CodeRouter is an independent executable. Do not hand it cmux's ambient - // terminal/control-plane context: CMUX_* and CMUXD_* may carry socket - // paths, capabilities, passwords, auth state, or internal paths. There is - // intentionally no auth handoff here; a future handoff must be explicit - // and narrowly allowlisted. - let childEnvironment = ProcessInfo.processInfo.environment.filter { key, _ in - !key.hasPrefix("CMUX_") && !key.hasPrefix("CMUXD_") - } - var argv = ([executablePath] + commandArgs).map { strdup($0) } - let environmentStrings = childEnvironment.keys.sorted().map { key in - "\(key)=\(childEnvironment[key] ?? "")" - } - var environment = environmentStrings.map { strdup($0) } - defer { - for item in argv { - free(item) - } - for item in environment { - free(item) - } - } - argv.append(nil) - environment.append(nil) - - let executionError = cliExecFailureErrno { - executablePath.withCString { executable in - _ = execve(executable, &argv, &environment) - } - } - let errorText = String(cString: strerror(executionError)) - cliDebugLog( - "cli.coderouter.exec_failed executable=\(executablePath) " - + "errno=\(executionError) error=\(errorText)" - ) - throw CLIError( - message: localizedCoderouterLaunchFailed(), - exitCode: 127 - ) - } - func run() async throws { let processEnv = ProcessInfo.processInfo.environment let cliBundleIdentifier = CLISocketPathResolver.currentAppBundleIdentifier() @@ -31120,36 +31017,6 @@ struct CMUXCLI { return nil } - private func summarizeClaudeHookNotification(parsedInput: ClaudeHookParsedInput) -> (subtitle: String, body: String) { - guard let object = parsedInput.object else { - if let fallback = parsedInput.rawFallback, !fallback.isEmpty { - return classifyClaudeNotification(signal: fallback, message: fallback) - } - // No payload at all: nothing to say. An empty body tells the - // caller to reuse the stored session summary or skip the banner — - // never to fabricate a needs-attention message. - return ("Waiting", "") - } - - let nested = (object["notification"] as? [String: Any]) ?? (object["data"] as? [String: Any]) ?? [:] - let signalParts = [ - firstString(in: object, keys: ["event", "event_name", "hook_event_name", "type", "kind"]), - firstString(in: object, keys: ["notification_type", "matcher", "reason"]), - firstString(in: nested, keys: ["type", "kind", "reason"]) - ] - let messageCandidates = [ - firstString(in: object, keys: ["message", "body", "text", "prompt", "error", "description"]), - firstString(in: nested, keys: ["message", "body", "text", "prompt", "error", "description"]) - ] - let message = messageCandidates.compactMap { $0 }.first ?? "" - let normalizedMessage = normalizedSingleLine(message) - let signal = signalParts.compactMap { $0 }.joined(separator: " ") - var classified = classifyClaudeNotification(signal: signal, message: normalizedMessage) - - classified.body = truncate(classified.body, maxLength: 180) - return classified - } - private func summarizeAgentHookNotification( def: AgentHookDef, parsedInput: ClaudeHookParsedInput, @@ -31485,69 +31352,6 @@ struct CMUXCLI { ) } - private func classifyClaudeNotification(signal: String, message: String) -> (subtitle: String, body: String) { - let lower = "\(signal) \(message)".lowercased() - if lower.contains("permission") || lower.contains("approve") || lower.contains("approval") || lower.contains("permission_prompt") { - let body = message.isEmpty ? "Approval needed" : message - return ("Permission", body) - } - if lower.contains("error") || lower.contains("failed") || lower.contains("exception") { - let body = message.isEmpty ? "Claude reported an error" : message - return ("Error", body) - } - if AgentHookNotificationClassifier.containsCompletionCue(lower) { - let body = message.isEmpty ? "Task completed" : message - return ("Completed", body) - } - if AgentHookNotificationClassifier.containsWaitingCue(lower) { - let body = message.isEmpty ? "Waiting for input" : message - return ("Waiting", body) - } - // Use the message directly when there is one. A payload with no - // usable message yields an empty body: callers reuse the stored - // session summary or skip the banner. The old "Claude needs your - // attention" fabrication (and the needs-input state it implied) is - // deliberately gone — an unparseable message is not a signal. - if !message.isEmpty { - return ("Attention", message) - } - return ("Attention", "") - } - - private func sanitizeNotificationField(_ value: String) -> String { - return normalizedSingleLine(value) - .replacingOccurrences(of: "|", with: "¦") - } - - func notificationPayload( - title: String, - subtitle: String, - body: String, - meta: String? = nil - ) -> String { - let base = "\(sanitizeNotificationField(title))|\(sanitizeNotificationField(subtitle))|\(sanitizeNotificationField(body))" - // `meta` is a structured, delimiter-safe tag: it has no - // "|" or spaces, so it is NOT sanitized and rides as a 4th pipe segment. - // Omitting it reproduces the exact 3-field payload every legacy caller sends. - guard let meta, !meta.isEmpty else { return base } - return base + "|" + meta - } - - /// True when a Claude `Stop`/`Notification` payload reports unfinished - /// background work: any `background_tasks` entry still `running`, or a - /// non-empty `session_crons`. A `nil` rawObject or absent keys (claude - /// < 2.1.145) yield `false`, so older clients behave exactly as before. - /// Pure over `rawObject` so both the notify gate and the hibernation - /// lifecycle decision can share it (mirrors `hasActiveAntigravityBackgroundWork`). - func hasActiveClaudeBackgroundWork(_ parsedInput: ClaudeHookParsedInput) -> Bool { - guard let obj = parsedInput.rawObject else { return false } - if let crons = obj["session_crons"] as? [Any], !crons.isEmpty { return true } - if let tasks = obj["background_tasks"] as? [[String: Any]] { - return tasks.contains { ($0["status"] as? String) == "running" } - } - return false - } - private func mergedNodeOptions(existing: String?, restoreModulePath: String) -> String { let requireOption = "--require=\(restoreModulePath)" let memoryOption = "--max-old-space-size=4096" From 6c507907708bfc4127c9c7edcb028647ba30b946 Mon Sep 17 00:00:00 2001 From: Austin Yang Date: Tue, 8 Sep 2026 09:42:34 -0700 Subject: [PATCH 17/34] fix: restore semantic hook delivery after merge --- CLI/cmux.swift | 92 +++++++++++++++---- Resources/bin/cmux-claude-wrapper | 14 +-- ...pper_node_options_survives_tmpdir_purge.py | 21 ++++- 3 files changed, 98 insertions(+), 29 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 0855704bd9a4..0d042495214f 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -27835,6 +27835,8 @@ struct CMUXCLI { surfaceId: nil, unattributedReason: "target-unresolved", nativeEvent: reportedHookEventName(from: parsedInput) ?? "SessionStart", + attention: Self.semanticAttentionContext(parsedInput.rawObject), + occurredAtMs: Self.semanticOccurredAtMs(parsedInput.rawObject), store: sessionStore, telemetry: telemetry ) @@ -27899,6 +27901,8 @@ struct CMUXCLI { surfaceId: surfaceId, isSubagent: suppressVisibleMutations, nativeEvent: reportedHookEventName(from: parsedInput) ?? "SessionStart", + attention: Self.semanticAttentionContext(parsedInput.rawObject), + occurredAtMs: Self.semanticOccurredAtMs(parsedInput.rawObject), detail: isClearSessionStart ? "clear-session-start" : nil, store: sessionStore, telemetry: telemetry @@ -27949,6 +27953,8 @@ struct CMUXCLI { surfaceId: nil, unattributedReason: "target-unresolved", nativeEvent: reportedHookEventName(from: parsedInput) ?? "Stop", + attention: Self.semanticAttentionContext(parsedInput.rawObject), + occurredAtMs: Self.semanticOccurredAtMs(parsedInput.rawObject), store: sessionStore, telemetry: telemetry ) @@ -28006,6 +28012,8 @@ struct CMUXCLI { workspaceId: workspaceId, surfaceId: surfaceId, nativeEvent: reportedHookEventName(from: parsedInput) ?? "Stop", + attention: Self.semanticAttentionContext(parsedInput.rawObject), + occurredAtMs: Self.semanticOccurredAtMs(parsedInput.rawObject), detail: "superseded-stale", store: sessionStore, telemetry: telemetry @@ -28027,6 +28035,8 @@ struct CMUXCLI { isSubagent: true, pendingWork: hasActiveClaudeBackgroundWork(parsedInput), nativeEvent: reportedHookEventName(from: parsedInput) ?? "Stop", + attention: Self.semanticAttentionContext(parsedInput.rawObject), + occurredAtMs: Self.semanticOccurredAtMs(parsedInput.rawObject), store: sessionStore, telemetry: telemetry ) @@ -28039,6 +28049,8 @@ struct CMUXCLI { // the ~60s-later idle_prompt Notification can consult it, and forwarded // to the app so it can suppress the done-ping until work truly drains. let hasPendingBackgroundWork = hasActiveClaudeBackgroundWork(parsedInput) + let hasUnsettledWork = hasPendingBackgroundWork + || parsedInput.rawObject?["stop_hook_active"] as? Bool == true // Update session with transcript summary and send completion notification. let completion = summarizeClaudeHookStop( @@ -28056,7 +28068,7 @@ struct CMUXCLI { // Pending background work keeps the pane out of the // hibernatable .idle state so the planner cannot SIGTERM // a live task (mirrors the antigravity fullyIdle flip). - agentLifecycle: hasPendingBackgroundWork ? .running : .idle, + agentLifecycle: hasUnsettledWork ? .running : .idle, hookEventName: reportedHookEventName(from: parsedInput) ?? "Stop", lastSubtitle: completion?.subtitle, lastBody: completion?.body, @@ -28087,12 +28099,14 @@ struct CMUXCLI { workspaceId: workspaceId, surfaceId: surfaceId, isSubagent: isNestedAgentSession, - pendingWork: hasPendingBackgroundWork, + pendingWork: hasUnsettledWork, nativeEvent: reportedHookEventName(from: parsedInput) ?? "Stop", + attention: Self.semanticAttentionContext(parsedInput.rawObject), + occurredAtMs: Self.semanticOccurredAtMs(parsedInput.rawObject), store: sessionStore, telemetry: telemetry ) - if hasPendingBackgroundWork { + if hasUnsettledWork { // The turn ended but a background task or scheduled wakeup is // still live, so the pane is not idle — show it as still // running rather than the misleading "Idle". Reuse the shared @@ -28125,12 +28139,15 @@ struct CMUXCLI { subtitle: completion.subtitle, body: completion.body, meta: AgentHookNotifyCategory.turnComplete.metaSegment( - pending: hasPendingBackgroundWork, + pending: hasUnsettledWork, agentID: "claude", isSubagent: isNestedAgentSession ) ) - _ = try? sendV1Command("notify_target_async \(workspaceId) \(surfaceId) \(payload)", client: client) + _ = try? sendV1Command(try semanticNotificationCommand(source: "claude", agentKey: Self.claudeCodeStatusKey, + sessionId: parsedInput.sessionId, workspaceId: workspaceId, surfaceId: surfaceId, + kind: .turnCompleted, rawObject: parsedInput.rawObject, payload: payload, + pendingWork: hasUnsettledWork), client: client) } printClaudeHookAck() } catch { @@ -28162,6 +28179,8 @@ struct CMUXCLI { surfaceId: nil, unattributedReason: "target-unresolved", nativeEvent: reportedHookEventName(from: parsedInput) ?? "UserPromptSubmit", + attention: Self.semanticAttentionContext(parsedInput.rawObject), + occurredAtMs: Self.semanticOccurredAtMs(parsedInput.rawObject), store: sessionStore, telemetry: telemetry ) @@ -28203,6 +28222,8 @@ struct CMUXCLI { workspaceId: workspaceId, surfaceId: surfaceId, nativeEvent: reportedHookEventName(from: parsedInput) ?? "UserPromptSubmit", + attention: Self.semanticAttentionContext(parsedInput.rawObject), + occurredAtMs: Self.semanticOccurredAtMs(parsedInput.rawObject), detail: "superseded-stale", store: sessionStore, telemetry: telemetry @@ -28222,6 +28243,8 @@ struct CMUXCLI { surfaceId: surfaceId, isSubagent: true, nativeEvent: reportedHookEventName(from: parsedInput) ?? "UserPromptSubmit", + attention: Self.semanticAttentionContext(parsedInput.rawObject), + occurredAtMs: Self.semanticOccurredAtMs(parsedInput.rawObject), store: sessionStore, telemetry: telemetry ) @@ -28278,6 +28301,8 @@ struct CMUXCLI { workspaceId: workspaceId, surfaceId: surfaceId, nativeEvent: reportedHookEventName(from: parsedInput) ?? "UserPromptSubmit", + attention: Self.semanticAttentionContext(parsedInput.rawObject), + occurredAtMs: Self.semanticOccurredAtMs(parsedInput.rawObject), store: sessionStore, telemetry: telemetry ) @@ -28359,6 +28384,8 @@ struct CMUXCLI { surfaceId: nil, unattributedReason: "target-unresolved", nativeEvent: reportedHookEventName(from: parsedInput) ?? "Notification", + attention: Self.semanticAttentionContext(parsedInput.rawObject), + occurredAtMs: Self.semanticOccurredAtMs(parsedInput.rawObject), store: sessionStore, telemetry: telemetry ) @@ -28398,6 +28425,8 @@ struct CMUXCLI { workspaceId: workspaceId, surfaceId: surfaceId, nativeEvent: reportedHookEventName(from: parsedInput) ?? "Notification", + attention: Self.semanticAttentionContext(parsedInput.rawObject), + occurredAtMs: Self.semanticOccurredAtMs(parsedInput.rawObject), detail: "superseded-stale", store: sessionStore, telemetry: telemetry @@ -28417,6 +28446,8 @@ struct CMUXCLI { surfaceId: surfaceId, isSubagent: true, nativeEvent: reportedHookEventName(from: parsedInput) ?? "Notification", + attention: Self.semanticAttentionContext(parsedInput.rawObject), + occurredAtMs: Self.semanticOccurredAtMs(parsedInput.rawObject), store: sessionStore, telemetry: telemetry ) @@ -28517,7 +28548,7 @@ struct CMUXCLI { case "permission_prompt": journalKind = .approvalRequested case "idle_prompt": - journalKind = suppressNeedsInputState ? .stateChanged : .questionRequested + journalKind = .idleObserved default: switch classifiedSubtitle { case "Permission": @@ -28529,7 +28560,7 @@ struct CMUXCLI { case "Error": journalKind = .errorReported default: - journalKind = summary.body.isEmpty ? .stateChanged : .questionRequested + journalKind = .stateChanged } } emitAgentJournalEvent( @@ -28543,6 +28574,8 @@ struct CMUXCLI { isSubagent: isNestedAgentSession, pendingWork: notifyPending, nativeEvent: reportedHookEventName(from: parsedInput) ?? "Notification", + attention: Self.semanticAttentionContext(parsedInput.rawObject), + occurredAtMs: Self.semanticOccurredAtMs(parsedInput.rawObject), store: sessionStore, telemetry: telemetry ) @@ -28567,7 +28600,7 @@ struct CMUXCLI { client: client, workspaceId: workspaceId, surfaceId: surfaceId, - value: "Needs input", + value: String(localized: "feed.status.needsInput", defaultValue: "Needs input"), icon: "bell.fill", color: "#4C8DFF", pid: claudePid ) @@ -28575,7 +28608,10 @@ struct CMUXCLI { // A notification with nothing to show is state signal only: the // journal recorded it; no banner is fabricated for it. if !summary.body.isEmpty { - _ = try sendV1Command("notify_target_async \(workspaceId) \(surfaceId) \(payload)", client: client) + _ = try sendV1Command(try semanticNotificationCommand(source: "claude", agentKey: Self.claudeCodeStatusKey, + sessionId: parsedInput.sessionId, workspaceId: workspaceId, surfaceId: surfaceId, + kind: journalKind, rawObject: parsedInput.rawObject, payload: payload, + pendingWork: notifyPending), client: client) } printClaudeHookAck() case "push-notification": try runClaudePushNotificationHook(client: client, telemetry: telemetry, parsedInput: parsedInput, sessionStore: sessionStore, routing: hookRouting, markFeedTelemetryHandled: { didSendFeedTelemetry = true }, sendFeedTelemetry: sendClaudeFeedTelemetry) @@ -28612,6 +28648,8 @@ struct CMUXCLI { surfaceId: nil, unattributedReason: "target-unresolved", nativeEvent: reportedHookEventName(from: parsedInput) ?? "SessionEnd", + attention: Self.semanticAttentionContext(parsedInput.rawObject), + occurredAtMs: Self.semanticOccurredAtMs(parsedInput.rawObject), store: sessionStore, telemetry: telemetry ) @@ -28650,6 +28688,8 @@ struct CMUXCLI { workspaceId: workspaceId, surfaceId: cleanupSurfaceId, nativeEvent: reportedHookEventName(from: parsedInput) ?? "SessionEnd", + attention: Self.semanticAttentionContext(parsedInput.rawObject), + occurredAtMs: Self.semanticOccurredAtMs(parsedInput.rawObject), store: sessionStore, telemetry: telemetry ) @@ -28833,6 +28873,8 @@ struct CMUXCLI { surfaceId: existingSurfaceId, isSubagent: isNestedAgentSession, nativeEvent: reportedHookEventName(from: parsedInput) ?? "PreToolUse", + attention: Self.semanticAttentionContext(parsedInput.rawObject), + occurredAtMs: Self.semanticOccurredAtMs(parsedInput.rawObject), store: sessionStore, telemetry: telemetry ) @@ -28878,10 +28920,9 @@ struct CMUXCLI { isSubagent: isNestedAgentSession ) ) - _ = try? sendV1Command( - "notify_target_async \(workspaceId) \(existingSurfaceId) \(payload)", - client: client - ) + _ = try? sendV1Command(try semanticNotificationCommand(source: "claude", agentKey: Self.claudeCodeStatusKey, + sessionId: parsedInput.sessionId, workspaceId: workspaceId, surfaceId: existingSurfaceId, + kind: .approvalRequested, rawObject: parsedInput.rawObject, payload: payload), client: client) } printClaudeHookAck() return @@ -28910,6 +28951,8 @@ struct CMUXCLI { surfaceId: surfaceId, isSubagent: isNestedAgentSession, nativeEvent: reportedHookEventName(from: parsedInput) ?? "PreToolUse", + attention: Self.semanticAttentionContext(parsedInput.rawObject), + occurredAtMs: Self.semanticOccurredAtMs(parsedInput.rawObject), store: sessionStore, telemetry: telemetry ) @@ -34352,6 +34395,8 @@ export default CMUXSessionRestore; nativeEvent: reportedHookEventName(from: input) ?? subcommand, declaredPhase: declaredPhase, detail: detail, + attention: Self.semanticAttentionContext(input.rawObject), + occurredAtMs: Self.semanticOccurredAtMs(input.rawObject), responseTimeout: responseTimeout, deadline: deadline ?? cursorShellDeadline, store: store, @@ -34956,9 +35001,15 @@ export default CMUXSessionRestore; body: pendingBody, meta: pendingMeta ) - sendCursorCriticalCommand( - "notify_target_async \(workspaceId) \(surfaceId) \(pendingPayload)" - ) + let remaining = mapped?.pendingCursorShellApprovals?.first { + $0.notificationCorrelationKey == resolution.remainingNotificationCorrelationKey + } + let evidence = remaining?.toolUseId.map { ["tool_use_id": $0] } + if let command = try? semanticNotificationCommand(source: def.name, agentKey: def.statusKey, + sessionId: sessionId, workspaceId: workspaceId, surfaceId: surfaceId, + kind: .approvalRequested, rawObject: evidence, payload: pendingPayload) { + sendCursorCriticalCommand(command) + } return } @@ -36239,7 +36290,10 @@ export default CMUXSessionRestore; body: body, meta: stopMeta ) - let notifyCommand = "notify_target_async \(workspaceId) \(surfaceId) \(payload)" + let notifyCommand = try semanticNotificationCommand(source: def.name, agentKey: def.statusKey, + sessionId: sessionId, workspaceId: workspaceId, surfaceId: surfaceId, + kind: stopNotificationStatus == .error ? .errorReported : .turnCompleted, + rawObject: input.rawObject, payload: payload, pendingWork: hasActiveBackgroundWork) #if DEBUG agentHookDebugLog( "agentHook.stop.notify agent=\(def.name) session=\(agentHookDebugShort(sessionId)) resumed=\(env["CMUX_AGENT_RESUME_LAUNCH"] == "1" ? 1 : 0) fallback=\(shouldPublishGrokStopFallbackNotification ? 1 : 0) workspace=\(agentHookDebugShort(workspaceId)) surface=\(agentHookDebugShort(surfaceId)) subtitleLen=\(subtitle.count) bodyLen=\(body.count)", @@ -36836,7 +36890,9 @@ export default CMUXSessionRestore; body: summary.body, meta: notificationMeta ) - let notifyCommand = "notify_target_async \(workspaceId) \(surfaceId) \(payload)" + let notifyCommand = try semanticNotificationCommand(source: def.name, agentKey: def.statusKey, + sessionId: sessionId, workspaceId: workspaceId, surfaceId: surfaceId, + kind: notificationJournalKind, rawObject: input.rawObject, payload: payload) #if DEBUG agentHookDebugLog( "agentHook.notification.notify agent=\(def.name) session=\(agentHookDebugShort(sessionId)) workspace=\(agentHookDebugShort(workspaceId)) surface=\(agentHookDebugShort(surfaceId))", diff --git a/Resources/bin/cmux-claude-wrapper b/Resources/bin/cmux-claude-wrapper index 332bd2a3f497..e3db007c201c 100755 --- a/Resources/bin/cmux-claude-wrapper +++ b/Resources/bin/cmux-claude-wrapper @@ -1183,12 +1183,14 @@ normalize_node_options_for_restore() { install_cmux_node_options() { local guard_path if guard_path="$(ensure_node_options_restore_module)"; then - if [[ ${NODE_OPTIONS+x} ]]; then - export CMUX_ORIGINAL_NODE_OPTIONS_PRESENT=1 - export CMUX_ORIGINAL_NODE_OPTIONS="$(normalize_node_options_for_restore "$NODE_OPTIONS")" - else - export CMUX_ORIGINAL_NODE_OPTIONS_PRESENT=0 - unset CMUX_ORIGINAL_NODE_OPTIONS + if [[ ! ${CMUX_ORIGINAL_NODE_OPTIONS_PRESENT+x} ]]; then + if [[ ${NODE_OPTIONS+x} ]]; then + export CMUX_ORIGINAL_NODE_OPTIONS_PRESENT=1 + export CMUX_ORIGINAL_NODE_OPTIONS="$(normalize_node_options_for_restore "$NODE_OPTIONS")" + else + export CMUX_ORIGINAL_NODE_OPTIONS_PRESENT=0 + unset CMUX_ORIGINAL_NODE_OPTIONS + fi fi export NODE_OPTIONS="$(merge_node_options "$guard_path")" fi diff --git a/tests/test_claude_wrapper_node_options_survives_tmpdir_purge.py b/tests/test_claude_wrapper_node_options_survives_tmpdir_purge.py index 8827d7825638..64058899ae04 100755 --- a/tests/test_claude_wrapper_node_options_survives_tmpdir_purge.py +++ b/tests/test_claude_wrapper_node_options_survives_tmpdir_purge.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Regression coverage for Claude children after macOS purges ``$TMPDIR``.""" +"""Regression coverage for durable Claude restore preload behavior.""" from __future__ import annotations @@ -24,7 +24,7 @@ def make_executable(path: Path, content: str) -> None: path.chmod(0o755) -def wait_for_text(path: Path, timeout: float = 10.0) -> str: +def wait_for_text(path: Path, timeout: float = 30.0) -> str: deadline = time.monotonic() + timeout while time.monotonic() < deadline: if path.exists(): @@ -68,6 +68,8 @@ def main() -> int: ) user_preload = root / "user-preload.cjs" user_preload.write_text("// preserved user preload\n", encoding="utf-8") + captured_preload = root / "captured-preload.cjs" + captured_preload.write_text("// captured original preload\n", encoding="utf-8") wrapper = wrapper_dir / "cmux-claude-wrapper" shutil.copy2(SOURCE_WRAPPER, wrapper) @@ -83,7 +85,7 @@ def main() -> int: while [[ ! -e "$FAKE_CONTINUE_PATH" ]]; do sleep 0.02 done -exec node -e 'process.stdout.write("node-child-survived")' +exec node -e 'process.stdout.write(process.env.NODE_OPTIONS || "__UNSET__")' """, ) make_executable( @@ -113,6 +115,8 @@ def main() -> int: "CMUX_BUNDLED_CLI_PATH": str(wrapper_dir / "cmux"), "CMUX_CUSTOM_CLAUDE_PATH": str(real_dir / "claude"), "NODE_OPTIONS": f'--require="{legacy_path}" --require="{user_preload}"', + "CMUX_ORIGINAL_NODE_OPTIONS_PRESENT": "1", + "CMUX_ORIGINAL_NODE_OPTIONS": f'--require="{captured_preload}"', "FAKE_READY_PATH": str(ready_path), "FAKE_CONTINUE_PATH": str(continue_path), } @@ -149,6 +153,12 @@ def main() -> int: stdout, stderr = process.communicate() print(f"FAIL: user NODE_OPTIONS preload was dropped: {node_options!r}") return 1 + if "--max-old-space-size=4096" not in node_options: + continue_path.touch() + process.kill() + stdout, stderr = process.communicate() + print(f"FAIL: cmux heap setting was dropped on wrapper re-entry: {node_options!r}") + return 1 shutil.rmtree(legacy_root, ignore_errors=True) continue_path.touch() @@ -169,8 +179,9 @@ def main() -> int: print(f"stdout={stdout!r}") print(f"stderr={stderr!r}") return 1 - if stdout != "node-child-survived": - print(f"FAIL: unexpected child output after TMPDIR purge: {stdout!r}") + expected_restored_node_options = f'--require="{captured_preload}"' + if stdout != expected_restored_node_options: + print(f"FAIL: original NODE_OPTIONS was overwritten on wrapper re-entry: {stdout!r}") return 1 if str(restore_path).startswith(str(session_tmpdir)): print(f"FAIL: restore shim still lives under TMPDIR: {restore_path}") From 2a1dc98840c5a9d847e1f7fad56e9d72f76c5e39 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:58:05 -0700 Subject: [PATCH 18/34] fix: expose shared CLI helpers across files --- CLI/cmux.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 0d042495214f..f883559daa42 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -4505,7 +4505,7 @@ struct CMUXCLI { return parts.joined(separator: " · ") } - private static let claudeCodeStatusKey = "claude_code" + static let claudeCodeStatusKey = "claude_code" private static func agentNotificationMeta( category: AgentHookNotifyCategory, @@ -16283,7 +16283,7 @@ struct CMUXCLI { SSHAgentSocketResolver().optionValue(named: key, in: options) } - private func cliDebugLog(_ message: @autoclosure () -> String) { + func cliDebugLog(_ message: @autoclosure () -> String) { #if DEBUG let trimmedExplicit = ProcessInfo.processInfo.environment["CMUX_DEBUG_LOG"]? .trimmingCharacters(in: .whitespacesAndNewlines) @@ -25700,7 +25700,7 @@ struct CMUXCLI { private static let legacyOmoPluginName = "oh-my-opencode" private static let openCodeSessionPluginConfigSpec = "./plugins/cmux-session.js" - private func resolveExecutableInPath(_ name: String, searchPath: String? = nil) -> String? { + func resolveExecutableInPath(_ name: String, searchPath: String? = nil) -> String? { let entries = (searchPath ?? ProcessInfo.processInfo.environment["PATH"])? .split(separator: ":") .map(String.init) ?? [] From dde447a08ed734b247d73e505d6950e165463a8f Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:11:01 -0700 Subject: [PATCH 19/34] fix: wire Claude node options into CLI target --- CLI/cmux.swift | 8 ++++---- cmux.xcodeproj/project.pbxproj | 4 ++++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index f883559daa42..1f439ee9b36c 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -27901,9 +27901,9 @@ struct CMUXCLI { surfaceId: surfaceId, isSubagent: suppressVisibleMutations, nativeEvent: reportedHookEventName(from: parsedInput) ?? "SessionStart", + detail: isClearSessionStart ? "clear-session-start" : nil, attention: Self.semanticAttentionContext(parsedInput.rawObject), occurredAtMs: Self.semanticOccurredAtMs(parsedInput.rawObject), - detail: isClearSessionStart ? "clear-session-start" : nil, store: sessionStore, telemetry: telemetry ) @@ -28012,9 +28012,9 @@ struct CMUXCLI { workspaceId: workspaceId, surfaceId: surfaceId, nativeEvent: reportedHookEventName(from: parsedInput) ?? "Stop", + detail: "superseded-stale", attention: Self.semanticAttentionContext(parsedInput.rawObject), occurredAtMs: Self.semanticOccurredAtMs(parsedInput.rawObject), - detail: "superseded-stale", store: sessionStore, telemetry: telemetry ) @@ -28222,9 +28222,9 @@ struct CMUXCLI { workspaceId: workspaceId, surfaceId: surfaceId, nativeEvent: reportedHookEventName(from: parsedInput) ?? "UserPromptSubmit", + detail: "superseded-stale", attention: Self.semanticAttentionContext(parsedInput.rawObject), occurredAtMs: Self.semanticOccurredAtMs(parsedInput.rawObject), - detail: "superseded-stale", store: sessionStore, telemetry: telemetry ) @@ -28425,9 +28425,9 @@ struct CMUXCLI { workspaceId: workspaceId, surfaceId: surfaceId, nativeEvent: reportedHookEventName(from: parsedInput) ?? "Notification", + detail: "superseded-stale", attention: Self.semanticAttentionContext(parsedInput.rawObject), occurredAtMs: Self.semanticOccurredAtMs(parsedInput.rawObject), - detail: "superseded-stale", store: sessionStore, telemetry: telemetry ) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 83893ca4e97e..c0fe279203d2 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -840,6 +840,7 @@ C79630010000000000000002 /* CMUXCLI+ClaudeHookAck.swift in Sources */ = {isa = PBXBuildFile; fileRef = C79630010000000000000001 /* CMUXCLI+ClaudeHookAck.swift */; }; 79390007AA11BB22CC33DD07 /* CMUXCLI+ClaudeHookDeliveryTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79390008AA11BB22CC33DD08 /* CMUXCLI+ClaudeHookDeliveryTarget.swift */; }; C72280000000000000000002 /* CMUXCLI+ClaudeHookWorkspaceRouting.swift in Sources */ = {isa = PBXBuildFile; fileRef = C72280000000000000000001 /* CMUXCLI+ClaudeHookWorkspaceRouting.swift */; }; + C12022010000000000000001 /* CMUXCLI+ClaudeNodeOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12022010000000000000002 /* CMUXCLI+ClaudeNodeOptions.swift */; }; 2DA8D48D99FB520EB179B015 /* CMUXCLI+ClaudePushNotificationHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF59318F1D5B2195AC77C28 /* CMUXCLI+ClaudePushNotificationHook.swift */; }; C10D0A100000000000000001 /* CMUXCLI+CloudDomains.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10D0A100000000000000002 /* CMUXCLI+CloudDomains.swift */; }; C0DE60C0000000000000A022 /* CMUXCLI+Coderouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE60C0000000000000A021 /* CMUXCLI+Coderouter.swift */; }; @@ -4272,6 +4273,7 @@ C79630010000000000000001 /* CMUXCLI+ClaudeHookAck.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+ClaudeHookAck.swift"; sourceTree = ""; }; 79390008AA11BB22CC33DD08 /* CMUXCLI+ClaudeHookDeliveryTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+ClaudeHookDeliveryTarget.swift"; sourceTree = ""; }; C72280000000000000000001 /* CMUXCLI+ClaudeHookWorkspaceRouting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+ClaudeHookWorkspaceRouting.swift"; sourceTree = ""; }; + C12022010000000000000002 /* CMUXCLI+ClaudeNodeOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+ClaudeNodeOptions.swift"; sourceTree = ""; }; 4CF59318F1D5B2195AC77C28 /* CMUXCLI+ClaudePushNotificationHook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+ClaudePushNotificationHook.swift"; sourceTree = ""; }; C10D0A100000000000000002 /* CMUXCLI+CloudDomains.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+CloudDomains.swift"; sourceTree = ""; }; C0DE60C0000000000000A021 /* CMUXCLI+Coderouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+Coderouter.swift"; sourceTree = ""; }; @@ -9249,6 +9251,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef DF43B7BD28A755A6280428D6 /* CMUXCLI+RovoDevHooks.swift */, 5257257034CA4729B1211170 /* CMUXCLI+AgentHookPayload.swift */, C79630010000000000000001 /* CMUXCLI+ClaudeHookAck.swift */, + C12022010000000000000002 /* CMUXCLI+ClaudeNodeOptions.swift */, 4CF59318F1D5B2195AC77C28 /* CMUXCLI+ClaudePushNotificationHook.swift */, C6711B050000000000000001 /* CMUXCLI+AgentHookRestoreEvidence.swift */, D96290040000000000000001 /* CMUXCLI+CodexResumeBindingVerification.swift */, @@ -13017,6 +13020,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef C79630010000000000000002 /* CMUXCLI+ClaudeHookAck.swift in Sources */, 79390007AA11BB22CC33DD07 /* CMUXCLI+ClaudeHookDeliveryTarget.swift in Sources */, C72280000000000000000002 /* CMUXCLI+ClaudeHookWorkspaceRouting.swift in Sources */, + C12022010000000000000001 /* CMUXCLI+ClaudeNodeOptions.swift in Sources */, 2DA8D48D99FB520EB179B015 /* CMUXCLI+ClaudePushNotificationHook.swift in Sources */, C10D0A100000000000000001 /* CMUXCLI+CloudDomains.swift in Sources */, C0DE60C0000000000000A022 /* CMUXCLI+Coderouter.swift in Sources */, From 9902adeee7edd704db3a17a0313bb1a677724e5d Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:22:09 -0700 Subject: [PATCH 20/34] fix: remove duplicate Claude node option helpers --- CLI/cmux.swift | 55 -------------------------------------------------- 1 file changed, 55 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 1f439ee9b36c..991232939ef9 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -31397,61 +31397,6 @@ struct CMUXCLI { ) } - private func mergedNodeOptions(existing: String?, restoreModulePath: String) -> String { - let requireOption = "--require=\(restoreModulePath)" - let memoryOption = "--max-old-space-size=4096" - let cleanedExisting = cleanedNodeOptions(existing) - guard !cleanedExisting.isEmpty else { - return "\(requireOption) \(memoryOption)" - } - return "\(requireOption) \(memoryOption) \(cleanedExisting)" - } - - private func cleanedNodeOptions(_ existing: String?) -> String { - let tokens = (existing ?? "") - .split(whereSeparator: \.isWhitespace) - .map(String.init) - guard !tokens.isEmpty else { return "" } - - var filtered: [String] = [] - var index = 0 - while index < tokens.count { - let token = tokens[index] - if token == "--max-old-space-size" { - index += min(2, tokens.count - index) - continue - } - if token.hasPrefix("--max-old-space-size=") { - index += 1 - continue - } - filtered.append(token) - index += 1 - } - return filtered.joined(separator: " ") - } - - private func normalizedNodeOptionsForRestore(_ existing: String) -> String { - let tokens = existing - .split(whereSeparator: \.isWhitespace) - .map(String.init) - guard !tokens.isEmpty else { return "" } - - var normalized: [String] = [] - var index = 0 - while index < tokens.count { - let token = tokens[index] - if token == "--max-old-space-size", index + 1 < tokens.count { - normalized.append("--max-old-space-size=\(tokens[index + 1])") - index += 2 - continue - } - normalized.append(token) - index += 1 - } - return normalized.joined(separator: " ") - } - // MARK: - Codex hooks /// The hooks.json content that cmux installs into ~/.codex/. From d0fe0f7489ef227ea18633179448463239446dc9 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:54:57 -0700 Subject: [PATCH 21/34] fix: wire cloud rename compatibility into app target --- cmux.xcodeproj/project.pbxproj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index c0fe279203d2..915b2209913e 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2663,6 +2663,7 @@ C0DE70530000000000000002 /* submit-cmux-profile in Copy CLI */ = {isa = PBXBuildFile; fileRef = C0DE70530000000000000001 /* submit-cmux-profile */; }; D7AB00000000000000B041 /* SupersededPhoneDismissBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000B040 /* SupersededPhoneDismissBuffer.swift */; }; F11347000000000000000001 /* SurfaceCatalog+CloudPorts.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11347000000000000000002 /* SurfaceCatalog+CloudPorts.swift */; }; + C12022020000000000000001 /* SurfaceCatalog+CloudRenameReconciliation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12022020000000000000002 /* SurfaceCatalog+CloudRenameReconciliation.swift */; }; DD3A13AD76F5196C5FC8C52A /* SurfaceCatalog+Groups.swift in Sources */ = {isa = PBXBuildFile; fileRef = 660286CE22B478E887361407 /* SurfaceCatalog+Groups.swift */; }; F7F3BED81BD5FE96D52055C0 /* SurfaceCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA63D377742D3A6A0BAB499B /* SurfaceCatalog.swift */; }; 31B62CAEA63AA5B0F737F227 /* SurfaceCatalogModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66CA02CE5FE3E9DC871AD810 /* SurfaceCatalogModel.swift */; }; @@ -6006,6 +6007,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef C0DE70530000000000000001 /* submit-cmux-profile */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "Resources/bin/submit-cmux-profile"; sourceTree = SOURCE_ROOT; }; D7AB00000000000000B040 /* SupersededPhoneDismissBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupersededPhoneDismissBuffer.swift; sourceTree = ""; }; F11347000000000000000002 /* SurfaceCatalog+CloudPorts.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "SurfaceCatalog+CloudPorts.swift"; sourceTree = ""; }; + C12022020000000000000002 /* SurfaceCatalog+CloudRenameReconciliation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "SurfaceCatalog+CloudRenameReconciliation.swift"; sourceTree = ""; }; 660286CE22B478E887361407 /* SurfaceCatalog+Groups.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "SurfaceCatalog+Groups.swift"; sourceTree = ""; }; EA63D377742D3A6A0BAB499B /* SurfaceCatalog.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SurfaceCatalog.swift; sourceTree = ""; }; 66CA02CE5FE3E9DC871AD810 /* SurfaceCatalogModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SurfaceCatalogModel.swift; sourceTree = ""; }; @@ -7115,6 +7117,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef C11323220000000000000001 /* CmuxTuiSurfaceProvider+ManualMirror.swift */, 660286CE22B478E887361407 /* SurfaceCatalog+Groups.swift */, F11347000000000000000002 /* SurfaceCatalog+CloudPorts.swift */, + C12022020000000000000002 /* SurfaceCatalog+CloudRenameReconciliation.swift */, 278BEFB3583AED9627966DFF /* SurfaceSocketCommands.swift */, 47BBA1A8703756FDDFD53CF3 /* Workspace+SurfaceCatalog.swift */, C5797FE9CD70513E4FFAC6EB /* Workspace+CloudPaneRouting.swift */, @@ -12421,6 +12424,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef D35B71010000000000000001 /* StartupBreadcrumbLog.swift in Sources */, D7AB00000000000000B041 /* SupersededPhoneDismissBuffer.swift in Sources */, F11347000000000000000001 /* SurfaceCatalog+CloudPorts.swift in Sources */, + C12022020000000000000001 /* SurfaceCatalog+CloudRenameReconciliation.swift in Sources */, DD3A13AD76F5196C5FC8C52A /* SurfaceCatalog+Groups.swift in Sources */, F7F3BED81BD5FE96D52055C0 /* SurfaceCatalog.swift in Sources */, 31B62CAEA63AA5B0F737F227 /* SurfaceCatalogModel.swift in Sources */, From d4e26db40853d7f0cb0cbc4810f1876dd8636bdf Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:09:41 -0700 Subject: [PATCH 22/34] fix: preserve unset NODE_OPTIONS during legacy migration --- .github/swift-warning-budget.tsv | 5 ++ .../FakeTerminalEngine.swift | 1 + Resources/bin/cmux-claude-wrapper | 50 ++++++++++++++++++- 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/.github/swift-warning-budget.tsv b/.github/swift-warning-budget.tsv index a9351335bab3..cf167f996a05 100644 --- a/.github/swift-warning-budget.tsv +++ b/.github/swift-warning-budget.tsv @@ -1,6 +1,11 @@ # cmux-owned Swift warning budget. # Format: countrelative pathwarning message # Reduce counts when warnings are fixed. CI fails if new warnings exceed this budget. +2 Sources/AppDelegate+PaneMemoryGuardrail.swift trailing closure in this context is confusable with the body of the statement; pass as a parenthesized argument to silence this warning +1 Sources/SessionIndexTableController.swift call to main actor-isolated instance method 'reconcilePresentation(in:)' in a synchronous nonisolated context +1 Sources/SessionIndexTableController.swift main actor-isolated property 'isApplyingRows' can not be referenced from a Sendable closure +1 Sources/Surfaces/CmuxTuiSnapshotParser.swift default will never be executed +1 Sources/Surfaces/SurfaceCatalogModel.swift immutable value 'rowID' was never used; consider replacing with '_' or removing it 1 CLI/cmux.swift literal value is already handled by previous pattern; consider removing it 2 CLI/cmux.swift variable 'groups' was never mutated; consider changing to 'let' constant 1 Sources/App/TerminalDirectoryOpenSupport.swift 'fullPath(forApplication:)' was deprecated in macOS 11.0: Use -[NSWorkspace URLForApplicationWithBundleIdentifier:] instead. diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/FakeTerminalEngine.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/FakeTerminalEngine.swift index e66d8e7e42a9..73e41d5634c2 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/FakeTerminalEngine.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/FakeTerminalEngine.swift @@ -1,3 +1,4 @@ +import Foundation import GhosttyKit @testable import CmuxTerminal diff --git a/Resources/bin/cmux-claude-wrapper b/Resources/bin/cmux-claude-wrapper index e3db007c201c..90c1998f9dc5 100755 --- a/Resources/bin/cmux-claude-wrapper +++ b/Resources/bin/cmux-claude-wrapper @@ -1180,13 +1180,59 @@ normalize_node_options_for_restore() { fi } +cmux_claude_wrapper_node_options_is_only_managed_restore_modules() { + local existing="$1" + local -a tokens=() + local token + local require_path + local index=0 + local found_restore_module=0 + + [[ -n "$existing" ]] || return 1 + cmux_claude_wrapper_tokenize_node_options "$existing" || return 1 + tokens=("${CMUX_CLAUDE_WRAPPER_NODE_OPTIONS_TOKENS[@]}") + (( ${#tokens[@]} > 0 )) || return 1 + + while (( index < ${#tokens[@]} )); do + token="${tokens[$index]}" + case "$token" in + --require|-r) + (( index + 1 < ${#tokens[@]} )) || return 1 + require_path="$(cmux_claude_wrapper_unquote_node_options_path "${tokens[$((index + 1))]}")" + cmux_claude_wrapper_is_node_options_restore_path "$require_path" || return 1 + found_restore_module=1 + index=$((index + 2)) + ;; + --require=*|-r=*) + require_path="$(cmux_claude_wrapper_unquote_node_options_path "${token#*=}")" + cmux_claude_wrapper_is_node_options_restore_path "$require_path" || return 1 + found_restore_module=1 + index=$((index + 1)) + ;; + *) + return 1 + ;; + esac + done + + (( found_restore_module == 1 )) +} + install_cmux_node_options() { local guard_path if guard_path="$(ensure_node_options_restore_module)"; then if [[ ! ${CMUX_ORIGINAL_NODE_OPTIONS_PRESENT+x} ]]; then if [[ ${NODE_OPTIONS+x} ]]; then - export CMUX_ORIGINAL_NODE_OPTIONS_PRESENT=1 - export CMUX_ORIGINAL_NODE_OPTIONS="$(normalize_node_options_for_restore "$NODE_OPTIONS")" + local normalized_original + normalized_original="$(normalize_node_options_for_restore "$NODE_OPTIONS")" + if [[ -n "$normalized_original" ]] || + ! cmux_claude_wrapper_node_options_is_only_managed_restore_modules "$NODE_OPTIONS"; then + export CMUX_ORIGINAL_NODE_OPTIONS_PRESENT=1 + export CMUX_ORIGINAL_NODE_OPTIONS="$normalized_original" + else + export CMUX_ORIGINAL_NODE_OPTIONS_PRESENT=0 + unset CMUX_ORIGINAL_NODE_OPTIONS + fi else export CMUX_ORIGINAL_NODE_OPTIONS_PRESENT=0 unset CMUX_ORIGINAL_NODE_OPTIONS From 12737f0616a48118ec361394f14653df6cd85f05 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:24:15 -0700 Subject: [PATCH 23/34] chore: remove obsolete Swift warning budget entries --- .github/swift-warning-budget.tsv | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/swift-warning-budget.tsv b/.github/swift-warning-budget.tsv index cf167f996a05..a9351335bab3 100644 --- a/.github/swift-warning-budget.tsv +++ b/.github/swift-warning-budget.tsv @@ -1,11 +1,6 @@ # cmux-owned Swift warning budget. # Format: countrelative pathwarning message # Reduce counts when warnings are fixed. CI fails if new warnings exceed this budget. -2 Sources/AppDelegate+PaneMemoryGuardrail.swift trailing closure in this context is confusable with the body of the statement; pass as a parenthesized argument to silence this warning -1 Sources/SessionIndexTableController.swift call to main actor-isolated instance method 'reconcilePresentation(in:)' in a synchronous nonisolated context -1 Sources/SessionIndexTableController.swift main actor-isolated property 'isApplyingRows' can not be referenced from a Sendable closure -1 Sources/Surfaces/CmuxTuiSnapshotParser.swift default will never be executed -1 Sources/Surfaces/SurfaceCatalogModel.swift immutable value 'rowID' was never used; consider replacing with '_' or removing it 1 CLI/cmux.swift literal value is already handled by previous pattern; consider removing it 2 CLI/cmux.swift variable 'groups' was never mutated; consider changing to 'let' constant 1 Sources/App/TerminalDirectoryOpenSupport.swift 'fullPath(forApplication:)' was deprecated in macOS 11.0: Use -[NSWorkspace URLForApplicationWithBundleIdentifier:] instead. From eb47397ab24d5098ac80cbf391ab518e32c4c8c6 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:26:21 -0700 Subject: [PATCH 24/34] chore: normalize Xcode project wiring --- cmux.xcodeproj/project.pbxproj | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 819c89456a55..f71163e8d624 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -195,6 +195,7 @@ REE0CA0000000000000000E2 /* AIAccountsClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = REE0CA0000000000000000E1 /* AIAccountsClient.swift */; }; A115C0DE0000000000000002 /* AllShortcutsPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = A115C0DE0000000000000001 /* AllShortcutsPopover.swift */; }; 9758A0000000000000000002 /* AmpVaultRegistrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9758A0000000000000000001 /* AmpVaultRegistrationTests.swift */; }; + A54730000000000000000002 /* AntigravityHookSessionSnapshotRestoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54730000000000000000001 /* AntigravityHookSessionSnapshotRestoreTests.swift */; }; F4350A110000000000000001 /* AppBundleIconPersistencePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4350A130000000000000001 /* AppBundleIconPersistencePolicy.swift */; }; F4350A120000000000000001 /* AppBundleIconPersistencePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4350A130000000000000001 /* AppBundleIconPersistencePolicy.swift */; }; A5C01700000000000000000D /* AppDelegate+AccountSignInWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C01700000000000000000E /* AppDelegate+AccountSignInWorkspace.swift */; }; @@ -1253,6 +1254,7 @@ C0DE31410000000000000103 /* DebugEventLogSerializedAppendTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE31410000000000000104 /* DebugEventLogSerializedAppendTests.swift */; }; A500D010A1B2C3D4E5F60718 /* DebugLogging.swift in Sources */ = {isa = PBXBuildFile; fileRef = A500D011A1B2C3D4E5F60718 /* DebugLogging.swift */; }; 917300000000000000000001 /* DeferredActionReplacementStackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 917300000000000000000002 /* DeferredActionReplacementStackTests.swift */; }; + A54730000000000000000006 /* DeferredAgentResumeIndexFallbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54730000000000000000005 /* DeferredAgentResumeIndexFallbackTests.swift */; }; D10786DB0000000000000001 /* DeferredBrowserPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10786DB0000000000000002 /* DeferredBrowserPanel.swift */; }; D10786DB0000000000000003 /* DeferredBrowserPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10786DB0000000000000004 /* DeferredBrowserPanelView.swift */; }; F87910140000000000000001 /* DeferredWorkspaceTerminalFontSizeCoordinatorJoin.swift in Sources */ = {isa = PBXBuildFile; fileRef = F87910140000000000000002 /* DeferredWorkspaceTerminalFontSizeCoordinatorJoin.swift */; }; @@ -2013,9 +2015,6 @@ 8672F0018672F0018672F001 /* PiFeedOwnershipTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8672F0028672F0028672F002 /* PiFeedOwnershipTests.swift */; }; 8672F0068672F0068672F006 /* PiFeedV2CallResultBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8672F0058672F0058672F005 /* PiFeedV2CallResultBox.swift */; }; B3575000000000000000000A /* PiVaultAgentPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B35750000000000000000009 /* PiVaultAgentPersistenceTests.swift */; }; - A54730000000000000000002 /* AntigravityHookSessionSnapshotRestoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54730000000000000000001 /* AntigravityHookSessionSnapshotRestoreTests.swift */; }; - A54730000000000000000004 /* RestoredStartupInputResendTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54730000000000000000003 /* RestoredStartupInputResendTests.swift */; }; - A54730000000000000000006 /* DeferredAgentResumeIndexFallbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54730000000000000000005 /* DeferredAgentResumeIndexFallbackTests.swift */; }; 5B0C00010000000000000005 /* PortalDividerCursorOcclusionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B0C00010000000000000006 /* PortalDividerCursorOcclusionTests.swift */; }; B1D5CEE0407545B6B57E3B0E /* PortalHitTestingPerformanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4335E241A33344C48361AD51 /* PortalHitTestingPerformanceTests.swift */; }; D0C658660000000000000004 /* PortalSplitDividerCacheInvalidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C658660000000000000003 /* PortalSplitDividerCacheInvalidator.swift */; }; @@ -2249,6 +2248,7 @@ F54100B0A1B2C3D4E5F60718 /* RestorableCodexForkTagTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F54100B1A1B2C3D4E5F60718 /* RestorableCodexForkTagTests.swift */; }; C7C5FAF3145342F395DB4C40 /* RestoredAgentCompletedGeneration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9864510B84F2428BB83568CF /* RestoredAgentCompletedGeneration.swift */; }; C7C5FAF2145342F395DB4C40 /* RestoredAgentLifecycleCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9864510A84F2428BB83568CF /* RestoredAgentLifecycleCoordinator.swift */; }; + A54730000000000000000004 /* RestoredStartupInputResendTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54730000000000000000003 /* RestoredStartupInputResendTests.swift */; }; 9200B0019200B0019200B001 /* ResumeLauncherCwdConsistencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9200B0019200B0019200B002 /* ResumeLauncherCwdConsistencyTests.swift */; }; B7F9A600B7F9A600B7F9A600 /* RightSidebarChromeGeometryReporting.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7F9A601B7F9A601B7F9A601 /* RightSidebarChromeGeometryReporting.swift */; }; AA1B2C3D4E5F60720 /* RightSidebarChromeHeightUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1B2C3D4E5F60721 /* RightSidebarChromeHeightUITests.swift */; }; @@ -3663,6 +3663,7 @@ REE0CA0000000000000000E1 /* AIAccountsClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIAccountsClient.swift; sourceTree = ""; }; A115C0DE0000000000000001 /* AllShortcutsPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllShortcutsPopover.swift; sourceTree = ""; }; 9758A0000000000000000001 /* AmpVaultRegistrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AmpVaultRegistrationTests.swift; sourceTree = ""; }; + A54730000000000000000001 /* AntigravityHookSessionSnapshotRestoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AntigravityHookSessionSnapshotRestoreTests.swift; sourceTree = ""; }; F4350A130000000000000001 /* AppBundleIconPersistencePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/AppBundleIconPersistencePolicy.swift; sourceTree = ""; }; A5C01700000000000000000E /* AppDelegate+AccountSignInWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+AccountSignInWorkspace.swift"; sourceTree = ""; }; 80410000000000000000000A /* AppDelegate+AdjacentNavigationShortcut.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+AdjacentNavigationShortcut.swift"; sourceTree = ""; }; @@ -4622,6 +4623,7 @@ C0DE31410000000000000104 /* DebugEventLogSerializedAppendTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugEventLogSerializedAppendTests.swift; sourceTree = ""; }; A500D011A1B2C3D4E5F60718 /* DebugLogging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/DebugLogging.swift; sourceTree = ""; }; 917300000000000000000002 /* DeferredActionReplacementStackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeferredActionReplacementStackTests.swift; sourceTree = ""; }; + A54730000000000000000005 /* DeferredAgentResumeIndexFallbackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeferredAgentResumeIndexFallbackTests.swift; sourceTree = ""; }; D10786DB0000000000000002 /* DeferredBrowserPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/DeferredBrowserPanel.swift; sourceTree = ""; }; D10786DB0000000000000004 /* DeferredBrowserPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/DeferredBrowserPanelView.swift; sourceTree = ""; }; F87910140000000000000002 /* DeferredWorkspaceTerminalFontSizeCoordinatorJoin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeferredWorkspaceTerminalFontSizeCoordinatorJoin.swift; sourceTree = ""; }; @@ -5373,9 +5375,6 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 8672F0028672F0028672F002 /* PiFeedOwnershipTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiFeedOwnershipTests.swift; sourceTree = ""; }; 8672F0058672F0058672F005 /* PiFeedV2CallResultBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiFeedV2CallResultBox.swift; sourceTree = ""; }; B35750000000000000000009 /* PiVaultAgentPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiVaultAgentPersistenceTests.swift; sourceTree = ""; }; - A54730000000000000000001 /* AntigravityHookSessionSnapshotRestoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AntigravityHookSessionSnapshotRestoreTests.swift; sourceTree = ""; }; - A54730000000000000000003 /* RestoredStartupInputResendTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoredStartupInputResendTests.swift; sourceTree = ""; }; - A54730000000000000000005 /* DeferredAgentResumeIndexFallbackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeferredAgentResumeIndexFallbackTests.swift; sourceTree = ""; }; 5B0C00010000000000000006 /* PortalDividerCursorOcclusionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortalDividerCursorOcclusionTests.swift; sourceTree = ""; }; 4335E241A33344C48361AD51 /* PortalHitTestingPerformanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortalHitTestingPerformanceTests.swift; sourceTree = ""; }; D0C658660000000000000003 /* PortalSplitDividerCacheInvalidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortalSplitDividerCacheInvalidator.swift; sourceTree = ""; }; @@ -5605,6 +5604,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef F54100B1A1B2C3D4E5F60718 /* RestorableCodexForkTagTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorableCodexForkTagTests.swift; sourceTree = ""; }; 9864510B84F2428BB83568CF /* RestoredAgentCompletedGeneration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoredAgentCompletedGeneration.swift; sourceTree = ""; }; 9864510A84F2428BB83568CF /* RestoredAgentLifecycleCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoredAgentLifecycleCoordinator.swift; sourceTree = ""; }; + A54730000000000000000003 /* RestoredStartupInputResendTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoredStartupInputResendTests.swift; sourceTree = ""; }; 9200B0019200B0019200B002 /* ResumeLauncherCwdConsistencyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResumeLauncherCwdConsistencyTests.swift; sourceTree = ""; }; B7F9A601B7F9A601B7F9A601 /* RightSidebarChromeGeometryReporting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarChromeGeometryReporting.swift; sourceTree = ""; }; AA1B2C3D4E5F60721 /* RightSidebarChromeHeightUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarChromeHeightUITests.swift; sourceTree = ""; }; @@ -13300,6 +13300,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef A9E030000000000000000002 /* AgentSessionSocketSurfaceTests.swift in Sources */, A9E050000000000000000002 /* AgentSessionWebRendererTests.swift in Sources */, 9758A0000000000000000002 /* AmpVaultRegistrationTests.swift in Sources */, + A54730000000000000000002 /* AntigravityHookSessionSnapshotRestoreTests.swift in Sources */, 725746692D9647948561044D /* AppDelegateBareSpaceShortcutRoutingTests.swift in Sources */, A1B2C3D4E5F600000000CF01 /* AppDelegateDisplayConfigRestoreTests.swift in Sources */, E3309A09 /* AppDelegateEqualizeSplitsShortcutTests.swift in Sources */, @@ -13551,6 +13552,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef DEBDADADADADADADAD000003 /* DebugDogfoodCredentialResolverTests.swift in Sources */, C0DE31410000000000000103 /* DebugEventLogSerializedAppendTests.swift in Sources */, 917300000000000000000001 /* DeferredActionReplacementStackTests.swift in Sources */, + A54730000000000000000006 /* DeferredAgentResumeIndexFallbackTests.swift in Sources */, C0DE75890000000000000003 /* DeflatedAssetTestSupport.swift in Sources */, B0555303B0555303B0555303 /* DetachedFolderPathLookupCacheTests.swift in Sources */, DE71CE000000000000000002 /* DeviceRegistryClientTests.swift in Sources */, @@ -13747,9 +13749,6 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 8672F0018672F0018672F001 /* PiFeedOwnershipTests.swift in Sources */, 8672F0068672F0068672F006 /* PiFeedV2CallResultBox.swift in Sources */, B3575000000000000000000A /* PiVaultAgentPersistenceTests.swift in Sources */, - A54730000000000000000002 /* AntigravityHookSessionSnapshotRestoreTests.swift in Sources */, - A54730000000000000000004 /* RestoredStartupInputResendTests.swift in Sources */, - A54730000000000000000006 /* DeferredAgentResumeIndexFallbackTests.swift in Sources */, 5B0C00010000000000000005 /* PortalDividerCursorOcclusionTests.swift in Sources */, B1D5CEE0407545B6B57E3B0E /* PortalHitTestingPerformanceTests.swift in Sources */, D0B10008A1B2C3D4E5F60001 /* PortalTabDragRoutingTests.swift in Sources */, @@ -13832,6 +13831,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef F5410006A1B2C3D4E5F60718 /* RestorableAgentSessionIndexTests.swift in Sources */, F54100A0A1B2C3D4E5F60718 /* RestorableAgentSessionStalePIDTests.swift in Sources */, F54100B0A1B2C3D4E5F60718 /* RestorableCodexForkTagTests.swift in Sources */, + A54730000000000000000004 /* RestoredStartupInputResendTests.swift in Sources */, 9200B0019200B0019200B001 /* ResumeLauncherCwdConsistencyTests.swift in Sources */, C3408A000000000000000003 /* RightSidebarCommandPaletteTests.swift in Sources */, C57570010000000000000002 /* RightSidebarPanelViewTestSupport.swift in Sources */, From 91271efb02f4bf15d34ffe6a18543540bc5b4409 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:58:21 -0700 Subject: [PATCH 25/34] fix: restore semantic hook journal delivery --- CLI/CMUXCLI+ClaudeNodeOptions.swift | 30 +++++++++ CLI/cmux.swift | 99 +++++++++++++++++++---------- 2 files changed, 94 insertions(+), 35 deletions(-) diff --git a/CLI/CMUXCLI+ClaudeNodeOptions.swift b/CLI/CMUXCLI+ClaudeNodeOptions.swift index 9819a59cd3c1..9cc0e4d5b232 100644 --- a/CLI/CMUXCLI+ClaudeNodeOptions.swift +++ b/CLI/CMUXCLI+ClaudeNodeOptions.swift @@ -231,6 +231,36 @@ extension CMUXCLI { return "\(requireOption) \(memoryOption) \(cleanedExisting)" } + func configureClaudeNodeOptionsEnvironment( + processEnvironment: [String: String], + restoreModuleURL: URL + ) { + switch processEnvironment["CMUX_ORIGINAL_NODE_OPTIONS_PRESENT"] { + case "1": + setenv("CMUX_ORIGINAL_NODE_OPTIONS_PRESENT", "1", 1) + setenv("CMUX_ORIGINAL_NODE_OPTIONS", processEnvironment["CMUX_ORIGINAL_NODE_OPTIONS"] ?? "", 1) + case "0": + setenv("CMUX_ORIGINAL_NODE_OPTIONS_PRESENT", "0", 1) + unsetenv("CMUX_ORIGINAL_NODE_OPTIONS") + default: + if let existing = processEnvironment["NODE_OPTIONS"] { + setenv("CMUX_ORIGINAL_NODE_OPTIONS_PRESENT", "1", 1) + setenv("CMUX_ORIGINAL_NODE_OPTIONS", normalizedNodeOptionsForRestore(existing), 1) + } else { + setenv("CMUX_ORIGINAL_NODE_OPTIONS_PRESENT", "0", 1) + unsetenv("CMUX_ORIGINAL_NODE_OPTIONS") + } + } + setenv( + "NODE_OPTIONS", + mergedNodeOptions( + existing: processEnvironment["NODE_OPTIONS"], + restoreModulePath: restoreModuleURL.path + ), + 1 + ) + } + private func cleanedNodeOptions(_ existing: String?) -> String { guard let tokens = Self.nodeOptionsTokens(existing ?? "") else { return existing ?? "" } guard !tokens.isEmpty else { return "" } diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 991232939ef9..ebbd4ff85199 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -23868,20 +23868,9 @@ struct CMUXCLI { unsetenv("CMUX_ORIGINAL_NODE_OPTIONS") return } - if let existing = processEnvironment["NODE_OPTIONS"] { - setenv("CMUX_ORIGINAL_NODE_OPTIONS_PRESENT", "1", 1) - setenv("CMUX_ORIGINAL_NODE_OPTIONS", normalizedNodeOptionsForRestore(existing), 1) - } else { - setenv("CMUX_ORIGINAL_NODE_OPTIONS_PRESENT", "0", 1) - unsetenv("CMUX_ORIGINAL_NODE_OPTIONS") - } - setenv( - "NODE_OPTIONS", - mergedNodeOptions( - existing: processEnvironment["NODE_OPTIONS"], - restoreModulePath: restoreModuleURL.path - ), - 1 + configureClaudeNodeOptionsEnvironment( + processEnvironment: processEnvironment, + restoreModuleURL: restoreModuleURL ) } @@ -26382,20 +26371,9 @@ struct CMUXCLI { unsetenv("CMUX_ORIGINAL_NODE_OPTIONS") return } - if let existing = processEnvironment["NODE_OPTIONS"] { - setenv("CMUX_ORIGINAL_NODE_OPTIONS_PRESENT", "1", 1) - setenv("CMUX_ORIGINAL_NODE_OPTIONS", normalizedNodeOptionsForRestore(existing), 1) - } else { - setenv("CMUX_ORIGINAL_NODE_OPTIONS_PRESENT", "0", 1) - unsetenv("CMUX_ORIGINAL_NODE_OPTIONS") - } - setenv( - "NODE_OPTIONS", - mergedNodeOptions( - existing: processEnvironment["NODE_OPTIONS"], - restoreModulePath: restoreModuleURL.path - ), - 1 + configureClaudeNodeOptionsEnvironment( + processEnvironment: processEnvironment, + restoreModuleURL: restoreModuleURL ) } @@ -28528,7 +28506,7 @@ struct CMUXCLI { // `errorStalled` context and silently lose the sound override. let payload = notificationPayload( title: title, - subtitle: summary.subtitle, + subtitle: localizedClaudeNotificationSubtitle(summary.subtitle), body: summary.body, meta: Self.agentNotificationMeta( category: notifyCategory, @@ -28922,7 +28900,8 @@ struct CMUXCLI { ) _ = try? sendV1Command(try semanticNotificationCommand(source: "claude", agentKey: Self.claudeCodeStatusKey, sessionId: parsedInput.sessionId, workspaceId: workspaceId, surfaceId: existingSurfaceId, - kind: .approvalRequested, rawObject: parsedInput.rawObject, payload: payload), client: client) + kind: toolName == "AskUserQuestion" ? .questionRequested : .planReviewRequested, + rawObject: parsedInput.rawObject, payload: payload), client: client) } printClaudeHookAck() return @@ -28940,10 +28919,9 @@ struct CMUXCLI { updateLastSummary: true ) } - _ = try? sendV1Command("clear_notifications --tab=\(workspaceId)\(socketPanelOption(surfaceId))", client: client) emitAgentJournalEvent( client: client, - kind: .turnStarted, + kind: .stateChanged, source: "claude", agentKey: Self.claudeCodeStatusKey, sessionId: parsedInput.sessionId, @@ -28951,6 +28929,7 @@ struct CMUXCLI { surfaceId: surfaceId, isSubagent: isNestedAgentSession, nativeEvent: reportedHookEventName(from: parsedInput) ?? "PreToolUse", + declaredPhase: .running, attention: Self.semanticAttentionContext(parsedInput.rawObject), occurredAtMs: Self.semanticOccurredAtMs(parsedInput.rawObject), store: sessionStore, @@ -39214,8 +39193,52 @@ export default CMUXSessionRestore; agentID: source, includeAgentContext: true ) else { return } + let evidence = Self.semanticAttentionContext(eventDict) + if classification.clearsNativeApprovalPrompt { + guard evidence.requestIdentity != nil, + let workspaceID = liveTarget?.workspaceId ?? ambientWorkspaceId, + let surfaceID = liveTarget?.surfaceId ?? ambientSurfaceId else { return } + emitAgentJournalEvent( + client: activeClient, + kind: .attentionResolved, + source: source, + agentKey: Self.agentDef(named: source)?.statusKey ?? source, + sessionId: FeedWorkstreamIdentifier( + rawValue: eventDict["session_id"] as? String ?? "" + )?.sessionID, + workspaceId: workspaceID, + surfaceId: surfaceID, + nativeEvent: eventDict["hook_event_name"] as? String, + declaredPhase: .running, + attention: evidence, + occurredAtMs: Self.semanticOccurredAtMs(eventDict), + responseTimeout: remainingBudget(), + deadline: deadline + ) + return + } + let command: String + if classification.notifiesNativeApprovalPrompt { + let fields = attentionLine.split(separator: " ", maxSplits: 3).map(String.init) + guard fields.count == 4, + let candidate = try? semanticNotificationCommand( + source: source, + agentKey: Self.agentDef(named: source)?.statusKey ?? source, + sessionId: FeedWorkstreamIdentifier( + rawValue: eventDict["session_id"] as? String ?? "" + )?.sessionID, + workspaceId: fields[1], + surfaceId: fields[2], + kind: .approvalRequested, + rawObject: eventDict, + payload: fields[3] + ) else { return } + command = candidate + } else { + command = attentionLine + } _ = try? activeClient.send( - command: attentionLine, + command: command, responseTimeout: remainingBudget(), deadline: deadline ) @@ -39632,10 +39655,16 @@ export default CMUXSessionRestore; hookEventName: hookEventName, promptText: promptText ) + let causalEvidence = Self.semanticAttentionContext(stdinObj) let requestId = stdinObj["_opencode_request_id"] as? String - ?? firstString(in: stdinObj, keys: ["request_id", "tool_use_id", "toolUseID"]) - ?? "\(source)-\(sessionId)-\(rawEvent)-\(toolName)-\(Int(Date().timeIntervalSince1970 * 1000))" + ?? causalEvidence.requestIdentity.map { "\(workstreamID):\(Data($0.utf8).base64EncodedString())" } + ?? UUID().uuidString eventDict["_opencode_request_id"] = requestId + if let value = causalEvidence.eventIdentity { eventDict["event_id"] = value } + if let value = causalEvidence.turnIdentity { eventDict["turn_id"] = value } + if let value = causalEvidence.requestIdentity { eventDict["request_id"] = value } + if let value = Self.semanticOccurredAtMs(stdinObj) { eventDict["occurred_at_ms"] = value } + if let value = firstString(in: stdinObj, keys: ["agent_id", "agentId"]) { eventDict["agent_id"] = value } // Sync. For actionable events we wait for the user's Feed click; // the hook's stdout is then a From fa993cfe2124175b7e0520af21777e8e2b0b4b6f Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:23:48 -0700 Subject: [PATCH 26/34] fix: preserve approval clears without request identity --- CLI/cmux.swift | 11 ++- .../CLINotificationPaneAttributionTests.swift | 72 +++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index ebbd4ff85199..6eeba861ff86 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -39195,9 +39195,16 @@ export default CMUXSessionRestore; ) else { return } let evidence = Self.semanticAttentionContext(eventDict) if classification.clearsNativeApprovalPrompt { - guard evidence.requestIdentity != nil, - let workspaceID = liveTarget?.workspaceId ?? ambientWorkspaceId, + guard let workspaceID = liveTarget?.workspaceId ?? ambientWorkspaceId, let surfaceID = liveTarget?.surfaceId ?? ambientSurfaceId else { return } + guard evidence.requestIdentity != nil else { + _ = try? activeClient.send( + command: attentionLine, + responseTimeout: remainingBudget(), + deadline: deadline + ) + return + } emitAgentJournalEvent( client: activeClient, kind: .attentionResolved, diff --git a/cmuxTests/CLINotificationPaneAttributionTests.swift b/cmuxTests/CLINotificationPaneAttributionTests.swift index 1e3f25cde520..2940d3d4486c 100644 --- a/cmuxTests/CLINotificationPaneAttributionTests.swift +++ b/cmuxTests/CLINotificationPaneAttributionTests.swift @@ -6,6 +6,78 @@ import Foundation // harness (`CLINotifyProcessIntegrationRegressionTests`), whose process runner // and mock server lifecycle are shared with the surrounding integration suite. extension CLINotifyProcessIntegrationRegressionTests { + func testCodexPostToolUseWithoutRequestIdentityFallsBackToPaneClear() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("codex-post-tool-clear-fallback") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + let workspaceId = "11111111-1111-1111-1111-111111111111" + let surfaceId = "22222222-2222-2222-2222-222222222222" + + defer { + Darwin.close(listenerFD) + unlink(socketPath) + } + + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + guard let payload = self.jsonObject(line) else { + return "OK" + } + guard let id = payload["id"] as? String, + let method = payload["method"] as? String else { + return self.malformedRequestResponse(id: payload["id"] as? String, raw: line) + } + switch method { + case "agent.resolve_delivery_target": + return self.v2Response( + id: id, + ok: false, + error: ["code": "method_not_found", "message": "Legacy app without live resolver"] + ) + case "system.top": + return self.v2Response(id: id, ok: true, result: ["windows": []]) + case "workspace.current", "feed.push": + return self.v2Response(id: id, ok: true, result: ["workspace_id": workspaceId]) + case "surface.list": + return self.v2Response( + id: id, + ok: true, + result: ["surfaces": [["id": surfaceId, "ref": "surface:1", "focused": true]]] + ) + case "agent_journal_append": + return "OK 1" + default: + return self.v2Response( + id: id, + ok: false, + error: ["code": "unrecognized_method", "message": "unexpected method: \(method)"] + ) + } + } + + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_WORKSPACE_ID"] = workspaceId + environment["CMUX_SURFACE_ID"] = surfaceId + + let result = runProcess( + executablePath: cliPath, + arguments: ["hooks", "codex", "post-tool-use"], + environment: environment, + standardInput: #"{"session_id":"codex-post-tool-clear-fallback","hook_event_name":"PostToolUse","tool_name":"shell"}"#, + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertEqual(result.stdout, "{}\n") + wait(for: [serverHandled], timeout: 5) + XCTAssertTrue( + state.commands.contains("clear_notifications --tab=\(workspaceId) --panel=\(surfaceId)"), + "missing pane-scoped fallback clear: \(state.commands)" + ) + } + /// Regression for https://github.com/manaflow-ai/cmux/issues/11189: when /// the ambient surface is stale and the live resolver cannot prove a pane, /// a generic hook must no-op instead of using the workspace's focused tab. From 40877a2ac078b3187d560abfacec19bba05d1540 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:34:07 -0700 Subject: [PATCH 27/34] fix: harden Claude hook fallback paths --- CLI/CMUXCLI+ClaudeNodeOptions.swift | 38 +++++++++++++++++++++++++++-- CLI/cmux.swift | 2 +- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/CLI/CMUXCLI+ClaudeNodeOptions.swift b/CLI/CMUXCLI+ClaudeNodeOptions.swift index 9cc0e4d5b232..4dc478da28ee 100644 --- a/CLI/CMUXCLI+ClaudeNodeOptions.swift +++ b/CLI/CMUXCLI+ClaudeNodeOptions.swift @@ -244,8 +244,14 @@ extension CMUXCLI { unsetenv("CMUX_ORIGINAL_NODE_OPTIONS") default: if let existing = processEnvironment["NODE_OPTIONS"] { - setenv("CMUX_ORIGINAL_NODE_OPTIONS_PRESENT", "1", 1) - setenv("CMUX_ORIGINAL_NODE_OPTIONS", normalizedNodeOptionsForRestore(existing), 1) + let normalized = normalizedNodeOptionsForRestore(existing) + if !normalized.isEmpty || !nodeOptionsContainOnlyManagedRestoreModules(existing) { + setenv("CMUX_ORIGINAL_NODE_OPTIONS_PRESENT", "1", 1) + setenv("CMUX_ORIGINAL_NODE_OPTIONS", normalized, 1) + } else { + setenv("CMUX_ORIGINAL_NODE_OPTIONS_PRESENT", "0", 1) + unsetenv("CMUX_ORIGINAL_NODE_OPTIONS") + } } else { setenv("CMUX_ORIGINAL_NODE_OPTIONS_PRESENT", "0", 1) unsetenv("CMUX_ORIGINAL_NODE_OPTIONS") @@ -297,6 +303,34 @@ extension CMUXCLI { return filtered.joined(separator: " ") } + private func nodeOptionsContainOnlyManagedRestoreModules(_ existing: String) -> Bool { + guard let tokens = Self.nodeOptionsTokens(existing), !tokens.isEmpty else { return false } + var foundRestoreModule = false + var index = 0 + while index < tokens.count { + let token = tokens[index] + if token == "--require" || token == "-r" { + guard index + 1 < tokens.count, + isClaudeNodeOptionsRestorePath(Self.unquoteNodeOptionsPath(tokens[index + 1])) else { + return false + } + foundRestoreModule = true + index += 2 + continue + } + if token.hasPrefix("--require=") || token.hasPrefix("-r=") { + let separator = token.hasPrefix("--require=") ? "--require=" : "-r=" + let path = Self.unquoteNodeOptionsPath(String(token.dropFirst(separator.count))) + guard isClaudeNodeOptionsRestorePath(path) else { return false } + foundRestoreModule = true + index += 1 + continue + } + return false + } + return foundRestoreModule + } + func normalizedNodeOptionsForRestore(_ existing: String) -> String { guard let tokens = Self.nodeOptionsTokens(existing) else { return existing } guard !tokens.isEmpty else { return "" } diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 6eeba861ff86..8485b4461624 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -28269,7 +28269,7 @@ struct CMUXCLI { ?? mappedSession?.lastPermissionMode ) } - _ = try sendV1Command("clear_notifications --tab=\(workspaceId)\(socketPanelOption(surfaceId))", client: client) + _ = try? sendV1Command("clear_notifications --tab=\(workspaceId)\(socketPanelOption(surfaceId))", client: client) emitAgentJournalEvent( client: client, kind: .turnStarted, From 201268ee8fb0a193043a03860ee2777ac5807c26 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:07:55 -0700 Subject: [PATCH 28/34] ci: rerun required checks after hosted runner flakes From fc9fc6ed8ab322e1a14ea56b70b253b04d637182 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:24:34 -0700 Subject: [PATCH 29/34] ci: grandfather pre-existing determinism findings --- .github/test-determinism-allowlist.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/test-determinism-allowlist.txt b/.github/test-determinism-allowlist.txt index 7f80ed7eb828..d6786adc9bf0 100644 --- a/.github/test-determinism-allowlist.txt +++ b/.github/test-determinism-allowlist.txt @@ -2,6 +2,8 @@ # Format: relpathruleshort reason # A finding whose (path, rule) appears here is suppressed. # Remove a line once the underlying test is determinized. +Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxRetryAfterPolicyTests.swift sleep-then-assert pre-existing on origin/main in #12179; injected policy sleep, remove when determinism checker recognizes it +Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTerminalLaneCoordinatorTests.swift sleep-then-assert pre-existing on origin/main in #12007; remove when determinized Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCTransportDrainTests.swift sleep-then-assert temporary outage debt owned by #8931; remove when determinized Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/OnboardingMacDiscoveryKeepAliveTests.swift sleep-then-assert temporary outage debt owned by #9163; remove when determinized cmuxTests/WorkspaceForkConversationContextMenuTests.swift assert-on-duration grandfathered From a6fc232792ac400119ac19a4174996568a34589d Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:51:54 -0700 Subject: [PATCH 30/34] ci: install Node for semantic integration tests --- .github/workflows/test-depot.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/test-depot.yml b/.github/workflows/test-depot.yml index dd7ecbb7c163..e9de3e11dd99 100644 --- a/.github/workflows/test-depot.yml +++ b/.github/workflows/test-depot.yml @@ -78,6 +78,11 @@ jobs: set -euo pipefail ./scripts/select-ci-xcode.sh + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "20" + - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 From cfb482644c7307fee400bb8a2d37642abd9617db Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:18:03 -0700 Subject: [PATCH 31/34] test: pass explicit Node path to Xcode harness --- .github/workflows/test-depot.yml | 3 +++ cmuxTests/OpenCodeHookRegressionTests.swift | 5 +++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-depot.yml b/.github/workflows/test-depot.yml index e9de3e11dd99..6e840bfc0efb 100644 --- a/.github/workflows/test-depot.yml +++ b/.github/workflows/test-depot.yml @@ -83,6 +83,9 @@ jobs: with: node-version: "20" + - name: Export Node path for Xcode test bundles + run: echo "CMUX_NODE_BINARY=$(command -v node)" >> "$GITHUB_ENV" + - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 diff --git a/cmuxTests/OpenCodeHookRegressionTests.swift b/cmuxTests/OpenCodeHookRegressionTests.swift index 6f3f755284d6..2ad8815e75a5 100644 --- a/cmuxTests/OpenCodeHookRegressionTests.swift +++ b/cmuxTests/OpenCodeHookRegressionTests.swift @@ -27,9 +27,10 @@ final class OpenCodeHookRegressionTests: XCTestCase { let harnessURL = root.appendingPathComponent("harness.js") try Self.openCodeFeedEventHarness.write(to: harnessURL, atomically: true, encoding: .utf8) + let nodeExecutable = ProcessInfo.processInfo.environment["CMUX_NODE_BINARY"] ?? "node" let result = runProcess( - executablePath: "/usr/bin/env", - arguments: ["node", harnessURL.path, pluginURL.path, socketPath], + executablePath: nodeExecutable, + arguments: [harnessURL.path, pluginURL.path, socketPath], environment: ProcessInfo.processInfo.environment, timeout: 5 ) From e177006ab996fcbc84698d0338f2cfaff409fb4e Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:40:20 -0700 Subject: [PATCH 32/34] ci: forward Node path to Xcode test runner --- .github/workflows/test-depot.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-depot.yml b/.github/workflows/test-depot.yml index 6e840bfc0efb..630711ed245f 100644 --- a/.github/workflows/test-depot.yml +++ b/.github/workflows/test-depot.yml @@ -83,8 +83,8 @@ jobs: with: node-version: "20" - - name: Export Node path for Xcode test bundles - run: echo "CMUX_NODE_BINARY=$(command -v node)" >> "$GITHUB_ENV" + - name: Export Node path for Xcode test runner + run: echo "TEST_RUNNER_CMUX_NODE_BINARY=$(command -v node)" >> "$GITHUB_ENV" - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 From 0c36a751f2fd2b7c3513ea73e06e1c604f5e9c7c Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:08:36 -0700 Subject: [PATCH 33/34] test: keep OpenCode socket path short on macOS --- cmuxTests/OpenCodeHookRegressionTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmuxTests/OpenCodeHookRegressionTests.swift b/cmuxTests/OpenCodeHookRegressionTests.swift index 2ad8815e75a5..73d3365c47eb 100644 --- a/cmuxTests/OpenCodeHookRegressionTests.swift +++ b/cmuxTests/OpenCodeHookRegressionTests.swift @@ -17,7 +17,7 @@ final class OpenCodeHookRegressionTests: XCTestCase { let pluginURL = repoRoot.appendingPathComponent("Resources/opencode-plugin.js", isDirectory: false) XCTAssertTrue(fileManager.fileExists(atPath: pluginURL.path)) - let root = fileManager.temporaryDirectory.appendingPathComponent( + let root = URL(fileURLWithPath: "/tmp", isDirectory: true).appendingPathComponent( "cmux-opencode-feed-\(UUID().uuidString)", isDirectory: true ) try fileManager.createDirectory(at: root, withIntermediateDirectories: true) From 6820f8fa14d276d97bc6a478b2078b3be01e78cc Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:47:16 -0700 Subject: [PATCH 34/34] ci: install Node for app-host tests --- .github/workflows/ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8ce06a9c871..84839eb8a235 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -637,6 +637,14 @@ jobs: set -euo pipefail ./scripts/select-ci-xcode.sh + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "20" + + - name: Export Node path for Xcode test runner + run: echo "TEST_RUNNER_CMUX_NODE_BINARY=$(command -v node)" >> "$GITHUB_ENV" + - name: Capture Ghostty revision id: ghostty-revision run: |